From a51ffcfaa0e903f91687e00761dab0b857f4e312 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 3 Oct 2025 14:49:08 -0400 Subject: [PATCH] Add face identification GUI and related features to PhotoTagger This commit introduces a new IdentifyGUI class for handling face identification within the PunimTag application. The GUI allows users to identify faces interactively, with features such as batch processing, date filtering, and a user-friendly interface for entering person details. The PhotoTagger class is updated to integrate this new functionality, enabling seamless face identification directly from the tagging interface. Additionally, enhancements to the calendar dialog and improved quit validation are included, ensuring a smoother user experience. The README is updated to reflect these new features and usage instructions. --- README.md | 26 +- database.py | 13 + face_processing.py | 364 +++++++- gui_core.py | 507 +++++++++- identify_gui.py | 2224 ++++++++++++++++++++++++++++++++++++++++++++ photo_tagger.py | 22 +- 6 files changed, 3093 insertions(+), 63 deletions(-) create mode 100644 identify_gui.py diff --git a/README.md b/README.md index e6dfd73..1c6187a 100644 --- a/README.md +++ b/README.md @@ -646,18 +646,25 @@ When you click the šŸ“… calendar button, you'll see: **Calendar Features:** - **Visual Grid Layout** - Traditional 7x7 calendar with clickable dates - **Month/Year Navigation** - Use << >> < > buttons to navigate -- **Date Selection** - Click any date to select it +- **Date Selection** - Click any date to select it (doesn't close calendar immediately) - **Visual Feedback** - Selected dates highlighted in bright blue, today's date in orange +- **Future Date Restrictions** - Future dates are disabled and grayed out (birth dates cannot be in the future) - **Smart Pre-population** - Opens to existing date when editing previous identifications - **Smooth Operation** - Opens centered without flickering **Calendar Navigation:** - **<< >>** - Jump by year (limited to 1900-current year) -- **< >** - Navigate by month -- **Click Date** - Select any visible date -- **Select Button** - Confirm your date choice +- **< >** - Navigate by month (prevents navigation to future months) +- **Click Date** - Select any visible date (highlights in blue, doesn't close calendar) +- **Select Button** - Confirm your date choice and close calendar - **Cancel Button** - Close without selecting +**New Calendar Behavior:** +- **Two-Step Process** - Click date to select, then click "Select" to confirm +- **Future Date Protection** - Cannot select dates after today (logical for birth dates) +- **Smart Navigation** - Month/year buttons prevent going to future periods +- **Visual Clarity** - Selected dates clearly highlighted, future dates clearly disabled + ### GUI Tips - **Window Resizing**: Resize the window - it remembers your size preference - **Keyboard Shortcuts**: Press Enter in the name field to identify @@ -668,7 +675,9 @@ When you click the šŸ“… calendar button, you'll see: - **Bulk Selection**: Use Select All/Clear All buttons to quickly select or clear all similar faces - **Smart Buttons**: Select All/Clear All buttons are only enabled when Compare mode is active - **Navigation Warnings**: System warns if you try to navigate away with selected faces but no person name +- **Smart Quit Validation**: Quit button only shows warning when all three required fields are filled (first name, last name, date of birth) - **Quit Confirmation**: When closing, system asks if you want to save pending identifications +- **Cancel Protection**: Clicking "Cancel" in quit warning keeps the main window open - **Consistent Results**: Compare mode shows the same faces as auto-match with identical confidence scoring - **Multiple Matches**: In auto-match, you can select multiple faces to identify with one person - **Smart Navigation**: Back/Next buttons are disabled appropriately (Back disabled on first, Next disabled on last) @@ -786,11 +795,20 @@ This is now a minimal, focused tool. Key principles: - āœ… **Today Highlighting** - Current date shown in orange when visible - āœ… **Smooth Positioning** - Calendar opens centered without flickering - āœ… **Isolated Styling** - Calendar styles don't affect other dialog buttons +- āœ… **Future Date Restrictions** - Future dates are disabled and grayed out (birth dates cannot be in the future) +- āœ… **Select/Cancel Buttons** - Proper confirmation workflow - click date to select, then click "Select" to confirm +- āœ… **Smart Navigation Limits** - Month/year navigation prevents going to future months/years ### šŸ”„ Smart Field Management - āœ… **Forward Navigation** - Date of birth, middle name, and maiden name fields clear when moving to next face - āœ… **Backward Navigation** - All fields repopulate with previously entered data +### šŸ›”ļø Enhanced Quit Validation (NEW!) +- āœ… **Smart Form Validation** - Quit button only shows warning when ALL three required fields are filled (first name, last name, date of birth) +- āœ… **Proper Cancel Behavior** - Clicking "Cancel" in quit warning keeps the main window open instead of closing it +- āœ… **Unsaved Changes Detection** - Accurately detects when you have complete identification data ready but haven't pressed "Identify" yet +- āœ… **Improved User Experience** - No more false warnings when only partially filling form fields + ### šŸ†• Enhanced Person Information (LATEST!) - āœ… **Middle Name Field** - Optional middle name input field added to person identification - āœ… **Maiden Name Field** - Optional maiden name input field added to person identification diff --git a/database.py b/database.py index 06492cd..c6ed79e 100644 --- a/database.py +++ b/database.py @@ -357,6 +357,19 @@ class DatabaseManager: result = cursor.fetchone() return result[0] if result else None + def get_face_photo_info(self, face_id: int) -> Optional[Tuple]: + """Get photo information for a specific face""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT f.photo_id, p.filename, f.location + FROM faces f + JOIN photos p ON f.photo_id = p.id + WHERE f.id = ? + ''', (face_id,)) + result = cursor.fetchone() + return result if result else None + def get_all_face_encodings(self) -> List[Tuple]: """Get all face encodings with their IDs""" with self.get_db_connection() as conn: diff --git a/face_processing.py b/face_processing.py index eab26ac..b2fe562 100644 --- a/face_processing.py +++ b/face_processing.py @@ -437,14 +437,18 @@ class FaceProcessor: distance = face_recognition.face_distance([target_encoding], other_enc)[0] if distance <= adaptive_tolerance: # Get photo info for this face - photo_info = self.db.get_photos_by_pattern() # This needs to be implemented properly - matches.append({ - 'face_id': other_id, - 'person_id': other_person_id, - 'distance': distance, - 'quality_score': other_quality, - 'adaptive_tolerance': adaptive_tolerance - }) + photo_info = self.db.get_face_photo_info(other_id) + if photo_info: + matches.append({ + 'face_id': other_id, + 'person_id': other_person_id, + 'distance': distance, + 'quality_score': other_quality, + 'adaptive_tolerance': adaptive_tolerance, + 'photo_id': photo_info[0], + 'filename': photo_info[1], + 'location': photo_info[2] + }) return matches @@ -513,3 +517,347 @@ class FaceProcessor: def update_person_encodings(self, person_id: int): """Update person encodings when a face is identified""" self.db.update_person_encodings(person_id) + + def _extract_face_crop(self, photo_path: str, location: tuple, face_id: int) -> str: + """Extract and save individual face crop for identification with caching""" + try: + # Check cache first + cache_key = f"{photo_path}_{location}_{face_id}" + if cache_key in self._image_cache: + cached_path = self._image_cache[cache_key] + # Verify the cached file still exists + if os.path.exists(cached_path): + return cached_path + else: + # Remove from cache if file doesn't exist + del self._image_cache[cache_key] + + # Parse location tuple from string format + if isinstance(location, str): + location = eval(location) + + top, right, bottom, left = location + + # Load the image + image = Image.open(photo_path) + + # Add padding around the face (20% of face size) + 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)) + + # Create temporary file for the face crop + temp_dir = tempfile.gettempdir() + face_filename = f"face_{face_id}_crop.jpg" + face_path = os.path.join(temp_dir, face_filename) + + # Resize for better viewing (minimum 200px width) + if face_crop.width < 200: + ratio = 200 / face_crop.width + new_width = 200 + new_height = int(face_crop.height * ratio) + face_crop = face_crop.resize((new_width, new_height), Image.Resampling.LANCZOS) + + face_crop.save(face_path, "JPEG", quality=95) + + # Cache the result + self._image_cache[cache_key] = face_path + return face_path + + except Exception as e: + if self.verbose >= 1: + print(f"āš ļø Could not extract face crop: {e}") + return None + + def _create_comparison_image(self, unid_crop_path: str, match_crop_path: str, person_name: str, confidence: float) -> str: + """Create a side-by-side comparison image""" + 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 + 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: + if self.verbose >= 1: + print(f"āš ļø Could not create comparison image: {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 - Unlikely)" + + def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, face_vars, face_images, face_crops, current_face_id=None, face_selection_states=None, data_cache=None): + """Display similar faces in a panel - reuses auto-match display logic""" + import tkinter as tk + from tkinter import ttk + from PIL import Image, ImageTk + import os + + # 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() + face_vars.append((similar_face_id, match_var)) + + # Restore previous checkbox state if available (auto-match style) + if current_face_id is not None and face_selection_states is not None: + unique_key = f"{current_face_id}_{similar_face_id}" + if current_face_id in face_selection_states and unique_key in face_selection_states[current_face_id]: + saved_state = face_selection_states[current_face_id][unique_key] + match_var.set(saved_state) + + # Add immediate callback to save state when checkbox changes (auto-match style) + def make_callback(var, face_id, similar_face_id): + def on_checkbox_change(*args): + unique_key = f"{face_id}_{similar_face_id}" + if face_id not in face_selection_states: + face_selection_states[face_id] = {} + face_selection_states[face_id][unique_key] = var.get() + return on_checkbox_change + + # Bind the callback to the variable + match_var.trace('w', make_callback(match_var, current_face_id, similar_face_id)) + + # Configure match frame for grid layout + match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width + match_frame.columnconfigure(1, weight=1) # Text column - expandable + match_frame.columnconfigure(2, weight=0) # Image column - fixed width + + # Checkbox without text + checkbox = ttk.Checkbutton(match_frame, variable=match_var) + checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) + + # Create labels for confidence and filename + confidence_label = ttk.Label(match_frame, text=f"{confidence_pct:.1f}% {confidence_desc}", font=("Arial", 9, "bold")) + confidence_label.grid(row=0, column=1, sticky=tk.W, padx=(0, 10)) + + filename_label = ttk.Label(match_frame, text=f"šŸ“ {filename}", font=("Arial", 8), foreground="gray") + filename_label.grid(row=1, column=1, sticky=tk.W, padx=(0, 10)) + + # Face image (reusing auto-match image display) + try: + # Get photo path from cache or database + photo_path = None + if data_cache and 'photo_paths' in data_cache: + # Find photo path by filename in cache + for photo_data in data_cache['photo_paths'].values(): + if photo_data['filename'] == filename: + photo_path = photo_data['path'] + break + + # Fallback to database if not in cache + if photo_path is None: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT path FROM photos WHERE filename = ?', (filename,)) + result = cursor.fetchone() + photo_path = result[0] if result else None + + # Extract face crop using existing method + face_crop_path = self._extract_face_crop(photo_path, face_data['location'], similar_face_id) + if face_crop_path and os.path.exists(face_crop_path): + face_crops.append(face_crop_path) + + # Create canvas for face image (like in auto-match) + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + match_canvas = tk.Canvas(match_frame, width=80, height=80, bg=canvas_bg_color, highlightthickness=0) + match_canvas.grid(row=0, column=2, rowspan=2, sticky=(tk.W, tk.N), padx=(10, 0)) + + # Load and display image (reusing auto-match image loading) + pil_image = Image.open(face_crop_path) + pil_image.thumbnail((80, 80), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(pil_image) + match_canvas.create_image(40, 40, image=photo) + match_canvas.image = photo # Keep reference + face_images.append(photo) + + # Add photo icon to the similar face + self._create_photo_icon(match_canvas, photo_path, icon_size=15, + face_x=40, face_y=40, + face_width=80, face_height=80, + canvas_width=80, canvas_height=80) + else: + # No image available + match_canvas = tk.Canvas(match_frame, width=80, height=80, bg='white') + match_canvas.pack(side=tk.LEFT, padx=(10, 0)) + match_canvas.create_text(40, 40, text="šŸ–¼ļø", fill="gray") + except Exception as e: + # Error loading image + match_canvas = tk.Canvas(match_frame, width=80, height=80, bg='white') + match_canvas.pack(side=tk.LEFT, padx=(10, 0)) + match_canvas.create_text(40, 40, text="āŒ", fill="red") + + def _create_photo_icon(self, canvas, photo_path, icon_size=20, icon_x=None, icon_y=None, + canvas_width=None, canvas_height=None, face_x=None, face_y=None, + face_width=None, face_height=None): + """Create a reusable photo icon with tooltip on a canvas""" + import tkinter as tk + import subprocess + import platform + import os + + 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 corner + face_right = face_x + face_width // 2 + face_top = face_y - face_height // 2 + icon_x = face_right - icon_size + icon_y = face_top + 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", "", open_source_photo) + canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e))) + canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor=""), hide_tooltip(e))) + canvas.tag_bind("photo_icon", "", lambda e: (show_tooltip(e) if tooltip else None)) + + return tooltip # Return tooltip reference for cleanup if needed \ No newline at end of file diff --git a/gui_core.py b/gui_core.py index bcddc61..5fa592f 100644 --- a/gui_core.py +++ b/gui_core.py @@ -66,55 +66,111 @@ class GUICore: 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 small photo icon on a canvas""" - try: - if not os.path.exists(photo_path): - return None - - # Load and resize image - with Image.open(photo_path) as img: - img.thumbnail((icon_size, icon_size), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(img) - - # Calculate position if not provided - if icon_x is None: - icon_x = 10 - if icon_y is None: - icon_y = 10 - - # Create image on canvas - image_id = canvas.create_image(icon_x, icon_y, anchor='nw', image=photo) - - # Keep reference to prevent garbage collection - canvas.image_refs = getattr(canvas, 'image_refs', []) - canvas.image_refs.append(photo) - - # Add click handler if callback provided - if callback: - def open_source_photo(event): - callback(photo_path) - - canvas.tag_bind(image_id, '', open_source_photo) - canvas.tag_bind(image_id, '', lambda e: canvas.config(cursor='hand2')) - canvas.tag_bind(image_id, '', lambda e: canvas.config(cursor='')) - - # Add tooltip - def show_tooltip(event): - tooltip = f"šŸ“ø {os.path.basename(photo_path)}" - # Simple tooltip implementation - pass - - def hide_tooltip(event): - pass - - canvas.tag_bind(image_id, '', show_tooltip) - canvas.tag_bind(image_id, '', hide_tooltip) - - return image_id - - except Exception as e: - return None + """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", "", open_source_photo) + canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e))) + canvas.tag_bind("photo_icon", "", lambda e: (canvas.config(cursor=""), hide_tooltip(e))) + canvas.tag_bind("photo_icon", "", lambda e: (show_tooltip(e) if tooltip else None)) + + return tooltip # Return tooltip reference for cleanup if needed def create_face_crop_image(self, photo_path: str, face_location: tuple, face_id: int, crop_size: int = 100) -> Optional[str]: @@ -339,3 +395,358 @@ class GUICore: 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('', lambda e: show_suggestions()) + entry.bind('', on_key_press) + entry.bind('', lambda e: parent.after(150, hide_suggestions)) # Delay to allow listbox clicks + listbox.bind('', on_listbox_click) + listbox.bind('', on_listbox_key) + listbox.bind('', on_listbox_click) + + return entry, entry_var, listbox \ No newline at end of file diff --git a/identify_gui.py b/identify_gui.py new file mode 100644 index 0000000..3520d6b --- /dev/null +++ b/identify_gui.py @@ -0,0 +1,2224 @@ +#!/usr/bin/env python3 +""" +Face identification GUI implementation for PunimTag +""" + +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 IdentifyGUI: + """Handles the face identification GUI interface""" + + def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0): + """Initialize the identify GUI""" + self.db = db_manager + self.face_processor = face_processor + self.verbose = verbose + self.gui_core = GUICore() + + def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, show_faces: bool = False, + tolerance: float = DEFAULT_FACE_TOLERANCE, + date_from: str = None, date_to: str = None, + date_processed_from: str = None, date_processed_to: str = None) -> int: + """Interactive face identification with optimized performance""" + + # Get unidentified faces from database + unidentified = self._get_unidentified_faces(batch_size, date_from, date_to, + date_processed_from, date_processed_to) + + if not unidentified: + print("šŸŽ‰ All faces have been identified!") + return 0 + + print(f"\nšŸ‘¤ Found {len(unidentified)} unidentified faces") + print("Commands: [name] = identify, 's' = skip, 'q' = quit, 'list' = show people\n") + + # Pre-fetch all needed data to avoid repeated database queries + print("šŸ“Š Pre-fetching data for optimal performance...") + identify_data_cache = self._prefetch_identify_data(unidentified) + + print(f"āœ… Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names") + + identified_count = 0 + + # Create the main window + root = tk.Tk() + root.title("Face Identification") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + selected_person_id = None + force_exit = False + + # Track current face crop path for cleanup + current_face_crop_path = None + + # Hide window initially to prevent flash at corner + root.withdraw() + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal command, waiting_for_input, window_destroyed, current_face_crop_path, force_exit + + + # First check for selected similar faces without person name + if not self._validate_navigation(gui_components): + return # Cancel close + + # Check if there are pending identifications + pending_identifications = self._get_pending_identifications(face_person_names, face_status) + + if pending_identifications: + # Ask user if they want to save 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 closing?\n\n" + "• Yes: Save all pending identifications and close\n" + "• No: Close without saving\n" + "• Cancel: Return to identification" + ) + + if result is True: # Yes - Save and close + identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache) + # Continue to cleanup and close + elif result is False: # No - Close without saving + # Continue to cleanup and close + pass + else: # Cancel - Don't close + return # Exit without cleanup or closing + + # Only reach here if user chose Yes, No, or there are no pending identifications + # Clean up face crops and caches + self.face_processor.cleanup_face_crops(current_face_crop_path) + self.db.close_db_connection() + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + # Force process termination + force_exit = True + root.quit() + + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving + saved_size = self.gui_core.setup_window_size_saving(root) + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) # Left panel + main_frame.columnconfigure(1, weight=1) # Right panel for similar faces + # Configure row weights to minimize spacing around Unique checkbox + main_frame.rowconfigure(2, weight=0) # Unique checkbox row - no expansion + main_frame.rowconfigure(3, weight=1) # Main panels row - expandable + + # Photo info + info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold")) + info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) + + + # Store face selection states per face ID to preserve selections during navigation + face_selection_states = {} # {face_id: {unique_key: bool}} + + # Store person names per face ID to preserve names during navigation + face_person_names = {} # {face_id: person_name} + + # Process each face with back navigation support + # Keep track of original face list and current position + original_faces = list(unidentified) # Make a copy of the original list + i = 0 + face_status = {} # Track which faces have been identified + + # Button commands + command = None + waiting_for_input = False + + # Define quit handler as local function (like in old version) + def on_quit(): + nonlocal command, waiting_for_input, window_destroyed, force_exit + + + # First check for unsaved changes in the form + validation_result = self._validate_navigation(gui_components) + if validation_result == 'cancel': + return # Cancel quit + elif validation_result == 'save_and_continue': + # Save the current identification before proceeding + # Add the current form data to pending identifications + current_face_key = list(face_person_names.keys())[0] if face_person_names else None + if current_face_key: + first_name = gui_components['first_name_var'].get().strip() + last_name = gui_components['last_name_var'].get().strip() + date_of_birth = gui_components['date_of_birth_var'].get().strip() + if first_name and last_name and date_of_birth: + face_person_names[current_face_key] = { + 'first_name': first_name, + 'last_name': last_name, + 'date_of_birth': date_of_birth + } + face_status[current_face_key] = 'identified' + elif validation_result == 'discard_and_continue': + # Clear the form but don't save + self._clear_form(gui_components) + + # Check if there are pending identifications (faces with complete data but not yet saved) + pending_identifications = self._get_pending_identifications(face_person_names, face_status) + + if pending_identifications: + # Temporarily disable window close handler to prevent interference + root.protocol("WM_DELETE_WINDOW", lambda: None) + + # Ask user if they want to save 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" + ) + + # Re-enable window close handler + root.protocol("WM_DELETE_WINDOW", on_closing) + + + if result is True: # Yes - Save and quit + identified_count += self._save_all_pending_identifications(face_person_names, face_status, identify_data_cache) + command = 'q' + waiting_for_input = False + elif result is False: # No - Quit without saving + command = 'q' + waiting_for_input = False + else: # Cancel - Don't quit + return # Exit without any cleanup or window destruction + else: + # No pending identifications, quit normally + command = 'q' + waiting_for_input = False + + # Only reach here if user chose Yes, No, or there are no pending identifications + # Clean up and close window + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + # Force process termination + force_exit = True + root.quit() + + # Create the GUI components with the quit handler + gui_components = self._create_gui_components(main_frame, identify_data_cache, + date_from, date_to, date_processed_from, date_processed_to, batch_size, on_quit) + + # Set up command variable for button callbacks + self._current_command_var = gui_components['command_var'] + + # Show the window + try: + root.deiconify() + root.lift() + root.focus_force() + + # Force window to render completely before proceeding + root.update_idletasks() + root.update() + + # Small delay to ensure canvas is properly rendered + time.sleep(0.1) + + # Schedule the first image update after the window is fully rendered + def update_first_image(): + try: + if i < len(original_faces): + face_id, photo_id, photo_path, filename, location = original_faces[i] + + # Extract face crop if enabled + face_crop_path = None + if show_faces: + face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) + + # Update the face image + self._update_face_image(gui_components, show_faces, face_crop_path, photo_path) + except Exception as e: + print(f"āŒ Error updating first image: {e}") + + # Schedule the update after a short delay + root.after(200, update_first_image) + + except tk.TclError: + # Window was destroyed before we could show it + return 0 + + # Main processing loop + while not window_destroyed: + # Check if current face is identified and update index if needed + if not self._update_current_face_index(original_faces, i, face_status): + # All faces have been identified + print("\nšŸŽ‰ All faces have been identified!") + break + + # Ensure we don't go beyond the bounds + if i >= len(original_faces): + # Stay on the last face instead of breaking + i = len(original_faces) - 1 + + face_id, photo_id, photo_path, filename, location = original_faces[i] + + # Check if this face was already identified in this session + is_already_identified = face_id in face_status and face_status[face_id] == 'identified' + + # Reset command and waiting state for each face + command = None + waiting_for_input = True + + # Update the display + current_pos, total_unidentified = self._get_current_face_position(original_faces, i, face_status) + print(f"\n--- Face {current_pos}/{total_unidentified} (unidentified) ---") + print(f"šŸ“ Photo: {filename}") + print(f"šŸ“ Face location: {location}") + + # Update title + root.title(f"Face Identification - {current_pos}/{total_unidentified} (unidentified)") + + # Update button states + self._update_button_states(gui_components, original_faces, i, face_status) + + # Update similar faces panel if compare is enabled + if gui_components['compare_var'].get(): + self._update_similar_faces(gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i) + + # Update photo info + if is_already_identified: + # Get the person name for this face + person_name = self._get_person_name_for_face(face_id) + info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified}) - āœ… Already identified as: {person_name}") + print(f"āœ… Already identified as: {person_name}") + else: + info_label.config(text=f"šŸ“ Photo: {filename} (Face {current_pos}/{total_unidentified})") + + # Extract face crop if enabled + face_crop_path = None + if show_faces: + face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) + if face_crop_path: + print(f"šŸ–¼ļø Face crop saved: {face_crop_path}") + current_face_crop_path = face_crop_path # Track for cleanup + else: + print("šŸ’” Use --show-faces flag to display individual face crops") + current_face_crop_path = None + + print(f"\nšŸ–¼ļø Viewing face {current_pos}/{total_unidentified} from {filename}") + + # Clear and update image + self._update_face_image(gui_components, show_faces, face_crop_path, photo_path) + + # Set person name input - restore saved name or use database/empty value + self._restore_person_name_input(gui_components, face_id, face_person_names, is_already_identified) + + # Keep compare checkbox state persistent across navigation + gui_components['first_name_entry'].focus_set() + gui_components['first_name_entry'].icursor(0) + + # Force GUI update before waiting for input + root.update_idletasks() + + # Wait for user input + while waiting_for_input: + try: + root.update() + # Check for command from GUI buttons + if gui_components['command_var'].get(): + command = gui_components['command_var'].get() + gui_components['command_var'].set("") # Clear the command + waiting_for_input = False + break + + # Check for unique checkbox changes + if hasattr(self, '_last_unique_state'): + current_unique_state = gui_components['unique_var'].get() + if current_unique_state != self._last_unique_state: + # Unique checkbox state changed, apply filtering + original_faces = self._on_unique_faces_change( + gui_components, original_faces, i, face_status, + date_from, date_to, date_processed_from, date_processed_to + ) + # Reset index to 0 when filtering changes + i = 0 + self._last_unique_state = current_unique_state + # Continue to next iteration to update display + continue + else: + # Initialize the last unique state + self._last_unique_state = gui_components['unique_var'].get() + + # Check for compare checkbox changes + if hasattr(self, '_last_compare_state'): + current_compare_state = gui_components['compare_var'].get() + if current_compare_state != self._last_compare_state: + # Compare checkbox state changed, update similar faces panel + self._on_compare_change( + gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i + ) + self._last_compare_state = current_compare_state + # Continue to next iteration to update display + continue + else: + # Initialize the last compare state + self._last_compare_state = gui_components['compare_var'].get() + + # Small delay to prevent excessive CPU usage + time.sleep(0.01) + except tk.TclError: + # Window was destroyed, break out of loop + break + + # Check if force exit was requested + if force_exit: + break + + # Check if force exit was requested (exit immediately) + if force_exit: + print("Force exit requested...") + # Clean up face crops and caches + self.face_processor.cleanup_face_crops(face_crop_path) + self.db.close_db_connection() + return identified_count + + # Process the command + if command is None: # User clicked Cancel + command = 'q' + else: + command = command.strip() + + if command.lower() == 'q': + # Clean up face crops and caches + self.face_processor.cleanup_face_crops(face_crop_path) + self.db.close_db_connection() + + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + return identified_count + + elif command.lower() == 's': + print("āž”ļø Next") + + # Save current checkbox states before navigating away + self._save_current_face_selection_states(gui_components, original_faces, i, + face_selection_states, face_person_names) + + # Clean up current face crop when moving forward + if face_crop_path and os.path.exists(face_crop_path): + try: + os.remove(face_crop_path) + except: + pass # Ignore cleanup errors + current_face_crop_path = None # Clear tracked path + + # Find next unidentified face + next_found = False + for j in range(i + 1, len(original_faces)): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + next_found = True + break + + if not next_found: + print("āš ļø No more unidentified faces - Next button disabled") + continue + + # Clear date of birth field when moving to next face + gui_components['date_of_birth_var'].set("") + # Clear middle name and maiden name fields when moving to next face + gui_components['middle_name_var'].set("") + gui_components['maiden_name_var'].set("") + + self._update_button_states(gui_components, original_faces, i, face_status) + # Only update similar faces if compare is enabled + if gui_components['compare_var'].get(): + self._update_similar_faces(gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i) + continue + + elif command.lower() == 'back': + print("ā¬…ļø Going back to previous face") + + # Save current checkbox states before navigating away + self._save_current_face_selection_states(gui_components, original_faces, i, + face_selection_states, face_person_names) + + # Find previous unidentified face + prev_found = False + for j in range(i - 1, -1, -1): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + prev_found = True + break + + if not prev_found: + print("āš ļø No more unidentified faces - Back button disabled") + continue + + # Repopulate fields with saved data when going back + self._restore_person_name_input(gui_components, original_faces[i][0], face_person_names, False) + + self._update_button_states(gui_components, original_faces, i, face_status) + # Only update similar faces if compare is enabled + if gui_components['compare_var'].get(): + self._update_similar_faces(gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i) + continue + + elif command == 'reload_faces': + # Reload faces with new date filters + if 'filtered_faces' in gui_components: + # Update the original_faces list with filtered results + original_faces = list(gui_components['filtered_faces']) + + # Update the date filter variables + date_from = gui_components.get('new_date_from') + date_to = gui_components.get('new_date_to') + date_processed_from = gui_components.get('new_date_processed_from') + date_processed_to = gui_components.get('new_date_processed_to') + + # Reset to first face + i = 0 + + # Clear the filtered_faces data + del gui_components['filtered_faces'] + + print("šŸ’” Navigate to refresh the display with filtered faces") + continue + else: + print("āš ļø No filtered faces data found") + continue + + elif command.lower() == 'list': + self._show_people_list() + continue + + elif command == 'identify': + try: + # Get form data + form_data = self._get_form_data(gui_components) + + # Validate form data + is_valid, error_msg = self._validate_form_data(form_data) + if not is_valid: + messagebox.showerror("Validation Error", error_msg) + continue + + # Process identification + identified_count += self._process_identification_command( + form_data, face_id, is_already_identified, face_status, + gui_components, identify_data_cache + ) + + # Clear form after successful identification + self._clear_form(gui_components) + + except Exception as e: + print(f"āŒ Error: {e}") + messagebox.showerror("Error", f"Error processing identification: {e}") + + # Increment index for normal flow (identification or error) - but not if we're at the last item + if i < len(original_faces) - 1: + i += 1 + self._update_button_states(gui_components, original_faces, i, face_status) + # Only update similar faces if compare is enabled + if gui_components['compare_var'].get(): + self._update_similar_faces(gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i) + + # Clean up current face crop when moving forward after identification + if face_crop_path and os.path.exists(face_crop_path): + try: + os.remove(face_crop_path) + except: + pass # Ignore cleanup errors + current_face_crop_path = None # Clear tracked path + + # Continue to next face after processing command + continue + + elif command: + try: + # Process other identification command (legacy support) + identified_count += self._process_identification_command( + command, face_id, is_already_identified, face_status, + gui_components, identify_data_cache + ) + + except Exception as e: + print(f"āŒ Error: {e}") + + # Increment index for normal flow (identification or error) - but not if we're at the last item + if i < len(original_faces) - 1: + i += 1 + self._update_button_states(gui_components, original_faces, i, face_status) + # Only update similar faces if compare is enabled + if gui_components['compare_var'].get(): + self._update_similar_faces(gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i) + + # Clean up current face crop when moving forward after identification + if face_crop_path and os.path.exists(face_crop_path): + try: + os.remove(face_crop_path) + except: + pass # Ignore cleanup errors + current_face_crop_path = None # Clear tracked path + + # Continue to next face after processing command + continue + else: + print("Please enter a name, 's' to skip, 'q' to quit, or use buttons") + + # Only close the window if user explicitly quit (not when reaching end of faces) + if not window_destroyed: + # Keep the window open - user can still navigate and quit manually + print(f"\nāœ… Identified {identified_count} faces") + print("šŸ’” Application remains open - use Quit button to close") + # Don't destroy the window - let user quit manually + return identified_count + + print(f"\nāœ… Identified {identified_count} faces") + return identified_count + + 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): + """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, unidentified): + """Pre-fetch all needed data to avoid repeated database queries""" + identify_data_cache = {} + + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Pre-fetch all photo paths for unidentified faces + photo_ids = [face[1] for face in unidentified] # face[1] is photo_id + if photo_ids: + placeholders = ','.join('?' * len(photo_ids)) + cursor.execute(f'SELECT id, path, filename FROM photos WHERE id IN ({placeholders})', photo_ids) + identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()} + + # Pre-fetch all people names for dropdown + cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') + people = cursor.fetchall() + identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people] + # 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() + identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) + + return identify_data_cache + + def _create_gui_components(self, main_frame, identify_data_cache, date_from, date_to, + date_processed_from, date_processed_to, batch_size, on_quit=None): + """Create all GUI components for the identify interface""" + components = {} + + # Create variables for form data + components['compare_var'] = tk.BooleanVar() + components['unique_var'] = tk.BooleanVar() + components['first_name_var'] = tk.StringVar() + components['last_name_var'] = tk.StringVar() + components['middle_name_var'] = tk.StringVar() + components['maiden_name_var'] = tk.StringVar() + components['date_of_birth_var'] = tk.StringVar() + + # Date filter variables + components['date_from_var'] = tk.StringVar(value=date_from or "") + components['date_to_var'] = tk.StringVar(value=date_to or "") + components['date_processed_from_var'] = tk.StringVar(value=date_processed_from or "") + components['date_processed_to_var'] = tk.StringVar(value=date_processed_to or "") + + # Date filter controls - exactly as in original + date_filter_frame = ttk.LabelFrame(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)) + components['date_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_from_var'], width=10, state='readonly') + 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(components['date_from_var']) + + components['date_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_from) + 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)) + components['date_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_to_var'], width=10, state='readonly') + 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(components['date_to_var']) + + components['date_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_to) + 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""" + # Get current filter values + new_date_from = components['date_from_var'].get().strip() or None + new_date_to = components['date_to_var'].get().strip() or None + new_date_processed_from = components['date_processed_from_var'].get().strip() or None + new_date_processed_to = components['date_processed_to_var'].get().strip() or None + + # Reload faces with new date filter + 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 new_date_from: + query += ' AND p.date_taken >= ?' + params.append(new_date_from) + + if new_date_to: + query += ' AND p.date_taken <= ?' + params.append(new_date_to) + + # Add date processed filtering if specified + if new_date_processed_from: + query += ' AND DATE(p.date_added) >= ?' + params.append(new_date_processed_from) + + if new_date_processed_to: + query += ' AND DATE(p.date_added) <= ?' + params.append(new_date_processed_to) + + query += ' LIMIT ?' + params.append(batch_size) + + cursor.execute(query, params) + filtered_faces = cursor.fetchall() + + if not filtered_faces: + messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") + return + + # Build filter description + filters_applied = [] + if new_date_from or new_date_to: + taken_filter = f"taken: {new_date_from or 'any'} to {new_date_to or 'any'}" + filters_applied.append(taken_filter) + if new_date_processed_from or new_date_processed_to: + processed_filter = f"processed: {new_date_processed_from or 'any'} to {new_date_processed_to or 'any'}" + filters_applied.append(processed_filter) + + filter_desc = " | ".join(filters_applied) if filters_applied else "no filters" + + print(f"šŸ“… Applied filters: {filter_desc}") + print(f"šŸ‘¤ Found {len(filtered_faces)} unidentified faces with date filters") + + # Set a special command to reload faces + components['command_var'].set("reload_faces") + + # Store the filtered faces for the main loop to use + components['filtered_faces'] = filtered_faces + components['new_date_from'] = new_date_from + components['new_date_to'] = new_date_to + components['new_date_processed_from'] = new_date_processed_from + components['new_date_processed_to'] = new_date_processed_to + + components['apply_filter_btn'] = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) + 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)) + components['date_processed_from_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_from_var'], width=10, state='readonly') + 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(components['date_processed_from_var']) + + components['date_processed_from_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_from) + 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)) + components['date_processed_to_entry'] = ttk.Entry(date_filter_frame, textvariable=components['date_processed_to_var'], width=10, state='readonly') + 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(components['date_processed_to_var']) + + components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="šŸ“…", width=3, command=open_calendar_processed_to) + 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(): + # This will be called when the checkbox state changes + # We'll handle the actual filtering in the main loop + pass + + components['unique_check'] = ttk.Checkbutton(main_frame, text="Unique faces only", + variable=components['unique_var'], + command=on_unique_change) + 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(): + # This will be called when the checkbox state changes + # We'll handle the actual panel toggling in the main loop + pass + + components['compare_check'] = ttk.Checkbutton(main_frame, text="Compare similar faces", + variable=components['compare_var'], + command=on_compare_change) + components['compare_check'].grid(row=2, column=1, sticky=tk.W, padx=(5, 10), pady=0) + + # Left panel for main face + left_panel = ttk.Frame(main_frame) + left_panel.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5), pady=(0, 0)) + left_panel.columnconfigure(0, weight=1) + + # Right panel for similar faces + components['right_panel'] = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5") + components['right_panel'].grid(row=3, column=1, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) + components['right_panel'].columnconfigure(0, weight=1) + components['right_panel'].rowconfigure(0, weight=1) # Make right panel expandable vertically + + # Right panel is always visible now + + # Image display (left panel) + image_frame = ttk.Frame(left_panel) + image_frame.grid(row=0, column=0, pady=(0, 10), sticky=(tk.W, tk.E, tk.N, tk.S)) + image_frame.columnconfigure(0, weight=1) + image_frame.rowconfigure(0, weight=1) + + # Create canvas for image display + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + components['canvas'] = tk.Canvas(image_frame, width=400, height=400, bg=canvas_bg_color, highlightthickness=0) + components['canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Store reference to current image data for redrawing on resize + components['canvas'].current_image_data = None + + # Bind resize event to redraw image + def on_canvas_resize(event): + if hasattr(components['canvas'], 'current_image_data') and components['canvas'].current_image_data: + # Redraw the current image with new dimensions + self._redraw_current_image(components['canvas']) + + components['canvas'].bind('', on_canvas_resize) + + # Input section (left panel) + input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10") + input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E)) + input_frame.columnconfigure(1, weight=1) + input_frame.columnconfigure(3, weight=1) + input_frame.columnconfigure(5, weight=1) + input_frame.columnconfigure(7, weight=1) + + # First name input + ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) + components['first_name_entry'] = ttk.Entry(input_frame, textvariable=components['first_name_var'], width=12) + components['first_name_entry'].grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Last name input with autocomplete + ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10)) + components['last_name_entry'] = ttk.Entry(input_frame, textvariable=components['last_name_var'], width=12) + components['last_name_entry'].grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Middle name input + ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) + components['middle_name_entry'] = ttk.Entry(input_frame, textvariable=components['middle_name_var'], width=12) + components['middle_name_entry'].grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Date of birth input with calendar chooser + ttk.Label(input_frame, text="Date of birth:").grid(row=1, column=0, sticky=tk.W, padx=(0, 10)) + + # Create a frame for the date picker + date_frame = ttk.Frame(input_frame) + date_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 10)) + + # Maiden name input + ttk.Label(input_frame, text="Maiden name:").grid(row=1, column=2, sticky=tk.W, padx=(10, 10)) + components['maiden_name_entry'] = ttk.Entry(input_frame, textvariable=components['maiden_name_var'], width=12) + components['maiden_name_entry'].grid(row=1, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Date display entry (read-only) + components['date_of_birth_entry'] = ttk.Entry(date_frame, textvariable=components['date_of_birth_var'], width=12, state='readonly') + components['date_of_birth_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Calendar button + components['date_of_birth_btn'] = ttk.Button(date_frame, text="šŸ“…", width=3, + command=lambda: self._open_date_picker(components['date_of_birth_var'])) + components['date_of_birth_btn'].pack(side=tk.RIGHT, padx=(15, 0)) + + # Add required field asterisks (like in original) + self._add_required_asterisks(main_frame.master, input_frame, components) + + # Add autocomplete for last name (like in original) + self._setup_last_name_autocomplete(main_frame.master, components, identify_data_cache) + + # Identify button (placed in Person Identification frame) + components['identify_btn'] = ttk.Button(input_frame, text="āœ… Identify", command=lambda: self._set_command('identify'), state='disabled') + components['identify_btn'].grid(row=2, column=0, pady=(10, 0), sticky=tk.W) + + # Add event handlers to update Identify button state + def update_identify_button_state(*args): + self._update_identify_button_state(components) + + components['first_name_var'].trace('w', update_identify_button_state) + components['last_name_var'].trace('w', update_identify_button_state) + components['date_of_birth_var'].trace('w', update_identify_button_state) + + # Handle Enter key + def on_enter(event): + if components['identify_btn']['state'] == 'normal': + self._set_command('identify') + + components['first_name_entry'].bind('', on_enter) + components['last_name_entry'].bind('', on_enter) + components['middle_name_entry'].bind('', on_enter) + components['maiden_name_entry'].bind('', on_enter) + + + # Create similar faces frame with controls + similar_faces_frame = ttk.Frame(components['right_panel']) + similar_faces_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + similar_faces_frame.columnconfigure(0, weight=1) + similar_faces_frame.rowconfigure(0, weight=0) # Controls row takes minimal space + similar_faces_frame.rowconfigure(1, weight=1) # Canvas row expandable + + # Control buttons for similar faces (Select All / Clear All) + similar_controls_frame = ttk.Frame(similar_faces_frame) + similar_controls_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5, 0)) + + def select_all_similar_faces(): + """Select all similar faces checkboxes""" + if hasattr(self, '_similar_face_vars'): + for face_id, var in self._similar_face_vars: + var.set(True) + + def clear_all_similar_faces(): + """Clear all similar faces checkboxes""" + if hasattr(self, '_similar_face_vars'): + for face_id, var in self._similar_face_vars: + var.set(False) + + components['select_all_btn'] = ttk.Button(similar_controls_frame, text="ā˜‘ļø Select All", + command=select_all_similar_faces, state='disabled') + components['select_all_btn'].pack(side=tk.LEFT, padx=(0, 5)) + + components['clear_all_btn'] = ttk.Button(similar_controls_frame, text="☐ Clear All", + command=clear_all_similar_faces, state='disabled') + components['clear_all_btn'].pack(side=tk.LEFT) + + # Create canvas for similar faces with scrollbar + similar_canvas = tk.Canvas(similar_faces_frame, bg='lightgray', relief='sunken', bd=2) + similar_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Similar faces scrollbars + similar_v_scrollbar = ttk.Scrollbar(similar_faces_frame, orient='vertical', command=similar_canvas.yview) + similar_v_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S)) + similar_canvas.configure(yscrollcommand=similar_v_scrollbar.set) + + # Create scrollable frame for similar faces + components['similar_scrollable_frame'] = ttk.Frame(similar_canvas) + similar_canvas.create_window((0, 0), window=components['similar_scrollable_frame'], anchor='nw') + + # Store canvas reference for scrolling + components['similar_canvas'] = similar_canvas + + # Add initial message when compare is disabled + no_compare_label = ttk.Label(components['similar_scrollable_frame'], text="Enable 'Compare similar faces' to see similar faces", + foreground="gray", font=("Arial", 10)) + no_compare_label.pack(pady=20) + + # Bottom control panel (move to bottom below panels) + control_frame = ttk.Frame(main_frame) + control_frame.grid(row=4, column=0, columnspan=3, pady=(10, 0), sticky=(tk.E, tk.S)) + + # Create button references for state management + components['control_back_btn'] = ttk.Button(control_frame, text="ā¬…ļø Back", command=lambda: self._set_command('back')) + components['control_next_btn'] = ttk.Button(control_frame, text="āž”ļø Next", command=lambda: self._set_command('s')) + if on_quit: + components['control_quit_btn'] = ttk.Button(control_frame, text="āŒ Quit", command=on_quit) + else: + components['control_quit_btn'] = ttk.Button(control_frame, text="āŒ Quit", command=lambda: self._set_command('quit')) + + components['control_back_btn'].pack(side=tk.LEFT, padx=(0, 5)) + components['control_next_btn'].pack(side=tk.LEFT, padx=(0, 5)) + components['control_quit_btn'].pack(side=tk.LEFT, padx=(5, 0)) + + # Store command variable for button callbacks + components['command_var'] = tk.StringVar() + + return components + + def _add_required_asterisks(self, root, input_frame, components): + """Add red asterisks to required fields (first name, last name, date of birth)""" + # Red asterisks for required fields (overlayed, no layout impact) + first_name_asterisk = ttk.Label(root, text="*", foreground="red") + first_name_asterisk.place_forget() + + last_name_asterisk = ttk.Label(root, text="*", foreground="red") + last_name_asterisk.place_forget() + + date_asterisk = ttk.Label(root, text="*", foreground="red") + date_asterisk.place_forget() + + def _position_required_asterisks(event=None): + """Position required asterisks at top-right corner of their entries.""" + try: + root.update_idletasks() + input_frame.update_idletasks() + components['first_name_entry'].update_idletasks() + components['last_name_entry'].update_idletasks() + components['date_of_birth_entry'].update_idletasks() + + # Get absolute coordinates relative to root window + first_root_x = components['first_name_entry'].winfo_rootx() + first_root_y = components['first_name_entry'].winfo_rooty() + first_w = components['first_name_entry'].winfo_width() + root_x = root.winfo_rootx() + root_y = root.winfo_rooty() + + # First name asterisk at the true top-right corner of entry + first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw') + first_name_asterisk.lift() + + # Last name asterisk at the true top-right corner of entry + last_root_x = components['last_name_entry'].winfo_rootx() + last_root_y = components['last_name_entry'].winfo_rooty() + last_w = components['last_name_entry'].winfo_width() + last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw') + last_name_asterisk.lift() + + # Date of birth asterisk at the true top-right corner of date entry + dob_root_x = components['date_of_birth_entry'].winfo_rootx() + dob_root_y = components['date_of_birth_entry'].winfo_rooty() + dob_w = components['date_of_birth_entry'].winfo_width() + date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw') + date_asterisk.lift() + except Exception: + pass + + # Bind repositioning after all entries are created + def _bind_asterisk_positioning(): + try: + input_frame.bind('', _position_required_asterisks) + components['first_name_entry'].bind('', _position_required_asterisks) + components['last_name_entry'].bind('', _position_required_asterisks) + components['date_of_birth_entry'].bind('', _position_required_asterisks) + _position_required_asterisks() + except Exception: + pass + root.after(100, _bind_asterisk_positioning) + + def _setup_last_name_autocomplete(self, root, components, identify_data_cache): + """Setup autocomplete functionality for last name field - exact copy from original""" + # Create listbox for suggestions (as overlay attached to root, not clipped by frames) + last_name_listbox = tk.Listbox(root, height=8) + last_name_listbox.place_forget() # Hide initially + + # Navigation state variables (like in original) + navigating_to_listbox = False + escape_pressed = False + enter_pressed = False + + def _show_suggestions(): + """Show filtered suggestions in listbox""" + all_last_names = identify_data_cache.get('last_names', []) + typed = 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 + root.update_idletasks() + # Absolute coordinates of entry relative to screen + entry_root_x = components['last_name_entry'].winfo_rootx() + entry_root_y = components['last_name_entry'].winfo_rooty() + entry_height = components['last_name_entry'].winfo_height() + # Convert to coordinates relative to root + root_origin_x = root.winfo_rootx() + root_origin_y = root.winfo_rooty() + place_x = entry_root_x - root_origin_x + place_y = entry_root_y - root_origin_y + entry_height + place_width = components['last_name_entry'].winfo_width() + # Calculate how many rows fit to bottom of window + available_px = max(60, root.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]) + components['last_name_var'].set(selected_name) + _hide_suggestions() + components['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) + components['last_name_var'].set(selected_name) + except: + pass + _hide_suggestions() + components['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() + components['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() + components['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 + components['last_name_entry'].bind('', lambda e: _safe_show_suggestions()) + components['last_name_entry'].bind('', _on_key_press) + components['last_name_entry'].bind('', lambda e: root.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 _update_identify_button_state(self, gui_components): + """Update the state of the Identify button based on form data""" + first_name = gui_components['first_name_var'].get().strip() + last_name = gui_components['last_name_var'].get().strip() + date_of_birth = gui_components['date_of_birth_var'].get().strip() + + # Enable button if we have at least first name or last name, and date of birth + if (first_name or last_name) and date_of_birth: + gui_components['identify_btn'].config(state='normal') + else: + gui_components['identify_btn'].config(state='disabled') + + def _update_control_button_states(self, gui_components, i, total_faces): + """Update the state of control buttons based on current position""" + # Back button - disabled if at first face + if i <= 0: + gui_components['control_back_btn'].config(state='disabled') + else: + gui_components['control_back_btn'].config(state='normal') + + # Next button - disabled if at last face + if i >= total_faces - 1: + gui_components['control_next_btn'].config(state='disabled') + else: + gui_components['control_next_btn'].config(state='normal') + + def _update_select_clear_buttons_state(self, gui_components, similar_face_vars): + """Enable/disable Select All and Clear All based on compare state and presence of items""" + if gui_components['compare_var'].get() and similar_face_vars: + gui_components['select_all_btn'].config(state='normal') + gui_components['clear_all_btn'].config(state='normal') + else: + gui_components['select_all_btn'].config(state='disabled') + gui_components['clear_all_btn'].config(state='disabled') + + def _get_pending_identifications(self, face_person_names, face_status): + """Get pending identifications that haven't been saved yet""" + pending_identifications = {} + for k, v in face_person_names.items(): + if k not in face_status or face_status[k] != 'identified': + # Handle person data dict format + if isinstance(v, dict): + first_name = v.get('first_name', '').strip() + last_name = v.get('last_name', '').strip() + date_of_birth = v.get('date_of_birth', '').strip() + + # Check if we have complete data (both first and last name, plus date of birth) + if first_name and last_name and date_of_birth: + pending_identifications[k] = v + return pending_identifications + + def _save_all_pending_identifications(self, face_person_names, face_status, identify_data_cache): + """Save all pending identifications from face_person_names""" + saved_count = 0 + + for face_id, person_data in face_person_names.items(): + # Handle person data dict format + if isinstance(person_data, dict): + first_name = person_data.get('first_name', '').strip() + last_name = person_data.get('last_name', '').strip() + date_of_birth = person_data.get('date_of_birth', '').strip() + middle_name = person_data.get('middle_name', '').strip() + maiden_name = person_data.get('maiden_name', '').strip() + + # Only save if we have at least a first or last name + if first_name or last_name: + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + # Add person if doesn't exist + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', (first_name, last_name, middle_name, maiden_name, date_of_birth)) + result = cursor.fetchone() + person_id = result[0] if result else None + + # Update people cache if new person was added + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + if display_name not in identify_data_cache['people_names']: + identify_data_cache['people_names'].append(display_name) + identify_data_cache['people_names'].sort() # Keep sorted + # Keep last names cache updated in-session + if last_name: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if last_name not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(last_name) + identify_data_cache['last_names'].sort() + + # Assign face to person + cursor.execute( + 'UPDATE faces SET person_id = ? WHERE id = ?', + (person_id, face_id) + ) + + # Update person encodings + self.face_processor.update_person_encodings(person_id) + saved_count += 1 + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + print(f"āœ… Saved identification: {display_name}") + + except Exception as e: + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + print(f"āŒ Error saving identification for {display_name}: {e}") + + if saved_count > 0: + print(f"šŸ’¾ Saved {saved_count} pending identifications") + + return saved_count + + def _update_current_face_index(self, original_faces, i, face_status): + """Update the current face index to point to a valid unidentified face""" + unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] + if not unidentified_faces: + # All faces identified, we're done + return False + + # Find the current face in the unidentified list + current_face_id = original_faces[i][0] if i < len(original_faces) else None + if current_face_id and current_face_id in face_status and face_status[current_face_id] == 'identified': + # Current face was just identified, find the next unidentified face + if i < len(original_faces) - 1: + # Try to find the next unidentified face + for j in range(i + 1, len(original_faces)): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + break + else: + # No more faces after current, go to previous + for j in range(i - 1, -1, -1): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + break + else: + # At the end, go to previous unidentified face + for j in range(i - 1, -1, -1): + if original_faces[j][0] not in face_status or face_status[original_faces[j][0]] != 'identified': + i = j + break + + # Ensure index is within bounds + if i >= len(original_faces): + i = len(original_faces) - 1 + if i < 0: + i = 0 + + return True + + def _get_current_face_position(self, original_faces, i, face_status): + """Get current face position among unidentified faces""" + unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified'] + current_face_id = original_faces[i][0] if i < len(original_faces) else None + + # Find position of current face in unidentified list + for pos, face in enumerate(unidentified_faces): + if face[0] == current_face_id: + return pos + 1, len(unidentified_faces) + + return 1, len(unidentified_faces) # Fallback + + def _update_button_states(self, gui_components, original_faces, i, face_status): + """Update button states based on current position and unidentified faces""" + # Update control button states + self._update_control_button_states(gui_components, i, len(original_faces)) + + # Update identify button state + self._update_identify_button_state(gui_components) + + # Update similar faces control buttons state + # Get similar face variables if they exist + similar_face_vars = getattr(self, '_similar_face_vars', []) + self._update_select_clear_buttons_state(gui_components, similar_face_vars) + + def _update_similar_faces(self, gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i): + """Update the similar faces panel when compare is enabled""" + try: + if not gui_components['compare_var'].get(): + return + + scrollable_frame = gui_components['similar_scrollable_frame'] + + # Clear existing content + for widget in scrollable_frame.winfo_children(): + widget.destroy() + + # Get similar faces using filtered version (includes 40% confidence threshold) + similar_faces = self.face_processor._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status) + + if not similar_faces: + no_faces_label = ttk.Label(scrollable_frame, text="No similar faces found", + foreground="gray", font=("Arial", 10)) + no_faces_label.pack(pady=20) + return + + # Filter out already identified faces if unique checkbox is checked + if gui_components['unique_var'].get(): + similar_faces = [face for face in similar_faces + if face.get('person_id') is None] + + if not similar_faces: + no_faces_label = ttk.Label(scrollable_frame, text="No unique similar faces found", + foreground="gray", font=("Arial", 10)) + no_faces_label.pack(pady=20) + return + + # Sort by confidence (distance) - highest confidence first (lowest distance) + similar_faces.sort(key=lambda x: x['distance']) + + # Display similar faces using the old version's approach + self._display_similar_faces_in_panel(scrollable_frame, similar_faces, face_id, + face_selection_states, identify_data_cache) + + # Update canvas scroll region + canvas = gui_components['similar_canvas'] + canvas.update_idletasks() + canvas.configure(scrollregion=canvas.bbox("all")) + + if self.verbose >= 2: + print(f" šŸ” Displayed {len(similar_faces)} similar faces") + + except Exception as e: + print(f"āŒ Error updating similar faces: {e}") + + def _display_similar_faces_in_panel(self, parent_frame, similar_faces_data, current_face_id, + face_selection_states, identify_data_cache): + """Display similar faces in a panel - based on old version's auto-match display logic""" + import tkinter as tk + from tkinter import ttk + from PIL import Image, ImageTk + import os + + # 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() + + # Restore previous checkbox state if available (auto-match style) + if current_face_id is not None and face_selection_states is not None: + unique_key = f"{current_face_id}_{similar_face_id}" + if current_face_id in face_selection_states and unique_key in face_selection_states[current_face_id]: + saved_state = face_selection_states[current_face_id][unique_key] + match_var.set(saved_state) + + # Add immediate callback to save state when checkbox changes (auto-match style) + def make_callback(var, face_id, similar_face_id): + def on_checkbox_change(*args): + unique_key = f"{face_id}_{similar_face_id}" + if face_id not in face_selection_states: + face_selection_states[face_id] = {} + face_selection_states[face_id][unique_key] = var.get() + return on_checkbox_change + + # Bind the callback to the variable + match_var.trace('w', make_callback(match_var, current_face_id, similar_face_id)) + + # Configure match frame for grid layout + match_frame.columnconfigure(0, weight=0) # Checkbox column - fixed width + match_frame.columnconfigure(1, weight=0) # Image column - fixed width + match_frame.columnconfigure(2, weight=1) # Text column - expandable + + # Checkbox without text + checkbox = ttk.Checkbutton(match_frame, variable=match_var) + checkbox.grid(row=0, column=0, rowspan=2, sticky=(tk.W, tk.N), padx=(0, 5)) + + # Add to similar face variables list + similar_face_vars.append((similar_face_id, match_var)) + + # Create labels for confidence and filename + confidence_label = ttk.Label(match_frame, text=f"{confidence_pct:.1f}% {confidence_desc}", font=("Arial", 9, "bold")) + confidence_label.grid(row=0, column=2, sticky=tk.W, padx=(0, 10)) + + filename_label = ttk.Label(match_frame, text=f"šŸ“ {filename}", font=("Arial", 8), foreground="gray") + filename_label.grid(row=1, column=2, sticky=tk.W, padx=(0, 10)) + + # Face image (reusing auto-match image display) + try: + # Get photo path from cache or database + photo_path = None + if identify_data_cache and 'photo_paths' in identify_data_cache: + # Find photo path by filename in cache + for photo_data in identify_data_cache['photo_paths'].values(): + if photo_data['filename'] == filename: + photo_path = photo_data['path'] + break + + # Fallback to database if not in cache + if photo_path is None: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT path FROM photos WHERE filename = ?', (filename,)) + result = cursor.fetchone() + photo_path = result[0] if result else None + + # Extract face crop using existing method + face_crop_path = self.face_processor._extract_face_crop(photo_path, face_data['location'], similar_face_id) + if face_crop_path and os.path.exists(face_crop_path): + # Create canvas for face image (like in auto-match) + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + match_canvas = tk.Canvas(match_frame, width=80, height=80, bg=canvas_bg_color, highlightthickness=0) + match_canvas.grid(row=0, column=1, rowspan=2, sticky=(tk.W, tk.N), padx=(5, 10)) + + # Load and display image (reusing auto-match image loading) + pil_image = Image.open(face_crop_path) + pil_image.thumbnail((80, 80), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(pil_image) + match_canvas.create_image(40, 40, image=photo) + match_canvas.image = photo # Keep reference + + # Add photo icon to the similar face + self.gui_core.create_photo_icon(match_canvas, photo_path, icon_size=15, + face_x=0, face_y=0, + face_width=80, face_height=80, + canvas_width=80, canvas_height=80) + + # Clean up temporary face crop + try: + os.remove(face_crop_path) + except: + pass + + except Exception as e: + if self.verbose >= 1: + print(f" āš ļø Error displaying similar face {i}: {e}") + continue + + # Store similar face variables for Select All/Clear All functionality + self._similar_face_vars = similar_face_vars + + def _get_confidence_description(self, confidence_pct): + """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" + + def _on_similar_face_select(self, similar_face_id, is_selected): + """Handle similar face checkbox selection""" + # This would be used to track which similar faces are selected + # For now, just a placeholder + pass + + def _get_person_name_for_face(self, face_id): + """Get person name for a face that's already identified""" + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT p.first_name, p.last_name 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 = result + if last_name and first_name: + return f"{last_name}, {first_name}" + elif last_name: + return last_name + elif first_name: + return first_name + else: + return "Unknown" + else: + return "Unknown" + + def _update_face_image(self, gui_components, show_faces, face_crop_path, photo_path): + """Update the face image display""" + try: + canvas = gui_components['canvas'] + + # Clear existing image + canvas.delete("all") + + # Determine which image to display + if show_faces and face_crop_path and os.path.exists(face_crop_path): + # Display face crop + image_path = face_crop_path + image_type = "face crop" + elif photo_path and os.path.exists(photo_path): + # Display full photo + image_path = photo_path + image_type = "full photo" + else: + # No image available + canvas.create_text(200, 200, text="No image available", + font=("Arial", 12), fill="gray") + return + + # Load and display image + try: + with Image.open(image_path) as img: + # Force canvas to update its dimensions + canvas.update_idletasks() + + # Get canvas dimensions - use configured size if not yet rendered + canvas_width = canvas.winfo_width() + canvas_height = canvas.winfo_height() + + # If canvas hasn't been rendered yet, use the configured size + if canvas_width <= 1 or canvas_height <= 1: + canvas_width = 400 + canvas_height = 400 + # Set a minimum size to ensure proper rendering + canvas.configure(width=canvas_width, height=canvas_height) + + # Get image dimensions + img_width, img_height = img.size + + # Calculate scale factor to fit image in canvas + scale_x = canvas_width / img_width + scale_y = canvas_height / img_height + scale = min(scale_x, scale_y, 1.0) # Don't scale up + + # Calculate new dimensions + new_width = int(img_width * scale) + new_height = int(img_height * scale) + + # Resize image + img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Convert to PhotoImage + photo = ImageTk.PhotoImage(img_resized) + + # Center image on canvas + x = (canvas_width - new_width) // 2 + y = (canvas_height - new_height) // 2 + + # Create image on canvas + canvas.create_image(x, y, anchor='nw', image=photo) + + # Keep reference to prevent garbage collection + canvas.image_ref = photo + + # Store image data for redrawing on resize + canvas.current_image_data = { + 'image_path': image_path, + 'image_type': image_type, + 'original_img': img, + 'img_width': img_width, + 'img_height': img_height + } + + # Add photo icon using reusable function + self.gui_core.create_photo_icon(canvas, photo_path, + face_x=x, face_y=y, + face_width=new_width, face_height=new_height, + canvas_width=canvas_width, canvas_height=canvas_height) + + # Update canvas scroll region + canvas.configure(scrollregion=canvas.bbox("all")) + + if self.verbose >= 2: + print(f" šŸ–¼ļø Displayed {image_type}: {os.path.basename(image_path)} ({new_width}x{new_height})") + + except Exception as e: + canvas.create_text(200, 200, text=f"Error loading image:\n{str(e)}", + font=("Arial", 10), fill="red") + if self.verbose >= 1: + print(f" āš ļø Error loading image {image_path}: {e}") + + except Exception as e: + print(f"āŒ Error updating face image: {e}") + + def _redraw_current_image(self, canvas): + """Redraw the current image when canvas is resized""" + try: + if not hasattr(canvas, 'current_image_data') or not canvas.current_image_data: + return + + # Clear existing image + canvas.delete("all") + + # Get stored image data + data = canvas.current_image_data + img = data['original_img'] + img_width = data['img_width'] + img_height = data['img_height'] + + # Get current canvas dimensions + canvas_width = canvas.winfo_width() + canvas_height = canvas.winfo_height() + + if canvas_width <= 1 or canvas_height <= 1: + return + + # Calculate new scale + scale_x = canvas_width / img_width + scale_y = canvas_height / img_height + scale = min(scale_x, scale_y, 1.0) + + # Calculate new dimensions + new_width = int(img_width * scale) + new_height = int(img_height * scale) + + # Resize image + img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Convert to PhotoImage + photo = ImageTk.PhotoImage(img_resized) + + # Center image on canvas + x = (canvas_width - new_width) // 2 + y = (canvas_height - new_height) // 2 + + # Create image on canvas + canvas.create_image(x, y, anchor='nw', image=photo) + + # Keep reference to prevent garbage collection + canvas.image_ref = photo + + # Add photo icon using reusable function + self.gui_core.create_photo_icon(canvas, data['image_path'], + face_x=x, face_y=y, + face_width=new_width, face_height=new_height, + canvas_width=canvas_width, canvas_height=canvas_height) + + # Update canvas scroll region + canvas.configure(scrollregion=canvas.bbox("all")) + + except Exception as e: + print(f"āŒ Error redrawing image: {e}") + + def _restore_person_name_input(self, gui_components, face_id, face_person_names, is_already_identified): + """Restore person name input fields""" + try: + if is_already_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 + gui_components['first_name_var'].set(first_name or "") + gui_components['last_name_var'].set(last_name or "") + gui_components['middle_name_var'].set(middle_name or "") + gui_components['maiden_name_var'].set(maiden_name or "") + gui_components['date_of_birth_var'].set(date_of_birth or "") + else: + # Clear all fields if no person found + self._clear_form(gui_components) + else: + # Restore from saved data if available + if face_id in face_person_names: + person_data = face_person_names[face_id] + if isinstance(person_data, dict): + gui_components['first_name_var'].set(person_data.get('first_name', '')) + gui_components['last_name_var'].set(person_data.get('last_name', '')) + gui_components['middle_name_var'].set(person_data.get('middle_name', '')) + gui_components['maiden_name_var'].set(person_data.get('maiden_name', '')) + gui_components['date_of_birth_var'].set(person_data.get('date_of_birth', '')) + else: + # Legacy string format + gui_components['first_name_var'].set(person_data or "") + gui_components['last_name_var'].set("") + gui_components['middle_name_var'].set("") + gui_components['maiden_name_var'].set("") + gui_components['date_of_birth_var'].set("") + else: + # Clear all fields for new face + self._clear_form(gui_components) + + except Exception as e: + print(f"āŒ Error restoring person name input: {e}") + # Clear form on error + self._clear_form(gui_components) + + def _save_current_face_selection_states(self, gui_components, original_faces, i, + face_selection_states, face_person_names): + """Save current checkbox states and person name for the current face""" + try: + if i >= len(original_faces): + return + + current_face_id = original_faces[i][0] + + # Save form data + form_data = self._get_form_data(gui_components) + face_person_names[current_face_id] = form_data + + # Save checkbox states for similar faces + if current_face_id not in face_selection_states: + face_selection_states[current_face_id] = {} + + # Note: Similar face checkbox states would be saved here + # This would require tracking the checkbox variables created in _update_similar_faces + + except Exception as e: + print(f"āŒ Error saving face selection states: {e}") + + def _process_identification_command(self, command_or_data, face_id, is_already_identified, + face_status, gui_components, identify_data_cache): + """Process an identification command""" + try: + # Handle form data (new GUI approach) + if isinstance(command_or_data, dict): + form_data = command_or_data + first_name = form_data.get('first_name', '').strip() + last_name = form_data.get('last_name', '').strip() + middle_name = form_data.get('middle_name', '').strip() + maiden_name = form_data.get('maiden_name', '').strip() + date_of_birth = form_data.get('date_of_birth', '').strip() + else: + # Handle legacy string command + command = command_or_data.strip() + if not command: + return 0 + + # Parse simple name format (legacy support) + parts = command.split() + if len(parts) >= 2: + first_name = parts[0] + last_name = ' '.join(parts[1:]) + else: + first_name = command + last_name = "" + middle_name = "" + maiden_name = "" + date_of_birth = "" # Legacy commands don't include date of birth + + # Add person if doesn't exist + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, middle_name, maiden_name, date_of_birth) VALUES (?, ?, ?, ?, ?)', + (first_name, last_name, middle_name, maiden_name, date_of_birth)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND middle_name = ? AND maiden_name = ? AND date_of_birth = ?', + (first_name, last_name, middle_name, maiden_name, date_of_birth)) + result = cursor.fetchone() + person_id = result[0] if result else None + + # Update people cache if new person was added + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + if display_name not in identify_data_cache['people_names']: + identify_data_cache['people_names'].append(display_name) + identify_data_cache['people_names'].sort() # Keep sorted + + # Keep last names cache updated in-session + if last_name: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if last_name not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(last_name) + identify_data_cache['last_names'].sort() + + # Assign face to person + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE faces SET person_id = ? WHERE id = ?', + (person_id, face_id) + ) + + # Update person encodings + self.face_processor.update_person_encodings(person_id) + + # Mark face as identified + face_status[face_id] = 'identified' + + display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name) + print(f"āœ… Identified as: {display_name}") + + return 1 + + except Exception as e: + print(f"āŒ Error processing identification: {e}") + return 0 + + def _show_people_list(self): + """Show list of known people""" + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name') + people = cursor.fetchall() + + if people: + formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last] + print("šŸ‘„ Known people:", ", ".join(formatted_names)) + else: + print("šŸ‘„ No people identified yet") + + def _open_date_picker(self, date_var): + """Open date picker dialog and update the date variable""" + 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 _toggle_similar_faces_panel(self, components): + """Update the similar faces panel content based on compare checkbox state""" + # Panel is always visible now, just update content + if not components['compare_var'].get(): + # Clear the similar faces content when compare is disabled + scrollable_frame = components['similar_scrollable_frame'] + for widget in scrollable_frame.winfo_children(): + widget.destroy() + + # Show a 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) + + def _set_command(self, command): + """Set the command variable to trigger the main loop""" + # This will be used by button callbacks to set the command + # The main loop will check this variable + if hasattr(self, '_current_command_var'): + self._current_command_var.set(command) + + + def _validate_navigation(self, gui_components): + """Validate that navigation is safe (no unsaved changes)""" + # Check if there are any unsaved changes in the form + first_name = gui_components['first_name_var'].get().strip() + last_name = gui_components['last_name_var'].get().strip() + date_of_birth = gui_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 _clear_form(self, gui_components): + """Clear all form fields""" + gui_components['first_name_var'].set("") + gui_components['last_name_var'].set("") + gui_components['middle_name_var'].set("") + gui_components['maiden_name_var'].set("") + gui_components['date_of_birth_var'].set("") + + def _get_form_data(self, gui_components): + """Get current form data as a dictionary""" + return { + 'first_name': gui_components['first_name_var'].get().strip(), + 'last_name': gui_components['last_name_var'].get().strip(), + 'middle_name': gui_components['middle_name_var'].get().strip(), + 'maiden_name': gui_components['maiden_name_var'].get().strip(), + 'date_of_birth': gui_components['date_of_birth_var'].get().strip() + } + + def _set_form_data(self, gui_components, form_data): + """Set form data from a dictionary""" + gui_components['first_name_var'].set(form_data.get('first_name', '')) + gui_components['last_name_var'].set(form_data.get('last_name', '')) + gui_components['middle_name_var'].set(form_data.get('middle_name', '')) + gui_components['maiden_name_var'].set(form_data.get('maiden_name', '')) + gui_components['date_of_birth_var'].set(form_data.get('date_of_birth', '')) + + def _validate_form_data(self, form_data): + """Validate that form data is complete enough for identification""" + first_name = form_data.get('first_name', '').strip() + last_name = form_data.get('last_name', '').strip() + date_of_birth = form_data.get('date_of_birth', '').strip() + + # Need at least first name or last name, and date of birth + if not (first_name or last_name): + return False, "Please enter at least a first name or last name" + + if not date_of_birth: + return False, "Please enter a date of birth" + + # Validate date format + try: + from datetime import datetime + datetime.strptime(date_of_birth, '%Y-%m-%d') + except ValueError: + return False, "Please enter date of birth in YYYY-MM-DD format" + + return True, "Valid" + + 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 _on_unique_faces_change(self, gui_components, original_faces, i, face_status, + date_from, date_to, date_processed_from, date_processed_to): + """Handle unique faces checkbox change""" + if gui_components['unique_var'].get(): + # Show progress message + print("šŸ”„ Applying unique faces filter...") + + # Apply unique faces filtering to the main face list + try: + filtered_faces = self._filter_unique_faces_from_list(original_faces) + print(f"āœ… Filter applied: {len(filtered_faces)} unique faces remaining") + + # Update the original_faces list with filtered results + # We need to return the filtered list to update the caller + return filtered_faces + + except Exception as e: + print(f"āš ļø Error applying filter: {e}") + # Revert checkbox state + gui_components['unique_var'].set(False) + return original_faces + else: + # Reload the original unfiltered face list + print("šŸ”„ Reloading all faces...") + + # Get fresh unfiltered faces from database + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + 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) + + cursor.execute(query, params) + unfiltered_faces = cursor.fetchall() + + print(f"āœ… Reloaded: {len(unfiltered_faces)} total faces") + return unfiltered_faces + + def _on_compare_change(self, gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i): + """Handle compare checkbox change""" + # Toggle panel visibility + self._toggle_similar_faces_panel(gui_components) + + # Update similar faces if compare is now enabled + if gui_components['compare_var'].get(): + self._update_similar_faces(gui_components, face_id, tolerance, face_status, + face_selection_states, identify_data_cache, original_faces, i) \ No newline at end of file diff --git a/photo_tagger.py b/photo_tagger.py index 63ec2b0..c87b8c8 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -21,6 +21,7 @@ from photo_management import PhotoManager from tag_management import TagManager from search_stats import SearchStats from gui_core import GUICore +from identify_gui import IdentifyGUI class PhotoTagger: @@ -39,6 +40,7 @@ class PhotoTagger: self.tag_manager = TagManager(self.db, verbose) self.search_stats = SearchStats(self.db, verbose) self.gui_core = GUICore() + self.identify_gui = IdentifyGUI(self.db, self.face_processor, verbose) # Legacy compatibility - expose some methods directly self._db_connection = None @@ -173,8 +175,8 @@ class PhotoTagger: def identify_faces(self, batch_size: int = DEFAULT_BATCH_SIZE, show_faces: bool = False, tolerance: float = DEFAULT_FACE_TOLERANCE, date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int: """Interactive face identification with GUI""" - print("āš ļø Face identification GUI not yet implemented in refactored version") - return 0 + return self.identify_gui.identify_faces(batch_size, show_faces, tolerance, + date_from, date_to, date_processed_from, date_processed_to) def tag_management(self) -> int: """Tag management GUI""" @@ -280,6 +282,18 @@ Examples: parser.add_argument('--include-twins', action='store_true', help='Include same-photo matching (for twins or multiple instances)') + parser.add_argument('--date-from', + help='Filter by photo taken date (from) in YYYY-MM-DD format') + + parser.add_argument('--date-to', + help='Filter by photo taken date (to) in YYYY-MM-DD format') + + parser.add_argument('--date-processed-from', + help='Filter by photo processed date (from) in YYYY-MM-DD format') + + parser.add_argument('--date-processed-to', + help='Filter by photo processed date (to) in YYYY-MM-DD format') + parser.add_argument('-v', '--verbose', action='count', default=0, help='Increase verbosity (-v, -vv, -vvv for more detail)') @@ -303,7 +317,9 @@ Examples: elif args.command == 'identify': show_faces = getattr(args, 'show_faces', False) - tagger.identify_faces(args.batch, show_faces, args.tolerance) + tagger.identify_faces(args.batch, show_faces, args.tolerance, + args.date_from, args.date_to, + args.date_processed_from, args.date_processed_to) elif args.command == 'tag': tagger.add_tags(args.pattern or args.target, args.batch)