Enhance the tagging system by introducing a normalized structure with a separate `tags` table for unique tag definitions and a `phototaglinkage` table to manage the many-to-many relationship between photos and tags. Update the logic for inserting and retrieving tags to improve data integrity and prevent duplicates. Additionally, update the README to reflect these changes and document the new folder view features.
6207 lines
299 KiB
Python
6207 lines
299 KiB
Python
#!/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 _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('<Configure>', 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('<Configure>', _position_required_asterisks)
|
||
first_name_entry.bind('<Configure>', _position_required_asterisks)
|
||
last_name_entry.bind('<Configure>', _position_required_asterisks)
|
||
date_of_birth_entry.bind('<Configure>', _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('<KeyRelease>', lambda e: _safe_show_suggestions())
|
||
last_name_entry.bind('<KeyPress>', _on_key_press)
|
||
last_name_entry.bind('<FocusOut>', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks
|
||
last_name_listbox.bind('<Button-1>', _on_listbox_click)
|
||
last_name_listbox.bind('<KeyPress>', _on_listbox_key)
|
||
last_name_listbox.bind('<Double-Button-1>', _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(
|
||
"<Configure>",
|
||
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('<Return>', on_enter)
|
||
last_name_entry.bind('<Return>', 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
|
||
|
||
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)
|
||
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 _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)
|
||
|
||
# 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
|
||
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
|
||
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
|
||
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('<Return>', 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
|
||
|
||
# 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)
|
||
|
||
# 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)
|
||
|
||
# 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(
|
||
"<Configure>",
|
||
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))
|
||
|
||
# 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("<MouseWheel>", 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("<ButtonRelease-1>", global_mouse_release)
|
||
|
||
# Unbind when window is destroyed
|
||
def cleanup_mousewheel():
|
||
try:
|
||
root.unbind_all("<MouseWheel>")
|
||
root.unbind_all("<ButtonRelease-1>")
|
||
except:
|
||
pass
|
||
|
||
root.bind("<Destroy>", 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': 150, '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': 150, 'weight': 1}
|
||
],
|
||
'compact': [
|
||
{'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1},
|
||
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
|
||
{'key': 'tags', 'label': 'Tags', 'width': 150, '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 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("<Button-1>", close_on_click_outside)
|
||
root.bind("<Button-3>", close_on_click_outside)
|
||
|
||
# Also bind to the main content area
|
||
content_canvas.bind("<Button-1>", close_on_click_outside)
|
||
content_canvas.bind("<Button-3>", close_on_click_outside)
|
||
|
||
# Focus the popup
|
||
popup.focus_set()
|
||
|
||
|
||
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("<Button-3>", 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("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
|
||
separator_frame.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
|
||
separator_frame.bind("<ButtonRelease-1>", stop_resize)
|
||
separator_frame.bind("<Enter>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) # Orange for debugging
|
||
separator_frame.bind("<Leave>", 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("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
|
||
inner_line.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
|
||
inner_line.bind("<ButtonRelease-1>", stop_resize)
|
||
|
||
# Bind right-click to the entire header frame
|
||
header_frame.bind("<Button-3>", 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':
|
||
text = photo['tags'] or "None"
|
||
|
||
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("<Button-3>", lambda e, mode='icons': show_column_context_menu(e, mode))
|
||
|
||
# Bind right-click to the entire header frame
|
||
header_frame.bind("<Button-3>", 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':
|
||
text = photo['tags'] or "None"
|
||
|
||
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("<Button-3>", lambda e, mode='compact': show_column_context_menu(e, mode))
|
||
|
||
# Bind right-click to the entire header frame
|
||
header_frame.bind("<Button-3>", 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':
|
||
text = photo['tags'] or "None"
|
||
|
||
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_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("<Enter>", self.on_enter)
|
||
self.widget.bind("<Leave>", self.on_leave)
|
||
|
||
def on_enter(self, event=None):
|
||
if self.tooltip_window or not self.text:
|
||
return
|
||
x, y, _, _ = self.widget.bbox("insert") if hasattr(self.widget, 'bbox') else (0, 0, 0, 0)
|
||
x += self.widget.winfo_rootx() + 25
|
||
y += self.widget.winfo_rooty() + 25
|
||
|
||
self.tooltip_window = tw = tk.Toplevel(self.widget)
|
||
tw.wm_overrideredirect(True)
|
||
tw.wm_geometry(f"+{x}+{y}")
|
||
|
||
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
||
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
|
||
font=("tahoma", "8", "normal"))
|
||
label.pack(ipadx=1)
|
||
|
||
def on_leave(self, event=None):
|
||
if self.tooltip_window:
|
||
self.tooltip_window.destroy()
|
||
self.tooltip_window = None
|
||
|
||
# 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(
|
||
"<Configure>",
|
||
lambda e: people_canvas.configure(scrollregion=people_canvas.bbox("all"))
|
||
)
|
||
|
||
people_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||
people_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
|
||
people_frame.rowconfigure(1, weight=1)
|
||
|
||
# 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(
|
||
"<Configure>",
|
||
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("<Configure>", 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('<Return>', 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("<Button-1>", lambda e, fid=face_id: unmatch_face(fid))
|
||
# Hover highlight: change bg, show white outline, and hand cursor
|
||
x_canvas.bind("<Enter>", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2"))
|
||
x_canvas.bind("<Leave>", 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('<Return>', lambda e: try_save())
|
||
last_entry.bind('<Return>', lambda e: try_save())
|
||
middle_entry.bind('<Return>', lambda e: try_save())
|
||
maiden_entry.bind('<Return>', lambda e: try_save())
|
||
dob_entry.bind('<Return>', lambda e: try_save())
|
||
first_entry.bind('<Escape>', lambda e: cancel_edit())
|
||
last_entry.bind('<Escape>', lambda e: cancel_edit())
|
||
middle_entry.bind('<Escape>', lambda e: cancel_edit())
|
||
maiden_entry.bind('<Escape>', lambda e: cancel_edit())
|
||
dob_entry.bind('<Escape>', lambda e: cancel_edit())
|
||
|
||
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("<Button-1>", 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())
|