diff --git a/bagheeraview.py b/bagheeraview.py index 3c59efa..2c0a013 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -579,8 +579,6 @@ class ThumbnailDelegate(QStyledItemDelegate): thumb_size = self.main_win.current_thumb_size path = index.data(PATH_ROLE) mtime = index.data(MTIME_ROLE) - inode = index.data(INODE_ROLE) - device_id = index.data(DEVICE_ROLE) # Optimization: Use QPixmapCache to avoid expensive QImage->QPixmap # conversion on every paint event. @@ -589,6 +587,8 @@ class ThumbnailDelegate(QStyledItemDelegate): if not source_pixmap or source_pixmap.isNull(): # Not in UI cache, try to get from main thumbnail cache (Memory/LMDB) + inode = index.data(INODE_ROLE) + device_id = index.data(DEVICE_ROLE) img, _ = self.main_win.cache.get_thumbnail( path, requested_size=thumb_size, curr_mtime=mtime, inode=inode, device_id=device_id, async_load=True) @@ -863,20 +863,34 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel): def lessThan(self, left, right): """Custom sorting logic for name and date.""" sort_role = self.sortRole() - left_data = self.sourceModel().data(left, sort_role) - right_data = self.sourceModel().data(right, sort_role) if sort_role == MTIME_ROLE: - left = left_data if left_data is not None else 0 - right = right_data if right_data is not None else 0 - return left < right + left_data = self.sourceModel().data(left, sort_role) + right_data = self.sourceModel().data(right, sort_role) + # Treat None as 0 for safe comparison + left_val = left_data if left_data is not None else 0 + right_val = right_data if right_data is not None else 0 + return left_val < right_val - # Default (DisplayRole) is case-insensitive name sorting - # Handle None values safely - l_str = str(left_data) if left_data is not None else "" - r_str = str(right_data) if right_data is not None else "" + # Default (DisplayRole) is name sorting. + # Optimization: Use the pre-calculated lowercase name from the cache + # to avoid repeated string operations during sorting. + left_path = self.sourceModel().data(left, PATH_ROLE) + right_path = self.sourceModel().data(right, PATH_ROLE) - return l_str.lower() < r_str.lower() + # Fallback for non-thumbnail items (like headers) or if cache is missing + if not left_path or not right_path or not self._data_cache: + l_str = str(self.sourceModel().data(left, Qt.DisplayRole) or "") + r_str = str(self.sourceModel().data(right, Qt.DisplayRole) or "") + return l_str.lower() < r_str.lower() + + # Get from cache, with a fallback just in case + _, left_name_lower = self._data_cache.get( + left_path, (None, os.path.basename(left_path).lower())) + _, right_name_lower = self._data_cache.get( + right_path, (None, os.path.basename(right_path).lower())) + + return left_name_lower < right_name_lower class MainWindow(QMainWindow): @@ -908,6 +922,7 @@ class MainWindow(QMainWindow): self.current_thumb_size = THUMBNAILS_DEFAULT_SIZE self.face_names_history = [] self.pet_names_history = [] + self.body_names_history = [] self.object_names_history = [] self.landmark_names_history = [] self.mru_tags = deque(maxlen=APP_CONFIG.get( @@ -1466,6 +1481,10 @@ class MainWindow(QMainWindow): if "geometry" in mw_data: g = mw_data["geometry"] self.setGeometry(g["x"], g["y"], g["w"], g["h"]) + + selected_path = mw_data.get("selected_path") + select_paths = [selected_path] if selected_path else None + if "window_state" in mw_data: self.restoreState( QByteArray.fromBase64(mw_data["window_state"].encode())) @@ -1521,7 +1540,7 @@ class MainWindow(QMainWindow): paths.append(d) self.start_scan([p.strip() for p in paths if p.strip() and os.path.exists(os.path.expanduser(p.strip()))], - select_path=mw_data.get("selected_path")) + select_paths=select_paths) if search_text: self.search_input.setEditText(search_text) @@ -1643,6 +1662,11 @@ class MainWindow(QMainWindow): if len(self.face_names_history) > new_max_faces: self.face_names_history = self.face_names_history[:new_max_faces] + new_max_bodies = APP_CONFIG.get("body_menu_max_items", + constants.FACES_MENU_MAX_ITEMS_DEFAULT) + if len(self.body_names_history) > new_max_bodies: + self.body_names_history = self.body_names_history[:new_max_bodies] + new_bg_color = APP_CONFIG.get("thumbnails_bg_color", constants.THUMBNAILS_BG_COLOR_DEFAULT) self.thumbnail_view.setStyleSheet(f"background-color: {new_bg_color};") @@ -1974,6 +1998,44 @@ class MainWindow(QMainWindow): return False + def get_selected_paths(self): + """Returns a list of all selected file paths.""" + paths = [] + seen = set() + for idx in self.thumbnail_view.selectedIndexes(): + path = self.proxy_model.data(idx, PATH_ROLE) + if path and path not in seen: + paths.append(path) + seen.add(path) + return paths + + def restore_selection(self, paths): + """Restores selection for a list of paths.""" + if not paths: + return + + selection_model = self.thumbnail_view.selectionModel() + selection = QItemSelection() + first_valid_index = QModelIndex() + + for path in paths: + if path in self._path_to_model_index: + persistent_index = self._path_to_model_index[path] + if persistent_index.isValid(): + source_index = QModelIndex(persistent_index) + proxy_index = self.proxy_model.mapFromSource(source_index) + if proxy_index.isValid(): + selection.select(proxy_index, proxy_index) + if not first_valid_index.isValid(): + first_valid_index = proxy_index + + if not selection.isEmpty(): + selection_model.select(selection, QItemSelectionModel.ClearAndSelect) + if first_valid_index.isValid(): + self.thumbnail_view.setCurrentIndex(first_valid_index) + self.thumbnail_view.scrollTo( + first_valid_index, QAbstractItemView.EnsureVisible) + def toggle_visibility(self): """Toggles the visibility of the main window, opening a viewer if needed.""" if self.isVisible(): @@ -2247,7 +2309,7 @@ class MainWindow(QMainWindow): w.load_and_fit_image() def start_scan(self, paths, sync_viewer=False, active_viewer=None, - select_path=None): + select_paths=None): """ Starts a new background scan for images. @@ -2255,7 +2317,7 @@ class MainWindow(QMainWindow): paths (list): A list of file paths or directories to scan. sync_viewer (bool): If True, avoids clearing the grid. active_viewer (ImageViewer): A viewer to sync with the scan results. - select_path (str): A path to select automatically after the scan finishes. + select_paths (list): A list of paths to select automatically. """ self.is_cleaning = True self._suppress_updates = True @@ -2299,11 +2361,11 @@ class MainWindow(QMainWindow): self.scanner.progress_msg.connect(self.status_lbl.setText) self.scanner.more_files_available.connect(self.more_files_available) self.scanner.finished_scan.connect( - lambda n: self._on_scan_finished(n, select_path)) + lambda n: self._on_scan_finished(n, select_paths)) self.scanner.start() self._scan_all = False - def _on_scan_finished(self, n, select_path=None): + def _on_scan_finished(self, n, select_paths=None): """Slot for when the image scanner has finished.""" self._suppress_updates = False self._scanner_last_index = self._scanner_total_files @@ -2331,8 +2393,8 @@ class MainWindow(QMainWindow): self.update_tag_edit_widget() # Select a specific path if requested (e.g., after layout restore) - if select_path: - self.find_and_select_path(select_path) + if select_paths: + self.restore_selection(select_paths) # Final rebuild to ensure all items are correctly placed if self.rebuild_timer.isActive(): @@ -2573,7 +2635,7 @@ class MainWindow(QMainWindow): self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) # Preserve selection - selected_path = self.get_current_selected_path() + selected_paths = self.get_selected_paths() mode = self.sort_combo.currentText() rev = "↓" in mode @@ -2628,7 +2690,7 @@ class MainWindow(QMainWindow): self._suppress_updates = False self.apply_filters() self.thumbnail_view.setUpdatesEnabled(True) - self.find_and_select_path(selected_path) + self.restore_selection(selected_paths) if self.main_dock.isVisible() and \ self.tags_tabs.currentWidget() == self.filter_widget: @@ -2782,7 +2844,7 @@ class MainWindow(QMainWindow): self._suppress_updates = False self.apply_filters() self.thumbnail_view.setUpdatesEnabled(True) - self.find_and_select_path(selected_path) + self.restore_selection(selected_paths) if self.main_dock.isVisible() and \ self.tags_tabs.currentWidget() == self.filter_widget: @@ -3064,7 +3126,7 @@ class MainWindow(QMainWindow): return # Preserve selection - selected_path = self.get_current_selected_path() + selected_paths = self.get_selected_paths() # Gather filter criteria from the UI include_tags = set() @@ -3112,8 +3174,8 @@ class MainWindow(QMainWindow): self.filtered_count_lbl.setText(UITexts.FILTERED_ZERO) # Restore selection if it's still visible - if selected_path: - self.find_and_select_path(selected_path) + if selected_paths: + self.restore_selection(selected_paths) # Sync open viewers with the new list of visible paths visible_paths = self.get_visible_image_paths() @@ -3163,13 +3225,18 @@ class MainWindow(QMainWindow): target_list.append(current_path) new_index = len(target_list) - 1 - w.controller.update_list( - target_list, new_index if new_index != -1 else None) + # Check if we are preserving the image to pass correct metadata + tags_to_pass = None + rating_to_pass = 0 + + if new_index != -1 and new_index < len(target_list): + if target_list[new_index] == current_path_in_viewer: + tags_to_pass = viewer_tags + rating_to_pass = viewer_rating - # Pass current image's tags and rating to the controller w.controller.update_list( target_list, new_index if new_index != -1 else None, - viewer_tags, viewer_rating) + tags_to_pass, rating_to_pass) if not w._is_persistent and not w.controller.image_list: w.close() continue @@ -3468,16 +3535,16 @@ class MainWindow(QMainWindow): if not self.history: return - current_selection = self.get_current_selected_path() + current_selection = self.get_selected_paths() term = self.history[0] if term.startswith("file:/"): path = term[6:] if os.path.isfile(path): - self.start_scan([os.path.dirname(path)], select_path=current_selection) + self.start_scan([os.path.dirname(path)], select_paths=current_selection) return - self.process_term(term, select_path=current_selection) + self.process_term(term, select_paths=current_selection) - def process_term(self, term, select_path=None): + def process_term(self, term, select_paths=None): """Processes a search term, file path, or layout directive.""" self.add_to_history(term) self.update_search_input() @@ -3529,7 +3596,7 @@ class MainWindow(QMainWindow): else: # If a directory or search term, start a scan - self.start_scan([path], select_path=select_path) + self.start_scan([path], select_paths=select_paths) def update_search_input(self): """Updates the search input combo box with history items and icons.""" @@ -3607,6 +3674,7 @@ class MainWindow(QMainWindow): self.tags_tabs.setCurrentIndex(d["active_dock_tab"]) self.face_names_history = d.get("face_names_history", []) self.pet_names_history = d.get("pet_names_history", []) + self.body_names_history = d.get("body_names_history", []) self.object_names_history = d.get("object_names_history", []) self.landmark_names_history = d.get("landmark_names_history", []) @@ -3674,6 +3742,7 @@ class MainWindow(QMainWindow): APP_CONFIG["active_dock_tab"] = self.tags_tabs.currentIndex() APP_CONFIG["face_names_history"] = self.face_names_history APP_CONFIG["pet_names_history"] = self.pet_names_history + APP_CONFIG["body_names_history"] = self.body_names_history APP_CONFIG["object_names_history"] = self.object_names_history APP_CONFIG["landmark_names_history"] = self.landmark_names_history APP_CONFIG["mru_tags"] = list(self.mru_tags) diff --git a/changelog.txt b/changelog.txt index 21aaafd..8df3856 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,16 @@ v0.9.11 - -· Hacer que el image viewer standalone admita múltiles sort +· Filmstrip fixed + +HAVE_BAGHEERASEARCH_LIB + +Refactor `load_image` to check if `pixmap_original` is already valid before reloading to optimize performance. + +Check if the `ImagePreloader` handles file deletion correctly if the file is deleted while being preloaded. + +Me gustaría implementar un modo de "Comparación" para ver 2 o 4 imágenes lado a lado en el visor. ¿Cómo podría abordarlo? + +· La instalación no debe usar Bagheera como motor a no ser que esté instalado. +· Hacer que el image viewer standalone admita múltiples sort · Comprobar hotkeys y funcionamiento en general. · Inhibir el salvapantallas con el slideshow y añadir opción de menú para inhibirlo durante un tiempo determinado · Mejorar el menú Open, con nombres correctos e iconos adecuados @@ -12,12 +23,8 @@ v0.9.11 - · Si quisiera distribuir mi aplicación como un AppImage, ¿cómo empaquetaría estos plugins de KDE para que funcionen en otros sistemas? · Solucionar el problema de las ventanas de diálogo nativas, tan simple como usar PySide nativo. -Analiza si la estrategia LIFO (Last-In, First-Out) en `CacheLoader` es la ideal para una galería de imágenes o si debería ser mixta. - ¿Cómo puedo añadir una opción para limitar el número de hilos que `ImageScanner` puede usar para la generación de miniaturas? -Verifica si el uso de `QPixmapCache` en `ThumbnailDelegate.paint_thumbnail` está optimizado para evitar la conversión repetitiva de QImage a QPixmap, lo que podría causar ralentizaciones al hacer scroll rápido. - Check if the `_suppress_updates` flag correctly prevents potential race conditions in `update_tag_list` when switching view modes rapidly. Verify if `find_and_select_path` logic in `on_view_mode_changed` handles cases where the selected item is filtered out after the view mode change. diff --git a/constants.py b/constants.py index 9a30241..232d3cb 100644 --- a/constants.py +++ b/constants.py @@ -110,7 +110,7 @@ SCANNER_SETTINGS_DEFAULTS = { "scan_full_on_start": True, "person_tags": "", "generation_threads": 4, - "search_engine": "Native" + "search_engine": "" } # --- IMAGE VIEWER DEFAULTS --- @@ -193,6 +193,10 @@ AVAILABLE_PET_ENGINES = [] if HAVE_MEDIAPIPE: AVAILABLE_PET_ENGINES.append("mediapipe") +AVAILABLE_BODY_ENGINES = [] +if HAVE_MEDIAPIPE: + AVAILABLE_BODY_ENGINES.append("mediapipe") + # Determine the default engine. This can be overridden by user config. DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None @@ -205,6 +209,7 @@ PET_DETECTION_ENGINE = APP_CONFIG.get("pet_detection_engine", DEFAULT_PET_ENGINE) DEFAULT_PET_BOX_COLOR = "#98FB98" # PaleGreen +DEFAULT_BODY_BOX_COLOR = "#FF4500" # OrangeRed DEFAULT_OBJECT_BOX_COLOR = "#FFD700" # Gold DEFAULT_LANDMARK_BOX_COLOR = "#00BFFF" # DeepSkyBlue # --- SHORTCUTS --- @@ -273,6 +278,7 @@ VIEWER_ACTIONS = { "detect_faces": ("Detect Faces", "Actions"), "detect_pets": ("Detect Pets", "Actions"), "fast_tag": ("Quick Tags", "Actions"), + "detect_bodies": ("Detect Bodies", "Actions"), "rotate_right": ("Rotate Right", "Transform"), "rotate_left": ("Rotate Left", "Transform"), "zoom_in": ("Zoom In", "Transform"), @@ -299,6 +305,7 @@ DEFAULT_VIEWER_SHORTCUTS = { "fullscreen": (Qt.Key_F11, Qt.NoModifier), "detect_faces": (Qt.Key_F, Qt.NoModifier), "detect_pets": (Qt.Key_P, Qt.NoModifier), + "detect_bodies": (Qt.Key_B, Qt.NoModifier), "fast_tag": (Qt.Key_T, Qt.NoModifier), "rotate_right": (Qt.Key_Plus, Qt.ControlModifier), "rotate_left": (Qt.Key_Minus, Qt.ControlModifier), @@ -395,13 +402,15 @@ _UI_TEXTS = { "RENAME_VIEWER_ERROR_TEXT": "Could not rename file: {}", "ADD_FACE_TITLE": "Add Face", "ADD_PET_TITLE": "Add Pet", + "ADD_BODY_TITLE": "Add Body", "ADD_OBJECT_TITLE": "Add Object", "ADD_LANDMARK_TITLE": "Add Landmark", "ADD_FACE_LABEL": "Name:", "ADD_PET_LABEL": "Name:", + "ADD_BODY_LABEL": "Name:", "ADD_OBJECT_LABEL": "Name:", "ADD_LANDMARK_LABEL": "Name:", - "DELETE_FACE": "Delete Face or area", + "DELETE_AREA_TITLE": "Delete area", "CREATE_TAG_TITLE": "Create Tag", "CREATE_TAG_TEXT": "The tag for '{}' does not exist. Do you want to create a " "new one?", @@ -409,6 +418,8 @@ _UI_TEXTS = { "NEW_PERSON_TAG_TEXT": "Enter the full path for the tag:", "NEW_PET_TAG_TITLE": "New Pet Tag", "NEW_PET_TAG_TEXT": "Enter the full path for the tag:", + "NEW_BODY_TAG_TITLE": "New Body Tag", + "NEW_BODY_TAG_TEXT": "Enter the full path for the tag:", "NEW_OBJECT_TAG_TITLE": "New Object Tag", "NEW_OBJECT_TAG_TEXT": "Enter the full path for the tag:", "NEW_LANDMARK_TAG_TITLE": "New Landmark Tag", @@ -418,10 +429,11 @@ _UI_TEXTS = { "one:", "FACE_NAME_TOOLTIP": "Type a name or select from history.", "CLEAR_TEXT_TOOLTIP": "Clear text field", - "RENAME_FACE_TITLE": "Rename Face or area", + "RENAME_AREA_TITLE": "Rename area", "SHOW_FACES": "Show Faces && other areas", "DETECT_FACES": "Detect Face", "DETECT_PETS": "Detect Pets", + "DETECT_BODIES": "Detect Bodies", "NO_FACE_LIBS": "No face detection libraries found. Install 'mediapipe' or " "'face_recognition'.", "THUMBNAIL_NO_NAME": "No name", @@ -441,7 +453,7 @@ _UI_TEXTS = { "MENU_SHOW_HISTORY": "Show History", "MENU_SETTINGS": "Settings", "SETTINGS_GROUP_SCANNER": "Scanner", - "SETTINGS_GROUP_FACES": "Faces && areas", + "SETTINGS_GROUP_AREAS": "Areas", "SETTINGS_GROUP_THUMBNAILS": "Thumbnails", "SETTINGS_GROUP_VIEWER": "Image Viewer", "SETTINGS_PERSON_TAGS_LABEL": "Person tags:", @@ -460,8 +472,19 @@ _UI_TEXTS = { "to remember.", "TYPE_FACE": "Face", "TYPE_PET": "Pet", + "TYPE_BODY": "Body", "TYPE_OBJECT": "Object", "TYPE_LANDMARK": "Landmark", + "SETTINGS_BODY_TAGS_LABEL": "Body tags:", + "SETTINGS_BODY_ENGINE_LABEL": "Body Detection Engine:", + "SETTINGS_BODY_COLOR_LABEL": "Body box color:", + "SETTINGS_BODY_HISTORY_COUNT_LABEL": "Max body history:", + "SETTINGS_BODY_TAGS_TOOLTIP": "Default tags for bodies, separated by commas.", + "SETTINGS_BODY_ENGINE_TOOLTIP": "Library used for body detection.", + "SETTINGS_BODY_COLOR_TOOLTIP": "Color of the bounding box drawn around " + "detected bodies.", + "SETTINGS_BODY_HISTORY_TOOLTIP": "Maximum number of recently used body names " + "to remember.", "SETTINGS_OBJECT_TAGS_LABEL": "Object tags:", "SETTINGS_OBJECT_ENGINE_LABEL": "Object Detection Engine:", "SETTINGS_OBJECT_COLOR_LABEL": "Object box color:", @@ -493,12 +516,15 @@ _UI_TEXTS = { "SETTINGS_THUMBS_RATING_COLOR_LABEL": "Thumbnails rating color:", "SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Thumbnails filename font size:", "SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL": "Thumbnails tags font size:", + "SETTINGS_SCAN_THREADS_LABEL": "Generation threads:", + "SETTINGS_SCAN_THREADS_TOOLTIP": "Maximum number of simultaneous threads to" + "generate thumbnails.", "SETTINGS_SCAN_MAX_LEVEL_LABEL": "Scan Max Level:", "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Scan Batch Size:", "SETTINGS_SCAN_FULL_ON_START_LABEL": "Scan Full On Start:", "SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "File search engine:", "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Engine to use for finding files. " - "'Native' uses BagheeraSearch library. 'baloosearch' uses KDE Baloo command.", + "'Bagheera' uses BagheeraSearch library. 'Baloo' uses 'baloosearch' command.", "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Maximum directory depth to scan " "recursively.", "SETTINGS_SCAN_BATCH_SIZE_TOOLTIP": "Number of images to load in each batch.", @@ -524,8 +550,8 @@ _UI_TEXTS = { "SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP": "Font size for filenames in " "thumbnails.", "SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Font size for tags in thumbnails.", - "SEARCH_ENGINE_NATIVE": "Native", - "SEARCH_ENGINE_BALOO": "baloosearch", + "SEARCH_ENGINE_NATIVE": "Bagheera", + "SEARCH_ENGINE_BALOO": "Baloo", "SETTINGS_VIEWER_WHEEL_SPEED_LABEL": "Viewer mouse wheel speed:", "SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Filename lines:", "SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Number of lines for the filename " @@ -801,19 +827,23 @@ _UI_TEXTS = { "RENAME_VIEWER_ERROR_TEXT": "No se pudo renombrar el archivo: {}", "ADD_FACE_TITLE": "Añadir Rostro", "ADD_PET_TITLE": "Añadir Mascota", + "ADD_BODY_TITLE": "Añadir Cuerpo", "ADD_OBJECT_TITLE": "Añadir Objeto", "ADD_LANDMARK_TITLE": "Añadir Lugar", "ADD_FACE_LABEL": "Nombre:", "ADD_PET_LABEL": "Nombre:", + "ADD_BODY_LABEL": "Nombre:", "ADD_OBJECT_LABEL": "Nombre:", "ADD_LANDMARK_LABEL": "Nombre:", - "DELETE_FACE": "Eliminar Rostro o área", + "DELETE_AREA_TITLE": "Eliminar área", "CREATE_TAG_TITLE": "Crear Etiqueta", "CREATE_TAG_TEXT": "La etiqueta para '{}' no existe. ¿Deseas crear una nueva?", "NEW_PERSON_TAG_TITLE": "Nueva Etiqueta de Persona", "NEW_PERSON_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", "NEW_PET_TAG_TITLE": "Nueva Etiqueta de Mascota", "NEW_PET_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", + "NEW_BODY_TAG_TITLE": "Nueva Etiqueta de Cuerpo", + "NEW_BODY_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", "NEW_OBJECT_TAG_TITLE": "Nueva Etiqueta de Objeto", "NEW_OBJECT_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", "NEW_LANDMARK_TAG_TITLE": "Nueva Etiqueta de Lugar", @@ -823,10 +853,11 @@ _UI_TEXTS = { "selecciona la correcta:", "FACE_NAME_TOOLTIP": "Escribe un nombre o selecciónalo del historial.", "CLEAR_TEXT_TOOLTIP": "Limpiar el campo de texto", - "RENAME_FACE_TITLE": "Renombrar Rostro o área", + "RENAME_AREA_TITLE": "Renombrar área", "SHOW_FACES": "Mostrar Rostros y otras áreas", "DETECT_FACES": "Detectar Rostros", "DETECT_PETS": "Detectar Mascotas", + "DETECT_BODIES": "Detectar Cuerpos", "NO_FACE_LIBS": "No se encontraron librerías de detección de rostros. Instale " "'mediapipe' o 'face_recognition'.", "THUMBNAIL_NO_NAME": "Sin nombre", @@ -846,7 +877,7 @@ _UI_TEXTS = { "MENU_SHOW_HISTORY": "Mostrar Historial", "MENU_SETTINGS": "Opciones", "SETTINGS_GROUP_SCANNER": "Escáner", - "SETTINGS_GROUP_FACES": "Rostros y áreas", + "SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_VIEWER": "Visor de Imágenes", "SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persona:", @@ -867,8 +898,21 @@ _UI_TEXTS = { "usados recientemente para recordar.", "TYPE_FACE": "Cara", "TYPE_PET": "Mascota", + "TYPE_BODY": "Cuerpo", "TYPE_OBJECT": "Objeto", "TYPE_LANDMARK": "Lugar", + "SETTINGS_BODY_TAGS_LABEL": "Etiquetas de cuerpo:", + "SETTINGS_BODY_ENGINE_LABEL": "Motor de detección de cuerpos:", + "SETTINGS_BODY_COLOR_LABEL": "Color del recuadro de cuerpo:", + "SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máx historial cuerpos:", + "SETTINGS_BODY_TAGS_TOOLTIP": "Etiquetas predeterminadas para cuerpos, " + "separadas por comas.", + "SETTINGS_BODY_ENGINE_TOOLTIP": "Librería utilizada para la detección de " + "cuerpos.", + "SETTINGS_BODY_COLOR_TOOLTIP": "Color del cuadro delimitador dibujado " + "alrededor de los cuerpos detectados.", + "SETTINGS_BODY_HISTORY_TOOLTIP": "Número máximo de nombres de cuerpos " + "usados recientemente para recordar.", "SETTINGS_OBJECT_TAGS_LABEL": "Etiquetas de objeto:", "SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de objetos:", "SETTINGS_OBJECT_COLOR_LABEL": "Color del recuadro de objeto:", @@ -906,8 +950,8 @@ _UI_TEXTS = { "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño de Lote de Escaneo:", "SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "Motor de búsqueda de archivos:", "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar archivos. " - "'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa el commando de" - "KDE Baloo.", + "'Bagheera' usa la librería de BagheeraSearch. 'Baloo0 usa el commando " + "'baloosearch'", "SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo al Inicio:", "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidad máxima de directorio para " "escanear recursivamente.", @@ -1213,19 +1257,23 @@ _UI_TEXTS = { "RENAME_VIEWER_ERROR_TEXT": "Non se puido renomear o ficheiro: {}", "ADD_FACE_TITLE": "Engadir Rostro", "ADD_PET_TITLE": "Engadir Mascota", + "ADD_BODY_TITLE": "Engadir Corpo", "ADD_OBJECT_TITLE": "Engadir Obxecto", "ADD_LANDMARK_TITLE": "Engadir Lugar", "ADD_FACE_LABEL": "Nome:", "ADD_PET_LABEL": "Nome:", + "ADD_BODY_LABEL": "Nome:", "ADD_OBJECT_LABEL": "Nome:", "ADD_LANDMARK_LABEL": "Nome:", - "DELETE_FACE": "Eliminar Rostro ou área", + "DELETE_AREA_TITLE": "Eliminar área", "CREATE_TAG_TITLE": "Crear Etiqueta", "CREATE_TAG_TEXT": "A etiqueta para '{}' non existe. Desexas crear unha nova?", "NEW_PERSON_TAG_TITLE": "Nova Etiqueta de Persoa", "NEW_PERSON_TAG_TEXT": "Introduce a ruta completa da etiqueta:", "NEW_PET_TAG_TITLE": "Nova Etiqueta de Mascota", "NEW_PET_TAG_TEXT": "Introduce a ruta completa da etiqueta:", + "NEW_BODY_TAG_TITLE": "Nova Etiqueta de Corpo", + "NEW_BODY_TAG_TEXT": "Introduce a ruta completa da etiqueta:", "NEW_OBJECT_TAG_TITLE": "Nova Etiqueta de Obxecto", "NEW_OBJECT_TAG_TEXT": "Introduce a ruta completa da etiqueta:", "NEW_LANDMARK_TAG_TITLE": "Nova Etiqueta de Lugar", @@ -1235,10 +1283,11 @@ _UI_TEXTS = { "selecciona a correcta:", "FACE_NAME_TOOLTIP": "Escribe un nome ou selecciónao do historial.", "CLEAR_TEXT_TOOLTIP": "Limpar o campo de texto", - "RENAME_FACE_TITLE": "Renomear Rostro ou área", + "RENAME_AREA_TITLE": "Renomear área", "SHOW_FACES": "Amosar Rostros e outras áreas", "DETECT_FACES": "Detectar Rostros", "DETECT_PETS": "Detectar Mascotas", + "DETECT_BODIES": "Detectar Corpos", "NO_FACE_LIBS": "Non se atoparon librarías de detección de rostros. Instale " "'mediapipe' ou 'face_recognition'.", "THUMBNAIL_NO_NAME": "Sen nome", @@ -1259,7 +1308,7 @@ _UI_TEXTS = { "MENU_SHOW_HISTORY": "Amosar Historial", "MENU_SETTINGS": "Opcións", "SETTINGS_GROUP_SCANNER": "Escáner", - "SETTINGS_GROUP_FACES": "Rostros e áreas", + "SETTINGS_GROUP_AREAS": "´áreas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_VIEWER": "Visor de Imaxes", "SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persoa:", @@ -1280,8 +1329,21 @@ _UI_TEXTS = { "recentemente para lembrar.", "TYPE_FACE": "Cara", "TYPE_PET": "Mascota", + "TYPE_BODY": "Corpo", "TYPE_OBJECT": "Obxecto", "TYPE_LANDMARK": "Lugar", + "SETTINGS_BODY_TAGS_LABEL": "Etiquetas de corpo:", + "SETTINGS_BODY_ENGINE_LABEL": "Motor de detección de corpos:", + "SETTINGS_BODY_COLOR_LABEL": "Cor do cadro de corpo:", + "SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máx historial corpos:", + "SETTINGS_BODY_TAGS_TOOLTIP": "Etiquetas predeterminadas para corpos, " + "separadas por comas.", + "SETTINGS_BODY_ENGINE_TOOLTIP": "Libraría utilizada para a detección de " + "corpos.", + "SETTINGS_BODY_COLOR_TOOLTIP": "Cor do cadro delimitador debuxado arredor " + "dos corpos detectados.", + "SETTINGS_BODY_HISTORY_TOOLTIP": "Número máximo de nomes de corpos usados " + "recentemente para lembrar.", "SETTINGS_OBJECT_TAGS_LABEL": "Etiquetas de obxecto:", "SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de obxectos:", "SETTINGS_OBJECT_COLOR_LABEL": "Cor do cadro de obxecto:", @@ -1322,8 +1384,8 @@ _UI_TEXTS = { "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño do Lote de Escaneo:", "SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "Motor de busca de ficheiros:", "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar ficheiros. " - "'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa o comando de " - "KDE Baloo.", + "'Bagheera' usa a libraría de BagheeraSearch. 'Baloo' usa o comando de " + "'baloosearch'.", "SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo ao Inicio:", "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidade máxima de directorio para " "escanear recursivamente.", @@ -1354,8 +1416,8 @@ _UI_TEXTS = { "ficheiro en miniaturas.", "SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Tamaño de fonte para etiquetas en " "miniaturas.", - "SEARCH_ENGINE_NATIVE": "Nativo", - "SEARCH_ENGINE_BALOO": "baloosearch", + "SEARCH_ENGINE_NATIVE": "Bagheera", + "SEARCH_ENGINE_BALOO": "Baloo", "SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Liñas para nome de ficheiro:", "SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Número de liñas para o nome do " "ficheiro debaixo da miniatura.", diff --git a/imagecontroller.py b/imagecontroller.py index 27a4e6f..02b00b7 100644 --- a/imagecontroller.py +++ b/imagecontroller.py @@ -16,11 +16,11 @@ from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform from xmpmanager import XmpManager from constants import ( - APP_CONFIG, AVAILABLE_FACE_ENGINES, AVAILABLE_PET_ENGINES, + APP_CONFIG, AVAILABLE_FACE_ENGINES, AVAILABLE_PET_ENGINES, AVAILABLE_BODY_ENGINES, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, RATING_XATTR_NAME, XATTR_NAME, UITexts ) -from metadatamanager import XattrManager +from metadatamanager import XattrManager, load_common_metadata class ImagePreloader(QThread): @@ -78,21 +78,6 @@ class ImagePreloader(QThread): self.mutex.unlock() self.wait() - def _load_metadata(self, path): - """Loads tag and rating data for a path.""" - tags = [] - raw_tags = XattrManager.get_attribute(path, XATTR_NAME) - if raw_tags: - tags = sorted(list(set(t.strip() - for t in raw_tags.split(',') if t.strip()))) - - raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0") - try: - rating = int(raw_rating) - except ValueError: - rating = 0 - return tags, rating - def run(self): """ The main execution loop for the thread. @@ -124,7 +109,7 @@ class ImagePreloader(QThread): img = reader.read() if not img.isNull(): # Load tags and rating here to avoid re-reading in main thread - tags, rating = self._load_metadata(path) + tags, rating = load_common_metadata(path) self.image_ready.emit(idx, path, img, tags, rating) except Exception: pass @@ -157,6 +142,8 @@ class ImageController(QObject): self.faces = [] self._current_tags = initial_tags if initial_tags is not None else [] self._current_rating = initial_rating + self._current_metadata_path = None + self._loaded_path = None self.show_faces = False # Preloading @@ -219,11 +206,27 @@ class ImageController(QObject): Loads the current image into the controller's main pixmap. """ path = self.get_current_path() + + # Optimization: Check if image is already loaded + if path and self._loaded_path == path and not self.pixmap_original.isNull(): + self.rotation = 0 + self.flip_h = False + self.flip_v = False + self.faces = [] + + # Ensure metadata is consistent with current path + if self._current_metadata_path != path: + self._current_tags, self._current_rating = load_common_metadata(path) + self._current_metadata_path = path + + self.load_faces() + self._trigger_preload() + return True + self.pixmap_original = QPixmap() + self._loaded_path = None self.rotation = 0 self.flip_h = False - self._current_tags = [] - self._current_rating = 0 self.flip_v = False self.faces = [] @@ -236,6 +239,7 @@ class ImageController(QObject): # Clear cache to free memory as we have consumed the image self._current_tags = self._cached_next_tags self._current_rating = self._cached_next_rating + self._current_metadata_path = path self._cached_next_image = None self._cached_next_index = -1 self._cached_next_tags = None @@ -249,9 +253,12 @@ class ImageController(QObject): return False self.pixmap_original = QPixmap.fromImage(image) - # Load tags and rating if not from cache - self._current_tags, self._current_rating = self._load_metadata(path) + # Load tags and rating if not already set for this path + if self._current_metadata_path != path: + self._current_tags, self._current_rating = load_common_metadata(path) + self._current_metadata_path = path + self._loaded_path = path self.load_faces() self._trigger_preload() return True @@ -422,6 +429,38 @@ class ImageController(QObject): face_data['h'] = h return face_data + def _create_region_from_pixels(self, x, y, w, h, img_w, img_h, region_type): + """ + Creates a normalized region dictionary from pixel coordinates. + + Args: + x (float): Top-left x coordinate in pixels. + y (float): Top-left y coordinate in pixels. + w (float): Width in pixels. + h (float): Height in pixels. + img_w (int): Image width in pixels. + img_h (int): Image height in pixels. + region_type (str): The type of region (Face, Pet, Body). + + Returns: + dict: Validated normalized region or None. + """ + if img_w <= 0 or img_h <= 0: + return None + + if w <= 0 or h <= 0: + return None + + new_region = { + 'name': '', + 'x': (x + w / 2) / img_w, + 'y': (y + h / 2) / img_h, + 'w': w / img_w, + 'h': h / img_h, + 'type': region_type + } + return self._clamp_and_validate_face(new_region) + def _detect_faces_face_recognition(self, path): """Detects faces using the 'face_recognition' library.""" import face_recognition @@ -433,12 +472,9 @@ class ImageController(QObject): for (top, right, bottom, left) in face_locations: box_w = right - left box_h = bottom - top - new_face = { - 'name': '', - 'x': (left + box_w / 2) / w, 'y': (top + box_h / 2) / h, - 'w': box_w / w, 'h': box_h / h, 'type': 'Face' - } - validated_face = self._clamp_and_validate_face(new_face) + validated_face = self._create_region_from_pixels( + left, top, box_w, box_h, w, h, 'Face' + ) if validated_face: new_faces.append(validated_face) except Exception as e: @@ -484,15 +520,10 @@ class ImageController(QObject): img_h, img_w = mp_image.height, mp_image.width for detection in detection_result.detections: bbox = detection.bounding_box # This is in pixels - new_face = { - 'name': '', - 'x': (bbox.origin_x + bbox.width / 2) / img_w, - 'y': (bbox.origin_y + bbox.height / 2) / img_h, - 'w': bbox.width / img_w, - 'h': bbox.height / img_h, - 'type': 'Face' - } - validated_face = self._clamp_and_validate_face(new_face) + validated_face = self._create_region_from_pixels( + bbox.origin_x, bbox.origin_y, bbox.width, bbox.height, + img_w, img_h, 'Face' + ) if validated_face: new_faces.append(validated_face) @@ -500,19 +531,27 @@ class ImageController(QObject): print(f"Error during MediaPipe detection: {e}") return new_faces - def _detect_pets_mediapipe(self, path): - """Detects pets using the 'mediapipe' library object detection.""" + def _detect_objects_mediapipe(self, path, allowlist, max_results, region_type): + """ + Generic method to detect objects using MediaPipe ObjectDetector. + + Args: + path (str): Path to image file. + allowlist (list): List of category names to detect. + max_results (int): Maximum number of results to return. + region_type (str): The 'type' label for the detected regions. + """ import mediapipe as mp from mediapipe.tasks import python from mediapipe.tasks.python import vision - new_pets = [] + new_regions = [] if not os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH): print(f"MediaPipe model not found at: {MEDIAPIPE_OBJECT_MODEL_PATH}") print("Please download 'efficientdet_lite0.tflite' and place it there.") print(f"URL: {MEDIAPIPE_OBJECT_MODEL_URL}") - return new_pets + return new_regions try: base_options = python.BaseOptions( @@ -520,8 +559,8 @@ class ImageController(QObject): options = vision.ObjectDetectorOptions( base_options=base_options, score_threshold=0.5, - max_results=5, - category_allowlist=["cat", "dog"]) # Detect cats and dogs + max_results=max_results, + category_allowlist=allowlist) # Silence MediaPipe warnings (stderr) during initialization stderr_fd = 2 @@ -542,21 +581,24 @@ class ImageController(QObject): img_h, img_w = mp_image.height, mp_image.width for detection in detection_result.detections: bbox = detection.bounding_box - new_pet = { - 'name': '', - 'x': (bbox.origin_x + bbox.width / 2) / img_w, - 'y': (bbox.origin_y + bbox.height / 2) / img_h, - 'w': bbox.width / img_w, - 'h': bbox.height / img_h, - 'type': 'Pet' - } - validated_pet = self._clamp_and_validate_face(new_pet) - if validated_pet: - new_pets.append(validated_pet) + validated_region = self._create_region_from_pixels( + bbox.origin_x, bbox.origin_y, bbox.width, bbox.height, + img_w, img_h, region_type + ) + if validated_region: + new_regions.append(validated_region) except Exception as e: - print(f"Error during MediaPipe pet detection: {e}") - return new_pets + print(f"Error during MediaPipe {region_type} detection: {e}") + return new_regions + + def _detect_pets_mediapipe(self, path): + """Detects pets using the 'mediapipe' library object detection.""" + return self._detect_objects_mediapipe(path, ["cat", "dog"], 5, "Pet") + + def _detect_bodies_mediapipe(self, path): + """Detects bodies using the 'mediapipe' library object detection.""" + return self._detect_objects_mediapipe(path, ["person"], 10, "Body") def detect_faces(self): """ @@ -615,6 +657,21 @@ class ImageController(QObject): return [] + def detect_bodies(self): + """ + Detects bodies using a configured or available detection engine. + """ + path = self.get_current_path() + if not path: + return [] + + engine = APP_CONFIG.get("body_detection_engine", "mediapipe") + + if engine == "mediapipe" and "mediapipe" in AVAILABLE_BODY_ENGINES: + return self._detect_bodies_mediapipe(path) + + return [] + def get_display_pixmap(self): """ Applies current transformations (rotation, zoom, flip) to the original @@ -709,30 +766,27 @@ class ImageController(QObject): elif self.index < 0: self.index = 0 - # Update current image metadata if provided - self._current_tags = current_image_tags \ - if current_image_tags is not None else [] - self._current_rating = current_image_rating + # Update current image metadata + if current_image_tags is not None: + self._current_tags = current_image_tags + self._current_rating = current_image_rating + self._current_metadata_path = self.get_current_path() + else: + # Reload from disk if not provided to ensure consistency + path = self.get_current_path() + if path: + self._current_tags, self._current_rating = load_common_metadata(path) + self._current_metadata_path = path + else: + self._current_tags = [] + self._current_rating = 0 + self._current_metadata_path = None + self._cached_next_image = None self._cached_next_index = -1 self._trigger_preload() self.list_updated.emit(self.index) - def _load_metadata(self, path): - """Loads tag and rating data for a path.""" - tags = [] - raw_tags = XattrManager.get_attribute(path, XATTR_NAME) - if raw_tags: - tags = sorted(list(set(t.strip() - for t in raw_tags.split(',') if t.strip()))) - - raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0") - try: - rating = int(raw_rating) - except ValueError: - rating = 0 - return tags, rating - def update_list_on_exists(self, new_list, new_index=None): """ Updates the list only if the old list is a subset of the new one. @@ -749,8 +803,17 @@ class ImageController(QObject): self.index = new_index if self.index >= len(self.image_list): self.index = max(0, len(self.image_list) - 1) - self._current_tags = [] # Clear current tags/rating, will be reloaded - self._current_rating = 0 + + # Reload metadata for the current image to avoid stale/empty state + path = self.get_current_path() + if path: + self._current_tags, self._current_rating = load_common_metadata(path) + self._current_metadata_path = path + else: + self._current_tags = [] + self._current_rating = 0 + self._current_metadata_path = None + self._cached_next_image = None self._cached_next_index = -1 self._trigger_preload() diff --git a/imagescanner.py b/imagescanner.py index 97f05cc..7f55adf 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -1404,8 +1404,8 @@ class ImageScanner(QThread): return None, [] def _search(self, query): - engine = APP_CONFIG.get("search_engine", "Native") - if HAVE_BAGHEERASEARCH_LIB and (engine == "Native" or not SEARCH_CMD): + engine = APP_CONFIG.get("search_engine", "Bagheera") + if HAVE_BAGHEERASEARCH_LIB and (engine == "Bagheera" or not SEARCH_CMD): query_text, main_options, other_options = self._parse_query(query) try: searcher = BagheeraSearcher() diff --git a/imageviewer.py b/imageviewer.py index 3b2a1ff..c45b8e7 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -29,6 +29,7 @@ from PySide6.QtCore import ( from constants import ( APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS, + DEFAULT_BODY_BOX_COLOR, DEFAULT_OBJECT_BOX_COLOR, DEFAULT_LANDMARK_BOX_COLOR, FORCE_X11, ICON_THEME_VIEWER, ICON_THEME_VIEWER_FALLBACK, KSCREEN_DOCTOR_MARGIN, KWINOUTPUTCONFIG_PATH, VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_FORM_MARGIN, VIEWER_LABEL, @@ -47,6 +48,9 @@ class FaceNameDialog(QDialog): if region_type == "Pet": self.setWindowTitle(UITexts.ADD_PET_TITLE) layout_label = UITexts.ADD_PET_LABEL + elif region_type == "Body": + self.setWindowTitle(UITexts.ADD_BODY_TITLE) + layout_label = UITexts.ADD_BODY_LABEL elif region_type == "Object": self.setWindowTitle(UITexts.ADD_OBJECT_TITLE) layout_label = UITexts.ADD_OBJECT_LABEL @@ -441,6 +445,8 @@ class FaceCanvas(QLabel): face_color = QColor(face_color_str) pet_color_str = APP_CONFIG.get("pet_box_color", DEFAULT_PET_BOX_COLOR) pet_color = QColor(pet_color_str) + body_color_str = APP_CONFIG.get("body_box_color", DEFAULT_BODY_BOX_COLOR) + body_color = QColor(body_color_str) object_color_str = APP_CONFIG.get("object_box_color", DEFAULT_OBJECT_BOX_COLOR) object_color = QColor(object_color_str) landmark_color_str = APP_CONFIG.get("landmark_box_color", @@ -452,11 +458,14 @@ class FaceCanvas(QLabel): rect = self.map_from_source(face) is_pet = face.get('type') == 'Pet' + is_body = face.get('type') == 'Body' is_object = face.get('type') == 'Object' is_landmark = face.get('type') == 'Landmark' if is_pet: color = pet_color + elif is_body: + color = body_color elif is_object: color = object_color elif is_landmark: @@ -677,8 +686,10 @@ class FaceCanvas(QLabel): elif event.button() == Qt.LeftButton: self.dragging = True self.drag_start_pos = event.globalPosition().toPoint() - self.drag_start_scroll_x = self.viewer.scroll_area.horizontalScrollBar().value() - self.drag_start_scroll_y = self.viewer.scroll_area.verticalScrollBar().value() + self.drag_start_scroll_x = \ + self.viewer.scroll_area.horizontalScrollBar().value() + self.drag_start_scroll_y = \ + self.viewer.scroll_area.verticalScrollBar().value() self.setCursor(Qt.ClosedHandCursor) event.accept() else: @@ -863,12 +874,15 @@ class FaceCanvas(QLabel): menu = QMenu(self) action_face = menu.addAction(UITexts.TYPE_FACE) action_pet = menu.addAction(UITexts.TYPE_PET) + action_body = menu.addAction(UITexts.TYPE_BODY) action_object = menu.addAction(UITexts.TYPE_OBJECT) action_landmark = menu.addAction(UITexts.TYPE_LANDMARK) # Show menu at mouse release position res = menu.exec(event.globalPosition().toPoint()) if res == action_pet: region_type = "Pet" + elif res == action_body: + region_type = "Body" elif res == action_object: region_type = "Object" elif res == action_landmark: @@ -885,6 +899,8 @@ class FaceCanvas(QLabel): if self.viewer.main_win: if region_type == "Pet": history_list = self.viewer.main_win.pet_names_history + elif region_type == "Body": + history_list = self.viewer.main_win.body_names_history elif region_type == "Object": history_list = self.viewer.main_win.object_names_history elif region_type == "Landmark": @@ -903,6 +919,8 @@ class FaceCanvas(QLabel): if self.viewer.main_win: if region_type == "Pet": self.viewer.main_win.pet_names_history = updated_history + elif region_type == "Body": + self.viewer.main_win.body_names_history = updated_history elif region_type == "Object": self.viewer.main_win.object_names_history = updated_history elif region_type == "Landmark": @@ -1192,6 +1210,7 @@ class ImageViewer(QWidget): "flip_vertical": self.toggle_flip_vertical, "detect_faces": self.run_face_detection, "detect_pets": self.run_pet_detection, + "detect_bodies": self.run_body_detection, "fast_tag": self.show_fast_tag_menu, "rotate_right": lambda: self.apply_rotation(90, True), "rotate_left": lambda: self.apply_rotation(-90, True), @@ -1217,7 +1236,7 @@ class ImageViewer(QWidget): Optimized to update the existing list if possible, rather than rebuilding it entirely. """ - if not self.filmstrip.isVisible(): + if self.filmstrip.isHidden(): return # --- OPTIMIZATION --- @@ -1887,9 +1906,14 @@ class ImageViewer(QWidget): zoom = int(self.controller.zoom_factor * 100) self.sb_info_label.setText(f"{w} x {h} px | {zoom}%") - # Use tags from controller's internal state + # Use tags from metadata if provided (priority to avoid race conditions), + # otherwise fallback to controller's internal state. + tags_source = self.controller._current_tags + if metadata and 'tags' in metadata: + tags_source = metadata['tags'] + display_tags = [t.strip().split('/')[-1] - for t in self.controller._current_tags if t.strip()] + for t in tags_source if t.strip()] self.sb_tags_label.setText(", ".join(display_tags)) @Slot(str, dict) @@ -2080,8 +2104,8 @@ class ImageViewer(QWidget): return False menu = QMenu(self) - action_del = menu.addAction(UITexts.DELETE_FACE) - action_ren = menu.addAction(UITexts.RENAME_FACE_TITLE) + action_del = menu.addAction(UITexts.DELETE_AREA_TITLE) + action_ren = menu.addAction(UITexts.RENAME_AREA_TITLE) res = menu.exec(event.globalPos()) if res == action_del: @@ -2107,6 +2131,8 @@ class ImageViewer(QWidget): if self.main_win: if region_type == "Pet": history_list = self.main_win.pet_names_history + elif region_type == "Body": + history_list = self.main_win.body_names_history elif region_type == "Object": history_list = self.main_win.object_names_history elif region_type == "Landmark": @@ -2135,6 +2161,8 @@ class ImageViewer(QWidget): if self.main_win: if region_type == "Pet": self.main_win.pet_names_history = updated_history + elif region_type == "Body": + self.main_win.body_names_history = updated_history elif region_type == "Object": self.main_win.object_names_history = updated_history elif region_type == "Landmark": @@ -2185,6 +2213,9 @@ class ImageViewer(QWidget): {"text": UITexts.DETECT_PETS, "action": "detect_pets", "icon": "edit-image-face-recognize"}, "separator", + {"text": UITexts.DETECT_BODIES, "action": "detect_bodies", + "icon": "edit-image-face-recognize"}, + "separator", {"text": UITexts.VIEWER_MENU_ROTATE, "icon": "transform-rotate", "submenu": [ {"text": UITexts.VIEWER_MENU_ROTATE_LEFT, @@ -2491,6 +2522,61 @@ class ImageViewer(QWidget): if added_count > 0: self.controller.save_faces() + def run_body_detection(self): + """Runs body detection on the current image.""" + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + new_bodies = self.controller.detect_bodies() + finally: + QApplication.restoreOverrideCursor() + + if not new_bodies: + return + + IOU_THRESHOLD = 0.7 + added_count = 0 + for new_body in new_bodies: + is_duplicate = False + for existing_face in self.controller.faces: + iou = self._calculate_iou(new_body, existing_face) + if iou > IOU_THRESHOLD: + is_duplicate = True + break + + if is_duplicate: + continue + + if not self.controller.show_faces: + self.toggle_faces() + + self.controller.faces.append(new_body) + self.canvas.update() + + w = self.canvas.width() + h = self.canvas.height() + self.scroll_area.ensureVisible(int(new_body.get('x', 0) * w), + int(new_body.get('y', 0) * h), 50, 50) + QApplication.processEvents() + + # For bodies, we typically don't ask for a name immediately unless desired + # Or we can treat it like pets/faces and ask. Let's ask. + history = self.main_win.body_names_history if self.main_win else [] + full_tag, updated_history, ok = FaceNameDialog.get_name( + self, history, main_win=self.main_win, region_type="Body") + + if ok and full_tag: + new_body['name'] = full_tag + self.controller.toggle_tag(full_tag, True) + if self.main_win: + self.main_win.body_names_history = updated_history + added_count += 1 + else: + self.controller.faces.pop() + self.canvas.update() + + if added_count > 0: + self.controller.save_faces() + def toggle_filmstrip(self): """Shows or hides the filmstrip widget.""" visible = not self.filmstrip.isVisible() diff --git a/metadatamanager.py b/metadatamanager.py index 5bf9b72..d7297e0 100644 --- a/metadatamanager.py +++ b/metadatamanager.py @@ -16,6 +16,7 @@ except ImportError: exiv2 = None HAVE_EXIV2 = False from utils import preserve_mtime +from constants import RATING_XATTR_NAME, XATTR_NAME def notify_baloo(path): @@ -40,6 +41,24 @@ def notify_baloo(path): QDBusConnection.sessionBus().call(msg, QDBus.NoBlock) +def load_common_metadata(path): + """ + Loads tag and rating data for a path using extended attributes. + """ + tags = [] + raw_tags = XattrManager.get_attribute(path, XATTR_NAME) + if raw_tags: + tags = sorted(list(set(t.strip() + for t in raw_tags.split(',') if t.strip()))) + + raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0") + try: + rating = int(raw_rating) + except ValueError: + rating = 0 + return tags, rating + + class MetadataManager: """Manages reading EXIF, IPTC, and XMP metadata.""" diff --git a/settings.py b/settings.py index 2190b05..e5ac988 100644 --- a/settings.py +++ b/settings.py @@ -25,7 +25,8 @@ from constants import ( APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_OBJECT_BOX_COLOR, DEFAULT_LANDMARK_BOX_COLOR, FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, - AVAILABLE_PET_ENGINES, MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, + AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR, + MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT, @@ -81,6 +82,7 @@ class SettingsDialog(QDialog): self.current_face_color = DEFAULT_FACE_BOX_COLOR self.current_pet_color = DEFAULT_PET_BOX_COLOR + self.current_body_color = DEFAULT_BODY_BOX_COLOR self.current_object_color = DEFAULT_OBJECT_BOX_COLOR self.current_landmark_color = DEFAULT_LANDMARK_BOX_COLOR self.current_thumbs_bg_color = THUMBNAILS_BG_COLOR_DEFAULT @@ -293,9 +295,9 @@ class SettingsDialog(QDialog): search_engine_layout = QHBoxLayout() search_engine_label = QLabel(UITexts.SETTINGS_SCANNER_SEARCH_ENGINE_LABEL) self.search_engine_combo = QComboBox() - self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_NATIVE, "Native") + self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_NATIVE, "Bagheera") if SEARCH_CMD: - self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_BALOO, "baloosearch") + self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_BALOO, "Baloo") search_engine_layout.addWidget(search_engine_label) search_engine_layout.addWidget(self.search_engine_combo) @@ -462,6 +464,53 @@ class SettingsDialog(QDialog): self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP) faces_layout.addLayout(pet_history_layout) + # --- Body Section --- + faces_layout.addSpacing(10) + body_header = QLabel("Body") + body_header.setFont(QFont("Sans", 10, QFont.Bold)) + faces_layout.addWidget(body_header) + + body_tags_layout = QHBoxLayout() + body_tags_label = QLabel(UITexts.SETTINGS_BODY_TAGS_LABEL) + self.body_tags_edit = QLineEdit() + self.body_tags_edit.setPlaceholderText("tag1, tag2, tag3/subtag") + self.body_tags_edit.setClearButtonEnabled(True) + body_tags_layout.addWidget(body_tags_label) + body_tags_layout.addWidget(self.body_tags_edit) + body_tags_label.setToolTip(UITexts.SETTINGS_BODY_TAGS_TOOLTIP) + self.body_tags_edit.setToolTip(UITexts.SETTINGS_BODY_TAGS_TOOLTIP) + faces_layout.addLayout(body_tags_layout) + + # body_engine_layout = QHBoxLayout() + # body_engine_label = QLabel(UITexts.SETTINGS_BODY_ENGINE_LABEL) + # self.body_engine_combo = QComboBox() + # self.body_engine_combo.addItems(AVAILABLE_BODY_ENGINES) + # body_engine_layout.addWidget(body_engine_label) + # body_engine_layout.addWidget(self.body_engine_combo, 1) + # body_engine_label.setToolTip(UITexts.SETTINGS_BODY_ENGINE_TOOLTIP) + # self.body_engine_combo.setToolTip(UITexts.SETTINGS_BODY_ENGINE_TOOLTIP) + # faces_layout.addLayout(body_engine_layout) + + body_color_layout = QHBoxLayout() + body_color_label = QLabel(UITexts.SETTINGS_BODY_COLOR_LABEL) + self.body_color_btn = QPushButton() + self.body_color_btn.clicked.connect(self.choose_body_color) + body_color_layout.addWidget(body_color_label) + body_color_layout.addWidget(self.body_color_btn) + body_color_label.setToolTip(UITexts.SETTINGS_BODY_COLOR_TOOLTIP) + self.body_color_btn.setToolTip(UITexts.SETTINGS_BODY_COLOR_TOOLTIP) + faces_layout.addLayout(body_color_layout) + + body_history_layout = QHBoxLayout() + self.body_history_spin = QSpinBox() + self.body_history_spin.setRange(5, 100) + body_hist_label = QLabel(UITexts.SETTINGS_BODY_HISTORY_COUNT_LABEL) + body_history_layout.addWidget(body_hist_label) + body_history_layout.addWidget(self.body_history_spin) + body_hist_label.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP) + self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP) + faces_layout.addLayout(body_history_layout) + # --- Object Section --- faces_layout.addSpacing(10) object_header = QLabel("Object") @@ -593,7 +642,7 @@ class SettingsDialog(QDialog): # Add tabs in the new order tabs.addTab(thumbs_tab, UITexts.SETTINGS_GROUP_THUMBNAILS) tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER) - tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_FACES) + tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS) tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER) # --- Button Box --- @@ -625,16 +674,19 @@ class SettingsDialog(QDialog): person_tags = APP_CONFIG.get( "person_tags", SCANNER_SETTINGS_DEFAULTS["person_tags"]) pet_tags = APP_CONFIG.get("pet_tags", "") + body_tags = APP_CONFIG.get("body_tags", "") object_tags = APP_CONFIG.get("object_tags", "") landmark_tags = APP_CONFIG.get("landmark_tags", "") face_detection_engine = APP_CONFIG.get("face_detection_engine") pet_detection_engine = APP_CONFIG.get("pet_detection_engine") + body_detection_engine = APP_CONFIG.get("body_detection_engine") object_detection_engine = APP_CONFIG.get("object_detection_engine") landmark_detection_engine = APP_CONFIG.get("landmark_detection_engine") face_color = APP_CONFIG.get("face_box_color", DEFAULT_FACE_BOX_COLOR) pet_color = APP_CONFIG.get("pet_box_color", DEFAULT_PET_BOX_COLOR) + body_color = APP_CONFIG.get("body_box_color", DEFAULT_BODY_BOX_COLOR) object_color = APP_CONFIG.get("object_box_color", DEFAULT_OBJECT_BOX_COLOR) landmark_color = APP_CONFIG.get("landmark_box_color", DEFAULT_LANDMARK_BOX_COLOR) @@ -645,6 +697,8 @@ class SettingsDialog(QDialog): "faces_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) pet_history_count = APP_CONFIG.get( "pets_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) + body_history_count = APP_CONFIG.get( + "body_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) object_history_count = APP_CONFIG.get( "object_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) landmark_history_count = APP_CONFIG.get( @@ -695,11 +749,13 @@ class SettingsDialog(QDialog): self.person_tags_edit.setText(person_tags) self.pet_tags_edit.setText(pet_tags) + self.body_tags_edit.setText(body_tags) self.object_tags_edit.setText(object_tags) self.landmark_tags_edit.setText(landmark_tags) self.set_button_color(face_color) self.set_pet_button_color(pet_color) + self.set_body_button_color(body_color) self.set_object_button_color(object_color) self.set_landmark_button_color(landmark_color) @@ -709,6 +765,8 @@ class SettingsDialog(QDialog): if self.pet_engine_combo and pet_detection_engine in AVAILABLE_PET_ENGINES: self.pet_engine_combo.setCurrentText(pet_detection_engine) + if body_detection_engine and hasattr(self, "body_detection_engine_combo"): + self.body_engine_combo.setCurrentText(body_detection_engine) if object_detection_engine and hasattr(self, "object_engine_combo"): self.object_engine_combo.setCurrentText(object_detection_engine) if landmark_detection_engine and hasattr(self, "landmark_engine_combo"): @@ -717,6 +775,7 @@ class SettingsDialog(QDialog): self.mru_tags_spin.setValue(mru_tags_count) self.face_history_spin.setValue(face_history_count) self.pet_history_spin.setValue(pet_history_count) + self.body_history_spin.setValue(body_history_count) self.object_history_spin.setValue(object_history_count) self.landmark_history_spin.setValue(landmark_history_count) @@ -771,6 +830,18 @@ class SettingsDialog(QDialog): if color.isValid(): self.set_pet_button_color(color.name()) + def set_body_button_color(self, color_str): + """Sets the background color of the body button and stores the value.""" + self.body_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_body_color = color_str + + def choose_body_color(self): + """Opens a color picker dialog for body box.""" + color = QColorDialog.getColor(QColor(self.current_body_color), self) + if color.isValid(): + self.set_body_button_color(color.name()) + def set_object_button_color(self, color_str): """Sets the background color of the object button.""" self.object_color_btn.setStyleSheet( @@ -942,15 +1013,18 @@ class SettingsDialog(QDialog): APP_CONFIG["scan_full_on_start"] = self.scan_full_on_start_checkbox.isChecked() APP_CONFIG["person_tags"] = self.person_tags_edit.text() APP_CONFIG["pet_tags"] = self.pet_tags_edit.text() + APP_CONFIG["body_tags"] = self.body_tags_edit.text() APP_CONFIG["object_tags"] = self.object_tags_edit.text() APP_CONFIG["landmark_tags"] = self.landmark_tags_edit.text() APP_CONFIG["face_box_color"] = self.current_face_color APP_CONFIG["pet_box_color"] = self.current_pet_color + APP_CONFIG["body_box_color"] = self.current_body_color APP_CONFIG["object_box_color"] = self.current_object_color APP_CONFIG["landmark_box_color"] = self.current_landmark_color APP_CONFIG["tags_menu_max_items"] = self.mru_tags_spin.value() APP_CONFIG["faces_menu_max_items"] = self.face_history_spin.value() APP_CONFIG["pets_menu_max_items"] = self.pet_history_spin.value() + APP_CONFIG["body_menu_max_items"] = self.body_history_spin.value() APP_CONFIG["object_menu_max_items"] = self.object_history_spin.value() APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value() @@ -975,9 +1049,10 @@ class SettingsDialog(QDialog): APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value() APP_CONFIG["viewer_auto_resize_window"] = \ self.viewer_auto_resize_check.isChecked() - if self.face_engine_combo: - APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText() + APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText() APP_CONFIG["pet_detection_engine"] = self.pet_engine_combo.currentText() + if hasattr(self, "object_engine_combo"): + APP_CONFIG["body_detection_engine"] = self.body_engine_combo.currentText() if hasattr(self, "object_engine_combo"): APP_CONFIG["object_detection_engine"] = \ self.object_engine_combo.currentText() diff --git a/widgets.py b/widgets.py index a80a8fe..495c198 100644 --- a/widgets.py +++ b/widgets.py @@ -1121,6 +1121,9 @@ class FaceNameInputWidget(QWidget): if self.region_type == "Pet": max_items = APP_CONFIG.get("pets_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) + elif self.region_type == "Body": + max_items = APP_CONFIG.get("body_menu_max_items", + FACES_MENU_MAX_ITEMS_DEFAULT) elif self.region_type == "Object": max_items = APP_CONFIG.get("object_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) @@ -1188,6 +1191,12 @@ class FaceNameInputWidget(QWidget): parent_tags_str = "Pet" dialog_title = UITexts.NEW_PET_TAG_TITLE dialog_text = UITexts.NEW_PET_TAG_TEXT + elif self.region_type == "Body": + parent_tags_str = APP_CONFIG.get("body_tags", "Body") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Body" + dialog_title = UITexts.NEW_BODY_TAG_TITLE + dialog_text = UITexts.NEW_BODY_TAG_TEXT elif self.region_type == "Object": parent_tags_str = APP_CONFIG.get("object_tags", "Object") if not parent_tags_str or not parent_tags_str.strip(): @@ -1273,6 +1282,10 @@ class FaceNameInputWidget(QWidget): parent_tags_str = APP_CONFIG.get("pet_tags", "Pet") if not parent_tags_str or not parent_tags_str.strip(): parent_tags_str = "Pet" + elif self.region_type == "Body": + parent_tags_str = APP_CONFIG.get("body_tags", "Body") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Body" elif self.region_type == "Object": parent_tags_str = APP_CONFIG.get("object_tags", "Object") if not parent_tags_str or not parent_tags_str.strip():