diff --git a/photo_tagger.py b/photo_tagger.py index 0a52fb6..45a5e9c 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -10,6 +10,7 @@ import argparse import face_recognition from pathlib import Path from PIL import Image, ImageDraw, ImageFont +from PIL.ExifTags import TAGS import pickle import numpy as np from typing import List, Dict, Tuple, Optional @@ -18,6 +19,7 @@ import tempfile import subprocess import threading import time +from datetime import datetime from functools import lru_cache from contextlib import contextmanager @@ -153,6 +155,7 @@ class PhotoTagger: path TEXT UNIQUE NOT NULL, filename TEXT NOT NULL, date_added DATETIME DEFAULT CURRENT_TIMESTAMP, + date_taken DATE, processed BOOLEAN DEFAULT 0 ) ''') @@ -219,11 +222,66 @@ class PhotoTagger: cursor.execute('CREATE INDEX IF NOT EXISTS idx_faces_quality ON faces(quality_score)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_person_id ON person_encodings(person_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_quality ON person_encodings(quality_score)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_photos_date_added ON photos(date_added)') + + # Migration: Add date_taken column to existing photos table if it doesn't exist + try: + cursor.execute('ALTER TABLE photos ADD COLUMN date_taken DATE') + if self.verbose >= 1: + print("✅ Added date_taken column to photos table") + except Exception: + # Column already exists, ignore + pass + + # Migration: Add date_added column to existing photos table if it doesn't exist + try: + cursor.execute('ALTER TABLE photos ADD COLUMN date_added DATETIME DEFAULT CURRENT_TIMESTAMP') + if self.verbose >= 1: + print("✅ Added date_added column to photos table") + except Exception: + # Column already exists, ignore + pass if self.verbose >= 1: print(f"✅ Database initialized: {self.db_path}") + def _extract_photo_date(self, photo_path: str) -> Optional[str]: + """Extract date taken from photo EXIF data""" + try: + with Image.open(photo_path) as image: + exifdata = image.getexif() + + # Look for date taken in EXIF tags + date_tags = [ + 306, # DateTime + 36867, # DateTimeOriginal + 36868, # DateTimeDigitized + ] + + for tag_id in date_tags: + if tag_id in exifdata: + date_str = exifdata[tag_id] + if date_str: + # Parse EXIF date format (YYYY:MM:DD HH:MM:SS) + try: + date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S') + return date_obj.strftime('%Y-%m-%d') + except ValueError: + # Try alternative format + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') + return date_obj.strftime('%Y-%m-%d') + except ValueError: + continue + + return None + except Exception as e: + if self.verbose >= 2: + print(f" ⚠️ Could not extract date from {os.path.basename(photo_path)}: {e}") + return None + def scan_folder(self, folder_path: str, recursive: bool = True) -> int: """Scan folder for photos and add to database""" # BREAKPOINT: Set breakpoint here for debugging @@ -266,14 +324,18 @@ class PhotoTagger: for photo_path, filename in found_photos: try: + # Extract date taken from EXIF data + date_taken = self._extract_photo_date(photo_path) + cursor.execute( - 'INSERT OR IGNORE INTO photos (path, filename) VALUES (?, ?)', - (photo_path, filename) + 'INSERT OR IGNORE INTO photos (path, filename, date_taken) VALUES (?, ?, ?)', + (photo_path, filename, date_taken) ) if cursor.rowcount > 0: added_count += 1 if self.verbose >= 2: - print(f" 📸 Added: {filename}") + date_info = f" (taken: {date_taken})" if date_taken else " (no date)" + print(f" 📸 Added: {filename}{date_info}") elif self.verbose >= 3: print(f" 📸 Already exists: {filename}") except Exception as e: @@ -283,6 +345,7 @@ class PhotoTagger: print(f"📁 Found {len(found_photos)} photos, added {added_count} new photos") return added_count + def process_faces(self, limit: int = 50, model: str = "hog") -> int: """Process unprocessed photos for faces""" with self.get_db_connection() as conn: @@ -355,18 +418,43 @@ class PhotoTagger: print(f"✅ Processed {processed_count} photos") return processed_count - def identify_faces(self, batch_size: int = 20, show_faces: bool = False, tolerance: float = 0.6) -> int: + def identify_faces(self, batch_size: int = 20, show_faces: bool = False, tolerance: float = 0.6, + date_from: str = None, date_to: str = None, date_processed_from: str = None, date_processed_to: str = None) -> int: """Interactive face identification with optimized performance""" with self.get_db_connection() as conn: cursor = conn.cursor() - cursor.execute(''' + # Build the SQL query with optional date filtering + query = ''' SELECT f.id, f.photo_id, p.path, p.filename, f.location FROM faces f JOIN photos p ON f.photo_id = p.id WHERE f.person_id IS NULL - LIMIT ? - ''', (batch_size,)) + ''' + params = [] + + # Add date taken filtering if specified + if date_from: + query += ' AND p.date_taken >= ?' + params.append(date_from) + + if date_to: + query += ' AND p.date_taken <= ?' + params.append(date_to) + + # Add date processed filtering if specified + if date_processed_from: + query += ' AND DATE(p.date_added) >= ?' + params.append(date_processed_from) + + if date_processed_to: + query += ' AND DATE(p.date_added) <= ?' + params.append(date_processed_to) + + query += ' LIMIT ?' + params.append(batch_size) + + cursor.execute(query, params) unidentified = cursor.fetchall() @@ -554,20 +642,354 @@ class PhotoTagger: root.rowconfigure(0, weight=1) main_frame.columnconfigure(0, weight=1) # Left panel main_frame.columnconfigure(1, weight=1) # Right panel for similar faces - main_frame.rowconfigure(1, weight=1) # Main content row + main_frame.rowconfigure(2, weight=1) # Main content row # Photo info info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold")) info_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) + # Calendar dialog function for date filter + def open_date_calendar(date_var, title): + """Open a visual calendar dialog to select date""" + from datetime import datetime, date, timedelta + import calendar + + # Create calendar window + calendar_window = tk.Toplevel(root) + calendar_window.title(title) + calendar_window.resizable(False, False) + calendar_window.transient(root) + calendar_window.grab_set() + + # Calculate center position before showing the window + 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) + + # Set geometry with center position before showing + calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") + + # Calendar variables + current_date = datetime.now() + + # Check if there's already a date selected + existing_date_str = date_var.get().strip() + if existing_date_str: + try: + existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() + display_year = existing_date.year + display_month = existing_date.month + selected_date = existing_date + except ValueError: + # If existing date is invalid, use current date + display_year = current_date.year + display_month = current_date.month + selected_date = None + else: + # Default to current date + display_year = current_date.year + display_month = current_date.month + selected_date = None + + # Month names + month_names = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + # Create custom style for calendar buttons + style = ttk.Style() + style.configure("Calendar.TButton", padding=(2, 2)) + style.map("Calendar.TButton", + background=[("active", "#e1e1e1")], + relief=[("pressed", "sunken")]) + + # 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 (created once, updated later) + 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() + + # Button text and style + button_text = str(day) + if is_today: + button_text = f"•{day}•" # Mark today + + day_btn = ttk.Button(calendar_frame, text=button_text, + style="Calendar.TButton" if not is_selected else "Calendar.TButton", + command=lambda d=day_date: select_date(d)) + day_btn.grid(row=week_num+1, column=day_num, padx=2, pady=2, sticky="nsew") + + # Highlight selected date + if is_selected: + day_btn.configure(style="Calendar.TButton") + # Add visual indication of selection + day_btn.configure(text=f"[{day}]") + + def select_date(selected_day): + """Select a date and close calendar""" + nonlocal selected_date + selected_date = selected_day + date_var.set(selected_day.strftime('%Y-%m-%d')) + calendar_window.destroy() + + 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 + update_calendar() + + def prev_year(): + nonlocal display_year + display_year -= 1 + update_calendar() + + def next_year(): + nonlocal display_year + display_year += 1 + 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 clear_date(): + """Clear the selected date""" + date_var.set("") + calendar_window.destroy() + + # Clear button + clear_btn = ttk.Button(buttons_frame, text="Clear", command=clear_date) + clear_btn.pack(side=tk.LEFT) + + # Cancel button + cancel_btn = ttk.Button(buttons_frame, text="Cancel", command=calendar_window.destroy) + cancel_btn.pack(side=tk.RIGHT) + + # Initial calendar display + update_calendar() + + # Date filter controls + date_filter_frame = ttk.LabelFrame(main_frame, text="Date Filters", padding="5") + date_filter_frame.grid(row=1, column=0, columnspan=2, pady=(0, 10), sticky=(tk.W, tk.E)) + date_filter_frame.columnconfigure(1, weight=1) + date_filter_frame.columnconfigure(4, weight=1) + date_filter_frame.columnconfigure(7, weight=1) + date_filter_frame.columnconfigure(10, weight=1) + + # Date from + ttk.Label(date_filter_frame, text="From:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) + date_from_var = tk.StringVar(value=date_from or "") + date_from_entry = ttk.Entry(date_filter_frame, textvariable=date_from_var, width=12, state='readonly') + date_from_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Calendar button for date from + def open_calendar_from(): + open_date_calendar(date_from_var, "Select Start Date") + + calendar_from_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_from) + calendar_from_btn.grid(row=0, column=2, padx=(0, 10)) + + # Date to + ttk.Label(date_filter_frame, text="To:").grid(row=0, column=3, sticky=tk.W, padx=(0, 5)) + date_to_var = tk.StringVar(value=date_to or "") + date_to_entry = ttk.Entry(date_filter_frame, textvariable=date_to_var, width=12, state='readonly') + date_to_entry.grid(row=0, column=4, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Calendar button for date to + def open_calendar_to(): + open_date_calendar(date_to_var, "Select End Date") + + calendar_to_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_to) + calendar_to_btn.grid(row=0, column=5, padx=(0, 10)) + + # Apply filter button + def apply_date_filter(): + nonlocal date_from, date_to + date_from = date_from_var.get().strip() or None + date_to = date_to_var.get().strip() or None + date_processed_from = date_processed_from_var.get().strip() or None + date_processed_to = date_processed_to_var.get().strip() or None + + # Reload faces with new date filter + with self.get_db_connection() as conn: + cursor = conn.cursor() + + # Build the SQL query with optional date filtering + query = ''' + SELECT f.id, f.photo_id, p.path, p.filename, f.location + FROM faces f + JOIN photos p ON f.photo_id = p.id + WHERE f.person_id IS NULL + ''' + params = [] + + # Add date taken filtering if specified + if date_from: + query += ' AND p.date_taken >= ?' + params.append(date_from) + + if date_to: + query += ' AND p.date_taken <= ?' + params.append(date_to) + + # Add date processed filtering if specified + if date_processed_from: + query += ' AND DATE(p.date_added) >= ?' + params.append(date_processed_from) + + if date_processed_to: + query += ' AND DATE(p.date_added) <= ?' + params.append(date_processed_to) + + query += ' LIMIT ?' + params.append(batch_size) + + cursor.execute(query, params) + unidentified = cursor.fetchall() + + if not unidentified: + messagebox.showinfo("No Faces Found", "No unidentified faces found with the current date filters.") + return + + # Update the global unidentified list and reset position + nonlocal current_pos, total_unidentified + current_pos = 0 + total_unidentified = len(unidentified) + + # Reset to first face - display will update when user navigates + if len(unidentified) > 0: + # Reset to first face + current_pos = 0 + # The display will be updated when the user navigates or when the window is shown + + # Build filter description + filters_applied = [] + if date_from or date_to: + taken_filter = f"taken: {date_from or 'any'} to {date_to or 'any'}" + filters_applied.append(taken_filter) + if date_processed_from or date_processed_to: + processed_filter = f"processed: {date_processed_from or 'any'} to {date_processed_to or 'any'}" + filters_applied.append(processed_filter) + + filter_desc = " | ".join(filters_applied) if filters_applied else "no filters" + + print(f"📅 Applied filters: {filter_desc}") + print(f"👤 Found {len(unidentified)} unidentified faces with date filters") + print("💡 Navigate to refresh the display with filtered faces") + + apply_filter_btn = ttk.Button(date_filter_frame, text="Apply Filter", command=apply_date_filter) + apply_filter_btn.grid(row=0, column=6, padx=(10, 0)) + + # Date processed filter (second row) + ttk.Label(date_filter_frame, text="Processed From:").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(10, 0)) + date_processed_from_var = tk.StringVar() + date_processed_from_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_from_var, width=12, state='readonly') + date_processed_from_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 5), pady=(10, 0)) + + # Calendar button for date processed from + def open_calendar_processed_from(): + open_date_calendar(date_processed_from_var, "Select Processing Start Date") + + calendar_processed_from_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_from) + calendar_processed_from_btn.grid(row=1, column=2, padx=(0, 10), pady=(10, 0)) + + # Date processed to + ttk.Label(date_filter_frame, text="Processed To:").grid(row=1, column=3, sticky=tk.W, padx=(0, 5), pady=(10, 0)) + date_processed_to_var = tk.StringVar() + date_processed_to_entry = ttk.Entry(date_filter_frame, textvariable=date_processed_to_var, width=12, state='readonly') + date_processed_to_entry.grid(row=1, column=4, sticky=(tk.W, tk.E), padx=(0, 5), pady=(10, 0)) + + # Calendar button for date processed to + def open_calendar_processed_to(): + open_date_calendar(date_processed_to_var, "Select Processing End Date") + + calendar_processed_to_btn = ttk.Button(date_filter_frame, text="📅", width=3, command=open_calendar_processed_to) + calendar_processed_to_btn.grid(row=1, column=5, padx=(0, 10), pady=(10, 0)) + # Left panel for main face left_panel = ttk.Frame(main_frame) - left_panel.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) + left_panel.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5)) left_panel.columnconfigure(0, weight=1) # Right panel for similar faces right_panel = ttk.LabelFrame(main_frame, text="Similar Faces", padding="5") - right_panel.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) + right_panel.grid(row=2, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0)) right_panel.columnconfigure(0, weight=1) right_panel.rowconfigure(0, weight=1) # Make right panel expandable vertically @@ -1268,7 +1690,7 @@ class PhotoTagger: # Bottom control panel control_frame = ttk.Frame(main_frame) - control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) + control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) # Create button references for state management back_btn = ttk.Button(control_frame, text="⬅️ Back", command=on_back) @@ -4279,7 +4701,7 @@ class PhotoTagger: # Control buttons control_frame = ttk.Frame(main_frame) - control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) + control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) def on_quit(): nonlocal window_destroyed