feat: add debug mode, distance-based thresholds, and improve pose detection

- Add debug mode support for encoding statistics in API responses
  - Debug info includes encoding length, min/max/mean/std, and first 10 values
  - Frontend logs encoding stats to browser console when debug enabled
  - Identify page enables debug mode by default

- Implement distance-based confidence thresholds for stricter matching
  - Borderline distances require higher confidence (70-95% vs 50%)
  - Applied when use_distance_based_thresholds=True (auto-match)
  - Reduces false positives for borderline matches

- Dual tolerance system for auto-match
  - Default tolerance 0.6 for regular browsing (more lenient)
  - Run auto-match button uses 0.5 tolerance with distance-based thresholds (stricter)
  - Auto-accept threshold updated to 85% (from 70%)

- Enhance pose detection with single-eye detection
  - Profile threshold reduced from 30° to 15° (stricter)
  - Detect single-eye visibility for extreme profile views
  - Infer profile direction from landmark visibility
  - Improved face width threshold (20px vs 10px)

- Clean up debug code
  - Remove test photo UUID checks from production code
  - Remove debug print statements
  - Replace print statements with proper logging
This commit is contained in:
Tanya
2026-02-10 13:20:07 -05:00
parent 6b6b1449b2
commit a6ba78cd54
13 changed files with 326 additions and 76 deletions
+97 -28
View File
@@ -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', {})