From fbeefb24a0fefaafb95b40425ab9c9259b7d3839 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Apr 2026 00:01:28 +0200 Subject: [PATCH] fix: vision tasks inherit user_id, admin owns mount root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detect_objects, classify_content, recluster_faces now look up the photo's user_id and set it on created Tag rows — fixes tags being invisible to the owning user due to NULL user_id - Initial admin setup creates source root at the mount root (/photos) instead of a subdirectory, since the admin owns the entire library - Revert to OpenCLIP ViT-B/32 (512-d) as default embedder — SigLIP requires transformers version alignment not yet available in the Docker image. SigLIP2 code remains for future enablement. - Add transformers to requirements for future SigLIP support Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/config.py | 2 +- backend/app/models/embeddings.py | 2 +- backend/app/routers/auth.py | 6 ++-- .../app/services/vision/bootstrap_models.py | 2 -- backend/app/services/vision/classify.py | 2 +- backend/app/services/vision/embed.py | 2 +- backend/app/services/vision/export_models.py | 6 ++-- backend/app/tasks/vision.py | 29 +++++++++++++++---- backend/requirements.txt | 1 + 9 files changed, 35 insertions(+), 17 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 0438e51..8a78858 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,7 +33,7 @@ class PerformanceSettings(BaseModel): class EmbedderSettings(BaseModel): """CLIP / SigLIP embedding model settings. Supported: "openclip_vitb32" (512-d), "siglip2_vitb16" (768-d, default).""" - name: str = "siglip2_vitb16" + name: str = "openclip_vitb32" batch_size: int = 8 class OCRSettings(BaseModel): diff --git a/backend/app/models/embeddings.py b/backend/app/models/embeddings.py index cf17aa0..0693e22 100644 --- a/backend/app/models/embeddings.py +++ b/backend/app/models/embeddings.py @@ -20,5 +20,5 @@ class Embedding(Base): photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True) model = Column(String(64), primary_key=True) # e.g. 'siglip2_vitb16' - vector = Column(Vector(768)) # SigLIP2 ViT-B/16 → 768-d + vector = Column(Vector(512)) # OpenCLIP ViT-B/32 → 512-d created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 86a60f0..91ac972 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -161,7 +161,9 @@ async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)): if len(body.password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") - media_path = os.path.join(settings.photo_dirs, body.username.strip()) + # The initial admin owns the entire photo mount root. Regular users + # (created later via admin panel) get a subdirectory under it. + media_path = settings.photo_dirs os.makedirs(media_path, exist_ok=True) user = User( @@ -171,8 +173,8 @@ async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)): media_path=media_path, ) db.add(user) + await db.flush() # get user.id before creating source root - # Create a source root for the new admin's media directory source_root = SourceRoot( name=f"{user.username}'s Library", path=media_path, diff --git a/backend/app/services/vision/bootstrap_models.py b/backend/app/services/vision/bootstrap_models.py index 427071e..61f9e85 100644 --- a/backend/app/services/vision/bootstrap_models.py +++ b/backend/app/services/vision/bootstrap_models.py @@ -29,8 +29,6 @@ DOWNLOADS = [] EXPORTS = [ ("embed/visual.onnx", "OpenCLIP ViT-B/32 visual encoder"), ("embed/textual.onnx", "OpenCLIP ViT-B/32 textual encoder"), - ("embed_siglip2/visual.onnx", "SigLIP2 ViT-B/16 visual encoder"), - ("embed_siglip2/textual.onnx", "SigLIP2 ViT-B/16 textual encoder"), ("detect/yolov8n.onnx", "YOLOv8n object detector"), ] diff --git a/backend/app/services/vision/classify.py b/backend/app/services/vision/classify.py index 4bb5a00..dcbcda7 100644 --- a/backend/app/services/vision/classify.py +++ b/backend/app/services/vision/classify.py @@ -63,7 +63,7 @@ class CLIPContentClassifier(ContentClassifier): # image embeddings. embedder_name = settings.embedder.name if embedder_name.startswith("siglip2"): - model_arch = "ViT-B-16-SigLIP2" + model_arch = "ViT-B-16-SigLIP-384" pretrained = "webli" else: model_arch = "ViT-B-32" diff --git a/backend/app/services/vision/embed.py b/backend/app/services/vision/embed.py index ae3cd03..6f42ba8 100644 --- a/backend/app/services/vision/embed.py +++ b/backend/app/services/vision/embed.py @@ -129,7 +129,7 @@ class SigLIP2Embedder(Embedder): def embed_text(self, text: str) -> np.ndarray: import open_clip - tokenizer = open_clip.get_tokenizer("ViT-B-16-SigLIP2") + tokenizer = open_clip.get_tokenizer("ViT-B-16-SigLIP-384") tokens = tokenizer([text]).numpy().astype(np.int64) inputs = self._textual.get_inputs() feed = {inputs[0].name: tokens} diff --git a/backend/app/services/vision/export_models.py b/backend/app/services/vision/export_models.py index 1818df3..e4822ae 100644 --- a/backend/app/services/vision/export_models.py +++ b/backend/app/services/vision/export_models.py @@ -135,9 +135,9 @@ def export_siglip2(models_dir: Path): logger.info("SigLIP2 ONNX files already exist, skipping export") return - logger.info("Loading SigLIP2 ViT-B-16-SigLIP2 webli...") + logger.info("Loading SigLIP2 ViT-B-16-SigLIP-384 webli...") model, _, preprocess = open_clip.create_model_and_transforms( - "ViT-B-16-SigLIP2", pretrained="webli" + "ViT-B-16-SigLIP-384", pretrained="webli" ) model.eval() @@ -162,7 +162,7 @@ def export_siglip2(models_dir: Path): # ── Textual encoder ─────────────────────────────────────────────── if not textual_path.exists(): logger.info("Exporting SigLIP2 textual encoder → %s", textual_path) - tokenizer = open_clip.get_tokenizer("ViT-B-16-SigLIP2") + tokenizer = open_clip.get_tokenizer("ViT-B-16-SigLIP-384") dummy_text = tokenizer(["a photo"]).to(torch.int64) class SigLIP2TextEncoder(torch.nn.Module): diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index c44a1de..f4005bf 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -163,6 +163,12 @@ def detect_objects(photo_id: str): session = _get_sync_session() try: + # Get the photo's user_id so tags inherit ownership. + photo = session.execute( + select(Photo).where(Photo.id == photo_id) + ).scalar_one_or_none() + owner_id = photo.user_id if photo else None + # Wipe previous detection results for this photo from this model session.execute( delete(photo_tags).where( @@ -178,13 +184,13 @@ def detect_objects(photo_id: str): best_per_label[det.label] = (det.confidence, det.bbox) for label, (confidence, bbox) in best_per_label.items(): - # Find or create the object tag + # Find or create the object tag (scoped to user) tag = session.execute( - select(Tag).where(Tag.name == label, Tag.kind == 'object') + select(Tag).where(Tag.name == label, Tag.kind == 'object', Tag.user_id == owner_id) ).scalar_one_or_none() if not tag: - tag = Tag(name=label, kind='object', source=source_name) + tag = Tag(name=label, kind='object', source=source_name, user_id=owner_id) session.add(tag) session.flush() # get tag.id @@ -234,6 +240,12 @@ def classify_content(photo_id: str): session = _get_sync_session() try: + # Get the photo's user_id so tags inherit ownership. + photo = session.execute( + select(Photo).where(Photo.id == photo_id) + ).scalar_one_or_none() + owner_id = photo.user_id if photo else None + # Wipe previous classification for this photo session.execute( delete(photo_tags).where( @@ -242,13 +254,13 @@ def classify_content(photo_id: str): ) ) - # Find or create content_type tag + # Find or create content_type tag (scoped to user) tag = session.execute( - select(Tag).where(Tag.name == best.label, Tag.kind == 'content_type') + select(Tag).where(Tag.name == best.label, Tag.kind == 'content_type', Tag.user_id == owner_id) ).scalar_one_or_none() if not tag: - tag = Tag(name=best.label, kind='content_type', source=source_name) + tag = Tag(name=best.label, kind='content_type', source=source_name, user_id=owner_id) session.add(tag) session.flush() @@ -434,11 +446,16 @@ def recluster_faces(): if label not in cluster_tag_map: cluster_name = f"Person {label + 1}" + # Inherit user_id from the representative photo. + rep_photo = session.execute( + select(Photo.user_id).where(Photo.id == face_rows[i].photo_id) + ).scalar_one_or_none() tag = Tag( name=cluster_name, kind='face_cluster', source=source_name, representative_photo_id=face_rows[i].photo_id, + user_id=rep_photo, ) session.add(tag) session.flush() diff --git a/backend/requirements.txt b/backend/requirements.txt index 66652ce..780e53b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -37,6 +37,7 @@ watchfiles==0.21.0 # Vision pipeline (ONNX Runtime CPU inference) onnxruntime==1.18.1 open-clip-torch==2.24.0 # tokenizer + export helper; inference via ONNX +transformers>=4.37.0 # HuggingFace tokenizer for SigLIP models ultralytics==8.4.37 # YOLOv8n export helper; inference via ONNX rapidocr-onnxruntime==1.3.22 scikit-learn==1.4.0 # DBSCAN for face clustering