diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index d89bef0..d6c918f 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -122,6 +122,29 @@ export interface AutoMatchPersonItem { total_matches: number } +export interface AutoMatchPersonSummary { + person_id: number + person_name: string + reference_face_id: number + reference_photo_id: number + reference_photo_filename: string + reference_location: string + reference_pose_mode?: string + face_count: number + total_matches: number +} + +export interface AutoMatchPeopleResponse { + people: AutoMatchPersonSummary[] + total_people: number +} + +export interface AutoMatchPersonMatchesResponse { + person_id: number + matches: AutoMatchFaceItem[] + total_matches: number +} + export interface AutoMatchResponse { people: AutoMatchPersonItem[] total_people: number @@ -215,6 +238,27 @@ export const facesApi = { const response = await apiClient.post('/api/v1/faces/auto-match', request) return response.data }, + getAutoMatchPeople: async (params?: { + filter_frontal_only?: boolean + }): Promise => { + const response = await apiClient.get('/api/v1/faces/auto-match/people', { + params, + }) + return response.data + }, + getAutoMatchPersonMatches: async ( + personId: number, + params?: { + tolerance?: number + filter_frontal_only?: boolean + } + ): Promise => { + const response = await apiClient.get( + `/api/v1/faces/auto-match/people/${personId}/matches`, + { params } + ) + return response.data + }, getMaintenanceFaces: async (params: { page?: number page_size?: number diff --git a/frontend/src/pages/AutoMatch.tsx b/frontend/src/pages/AutoMatch.tsx index 4c2ec2f..6aa2709 100644 --- a/frontend/src/pages/AutoMatch.tsx +++ b/frontend/src/pages/AutoMatch.tsx @@ -1,5 +1,8 @@ -import { useState, useEffect, useMemo } from 'react' -import facesApi, { AutoMatchResponse, AutoMatchPersonItem, AutoMatchFaceItem } from '../api/faces' +import { useState, useEffect, useMemo, useRef } from 'react' +import facesApi, { + AutoMatchPersonSummary, + AutoMatchFaceItem +} from '../api/faces' import peopleApi from '../api/people' import { apiClient } from '../api/client' import { useDeveloperMode } from '../context/DeveloperModeContext' @@ -11,8 +14,10 @@ export default function AutoMatch() { const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE) const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70) const [isActive, setIsActive] = useState(false) - const [people, setPeople] = useState([]) - const [filteredPeople, setFilteredPeople] = useState([]) + const [people, setPeople] = useState([]) + const [filteredPeople, setFilteredPeople] = useState([]) + // Store matches separately, keyed by person_id + const [matchesCache, setMatchesCache] = useState>({}) const [currentIndex, setCurrentIndex] = useState(0) const [searchQuery, setSearchQuery] = useState('') const [selectedFaces, setSelectedFaces] = useState>({}) @@ -22,17 +27,87 @@ export default function AutoMatch() { const [hasNoResults, setHasNoResults] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) + // SessionStorage keys for persisting state and settings + const STATE_KEY = 'automatch_state' + const SETTINGS_KEY = 'automatch_settings' + + // Track if initial load has happened + const initialLoadRef = useRef(false) + // Track if settings have been loaded from sessionStorage + const [settingsLoaded, setSettingsLoaded] = useState(false) + // Track if state has been restored from sessionStorage + const [stateRestored, setStateRestored] = useState(false) + // Track if initial restoration is complete (prevents reload effects from firing during restoration) + const restorationCompleteRef = useRef(false) + const currentPerson = useMemo(() => { const activePeople = filteredPeople.length > 0 ? filteredPeople : people return activePeople[currentIndex] }, [filteredPeople, people, currentIndex]) const currentMatches = useMemo(() => { - return currentPerson?.matches || [] - }, [currentPerson]) + if (!currentPerson) return [] + return matchesCache[currentPerson.person_id] || [] + }, [currentPerson, matchesCache]) - // Shared function for auto-load and refresh - const loadAutoMatch = async () => { + // Load matches for a specific person (lazy loading) + const loadPersonMatches = async (personId: number) => { + // Skip if already cached + if (matchesCache[personId]) { + return + } + + try { + const response = await facesApi.getAutoMatchPersonMatches(personId, { + tolerance, + filter_frontal_only: false + }) + + setMatchesCache(prev => ({ + ...prev, + [personId]: response.matches + })) + + // Update total_matches in people list + setPeople(prev => prev.map(p => + p.person_id === personId + ? { ...p, total_matches: response.total_matches } + : p + )) + + // If no matches found, remove person from list (matching original behavior) + // Original endpoint only returns people who have matches + if (response.total_matches === 0) { + setPeople(prev => { + const removedIndex = prev.findIndex(p => p.person_id === personId) + // Adjust current index if needed + if (removedIndex !== -1) { + setCurrentIndex(currentIdx => { + if (currentIdx >= removedIndex) { + return Math.max(0, currentIdx - 1) + } + return currentIdx + }) + } + return prev.filter(p => p.person_id !== personId) + }) + setFilteredPeople(prev => prev.filter(p => p.person_id !== personId)) + } + } catch (error) { + console.error('Failed to load matches for person:', error) + // Set empty matches on error, and remove person from list + setMatchesCache(prev => ({ + ...prev, + [personId]: [] + })) + // Remove person if matches failed to load (assume no matches) + setPeople(prev => prev.filter(p => p.person_id !== personId)) + setFilteredPeople(prev => prev.filter(p => p.person_id !== personId)) + } + } + + // Shared function for auto-load and refresh (loads people list only - fast) + const loadAutoMatch = async (clearState: boolean = false) => { if (tolerance < 0 || tolerance > 1) { return } @@ -40,9 +115,15 @@ export default function AutoMatch() { setBusy(true) setIsRefreshing(true) try { - const response = await facesApi.autoMatch({ - tolerance, - auto_accept: false // Don't auto-accept on load/refresh, only on button click + // Clear saved state if explicitly requested (Refresh button) + if (clearState) { + sessionStorage.removeItem(STATE_KEY) + setMatchesCache({}) // Clear matches cache + } + + // Load people list only (fast - no match calculations) + const response = await facesApi.getAutoMatchPeople({ + filter_frontal_only: false }) if (response.people.length === 0) { @@ -62,6 +143,11 @@ export default function AutoMatch() { setSelectedFaces({}) setOriginalSelectedFaces({}) setIsActive(true) + + // Load matches for first person immediately + if (response.people.length > 0) { + await loadPersonMatches(response.people[0].person_id) + } } catch (error) { console.error('Auto-match failed:', error) } finally { @@ -70,9 +156,181 @@ export default function AutoMatch() { } } - // Auto-start auto-match when component mounts or tolerance changes (without auto-accept) + // Load settings from sessionStorage on mount useEffect(() => { - loadAutoMatch() + try { + const saved = sessionStorage.getItem(SETTINGS_KEY) + if (saved) { + const settings = JSON.parse(saved) + if (settings.tolerance !== undefined) setTolerance(settings.tolerance) + if (settings.autoAcceptThreshold !== undefined) setAutoAcceptThreshold(settings.autoAcceptThreshold) + } + } catch (error) { + console.error('Error loading settings from sessionStorage:', error) + } finally { + setSettingsLoaded(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Load state from sessionStorage on mount (people, current index, selected faces) + // Note: This effect runs after settings are loaded, so tolerance is already set + useEffect(() => { + if (!settingsLoaded) return // Wait for settings to load first + + try { + const saved = sessionStorage.getItem(STATE_KEY) + if (saved) { + const state = JSON.parse(saved) + // Only restore state if tolerance matches (cached state is for current tolerance) + if (state.people && Array.isArray(state.people) && state.people.length > 0 && + state.tolerance === tolerance) { + setPeople(state.people) + if (state.currentIndex !== undefined) { + setCurrentIndex(Math.min(state.currentIndex, state.people.length - 1)) + } + if (state.selectedFaces && typeof state.selectedFaces === 'object') { + setSelectedFaces(state.selectedFaces) + } + if (state.originalSelectedFaces && typeof state.originalSelectedFaces === 'object') { + setOriginalSelectedFaces(state.originalSelectedFaces) + } + if (state.matchesCache && typeof state.matchesCache === 'object') { + setMatchesCache(state.matchesCache) + } + if (state.isActive !== undefined) { + setIsActive(state.isActive) + } + if (state.hasNoResults !== undefined) { + setHasNoResults(state.hasNoResults) + } + // Mark that we restored state, so we don't reload + initialLoadRef.current = true + // Mark restoration as complete after state is restored + setTimeout(() => { + restorationCompleteRef.current = true + }, 50) + } else if (state.tolerance !== undefined && state.tolerance !== tolerance) { + // Tolerance changed, clear old cache + sessionStorage.removeItem(STATE_KEY) + } + } + } catch (error) { + console.error('Error loading state from sessionStorage:', error) + } finally { + setStateRestored(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settingsLoaded]) + + // Save state to sessionStorage whenever it changes (but only after initial restore) + useEffect(() => { + if (!stateRestored) return // Don't save during initial restore + + try { + const state = { + people, + currentIndex, + selectedFaces, + originalSelectedFaces, + matchesCache, + isActive, + hasNoResults, + tolerance, // Include tolerance to validate cache on restore + } + sessionStorage.setItem(STATE_KEY, JSON.stringify(state)) + } catch (error) { + console.error('Error saving state to sessionStorage:', error) + } + }, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance, stateRestored]) + + // Save state on unmount (when navigating away) - use refs to capture latest values + const peopleRef = useRef(people) + const currentIndexRef = useRef(currentIndex) + const selectedFacesRef = useRef(selectedFaces) + const originalSelectedFacesRef = useRef(originalSelectedFaces) + const matchesCacheRef = useRef(matchesCache) + const isActiveRef = useRef(isActive) + const hasNoResultsRef = useRef(hasNoResults) + const toleranceRef = useRef(tolerance) + + // Update refs whenever state changes + useEffect(() => { + peopleRef.current = people + currentIndexRef.current = currentIndex + selectedFacesRef.current = selectedFaces + originalSelectedFacesRef.current = originalSelectedFaces + matchesCacheRef.current = matchesCache + isActiveRef.current = isActive + hasNoResultsRef.current = hasNoResults + toleranceRef.current = tolerance + }, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance]) + + // Save state on unmount (when navigating away) + useEffect(() => { + return () => { + try { + const state = { + people: peopleRef.current, + currentIndex: currentIndexRef.current, + selectedFaces: selectedFacesRef.current, + originalSelectedFaces: originalSelectedFacesRef.current, + matchesCache: matchesCacheRef.current, + isActive: isActiveRef.current, + hasNoResults: hasNoResultsRef.current, + tolerance: toleranceRef.current, // Include tolerance to validate cache on restore + } + sessionStorage.setItem(STATE_KEY, JSON.stringify(state)) + } catch (error) { + console.error('Error saving state on unmount:', error) + } + } + }, []) + + // Save settings to sessionStorage whenever they change (but only after initial load) + useEffect(() => { + if (!settingsLoaded) return // Don't save during initial load + try { + const settings = { + tolerance, + autoAcceptThreshold, + } + sessionStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) + } catch (error) { + console.error('Error saving settings to sessionStorage:', error) + } + }, [tolerance, autoAcceptThreshold, settingsLoaded]) + + // Initial load on mount (after settings and state are loaded) + useEffect(() => { + if (!initialLoadRef.current && settingsLoaded && stateRestored) { + initialLoadRef.current = true + // Only load if we didn't restore state (no people means we need to load) + if (people.length === 0) { + loadAutoMatch() + // If we're loading fresh, mark restoration as complete immediately + restorationCompleteRef.current = true + } else { + // If state was restored, restorationCompleteRef is already set in the state restoration effect + // But ensure it's set in case state restoration didn't happen + if (!restorationCompleteRef.current) { + setTimeout(() => { + restorationCompleteRef.current = true + }, 50) + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settingsLoaded, stateRestored]) + + // Reload when tolerance changes (immediate reload) + // But only if restoration is complete (prevents reload during initial restoration) + useEffect(() => { + if (initialLoadRef.current && restorationCompleteRef.current) { + // Clear matches cache when tolerance changes (matches depend on tolerance) + setMatchesCache({}) + loadAutoMatch() + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tolerance]) @@ -134,7 +392,8 @@ export default function AutoMatch() { } // Reload faces after auto-accept to remove auto-accepted faces from the list - await loadAutoMatch() + // Clear cache to get fresh data after auto-accept + await loadAutoMatch(true) return } @@ -213,16 +472,33 @@ export default function AutoMatch() { } } + // Load matches when current person changes (lazy loading) + useEffect(() => { + if (currentPerson && restorationCompleteRef.current) { + loadPersonMatches(currentPerson.person_id) + + // Preload matches for next person in background + const activePeople = filteredPeople.length > 0 ? filteredPeople : people + if (currentIndex + 1 < activePeople.length) { + const nextPerson = activePeople[currentIndex + 1] + loadPersonMatches(nextPerson.person_id) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPerson?.person_id, currentIndex]) + // Restore selected faces when navigating to a different person useEffect(() => { if (currentPerson) { + const matches = matchesCache[currentPerson.person_id] || [] const restored: Record = {} - currentPerson.matches.forEach(match => { + matches.forEach(match => { restored[match.id] = originalSelectedFaces[match.id] || false }) setSelectedFaces(restored) } - }, [currentIndex, filteredPeople.length, people.length]) // Only when person changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache]) const goBack = () => { if (currentIndex > 0) { @@ -255,9 +531,10 @@ export default function AutoMatch() {
@@ -507,22 +784,6 @@ export default function AutoMatch() { Person {currentIndex + 1} of {activePeople.length} {currentPerson && ` • ${currentPerson.total_matches} matches`}
-
)} diff --git a/src/web/api/faces.py b/src/web/api/faces.py index c9be616..4049842 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -30,6 +30,9 @@ from src.web.schemas.faces import ( AutoMatchResponse, AutoMatchPersonItem, AutoMatchFaceItem, + AutoMatchPeopleResponse, + AutoMatchPersonSummary, + AutoMatchPersonMatchesResponse, AcceptMatchesRequest, MaintenanceFacesResponse, MaintenanceFaceItem, @@ -44,6 +47,8 @@ from src.web.services.face_service import ( calculate_batch_similarities, find_auto_match_matches, accept_auto_match_matches, + get_auto_match_people_list, + get_auto_match_person_matches as get_person_matches_service, ) # Note: Function passed as string path to avoid RQ serialization issues @@ -702,6 +707,125 @@ def auto_match_faces( ) +@router.get("/auto-match/people", response_model=AutoMatchPeopleResponse) +def get_auto_match_people( + filter_frontal_only: bool = Query(False, description="Only include frontal/tilted reference faces"), + tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"), + db: Session = Depends(get_db), +) -> AutoMatchPeopleResponse: + """Get list of people for auto-match (without matches) - fast initial load. + + Returns just the people list with reference faces, without calculating matches. + This allows fast initial page load, then matches can be loaded on-demand via + /auto-match/people/{person_id}/matches endpoint. + + Note: Only returns people if there are unidentified faces in the database + (since people can't have matches if there are no unidentified faces). + """ + from src.web.db.models import Person, Photo + + # Get people list (fast - no match calculations, but checks for unidentified faces) + people_data = get_auto_match_people_list( + db, + filter_frontal_only=filter_frontal_only, + tolerance=tolerance + ) + + if not people_data: + return AutoMatchPeopleResponse(people=[], total_people=0) + + # Build response + people_items = [] + for person_id, reference_face, person_name, face_count in people_data: + # Get person details + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + continue + + # Get reference face photo info + reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first() + if not reference_photo: + continue + + # Get reference face pose_mode + reference_pose_mode = reference_face.pose_mode or 'frontal' + + people_items.append( + AutoMatchPersonSummary( + person_id=person_id, + person_name=person_name, + reference_face_id=reference_face.id, + 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, + total_matches=0, # Will be loaded separately + ) + ) + + return AutoMatchPeopleResponse( + people=people_items, + total_people=len(people_items), + ) + + +@router.get("/auto-match/people/{person_id}/matches", response_model=AutoMatchPersonMatchesResponse) +def get_auto_match_person_matches( + person_id: int, + tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"), + filter_frontal_only: bool = Query(False, description="Only return frontal/tilted faces"), + db: Session = Depends(get_db), +) -> AutoMatchPersonMatchesResponse: + """Get matches for a specific person - for lazy loading. + + This endpoint is called on-demand when user navigates to a person. + """ + from src.web.db.models import Photo + + # Get matches for this person + similar_faces = get_person_matches_service( + db, + person_id=person_id, + tolerance=tolerance, + filter_frontal_only=filter_frontal_only, + ) + + if not similar_faces: + return AutoMatchPersonMatchesResponse( + person_id=person_id, + matches=[], + total_matches=0, + ) + + # Build matches list + match_items = [] + for face, distance, confidence_pct in similar_faces: + # Get photo info for this match + match_photo = db.query(Photo).filter(Photo.id == face.photo_id).first() + if not match_photo: + continue + + match_items.append( + AutoMatchFaceItem( + id=face.id, + photo_id=face.photo_id, + photo_filename=match_photo.filename, + location=face.location, + quality_score=float(face.quality_score), + similarity=confidence_pct, # Confidence percentage (0-100) + distance=distance, + pose_mode=face.pose_mode or 'frontal', + ) + ) + + return AutoMatchPersonMatchesResponse( + person_id=person_id, + matches=match_items, + total_matches=len(match_items), + ) + + @router.get("/maintenance", response_model=MaintenanceFacesResponse) def list_all_faces( page: int = Query(1, ge=1), diff --git a/src/web/schemas/faces.py b/src/web/schemas/faces.py index c2a2f9c..cc51ea9 100644 --- a/src/web/schemas/faces.py +++ b/src/web/schemas/faces.py @@ -248,6 +248,41 @@ class AutoMatchPersonItem(BaseModel): 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.""" diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index 5635d41..0da1d8f 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -12,7 +12,7 @@ from datetime import date import numpy as np from PIL import Image from sqlalchemy.orm import Session, joinedload -from sqlalchemy import and_, func +from sqlalchemy import and_, func, case try: from deepface import DeepFace @@ -1299,6 +1299,20 @@ def list_unidentified_faces( query = query.order_by(sort_col.asc().nullslast()) else: query = query.order_by(sort_col.desc().nullslast()) + + # Add secondary sort by quality (descending, best first) when primary sort is not quality + if sort_by != "quality": + query = query.order_by(Face.quality_score.desc().nullslast()) + + # Add tertiary sort by pose mode: frontal first, then tilted, then profile last + # Check profile first to catch poses that might contain both 'tilted' and 'profile' + pose_order = case( + (func.lower(Face.pose_mode).like('%profile%'), 2), # Profile = 2 (last) + (func.lower(Face.pose_mode).like('%tilted%'), 1), # Tilted = 1 (second) + (func.lower(Face.pose_mode).like('frontal%'), 0), # Frontal = 0 (first) + else_=2 # Default to last for unknown poses + ) + query = query.order_by(pose_order.asc()) # Total count for pagination total = query.count() @@ -1752,13 +1766,21 @@ def find_auto_match_matches( # FROM faces f # JOIN photos p ON f.photo_id = p.id # WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 - # ORDER BY f.person_id, f.quality_score DESC + # ORDER BY f.person_id, f.quality_score DESC, pose_mode (frontal first, then tilted, then profile) + # Add pose mode ordering: frontal first, then tilted, then profile last + pose_order = case( + (func.lower(Face.pose_mode).like('%profile%'), 2), # Profile = 2 (last) + (func.lower(Face.pose_mode).like('%tilted%'), 1), # Tilted = 1 (second) + (func.lower(Face.pose_mode).like('frontal%'), 0), # Frontal = 0 (first) + else_=2 # Default to last for unknown poses + ) + identified_faces: List[Face] = ( db.query(Face) .join(Photo, Face.photo_id == Photo.id) .filter(Face.person_id.isnot(None)) .filter(Face.quality_score >= 0.3) - .order_by(Face.person_id, Face.quality_score.desc()) + .order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc()) .all() ) @@ -1824,6 +1846,153 @@ def find_auto_match_matches( return results +def get_auto_match_people_list( + db: Session, + filter_frontal_only: bool = False, + tolerance: float = 0.6, +) -> List[Tuple[int, Face, str, int]]: + """Get list of people for auto-match (without matches) - fast initial load. + + Returns just the people list with reference faces, without calculating matches. + This allows fast initial page load, then matches can be loaded on-demand. + + However, we do a quick check to see if there are any unidentified faces at all. + If there are no unidentified faces, we return an empty list (no one can have matches). + + Args: + filter_frontal_only: Only include persons with frontal or tilted reference face (not profile) + tolerance: Similarity tolerance (used to check if there are potential matches) + + Returns: + List of (person_id, reference_face, person_name, face_count) tuples + """ + from src.web.db.models import Person, Photo + from src.core.config import DEFAULT_FACE_TOLERANCE + from sqlalchemy import func, case + + if tolerance is None: + tolerance = DEFAULT_FACE_TOLERANCE + + # Quick check: if there are no unidentified faces, no one can have matches + # This is a fast query that avoids loading people who can't possibly have matches + unidentified_count = ( + db.query(func.count(Face.id)) + .filter(Face.person_id.is_(None)) + .scalar() or 0 + ) + + if unidentified_count == 0: + return [] + + # Get all identified faces (one per person) to use as reference faces + # Same logic as find_auto_match_matches but without finding matches + pose_order = case( + (func.lower(Face.pose_mode).like('%profile%'), 2), # Profile = 2 (last) + (func.lower(Face.pose_mode).like('%tilted%'), 1), # Tilted = 1 (second) + (func.lower(Face.pose_mode).like('frontal%'), 0), # Frontal = 0 (first) + else_=2 # Default to last for unknown poses + ) + + identified_faces: List[Face] = ( + db.query(Face) + .join(Photo, Face.photo_id == Photo.id) + .filter(Face.person_id.isnot(None)) + .filter(Face.quality_score >= 0.3) + .order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc()) + .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 [] + + # Group by person and get the best quality face per person + person_faces: Dict[int, Face] = {} + for face in identified_faces: + person_id = face.person_id + if person_id not in person_faces: + person_faces[person_id] = face + + # Convert to ordered list with person names + person_faces_list = [] + for person_id, face in person_faces.items(): + # Get person name for ordering + person = db.query(Person).filter(Person.id == person_id).first() + if person: + if person.last_name and person.first_name: + person_name = f"{person.last_name}, {person.first_name}" + elif person.last_name: + person_name = person.last_name + elif person.first_name: + person_name = person.first_name + else: + person_name = "Unknown" + else: + person_name = "Unknown" + + # Get face count for this person + face_count = ( + db.query(func.count(Face.id)) + .filter(Face.person_id == person_id) + .scalar() or 0 + ) + + person_faces_list.append((person_id, face, person_name, face_count)) + + # Sort by person name for consistent, user-friendly ordering + person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2) + + return person_faces_list + + +def get_auto_match_person_matches( + db: Session, + person_id: int, + tolerance: float = 0.6, + filter_frontal_only: bool = False, +) -> List[Tuple[Face, float, float]]: + """Get matches for a specific person - for lazy loading. + + Args: + person_id: Person ID to get matches for + tolerance: Similarity tolerance (default: 0.6) + filter_frontal_only: Only return frontal or tilted faces (not profile) + + Returns: + List of (face, distance, confidence_pct) tuples + """ + from src.web.db.models import Person, Face + + # Get reference face for this person (best quality >= 0.3) + reference_face = ( + db.query(Face) + .filter(Face.person_id == person_id) + .filter(Face.quality_score >= 0.3) + .order_by(Face.quality_score.desc()) + .first() + ) + + if not reference_face: + return [] + + # Find similar faces using existing function + similar_faces = find_similar_faces( + db, reference_face.id, tolerance=tolerance, + filter_frontal_only=filter_frontal_only + ) + + return similar_faces + + def accept_auto_match_matches( db: Session, person_id: int,