diff --git a/backend/app/config.py b/backend/app/config.py index de4adcb..cc3b9ef 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.85 + recognition_threshold: float = 0.6 cluster_eps: float = 0.25 class VisionSettings(BaseModel): diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index 5ac29a6..4c30200 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -242,10 +242,32 @@ def _load_original(photo_id: str) -> np.ndarray | None: return None +def _iou(a: list[float], b: list[float]) -> float: + """Intersection-over-area of box a within box b (how much of a is inside b). + Boxes are [x1, y1, x2, y2] normalized 0-1.""" + x1 = max(a[0], b[0]) + y1 = max(a[1], b[1]) + x2 = min(a[2], b[2]) + y2 = min(a[3], b[3]) + inter = max(0, x2 - x1) * max(0, y2 - y1) + area_a = max(0, a[2] - a[0]) * max(0, a[3] - a[1]) + return inter / area_a if area_a > 0 else 0 + + +def _face_inside_person(face_bbox: list[float], person_bboxes: list[list[float]]) -> bool: + """Return True if the face bbox overlaps at least 50% with any YOLO + 'person' detection. Filters out faces on dogs, paintings, etc.""" + for pb in person_bboxes: + if _iou(face_bbox, pb) >= 0.5: + return True + return False + + @shared_task(name='extract_faces', queue='vision') def extract_faces(photo_id: str): - """Detect faces and store recognition embeddings. Clustering is - handled separately by recluster_faces (periodic task).""" + """Detect faces and store recognition embeddings. Only keeps faces + that overlap with a YOLO 'person' detection to filter out animal + and cartoon false positives.""" if not settings.vision.enabled or not settings.vision.faces.enabled: return {'status': 'skipped', 'reason': 'faces disabled'} @@ -257,14 +279,40 @@ def extract_faces(photo_id: str): if image is None: return {'status': 'error', 'message': 'no image available'} + # Step 1: run YOLO to find "person" bounding boxes from app.services.vision.registry import registry + detector = registry.get_detector() + thumb = _load_thumb(photo_id, "medium") + person_bboxes = [] + if thumb is not None: + detections = detector.detect(thumb) + person_bboxes = [d.bbox for d in detections if d.label == 'person'] + + # Step 2: run face detection face_proc = registry.get_face_processor() faces = face_proc.process(image) if not faces: logger.info("No faces detected for photo %s", photo_id) - return {'status': 'success', 'photo_id': photo_id, 'faces': 0} + return _save_faces(photo_id, []) + # Step 3: filter — keep only faces inside a person bbox + if person_bboxes: + verified = [f for f in faces if _face_inside_person(f.bbox, person_bboxes)] + dropped = len(faces) - len(verified) + if dropped: + logger.info("Dropped %d non-person face(s) for photo %s", dropped, photo_id) + faces = verified + else: + # No person detected by YOLO → drop all face detections + # (no human body visible = likely false positives) + logger.info("No YOLO person detected, dropping %d face(s) for photo %s", len(faces), photo_id) + faces = [] + + return _save_faces(photo_id, faces) + + +def _save_faces(photo_id: str, faces) -> dict: from app.models.face_embedding import FaceEmbedding session = _get_sync_session() @@ -282,7 +330,8 @@ def extract_faces(photo_id: str): finally: session.close() - logger.info("Extracted %d faces from photo %s", len(faces), photo_id) + if faces: + logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id) return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)} diff --git a/mulita.yml b/mulita.yml index dea7564..6f22fcf 100644 --- a/mulita.yml +++ b/mulita.yml @@ -44,6 +44,6 @@ vision: faces: enabled: true min_face_size: 40 - recognition_threshold: 0.85 + recognition_threshold: 0.6 cluster_eps: 0.25 worker_concurrency: 2