feat: Add pose mode analysis and face width detection for improved profile classification
This commit introduces a comprehensive analysis of pose modes and face width detection to enhance profile classification accuracy. New scripts have been added to analyze pose data in the database, check identified faces for pose information, and validate yaw angles. The PoseDetector class has been updated to calculate face width from landmarks, which serves as an additional indicator for profile detection. The frontend and API have been modified to include pose mode in responses, ensuring better integration with existing functionalities. Documentation has been updated to reflect these changes, improving user experience and accuracy in face processing.
This commit is contained in:
@@ -290,6 +290,15 @@ class FaceProcessor:
|
||||
yaw_angle = pose_info.get('yaw_angle')
|
||||
pitch_angle = pose_info.get('pitch_angle')
|
||||
roll_angle = pose_info.get('roll_angle')
|
||||
face_width = pose_info.get('face_width') # Extract face width for verification
|
||||
|
||||
# Log face width for profile detection verification
|
||||
if self.verbose >= 2 and face_width is not None:
|
||||
profile_status = "PROFILE" if face_width < 25.0 else "FRONTAL"
|
||||
print(f" Face {i+1}: face_width={face_width:.2f}px, pose_mode={pose_mode} ({profile_status})")
|
||||
elif self.verbose >= 3:
|
||||
# Even more verbose: show all pose info
|
||||
print(f" Face {i+1} pose info: yaw={yaw_angle:.1f}°, pitch={pitch_angle:.1f}°, roll={roll_angle:.1f}°, width={face_width:.2f}px, mode={pose_mode}")
|
||||
|
||||
# Store in database with DeepFace format, EXIF orientation, and pose data
|
||||
self.db.add_face(
|
||||
@@ -622,14 +631,16 @@ class FaceProcessor:
|
||||
'pose_mode': best_match.get('pose_mode', 'frontal'),
|
||||
'yaw_angle': best_match.get('yaw_angle'),
|
||||
'pitch_angle': best_match.get('pitch_angle'),
|
||||
'roll_angle': best_match.get('roll_angle')
|
||||
'roll_angle': best_match.get('roll_angle'),
|
||||
'face_width': best_match.get('face_width') # Extract face width for verification
|
||||
}
|
||||
|
||||
return {
|
||||
'pose_mode': 'frontal',
|
||||
'yaw_angle': None,
|
||||
'pitch_angle': None,
|
||||
'roll_angle': None
|
||||
'roll_angle': None,
|
||||
'face_width': None
|
||||
}
|
||||
|
||||
def _extract_face_crop(self, photo_path: str, location: dict, face_id: int) -> str:
|
||||
|
||||
@@ -72,7 +72,7 @@ class AutoMatchPanel:
|
||||
# Don't give weight to any column to prevent stretching
|
||||
|
||||
# Start button (moved to the left)
|
||||
start_btn = ttk.Button(config_frame, text="🚀 Start Auto-Match", command=self._start_auto_match)
|
||||
start_btn = ttk.Button(config_frame, text="🚀 Run Auto-Match", command=self._start_auto_match)
|
||||
start_btn.grid(row=0, column=0, padx=(0, 20))
|
||||
|
||||
# Tolerance setting
|
||||
|
||||
+98
-15
@@ -72,6 +72,38 @@ class PoseDetector:
|
||||
faces = RetinaFace.detect_faces(img_path)
|
||||
return faces
|
||||
|
||||
@staticmethod
|
||||
def calculate_face_width_from_landmarks(landmarks: Dict) -> Optional[float]:
|
||||
"""Calculate face width (eye distance) from facial landmarks.
|
||||
|
||||
Face width is the horizontal distance between the two eyes.
|
||||
For profile faces, this distance is very small (< 20-30 pixels).
|
||||
|
||||
Args:
|
||||
landmarks: Dictionary with landmark positions:
|
||||
{
|
||||
'left_eye': (x, y),
|
||||
'right_eye': (x, y),
|
||||
...
|
||||
}
|
||||
|
||||
Returns:
|
||||
Face width in pixels, or None if landmarks invalid
|
||||
"""
|
||||
if not landmarks:
|
||||
return None
|
||||
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
|
||||
if not all([left_eye, right_eye]):
|
||||
return None
|
||||
|
||||
# Calculate face width (eye distance)
|
||||
face_width = abs(right_eye[0] - left_eye[0])
|
||||
|
||||
return face_width if face_width > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def calculate_yaw_from_landmarks(landmarks: Dict) -> Optional[float]:
|
||||
"""Calculate yaw angle from facial landmarks
|
||||
@@ -88,8 +120,8 @@ class PoseDetector:
|
||||
|
||||
Returns:
|
||||
Yaw angle in degrees (-90 to +90):
|
||||
- Negative: face turned left (right profile)
|
||||
- Positive: face turned right (left profile)
|
||||
- Negative: face turned left (left profile visible)
|
||||
- Positive: face turned right (right profile visible)
|
||||
- Zero: frontal face
|
||||
- None: if landmarks invalid
|
||||
"""
|
||||
@@ -145,8 +177,9 @@ class PoseDetector:
|
||||
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
left_mouth = landmarks.get('left_mouth')
|
||||
right_mouth = landmarks.get('right_mouth')
|
||||
# RetinaFace uses 'mouth_left' and 'mouth_right', not 'left_mouth' and 'right_mouth'
|
||||
left_mouth = landmarks.get('mouth_left') or landmarks.get('left_mouth')
|
||||
right_mouth = landmarks.get('mouth_right') or landmarks.get('right_mouth')
|
||||
nose = landmarks.get('nose')
|
||||
|
||||
if not all([left_eye, right_eye, left_mouth, right_mouth, nose]):
|
||||
@@ -204,22 +237,33 @@ class PoseDetector:
|
||||
if dx == 0:
|
||||
return 90.0 if dy > 0 else -90.0 # Vertical line
|
||||
|
||||
# Roll angle
|
||||
# Roll angle - atan2 returns [-180, 180], normalize to [-90, 90]
|
||||
roll_radians = atan2(dy, dx)
|
||||
roll_degrees = degrees(roll_radians)
|
||||
|
||||
# Normalize to [-90, 90] range for head tilt
|
||||
# If angle is > 90°, subtract 180°; if < -90°, add 180°
|
||||
if roll_degrees > 90.0:
|
||||
roll_degrees = roll_degrees - 180.0
|
||||
elif roll_degrees < -90.0:
|
||||
roll_degrees = roll_degrees + 180.0
|
||||
|
||||
return roll_degrees
|
||||
|
||||
@staticmethod
|
||||
def classify_pose_mode(yaw: Optional[float],
|
||||
pitch: Optional[float],
|
||||
roll: Optional[float]) -> str:
|
||||
"""Classify face pose mode from all three angles
|
||||
roll: Optional[float],
|
||||
face_width: Optional[float] = None) -> str:
|
||||
"""Classify face pose mode from all three angles and optionally face width
|
||||
|
||||
Args:
|
||||
yaw: Yaw angle in degrees
|
||||
pitch: Pitch angle in degrees
|
||||
roll: Roll angle in degrees
|
||||
face_width: Face width in pixels (eye distance). Used as fallback indicator
|
||||
only when yaw is unavailable (None) - if face_width < 25px, indicates profile.
|
||||
When yaw is available, it takes precedence over face_width.
|
||||
|
||||
Returns:
|
||||
Pose mode classification string:
|
||||
@@ -230,6 +274,7 @@ class PoseDetector:
|
||||
- Combined modes: e.g., 'profile_left_looking_up'
|
||||
"""
|
||||
# Default to frontal if angles unknown
|
||||
yaw_original = yaw
|
||||
if yaw is None:
|
||||
yaw = 0.0
|
||||
if pitch is None:
|
||||
@@ -237,15 +282,49 @@ class PoseDetector:
|
||||
if roll is None:
|
||||
roll = 0.0
|
||||
|
||||
# Yaw classification
|
||||
# Face width threshold for profile detection (in pixels)
|
||||
# Profile faces have very small eye distance (< 25 pixels typically)
|
||||
PROFILE_FACE_WIDTH_THRESHOLD = 25.0
|
||||
|
||||
# Yaw classification - PRIMARY INDICATOR
|
||||
# Use yaw angle as the primary indicator (30° threshold)
|
||||
abs_yaw = abs(yaw)
|
||||
|
||||
# Primary classification based on yaw angle
|
||||
if abs_yaw < 30.0:
|
||||
yaw_mode = "frontal"
|
||||
elif yaw < -30.0:
|
||||
yaw_mode = "profile_right"
|
||||
elif yaw > 30.0:
|
||||
yaw_mode = "profile_left"
|
||||
# Yaw indicates frontal view
|
||||
# Trust yaw when it's available and reasonable (< 30°)
|
||||
# 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:
|
||||
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
|
||||
else:
|
||||
# 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"
|
||||
else:
|
||||
# Yaw is available and < 30° - trust yaw, classify as frontal
|
||||
# Don't override with face_width when yaw is available
|
||||
yaw_mode = "frontal"
|
||||
elif yaw <= -30.0:
|
||||
# abs_yaw >= 30.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
|
||||
yaw_mode = "profile_right" # Positive yaw = face turned right = right profile visible
|
||||
else:
|
||||
# This should never be reached, but handle edge case
|
||||
yaw_mode = "slight_yaw"
|
||||
|
||||
# Pitch classification
|
||||
@@ -314,8 +393,11 @@ class PoseDetector:
|
||||
pitch_angle = self.calculate_pitch_from_landmarks(landmarks)
|
||||
roll_angle = self.calculate_roll_from_landmarks(landmarks)
|
||||
|
||||
# Classify pose mode
|
||||
pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle)
|
||||
# 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)
|
||||
|
||||
# Normalize facial_area format (RetinaFace returns list [x, y, w, h] or dict)
|
||||
facial_area_raw = face_data.get('facial_area', {})
|
||||
@@ -341,6 +423,7 @@ class PoseDetector:
|
||||
'yaw_angle': yaw_angle,
|
||||
'pitch_angle': pitch_angle,
|
||||
'roll_angle': roll_angle,
|
||||
'face_width': face_width, # Eye distance in pixels
|
||||
'pose_mode': pose_mode
|
||||
}
|
||||
results.append(result)
|
||||
|
||||
@@ -127,6 +127,7 @@ def get_unidentified_faces(
|
||||
quality_score=float(f.quality_score),
|
||||
face_confidence=float(getattr(f, "face_confidence", 0.0)),
|
||||
location=f.location,
|
||||
pose_mode=getattr(f, "pose_mode", None) or "frontal",
|
||||
)
|
||||
for f in faces
|
||||
]
|
||||
@@ -158,6 +159,7 @@ def get_similar_faces(face_id: int, db: Session = Depends(get_db)) -> SimilarFac
|
||||
location=f.location,
|
||||
quality_score=float(f.quality_score),
|
||||
filename=f.photo.filename if f.photo else "unknown",
|
||||
pose_mode=getattr(f, "pose_mode", None) or "frontal",
|
||||
)
|
||||
for f, distance, confidence_pct in results
|
||||
]
|
||||
|
||||
@@ -50,6 +50,7 @@ class FaceItem(BaseModel):
|
||||
quality_score: float
|
||||
face_confidence: float
|
||||
location: str
|
||||
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
|
||||
|
||||
|
||||
class UnidentifiedFacesQuery(BaseModel):
|
||||
@@ -86,6 +87,7 @@ class SimilarFaceItem(BaseModel):
|
||||
location: str
|
||||
quality_score: float
|
||||
filename: str
|
||||
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
|
||||
|
||||
|
||||
class SimilarFacesResponse(BaseModel):
|
||||
|
||||
+182
-105
@@ -280,6 +280,7 @@ def process_photo_faces(
|
||||
detector_backend: str = "retinaface",
|
||||
model_name: str = "ArcFace",
|
||||
update_progress: Optional[Callable[[int, int, str], None]] = None,
|
||||
pose_detector: Optional[PoseDetector] = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Process faces in a single photo using DeepFace.
|
||||
|
||||
@@ -289,6 +290,8 @@ def process_photo_faces(
|
||||
detector_backend: DeepFace detector backend (retinaface, mtcnn, opencv, ssd)
|
||||
model_name: DeepFace model name (ArcFace, Facenet, Facenet512, VGG-Face)
|
||||
update_progress: Optional progress callback (processed, total, message)
|
||||
pose_detector: Optional PoseDetector instance to reuse (initialized once per batch)
|
||||
If None and RETINAFACE_AVAILABLE, will create one locally
|
||||
|
||||
Returns:
|
||||
Tuple of (faces_detected, faces_stored)
|
||||
@@ -328,17 +331,27 @@ def process_photo_faces(
|
||||
face_detection_path = photo_path
|
||||
|
||||
# Step 1: Use RetinaFace directly for detection + landmarks (with graceful fallback)
|
||||
# Reuse the pose_detector passed in (initialized once per batch) or create one if needed
|
||||
pose_faces = []
|
||||
pose_detector = None
|
||||
if RETINAFACE_AVAILABLE:
|
||||
if pose_detector is not None:
|
||||
# Use the shared detector instance (much faster - no reinitialization)
|
||||
try:
|
||||
pose_detector = PoseDetector()
|
||||
pose_faces = pose_detector.detect_pose_faces(face_detection_path)
|
||||
if pose_faces:
|
||||
print(f"[FaceService] Pose detection: found {len(pose_faces)} faces with pose data")
|
||||
except Exception as e:
|
||||
print(f"[FaceService] ⚠️ Pose detection failed for {photo.filename}: {e}, using defaults")
|
||||
pose_faces = []
|
||||
elif RETINAFACE_AVAILABLE:
|
||||
# Fallback: create detector if not provided (backward compatibility)
|
||||
try:
|
||||
pose_detector_local = PoseDetector()
|
||||
pose_faces = pose_detector_local.detect_pose_faces(face_detection_path)
|
||||
if pose_faces:
|
||||
print(f"[FaceService] Pose detection: found {len(pose_faces)} faces with pose data")
|
||||
except Exception as e:
|
||||
print(f"[FaceService] ⚠️ Pose detection failed for {photo.filename}: {e}, using defaults")
|
||||
pose_faces = []
|
||||
|
||||
try:
|
||||
# Step 2: Use DeepFace for encoding generation
|
||||
@@ -457,6 +470,18 @@ def process_photo_faces(
|
||||
yaw_angle = pose_info.get('yaw_angle')
|
||||
pitch_angle = pose_info.get('pitch_angle')
|
||||
roll_angle = pose_info.get('roll_angle')
|
||||
face_width = pose_info.get('face_width') # Extract face width for verification
|
||||
|
||||
# Log face width for profile detection verification
|
||||
if face_width is not None:
|
||||
profile_status = "PROFILE" if face_width < 25.0 else "FRONTAL"
|
||||
yaw_str = f"{yaw_angle:.2f}°" if yaw_angle is not None else "None"
|
||||
print(f"[FaceService] Face {idx+1}/{faces_detected} in {photo.filename}: "
|
||||
f"face_width={face_width:.2f}px, pose_mode={pose_mode} ({profile_status}), yaw={yaw_str}")
|
||||
else:
|
||||
yaw_str = f"{yaw_angle:.2f}°" if yaw_angle is not None else "None"
|
||||
print(f"[FaceService] Face {idx+1}/{faces_detected} in {photo.filename}: "
|
||||
f"face_width=None, pose_mode={pose_mode}, yaw={yaw_str}")
|
||||
|
||||
# Store face in database - match desktop schema exactly
|
||||
# Desktop: confidence REAL DEFAULT 0.0 (legacy), face_confidence REAL (actual)
|
||||
@@ -511,8 +536,55 @@ def process_photo_faces(
|
||||
raise Exception(f"Error processing faces in {photo.filename}: {str(e)}")
|
||||
|
||||
|
||||
def _calculate_iou(box1: Dict, box2: Dict) -> float:
|
||||
"""Calculate Intersection over Union (IoU) between two bounding boxes.
|
||||
|
||||
Args:
|
||||
box1: First bounding box {'x': x, 'y': y, 'w': w, 'h': h}
|
||||
box2: Second bounding box {'x': x, 'y': y, 'w': w, 'h': h}
|
||||
|
||||
Returns:
|
||||
IoU value between 0.0 and 1.0 (1.0 = perfect overlap)
|
||||
"""
|
||||
# Get coordinates
|
||||
x1_min = box1.get('x', 0)
|
||||
y1_min = box1.get('y', 0)
|
||||
x1_max = x1_min + box1.get('w', 0)
|
||||
y1_max = y1_min + box1.get('h', 0)
|
||||
|
||||
x2_min = box2.get('x', 0)
|
||||
y2_min = box2.get('y', 0)
|
||||
x2_max = x2_min + box2.get('w', 0)
|
||||
y2_max = y2_min + box2.get('h', 0)
|
||||
|
||||
# Calculate intersection
|
||||
inter_x_min = max(x1_min, x2_min)
|
||||
inter_y_min = max(y1_min, y2_min)
|
||||
inter_x_max = min(x1_max, x2_max)
|
||||
inter_y_max = min(y1_max, y2_max)
|
||||
|
||||
if inter_x_max <= inter_x_min or inter_y_max <= inter_y_min:
|
||||
return 0.0
|
||||
|
||||
inter_area = (inter_x_max - inter_x_min) * (inter_y_max - inter_y_min)
|
||||
|
||||
# Calculate union
|
||||
box1_area = box1.get('w', 0) * box1.get('h', 0)
|
||||
box2_area = box2.get('w', 0) * box2.get('h', 0)
|
||||
union_area = box1_area + box2_area - inter_area
|
||||
|
||||
if union_area == 0:
|
||||
return 0.0
|
||||
|
||||
return inter_area / union_area
|
||||
|
||||
|
||||
def _find_matching_pose_info(facial_area: Dict, pose_faces: List[Dict]) -> Dict:
|
||||
"""Match DeepFace result with RetinaFace pose detection result
|
||||
"""Match DeepFace result with RetinaFace pose detection result using IoU.
|
||||
|
||||
Uses Intersection over Union (IoU) for robust bounding box matching, which is
|
||||
the standard approach in computer vision. This is more reliable than center
|
||||
point distance, especially when bounding boxes have different sizes or aspect ratios.
|
||||
|
||||
Args:
|
||||
facial_area: DeepFace facial_area {'x': x, 'y': y, 'w': w, 'h': h}
|
||||
@@ -521,28 +593,50 @@ def _find_matching_pose_info(facial_area: Dict, pose_faces: List[Dict]) -> Dict:
|
||||
Returns:
|
||||
Dictionary with pose information, or defaults
|
||||
"""
|
||||
# Match by bounding box overlap
|
||||
# Simple approach: find closest match by center point
|
||||
if not pose_faces:
|
||||
return {
|
||||
'pose_mode': 'frontal',
|
||||
'yaw_angle': None,
|
||||
'pitch_angle': None,
|
||||
'roll_angle': None
|
||||
'roll_angle': None,
|
||||
'face_width': None
|
||||
}
|
||||
|
||||
deepface_center_x = facial_area.get('x', 0) + facial_area.get('w', 0) / 2
|
||||
deepface_center_y = facial_area.get('y', 0) + facial_area.get('h', 0) / 2
|
||||
# If only one face detected by both systems, use it directly
|
||||
if len(pose_faces) == 1:
|
||||
pose_face = pose_faces[0]
|
||||
pose_area = pose_face.get('facial_area', {})
|
||||
|
||||
# Handle both dict and list formats
|
||||
if isinstance(pose_area, list) and len(pose_area) >= 4:
|
||||
pose_area = {
|
||||
'x': pose_area[0],
|
||||
'y': pose_area[1],
|
||||
'w': pose_area[2],
|
||||
'h': pose_area[3]
|
||||
}
|
||||
|
||||
if isinstance(pose_area, dict) and pose_area:
|
||||
# Still check IoU to ensure it's a reasonable match
|
||||
iou = _calculate_iou(facial_area, pose_area)
|
||||
if iou > 0.1: # At least 10% overlap
|
||||
return {
|
||||
'pose_mode': pose_face.get('pose_mode', 'frontal'),
|
||||
'yaw_angle': pose_face.get('yaw_angle'),
|
||||
'pitch_angle': pose_face.get('pitch_angle'),
|
||||
'roll_angle': pose_face.get('roll_angle'),
|
||||
'face_width': pose_face.get('face_width') # Extract face width
|
||||
}
|
||||
|
||||
# Multiple faces: find best match using IoU
|
||||
best_match = None
|
||||
min_distance = float('inf')
|
||||
best_iou = 0.0
|
||||
|
||||
for pose_face in pose_faces:
|
||||
pose_area = pose_face.get('facial_area', {})
|
||||
|
||||
# Handle both dict and list formats (for robustness)
|
||||
# Handle both dict and list formats
|
||||
if isinstance(pose_area, list) and len(pose_area) >= 4:
|
||||
# Convert list [x, y, w, h] to dict format
|
||||
pose_area = {
|
||||
'x': pose_area[0],
|
||||
'y': pose_area[1],
|
||||
@@ -550,36 +644,85 @@ def _find_matching_pose_info(facial_area: Dict, pose_faces: List[Dict]) -> Dict:
|
||||
'h': pose_area[3]
|
||||
}
|
||||
elif not isinstance(pose_area, dict):
|
||||
# Skip if not dict or list
|
||||
continue
|
||||
|
||||
pose_center_x = (pose_area.get('x', 0) +
|
||||
pose_area.get('w', 0) / 2)
|
||||
pose_center_y = (pose_area.get('y', 0) +
|
||||
pose_area.get('h', 0) / 2)
|
||||
if not pose_area:
|
||||
continue
|
||||
|
||||
# Calculate distance between centers
|
||||
distance = ((deepface_center_x - pose_center_x) ** 2 +
|
||||
(deepface_center_y - pose_center_y) ** 2) ** 0.5
|
||||
# Calculate IoU between DeepFace and RetinaFace bounding boxes
|
||||
iou = _calculate_iou(facial_area, pose_area)
|
||||
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
if iou > best_iou:
|
||||
best_iou = iou
|
||||
best_match = pose_face
|
||||
|
||||
# If match is close enough (within 50 pixels), use it
|
||||
if best_match and min_distance < 50:
|
||||
# Use match if IoU is above threshold (0.1 = 10% overlap is very lenient)
|
||||
# Since DeepFace uses RetinaFace as detector_backend, they should detect similar faces
|
||||
# Lower threshold to catch more matches
|
||||
if best_match and best_iou > 0.1:
|
||||
return {
|
||||
'pose_mode': best_match.get('pose_mode', 'frontal'),
|
||||
'yaw_angle': best_match.get('yaw_angle'),
|
||||
'pitch_angle': best_match.get('pitch_angle'),
|
||||
'roll_angle': best_match.get('roll_angle')
|
||||
'roll_angle': best_match.get('roll_angle'),
|
||||
'face_width': best_match.get('face_width') # Extract face width
|
||||
}
|
||||
|
||||
# Aggressive fallback: if we have pose_faces detected, use the best match
|
||||
# DeepFace and RetinaFace might detect slightly different bounding boxes,
|
||||
# but if we have pose data, we should use it
|
||||
if best_match:
|
||||
deepface_center_x = facial_area.get('x', 0) + facial_area.get('w', 0) / 2
|
||||
deepface_center_y = facial_area.get('y', 0) + facial_area.get('h', 0) / 2
|
||||
|
||||
pose_area = best_match.get('facial_area', {})
|
||||
if isinstance(pose_area, list) and len(pose_area) >= 4:
|
||||
pose_area = {
|
||||
'x': pose_area[0],
|
||||
'y': pose_area[1],
|
||||
'w': pose_area[2],
|
||||
'h': pose_area[3]
|
||||
}
|
||||
|
||||
if isinstance(pose_area, dict) and pose_area:
|
||||
pose_center_x = pose_area.get('x', 0) + pose_area.get('w', 0) / 2
|
||||
pose_center_y = pose_area.get('y', 0) + pose_area.get('h', 0) / 2
|
||||
|
||||
distance = ((deepface_center_x - pose_center_x) ** 2 +
|
||||
(deepface_center_y - pose_center_y) ** 2) ** 0.5
|
||||
|
||||
# Very lenient fallback: use if distance is within 30% of face size or 150 pixels
|
||||
# This ensures we capture pose data even when bounding boxes differ significantly
|
||||
face_size = (facial_area.get('w', 0) + facial_area.get('h', 0)) / 2
|
||||
threshold = max(face_size * 0.30, 150.0) # At least 150 pixels, or 30% of face size
|
||||
|
||||
if distance < threshold:
|
||||
return {
|
||||
'pose_mode': best_match.get('pose_mode', 'frontal'),
|
||||
'yaw_angle': best_match.get('yaw_angle'),
|
||||
'pitch_angle': best_match.get('pitch_angle'),
|
||||
'roll_angle': best_match.get('roll_angle'),
|
||||
'face_width': best_match.get('face_width') # Extract face width
|
||||
}
|
||||
|
||||
# Last resort: if we have pose_faces and only one face, use it regardless
|
||||
# This handles cases where DeepFace and RetinaFace detect the same face
|
||||
# but with very different bounding boxes
|
||||
if len(pose_faces) == 1:
|
||||
return {
|
||||
'pose_mode': best_match.get('pose_mode', 'frontal'),
|
||||
'yaw_angle': best_match.get('yaw_angle'),
|
||||
'pitch_angle': best_match.get('pitch_angle'),
|
||||
'roll_angle': best_match.get('roll_angle'),
|
||||
'face_width': best_match.get('face_width') # Extract face width
|
||||
}
|
||||
|
||||
return {
|
||||
'pose_mode': 'frontal',
|
||||
'yaw_angle': None,
|
||||
'pitch_angle': None,
|
||||
'roll_angle': None
|
||||
'roll_angle': None,
|
||||
'face_width': None
|
||||
}
|
||||
|
||||
|
||||
@@ -668,6 +811,18 @@ def process_unprocessed_photos(
|
||||
print("[FaceService] Job cancelled before processing started")
|
||||
return photos_processed, total_faces_detected, total_faces_stored
|
||||
|
||||
# Initialize PoseDetector ONCE for the entire batch (reuse across all photos)
|
||||
# This avoids reinitializing RetinaFace for every photo, which is very slow
|
||||
pose_detector = None
|
||||
if RETINAFACE_AVAILABLE:
|
||||
try:
|
||||
print(f"[FaceService] Initializing RetinaFace pose detector...")
|
||||
pose_detector = PoseDetector()
|
||||
print(f"[FaceService] Pose detector initialized successfully")
|
||||
except Exception as e:
|
||||
print(f"[FaceService] ⚠️ Pose detection not available: {e}, will skip pose detection")
|
||||
pose_detector = None
|
||||
|
||||
# Update progress - models are ready, starting photo processing
|
||||
if update_progress and total > 0:
|
||||
update_progress(0, total, f"Starting face detection on {total} photos...", 0, 0)
|
||||
@@ -709,6 +864,7 @@ def process_unprocessed_photos(
|
||||
photo,
|
||||
detector_backend=detector_backend,
|
||||
model_name=model_name,
|
||||
pose_detector=pose_detector, # Reuse the same detector for all photos
|
||||
)
|
||||
|
||||
total_faces_detected += faces_detected
|
||||
@@ -1052,7 +1208,6 @@ def find_similar_faces(
|
||||
# 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 []
|
||||
|
||||
# Load base encoding - desktop uses float64, ArcFace has 512 dimensions
|
||||
@@ -1060,27 +1215,9 @@ def find_similar_faces(
|
||||
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}")
|
||||
|
||||
# Desktop: get ALL faces from database (matching get_all_face_encodings)
|
||||
# Desktop find_similar_faces gets ALL faces, doesn't filter by photo_id
|
||||
@@ -1092,34 +1229,12 @@ def find_similar_faces(
|
||||
.all()
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -1129,17 +1244,6 @@ def find_similar_faces(
|
||||
# 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)
|
||||
@@ -1152,45 +1256,18 @@ def find_similar_faces(
|
||||
# 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:
|
||||
# Filter by pose_mode if requested (only frontal or tilted faces)
|
||||
if filter_frontal_only and not _is_acceptable_pose_for_auto_match(f.pose_mode):
|
||||
if face_id in [111, 113, 1] or (face_id == 1 and len(matches) < 10):
|
||||
print(f"DEBUG: ✗ Face {f.id} filtered out (not frontal/tilted: {f.pose_mode})")
|
||||
continue
|
||||
|
||||
# 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)")
|
||||
|
||||
# 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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user