diff --git a/admin-frontend/.env_example b/admin-frontend/.env_example index 58c6cb3..e9e5bd3 100644 --- a/admin-frontend/.env_example +++ b/admin-frontend/.env_example @@ -3,4 +3,8 @@ # Backend API base URL (must be reachable from the browser) VITE_API_URL= +# Enable developer mode (shows additional debug info and options) +# Set to "true" to enable, leave empty or unset to disable +VITE_DEVELOPER_MODE= + diff --git a/admin-frontend/public/enable-dev-mode.html b/admin-frontend/public/enable-dev-mode.html new file mode 100644 index 0000000..c4e659a --- /dev/null +++ b/admin-frontend/public/enable-dev-mode.html @@ -0,0 +1,67 @@ + + + + Enable Developer Mode + + + +
+

Enable Developer Mode

+

Click the button below to enable Developer Mode for PunimTag.

+ +
+
+ + + + + diff --git a/admin-frontend/src/api/faces.ts b/admin-frontend/src/api/faces.ts index e173cd1..af9bcf0 100644 --- a/admin-frontend/src/api/faces.ts +++ b/admin-frontend/src/api/faces.ts @@ -39,11 +39,27 @@ export interface SimilarFaceItem { quality_score: number filename: string pose_mode?: string + debug_info?: { + encoding_length: number + encoding_min: number + encoding_max: number + encoding_mean: number + encoding_std: number + encoding_first_10: number[] + } } export interface SimilarFacesResponse { base_face_id: number items: SimilarFaceItem[] + debug_info?: { + encoding_length: number + encoding_min: number + encoding_max: number + encoding_mean: number + encoding_std: number + encoding_first_10: number[] + } } export interface FaceSimilarityPair { @@ -97,6 +113,7 @@ export interface AutoMatchRequest { tolerance: number auto_accept?: boolean auto_accept_threshold?: number + use_distance_based_thresholds?: boolean } export interface AutoMatchFaceItem { @@ -217,11 +234,25 @@ export const facesApi = { }) return response.data }, - getSimilar: async (faceId: number, includeExcluded?: boolean): Promise => { + getSimilar: async (faceId: number, includeExcluded?: boolean, debug?: boolean): Promise => { const response = await apiClient.get(`/api/v1/faces/${faceId}/similar`, { - params: { include_excluded: includeExcluded || false }, + params: { include_excluded: includeExcluded || false, debug: debug || false }, }) - return response.data + const data = response.data + + // Log debug info to browser console if available + if (debug && data.debug_info) { + console.log('🔍 Base Face Encoding Debug Info:', data.debug_info) + } + if (debug && data.items) { + data.items.forEach((item, index) => { + if (item.debug_info) { + console.log(`🔍 Similar Face ${index + 1} (ID: ${item.id}) Encoding Debug Info:`, item.debug_info) + } + }) + } + + return data }, batchSimilarity: async (request: BatchSimilarityRequest): Promise => { const response = await apiClient.post('/api/v1/faces/batch-similarity', request) @@ -251,6 +282,7 @@ export const facesApi = { }, getAutoMatchPeople: async (params?: { filter_frontal_only?: boolean + tolerance?: number }): Promise => { const response = await apiClient.get('/api/v1/faces/auto-match/people', { params, diff --git a/admin-frontend/src/context/DeveloperModeContext.tsx b/admin-frontend/src/context/DeveloperModeContext.tsx index 25a1bfa..426be3e 100644 --- a/admin-frontend/src/context/DeveloperModeContext.tsx +++ b/admin-frontend/src/context/DeveloperModeContext.tsx @@ -1,32 +1,17 @@ -import { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import { createContext, useContext, ReactNode } from 'react' interface DeveloperModeContextType { isDeveloperMode: boolean - setDeveloperMode: (enabled: boolean) => void } const DeveloperModeContext = createContext(undefined) -const STORAGE_KEY = 'punimtag_developer_mode' +// Check environment variable (set at build time) +const isDeveloperMode = import.meta.env.VITE_DEVELOPER_MODE === 'true' export function DeveloperModeProvider({ children }: { children: ReactNode }) { - const [isDeveloperMode, setIsDeveloperMode] = useState(false) - - // Load from localStorage on mount - useEffect(() => { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored !== null) { - setIsDeveloperMode(stored === 'true') - } - }, []) - - const setDeveloperMode = (enabled: boolean) => { - setIsDeveloperMode(enabled) - localStorage.setItem(STORAGE_KEY, enabled.toString()) - } - return ( - + {children} ) diff --git a/admin-frontend/src/pages/AutoMatch.tsx b/admin-frontend/src/pages/AutoMatch.tsx index 6ecde7b..45a0b17 100644 --- a/admin-frontend/src/pages/AutoMatch.tsx +++ b/admin-frontend/src/pages/AutoMatch.tsx @@ -7,7 +7,8 @@ import peopleApi, { Person } from '../api/people' import { apiClient } from '../api/client' import { useDeveloperMode } from '../context/DeveloperModeContext' -const DEFAULT_TOLERANCE = 0.6 +const DEFAULT_TOLERANCE = 0.6 // Default for regular auto-match (more lenient) +const RUN_AUTO_MATCH_TOLERANCE = 0.5 // Tolerance for Run auto-match button (stricter) export default function AutoMatch() { const { isDeveloperMode } = useDeveloperMode() @@ -16,8 +17,8 @@ export default function AutoMatch() { const [isActive, setIsActive] = useState(false) const [people, setPeople] = useState([]) const [filteredPeople, setFilteredPeople] = useState([]) - // Store matches separately, keyed by person_id - const [matchesCache, setMatchesCache] = useState>({}) + // Store matches separately, keyed by person_id_tolerance (composite key) + const [matchesCache, setMatchesCache] = useState>({}) const [currentIndex, setCurrentIndex] = useState(0) const [searchQuery, setSearchQuery] = useState('') const [allPeople, setAllPeople] = useState([]) @@ -44,6 +45,8 @@ export default function AutoMatch() { const [stateRestored, setStateRestored] = useState(false) // Track if initial restoration is complete (prevents reload effects from firing during restoration) const restorationCompleteRef = useRef(false) + // Track current tolerance in a ref to avoid stale closures + const toleranceRef = useRef(tolerance) const currentPerson = useMemo(() => { const activePeople = filteredPeople.length > 0 ? filteredPeople : people @@ -52,30 +55,49 @@ export default function AutoMatch() { const currentMatches = useMemo(() => { if (!currentPerson) return [] - return matchesCache[currentPerson.person_id] || [] - }, [currentPerson, matchesCache]) + // Use ref tolerance to ensure we always get the current tolerance value + const currentTolerance = toleranceRef.current + const cacheKey = `${currentPerson.person_id}_${currentTolerance}` + return matchesCache[cacheKey] || [] + }, [currentPerson, matchesCache, tolerance]) // Keep tolerance in deps to trigger recalculation when it changes // Check if any matches are selected const hasSelectedMatches = useMemo(() => { - return currentMatches.some(match => selectedFaces[match.id] === true) + return currentMatches.some((match: AutoMatchFaceItem) => selectedFaces[match.id] === true) }, [currentMatches, selectedFaces]) + // Update tolerance ref whenever tolerance changes + useEffect(() => { + toleranceRef.current = tolerance + }, [tolerance]) + // Load matches for a specific person (lazy loading) - const loadPersonMatches = async (personId: number) => { - // Skip if already cached - if (matchesCache[personId]) { - return + const loadPersonMatches = async (personId: number, currentTolerance?: number) => { + // Use provided tolerance, or ref tolerance (always current), or state tolerance as fallback + const toleranceToUse = currentTolerance !== undefined ? currentTolerance : toleranceRef.current + // Create cache key that includes tolerance to avoid stale matches + const cacheKey = `${personId}_${toleranceToUse}` + + // Double-check: if tolerance changed, don't use cached value + if (toleranceToUse !== toleranceRef.current) { + // Tolerance changed since this was called, don't use cache + // Will fall through to load fresh matches + } else { + // Skip if already cached for this tolerance + if (matchesCache[cacheKey]) { + return + } } try { const response = await facesApi.getAutoMatchPersonMatches(personId, { - tolerance, + tolerance: toleranceToUse, filter_frontal_only: false }) setMatchesCache(prev => ({ ...prev, - [personId]: response.matches + [cacheKey]: response.matches })) // Update total_matches in people list @@ -106,9 +128,10 @@ export default function AutoMatch() { } catch (error) { console.error('Failed to load matches for person:', error) // Set empty matches on error, and remove person from list + // Use composite cache key setMatchesCache(prev => ({ ...prev, - [personId]: [] + [cacheKey]: [] })) // Remove person if matches failed to load (assume no matches) setPeople(prev => prev.filter(p => p.person_id !== personId)) @@ -118,7 +141,10 @@ export default function AutoMatch() { // Shared function for auto-load and refresh (loads people list only - fast) const loadAutoMatch = async (clearState: boolean = false) => { - if (tolerance < 0 || tolerance > 1) { + // Use ref to get current tolerance (avoids stale closure) + const currentTolerance = toleranceRef.current + + if (currentTolerance < 0 || currentTolerance > 1) { return } @@ -128,12 +154,30 @@ export default function AutoMatch() { // Clear saved state if explicitly requested (Refresh button) if (clearState) { sessionStorage.removeItem(STATE_KEY) - setMatchesCache({}) // Clear matches cache + // Clear ALL cache entries + setMatchesCache({}) + } else { + // Also clear any cache entries that don't match current tolerance (even if not explicitly clearing) + setMatchesCache(prev => { + const cleaned: Record = {} + // Only keep cache entries that match current tolerance + Object.keys(prev).forEach(key => { + const parts = key.split('_') + if (parts.length >= 2) { + const cachedTolerance = parseFloat(parts[parts.length - 1]) + if (!isNaN(cachedTolerance) && cachedTolerance === currentTolerance) { + cleaned[key] = prev[key] + } + } + }) + return cleaned + }) } // Load people list only (fast - no match calculations) const response = await facesApi.getAutoMatchPeople({ - filter_frontal_only: false + filter_frontal_only: false, + tolerance: currentTolerance }) if (response.people.length === 0) { @@ -154,9 +198,9 @@ export default function AutoMatch() { setOriginalSelectedFaces({}) setIsActive(true) - // Load matches for first person immediately + // Load matches for first person immediately with current tolerance if (response.people.length > 0) { - await loadPersonMatches(response.people[0].person_id) + await loadPersonMatches(response.people[0].person_id, currentTolerance) } } catch (error) { console.error('Auto-match failed:', error) @@ -261,7 +305,7 @@ export default function AutoMatch() { const matchesCacheRef = useRef(matchesCache) const isActiveRef = useRef(isActive) const hasNoResultsRef = useRef(hasNoResults) - const toleranceRef = useRef(tolerance) + // Note: toleranceRef is already declared above, don't redeclare // Update refs whenever state changes useEffect(() => { @@ -355,7 +399,15 @@ export default function AutoMatch() { if (initialLoadRef.current && restorationCompleteRef.current) { // Clear matches cache when tolerance changes (matches depend on tolerance) setMatchesCache({}) - loadAutoMatch() + // Clear people list to force fresh load with new tolerance + setPeople([]) + setFilteredPeople([]) + setSelectedFaces({}) + setOriginalSelectedFaces({}) + setCurrentIndex(0) + setIsActive(false) + // Reload with new tolerance + loadAutoMatch(true) // Pass true to clear sessionStorage as well } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tolerance]) @@ -400,9 +452,10 @@ export default function AutoMatch() { setBusy(true) try { const response = await facesApi.autoMatch({ - tolerance, + tolerance: RUN_AUTO_MATCH_TOLERANCE, // Use 0.5 for Run auto-match button (stricter) auto_accept: true, - auto_accept_threshold: autoAcceptThreshold + auto_accept_threshold: autoAcceptThreshold, + use_distance_based_thresholds: true // Enable distance-based thresholds for Run auto-match button }) // Show summary if auto-accept was performed @@ -457,7 +510,7 @@ export default function AutoMatch() { const selectAll = () => { const newSelected: Record = {} - currentMatches.forEach(match => { + currentMatches.forEach((match: AutoMatchFaceItem) => { newSelected[match.id] = true }) setSelectedFaces(newSelected) @@ -465,7 +518,7 @@ export default function AutoMatch() { const clearAll = () => { const newSelected: Record = {} - currentMatches.forEach(match => { + currentMatches.forEach((match: AutoMatchFaceItem) => { newSelected[match.id] = false }) setSelectedFaces(newSelected) @@ -477,14 +530,14 @@ export default function AutoMatch() { setSaving(true) try { const faceIds = currentMatches - .filter(match => selectedFaces[match.id] === true) - .map(match => match.id) + .filter((match: AutoMatchFaceItem) => selectedFaces[match.id] === true) + .map((match: AutoMatchFaceItem) => match.id) await peopleApi.acceptMatches(currentPerson.person_id, faceIds) // Update original selected faces to current state const newOriginal: Record = {} - currentMatches.forEach(match => { + currentMatches.forEach((match: AutoMatchFaceItem) => { newOriginal[match.id] = selectedFaces[match.id] || false }) setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal })) @@ -498,33 +551,45 @@ export default function AutoMatch() { } } - // Load matches when current person changes (lazy loading) + // Load matches when current person changes OR tolerance changes (lazy loading) useEffect(() => { if (currentPerson && restorationCompleteRef.current) { - loadPersonMatches(currentPerson.person_id) + // Always use ref tolerance (always current) to avoid stale matches + const currentTolerance = toleranceRef.current + + // Force reload when tolerance changes - clear cache for this person first + const cacheKey = `${currentPerson.person_id}_${currentTolerance}` + if (!matchesCache[cacheKey]) { + // Only load if not already cached for current tolerance + loadPersonMatches(currentPerson.person_id, currentTolerance) + } // 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) + const nextCacheKey = `${nextPerson.person_id}_${currentTolerance}` + if (!matchesCache[nextCacheKey]) { + loadPersonMatches(nextPerson.person_id, currentTolerance) + } } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPerson?.person_id, currentIndex]) + }, [currentPerson?.person_id, currentIndex, tolerance]) // Restore selected faces when navigating to a different person useEffect(() => { if (currentPerson) { - const matches = matchesCache[currentPerson.person_id] || [] + const cacheKey = `${currentPerson.person_id}_${tolerance}` + const matches = matchesCache[cacheKey] || [] const restored: Record = {} - matches.forEach(match => { + matches.forEach((match: AutoMatchFaceItem) => { restored[match.id] = originalSelectedFaces[match.id] || false }) setSelectedFaces(restored) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache]) + }, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache, tolerance]) const goBack = () => { if (currentIndex > 0) { @@ -695,7 +760,7 @@ export default function AutoMatch() { )}
- ℹ️ Auto-Match Criteria: Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy. + ℹ️ Auto-Match Criteria: Only faces with similarity higher than 85% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
diff --git a/admin-frontend/src/pages/Help.tsx b/admin-frontend/src/pages/Help.tsx index b1df125..dd6b035 100644 --- a/admin-frontend/src/pages/Help.tsx +++ b/admin-frontend/src/pages/Help.tsx @@ -474,7 +474,7 @@ function AutoMatchPageHelp({ onBack }: { onBack: () => void }) {
  • Click "🚀 Run Auto-Match" button
  • The system will automatically match unidentified faces to identified people based on:
      -
    • Similarity higher than 70%
    • +
    • Similarity higher than 85%
    • Picture quality higher than 50%
    • Profile faces are excluded for better accuracy
    diff --git a/admin-frontend/src/pages/Identify.tsx b/admin-frontend/src/pages/Identify.tsx index 1adc2ec..72a92d0 100644 --- a/admin-frontend/src/pages/Identify.tsx +++ b/admin-frontend/src/pages/Identify.tsx @@ -348,7 +348,8 @@ export default function Identify() { return } try { - const res = await facesApi.getSimilar(faceId, includeExcludedFaces) + // Enable debug mode to log encoding info to browser console + const res = await facesApi.getSimilar(faceId, includeExcludedFaces, true) setSimilar(res.items || []) setSelectedSimilar({}) } catch (error) { diff --git a/admin-frontend/src/pages/Settings.tsx b/admin-frontend/src/pages/Settings.tsx index 933cb8a..75edc6c 100644 --- a/admin-frontend/src/pages/Settings.tsx +++ b/admin-frontend/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { useDeveloperMode } from '../context/DeveloperModeContext' export default function Settings() { - const { isDeveloperMode, setDeveloperMode } = useDeveloperMode() + const { isDeveloperMode } = useDeveloperMode() return (
    @@ -11,24 +11,23 @@ export default function Settings() {
    -
    - +
    + {isDeveloperMode ? 'Enabled' : 'Disabled'} +
    diff --git a/admin-frontend/src/vite-env.d.ts b/admin-frontend/src/vite-env.d.ts index 9134121..26726e8 100644 --- a/admin-frontend/src/vite-env.d.ts +++ b/admin-frontend/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string + readonly VITE_DEVELOPER_MODE?: string } interface ImportMeta { diff --git a/backend/api/faces.py b/backend/api/faces.py index 64f9f4f..61d4e83 100644 --- a/backend/api/faces.py +++ b/backend/api/faces.py @@ -90,9 +90,9 @@ def process_faces(request: ProcessFacesRequest) -> ProcessFacesResponse: job_timeout="1h", # Long timeout for face processing ) - print(f"[Faces API] Enqueued face processing job: {job.id}") - print(f"[Faces API] Job status: {job.get_status()}") - print(f"[Faces API] Queue length: {len(queue)}") + import logging + logger = logging.getLogger(__name__) + logger.info(f"Enqueued face processing job: {job.id}, status: {job.get_status()}, queue length: {len(queue)}") return ProcessFacesResponse( job_id=job.id, @@ -197,12 +197,14 @@ def get_unidentified_faces( def get_similar_faces( face_id: int, include_excluded: bool = Query(False, description="Include excluded faces in results"), + debug: bool = Query(False, description="Include debug information (encoding stats) in response"), db: Session = Depends(get_db) ) -> SimilarFacesResponse: """Return similar unidentified faces for a given face.""" import logging + import numpy as np logger = logging.getLogger(__name__) - logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}") + logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}, debug={debug}") # Validate face exists base = db.query(Face).filter(Face.id == face_id).first() @@ -210,8 +212,23 @@ def get_similar_faces( 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") + # Load base encoding for debug info if needed + base_debug_info = None + if debug: + from backend.services.face_service import load_face_encoding + base_enc = load_face_encoding(base.encoding) + base_debug_info = { + "encoding_length": len(base_enc), + "encoding_min": float(np.min(base_enc)), + "encoding_max": float(np.max(base_enc)), + "encoding_mean": float(np.mean(base_enc)), + "encoding_std": float(np.std(base_enc)), + "encoding_first_10": [float(x) for x in base_enc[:10].tolist()], + } + logger.info(f"API: Calling find_similar_faces for face_id={face_id}, include_excluded={include_excluded}") - results = find_similar_faces(db, face_id, include_excluded=include_excluded) + # Use 0.6 tolerance for Identify People (more lenient for manual review) + results = find_similar_faces(db, face_id, tolerance=0.6, include_excluded=include_excluded, debug=debug) logger.info(f"API: find_similar_faces returned {len(results)} results") items = [ @@ -223,12 +240,13 @@ def get_similar_faces( quality_score=float(f.quality_score), filename=f.photo.filename if f.photo else "unknown", pose_mode=getattr(f, "pose_mode", None) or "frontal", + debug_info=debug_info if debug else None, ) - for f, distance, confidence_pct in results + for f, distance, confidence_pct, debug_info in results ] logger.info(f"API: Returning {len(items)} items for face_id={face_id}") - return SimilarFacesResponse(base_face_id=face_id, items=items) + return SimilarFacesResponse(base_face_id=face_id, items=items, debug_info=base_debug_info) @router.post("/batch-similarity", response_model=BatchSimilarityResponse) @@ -246,10 +264,12 @@ def get_batch_similarities( logger.info(f"API: batch_similarity called for {len(request.face_ids)} faces") # Calculate similarities between all pairs + # Use 0.6 tolerance for Identify People (more lenient for manual review) pairs = calculate_batch_similarities( db, request.face_ids, min_confidence=request.min_confidence, + tolerance=0.6, ) # Convert to response format @@ -435,7 +455,9 @@ def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response: except HTTPException: raise except Exception as e: - print(f"[Faces API] get_face_crop error for face {face_id}: {e}") + import logging + logger = logging.getLogger(__name__) + logger.error(f"get_face_crop error for face {face_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to extract face crop: {str(e)}", @@ -607,10 +629,12 @@ def auto_match_faces( # Find matches for all identified people # Filter by frontal reference faces if auto_accept enabled + # Use distance-based thresholds only when auto_accept is enabled (Run auto-match button) matches_data = find_auto_match_matches( db, tolerance=request.tolerance, - filter_frontal_only=request.auto_accept + filter_frontal_only=request.auto_accept, + use_distance_based_thresholds=request.use_distance_based_thresholds or request.auto_accept ) # If auto_accept enabled, process matches automatically @@ -644,7 +668,9 @@ def auto_match_faces( ) auto_accepted_faces += identified_count except Exception as e: - print(f"Error auto-accepting matches for person {person_id}: {e}") + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error auto-accepting matches for person {person_id}: {e}") if not matches_data: return AutoMatchResponse( @@ -747,7 +773,7 @@ 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"), + tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"), db: Session = Depends(get_db), ) -> AutoMatchPeopleResponse: """Get list of people for auto-match (without matches) - fast initial load. @@ -810,7 +836,7 @@ def get_auto_match_people( @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"), + tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"), filter_frontal_only: bool = Query(False, description="Only return frontal/tilted faces"), db: Session = Depends(get_db), ) -> AutoMatchPersonMatchesResponse: diff --git a/backend/config.py b/backend/config.py index 0d6c576..2cf11e5 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,8 +22,13 @@ MIN_FACE_SIZE = 40 MAX_FACE_SIZE = 1500 # Matching tolerance and calibration options -DEFAULT_FACE_TOLERANCE = 0.6 +DEFAULT_FACE_TOLERANCE = 0.5 # Lowered from 0.6 for stricter matching USE_CALIBRATED_CONFIDENCE = True CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid" +# Auto-match face size filtering +# Minimum face size as percentage of image area (0.5% = 0.005) +# Faces smaller than this are excluded from auto-match to avoid generic encodings +MIN_AUTO_MATCH_FACE_SIZE_RATIO = 0.005 # 0.5% of image area + diff --git a/backend/schemas/faces.py b/backend/schemas/faces.py index cc3f3d4..19bb8b1 100644 --- a/backend/schemas/faces.py +++ b/backend/schemas/faces.py @@ -89,6 +89,7 @@ class SimilarFaceItem(BaseModel): quality_score: float filename: str pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)") + debug_info: Optional[dict] = Field(None, description="Debug information (encoding stats) when debug mode is enabled") class SimilarFacesResponse(BaseModel): @@ -98,6 +99,7 @@ class SimilarFacesResponse(BaseModel): base_face_id: int items: list[SimilarFaceItem] + debug_info: Optional[dict] = Field(None, description="Debug information (base face encoding stats) when debug mode is enabled") class BatchSimilarityRequest(BaseModel): @@ -212,9 +214,10 @@ class AutoMatchRequest(BaseModel): model_config = ConfigDict(protected_namespaces=()) - tolerance: float = Field(0.6, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)") + tolerance: float = Field(0.5, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)") auto_accept: bool = Field(False, description="Enable automatic acceptance of matching faces") auto_accept_threshold: float = Field(70.0, ge=0.0, le=100.0, description="Similarity threshold for auto-acceptance (0-100%)") + use_distance_based_thresholds: bool = Field(False, description="Use distance-based confidence thresholds (stricter for borderline distances)") class AutoMatchFaceItem(BaseModel): diff --git a/backend/services/face_service.py b/backend/services/face_service.py index 761fb7b..334c409 100644 --- a/backend/services/face_service.py +++ b/backend/services/face_service.py @@ -6,6 +6,7 @@ import json import os import tempfile import time +from pathlib import Path from typing import Callable, Optional, Tuple, List, Dict from datetime import date @@ -34,6 +35,7 @@ from backend.config import ( MAX_FACE_SIZE, MIN_FACE_CONFIDENCE, MIN_FACE_SIZE, + MIN_AUTO_MATCH_FACE_SIZE_RATIO, USE_CALIBRATED_CONFIDENCE, ) from src.utils.exif_utils import EXIFOrientationHandler @@ -526,7 +528,9 @@ def process_photo_faces( _print_with_stderr(f"[FaceService] Debug - face_confidence value: {face_confidence}") _print_with_stderr(f"[FaceService] Debug - result['face_confidence'] exists: {'face_confidence' in result}") - encoding = np.array(result['embedding']) + # DeepFace returns float32 embeddings, but we store as float64 for consistency + # Convert to float64 explicitly to match how we read them back + encoding = np.array(result['embedding'], dtype=np.float64) # Convert to location format (JSON string like desktop version) location = { @@ -627,17 +631,21 @@ def process_photo_faces( if face_width is None: face_width = matched_pose_face.get('face_width') pose_mode = PoseDetector.classify_pose_mode( - yaw_angle, pitch_angle, roll_angle, face_width + yaw_angle, pitch_angle, roll_angle, face_width, landmarks ) else: - # Can't calculate yaw, use face_width + # Can't calculate yaw, use face_width and landmarks for single-eye detection pose_mode = PoseDetector.classify_pose_mode( - yaw_angle, pitch_angle, roll_angle, face_width + yaw_angle, pitch_angle, roll_angle, face_width, landmarks ) elif face_width is not None: # No landmarks available, use face_width only + # Try to get landmarks from matched_pose_face if available + landmarks_for_classification = None + if matched_pose_face: + landmarks_for_classification = matched_pose_face.get('landmarks') pose_mode = PoseDetector.classify_pose_mode( - yaw_angle, pitch_angle, roll_angle, face_width + yaw_angle, pitch_angle, roll_angle, face_width, landmarks_for_classification ) else: # No landmarks and no face_width, use default @@ -1669,6 +1677,47 @@ def list_unidentified_faces( return items, total +def load_face_encoding(encoding_bytes: bytes) -> np.ndarray: + """Load face encoding from bytes, auto-detecting dtype (float32 or float64). + + ArcFace encodings are 512 dimensions: + - float32: 512 * 4 bytes = 2048 bytes + - float64: 512 * 8 bytes = 4096 bytes + + Args: + encoding_bytes: Raw encoding bytes from database + + Returns: + numpy array of encoding (always float64 for consistency) + """ + encoding_size = len(encoding_bytes) + + # Auto-detect dtype based on size + if encoding_size == 2048: + # float32 encoding (old format) + encoding = np.frombuffer(encoding_bytes, dtype=np.float32) + # Convert to float64 for consistency + return encoding.astype(np.float64) + elif encoding_size == 4096: + # float64 encoding (new format) + return np.frombuffer(encoding_bytes, dtype=np.float64) + else: + # Unexpected size - try float64 first, fallback to float32 + # This handles edge cases or future changes + try: + encoding = np.frombuffer(encoding_bytes, dtype=np.float64) + if len(encoding) == 512: + return encoding + except: + pass + # Fallback to float32 + encoding = np.frombuffer(encoding_bytes, dtype=np.float32) + if len(encoding) == 512: + return encoding.astype(np.float64) + else: + raise ValueError(f"Unexpected encoding size: {encoding_size} bytes (expected 2048 or 4096)") + + def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> float: """Calculate cosine distance between two face encodings, matching desktop exactly. @@ -1701,7 +1750,6 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f # Normalize encodings (matching desktop exactly) norm1 = np.linalg.norm(enc1) norm2 = np.linalg.norm(enc2) - if norm1 == 0 or norm2 == 0: return 2.0 @@ -1724,6 +1772,32 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f return 2.0 # Maximum distance on error +def get_distance_based_min_confidence(distance: float) -> float: + """Get minimum confidence threshold based on distance. + + For borderline distances, require higher confidence to reduce false positives. + This is used only when use_distance_based_thresholds=True (e.g., in auto-match). + + Args: + distance: Cosine distance between faces (0 = identical, 2 = opposite) + + Returns: + Minimum confidence percentage (0-100) required for this distance + """ + if distance <= 0.15: + # Very close matches: standard threshold + return 50.0 + elif distance <= 0.20: + # Borderline matches: require higher confidence + return 70.0 + elif distance <= 0.25: + # Near threshold: require very high confidence + return 85.0 + else: + # Far matches: require extremely high confidence + return 95.0 + + 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 @@ -1734,7 +1808,10 @@ def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) -> tolerance *= quality_factor # Ensure tolerance stays within reasonable bounds for DeepFace - return max(0.2, min(0.6, tolerance)) + # Allow tolerance down to 0.0 (user can set very strict matching) + # Allow tolerance up to 1.0 (matching API validation range) + # The quality factor can increase tolerance up to 1.1x, so cap at 1.0 to stay within API limits + return max(0.0, min(1.0, tolerance)) def calibrate_confidence(distance: float, tolerance: float = None) -> float: @@ -1768,27 +1845,34 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float: 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 + # Moderate calibration: stricter than original but not too strict + + # For very close distances (< 0.12): very high confidence + if distance <= 0.12: + # Very close matches: exponential decay from 100% + confidence = 100 * np.exp(-distance * 2.8) + return min(100, max(92, confidence)) # 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)) + elif distance <= tolerance * 0.5: + # Close matches: exponential decay + confidence = 100 * np.exp(-distance * 2.6) + return min(92, max(82, 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)) + confidence = 82 - (normalized_distance * 32) # 82% to 50% range + return max(50, min(82, 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)) + confidence = 50 - (normalized_distance * 30) # 50% to 20% range + return max(20, min(50, confidence)) # For very large distances: very low confidence else: @@ -1797,6 +1881,46 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float: return max(1, min(20, confidence)) +def _calculate_face_size_ratio(face: Face, photo: Photo) -> float: + """Calculate face size as ratio of image area. + + Args: + face: Face model with location + photo: Photo model (needed for path to load image dimensions) + + Returns: + Face size ratio (0.0-1.0), or 0.0 if cannot calculate + """ + try: + import json + from PIL import Image + + # Parse location + location = json.loads(face.location) if isinstance(face.location, str) else face.location + face_w = location.get('w', 0) + face_h = location.get('h', 0) + face_area = face_w * face_h + + if face_area == 0: + return 0.0 + + # Load image to get dimensions + photo_path = Path(photo.path) + if not photo_path.exists(): + return 0.0 + + img = Image.open(photo_path) + img_width, img_height = img.size + image_area = img_width * img_height + + if image_area == 0: + return 0.0 + + return face_area / image_area + except Exception: + return 0.0 + + def _is_acceptable_pose_for_auto_match(pose_mode: str) -> bool: """Check if pose_mode is acceptable for auto-match (frontal or tilted, but not profile). @@ -1836,10 +1960,14 @@ def find_similar_faces( db: Session, face_id: int, limit: int = 20000, # Very high default limit - effectively unlimited - tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop + tolerance: float = 0.5, # DEFAULT_FACE_TOLERANCE filter_frontal_only: bool = False, # New: Only return frontal or tilted faces (not profile) include_excluded: bool = False, # Include excluded faces in results -) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct) + filter_small_faces: bool = False, # Filter out small faces (for auto-match) + min_face_size_ratio: float = 0.005, # Minimum face size ratio (0.5% of image) + debug: bool = False, # Include debug information (encoding stats) + use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds (for auto-match) +) -> List[Tuple[Face, float, float, dict | None]]: # Returns (face, distance, confidence_pct, debug_info) """Find similar faces matching desktop logic exactly. Desktop flow: @@ -1866,32 +1994,48 @@ def find_similar_faces( base: Face = db.query(Face).filter(Face.id == face_id).first() if not base: return [] + - # 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) + # Load base encoding - auto-detect dtype (supports both float32 and float64) + base_enc = load_face_encoding(base.encoding) base_enc = base_enc.copy() # Make a copy to avoid buffer issues - # 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 + # Use actual quality score of the reference face, defaulting to 0.5 if not set + # This ensures adaptive tolerance is calculated correctly based on the actual face quality + base_quality = float(base.quality_score) if base.quality_score is not None else 0.5 # 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 + # However, for auto-match, we should exclude faces from the same photo to avoid + # duplicate detections of the same face (same encoding stored multiple times) + # Get all faces except itself and faces from the same photo, with photo loaded all_faces: List[Face] = ( db.query(Face) .options(joinedload(Face.photo)) .filter(Face.id != face_id) + .filter(Face.photo_id != base.photo_id) # Exclude faces from same photo .all() ) 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) + # Load other encoding - auto-detect dtype (supports both float32 and float64) + other_enc = load_face_encoding(f.encoding) other_enc = other_enc.copy() # Make a copy to avoid buffer issues + # Calculate debug info if requested + debug_info = None + if debug: + debug_info = { + "encoding_length": len(other_enc), + "encoding_min": float(np.min(other_enc)), + "encoding_max": float(np.max(other_enc)), + "encoding_mean": float(np.mean(other_enc)), + "encoding_std": float(np.std(other_enc)), + "encoding_first_10": [float(x) for x in other_enc[:10].tolist()], + } + 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) @@ -1906,14 +2050,22 @@ def find_similar_faces( # 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) + # Use the actual tolerance parameter, not the default + confidence_pct = calibrate_confidence(distance, tolerance) # Desktop _get_filtered_similar_faces filters by: # 1. person_id is None (unidentified) - # 2. confidence >= 40% + # 2. confidence >= 50% (increased from 40% to reduce false matches) + # OR confidence >= distance-based threshold if use_distance_based_thresholds=True is_unidentified = f.person_id is None - if is_unidentified and confidence_pct >= 40: + # Calculate minimum confidence threshold + if use_distance_based_thresholds: + min_confidence = get_distance_based_min_confidence(distance) + else: + min_confidence = 50.0 # Standard threshold + + if is_unidentified and confidence_pct >= min_confidence: # Filter by excluded status if not including excluded faces if not include_excluded and getattr(f, "excluded", False): continue @@ -1922,9 +2074,16 @@ def find_similar_faces( if filter_frontal_only and not _is_acceptable_pose_for_auto_match(f.pose_mode): continue + # Filter by face size if requested (for auto-match) + if filter_small_faces: + if f.photo: + face_size_ratio = _calculate_face_size_ratio(f, f.photo) + if face_size_ratio < min_face_size_ratio: + continue # Skip small faces + # Return calibrated confidence percentage (matching desktop) # Desktop displays confidence_pct directly from _get_calibrated_confidence - matches.append((f, distance, confidence_pct)) + matches.append((f, distance, confidence_pct, debug_info)) # Sort by distance (lower is better) - matching desktop matches.sort(key=lambda x: x[1]) @@ -1937,6 +2096,7 @@ def calculate_batch_similarities( db: Session, face_ids: list[int], min_confidence: float = 60.0, + tolerance: float = 0.6, # Use 0.6 for Identify People (more lenient for manual review) ) -> list[tuple[int, int, float, float]]: """Calculate similarities between N faces and all M faces in database. @@ -1986,7 +2146,7 @@ def calculate_batch_similarities( for face in all_faces: # Pre-load encoding as numpy array - all_encodings[face.id] = np.frombuffer(face.encoding, dtype=np.float64) + all_encodings[face.id] = load_face_encoding(face.encoding) # Pre-cache quality score all_qualities[face.id] = float(face.quality_score) if face.quality_score is not None else 0.5 @@ -2082,8 +2242,9 @@ def calculate_batch_similarities( def find_auto_match_matches( db: Session, - tolerance: float = 0.6, + tolerance: float = 0.5, filter_frontal_only: bool = False, + use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds ) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]: """Find auto-match matches for all identified people, matching desktop logic exactly. @@ -2176,16 +2337,30 @@ def find_auto_match_matches( for person_id, reference_face, person_name in person_faces_list: reference_face_id = reference_face.id + # TEMPORARILY DISABLED: Check if reference face is too small (exclude from auto-match) + # reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first() + # if reference_photo: + # ref_size_ratio = _calculate_face_size_ratio(reference_face, reference_photo) + # if ref_size_ratio < MIN_AUTO_MATCH_FACE_SIZE_RATIO: + # # Skip this person - reference face is too small + # continue + # Use find_similar_faces which matches desktop _get_filtered_similar_faces logic # Desktop: similar_faces = self.face_processor._get_filtered_similar_faces( # reference_face_id, tolerance, include_same_photo=False, face_status=None) - # This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance + # This filters by: person_id is None (unidentified), confidence >= 50% (increased from 40%), sorts by distance # Auto-match always excludes excluded faces - similar_faces = find_similar_faces( + # TEMPORARILY DISABLED: filter_small_faces=True to exclude small match faces + similar_faces_with_debug = find_similar_faces( db, reference_face_id, tolerance=tolerance, filter_frontal_only=filter_frontal_only, - include_excluded=False # Auto-match always excludes excluded faces + include_excluded=False, # Auto-match always excludes excluded faces + filter_small_faces=False, # TEMPORARILY DISABLED: Exclude small faces from auto-match + min_face_size_ratio=MIN_AUTO_MATCH_FACE_SIZE_RATIO, + use_distance_based_thresholds=use_distance_based_thresholds # Use distance-based thresholds if enabled ) + # Strip debug_info for internal use + similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug] if similar_faces: results.append((person_id, reference_face_id, reference_face, similar_faces)) @@ -2196,7 +2371,7 @@ def find_auto_match_matches( def get_auto_match_people_list( db: Session, filter_frontal_only: bool = False, - tolerance: float = 0.6, + tolerance: float = 0.5, ) -> List[Tuple[int, Face, str, int]]: """Get list of people for auto-match (without matches) - fast initial load. @@ -2300,7 +2475,7 @@ def get_auto_match_people_list( def get_auto_match_person_matches( db: Session, person_id: int, - tolerance: float = 0.6, + tolerance: float = 0.5, filter_frontal_only: bool = False, ) -> List[Tuple[Face, float, float]]: """Get matches for a specific person - for lazy loading. @@ -2329,11 +2504,13 @@ def get_auto_match_person_matches( # Find similar faces using existing function # Auto-match always excludes excluded faces - similar_faces = find_similar_faces( + similar_faces_with_debug = find_similar_faces( db, reference_face.id, tolerance=tolerance, filter_frontal_only=filter_frontal_only, include_excluded=False # Auto-match always excludes excluded faces ) + # Strip debug_info for internal use + similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug] return similar_faces diff --git a/src/utils/pose_detection.py b/src/utils/pose_detection.py index 8a93829..fb8214e 100644 --- a/src/utils/pose_detection.py +++ b/src/utils/pose_detection.py @@ -22,7 +22,7 @@ class PoseDetector: """Detect face pose (yaw, pitch, roll) using RetinaFace landmarks""" # Thresholds for pose detection (in degrees) - PROFILE_YAW_THRESHOLD = 30.0 # Faces with |yaw| >= 30° are considered profile + PROFILE_YAW_THRESHOLD = 15.0 # Faces with |yaw| >= 15° are considered profile EXTREME_YAW_THRESHOLD = 60.0 # Faces with |yaw| >= 60° are extreme profile PITCH_THRESHOLD = 20.0 # Faces with |pitch| >= 20° are looking up/down @@ -39,7 +39,7 @@ class PoseDetector: Args: yaw_threshold: Yaw angle threshold for profile detection (degrees) - Default: 30.0 + Default: 15.0 pitch_threshold: Pitch angle threshold for up/down detection (degrees) Default: 20.0 roll_threshold: Roll angle threshold for tilt detection (degrees) @@ -53,17 +53,24 @@ class PoseDetector: self.roll_threshold = roll_threshold or self.ROLL_THRESHOLD @staticmethod - def detect_faces_with_landmarks(img_path: str) -> Dict: + def detect_faces_with_landmarks(img_path: str, filter_estimated_landmarks: bool = False) -> Dict: """Detect faces using RetinaFace directly + Args: + img_path: Path to image file + filter_estimated_landmarks: If True, remove landmarks that appear to be estimated + (e.g., hidden eye in profile views) rather than actually visible. + Uses heuristics: if eyes are very close together (< 20px) and + yaw calculation suggests extreme profile, mark hidden eye as None. + Returns: Dictionary with face keys and landmark data: { 'face_1': { 'facial_area': {'x': x, 'y': y, 'w': w, 'h': h}, 'landmarks': { - 'left_eye': (x, y), - 'right_eye': (x, y), + 'left_eye': (x, y) or None, + 'right_eye': (x, y) or None, 'nose': (x, y), 'left_mouth': (x, y), 'right_mouth': (x, y) @@ -76,6 +83,42 @@ class PoseDetector: return {} faces = RetinaFace.detect_faces(img_path) + + # Post-process to filter estimated landmarks if requested + if filter_estimated_landmarks: + for face_key, face_data in faces.items(): + landmarks = face_data.get('landmarks', {}) + if not landmarks: + continue + + left_eye = landmarks.get('left_eye') + right_eye = landmarks.get('right_eye') + nose = landmarks.get('nose') + + # Check if both eyes are present and very close together (profile view) + if left_eye and right_eye and nose: + face_width = abs(right_eye[0] - left_eye[0]) + + # If eyes are very close (< 20px), likely a profile view + if face_width < 20.0: + # Calculate which eye is likely hidden based on nose position + eye_mid_x = (left_eye[0] + right_eye[0]) / 2 + nose_x = nose[0] + + # If nose is closer to left eye, right eye is likely hidden (face turned left) + # If nose is closer to right eye, left eye is likely hidden (face turned right) + dist_to_left = abs(nose_x - left_eye[0]) + dist_to_right = abs(nose_x - right_eye[0]) + + if dist_to_left < dist_to_right: + # Nose closer to left eye = face turned left = right eye hidden + landmarks['right_eye'] = None + else: + # Nose closer to right eye = face turned right = left eye hidden + landmarks['left_eye'] = None + + face_data['landmarks'] = landmarks + return faces @staticmethod @@ -260,7 +303,8 @@ class PoseDetector: def classify_pose_mode(yaw: Optional[float], pitch: Optional[float], roll: Optional[float], - face_width: Optional[float] = None) -> str: + face_width: Optional[float] = None, + landmarks: Optional[Dict] = None) -> str: """Classify face pose mode from all three angles and optionally face width Args: @@ -268,8 +312,10 @@ class PoseDetector: pitch: Pitch angle in degrees roll: Roll angle in degrees face_width: Face width in pixels (eye distance). Used as indicator for profile detection. - If face_width < 25px, indicates profile view. When yaw is available but < 30°, + If face_width < 25px, indicates profile view. When yaw is available but < 15°, face_width can override yaw if it suggests profile (face_width < 25px). + landmarks: Optional facial landmarks dictionary. Used to detect single-eye visibility + for extreme profile views where only one eye is visible. Returns: Pose mode classification string: @@ -279,6 +325,28 @@ class PoseDetector: - 'tilted_left', 'tilted_right': roll variations - Combined modes: e.g., 'profile_left_looking_up' """ + # Check for single-eye visibility to infer profile direction + # This handles extreme profile views where only one eye is visible + if landmarks: + left_eye = landmarks.get('left_eye') + right_eye = landmarks.get('right_eye') + + # Only right eye visible -> face turned left -> profile_left + if left_eye is None and right_eye is not None: + # Infer profile_left when only right eye is visible + inferred_profile = "profile_left" + # Only left eye visible -> face turned right -> profile_right + elif left_eye is not None and right_eye is None: + # Infer profile_right when only left eye is visible + inferred_profile = "profile_right" + # No eyes visible -> extreme profile, default to profile_left + elif left_eye is None and right_eye is None: + inferred_profile = "profile_left" + else: + inferred_profile = None # Both eyes visible, use normal logic + else: + inferred_profile = None + # Default to frontal if angles unknown yaw_original = yaw if yaw is None: @@ -290,20 +358,23 @@ class PoseDetector: # Face width threshold for profile detection (in pixels) # Profile faces have very small eye distance (< 25 pixels typically) - PROFILE_FACE_WIDTH_THRESHOLD = 10.0 #25.0 + PROFILE_FACE_WIDTH_THRESHOLD = 20.0 # Yaw classification - PRIMARY INDICATOR - # Use yaw angle as the primary indicator (30° threshold) + # Use yaw angle as the primary indicator (15° threshold) abs_yaw = abs(yaw) # Primary classification based on yaw angle - if abs_yaw < 30.0: + if abs_yaw < 15.0: # Yaw indicates frontal view - # Trust yaw when it's available and reasonable (< 30°) + # Trust yaw when it's available and reasonable (< 15°) # Only use face_width as fallback when yaw is unavailable (None) if yaw_original is None: - # Yaw unavailable - use face_width as fallback - if face_width is not None: + # Yaw unavailable - check for single-eye visibility first + if inferred_profile is not None: + # Single eye visible or no eyes visible -> use inferred profile direction + yaw_mode = inferred_profile + elif face_width is not None: if face_width < PROFILE_FACE_WIDTH_THRESHOLD: # Face width suggests profile view - use it when yaw is unavailable yaw_mode = "profile_left" # Default direction when yaw unavailable @@ -311,16 +382,14 @@ class PoseDetector: # Face width is normal (>= 25px) - likely frontal yaw_mode = "frontal" else: - # Both yaw and face_width unavailable - cannot determine reliably - # This usually means landmarks are incomplete (missing nose and/or eyes) - # For extreme profile views, both eyes might not be visible, which would - # cause face_width to be None. In this case, we cannot reliably determine - # pose without additional indicators (like face bounding box aspect ratio). - # Default to frontal (conservative approach), but this might misclassify - # some extreme profile faces. - yaw_mode = "frontal" + # Both yaw and face_width unavailable - check if we inferred profile from landmarks + if inferred_profile is not None: + yaw_mode = inferred_profile + else: + # Cannot determine reliably - default to frontal + yaw_mode = "frontal" else: - # Yaw is available and < 30° - but still check face_width + # Yaw is available and < 15° - but still check face_width # If face_width is very small (< 25px), it suggests profile even with small yaw if face_width is not None: if face_width < PROFILE_FACE_WIDTH_THRESHOLD: @@ -332,11 +401,11 @@ class PoseDetector: else: # No face_width provided - trust yaw, classify as frontal yaw_mode = "frontal" - elif yaw <= -30.0: - # abs_yaw >= 30.0 and yaw is negative - profile left + elif yaw <= -15.0: + # abs_yaw >= 15.0 and yaw is negative - profile left yaw_mode = "profile_left" # Negative yaw = face turned left = left profile visible - elif yaw >= 30.0: - # abs_yaw >= 30.0 and yaw is positive - profile right + elif yaw >= 15.0: + # abs_yaw >= 15.0 and yaw is positive - profile right yaw_mode = "profile_right" # Positive yaw = face turned right = right profile visible else: # This should never be reached, but handle edge case @@ -411,8 +480,8 @@ class PoseDetector: # Calculate face width (eye distance) for profile detection face_width = self.calculate_face_width_from_landmarks(landmarks) - # Classify pose mode (using face width as additional indicator) - pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle, face_width) + # Classify pose mode (using face width and landmarks as additional indicators) + pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle, face_width, landmarks) # Normalize facial_area format (RetinaFace returns list [x, y, w, h] or dict) facial_area_raw = face_data.get('facial_area', {}) diff --git a/tests/README.md b/tests/README.md index be8a184..3510603 100644 --- a/tests/README.md +++ b/tests/README.md @@ -111,3 +111,4 @@ In CI (GitHub Actions/Gitea Actions), test results appear in: + diff --git a/viewer-frontend/scripts/install-dependencies.sh b/viewer-frontend/scripts/install-dependencies.sh index 4165168..ab64403 100755 --- a/viewer-frontend/scripts/install-dependencies.sh +++ b/viewer-frontend/scripts/install-dependencies.sh @@ -207,3 +207,4 @@ echo "" + diff --git a/viewer-frontend/scripts/test-prisma-query.ts b/viewer-frontend/scripts/test-prisma-query.ts index 13a11f1..6b37630 100644 --- a/viewer-frontend/scripts/test-prisma-query.ts +++ b/viewer-frontend/scripts/test-prisma-query.ts @@ -148,3 +148,4 @@ testQueries() + diff --git a/viewer-frontend/scripts/with-sharp-libpath.sh b/viewer-frontend/scripts/with-sharp-libpath.sh index f1dc94c..6677835 100755 --- a/viewer-frontend/scripts/with-sharp-libpath.sh +++ b/viewer-frontend/scripts/with-sharp-libpath.sh @@ -25,3 +25,4 @@ fi +