chore: Add configuration and documentation files for project structure and guidelines
This commit introduces several new files to enhance project organization and developer onboarding. The `.cursorignore` and `.cursorrules` files provide guidelines for Cursor AI, while `CONTRIBUTING.md` outlines contribution procedures. Additionally, `IMPORT_FIX_SUMMARY.md`, `RESTRUCTURE_SUMMARY.md`, and `STATUS.md` summarize recent changes and project status. The `README.md` has been updated to reflect the new project focus and structure, ensuring clarity for contributors and users. These additions aim to improve maintainability and facilitate collaboration within the PunimTag project.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
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',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
#!/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="🚀 Start 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()
|
||||
search_entry = ttk.Entry(search_frame, textvariable=self.components['search_var'], width=20)
|
||||
search_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
|
||||
# Search buttons
|
||||
search_btn = ttk.Button(search_frame, text="Search", width=8, command=self._apply_search_filter)
|
||||
search_btn.grid(row=0, column=1, padx=(0, 5))
|
||||
|
||||
clear_btn = ttk.Button(search_frame, text="Clear", width=6, command=self._clear_search_filter)
|
||||
clear_btn.grid(row=0, column=2)
|
||||
|
||||
# Search help label
|
||||
self.components['search_help_label'] = ttk.Label(search_frame, text="Type Last Name",
|
||||
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
|
||||
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
|
||||
|
||||
# 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("")
|
||||
search_entry = None
|
||||
for widget in self.components['left_panel'].winfo_children():
|
||||
if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0:
|
||||
for child in widget.winfo_children():
|
||||
if isinstance(child, ttk.Entry):
|
||||
search_entry = child
|
||||
break
|
||||
if search_entry:
|
||||
search_entry.config(state='disabled')
|
||||
self.components['search_help_label'].config(text="(Search disabled - only one person found)")
|
||||
|
||||
# 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 confidence
|
||||
confidence_pct = (1 - match['distance']) * 100
|
||||
confidence_desc = self.face_processor._get_confidence_description(confidence_pct)
|
||||
|
||||
# Create match frame
|
||||
match_frame = ttk.Frame(matches_inner_frame)
|
||||
match_frame.grid(row=i, column=0, sticky=(tk.W, tk.E), pady=5)
|
||||
|
||||
# Checkbox for this match
|
||||
match_var = tk.BooleanVar()
|
||||
|
||||
# Restore previous checkbox state if available
|
||||
unique_key = f"{matched_id}_{match['unidentified_id']}"
|
||||
if matched_id in 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]
|
||||
|
||||
# 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()
|
||||
|
||||
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
|
||||
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']}")
|
||||
|
||||
# Update person encodings for all affected persons
|
||||
for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']):
|
||||
self.face_processor.update_person_encodings(person_id)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
self.components['search_var'].set("")
|
||||
self.components['search_help_label'].config(text="Type Last Name")
|
||||
|
||||
# Re-enable search entry
|
||||
search_entry = None
|
||||
for widget in self.components['left_panel'].winfo_children():
|
||||
if isinstance(widget, ttk.Frame) and len(widget.winfo_children()) > 0:
|
||||
for child in widget.winfo_children():
|
||||
if isinstance(child, ttk.Entry):
|
||||
search_entry = child
|
||||
break
|
||||
if search_entry:
|
||||
search_entry.config(state='normal')
|
||||
|
||||
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
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common GUI utilities and widgets for PunimTag
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
from PIL import Image, ImageTk
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from src.core.config import DEFAULT_CONFIG_FILE, DEFAULT_WINDOW_SIZE, ICON_SIZE
|
||||
|
||||
|
||||
class GUICore:
|
||||
"""Common GUI utilities and helper functions"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize GUI core utilities"""
|
||||
pass
|
||||
|
||||
def setup_window_size_saving(self, root, config_file: str = DEFAULT_CONFIG_FILE) -> str:
|
||||
"""Set up window size saving functionality"""
|
||||
# Load saved window size
|
||||
saved_size = DEFAULT_WINDOW_SIZE
|
||||
|
||||
if os.path.exists(config_file):
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
saved_size = config.get('window_size', DEFAULT_WINDOW_SIZE)
|
||||
except:
|
||||
saved_size = DEFAULT_WINDOW_SIZE
|
||||
|
||||
# Calculate center position before showing window
|
||||
try:
|
||||
width = int(saved_size.split('x')[0])
|
||||
height = int(saved_size.split('x')[1])
|
||||
x = (root.winfo_screenwidth() // 2) - (width // 2)
|
||||
y = (root.winfo_screenheight() // 2) - (height // 2)
|
||||
root.geometry(f"{saved_size}+{x}+{y}")
|
||||
except:
|
||||
# Fallback to default geometry if positioning fails
|
||||
root.geometry(saved_size)
|
||||
|
||||
# Track previous size to detect actual resizing
|
||||
last_size = None
|
||||
|
||||
def save_window_size(event=None):
|
||||
nonlocal last_size
|
||||
if event and event.widget == root:
|
||||
current_size = f"{root.winfo_width()}x{root.winfo_height()}"
|
||||
# Only save if size actually changed
|
||||
if current_size != last_size:
|
||||
last_size = current_size
|
||||
try:
|
||||
config = {'window_size': current_size}
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f)
|
||||
except:
|
||||
pass # Ignore save errors
|
||||
|
||||
# Bind resize event
|
||||
root.bind('<Configure>', save_window_size)
|
||||
return saved_size
|
||||
|
||||
def create_photo_icon(self, canvas, photo_path: str, icon_size: int = ICON_SIZE,
|
||||
icon_x: int = None, icon_y: int = None,
|
||||
canvas_width: int = None, canvas_height: int = None,
|
||||
face_x: int = None, face_y: int = None,
|
||||
face_width: int = None, face_height: int = None,
|
||||
callback: callable = None) -> Optional[int]:
|
||||
"""Create a reusable photo icon with tooltip on a canvas"""
|
||||
import tkinter as tk
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
def open_source_photo(event):
|
||||
"""Open the source photo in a properly sized window"""
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# Try to open with a specific image viewer that supports window sizing
|
||||
try:
|
||||
subprocess.run(["mspaint", photo_path], check=False)
|
||||
except:
|
||||
os.startfile(photo_path)
|
||||
elif system == "Darwin": # macOS
|
||||
# Use Preview with specific window size
|
||||
subprocess.run(["open", "-a", "Preview", photo_path])
|
||||
else: # Linux and others
|
||||
# Try common image viewers with window sizing options
|
||||
viewers_to_try = [
|
||||
["eog", "--new-window", photo_path], # Eye of GNOME
|
||||
["gwenview", photo_path], # KDE image viewer
|
||||
["feh", "--geometry", "800x600", photo_path], # feh with specific size
|
||||
["gimp", photo_path], # GIMP
|
||||
["xdg-open", photo_path] # Fallback to default
|
||||
]
|
||||
|
||||
opened = False
|
||||
for viewer_cmd in viewers_to_try:
|
||||
try:
|
||||
result = subprocess.run(viewer_cmd, check=False, capture_output=True)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not opened:
|
||||
# Final fallback
|
||||
subprocess.run(["xdg-open", photo_path])
|
||||
except Exception as e:
|
||||
print(f"❌ Could not open photo: {e}")
|
||||
|
||||
# Create tooltip for the icon
|
||||
tooltip = None
|
||||
|
||||
def show_tooltip(event):
|
||||
nonlocal tooltip
|
||||
if tooltip:
|
||||
tooltip.destroy()
|
||||
tooltip = tk.Toplevel()
|
||||
tooltip.wm_overrideredirect(True)
|
||||
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
|
||||
label = tk.Label(tooltip, text="Show original photo",
|
||||
background="lightyellow", relief="solid", borderwidth=1,
|
||||
font=("Arial", 9))
|
||||
label.pack()
|
||||
|
||||
def hide_tooltip(event):
|
||||
nonlocal tooltip
|
||||
if tooltip:
|
||||
tooltip.destroy()
|
||||
tooltip = None
|
||||
|
||||
# Calculate icon position
|
||||
if icon_x is None or icon_y is None:
|
||||
if face_x is not None and face_y is not None and face_width is not None and face_height is not None:
|
||||
# Position relative to face image - exactly in the top-right corner
|
||||
icon_x = face_x + face_width - icon_size
|
||||
icon_y = face_y
|
||||
else:
|
||||
# Position relative to canvas - exactly in the corner
|
||||
if canvas_width is None:
|
||||
canvas_width = canvas.winfo_width()
|
||||
if canvas_height is None:
|
||||
canvas_height = canvas.winfo_height()
|
||||
icon_x = canvas_width - icon_size
|
||||
icon_y = 0
|
||||
|
||||
# Ensure icon stays within canvas bounds
|
||||
if canvas_width is None:
|
||||
canvas_width = canvas.winfo_width()
|
||||
if canvas_height is None:
|
||||
canvas_height = canvas.winfo_height()
|
||||
icon_x = min(icon_x, canvas_width - icon_size)
|
||||
icon_y = max(icon_y, 0)
|
||||
|
||||
# Draw the photo icon
|
||||
canvas.create_rectangle(icon_x, icon_y, icon_x + icon_size, icon_y + icon_size,
|
||||
fill="white", outline="black", width=1, tags="photo_icon")
|
||||
canvas.create_text(icon_x + icon_size//2, icon_y + icon_size//2,
|
||||
text="📷", font=("Arial", 10), tags="photo_icon")
|
||||
|
||||
# Bind events
|
||||
canvas.tag_bind("photo_icon", "<Button-1>", open_source_photo)
|
||||
canvas.tag_bind("photo_icon", "<Enter>", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e)))
|
||||
canvas.tag_bind("photo_icon", "<Leave>", lambda e: (canvas.config(cursor=""), hide_tooltip(e)))
|
||||
canvas.tag_bind("photo_icon", "<Motion>", lambda e: (show_tooltip(e) if tooltip else None))
|
||||
|
||||
return tooltip # Return tooltip reference for cleanup if needed
|
||||
|
||||
def create_confidence_badge(self, parent, confidence_pct: float):
|
||||
"""Create a colorful confidence badge with percentage and label.
|
||||
Returns a frame containing a small colored circle (with percent) and a text label.
|
||||
"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
# Determine color and label
|
||||
if confidence_pct >= 80:
|
||||
color = "#27AE60" # green
|
||||
label = "Very High"
|
||||
text_color = "white"
|
||||
elif confidence_pct >= 70:
|
||||
color = "#F1C40F" # yellow
|
||||
label = "High"
|
||||
text_color = "black"
|
||||
elif confidence_pct >= 60:
|
||||
color = "#E67E22" # orange
|
||||
label = "Medium"
|
||||
text_color = "white"
|
||||
elif confidence_pct >= 50:
|
||||
color = "#E74C3C" # red
|
||||
label = "Low"
|
||||
text_color = "white"
|
||||
else:
|
||||
color = "#2C3E50" # dark blue/black
|
||||
label = "Very Low"
|
||||
text_color = "white"
|
||||
|
||||
badge_frame = ttk.Frame(parent)
|
||||
|
||||
# Draw circle sized to roughly match the label font height
|
||||
size = 14
|
||||
style = ttk.Style()
|
||||
bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
||||
canvas = tk.Canvas(badge_frame, width=size, height=size, highlightthickness=0, bg=bg_color)
|
||||
canvas.grid(row=0, column=0, padx=(0, 4))
|
||||
canvas.create_oval(1, 1, size-1, size-1, fill=color, outline=color)
|
||||
|
||||
# Text label right to the circle
|
||||
label_widget = ttk.Label(badge_frame, text=f"{int(round(confidence_pct))}% {label}", font=("Arial", 9, "bold"))
|
||||
label_widget.grid(row=0, column=1, sticky="w")
|
||||
|
||||
return badge_frame
|
||||
|
||||
def create_face_crop_image(self, photo_path: str, face_location: tuple,
|
||||
face_id: int, crop_size: int = 100) -> Optional[str]:
|
||||
"""Create a face crop image for display"""
|
||||
try:
|
||||
# Parse location tuple from string format
|
||||
if isinstance(face_location, str):
|
||||
face_location = eval(face_location)
|
||||
|
||||
top, right, bottom, left = face_location
|
||||
|
||||
# Load the image
|
||||
with Image.open(photo_path) as image:
|
||||
# Add padding around the face
|
||||
face_width = right - left
|
||||
face_height = bottom - top
|
||||
padding_x = int(face_width * 0.2)
|
||||
padding_y = int(face_height * 0.2)
|
||||
|
||||
# Calculate crop bounds with padding
|
||||
crop_left = max(0, left - padding_x)
|
||||
crop_top = max(0, top - padding_y)
|
||||
crop_right = min(image.width, right + padding_x)
|
||||
crop_bottom = min(image.height, bottom + padding_y)
|
||||
|
||||
# Crop the face
|
||||
face_crop = image.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||||
|
||||
# Resize to standard size
|
||||
face_crop = face_crop.resize((crop_size, crop_size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create temporary file
|
||||
temp_dir = tempfile.gettempdir()
|
||||
face_filename = f"face_{face_id}_display.jpg"
|
||||
face_path = os.path.join(temp_dir, face_filename)
|
||||
|
||||
face_crop.save(face_path, "JPEG", quality=95)
|
||||
return face_path
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def create_photo_thumbnail(self, photo_path: str, thumbnail_size: int = 150) -> Optional[ImageTk.PhotoImage]:
|
||||
"""Create a thumbnail for display"""
|
||||
try:
|
||||
if not os.path.exists(photo_path):
|
||||
return None
|
||||
|
||||
with Image.open(photo_path) as img:
|
||||
img.thumbnail((thumbnail_size, thumbnail_size), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def create_comparison_image(self, unid_crop_path: str, match_crop_path: str,
|
||||
person_name: str, confidence: float) -> Optional[str]:
|
||||
"""Create a side-by-side comparison image"""
|
||||
try:
|
||||
# Load both face crops
|
||||
unid_img = Image.open(unid_crop_path)
|
||||
match_img = Image.open(match_crop_path)
|
||||
|
||||
# Resize both to same height for better comparison
|
||||
target_height = 300
|
||||
unid_ratio = target_height / unid_img.height
|
||||
match_ratio = target_height / match_img.height
|
||||
|
||||
unid_resized = unid_img.resize((int(unid_img.width * unid_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
match_resized = match_img.resize((int(match_img.width * match_ratio), target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create comparison image
|
||||
total_width = unid_resized.width + match_resized.width + 20 # 20px gap
|
||||
comparison = Image.new('RGB', (total_width, target_height + 60), 'white')
|
||||
|
||||
# Paste images
|
||||
comparison.paste(unid_resized, (0, 30))
|
||||
comparison.paste(match_resized, (unid_resized.width + 20, 30))
|
||||
|
||||
# Add labels
|
||||
from PIL import ImageDraw, ImageFont
|
||||
draw = ImageDraw.Draw(comparison)
|
||||
try:
|
||||
# Try to use a font
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw.text((10, 5), "UNKNOWN", fill='red', font=font)
|
||||
draw.text((unid_resized.width + 30, 5), f"{person_name.upper()}", fill='green', font=font)
|
||||
draw.text((10, target_height + 35), f"Confidence: {confidence:.1%}", fill='blue', font=font)
|
||||
|
||||
# Save comparison image
|
||||
temp_dir = tempfile.gettempdir()
|
||||
comparison_path = os.path.join(temp_dir, f"face_comparison_{person_name}.jpg")
|
||||
comparison.save(comparison_path, "JPEG", quality=95)
|
||||
|
||||
return comparison_path
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def get_confidence_description(self, confidence_pct: float) -> str:
|
||||
"""Get human-readable confidence description"""
|
||||
if confidence_pct >= 80:
|
||||
return "🟢 (Very High - Almost Certain)"
|
||||
elif confidence_pct >= 70:
|
||||
return "🟡 (High - Likely Match)"
|
||||
elif confidence_pct >= 60:
|
||||
return "🟠 (Medium - Possible Match)"
|
||||
elif confidence_pct >= 50:
|
||||
return "🔴 (Low - Questionable)"
|
||||
else:
|
||||
return "⚫ (Very Low)"
|
||||
|
||||
def center_window(self, root, width: int = None, height: int = None):
|
||||
"""Center a window on the screen"""
|
||||
if width is None:
|
||||
width = root.winfo_width()
|
||||
if height is None:
|
||||
height = root.winfo_height()
|
||||
|
||||
screen_width = root.winfo_screenwidth()
|
||||
screen_height = root.winfo_screenheight()
|
||||
|
||||
x = (screen_width - width) // 2
|
||||
y = (screen_height - height) // 2
|
||||
|
||||
root.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
def create_tooltip(self, widget, text: str):
|
||||
"""Create a tooltip for a widget"""
|
||||
def show_tooltip(event):
|
||||
tooltip = tk.Toplevel()
|
||||
tooltip.wm_overrideredirect(True)
|
||||
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
|
||||
|
||||
label = tk.Label(tooltip, text=text, background="lightyellow",
|
||||
relief="solid", borderwidth=1, font=("Arial", 9))
|
||||
label.pack()
|
||||
|
||||
widget.tooltip = tooltip
|
||||
|
||||
def hide_tooltip(event):
|
||||
if hasattr(widget, 'tooltip'):
|
||||
widget.tooltip.destroy()
|
||||
del widget.tooltip
|
||||
|
||||
widget.bind('<Enter>', show_tooltip)
|
||||
widget.bind('<Leave>', hide_tooltip)
|
||||
|
||||
def create_progress_bar(self, parent, text: str = "Processing..."):
|
||||
"""Create a progress bar dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
progress_window = tk.Toplevel(parent)
|
||||
progress_window.title("Progress")
|
||||
progress_window.resizable(False, False)
|
||||
|
||||
# Center the progress window
|
||||
progress_window.transient(parent)
|
||||
progress_window.grab_set()
|
||||
|
||||
frame = ttk.Frame(progress_window, padding="20")
|
||||
frame.pack()
|
||||
|
||||
label = ttk.Label(frame, text=text)
|
||||
label.pack(pady=(0, 10))
|
||||
|
||||
progress = ttk.Progressbar(frame, mode='indeterminate')
|
||||
progress.pack(fill='x', pady=(0, 10))
|
||||
progress.start()
|
||||
|
||||
# Center the window
|
||||
progress_window.update_idletasks()
|
||||
x = (progress_window.winfo_screenwidth() // 2) - (progress_window.winfo_width() // 2)
|
||||
y = (progress_window.winfo_screenheight() // 2) - (progress_window.winfo_height() // 2)
|
||||
progress_window.geometry(f"+{x}+{y}")
|
||||
|
||||
return progress_window, progress
|
||||
|
||||
def create_confirmation_dialog(self, parent, title: str, message: str) -> bool:
|
||||
"""Create a confirmation dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
result = messagebox.askyesno(title, message, parent=parent)
|
||||
return result
|
||||
|
||||
def create_large_messagebox(self, parent, title: str, message: str, msg_type: str = "warning") -> bool:
|
||||
"""Create a larger messagebox dialog that fits text without wrapping"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
# Calculate appropriate size based on message length
|
||||
lines = message.count('\n') + 1
|
||||
max_line_length = max(len(line) for line in message.split('\n'))
|
||||
|
||||
# Set width to accommodate text (minimum 400, maximum 800)
|
||||
width = max(400, min(800, max_line_length * 8 + 100))
|
||||
# Set height based on number of lines (minimum 200, maximum 500)
|
||||
height = max(200, min(500, lines * 25 + 150))
|
||||
|
||||
# Calculate center position first
|
||||
screen_width = parent.winfo_screenwidth() if parent else tk._default_root.winfo_screenwidth()
|
||||
screen_height = parent.winfo_screenheight() if parent else tk._default_root.winfo_screenheight()
|
||||
x = (screen_width - width) // 2
|
||||
y = (screen_height - height) // 2
|
||||
|
||||
# Create a custom dialog for better control over size
|
||||
dialog = tk.Toplevel(parent)
|
||||
dialog.title(title)
|
||||
dialog.transient(parent)
|
||||
dialog.grab_set()
|
||||
|
||||
# Set geometry with position in one call to prevent jumping
|
||||
dialog.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
# Create main frame
|
||||
main_frame = ttk.Frame(dialog, padding="20")
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Add message label
|
||||
message_label = tk.Label(main_frame, text=message, font=("Arial", 10),
|
||||
justify=tk.LEFT, wraplength=width-100)
|
||||
message_label.pack(pady=(0, 20))
|
||||
|
||||
# Add buttons based on message type
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.pack()
|
||||
|
||||
def close_dialog(result_value):
|
||||
"""Close dialog and set result"""
|
||||
dialog._result = result_value
|
||||
dialog.destroy()
|
||||
|
||||
if msg_type == "warning":
|
||||
ttk.Button(button_frame, text="OK", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
|
||||
elif msg_type == "askyesno":
|
||||
ttk.Button(button_frame, text="Yes", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text="No", command=lambda: close_dialog(False)).pack(side=tk.LEFT, padx=5)
|
||||
elif msg_type == "askyesnocancel":
|
||||
ttk.Button(button_frame, text="Yes", command=lambda: close_dialog(True)).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text="No", command=lambda: close_dialog(False)).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text="Cancel", command=lambda: close_dialog(None)).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# Wait for dialog to close
|
||||
dialog.wait_window()
|
||||
return getattr(dialog, '_result', None)
|
||||
|
||||
def create_input_dialog(self, parent, title: str, prompt: str, default: str = "") -> Optional[str]:
|
||||
"""Create an input dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import simpledialog
|
||||
|
||||
result = simpledialog.askstring(title, prompt, initialvalue=default, parent=parent)
|
||||
return result
|
||||
|
||||
def create_file_dialog(self, parent, title: str, filetypes: list = None) -> Optional[str]:
|
||||
"""Create a file dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
if filetypes is None:
|
||||
filetypes = [("Image files", "*.jpg *.jpeg *.png *.gif *.bmp *.tiff")]
|
||||
|
||||
result = filedialog.askopenfilename(title=title, filetypes=filetypes, parent=parent)
|
||||
return result if result else None
|
||||
|
||||
def create_directory_dialog(self, parent, title: str) -> Optional[str]:
|
||||
"""Create a directory dialog"""
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
result = filedialog.askdirectory(title=title, parent=parent)
|
||||
return result if result else None
|
||||
|
||||
def cleanup_temp_files(self, file_paths: list):
|
||||
"""Clean up temporary files"""
|
||||
for file_path in file_paths:
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
def create_calendar_dialog(self, parent, title: str, initial_date: str = None) -> Optional[str]:
|
||||
"""Create a calendar dialog for date selection"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from datetime import datetime, date
|
||||
import calendar
|
||||
|
||||
# Create calendar window
|
||||
calendar_window = tk.Toplevel(parent)
|
||||
calendar_window.title(title)
|
||||
calendar_window.resizable(False, False)
|
||||
calendar_window.transient(parent)
|
||||
calendar_window.grab_set()
|
||||
|
||||
# Calculate center position
|
||||
window_width = 400
|
||||
window_height = 400
|
||||
screen_width = calendar_window.winfo_screenwidth()
|
||||
screen_height = calendar_window.winfo_screenheight()
|
||||
x = (screen_width // 2) - (window_width // 2)
|
||||
y = (screen_height // 2) - (window_height // 2)
|
||||
calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
||||
|
||||
# Calendar variables
|
||||
current_date = datetime.now()
|
||||
selected_date = None
|
||||
|
||||
# Create custom styles for calendar buttons
|
||||
style = ttk.Style()
|
||||
style.configure("Calendar.TButton", padding=(2, 2))
|
||||
style.configure("Selected.TButton", background="lightblue")
|
||||
style.configure("Today.TButton", background="lightyellow")
|
||||
|
||||
# Check if there's already a date selected
|
||||
if initial_date:
|
||||
try:
|
||||
selected_date = datetime.strptime(initial_date, '%Y-%m-%d').date()
|
||||
display_year = selected_date.year
|
||||
display_month = selected_date.month
|
||||
except ValueError:
|
||||
display_year = current_date.year
|
||||
display_month = current_date.month
|
||||
selected_date = None
|
||||
else:
|
||||
display_year = current_date.year
|
||||
display_month = current_date.month
|
||||
|
||||
# Month names
|
||||
month_names = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"]
|
||||
|
||||
# Main frame
|
||||
main_cal_frame = ttk.Frame(calendar_window, padding="10")
|
||||
main_cal_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Header frame with navigation
|
||||
header_frame = ttk.Frame(main_cal_frame)
|
||||
header_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
|
||||
# Month/Year display and navigation
|
||||
nav_frame = ttk.Frame(header_frame)
|
||||
nav_frame.pack()
|
||||
|
||||
# Month/Year label
|
||||
month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold"))
|
||||
month_year_label.pack(side=tk.LEFT, padx=10)
|
||||
|
||||
def update_calendar():
|
||||
"""Update the calendar display"""
|
||||
# Update month/year label
|
||||
month_year_label.configure(text=f"{month_names[display_month-1]} {display_year}")
|
||||
|
||||
# Clear existing calendar
|
||||
for widget in calendar_frame.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# Get calendar data
|
||||
cal = calendar.monthcalendar(display_year, display_month)
|
||||
|
||||
# Day headers
|
||||
day_headers = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
for i, day in enumerate(day_headers):
|
||||
header_label = ttk.Label(calendar_frame, text=day, font=("Arial", 10, "bold"))
|
||||
header_label.grid(row=0, column=i, padx=2, pady=2, sticky="nsew")
|
||||
|
||||
# Calendar days
|
||||
for week_num, week in enumerate(cal):
|
||||
for day_num, day in enumerate(week):
|
||||
if day == 0:
|
||||
# Empty cell
|
||||
empty_label = ttk.Label(calendar_frame, text="")
|
||||
empty_label.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew")
|
||||
else:
|
||||
# Day button
|
||||
day_date = date(display_year, display_month, day)
|
||||
is_selected = selected_date == day_date
|
||||
is_today = day_date == current_date.date()
|
||||
is_future = day_date > current_date.date()
|
||||
|
||||
if is_future:
|
||||
# Disable future dates
|
||||
day_btn = ttk.Button(calendar_frame, text=str(day),
|
||||
state='disabled', style="Calendar.TButton")
|
||||
else:
|
||||
# Create day selection handler
|
||||
def make_day_handler(day_value):
|
||||
def select_day():
|
||||
nonlocal selected_date
|
||||
selected_date = date(display_year, display_month, day_value)
|
||||
# Reset all buttons to normal calendar style
|
||||
for widget in calendar_frame.winfo_children():
|
||||
if isinstance(widget, ttk.Button):
|
||||
widget.config(style="Calendar.TButton")
|
||||
# Highlight selected day
|
||||
for widget in calendar_frame.winfo_children():
|
||||
if isinstance(widget, ttk.Button) and widget.cget("text") == str(day_value):
|
||||
widget.config(style="Selected.TButton")
|
||||
return select_day
|
||||
|
||||
day_btn = ttk.Button(calendar_frame, text=str(day),
|
||||
command=make_day_handler(day),
|
||||
style="Calendar.TButton")
|
||||
|
||||
day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew")
|
||||
|
||||
# Apply initial styling
|
||||
if is_selected:
|
||||
day_btn.config(style="Selected.TButton")
|
||||
elif is_today and not is_future:
|
||||
day_btn.config(style="Today.TButton")
|
||||
|
||||
|
||||
def prev_month():
|
||||
nonlocal display_month, display_year
|
||||
display_month -= 1
|
||||
if display_month < 1:
|
||||
display_month = 12
|
||||
display_year -= 1
|
||||
update_calendar()
|
||||
|
||||
def next_month():
|
||||
nonlocal display_month, display_year
|
||||
display_month += 1
|
||||
if display_month > 12:
|
||||
display_month = 1
|
||||
display_year += 1
|
||||
|
||||
# Don't allow navigation to future months
|
||||
if date(display_year, display_month, 1) > current_date.date():
|
||||
display_month -= 1
|
||||
if display_month < 1:
|
||||
display_month = 12
|
||||
display_year -= 1
|
||||
return
|
||||
|
||||
update_calendar()
|
||||
|
||||
def prev_year():
|
||||
nonlocal display_year
|
||||
display_year -= 1
|
||||
update_calendar()
|
||||
|
||||
def next_year():
|
||||
nonlocal display_year
|
||||
display_year += 1
|
||||
|
||||
# Don't allow navigation to future years
|
||||
if display_year > current_date.year:
|
||||
display_year -= 1
|
||||
return
|
||||
|
||||
update_calendar()
|
||||
|
||||
# Navigation buttons
|
||||
prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year)
|
||||
prev_year_btn.pack(side=tk.LEFT)
|
||||
|
||||
prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month)
|
||||
prev_month_btn.pack(side=tk.LEFT, padx=(5, 0))
|
||||
|
||||
next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month)
|
||||
next_month_btn.pack(side=tk.LEFT, padx=(5, 0))
|
||||
|
||||
next_year_btn = ttk.Button(nav_frame, text=">>", width=3, command=next_year)
|
||||
next_year_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Calendar grid frame
|
||||
calendar_frame = ttk.Frame(main_cal_frame)
|
||||
calendar_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||||
|
||||
# Configure grid weights
|
||||
for i in range(7):
|
||||
calendar_frame.columnconfigure(i, weight=1)
|
||||
for i in range(7):
|
||||
calendar_frame.rowconfigure(i, weight=1)
|
||||
|
||||
# Buttons frame
|
||||
buttons_frame = ttk.Frame(main_cal_frame)
|
||||
buttons_frame.pack(fill=tk.X)
|
||||
|
||||
def select_date():
|
||||
"""Select the date and close calendar"""
|
||||
if selected_date:
|
||||
calendar_window.selected_date = selected_date.strftime('%Y-%m-%d')
|
||||
else:
|
||||
calendar_window.selected_date = ""
|
||||
calendar_window.destroy()
|
||||
|
||||
def cancel_selection():
|
||||
"""Cancel date selection"""
|
||||
calendar_window.destroy()
|
||||
|
||||
# Buttons (matching original layout)
|
||||
ttk.Button(buttons_frame, text="Select", command=select_date).pack(side=tk.LEFT, padx=(0, 5))
|
||||
ttk.Button(buttons_frame, text="Cancel", command=cancel_selection).pack(side=tk.LEFT)
|
||||
|
||||
# Initialize calendar display
|
||||
update_calendar()
|
||||
|
||||
# Wait for window to close
|
||||
calendar_window.wait_window()
|
||||
|
||||
# Return selected date or None
|
||||
return getattr(calendar_window, 'selected_date', None)
|
||||
|
||||
def create_autocomplete_entry(self, parent, suggestions: list, callback: callable = None):
|
||||
"""Create an entry widget with autocomplete functionality"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
# Create entry
|
||||
entry_var = tk.StringVar()
|
||||
entry = ttk.Entry(parent, textvariable=entry_var)
|
||||
|
||||
# Create listbox for suggestions
|
||||
listbox = tk.Listbox(parent, height=8)
|
||||
listbox.place_forget() # Hide initially
|
||||
|
||||
def show_suggestions():
|
||||
"""Show filtered suggestions in listbox"""
|
||||
typed = entry_var.get().strip()
|
||||
|
||||
if not typed:
|
||||
filtered = [] # Show nothing if no typing
|
||||
else:
|
||||
low = typed.lower()
|
||||
# Only show names that start with the typed text
|
||||
filtered = [n for n in suggestions if n.lower().startswith(low)][:10]
|
||||
|
||||
# Update listbox
|
||||
listbox.delete(0, tk.END)
|
||||
for name in filtered:
|
||||
listbox.insert(tk.END, name)
|
||||
|
||||
# Show listbox if we have suggestions
|
||||
if filtered:
|
||||
# Position listbox below entry
|
||||
entry.update_idletasks()
|
||||
x = entry.winfo_x()
|
||||
y = entry.winfo_y() + entry.winfo_height()
|
||||
width = entry.winfo_width()
|
||||
|
||||
listbox.place(x=x, y=y, width=width)
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(0) # Select first item
|
||||
listbox.activate(0) # Activate first item
|
||||
else:
|
||||
listbox.place_forget()
|
||||
|
||||
def hide_suggestions():
|
||||
"""Hide the suggestions listbox"""
|
||||
listbox.place_forget()
|
||||
|
||||
def on_listbox_select(event=None):
|
||||
"""Handle listbox selection and hide list"""
|
||||
selection = listbox.curselection()
|
||||
if selection:
|
||||
selected_name = listbox.get(selection[0])
|
||||
entry_var.set(selected_name)
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
if callback:
|
||||
callback(selected_name)
|
||||
|
||||
def on_listbox_click(event):
|
||||
"""Handle mouse click selection"""
|
||||
try:
|
||||
index = listbox.nearest(event.y)
|
||||
if index is not None and index >= 0:
|
||||
selected_name = listbox.get(index)
|
||||
entry_var.set(selected_name)
|
||||
except:
|
||||
pass
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
if callback:
|
||||
callback(selected_name)
|
||||
return 'break'
|
||||
|
||||
def on_key_press(event):
|
||||
"""Handle key navigation in entry"""
|
||||
if event.keysym == 'Down':
|
||||
if listbox.winfo_viewable():
|
||||
listbox.focus_set()
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(0)
|
||||
listbox.activate(0)
|
||||
return 'break'
|
||||
elif event.keysym == 'Escape':
|
||||
hide_suggestions()
|
||||
return 'break'
|
||||
elif event.keysym == 'Return':
|
||||
return 'break'
|
||||
|
||||
def on_listbox_key(event):
|
||||
"""Handle key navigation in listbox"""
|
||||
if event.keysym == 'Return':
|
||||
on_listbox_select(event)
|
||||
return 'break'
|
||||
elif event.keysym == 'Escape':
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
return 'break'
|
||||
elif event.keysym == 'Up':
|
||||
selection = listbox.curselection()
|
||||
if selection and selection[0] > 0:
|
||||
# Move up in listbox
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(selection[0] - 1)
|
||||
listbox.see(selection[0] - 1)
|
||||
else:
|
||||
# At top, go back to entry field
|
||||
hide_suggestions()
|
||||
entry.focus_set()
|
||||
return 'break'
|
||||
elif event.keysym == 'Down':
|
||||
selection = listbox.curselection()
|
||||
max_index = listbox.size() - 1
|
||||
if selection and selection[0] < max_index:
|
||||
# Move down in listbox
|
||||
listbox.selection_clear(0, tk.END)
|
||||
listbox.selection_set(selection[0] + 1)
|
||||
listbox.see(selection[0] + 1)
|
||||
return 'break'
|
||||
|
||||
# Bind events
|
||||
entry.bind('<KeyRelease>', lambda e: show_suggestions())
|
||||
entry.bind('<KeyPress>', on_key_press)
|
||||
entry.bind('<FocusOut>', lambda e: parent.after(150, hide_suggestions)) # Delay to allow listbox clicks
|
||||
listbox.bind('<Button-1>', on_listbox_click)
|
||||
listbox.bind('<KeyPress>', on_listbox_key)
|
||||
listbox.bind('<Double-Button-1>', on_listbox_click)
|
||||
|
||||
return entry, entry_var, listbox
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,740 @@
|
||||
#!/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
|
||||
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) 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
Reference in New Issue
Block a user