From 4c0a1a3b380f113a9d720b85fb8fade446608ca9 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 29 Sep 2025 16:01:06 -0400 Subject: [PATCH] =?UTF-8?q?Add=20unique=20faces=20only=20filter=20to=20Pho?= =?UTF-8?q?toTagger.=20Introduce=20a=20checkbox=20in=20the=20Date=20Filter?= =?UTF-8?q?s=20section=20to=20hide=20duplicates=20with=20high/medium=20con?= =?UTF-8?q?fidence=20matches,=20enhancing=20face=20identification=20accura?= =?UTF-8?q?cy.=20Implement=20filtering=20logic=20that=20groups=20faces=20w?= =?UTF-8?q?ith=20=E2=89=A560%=20confidence,=20ensuring=20only=20one=20repr?= =?UTF-8?q?esentative=20is=20displayed=20in=20the=20main=20list=20while=20?= =?UTF-8?q?keeping=20the=20Similar=20Faces=20panel=20unfiltered.=20Update?= =?UTF-8?q?=20README=20to=20document=20this=20new=20feature=20and=20its=20?= =?UTF-8?q?behavior.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++ photo_tagger.py | 192 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 460e8c6..6fb7063 100644 --- a/README.md +++ b/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: diff --git a/photo_tagger.py b/photo_tagger.py index 45a5e9c..aff1d6f 100644 --- a/photo_tagger.py +++ b/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: