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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:48:19 +02:00
parent db20cbb7d8
commit 229611b4c3
3 changed files with 47 additions and 21 deletions

View File

@@ -50,7 +50,7 @@ class FacesSettings(BaseModel):
"""YuNet + SFace face detection/recognition settings""" """YuNet + SFace face detection/recognition settings"""
enabled: bool = True enabled: bool = True
min_face_size: int = 40 min_face_size: int = 40
recognition_threshold: float = 0.4 recognition_threshold: float = 0.7
cluster_eps: float = 0.35 cluster_eps: float = 0.35
class VisionSettings(BaseModel): class VisionSettings(BaseModel):

View File

@@ -294,9 +294,11 @@ def recluster_faces():
return {'status': 'skipped', 'reason': 'faces disabled'} return {'status': 'skipped', 'reason': 'faces disabled'}
from app.models.face_embedding import FaceEmbedding 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 from app.services.vision.clustering import cluster_faces
source_name = "vision:sface"
session = _get_sync_session() session = _get_sync_session()
try: try:
face_rows = session.execute( face_rows = session.execute(
@@ -310,37 +312,61 @@ def recluster_faces():
embeddings = np.array([f.vector for f in face_rows], dtype=np.float32) embeddings = np.array([f.vector for f in face_rows], dtype=np.float32)
labels = cluster_faces(embeddings, eps=settings.vision.faces.cluster_eps) 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] = {} 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): for i, label in enumerate(labels):
if label == -1: if label == -1:
face_rows[i].cluster_id = None face_rows[i].cluster_id = None
continue 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: if label not in cluster_tag_map:
cluster_name = f"Person {label + 1}" cluster_name = f"Person {label + 1}"
tag = session.execute( tag = Tag(
select(Tag).where( name=cluster_name,
Tag.kind == 'face_cluster', kind='face_cluster',
Tag.source == source_name, source=source_name,
Tag.name == cluster_name, representative_photo_id=face_rows[i].photo_id,
) )
).scalar_one_or_none() session.add(tag)
session.flush()
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()
cluster_tag_map[label] = tag.id cluster_tag_map[label] = tag.id
face_rows[i].cluster_id = cluster_tag_map[label] 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() session.commit()
finally: finally:
session.close() session.close()

View File

@@ -44,6 +44,6 @@ vision:
faces: faces:
enabled: true enabled: true
min_face_size: 40 min_face_size: 40
recognition_threshold: 0.4 recognition_threshold: 0.7
cluster_eps: 0.35 cluster_eps: 0.35
worker_concurrency: 2 worker_concurrency: 2