feat: Enhance Identify Panel with quality filtering and navigation improvements

This commit introduces a quality filtering feature in the Identify Panel, allowing users to filter faces based on a quality score (0-100%). The panel now includes a slider for adjusting the quality threshold and displays the current quality percentage. Additionally, navigation functions have been updated to skip to the next or previous face that meets the quality criteria, improving the user experience during identification. The README has been updated to reflect these new features and enhancements.
This commit is contained in:
tanyar09 2025-10-16 14:49:00 -04:00
parent b2847a066e
commit 986fc81005
5 changed files with 532 additions and 134 deletions

View File

@ -16,6 +16,7 @@ A powerful desktop application for organizing and tagging photos using **state-o
- **👤 Person Identification**: Identify and tag people across your photo collection
- **🤖 Smart Auto-Matching**: Intelligent face matching with quality scoring and cosine similarity
- **🔍 Advanced Search**: Search by people, dates, tags, and folders
- **🎚️ Quality Filtering**: Filter faces by quality score in Identify panel (0-100%)
- **🏷️ Tag Management**: Organize photos with hierarchical tags
- **⚡ Batch Processing**: Process thousands of photos efficiently
- **🔒 Privacy-First**: All data stored locally, no cloud dependencies
@ -114,7 +115,12 @@ python src/photo_tagger.py scan /path/to/photos
Open the dashboard and click "Process Photos" to detect faces.
### 3. Identify People
Use the "Identify" panel to tag faces with names.
Use the "Identify" panel to tag faces with names:
- **Quality Filter**: Adjust the quality slider (0-100%) to filter out low-quality faces
- **Unique Faces**: Enable to hide duplicate faces using cosine similarity
- **Date Filters**: Filter faces by date range
- **Navigation**: Browse through unidentified faces with prev/next buttons
- **Photo Viewer**: Click the photo icon to view the full source image
### 4. Search
Use the "Search" panel to find photos by people, dates, or tags.
@ -133,11 +139,12 @@ Use the dashboard to configure DeepFace settings:
### Manual Configuration
Edit `src/core/config.py` to customize:
- `DEEPFACE_DETECTOR_BACKEND` - Face detection model
- `DEEPFACE_MODEL_NAME` - Recognition model
- `DEFAULT_FACE_TOLERANCE` - Similarity tolerance (0.4 for DeepFace)
- `DEEPFACE_SIMILARITY_THRESHOLD` - Minimum similarity percentage
- Batch sizes and quality thresholds
- `DEEPFACE_DETECTOR_BACKEND` - Face detection model (default: `retinaface`)
- `DEEPFACE_MODEL_NAME` - Recognition model (default: `ArcFace`)
- `DEFAULT_FACE_TOLERANCE` - Similarity tolerance (default: `0.6` for DeepFace)
- `DEEPFACE_SIMILARITY_THRESHOLD` - Minimum similarity percentage (default: `60`)
- `MIN_FACE_QUALITY` - Minimum face quality score (default: `0.3`)
- Batch sizes and other processing thresholds
---
@ -169,6 +176,11 @@ python tests/test_deepface_gui.py
- ✅ Multiple detector/model options (GUI selectable)
- ✅ Cosine similarity matching
- ✅ Face confidence scores and quality metrics
- ✅ Quality filtering in Identify panel (adjustable 0-100%)
- ✅ Unique faces detection (cosine similarity-based deduplication)
- ✅ Enhanced thumbnail display (100x100px)
- ✅ External system photo viewer integration
- ✅ Improved auto-match save responsiveness
- ✅ Metadata display (detector/model info in GUI)
- ✅ Enhanced accuracy and reliability
- ✅ Comprehensive test coverage (20/20 tests passing)

View File

