chore: Add configuration and documentation files for project structure and guidelines
This commit introduces several new files to enhance project organization and developer onboarding. The `.cursorignore` and `.cursorrules` files provide guidelines for Cursor AI, while `CONTRIBUTING.md` outlines contribution procedures. Additionally, `IMPORT_FIX_SUMMARY.md`, `RESTRUCTURE_SUMMARY.md`, and `STATUS.md` summarize recent changes and project status. The `README.md` has been updated to reflect the new project focus and structure, ensuring clarity for contributors and users. These additions aim to improve maintainability and facilitate collaboration within the PunimTag project.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
PunimTag - Photo Management and Facial Recognition System
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "PunimTag Development Team"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Core business logic modules for PunimTag
|
||||
"""
|
||||
|
||||
from .database import DatabaseManager
|
||||
from .face_processing import FaceProcessor
|
||||
from .photo_management import PhotoManager
|
||||
from .tag_management import TagManager
|
||||
from .search_stats import SearchStats
|
||||
|
||||
__all__ = [
|
||||
'DatabaseManager',
|
||||
'FaceProcessor',
|
||||
'PhotoManager',
|
||||
'TagManager',
|
||||
'SearchStats',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration constants and settings for PunimTag
|
||||
"""
|
||||
|
||||
# Default file paths
|
||||
DEFAULT_DB_PATH = "data/photos.db"
|
||||
DEFAULT_CONFIG_FILE = "gui_config.json"
|
||||
DEFAULT_WINDOW_SIZE = "600x500"
|
||||
|
||||
# Face detection settings
|
||||
DEFAULT_FACE_DETECTION_MODEL = "hog"
|
||||
DEFAULT_FACE_TOLERANCE = 0.6
|
||||
DEFAULT_BATCH_SIZE = 20
|
||||
DEFAULT_PROCESSING_LIMIT = 50
|
||||
|
||||
# Face quality settings
|
||||
MIN_FACE_QUALITY = 0.3
|
||||
DEFAULT_CONFIDENCE_THRESHOLD = 0.5
|
||||
|
||||
# GUI settings
|
||||
FACE_CROP_SIZE = 100
|
||||
ICON_SIZE = 20
|
||||
MAX_SUGGESTIONS = 10
|
||||
|
||||
# Database settings
|
||||
DB_TIMEOUT = 30.0
|
||||
|
||||
# Supported image formats
|
||||
SUPPORTED_IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
|
||||
|
||||
# Face crop temporary directory
|
||||
TEMP_FACE_CROP_DIR = "temp_face_crops"
|
||||
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database operations and schema management for PunimTag
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from src.core.config import DEFAULT_DB_PATH, DB_TIMEOUT
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""Handles all database operations for the photo tagger"""
|
||||
|
||||
def __init__(self, db_path: str = DEFAULT_DB_PATH, verbose: int = 0):
|
||||
"""Initialize database manager"""
|
||||
self.db_path = db_path
|
||||
self.verbose = verbose
|
||||
self._db_connection = None
|
||||
self._db_lock = threading.Lock()
|
||||
self.init_database()
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection(self):
|
||||
"""Context manager for database connections with connection pooling"""
|
||||
with self._db_lock:
|
||||
if self._db_connection is None:
|
||||
self._db_connection = sqlite3.connect(self.db_path, timeout=DB_TIMEOUT, check_same_thread=False)
|
||||
self._db_connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield self._db_connection
|
||||
except Exception:
|
||||
self._db_connection.rollback()
|
||||
raise
|
||||
else:
|
||||
self._db_connection.commit()
|
||||
|
||||
def close_db_connection(self):
|
||||
"""Close database connection"""
|
||||
with self._db_lock:
|
||||
if self._db_connection:
|
||||
self._db_connection.close()
|
||||
self._db_connection = None
|
||||
|
||||
def init_database(self):
|
||||
"""Create database tables if they don't exist"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Photos table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
date_added DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
date_taken DATE,
|
||||
processed BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
# People table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
middle_name TEXT,
|
||||
maiden_name TEXT,
|
||||
date_of_birth DATE,
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(first_name, last_name, middle_name, maiden_name, date_of_birth)
|
||||
)
|
||||
''')
|
||||
|
||||
# Faces table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS faces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
photo_id INTEGER NOT NULL,
|
||||
person_id INTEGER,
|
||||
encoding BLOB NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.0,
|
||||
quality_score REAL DEFAULT 0.0,
|
||||
is_primary_encoding BOOLEAN DEFAULT 0,
|
||||
FOREIGN KEY (photo_id) REFERENCES photos (id),
|
||||
FOREIGN KEY (person_id) REFERENCES people (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Person encodings table for multiple encodings per person
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS person_encodings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id INTEGER NOT NULL,
|
||||
face_id INTEGER NOT NULL,
|
||||
encoding BLOB NOT NULL,
|
||||
quality_score REAL DEFAULT 0.0,
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (person_id) REFERENCES people (id),
|
||||
FOREIGN KEY (face_id) REFERENCES faces (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Tags table - holds only tag information
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tag_name TEXT UNIQUE NOT NULL,
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Photo-Tag linkage table
|
||||
# linkage_type: INTEGER enum → 0 = single (per-photo add), 1 = bulk (folder-wide add)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS phototaglinkage (
|
||||
linkage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
photo_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
linkage_type INTEGER NOT NULL DEFAULT 0 CHECK(linkage_type IN (0,1)),
|
||||
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (photo_id) REFERENCES photos (id),
|
||||
FOREIGN KEY (tag_id) REFERENCES tags (id),
|
||||
UNIQUE(photo_id, tag_id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Add indexes for better performance
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_person_id ON faces(person_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_photo_id ON faces(photo_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_processed ON photos(processed)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_quality ON faces(quality_score)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_person_id ON person_encodings(person_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_quality ON person_encodings(quality_score)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_added ON photos(date_added)')
|
||||
|
||||
|
||||
|
||||
if self.verbose >= 1:
|
||||
print(f"✅ Database initialized: {self.db_path}")
|
||||
|
||||
def load_tag_mappings(self) -> Tuple[Dict[int, str], Dict[str, int]]:
|
||||
"""Load tag name to ID and ID to name mappings from database (case-insensitive)"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT id, tag_name FROM tags ORDER BY LOWER(tag_name)')
|
||||
tag_id_to_name = {}
|
||||
tag_name_to_id = {}
|
||||
for row in cursor.fetchall():
|
||||
tag_id, tag_name = row
|
||||
tag_id_to_name[tag_id] = tag_name
|
||||
# Use lowercase for case-insensitive lookups
|
||||
tag_name_to_id[tag_name.lower()] = tag_id
|
||||
return tag_id_to_name, tag_name_to_id
|
||||
|
||||
def get_existing_tag_ids_for_photo(self, photo_id: int) -> List[int]:
|
||||
"""Get list of tag IDs for a photo from database"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT ptl.tag_id
|
||||
FROM phototaglinkage ptl
|
||||
WHERE ptl.photo_id = ?
|
||||
ORDER BY ptl.created_date
|
||||
''', (photo_id,))
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
def get_tag_id_by_name(self, tag_name: str, tag_name_to_id_map: Dict[str, int]) -> Optional[int]:
|
||||
"""Get tag ID by name, creating the tag if it doesn't exist"""
|
||||
if tag_name in tag_name_to_id_map:
|
||||
return tag_name_to_id_map[tag_name]
|
||||
return None
|
||||
|
||||
def get_tag_name_by_id(self, tag_id: int, tag_id_to_name_map: Dict[int, str]) -> str:
|
||||
"""Get tag name by ID"""
|
||||
return tag_id_to_name_map.get(tag_id, f"Unknown Tag {tag_id}")
|
||||
|
||||
def show_people_list(self, cursor=None) -> List[Tuple]:
|
||||
"""Show list of people in database"""
|
||||
if cursor is None:
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date
|
||||
FROM people
|
||||
ORDER BY last_name, first_name
|
||||
''')
|
||||
return cursor.fetchall()
|
||||
|
||||
def add_photo(self, photo_path: str, filename: str, date_taken: Optional[str] = None) -> int:
|
||||
"""Add a photo to the database and return its ID if new, None if already exists"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if photo already exists
|
||||
cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Photo already exists, return None to indicate it wasn't added
|
||||
return None
|
||||
|
||||
# Photo doesn't exist, insert it
|
||||
cursor.execute('''
|
||||
INSERT INTO photos (path, filename, date_taken)
|
||||
VALUES (?, ?, ?)
|
||||
''', (photo_path, filename, date_taken))
|
||||
|
||||
# Get the new photo ID
|
||||
cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,))
|
||||
result = cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
|
||||
def mark_photo_processed(self, photo_id: int):
|
||||
"""Mark a photo as processed"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,))
|
||||
|
||||
def add_face(self, photo_id: int, encoding: bytes, location: str, confidence: float = 0.0,
|
||||
quality_score: float = 0.0, person_id: Optional[int] = None) -> int:
|
||||
"""Add a face to the database and return its ID"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO faces (photo_id, person_id, encoding, location, confidence, quality_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (photo_id, person_id, encoding, location, confidence, quality_score))
|
||||
return cursor.lastrowid
|
||||
|
||||
def update_face_person(self, face_id: int, person_id: Optional[int]):
|
||||
"""Update the person_id for a face"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('UPDATE faces SET person_id = ? WHERE id = ?', (person_id, face_id))
|
||||
|
||||
def add_person(self, first_name: str, last_name: str, middle_name: str = None,
|
||||
maiden_name: str = None, date_of_birth: str = None) -> int:
|
||||
"""Add a person to the database and return their ID (case-insensitive)"""
|
||||
# Normalize names to title case for case-insensitive matching
|
||||
normalized_first = first_name.strip().title()
|
||||
normalized_last = last_name.strip().title()
|
||||
normalized_middle = middle_name.strip().title() if middle_name else ''
|
||||
normalized_maiden = maiden_name.strip().title() if maiden_name else ''
|
||||
normalized_dob = date_of_birth.strip() if date_of_birth else ''
|
||||
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (normalized_first, normalized_last, normalized_middle, normalized_maiden, normalized_dob))
|
||||
|
||||
# Get the person ID (case-insensitive lookup)
|
||||
cursor.execute('''
|
||||
SELECT id FROM people
|
||||
WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?)
|
||||
AND LOWER(COALESCE(middle_name, '')) = LOWER(?) AND LOWER(COALESCE(maiden_name, '')) = LOWER(?)
|
||||
AND date_of_birth = ?
|
||||
''', (normalized_first, normalized_last, normalized_middle, normalized_maiden, normalized_dob))
|
||||
result = cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
|
||||
def add_tag(self, tag_name: str) -> int:
|
||||
"""Add a tag to the database and return its ID (case-insensitive)"""
|
||||
# Normalize tag name to lowercase for consistency
|
||||
normalized_tag_name = tag_name.lower().strip()
|
||||
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Check if tag already exists (case-insensitive)
|
||||
cursor.execute('SELECT id FROM tags WHERE LOWER(tag_name) = ?', (normalized_tag_name,))
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
return existing[0]
|
||||
|
||||
# Insert new tag with original case
|
||||
cursor.execute('INSERT INTO tags (tag_name) VALUES (?)', (tag_name.strip(),))
|
||||
|
||||
# Get the tag ID
|
||||
cursor.execute('SELECT id FROM tags WHERE LOWER(tag_name) = ?', (normalized_tag_name,))
|
||||
result = cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
|
||||
def link_photo_tag(self, photo_id: int, tag_id: int):
|
||||
"""Link a photo to a tag"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id)
|
||||
VALUES (?, ?)
|
||||
''', (photo_id, tag_id))
|
||||
|
||||
def unlink_photo_tag(self, photo_id: int, tag_id: int):
|
||||
"""Unlink a photo from a tag"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
DELETE FROM phototaglinkage
|
||||
WHERE photo_id = ? AND tag_id = ?
|
||||
''', (photo_id, tag_id))
|
||||
|
||||
def get_photos_by_pattern(self, pattern: str = None, limit: int = 10) -> List[Tuple]:
|
||||
"""Get photos matching a pattern"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
if pattern:
|
||||
cursor.execute('''
|
||||
SELECT id, path, filename, date_taken, processed
|
||||
FROM photos
|
||||
WHERE filename LIKE ? OR path LIKE ?
|
||||
ORDER BY date_added DESC
|
||||
LIMIT ?
|
||||
''', (f'%{pattern}%', f'%{pattern}%', limit))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT id, path, filename, date_taken, processed
|
||||
FROM photos
|
||||
ORDER BY date_added DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_unprocessed_photos(self, limit: int = 50) -> List[Tuple]:
|
||||
"""Get unprocessed photos"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, path, filename, date_taken
|
||||
FROM photos
|
||||
WHERE processed = 0
|
||||
ORDER BY date_added ASC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_unidentified_faces(self, limit: int = 20) -> List[Tuple]:
|
||||
"""Get unidentified faces"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT f.id, f.photo_id, f.location, f.confidence, f.quality_score,
|
||||
p.path, p.filename
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.person_id IS NULL
|
||||
ORDER BY f.quality_score DESC, f.confidence DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_face_encodings(self, face_id: int) -> Optional[bytes]:
|
||||
"""Get face encoding for a specific face"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT encoding FROM faces WHERE id = ?', (face_id,))
|
||||
result = cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
|
||||
def get_face_photo_info(self, face_id: int) -> Optional[Tuple]:
|
||||
"""Get photo information for a specific face"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT f.photo_id, p.filename, f.location
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.id = ?
|
||||
''', (face_id,))
|
||||
result = cursor.fetchone()
|
||||
return result if result else None
|
||||
|
||||
def get_all_face_encodings(self) -> List[Tuple]:
|
||||
"""Get all face encodings with their IDs"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT id, encoding, person_id, quality_score FROM faces')
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_person_encodings(self, person_id: int, min_quality: float = 0.3) -> List[Tuple]:
|
||||
"""Get all encodings for a person above minimum quality"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT pe.encoding, pe.quality_score, pe.face_id
|
||||
FROM person_encodings pe
|
||||
WHERE pe.person_id = ? AND pe.quality_score >= ?
|
||||
ORDER BY pe.quality_score DESC
|
||||
''', (person_id, min_quality))
|
||||
return cursor.fetchall()
|
||||
|
||||
def add_person_encoding(self, person_id: int, face_id: int, encoding: bytes, quality_score: float):
|
||||
"""Add a person encoding"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO person_encodings (person_id, face_id, encoding, quality_score)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (person_id, face_id, encoding, quality_score))
|
||||
|
||||
def update_person_encodings(self, person_id: int):
|
||||
"""Update person encodings by removing old ones and adding current face encodings"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Remove old encodings
|
||||
cursor.execute('DELETE FROM person_encodings WHERE person_id = ?', (person_id,))
|
||||
|
||||
# Add current face encodings
|
||||
cursor.execute('''
|
||||
INSERT INTO person_encodings (person_id, face_id, encoding, quality_score)
|
||||
SELECT ?, id, encoding, quality_score
|
||||
FROM faces
|
||||
WHERE person_id = ? AND quality_score >= 0.3
|
||||
''', (person_id, person_id))
|
||||
|
||||
def get_similar_faces(self, face_id: int, tolerance: float = 0.6,
|
||||
include_same_photo: bool = False) -> List[Dict]:
|
||||
"""Get faces similar to the given face ID"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get the target face encoding and photo
|
||||
cursor.execute('''
|
||||
SELECT f.encoding, f.photo_id, p.path, p.filename
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.id = ?
|
||||
''', (face_id,))
|
||||
target_result = cursor.fetchone()
|
||||
|
||||
if not target_result:
|
||||
return []
|
||||
|
||||
target_encoding = target_result[0]
|
||||
target_photo_id = target_result[1]
|
||||
target_path = target_result[2]
|
||||
target_filename = target_result[3]
|
||||
|
||||
# Get all other faces
|
||||
if include_same_photo:
|
||||
cursor.execute('''
|
||||
SELECT f.id, f.encoding, f.person_id, f.quality_score, f.confidence,
|
||||
p.path, p.filename, f.photo_id
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.id != ?
|
||||
''', (face_id,))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT f.id, f.encoding, f.person_id, f.quality_score, f.confidence,
|
||||
p.path, p.filename, f.photo_id
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.id != ? AND f.photo_id != ?
|
||||
''', (face_id, target_photo_id))
|
||||
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""Get database statistics"""
|
||||
with self.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
stats = {}
|
||||
|
||||
# Photo statistics
|
||||
cursor.execute('SELECT COUNT(*) FROM photos')
|
||||
stats['total_photos'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM photos WHERE processed = 1')
|
||||
stats['processed_photos'] = cursor.fetchone()[0]
|
||||
|
||||
# Face statistics
|
||||
cursor.execute('SELECT COUNT(*) FROM faces')
|
||||
stats['total_faces'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL')
|
||||
stats['identified_faces'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM faces WHERE person_id IS NULL')
|
||||
stats['unidentified_faces'] = cursor.fetchone()[0]
|
||||
|
||||
# People statistics
|
||||
cursor.execute('SELECT COUNT(*) FROM people')
|
||||
stats['total_people'] = cursor.fetchone()[0]
|
||||
|
||||
# Tag statistics
|
||||
cursor.execute('SELECT COUNT(*) FROM tags')
|
||||
stats['total_tags'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM phototaglinkage')
|
||||
stats['total_photo_tags'] = cursor.fetchone()[0]
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,883 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Face detection, encoding, and matching functionality for PunimTag
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import numpy as np
|
||||
import face_recognition
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from functools import lru_cache
|
||||
|
||||
from src.core.config import DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE, MIN_FACE_QUALITY
|
||||
from src.core.database import DatabaseManager
|
||||
|
||||
|
||||
class FaceProcessor:
|
||||
"""Handles face detection, encoding, and matching operations"""
|
||||
|
||||
def __init__(self, db_manager: DatabaseManager, verbose: int = 0):
|
||||
"""Initialize face processor"""
|
||||
self.db = db_manager
|
||||
self.verbose = verbose
|
||||
self._face_encoding_cache = {}
|
||||
self._image_cache = {}
|
||||
|
||||
@lru_cache(maxsize=1000)
|
||||
def _get_cached_face_encoding(self, face_id: int, encoding_bytes: bytes) -> np.ndarray:
|
||||
"""Cache face encodings to avoid repeated numpy conversions"""
|
||||
return np.frombuffer(encoding_bytes, dtype=np.float64)
|
||||
|
||||
def _clear_caches(self):
|
||||
"""Clear all caches to free memory"""
|
||||
self._face_encoding_cache.clear()
|
||||
self._image_cache.clear()
|
||||
self._get_cached_face_encoding.cache_clear()
|
||||
|
||||
def cleanup_face_crops(self, current_face_crop_path=None):
|
||||
"""Clean up face crop files and caches"""
|
||||
# Clean up current face crop if provided
|
||||
if current_face_crop_path and os.path.exists(current_face_crop_path):
|
||||
try:
|
||||
os.remove(current_face_crop_path)
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
# Clean up all cached face crop files
|
||||
for cache_key, cached_path in list(self._image_cache.items()):
|
||||
if os.path.exists(cached_path):
|
||||
try:
|
||||
os.remove(cached_path)
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
# Clear caches
|
||||
self._clear_caches()
|
||||
|
||||
def process_faces(self, limit: int = 50, model: str = DEFAULT_FACE_DETECTION_MODEL, progress_callback=None, stop_event=None) -> int:
|
||||
"""Process unprocessed photos for faces
|
||||
|
||||
If provided, progress_callback will be called as progress_callback(index, total, filename)
|
||||
where index is 1-based count of processed photos so far.
|
||||
"""
|
||||
unprocessed = self.db.get_unprocessed_photos(limit)
|
||||
|
||||
if not unprocessed:
|
||||
print("✅ No unprocessed photos found")
|
||||
return 0
|
||||
|
||||
print(f"🔍 Processing {len(unprocessed)} photos for faces...")
|
||||
processed_count = 0
|
||||
|
||||
total_count = len(unprocessed)
|
||||
for photo_id, photo_path, filename, date_taken in unprocessed:
|
||||
# Cooperative cancellation
|
||||
if stop_event is not None and getattr(stop_event, 'is_set', None) and stop_event.is_set():
|
||||
print("⏹️ Processing cancelled by user")
|
||||
break
|
||||
# Notify UI/CLI before starting this photo
|
||||
if callable(progress_callback):
|
||||
try:
|
||||
progress_callback(processed_count + 1, total_count, filename)
|
||||
except Exception:
|
||||
# Best-effort progress; ignore callback errors
|
||||
pass
|
||||
if not os.path.exists(photo_path):
|
||||
print(f"❌ File not found: {filename}")
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
continue
|
||||
|
||||
try:
|
||||
# Load image and find faces
|
||||
if self.verbose >= 1:
|
||||
print(f"📸 Processing: {filename}")
|
||||
elif self.verbose == 0:
|
||||
print(".", end="", flush=True)
|
||||
|
||||
if self.verbose >= 2:
|
||||
print(f" 🔍 Loading image: {photo_path}")
|
||||
|
||||
image = face_recognition.load_image_file(photo_path)
|
||||
face_locations = face_recognition.face_locations(image, model=model)
|
||||
|
||||
if face_locations:
|
||||
face_encodings = face_recognition.face_encodings(image, face_locations)
|
||||
if self.verbose >= 1:
|
||||
print(f" 👤 Found {len(face_locations)} faces")
|
||||
|
||||
# Save faces to database with quality scores
|
||||
for i, (encoding, location) in enumerate(zip(face_encodings, face_locations)):
|
||||
# Check cancellation within inner loop as well
|
||||
if stop_event is not None and getattr(stop_event, 'is_set', None) and stop_event.is_set():
|
||||
print("⏹️ Processing cancelled by user")
|
||||
break
|
||||
# Calculate face quality score
|
||||
quality_score = self._calculate_face_quality_score(image, location)
|
||||
|
||||
self.db.add_face(
|
||||
photo_id=photo_id,
|
||||
encoding=encoding.tobytes(),
|
||||
location=str(location),
|
||||
quality_score=quality_score
|
||||
)
|
||||
if self.verbose >= 3:
|
||||
print(f" Face {i+1}: {location} (quality: {quality_score:.2f})")
|
||||
else:
|
||||
if self.verbose >= 1:
|
||||
print(f" 👤 No faces found")
|
||||
elif self.verbose >= 2:
|
||||
print(f" 👤 {filename}: No faces found")
|
||||
|
||||
# Mark as processed
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
processed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing {filename}: {e}")
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
|
||||
if self.verbose == 0:
|
||||
print() # New line after dots
|
||||
print(f"✅ Processed {processed_count} photos")
|
||||
return processed_count
|
||||
|
||||
def _calculate_face_quality_score(self, image: np.ndarray, face_location: tuple) -> float:
|
||||
"""Calculate face quality score based on multiple factors"""
|
||||
try:
|
||||
top, right, bottom, left = face_location
|
||||
face_height = bottom - top
|
||||
face_width = right - left
|
||||
|
||||
# Basic size check - faces too small get lower scores
|
||||
min_face_size = 50
|
||||
size_score = min(1.0, (face_height * face_width) / (min_face_size * min_face_size))
|
||||
|
||||
# Extract face region
|
||||
face_region = image[top:bottom, left:right]
|
||||
if face_region.size == 0:
|
||||
return 0.0
|
||||
|
||||
# Convert to grayscale for analysis
|
||||
if len(face_region.shape) == 3:
|
||||
gray_face = np.mean(face_region, axis=2)
|
||||
else:
|
||||
gray_face = face_region
|
||||
|
||||
# Calculate sharpness (Laplacian variance)
|
||||
laplacian_var = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32))
|
||||
if laplacian_var > 0:
|
||||
sharpness = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32))
|
||||
else:
|
||||
sharpness = 0.0
|
||||
sharpness_score = min(1.0, sharpness / 1000.0) # Normalize sharpness
|
||||
|
||||
# Calculate brightness and contrast
|
||||
mean_brightness = np.mean(gray_face)
|
||||
brightness_score = 1.0 - abs(mean_brightness - 128) / 128.0 # Prefer middle brightness
|
||||
|
||||
contrast = np.std(gray_face)
|
||||
contrast_score = min(1.0, contrast / 64.0) # Prefer good contrast
|
||||
|
||||
# Calculate aspect ratio (faces should be roughly square)
|
||||
aspect_ratio = face_width / face_height if face_height > 0 else 1.0
|
||||
aspect_score = 1.0 - abs(aspect_ratio - 1.0) # Prefer square faces
|
||||
|
||||
# Calculate position in image (centered faces are better)
|
||||
image_height, image_width = image.shape[:2]
|
||||
center_x = (left + right) / 2
|
||||
center_y = (top + bottom) / 2
|
||||
position_x_score = 1.0 - abs(center_x - image_width / 2) / (image_width / 2)
|
||||
position_y_score = 1.0 - abs(center_y - image_height / 2) / (image_height / 2)
|
||||
position_score = (position_x_score + position_y_score) / 2.0
|
||||
|
||||
# Weighted combination of all factors
|
||||
quality_score = (
|
||||
size_score * 0.25 +
|
||||
sharpness_score * 0.25 +
|
||||
brightness_score * 0.15 +
|
||||
contrast_score * 0.15 +
|
||||
aspect_score * 0.10 +
|
||||
position_score * 0.10
|
||||
)
|
||||
|
||||
return max(0.0, min(1.0, quality_score))
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose >= 2:
|
||||
print(f"⚠️ Error calculating face quality: {e}")
|
||||
return 0.5 # Default medium quality on error
|
||||
|
||||
def _extract_face_crop(self, photo_path: str, location: tuple, face_id: int) -> str:
|
||||
"""Extract and save individual face crop for identification with caching"""
|
||||
try:
|
||||
# Check cache first
|
||||
cache_key = f"{photo_path}_{location}_{face_id}"
|
||||
if cache_key in self._image_cache:
|
||||
cached_path = self._image_cache[cache_key]
|
||||
# Verify the cached file still exists
|
||||
if os.path.exists(cached_path):
|
||||
return cached_path
|
||||
else:
|
||||
# Remove from cache if file doesn't exist
|
||||
del self._image_cache[cache_key]
|
||||
|
||||
# Parse location tuple from string format
|
||||
if isinstance(location, str):
|
||||
location = eval(location)
|
||||
|
||||
top, right, bottom, left = location
|
||||
|
||||
# Load the image
|
||||
image = Image.open(photo_path)
|
||||
|
||||
# Add padding around the face (20% of face size)
|
||||
face_width = right - left
|
||||
face_height = bottom - top
|
||||
padding_x = int(face_width * 0.2)
|
||||
padding_y = int(face_height * 0.2)
|
||||
|
||||
# Calculate crop bounds with padding
|
||||
crop_left = max(0, left - padding_x)
|
||||
crop_top = max(0, top - padding_y)
|
||||
crop_right = min(image.width, right + padding_x)
|
||||
crop_bottom = min(image.height, bottom + padding_y)
|
||||
|
||||
# Crop the face
|
||||
face_crop = image.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||||
|
||||
# Create temporary file for the face crop
|
||||
temp_dir = tempfile.gettempdir()
|
||||
face_filename = f"face_{face_id}_crop.jpg"
|
||||
face_path = os.path.join(temp_dir, face_filename)
|
||||
|
||||
# Resize for better viewing (minimum 200px width)
|
||||
if face_crop.width < 200:
|
||||
ratio = 200 / face_crop.width
|
||||
new_width = 200
|
||||
new_height = int(face_crop.height * ratio)
|
||||
face_crop = face_crop.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
face_crop.save(face_path, "JPEG", quality=95)
|
||||
|
||||
# Cache the result
|
||||
self._image_cache[cache_key] = face_path
|
||||
return face_path
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose >= 1:
|
||||
print(f"⚠️ Could not extract face crop: {e}")
|
||||
return None
|
||||
|
||||
def _create_comparison_image(self, unid_crop_path: str, match_crop_path: str, person_name: str, confidence: float) -> str:
|
||||
"""Create a side-by-side comparison image"""
|
||||
try:
|
||||
# Load both face crops
|
||||
unid_img = Image.open(unid_crop_path)
|
||||
match_img = Image.open(match_crop_path)
|
||||
|
||||
# Resize both to same height for better comparison
|
||||
target_height = 300
|
||||
unid_ratio = target_height / unid_img.height
|
||||
match_ratio = target_height / match_img.height
|
||||
|
||||
unid_resized = unid_img.resize((int(unid_img.width * unid_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
match_resized = match_img.resize((int(match_img.width * match_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create comparison image
|
||||
total_width = unid_resized.width + match_resized.width + 20 # 20px gap
|
||||
comparison = Image.new('RGB', (total_width, target_height + 60), 'white')
|
||||
|
||||
# Paste images
|
||||
comparison.paste(unid_resized, (0, 30))
|
||||
comparison.paste(match_resized, (unid_resized.width + 20, 30))
|
||||
|
||||
# Add labels
|
||||
draw = ImageDraw.Draw(comparison)
|
||||
try:
|
||||
# Try to use a font
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw.text((10, 5), "UNKNOWN", fill='red', font=font)
|
||||
draw.text((unid_resized.width + 30, 5), f"{person_name.upper()}", fill='green', font=font)
|
||||
draw.text((10, target_height + 35), f"Confidence: {confidence:.1%}", fill='blue', font=font)
|
||||
|
||||
# Save comparison image
|
||||
temp_dir = tempfile.gettempdir()
|
||||
comparison_path = os.path.join(temp_dir, f"face_comparison_{person_name}.jpg")
|
||||
comparison.save(comparison_path, "JPEG", quality=95)
|
||||
|
||||
return comparison_path
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose >= 1:
|
||||
print(f"⚠️ Could not create comparison image: {e}")
|
||||
return None
|
||||
|
||||
def _get_confidence_description(self, confidence_pct: float) -> str:
|
||||
"""Get human-readable confidence description"""
|
||||
if confidence_pct >= 80:
|
||||
return "🟢 (Very High - Almost Certain)"
|
||||
elif confidence_pct >= 70:
|
||||
return "🟡 (High - Likely Match)"
|
||||
elif confidence_pct >= 60:
|
||||
return "🟠 (Medium - Possible Match)"
|
||||
elif confidence_pct >= 50:
|
||||
return "🔴 (Low - Questionable)"
|
||||
else:
|
||||
return "⚫ (Very Low)"
|
||||
|
||||
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
|
||||
"""Calculate adaptive tolerance based on face quality and match confidence"""
|
||||
# Start with base tolerance
|
||||
tolerance = base_tolerance
|
||||
|
||||
# Adjust based on face quality (higher quality = stricter tolerance)
|
||||
# More conservative: range 0.9 to 1.1 instead of 0.8 to 1.2
|
||||
quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1
|
||||
tolerance *= quality_factor
|
||||
|
||||
# If we have match confidence, adjust further
|
||||
if match_confidence is not None:
|
||||
# Higher confidence matches can use stricter tolerance
|
||||
# More conservative: range 0.95 to 1.05 instead of 0.9 to 1.1
|
||||
confidence_factor = 0.95 + (match_confidence * 0.1) # Range: 0.95 to 1.05
|
||||
tolerance *= confidence_factor
|
||||
|
||||
# Ensure tolerance stays within reasonable bounds
|
||||
return max(0.3, min(0.8, tolerance)) # Reduced max from 0.9 to 0.8
|
||||
|
||||
def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None) -> List[Dict]:
|
||||
"""Get similar faces with consistent filtering and sorting logic used by both auto-match and identify"""
|
||||
# Find similar faces using the core function
|
||||
similar_faces_data = self.find_similar_faces(face_id, tolerance=tolerance, include_same_photo=include_same_photo)
|
||||
|
||||
# Filter to only show unidentified faces with confidence filtering
|
||||
filtered_faces = []
|
||||
for face in similar_faces_data:
|
||||
# For auto-match: only filter by database state (keep existing behavior)
|
||||
# For identify: also filter by current session state
|
||||
is_identified_in_db = face.get('person_id') is not None
|
||||
is_identified_in_session = face_status and face.get('face_id') in face_status and face_status[face.get('face_id')] == 'identified'
|
||||
|
||||
# If face_status is provided (identify mode), use both filters
|
||||
# If face_status is None (auto-match mode), only use database filter
|
||||
if face_status is not None:
|
||||
# Identify mode: filter out both database and session identified faces
|
||||
if not is_identified_in_db and not is_identified_in_session:
|
||||
# Calculate confidence percentage
|
||||
confidence_pct = (1 - face['distance']) * 100
|
||||
|
||||
# Only include matches with reasonable confidence (at least 40%)
|
||||
if confidence_pct >= 40:
|
||||
filtered_faces.append(face)
|
||||
else:
|
||||
# Auto-match mode: only filter by database state (keep existing behavior)
|
||||
if not is_identified_in_db:
|
||||
# Calculate confidence percentage
|
||||
confidence_pct = (1 - face['distance']) * 100
|
||||
|
||||
# Only include matches with reasonable confidence (at least 40%)
|
||||
if confidence_pct >= 40:
|
||||
filtered_faces.append(face)
|
||||
|
||||
# Sort by confidence (distance) - highest confidence first
|
||||
filtered_faces.sort(key=lambda x: x['distance'])
|
||||
|
||||
return filtered_faces
|
||||
|
||||
def _filter_unique_faces(self, faces: List[Dict]) -> List[Dict]:
|
||||
"""Filter faces to show only unique ones, hiding duplicates with high/medium confidence matches"""
|
||||
if not faces:
|
||||
return faces
|
||||
|
||||
unique_faces = []
|
||||
seen_face_groups = set() # Track face groups that have been seen
|
||||
|
||||
for face in faces:
|
||||
face_id = face['face_id']
|
||||
confidence_pct = (1 - face['distance']) * 100
|
||||
|
||||
# Only consider high (>=70%) or medium (>=60%) confidence matches for grouping
|
||||
if confidence_pct >= 60:
|
||||
# Find all faces that match this one with high/medium confidence
|
||||
matching_face_ids = set()
|
||||
for other_face in faces:
|
||||
other_face_id = other_face['face_id']
|
||||
other_confidence_pct = (1 - other_face['distance']) * 100
|
||||
|
||||
# If this face matches the current face with high/medium confidence
|
||||
if other_confidence_pct >= 60:
|
||||
matching_face_ids.add(other_face_id)
|
||||
|
||||
# Create a sorted tuple to represent this group of matching faces
|
||||
face_group = tuple(sorted(matching_face_ids))
|
||||
|
||||
# Only show this face if we haven't seen this group before
|
||||
if face_group not in seen_face_groups:
|
||||
seen_face_groups.add(face_group)
|
||||
unique_faces.append(face)
|
||||
else:
|
||||
# For low confidence matches, always show them (they're likely different people)
|
||||
unique_faces.append(face)
|
||||
|
||||
return unique_faces
|
||||
|
||||
def find_similar_faces(self, face_id: int = None, tolerance: float = DEFAULT_FACE_TOLERANCE, include_same_photo: bool = False) -> List[Dict]:
|
||||
"""Find similar faces across all photos with improved multi-encoding and quality scoring"""
|
||||
if face_id:
|
||||
# Find faces similar to a specific face
|
||||
target_face = self.db.get_face_encodings(face_id)
|
||||
if not target_face:
|
||||
print(f"❌ Face ID {face_id} not found")
|
||||
return []
|
||||
|
||||
target_encoding = self._get_cached_face_encoding(face_id, target_face)
|
||||
|
||||
# Get all other faces with quality scores
|
||||
all_faces = self.db.get_all_face_encodings()
|
||||
matches = []
|
||||
|
||||
# Compare target face with all other faces using adaptive tolerance
|
||||
for face_data in all_faces:
|
||||
other_id, other_encoding, other_person_id, other_quality = face_data
|
||||
if other_id == face_id:
|
||||
continue
|
||||
|
||||
other_enc = self._get_cached_face_encoding(other_id, other_encoding)
|
||||
|
||||
# Calculate adaptive tolerance based on both face qualities
|
||||
target_quality = 0.5 # Default quality for target face
|
||||
avg_quality = (target_quality + other_quality) / 2
|
||||
adaptive_tolerance = self._calculate_adaptive_tolerance(tolerance, avg_quality)
|
||||
|
||||
distance = face_recognition.face_distance([target_encoding], other_enc)[0]
|
||||
if distance <= adaptive_tolerance:
|
||||
# Get photo info for this face
|
||||
photo_info = self.db.get_face_photo_info(other_id)
|
||||
if photo_info:
|
||||
matches.append({
|
||||
'face_id': other_id,
|
||||
'person_id': other_person_id,
|
||||
'distance': distance,
|
||||
'quality_score': other_quality,
|
||||
'adaptive_tolerance': adaptive_tolerance,
|
||||
'photo_id': photo_info[0],
|
||||
'filename': photo_info[1],
|
||||
'location': photo_info[2]
|
||||
})
|
||||
|
||||
return matches
|
||||
|
||||
else:
|
||||
# Find all unidentified faces and try to match them with identified ones
|
||||
all_faces = self.db.get_all_face_encodings()
|
||||
matches = []
|
||||
|
||||
# Auto-match unidentified faces with identified ones using multi-encoding
|
||||
identified_faces = [f for f in all_faces if f[2] is not None] # person_id is not None
|
||||
unidentified_faces = [f for f in all_faces if f[2] is None] # person_id is None
|
||||
|
||||
print(f"\n🔍 Auto-matching {len(unidentified_faces)} unidentified faces with {len(identified_faces)} known faces...")
|
||||
|
||||
# Group identified faces by person
|
||||
person_encodings = {}
|
||||
for id_face in identified_faces:
|
||||
person_id = id_face[2]
|
||||
if person_id not in person_encodings:
|
||||
id_enc = self._get_cached_face_encoding(id_face[0], id_face[1])
|
||||
person_encodings[person_id] = [(id_enc, id_face[3])]
|
||||
|
||||
for unid_face in unidentified_faces:
|
||||
unid_id, unid_encoding, _, unid_quality = unid_face
|
||||
unid_enc = self._get_cached_face_encoding(unid_id, unid_encoding)
|
||||
|
||||
best_match = None
|
||||
best_distance = float('inf')
|
||||
best_person_id = None
|
||||
|
||||
# Compare with all person encodings
|
||||
for person_id, encodings in person_encodings.items():
|
||||
for person_enc, person_quality in encodings:
|
||||
# Calculate adaptive tolerance based on both face qualities
|
||||
avg_quality = (unid_quality + person_quality) / 2
|
||||
adaptive_tolerance = self._calculate_adaptive_tolerance(tolerance, avg_quality)
|
||||
|
||||
distance = face_recognition.face_distance([unid_enc], person_enc)[0]
|
||||
|
||||
if distance <= adaptive_tolerance and distance < best_distance:
|
||||
best_distance = distance
|
||||
best_person_id = person_id
|
||||
|
||||
best_match = {
|
||||
'unidentified_id': unid_id,
|
||||
'person_id': person_id,
|
||||
'distance': distance,
|
||||
'quality_score': unid_quality,
|
||||
'adaptive_tolerance': adaptive_tolerance
|
||||
}
|
||||
|
||||
if best_match:
|
||||
matches.append(best_match)
|
||||
|
||||
return matches
|
||||
|
||||
def add_person_encoding(self, person_id: int, face_id: int, encoding: np.ndarray, quality_score: float):
|
||||
"""Add a face encoding to a person's encoding collection"""
|
||||
self.db.add_person_encoding(person_id, face_id, encoding.tobytes(), quality_score)
|
||||
|
||||
def get_person_encodings(self, person_id: int, min_quality: float = MIN_FACE_QUALITY) -> List[Tuple[np.ndarray, float]]:
|
||||
"""Get all high-quality encodings for a person"""
|
||||
results = self.db.get_person_encodings(person_id, min_quality)
|
||||
return [(np.frombuffer(encoding, dtype=np.float64), quality_score) for encoding, quality_score in results]
|
||||
|
||||
def update_person_encodings(self, person_id: int):
|
||||
"""Update person encodings when a face is identified"""
|
||||
self.db.update_person_encodings(person_id)
|
||||
|
||||
def _extract_face_crop(self, photo_path: str, location: tuple, face_id: int) -> str:
|
||||
"""Extract and save individual face crop for identification with caching"""
|
||||
try:
|
||||
# Check cache first
|
||||
cache_key = f"{photo_path}_{location}_{face_id}"
|
||||
if cache_key in self._image_cache:
|
||||
cached_path = self._image_cache[cache_key]
|
||||
# Verify the cached file still exists
|
||||
if os.path.exists(cached_path):
|
||||
return cached_path
|
||||
else:
|
||||
# Remove from cache if file doesn't exist
|
||||
del self._image_cache[cache_key]
|
||||
|
||||
# Parse location tuple from string format
|
||||
if isinstance(location, str):
|
||||
location = eval(location)
|
||||
|
||||
top, right, bottom, left = location
|
||||
|
||||
# Load the image
|
||||
image = Image.open(photo_path)
|
||||
|
||||
# Add padding around the face (20% of face size)
|
||||
face_width = right - left
|
||||
face_height = bottom - top
|
||||
padding_x = int(face_width * 0.2)
|
||||
padding_y = int(face_height * 0.2)
|
||||
|
||||
# Calculate crop bounds with padding
|
||||
crop_left = max(0, left - padding_x)
|
||||
crop_top = max(0, top - padding_y)
|
||||
crop_right = min(image.width, right + padding_x)
|
||||
crop_bottom = min(image.height, bottom + padding_y)
|
||||
|
||||
# Crop the face
|
||||
face_crop = image.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||||
|
||||
# Create temporary file for the face crop
|
||||
temp_dir = tempfile.gettempdir()
|
||||
face_filename = f"face_{face_id}_crop.jpg"
|
||||
face_path = os.path.join(temp_dir, face_filename)
|
||||
|
||||
# Resize for better viewing (minimum 200px width)
|
||||
if face_crop.width < 200:
|
||||
ratio = 200 / face_crop.width
|
||||
new_width = 200
|
||||
new_height = int(face_crop.height * ratio)
|
||||
face_crop = face_crop.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
face_crop.save(face_path, "JPEG", quality=95)
|
||||
|
||||
# Cache the result
|
||||
self._image_cache[cache_key] = face_path
|
||||
return face_path
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose >= 1:
|
||||
print(f"⚠️ Could not extract face crop: {e}")
|
||||
return None
|
||||
|
||||
def _create_comparison_image(self, unid_crop_path: str, match_crop_path: str, person_name: str, confidence: float) -> str:
|
||||
"""Create a side-by-side comparison image"""
|
||||
try:
|
||||
# Load both face crops
|
||||
unid_img = Image.open(unid_crop_path)
|
||||
match_img = Image.open(match_crop_path)
|
||||
|
||||
# Resize both to same height for better comparison
|
||||
target_height = 300
|
||||
unid_ratio = target_height / unid_img.height
|
||||
match_ratio = target_height / match_img.height
|
||||
|
||||
unid_resized = unid_img.resize((int(unid_img.width * unid_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
match_resized = match_img.resize((int(match_img.width * match_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create comparison image
|
||||
total_width = unid_resized.width + match_resized.width + 20 # 20px gap
|
||||
comparison = Image.new('RGB', (total_width, target_height + 60), 'white')
|
||||
|
||||
# Paste images
|
||||
comparison.paste(unid_resized, (0, 30))
|
||||
comparison.paste(match_resized, (unid_resized.width + 20, 30))
|
||||
|
||||
# Add labels
|
||||
draw = ImageDraw.Draw(comparison)
|
||||
try:
|
||||
# Try to use a font
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw.text((10, 5), "UNKNOWN", fill='red', font=font)
|
||||
draw.text((unid_resized.width + 30, 5), f"{person_name.upper()}", fill='green', font=font)
|
||||
draw.text((10, target_height + 35), f"Confidence: {confidence:.1%}", fill='blue', font=font)
|
||||
|
||||
# Save comparison image
|
||||
temp_dir = tempfile.gettempdir()
|
||||
comparison_path = os.path.join(temp_dir, f"face_comparison_{person_name}.jpg")
|
||||
comparison.save(comparison_path, "JPEG", quality=95)
|
||||
|
||||
return comparison_path
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose >= 1:
|
||||
print(f"⚠️ Could not create comparison image: {e}")
|
||||
return None
|
||||
|
||||
def _get_confidence_description(self, confidence_pct: float) -> str:
|
||||
"""Get human-readable confidence description"""
|
||||
if confidence_pct >= 80:
|
||||
return "🟢 (Very High - Almost Certain)"
|
||||
elif confidence_pct >= 70:
|
||||
return "🟡 (High - Likely Match)"
|
||||
elif confidence_pct >= 60:
|
||||
return "🟠 (Medium - Possible Match)"
|
||||
elif confidence_pct >= 50:
|
||||
return "🔴 (Low - Questionable)"
|
||||
else:
|
||||
return "⚫ (Very Low)"
|
||||
|
||||
def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None):
|
||||
"""Display similar faces in a panel - reuses auto-match display logic"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from PIL import Image, ImageTk
|
||||
import os
|
||||
|
||||
# Create all similar faces using auto-match style display
|
||||
for i, face_data in enumerate(similar_faces_data[:10]): # Limit to 10 faces
|
||||
similar_face_id = face_data['face_id']
|
||||
filename = face_data['filename']
|
||||
distance = face_data['distance']
|
||||
quality = face_data.get('quality_score', 0.5)
|
||||
|
||||
# Calculate confidence like in auto-match
|
||||
confidence_pct = (1 - distance) * 100
|
||||
confidence_desc = self._get_confidence_description(confidence_pct)
|
||||
|
||||
# Create match frame using auto-match style
|
||||
match_frame = ttk.Frame(parent_frame)
|
||||
match_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
|
||||
# Checkbox for this match (reusing auto-match checkbox style)
|
||||
match_var = tk.BooleanVar()
|
||||
face_vars.append((similar_face_id, match_var))
|
||||
|
||||
# Restore previous checkbox state if available (auto-match style)
|
||||
if current_face_id is not None and face_selection_states is not None:
|
||||
unique_key = f"{current_face_id}_{similar_face_id}"
|
||||
if current_face_id in face_selection_states and unique_key in face_selection_states[current_face_id]:
|
||||
saved_state = face_selection_states[current_face_id][unique_key]
|
||||
match_var.set(saved_state)
|
||||
|
||||
# Add immediate callback to save state when checkbox changes (auto-match style)
|
||||
def make_callback(var, face_id, similar_face_id):
|
||||
def on_checkbox_change(*args):
|
||||
unique_key = f"{face_id}_{similar_face_id}"
|
||||
if face_id not in face_selection_states:
|
||||
face_selection_states[face_id] = {}
|
||||
face_selection_states[face_id][unique_key] = var.get()
|
||||
return on_checkbox_change
|
||||
|
||||
# Bind the callback to the variable
|
||||
match_var.trace('w', make_callback(match_var, current_face_id, similar_face_id))
|
||||
|
||||
# Configure match frame for grid layout
|
||||
match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width
|
||||
match_frame.columnconfigure(1, weight=1) # Text column - expandable
|
||||
match_frame.columnconfigure(2, weight=0) # Image column - fixed width
|
||||
|
||||
# Checkbox without text
|
||||
checkbox = ttk.Checkbutton(match_frame, variable=match_var)
|
||||
checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5))
|
||||
|
||||
# Create labels for confidence and filename
|
||||
confidence_label = ttk.Label(match_frame, text=f"{confidence_pct:.1f}% {confidence_desc}", font=("Arial", 9, "bold"))
|
||||
confidence_label.grid(row=0, column=1, sticky=tk.W, padx=(0, 10))
|
||||
|
||||
filename_label = ttk.Label(match_frame, text=f"📁 {filename}", font=("Arial", 8), foreground="gray")
|
||||
filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10))
|
||||
|
||||
# Face image (reusing auto-match image display)
|
||||
try:
|
||||
# Get photo path from cache or database
|
||||
photo_path = None
|
||||
if data_cache and 'photo_paths' in data_cache:
|
||||
# Find photo path by filename in cache
|
||||
for photo_data in data_cache['photo_paths'].values():
|
||||
if photo_data['filename'] == filename:
|
||||
photo_path = photo_data['path']
|
||||
break
|
||||
|
||||
# Fallback to database if not in cache
|
||||
if photo_path is None:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT path FROM photos WHERE filename = ?', (filename,))
|
||||
result = cursor.fetchone()
|
||||
photo_path = result[0] if result else None
|
||||
|
||||
# Extract face crop using existing method
|
||||
face_crop_path = self._extract_face_crop(photo_path, face_data['location'], similar_face_id)
|
||||
if face_crop_path and os.path.exists(face_crop_path):
|
||||
face_crops.append(face_crop_path)
|
||||
|
||||
# Create canvas for face image (like in auto-match)
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
match_canvas = tk.Canvas(match_frame, width=80, height=80, bg=canvas_bg_color, highlightthickness=0)
|
||||
match_canvas.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0))
|
||||
|
||||
# Load and display image (reusing auto-match image loading)
|
||||
pil_image = Image.open(face_crop_path)
|
||||
pil_image.thumbnail((80, 80), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(pil_image)
|
||||
match_canvas.create_image(40, 40, image=photo)
|
||||
match_canvas.image = photo # Keep reference
|
||||
face_images.append(photo)
|
||||
|
||||
# Add photo icon to the similar face
|
||||
self._create_photo_icon(match_canvas, photo_path, icon_size=15,
|
||||
face_x=40, face_y=40,
|
||||
face_width=80, face_height=80,
|
||||
canvas_width=80, canvas_height=80)
|
||||
else:
|
||||
# No image available
|
||||
match_canvas = tk.Canvas(match_frame, width=80, height=80, bg='white')
|
||||
match_canvas.pack(side=tk.LEFT, padx=(10, 0))
|
||||
match_canvas.create_text(40, 40, text="🖼️", fill="gray")
|
||||
except Exception as e:
|
||||
# Error loading image
|
||||
match_canvas = tk.Canvas(match_frame, width=80, height=80, bg='white')
|
||||
match_canvas.pack(side=tk.LEFT, padx=(10, 0))
|
||||
match_canvas.create_text(40, 40, text="❌", fill="red")
|
||||
|
||||
def _create_photo_icon(self, canvas, photo_path, icon_size=20, icon_x=None, icon_y=None,
|
||||
canvas_width=None, canvas_height=None, face_x=None, face_y=None,
|
||||
face_width=None, face_height=None):
|
||||
"""Create a reusable photo icon with tooltip on a canvas"""
|
||||
import tkinter as tk
|
||||
import subprocess
|
||||
import platform
|
||||
import os
|
||||
|
||||
def open_source_photo(event):
|
||||
"""Open the source photo in a properly sized window"""
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# Try to open with a specific image viewer that supports window sizing
|
||||
try:
|
||||
subprocess.run(["mspaint", photo_path], check=False)
|
||||
except:
|
||||
os.startfile(photo_path)
|
||||
elif system == "Darwin": # macOS
|
||||
# Use Preview with specific window size
|
||||
subprocess.run(["open", "-a", "Preview", photo_path])
|
||||
else: # Linux and others
|
||||
# Try common image viewers with window sizing options
|
||||
viewers_to_try = [
|
||||
["eog", "--new-window", photo_path], # Eye of GNOME
|
||||
["gwenview", photo_path], # KDE image viewer
|
||||
["feh", "--geometry", "800x600", photo_path], # feh with specific size
|
||||
["gimp", photo_path], # GIMP
|
||||
["xdg-open", photo_path] # Fallback to default
|
||||
]
|
||||
|
||||
opened = False
|
||||
for viewer_cmd in viewers_to_try:
|
||||
try:
|
||||
result = subprocess.run(viewer_cmd, check=False, capture_output=True)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not opened:
|
||||
# Final fallback
|
||||
subprocess.run(["xdg-open", photo_path])
|
||||
except Exception as e:
|
||||
print(f"❌ Could not open photo: {e}")
|
||||
|
||||
# Create tooltip for the icon
|
||||
tooltip = None
|
||||
|
||||
def show_tooltip(event):
|
||||
nonlocal tooltip
|
||||
if tooltip:
|
||||
tooltip.destroy()
|
||||
tooltip = tk.Toplevel()
|
||||
tooltip.wm_overrideredirect(True)
|
||||
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
|
||||
label = tk.Label(tooltip, text="Show original photo",
|
||||
background="lightyellow", relief="solid", borderwidth=1,
|
||||
font=("Arial", 9))
|
||||
label.pack()
|
||||
|
||||
def hide_tooltip(event):
|
||||
nonlocal tooltip
|
||||
if tooltip:
|
||||
tooltip.destroy()
|
||||
tooltip = None
|
||||
|
||||
# Calculate icon position
|
||||
if icon_x is None or icon_y is None:
|
||||
if face_x is not None and face_y is not None and face_width is not None and face_height is not None:
|
||||
# Position relative to face image - exactly in the corner
|
||||
face_right = face_x + face_width // 2
|
||||
face_top = face_y - face_height // 2
|
||||
icon_x = face_right - icon_size
|
||||
icon_y = face_top
|
||||
else:
|
||||
# Position relative to canvas - exactly in the corner
|
||||
if canvas_width is None:
|
||||
canvas_width = canvas.winfo_width()
|
||||
if canvas_height is None:
|
||||
canvas_height = canvas.winfo_height()
|
||||
icon_x = canvas_width - icon_size
|
||||
icon_y = 0
|
||||
|
||||
# Ensure icon stays within canvas bounds
|
||||
if canvas_width is None:
|
||||
canvas_width = canvas.winfo_width()
|
||||
if canvas_height is None:
|
||||
canvas_height = canvas.winfo_height()
|
||||
icon_x = min(icon_x, canvas_width - icon_size)
|
||||
icon_y = max(icon_y, 0)
|
||||
|
||||
# Draw the photo icon
|
||||
canvas.create_rectangle(icon_x, icon_y, icon_x + icon_size, icon_y + icon_size,
|
||||
fill="white", outline="black", width=1, tags="photo_icon")
|
||||
canvas.create_text(icon_x + icon_size//2, icon_y + icon_size//2,
|
||||
text="📷", font=("Arial", 10), tags="photo_icon")
|
||||
|
||||
# Bind events
|
||||
canvas.tag_bind("photo_icon", "<Button-1>", open_source_photo)
|
||||
canvas.tag_bind("photo_icon", "<Enter>", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e)))
|
||||
canvas.tag_bind("photo_icon", "<Leave>", lambda e: (canvas.config(cursor=""), hide_tooltip(e)))
|
||||
canvas.tag_bind("photo_icon", "<Motion>", lambda e: (show_tooltip(e) if tooltip else None))
|
||||
|
||||
return tooltip # Return tooltip reference for cleanup if needed
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Photo scanning, metadata extraction, and file operations for PunimTag
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from src.core.config import SUPPORTED_IMAGE_FORMATS
|
||||
from src.core.database import DatabaseManager
|
||||
from src.utils.path_utils import normalize_path, validate_path_exists
|
||||
|
||||
|
||||
class PhotoManager:
|
||||
"""Handles photo scanning, metadata extraction, and file operations"""
|
||||
|
||||
def __init__(self, db_manager: DatabaseManager, verbose: int = 0):
|
||||
"""Initialize photo manager"""
|
||||
self.db = db_manager
|
||||
self.verbose = verbose
|
||||
|
||||
def extract_photo_date(self, photo_path: str) -> Optional[str]:
|
||||
"""Extract date taken from photo EXIF data"""
|
||||
try:
|
||||
with Image.open(photo_path) as image:
|
||||
exifdata = image.getexif()
|
||||
|
||||
# Look for date taken in EXIF tags
|
||||
date_tags = [
|
||||
306, # DateTime
|
||||
36867, # DateTimeOriginal
|
||||
36868, # DateTimeDigitized
|
||||
]
|
||||
|
||||
for tag_id in date_tags:
|
||||
if tag_id in exifdata:
|
||||
date_str = exifdata[tag_id]
|
||||
if date_str:
|
||||
# Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
|
||||
return date_obj.strftime('%Y-%m-%d')
|
||||
except ValueError:
|
||||
# Try alternative format
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
||||
return date_obj.strftime('%Y-%m-%d')
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
if self.verbose >= 2:
|
||||
print(f" ⚠️ Could not extract date from {os.path.basename(photo_path)}: {e}")
|
||||
return None
|
||||
|
||||
def scan_folder(self, folder_path: str, recursive: bool = True) -> int:
|
||||
"""Scan folder for photos and add to database"""
|
||||
# Normalize path to absolute path
|
||||
try:
|
||||
folder_path = normalize_path(folder_path)
|
||||
except ValueError as e:
|
||||
print(f"❌ Invalid path: {e}")
|
||||
return 0
|
||||
|
||||
if not validate_path_exists(folder_path):
|
||||
print(f"❌ Folder not found or not accessible: {folder_path}")
|
||||
return 0
|
||||
|
||||
found_photos = []
|
||||
|
||||
if recursive:
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
for file in files:
|
||||
file_ext = Path(file).suffix.lower()
|
||||
if file_ext in SUPPORTED_IMAGE_FORMATS:
|
||||
photo_path = os.path.join(root, file)
|
||||
found_photos.append((photo_path, file))
|
||||
else:
|
||||
for file in os.listdir(folder_path):
|
||||
file_ext = Path(file).suffix.lower()
|
||||
if file_ext in SUPPORTED_IMAGE_FORMATS:
|
||||
photo_path = os.path.join(folder_path, file)
|
||||
found_photos.append((photo_path, file))
|
||||
|
||||
if not found_photos:
|
||||
print(f"📁 No photos found in {folder_path}")
|
||||
return 0
|
||||
|
||||
# Add to database
|
||||
added_count = 0
|
||||
existing_count = 0
|
||||
|
||||
for photo_path, filename in found_photos:
|
||||
try:
|
||||
# Ensure photo path is absolute
|
||||
photo_path = normalize_path(photo_path)
|
||||
|
||||
# Extract date taken from EXIF data
|
||||
date_taken = self.extract_photo_date(photo_path)
|
||||
|
||||
# Add photo to database (with absolute path)
|
||||
photo_id = self.db.add_photo(photo_path, filename, date_taken)
|
||||
if photo_id:
|
||||
# New photo was added
|
||||
added_count += 1
|
||||
if self.verbose >= 2:
|
||||
date_info = f" (taken: {date_taken})" if date_taken else " (no date)"
|
||||
print(f" 📸 Added: {filename}{date_info}")
|
||||
else:
|
||||
# Photo already exists
|
||||
existing_count += 1
|
||||
if self.verbose >= 2:
|
||||
print(f" 📸 Already exists: {filename}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error adding {filename}: {e}")
|
||||
|
||||
# Print summary
|
||||
if added_count > 0 and existing_count > 0:
|
||||
print(f"📁 Found {len(found_photos)} photos: {added_count} new, {existing_count} already in database")
|
||||
elif added_count > 0:
|
||||
print(f"📁 Found {len(found_photos)} photos, added {added_count} new photos")
|
||||
elif existing_count > 0:
|
||||
print(f"📁 Found {len(found_photos)} photos, all already in database")
|
||||
else:
|
||||
print(f"📁 Found {len(found_photos)} photos, none could be added")
|
||||
|
||||
return added_count
|
||||
|
||||
def get_photo_info(self, photo_id: int) -> Optional[Tuple]:
|
||||
"""Get photo information by ID"""
|
||||
photos = self.db.get_photos_by_pattern(limit=1000) # Get all photos
|
||||
for photo in photos:
|
||||
if photo[0] == photo_id: # photo[0] is the ID
|
||||
return photo
|
||||
return None
|
||||
|
||||
def get_photo_path(self, photo_id: int) -> Optional[str]:
|
||||
"""Get photo path by ID"""
|
||||
photo_info = self.get_photo_info(photo_id)
|
||||
return photo_info[1] if photo_info else None # photo[1] is the path
|
||||
|
||||
def get_photo_filename(self, photo_id: int) -> Optional[str]:
|
||||
"""Get photo filename by ID"""
|
||||
photo_info = self.get_photo_info(photo_id)
|
||||
return photo_info[2] if photo_info else None # photo[2] is the filename
|
||||
|
||||
def is_photo_processed(self, photo_id: int) -> bool:
|
||||
"""Check if photo has been processed for faces"""
|
||||
photo_info = self.get_photo_info(photo_id)
|
||||
return photo_info[4] if photo_info else False # photo[4] is the processed flag
|
||||
|
||||
def mark_photo_processed(self, photo_id: int):
|
||||
"""Mark a photo as processed"""
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
|
||||
def get_photos_by_date_range(self, date_from: str = None, date_to: str = None) -> List[Tuple]:
|
||||
"""Get photos within a date range"""
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return all photos
|
||||
return self.db.get_photos_by_pattern()
|
||||
|
||||
def get_photos_by_pattern(self, pattern: str = None, limit: int = 10) -> List[Tuple]:
|
||||
"""Get photos matching a pattern"""
|
||||
return self.db.get_photos_by_pattern(pattern, limit)
|
||||
|
||||
def validate_photo_file(self, photo_path: str) -> bool:
|
||||
"""Validate that a photo file exists and is readable"""
|
||||
if not os.path.exists(photo_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with Image.open(photo_path) as image:
|
||||
image.verify()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_photo_dimensions(self, photo_path: str) -> Optional[Tuple[int, int]]:
|
||||
"""Get photo dimensions (width, height)"""
|
||||
try:
|
||||
with Image.open(photo_path) as image:
|
||||
return image.size
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_photo_format(self, photo_path: str) -> Optional[str]:
|
||||
"""Get photo format"""
|
||||
try:
|
||||
with Image.open(photo_path) as image:
|
||||
return image.format
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_photo_exif_data(self, photo_path: str) -> dict:
|
||||
"""Get EXIF data from photo"""
|
||||
try:
|
||||
with Image.open(photo_path) as image:
|
||||
exifdata = image.getexif()
|
||||
return dict(exifdata)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_photo_file_size(self, photo_path: str) -> Optional[int]:
|
||||
"""Get photo file size in bytes"""
|
||||
try:
|
||||
return os.path.getsize(photo_path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_photo_creation_time(self, photo_path: str) -> Optional[datetime]:
|
||||
"""Get photo file creation time"""
|
||||
try:
|
||||
timestamp = os.path.getctime(photo_path)
|
||||
return datetime.fromtimestamp(timestamp)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_photo_modification_time(self, photo_path: str) -> Optional[datetime]:
|
||||
"""Get photo file modification time"""
|
||||
try:
|
||||
timestamp = os.path.getmtime(photo_path)
|
||||
return datetime.fromtimestamp(timestamp)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Search functionality and statistics for PunimTag
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from src.core.database import DatabaseManager
|
||||
|
||||
|
||||
class SearchStats:
|
||||
"""Handles search functionality and statistics generation"""
|
||||
|
||||
def __init__(self, db_manager: DatabaseManager, verbose: int = 0):
|
||||
"""Initialize search and stats manager"""
|
||||
self.db = db_manager
|
||||
self.verbose = verbose
|
||||
|
||||
def search_faces(self, person_name: str) -> List[Tuple[str, str]]:
|
||||
"""Search for photos containing a specific person by name (partial, case-insensitive).
|
||||
|
||||
Returns a list of tuples: (photo_path, person_full_name).
|
||||
"""
|
||||
# Get all people matching the name
|
||||
people = self.db.show_people_list()
|
||||
matching_people = []
|
||||
|
||||
search_name = (person_name or "").strip().lower()
|
||||
if not search_name:
|
||||
return []
|
||||
|
||||
for person in people:
|
||||
person_id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date = person
|
||||
full_name = f"{first_name or ''} {last_name or ''}".strip().lower()
|
||||
|
||||
# Check if search term matches any part of the name
|
||||
if (
|
||||
(full_name and search_name in full_name) or
|
||||
(first_name and search_name in first_name.lower()) or
|
||||
(last_name and search_name in last_name.lower()) or
|
||||
(middle_name and search_name in middle_name.lower()) or
|
||||
(maiden_name and search_name in maiden_name.lower())
|
||||
):
|
||||
matching_people.append(person_id)
|
||||
|
||||
if not matching_people:
|
||||
return []
|
||||
|
||||
# Fetch photo paths for each matching person using database helper if available
|
||||
results: List[Tuple[str, str]] = []
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# faces.person_id links to photos via faces.photo_id
|
||||
placeholders = ",".join(["?"] * len(matching_people))
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT DISTINCT p.path, pe.first_name, pe.last_name
|
||||
FROM faces f
|
||||
JOIN photos p ON p.id = f.photo_id
|
||||
JOIN people pe ON pe.id = f.person_id
|
||||
WHERE f.person_id IN ({placeholders})
|
||||
ORDER BY pe.last_name, pe.first_name, p.path
|
||||
""",
|
||||
tuple(matching_people),
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
if row and row[0]:
|
||||
path = row[0]
|
||||
first = (row[1] or "").strip()
|
||||
last = (row[2] or "").strip()
|
||||
full_name = (f"{first} {last}").strip() or "Unknown"
|
||||
results.append((path, full_name))
|
||||
except Exception:
|
||||
# Fall back gracefully if schema differs
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""Get comprehensive database statistics"""
|
||||
stats = self.db.get_statistics()
|
||||
|
||||
# Add calculated statistics
|
||||
if stats['total_photos'] > 0:
|
||||
stats['processing_percentage'] = (stats['processed_photos'] / stats['total_photos']) * 100
|
||||
else:
|
||||
stats['processing_percentage'] = 0
|
||||
|
||||
if stats['total_faces'] > 0:
|
||||
stats['identification_percentage'] = (stats['identified_faces'] / stats['total_faces']) * 100
|
||||
else:
|
||||
stats['identification_percentage'] = 0
|
||||
|
||||
if stats['total_people'] > 0:
|
||||
stats['faces_per_person'] = stats['identified_faces'] / stats['total_people']
|
||||
else:
|
||||
stats['faces_per_person'] = 0
|
||||
|
||||
if stats['total_photos'] > 0:
|
||||
stats['faces_per_photo'] = stats['total_faces'] / stats['total_photos']
|
||||
else:
|
||||
stats['faces_per_photo'] = 0
|
||||
|
||||
if stats['total_photos'] > 0:
|
||||
stats['tags_per_photo'] = stats['total_photo_tags'] / stats['total_photos']
|
||||
else:
|
||||
stats['tags_per_photo'] = 0
|
||||
|
||||
return stats
|
||||
|
||||
def print_statistics(self):
|
||||
"""Print formatted statistics to console"""
|
||||
stats = self.get_statistics()
|
||||
|
||||
print("\n📊 PunimTag Database Statistics")
|
||||
print("=" * 50)
|
||||
|
||||
print(f"📸 Photos:")
|
||||
print(f" Total photos: {stats['total_photos']}")
|
||||
print(f" Processed: {stats['processed_photos']} ({stats['processing_percentage']:.1f}%)")
|
||||
print(f" Unprocessed: {stats['total_photos'] - stats['processed_photos']}")
|
||||
|
||||
print(f"\n👤 Faces:")
|
||||
print(f" Total faces: {stats['total_faces']}")
|
||||
print(f" Identified: {stats['identified_faces']} ({stats['identification_percentage']:.1f}%)")
|
||||
print(f" Unidentified: {stats['unidentified_faces']}")
|
||||
|
||||
print(f"\n👥 People:")
|
||||
print(f" Total people: {stats['total_people']}")
|
||||
print(f" Average faces per person: {stats['faces_per_person']:.1f}")
|
||||
|
||||
print(f"\n🏷️ Tags:")
|
||||
print(f" Total tags: {stats['total_tags']}")
|
||||
print(f" Total photo-tag links: {stats['total_photo_tags']}")
|
||||
print(f" Average tags per photo: {stats['tags_per_photo']:.1f}")
|
||||
|
||||
print(f"\n📈 Averages:")
|
||||
print(f" Faces per photo: {stats['faces_per_photo']:.1f}")
|
||||
print(f" Tags per photo: {stats['tags_per_photo']:.1f}")
|
||||
|
||||
print("=" * 50)
|
||||
|
||||
def get_photo_statistics(self) -> Dict:
|
||||
"""Get detailed photo statistics"""
|
||||
stats = self.get_statistics()
|
||||
|
||||
# This could be expanded with more detailed photo analysis
|
||||
return {
|
||||
'total_photos': stats['total_photos'],
|
||||
'processed_photos': stats['processed_photos'],
|
||||
'unprocessed_photos': stats['total_photos'] - stats['processed_photos'],
|
||||
'processing_percentage': stats['processing_percentage']
|
||||
}
|
||||
|
||||
def get_face_statistics(self) -> Dict:
|
||||
"""Get detailed face statistics"""
|
||||
stats = self.get_statistics()
|
||||
|
||||
return {
|
||||
'total_faces': stats['total_faces'],
|
||||
'identified_faces': stats['identified_faces'],
|
||||
'unidentified_faces': stats['unidentified_faces'],
|
||||
'identification_percentage': stats['identification_percentage'],
|
||||
'faces_per_photo': stats['faces_per_photo']
|
||||
}
|
||||
|
||||
def get_people_statistics(self) -> Dict:
|
||||
"""Get detailed people statistics"""
|
||||
stats = self.get_statistics()
|
||||
|
||||
return {
|
||||
'total_people': stats['total_people'],
|
||||
'faces_per_person': stats['faces_per_person']
|
||||
}
|
||||
|
||||
def get_tag_statistics(self) -> Dict:
|
||||
"""Get detailed tag statistics"""
|
||||
stats = self.get_statistics()
|
||||
|
||||
return {
|
||||
'total_tags': stats['total_tags'],
|
||||
'total_photo_tags': stats['total_photo_tags'],
|
||||
'tags_per_photo': stats['tags_per_photo']
|
||||
}
|
||||
|
||||
def search_photos_by_date(self, date_from: str = None, date_to: str = None) -> List[Tuple[str, str]]:
|
||||
"""Search photos by date range.
|
||||
|
||||
Args:
|
||||
date_from: Start date in YYYY-MM-DD format (inclusive)
|
||||
date_to: End date in YYYY-MM-DD format (inclusive)
|
||||
|
||||
Returns:
|
||||
List of tuples: (photo_path, date_taken)
|
||||
"""
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build the query based on provided date parameters
|
||||
if date_from and date_to:
|
||||
# Both dates provided - search within range
|
||||
query = '''
|
||||
SELECT path, date_taken
|
||||
FROM photos
|
||||
WHERE date_taken IS NOT NULL
|
||||
AND date_taken >= ? AND date_taken <= ?
|
||||
ORDER BY date_taken DESC, filename
|
||||
'''
|
||||
cursor.execute(query, (date_from, date_to))
|
||||
elif date_from:
|
||||
# Only start date provided - search from date onwards
|
||||
query = '''
|
||||
SELECT path, date_taken
|
||||
FROM photos
|
||||
WHERE date_taken IS NOT NULL
|
||||
AND date_taken >= ?
|
||||
ORDER BY date_taken DESC, filename
|
||||
'''
|
||||
cursor.execute(query, (date_from,))
|
||||
elif date_to:
|
||||
# Only end date provided - search up to date
|
||||
query = '''
|
||||
SELECT path, date_taken
|
||||
FROM photos
|
||||
WHERE date_taken IS NOT NULL
|
||||
AND date_taken <= ?
|
||||
ORDER BY date_taken DESC, filename
|
||||
'''
|
||||
cursor.execute(query, (date_to,))
|
||||
else:
|
||||
# No dates provided - return all photos with date_taken
|
||||
query = '''
|
||||
SELECT path, date_taken
|
||||
FROM photos
|
||||
WHERE date_taken IS NOT NULL
|
||||
ORDER BY date_taken DESC, filename
|
||||
'''
|
||||
cursor.execute(query)
|
||||
|
||||
results = cursor.fetchall()
|
||||
return [(row[0], row[1]) for row in results]
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose >= 1:
|
||||
print(f"Error searching photos by date: {e}")
|
||||
return []
|
||||
|
||||
def search_photos_by_tags(self, tags: List[str], match_all: bool = False) -> List[Tuple]:
|
||||
"""Search photos by tags
|
||||
|
||||
Args:
|
||||
tags: List of tag names to search for
|
||||
match_all: If True, photos must have ALL tags. If False, photos with ANY tag.
|
||||
|
||||
Returns:
|
||||
List of tuples: (photo_path, tag_info)
|
||||
"""
|
||||
if not tags:
|
||||
return []
|
||||
|
||||
# Get tag IDs for the provided tag names (case-insensitive)
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
tag_ids = []
|
||||
|
||||
for tag_name in tags:
|
||||
# Convert to lowercase for case-insensitive lookup
|
||||
normalized_tag_name = tag_name.lower().strip()
|
||||
if normalized_tag_name in tag_name_to_id:
|
||||
tag_ids.append(tag_name_to_id[normalized_tag_name])
|
||||
|
||||
if not tag_ids:
|
||||
return []
|
||||
|
||||
results = []
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if match_all:
|
||||
# Photos that have ALL specified tags
|
||||
placeholders = ",".join(["?"] * len(tag_ids))
|
||||
cursor.execute(f'''
|
||||
SELECT p.path, GROUP_CONCAT(t.tag_name, ', ') as tag_names
|
||||
FROM photos p
|
||||
JOIN phototaglinkage ptl ON p.id = ptl.photo_id
|
||||
JOIN tags t ON ptl.tag_id = t.id
|
||||
WHERE ptl.tag_id IN ({placeholders})
|
||||
GROUP BY p.id, p.path
|
||||
HAVING COUNT(DISTINCT ptl.tag_id) = ?
|
||||
ORDER BY p.path
|
||||
''', tuple(tag_ids) + (len(tag_ids),))
|
||||
else:
|
||||
# Photos that have ANY of the specified tags
|
||||
placeholders = ",".join(["?"] * len(tag_ids))
|
||||
cursor.execute(f'''
|
||||
SELECT DISTINCT p.path, GROUP_CONCAT(t.tag_name, ', ') as tag_names
|
||||
FROM photos p
|
||||
JOIN phototaglinkage ptl ON p.id = ptl.photo_id
|
||||
JOIN tags t ON ptl.tag_id = t.id
|
||||
WHERE ptl.tag_id IN ({placeholders})
|
||||
GROUP BY p.id, p.path
|
||||
ORDER BY p.path
|
||||
''', tuple(tag_ids))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
if row and row[0]:
|
||||
results.append((row[0], row[1] or ""))
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose > 0:
|
||||
print(f"Error searching photos by tags: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def search_photos_by_people(self, people: List[str]) -> List[Tuple]:
|
||||
"""Search photos by people"""
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def get_most_common_tags(self, limit: int = 10) -> List[Tuple[str, int]]:
|
||||
"""Get most commonly used tags"""
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def get_most_photographed_people(self, limit: int = 10) -> List[Tuple[str, int]]:
|
||||
"""Get most photographed people"""
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def get_photos_without_faces(self) -> List[Tuple]:
|
||||
"""Get photos that have no detected faces
|
||||
|
||||
Returns:
|
||||
List of tuples: (photo_path, filename)
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Find photos that have no faces associated with them
|
||||
cursor.execute('''
|
||||
SELECT p.path, p.filename
|
||||
FROM photos p
|
||||
LEFT JOIN faces f ON p.id = f.photo_id
|
||||
WHERE f.photo_id IS NULL
|
||||
ORDER BY p.filename
|
||||
''')
|
||||
for row in cursor.fetchall():
|
||||
if row and row[0]:
|
||||
results.append((row[0], row[1]))
|
||||
except Exception as e:
|
||||
if self.verbose > 0:
|
||||
print(f"Error searching photos without faces: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def get_photos_without_tags(self) -> List[Tuple]:
|
||||
"""Get photos that have no tags
|
||||
|
||||
Returns:
|
||||
List of tuples: (photo_path, filename)
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Find photos that have no tags associated with them
|
||||
cursor.execute('''
|
||||
SELECT p.path, p.filename
|
||||
FROM photos p
|
||||
LEFT JOIN phototaglinkage ptl ON p.id = ptl.photo_id
|
||||
WHERE ptl.photo_id IS NULL
|
||||
ORDER BY p.filename
|
||||
''')
|
||||
for row in cursor.fetchall():
|
||||
if row and row[0]:
|
||||
results.append((row[0], row[1]))
|
||||
except Exception as e:
|
||||
if self.verbose > 0:
|
||||
print(f"Error searching photos without tags: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def get_duplicate_faces(self, tolerance: float = 0.6) -> List[Dict]:
|
||||
"""Get potential duplicate faces (same person, different photos)"""
|
||||
# This would need to be implemented using face matching
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def get_face_quality_distribution(self) -> Dict:
|
||||
"""Get distribution of face quality scores"""
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty dict
|
||||
return {}
|
||||
|
||||
def get_processing_timeline(self) -> List[Tuple[str, int]]:
|
||||
"""Get timeline of photo processing (photos processed per day)"""
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def export_statistics(self, filename: str = "punimtag_stats.json"):
|
||||
"""Export statistics to a JSON file"""
|
||||
import json
|
||||
|
||||
stats = self.get_statistics()
|
||||
|
||||
try:
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(stats, f, indent=2)
|
||||
print(f"✅ Statistics exported to {filename}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error exporting statistics: {e}")
|
||||
|
||||
def generate_report(self) -> str:
|
||||
"""Generate a text report of statistics"""
|
||||
stats = self.get_statistics()
|
||||
|
||||
report = f"""
|
||||
PunimTag Database Report
|
||||
Generated: {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
PHOTO STATISTICS:
|
||||
- Total photos: {stats['total_photos']}
|
||||
- Processed: {stats['processed_photos']} ({stats['processing_percentage']:.1f}%)
|
||||
- Unprocessed: {stats['total_photos'] - stats['processed_photos']}
|
||||
|
||||
FACE STATISTICS:
|
||||
- Total faces: {stats['total_faces']}
|
||||
- Identified: {stats['identified_faces']} ({stats['identification_percentage']:.1f}%)
|
||||
- Unidentified: {stats['unidentified_faces']}
|
||||
- Average faces per photo: {stats['faces_per_photo']:.1f}
|
||||
|
||||
PEOPLE STATISTICS:
|
||||
- Total people: {stats['total_people']}
|
||||
- Average faces per person: {stats['faces_per_person']:.1f}
|
||||
|
||||
TAG STATISTICS:
|
||||
- Total tags: {stats['total_tags']}
|
||||
- Total photo-tag links: {stats['total_photo_tags']}
|
||||
- Average tags per photo: {stats['tags_per_photo']:.1f}
|
||||
"""
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tag management functionality for PunimTag
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from src.core.config import DEFAULT_BATCH_SIZE
|
||||
from src.core.database import DatabaseManager
|
||||
|
||||
|
||||
class TagManager:
|
||||
"""Handles photo tagging and tag management operations"""
|
||||
|
||||
def __init__(self, db_manager: DatabaseManager, verbose: int = 0):
|
||||
"""Initialize tag manager"""
|
||||
self.db = db_manager
|
||||
self.verbose = verbose
|
||||
|
||||
def deduplicate_tags(self, tag_list: List[str]) -> List[str]:
|
||||
"""Remove duplicate tags from a list while preserving order (case insensitive)"""
|
||||
seen = set()
|
||||
unique_tags = []
|
||||
for tag in tag_list:
|
||||
if tag.lower() not in seen:
|
||||
seen.add(tag.lower())
|
||||
unique_tags.append(tag)
|
||||
return unique_tags
|
||||
|
||||
def parse_tags_string(self, tags_string: str) -> List[str]:
|
||||
"""Parse a comma-separated tags string into a list, handling empty strings and whitespace"""
|
||||
if not tags_string or tags_string.strip() == "":
|
||||
return []
|
||||
# Split by comma and strip whitespace from each tag
|
||||
tags = [tag.strip() for tag in tags_string.split(",")]
|
||||
# Remove empty strings that might result from splitting
|
||||
return [tag for tag in tags if tag]
|
||||
|
||||
def add_tags_to_photos(self, photo_pattern: str = None, batch_size: int = DEFAULT_BATCH_SIZE) -> int:
|
||||
"""Add custom tags to photos via command line interface"""
|
||||
if photo_pattern:
|
||||
photos = self.db.get_photos_by_pattern(photo_pattern, batch_size)
|
||||
else:
|
||||
photos = self.db.get_photos_by_pattern(limit=batch_size)
|
||||
|
||||
if not photos:
|
||||
print("No photos found")
|
||||
return 0
|
||||
|
||||
print(f"🏷️ Tagging {len(photos)} photos (enter comma-separated tags)")
|
||||
tagged_count = 0
|
||||
|
||||
for photo_id, photo_path, filename, date_taken, processed in photos:
|
||||
print(f"\n📸 {filename}")
|
||||
tags_input = input("🏷️ Tags: ").strip()
|
||||
|
||||
if tags_input.lower() == 'q':
|
||||
break
|
||||
|
||||
if tags_input:
|
||||
tags = self.parse_tags_string(tags_input)
|
||||
tags = self.deduplicate_tags(tags)
|
||||
|
||||
for tag_name in tags:
|
||||
# Add tag to database and get its ID
|
||||
tag_id = self.db.add_tag(tag_name)
|
||||
if tag_id:
|
||||
# Link photo to tag
|
||||
self.db.link_photo_tag(photo_id, tag_id)
|
||||
|
||||
print(f" ✅ Added {len(tags)} tags")
|
||||
tagged_count += 1
|
||||
|
||||
print(f"✅ Tagged {tagged_count} photos")
|
||||
return tagged_count
|
||||
|
||||
def add_tags_to_photo(self, photo_id: int, tags: List[str]) -> int:
|
||||
"""Add tags to a specific photo"""
|
||||
if not tags:
|
||||
return 0
|
||||
|
||||
tags = self.deduplicate_tags(tags)
|
||||
added_count = 0
|
||||
|
||||
for tag_name in tags:
|
||||
# Add tag to database and get its ID
|
||||
tag_id = self.db.add_tag(tag_name)
|
||||
if tag_id:
|
||||
# Link photo to tag
|
||||
self.db.link_photo_tag(photo_id, tag_id)
|
||||
added_count += 1
|
||||
|
||||
return added_count
|
||||
|
||||
def remove_tags_from_photo(self, photo_id: int, tags: List[str]) -> int:
|
||||
"""Remove tags from a specific photo"""
|
||||
if not tags:
|
||||
return 0
|
||||
|
||||
removed_count = 0
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
|
||||
for tag_name in tags:
|
||||
if tag_name in tag_name_to_id:
|
||||
tag_id = tag_name_to_id[tag_name]
|
||||
self.db.unlink_photo_tag(photo_id, tag_id)
|
||||
removed_count += 1
|
||||
|
||||
return removed_count
|
||||
|
||||
def get_photo_tags(self, photo_id: int) -> List[str]:
|
||||
"""Get all tags for a specific photo"""
|
||||
tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
|
||||
tag_id_to_name, _ = self.db.load_tag_mappings()
|
||||
|
||||
tags = []
|
||||
for tag_id in tag_ids:
|
||||
tag_name = self.db.get_tag_name_by_id(tag_id, tag_id_to_name)
|
||||
tags.append(tag_name)
|
||||
|
||||
return tags
|
||||
|
||||
def get_all_tags(self) -> List[Tuple[int, str]]:
|
||||
"""Get all tags in the database"""
|
||||
tag_id_to_name, _ = self.db.load_tag_mappings()
|
||||
return [(tag_id, tag_name) for tag_id, tag_name in tag_id_to_name.items()]
|
||||
|
||||
def get_photos_with_tag(self, tag_name: str) -> List[Tuple]:
|
||||
"""Get all photos that have a specific tag"""
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
|
||||
if tag_name not in tag_name_to_id:
|
||||
return []
|
||||
|
||||
tag_id = tag_name_to_id[tag_name]
|
||||
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def get_tag_statistics(self) -> Dict:
|
||||
"""Get tag usage statistics"""
|
||||
tag_id_to_name, _ = self.db.load_tag_mappings()
|
||||
stats = {
|
||||
'total_tags': len(tag_id_to_name),
|
||||
'tag_usage': {}
|
||||
}
|
||||
|
||||
# Count usage for each tag
|
||||
for tag_id, tag_name in tag_id_to_name.items():
|
||||
# This would need to be implemented in the database module
|
||||
# For now, set usage to 0
|
||||
stats['tag_usage'][tag_name] = 0
|
||||
|
||||
return stats
|
||||
|
||||
def delete_tag(self, tag_name: str) -> bool:
|
||||
"""Delete a tag from the database (and all its linkages)"""
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
|
||||
if tag_name not in tag_name_to_id:
|
||||
return False
|
||||
|
||||
tag_id = tag_name_to_id[tag_name]
|
||||
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return False
|
||||
return False
|
||||
|
||||
def rename_tag(self, old_name: str, new_name: str) -> bool:
|
||||
"""Rename a tag"""
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
|
||||
if old_name not in tag_name_to_id:
|
||||
return False
|
||||
|
||||
if new_name in tag_name_to_id:
|
||||
return False # New name already exists
|
||||
|
||||
tag_id = tag_name_to_id[old_name]
|
||||
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return False
|
||||
return False
|
||||
|
||||
def merge_tags(self, source_tag: str, target_tag: str) -> bool:
|
||||
"""Merge one tag into another (move all linkages from source to target)"""
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
|
||||
if source_tag not in tag_name_to_id or target_tag not in tag_name_to_id:
|
||||
return False
|
||||
|
||||
source_tag_id = tag_name_to_id[source_tag]
|
||||
target_tag_id = tag_name_to_id[target_tag]
|
||||
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return False
|
||||
return False
|
||||
|
||||
def get_photos_by_tags(self, tags: List[str], match_all: bool = False) -> List[Tuple]:
|
||||
"""Get photos that have any (or all) of the specified tags"""
|
||||
if not tags:
|
||||
return []
|
||||
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
tag_ids = []
|
||||
|
||||
for tag_name in tags:
|
||||
# Convert to lowercase for case-insensitive lookup
|
||||
normalized_tag_name = tag_name.lower().strip()
|
||||
if normalized_tag_name in tag_name_to_id:
|
||||
tag_ids.append(tag_name_to_id[normalized_tag_name])
|
||||
|
||||
if not tag_ids:
|
||||
return []
|
||||
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def get_common_tags(self, photo_ids: List[int]) -> List[str]:
|
||||
"""Get tags that are common to all specified photos"""
|
||||
if not photo_ids:
|
||||
return []
|
||||
|
||||
# Get tags for each photo
|
||||
all_photo_tags = []
|
||||
for photo_id in photo_ids:
|
||||
tags = self.get_photo_tags(photo_id)
|
||||
all_photo_tags.append(set(tags))
|
||||
|
||||
if not all_photo_tags:
|
||||
return []
|
||||
|
||||
# Find intersection of all tag sets
|
||||
common_tags = set.intersection(*all_photo_tags)
|
||||
return list(common_tags)
|
||||
|
||||
def get_suggested_tags(self, photo_id: int, limit: int = 5) -> List[str]:
|
||||
"""Get suggested tags based on similar photos"""
|
||||
# This is a placeholder for tag suggestion logic
|
||||
# Could be implemented based on:
|
||||
# - Tags from photos in the same folder
|
||||
# - Tags from photos taken on the same date
|
||||
# - Most commonly used tags
|
||||
# - Machine learning based suggestions
|
||||
|
||||
return []
|
||||
|
||||
def validate_tag_name(self, tag_name: str) -> Tuple[bool, str]:
|
||||
"""Validate a tag name and return (is_valid, error_message)"""
|
||||
if not tag_name or not tag_name.strip():
|
||||
return False, "Tag name cannot be empty"
|
||||
|
||||
tag_name = tag_name.strip()
|
||||
|
||||
if len(tag_name) > 50:
|
||||
return False, "Tag name is too long (max 50 characters)"
|
||||
|
||||
if ',' in tag_name:
|
||||
return False, "Tag name cannot contain commas"
|
||||
|
||||
if tag_name.lower() in ['all', 'none', 'untagged']:
|
||||
return False, "Tag name is reserved"
|
||||
|
||||
return True, ""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
GUI components and panels for PunimTag
|
||||
"""
|
||||
|
||||
from .gui_core import GUICore
|
||||
from .dashboard_gui import DashboardGUI
|
||||
from .identify_panel import IdentifyPanel
|
||||
from .auto_match_panel import AutoMatchPanel
|
||||
from .modify_panel import ModifyPanel
|
||||
from .tag_manager_panel import TagManagerPanel
|
||||
|
||||
__all__ = [
|
||||
'GUICore',
|
||||
'DashboardGUI',
|
||||
'IdentifyPanel',
|
||||
'AutoMatchPanel',
|
||||
'ModifyPanel',
|
||||
'TagManagerPanel',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-Match Panel for PunimTag Dashboard
|
||||
Embeds the full auto-match GUI functionality into the dashboard frame
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from PIL import Image, ImageTk
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from src.core.config import DEFAULT_FACE_TOLERANCE
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.gui.gui_core import GUICore
|
||||
|
||||
|
||||
class AutoMatchPanel:
|
||||
"""Integrated auto-match panel that embeds the full auto-match GUI functionality into the dashboard"""
|
||||
|
||||
def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager,
|
||||
face_processor: FaceProcessor, gui_core: GUICore, on_navigate_home=None, verbose: int = 0):
|
||||
"""Initialize the auto-match panel"""
|
||||
self.parent_frame = parent_frame
|
||||
self.db = db_manager
|
||||
self.face_processor = face_processor
|
||||
self.gui_core = gui_core
|
||||
self.on_navigate_home = on_navigate_home
|
||||
self.verbose = verbose
|
||||
|
||||
# Panel state
|
||||
self.is_active = False
|
||||
self.matches_by_matched = {}
|
||||
self.data_cache = {}
|
||||
self.current_matched_index = 0
|
||||
self.matched_ids = []
|
||||
self.filtered_matched_ids = None
|
||||
self.identified_faces_per_person = {}
|
||||
self.checkbox_states_per_person = {}
|
||||
self.original_checkbox_states_per_person = {}
|
||||
self.identified_count = 0
|
||||
|
||||
# GUI components
|
||||
self.components = {}
|
||||
self.main_frame = None
|
||||
|
||||
def create_panel(self) -> ttk.Frame:
|
||||
"""Create the auto-match panel with all GUI components"""
|
||||
self.main_frame = ttk.Frame(self.parent_frame)
|
||||
|
||||
# Configure grid weights for full screen responsiveness
|
||||
self.main_frame.columnconfigure(0, weight=1) # Left panel
|
||||
self.main_frame.columnconfigure(1, weight=1) # Right panel
|
||||
self.main_frame.rowconfigure(0, weight=0) # Configuration row - fixed height
|
||||
self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable
|
||||
self.main_frame.rowconfigure(2, weight=0) # Control buttons row - fixed height
|
||||
|
||||
# Create all GUI components
|
||||
self._create_gui_components()
|
||||
|
||||
# Create main content panels
|
||||
self._create_main_panels()
|
||||
|
||||
return self.main_frame
|
||||
|
||||
def _create_gui_components(self):
|
||||
"""Create all GUI components for the auto-match interface"""
|
||||
# Configuration frame
|
||||
config_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="10")
|
||||
config_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
# 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.grid(row=0, column=0, padx=(0, 20))
|
||||
|
||||
# Tolerance setting
|
||||
ttk.Label(config_frame, text="Tolerance:").grid(row=0, column=1, sticky=tk.W, padx=(0, 2))
|
||||
self.components['tolerance_var'] = tk.StringVar(value=str(DEFAULT_FACE_TOLERANCE))
|
||||
tolerance_entry = ttk.Entry(config_frame, textvariable=self.components['tolerance_var'], width=8)
|
||||
tolerance_entry.grid(row=0, column=2, sticky=tk.W, padx=(0, 10))
|
||||
ttk.Label(config_frame, text="(lower = stricter matching)").grid(row=0, column=3, sticky=tk.W)
|
||||
|
||||
def _create_main_panels(self):
|
||||
"""Create the main left and right panels"""
|
||||
# Left panel for identified person
|
||||
self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Identified Person", padding="10")
|
||||
self.components['left_panel'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
|
||||
|
||||
# Right panel for unidentified faces
|
||||
self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Unidentified Faces to Match", padding="10")
|
||||
self.components['right_panel'].grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
|
||||
|
||||
# Create left panel content
|
||||
self._create_left_panel_content()
|
||||
|
||||
# Create right panel content
|
||||
self._create_right_panel_content()
|
||||
|
||||
# Create control buttons
|
||||
self._create_control_buttons()
|
||||
|
||||
def _create_left_panel_content(self):
|
||||
"""Create the left panel content for identified person"""
|
||||
left_panel = self.components['left_panel']
|
||||
|
||||
# Search controls for filtering people by last name
|
||||
search_frame = ttk.Frame(left_panel)
|
||||
search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
search_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Search input
|
||||
self.components['search_var'] = tk.StringVar()
|
||||
search_entry = ttk.Entry(search_frame, textvariable=self.components['search_var'], width=20)
|
||||
search_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Search buttons
|
||||
search_btn = ttk.Button(search_frame, text="Search", width=8, command=self._apply_search_filter)
|
||||
search_btn.grid(row=0, column=1, padx=(0, 5))
|
||||
|
||||
clear_btn = ttk.Button(search_frame, text="Clear", width=6, command=self._clear_search_filter)
|
||||
clear_btn.grid(row=0, column=2)
|
||||
|
||||
# Search help label
|
||||
self.components['search_help_label'] = ttk.Label(search_frame, text="Type Last Name",
|
||||
font=("Arial", 8), foreground="gray")
|
||||
self.components['search_help_label'].grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=(2, 0))
|
||||
|
||||
# Person info label
|
||||
self.components['person_info_label'] = ttk.Label(left_panel, text="", font=("Arial", 10, "bold"))
|
||||
self.components['person_info_label'].grid(row=1, column=0, pady=(0, 10), sticky=tk.W)
|
||||
|
||||
# Person image canvas
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
self.components['person_canvas'] = tk.Canvas(left_panel, width=300, height=300,
|
||||
bg=canvas_bg_color, highlightthickness=0)
|
||||
self.components['person_canvas'].grid(row=2, column=0, pady=(0, 10))
|
||||
|
||||
# Save button
|
||||
self.components['save_btn'] = ttk.Button(left_panel, text="💾 Save Changes",
|
||||
command=self._save_changes, state='disabled')
|
||||
self.components['save_btn'].grid(row=3, column=0, pady=(0, 10), sticky=(tk.W, tk.E))
|
||||
|
||||
def _create_right_panel_content(self):
|
||||
"""Create the right panel content for unidentified faces"""
|
||||
right_panel = self.components['right_panel']
|
||||
|
||||
# Control buttons for matches (Select All / Clear All)
|
||||
matches_controls_frame = ttk.Frame(right_panel)
|
||||
matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
|
||||
self.components['select_all_btn'] = ttk.Button(matches_controls_frame, text="☑️ Select All",
|
||||
command=self._select_all_matches, state='disabled')
|
||||
self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5))
|
||||
|
||||
self.components['clear_all_btn'] = ttk.Button(matches_controls_frame, text="☐ Clear All",
|
||||
command=self._clear_all_matches, state='disabled')
|
||||
self.components['clear_all_btn'].pack(side=tk.LEFT)
|
||||
|
||||
# Create scrollable frame for matches
|
||||
matches_frame = ttk.Frame(right_panel)
|
||||
matches_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
matches_frame.columnconfigure(0, weight=1)
|
||||
matches_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Create canvas and scrollbar for matches
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
self.components['matches_canvas'] = tk.Canvas(matches_frame, bg=canvas_bg_color, highlightthickness=0)
|
||||
self.components['matches_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
scrollbar = ttk.Scrollbar(matches_frame, orient="vertical", command=self.components['matches_canvas'].yview)
|
||||
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
self.components['matches_canvas'].configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Configure right panel grid weights
|
||||
right_panel.columnconfigure(0, weight=1)
|
||||
right_panel.rowconfigure(1, weight=1)
|
||||
|
||||
def _create_control_buttons(self):
|
||||
"""Create the control buttons for navigation"""
|
||||
control_frame = ttk.Frame(self.main_frame)
|
||||
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
|
||||
|
||||
self.components['back_btn'] = ttk.Button(control_frame, text="⏮️ Back",
|
||||
command=self._go_back, state='disabled')
|
||||
self.components['back_btn'].grid(row=0, column=0, padx=(0, 5))
|
||||
|
||||
self.components['next_btn'] = ttk.Button(control_frame, text="⏭️ Next",
|
||||
command=self._go_next, state='disabled')
|
||||
self.components['next_btn'].grid(row=0, column=1, padx=5)
|
||||
|
||||
self.components['quit_btn'] = ttk.Button(control_frame, text="❌ Exit Auto-Match",
|
||||
command=self._quit_auto_match)
|
||||
self.components['quit_btn'].grid(row=0, column=2, padx=(5, 0))
|
||||
|
||||
def _start_auto_match(self):
|
||||
"""Start the auto-match process"""
|
||||
try:
|
||||
tolerance = float(self.components['tolerance_var'].get().strip())
|
||||
if tolerance < 0 or tolerance > 1:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
messagebox.showerror("Error", "Please enter a valid tolerance value between 0.0 and 1.0.")
|
||||
return
|
||||
|
||||
include_same_photo = False # Always exclude same photo matching
|
||||
|
||||
# Get all identified faces (one per person) to use as reference faces
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3
|
||||
ORDER BY f.person_id, f.quality_score DESC
|
||||
''')
|
||||
identified_faces = cursor.fetchall()
|
||||
|
||||
if not identified_faces:
|
||||
messagebox.showinfo("No Identified Faces", "🔍 No identified faces found for auto-matching")
|
||||
return
|
||||
|
||||
# Group by person and get the best quality face per person
|
||||
person_faces = {}
|
||||
for face in identified_faces:
|
||||
person_id = face[1]
|
||||
if person_id not in person_faces:
|
||||
person_faces[person_id] = face
|
||||
|
||||
# Convert to ordered list to ensure consistent ordering
|
||||
person_faces_list = []
|
||||
for person_id, face in person_faces.items():
|
||||
# Get person name for ordering
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
first_name, last_name = result
|
||||
if last_name and first_name:
|
||||
person_name = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
person_name = last_name
|
||||
elif first_name:
|
||||
person_name = first_name
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
person_faces_list.append((person_id, face, person_name))
|
||||
|
||||
# Sort by person name for consistent, user-friendly ordering
|
||||
person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2)
|
||||
|
||||
# Find similar faces for each identified person
|
||||
self.matches_by_matched = {}
|
||||
for person_id, reference_face, person_name in person_faces_list:
|
||||
reference_face_id = reference_face[0]
|
||||
|
||||
# Use the same filtering and sorting logic as identify
|
||||
similar_faces = self.face_processor._get_filtered_similar_faces(
|
||||
reference_face_id, tolerance, include_same_photo, face_status=None)
|
||||
|
||||
# Convert to auto-match format
|
||||
person_matches = []
|
||||
for similar_face in similar_faces:
|
||||
match = {
|
||||
'unidentified_id': similar_face['face_id'],
|
||||
'unidentified_photo_id': similar_face['photo_id'],
|
||||
'unidentified_filename': similar_face['filename'],
|
||||
'unidentified_location': similar_face['location'],
|
||||
'matched_id': reference_face_id,
|
||||
'matched_photo_id': reference_face[2],
|
||||
'matched_filename': reference_face[4],
|
||||
'matched_location': reference_face[3],
|
||||
'person_id': person_id,
|
||||
'distance': similar_face['distance'],
|
||||
'quality_score': similar_face['quality_score'],
|
||||
'adaptive_tolerance': similar_face.get('adaptive_tolerance', tolerance)
|
||||
}
|
||||
person_matches.append(match)
|
||||
|
||||
self.matches_by_matched[person_id] = person_matches
|
||||
|
||||
# Flatten all matches for counting
|
||||
all_matches = []
|
||||
for person_matches in self.matches_by_matched.values():
|
||||
all_matches.extend(person_matches)
|
||||
|
||||
if not all_matches:
|
||||
messagebox.showinfo("No Matches", "🔍 No similar faces found for auto-identification")
|
||||
return
|
||||
|
||||
# Pre-fetch all needed data
|
||||
self.data_cache = self._prefetch_auto_match_data(self.matches_by_matched)
|
||||
|
||||
# Initialize state
|
||||
self.matched_ids = [person_id for person_id, _, _ in person_faces_list
|
||||
if person_id in self.matches_by_matched and self.matches_by_matched[person_id]]
|
||||
self.filtered_matched_ids = None
|
||||
self.current_matched_index = 0
|
||||
self.identified_faces_per_person = {}
|
||||
self.checkbox_states_per_person = {}
|
||||
self.original_checkbox_states_per_person = {}
|
||||
self.identified_count = 0
|
||||
|
||||
# Check if there's only one person - disable search if so
|
||||
has_only_one_person = len(self.matched_ids) == 1
|
||||
if has_only_one_person:
|
||||
self.components['search_var'].set("")
|
||||
search_entry = None
|
||||
for widget in self.components['left_panel'].winfo_children():
|
||||
if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0:
|
||||
for child in widget.winfo_children():
|
||||
if isinstance(child, ttk.Entry):
|
||||
search_entry = child
|
||||
break
|
||||
if search_entry:
|
||||
search_entry.config(state='disabled')
|
||||
self.components['search_help_label'].config(text="(Search disabled - only one person found)")
|
||||
|
||||
# Enable controls
|
||||
self._update_control_states()
|
||||
|
||||
# Show the first person
|
||||
self._update_display()
|
||||
|
||||
self.is_active = True
|
||||
|
||||
def _prefetch_auto_match_data(self, matches_by_matched: Dict) -> Dict:
|
||||
"""Pre-fetch all needed data to avoid repeated database queries"""
|
||||
data_cache = {}
|
||||
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pre-fetch all person names and details
|
||||
person_ids = list(matches_by_matched.keys())
|
||||
if person_ids:
|
||||
placeholders = ','.join('?' * len(person_ids))
|
||||
cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids)
|
||||
data_cache['person_details'] = {}
|
||||
for row in cursor.fetchall():
|
||||
person_id = row[0]
|
||||
first_name = row[1] or ''
|
||||
last_name = row[2] or ''
|
||||
middle_name = row[3] or ''
|
||||
maiden_name = row[4] or ''
|
||||
date_of_birth = row[5] or ''
|
||||
|
||||
# Create full name display
|
||||
name_parts = []
|
||||
if first_name:
|
||||
name_parts.append(first_name)
|
||||
if middle_name:
|
||||
name_parts.append(middle_name)
|
||||
if last_name:
|
||||
name_parts.append(last_name)
|
||||
if maiden_name:
|
||||
name_parts.append(f"({maiden_name})")
|
||||
|
||||
full_name = ' '.join(name_parts)
|
||||
data_cache['person_details'][person_id] = {
|
||||
'full_name': full_name,
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'middle_name': middle_name,
|
||||
'maiden_name': maiden_name,
|
||||
'date_of_birth': date_of_birth
|
||||
}
|
||||
|
||||
# Pre-fetch all photo paths (both matched and unidentified)
|
||||
all_photo_ids = set()
|
||||
for person_matches in matches_by_matched.values():
|
||||
for match in person_matches:
|
||||
all_photo_ids.add(match['matched_photo_id'])
|
||||
all_photo_ids.add(match['unidentified_photo_id'])
|
||||
|
||||
if all_photo_ids:
|
||||
photo_ids_list = list(all_photo_ids)
|
||||
placeholders = ','.join('?' * len(photo_ids_list))
|
||||
cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list)
|
||||
data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
return data_cache
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the display for the current person"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
|
||||
if self.current_matched_index >= len(active_ids):
|
||||
self._finish_auto_match()
|
||||
return
|
||||
|
||||
matched_id = active_ids[self.current_matched_index]
|
||||
matches_for_this_person = self.matches_by_matched[matched_id]
|
||||
|
||||
# Update button states
|
||||
self._update_control_states()
|
||||
|
||||
# Update save button text with person name
|
||||
self._update_save_button_text()
|
||||
|
||||
# Get the first match to get matched person info
|
||||
if not matches_for_this_person:
|
||||
print(f"❌ Error: No matches found for current person {matched_id}")
|
||||
# Skip to next person if available
|
||||
if self.current_matched_index < len(active_ids) - 1:
|
||||
self.current_matched_index += 1
|
||||
self._update_display()
|
||||
else:
|
||||
self._finish_auto_match()
|
||||
return
|
||||
|
||||
first_match = matches_for_this_person[0]
|
||||
|
||||
# Use cached data instead of database queries
|
||||
person_details = self.data_cache['person_details'].get(first_match['person_id'], {})
|
||||
person_name = person_details.get('full_name', "Unknown")
|
||||
date_of_birth = person_details.get('date_of_birth', '')
|
||||
matched_photo_path = self.data_cache['photo_paths'].get(first_match['matched_photo_id'], None)
|
||||
|
||||
# Create detailed person info display
|
||||
person_info_lines = [f"👤 Person: {person_name}"]
|
||||
if date_of_birth:
|
||||
person_info_lines.append(f"📅 Born: {date_of_birth}")
|
||||
person_info_lines.extend([
|
||||
f"📁 Photo: {first_match['matched_filename']}",
|
||||
f"📍 Face location: {first_match['matched_location']}"
|
||||
])
|
||||
|
||||
# Update matched person info
|
||||
self.components['person_info_label'].config(text="\n".join(person_info_lines))
|
||||
|
||||
# Display matched person face
|
||||
self.components['person_canvas'].delete("all")
|
||||
if matched_photo_path:
|
||||
matched_crop_path = self.face_processor._extract_face_crop(
|
||||
matched_photo_path,
|
||||
first_match['matched_location'],
|
||||
f"matched_{first_match['person_id']}"
|
||||
)
|
||||
|
||||
if matched_crop_path and os.path.exists(matched_crop_path):
|
||||
try:
|
||||
pil_image = Image.open(matched_crop_path)
|
||||
pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(pil_image)
|
||||
self.components['person_canvas'].create_image(150, 150, image=photo)
|
||||
self.components['person_canvas'].image = photo
|
||||
|
||||
# Add photo icon to the matched person face
|
||||
actual_width, actual_height = pil_image.size
|
||||
top_left_x = 150 - (actual_width // 2)
|
||||
top_left_y = 150 - (actual_height // 2)
|
||||
self.gui_core.create_photo_icon(self.components['person_canvas'], matched_photo_path, icon_size=20,
|
||||
face_x=top_left_x, face_y=top_left_y,
|
||||
face_width=actual_width, face_height=actual_height,
|
||||
canvas_width=300, canvas_height=300)
|
||||
except Exception as e:
|
||||
self.components['person_canvas'].create_text(150, 150, text=f"❌ Could not load image: {e}", fill="red")
|
||||
else:
|
||||
self.components['person_canvas'].create_text(150, 150, text="🖼️ No face crop available", fill="gray")
|
||||
|
||||
# Clear and populate unidentified faces
|
||||
self._update_matches_display(matches_for_this_person, matched_id)
|
||||
|
||||
def _update_matches_display(self, matches_for_this_person, matched_id):
|
||||
"""Update the matches display for the current person"""
|
||||
# Clear existing matches
|
||||
self.components['matches_canvas'].delete("all")
|
||||
self.match_checkboxes = []
|
||||
self.match_vars = []
|
||||
|
||||
# Create frame for unidentified faces inside canvas
|
||||
matches_inner_frame = ttk.Frame(self.components['matches_canvas'])
|
||||
self.components['matches_canvas'].create_window((0, 0), window=matches_inner_frame, anchor="nw")
|
||||
|
||||
# Use cached photo paths
|
||||
photo_paths = self.data_cache['photo_paths']
|
||||
|
||||
# Create all checkboxes
|
||||
for i, match in enumerate(matches_for_this_person):
|
||||
# Get unidentified face info from cached data
|
||||
unidentified_photo_path = photo_paths.get(match['unidentified_photo_id'], '')
|
||||
|
||||
# Calculate confidence
|
||||
confidence_pct = (1 - match['distance']) * 100
|
||||
confidence_desc = self.face_processor._get_confidence_description(confidence_pct)
|
||||
|
||||
# Create match frame
|
||||
match_frame = ttk.Frame(matches_inner_frame)
|
||||
match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Checkbox for this match
|
||||
match_var = tk.BooleanVar()
|
||||
|
||||
# Restore previous checkbox state if available
|
||||
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
||||
if matched_id in self.checkbox_states_per_person and unique_key in self.checkbox_states_per_person[matched_id]:
|
||||
saved_state = self.checkbox_states_per_person[matched_id][unique_key]
|
||||
match_var.set(saved_state)
|
||||
# Otherwise, pre-select if this face was previously identified for this person
|
||||
elif matched_id in self.identified_faces_per_person and match['unidentified_id'] in self.identified_faces_per_person[matched_id]:
|
||||
match_var.set(True)
|
||||
|
||||
self.match_vars.append(match_var)
|
||||
|
||||
# Capture original state at render time
|
||||
if matched_id not in self.original_checkbox_states_per_person:
|
||||
self.original_checkbox_states_per_person[matched_id] = {}
|
||||
if unique_key not in self.original_checkbox_states_per_person[matched_id]:
|
||||
self.original_checkbox_states_per_person[matched_id][unique_key] = match_var.get()
|
||||
|
||||
# Add callback to save state immediately when checkbox changes
|
||||
def on_checkbox_change(var, person_id, face_id):
|
||||
unique_key = f"{person_id}_{face_id}"
|
||||
if person_id not in self.checkbox_states_per_person:
|
||||
self.checkbox_states_per_person[person_id] = {}
|
||||
|
||||
current_value = var.get()
|
||||
self.checkbox_states_per_person[person_id][unique_key] = current_value
|
||||
|
||||
# Bind the callback to the variable
|
||||
current_person_id = matched_id
|
||||
current_face_id = match['unidentified_id']
|
||||
match_var.trace('w', lambda *args, var=match_var, person_id=current_person_id, face_id=current_face_id: on_checkbox_change(var, person_id, face_id))
|
||||
|
||||
# Configure match frame for grid layout
|
||||
match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width
|
||||
match_frame.columnconfigure(1, weight=0) # Image column - fixed width
|
||||
match_frame.columnconfigure(2, weight=1) # Text column - expandable
|
||||
|
||||
# Checkbox
|
||||
checkbox = ttk.Checkbutton(match_frame, variable=match_var)
|
||||
checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5))
|
||||
self.match_checkboxes.append(checkbox)
|
||||
|
||||
# Unidentified face image
|
||||
match_canvas = None
|
||||
if unidentified_photo_path:
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
match_canvas = tk.Canvas(match_frame, width=100, height=100, bg=canvas_bg_color, highlightthickness=0)
|
||||
match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10))
|
||||
|
||||
unidentified_crop_path = self.face_processor._extract_face_crop(
|
||||
unidentified_photo_path,
|
||||
match['unidentified_location'],
|
||||
f"unid_{match['unidentified_id']}"
|
||||
)
|
||||
|
||||
if unidentified_crop_path and os.path.exists(unidentified_crop_path):
|
||||
try:
|
||||
pil_image = Image.open(unidentified_crop_path)
|
||||
pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(pil_image)
|
||||
match_canvas.create_image(50, 50, image=photo)
|
||||
match_canvas.image = photo
|
||||
|
||||
# Add photo icon
|
||||
self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15,
|
||||
face_x=0, face_y=0,
|
||||
face_width=100, face_height=100,
|
||||
canvas_width=100, canvas_height=100)
|
||||
except Exception:
|
||||
match_canvas.create_text(50, 50, text="❌", fill="red")
|
||||
else:
|
||||
match_canvas.create_text(50, 50, text="🖼️", fill="gray")
|
||||
|
||||
# Confidence badge and filename
|
||||
info_container = ttk.Frame(match_frame)
|
||||
info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E))
|
||||
|
||||
badge = self.gui_core.create_confidence_badge(info_container, confidence_pct)
|
||||
badge.pack(anchor=tk.W)
|
||||
|
||||
filename_label = ttk.Label(info_container, text=f"📁 {match['unidentified_filename']}",
|
||||
font=("Arial", 8), foreground="gray")
|
||||
filename_label.pack(anchor=tk.W, pady=(2, 0))
|
||||
|
||||
# Update Select All / Clear All button states
|
||||
self._update_match_control_buttons_state()
|
||||
|
||||
# Update scroll region
|
||||
self.components['matches_canvas'].update_idletasks()
|
||||
self.components['matches_canvas'].configure(scrollregion=self.components['matches_canvas'].bbox("all"))
|
||||
|
||||
def _update_control_states(self):
|
||||
"""Update control button states based on current position"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
|
||||
# Enable/disable Back button
|
||||
if self.current_matched_index > 0:
|
||||
self.components['back_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['back_btn'].config(state='disabled')
|
||||
|
||||
# Enable/disable Next button
|
||||
if self.current_matched_index < len(active_ids) - 1:
|
||||
self.components['next_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['next_btn'].config(state='disabled')
|
||||
|
||||
# Enable save button if we have matches
|
||||
if active_ids and self.current_matched_index < len(active_ids):
|
||||
self.components['save_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['save_btn'].config(state='disabled')
|
||||
|
||||
def _update_save_button_text(self):
|
||||
"""Update save button text with current person name"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
if self.current_matched_index < len(active_ids):
|
||||
matched_id = active_ids[self.current_matched_index]
|
||||
matches_for_current_person = self.matches_by_matched[matched_id]
|
||||
if matches_for_current_person:
|
||||
person_id = matches_for_current_person[0]['person_id']
|
||||
person_details = self.data_cache['person_details'].get(person_id, {})
|
||||
person_name = person_details.get('full_name', "Unknown")
|
||||
self.components['save_btn'].config(text=f"💾 Save changes for {person_name}")
|
||||
else:
|
||||
self.components['save_btn'].config(text="💾 Save Changes")
|
||||
else:
|
||||
self.components['save_btn'].config(text="💾 Save Changes")
|
||||
|
||||
def _update_match_control_buttons_state(self):
|
||||
"""Enable/disable Select All / Clear All based on matches presence"""
|
||||
if hasattr(self, 'match_vars') and self.match_vars:
|
||||
self.components['select_all_btn'].config(state='normal')
|
||||
self.components['clear_all_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['select_all_btn'].config(state='disabled')
|
||||
self.components['clear_all_btn'].config(state='disabled')
|
||||
|
||||
def _select_all_matches(self):
|
||||
"""Select all match checkboxes"""
|
||||
if hasattr(self, 'match_vars'):
|
||||
for var in self.match_vars:
|
||||
var.set(True)
|
||||
|
||||
def _clear_all_matches(self):
|
||||
"""Clear all match checkboxes"""
|
||||
if hasattr(self, 'match_vars'):
|
||||
for var in self.match_vars:
|
||||
var.set(False)
|
||||
|
||||
def _save_changes(self):
|
||||
"""Save changes for the current person"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
if self.current_matched_index < len(active_ids):
|
||||
matched_id = active_ids[self.current_matched_index]
|
||||
matches_for_this_person = self.matches_by_matched[matched_id]
|
||||
|
||||
# Initialize identified faces for this person if not exists
|
||||
if matched_id not in self.identified_faces_per_person:
|
||||
self.identified_faces_per_person[matched_id] = set()
|
||||
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Process all matches (both checked and unchecked)
|
||||
for i, (match, var) in enumerate(zip(matches_for_this_person, self.match_vars)):
|
||||
if var.get():
|
||||
# Face is checked - assign to person
|
||||
cursor.execute(
|
||||
'UPDATE faces SET person_id = ? WHERE id = ?',
|
||||
(match['person_id'], match['unidentified_id'])
|
||||
)
|
||||
|
||||
# Use cached person name
|
||||
person_details = self.data_cache['person_details'].get(match['person_id'], {})
|
||||
person_name = person_details.get('full_name', "Unknown")
|
||||
|
||||
# Track this face as identified for this person
|
||||
self.identified_faces_per_person[matched_id].add(match['unidentified_id'])
|
||||
|
||||
print(f"✅ Identified as: {person_name}")
|
||||
self.identified_count += 1
|
||||
else:
|
||||
# Face is unchecked - check if it was previously identified for this person
|
||||
if match['unidentified_id'] in self.identified_faces_per_person[matched_id]:
|
||||
# This face was previously identified for this person, now unchecking it
|
||||
cursor.execute(
|
||||
'UPDATE faces SET person_id = NULL WHERE id = ?',
|
||||
(match['unidentified_id'],)
|
||||
)
|
||||
|
||||
# Remove from identified faces for this person
|
||||
self.identified_faces_per_person[matched_id].discard(match['unidentified_id'])
|
||||
|
||||
print(f"❌ Unidentified: {match['unidentified_filename']}")
|
||||
|
||||
# Update person encodings for all affected persons
|
||||
for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']):
|
||||
self.face_processor.update_person_encodings(person_id)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# After saving, set original states to the current UI states
|
||||
current_snapshot = {}
|
||||
for match, var in zip(matches_for_this_person, self.match_vars):
|
||||
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
||||
current_snapshot[unique_key] = var.get()
|
||||
self.checkbox_states_per_person[matched_id] = dict(current_snapshot)
|
||||
self.original_checkbox_states_per_person[matched_id] = dict(current_snapshot)
|
||||
|
||||
def _go_back(self):
|
||||
"""Go back to the previous person"""
|
||||
if self.current_matched_index > 0:
|
||||
self.current_matched_index -= 1
|
||||
self._update_display()
|
||||
|
||||
def _go_next(self):
|
||||
"""Go to the next person"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
if self.current_matched_index < len(active_ids) - 1:
|
||||
self.current_matched_index += 1
|
||||
self._update_display()
|
||||
else:
|
||||
self._finish_auto_match()
|
||||
|
||||
def _apply_search_filter(self):
|
||||
"""Filter people by last name and update navigation"""
|
||||
query = self.components['search_var'].get().strip().lower()
|
||||
if query:
|
||||
# Filter person_faces_list by last name
|
||||
filtered_people = []
|
||||
for person_id in self.matched_ids:
|
||||
# Get person name from cache
|
||||
person_details = self.data_cache['person_details'].get(person_id, {})
|
||||
person_name = person_details.get('full_name', '')
|
||||
|
||||
# Extract last name from person_name
|
||||
if ',' in person_name:
|
||||
last_name = person_name.split(',')[0].strip().lower()
|
||||
else:
|
||||
# Try to extract last name from full name
|
||||
name_parts = person_name.strip().split()
|
||||
if name_parts:
|
||||
last_name = name_parts[-1].lower()
|
||||
else:
|
||||
last_name = ''
|
||||
|
||||
if query in last_name:
|
||||
filtered_people.append(person_id)
|
||||
|
||||
self.filtered_matched_ids = filtered_people
|
||||
else:
|
||||
self.filtered_matched_ids = None
|
||||
|
||||
# Reset to first person in filtered list
|
||||
self.current_matched_index = 0
|
||||
if self.filtered_matched_ids:
|
||||
self._update_display()
|
||||
else:
|
||||
# No matches - clear display
|
||||
self.components['person_info_label'].config(text="No people match filter")
|
||||
self.components['person_canvas'].delete("all")
|
||||
self.components['person_canvas'].create_text(150, 150, text="No matches found", fill="gray")
|
||||
self.components['matches_canvas'].delete("all")
|
||||
self._update_control_states()
|
||||
|
||||
def _clear_search_filter(self):
|
||||
"""Clear filter and show all people"""
|
||||
self.components['search_var'].set("")
|
||||
self.filtered_matched_ids = None
|
||||
self.current_matched_index = 0
|
||||
self._update_display()
|
||||
|
||||
def _finish_auto_match(self):
|
||||
"""Finish the auto-match process"""
|
||||
print(f"\n✅ Auto-identified {self.identified_count} faces")
|
||||
messagebox.showinfo("Auto-Match Complete", f"Auto-identified {self.identified_count} faces")
|
||||
self._cleanup()
|
||||
|
||||
def _quit_auto_match(self):
|
||||
"""Quit the auto-match process"""
|
||||
# Check for unsaved changes before quitting
|
||||
if self._has_unsaved_changes():
|
||||
result = self.gui_core.create_large_messagebox(
|
||||
self.main_frame,
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes that will be lost if you quit.\n\n"
|
||||
"Yes: Save current changes and quit\n"
|
||||
"No: Quit without saving\n"
|
||||
"Cancel: Return to auto-match",
|
||||
"askyesnocancel"
|
||||
)
|
||||
if result is None:
|
||||
# Cancel
|
||||
return
|
||||
if result:
|
||||
# Save current person's changes, then quit
|
||||
self._save_changes()
|
||||
|
||||
self._cleanup()
|
||||
|
||||
# Navigate to home if callback is available (dashboard mode)
|
||||
if self.on_navigate_home:
|
||||
self.on_navigate_home()
|
||||
|
||||
def _has_unsaved_changes(self):
|
||||
"""Check if there are any unsaved changes"""
|
||||
for person_id, current_states in self.checkbox_states_per_person.items():
|
||||
if person_id in self.original_checkbox_states_per_person:
|
||||
original_states = self.original_checkbox_states_per_person[person_id]
|
||||
# Check if any checkbox state differs from its original state
|
||||
for key, current_value in current_states.items():
|
||||
if key not in original_states or original_states[key] != current_value:
|
||||
return True
|
||||
else:
|
||||
# If person has current states but no original states, there are changes
|
||||
if any(current_states.values()):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up resources and reset state"""
|
||||
# Clean up face crops
|
||||
self.face_processor.cleanup_face_crops()
|
||||
|
||||
# Reset state
|
||||
self.matches_by_matched = {}
|
||||
self.data_cache = {}
|
||||
self.current_matched_index = 0
|
||||
self.matched_ids = []
|
||||
self.filtered_matched_ids = None
|
||||
self.identified_faces_per_person = {}
|
||||
self.checkbox_states_per_person = {}
|
||||
self.original_checkbox_states_per_person = {}
|
||||
self.identified_count = 0
|
||||
|
||||
# Clear displays
|
||||
self.components['person_info_label'].config(text="")
|
||||
self.components['person_canvas'].delete("all")
|
||||
self.components['matches_canvas'].delete("all")
|
||||
|
||||
# Disable controls
|
||||
self.components['back_btn'].config(state='disabled')
|
||||
self.components['next_btn'].config(state='disabled')
|
||||
self.components['save_btn'].config(state='disabled')
|
||||
self.components['select_all_btn'].config(state='disabled')
|
||||
self.components['clear_all_btn'].config(state='disabled')
|
||||
|
||||
# Clear search
|
||||
self.components['search_var'].set("")
|
||||
self.components['search_help_label'].config(text="Type Last Name")
|
||||
|
||||
# Re-enable search entry
|
||||
search_entry = None
|
||||
for widget in self.components['left_panel'].winfo_children():
|
||||
if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0:
|
||||
for child in widget.winfo_children():
|
||||
if isinstance(child, ttk.Entry):
|
||||
search_entry = child
|
||||
break
|
||||
if search_entry:
|
||||
search_entry.config(state='normal')
|
||||
|
||||
self.is_active = False
|
||||
|
||||
def activate(self):
|
||||
"""Activate the panel"""
|
||||
self.is_active = True
|
||||
|
||||
def deactivate(self):
|
||||
"""Deactivate the panel"""
|
||||
if self.is_active:
|
||||
self._cleanup()
|
||||
self.is_active = False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common GUI utilities and widgets for PunimTag
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
from PIL import Image, ImageTk
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from src.core.config import DEFAULT_CONFIG_FILE, DEFAULT_WINDOW_SIZE, ICON_SIZE
|
||||
|
||||
|
||||
class GUICore:
|
||||
"""Common GUI utilities and helper functions"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize GUI core utilities"""
|
||||
pass
|
||||
|
||||
def setup_window_size_saving(self, root, config_file: str = DEFAULT_CONFIG_FILE) -> str:
|
||||
"""Set up window size saving functionality"""
|
||||
# Load saved window size
|
||||
saved_size = DEFAULT_WINDOW_SIZE
|
||||
|
||||
if os.path.exists(config_file):
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
saved_size = config.get('window_size', DEFAULT_WINDOW_SIZE)
|
||||
except:
|
||||
saved_size = DEFAULT_WINDOW_SIZE
|
||||
|
||||
# Calculate center position before showing window
|
||||
try:
|
||||
width = int(saved_size.split('x')[0])
|
||||
height = int(saved_size.split('x')[1])
|
||||
x = (root.winfo_screenwidth() // 2) - (width // 2)
|
||||
y = (root.winfo_screenheight() // 2) - (height // 2)
|
||||
root.geometry(f"{saved_size}+{x}+{y}")
|
||||
except:
|
||||
# Fallback to default geometry if positioning fails
|
||||
root.geometry(saved_size)
|
||||
|
||||
# Track previous size to detect actual resizing
|
||||
last_size = None
|
||||
|
||||
def save_window_size(event=None):
|
||||
nonlocal last_size
|
||||
if event and event.widget == root:
|
||||
current_size = f"{root.winfo_width()}x{root.winfo_height()}"
|
||||
# Only save if size actually changed
|
||||
if current_size != last_size:
|
||||
last_size = current_size
|
||||
try:
|
||||
config = {'window_size': current_size}
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f)
|
||||
except:
|
||||
pass # Ignore save errors
|
||||
|
||||
# Bind resize event
|
||||
root.bind('<Configure>', save_window_size)
|
||||
return saved_size
|
||||
|
||||
def create_photo_icon(self, canvas, photo_path: str, icon_size: int = ICON_SIZE,
|
||||
icon_x: int = None, icon_y: int = None,
|
||||
canvas_width: int = None, canvas_height: int = None,
|
||||
face_x: int = None, face_y: int = None,
|
||||
face_width: int = None, face_height: int = None,
|
||||
callback: callable = None) -> Optional[int]:
|
||||
"""Create a reusable photo icon with tooltip on a canvas"""
|
||||
import tkinter as tk
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
def open_source_photo(event):
|
||||
"""Open the source photo in a properly sized window"""
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# Try to open with a specific image viewer that supports window sizing
|
||||
try:
|
||||
subprocess.run(["mspaint", photo_path], check=False)
|
||||
except:
|
||||
os.startfile(photo_path)
|
||||
elif system == "Darwin": # macOS
|
||||
# Use Preview with specific window size
|
||||
subprocess.run(["open", "-a", "Preview", photo_path])
|
||||
else: # Linux and others
|
||||
# Try common image viewers with window sizing options
|
||||
viewers_to_try = [
|
||||
["eog", "--new-window", photo_path], # Eye of GNOME
|
||||
["gwenview", photo_path], # KDE image viewer
|
||||
["feh", "--geometry", "800x600", photo_path], # feh with specific size
|
||||
["gimp", photo_path], # GIMP
|
||||
["xdg-open", photo_path] # Fallback to default
|
||||
]
|
||||
|
||||
opened = False
|
||||
for viewer_cmd in viewers_to_try:
|
||||
try:
|
||||
result = subprocess.run(viewer_cmd, check=False, capture_output=True)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not opened:
|
||||
# Final fallback
|
||||
subprocess.run(["xdg-open", photo_path])
|
||||
except Exception as e:
|
||||
print(f"❌ Could not open photo: {e}")
|
||||
|
||||
# Create tooltip for the icon
|
||||
tooltip = None
|
||||
|
||||
def show_tooltip(event):
|
||||
nonlocal tooltip
|
||||
if tooltip:
|
||||
tooltip.destroy()
|
||||
tooltip = tk.Toplevel()
|
||||
tooltip.wm_overrideredirect(True)
|
||||
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
|
||||
label = tk.Label(tooltip, text="Show original photo",
|
||||
background="lightyellow", relief="solid", borderwidth=1,
|
||||
font=("Arial", 9))
|
||||
label.pack()
|
||||
|
||||
def hide_tooltip(event):
|
||||
nonlocal tooltip
|
||||
if tooltip:
|
||||
tooltip.destroy()
|
||||
tooltip = None
|
||||
|
||||
# Calculate icon position
|
||||
if icon_x is None or icon_y is None:
|
||||
if face_x is not None and face_y is not None and face_width is not None and face_height is not None:
|
||||
# Position relative to face image - exactly in the top-right corner
|
||||
icon_x = face_x + face_width - icon_size
|
||||
icon_y = face_y
|
||||
else:
|
||||
# Position relative to canvas - exactly in the corner
|
||||
if canvas_width is None:
|
||||
canvas_width = canvas.winfo_width()
|
||||
if canvas_height is None:
|
||||
canvas_height = canvas.winfo_height()
|
||||
icon_x = canvas_width - icon_size
|
||||
icon_y = 0
|
||||
|
||||
# Ensure icon stays within canvas bounds
|
||||
if canvas_width is None:
|
||||
canvas_width = canvas.winfo_width()
|
||||
if canvas_height is None:
|
||||
canvas_height = canvas.winfo_height()
|
||||
icon_x = min(icon_x, canvas_width - icon_size)
|
||||
icon_y = max(icon_y, 0)
|
||||
|
||||
# Draw the photo icon
|
||||
canvas.create_rectangle(icon_x, icon_y, icon_x + icon_size, icon_y + icon_size,
|
||||
fill="white", outline="black", width=1, tags="photo_icon")
|
||||
canvas.create_text(icon_x + icon_size//2, icon_y + icon_size//2,
|
||||
text="📷", font=("Arial", 10), tags="photo_icon")
|
||||
|
||||
# Bind events
|
||||
canvas.tag_bind("photo_icon", "<Button-1>", open_source_photo)
|
||||
canvas.tag_bind("photo_icon", "<Enter>", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e)))
|
||||
canvas.tag_bind("photo_icon", "<Leave>", lambda e: (canvas.config(cursor=""), hide_tooltip(e)))
|
||||
canvas.tag_bind("photo_icon", "<Motion>", lambda e: (show_tooltip(e) if tooltip else None))
|
||||
|
||||
return tooltip # Return tooltip reference for cleanup if needed
|
||||
|
||||
def create_confidence_badge(self, parent, confidence_pct: float):
|
||||
"""Create a colorful confidence badge with percentage and label.
|
||||
Returns a frame containing a small colored circle (with percent) and a text label.
|
||||
"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
# Determine color and label
|
||||
if confidence_pct >= 80:
|
||||
color = "#27AE60" # green
|
||||
label = "Very High"
|
||||
text_color = "white"
|
||||
elif confidence_pct >= 70:
|
||||
color = "#F1C40F" # yellow
|
||||
label = "High"
|
||||
text_color = "black"
|
||||
elif confidence_pct >= 60:
|
||||
color = "#E67E22" # orange
|
||||
label = "Medium"
|
||||
text_color = "white"
|
||||
elif confidence_pct >= 50:
|
||||
color = "#E74C3C" # red
|
||||
label = "Low"
|
||||
text_color = "white"
|
||||
else:
|
||||
color = "#2C3E50" # dark blue/black
|
||||
label = "Very Low"
|
||||
text_color = "white"
|
||||
|
||||
badge_frame = ttk.Frame(parent)
|
||||
|
||||
# Draw circle sized to roughly match the label font height
|
||||
size = 14
|
||||
style = ttk.Style()
|
||||
bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
canvas = tk.Canvas(badge_frame, width=size, height=size, highlightthickness=0, bg=bg_color)
|
||||
canvas.grid(row=0, column=0, padx=(0, 4))
|
||||
canvas.create_oval(1, 1, size-1, size-1, fill=color, outline=color)
|
||||
|
||||
# Text label right to the circle
|
||||
label_widget = ttk.Label(badge_frame, text=f"{int(round(confidence_pct))}% {label}", font=("Arial", 9, "bold"))
|
||||
label_widget.grid(row=0, column=1, sticky="w")
|
||||
|
||||
return badge_frame
|
||||
|
||||
def create_face_crop_image(self, photo_path: str, face_location: tuple,
|
||||
face_id: int, crop_size: int = 100) -> Optional[str]:
|
||||
"""Create a face crop image for display"""
|
||||
try:
|
||||
# Parse location tuple from string format
|
||||
if isinstance(face_location, str):
|
||||
face_location = eval(face_location)
|
||||
|
||||
top, right, bottom, left = face_location
|
||||
|
||||
# Load the image
|
||||
with Image.open(photo_path) as image:
|
||||
# Add padding around the face
|
||||
face_width = right - left
|
||||
face_height = bottom - top
|
||||
padding_x = int(face_width * 0.2)
|
||||
padding_y = int(face_height * 0.2)
|
||||
|
||||
# Calculate crop bounds with padding
|
||||
crop_left = max(0, left - padding_x)
|
||||
crop_top = max(0, top - padding_y)
|
||||
crop_right = min(image.width, right + padding_x)
|
||||
crop_bottom = min(image.height, bottom + padding_y)
|
||||
|
||||
# Crop the face
|
||||
face_crop = image.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||||
|
||||
# Resize to standard size
|
||||
face_crop = face_crop.resize((crop_size, crop_size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create temporary file
|
||||
temp_dir = tempfile.gettempdir()
|
||||
face_filename = f"face_{face_id}_display.jpg"
|
||||
face_path = os.path.join(temp_dir, face_filename)
|
||||
|
||||
face_crop.save(face_path, "JPEG", quality=95)
|
||||
return face_path
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def create_photo_thumbnail(self, photo_path: str, thumbnail_size: int = 150) -> Optional[ImageTk.PhotoImage]:
|
||||
"""Create a thumbnail for display"""
|
||||
try:
|
||||
if not os.path.exists(photo_path):
|
||||
return None
|
||||
|
||||
with Image.open(photo_path) as img:
|
||||
img.thumbnail((thumbnail_size, thumbnail_size), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def create_comparison_image(self, unid_crop_path: str, match_crop_path: str,
|
||||
person_name: str, confidence: float) -> Optional[str]:
|
||||
"""Create a side-by-side comparison image"""
|
||||
try:
|
||||
# Load both face crops
|
||||
unid_img = Image.open(unid_crop_path)
|
||||
match_img = Image.open(match_crop_path)
|
||||
|
||||
# Resize both to same height for better comparison
|
||||
target_height = 300
|
||||
unid_ratio = target_height / unid_img.height
|
||||
match_ratio = target_height / match_img.height
|
||||
|
||||
unid_resized = unid_img.resize((int(unid_img.width * unid_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
match_resized = match_img.resize((int(match_img.width * match_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create comparison image
|
||||
total_width = unid_resized.width + match_resized.width + 20 # 20px gap
|
||||
comparison = Image.new('RGB', (total_width, target_height + 60), 'white')
|
||||
|
||||
# Paste images
|
||||
comparison.paste(unid_resized, (0, 30))
|
||||
comparison.paste(match_resized, (unid_resized.width + 20, 30))
|
||||
|
||||
# Add labels
|
||||
from PIL import ImageDraw, ImageFont
|
||||
draw = ImageDraw.Draw(comparison)
|
||||
try:
|
||||
# Try to use a font
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw.text((10, 5), "UNKNOWN", fill='red', font=font)
|
||||
draw.text((unid_resized.width + 30, 5), f"{person_name.upper()}", fill='green', font=font)
|
||||
draw.text((10, target_height + 35), f"Confidence: {confidence:.1%}", fill='blue', font=font)
|
||||
|
||||
# Save comparison image
|
||||
temp_dir = tempfile.gettempdir()
|
||||
comparison_path = os.path.join(temp_dir, f"face_comparison_{person_name}.jpg")
|
||||
comparison.save(comparison_path, "JPEG", quality=95)
|
||||
|
||||
return comparison_path
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def get_confidence_description(self, confidence_pct: float) -> str:
|
||||
"""Get human-readable confidence description"""
|
||||
if confidence_pct >= 80:
|
||||
return "🟢 (Very High - Almost Certain)"
|
||||
elif confidence_pct >= 70:
|
||||
return "🟡 (High - Likely Match)"
|
||||
elif confidence_pct >= 60:
|
||||
return "🟠 (Medium - Possible Match)"
|
||||
elif confidence_pct >= 50:
|
||||
return "🔴 (Low - Questionable)"
|
||||
else:
|
||||
return "⚫ (Very Low)"
|
||||
|
||||
def center_window(self, root, width: int = None, height: int = None):
|
||||
"""Center a window on the screen"""
|
||||
if width is None:
|
||||
width = root.winfo_width()
|
||||
if height is None:
|
||||
height = root.winfo_height()
|
||||
|
||||
screen_width = root.winfo_screenwidth()
|
||||
screen_height = root.winfo_screenheight()
|
||||
|
||||
x = (screen_width - width) // 2
|
||||
y = (screen_height - height) // 2
|
||||
|
||||
root.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
def create_tooltip(self, widget, text: str):
|
||||
"""Create a tooltip for a widget"""
|
||||
def show_tooltip(event):
|
||||
tooltip = tk.Toplevel()
|
||||
tooltip.wm_overrideredirect(True)
|
||||
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
|
||||
|
||||
label = tk.Label(tooltip, text=text, background="lightyellow",
|
||||
relief="solid", borderwidth=1, font=("Arial", 9))
|
||||
label.pack()
|
||||
|
||||
widget.tooltip = tooltip
|
||||
|
||||
def hide_tooltip(event):
|
||||
if hasattr(widget, 'tooltip'):
|
||||
widget.tooltip.destroy()
|
||||
del widget.tooltip
|
||||
|
||||
widget.bind('<Enter>', show_tooltip)
|
||||
widget.bind('<Leave>', hide_tooltip)
|
||||
|
||||
def create_progress_bar(self, parent, text: str = "Processing..."):
|
||||
"""Create a progress bar dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
progress_window = tk.Toplevel(parent)
|
||||
progress_window.title("Progress")
|
||||
progress_window.resizable(False, False)
|
||||
|
||||
# Center the progress window
|
||||
progress_window.transient(parent)
|
||||
progress_window.grab_set()
|
||||
|
||||
frame = ttk.Frame(progress_window, padding="20")
|
||||
frame.pack()
|
||||
|
||||
label = ttk.Label(frame, text=text)
|
||||
label.pack(pady=(0, 10))
|
||||
|
||||
progress = ttk.Progressbar(frame, mode='indeterminate')
|
||||
progress.pack(fill='x', pady=(0, 10))
|
||||
progress.start()
|
||||
|
||||
# Center the window
|
||||
progress_window.update_idletasks()
|
||||
x = (progress_window.winfo_screenwidth() // 2) - (progress_window.winfo_width() // 2)
|
||||
y = (progress_window.winfo_screenheight() // 2) - (progress_window.winfo_height() // 2)
|
||||
progress_window.geometry(f"+{x}+{y}")
|
||||
|
||||
return progress_window, progress
|
||||
|
||||
def create_confirmation_dialog(self, parent, title: str, message: str) -> bool:
|
||||
"""Create a confirmation dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
result = messagebox.askyesno(title, message, parent=parent)
|
||||
return result
|
||||
|
||||
def create_large_messagebox(self, parent, title: str, message: str, msg_type: str = "warning") -> bool:
|
||||
"""Create a larger messagebox dialog that fits text without wrapping"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
# Calculate appropriate size based on message length
|
||||
lines = message.count('\n') + 1
|
||||
max_line_length = max(len(line) for line in message.split('\n'))
|
||||
|
||||
# Set width to accommodate text (minimum 400, maximum 800)
|
||||
width = max(400, min(800, max_line_length * 8 + 100))
|
||||
# Set height based on number of lines (minimum 200, maximum 500)
|
||||
height = max(200, min(500, lines * 25 + 150))
|
||||
|
||||
# Calculate center position first
|
||||
screen_width = parent.winfo_screenwidth() if parent else tk._default_root.winfo_screenwidth()
|
||||
screen_height = parent.winfo_screenheight() if parent else tk._default_root.winfo_screenheight()
|
||||
x = (screen_width - width) // 2
|
||||
y = (screen_height - height) // 2
|
||||
|
||||
# Create a custom dialog for better control over size
|
||||
dialog = tk.Toplevel(parent)
|
||||
dialog.title(title)
|
||||
dialog.transient(parent)
|
||||
dialog.grab_set()
|
||||
|
||||
# Set geometry with position in one call to prevent jumping
|
||||
dialog.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
# Create main frame
|
||||
main_frame = ttk.Frame(dialog, padding="20")
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Add message label
|
||||
message_label = tk.Label(main_frame, text=message, font=("Arial", 10),
|
||||
justify=tk.LEFT, wraplength=width-100)
|
||||
message_label.pack(pady=(0, 20))
|
||||
|
||||
# Add buttons based on message type
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.pack()
|
||||
|
||||
def close_dialog(result_value):
|
||||
"""Close dialog and set result"""
|
||||
dialog._result = result_value
|
||||
dialog.destroy()
|
||||
|
||||
if msg_type == "warning":
|
||||
ttk.Button(button_frame, text="OK", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
|
||||
elif msg_type == "askyesno":
|
||||
ttk.Button(button_frame, text="Yes", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text="No", command=lambda: close_dialog(False)).pack(side=tk.LEFT, padx=5)
|
||||
elif msg_type == "askyesnocancel":
|
||||
ttk.Button(button_frame, text="Yes", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text="No", command=lambda: close_dialog(False)).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text="Cancel", command=lambda: close_dialog(None)).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# Wait for dialog to close
|
||||
dialog.wait_window()
|
||||
return getattr(dialog, '_result', None)
|
||||
|
||||
def create_input_dialog(self, parent, title: str, prompt: str, default: str = "") -> Optional[str]:
|
||||
"""Create an input dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import simpledialog
|
||||
|
||||
result = simpledialog.askstring(title, prompt, initialvalue=default, parent=parent)
|
||||
return result
|
||||
|
||||
def create_file_dialog(self, parent, title: str, filetypes: list = None) -> Optional[str]:
|
||||
"""Create a file dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
if filetypes is None:
|
||||
filetypes = [("Image files", "*.jpg *.jpeg *.png *.gif *.bmp *.tiff")]
|
||||
|
||||
result = filedialog.askopenfilename(title=title, filetypes=filetypes, parent=parent)
|
||||
return result if result else None
|
||||
|
||||
def create_directory_dialog(self, parent, title: str) -> Optional[str]:
|
||||
"""Create a directory dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
result = filedialog.askdirectory(title=title, parent=parent)
|
||||
return result if result else None
|
||||
|
||||
def cleanup_temp_files(self, file_paths: list):
|
||||
"""Clean up temporary files"""
|
||||
for file_path in file_paths:
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
def create_calendar_dialog(self, parent, title: str, initial_date: str = None) -> Optional[str]:
|
||||
"""Create a calendar dialog for date selection"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from datetime import datetime, date
|
||||
import calendar
|
||||
|
||||
# Create calendar window
|
||||
calendar_window = tk.Toplevel(parent)
|
||||
calendar_window.title(title)
|
||||
calendar_window.resizable(False, False)
|
||||
calendar_window.transient(parent)
|
||||
calendar_window.grab_set()
|
||||
|
||||
# Calculate center position
|
||||
window_width = 400
|
||||
window_height = 400
|
||||
screen_width = calendar_window.winfo_screenwidth()
|
||||
screen_height = calendar_window.winfo_screenheight()
|
||||
x = (screen_width // 2) - (window_width // 2)
|
||||
y = (screen_height // 2) - (window_height // 2)
|
||||
calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
||||
|
||||
# Calendar variables
|
||||
current_date = datetime.now()
|
||||
selected_date = None
|
||||
|
||||
# Create custom styles for calendar buttons
|
||||
style = ttk.Style()
|
||||
style.configure("Calendar.TButton", padding=(2, 2))
|
||||
style.configure("Selected.TButton", background="lightblue")
|
||||
style.configure("Today.TButton", background="lightyellow")
|
||||
|
||||
# Check if there's already a date selected
|
||||
if initial_date:
|
||||
try:
|
||||
selected_date = datetime.strptime(initial_date, '%Y-%m-%d').date()
|
||||
display_year = selected_date.year
|
||||
display_month = selected_date.month
|
||||
except ValueError:
|
||||
display_year = current_date.year
|
||||
display_month = current_date.month
|
||||
selected_date = None
|
||||
else:
|
||||
display_year = current_date.year
|
||||
display_month = current_date.month
|
||||
|
||||
# Month names
|
||||
month_names = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"]
|
||||
|
||||
# Main frame
|
||||
main_cal_frame = ttk.Frame(calendar_window, padding="10")
|
||||
main_cal_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Header frame with navigation
|
||||
header_frame = ttk.Frame(main_cal_frame)
|
||||
header_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
|
||||
# Month/Year display and navigation
|
||||
nav_frame = ttk.Frame(header_frame)
|
||||
nav_frame.pack()
|
||||
|
||||
# Month/Year label
|
||||
month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold"))
|
||||
month_year_label.pack(side=tk.LEFT, padx=10)
|
||||
|
||||
def update_calendar():
|
||||
"""Update the calendar display"""
|
||||
# Update month/year label
|
||||
month_year_label.configure(text=f"{month_names[display_month-1]} {display_year}")
|
||||
|
||||
# Clear existing calendar
|
||||
for widget in calendar_frame.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# Get calendar data
|
||||
cal = calendar.monthcalendar(display_year, display_month)
|
||||
|
||||
# Day headers
|
||||
day_headers = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
for i, day in enumerate(day_headers):
|
||||
header_label = ttk.Label(calendar_frame, text=day, font=("Arial", 10, "bold"))
|
||||
header_label.grid(row=0, column=i, padx=2, pady=2, sticky="nsew")
|
||||
|
||||
# Calendar days
|
||||
for week_num, week in enumerate(cal):
|
||||
for day_num, day in enumerate(week):
|
||||
if day == 0:
|
||||
# Empty cell
|
||||
empty_label = ttk.Label(calendar_frame, text="")
|
||||
empty_label.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew")
|
||||
else:
|
||||
# Day button
|
||||
day_date = date(display_year, display_month, day)
|
||||
is_selected = selected_date == day_date
|
||||
is_today = day_date == current_date.date()
|
||||
is_future = day_date > current_date.date()
|
||||
|
||||
if is_future:
|
||||
# Disable future dates
|
||||
day_btn = ttk.Button(calendar_frame, text=str(day),
|
||||
state='disabled', style="Calendar.TButton")
|
||||
else:
|
||||
# Create day selection handler
|
||||
def make_day_handler(day_value):
|
||||
def select_day():
|
||||
nonlocal selected_date
|
||||
selected_date = date(display_year, display_month, day_value)
|
||||
# Reset all buttons to normal calendar style
|
||||
for widget in calendar_frame.winfo_children():
|
||||
if isinstance(widget, ttk.Button):
|
||||
widget.config(style="Calendar.TButton")
|
||||
# Highlight selected day
|
||||
for widget in calendar_frame.winfo_children():
|
||||
if isinstance(widget, ttk.Button) and widget.cget("text") == str(day_value):
|
||||
widget.config(style="Selected.TButton")
|
||||
return select_day
|
||||
|
||||
day_btn = ttk.Button(calendar_frame, text=str(day),
|
||||
command=make_day_handler(day),
|
||||
style="Calendar.TButton")
|
||||
|
||||
day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew")
|
||||
|
||||
# Apply initial styling
|
||||
if is_selected:
|
||||
day_btn.config(style="Selected.TButton")
|
||||
elif is_today and not is_future:
|
||||
day_btn.config(style="Today.TButton")
|
||||
|
||||
|
||||
def prev_month():
|
||||
nonlocal display_month, display_year
|
||||
display_month -= 1
|
||||
if display_month < 1:
|
||||
display_month = 12
|
||||
display_year -= 1
|
||||
update_calendar()
|
||||
|
||||
def next_month():
|
||||
nonlocal display_month, display_year
|
||||
display_month += 1
|
||||
if display_month > 12:
|
||||
display_month = 1
|
||||
display_year += 1
|
||||
|
||||
# Don't allow navigation to future months
|
||||
if date(display_year, display_month, 1) > current_date.date():
|
||||
display_month -= 1
|
||||
if display_month < 1:
|
||||
display_month = 12
|
||||
display_year -= 1
|
||||
return
|
||||
|
||||
update_calendar()
|
||||
|
||||
def prev_year():
|
||||
nonlocal display_year
|
||||
display_year -= 1
|
||||
update_calendar()
|
||||
|
||||
def next_year():
|
||||
nonlocal display_year
|
||||
display_year += 1
|
||||
|
||||
# Don't allow navigation to future years
|
||||
if display_year > current_date.year:
|
||||
display_year -= 1
|
||||
return
|
||||
|
||||
update_calendar()
|
||||
|
||||
# Navigation buttons
|
||||
prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year)
|
||||
prev_year_btn.pack(side=tk.LEFT)
|
||||
|
||||
prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month)
|
||||
prev_month_btn.pack(side=tk.LEFT, padx=(5, 0))
|
||||
|
||||
next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month)
|
||||
next_month_btn.pack(side=tk.LEFT, padx=(5, 0))
|
||||
|
||||
next_year_btn = ttk.Button(nav_frame, text=">>", width=3, command=next_year)
|
||||
next_year_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Calendar grid frame
|
||||
calendar_frame = ttk.Frame(main_cal_frame)
|
||||
calendar_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||||
|
||||
# Configure grid weights
|
||||
for i in range(7):
|
||||
calendar_frame.columnconfigure(i, weight=1)
|
||||
for i in range(7):
|
||||
calendar_frame.rowconfigure(i, weight=1)
|
||||
|
||||
# Buttons frame
|
||||
buttons_frame = ttk.Frame(main_cal_frame)
|
||||
buttons_frame.pack(fill=tk.X)
|
||||
|
||||
def select_date():
|
||||
"""Select the date and close calendar"""
|
||||
if selected_date:
|
||||
calendar_window.selected_date = selected_date.strftime('%Y-%m-%d')
|
||||
else:
|
||||
calendar_window.selected_date = ""
|
||||
calendar_window.destroy()
|
||||
|
||||
def cancel_selection():
|
||||
"""Cancel date selection"""
|
||||
calendar_window.destroy()
|
||||
|
||||
# Buttons (matching original layout)
|
||||
ttk.Button(buttons_frame, text="Select", command=select_date).pack(side=tk.LEFT, padx=(0, 5))
|
||||
ttk.Button(buttons_frame, text="Cancel", command=cancel_selection).pack(side=tk.LEFT)
|
||||
|
||||
# Initialize calendar display
|
||||
update_calendar()
|
||||
|
||||
# Wait for window to close
|
||||
calendar_window.wait_window()
|
||||
|
||||
# Return selected date or None
|
||||
return getattr(calendar_window, 'selected_date', None)
|
||||
|
||||
def create_autocomplete_entry(self, parent, suggestions: list, callback: callable = None):
|
||||
"""Create an entry widget with autocomplete functionality"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
# Create entry
|
||||
entry_var = tk.StringVar()
|
||||
entry = ttk.Entry(parent, textvariable=entry_var)
|
||||
|
||||
# Create listbox for suggestions
|
||||
listbox = tk.Listbox(parent, height=8)
|
||||
listbox.place_forget() # Hide initially
|
||||
|
||||
def show_suggestions():
|
||||
"""Show filtered suggestions in listbox"""
|
||||
typed = entry_var.get().strip()
|
||||
|
||||
if not typed:
|
||||
filtered = [] # Show nothing if no typing
|
||||
else:
|
||||
low = typed.lower()
|
||||
# Only show names that start with the typed text
|
||||
filtered = [n for n in suggestions if n.lower().startswith(low)][:10]
|
||||
|
||||
# Update listbox
|
||||
listbox.delete(0, tk.END)
|
||||
for name in filtered:
|
||||
listbox.insert(tk.END, name)
|
||||
|
||||
# Show listbox if we have suggestions
|
||||
if filtered:
|
||||
# Position listbox below entry
|
||||
entry.update_idletasks()
|
||||
x = entry.winfo_x()
|
||||
y = entry.winfo_y() + entry.winfo_height()
|
||||
width = entry.winfo_width()
|
||||
|
||||
listbox.place(x=x, y=y, width=width)
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(0) # Select first item
|
||||
listbox.activate(0) # Activate first item
|
||||
else:
|
||||
listbox.place_forget()
|
||||
|
||||
def hide_suggestions():
|
||||
"""Hide the suggestions listbox"""
|
||||
listbox.place_forget()
|
||||
|
||||
def on_listbox_select(event=None):
|
||||
"""Handle listbox selection and hide list"""
|
||||
selection = listbox.curselection()
|
||||
if selection:
|
||||
selected_name = listbox.get(selection[0])
|
||||
entry_var.set(selected_name)
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
if callback:
|
||||
callback(selected_name)
|
||||
|
||||
def on_listbox_click(event):
|
||||
"""Handle mouse click selection"""
|
||||
try:
|
||||
index = listbox.nearest(event.y)
|
||||
if index is not None and index >= 0:
|
||||
selected_name = listbox.get(index)
|
||||
entry_var.set(selected_name)
|
||||
except:
|
||||
pass
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
if callback:
|
||||
callback(selected_name)
|
||||
return 'break'
|
||||
|
||||
def on_key_press(event):
|
||||
"""Handle key navigation in entry"""
|
||||
if event.keysym == 'Down':
|
||||
if listbox.winfo_viewable():
|
||||
listbox.focus_set()
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(0)
|
||||
listbox.activate(0)
|
||||
return 'break'
|
||||
elif event.keysym == 'Escape':
|
||||
hide_suggestions()
|
||||
return 'break'
|
||||
elif event.keysym == 'Return':
|
||||
return 'break'
|
||||
|
||||
def on_listbox_key(event):
|
||||
"""Handle key navigation in listbox"""
|
||||
if event.keysym == 'Return':
|
||||
on_listbox_select(event)
|
||||
return 'break'
|
||||
elif event.keysym == 'Escape':
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
return 'break'
|
||||
elif event.keysym == 'Up':
|
||||
selection = listbox.curselection()
|
||||
if selection and selection[0] > 0:
|
||||
# Move up in listbox
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(selection[0] - 1)
|
||||
listbox.see(selection[0] - 1)
|
||||
else:
|
||||
# At top, go back to entry field
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
return 'break'
|
||||
elif event.keysym == 'Down':
|
||||
selection = listbox.curselection()
|
||||
max_index = listbox.size() - 1
|
||||
if selection and selection[0] < max_index:
|
||||
# Move down in listbox
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(selection[0] + 1)
|
||||
listbox.see(selection[0] + 1)
|
||||
return 'break'
|
||||
|
||||
# Bind events
|
||||
entry.bind('<KeyRelease>', lambda e: show_suggestions())
|
||||
entry.bind('<KeyPress>', on_key_press)
|
||||
entry.bind('<FocusOut>', lambda e: parent.after(150, hide_suggestions)) # Delay to allow listbox clicks
|
||||
listbox.bind('<Button-1>', on_listbox_click)
|
||||
listbox.bind('<KeyPress>', on_listbox_key)
|
||||
listbox.bind('<Double-Button-1>', on_listbox_click)
|
||||
|
||||
return entry, entry_var, listbox
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,740 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integrated Modify Panel for PunimTag Dashboard
|
||||
Embeds the full modify identified GUI functionality into the dashboard frame
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from PIL import Image, ImageTk
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from src.core.config import DEFAULT_FACE_TOLERANCE
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.gui.gui_core import GUICore
|
||||
|
||||
|
||||
class ToolTip:
|
||||
"""Simple tooltip implementation"""
|
||||
def __init__(self, widget, text):
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.tooltip_window = None
|
||||
self.widget.bind("<Enter>", self.on_enter)
|
||||
self.widget.bind("<Leave>", self.on_leave)
|
||||
|
||||
def on_enter(self, event=None):
|
||||
if self.tooltip_window or not self.text:
|
||||
return
|
||||
x, y, _, _ = self.widget.bbox("insert") if hasattr(self.widget, 'bbox') else (0, 0, 0, 0)
|
||||
x += self.widget.winfo_rootx() + 25
|
||||
y += self.widget.winfo_rooty() + 25
|
||||
|
||||
self.tooltip_window = tw = tk.Toplevel(self.widget)
|
||||
tw.wm_overrideredirect(True)
|
||||
tw.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
||||
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
|
||||
font=("tahoma", "8", "normal"))
|
||||
label.pack(ipadx=1)
|
||||
|
||||
def on_leave(self, event=None):
|
||||
if self.tooltip_window:
|
||||
self.tooltip_window.destroy()
|
||||
self.tooltip_window = None
|
||||
|
||||
|
||||
class ModifyPanel:
|
||||
"""Integrated modify panel that embeds the full modify identified GUI functionality into the dashboard"""
|
||||
|
||||
def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager,
|
||||
face_processor: FaceProcessor, gui_core: GUICore, on_navigate_home=None, verbose: int = 0):
|
||||
"""Initialize the modify panel"""
|
||||
self.parent_frame = parent_frame
|
||||
self.db = db_manager
|
||||
self.face_processor = face_processor
|
||||
self.gui_core = gui_core
|
||||
self.on_navigate_home = on_navigate_home
|
||||
self.verbose = verbose
|
||||
|
||||
# Panel state
|
||||
self.is_active = False
|
||||
self.temp_crops = []
|
||||
self.right_panel_images = [] # Keep PhotoImage refs alive
|
||||
self.selected_person_id = None
|
||||
|
||||
# Track unmatched faces (temporary changes)
|
||||
self.unmatched_faces = set() # All face IDs unmatched across people (for global save)
|
||||
self.unmatched_by_person = {} # person_id -> set(face_id) for per-person undo
|
||||
self.original_faces_data = [] # store original faces data for potential future use
|
||||
|
||||
# People data
|
||||
self.people_data = [] # list of dicts: {id, name, count, first_name, last_name}
|
||||
self.people_filtered = None # filtered subset based on last name search
|
||||
self.current_person_id = None
|
||||
self.current_person_name = ""
|
||||
self.resize_job = None
|
||||
|
||||
# GUI components
|
||||
self.components = {}
|
||||
self.main_frame = None
|
||||
|
||||
def create_panel(self) -> ttk.Frame:
|
||||
"""Create the modify panel with all GUI components"""
|
||||
self.main_frame = ttk.Frame(self.parent_frame)
|
||||
|
||||
# Configure grid weights for full screen responsiveness
|
||||
self.main_frame.columnconfigure(0, weight=1) # Left panel
|
||||
self.main_frame.columnconfigure(1, weight=2) # Right panel
|
||||
self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable
|
||||
|
||||
# Create all GUI components
|
||||
self._create_gui_components()
|
||||
|
||||
# Create main content panels
|
||||
self._create_main_panels()
|
||||
|
||||
return self.main_frame
|
||||
|
||||
def _create_gui_components(self):
|
||||
"""Create all GUI components for the modify interface"""
|
||||
# Search controls (Last Name) with label under the input (match auto-match style)
|
||||
self.components['last_name_search_var'] = tk.StringVar()
|
||||
|
||||
# Control buttons
|
||||
self.components['quit_btn'] = None
|
||||
self.components['save_btn_bottom'] = None
|
||||
|
||||
def _create_main_panels(self):
|
||||
"""Create the main left and right panels"""
|
||||
# Left panel: People list
|
||||
self.components['people_frame'] = ttk.LabelFrame(self.main_frame, text="People", padding="10")
|
||||
self.components['people_frame'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8))
|
||||
self.components['people_frame'].columnconfigure(0, weight=1)
|
||||
|
||||
# Right panel: Faces for selected person
|
||||
self.components['faces_frame'] = ttk.LabelFrame(self.main_frame, text="Faces", padding="10")
|
||||
self.components['faces_frame'].grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
self.components['faces_frame'].columnconfigure(0, weight=1)
|
||||
self.components['faces_frame'].rowconfigure(0, weight=1)
|
||||
|
||||
# Create left panel content
|
||||
self._create_left_panel_content()
|
||||
|
||||
# Create right panel content
|
||||
self._create_right_panel_content()
|
||||
|
||||
# Create control buttons
|
||||
self._create_control_buttons()
|
||||
|
||||
def _create_left_panel_content(self):
|
||||
"""Create the left panel content for people list"""
|
||||
people_frame = self.components['people_frame']
|
||||
|
||||
# Search controls
|
||||
search_frame = ttk.Frame(people_frame)
|
||||
search_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 6))
|
||||
|
||||
# Entry on the left
|
||||
search_entry = ttk.Entry(search_frame, textvariable=self.components['last_name_search_var'], width=20)
|
||||
search_entry.grid(row=0, column=0, sticky=tk.W)
|
||||
|
||||
# Buttons to the right of the entry
|
||||
buttons_row = ttk.Frame(search_frame)
|
||||
buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0))
|
||||
search_btn = ttk.Button(buttons_row, text="Search", width=8, command=self.apply_last_name_filter)
|
||||
search_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
clear_btn = ttk.Button(buttons_row, text="Clear", width=6, command=self.clear_last_name_filter)
|
||||
clear_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Helper label directly under the entry
|
||||
last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray")
|
||||
last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0))
|
||||
|
||||
# People list with scrollbar
|
||||
people_canvas = tk.Canvas(people_frame, bg='white')
|
||||
people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview)
|
||||
self.components['people_list_inner'] = ttk.Frame(people_canvas)
|
||||
people_canvas.create_window((0, 0), window=self.components['people_list_inner'], anchor="nw")
|
||||
people_canvas.configure(yscrollcommand=people_scrollbar.set)
|
||||
|
||||
self.components['people_list_inner'].bind(
|
||||
"<Configure>",
|
||||
lambda e: people_canvas.configure(scrollregion=people_canvas.bbox("all"))
|
||||
)
|
||||
|
||||
people_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
people_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
|
||||
people_frame.rowconfigure(1, weight=1)
|
||||
|
||||
# Store canvas reference
|
||||
self.components['people_canvas'] = people_canvas
|
||||
|
||||
# Bind Enter key for search
|
||||
search_entry.bind('<Return>', lambda e: self.apply_last_name_filter())
|
||||
|
||||
def _create_right_panel_content(self):
|
||||
"""Create the right panel content for faces display"""
|
||||
faces_frame = self.components['faces_frame']
|
||||
|
||||
# Style configuration
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
|
||||
self.components['faces_canvas'] = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0)
|
||||
faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=self.components['faces_canvas'].yview)
|
||||
self.components['faces_inner'] = ttk.Frame(self.components['faces_canvas'])
|
||||
self.components['faces_canvas'].create_window((0, 0), window=self.components['faces_inner'], anchor="nw")
|
||||
self.components['faces_canvas'].configure(yscrollcommand=faces_scrollbar.set)
|
||||
|
||||
self.components['faces_inner'].bind(
|
||||
"<Configure>",
|
||||
lambda e: self.components['faces_canvas'].configure(scrollregion=self.components['faces_canvas'].bbox("all"))
|
||||
)
|
||||
|
||||
# Bind resize handler for responsive face grid
|
||||
self.components['faces_canvas'].bind("<Configure>", self.on_faces_canvas_resize)
|
||||
|
||||
self.components['faces_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
faces_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
|
||||
def _create_control_buttons(self):
|
||||
"""Create control buttons at the bottom"""
|
||||
# Control buttons
|
||||
control_frame = ttk.Frame(self.main_frame)
|
||||
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
|
||||
|
||||
self.components['quit_btn'] = ttk.Button(control_frame, text="❌ Exit Edit Identified", command=self.on_quit)
|
||||
self.components['quit_btn'].pack(side=tk.RIGHT)
|
||||
self.components['save_btn_bottom'] = ttk.Button(control_frame, text="💾 Save changes", command=self.on_save_all_changes, state="disabled")
|
||||
self.components['save_btn_bottom'].pack(side=tk.RIGHT, padx=(0, 10))
|
||||
self.components['undo_btn'] = ttk.Button(control_frame, text="↶ Undo changes", command=self.undo_changes, state="disabled")
|
||||
self.components['undo_btn'].pack(side=tk.RIGHT, padx=(0, 10))
|
||||
|
||||
def on_faces_canvas_resize(self, event):
|
||||
"""Handle canvas resize for responsive face grid"""
|
||||
if self.current_person_id is None:
|
||||
return
|
||||
# Debounce re-render on resize
|
||||
try:
|
||||
if self.resize_job is not None:
|
||||
self.main_frame.after_cancel(self.resize_job)
|
||||
except Exception:
|
||||
pass
|
||||
self.resize_job = self.main_frame.after(150, lambda: self.show_person_faces(self.current_person_id, self.current_person_name))
|
||||
|
||||
def load_people(self):
|
||||
"""Load people from database with counts"""
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT p.id, p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth, COUNT(f.id) as face_count
|
||||
FROM people p
|
||||
JOIN faces f ON f.person_id = p.id
|
||||
GROUP BY p.id, p.last_name, p.first_name, p.middle_name, p.maiden_name, p.date_of_birth
|
||||
HAVING face_count > 0
|
||||
ORDER BY p.last_name, p.first_name COLLATE NOCASE
|
||||
"""
|
||||
)
|
||||
self.people_data = []
|
||||
for (pid, first_name, last_name, middle_name, maiden_name, date_of_birth, count) in cursor.fetchall():
|
||||
# Create full name display with all available information
|
||||
name_parts = []
|
||||
if first_name:
|
||||
name_parts.append(first_name)
|
||||
if middle_name:
|
||||
name_parts.append(middle_name)
|
||||
if last_name:
|
||||
name_parts.append(last_name)
|
||||
if maiden_name:
|
||||
name_parts.append(f"({maiden_name})")
|
||||
|
||||
full_name = ' '.join(name_parts) if name_parts else "Unknown"
|
||||
|
||||
# Create detailed display with date of birth if available
|
||||
display_name = full_name
|
||||
if date_of_birth:
|
||||
display_name += f" - Born: {date_of_birth}"
|
||||
|
||||
self.people_data.append({
|
||||
'id': pid,
|
||||
'name': display_name,
|
||||
'full_name': full_name,
|
||||
'first_name': first_name or "",
|
||||
'last_name': last_name or "",
|
||||
'middle_name': middle_name or "",
|
||||
'maiden_name': maiden_name or "",
|
||||
'date_of_birth': date_of_birth or "",
|
||||
'count': count
|
||||
})
|
||||
# Re-apply filter (if any) after loading
|
||||
try:
|
||||
self.apply_last_name_filter()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def apply_last_name_filter(self):
|
||||
"""Apply last name filter to people list"""
|
||||
query = self.components['last_name_search_var'].get().strip().lower()
|
||||
if query:
|
||||
self.people_filtered = [p for p in self.people_data if p.get('last_name', '').lower().find(query) != -1]
|
||||
else:
|
||||
self.people_filtered = None
|
||||
self.populate_people_list()
|
||||
|
||||
def clear_last_name_filter(self):
|
||||
"""Clear the last name filter"""
|
||||
self.components['last_name_search_var'].set("")
|
||||
self.people_filtered = None
|
||||
self.populate_people_list()
|
||||
|
||||
def populate_people_list(self):
|
||||
"""Populate the people list with current data"""
|
||||
# Clear existing widgets
|
||||
for widget in self.components['people_list_inner'].winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# Use filtered data if available, otherwise use all data
|
||||
people_to_show = self.people_filtered if self.people_filtered is not None else self.people_data
|
||||
|
||||
for i, person in enumerate(people_to_show):
|
||||
row_frame = ttk.Frame(self.components['people_list_inner'])
|
||||
row_frame.pack(fill=tk.X, padx=2, pady=1)
|
||||
|
||||
# Edit button (on the left)
|
||||
edit_btn = ttk.Button(row_frame, text="✏️", width=3,
|
||||
command=lambda p=person: self.start_edit_person(p))
|
||||
edit_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
# Add tooltip to edit button
|
||||
ToolTip(edit_btn, "Update name")
|
||||
|
||||
# Label (clickable) - takes remaining space
|
||||
name_lbl = ttk.Label(row_frame, text=f"{person['name']} ({person['count']})", font=("Arial", 10))
|
||||
name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
name_lbl.bind("<Button-1>", lambda e, p=person: self.show_person_faces(p['id'], p['name']))
|
||||
name_lbl.config(cursor="hand2")
|
||||
|
||||
# Bold if selected
|
||||
if (self.selected_person_id is None and i == 0) or (self.selected_person_id == person['id']):
|
||||
name_lbl.config(font=("Arial", 10, "bold"))
|
||||
|
||||
def start_edit_person(self, person_record):
|
||||
"""Start editing a person's information"""
|
||||
# Create a new window for editing
|
||||
edit_window = tk.Toplevel(self.main_frame)
|
||||
edit_window.title(f"Edit {person_record['name']}")
|
||||
edit_window.geometry("500x400")
|
||||
edit_window.transient(self.main_frame)
|
||||
edit_window.grab_set()
|
||||
|
||||
# Center the window
|
||||
edit_window.update_idletasks()
|
||||
x = (edit_window.winfo_screenwidth() // 2) - (edit_window.winfo_width() // 2)
|
||||
y = (edit_window.winfo_screenheight() // 2) - (edit_window.winfo_height() // 2)
|
||||
edit_window.geometry(f"+{x}+{y}")
|
||||
|
||||
# Create form fields
|
||||
form_frame = ttk.Frame(edit_window, padding="20")
|
||||
form_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# First name
|
||||
ttk.Label(form_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, pady=5)
|
||||
first_name_var = tk.StringVar(value=person_record.get('first_name', ''))
|
||||
first_entry = ttk.Entry(form_frame, textvariable=first_name_var, width=30)
|
||||
first_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Last name
|
||||
ttk.Label(form_frame, text="Last name:").grid(row=1, column=0, sticky=tk.W, pady=5)
|
||||
last_name_var = tk.StringVar(value=person_record.get('last_name', ''))
|
||||
last_entry = ttk.Entry(form_frame, textvariable=last_name_var, width=30)
|
||||
last_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Middle name
|
||||
ttk.Label(form_frame, text="Middle name:").grid(row=2, column=0, sticky=tk.W, pady=5)
|
||||
middle_name_var = tk.StringVar(value=person_record.get('middle_name', ''))
|
||||
middle_entry = ttk.Entry(form_frame, textvariable=middle_name_var, width=30)
|
||||
middle_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Maiden name
|
||||
ttk.Label(form_frame, text="Maiden name:").grid(row=3, column=0, sticky=tk.W, pady=5)
|
||||
maiden_name_var = tk.StringVar(value=person_record.get('maiden_name', ''))
|
||||
maiden_entry = ttk.Entry(form_frame, textvariable=maiden_name_var, width=30)
|
||||
maiden_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Date of birth
|
||||
ttk.Label(form_frame, text="Date of birth:").grid(row=4, column=0, sticky=tk.W, pady=5)
|
||||
dob_var = tk.StringVar(value=person_record.get('date_of_birth', ''))
|
||||
dob_entry = ttk.Entry(form_frame, textvariable=dob_var, width=30, state='readonly')
|
||||
dob_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Calendar button for date of birth
|
||||
def open_dob_calendar():
|
||||
selected_date = self.gui_core.create_calendar_dialog(edit_window, "Select Date of Birth", dob_var.get())
|
||||
if selected_date is not None:
|
||||
dob_var.set(selected_date)
|
||||
|
||||
dob_calendar_btn = ttk.Button(form_frame, text="📅", width=3, command=open_dob_calendar)
|
||||
dob_calendar_btn.grid(row=4, column=2, padx=(5, 0), pady=5)
|
||||
|
||||
# Configure grid weights
|
||||
form_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(edit_window)
|
||||
button_frame.pack(fill=tk.X, padx=20, pady=10)
|
||||
|
||||
def save_rename():
|
||||
"""Save the renamed person"""
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE people
|
||||
SET first_name = ?, last_name = ?, middle_name = ?, maiden_name = ?, date_of_birth = ?
|
||||
WHERE id = ?
|
||||
""", (
|
||||
first_name_var.get().strip(),
|
||||
last_name_var.get().strip(),
|
||||
middle_name_var.get().strip(),
|
||||
maiden_name_var.get().strip(),
|
||||
dob_var.get().strip(),
|
||||
person_record['id']
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Refresh the people list
|
||||
self.load_people()
|
||||
self.populate_people_list()
|
||||
|
||||
# Close the edit window
|
||||
edit_window.destroy()
|
||||
|
||||
messagebox.showinfo("Success", "Person information updated successfully.")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to update person: {e}")
|
||||
|
||||
def cancel_edit():
|
||||
"""Cancel editing"""
|
||||
edit_window.destroy()
|
||||
|
||||
save_btn = ttk.Button(button_frame, text="Save", command=save_rename)
|
||||
save_btn.pack(side=tk.LEFT, padx=(0, 10))
|
||||
cancel_btn = ttk.Button(button_frame, text="Cancel", command=cancel_edit)
|
||||
cancel_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Focus on first name field
|
||||
first_entry.focus_set()
|
||||
|
||||
# Add keyboard shortcuts
|
||||
def try_save():
|
||||
if save_btn.cget('state') == 'normal':
|
||||
save_rename()
|
||||
|
||||
first_entry.bind('<Return>', lambda e: try_save())
|
||||
last_entry.bind('<Return>', lambda e: try_save())
|
||||
middle_entry.bind('<Return>', lambda e: try_save())
|
||||
maiden_entry.bind('<Return>', lambda e: try_save())
|
||||
dob_entry.bind('<Return>', lambda e: try_save())
|
||||
first_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
last_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
middle_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
maiden_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
dob_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
|
||||
# Add validation
|
||||
def validate_save_button():
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
if first_name and last_name:
|
||||
save_btn.config(state='normal')
|
||||
else:
|
||||
save_btn.config(state='disabled')
|
||||
|
||||
# Bind validation to all fields
|
||||
first_name_var.trace('w', lambda *args: validate_save_button())
|
||||
last_name_var.trace('w', lambda *args: validate_save_button())
|
||||
middle_name_var.trace('w', lambda *args: validate_save_button())
|
||||
maiden_name_var.trace('w', lambda *args: validate_save_button())
|
||||
dob_var.trace('w', lambda *args: validate_save_button())
|
||||
|
||||
# Initial validation
|
||||
validate_save_button()
|
||||
|
||||
def show_person_faces(self, person_id, person_name):
|
||||
"""Show faces for the selected person"""
|
||||
self.current_person_id = person_id
|
||||
self.current_person_name = person_name
|
||||
self.selected_person_id = person_id
|
||||
|
||||
# Clear existing face widgets
|
||||
for widget in self.components['faces_inner'].winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# Load faces for this person
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT f.id, f.photo_id, p.path, p.filename, f.location
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.person_id = ?
|
||||
ORDER BY p.filename
|
||||
""", (person_id,))
|
||||
|
||||
faces = cursor.fetchall()
|
||||
|
||||
# Filter out unmatched faces
|
||||
visible_faces = [face for face in faces if face[0] not in self.unmatched_faces]
|
||||
|
||||
if not visible_faces:
|
||||
if not faces:
|
||||
no_faces_label = ttk.Label(self.components['faces_inner'],
|
||||
text="No faces found for this person",
|
||||
font=("Arial", 12))
|
||||
else:
|
||||
no_faces_label = ttk.Label(self.components['faces_inner'],
|
||||
text="All faces unmatched",
|
||||
font=("Arial", 12))
|
||||
no_faces_label.pack(pady=20)
|
||||
return
|
||||
|
||||
# Display faces in a grid
|
||||
self._display_faces_grid(visible_faces)
|
||||
|
||||
# Update people list to show selection
|
||||
self.populate_people_list()
|
||||
|
||||
# Update button states based on unmatched faces
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
def _display_faces_grid(self, faces):
|
||||
"""Display faces in a responsive grid layout"""
|
||||
# Calculate grid dimensions based on canvas width
|
||||
canvas_width = self.components['faces_canvas'].winfo_width()
|
||||
if canvas_width < 100: # Canvas not yet rendered
|
||||
canvas_width = 400 # Default width
|
||||
|
||||
face_size = 80
|
||||
padding = 10
|
||||
faces_per_row = max(1, (canvas_width - padding) // (face_size + padding))
|
||||
|
||||
# Clear existing images
|
||||
self.right_panel_images.clear()
|
||||
|
||||
for i, (face_id, photo_id, photo_path, filename, location) in enumerate(faces):
|
||||
row = i // faces_per_row
|
||||
col = i % faces_per_row
|
||||
|
||||
# Create face frame
|
||||
face_frame = ttk.Frame(self.components['faces_inner'])
|
||||
face_frame.grid(row=row, column=col, padx=5, pady=5, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
# Face image
|
||||
try:
|
||||
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id)
|
||||
if face_crop_path and os.path.exists(face_crop_path):
|
||||
self.temp_crops.append(face_crop_path)
|
||||
|
||||
image = Image.open(face_crop_path)
|
||||
image.thumbnail((face_size, face_size), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(image)
|
||||
self.right_panel_images.append(photo) # Keep reference
|
||||
|
||||
# Create canvas for face image
|
||||
face_canvas = tk.Canvas(face_frame, width=face_size, height=face_size, highlightthickness=0)
|
||||
face_canvas.pack()
|
||||
face_canvas.create_image(face_size//2, face_size//2, image=photo, anchor=tk.CENTER)
|
||||
|
||||
# Add photo icon
|
||||
self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15,
|
||||
face_x=0, face_y=0,
|
||||
face_width=face_size, face_height=face_size,
|
||||
canvas_width=face_size, canvas_height=face_size)
|
||||
|
||||
# Unmatch button
|
||||
unmatch_btn = ttk.Button(face_frame, text="Unmatch",
|
||||
command=lambda fid=face_id: self.unmatch_face(fid))
|
||||
unmatch_btn.pack(pady=2)
|
||||
|
||||
else:
|
||||
# Placeholder for missing face crop
|
||||
placeholder_label = ttk.Label(face_frame, text=f"Face {face_id}",
|
||||
font=("Arial", 8))
|
||||
placeholder_label.pack()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error displaying face {face_id}: {e}")
|
||||
# Placeholder for error
|
||||
error_label = ttk.Label(face_frame, text=f"Error {face_id}",
|
||||
font=("Arial", 8), foreground="red")
|
||||
error_label.pack()
|
||||
|
||||
def unmatch_face(self, face_id):
|
||||
"""Unmatch a face from its person"""
|
||||
if face_id not in self.unmatched_faces:
|
||||
self.unmatched_faces.add(face_id)
|
||||
if self.current_person_id not in self.unmatched_by_person:
|
||||
self.unmatched_by_person[self.current_person_id] = set()
|
||||
self.unmatched_by_person[self.current_person_id].add(face_id)
|
||||
print(f"Face {face_id} marked for unmatching")
|
||||
# Immediately refresh the display to hide the unmatched face
|
||||
if self.current_person_id:
|
||||
self.show_person_faces(self.current_person_id, self.current_person_name)
|
||||
# Update button states
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
def _update_undo_button_state(self):
|
||||
"""Update the undo button state based on unmatched faces for current person"""
|
||||
if 'undo_btn' in self.components:
|
||||
current_has_unmatched = bool(self.unmatched_by_person.get(self.current_person_id))
|
||||
if current_has_unmatched:
|
||||
self.components['undo_btn'].config(state="normal")
|
||||
else:
|
||||
self.components['undo_btn'].config(state="disabled")
|
||||
|
||||
def _update_save_button_state(self):
|
||||
"""Update the save button state based on whether there are any unmatched faces to save"""
|
||||
if 'save_btn_bottom' in self.components:
|
||||
if self.unmatched_faces:
|
||||
self.components['save_btn_bottom'].config(state="normal")
|
||||
else:
|
||||
self.components['save_btn_bottom'].config(state="disabled")
|
||||
|
||||
def undo_changes(self):
|
||||
"""Undo all unmatched faces for the current person"""
|
||||
if self.current_person_id and self.current_person_id in self.unmatched_by_person:
|
||||
# Remove faces for current person from unmatched sets
|
||||
person_faces = self.unmatched_by_person[self.current_person_id]
|
||||
self.unmatched_faces -= person_faces
|
||||
del self.unmatched_by_person[self.current_person_id]
|
||||
|
||||
# Refresh the display to show the restored faces
|
||||
if self.current_person_id:
|
||||
self.show_person_faces(self.current_person_id, self.current_person_name)
|
||||
# Update button states
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
messagebox.showinfo("Undo", f"Undid changes for {len(person_faces)} face(s).")
|
||||
else:
|
||||
messagebox.showinfo("No Changes", "No changes to undo for this person.")
|
||||
|
||||
|
||||
def on_quit(self):
|
||||
"""Handle quit button click"""
|
||||
# Check for unsaved changes
|
||||
if self.unmatched_faces:
|
||||
result = self.gui_core.create_large_messagebox(
|
||||
self.main_frame,
|
||||
"Unsaved Changes",
|
||||
f"You have {len(self.unmatched_faces)} unsaved changes.\n\n"
|
||||
"Do you want to save them before quitting?\n\n"
|
||||
"• Yes: Save changes and quit\n"
|
||||
"• No: Quit without saving\n"
|
||||
"• Cancel: Return to modify",
|
||||
"askyesnocancel"
|
||||
)
|
||||
|
||||
if result is True: # Yes - Save and quit
|
||||
self.on_save_all_changes()
|
||||
elif result is False: # No - Quit without saving
|
||||
pass
|
||||
else: # Cancel - Don't quit
|
||||
return
|
||||
|
||||
# Clean up and deactivate
|
||||
self._cleanup()
|
||||
self.is_active = False
|
||||
|
||||
# Navigate to home if callback is available (dashboard mode)
|
||||
if self.on_navigate_home:
|
||||
self.on_navigate_home()
|
||||
|
||||
def on_save_all_changes(self):
|
||||
"""Save all unmatched faces to database"""
|
||||
if not self.unmatched_faces:
|
||||
messagebox.showinfo("No Changes", "No changes to save.")
|
||||
return
|
||||
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
count = 0
|
||||
for face_id in self.unmatched_faces:
|
||||
cursor.execute("UPDATE faces SET person_id = NULL WHERE id = ?", (face_id,))
|
||||
count += 1
|
||||
conn.commit()
|
||||
|
||||
# Clear the unmatched faces
|
||||
self.unmatched_faces.clear()
|
||||
self.unmatched_by_person.clear()
|
||||
|
||||
# Refresh the display
|
||||
if self.current_person_id:
|
||||
self.show_person_faces(self.current_person_id, self.current_person_name)
|
||||
|
||||
# Update button states
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to save changes: {e}")
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up resources"""
|
||||
# Clear temporary crops
|
||||
for crop_path in self.temp_crops:
|
||||
try:
|
||||
if os.path.exists(crop_path):
|
||||
os.remove(crop_path)
|
||||
except Exception:
|
||||
pass
|
||||
self.temp_crops.clear()
|
||||
|
||||
# Clear right panel images
|
||||
self.right_panel_images.clear()
|
||||
|
||||
# Clear state
|
||||
self.unmatched_faces.clear()
|
||||
self.unmatched_by_person.clear()
|
||||
self.original_faces_data.clear()
|
||||
self.people_data.clear()
|
||||
self.people_filtered = None
|
||||
self.current_person_id = None
|
||||
self.current_person_name = ""
|
||||
self.selected_person_id = None
|
||||
|
||||
def activate(self):
|
||||
"""Activate the panel"""
|
||||
self.is_active = True
|
||||
# Initial load
|
||||
self.load_people()
|
||||
self.populate_people_list()
|
||||
|
||||
# Show first person's faces by default and mark selected
|
||||
if self.people_data:
|
||||
self.selected_person_id = self.people_data[0]['id']
|
||||
self.show_person_faces(self.people_data[0]['id'], self.people_data[0]['name'])
|
||||
|
||||
def deactivate(self):
|
||||
"""Deactivate the panel"""
|
||||
if self.is_active:
|
||||
self._cleanup()
|
||||
self.is_active = False
|
||||
|
||||
def update_layout(self):
|
||||
"""Update panel layout for responsiveness"""
|
||||
if hasattr(self, 'components') and 'faces_canvas' in self.components:
|
||||
# Update faces canvas scroll region
|
||||
canvas = self.components['faces_canvas']
|
||||
canvas.update_idletasks()
|
||||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PunimTag CLI - Minimal Photo Face Tagger (Refactored)
|
||||
Simple command-line tool for face recognition and photo tagging
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import threading
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
# Import our new modules
|
||||
from src.core.config import (
|
||||
DEFAULT_DB_PATH, DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE,
|
||||
DEFAULT_BATCH_SIZE, DEFAULT_PROCESSING_LIMIT
|
||||
)
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.photo_management import PhotoManager
|
||||
from src.core.tag_management import TagManager
|
||||
from src.core.search_stats import SearchStats
|
||||
from src.gui.gui_core import GUICore
|
||||
from src.gui.dashboard_gui import DashboardGUI
|
||||
|
||||
|
||||
class PhotoTagger:
|
||||
"""Main PhotoTagger class - orchestrates all functionality"""
|
||||
|
||||
def __init__(self, db_path: str = DEFAULT_DB_PATH, verbose: int = 0, debug: bool = False):
|
||||
"""Initialize the photo tagger with database and all managers"""
|
||||
self.db_path = db_path
|
||||
self.verbose = verbose
|
||||
self.debug = debug
|
||||
|
||||
# Initialize all managers
|
||||
self.db = DatabaseManager(db_path, verbose)
|
||||
self.face_processor = FaceProcessor(self.db, verbose)
|
||||
self.photo_manager = PhotoManager(self.db, verbose)
|
||||
self.tag_manager = TagManager(self.db, verbose)
|
||||
self.search_stats = SearchStats(self.db, verbose)
|
||||
self.gui_core = GUICore()
|
||||
self.identify_gui = IdentifyGUI(self.db, self.face_processor, verbose)
|
||||
self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose)
|
||||
self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose)
|
||||
self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose)
|
||||
self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, self.tag_manager, verbose)
|
||||
self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify, search_stats=self.search_stats, tag_manager=self.tag_manager)
|
||||
|
||||
# Legacy compatibility - expose some methods directly
|
||||
self._db_connection = None
|
||||
self._db_lock = threading.Lock()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources and close connections"""
|
||||
self.face_processor.cleanup_face_crops()
|
||||
self.db.close_db_connection()
|
||||
|
||||
# Database methods (delegated)
|
||||
def get_db_connection(self):
|
||||
"""Get database connection (legacy compatibility)"""
|
||||
return self.db.get_db_connection()
|
||||
|
||||
def close_db_connection(self):
|
||||
"""Close database connection (legacy compatibility)"""
|
||||
self.db.close_db_connection()
|
||||
|
||||
def init_database(self):
|
||||
"""Initialize database (legacy compatibility)"""
|
||||
self.db.init_database()
|
||||
|
||||
# Photo management methods (delegated)
|
||||
def scan_folder(self, folder_path: str, recursive: bool = True) -> int:
|
||||
"""Scan folder for photos and add to database"""
|
||||
return self.photo_manager.scan_folder(folder_path, recursive)
|
||||
|
||||
def _extract_photo_date(self, photo_path: str) -> Optional[str]:
|
||||
"""Extract date taken from photo EXIF data (legacy compatibility)"""
|
||||
return self.photo_manager.extract_photo_date(photo_path)
|
||||
|
||||
# Face processing methods (delegated)
|
||||
def process_faces(self, limit: int = DEFAULT_PROCESSING_LIMIT, model: str = DEFAULT_FACE_DETECTION_MODEL, progress_callback=None, stop_event=None) -> int:
|
||||
"""Process unprocessed photos for faces with optional progress and cancellation"""
|
||||
return self.face_processor.process_faces(limit, model, progress_callback, stop_event)
|
||||
|
||||
def _extract_face_crop(self, photo_path: str, location: tuple, face_id: int) -> str:
|
||||
"""Extract and save individual face crop for identification (legacy compatibility)"""
|
||||
return self.face_processor._extract_face_crop(photo_path, location, face_id)
|
||||
|
||||
def _create_comparison_image(self, unid_crop_path: str, match_crop_path: str, person_name: str, confidence: float) -> str:
|
||||
"""Create a side-by-side comparison image (legacy compatibility)"""
|
||||
return self.face_processor._create_comparison_image(unid_crop_path, match_crop_path, person_name, confidence)
|
||||
|
||||
def _calculate_face_quality_score(self, image, face_location: tuple) -> float:
|
||||
"""Calculate face quality score (legacy compatibility)"""
|
||||
return self.face_processor._calculate_face_quality_score(image, face_location)
|
||||
|
||||
def _add_person_encoding(self, person_id: int, face_id: int, encoding, quality_score: float):
|
||||
"""Add a face encoding to a person's encoding collection (legacy compatibility)"""
|
||||
self.face_processor.add_person_encoding(person_id, face_id, encoding, quality_score)
|
||||
|
||||
def _get_person_encodings(self, person_id: int, min_quality: float = 0.3):
|
||||
"""Get all high-quality encodings for a person (legacy compatibility)"""
|
||||
return self.face_processor.get_person_encodings(person_id, min_quality)
|
||||
|
||||
def _update_person_encodings(self, person_id: int):
|
||||
"""Update person encodings when a face is identified (legacy compatibility)"""
|
||||
self.face_processor.update_person_encodings(person_id)
|
||||
|
||||
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
|
||||
"""Calculate adaptive tolerance (legacy compatibility)"""
|
||||
return self.face_processor._calculate_adaptive_tolerance(base_tolerance, face_quality, match_confidence)
|
||||
|
||||
def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None):
|
||||
"""Get similar faces with filtering (legacy compatibility)"""
|
||||
return self.face_processor._get_filtered_similar_faces(face_id, tolerance, include_same_photo, face_status)
|
||||
|
||||
def _filter_unique_faces(self, faces: List[Dict]):
|
||||
"""Filter faces to show only unique ones (legacy compatibility)"""
|
||||
return self.face_processor._filter_unique_faces(faces)
|
||||
|
||||
def _filter_unique_faces_from_list(self, faces_list: List[tuple]):
|
||||
"""Filter face list to show only unique ones (legacy compatibility)"""
|
||||
return self.face_processor._filter_unique_faces_from_list(faces_list)
|
||||
|
||||
def find_similar_faces(self, face_id: int = None, tolerance: float = DEFAULT_FACE_TOLERANCE, include_same_photo: bool = False):
|
||||
"""Find similar faces across all photos"""
|
||||
return self.face_processor.find_similar_faces(face_id, tolerance, include_same_photo)
|
||||
|
||||
def auto_identify_matches(self, tolerance: float = DEFAULT_FACE_TOLERANCE, confirm: bool = True, show_faces: bool = False, include_same_photo: bool = False) -> int:
|
||||
"""Automatically identify faces that match already identified faces using GUI"""
|
||||
return self.auto_match_gui.auto_identify_matches(tolerance, confirm, show_faces, include_same_photo)
|
||||
|
||||
# Tag management methods (delegated)
|
||||
def add_tags(self, photo_pattern: str = None, batch_size: int = DEFAULT_BATCH_SIZE) -> int:
|
||||
"""Add custom tags to photos"""
|
||||
return self.tag_manager.add_tags_to_photos(photo_pattern, batch_size)
|
||||
|
||||
def _deduplicate_tags(self, tag_list):
|
||||
"""Remove duplicate tags from a list (legacy compatibility)"""
|
||||
return self.tag_manager.deduplicate_tags(tag_list)
|
||||
|
||||
def _parse_tags_string(self, tags_string):
|
||||
"""Parse a comma-separated tags string (legacy compatibility)"""
|
||||
return self.tag_manager.parse_tags_string(tags_string)
|
||||
|
||||
def _get_tag_id_by_name(self, tag_name, tag_name_to_id_map):
|
||||
"""Get tag ID by name (legacy compatibility)"""
|
||||
return self.db.get_tag_id_by_name(tag_name, tag_name_to_id_map)
|
||||
|
||||
def _get_tag_name_by_id(self, tag_id, tag_id_to_name_map):
|
||||
"""Get tag name by ID (legacy compatibility)"""
|
||||
return self.db.get_tag_name_by_id(tag_id, tag_id_to_name_map)
|
||||
|
||||
def _load_tag_mappings(self):
|
||||
"""Load tag name to ID and ID to name mappings (legacy compatibility)"""
|
||||
return self.db.load_tag_mappings()
|
||||
|
||||
def _get_existing_tag_ids_for_photo(self, photo_id):
|
||||
"""Get list of tag IDs for a photo (legacy compatibility)"""
|
||||
return self.db.get_existing_tag_ids_for_photo(photo_id)
|
||||
|
||||
def _show_people_list(self, cursor=None):
|
||||
"""Show list of people in database (legacy compatibility)"""
|
||||
return self.db.show_people_list(cursor)
|
||||
|
||||
# Search and statistics methods (delegated)
|
||||
def search_faces(self, person_name: str):
|
||||
"""Search for photos containing a specific person"""
|
||||
return self.search_stats.search_faces(person_name)
|
||||
|
||||
def stats(self):
|
||||
"""Show database statistics"""
|
||||
return self.search_stats.print_statistics()
|
||||
|
||||
# GUI methods (legacy compatibility - these would need to be implemented)
|
||||
def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, tolerance: float = DEFAULT_FACE_TOLERANCE,
|
||||
date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int:
|
||||
"""Interactive face identification with GUI (show_faces is always True)"""
|
||||
return self.identify_gui.identify_faces(batch_size, True, tolerance,
|
||||
date_from, date_to, date_processed_from, date_processed_to)
|
||||
|
||||
def tag_management(self) -> int:
|
||||
"""Tag management GUI"""
|
||||
return self.tag_manager_gui.tag_management()
|
||||
|
||||
def modifyidentified(self) -> int:
|
||||
return self.modify_identified_gui.modifyidentified()
|
||||
|
||||
def searchgui(self) -> int:
|
||||
"""Open the Search GUI."""
|
||||
return self.search_gui.search_gui()
|
||||
|
||||
def dashboard(self) -> int:
|
||||
"""Open the Dashboard GUI (placeholders only)."""
|
||||
return self.dashboard_gui.open()
|
||||
|
||||
# Dashboard callbacks
|
||||
def _dashboard_scan(self, folder_path: str, recursive: bool) -> int:
|
||||
"""Callback to scan a folder from the dashboard."""
|
||||
return self.scan_folder(folder_path, recursive)
|
||||
|
||||
def _dashboard_process(self, limit_value: Optional[int], progress_callback=None, stop_event=None) -> int:
|
||||
"""Callback to process faces from the dashboard with optional limit, progress, cancel."""
|
||||
if limit_value is None:
|
||||
return self.process_faces(progress_callback=progress_callback, stop_event=stop_event)
|
||||
return self.process_faces(limit=limit_value, progress_callback=progress_callback, stop_event=stop_event)
|
||||
|
||||
def _dashboard_identify(self, batch_value: Optional[int]) -> int:
|
||||
"""Callback to identify faces from the dashboard with optional batch (show_faces is always True)."""
|
||||
if batch_value is None:
|
||||
return self.identify_faces()
|
||||
return self.identify_faces(batch_size=batch_value)
|
||||
|
||||
def _setup_window_size_saving(self, root, config_file="gui_config.json"):
|
||||
"""Set up window size saving functionality (legacy compatibility)"""
|
||||
return self.gui_core.setup_window_size_saving(root, config_file)
|
||||
|
||||
def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None):
|
||||
"""Display similar faces in panel (legacy compatibility)"""
|
||||
print("⚠️ Similar faces panel not yet implemented in refactored version")
|
||||
return None
|
||||
|
||||
def _create_photo_icon(self, canvas, photo_path, icon_size=20, icon_x=None, icon_y=None, callback=None):
|
||||
"""Create a small photo icon on a canvas (legacy compatibility)"""
|
||||
return self.gui_core.create_photo_icon(canvas, photo_path, icon_size, icon_x, icon_y, callback)
|
||||
|
||||
def _get_confidence_description(self, confidence_pct: float) -> str:
|
||||
"""Get human-readable confidence description (legacy compatibility)"""
|
||||
return self.face_processor._get_confidence_description(confidence_pct)
|
||||
|
||||
# Cache management (legacy compatibility)
|
||||
def _clear_caches(self):
|
||||
"""Clear all caches to free memory (legacy compatibility)"""
|
||||
self.face_processor._clear_caches()
|
||||
|
||||
def _cleanup_face_crops(self, current_face_crop_path=None):
|
||||
"""Clean up face crop files and caches (legacy compatibility)"""
|
||||
self.face_processor.cleanup_face_crops(current_face_crop_path)
|
||||
|
||||
@property
|
||||
def _face_encoding_cache(self):
|
||||
"""Face encoding cache (legacy compatibility)"""
|
||||
return self.face_processor._face_encoding_cache
|
||||
|
||||
@property
|
||||
def _image_cache(self):
|
||||
"""Image cache (legacy compatibility)"""
|
||||
return self.face_processor._image_cache
|
||||
|
||||
def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None) -> List[Dict]:
|
||||
"""Get similar faces with consistent filtering and sorting logic used by both auto-match and identify"""
|
||||
# Find similar faces using the core function
|
||||
similar_faces_data = self.find_similar_faces(face_id, tolerance=tolerance, include_same_photo=include_same_photo)
|
||||
|
||||
# Filter to only show unidentified faces with confidence filtering
|
||||
filtered_faces = []
|
||||
for face in similar_faces_data:
|
||||
# For auto-match: only filter by database state (keep existing behavior)
|
||||
# For identify: also filter by current session state
|
||||
is_identified_in_db = face.get('person_id') is not None
|
||||
is_identified_in_session = face_status and face.get('face_id') in face_status and face_status[face.get('face_id')] == 'identified'
|
||||
|
||||
# If face_status is provided (identify mode), use both filters
|
||||
# If face_status is None (auto-match mode), only use database filter
|
||||
if face_status is not None:
|
||||
# Identify mode: filter out both database and session identified faces
|
||||
if not is_identified_in_db and not is_identified_in_session:
|
||||
# Calculate confidence percentage
|
||||
confidence_pct = (1 - face['distance']) * 100
|
||||
|
||||
# Only include matches with reasonable confidence (at least 40%)
|
||||
if confidence_pct >= 40:
|
||||
filtered_faces.append(face)
|
||||
else:
|
||||
# Auto-match mode: only filter by database state (keep existing behavior)
|
||||
if not is_identified_in_db:
|
||||
# Calculate confidence percentage
|
||||
confidence_pct = (1 - face['distance']) * 100
|
||||
|
||||
# Only include matches with reasonable confidence (at least 40%)
|
||||
if confidence_pct >= 40:
|
||||
filtered_faces.append(face)
|
||||
|
||||
# Sort by confidence (distance) - highest confidence first
|
||||
filtered_faces.sort(key=lambda x: x['distance'])
|
||||
|
||||
return filtered_faces
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI interface"""
|
||||
# Suppress pkg_resources deprecation warning from face_recognition library
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", message="pkg_resources is deprecated", category=UserWarning)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PunimTag CLI - Simple photo face tagger (Refactored)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
photo_tagger_refactored.py scan /path/to/photos # Scan folder for photos
|
||||
photo_tagger_refactored.py process --limit 20 # Process 20 photos for faces
|
||||
photo_tagger_refactored.py identify --batch 10 # Identify 10 faces interactively
|
||||
photo_tagger_refactored.py auto-match # Auto-identify matching faces
|
||||
photo_tagger_refactored.py modifyidentified # Show and Modify identified faces
|
||||
photo_tagger_refactored.py match 15 # Find faces similar to face ID 15
|
||||
photo_tagger_refactored.py tag --pattern "vacation" # Tag photos matching pattern
|
||||
photo_tagger_refactored.py search "John" # Find photos with John
|
||||
photo_tagger_refactored.py tag-manager # Open tag management GUI
|
||||
photo_tagger_refactored.py stats # Show statistics
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('command',
|
||||
choices=['scan', 'process', 'identify', 'tag', 'search', 'search-gui', 'dashboard', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'],
|
||||
help='Command to execute')
|
||||
|
||||
parser.add_argument('target', nargs='?',
|
||||
help='Target folder (scan), person name (search), or pattern (tag)')
|
||||
|
||||
parser.add_argument('--db', default=DEFAULT_DB_PATH,
|
||||
help=f'Database file path (default: {DEFAULT_DB_PATH})')
|
||||
|
||||
parser.add_argument('--limit', type=int, default=DEFAULT_PROCESSING_LIMIT,
|
||||
help=f'Batch size limit for processing (default: {DEFAULT_PROCESSING_LIMIT})')
|
||||
|
||||
parser.add_argument('--batch', type=int, default=DEFAULT_BATCH_SIZE,
|
||||
help=f'Batch size for identification (default: {DEFAULT_BATCH_SIZE})')
|
||||
|
||||
parser.add_argument('--pattern',
|
||||
help='Pattern for filtering photos when tagging')
|
||||
|
||||
parser.add_argument('--model', choices=['hog', 'cnn'], default=DEFAULT_FACE_DETECTION_MODEL,
|
||||
help=f'Face detection model: hog (faster) or cnn (more accurate) (default: {DEFAULT_FACE_DETECTION_MODEL})')
|
||||
|
||||
parser.add_argument('--recursive', action='store_true',
|
||||
help='Scan folders recursively')
|
||||
|
||||
|
||||
parser.add_argument('--tolerance', type=float, default=DEFAULT_FACE_TOLERANCE,
|
||||
help=f'Face matching tolerance (0.0-1.0, lower = stricter, default: {DEFAULT_FACE_TOLERANCE})')
|
||||
|
||||
parser.add_argument('--auto', action='store_true',
|
||||
help='Auto-identify high-confidence matches without confirmation')
|
||||
|
||||
parser.add_argument('--include-twins', action='store_true',
|
||||
help='Include same-photo matching (for twins or multiple instances)')
|
||||
|
||||
parser.add_argument('--date-from',
|
||||
help='Filter by photo taken date (from) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('--date-to',
|
||||
help='Filter by photo taken date (to) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('--date-processed-from',
|
||||
help='Filter by photo processed date (from) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('--date-processed-to',
|
||||
help='Filter by photo processed date (to) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('-v', '--verbose', action='count', default=0,
|
||||
help='Increase verbosity (-v, -vv, -vvv for more detail)')
|
||||
|
||||
parser.add_argument('--debug', action='store_true',
|
||||
help='Enable line-by-line debugging with pdb')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize tagger
|
||||
tagger = PhotoTagger(args.db, args.verbose, args.debug)
|
||||
|
||||
try:
|
||||
if args.command == 'scan':
|
||||
if not args.target:
|
||||
print("❌ Please specify a folder to scan")
|
||||
return 1
|
||||
|
||||
# Normalize path to absolute path
|
||||
from path_utils import normalize_path
|
||||
try:
|
||||
normalized_path = normalize_path(args.target)
|
||||
print(f"📁 Scanning folder: {normalized_path}")
|
||||
tagger.scan_folder(normalized_path, args.recursive)
|
||||
except ValueError as e:
|
||||
print(f"❌ Invalid path: {e}")
|
||||
return 1
|
||||
|
||||
elif args.command == 'process':
|
||||
tagger.process_faces(args.limit, args.model)
|
||||
|
||||
elif args.command == 'identify':
|
||||
tagger.identify_faces(args.batch, args.tolerance,
|
||||
args.date_from, args.date_to,
|
||||
args.date_processed_from, args.date_processed_to)
|
||||
|
||||
elif args.command == 'tag':
|
||||
tagger.add_tags(args.pattern or args.target, args.batch)
|
||||
|
||||
elif args.command == 'search':
|
||||
if not args.target:
|
||||
print("❌ Please specify a person name to search for")
|
||||
return 1
|
||||
tagger.search_faces(args.target)
|
||||
|
||||
elif args.command == 'search-gui':
|
||||
tagger.searchgui()
|
||||
|
||||
elif args.command == 'dashboard':
|
||||
tagger.dashboard()
|
||||
|
||||
elif args.command == 'stats':
|
||||
tagger.stats()
|
||||
|
||||
elif args.command == 'match':
|
||||
if args.target and args.target.isdigit():
|
||||
face_id = int(args.target)
|
||||
matches = tagger.find_similar_faces(face_id, args.tolerance)
|
||||
if matches:
|
||||
print(f"\n🎯 Found {len(matches)} similar faces:")
|
||||
for match in matches:
|
||||
person_name = "Unknown" if match.get('person_id') is None else f"Person ID {match.get('person_id')}"
|
||||
print(f" 📸 {match.get('filename', 'Unknown')} - {person_name} (confidence: {(1-match.get('distance', 1)):.1%})")
|
||||
else:
|
||||
print("🔍 No similar faces found")
|
||||
else:
|
||||
print("❌ Please specify a face ID number to find matches for")
|
||||
|
||||
elif args.command == 'auto-match':
|
||||
show_faces = getattr(args, 'show_faces', False)
|
||||
include_twins = getattr(args, 'include_twins', False)
|
||||
tagger.auto_identify_matches(args.tolerance, not args.auto, show_faces, include_twins)
|
||||
|
||||
elif args.command == 'modifyidentified':
|
||||
tagger.modifyidentified()
|
||||
|
||||
elif args.command == 'tag-manager':
|
||||
tagger.tag_management()
|
||||
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Interrupted by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
if args.debug:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
# Always cleanup resources
|
||||
tagger.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PunimTag CLI Setup Script
|
||||
Simple setup for the minimal photo tagger
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_python_version():
|
||||
"""Check if Python version is compatible"""
|
||||
if sys.version_info < (3, 7):
|
||||
print("❌ Python 3.7+ is required")
|
||||
return False
|
||||
print(f"✅ Python {sys.version_info.major}.{sys.version_info.minor} detected")
|
||||
return True
|
||||
|
||||
|
||||
def install_system_dependencies():
|
||||
"""Install system-level packages required for compilation and runtime"""
|
||||
print("🔧 Installing system dependencies...")
|
||||
print(" (Build tools, libraries, and image viewer)")
|
||||
|
||||
# Check if we're on a Debian/Ubuntu system
|
||||
if Path("/usr/bin/apt").exists():
|
||||
try:
|
||||
# Install required system packages for building Python packages and running tools
|
||||
packages = [
|
||||
"cmake", "build-essential", "libopenblas-dev", "liblapack-dev",
|
||||
"libx11-dev", "libgtk-3-dev", "libboost-python-dev", "feh"
|
||||
]
|
||||
|
||||
print(f"📦 Installing packages: {', '.join(packages)}")
|
||||
subprocess.run([
|
||||
"sudo", "apt", "install", "-y"
|
||||
] + packages, check=True)
|
||||
print("✅ System dependencies installed successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ Failed to install system dependencies: {e}")
|
||||
print(" You may need to run: sudo apt update")
|
||||
return False
|
||||
else:
|
||||
print("⚠️ System dependency installation not supported on this platform")
|
||||
print(" Please install manually:")
|
||||
print(" - cmake, build-essential")
|
||||
print(" - libopenblas-dev, liblapack-dev")
|
||||
print(" - libx11-dev, libgtk-3-dev, libboost-python-dev")
|
||||
print(" - feh (image viewer)")
|
||||
return True
|
||||
|
||||
|
||||
def install_requirements():
|
||||
"""Install Python requirements"""
|
||||
requirements_file = Path("requirements.txt")
|
||||
|
||||
if not requirements_file.exists():
|
||||
print("❌ requirements.txt not found!")
|
||||
return False
|
||||
|
||||
print("📦 Installing Python dependencies...")
|
||||
try:
|
||||
subprocess.run([
|
||||
sys.executable, '-m', 'pip', 'install', '-r', 'requirements.txt'
|
||||
], check=True)
|
||||
print("✅ Dependencies installed successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ Failed to install dependencies: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_directories():
|
||||
"""Create necessary directories"""
|
||||
directories = ['data', 'logs']
|
||||
|
||||
for directory in directories:
|
||||
Path(directory).mkdir(exist_ok=True)
|
||||
print(f"✅ Created directory: {directory}")
|
||||
|
||||
|
||||
def test_installation():
|
||||
"""Test if face recognition works"""
|
||||
print("🧪 Testing face recognition installation...")
|
||||
try:
|
||||
import face_recognition
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
print("✅ All required modules imported successfully")
|
||||
return True
|
||||
except ImportError as e:
|
||||
print(f"❌ Import error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main setup function"""
|
||||
print("🚀 PunimTag CLI Setup")
|
||||
print("=" * 40)
|
||||
|
||||
# Check Python version
|
||||
if not check_python_version():
|
||||
return 1
|
||||
|
||||
# Check if we're in a virtual environment (recommended)
|
||||
if sys.prefix == sys.base_prefix:
|
||||
print("⚠️ Not in a virtual environment!")
|
||||
print(" Recommended: python -m venv venv && source venv/bin/activate")
|
||||
response = input(" Continue anyway? (y/N): ").strip().lower()
|
||||
if response != 'y':
|
||||
print("Setup cancelled. Create a virtual environment first.")
|
||||
return 1
|
||||
else:
|
||||
print("✅ Virtual environment detected")
|
||||
|
||||
print()
|
||||
|
||||
# Install system dependencies
|
||||
if not install_system_dependencies():
|
||||
return 1
|
||||
print()
|
||||
|
||||
# Create directories
|
||||
print("📁 Creating directories...")
|
||||
create_directories()
|
||||
print()
|
||||
|
||||
# Install requirements
|
||||
if not install_requirements():
|
||||
return 1
|
||||
print()
|
||||
|
||||
# Test installation
|
||||
if not test_installation():
|
||||
print("⚠️ Installation test failed. You may need to install additional dependencies.")
|
||||
print(" For Ubuntu/Debian: sudo apt-get install build-essential cmake")
|
||||
print(" For macOS: brew install cmake")
|
||||
return 1
|
||||
print()
|
||||
|
||||
print("✅ Setup complete!")
|
||||
print()
|
||||
print("🎯 Quick Start:")
|
||||
print(" 1. Add photos: python3 photo_tagger.py scan /path/to/photos")
|
||||
print(" 2. Process faces: python3 photo_tagger.py process")
|
||||
print(" 3. Identify faces: python3 photo_tagger.py identify")
|
||||
print(" 4. View stats: python3 photo_tagger.py stats")
|
||||
print()
|
||||
print("📖 For help: python3 photo_tagger.py --help")
|
||||
print()
|
||||
print("⚠️ IMPORTANT: Always activate virtual environment first!")
|
||||
print(" source venv/bin/activate")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Utility functions for PunimTag
|
||||
"""
|
||||
|
||||
from .path_utils import normalize_path, validate_path_exists
|
||||
|
||||
__all__ = [
|
||||
'normalize_path',
|
||||
'validate_path_exists',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Path utility functions for PunimTag
|
||||
Ensures all paths are stored as absolute paths for consistency
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
|
||||
def normalize_path(path: Union[str, Path]) -> str:
|
||||
"""
|
||||
Convert any path to an absolute path.
|
||||
|
||||
Args:
|
||||
path: Path to normalize (can be relative or absolute)
|
||||
|
||||
Returns:
|
||||
Absolute path as string
|
||||
|
||||
Examples:
|
||||
normalize_path("demo_photos") -> "/home/user/punimtag/demo_photos"
|
||||
normalize_path("./photos") -> "/home/user/punimtag/photos"
|
||||
normalize_path("/absolute/path") -> "/absolute/path"
|
||||
"""
|
||||
if not path:
|
||||
raise ValueError("Path cannot be empty or None")
|
||||
|
||||
# Convert to string if Path object
|
||||
path_str = str(path)
|
||||
|
||||
# Use pathlib for robust path resolution
|
||||
normalized = Path(path_str).resolve()
|
||||
|
||||
return str(normalized)
|
||||
|
||||
|
||||
def is_absolute_path(path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Check if a path is absolute.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if path is absolute, False if relative
|
||||
"""
|
||||
return Path(path).is_absolute()
|
||||
|
||||
|
||||
def is_relative_path(path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Check if a path is relative.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if path is relative, False if absolute
|
||||
"""
|
||||
return not Path(path).is_absolute()
|
||||
|
||||
|
||||
def validate_path_exists(path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Check if a path exists and is accessible.
|
||||
|
||||
Args:
|
||||
path: Path to validate
|
||||
|
||||
Returns:
|
||||
True if path exists and is accessible, False otherwise
|
||||
"""
|
||||
try:
|
||||
normalized = normalize_path(path)
|
||||
return os.path.exists(normalized) and os.access(normalized, os.R_OK)
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def get_path_info(path: Union[str, Path]) -> dict:
|
||||
"""
|
||||
Get detailed information about a path.
|
||||
|
||||
Args:
|
||||
path: Path to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary with path information
|
||||
"""
|
||||
try:
|
||||
normalized = normalize_path(path)
|
||||
path_obj = Path(normalized)
|
||||
|
||||
return {
|
||||
'original': str(path),
|
||||
'normalized': normalized,
|
||||
'is_absolute': path_obj.is_absolute(),
|
||||
'is_relative': not path_obj.is_absolute(),
|
||||
'exists': path_obj.exists(),
|
||||
'is_file': path_obj.is_file(),
|
||||
'is_dir': path_obj.is_dir(),
|
||||
'parent': str(path_obj.parent),
|
||||
'name': path_obj.name,
|
||||
'stem': path_obj.stem,
|
||||
'suffix': path_obj.suffix
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'original': str(path),
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def ensure_directory_exists(path: Union[str, Path]) -> str:
|
||||
"""
|
||||
Ensure a directory exists, creating it if necessary.
|
||||
|
||||
Args:
|
||||
path: Directory path to ensure exists
|
||||
|
||||
Returns:
|
||||
Absolute path to the directory
|
||||
"""
|
||||
normalized = normalize_path(path)
|
||||
Path(normalized).mkdir(parents=True, exist_ok=True)
|
||||
return normalized
|
||||
|
||||
|
||||
def get_relative_path_from_base(absolute_path: Union[str, Path], base_path: Union[str, Path]) -> str:
|
||||
"""
|
||||
Get relative path from a base directory.
|
||||
|
||||
Args:
|
||||
absolute_path: Absolute path to convert
|
||||
base_path: Base directory to make relative to
|
||||
|
||||
Returns:
|
||||
Relative path from base directory
|
||||
"""
|
||||
abs_path = normalize_path(absolute_path)
|
||||
base = normalize_path(base_path)
|
||||
|
||||
try:
|
||||
return str(Path(abs_path).relative_to(base))
|
||||
except ValueError:
|
||||
# If paths don't share a common base, return the absolute path
|
||||
return abs_path
|
||||
|
||||
|
||||
# Test the utility functions
|
||||
if __name__ == "__main__":
|
||||
print("=== Path Utility Functions Test ===")
|
||||
|
||||
test_paths = [
|
||||
"demo_photos",
|
||||
"./demo_photos",
|
||||
"../demo_photos",
|
||||
"/home/ladmin/Code/punimtag/demo_photos",
|
||||
"C:/Users/Test/Photos",
|
||||
"/tmp/test"
|
||||
]
|
||||
|
||||
for path in test_paths:
|
||||
print(f"\nTesting: {path}")
|
||||
try:
|
||||
normalized = normalize_path(path)
|
||||
info = get_path_info(path)
|
||||
print(f" Normalized: {normalized}")
|
||||
print(f" Exists: {info['exists']}")
|
||||
print(f" Is directory: {info['is_dir']}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
Reference in New Issue
Block a user