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:
parent
f410e60e66
commit
a51ffcfaa0
26
README.md
26
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
|
||||
|
||||
13
database.py
13
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:
|
||||
|
||||
@ -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
|
||||
507
gui_core.py
507
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, '<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
2224
identify_gui.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user