From 229611b4c3eb7ecc4619dc5ac7d76bd55654c8b9 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Apr 2026 10:48:19 +0200 Subject: [PATCH] fix: face clusters write photo_tags, raise detection threshold to 0.7 - recluster_faces now writes photo_tags rows for each face cluster so the tag count and tag_ids filter work (previously count was always 0) - Old cluster tags and photo_tags are cleaned up before re-clustering - Raise face detection threshold from 0.4 to 0.7 to reduce false positives (was detecting dog faces as people) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/config.py | 2 +- backend/app/tasks/vision.py | 64 ++++++++++++++++++++++++++----------- mulita.yml | 2 +- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6ad7ae8..0128606 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -50,7 +50,7 @@ class FacesSettings(BaseModel): """YuNet + SFace face detection/recognition settings""" enabled: bool = True min_face_size: int = 40 - recognition_threshold: float = 0.4 + recognition_threshold: float = 0.7 cluster_eps: float = 0.35 class VisionSettings(BaseModel): diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index ac4df17..5ac29a6 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -294,9 +294,11 @@ def recluster_faces(): return {'status': 'skipped', 'reason': 'faces disabled'} from app.models.face_embedding import FaceEmbedding - from app.models.tags import Tag + from app.models.tags import Tag, photo_tags from app.services.vision.clustering import cluster_faces + source_name = "vision:sface" + session = _get_sync_session() try: face_rows = session.execute( @@ -310,37 +312,61 @@ def recluster_faces(): embeddings = np.array([f.vector for f in face_rows], dtype=np.float32) labels = cluster_faces(embeddings, eps=settings.vision.faces.cluster_eps) + # Clean up old face_cluster tags and their photo_tags + old_cluster_tags = session.execute( + select(Tag).where(Tag.kind == 'face_cluster', Tag.source == source_name) + ).scalars().all() + for old_tag in old_cluster_tags: + session.execute( + delete(photo_tags).where( + photo_tags.c.tag_id == old_tag.id, + photo_tags.c.source == source_name, + ) + ) + session.delete(old_tag) + session.flush() + + # Build new clusters cluster_tag_map: dict[int, str] = {} - source_name = "vision:sface" + # Track which photos belong to which cluster + cluster_photos: dict[int, set[str]] = {} for i, label in enumerate(labels): if label == -1: face_rows[i].cluster_id = None continue + if label not in cluster_photos: + cluster_photos[label] = set() + cluster_photos[label].add(face_rows[i].photo_id) + if label not in cluster_tag_map: cluster_name = f"Person {label + 1}" - tag = session.execute( - select(Tag).where( - Tag.kind == 'face_cluster', - Tag.source == source_name, - Tag.name == cluster_name, - ) - ).scalar_one_or_none() - - if not tag: - tag = Tag( - name=cluster_name, - kind='face_cluster', - source=source_name, - representative_photo_id=face_rows[i].photo_id, - ) - session.add(tag) - session.flush() + tag = Tag( + name=cluster_name, + kind='face_cluster', + source=source_name, + representative_photo_id=face_rows[i].photo_id, + ) + session.add(tag) + session.flush() cluster_tag_map[label] = tag.id face_rows[i].cluster_id = cluster_tag_map[label] + # Write photo_tags associations so the tag count and tag_ids + # filter work for face clusters + for label, photo_ids in cluster_photos.items(): + tag_id = cluster_tag_map[label] + for pid in photo_ids: + session.execute( + photo_tags.insert().values( + photo_id=pid, + tag_id=tag_id, + source=source_name, + ) + ) + session.commit() finally: session.close() diff --git a/mulita.yml b/mulita.yml index 265ad75..a5dcd80 100644 --- a/mulita.yml +++ b/mulita.yml @@ -44,6 +44,6 @@ vision: faces: enabled: true min_face_size: 40 - recognition_threshold: 0.4 + recognition_threshold: 0.7 cluster_eps: 0.35 worker_concurrency: 2