6702 lines
323 KiB
Python
6702 lines
323 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 _deduplicate_tags(self, tag_list):
|
||
"""Remove duplicate tags from a list while preserving order (case insensitive)"""
|
||
seen = set()
|
||
unique_tags = []
|
||
for tag in tag_list:
|
||
if tag.lower() not in seen:
|
||
seen.add(tag.lower())
|
||
unique_tags.append(tag)
|
||
return unique_tags
|
||
|
||
def _parse_tags_string(self, tags_string):
|
||
"""Parse a comma-separated tags string into a list, handling empty strings and whitespace"""
|
||
if not tags_string or tags_string.strip() == "":
|
||
return []
|
||
# Split by comma and strip whitespace from each tag
|
||
tags = [tag.strip() for tag in tags_string.split(",")]
|
||
# Remove empty strings that might result from splitting
|
||
return [tag for tag in tags if tag]
|
||
|
||
def _get_tag_id_by_name(self, tag_name, tag_name_to_id_map):
|
||
"""Get tag ID by name, creating the tag if it doesn't exist"""
|
||
if tag_name in tag_name_to_id_map:
|
||
return tag_name_to_id_map[tag_name]
|
||
return None
|
||
|
||
def _get_tag_name_by_id(self, tag_id, tag_id_to_name_map):
|
||
"""Get tag name by ID"""
|
||
return tag_id_to_name_map.get(tag_id, f"Unknown Tag {tag_id}")
|
||
|
||
def _load_tag_mappings(self):
|
||
"""Load tag name to ID and ID to name mappings from database"""
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name')
|
||
tag_id_to_name = {}
|
||
tag_name_to_id = {}
|
||
for row in cursor.fetchall():
|
||
tag_id, tag_name = row
|
||
tag_id_to_name[tag_id] = tag_name
|
||
tag_name_to_id[tag_name] = tag_id
|
||
return tag_id_to_name, tag_name_to_id
|
||
|
||
def _get_existing_tag_ids_for_photo(self, photo_id):
|
||
"""Get list of tag IDs for a photo from database"""
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('''
|
||
SELECT ptl.tag_id
|
||
FROM phototaglinkage ptl
|
||
WHERE ptl.photo_id = ?
|
||
ORDER BY ptl.created_date
|
||
''', (photo_id,))
|
||
return [row[0] for row in cursor.fetchall()]
|
||
|
||
def _setup_window_size_saving(self, root, config_file="gui_config.json"):
|
||
"""Set up window size saving functionality"""
|
||
import json
|
||
import tkinter as tk
|
||
|
||
# Load saved window size
|
||
default_size = "600x500"
|
||
saved_size = default_size
|
||
|
||
if os.path.exists(config_file):
|
||
try:
|
||
with open(config_file, 'r') as f:
|
||
config = json.load(f)
|
||
saved_size = config.get('window_size', default_size)
|
||
except:
|
||
saved_size = default_size
|
||
|
||
# Calculate center position before showing window
|
||
try:
|
||
width = int(saved_size.split('x')[0])
|
||
height = int(saved_size.split('x')[1])
|
||
x = (root.winfo_screenwidth() // 2) - (width // 2)
|
||
y = (root.winfo_screenheight() // 2) - (height // 2)
|
||
root.geometry(f"{saved_size}+{x}+{y}")
|
||
except tk.TclError:
|
||
# Fallback to default geometry if positioning fails
|
||
root.geometry(saved_size)
|
||
|
||
# Track previous size to detect actual resizing
|
||
last_size = None
|
||
|
||
def save_window_size(event=None):
|
||
nonlocal last_size
|
||
if event and event.widget == root:
|
||
current_size = f"{root.winfo_width()}x{root.winfo_height()}"
|
||
# Only save if size actually changed
|
||
if current_size != last_size:
|
||
last_size = current_size
|
||
try:
|
||
config = {'window_size': current_size}
|
||
with open(config_file, 'w') as f:
|
||
json.dump(config, f)
|
||
except:
|
||
pass # Ignore save errors
|
||
|
||
# Bind resize event
|
||
root.bind('<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
|
||
|
||
# Track pending tag changes (photo_id -> list of tag IDs)
|
||
pending_tag_changes = {}
|
||
existing_tags = [] # Cache of existing tag names from database (for UI display)
|
||
tag_id_to_name = {} # Cache of tag ID to name mapping
|
||
tag_name_to_id = {} # Cache of tag name to ID mapping
|
||
|
||
# Hide window initially to prevent flash at corner
|
||
root.withdraw()
|
||
|
||
# Set up protocol handler for window close button (X)
|
||
def on_closing():
|
||
nonlocal window_destroyed
|
||
# Cleanup temp crops
|
||
for crop in list(temp_crops):
|
||
try:
|
||
if os.path.exists(crop):
|
||
os.remove(crop)
|
||
except:
|
||
pass
|
||
temp_crops.clear()
|
||
if not window_destroyed:
|
||
window_destroyed = True
|
||
try:
|
||
root.destroy()
|
||
except tk.TclError:
|
||
pass # Window already destroyed
|
||
|
||
root.protocol("WM_DELETE_WINDOW", on_closing)
|
||
|
||
# Set up window size saving
|
||
saved_size = self._setup_window_size_saving(root)
|
||
|
||
# Create main frame
|
||
main_frame = ttk.Frame(root, padding="10")
|
||
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||
|
||
# Configure grid weights
|
||
root.columnconfigure(0, weight=1)
|
||
root.rowconfigure(0, weight=1)
|
||
main_frame.columnconfigure(0, weight=1)
|
||
main_frame.rowconfigure(1, weight=1)
|
||
main_frame.rowconfigure(2, weight=0)
|
||
|
||
# Title and controls frame
|
||
header_frame = ttk.Frame(main_frame)
|
||
header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
||
header_frame.columnconfigure(1, weight=1)
|
||
|
||
# Title label
|
||
title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold"))
|
||
title_label.grid(row=0, column=0, sticky=tk.W)
|
||
|
||
# View mode controls
|
||
view_frame = ttk.Frame(header_frame)
|
||
view_frame.grid(row=0, column=1, sticky=tk.E)
|
||
|
||
view_mode_var = tk.StringVar(value="list")
|
||
ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5))
|
||
ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list",
|
||
command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5))
|
||
ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons",
|
||
command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5))
|
||
ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact",
|
||
command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT)
|
||
|
||
# Manage Tags button
|
||
def open_manage_tags_dialog():
|
||
"""Open a dialog to manage tags: list, edit, add, and delete."""
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, simpledialog
|
||
|
||
# Dialog window
|
||
dialog = tk.Toplevel(root)
|
||
dialog.title("Manage Tags")
|
||
dialog.transient(root)
|
||
dialog.grab_set()
|
||
dialog.geometry("500x500")
|
||
|
||
# Layout frames
|
||
top_frame = ttk.Frame(dialog, padding="8")
|
||
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
||
list_frame = ttk.Frame(dialog, padding="8")
|
||
list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
||
bottom_frame = ttk.Frame(dialog, padding="8")
|
||
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E))
|
||
|
||
dialog.columnconfigure(0, weight=1)
|
||
dialog.rowconfigure(1, weight=1)
|
||
|
||
# Add tag controls (top)
|
||
new_tag_var = tk.StringVar()
|
||
new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30)
|
||
new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E))
|
||
|
||
def add_new_tag():
|
||
tag_name = new_tag_var.get().strip()
|
||
if not tag_name:
|
||
return
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
||
conn.commit()
|
||
new_tag_var.set("")
|
||
refresh_tag_list()
|
||
load_existing_tags()
|
||
# Refresh main view to reflect new tag options
|
||
switch_view_mode(view_mode_var.get())
|
||
except Exception as e:
|
||
messagebox.showerror("Error", f"Failed to add tag: {e}")
|
||
|
||
add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag)
|
||
add_btn.grid(row=0, column=1, sticky=tk.W)
|
||
top_frame.columnconfigure(0, weight=1)
|
||
|
||
# Scrollable tag list (center)
|
||
canvas = tk.Canvas(list_frame, highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
|
||
rows_container = ttk.Frame(canvas)
|
||
canvas.create_window((0, 0), window=rows_container, anchor="nw")
|
||
canvas.configure(yscrollcommand=scrollbar.set)
|
||
canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
||
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||
list_frame.columnconfigure(0, weight=1)
|
||
list_frame.rowconfigure(0, weight=1)
|
||
|
||
rows_container.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
|
||
# Selection tracking
|
||
selected_tag_vars = {}
|
||
current_tags = [] # list of dicts: {id, tag_name}
|
||
|
||
def refresh_tag_list():
|
||
# Clear rows
|
||
for child in list(rows_container.winfo_children()):
|
||
child.destroy()
|
||
selected_tag_vars.clear()
|
||
current_tags.clear()
|
||
# Load tags
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE')
|
||
for row in cursor.fetchall():
|
||
current_tags.append({'id': row[0], 'tag_name': row[1]})
|
||
except Exception as e:
|
||
messagebox.showerror("Error", f"Failed to load tags: {e}")
|
||
return
|
||
# Build header
|
||
head = ttk.Frame(rows_container)
|
||
head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6))
|
||
chk_lbl = ttk.Label(head, text="Delete")
|
||
chk_lbl.pack(side=tk.LEFT, padx=(0, 10))
|
||
name_lbl = ttk.Label(head, text="Tag name", width=30)
|
||
name_lbl.pack(side=tk.LEFT)
|
||
act_lbl = ttk.Label(head, text="Edit", width=6)
|
||
act_lbl.pack(side=tk.LEFT, padx=(10, 0))
|
||
|
||
# Populate rows
|
||
for idx, tag in enumerate(current_tags, start=1):
|
||
row = ttk.Frame(rows_container)
|
||
row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2)
|
||
var = tk.BooleanVar(value=False)
|
||
selected_tag_vars[tag['id']] = var
|
||
chk = ttk.Checkbutton(row, variable=var)
|
||
chk.pack(side=tk.LEFT, padx=(0, 10))
|
||
name = ttk.Label(row, text=tag['tag_name'], width=30)
|
||
name.pack(side=tk.LEFT)
|
||
|
||
def make_edit_handler(tag_id, name_label):
|
||
def handler():
|
||
new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_label.cget('text'), parent=dialog)
|
||
if new_name is None:
|
||
return
|
||
new_name = new_name.strip()
|
||
if not new_name:
|
||
return
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
# Ensure name is unique
|
||
cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id))
|
||
conn.commit()
|
||
except Exception as e:
|
||
messagebox.showerror("Error", f"Failed to rename tag: {e}")
|
||
return
|
||
# Update UI and caches
|
||
refresh_tag_list()
|
||
load_existing_tags()
|
||
switch_view_mode(view_mode_var.get())
|
||
return handler
|
||
|
||
edit_btn = ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name))
|
||
edit_btn.pack(side=tk.LEFT, padx=(10, 0))
|
||
|
||
refresh_tag_list()
|
||
|
||
# Bottom buttons
|
||
def delete_selected():
|
||
ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()]
|
||
if not ids_to_delete:
|
||
return
|
||
if not messagebox.askyesno("Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos."):
|
||
return
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
# Remove linkages first to maintain integrity
|
||
cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
|
||
# Delete tags
|
||
cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
|
||
conn.commit()
|
||
|
||
# Clean up pending tag changes for deleted tags
|
||
for photo_id in list(pending_tag_changes.keys()):
|
||
pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in ids_to_delete]
|
||
if not pending_tag_changes[photo_id]:
|
||
del pending_tag_changes[photo_id]
|
||
|
||
refresh_tag_list()
|
||
load_existing_tags()
|
||
load_photos() # Refresh photo data to reflect deleted tags
|
||
switch_view_mode(view_mode_var.get())
|
||
except Exception as e:
|
||
messagebox.showerror("Error", f"Failed to delete tags: {e}")
|
||
|
||
delete_btn = ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected)
|
||
delete_btn.pack(side=tk.LEFT)
|
||
quit_btn = ttk.Button(bottom_frame, text="Quit", command=dialog.destroy)
|
||
quit_btn.pack(side=tk.RIGHT)
|
||
|
||
# Keyboard focus
|
||
new_tag_entry.focus_set()
|
||
|
||
manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog)
|
||
manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0))
|
||
|
||
# Main content area
|
||
content_frame = ttk.Frame(main_frame)
|
||
content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||
content_frame.columnconfigure(0, weight=1)
|
||
content_frame.rowconfigure(0, weight=1)
|
||
|
||
# Style for consistent gray background
|
||
style = ttk.Style()
|
||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||
|
||
# Create canvas and scrollbar for content
|
||
content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0)
|
||
content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview)
|
||
content_inner = ttk.Frame(content_canvas)
|
||
content_canvas.create_window((0, 0), window=content_inner, anchor="nw")
|
||
content_canvas.configure(yscrollcommand=content_scrollbar.set)
|
||
|
||
content_inner.bind(
|
||
"<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))
|
||
|
||
# Bottom frame for save button
|
||
bottom_frame = ttk.Frame(main_frame)
|
||
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0))
|
||
|
||
# Save tagging button (function will be defined later)
|
||
save_button = ttk.Button(bottom_frame, text="Save Tagging")
|
||
save_button.pack(side=tk.RIGHT, padx=10, pady=5)
|
||
|
||
# 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': 180, 'weight': 1}
|
||
],
|
||
'icons': [
|
||
{'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0},
|
||
{'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0},
|
||
{'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1},
|
||
{'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0},
|
||
{'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0},
|
||
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
|
||
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
|
||
],
|
||
'compact': [
|
||
{'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1},
|
||
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
|
||
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
|
||
]
|
||
}
|
||
|
||
def load_photos():
|
||
nonlocal photos_data
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('''
|
||
SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added,
|
||
COUNT(f.id) as face_count,
|
||
GROUP_CONCAT(DISTINCT t.tag_name) as tags
|
||
FROM photos p
|
||
LEFT JOIN faces f ON f.photo_id = p.id
|
||
LEFT JOIN phototaglinkage ptl ON ptl.photo_id = p.id
|
||
LEFT JOIN tags t ON t.id = ptl.tag_id
|
||
GROUP BY p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added
|
||
ORDER BY p.date_taken DESC, p.filename
|
||
''')
|
||
photos_data = []
|
||
for row in cursor.fetchall():
|
||
photos_data.append({
|
||
'id': row[0],
|
||
'filename': row[1],
|
||
'path': row[2],
|
||
'processed': row[3],
|
||
'date_taken': row[4],
|
||
'date_added': row[5],
|
||
'face_count': row[6] or 0,
|
||
'tags': row[7] or ""
|
||
})
|
||
|
||
def prepare_folder_grouped_data():
|
||
"""Prepare photo data grouped by folders"""
|
||
import os
|
||
from collections import defaultdict
|
||
|
||
# Group photos by folder
|
||
folder_groups = defaultdict(list)
|
||
for photo in photos_data:
|
||
folder_path = os.path.dirname(photo['path'])
|
||
folder_name = os.path.basename(folder_path) if folder_path else "Root"
|
||
folder_groups[folder_path].append(photo)
|
||
|
||
# Sort folders by path and photos within each folder by date_taken
|
||
sorted_folders = []
|
||
for folder_path in sorted(folder_groups.keys()):
|
||
folder_name = os.path.basename(folder_path) if folder_path else "Root"
|
||
photos_in_folder = sorted(folder_groups[folder_path],
|
||
key=lambda x: x['date_taken'] or '', reverse=True)
|
||
|
||
# Initialize folder state if not exists (default to expanded)
|
||
if folder_path not in folder_states:
|
||
folder_states[folder_path] = True
|
||
|
||
sorted_folders.append({
|
||
'folder_path': folder_path,
|
||
'folder_name': folder_name,
|
||
'photos': photos_in_folder,
|
||
'photo_count': len(photos_in_folder)
|
||
})
|
||
|
||
return sorted_folders
|
||
|
||
def create_folder_header(parent, folder_info, current_row, col_count, view_mode):
|
||
"""Create a collapsible folder header with toggle button"""
|
||
# Create folder header frame
|
||
folder_header_frame = ttk.Frame(parent)
|
||
folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5))
|
||
folder_header_frame.configure(relief='raised', borderwidth=1)
|
||
|
||
# Create toggle button
|
||
is_expanded = folder_states.get(folder_info['folder_path'], True)
|
||
toggle_text = "▼" if is_expanded else "▶"
|
||
toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1,
|
||
command=lambda: toggle_folder(folder_info['folder_path'], view_mode),
|
||
font=("Arial", 8), relief='flat', bd=1)
|
||
toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5)
|
||
|
||
# Create folder label
|
||
folder_label = ttk.Label(folder_header_frame,
|
||
text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos)",
|
||
font=("Arial", 11, "bold"))
|
||
folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5)
|
||
|
||
return folder_header_frame
|
||
|
||
def toggle_folder(folder_path, view_mode):
|
||
"""Toggle folder expand/collapse state and refresh view"""
|
||
folder_states[folder_path] = not folder_states.get(folder_path, True)
|
||
switch_view_mode(view_mode)
|
||
|
||
def load_existing_tags():
|
||
"""Load existing tags from database"""
|
||
nonlocal existing_tags, tag_id_to_name, tag_name_to_id
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name')
|
||
existing_tags = []
|
||
tag_id_to_name = {}
|
||
tag_name_to_id = {}
|
||
for row in cursor.fetchall():
|
||
tag_id, tag_name = row
|
||
existing_tags.append(tag_name)
|
||
tag_id_to_name[tag_id] = tag_name
|
||
tag_name_to_id[tag_name] = tag_id
|
||
|
||
def create_tagging_widget(parent, photo_id, current_tags=""):
|
||
"""Create a tagging widget with dropdown and text input"""
|
||
import tkinter as tk
|
||
from tkinter import ttk
|
||
|
||
# Create frame for tagging widget
|
||
tagging_frame = ttk.Frame(parent)
|
||
|
||
# Create combobox for tag selection/input
|
||
tag_var = tk.StringVar()
|
||
tag_combo = ttk.Combobox(tagging_frame, textvariable=tag_var, width=12)
|
||
tag_combo['values'] = existing_tags
|
||
tag_combo.pack(side=tk.LEFT, padx=2, pady=2)
|
||
|
||
# Create label to show current pending tags
|
||
pending_tags_var = tk.StringVar()
|
||
pending_tags_label = ttk.Label(tagging_frame, textvariable=pending_tags_var,
|
||
font=("Arial", 8), foreground="blue", width=20)
|
||
pending_tags_label.pack(side=tk.LEFT, padx=2, pady=2)
|
||
|
||
# Initialize pending tags display
|
||
if photo_id in pending_tag_changes:
|
||
# Convert tag IDs to names for display
|
||
pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo_id]]
|
||
pending_tags_var.set(", ".join(pending_tag_names))
|
||
else:
|
||
pending_tags_var.set(current_tags or "")
|
||
|
||
# Add button to add tag
|
||
def add_tag():
|
||
tag_name = tag_var.get().strip()
|
||
if tag_name:
|
||
# Get or create tag ID
|
||
if tag_name in tag_name_to_id:
|
||
tag_id = tag_name_to_id[tag_name]
|
||
else:
|
||
# Create new tag in database
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
||
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
|
||
tag_id = cursor.fetchone()[0]
|
||
# Update mappings
|
||
tag_name_to_id[tag_name] = tag_id
|
||
tag_id_to_name[tag_id] = tag_name
|
||
if tag_name not in existing_tags:
|
||
existing_tags.append(tag_name)
|
||
existing_tags.sort()
|
||
|
||
# Check if tag already exists (compare tag IDs) before adding to pending changes
|
||
existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id)
|
||
pending_tag_ids = pending_tag_changes.get(photo_id, [])
|
||
all_existing_tag_ids = existing_tag_ids + pending_tag_ids
|
||
|
||
if tag_id not in all_existing_tag_ids:
|
||
# Only add to pending changes if tag is actually new
|
||
if photo_id not in pending_tag_changes:
|
||
pending_tag_changes[photo_id] = []
|
||
pending_tag_changes[photo_id].append(tag_id)
|
||
# Update display
|
||
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]]
|
||
pending_tags_var.set(", ".join(pending_tag_names))
|
||
tag_var.set("") # Clear the input field
|
||
|
||
add_button = tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag)
|
||
add_button.pack(side=tk.LEFT, padx=2, pady=2)
|
||
|
||
# Remove button to remove last tag
|
||
def remove_tag():
|
||
if photo_id in pending_tag_changes and pending_tag_changes[photo_id]:
|
||
pending_tag_changes[photo_id].pop()
|
||
if pending_tag_changes[photo_id]:
|
||
# Convert tag IDs to names for display
|
||
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]]
|
||
pending_tags_var.set(", ".join(pending_tag_names))
|
||
else:
|
||
pending_tags_var.set("")
|
||
del pending_tag_changes[photo_id]
|
||
|
||
remove_button = tk.Button(tagging_frame, text="-", width=2, height=1, command=remove_tag)
|
||
remove_button.pack(side=tk.LEFT, padx=2, pady=2)
|
||
|
||
return tagging_frame
|
||
|
||
def save_tagging_changes():
|
||
"""Save all pending tag changes to database"""
|
||
if not pending_tag_changes:
|
||
messagebox.showinfo("Info", "No tag changes to save.")
|
||
return
|
||
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
for photo_id, tag_ids in pending_tag_changes.items():
|
||
for tag_id in tag_ids:
|
||
# Insert linkage (ignore if already exists)
|
||
cursor.execute(
|
||
'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)',
|
||
(photo_id, tag_id)
|
||
)
|
||
|
||
conn.commit()
|
||
|
||
# Store count before clearing
|
||
saved_count = len(pending_tag_changes)
|
||
|
||
# Clear pending changes and reload data
|
||
pending_tag_changes.clear()
|
||
load_existing_tags()
|
||
load_photos()
|
||
switch_view_mode(view_mode_var.get())
|
||
update_save_button_text()
|
||
|
||
messagebox.showinfo("Success", f"Saved tags for {saved_count} photos.")
|
||
|
||
except Exception as e:
|
||
messagebox.showerror("Error", f"Failed to save tags: {str(e)}")
|
||
|
||
def update_save_button_text():
|
||
"""Update save button text to show pending changes count"""
|
||
if pending_tag_changes:
|
||
total_changes = sum(len(tags) for tags in pending_tag_changes.values())
|
||
save_button.configure(text=f"Save Tagging ({total_changes} pending)")
|
||
else:
|
||
save_button.configure(text="Save Tagging")
|
||
|
||
# Configure the save button command now that the function is defined
|
||
save_button.configure(command=save_tagging_changes)
|
||
|
||
def clear_content():
|
||
for widget in content_inner.winfo_children():
|
||
widget.destroy()
|
||
# Cleanup temp crops
|
||
for crop in list(temp_crops):
|
||
try:
|
||
if os.path.exists(crop):
|
||
os.remove(crop)
|
||
except:
|
||
pass
|
||
temp_crops.clear()
|
||
photo_images.clear()
|
||
|
||
def show_column_context_menu(event, view_mode):
|
||
"""Show context menu for column visibility"""
|
||
# Create a custom popup window instead of a menu
|
||
popup = tk.Toplevel(root)
|
||
popup.wm_overrideredirect(True)
|
||
popup.wm_geometry(f"+{event.x_root}+{event.y_root}")
|
||
popup.configure(bg='white', relief='flat', bd=0)
|
||
|
||
# Define columns that cannot be hidden
|
||
protected_columns = {
|
||
'icons': ['thumbnail'],
|
||
'compact': ['filename'],
|
||
'list': ['filename']
|
||
}
|
||
|
||
# Create frame for menu items
|
||
menu_frame = tk.Frame(popup, bg='white')
|
||
menu_frame.pack(padx=2, pady=2)
|
||
|
||
# Variables to track checkbox states
|
||
checkbox_vars = {}
|
||
|
||
for col in column_config[view_mode]:
|
||
key = col['key']
|
||
label = col['label']
|
||
is_visible = column_visibility[view_mode][key]
|
||
is_protected = key in protected_columns.get(view_mode, [])
|
||
|
||
# Create frame for this menu item
|
||
item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0)
|
||
item_frame.pack(fill=tk.X, pady=1)
|
||
|
||
# Create checkbox variable
|
||
var = tk.BooleanVar(value=is_visible)
|
||
checkbox_vars[key] = var
|
||
|
||
def make_toggle_command(col_key, var_ref):
|
||
def toggle_column():
|
||
if col_key in protected_columns.get(view_mode, []):
|
||
return
|
||
# The checkbox has already toggled its state automatically
|
||
# Just sync it with our column visibility
|
||
column_visibility[view_mode][col_key] = var_ref.get()
|
||
# Refresh the view
|
||
switch_view_mode(view_mode)
|
||
return toggle_column
|
||
|
||
if is_protected:
|
||
# Protected columns - disabled checkbox
|
||
cb = tk.Checkbutton(item_frame, text=label, variable=var,
|
||
state='disabled', bg='white', fg='gray',
|
||
font=("Arial", 9), relief='flat', bd=0,
|
||
highlightthickness=0)
|
||
cb.pack(side=tk.LEFT, padx=5, pady=2)
|
||
tk.Label(item_frame, text="(always visible)", bg='white', fg='gray',
|
||
font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5))
|
||
else:
|
||
# Regular columns - clickable checkbox
|
||
cb = tk.Checkbutton(item_frame, text=label, variable=var,
|
||
command=make_toggle_command(key, var),
|
||
bg='white', font=("Arial", 9), relief='flat', bd=0,
|
||
highlightthickness=0)
|
||
cb.pack(side=tk.LEFT, padx=5, pady=2)
|
||
|
||
# Function to close popup
|
||
def close_popup():
|
||
try:
|
||
popup.destroy()
|
||
except:
|
||
pass
|
||
|
||
# Bind events to close popup
|
||
def close_on_click_outside(event):
|
||
# Close popup when clicking anywhere in the main window
|
||
# Check if the click is not on the popup itself
|
||
if event.widget != popup:
|
||
try:
|
||
# Check if popup still exists
|
||
popup.winfo_exists()
|
||
# If we get here, popup exists, so close it
|
||
close_popup()
|
||
except tk.TclError:
|
||
# Popup was already destroyed, do nothing
|
||
pass
|
||
|
||
root.bind("<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()
|
||
|
||
# Shared tag linking functions for all view modes
|
||
def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags):
|
||
"""Create a handler function for adding tags to a photo"""
|
||
def handler():
|
||
# Create popup window for tag selection
|
||
popup = tk.Toplevel(root)
|
||
popup.title("Add Tag")
|
||
popup.transient(root)
|
||
popup.grab_set()
|
||
popup.geometry("300x150")
|
||
popup.resizable(False, False)
|
||
|
||
# Create main frame
|
||
main_frame = ttk.Frame(popup, padding="10")
|
||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
ttk.Label(main_frame, text="Select a tag:").pack(pady=(0, 5))
|
||
|
||
tag_var = tk.StringVar()
|
||
combo = ttk.Combobox(main_frame, textvariable=tag_var, values=available_tags, width=30)
|
||
combo.pack(pady=(0, 10), fill=tk.X)
|
||
combo.focus_set()
|
||
|
||
def confirm():
|
||
tag_name = tag_var.get().strip()
|
||
if not tag_name:
|
||
popup.destroy()
|
||
return
|
||
|
||
# Get or create tag ID
|
||
if tag_name in tag_name_to_id:
|
||
tag_id = tag_name_to_id[tag_name]
|
||
else:
|
||
# Create new tag in database
|
||
with self.get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
||
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
|
||
tag_id = cursor.fetchone()[0]
|
||
# Update mappings
|
||
tag_name_to_id[tag_name] = tag_id
|
||
tag_id_to_name[tag_id] = tag_name
|
||
if tag_name not in existing_tags:
|
||
existing_tags.append(tag_name)
|
||
existing_tags.sort()
|
||
|
||
# Check if tag already exists (compare tag IDs) before adding to pending changes
|
||
existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id)
|
||
pending_tag_ids = pending_tag_changes.get(photo_id, [])
|
||
all_existing_tag_ids = existing_tag_ids + pending_tag_ids
|
||
|
||
if tag_id not in all_existing_tag_ids:
|
||
# Only add to pending changes if tag is actually new
|
||
if photo_id not in pending_tag_changes:
|
||
pending_tag_changes[photo_id] = []
|
||
pending_tag_changes[photo_id].append(tag_id)
|
||
# Update the display immediately - combine existing and pending, removing duplicates
|
||
existing_tags_list = self._parse_tags_string(photo_tags)
|
||
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]]
|
||
all_tags = existing_tags_list + pending_tag_names
|
||
unique_tags = self._deduplicate_tags(all_tags)
|
||
current_tags = ", ".join(unique_tags)
|
||
label_widget.configure(text=current_tags)
|
||
update_save_button_text()
|
||
|
||
popup.destroy()
|
||
|
||
ttk.Button(main_frame, text="Add", command=confirm).pack(pady=(0, 5))
|
||
return handler
|
||
|
||
|
||
def create_tag_buttons_frame(parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0):
|
||
"""Create a frame with tag display and add button that can be used in any view mode"""
|
||
tags_frame = ttk.Frame(parent)
|
||
|
||
# Display current tags
|
||
existing_tags_list = self._parse_tags_string(photo_tags)
|
||
pending_tag_names = []
|
||
if photo_id in pending_tag_changes:
|
||
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]]
|
||
|
||
all_tags = existing_tags_list + pending_tag_names
|
||
unique_tags = self._deduplicate_tags(all_tags)
|
||
current_display = ", ".join(unique_tags) if unique_tags else "None"
|
||
|
||
tags_text = ttk.Label(tags_frame, text=current_display)
|
||
tags_text.pack(side=tk.LEFT)
|
||
|
||
# Add button
|
||
add_btn = tk.Button(tags_frame, text="+", width=2,
|
||
command=create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags))
|
||
add_btn.pack(side=tk.LEFT, padx=(6, 0))
|
||
|
||
# Pack or grid the frame based on the view mode
|
||
if use_grid:
|
||
tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W)
|
||
else:
|
||
tags_frame.pack(side=tk.LEFT, padx=5)
|
||
|
||
return tags_frame
|
||
|
||
def show_list_view():
|
||
clear_content()
|
||
|
||
# Get visible columns and store globally for resize functions
|
||
nonlocal current_visible_cols
|
||
current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]]
|
||
col_count = len(current_visible_cols)
|
||
|
||
if col_count == 0:
|
||
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.",
|
||
font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
|
||
return
|
||
|
||
# Configure column weights for visible columns
|
||
for i, col in enumerate(current_visible_cols):
|
||
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
||
|
||
# Create header row
|
||
header_frame = ttk.Frame(content_inner)
|
||
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
||
|
||
# Configure header frame columns (accounting for separators)
|
||
for i, col in enumerate(current_visible_cols):
|
||
header_frame.columnconfigure(i*2, weight=col['weight'], minsize=col['width'])
|
||
if i < len(current_visible_cols) - 1:
|
||
header_frame.columnconfigure(i*2+1, weight=0, minsize=1) # Separator column
|
||
|
||
# Create header labels with right-click context menu and resizable separators
|
||
for i, col in enumerate(current_visible_cols):
|
||
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
|
||
header_label.grid(row=0, column=i*2, padx=5, sticky=tk.W)
|
||
# Bind right-click to each label as well
|
||
header_label.bind("<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':
|
||
# Use shared tag buttons frame for list view
|
||
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i)
|
||
continue
|
||
|
||
ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W)
|
||
|
||
current_row += 1
|
||
|
||
def show_icon_view():
|
||
clear_content()
|
||
|
||
# Get visible columns
|
||
visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]]
|
||
col_count = len(visible_cols)
|
||
|
||
if col_count == 0:
|
||
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.",
|
||
font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
|
||
return
|
||
|
||
# Configure column weights for visible columns
|
||
for i, col in enumerate(visible_cols):
|
||
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
||
|
||
# Create header row
|
||
header_frame = ttk.Frame(content_inner)
|
||
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
||
|
||
for i, col in enumerate(visible_cols):
|
||
header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
||
|
||
# Create header labels with right-click context menu
|
||
for i, col in enumerate(visible_cols):
|
||
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
|
||
header_label.grid(row=0, column=i, padx=5, sticky=tk.W)
|
||
# Bind right-click to each label as well
|
||
header_label.bind("<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':
|
||
# Use shared tag buttons frame for icon view
|
||
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
|
||
col_idx += 1
|
||
continue
|
||
|
||
ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
||
|
||
col_idx += 1
|
||
|
||
current_row += 1
|
||
|
||
def show_compact_view():
|
||
clear_content()
|
||
|
||
# Get visible columns
|
||
visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]]
|
||
col_count = len(visible_cols)
|
||
|
||
if col_count == 0:
|
||
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.",
|
||
font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
|
||
return
|
||
|
||
# Configure column weights for visible columns
|
||
for i, col in enumerate(visible_cols):
|
||
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
||
|
||
# Create header
|
||
header_frame = ttk.Frame(content_inner)
|
||
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
||
|
||
for i, col in enumerate(visible_cols):
|
||
header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
||
|
||
# Create header labels with right-click context menu
|
||
for i, col in enumerate(visible_cols):
|
||
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
|
||
header_label.grid(row=0, column=i, padx=5, sticky=tk.W)
|
||
# Bind right-click to each label as well
|
||
header_label.bind("<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':
|
||
# Use shared tag buttons frame for compact view
|
||
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
|
||
col_idx += 1
|
||
continue
|
||
|
||
ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
||
col_idx += 1
|
||
|
||
current_row += 1
|
||
|
||
def switch_view_mode(mode):
|
||
if mode == "list":
|
||
show_list_view()
|
||
elif mode == "icons":
|
||
show_icon_view()
|
||
elif mode == "compact":
|
||
show_compact_view()
|
||
|
||
# No need for canvas resize handler since icon view is now single column
|
||
|
||
# Load initial data and show default view
|
||
load_existing_tags()
|
||
load_photos()
|
||
show_list_view()
|
||
|
||
# Show window
|
||
root.deiconify()
|
||
root.mainloop()
|
||
|
||
return 0
|
||
|
||
def modifyidentified(self) -> int:
|
||
"""Modify identified faces interface - empty window with Quit button for now"""
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox
|
||
from PIL import Image, ImageTk
|
||
import os
|
||
|
||
# Simple tooltip implementation
|
||
class ToolTip:
|
||
def __init__(self, widget, text):
|
||
self.widget = widget
|
||
self.text = text
|
||
self.tooltip_window = None
|
||
self.widget.bind("<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())
|