Refactor IdentifyGUI for improved face identification and management

This commit enhances the IdentifyGUI class by updating the face identification process to handle similar faces more effectively. The logic for updating the current face index has been streamlined, allowing for better flow when identifying faces. Additionally, new methods have been introduced to manage selected similar faces, ensuring that all identified faces are properly marked and displayed. The form clearing functionality has also been updated to include similar face selections, improving user experience during the identification process.
This commit is contained in:
tanyar09 2025-10-08 12:55:56 -04:00
parent 69150b2025
commit 1972a69685
3 changed files with 136 additions and 34 deletions

View File

@ -277,7 +277,8 @@ class IdentifyGUI:
# Main processing loop
while not window_destroyed:
# Check if current face is identified and update index if needed
if not self._update_current_face_index(original_faces, i, face_status):
has_faces, i = self._update_current_face_index(original_faces, i, face_status)
if not has_faces:
# All faces have been identified
print("\n🎉 All faces have been identified!")
break
@ -547,19 +548,17 @@ class IdentifyGUI:
# Clear form after successful identification
self._clear_form(gui_components)
# Update face index to move to next unidentified face
has_faces, i = self._update_current_face_index(original_faces, i, face_status)
if not has_faces:
# All faces have been identified
print("\n🎉 All faces have been identified!")
break
except Exception as e:
print(f"❌ Error: {e}")
messagebox.showerror("Error", f"Error processing identification: {e}")
# Increment index for normal flow (identification or error) - but not if we're at the last item
if i < len(original_faces) - 1:
i += 1
self._update_button_states(gui_components, original_faces, i, face_status)
# Only update similar faces if compare is enabled
if gui_components['compare_var'].get():
self._update_similar_faces(gui_components, face_id, tolerance, face_status,
face_selection_states, identify_data_cache, original_faces, i)
# Clean up current face crop when moving forward after identification
if face_crop_path and os.path.exists(face_crop_path):
try:
@ -1370,7 +1369,7 @@ class IdentifyGUI:
unidentified_faces = [face for face in original_faces if face[0] not in face_status or face_status[face[0]] != 'identified']
if not unidentified_faces:
# All faces identified, we're done
return False
return False, i
# Find the current face in the unidentified list
current_face_id = original_faces[i][0] if i < len(original_faces) else None
@ -1401,7 +1400,7 @@ class IdentifyGUI:
if i < 0:
i = 0
return True
return True, i
def _get_current_face_position(self, original_faces, i, face_status):
"""Get current face position among unidentified faces"""
@ -1933,7 +1932,10 @@ class IdentifyGUI:
identify_data_cache['last_names'].append(last_name)
identify_data_cache['last_names'].sort()
# Assign face to person
# Get selected similar faces
selected_similar_faces = self._get_selected_similar_faces()
# Assign main face to person
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
@ -1941,16 +1943,35 @@ class IdentifyGUI:
(person_id, face_id)
)
# Assign selected similar faces to the same person
similar_faces_identified = 0
if selected_similar_faces:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for similar_face_id in selected_similar_faces:
cursor.execute(
'UPDATE faces SET person_id = ? WHERE id = ?',
(person_id, similar_face_id)
)
similar_faces_identified += 1
# Update person encodings
self.face_processor.update_person_encodings(person_id)
# Mark face as identified
# Mark main face as identified
face_status[face_id] = 'identified'
display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name)
print(f"✅ Identified as: {display_name}")
# Mark selected similar faces as identified
for similar_face_id in selected_similar_faces:
face_status[similar_face_id] = 'identified'
return 1
display_name = f"{last_name}, {first_name}" if last_name and first_name else (last_name or first_name)
if similar_faces_identified > 0:
print(f"✅ Identified as: {display_name} (main face + {similar_faces_identified} similar faces)")
else:
print(f"✅ Identified as: {display_name}")
return 1 + similar_faces_identified
except Exception as e:
print(f"❌ Error processing identification: {e}")
@ -2026,12 +2047,17 @@ class IdentifyGUI:
return 'continue' # No changes, safe to continue
def _clear_form(self, gui_components):
"""Clear all form fields"""
"""Clear all form fields and similar face selections"""
gui_components['first_name_var'].set("")
gui_components['last_name_var'].set("")
gui_components['middle_name_var'].set("")
gui_components['maiden_name_var'].set("")
gui_components['date_of_birth_var'].set("")
# Clear selected similar faces
if hasattr(self, '_similar_face_vars'):
for face_id, var in self._similar_face_vars:
var.set(False)
def _get_form_data(self, gui_components):
"""Get current form data as a dictionary"""
@ -2043,6 +2069,15 @@ class IdentifyGUI:
'date_of_birth': gui_components['date_of_birth_var'].get().strip()
}
def _get_selected_similar_faces(self):
"""Get list of selected similar face IDs"""
selected_faces = []
if hasattr(self, '_similar_face_vars'):
for face_id, var in self._similar_face_vars:
if var.get(): # If checkbox is checked
selected_faces.append(face_id)
return selected_faces
def _set_form_data(self, gui_components, form_data):
"""Set form data from a dictionary"""
gui_components['first_name_var'].set(form_data.get('first_name', ''))

View File

