feat: Enhance API startup script and add file hash management for photos
This commit improves the `run_api_with_worker.sh` script by ensuring the virtual environment is created if it doesn't exist and dependencies are installed. It also adds a check to ensure the database schema is up to date. Additionally, new functionality has been introduced to calculate and store file hashes for uploaded photos, preventing duplicates. The database schema has been updated to include a `file_hash` column in the `photos` table, along with an index for efficient querying. The frontend has been updated to handle warnings for duplicate photos during the review process. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
#!/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)
|
||||
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check two identified faces and analyze why their pose modes are wrong"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# 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
|
||||
from src.utils.pose_detection import PoseDetector
|
||||
|
||||
def check_two_faces(face_id1: int = None, face_id2: int = None):
|
||||
"""Check two identified faces and analyze their pose modes"""
|
||||
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
|
||||
query = (
|
||||
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(Face.id)
|
||||
)
|
||||
|
||||
if face_id1:
|
||||
query = query.filter(Face.id == face_id1)
|
||||
elif face_id2:
|
||||
query = query.filter(Face.id == face_id2)
|
||||
|
||||
faces = query.limit(2).all()
|
||||
|
||||
if len(faces) < 2:
|
||||
print(f"Found {len(faces)} identified face(s). Need 2 faces to compare.")
|
||||
if len(faces) == 0:
|
||||
print("No identified faces found.")
|
||||
return
|
||||
print("\nShowing available identified faces:")
|
||||
all_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(Face.id)
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
for face, person, photo in all_faces:
|
||||
print(f" Face ID: {face.id}, Person: {person.first_name} {person.last_name}, Photo: {photo.filename}, Pose: {face.pose_mode}")
|
||||
return
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print("ANALYZING TWO IDENTIFIED FACES")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
for idx, (face, person, photo) in enumerate(faces, 1):
|
||||
person_name = f"{person.first_name} {person.last_name}"
|
||||
|
||||
print(f"{'='*80}")
|
||||
print(f"FACE {idx}: ID {face.id}")
|
||||
print(f"{'='*80}")
|
||||
print(f"Person: {person_name} (ID: {face.person_id})")
|
||||
print(f"Photo: {photo.filename}")
|
||||
print(f"Current 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"Face Width: {face.face_width if hasattr(face, 'face_width') else 'N/A'}")
|
||||
print(f"Confidence: {face.face_confidence:.3f}")
|
||||
print(f"Quality: {face.quality_score:.3f}")
|
||||
print(f"Location: {face.location}")
|
||||
|
||||
# Parse landmarks if available
|
||||
landmarks = None
|
||||
if face.landmarks:
|
||||
try:
|
||||
landmarks = json.loads(face.landmarks)
|
||||
print(f"\nLandmarks:")
|
||||
for key, value in landmarks.items():
|
||||
print(f" {key}: {value}")
|
||||
except json.JSONDecodeError:
|
||||
print(f"\nLandmarks: (invalid JSON)")
|
||||
|
||||
# Recalculate pose mode using current logic
|
||||
print(f"\n{'─'*80}")
|
||||
print("RECALCULATING POSE MODE:")
|
||||
print(f"{'─'*80}")
|
||||
|
||||
# Calculate face width from landmarks if available
|
||||
face_width = None
|
||||
if landmarks:
|
||||
face_width = PoseDetector.calculate_face_width_from_landmarks(landmarks)
|
||||
print(f"Calculated face_width from landmarks: {face_width}")
|
||||
|
||||
# Recalculate pose mode
|
||||
recalculated_pose = PoseDetector.classify_pose_mode(
|
||||
face.yaw_angle,
|
||||
face.pitch_angle,
|
||||
face.roll_angle,
|
||||
face_width,
|
||||
landmarks
|
||||
)
|
||||
|
||||
print(f"Recalculated Pose Mode: {recalculated_pose}")
|
||||
|
||||
if recalculated_pose != face.pose_mode:
|
||||
print(f"⚠️ MISMATCH! Current: '{face.pose_mode}' vs Recalculated: '{recalculated_pose}'")
|
||||
|
||||
# Analyze why
|
||||
print(f"\nAnalysis:")
|
||||
if face.yaw_angle is None:
|
||||
print(f" - Yaw is None")
|
||||
if landmarks:
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
nose = landmarks.get('nose')
|
||||
missing = []
|
||||
if not left_eye:
|
||||
missing.append('left_eye')
|
||||
if not right_eye:
|
||||
missing.append('right_eye')
|
||||
if not nose:
|
||||
missing.append('nose')
|
||||
if missing:
|
||||
print(f" - Missing landmarks: {', '.join(missing)}")
|
||||
print(f" - Should be classified as profile (missing landmarks)")
|
||||
else:
|
||||
print(f" - All landmarks present")
|
||||
if face_width:
|
||||
print(f" - Face width: {face_width}px")
|
||||
if face_width < 25.0:
|
||||
print(f" - Face width < 25px, should be profile")
|
||||
else:
|
||||
print(f" - Face width >= 25px, should be frontal")
|
||||
else:
|
||||
print(f" - No landmarks available")
|
||||
else:
|
||||
abs_yaw = abs(face.yaw_angle)
|
||||
print(f" - Yaw angle: {face.yaw_angle:.2f}° (abs: {abs_yaw:.2f}°)")
|
||||
if abs_yaw >= 30.0:
|
||||
expected = "profile_left" if face.yaw_angle < 0 else "profile_right"
|
||||
print(f" - |yaw| >= 30°, should be '{expected}'")
|
||||
else:
|
||||
print(f" - |yaw| < 30°, should be 'frontal'")
|
||||
else:
|
||||
print(f"✓ Pose mode matches recalculated value")
|
||||
|
||||
print()
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
face_id1 = None
|
||||
face_id2 = None
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
face_id1 = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print(f"Invalid face ID: {sys.argv[1]}")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
face_id2 = int(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Invalid face ID: {sys.argv[2]}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
check_two_faces(face_id1, face_id2)
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to clean up false positive face detections from the database
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the project root to the Python path
|
||||
project_root = os.path.join(os.path.dirname(__file__), '..')
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
|
||||
def main():
|
||||
"""Clean up false positive faces from the database"""
|
||||
print("🧹 PunimTag False Positive Face Cleanup")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialize database and face processor
|
||||
db_manager = DatabaseManager()
|
||||
face_processor = FaceProcessor(db_manager, verbose=1)
|
||||
|
||||
# Clean up false positives
|
||||
removed_count = face_processor.cleanup_false_positive_faces(verbose=True)
|
||||
|
||||
if removed_count > 0:
|
||||
print(f"\n✅ Cleanup complete! Removed {removed_count} false positive faces.")
|
||||
print("You can now re-run face processing with improved filtering.")
|
||||
else:
|
||||
print("\n✅ No false positive faces found to remove.")
|
||||
|
||||
print("\nTo reprocess photos with improved face detection:")
|
||||
print("1. Use the web interface to process photos")
|
||||
print("2. Or use the API endpoints to trigger face processing")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -62,3 +62,4 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
@@ -97,3 +97,4 @@ def grant_delete_permission() -> None:
|
||||
if __name__ == "__main__":
|
||||
grant_delete_permission()
|
||||
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to prepare database for DeepFace
|
||||
Drops all existing tables and recreates with new schema
|
||||
|
||||
⚠️ WARNING: This will delete ALL existing data!
|
||||
Run this script before migrating to DeepFace.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Drop all tables and reinitialize with DeepFace schema"""
|
||||
print("=" * 70)
|
||||
print("DeepFace Migration Script - Database Reset")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("⚠️ WARNING: This will delete ALL existing data!")
|
||||
print()
|
||||
print("This includes:")
|
||||
print(" • All photos")
|
||||
print(" • All faces and face encodings")
|
||||
print(" • All people and person data")
|
||||
print(" • All tags and photo-tag linkages")
|
||||
print()
|
||||
print("The database will be recreated with the new DeepFace schema.")
|
||||
print()
|
||||
|
||||
response = input("Type 'DELETE ALL DATA' to confirm (or anything else to cancel): ")
|
||||
|
||||
if response != "DELETE ALL DATA":
|
||||
print()
|
||||
print("❌ Migration cancelled.")
|
||||
print()
|
||||
return False
|
||||
|
||||
print()
|
||||
print("🗑️ Dropping all existing tables...")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Connect directly to database
|
||||
conn = sqlite3.connect(DEFAULT_DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get list of all tables (excluding SQLite system tables)
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
if not tables:
|
||||
print(" No tables found in database.")
|
||||
else:
|
||||
# Drop all tables in correct order (respecting foreign keys)
|
||||
drop_order = ['phototaglinkage', 'person_encodings', 'faces', 'tags', 'people', 'photos']
|
||||
|
||||
for table in drop_order:
|
||||
if table in tables:
|
||||
cursor.execute(f'DROP TABLE IF EXISTS {table}')
|
||||
print(f" ✓ Dropped table: {table}")
|
||||
|
||||
# Drop any remaining tables not in our list (excluding SQLite system tables)
|
||||
for table in tables:
|
||||
if table not in drop_order and not table.startswith('sqlite_'):
|
||||
cursor.execute(f'DROP TABLE IF EXISTS {table}')
|
||||
print(f" ✓ Dropped table: {table}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print()
|
||||
print("✅ All tables dropped successfully")
|
||||
print()
|
||||
print("🔄 Reinitializing database with DeepFace schema...")
|
||||
print()
|
||||
|
||||
# Reinitialize with new schema
|
||||
db = DatabaseManager(DEFAULT_DB_PATH, verbose=1)
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("✅ Database migration complete!")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" 1. Add photos using the dashboard (File → Add Photos)")
|
||||
print(" 2. Process faces with DeepFace (Tools → Process Faces)")
|
||||
print(" 3. Identify people in the Identify panel")
|
||||
print()
|
||||
print("New DeepFace features:")
|
||||
print(" • 512-dimensional face encodings (vs 128)")
|
||||
print(" • Multiple detector backends (RetinaFace, MTCNN, etc.)")
|
||||
print(" • ArcFace model for improved accuracy")
|
||||
print(" • Face confidence scores from detector")
|
||||
print()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print()
|
||||
print(f"❌ Error during migration: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = migrate_database()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
@@ -60,3 +60,4 @@ echo "3. Run your application - it will connect to PostgreSQL automatically"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update status of a reported photo in the auth database."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import text
|
||||
from src.web.db.session import get_auth_database_url, AuthSessionLocal
|
||||
|
||||
def update_reported_photo_status(report_id: int, new_status: str):
|
||||
"""Update the status of a reported photo."""
|
||||
if AuthSessionLocal is None:
|
||||
raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.")
|
||||
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
# First check if the report exists and get its current status
|
||||
check_result = db.execute(text("""
|
||||
SELECT id, status, review_notes
|
||||
FROM inappropriate_photo_reports
|
||||
WHERE id = :report_id
|
||||
"""), {"report_id": report_id})
|
||||
|
||||
row = check_result.fetchone()
|
||||
if not row:
|
||||
print(f"❌ Reported photo {report_id} not found in database.")
|
||||
return
|
||||
|
||||
current_status = row.status
|
||||
review_notes = row.review_notes
|
||||
|
||||
print(f"📋 Current status: '{current_status}'")
|
||||
if review_notes:
|
||||
print(f"📝 Review notes: '{review_notes}'")
|
||||
|
||||
if current_status == new_status:
|
||||
print(f"ℹ️ Status is already '{new_status}'. No update needed.")
|
||||
return
|
||||
|
||||
# Update the status
|
||||
result = db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = :new_status
|
||||
WHERE id = :report_id
|
||||
"""), {
|
||||
"new_status": new_status,
|
||||
"report_id": report_id
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
print(f"✅ Successfully updated reported photo {report_id} status from '{current_status}' to '{new_status}'")
|
||||
else:
|
||||
print(f"⚠️ No rows updated.")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ Error updating reported photo status: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def find_reported_photo_by_note(search_note: str):
|
||||
"""Find reported photos by review notes."""
|
||||
if AuthSessionLocal is None:
|
||||
raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.")
|
||||
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT id, photo_id, status, review_notes, reported_at
|
||||
FROM inappropriate_photo_reports
|
||||
WHERE review_notes LIKE :search_pattern
|
||||
ORDER BY id DESC
|
||||
"""), {"search_pattern": f"%{search_note}%"})
|
||||
|
||||
rows = result.fetchall()
|
||||
if not rows:
|
||||
print(f"❌ No reported photos found with note containing '{search_note}'")
|
||||
return []
|
||||
|
||||
print(f"📋 Found {len(rows)} reported photo(s) with note containing '{search_note}':\n")
|
||||
for row in rows:
|
||||
print(f" ID: {row.id}, Photo ID: {row.photo_id}, Status: {row.status}")
|
||||
print(f" Notes: {row.review_notes}")
|
||||
print(f" Reported at: {row.reported_at}\n")
|
||||
|
||||
return rows
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error searching for reported photos: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python scripts/update_reported_photo_status.py <report_id> <new_status>")
|
||||
print(" OR: python scripts/update_reported_photo_status.py search <search_text>")
|
||||
print("Example: python scripts/update_reported_photo_status.py 57 dismissed")
|
||||
print("Example: python scripts/update_reported_photo_status.py search 'agree. removed'")
|
||||
sys.exit(1)
|
||||
|
||||
if sys.argv[1] == "search":
|
||||
search_text = sys.argv[2]
|
||||
find_reported_photo_by_note(search_text)
|
||||
else:
|
||||
report_id = int(sys.argv[1])
|
||||
new_status = sys.argv[2]
|
||||
update_reported_photo_status(report_id, new_status)
|
||||
|
||||
Reference in New Issue
Block a user