chore: Remove archived GUI files and demo resources
This commit deletes obsolete GUI files from the archive, including various panel implementations and the main dashboard GUI, as the project has migrated to a web-based interface. Additionally, demo photos and related instructions have been removed to streamline the repository and eliminate outdated resources. This cleanup enhances project maintainability and clarity.
@ -1,840 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-match face identification GUI implementation for PunimTag
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from PIL import Image, ImageTk
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from config import DEFAULT_FACE_TOLERANCE
|
||||
from database import DatabaseManager
|
||||
from face_processing import FaceProcessor
|
||||
from gui_core import GUICore
|
||||
|
||||
|
||||
class AutoMatchGUI:
|
||||
"""Handles the auto-match face identification GUI interface"""
|
||||
|
||||
def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0):
|
||||
"""Initialize the auto-match GUI"""
|
||||
self.db = db_manager
|
||||
self.face_processor = face_processor
|
||||
self.verbose = verbose
|
||||
self.gui_core = GUICore()
|
||||
|
||||
def auto_identify_matches(self, tolerance: float = DEFAULT_FACE_TOLERANCE, confirm: bool = True,
|
||||
show_faces: bool = False, include_same_photo: bool = False) -> int:
|
||||
"""Automatically identify faces that match already identified faces using GUI"""
|
||||
# Get all identified faces (one per person) to use as reference faces
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3
|
||||
ORDER BY f.person_id, f.quality_score DESC
|
||||
''')
|
||||
identified_faces = cursor.fetchall()
|
||||
|
||||
if not identified_faces:
|
||||
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.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
first_name, last_name = result
|
||||
if last_name and first_name:
|
||||
person_name = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
person_name = last_name
|
||||
elif first_name:
|
||||
person_name = first_name
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
person_faces_list.append((person_id, face, person_name))
|
||||
|
||||
# Sort by person name for consistent, user-friendly ordering
|
||||
person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2)
|
||||
|
||||
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.face_processor._get_filtered_similar_faces(reference_face_id, tolerance, include_same_photo, face_status=None)
|
||||
|
||||
# Convert to auto-match format
|
||||
person_matches = []
|
||||
for similar_face in similar_faces:
|
||||
# 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 = self._prefetch_auto_match_data(matches_by_matched)
|
||||
|
||||
print(f"✅ Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths")
|
||||
|
||||
identified_count = 0
|
||||
|
||||
# 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.face_processor.cleanup_face_crops()
|
||||
self.db.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.gui_core.setup_window_size_saving(root, "gui_config.json")
|
||||
# Override with larger size for auto-match window
|
||||
root.geometry("1000x700")
|
||||
|
||||
# Create main frame
|
||||
main_frame = ttk.Frame(root, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
# Configure grid weights
|
||||
root.columnconfigure(0, weight=1)
|
||||
root.rowconfigure(0, weight=1)
|
||||
main_frame.columnconfigure(0, weight=1)
|
||||
main_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# Left side - identified person
|
||||
left_frame = ttk.LabelFrame(main_frame, text="Identified person", padding="10")
|
||||
left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
|
||||
|
||||
# Right side - unidentified faces that match this person
|
||||
right_frame = ttk.LabelFrame(main_frame, text="Unidentified Faces to Match", padding="10")
|
||||
right_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
|
||||
|
||||
# Configure row weights
|
||||
main_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Check if there's only one person - if so, disable search functionality
|
||||
# Use matched_ids instead of person_faces_list since we only show people with potential matches
|
||||
matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]]
|
||||
has_only_one_person = len(matched_ids) == 1
|
||||
print(f"DEBUG: person_faces_list length: {len(person_faces_list)}, matched_ids length: {len(matched_ids)}, has_only_one_person: {has_only_one_person}")
|
||||
|
||||
# Search controls for filtering people by last name
|
||||
last_name_search_var = tk.StringVar()
|
||||
# Search field with label underneath (like modifyidentified edit section)
|
||||
search_frame = ttk.Frame(left_frame)
|
||||
search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
|
||||
# Search input on the left
|
||||
search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20)
|
||||
search_entry.grid(row=0, column=0, sticky=tk.W)
|
||||
|
||||
# Buttons on the right of the search input
|
||||
buttons_row = ttk.Frame(search_frame)
|
||||
buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0))
|
||||
|
||||
search_btn = ttk.Button(buttons_row, text="Search", width=8)
|
||||
search_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
clear_btn = ttk.Button(buttons_row, text="Clear", width=6)
|
||||
clear_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Helper label directly under the search input
|
||||
if has_only_one_person:
|
||||
print("DEBUG: Disabling search functionality - only one person found")
|
||||
# Disable search functionality if there's only one person
|
||||
search_entry.config(state='disabled')
|
||||
search_btn.config(state='disabled')
|
||||
clear_btn.config(state='disabled')
|
||||
# Add a label to explain why search is disabled
|
||||
disabled_label = ttk.Label(search_frame, text="(Search disabled - only one person found)",
|
||||
font=("Arial", 8), foreground="gray")
|
||||
disabled_label.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(2, 0))
|
||||
else:
|
||||
print("DEBUG: Search functionality enabled - multiple people found")
|
||||
# Normal helper label when search is enabled
|
||||
last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray")
|
||||
last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0))
|
||||
|
||||
# Matched person info
|
||||
matched_info_label = ttk.Label(left_frame, text="", font=("Arial", 10, "bold"))
|
||||
matched_info_label.grid(row=2, column=0, pady=(0, 10), sticky=tk.W)
|
||||
|
||||
# Matched person image
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
matched_canvas = tk.Canvas(left_frame, width=300, height=300, bg=canvas_bg_color, highlightthickness=0)
|
||||
matched_canvas.grid(row=3, column=0, pady=(0, 10))
|
||||
|
||||
# Save button for this person (will be created after function definitions)
|
||||
save_btn = None
|
||||
|
||||
# Matches scrollable frame
|
||||
matches_frame = ttk.Frame(right_frame)
|
||||
matches_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
# Control buttons for matches (Select All / Clear All)
|
||||
matches_controls_frame = ttk.Frame(matches_frame)
|
||||
matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0))
|
||||
|
||||
def select_all_matches():
|
||||
"""Select all match checkboxes"""
|
||||
for var in match_vars:
|
||||
var.set(True)
|
||||
|
||||
def clear_all_matches():
|
||||
"""Clear all match checkboxes"""
|
||||
for var in match_vars:
|
||||
var.set(False)
|
||||
|
||||
select_all_matches_btn = ttk.Button(matches_controls_frame, text="☑️ Select All", command=select_all_matches)
|
||||
select_all_matches_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
|
||||
clear_all_matches_btn = ttk.Button(matches_controls_frame, text="☐ Clear All", command=clear_all_matches)
|
||||
clear_all_matches_btn.pack(side=tk.LEFT)
|
||||
|
||||
def update_match_control_buttons_state():
|
||||
"""Enable/disable Select All / Clear All based on matches presence"""
|
||||
if match_vars:
|
||||
select_all_matches_btn.config(state='normal')
|
||||
clear_all_matches_btn.config(state='normal')
|
||||
else:
|
||||
select_all_matches_btn.config(state='disabled')
|
||||
clear_all_matches_btn.config(state='disabled')
|
||||
|
||||
# Create scrollbar for matches
|
||||
scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=None)
|
||||
scrollbar.grid(row=0, column=1, rowspan=1, sticky=(tk.N, tk.S))
|
||||
|
||||
# Create canvas for matches with scrollbar
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
matches_canvas = tk.Canvas(matches_frame, yscrollcommand=scrollbar.set, bg=canvas_bg_color, highlightthickness=0)
|
||||
matches_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
scrollbar.config(command=matches_canvas.yview)
|
||||
|
||||
# Configure grid weights
|
||||
right_frame.columnconfigure(0, weight=1)
|
||||
right_frame.rowconfigure(0, weight=1)
|
||||
matches_frame.columnconfigure(0, weight=1)
|
||||
matches_frame.rowconfigure(0, weight=0) # Controls row takes minimal space
|
||||
matches_frame.rowconfigure(1, weight=1) # Canvas row expandable
|
||||
|
||||
# Control buttons (navigation only)
|
||||
control_frame = ttk.Frame(main_frame)
|
||||
control_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0))
|
||||
|
||||
# Button commands
|
||||
current_matched_index = 0
|
||||
matched_ids = [person_id for person_id, _, _ in person_faces_list if person_id in matches_by_matched and matches_by_matched[person_id]]
|
||||
filtered_matched_ids = None # filtered subset based on last name search
|
||||
|
||||
match_checkboxes = []
|
||||
match_vars = []
|
||||
identified_faces_per_person = {} # Track which faces were identified for each person
|
||||
checkbox_states_per_person = {} # Track checkbox states for each person (unsaved selections)
|
||||
original_checkbox_states_per_person = {} # Track original/default checkbox states for comparison
|
||||
|
||||
def on_confirm_matches():
|
||||
nonlocal identified_count, current_matched_index, identified_faces_per_person
|
||||
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]
|
||||
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.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Process all matches (both checked and unchecked)
|
||||
for i, (match, var) in enumerate(zip(matches_for_this_person, 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.face_processor.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():
|
||||
result = messagebox.askyesnocancel(
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes that will be lost if you quit.\n\n"
|
||||
"Yes: Save current changes and quit\n"
|
||||
"No: Quit without saving\n"
|
||||
"Cancel: Return to auto-match"
|
||||
)
|
||||
if result is None:
|
||||
# Cancel
|
||||
return
|
||||
if result:
|
||||
# Save current person's changes, then quit
|
||||
save_current_checkbox_states()
|
||||
on_confirm_matches()
|
||||
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.
|
||||
"""
|
||||
active_ids = filtered_matched_ids if filtered_matched_ids is not None else matched_ids
|
||||
if current_matched_index < len(active_ids) and match_vars:
|
||||
current_matched_id = active_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.face_processor._extract_face_crop(
|
||||
matched_photo_path,
|
||||
first_match['matched_location'],
|
||||
f"matched_{first_match['person_id']}"
|
||||
)
|
||||
|
||||
if matched_crop_path and os.path.exists(matched_crop_path):
|
||||
try:
|
||||
pil_image = Image.open(matched_crop_path)
|
||||
pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(pil_image)
|
||||
matched_canvas.create_image(150, 150, image=photo)
|
||||
matched_canvas.image = photo
|
||||
|
||||
# Add photo icon to the matched person face - exactly in corner
|
||||
# Compute top-left of the centered image for accurate placement
|
||||
actual_width, actual_height = pil_image.size
|
||||
top_left_x = 150 - (actual_width // 2)
|
||||
top_left_y = 150 - (actual_height // 2)
|
||||
self.gui_core.create_photo_icon(matched_canvas, matched_photo_path, icon_size=20,
|
||||
face_x=top_left_x, face_y=top_left_y,
|
||||
face_width=actual_width, face_height=actual_height,
|
||||
canvas_width=300, canvas_height=300)
|
||||
except Exception as e:
|
||||
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.face_processor._get_confidence_description(confidence_pct)
|
||||
|
||||
# Create match frame
|
||||
match_frame = ttk.Frame(matches_inner_frame)
|
||||
match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Checkbox for this match
|
||||
match_var = tk.BooleanVar()
|
||||
|
||||
# Restore previous checkbox state if available
|
||||
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
||||
if matched_id in 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 (avoid late-binding by capturing ids)
|
||||
current_person_id = matched_id
|
||||
current_face_id = match['unidentified_id']
|
||||
match_var.trace('w', lambda *args, var=match_var, person_id=current_person_id, face_id=current_face_id: on_checkbox_change(var, person_id, face_id))
|
||||
|
||||
# Configure match frame for grid layout
|
||||
match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width
|
||||
match_frame.columnconfigure(1, weight=0) # Image column - fixed width
|
||||
match_frame.columnconfigure(2, weight=1) # Text column - expandable
|
||||
|
||||
# Checkbox 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)
|
||||
|
||||
# Unidentified face image now immediately after checkbox (right panel face to the right of checkbox)
|
||||
match_canvas = None
|
||||
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=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10))
|
||||
|
||||
unidentified_crop_path = self.face_processor._extract_face_crop(
|
||||
unidentified_photo_path,
|
||||
match['unidentified_location'],
|
||||
f"unid_{match['unidentified_id']}"
|
||||
)
|
||||
|
||||
if unidentified_crop_path and os.path.exists(unidentified_crop_path):
|
||||
try:
|
||||
pil_image = Image.open(unidentified_crop_path)
|
||||
pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(pil_image)
|
||||
match_canvas.create_image(50, 50, image=photo)
|
||||
match_canvas.image = photo
|
||||
|
||||
# Add photo icon exactly at top-right of the face area
|
||||
self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15,
|
||||
face_x=0, face_y=0,
|
||||
face_width=100, face_height=100,
|
||||
canvas_width=100, canvas_height=100)
|
||||
except Exception:
|
||||
match_canvas.create_text(50, 50, text="❌", fill="red")
|
||||
else:
|
||||
match_canvas.create_text(50, 50, text="🖼️", fill="gray")
|
||||
|
||||
# Confidence badge and filename to the right of image
|
||||
info_container = ttk.Frame(match_frame)
|
||||
info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E))
|
||||
|
||||
badge = self.gui_core.create_confidence_badge(info_container, confidence_pct)
|
||||
badge.pack(anchor=tk.W)
|
||||
|
||||
filename_label = ttk.Label(info_container, text=f"📁 {match['unidentified_filename']}", font=("Arial", 8), foreground="gray")
|
||||
filename_label.pack(anchor=tk.W, pady=(2, 0))
|
||||
|
||||
# Update Select All / Clear All button states 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 _prefetch_auto_match_data(self, matches_by_matched: Dict) -> Dict:
|
||||
"""Pre-fetch all needed data to avoid repeated database queries"""
|
||||
data_cache = {}
|
||||
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pre-fetch all person names and details
|
||||
person_ids = list(matches_by_matched.keys())
|
||||
if person_ids:
|
||||
placeholders = ','.join('?' * len(person_ids))
|
||||
cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids)
|
||||
data_cache['person_details'] = {}
|
||||
for row in cursor.fetchall():
|
||||
person_id = row[0]
|
||||
first_name = row[1] or ''
|
||||
last_name = row[2] or ''
|
||||
middle_name = row[3] or ''
|
||||
maiden_name = row[4] or ''
|
||||
date_of_birth = row[5] or ''
|
||||
|
||||
# Create full name display
|
||||
name_parts = []
|
||||
if first_name:
|
||||
name_parts.append(first_name)
|
||||
if middle_name:
|
||||
name_parts.append(middle_name)
|
||||
if last_name:
|
||||
name_parts.append(last_name)
|
||||
if maiden_name:
|
||||
name_parts.append(f"({maiden_name})")
|
||||
|
||||
full_name = ' '.join(name_parts)
|
||||
data_cache['person_details'][person_id] = {
|
||||
'full_name': full_name,
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'middle_name': middle_name,
|
||||
'maiden_name': maiden_name,
|
||||
'date_of_birth': date_of_birth
|
||||
}
|
||||
|
||||
# Pre-fetch all photo paths (both matched and unidentified)
|
||||
all_photo_ids = set()
|
||||
for person_matches in matches_by_matched.values():
|
||||
for match in person_matches:
|
||||
all_photo_ids.add(match['matched_photo_id'])
|
||||
all_photo_ids.add(match['unidentified_photo_id'])
|
||||
|
||||
if all_photo_ids:
|
||||
photo_ids_list = list(all_photo_ids)
|
||||
placeholders = ','.join('?' * len(photo_ids_list))
|
||||
cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list)
|
||||
data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
return data_cache
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
# Desktop Version Archive
|
||||
|
||||
This directory contains all files related to the desktop GUI version of PunimTag, which has been archived as the project has migrated to a web-based interface.
|
||||
|
||||
## Archived Files
|
||||
|
||||
### GUI Components (gui/)
|
||||
- dashboard_gui.py - Main unified dashboard GUI
|
||||
- gui_core.py - Core GUI utilities
|
||||
- identify_panel.py - Face identification panel
|
||||
- modify_panel.py - Modify identified faces panel
|
||||
- auto_match_panel.py - Auto-matching panel
|
||||
- tag_manager_panel.py - Tag management panel
|
||||
|
||||
### Entry Points
|
||||
- run_dashboard.py - Desktop dashboard launcher
|
||||
- run_deepface_gui.sh - DeepFace GUI test script
|
||||
- photo_tagger.py - CLI entry point for desktop version
|
||||
|
||||
### Test Files (tests/)
|
||||
- test_deepface_gui.py - DeepFace GUI test application
|
||||
- test_simple_gui.py - Simple GUI test
|
||||
- test_thumbnail_sizes.py - Thumbnail size test
|
||||
- show_large_thumbnails.py - Large thumbnails demo
|
||||
|
||||
### Documentation
|
||||
- README_DESKTOP.md - Desktop version documentation
|
||||
|
||||
## Migration Date
|
||||
November 2025
|
||||
|
||||
## Status
|
||||
These files are archived and no longer maintained. The project now uses a web-based interface accessible via the API and frontend.
|
||||
@ -1,304 +0,0 @@
|
||||
# PunimTag
|
||||
|
||||
**Photo Management and Facial Recognition System**
|
||||
|
||||
A powerful desktop application for organizing and tagging photos using **state-of-the-art DeepFace AI** with ArcFace recognition model.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
- **🔥 DeepFace AI**: State-of-the-art face detection with RetinaFace and ArcFace models
|
||||
- **🎯 Superior Accuracy**: 512-dimensional embeddings (4x more detailed than face_recognition)
|
||||
- **⚙️ Multiple Detectors**: Choose from RetinaFace, MTCNN, OpenCV, or SSD detectors
|
||||
- **🎨 Flexible Models**: Select ArcFace, Facenet, Facenet512, or VGG-Face recognition models
|
||||
- **📊 Rich Metadata**: Face confidence scores, quality metrics, detector/model info displayed in GUI
|
||||
- **👤 Person Identification**: Identify and tag people across your photo collection
|
||||
- **🤖 Smart Auto-Matching**: Intelligent face matching with quality scoring and cosine similarity
|
||||
- **🔍 Advanced Search**: Search by people, dates, tags, and folders
|
||||
- **🎚️ Quality Filtering**: Filter faces by quality score in Identify panel (0-100%)
|
||||
- **🏷️ Tag Management**: Organize photos with hierarchical tags
|
||||
- **⚡ Batch Processing**: Process thousands of photos efficiently
|
||||
- **🔒 Privacy-First**: All data stored locally, no cloud dependencies
|
||||
- **✅ Production Ready**: Complete migration with 20/20 tests passing
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.12 or higher
|
||||
- pip package manager
|
||||
- Virtual environment (recommended)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd punimtag
|
||||
|
||||
# Create and activate virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
|
||||
#### GUI Dashboard (Recommended)
|
||||
```bash
|
||||
python run_dashboard.py
|
||||
```
|
||||
|
||||
Or:
|
||||
```bash
|
||||
python src/gui/dashboard_gui.py
|
||||
```
|
||||
|
||||
#### CLI Interface
|
||||
```bash
|
||||
python src/photo_tagger.py --help
|
||||
```
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
If you have an existing database from before the DeepFace migration, you need to migrate:
|
||||
|
||||
```bash
|
||||
# IMPORTANT: This will delete all existing data!
|
||||
python scripts/migrate_to_deepface.py
|
||||
```
|
||||
|
||||
Then re-add your photos and process them with DeepFace.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **[Architecture](docs/ARCHITECTURE.md)**: System design and technical details
|
||||
- **[Demo Guide](docs/DEMO.md)**: Step-by-step tutorial
|
||||
- **[Dashboard Guide](docs/README_UNIFIED_DASHBOARD.md)**: GUI reference
|
||||
- **[Contributing](CONTRIBUTING.md)**: How to contribute
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
punimtag/
|
||||
├── src/ # Source code
|
||||
│ ├── core/ # Business logic
|
||||
│ ├── gui/ # GUI components
|
||||
│ └── utils/ # Utilities
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
├── .notes/ # Project notes
|
||||
└── data/ # Application data
|
||||
```
|
||||
|
||||
See [Directory Structure](.notes/directory_structure.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Usage
|
||||
|
||||
### 1. Import Photos
|
||||
```bash
|
||||
# Add photos from a folder
|
||||
python src/photo_tagger.py scan /path/to/photos
|
||||
```
|
||||
|
||||
### 2. Process Faces
|
||||
Open the dashboard and click "Process Photos" to detect faces.
|
||||
|
||||
### 3. Identify People
|
||||
Use the "Identify" panel to tag faces with names:
|
||||
- **Quality Filter**: Adjust the quality slider (0-100%) to filter out low-quality faces
|
||||
- **Unique Faces**: Enable to hide duplicate faces using cosine similarity
|
||||
- **Date Filters**: Filter faces by date range
|
||||
- **Navigation**: Browse through unidentified faces with prev/next buttons
|
||||
- **Photo Viewer**: Click the photo icon to view the full source image
|
||||
|
||||
### 4. Search
|
||||
Use the "Search" panel to find photos by people, dates, or tags.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### GUI Configuration (Recommended)
|
||||
Use the dashboard to configure DeepFace settings:
|
||||
1. Open the dashboard: `python run_dashboard.py`
|
||||
2. Click "🔍 Process"
|
||||
3. Select your preferred:
|
||||
- **Face Detector**: RetinaFace (best), MTCNN, OpenCV, or SSD
|
||||
- **Recognition Model**: ArcFace (best), Facenet, Facenet512, or VGG-Face
|
||||
|
||||
### Manual Configuration
|
||||
Edit `src/core/config.py` to customize:
|
||||
- `DEEPFACE_DETECTOR_BACKEND` - Face detection model (default: `retinaface`)
|
||||
- `DEEPFACE_MODEL_NAME` - Recognition model (default: `ArcFace`)
|
||||
- `DEFAULT_FACE_TOLERANCE` - Similarity tolerance (default: `0.6` for DeepFace)
|
||||
- `DEEPFACE_SIMILARITY_THRESHOLD` - Minimum similarity percentage (default: `60`)
|
||||
- `MIN_FACE_QUALITY` - Minimum face quality score (default: `0.3`)
|
||||
- Batch sizes and other processing thresholds
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all migration tests (20 tests total)
|
||||
python tests/test_phase1_schema.py # Phase 1: Database schema (5 tests)
|
||||
python tests/test_phase2_config.py # Phase 2: Configuration (5 tests)
|
||||
python tests/test_phase3_deepface.py # Phase 3: Core processing (5 tests)
|
||||
python tests/test_phase4_gui.py # Phase 4: GUI integration (5 tests)
|
||||
python tests/test_deepface_integration.py # Phase 6: Integration tests (5 tests)
|
||||
|
||||
# Run DeepFace GUI test (working example)
|
||||
python tests/test_deepface_gui.py
|
||||
|
||||
# All tests should pass ✅ (20/20 passing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### Current (v1.1 - DeepFace Edition) ✅
|
||||
- ✅ Complete DeepFace migration (all 6 phases)
|
||||
- ✅ Unified dashboard interface
|
||||
- ✅ ArcFace recognition model (512-dim embeddings)
|
||||
- ✅ RetinaFace detection (state-of-the-art)
|
||||
- ✅ Multiple detector/model options (GUI selectable)
|
||||
- ✅ Cosine similarity matching
|
||||
- ✅ Face confidence scores and quality metrics
|
||||
- ✅ Quality filtering in Identify panel (adjustable 0-100%)
|
||||
- ✅ Unique faces detection (cosine similarity-based deduplication)
|
||||
- ✅ Enhanced thumbnail display (100x100px)
|
||||
- ✅ External system photo viewer integration
|
||||
- ✅ Improved auto-match save responsiveness
|
||||
- ✅ Metadata display (detector/model info in GUI)
|
||||
- ✅ Enhanced accuracy and reliability
|
||||
- ✅ Comprehensive test coverage (20/20 tests passing)
|
||||
|
||||
### Next (v1.2)
|
||||
- 📋 GPU acceleration for faster processing
|
||||
- 📋 Performance optimization
|
||||
- 📋 Enhanced GUI features
|
||||
- 📋 Batch processing improvements
|
||||
|
||||
### Future (v2.0+)
|
||||
- Web interface
|
||||
- Cloud storage integration
|
||||
- Mobile app
|
||||
- Video face detection
|
||||
- Face clustering (unsupervised)
|
||||
- Age estimation
|
||||
- Emotion detection
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
### Quick Contribution Guide
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests
|
||||
5. Submit a pull request
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current Status
|
||||
|
||||
- **Version**: 1.1 (DeepFace Edition)
|
||||
- **Face Detection**: DeepFace with RetinaFace (state-of-the-art)
|
||||
- **Recognition Model**: ArcFace (512-dimensional embeddings)
|
||||
- **Database**: SQLite with DeepFace schema and metadata columns
|
||||
- **GUI**: Tkinter with model selection and metadata display
|
||||
- **Platform**: Cross-platform (Linux, Windows, macOS)
|
||||
- **Migration Status**: ✅ Complete (all 6 phases done, 20/20 tests passing)
|
||||
- **Test Coverage**: 100% (20 tests across 6 phases)
|
||||
- **Production Ready**: Yes ✅
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Limitations
|
||||
|
||||
- Processing ~2-3x slower than old face_recognition (but much more accurate!)
|
||||
- Large databases (>50K photos) may experience slowdown
|
||||
- No GPU acceleration yet (CPU-only processing)
|
||||
- First run downloads models (~100MB+)
|
||||
- Existing databases require migration (data will be lost)
|
||||
|
||||
See [Task List](.notes/task_list.md) for all tracked issues.
|
||||
|
||||
## 📦 Model Downloads
|
||||
|
||||
On first run, DeepFace will download required models:
|
||||
- ArcFace model (~100MB)
|
||||
- RetinaFace detector (~1.5MB)
|
||||
- Models stored in `~/.deepface/weights/`
|
||||
- Requires internet connection for first run only
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
[Add your license here]
|
||||
|
||||
---
|
||||
|
||||
## 👥 Authors
|
||||
|
||||
PunimTag Development Team
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **DeepFace** library by Sefik Ilkin Serengil - Modern face recognition framework
|
||||
- **ArcFace** - Additive Angular Margin Loss for Deep Face Recognition
|
||||
- **RetinaFace** - State-of-the-art face detection
|
||||
- TensorFlow, OpenCV, NumPy, and Pillow teams
|
||||
- All contributors and users
|
||||
|
||||
## 📚 Technical Details
|
||||
|
||||
### Face Recognition Technology
|
||||
- **Detection**: RetinaFace (default), MTCNN, OpenCV, or SSD
|
||||
- **Model**: ArcFace (512-dim), Facenet (128-dim), Facenet512 (512-dim), or VGG-Face (2622-dim)
|
||||
- **Similarity**: Cosine similarity (industry standard for deep learning embeddings)
|
||||
- **Accuracy**: Significantly improved over previous face_recognition library
|
||||
|
||||
### Migration Documentation
|
||||
- [Phase 1: Database Schema](PHASE1_COMPLETE.md) - Database updates with DeepFace columns
|
||||
- [Phase 2: Configuration](PHASE2_COMPLETE.md) - Configuration settings for DeepFace
|
||||
- [Phase 3: Core Processing](PHASE3_COMPLETE.md) - Face processing with DeepFace
|
||||
- [Phase 4: GUI Integration](PHASE4_COMPLETE.md) - GUI updates and metadata display
|
||||
- [Phase 5 & 6: Dependencies and Testing](PHASE5_AND_6_COMPLETE.md) - Final validation
|
||||
- [Complete Migration Summary](DEEPFACE_MIGRATION_COMPLETE_SUMMARY.md) - Full overview
|
||||
- [Original Migration Plan](.notes/deepface_migration_plan.md) - Detailed plan
|
||||
|
||||
---
|
||||
|
||||
## 📧 Contact
|
||||
|
||||
[Add contact information]
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
If you find this project useful, please consider giving it a star!
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for photo enthusiasts**
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
"""
|
||||
GUI components and panels for PunimTag
|
||||
"""
|
||||
|
||||
from .gui_core import GUICore
|
||||
from .dashboard_gui import DashboardGUI
|
||||
from .identify_panel import IdentifyPanel
|
||||
from .auto_match_panel import AutoMatchPanel
|
||||
from .modify_panel import ModifyPanel
|
||||
from .tag_manager_panel import TagManagerPanel
|
||||
|
||||
__all__ = [
|
||||
'GUICore',
|
||||
'DashboardGUI',
|
||||
'IdentifyPanel',
|
||||
'AutoMatchPanel',
|
||||
'ModifyPanel',
|
||||
'TagManagerPanel',
|
||||
]
|
||||
|
||||
@ -1,893 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-Match Panel for PunimTag Dashboard
|
||||
Embeds the full auto-match GUI functionality into the dashboard frame
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from PIL import Image, ImageTk
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from src.core.config import DEFAULT_FACE_TOLERANCE
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.gui.gui_core import GUICore
|
||||
|
||||
|
||||
class AutoMatchPanel:
|
||||
"""Integrated auto-match panel that embeds the full auto-match GUI functionality into the dashboard"""
|
||||
|
||||
def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager,
|
||||
face_processor: FaceProcessor, gui_core: GUICore, on_navigate_home=None, verbose: int = 0):
|
||||
"""Initialize the auto-match panel"""
|
||||
self.parent_frame = parent_frame
|
||||
self.db = db_manager
|
||||
self.face_processor = face_processor
|
||||
self.gui_core = gui_core
|
||||
self.on_navigate_home = on_navigate_home
|
||||
self.verbose = verbose
|
||||
|
||||
# Panel state
|
||||
self.is_active = False
|
||||
self.matches_by_matched = {}
|
||||
self.data_cache = {}
|
||||
self.current_matched_index = 0
|
||||
self.matched_ids = []
|
||||
self.filtered_matched_ids = None
|
||||
self.identified_faces_per_person = {}
|
||||
self.checkbox_states_per_person = {}
|
||||
self.original_checkbox_states_per_person = {}
|
||||
self.identified_count = 0
|
||||
|
||||
# GUI components
|
||||
self.components = {}
|
||||
self.main_frame = None
|
||||
|
||||
def create_panel(self) -> ttk.Frame:
|
||||
"""Create the auto-match panel with all GUI components"""
|
||||
self.main_frame = ttk.Frame(self.parent_frame)
|
||||
|
||||
# Configure grid weights for full screen responsiveness
|
||||
self.main_frame.columnconfigure(0, weight=1) # Left panel
|
||||
self.main_frame.columnconfigure(1, weight=1) # Right panel
|
||||
self.main_frame.rowconfigure(0, weight=0) # Configuration row - fixed height
|
||||
self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable
|
||||
self.main_frame.rowconfigure(2, weight=0) # Control buttons row - fixed height
|
||||
|
||||
# Create all GUI components
|
||||
self._create_gui_components()
|
||||
|
||||
# Create main content panels
|
||||
self._create_main_panels()
|
||||
|
||||
return self.main_frame
|
||||
|
||||
def _create_gui_components(self):
|
||||
"""Create all GUI components for the auto-match interface"""
|
||||
# Configuration frame
|
||||
config_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="10")
|
||||
config_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
# Don't give weight to any column to prevent stretching
|
||||
|
||||
# Start button (moved to the left)
|
||||
start_btn = ttk.Button(config_frame, text="🚀 Run Auto-Match", command=self._start_auto_match)
|
||||
start_btn.grid(row=0, column=0, padx=(0, 20))
|
||||
|
||||
# Tolerance setting
|
||||
ttk.Label(config_frame, text="Tolerance:").grid(row=0, column=1, sticky=tk.W, padx=(0, 2))
|
||||
self.components['tolerance_var'] = tk.StringVar(value=str(DEFAULT_FACE_TOLERANCE))
|
||||
tolerance_entry = ttk.Entry(config_frame, textvariable=self.components['tolerance_var'], width=8)
|
||||
tolerance_entry.grid(row=0, column=2, sticky=tk.W, padx=(0, 10))
|
||||
ttk.Label(config_frame, text="(lower = stricter matching)").grid(row=0, column=3, sticky=tk.W)
|
||||
|
||||
def _create_main_panels(self):
|
||||
"""Create the main left and right panels"""
|
||||
# Left panel for identified person
|
||||
self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Identified Person", padding="10")
|
||||
self.components['left_panel'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
|
||||
|
||||
# Right panel for unidentified faces
|
||||
self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Unidentified Faces to Match", padding="10")
|
||||
self.components['right_panel'].grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
|
||||
|
||||
# Create left panel content
|
||||
self._create_left_panel_content()
|
||||
|
||||
# Create right panel content
|
||||
self._create_right_panel_content()
|
||||
|
||||
# Create control buttons
|
||||
self._create_control_buttons()
|
||||
|
||||
def _create_left_panel_content(self):
|
||||
"""Create the left panel content for identified person"""
|
||||
left_panel = self.components['left_panel']
|
||||
|
||||
# Search controls for filtering people by last name
|
||||
search_frame = ttk.Frame(left_panel)
|
||||
search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
search_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Search input
|
||||
self.components['search_var'] = tk.StringVar()
|
||||
self.components['search_entry'] = ttk.Entry(search_frame, textvariable=self.components['search_var'], width=20, state='disabled')
|
||||
self.components['search_entry'].grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Search buttons
|
||||
self.components['search_btn'] = ttk.Button(search_frame, text="Search", width=8, command=self._apply_search_filter, state='disabled')
|
||||
self.components['search_btn'].grid(row=0, column=1, padx=(0, 5))
|
||||
|
||||
self.components['clear_btn'] = ttk.Button(search_frame, text="Clear", width=6, command=self._clear_search_filter, state='disabled')
|
||||
self.components['clear_btn'].grid(row=0, column=2)
|
||||
|
||||
# Search help label
|
||||
self.components['search_help_label'] = ttk.Label(search_frame, text="Search disabled - click 'Start Auto-Match' first",
|
||||
font=("Arial", 8), foreground="gray")
|
||||
self.components['search_help_label'].grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=(2, 0))
|
||||
|
||||
# Person info label
|
||||
self.components['person_info_label'] = ttk.Label(left_panel, text="", font=("Arial", 10, "bold"))
|
||||
self.components['person_info_label'].grid(row=1, column=0, pady=(0, 10), sticky=tk.W)
|
||||
|
||||
# Person image canvas
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
self.components['person_canvas'] = tk.Canvas(left_panel, width=300, height=300,
|
||||
bg=canvas_bg_color, highlightthickness=0)
|
||||
self.components['person_canvas'].grid(row=2, column=0, pady=(0, 10))
|
||||
|
||||
# Save button
|
||||
self.components['save_btn'] = ttk.Button(left_panel, text="💾 Save Changes",
|
||||
command=self._save_changes, state='disabled')
|
||||
self.components['save_btn'].grid(row=3, column=0, pady=(0, 10), sticky=(tk.W, tk.E))
|
||||
|
||||
def _create_right_panel_content(self):
|
||||
"""Create the right panel content for unidentified faces"""
|
||||
right_panel = self.components['right_panel']
|
||||
|
||||
# Control buttons for matches (Select All / Clear All)
|
||||
matches_controls_frame = ttk.Frame(right_panel)
|
||||
matches_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
|
||||
self.components['select_all_btn'] = ttk.Button(matches_controls_frame, text="☑️ Select All",
|
||||
command=self._select_all_matches, state='disabled')
|
||||
self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5))
|
||||
|
||||
self.components['clear_all_btn'] = ttk.Button(matches_controls_frame, text="☐ Clear All",
|
||||
command=self._clear_all_matches, state='disabled')
|
||||
self.components['clear_all_btn'].pack(side=tk.LEFT)
|
||||
|
||||
# Create scrollable frame for matches
|
||||
matches_frame = ttk.Frame(right_panel)
|
||||
matches_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
matches_frame.columnconfigure(0, weight=1)
|
||||
matches_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Create canvas and scrollbar for matches
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
self.components['matches_canvas'] = tk.Canvas(matches_frame, bg=canvas_bg_color, highlightthickness=0)
|
||||
self.components['matches_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
scrollbar = ttk.Scrollbar(matches_frame, orient="vertical", command=self.components['matches_canvas'].yview)
|
||||
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
self.components['matches_canvas'].configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Configure right panel grid weights
|
||||
right_panel.columnconfigure(0, weight=1)
|
||||
right_panel.rowconfigure(1, weight=1)
|
||||
|
||||
def _create_control_buttons(self):
|
||||
"""Create the control buttons for navigation"""
|
||||
control_frame = ttk.Frame(self.main_frame)
|
||||
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
|
||||
|
||||
self.components['back_btn'] = ttk.Button(control_frame, text="⏮️ Back",
|
||||
command=self._go_back, state='disabled')
|
||||
self.components['back_btn'].grid(row=0, column=0, padx=(0, 5))
|
||||
|
||||
self.components['next_btn'] = ttk.Button(control_frame, text="⏭️ Next",
|
||||
command=self._go_next, state='disabled')
|
||||
self.components['next_btn'].grid(row=0, column=1, padx=5)
|
||||
|
||||
self.components['quit_btn'] = ttk.Button(control_frame, text="❌ Exit Auto-Match",
|
||||
command=self._quit_auto_match)
|
||||
self.components['quit_btn'].grid(row=0, column=2, padx=(5, 0))
|
||||
|
||||
def _start_auto_match(self):
|
||||
"""Start the auto-match process"""
|
||||
try:
|
||||
tolerance = float(self.components['tolerance_var'].get().strip())
|
||||
if tolerance < 0 or tolerance > 1:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
messagebox.showerror("Error", "Please enter a valid tolerance value between 0.0 and 1.0.")
|
||||
return
|
||||
|
||||
include_same_photo = False # Always exclude same photo matching
|
||||
|
||||
# Get all identified faces (one per person) to use as reference faces
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score,
|
||||
f.face_confidence, f.detector_backend, f.model_name
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3
|
||||
ORDER BY f.person_id, f.quality_score DESC
|
||||
''')
|
||||
identified_faces = cursor.fetchall()
|
||||
|
||||
if not identified_faces:
|
||||
messagebox.showinfo("No Identified Faces", "🔍 No identified faces found for auto-matching")
|
||||
return
|
||||
|
||||
# Group by person and get the best quality face per person
|
||||
person_faces = {}
|
||||
for face in identified_faces:
|
||||
person_id = face[1]
|
||||
if person_id not in person_faces:
|
||||
person_faces[person_id] = face
|
||||
|
||||
# Convert to ordered list to ensure consistent ordering
|
||||
person_faces_list = []
|
||||
for person_id, face in person_faces.items():
|
||||
# Get person name for ordering
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
first_name, last_name = result
|
||||
if last_name and first_name:
|
||||
person_name = f"{last_name}, {first_name}"
|
||||
elif last_name:
|
||||
person_name = last_name
|
||||
elif first_name:
|
||||
person_name = first_name
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
else:
|
||||
person_name = "Unknown"
|
||||
person_faces_list.append((person_id, face, person_name))
|
||||
|
||||
# Sort by person name for consistent, user-friendly ordering
|
||||
person_faces_list.sort(key=lambda x: x[2]) # Sort by person name (index 2)
|
||||
|
||||
# Find similar faces for each identified person
|
||||
self.matches_by_matched = {}
|
||||
for person_id, reference_face, person_name in person_faces_list:
|
||||
reference_face_id = reference_face[0]
|
||||
|
||||
# Use the same filtering and sorting logic as identify
|
||||
similar_faces = self.face_processor._get_filtered_similar_faces(
|
||||
reference_face_id, tolerance, include_same_photo, face_status=None)
|
||||
|
||||
# Convert to auto-match format
|
||||
person_matches = []
|
||||
for similar_face in similar_faces:
|
||||
match = {
|
||||
'unidentified_id': similar_face['face_id'],
|
||||
'unidentified_photo_id': similar_face['photo_id'],
|
||||
'unidentified_filename': similar_face['filename'],
|
||||
'unidentified_location': similar_face['location'],
|
||||
'matched_id': reference_face_id,
|
||||
'matched_photo_id': reference_face[2],
|
||||
'matched_filename': reference_face[4],
|
||||
'matched_location': reference_face[3],
|
||||
'person_id': person_id,
|
||||
'distance': similar_face['distance'],
|
||||
'quality_score': similar_face['quality_score'],
|
||||
'adaptive_tolerance': similar_face.get('adaptive_tolerance', tolerance)
|
||||
}
|
||||
person_matches.append(match)
|
||||
|
||||
self.matches_by_matched[person_id] = person_matches
|
||||
|
||||
# Flatten all matches for counting
|
||||
all_matches = []
|
||||
for person_matches in self.matches_by_matched.values():
|
||||
all_matches.extend(person_matches)
|
||||
|
||||
if not all_matches:
|
||||
messagebox.showinfo("No Matches", "🔍 No similar faces found for auto-identification")
|
||||
return
|
||||
|
||||
# Pre-fetch all needed data
|
||||
self.data_cache = self._prefetch_auto_match_data(self.matches_by_matched)
|
||||
|
||||
# Initialize state
|
||||
self.matched_ids = [person_id for person_id, _, _ in person_faces_list
|
||||
if person_id in self.matches_by_matched and self.matches_by_matched[person_id]]
|
||||
self.filtered_matched_ids = None
|
||||
self.current_matched_index = 0
|
||||
self.identified_faces_per_person = {}
|
||||
self.checkbox_states_per_person = {}
|
||||
self.original_checkbox_states_per_person = {}
|
||||
self.identified_count = 0
|
||||
|
||||
# Enable search controls now that auto-match has started
|
||||
self.components['search_entry'].config(state='normal')
|
||||
self.components['search_btn'].config(state='normal')
|
||||
self.components['clear_btn'].config(state='normal')
|
||||
|
||||
# Check if there's only one person - disable search if so
|
||||
has_only_one_person = len(self.matched_ids) == 1
|
||||
if has_only_one_person:
|
||||
self.components['search_var'].set("")
|
||||
self.components['search_entry'].config(state='disabled')
|
||||
self.components['search_btn'].config(state='disabled')
|
||||
self.components['clear_btn'].config(state='disabled')
|
||||
self.components['search_help_label'].config(text="(Search disabled - only one person found)")
|
||||
else:
|
||||
self.components['search_help_label'].config(text="Type Last Name")
|
||||
|
||||
# Enable controls
|
||||
self._update_control_states()
|
||||
|
||||
# Show the first person
|
||||
self._update_display()
|
||||
|
||||
self.is_active = True
|
||||
|
||||
def _prefetch_auto_match_data(self, matches_by_matched: Dict) -> Dict:
|
||||
"""Pre-fetch all needed data to avoid repeated database queries"""
|
||||
data_cache = {}
|
||||
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pre-fetch all person names and details
|
||||
person_ids = list(matches_by_matched.keys())
|
||||
if person_ids:
|
||||
placeholders = ','.join('?' * len(person_ids))
|
||||
cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids)
|
||||
data_cache['person_details'] = {}
|
||||
for row in cursor.fetchall():
|
||||
person_id = row[0]
|
||||
first_name = row[1] or ''
|
||||
last_name = row[2] or ''
|
||||
middle_name = row[3] or ''
|
||||
maiden_name = row[4] or ''
|
||||
date_of_birth = row[5] or ''
|
||||
|
||||
# Create full name display
|
||||
name_parts = []
|
||||
if first_name:
|
||||
name_parts.append(first_name)
|
||||
if middle_name:
|
||||
name_parts.append(middle_name)
|
||||
if last_name:
|
||||
name_parts.append(last_name)
|
||||
if maiden_name:
|
||||
name_parts.append(f"({maiden_name})")
|
||||
|
||||
full_name = ' '.join(name_parts)
|
||||
data_cache['person_details'][person_id] = {
|
||||
'full_name': full_name,
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'middle_name': middle_name,
|
||||
'maiden_name': maiden_name,
|
||||
'date_of_birth': date_of_birth
|
||||
}
|
||||
|
||||
# Pre-fetch all photo paths (both matched and unidentified)
|
||||
all_photo_ids = set()
|
||||
for person_matches in matches_by_matched.values():
|
||||
for match in person_matches:
|
||||
all_photo_ids.add(match['matched_photo_id'])
|
||||
all_photo_ids.add(match['unidentified_photo_id'])
|
||||
|
||||
if all_photo_ids:
|
||||
photo_ids_list = list(all_photo_ids)
|
||||
placeholders = ','.join('?' * len(photo_ids_list))
|
||||
cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list)
|
||||
data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
return data_cache
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the display for the current person"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
|
||||
if self.current_matched_index >= len(active_ids):
|
||||
self._finish_auto_match()
|
||||
return
|
||||
|
||||
matched_id = active_ids[self.current_matched_index]
|
||||
matches_for_this_person = self.matches_by_matched[matched_id]
|
||||
|
||||
# Update button states
|
||||
self._update_control_states()
|
||||
|
||||
# Update save button text with person name
|
||||
self._update_save_button_text()
|
||||
|
||||
# Get the first match to get matched person info
|
||||
if not matches_for_this_person:
|
||||
print(f"❌ Error: No matches found for current person {matched_id}")
|
||||
# Skip to next person if available
|
||||
if self.current_matched_index < len(active_ids) - 1:
|
||||
self.current_matched_index += 1
|
||||
self._update_display()
|
||||
else:
|
||||
self._finish_auto_match()
|
||||
return
|
||||
|
||||
first_match = matches_for_this_person[0]
|
||||
|
||||
# Use cached data instead of database queries
|
||||
person_details = self.data_cache['person_details'].get(first_match['person_id'], {})
|
||||
person_name = person_details.get('full_name', "Unknown")
|
||||
date_of_birth = person_details.get('date_of_birth', '')
|
||||
matched_photo_path = self.data_cache['photo_paths'].get(first_match['matched_photo_id'], None)
|
||||
|
||||
# Create detailed person info display
|
||||
person_info_lines = [f"👤 Person: {person_name}"]
|
||||
if date_of_birth:
|
||||
person_info_lines.append(f"📅 Born: {date_of_birth}")
|
||||
person_info_lines.extend([
|
||||
f"📁 Photo: {first_match['matched_filename']}",
|
||||
f"📍 Face location: {first_match['matched_location']}"
|
||||
])
|
||||
|
||||
# Update matched person info
|
||||
self.components['person_info_label'].config(text="\n".join(person_info_lines))
|
||||
|
||||
# Display matched person face
|
||||
self.components['person_canvas'].delete("all")
|
||||
if matched_photo_path:
|
||||
matched_crop_path = self.face_processor._extract_face_crop(
|
||||
matched_photo_path,
|
||||
first_match['matched_location'],
|
||||
f"matched_{first_match['person_id']}"
|
||||
)
|
||||
|
||||
if matched_crop_path and os.path.exists(matched_crop_path):
|
||||
try:
|
||||
pil_image = Image.open(matched_crop_path)
|
||||
pil_image.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(pil_image)
|
||||
self.components['person_canvas'].create_image(150, 150, image=photo)
|
||||
self.components['person_canvas'].image = photo
|
||||
|
||||
# Add photo icon to the matched person face
|
||||
actual_width, actual_height = pil_image.size
|
||||
top_left_x = 150 - (actual_width // 2)
|
||||
top_left_y = 150 - (actual_height // 2)
|
||||
self.gui_core.create_photo_icon(self.components['person_canvas'], matched_photo_path, icon_size=20,
|
||||
face_x=top_left_x, face_y=top_left_y,
|
||||
face_width=actual_width, face_height=actual_height,
|
||||
canvas_width=300, canvas_height=300)
|
||||
except Exception as e:
|
||||
self.components['person_canvas'].create_text(150, 150, text=f"❌ Could not load image: {e}", fill="red")
|
||||
else:
|
||||
self.components['person_canvas'].create_text(150, 150, text="🖼️ No face crop available", fill="gray")
|
||||
|
||||
# Clear and populate unidentified faces
|
||||
self._update_matches_display(matches_for_this_person, matched_id)
|
||||
|
||||
def _update_matches_display(self, matches_for_this_person, matched_id):
|
||||
"""Update the matches display for the current person"""
|
||||
# Clear existing matches
|
||||
self.components['matches_canvas'].delete("all")
|
||||
self.match_checkboxes = []
|
||||
self.match_vars = []
|
||||
|
||||
# Create frame for unidentified faces inside canvas
|
||||
matches_inner_frame = ttk.Frame(self.components['matches_canvas'])
|
||||
self.components['matches_canvas'].create_window((0, 0), window=matches_inner_frame, anchor="nw")
|
||||
|
||||
# Use cached photo paths
|
||||
photo_paths = self.data_cache['photo_paths']
|
||||
|
||||
# Create all checkboxes
|
||||
for i, match in enumerate(matches_for_this_person):
|
||||
# Get unidentified face info from cached data
|
||||
unidentified_photo_path = photo_paths.get(match['unidentified_photo_id'], '')
|
||||
|
||||
# Calculate calibrated confidence (actual match probability)
|
||||
confidence_pct, confidence_desc = self.face_processor._get_calibrated_confidence(match['distance'])
|
||||
|
||||
# Create match frame
|
||||
match_frame = ttk.Frame(matches_inner_frame)
|
||||
match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Checkbox for this match
|
||||
match_var = tk.BooleanVar()
|
||||
|
||||
# Restore previous checkbox state if available
|
||||
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
||||
if matched_id in self.checkbox_states_per_person and unique_key in self.checkbox_states_per_person[matched_id]:
|
||||
saved_state = self.checkbox_states_per_person[matched_id][unique_key]
|
||||
match_var.set(saved_state)
|
||||
# Otherwise, pre-select if this face was previously identified for this person
|
||||
elif matched_id in self.identified_faces_per_person and match['unidentified_id'] in self.identified_faces_per_person[matched_id]:
|
||||
match_var.set(True)
|
||||
|
||||
self.match_vars.append(match_var)
|
||||
|
||||
# Capture original state at render time
|
||||
if matched_id not in self.original_checkbox_states_per_person:
|
||||
self.original_checkbox_states_per_person[matched_id] = {}
|
||||
if unique_key not in self.original_checkbox_states_per_person[matched_id]:
|
||||
self.original_checkbox_states_per_person[matched_id][unique_key] = match_var.get()
|
||||
|
||||
# Add callback to save state immediately when checkbox changes
|
||||
def on_checkbox_change(var, person_id, face_id):
|
||||
unique_key = f"{person_id}_{face_id}"
|
||||
if person_id not in self.checkbox_states_per_person:
|
||||
self.checkbox_states_per_person[person_id] = {}
|
||||
|
||||
current_value = var.get()
|
||||
self.checkbox_states_per_person[person_id][unique_key] = current_value
|
||||
|
||||
# Bind the callback to the variable
|
||||
current_person_id = matched_id
|
||||
current_face_id = match['unidentified_id']
|
||||
match_var.trace('w', lambda *args, var=match_var, person_id=current_person_id, face_id=current_face_id: on_checkbox_change(var, person_id, face_id))
|
||||
|
||||
# Configure match frame for grid layout
|
||||
match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width
|
||||
match_frame.columnconfigure(1, weight=0) # Image column - fixed width
|
||||
match_frame.columnconfigure(2, weight=1) # Text column - expandable
|
||||
|
||||
# Checkbox
|
||||
checkbox = ttk.Checkbutton(match_frame, variable=match_var)
|
||||
checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5))
|
||||
self.match_checkboxes.append(checkbox)
|
||||
|
||||
# Unidentified face image
|
||||
match_canvas = None
|
||||
if unidentified_photo_path:
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
match_canvas = tk.Canvas(match_frame, width=100, height=100, bg=canvas_bg_color, highlightthickness=0)
|
||||
match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10))
|
||||
|
||||
unidentified_crop_path = self.face_processor._extract_face_crop(
|
||||
unidentified_photo_path,
|
||||
match['unidentified_location'],
|
||||
f"unid_{match['unidentified_id']}"
|
||||
)
|
||||
|
||||
if unidentified_crop_path and os.path.exists(unidentified_crop_path):
|
||||
try:
|
||||
pil_image = Image.open(unidentified_crop_path)
|
||||
pil_image.thumbnail((100, 100), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(pil_image)
|
||||
match_canvas.create_image(50, 50, image=photo)
|
||||
match_canvas.image = photo
|
||||
|
||||
# Add photo icon
|
||||
self.gui_core.create_photo_icon(match_canvas, unidentified_photo_path, icon_size=15,
|
||||
face_x=0, face_y=0,
|
||||
face_width=100, face_height=100,
|
||||
canvas_width=100, canvas_height=100)
|
||||
except Exception:
|
||||
match_canvas.create_text(50, 50, text="❌", fill="red")
|
||||
else:
|
||||
match_canvas.create_text(50, 50, text="🖼️", fill="gray")
|
||||
|
||||
# Confidence badge and filename
|
||||
info_container = ttk.Frame(match_frame)
|
||||
info_container.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.E))
|
||||
|
||||
badge = self.gui_core.create_confidence_badge(info_container, confidence_pct)
|
||||
badge.pack(anchor=tk.W)
|
||||
|
||||
filename_label = ttk.Label(info_container, text=f"📁 {match['unidentified_filename']}",
|
||||
font=("Arial", 8), foreground="gray")
|
||||
filename_label.pack(anchor=tk.W, pady=(2, 0))
|
||||
|
||||
# Update Select All / Clear All button states
|
||||
self._update_match_control_buttons_state()
|
||||
|
||||
# Update scroll region
|
||||
self.components['matches_canvas'].update_idletasks()
|
||||
self.components['matches_canvas'].configure(scrollregion=self.components['matches_canvas'].bbox("all"))
|
||||
|
||||
def _update_control_states(self):
|
||||
"""Update control button states based on current position"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
|
||||
# Enable/disable Back button
|
||||
if self.current_matched_index > 0:
|
||||
self.components['back_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['back_btn'].config(state='disabled')
|
||||
|
||||
# Enable/disable Next button
|
||||
if self.current_matched_index < len(active_ids) - 1:
|
||||
self.components['next_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['next_btn'].config(state='disabled')
|
||||
|
||||
# Enable save button if we have matches
|
||||
if active_ids and self.current_matched_index < len(active_ids):
|
||||
self.components['save_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['save_btn'].config(state='disabled')
|
||||
|
||||
def _update_save_button_text(self):
|
||||
"""Update save button text with current person name"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
if self.current_matched_index < len(active_ids):
|
||||
matched_id = active_ids[self.current_matched_index]
|
||||
matches_for_current_person = self.matches_by_matched[matched_id]
|
||||
if matches_for_current_person:
|
||||
person_id = matches_for_current_person[0]['person_id']
|
||||
person_details = self.data_cache['person_details'].get(person_id, {})
|
||||
person_name = person_details.get('full_name', "Unknown")
|
||||
self.components['save_btn'].config(text=f"💾 Save changes for {person_name}")
|
||||
else:
|
||||
self.components['save_btn'].config(text="💾 Save Changes")
|
||||
else:
|
||||
self.components['save_btn'].config(text="💾 Save Changes")
|
||||
|
||||
def _update_match_control_buttons_state(self):
|
||||
"""Enable/disable Select All / Clear All based on matches presence"""
|
||||
if hasattr(self, 'match_vars') and self.match_vars:
|
||||
self.components['select_all_btn'].config(state='normal')
|
||||
self.components['clear_all_btn'].config(state='normal')
|
||||
else:
|
||||
self.components['select_all_btn'].config(state='disabled')
|
||||
self.components['clear_all_btn'].config(state='disabled')
|
||||
|
||||
def _select_all_matches(self):
|
||||
"""Select all match checkboxes"""
|
||||
if hasattr(self, 'match_vars'):
|
||||
for var in self.match_vars:
|
||||
var.set(True)
|
||||
|
||||
def _clear_all_matches(self):
|
||||
"""Clear all match checkboxes"""
|
||||
if hasattr(self, 'match_vars'):
|
||||
for var in self.match_vars:
|
||||
var.set(False)
|
||||
|
||||
def _save_changes(self):
|
||||
"""Save changes for the current person"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
if self.current_matched_index < len(active_ids):
|
||||
matched_id = active_ids[self.current_matched_index]
|
||||
matches_for_this_person = self.matches_by_matched[matched_id]
|
||||
|
||||
# Show saving message
|
||||
self.components['save_btn'].config(text="💾 Saving...", state='disabled')
|
||||
self.main_frame.update_idletasks()
|
||||
|
||||
# Initialize identified faces for this person if not exists
|
||||
if matched_id not in self.identified_faces_per_person:
|
||||
self.identified_faces_per_person[matched_id] = set()
|
||||
|
||||
# Count changes for feedback
|
||||
changes_made = 0
|
||||
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Process all matches (both checked and unchecked)
|
||||
for i, (match, var) in enumerate(zip(matches_for_this_person, self.match_vars)):
|
||||
if var.get():
|
||||
# Face is checked - assign to person
|
||||
cursor.execute(
|
||||
'UPDATE faces SET person_id = ? WHERE id = ?',
|
||||
(match['person_id'], match['unidentified_id'])
|
||||
)
|
||||
|
||||
# Use cached person name
|
||||
person_details = self.data_cache['person_details'].get(match['person_id'], {})
|
||||
person_name = person_details.get('full_name', "Unknown")
|
||||
|
||||
# Track this face as identified for this person
|
||||
self.identified_faces_per_person[matched_id].add(match['unidentified_id'])
|
||||
|
||||
print(f"✅ Identified as: {person_name}")
|
||||
self.identified_count += 1
|
||||
changes_made += 1
|
||||
else:
|
||||
# Face is unchecked - check if it was previously identified for this person
|
||||
if match['unidentified_id'] in self.identified_faces_per_person[matched_id]:
|
||||
# This face was previously identified for this person, now unchecking it
|
||||
cursor.execute(
|
||||
'UPDATE faces SET person_id = NULL WHERE id = ?',
|
||||
(match['unidentified_id'],)
|
||||
)
|
||||
|
||||
# Remove from identified faces for this person
|
||||
self.identified_faces_per_person[matched_id].discard(match['unidentified_id'])
|
||||
|
||||
print(f"❌ Unidentified: {match['unidentified_filename']}")
|
||||
changes_made += 1
|
||||
|
||||
# Commit changes first
|
||||
conn.commit()
|
||||
|
||||
# Update person encodings for all affected persons (outside of transaction)
|
||||
# This can be slow, so we show progress
|
||||
affected_person_ids = set(match['person_id'] for match in matches_for_this_person if match['person_id'])
|
||||
if affected_person_ids:
|
||||
self.components['save_btn'].config(text="💾 Updating encodings...")
|
||||
self.main_frame.update_idletasks()
|
||||
|
||||
for person_id in affected_person_ids:
|
||||
self.face_processor.update_person_encodings(person_id)
|
||||
|
||||
# After saving, set original states to the current UI states
|
||||
current_snapshot = {}
|
||||
for match, var in zip(matches_for_this_person, self.match_vars):
|
||||
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
||||
current_snapshot[unique_key] = var.get()
|
||||
self.checkbox_states_per_person[matched_id] = dict(current_snapshot)
|
||||
self.original_checkbox_states_per_person[matched_id] = dict(current_snapshot)
|
||||
|
||||
# Show completion message
|
||||
if changes_made > 0:
|
||||
print(f"✅ Saved {changes_made} change(s)")
|
||||
|
||||
# Restore button text and update state
|
||||
self._update_save_button_text()
|
||||
self.components['save_btn'].config(state='normal')
|
||||
self.main_frame.update_idletasks()
|
||||
|
||||
def _go_back(self):
|
||||
"""Go back to the previous person"""
|
||||
if self.current_matched_index > 0:
|
||||
self.current_matched_index -= 1
|
||||
self._update_display()
|
||||
|
||||
def _go_next(self):
|
||||
"""Go to the next person"""
|
||||
active_ids = self.filtered_matched_ids if self.filtered_matched_ids is not None else self.matched_ids
|
||||
if self.current_matched_index < len(active_ids) - 1:
|
||||
self.current_matched_index += 1
|
||||
self._update_display()
|
||||
else:
|
||||
self._finish_auto_match()
|
||||
|
||||
def _apply_search_filter(self):
|
||||
"""Filter people by last name and update navigation"""
|
||||
query = self.components['search_var'].get().strip().lower()
|
||||
if query:
|
||||
# Filter person_faces_list by last name
|
||||
filtered_people = []
|
||||
for person_id in self.matched_ids:
|
||||
# Get person name from cache
|
||||
person_details = self.data_cache['person_details'].get(person_id, {})
|
||||
person_name = person_details.get('full_name', '')
|
||||
|
||||
# Extract last name from person_name
|
||||
if ',' in person_name:
|
||||
last_name = person_name.split(',')[0].strip().lower()
|
||||
else:
|
||||
# Try to extract last name from full name
|
||||
name_parts = person_name.strip().split()
|
||||
if name_parts:
|
||||
last_name = name_parts[-1].lower()
|
||||
else:
|
||||
last_name = ''
|
||||
|
||||
if query in last_name:
|
||||
filtered_people.append(person_id)
|
||||
|
||||
self.filtered_matched_ids = filtered_people
|
||||
else:
|
||||
self.filtered_matched_ids = None
|
||||
|
||||
# Reset to first person in filtered list
|
||||
self.current_matched_index = 0
|
||||
if self.filtered_matched_ids:
|
||||
self._update_display()
|
||||
else:
|
||||
# No matches - clear display
|
||||
self.components['person_info_label'].config(text="No people match filter")
|
||||
self.components['person_canvas'].delete("all")
|
||||
self.components['person_canvas'].create_text(150, 150, text="No matches found", fill="gray")
|
||||
self.components['matches_canvas'].delete("all")
|
||||
self._update_control_states()
|
||||
|
||||
def _clear_search_filter(self):
|
||||
"""Clear filter and show all people"""
|
||||
self.components['search_var'].set("")
|
||||
self.filtered_matched_ids = None
|
||||
self.current_matched_index = 0
|
||||
self._update_display()
|
||||
|
||||
def _finish_auto_match(self):
|
||||
"""Finish the auto-match process"""
|
||||
print(f"\n✅ Auto-identified {self.identified_count} faces")
|
||||
messagebox.showinfo("Auto-Match Complete", f"Auto-identified {self.identified_count} faces")
|
||||
self._cleanup()
|
||||
|
||||
def _quit_auto_match(self):
|
||||
"""Quit the auto-match process"""
|
||||
# Check for unsaved changes before quitting
|
||||
if self._has_unsaved_changes():
|
||||
result = self.gui_core.create_large_messagebox(
|
||||
self.main_frame,
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes that will be lost if you quit.\n\n"
|
||||
"Yes: Save current changes and quit\n"
|
||||
"No: Quit without saving\n"
|
||||
"Cancel: Return to auto-match",
|
||||
"askyesnocancel"
|
||||
)
|
||||
if result is None:
|
||||
# Cancel
|
||||
return
|
||||
if result:
|
||||
# Save current person's changes, then quit
|
||||
self._save_changes()
|
||||
|
||||
self._cleanup()
|
||||
|
||||
# Navigate to home if callback is available (dashboard mode)
|
||||
if self.on_navigate_home:
|
||||
self.on_navigate_home()
|
||||
|
||||
def _has_unsaved_changes(self):
|
||||
"""Check if there are any unsaved changes"""
|
||||
for person_id, current_states in self.checkbox_states_per_person.items():
|
||||
if person_id in self.original_checkbox_states_per_person:
|
||||
original_states = self.original_checkbox_states_per_person[person_id]
|
||||
# Check if any checkbox state differs from its original state
|
||||
for key, current_value in current_states.items():
|
||||
if key not in original_states or original_states[key] != current_value:
|
||||
return True
|
||||
else:
|
||||
# If person has current states but no original states, there are changes
|
||||
if any(current_states.values()):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up resources and reset state"""
|
||||
# Clean up face crops
|
||||
self.face_processor.cleanup_face_crops()
|
||||
|
||||
# Reset state
|
||||
self.matches_by_matched = {}
|
||||
self.data_cache = {}
|
||||
self.current_matched_index = 0
|
||||
self.matched_ids = []
|
||||
self.filtered_matched_ids = None
|
||||
self.identified_faces_per_person = {}
|
||||
self.checkbox_states_per_person = {}
|
||||
self.original_checkbox_states_per_person = {}
|
||||
self.identified_count = 0
|
||||
|
||||
# Clear displays
|
||||
self.components['person_info_label'].config(text="")
|
||||
self.components['person_canvas'].delete("all")
|
||||
self.components['matches_canvas'].delete("all")
|
||||
|
||||
# Disable controls
|
||||
self.components['back_btn'].config(state='disabled')
|
||||
self.components['next_btn'].config(state='disabled')
|
||||
self.components['save_btn'].config(state='disabled')
|
||||
self.components['select_all_btn'].config(state='disabled')
|
||||
self.components['clear_all_btn'].config(state='disabled')
|
||||
|
||||
# Clear search and disable search controls
|
||||
self.components['search_var'].set("")
|
||||
self.components['search_entry'].config(state='disabled')
|
||||
self.components['search_btn'].config(state='disabled')
|
||||
self.components['clear_btn'].config(state='disabled')
|
||||
self.components['search_help_label'].config(text="Search disabled - click 'Start Auto-Match' first")
|
||||
|
||||
self.is_active = False
|
||||
|
||||
def activate(self):
|
||||
"""Activate the panel"""
|
||||
self.is_active = True
|
||||
|
||||
def deactivate(self):
|
||||
"""Deactivate the panel"""
|
||||
if self.is_active:
|
||||
self._cleanup()
|
||||
self.is_active = False
|
||||
@ -1,741 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integrated Modify Panel for PunimTag Dashboard
|
||||
Embeds the full modify identified GUI functionality into the dashboard frame
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from PIL import Image, ImageTk
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from src.core.config import DEFAULT_FACE_TOLERANCE
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.gui.gui_core import GUICore
|
||||
|
||||
|
||||
class ToolTip:
|
||||
"""Simple tooltip implementation"""
|
||||
def __init__(self, widget, text):
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.tooltip_window = None
|
||||
self.widget.bind("<Enter>", self.on_enter)
|
||||
self.widget.bind("<Leave>", self.on_leave)
|
||||
|
||||
def on_enter(self, event=None):
|
||||
if self.tooltip_window or not self.text:
|
||||
return
|
||||
x, y, _, _ = self.widget.bbox("insert") if hasattr(self.widget, 'bbox') else (0, 0, 0, 0)
|
||||
x += self.widget.winfo_rootx() + 25
|
||||
y += self.widget.winfo_rooty() + 25
|
||||
|
||||
self.tooltip_window = tw = tk.Toplevel(self.widget)
|
||||
tw.wm_overrideredirect(True)
|
||||
tw.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
||||
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
|
||||
font=("tahoma", "8", "normal"))
|
||||
label.pack(ipadx=1)
|
||||
|
||||
def on_leave(self, event=None):
|
||||
if self.tooltip_window:
|
||||
self.tooltip_window.destroy()
|
||||
self.tooltip_window = None
|
||||
|
||||
|
||||
class ModifyPanel:
|
||||
"""Integrated modify panel that embeds the full modify identified GUI functionality into the dashboard"""
|
||||
|
||||
def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager,
|
||||
face_processor: FaceProcessor, gui_core: GUICore, on_navigate_home=None, verbose: int = 0):
|
||||
"""Initialize the modify panel"""
|
||||
self.parent_frame = parent_frame
|
||||
self.db = db_manager
|
||||
self.face_processor = face_processor
|
||||
self.gui_core = gui_core
|
||||
self.on_navigate_home = on_navigate_home
|
||||
self.verbose = verbose
|
||||
|
||||
# Panel state
|
||||
self.is_active = False
|
||||
self.temp_crops = []
|
||||
self.right_panel_images = [] # Keep PhotoImage refs alive
|
||||
self.selected_person_id = None
|
||||
|
||||
# Track unmatched faces (temporary changes)
|
||||
self.unmatched_faces = set() # All face IDs unmatched across people (for global save)
|
||||
self.unmatched_by_person = {} # person_id -> set(face_id) for per-person undo
|
||||
self.original_faces_data = [] # store original faces data for potential future use
|
||||
|
||||
# People data
|
||||
self.people_data = [] # list of dicts: {id, name, count, first_name, last_name}
|
||||
self.people_filtered = None # filtered subset based on last name search
|
||||
self.current_person_id = None
|
||||
self.current_person_name = ""
|
||||
self.resize_job = None
|
||||
|
||||
# GUI components
|
||||
self.components = {}
|
||||
self.main_frame = None
|
||||
|
||||
def create_panel(self) -> ttk.Frame:
|
||||
"""Create the modify panel with all GUI components"""
|
||||
self.main_frame = ttk.Frame(self.parent_frame)
|
||||
|
||||
# Configure grid weights for full screen responsiveness
|
||||
self.main_frame.columnconfigure(0, weight=1) # Left panel
|
||||
self.main_frame.columnconfigure(1, weight=2) # Right panel
|
||||
self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable
|
||||
|
||||
# Create all GUI components
|
||||
self._create_gui_components()
|
||||
|
||||
# Create main content panels
|
||||
self._create_main_panels()
|
||||
|
||||
return self.main_frame
|
||||
|
||||
def _create_gui_components(self):
|
||||
"""Create all GUI components for the modify interface"""
|
||||
# Search controls (Last Name) with label under the input (match auto-match style)
|
||||
self.components['last_name_search_var'] = tk.StringVar()
|
||||
|
||||
# Control buttons
|
||||
self.components['quit_btn'] = None
|
||||
self.components['save_btn_bottom'] = None
|
||||
|
||||
def _create_main_panels(self):
|
||||
"""Create the main left and right panels"""
|
||||
# Left panel: People list
|
||||
self.components['people_frame'] = ttk.LabelFrame(self.main_frame, text="People", padding="10")
|
||||
self.components['people_frame'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8))
|
||||
self.components['people_frame'].columnconfigure(0, weight=1)
|
||||
|
||||
# Right panel: Faces for selected person
|
||||
self.components['faces_frame'] = ttk.LabelFrame(self.main_frame, text="Faces", padding="10")
|
||||
self.components['faces_frame'].grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
self.components['faces_frame'].columnconfigure(0, weight=1)
|
||||
self.components['faces_frame'].rowconfigure(0, weight=1)
|
||||
|
||||
# Create left panel content
|
||||
self._create_left_panel_content()
|
||||
|
||||
# Create right panel content
|
||||
self._create_right_panel_content()
|
||||
|
||||
# Create control buttons
|
||||
self._create_control_buttons()
|
||||
|
||||
def _create_left_panel_content(self):
|
||||
"""Create the left panel content for people list"""
|
||||
people_frame = self.components['people_frame']
|
||||
|
||||
# Search controls
|
||||
search_frame = ttk.Frame(people_frame)
|
||||
search_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 6))
|
||||
|
||||
# Entry on the left
|
||||
search_entry = ttk.Entry(search_frame, textvariable=self.components['last_name_search_var'], width=20)
|
||||
search_entry.grid(row=0, column=0, sticky=tk.W)
|
||||
|
||||
# Buttons to the right of the entry
|
||||
buttons_row = ttk.Frame(search_frame)
|
||||
buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0))
|
||||
search_btn = ttk.Button(buttons_row, text="Search", width=8, command=self.apply_last_name_filter)
|
||||
search_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
clear_btn = ttk.Button(buttons_row, text="Clear", width=6, command=self.clear_last_name_filter)
|
||||
clear_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Helper label directly under the entry
|
||||
last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray")
|
||||
last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0))
|
||||
|
||||
# People list with scrollbar
|
||||
people_canvas = tk.Canvas(people_frame, bg='white')
|
||||
people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview)
|
||||
self.components['people_list_inner'] = ttk.Frame(people_canvas)
|
||||
people_canvas.create_window((0, 0), window=self.components['people_list_inner'], anchor="nw")
|
||||
people_canvas.configure(yscrollcommand=people_scrollbar.set)
|
||||
|
||||
self.components['people_list_inner'].bind(
|
||||
"<Configure>",
|
||||
lambda e: people_canvas.configure(scrollregion=people_canvas.bbox("all"))
|
||||
)
|
||||
|
||||
people_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
people_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
|
||||
people_frame.rowconfigure(1, weight=1)
|
||||
|
||||
# Store canvas reference
|
||||
self.components['people_canvas'] = people_canvas
|
||||
|
||||
# Bind Enter key for search
|
||||
search_entry.bind('<Return>', lambda e: self.apply_last_name_filter())
|
||||
|
||||
def _create_right_panel_content(self):
|
||||
"""Create the right panel content for faces display"""
|
||||
faces_frame = self.components['faces_frame']
|
||||
|
||||
# Style configuration
|
||||
style = ttk.Style()
|
||||
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
|
||||
self.components['faces_canvas'] = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0)
|
||||
faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=self.components['faces_canvas'].yview)
|
||||
self.components['faces_inner'] = ttk.Frame(self.components['faces_canvas'])
|
||||
self.components['faces_canvas'].create_window((0, 0), window=self.components['faces_inner'], anchor="nw")
|
||||
self.components['faces_canvas'].configure(yscrollcommand=faces_scrollbar.set)
|
||||
|
||||
self.components['faces_inner'].bind(
|
||||
"<Configure>",
|
||||
lambda e: self.components['faces_canvas'].configure(scrollregion=self.components['faces_canvas'].bbox("all"))
|
||||
)
|
||||
|
||||
# Bind resize handler for responsive face grid
|
||||
self.components['faces_canvas'].bind("<Configure>", self.on_faces_canvas_resize)
|
||||
|
||||
self.components['faces_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
faces_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
|
||||
def _create_control_buttons(self):
|
||||
"""Create control buttons at the bottom"""
|
||||
# Control buttons
|
||||
control_frame = ttk.Frame(self.main_frame)
|
||||
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
|
||||
|
||||
self.components['quit_btn'] = ttk.Button(control_frame, text="❌ Exit Edit Identified", command=self.on_quit)
|
||||
self.components['quit_btn'].pack(side=tk.RIGHT)
|
||||
self.components['save_btn_bottom'] = ttk.Button(control_frame, text="💾 Save changes", command=self.on_save_all_changes, state="disabled")
|
||||
self.components['save_btn_bottom'].pack(side=tk.RIGHT, padx=(0, 10))
|
||||
self.components['undo_btn'] = ttk.Button(control_frame, text="↶ Undo changes", command=self.undo_changes, state="disabled")
|
||||
self.components['undo_btn'].pack(side=tk.RIGHT, padx=(0, 10))
|
||||
|
||||
def on_faces_canvas_resize(self, event):
|
||||
"""Handle canvas resize for responsive face grid"""
|
||||
if self.current_person_id is None:
|
||||
return
|
||||
# Debounce re-render on resize
|
||||
try:
|
||||
if self.resize_job is not None:
|
||||
self.main_frame.after_cancel(self.resize_job)
|
||||
except Exception:
|
||||
pass
|
||||
self.resize_job = self.main_frame.after(150, lambda: self.show_person_faces(self.current_person_id, self.current_person_name))
|
||||
|
||||
def load_people(self):
|
||||
"""Load people from database with counts"""
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT p.id, p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth, COUNT(f.id) as face_count
|
||||
FROM people p
|
||||
JOIN faces f ON f.person_id = p.id
|
||||
GROUP BY p.id, p.last_name, p.first_name, p.middle_name, p.maiden_name, p.date_of_birth
|
||||
HAVING face_count > 0
|
||||
ORDER BY p.last_name, p.first_name COLLATE NOCASE
|
||||
"""
|
||||
)
|
||||
self.people_data = []
|
||||
for (pid, first_name, last_name, middle_name, maiden_name, date_of_birth, count) in cursor.fetchall():
|
||||
# Create full name display with all available information
|
||||
name_parts = []
|
||||
if first_name:
|
||||
name_parts.append(first_name)
|
||||
if middle_name:
|
||||
name_parts.append(middle_name)
|
||||
if last_name:
|
||||
name_parts.append(last_name)
|
||||
if maiden_name:
|
||||
name_parts.append(f"({maiden_name})")
|
||||
|
||||
full_name = ' '.join(name_parts) if name_parts else "Unknown"
|
||||
|
||||
# Create detailed display with date of birth if available
|
||||
display_name = full_name
|
||||
if date_of_birth:
|
||||
display_name += f" - Born: {date_of_birth}"
|
||||
|
||||
self.people_data.append({
|
||||
'id': pid,
|
||||
'name': display_name,
|
||||
'full_name': full_name,
|
||||
'first_name': first_name or "",
|
||||
'last_name': last_name or "",
|
||||
'middle_name': middle_name or "",
|
||||
'maiden_name': maiden_name or "",
|
||||
'date_of_birth': date_of_birth or "",
|
||||
'count': count
|
||||
})
|
||||
# Re-apply filter (if any) after loading
|
||||
try:
|
||||
self.apply_last_name_filter()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def apply_last_name_filter(self):
|
||||
"""Apply last name filter to people list"""
|
||||
query = self.components['last_name_search_var'].get().strip().lower()
|
||||
if query:
|
||||
self.people_filtered = [p for p in self.people_data if p.get('last_name', '').lower().find(query) != -1]
|
||||
else:
|
||||
self.people_filtered = None
|
||||
self.populate_people_list()
|
||||
|
||||
def clear_last_name_filter(self):
|
||||
"""Clear the last name filter"""
|
||||
self.components['last_name_search_var'].set("")
|
||||
self.people_filtered = None
|
||||
self.populate_people_list()
|
||||
|
||||
def populate_people_list(self):
|
||||
"""Populate the people list with current data"""
|
||||
# Clear existing widgets
|
||||
for widget in self.components['people_list_inner'].winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# Use filtered data if available, otherwise use all data
|
||||
people_to_show = self.people_filtered if self.people_filtered is not None else self.people_data
|
||||
|
||||
for i, person in enumerate(people_to_show):
|
||||
row_frame = ttk.Frame(self.components['people_list_inner'])
|
||||
row_frame.pack(fill=tk.X, padx=2, pady=1)
|
||||
|
||||
# Edit button (on the left)
|
||||
edit_btn = ttk.Button(row_frame, text="✏️", width=3,
|
||||
command=lambda p=person: self.start_edit_person(p))
|
||||
edit_btn.pack(side=tk.LEFT, padx=(0, 5))
|
||||
# Add tooltip to edit button
|
||||
ToolTip(edit_btn, "Update name")
|
||||
|
||||
# Label (clickable) - takes remaining space
|
||||
name_lbl = ttk.Label(row_frame, text=f"{person['name']} ({person['count']})", font=("Arial", 10))
|
||||
name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
name_lbl.bind("<Button-1>", lambda e, p=person: self.show_person_faces(p['id'], p['name']))
|
||||
name_lbl.config(cursor="hand2")
|
||||
|
||||
# Bold if selected
|
||||
if (self.selected_person_id is None and i == 0) or (self.selected_person_id == person['id']):
|
||||
name_lbl.config(font=("Arial", 10, "bold"))
|
||||
|
||||
def start_edit_person(self, person_record):
|
||||
"""Start editing a person's information"""
|
||||
# Create a new window for editing
|
||||
edit_window = tk.Toplevel(self.main_frame)
|
||||
edit_window.title(f"Edit {person_record['name']}")
|
||||
edit_window.geometry("500x400")
|
||||
edit_window.transient(self.main_frame)
|
||||
edit_window.grab_set()
|
||||
|
||||
# Center the window
|
||||
edit_window.update_idletasks()
|
||||
x = (edit_window.winfo_screenwidth() // 2) - (edit_window.winfo_width() // 2)
|
||||
y = (edit_window.winfo_screenheight() // 2) - (edit_window.winfo_height() // 2)
|
||||
edit_window.geometry(f"+{x}+{y}")
|
||||
|
||||
# Create form fields
|
||||
form_frame = ttk.Frame(edit_window, padding="20")
|
||||
form_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# First name
|
||||
ttk.Label(form_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, pady=5)
|
||||
first_name_var = tk.StringVar(value=person_record.get('first_name', ''))
|
||||
first_entry = ttk.Entry(form_frame, textvariable=first_name_var, width=30)
|
||||
first_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Last name
|
||||
ttk.Label(form_frame, text="Last name:").grid(row=1, column=0, sticky=tk.W, pady=5)
|
||||
last_name_var = tk.StringVar(value=person_record.get('last_name', ''))
|
||||
last_entry = ttk.Entry(form_frame, textvariable=last_name_var, width=30)
|
||||
last_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Middle name
|
||||
ttk.Label(form_frame, text="Middle name:").grid(row=2, column=0, sticky=tk.W, pady=5)
|
||||
middle_name_var = tk.StringVar(value=person_record.get('middle_name', ''))
|
||||
middle_entry = ttk.Entry(form_frame, textvariable=middle_name_var, width=30)
|
||||
middle_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Maiden name
|
||||
ttk.Label(form_frame, text="Maiden name:").grid(row=3, column=0, sticky=tk.W, pady=5)
|
||||
maiden_name_var = tk.StringVar(value=person_record.get('maiden_name', ''))
|
||||
maiden_entry = ttk.Entry(form_frame, textvariable=maiden_name_var, width=30)
|
||||
maiden_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Date of birth
|
||||
ttk.Label(form_frame, text="Date of birth:").grid(row=4, column=0, sticky=tk.W, pady=5)
|
||||
dob_var = tk.StringVar(value=person_record.get('date_of_birth', ''))
|
||||
dob_entry = ttk.Entry(form_frame, textvariable=dob_var, width=30, state='readonly')
|
||||
dob_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Calendar button for date of birth
|
||||
def open_dob_calendar():
|
||||
selected_date = self.gui_core.create_calendar_dialog(edit_window, "Select Date of Birth", dob_var.get())
|
||||
if selected_date is not None:
|
||||
dob_var.set(selected_date)
|
||||
|
||||
dob_calendar_btn = ttk.Button(form_frame, text="📅", width=3, command=open_dob_calendar)
|
||||
dob_calendar_btn.grid(row=4, column=2, padx=(5, 0), pady=5)
|
||||
|
||||
# Configure grid weights
|
||||
form_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(edit_window)
|
||||
button_frame.pack(fill=tk.X, padx=20, pady=10)
|
||||
|
||||
def save_rename():
|
||||
"""Save the renamed person"""
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE people
|
||||
SET first_name = ?, last_name = ?, middle_name = ?, maiden_name = ?, date_of_birth = ?
|
||||
WHERE id = ?
|
||||
""", (
|
||||
first_name_var.get().strip(),
|
||||
last_name_var.get().strip(),
|
||||
middle_name_var.get().strip(),
|
||||
maiden_name_var.get().strip(),
|
||||
dob_var.get().strip(),
|
||||
person_record['id']
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Refresh the people list
|
||||
self.load_people()
|
||||
self.populate_people_list()
|
||||
|
||||
# Close the edit window
|
||||
edit_window.destroy()
|
||||
|
||||
messagebox.showinfo("Success", "Person information updated successfully.")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to update person: {e}")
|
||||
|
||||
def cancel_edit():
|
||||
"""Cancel editing"""
|
||||
edit_window.destroy()
|
||||
|
||||
save_btn = ttk.Button(button_frame, text="Save", command=save_rename)
|
||||
save_btn.pack(side=tk.LEFT, padx=(0, 10))
|
||||
cancel_btn = ttk.Button(button_frame, text="Cancel", command=cancel_edit)
|
||||
cancel_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Focus on first name field
|
||||
first_entry.focus_set()
|
||||
|
||||
# Add keyboard shortcuts
|
||||
def try_save():
|
||||
if save_btn.cget('state') == 'normal':
|
||||
save_rename()
|
||||
|
||||
first_entry.bind('<Return>', lambda e: try_save())
|
||||
last_entry.bind('<Return>', lambda e: try_save())
|
||||
middle_entry.bind('<Return>', lambda e: try_save())
|
||||
maiden_entry.bind('<Return>', lambda e: try_save())
|
||||
dob_entry.bind('<Return>', lambda e: try_save())
|
||||
first_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
last_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
middle_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
maiden_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
dob_entry.bind('<Escape>', lambda e: cancel_edit())
|
||||
|
||||
# Add validation
|
||||
def validate_save_button():
|
||||
first_name = first_name_var.get().strip()
|
||||
last_name = last_name_var.get().strip()
|
||||
if first_name and last_name:
|
||||
save_btn.config(state='normal')
|
||||
else:
|
||||
save_btn.config(state='disabled')
|
||||
|
||||
# Bind validation to all fields
|
||||
first_name_var.trace('w', lambda *args: validate_save_button())
|
||||
last_name_var.trace('w', lambda *args: validate_save_button())
|
||||
middle_name_var.trace('w', lambda *args: validate_save_button())
|
||||
maiden_name_var.trace('w', lambda *args: validate_save_button())
|
||||
dob_var.trace('w', lambda *args: validate_save_button())
|
||||
|
||||
# Initial validation
|
||||
validate_save_button()
|
||||
|
||||
def show_person_faces(self, person_id, person_name):
|
||||
"""Show faces for the selected person"""
|
||||
self.current_person_id = person_id
|
||||
self.current_person_name = person_name
|
||||
self.selected_person_id = person_id
|
||||
|
||||
# Clear existing face widgets
|
||||
for widget in self.components['faces_inner'].winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# Load faces for this person
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT f.id, f.photo_id, p.path, p.filename, f.location,
|
||||
f.face_confidence, f.quality_score, f.detector_backend, f.model_name
|
||||
FROM faces f
|
||||
JOIN photos p ON f.photo_id = p.id
|
||||
WHERE f.person_id = ?
|
||||
ORDER BY p.filename
|
||||
""", (person_id,))
|
||||
|
||||
faces = cursor.fetchall()
|
||||
|
||||
# Filter out unmatched faces
|
||||
visible_faces = [face for face in faces if face[0] not in self.unmatched_faces]
|
||||
|
||||
if not visible_faces:
|
||||
if not faces:
|
||||
no_faces_label = ttk.Label(self.components['faces_inner'],
|
||||
text="No faces found for this person",
|
||||
font=("Arial", 12))
|
||||
else:
|
||||
no_faces_label = ttk.Label(self.components['faces_inner'],
|
||||
text="All faces unmatched",
|
||||
font=("Arial", 12))
|
||||
no_faces_label.pack(pady=20)
|
||||
return
|
||||
|
||||
# Display faces in a grid
|
||||
self._display_faces_grid(visible_faces)
|
||||
|
||||
# Update people list to show selection
|
||||
self.populate_people_list()
|
||||
|
||||
# Update button states based on unmatched faces
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
def _display_faces_grid(self, faces):
|
||||
"""Display faces in a responsive grid layout"""
|
||||
# Calculate grid dimensions based on canvas width
|
||||
canvas_width = self.components['faces_canvas'].winfo_width()
|
||||
if canvas_width < 100: # Canvas not yet rendered
|
||||
canvas_width = 400 # Default width
|
||||
|
||||
face_size = 80
|
||||
padding = 10
|
||||
faces_per_row = max(1, (canvas_width - padding) // (face_size + padding))
|
||||
|
||||
# Clear existing images
|
||||
self.right_panel_images.clear()
|
||||
|
||||
for i, (face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model) in enumerate(faces):
|
||||
row = i // faces_per_row
|
||||
col = i % faces_per_row
|
||||
|
||||
# Create face frame
|
||||
face_frame = ttk.Frame(self.components['faces_inner'])
|
||||
face_frame.grid(row=row, column=col, padx=5, pady=5, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
# Face image
|
||||
try:
|
||||
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id)
|
||||
if face_crop_path and os.path.exists(face_crop_path):
|
||||
self.temp_crops.append(face_crop_path)
|
||||
|
||||
image = Image.open(face_crop_path)
|
||||
image.thumbnail((face_size, face_size), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(image)
|
||||
self.right_panel_images.append(photo) # Keep reference
|
||||
|
||||
# Create canvas for face image
|
||||
face_canvas = tk.Canvas(face_frame, width=face_size, height=face_size, highlightthickness=0)
|
||||
face_canvas.pack()
|
||||
face_canvas.create_image(face_size//2, face_size//2, image=photo, anchor=tk.CENTER)
|
||||
|
||||
# Add photo icon
|
||||
self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15,
|
||||
face_x=0, face_y=0,
|
||||
face_width=face_size, face_height=face_size,
|
||||
canvas_width=face_size, canvas_height=face_size)
|
||||
|
||||
# Unmatch button
|
||||
unmatch_btn = ttk.Button(face_frame, text="Unmatch",
|
||||
command=lambda fid=face_id: self.unmatch_face(fid))
|
||||
unmatch_btn.pack(pady=2)
|
||||
|
||||
else:
|
||||
# Placeholder for missing face crop
|
||||
placeholder_label = ttk.Label(face_frame, text=f"Face {face_id}",
|
||||
font=("Arial", 8))
|
||||
placeholder_label.pack()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error displaying face {face_id}: {e}")
|
||||
# Placeholder for error
|
||||
error_label = ttk.Label(face_frame, text=f"Error {face_id}",
|
||||
font=("Arial", 8), foreground="red")
|
||||
error_label.pack()
|
||||
|
||||
def unmatch_face(self, face_id):
|
||||
"""Unmatch a face from its person"""
|
||||
if face_id not in self.unmatched_faces:
|
||||
self.unmatched_faces.add(face_id)
|
||||
if self.current_person_id not in self.unmatched_by_person:
|
||||
self.unmatched_by_person[self.current_person_id] = set()
|
||||
self.unmatched_by_person[self.current_person_id].add(face_id)
|
||||
print(f"Face {face_id} marked for unmatching")
|
||||
# Immediately refresh the display to hide the unmatched face
|
||||
if self.current_person_id:
|
||||
self.show_person_faces(self.current_person_id, self.current_person_name)
|
||||
# Update button states
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
def _update_undo_button_state(self):
|
||||
"""Update the undo button state based on unmatched faces for current person"""
|
||||
if 'undo_btn' in self.components:
|
||||
current_has_unmatched = bool(self.unmatched_by_person.get(self.current_person_id))
|
||||
if current_has_unmatched:
|
||||
self.components['undo_btn'].config(state="normal")
|
||||
else:
|
||||
self.components['undo_btn'].config(state="disabled")
|
||||
|
||||
def _update_save_button_state(self):
|
||||
"""Update the save button state based on whether there are any unmatched faces to save"""
|
||||
if 'save_btn_bottom' in self.components:
|
||||
if self.unmatched_faces:
|
||||
self.components['save_btn_bottom'].config(state="normal")
|
||||
else:
|
||||
self.components['save_btn_bottom'].config(state="disabled")
|
||||
|
||||
def undo_changes(self):
|
||||
"""Undo all unmatched faces for the current person"""
|
||||
if self.current_person_id and self.current_person_id in self.unmatched_by_person:
|
||||
# Remove faces for current person from unmatched sets
|
||||
person_faces = self.unmatched_by_person[self.current_person_id]
|
||||
self.unmatched_faces -= person_faces
|
||||
del self.unmatched_by_person[self.current_person_id]
|
||||
|
||||
# Refresh the display to show the restored faces
|
||||
if self.current_person_id:
|
||||
self.show_person_faces(self.current_person_id, self.current_person_name)
|
||||
# Update button states
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
messagebox.showinfo("Undo", f"Undid changes for {len(person_faces)} face(s).")
|
||||
else:
|
||||
messagebox.showinfo("No Changes", "No changes to undo for this person.")
|
||||
|
||||
|
||||
def on_quit(self):
|
||||
"""Handle quit button click"""
|
||||
# Check for unsaved changes
|
||||
if self.unmatched_faces:
|
||||
result = self.gui_core.create_large_messagebox(
|
||||
self.main_frame,
|
||||
"Unsaved Changes",
|
||||
f"You have {len(self.unmatched_faces)} unsaved changes.\n\n"
|
||||
"Do you want to save them before quitting?\n\n"
|
||||
"• Yes: Save changes and quit\n"
|
||||
"• No: Quit without saving\n"
|
||||
"• Cancel: Return to modify",
|
||||
"askyesnocancel"
|
||||
)
|
||||
|
||||
if result is True: # Yes - Save and quit
|
||||
self.on_save_all_changes()
|
||||
elif result is False: # No - Quit without saving
|
||||
pass
|
||||
else: # Cancel - Don't quit
|
||||
return
|
||||
|
||||
# Clean up and deactivate
|
||||
self._cleanup()
|
||||
self.is_active = False
|
||||
|
||||
# Navigate to home if callback is available (dashboard mode)
|
||||
if self.on_navigate_home:
|
||||
self.on_navigate_home()
|
||||
|
||||
def on_save_all_changes(self):
|
||||
"""Save all unmatched faces to database"""
|
||||
if not self.unmatched_faces:
|
||||
messagebox.showinfo("No Changes", "No changes to save.")
|
||||
return
|
||||
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
count = 0
|
||||
for face_id in self.unmatched_faces:
|
||||
cursor.execute("UPDATE faces SET person_id = NULL WHERE id = ?", (face_id,))
|
||||
count += 1
|
||||
conn.commit()
|
||||
|
||||
# Clear the unmatched faces
|
||||
self.unmatched_faces.clear()
|
||||
self.unmatched_by_person.clear()
|
||||
|
||||
# Refresh the display
|
||||
if self.current_person_id:
|
||||
self.show_person_faces(self.current_person_id, self.current_person_name)
|
||||
|
||||
# Update button states
|
||||
self._update_undo_button_state()
|
||||
self._update_save_button_state()
|
||||
|
||||
messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to save changes: {e}")
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up resources"""
|
||||
# Clear temporary crops
|
||||
for crop_path in self.temp_crops:
|
||||
try:
|
||||
if os.path.exists(crop_path):
|
||||
os.remove(crop_path)
|
||||
except Exception:
|
||||
pass
|
||||
self.temp_crops.clear()
|
||||
|
||||
# Clear right panel images
|
||||
self.right_panel_images.clear()
|
||||
|
||||
# Clear state
|
||||
self.unmatched_faces.clear()
|
||||
self.unmatched_by_person.clear()
|
||||
self.original_faces_data.clear()
|
||||
self.people_data.clear()
|
||||
self.people_filtered = None
|
||||
self.current_person_id = None
|
||||
self.current_person_name = ""
|
||||
self.selected_person_id = None
|
||||
|
||||
def activate(self):
|
||||
"""Activate the panel"""
|
||||
self.is_active = True
|
||||
# Initial load
|
||||
self.load_people()
|
||||
self.populate_people_list()
|
||||
|
||||
# Show first person's faces by default and mark selected
|
||||
if self.people_data:
|
||||
self.selected_person_id = self.people_data[0]['id']
|
||||
self.show_person_faces(self.people_data[0]['id'], self.people_data[0]['name'])
|
||||
|
||||
def deactivate(self):
|
||||
"""Deactivate the panel"""
|
||||
if self.is_active:
|
||||
self._cleanup()
|
||||
self.is_active = False
|
||||
|
||||
def update_layout(self):
|
||||
"""Update panel layout for responsiveness"""
|
||||
if hasattr(self, 'components') and 'faces_canvas' in self.components:
|
||||
# Update faces canvas scroll region
|
||||
canvas = self.components['faces_canvas']
|
||||
canvas.update_idletasks()
|
||||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
@ -1,466 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PunimTag CLI - Minimal Photo Face Tagger (Refactored)
|
||||
Simple command-line tool for face recognition and photo tagging
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
import argparse
|
||||
import threading
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
# Suppress TensorFlow warnings (must be before DeepFace import)
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# Import our new modules
|
||||
from src.core.config import (
|
||||
DEFAULT_DB_PATH, DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE,
|
||||
DEFAULT_BATCH_SIZE, DEFAULT_PROCESSING_LIMIT
|
||||
)
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.photo_management import PhotoManager
|
||||
from src.core.tag_management import TagManager
|
||||
from src.core.search_stats import SearchStats
|
||||
from src.gui.gui_core import GUICore
|
||||
from src.gui.dashboard_gui import DashboardGUI
|
||||
|
||||
|
||||
class PhotoTagger:
|
||||
"""Main PhotoTagger class - orchestrates all functionality"""
|
||||
|
||||
def __init__(self, db_path: str = DEFAULT_DB_PATH, verbose: int = 0, debug: bool = False):
|
||||
"""Initialize the photo tagger with database and all managers"""
|
||||
self.db_path = db_path
|
||||
self.verbose = verbose
|
||||
self.debug = debug
|
||||
|
||||
# Initialize all managers
|
||||
self.db = DatabaseManager(db_path, verbose)
|
||||
self.face_processor = FaceProcessor(self.db, verbose)
|
||||
self.photo_manager = PhotoManager(self.db, verbose)
|
||||
self.tag_manager = TagManager(self.db, verbose)
|
||||
self.search_stats = SearchStats(self.db, verbose)
|
||||
self.gui_core = GUICore()
|
||||
self.identify_gui = IdentifyGUI(self.db, self.face_processor, verbose)
|
||||
self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose)
|
||||
self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose)
|
||||
self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose)
|
||||
self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, self.tag_manager, verbose)
|
||||
self.dashboard_gui = DashboardGUI(self.gui_core, self.db, self.face_processor, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify, search_stats=self.search_stats, tag_manager=self.tag_manager)
|
||||
|
||||
# Legacy compatibility - expose some methods directly
|
||||
self._db_connection = None
|
||||
self._db_lock = threading.Lock()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources and close connections"""
|
||||
self.face_processor.cleanup_face_crops()
|
||||
self.db.close_db_connection()
|
||||
|
||||
# Database methods (delegated)
|
||||
def get_db_connection(self):
|
||||
"""Get database connection (legacy compatibility)"""
|
||||
return self.db.get_db_connection()
|
||||
|
||||
def close_db_connection(self):
|
||||
"""Close database connection (legacy compatibility)"""
|
||||
self.db.close_db_connection()
|
||||
|
||||
def init_database(self):
|
||||
"""Initialize database (legacy compatibility)"""
|
||||
self.db.init_database()
|
||||
|
||||
# Photo management methods (delegated)
|
||||
def scan_folder(self, folder_path: str, recursive: bool = True) -> int:
|
||||
"""Scan folder for photos and add to database"""
|
||||
return self.photo_manager.scan_folder(folder_path, recursive)
|
||||
|
||||
def _extract_photo_date(self, photo_path: str) -> Optional[str]:
|
||||
"""Extract date taken from photo EXIF data (legacy compatibility)"""
|
||||
return self.photo_manager.extract_photo_date(photo_path)
|
||||
|
||||
# Face processing methods (delegated)
|
||||
def process_faces(self, limit: Optional[int] = None, model: str = DEFAULT_FACE_DETECTION_MODEL, progress_callback=None, stop_event=None) -> int:
|
||||
"""Process unprocessed photos for faces with optional progress and cancellation
|
||||
|
||||
Args:
|
||||
limit: Maximum number of photos to process. If None, process all unprocessed photos.
|
||||
"""
|
||||
return self.face_processor.process_faces(limit, model, progress_callback, stop_event)
|
||||
|
||||
def _extract_face_crop(self, photo_path: str, location: dict, face_id: int) -> str:
|
||||
"""Extract and save individual face crop for identification (legacy compatibility)"""
|
||||
return self.face_processor._extract_face_crop(photo_path, location, face_id)
|
||||
|
||||
def _create_comparison_image(self, unid_crop_path: str, match_crop_path: str, person_name: str, confidence: float) -> str:
|
||||
"""Create a side-by-side comparison image (legacy compatibility)"""
|
||||
return self.face_processor._create_comparison_image(unid_crop_path, match_crop_path, person_name, confidence)
|
||||
|
||||
def _calculate_face_quality_score(self, image, face_location: dict) -> float:
|
||||
"""Calculate face quality score (legacy compatibility)"""
|
||||
return self.face_processor._calculate_face_quality_score(image, face_location)
|
||||
|
||||
def _add_person_encoding(self, person_id: int, face_id: int, encoding, quality_score: float):
|
||||
"""Add a face encoding to a person's encoding collection (legacy compatibility)"""
|
||||
self.face_processor.add_person_encoding(person_id, face_id, encoding, quality_score)
|
||||
|
||||
def _get_person_encodings(self, person_id: int, min_quality: float = 0.3):
|
||||
"""Get all high-quality encodings for a person (legacy compatibility)"""
|
||||
return self.face_processor.get_person_encodings(person_id, min_quality)
|
||||
|
||||
def _update_person_encodings(self, person_id: int):
|
||||
"""Update person encodings when a face is identified (legacy compatibility)"""
|
||||
self.face_processor.update_person_encodings(person_id)
|
||||
|
||||
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
|
||||
"""Calculate adaptive tolerance (legacy compatibility)"""
|
||||
return self.face_processor._calculate_adaptive_tolerance(base_tolerance, face_quality, match_confidence)
|
||||
|
||||
def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None):
|
||||
"""Get similar faces with filtering (legacy compatibility)"""
|
||||
return self.face_processor._get_filtered_similar_faces(face_id, tolerance, include_same_photo, face_status)
|
||||
|
||||
def _filter_unique_faces(self, faces: List[Dict]):
|
||||
"""Filter faces to show only unique ones (legacy compatibility)"""
|
||||
return self.face_processor._filter_unique_faces(faces)
|
||||
|
||||
def _filter_unique_faces_from_list(self, faces_list: List[tuple]):
|
||||
"""Filter face list to show only unique ones (legacy compatibility)"""
|
||||
return self.face_processor._filter_unique_faces_from_list(faces_list)
|
||||
|
||||
def find_similar_faces(self, face_id: int = None, tolerance: float = DEFAULT_FACE_TOLERANCE, include_same_photo: bool = False):
|
||||
"""Find similar faces across all photos"""
|
||||
return self.face_processor.find_similar_faces(face_id, tolerance, include_same_photo)
|
||||
|
||||
def auto_identify_matches(self, tolerance: float = DEFAULT_FACE_TOLERANCE, confirm: bool = True, show_faces: bool = False, include_same_photo: bool = False) -> int:
|
||||
"""Automatically identify faces that match already identified faces using GUI"""
|
||||
return self.auto_match_gui.auto_identify_matches(tolerance, confirm, show_faces, include_same_photo)
|
||||
|
||||
# Tag management methods (delegated)
|
||||
def add_tags(self, photo_pattern: str = None, batch_size: int = DEFAULT_BATCH_SIZE) -> int:
|
||||
"""Add custom tags to photos"""
|
||||
return self.tag_manager.add_tags_to_photos(photo_pattern, batch_size)
|
||||
|
||||
def _deduplicate_tags(self, tag_list):
|
||||
"""Remove duplicate tags from a list (legacy compatibility)"""
|
||||
return self.tag_manager.deduplicate_tags(tag_list)
|
||||
|
||||
def _parse_tags_string(self, tags_string):
|
||||
"""Parse a comma-separated tags string (legacy compatibility)"""
|
||||
return self.tag_manager.parse_tags_string(tags_string)
|
||||
|
||||
def _get_tag_id_by_name(self, tag_name, tag_name_to_id_map):
|
||||
"""Get tag ID by name (legacy compatibility)"""
|
||||
return self.db.get_tag_id_by_name(tag_name, tag_name_to_id_map)
|
||||
|
||||
def _get_tag_name_by_id(self, tag_id, tag_id_to_name_map):
|
||||
"""Get tag name by ID (legacy compatibility)"""
|
||||
return self.db.get_tag_name_by_id(tag_id, tag_id_to_name_map)
|
||||
|
||||
def _load_tag_mappings(self):
|
||||
"""Load tag name to ID and ID to name mappings (legacy compatibility)"""
|
||||
return self.db.load_tag_mappings()
|
||||
|
||||
def _get_existing_tag_ids_for_photo(self, photo_id):
|
||||
"""Get list of tag IDs for a photo (legacy compatibility)"""
|
||||
return self.db.get_existing_tag_ids_for_photo(photo_id)
|
||||
|
||||
def _show_people_list(self, cursor=None):
|
||||
"""Show list of people in database (legacy compatibility)"""
|
||||
return self.db.show_people_list(cursor)
|
||||
|
||||
# Search and statistics methods (delegated)
|
||||
def search_faces(self, person_name: str):
|
||||
"""Search for photos containing a specific person"""
|
||||
return self.search_stats.search_faces(person_name)
|
||||
|
||||
def stats(self):
|
||||
"""Show database statistics"""
|
||||
return self.search_stats.print_statistics()
|
||||
|
||||
# GUI methods (legacy compatibility - these would need to be implemented)
|
||||
def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, tolerance: float = DEFAULT_FACE_TOLERANCE,
|
||||
date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int:
|
||||
"""Interactive face identification with GUI (show_faces is always True)"""
|
||||
return self.identify_gui.identify_faces(batch_size, True, tolerance,
|
||||
date_from, date_to, date_processed_from, date_processed_to)
|
||||
|
||||
def tag_management(self) -> int:
|
||||
"""Tag management GUI"""
|
||||
return self.tag_manager_gui.tag_management()
|
||||
|
||||
def modifyidentified(self) -> int:
|
||||
return self.modify_identified_gui.modifyidentified()
|
||||
|
||||
def searchgui(self) -> int:
|
||||
"""Open the Search GUI."""
|
||||
return self.search_gui.search_gui()
|
||||
|
||||
def dashboard(self) -> int:
|
||||
"""Open the Dashboard GUI (placeholders only)."""
|
||||
return self.dashboard_gui.open()
|
||||
|
||||
# Dashboard callbacks
|
||||
def _dashboard_scan(self, folder_path: str, recursive: bool) -> int:
|
||||
"""Callback to scan a folder from the dashboard."""
|
||||
return self.scan_folder(folder_path, recursive)
|
||||
|
||||
def _dashboard_process(self, limit_value: Optional[int], progress_callback=None, stop_event=None) -> int:
|
||||
"""Callback to process faces from the dashboard with optional limit, progress, cancel."""
|
||||
if limit_value is None:
|
||||
return self.process_faces(progress_callback=progress_callback, stop_event=stop_event)
|
||||
return self.process_faces(limit=limit_value, progress_callback=progress_callback, stop_event=stop_event)
|
||||
|
||||
def _dashboard_identify(self, batch_value: Optional[int]) -> int:
|
||||
"""Callback to identify faces from the dashboard with optional batch (show_faces is always True)."""
|
||||
if batch_value is None:
|
||||
return self.identify_faces()
|
||||
return self.identify_faces(batch_size=batch_value)
|
||||
|
||||
def _setup_window_size_saving(self, root, config_file="gui_config.json"):
|
||||
"""Set up window size saving functionality (legacy compatibility)"""
|
||||
return self.gui_core.setup_window_size_saving(root, config_file)
|
||||
|
||||
def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None):
|
||||
"""Display similar faces in panel (legacy compatibility)"""
|
||||
print("⚠️ Similar faces panel not yet implemented in refactored version")
|
||||
return None
|
||||
|
||||
def _create_photo_icon(self, canvas, photo_path, icon_size=20, icon_x=None, icon_y=None, callback=None):
|
||||
"""Create a small photo icon on a canvas (legacy compatibility)"""
|
||||
return self.gui_core.create_photo_icon(canvas, photo_path, icon_size, icon_x, icon_y, callback)
|
||||
|
||||
def _get_confidence_description(self, confidence_pct: float) -> str:
|
||||
"""Get human-readable confidence description (legacy compatibility)"""
|
||||
return self.face_processor._get_confidence_description(confidence_pct)
|
||||
|
||||
# Cache management (legacy compatibility)
|
||||
def _clear_caches(self):
|
||||
"""Clear all caches to free memory (legacy compatibility)"""
|
||||
self.face_processor._clear_caches()
|
||||
|
||||
def _cleanup_face_crops(self, current_face_crop_path=None):
|
||||
"""Clean up face crop files and caches (legacy compatibility)"""
|
||||
self.face_processor.cleanup_face_crops(current_face_crop_path)
|
||||
|
||||
@property
|
||||
def _face_encoding_cache(self):
|
||||
"""Face encoding cache (legacy compatibility)"""
|
||||
return self.face_processor._face_encoding_cache
|
||||
|
||||
@property
|
||||
def _image_cache(self):
|
||||
"""Image cache (legacy compatibility)"""
|
||||
return self.face_processor._image_cache
|
||||
|
||||
def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None) -> List[Dict]:
|
||||
"""Get similar faces with consistent filtering and sorting logic used by both auto-match and identify"""
|
||||
# Find similar faces using the core function
|
||||
similar_faces_data = self.find_similar_faces(face_id, tolerance=tolerance, include_same_photo=include_same_photo)
|
||||
|
||||
# Filter to only show unidentified faces with confidence filtering
|
||||
filtered_faces = []
|
||||
for face in similar_faces_data:
|
||||
# For auto-match: only filter by database state (keep existing behavior)
|
||||
# For identify: also filter by current session state
|
||||
is_identified_in_db = face.get('person_id') is not None
|
||||
is_identified_in_session = face_status and face.get('face_id') in face_status and face_status[face.get('face_id')] == 'identified'
|
||||
|
||||
# If face_status is provided (identify mode), use both filters
|
||||
# If face_status is None (auto-match mode), only use database filter
|
||||
if face_status is not None:
|
||||
# Identify mode: filter out both database and session identified faces
|
||||
if not is_identified_in_db and not is_identified_in_session:
|
||||
# Calculate confidence percentage
|
||||
confidence_pct, _ = self.face_processor._get_calibrated_confidence(face['distance'])
|
||||
|
||||
# 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, _ = self.face_processor._get_calibrated_confidence(face['distance'])
|
||||
|
||||
# Only include matches with reasonable confidence (at least 40%)
|
||||
if confidence_pct >= 40:
|
||||
filtered_faces.append(face)
|
||||
|
||||
# Sort by confidence (distance) - highest confidence first
|
||||
filtered_faces.sort(key=lambda x: x['distance'])
|
||||
|
||||
return filtered_faces
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI interface"""
|
||||
# Suppress TensorFlow and other deprecation warnings from DeepFace dependencies
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", message="pkg_resources is deprecated", category=UserWarning)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PunimTag CLI - Simple photo face tagger (Refactored)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
photo_tagger_refactored.py scan /path/to/photos # Scan folder for photos
|
||||
photo_tagger_refactored.py process --limit 20 # Process 20 photos for faces
|
||||
photo_tagger_refactored.py identify --batch 10 # Identify 10 faces interactively
|
||||
photo_tagger_refactored.py auto-match # Auto-identify matching faces
|
||||
photo_tagger_refactored.py modifyidentified # Show and Modify identified faces
|
||||
photo_tagger_refactored.py match 15 # Find faces similar to face ID 15
|
||||
photo_tagger_refactored.py tag --pattern "vacation" # Tag photos matching pattern
|
||||
photo_tagger_refactored.py search "John" # Find photos with John
|
||||
photo_tagger_refactored.py tag-manager # Open tag management GUI
|
||||
photo_tagger_refactored.py stats # Show statistics
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('command',
|
||||
choices=['scan', 'process', 'identify', 'tag', 'search', 'search-gui', 'dashboard', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'],
|
||||
help='Command to execute')
|
||||
|
||||
parser.add_argument('target', nargs='?',
|
||||
help='Target folder (scan), person name (search), or pattern (tag)')
|
||||
|
||||
parser.add_argument('--db', default=DEFAULT_DB_PATH,
|
||||
help=f'Database file path (default: {DEFAULT_DB_PATH})')
|
||||
|
||||
parser.add_argument('--limit', type=int, default=DEFAULT_PROCESSING_LIMIT,
|
||||
help=f'Batch size limit for processing (default: {DEFAULT_PROCESSING_LIMIT})')
|
||||
|
||||
parser.add_argument('--batch', type=int, default=DEFAULT_BATCH_SIZE,
|
||||
help=f'Batch size for identification (default: {DEFAULT_BATCH_SIZE})')
|
||||
|
||||
parser.add_argument('--pattern',
|
||||
help='Pattern for filtering photos when tagging')
|
||||
|
||||
parser.add_argument('--model', choices=['hog', 'cnn'], default=DEFAULT_FACE_DETECTION_MODEL,
|
||||
help=f'Face detection model: hog (faster) or cnn (more accurate) (default: {DEFAULT_FACE_DETECTION_MODEL})')
|
||||
|
||||
parser.add_argument('--recursive', action='store_true',
|
||||
help='Scan folders recursively')
|
||||
|
||||
|
||||
parser.add_argument('--tolerance', type=float, default=DEFAULT_FACE_TOLERANCE,
|
||||
help=f'Face matching tolerance (0.0-1.0, lower = stricter, default: {DEFAULT_FACE_TOLERANCE})')
|
||||
|
||||
parser.add_argument('--auto', action='store_true',
|
||||
help='Auto-identify high-confidence matches without confirmation')
|
||||
|
||||
parser.add_argument('--include-twins', action='store_true',
|
||||
help='Include same-photo matching (for twins or multiple instances)')
|
||||
|
||||
parser.add_argument('--date-from',
|
||||
help='Filter by photo taken date (from) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('--date-to',
|
||||
help='Filter by photo taken date (to) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('--date-processed-from',
|
||||
help='Filter by photo processed date (from) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('--date-processed-to',
|
||||
help='Filter by photo processed date (to) in YYYY-MM-DD format')
|
||||
|
||||
parser.add_argument('-v', '--verbose', action='count', default=0,
|
||||
help='Increase verbosity (-v, -vv, -vvv for more detail)')
|
||||
|
||||
parser.add_argument('--debug', action='store_true',
|
||||
help='Enable line-by-line debugging with pdb')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize tagger
|
||||
tagger = PhotoTagger(args.db, args.verbose, args.debug)
|
||||
|
||||
try:
|
||||
if args.command == 'scan':
|
||||
if not args.target:
|
||||
print("❌ Please specify a folder to scan")
|
||||
return 1
|
||||
|
||||
# Normalize path to absolute path
|
||||
from path_utils import normalize_path
|
||||
try:
|
||||
normalized_path = normalize_path(args.target)
|
||||
print(f"📁 Scanning folder: {normalized_path}")
|
||||
tagger.scan_folder(normalized_path, args.recursive)
|
||||
except ValueError as e:
|
||||
print(f"❌ Invalid path: {e}")
|
||||
return 1
|
||||
|
||||
elif args.command == 'process':
|
||||
tagger.process_faces(args.limit, args.model)
|
||||
|
||||
elif args.command == 'identify':
|
||||
tagger.identify_faces(args.batch, args.tolerance,
|
||||
args.date_from, args.date_to,
|
||||
args.date_processed_from, args.date_processed_to)
|
||||
|
||||
elif args.command == 'tag':
|
||||
tagger.add_tags(args.pattern or args.target, args.batch)
|
||||
|
||||
elif args.command == 'search':
|
||||
if not args.target:
|
||||
print("❌ Please specify a person name to search for")
|
||||
return 1
|
||||
tagger.search_faces(args.target)
|
||||
|
||||
elif args.command == 'search-gui':
|
||||
tagger.searchgui()
|
||||
|
||||
elif args.command == 'dashboard':
|
||||
tagger.dashboard()
|
||||
|
||||
elif args.command == 'stats':
|
||||
tagger.stats()
|
||||
|
||||
elif args.command == 'match':
|
||||
if args.target and args.target.isdigit():
|
||||
face_id = int(args.target)
|
||||
matches = tagger.find_similar_faces(face_id, args.tolerance)
|
||||
if matches:
|
||||
print(f"\n🎯 Found {len(matches)} similar faces:")
|
||||
for match in matches:
|
||||
person_name = "Unknown" if match.get('person_id') is None else f"Person ID {match.get('person_id')}"
|
||||
print(f" 📸 {match.get('filename', 'Unknown')} - {person_name} (confidence: {(1-match.get('distance', 1)):.1%})")
|
||||
else:
|
||||
print("🔍 No similar faces found")
|
||||
else:
|
||||
print("❌ Please specify a face ID number to find matches for")
|
||||
|
||||
elif args.command == 'auto-match':
|
||||
show_faces = getattr(args, 'show_faces', False)
|
||||
include_twins = getattr(args, 'include_twins', False)
|
||||
tagger.auto_identify_matches(args.tolerance, not args.auto, show_faces, include_twins)
|
||||
|
||||
elif args.command == 'modifyidentified':
|
||||
tagger.modifyidentified()
|
||||
|
||||
elif args.command == 'tag-manager':
|
||||
tagger.tag_management()
|
||||
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Interrupted by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
if args.debug:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
# Always cleanup resources
|
||||
tagger.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Launcher script for PunimTag Dashboard
|
||||
Adds project root to Python path and launches the dashboard
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
# Suppress TensorFlow warnings (must be before DeepFace import)
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# Add project root to Python path
|
||||
project_root = Path(__file__).parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# Now import required modules
|
||||
from src.gui.dashboard_gui import DashboardGUI
|
||||
from src.gui.gui_core import GUICore
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.photo_management import PhotoManager
|
||||
from src.core.tag_management import TagManager
|
||||
from src.core.search_stats import SearchStats
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Initialize all required components
|
||||
gui_core = GUICore()
|
||||
db_manager = DatabaseManager(DEFAULT_DB_PATH, verbose=0)
|
||||
# Initialize face_processor without detector/model (will be updated by GUI)
|
||||
face_processor = FaceProcessor(db_manager, verbose=0)
|
||||
photo_manager = PhotoManager(db_manager, verbose=0)
|
||||
tag_manager = TagManager(db_manager, verbose=0)
|
||||
search_stats = SearchStats(db_manager)
|
||||
|
||||
# Define callback functions for scan and process operations
|
||||
def on_scan(folder, recursive):
|
||||
"""Callback for scanning photos"""
|
||||
return photo_manager.scan_folder(folder, recursive)
|
||||
|
||||
def on_process(limit=None, stop_event=None, progress_callback=None,
|
||||
detector_backend=None, model_name=None):
|
||||
"""Callback for processing faces with DeepFace settings"""
|
||||
# Update face_processor settings if provided
|
||||
if detector_backend:
|
||||
face_processor.detector_backend = detector_backend
|
||||
if model_name:
|
||||
face_processor.model_name = model_name
|
||||
|
||||
return face_processor.process_faces(
|
||||
limit=limit, # Pass None if no limit is specified
|
||||
stop_event=stop_event,
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
# Create and run dashboard
|
||||
app = DashboardGUI(
|
||||
gui_core=gui_core,
|
||||
db_manager=db_manager,
|
||||
face_processor=face_processor,
|
||||
on_scan=on_scan,
|
||||
on_process=on_process,
|
||||
search_stats=search_stats,
|
||||
tag_manager=tag_manager
|
||||
)
|
||||
app.open()
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Run DeepFace GUI Test Application
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Activate virtual environment if it exists
|
||||
if [ -d "venv" ]; then
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# Run the GUI application
|
||||
python test_deepface_gui.py
|
||||
@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Show large thumbnails directly from the test images
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from PIL import Image, ImageTk
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Suppress TensorFlow warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
|
||||
def show_large_thumbnails():
|
||||
root = tk.Tk()
|
||||
root.title("Large Thumbnails Demo")
|
||||
root.geometry("1600x1000")
|
||||
|
||||
frame = ttk.Frame(root, padding="20")
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Get test images
|
||||
test_folder = Path("demo_photos/testdeepface/")
|
||||
if not test_folder.exists():
|
||||
ttk.Label(frame, text="Test folder not found: demo_photos/testdeepface/",
|
||||
font=("Arial", 16, "bold"), foreground="red").pack(pady=20)
|
||||
root.mainloop()
|
||||
return
|
||||
|
||||
image_files = list(test_folder.glob("*.jpg"))
|
||||
|
||||
if not image_files:
|
||||
ttk.Label(frame, text="No images found in test folder",
|
||||
font=("Arial", 16, "bold"), foreground="red").pack(pady=20)
|
||||
root.mainloop()
|
||||
return
|
||||
|
||||
# Show first few images with large thumbnails
|
||||
ttk.Label(frame, text="Large Thumbnails Demo (400x400 pixels)",
|
||||
font=("Arial", 18, "bold")).pack(pady=10)
|
||||
|
||||
for i, img_path in enumerate(image_files[:4]): # Show first 4 images
|
||||
try:
|
||||
# Load and resize image
|
||||
image = Image.open(img_path)
|
||||
image.thumbnail((400, 400), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(image)
|
||||
|
||||
# Create frame for this image
|
||||
img_frame = ttk.Frame(frame)
|
||||
img_frame.pack(pady=10)
|
||||
|
||||
# Image label
|
||||
img_label = ttk.Label(img_frame, image=photo)
|
||||
img_label.image = photo # Keep a reference
|
||||
img_label.pack()
|
||||
|
||||
# Text label
|
||||
text_label = ttk.Label(img_frame, text=f"{img_path.name} (400x400)",
|
||||
font=("Arial", 12, "bold"))
|
||||
text_label.pack()
|
||||
|
||||
except Exception as e:
|
||||
ttk.Label(frame, text=f"Error loading {img_path.name}: {e}",
|
||||
font=("Arial", 12), foreground="red").pack()
|
||||
|
||||
# Add instruction
|
||||
instruction = ttk.Label(frame, text="These are 400x400 pixel thumbnails - the same size the GUI will use!",
|
||||
font=("Arial", 14, "bold"), foreground="green")
|
||||
instruction.pack(pady=20)
|
||||
|
||||
root.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
show_large_thumbnails()
|
||||
@ -1,724 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DeepFace GUI Test Application
|
||||
|
||||
GUI version of test_deepface_only.py that shows face comparison results
|
||||
with left panel for reference faces and right panel for comparison faces with confidence scores.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
import numpy as np
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
# Suppress TensorFlow warnings and CUDA errors
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# DeepFace library
|
||||
from deepface import DeepFace
|
||||
|
||||
# Face recognition library
|
||||
import face_recognition
|
||||
|
||||
# Supported image formats
|
||||
SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
|
||||
|
||||
|
||||
class FaceComparisonGUI:
|
||||
"""GUI application for DeepFace face comparison testing"""
|
||||
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("Face Comparison Test - DeepFace vs face_recognition")
|
||||
self.root.geometry("2000x1000")
|
||||
self.root.minsize(1200, 800)
|
||||
|
||||
# Data storage
|
||||
self.deepface_faces = [] # DeepFace faces from all images
|
||||
self.facerec_faces = [] # face_recognition faces from all images
|
||||
self.deepface_similarities = [] # DeepFace similarity results
|
||||
self.facerec_similarities = [] # face_recognition similarity results
|
||||
self.processing_times = {} # Timing information for each photo
|
||||
|
||||
# GUI components
|
||||
self.setup_gui()
|
||||
|
||||
def setup_gui(self):
|
||||
"""Set up the GUI layout"""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.root, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
# Configure grid weights
|
||||
self.root.columnconfigure(0, weight=1)
|
||||
self.root.rowconfigure(0, weight=1)
|
||||
main_frame.columnconfigure(0, weight=1)
|
||||
main_frame.rowconfigure(2, weight=1) # Make the content area expandable
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(main_frame, text="Face Comparison Test - DeepFace vs face_recognition",
|
||||
font=("Arial", 16, "bold"))
|
||||
title_label.grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||
|
||||
# Control panel
|
||||
control_frame = ttk.Frame(main_frame)
|
||||
control_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 5))
|
||||
|
||||
# Folder selection
|
||||
ttk.Label(control_frame, text="Test Folder:").grid(row=0, column=0, padx=(0, 5))
|
||||
self.folder_var = tk.StringVar(value="demo_photos/testdeepface/")
|
||||
folder_entry = ttk.Entry(control_frame, textvariable=self.folder_var, width=40)
|
||||
folder_entry.grid(row=0, column=1, padx=(0, 5))
|
||||
|
||||
browse_btn = ttk.Button(control_frame, text="Browse", command=self.browse_folder)
|
||||
browse_btn.grid(row=0, column=2, padx=(0, 10))
|
||||
|
||||
# Reference image selection
|
||||
ttk.Label(control_frame, text="Reference Image:").grid(row=0, column=3, padx=(10, 5))
|
||||
self.reference_var = tk.StringVar(value="2019-11-22_0011.JPG")
|
||||
reference_entry = ttk.Entry(control_frame, textvariable=self.reference_var, width=20)
|
||||
reference_entry.grid(row=0, column=4, padx=(0, 5))
|
||||
|
||||
# Face detector selection
|
||||
ttk.Label(control_frame, text="Detector:").grid(row=0, column=5, padx=(10, 5))
|
||||
self.detector_var = tk.StringVar(value="retinaface")
|
||||
detector_combo = ttk.Combobox(control_frame, textvariable=self.detector_var,
|
||||
values=["retinaface", "mtcnn", "opencv", "ssd"],
|
||||
state="readonly", width=10)
|
||||
detector_combo.grid(row=0, column=6, padx=(0, 5))
|
||||
|
||||
# Similarity threshold
|
||||
ttk.Label(control_frame, text="Threshold:").grid(row=0, column=7, padx=(10, 5))
|
||||
self.threshold_var = tk.StringVar(value="60")
|
||||
threshold_entry = ttk.Entry(control_frame, textvariable=self.threshold_var, width=8)
|
||||
threshold_entry.grid(row=0, column=8, padx=(0, 5))
|
||||
|
||||
# Process button
|
||||
process_btn = ttk.Button(control_frame, text="Process Images",
|
||||
command=self.process_images, style="Accent.TButton")
|
||||
process_btn.grid(row=0, column=9, padx=(10, 0))
|
||||
|
||||
# Progress bar
|
||||
self.progress_var = tk.DoubleVar()
|
||||
self.progress_bar = ttk.Progressbar(control_frame, variable=self.progress_var,
|
||||
maximum=100, length=200)
|
||||
self.progress_bar.grid(row=1, column=0, columnspan=10, sticky=(tk.W, tk.E), pady=(5, 0))
|
||||
|
||||
# Status label
|
||||
self.status_var = tk.StringVar(value="Ready to process images")
|
||||
status_label = ttk.Label(control_frame, textvariable=self.status_var)
|
||||
status_label.grid(row=2, column=0, columnspan=10, pady=(5, 0))
|
||||
|
||||
# Main content area with three panels
|
||||
content_frame = ttk.Frame(main_frame)
|
||||
content_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(10, 0))
|
||||
content_frame.columnconfigure(0, weight=1)
|
||||
content_frame.columnconfigure(1, weight=1)
|
||||
content_frame.columnconfigure(2, weight=1)
|
||||
content_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Left panel - DeepFace results
|
||||
left_frame = ttk.LabelFrame(content_frame, text="DeepFace Results", padding="5")
|
||||
left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
|
||||
left_frame.columnconfigure(0, weight=1)
|
||||
left_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Left panel scrollable area
|
||||
left_canvas = tk.Canvas(left_frame, bg="white")
|
||||
left_scrollbar = ttk.Scrollbar(left_frame, orient="vertical", command=left_canvas.yview)
|
||||
self.left_scrollable_frame = ttk.Frame(left_canvas)
|
||||
|
||||
self.left_scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: left_canvas.configure(scrollregion=left_canvas.bbox("all"))
|
||||
)
|
||||
|
||||
left_canvas.create_window((0, 0), window=self.left_scrollable_frame, anchor="nw")
|
||||
left_canvas.configure(yscrollcommand=left_scrollbar.set)
|
||||
|
||||
left_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
left_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
|
||||
# Middle panel - face_recognition results
|
||||
middle_frame = ttk.LabelFrame(content_frame, text="face_recognition Results", padding="5")
|
||||
middle_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 5))
|
||||
middle_frame.columnconfigure(0, weight=1)
|
||||
middle_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Right panel - Comparison Results
|
||||
right_frame = ttk.LabelFrame(content_frame, text="Comparison Results", padding="5")
|
||||
right_frame.grid(row=0, column=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
|
||||
right_frame.columnconfigure(0, weight=1)
|
||||
right_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Middle panel scrollable area
|
||||
middle_canvas = tk.Canvas(middle_frame, bg="white")
|
||||
middle_scrollbar = ttk.Scrollbar(middle_frame, orient="vertical", command=middle_canvas.yview)
|
||||
self.middle_scrollable_frame = ttk.Frame(middle_canvas)
|
||||
|
||||
self.middle_scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: middle_canvas.configure(scrollregion=middle_canvas.bbox("all"))
|
||||
)
|
||||
|
||||
middle_canvas.create_window((0, 0), window=self.middle_scrollable_frame, anchor="nw")
|
||||
middle_canvas.configure(yscrollcommand=middle_scrollbar.set)
|
||||
|
||||
middle_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
middle_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
|
||||
# Right panel scrollable area
|
||||
right_canvas = tk.Canvas(right_frame, bg="white")
|
||||
right_scrollbar = ttk.Scrollbar(right_frame, orient="vertical", command=right_canvas.yview)
|
||||
self.right_scrollable_frame = ttk.Frame(right_canvas)
|
||||
|
||||
self.right_scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: right_canvas.configure(scrollregion=right_canvas.bbox("all"))
|
||||
)
|
||||
|
||||
right_canvas.create_window((0, 0), window=self.right_scrollable_frame, anchor="nw")
|
||||
right_canvas.configure(yscrollcommand=right_scrollbar.set)
|
||||
|
||||
right_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
right_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
||||
|
||||
# Bind mousewheel to all canvases
|
||||
def _on_mousewheel(event):
|
||||
left_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
|
||||
middle_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
|
||||
right_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
|
||||
|
||||
left_canvas.bind("<MouseWheel>", _on_mousewheel)
|
||||
middle_canvas.bind("<MouseWheel>", _on_mousewheel)
|
||||
right_canvas.bind("<MouseWheel>", _on_mousewheel)
|
||||
|
||||
def browse_folder(self):
|
||||
"""Browse for folder containing test images"""
|
||||
folder = filedialog.askdirectory(initialdir="demo_photos/")
|
||||
if folder:
|
||||
self.folder_var.set(folder)
|
||||
|
||||
def update_status(self, message: str):
|
||||
"""Update status message"""
|
||||
self.status_var.set(message)
|
||||
self.root.update_idletasks()
|
||||
|
||||
def update_progress(self, value: float):
|
||||
"""Update progress bar"""
|
||||
self.progress_var.set(value)
|
||||
self.root.update_idletasks()
|
||||
|
||||
def get_image_files(self, folder_path: str) -> List[str]:
|
||||
"""Get all supported image files from folder"""
|
||||
folder = Path(folder_path)
|
||||
if not folder.exists():
|
||||
raise FileNotFoundError(f"Folder not found: {folder_path}")
|
||||
|
||||
image_files = []
|
||||
for file_path in folder.rglob("*"):
|
||||
if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_FORMATS:
|
||||
image_files.append(str(file_path))
|
||||
|
||||
return sorted(image_files)
|
||||
|
||||
def process_with_deepface(self, image_path: str, detector: str = "retinaface") -> Dict:
|
||||
"""Process image with DeepFace library"""
|
||||
try:
|
||||
# Use DeepFace.represent() to get proper face detection with regions
|
||||
# Using selected detector for face detection
|
||||
results = DeepFace.represent(
|
||||
img_path=image_path,
|
||||
model_name='ArcFace', # Best accuracy model
|
||||
detector_backend=detector, # User-selected detector
|
||||
enforce_detection=False, # Don't fail if no faces
|
||||
align=True # Face alignment for better accuracy
|
||||
)
|
||||
|
||||
if not results:
|
||||
print(f"No faces found in {Path(image_path).name}")
|
||||
return {'faces': [], 'encodings': []}
|
||||
|
||||
print(f"Found {len(results)} faces in {Path(image_path).name}")
|
||||
|
||||
# Convert to our format
|
||||
faces = []
|
||||
encodings = []
|
||||
|
||||
for i, result in enumerate(results):
|
||||
try:
|
||||
# Extract face region info from DeepFace result
|
||||
# DeepFace uses 'facial_area' instead of 'region'
|
||||
facial_area = result.get('facial_area', {})
|
||||
face_confidence = result.get('face_confidence', 0.0)
|
||||
|
||||
# Create face data with proper bounding box
|
||||
face_data = {
|
||||
'image_path': image_path,
|
||||
'face_id': f"df_{Path(image_path).stem}_{i}",
|
||||
'location': (facial_area.get('y', 0), facial_area.get('x', 0) + facial_area.get('w', 0),
|
||||
facial_area.get('y', 0) + facial_area.get('h', 0), facial_area.get('x', 0)),
|
||||
'bbox': facial_area,
|
||||
'encoding': np.array(result['embedding']),
|
||||
'confidence': face_confidence
|
||||
}
|
||||
faces.append(face_data)
|
||||
encodings.append(np.array(result['embedding']))
|
||||
|
||||
print(f"Face {i}: facial_area={facial_area}, confidence={face_confidence:.2f}, embedding shape={np.array(result['embedding']).shape}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing face {i}: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
'faces': faces,
|
||||
'encodings': encodings
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"DeepFace error on {image_path}: {e}")
|
||||
return {'faces': [], 'encodings': []}
|
||||
|
||||
def process_with_face_recognition(self, image_path: str) -> Dict:
|
||||
"""Process image with face_recognition library"""
|
||||
try:
|
||||
# Load image
|
||||
image = face_recognition.load_image_file(image_path)
|
||||
|
||||
# Find face locations
|
||||
face_locations = face_recognition.face_locations(image, model="hog") # Use HOG model for speed
|
||||
|
||||
if not face_locations:
|
||||
print(f"No faces found in {Path(image_path).name} (face_recognition)")
|
||||
return {'faces': [], 'encodings': []}
|
||||
|
||||
print(f"Found {len(face_locations)} faces in {Path(image_path).name} (face_recognition)")
|
||||
|
||||
# Get face encodings
|
||||
face_encodings = face_recognition.face_encodings(image, face_locations)
|
||||
|
||||
# Convert to our format
|
||||
faces = []
|
||||
encodings = []
|
||||
|
||||
for i, (face_location, face_encoding) in enumerate(zip(face_locations, face_encodings)):
|
||||
try:
|
||||
# DeepFace returns {x, y, w, h} format
|
||||
if isinstance(face_location, dict):
|
||||
x = face_location.get('x', 0)
|
||||
y = face_location.get('y', 0)
|
||||
w = face_location.get('w', 0)
|
||||
h = face_location.get('h', 0)
|
||||
top, right, bottom, left = y, x + w, y + h, x
|
||||
else:
|
||||
# Legacy format - should not be used
|
||||
top, right, bottom, left = face_location
|
||||
|
||||
# Create face data with proper bounding box
|
||||
face_data = {
|
||||
'image_path': image_path,
|
||||
'face_id': f"fr_{Path(image_path).stem}_{i}",
|
||||
'location': face_location,
|
||||
'bbox': {'x': left, 'y': top, 'w': right - left, 'h': bottom - top},
|
||||
'encoding': np.array(face_encoding),
|
||||
'confidence': 1.0 # face_recognition doesn't provide confidence scores
|
||||
}
|
||||
faces.append(face_data)
|
||||
encodings.append(np.array(face_encoding))
|
||||
|
||||
print(f"Face {i}: location={face_location}, encoding shape={np.array(face_encoding).shape}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing face {i}: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
'faces': faces,
|
||||
'encodings': encodings
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"face_recognition error on {image_path}: {e}")
|
||||
return {'faces': [], 'encodings': []}
|
||||
|
||||
def extract_face_thumbnail(self, face_data: Dict, size: Tuple[int, int] = (150, 150)) -> ImageTk.PhotoImage:
|
||||
"""Extract face thumbnail from image"""
|
||||
try:
|
||||
# Load original image
|
||||
image = Image.open(face_data['image_path'])
|
||||
|
||||
# Extract face region
|
||||
bbox = face_data['bbox']
|
||||
left = bbox.get('x', 0)
|
||||
top = bbox.get('y', 0)
|
||||
right = left + bbox.get('w', 0)
|
||||
bottom = top + bbox.get('h', 0)
|
||||
|
||||
# Add padding
|
||||
padding = 20
|
||||
left = max(0, left - padding)
|
||||
top = max(0, top - padding)
|
||||
right = min(image.width, right + padding)
|
||||
bottom = min(image.height, bottom + padding)
|
||||
|
||||
# Crop face
|
||||
face_crop = image.crop((left, top, right, bottom))
|
||||
|
||||
# FORCE resize to exact size (don't use thumbnail which maintains aspect ratio)
|
||||
face_crop = face_crop.resize(size, Image.Resampling.LANCZOS)
|
||||
|
||||
print(f"DEBUG: Created thumbnail of size {face_crop.size} for {face_data['face_id']}")
|
||||
|
||||
# Convert to PhotoImage
|
||||
return ImageTk.PhotoImage(face_crop)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting thumbnail for {face_data['face_id']}: {e}")
|
||||
# Return a placeholder image
|
||||
placeholder = Image.new('RGB', size, color='lightgray')
|
||||
return ImageTk.PhotoImage(placeholder)
|
||||
|
||||
def calculate_face_similarity(self, encoding1: np.ndarray, encoding2: np.ndarray) -> float:
|
||||
"""Calculate similarity between two face encodings using cosine similarity"""
|
||||
try:
|
||||
# Ensure encodings are numpy arrays
|
||||
enc1 = np.array(encoding1).flatten()
|
||||
enc2 = np.array(encoding2).flatten()
|
||||
|
||||
# Check if encodings have the same length
|
||||
if len(enc1) != len(enc2):
|
||||
print(f"Warning: Encoding length mismatch: {len(enc1)} vs {len(enc2)}")
|
||||
return 0.0
|
||||
|
||||
# Normalize encodings
|
||||
enc1_norm = enc1 / (np.linalg.norm(enc1) + 1e-8) # Add small epsilon to avoid division by zero
|
||||
enc2_norm = enc2 / (np.linalg.norm(enc2) + 1e-8)
|
||||
|
||||
# Calculate cosine similarity
|
||||
cosine_sim = np.dot(enc1_norm, enc2_norm)
|
||||
|
||||
# Clamp cosine similarity to valid range [-1, 1]
|
||||
cosine_sim = np.clip(cosine_sim, -1.0, 1.0)
|
||||
|
||||
# Convert to confidence percentage (0-100)
|
||||
# For face recognition, we typically want values between 0-100%
|
||||
# where higher values mean more similar faces
|
||||
confidence = max(0, min(100, (cosine_sim + 1) * 50)) # Scale from [-1,1] to [0,100]
|
||||
|
||||
return confidence
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calculating similarity: {e}")
|
||||
return 0.0
|
||||
|
||||
def process_images(self):
|
||||
"""Process all images and perform face comparison"""
|
||||
try:
|
||||
# Clear previous results
|
||||
self.deepface_faces = []
|
||||
self.facerec_faces = []
|
||||
self.deepface_similarities = []
|
||||
self.facerec_similarities = []
|
||||
self.processing_times = {}
|
||||
|
||||
# Clear GUI panels
|
||||
for widget in self.left_scrollable_frame.winfo_children():
|
||||
widget.destroy()
|
||||
for widget in self.middle_scrollable_frame.winfo_children():
|
||||
widget.destroy()
|
||||
for widget in self.right_scrollable_frame.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
folder_path = self.folder_var.get()
|
||||
threshold = float(self.threshold_var.get())
|
||||
|
||||
if not folder_path:
|
||||
messagebox.showerror("Error", "Please specify folder path")
|
||||
return
|
||||
|
||||
self.update_status("Getting image files...")
|
||||
self.update_progress(10)
|
||||
|
||||
# Get all image files
|
||||
image_files = self.get_image_files(folder_path)
|
||||
if not image_files:
|
||||
messagebox.showerror("Error", "No image files found in the specified folder")
|
||||
return
|
||||
|
||||
# Get selected detector
|
||||
detector = self.detector_var.get()
|
||||
|
||||
self.update_status(f"Processing all images with both DeepFace and face_recognition...")
|
||||
self.update_progress(20)
|
||||
|
||||
# Process all images with both libraries
|
||||
for i, image_path in enumerate(image_files):
|
||||
filename = Path(image_path).name
|
||||
self.update_status(f"Processing {filename}...")
|
||||
progress = 20 + (i / len(image_files)) * 50
|
||||
self.update_progress(progress)
|
||||
|
||||
# Process with DeepFace
|
||||
start_time = time.time()
|
||||
deepface_result = self.process_with_deepface(image_path, detector)
|
||||
deepface_time = time.time() - start_time
|
||||
|
||||
# Process with face_recognition
|
||||
start_time = time.time()
|
||||
facerec_result = self.process_with_face_recognition(image_path)
|
||||
facerec_time = time.time() - start_time
|
||||
|
||||
# Store timing information
|
||||
self.processing_times[filename] = {
|
||||
'deepface_time': deepface_time,
|
||||
'facerec_time': facerec_time,
|
||||
'total_time': deepface_time + facerec_time
|
||||
}
|
||||
|
||||
# Store results
|
||||
self.deepface_faces.extend(deepface_result['faces'])
|
||||
self.facerec_faces.extend(facerec_result['faces'])
|
||||
|
||||
print(f"Processed {filename}: DeepFace={deepface_time:.2f}s, face_recognition={facerec_time:.2f}s")
|
||||
|
||||
if not self.deepface_faces and not self.facerec_faces:
|
||||
messagebox.showwarning("Warning", "No faces found in any images")
|
||||
return
|
||||
|
||||
self.update_status("Calculating face similarities...")
|
||||
self.update_progress(75)
|
||||
|
||||
# Calculate similarities for DeepFace
|
||||
for i, face1 in enumerate(self.deepface_faces):
|
||||
similarities = []
|
||||
for j, face2 in enumerate(self.deepface_faces):
|
||||
if i != j: # Don't compare face with itself
|
||||
confidence = self.calculate_face_similarity(
|
||||
face1['encoding'], face2['encoding']
|
||||
)
|
||||
if confidence >= threshold: # Only include faces above threshold
|
||||
similarities.append({
|
||||
'face': face2,
|
||||
'confidence': confidence
|
||||
})
|
||||
|
||||
# Sort by confidence (highest first)
|
||||
similarities.sort(key=lambda x: x['confidence'], reverse=True)
|
||||
self.deepface_similarities.append({
|
||||
'face': face1,
|
||||
'similarities': similarities
|
||||
})
|
||||
|
||||
# Calculate similarities for face_recognition
|
||||
for i, face1 in enumerate(self.facerec_faces):
|
||||
similarities = []
|
||||
for j, face2 in enumerate(self.facerec_faces):
|
||||
if i != j: # Don't compare face with itself
|
||||
confidence = self.calculate_face_similarity(
|
||||
face1['encoding'], face2['encoding']
|
||||
)
|
||||
if confidence >= threshold: # Only include faces above threshold
|
||||
similarities.append({
|
||||
'face': face2,
|
||||
'confidence': confidence
|
||||
})
|
||||
|
||||
# Sort by confidence (highest first)
|
||||
similarities.sort(key=lambda x: x['confidence'], reverse=True)
|
||||
self.facerec_similarities.append({
|
||||
'face': face1,
|
||||
'similarities': similarities
|
||||
})
|
||||
|
||||
self.update_status("Displaying results...")
|
||||
self.update_progress(95)
|
||||
|
||||
# Display results in GUI
|
||||
self.display_results()
|
||||
|
||||
total_deepface_faces = len(self.deepface_faces)
|
||||
total_facerec_faces = len(self.facerec_faces)
|
||||
avg_deepface_time = sum(t['deepface_time'] for t in self.processing_times.values()) / len(self.processing_times)
|
||||
avg_facerec_time = sum(t['facerec_time'] for t in self.processing_times.values()) / len(self.processing_times)
|
||||
|
||||
self.update_status(f"Complete! DeepFace: {total_deepface_faces} faces ({avg_deepface_time:.2f}s avg), face_recognition: {total_facerec_faces} faces ({avg_facerec_time:.2f}s avg)")
|
||||
self.update_progress(100)
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Processing failed: {str(e)}")
|
||||
self.update_status("Error occurred during processing")
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def display_results(self):
|
||||
"""Display the face comparison results in the GUI panels"""
|
||||
# Display DeepFace results in left panel
|
||||
self.display_library_results(self.deepface_similarities, self.left_scrollable_frame, "DeepFace")
|
||||
|
||||
# Display face_recognition results in middle panel
|
||||
self.display_library_results(self.facerec_similarities, self.middle_scrollable_frame, "face_recognition")
|
||||
|
||||
# Display timing comparison in right panel
|
||||
self.display_timing_comparison()
|
||||
|
||||
def display_library_results(self, similarities_list: List[Dict], parent_frame, library_name: str):
|
||||
"""Display results for a specific library"""
|
||||
for i, result in enumerate(similarities_list):
|
||||
face = result['face']
|
||||
|
||||
# Create frame for this face
|
||||
face_frame = ttk.Frame(parent_frame)
|
||||
face_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5, padx=5)
|
||||
|
||||
# Face thumbnail
|
||||
thumbnail = self.extract_face_thumbnail(face, size=(80, 80))
|
||||
thumbnail_label = ttk.Label(face_frame, image=thumbnail)
|
||||
thumbnail_label.image = thumbnail # Keep a reference
|
||||
thumbnail_label.grid(row=0, column=0, padx=5, pady=5)
|
||||
|
||||
# Face info
|
||||
info_frame = ttk.Frame(face_frame)
|
||||
info_frame.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5)
|
||||
|
||||
ttk.Label(info_frame, text=f"Face {i+1}", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky=tk.W, pady=1)
|
||||
ttk.Label(info_frame, text=f"ID: {face['face_id']}", font=("Arial", 8)).grid(row=1, column=0, sticky=tk.W, pady=1)
|
||||
ttk.Label(info_frame, text=f"Image: {Path(face['image_path']).name}", font=("Arial", 8)).grid(row=2, column=0, sticky=tk.W, pady=1)
|
||||
|
||||
# Show number of similar faces
|
||||
similar_count = len(result['similarities'])
|
||||
ttk.Label(info_frame, text=f"Similar: {similar_count}", font=("Arial", 8, "bold")).grid(row=3, column=0, sticky=tk.W, pady=1)
|
||||
|
||||
def display_timing_comparison(self):
|
||||
"""Display timing comparison between libraries"""
|
||||
if not self.processing_times:
|
||||
return
|
||||
|
||||
# Create summary frame
|
||||
summary_frame = ttk.LabelFrame(self.right_scrollable_frame, text="Processing Times Summary")
|
||||
summary_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5, padx=5)
|
||||
|
||||
# Calculate averages
|
||||
total_deepface_time = sum(t['deepface_time'] for t in self.processing_times.values())
|
||||
total_facerec_time = sum(t['facerec_time'] for t in self.processing_times.values())
|
||||
avg_deepface_time = total_deepface_time / len(self.processing_times)
|
||||
avg_facerec_time = total_facerec_time / len(self.processing_times)
|
||||
|
||||
# Summary statistics
|
||||
ttk.Label(summary_frame, text=f"Total Images: {len(self.processing_times)}", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky=tk.W, pady=2)
|
||||
ttk.Label(summary_frame, text=f"DeepFace Avg: {avg_deepface_time:.2f}s", font=("Arial", 9)).grid(row=1, column=0, sticky=tk.W, pady=1)
|
||||
ttk.Label(summary_frame, text=f"face_recognition Avg: {avg_facerec_time:.2f}s", font=("Arial", 9)).grid(row=2, column=0, sticky=tk.W, pady=1)
|
||||
|
||||
speed_ratio = avg_deepface_time / avg_facerec_time if avg_facerec_time > 0 else 0
|
||||
if speed_ratio > 1:
|
||||
faster_lib = "face_recognition"
|
||||
speed_text = f"{speed_ratio:.1f}x faster"
|
||||
else:
|
||||
faster_lib = "DeepFace"
|
||||
speed_text = f"{1/speed_ratio:.1f}x faster"
|
||||
|
||||
ttk.Label(summary_frame, text=f"{faster_lib} is {speed_text}", font=("Arial", 9, "bold"), foreground="green").grid(row=3, column=0, sticky=tk.W, pady=2)
|
||||
|
||||
# Individual photo timings
|
||||
timing_frame = ttk.LabelFrame(self.right_scrollable_frame, text="Per-Photo Timing")
|
||||
timing_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=5, padx=5)
|
||||
|
||||
row = 0
|
||||
for filename, times in sorted(self.processing_times.items()):
|
||||
ttk.Label(timing_frame, text=f"{filename[:20]}...", font=("Arial", 8)).grid(row=row, column=0, sticky=tk.W, pady=1)
|
||||
ttk.Label(timing_frame, text=f"DF: {times['deepface_time']:.2f}s", font=("Arial", 8)).grid(row=row, column=1, sticky=tk.W, pady=1, padx=(5,0))
|
||||
ttk.Label(timing_frame, text=f"FR: {times['facerec_time']:.2f}s", font=("Arial", 8)).grid(row=row, column=2, sticky=tk.W, pady=1, padx=(5,0))
|
||||
row += 1
|
||||
|
||||
def display_comparison_faces(self, ref_index: int, similarities: List[Dict]):
|
||||
"""Display comparison faces for a specific reference face"""
|
||||
# Create frame for this reference face's comparisons
|
||||
comp_frame = ttk.LabelFrame(self.right_scrollable_frame,
|
||||
text=f"Matches for Reference Face {ref_index + 1}")
|
||||
comp_frame.grid(row=ref_index, column=0, sticky=(tk.W, tk.E), pady=10, padx=10)
|
||||
|
||||
# Display top matches (limit to avoid too much clutter)
|
||||
max_matches = min(8, len(similarities))
|
||||
|
||||
for i in range(max_matches):
|
||||
sim_data = similarities[i]
|
||||
face = sim_data['face']
|
||||
confidence = sim_data['confidence']
|
||||
|
||||
# Create frame for this comparison face
|
||||
face_frame = ttk.Frame(comp_frame)
|
||||
face_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
|
||||
# Face thumbnail
|
||||
thumbnail = self.extract_face_thumbnail(face, size=(120, 120))
|
||||
thumbnail_label = ttk.Label(face_frame, image=thumbnail)
|
||||
thumbnail_label.image = thumbnail # Keep a reference
|
||||
thumbnail_label.grid(row=0, column=0, padx=10, pady=5)
|
||||
|
||||
# Face info with confidence
|
||||
info_frame = ttk.Frame(face_frame)
|
||||
info_frame.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=10)
|
||||
|
||||
# Confidence with color coding
|
||||
confidence_text = f"{confidence:.1f}%"
|
||||
if confidence >= 80:
|
||||
confidence_color = "green"
|
||||
elif confidence >= 60:
|
||||
confidence_color = "orange"
|
||||
else:
|
||||
confidence_color = "red"
|
||||
|
||||
ttk.Label(info_frame, text=confidence_text,
|
||||
font=("Arial", 14, "bold"), foreground=confidence_color).grid(row=0, column=0, sticky=tk.W, pady=2)
|
||||
ttk.Label(info_frame, text=f"ID: {face['face_id']}", font=("Arial", 10)).grid(row=1, column=0, sticky=tk.W, pady=2)
|
||||
ttk.Label(info_frame, text=f"Image: {Path(face['image_path']).name}", font=("Arial", 10)).grid(row=2, column=0, sticky=tk.W, pady=2)
|
||||
|
||||
def run(self):
|
||||
"""Start the GUI application"""
|
||||
self.root.mainloop()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
# Check dependencies
|
||||
try:
|
||||
from deepface import DeepFace
|
||||
except ImportError as e:
|
||||
print(f"Error: Missing required dependency: {e}")
|
||||
print("Please install with: pip install deepface")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import face_recognition
|
||||
except ImportError as e:
|
||||
print(f"Error: Missing required dependency: {e}")
|
||||
print("Please install with: pip install face_recognition")
|
||||
sys.exit(1)
|
||||
|
||||
# Suppress TensorFlow warnings and errors
|
||||
import os
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' # Suppress TensorFlow warnings
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
try:
|
||||
# Create and run GUI
|
||||
app = FaceComparisonGUI()
|
||||
app.run()
|
||||
except Exception as e:
|
||||
print(f"GUI Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test to verify thumbnail sizes work
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from PIL import Image, ImageTk
|
||||
import os
|
||||
|
||||
# Suppress TensorFlow warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
|
||||
def test_thumbnails():
|
||||
root = tk.Tk()
|
||||
root.title("Thumbnail Size Test")
|
||||
root.geometry("1000x600")
|
||||
|
||||
frame = ttk.Frame(root, padding="20")
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Create test images with different sizes
|
||||
sizes = [
|
||||
(100, 100, "Small (100x100)"),
|
||||
(200, 200, "Medium (200x200)"),
|
||||
(300, 300, "Large (300x300)"),
|
||||
(400, 400, "HUGE (400x400)")
|
||||
]
|
||||
|
||||
for i, (width, height, label) in enumerate(sizes):
|
||||
# Create a colored rectangle
|
||||
test_image = Image.new('RGB', (width, height), color='red')
|
||||
photo = ImageTk.PhotoImage(test_image)
|
||||
|
||||
# Create label with image
|
||||
img_label = ttk.Label(frame, image=photo)
|
||||
img_label.image = photo # Keep a reference
|
||||
img_label.grid(row=0, column=i, padx=10, pady=10)
|
||||
|
||||
# Create label with text
|
||||
text_label = ttk.Label(frame, text=label, font=("Arial", 12, "bold"))
|
||||
text_label.grid(row=1, column=i, padx=10)
|
||||
|
||||
# Add instruction
|
||||
instruction = ttk.Label(frame, text="This shows the difference in thumbnail sizes. The GUI will use 400x400 and 350x350 pixels!",
|
||||
font=("Arial", 14, "bold"), foreground="blue")
|
||||
instruction.grid(row=2, column=0, columnspan=4, pady=20)
|
||||
|
||||
# Add button to test DeepFace GUI
|
||||
def open_deepface_gui():
|
||||
root.destroy()
|
||||
import subprocess
|
||||
subprocess.Popen(['python', 'test_deepface_gui.py'])
|
||||
|
||||
test_btn = ttk.Button(frame, text="Open DeepFace GUI", command=open_deepface_gui)
|
||||
test_btn.grid(row=3, column=0, columnspan=4, pady=20)
|
||||
|
||||
root.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_thumbnails()
|
||||
@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to show thumbnail size differences
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
def create_test_thumbnails():
|
||||
"""Create test thumbnails to show size differences"""
|
||||
root = tk.Tk()
|
||||
root.title("Thumbnail Size Test")
|
||||
root.geometry("800x600")
|
||||
|
||||
# Create a test image (colored rectangle)
|
||||
test_image = Image.new('RGB', (100, 100), color='red')
|
||||
|
||||
# Create different sized thumbnails
|
||||
sizes = [
|
||||
(100, 100, "Original (100x100)"),
|
||||
(200, 200, "Medium (200x200)"),
|
||||
(300, 300, "Large (300x300)"),
|
||||
(400, 400, "HUGE (400x400)")
|
||||
]
|
||||
|
||||
frame = ttk.Frame(root, padding="20")
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
for i, (width, height, label) in enumerate(sizes):
|
||||
# Resize the test image
|
||||
resized = test_image.resize((width, height), Image.Resampling.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(resized)
|
||||
|
||||
# Create label with image
|
||||
img_label = ttk.Label(frame, image=photo)
|
||||
img_label.image = photo # Keep a reference
|
||||
img_label.grid(row=0, column=i, padx=10, pady=10)
|
||||
|
||||
# Create label with text
|
||||
text_label = ttk.Label(frame, text=label, font=("Arial", 12, "bold"))
|
||||
text_label.grid(row=1, column=i, padx=10)
|
||||
|
||||
# Add instruction
|
||||
instruction = ttk.Label(frame, text="This shows the difference in thumbnail sizes. The GUI will use 400x400 and 350x350 pixels!",
|
||||
font=("Arial", 14, "bold"), foreground="blue")
|
||||
instruction.grid(row=2, column=0, columnspan=4, pady=20)
|
||||
|
||||
root.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_test_thumbnails()
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 7.2 MiB |
|
Before Width: | Height: | Size: 7.7 MiB |
|
Before Width: | Height: | Size: 7.8 MiB |
@ -1,92 +0,0 @@
|
||||
# 🎯 Enhanced Demo Photo Setup Instructions
|
||||
|
||||
To run the enhanced demo with visual face recognition, you need sample photos in this folder structure.
|
||||
|
||||
## 📸 Quick Setup for Enhanced Demo
|
||||
|
||||
1. **Find 6-12 photos** with clear faces from your collection
|
||||
2. **Copy them** into the subfolders below:
|
||||
|
||||
```
|
||||
demo_photos/
|
||||
├── family/ ← 3-5 photos with family members (SOME PEOPLE IN MULTIPLE PHOTOS)
|
||||
├── friends/ ← 2-3 photos with friends
|
||||
└── events/ ← 2-4 photos from events/gatherings
|
||||
```
|
||||
|
||||
## 🎭 Ideal Demo Photos for Enhanced Features:
|
||||
|
||||
- **Clear faces**: Well-lit, not too small (face recognition works better)
|
||||
- **Multiple people**: 2-5 people per photo works best
|
||||
- **⭐ REPEAT appearances**: Same people in multiple photos (for auto-matching demo!)
|
||||
- **Mix of scenarios**: Group photos + individual portraits
|
||||
- **Different lighting/angles**: Shows robustness of cross-photo matching
|
||||
|
||||
## 🚀 Enhanced Demo Test Commands:
|
||||
|
||||
### Basic Setup Test:
|
||||
```bash
|
||||
# Test the scan
|
||||
source venv/bin/activate
|
||||
python3 photo_tagger.py scan demo_photos --recursive --db demo.db -v
|
||||
|
||||
# Should output something like:
|
||||
# 📁 Found 8 photos, added 8 new photos
|
||||
```
|
||||
|
||||
### Face Detection Test:
|
||||
```bash
|
||||
python3 photo_tagger.py process --db demo.db -v
|
||||
|
||||
# Should output:
|
||||
# 🔍 Processing 8 photos for faces...
|
||||
# 📸 Processing: family_photo1.jpg
|
||||
# 👤 Found 4 faces
|
||||
```
|
||||
|
||||
### Enhanced Identification Test:
|
||||
```bash
|
||||
# Test individual face display
|
||||
python3 photo_tagger.py identify --show-faces --batch 2 --db demo.db
|
||||
|
||||
# Should show individual face crops automatically
|
||||
```
|
||||
|
||||
## 🎪 Enhanced Demo Success Criteria:
|
||||
|
||||
After adding photos, you should be able to demonstrate:
|
||||
|
||||
1. ✅ **Scan** finds your photos
|
||||
2. ✅ **Process** detects faces in all photos
|
||||
3. ✅ **Individual face display** shows cropped faces during identification
|
||||
4. ✅ **Cross-photo matching** finds same people in different photos
|
||||
5. ✅ **Confidence scoring** with color-coded quality levels
|
||||
6. ✅ **Visual comparison** with side-by-side face images
|
||||
7. ✅ **Search** finds photos by person name
|
||||
8. ✅ **Smart filtering** only shows logical matches
|
||||
|
||||
## 📊 Expected Demo Results:
|
||||
|
||||
With good demo photos, you should see:
|
||||
- **15-30 faces detected** across all photos
|
||||
- **3-8 unique people** identified
|
||||
- **2-5 cross-photo matches** found by auto-matching
|
||||
- **60-80% confidence** for good matches
|
||||
- **Individual face crops** displayed automatically
|
||||
|
||||
## 🎯 Pro Tips for Best Demo:
|
||||
|
||||
1. **Include repeat people**: Same person in 2-3 different photos
|
||||
2. **Vary conditions**: Indoor/outdoor, different lighting
|
||||
3. **Group + individual**: Mix of group photos and portraits
|
||||
4. **Clear faces**: Avoid sunglasses, hats, or poor lighting
|
||||
5. **Multiple angles**: Front-facing and slight profile views
|
||||
|
||||
---
|
||||
|
||||
**Ready for Enhanced Demo!** 🎉
|
||||
|
||||
Your demo will showcase:
|
||||
- **Visual face recognition** with individual face display
|
||||
- **Intelligent cross-photo matching** with confidence scoring
|
||||
- **Privacy-first local processing** with professional features
|
||||
@ -1,23 +0,0 @@
|
||||
# 📸 Demo Photos Setup
|
||||
|
||||
## 🎯 Quick Setup for Demo
|
||||
|
||||
1. **Add 6-10 photos with faces** to these folders:
|
||||
- `family/` - Family photos (3-4 photos)
|
||||
- `friends/` - Friend photos (2-3 photos)
|
||||
- `events/` - Event photos (2-3 photos)
|
||||
|
||||
2. **Important**: Include some people in multiple photos for auto-matching demo
|
||||
|
||||
3. **Run demo**: See main `DEMO.md` file
|
||||
|
||||
## ✅ Current Status
|
||||
|
||||
- **3 photos** in `family/` folder
|
||||
- **20 faces detected**
|
||||
- **14 people identified**
|
||||
- **Ready for demo!**
|
||||
|
||||
---
|
||||
|
||||
For complete setup instructions: `DEMO_INSTRUCTIONS.md`
|
||||
@ -1 +0,0 @@
|
||||
Demo placeholder - replace with actual photos
|
||||
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 7.4 MiB |