Add unique faces only filter to PhotoTagger. Introduce a checkbox in the Date Filters section to hide duplicates with high/medium confidence matches, enhancing face identification accuracy. Implement filtering logic that groups faces with ≥60% confidence, ensuring only one representative is displayed in the main list while keeping the Similar Faces panel unfiltered. Update README to document this new feature and its behavior.

This commit is contained in:
tanyar09 2025-09-29 16:01:06 -04:00
parent e1bed343b6
commit 4c0a1a3b38
2 changed files with 208 additions and 1 deletions

View File

@ -143,6 +143,12 @@ python3 photo_tagger.py auto-match --auto --show-faces
- ⚡ **Performance Optimized** - Pre-fetched data for faster similar faces display
- 🎯 **Clean Database Storage** - Names are stored as separate first_name and last_name fields without commas
- 🔧 **Improved Data Handling** - Fixed field restoration and quit confirmation logic for better reliability
- 🧩 **Unique Faces Only Filter (NEW)**
- Checkbox in the Date Filters section: "Unique faces only (hide duplicates with high/medium confidence)"
- Applies only to the main face list (left/navigation); the Similar Faces panel (right) remains unfiltered
- Groups faces with ≥60% confidence matches (Medium/High/Very High) and shows only one representative
- Takes effect immediately when toggled (no need to click Apply Filter); Apply Filter is only for date filters
- Uses existing database encodings for fast, non-blocking filtering
**🎯 New Auto-Match GUI Features:**
- 📊 **Person-Centric View** - Shows matched person on left, all their unidentified faces on right
@ -498,6 +504,17 @@ The Compare feature in the Identify GUI works seamlessly with the main identific
- **Smart Warnings**: Prevents accidental loss of work
- **Performance Optimized**: Instant loading of similar faces
### Unique Faces Only Filter
- Location: in the "Date Filters" bar at the top of the Identify GUI.
- Behavior:
- Filters the main navigation list on the left to avoid showing near-duplicate faces of the same person.
- The Similar Faces panel on the right is NOT filtered and continues to show all similar faces for comparison.
- Confidence rule: faces that match at ≥60% (Medium/High/Very High) are grouped; only one shows in the main list.
- Interaction:
- Takes effect immediately when toggled. You do NOT need to press Apply Filter.
- Apply Filter continues to control the date filters only (Taken/Processed ranges).
- Filtering uses precomputed encodings from the database, so it is fast and non-blocking.
### Auto-Match Workflow
The auto-match feature now works in a **person-centric** way:

View File

@ -1282,6 +1282,9 @@ class PhotoTagger:
# Initialize calendar
update_calendar()
# Unique faces only checkbox variable (defined before update_similar_faces function)
unique_faces_var = tk.BooleanVar()
# Define update_similar_faces function first - reusing auto-match display logic
def update_similar_faces():
"""Update the similar faces panel when compare is enabled - reuses auto-match display logic"""
@ -1305,7 +1308,7 @@ class PhotoTagger:
similar_face_crops.clear()
if compare_var.get():
# Use the same filtering and sorting logic as auto-match
# Use the same filtering and sorting logic as auto-match (no unique faces filter for similar faces)
unidentified_similar_faces = self._get_filtered_similar_faces(face_id, tolerance, include_same_photo=False, face_status=face_status)
if unidentified_similar_faces:
@ -1344,6 +1347,72 @@ class PhotoTagger:
command=on_compare_change)
compare_checkbox.grid(row=3, column=0, columnspan=4, sticky=tk.W, pady=(5, 0))
# Unique faces only checkbox widget
def on_unique_faces_change():
"""Handle unique faces checkbox change"""
nonlocal original_faces, i
if unique_faces_var.get():
# Show progress message
print("🔄 Applying unique faces filter...")
root.update() # Update UI to show the message
# Apply unique faces filtering to the main face list
try:
original_faces = self._filter_unique_faces_from_list(original_faces)
print(f"✅ Filter applied: {len(original_faces)} unique faces remaining")
except Exception as e:
print(f"⚠️ Error applying filter: {e}")
# Revert checkbox state
unique_faces_var.set(False)
return
else:
# Reload the original unfiltered face list
print("🔄 Reloading all faces...")
root.update() # Update UI to show the message
with self.get_db_connection() as conn:
cursor = conn.cursor()
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 += ' ORDER BY f.id'
cursor.execute(query, params)
original_faces = list(cursor.fetchall())
print(f"✅ Reloaded: {len(original_faces)} faces")
# Reset to first face and update display
i = 0
update_similar_faces()
unique_faces_checkbox = ttk.Checkbutton(date_filter_frame, text="Unique faces only (hide duplicates with high/medium confidence)",
variable=unique_faces_var, command=on_unique_faces_change)
unique_faces_checkbox.grid(row=2, column=0, columnspan=6, sticky=tk.W, pady=(10, 0))
# Add callback to save person name when it changes
def on_name_change(*args):
if i < len(original_faces):
@ -2617,6 +2686,127 @@ class PhotoTagger:
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 _filter_unique_faces_from_list(self, faces_list: List[tuple]) -> List[tuple]:
"""Filter face list to show only unique ones, hiding duplicates with high/medium confidence matches"""
if not faces_list:
return faces_list
# Extract face IDs from the list
face_ids = [face_tuple[0] for face_tuple in faces_list]
# Get face encodings from database for all faces
face_encodings = {}
with self.get_db_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join('?' * len(face_ids))
cursor.execute(f'''
SELECT id, encoding
FROM faces
WHERE id IN ({placeholders}) AND encoding IS NOT NULL
''', face_ids)
for face_id, encoding_blob in cursor.fetchall():
try:
import numpy as np
# Load encoding as numpy array (not pickle)
encoding = np.frombuffer(encoding_blob, dtype=np.float64)
face_encodings[face_id] = encoding
except Exception:
continue
# If we don't have enough encodings, return original list
if len(face_encodings) < 2:
return faces_list
# Calculate distances between all faces using existing encodings
face_distances = {}
face_id_list = list(face_encodings.keys())
for i, face_id1 in enumerate(face_id_list):
for j, face_id2 in enumerate(face_id_list):
if i != j:
try:
import face_recognition
encoding1 = face_encodings[face_id1]
encoding2 = face_encodings[face_id2]
# Calculate distance
distance = face_recognition.face_distance([encoding1], encoding2)[0]
face_distances[(face_id1, face_id2)] = distance
except Exception:
# If calculation fails, assume no match
face_distances[(face_id1, face_id2)] = 1.0
# Apply unique faces filtering
unique_faces = []
seen_face_groups = set()
for face_tuple in faces_list:
face_id = face_tuple[0]
# Skip if we don't have encoding for this face
if face_id not in face_encodings:
unique_faces.append(face_tuple)
continue
# Find all faces that match this one with high/medium confidence
matching_face_ids = set([face_id]) # Include self
for other_face_id in face_encodings.keys():
if other_face_id != face_id:
distance = face_distances.get((face_id, other_face_id), 1.0)
confidence_pct = (1 - distance) * 100
# If this face matches with high/medium confidence
if 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_tuple)
return unique_faces
def _show_people_list(self, cursor=None):
"""Show list of known people"""
if cursor is None: