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:
+56
-2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user