@ -27,7 +27,7 @@ DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"]
DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"]
# Face tolerance/threshold settings (adjusted for DeepFace)
DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6 for face_recognition)
DEFAULT_FACE_TOLERANCE = 0.6 # Default tolerance for face matching
DEEPFACE_SIMILARITY_THRESHOLD = 60 # Minimum similarity percentage (0-100)
# Legacy settings (kept for compatibility until Phase 3 migration)

View File

@ -657,10 +657,17 @@ class AutoMatchPanel:
matched_id = active_ids[self.current_matched_index]
matches_for_this_person = self.matches_by_matched[matched_id]
# Show saving message
self.components['save_btn'].config(text="💾 Saving...", state='disabled')
self.main_frame.update_idletasks()
# Initialize identified faces for this person if not exists
if matched_id not in self.identified_faces_per_person:
self.identified_faces_per_person[matched_id] = set()
# Count changes for feedback
changes_made = 0
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
@ -682,6 +689,7 @@ class AutoMatchPanel:
print(f"✅ Identified as: {person_name}")
self.identified_count += 1
changes_made += 1
else:
# Face is unchecked - check if it was previously identified for this person
if match['unidentified_id'] in self.identified_faces_per_person[matched_id]:
@ -695,13 +703,21 @@ class AutoMatchPanel:
self.identified_faces_per_person[matched_id].discard(match['unidentified_id'])
print(f"❌ Unidentified: {match['unidentified_filename']}")
changes_made += 1
# Update person encodings for all affected persons
for person_id in set(match['person_id'] for match in matches_for_this_person if match['person_id']):
self.face_processor.update_person_encodings(person_id)
# Commit changes first
conn.commit()
# Update person encodings for all affected persons (outside of transaction)
# This can be slow, so we show progress
affected_person_ids = set(match['person_id'] for match in matches_for_this_person if match['person_id'])
if affected_person_ids:
self.components['save_btn'].config(text="💾 Updating encodings...")
self.main_frame.update_idletasks()
for person_id in affected_person_ids:
self.face_processor.update_person_encodings(person_id)
# After saving, set original states to the current UI states
current_snapshot = {}
for match, var in zip(matches_for_this_person, self.match_vars):
@ -709,6 +725,15 @@ class AutoMatchPanel:
current_snapshot[unique_key] = var.get()
self.checkbox_states_per_person[matched_id] = dict(current_snapshot)
self.original_checkbox_states_per_person[matched_id] = dict(current_snapshot)
# Show completion message
if changes_made > 0:
print(f"✅ Saved {changes_made} change(s)")
# Restore button text and update state
self._update_save_button_text()
self.components['save_btn'].config(state='normal')
self.main_frame.update_idletasks()
def _go_back(self):
"""Go back to the previous person"""

View File

