#!/usr/bin/env python3 """ Integrated Identify Panel for PunimTag Dashboard Embeds the full identify GUI functionality into the dashboard frame """ import os import time 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_BATCH_SIZE, DEFAULT_FACE_TOLERANCE from database import DatabaseManager from face_processing import FaceProcessor from gui_core import GUICore class IdentifyPanel: """Integrated identify panel that embeds the full identify 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 identify 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.current_faces = [] self.current_face_index = 0 self.face_status = {} self.face_person_names = {} self.face_selection_states = {} self.identify_data_cache = {} self.current_face_crop_path = None # GUI components self.components = {} self.main_frame = None def create_panel(self) -> ttk.Frame: """Create the identify panel with all GUI components""" self.main_frame = ttk.Frame(self.parent_frame) # Configure grid weights self.main_frame.columnconfigure(0, weight=1) # Left panel self.main_frame.columnconfigure(1, weight=1) # Right panel for similar faces self.main_frame.rowconfigure(3, weight=0) # Configuration row - no expansion self.main_frame.rowconfigure(4, weight=1) # Main panels row - expandable # Photo info self.components['info_label'] = ttk.Label(self.main_frame, text="", font=("Arial", 10, "bold")) self.components['info_label'].grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) # 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 identify interface""" # Create variables for form data self.components['compare_var'] = tk.BooleanVar() self.components['unique_var'] = tk.BooleanVar() self.components['first_name_var'] = tk.StringVar() self.components['last_name_var'] = tk.StringVar() self.components['middle_name_var'] = tk.StringVar() self.components['maiden_name_var'] = tk.StringVar() self.components['date_of_birth_var'] = tk.StringVar() # Date filter variables self.components['date_from_var'] = tk.StringVar(value="") self.components['date_to_var'] = tk.StringVar(value="") self.components['date_processed_from_var'] = tk.StringVar(value="") self.components['date_processed_to_var'] = tk.StringVar(value="") # Date filter controls date_filter_frame = ttk.LabelFrame(self.main_frame, text="Filter", padding="5") date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W) date_filter_frame.columnconfigure(1, weight=0) date_filter_frame.columnconfigure(4, weight=0) # Date from ttk.Label(date_filter_frame, text="Taken date: from").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) self.components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_from_var'], width=10, state='readonly') self.components['date_from_entry'].grid(row=0, column=1, sticky=tk.W, padx=(0, 5)) # Calendar button for date from def open_calendar_from(): self._open_date_picker(self.components['date_from_var']) self.components['date_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_from) self.components['date_from_btn'].grid(row=0, column=2, padx=(0, 10)) # Date to ttk.Label(date_filter_frame, text="to").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) self.components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_to_var'], width=10, state='readonly') self.components['date_to_entry'].grid(row=0, column=4, sticky=tk.W, padx=(0, 5)) # Calendar button for date to def open_calendar_to(): self._open_date_picker(self.components['date_to_var']) self.components['date_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_to) self.components['date_to_btn'].grid(row=0, column=5, padx=(0, 10)) # Apply filter button def apply_date_filter(): """Apply date filters and reload faces""" self._apply_date_filters() self.components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) self.components['apply_filter_btn'].grid(row=0, column=6, padx=(10, 0)) # Date processed filter (second row) ttk.Label(date_filter_frame, text="Processed date: from").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) self.components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_from_var'], width=10, state='readonly') self.components['date_processed_from_entry'].grid(row=1, column=1, sticky=tk.W, padx=(0, 5), pady=(10, 0)) # Calendar button for date processed from def open_calendar_processed_from(): self._open_date_picker(self.components['date_processed_from_var']) self.components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_from) self.components['date_processed_from_btn'].grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) # Date processed to ttk.Label(date_filter_frame, text="to").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) self.components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=self.components['date_processed_to_var'], width=10, state='readonly') self.components['date_processed_to_entry'].grid(row=1, column=4, sticky=tk.W, padx=(0, 5), pady=(10, 0)) # Calendar button for date processed to def open_calendar_processed_to(): self._open_date_picker(self.components['date_processed_to_var']) self.components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to) self.components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) # Unique checkbox under the filter frame def on_unique_change(): """Handle unique faces checkbox change - filter main face list like old implementation""" if self.components['unique_var'].get(): # Show progress message print("🔄 Applying unique faces filter...") self.main_frame.update() # Update UI to show the message # Apply unique faces filtering to the main face list try: self.current_faces = self._filter_unique_faces_from_list(self.current_faces) print(f"✅ Filter applied: {len(self.current_faces)} unique faces remaining") except Exception as e: print(f"⚠️ Error applying filter: {e}") # Revert checkbox state self.components['unique_var'].set(False) return else: # Reload the original unfiltered face list print("🔄 Reloading all faces...") self.main_frame.update() # Update UI to show the message # Get current date filters date_from = self.components['date_from_var'].get().strip() or None date_to = self.components['date_to_var'].get().strip() or None date_processed_from = self.components['date_processed_from_var'].get().strip() or None date_processed_to = self.components['date_processed_to_var'].get().strip() or None # Get batch size try: batch_size = int(self.components['batch_var'].get().strip()) except Exception: batch_size = DEFAULT_BATCH_SIZE # Reload faces with current filters self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, date_processed_from, date_processed_to) print(f"✅ Reloaded: {len(self.current_faces)} faces") # Reset to first face and update display self.current_face_index = 0 if self.current_faces: self._update_current_face() self._update_button_states() # Update similar faces if compare is enabled if self.components['compare_var'].get(): face_id, _, _, _, _ = self.current_faces[self.current_face_index] self._update_similar_faces(face_id) self.components['unique_check'] = ttk.Checkbutton(self.main_frame, text="Unique faces only", variable=self.components['unique_var'], command=on_unique_change) self.components['unique_check'].grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=0) # Compare checkbox on the same row as Unique def on_compare_change(): # Toggle the similar faces functionality if self.components['compare_var'].get(): # Enable select all/clear all buttons self.components['select_all_btn'].config(state='normal') self.components['clear_all_btn'].config(state='normal') # Update similar faces if we have a current face if self.current_faces and self.current_face_index < len(self.current_faces): face_id, _, _, _, _ = self.current_faces[self.current_face_index] self._update_similar_faces(face_id) else: # Disable select all/clear all buttons self.components['select_all_btn'].config(state='disabled') self.components['clear_all_btn'].config(state='disabled') # Clear similar faces content scrollable_frame = self.components['similar_scrollable_frame'] for widget in scrollable_frame.winfo_children(): widget.destroy() # Show message that compare is disabled no_compare_label = ttk.Label(scrollable_frame, text="Enable 'Compare similar faces' to see similar faces", foreground="gray", font=("Arial", 10)) no_compare_label.pack(pady=20) self.components['compare_check'] = ttk.Checkbutton(self.main_frame, text="Compare similar faces", variable=self.components['compare_var'], command=on_compare_change) self.components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(0, 5), pady=0) # Command variable for button callbacks self.components['command_var'] = tk.StringVar() # Batch size configuration batch_frame = ttk.LabelFrame(self.main_frame, text="Configuration", padding="5") batch_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.W) ttk.Label(batch_frame, text="Batch size:").pack(side=tk.LEFT, padx=(0, 5)) self.components['batch_var'] = tk.StringVar(value=str(DEFAULT_BATCH_SIZE)) batch_entry = ttk.Entry(batch_frame, textvariable=self.components['batch_var'], width=8) batch_entry.pack(side=tk.LEFT, padx=(0, 10)) # Start button start_btn = ttk.Button(batch_frame, text="🚀 Start Identification", command=self._start_identification) start_btn.pack(side=tk.LEFT, padx=(10, 0)) def _create_main_panels(self): """Create the main left and right panels""" # Left panel for face display and identification self.components['left_panel'] = ttk.LabelFrame(self.main_frame, text="Face Identification", padding="10") self.components['left_panel'].grid(row=4, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) # Right panel for similar faces (always visible) self.components['right_panel'] = ttk.LabelFrame(self.main_frame, text="Similar Faces", padding="10") self.components['right_panel'].grid(row=4, 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() def _create_left_panel_content(self): """Create the left panel content for face identification""" left_panel = self.components['left_panel'] # Face image display self.components['face_canvas'] = tk.Canvas(left_panel, width=300, height=300, bg='white', relief='sunken', bd=2) self.components['face_canvas'].pack(pady=(0, 10)) # Person name fields name_frame = ttk.LabelFrame(left_panel, text="Person Information", padding="5") name_frame.pack(fill=tk.X, pady=(0, 10)) # First name ttk.Label(name_frame, text="First name *:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5), pady=2) first_name_entry = ttk.Entry(name_frame, textvariable=self.components['first_name_var'], width=20) first_name_entry.grid(row=0, column=1, sticky=tk.W, padx=(0, 10), pady=2) # Last name ttk.Label(name_frame, text="Last name *:").grid(row=0, column=2, sticky=tk.W, padx=(0, 5), pady=2) last_name_entry = ttk.Entry(name_frame, textvariable=self.components['last_name_var'], width=20) last_name_entry.grid(row=0, column=3, sticky=tk.W, pady=2) # Middle name ttk.Label(name_frame, text="Middle name:").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=2) middle_name_entry = ttk.Entry(name_frame, textvariable=self.components['middle_name_var'], width=20) middle_name_entry.grid(row=1, column=1, sticky=tk.W, padx=(0, 10), pady=2) # Maiden name ttk.Label(name_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(0, 5), pady=2) maiden_name_entry = ttk.Entry(name_frame, textvariable=self.components['maiden_name_var'], width=20) maiden_name_entry.grid(row=1, column=3, sticky=tk.W, pady=2) # Date of birth dob_frame = ttk.Frame(name_frame) dob_frame.grid(row=2, column=0, columnspan=4, sticky=tk.W, pady=2) ttk.Label(dob_frame, text="Date of birth *:").pack(side=tk.LEFT, padx=(0, 5)) dob_entry = ttk.Entry(dob_frame, textvariable=self.components['date_of_birth_var'], width=15, state='readonly') dob_entry.pack(side=tk.LEFT, padx=(0, 5)) def open_dob_calendar(): self._open_date_picker(self.components['date_of_birth_var']) dob_calendar_btn = ttk.Button(dob_frame, text="📅", width=3, command=open_dob_calendar) dob_calendar_btn.pack(side=tk.LEFT) # Add event handlers to update Identify button state def update_identify_button_state(*args): self._update_identify_button_state() self.components['first_name_var'].trace('w', update_identify_button_state) self.components['last_name_var'].trace('w', update_identify_button_state) self.components['date_of_birth_var'].trace('w', update_identify_button_state) # Required field asterisks are now included in the label text # Add autocomplete for last name self._setup_last_name_autocomplete(last_name_entry) # Control buttons button_frame = ttk.Frame(left_panel) button_frame.pack(fill=tk.X, pady=(10, 0)) self.components['identify_btn'] = ttk.Button(button_frame, text="✅ Identify", command=self._identify_face, state='disabled') self.components['identify_btn'].pack(side=tk.LEFT, padx=(0, 5)) self.components['back_btn'] = ttk.Button(button_frame, text="⬅️ Back", command=self._go_back) self.components['back_btn'].pack(side=tk.LEFT, padx=(0, 5)) self.components['next_btn'] = ttk.Button(button_frame, text="➡️ Next", command=self._go_next) self.components['next_btn'].pack(side=tk.LEFT, padx=(0, 5)) self.components['quit_btn'] = ttk.Button(button_frame, text="❌ Quit", command=self._quit_identification) self.components['quit_btn'].pack(side=tk.RIGHT) def _create_right_panel_content(self): """Create the right panel content for similar faces""" right_panel = self.components['right_panel'] # Select All/Clear All buttons select_frame = ttk.Frame(right_panel) select_frame.pack(fill=tk.X, pady=(0, 10)) self.components['select_all_btn'] = ttk.Button(select_frame, text="Select All", command=self._select_all_similar) self.components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) self.components['clear_all_btn'] = ttk.Button(select_frame, text="Clear All", command=self._clear_all_similar) self.components['clear_all_btn'].pack(side=tk.LEFT) # Initially disable these buttons self.components['select_all_btn'].config(state='disabled') self.components['clear_all_btn'].config(state='disabled') # Create a frame to hold canvas and scrollbar canvas_frame = ttk.Frame(right_panel) canvas_frame.pack(fill=tk.BOTH, expand=True) # Create canvas for similar faces with scrollbar similar_canvas = tk.Canvas(canvas_frame, bg='lightgray', relief='sunken', bd=2) similar_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Similar faces scrollbars similar_v_scrollbar = ttk.Scrollbar(canvas_frame, orient='vertical', command=similar_canvas.yview) similar_v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set) # Create scrollable frame for similar faces self.components['similar_scrollable_frame'] = ttk.Frame(similar_canvas) similar_canvas.create_window((0, 0), window=self.components['similar_scrollable_frame'], anchor='nw') # Store canvas reference for scrolling self.components['similar_canvas'] = similar_canvas # Add initial message when compare is disabled no_compare_label = ttk.Label(self.components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces", foreground="gray", font=("Arial", 10)) no_compare_label.pack(pady=20) def _start_identification(self): """Start the identification process""" try: batch_size = int(self.components['batch_var'].get().strip()) if batch_size <= 0: raise ValueError except Exception: messagebox.showerror("Error", "Please enter a valid positive integer for batch size.") return # Get date filters date_from = self.components['date_from_var'].get().strip() or None date_to = self.components['date_to_var'].get().strip() or None date_processed_from = self.components['date_processed_from_var'].get().strip() or None date_processed_to = self.components['date_processed_to_var'].get().strip() or None # Get unidentified faces self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, date_processed_from, date_processed_to) if not self.current_faces: messagebox.showinfo("No Faces", "🎉 All faces have been identified!") return # Pre-fetch data for optimal performance self.identify_data_cache = self._prefetch_identify_data(self.current_faces) # Reset state self.current_face_index = 0 self.face_status = {} self.face_person_names = {} self.face_selection_states = {} # Show the first face self._update_current_face() # Enable/disable buttons self._update_button_states() self.is_active = True def _get_unidentified_faces(self, batch_size: int, date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> List[Tuple]: """Get unidentified faces from database with optional date filtering""" with self.db.get_db_connection() as conn: cursor = conn.cursor() # Build the SQL query with optional date filtering query = ''' SELECT f.id, f.photo_id, p.path, p.filename, f.location FROM faces f JOIN photos p ON f.photo_id = p.id WHERE f.person_id IS NULL ''' params = [] # Add date taken filtering if specified if date_from: query += ' AND p.date_taken >= ?' params.append(date_from) if date_to: query += ' AND p.date_taken <= ?' params.append(date_to) # Add date processed filtering if specified if date_processed_from: query += ' AND DATE(p.date_added) >= ?' params.append(date_processed_from) if date_processed_to: query += ' AND DATE(p.date_added) <= ?' params.append(date_processed_to) query += ' LIMIT ?' params.append(batch_size) cursor.execute(query, params) return cursor.fetchall() def _prefetch_identify_data(self, faces: List[Tuple]) -> Dict: """Pre-fetch all needed data to avoid repeated database queries""" cache = { 'photo_paths': {}, 'people_names': [], 'last_names': [], 'face_encodings': {} } with self.db.get_db_connection() as conn: cursor = conn.cursor() # Get photo paths photo_ids = [face[1] for face in faces] if photo_ids: placeholders = ','.join(['?' for _ in photo_ids]) cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids) cache['photo_paths'] = dict(cursor.fetchall()) # Get people names cursor.execute('SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people ORDER BY last_name, first_name') cache['people_names'] = cursor.fetchall() # Pre-fetch unique last names for autocomplete (no DB during typing) cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') _last_rows = cursor.fetchall() cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) # Get face encodings for similar face matching face_ids = [face[0] for face in faces] if face_ids: placeholders = ','.join(['?' for _ in face_ids]) cursor.execute(f'SELECT id, encoding FROM faces WHERE id IN ({placeholders})', face_ids) cache['face_encodings'] = dict(cursor.fetchall()) return cache def _filter_unique_faces_from_list(self, faces_list: List[Tuple]) -> List[Tuple]: """Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches""" if not faces_list: return faces_list # Extract face IDs from the list face_ids = [face_tuple[0] for face_tuple in faces_list] # Get face encodings from database for all faces face_encodings = {} with self.db.get_db_connection() as conn: cursor = conn.cursor() placeholders = ','.join('?' * len(face_ids)) cursor.execute(f''' SELECT id, encoding FROM faces WHERE id IN ({placeholders}) AND encoding IS NOT NULL ''', face_ids) for face_id, encoding_blob in cursor.fetchall(): try: import numpy as np # Load encoding as numpy array (not pickle) encoding = np.frombuffer(encoding_blob, dtype=np.float64) face_encodings[face_id] = encoding except Exception: continue # If we don't have enough encodings, return original list if len(face_encodings) < 2: return faces_list # Calculate distances between all faces using existing encodings face_distances = {} face_id_list = list(face_encodings.keys()) for i, face_id1 in enumerate(face_id_list): for j, face_id2 in enumerate(face_id_list): if i != j: try: import face_recognition encoding1 = face_encodings[face_id1] encoding2 = face_encodings[face_id2] # Calculate distance distance = face_recognition.face_distance([encoding1], encoding2)[0] face_distances[(face_id1, face_id2)] = distance except Exception: # If calculation fails, assume no match face_distances[(face_id1, face_id2)] = 1.0 # Apply unique faces filtering unique_faces = [] seen_face_groups = set() for face_tuple in faces_list: face_id = face_tuple[0] # Skip if we don't have encoding for this face if face_id not in face_encodings: unique_faces.append(face_tuple) continue # Find all faces that match this one with high/medium confidence matching_face_ids = set([face_id]) # Include self for other_face_id in face_encodings.keys(): if other_face_id != face_id: distance = face_distances.get((face_id, other_face_id), 1.0) confidence_pct = (1 - distance) * 100 # If this face matches with high/medium confidence if confidence_pct >= 60: matching_face_ids.add(other_face_id) # Create a sorted tuple to represent this group of matching faces face_group = tuple(sorted(matching_face_ids)) # Only show this face if we haven't seen this group before if face_group not in seen_face_groups: seen_face_groups.add(face_group) unique_faces.append(face_tuple) return unique_faces def _update_current_face(self): """Update the display for the current face""" if not self.current_faces or self.current_face_index >= len(self.current_faces): return face_id, photo_id, photo_path, filename, location = self.current_faces[self.current_face_index] # Update info label self.components['info_label'].config(text=f"Face {self.current_face_index + 1} of {len(self.current_faces)} - {filename}") # Extract and display face crop (show_faces is always True) face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) self.current_face_crop_path = face_crop_path # Update face image self._update_face_image(face_crop_path, photo_path) # Check if face is already identified is_identified = face_id in self.face_status and self.face_status[face_id] == 'identified' # Restore person name input - restore saved name or use database/empty value self._restore_person_name_input(face_id, is_identified) # Update similar faces if compare is enabled if self.components['compare_var'].get(): self._update_similar_faces(face_id) def _update_face_image(self, face_crop_path: str, photo_path: str): """Update the face image display""" try: if face_crop_path and os.path.exists(face_crop_path): # Load and display face crop image = Image.open(face_crop_path) # Resize to exactly fill the 300x300 frame image = image.resize((300, 300), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(image) # Clear canvas and display image canvas = self.components['face_canvas'] canvas.delete("all") # Position image at top-left corner like the original canvas.create_image(0, 0, image=photo, anchor=tk.NW) canvas.image = photo # Keep a reference # Add photo icon exactly at the image's top-right corner # Image starts at (0, 0) and is 300x300, so top-right corner is at (300, 0) self.gui_core.create_photo_icon(canvas, photo_path, icon_size=20, face_x=0, face_y=0, face_width=300, face_height=300, canvas_width=300, canvas_height=300) else: # Clear canvas if no image canvas = self.components['face_canvas'] canvas.delete("all") canvas.create_text(150, 150, text="No face image", fill="gray") except Exception as e: print(f"Error updating face image: {e}") def _restore_person_name_input(self, face_id: int, is_identified: bool): """Restore person name input fields""" try: if is_identified: # Get person data from database with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth FROM people p JOIN faces f ON p.id = f.person_id WHERE f.id = ? ''', (face_id,)) result = cursor.fetchone() if result: first_name, last_name, middle_name, maiden_name, date_of_birth = result self.components['first_name_var'].set(first_name or "") self.components['last_name_var'].set(last_name or "") self.components['middle_name_var'].set(middle_name or "") self.components['maiden_name_var'].set(maiden_name or "") self.components['date_of_birth_var'].set(date_of_birth or "") else: # Clear all fields if no person found self._clear_form() else: # Restore from saved data if available if face_id in self.face_person_names: person_data = self.face_person_names[face_id] if isinstance(person_data, dict): self.components['first_name_var'].set(person_data.get('first_name', '')) self.components['last_name_var'].set(person_data.get('last_name', '')) self.components['middle_name_var'].set(person_data.get('middle_name', '')) self.components['maiden_name_var'].set(person_data.get('maiden_name', '')) self.components['date_of_birth_var'].set(person_data.get('date_of_birth', '')) else: # Legacy string format self.components['first_name_var'].set(person_data or "") self.components['last_name_var'].set("") self.components['middle_name_var'].set("") self.components['maiden_name_var'].set("") self.components['date_of_birth_var'].set("") else: # Clear all fields for new face self._clear_form() except Exception as e: print(f"❌ Error restoring person name input: {e}") # Clear form on error self._clear_form() def _update_identify_button_state(self): """Update the identify button state based on form completion""" first_name = self.components['first_name_var'].get().strip() last_name = self.components['last_name_var'].get().strip() date_of_birth = self.components['date_of_birth_var'].get().strip() # Enable button only if all required fields are filled if first_name and last_name and date_of_birth: self.components['identify_btn'].config(state='normal') else: self.components['identify_btn'].config(state='disabled') def _setup_last_name_autocomplete(self, last_name_entry): """Setup autocomplete functionality for last name field - exactly like old implementation""" # Create listbox for suggestions (as overlay attached to main frame, not clipped by frames) last_name_listbox = tk.Listbox(self.main_frame, height=8) last_name_listbox.place_forget() # Hide initially def _show_suggestions(): """Show filtered suggestions in listbox""" all_last_names = self.identify_data_cache.get('last_names', []) typed = self.components['last_name_var'].get().strip() if not typed: filtered = [] # Show nothing if no typing else: low = typed.lower() # Only show names that start with the typed text filtered = [n for n in all_last_names if n.lower().startswith(low)][:10] # Update listbox last_name_listbox.delete(0, tk.END) for name in filtered: last_name_listbox.insert(tk.END, name) # Show listbox if we have suggestions (as overlay) if filtered: # Ensure geometry is up to date before positioning self.main_frame.update_idletasks() # Absolute coordinates of entry relative to screen entry_root_x = last_name_entry.winfo_rootx() entry_root_y = last_name_entry.winfo_rooty() entry_height = last_name_entry.winfo_height() # Convert to coordinates relative to main frame main_frame_origin_x = self.main_frame.winfo_rootx() main_frame_origin_y = self.main_frame.winfo_rooty() place_x = entry_root_x - main_frame_origin_x place_y = entry_root_y - main_frame_origin_y + entry_height place_width = last_name_entry.winfo_width() # Calculate how many rows fit to bottom of window available_px = max(60, self.main_frame.winfo_height() - place_y - 8) # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows approx_row_px = 18 rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) last_name_listbox.configure(height=rows_fit) last_name_listbox.place(x=place_x, y=place_y, width=place_width) last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(0) # Select first item last_name_listbox.activate(0) # Activate first item else: last_name_listbox.place_forget() def _hide_suggestions(): """Hide the suggestions listbox""" last_name_listbox.place_forget() def _on_listbox_select(event=None): """Handle listbox selection and hide list""" selection = last_name_listbox.curselection() if selection: selected_name = last_name_listbox.get(selection[0]) self.components['last_name_var'].set(selected_name) _hide_suggestions() last_name_entry.focus_set() def _on_listbox_click(event): """Handle mouse click selection""" try: index = last_name_listbox.nearest(event.y) if index is not None and index >= 0: selected_name = last_name_listbox.get(index) self.components['last_name_var'].set(selected_name) except: pass _hide_suggestions() last_name_entry.focus_set() return 'break' def _on_key_press(event): """Handle key navigation in entry""" nonlocal navigating_to_listbox, escape_pressed, enter_pressed if event.keysym == 'Down': if last_name_listbox.winfo_ismapped(): navigating_to_listbox = True last_name_listbox.focus_set() last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(0) last_name_listbox.activate(0) return 'break' elif event.keysym == 'Escape': escape_pressed = True _hide_suggestions() return 'break' elif event.keysym == 'Return': enter_pressed = True return 'break' def _on_listbox_key(event): """Handle key navigation in listbox""" nonlocal enter_pressed, escape_pressed if event.keysym == 'Return': enter_pressed = True _on_listbox_select(event) return 'break' elif event.keysym == 'Escape': escape_pressed = True _hide_suggestions() last_name_entry.focus_set() return 'break' elif event.keysym == 'Up': selection = last_name_listbox.curselection() if selection and selection[0] > 0: # Move up in listbox last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(selection[0] - 1) last_name_listbox.see(selection[0] - 1) else: # At top, go back to entry field _hide_suggestions() last_name_entry.focus_set() return 'break' elif event.keysym == 'Down': selection = last_name_listbox.curselection() max_index = last_name_listbox.size() - 1 if selection and selection[0] < max_index: # Move down in listbox last_name_listbox.selection_clear(0, tk.END) last_name_listbox.selection_set(selection[0] + 1) last_name_listbox.see(selection[0] + 1) return 'break' # Track if we're navigating to listbox to prevent auto-hide navigating_to_listbox = False escape_pressed = False enter_pressed = False def _safe_hide_suggestions(): """Hide suggestions only if not navigating to listbox""" nonlocal navigating_to_listbox if not navigating_to_listbox: _hide_suggestions() navigating_to_listbox = False def _safe_show_suggestions(): """Show suggestions only if escape or enter wasn't just pressed""" nonlocal escape_pressed, enter_pressed if not escape_pressed and not enter_pressed: _show_suggestions() escape_pressed = False enter_pressed = False # Bind events last_name_entry.bind('', lambda e: _safe_show_suggestions()) last_name_entry.bind('', _on_key_press) last_name_entry.bind('', lambda e: self.main_frame.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks last_name_listbox.bind('', _on_listbox_click) last_name_listbox.bind('', _on_listbox_key) last_name_listbox.bind('', _on_listbox_click) def _clear_form(self): """Clear the identification form""" self.components['first_name_var'].set('') self.components['last_name_var'].set('') self.components['middle_name_var'].set('') self.components['maiden_name_var'].set('') self.components['date_of_birth_var'].set('') def _update_similar_faces(self, face_id: int): """Update the similar faces panel""" # Enable select all/clear all buttons self.components['select_all_btn'].config(state='normal') self.components['clear_all_btn'].config(state='normal') # Find similar faces using the filtered method like the original similar_faces = self.face_processor._get_filtered_similar_faces(face_id, DEFAULT_FACE_TOLERANCE, include_same_photo=False, face_status=self.face_status) # Clear existing similar faces scrollable_frame = self.components['similar_scrollable_frame'] for widget in scrollable_frame.winfo_children(): widget.destroy() # Display similar faces if similar_faces: # Sort by confidence (distance) - highest confidence first (lowest distance) similar_faces.sort(key=lambda x: x['distance']) # Ensure photo paths are available for similar faces self._ensure_photo_paths_for_similar_faces(similar_faces) # Display similar faces using the original approach self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id) # Update canvas scroll region canvas = self.components['similar_canvas'] canvas.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all")) else: no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found", foreground="gray", font=("Arial", 10)) no_faces_label.pack(pady=20) def _ensure_photo_paths_for_similar_faces(self, similar_faces): """Ensure photo paths are available in cache for similar faces""" # Get photo IDs from similar faces that are not in cache missing_photo_ids = [] for face_data in similar_faces: photo_id = face_data['photo_id'] if photo_id not in self.identify_data_cache['photo_paths']: missing_photo_ids.append(photo_id) # Fetch missing photo paths from database if missing_photo_ids: with self.db.get_db_connection() as conn: cursor = conn.cursor() placeholders = ','.join(['?' for _ in missing_photo_ids]) cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', missing_photo_ids) missing_photo_paths = dict(cursor.fetchall()) # Add to cache self.identify_data_cache['photo_paths'].update(missing_photo_paths) def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id): """Display similar faces in a panel - based on original implementation""" # Store similar face variables for Select All/Clear All functionality similar_face_vars = [] # Create all similar faces using auto-match style display for i, face_data in enumerate(similar_faces_data[:10]): # Limit to 10 faces similar_face_id = face_data['face_id'] filename = face_data['filename'] distance = face_data['distance'] quality = face_data.get('quality_score', 0.5) # Calculate confidence like in auto-match confidence_pct = (1 - distance) * 100 confidence_desc = self._get_confidence_description(confidence_pct) # Create match frame using auto-match style match_frame = ttk.Frame(parent_frame) match_frame.pack(fill=tk.X, padx=5, pady=5) # Checkbox for this match (reusing auto-match checkbox style) match_var = tk.BooleanVar() similar_face_vars.append((similar_face_id, match_var)) # Store the variable for later use if similar_face_id not in self.face_selection_states: self.face_selection_states[similar_face_id] = {} self.face_selection_states[similar_face_id]['var'] = match_var # Restore previous checkbox state if available (auto-match style) if similar_face_id in self.face_selection_states and 'var' in self.face_selection_states[similar_face_id]: prev_var = self.face_selection_states[similar_face_id]['var'] if hasattr(prev_var, 'get'): match_var.set(prev_var.get()) # Checkbox checkbox = ttk.Checkbutton(match_frame, variable=match_var) checkbox.pack(side=tk.LEFT, padx=(0, 5)) # Face image (moved to be right after checkbox) try: photo_id = face_data['photo_id'] location = face_data['location'] photo_path = self.identify_data_cache['photo_paths'].get(photo_id, '') if photo_path: face_crop_path = self.face_processor._extract_face_crop(photo_path, location, similar_face_id) if face_crop_path and os.path.exists(face_crop_path): image = Image.open(face_crop_path) image.thumbnail((50, 50), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(image) # Create a canvas for the face image to allow photo icon drawing face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0) face_canvas.pack(side=tk.LEFT, padx=(0, 5)) face_canvas.create_image(25, 25, image=photo, anchor=tk.CENTER) face_canvas.image = photo # Keep reference # Add photo icon exactly at the image's top-right corner self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15, face_x=0, face_y=0, face_width=50, face_height=50, canvas_width=50, canvas_height=50) else: # Face crop extraction failed or file doesn't exist print(f"Face crop not available for face {similar_face_id}: {face_crop_path}") # Create placeholder canvas face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') face_canvas.pack(side=tk.LEFT, padx=(0, 5)) face_canvas.create_text(25, 25, text="No\nImage", fill="gray", font=("Arial", 8)) else: # Photo path not found in cache print(f"Photo path not found for photo_id {photo_id} in cache") # Create placeholder canvas face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') face_canvas.pack(side=tk.LEFT, padx=(0, 5)) face_canvas.create_text(25, 25, text="No\nPath", fill="gray", font=("Arial", 8)) except Exception as e: print(f"Error creating similar face widget for face {similar_face_id}: {e}") # Create placeholder canvas on error face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray') face_canvas.pack(side=tk.LEFT, padx=(0, 5)) face_canvas.create_text(25, 25, text="Error", fill="red", font=("Arial", 8)) # Confidence label with color coding and description confidence_text = f"{confidence_pct:.0f}% {confidence_desc}" if confidence_pct >= 80: color = "green" elif confidence_pct >= 70: color = "orange" elif confidence_pct >= 60: color = "red" else: color = "gray" confidence_label = ttk.Label(match_frame, text=confidence_text, foreground=color, font=("Arial", 8, "bold")) confidence_label.pack(side=tk.LEFT, padx=(0, 5)) # Filename filename_label = ttk.Label(match_frame, text=filename, font=("Arial", 8)) filename_label.pack(side=tk.LEFT, padx=(0, 5)) # Store the similar face variables for Select All/Clear All functionality self.similar_face_vars = similar_face_vars def _get_confidence_description(self, confidence_pct: float) -> str: """Get confidence description based on percentage""" if confidence_pct >= 80: return "(Very High)" elif confidence_pct >= 70: return "(High)" elif confidence_pct >= 60: return "(Medium)" elif confidence_pct >= 50: return "(Low)" else: return "(Very Low - Unlikely)" def _identify_face(self): """Identify the current face""" first_name = self.components['first_name_var'].get().strip() last_name = self.components['last_name_var'].get().strip() date_of_birth = self.components['date_of_birth_var'].get().strip() if not first_name or not last_name or not date_of_birth: messagebox.showwarning("Missing Information", "Please fill in first name, last name, and date of birth.") return if not self.current_faces or self.current_face_index >= len(self.current_faces): return face_id, photo_id, photo_path, filename, location = self.current_faces[self.current_face_index] # Get person data person_data = { 'first_name': first_name, 'last_name': last_name, 'middle_name': self.components['middle_name_var'].get().strip(), 'maiden_name': self.components['maiden_name_var'].get().strip(), 'date_of_birth': date_of_birth } # Store the identification self.face_person_names[face_id] = person_data self.face_status[face_id] = 'identified' # Save to database self._save_identification(face_id, person_data) # If compare mode is active, identify selected similar faces if self.components['compare_var'].get(): self._identify_selected_similar_faces(person_data) # Move to next face self._go_next() def _save_identification(self, face_id: int, person_data: Dict): """Save face identification to database""" try: with self.db.get_db_connection() as conn: cursor = conn.cursor() # Normalize names to title case for case-insensitive matching normalized_data = { 'first_name': person_data['first_name'].strip().title(), 'last_name': person_data['last_name'].strip().title(), 'middle_name': person_data['middle_name'].strip().title() if person_data['middle_name'] else '', 'maiden_name': person_data['maiden_name'].strip().title() if person_data['maiden_name'] else '', 'date_of_birth': person_data['date_of_birth'].strip() } # Check if person already exists (case-insensitive) cursor.execute(''' SELECT id FROM people WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?) AND LOWER(COALESCE(middle_name, '')) = LOWER(?) AND LOWER(COALESCE(maiden_name, '')) = LOWER(?) AND date_of_birth = ? ''', (normalized_data['first_name'], normalized_data['last_name'], normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth'])) person_row = cursor.fetchone() if person_row: person_id = person_row[0] else: # Create new person cursor.execute(''' INSERT INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?) ''', (normalized_data['first_name'], normalized_data['last_name'], normalized_data['middle_name'], normalized_data['maiden_name'], normalized_data['date_of_birth'])) person_id = cursor.lastrowid # Update face with person_id cursor.execute('UPDATE faces SET person_id = ? WHERE id = ?', (person_id, face_id)) conn.commit() except Exception as e: print(f"Error saving identification: {e}") messagebox.showerror("Error", f"Failed to save identification: {e}") def _identify_selected_similar_faces(self, person_data: Dict): """Identify selected similar faces with the same person""" if hasattr(self, 'similar_face_vars'): for face_id, var in self.similar_face_vars: if var.get(): # This face is selected, identify it self.face_person_names[face_id] = person_data self.face_status[face_id] = 'identified' self._save_identification(face_id, person_data) def _go_back(self): """Go back to the previous face""" if self.current_face_index > 0: # Validate navigation (check for unsaved changes) validation_result = self._validate_navigation() if validation_result == 'cancel': return # Cancel navigation elif validation_result == 'save_and_continue': # Save the current identification before proceeding if self.current_faces and self.current_face_index < len(self.current_faces): face_id, _, _, _, _ = self.current_faces[self.current_face_index] first_name = self.components['first_name_var'].get().strip() last_name = self.components['last_name_var'].get().strip() date_of_birth = self.components['date_of_birth_var'].get().strip() if first_name and last_name and date_of_birth: person_data = { 'first_name': first_name, 'last_name': last_name, 'middle_name': self.components['middle_name_var'].get().strip(), 'maiden_name': self.components['maiden_name_var'].get().strip(), 'date_of_birth': date_of_birth } self.face_person_names[face_id] = person_data self.face_status[face_id] = 'identified' elif validation_result == 'discard_and_continue': # Clear the form but don't save self._clear_form() self.current_face_index -= 1 self._update_current_face() self._update_button_states() def _go_next(self): """Go to the next face""" if self.current_face_index < len(self.current_faces) - 1: # Validate navigation (check for unsaved changes) validation_result = self._validate_navigation() if validation_result == 'cancel': return # Cancel navigation elif validation_result == 'save_and_continue': # Save the current identification before proceeding if self.current_faces and self.current_face_index < len(self.current_faces): face_id, _, _, _, _ = self.current_faces[self.current_face_index] first_name = self.components['first_name_var'].get().strip() last_name = self.components['last_name_var'].get().strip() date_of_birth = self.components['date_of_birth_var'].get().strip() if first_name and last_name and date_of_birth: person_data = { 'first_name': first_name, 'last_name': last_name, 'middle_name': self.components['middle_name_var'].get().strip(), 'maiden_name': self.components['maiden_name_var'].get().strip(), 'date_of_birth': date_of_birth } self.face_person_names[face_id] = person_data self.face_status[face_id] = 'identified' elif validation_result == 'discard_and_continue': # Clear the form but don't save self._clear_form() self.current_face_index += 1 self._update_current_face() self._update_button_states() else: # Check if there are more faces to load self._load_more_faces() def _load_more_faces(self): """Load more faces if available""" # Get current date filters date_from = self.components['date_from_var'].get().strip() or None date_to = self.components['date_to_var'].get().strip() or None date_processed_from = self.components['date_processed_from_var'].get().strip() or None date_processed_to = self.components['date_processed_to_var'].get().strip() or None # Get more faces more_faces = self._get_unidentified_faces(DEFAULT_BATCH_SIZE, date_from, date_to, date_processed_from, date_processed_to) if more_faces: # Add to current faces self.current_faces.extend(more_faces) self.current_face_index += 1 self._update_current_face() self._update_button_states() else: # No more faces messagebox.showinfo("Complete", "🎉 All faces have been identified!") self._quit_identification() def _update_button_states(self): """Update button states based on current position""" self.components['back_btn'].config(state='normal' if self.current_face_index > 0 else 'disabled') self.components['next_btn'].config(state='normal' if self.current_face_index < len(self.current_faces) - 1 else 'disabled') def _select_all_similar(self): """Select all similar faces""" if hasattr(self, 'similar_face_vars'): for face_id, var in self.similar_face_vars: var.set(True) def _clear_all_similar(self): """Clear all similar face selections""" if hasattr(self, 'similar_face_vars'): for face_id, var in self.similar_face_vars: var.set(False) def _quit_identification(self): """Quit the identification process""" # First check for unsaved changes in the current form validation_result = self._validate_navigation() if validation_result == 'cancel': return # Cancel quit elif validation_result == 'save_and_continue': # Save the current identification before proceeding if self.current_faces and self.current_face_index < len(self.current_faces): face_id, _, _, _, _ = self.current_faces[self.current_face_index] first_name = self.components['first_name_var'].get().strip() last_name = self.components['last_name_var'].get().strip() date_of_birth = self.components['date_of_birth_var'].get().strip() if first_name and last_name and date_of_birth: person_data = { 'first_name': first_name, 'last_name': last_name, 'middle_name': self.components['middle_name_var'].get().strip(), 'maiden_name': self.components['maiden_name_var'].get().strip(), 'date_of_birth': date_of_birth } self.face_person_names[face_id] = person_data self.face_status[face_id] = 'identified' elif validation_result == 'discard_and_continue': # Clear the form but don't save self._clear_form() # Check for pending identifications pending_identifications = self._get_pending_identifications() if pending_identifications: result = messagebox.askyesnocancel( "Save Pending Identifications?", f"You have {len(pending_identifications)} pending identifications.\n\n" "Do you want to save them before quitting?\n\n" "• Yes: Save all pending identifications and quit\n" "• No: Quit without saving\n" "• Cancel: Return to identification" ) if result is True: # Yes - Save and quit self._save_all_pending_identifications() elif result is False: # No - Quit without saving pass else: # Cancel - Don't quit return # Clean up self._cleanup() self.is_active = False def _validate_navigation(self): """Validate that navigation is safe (no unsaved changes)""" # Check if there are any unsaved changes in the form first_name = self.components['first_name_var'].get().strip() last_name = self.components['last_name_var'].get().strip() date_of_birth = self.components['date_of_birth_var'].get().strip() # If all three required fields are filled, ask for confirmation if first_name and last_name and date_of_birth: result = messagebox.askyesnocancel( "Unsaved Changes", "You have unsaved changes in the identification form.\n\n" "Do you want to save them before continuing?\n\n" "• Yes: Save current identification and continue\n" "• No: Discard changes and continue\n" "• Cancel: Stay on current face" ) if result is True: # Yes - Save and continue return 'save_and_continue' elif result is False: # No - Discard and continue return 'discard_and_continue' else: # Cancel - Don't navigate return 'cancel' return 'continue' # No changes, safe to continue def _get_pending_identifications(self) -> List[int]: """Get list of face IDs with pending identifications""" pending = [] for face_id, person_data in self.face_person_names.items(): if face_id not in self.face_status or self.face_status[face_id] != 'identified': # Check if form has complete data if (person_data.get('first_name') and person_data.get('last_name') and person_data.get('date_of_birth')): pending.append(face_id) return pending def _save_all_pending_identifications(self): """Save all pending identifications""" for face_id in self._get_pending_identifications(): person_data = self.face_person_names[face_id] self._save_identification(face_id, person_data) self.face_status[face_id] = 'identified' def _cleanup(self): """Clean up resources""" if self.current_face_crop_path: self.face_processor.cleanup_face_crops(self.current_face_crop_path) # Clear state self.current_faces = [] self.current_face_index = 0 self.face_status = {} self.face_person_names = {} self.face_selection_states = {} self.identify_data_cache = {} if hasattr(self, 'similar_face_vars'): self.similar_face_vars = [] # Clear right panel content scrollable_frame = self.components['similar_scrollable_frame'] for widget in scrollable_frame.winfo_children(): widget.destroy() # Show message that compare is disabled no_compare_label = ttk.Label(scrollable_frame, text="Enable 'Compare similar faces' to see similar faces", foreground="gray", font=("Arial", 10)) no_compare_label.pack(pady=20) # Clear form self._clear_form() # Clear info label self.components['info_label'].config(text="") # Clear face canvas self.components['face_canvas'].delete("all") def _apply_date_filters(self): """Apply date filters and reload faces""" # Get current filter values date_from = self.components['date_from_var'].get().strip() or None date_to = self.components['date_to_var'].get().strip() or None date_processed_from = self.components['date_processed_from_var'].get().strip() or None date_processed_to = self.components['date_processed_to_var'].get().strip() or None # Get batch size try: batch_size = int(self.components['batch_var'].get().strip()) except Exception: batch_size = DEFAULT_BATCH_SIZE # Reload faces with new filters self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to, date_processed_from, date_processed_to) if not self.current_faces: messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") return # Reset state self.current_face_index = 0 self.face_status = {} self.face_person_names = {} self.face_selection_states = {} # Pre-fetch data self.identify_data_cache = self._prefetch_identify_data(self.current_faces) # Show the first face self._update_current_face() self._update_button_states() self.is_active = True def _open_date_picker(self, date_var: tk.StringVar): """Open date picker dialog""" current_date = date_var.get() selected_date = self.gui_core.create_calendar_dialog(None, "Select Date", current_date) if selected_date is not None: date_var.set(selected_date) 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