Tanya a6ba78cd54 feat: add debug mode, distance-based thresholds, and improve pose detection
- Add debug mode support for encoding statistics in API responses
  - Debug info includes encoding length, min/max/mean/std, and first 10 values
  - Frontend logs encoding stats to browser console when debug enabled
  - Identify page enables debug mode by default

- Implement distance-based confidence thresholds for stricter matching
  - Borderline distances require higher confidence (70-95% vs 50%)
  - Applied when use_distance_based_thresholds=True (auto-match)
  - Reduces false positives for borderline matches

- Dual tolerance system for auto-match
  - Default tolerance 0.6 for regular browsing (more lenient)
  - Run auto-match button uses 0.5 tolerance with distance-based thresholds (stricter)
  - Auto-accept threshold updated to 85% (from 70%)

- Enhance pose detection with single-eye detection
  - Profile threshold reduced from 30° to 15° (stricter)
  - Detect single-eye visibility for extreme profile views
  - Infer profile direction from landmark visibility
  - Improved face width threshold (20px vs 10px)

- Clean up debug code
  - Remove test photo UUID checks from production code
  - Remove debug print statements
  - Replace print statements with proper logging
2026-02-10 13:20:07 -05:00

352 lines
10 KiB
Python

"""Face processing and identify workflow schemas."""
from __future__ import annotations
from datetime import date
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class ProcessFacesRequest(BaseModel):
"""Request to process faces in photos."""
model_config = ConfigDict(protected_namespaces=())
batch_size: Optional[int] = Field(
None,
ge=1,
description="Maximum number of photos to process (None = all unprocessed)",
)
detector_backend: str = Field(
"retinaface",
description="DeepFace detector backend (retinaface, mtcnn, opencv, ssd)",
)
model_name: str = Field(
"ArcFace",
description="DeepFace model name (ArcFace, Facenet, Facenet512, VGG-Face)",
)
class ProcessFacesResponse(BaseModel):
"""Response after initiating face processing."""
model_config = ConfigDict(protected_namespaces=())
job_id: str
message: str
batch_size: Optional[int] = None
detector_backend: str
model_name: str
class FaceItem(BaseModel):
"""Minimal face item for list views."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
quality_score: float
face_confidence: float
location: str
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
excluded: bool = Field(False, description="Whether this face is excluded from identification")
class UnidentifiedFacesQuery(BaseModel):
"""Query params for listing unidentified faces."""
model_config = ConfigDict(protected_namespaces=())
page: int = 1
page_size: int = 50
min_quality: float = 0.0
date_from: Optional[date] = None
date_to: Optional[date] = None
sort_by: str = Field("quality", description="quality|date_taken|date_added")
sort_dir: str = Field("desc", description="asc|desc")
class UnidentifiedFacesResponse(BaseModel):
"""Paginated unidentified faces list."""
model_config = ConfigDict(protected_namespaces=())
items: list[FaceItem]
page: int
page_size: int
total: int
class SimilarFaceItem(BaseModel):
"""Similar face with similarity score (0-1)."""
id: int
photo_id: int
similarity: float
location: str
quality_score: float
filename: str
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
debug_info: Optional[dict] = Field(None, description="Debug information (encoding stats) when debug mode is enabled")
class SimilarFacesResponse(BaseModel):
"""Response containing similar faces for a given face."""
model_config = ConfigDict(protected_namespaces=())
base_face_id: int
items: list[SimilarFaceItem]
debug_info: Optional[dict] = Field(None, description="Debug information (base face encoding stats) when debug mode is enabled")
class BatchSimilarityRequest(BaseModel):
"""Request to get similarities between multiple faces."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., description="List of face IDs to calculate similarities for")
min_confidence: float = Field(60.0, ge=0.0, le=100.0, description="Minimum confidence percentage (0-100)")
class FaceSimilarityPair(BaseModel):
"""A pair of similar faces with their similarity score."""
model_config = ConfigDict(protected_namespaces=())
face_id_1: int
face_id_2: int
similarity: float # 0-1 range
confidence_pct: float # 0-100 range
class BatchSimilarityResponse(BaseModel):
"""Response containing similarities between face pairs."""
model_config = ConfigDict(protected_namespaces=())
pairs: list[FaceSimilarityPair] = Field(..., description="List of similar face pairs")
class IdentifyFaceRequest(BaseModel):
"""Identify a face by selecting existing or creating new person."""
model_config = ConfigDict(protected_namespaces=())
# Either provide person_id or the fields to create new person
person_id: Optional[int] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
middle_name: Optional[str] = None
maiden_name: Optional[str] = None
date_of_birth: Optional[date] = None
# Optionally identify a batch of face IDs along with this one
additional_face_ids: Optional[list[int]] = None
class IdentifyFaceResponse(BaseModel):
"""Result of identify operation."""
model_config = ConfigDict(protected_namespaces=())
identified_face_ids: list[int]
person_id: int
created_person: bool
class FaceUnmatchResponse(BaseModel):
"""Result of unmatch operation."""
model_config = ConfigDict(protected_namespaces=())
face_id: int
message: str
class BatchUnmatchRequest(BaseModel):
"""Request to batch unmatch multiple faces."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., min_items=1)
class BatchUnmatchResponse(BaseModel):
"""Result of batch unmatch operation."""
model_config = ConfigDict(protected_namespaces=())
unmatched_face_ids: list[int]
count: int
message: str
class PersonFaceItem(BaseModel):
"""Face item for person's faces list (includes photo info)."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
photo_path: str
photo_filename: str
location: str
face_confidence: float
quality_score: float
detector_backend: str
model_name: str
class PersonFacesResponse(BaseModel):
"""Response containing all faces for a person."""
model_config = ConfigDict(protected_namespaces=())
person_id: int
items: list[PersonFaceItem]
total: int
class AutoMatchRequest(BaseModel):
"""Request to start auto-match process."""
model_config = ConfigDict(protected_namespaces=())
tolerance: float = Field(0.5, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
auto_accept: bool = Field(False, description="Enable automatic acceptance of matching faces")
auto_accept_threshold: float = Field(70.0, ge=0.0, le=100.0, description="Similarity threshold for auto-acceptance (0-100%)")
use_distance_based_thresholds: bool = Field(False, description="Use distance-based confidence thresholds (stricter for borderline distances)")
class AutoMatchFaceItem(BaseModel):
"""Unidentified face match for a person."""
model_config = ConfigDict(protected_namespaces=())
id: int
photo_id: int
photo_filename: str
location: str
quality_score: float
similarity: float # Confidence percentage (0-100)
distance: float
pose_mode: str = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
class AutoMatchPersonItem(BaseModel):
"""Person with matches for auto-match workflow."""
model_config = ConfigDict(protected_namespaces=())
person_id: int
person_name: str
reference_face_id: int
reference_photo_id: int
reference_photo_filename: str
reference_location: str
reference_pose_mode: str = Field("frontal", description="Reference face pose classification")
face_count: int # Number of faces already identified for this person
matches: list[AutoMatchFaceItem]
total_matches: int
class AutoMatchPersonSummary(BaseModel):
"""Person summary without matches (for fast initial load)."""
model_config = ConfigDict(protected_namespaces=())
person_id: int
person_name: str
reference_face_id: int
reference_photo_id: int
reference_photo_filename: str
reference_location: str
reference_pose_mode: str = Field("frontal", description="Reference face pose classification")
face_count: int # Number of faces already identified for this person
total_matches: int = Field(0, description="Total matches (loaded separately)")
class AutoMatchPeopleResponse(BaseModel):
"""Response containing people list without matches (for fast initial load)."""
model_config = ConfigDict(protected_namespaces=())
people: list[AutoMatchPersonSummary]
total_people: int
class AutoMatchPersonMatchesResponse(BaseModel):
"""Response containing matches for a specific person."""
model_config = ConfigDict(protected_namespaces=())
person_id: int
matches: list[AutoMatchFaceItem]
total_matches: int
class AutoMatchResponse(BaseModel):
"""Response from auto-match start operation."""
model_config = ConfigDict(protected_namespaces=())
people: list[AutoMatchPersonItem]
total_people: int
total_matches: int
auto_accepted: bool = Field(False, description="Whether auto-acceptance was performed")
auto_accepted_faces: int = Field(0, description="Number of faces automatically accepted")
skipped_persons: int = Field(0, description="Number of persons skipped (non-frontal reference)")
skipped_matches: int = Field(0, description="Number of matches skipped (didn't meet criteria)")
class AcceptMatchesRequest(BaseModel):
"""Request to accept matches for a person."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., min_items=0, description="Face IDs to identify with this person")
class MaintenanceFaceItem(BaseModel):
"""Face item for maintenance view with person info and file path."""
model_config = ConfigDict(protected_namespaces=())
id: int
photo_id: int
photo_path: str
photo_filename: str
quality_score: float
person_id: Optional[int] = None
person_name: Optional[str] = None # Full name if identified
excluded: bool
class MaintenanceFacesResponse(BaseModel):
"""Response containing all faces for maintenance."""
model_config = ConfigDict(protected_namespaces=())
items: list[MaintenanceFaceItem]
total: int
class DeleteFacesRequest(BaseModel):
"""Request to delete multiple faces."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., min_items=1, description="Face IDs to delete")
class DeleteFacesResponse(BaseModel):
"""Response after deleting faces."""
model_config = ConfigDict(protected_namespaces=())
deleted_face_ids: list[int]
count: int
message: str