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)