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
+40
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,
+28
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."""
+168 -4
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,