feat: Introduce CLI and GUI components for PunimTag desktop version
This commit adds a new command-line interface (CLI) and graphical user interface (GUI) components for the PunimTag desktop application. The `photo_tagger.py` script serves as the CLI entry point, enabling users to scan, process, and manage photos with facial recognition capabilities. The GUI includes a unified dashboard with various panels for identifying, modifying, and managing tags for photos. Additionally, comprehensive documentation has been created to guide users through installation and usage, ensuring a smooth experience. This update enhances the overall functionality and accessibility of the PunimTag application.
This commit is contained in:
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
-1039
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
+4
-5
@@ -145,12 +145,11 @@ def main():
|
||||
print("✅ Setup complete!")
|
||||
print()
|
||||
print("🎯 Quick Start:")
|
||||
print(" 1. Add photos: python3 photo_tagger.py scan /path/to/photos")
|
||||
print(" 2. Process faces: python3 photo_tagger.py process")
|
||||
print(" 3. Identify faces: python3 photo_tagger.py identify")
|
||||
print(" 4. View stats: python3 photo_tagger.py stats")
|
||||
print(" 1. Start the web server: python run_api_with_worker.sh")
|
||||
print(" 2. Access the web interface at http://localhost:8000")
|
||||
print(" 3. Use the web UI to scan, process, and identify faces")
|
||||
print()
|
||||
print("📖 For help: python3 photo_tagger.py --help")
|
||||
print("📖 For more information, see README.md")
|
||||
print()
|
||||
print("⚠️ IMPORTANT: Always activate virtual environment first!")
|
||||
print(" source venv/bin/activate")
|
||||
|
||||
Reference in New Issue
Block a user