@ -441,6 +441,22 @@ class SearchGUI:
combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E))
combo.focus_set()
def get_saved_tag_types_for_photo(photo_id: int):
"""Get saved linkage types for a photo {tag_id: type_int}"""
types = {}
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
for row in cursor.fetchall():
try:
types[row[0]] = int(row[1]) if row[1] is not None else 0
except Exception:
types[row[0]] = 0
except Exception:
pass
return types
def add_selected_tag():
tag_name = tag_var.get().strip()
if not tag_name:
@ -469,7 +485,7 @@ class SearchGUI:
messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup)
return
# Add tag to all selected photos
# Add tag to all selected photos with single linkage type (0)
affected = 0
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
@ -507,28 +523,76 @@ class SearchGUI:
widget.destroy()
selected_tag_vars.clear()
# Get all unique tags from selected photos
all_tag_ids = set()
# Get tags that exist in ALL selected photos
# First, get all tags for each photo
photo_tags = {} # photo_id -> set of tag_ids
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for photo_id in photo_ids:
cursor.execute('SELECT tag_id FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
photo_tags[photo_id] = set()
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
for row in cursor.fetchall():
all_tag_ids.add(row[0])
photo_tags[photo_id].add(row[0])
if not all_tag_ids:
# Find intersection - tags that exist in ALL selected photos
if not photo_tags:
ttk.Label(scrollable_frame, text="No tags linked to selected photos", foreground="gray").pack(anchor=tk.W, pady=5)
return
for tag_id in sorted(all_tag_ids):
# Start with tags from first photo, then intersect with others
common_tag_ids = set(photo_tags[photo_ids[0]])
for photo_id in photo_ids[1:]:
common_tag_ids = common_tag_ids.intersection(photo_tags[photo_id])
if not common_tag_ids:
ttk.Label(scrollable_frame, text="No common tags found across all selected photos", foreground="gray").pack(anchor=tk.W, pady=5)
return
# Get linkage type information for common tags
# For tags that exist in all photos, we need to determine the linkage type
# If a tag has different linkage types across photos, we'll show the most restrictive
common_tag_data = {} # tag_id -> {linkage_type, photo_count}
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for photo_id in photo_ids:
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id IN ({})'.format(','.join('?' * len(common_tag_ids))), [photo_id] + list(common_tag_ids))
for row in cursor.fetchall():
tag_id = row[0]
linkage_type = int(row[1]) if row[1] is not None else 0
if tag_id not in common_tag_data:
common_tag_data[tag_id] = {'linkage_type': linkage_type, 'photo_count': 0}
common_tag_data[tag_id]['photo_count'] += 1
# If we find a bulk linkage type (1), use that as it's more restrictive
if linkage_type == 1:
common_tag_data[tag_id]['linkage_type'] = 1
# Sort tags by name for consistent display
for tag_id in sorted(common_tag_data.keys()):
tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}")
var = tk.BooleanVar()
selected_tag_vars[tag_name] = var
frame = ttk.Frame(scrollable_frame)
frame.pack(fill=tk.X, pady=1)
# Determine if this tag can be selected for deletion
# In single linkage dialog, only allow deleting single linkage type (0) tags
linkage_type = common_tag_data[tag_id]['linkage_type']
can_select = (linkage_type == 0) # Only single linkage type can be deleted
cb = ttk.Checkbutton(frame, variable=var)
if not can_select:
try:
cb.state(["disabled"]) # disable selection for bulk tags
except Exception:
pass
cb.pack(side=tk.LEFT, padx=(0, 5))
ttk.Label(frame, text=tag_name).pack(side=tk.LEFT)
# Display tag name with status information
type_label = 'single' if linkage_type == 0 else 'bulk'
photo_count = common_tag_data[tag_id]['photo_count']
status_text = f" (saved {type_label}, on {photo_count}/{len(photo_ids)} photos)"
status_color = "black" if can_select else "gray"
ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT)
def remove_selected_tags():
tag_ids_to_remove = []
@ -539,12 +603,16 @@ class SearchGUI:
if not tag_ids_to_remove:
return
# Remove tags from all selected photos
# Only remove single linkage type tags (bulk tags should be disabled anyway)
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for photo_id in photo_ids:
for tag_id in tag_ids_to_remove:
cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
# Double-check that this is a single linkage type before deleting
cursor.execute('SELECT linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
result = cursor.fetchone()
if result and int(result[0]) == 0: # Only delete single linkage type
cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
refresh_tag_list()

View File

@ -556,13 +556,12 @@ class TagManagerGUI:
cursor = conn.cursor()
cursor.execute('''
SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added,
COUNT(f.id) as face_count,
GROUP_CONCAT(DISTINCT t.tag_name) as tags
(SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count,
(SELECT GROUP_CONCAT(DISTINCT t.tag_name)
FROM phototaglinkage ptl
JOIN tags t ON t.id = ptl.tag_id
WHERE ptl.photo_id = p.id) as tags
FROM photos p
LEFT JOIN faces f ON f.photo_id = p.id
LEFT JOIN phototaglinkage ptl ON ptl.photo_id = p.id
LEFT JOIN tags t ON t.id = ptl.tag_id
GROUP BY p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added
ORDER BY p.date_taken DESC, p.filename
''')
photos_data = []