This commit introduces the ModifyPanel class into the Dashboard GUI, providing a fully integrated interface for editing identified faces. Users can now view and modify person details, unmatch faces, and perform bulk operations with visual confirmation. The panel includes a responsive layout, search functionality for filtering people by last name, and a calendar interface for date selection. The README has been updated to reflect the new capabilities of the Modify Panel, emphasizing its full functionality and improved user experience in managing photo identifications.
734 lines
32 KiB
Python
734 lines
32 KiB
Python
#!/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 config import DEFAULT_FACE_TOLERANCE
|
|
from database import DatabaseManager
|
|
from face_processing import FaceProcessor
|
|
from 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, 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.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("400x300")
|
|
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 = messagebox.askyesnocancel(
|
|
"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"
|
|
)
|
|
|
|
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
|
|
|
|
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"))
|