feat: Add batch similarity endpoint and update Identify component for improved face comparison

This commit introduces a new batch similarity API endpoint to efficiently calculate similarities between multiple faces in a single request. The frontend has been updated to utilize this endpoint, enhancing the Identify component by replacing individual similarity checks with a batch processing approach. Progress indicators have been added to provide user feedback during similarity calculations, improving the overall user experience. Additionally, new data models for batch similarity requests and responses have been defined, ensuring a structured and efficient data flow. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-07 12:56:23 -05:00
parent e4a5ff8a57
commit 81b845c98f
5 changed files with 392 additions and 38 deletions
+20
View File
@@ -45,6 +45,22 @@ export interface SimilarFacesResponse {
items: SimilarFaceItem[]
}
export interface FaceSimilarityPair {
face_id_1: number
face_id_2: number
similarity: number // 0-1 range
confidence_pct: number // 0-100 range
}
export interface BatchSimilarityRequest {
face_ids: number[]
min_confidence?: number // 0-100, default 60
}
export interface BatchSimilarityResponse {
pairs: FaceSimilarityPair[]
}
export interface IdentifyFaceRequest {
person_id?: number
first_name?: string
@@ -146,6 +162,10 @@ export const facesApi = {
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`)
return response.data
},
batchSimilarity: async (request: BatchSimilarityRequest): Promise<BatchSimilarityResponse> => {
const response = await apiClient.post<BatchSimilarityResponse>('/api/v1/faces/batch-similarity', request)
return response.data
},
identify: async (faceId: number, payload: IdentifyFaceRequest): Promise<IdentifyFaceResponse> => {
const response = await apiClient.post<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
return response.data
+136 -34
View File
@@ -49,6 +49,8 @@ export default function Identify() {
// Track previous face ID to save data on navigation
const prevFaceIdRef = useRef<number | undefined>(undefined)
// Track if initial load has happened
const initialLoadRef = useRef(false)
const canIdentify = useMemo(() => {
return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace))
@@ -92,37 +94,94 @@ export default function Identify() {
// Create a map of face IDs to face objects for quick lookup
const faceMap = new Map(faces.map(f => [f.id, f]))
// Build similarity graph: for each face, find all similar faces (≥60% confidence) in current list
// Build similarity graph: use batch endpoint to get all similarities at once
const similarityMap = new Map<number, Set<number>>()
for (let i = 0; i < faces.length; i++) {
const face = faces[i]
const similarSet = new Set<number>()
// Initialize similarity map for all faces
for (const face of faces) {
similarityMap.set(face.id, new Set<number>())
}
// Update progress - loading all faces once
setLoadingProgress({
current: 0,
total: faces.length,
message: 'Loading all faces from database...'
})
try {
// Get all face IDs
const faceIds = faces.map(f => f.id)
// Update progress
// Update progress - calculating similarities
setLoadingProgress({
current: i + 1,
current: 0,
total: faces.length,
message: `Checking face ${i + 1} of ${faces.length}...`
message: `Calculating similarities for ${faces.length} faces (this may take a while)...`
})
try {
const similarRes = await facesApi.getSimilar(face.id)
for (const similar of similarRes.items) {
// Only include similar faces that are in the current list
if (!faceMap.has(similar.id)) continue
// Convert similarity back to percentage (similarity is in [0,1])
const confidencePct = Math.round(similar.similarity * 100)
if (confidencePct >= 60) {
similarSet.add(similar.id)
}
}
} catch (error) {
// Silently skip faces with errors
}
// Call batch similarity endpoint - loads all faces once from DB
// Note: This is where the heavy computation happens (comparing N faces to M faces)
// The progress bar will show 0% during this time as we can't track backend progress
const batchRes = await facesApi.batchSimilarity({
face_ids: faceIds,
min_confidence: 60.0
})
similarityMap.set(face.id, similarSet)
// Update progress - calculation complete, now processing results
const totalPairs = batchRes.pairs.length
setLoadingProgress({
current: 0,
total: totalPairs,
message: `Similarity calculation complete! Processing ${totalPairs} results...`
})
// Build similarity map from batch results
// Note: results include similarities to all faces in DB, but we only care about
// similarities between faces in the current list
let processedPairs = 0
for (const pair of batchRes.pairs) {
// Only include pairs where both faces are in the current list
if (!faceMap.has(pair.face_id_1) || !faceMap.has(pair.face_id_2)) {
processedPairs++
// Update progress every 100 pairs or at the end
if (processedPairs % 100 === 0 || processedPairs === totalPairs) {
setLoadingProgress({
current: processedPairs,
total: totalPairs,
message: `Processing similarity results... (${processedPairs} / ${totalPairs})`
})
// Allow UI to update
await new Promise(resolve => setTimeout(resolve, 0))
}
continue
}
// Add bidirectional relationships
const set1 = similarityMap.get(pair.face_id_1) || new Set<number>()
set1.add(pair.face_id_2)
similarityMap.set(pair.face_id_1, set1)
const set2 = similarityMap.get(pair.face_id_2) || new Set<number>()
set2.add(pair.face_id_1)
similarityMap.set(pair.face_id_2, set2)
processedPairs++
// Update progress every 100 pairs or at the end
if (processedPairs % 100 === 0 || processedPairs === totalPairs) {
setLoadingProgress({
current: processedPairs,
total: totalPairs,
message: `Processing similarity results... (${processedPairs} / ${totalPairs})`
})
// Allow UI to update
await new Promise(resolve => setTimeout(resolve, 0))
}
}
} catch (error) {
// Silently skip on error - return original faces
console.error('Error calculating batch similarities:', error)
return faces
}
// Find connected components (groups of similar faces)
@@ -194,11 +253,23 @@ export default function Identify() {
}
}
// Initial load on mount
useEffect(() => {
loadFaces()
loadPeople()
if (!initialLoadRef.current) {
initialLoadRef.current = true
loadFaces()
loadPeople()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly])
}, [])
// Reload when uniqueFacesOnly changes (immediate reload)
useEffect(() => {
if (initialLoadRef.current) {
loadFaces()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueFacesOnly])
useEffect(() => {
if (currentFace) {
@@ -373,18 +444,40 @@ export default function Identify() {
{loadingProgress.total > 0 && (
<span className="text-sm text-gray-500">
{loadingProgress.current} / {loadingProgress.total}
{loadingProgress.total > 0 && (
<span className="ml-1">
({Math.round((loadingProgress.current / loadingProgress.total) * 100)}%)
</span>
)}
</span>
)}
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{
width: loadingProgress.total > 0
? `${(loadingProgress.current / loadingProgress.total) * 100}%`
: '100%'
}}
/>
{loadingProgress.total > 0 ? (
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{
width: `${Math.max(1, (loadingProgress.current / loadingProgress.total) * 100)}%`
}}
/>
) : (
<div className="relative h-2.5 overflow-hidden rounded-full bg-gray-200">
<div
className="absolute h-2.5 bg-blue-600 rounded-full"
style={{
width: '30%',
animation: 'slide 1.5s ease-in-out infinite',
left: '-30%'
}}
/>
<style>{`
@keyframes slide {
0% { left: -30%; }
100% { left: 100%; }
}
`}</style>
</div>
)}
</div>
</div>
)}
@@ -466,6 +559,15 @@ export default function Identify() {
Hide duplicates with 60% match confidence
</p>
</div>
<div className="mt-4 pt-3 border-t">
<button
onClick={loadFaces}
disabled={loadingFaces}
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{loadingFaces ? 'Loading...' : 'Apply Filters'}
</button>
</div>
</div>
)}
</div>