feat: Implement auto-match automation plan with enhanced API and frontend support

This commit introduces a comprehensive auto-match automation plan that automates the face matching process in the application. Key features include the ability to automatically identify faces based on pose and similarity thresholds, with configurable options for auto-acceptance. The API has been updated to support new parameters for auto-acceptance and pose filtering, while the frontend has been enhanced to allow users to set an auto-accept threshold and view results. Documentation has been updated to reflect these changes, improving user experience and functionality.
This commit is contained in:
tanyar09
2025-11-04 14:55:05 -05:00
parent 0dcfe327cd
commit e2cadf3232
6 changed files with 913 additions and 11 deletions
+56 -2
View File
@@ -425,24 +425,69 @@ def auto_match_faces(
request: AutoMatchRequest,
db: Session = Depends(get_db),
) -> AutoMatchResponse:
"""Start auto-match process with tolerance threshold.
"""Start auto-match process with tolerance threshold and optional auto-acceptance.
Matches desktop auto-match workflow exactly:
1. Gets all identified people (one face per person, best quality >= 0.3)
2. For each person, finds similar unidentified faces (confidence >= 40%)
3. Returns matches grouped by person, sorted by person name
If auto_accept=True:
- Only processes persons with frontal or tilted reference faces (not profile)
- Only matches with frontal or tilted unidentified faces (not profile)
- Only auto-accepts matches with similarity >= threshold
"""
from src.web.db.models import Person, Photo
from sqlalchemy import func
# Track statistics for auto-accept
auto_accepted_faces = 0
skipped_persons = 0
skipped_matches = 0
# Find matches for all identified people
matches_data = find_auto_match_matches(db, tolerance=request.tolerance)
# Filter by frontal reference faces if auto_accept enabled
matches_data = find_auto_match_matches(
db,
tolerance=request.tolerance,
filter_frontal_only=request.auto_accept
)
# If auto_accept enabled, process matches automatically
if request.auto_accept and matches_data:
for person_id, reference_face_id, reference_face, similar_faces in matches_data:
# Filter matches by criteria:
# 1. Match face must be frontal (already filtered by find_similar_faces)
# 2. Similarity must be >= threshold
qualifying_faces = []
for face, distance, confidence_pct in similar_faces:
# Check similarity threshold
if confidence_pct < request.auto_accept_threshold:
skipped_matches += 1
continue
qualifying_faces.append(face.id)
# Auto-accept qualifying faces
if qualifying_faces:
try:
identified_count, updated_count = accept_auto_match_matches(
db, person_id, qualifying_faces
)
auto_accepted_faces += identified_count
except Exception as e:
print(f"Error auto-accepting matches for person {person_id}: {e}")
if not matches_data:
return AutoMatchResponse(
people=[],
total_people=0,
total_matches=0,
auto_accepted=request.auto_accept,
auto_accepted_faces=auto_accepted_faces,
skipped_persons=skipped_persons,
skipped_matches=skipped_matches,
)
# Build response matching desktop format
@@ -480,6 +525,9 @@ def auto_match_faces(
if not reference_photo:
continue
# Get reference face pose_mode
reference_pose_mode = reference_face.pose_mode or 'frontal'
# Build matches list
match_items = []
for face, distance, confidence_pct in similar_faces:
@@ -497,6 +545,7 @@ def auto_match_faces(
quality_score=float(face.quality_score),
similarity=confidence_pct, # Confidence percentage (0-100)
distance=distance,
pose_mode=face.pose_mode or 'frontal',
)
)
@@ -509,6 +558,7 @@ def auto_match_faces(
reference_photo_id=reference_face.photo_id,
reference_photo_filename=reference_photo.filename,
reference_location=reference_face.location,
reference_pose_mode=reference_pose_mode,
face_count=face_count,
matches=match_items,
total_matches=len(match_items),
@@ -520,6 +570,10 @@ def auto_match_faces(
people=people_items,
total_people=len(people_items),
total_matches=total_matches,
auto_accepted=request.auto_accept,
auto_accepted_faces=auto_accepted_faces,
skipped_persons=skipped_persons,
skipped_matches=skipped_matches,
)
+8
View File
@@ -182,6 +182,8 @@ class AutoMatchRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
tolerance: float = Field(0.6, 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%)")
class AutoMatchFaceItem(BaseModel):
@@ -196,6 +198,7 @@ class AutoMatchFaceItem(BaseModel):
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):
@@ -209,6 +212,7 @@ class AutoMatchPersonItem(BaseModel):
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
@@ -222,6 +226,10 @@ class AutoMatchResponse(BaseModel):
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):
+62 -1
View File
@@ -913,11 +913,47 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
return max(1, min(20, confidence))
def _is_acceptable_pose_for_auto_match(pose_mode: str) -> bool:
"""Check if pose_mode is acceptable for auto-match (frontal or tilted, but not profile).
Args:
pose_mode: Pose classification string (e.g., 'frontal', 'tilted_left', 'profile_left')
Returns:
True if pose is acceptable (frontal or tilted), False if profile or other non-frontal
"""
if not pose_mode:
return True # Default to frontal if None
pose_mode = pose_mode.lower()
# Accept frontal faces
if pose_mode == 'frontal':
return True
# Accept tilted faces (but not profile)
# Check if it contains 'tilted' but NOT 'profile'
if 'tilted' in pose_mode and 'profile' not in pose_mode:
return True
# Reject profile faces and other non-frontal poses
if 'profile' in pose_mode:
return False
# For other combinations, check if they're still frontal-like
# (e.g., 'frontal_looking_up' would be acceptable)
if pose_mode.startswith('frontal'):
return True
return False
def find_similar_faces(
db: Session,
face_id: int,
limit: int = 20,
tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop
filter_frontal_only: bool = False, # New: Only return frontal or tilted faces (not profile)
) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct)
"""Find similar faces matching desktop logic exactly.
@@ -931,6 +967,9 @@ def find_similar_faces(
1. Filters by person_id is None (unidentified)
2. Filters by confidence >= 40%
3. Sorts by distance
Args:
filter_frontal_only: Only return frontal or tilted faces (not profile)
"""
from src.core.config import DEFAULT_FACE_TOLERANCE
from src.web.db.models import Photo
@@ -1050,6 +1089,12 @@ def find_similar_faces(
print(f"DEBUG: Will include? {is_unidentified and confidence_pct >= 40}")
if is_unidentified and confidence_pct >= 40:
# Filter by pose_mode if requested (only frontal or tilted faces)
if filter_frontal_only and not _is_acceptable_pose_for_auto_match(f.pose_mode):
if face_id in [111, 113, 1] or (face_id == 1 and len(matches) < 10):
print(f"DEBUG: ✗ Face {f.id} filtered out (not frontal/tilted: {f.pose_mode})")
continue
# Return calibrated confidence percentage (matching desktop)
# Desktop displays confidence_pct directly from _get_calibrated_confidence
matches.append((f, distance, confidence_pct))
@@ -1081,6 +1126,7 @@ def find_similar_faces(
def find_auto_match_matches(
db: Session,
tolerance: float = 0.6,
filter_frontal_only: bool = False,
) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
"""Find auto-match matches for all identified people, matching desktop logic exactly.
@@ -1090,6 +1136,10 @@ def find_auto_match_matches(
3. For each person, find similar unidentified faces using _get_filtered_similar_faces
4. Return matches grouped by person
Args:
tolerance: Similarity tolerance (default: 0.6)
filter_frontal_only: Only include persons with frontal or tilted reference face (not profile)
Returns:
List of (person_id, reference_face_id, reference_face, matches) tuples
where matches is list of (face, distance, confidence_pct) tuples
@@ -1116,6 +1166,16 @@ def find_auto_match_matches(
.all()
)
if not identified_faces:
return []
# Filter by pose_mode if requested (only frontal or tilted faces)
if filter_frontal_only:
identified_faces = [
f for f in identified_faces
if _is_acceptable_pose_for_auto_match(f.pose_mode)
]
if not identified_faces:
return []
@@ -1158,7 +1218,8 @@ def find_auto_match_matches(
# reference_face_id, tolerance, include_same_photo=False, face_status=None)
# This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance
similar_faces = find_similar_faces(
db, reference_face_id, limit=1000, tolerance=tolerance
db, reference_face_id, limit=1000, tolerance=tolerance,
filter_frontal_only=filter_frontal_only
)
if similar_faces: