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:
parent
e1bed343b6
commit
4c0a1a3b38
17
README.md
17
README.md
@ -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:
|
||||
|
||||
|
||||
192
photo_tagger.py
192
photo_tagger.py
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user