Compare commits

...

5 Commits

Author SHA1 Message Date
Ignacio Serantes
b87e34a1b8 Merge branch 'main' of ssh://git.aynoa.net/ignacio/bagheeraview 2026-03-24 09:07:19 +01:00
Ignacio Serantes
20e5318a53 A bunch of changes 2026-03-24 09:06:37 +01:00
Ignacio Serantes
144ad665e4 A bunch of changes 2026-03-23 23:44:09 +01:00
Ignacio Serantes
291f2f9e47 A bunch of changes 2026-03-23 22:50:02 +01:00
Ignacio Serantes
547bfbf760 A bunch of changes 2026-03-23 21:53:19 +01:00
10 changed files with 1788 additions and 699 deletions

View File

@@ -68,7 +68,8 @@ from constants import (
) )
import constants import constants
from settings import SettingsDialog from settings import SettingsDialog
from imagescanner import CacheCleaner, ImageScanner, ThumbnailCache, ThumbnailGenerator from imagescanner import (CacheCleaner, ImageScanner, ThumbnailCache,
ThumbnailGenerator, ThreadPoolManager)
from imageviewer import ImageViewer from imageviewer import ImageViewer
from propertiesdialog import PropertiesDialog from propertiesdialog import PropertiesDialog
from widgets import ( from widgets import (
@@ -579,8 +580,6 @@ class ThumbnailDelegate(QStyledItemDelegate):
thumb_size = self.main_win.current_thumb_size thumb_size = self.main_win.current_thumb_size
path = index.data(PATH_ROLE) path = index.data(PATH_ROLE)
mtime = index.data(MTIME_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 # Optimization: Use QPixmapCache to avoid expensive QImage->QPixmap
# conversion on every paint event. # conversion on every paint event.
@@ -589,6 +588,8 @@ class ThumbnailDelegate(QStyledItemDelegate):
if not source_pixmap or source_pixmap.isNull(): if not source_pixmap or source_pixmap.isNull():
# Not in UI cache, try to get from main thumbnail cache (Memory/LMDB) # 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( img, _ = self.main_win.cache.get_thumbnail(
path, requested_size=thumb_size, curr_mtime=mtime, path, requested_size=thumb_size, curr_mtime=mtime,
inode=inode, device_id=device_id, async_load=True) inode=inode, device_id=device_id, async_load=True)
@@ -863,20 +864,34 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel):
def lessThan(self, left, right): def lessThan(self, left, right):
"""Custom sorting logic for name and date.""" """Custom sorting logic for name and date."""
sort_role = self.sortRole() 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: if sort_role == MTIME_ROLE:
left = left_data if left_data is not None else 0 left_data = self.sourceModel().data(left, sort_role)
right = right_data if right_data is not None else 0 right_data = self.sourceModel().data(right, sort_role)
return left < right # 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 # Default (DisplayRole) is name sorting.
# Handle None values safely # Optimization: Use the pre-calculated lowercase name from the cache
l_str = str(left_data) if left_data is not None else "" # to avoid repeated string operations during sorting.
r_str = str(right_data) if right_data is not None else "" 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): class MainWindow(QMainWindow):
@@ -889,13 +904,14 @@ class MainWindow(QMainWindow):
scanners and individual image viewer windows. scanners and individual image viewer windows.
""" """
def __init__(self, cache, args): def __init__(self, cache, args, thread_pool_manager):
""" """
Initializes the MainWindow. Initializes the MainWindow.
Args: Args:
cache (ThumbnailCache): The shared thumbnail cache instance. cache (ThumbnailCache): The shared thumbnail cache instance.
args (list): Command-line arguments passed to the application. args (list): Command-line arguments passed to the application.
thread_pool_manager (ThreadPoolManager): The shared thread pool manager.
""" """
super().__init__() super().__init__()
self.cache = cache self.cache = cache
@@ -903,11 +919,13 @@ class MainWindow(QMainWindow):
self.set_app_icon() self.set_app_icon()
self.viewer_shortcuts = {} self.viewer_shortcuts = {}
self.thread_pool_manager = thread_pool_manager
self.full_history = [] self.full_history = []
self.history = [] self.history = []
self.current_thumb_size = THUMBNAILS_DEFAULT_SIZE self.current_thumb_size = THUMBNAILS_DEFAULT_SIZE
self.face_names_history = [] self.face_names_history = []
self.pet_names_history = [] self.pet_names_history = []
self.body_names_history = []
self.object_names_history = [] self.object_names_history = []
self.landmark_names_history = [] self.landmark_names_history = []
self.mru_tags = deque(maxlen=APP_CONFIG.get( self.mru_tags = deque(maxlen=APP_CONFIG.get(
@@ -1305,12 +1323,14 @@ class MainWindow(QMainWindow):
def _on_scroll_interaction(self, value): def _on_scroll_interaction(self, value):
"""Pauses scanning during scroll to keep UI fluid.""" """Pauses scanning during scroll to keep UI fluid."""
if self.scanner and self.scanner.isRunning(): if self.scanner and self.scanner.isRunning():
self.thread_pool_manager.set_user_active(True)
self.scanner.set_paused(True) self.scanner.set_paused(True)
self.resume_scan_timer.start() self.resume_scan_timer.start()
def _resume_scanning(self): def _resume_scanning(self):
"""Resumes scanning after interaction pause.""" """Resumes scanning after interaction pause."""
if self.scanner: if self.scanner:
self.thread_pool_manager.set_user_active(False)
# Prioritize currently visible images # Prioritize currently visible images
visible_paths = self.get_visible_image_paths() visible_paths = self.get_visible_image_paths()
self.scanner.prioritize(visible_paths) self.scanner.prioritize(visible_paths)
@@ -1466,6 +1486,10 @@ class MainWindow(QMainWindow):
if "geometry" in mw_data: if "geometry" in mw_data:
g = mw_data["geometry"] g = mw_data["geometry"]
self.setGeometry(g["x"], g["y"], g["w"], g["h"]) 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: if "window_state" in mw_data:
self.restoreState( self.restoreState(
QByteArray.fromBase64(mw_data["window_state"].encode())) QByteArray.fromBase64(mw_data["window_state"].encode()))
@@ -1521,7 +1545,7 @@ class MainWindow(QMainWindow):
paths.append(d) paths.append(d)
self.start_scan([p.strip() for p in paths if p.strip() self.start_scan([p.strip() for p in paths if p.strip()
and os.path.exists(os.path.expanduser(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: if search_text:
self.search_input.setEditText(search_text) self.search_input.setEditText(search_text)
@@ -1643,6 +1667,11 @@ class MainWindow(QMainWindow):
if len(self.face_names_history) > new_max_faces: if len(self.face_names_history) > new_max_faces:
self.face_names_history = 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", new_bg_color = APP_CONFIG.get("thumbnails_bg_color",
constants.THUMBNAILS_BG_COLOR_DEFAULT) constants.THUMBNAILS_BG_COLOR_DEFAULT)
self.thumbnail_view.setStyleSheet(f"background-color: {new_bg_color};") self.thumbnail_view.setStyleSheet(f"background-color: {new_bg_color};")
@@ -1652,6 +1681,7 @@ class MainWindow(QMainWindow):
# Trigger a repaint to apply other color changes like filename color # Trigger a repaint to apply other color changes like filename color
self._apply_global_stylesheet() self._apply_global_stylesheet()
self.thread_pool_manager.update_default_thread_count()
self.thumbnail_view.updateGeometries() self.thumbnail_view.updateGeometries()
self.thumbnail_view.viewport().update() self.thumbnail_view.viewport().update()
@@ -1974,6 +2004,44 @@ class MainWindow(QMainWindow):
return False 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): def toggle_visibility(self):
"""Toggles the visibility of the main window, opening a viewer if needed.""" """Toggles the visibility of the main window, opening a viewer if needed."""
if self.isVisible(): if self.isVisible():
@@ -2247,7 +2315,7 @@ class MainWindow(QMainWindow):
w.load_and_fit_image() w.load_and_fit_image()
def start_scan(self, paths, sync_viewer=False, active_viewer=None, def start_scan(self, paths, sync_viewer=False, active_viewer=None,
select_path=None): select_paths=None):
""" """
Starts a new background scan for images. Starts a new background scan for images.
@@ -2255,7 +2323,7 @@ class MainWindow(QMainWindow):
paths (list): A list of file paths or directories to scan. paths (list): A list of file paths or directories to scan.
sync_viewer (bool): If True, avoids clearing the grid. sync_viewer (bool): If True, avoids clearing the grid.
active_viewer (ImageViewer): A viewer to sync with the scan results. 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.is_cleaning = True
self._suppress_updates = True self._suppress_updates = True
@@ -2290,6 +2358,7 @@ class MainWindow(QMainWindow):
self.is_cleaning = False self.is_cleaning = False
self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all, self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all,
thread_pool_manager=self.thread_pool_manager,
viewers=self.viewers) viewers=self.viewers)
if self._is_loading_all: if self._is_loading_all:
self.scanner.set_auto_load(True) self.scanner.set_auto_load(True)
@@ -2299,11 +2368,11 @@ class MainWindow(QMainWindow):
self.scanner.progress_msg.connect(self.status_lbl.setText) self.scanner.progress_msg.connect(self.status_lbl.setText)
self.scanner.more_files_available.connect(self.more_files_available) self.scanner.more_files_available.connect(self.more_files_available)
self.scanner.finished_scan.connect( 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.scanner.start()
self._scan_all = False 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.""" """Slot for when the image scanner has finished."""
self._suppress_updates = False self._suppress_updates = False
self._scanner_last_index = self._scanner_total_files self._scanner_last_index = self._scanner_total_files
@@ -2331,8 +2400,8 @@ class MainWindow(QMainWindow):
self.update_tag_edit_widget() self.update_tag_edit_widget()
# Select a specific path if requested (e.g., after layout restore) # Select a specific path if requested (e.g., after layout restore)
if select_path: if select_paths:
self.find_and_select_path(select_path) self.restore_selection(select_paths)
# Final rebuild to ensure all items are correctly placed # Final rebuild to ensure all items are correctly placed
if self.rebuild_timer.isActive(): if self.rebuild_timer.isActive():
@@ -2573,7 +2642,7 @@ class MainWindow(QMainWindow):
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
# Preserve selection # Preserve selection
selected_path = self.get_current_selected_path() selected_paths = self.get_selected_paths()
mode = self.sort_combo.currentText() mode = self.sort_combo.currentText()
rev = "" in mode rev = "" in mode
@@ -2628,7 +2697,7 @@ class MainWindow(QMainWindow):
self._suppress_updates = False self._suppress_updates = False
self.apply_filters() self.apply_filters()
self.thumbnail_view.setUpdatesEnabled(True) self.thumbnail_view.setUpdatesEnabled(True)
self.find_and_select_path(selected_path) self.restore_selection(selected_paths)
if self.main_dock.isVisible() and \ if self.main_dock.isVisible() and \
self.tags_tabs.currentWidget() == self.filter_widget: self.tags_tabs.currentWidget() == self.filter_widget:
@@ -2782,7 +2851,7 @@ class MainWindow(QMainWindow):
self._suppress_updates = False self._suppress_updates = False
self.apply_filters() self.apply_filters()
self.thumbnail_view.setUpdatesEnabled(True) self.thumbnail_view.setUpdatesEnabled(True)
self.find_and_select_path(selected_path) self.restore_selection(selected_paths)
if self.main_dock.isVisible() and \ if self.main_dock.isVisible() and \
self.tags_tabs.currentWidget() == self.filter_widget: self.tags_tabs.currentWidget() == self.filter_widget:
@@ -3064,7 +3133,7 @@ class MainWindow(QMainWindow):
return return
# Preserve selection # Preserve selection
selected_path = self.get_current_selected_path() selected_paths = self.get_selected_paths()
# Gather filter criteria from the UI # Gather filter criteria from the UI
include_tags = set() include_tags = set()
@@ -3112,8 +3181,8 @@ class MainWindow(QMainWindow):
self.filtered_count_lbl.setText(UITexts.FILTERED_ZERO) self.filtered_count_lbl.setText(UITexts.FILTERED_ZERO)
# Restore selection if it's still visible # Restore selection if it's still visible
if selected_path: if selected_paths:
self.find_and_select_path(selected_path) self.restore_selection(selected_paths)
# Sync open viewers with the new list of visible paths # Sync open viewers with the new list of visible paths
visible_paths = self.get_visible_image_paths() visible_paths = self.get_visible_image_paths()
@@ -3163,13 +3232,18 @@ class MainWindow(QMainWindow):
target_list.append(current_path) target_list.append(current_path)
new_index = len(target_list) - 1 new_index = len(target_list) - 1
w.controller.update_list( # Check if we are preserving the image to pass correct metadata
target_list, new_index if new_index != -1 else None) 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( w.controller.update_list(
target_list, new_index if new_index != -1 else None, 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: if not w._is_persistent and not w.controller.image_list:
w.close() w.close()
continue continue
@@ -3449,7 +3523,8 @@ class MainWindow(QMainWindow):
if not paths: if not paths:
return return
self.thumbnail_generator = ThumbnailGenerator(self.cache, paths, size) self.thumbnail_generator = ThumbnailGenerator(
self.cache, paths, size, self.thread_pool_manager)
self.thumbnail_generator.generation_complete.connect( self.thumbnail_generator.generation_complete.connect(
self.on_high_res_generation_finished) self.on_high_res_generation_finished)
self.thumbnail_generator.progress.connect( self.thumbnail_generator.progress.connect(
@@ -3468,16 +3543,16 @@ class MainWindow(QMainWindow):
if not self.history: if not self.history:
return return
current_selection = self.get_current_selected_path() current_selection = self.get_selected_paths()
term = self.history[0] term = self.history[0]
if term.startswith("file:/"): if term.startswith("file:/"):
path = term[6:] path = term[6:]
if os.path.isfile(path): 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 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.""" """Processes a search term, file path, or layout directive."""
self.add_to_history(term) self.add_to_history(term)
self.update_search_input() self.update_search_input()
@@ -3529,7 +3604,7 @@ class MainWindow(QMainWindow):
else: else:
# If a directory or search term, start a scan # 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): def update_search_input(self):
"""Updates the search input combo box with history items and icons.""" """Updates the search input combo box with history items and icons."""
@@ -3607,6 +3682,7 @@ class MainWindow(QMainWindow):
self.tags_tabs.setCurrentIndex(d["active_dock_tab"]) self.tags_tabs.setCurrentIndex(d["active_dock_tab"])
self.face_names_history = d.get("face_names_history", []) self.face_names_history = d.get("face_names_history", [])
self.pet_names_history = d.get("pet_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.object_names_history = d.get("object_names_history", [])
self.landmark_names_history = d.get("landmark_names_history", []) self.landmark_names_history = d.get("landmark_names_history", [])
@@ -3674,6 +3750,7 @@ class MainWindow(QMainWindow):
APP_CONFIG["active_dock_tab"] = self.tags_tabs.currentIndex() APP_CONFIG["active_dock_tab"] = self.tags_tabs.currentIndex()
APP_CONFIG["face_names_history"] = self.face_names_history APP_CONFIG["face_names_history"] = self.face_names_history
APP_CONFIG["pet_names_history"] = self.pet_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["object_names_history"] = self.object_names_history
APP_CONFIG["landmark_names_history"] = self.landmark_names_history APP_CONFIG["landmark_names_history"] = self.landmark_names_history
APP_CONFIG["mru_tags"] = list(self.mru_tags) APP_CONFIG["mru_tags"] = list(self.mru_tags)
@@ -3914,7 +3991,8 @@ class MainWindow(QMainWindow):
# Create a ThumbnailGenerator to regenerate the thumbnail # Create a ThumbnailGenerator to regenerate the thumbnail
size = self._get_tier_for_size(self.current_thumb_size) size = self._get_tier_for_size(self.current_thumb_size)
self.thumbnail_generator = ThumbnailGenerator(self.cache, [path], size) self.thumbnail_generator = ThumbnailGenerator(
self.cache, [path], size, self.thread_pool_manager)
self.thumbnail_generator.generation_complete.connect( self.thumbnail_generator.generation_complete.connect(
self.on_high_res_generation_finished) self.on_high_res_generation_finished)
self.thumbnail_generator.progress.connect( self.thumbnail_generator.progress.connect(
@@ -4293,6 +4371,7 @@ def main():
# Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB # Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB
QPixmapCache.setCacheLimit(102400) QPixmapCache.setCacheLimit(102400)
thread_pool_manager = ThreadPoolManager()
cache = ThumbnailCache() cache = ThumbnailCache()
args = [a for a in sys.argv[1:] if a != "--x11"] args = [a for a in sys.argv[1:] if a != "--x11"]
@@ -4301,7 +4380,7 @@ def main():
if path.startswith("file:/"): if path.startswith("file:/"):
path = path[6:] path = path[6:]
win = MainWindow(cache, args) win = MainWindow(cache, args, thread_pool_manager)
shortcut_controller = AppShortcutController(win) shortcut_controller = AppShortcutController(win)
win.shortcut_controller = shortcut_controller win.shortcut_controller = shortcut_controller
app.installEventFilter(shortcut_controller) app.installEventFilter(shortcut_controller)

View File

@@ -1,5 +1,23 @@
v0.9.11 - v0.9.11 -
· Hacer que el image viewer standalone admita múltiles sort · Filmstrip fixed
· Añadida una nueva área llamada Body.
· Refactorizaciones, optimizaciones y cambios a saco.
· Image viewer tiene comparisonb
Implement a bulk rename feature for the selected pet or face tags.
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
Add a `shutdown` signal or method to `ScannerWorker` to allow cleaner cancellation of long-running tasks like `generate_thumbnail`.
Implement a mechanism to dynamically adjust the thread pool size based on system load or user activity.
Implement a mechanism to monitor system CPU load and adjust the thread pool size accordingly.
Refactor the `ThreadPoolManager` to be a QObject and emit signals when the thread count changes.
Implement a "Comparison" mode to view 2 or 4 images side-by-side in the viewer.
· 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. · 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 · 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 · Mejorar el menú Open, con nombres correctos e iconos adecuados
@@ -12,12 +30,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? · 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. · 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? ¿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. 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. 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, "scan_full_on_start": True,
"person_tags": "", "person_tags": "",
"generation_threads": 4, "generation_threads": 4,
"search_engine": "Native" "search_engine": ""
} }
# --- IMAGE VIEWER DEFAULTS --- # --- IMAGE VIEWER DEFAULTS ---
@@ -167,6 +167,8 @@ if importlib.util.find_spec("mediapipe") is not None:
pass pass
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
HAVE_BAGHEERASEARCH_LIB = True
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR, MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR,
"blaze_face_short_range.tflite") "blaze_face_short_range.tflite")
MEDIAPIPE_FACE_MODEL_URL = ( MEDIAPIPE_FACE_MODEL_URL = (
@@ -193,6 +195,10 @@ AVAILABLE_PET_ENGINES = []
if HAVE_MEDIAPIPE: if HAVE_MEDIAPIPE:
AVAILABLE_PET_ENGINES.append("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. # 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_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 DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None
@@ -205,6 +211,7 @@ PET_DETECTION_ENGINE = APP_CONFIG.get("pet_detection_engine",
DEFAULT_PET_ENGINE) DEFAULT_PET_ENGINE)
DEFAULT_PET_BOX_COLOR = "#98FB98" # PaleGreen DEFAULT_PET_BOX_COLOR = "#98FB98" # PaleGreen
DEFAULT_BODY_BOX_COLOR = "#FF4500" # OrangeRed
DEFAULT_OBJECT_BOX_COLOR = "#FFD700" # Gold DEFAULT_OBJECT_BOX_COLOR = "#FFD700" # Gold
DEFAULT_LANDMARK_BOX_COLOR = "#00BFFF" # DeepSkyBlue DEFAULT_LANDMARK_BOX_COLOR = "#00BFFF" # DeepSkyBlue
# --- SHORTCUTS --- # --- SHORTCUTS ---
@@ -273,6 +280,7 @@ VIEWER_ACTIONS = {
"detect_faces": ("Detect Faces", "Actions"), "detect_faces": ("Detect Faces", "Actions"),
"detect_pets": ("Detect Pets", "Actions"), "detect_pets": ("Detect Pets", "Actions"),
"fast_tag": ("Quick Tags", "Actions"), "fast_tag": ("Quick Tags", "Actions"),
"detect_bodies": ("Detect Bodies", "Actions"),
"rotate_right": ("Rotate Right", "Transform"), "rotate_right": ("Rotate Right", "Transform"),
"rotate_left": ("Rotate Left", "Transform"), "rotate_left": ("Rotate Left", "Transform"),
"zoom_in": ("Zoom In", "Transform"), "zoom_in": ("Zoom In", "Transform"),
@@ -283,6 +291,10 @@ VIEWER_ACTIONS = {
"toggle_visibility": ("Show/Hide Main Window", "Window"), "toggle_visibility": ("Show/Hide Main Window", "Window"),
"toggle_crop": ("Toggle Crop Mode", "Edit"), "toggle_crop": ("Toggle Crop Mode", "Edit"),
"save_crop": ("Save Cropped Image", "File"), "save_crop": ("Save Cropped Image", "File"),
"compare_1": ("Single View", "View"),
"compare_2": ("Compare 2 Images", "View"),
"compare_4": ("Compare 4 Images", "View"),
"link_panes": ("Link Panes", "View"),
} }
DEFAULT_VIEWER_SHORTCUTS = { DEFAULT_VIEWER_SHORTCUTS = {
@@ -299,6 +311,7 @@ DEFAULT_VIEWER_SHORTCUTS = {
"fullscreen": (Qt.Key_F11, Qt.NoModifier), "fullscreen": (Qt.Key_F11, Qt.NoModifier),
"detect_faces": (Qt.Key_F, Qt.NoModifier), "detect_faces": (Qt.Key_F, Qt.NoModifier),
"detect_pets": (Qt.Key_P, Qt.NoModifier), "detect_pets": (Qt.Key_P, Qt.NoModifier),
"detect_bodies": (Qt.Key_B, Qt.NoModifier),
"fast_tag": (Qt.Key_T, Qt.NoModifier), "fast_tag": (Qt.Key_T, Qt.NoModifier),
"rotate_right": (Qt.Key_Plus, Qt.ControlModifier), "rotate_right": (Qt.Key_Plus, Qt.ControlModifier),
"rotate_left": (Qt.Key_Minus, Qt.ControlModifier), "rotate_left": (Qt.Key_Minus, Qt.ControlModifier),
@@ -310,6 +323,10 @@ DEFAULT_VIEWER_SHORTCUTS = {
"toggle_visibility": (Qt.Key_H, Qt.ControlModifier), "toggle_visibility": (Qt.Key_H, Qt.ControlModifier),
"toggle_crop": (Qt.Key_C, Qt.NoModifier), "toggle_crop": (Qt.Key_C, Qt.NoModifier),
"save_crop": (Qt.Key_S, Qt.ControlModifier), "save_crop": (Qt.Key_S, Qt.ControlModifier),
"compare_1": (Qt.Key_1, Qt.AltModifier),
"compare_2": (Qt.Key_2, Qt.AltModifier),
"compare_4": (Qt.Key_4, Qt.AltModifier),
"link_panes": (Qt.Key_L, Qt.AltModifier),
} }
@@ -395,13 +412,15 @@ _UI_TEXTS = {
"RENAME_VIEWER_ERROR_TEXT": "Could not rename file: {}", "RENAME_VIEWER_ERROR_TEXT": "Could not rename file: {}",
"ADD_FACE_TITLE": "Add Face", "ADD_FACE_TITLE": "Add Face",
"ADD_PET_TITLE": "Add Pet", "ADD_PET_TITLE": "Add Pet",
"ADD_BODY_TITLE": "Add Body",
"ADD_OBJECT_TITLE": "Add Object", "ADD_OBJECT_TITLE": "Add Object",
"ADD_LANDMARK_TITLE": "Add Landmark", "ADD_LANDMARK_TITLE": "Add Landmark",
"ADD_FACE_LABEL": "Name:", "ADD_FACE_LABEL": "Name:",
"ADD_PET_LABEL": "Name:", "ADD_PET_LABEL": "Name:",
"ADD_BODY_LABEL": "Name:",
"ADD_OBJECT_LABEL": "Name:", "ADD_OBJECT_LABEL": "Name:",
"ADD_LANDMARK_LABEL": "Name:", "ADD_LANDMARK_LABEL": "Name:",
"DELETE_FACE": "Delete Face or area", "DELETE_AREA_TITLE": "Delete area",
"CREATE_TAG_TITLE": "Create Tag", "CREATE_TAG_TITLE": "Create Tag",
"CREATE_TAG_TEXT": "The tag for '{}' does not exist. Do you want to create a " "CREATE_TAG_TEXT": "The tag for '{}' does not exist. Do you want to create a "
"new one?", "new one?",
@@ -409,6 +428,8 @@ _UI_TEXTS = {
"NEW_PERSON_TAG_TEXT": "Enter the full path for the tag:", "NEW_PERSON_TAG_TEXT": "Enter the full path for the tag:",
"NEW_PET_TAG_TITLE": "New Pet Tag", "NEW_PET_TAG_TITLE": "New Pet Tag",
"NEW_PET_TAG_TEXT": "Enter the full path for the 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_TITLE": "New Object Tag",
"NEW_OBJECT_TAG_TEXT": "Enter the full path for the tag:", "NEW_OBJECT_TAG_TEXT": "Enter the full path for the tag:",
"NEW_LANDMARK_TAG_TITLE": "New Landmark Tag", "NEW_LANDMARK_TAG_TITLE": "New Landmark Tag",
@@ -418,10 +439,11 @@ _UI_TEXTS = {
"one:", "one:",
"FACE_NAME_TOOLTIP": "Type a name or select from history.", "FACE_NAME_TOOLTIP": "Type a name or select from history.",
"CLEAR_TEXT_TOOLTIP": "Clear text field", "CLEAR_TEXT_TOOLTIP": "Clear text field",
"RENAME_FACE_TITLE": "Rename Face or area", "RENAME_AREA_TITLE": "Rename area",
"SHOW_FACES": "Show Faces && other areas", "SHOW_FACES": "Show Faces && other areas",
"DETECT_FACES": "Detect Face", "DETECT_FACES": "Detect Face",
"DETECT_PETS": "Detect Pets", "DETECT_PETS": "Detect Pets",
"DETECT_BODIES": "Detect Bodies",
"NO_FACE_LIBS": "No face detection libraries found. Install 'mediapipe' or " "NO_FACE_LIBS": "No face detection libraries found. Install 'mediapipe' or "
"'face_recognition'.", "'face_recognition'.",
"THUMBNAIL_NO_NAME": "No name", "THUMBNAIL_NO_NAME": "No name",
@@ -441,7 +463,7 @@ _UI_TEXTS = {
"MENU_SHOW_HISTORY": "Show History", "MENU_SHOW_HISTORY": "Show History",
"MENU_SETTINGS": "Settings", "MENU_SETTINGS": "Settings",
"SETTINGS_GROUP_SCANNER": "Scanner", "SETTINGS_GROUP_SCANNER": "Scanner",
"SETTINGS_GROUP_FACES": "Faces && areas", "SETTINGS_GROUP_AREAS": "Areas",
"SETTINGS_GROUP_THUMBNAILS": "Thumbnails", "SETTINGS_GROUP_THUMBNAILS": "Thumbnails",
"SETTINGS_GROUP_VIEWER": "Image Viewer", "SETTINGS_GROUP_VIEWER": "Image Viewer",
"SETTINGS_PERSON_TAGS_LABEL": "Person tags:", "SETTINGS_PERSON_TAGS_LABEL": "Person tags:",
@@ -460,8 +482,19 @@ _UI_TEXTS = {
"to remember.", "to remember.",
"TYPE_FACE": "Face", "TYPE_FACE": "Face",
"TYPE_PET": "Pet", "TYPE_PET": "Pet",
"TYPE_BODY": "Body",
"TYPE_OBJECT": "Object", "TYPE_OBJECT": "Object",
"TYPE_LANDMARK": "Landmark", "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_TAGS_LABEL": "Object tags:",
"SETTINGS_OBJECT_ENGINE_LABEL": "Object Detection Engine:", "SETTINGS_OBJECT_ENGINE_LABEL": "Object Detection Engine:",
"SETTINGS_OBJECT_COLOR_LABEL": "Object box color:", "SETTINGS_OBJECT_COLOR_LABEL": "Object box color:",
@@ -493,12 +526,15 @@ _UI_TEXTS = {
"SETTINGS_THUMBS_RATING_COLOR_LABEL": "Thumbnails rating color:", "SETTINGS_THUMBS_RATING_COLOR_LABEL": "Thumbnails rating color:",
"SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Thumbnails filename font size:", "SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Thumbnails filename font size:",
"SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL": "Thumbnails tags 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_MAX_LEVEL_LABEL": "Scan Max Level:",
"SETTINGS_SCAN_BATCH_SIZE_LABEL": "Scan Batch Size:", "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Scan Batch Size:",
"SETTINGS_SCAN_FULL_ON_START_LABEL": "Scan Full On Start:", "SETTINGS_SCAN_FULL_ON_START_LABEL": "Scan Full On Start:",
"SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "File search engine:", "SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "File search engine:",
"SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Engine to use for finding files. " "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 " "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Maximum directory depth to scan "
"recursively.", "recursively.",
"SETTINGS_SCAN_BATCH_SIZE_TOOLTIP": "Number of images to load in each batch.", "SETTINGS_SCAN_BATCH_SIZE_TOOLTIP": "Number of images to load in each batch.",
@@ -524,8 +560,8 @@ _UI_TEXTS = {
"SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP": "Font size for filenames in " "SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP": "Font size for filenames in "
"thumbnails.", "thumbnails.",
"SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Font size for tags in thumbnails.", "SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Font size for tags in thumbnails.",
"SEARCH_ENGINE_NATIVE": "Native", "SEARCH_ENGINE_NATIVE": "Bagheera",
"SEARCH_ENGINE_BALOO": "baloosearch", "SEARCH_ENGINE_BALOO": "Baloo",
"SETTINGS_VIEWER_WHEEL_SPEED_LABEL": "Viewer mouse wheel speed:", "SETTINGS_VIEWER_WHEEL_SPEED_LABEL": "Viewer mouse wheel speed:",
"SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Filename lines:", "SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Filename lines:",
"SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Number of lines for the filename " "SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Number of lines for the filename "
@@ -707,6 +743,11 @@ _UI_TEXTS = {
"VIEWER_MENU_CROP": "Crop Mode", "VIEWER_MENU_CROP": "Crop Mode",
"VIEWER_MENU_SAVE_CROP": "Save Selection...", "VIEWER_MENU_SAVE_CROP": "Save Selection...",
"SAVE_CROP_TITLE": "Save Cropped Image", "SAVE_CROP_TITLE": "Save Cropped Image",
"VIEWER_MENU_COMPARE": "Comparison Mode",
"VIEWER_MENU_COMPARE_1": "Single View",
"VIEWER_MENU_COMPARE_2": "2 Images",
"VIEWER_MENU_COMPARE_4": "4 Images",
"VIEWER_MENU_LINK_PANES": "Link Panes",
"SAVE_CROP_FILTER": "Images (*.jpg *.jpeg *.png *.bmp *.webp)", "SAVE_CROP_FILTER": "Images (*.jpg *.jpeg *.png *.bmp *.webp)",
"SLIDESHOW_INTERVAL_TITLE": "Slideshow Interval", "SLIDESHOW_INTERVAL_TITLE": "Slideshow Interval",
"SLIDESHOW_INTERVAL_TEXT": "Seconds:", "SLIDESHOW_INTERVAL_TEXT": "Seconds:",
@@ -801,19 +842,23 @@ _UI_TEXTS = {
"RENAME_VIEWER_ERROR_TEXT": "No se pudo renombrar el archivo: {}", "RENAME_VIEWER_ERROR_TEXT": "No se pudo renombrar el archivo: {}",
"ADD_FACE_TITLE": "Añadir Rostro", "ADD_FACE_TITLE": "Añadir Rostro",
"ADD_PET_TITLE": "Añadir Mascota", "ADD_PET_TITLE": "Añadir Mascota",
"ADD_BODY_TITLE": "Añadir Cuerpo",
"ADD_OBJECT_TITLE": "Añadir Objeto", "ADD_OBJECT_TITLE": "Añadir Objeto",
"ADD_LANDMARK_TITLE": "Añadir Lugar", "ADD_LANDMARK_TITLE": "Añadir Lugar",
"ADD_FACE_LABEL": "Nombre:", "ADD_FACE_LABEL": "Nombre:",
"ADD_PET_LABEL": "Nombre:", "ADD_PET_LABEL": "Nombre:",
"ADD_BODY_LABEL": "Nombre:",
"ADD_OBJECT_LABEL": "Nombre:", "ADD_OBJECT_LABEL": "Nombre:",
"ADD_LANDMARK_LABEL": "Nombre:", "ADD_LANDMARK_LABEL": "Nombre:",
"DELETE_FACE": "Eliminar Rostro o área", "DELETE_AREA_TITLE": "Eliminar área",
"CREATE_TAG_TITLE": "Crear Etiqueta", "CREATE_TAG_TITLE": "Crear Etiqueta",
"CREATE_TAG_TEXT": "La etiqueta para '{}' no existe. ¿Deseas crear una nueva?", "CREATE_TAG_TEXT": "La etiqueta para '{}' no existe. ¿Deseas crear una nueva?",
"NEW_PERSON_TAG_TITLE": "Nueva Etiqueta de Persona", "NEW_PERSON_TAG_TITLE": "Nueva Etiqueta de Persona",
"NEW_PERSON_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", "NEW_PERSON_TAG_TEXT": "Introduce la ruta completa de la etiqueta:",
"NEW_PET_TAG_TITLE": "Nueva Etiqueta de Mascota", "NEW_PET_TAG_TITLE": "Nueva Etiqueta de Mascota",
"NEW_PET_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", "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_TITLE": "Nueva Etiqueta de Objeto",
"NEW_OBJECT_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", "NEW_OBJECT_TAG_TEXT": "Introduce la ruta completa de la etiqueta:",
"NEW_LANDMARK_TAG_TITLE": "Nueva Etiqueta de Lugar", "NEW_LANDMARK_TAG_TITLE": "Nueva Etiqueta de Lugar",
@@ -823,10 +868,11 @@ _UI_TEXTS = {
"selecciona la correcta:", "selecciona la correcta:",
"FACE_NAME_TOOLTIP": "Escribe un nombre o selecciónalo del historial.", "FACE_NAME_TOOLTIP": "Escribe un nombre o selecciónalo del historial.",
"CLEAR_TEXT_TOOLTIP": "Limpiar el campo de texto", "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", "SHOW_FACES": "Mostrar Rostros y otras áreas",
"DETECT_FACES": "Detectar Rostros", "DETECT_FACES": "Detectar Rostros",
"DETECT_PETS": "Detectar Mascotas", "DETECT_PETS": "Detectar Mascotas",
"DETECT_BODIES": "Detectar Cuerpos",
"NO_FACE_LIBS": "No se encontraron librerías de detección de rostros. Instale " "NO_FACE_LIBS": "No se encontraron librerías de detección de rostros. Instale "
"'mediapipe' o 'face_recognition'.", "'mediapipe' o 'face_recognition'.",
"THUMBNAIL_NO_NAME": "Sin nombre", "THUMBNAIL_NO_NAME": "Sin nombre",
@@ -846,7 +892,7 @@ _UI_TEXTS = {
"MENU_SHOW_HISTORY": "Mostrar Historial", "MENU_SHOW_HISTORY": "Mostrar Historial",
"MENU_SETTINGS": "Opciones", "MENU_SETTINGS": "Opciones",
"SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_FACES": "Rostros y áreas", "SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
"SETTINGS_GROUP_VIEWER": "Visor de Imágenes", "SETTINGS_GROUP_VIEWER": "Visor de Imágenes",
"SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persona:", "SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persona:",
@@ -867,8 +913,21 @@ _UI_TEXTS = {
"usados recientemente para recordar.", "usados recientemente para recordar.",
"TYPE_FACE": "Cara", "TYPE_FACE": "Cara",
"TYPE_PET": "Mascota", "TYPE_PET": "Mascota",
"TYPE_BODY": "Cuerpo",
"TYPE_OBJECT": "Objeto", "TYPE_OBJECT": "Objeto",
"TYPE_LANDMARK": "Lugar", "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_TAGS_LABEL": "Etiquetas de objeto:",
"SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de objetos:", "SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de objetos:",
"SETTINGS_OBJECT_COLOR_LABEL": "Color del recuadro de objeto:", "SETTINGS_OBJECT_COLOR_LABEL": "Color del recuadro de objeto:",
@@ -906,8 +965,8 @@ _UI_TEXTS = {
"SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño de Lote de Escaneo:", "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_LABEL": "Motor de búsqueda de archivos:",
"SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar archivos. " "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar archivos. "
"'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa el commando de" "'Bagheera' usa la librería de BagheeraSearch. 'Baloo0 usa el commando "
"KDE Baloo.", "'baloosearch'",
"SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo al Inicio:", "SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo al Inicio:",
"SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidad máxima de directorio para " "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidad máxima de directorio para "
"escanear recursivamente.", "escanear recursivamente.",
@@ -1118,6 +1177,11 @@ _UI_TEXTS = {
"VIEWER_MENU_TAGS": "Etiquetas rápidas", "VIEWER_MENU_TAGS": "Etiquetas rápidas",
"VIEWER_MENU_CROP": "Modo Recorte", "VIEWER_MENU_CROP": "Modo Recorte",
"VIEWER_MENU_SAVE_CROP": "Guardar Selección...", "VIEWER_MENU_SAVE_CROP": "Guardar Selección...",
"VIEWER_MENU_COMPARE": "Modo Comparación",
"VIEWER_MENU_COMPARE_1": "Vista Única",
"VIEWER_MENU_COMPARE_2": "2 Imágenes",
"VIEWER_MENU_COMPARE_4": "4 Imágenes",
"VIEWER_MENU_LINK_PANES": "Vincular Paneles",
"SAVE_CROP_TITLE": "Guardar Imagen Recortada", "SAVE_CROP_TITLE": "Guardar Imagen Recortada",
"SAVE_CROP_FILTER": "Imágenes (*.jpg *.jpeg *.png *.bmp *.webp)", "SAVE_CROP_FILTER": "Imágenes (*.jpg *.jpeg *.png *.bmp *.webp)",
"SLIDESHOW_INTERVAL_TITLE": "Intervalo de Presentación", "SLIDESHOW_INTERVAL_TITLE": "Intervalo de Presentación",
@@ -1213,19 +1277,23 @@ _UI_TEXTS = {
"RENAME_VIEWER_ERROR_TEXT": "Non se puido renomear o ficheiro: {}", "RENAME_VIEWER_ERROR_TEXT": "Non se puido renomear o ficheiro: {}",
"ADD_FACE_TITLE": "Engadir Rostro", "ADD_FACE_TITLE": "Engadir Rostro",
"ADD_PET_TITLE": "Engadir Mascota", "ADD_PET_TITLE": "Engadir Mascota",
"ADD_BODY_TITLE": "Engadir Corpo",
"ADD_OBJECT_TITLE": "Engadir Obxecto", "ADD_OBJECT_TITLE": "Engadir Obxecto",
"ADD_LANDMARK_TITLE": "Engadir Lugar", "ADD_LANDMARK_TITLE": "Engadir Lugar",
"ADD_FACE_LABEL": "Nome:", "ADD_FACE_LABEL": "Nome:",
"ADD_PET_LABEL": "Nome:", "ADD_PET_LABEL": "Nome:",
"ADD_BODY_LABEL": "Nome:",
"ADD_OBJECT_LABEL": "Nome:", "ADD_OBJECT_LABEL": "Nome:",
"ADD_LANDMARK_LABEL": "Nome:", "ADD_LANDMARK_LABEL": "Nome:",
"DELETE_FACE": "Eliminar Rostro ou área", "DELETE_AREA_TITLE": "Eliminar área",
"CREATE_TAG_TITLE": "Crear Etiqueta", "CREATE_TAG_TITLE": "Crear Etiqueta",
"CREATE_TAG_TEXT": "A etiqueta para '{}' non existe. Desexas crear unha nova?", "CREATE_TAG_TEXT": "A etiqueta para '{}' non existe. Desexas crear unha nova?",
"NEW_PERSON_TAG_TITLE": "Nova Etiqueta de Persoa", "NEW_PERSON_TAG_TITLE": "Nova Etiqueta de Persoa",
"NEW_PERSON_TAG_TEXT": "Introduce a ruta completa da etiqueta:", "NEW_PERSON_TAG_TEXT": "Introduce a ruta completa da etiqueta:",
"NEW_PET_TAG_TITLE": "Nova Etiqueta de Mascota", "NEW_PET_TAG_TITLE": "Nova Etiqueta de Mascota",
"NEW_PET_TAG_TEXT": "Introduce a ruta completa da etiqueta:", "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_TITLE": "Nova Etiqueta de Obxecto",
"NEW_OBJECT_TAG_TEXT": "Introduce a ruta completa da etiqueta:", "NEW_OBJECT_TAG_TEXT": "Introduce a ruta completa da etiqueta:",
"NEW_LANDMARK_TAG_TITLE": "Nova Etiqueta de Lugar", "NEW_LANDMARK_TAG_TITLE": "Nova Etiqueta de Lugar",
@@ -1235,10 +1303,11 @@ _UI_TEXTS = {
"selecciona a correcta:", "selecciona a correcta:",
"FACE_NAME_TOOLTIP": "Escribe un nome ou selecciónao do historial.", "FACE_NAME_TOOLTIP": "Escribe un nome ou selecciónao do historial.",
"CLEAR_TEXT_TOOLTIP": "Limpar o campo de texto", "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", "SHOW_FACES": "Amosar Rostros e outras áreas",
"DETECT_FACES": "Detectar Rostros", "DETECT_FACES": "Detectar Rostros",
"DETECT_PETS": "Detectar Mascotas", "DETECT_PETS": "Detectar Mascotas",
"DETECT_BODIES": "Detectar Corpos",
"NO_FACE_LIBS": "Non se atoparon librarías de detección de rostros. Instale " "NO_FACE_LIBS": "Non se atoparon librarías de detección de rostros. Instale "
"'mediapipe' ou 'face_recognition'.", "'mediapipe' ou 'face_recognition'.",
"THUMBNAIL_NO_NAME": "Sen nome", "THUMBNAIL_NO_NAME": "Sen nome",
@@ -1259,7 +1328,7 @@ _UI_TEXTS = {
"MENU_SHOW_HISTORY": "Amosar Historial", "MENU_SHOW_HISTORY": "Amosar Historial",
"MENU_SETTINGS": "Opcións", "MENU_SETTINGS": "Opcións",
"SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_FACES": "Rostros e áreas", "SETTINGS_GROUP_AREAS": "´áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
"SETTINGS_GROUP_VIEWER": "Visor de Imaxes", "SETTINGS_GROUP_VIEWER": "Visor de Imaxes",
"SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persoa:", "SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persoa:",
@@ -1280,8 +1349,21 @@ _UI_TEXTS = {
"recentemente para lembrar.", "recentemente para lembrar.",
"TYPE_FACE": "Cara", "TYPE_FACE": "Cara",
"TYPE_PET": "Mascota", "TYPE_PET": "Mascota",
"TYPE_BODY": "Corpo",
"TYPE_OBJECT": "Obxecto", "TYPE_OBJECT": "Obxecto",
"TYPE_LANDMARK": "Lugar", "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_TAGS_LABEL": "Etiquetas de obxecto:",
"SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de obxectos:", "SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de obxectos:",
"SETTINGS_OBJECT_COLOR_LABEL": "Cor do cadro de obxecto:", "SETTINGS_OBJECT_COLOR_LABEL": "Cor do cadro de obxecto:",
@@ -1322,8 +1404,8 @@ _UI_TEXTS = {
"SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño do Lote de Escaneo:", "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_LABEL": "Motor de busca de ficheiros:",
"SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar ficheiros. " "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar ficheiros. "
"'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa o comando de " "'Bagheera' usa a libraría de BagheeraSearch. 'Baloo' usa o comando de "
"KDE Baloo.", "'baloosearch'.",
"SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo ao Inicio:", "SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo ao Inicio:",
"SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidade máxima de directorio para " "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidade máxima de directorio para "
"escanear recursivamente.", "escanear recursivamente.",
@@ -1354,8 +1436,8 @@ _UI_TEXTS = {
"ficheiro en miniaturas.", "ficheiro en miniaturas.",
"SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Tamaño de fonte para etiquetas en " "SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Tamaño de fonte para etiquetas en "
"miniaturas.", "miniaturas.",
"SEARCH_ENGINE_NATIVE": "Nativo", "SEARCH_ENGINE_NATIVE": "Bagheera",
"SEARCH_ENGINE_BALOO": "baloosearch", "SEARCH_ENGINE_BALOO": "Baloo",
"SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Liñas para nome de ficheiro:", "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 " "SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Número de liñas para o nome do "
"ficheiro debaixo da miniatura.", "ficheiro debaixo da miniatura.",
@@ -1533,6 +1615,11 @@ _UI_TEXTS = {
"VIEWER_MENU_TAGS": "Etiquetas rápidas", "VIEWER_MENU_TAGS": "Etiquetas rápidas",
"VIEWER_MENU_CROP": "Modo Recorte", "VIEWER_MENU_CROP": "Modo Recorte",
"VIEWER_MENU_SAVE_CROP": "Gardar Selección...", "VIEWER_MENU_SAVE_CROP": "Gardar Selección...",
"VIEWER_MENU_COMPARE": "Modo Comparación",
"VIEWER_MENU_COMPARE_1": "Vista Única",
"VIEWER_MENU_COMPARE_2": "2 Imaxes",
"VIEWER_MENU_COMPARE_4": "4 Imaxes",
"VIEWER_MENU_LINK_PANES": "Vincular Paneis",
"SAVE_CROP_TITLE": "Gardar Imaxe Recortada", "SAVE_CROP_TITLE": "Gardar Imaxe Recortada",
"SAVE_CROP_FILTER": "Imaxes (*.jpg *.jpeg *.png *.bmp *.webp)", "SAVE_CROP_FILTER": "Imaxes (*.jpg *.jpeg *.png *.bmp *.webp)",
"SLIDESHOW_INTERVAL_TITLE": "Intervalo da Presentación", "SLIDESHOW_INTERVAL_TITLE": "Intervalo da Presentación",

View File

@@ -11,16 +11,19 @@ Classes:
interacts with the ImagePreloader. interacts with the ImagePreloader.
""" """
import os import os
import logging
import math import math
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt
from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform
from xmpmanager import XmpManager from xmpmanager import XmpManager
from constants import ( 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_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, MEDIAPIPE_OBJECT_MODEL_PATH,
MEDIAPIPE_OBJECT_MODEL_URL, RATING_XATTR_NAME, XATTR_NAME, UITexts MEDIAPIPE_OBJECT_MODEL_URL, RATING_XATTR_NAME, XATTR_NAME, UITexts
) )
from metadatamanager import XattrManager from metadatamanager import XattrManager, load_common_metadata
logger = logging.getLogger(__name__)
class ImagePreloader(QThread): class ImagePreloader(QThread):
@@ -78,21 +81,6 @@ class ImagePreloader(QThread):
self.mutex.unlock() self.mutex.unlock()
self.wait() 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): def run(self):
""" """
The main execution loop for the thread. The main execution loop for the thread.
@@ -124,10 +112,10 @@ class ImagePreloader(QThread):
img = reader.read() img = reader.read()
if not img.isNull(): if not img.isNull():
# Load tags and rating here to avoid re-reading in main thread # 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) self.image_ready.emit(idx, path, img, tags, rating)
except Exception: except Exception as e:
pass logger.warning(f"ImagePreloader failed to load {path}: {e}")
class ImageController(QObject): class ImageController(QObject):
@@ -157,6 +145,8 @@ class ImageController(QObject):
self.faces = [] self.faces = []
self._current_tags = initial_tags if initial_tags is not None else [] self._current_tags = initial_tags if initial_tags is not None else []
self._current_rating = initial_rating self._current_rating = initial_rating
self._current_metadata_path = None
self._loaded_path = None
self.show_faces = False self.show_faces = False
# Preloading # Preloading
@@ -169,6 +159,12 @@ class ImageController(QObject):
def cleanup(self): def cleanup(self):
"""Stops the background preloader thread.""" """Stops the background preloader thread."""
self.preloader.stop() self.preloader.stop()
self._current_metadata_path = None
self._loaded_path = None
self._current_tags = []
self._current_rating = 0
self._cached_next_image = None
self._cached_next_index = -1
def _trigger_preload(self): def _trigger_preload(self):
"""Identifies the next image in the list and asks the preloader to load it.""" """Identifies the next image in the list and asks the preloader to load it."""
@@ -219,16 +215,26 @@ class ImageController(QObject):
Loads the current image into the controller's main pixmap. Loads the current image into the controller's main pixmap.
""" """
path = self.get_current_path() 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():
# 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._trigger_preload()
return True, False
self.pixmap_original = QPixmap() self.pixmap_original = QPixmap()
self._loaded_path = None
self.rotation = 0 self.rotation = 0
self.flip_h = False self.flip_h = False
self._current_tags = []
self._current_rating = 0
self.flip_v = False self.flip_v = False
self.faces = [] self.faces = []
if not path: if not path:
return False return False, False
# Check cache # Check cache
if self.index == self._cached_next_index and self._cached_next_image: if self.index == self._cached_next_index and self._cached_next_image:
@@ -236,6 +242,7 @@ class ImageController(QObject):
# Clear cache to free memory as we have consumed the image # Clear cache to free memory as we have consumed the image
self._current_tags = self._cached_next_tags self._current_tags = self._cached_next_tags
self._current_rating = self._cached_next_rating self._current_rating = self._cached_next_rating
self._current_metadata_path = path
self._cached_next_image = None self._cached_next_image = None
self._cached_next_index = -1 self._cached_next_index = -1
self._cached_next_tags = None self._cached_next_tags = None
@@ -246,15 +253,18 @@ class ImageController(QObject):
image = reader.read() image = reader.read()
if image.isNull(): if image.isNull():
self._trigger_preload() self._trigger_preload()
return False return False, False
self.pixmap_original = QPixmap.fromImage(image) self.pixmap_original = QPixmap.fromImage(image)
# Load tags and rating if not from cache # Load tags and rating if not already set for this path
self._current_tags, self._current_rating = self._load_metadata(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.load_faces()
self._trigger_preload() self._trigger_preload()
return True return True, True
def load_faces(self): def load_faces(self):
""" """
@@ -422,6 +432,38 @@ class ImageController(QObject):
face_data['h'] = h face_data['h'] = h
return face_data 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): def _detect_faces_face_recognition(self, path):
"""Detects faces using the 'face_recognition' library.""" """Detects faces using the 'face_recognition' library."""
import face_recognition import face_recognition
@@ -433,12 +475,9 @@ class ImageController(QObject):
for (top, right, bottom, left) in face_locations: for (top, right, bottom, left) in face_locations:
box_w = right - left box_w = right - left
box_h = bottom - top box_h = bottom - top
new_face = { validated_face = self._create_region_from_pixels(
'name': '', left, top, box_w, box_h, w, h, 'Face'
'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)
if validated_face: if validated_face:
new_faces.append(validated_face) new_faces.append(validated_face)
except Exception as e: except Exception as e:
@@ -484,15 +523,10 @@ class ImageController(QObject):
img_h, img_w = mp_image.height, mp_image.width img_h, img_w = mp_image.height, mp_image.width
for detection in detection_result.detections: for detection in detection_result.detections:
bbox = detection.bounding_box # This is in pixels bbox = detection.bounding_box # This is in pixels
new_face = { validated_face = self._create_region_from_pixels(
'name': '', bbox.origin_x, bbox.origin_y, bbox.width, bbox.height,
'x': (bbox.origin_x + bbox.width / 2) / img_w, img_w, img_h, 'Face'
'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)
if validated_face: if validated_face:
new_faces.append(validated_face) new_faces.append(validated_face)
@@ -500,19 +534,27 @@ class ImageController(QObject):
print(f"Error during MediaPipe detection: {e}") print(f"Error during MediaPipe detection: {e}")
return new_faces return new_faces
def _detect_pets_mediapipe(self, path): def _detect_objects_mediapipe(self, path, allowlist, max_results, region_type):
"""Detects pets using the 'mediapipe' library object detection.""" """
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 import mediapipe as mp
from mediapipe.tasks import python from mediapipe.tasks import python
from mediapipe.tasks.python import vision from mediapipe.tasks.python import vision
new_pets = [] new_regions = []
if not os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH): if not os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH):
print(f"MediaPipe model not found at: {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("Please download 'efficientdet_lite0.tflite' and place it there.")
print(f"URL: {MEDIAPIPE_OBJECT_MODEL_URL}") print(f"URL: {MEDIAPIPE_OBJECT_MODEL_URL}")
return new_pets return new_regions
try: try:
base_options = python.BaseOptions( base_options = python.BaseOptions(
@@ -520,8 +562,8 @@ class ImageController(QObject):
options = vision.ObjectDetectorOptions( options = vision.ObjectDetectorOptions(
base_options=base_options, base_options=base_options,
score_threshold=0.5, score_threshold=0.5,
max_results=5, max_results=max_results,
category_allowlist=["cat", "dog"]) # Detect cats and dogs category_allowlist=allowlist)
# Silence MediaPipe warnings (stderr) during initialization # Silence MediaPipe warnings (stderr) during initialization
stderr_fd = 2 stderr_fd = 2
@@ -542,21 +584,24 @@ class ImageController(QObject):
img_h, img_w = mp_image.height, mp_image.width img_h, img_w = mp_image.height, mp_image.width
for detection in detection_result.detections: for detection in detection_result.detections:
bbox = detection.bounding_box bbox = detection.bounding_box
new_pet = { validated_region = self._create_region_from_pixels(
'name': '', bbox.origin_x, bbox.origin_y, bbox.width, bbox.height,
'x': (bbox.origin_x + bbox.width / 2) / img_w, img_w, img_h, region_type
'y': (bbox.origin_y + bbox.height / 2) / img_h, )
'w': bbox.width / img_w, if validated_region:
'h': bbox.height / img_h, new_regions.append(validated_region)
'type': 'Pet'
}
validated_pet = self._clamp_and_validate_face(new_pet)
if validated_pet:
new_pets.append(validated_pet)
except Exception as e: except Exception as e:
print(f"Error during MediaPipe pet detection: {e}") print(f"Error during MediaPipe {region_type} detection: {e}")
return new_pets 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): def detect_faces(self):
""" """
@@ -615,6 +660,21 @@ class ImageController(QObject):
return [] 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): def get_display_pixmap(self):
""" """
Applies current transformations (rotation, zoom, flip) to the original Applies current transformations (rotation, zoom, flip) to the original
@@ -709,30 +769,27 @@ class ImageController(QObject):
elif self.index < 0: elif self.index < 0:
self.index = 0 self.index = 0
# Update current image metadata if provided # Update current image metadata
self._current_tags = current_image_tags \ if current_image_tags is not None:
if current_image_tags is not None else [] self._current_tags = current_image_tags
self._current_rating = current_image_rating 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_image = None
self._cached_next_index = -1 self._cached_next_index = -1
self._trigger_preload() self._trigger_preload()
self.list_updated.emit(self.index) 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): 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. Updates the list only if the old list is a subset of the new one.
@@ -749,8 +806,17 @@ class ImageController(QObject):
self.index = new_index self.index = new_index
if self.index >= len(self.image_list): if self.index >= len(self.image_list):
self.index = max(0, len(self.image_list) - 1) 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_image = None
self._cached_next_index = -1 self._cached_next_index = -1
self._trigger_preload() self._trigger_preload()

View File

@@ -28,35 +28,219 @@ import collections
from pathlib import Path from pathlib import Path
from contextlib import contextmanager from contextlib import contextmanager
import lmdb import lmdb
from PySide6.QtCore import (QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, from PySide6.QtCore import (
QWaitCondition, QByteArray, QBuffer, QIODevice, Qt, QTimer, QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition,
QRunnable, QThreadPool) QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile
)
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
from constants import ( from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES, APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES,
IMAGE_EXTENSIONS, SEARCH_CMD, THUMBNAIL_SIZES, RATING_XATTR_NAME, XATTR_NAME, IMAGE_EXTENSIONS, SEARCH_CMD, THUMBNAIL_SIZES, RATING_XATTR_NAME, XATTR_NAME,
UITexts, SCANNER_SETTINGS_DEFAULTS UITexts, SCANNER_SETTINGS_DEFAULTS, HAVE_BAGHEERASEARCH_LIB
) )
from imageviewer import ImageViewer from imageviewer import ImageViewer
from metadatamanager import XattrManager from metadatamanager import XattrManager
try: if HAVE_BAGHEERASEARCH_LIB:
# Attempt to import bagheerasearch for direct integration try:
from bagheera_search_lib import BagheeraSearcher from bagheera_search_lib import BagheeraSearcher
HAVE_BAGHEERASEARCH_LIB = True except ImportError:
except ImportError: HAVE_BAGHEERASEARCH_LIB = False
HAVE_BAGHEERASEARCH_LIB = False pass
# Set up logging for better debugging # Set up logging for better debugging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def generate_thumbnail(path, size): class ThreadPoolManager:
"""Manages a global QThreadPool to dynamically adjust thread count."""
def __init__(self):
self.pool = QThreadPool()
self.default_thread_count = APP_CONFIG.get(
"generation_threads",
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4)
)
self.pool.setMaxThreadCount(self.default_thread_count)
self.is_user_active = False
logger.info(f"ThreadPoolManager initialized with {self.default_thread_count} threads.")
def get_pool(self):
"""Returns the managed QThreadPool instance."""
return self.pool
def set_user_active(self, active):
"""
Adjusts thread count based on user activity.
Args:
active (bool): True if the user is interacting with the UI.
"""
if active == self.is_user_active:
return
self.is_user_active = active
if active:
# User is active, reduce threads to 1 to prioritize UI responsiveness.
self.pool.setMaxThreadCount(1)
logger.debug("User is active, reducing thread pool to 1.")
else:
# User is idle, restore to default thread count.
self.pool.setMaxThreadCount(self.default_thread_count)
logger.debug(f"User is idle, restoring thread pool to {self.default_thread_count}.")
def update_default_thread_count(self):
"""Updates the default thread count from application settings."""
self.default_thread_count = APP_CONFIG.get(
"generation_threads",
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4)
)
# Only apply if not in a user-active (low-thread) state.
if not self.is_user_active:
self.pool.setMaxThreadCount(self.default_thread_count)
logger.info(f"Default thread count updated to {self.default_thread_count}.")
class ScannerWorker(QRunnable):
"""
Worker to process a single image in a thread pool.
Handles thumbnail retrieval/generation and metadata loading.
"""
def __init__(self, cache, path, target_sizes=None, load_metadata=True,
signal_emitter=None, semaphore=None):
super().__init__()
self.cache = cache
self.path = path
self.target_sizes = target_sizes
self.load_metadata_flag = load_metadata
self.emitter = signal_emitter
self.semaphore = semaphore
self._is_cancelled = False
# Result will be (path, thumb, mtime, tags, rating, inode, dev) or None
self.result = None
def shutdown(self):
"""Marks the worker as cancelled."""
self._is_cancelled = True
def run(self):
from constants import SCANNER_GENERATE_SIZES
sizes_to_check = self.target_sizes if self.target_sizes is not None \
else SCANNER_GENERATE_SIZES
if self._is_cancelled:
if self.semaphore:
self.semaphore.release()
return
fd = None
try:
# Optimize: Open file once to reuse FD for stat and xattrs
fd = os.open(self.path, os.O_RDONLY)
stat_res = os.fstat(fd)
curr_mtime = stat_res.st_mtime
curr_inode = stat_res.st_ino
curr_dev = stat_res.st_dev
smallest_thumb_for_signal = None
min_size = min(sizes_to_check) if sizes_to_check else 0
# Ensure required thumbnails exist
for size in sizes_to_check:
if self._is_cancelled:
return
# Check if a valid thumbnail for this size exists
thumb, mtime = self.cache.get_thumbnail(self.path, size,
curr_mtime=curr_mtime,
inode=curr_inode,
device_id=curr_dev)
if not thumb or mtime != curr_mtime:
# Use generation lock to prevent multiple threads generating
with self.cache.generation_lock(
self.path, size, curr_mtime,
curr_inode, curr_dev) as should_gen:
if self._is_cancelled:
return
if should_gen:
# I am the owner, I generate the thumbnail
new_thumb = generate_thumbnail(self.path, size, fd=fd)
if self._is_cancelled:
return
if new_thumb and not new_thumb.isNull():
self.cache.set_thumbnail(
self.path, new_thumb, curr_mtime, size,
inode=curr_inode, device_id=curr_dev, block=True)
if size == min_size:
smallest_thumb_for_signal = new_thumb
else:
# Another thread generated it, re-fetch
if size == min_size:
re_thumb, _ = self.cache.get_thumbnail(
self.path, size, curr_mtime=curr_mtime,
inode=curr_inode, device_id=curr_dev,
async_load=False)
smallest_thumb_for_signal = re_thumb
elif size == min_size:
# valid thumb exists, use it for signal
smallest_thumb_for_signal = thumb
tags = []
rating = 0
if self.load_metadata_flag:
tags, rating = self._load_metadata(fd)
self.result = (self.path, smallest_thumb_for_signal,
curr_mtime, tags, rating, curr_inode, curr_dev)
except Exception as e:
logger.error(f"Error processing image {self.path}: {e}")
self.result = None
finally:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
if self.emitter:
self.emitter.emit_progress()
if self.semaphore:
self.semaphore.release()
def _load_metadata(self, path_or_fd):
"""Loads tag and rating data for a path or file descriptor."""
tags = []
raw_tags = XattrManager.get_attribute(path_or_fd, 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_or_fd, RATING_XATTR_NAME, "0")
try:
rating = int(raw_rating)
except ValueError:
rating = 0
return tags, rating
def generate_thumbnail(path, size, fd=None):
"""Generates a QImage thumbnail for a given path and size.""" """Generates a QImage thumbnail for a given path and size."""
try: try:
reader = QImageReader(path) qfile = None
if fd is not None:
try:
# Ensure we are at the beginning of the file
os.lseek(fd, 0, os.SEEK_SET)
qfile = QFile()
if qfile.open(fd, QIODevice.ReadOnly, QFile.DontCloseHandle):
reader = QImageReader(qfile)
else:
qfile = None
reader = QImageReader(path)
except OSError:
reader = QImageReader(path)
else:
reader = QImageReader(path)
# Optimization: Instruct the image decoder to scale while reading. # Optimization: Instruct the image decoder to scale while reading.
# This drastically reduces memory usage and CPU time for large images # This drastically reduces memory usage and CPU time for large images
@@ -130,6 +314,10 @@ class CacheWriter(QThread):
if not self._running: if not self._running:
return return
# Ensure we don't accept new items if stopping, especially when block=False
if not self._running:
return
# --- Soft Cleaning: Deduplication --- # --- Soft Cleaning: Deduplication ---
# Remove redundant pending updates for the same image/size (e.g. # Remove redundant pending updates for the same image/size (e.g.
# rapid rotations) # rapid rotations)
@@ -154,7 +342,7 @@ class CacheWriter(QThread):
def stop(self): def stop(self):
self._mutex.lock() self._mutex.lock()
self._running = False self._running = False
self._queue.clear() # Do not clear the queue here; let the run loop drain it to prevent data loss.
self._condition_new_data.wakeAll() self._condition_new_data.wakeAll()
self._condition_space_available.wakeAll() self._condition_space_available.wakeAll()
self._mutex.unlock() self._mutex.unlock()
@@ -187,11 +375,8 @@ class CacheWriter(QThread):
# Gather a batch of items # Gather a batch of items
# Adaptive batch size: if queue is backing up, increase transaction size # Adaptive batch size: if queue is backing up, increase transaction size
# to improve throughput. # to improve throughput.
if not self._running: # Respect max size even during shutdown to avoid OOM or huge transactions
# Flush everything if stopping batch_limit = self._max_size
batch_limit = len(self._queue)
else:
batch_limit = self._max_size
batch = [] batch = []
while self._queue and len(batch) < batch_limit: while self._queue and len(batch) < batch_limit:
@@ -1046,45 +1231,6 @@ class CacheCleaner(QThread):
self.finished_clean.emit(removed_count) self.finished_clean.emit(removed_count)
class ThumbnailRunnable(QRunnable):
"""Runnable task to generate a single thumbnail."""
def __init__(self, cache, path, size, signal_emitter):
super().__init__()
self.cache = cache
self.path = path
self.size = size
self.emitter = signal_emitter
def run(self):
try:
# Optimization: Single stat call per file
stat_res = os.stat(self.path)
curr_mtime = stat_res.st_mtime
inode = stat_res.st_ino
dev = stat_res.st_dev
# Check cache first to avoid expensive generation
thumb, mtime = self.cache.get_thumbnail(
self.path, self.size, curr_mtime=curr_mtime,
inode=inode, device_id=dev, async_load=False)
if not thumb or mtime != curr_mtime:
# Use the generation lock to coordinate
with self.cache.generation_lock(
self.path, self.size, curr_mtime, inode, dev) as should_gen:
if should_gen:
# I am the owner, I generate the thumbnail
new_thumb = generate_thumbnail(self.path, self.size)
if new_thumb and not new_thumb.isNull():
self.cache.set_thumbnail(
self.path, new_thumb, curr_mtime, self.size,
inode=inode, device_id=dev, block=True)
except Exception as e:
logger.error(f"Error generating thumbnail for {self.path}: {e}")
finally:
self.emitter.emit_progress()
class ThumbnailGenerator(QThread): class ThumbnailGenerator(QThread):
""" """
Background thread to generate thumbnails for a specific size for a list of Background thread to generate thumbnails for a specific size for a list of
@@ -1097,34 +1243,38 @@ class ThumbnailGenerator(QThread):
"""Helper to emit signals from runnables to the main thread.""" """Helper to emit signals from runnables to the main thread."""
progress_tick = Signal() progress_tick = Signal()
def emit_progress(self): def emit_progress(self):
self.progress_tick.emit() self.progress_tick.emit()
def __init__(self, cache, paths, size): def __init__(self, cache, paths, size, thread_pool_manager):
super().__init__() super().__init__()
self.cache = cache self.cache = cache
self.paths = paths self.paths = paths
self.size = size self.size = size
self._abort = False self._abort = False
self.thread_pool_manager = thread_pool_manager
self._workers = []
self._workers_mutex = QMutex()
def stop(self): def stop(self):
"""Stops the worker thread gracefully.""" """Stops the worker thread gracefully."""
self._abort = True self._abort = True
self._workers_mutex.lock()
for worker in self._workers:
worker.shutdown()
self._workers_mutex.unlock()
self.wait() self.wait()
def run(self): def run(self):
""" """
Main execution loop. Uses a thread pool to process paths in parallel. Main execution loop. Uses a thread pool to process paths in parallel.
""" """
pool = QThreadPool() pool = self.thread_pool_manager.get_pool()
max_threads = APP_CONFIG.get(
"generation_threads",
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4))
pool.setMaxThreadCount(max_threads)
emitter = self.SignalEmitter() emitter = self.SignalEmitter()
processed_count = 0 processed_count = 0
total = len(self.paths) total = len(self.paths)
sem = QSemaphore(0)
def on_tick(): def on_tick():
nonlocal processed_count nonlocal processed_count
@@ -1138,14 +1288,34 @@ class ThumbnailGenerator(QThread):
# The signal/slot mechanism handles thread safety automatically. # The signal/slot mechanism handles thread safety automatically.
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection) emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
started_count = 0
for path in self.paths: for path in self.paths:
if self._abort: if self._abort:
break break
runnable = ThumbnailRunnable(self.cache, path, self.size, emitter) runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
pool.start(runnable) load_metadata=False, signal_emitter=emitter,
semaphore=sem)
runnable.setAutoDelete(False)
pool.waitForDone() self._workers_mutex.lock()
self.generation_complete.emit() if self._abort:
self._workers_mutex.unlock()
break
self._workers.append(runnable)
self._workers_mutex.unlock()
pool.start(runnable)
started_count += 1
if started_count > 0:
sem.acquire(started_count)
self._workers_mutex.lock()
self._workers.clear()
self._workers_mutex.unlock()
if not self._abort:
self.generation_complete.emit()
class ImageScanner(QThread): class ImageScanner(QThread):
@@ -1159,8 +1329,8 @@ class ImageScanner(QThread):
progress_percent = Signal(int) progress_percent = Signal(int)
finished_scan = Signal(int) # Total images found finished_scan = Signal(int) # Total images found
more_files_available = Signal(int, int) # Last loaded index, remainder more_files_available = Signal(int, int) # Last loaded index, remainder
def __init__(self, cache, paths, is_file_list=False, viewers=None,
def __init__(self, cache, paths, is_file_list=False, viewers=None): thread_pool_manager=None):
# is_file_list is not used # is_file_list is not used
if not paths or not isinstance(paths, (list, tuple)): if not paths or not isinstance(paths, (list, tuple)):
logger.warning("ImageScanner initialized with empty or invalid paths") logger.warning("ImageScanner initialized with empty or invalid paths")
@@ -1168,6 +1338,7 @@ class ImageScanner(QThread):
super().__init__() super().__init__()
self.cache = cache self.cache = cache
self.all_files = [] self.all_files = []
self.thread_pool_manager = thread_pool_manager
self._viewers = viewers self._viewers = viewers
self._seen_files = set() self._seen_files = set()
self._is_file_list = is_file_list self._is_file_list = is_file_list
@@ -1196,12 +1367,23 @@ class ImageScanner(QThread):
self.pending_tasks = [] self.pending_tasks = []
self._priority_queue = collections.deque() self._priority_queue = collections.deque()
self._processed_paths = set() self._processed_paths = set()
self._current_workers = []
self._current_workers_mutex = QMutex()
# Initial load # Initial load
self.pending_tasks.append((0, APP_CONFIG.get( self.pending_tasks.append((0, APP_CONFIG.get(
"scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"]))) "scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"])))
self._last_update_time = 0 self._last_update_time = 0
if self.thread_pool_manager:
self.pool = self.thread_pool_manager.get_pool()
else:
self.pool = QThreadPool()
max_threads = APP_CONFIG.get(
"generation_threads",
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4))
self.pool.setMaxThreadCount(max_threads)
logger.info(f"ImageScanner initialized with {len(paths)} paths") logger.info(f"ImageScanner initialized with {len(paths)} paths")
def set_auto_load(self, enabled): def set_auto_load(self, enabled):
@@ -1404,8 +1586,8 @@ class ImageScanner(QThread):
return None, [] return None, []
def _search(self, query): def _search(self, query):
engine = APP_CONFIG.get("search_engine", "Native") engine = APP_CONFIG.get("search_engine", "Bagheera")
if HAVE_BAGHEERASEARCH_LIB and (engine == "Native" or not SEARCH_CMD): if HAVE_BAGHEERASEARCH_LIB and (engine == "Bagheera" or not SEARCH_CMD):
query_text, main_options, other_options = self._parse_query(query) query_text, main_options, other_options = self._parse_query(query)
try: try:
searcher = BagheeraSearcher() searcher = BagheeraSearcher()
@@ -1455,84 +1637,129 @@ class ImageScanner(QThread):
self.finished_scan.emit(self.count) self.finished_scan.emit(self.count)
return return
if self.thread_pool_manager:
max_threads = self.thread_pool_manager.default_thread_count
else:
max_threads = APP_CONFIG.get(
"generation_threads",
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4))
self.pool.setMaxThreadCount(max_threads)
images_loaded = 0 images_loaded = 0
batch = [] batch = []
while i < len(self.all_files): while i < len(self.all_files):
if not self._is_running: if not self._is_running:
return return
self.msleep(1) # Force yield to UI thread per item
while self._paused and self._is_running: while self._paused and self._is_running:
self.msleep(100) self.msleep(100)
# 1. Check priority queue first # Collect paths for this chunk to process in parallel
priority_path = None chunk_size = max_threads * 2
tasks = [] # List of (path, is_from_priority_queue)
# 1. Drain priority queue up to chunk size
self.mutex.lock() self.mutex.lock()
while self._priority_queue: while len(tasks) < chunk_size and self._priority_queue:
p = self._priority_queue.popleft() p = self._priority_queue.popleft()
if p not in self._processed_paths and p in self._seen_files: if p not in self._processed_paths and p in self._seen_files:
priority_path = p tasks.append((p, True))
break
self.mutex.unlock() self.mutex.unlock()
# 2. Determine file to process # 2. Fill remaining chunk space with sequential files
if priority_path: temp_i = i
f_path = priority_path while len(tasks) < chunk_size and temp_i < len(self.all_files):
# Don't increment 'i' yet, we are processing out of order p = self.all_files[temp_i]
else: # Skip if already processed (e.g. via priority earlier)
f_path = self.all_files[i] if p not in self._processed_paths \
i += 1 # Only advance sequential index if processing sequentially and Path(p).suffix.lower() in IMAGE_EXTENSIONS:
tasks.append((p, False))
temp_i += 1
if f_path not in self._processed_paths \ if not tasks:
and Path(f_path).suffix.lower() in IMAGE_EXTENSIONS: # If no tasks found but still have files (e.g. all skipped extensions),
# Pass the batch list to store result instead of emitting immediately # update index and continue loop
was_loaded = self._process_single_image(f_path, batch) i = temp_i
continue
# Emit batch if size is enough (responsiveness optimization) # Submit tasks to thread pool
# Dynamic batching: Start small for instant feedback. sem = QSemaphore(0)
# Keep batches small enough to prevent UI starvation during rapid cache runnables = []
# reads.
if self.count <= 100:
target_batch_size = 20
else:
target_batch_size = 200
if len(batch) >= target_batch_size: self._current_workers_mutex.lock()
if not self._is_running:
self._current_workers_mutex.unlock()
return
self.images_found.emit(batch) for f_path, _ in tasks:
batch = [] r = ScannerWorker(self.cache, f_path, semaphore=sem)
# Yield briefly to let the main thread process the emitted batch r.setAutoDelete(False)
# (update UI), preventing UI freeze during fast cache reading. runnables.append(r)
self.msleep(10) self._current_workers.append(r)
self.pool.start(r)
self._current_workers_mutex.unlock()
if was_loaded: # Wait only for this chunk to finish using semaphore
self._processed_paths.add(f_path) sem.acquire(len(runnables))
self._current_workers_mutex.lock()
self._current_workers.clear()
self._current_workers_mutex.unlock()
if not self._is_running:
return
# Process results
for r in runnables:
if r.result:
self._processed_paths.add(r.path)
batch.append(r.result)
self.count += 1
images_loaded += 1 images_loaded += 1
if images_loaded >= to_load and to_load > 0:
if batch: # Emit remaining items
self.images_found.emit(batch)
next_index = i + 1 # Clean up runnables
total_files = len(self.all_files) runnables.clear()
self.index = next_index
self.progress_msg.emit(UITexts.LOADED_PARTIAL.format(
self.count, total_files - next_index))
if total_files > 0: # Advance sequential index
percent = int((self.count / total_files) * 100) i = temp_i
self.progress_percent.emit(percent)
self.more_files_available.emit(next_index, total_files) # Emit batch if size is enough (responsiveness optimization)
# This loads all images continuously without pausing only if if self.count <= 100:
# explicitly requested target_batch_size = 20
if self._auto_load_enabled: else:
self.load_images( target_batch_size = 200
next_index,
APP_CONFIG.get("scan_batch_size", if len(batch) >= target_batch_size:
SCANNER_SETTINGS_DEFAULTS[ self.images_found.emit(batch)
"scan_batch_size"])) batch = []
return self.msleep(10) # Yield to UI
# Check if loading limit reached
if images_loaded >= to_load and to_load > 0:
if batch: # Emit remaining items
self.images_found.emit(batch)
next_index = i
total_files = len(self.all_files)
self.index = next_index
self.progress_msg.emit(UITexts.LOADED_PARTIAL.format(
self.count, total_files - next_index))
if total_files > 0:
percent = int((self.count / total_files) * 100)
self.progress_percent.emit(percent)
self.more_files_available.emit(next_index, total_files)
# This loads all images continuously without pausing only if
# explicitly requested
if self._auto_load_enabled:
self.load_images(
next_index,
APP_CONFIG.get("scan_batch_size",
SCANNER_SETTINGS_DEFAULTS[
"scan_batch_size"]))
return
if self.count % 10 == 0: # Update progress less frequently if self.count % 10 == 0: # Update progress less frequently
self.progress_msg.emit( self.progress_msg.emit(
@@ -1547,88 +1774,17 @@ class ImageScanner(QThread):
self.progress_percent.emit(100) self.progress_percent.emit(100)
self.finished_scan.emit(self.count) self.finished_scan.emit(self.count)
def _load_metadata(self, path_or_fd):
"""Loads tag and rating data for a path or file descriptor."""
tags = []
raw_tags = XattrManager.get_attribute(path_or_fd, 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_or_fd, RATING_XATTR_NAME, "0")
try:
rating = int(raw_rating)
except ValueError:
rating = 0
return tags, rating
def _process_single_image(self, f_path, batch_list):
from constants import SCANNER_GENERATE_SIZES
fd = None
try:
# Optimize: Open file once to reuse FD for stat and xattrs
fd = os.open(f_path, os.O_RDONLY)
stat_res = os.fstat(fd)
curr_mtime = stat_res.st_mtime
curr_inode = stat_res.st_ino
curr_dev = stat_res.st_dev
smallest_thumb_for_signal = None
# Ensure required thumbnails exist
for size in SCANNER_GENERATE_SIZES:
# Check if a valid thumbnail for this size exists
thumb, mtime = self.cache.get_thumbnail(f_path, size,
curr_mtime=curr_mtime,
inode=curr_inode,
device_id=curr_dev)
if not thumb or mtime != curr_mtime:
# Use generation lock to prevent multiple threads generating the
# same thumb
with self.cache.generation_lock(
f_path, size, curr_mtime,
curr_inode, curr_dev) as should_gen:
if should_gen:
# I am the owner, I generate the thumbnail
new_thumb = generate_thumbnail(f_path, size)
if new_thumb and not new_thumb.isNull():
self.cache.set_thumbnail(
f_path, new_thumb, curr_mtime, size,
inode=curr_inode, device_id=curr_dev, block=True)
if size == min(SCANNER_GENERATE_SIZES):
smallest_thumb_for_signal = new_thumb
else:
# Another thread generated it, re-fetch to use it for the
# signal
if size == min(SCANNER_GENERATE_SIZES):
re_thumb, _ = self.cache.get_thumbnail(
f_path, size, curr_mtime=curr_mtime,
inode=curr_inode, device_id=curr_dev,
async_load=False)
smallest_thumb_for_signal = re_thumb
elif size == min(SCANNER_GENERATE_SIZES):
# valid thumb exists, use it for signal
smallest_thumb_for_signal = thumb
tags, rating = self._load_metadata(fd)
batch_list.append((f_path, smallest_thumb_for_signal,
curr_mtime, tags, rating, curr_inode, curr_dev))
self.count += 1
return True
except Exception as e:
logger.error(f"Error processing image {f_path}: {e}")
return False
finally:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
def stop(self): def stop(self):
logger.info("ImageScanner stop requested")
self._is_running = False self._is_running = False
# Cancel currently running workers in the active batch
self._current_workers_mutex.lock()
for worker in self._current_workers:
worker.shutdown()
self._current_workers_mutex.unlock()
# Wake up the condition variable
self.mutex.lock() self.mutex.lock()
self.condition.wakeAll() self.condition.wakeAll()
self.mutex.unlock() self.mutex.unlock()

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ except ImportError:
exiv2 = None exiv2 = None
HAVE_EXIV2 = False HAVE_EXIV2 = False
from utils import preserve_mtime from utils import preserve_mtime
from constants import RATING_XATTR_NAME, XATTR_NAME
def notify_baloo(path): def notify_baloo(path):
@@ -40,6 +41,24 @@ def notify_baloo(path):
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock) 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: class MetadataManager:
"""Manages reading EXIF, IPTC, and XMP metadata.""" """Manages reading EXIF, IPTC, and XMP metadata."""
@@ -136,3 +155,33 @@ class XattrManager:
except Exception as e: except Exception as e:
raise IOError(f"Could not save xattr '{attr_name}' " raise IOError(f"Could not save xattr '{attr_name}' "
"for {file_path}: {e}") from e "for {file_path}: {e}") from e
@staticmethod
def get_all_attributes(path):
"""
Gets all extended attributes for a file as a dictionary.
Args:
path (str): The path to the file.
Returns:
dict: A dictionary mapping attribute names to values.
"""
attributes = {}
if not path:
return attributes
try:
keys = os.listxattr(path)
for key in keys:
try:
val = os.getxattr(path, key)
try:
val_str = val.decode('utf-8')
except UnicodeDecodeError:
val_str = str(val)
attributes[key] = val_str
except (OSError, AttributeError):
pass
except (OSError, AttributeError):
pass
return attributes

View File

@@ -9,7 +9,6 @@ Classes:
PropertiesDialog: A QDialog that presents file properties in a tabbed PropertiesDialog: A QDialog that presents file properties in a tabbed
interface. interface.
""" """
import os
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
@@ -18,14 +17,40 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import ( from PySide6.QtGui import (
QImageReader, QIcon, QColor QImageReader, QIcon, QColor
) )
from PySide6.QtCore import ( from PySide6.QtCore import (QThread, Signal, Qt, QFileInfo, QLocale)
Qt, QFileInfo, QLocale
)
from constants import ( from constants import (
RATING_XATTR_NAME, XATTR_NAME, UITexts RATING_XATTR_NAME, XATTR_NAME, UITexts
) )
from metadatamanager import MetadataManager, HAVE_EXIV2, notify_baloo from metadatamanager import MetadataManager, HAVE_EXIV2, XattrManager
from utils import preserve_mtime
class PropertiesLoader(QThread):
"""Background thread to load metadata (xattrs and EXIF) asynchronously."""
loaded = Signal(dict, dict)
def __init__(self, path, parent=None):
super().__init__(parent)
self.path = path
self._abort = False
def stop(self):
"""Signals the thread to stop and waits for it."""
self._abort = True
self.wait()
def run(self):
# Xattrs
if self._abort:
return
xattrs = XattrManager.get_all_attributes(self.path)
if self._abort:
return
# EXIF
exif_data = MetadataManager.read_all_metadata(self.path)
if not self._abort:
self.loaded.emit(xattrs, exif_data)
class PropertiesDialog(QDialog): class PropertiesDialog(QDialog):
@@ -51,6 +76,7 @@ class PropertiesDialog(QDialog):
self.setWindowTitle(UITexts.PROPERTIES_TITLE) self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else [] self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating self._initial_rating = initial_rating
self.loader = None
self.resize(400, 500) self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
@@ -128,7 +154,8 @@ class PropertiesDialog(QDialog):
self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu) self.table.customContextMenuRequested.connect(self.show_context_menu)
self.load_metadata() # Initial partial load (synchronous, just passed args)
self.update_metadata_table({}, initial_only=True)
meta_layout.addWidget(self.table) meta_layout.addWidget(self.table)
tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"), tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"),
UITexts.PROPERTIES_METADATA_TAB) UITexts.PROPERTIES_METADATA_TAB)
@@ -159,7 +186,8 @@ class PropertiesDialog(QDialog):
# This is a disk read. # This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
self.load_exif_data() # Placeholder for EXIF
self.update_exif_table(None)
exif_layout.addWidget(self.exif_table) exif_layout.addWidget(self.exif_table)
tabs.addTab(exif_widget, QIcon.fromTheme("view-details"), tabs.addTab(exif_widget, QIcon.fromTheme("view-details"),
@@ -173,10 +201,18 @@ class PropertiesDialog(QDialog):
btn_box.rejected.connect(self.close) btn_box.rejected.connect(self.close)
layout.addWidget(btn_box) layout.addWidget(btn_box)
def load_metadata(self): # Start background loading
self.reload_metadata()
def closeEvent(self, event):
if self.loader and self.loader.isRunning():
self.loader.stop()
super().closeEvent(event)
def update_metadata_table(self, disk_xattrs, initial_only=False):
""" """
Loads metadata from the file's text keys (via QImageReader) and Updates the metadata table with extended attributes.
extended attributes (xattrs) into the metadata table. Merges initial tags/rating with loaded xattrs.
""" """
self.table.blockSignals(True) self.table.blockSignals(True)
self.table.setRowCount(0) self.table.setRowCount(0)
@@ -188,26 +224,11 @@ class PropertiesDialog(QDialog):
if self._initial_rating > 0: if self._initial_rating > 0:
preloaded_xattrs[RATING_XATTR_NAME] = str(self._initial_rating) preloaded_xattrs[RATING_XATTR_NAME] = str(self._initial_rating)
# Read other xattrs from disk
xattrs = {}
try:
for xkey in os.listxattr(self.path):
# Avoid re-reading already known attributes
if xkey not in preloaded_xattrs:
try:
val = os.getxattr(self.path, xkey) # This is a disk read
try:
val_str = val.decode('utf-8')
except UnicodeDecodeError:
val_str = str(val)
xattrs[xkey] = val_str
except Exception:
pass
except Exception:
pass
# Combine preloaded and newly read xattrs # Combine preloaded and newly read xattrs
all_xattrs = {**preloaded_xattrs, **xattrs} all_xattrs = preloaded_xattrs.copy()
if not initial_only and disk_xattrs:
# Disk data takes precedence or adds to it
all_xattrs.update(disk_xattrs)
self.table.setRowCount(len(all_xattrs)) self.table.setRowCount(len(all_xattrs))
@@ -224,11 +245,34 @@ class PropertiesDialog(QDialog):
row += 1 row += 1
self.table.blockSignals(False) self.table.blockSignals(False)
def load_exif_data(self): def reload_metadata(self):
"""Loads EXIF, XMP, and IPTC metadata using the MetadataManager.""" """Starts the background thread to load metadata."""
if self.loader and self.loader.isRunning():
# Already running
return
self.loader = PropertiesLoader(self.path, self)
self.loader.loaded.connect(self.on_data_loaded)
self.loader.start()
def on_data_loaded(self, xattrs, exif_data):
"""Slot called when metadata is loaded from the thread."""
self.update_metadata_table(xattrs, initial_only=False)
self.update_exif_table(exif_data)
def update_exif_table(self, exif_data):
"""Updates the EXIF table with loaded data."""
self.exif_table.blockSignals(True) self.exif_table.blockSignals(True)
self.exif_table.setRowCount(0) self.exif_table.setRowCount(0)
if exif_data is None:
# Loading state
self.exif_table.setRowCount(1)
item = QTableWidgetItem("Loading data...")
item.setFlags(Qt.ItemIsEnabled)
self.exif_table.setItem(0, 0, item)
self.exif_table.blockSignals(False)
return
if not HAVE_EXIV2: if not HAVE_EXIV2:
self.exif_table.setRowCount(1) self.exif_table.setRowCount(1)
error_color = QColor("red") error_color = QColor("red")
@@ -243,8 +287,6 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False) self.exif_table.blockSignals(False)
return return
exif_data = MetadataManager.read_all_metadata(self.path)
if not exif_data: if not exif_data:
self.exif_table.setRowCount(1) self.exif_table.setRowCount(1)
item = QTableWidgetItem(UITexts.INFO) item = QTableWidgetItem(UITexts.INFO)
@@ -291,16 +333,11 @@ class PropertiesDialog(QDialog):
if item.column() == 1: if item.column() == 1:
key = self.table.item(item.row(), 0).text() key = self.table.item(item.row(), 0).text()
val = item.text() val = item.text()
# Treat empty or whitespace-only values as removal to match previous
# behavior
val_to_set = val if val.strip() else None
try: try:
with preserve_mtime(self.path): XattrManager.set_attribute(self.path, key, val_to_set)
if not val.strip():
try:
os.removexattr(self.path, key)
except OSError:
pass
else:
os.setxattr(self.path, key, val.encode('utf-8'))
notify_baloo(self.path)
except Exception as e: except Exception as e:
QMessageBox.warning(self, UITexts.ERROR, QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e)) UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
@@ -361,10 +398,8 @@ class PropertiesDialog(QDialog):
key)) key))
if ok2: if ok2:
try: try:
with preserve_mtime(self.path): XattrManager.set_attribute(self.path, key, val)
os.setxattr(self.path, key, val.encode('utf-8')) self.reload_metadata()
notify_baloo(self.path)
self.load_metadata()
except Exception as e: except Exception as e:
QMessageBox.warning(self, UITexts.ERROR, QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e)) UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e))
@@ -378,9 +413,7 @@ class PropertiesDialog(QDialog):
""" """
key = self.table.item(row, 0).text() key = self.table.item(row, 0).text()
try: try:
with preserve_mtime(self.path): XattrManager.set_attribute(self.path, key, None)
os.removexattr(self.path, key)
notify_baloo(self.path)
self.table.removeRow(row) self.table.removeRow(row)
except Exception as e: except Exception as e:
QMessageBox.warning(self, UITexts.ERROR, QMessageBox.warning(self, UITexts.ERROR,

View File

@@ -25,7 +25,8 @@ from constants import (
APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR, APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR,
DEFAULT_PET_BOX_COLOR, DEFAULT_OBJECT_BOX_COLOR, DEFAULT_LANDMARK_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, 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, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_FILENAME_LINES_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT,
THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT,
@@ -34,7 +35,7 @@ from constants import (
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT,
THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT,
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT, VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT,
UITexts, save_app_config, UITexts, save_app_config, HAVE_BAGHEERASEARCH_LIB
) )
@@ -81,6 +82,7 @@ class SettingsDialog(QDialog):
self.current_face_color = DEFAULT_FACE_BOX_COLOR self.current_face_color = DEFAULT_FACE_BOX_COLOR
self.current_pet_color = DEFAULT_PET_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_object_color = DEFAULT_OBJECT_BOX_COLOR
self.current_landmark_color = DEFAULT_LANDMARK_BOX_COLOR self.current_landmark_color = DEFAULT_LANDMARK_BOX_COLOR
self.current_thumbs_bg_color = THUMBNAILS_BG_COLOR_DEFAULT self.current_thumbs_bg_color = THUMBNAILS_BG_COLOR_DEFAULT
@@ -293,9 +295,9 @@ class SettingsDialog(QDialog):
search_engine_layout = QHBoxLayout() search_engine_layout = QHBoxLayout()
search_engine_label = QLabel(UITexts.SETTINGS_SCANNER_SEARCH_ENGINE_LABEL) search_engine_label = QLabel(UITexts.SETTINGS_SCANNER_SEARCH_ENGINE_LABEL)
self.search_engine_combo = QComboBox() 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: 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(search_engine_label)
search_engine_layout.addWidget(self.search_engine_combo) 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) self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP)
faces_layout.addLayout(pet_history_layout) 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 --- # --- Object Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
object_header = QLabel("Object") object_header = QLabel("Object")
@@ -593,7 +642,7 @@ class SettingsDialog(QDialog):
# Add tabs in the new order # Add tabs in the new order
tabs.addTab(thumbs_tab, UITexts.SETTINGS_GROUP_THUMBNAILS) tabs.addTab(thumbs_tab, UITexts.SETTINGS_GROUP_THUMBNAILS)
tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER) 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) tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER)
# --- Button Box --- # --- Button Box ---
@@ -625,16 +674,19 @@ class SettingsDialog(QDialog):
person_tags = APP_CONFIG.get( person_tags = APP_CONFIG.get(
"person_tags", SCANNER_SETTINGS_DEFAULTS["person_tags"]) "person_tags", SCANNER_SETTINGS_DEFAULTS["person_tags"])
pet_tags = APP_CONFIG.get("pet_tags", "") pet_tags = APP_CONFIG.get("pet_tags", "")
body_tags = APP_CONFIG.get("body_tags", "")
object_tags = APP_CONFIG.get("object_tags", "") object_tags = APP_CONFIG.get("object_tags", "")
landmark_tags = APP_CONFIG.get("landmark_tags", "") landmark_tags = APP_CONFIG.get("landmark_tags", "")
face_detection_engine = APP_CONFIG.get("face_detection_engine") face_detection_engine = APP_CONFIG.get("face_detection_engine")
pet_detection_engine = APP_CONFIG.get("pet_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") object_detection_engine = APP_CONFIG.get("object_detection_engine")
landmark_detection_engine = APP_CONFIG.get("landmark_detection_engine") landmark_detection_engine = APP_CONFIG.get("landmark_detection_engine")
face_color = APP_CONFIG.get("face_box_color", DEFAULT_FACE_BOX_COLOR) face_color = APP_CONFIG.get("face_box_color", DEFAULT_FACE_BOX_COLOR)
pet_color = APP_CONFIG.get("pet_box_color", DEFAULT_PET_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) object_color = APP_CONFIG.get("object_box_color", DEFAULT_OBJECT_BOX_COLOR)
landmark_color = APP_CONFIG.get("landmark_box_color", landmark_color = APP_CONFIG.get("landmark_box_color",
DEFAULT_LANDMARK_BOX_COLOR) DEFAULT_LANDMARK_BOX_COLOR)
@@ -645,6 +697,8 @@ class SettingsDialog(QDialog):
"faces_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) "faces_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
pet_history_count = APP_CONFIG.get( pet_history_count = APP_CONFIG.get(
"pets_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) "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_history_count = APP_CONFIG.get(
"object_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) "object_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
landmark_history_count = APP_CONFIG.get( landmark_history_count = APP_CONFIG.get(
@@ -687,19 +741,36 @@ class SettingsDialog(QDialog):
self.threads_spin.setValue(scan_threads) self.threads_spin.setValue(scan_threads)
# Set search engine # Set search engine
index = self.search_engine_combo.findData(search_engine) if HAVE_BAGHEERASEARCH_LIB:
if index != -1: self.search_engine_combo.setEnabled(True)
self.search_engine_combo.setCurrentIndex(index) if search_engine != "Baloo":
index = self.search_engine_combo.findData("Bagheera")
if index != -1:
self.search_engine_combo.setCurrentIndex(index)
else:
index = self.search_engine_combo.findData("Baloo")
if index != -1:
self.search_engine_combo.setCurrentIndex(index)
else:
self.search_engine_combo.setEnabled(False)
if SEARCH_CMD:
index = self.search_engine_combo.findData("Baloo")
if index != -1:
self.search_engine_combo.setCurrentIndex(index)
else:
self.search_engine_combo.setCurrentIndex(-1)
self.scan_full_on_start_checkbox.setChecked(scan_full_on_start) self.scan_full_on_start_checkbox.setChecked(scan_full_on_start)
self.person_tags_edit.setText(person_tags) self.person_tags_edit.setText(person_tags)
self.pet_tags_edit.setText(pet_tags) self.pet_tags_edit.setText(pet_tags)
self.body_tags_edit.setText(body_tags)
self.object_tags_edit.setText(object_tags) self.object_tags_edit.setText(object_tags)
self.landmark_tags_edit.setText(landmark_tags) self.landmark_tags_edit.setText(landmark_tags)
self.set_button_color(face_color) self.set_button_color(face_color)
self.set_pet_button_color(pet_color) self.set_pet_button_color(pet_color)
self.set_body_button_color(body_color)
self.set_object_button_color(object_color) self.set_object_button_color(object_color)
self.set_landmark_button_color(landmark_color) self.set_landmark_button_color(landmark_color)
@@ -709,6 +780,8 @@ class SettingsDialog(QDialog):
if self.pet_engine_combo and pet_detection_engine in AVAILABLE_PET_ENGINES: if self.pet_engine_combo and pet_detection_engine in AVAILABLE_PET_ENGINES:
self.pet_engine_combo.setCurrentText(pet_detection_engine) 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"): if object_detection_engine and hasattr(self, "object_engine_combo"):
self.object_engine_combo.setCurrentText(object_detection_engine) self.object_engine_combo.setCurrentText(object_detection_engine)
if landmark_detection_engine and hasattr(self, "landmark_engine_combo"): if landmark_detection_engine and hasattr(self, "landmark_engine_combo"):
@@ -717,6 +790,7 @@ class SettingsDialog(QDialog):
self.mru_tags_spin.setValue(mru_tags_count) self.mru_tags_spin.setValue(mru_tags_count)
self.face_history_spin.setValue(face_history_count) self.face_history_spin.setValue(face_history_count)
self.pet_history_spin.setValue(pet_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.object_history_spin.setValue(object_history_count)
self.landmark_history_spin.setValue(landmark_history_count) self.landmark_history_spin.setValue(landmark_history_count)
@@ -771,6 +845,18 @@ class SettingsDialog(QDialog):
if color.isValid(): if color.isValid():
self.set_pet_button_color(color.name()) 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): def set_object_button_color(self, color_str):
"""Sets the background color of the object button.""" """Sets the background color of the object button."""
self.object_color_btn.setStyleSheet( self.object_color_btn.setStyleSheet(
@@ -938,19 +1024,23 @@ class SettingsDialog(QDialog):
APP_CONFIG["scan_max_level"] = self.scan_max_level_spin.value() APP_CONFIG["scan_max_level"] = self.scan_max_level_spin.value()
APP_CONFIG["generation_threads"] = self.threads_spin.value() APP_CONFIG["generation_threads"] = self.threads_spin.value()
APP_CONFIG["scan_batch_size"] = self.scan_batch_size_spin.value() APP_CONFIG["scan_batch_size"] = self.scan_batch_size_spin.value()
APP_CONFIG["search_engine"] = self.search_engine_combo.currentData() if HAVE_BAGHEERASEARCH_LIB:
APP_CONFIG["search_engine"] = self.search_engine_combo.currentData()
APP_CONFIG["scan_full_on_start"] = self.scan_full_on_start_checkbox.isChecked() APP_CONFIG["scan_full_on_start"] = self.scan_full_on_start_checkbox.isChecked()
APP_CONFIG["person_tags"] = self.person_tags_edit.text() APP_CONFIG["person_tags"] = self.person_tags_edit.text()
APP_CONFIG["pet_tags"] = self.pet_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["object_tags"] = self.object_tags_edit.text()
APP_CONFIG["landmark_tags"] = self.landmark_tags_edit.text() APP_CONFIG["landmark_tags"] = self.landmark_tags_edit.text()
APP_CONFIG["face_box_color"] = self.current_face_color APP_CONFIG["face_box_color"] = self.current_face_color
APP_CONFIG["pet_box_color"] = self.current_pet_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["object_box_color"] = self.current_object_color
APP_CONFIG["landmark_box_color"] = self.current_landmark_color APP_CONFIG["landmark_box_color"] = self.current_landmark_color
APP_CONFIG["tags_menu_max_items"] = self.mru_tags_spin.value() APP_CONFIG["tags_menu_max_items"] = self.mru_tags_spin.value()
APP_CONFIG["faces_menu_max_items"] = self.face_history_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["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["object_menu_max_items"] = self.object_history_spin.value()
APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value() APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value()
@@ -975,9 +1065,10 @@ class SettingsDialog(QDialog):
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value() APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value()
APP_CONFIG["viewer_auto_resize_window"] = \ APP_CONFIG["viewer_auto_resize_window"] = \
self.viewer_auto_resize_check.isChecked() 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() 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"): if hasattr(self, "object_engine_combo"):
APP_CONFIG["object_detection_engine"] = \ APP_CONFIG["object_detection_engine"] = \
self.object_engine_combo.currentText() self.object_engine_combo.currentText()

View File

@@ -1121,6 +1121,9 @@ class FaceNameInputWidget(QWidget):
if self.region_type == "Pet": if self.region_type == "Pet":
max_items = APP_CONFIG.get("pets_menu_max_items", max_items = APP_CONFIG.get("pets_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT) 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": elif self.region_type == "Object":
max_items = APP_CONFIG.get("object_menu_max_items", max_items = APP_CONFIG.get("object_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT) FACES_MENU_MAX_ITEMS_DEFAULT)
@@ -1188,6 +1191,12 @@ class FaceNameInputWidget(QWidget):
parent_tags_str = "Pet" parent_tags_str = "Pet"
dialog_title = UITexts.NEW_PET_TAG_TITLE dialog_title = UITexts.NEW_PET_TAG_TITLE
dialog_text = UITexts.NEW_PET_TAG_TEXT 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": elif self.region_type == "Object":
parent_tags_str = APP_CONFIG.get("object_tags", "Object") parent_tags_str = APP_CONFIG.get("object_tags", "Object")
if not parent_tags_str or not parent_tags_str.strip(): 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") parent_tags_str = APP_CONFIG.get("pet_tags", "Pet")
if not parent_tags_str or not parent_tags_str.strip(): if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Pet" 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": elif self.region_type == "Object":
parent_tags_str = APP_CONFIG.get("object_tags", "Object") parent_tags_str = APP_CONFIG.get("object_tags", "Object")
if not parent_tags_str or not parent_tags_str.strip(): if not parent_tags_str or not parent_tags_str.strip():