feat: Implement auto-match people and person matches API with frontend integration
This commit introduces new API endpoints for retrieving a list of people for auto-matching and fetching matches for specific individuals. The frontend has been updated to utilize these endpoints, allowing for lazy loading of matches and improved state management. The AutoMatch component now supports caching of matches and session storage for user settings, enhancing performance and user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
4f0b72ee5f
commit
c661aeeda6
@ -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<AutoMatchResponse>('/api/v1/faces/auto-match', request)
|
||||
return response.data
|
||||
},
|
||||
getAutoMatchPeople: async (params?: {
|
||||
filter_frontal_only?: boolean
|
||||
}): Promise<AutoMatchPeopleResponse> => {
|
||||
const response = await apiClient.get<AutoMatchPeopleResponse>('/api/v1/faces/auto-match/people', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
getAutoMatchPersonMatches: async (
|
||||
personId: number,
|
||||
params?: {
|
||||
tolerance?: number
|
||||
filter_frontal_only?: boolean
|
||||
}
|
||||
): Promise<AutoMatchPersonMatchesResponse> => {
|
||||
const response = await apiClient.get<AutoMatchPersonMatchesResponse>(
|
||||
`/api/v1/faces/auto-match/people/${personId}/matches`,
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
getMaintenanceFaces: async (params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
|
||||
@ -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<AutoMatchPersonItem[]>([])
|
||||
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonItem[]>([])
|
||||
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
|
||||
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
|
||||
// Store matches separately, keyed by person_id
|
||||
const [matchesCache, setMatchesCache] = useState<Record<number, AutoMatchFaceItem[]>>({})
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
|
||||
@ -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<number, boolean> = {}
|
||||
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() {
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={loadAutoMatch}
|
||||
onClick={() => loadAutoMatch(true)}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title="Refresh and start from beginning"
|
||||
>
|
||||
{isRefreshing ? 'Refreshing...' : '🔄 Refresh'}
|
||||
</button>
|
||||
@ -507,22 +784,6 @@ export default function AutoMatch() {
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
{currentPerson && ` • ${currentPerson.total_matches} matches`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to exit auto-match?')) {
|
||||
setIsActive(false)
|
||||
setPeople([])
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
setSelectedFaces({})
|
||||
setOriginalSelectedFaces({})
|
||||
setSearchQuery('')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
❌ Exit Auto-Match
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user