fix: verify faces against YOLO person detections for precision

Cross-reference face detections with YOLO 'person' bounding boxes —
only keep faces that overlap >= 50% with a detected human body. This
eliminates false positives on dogs, paintings, and cartoons without
needing an aggressive score threshold.

Lower face detection threshold back to 0.6 since the person-overlap
check is now the primary precision filter.

Tested: 6 verified faces from 4 photos, zero false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:13:01 +02:00
parent 40d570f2c2
commit f48e099bd2
3 changed files with 55 additions and 6 deletions

View File

@@ -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):

View File

@@ -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)}