feat: Enhance face identification with unique faces filter and improved API logging

This commit introduces a new feature in the Identify component that allows users to filter for unique faces only, hiding duplicates with ≥60% match confidence. The API has been updated to log calls to the get_similar_faces endpoint, including warnings for non-existent faces and information on the number of results returned. Additionally, the SimilarFaceItem schema has been updated to include the filename, improving data handling and user experience. Documentation and tests have been updated accordingly.
This commit is contained in:
tanyar09
2025-11-03 14:00:25 -05:00
parent 817e95337f
commit 59dc01118e
10 changed files with 481 additions and 70 deletions
+13 -2
View File
@@ -123,22 +123,33 @@ def get_unidentified_faces(
@router.get("/{face_id}/similar", response_model=SimilarFacesResponse)
def get_similar_faces(face_id: int, db: Session = Depends(get_db)) -> SimilarFacesResponse:
"""Return similar unidentified faces for a given face."""
import logging
logger = logging.getLogger(__name__)
logger.info(f"API: get_similar_faces called for face_id={face_id}")
# Validate face exists
base = db.query(Face).filter(Face.id == face_id).first()
if not base:
logger.warning(f"API: Face {face_id} not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Face {face_id} not found")
logger.info(f"API: Calling find_similar_faces for face_id={face_id}")
results = find_similar_faces(db, face_id)
logger.info(f"API: find_similar_faces returned {len(results)} results")
items = [
SimilarFaceItem(
id=f.id,
photo_id=f.photo_id,
similarity=sim,
similarity=confidence_pct / 100.0, # Convert confidence percentage to [0,1] for API compatibility
location=f.location,
quality_score=float(f.quality_score),
filename=f.photo.filename if f.photo else "unknown",
)
for f, sim in results
for f, distance, confidence_pct in results
]
logger.info(f"API: Returning {len(items)} items for face_id={face_id}")
return SimilarFacesResponse(base_face_id=face_id, items=items)
+1
View File
@@ -85,6 +85,7 @@ class SimilarFaceItem(BaseModel):
similarity: float
location: str
quality_score: float
filename: str
class SimilarFacesResponse(BaseModel):
+1
View File
@@ -42,3 +42,4 @@ class PeopleListResponse(BaseModel):
total: int
+263 -24
View File
@@ -11,7 +11,7 @@ from datetime import date
import numpy as np
from PIL import Image
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func
try:
@@ -700,44 +700,283 @@ def list_unidentified_faces(
return items, total
def compute_cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Compute cosine similarity for two float vectors in range [0,1]."""
denom = (np.linalg.norm(a) * np.linalg.norm(b))
if denom == 0:
return 0.0
return float(np.dot(a, b) / denom)
def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> float:
"""Calculate cosine distance between two face encodings, matching desktop exactly.
Desktop: _calculate_cosine_similarity returns distance (0 = identical, 2 = opposite)
This matches the desktop implementation exactly.
"""
try:
# Ensure encodings are numpy arrays
enc1 = np.array(encoding1).flatten()
enc2 = np.array(encoding2).flatten()
# Check if encodings have the same length
if len(enc1) != len(enc2):
return 2.0 # Maximum distance on mismatch
# Normalize encodings (matching desktop exactly)
norm1 = np.linalg.norm(enc1)
norm2 = np.linalg.norm(enc2)
if norm1 == 0 or norm2 == 0:
return 2.0
enc1_norm = enc1 / (norm1 + 1e-8)
enc2_norm = enc2 / (norm2 + 1e-8)
# Calculate cosine similarity
cosine_sim = np.dot(enc1_norm, enc2_norm)
# Clamp to valid range [-1, 1]
cosine_sim = np.clip(cosine_sim, -1.0, 1.0)
# Convert to distance (0 = identical, 2 = opposite)
# For consistency with face_recognition's distance metric
distance = 1.0 - cosine_sim # Range [0, 2], where 0 is perfect match
return float(distance)
except Exception as e:
return 2.0 # Maximum distance on error
def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) -> float:
"""Calculate adaptive tolerance based on face quality, matching desktop exactly."""
# Start with base tolerance
tolerance = base_tolerance
# Adjust based on face quality (higher quality = stricter tolerance)
quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1
tolerance *= quality_factor
# Ensure tolerance stays within reasonable bounds for DeepFace
return max(0.2, min(0.6, tolerance))
def calibrate_confidence(distance: float, tolerance: float = None) -> float:
"""Convert distance to calibrated confidence percentage, matching desktop exactly.
Uses empirical calibration method matching desktop _calibrate_confidence.
Args:
distance: Cosine distance (0 = identical, 2 = opposite)
tolerance: Matching tolerance threshold (default: DEFAULT_FACE_TOLERANCE)
Returns:
Calibrated confidence percentage (0-100) representing actual match probability
"""
from src.core.config import DEFAULT_FACE_TOLERANCE, USE_CALIBRATED_CONFIDENCE, CONFIDENCE_CALIBRATION_METHOD
if tolerance is None:
tolerance = DEFAULT_FACE_TOLERANCE
# Use configuration setting to determine calibration method (matching desktop)
if not USE_CALIBRATED_CONFIDENCE:
# Fallback to simple linear transformation (old behavior)
return max(0, min(100, (1 - distance) * 100))
if CONFIDENCE_CALIBRATION_METHOD == "linear":
# Simple linear transformation (old behavior)
return max(0, min(100, (1 - distance) * 100))
elif CONFIDENCE_CALIBRATION_METHOD == "sigmoid":
# Sigmoid-based calibration
normalized_distance = distance / tolerance
sigmoid_factor = 1 / (1 + np.exp(5 * (normalized_distance - 1)))
return max(1, min(100, sigmoid_factor * 100))
else: # "empirical" - default method (matching desktop exactly)
# Empirical calibration parameters for DeepFace ArcFace model
# These are derived from analysis of distance distributions for matching/non-matching pairs
# For distances well below threshold: high confidence
if distance <= tolerance * 0.5:
# Very close matches: exponential decay from 100%
confidence = 100 * np.exp(-distance * 2.5)
return min(100, max(95, confidence))
# For distances near threshold: moderate confidence
elif distance <= tolerance:
# Near-threshold matches: sigmoid-like curve
# Maps distance to probability based on empirical data
normalized_distance = (distance - tolerance * 0.5) / (tolerance * 0.5)
confidence = 95 - (normalized_distance * 40) # 95% to 55% range
return max(55, min(95, confidence))
# For distances above threshold: low confidence
elif distance <= tolerance * 1.5:
# Above threshold but not too far: rapid decay
normalized_distance = (distance - tolerance) / (tolerance * 0.5)
confidence = 55 - (normalized_distance * 35) # 55% to 20% range
return max(20, min(55, confidence))
# For very large distances: very low confidence
else:
# Very far matches: very low probability
confidence = 20 * np.exp(-(distance - tolerance * 1.5) * 1.5)
return max(1, min(20, confidence))
def find_similar_faces(
db: Session,
face_id: int,
limit: int = 20,
min_similarity: float = 0.4,
) -> List[Tuple[Face, float]]:
"""Find similar unidentified faces to the given face using cosine similarity.
Returns list of (face, similarity) sorted by similarity desc.
tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop
) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct)
"""Find similar faces matching desktop logic exactly.
Desktop flow:
1. Get ALL faces from database
2. Calculate distance for each
3. Filter by distance <= adaptive_tolerance
4. Return matches with photo info
Then _get_filtered_similar_faces:
1. Filters by person_id is None (unidentified)
2. Filters by confidence >= 40%
3. Sorts by distance
"""
from src.core.config import DEFAULT_FACE_TOLERANCE
from src.web.db.models import Photo
if tolerance is None:
tolerance = DEFAULT_FACE_TOLERANCE
# Get base face - matching desktop
base: Face = db.query(Face).filter(Face.id == face_id).first()
if not base:
print(f"DEBUG: Face {face_id} not found")
return []
base_enc = np.frombuffer(base.encoding, dtype=np.float32)
# Load base encoding - desktop uses float64, ArcFace has 512 dimensions
# Stored as float64: 512 * 8 bytes = 4096 bytes
base_enc = np.frombuffer(base.encoding, dtype=np.float64)
base_enc = base_enc.copy() # Make a copy to avoid buffer issues
# Debug encoding info
if face_id in [111, 113]:
print(f"DEBUG: Base face {face_id} encoding:")
print(f"DEBUG: - Type: {type(base.encoding)}, Length: {len(base.encoding) if hasattr(base.encoding, '__len__') else 'N/A'}")
print(f"DEBUG: - Shape: {base_enc.shape}")
print(f"DEBUG: - Dtype: {base_enc.dtype}")
print(f"DEBUG: - Has NaN: {np.isnan(base_enc).any()}")
print(f"DEBUG: - Has Inf: {np.isinf(base_enc).any()}")
print(f"DEBUG: - Min: {np.min(base_enc)}, Max: {np.max(base_enc)}")
print(f"DEBUG: - Norm: {np.linalg.norm(base_enc)}")
# Desktop uses 0.5 as default quality for target face (hardcoded, matching desktop exactly)
# Desktop: target_quality = 0.5 # Default quality for target face
base_quality = 0.5
# Debug for face ID 1
if face_id == 1:
print(f"DEBUG: Base face {face_id} quality (hardcoded): {base_quality}")
print(f"DEBUG: Base face {face_id} actual quality_score: {base.quality_score}")
print(f"DEBUG: Base face {face_id} photo_id: {base.photo_id}")
print(f"DEBUG: Base face {face_id} person_id: {base.person_id}")
# Compare against unidentified faces except itself
candidates: List[Face] = (
# Desktop: get ALL faces from database (matching get_all_face_encodings)
# Desktop find_similar_faces gets ALL faces, doesn't filter by photo_id
# Get all faces except itself, with photo loaded
all_faces: List[Face] = (
db.query(Face)
.filter(Face.person_id.is_(None), Face.id != face_id)
.options(joinedload(Face.photo))
.filter(Face.id != face_id)
.all()
)
scored: List[Tuple[Face, float]] = []
for f in candidates:
enc = np.frombuffer(f.encoding, dtype=np.float32)
sim = compute_cosine_similarity(base_enc, enc)
if sim >= min_similarity:
scored.append((f, sim))
print(f"DEBUG: Comparing face {face_id} with {len(all_faces)} other faces")
# Check if target face (111 or 113, or 1 for debugging) is in candidates
if face_id in [111, 113, 1]:
target_face_id = 113 if face_id == 111 else 111
target_face = next((f for f in all_faces if f.id == target_face_id), None)
if target_face:
print(f"DEBUG: Target face {target_face_id} found in candidates")
print(f"DEBUG: Target face {target_face_id} person_id: {target_face.person_id}")
print(f"DEBUG: Target face {target_face_id} quality: {target_face.quality_score}")
else:
print(f"DEBUG: Target face {target_face_id} NOT found in candidates!")
matches: List[Tuple[Face, float, float]] = []
for f in all_faces:
# Load other encoding - desktop uses float64, ArcFace has 512 dimensions
other_enc = np.frombuffer(f.encoding, dtype=np.float64)
other_enc = other_enc.copy() # Make a copy to avoid buffer issues
# Debug encoding info for comparison
if face_id in [111, 113] and f.id in [111, 113]:
print(f"DEBUG: Other face {f.id} encoding:")
print(f"DEBUG: - Shape: {other_enc.shape}")
print(f"DEBUG: - Has NaN: {np.isnan(other_enc).any()}")
print(f"DEBUG: - Has Inf: {np.isinf(other_enc).any()}")
print(f"DEBUG: - Min: {np.min(other_enc)}, Max: {np.max(other_enc)}")
print(f"DEBUG: - Norm: {np.linalg.norm(other_enc)}")
other_quality = float(f.quality_score) if f.quality_score is not None else 0.5
# Calculate adaptive tolerance based on both face qualities (matching desktop exactly)
avg_quality = (base_quality + other_quality) / 2
adaptive_tolerance = calculate_adaptive_tolerance(tolerance, avg_quality)
# Calculate distance (matching desktop exactly)
distance = calculate_cosine_distance(base_enc, other_enc)
# Special debug for faces 111, 113, and 1
if face_id in [111, 113, 1] and (f.id in [111, 113, 1] or (face_id == 1 and len(matches) < 5)):
print(f"DEBUG: ===== COMPARING FACE {face_id} WITH FACE {f.id} =====")
print(f"DEBUG: Base quality: {base_quality}, Other quality: {other_quality}")
print(f"DEBUG: Avg quality: {avg_quality:.4f}")
print(f"DEBUG: Base tolerance: {tolerance}, Adaptive tolerance: {adaptive_tolerance:.6f}")
print(f"DEBUG: Calculated distance: {distance:.6f}")
print(f"DEBUG: Distance <= adaptive_tolerance? {distance <= adaptive_tolerance} ({distance:.6f} <= {adaptive_tolerance:.6f})")
print(f"DEBUG: Base encoding shape: {base_enc.shape}, Other encoding shape: {other_enc.shape}")
print(f"DEBUG: Base encoding norm: {np.linalg.norm(base_enc):.4f}, Other encoding norm: {np.linalg.norm(other_enc):.4f}")
# Filter by distance <= adaptive_tolerance (matching desktop find_similar_faces)
if distance <= adaptive_tolerance:
# Get photo info (desktop does this in find_similar_faces)
if f.photo:
# Calculate calibrated confidence (matching desktop _get_filtered_similar_faces)
confidence_pct = calibrate_confidence(distance, DEFAULT_FACE_TOLERANCE)
# Desktop _get_filtered_similar_faces filters by:
# 1. person_id is None (unidentified)
# 2. confidence >= 40%
is_unidentified = f.person_id is None
# Special debug for faces 111, 113, and 1
if face_id in [111, 113, 1] and (f.id in [111, 113, 1] or (face_id == 1 and len(matches) < 10)):
print(f"DEBUG: === AFTER DISTANCE FILTER FOR FACE {f.id} ===")
print(f"DEBUG: Confidence calculated: {confidence_pct:.2f}%")
print(f"DEBUG: Is unidentified: {is_unidentified} (person_id={f.person_id})")
print(f"DEBUG: Confidence >= 40? {confidence_pct >= 40}")
print(f"DEBUG: Will include? {is_unidentified and confidence_pct >= 40}")
if is_unidentified and confidence_pct >= 40:
# Return calibrated confidence percentage (matching desktop)
# Desktop displays confidence_pct directly from _get_calibrated_confidence
matches.append((f, distance, confidence_pct))
if face_id in [111, 113, 1] or (face_id == 1 and len(matches) < 10):
print(f"DEBUG: ✓✓✓ MATCH FOUND: face {f.id} (distance={distance:.6f}, confidence={confidence_pct:.2f}%, adaptive_tol={adaptive_tolerance:.6f}) ✓✓✓")
else:
if face_id in [111, 113, 1] or (face_id == 1 and len(matches) < 10):
print(f"DEBUG: ✗✗✗ Face {f.id} FILTERED OUT:")
print(f"DEBUG: - unidentified: {is_unidentified} (person_id={f.person_id})")
print(f"DEBUG: - confidence: {confidence_pct:.2f}% (need >= 40%)")
print(f"DEBUG: - distance: {distance:.6f}, adaptive_tolerance: {adaptive_tolerance:.6f}")
else:
if face_id == 1 and len(matches) < 5:
print(f"DEBUG: ✗ Face {f.id} has no photo")
else:
if face_id == 1 and len(matches) < 10:
print(f"DEBUG: ✗ Face {f.id} distance {distance:.6f} > tolerance {adaptive_tolerance:.6f} (failed distance filter)")
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:limit]
# Sort by distance (lower is better) - matching desktop
matches.sort(key=lambda x: x[1])
print(f"DEBUG: Returning {len(matches)} matches for face_id={face_id}")
# Limit results
return matches[:limit]