diff --git a/identify_gui.py b/identify_gui.py index 6679cdf..62149cb 100644 --- a/identify_gui.py +++ b/identify_gui.py @@ -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', '')) diff --git a/search_gui.py b/search_gui.py index e7f6d5b..7962c62 100644 --- a/search_gui.py +++ b/search_gui.py @@ -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() diff --git a/tag_manager_gui.py b/tag_manager_gui.py index cb0c2f1..ccf85a0 100644 --- a/tag_manager_gui.py +++ b/tag_manager_gui.py @@ -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 = []