A bunch of changes

This commit is contained in:
Ignacio Serantes
2026-03-23 21:53:19 +01:00
parent a402828d1a
commit 547bfbf760
9 changed files with 544 additions and 150 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.",

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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():