@ -76,41 +76,18 @@ class GUICore:
import platform
def open_source_photo(event):
"""Open the source photo in a properly sized window"""
"""Open the source photo in the system's default photo viewer"""
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)
# Windows - use default photo viewer
os.startfile(photo_path)
elif system == "Darwin": # macOS
# Use Preview with specific window size
subprocess.run(["open", "-a", "Preview", photo_path])
# macOS - use default Preview or Photos app
subprocess.Popen(["open", 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])
# Linux - use xdg-open which opens with default viewer
subprocess.Popen(["xdg-open", photo_path])
except Exception as e:
print(f"❌ Could not open photo: {e}")
@ -855,4 +832,196 @@ class GUICore:
listbox.bind('<KeyPress>', on_listbox_key)
listbox.bind('<Double-Button-1>', on_listbox_click)
return entry, entry_var, listbox
return entry, entry_var, listbox
def show_image_viewer(self, parent, photo_path: str):
"""Show an in-app image viewer dialog with zoom functionality"""
import tkinter as tk
from tkinter import ttk
from PIL import Image, ImageTk
try:
# Create viewer window
viewer = tk.Toplevel(parent)
viewer.title(f"Photo Viewer - {os.path.basename(photo_path)}")
# Get screen dimensions
screen_width = viewer.winfo_screenwidth()
screen_height = viewer.winfo_screenheight()
# Load original image
original_img = Image.open(photo_path)
img_width, img_height = original_img.size
# Calculate optimal window size (90% of screen, but respecting aspect ratio)
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.9)
# Calculate initial scaling to fit image while maintaining aspect ratio
width_ratio = max_width / img_width
height_ratio = max_height / img_height
initial_scale = min(width_ratio, height_ratio, 1.0) # Don't upscale initially
# Window size based on initial scale
window_width = min(int(img_width * initial_scale) + 40, max_width)
window_height = min(int(img_height * initial_scale) + 150, max_height)
# Center window on screen
x = (screen_width - window_width) // 2
y = (screen_height - window_height) // 2
viewer.geometry(f"{window_width}x{window_height}+{x}+{y}")
# Create main frame with padding
main_frame = ttk.Frame(viewer, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
# Zoom controls frame
zoom_frame = ttk.Frame(main_frame)
zoom_frame.pack(fill=tk.X, pady=(0, 5))
# Create canvas with scrollbars for large images
canvas_frame = ttk.Frame(main_frame)
canvas_frame.pack(fill=tk.BOTH, expand=True)
# Canvas
canvas = tk.Canvas(canvas_frame, bg='gray25', highlightthickness=0)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Scrollbars
v_scrollbar = ttk.Scrollbar(canvas_frame, orient='vertical', command=canvas.yview)
v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
h_scrollbar = ttk.Scrollbar(main_frame, orient='horizontal', command=canvas.xview)
h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)
canvas.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set)
# Zoom state
current_zoom = initial_scale
zoom_levels = [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0]
# Info label
info_label = ttk.Label(main_frame, text="", font=("Arial", 9))
info_label.pack(pady=(5, 0))
def update_image(zoom_scale):
"""Update the displayed image with the given zoom scale"""
nonlocal current_zoom
current_zoom = zoom_scale
# Calculate new dimensions
new_width = int(img_width * zoom_scale)
new_height = int(img_height * zoom_scale)
# Resize image
img_resized = original_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(img_resized)
# Clear canvas and create new image
canvas.delete("all")
canvas.create_image(new_width // 2, new_height // 2,
image=photo, anchor=tk.CENTER, tags="image")
canvas.image = photo # Keep reference
# Update scroll region
canvas.configure(scrollregion=(0, 0, new_width, new_height))
# Update info label
info_text = f"Image: {os.path.basename(photo_path)} | " \
f"Original: {img_width}×{img_height}px | " \
f"Zoom: {zoom_scale*100:.0f}% | " \
f"Display: {new_width}×{new_height}px"
info_label.config(text=info_text)
# Update zoom buttons state
zoom_in_btn.config(state='normal' if zoom_scale < zoom_levels[-1] else 'disabled')
zoom_out_btn.config(state='normal' if zoom_scale > zoom_levels[0] else 'disabled')
zoom_reset_btn.config(state='normal' if zoom_scale != 1.0 else 'disabled')
def zoom_in():
"""Zoom in to the next level"""
next_zoom = min([z for z in zoom_levels if z > current_zoom], default=current_zoom)
if next_zoom > current_zoom:
update_image(next_zoom)
def zoom_out():
"""Zoom out to the previous level"""
prev_zoom = max([z for z in zoom_levels if z < current_zoom], default=current_zoom)
if prev_zoom < current_zoom:
update_image(prev_zoom)
def zoom_fit():
"""Zoom to fit window"""
update_image(initial_scale)
def zoom_100():
"""Zoom to 100% (actual size)"""
update_image(1.0)
def on_mouse_wheel(event):
"""Handle mouse wheel zoom"""
# Get mouse position relative to canvas
canvas_x = canvas.canvasx(event.x)
canvas_y = canvas.canvasy(event.y)
# Determine zoom direction
if event.delta > 0 or event.num == 4: # Zoom in
zoom_in()
elif event.delta < 0 or event.num == 5: # Zoom out
zoom_out()
# Zoom control buttons
ttk.Label(zoom_frame, text="Zoom:", font=("Arial", 9, "bold")).pack(side=tk.LEFT, padx=(0, 5))
zoom_out_btn = ttk.Button(zoom_frame, text="", width=3, command=zoom_out)
zoom_out_btn.pack(side=tk.LEFT, padx=2)
zoom_in_btn = ttk.Button(zoom_frame, text="+", width=3, command=zoom_in)
zoom_in_btn.pack(side=tk.LEFT, padx=2)
zoom_reset_btn = ttk.Button(zoom_frame, text="Fit", width=5, command=zoom_fit)
zoom_reset_btn.pack(side=tk.LEFT, padx=2)
zoom_100_btn = ttk.Button(zoom_frame, text="100%", width=5, command=zoom_100)
zoom_100_btn.pack(side=tk.LEFT, padx=2)
ttk.Label(zoom_frame, text="(Use mouse wheel to zoom)",
font=("Arial", 8), foreground="gray").pack(side=tk.LEFT, padx=(10, 0))
# Close button
button_frame = ttk.Frame(main_frame)
button_frame.pack(pady=(5, 0))
close_btn = ttk.Button(button_frame, text="Close", command=viewer.destroy)
close_btn.pack()
# Bind mouse wheel events (different on different platforms)
canvas.bind("<MouseWheel>", on_mouse_wheel) # Windows/Mac
canvas.bind("<Button-4>", on_mouse_wheel) # Linux scroll up
canvas.bind("<Button-5>", on_mouse_wheel) # Linux scroll down
# Keyboard shortcuts
viewer.bind('<Escape>', lambda e: viewer.destroy())
viewer.bind('<Return>', lambda e: viewer.destroy())
viewer.bind('<plus>', lambda e: zoom_in())
viewer.bind('<minus>', lambda e: zoom_out())
viewer.bind('<equal>', lambda e: zoom_in()) # + without shift
viewer.bind('<KP_Add>', lambda e: zoom_in()) # Numpad +
viewer.bind('<KP_Subtract>', lambda e: zoom_out()) # Numpad -
viewer.bind('f', lambda e: zoom_fit())
viewer.bind('F', lambda e: zoom_fit())
viewer.bind('1', lambda e: zoom_100())
# Initialize image at initial scale
update_image(initial_scale)
# Make modal (wait for window to be visible before grabbing)
viewer.transient(parent)
viewer.update_idletasks() # Ensure window is rendered
viewer.deiconify() # Ensure window is shown
viewer.wait_visibility() # Wait for window to become visible
viewer.grab_set() # Now safe to grab focus
except Exception as e:
import tkinter.messagebox as messagebox
messagebox.showerror("Error", f"Could not display image:\n{e}", parent=parent)

