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:
tanyar09
2025-11-06 13:26:25 -05:00
parent a70637feff
commit e74ade9278
22 changed files with 2052 additions and 124 deletions
+105
View File
@@ -0,0 +1,105 @@
# Face Width-Based Profile Detection Implementation
## Overview
Implemented face width (eye distance) as an additional indicator for profile face detection. This enhances profile detection accuracy, especially for extreme profile views where yaw angle calculation might be less reliable.
## Implementation Details
### 1. Added `calculate_face_width_from_landmarks()` Method
**Location:** `src/utils/pose_detection.py`
Calculates the horizontal distance between the two eyes (face width). For profile faces, this distance is very small (< 20-30 pixels), while frontal faces have much larger eye distances (typically 50-100+ pixels).
```python
@staticmethod
def calculate_face_width_from_landmarks(landmarks: Dict) -> Optional[float]:
"""Calculate face width (eye distance) from facial landmarks."""
```
### 2. Enhanced `classify_pose_mode()` Method
**Location:** `src/utils/pose_detection.py`
Added optional `face_width` parameter to `classify_pose_mode()`:
```python
@staticmethod
def classify_pose_mode(yaw: Optional[float],
pitch: Optional[float],
roll: Optional[float],
face_width: Optional[float] = None) -> str:
```
**Logic:**
- If `face_width < 25 pixels`, strongly indicates profile view
- Even if yaw angle is below the 30° threshold, small face width suggests profile
- Uses yaw direction (if available) to determine `profile_left` vs `profile_right`
- Falls back to `profile_left` if yaw is unavailable but face width is small
### 3. Updated `detect_pose_faces()` Method
**Location:** `src/utils/pose_detection.py`
Now calculates face width and includes it in the result:
```python
# 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)
```
The result dictionary now includes `face_width`:
```python
result = {
'facial_area': facial_area,
'landmarks': landmarks,
'confidence': face_data.get('confidence', 0.0),
'yaw_angle': yaw_angle,
'pitch_angle': pitch_angle,
'roll_angle': roll_angle,
'face_width': face_width, # Eye distance in pixels
'pose_mode': pose_mode
}
```
## Benefits
1. **Better Profile Detection:** Catches extreme profile views where yaw angle might be unreliable
2. **Fallback Indicator:** When yaw calculation fails (None), small face width still indicates profile
3. **More Accurate:** Uses both yaw angle and face width for robust profile detection
4. **Backward Compatible:** `face_width` parameter is optional, so existing code still works
## Threshold
**Profile Face Width Threshold: 25 pixels**
- Faces with eye distance < 25 pixels are classified as profile
- This threshold is based on empirical testing:
- Profile faces: 6-10 pixels (e.g., face_4 with yaw=-30.48° has 6.52 pixels)
- Frontal faces: 50-100+ pixels (e.g., face_2 with yaw=3.04° has 92.94 pixels)
## Testing
To test the implementation:
1. Process photos with profile faces
2. Check pose_mode classification - should see more profile faces detected
3. Verify face_width values in pose detection results
## Example
**Before:** Face with yaw=16.77° might be classified as "frontal" (below 30° threshold)
**After:** Same face with face_width=3.86 pixels is correctly classified as "profile_left"
## Files Modified
- `src/utils/pose_detection.py`:
- Added `calculate_face_width_from_landmarks()` method
- Enhanced `classify_pose_mode()` with face_width parameter
- Updated `detect_pose_faces()` to calculate and use face_width
+97
View File
@@ -0,0 +1,97 @@
# Pose Detection Investigation & Fixes
## Summary
Investigated two issues with pose detection in the faces table:
1. **Pitch angles not being calculated** - All pitch angles were `NULL` in the database
2. **Roll angle normalization** - Roll angles were showing extreme values near ±180° instead of normalized [-90, 90] range
## Issue 1: Pitch Angles Not Calculated
### Root Cause
RetinaFace returns landmark keys as:
- `'mouth_left'` and `'mouth_right'`
But the code was looking for:
- `'left_mouth'` and `'right_mouth'`
This mismatch caused `calculate_pitch_from_landmarks()` to always return `None` because it couldn't find the required landmarks.
### Fix
Updated `calculate_pitch_from_landmarks()` in `src/utils/pose_detection.py` to handle both naming conventions:
```python
# 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')
```
### Result
Pitch angles are now being calculated correctly. Test results show:
- Face 1: Pitch = 18.35° (looking up)
- Face 2: Pitch = -2.96° (slightly down)
- Face 3: Pitch = 3.49° (slightly up)
## Issue 2: Roll Angle Normalization
### Root Cause
The `calculate_roll_from_landmarks()` function uses `atan2(dy, dx)` which returns angles in the range [-180, 180] degrees. When the eye line is nearly horizontal (which is normal for most faces), small values of `dy` relative to `dx` can result in angles near ±180° instead of near 0°.
For example:
- If `dx = -92.94` and `dy = -2.80` (eyes nearly horizontal)
- `atan2(-2.80, -92.94) = -178.28°` (should be ~1.72°)
### Fix
Added normalization to convert roll angles to [-90, 90] range in `calculate_roll_from_landmarks()`:
```python
# 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
```
### Result
Roll angles are now normalized correctly. Database examples:
- Before: -179.18° → After: 0.82°
- Before: 177.25° → After: -2.75°
- Before: -178.97° → After: 1.03°
## Testing
The fixes were tested using `scripts/test_pose_calculation.py`:
- ✅ Pitch angles now calculate correctly
- ✅ Roll angles are normalized to [-90, 90] range
- ✅ All three angles (yaw, pitch, roll) are working as expected
## Database Impact
### Current State (Web Database)
- Total faces: 163
- Faces with angle data: 10 (only those with non-frontal poses)
- Pitch angles: 0 (all NULL before fix)
- Roll angles: 10 (had extreme values before fix)
### After Fix
- Pitch angles will be calculated for all faces processed after the fix
- Roll angles will be normalized to [-90, 90] range
- Existing faces in database will need to be reprocessed to get pitch angles and normalized roll angles
## Files Modified
1. `src/utils/pose_detection.py`
- `calculate_pitch_from_landmarks()`: Fixed landmark key names
- `calculate_roll_from_landmarks()`: Added normalization to [-90, 90] range
## Next Steps
1. **Reprocess existing faces** (optional): To get pitch angles and normalized roll angles for existing faces, reprocess photos through the face detection pipeline
2. **Monitor new faces**: New faces processed after the fix will automatically have correct pitch and roll angles
3. **Update database migration** (if needed): Consider adding a migration script to normalize existing roll angles in the database
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
Analyze all faces to see why most don't have angle data
"""
import sqlite3
import os
db_path = "data/punimtag.db"
if not os.path.exists(db_path):
print(f"❌ Database not found: {db_path}")
exit(1)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get total faces
cursor.execute("SELECT COUNT(*) FROM faces")
total_faces = cursor.fetchone()[0]
# Get faces with angle data
cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NOT NULL OR pitch_angle IS NOT NULL OR roll_angle IS NOT NULL")
faces_with_angles = cursor.fetchone()[0]
# Get faces without any angle data
faces_without_angles = total_faces - faces_with_angles
print("=" * 80)
print("FACE ANGLE DATA ANALYSIS")
print("=" * 80)
print(f"\nTotal faces: {total_faces}")
print(f"Faces WITH angle data: {faces_with_angles}")
print(f"Faces WITHOUT angle data: {faces_without_angles}")
print(f"Percentage with angle data: {(faces_with_angles/total_faces*100):.1f}%")
# Check pose_mode distribution
print("\n" + "=" * 80)
print("POSE_MODE DISTRIBUTION")
print("=" * 80)
cursor.execute("""
SELECT pose_mode, COUNT(*) as count
FROM faces
GROUP BY pose_mode
ORDER BY count DESC
""")
pose_modes = cursor.fetchall()
for row in pose_modes:
percentage = (row['count'] / total_faces) * 100
print(f" {row['pose_mode']:<30} : {row['count']:>4} ({percentage:>5.1f}%)")
# Check faces with pose_mode=frontal but might have high yaw
print("\n" + "=" * 80)
print("FACES WITH POSE_MODE='frontal' BUT NO ANGLE DATA")
print("=" * 80)
print("(These faces might actually be profile faces but weren't analyzed)")
cursor.execute("""
SELECT COUNT(*)
FROM faces
WHERE pose_mode = 'frontal'
AND yaw_angle IS NULL
AND pitch_angle IS NULL
AND roll_angle IS NULL
""")
frontal_no_data = cursor.fetchone()[0]
print(f" Faces with pose_mode='frontal' and no angle data: {frontal_no_data}")
# Check if pose detection is being run for all faces
print("\n" + "=" * 80)
print("ANALYSIS")
print("=" * 80)
print(f"Only {faces_with_angles} out of {total_faces} faces have angle data stored.")
print("This suggests that pose detection is NOT being run for all faces.")
print("\nPossible reasons:")
print(" 1. Pose detection may have been disabled or failed for most faces")
print(" 2. Only faces processed recently have pose data")
print(" 3. Pose detection might only run when RetinaFace is available")
conn.close()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
Analyze why only 6 faces have yaw angle data - investigate the matching process
"""
import sqlite3
import os
import json
db_path = "data/punimtag.db"
if not os.path.exists(db_path):
print(f"❌ Database not found: {db_path}")
exit(1)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get total faces
cursor.execute("SELECT COUNT(*) FROM faces")
total_faces = cursor.fetchone()[0]
# Get faces with angle data
cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NOT NULL")
faces_with_yaw = cursor.fetchone()[0]
# Get faces without angle data
cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NULL AND pitch_angle IS NULL AND roll_angle IS NULL")
faces_without_angles = cursor.fetchone()[0]
print("=" * 80)
print("POSE DATA COVERAGE ANALYSIS")
print("=" * 80)
print(f"\nTotal faces: {total_faces}")
print(f"Faces WITH yaw angle: {faces_with_yaw}")
print(f"Faces WITHOUT any angle data: {faces_without_angles}")
print(f"Coverage: {(faces_with_yaw/total_faces*100):.1f}%")
# Check pose_mode distribution
print("\n" + "=" * 80)
print("POSE_MODE DISTRIBUTION")
print("=" * 80)
cursor.execute("""
SELECT pose_mode, COUNT(*) as count,
SUM(CASE WHEN yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as with_yaw,
SUM(CASE WHEN pitch_angle IS NOT NULL THEN 1 ELSE 0 END) as with_pitch,
SUM(CASE WHEN roll_angle IS NOT NULL THEN 1 ELSE 0 END) as with_roll
FROM faces
GROUP BY pose_mode
ORDER BY count DESC
""")
pose_modes = cursor.fetchall()
for row in pose_modes:
print(f"\n{row['pose_mode']}:")
print(f" Total: {row['count']}")
print(f" With yaw: {row['with_yaw']}")
print(f" With pitch: {row['with_pitch']}")
print(f" With roll: {row['with_roll']}")
# Check photos and see if some photos have pose data while others don't
print("\n" + "=" * 80)
print("POSE DATA BY PHOTO")
print("=" * 80)
cursor.execute("""
SELECT
p.id as photo_id,
p.filename,
COUNT(f.id) as total_faces,
SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw,
SUM(CASE WHEN f.pitch_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_pitch,
SUM(CASE WHEN f.roll_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_roll
FROM photos p
LEFT JOIN faces f ON f.photo_id = p.id
GROUP BY p.id, p.filename
HAVING COUNT(f.id) > 0
ORDER BY faces_with_yaw DESC, total_faces DESC
LIMIT 20
""")
photos = cursor.fetchall()
print(f"\n{'Photo ID':<10} {'Filename':<40} {'Total':<8} {'Yaw':<6} {'Pitch':<7} {'Roll':<6}")
print("-" * 80)
for row in photos:
print(f"{row['photo_id']:<10} {row['filename'][:38]:<40} {row['total_faces']:<8} "
f"{row['faces_with_yaw']:<6} {row['faces_with_pitch']:<7} {row['faces_with_roll']:<6}")
# Check if there's a pattern - maybe older photos don't have pose data
print("\n" + "=" * 80)
print("ANALYSIS")
print("=" * 80)
# Check date added vs pose data
cursor.execute("""
SELECT
DATE(p.date_added) as date_added,
COUNT(f.id) as total_faces,
SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw
FROM photos p
LEFT JOIN faces f ON f.photo_id = p.id
GROUP BY DATE(p.date_added)
ORDER BY date_added DESC
""")
dates = cursor.fetchall()
print("\nFaces by date added:")
print(f"{'Date':<15} {'Total':<8} {'With Yaw':<10} {'Coverage':<10}")
print("-" * 50)
for row in dates:
coverage = (row['faces_with_yaw'] / row['total_faces'] * 100) if row['total_faces'] > 0 else 0
print(f"{row['date_added'] or 'NULL':<15} {row['total_faces']:<8} {row['faces_with_yaw']:<10} {coverage:.1f}%")
# Check if pose detection might be failing for some photos
print("\n" + "=" * 80)
print("POSSIBLE REASONS FOR LOW COVERAGE")
print("=" * 80)
print("\n1. Pose detection might not be running for all photos")
print("2. Matching between DeepFace and RetinaFace might be failing (IoU threshold too strict?)")
print("3. RetinaFace might not be detecting faces in some photos")
print("4. Photos might have been processed before pose detection was fully implemented")
# Check if there are photos with multiple faces where some have pose data and some don't
cursor.execute("""
SELECT
p.id as photo_id,
p.filename,
COUNT(f.id) as total_faces,
SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw,
SUM(CASE WHEN f.yaw_angle IS NULL THEN 1 ELSE 0 END) as faces_without_yaw
FROM photos p
JOIN faces f ON f.photo_id = p.id
GROUP BY p.id, p.filename
HAVING COUNT(f.id) > 1
AND SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN f.yaw_angle IS NULL THEN 1 ELSE 0 END) > 0
ORDER BY total_faces DESC
LIMIT 10
""")
mixed_photos = cursor.fetchall()
if mixed_photos:
print("\n" + "=" * 80)
print("PHOTOS WITH MIXED POSE DATA (some faces have it, some don't)")
print("=" * 80)
print(f"\n{'Photo ID':<10} {'Filename':<40} {'Total':<8} {'With Yaw':<10} {'Without Yaw':<12}")
print("-" * 80)
for row in mixed_photos:
print(f"{row['photo_id']:<10} {row['filename'][:38]:<40} {row['total_faces']:<8} "
f"{row['faces_with_yaw']:<10} {row['faces_without_yaw']:<12}")
print("\n⚠️ This suggests matching is failing for some faces even when pose detection runs")
else:
print("\n✅ No photos found with mixed pose data (all or nothing per photo)")
conn.close()
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Analyze pose_mode values in the faces table
"""
import sqlite3
import sys
import os
from collections import Counter
from typing import Dict, List, Tuple
# Default database path
DEFAULT_DB_PATH = "data/photos.db"
def analyze_poses(db_path: str) -> None:
"""Analyze pose_mode values in faces table"""
if not os.path.exists(db_path):
print(f"❌ Database not found: {db_path}")
return
print(f"📊 Analyzing poses in database: {db_path}\n")
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get total number of faces
cursor.execute("SELECT COUNT(*) FROM faces")
total_faces = cursor.fetchone()[0]
print(f"Total faces in database: {total_faces}\n")
if total_faces == 0:
print("No faces found in database.")
conn.close()
return
# Get pose_mode distribution
cursor.execute("""
SELECT pose_mode, COUNT(*) as count
FROM faces
GROUP BY pose_mode
ORDER BY count DESC
""")
pose_modes = cursor.fetchall()
print("=" * 60)
print("POSE_MODE DISTRIBUTION")
print("=" * 60)
for row in pose_modes:
pose_mode = row['pose_mode'] or 'NULL'
count = row['count']
percentage = (count / total_faces) * 100
print(f" {pose_mode:30s} : {count:6d} ({percentage:5.1f}%)")
print("\n" + "=" * 60)
print("ANGLE STATISTICS")
print("=" * 60)
# Yaw angle statistics
cursor.execute("""
SELECT
COUNT(*) as total,
COUNT(yaw_angle) as with_yaw,
MIN(yaw_angle) as min_yaw,
MAX(yaw_angle) as max_yaw,
AVG(yaw_angle) as avg_yaw
FROM faces
WHERE yaw_angle IS NOT NULL
""")
yaw_stats = cursor.fetchone()
# Pitch angle statistics
cursor.execute("""
SELECT
COUNT(*) as total,
COUNT(pitch_angle) as with_pitch,
MIN(pitch_angle) as min_pitch,
MAX(pitch_angle) as max_pitch,
AVG(pitch_angle) as avg_pitch
FROM faces
WHERE pitch_angle IS NOT NULL
""")
pitch_stats = cursor.fetchone()
# Roll angle statistics
cursor.execute("""
SELECT
COUNT(*) as total,
COUNT(roll_angle) as with_roll,
MIN(roll_angle) as min_roll,
MAX(roll_angle) as max_roll,
AVG(roll_angle) as avg_roll
FROM faces
WHERE roll_angle IS NOT NULL
""")
roll_stats = cursor.fetchone()
print(f"\nYaw Angle:")
print(f" Faces with yaw data: {yaw_stats['with_yaw']}")
if yaw_stats['with_yaw'] > 0:
print(f" Min: {yaw_stats['min_yaw']:.1f}°")
print(f" Max: {yaw_stats['max_yaw']:.1f}°")
print(f" Avg: {yaw_stats['avg_yaw']:.1f}°")
print(f"\nPitch Angle:")
print(f" Faces with pitch data: {pitch_stats['with_pitch']}")
if pitch_stats['with_pitch'] > 0:
print(f" Min: {pitch_stats['min_pitch']:.1f}°")
print(f" Max: {pitch_stats['max_pitch']:.1f}°")
print(f" Avg: {pitch_stats['avg_pitch']:.1f}°")
print(f"\nRoll Angle:")
print(f" Faces with roll data: {roll_stats['with_roll']}")
if roll_stats['with_roll'] > 0:
print(f" Min: {roll_stats['min_roll']:.1f}°")
print(f" Max: {roll_stats['max_roll']:.1f}°")
print(f" Avg: {roll_stats['avg_roll']:.1f}°")
# Sample faces with different poses
print("\n" + "=" * 60)
print("SAMPLE FACES BY POSE")
print("=" * 60)
for row in pose_modes[:10]: # Top 10 pose modes
pose_mode = row['pose_mode']
cursor.execute("""
SELECT id, photo_id, pose_mode, yaw_angle, pitch_angle, roll_angle
FROM faces
WHERE pose_mode = ?
LIMIT 3
""", (pose_mode,))
samples = cursor.fetchall()
print(f"\n{pose_mode}:")
for sample in samples:
yaw_str = f"{sample['yaw_angle']:.1f}°" if sample['yaw_angle'] is not None else "N/A"
pitch_str = f"{sample['pitch_angle']:.1f}°" if sample['pitch_angle'] is not None else "N/A"
roll_str = f"{sample['roll_angle']:.1f}°" if sample['roll_angle'] is not None else "N/A"
print(f" Face ID {sample['id']}: "
f"yaw={yaw_str} "
f"pitch={pitch_str} "
f"roll={roll_str}")
conn.close()
except sqlite3.Error as e:
print(f"❌ Database error: {e}")
except Exception as e:
print(f"❌ Error: {e}")
def check_web_database() -> None:
"""Check if web database exists and analyze it"""
# Common web database locations
web_db_paths = [
"data/punimtag.db", # Default web database
"data/web_photos.db",
"data/photos_web.db",
"web_photos.db",
]
for db_path in web_db_paths:
if os.path.exists(db_path):
print(f"\n{'='*60}")
print(f"WEB DATABASE: {db_path}")
print(f"{'='*60}\n")
analyze_poses(db_path)
break
if __name__ == "__main__":
# Check desktop database
desktop_db = DEFAULT_DB_PATH
if os.path.exists(desktop_db):
analyze_poses(desktop_db)
# Check web database
check_web_database()
# If no database found, list what we tried
if not os.path.exists(desktop_db):
print(f"❌ Desktop database not found: {desktop_db}")
print("\nTrying to find database files...")
for root, dirs, files in os.walk("data"):
for file in files:
if file.endswith(('.db', '.sqlite', '.sqlite3')):
print(f" Found: {os.path.join(root, file)}")
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Check all identified faces for pose information"""
import sqlite3
import sys
import os
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.core.config import DEFAULT_DB_PATH
def check_identified_faces(db_path: str):
"""Check all identified faces for pose information"""
if not os.path.exists(db_path):
print(f"Database not found: {db_path}")
return
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get all identified faces with pose information
cursor.execute('''
SELECT
f.id,
f.person_id,
p.name || ' ' || p.last_name as person_name,
ph.filename,
f.pose_mode,
f.yaw_angle,
f.pitch_angle,
f.roll_angle,
f.face_confidence,
f.quality_score,
f.location
FROM faces f
JOIN people p ON f.person_id = p.id
JOIN photos ph ON f.photo_id = ph.id
WHERE f.person_id IS NOT NULL
ORDER BY p.id, f.id
''')
faces = cursor.fetchall()
if not faces:
print("No identified faces found.")
return
print(f"\n{'='*80}")
print(f"Found {len(faces)} identified faces")
print(f"{'='*80}\n")
# Group by person
by_person = {}
for face in faces:
person_id = face['person_id']
if person_id not in by_person:
by_person[person_id] = []
by_person[person_id].append(face)
# Print summary
print("SUMMARY BY PERSON:")
print("-" * 80)
for person_id, person_faces in by_person.items():
person_name = person_faces[0]['person_name']
pose_modes = [f['pose_mode'] for f in person_faces]
frontal_count = sum(1 for p in pose_modes if p == 'frontal')
profile_count = sum(1 for p in pose_modes if 'profile' in p)
other_count = len(pose_modes) - frontal_count - profile_count
print(f"\nPerson {person_id}: {person_name}")
print(f" Total faces: {len(person_faces)}")
print(f" Frontal: {frontal_count}")
print(f" Profile: {profile_count}")
print(f" Other: {other_count}")
print(f" Pose modes: {set(pose_modes)}")
# Print detailed information
print(f"\n{'='*80}")
print("DETAILED FACE INFORMATION:")
print(f"{'='*80}\n")
for face in faces:
print(f"Face ID: {face['id']}")
print(f" Person: {face['person_name']} (ID: {face['person_id']})")
print(f" Photo: {face['filename']}")
print(f" Pose Mode: {face['pose_mode']}")
print(f" Yaw: {face['yaw_angle']:.2f}°" if face['yaw_angle'] is not None else " Yaw: None")
print(f" Pitch: {face['pitch_angle']:.2f}°" if face['pitch_angle'] is not None else " Pitch: None")
print(f" Roll: {face['roll_angle']:.2f}°" if face['roll_angle'] is not None else " Roll: None")
print(f" Confidence: {face['face_confidence']:.3f}")
print(f" Quality: {face['quality_score']:.3f}")
print(f" Location: {face['location']}")
print()
conn.close()
if __name__ == "__main__":
db_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_DB_PATH
check_identified_faces(db_path)
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Check all identified faces for pose information (web database)"""
import sys
import os
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.web.db.models import Face, Person, Photo
from src.web.db.session import get_database_url
def check_identified_faces():
"""Check all identified faces for pose information"""
db_url = get_database_url()
print(f"Connecting to database: {db_url}")
engine = create_engine(db_url)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Get all identified faces with pose information
faces = (
session.query(Face, Person, Photo)
.join(Person, Face.person_id == Person.id)
.join(Photo, Face.photo_id == Photo.id)
.filter(Face.person_id.isnot(None))
.order_by(Person.id, Face.id)
.all()
)
if not faces:
print("No identified faces found.")
return
print(f"\n{'='*80}")
print(f"Found {len(faces)} identified faces")
print(f"{'='*80}\n")
# Group by person
by_person = {}
for face, person, photo in faces:
person_id = person.id
if person_id not in by_person:
by_person[person_id] = []
by_person[person_id].append((face, person, photo))
# Print summary
print("SUMMARY BY PERSON:")
print("-" * 80)
for person_id, person_faces in by_person.items():
person = person_faces[0][1]
person_name = f"{person.first_name} {person.last_name}"
pose_modes = [f[0].pose_mode for f in person_faces]
frontal_count = sum(1 for p in pose_modes if p == 'frontal')
profile_count = sum(1 for p in pose_modes if 'profile' in p)
other_count = len(pose_modes) - frontal_count - profile_count
print(f"\nPerson {person_id}: {person_name}")
print(f" Total faces: {len(person_faces)}")
print(f" Frontal: {frontal_count}")
print(f" Profile: {profile_count}")
print(f" Other: {other_count}")
print(f" Pose modes: {set(pose_modes)}")
# Print detailed information
print(f"\n{'='*80}")
print("DETAILED FACE INFORMATION:")
print(f"{'='*80}\n")
for face, person, photo in faces:
person_name = f"{person.first_name} {person.last_name}"
print(f"Face ID: {face.id}")
print(f" Person: {person_name} (ID: {face.person_id})")
print(f" Photo: {photo.filename}")
print(f" Pose Mode: {face.pose_mode}")
print(f" Yaw: {face.yaw_angle:.2f}°" if face.yaw_angle is not None else " Yaw: None")
print(f" Pitch: {face.pitch_angle:.2f}°" if face.pitch_angle is not None else " Pitch: None")
print(f" Roll: {face.roll_angle:.2f}°" if face.roll_angle is not None else " Roll: None")
print(f" Confidence: {face.face_confidence:.3f}")
print(f" Quality: {face.quality_score:.3f}")
print(f" Location: {face.location}")
print()
finally:
session.close()
if __name__ == "__main__":
try:
check_identified_faces()
except Exception as e:
print(f"❌ Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Check yaw angles in database to see why profile faces aren't being detected
"""
import sqlite3
import os
db_path = "data/punimtag.db"
if not os.path.exists(db_path):
print(f"❌ Database not found: {db_path}")
exit(1)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get all faces with yaw data
cursor.execute("""
SELECT id, pose_mode, yaw_angle, pitch_angle, roll_angle
FROM faces
WHERE yaw_angle IS NOT NULL
ORDER BY ABS(yaw_angle) DESC
""")
faces = cursor.fetchall()
print(f"Found {len(faces)} faces with yaw data\n")
print("=" * 80)
print("YAW ANGLE ANALYSIS")
print("=" * 80)
print(f"\n{'Face ID':<10} {'Pose Mode':<25} {'Yaw':<10} {'Should be Profile?'}")
print("-" * 80)
PROFILE_THRESHOLD = 30.0 # From pose_detection.py
profile_count = 0
for face in faces:
yaw = face['yaw_angle']
pose_mode = face['pose_mode']
is_profile = abs(yaw) >= PROFILE_THRESHOLD
should_be_profile = "YES" if is_profile else "NO"
if is_profile:
profile_count += 1
print(f"{face['id']:<10} {pose_mode:<25} {yaw:>8.2f}° {should_be_profile}")
print("\n" + "=" * 80)
print(f"Total faces with yaw data: {len(faces)}")
print(f"Faces with |yaw| >= {PROFILE_THRESHOLD}° (should be profile): {profile_count}")
print(f"Faces currently classified as profile: {cursor.execute('SELECT COUNT(*) FROM faces WHERE pose_mode LIKE \"profile%\"').fetchone()[0]}")
print("=" * 80)
# Check yaw distribution
print("\n" + "=" * 80)
print("YAW ANGLE DISTRIBUTION")
print("=" * 80)
cursor.execute("""
SELECT
CASE
WHEN ABS(yaw_angle) < 30 THEN 'frontal (< 30°)'
WHEN ABS(yaw_angle) >= 30 AND ABS(yaw_angle) < 60 THEN 'profile (30-60°)'
WHEN ABS(yaw_angle) >= 60 THEN 'extreme profile (>= 60°)'
ELSE 'unknown'
END as category,
COUNT(*) as count
FROM faces
WHERE yaw_angle IS NOT NULL
GROUP BY category
ORDER BY count DESC
""")
distribution = cursor.fetchall()
for row in distribution:
print(f" {row['category']}: {row['count']} faces")
conn.close()
Regular → Executable
View File
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Test if RetinaFace provides both eyes for profile faces or if one eye is missing
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
try:
from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
from pathlib import Path
if not RETINAFACE_AVAILABLE:
print("❌ RetinaFace not available")
exit(1)
detector = PoseDetector()
# Find test images
test_image_paths = ["demo_photos", "data/uploads"]
test_image = None
for path in test_image_paths:
if os.path.exists(path):
for ext in ['.jpg', '.jpeg', '.png']:
for img_file in Path(path).glob(f'*{ext}'):
test_image = str(img_file)
break
if test_image:
break
if not test_image:
print("❌ No test image found")
exit(1)
print(f"Testing with: {test_image}\n")
print("=" * 80)
print("EYE VISIBILITY ANALYSIS")
print("=" * 80)
faces = detector.detect_faces_with_landmarks(test_image)
if not faces:
print("❌ No faces detected")
exit(1)
print(f"Found {len(faces)} face(s)\n")
for face_key, face_data in faces.items():
landmarks = face_data.get('landmarks', {})
print(f"{face_key}:")
print(f" Landmarks available: {list(landmarks.keys())}")
left_eye = landmarks.get('left_eye')
right_eye = landmarks.get('right_eye')
nose = landmarks.get('nose')
print(f" Left eye: {left_eye}")
print(f" Right eye: {right_eye}")
print(f" Nose: {nose}")
# Check if both eyes are present
both_eyes_present = left_eye is not None and right_eye is not None
only_left_eye = left_eye is not None and right_eye is None
only_right_eye = left_eye is None and right_eye is not None
no_eyes = left_eye is None and right_eye is None
print(f"\n Eye visibility:")
print(f" Both eyes present: {both_eyes_present}")
print(f" Only left eye: {only_left_eye}")
print(f" Only right eye: {only_right_eye}")
print(f" No eyes: {no_eyes}")
# Calculate yaw if possible
yaw = detector.calculate_yaw_from_landmarks(landmarks)
print(f" Yaw angle: {yaw:.2f}°" if yaw is not None else " Yaw angle: None (requires both eyes)")
# Calculate face width if both eyes present
if both_eyes_present:
face_width = abs(right_eye[0] - left_eye[0])
print(f" Face width (eye distance): {face_width:.2f} pixels")
# If face width is very small, it might be a profile view
if face_width < 20:
print(f" ⚠️ Very small face width - likely extreme profile view")
# Classify pose
pitch = detector.calculate_pitch_from_landmarks(landmarks)
roll = detector.calculate_roll_from_landmarks(landmarks)
pose_mode = detector.classify_pose_mode(yaw, pitch, roll)
print(f" Pose mode: {pose_mode}")
print()
print("\n" + "=" * 80)
print("CONCLUSION")
print("=" * 80)
print("""
If RetinaFace provides both eyes even for profile faces:
- We can use eye distance (face width) as an indicator
- Small face width (< 20-30 pixels) suggests extreme profile
- But we can't directly use 'missing eye' as a signal
If RetinaFace sometimes only provides one eye for profile faces:
- We can check if left_eye or right_eye is None
- If only one eye is present, it's likely a profile view
- This would be a strong indicator for profile detection
""")
except ImportError as e:
print(f"❌ Import error: {e}")
print("Make sure you're in the project directory and dependencies are installed")
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
Test pitch and roll angle calculations to investigate issues
"""
import sys
import os
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
try:
from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
import sqlite3
from pathlib import Path
def test_retinaface_landmarks():
"""Test what landmarks RetinaFace actually provides"""
if not RETINAFACE_AVAILABLE:
print("❌ RetinaFace not available")
return
print("=" * 60)
print("TESTING RETINAFACE LANDMARKS")
print("=" * 60)
# Try to find a test image
test_image_paths = [
"demo_photos",
"data/uploads",
"data"
]
detector = PoseDetector()
test_image = None
for path in test_image_paths:
if os.path.exists(path):
for ext in ['.jpg', '.jpeg', '.png']:
for img_file in Path(path).glob(f'*{ext}'):
test_image = str(img_file)
break
if test_image:
break
if not test_image:
print("❌ No test image found")
return
print(f"Using test image: {test_image}")
# Detect faces
faces = detector.detect_faces_with_landmarks(test_image)
if not faces:
print("❌ No faces detected")
return
print(f"\n✅ Found {len(faces)} face(s)")
for face_key, face_data in faces.items():
print(f"\n{face_key}:")
landmarks = face_data.get('landmarks', {})
print(f" Landmarks keys: {list(landmarks.keys())}")
for landmark_name, position in landmarks.items():
print(f" {landmark_name}: {position}")
# Test calculations
yaw = detector.calculate_yaw_from_landmarks(landmarks)
pitch = detector.calculate_pitch_from_landmarks(landmarks)
roll = detector.calculate_roll_from_landmarks(landmarks)
print(f"\n Calculated angles:")
print(f" Yaw: {yaw:.2f}°" if yaw is not None else " Yaw: None")
print(f" Pitch: {pitch:.2f}°" if pitch is not None else " Pitch: None")
print(f" Roll: {roll:.2f}°" if roll is not None else " Roll: None")
# Check which landmarks are missing for pitch
required_for_pitch = ['left_eye', 'right_eye', 'left_mouth', 'right_mouth', 'nose']
missing = [lm for lm in required_for_pitch if lm not in landmarks]
if missing:
print(f" ⚠️ Missing landmarks for pitch: {missing}")
# Check roll calculation
if roll is not None:
left_eye = landmarks.get('left_eye')
right_eye = landmarks.get('right_eye')
if left_eye and right_eye:
dx = right_eye[0] - left_eye[0]
dy = right_eye[1] - left_eye[1]
print(f" Roll calculation details:")
print(f" dx (right_eye[0] - left_eye[0]): {dx:.2f}")
print(f" dy (right_eye[1] - left_eye[1]): {dy:.2f}")
print(f" atan2(dy, dx) = {roll:.2f}°")
# Normalize to [-90, 90] range
normalized_roll = roll
if normalized_roll > 90:
normalized_roll = normalized_roll - 180
elif normalized_roll < -90:
normalized_roll = normalized_roll + 180
print(f" Normalized to [-90, 90]: {normalized_roll:.2f}°")
pose_mode = detector.classify_pose_mode(yaw, pitch, roll)
print(f" Pose mode: {pose_mode}")
def analyze_database_angles():
"""Analyze angles in database to find patterns"""
db_path = "data/punimtag.db"
if not os.path.exists(db_path):
print(f"❌ Database not found: {db_path}")
return
print("\n" + "=" * 60)
print("ANALYZING DATABASE ANGLES")
print("=" * 60)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get faces with angle data
cursor.execute("""
SELECT id, pose_mode, yaw_angle, pitch_angle, roll_angle
FROM faces
WHERE yaw_angle IS NOT NULL OR pitch_angle IS NOT NULL OR roll_angle IS NOT NULL
LIMIT 20
""")
faces = cursor.fetchall()
print(f"\nFound {len(faces)} faces with angle data\n")
for face in faces:
print(f"Face ID {face['id']}: {face['pose_mode']}")
print(f" Yaw: {face['yaw_angle']:.2f}°" if face['yaw_angle'] else " Yaw: None")
print(f" Pitch: {face['pitch_angle']:.2f}°" if face['pitch_angle'] else " Pitch: None")
print(f" Roll: {face['roll_angle']:.2f}°" if face['roll_angle'] else " Roll: None")
# Check roll normalization
if face['roll_angle'] is not None:
roll = face['roll_angle']
normalized = roll
if normalized > 90:
normalized = normalized - 180
elif normalized < -90:
normalized = normalized + 180
print(f" Roll normalized: {normalized:.2f}°")
print()
conn.close()
if __name__ == "__main__":
test_retinaface_landmarks()
analyze_database_angles()
except ImportError as e:
print(f"❌ Import error: {e}")
print("Make sure you're in the project directory and dependencies are installed")