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

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

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>

View File

@ -17,6 +17,9 @@ from src.web.schemas.faces import (
FaceItem,
SimilarFacesResponse,
SimilarFaceItem,
BatchSimilarityRequest,
BatchSimilarityResponse,
FaceSimilarityPair,
IdentifyFaceRequest,
IdentifyFaceResponse,
FaceUnmatchResponse,
@ -33,6 +36,7 @@ from src.web.db.models import Face, Person, PersonEncoding
from src.web.services.face_service import (
list_unidentified_faces,
find_similar_faces,
calculate_batch_similarities,
find_auto_match_matches,
accept_auto_match_matches,
)
@ -168,6 +172,42 @@ def get_similar_faces(face_id: int, db: Session = Depends(get_db)) -> SimilarFac
return SimilarFacesResponse(base_face_id=face_id, items=items)
@router.post("/batch-similarity", response_model=BatchSimilarityResponse)
def get_batch_similarities(
request: BatchSimilarityRequest,
db: Session = Depends(get_db),
) -> BatchSimilarityResponse:
"""Calculate similarities between all pairs of faces in the provided list.
Loads all faces once from database and calculates similarities between all pairs.
Much more efficient than calling /similar for each face individually.
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"API: batch_similarity called for {len(request.face_ids)} faces")
# Calculate similarities between all pairs
pairs = calculate_batch_similarities(
db,
request.face_ids,
min_confidence=request.min_confidence,
)
# Convert to response format
items = [
FaceSimilarityPair(
face_id_1=face_id_1,
face_id_2=face_id_2,
similarity=similarity,
confidence_pct=confidence_pct,
)
for face_id_1, face_id_2, similarity, confidence_pct in pairs
]
logger.info(f"API: batch_similarity returning {len(items)} pairs")
return BatchSimilarityResponse(pairs=items)
@router.post("/{face_id}/identify", response_model=IdentifyFaceResponse)
def identify_face(
face_id: int,

View File

@ -99,6 +99,34 @@ class SimilarFacesResponse(BaseModel):
items: list[SimilarFaceItem]
class BatchSimilarityRequest(BaseModel):
"""Request to get similarities between multiple faces."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., description="List of face IDs to calculate similarities for")
min_confidence: float = Field(60.0, ge=0.0, le=100.0, description="Minimum confidence percentage (0-100)")
class FaceSimilarityPair(BaseModel):
"""A pair of similar faces with their similarity score."""
model_config = ConfigDict(protected_namespaces=())
face_id_1: int
face_id_2: int
similarity: float # 0-1 range
confidence_pct: float # 0-100 range
class BatchSimilarityResponse(BaseModel):
"""Response containing similarities between face pairs."""
model_config = ConfigDict(protected_namespaces=())
pairs: list[FaceSimilarityPair] = Field(..., description="List of similar face pairs")
class IdentifyFaceRequest(BaseModel):
"""Identify a face by selecting existing or creating new person."""

View File

@ -1256,13 +1256,27 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
Desktop: _calculate_cosine_similarity returns distance (0 = identical, 2 = opposite)
This matches the desktop implementation exactly.
Optimized: Avoids unnecessary array creation if inputs are already numpy arrays.
"""
try:
# Ensure encodings are numpy arrays
enc1 = np.array(encoding1).flatten()
enc2 = np.array(encoding2).flatten()
# Optimize: Check if already numpy arrays to avoid unnecessary conversions
# Since we pre-load encodings as 1D arrays from np.frombuffer(), we can use them directly
if isinstance(encoding1, np.ndarray):
# Already numpy array - use directly if 1D, otherwise flatten (creates view, not copy)
enc1 = encoding1 if encoding1.ndim == 1 else encoding1.flatten()
else:
# Convert to numpy array only if needed (rare case)
enc1 = np.asarray(encoding1, dtype=np.float64).flatten()
# Check if encodings have the same length
if isinstance(encoding2, np.ndarray):
# Already numpy array - use directly if 1D, otherwise flatten (creates view, not copy)
enc2 = encoding2 if encoding2.ndim == 1 else encoding2.flatten()
else:
# Convert to numpy array only if needed (rare case)
enc2 = np.asarray(encoding2, dtype=np.float64).flatten()
# Check if encodings have the same length (all ArcFace encodings are 512-dim, but check for safety)
if len(enc1) != len(enc2):
return 2.0 # Maximum distance on mismatch
@ -1498,6 +1512,156 @@ def find_similar_faces(
return matches[:limit]
def calculate_batch_similarities(
db: Session,
face_ids: list[int],
min_confidence: float = 60.0,
) -> list[tuple[int, int, float, float]]:
"""Calculate similarities between N faces and all M faces in database.
Optimized implementation using:
- Phase 1: Pre-normalization of all encodings (avoids repeated normalization)
- Phase 2: Vectorized batch operations using numpy matrix multiplication
Loads all M faces once from database (cached) and compares each of the N faces
to all M faces using efficient vectorized operations. Returns list of
(face_id_1, face_id_2, similarity, confidence_pct) tuples.
Args:
db: Database session
face_ids: List of face IDs to calculate similarities for (N faces)
min_confidence: Minimum confidence percentage (0-100) to include a pair
Returns:
List of (face_id_1, face_id_2, similarity, confidence_pct) tuples
where face_id_1 is from the request list, face_id_2 is from all faces in DB
similarity is in [0,1] range and confidence_pct is in [0,100] range
"""
from src.core.config import DEFAULT_FACE_TOLERANCE
from src.web.db.models import Photo
if not face_ids:
return []
# Load all M faces once from database (cache all faces)
# Note: Don't load photo data - we only need encodings and quality scores
all_faces: list[Face] = (
db.query(Face)
.all()
)
# Create a map of all face_id -> Face for quick lookup
all_face_map = {f.id: f for f in all_faces}
# Load the N faces from the request
request_faces: list[Face] = [
all_face_map[fid] for fid in face_ids if fid in all_face_map
]
if not request_faces:
return []
# Pre-load all M encodings and quality scores once (major optimization)
# This avoids loading from bytes repeatedly in the inner loop
all_encodings: dict[int, np.ndarray] = {}
all_qualities: dict[int, float] = {}
for face in all_faces:
# Pre-load encoding as numpy array
all_encodings[face.id] = np.frombuffer(face.encoding, dtype=np.float64)
# Pre-cache quality score
all_qualities[face.id] = float(face.quality_score) if face.quality_score is not None else 0.5
# Pre-load request face encodings and qualities
request_encodings: dict[int, np.ndarray] = {}
request_qualities: dict[int, float] = {}
for face in request_faces:
request_encodings[face.id] = all_encodings[face.id]
request_qualities[face.id] = all_qualities[face.id]
# Phase 1: Pre-normalize all encodings (major optimization)
# This avoids normalizing each encoding thousands of times
normalized_all_encodings: dict[int, np.ndarray] = {}
for face_id, enc in all_encodings.items():
# Normalize encoding once
norm = np.linalg.norm(enc)
if norm > 0:
normalized_all_encodings[face_id] = enc / (norm + 1e-8)
else:
# Handle zero-norm edge case
normalized_all_encodings[face_id] = enc
normalized_request_encodings: dict[int, np.ndarray] = {}
for face_id, enc in request_encodings.items():
# Normalize encoding once
norm = np.linalg.norm(enc)
if norm > 0:
normalized_request_encodings[face_id] = enc / (norm + 1e-8)
else:
# Handle zero-norm edge case
normalized_request_encodings[face_id] = enc
# Phase 2: Vectorized batch operations using numpy matrix multiplication
# Build matrices for vectorized computation
request_face_ids = list(request_encodings.keys())
all_face_ids = list(all_encodings.keys())
# Create matrices: (N, 512) and (M, 512) where 512 is encoding dimension
request_matrix = np.array([normalized_request_encodings[fid] for fid in request_face_ids])
all_matrix = np.array([normalized_all_encodings[fid] for fid in all_face_ids])
# Calculate all cosine similarities at once using matrix multiplication
# Shape: (N, 512) @ (512, M) = (N, M)
# This computes dot product of each request face with each database face
similarity_matrix = request_matrix @ all_matrix.T
# Clamp to valid range [-1, 1] for cosine similarity
similarity_matrix = np.clip(similarity_matrix, -1.0, 1.0)
# Convert to distance: distance = 1 - similarity
# Range: [0, 2] where 0 is perfect match
distance_matrix = 1.0 - similarity_matrix
# Calculate similarities: filter and process results
pairs: list[tuple[int, int, float, float]] = []
tolerance = DEFAULT_FACE_TOLERANCE
# Process results from the distance matrix
for i, face_id_1 in enumerate(request_face_ids):
quality_1 = request_qualities[face_id_1]
for j, face_id_2 in enumerate(all_face_ids):
# Skip comparing face to itself
if face_id_1 == face_id_2:
continue
# Get distance from pre-computed matrix
distance = float(distance_matrix[i, j])
# Get pre-cached quality score
quality_2 = all_qualities[face_id_2]
# Calculate adaptive tolerance
avg_quality = (quality_1 + quality_2) / 2
adaptive_tolerance = calculate_adaptive_tolerance(tolerance, avg_quality)
# Check if within tolerance
if distance <= adaptive_tolerance:
# Calculate calibrated confidence
confidence_pct = calibrate_confidence(distance, tolerance)
# Filter by minimum confidence
if confidence_pct >= min_confidence:
# Convert to similarity (0-1 range, higher = more similar)
similarity = 1.0 - (distance / 2.0) # Normalize distance to [0,1]
similarity = max(0.0, min(1.0, similarity)) # Clamp to [0,1]
pairs.append((face_id_1, face_id_2, similarity, confidence_pct))
return pairs
def find_auto_match_matches(
db: Session,
tolerance: float = 0.6,