View File

@ -86,6 +86,9 @@ class IdentifyPanel:
self.components['date_processed_from_var'] = tk.StringVar(value="")
self.components['date_processed_to_var'] = tk.StringVar(value="")
# Quality filter variable (0-100%)
self.components['quality_filter_var'] = tk.IntVar(value=0)
# Date filter controls
date_filter_frame = ttk.LabelFrame(self.main_frame, text="Filter", padding="5")
date_filter_frame.grid(row=1, column=0, pady=(0, 0), sticky=tk.W)
@ -148,20 +151,99 @@ class IdentifyPanel:
self.components['date_processed_to_btn'] = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to)
self.components['date_processed_to_btn'].grid(row=1, column=5, padx=(0, 10), pady=(10, 0))
# Quality filter (third row)
ttk.Label(date_filter_frame, text="Min quality:").grid(row=2, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0))
# Quality slider frame
quality_slider_frame = ttk.Frame(date_filter_frame)
quality_slider_frame.grid(row=2, column=1, columnspan=5, sticky=tk.W, pady=(10, 0))
# Quality slider
self.components['quality_slider'] = tk.Scale(
quality_slider_frame,
from_=0,
to=100,
orient=tk.HORIZONTAL,
variable=self.components['quality_filter_var'],
length=250,
tickinterval=25,
resolution=5,
showvalue=0
)
self.components['quality_slider'].pack(side=tk.LEFT, padx=(0, 10))
# Quality value label
self.components['quality_value_label'] = ttk.Label(
quality_slider_frame,
text=f"{self.components['quality_filter_var'].get()}%",
font=("Arial", 10, "bold"),
width=6
)
self.components['quality_value_label'].pack(side=tk.LEFT, padx=(0, 5))
# Update label when slider changes
def update_quality_label(*args):
quality = self.components['quality_filter_var'].get()
self.components['quality_value_label'].config(text=f"{quality}%")
# Color code the label
if quality == 0:
color = "gray"
elif quality < 30:
color = "orange"
elif quality < 60:
color = "#E67E22" # orange
else:
color = "green"
self.components['quality_value_label'].config(foreground=color)
self.components['quality_filter_var'].trace('w', update_quality_label)
update_quality_label() # Initialize
# Quality filter help text
ttk.Label(
quality_slider_frame,
text="(0 = all faces)",
font=("Arial", 8),
foreground="gray"
).pack(side=tk.LEFT)
# Unique checkbox under the filter frame
def on_unique_change():
"""Handle unique faces checkbox change - filter main face list like old implementation"""
if self.components['unique_var'].get():
# Check if identification has started
if not self.current_faces:
messagebox.showinfo("Start Identification First",
"Please click 'Start Identification' before applying the unique faces filter.")
self.components['unique_var'].set(False)
return
# Show progress message
print("🔄 Applying unique faces filter...")
self.main_frame.update() # Update UI to show the message
# Store original count
original_count = len(self.current_faces)
# Apply unique faces filtering to the main face list
try:
self.current_faces = self._filter_unique_faces_from_list(self.current_faces)
print(f"✅ Filter applied: {len(self.current_faces)} unique faces remaining")
filtered_count = len(self.current_faces)
removed_count = original_count - filtered_count
print(f"✅ Filter applied: {filtered_count} unique faces remaining ({removed_count} duplicates hidden)")
if removed_count > 0:
messagebox.showinfo("Unique Faces Filter Applied",
f"Showing {filtered_count} unique faces.\n"
f"Hidden {removed_count} duplicate faces (≥60% match confidence).")
else:
messagebox.showinfo("No Duplicates Found",
"All faces appear to be unique - no duplicates to hide.")
except Exception as e:
print(f"⚠️ Error applying filter: {e}")
messagebox.showerror("Filter Error", f"Error applying unique faces filter:\n{e}")
# Revert checkbox state
self.components['unique_var'].set(False)
return
@ -409,7 +491,7 @@ class IdentifyPanel:
date_processed_from = self.components['date_processed_from_var'].get().strip() or None
date_processed_to = self.components['date_processed_to_var'].get().strip() or None
# Get unidentified faces
# Get unidentified faces (without quality filter - we'll filter in display)
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
date_processed_from, date_processed_to)
@ -469,6 +551,9 @@ class IdentifyPanel:
query += ' AND DATE(p.date_added) <= ?'
params.append(date_processed_to)
# Order by quality score (highest first) to show best quality faces first
query += ' ORDER BY f.quality_score DESC'
query += ' LIMIT ?'
params.append(batch_size)
@ -552,15 +637,23 @@ class IdentifyPanel:
for j, face_id2 in enumerate(face_id_list):
if i != j:
try:
import face_recognition
import numpy as np
encoding1 = face_encodings[face_id1]
encoding2 = face_encodings[face_id2]
# Calculate distance
distance = face_recognition.face_distance([encoding1], encoding2)[0]
# Calculate distance using cosine similarity (DeepFace compatible)
# Normalize encodings
encoding1_norm = encoding1 / np.linalg.norm(encoding1)
encoding2_norm = encoding2 / np.linalg.norm(encoding2)
# Cosine distance = 1 - cosine similarity
cosine_similarity = np.dot(encoding1_norm, encoding2_norm)
distance = 1.0 - cosine_similarity
face_distances[(face_id1, face_id2)] = distance
except Exception:
except Exception as e:
# If calculation fails, assume no match
print(f"⚠️ Error calculating distance between faces {face_id1} and {face_id2}: {e}")
face_distances[(face_id1, face_id2)] = 1.0
# Apply unique faces filtering
@ -995,40 +1088,40 @@ class IdentifyPanel:
face_crop_path = self.face_processor._extract_face_crop(photo_path, location, similar_face_id)
if face_crop_path and os.path.exists(face_crop_path):
image = Image.open(face_crop_path)
image.thumbnail((50, 50), Image.Resampling.LANCZOS)
image.thumbnail((100, 100), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(image)
# Create a canvas for the face image to allow photo icon drawing
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0)
face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0)
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
face_canvas.create_image(25, 25, image=photo, anchor=tk.CENTER)
face_canvas.create_image(50, 50, image=photo, anchor=tk.CENTER)
face_canvas.image = photo # Keep reference
# Add photo icon exactly at the image's top-right corner
self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15,
self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=20,
face_x=0, face_y=0,
face_width=50, face_height=50,
canvas_width=50, canvas_height=50)
face_width=100, face_height=100,
canvas_width=100, canvas_height=100)
else:
# Face crop extraction failed or file doesn't exist
print(f"Face crop not available for face {similar_face_id}: {face_crop_path}")
# Create placeholder canvas
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray')
face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray')
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
face_canvas.create_text(25, 25, text="No\nImage", fill="gray", font=("Arial", 8))
face_canvas.create_text(50, 50, text="No\nImage", fill="gray", font=("Arial", 10))
else:
# Photo path not found in cache
print(f"Photo path not found for photo_id {photo_id} in cache")
# Create placeholder canvas
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray')
face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray')
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
face_canvas.create_text(25, 25, text="No\nPath", fill="gray", font=("Arial", 8))
face_canvas.create_text(50, 50, text="No\nPath", fill="gray", font=("Arial", 10))
except Exception as e:
print(f"Error creating similar face widget for face {similar_face_id}: {e}")
# Create placeholder canvas on error
face_canvas = tk.Canvas(match_frame, width=50, height=50, highlightthickness=0, bg='lightgray')
face_canvas = tk.Canvas(match_frame, width=100, height=100, highlightthickness=0, bg='lightgray')
face_canvas.pack(side=tk.LEFT, padx=(0, 5))
face_canvas.create_text(25, 25, text="Error", fill="red", font=("Arial", 8))
face_canvas.create_text(50, 50, text="Error", fill="red", font=("Arial", 10))
# Confidence label with color coding and description
confidence_text = f"{confidence_pct:.0f}% {confidence_desc}"
@ -1158,70 +1251,104 @@ class IdentifyPanel:
self._save_identification(face_id, person_data)
def _go_back(self):
"""Go back to the previous face"""
if self.current_face_index > 0:
# Validate navigation (check for unsaved changes)
validation_result = self._validate_navigation()
if validation_result == 'cancel':
return # Cancel navigation
elif validation_result == 'save_and_continue':
# Save the current identification before proceeding
if self.current_faces and self.current_face_index < len(self.current_faces):
face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index]
first_name = self.components['first_name_var'].get().strip()
last_name = self.components['last_name_var'].get().strip()
date_of_birth = self.components['date_of_birth_var'].get().strip()
if first_name and last_name and date_of_birth:
person_data = {
'first_name': first_name,
'last_name': last_name,
'middle_name': self.components['middle_name_var'].get().strip(),
'maiden_name': self.components['maiden_name_var'].get().strip(),
'date_of_birth': date_of_birth
}
self.face_person_names[face_id] = person_data
self.face_status[face_id] = 'identified'
elif validation_result == 'discard_and_continue':
# Clear the form but don't save
self._clear_form()
"""Go back to the previous face that meets quality criteria"""
# Validate navigation (check for unsaved changes)
validation_result = self._validate_navigation()
if validation_result == 'cancel':
return # Cancel navigation
elif validation_result == 'save_and_continue':
# Save the current identification before proceeding
if self.current_faces and self.current_face_index < len(self.current_faces):
face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index]
first_name = self.components['first_name_var'].get().strip()
last_name = self.components['last_name_var'].get().strip()
date_of_birth = self.components['date_of_birth_var'].get().strip()
if first_name and last_name and date_of_birth:
person_data = {
'first_name': first_name,
'last_name': last_name,
'middle_name': self.components['middle_name_var'].get().strip(),
'maiden_name': self.components['maiden_name_var'].get().strip(),
'date_of_birth': date_of_birth
}
self.face_person_names[face_id] = person_data
self.face_status[face_id] = 'identified'
elif validation_result == 'discard_and_continue':
# Clear the form but don't save
self._clear_form()
# Get quality filter
min_quality = self.components['quality_filter_var'].get()
min_quality_score = min_quality / 100.0
# Find previous qualifying face
found = False
for i in range(self.current_face_index - 1, -1, -1):
_, _, _, _, _, _, quality, _, _ = self.current_faces[i]
quality_score = quality if quality is not None else 0.0
self.current_face_index -= 1
self._update_current_face()
self._update_button_states()
def _go_next(self):
"""Go to the next face"""
if self.current_face_index < len(self.current_faces) - 1:
# Validate navigation (check for unsaved changes)
validation_result = self._validate_navigation()
if validation_result == 'cancel':
return # Cancel navigation
elif validation_result == 'save_and_continue':
# Save the current identification before proceeding
if self.current_faces and self.current_face_index < len(self.current_faces):
face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index]
first_name = self.components['first_name_var'].get().strip()
last_name = self.components['last_name_var'].get().strip()
date_of_birth = self.components['date_of_birth_var'].get().strip()
if first_name and last_name and date_of_birth:
person_data = {
'first_name': first_name,
'last_name': last_name,
'middle_name': self.components['middle_name_var'].get().strip(),
'maiden_name': self.components['maiden_name_var'].get().strip(),
'date_of_birth': date_of_birth
}
self.face_person_names[face_id] = person_data
self.face_status[face_id] = 'identified'
elif validation_result == 'discard_and_continue':
# Clear the form but don't save
self._clear_form()
self.current_face_index += 1
if quality_score >= min_quality_score:
self.current_face_index = i
found = True
break
if found:
self._update_current_face()
self._update_button_states()
else:
# Check if there are more faces to load
messagebox.showinfo("No Previous Face",
f"No previous face with quality >= {min_quality}%.")
def _go_next(self):
"""Go to the next face that meets quality criteria"""
# Validate navigation (check for unsaved changes)
validation_result = self._validate_navigation()
if validation_result == 'cancel':
return # Cancel navigation
elif validation_result == 'save_and_continue':
# Save the current identification before proceeding
if self.current_faces and self.current_face_index < len(self.current_faces):
face_id, _, _, _, _, _, _, _, _ = self.current_faces[self.current_face_index]
first_name = self.components['first_name_var'].get().strip()
last_name = self.components['last_name_var'].get().strip()
date_of_birth = self.components['date_of_birth_var'].get().strip()
if first_name and last_name and date_of_birth:
person_data = {
'first_name': first_name,
'last_name': last_name,
'middle_name': self.components['middle_name_var'].get().strip(),
'maiden_name': self.components['maiden_name_var'].get().strip(),
'date_of_birth': date_of_birth
}
self.face_person_names[face_id] = person_data
self.face_status[face_id] = 'identified'
elif validation_result == 'discard_and_continue':
# Clear the form but don't save
self._clear_form()
# Get quality filter
min_quality = self.components['quality_filter_var'].get()
min_quality_score = min_quality / 100.0
# Find next qualifying face
original_index = self.current_face_index
found = False
for i in range(self.current_face_index + 1, len(self.current_faces)):
_, _, _, _, _, _, quality, _, _ = self.current_faces[i]
quality_score = quality if quality is not None else 0.0
if quality_score >= min_quality_score:
self.current_face_index = i
found = True
break
if found:
self._update_current_face()
self._update_button_states()
else:
# No more qualifying faces in current batch, try to load more
self._load_more_faces()
def _load_more_faces(self):
@ -1403,22 +1530,48 @@ class IdentifyPanel:
self.components['face_canvas'].delete("all")
def _apply_date_filters(self):
"""Apply date filters and reload faces"""
# Get current filter values
date_from = self.components['date_from_var'].get().strip() or None
date_to = self.components['date_to_var'].get().strip() or None
date_processed_from = self.components['date_processed_from_var'].get().strip() or None
date_processed_to = self.components['date_processed_to_var'].get().strip() or None
"""Apply date and quality filters by jumping to next qualifying face"""
# Get quality filter
min_quality = self.components['quality_filter_var'].get()
# Get batch size
try:
batch_size = int(self.components['batch_var'].get().strip())
except Exception:
batch_size = DEFAULT_BATCH_SIZE
# If we have faces loaded, find the next face that meets quality criteria
if self.current_faces:
# Start from current position and find next qualifying face
self._find_next_qualifying_face(min_quality)
else:
# No faces loaded, need to reload
date_from = self.components['date_from_var'].get().strip() or None
date_to = self.components['date_to_var'].get().strip() or None
date_processed_from = self.components['date_processed_from_var'].get().strip() or None
date_processed_to = self.components['date_processed_to_var'].get().strip() or None
# Get batch size
try:
batch_size = int(self.components['batch_var'].get().strip())
except Exception:
batch_size = DEFAULT_BATCH_SIZE
# Reload faces with new filters
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
date_processed_from, date_processed_to)
if not self.current_faces:
messagebox.showinfo("No Faces Found", "No unidentified faces found with the current filters.")
return
# Reset state
self.current_face_index = 0
self.face_status = {}
self.face_person_names = {}
self.face_selection_states = {}
# Pre-fetch data
self.identify_data_cache = self._prefetch_identify_data(self.current_faces)
# Find first qualifying face
self._find_next_qualifying_face(min_quality)
# Reload faces with new filters
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
date_processed_from, date_processed_to)
self.is_active = True
if not self.current_faces:
messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.")
@ -1439,6 +1592,45 @@ class IdentifyPanel:
self.is_active = True
def _find_next_qualifying_face(self, min_quality: int):
"""Find the next face that meets the quality criteria"""
if not self.current_faces:
return
min_quality_score = min_quality / 100.0 # Convert percentage to 0-1 scale
# Search from current index forward
original_index = self.current_face_index
found = False
for i in range(self.current_face_index, len(self.current_faces)):
_, _, _, _, _, _, quality, _, _ = self.current_faces[i]
quality_score = quality if quality is not None else 0.0
if quality_score >= min_quality_score:
self.current_face_index = i
found = True
break
# If not found forward, search from beginning
if not found:
for i in range(0, original_index):
_, _, _, _, _, _, quality, _, _ = self.current_faces[i]
quality_score = quality if quality is not None else 0.0
if quality_score >= min_quality_score:
self.current_face_index = i
found = True
break
if found:
self._update_current_face()
self._update_button_states()
else:
messagebox.showinfo("No Qualifying Faces",
f"No faces found with quality >= {min_quality}%.\n"
f"Lower the quality filter to see more faces.")
def _open_date_picker(self, date_var: tk.StringVar):
"""Open date picker dialog"""
current_date = date_var.get()