diff --git a/config.py b/config.py new file mode 100644 index 0000000..50a9d30 --- /dev/null +++ b/config.py @@ -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" diff --git a/database.py b/database.py new file mode 100644 index 0000000..06492cd --- /dev/null +++ b/database.py @@ -0,0 +1,482 @@ +#!/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 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) + 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 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS phototaglinkage ( + linkage_id INTEGER PRIMARY KEY AUTOINCREMENT, + photo_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + 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)') + + # Migration: Add date_taken column to existing photos table if it doesn't exist + try: + cursor.execute('ALTER TABLE photos ADD COLUMN date_taken DATE') + if self.verbose >= 1: + print("āœ… Added date_taken column to photos table") + except Exception: + # Column already exists, ignore + pass + + # Migration: Add date_added column to existing photos table if it doesn't exist + try: + cursor.execute('ALTER TABLE photos ADD COLUMN date_added DATETIME DEFAULT CURRENT_TIMESTAMP') + if self.verbose >= 1: + print("āœ… Added date_added column to photos table") + except Exception: + # Column already exists, ignore + pass + + 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""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, tag_name FROM tags ORDER BY 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 + tag_name_to_id[tag_name] = 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""" + 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 (?, ?, ?, ?, ?) + ''', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + + # Get the person ID + cursor.execute(''' + SELECT id FROM people + WHERE first_name = ? AND last_name = ? AND middle_name = ? + AND maiden_name = ? AND date_of_birth = ? + ''', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + 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""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) + + # Get the tag ID + cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (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_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 diff --git a/face_processing.py b/face_processing.py new file mode 100644 index 0000000..eab26ac --- /dev/null +++ b/face_processing.py @@ -0,0 +1,515 @@ +#!/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 config import DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE, MIN_FACE_QUALITY +from 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) -> int: + """Process unprocessed photos for faces""" + 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 + + for photo_id, photo_path, filename, date_taken in unprocessed: + 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)): + # 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 - Unlikely)" + + 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_photos_by_pattern() # This needs to be implemented properly + matches.append({ + 'face_id': other_id, + 'person_id': other_person_id, + 'distance': distance, + 'quality_score': other_quality, + 'adaptive_tolerance': adaptive_tolerance + }) + + 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) diff --git a/gui_config.json b/gui_config.json new file mode 100644 index 0000000..1db49a5 --- /dev/null +++ b/gui_config.json @@ -0,0 +1 @@ +{"window_size": "1069x882"} \ No newline at end of file diff --git a/gui_core.py b/gui_core.py new file mode 100644 index 0000000..bcddc61 --- /dev/null +++ b/gui_core.py @@ -0,0 +1,341 @@ +#!/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 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('', 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, + callback: callable = None) -> Optional[int]: + """Create a small photo icon on a canvas""" + try: + if not os.path.exists(photo_path): + return None + + # Load and resize image + with Image.open(photo_path) as img: + img.thumbnail((icon_size, icon_size), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img) + + # Calculate position if not provided + if icon_x is None: + icon_x = 10 + if icon_y is None: + icon_y = 10 + + # Create image on canvas + image_id = canvas.create_image(icon_x, icon_y, anchor='nw', image=photo) + + # Keep reference to prevent garbage collection + canvas.image_refs = getattr(canvas, 'image_refs', []) + canvas.image_refs.append(photo) + + # Add click handler if callback provided + if callback: + def open_source_photo(event): + callback(photo_path) + + canvas.tag_bind(image_id, '', open_source_photo) + canvas.tag_bind(image_id, '', lambda e: canvas.config(cursor='hand2')) + canvas.tag_bind(image_id, '', lambda e: canvas.config(cursor='')) + + # Add tooltip + def show_tooltip(event): + tooltip = f"šŸ“ø {os.path.basename(photo_path)}" + # Simple tooltip implementation + pass + + def hide_tooltip(event): + pass + + canvas.tag_bind(image_id, '', show_tooltip) + canvas.tag_bind(image_id, '', hide_tooltip) + + return image_id + + except Exception as e: + return None + + 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 - Unlikely)" + + 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('', show_tooltip) + widget.bind('', 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_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 diff --git a/photo_management.py b/photo_management.py new file mode 100644 index 0000000..c1c06d9 --- /dev/null +++ b/photo_management.py @@ -0,0 +1,217 @@ +#!/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 config import SUPPORTED_IMAGE_FORMATS +from database import DatabaseManager + + +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""" + if not os.path.exists(folder_path): + print(f"āŒ Folder not found: {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: + # Extract date taken from EXIF data + date_taken = self.extract_photo_date(photo_path) + + # Add photo to database + 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 diff --git a/photo_tagger.py b/photo_tagger.py index 340c2f5..63ec2b0 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -1,6956 +1,245 @@ #!/usr/bin/env python3 """ -PunimTag CLI - Minimal Photo Face Tagger +PunimTag CLI - Minimal Photo Face Tagger (Refactored) Simple command-line tool for face recognition and photo tagging """ import os -import sqlite3 -import argparse -import face_recognition -from pathlib import Path -from PIL import Image, ImageDraw, ImageFont -from PIL.ExifTags import TAGS -import pickle -import numpy as np -from typing import List, Dict, Tuple, Optional import sys -import tempfile -import subprocess +import argparse import threading -import time -from datetime import datetime -from functools import lru_cache -from contextlib import contextmanager +from typing import List, Dict, Tuple, Optional + +# Import our new modules +from config import ( + DEFAULT_DB_PATH, DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE, + DEFAULT_BATCH_SIZE, DEFAULT_PROCESSING_LIMIT +) +from database import DatabaseManager +from face_processing import FaceProcessor +from photo_management import PhotoManager +from tag_management import TagManager +from search_stats import SearchStats +from gui_core import GUICore class PhotoTagger: - def __init__(self, db_path: str = "data/photos.db", verbose: int = 0, debug: bool = False): - """Initialize the photo tagger with database""" + """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 - self._face_encoding_cache = {} - self._image_cache = {} + + # 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() + + # Legacy compatibility - expose some methods directly 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) - 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 - - @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(self): """Clean up resources and close connections""" - self._clear_caches() - self.close_db_connection() + self.face_processor.cleanup_face_crops() + self.db.close_db_connection() - 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() + # Database methods (delegated) + def get_db_connection(self): + """Get database connection (legacy compatibility)""" + return self.db.get_db_connection() - def _deduplicate_tags(self, tag_list): - """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): - """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 _get_tag_id_by_name(self, tag_name, tag_name_to_id_map): - """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, tag_id_to_name_map): - """Get tag name by ID""" - return tag_id_to_name_map.get(tag_id, f"Unknown Tag {tag_id}") - - def _load_tag_mappings(self): - """Load tag name to ID and ID to name mappings from database""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY 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 - tag_name_to_id[tag_name] = tag_id - return tag_id_to_name, tag_name_to_id - - def _get_existing_tag_ids_for_photo(self, photo_id): - """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 _setup_window_size_saving(self, root, config_file="gui_config.json"): - """Set up window size saving functionality""" - import json - import tkinter as tk - - # Load saved window size - default_size = "600x500" - saved_size = default_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_size) - except: - saved_size = default_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 tk.TclError: - # 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('', save_window_size) - return saved_size + def close_db_connection(self): + """Close database connection (legacy compatibility)""" + self.db.close_db_connection() 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 - cursor.execute(''' - CREATE TABLE IF NOT EXISTS phototaglinkage ( - linkage_id INTEGER PRIMARY KEY AUTOINCREMENT, - photo_id INTEGER NOT NULL, - tag_id INTEGER NOT NULL, - 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)') - - # Migration: Add date_taken column to existing photos table if it doesn't exist - try: - cursor.execute('ALTER TABLE photos ADD COLUMN date_taken DATE') - if self.verbose >= 1: - print("āœ… Added date_taken column to photos table") - except Exception: - # Column already exists, ignore - pass - - # Migration: Add date_added column to existing photos table if it doesn't exist - try: - cursor.execute('ALTER TABLE photos ADD COLUMN date_added DATETIME DEFAULT CURRENT_TIMESTAMP') - if self.verbose >= 1: - print("āœ… Added date_added column to photos table") - except Exception: - # Column already exists, ignore - pass - - - if self.verbose >= 1: - print(f"āœ… Database initialized: {self.db_path}") - - 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 + """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""" - # BREAKPOINT: Set breakpoint here for debugging - - - if not os.path.exists(folder_path): - print(f"āŒ Folder not found: {folder_path}") - return 0 - - photo_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff'} - - found_photos = [] - - # BREAKPOINT: Set breakpoint here for debugging - - 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 photo_extensions: - 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 photo_extensions: - 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 - # BREAKPOINT: Set breakpoint here for debugging - - with self.get_db_connection() as conn: - cursor = conn.cursor() - added_count = 0 - - for photo_path, filename in found_photos: - try: - # Extract date taken from EXIF data - date_taken = self._extract_photo_date(photo_path) - - cursor.execute( - 'INSERT OR IGNORE INTO photos (path, filename, date_taken) VALUES (?, ?, ?)', - (photo_path, filename, date_taken) - ) - if cursor.rowcount > 0: - 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}") - elif self.verbose >= 3: - print(f" šŸ“ø Already exists: {filename}") - except Exception as e: - print(f"āš ļø Error adding {filename}: {e}") - - - print(f"šŸ“ Found {len(found_photos)} photos, added {added_count} new photos") - return added_count + 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) - def process_faces(self, limit: int = 50, model: str = "hog") -> int: + # Face processing methods (delegated) + def process_faces(self, limit: int = DEFAULT_PROCESSING_LIMIT, model: str = DEFAULT_FACE_DETECTION_MODEL) -> int: """Process unprocessed photos for faces""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - cursor.execute( - 'SELECT id, path, filename FROM photos WHERE processed = 0 LIMIT ?', - (limit,) - ) - unprocessed = cursor.fetchall() - - if not unprocessed: - print("āœ… No unprocessed photos found") - return 0 - - print(f"šŸ” Processing {len(unprocessed)} photos for faces...") - processed_count = 0 - - for photo_id, photo_path, filename in unprocessed: - if not os.path.exists(photo_path): - print(f"āŒ File not found: {filename}") - cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (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)): - # Calculate face quality score - quality_score = self._calculate_face_quality_score(image, location) - - cursor.execute( - 'INSERT INTO faces (photo_id, encoding, location, quality_score) VALUES (?, ?, ?, ?)', - (photo_id, encoding.tobytes(), str(location), 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 - cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,)) - processed_count += 1 - - except Exception as e: - print(f"āŒ Error processing {filename}: {e}") - cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,)) - - if self.verbose == 0: - print() # New line after dots - print(f"āœ… Processed {processed_count} photos") - return processed_count + return self.face_processor.process_faces(limit, model) - def identify_faces(self, batch_size: int = 20, show_faces: bool = False, tolerance: float = 0.6, + 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""" + # This would need to be implemented in the face_processing module + # For now, return 0 + print("āš ļø Auto-identify matches not yet implemented in refactored version") + return 0 + + # 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, show_faces: bool = False, 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 optimized performance""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - query += ' LIMIT ?' - params.append(batch_size) - - cursor.execute(query, params) - - unidentified = cursor.fetchall() - - if not unidentified: - print("šŸŽ‰ All faces have been identified!") - return 0 - - print(f"\nšŸ‘¤ Found {len(unidentified)} unidentified faces") - print("Commands: [name] = identify, 's' = skip, 'q' = quit, 'list' = show people\n") - - # Pre-fetch all needed data to avoid repeated database queries - print("šŸ“Š Pre-fetching data for optimal performance...") - identify_data_cache = {} - - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Pre-fetch all photo paths for unidentified faces - photo_ids = [face[1] for face in unidentified] # face[1] is photo_id - if photo_ids: - placeholders = ','.join('?' * len(photo_ids)) - cursor.execute(f'SELECT id, path, filename FROM photos WHERE id IN ({placeholders})', photo_ids) - identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()} - - # Pre-fetch all people names for dropdown - cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people] - # Pre-fetch unique last names for autocomplete (no DB during typing) - cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') - _last_rows = cursor.fetchall() - identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) - - print(f"āœ… Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names") - - identified_count = 0 - - # Use integrated GUI with image and input - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import json - import os - - # Create the main window once - root = tk.Tk() - root.title("Face Identification") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - selected_person_id = None - force_exit = False - - # Track current face crop path for cleanup - current_face_crop_path = None - - # Hide window initially to prevent flash at corner - root.withdraw() - - def save_all_pending_identifications(): - """Save all pending identifications from face_person_names""" - nonlocal identified_count - saved_count = 0 - - for face_id, person_data in face_person_names.items(): - # Handle person data dict format - if isinstance(person_data, dict): - first_name = person_data.get('first_name', '').strip() - last_name = person_data.get('last_name', '').strip() - date_of_birth = person_data.get('date_of_birth', '').strip() - middle_name = person_data.get('middle_name', '').strip() - maiden_name = person_data.get('maiden_name', '').strip() - - # Only save if we have at least a first or last name - if first_name or last_name: - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Add person if doesn't exist - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - if display_name not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(display_name) - identify_data_cache['people_names'].sort() # Keep sorted - # Keep last names cache updated in-session - if last_name: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if last_name not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(last_name) - identify_data_cache['last_names'].sort() - - # Assign face to person - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, face_id) - ) - - # Update person encodings - self._update_person_encodings(person_id) - saved_count += 1 - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - print(f"āœ… Saved identification: {display_name}") - - except Exception as e: - display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) - print(f"āŒ Error saving identification for {display_name}: {e}") - else: - # Handle legacy string format - skip for now as it doesn't have complete data - pass - - if saved_count > 0: - identified_count += saved_count - print(f"šŸ’¾ Saved {saved_count} pending identifications") - - return saved_count - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal command, waiting_for_input, window_destroyed, current_face_crop_path, force_exit - - # First check for selected similar faces without person name - if not validate_navigation(): - return # Cancel close - - # Check if there are pending identifications (faces with complete data but not yet saved) - pending_identifications = {} - for k, v in face_person_names.items(): - if k not in face_status or face_status[k] != 'identified': - # Handle person data dict format - if isinstance(v, dict): - first_name = v.get('first_name', '').strip() - last_name = v.get('last_name', '').strip() - date_of_birth = v.get('date_of_birth', '').strip() - - # Check if we have complete data (both first and last name, plus date of birth) - if first_name and last_name and date_of_birth: - pending_identifications[k] = v - else: - # Handle legacy string format - not considered complete without date of birth - pass - - if pending_identifications: - # Ask user if they want to save pending identifications - result = messagebox.askyesnocancel( - "Save Pending Identifications?", - f"You have {len(pending_identifications)} pending identifications.\n\n" - "Do you want to save them before closing?\n\n" - "• Yes: Save all pending identifications and close\n" - "• No: Close without saving\n" - "• Cancel: Return to identification" - ) - - if result is True: # Yes - Save and close - save_all_pending_identifications() - command = 'q' - waiting_for_input = False - elif result is False: # No - Close without saving - command = 'q' - waiting_for_input = False - else: # Cancel - Don't close - return - - # Clean up face crops and caches - self._cleanup_face_crops(current_face_crop_path) - self.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Force process termination - force_exit = True - root.quit() - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self._setup_window_size_saving(root) - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) # Left panel - main_frame.columnconfigure(1, weight=1) # Right panel for similar faces - # Configure row weights to minimize spacing around Unique checkbox - main_frame.rowconfigure(2, weight=0) # Unique checkbox row - no expansion - main_frame.rowconfigure(3, weight=1) # Main panels row - expandable - - # Photo info - info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold")) - info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) - - # Calendar dialog function for date filter - def open_date_calendar(date_var, title): - """Open a visual calendar dialog to select date""" - from datetime import datetime, date, timedelta - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(root) - calendar_window.title(title) - calendar_window.resizable(False, False) - calendar_window.transient(root) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = date_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use current date - display_year = current_date.year - display_month = current_date.month - selected_date = None - else: - # Default to current date - display_year = current_date.year - display_month = current_date.month - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Create custom style for calendar buttons - style = ttk.Style() - style.configure("Calendar.TButton", padding=(2, 2)) - style.map("Calendar.TButton", - background=[("active", "#e1e1e1")], - relief=[("pressed", "sunken")]) - - # 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 (created once, updated later) - 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() - - # Button text and style - button_text = str(day) - if is_today: - button_text = f"•{day}•" # Mark today - - day_btn = ttk.Button(calendar_frame, text=button_text, - style="Calendar.TButton" if not is_selected else "Calendar.TButton", - command=lambda d=day_date: select_date(d)) - day_btn.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew") - - # Highlight selected date - if is_selected: - day_btn.configure(style="Calendar.TButton") - # Add visual indication of selection - day_btn.configure(text=f"[{day}]") - - def select_date(selected_day): - """Select a date and close calendar""" - nonlocal selected_date - selected_date = selected_day - date_var.set(selected_day.strftime('%Y-%m-%d')) - calendar_window.destroy() - - 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 - update_calendar() - - def prev_year(): - nonlocal display_year - display_year -= 1 - update_calendar() - - def next_year(): - nonlocal display_year - display_year += 1 - 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 clear_date(): - """Clear the selected date""" - date_var.set("") - calendar_window.destroy() - - # Clear button - clear_btn = ttk.Button(buttons_frame, text="Clear", command=clear_date) - clear_btn.pack(side=tk.LEFT) - - # Cancel button - cancel_btn = ttk.Button(buttons_frame, text="Cancel", command=calendar_window.destroy) - cancel_btn.pack(side=tk.RIGHT) - - # Initial calendar display - update_calendar() - - # Unique faces only checkbox variable (must be defined before widgets that use it) - unique_faces_var = tk.BooleanVar() - - # Define update_similar_faces function first - reusing auto-match display logic - def update_similar_faces(): - """Update the similar faces panel when compare is enabled - reuses auto-match display logic""" - nonlocal similar_faces_data, similar_face_vars, similar_face_images, similar_face_crops, face_selection_states - - # Note: Selection states are now saved automatically via callbacks (auto-match style) - - # Clear existing similar faces - for widget in similar_scrollable_frame.winfo_children(): - widget.destroy() - similar_face_vars.clear() - similar_face_images.clear() - - # Clean up existing face crops - for crop_path in similar_face_crops: - try: - if os.path.exists(crop_path): - os.remove(crop_path) - except: - pass - similar_face_crops.clear() - - if compare_var.get(): - # Use the same filtering and sorting logic as auto-match (no unique faces filter for similar faces) - unidentified_similar_faces = self._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status) - - if unidentified_similar_faces: - # Get current face_id for selection state management - current_face_id = original_faces[i][0] # Get current face_id - - # Reuse auto-match display logic for similar faces - self._display_similar_faces_in_panel(similar_scrollable_frame, unidentified_similar_faces, - similar_face_vars, similar_face_images, similar_face_crops, - current_face_id, face_selection_states, identify_data_cache) - - # Note: Selection states are now restored automatically during checkbox creation (auto-match style) - else: - # No similar unidentified faces found - no_faces_label = ttk.Label(similar_scrollable_frame, text="No similar unidentified faces found", - foreground="gray", font=("Arial", 10)) - no_faces_label.pack(pady=20) - else: - # Compare disabled - clear the panel - clear_label = ttk.Label(similar_scrollable_frame, text="Enable 'Compare with similar faces' to see matches", - foreground="gray", font=("Arial", 10)) - clear_label.pack(pady=20) - - # Update button states based on compare checkbox and list contents - update_select_clear_buttons_state() - - # Unique faces change handler (must be defined before checkbox that uses it) - def on_unique_faces_change(): - """Handle unique faces checkbox change""" - nonlocal original_faces, i - - if unique_faces_var.get(): - # Show progress message - print("šŸ”„ Applying unique faces filter...") - root.update() # Update UI to show the message - - # Apply unique faces filtering to the main face list - try: - original_faces = self._filter_unique_faces_from_list(original_faces) - print(f"āœ… Filter applied: {len(original_faces)} unique faces remaining") - except Exception as e: - print(f"āš ļø Error applying filter: {e}") - # Revert checkbox state - unique_faces_var.set(False) - return - else: - # Reload the original unfiltered face list - print("šŸ”„ Reloading all faces...") - root.update() # Update UI to show the message - - with self.get_db_connection() as conn: - cursor = conn.cursor() - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - query += ' ORDER BY f.id' - cursor.execute(query, params) - original_faces = list(cursor.fetchall()) - - print(f"āœ… Reloaded: {len(original_faces)} faces") - - # Reset to first face and update display - i = 0 - update_similar_faces() - - # Compare checkbox variable and handler (must be defined before widgets that use it) - compare_var = tk.BooleanVar() - - def on_compare_change(): - """Handle compare checkbox change""" - update_similar_faces() - update_select_clear_buttons_state() - - # Date filter controls - date_filter_frame = ttk.LabelFrame(main_frame, text="Filter", padding="5") - date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) - date_filter_frame.columnconfigure(1, weight=0) - date_filter_frame.columnconfigure(4, weight=0) - - # Date from - ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) - date_from_var = tk.StringVar(value=date_from or "") - date_from_entry = ttk.Entry(date_filter_frame, textvariable=date_from_var, width=10, state='readonly') - date_from_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date from - def open_calendar_from(): - open_date_calendar(date_from_var, "Select Start Date") - - calendar_from_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_from) - calendar_from_btn.grid(row=0, column=2, padx=(0, 10)) - - # Date to - ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) - date_to_var = tk.StringVar(value=date_to or "") - date_to_entry = ttk.Entry(date_filter_frame, textvariable=date_to_var, width=10, state='readonly') - date_to_entry.grid(row=0, column=4, sticky=tk.W, padx=(0, 5)) - - # Calendar button for date to - def open_calendar_to(): - open_date_calendar(date_to_var, "Select End Date") - - calendar_to_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_to) - calendar_to_btn.grid(row=0, column=5, padx=(0, 10)) - - # Apply filter button - def apply_date_filter(): - nonlocal date_from, date_to - date_from = date_from_var.get().strip() or None - date_to = date_to_var.get().strip() or None - date_processed_from = date_processed_from_var.get().strip() or None - date_processed_to = date_processed_to_var.get().strip() or None - - # Reload faces with new date filter - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Build the SQL query with optional date filtering - query = ''' - 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 IS NULL - ''' - params = [] - - # Add date taken filtering if specified - if date_from: - query += ' AND p.date_taken >= ?' - params.append(date_from) - - if date_to: - query += ' AND p.date_taken <= ?' - params.append(date_to) - - # Add date processed filtering if specified - if date_processed_from: - query += ' AND DATE(p.date_added) >= ?' - params.append(date_processed_from) - - if date_processed_to: - query += ' AND DATE(p.date_added) <= ?' - params.append(date_processed_to) - - query += ' LIMIT ?' - params.append(batch_size) - - cursor.execute(query, params) - unidentified = cursor.fetchall() - - if not unidentified: - messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") - return - - # Update the global unidentified list and reset position - nonlocal current_pos, total_unidentified - current_pos = 0 - total_unidentified = len(unidentified) - - # Reset to first face - display will update when user navigates - if len(unidentified) > 0: - # Reset to first face - current_pos = 0 - # The display will be updated when the user navigates or when the window is shown - - # Build filter description - filters_applied = [] - if date_from or date_to: - taken_filter = f"taken: {date_from or 'any'} to {date_to or 'any'}" - filters_applied.append(taken_filter) - if date_processed_from or date_processed_to: - processed_filter = f"processed: {date_processed_from or 'any'} to {date_processed_to or 'any'}" - filters_applied.append(processed_filter) - - filter_desc = " | ".join(filters_applied) if filters_applied else "no filters" - - print(f"šŸ“… Applied filters: {filter_desc}") - print(f"šŸ‘¤ Found {len(unidentified)} unidentified faces with date filters") - print("šŸ’” Navigate to refresh the display with filtered faces") - - # Apply filter button (inside filter frame) - apply_filter_btn = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) - apply_filter_btn.grid(row=0, column=6, padx=(10, 0)) - - # Date processed filter (second row) - ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - date_processed_from_var = tk.StringVar() - date_processed_from_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_from_var, width=10, state='readonly') - date_processed_from_entry.grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed from - def open_calendar_processed_from(): - open_date_calendar(date_processed_from_var, "Select Processing Start Date") - - calendar_processed_from_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_from) - calendar_processed_from_btn.grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) - - # Date processed to - ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - date_processed_to_var = tk.StringVar() - date_processed_to_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_to_var, width=10, state='readonly') - date_processed_to_entry.grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0)) - - # Calendar button for date processed to - def open_calendar_processed_to(): - open_date_calendar(date_processed_to_var, "Select Processing End Date") - - calendar_processed_to_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_to) - calendar_processed_to_btn.grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) - - # Unique checkbox under the filter frame - unique_faces_checkbox = ttk.Checkbutton(main_frame, text="Unique faces only", - variable=unique_faces_var, command=on_unique_faces_change) - unique_faces_checkbox.grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0) - - # Compare checkbox on the same row as Unique - compare_checkbox = ttk.Checkbutton(main_frame, text="Compare similar faces", variable=compare_var, - command=on_compare_change) - compare_checkbox.grid(row=2, column=1, sticky=tk.W, padx=(5, 10), pady=0) - - # Left panel for main face - left_panel = ttk.Frame(main_frame) - left_panel.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5), pady=(0, 0)) - left_panel.columnconfigure(0, weight=1) - - # Right panel for similar faces - right_panel = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5") - right_panel.grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - right_panel.columnconfigure(0, weight=1) - right_panel.rowconfigure(0, weight=1) # Make right panel expandable vertically - - # Image display (left panel) - image_frame = ttk.Frame(left_panel) - image_frame.grid(row=0, column=0, pady=(0, 10), sticky=(tk.W, tk.E, tk.N, tk.S)) - image_frame.columnconfigure(0, weight=1) - image_frame.rowconfigure(0, weight=1) - - # Create canvas for image display - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - canvas = tk.Canvas(image_frame, width=400, height=400, bg=canvas_bg_color, highlightthickness=0) - canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Input section (left panel) - input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10") - input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E)) - input_frame.columnconfigure(1, weight=1) - input_frame.columnconfigure(3, weight=1) - input_frame.columnconfigure(5, weight=1) - input_frame.columnconfigure(7, weight=1) - - # First name input - ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) - first_name_var = tk.StringVar() - first_name_entry = ttk.Entry(input_frame, textvariable=first_name_var, width=12) - first_name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Red asterisk for required first name field (overlayed, no layout impact) - first_name_asterisk = ttk.Label(root, text="*", foreground="red") - first_name_asterisk.place_forget() - - # Last name input (with live listbox autocomplete) - ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10)) - last_name_var = tk.StringVar() - last_name_entry = ttk.Entry(input_frame, textvariable=last_name_var, width=12) - last_name_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Red asterisk for required last name field (overlayed, no layout impact) - last_name_asterisk = ttk.Label(root, text="*", foreground="red") - last_name_asterisk.place_forget() - - def _position_required_asterisks(event=None): - """Position required asterisks at top-right corner of their entries.""" - try: - root.update_idletasks() - input_frame.update_idletasks() - first_name_entry.update_idletasks() - last_name_entry.update_idletasks() - date_of_birth_entry.update_idletasks() - - # Get absolute coordinates relative to root window - first_root_x = first_name_entry.winfo_rootx() - first_root_y = first_name_entry.winfo_rooty() - first_w = first_name_entry.winfo_width() - root_x = root.winfo_rootx() - root_y = root.winfo_rooty() - - # First name asterisk at the true top-right corner of entry - first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw') - first_name_asterisk.lift() - - # Last name asterisk at the true top-right corner of entry - last_root_x = last_name_entry.winfo_rootx() - last_root_y = last_name_entry.winfo_rooty() - last_w = last_name_entry.winfo_width() - last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw') - last_name_asterisk.lift() - - # Date of birth asterisk at the true top-right corner of date entry - dob_root_x = date_of_birth_entry.winfo_rootx() - dob_root_y = date_of_birth_entry.winfo_rooty() - dob_w = date_of_birth_entry.winfo_width() - date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw') - date_asterisk.lift() - except Exception: - pass - - # Bind repositioning after all entries are created - def _bind_asterisk_positioning(): - try: - input_frame.bind('', _position_required_asterisks) - first_name_entry.bind('', _position_required_asterisks) - last_name_entry.bind('', _position_required_asterisks) - date_of_birth_entry.bind('', _position_required_asterisks) - _position_required_asterisks() - except Exception: - pass - root.after(100, _bind_asterisk_positioning) - - # Create listbox for suggestions (as overlay attached to root, not clipped by frames) - last_name_listbox = tk.Listbox(root, height=8) - last_name_listbox.place_forget() # Hide initially - - def _show_suggestions(): - """Show filtered suggestions in listbox""" - all_last_names = identify_data_cache.get('last_names', []) - typed = last_name_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 all_last_names if n.lower().startswith(low)][:10] - - # Update listbox - last_name_listbox.delete(0, tk.END) - for name in filtered: - last_name_listbox.insert(tk.END, name) - - # Show listbox if we have suggestions (as overlay) - if filtered: - # Ensure geometry is up to date before positioning - root.update_idletasks() - # Absolute coordinates of entry relative to screen - entry_root_x = last_name_entry.winfo_rootx() - entry_root_y = last_name_entry.winfo_rooty() - entry_height = last_name_entry.winfo_height() - # Convert to coordinates relative to root - root_origin_x = root.winfo_rootx() - root_origin_y = root.winfo_rooty() - place_x = entry_root_x - root_origin_x - place_y = entry_root_y - root_origin_y + entry_height - place_width = last_name_entry.winfo_width() - # Calculate how many rows fit to bottom of window - available_px = max(60, root.winfo_height() - place_y - 8) - # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows - approx_row_px = 18 - rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) - last_name_listbox.configure(height=rows_fit) - last_name_listbox.place(x=place_x, y=place_y, width=place_width) - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) # Select first item - last_name_listbox.activate(0) # Activate first item - else: - last_name_listbox.place_forget() - - def _hide_suggestions(): - """Hide the suggestions listbox""" - last_name_listbox.place_forget() - - def _on_listbox_select(event=None): - """Handle listbox selection and hide list""" - selection = last_name_listbox.curselection() - if selection: - selected_name = last_name_listbox.get(selection[0]) - last_name_var.set(selected_name) - _hide_suggestions() - last_name_entry.focus_set() - - def _on_listbox_click(event): - """Handle mouse click selection""" - try: - index = last_name_listbox.nearest(event.y) - if index is not None and index >= 0: - selected_name = last_name_listbox.get(index) - last_name_var.set(selected_name) - except: - pass - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - - def _on_key_press(event): - """Handle key navigation in entry""" - nonlocal navigating_to_listbox, escape_pressed, enter_pressed - if event.keysym == 'Down': - if last_name_listbox.winfo_ismapped(): - navigating_to_listbox = True - last_name_listbox.focus_set() - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(0) - last_name_listbox.activate(0) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - return 'break' - elif event.keysym == 'Return': - enter_pressed = True - return 'break' - - def _on_listbox_key(event): - """Handle key navigation in listbox""" - nonlocal enter_pressed, escape_pressed - if event.keysym == 'Return': - enter_pressed = True - _on_listbox_select(event) - return 'break' - elif event.keysym == 'Escape': - escape_pressed = True - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - elif event.keysym == 'Up': - selection = last_name_listbox.curselection() - if selection and selection[0] > 0: - # Move up in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] - 1) - last_name_listbox.see(selection[0] - 1) - else: - # At top, go back to entry field - _hide_suggestions() - last_name_entry.focus_set() - return 'break' - elif event.keysym == 'Down': - selection = last_name_listbox.curselection() - max_index = last_name_listbox.size() - 1 - if selection and selection[0] < max_index: - # Move down in listbox - last_name_listbox.selection_clear(0, tk.END) - last_name_listbox.selection_set(selection[0] + 1) - last_name_listbox.see(selection[0] + 1) - return 'break' - - # Track if we're navigating to listbox to prevent auto-hide - navigating_to_listbox = False - escape_pressed = False - enter_pressed = False - - def _safe_hide_suggestions(): - """Hide suggestions only if not navigating to listbox""" - nonlocal navigating_to_listbox - if not navigating_to_listbox: - _hide_suggestions() - navigating_to_listbox = False - - def _safe_show_suggestions(): - """Show suggestions only if escape or enter wasn't just pressed""" - nonlocal escape_pressed, enter_pressed - if not escape_pressed and not enter_pressed: - _show_suggestions() - escape_pressed = False - enter_pressed = False - - # Bind events - last_name_entry.bind('', lambda e: _safe_show_suggestions()) - last_name_entry.bind('', _on_key_press) - last_name_entry.bind('', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks - last_name_listbox.bind('', _on_listbox_click) - last_name_listbox.bind('', _on_listbox_key) - last_name_listbox.bind('', _on_listbox_click) - - # Middle name input - ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) - middle_name_var = tk.StringVar() - middle_name_entry = ttk.Entry(input_frame, textvariable=middle_name_var, width=12) - middle_name_entry.grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Date of birth input with calendar chooser - ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10)) - date_of_birth_var = tk.StringVar() - - # Create a frame for the date picker - date_frame = ttk.Frame(input_frame) - date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10)) - - # Maiden name input - ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10)) - maiden_name_var = tk.StringVar() - maiden_name_entry = ttk.Entry(input_frame, textvariable=maiden_name_var, width=12) - maiden_name_entry.grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) - - # Date display entry (read-only) - date_of_birth_entry = ttk.Entry(date_frame, textvariable=date_of_birth_var, width=12, state='readonly') - date_of_birth_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Red asterisk for required date of birth field (overlayed, no layout impact) - date_asterisk = ttk.Label(root, text="*", foreground="red") - date_asterisk.place_forget() - - # Calendar button - calendar_btn = ttk.Button(date_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) - calendar_btn.pack(side=tk.RIGHT, padx=(15, 0)) - - def open_calendar(): - """Open a visual calendar dialog to select date of birth""" - from datetime import datetime, date, timedelta - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(root) - calendar_window.title("Select Date of Birth") - calendar_window.resizable(False, False) - calendar_window.transient(root) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = date_of_birth_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use default - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - else: - # Default to 25 years ago - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Configure custom styles for better visual highlighting - style = ttk.Style() - - # Selected date style - bright blue background with white text - style.configure("Selected.TButton", - background="#0078d4", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=2) - style.map("Selected.TButton", - background=[("active", "#106ebe")], - relief=[("pressed", "sunken")]) - - # Today's date style - orange background - style.configure("Today.TButton", - background="#ff8c00", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=1) - style.map("Today.TButton", - background=[("active", "#e67e00")], - relief=[("pressed", "sunken")]) - - # Calendar-specific normal button style (don't affect global TButton) - style.configure("Calendar.TButton", - font=("Arial", 9), - relief="flat") - style.map("Calendar.TButton", - background=[("active", "#e1e1e1")], - relief=[("pressed", "sunken")]) - - # 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() - - def update_calendar(): - """Update the calendar display""" - # Clear existing calendar - for widget in calendar_frame.winfo_children(): - widget.destroy() - - # Update header - month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") - - # 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): - label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) - label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") - - # Calendar days - for week_num, week in enumerate(cal): - for day_num, day in enumerate(week): - if day == 0: - # Empty cell - label = ttk.Label(calendar_frame, text="") - label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - else: - # Day button - 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 with prominent style - 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), - width=3, style="Calendar.TButton") - day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - - # Check if this day should be highlighted - is_today = (display_year == current_date.year and - display_month == current_date.month and - day == current_date.day) - is_selected = (selected_date and - selected_date.year == display_year and - selected_date.month == display_month and - selected_date.day == day) - - if is_selected: - day_btn.config(style="Selected.TButton") - elif is_today: - day_btn.config(style="Today.TButton") - - # Navigation functions - def prev_year(): - nonlocal display_year - display_year = max(1900, display_year - 1) - update_calendar() - - def next_year(): - nonlocal display_year - display_year = min(current_date.year, display_year + 1) - update_calendar() - - def prev_month(): - nonlocal display_month, display_year - if display_month > 1: - display_month -= 1 - else: - display_month = 12 - display_year = max(1900, display_year - 1) - update_calendar() - - def next_month(): - nonlocal display_month, display_year - if display_month < 12: - display_month += 1 - else: - display_month = 1 - display_year = min(current_date.year, display_year + 1) - update_calendar() - - # Navigation buttons - prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) - prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) - - prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) - prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) - - month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) - month_year_label.pack(side=tk.LEFT, padx=5) - - next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) - next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) - - 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: - date_str = selected_date.strftime('%Y-%m-%d') - date_of_birth_var.set(date_str) - calendar_window.destroy() - else: - messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") - - def cancel_selection(): - """Cancel date selection""" - calendar_window.destroy() - - # Buttons - 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 - update_calendar() - - # (moved) unique_faces_var is defined earlier before date filter widgets - - # (moved) update_similar_faces function is defined earlier before on_unique_faces_change - - # (moved) Compare checkbox is now inside date_filter_frame to the right of dates - - # (moved) on_unique_faces_change function is defined earlier before date filter widgets - - - # Add callback to save person name when it changes - def on_name_change(*args): - if i < len(original_faces): - current_face_id = original_faces[i][0] - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - middle_name = middle_name_var.get().strip() - maiden_name = maiden_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - - if first_name or last_name or date_of_birth: - # Store as dictionary to maintain consistency - face_person_names[current_face_id] = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': middle_name, - 'maiden_name': maiden_name, - 'date_of_birth': date_of_birth - } - elif current_face_id in face_person_names: - # Remove empty names from storage - del face_person_names[current_face_id] - - first_name_var.trace('w', on_name_change) - last_name_var.trace('w', on_name_change) - date_of_birth_var.trace('w', on_name_change) - - # Buttons moved to bottom of window - - - # Right panel for similar faces - similar_faces_frame = ttk.Frame(right_panel) - similar_faces_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - similar_faces_frame.columnconfigure(0, weight=1) - similar_faces_frame.rowconfigure(0, weight=0) # Controls row takes minimal space - similar_faces_frame.rowconfigure(1, weight=1) # Canvas row expandable - - # Control buttons for similar faces (Select All / Clear All) - similar_controls_frame = ttk.Frame(similar_faces_frame) - similar_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) - - def select_all_similar_faces(): - """Select all similar faces checkboxes""" - for face_id, var in similar_face_vars: - var.set(True) - - def clear_all_similar_faces(): - """Clear all similar faces checkboxes""" - for face_id, var in similar_face_vars: - var.set(False) - - select_all_btn = ttk.Button(similar_controls_frame, text="ā˜‘ļø Select All", command=select_all_similar_faces, state='disabled') - select_all_btn.pack(side=tk.LEFT, padx=(0, 5)) - - clear_all_btn = ttk.Button(similar_controls_frame, text="☐ Clear All", command=clear_all_similar_faces, state='disabled') - clear_all_btn.pack(side=tk.LEFT) - - def update_select_clear_buttons_state(): - """Enable/disable Select All and Clear All based on compare state and presence of items""" - if compare_var.get() and similar_face_vars: - select_all_btn.config(state='normal') - clear_all_btn.config(state='normal') - else: - select_all_btn.config(state='disabled') - clear_all_btn.config(state='disabled') - - # Create canvas for similar faces with scrollbar - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - similar_canvas = tk.Canvas(similar_faces_frame, bg=canvas_bg_color, highlightthickness=0) - similar_scrollbar = ttk.Scrollbar(similar_faces_frame, orient="vertical", command=similar_canvas.yview) - similar_scrollable_frame = ttk.Frame(similar_canvas) - - similar_scrollable_frame.bind( - "", - lambda e: similar_canvas.configure(scrollregion=similar_canvas.bbox("all")) - ) - - similar_canvas.create_window((0, 0), window=similar_scrollable_frame, anchor="nw") - similar_canvas.configure(yscrollcommand=similar_scrollbar.set) - - # Pack canvas and scrollbar - similar_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - similar_scrollbar.grid(row=0, column=1, rowspan=2, sticky=(tk.N, tk.S)) - - # Variables for similar faces - similar_faces_data = [] - similar_face_vars = [] - similar_face_images = [] - similar_face_crops = [] - - # Store face selection states per face ID to preserve selections during navigation (auto-match style) - face_selection_states = {} # {face_id: {unique_key: bool}} - - # Store person names per face ID to preserve names during navigation - face_person_names = {} # {face_id: person_name} - - def save_current_face_selection_states(): - """Save current checkbox states and person name for the current face (auto-match style backup)""" - if i < len(original_faces): - current_face_id = original_faces[i][0] - - # Save checkbox states - if similar_face_vars: - if current_face_id not in face_selection_states: - face_selection_states[current_face_id] = {} - - # Save current checkbox states using unique keys - for similar_face_id, var in similar_face_vars: - unique_key = f"{current_face_id}_{similar_face_id}" - face_selection_states[current_face_id][unique_key] = var.get() - - # Save person name and date of birth - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - middle_name = middle_name_var.get().strip() - maiden_name = maiden_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - - if first_name or last_name: - # Store all fields - face_person_names[current_face_id] = { - 'first_name': first_name, - 'last_name': last_name, - 'middle_name': middle_name, - 'maiden_name': maiden_name, - 'date_of_birth': date_of_birth - } - - # Button commands - command = None - waiting_for_input = False - - def on_identify(): - nonlocal command, waiting_for_input - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - middle_name = middle_name_var.get().strip() - maiden_name = maiden_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - compare_enabled = compare_var.get() - - if not first_name: - print("āš ļø Please enter a first name before identifying") - return - - if not last_name: - print("āš ļø Please enter a last name before identifying") - return - - if not date_of_birth: - print("āš ļø Please select a date of birth before identifying") - return - - # Validate date format (YYYY-MM-DD) - should always be valid from calendar - try: - from datetime import datetime - datetime.strptime(date_of_birth, '%Y-%m-%d') - except ValueError: - print("āš ļø Invalid date format. Please use the calendar to select a date.") - return - - # Combine first and last name properly - if last_name and first_name: - command = f"{last_name}, {first_name}" - elif last_name: - command = last_name - elif first_name: - command = first_name - else: - command = "" - - # Store the additional fields for database insertion - # We'll pass them through the command structure - if middle_name or maiden_name: - command += f"|{middle_name}|{maiden_name}|{date_of_birth}" - else: - command += f"|||{date_of_birth}" - - if not command: - print("āš ļø Please enter at least a first name or last name before identifying") - return - - if compare_enabled: - # Get selected similar faces - selected_face_ids = [face_id for face_id, var in similar_face_vars if var.get()] - if selected_face_ids: - # Create compare command with selected face IDs - command = f"compare:{command}:{','.join(map(str, selected_face_ids))}" - # If no similar faces selected, just identify the current face - else: - # Regular identification - pass - - waiting_for_input = False - - - def validate_navigation(): - """Check if navigation is allowed (no selected similar faces without person name)""" - # Check if compare is enabled and similar faces are selected - if compare_var.get() and similar_face_vars: - selected_faces = [face_id for face_id, var in similar_face_vars if var.get()] - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - if selected_faces and not (first_name or last_name): - # Show warning dialog - result = messagebox.askyesno( - "Selected Faces Not Identified", - f"You have {len(selected_faces)} similar face(s) selected but no person name entered.\n\n" - "These faces will not be identified if you continue.\n\n" - "Do you want to continue anyway?", - icon='warning' - ) - return result # True = continue, False = cancel - return True # No validation issues, allow navigation - - def on_back(): - nonlocal command, waiting_for_input - if not validate_navigation(): - return # Cancel navigation - command = 'back' - waiting_for_input = False - - def on_skip(): - nonlocal command, waiting_for_input - if not validate_navigation(): - return # Cancel navigation - command = 's' - waiting_for_input = False - - def on_quit(): - nonlocal command, waiting_for_input, window_destroyed, force_exit - - # First check for selected similar faces without person name - if not validate_navigation(): - return # Cancel quit - - # Check if there are pending identifications (faces with complete data but not yet saved) - pending_identifications = {} - for k, v in face_person_names.items(): - if k not in face_status or face_status[k] != 'identified': - # Handle person data dict format - if isinstance(v, dict): - first_name = v.get('first_name', '').strip() - last_name = v.get('last_name', '').strip() - date_of_birth = v.get('date_of_birth', '').strip() - - # Check if we have complete data (both first and last name, plus date of birth) - if first_name and last_name and date_of_birth: - pending_identifications[k] = v - else: - # Handle legacy string format - person_name = v.strip() - date_of_birth = '' # Legacy format doesn't have date_of_birth - # Legacy format is not considered complete without date of birth - pass - - if pending_identifications: - # Ask user if they want to save pending identifications - result = messagebox.askyesnocancel( - "Save Pending Identifications?", - f"You have {len(pending_identifications)} pending identifications.\n\n" - "Do you want to save them before quitting?\n\n" - "• Yes: Save all pending identifications and quit\n" - "• No: Quit without saving\n" - "• Cancel: Return to identification" - ) - - if result is True: # Yes - Save and quit - save_all_pending_identifications() - command = 'q' - waiting_for_input = False - elif result is False: # No - Quit without saving - command = 'q' - waiting_for_input = False - else: # Cancel - Don't quit - return - else: - # No pending identifications, quit normally - command = 'q' - waiting_for_input = False - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Force process termination - force_exit = True - root.quit() - - - - def update_button_states(): - """Update button states based on current position and unidentified faces""" - # Check if there are previous unidentified faces - has_prev_unidentified = False - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - has_prev_unidentified = True - break - - # Check if there are next unidentified faces - has_next_unidentified = False - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - has_next_unidentified = True - break - - # Enable/disable Back button - if has_prev_unidentified: - back_btn.config(state='normal') - else: - back_btn.config(state='disabled') - - # Enable/disable Next button - if has_next_unidentified: - next_btn.config(state='normal') - else: - next_btn.config(state='disabled') - - # Button references moved to bottom control panel - - def update_identify_button_state(): - """Enable/disable identify button based on first name, last name, and date of birth""" - first_name = first_name_var.get().strip() - last_name = last_name_var.get().strip() - date_of_birth = date_of_birth_var.get().strip() - if first_name and last_name and date_of_birth: - identify_btn.config(state='normal') - else: - identify_btn.config(state='disabled') - - # Bind name input changes to update button state - first_name_var.trace('w', lambda *args: update_identify_button_state()) - last_name_var.trace('w', lambda *args: update_identify_button_state()) - date_of_birth_var.trace('w', lambda *args: update_identify_button_state()) - - # Handle Enter key - def on_enter(event): - on_identify() - - first_name_entry.bind('', on_enter) - last_name_entry.bind('', on_enter) - - # Bottom control panel (move to bottom below panels) - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=4, column=0, columnspan=3, pady=(10, 0), sticky=(tk.E, tk.S)) - - # Create button references for state management - back_btn = ttk.Button(control_frame, text="ā¬…ļø Back", command=on_back) - next_btn = ttk.Button(control_frame, text="āž”ļø Next", command=on_skip) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) - - back_btn.pack(side=tk.LEFT, padx=(0, 5)) - next_btn.pack(side=tk.LEFT, padx=(0, 5)) - quit_btn.pack(side=tk.LEFT, padx=(5, 0)) - - # Identify button (placed after on_identify is defined) - identify_btn = ttk.Button(input_frame, text="āœ… Identify", command=on_identify, state='disabled') - identify_btn.grid(row=4, column=0, pady=(10, 0), sticky=tk.W) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - conn.close() - return 0 - - - - # Process each face with back navigation support - # Keep track of original face list and current position - original_faces = list(unidentified) # Make a copy of the original list - i = 0 - face_status = {} # Track which faces have been identified - - def get_unidentified_faces(): - """Get list of faces that haven't been identified yet""" - return [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] - - def get_current_face_position(): - """Get current face position among unidentified faces""" - unidentified_faces = get_unidentified_faces() - current_face_id = original_faces[i][0] if i < len(original_faces) else None - - # Find position of current face in unidentified list - for pos, face in enumerate(unidentified_faces): - if face[0] == current_face_id: - return pos + 1, len(unidentified_faces) - - return 1, len(unidentified_faces) # Fallback - - def update_current_face_index(): - """Update the current face index to point to a valid unidentified face""" - nonlocal i - unidentified_faces = get_unidentified_faces() - if not unidentified_faces: - # All faces identified, we're done - return False - - # Find the current face in the unidentified list - current_face_id = original_faces[i][0] if i < len(original_faces) else None - if current_face_id and current_face_id in face_status and face_status[current_face_id] == 'identified': - # Current face was just identified, find the next unidentified face - if i < len(original_faces) - 1: - # Try to find the next unidentified face - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - else: - # No more faces after current, go to previous - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - else: - # At the end, go to previous unidentified face - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - break - - # Ensure index is within bounds - if i >= len(original_faces): - i = len(original_faces) - 1 - if i < 0: - i = 0 - - return True - - while not window_destroyed: - # Check if current face is identified and update index if needed - if not update_current_face_index(): - # All faces have been identified - print("\nšŸŽ‰ All faces have been identified!") - break - - # Ensure we don't go beyond the bounds - if i >= len(original_faces): - # Stay on the last face instead of breaking - i = len(original_faces) - 1 - - face_id, photo_id, photo_path, filename, location = original_faces[i] - - # Check if this face was already identified in this session - is_already_identified = face_id in face_status and face_status[face_id] == 'identified' - - # Reset command and waiting state for each face - command = None - waiting_for_input = True - - # Update the display - current_pos, total_unidentified = get_current_face_position() - print(f"\n--- Face {current_pos}/{total_unidentified} (unidentified) ---") - print(f"šŸ“ Photo: {filename}") - print(f"šŸ“ Face location: {location}") - - # Update title - root.title(f"Face Identification - {current_pos}/{total_unidentified} (unidentified)") - - # Update button states - update_button_states() - - # Update similar faces panel if compare is enabled - if compare_var.get(): - update_similar_faces() - - # Update photo info - if is_already_identified: - # Get the person name for this face - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.first_name, p.last_name FROM people p - JOIN faces f ON p.id = f.person_id - WHERE f.id = ? - ''', (face_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" - - info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified}) - āœ… Already identified as: {person_name}") - print(f"āœ… Already identified as: {person_name}") - else: - info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified})") - - # Extract face crop if enabled - face_crop_path = None - if show_faces: - face_crop_path = self._extract_face_crop(photo_path, location, face_id) - if face_crop_path: - print(f"šŸ–¼ļø Face crop saved: {face_crop_path}") - current_face_crop_path = face_crop_path # Track for cleanup - else: - print("šŸ’” Use --show-faces flag to display individual face crops") - current_face_crop_path = None - - print(f"\nšŸ–¼ļø Viewing face {current_pos}/{total_unidentified} from {filename}") - - # Clear and update image - canvas.delete("all") - if show_faces and face_crop_path and os.path.exists(face_crop_path): - try: - # Load and display the face crop image - pil_image = Image.open(face_crop_path) - - # Get canvas dimensions - canvas_width = canvas.winfo_width() - canvas_height = canvas.winfo_height() - - # If canvas hasn't been rendered yet, force update and use actual size - if canvas_width <= 1 or canvas_height <= 1: - # Force the canvas to update its geometry - canvas.update_idletasks() - canvas_width = canvas.winfo_width() - canvas_height = canvas.winfo_height() - - # If still not rendered, use default size - if canvas_width <= 1: - canvas_width = 400 - if canvas_height <= 1: - canvas_height = 400 - - # Calculate scaling to fit within the canvas while maintaining aspect ratio - img_width, img_height = pil_image.size - scale_x = canvas_width / img_width - scale_y = canvas_height / img_height - # Allow slight upscaling (up to 1.2x) for better visibility, but cap to avoid excessive blurriness - max_scale = min(1.2, max(scale_x, scale_y)) - scale = min(scale_x, scale_y, max_scale) - - # Resize image to fill canvas - new_width = int(img_width * scale) - new_height = int(img_height * scale) - pil_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS) - - photo = ImageTk.PhotoImage(pil_image) - - # Center the image in the canvas - x = canvas_width // 2 - y = canvas_height // 2 - canvas.create_image(x, y, image=photo) - - # Keep a reference to prevent garbage collection - canvas.image = photo - - # Add photo icon using reusable function - self._create_photo_icon(canvas, photo_path, - face_x=x, face_y=y, - face_width=new_width, face_height=new_height, - canvas_width=canvas_width, canvas_height=canvas_height) - - except Exception as e: - canvas.create_text(200, 200, text=f"āŒ Could not load image: {e}", fill="red") - else: - canvas.create_text(200, 200, text="šŸ–¼ļø No face crop available", fill="gray") - - # Set person name input - restore saved name or use database/empty value - if face_id in face_person_names: - # Restore previously entered name for this face - person_data = face_person_names[face_id] - if isinstance(person_data, dict): - # Handle dictionary format - use individual field values for proper restoration - first_name = person_data.get('first_name', '').strip() - last_name = person_data.get('last_name', '').strip() - middle_name = person_data.get('middle_name', '').strip() - maiden_name = person_data.get('maiden_name', '').strip() - date_of_birth = person_data.get('date_of_birth', '').strip() - - # Restore all fields directly - first_name_var.set(first_name) - last_name_var.set(last_name) - middle_name_var.set(middle_name) - maiden_name_var.set(maiden_name) - date_of_birth_var.set(date_of_birth) - else: - # Handle legacy string format (for backward compatibility) - full_name = person_data - # Parse "Last, First" format back to separate fields - if ', ' in full_name: - parts = full_name.split(', ', 1) - last_name_var.set(parts[0].strip()) - first_name_var.set(parts[1].strip()) - else: - # Single name format - first_name_var.set(full_name) - last_name_var.set("") - elif is_already_identified: - # Pre-populate with the current person name from database - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth FROM people p - JOIN faces f ON p.id = f.person_id - WHERE f.id = ? - ''', (face_id,)) - result = cursor.fetchone() - if result: - first_name_var.set(result[0] or "") - last_name_var.set(result[1] or "") - middle_name_var.set(result[2] or "") - maiden_name_var.set(result[3] or "") - date_of_birth_var.set(result[4] or "") - else: - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - else: - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - - # Keep compare checkbox state persistent across navigation - first_name_entry.focus_set() - first_name_entry.icursor(0) - - # Force GUI update before waiting for input - root.update_idletasks() - - # Wait for user input - while waiting_for_input: - try: - root.update() - # Small delay to prevent excessive CPU usage - time.sleep(0.01) - except tk.TclError: - # Window was destroyed, break out of loop - break - - # Check if force exit was requested - if force_exit: - break - - # Check if force exit was requested (exit immediately) - if force_exit: - print("Force exit requested...") - # Clean up face crops and caches - self._cleanup_face_crops(face_crop_path) - self.close_db_connection() - return identified_count - - # Process the command - if command is None: # User clicked Cancel - command = 'q' - else: - command = command.strip() - - if command.lower() == 'q': - print("Quitting...") - # Clean up face crops and caches - self._cleanup_face_crops(face_crop_path) - self.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - return identified_count - - - elif command.lower() == 's': - print("āž”ļø Next") - - # Save current checkbox states before navigating away (auto-match style backup) - save_current_face_selection_states() - - # Clean up current face crop when moving forward - if face_crop_path and os.path.exists(face_crop_path): - try: - os.remove(face_crop_path) - except: - pass # Ignore cleanup errors - current_face_crop_path = None # Clear tracked path - - # Find next unidentified face - next_found = False - for j in range(i + 1, len(original_faces)): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - next_found = True - break - - if not next_found: - print("āš ļø No more unidentified faces - Next button disabled") - continue - - # Clear date of birth field when moving to next face - date_of_birth_var.set("") - # Clear middle name and maiden name fields when moving to next face - middle_name_var.set("") - maiden_name_var.set("") - - update_button_states() - # Only update similar faces if compare is enabled - if compare_var.get(): - update_similar_faces() - continue - - elif command.lower() == 'back': - print("ā¬…ļø Going back to previous face") - - # Save current checkbox states before navigating away (auto-match style backup) - save_current_face_selection_states() - - # Find previous unidentified face - prev_found = False - for j in range(i - 1, -1, -1): - if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': - i = j - prev_found = True - break - - if not prev_found: - print("āš ļø No more unidentified faces - Back button disabled") - continue - - # Repopulate fields with saved data when going back - current_face_id = original_faces[i][0] - if current_face_id in face_person_names: - person_data = face_person_names[current_face_id] - if isinstance(person_data, dict): - # Use individual field values for proper restoration - first_name = person_data.get('first_name', '').strip() - last_name = person_data.get('last_name', '').strip() - middle_name = person_data.get('middle_name', '').strip() - maiden_name = person_data.get('maiden_name', '').strip() - date_of_birth = person_data.get('date_of_birth', '').strip() - - # Restore all fields directly - first_name_var.set(first_name) - last_name_var.set(last_name) - middle_name_var.set(middle_name) - maiden_name_var.set(maiden_name) - date_of_birth_var.set(date_of_birth) - else: - # Clear fields - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - else: - # No saved data - clear fields - first_name_var.set("") - last_name_var.set("") - middle_name_var.set("") - maiden_name_var.set("") - date_of_birth_var.set("") - - update_button_states() - # Only update similar faces if compare is enabled - if compare_var.get(): - update_similar_faces() - continue - - elif command.lower() == 'list': - self._show_people_list() - continue - - elif command: - try: - # Check if this is a compare command - if command.startswith('compare:'): - # Parse compare command: compare:person_name:face_id1,face_id2,face_id3 - parts = command.split(':', 2) - if len(parts) == 3: - person_name = parts[1] - selected_face_ids = [int(fid.strip()) for fid in parts[2].split(',') if fid.strip()] - - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Add person if doesn't exist - # Parse person_name in "Last, First" or single-token format - # Parse person_name with additional fields (middle_name|maiden_name|date_of_birth) - name_part, middle_name, maiden_name, date_of_birth = person_name.split('|', 3) - parts = [p.strip() for p in name_part.split(',', 1)] - - if len(parts) == 2: - last_name, first_name = parts[0], parts[1] - else: - first_name = parts[0] if parts else '' - last_name = '' - - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - if person_name not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(person_name) - identify_data_cache['people_names'].sort() # Keep sorted - # Update last names cache from person_name ("Last, First" or single) - inferred_last = person_name.split(',')[0].strip() if ',' in person_name else person_name.strip() - if inferred_last: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if inferred_last not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(inferred_last) - identify_data_cache['last_names'].sort() - - # Identify all selected faces (including current face) - all_face_ids = [face_id] + selected_face_ids - for fid in all_face_ids: - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, fid) - ) - - # Mark all faces as identified in our tracking - for fid in all_face_ids: - face_status[fid] = 'identified' - - if is_already_identified: - print(f"āœ… Re-identified current face and {len(selected_face_ids)} similar faces as: {person_name}") - else: - print(f"āœ… Identified current face and {len(selected_face_ids)} similar faces as: {person_name}") - identified_count += 1 + len(selected_face_ids) - - - # Update person encodings after database transaction is complete - self._update_person_encodings(person_id) - else: - print("āŒ Invalid compare command format") - else: - # Regular identification - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Add person if doesn't exist - # Parse command in "Last, First" or single-token format - # Parse command with additional fields (middle_name|maiden_name|date_of_birth) - name_part, middle_name, maiden_name, date_of_birth = command.split('|', 3) - parts = [p.strip() for p in name_part.split(',', 1)] - - if len(parts) == 2: - last_name, first_name = parts[0], parts[1] - else: - first_name = parts[0] if parts else '' - last_name = '' - - cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) - result = cursor.fetchone() - person_id = result[0] if result else None - - # Update people cache if new person was added - if command not in identify_data_cache['people_names']: - identify_data_cache['people_names'].append(command) - identify_data_cache['people_names'].sort() # Keep sorted - # Update last names cache from command ("Last, First" or single) - inferred_last = command.split(',')[0].strip() if ',' in command else command.strip() - if inferred_last: - if 'last_names' not in identify_data_cache: - identify_data_cache['last_names'] = [] - if inferred_last not in identify_data_cache['last_names']: - identify_data_cache['last_names'].append(inferred_last) - identify_data_cache['last_names'].sort() - - # Assign face to person - cursor.execute( - 'UPDATE faces SET person_id = ? WHERE id = ?', - (person_id, face_id) - ) - - if is_already_identified: - print(f"āœ… Re-identified as: {command}") - else: - print(f"āœ… Identified as: {command}") - identified_count += 1 - - # Mark this face as identified in our tracking - face_status[face_id] = 'identified' - - - # Update person encodings after database transaction is complete - self._update_person_encodings(person_id) - - except Exception as e: - print(f"āŒ Error: {e}") - - # Increment index for normal flow (identification or error) - but not if we're at the last item - if i < len(original_faces) - 1: - i += 1 - update_button_states() - # Only update similar faces if compare is enabled - if compare_var.get(): - update_similar_faces() - - # Clean up current face crop when moving forward after identification - if face_crop_path and os.path.exists(face_crop_path): - try: - os.remove(face_crop_path) - except: - pass # Ignore cleanup errors - current_face_crop_path = None # Clear tracked path - - # Continue to next face after processing command - continue - else: - print("Please enter a name, 's' to skip, 'q' to quit, or use buttons") - - # Only close the window if user explicitly quit (not when reaching end of faces) - if not window_destroyed: - # Keep the window open - user can still navigate and quit manually - print(f"\nāœ… Identified {identified_count} faces") - print("šŸ’” Application remains open - use Quit button to close") - # Don't destroy the window - let user quit manually - return identified_count - - print(f"\nāœ… Identified {identified_count} faces") - return identified_count + """Interactive face identification with GUI""" + print("āš ļø Face identification GUI not yet implemented in refactored version") + return 0 + + def tag_management(self) -> int: + """Tag management GUI""" + print("āš ļø Tag management GUI not yet implemented in refactored version") + return 0 + + def modifyidentified(self) -> int: + """Modify identified faces GUI""" + print("āš ļø Face modification GUI not yet implemented in refactored version") + return 0 + + 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 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.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") + """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 _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", "", open_source_photo) - canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e))) - canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor=""), hide_tooltip(e))) - canvas.tag_bind("photo_icon", "", lambda e: (show_tooltip(e) if tooltip else None)) - - return tooltip # Return tooltip reference for cleanup if needed - - 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 - Unlikely)" - - 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 _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""" - 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.tobytes(), quality_score) - ) - - def _get_person_encodings(self, person_id: int, min_quality: float = 0.3) -> List[Tuple[np.ndarray, float]]: - """Get all high-quality encodings for a person""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - 'SELECT encoding, quality_score FROM person_encodings WHERE person_id = ? AND quality_score >= ? ORDER BY quality_score DESC', - (person_id, min_quality) - ) - results = cursor.fetchall() - 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""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Get all faces for this person - cursor.execute( - 'SELECT id, encoding, quality_score FROM faces WHERE person_id = ? ORDER BY quality_score DESC', - (person_id,) - ) - faces = cursor.fetchall() - - # Clear existing person encodings - cursor.execute('DELETE FROM person_encodings WHERE person_id = ?', (person_id,)) - - # Add all faces as person encodings - for face_id, encoding, quality_score in faces: - cursor.execute( - 'INSERT INTO person_encodings (person_id, face_id, encoding, quality_score) VALUES (?, ?, ?, ?)', - (person_id, face_id, encoding, quality_score) - ) - - 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 _filter_unique_faces_from_list(self, faces_list: List[tuple]) -> List[tuple]: - """Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches""" - if not faces_list: - return faces_list - - # Extract face IDs from the list - face_ids = [face_tuple[0] for face_tuple in faces_list] - - # Get face encodings from database for all faces - face_encodings = {} - with self.get_db_connection() as conn: - cursor = conn.cursor() - placeholders = ','.join('?' * len(face_ids)) - cursor.execute(f''' - SELECT id, encoding - FROM faces - WHERE id IN ({placeholders}) AND encoding IS NOT NULL - ''', face_ids) - - for face_id, encoding_blob in cursor.fetchall(): - try: - import numpy as np - # Load encoding as numpy array (not pickle) - encoding = np.frombuffer(encoding_blob, dtype=np.float64) - face_encodings[face_id] = encoding - except Exception: - continue - - # If we don't have enough encodings, return original list - if len(face_encodings) < 2: - return faces_list - - # Calculate distances between all faces using existing encodings - face_distances = {} - face_id_list = list(face_encodings.keys()) - - for i, face_id1 in enumerate(face_id_list): - for j, face_id2 in enumerate(face_id_list): - if i != j: - try: - import face_recognition - encoding1 = face_encodings[face_id1] - encoding2 = face_encodings[face_id2] - - # Calculate distance - distance = face_recognition.face_distance([encoding1], encoding2)[0] - face_distances[(face_id1, face_id2)] = distance - except Exception: - # If calculation fails, assume no match - face_distances[(face_id1, face_id2)] = 1.0 - - # Apply unique faces filtering - unique_faces = [] - seen_face_groups = set() - - for face_tuple in faces_list: - face_id = face_tuple[0] - - # Skip if we don't have encoding for this face - if face_id not in face_encodings: - unique_faces.append(face_tuple) - continue - - # Find all faces that match this one with high/medium confidence - matching_face_ids = set([face_id]) # Include self - for other_face_id in face_encodings.keys(): - if other_face_id != face_id: - distance = face_distances.get((face_id, other_face_id), 1.0) - confidence_pct = (1 - distance) * 100 - - # If this face matches with high/medium confidence - if 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_tuple) - - return unique_faces - - def _show_people_list(self, cursor=None): - """Show list of known people""" - if cursor is None: - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - else: - cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') - people = cursor.fetchall() - - if people: - formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last] - print("šŸ‘„ Known people:", ", ".join(formatted_names)) - else: - print("šŸ‘„ No people identified yet") + """Get human-readable confidence description (legacy compatibility)""" + return self.face_processor._get_confidence_description(confidence_pct) - def add_tags(self, photo_pattern: str = None, batch_size: int = 10) -> int: - """Add custom tags to photos""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - if photo_pattern: - cursor.execute( - 'SELECT id, filename FROM photos WHERE filename LIKE ? LIMIT ?', - (f'%{photo_pattern}%', batch_size) - ) - else: - cursor.execute('SELECT id, filename FROM photos LIMIT ?', (batch_size,)) - - photos = cursor.fetchall() - - 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, filename in photos: - print(f"\nšŸ“ø {filename}") - tags_input = input("šŸ·ļø Tags: ").strip() - - if tags_input.lower() == 'q': - break - - if tags_input: - tags = [tag.strip() for tag in tags_input.split(',') if tag.strip()] - for tag_name in tags: - # First, insert or get the tag_id from tags table - cursor.execute( - 'INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', - (tag_name,) - ) - cursor.execute( - 'SELECT id FROM tags WHERE tag_name = ?', - (tag_name,) - ) - tag_id = cursor.fetchone()[0] - - # Then, insert the linkage (ignore if already exists due to UNIQUE constraint) - cursor.execute( - 'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', - (photo_id, tag_id) - ) - print(f" āœ… Added {len(tags)} tags") - tagged_count += 1 - - print(f"āœ… Tagged {tagged_count} photos") - return tagged_count + # Cache management (legacy compatibility) + def _clear_caches(self): + """Clear all caches to free memory (legacy compatibility)""" + self.face_processor._clear_caches() - def stats(self) -> Dict: - """Show database statistics""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - stats = {} - - # Basic counts - cursor.execute('SELECT COUNT(*) FROM photos') - result = cursor.fetchone() - stats['total_photos'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM photos WHERE processed = 1') - result = cursor.fetchone() - stats['processed_photos'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM faces') - result = cursor.fetchone() - stats['total_faces'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL') - result = cursor.fetchone() - stats['identified_faces'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM people') - result = cursor.fetchone() - stats['total_people'] = result[0] if result else 0 - - cursor.execute('SELECT COUNT(*) FROM tags') - result = cursor.fetchone() - stats['unique_tags'] = result[0] if result else 0 - - # Top people - cursor.execute(''' - SELECT - CASE - WHEN p.last_name AND p.first_name THEN p.last_name || ', ' || p.first_name - WHEN p.first_name THEN p.first_name - WHEN p.last_name THEN p.last_name - ELSE 'Unknown' - END as full_name, - COUNT(f.id) as face_count - FROM people p - LEFT JOIN faces f ON p.id = f.person_id - GROUP BY p.id - ORDER BY face_count DESC - LIMIT 15 - ''') - stats['top_people'] = cursor.fetchall() - - # Display stats - print(f"\nšŸ“Š Database Statistics") - print("=" * 40) - print(f"Photos: {stats['processed_photos']}/{stats['total_photos']} processed") - print(f"Faces: {stats['identified_faces']}/{stats['total_faces']} identified") - print(f"People: {stats['total_people']} unique") - print(f"Tags: {stats['unique_tags']} unique") - - if stats['top_people']: - print(f"\nšŸ‘„ Top People:") - for name, count in stats['top_people']: - print(f" {name}: {count} faces") - - unidentified = stats['total_faces'] - stats['identified_faces'] - if unidentified > 0: - print(f"\nāš ļø {unidentified} faces still need identification") - - return stats + 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) - def search_faces(self, person_name: str) -> List[str]: - """Search for photos containing a specific person""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - cursor.execute(''' - SELECT DISTINCT p.filename, p.path - FROM photos p - JOIN faces f ON p.id = f.photo_id - JOIN people pe ON f.person_id = pe.id - WHERE pe.name LIKE ? - ''', (f'%{person_name}%',)) - - results = cursor.fetchall() - - if results: - print(f"\nšŸ” Found {len(results)} photos with '{person_name}':") - for filename, path in results: - print(f" šŸ“ø {filename}") - else: - print(f"šŸ” No photos found with '{person_name}'") - - return [path for filename, path in results] - - def find_similar_faces(self, face_id: int = None, tolerance: float = 0.6, include_same_photo: bool = False) -> List[Dict]: - """Find similar faces across all photos with improved multi-encoding and quality scoring""" - with self.get_db_connection() as conn: - cursor = conn.cursor() - - if face_id: - # Find faces similar to a specific face - cursor.execute(''' - SELECT id, photo_id, encoding, location, quality_score - FROM faces - WHERE id = ? - ''', (face_id,)) - target_face = cursor.fetchone() - - if not target_face: - print(f"āŒ Face ID {face_id} not found") - return [] - - target_encoding = self._get_cached_face_encoding(face_id, target_face[2]) - target_quality = target_face[4] if len(target_face) > 4 else 0.5 - - # Get all other faces with quality scores - cursor.execute(''' - SELECT f.id, f.photo_id, f.encoding, f.location, p.filename, f.person_id, f.quality_score - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.id != ? AND f.quality_score >= 0.2 - ''', (face_id,)) - - else: - # Find all unidentified faces and try to match them with identified ones - cursor.execute(''' - SELECT f.id, f.photo_id, f.encoding, f.location, p.filename, f.person_id, f.quality_score - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.quality_score >= 0.2 - ORDER BY f.quality_score DESC, f.id - ''') - - all_faces = cursor.fetchall() - matches = [] - - if face_id: - # Compare target face with all other faces using adaptive tolerance - for face_data in all_faces: - other_id, other_photo_id, other_encoding, other_location, other_filename, other_person_id, other_quality = face_data - other_enc = self._get_cached_face_encoding(other_id, other_encoding) - - # Calculate adaptive tolerance based on both face qualities - 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: - matches.append({ - 'face_id': other_id, - 'photo_id': other_photo_id, - 'filename': other_filename, - 'location': other_location, - 'distance': distance, - 'person_id': other_person_id, - 'quality_score': other_quality, - 'adaptive_tolerance': adaptive_tolerance - }) - - # Get target photo info - cursor.execute('SELECT filename FROM photos WHERE id = ?', (target_face[1],)) - result = cursor.fetchone() - target_filename = result[0] if result else "Unknown" - - print(f"\nšŸ” Finding faces similar to face in: {target_filename}") - print(f"šŸ“ Target face location: {target_face[3]}") - - else: - # Auto-match unidentified faces with identified ones using multi-encoding - identified_faces = [f for f in all_faces if f[5] is not None] # person_id is not None - unidentified_faces = [f for f in all_faces if f[5] 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 (simplified for now) - person_encodings = {} - for id_face in identified_faces: - person_id = id_face[5] - if person_id not in person_encodings: - # Use single encoding per person for now (simplified) - id_enc = self._get_cached_face_encoding(id_face[0], id_face[2]) - person_encodings[person_id] = [(id_enc, id_face[6])] - - for unid_face in unidentified_faces: - unid_id, unid_photo_id, unid_encoding, unid_location, unid_filename, _, 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] - - # Skip if same photo (unless specifically requested for twins detection) - # Note: Same photo check is simplified for performance - if not include_same_photo: - # For now, we'll skip this check to avoid performance issues - # TODO: Implement efficient same-photo checking - pass - - if distance <= adaptive_tolerance and distance < best_distance: - best_distance = distance - best_person_id = person_id - - # Get the best matching face info for this person - cursor.execute(''' - SELECT f.id, f.photo_id, f.location, p.filename - FROM faces f - JOIN photos p ON f.photo_id = p.id - WHERE f.person_id = ? AND f.quality_score >= ? - ORDER BY f.quality_score DESC - LIMIT 1 - ''', (person_id, 0.3)) - - best_face_info = cursor.fetchone() - if best_face_info: - best_match = { - 'unidentified_id': unid_id, - 'unidentified_photo_id': unid_photo_id, - 'unidentified_filename': unid_filename, - 'unidentified_location': unid_location, - 'matched_id': best_face_info[0], - 'matched_photo_id': best_face_info[1], - 'matched_filename': best_face_info[3], - 'matched_location': best_face_info[2], - 'person_id': person_id, - 'distance': distance, - 'quality_score': unid_quality, - 'adaptive_tolerance': adaptive_tolerance - } - - if best_match: - matches.append(best_match) - - return matches - - def auto_identify_matches(self, tolerance: float = 0.6, confirm: bool = True, show_faces: bool = False, include_same_photo: bool = False) -> int: - """Automatically identify faces that match already identified faces using GUI""" - # Get all identified faces (one per person) to use as reference faces - with self.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: - print("šŸ” No identified faces found for auto-matching") - return 0 - - # 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 - # Order by person name for user-friendly consistent results across runs - person_faces_list = [] - for person_id, face in person_faces.items(): - # Get person name for ordering - with self.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) - - print(f"\nšŸŽÆ Found {len(person_faces)} identified people to match against") - print("šŸ“Š Confidence Guide: 🟢80%+ = Very High, 🟔70%+ = High, 🟠60%+ = Medium, šŸ”“50%+ = Low, ⚫<50% = Very Low") - - # Find similar faces for each identified person using face-to-face comparison - 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._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: - # Convert to auto-match format - 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) - - matches_by_matched[person_id] = person_matches - - # Flatten all matches for counting - all_matches = [] - for person_matches in matches_by_matched.values(): - all_matches.extend(person_matches) - - if not all_matches: - print("šŸ” No similar faces found for auto-identification") - return 0 - - print(f"\nšŸŽÆ Found {len(all_matches)} potential matches") - - # Pre-fetch all needed data to avoid repeated database queries in update_display - print("šŸ“Š Pre-fetching data for optimal performance...") - data_cache = {} - - with self.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()} - - print(f"āœ… Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths") - - identified_count = 0 - - # Use integrated GUI for auto-matching - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import json - import os - - # Create the main window - root = tk.Tk() - root.title("Auto-Match Face Identification") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Clean up face crops and caches - self._cleanup_face_crops() - self.close_db_connection() - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving with larger default size - saved_size = self._setup_window_size_saving(root, "gui_config.json") - # Override with larger size for auto-match window - root.geometry("1000x700") - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=1) - - # Left side - identified person - left_frame = ttk.LabelFrame(main_frame, text="Identified person", padding="10") - left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) - - # Right side - unidentified faces that match this person - right_frame = ttk.LabelFrame(main_frame, text="Unidentified Faces to Match", padding="10") - right_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) - - # Configure row weights - main_frame.rowconfigure(0, weight=1) - - # Check if there's only one person - if so, disable search functionality - # Use matched_ids instead of person_faces_list since we only show people with potential matches - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - has_only_one_person = len(matched_ids) == 1 - print(f"DEBUG: person_faces_list length: {len(person_faces_list)}, matched_ids length: {len(matched_ids)}, has_only_one_person: {has_only_one_person}") - - # Search controls for filtering people by last name - last_name_search_var = tk.StringVar() - # Search field with label underneath (like modifyidentified edit section) - search_frame = ttk.Frame(left_frame) - search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - - # Search input on the left - search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20) - search_entry.grid(row=0, column=0, sticky=tk.W) - - # Buttons on the right of the search input - 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) - search_btn.pack(side=tk.LEFT, padx=(0, 5)) - clear_btn = ttk.Button(buttons_row, text="Clear", width=6) - clear_btn.pack(side=tk.LEFT) - - # Helper label directly under the search input - if has_only_one_person: - print("DEBUG: Disabling search functionality - only one person found") - # Disable search functionality if there's only one person - search_entry.config(state='disabled') - search_btn.config(state='disabled') - clear_btn.config(state='disabled') - # Add a label to explain why search is disabled - disabled_label = ttk.Label(search_frame, text="(Search disabled - only one person found)", - font=("Arial", 8), foreground="gray") - disabled_label.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(2, 0)) - else: - print("DEBUG: Search functionality enabled - multiple people found") - # Normal helper label when search is enabled - 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)) - - # Matched person info - matched_info_label = ttk.Label(left_frame, text="", font=("Arial", 10, "bold")) - matched_info_label.grid(row=2, column=0, pady=(0, 10), sticky=tk.W) - - # Matched person image - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matched_canvas = tk.Canvas(left_frame, width=300, height=300, bg=canvas_bg_color, highlightthickness=0) - matched_canvas.grid(row=3, column=0, pady=(0, 10)) - - # Save button for this person (will be created after function definitions) - save_btn = None - - # Matches scrollable frame - matches_frame = ttk.Frame(right_frame) - matches_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Control buttons for matches (Select All / Clear All) - matches_controls_frame = ttk.Frame(matches_frame) - matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) - - def select_all_matches(): - """Select all match checkboxes""" - for var in match_vars: - var.set(True) - - def clear_all_matches(): - """Clear all match checkboxes""" - for var in match_vars: - var.set(False) - - select_all_matches_btn = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", command=select_all_matches) - select_all_matches_btn.pack(side=tk.LEFT, padx=(0, 5)) - - clear_all_matches_btn = ttk.Button(matches_controls_frame, text="☐ Clear All", command=clear_all_matches) - clear_all_matches_btn.pack(side=tk.LEFT) - - def update_match_control_buttons_state(): - """Enable/disable Select All / Clear All based on matches presence""" - if match_vars: - select_all_matches_btn.config(state='normal') - clear_all_matches_btn.config(state='normal') - else: - select_all_matches_btn.config(state='disabled') - clear_all_matches_btn.config(state='disabled') - - # Create scrollbar for matches - scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=None) - scrollbar.grid(row=0, column=1, rowspan=1, sticky=(tk.N, tk.S)) - - # Create canvas for matches with scrollbar - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - matches_canvas = tk.Canvas(matches_frame, yscrollcommand=scrollbar.set, bg=canvas_bg_color, highlightthickness=0) - matches_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - scrollbar.config(command=matches_canvas.yview) - - # Configure grid weights - right_frame.columnconfigure(0, weight=1) - right_frame.rowconfigure(0, weight=1) - matches_frame.columnconfigure(0, weight=1) - matches_frame.rowconfigure(0, weight=0) # Controls row takes minimal space - matches_frame.rowconfigure(1, weight=1) # Canvas row expandable - - # Control buttons (navigation only) - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0)) - - # Button commands - current_matched_index = 0 - matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] - filtered_matched_ids = None # filtered subset based on last name search - - match_checkboxes = [] - match_vars = [] - identified_faces_per_person = {} # Track which faces were identified for each person - checkbox_states_per_person = {} # Track checkbox states for each person (unsaved selections) - original_checkbox_states_per_person = {} # Track original/default checkbox states for comparison - - def on_confirm_matches(): - nonlocal identified_count, current_matched_index, identified_faces_per_person - if current_matched_index < len(matched_ids): - matched_id = matched_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Initialize identified faces for this person if not exists - if matched_id not in identified_faces_per_person: - identified_faces_per_person[matched_id] = set() - - with self.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, 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 instead of database query - person_details = data_cache['person_details'].get(match['person_id'], {}) - person_name = person_details.get('full_name', "Unknown") - - # Track this face as identified for this person - identified_faces_per_person[matched_id].add(match['unidentified_id']) - - print(f"āœ… Identified as: {person_name}") - identified_count += 1 - else: - # Face is unchecked - check if it was previously identified for this person - if match['unidentified_id'] in 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 - identified_faces_per_person[matched_id].discard(match['unidentified_id']) - - print(f"āŒ Unidentified: {match['unidentified_filename']}") - - # Update person encodings for all affected persons after database transaction is complete - for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']): - self._update_person_encodings(person_id) - - # After saving, set original states to the current UI states so there are no unsaved changes - current_snapshot = {} - for match, var in zip(matches_for_this_person, match_vars): - unique_key = f"{matched_id}_{match['unidentified_id']}" - current_snapshot[unique_key] = var.get() - checkbox_states_per_person[matched_id] = dict(current_snapshot) - original_checkbox_states_per_person[matched_id] = dict(current_snapshot) - - def on_skip_current(): - nonlocal current_matched_index - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index += 1 - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - update_display() - else: - finish_auto_match() - - def on_go_back(): - nonlocal current_matched_index - if current_matched_index > 0: - # Save current checkbox states before navigating away - save_current_checkbox_states() - current_matched_index -= 1 - update_display() - - def has_unsaved_changes(): - """Check if there are any unsaved changes by comparing current states with original states""" - for person_id, current_states in checkbox_states_per_person.items(): - if person_id in original_checkbox_states_per_person: - original_states = 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 apply_last_name_filter(): - """Filter people by last name and update navigation""" - nonlocal filtered_matched_ids, current_matched_index - query = last_name_search_var.get().strip().lower() - if query: - # Filter person_faces_list by last name - filtered_people = [] - for person_id, face, person_name in person_faces_list: - # Extract last name from person_name (format: "Last, First") - if ',' in person_name: - last_name = person_name.split(',')[0].strip().lower() - else: - last_name = person_name.strip().lower() - - if query in last_name: - filtered_people.append((person_id, face, person_name)) - - # Get filtered matched_ids - filtered_matched_ids = [person_id for person_id, _, _ in filtered_people if person_id in matches_by_matched and matches_by_matched[person_id]] - else: - filtered_matched_ids = None - - # Reset to first person in filtered list - current_matched_index = 0 - if filtered_matched_ids: - update_display() - else: - # No matches - clear display - matched_info_label.config(text="No people match filter") - matched_canvas.delete("all") - matched_canvas.create_text(150, 150, text="No matches found", fill="gray") - matches_canvas.delete("all") - update_button_states() - - def clear_last_name_filter(): - """Clear filter and show all people""" - nonlocal filtered_matched_ids, current_matched_index - last_name_search_var.set("") - filtered_matched_ids = None - current_matched_index = 0 - update_display() - - def on_quit_auto_match(): - nonlocal window_destroyed - - # Check for unsaved changes before quitting - if has_unsaved_changes(): - # Show warning dialog with custom width - from tkinter import messagebox - - # Create a custom dialog for better width control - dialog = tk.Toplevel(root) - dialog.title("Unsaved Changes") - dialog.geometry("500x250") - dialog.resizable(True, True) - dialog.transient(root) - dialog.grab_set() - - # Center the dialog - dialog.geometry("+%d+%d" % (root.winfo_rootx() + 50, root.winfo_rooty() + 50)) - - # Main message - message_frame = ttk.Frame(dialog, padding="20") - message_frame.pack(fill=tk.BOTH, expand=True) - - # Warning icon and text - icon_label = ttk.Label(message_frame, text="āš ļø", font=("Arial", 16)) - icon_label.pack(anchor=tk.W) - - main_text = ttk.Label(message_frame, - text="You have unsaved changes that will be lost if you quit.", - font=("Arial", 10)) - main_text.pack(anchor=tk.W, pady=(5, 10)) - - # Options - options_text = ttk.Label(message_frame, - text="• Yes: Save current changes and quit\n" - "• No: Quit without saving\n" - "• Cancel: Return to auto-match", - font=("Arial", 9)) - options_text.pack(anchor=tk.W, pady=(0, 10)) - - - # Buttons - button_frame = ttk.Frame(dialog) - button_frame.pack(fill=tk.X, padx=20, pady=(0, 20)) - - result = None - - def on_yes(): - nonlocal result - result = True - dialog.destroy() - - def on_no(): - nonlocal result - result = False - dialog.destroy() - - def on_cancel(): - nonlocal result - result = None - dialog.destroy() - - yes_btn = ttk.Button(button_frame, text="Yes", command=on_yes) - no_btn = ttk.Button(button_frame, text="No", command=on_no) - cancel_btn = ttk.Button(button_frame, text="Cancel", command=on_cancel) - - yes_btn.pack(side=tk.LEFT, padx=(0, 5)) - no_btn.pack(side=tk.LEFT, padx=5) - cancel_btn.pack(side=tk.RIGHT, padx=(5, 0)) - - # Wait for dialog to close - dialog.wait_window() - - if result is None: # Cancel - don't quit - return - elif result: # Yes - save changes first - # Save current checkbox states before quitting - save_current_checkbox_states() - # Note: We don't actually save to database here, just preserve the states - # The user would need to click Save button for each person to persist changes - print("āš ļø Warning: Changes are preserved but not saved to database.") - print(" Click 'Save Changes' button for each person to persist changes.") - - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - def finish_auto_match(): - nonlocal window_destroyed - print(f"\nāœ… Auto-identified {identified_count} faces") - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - # Create button references for state management - back_btn = ttk.Button(control_frame, text="ā®ļø Back", command=on_go_back) - next_btn = ttk.Button(control_frame, text="ā­ļø Next", command=on_skip_current) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit_auto_match) - - back_btn.grid(row=0, column=0, padx=(0, 5)) - next_btn.grid(row=0, column=1, padx=5) - quit_btn.grid(row=0, column=2, padx=(5, 0)) - - # Create save button now that functions are defined - save_btn = ttk.Button(left_frame, text="šŸ’¾ Save Changes", command=on_confirm_matches) - save_btn.grid(row=4, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) - - def update_button_states(): - """Update button states based on current position""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - # Enable/disable Back button based on position - if current_matched_index > 0: - back_btn.config(state='normal') - else: - back_btn.config(state='disabled') - - # Enable/disable Next button based on position - if current_matched_index < len(active_ids) - 1: - next_btn.config(state='normal') - else: - next_btn.config(state='disabled') - - def update_save_button_text(): - """Update save button text with current person name""" - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index < len(active_ids): - matched_id = active_ids[current_matched_index] - # Get person name from the first match for this person - matches_for_current_person = matches_by_matched[matched_id] - if matches_for_current_person: - person_id = matches_for_current_person[0]['person_id'] - # Use cached person name instead of database query - person_details = data_cache['person_details'].get(person_id, {}) - person_name = person_details.get('full_name', "Unknown") - save_btn.config(text=f"šŸ’¾ Save changes for {person_name}") - else: - save_btn.config(text="šŸ’¾ Save Changes") - else: - save_btn.config(text="šŸ’¾ Save Changes") - - def save_current_checkbox_states(): - """Save current checkbox states for the current person. - Note: Do NOT modify original states here to avoid false positives - when a user toggles and reverts a checkbox. - """ - if current_matched_index < len(matched_ids) and match_vars: - current_matched_id = matched_ids[current_matched_index] - matches_for_current_person = matches_by_matched[current_matched_id] - - if len(match_vars) == len(matches_for_current_person): - if current_matched_id not in checkbox_states_per_person: - checkbox_states_per_person[current_matched_id] = {} - - # Save current checkbox states for this person - for i, (match, var) in enumerate(zip(matches_for_current_person, match_vars)): - unique_key = f"{current_matched_id}_{match['unidentified_id']}" - current_value = var.get() - checkbox_states_per_person[current_matched_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Saved state for person {current_matched_id}, face {match['unidentified_id']}: {current_value}") - - def update_display(): - nonlocal current_matched_index - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - if current_matched_index >= len(active_ids): - finish_auto_match() - return - - matched_id = active_ids[current_matched_index] - matches_for_this_person = matches_by_matched[matched_id] - - # Update button states - update_button_states() - - # Update save button text with person name - update_save_button_text() - - # Update title - active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids - root.title(f"Auto-Match Face Identification - {current_matched_index + 1}/{len(active_ids)}") - - # 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}") - # No items on the right panel – disable Select All / Clear All - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - # Skip to next person if available - if current_matched_index < len(matched_ids) - 1: - current_matched_index += 1 - update_display() - else: - finish_auto_match() - return - - first_match = matches_for_this_person[0] - - # Use cached data instead of database queries - person_details = 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 = 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 - matched_info_label.config(text="\n".join(person_info_lines)) - - # Display matched person face - matched_canvas.delete("all") - if show_faces: - matched_crop_path = self._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) - matched_canvas.create_image(150, 150, image=photo) - matched_canvas.image = photo - - # Add photo icon to the matched person face - exactly in corner - # Use actual image dimensions instead of assuming 300x300 - actual_width, actual_height = pil_image.size - self._create_photo_icon(matched_canvas, matched_photo_path, icon_size=20, - face_x=150, face_y=150, - face_width=actual_width, face_height=actual_height, - canvas_width=300, canvas_height=300) - except Exception as e: - matched_canvas.create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") - else: - matched_canvas.create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") - - # Clear and populate unidentified faces - matches_canvas.delete("all") - match_checkboxes.clear() - match_vars.clear() - update_match_control_buttons_state() - - # Create frame for unidentified faces inside canvas - matches_inner_frame = ttk.Frame(matches_canvas) - matches_canvas.create_window((0, 0), window=matches_inner_frame, anchor="nw") - - # Use cached photo paths instead of database queries - photo_paths = 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._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 checkbox_states_per_person and unique_key in checkbox_states_per_person[matched_id]: - saved_state = checkbox_states_per_person[matched_id][unique_key] - match_var.set(saved_state) - if self.verbose >= 2: - print(f"DEBUG: Restored state for person {matched_id}, face {match['unidentified_id']}: {saved_state}") - # Otherwise, pre-select if this face was previously identified for this person - elif matched_id in identified_faces_per_person and match['unidentified_id'] in identified_faces_per_person[matched_id]: - match_var.set(True) - if self.verbose >= 2: - print(f"DEBUG: Pre-selected identified face for person {matched_id}, face {match['unidentified_id']}") - - match_vars.append(match_var) - - # Capture original state at render time (once per person per face) - if matched_id not in original_checkbox_states_per_person: - original_checkbox_states_per_person[matched_id] = {} - if unique_key not in original_checkbox_states_per_person[matched_id]: - 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 checkbox_states_per_person: - checkbox_states_per_person[person_id] = {} - - current_value = var.get() - checkbox_states_per_person[person_id][unique_key] = current_value - - if self.verbose >= 2: - print(f"DEBUG: Checkbox changed for person {person_id}, face {face_id}: {current_value}") - - # Bind the callback to the variable - match_var.trace('w', lambda *args: on_checkbox_change(match_var, matched_id, match['unidentified_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)) - match_checkboxes.append(checkbox) - - # 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"šŸ“ {match['unidentified_filename']}", font=("Arial", 8), foreground="gray") - filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10)) - - # Unidentified face image - if show_faces: - 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=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0)) - - unidentified_crop_path = self._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 to the unidentified face - self._create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, - face_x=50, face_y=50, - face_width=100, face_height=100, - canvas_width=100, canvas_height=100) - except Exception as e: - match_canvas.create_text(50, 50, text="āŒ", fill="red") - else: - match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") - - # Update Select All / Clear All button states after populating - update_match_control_buttons_state() - - # Update scroll region - matches_canvas.update_idletasks() - matches_canvas.configure(scrollregion=matches_canvas.bbox("all")) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Wire up search controls now that helper functions exist - try: - search_btn.config(command=lambda: apply_last_name_filter()) - clear_btn.config(command=lambda: clear_last_name_filter()) - search_entry.bind('', lambda e: apply_last_name_filter()) - except Exception: - pass - - # Start with first matched person - update_display() - - # Main event loop - try: - root.mainloop() - except tk.TclError: - pass # Window was destroyed - - return identified_count - - def tag_management(self) -> int: - """Tag management GUI - file explorer-like interface for managing photo tags""" - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import os - - # Create the main window - root = tk.Tk() - root.title("Tag Management - Photo Explorer") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - temp_crops = [] - photo_images = [] # Keep PhotoImage refs alive - - # Track folder expand/collapse states - folder_states = {} # folder_path -> is_expanded - - # Track pending tag changes (photo_id -> list of tag IDs) - pending_tag_changes = {} - # Track pending tag removals (photo_id -> list of tag IDs to remove) - pending_tag_removals = {} - existing_tags = [] # Cache of existing tag names from database (for UI display) - tag_id_to_name = {} # Cache of tag ID to name mapping - tag_name_to_id = {} # Cache of tag name to ID mapping - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self._setup_window_size_saving(root) - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.rowconfigure(1, weight=1) - main_frame.rowconfigure(2, weight=0) - - # Title and controls frame - header_frame = ttk.Frame(main_frame) - header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) - header_frame.columnconfigure(1, weight=1) - - # Title label - title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, sticky=tk.W) - - # View mode controls - view_frame = ttk.Frame(header_frame) - view_frame.grid(row=0, column=1, sticky=tk.E) - - view_mode_var = tk.StringVar(value="list") - ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list", - command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons", - command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5)) - ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact", - command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT) - - # Manage Tags button - def open_manage_tags_dialog(): - """Open a dialog to manage tags: list, edit, add, and delete.""" - import tkinter as tk - from tkinter import ttk, messagebox, simpledialog - - # Dialog window - dialog = tk.Toplevel(root) - dialog.title("Manage Tags") - dialog.transient(root) - dialog.grab_set() - dialog.geometry("500x500") - - # Layout frames - top_frame = ttk.Frame(dialog, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(dialog, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(dialog, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - - dialog.columnconfigure(0, weight=1) - dialog.rowconfigure(1, weight=1) - - # Add tag controls (top) - new_tag_var = tk.StringVar() - new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30) - new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E)) - - def add_new_tag(): - tag_name = new_tag_var.get().strip() - if not tag_name: - return - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - conn.commit() - new_tag_var.set("") - refresh_tag_list() - load_existing_tags() - # Refresh main view to reflect new tag options - switch_view_mode(view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to add tag: {e}") - - add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag) - add_btn.grid(row=0, column=1, sticky=tk.W) - top_frame.columnconfigure(0, weight=1) - - # Scrollable tag list (center) - canvas = tk.Canvas(list_frame, highlightthickness=0) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - rows_container = ttk.Frame(canvas) - canvas.create_window((0, 0), window=rows_container, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - list_frame.columnconfigure(0, weight=1) - list_frame.rowconfigure(0, weight=1) - - rows_container.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - - # Selection tracking - selected_tag_vars = {} - current_tags = [] # list of dicts: {id, tag_name} - - def refresh_tag_list(): - # Clear rows - for child in list(rows_container.winfo_children()): - child.destroy() - selected_tag_vars.clear() - current_tags.clear() - # Load tags - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE') - for row in cursor.fetchall(): - current_tags.append({'id': row[0], 'tag_name': row[1]}) - except Exception as e: - messagebox.showerror("Error", f"Failed to load tags: {e}") - return - # Build header - head = ttk.Frame(rows_container) - head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6)) - chk_lbl = ttk.Label(head, text="Delete") - chk_lbl.pack(side=tk.LEFT, padx=(0, 10)) - name_lbl = ttk.Label(head, text="Tag name", width=30) - name_lbl.pack(side=tk.LEFT) - act_lbl = ttk.Label(head, text="Edit", width=6) - act_lbl.pack(side=tk.LEFT, padx=(10, 0)) - - # Populate rows - for idx, tag in enumerate(current_tags, start=1): - row = ttk.Frame(rows_container) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2) - var = tk.BooleanVar(value=False) - selected_tag_vars[tag['id']] = var - chk = ttk.Checkbutton(row, variable=var) - chk.pack(side=tk.LEFT, padx=(0, 10)) - name = ttk.Label(row, text=tag['tag_name'], width=30) - name.pack(side=tk.LEFT) - - def make_edit_handler(tag_id, name_label): - def handler(): - new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_label.cget('text'), parent=dialog) - if new_name is None: - return - new_name = new_name.strip() - if not new_name: - return - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Ensure name is unique - cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id)) - conn.commit() - except Exception as e: - messagebox.showerror("Error", f"Failed to rename tag: {e}") - return - # Update UI and caches - refresh_tag_list() - load_existing_tags() - switch_view_mode(view_mode_var.get()) - return handler - - edit_btn = ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name)) - edit_btn.pack(side=tk.LEFT, padx=(10, 0)) - - refresh_tag_list() - - # Bottom buttons - def delete_selected(): - ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()] - if not ids_to_delete: - return - if not messagebox.askyesno("Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos."): - return - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - # Remove linkages first to maintain integrity - cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - # Delete tags - cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) - conn.commit() - - # Clean up pending tag changes for deleted tags - for photo_id in list(pending_tag_changes.keys()): - pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in ids_to_delete] - if not pending_tag_changes[photo_id]: - del pending_tag_changes[photo_id] - - # Clean up pending tag removals for deleted tags - for photo_id in list(pending_tag_removals.keys()): - pending_tag_removals[photo_id] = [tid for tid in pending_tag_removals[photo_id] if tid not in ids_to_delete] - if not pending_tag_removals[photo_id]: - del pending_tag_removals[photo_id] - - refresh_tag_list() - load_existing_tags() - load_photos() # Refresh photo data to reflect deleted tags - switch_view_mode(view_mode_var.get()) - except Exception as e: - messagebox.showerror("Error", f"Failed to delete tags: {e}") - - delete_btn = ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected) - delete_btn.pack(side=tk.LEFT) - quit_btn = ttk.Button(bottom_frame, text="Quit", command=dialog.destroy) - quit_btn.pack(side=tk.RIGHT) - - # Keyboard focus - new_tag_entry.focus_set() - - manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog) - manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0)) - - # Main content area - content_frame = ttk.Frame(main_frame) - content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - content_frame.columnconfigure(0, weight=1) - content_frame.rowconfigure(0, weight=1) - - # Style for consistent gray background - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - - # Create canvas and scrollbar for content - content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0) - content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview) - content_inner = ttk.Frame(content_canvas) - content_canvas.create_window((0, 0), window=content_inner, anchor="nw") - content_canvas.configure(yscrollcommand=content_scrollbar.set) - - content_inner.bind( - "", - lambda e: content_canvas.configure(scrollregion=content_canvas.bbox("all")) - ) - - content_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - content_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - - # Bottom frame for save button - bottom_frame = ttk.Frame(main_frame) - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) - - # Save tagging button (function will be defined later) - save_button = ttk.Button(bottom_frame, text="Save Tagging") - save_button.pack(side=tk.RIGHT, padx=10, pady=5) - - # Quit button with warning for pending changes - def quit_with_warning(): - """Quit the dialog, but warn if there are pending changes""" - # Check for pending changes - has_pending_changes = bool(pending_tag_changes or pending_tag_removals) - - if has_pending_changes: - total_additions = sum(len(tags) for tags in pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in pending_tag_removals.values()) - - changes_text = [] - if total_additions > 0: - changes_text.append(f"{total_additions} tag addition(s)") - if total_removals > 0: - changes_text.append(f"{total_removals} tag removal(s)") - - changes_summary = " and ".join(changes_text) - - result = messagebox.askyesnocancel( - "Unsaved Changes", - f"You have unsaved changes: {changes_summary}.\n\n" - "Do you want to save your changes before quitting?\n\n" - "Yes = Save and quit\n" - "No = Quit without saving\n" - "Cancel = Stay in dialog" - ) - - if result is True: # Yes - Save and quit - save_tagging_changes() - root.destroy() - elif result is False: # No - Quit without saving - root.destroy() - # If result is None (Cancel), do nothing - stay in dialog - else: - # No pending changes, just quit - root.destroy() - - quit_button = ttk.Button(bottom_frame, text="Quit", command=quit_with_warning) - quit_button.pack(side=tk.RIGHT, padx=(0, 10), pady=5) - - # Enable mouse scroll anywhere in the dialog - def on_mousewheel(event): - content_canvas.yview_scroll(int(-1*(event.delta/120)), "units") - - # Column resizing variables - resize_start_x = 0 - resize_start_widths = [] - current_visible_cols = [] - is_resizing = False - - def start_resize(event, col_idx): - """Start column resizing""" - nonlocal resize_start_x, resize_start_widths, is_resizing - print(f"DEBUG: start_resize called for column {col_idx}, event.x_root={event.x_root}") # Debug output - is_resizing = True - resize_start_x = event.x_root - # Store current column widths - resize_start_widths = [] - for i, col in enumerate(current_visible_cols): - resize_start_widths.append(col['width']) - print(f"DEBUG: Stored widths: {resize_start_widths}") # Debug output - # Change cursor globally - root.configure(cursor="sb_h_double_arrow") - - def do_resize(event, col_idx): - """Perform column resizing""" - nonlocal resize_start_x, resize_start_widths, is_resizing - print(f"DEBUG: do_resize called for column {col_idx}, is_resizing={is_resizing}") # Debug output - if not is_resizing or not resize_start_widths or not current_visible_cols: - return - - # Calculate width change - delta_x = event.x_root - resize_start_x - - # Update column widths - if col_idx < len(current_visible_cols) and col_idx + 1 < len(current_visible_cols): - # Resize current and next column - new_width_left = max(50, resize_start_widths[col_idx] + delta_x) - new_width_right = max(50, resize_start_widths[col_idx + 1] - delta_x) - - # Update column configuration - current_visible_cols[col_idx]['width'] = new_width_left - current_visible_cols[col_idx + 1]['width'] = new_width_right - - # Update the actual column configuration in the global config - for i, col in enumerate(column_config['list']): - if col['key'] == current_visible_cols[col_idx]['key']: - column_config['list'][i]['width'] = new_width_left - elif col['key'] == current_visible_cols[col_idx + 1]['key']: - column_config['list'][i]['width'] = new_width_right - - # Force immediate visual update by reconfiguring grid weights - try: - header_frame_ref = None - row_frames = [] - for widget in content_inner.winfo_children(): - # First frame is header, subsequent frames are data rows - if isinstance(widget, ttk.Frame): - if header_frame_ref is None: - header_frame_ref = widget - else: - row_frames.append(widget) - - # Update header columns (accounting for separator columns) - if header_frame_ref is not None: - # Update both minsize and weight to force resize - header_frame_ref.columnconfigure(col_idx*2, - weight=current_visible_cols[col_idx]['weight'], - minsize=new_width_left) - header_frame_ref.columnconfigure((col_idx+1)*2, - weight=current_visible_cols[col_idx+1]['weight'], - minsize=new_width_right) - print(f"DEBUG: Updated header columns {col_idx*2} and {(col_idx+1)*2} with widths {new_width_left} and {new_width_right}") - - # Update each data row frame columns (no separators, direct indices) - for rf in row_frames: - rf.columnconfigure(col_idx, - weight=current_visible_cols[col_idx]['weight'], - minsize=new_width_left) - rf.columnconfigure(col_idx+1, - weight=current_visible_cols[col_idx+1]['weight'], - minsize=new_width_right) - - # Force update of the display - root.update_idletasks() - - except Exception as e: - print(f"DEBUG: Error during resize update: {e}") # Debug output - pass # Ignore errors during resize - - def stop_resize(event): - """Stop column resizing""" - nonlocal is_resizing - if is_resizing: - print(f"DEBUG: stop_resize called, was resizing={is_resizing}") # Debug output - is_resizing = False - root.configure(cursor="") - - # Bind mouse wheel to the entire window - root.bind_all("", on_mousewheel) - - # Global mouse release handler that only stops resize if we're actually resizing - def global_mouse_release(event): - if is_resizing: - stop_resize(event) - root.bind_all("", global_mouse_release) - - # Unbind when window is destroyed - def cleanup_mousewheel(): - try: - root.unbind_all("") - root.unbind_all("") - except: - pass - - root.bind("", lambda e: cleanup_mousewheel()) - - # Load photos from database - photos_data = [] - - # Column visibility state - column_visibility = { - 'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, - 'compact': {'filename': True, 'faces': True, 'tags': True} - } - - # Column order and configuration - column_config = { - 'list': [ - {'key': 'id', 'label': 'ID', 'width': 50, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 150, 'weight': 1}, - {'key': 'path', 'label': 'Path', 'width': 200, 'weight': 2}, - {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ], - 'icons': [ - {'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0}, - {'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0}, - {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, - {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, - {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ], - 'compact': [ - {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, - {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, - {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} - ] - } - - def load_photos(): - nonlocal photos_data - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added, - COUNT(f.id) as face_count, - GROUP_CONCAT(DISTINCT t.tag_name) as tags - FROM photos p - LEFT JOIN faces f ON f.photo_id = p.id - LEFT JOIN phototaglinkage ptl ON ptl.photo_id = p.id - LEFT JOIN tags t ON t.id = ptl.tag_id - GROUP BY p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added - ORDER BY p.date_taken DESC, p.filename - ''') - photos_data = [] - for row in cursor.fetchall(): - photos_data.append({ - 'id': row[0], - 'filename': row[1], - 'path': row[2], - 'processed': row[3], - 'date_taken': row[4], - 'date_added': row[5], - 'face_count': row[6] or 0, - 'tags': row[7] or "" - }) - - def prepare_folder_grouped_data(): - """Prepare photo data grouped by folders""" - import os - from collections import defaultdict - - # Group photos by folder - folder_groups = defaultdict(list) - for photo in photos_data: - folder_path = os.path.dirname(photo['path']) - folder_name = os.path.basename(folder_path) if folder_path else "Root" - folder_groups[folder_path].append(photo) - - # Sort folders by path and photos within each folder by date_taken - sorted_folders = [] - for folder_path in sorted(folder_groups.keys()): - folder_name = os.path.basename(folder_path) if folder_path else "Root" - photos_in_folder = sorted(folder_groups[folder_path], - key=lambda x: x['date_taken'] or '', reverse=True) - - # Initialize folder state if not exists (default to expanded) - if folder_path not in folder_states: - folder_states[folder_path] = True - - sorted_folders.append({ - 'folder_path': folder_path, - 'folder_name': folder_name, - 'photos': photos_in_folder, - 'photo_count': len(photos_in_folder) - }) - - return sorted_folders - - def create_folder_header(parent, folder_info, current_row, col_count, view_mode): - """Create a collapsible folder header with toggle button""" - # Create folder header frame - folder_header_frame = ttk.Frame(parent) - folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5)) - folder_header_frame.configure(relief='raised', borderwidth=1) - - # Create toggle button - is_expanded = folder_states.get(folder_info['folder_path'], True) - toggle_text = "ā–¼" if is_expanded else "ā–¶" - toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1, - command=lambda: toggle_folder(folder_info['folder_path'], view_mode), - font=("Arial", 8), relief='flat', bd=1) - toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5) - - # Create folder label - folder_label = ttk.Label(folder_header_frame, - text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos)", - font=("Arial", 11, "bold")) - folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5) - - return folder_header_frame - - def toggle_folder(folder_path, view_mode): - """Toggle folder expand/collapse state and refresh view""" - folder_states[folder_path] = not folder_states.get(folder_path, True) - switch_view_mode(view_mode) - - def load_existing_tags(): - """Load existing tags from database""" - nonlocal existing_tags, tag_id_to_name, tag_name_to_id - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') - existing_tags = [] - tag_id_to_name = {} - tag_name_to_id = {} - for row in cursor.fetchall(): - tag_id, tag_name = row - existing_tags.append(tag_name) - tag_id_to_name[tag_id] = tag_name - tag_name_to_id[tag_name] = tag_id - - def create_tagging_widget(parent, photo_id, current_tags=""): - """Create a tagging widget with dropdown and text input""" - import tkinter as tk - from tkinter import ttk - - # Create frame for tagging widget - tagging_frame = ttk.Frame(parent) - - # Create combobox for tag selection/input - tag_var = tk.StringVar() - tag_combo = ttk.Combobox(tagging_frame, textvariable=tag_var, width=12) - tag_combo['values'] = existing_tags - tag_combo.pack(side=tk.LEFT, padx=2, pady=2) - - # Create label to show current pending tags - pending_tags_var = tk.StringVar() - pending_tags_label = ttk.Label(tagging_frame, textvariable=pending_tags_var, - font=("Arial", 8), foreground="blue", width=20) - pending_tags_label.pack(side=tk.LEFT, padx=2, pady=2) - - # Initialize pending tags display - if photo_id in pending_tag_changes: - # Convert tag IDs to names for display - pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - else: - pending_tags_var.set(current_tags or "") - - # Add button to add tag - def add_tag(): - tag_name = tag_var.get().strip() - if tag_name: - # Get or create tag ID - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] - else: - # Create new tag in database - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - tag_id = cursor.fetchone()[0] - # Update mappings - tag_name_to_id[tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - - # Check if tag already exists (compare tag IDs) before adding to pending changes - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - all_existing_tag_ids = existing_tag_ids + pending_tag_ids - - if tag_id not in all_existing_tag_ids: - # Only add to pending changes if tag is actually new - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - pending_tag_changes[photo_id].append(tag_id) - # Update display - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - tag_var.set("") # Clear the input field - - add_button = tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag) - add_button.pack(side=tk.LEFT, padx=2, pady=2) - - # Remove button to remove last tag - def remove_tag(): - if photo_id in pending_tag_changes and pending_tag_changes[photo_id]: - pending_tag_changes[photo_id].pop() - if pending_tag_changes[photo_id]: - # Convert tag IDs to names for display - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - pending_tags_var.set(", ".join(pending_tag_names)) - else: - pending_tags_var.set("") - del pending_tag_changes[photo_id] - - remove_button = tk.Button(tagging_frame, text="-", width=2, height=1, command=remove_tag) - remove_button.pack(side=tk.LEFT, padx=2, pady=2) - - return tagging_frame - - def save_tagging_changes(): - """Save all pending tag changes to database""" - if not pending_tag_changes and not pending_tag_removals: - messagebox.showinfo("Info", "No tag changes to save.") - return - - try: - with self.get_db_connection() as conn: - cursor = conn.cursor() - - # Handle tag additions - for photo_id, tag_ids in pending_tag_changes.items(): - for tag_id in tag_ids: - # Insert linkage (ignore if already exists) - cursor.execute( - 'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', - (photo_id, tag_id) - ) - - # Handle tag removals - for photo_id, tag_ids in pending_tag_removals.items(): - for tag_id in tag_ids: - # Remove linkage - cursor.execute( - 'DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', - (photo_id, tag_id) - ) - - conn.commit() - - # Store counts before clearing - saved_additions = len(pending_tag_changes) - saved_removals = len(pending_tag_removals) - - # Clear pending changes and reload data - pending_tag_changes.clear() - pending_tag_removals.clear() - load_existing_tags() - load_photos() - switch_view_mode(view_mode_var.get()) - update_save_button_text() - - message = f"Saved {saved_additions} tag additions" - if saved_removals > 0: - message += f" and {saved_removals} tag removals" - message += "." - messagebox.showinfo("Success", message) - - except Exception as e: - messagebox.showerror("Error", f"Failed to save tags: {str(e)}") - - def update_save_button_text(): - """Update save button text to show pending changes count""" - total_additions = sum(len(tags) for tags in pending_tag_changes.values()) - total_removals = sum(len(tags) for tags in pending_tag_removals.values()) - total_changes = total_additions + total_removals - - if total_changes > 0: - save_button.configure(text=f"Save Tagging ({total_changes} pending)") - else: - save_button.configure(text="Save Tagging") - - # Configure the save button command now that the function is defined - save_button.configure(command=save_tagging_changes) - - def clear_content(): - for widget in content_inner.winfo_children(): - widget.destroy() - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - photo_images.clear() - - def show_column_context_menu(event, view_mode): - """Show context menu for column visibility""" - # Create a custom popup window instead of a menu - popup = tk.Toplevel(root) - popup.wm_overrideredirect(True) - popup.wm_geometry(f"+{event.x_root}+{event.y_root}") - popup.configure(bg='white', relief='flat', bd=0) - - # Define columns that cannot be hidden - protected_columns = { - 'icons': ['thumbnail'], - 'compact': ['filename'], - 'list': ['filename'] - } - - # Create frame for menu items - menu_frame = tk.Frame(popup, bg='white') - menu_frame.pack(padx=2, pady=2) - - # Variables to track checkbox states - checkbox_vars = {} - - for col in column_config[view_mode]: - key = col['key'] - label = col['label'] - is_visible = column_visibility[view_mode][key] - is_protected = key in protected_columns.get(view_mode, []) - - # Create frame for this menu item - item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0) - item_frame.pack(fill=tk.X, pady=1) - - # Create checkbox variable - var = tk.BooleanVar(value=is_visible) - checkbox_vars[key] = var - - def make_toggle_command(col_key, var_ref): - def toggle_column(): - if col_key in protected_columns.get(view_mode, []): - return - # The checkbox has already toggled its state automatically - # Just sync it with our column visibility - column_visibility[view_mode][col_key] = var_ref.get() - # Refresh the view - switch_view_mode(view_mode) - return toggle_column - - if is_protected: - # Protected columns - disabled checkbox - cb = tk.Checkbutton(item_frame, text=label, variable=var, - state='disabled', bg='white', fg='gray', - font=("Arial", 9), relief='flat', bd=0, - highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', - font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5)) - else: - # Regular columns - clickable checkbox - cb = tk.Checkbutton(item_frame, text=label, variable=var, - command=make_toggle_command(key, var), - bg='white', font=("Arial", 9), relief='flat', bd=0, - highlightthickness=0) - cb.pack(side=tk.LEFT, padx=5, pady=2) - - # Function to close popup - def close_popup(): - try: - popup.destroy() - except: - pass - - # Bind events to close popup - def close_on_click_outside(event): - # Close popup when clicking anywhere in the main window - # Check if the click is not on the popup itself - if event.widget != popup: - try: - # Check if popup still exists - popup.winfo_exists() - # If we get here, popup exists, so close it - close_popup() - except tk.TclError: - # Popup was already destroyed, do nothing - pass - - root.bind("", close_on_click_outside) - root.bind("", close_on_click_outside) - - # Also bind to the main content area - content_canvas.bind("", close_on_click_outside) - content_canvas.bind("", close_on_click_outside) - - # Focus the popup - popup.focus_set() - - # Shared tag linking functions for all view modes - def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags): - """Create a handler function for adding tags to a photo""" - def handler(): - # Create popup window for tag management - popup = tk.Toplevel(root) - popup.title("Manage Photo Tags") - popup.transient(root) - popup.grab_set() - popup.geometry("500x400") - popup.resizable(True, True) - - # Layout frames - top_frame = ttk.Frame(popup, padding="8") - top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) - list_frame = ttk.Frame(popup, padding="8") - list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) - bottom_frame = ttk.Frame(popup, padding="8") - bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) - - popup.columnconfigure(0, weight=1) - popup.rowconfigure(1, weight=1) - - # Top frame - dropdown to select tag to add - ttk.Label(top_frame, text="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W) - tag_var = tk.StringVar() - combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly') - combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) - combo.focus_set() - - def add_selected_tag(): - tag_name = tag_var.get().strip() - if not tag_name: - return - - # Get or create tag ID - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] - else: - # Create new tag in database - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - tag_id = cursor.fetchone()[0] - # Update mappings - tag_name_to_id[tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - - # Check if tag already exists (compare tag IDs) before adding to pending changes - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - pending_tag_ids = pending_tag_changes.get(photo_id, []) - all_existing_tag_ids = existing_tag_ids + pending_tag_ids - - if tag_id not in all_existing_tag_ids: - # Only add to pending changes if tag is actually new - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] - pending_tag_changes[photo_id].append(tag_id) - refresh_tag_list() - update_save_button_text() - - tag_var.set("") # Clear the dropdown - - add_btn = ttk.Button(top_frame, text="Add", command=add_selected_tag) - add_btn.grid(row=0, column=2, padx=(0, 8)) - - # List frame - show all linked tags (existing + pending) with checkboxes - ttk.Label(list_frame, text="Linked tags (check to remove):", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=(0, 5)) - - # Create scrollable frame for tags - canvas = tk.Canvas(list_frame, height=200) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - - scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # Variables to track selected tags for removal - selected_tag_vars = {} - - def refresh_tag_list(): - # Clear existing widgets - for widget in scrollable_frame.winfo_children(): - widget.destroy() - selected_tag_vars.clear() - - # Get existing tags for this photo - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - - # Get pending tags for this photo - pending_tag_ids = pending_tag_changes.get(photo_id, []) - - # Get pending removals for this photo - pending_removal_ids = pending_tag_removals.get(photo_id, []) - - # Combine and deduplicate tag IDs, but exclude tags marked for removal - all_tag_ids = existing_tag_ids + pending_tag_ids - unique_tag_ids = list(set(all_tag_ids)) # Remove duplicates - # Remove tags that are marked for removal - unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids] - - # Convert to names for display - unique_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids] - - if not unique_tag_names: - ttk.Label(scrollable_frame, text="No tags linked to this photo", - foreground="gray").pack(anchor=tk.W, pady=5) - return - - # Create checkboxes for each tag - for i, tag_id in enumerate(unique_tag_ids): - tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}") - var = tk.BooleanVar() - selected_tag_vars[tag_name] = var - - # Determine if this is a pending tag - is_pending = tag_id in pending_tag_ids - status_text = " (pending)" if is_pending else " (saved)" - status_color = "blue" if is_pending else "black" - - frame = ttk.Frame(scrollable_frame) - frame.pack(fill=tk.X, pady=1) - - checkbox = ttk.Checkbutton(frame, variable=var) - checkbox.pack(side=tk.LEFT, padx=(0, 5)) - - label = ttk.Label(frame, text=tag_name + status_text, foreground=status_color) - label.pack(side=tk.LEFT) - - def remove_selected_tags(): - # Get tag IDs to remove (convert names to IDs) - tag_ids_to_remove = [] - for tag_name, var in selected_tag_vars.items(): - if var.get() and tag_name in tag_name_to_id: - tag_ids_to_remove.append(tag_name_to_id[tag_name]) - - if not tag_ids_to_remove: - return - - # Remove from pending changes (using IDs) - if photo_id in pending_tag_changes: - pending_tag_changes[photo_id] = [ - tid for tid in pending_tag_changes[photo_id] - if tid not in tag_ids_to_remove - ] - if not pending_tag_changes[photo_id]: - del pending_tag_changes[photo_id] - - # Track removals for saved tags (using IDs) - existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) - for tag_id in tag_ids_to_remove: - if tag_id in existing_tag_ids: - # This is a saved tag, add to pending removals - if photo_id not in pending_tag_removals: - pending_tag_removals[photo_id] = [] - if tag_id not in pending_tag_removals[photo_id]: - pending_tag_removals[photo_id].append(tag_id) - - refresh_tag_list() - update_save_button_text() - - # Bottom frame - buttons - remove_btn = ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags) - remove_btn.pack(side=tk.LEFT, padx=(0, 8)) - - close_btn = ttk.Button(bottom_frame, text="Close", command=popup.destroy) - close_btn.pack(side=tk.RIGHT) - - # Initial load - refresh_tag_list() - - # Update main display when dialog closes - def on_close(): - # Update the main display - existing_tags_list = self._parse_tags_string(photo_tags) - - # Get pending tag IDs and convert to names - pending_tag_names = [] - if photo_id in pending_tag_changes: - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - - # Get pending removal IDs and convert to names - pending_removal_names = [] - if photo_id in pending_tag_removals: - pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] - - all_tags = existing_tags_list + pending_tag_names - unique_tags = self._deduplicate_tags(all_tags) - # Remove tags marked for removal - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_tags = ", ".join(unique_tags) if unique_tags else "None" - label_widget.configure(text=current_tags) - popup.destroy() - - popup.protocol("WM_DELETE_WINDOW", on_close) - - return handler - - - def create_tag_buttons_frame(parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0): - """Create a frame with tag display and add button that can be used in any view mode""" - tags_frame = ttk.Frame(parent) - - # Display current tags - existing_tags_list = self._parse_tags_string(photo_tags) - - # Get pending tag names - pending_tag_names = [] - if photo_id in pending_tag_changes: - pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] - - # Get pending removal names - pending_removal_names = [] - if photo_id in pending_tag_removals: - pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] - - all_tags = existing_tags_list + pending_tag_names - unique_tags = self._deduplicate_tags(all_tags) - # Remove tags marked for removal - unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] - current_display = ", ".join(unique_tags) if unique_tags else "None" - - tags_text = ttk.Label(tags_frame, text=current_display) - tags_text.pack(side=tk.LEFT) - - # Add button with linkage icon - add_btn = tk.Button(tags_frame, text="šŸ”—", width=2, - command=create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags)) - add_btn.pack(side=tk.LEFT, padx=(6, 0)) - - # Pack or grid the frame based on the view mode - if use_grid: - tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W) - else: - tags_frame.pack(side=tk.LEFT, padx=5) - - return tags_frame - - def show_list_view(): - clear_content() - - # Get visible columns and store globally for resize functions - nonlocal current_visible_cols - current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]] - col_count = len(current_visible_cols) - - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure column weights for visible columns - for i, col in enumerate(current_visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header row - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Configure header frame columns (accounting for separators) - for i, col in enumerate(current_visible_cols): - header_frame.columnconfigure(i*2, weight=col['weight'], minsize=col['width']) - if i < len(current_visible_cols) - 1: - header_frame.columnconfigure(i*2+1, weight=0, minsize=1) # Separator column - - # Create header labels with right-click context menu and resizable separators - for i, col in enumerate(current_visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i*2, padx=5, sticky=tk.W) - # Bind right-click to each label as well - header_label.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) - - # Add resizable vertical separator after each column (except the last one) - if i < len(current_visible_cols) - 1: - # Create a more visible separator frame with inner dark line - separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow") # Bright red for debugging - separator_frame.grid(row=0, column=i*2+1, sticky='ns', padx=0) - separator_frame.grid_propagate(False) # Maintain fixed width - # Inner dark line for better contrast - inner_line = tk.Frame(separator_frame, bg='darkred', width=2) # Dark red for debugging - inner_line.pack(fill=tk.Y, expand=True) - - # Make separator resizable - separator_frame.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) - separator_frame.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) - separator_frame.bind("", stop_resize) - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) # Orange for debugging - separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred'))) # Red for debugging - - # Also bind to the inner line for better hit detection - inner_line.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) - inner_line.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) - inner_line.bind("", stop_resize) - - # Bind right-click to the entire header frame - header_frame.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) - - # Add separator - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Get folder-grouped data - folder_data = prepare_folder_grouped_data() - - # Add folder sections and photo rows - current_row = 2 - for folder_info in folder_data: - # Add collapsible folder header - create_folder_header(content_inner, folder_info, current_row, col_count, 'list') - current_row += 1 - - # Add photos in this folder only if expanded - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - - # Configure row frame columns (no separators in data rows) - for i, col in enumerate(current_visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - for i, col in enumerate(current_visible_cols): - key = col['key'] - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'path': - text = photo['path'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - # Use shared tag buttons frame for list view - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i) - continue - - ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W) - - current_row += 1 - - def show_icon_view(): - clear_content() - - # Get visible columns - visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]] - col_count = len(visible_cols) - - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure column weights for visible columns - for i, col in enumerate(visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header row - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header labels with right-click context menu - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - # Bind right-click to each label as well - header_label.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) - - # Bind right-click to the entire header frame - header_frame.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) - - # Add separator - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Get folder-grouped data - folder_data = prepare_folder_grouped_data() - - # Show photos grouped by folders - current_row = 2 - for folder_info in folder_data: - # Add collapsible folder header - create_folder_header(content_inner, folder_info, current_row, col_count, 'icons') - current_row += 1 - - # Add photos in this folder only if expanded - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2) - - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - col_idx = 0 - for col in visible_cols: - key = col['key'] - - if key == 'thumbnail': - # Thumbnail column - thumbnail_frame = ttk.Frame(row_frame) - thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - - try: - if os.path.exists(photo['path']): - img = Image.open(photo['path']) - img.thumbnail((150, 150), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - photo_images.append(photo_img) - - # Create canvas for image - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_image(75, 75, image=photo_img) - else: - # Placeholder for missing image - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="šŸ–¼ļø", fill="gray", font=("Arial", 24)) - except Exception: - # Error loading image - canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) - canvas.pack() - canvas.create_text(75, 75, text="āŒ", fill="red", font=("Arial", 24)) - else: - # Data columns - if key == 'id': - text = str(photo['id']) - elif key == 'filename': - text = photo['filename'] - elif key == 'processed': - text = "Yes" if photo['processed'] else "No" - elif key == 'date_taken': - text = photo['date_taken'] or "Unknown" - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - # Use shared tag buttons frame for icon view - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - - ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) - - col_idx += 1 - - current_row += 1 - - def show_compact_view(): - clear_content() - - # Get visible columns - visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]] - col_count = len(visible_cols) - - if col_count == 0: - ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", - font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) - return - - # Configure column weights for visible columns - for i, col in enumerate(visible_cols): - content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header - header_frame = ttk.Frame(content_inner) - header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - for i, col in enumerate(visible_cols): - header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - # Create header labels with right-click context menu - for i, col in enumerate(visible_cols): - header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) - header_label.grid(row=0, column=i, padx=5, sticky=tk.W) - # Bind right-click to each label as well - header_label.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) - - # Bind right-click to the entire header frame - header_frame.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) - - # Add separator - ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) - - # Get folder-grouped data - folder_data = prepare_folder_grouped_data() - - # Add folder sections and photo rows - current_row = 2 - for folder_info in folder_data: - # Add collapsible folder header - create_folder_header(content_inner, folder_info, current_row, col_count, 'compact') - current_row += 1 - - # Add photos in this folder only if expanded - if folder_states.get(folder_info['folder_path'], True): - for photo in folder_info['photos']: - row_frame = ttk.Frame(content_inner) - row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) - - for i, col in enumerate(visible_cols): - row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) - - col_idx = 0 - for col in visible_cols: - key = col['key'] - if key == 'filename': - text = photo['filename'] - elif key == 'faces': - text = str(photo['face_count']) - elif key == 'tags': - # Use shared tag buttons frame for compact view - create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) - col_idx += 1 - continue - - ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) - col_idx += 1 - - current_row += 1 - - def switch_view_mode(mode): - if mode == "list": - show_list_view() - elif mode == "icons": - show_icon_view() - elif mode == "compact": - show_compact_view() - - # No need for canvas resize handler since icon view is now single column - - # Load initial data and show default view - load_existing_tags() - load_photos() - show_list_view() - - # Show window - root.deiconify() - root.mainloop() - - return 0 - - def modifyidentified(self) -> int: - """Modify identified faces interface - empty window with Quit button for now""" - import tkinter as tk - from tkinter import ttk, messagebox - from PIL import Image, ImageTk - import os - - # Simple tooltip implementation - class ToolTip: - def __init__(self, widget, text): - self.widget = widget - self.text = text - self.tooltip_window = None - self.widget.bind("", self.on_enter) - self.widget.bind("", 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 - - # Create the main window - root = tk.Tk() - root.title("View and Modify Identified Faces") - root.resizable(True, True) - - # Track window state to prevent multiple destroy calls - window_destroyed = False - temp_crops = [] - right_panel_images = [] # Keep PhotoImage refs alive - selected_person_id = None - - # Hide window initially to prevent flash at corner - root.withdraw() - - # Set up protocol handler for window close button (X) - def on_closing(): - nonlocal window_destroyed - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - root.protocol("WM_DELETE_WINDOW", on_closing) - - # Set up window size saving - saved_size = self._setup_window_size_saving(root) - - # Create main frame - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=2) - main_frame.rowconfigure(1, weight=1) - - # Title label - title_label = ttk.Label(main_frame, text="View and Modify Identified Faces", font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) - - # Left panel: People list - people_frame = ttk.LabelFrame(main_frame, text="People", padding="10") - people_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8)) - people_frame.columnconfigure(0, weight=1) - - # Search controls (Last Name) with label under the input (match auto-match style) - last_name_search_var = tk.StringVar() - 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=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) - search_btn.pack(side=tk.LEFT, padx=(0, 5)) - clear_btn = ttk.Button(buttons_row, text="Clear", width=6) - 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_canvas = tk.Canvas(people_frame, bg='white') - people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview) - people_list_inner = ttk.Frame(people_canvas) - people_canvas.create_window((0, 0), window=people_list_inner, anchor="nw") - people_canvas.configure(yscrollcommand=people_scrollbar.set) - - people_list_inner.bind( - "", - 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) - - # Right panel: Faces for selected person - faces_frame = ttk.LabelFrame(main_frame, text="Faces", padding="10") - faces_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) - faces_frame.columnconfigure(0, weight=1) - faces_frame.rowconfigure(0, weight=1) - - style = ttk.Style() - canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' - # Match auto-match UI: set gray background for left canvas and remove highlight border - try: - people_canvas.configure(bg=canvas_bg_color, highlightthickness=0) - except Exception: - pass - faces_canvas = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0) - faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=faces_canvas.yview) - faces_inner = ttk.Frame(faces_canvas) - faces_canvas.create_window((0, 0), window=faces_inner, anchor="nw") - faces_canvas.configure(yscrollcommand=faces_scrollbar.set) - - faces_inner.bind( - "", - lambda e: faces_canvas.configure(scrollregion=faces_canvas.bbox("all")) - ) - - # Track current person for responsive face grid - current_person_id = None - current_person_name = "" - resize_job = None - - # Track unmatched faces (temporary changes) - unmatched_faces = set() # All face IDs unmatched across people (for global save) - unmatched_by_person = {} # person_id -> set(face_id) for per-person undo - original_faces_data = [] # store original faces data for potential future use - - def on_faces_canvas_resize(event): - nonlocal resize_job - if current_person_id is None: - return - # Debounce re-render on resize - try: - if resize_job is not None: - root.after_cancel(resize_job) - except Exception: - pass - resize_job = root.after(150, lambda: show_person_faces(current_person_id, current_person_name)) - - faces_canvas.bind("", on_faces_canvas_resize) - - 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)) - - # Load people from DB with counts - people_data = [] # list of dicts: {id, name, count, first_name, last_name} - people_filtered = None # filtered subset based on last name search - - def load_people(): - nonlocal people_data - with self.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 - """ - ) - 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}" - - 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: - apply_last_name_filter() - except Exception: - pass - - # Wire up search controls now that helper functions exist - try: - search_btn.config(command=lambda: apply_last_name_filter()) - clear_btn.config(command=lambda: clear_last_name_filter()) - search_entry.bind('', lambda e: apply_last_name_filter()) - except Exception: - pass - - def apply_last_name_filter(): - nonlocal people_filtered - query = last_name_search_var.get().strip().lower() - if query: - people_filtered = [p for p in people_data if p.get('last_name', '').lower().find(query) != -1] - else: - people_filtered = None - populate_people_list() - # Update right panel based on filtered results - source = people_filtered if people_filtered is not None else people_data - if source: - # Load faces for the first person in the list - first = source[0] - try: - # Update selection state - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Bold the first label if present - first_row = people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - # Show faces for the first person - show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - # No matches: clear faces panel - clear_faces_panel() - - def clear_last_name_filter(): - nonlocal people_filtered - last_name_search_var.set("") - people_filtered = None - populate_people_list() - # After clearing, load faces for the first available person if any - if people_data: - first = people_data[0] - try: - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - first_row = people_list_inner.winfo_children()[0] - for widget in first_row.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10, "bold")) - break - except Exception: - pass - show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") - else: - clear_faces_panel() - - def clear_faces_panel(): - for w in faces_inner.winfo_children(): - w.destroy() - # Cleanup temp crops - for crop in list(temp_crops): - try: - if os.path.exists(crop): - os.remove(crop) - except: - pass - temp_crops.clear() - right_panel_images.clear() - - def unmatch_face(face_id: int): - """Temporarily unmatch a face from the current person""" - nonlocal unmatched_faces, unmatched_by_person - unmatched_faces.add(face_id) - # Track per-person for Undo - person_set = unmatched_by_person.get(current_person_id) - if person_set is None: - person_set = set() - unmatched_by_person[current_person_id] = person_set - person_set.add(face_id) - # Refresh the display - show_person_faces(current_person_id, current_person_name) - - def undo_changes(): - """Undo all temporary changes""" - nonlocal unmatched_faces, unmatched_by_person - if current_person_id in unmatched_by_person: - for fid in list(unmatched_by_person[current_person_id]): - unmatched_faces.discard(fid) - unmatched_by_person[current_person_id].clear() - # Refresh the display - show_person_faces(current_person_id, current_person_name) - - def save_changes(): - """Save unmatched faces to database""" - if not unmatched_faces: - return - - # Confirm with user - result = messagebox.askyesno( - "Confirm Changes", - f"Are you sure you want to unlink {len(unmatched_faces)} face(s) from '{current_person_name}'?\n\n" - "This will make these faces unidentified again." - ) - - if not result: - return - - # Update database - with self.get_db_connection() as conn: - cursor = conn.cursor() - for face_id in unmatched_faces: - cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) - conn.commit() - - # Store count for message before clearing - unlinked_count = len(unmatched_faces) - - # Clear unmatched faces and refresh - unmatched_faces.clear() - original_faces_data.clear() - - # Refresh people list to update counts - load_people() - populate_people_list() - - # Refresh faces display - show_person_faces(current_person_id, current_person_name) - - messagebox.showinfo("Changes Saved", f"Successfully unlinked {unlinked_count} face(s) from '{current_person_name}'.") - - def show_person_faces(person_id: int, person_name: str): - nonlocal current_person_id, current_person_name, unmatched_faces, original_faces_data - current_person_id = person_id - current_person_name = person_name - clear_faces_panel() - - # Determine how many columns fit the available width - available_width = faces_canvas.winfo_width() - if available_width <= 1: - available_width = faces_frame.winfo_width() - tile_width = 150 # approx tile + padding - cols = max(1, available_width // tile_width) - - # Header row - header = ttk.Label(faces_inner, text=f"Faces for: {person_name}", font=("Arial", 12, "bold")) - header.grid(row=0, column=0, columnspan=cols, sticky=tk.W, pady=(0, 5)) - - # Control buttons row - button_frame = ttk.Frame(faces_inner) - button_frame.grid(row=1, column=0, columnspan=cols, sticky=tk.W, pady=(0, 10)) - - # Enable Undo only if current person has unmatched faces - current_has_unmatched = bool(unmatched_by_person.get(current_person_id)) - undo_btn = ttk.Button(button_frame, text="↶ Undo changes", - command=lambda: undo_changes(), - state="disabled" if not current_has_unmatched else "normal") - undo_btn.pack(side=tk.LEFT, padx=(0, 10)) - - # Note: Save button moved to bottom control bar - - # Query faces for this person - with self.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """ - SELECT f.id, f.location, ph.path, ph.filename - FROM faces f - JOIN photos ph ON ph.id = f.photo_id - WHERE f.person_id = ? - ORDER BY f.id DESC - """, - (person_id,) - ) - rows = cursor.fetchall() - - # Filter out unmatched faces - visible_rows = [row for row in rows if row[0] not in unmatched_faces] - - if not visible_rows: - ttk.Label(faces_inner, text="No faces found." if not rows else "All faces unmatched.", foreground="gray").grid(row=2, column=0, sticky=tk.W) - return - - # Grid thumbnails with responsive column count - row_index = 2 # Start after header and buttons - col_index = 0 - for face_id, location, photo_path, filename in visible_rows: - crop_path = self._extract_face_crop(photo_path, location, face_id) - thumb = None - if crop_path and os.path.exists(crop_path): - try: - img = Image.open(crop_path) - img.thumbnail((130, 130), Image.Resampling.LANCZOS) - photo_img = ImageTk.PhotoImage(img) - temp_crops.append(crop_path) - right_panel_images.append(photo_img) - thumb = photo_img - except Exception: - thumb = None - - tile = ttk.Frame(faces_inner, padding="5") - tile.grid(row=row_index, column=col_index, padx=5, pady=5, sticky=tk.N) - - # Create a frame for the face image with X button overlay - face_frame = ttk.Frame(tile) - face_frame.grid(row=0, column=0) - - canvas = tk.Canvas(face_frame, width=130, height=130, bg=canvas_bg_color, highlightthickness=0) - canvas.grid(row=0, column=0) - if thumb is not None: - canvas.create_image(65, 65, image=thumb) - else: - canvas.create_text(65, 65, text="šŸ–¼ļø", fill="gray") - - # X button to unmatch face - pin exactly to the canvas' top-right corner - x_canvas = tk.Canvas(face_frame, width=12, height=12, bg='red', - highlightthickness=0, relief="flat") - x_canvas.create_text(6, 6, text="āœ–", fill="white", font=("Arial", 8, "bold")) - # Click handler - x_canvas.bind("", lambda e, fid=face_id: unmatch_face(fid)) - # Hover highlight: change bg, show white outline, and hand cursor - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2")) - x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="red", highlightthickness=0, cursor="")) - # Anchor to the canvas' top-right regardless of layout/size - try: - x_canvas.place(in_=canvas, relx=1.0, x=0, y=0, anchor='ne') - except Exception: - # Fallback to absolute coords if relative placement fails - x_canvas.place(x=118, y=0) - - ttk.Label(tile, text=f"ID {face_id}", foreground="gray").grid(row=1, column=0) - - col_index += 1 - if col_index >= cols: - col_index = 0 - row_index += 1 - - def populate_people_list(): - for w in people_list_inner.winfo_children(): - w.destroy() - source = people_filtered if people_filtered is not None else people_data - if not source: - empty_label = ttk.Label(people_list_inner, text="No people match filter", foreground="gray") - empty_label.grid(row=0, column=0, sticky=tk.W, pady=4) - return - for idx, person in enumerate(source): - row = ttk.Frame(people_list_inner) - row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=4) - # Freeze per-row values to avoid late-binding issues - row_person = person - row_idx = idx - - # Make person name clickable - def make_click_handler(p_id, p_name, p_idx): - def on_click(event): - nonlocal selected_person_id - # Reset all labels to normal font - for child in people_list_inner.winfo_children(): - for widget in child.winfo_children(): - if isinstance(widget, ttk.Label): - widget.config(font=("Arial", 10)) - # Set clicked label to bold - event.widget.config(font=("Arial", 10, "bold")) - selected_person_id = p_id - # Show faces for this person - show_person_faces(p_id, p_name) - return on_click - - - # Edit (rename) button - def start_edit_person(row_frame, person_record, row_index): - for w in row_frame.winfo_children(): - w.destroy() - - # Use pre-loaded data instead of database query - cur_first = person_record.get('first_name', '') - cur_last = person_record.get('last_name', '') - cur_middle = person_record.get('middle_name', '') - cur_maiden = person_record.get('maiden_name', '') - cur_dob = person_record.get('date_of_birth', '') - - # Create a larger container frame for the text boxes and labels - edit_container = ttk.Frame(row_frame) - edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Create a grid layout for better organization - # First name field with label - first_frame = ttk.Frame(edit_container) - first_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - first_var = tk.StringVar(value=cur_first) - first_entry = ttk.Entry(first_frame, textvariable=first_var, width=15) - first_entry.pack(side=tk.TOP) - first_entry.focus_set() - - first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray") - first_label.pack(side=tk.TOP, pady=(2, 0)) - - # Last name field with label - last_frame = ttk.Frame(edit_container) - last_frame.grid(row=0, column=1, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - last_var = tk.StringVar(value=cur_last) - last_entry = ttk.Entry(last_frame, textvariable=last_var, width=15) - last_entry.pack(side=tk.TOP) - - last_label = ttk.Label(last_frame, text="Last Name", font=("Arial", 8), foreground="gray") - last_label.pack(side=tk.TOP, pady=(2, 0)) - - # Middle name field with label - middle_frame = ttk.Frame(edit_container) - middle_frame.grid(row=0, column=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - middle_var = tk.StringVar(value=cur_middle) - middle_entry = ttk.Entry(middle_frame, textvariable=middle_var, width=15) - middle_entry.pack(side=tk.TOP) - - middle_label = ttk.Label(middle_frame, text="Middle Name", font=("Arial", 8), foreground="gray") - middle_label.pack(side=tk.TOP, pady=(2, 0)) - - # Maiden name field with label - maiden_frame = ttk.Frame(edit_container) - maiden_frame.grid(row=1, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - maiden_var = tk.StringVar(value=cur_maiden) - maiden_entry = ttk.Entry(maiden_frame, textvariable=maiden_var, width=15) - maiden_entry.pack(side=tk.TOP) - - maiden_label = ttk.Label(maiden_frame, text="Maiden Name", font=("Arial", 8), foreground="gray") - maiden_label.pack(side=tk.TOP, pady=(2, 0)) - - # Date of birth field with label and calendar button - dob_frame = ttk.Frame(edit_container) - dob_frame.grid(row=1, column=1, columnspan=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) - - # Create a frame for the date picker - date_picker_frame = ttk.Frame(dob_frame) - date_picker_frame.pack(side=tk.TOP) - - dob_var = tk.StringVar(value=cur_dob) - dob_entry = ttk.Entry(date_picker_frame, textvariable=dob_var, width=20, state='readonly') - dob_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Calendar button - calendar_btn = ttk.Button(date_picker_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) - calendar_btn.pack(side=tk.RIGHT, padx=(5, 0)) - - dob_label = ttk.Label(dob_frame, text="Date of Birth", font=("Arial", 8), foreground="gray") - dob_label.pack(side=tk.TOP, pady=(2, 0)) - - def open_calendar(): - """Open a visual calendar dialog to select date of birth""" - from datetime import datetime, date, timedelta - import calendar - - # Create calendar window - calendar_window = tk.Toplevel(root) - calendar_window.title("Select Date of Birth") - calendar_window.resizable(False, False) - calendar_window.transient(root) - calendar_window.grab_set() - - # Calculate center position before showing the window - 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) - - # Set geometry with center position before showing - calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - # Calendar variables - current_date = datetime.now() - - # Check if there's already a date selected - existing_date_str = dob_var.get().strip() - if existing_date_str: - try: - existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() - display_year = existing_date.year - display_month = existing_date.month - selected_date = existing_date - except ValueError: - # If existing date is invalid, use default - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - else: - # Default to 25 years ago - display_year = current_date.year - 25 - display_month = 1 - selected_date = None - - # Month names - month_names = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - - # Configure custom styles for better visual highlighting - style = ttk.Style() - - # Selected date style - bright blue background with white text - style.configure("Selected.TButton", - background="#0078d4", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=2) - style.map("Selected.TButton", - background=[("active", "#106ebe")], - relief=[("pressed", "sunken")]) - - # Today's date style - orange background - style.configure("Today.TButton", - background="#ff8c00", - foreground="white", - font=("Arial", 9, "bold"), - relief="raised", - borderwidth=1) - style.map("Today.TButton", - background=[("active", "#e67e00")], - relief=[("pressed", "sunken")]) - - # Calendar-specific normal button style (don't affect global TButton) - style.configure("Calendar.TButton", - font=("Arial", 9), - relief="flat") - style.map("Calendar.TButton", - background=[("active", "#e1e1e1")], - relief=[("pressed", "sunken")]) - - # 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() - - def update_calendar(): - """Update the calendar display""" - # Clear existing calendar - for widget in calendar_frame.winfo_children(): - widget.destroy() - - # Update header - month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") - - # 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): - label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) - label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") - - # Calendar days - for week_num, week in enumerate(cal): - for day_num, day in enumerate(week): - if day == 0: - # Empty cell - label = ttk.Label(calendar_frame, text="") - label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - else: - # Day button - 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 with prominent style - 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), - width=3, style="Calendar.TButton") - day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") - - # Check if this day should be highlighted - is_today = (display_year == current_date.year and - display_month == current_date.month and - day == current_date.day) - is_selected = (selected_date and - selected_date.year == display_year and - selected_date.month == display_month and - selected_date.day == day) - - if is_selected: - day_btn.config(style="Selected.TButton") - elif is_today: - day_btn.config(style="Today.TButton") - - # Navigation functions - def prev_year(): - nonlocal display_year - display_year = max(1900, display_year - 1) - update_calendar() - - def next_year(): - nonlocal display_year - display_year = min(current_date.year, display_year + 1) - update_calendar() - - def prev_month(): - nonlocal display_month, display_year - if display_month > 1: - display_month -= 1 - else: - display_month = 12 - display_year = max(1900, display_year - 1) - update_calendar() - - def next_month(): - nonlocal display_month, display_year - if display_month < 12: - display_month += 1 - else: - display_month = 1 - display_year = min(current_date.year, display_year + 1) - update_calendar() - - # Navigation buttons - prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) - prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) - - prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) - prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) - - month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) - month_year_label.pack(side=tk.LEFT, padx=5) - - next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) - next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) - - 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: - date_str = selected_date.strftime('%Y-%m-%d') - dob_var.set(date_str) - calendar_window.destroy() - else: - messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") - - def cancel_selection(): - """Cancel date selection""" - calendar_window.destroy() - - # Buttons - 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 - update_calendar() - - def save_rename(): - new_first = first_var.get().strip() - new_last = last_var.get().strip() - new_middle = middle_var.get().strip() - new_maiden = maiden_var.get().strip() - new_dob = dob_var.get().strip() - - if not new_first and not new_last: - messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.") - return - - # Check for duplicates in local data first (based on first and last name only) - for person in people_data: - if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last: - display_name = f"{new_last}, {new_first}".strip(", ").strip() - messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.") - return - - # Single database access - save to database - with self.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 = ?', - (new_first, new_last, new_middle, new_maiden, new_dob, person_record['id'])) - conn.commit() - - # Update local data structure - person_record['first_name'] = new_first - person_record['last_name'] = new_last - person_record['middle_name'] = new_middle - person_record['maiden_name'] = new_maiden - person_record['date_of_birth'] = new_dob - - # Recreate the full display name with all available information - name_parts = [] - if new_first: - name_parts.append(new_first) - if new_middle: - name_parts.append(new_middle) - if new_last: - name_parts.append(new_last) - if new_maiden: - name_parts.append(f"({new_maiden})") - - full_name = ' '.join(name_parts) if name_parts else "Unknown" - - # Create detailed display with date of birth if available - display_name = full_name - if new_dob: - display_name += f" - Born: {new_dob}" - - person_record['name'] = display_name - person_record['full_name'] = full_name - - # Refresh list - current_selected_id = person_record['id'] - populate_people_list() - # Reselect and refresh right panel header if needed - if selected_person_id == current_selected_id or selected_person_id is None: - # Find updated name - updated = next((p for p in people_data if p['id'] == current_selected_id), None) - if updated: - # Bold corresponding label - for child in people_list_inner.winfo_children(): - # child is row frame: contains label and button - widgets = child.winfo_children() - if not widgets: - continue - lbl = widgets[0] - if isinstance(lbl, ttk.Label) and lbl.cget('text').startswith(updated['name'] + " ("): - lbl.config(font=("Arial", 10, "bold")) - break - # Update right panel header by re-showing faces - show_person_faces(updated['id'], updated['name']) - - def cancel_edit(): - # Rebuild the row back to label + edit - for w in row_frame.winfo_children(): - w.destroy() - rebuild_row(row_frame, person_record, row_index) - - save_btn = ttk.Button(row_frame, text="šŸ’¾", width=3, command=save_rename) - save_btn.pack(side=tk.LEFT, padx=(5, 0)) - cancel_btn = ttk.Button(row_frame, text="āœ–", width=3, command=cancel_edit) - cancel_btn.pack(side=tk.LEFT, padx=(5, 0)) - - # Configure custom disabled button style for better visibility - style = ttk.Style() - style.configure("Disabled.TButton", - background="#d3d3d3", # Light gray background - foreground="#808080", # Dark gray text - relief="flat", - borderwidth=1) - - def validate_save_button(): - """Enable/disable save button based on required fields""" - first_val = first_var.get().strip() - last_val = last_var.get().strip() - dob_val = dob_var.get().strip() - - # Enable save button only if both name fields and date of birth are provided - has_first = bool(first_val) - has_last = bool(last_val) - has_dob = bool(dob_val) - - if has_first and has_last and has_dob: - save_btn.config(state="normal") - # Reset to normal styling when enabled - save_btn.config(style="TButton") - else: - save_btn.config(state="disabled") - # Apply custom disabled styling for better visibility - save_btn.config(style="Disabled.TButton") - - # Set up validation callbacks for all input fields - first_var.trace('w', lambda *args: validate_save_button()) - last_var.trace('w', lambda *args: validate_save_button()) - middle_var.trace('w', lambda *args: validate_save_button()) - maiden_var.trace('w', lambda *args: validate_save_button()) - dob_var.trace('w', lambda *args: validate_save_button()) - - # Initial validation - validate_save_button() - - # Keyboard shortcuts (only work when save button is enabled) - def try_save(): - if save_btn.cget('state') == 'normal': - save_rename() - - first_entry.bind('', lambda e: try_save()) - last_entry.bind('', lambda e: try_save()) - middle_entry.bind('', lambda e: try_save()) - maiden_entry.bind('', lambda e: try_save()) - dob_entry.bind('', lambda e: try_save()) - first_entry.bind('', lambda e: cancel_edit()) - last_entry.bind('', lambda e: cancel_edit()) - middle_entry.bind('', lambda e: cancel_edit()) - maiden_entry.bind('', lambda e: cancel_edit()) - dob_entry.bind('', lambda e: cancel_edit()) - - def rebuild_row(row_frame, p, i): - # Edit button (on the left) - edit_btn = ttk.Button(row_frame, text="āœļø", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii)) - 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"{p['name']} ({p['count']})", font=("Arial", 10)) - name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) - name_lbl.bind("", make_click_handler(p['id'], p['name'], i)) - name_lbl.config(cursor="hand2") - # Bold if selected - if (selected_person_id is None and i == 0) or (selected_person_id == p['id']): - name_lbl.config(font=("Arial", 10, "bold")) - - # Build row contents with edit button - rebuild_row(row, row_person, row_idx) - - # Initial load - load_people() - populate_people_list() - - # Show first person's faces by default and mark selected - if people_data: - selected_person_id = people_data[0]['id'] - show_person_faces(people_data[0]['id'], people_data[0]['name']) - - # Control buttons - control_frame = ttk.Frame(main_frame) - control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) - - def on_quit(): - nonlocal window_destroyed - on_closing() - if not window_destroyed: - window_destroyed = True - try: - root.destroy() - except tk.TclError: - pass # Window already destroyed - - def on_save_all_changes(): - # Use global unmatched_faces set; commit all across people - nonlocal unmatched_faces - if not unmatched_faces: - messagebox.showinfo("Nothing to Save", "There are no pending changes to save.") - return - result = messagebox.askyesno( - "Confirm Save", - f"Unlink {len(unmatched_faces)} face(s) across all people?\n\nThis will make them unidentified." - ) - if not result: - return - with self.get_db_connection() as conn: - cursor = conn.cursor() - for face_id in unmatched_faces: - cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) - conn.commit() - count = len(unmatched_faces) - unmatched_faces.clear() - # Refresh people list and right panel for current selection - load_people() - populate_people_list() - if current_person_id is not None and current_person_name: - show_person_faces(current_person_id, current_person_name) - messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).") - - save_btn_bottom = ttk.Button(control_frame, text="šŸ’¾ Save changes", command=on_save_all_changes) - save_btn_bottom.pack(side=tk.RIGHT, padx=(0, 10)) - quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) - quit_btn.pack(side=tk.RIGHT) - - # Show the window - try: - root.deiconify() - root.lift() - root.focus_force() - except tk.TclError: - # Window was destroyed before we could show it - return 0 - - # Main event loop - try: - root.mainloop() - except tk.TclError: - pass # Window was destroyed - - return 0 + @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 main(): """Main CLI interface""" parser = argparse.ArgumentParser( - description="PunimTag CLI - Simple photo face tagger", + description="PunimTag CLI - Simple photo face tagger (Refactored)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - photo_tagger.py scan /path/to/photos # Scan folder for photos - photo_tagger.py process --limit 20 # Process 20 photos for faces - photo_tagger.py identify --batch 10 # Identify 10 faces interactively - photo_tagger.py auto-match # Auto-identify matching faces - photo_tagger.py modifyidentified # Show and Modify identified faces - photo_tagger.py match 15 # Find faces similar to face ID 15 - photo_tagger.py tag --pattern "vacation" # Tag photos matching pattern - photo_tagger.py search "John" # Find photos with John - photo_tagger.py tag-manager # Open tag management GUI - photo_tagger.py stats # Show statistics + 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 """ ) @@ -6961,20 +250,20 @@ Examples: parser.add_argument('target', nargs='?', help='Target folder (scan), person name (search), or pattern (tag)') - parser.add_argument('--db', default='data/photos.db', - help='Database file path (default: data/photos.db)') + parser.add_argument('--db', default=DEFAULT_DB_PATH, + help=f'Database file path (default: {DEFAULT_DB_PATH})') - parser.add_argument('--limit', type=int, default=50, - help='Batch size limit for processing (default: 50)') + 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=20, - help='Batch size for identification (default: 20)') + 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='hog', - help='Face detection model: hog (faster) or cnn (more accurate)') + 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') @@ -6982,8 +271,8 @@ Examples: parser.add_argument('--show-faces', action='store_true', help='Show individual face crops during identification') - parser.add_argument('--tolerance', type=float, default=0.5, - help='Face matching tolerance (0.0-1.0, lower = stricter, default: 0.5)') + 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') @@ -7035,8 +324,8 @@ Examples: if matches: print(f"\nšŸŽÆ Found {len(matches)} similar faces:") for match in matches: - person_name = "Unknown" if match['person_id'] is None else f"Person ID {match['person_id']}" - print(f" šŸ“ø {match['filename']} - {person_name} (confidence: {(1-match['distance']):.1%})") + 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: @@ -7060,6 +349,9 @@ Examples: return 1 except Exception as e: print(f"āŒ Error: {e}") + if args.debug: + import traceback + traceback.print_exc() return 1 finally: # Always cleanup resources diff --git a/photo_tagger_original_backup.py b/photo_tagger_original_backup.py new file mode 100644 index 0000000..340c2f5 --- /dev/null +++ b/photo_tagger_original_backup.py @@ -0,0 +1,7070 @@ +#!/usr/bin/env python3 +""" +PunimTag CLI - Minimal Photo Face Tagger +Simple command-line tool for face recognition and photo tagging +""" + +import os +import sqlite3 +import argparse +import face_recognition +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +from PIL.ExifTags import TAGS +import pickle +import numpy as np +from typing import List, Dict, Tuple, Optional +import sys +import tempfile +import subprocess +import threading +import time +from datetime import datetime +from functools import lru_cache +from contextlib import contextmanager + + +class PhotoTagger: + def __init__(self, db_path: str = "data/photos.db", verbose: int = 0, debug: bool = False): + """Initialize the photo tagger with database""" + self.db_path = db_path + self.verbose = verbose + self.debug = debug + self._face_encoding_cache = {} + self._image_cache = {} + 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) + 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 + + @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(self): + """Clean up resources and close connections""" + self._clear_caches() + self.close_db_connection() + + 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 _deduplicate_tags(self, tag_list): + """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): + """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 _get_tag_id_by_name(self, tag_name, tag_name_to_id_map): + """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, tag_id_to_name_map): + """Get tag name by ID""" + return tag_id_to_name_map.get(tag_id, f"Unknown Tag {tag_id}") + + def _load_tag_mappings(self): + """Load tag name to ID and ID to name mappings from database""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, tag_name FROM tags ORDER BY 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 + tag_name_to_id[tag_name] = tag_id + return tag_id_to_name, tag_name_to_id + + def _get_existing_tag_ids_for_photo(self, photo_id): + """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 _setup_window_size_saving(self, root, config_file="gui_config.json"): + """Set up window size saving functionality""" + import json + import tkinter as tk + + # Load saved window size + default_size = "600x500" + saved_size = default_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_size) + except: + saved_size = default_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 tk.TclError: + # 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('', save_window_size) + return saved_size + + 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 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS phototaglinkage ( + linkage_id INTEGER PRIMARY KEY AUTOINCREMENT, + photo_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + 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)') + + # Migration: Add date_taken column to existing photos table if it doesn't exist + try: + cursor.execute('ALTER TABLE photos ADD COLUMN date_taken DATE') + if self.verbose >= 1: + print("āœ… Added date_taken column to photos table") + except Exception: + # Column already exists, ignore + pass + + # Migration: Add date_added column to existing photos table if it doesn't exist + try: + cursor.execute('ALTER TABLE photos ADD COLUMN date_added DATETIME DEFAULT CURRENT_TIMESTAMP') + if self.verbose >= 1: + print("āœ… Added date_added column to photos table") + except Exception: + # Column already exists, ignore + pass + + + if self.verbose >= 1: + print(f"āœ… Database initialized: {self.db_path}") + + 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""" + # BREAKPOINT: Set breakpoint here for debugging + + + if not os.path.exists(folder_path): + print(f"āŒ Folder not found: {folder_path}") + return 0 + + photo_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff'} + + found_photos = [] + + # BREAKPOINT: Set breakpoint here for debugging + + 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 photo_extensions: + 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 photo_extensions: + 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 + # BREAKPOINT: Set breakpoint here for debugging + + with self.get_db_connection() as conn: + cursor = conn.cursor() + added_count = 0 + + for photo_path, filename in found_photos: + try: + # Extract date taken from EXIF data + date_taken = self._extract_photo_date(photo_path) + + cursor.execute( + 'INSERT OR IGNORE INTO photos (path, filename, date_taken) VALUES (?, ?, ?)', + (photo_path, filename, date_taken) + ) + if cursor.rowcount > 0: + 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}") + elif self.verbose >= 3: + print(f" šŸ“ø Already exists: {filename}") + except Exception as e: + print(f"āš ļø Error adding {filename}: {e}") + + + print(f"šŸ“ Found {len(found_photos)} photos, added {added_count} new photos") + return added_count + + + def process_faces(self, limit: int = 50, model: str = "hog") -> int: + """Process unprocessed photos for faces""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + cursor.execute( + 'SELECT id, path, filename FROM photos WHERE processed = 0 LIMIT ?', + (limit,) + ) + unprocessed = cursor.fetchall() + + if not unprocessed: + print("āœ… No unprocessed photos found") + return 0 + + print(f"šŸ” Processing {len(unprocessed)} photos for faces...") + processed_count = 0 + + for photo_id, photo_path, filename in unprocessed: + if not os.path.exists(photo_path): + print(f"āŒ File not found: {filename}") + cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (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)): + # Calculate face quality score + quality_score = self._calculate_face_quality_score(image, location) + + cursor.execute( + 'INSERT INTO faces (photo_id, encoding, location, quality_score) VALUES (?, ?, ?, ?)', + (photo_id, encoding.tobytes(), str(location), 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 + cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,)) + processed_count += 1 + + except Exception as e: + print(f"āŒ Error processing {filename}: {e}") + cursor.execute('UPDATE photos SET processed = 1 WHERE id = ?', (photo_id,)) + + if self.verbose == 0: + print() # New line after dots + print(f"āœ… Processed {processed_count} photos") + return processed_count + + def identify_faces(self, batch_size: int = 20, show_faces: bool = False, tolerance: float = 0.6, + date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int: + """Interactive face identification with optimized performance""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + # Build the SQL query with optional date filtering + query = ''' + 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 IS NULL + ''' + params = [] + + # Add date taken filtering if specified + if date_from: + query += ' AND p.date_taken >= ?' + params.append(date_from) + + if date_to: + query += ' AND p.date_taken <= ?' + params.append(date_to) + + # Add date processed filtering if specified + if date_processed_from: + query += ' AND DATE(p.date_added) >= ?' + params.append(date_processed_from) + + if date_processed_to: + query += ' AND DATE(p.date_added) <= ?' + params.append(date_processed_to) + + query += ' LIMIT ?' + params.append(batch_size) + + cursor.execute(query, params) + + unidentified = cursor.fetchall() + + if not unidentified: + print("šŸŽ‰ All faces have been identified!") + return 0 + + print(f"\nšŸ‘¤ Found {len(unidentified)} unidentified faces") + print("Commands: [name] = identify, 's' = skip, 'q' = quit, 'list' = show people\n") + + # Pre-fetch all needed data to avoid repeated database queries + print("šŸ“Š Pre-fetching data for optimal performance...") + identify_data_cache = {} + + with self.get_db_connection() as conn: + cursor = conn.cursor() + + # Pre-fetch all photo paths for unidentified faces + photo_ids = [face[1] for face in unidentified] # face[1] is photo_id + if photo_ids: + placeholders = ','.join('?' * len(photo_ids)) + cursor.execute(f'SELECT id, path, filename FROM photos WHERE id IN ({placeholders})', photo_ids) + identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()} + + # Pre-fetch all people names for dropdown + cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') + people = cursor.fetchall() + identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people] + # Pre-fetch unique last names for autocomplete (no DB during typing) + cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') + _last_rows = cursor.fetchall() + identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) + + print(f"āœ… Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names") + + identified_count = 0 + + # Use integrated GUI with image and input + import tkinter as tk + from tkinter import ttk, messagebox + from PIL import Image, ImageTk + import json + import os + + # Create the main window once + root = tk.Tk() + root.title("Face Identification") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + selected_person_id = None + force_exit = False + + # Track current face crop path for cleanup + current_face_crop_path = None + + # Hide window initially to prevent flash at corner + root.withdraw() + + def save_all_pending_identifications(): + """Save all pending identifications from face_person_names""" + nonlocal identified_count + saved_count = 0 + + for face_id, person_data in face_person_names.items(): + # Handle person data dict format + if isinstance(person_data, dict): + first_name = person_data.get('first_name', '').strip() + last_name = person_data.get('last_name', '').strip() + date_of_birth = person_data.get('date_of_birth', '').strip() + middle_name = person_data.get('middle_name', '').strip() + maiden_name = person_data.get('maiden_name', '').strip() + + # Only save if we have at least a first or last name + if first_name or last_name: + try: + with self.get_db_connection() as conn: + cursor = conn.cursor() + # Add person if doesn't exist + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + result = cursor.fetchone() + person_id = result[0] if result else None + + # Update people cache if new person was added + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + if display_name not in identify_data_cache['people_names']: + identify_data_cache['people_names'].append(display_name) + identify_data_cache['people_names'].sort() # Keep sorted + # Keep last names cache updated in-session + if last_name: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if last_name not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(last_name) + identify_data_cache['last_names'].sort() + + # Assign face to person + cursor.execute( + 'UPDATE faces SET person_id = ? WHERE id = ?', + (person_id, face_id) + ) + + # Update person encodings + self._update_person_encodings(person_id) + saved_count += 1 + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + print(f"āœ… Saved identification: {display_name}") + + except Exception as e: + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + print(f"āŒ Error saving identification for {display_name}: {e}") + else: + # Handle legacy string format - skip for now as it doesn't have complete data + pass + + if saved_count > 0: + identified_count += saved_count + print(f"šŸ’¾ Saved {saved_count} pending identifications") + + return saved_count + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal command, waiting_for_input, window_destroyed, current_face_crop_path, force_exit + + # First check for selected similar faces without person name + if not validate_navigation(): + return # Cancel close + + # Check if there are pending identifications (faces with complete data but not yet saved) + pending_identifications = {} + for k, v in face_person_names.items(): + if k not in face_status or face_status[k] != 'identified': + # Handle person data dict format + if isinstance(v, dict): + first_name = v.get('first_name', '').strip() + last_name = v.get('last_name', '').strip() + date_of_birth = v.get('date_of_birth', '').strip() + + # Check if we have complete data (both first and last name, plus date of birth) + if first_name and last_name and date_of_birth: + pending_identifications[k] = v + else: + # Handle legacy string format - not considered complete without date of birth + pass + + if pending_identifications: + # Ask user if they want to save pending identifications + result = messagebox.askyesnocancel( + "Save Pending Identifications?", + f"You have {len(pending_identifications)} pending identifications.\n\n" + "Do you want to save them before closing?\n\n" + "• Yes: Save all pending identifications and close\n" + "• No: Close without saving\n" + "• Cancel: Return to identification" + ) + + if result is True: # Yes - Save and close + save_all_pending_identifications() + command = 'q' + waiting_for_input = False + elif result is False: # No - Close without saving + command = 'q' + waiting_for_input = False + else: # Cancel - Don't close + return + + # Clean up face crops and caches + self._cleanup_face_crops(current_face_crop_path) + self.close_db_connection() + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + # Force process termination + force_exit = True + root.quit() + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving + saved_size = self._setup_window_size_saving(root) + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) # Left panel + main_frame.columnconfigure(1, weight=1) # Right panel for similar faces + # Configure row weights to minimize spacing around Unique checkbox + main_frame.rowconfigure(2, weight=0) # Unique checkbox row - no expansion + main_frame.rowconfigure(3, weight=1) # Main panels row - expandable + + # Photo info + info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold")) + info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) + + # Calendar dialog function for date filter + def open_date_calendar(date_var, title): + """Open a visual calendar dialog to select date""" + from datetime import datetime, date, timedelta + import calendar + + # Create calendar window + calendar_window = tk.Toplevel(root) + calendar_window.title(title) + calendar_window.resizable(False, False) + calendar_window.transient(root) + calendar_window.grab_set() + + # Calculate center position before showing the window + 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) + + # Set geometry with center position before showing + calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") + + # Calendar variables + current_date = datetime.now() + + # Check if there's already a date selected + existing_date_str = date_var.get().strip() + if existing_date_str: + try: + existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() + display_year = existing_date.year + display_month = existing_date.month + selected_date = existing_date + except ValueError: + # If existing date is invalid, use current date + display_year = current_date.year + display_month = current_date.month + selected_date = None + else: + # Default to current date + display_year = current_date.year + display_month = current_date.month + selected_date = None + + # Month names + month_names = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + # Create custom style for calendar buttons + style = ttk.Style() + style.configure("Calendar.TButton", padding=(2, 2)) + style.map("Calendar.TButton", + background=[("active", "#e1e1e1")], + relief=[("pressed", "sunken")]) + + # 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 (created once, updated later) + 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() + + # Button text and style + button_text = str(day) + if is_today: + button_text = f"•{day}•" # Mark today + + day_btn = ttk.Button(calendar_frame, text=button_text, + style="Calendar.TButton" if not is_selected else "Calendar.TButton", + command=lambda d=day_date: select_date(d)) + day_btn.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew") + + # Highlight selected date + if is_selected: + day_btn.configure(style="Calendar.TButton") + # Add visual indication of selection + day_btn.configure(text=f"[{day}]") + + def select_date(selected_day): + """Select a date and close calendar""" + nonlocal selected_date + selected_date = selected_day + date_var.set(selected_day.strftime('%Y-%m-%d')) + calendar_window.destroy() + + 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 + update_calendar() + + def prev_year(): + nonlocal display_year + display_year -= 1 + update_calendar() + + def next_year(): + nonlocal display_year + display_year += 1 + 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 clear_date(): + """Clear the selected date""" + date_var.set("") + calendar_window.destroy() + + # Clear button + clear_btn = ttk.Button(buttons_frame, text="Clear", command=clear_date) + clear_btn.pack(side=tk.LEFT) + + # Cancel button + cancel_btn = ttk.Button(buttons_frame, text="Cancel", command=calendar_window.destroy) + cancel_btn.pack(side=tk.RIGHT) + + # Initial calendar display + update_calendar() + + # Unique faces only checkbox variable (must be defined before widgets that use it) + unique_faces_var = tk.BooleanVar() + + # Define update_similar_faces function first - reusing auto-match display logic + def update_similar_faces(): + """Update the similar faces panel when compare is enabled - reuses auto-match display logic""" + nonlocal similar_faces_data, similar_face_vars, similar_face_images, similar_face_crops, face_selection_states + + # Note: Selection states are now saved automatically via callbacks (auto-match style) + + # Clear existing similar faces + for widget in similar_scrollable_frame.winfo_children(): + widget.destroy() + similar_face_vars.clear() + similar_face_images.clear() + + # Clean up existing face crops + for crop_path in similar_face_crops: + try: + if os.path.exists(crop_path): + os.remove(crop_path) + except: + pass + similar_face_crops.clear() + + if compare_var.get(): + # Use the same filtering and sorting logic as auto-match (no unique faces filter for similar faces) + unidentified_similar_faces = self._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status) + + if unidentified_similar_faces: + # Get current face_id for selection state management + current_face_id = original_faces[i][0] # Get current face_id + + # Reuse auto-match display logic for similar faces + self._display_similar_faces_in_panel(similar_scrollable_frame, unidentified_similar_faces, + similar_face_vars, similar_face_images, similar_face_crops, + current_face_id, face_selection_states, identify_data_cache) + + # Note: Selection states are now restored automatically during checkbox creation (auto-match style) + else: + # No similar unidentified faces found + no_faces_label = ttk.Label(similar_scrollable_frame, text="No similar unidentified faces found", + foreground="gray", font=("Arial", 10)) + no_faces_label.pack(pady=20) + else: + # Compare disabled - clear the panel + clear_label = ttk.Label(similar_scrollable_frame, text="Enable 'Compare with similar faces' to see matches", + foreground="gray", font=("Arial", 10)) + clear_label.pack(pady=20) + + # Update button states based on compare checkbox and list contents + update_select_clear_buttons_state() + + # Unique faces change handler (must be defined before checkbox that uses it) + def on_unique_faces_change(): + """Handle unique faces checkbox change""" + nonlocal original_faces, i + + if unique_faces_var.get(): + # Show progress message + print("šŸ”„ Applying unique faces filter...") + root.update() # Update UI to show the message + + # Apply unique faces filtering to the main face list + try: + original_faces = self._filter_unique_faces_from_list(original_faces) + print(f"āœ… Filter applied: {len(original_faces)} unique faces remaining") + except Exception as e: + print(f"āš ļø Error applying filter: {e}") + # Revert checkbox state + unique_faces_var.set(False) + return + else: + # Reload the original unfiltered face list + print("šŸ”„ Reloading all faces...") + root.update() # Update UI to show the message + + with self.get_db_connection() as conn: + cursor = conn.cursor() + query = ''' + 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 IS NULL + ''' + params = [] + + # Add date taken filtering if specified + if date_from: + query += ' AND p.date_taken >= ?' + params.append(date_from) + + if date_to: + query += ' AND p.date_taken <= ?' + params.append(date_to) + + # Add date processed filtering if specified + if date_processed_from: + query += ' AND DATE(p.date_added) >= ?' + params.append(date_processed_from) + + if date_processed_to: + query += ' AND DATE(p.date_added) <= ?' + params.append(date_processed_to) + + query += ' ORDER BY f.id' + cursor.execute(query, params) + original_faces = list(cursor.fetchall()) + + print(f"āœ… Reloaded: {len(original_faces)} faces") + + # Reset to first face and update display + i = 0 + update_similar_faces() + + # Compare checkbox variable and handler (must be defined before widgets that use it) + compare_var = tk.BooleanVar() + + def on_compare_change(): + """Handle compare checkbox change""" + update_similar_faces() + update_select_clear_buttons_state() + + # Date filter controls + date_filter_frame = ttk.LabelFrame(main_frame, text="Filter", padding="5") + date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) + date_filter_frame.columnconfigure(1, weight=0) + date_filter_frame.columnconfigure(4, weight=0) + + # Date from + ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) + date_from_var = tk.StringVar(value=date_from or "") + date_from_entry = ttk.Entry(date_filter_frame, textvariable=date_from_var, width=10, state='readonly') + date_from_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 5)) + + # Calendar button for date from + def open_calendar_from(): + open_date_calendar(date_from_var, "Select Start Date") + + calendar_from_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_from) + calendar_from_btn.grid(row=0, column=2, padx=(0, 10)) + + # Date to + ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) + date_to_var = tk.StringVar(value=date_to or "") + date_to_entry = ttk.Entry(date_filter_frame, textvariable=date_to_var, width=10, state='readonly') + date_to_entry.grid(row=0, column=4, sticky=tk.W, padx=(0, 5)) + + # Calendar button for date to + def open_calendar_to(): + open_date_calendar(date_to_var, "Select End Date") + + calendar_to_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_to) + calendar_to_btn.grid(row=0, column=5, padx=(0, 10)) + + # Apply filter button + def apply_date_filter(): + nonlocal date_from, date_to + date_from = date_from_var.get().strip() or None + date_to = date_to_var.get().strip() or None + date_processed_from = date_processed_from_var.get().strip() or None + date_processed_to = date_processed_to_var.get().strip() or None + + # Reload faces with new date filter + with self.get_db_connection() as conn: + cursor = conn.cursor() + + # Build the SQL query with optional date filtering + query = ''' + 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 IS NULL + ''' + params = [] + + # Add date taken filtering if specified + if date_from: + query += ' AND p.date_taken >= ?' + params.append(date_from) + + if date_to: + query += ' AND p.date_taken <= ?' + params.append(date_to) + + # Add date processed filtering if specified + if date_processed_from: + query += ' AND DATE(p.date_added) >= ?' + params.append(date_processed_from) + + if date_processed_to: + query += ' AND DATE(p.date_added) <= ?' + params.append(date_processed_to) + + query += ' LIMIT ?' + params.append(batch_size) + + cursor.execute(query, params) + unidentified = cursor.fetchall() + + if not unidentified: + messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") + return + + # Update the global unidentified list and reset position + nonlocal current_pos, total_unidentified + current_pos = 0 + total_unidentified = len(unidentified) + + # Reset to first face - display will update when user navigates + if len(unidentified) > 0: + # Reset to first face + current_pos = 0 + # The display will be updated when the user navigates or when the window is shown + + # Build filter description + filters_applied = [] + if date_from or date_to: + taken_filter = f"taken: {date_from or 'any'} to {date_to or 'any'}" + filters_applied.append(taken_filter) + if date_processed_from or date_processed_to: + processed_filter = f"processed: {date_processed_from or 'any'} to {date_processed_to or 'any'}" + filters_applied.append(processed_filter) + + filter_desc = " | ".join(filters_applied) if filters_applied else "no filters" + + print(f"šŸ“… Applied filters: {filter_desc}") + print(f"šŸ‘¤ Found {len(unidentified)} unidentified faces with date filters") + print("šŸ’” Navigate to refresh the display with filtered faces") + + # Apply filter button (inside filter frame) + apply_filter_btn = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) + apply_filter_btn.grid(row=0, column=6, padx=(10, 0)) + + # Date processed filter (second row) + ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) + date_processed_from_var = tk.StringVar() + date_processed_from_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_from_var, width=10, state='readonly') + date_processed_from_entry.grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0)) + + # Calendar button for date processed from + def open_calendar_processed_from(): + open_date_calendar(date_processed_from_var, "Select Processing Start Date") + + calendar_processed_from_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_from) + calendar_processed_from_btn.grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) + + # Date processed to + ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) + date_processed_to_var = tk.StringVar() + date_processed_to_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_to_var, width=10, state='readonly') + date_processed_to_entry.grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0)) + + # Calendar button for date processed to + def open_calendar_processed_to(): + open_date_calendar(date_processed_to_var, "Select Processing End Date") + + calendar_processed_to_btn = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_to) + calendar_processed_to_btn.grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) + + # Unique checkbox under the filter frame + unique_faces_checkbox = ttk.Checkbutton(main_frame, text="Unique faces only", + variable=unique_faces_var, command=on_unique_faces_change) + unique_faces_checkbox.grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0) + + # Compare checkbox on the same row as Unique + compare_checkbox = ttk.Checkbutton(main_frame, text="Compare similar faces", variable=compare_var, + command=on_compare_change) + compare_checkbox.grid(row=2, column=1, sticky=tk.W, padx=(5, 10), pady=0) + + # Left panel for main face + left_panel = ttk.Frame(main_frame) + left_panel.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5), pady=(0, 0)) + left_panel.columnconfigure(0, weight=1) + + # Right panel for similar faces + right_panel = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5") + right_panel.grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) + right_panel.columnconfigure(0, weight=1) + right_panel.rowconfigure(0, weight=1) # Make right panel expandable vertically + + # Image display (left panel) + image_frame = ttk.Frame(left_panel) + image_frame.grid(row=0, column=0, pady=(0, 10), sticky=(tk.W, tk.E, tk.N, tk.S)) + image_frame.columnconfigure(0, weight=1) + image_frame.rowconfigure(0, weight=1) + + # Create canvas for image display + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + canvas = tk.Canvas(image_frame, width=400, height=400, bg=canvas_bg_color, highlightthickness=0) + canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Input section (left panel) + input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10") + input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E)) + input_frame.columnconfigure(1, weight=1) + input_frame.columnconfigure(3, weight=1) + input_frame.columnconfigure(5, weight=1) + input_frame.columnconfigure(7, weight=1) + + # First name input + ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) + first_name_var = tk.StringVar() + first_name_entry = ttk.Entry(input_frame, textvariable=first_name_var, width=12) + first_name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Red asterisk for required first name field (overlayed, no layout impact) + first_name_asterisk = ttk.Label(root, text="*", foreground="red") + first_name_asterisk.place_forget() + + # Last name input (with live listbox autocomplete) + ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10)) + last_name_var = tk.StringVar() + last_name_entry = ttk.Entry(input_frame, textvariable=last_name_var, width=12) + last_name_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Red asterisk for required last name field (overlayed, no layout impact) + last_name_asterisk = ttk.Label(root, text="*", foreground="red") + last_name_asterisk.place_forget() + + def _position_required_asterisks(event=None): + """Position required asterisks at top-right corner of their entries.""" + try: + root.update_idletasks() + input_frame.update_idletasks() + first_name_entry.update_idletasks() + last_name_entry.update_idletasks() + date_of_birth_entry.update_idletasks() + + # Get absolute coordinates relative to root window + first_root_x = first_name_entry.winfo_rootx() + first_root_y = first_name_entry.winfo_rooty() + first_w = first_name_entry.winfo_width() + root_x = root.winfo_rootx() + root_y = root.winfo_rooty() + + # First name asterisk at the true top-right corner of entry + first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw') + first_name_asterisk.lift() + + # Last name asterisk at the true top-right corner of entry + last_root_x = last_name_entry.winfo_rootx() + last_root_y = last_name_entry.winfo_rooty() + last_w = last_name_entry.winfo_width() + last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw') + last_name_asterisk.lift() + + # Date of birth asterisk at the true top-right corner of date entry + dob_root_x = date_of_birth_entry.winfo_rootx() + dob_root_y = date_of_birth_entry.winfo_rooty() + dob_w = date_of_birth_entry.winfo_width() + date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw') + date_asterisk.lift() + except Exception: + pass + + # Bind repositioning after all entries are created + def _bind_asterisk_positioning(): + try: + input_frame.bind('', _position_required_asterisks) + first_name_entry.bind('', _position_required_asterisks) + last_name_entry.bind('', _position_required_asterisks) + date_of_birth_entry.bind('', _position_required_asterisks) + _position_required_asterisks() + except Exception: + pass + root.after(100, _bind_asterisk_positioning) + + # Create listbox for suggestions (as overlay attached to root, not clipped by frames) + last_name_listbox = tk.Listbox(root, height=8) + last_name_listbox.place_forget() # Hide initially + + def _show_suggestions(): + """Show filtered suggestions in listbox""" + all_last_names = identify_data_cache.get('last_names', []) + typed = last_name_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 all_last_names if n.lower().startswith(low)][:10] + + # Update listbox + last_name_listbox.delete(0, tk.END) + for name in filtered: + last_name_listbox.insert(tk.END, name) + + # Show listbox if we have suggestions (as overlay) + if filtered: + # Ensure geometry is up to date before positioning + root.update_idletasks() + # Absolute coordinates of entry relative to screen + entry_root_x = last_name_entry.winfo_rootx() + entry_root_y = last_name_entry.winfo_rooty() + entry_height = last_name_entry.winfo_height() + # Convert to coordinates relative to root + root_origin_x = root.winfo_rootx() + root_origin_y = root.winfo_rooty() + place_x = entry_root_x - root_origin_x + place_y = entry_root_y - root_origin_y + entry_height + place_width = last_name_entry.winfo_width() + # Calculate how many rows fit to bottom of window + available_px = max(60, root.winfo_height() - place_y - 8) + # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows + approx_row_px = 18 + rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) + last_name_listbox.configure(height=rows_fit) + last_name_listbox.place(x=place_x, y=place_y, width=place_width) + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(0) # Select first item + last_name_listbox.activate(0) # Activate first item + else: + last_name_listbox.place_forget() + + def _hide_suggestions(): + """Hide the suggestions listbox""" + last_name_listbox.place_forget() + + def _on_listbox_select(event=None): + """Handle listbox selection and hide list""" + selection = last_name_listbox.curselection() + if selection: + selected_name = last_name_listbox.get(selection[0]) + last_name_var.set(selected_name) + _hide_suggestions() + last_name_entry.focus_set() + + def _on_listbox_click(event): + """Handle mouse click selection""" + try: + index = last_name_listbox.nearest(event.y) + if index is not None and index >= 0: + selected_name = last_name_listbox.get(index) + last_name_var.set(selected_name) + except: + pass + _hide_suggestions() + last_name_entry.focus_set() + return 'break' + + def _on_key_press(event): + """Handle key navigation in entry""" + nonlocal navigating_to_listbox, escape_pressed, enter_pressed + if event.keysym == 'Down': + if last_name_listbox.winfo_ismapped(): + navigating_to_listbox = True + last_name_listbox.focus_set() + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(0) + last_name_listbox.activate(0) + return 'break' + elif event.keysym == 'Escape': + escape_pressed = True + _hide_suggestions() + return 'break' + elif event.keysym == 'Return': + enter_pressed = True + return 'break' + + def _on_listbox_key(event): + """Handle key navigation in listbox""" + nonlocal enter_pressed, escape_pressed + if event.keysym == 'Return': + enter_pressed = True + _on_listbox_select(event) + return 'break' + elif event.keysym == 'Escape': + escape_pressed = True + _hide_suggestions() + last_name_entry.focus_set() + return 'break' + elif event.keysym == 'Up': + selection = last_name_listbox.curselection() + if selection and selection[0] > 0: + # Move up in listbox + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(selection[0] - 1) + last_name_listbox.see(selection[0] - 1) + else: + # At top, go back to entry field + _hide_suggestions() + last_name_entry.focus_set() + return 'break' + elif event.keysym == 'Down': + selection = last_name_listbox.curselection() + max_index = last_name_listbox.size() - 1 + if selection and selection[0] < max_index: + # Move down in listbox + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(selection[0] + 1) + last_name_listbox.see(selection[0] + 1) + return 'break' + + # Track if we're navigating to listbox to prevent auto-hide + navigating_to_listbox = False + escape_pressed = False + enter_pressed = False + + def _safe_hide_suggestions(): + """Hide suggestions only if not navigating to listbox""" + nonlocal navigating_to_listbox + if not navigating_to_listbox: + _hide_suggestions() + navigating_to_listbox = False + + def _safe_show_suggestions(): + """Show suggestions only if escape or enter wasn't just pressed""" + nonlocal escape_pressed, enter_pressed + if not escape_pressed and not enter_pressed: + _show_suggestions() + escape_pressed = False + enter_pressed = False + + # Bind events + last_name_entry.bind('', lambda e: _safe_show_suggestions()) + last_name_entry.bind('', _on_key_press) + last_name_entry.bind('', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks + last_name_listbox.bind('', _on_listbox_click) + last_name_listbox.bind('', _on_listbox_key) + last_name_listbox.bind('', _on_listbox_click) + + # Middle name input + ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) + middle_name_var = tk.StringVar() + middle_name_entry = ttk.Entry(input_frame, textvariable=middle_name_var, width=12) + middle_name_entry.grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Date of birth input with calendar chooser + ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10)) + date_of_birth_var = tk.StringVar() + + # Create a frame for the date picker + date_frame = ttk.Frame(input_frame) + date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10)) + + # Maiden name input + ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10)) + maiden_name_var = tk.StringVar() + maiden_name_entry = ttk.Entry(input_frame, textvariable=maiden_name_var, width=12) + maiden_name_entry.grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Date display entry (read-only) + date_of_birth_entry = ttk.Entry(date_frame, textvariable=date_of_birth_var, width=12, state='readonly') + date_of_birth_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Red asterisk for required date of birth field (overlayed, no layout impact) + date_asterisk = ttk.Label(root, text="*", foreground="red") + date_asterisk.place_forget() + + # Calendar button + calendar_btn = ttk.Button(date_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) + calendar_btn.pack(side=tk.RIGHT, padx=(15, 0)) + + def open_calendar(): + """Open a visual calendar dialog to select date of birth""" + from datetime import datetime, date, timedelta + import calendar + + # Create calendar window + calendar_window = tk.Toplevel(root) + calendar_window.title("Select Date of Birth") + calendar_window.resizable(False, False) + calendar_window.transient(root) + calendar_window.grab_set() + + # Calculate center position before showing the window + 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) + + # Set geometry with center position before showing + calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") + + # Calendar variables + current_date = datetime.now() + + # Check if there's already a date selected + existing_date_str = date_of_birth_var.get().strip() + if existing_date_str: + try: + existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() + display_year = existing_date.year + display_month = existing_date.month + selected_date = existing_date + except ValueError: + # If existing date is invalid, use default + display_year = current_date.year - 25 + display_month = 1 + selected_date = None + else: + # Default to 25 years ago + display_year = current_date.year - 25 + display_month = 1 + selected_date = None + + # Month names + month_names = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + # Configure custom styles for better visual highlighting + style = ttk.Style() + + # Selected date style - bright blue background with white text + style.configure("Selected.TButton", + background="#0078d4", + foreground="white", + font=("Arial", 9, "bold"), + relief="raised", + borderwidth=2) + style.map("Selected.TButton", + background=[("active", "#106ebe")], + relief=[("pressed", "sunken")]) + + # Today's date style - orange background + style.configure("Today.TButton", + background="#ff8c00", + foreground="white", + font=("Arial", 9, "bold"), + relief="raised", + borderwidth=1) + style.map("Today.TButton", + background=[("active", "#e67e00")], + relief=[("pressed", "sunken")]) + + # Calendar-specific normal button style (don't affect global TButton) + style.configure("Calendar.TButton", + font=("Arial", 9), + relief="flat") + style.map("Calendar.TButton", + background=[("active", "#e1e1e1")], + relief=[("pressed", "sunken")]) + + # 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() + + def update_calendar(): + """Update the calendar display""" + # Clear existing calendar + for widget in calendar_frame.winfo_children(): + widget.destroy() + + # Update header + month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") + + # 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): + label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) + label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") + + # Calendar days + for week_num, week in enumerate(cal): + for day_num, day in enumerate(week): + if day == 0: + # Empty cell + label = ttk.Label(calendar_frame, text="") + label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") + else: + # Day button + 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 with prominent style + 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), + width=3, style="Calendar.TButton") + day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") + + # Check if this day should be highlighted + is_today = (display_year == current_date.year and + display_month == current_date.month and + day == current_date.day) + is_selected = (selected_date and + selected_date.year == display_year and + selected_date.month == display_month and + selected_date.day == day) + + if is_selected: + day_btn.config(style="Selected.TButton") + elif is_today: + day_btn.config(style="Today.TButton") + + # Navigation functions + def prev_year(): + nonlocal display_year + display_year = max(1900, display_year - 1) + update_calendar() + + def next_year(): + nonlocal display_year + display_year = min(current_date.year, display_year + 1) + update_calendar() + + def prev_month(): + nonlocal display_month, display_year + if display_month > 1: + display_month -= 1 + else: + display_month = 12 + display_year = max(1900, display_year - 1) + update_calendar() + + def next_month(): + nonlocal display_month, display_year + if display_month < 12: + display_month += 1 + else: + display_month = 1 + display_year = min(current_date.year, display_year + 1) + update_calendar() + + # Navigation buttons + prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) + prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) + + prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) + prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) + + month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) + month_year_label.pack(side=tk.LEFT, padx=5) + + next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) + next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) + + 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: + date_str = selected_date.strftime('%Y-%m-%d') + date_of_birth_var.set(date_str) + calendar_window.destroy() + else: + messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") + + def cancel_selection(): + """Cancel date selection""" + calendar_window.destroy() + + # Buttons + 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 + update_calendar() + + # (moved) unique_faces_var is defined earlier before date filter widgets + + # (moved) update_similar_faces function is defined earlier before on_unique_faces_change + + # (moved) Compare checkbox is now inside date_filter_frame to the right of dates + + # (moved) on_unique_faces_change function is defined earlier before date filter widgets + + + # Add callback to save person name when it changes + def on_name_change(*args): + if i < len(original_faces): + current_face_id = original_faces[i][0] + first_name = first_name_var.get().strip() + last_name = last_name_var.get().strip() + middle_name = middle_name_var.get().strip() + maiden_name = maiden_name_var.get().strip() + date_of_birth = date_of_birth_var.get().strip() + + if first_name or last_name or date_of_birth: + # Store as dictionary to maintain consistency + face_person_names[current_face_id] = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': middle_name, + 'maiden_name': maiden_name, + 'date_of_birth': date_of_birth + } + elif current_face_id in face_person_names: + # Remove empty names from storage + del face_person_names[current_face_id] + + first_name_var.trace('w', on_name_change) + last_name_var.trace('w', on_name_change) + date_of_birth_var.trace('w', on_name_change) + + # Buttons moved to bottom of window + + + # Right panel for similar faces + similar_faces_frame = ttk.Frame(right_panel) + similar_faces_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + similar_faces_frame.columnconfigure(0, weight=1) + similar_faces_frame.rowconfigure(0, weight=0) # Controls row takes minimal space + similar_faces_frame.rowconfigure(1, weight=1) # Canvas row expandable + + # Control buttons for similar faces (Select All / Clear All) + similar_controls_frame = ttk.Frame(similar_faces_frame) + similar_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) + + def select_all_similar_faces(): + """Select all similar faces checkboxes""" + for face_id, var in similar_face_vars: + var.set(True) + + def clear_all_similar_faces(): + """Clear all similar faces checkboxes""" + for face_id, var in similar_face_vars: + var.set(False) + + select_all_btn = ttk.Button(similar_controls_frame, text="ā˜‘ļø Select All", command=select_all_similar_faces, state='disabled') + select_all_btn.pack(side=tk.LEFT, padx=(0, 5)) + + clear_all_btn = ttk.Button(similar_controls_frame, text="☐ Clear All", command=clear_all_similar_faces, state='disabled') + clear_all_btn.pack(side=tk.LEFT) + + def update_select_clear_buttons_state(): + """Enable/disable Select All and Clear All based on compare state and presence of items""" + if compare_var.get() and similar_face_vars: + select_all_btn.config(state='normal') + clear_all_btn.config(state='normal') + else: + select_all_btn.config(state='disabled') + clear_all_btn.config(state='disabled') + + # Create canvas for similar faces with scrollbar + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + similar_canvas = tk.Canvas(similar_faces_frame, bg=canvas_bg_color, highlightthickness=0) + similar_scrollbar = ttk.Scrollbar(similar_faces_frame, orient="vertical", command=similar_canvas.yview) + similar_scrollable_frame = ttk.Frame(similar_canvas) + + similar_scrollable_frame.bind( + "", + lambda e: similar_canvas.configure(scrollregion=similar_canvas.bbox("all")) + ) + + similar_canvas.create_window((0, 0), window=similar_scrollable_frame, anchor="nw") + similar_canvas.configure(yscrollcommand=similar_scrollbar.set) + + # Pack canvas and scrollbar + similar_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + similar_scrollbar.grid(row=0, column=1, rowspan=2, sticky=(tk.N, tk.S)) + + # Variables for similar faces + similar_faces_data = [] + similar_face_vars = [] + similar_face_images = [] + similar_face_crops = [] + + # Store face selection states per face ID to preserve selections during navigation (auto-match style) + face_selection_states = {} # {face_id: {unique_key: bool}} + + # Store person names per face ID to preserve names during navigation + face_person_names = {} # {face_id: person_name} + + def save_current_face_selection_states(): + """Save current checkbox states and person name for the current face (auto-match style backup)""" + if i < len(original_faces): + current_face_id = original_faces[i][0] + + # Save checkbox states + if similar_face_vars: + if current_face_id not in face_selection_states: + face_selection_states[current_face_id] = {} + + # Save current checkbox states using unique keys + for similar_face_id, var in similar_face_vars: + unique_key = f"{current_face_id}_{similar_face_id}" + face_selection_states[current_face_id][unique_key] = var.get() + + # Save person name and date of birth + first_name = first_name_var.get().strip() + last_name = last_name_var.get().strip() + middle_name = middle_name_var.get().strip() + maiden_name = maiden_name_var.get().strip() + date_of_birth = date_of_birth_var.get().strip() + + if first_name or last_name: + # Store all fields + face_person_names[current_face_id] = { + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': middle_name, + 'maiden_name': maiden_name, + 'date_of_birth': date_of_birth + } + + # Button commands + command = None + waiting_for_input = False + + def on_identify(): + nonlocal command, waiting_for_input + first_name = first_name_var.get().strip() + last_name = last_name_var.get().strip() + middle_name = middle_name_var.get().strip() + maiden_name = maiden_name_var.get().strip() + date_of_birth = date_of_birth_var.get().strip() + compare_enabled = compare_var.get() + + if not first_name: + print("āš ļø Please enter a first name before identifying") + return + + if not last_name: + print("āš ļø Please enter a last name before identifying") + return + + if not date_of_birth: + print("āš ļø Please select a date of birth before identifying") + return + + # Validate date format (YYYY-MM-DD) - should always be valid from calendar + try: + from datetime import datetime + datetime.strptime(date_of_birth, '%Y-%m-%d') + except ValueError: + print("āš ļø Invalid date format. Please use the calendar to select a date.") + return + + # Combine first and last name properly + if last_name and first_name: + command = f"{last_name}, {first_name}" + elif last_name: + command = last_name + elif first_name: + command = first_name + else: + command = "" + + # Store the additional fields for database insertion + # We'll pass them through the command structure + if middle_name or maiden_name: + command += f"|{middle_name}|{maiden_name}|{date_of_birth}" + else: + command += f"|||{date_of_birth}" + + if not command: + print("āš ļø Please enter at least a first name or last name before identifying") + return + + if compare_enabled: + # Get selected similar faces + selected_face_ids = [face_id for face_id, var in similar_face_vars if var.get()] + if selected_face_ids: + # Create compare command with selected face IDs + command = f"compare:{command}:{','.join(map(str, selected_face_ids))}" + # If no similar faces selected, just identify the current face + else: + # Regular identification + pass + + waiting_for_input = False + + + def validate_navigation(): + """Check if navigation is allowed (no selected similar faces without person name)""" + # Check if compare is enabled and similar faces are selected + if compare_var.get() and similar_face_vars: + selected_faces = [face_id for face_id, var in similar_face_vars if var.get()] + first_name = first_name_var.get().strip() + last_name = last_name_var.get().strip() + if selected_faces and not (first_name or last_name): + # Show warning dialog + result = messagebox.askyesno( + "Selected Faces Not Identified", + f"You have {len(selected_faces)} similar face(s) selected but no person name entered.\n\n" + "These faces will not be identified if you continue.\n\n" + "Do you want to continue anyway?", + icon='warning' + ) + return result # True = continue, False = cancel + return True # No validation issues, allow navigation + + def on_back(): + nonlocal command, waiting_for_input + if not validate_navigation(): + return # Cancel navigation + command = 'back' + waiting_for_input = False + + def on_skip(): + nonlocal command, waiting_for_input + if not validate_navigation(): + return # Cancel navigation + command = 's' + waiting_for_input = False + + def on_quit(): + nonlocal command, waiting_for_input, window_destroyed, force_exit + + # First check for selected similar faces without person name + if not validate_navigation(): + return # Cancel quit + + # Check if there are pending identifications (faces with complete data but not yet saved) + pending_identifications = {} + for k, v in face_person_names.items(): + if k not in face_status or face_status[k] != 'identified': + # Handle person data dict format + if isinstance(v, dict): + first_name = v.get('first_name', '').strip() + last_name = v.get('last_name', '').strip() + date_of_birth = v.get('date_of_birth', '').strip() + + # Check if we have complete data (both first and last name, plus date of birth) + if first_name and last_name and date_of_birth: + pending_identifications[k] = v + else: + # Handle legacy string format + person_name = v.strip() + date_of_birth = '' # Legacy format doesn't have date_of_birth + # Legacy format is not considered complete without date of birth + pass + + if pending_identifications: + # Ask user if they want to save pending identifications + result = messagebox.askyesnocancel( + "Save Pending Identifications?", + f"You have {len(pending_identifications)} pending identifications.\n\n" + "Do you want to save them before quitting?\n\n" + "• Yes: Save all pending identifications and quit\n" + "• No: Quit without saving\n" + "• Cancel: Return to identification" + ) + + if result is True: # Yes - Save and quit + save_all_pending_identifications() + command = 'q' + waiting_for_input = False + elif result is False: # No - Quit without saving + command = 'q' + waiting_for_input = False + else: # Cancel - Don't quit + return + else: + # No pending identifications, quit normally + command = 'q' + waiting_for_input = False + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + # Force process termination + force_exit = True + root.quit() + + + + def update_button_states(): + """Update button states based on current position and unidentified faces""" + # Check if there are previous unidentified faces + has_prev_unidentified = False + for j in range(i - 1, -1, -1): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + has_prev_unidentified = True + break + + # Check if there are next unidentified faces + has_next_unidentified = False + for j in range(i + 1, len(original_faces)): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + has_next_unidentified = True + break + + # Enable/disable Back button + if has_prev_unidentified: + back_btn.config(state='normal') + else: + back_btn.config(state='disabled') + + # Enable/disable Next button + if has_next_unidentified: + next_btn.config(state='normal') + else: + next_btn.config(state='disabled') + + # Button references moved to bottom control panel + + def update_identify_button_state(): + """Enable/disable identify button based on first name, last name, and date of birth""" + first_name = first_name_var.get().strip() + last_name = last_name_var.get().strip() + date_of_birth = date_of_birth_var.get().strip() + if first_name and last_name and date_of_birth: + identify_btn.config(state='normal') + else: + identify_btn.config(state='disabled') + + # Bind name input changes to update button state + first_name_var.trace('w', lambda *args: update_identify_button_state()) + last_name_var.trace('w', lambda *args: update_identify_button_state()) + date_of_birth_var.trace('w', lambda *args: update_identify_button_state()) + + # Handle Enter key + def on_enter(event): + on_identify() + + first_name_entry.bind('', on_enter) + last_name_entry.bind('', on_enter) + + # Bottom control panel (move to bottom below panels) + control_frame = ttk.Frame(main_frame) + control_frame.grid(row=4, column=0, columnspan=3, pady=(10, 0), sticky=(tk.E, tk.S)) + + # Create button references for state management + back_btn = ttk.Button(control_frame, text="ā¬…ļø Back", command=on_back) + next_btn = ttk.Button(control_frame, text="āž”ļø Next", command=on_skip) + quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) + + back_btn.pack(side=tk.LEFT, padx=(0, 5)) + next_btn.pack(side=tk.LEFT, padx=(0, 5)) + quit_btn.pack(side=tk.LEFT, padx=(5, 0)) + + # Identify button (placed after on_identify is defined) + identify_btn = ttk.Button(input_frame, text="āœ… Identify", command=on_identify, state='disabled') + identify_btn.grid(row=4, column=0, pady=(10, 0), sticky=tk.W) + + # Show the window + try: + root.deiconify() + root.lift() + root.focus_force() + except tk.TclError: + # Window was destroyed before we could show it + conn.close() + return 0 + + + + # Process each face with back navigation support + # Keep track of original face list and current position + original_faces = list(unidentified) # Make a copy of the original list + i = 0 + face_status = {} # Track which faces have been identified + + def get_unidentified_faces(): + """Get list of faces that haven't been identified yet""" + return [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] + + def get_current_face_position(): + """Get current face position among unidentified faces""" + unidentified_faces = get_unidentified_faces() + current_face_id = original_faces[i][0] if i < len(original_faces) else None + + # Find position of current face in unidentified list + for pos, face in enumerate(unidentified_faces): + if face[0] == current_face_id: + return pos + 1, len(unidentified_faces) + + return 1, len(unidentified_faces) # Fallback + + def update_current_face_index(): + """Update the current face index to point to a valid unidentified face""" + nonlocal i + unidentified_faces = get_unidentified_faces() + if not unidentified_faces: + # All faces identified, we're done + return False + + # Find the current face in the unidentified list + current_face_id = original_faces[i][0] if i < len(original_faces) else None + if current_face_id and current_face_id in face_status and face_status[current_face_id] == 'identified': + # Current face was just identified, find the next unidentified face + if i < len(original_faces) - 1: + # Try to find the next unidentified face + for j in range(i + 1, len(original_faces)): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + break + else: + # No more faces after current, go to previous + for j in range(i - 1, -1, -1): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + break + else: + # At the end, go to previous unidentified face + for j in range(i - 1, -1, -1): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + break + + # Ensure index is within bounds + if i >= len(original_faces): + i = len(original_faces) - 1 + if i < 0: + i = 0 + + return True + + while not window_destroyed: + # Check if current face is identified and update index if needed + if not update_current_face_index(): + # All faces have been identified + print("\nšŸŽ‰ All faces have been identified!") + break + + # Ensure we don't go beyond the bounds + if i >= len(original_faces): + # Stay on the last face instead of breaking + i = len(original_faces) - 1 + + face_id, photo_id, photo_path, filename, location = original_faces[i] + + # Check if this face was already identified in this session + is_already_identified = face_id in face_status and face_status[face_id] == 'identified' + + # Reset command and waiting state for each face + command = None + waiting_for_input = True + + # Update the display + current_pos, total_unidentified = get_current_face_position() + print(f"\n--- Face {current_pos}/{total_unidentified} (unidentified) ---") + print(f"šŸ“ Photo: {filename}") + print(f"šŸ“ Face location: {location}") + + # Update title + root.title(f"Face Identification - {current_pos}/{total_unidentified} (unidentified)") + + # Update button states + update_button_states() + + # Update similar faces panel if compare is enabled + if compare_var.get(): + update_similar_faces() + + # Update photo info + if is_already_identified: + # Get the person name for this face + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT p.first_name, p.last_name FROM people p + JOIN faces f ON p.id = f.person_id + WHERE f.id = ? + ''', (face_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" + + info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified}) - āœ… Already identified as: {person_name}") + print(f"āœ… Already identified as: {person_name}") + else: + info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified})") + + # Extract face crop if enabled + face_crop_path = None + if show_faces: + face_crop_path = self._extract_face_crop(photo_path, location, face_id) + if face_crop_path: + print(f"šŸ–¼ļø Face crop saved: {face_crop_path}") + current_face_crop_path = face_crop_path # Track for cleanup + else: + print("šŸ’” Use --show-faces flag to display individual face crops") + current_face_crop_path = None + + print(f"\nšŸ–¼ļø Viewing face {current_pos}/{total_unidentified} from {filename}") + + # Clear and update image + canvas.delete("all") + if show_faces and face_crop_path and os.path.exists(face_crop_path): + try: + # Load and display the face crop image + pil_image = Image.open(face_crop_path) + + # Get canvas dimensions + canvas_width = canvas.winfo_width() + canvas_height = canvas.winfo_height() + + # If canvas hasn't been rendered yet, force update and use actual size + if canvas_width <= 1 or canvas_height <= 1: + # Force the canvas to update its geometry + canvas.update_idletasks() + canvas_width = canvas.winfo_width() + canvas_height = canvas.winfo_height() + + # If still not rendered, use default size + if canvas_width <= 1: + canvas_width = 400 + if canvas_height <= 1: + canvas_height = 400 + + # Calculate scaling to fit within the canvas while maintaining aspect ratio + img_width, img_height = pil_image.size + scale_x = canvas_width / img_width + scale_y = canvas_height / img_height + # Allow slight upscaling (up to 1.2x) for better visibility, but cap to avoid excessive blurriness + max_scale = min(1.2, max(scale_x, scale_y)) + scale = min(scale_x, scale_y, max_scale) + + # Resize image to fill canvas + new_width = int(img_width * scale) + new_height = int(img_height * scale) + pil_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + photo = ImageTk.PhotoImage(pil_image) + + # Center the image in the canvas + x = canvas_width // 2 + y = canvas_height // 2 + canvas.create_image(x, y, image=photo) + + # Keep a reference to prevent garbage collection + canvas.image = photo + + # Add photo icon using reusable function + self._create_photo_icon(canvas, photo_path, + face_x=x, face_y=y, + face_width=new_width, face_height=new_height, + canvas_width=canvas_width, canvas_height=canvas_height) + + except Exception as e: + canvas.create_text(200, 200, text=f"āŒ Could not load image: {e}", fill="red") + else: + canvas.create_text(200, 200, text="šŸ–¼ļø No face crop available", fill="gray") + + # Set person name input - restore saved name or use database/empty value + if face_id in face_person_names: + # Restore previously entered name for this face + person_data = face_person_names[face_id] + if isinstance(person_data, dict): + # Handle dictionary format - use individual field values for proper restoration + first_name = person_data.get('first_name', '').strip() + last_name = person_data.get('last_name', '').strip() + middle_name = person_data.get('middle_name', '').strip() + maiden_name = person_data.get('maiden_name', '').strip() + date_of_birth = person_data.get('date_of_birth', '').strip() + + # Restore all fields directly + first_name_var.set(first_name) + last_name_var.set(last_name) + middle_name_var.set(middle_name) + maiden_name_var.set(maiden_name) + date_of_birth_var.set(date_of_birth) + else: + # Handle legacy string format (for backward compatibility) + full_name = person_data + # Parse "Last, First" format back to separate fields + if ', ' in full_name: + parts = full_name.split(', ', 1) + last_name_var.set(parts[0].strip()) + first_name_var.set(parts[1].strip()) + else: + # Single name format + first_name_var.set(full_name) + last_name_var.set("") + elif is_already_identified: + # Pre-populate with the current person name from database + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth FROM people p + JOIN faces f ON p.id = f.person_id + WHERE f.id = ? + ''', (face_id,)) + result = cursor.fetchone() + if result: + first_name_var.set(result[0] or "") + last_name_var.set(result[1] or "") + middle_name_var.set(result[2] or "") + maiden_name_var.set(result[3] or "") + date_of_birth_var.set(result[4] or "") + else: + first_name_var.set("") + last_name_var.set("") + middle_name_var.set("") + maiden_name_var.set("") + date_of_birth_var.set("") + else: + first_name_var.set("") + last_name_var.set("") + middle_name_var.set("") + maiden_name_var.set("") + date_of_birth_var.set("") + + # Keep compare checkbox state persistent across navigation + first_name_entry.focus_set() + first_name_entry.icursor(0) + + # Force GUI update before waiting for input + root.update_idletasks() + + # Wait for user input + while waiting_for_input: + try: + root.update() + # Small delay to prevent excessive CPU usage + time.sleep(0.01) + except tk.TclError: + # Window was destroyed, break out of loop + break + + # Check if force exit was requested + if force_exit: + break + + # Check if force exit was requested (exit immediately) + if force_exit: + print("Force exit requested...") + # Clean up face crops and caches + self._cleanup_face_crops(face_crop_path) + self.close_db_connection() + return identified_count + + # Process the command + if command is None: # User clicked Cancel + command = 'q' + else: + command = command.strip() + + if command.lower() == 'q': + print("Quitting...") + # Clean up face crops and caches + self._cleanup_face_crops(face_crop_path) + self.close_db_connection() + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + return identified_count + + + elif command.lower() == 's': + print("āž”ļø Next") + + # Save current checkbox states before navigating away (auto-match style backup) + save_current_face_selection_states() + + # Clean up current face crop when moving forward + if face_crop_path and os.path.exists(face_crop_path): + try: + os.remove(face_crop_path) + except: + pass # Ignore cleanup errors + current_face_crop_path = None # Clear tracked path + + # Find next unidentified face + next_found = False + for j in range(i + 1, len(original_faces)): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + next_found = True + break + + if not next_found: + print("āš ļø No more unidentified faces - Next button disabled") + continue + + # Clear date of birth field when moving to next face + date_of_birth_var.set("") + # Clear middle name and maiden name fields when moving to next face + middle_name_var.set("") + maiden_name_var.set("") + + update_button_states() + # Only update similar faces if compare is enabled + if compare_var.get(): + update_similar_faces() + continue + + elif command.lower() == 'back': + print("ā¬…ļø Going back to previous face") + + # Save current checkbox states before navigating away (auto-match style backup) + save_current_face_selection_states() + + # Find previous unidentified face + prev_found = False + for j in range(i - 1, -1, -1): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + prev_found = True + break + + if not prev_found: + print("āš ļø No more unidentified faces - Back button disabled") + continue + + # Repopulate fields with saved data when going back + current_face_id = original_faces[i][0] + if current_face_id in face_person_names: + person_data = face_person_names[current_face_id] + if isinstance(person_data, dict): + # Use individual field values for proper restoration + first_name = person_data.get('first_name', '').strip() + last_name = person_data.get('last_name', '').strip() + middle_name = person_data.get('middle_name', '').strip() + maiden_name = person_data.get('maiden_name', '').strip() + date_of_birth = person_data.get('date_of_birth', '').strip() + + # Restore all fields directly + first_name_var.set(first_name) + last_name_var.set(last_name) + middle_name_var.set(middle_name) + maiden_name_var.set(maiden_name) + date_of_birth_var.set(date_of_birth) + else: + # Clear fields + first_name_var.set("") + last_name_var.set("") + middle_name_var.set("") + maiden_name_var.set("") + date_of_birth_var.set("") + else: + # No saved data - clear fields + first_name_var.set("") + last_name_var.set("") + middle_name_var.set("") + maiden_name_var.set("") + date_of_birth_var.set("") + + update_button_states() + # Only update similar faces if compare is enabled + if compare_var.get(): + update_similar_faces() + continue + + elif command.lower() == 'list': + self._show_people_list() + continue + + elif command: + try: + # Check if this is a compare command + if command.startswith('compare:'): + # Parse compare command: compare:person_name:face_id1,face_id2,face_id3 + parts = command.split(':', 2) + if len(parts) == 3: + person_name = parts[1] + selected_face_ids = [int(fid.strip()) for fid in parts[2].split(',') if fid.strip()] + + with self.get_db_connection() as conn: + cursor = conn.cursor() + # Add person if doesn't exist + # Parse person_name in "Last, First" or single-token format + # Parse person_name with additional fields (middle_name|maiden_name|date_of_birth) + name_part, middle_name, maiden_name, date_of_birth = person_name.split('|', 3) + parts = [p.strip() for p in name_part.split(',', 1)] + + if len(parts) == 2: + last_name, first_name = parts[0], parts[1] + else: + first_name = parts[0] if parts else '' + last_name = '' + + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + result = cursor.fetchone() + person_id = result[0] if result else None + + # Update people cache if new person was added + if person_name not in identify_data_cache['people_names']: + identify_data_cache['people_names'].append(person_name) + identify_data_cache['people_names'].sort() # Keep sorted + # Update last names cache from person_name ("Last, First" or single) + inferred_last = person_name.split(',')[0].strip() if ',' in person_name else person_name.strip() + if inferred_last: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if inferred_last not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(inferred_last) + identify_data_cache['last_names'].sort() + + # Identify all selected faces (including current face) + all_face_ids = [face_id] + selected_face_ids + for fid in all_face_ids: + cursor.execute( + 'UPDATE faces SET person_id = ? WHERE id = ?', + (person_id, fid) + ) + + # Mark all faces as identified in our tracking + for fid in all_face_ids: + face_status[fid] = 'identified' + + if is_already_identified: + print(f"āœ… Re-identified current face and {len(selected_face_ids)} similar faces as: {person_name}") + else: + print(f"āœ… Identified current face and {len(selected_face_ids)} similar faces as: {person_name}") + identified_count += 1 + len(selected_face_ids) + + + # Update person encodings after database transaction is complete + self._update_person_encodings(person_id) + else: + print("āŒ Invalid compare command format") + else: + # Regular identification + with self.get_db_connection() as conn: + cursor = conn.cursor() + # Add person if doesn't exist + # Parse command in "Last, First" or single-token format + # Parse command with additional fields (middle_name|maiden_name|date_of_birth) + name_part, middle_name, maiden_name, date_of_birth = command.split('|', 3) + parts = [p.strip() for p in name_part.split(',', 1)] + + if len(parts) == 2: + last_name, first_name = parts[0], parts[1] + else: + first_name = parts[0] if parts else '' + last_name = '' + + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + result = cursor.fetchone() + person_id = result[0] if result else None + + # Update people cache if new person was added + if command not in identify_data_cache['people_names']: + identify_data_cache['people_names'].append(command) + identify_data_cache['people_names'].sort() # Keep sorted + # Update last names cache from command ("Last, First" or single) + inferred_last = command.split(',')[0].strip() if ',' in command else command.strip() + if inferred_last: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if inferred_last not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(inferred_last) + identify_data_cache['last_names'].sort() + + # Assign face to person + cursor.execute( + 'UPDATE faces SET person_id = ? WHERE id = ?', + (person_id, face_id) + ) + + if is_already_identified: + print(f"āœ… Re-identified as: {command}") + else: + print(f"āœ… Identified as: {command}") + identified_count += 1 + + # Mark this face as identified in our tracking + face_status[face_id] = 'identified' + + + # Update person encodings after database transaction is complete + self._update_person_encodings(person_id) + + except Exception as e: + print(f"āŒ Error: {e}") + + # Increment index for normal flow (identification or error) - but not if we're at the last item + if i < len(original_faces) - 1: + i += 1 + update_button_states() + # Only update similar faces if compare is enabled + if compare_var.get(): + update_similar_faces() + + # Clean up current face crop when moving forward after identification + if face_crop_path and os.path.exists(face_crop_path): + try: + os.remove(face_crop_path) + except: + pass # Ignore cleanup errors + current_face_crop_path = None # Clear tracked path + + # Continue to next face after processing command + continue + else: + print("Please enter a name, 's' to skip, 'q' to quit, or use buttons") + + # Only close the window if user explicitly quit (not when reaching end of faces) + if not window_destroyed: + # Keep the window open - user can still navigate and quit manually + print(f"\nāœ… Identified {identified_count} faces") + print("šŸ’” Application remains open - use Quit button to close") + # Don't destroy the window - let user quit manually + return identified_count + + print(f"\nāœ… Identified {identified_count} faces") + return identified_count + + 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.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", "", open_source_photo) + canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e))) + canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor=""), hide_tooltip(e))) + canvas.tag_bind("photo_icon", "", lambda e: (show_tooltip(e) if tooltip else None)) + + return tooltip # Return tooltip reference for cleanup if needed + + 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 - Unlikely)" + + 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 _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""" + 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.tobytes(), quality_score) + ) + + def _get_person_encodings(self, person_id: int, min_quality: float = 0.3) -> List[Tuple[np.ndarray, float]]: + """Get all high-quality encodings for a person""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'SELECT encoding, quality_score FROM person_encodings WHERE person_id = ? AND quality_score >= ? ORDER BY quality_score DESC', + (person_id, min_quality) + ) + results = cursor.fetchall() + 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""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + # Get all faces for this person + cursor.execute( + 'SELECT id, encoding, quality_score FROM faces WHERE person_id = ? ORDER BY quality_score DESC', + (person_id,) + ) + faces = cursor.fetchall() + + # Clear existing person encodings + cursor.execute('DELETE FROM person_encodings WHERE person_id = ?', (person_id,)) + + # Add all faces as person encodings + for face_id, encoding, quality_score in faces: + cursor.execute( + 'INSERT INTO person_encodings (person_id, face_id, encoding, quality_score) VALUES (?, ?, ?, ?)', + (person_id, face_id, encoding, quality_score) + ) + + 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 _filter_unique_faces_from_list(self, faces_list: List[tuple]) -> List[tuple]: + """Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches""" + if not faces_list: + return faces_list + + # Extract face IDs from the list + face_ids = [face_tuple[0] for face_tuple in faces_list] + + # Get face encodings from database for all faces + face_encodings = {} + with self.get_db_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' * len(face_ids)) + cursor.execute(f''' + SELECT id, encoding + FROM faces + WHERE id IN ({placeholders}) AND encoding IS NOT NULL + ''', face_ids) + + for face_id, encoding_blob in cursor.fetchall(): + try: + import numpy as np + # Load encoding as numpy array (not pickle) + encoding = np.frombuffer(encoding_blob, dtype=np.float64) + face_encodings[face_id] = encoding + except Exception: + continue + + # If we don't have enough encodings, return original list + if len(face_encodings) < 2: + return faces_list + + # Calculate distances between all faces using existing encodings + face_distances = {} + face_id_list = list(face_encodings.keys()) + + for i, face_id1 in enumerate(face_id_list): + for j, face_id2 in enumerate(face_id_list): + if i != j: + try: + import face_recognition + encoding1 = face_encodings[face_id1] + encoding2 = face_encodings[face_id2] + + # Calculate distance + distance = face_recognition.face_distance([encoding1], encoding2)[0] + face_distances[(face_id1, face_id2)] = distance + except Exception: + # If calculation fails, assume no match + face_distances[(face_id1, face_id2)] = 1.0 + + # Apply unique faces filtering + unique_faces = [] + seen_face_groups = set() + + for face_tuple in faces_list: + face_id = face_tuple[0] + + # Skip if we don't have encoding for this face + if face_id not in face_encodings: + unique_faces.append(face_tuple) + continue + + # Find all faces that match this one with high/medium confidence + matching_face_ids = set([face_id]) # Include self + for other_face_id in face_encodings.keys(): + if other_face_id != face_id: + distance = face_distances.get((face_id, other_face_id), 1.0) + confidence_pct = (1 - distance) * 100 + + # If this face matches with high/medium confidence + if 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_tuple) + + return unique_faces + + def _show_people_list(self, cursor=None): + """Show list of known people""" + if cursor is None: + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') + people = cursor.fetchall() + else: + cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') + people = cursor.fetchall() + + if people: + formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last] + print("šŸ‘„ Known people:", ", ".join(formatted_names)) + else: + print("šŸ‘„ No people identified yet") + + def add_tags(self, photo_pattern: str = None, batch_size: int = 10) -> int: + """Add custom tags to photos""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + if photo_pattern: + cursor.execute( + 'SELECT id, filename FROM photos WHERE filename LIKE ? LIMIT ?', + (f'%{photo_pattern}%', batch_size) + ) + else: + cursor.execute('SELECT id, filename FROM photos LIMIT ?', (batch_size,)) + + photos = cursor.fetchall() + + 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, filename in photos: + print(f"\nšŸ“ø {filename}") + tags_input = input("šŸ·ļø Tags: ").strip() + + if tags_input.lower() == 'q': + break + + if tags_input: + tags = [tag.strip() for tag in tags_input.split(',') if tag.strip()] + for tag_name in tags: + # First, insert or get the tag_id from tags table + cursor.execute( + 'INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', + (tag_name,) + ) + cursor.execute( + 'SELECT id FROM tags WHERE tag_name = ?', + (tag_name,) + ) + tag_id = cursor.fetchone()[0] + + # Then, insert the linkage (ignore if already exists due to UNIQUE constraint) + cursor.execute( + 'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', + (photo_id, tag_id) + ) + print(f" āœ… Added {len(tags)} tags") + tagged_count += 1 + + print(f"āœ… Tagged {tagged_count} photos") + return tagged_count + + def stats(self) -> Dict: + """Show database statistics""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + stats = {} + + # Basic counts + cursor.execute('SELECT COUNT(*) FROM photos') + result = cursor.fetchone() + stats['total_photos'] = result[0] if result else 0 + + cursor.execute('SELECT COUNT(*) FROM photos WHERE processed = 1') + result = cursor.fetchone() + stats['processed_photos'] = result[0] if result else 0 + + cursor.execute('SELECT COUNT(*) FROM faces') + result = cursor.fetchone() + stats['total_faces'] = result[0] if result else 0 + + cursor.execute('SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL') + result = cursor.fetchone() + stats['identified_faces'] = result[0] if result else 0 + + cursor.execute('SELECT COUNT(*) FROM people') + result = cursor.fetchone() + stats['total_people'] = result[0] if result else 0 + + cursor.execute('SELECT COUNT(*) FROM tags') + result = cursor.fetchone() + stats['unique_tags'] = result[0] if result else 0 + + # Top people + cursor.execute(''' + SELECT + CASE + WHEN p.last_name AND p.first_name THEN p.last_name || ', ' || p.first_name + WHEN p.first_name THEN p.first_name + WHEN p.last_name THEN p.last_name + ELSE 'Unknown' + END as full_name, + COUNT(f.id) as face_count + FROM people p + LEFT JOIN faces f ON p.id = f.person_id + GROUP BY p.id + ORDER BY face_count DESC + LIMIT 15 + ''') + stats['top_people'] = cursor.fetchall() + + # Display stats + print(f"\nšŸ“Š Database Statistics") + print("=" * 40) + print(f"Photos: {stats['processed_photos']}/{stats['total_photos']} processed") + print(f"Faces: {stats['identified_faces']}/{stats['total_faces']} identified") + print(f"People: {stats['total_people']} unique") + print(f"Tags: {stats['unique_tags']} unique") + + if stats['top_people']: + print(f"\nšŸ‘„ Top People:") + for name, count in stats['top_people']: + print(f" {name}: {count} faces") + + unidentified = stats['total_faces'] - stats['identified_faces'] + if unidentified > 0: + print(f"\nāš ļø {unidentified} faces still need identification") + + return stats + + def search_faces(self, person_name: str) -> List[str]: + """Search for photos containing a specific person""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + cursor.execute(''' + SELECT DISTINCT p.filename, p.path + FROM photos p + JOIN faces f ON p.id = f.photo_id + JOIN people pe ON f.person_id = pe.id + WHERE pe.name LIKE ? + ''', (f'%{person_name}%',)) + + results = cursor.fetchall() + + if results: + print(f"\nšŸ” Found {len(results)} photos with '{person_name}':") + for filename, path in results: + print(f" šŸ“ø {filename}") + else: + print(f"šŸ” No photos found with '{person_name}'") + + return [path for filename, path in results] + + def find_similar_faces(self, face_id: int = None, tolerance: float = 0.6, include_same_photo: bool = False) -> List[Dict]: + """Find similar faces across all photos with improved multi-encoding and quality scoring""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + if face_id: + # Find faces similar to a specific face + cursor.execute(''' + SELECT id, photo_id, encoding, location, quality_score + FROM faces + WHERE id = ? + ''', (face_id,)) + target_face = cursor.fetchone() + + if not target_face: + print(f"āŒ Face ID {face_id} not found") + return [] + + target_encoding = self._get_cached_face_encoding(face_id, target_face[2]) + target_quality = target_face[4] if len(target_face) > 4 else 0.5 + + # Get all other faces with quality scores + cursor.execute(''' + SELECT f.id, f.photo_id, f.encoding, f.location, p.filename, f.person_id, f.quality_score + FROM faces f + JOIN photos p ON f.photo_id = p.id + WHERE f.id != ? AND f.quality_score >= 0.2 + ''', (face_id,)) + + else: + # Find all unidentified faces and try to match them with identified ones + cursor.execute(''' + SELECT f.id, f.photo_id, f.encoding, f.location, p.filename, f.person_id, f.quality_score + FROM faces f + JOIN photos p ON f.photo_id = p.id + WHERE f.quality_score >= 0.2 + ORDER BY f.quality_score DESC, f.id + ''') + + all_faces = cursor.fetchall() + matches = [] + + if face_id: + # Compare target face with all other faces using adaptive tolerance + for face_data in all_faces: + other_id, other_photo_id, other_encoding, other_location, other_filename, other_person_id, other_quality = face_data + other_enc = self._get_cached_face_encoding(other_id, other_encoding) + + # Calculate adaptive tolerance based on both face qualities + 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: + matches.append({ + 'face_id': other_id, + 'photo_id': other_photo_id, + 'filename': other_filename, + 'location': other_location, + 'distance': distance, + 'person_id': other_person_id, + 'quality_score': other_quality, + 'adaptive_tolerance': adaptive_tolerance + }) + + # Get target photo info + cursor.execute('SELECT filename FROM photos WHERE id = ?', (target_face[1],)) + result = cursor.fetchone() + target_filename = result[0] if result else "Unknown" + + print(f"\nšŸ” Finding faces similar to face in: {target_filename}") + print(f"šŸ“ Target face location: {target_face[3]}") + + else: + # Auto-match unidentified faces with identified ones using multi-encoding + identified_faces = [f for f in all_faces if f[5] is not None] # person_id is not None + unidentified_faces = [f for f in all_faces if f[5] 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 (simplified for now) + person_encodings = {} + for id_face in identified_faces: + person_id = id_face[5] + if person_id not in person_encodings: + # Use single encoding per person for now (simplified) + id_enc = self._get_cached_face_encoding(id_face[0], id_face[2]) + person_encodings[person_id] = [(id_enc, id_face[6])] + + for unid_face in unidentified_faces: + unid_id, unid_photo_id, unid_encoding, unid_location, unid_filename, _, 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] + + # Skip if same photo (unless specifically requested for twins detection) + # Note: Same photo check is simplified for performance + if not include_same_photo: + # For now, we'll skip this check to avoid performance issues + # TODO: Implement efficient same-photo checking + pass + + if distance <= adaptive_tolerance and distance < best_distance: + best_distance = distance + best_person_id = person_id + + # Get the best matching face info for this person + cursor.execute(''' + SELECT f.id, f.photo_id, f.location, p.filename + FROM faces f + JOIN photos p ON f.photo_id = p.id + WHERE f.person_id = ? AND f.quality_score >= ? + ORDER BY f.quality_score DESC + LIMIT 1 + ''', (person_id, 0.3)) + + best_face_info = cursor.fetchone() + if best_face_info: + best_match = { + 'unidentified_id': unid_id, + 'unidentified_photo_id': unid_photo_id, + 'unidentified_filename': unid_filename, + 'unidentified_location': unid_location, + 'matched_id': best_face_info[0], + 'matched_photo_id': best_face_info[1], + 'matched_filename': best_face_info[3], + 'matched_location': best_face_info[2], + 'person_id': person_id, + 'distance': distance, + 'quality_score': unid_quality, + 'adaptive_tolerance': adaptive_tolerance + } + + if best_match: + matches.append(best_match) + + return matches + + def auto_identify_matches(self, tolerance: float = 0.6, confirm: bool = True, show_faces: bool = False, include_same_photo: bool = False) -> int: + """Automatically identify faces that match already identified faces using GUI""" + # Get all identified faces (one per person) to use as reference faces + with self.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: + print("šŸ” No identified faces found for auto-matching") + return 0 + + # 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 + # Order by person name for user-friendly consistent results across runs + person_faces_list = [] + for person_id, face in person_faces.items(): + # Get person name for ordering + with self.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) + + print(f"\nšŸŽÆ Found {len(person_faces)} identified people to match against") + print("šŸ“Š Confidence Guide: 🟢80%+ = Very High, 🟔70%+ = High, 🟠60%+ = Medium, šŸ”“50%+ = Low, ⚫<50% = Very Low") + + # Find similar faces for each identified person using face-to-face comparison + 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._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: + # Convert to auto-match format + 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) + + matches_by_matched[person_id] = person_matches + + # Flatten all matches for counting + all_matches = [] + for person_matches in matches_by_matched.values(): + all_matches.extend(person_matches) + + if not all_matches: + print("šŸ” No similar faces found for auto-identification") + return 0 + + print(f"\nšŸŽÆ Found {len(all_matches)} potential matches") + + # Pre-fetch all needed data to avoid repeated database queries in update_display + print("šŸ“Š Pre-fetching data for optimal performance...") + data_cache = {} + + with self.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()} + + print(f"āœ… Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths") + + identified_count = 0 + + # Use integrated GUI for auto-matching + import tkinter as tk + from tkinter import ttk, messagebox + from PIL import Image, ImageTk + import json + import os + + # Create the main window + root = tk.Tk() + root.title("Auto-Match Face Identification") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + + # Hide window initially to prevent flash at corner + root.withdraw() + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal window_destroyed + # Clean up face crops and caches + self._cleanup_face_crops() + self.close_db_connection() + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving with larger default size + saved_size = self._setup_window_size_saving(root, "gui_config.json") + # Override with larger size for auto-match window + root.geometry("1000x700") + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + + # Left side - identified person + left_frame = ttk.LabelFrame(main_frame, text="Identified person", padding="10") + left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) + + # Right side - unidentified faces that match this person + right_frame = ttk.LabelFrame(main_frame, text="Unidentified Faces to Match", padding="10") + right_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) + + # Configure row weights + main_frame.rowconfigure(0, weight=1) + + # Check if there's only one person - if so, disable search functionality + # Use matched_ids instead of person_faces_list since we only show people with potential matches + matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] + has_only_one_person = len(matched_ids) == 1 + print(f"DEBUG: person_faces_list length: {len(person_faces_list)}, matched_ids length: {len(matched_ids)}, has_only_one_person: {has_only_one_person}") + + # Search controls for filtering people by last name + last_name_search_var = tk.StringVar() + # Search field with label underneath (like modifyidentified edit section) + search_frame = ttk.Frame(left_frame) + search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + + # Search input on the left + search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20) + search_entry.grid(row=0, column=0, sticky=tk.W) + + # Buttons on the right of the search input + 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) + search_btn.pack(side=tk.LEFT, padx=(0, 5)) + clear_btn = ttk.Button(buttons_row, text="Clear", width=6) + clear_btn.pack(side=tk.LEFT) + + # Helper label directly under the search input + if has_only_one_person: + print("DEBUG: Disabling search functionality - only one person found") + # Disable search functionality if there's only one person + search_entry.config(state='disabled') + search_btn.config(state='disabled') + clear_btn.config(state='disabled') + # Add a label to explain why search is disabled + disabled_label = ttk.Label(search_frame, text="(Search disabled - only one person found)", + font=("Arial", 8), foreground="gray") + disabled_label.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(2, 0)) + else: + print("DEBUG: Search functionality enabled - multiple people found") + # Normal helper label when search is enabled + 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)) + + # Matched person info + matched_info_label = ttk.Label(left_frame, text="", font=("Arial", 10, "bold")) + matched_info_label.grid(row=2, column=0, pady=(0, 10), sticky=tk.W) + + # Matched person image + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + matched_canvas = tk.Canvas(left_frame, width=300, height=300, bg=canvas_bg_color, highlightthickness=0) + matched_canvas.grid(row=3, column=0, pady=(0, 10)) + + # Save button for this person (will be created after function definitions) + save_btn = None + + # Matches scrollable frame + matches_frame = ttk.Frame(right_frame) + matches_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Control buttons for matches (Select All / Clear All) + matches_controls_frame = ttk.Frame(matches_frame) + matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) + + def select_all_matches(): + """Select all match checkboxes""" + for var in match_vars: + var.set(True) + + def clear_all_matches(): + """Clear all match checkboxes""" + for var in match_vars: + var.set(False) + + select_all_matches_btn = ttk.Button(matches_controls_frame, text="ā˜‘ļø Select All", command=select_all_matches) + select_all_matches_btn.pack(side=tk.LEFT, padx=(0, 5)) + + clear_all_matches_btn = ttk.Button(matches_controls_frame, text="☐ Clear All", command=clear_all_matches) + clear_all_matches_btn.pack(side=tk.LEFT) + + def update_match_control_buttons_state(): + """Enable/disable Select All / Clear All based on matches presence""" + if match_vars: + select_all_matches_btn.config(state='normal') + clear_all_matches_btn.config(state='normal') + else: + select_all_matches_btn.config(state='disabled') + clear_all_matches_btn.config(state='disabled') + + # Create scrollbar for matches + scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=None) + scrollbar.grid(row=0, column=1, rowspan=1, sticky=(tk.N, tk.S)) + + # Create canvas for matches with scrollbar + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + matches_canvas = tk.Canvas(matches_frame, yscrollcommand=scrollbar.set, bg=canvas_bg_color, highlightthickness=0) + matches_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + scrollbar.config(command=matches_canvas.yview) + + # Configure grid weights + right_frame.columnconfigure(0, weight=1) + right_frame.rowconfigure(0, weight=1) + matches_frame.columnconfigure(0, weight=1) + matches_frame.rowconfigure(0, weight=0) # Controls row takes minimal space + matches_frame.rowconfigure(1, weight=1) # Canvas row expandable + + # Control buttons (navigation only) + control_frame = ttk.Frame(main_frame) + control_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0)) + + # Button commands + current_matched_index = 0 + matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]] + filtered_matched_ids = None # filtered subset based on last name search + + match_checkboxes = [] + match_vars = [] + identified_faces_per_person = {} # Track which faces were identified for each person + checkbox_states_per_person = {} # Track checkbox states for each person (unsaved selections) + original_checkbox_states_per_person = {} # Track original/default checkbox states for comparison + + def on_confirm_matches(): + nonlocal identified_count, current_matched_index, identified_faces_per_person + if current_matched_index < len(matched_ids): + matched_id = matched_ids[current_matched_index] + matches_for_this_person = matches_by_matched[matched_id] + + # Initialize identified faces for this person if not exists + if matched_id not in identified_faces_per_person: + identified_faces_per_person[matched_id] = set() + + with self.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, 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 instead of database query + person_details = data_cache['person_details'].get(match['person_id'], {}) + person_name = person_details.get('full_name', "Unknown") + + # Track this face as identified for this person + identified_faces_per_person[matched_id].add(match['unidentified_id']) + + print(f"āœ… Identified as: {person_name}") + identified_count += 1 + else: + # Face is unchecked - check if it was previously identified for this person + if match['unidentified_id'] in 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 + identified_faces_per_person[matched_id].discard(match['unidentified_id']) + + print(f"āŒ Unidentified: {match['unidentified_filename']}") + + # Update person encodings for all affected persons after database transaction is complete + for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']): + self._update_person_encodings(person_id) + + # After saving, set original states to the current UI states so there are no unsaved changes + current_snapshot = {} + for match, var in zip(matches_for_this_person, match_vars): + unique_key = f"{matched_id}_{match['unidentified_id']}" + current_snapshot[unique_key] = var.get() + checkbox_states_per_person[matched_id] = dict(current_snapshot) + original_checkbox_states_per_person[matched_id] = dict(current_snapshot) + + def on_skip_current(): + nonlocal current_matched_index + # Save current checkbox states before navigating away + save_current_checkbox_states() + current_matched_index += 1 + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + if current_matched_index < len(active_ids): + update_display() + else: + finish_auto_match() + + def on_go_back(): + nonlocal current_matched_index + if current_matched_index > 0: + # Save current checkbox states before navigating away + save_current_checkbox_states() + current_matched_index -= 1 + update_display() + + def has_unsaved_changes(): + """Check if there are any unsaved changes by comparing current states with original states""" + for person_id, current_states in checkbox_states_per_person.items(): + if person_id in original_checkbox_states_per_person: + original_states = 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 apply_last_name_filter(): + """Filter people by last name and update navigation""" + nonlocal filtered_matched_ids, current_matched_index + query = last_name_search_var.get().strip().lower() + if query: + # Filter person_faces_list by last name + filtered_people = [] + for person_id, face, person_name in person_faces_list: + # Extract last name from person_name (format: "Last, First") + if ',' in person_name: + last_name = person_name.split(',')[0].strip().lower() + else: + last_name = person_name.strip().lower() + + if query in last_name: + filtered_people.append((person_id, face, person_name)) + + # Get filtered matched_ids + filtered_matched_ids = [person_id for person_id, _, _ in filtered_people if person_id in matches_by_matched and matches_by_matched[person_id]] + else: + filtered_matched_ids = None + + # Reset to first person in filtered list + current_matched_index = 0 + if filtered_matched_ids: + update_display() + else: + # No matches - clear display + matched_info_label.config(text="No people match filter") + matched_canvas.delete("all") + matched_canvas.create_text(150, 150, text="No matches found", fill="gray") + matches_canvas.delete("all") + update_button_states() + + def clear_last_name_filter(): + """Clear filter and show all people""" + nonlocal filtered_matched_ids, current_matched_index + last_name_search_var.set("") + filtered_matched_ids = None + current_matched_index = 0 + update_display() + + def on_quit_auto_match(): + nonlocal window_destroyed + + # Check for unsaved changes before quitting + if has_unsaved_changes(): + # Show warning dialog with custom width + from tkinter import messagebox + + # Create a custom dialog for better width control + dialog = tk.Toplevel(root) + dialog.title("Unsaved Changes") + dialog.geometry("500x250") + dialog.resizable(True, True) + dialog.transient(root) + dialog.grab_set() + + # Center the dialog + dialog.geometry("+%d+%d" % (root.winfo_rootx() + 50, root.winfo_rooty() + 50)) + + # Main message + message_frame = ttk.Frame(dialog, padding="20") + message_frame.pack(fill=tk.BOTH, expand=True) + + # Warning icon and text + icon_label = ttk.Label(message_frame, text="āš ļø", font=("Arial", 16)) + icon_label.pack(anchor=tk.W) + + main_text = ttk.Label(message_frame, + text="You have unsaved changes that will be lost if you quit.", + font=("Arial", 10)) + main_text.pack(anchor=tk.W, pady=(5, 10)) + + # Options + options_text = ttk.Label(message_frame, + text="• Yes: Save current changes and quit\n" + "• No: Quit without saving\n" + "• Cancel: Return to auto-match", + font=("Arial", 9)) + options_text.pack(anchor=tk.W, pady=(0, 10)) + + + # Buttons + button_frame = ttk.Frame(dialog) + button_frame.pack(fill=tk.X, padx=20, pady=(0, 20)) + + result = None + + def on_yes(): + nonlocal result + result = True + dialog.destroy() + + def on_no(): + nonlocal result + result = False + dialog.destroy() + + def on_cancel(): + nonlocal result + result = None + dialog.destroy() + + yes_btn = ttk.Button(button_frame, text="Yes", command=on_yes) + no_btn = ttk.Button(button_frame, text="No", command=on_no) + cancel_btn = ttk.Button(button_frame, text="Cancel", command=on_cancel) + + yes_btn.pack(side=tk.LEFT, padx=(0, 5)) + no_btn.pack(side=tk.LEFT, padx=5) + cancel_btn.pack(side=tk.RIGHT, padx=(5, 0)) + + # Wait for dialog to close + dialog.wait_window() + + if result is None: # Cancel - don't quit + return + elif result: # Yes - save changes first + # Save current checkbox states before quitting + save_current_checkbox_states() + # Note: We don't actually save to database here, just preserve the states + # The user would need to click Save button for each person to persist changes + print("āš ļø Warning: Changes are preserved but not saved to database.") + print(" Click 'Save Changes' button for each person to persist changes.") + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + def finish_auto_match(): + nonlocal window_destroyed + print(f"\nāœ… Auto-identified {identified_count} faces") + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + # Create button references for state management + back_btn = ttk.Button(control_frame, text="ā®ļø Back", command=on_go_back) + next_btn = ttk.Button(control_frame, text="ā­ļø Next", command=on_skip_current) + quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit_auto_match) + + back_btn.grid(row=0, column=0, padx=(0, 5)) + next_btn.grid(row=0, column=1, padx=5) + quit_btn.grid(row=0, column=2, padx=(5, 0)) + + # Create save button now that functions are defined + save_btn = ttk.Button(left_frame, text="šŸ’¾ Save Changes", command=on_confirm_matches) + save_btn.grid(row=4, column=0, pady=(0, 10), sticky=(tk.W, tk.E)) + + def update_button_states(): + """Update button states based on current position""" + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + # Enable/disable Back button based on position + if current_matched_index > 0: + back_btn.config(state='normal') + else: + back_btn.config(state='disabled') + + # Enable/disable Next button based on position + if current_matched_index < len(active_ids) - 1: + next_btn.config(state='normal') + else: + next_btn.config(state='disabled') + + def update_save_button_text(): + """Update save button text with current person name""" + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + if current_matched_index < len(active_ids): + matched_id = active_ids[current_matched_index] + # Get person name from the first match for this person + matches_for_current_person = matches_by_matched[matched_id] + if matches_for_current_person: + person_id = matches_for_current_person[0]['person_id'] + # Use cached person name instead of database query + person_details = data_cache['person_details'].get(person_id, {}) + person_name = person_details.get('full_name', "Unknown") + save_btn.config(text=f"šŸ’¾ Save changes for {person_name}") + else: + save_btn.config(text="šŸ’¾ Save Changes") + else: + save_btn.config(text="šŸ’¾ Save Changes") + + def save_current_checkbox_states(): + """Save current checkbox states for the current person. + Note: Do NOT modify original states here to avoid false positives + when a user toggles and reverts a checkbox. + """ + if current_matched_index < len(matched_ids) and match_vars: + current_matched_id = matched_ids[current_matched_index] + matches_for_current_person = matches_by_matched[current_matched_id] + + if len(match_vars) == len(matches_for_current_person): + if current_matched_id not in checkbox_states_per_person: + checkbox_states_per_person[current_matched_id] = {} + + # Save current checkbox states for this person + for i, (match, var) in enumerate(zip(matches_for_current_person, match_vars)): + unique_key = f"{current_matched_id}_{match['unidentified_id']}" + current_value = var.get() + checkbox_states_per_person[current_matched_id][unique_key] = current_value + + if self.verbose >= 2: + print(f"DEBUG: Saved state for person {current_matched_id}, face {match['unidentified_id']}: {current_value}") + + def update_display(): + nonlocal current_matched_index + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + if current_matched_index >= len(active_ids): + finish_auto_match() + return + + matched_id = active_ids[current_matched_index] + matches_for_this_person = matches_by_matched[matched_id] + + # Update button states + update_button_states() + + # Update save button text with person name + update_save_button_text() + + # Update title + active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids + root.title(f"Auto-Match Face Identification - {current_matched_index + 1}/{len(active_ids)}") + + # 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}") + # No items on the right panel – disable Select All / Clear All + match_checkboxes.clear() + match_vars.clear() + update_match_control_buttons_state() + # Skip to next person if available + if current_matched_index < len(matched_ids) - 1: + current_matched_index += 1 + update_display() + else: + finish_auto_match() + return + + first_match = matches_for_this_person[0] + + # Use cached data instead of database queries + person_details = 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 = 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 + matched_info_label.config(text="\n".join(person_info_lines)) + + # Display matched person face + matched_canvas.delete("all") + if show_faces: + matched_crop_path = self._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) + matched_canvas.create_image(150, 150, image=photo) + matched_canvas.image = photo + + # Add photo icon to the matched person face - exactly in corner + # Use actual image dimensions instead of assuming 300x300 + actual_width, actual_height = pil_image.size + self._create_photo_icon(matched_canvas, matched_photo_path, icon_size=20, + face_x=150, face_y=150, + face_width=actual_width, face_height=actual_height, + canvas_width=300, canvas_height=300) + except Exception as e: + matched_canvas.create_text(150, 150, text=f"āŒ Could not load image: {e}", fill="red") + else: + matched_canvas.create_text(150, 150, text="šŸ–¼ļø No face crop available", fill="gray") + + # Clear and populate unidentified faces + matches_canvas.delete("all") + match_checkboxes.clear() + match_vars.clear() + update_match_control_buttons_state() + + # Create frame for unidentified faces inside canvas + matches_inner_frame = ttk.Frame(matches_canvas) + matches_canvas.create_window((0, 0), window=matches_inner_frame, anchor="nw") + + # Use cached photo paths instead of database queries + photo_paths = 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._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 checkbox_states_per_person and unique_key in checkbox_states_per_person[matched_id]: + saved_state = checkbox_states_per_person[matched_id][unique_key] + match_var.set(saved_state) + if self.verbose >= 2: + print(f"DEBUG: Restored state for person {matched_id}, face {match['unidentified_id']}: {saved_state}") + # Otherwise, pre-select if this face was previously identified for this person + elif matched_id in identified_faces_per_person and match['unidentified_id'] in identified_faces_per_person[matched_id]: + match_var.set(True) + if self.verbose >= 2: + print(f"DEBUG: Pre-selected identified face for person {matched_id}, face {match['unidentified_id']}") + + match_vars.append(match_var) + + # Capture original state at render time (once per person per face) + if matched_id not in original_checkbox_states_per_person: + original_checkbox_states_per_person[matched_id] = {} + if unique_key not in original_checkbox_states_per_person[matched_id]: + 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 checkbox_states_per_person: + checkbox_states_per_person[person_id] = {} + + current_value = var.get() + checkbox_states_per_person[person_id][unique_key] = current_value + + if self.verbose >= 2: + print(f"DEBUG: Checkbox changed for person {person_id}, face {face_id}: {current_value}") + + # Bind the callback to the variable + match_var.trace('w', lambda *args: on_checkbox_change(match_var, matched_id, match['unidentified_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)) + match_checkboxes.append(checkbox) + + # 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"šŸ“ {match['unidentified_filename']}", font=("Arial", 8), foreground="gray") + filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10)) + + # Unidentified face image + if show_faces: + 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=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0)) + + unidentified_crop_path = self._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 to the unidentified face + self._create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15, + face_x=50, face_y=50, + face_width=100, face_height=100, + canvas_width=100, canvas_height=100) + except Exception as e: + match_canvas.create_text(50, 50, text="āŒ", fill="red") + else: + match_canvas.create_text(50, 50, text="šŸ–¼ļø", fill="gray") + + # Update Select All / Clear All button states after populating + update_match_control_buttons_state() + + # Update scroll region + matches_canvas.update_idletasks() + matches_canvas.configure(scrollregion=matches_canvas.bbox("all")) + + # Show the window + try: + root.deiconify() + root.lift() + root.focus_force() + except tk.TclError: + # Window was destroyed before we could show it + return 0 + + # Wire up search controls now that helper functions exist + try: + search_btn.config(command=lambda: apply_last_name_filter()) + clear_btn.config(command=lambda: clear_last_name_filter()) + search_entry.bind('', lambda e: apply_last_name_filter()) + except Exception: + pass + + # Start with first matched person + update_display() + + # Main event loop + try: + root.mainloop() + except tk.TclError: + pass # Window was destroyed + + return identified_count + + def tag_management(self) -> int: + """Tag management GUI - file explorer-like interface for managing photo tags""" + import tkinter as tk + from tkinter import ttk, messagebox + from PIL import Image, ImageTk + import os + + # Create the main window + root = tk.Tk() + root.title("Tag Management - Photo Explorer") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + temp_crops = [] + photo_images = [] # Keep PhotoImage refs alive + + # Track folder expand/collapse states + folder_states = {} # folder_path -> is_expanded + + # Track pending tag changes (photo_id -> list of tag IDs) + pending_tag_changes = {} + # Track pending tag removals (photo_id -> list of tag IDs to remove) + pending_tag_removals = {} + existing_tags = [] # Cache of existing tag names from database (for UI display) + tag_id_to_name = {} # Cache of tag ID to name mapping + tag_name_to_id = {} # Cache of tag name to ID mapping + + # Hide window initially to prevent flash at corner + root.withdraw() + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal window_destroyed + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except: + pass + temp_crops.clear() + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving + saved_size = self._setup_window_size_saving(root) + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(1, weight=1) + main_frame.rowconfigure(2, weight=0) + + # Title and controls frame + header_frame = ttk.Frame(main_frame) + header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + header_frame.columnconfigure(1, weight=1) + + # Title label + title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold")) + title_label.grid(row=0, column=0, sticky=tk.W) + + # View mode controls + view_frame = ttk.Frame(header_frame) + view_frame.grid(row=0, column=1, sticky=tk.E) + + view_mode_var = tk.StringVar(value="list") + ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list", + command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons", + command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact", + command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT) + + # Manage Tags button + def open_manage_tags_dialog(): + """Open a dialog to manage tags: list, edit, add, and delete.""" + import tkinter as tk + from tkinter import ttk, messagebox, simpledialog + + # Dialog window + dialog = tk.Toplevel(root) + dialog.title("Manage Tags") + dialog.transient(root) + dialog.grab_set() + dialog.geometry("500x500") + + # Layout frames + top_frame = ttk.Frame(dialog, padding="8") + top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) + list_frame = ttk.Frame(dialog, padding="8") + list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) + bottom_frame = ttk.Frame(dialog, padding="8") + bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) + + dialog.columnconfigure(0, weight=1) + dialog.rowconfigure(1, weight=1) + + # Add tag controls (top) + new_tag_var = tk.StringVar() + new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30) + new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E)) + + def add_new_tag(): + tag_name = new_tag_var.get().strip() + if not tag_name: + return + try: + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) + conn.commit() + new_tag_var.set("") + refresh_tag_list() + load_existing_tags() + # Refresh main view to reflect new tag options + switch_view_mode(view_mode_var.get()) + except Exception as e: + messagebox.showerror("Error", f"Failed to add tag: {e}") + + add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag) + add_btn.grid(row=0, column=1, sticky=tk.W) + top_frame.columnconfigure(0, weight=1) + + # Scrollable tag list (center) + canvas = tk.Canvas(list_frame, highlightthickness=0) + scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) + rows_container = ttk.Frame(canvas) + canvas.create_window((0, 0), window=rows_container, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) + scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) + list_frame.columnconfigure(0, weight=1) + list_frame.rowconfigure(0, weight=1) + + rows_container.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + + # Selection tracking + selected_tag_vars = {} + current_tags = [] # list of dicts: {id, tag_name} + + def refresh_tag_list(): + # Clear rows + for child in list(rows_container.winfo_children()): + child.destroy() + selected_tag_vars.clear() + current_tags.clear() + # Load tags + try: + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE') + for row in cursor.fetchall(): + current_tags.append({'id': row[0], 'tag_name': row[1]}) + except Exception as e: + messagebox.showerror("Error", f"Failed to load tags: {e}") + return + # Build header + head = ttk.Frame(rows_container) + head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6)) + chk_lbl = ttk.Label(head, text="Delete") + chk_lbl.pack(side=tk.LEFT, padx=(0, 10)) + name_lbl = ttk.Label(head, text="Tag name", width=30) + name_lbl.pack(side=tk.LEFT) + act_lbl = ttk.Label(head, text="Edit", width=6) + act_lbl.pack(side=tk.LEFT, padx=(10, 0)) + + # Populate rows + for idx, tag in enumerate(current_tags, start=1): + row = ttk.Frame(rows_container) + row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2) + var = tk.BooleanVar(value=False) + selected_tag_vars[tag['id']] = var + chk = ttk.Checkbutton(row, variable=var) + chk.pack(side=tk.LEFT, padx=(0, 10)) + name = ttk.Label(row, text=tag['tag_name'], width=30) + name.pack(side=tk.LEFT) + + def make_edit_handler(tag_id, name_label): + def handler(): + new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_label.cget('text'), parent=dialog) + if new_name is None: + return + new_name = new_name.strip() + if not new_name: + return + try: + with self.get_db_connection() as conn: + cursor = conn.cursor() + # Ensure name is unique + cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id)) + conn.commit() + except Exception as e: + messagebox.showerror("Error", f"Failed to rename tag: {e}") + return + # Update UI and caches + refresh_tag_list() + load_existing_tags() + switch_view_mode(view_mode_var.get()) + return handler + + edit_btn = ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name)) + edit_btn.pack(side=tk.LEFT, padx=(10, 0)) + + refresh_tag_list() + + # Bottom buttons + def delete_selected(): + ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()] + if not ids_to_delete: + return + if not messagebox.askyesno("Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos."): + return + try: + with self.get_db_connection() as conn: + cursor = conn.cursor() + # Remove linkages first to maintain integrity + cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) + # Delete tags + cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) + conn.commit() + + # Clean up pending tag changes for deleted tags + for photo_id in list(pending_tag_changes.keys()): + pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in ids_to_delete] + if not pending_tag_changes[photo_id]: + del pending_tag_changes[photo_id] + + # Clean up pending tag removals for deleted tags + for photo_id in list(pending_tag_removals.keys()): + pending_tag_removals[photo_id] = [tid for tid in pending_tag_removals[photo_id] if tid not in ids_to_delete] + if not pending_tag_removals[photo_id]: + del pending_tag_removals[photo_id] + + refresh_tag_list() + load_existing_tags() + load_photos() # Refresh photo data to reflect deleted tags + switch_view_mode(view_mode_var.get()) + except Exception as e: + messagebox.showerror("Error", f"Failed to delete tags: {e}") + + delete_btn = ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected) + delete_btn.pack(side=tk.LEFT) + quit_btn = ttk.Button(bottom_frame, text="Quit", command=dialog.destroy) + quit_btn.pack(side=tk.RIGHT) + + # Keyboard focus + new_tag_entry.focus_set() + + manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog) + manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0)) + + # Main content area + content_frame = ttk.Frame(main_frame) + content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + content_frame.columnconfigure(0, weight=1) + content_frame.rowconfigure(0, weight=1) + + # Style for consistent gray background + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + + # Create canvas and scrollbar for content + content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0) + content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview) + content_inner = ttk.Frame(content_canvas) + content_canvas.create_window((0, 0), window=content_inner, anchor="nw") + content_canvas.configure(yscrollcommand=content_scrollbar.set) + + content_inner.bind( + "", + lambda e: content_canvas.configure(scrollregion=content_canvas.bbox("all")) + ) + + content_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + content_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) + + # Bottom frame for save button + bottom_frame = ttk.Frame(main_frame) + bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) + + # Save tagging button (function will be defined later) + save_button = ttk.Button(bottom_frame, text="Save Tagging") + save_button.pack(side=tk.RIGHT, padx=10, pady=5) + + # Quit button with warning for pending changes + def quit_with_warning(): + """Quit the dialog, but warn if there are pending changes""" + # Check for pending changes + has_pending_changes = bool(pending_tag_changes or pending_tag_removals) + + if has_pending_changes: + total_additions = sum(len(tags) for tags in pending_tag_changes.values()) + total_removals = sum(len(tags) for tags in pending_tag_removals.values()) + + changes_text = [] + if total_additions > 0: + changes_text.append(f"{total_additions} tag addition(s)") + if total_removals > 0: + changes_text.append(f"{total_removals} tag removal(s)") + + changes_summary = " and ".join(changes_text) + + result = messagebox.askyesnocancel( + "Unsaved Changes", + f"You have unsaved changes: {changes_summary}.\n\n" + "Do you want to save your changes before quitting?\n\n" + "Yes = Save and quit\n" + "No = Quit without saving\n" + "Cancel = Stay in dialog" + ) + + if result is True: # Yes - Save and quit + save_tagging_changes() + root.destroy() + elif result is False: # No - Quit without saving + root.destroy() + # If result is None (Cancel), do nothing - stay in dialog + else: + # No pending changes, just quit + root.destroy() + + quit_button = ttk.Button(bottom_frame, text="Quit", command=quit_with_warning) + quit_button.pack(side=tk.RIGHT, padx=(0, 10), pady=5) + + # Enable mouse scroll anywhere in the dialog + def on_mousewheel(event): + content_canvas.yview_scroll(int(-1*(event.delta/120)), "units") + + # Column resizing variables + resize_start_x = 0 + resize_start_widths = [] + current_visible_cols = [] + is_resizing = False + + def start_resize(event, col_idx): + """Start column resizing""" + nonlocal resize_start_x, resize_start_widths, is_resizing + print(f"DEBUG: start_resize called for column {col_idx}, event.x_root={event.x_root}") # Debug output + is_resizing = True + resize_start_x = event.x_root + # Store current column widths + resize_start_widths = [] + for i, col in enumerate(current_visible_cols): + resize_start_widths.append(col['width']) + print(f"DEBUG: Stored widths: {resize_start_widths}") # Debug output + # Change cursor globally + root.configure(cursor="sb_h_double_arrow") + + def do_resize(event, col_idx): + """Perform column resizing""" + nonlocal resize_start_x, resize_start_widths, is_resizing + print(f"DEBUG: do_resize called for column {col_idx}, is_resizing={is_resizing}") # Debug output + if not is_resizing or not resize_start_widths or not current_visible_cols: + return + + # Calculate width change + delta_x = event.x_root - resize_start_x + + # Update column widths + if col_idx < len(current_visible_cols) and col_idx + 1 < len(current_visible_cols): + # Resize current and next column + new_width_left = max(50, resize_start_widths[col_idx] + delta_x) + new_width_right = max(50, resize_start_widths[col_idx + 1] - delta_x) + + # Update column configuration + current_visible_cols[col_idx]['width'] = new_width_left + current_visible_cols[col_idx + 1]['width'] = new_width_right + + # Update the actual column configuration in the global config + for i, col in enumerate(column_config['list']): + if col['key'] == current_visible_cols[col_idx]['key']: + column_config['list'][i]['width'] = new_width_left + elif col['key'] == current_visible_cols[col_idx + 1]['key']: + column_config['list'][i]['width'] = new_width_right + + # Force immediate visual update by reconfiguring grid weights + try: + header_frame_ref = None + row_frames = [] + for widget in content_inner.winfo_children(): + # First frame is header, subsequent frames are data rows + if isinstance(widget, ttk.Frame): + if header_frame_ref is None: + header_frame_ref = widget + else: + row_frames.append(widget) + + # Update header columns (accounting for separator columns) + if header_frame_ref is not None: + # Update both minsize and weight to force resize + header_frame_ref.columnconfigure(col_idx*2, + weight=current_visible_cols[col_idx]['weight'], + minsize=new_width_left) + header_frame_ref.columnconfigure((col_idx+1)*2, + weight=current_visible_cols[col_idx+1]['weight'], + minsize=new_width_right) + print(f"DEBUG: Updated header columns {col_idx*2} and {(col_idx+1)*2} with widths {new_width_left} and {new_width_right}") + + # Update each data row frame columns (no separators, direct indices) + for rf in row_frames: + rf.columnconfigure(col_idx, + weight=current_visible_cols[col_idx]['weight'], + minsize=new_width_left) + rf.columnconfigure(col_idx+1, + weight=current_visible_cols[col_idx+1]['weight'], + minsize=new_width_right) + + # Force update of the display + root.update_idletasks() + + except Exception as e: + print(f"DEBUG: Error during resize update: {e}") # Debug output + pass # Ignore errors during resize + + def stop_resize(event): + """Stop column resizing""" + nonlocal is_resizing + if is_resizing: + print(f"DEBUG: stop_resize called, was resizing={is_resizing}") # Debug output + is_resizing = False + root.configure(cursor="") + + # Bind mouse wheel to the entire window + root.bind_all("", on_mousewheel) + + # Global mouse release handler that only stops resize if we're actually resizing + def global_mouse_release(event): + if is_resizing: + stop_resize(event) + root.bind_all("", global_mouse_release) + + # Unbind when window is destroyed + def cleanup_mousewheel(): + try: + root.unbind_all("") + root.unbind_all("") + except: + pass + + root.bind("", lambda e: cleanup_mousewheel()) + + # Load photos from database + photos_data = [] + + # Column visibility state + column_visibility = { + 'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, + 'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, + 'compact': {'filename': True, 'faces': True, 'tags': True} + } + + # Column order and configuration + column_config = { + 'list': [ + {'key': 'id', 'label': 'ID', 'width': 50, 'weight': 0}, + {'key': 'filename', 'label': 'Filename', 'width': 150, 'weight': 1}, + {'key': 'path', 'label': 'Path', 'width': 200, 'weight': 2}, + {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, + {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, + {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, + {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} + ], + 'icons': [ + {'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0}, + {'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0}, + {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, + {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, + {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, + {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, + {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} + ], + 'compact': [ + {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, + {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, + {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} + ] + } + + def load_photos(): + nonlocal photos_data + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added, + COUNT(f.id) as face_count, + GROUP_CONCAT(DISTINCT t.tag_name) as tags + FROM photos p + LEFT JOIN faces f ON f.photo_id = p.id + LEFT JOIN phototaglinkage ptl ON ptl.photo_id = p.id + LEFT JOIN tags t ON t.id = ptl.tag_id + GROUP BY p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added + ORDER BY p.date_taken DESC, p.filename + ''') + photos_data = [] + for row in cursor.fetchall(): + photos_data.append({ + 'id': row[0], + 'filename': row[1], + 'path': row[2], + 'processed': row[3], + 'date_taken': row[4], + 'date_added': row[5], + 'face_count': row[6] or 0, + 'tags': row[7] or "" + }) + + def prepare_folder_grouped_data(): + """Prepare photo data grouped by folders""" + import os + from collections import defaultdict + + # Group photos by folder + folder_groups = defaultdict(list) + for photo in photos_data: + folder_path = os.path.dirname(photo['path']) + folder_name = os.path.basename(folder_path) if folder_path else "Root" + folder_groups[folder_path].append(photo) + + # Sort folders by path and photos within each folder by date_taken + sorted_folders = [] + for folder_path in sorted(folder_groups.keys()): + folder_name = os.path.basename(folder_path) if folder_path else "Root" + photos_in_folder = sorted(folder_groups[folder_path], + key=lambda x: x['date_taken'] or '', reverse=True) + + # Initialize folder state if not exists (default to expanded) + if folder_path not in folder_states: + folder_states[folder_path] = True + + sorted_folders.append({ + 'folder_path': folder_path, + 'folder_name': folder_name, + 'photos': photos_in_folder, + 'photo_count': len(photos_in_folder) + }) + + return sorted_folders + + def create_folder_header(parent, folder_info, current_row, col_count, view_mode): + """Create a collapsible folder header with toggle button""" + # Create folder header frame + folder_header_frame = ttk.Frame(parent) + folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5)) + folder_header_frame.configure(relief='raised', borderwidth=1) + + # Create toggle button + is_expanded = folder_states.get(folder_info['folder_path'], True) + toggle_text = "ā–¼" if is_expanded else "ā–¶" + toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1, + command=lambda: toggle_folder(folder_info['folder_path'], view_mode), + font=("Arial", 8), relief='flat', bd=1) + toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5) + + # Create folder label + folder_label = ttk.Label(folder_header_frame, + text=f"šŸ“ {folder_info['folder_name']} ({folder_info['photo_count']} photos)", + font=("Arial", 11, "bold")) + folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5) + + return folder_header_frame + + def toggle_folder(folder_path, view_mode): + """Toggle folder expand/collapse state and refresh view""" + folder_states[folder_path] = not folder_states.get(folder_path, True) + switch_view_mode(view_mode) + + def load_existing_tags(): + """Load existing tags from database""" + nonlocal existing_tags, tag_id_to_name, tag_name_to_id + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') + existing_tags = [] + tag_id_to_name = {} + tag_name_to_id = {} + for row in cursor.fetchall(): + tag_id, tag_name = row + existing_tags.append(tag_name) + tag_id_to_name[tag_id] = tag_name + tag_name_to_id[tag_name] = tag_id + + def create_tagging_widget(parent, photo_id, current_tags=""): + """Create a tagging widget with dropdown and text input""" + import tkinter as tk + from tkinter import ttk + + # Create frame for tagging widget + tagging_frame = ttk.Frame(parent) + + # Create combobox for tag selection/input + tag_var = tk.StringVar() + tag_combo = ttk.Combobox(tagging_frame, textvariable=tag_var, width=12) + tag_combo['values'] = existing_tags + tag_combo.pack(side=tk.LEFT, padx=2, pady=2) + + # Create label to show current pending tags + pending_tags_var = tk.StringVar() + pending_tags_label = ttk.Label(tagging_frame, textvariable=pending_tags_var, + font=("Arial", 8), foreground="blue", width=20) + pending_tags_label.pack(side=tk.LEFT, padx=2, pady=2) + + # Initialize pending tags display + if photo_id in pending_tag_changes: + # Convert tag IDs to names for display + pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo_id]] + pending_tags_var.set(", ".join(pending_tag_names)) + else: + pending_tags_var.set(current_tags or "") + + # Add button to add tag + def add_tag(): + tag_name = tag_var.get().strip() + if tag_name: + # Get or create tag ID + if tag_name in tag_name_to_id: + tag_id = tag_name_to_id[tag_name] + else: + # Create new tag in database + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) + cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) + tag_id = cursor.fetchone()[0] + # Update mappings + tag_name_to_id[tag_name] = tag_id + tag_id_to_name[tag_id] = tag_name + if tag_name not in existing_tags: + existing_tags.append(tag_name) + existing_tags.sort() + + # Check if tag already exists (compare tag IDs) before adding to pending changes + existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) + pending_tag_ids = pending_tag_changes.get(photo_id, []) + all_existing_tag_ids = existing_tag_ids + pending_tag_ids + + if tag_id not in all_existing_tag_ids: + # Only add to pending changes if tag is actually new + if photo_id not in pending_tag_changes: + pending_tag_changes[photo_id] = [] + pending_tag_changes[photo_id].append(tag_id) + # Update display + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + pending_tags_var.set(", ".join(pending_tag_names)) + tag_var.set("") # Clear the input field + + add_button = tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag) + add_button.pack(side=tk.LEFT, padx=2, pady=2) + + # Remove button to remove last tag + def remove_tag(): + if photo_id in pending_tag_changes and pending_tag_changes[photo_id]: + pending_tag_changes[photo_id].pop() + if pending_tag_changes[photo_id]: + # Convert tag IDs to names for display + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + pending_tags_var.set(", ".join(pending_tag_names)) + else: + pending_tags_var.set("") + del pending_tag_changes[photo_id] + + remove_button = tk.Button(tagging_frame, text="-", width=2, height=1, command=remove_tag) + remove_button.pack(side=tk.LEFT, padx=2, pady=2) + + return tagging_frame + + def save_tagging_changes(): + """Save all pending tag changes to database""" + if not pending_tag_changes and not pending_tag_removals: + messagebox.showinfo("Info", "No tag changes to save.") + return + + try: + with self.get_db_connection() as conn: + cursor = conn.cursor() + + # Handle tag additions + for photo_id, tag_ids in pending_tag_changes.items(): + for tag_id in tag_ids: + # Insert linkage (ignore if already exists) + cursor.execute( + 'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', + (photo_id, tag_id) + ) + + # Handle tag removals + for photo_id, tag_ids in pending_tag_removals.items(): + for tag_id in tag_ids: + # Remove linkage + cursor.execute( + 'DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', + (photo_id, tag_id) + ) + + conn.commit() + + # Store counts before clearing + saved_additions = len(pending_tag_changes) + saved_removals = len(pending_tag_removals) + + # Clear pending changes and reload data + pending_tag_changes.clear() + pending_tag_removals.clear() + load_existing_tags() + load_photos() + switch_view_mode(view_mode_var.get()) + update_save_button_text() + + message = f"Saved {saved_additions} tag additions" + if saved_removals > 0: + message += f" and {saved_removals} tag removals" + message += "." + messagebox.showinfo("Success", message) + + except Exception as e: + messagebox.showerror("Error", f"Failed to save tags: {str(e)}") + + def update_save_button_text(): + """Update save button text to show pending changes count""" + total_additions = sum(len(tags) for tags in pending_tag_changes.values()) + total_removals = sum(len(tags) for tags in pending_tag_removals.values()) + total_changes = total_additions + total_removals + + if total_changes > 0: + save_button.configure(text=f"Save Tagging ({total_changes} pending)") + else: + save_button.configure(text="Save Tagging") + + # Configure the save button command now that the function is defined + save_button.configure(command=save_tagging_changes) + + def clear_content(): + for widget in content_inner.winfo_children(): + widget.destroy() + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except: + pass + temp_crops.clear() + photo_images.clear() + + def show_column_context_menu(event, view_mode): + """Show context menu for column visibility""" + # Create a custom popup window instead of a menu + popup = tk.Toplevel(root) + popup.wm_overrideredirect(True) + popup.wm_geometry(f"+{event.x_root}+{event.y_root}") + popup.configure(bg='white', relief='flat', bd=0) + + # Define columns that cannot be hidden + protected_columns = { + 'icons': ['thumbnail'], + 'compact': ['filename'], + 'list': ['filename'] + } + + # Create frame for menu items + menu_frame = tk.Frame(popup, bg='white') + menu_frame.pack(padx=2, pady=2) + + # Variables to track checkbox states + checkbox_vars = {} + + for col in column_config[view_mode]: + key = col['key'] + label = col['label'] + is_visible = column_visibility[view_mode][key] + is_protected = key in protected_columns.get(view_mode, []) + + # Create frame for this menu item + item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0) + item_frame.pack(fill=tk.X, pady=1) + + # Create checkbox variable + var = tk.BooleanVar(value=is_visible) + checkbox_vars[key] = var + + def make_toggle_command(col_key, var_ref): + def toggle_column(): + if col_key in protected_columns.get(view_mode, []): + return + # The checkbox has already toggled its state automatically + # Just sync it with our column visibility + column_visibility[view_mode][col_key] = var_ref.get() + # Refresh the view + switch_view_mode(view_mode) + return toggle_column + + if is_protected: + # Protected columns - disabled checkbox + cb = tk.Checkbutton(item_frame, text=label, variable=var, + state='disabled', bg='white', fg='gray', + font=("Arial", 9), relief='flat', bd=0, + highlightthickness=0) + cb.pack(side=tk.LEFT, padx=5, pady=2) + tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', + font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5)) + else: + # Regular columns - clickable checkbox + cb = tk.Checkbutton(item_frame, text=label, variable=var, + command=make_toggle_command(key, var), + bg='white', font=("Arial", 9), relief='flat', bd=0, + highlightthickness=0) + cb.pack(side=tk.LEFT, padx=5, pady=2) + + # Function to close popup + def close_popup(): + try: + popup.destroy() + except: + pass + + # Bind events to close popup + def close_on_click_outside(event): + # Close popup when clicking anywhere in the main window + # Check if the click is not on the popup itself + if event.widget != popup: + try: + # Check if popup still exists + popup.winfo_exists() + # If we get here, popup exists, so close it + close_popup() + except tk.TclError: + # Popup was already destroyed, do nothing + pass + + root.bind("", close_on_click_outside) + root.bind("", close_on_click_outside) + + # Also bind to the main content area + content_canvas.bind("", close_on_click_outside) + content_canvas.bind("", close_on_click_outside) + + # Focus the popup + popup.focus_set() + + # Shared tag linking functions for all view modes + def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags): + """Create a handler function for adding tags to a photo""" + def handler(): + # Create popup window for tag management + popup = tk.Toplevel(root) + popup.title("Manage Photo Tags") + popup.transient(root) + popup.grab_set() + popup.geometry("500x400") + popup.resizable(True, True) + + # Layout frames + top_frame = ttk.Frame(popup, padding="8") + top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) + list_frame = ttk.Frame(popup, padding="8") + list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) + bottom_frame = ttk.Frame(popup, padding="8") + bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E)) + + popup.columnconfigure(0, weight=1) + popup.rowconfigure(1, weight=1) + + # Top frame - dropdown to select tag to add + ttk.Label(top_frame, text="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W) + tag_var = tk.StringVar() + combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly') + combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) + combo.focus_set() + + def add_selected_tag(): + tag_name = tag_var.get().strip() + if not tag_name: + return + + # Get or create tag ID + if tag_name in tag_name_to_id: + tag_id = tag_name_to_id[tag_name] + else: + # Create new tag in database + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) + cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) + tag_id = cursor.fetchone()[0] + # Update mappings + tag_name_to_id[tag_name] = tag_id + tag_id_to_name[tag_id] = tag_name + if tag_name not in existing_tags: + existing_tags.append(tag_name) + existing_tags.sort() + + # Check if tag already exists (compare tag IDs) before adding to pending changes + existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) + pending_tag_ids = pending_tag_changes.get(photo_id, []) + all_existing_tag_ids = existing_tag_ids + pending_tag_ids + + if tag_id not in all_existing_tag_ids: + # Only add to pending changes if tag is actually new + if photo_id not in pending_tag_changes: + pending_tag_changes[photo_id] = [] + pending_tag_changes[photo_id].append(tag_id) + refresh_tag_list() + update_save_button_text() + + tag_var.set("") # Clear the dropdown + + add_btn = ttk.Button(top_frame, text="Add", command=add_selected_tag) + add_btn.grid(row=0, column=2, padx=(0, 8)) + + # List frame - show all linked tags (existing + pending) with checkboxes + ttk.Label(list_frame, text="Linked tags (check to remove):", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=(0, 5)) + + # Create scrollable frame for tags + canvas = tk.Canvas(list_frame, height=200) + scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) + scrollable_frame = ttk.Frame(canvas) + + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # Variables to track selected tags for removal + selected_tag_vars = {} + + def refresh_tag_list(): + # Clear existing widgets + for widget in scrollable_frame.winfo_children(): + widget.destroy() + selected_tag_vars.clear() + + # Get existing tags for this photo + existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) + + # Get pending tags for this photo + pending_tag_ids = pending_tag_changes.get(photo_id, []) + + # Get pending removals for this photo + pending_removal_ids = pending_tag_removals.get(photo_id, []) + + # Combine and deduplicate tag IDs, but exclude tags marked for removal + all_tag_ids = existing_tag_ids + pending_tag_ids + unique_tag_ids = list(set(all_tag_ids)) # Remove duplicates + # Remove tags that are marked for removal + unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids] + + # Convert to names for display + unique_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids] + + if not unique_tag_names: + ttk.Label(scrollable_frame, text="No tags linked to this photo", + foreground="gray").pack(anchor=tk.W, pady=5) + return + + # Create checkboxes for each tag + for i, tag_id in enumerate(unique_tag_ids): + tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}") + var = tk.BooleanVar() + selected_tag_vars[tag_name] = var + + # Determine if this is a pending tag + is_pending = tag_id in pending_tag_ids + status_text = " (pending)" if is_pending else " (saved)" + status_color = "blue" if is_pending else "black" + + frame = ttk.Frame(scrollable_frame) + frame.pack(fill=tk.X, pady=1) + + checkbox = ttk.Checkbutton(frame, variable=var) + checkbox.pack(side=tk.LEFT, padx=(0, 5)) + + label = ttk.Label(frame, text=tag_name + status_text, foreground=status_color) + label.pack(side=tk.LEFT) + + def remove_selected_tags(): + # Get tag IDs to remove (convert names to IDs) + tag_ids_to_remove = [] + for tag_name, var in selected_tag_vars.items(): + if var.get() and tag_name in tag_name_to_id: + tag_ids_to_remove.append(tag_name_to_id[tag_name]) + + if not tag_ids_to_remove: + return + + # Remove from pending changes (using IDs) + if photo_id in pending_tag_changes: + pending_tag_changes[photo_id] = [ + tid for tid in pending_tag_changes[photo_id] + if tid not in tag_ids_to_remove + ] + if not pending_tag_changes[photo_id]: + del pending_tag_changes[photo_id] + + # Track removals for saved tags (using IDs) + existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) + for tag_id in tag_ids_to_remove: + if tag_id in existing_tag_ids: + # This is a saved tag, add to pending removals + if photo_id not in pending_tag_removals: + pending_tag_removals[photo_id] = [] + if tag_id not in pending_tag_removals[photo_id]: + pending_tag_removals[photo_id].append(tag_id) + + refresh_tag_list() + update_save_button_text() + + # Bottom frame - buttons + remove_btn = ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags) + remove_btn.pack(side=tk.LEFT, padx=(0, 8)) + + close_btn = ttk.Button(bottom_frame, text="Close", command=popup.destroy) + close_btn.pack(side=tk.RIGHT) + + # Initial load + refresh_tag_list() + + # Update main display when dialog closes + def on_close(): + # Update the main display + existing_tags_list = self._parse_tags_string(photo_tags) + + # Get pending tag IDs and convert to names + pending_tag_names = [] + if photo_id in pending_tag_changes: + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + + # Get pending removal IDs and convert to names + pending_removal_names = [] + if photo_id in pending_tag_removals: + pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] + + all_tags = existing_tags_list + pending_tag_names + unique_tags = self._deduplicate_tags(all_tags) + # Remove tags marked for removal + unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] + current_tags = ", ".join(unique_tags) if unique_tags else "None" + label_widget.configure(text=current_tags) + popup.destroy() + + popup.protocol("WM_DELETE_WINDOW", on_close) + + return handler + + + def create_tag_buttons_frame(parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0): + """Create a frame with tag display and add button that can be used in any view mode""" + tags_frame = ttk.Frame(parent) + + # Display current tags + existing_tags_list = self._parse_tags_string(photo_tags) + + # Get pending tag names + pending_tag_names = [] + if photo_id in pending_tag_changes: + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + + # Get pending removal names + pending_removal_names = [] + if photo_id in pending_tag_removals: + pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] + + all_tags = existing_tags_list + pending_tag_names + unique_tags = self._deduplicate_tags(all_tags) + # Remove tags marked for removal + unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names] + current_display = ", ".join(unique_tags) if unique_tags else "None" + + tags_text = ttk.Label(tags_frame, text=current_display) + tags_text.pack(side=tk.LEFT) + + # Add button with linkage icon + add_btn = tk.Button(tags_frame, text="šŸ”—", width=2, + command=create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags)) + add_btn.pack(side=tk.LEFT, padx=(6, 0)) + + # Pack or grid the frame based on the view mode + if use_grid: + tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W) + else: + tags_frame.pack(side=tk.LEFT, padx=5) + + return tags_frame + + def show_list_view(): + clear_content() + + # Get visible columns and store globally for resize functions + nonlocal current_visible_cols + current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]] + col_count = len(current_visible_cols) + + if col_count == 0: + ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure column weights for visible columns + for i, col in enumerate(current_visible_cols): + content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header row + header_frame = ttk.Frame(content_inner) + header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Configure header frame columns (accounting for separators) + for i, col in enumerate(current_visible_cols): + header_frame.columnconfigure(i*2, weight=col['weight'], minsize=col['width']) + if i < len(current_visible_cols) - 1: + header_frame.columnconfigure(i*2+1, weight=0, minsize=1) # Separator column + + # Create header labels with right-click context menu and resizable separators + for i, col in enumerate(current_visible_cols): + header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) + header_label.grid(row=0, column=i*2, padx=5, sticky=tk.W) + # Bind right-click to each label as well + header_label.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) + + # Add resizable vertical separator after each column (except the last one) + if i < len(current_visible_cols) - 1: + # Create a more visible separator frame with inner dark line + separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow") # Bright red for debugging + separator_frame.grid(row=0, column=i*2+1, sticky='ns', padx=0) + separator_frame.grid_propagate(False) # Maintain fixed width + # Inner dark line for better contrast + inner_line = tk.Frame(separator_frame, bg='darkred', width=2) # Dark red for debugging + inner_line.pack(fill=tk.Y, expand=True) + + # Make separator resizable + separator_frame.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) + separator_frame.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) + separator_frame.bind("", stop_resize) + separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) # Orange for debugging + separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred'))) # Red for debugging + + # Also bind to the inner line for better hit detection + inner_line.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) + inner_line.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) + inner_line.bind("", stop_resize) + + # Bind right-click to the entire header frame + header_frame.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) + + # Add separator + ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Get folder-grouped data + folder_data = prepare_folder_grouped_data() + + # Add folder sections and photo rows + current_row = 2 + for folder_info in folder_data: + # Add collapsible folder header + create_folder_header(content_inner, folder_info, current_row, col_count, 'list') + current_row += 1 + + # Add photos in this folder only if expanded + if folder_states.get(folder_info['folder_path'], True): + for photo in folder_info['photos']: + row_frame = ttk.Frame(content_inner) + row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) + + # Configure row frame columns (no separators in data rows) + for i, col in enumerate(current_visible_cols): + row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + for i, col in enumerate(current_visible_cols): + key = col['key'] + if key == 'id': + text = str(photo['id']) + elif key == 'filename': + text = photo['filename'] + elif key == 'path': + text = photo['path'] + elif key == 'processed': + text = "Yes" if photo['processed'] else "No" + elif key == 'date_taken': + text = photo['date_taken'] or "Unknown" + elif key == 'faces': + text = str(photo['face_count']) + elif key == 'tags': + # Use shared tag buttons frame for list view + create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i) + continue + + ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W) + + current_row += 1 + + def show_icon_view(): + clear_content() + + # Get visible columns + visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]] + col_count = len(visible_cols) + + if col_count == 0: + ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure column weights for visible columns + for i, col in enumerate(visible_cols): + content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header row + header_frame = ttk.Frame(content_inner) + header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + for i, col in enumerate(visible_cols): + header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header labels with right-click context menu + for i, col in enumerate(visible_cols): + header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) + header_label.grid(row=0, column=i, padx=5, sticky=tk.W) + # Bind right-click to each label as well + header_label.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) + + # Bind right-click to the entire header frame + header_frame.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) + + # Add separator + ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Get folder-grouped data + folder_data = prepare_folder_grouped_data() + + # Show photos grouped by folders + current_row = 2 + for folder_info in folder_data: + # Add collapsible folder header + create_folder_header(content_inner, folder_info, current_row, col_count, 'icons') + current_row += 1 + + # Add photos in this folder only if expanded + if folder_states.get(folder_info['folder_path'], True): + for photo in folder_info['photos']: + row_frame = ttk.Frame(content_inner) + row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2) + + for i, col in enumerate(visible_cols): + row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + col_idx = 0 + for col in visible_cols: + key = col['key'] + + if key == 'thumbnail': + # Thumbnail column + thumbnail_frame = ttk.Frame(row_frame) + thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + + try: + if os.path.exists(photo['path']): + img = Image.open(photo['path']) + img.thumbnail((150, 150), Image.Resampling.LANCZOS) + photo_img = ImageTk.PhotoImage(img) + photo_images.append(photo_img) + + # Create canvas for image + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) + canvas.pack() + canvas.create_image(75, 75, image=photo_img) + else: + # Placeholder for missing image + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) + canvas.pack() + canvas.create_text(75, 75, text="šŸ–¼ļø", fill="gray", font=("Arial", 24)) + except Exception: + # Error loading image + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) + canvas.pack() + canvas.create_text(75, 75, text="āŒ", fill="red", font=("Arial", 24)) + else: + # Data columns + if key == 'id': + text = str(photo['id']) + elif key == 'filename': + text = photo['filename'] + elif key == 'processed': + text = "Yes" if photo['processed'] else "No" + elif key == 'date_taken': + text = photo['date_taken'] or "Unknown" + elif key == 'faces': + text = str(photo['face_count']) + elif key == 'tags': + # Use shared tag buttons frame for icon view + create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) + col_idx += 1 + continue + + ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) + + col_idx += 1 + + current_row += 1 + + def show_compact_view(): + clear_content() + + # Get visible columns + visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]] + col_count = len(visible_cols) + + if col_count == 0: + ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure column weights for visible columns + for i, col in enumerate(visible_cols): + content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header + header_frame = ttk.Frame(content_inner) + header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + for i, col in enumerate(visible_cols): + header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header labels with right-click context menu + for i, col in enumerate(visible_cols): + header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) + header_label.grid(row=0, column=i, padx=5, sticky=tk.W) + # Bind right-click to each label as well + header_label.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) + + # Bind right-click to the entire header frame + header_frame.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) + + # Add separator + ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Get folder-grouped data + folder_data = prepare_folder_grouped_data() + + # Add folder sections and photo rows + current_row = 2 + for folder_info in folder_data: + # Add collapsible folder header + create_folder_header(content_inner, folder_info, current_row, col_count, 'compact') + current_row += 1 + + # Add photos in this folder only if expanded + if folder_states.get(folder_info['folder_path'], True): + for photo in folder_info['photos']: + row_frame = ttk.Frame(content_inner) + row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) + + for i, col in enumerate(visible_cols): + row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + col_idx = 0 + for col in visible_cols: + key = col['key'] + if key == 'filename': + text = photo['filename'] + elif key == 'faces': + text = str(photo['face_count']) + elif key == 'tags': + # Use shared tag buttons frame for compact view + create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx) + col_idx += 1 + continue + + ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) + col_idx += 1 + + current_row += 1 + + def switch_view_mode(mode): + if mode == "list": + show_list_view() + elif mode == "icons": + show_icon_view() + elif mode == "compact": + show_compact_view() + + # No need for canvas resize handler since icon view is now single column + + # Load initial data and show default view + load_existing_tags() + load_photos() + show_list_view() + + # Show window + root.deiconify() + root.mainloop() + + return 0 + + def modifyidentified(self) -> int: + """Modify identified faces interface - empty window with Quit button for now""" + import tkinter as tk + from tkinter import ttk, messagebox + from PIL import Image, ImageTk + import os + + # Simple tooltip implementation + class ToolTip: + def __init__(self, widget, text): + self.widget = widget + self.text = text + self.tooltip_window = None + self.widget.bind("", self.on_enter) + self.widget.bind("", 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 + + # Create the main window + root = tk.Tk() + root.title("View and Modify Identified Faces") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + temp_crops = [] + right_panel_images = [] # Keep PhotoImage refs alive + selected_person_id = None + + # Hide window initially to prevent flash at corner + root.withdraw() + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal window_destroyed + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except: + pass + temp_crops.clear() + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving + saved_size = self._setup_window_size_saving(root) + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=2) + main_frame.rowconfigure(1, weight=1) + + # Title label + title_label = ttk.Label(main_frame, text="View and Modify Identified Faces", font=("Arial", 16, "bold")) + title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) + + # Left panel: People list + people_frame = ttk.LabelFrame(main_frame, text="People", padding="10") + people_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8)) + people_frame.columnconfigure(0, weight=1) + + # Search controls (Last Name) with label under the input (match auto-match style) + last_name_search_var = tk.StringVar() + 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=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) + search_btn.pack(side=tk.LEFT, padx=(0, 5)) + clear_btn = ttk.Button(buttons_row, text="Clear", width=6) + 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_canvas = tk.Canvas(people_frame, bg='white') + people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview) + people_list_inner = ttk.Frame(people_canvas) + people_canvas.create_window((0, 0), window=people_list_inner, anchor="nw") + people_canvas.configure(yscrollcommand=people_scrollbar.set) + + people_list_inner.bind( + "", + 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) + + # Right panel: Faces for selected person + faces_frame = ttk.LabelFrame(main_frame, text="Faces", padding="10") + faces_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) + faces_frame.columnconfigure(0, weight=1) + faces_frame.rowconfigure(0, weight=1) + + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + # Match auto-match UI: set gray background for left canvas and remove highlight border + try: + people_canvas.configure(bg=canvas_bg_color, highlightthickness=0) + except Exception: + pass + faces_canvas = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0) + faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=faces_canvas.yview) + faces_inner = ttk.Frame(faces_canvas) + faces_canvas.create_window((0, 0), window=faces_inner, anchor="nw") + faces_canvas.configure(yscrollcommand=faces_scrollbar.set) + + faces_inner.bind( + "", + lambda e: faces_canvas.configure(scrollregion=faces_canvas.bbox("all")) + ) + + # Track current person for responsive face grid + current_person_id = None + current_person_name = "" + resize_job = None + + # Track unmatched faces (temporary changes) + unmatched_faces = set() # All face IDs unmatched across people (for global save) + unmatched_by_person = {} # person_id -> set(face_id) for per-person undo + original_faces_data = [] # store original faces data for potential future use + + def on_faces_canvas_resize(event): + nonlocal resize_job + if current_person_id is None: + return + # Debounce re-render on resize + try: + if resize_job is not None: + root.after_cancel(resize_job) + except Exception: + pass + resize_job = root.after(150, lambda: show_person_faces(current_person_id, current_person_name)) + + faces_canvas.bind("", on_faces_canvas_resize) + + 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)) + + # Load people from DB with counts + people_data = [] # list of dicts: {id, name, count, first_name, last_name} + people_filtered = None # filtered subset based on last name search + + def load_people(): + nonlocal people_data + with self.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 + """ + ) + 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}" + + 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: + apply_last_name_filter() + except Exception: + pass + + # Wire up search controls now that helper functions exist + try: + search_btn.config(command=lambda: apply_last_name_filter()) + clear_btn.config(command=lambda: clear_last_name_filter()) + search_entry.bind('', lambda e: apply_last_name_filter()) + except Exception: + pass + + def apply_last_name_filter(): + nonlocal people_filtered + query = last_name_search_var.get().strip().lower() + if query: + people_filtered = [p for p in people_data if p.get('last_name', '').lower().find(query) != -1] + else: + people_filtered = None + populate_people_list() + # Update right panel based on filtered results + source = people_filtered if people_filtered is not None else people_data + if source: + # Load faces for the first person in the list + first = source[0] + try: + # Update selection state + for child in people_list_inner.winfo_children(): + for widget in child.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10)) + # Bold the first label if present + first_row = people_list_inner.winfo_children()[0] + for widget in first_row.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10, "bold")) + break + except Exception: + pass + # Show faces for the first person + show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") + else: + # No matches: clear faces panel + clear_faces_panel() + + def clear_last_name_filter(): + nonlocal people_filtered + last_name_search_var.set("") + people_filtered = None + populate_people_list() + # After clearing, load faces for the first available person if any + if people_data: + first = people_data[0] + try: + for child in people_list_inner.winfo_children(): + for widget in child.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10)) + first_row = people_list_inner.winfo_children()[0] + for widget in first_row.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10, "bold")) + break + except Exception: + pass + show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") + else: + clear_faces_panel() + + def clear_faces_panel(): + for w in faces_inner.winfo_children(): + w.destroy() + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except: + pass + temp_crops.clear() + right_panel_images.clear() + + def unmatch_face(face_id: int): + """Temporarily unmatch a face from the current person""" + nonlocal unmatched_faces, unmatched_by_person + unmatched_faces.add(face_id) + # Track per-person for Undo + person_set = unmatched_by_person.get(current_person_id) + if person_set is None: + person_set = set() + unmatched_by_person[current_person_id] = person_set + person_set.add(face_id) + # Refresh the display + show_person_faces(current_person_id, current_person_name) + + def undo_changes(): + """Undo all temporary changes""" + nonlocal unmatched_faces, unmatched_by_person + if current_person_id in unmatched_by_person: + for fid in list(unmatched_by_person[current_person_id]): + unmatched_faces.discard(fid) + unmatched_by_person[current_person_id].clear() + # Refresh the display + show_person_faces(current_person_id, current_person_name) + + def save_changes(): + """Save unmatched faces to database""" + if not unmatched_faces: + return + + # Confirm with user + result = messagebox.askyesno( + "Confirm Changes", + f"Are you sure you want to unlink {len(unmatched_faces)} face(s) from '{current_person_name}'?\n\n" + "This will make these faces unidentified again." + ) + + if not result: + return + + # Update database + with self.get_db_connection() as conn: + cursor = conn.cursor() + for face_id in unmatched_faces: + cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) + conn.commit() + + # Store count for message before clearing + unlinked_count = len(unmatched_faces) + + # Clear unmatched faces and refresh + unmatched_faces.clear() + original_faces_data.clear() + + # Refresh people list to update counts + load_people() + populate_people_list() + + # Refresh faces display + show_person_faces(current_person_id, current_person_name) + + messagebox.showinfo("Changes Saved", f"Successfully unlinked {unlinked_count} face(s) from '{current_person_name}'.") + + def show_person_faces(person_id: int, person_name: str): + nonlocal current_person_id, current_person_name, unmatched_faces, original_faces_data + current_person_id = person_id + current_person_name = person_name + clear_faces_panel() + + # Determine how many columns fit the available width + available_width = faces_canvas.winfo_width() + if available_width <= 1: + available_width = faces_frame.winfo_width() + tile_width = 150 # approx tile + padding + cols = max(1, available_width // tile_width) + + # Header row + header = ttk.Label(faces_inner, text=f"Faces for: {person_name}", font=("Arial", 12, "bold")) + header.grid(row=0, column=0, columnspan=cols, sticky=tk.W, pady=(0, 5)) + + # Control buttons row + button_frame = ttk.Frame(faces_inner) + button_frame.grid(row=1, column=0, columnspan=cols, sticky=tk.W, pady=(0, 10)) + + # Enable Undo only if current person has unmatched faces + current_has_unmatched = bool(unmatched_by_person.get(current_person_id)) + undo_btn = ttk.Button(button_frame, text="↶ Undo changes", + command=lambda: undo_changes(), + state="disabled" if not current_has_unmatched else "normal") + undo_btn.pack(side=tk.LEFT, padx=(0, 10)) + + # Note: Save button moved to bottom control bar + + # Query faces for this person + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT f.id, f.location, ph.path, ph.filename + FROM faces f + JOIN photos ph ON ph.id = f.photo_id + WHERE f.person_id = ? + ORDER BY f.id DESC + """, + (person_id,) + ) + rows = cursor.fetchall() + + # Filter out unmatched faces + visible_rows = [row for row in rows if row[0] not in unmatched_faces] + + if not visible_rows: + ttk.Label(faces_inner, text="No faces found." if not rows else "All faces unmatched.", foreground="gray").grid(row=2, column=0, sticky=tk.W) + return + + # Grid thumbnails with responsive column count + row_index = 2 # Start after header and buttons + col_index = 0 + for face_id, location, photo_path, filename in visible_rows: + crop_path = self._extract_face_crop(photo_path, location, face_id) + thumb = None + if crop_path and os.path.exists(crop_path): + try: + img = Image.open(crop_path) + img.thumbnail((130, 130), Image.Resampling.LANCZOS) + photo_img = ImageTk.PhotoImage(img) + temp_crops.append(crop_path) + right_panel_images.append(photo_img) + thumb = photo_img + except Exception: + thumb = None + + tile = ttk.Frame(faces_inner, padding="5") + tile.grid(row=row_index, column=col_index, padx=5, pady=5, sticky=tk.N) + + # Create a frame for the face image with X button overlay + face_frame = ttk.Frame(tile) + face_frame.grid(row=0, column=0) + + canvas = tk.Canvas(face_frame, width=130, height=130, bg=canvas_bg_color, highlightthickness=0) + canvas.grid(row=0, column=0) + if thumb is not None: + canvas.create_image(65, 65, image=thumb) + else: + canvas.create_text(65, 65, text="šŸ–¼ļø", fill="gray") + + # X button to unmatch face - pin exactly to the canvas' top-right corner + x_canvas = tk.Canvas(face_frame, width=12, height=12, bg='red', + highlightthickness=0, relief="flat") + x_canvas.create_text(6, 6, text="āœ–", fill="white", font=("Arial", 8, "bold")) + # Click handler + x_canvas.bind("", lambda e, fid=face_id: unmatch_face(fid)) + # Hover highlight: change bg, show white outline, and hand cursor + x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2")) + x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="red", highlightthickness=0, cursor="")) + # Anchor to the canvas' top-right regardless of layout/size + try: + x_canvas.place(in_=canvas, relx=1.0, x=0, y=0, anchor='ne') + except Exception: + # Fallback to absolute coords if relative placement fails + x_canvas.place(x=118, y=0) + + ttk.Label(tile, text=f"ID {face_id}", foreground="gray").grid(row=1, column=0) + + col_index += 1 + if col_index >= cols: + col_index = 0 + row_index += 1 + + def populate_people_list(): + for w in people_list_inner.winfo_children(): + w.destroy() + source = people_filtered if people_filtered is not None else people_data + if not source: + empty_label = ttk.Label(people_list_inner, text="No people match filter", foreground="gray") + empty_label.grid(row=0, column=0, sticky=tk.W, pady=4) + return + for idx, person in enumerate(source): + row = ttk.Frame(people_list_inner) + row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=4) + # Freeze per-row values to avoid late-binding issues + row_person = person + row_idx = idx + + # Make person name clickable + def make_click_handler(p_id, p_name, p_idx): + def on_click(event): + nonlocal selected_person_id + # Reset all labels to normal font + for child in people_list_inner.winfo_children(): + for widget in child.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10)) + # Set clicked label to bold + event.widget.config(font=("Arial", 10, "bold")) + selected_person_id = p_id + # Show faces for this person + show_person_faces(p_id, p_name) + return on_click + + + # Edit (rename) button + def start_edit_person(row_frame, person_record, row_index): + for w in row_frame.winfo_children(): + w.destroy() + + # Use pre-loaded data instead of database query + cur_first = person_record.get('first_name', '') + cur_last = person_record.get('last_name', '') + cur_middle = person_record.get('middle_name', '') + cur_maiden = person_record.get('maiden_name', '') + cur_dob = person_record.get('date_of_birth', '') + + # Create a larger container frame for the text boxes and labels + edit_container = ttk.Frame(row_frame) + edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Create a grid layout for better organization + # First name field with label + first_frame = ttk.Frame(edit_container) + first_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + first_var = tk.StringVar(value=cur_first) + first_entry = ttk.Entry(first_frame, textvariable=first_var, width=15) + first_entry.pack(side=tk.TOP) + first_entry.focus_set() + + first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray") + first_label.pack(side=tk.TOP, pady=(2, 0)) + + # Last name field with label + last_frame = ttk.Frame(edit_container) + last_frame.grid(row=0, column=1, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + last_var = tk.StringVar(value=cur_last) + last_entry = ttk.Entry(last_frame, textvariable=last_var, width=15) + last_entry.pack(side=tk.TOP) + + last_label = ttk.Label(last_frame, text="Last Name", font=("Arial", 8), foreground="gray") + last_label.pack(side=tk.TOP, pady=(2, 0)) + + # Middle name field with label + middle_frame = ttk.Frame(edit_container) + middle_frame.grid(row=0, column=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + middle_var = tk.StringVar(value=cur_middle) + middle_entry = ttk.Entry(middle_frame, textvariable=middle_var, width=15) + middle_entry.pack(side=tk.TOP) + + middle_label = ttk.Label(middle_frame, text="Middle Name", font=("Arial", 8), foreground="gray") + middle_label.pack(side=tk.TOP, pady=(2, 0)) + + # Maiden name field with label + maiden_frame = ttk.Frame(edit_container) + maiden_frame.grid(row=1, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + maiden_var = tk.StringVar(value=cur_maiden) + maiden_entry = ttk.Entry(maiden_frame, textvariable=maiden_var, width=15) + maiden_entry.pack(side=tk.TOP) + + maiden_label = ttk.Label(maiden_frame, text="Maiden Name", font=("Arial", 8), foreground="gray") + maiden_label.pack(side=tk.TOP, pady=(2, 0)) + + # Date of birth field with label and calendar button + dob_frame = ttk.Frame(edit_container) + dob_frame.grid(row=1, column=1, columnspan=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + # Create a frame for the date picker + date_picker_frame = ttk.Frame(dob_frame) + date_picker_frame.pack(side=tk.TOP) + + dob_var = tk.StringVar(value=cur_dob) + dob_entry = ttk.Entry(date_picker_frame, textvariable=dob_var, width=20, state='readonly') + dob_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Calendar button + calendar_btn = ttk.Button(date_picker_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) + calendar_btn.pack(side=tk.RIGHT, padx=(5, 0)) + + dob_label = ttk.Label(dob_frame, text="Date of Birth", font=("Arial", 8), foreground="gray") + dob_label.pack(side=tk.TOP, pady=(2, 0)) + + def open_calendar(): + """Open a visual calendar dialog to select date of birth""" + from datetime import datetime, date, timedelta + import calendar + + # Create calendar window + calendar_window = tk.Toplevel(root) + calendar_window.title("Select Date of Birth") + calendar_window.resizable(False, False) + calendar_window.transient(root) + calendar_window.grab_set() + + # Calculate center position before showing the window + 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) + + # Set geometry with center position before showing + calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") + + # Calendar variables + current_date = datetime.now() + + # Check if there's already a date selected + existing_date_str = dob_var.get().strip() + if existing_date_str: + try: + existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() + display_year = existing_date.year + display_month = existing_date.month + selected_date = existing_date + except ValueError: + # If existing date is invalid, use default + display_year = current_date.year - 25 + display_month = 1 + selected_date = None + else: + # Default to 25 years ago + display_year = current_date.year - 25 + display_month = 1 + selected_date = None + + # Month names + month_names = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + # Configure custom styles for better visual highlighting + style = ttk.Style() + + # Selected date style - bright blue background with white text + style.configure("Selected.TButton", + background="#0078d4", + foreground="white", + font=("Arial", 9, "bold"), + relief="raised", + borderwidth=2) + style.map("Selected.TButton", + background=[("active", "#106ebe")], + relief=[("pressed", "sunken")]) + + # Today's date style - orange background + style.configure("Today.TButton", + background="#ff8c00", + foreground="white", + font=("Arial", 9, "bold"), + relief="raised", + borderwidth=1) + style.map("Today.TButton", + background=[("active", "#e67e00")], + relief=[("pressed", "sunken")]) + + # Calendar-specific normal button style (don't affect global TButton) + style.configure("Calendar.TButton", + font=("Arial", 9), + relief="flat") + style.map("Calendar.TButton", + background=[("active", "#e1e1e1")], + relief=[("pressed", "sunken")]) + + # 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() + + def update_calendar(): + """Update the calendar display""" + # Clear existing calendar + for widget in calendar_frame.winfo_children(): + widget.destroy() + + # Update header + month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") + + # 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): + label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) + label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") + + # Calendar days + for week_num, week in enumerate(cal): + for day_num, day in enumerate(week): + if day == 0: + # Empty cell + label = ttk.Label(calendar_frame, text="") + label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") + else: + # Day button + 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 with prominent style + 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), + width=3, style="Calendar.TButton") + day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") + + # Check if this day should be highlighted + is_today = (display_year == current_date.year and + display_month == current_date.month and + day == current_date.day) + is_selected = (selected_date and + selected_date.year == display_year and + selected_date.month == display_month and + selected_date.day == day) + + if is_selected: + day_btn.config(style="Selected.TButton") + elif is_today: + day_btn.config(style="Today.TButton") + + # Navigation functions + def prev_year(): + nonlocal display_year + display_year = max(1900, display_year - 1) + update_calendar() + + def next_year(): + nonlocal display_year + display_year = min(current_date.year, display_year + 1) + update_calendar() + + def prev_month(): + nonlocal display_month, display_year + if display_month > 1: + display_month -= 1 + else: + display_month = 12 + display_year = max(1900, display_year - 1) + update_calendar() + + def next_month(): + nonlocal display_month, display_year + if display_month < 12: + display_month += 1 + else: + display_month = 1 + display_year = min(current_date.year, display_year + 1) + update_calendar() + + # Navigation buttons + prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) + prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) + + prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) + prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) + + month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) + month_year_label.pack(side=tk.LEFT, padx=5) + + next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) + next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) + + 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: + date_str = selected_date.strftime('%Y-%m-%d') + dob_var.set(date_str) + calendar_window.destroy() + else: + messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") + + def cancel_selection(): + """Cancel date selection""" + calendar_window.destroy() + + # Buttons + 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 + update_calendar() + + def save_rename(): + new_first = first_var.get().strip() + new_last = last_var.get().strip() + new_middle = middle_var.get().strip() + new_maiden = maiden_var.get().strip() + new_dob = dob_var.get().strip() + + if not new_first and not new_last: + messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.") + return + + # Check for duplicates in local data first (based on first and last name only) + for person in people_data: + if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last: + display_name = f"{new_last}, {new_first}".strip(", ").strip() + messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.") + return + + # Single database access - save to database + with self.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 = ?', + (new_first, new_last, new_middle, new_maiden, new_dob, person_record['id'])) + conn.commit() + + # Update local data structure + person_record['first_name'] = new_first + person_record['last_name'] = new_last + person_record['middle_name'] = new_middle + person_record['maiden_name'] = new_maiden + person_record['date_of_birth'] = new_dob + + # Recreate the full display name with all available information + name_parts = [] + if new_first: + name_parts.append(new_first) + if new_middle: + name_parts.append(new_middle) + if new_last: + name_parts.append(new_last) + if new_maiden: + name_parts.append(f"({new_maiden})") + + full_name = ' '.join(name_parts) if name_parts else "Unknown" + + # Create detailed display with date of birth if available + display_name = full_name + if new_dob: + display_name += f" - Born: {new_dob}" + + person_record['name'] = display_name + person_record['full_name'] = full_name + + # Refresh list + current_selected_id = person_record['id'] + populate_people_list() + # Reselect and refresh right panel header if needed + if selected_person_id == current_selected_id or selected_person_id is None: + # Find updated name + updated = next((p for p in people_data if p['id'] == current_selected_id), None) + if updated: + # Bold corresponding label + for child in people_list_inner.winfo_children(): + # child is row frame: contains label and button + widgets = child.winfo_children() + if not widgets: + continue + lbl = widgets[0] + if isinstance(lbl, ttk.Label) and lbl.cget('text').startswith(updated['name'] + " ("): + lbl.config(font=("Arial", 10, "bold")) + break + # Update right panel header by re-showing faces + show_person_faces(updated['id'], updated['name']) + + def cancel_edit(): + # Rebuild the row back to label + edit + for w in row_frame.winfo_children(): + w.destroy() + rebuild_row(row_frame, person_record, row_index) + + save_btn = ttk.Button(row_frame, text="šŸ’¾", width=3, command=save_rename) + save_btn.pack(side=tk.LEFT, padx=(5, 0)) + cancel_btn = ttk.Button(row_frame, text="āœ–", width=3, command=cancel_edit) + cancel_btn.pack(side=tk.LEFT, padx=(5, 0)) + + # Configure custom disabled button style for better visibility + style = ttk.Style() + style.configure("Disabled.TButton", + background="#d3d3d3", # Light gray background + foreground="#808080", # Dark gray text + relief="flat", + borderwidth=1) + + def validate_save_button(): + """Enable/disable save button based on required fields""" + first_val = first_var.get().strip() + last_val = last_var.get().strip() + dob_val = dob_var.get().strip() + + # Enable save button only if both name fields and date of birth are provided + has_first = bool(first_val) + has_last = bool(last_val) + has_dob = bool(dob_val) + + if has_first and has_last and has_dob: + save_btn.config(state="normal") + # Reset to normal styling when enabled + save_btn.config(style="TButton") + else: + save_btn.config(state="disabled") + # Apply custom disabled styling for better visibility + save_btn.config(style="Disabled.TButton") + + # Set up validation callbacks for all input fields + first_var.trace('w', lambda *args: validate_save_button()) + last_var.trace('w', lambda *args: validate_save_button()) + middle_var.trace('w', lambda *args: validate_save_button()) + maiden_var.trace('w', lambda *args: validate_save_button()) + dob_var.trace('w', lambda *args: validate_save_button()) + + # Initial validation + validate_save_button() + + # Keyboard shortcuts (only work when save button is enabled) + def try_save(): + if save_btn.cget('state') == 'normal': + save_rename() + + first_entry.bind('', lambda e: try_save()) + last_entry.bind('', lambda e: try_save()) + middle_entry.bind('', lambda e: try_save()) + maiden_entry.bind('', lambda e: try_save()) + dob_entry.bind('', lambda e: try_save()) + first_entry.bind('', lambda e: cancel_edit()) + last_entry.bind('', lambda e: cancel_edit()) + middle_entry.bind('', lambda e: cancel_edit()) + maiden_entry.bind('', lambda e: cancel_edit()) + dob_entry.bind('', lambda e: cancel_edit()) + + def rebuild_row(row_frame, p, i): + # Edit button (on the left) + edit_btn = ttk.Button(row_frame, text="āœļø", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii)) + 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"{p['name']} ({p['count']})", font=("Arial", 10)) + name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) + name_lbl.bind("", make_click_handler(p['id'], p['name'], i)) + name_lbl.config(cursor="hand2") + # Bold if selected + if (selected_person_id is None and i == 0) or (selected_person_id == p['id']): + name_lbl.config(font=("Arial", 10, "bold")) + + # Build row contents with edit button + rebuild_row(row, row_person, row_idx) + + # Initial load + load_people() + populate_people_list() + + # Show first person's faces by default and mark selected + if people_data: + selected_person_id = people_data[0]['id'] + show_person_faces(people_data[0]['id'], people_data[0]['name']) + + # Control buttons + control_frame = ttk.Frame(main_frame) + control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) + + def on_quit(): + nonlocal window_destroyed + on_closing() + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + def on_save_all_changes(): + # Use global unmatched_faces set; commit all across people + nonlocal unmatched_faces + if not unmatched_faces: + messagebox.showinfo("Nothing to Save", "There are no pending changes to save.") + return + result = messagebox.askyesno( + "Confirm Save", + f"Unlink {len(unmatched_faces)} face(s) across all people?\n\nThis will make them unidentified." + ) + if not result: + return + with self.get_db_connection() as conn: + cursor = conn.cursor() + for face_id in unmatched_faces: + cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) + conn.commit() + count = len(unmatched_faces) + unmatched_faces.clear() + # Refresh people list and right panel for current selection + load_people() + populate_people_list() + if current_person_id is not None and current_person_name: + show_person_faces(current_person_id, current_person_name) + messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).") + + save_btn_bottom = ttk.Button(control_frame, text="šŸ’¾ Save changes", command=on_save_all_changes) + save_btn_bottom.pack(side=tk.RIGHT, padx=(0, 10)) + quit_btn = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) + quit_btn.pack(side=tk.RIGHT) + + # Show the window + try: + root.deiconify() + root.lift() + root.focus_force() + except tk.TclError: + # Window was destroyed before we could show it + return 0 + + # Main event loop + try: + root.mainloop() + except tk.TclError: + pass # Window was destroyed + + return 0 + + +def main(): + """Main CLI interface""" + parser = argparse.ArgumentParser( + description="PunimTag CLI - Simple photo face tagger", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + photo_tagger.py scan /path/to/photos # Scan folder for photos + photo_tagger.py process --limit 20 # Process 20 photos for faces + photo_tagger.py identify --batch 10 # Identify 10 faces interactively + photo_tagger.py auto-match # Auto-identify matching faces + photo_tagger.py modifyidentified # Show and Modify identified faces + photo_tagger.py match 15 # Find faces similar to face ID 15 + photo_tagger.py tag --pattern "vacation" # Tag photos matching pattern + photo_tagger.py search "John" # Find photos with John + photo_tagger.py tag-manager # Open tag management GUI + photo_tagger.py stats # Show statistics + """ + ) + + parser.add_argument('command', + choices=['scan', 'process', 'identify', 'tag', 'search', '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='data/photos.db', + help='Database file path (default: data/photos.db)') + + parser.add_argument('--limit', type=int, default=50, + help='Batch size limit for processing (default: 50)') + + parser.add_argument('--batch', type=int, default=20, + help='Batch size for identification (default: 20)') + + parser.add_argument('--pattern', + help='Pattern for filtering photos when tagging') + + parser.add_argument('--model', choices=['hog', 'cnn'], default='hog', + help='Face detection model: hog (faster) or cnn (more accurate)') + + parser.add_argument('--recursive', action='store_true', + help='Scan folders recursively') + + parser.add_argument('--show-faces', action='store_true', + help='Show individual face crops during identification') + + parser.add_argument('--tolerance', type=float, default=0.5, + help='Face matching tolerance (0.0-1.0, lower = stricter, default: 0.5)') + + 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('-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 + tagger.scan_folder(args.target, args.recursive) + + elif args.command == 'process': + tagger.process_faces(args.limit, args.model) + + elif args.command == 'identify': + show_faces = getattr(args, 'show_faces', False) + tagger.identify_faces(args.batch, show_faces, args.tolerance) + + 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 == '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['person_id'] is None else f"Person ID {match['person_id']}" + print(f" šŸ“ø {match['filename']} - {person_name} (confidence: {(1-match['distance']):.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}") + return 1 + finally: + # Always cleanup resources + tagger.cleanup() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/photo_tagger_refactored.py b/photo_tagger_refactored.py new file mode 100644 index 0000000..6478b55 --- /dev/null +++ b/photo_tagger_refactored.py @@ -0,0 +1,364 @@ +#!/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 config import ( + DEFAULT_DB_PATH, DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE, + DEFAULT_BATCH_SIZE, DEFAULT_PROCESSING_LIMIT +) +from database import DatabaseManager +from face_processing import FaceProcessor +from photo_management import PhotoManager +from tag_management import TagManager +from search_stats import SearchStats +from gui_core import GUICore + + +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() + + # Legacy compatibility - expose some methods directly + self._face_encoding_cache = {} + self._image_cache = {} + 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) -> int: + """Process unprocessed photos for faces""" + return self.face_processor.process_faces(limit, model) + + 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""" + # This would need to be implemented in the face_processing module + # For now, return 0 + print("āš ļø Auto-identify matches not yet implemented in refactored version") + return 0 + + # 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, show_faces: bool = False, 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""" + print("āš ļø Face identification GUI not yet implemented in refactored version") + return 0 + + def tag_management(self) -> int: + """Tag management GUI""" + print("āš ļø Tag management GUI not yet implemented in refactored version") + return 0 + + def modifyidentified(self) -> int: + """Modify identified faces GUI""" + print("āš ļø Face modification GUI not yet implemented in refactored version") + return 0 + + 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 main(): + """Main CLI interface""" + 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', '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('--show-faces', action='store_true', + help='Show individual face crops during identification') + + 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('-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 + tagger.scan_folder(args.target, args.recursive) + + elif args.command == 'process': + tagger.process_faces(args.limit, args.model) + + elif args.command == 'identify': + show_faces = getattr(args, 'show_faces', False) + tagger.identify_faces(args.batch, show_faces, args.tolerance) + + 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 == '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()) diff --git a/search_stats.py b/search_stats.py new file mode 100644 index 0000000..da0f6d7 --- /dev/null +++ b/search_stats.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +Search functionality and statistics for PunimTag +""" + +from typing import List, Dict, Tuple, Optional + +from 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[str]: + """Search for photos containing a specific person""" + # Get all people matching the name + people = self.db.show_people_list() + matching_people = [] + + 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} {last_name}".lower() + search_name = person_name.lower() + + # Check if search term matches any part of the name + if (search_name in full_name or + search_name in first_name.lower() or + 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: + print(f"āŒ No people found matching '{person_name}'") + return [] + + # Get photos for matching people + photo_paths = [] + for person_id in matching_people: + # This would need to be implemented in the database module + # For now, we'll use a placeholder + pass + + if photo_paths: + print(f"šŸ” Found {len(photo_paths)} photos with '{person_name}':") + for path in photo_paths: + print(f" šŸ“ø {path}") + else: + print(f"āŒ No photos found for '{person_name}'") + + return photo_paths + + 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]: + """Search photos by date range""" + # This would need to be implemented in the database module + # For now, return empty list + return [] + + def search_photos_by_tags(self, tags: List[str], match_all: bool = False) -> List[Tuple]: + """Search photos by tags""" + # This would need to be implemented in the database module + # For now, return empty list + return [] + + 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""" + # This would need to be implemented in the database module + # For now, return empty list + return [] + + def get_photos_without_tags(self) -> List[Tuple]: + """Get photos that have no tags""" + # This would need to be implemented in the database module + # For now, return empty list + return [] + + 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 diff --git a/tag_management.py b/tag_management.py new file mode 100644 index 0000000..57c2cdf --- /dev/null +++ b/tag_management.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +Tag management functionality for PunimTag +""" + +from typing import List, Dict, Tuple, Optional + +from config import DEFAULT_BATCH_SIZE +from 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: + if tag_name in tag_name_to_id: + tag_ids.append(tag_name_to_id[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, ""