punimtag/face_processing.py
tanyar09 8ce538c508 Add Auto-Match Panel to Dashboard GUI for enhanced face matching functionality
This commit introduces the AutoMatchPanel class into the Dashboard GUI, providing a fully integrated interface for automatic face matching. The new panel allows users to start the auto-match process, configure tolerance settings, and visually confirm matches between identified and unidentified faces. It includes features for bulk selection of matches, smart navigation through matched individuals, and a search filter for large databases. The README has been updated to reflect the new functionality and improvements in the auto-match workflow, enhancing the overall user experience in managing photo identifications.
2025-10-10 12:40:24 -04:00

863 lines
40 KiB
Python

#!/usr/bin/env python3
"""
Face detection, encoding, and matching functionality for PunimTag
"""
import os
import tempfile
import numpy as np
import face_recognition
from PIL import Image, ImageDraw, ImageFont
from typing import List, Dict, Tuple, Optional
from functools import lru_cache
from config import DEFAULT_FACE_DETECTION_MODEL, DEFAULT_FACE_TOLERANCE, MIN_FACE_QUALITY
from database import DatabaseManager
class FaceProcessor:
"""Handles face detection, encoding, and matching operations"""
def __init__(self, db_manager: DatabaseManager, verbose: int = 0):
"""Initialize face processor"""
self.db = db_manager
self.verbose = verbose
self._face_encoding_cache = {}
self._image_cache = {}
@lru_cache(maxsize=1000)
def _get_cached_face_encoding(self, face_id: int, encoding_bytes: bytes) -> np.ndarray:
"""Cache face encodings to avoid repeated numpy conversions"""
return np.frombuffer(encoding_bytes, dtype=np.float64)
def _clear_caches(self):
"""Clear all caches to free memory"""
self._face_encoding_cache.clear()
self._image_cache.clear()
self._get_cached_face_encoding.cache_clear()
def cleanup_face_crops(self, current_face_crop_path=None):
"""Clean up face crop files and caches"""
# Clean up current face crop if provided
if current_face_crop_path and os.path.exists(current_face_crop_path):
try:
os.remove(current_face_crop_path)
except:
pass # Ignore cleanup errors
# Clean up all cached face crop files
for cache_key, cached_path in list(self._image_cache.items()):
if os.path.exists(cached_path):
try:
os.remove(cached_path)
except:
pass # Ignore cleanup errors
# Clear caches
self._clear_caches()
def process_faces(self, limit: int = 50, model: str = DEFAULT_FACE_DETECTION_MODEL) -> int:
"""Process unprocessed photos for faces"""
unprocessed = self.db.get_unprocessed_photos(limit)
if not unprocessed:
print("✅ No unprocessed photos found")
return 0
print(f"🔍 Processing {len(unprocessed)} photos for faces...")
processed_count = 0
for photo_id, photo_path, filename, date_taken in unprocessed:
if not os.path.exists(photo_path):
print(f"❌ File not found: {filename}")
self.db.mark_photo_processed(photo_id)
continue
try:
# Load image and find faces
if self.verbose >= 1:
print(f"📸 Processing: {filename}")
elif self.verbose == 0:
print(".", end="", flush=True)
if self.verbose >= 2:
print(f" 🔍 Loading image: {photo_path}")
image = face_recognition.load_image_file(photo_path)
face_locations = face_recognition.face_locations(image, model=model)
if face_locations:
face_encodings = face_recognition.face_encodings(image, face_locations)
if self.verbose >= 1:
print(f" 👤 Found {len(face_locations)} faces")
# Save faces to database with quality scores
for i, (encoding, location) in enumerate(zip(face_encodings, face_locations)):
# Calculate face quality score
quality_score = self._calculate_face_quality_score(image, location)
self.db.add_face(
photo_id=photo_id,
encoding=encoding.tobytes(),
location=str(location),
quality_score=quality_score
)
if self.verbose >= 3:
print(f" Face {i+1}: {location} (quality: {quality_score:.2f})")
else:
if self.verbose >= 1:
print(f" 👤 No faces found")
elif self.verbose >= 2:
print(f" 👤 {filename}: No faces found")
# Mark as processed
self.db.mark_photo_processed(photo_id)
processed_count += 1
except Exception as e:
print(f"❌ Error processing {filename}: {e}")
self.db.mark_photo_processed(photo_id)
if self.verbose == 0:
print() # New line after dots
print(f"✅ Processed {processed_count} photos")
return processed_count
def _calculate_face_quality_score(self, image: np.ndarray, face_location: tuple) -> float:
"""Calculate face quality score based on multiple factors"""
try:
top, right, bottom, left = face_location
face_height = bottom - top
face_width = right - left
# Basic size check - faces too small get lower scores
min_face_size = 50
size_score = min(1.0, (face_height * face_width) / (min_face_size * min_face_size))
# Extract face region
face_region = image[top:bottom, left:right]
if face_region.size == 0:
return 0.0
# Convert to grayscale for analysis
if len(face_region.shape) == 3:
gray_face = np.mean(face_region, axis=2)
else:
gray_face = face_region
# Calculate sharpness (Laplacian variance)
laplacian_var = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32))
if laplacian_var > 0:
sharpness = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32))
else:
sharpness = 0.0
sharpness_score = min(1.0, sharpness / 1000.0) # Normalize sharpness
# Calculate brightness and contrast
mean_brightness = np.mean(gray_face)
brightness_score = 1.0 - abs(mean_brightness - 128) / 128.0 # Prefer middle brightness
contrast = np.std(gray_face)
contrast_score = min(1.0, contrast / 64.0) # Prefer good contrast
# Calculate aspect ratio (faces should be roughly square)
aspect_ratio = face_width / face_height if face_height > 0 else 1.0
aspect_score = 1.0 - abs(aspect_ratio - 1.0) # Prefer square faces
# Calculate position in image (centered faces are better)
image_height, image_width = image.shape[:2]
center_x = (left + right) / 2
center_y = (top + bottom) / 2
position_x_score = 1.0 - abs(center_x - image_width / 2) / (image_width / 2)
position_y_score = 1.0 - abs(center_y - image_height / 2) / (image_height / 2)
position_score = (position_x_score + position_y_score) / 2.0
# Weighted combination of all factors
quality_score = (
size_score * 0.25 +
sharpness_score * 0.25 +
brightness_score * 0.15 +
contrast_score * 0.15 +
aspect_score * 0.10 +
position_score * 0.10
)
return max(0.0, min(1.0, quality_score))
except Exception as e:
if self.verbose >= 2:
print(f"⚠️ Error calculating face quality: {e}")
return 0.5 # Default medium quality on error
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)"
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
"""Calculate adaptive tolerance based on face quality and match confidence"""
# Start with base tolerance
tolerance = base_tolerance
# Adjust based on face quality (higher quality = stricter tolerance)
# More conservative: range 0.9 to 1.1 instead of 0.8 to 1.2
quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1
tolerance *= quality_factor
# If we have match confidence, adjust further
if match_confidence is not None:
# Higher confidence matches can use stricter tolerance
# More conservative: range 0.95 to 1.05 instead of 0.9 to 1.1
confidence_factor = 0.95 + (match_confidence * 0.1) # Range: 0.95 to 1.05
tolerance *= confidence_factor
# Ensure tolerance stays within reasonable bounds
return max(0.3, min(0.8, tolerance)) # Reduced max from 0.9 to 0.8
def _get_filtered_similar_faces(self, face_id: int, tolerance: float, include_same_photo: bool = False, face_status: dict = None) -> List[Dict]:
"""Get similar faces with consistent filtering and sorting logic used by both auto-match and identify"""
# Find similar faces using the core function
similar_faces_data = self.find_similar_faces(face_id, tolerance=tolerance, include_same_photo=include_same_photo)
# Filter to only show unidentified faces with confidence filtering
filtered_faces = []
for face in similar_faces_data:
# For auto-match: only filter by database state (keep existing behavior)
# For identify: also filter by current session state
is_identified_in_db = face.get('person_id') is not None
is_identified_in_session = face_status and face.get('face_id') in face_status and face_status[face.get('face_id')] == 'identified'
# If face_status is provided (identify mode), use both filters
# If face_status is None (auto-match mode), only use database filter
if face_status is not None:
# Identify mode: filter out both database and session identified faces
if not is_identified_in_db and not is_identified_in_session:
# Calculate confidence percentage
confidence_pct = (1 - face['distance']) * 100
# Only include matches with reasonable confidence (at least 40%)
if confidence_pct >= 40:
filtered_faces.append(face)
else:
# Auto-match mode: only filter by database state (keep existing behavior)
if not is_identified_in_db:
# Calculate confidence percentage
confidence_pct = (1 - face['distance']) * 100
# Only include matches with reasonable confidence (at least 40%)
if confidence_pct >= 40:
filtered_faces.append(face)
# Sort by confidence (distance) - highest confidence first
filtered_faces.sort(key=lambda x: x['distance'])
return filtered_faces
def _filter_unique_faces(self, faces: List[Dict]) -> List[Dict]:
"""Filter faces to show only unique ones, hiding duplicates with high/medium confidence matches"""
if not faces:
return faces
unique_faces = []
seen_face_groups = set() # Track face groups that have been seen
for face in faces:
face_id = face['face_id']
confidence_pct = (1 - face['distance']) * 100
# Only consider high (>=70%) or medium (>=60%) confidence matches for grouping
if confidence_pct >= 60:
# Find all faces that match this one with high/medium confidence
matching_face_ids = set()
for other_face in faces:
other_face_id = other_face['face_id']
other_confidence_pct = (1 - other_face['distance']) * 100
# If this face matches the current face with high/medium confidence
if other_confidence_pct >= 60:
matching_face_ids.add(other_face_id)
# Create a sorted tuple to represent this group of matching faces
face_group = tuple(sorted(matching_face_ids))
# Only show this face if we haven't seen this group before
if face_group not in seen_face_groups:
seen_face_groups.add(face_group)
unique_faces.append(face)
else:
# For low confidence matches, always show them (they're likely different people)
unique_faces.append(face)
return unique_faces
def find_similar_faces(self, face_id: int = None, tolerance: float = DEFAULT_FACE_TOLERANCE, include_same_photo: bool = False) -> List[Dict]:
"""Find similar faces across all photos with improved multi-encoding and quality scoring"""
if face_id:
# Find faces similar to a specific face
target_face = self.db.get_face_encodings(face_id)
if not target_face:
print(f"❌ Face ID {face_id} not found")
return []
target_encoding = self._get_cached_face_encoding(face_id, target_face)
# Get all other faces with quality scores
all_faces = self.db.get_all_face_encodings()
matches = []
# Compare target face with all other faces using adaptive tolerance
for face_data in all_faces:
other_id, other_encoding, other_person_id, other_quality = face_data
if other_id == face_id:
continue
other_enc = self._get_cached_face_encoding(other_id, other_encoding)
# Calculate adaptive tolerance based on both face qualities
target_quality = 0.5 # Default quality for target face
avg_quality = (target_quality + other_quality) / 2
adaptive_tolerance = self._calculate_adaptive_tolerance(tolerance, avg_quality)
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_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
else:
# Find all unidentified faces and try to match them with identified ones
all_faces = self.db.get_all_face_encodings()
matches = []
# Auto-match unidentified faces with identified ones using multi-encoding
identified_faces = [f for f in all_faces if f[2] is not None] # person_id is not None
unidentified_faces = [f for f in all_faces if f[2] is None] # person_id is None
print(f"\n🔍 Auto-matching {len(unidentified_faces)} unidentified faces with {len(identified_faces)} known faces...")
# Group identified faces by person
person_encodings = {}
for id_face in identified_faces:
person_id = id_face[2]
if person_id not in person_encodings:
id_enc = self._get_cached_face_encoding(id_face[0], id_face[1])
person_encodings[person_id] = [(id_enc, id_face[3])]
for unid_face in unidentified_faces:
unid_id, unid_encoding, _, unid_quality = unid_face
unid_enc = self._get_cached_face_encoding(unid_id, unid_encoding)
best_match = None
best_distance = float('inf')
best_person_id = None
# Compare with all person encodings
for person_id, encodings in person_encodings.items():
for person_enc, person_quality in encodings:
# Calculate adaptive tolerance based on both face qualities
avg_quality = (unid_quality + person_quality) / 2
adaptive_tolerance = self._calculate_adaptive_tolerance(tolerance, avg_quality)
distance = face_recognition.face_distance([unid_enc], person_enc)[0]
if distance <= adaptive_tolerance and distance < best_distance:
best_distance = distance
best_person_id = person_id
best_match = {
'unidentified_id': unid_id,
'person_id': person_id,
'distance': distance,
'quality_score': unid_quality,
'adaptive_tolerance': adaptive_tolerance
}
if best_match:
matches.append(best_match)
return matches
def add_person_encoding(self, person_id: int, face_id: int, encoding: np.ndarray, quality_score: float):
"""Add a face encoding to a person's encoding collection"""
self.db.add_person_encoding(person_id, face_id, encoding.tobytes(), quality_score)
def get_person_encodings(self, person_id: int, min_quality: float = MIN_FACE_QUALITY) -> List[Tuple[np.ndarray, float]]:
"""Get all high-quality encodings for a person"""
results = self.db.get_person_encodings(person_id, min_quality)
return [(np.frombuffer(encoding, dtype=np.float64), quality_score) for encoding, quality_score in results]
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)"
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