Add face identification GUI and related features to PhotoTagger

This commit introduces a new IdentifyGUI class for handling face identification within the PunimTag application. The GUI allows users to identify faces interactively, with features such as batch processing, date filtering, and a user-friendly interface for entering person details. The PhotoTagger class is updated to integrate this new functionality, enabling seamless face identification directly from the tagging interface. Additionally, enhancements to the calendar dialog and improved quit validation are included, ensuring a smoother user experience. The README is updated to reflect these new features and usage instructions.
This commit is contained in:
tanyar09 2025-10-03 14:49:08 -04:00
parent f410e60e66
commit a51ffcfaa0
6 changed files with 3093 additions and 63 deletions

View File

@ -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

View File

@ -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:

View File

@ -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", "<Button-1>", open_source_photo)
canvas.tag_bind("photo_icon", "<Enter>", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e)))
canvas.tag_bind("photo_icon", "<Leave>", lambda e: (canvas.config(cursor=""), hide_tooltip(e)))
canvas.tag_bind("photo_icon", "<Motion>", lambda e: (show_tooltip(e) if tooltip else None))
return tooltip # Return tooltip reference for cleanup if needed

View File

@ -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, '<Button-1>', open_source_photo)
canvas.tag_bind(image_id, '<Enter>', lambda e: canvas.config(cursor='hand2'))
canvas.tag_bind(image_id, '<Leave>', 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, '<Enter>', show_tooltip)
canvas.tag_bind(image_id, '<Leave>', 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", "<Button-1>", open_source_photo)
canvas.tag_bind("photo_icon", "<Enter>", lambda e: (canvas.config(cursor="hand2"), show_tooltip(e)))
canvas.tag_bind("photo_icon", "<Leave>", lambda e: (canvas.config(cursor=""), hide_tooltip(e)))
canvas.tag_bind("photo_icon", "<Motion>", 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('<KeyRelease>', lambda e: show_suggestions())
entry.bind('<KeyPress>', on_key_press)
entry.bind('<FocusOut>', lambda e: parent.after(150, hide_suggestions)) # Delay to allow listbox clicks
listbox.bind('<Button-1>', on_listbox_click)
listbox.bind('<KeyPress>', on_listbox_key)
listbox.bind('<Double-Button-1>', on_listbox_click)
return entry, entry_var, listbox

2224
identify_gui.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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)