Compare commits

..

6 Commits

Author SHA1 Message Date
Ignacio Serantes
0349155fd2 v0.9.11 2026-03-24 09:14:57 +01:00
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 1810 additions and 699 deletions

View File

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

View File

@@ -1,5 +1,23 @@
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.
· Inhibir el salvapantallas con el slideshow y añadir opción de menú para inhibirlo durante un tiempo determinado
· Mejorar el menú Open, con nombres correctos e iconos adecuados
@@ -12,12 +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?
· Solucionar el problema de las ventanas de diálogo nativas, tan simple como usar PySide nativo.
Analiza si la estrategia LIFO (Last-In, First-Out) en `CacheLoader` es la ideal para una galería de imágenes o si debería ser mixta.
¿Cómo puedo añadir una opción para limitar el número de hilos que `ImageScanner` puede usar para la generación de miniaturas?
Verifica si el uso de `QPixmapCache` en `ThumbnailDelegate.paint_thumbnail` está optimizado para evitar la conversión repetitiva de QImage a QPixmap, lo que podría causar ralentizaciones al hacer scroll rápido.
Check if the `_suppress_updates` flag correctly prevents potential race conditions in `update_tag_list` when switching view modes rapidly.
Verify if `find_and_select_path` logic in `on_view_mode_changed` handles cases where the selected item is filtered out after the view mode change.

View File

@@ -29,7 +29,7 @@ if FORCE_X11:
# --- CONFIGURATION ---
PROG_NAME = "Bagheera Image Viewer"
PROG_ID = "bagheeraview"
PROG_VERSION = "0.9.11-dev"
PROG_VERSION = "0.9.11"
PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS ---
@@ -110,7 +110,7 @@ SCANNER_SETTINGS_DEFAULTS = {
"scan_full_on_start": True,
"person_tags": "",
"generation_threads": 4,
"search_engine": "Native"
"search_engine": ""
}
# --- IMAGE VIEWER DEFAULTS ---
@@ -167,6 +167,8 @@ if importlib.util.find_spec("mediapipe") is not None:
pass
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,
"blaze_face_short_range.tflite")
MEDIAPIPE_FACE_MODEL_URL = (
@@ -193,6 +195,10 @@ AVAILABLE_PET_ENGINES = []
if HAVE_MEDIAPIPE:
AVAILABLE_PET_ENGINES.append("mediapipe")
AVAILABLE_BODY_ENGINES = []
if HAVE_MEDIAPIPE:
AVAILABLE_BODY_ENGINES.append("mediapipe")
# Determine the default engine. This can be overridden by user config.
DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None
DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None
@@ -205,6 +211,7 @@ PET_DETECTION_ENGINE = APP_CONFIG.get("pet_detection_engine",
DEFAULT_PET_ENGINE)
DEFAULT_PET_BOX_COLOR = "#98FB98" # PaleGreen
DEFAULT_BODY_BOX_COLOR = "#FF4500" # OrangeRed
DEFAULT_OBJECT_BOX_COLOR = "#FFD700" # Gold
DEFAULT_LANDMARK_BOX_COLOR = "#00BFFF" # DeepSkyBlue
# --- SHORTCUTS ---
@@ -273,6 +280,7 @@ VIEWER_ACTIONS = {
"detect_faces": ("Detect Faces", "Actions"),
"detect_pets": ("Detect Pets", "Actions"),
"fast_tag": ("Quick Tags", "Actions"),
"detect_bodies": ("Detect Bodies", "Actions"),
"rotate_right": ("Rotate Right", "Transform"),
"rotate_left": ("Rotate Left", "Transform"),
"zoom_in": ("Zoom In", "Transform"),
@@ -283,6 +291,10 @@ VIEWER_ACTIONS = {
"toggle_visibility": ("Show/Hide Main Window", "Window"),
"toggle_crop": ("Toggle Crop Mode", "Edit"),
"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 = {
@@ -299,6 +311,7 @@ DEFAULT_VIEWER_SHORTCUTS = {
"fullscreen": (Qt.Key_F11, Qt.NoModifier),
"detect_faces": (Qt.Key_F, Qt.NoModifier),
"detect_pets": (Qt.Key_P, Qt.NoModifier),
"detect_bodies": (Qt.Key_B, Qt.NoModifier),
"fast_tag": (Qt.Key_T, Qt.NoModifier),
"rotate_right": (Qt.Key_Plus, Qt.ControlModifier),
"rotate_left": (Qt.Key_Minus, Qt.ControlModifier),
@@ -310,6 +323,10 @@ DEFAULT_VIEWER_SHORTCUTS = {
"toggle_visibility": (Qt.Key_H, Qt.ControlModifier),
"toggle_crop": (Qt.Key_C, Qt.NoModifier),
"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: {}",
"ADD_FACE_TITLE": "Add Face",
"ADD_PET_TITLE": "Add Pet",
"ADD_BODY_TITLE": "Add Body",
"ADD_OBJECT_TITLE": "Add Object",
"ADD_LANDMARK_TITLE": "Add Landmark",
"ADD_FACE_LABEL": "Name:",
"ADD_PET_LABEL": "Name:",
"ADD_BODY_LABEL": "Name:",
"ADD_OBJECT_LABEL": "Name:",
"ADD_LANDMARK_LABEL": "Name:",
"DELETE_FACE": "Delete Face or area",
"DELETE_AREA_TITLE": "Delete area",
"CREATE_TAG_TITLE": "Create Tag",
"CREATE_TAG_TEXT": "The tag for '{}' does not exist. Do you want to create a "
"new one?",
@@ -409,6 +428,8 @@ _UI_TEXTS = {
"NEW_PERSON_TAG_TEXT": "Enter the full path for the tag:",
"NEW_PET_TAG_TITLE": "New Pet Tag",
"NEW_PET_TAG_TEXT": "Enter the full path for the tag:",
"NEW_BODY_TAG_TITLE": "New Body Tag",
"NEW_BODY_TAG_TEXT": "Enter the full path for the tag:",
"NEW_OBJECT_TAG_TITLE": "New Object Tag",
"NEW_OBJECT_TAG_TEXT": "Enter the full path for the tag:",
"NEW_LANDMARK_TAG_TITLE": "New Landmark Tag",
@@ -418,10 +439,11 @@ _UI_TEXTS = {
"one:",
"FACE_NAME_TOOLTIP": "Type a name or select from history.",
"CLEAR_TEXT_TOOLTIP": "Clear text field",
"RENAME_FACE_TITLE": "Rename Face or area",
"RENAME_AREA_TITLE": "Rename area",
"SHOW_FACES": "Show Faces && other areas",
"DETECT_FACES": "Detect Face",
"DETECT_PETS": "Detect Pets",
"DETECT_BODIES": "Detect Bodies",
"NO_FACE_LIBS": "No face detection libraries found. Install 'mediapipe' or "
"'face_recognition'.",
"THUMBNAIL_NO_NAME": "No name",
@@ -441,7 +463,7 @@ _UI_TEXTS = {
"MENU_SHOW_HISTORY": "Show History",
"MENU_SETTINGS": "Settings",
"SETTINGS_GROUP_SCANNER": "Scanner",
"SETTINGS_GROUP_FACES": "Faces && areas",
"SETTINGS_GROUP_AREAS": "Areas",
"SETTINGS_GROUP_THUMBNAILS": "Thumbnails",
"SETTINGS_GROUP_VIEWER": "Image Viewer",
"SETTINGS_PERSON_TAGS_LABEL": "Person tags:",
@@ -460,8 +482,19 @@ _UI_TEXTS = {
"to remember.",
"TYPE_FACE": "Face",
"TYPE_PET": "Pet",
"TYPE_BODY": "Body",
"TYPE_OBJECT": "Object",
"TYPE_LANDMARK": "Landmark",
"SETTINGS_BODY_TAGS_LABEL": "Body tags:",
"SETTINGS_BODY_ENGINE_LABEL": "Body Detection Engine:",
"SETTINGS_BODY_COLOR_LABEL": "Body box color:",
"SETTINGS_BODY_HISTORY_COUNT_LABEL": "Max body history:",
"SETTINGS_BODY_TAGS_TOOLTIP": "Default tags for bodies, separated by commas.",
"SETTINGS_BODY_ENGINE_TOOLTIP": "Library used for body detection.",
"SETTINGS_BODY_COLOR_TOOLTIP": "Color of the bounding box drawn around "
"detected bodies.",
"SETTINGS_BODY_HISTORY_TOOLTIP": "Maximum number of recently used body names "
"to remember.",
"SETTINGS_OBJECT_TAGS_LABEL": "Object tags:",
"SETTINGS_OBJECT_ENGINE_LABEL": "Object Detection Engine:",
"SETTINGS_OBJECT_COLOR_LABEL": "Object box color:",
@@ -493,12 +526,15 @@ _UI_TEXTS = {
"SETTINGS_THUMBS_RATING_COLOR_LABEL": "Thumbnails rating color:",
"SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Thumbnails filename font size:",
"SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL": "Thumbnails tags font size:",
"SETTINGS_SCAN_THREADS_LABEL": "Generation threads:",
"SETTINGS_SCAN_THREADS_TOOLTIP": "Maximum number of simultaneous threads to"
"generate thumbnails.",
"SETTINGS_SCAN_MAX_LEVEL_LABEL": "Scan Max Level:",
"SETTINGS_SCAN_BATCH_SIZE_LABEL": "Scan Batch Size:",
"SETTINGS_SCAN_FULL_ON_START_LABEL": "Scan Full On Start:",
"SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "File search engine:",
"SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Engine to use for finding files. "
"'Native' uses BagheeraSearch library. 'baloosearch' uses KDE Baloo command.",
"'Bagheera' uses BagheeraSearch library. 'Baloo' uses 'baloosearch' command.",
"SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Maximum directory depth to scan "
"recursively.",
"SETTINGS_SCAN_BATCH_SIZE_TOOLTIP": "Number of images to load in each batch.",
@@ -524,8 +560,8 @@ _UI_TEXTS = {
"SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP": "Font size for filenames in "
"thumbnails.",
"SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Font size for tags in thumbnails.",
"SEARCH_ENGINE_NATIVE": "Native",
"SEARCH_ENGINE_BALOO": "baloosearch",
"SEARCH_ENGINE_NATIVE": "Bagheera",
"SEARCH_ENGINE_BALOO": "Baloo",
"SETTINGS_VIEWER_WHEEL_SPEED_LABEL": "Viewer mouse wheel speed:",
"SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Filename lines:",
"SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Number of lines for the filename "
@@ -707,6 +743,11 @@ _UI_TEXTS = {
"VIEWER_MENU_CROP": "Crop Mode",
"VIEWER_MENU_SAVE_CROP": "Save Selection...",
"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)",
"SLIDESHOW_INTERVAL_TITLE": "Slideshow Interval",
"SLIDESHOW_INTERVAL_TEXT": "Seconds:",
@@ -801,19 +842,23 @@ _UI_TEXTS = {
"RENAME_VIEWER_ERROR_TEXT": "No se pudo renombrar el archivo: {}",
"ADD_FACE_TITLE": "Añadir Rostro",
"ADD_PET_TITLE": "Añadir Mascota",
"ADD_BODY_TITLE": "Añadir Cuerpo",
"ADD_OBJECT_TITLE": "Añadir Objeto",
"ADD_LANDMARK_TITLE": "Añadir Lugar",
"ADD_FACE_LABEL": "Nombre:",
"ADD_PET_LABEL": "Nombre:",
"ADD_BODY_LABEL": "Nombre:",
"ADD_OBJECT_LABEL": "Nombre:",
"ADD_LANDMARK_LABEL": "Nombre:",
"DELETE_FACE": "Eliminar Rostro o área",
"DELETE_AREA_TITLE": "Eliminar área",
"CREATE_TAG_TITLE": "Crear Etiqueta",
"CREATE_TAG_TEXT": "La etiqueta para '{}' no existe. ¿Deseas crear una nueva?",
"NEW_PERSON_TAG_TITLE": "Nueva Etiqueta de Persona",
"NEW_PERSON_TAG_TEXT": "Introduce la ruta completa de la etiqueta:",
"NEW_PET_TAG_TITLE": "Nueva Etiqueta de Mascota",
"NEW_PET_TAG_TEXT": "Introduce la ruta completa de la etiqueta:",
"NEW_BODY_TAG_TITLE": "Nueva Etiqueta de Cuerpo",
"NEW_BODY_TAG_TEXT": "Introduce la ruta completa de la etiqueta:",
"NEW_OBJECT_TAG_TITLE": "Nueva Etiqueta de Objeto",
"NEW_OBJECT_TAG_TEXT": "Introduce la ruta completa de la etiqueta:",
"NEW_LANDMARK_TAG_TITLE": "Nueva Etiqueta de Lugar",
@@ -823,10 +868,11 @@ _UI_TEXTS = {
"selecciona la correcta:",
"FACE_NAME_TOOLTIP": "Escribe un nombre o selecciónalo del historial.",
"CLEAR_TEXT_TOOLTIP": "Limpiar el campo de texto",
"RENAME_FACE_TITLE": "Renombrar Rostro o área",
"RENAME_AREA_TITLE": "Renombrar área",
"SHOW_FACES": "Mostrar Rostros y otras áreas",
"DETECT_FACES": "Detectar Rostros",
"DETECT_PETS": "Detectar Mascotas",
"DETECT_BODIES": "Detectar Cuerpos",
"NO_FACE_LIBS": "No se encontraron librerías de detección de rostros. Instale "
"'mediapipe' o 'face_recognition'.",
"THUMBNAIL_NO_NAME": "Sin nombre",
@@ -846,7 +892,7 @@ _UI_TEXTS = {
"MENU_SHOW_HISTORY": "Mostrar Historial",
"MENU_SETTINGS": "Opciones",
"SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_FACES": "Rostros y áreas",
"SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
"SETTINGS_GROUP_VIEWER": "Visor de Imágenes",
"SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persona:",
@@ -867,8 +913,21 @@ _UI_TEXTS = {
"usados recientemente para recordar.",
"TYPE_FACE": "Cara",
"TYPE_PET": "Mascota",
"TYPE_BODY": "Cuerpo",
"TYPE_OBJECT": "Objeto",
"TYPE_LANDMARK": "Lugar",
"SETTINGS_BODY_TAGS_LABEL": "Etiquetas de cuerpo:",
"SETTINGS_BODY_ENGINE_LABEL": "Motor de detección de cuerpos:",
"SETTINGS_BODY_COLOR_LABEL": "Color del recuadro de cuerpo:",
"SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máx historial cuerpos:",
"SETTINGS_BODY_TAGS_TOOLTIP": "Etiquetas predeterminadas para cuerpos, "
"separadas por comas.",
"SETTINGS_BODY_ENGINE_TOOLTIP": "Librería utilizada para la detección de "
"cuerpos.",
"SETTINGS_BODY_COLOR_TOOLTIP": "Color del cuadro delimitador dibujado "
"alrededor de los cuerpos detectados.",
"SETTINGS_BODY_HISTORY_TOOLTIP": "Número máximo de nombres de cuerpos "
"usados recientemente para recordar.",
"SETTINGS_OBJECT_TAGS_LABEL": "Etiquetas de objeto:",
"SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de objetos:",
"SETTINGS_OBJECT_COLOR_LABEL": "Color del recuadro de objeto:",
@@ -906,8 +965,8 @@ _UI_TEXTS = {
"SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño de Lote de Escaneo:",
"SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "Motor de búsqueda de archivos:",
"SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar archivos. "
"'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa el commando de"
"KDE Baloo.",
"'Bagheera' usa la librería de BagheeraSearch. 'Baloo0 usa el commando "
"'baloosearch'",
"SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo al Inicio:",
"SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidad máxima de directorio para "
"escanear recursivamente.",
@@ -1118,6 +1177,11 @@ _UI_TEXTS = {
"VIEWER_MENU_TAGS": "Etiquetas rápidas",
"VIEWER_MENU_CROP": "Modo Recorte",
"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_FILTER": "Imágenes (*.jpg *.jpeg *.png *.bmp *.webp)",
"SLIDESHOW_INTERVAL_TITLE": "Intervalo de Presentación",
@@ -1213,19 +1277,23 @@ _UI_TEXTS = {
"RENAME_VIEWER_ERROR_TEXT": "Non se puido renomear o ficheiro: {}",
"ADD_FACE_TITLE": "Engadir Rostro",
"ADD_PET_TITLE": "Engadir Mascota",
"ADD_BODY_TITLE": "Engadir Corpo",
"ADD_OBJECT_TITLE": "Engadir Obxecto",
"ADD_LANDMARK_TITLE": "Engadir Lugar",
"ADD_FACE_LABEL": "Nome:",
"ADD_PET_LABEL": "Nome:",
"ADD_BODY_LABEL": "Nome:",
"ADD_OBJECT_LABEL": "Nome:",
"ADD_LANDMARK_LABEL": "Nome:",
"DELETE_FACE": "Eliminar Rostro ou área",
"DELETE_AREA_TITLE": "Eliminar área",
"CREATE_TAG_TITLE": "Crear Etiqueta",
"CREATE_TAG_TEXT": "A etiqueta para '{}' non existe. Desexas crear unha nova?",
"NEW_PERSON_TAG_TITLE": "Nova Etiqueta de Persoa",
"NEW_PERSON_TAG_TEXT": "Introduce a ruta completa da etiqueta:",
"NEW_PET_TAG_TITLE": "Nova Etiqueta de Mascota",
"NEW_PET_TAG_TEXT": "Introduce a ruta completa da etiqueta:",
"NEW_BODY_TAG_TITLE": "Nova Etiqueta de Corpo",
"NEW_BODY_TAG_TEXT": "Introduce a ruta completa da etiqueta:",
"NEW_OBJECT_TAG_TITLE": "Nova Etiqueta de Obxecto",
"NEW_OBJECT_TAG_TEXT": "Introduce a ruta completa da etiqueta:",
"NEW_LANDMARK_TAG_TITLE": "Nova Etiqueta de Lugar",
@@ -1235,10 +1303,11 @@ _UI_TEXTS = {
"selecciona a correcta:",
"FACE_NAME_TOOLTIP": "Escribe un nome ou selecciónao do historial.",
"CLEAR_TEXT_TOOLTIP": "Limpar o campo de texto",
"RENAME_FACE_TITLE": "Renomear Rostro ou área",
"RENAME_AREA_TITLE": "Renomear área",
"SHOW_FACES": "Amosar Rostros e outras áreas",
"DETECT_FACES": "Detectar Rostros",
"DETECT_PETS": "Detectar Mascotas",
"DETECT_BODIES": "Detectar Corpos",
"NO_FACE_LIBS": "Non se atoparon librarías de detección de rostros. Instale "
"'mediapipe' ou 'face_recognition'.",
"THUMBNAIL_NO_NAME": "Sen nome",
@@ -1259,7 +1328,7 @@ _UI_TEXTS = {
"MENU_SHOW_HISTORY": "Amosar Historial",
"MENU_SETTINGS": "Opcións",
"SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_FACES": "Rostros e áreas",
"SETTINGS_GROUP_AREAS": "´áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
"SETTINGS_GROUP_VIEWER": "Visor de Imaxes",
"SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persoa:",
@@ -1280,8 +1349,21 @@ _UI_TEXTS = {
"recentemente para lembrar.",
"TYPE_FACE": "Cara",
"TYPE_PET": "Mascota",
"TYPE_BODY": "Corpo",
"TYPE_OBJECT": "Obxecto",
"TYPE_LANDMARK": "Lugar",
"SETTINGS_BODY_TAGS_LABEL": "Etiquetas de corpo:",
"SETTINGS_BODY_ENGINE_LABEL": "Motor de detección de corpos:",
"SETTINGS_BODY_COLOR_LABEL": "Cor do cadro de corpo:",
"SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máx historial corpos:",
"SETTINGS_BODY_TAGS_TOOLTIP": "Etiquetas predeterminadas para corpos, "
"separadas por comas.",
"SETTINGS_BODY_ENGINE_TOOLTIP": "Libraría utilizada para a detección de "
"corpos.",
"SETTINGS_BODY_COLOR_TOOLTIP": "Cor do cadro delimitador debuxado arredor "
"dos corpos detectados.",
"SETTINGS_BODY_HISTORY_TOOLTIP": "Número máximo de nomes de corpos usados "
"recentemente para lembrar.",
"SETTINGS_OBJECT_TAGS_LABEL": "Etiquetas de obxecto:",
"SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de obxectos:",
"SETTINGS_OBJECT_COLOR_LABEL": "Cor do cadro de obxecto:",
@@ -1322,8 +1404,8 @@ _UI_TEXTS = {
"SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño do Lote de Escaneo:",
"SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "Motor de busca de ficheiros:",
"SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar ficheiros. "
"'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa o comando de "
"KDE Baloo.",
"'Bagheera' usa a libraría de BagheeraSearch. 'Baloo' usa o comando de "
"'baloosearch'.",
"SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo ao Inicio:",
"SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidade máxima de directorio para "
"escanear recursivamente.",
@@ -1354,8 +1436,8 @@ _UI_TEXTS = {
"ficheiro en miniaturas.",
"SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Tamaño de fonte para etiquetas en "
"miniaturas.",
"SEARCH_ENGINE_NATIVE": "Nativo",
"SEARCH_ENGINE_BALOO": "baloosearch",
"SEARCH_ENGINE_NATIVE": "Bagheera",
"SEARCH_ENGINE_BALOO": "Baloo",
"SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Liñas para nome de ficheiro:",
"SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Número de liñas para o nome do "
"ficheiro debaixo da miniatura.",
@@ -1533,6 +1615,11 @@ _UI_TEXTS = {
"VIEWER_MENU_TAGS": "Etiquetas rápidas",
"VIEWER_MENU_CROP": "Modo Recorte",
"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_FILTER": "Imaxes (*.jpg *.jpeg *.png *.bmp *.webp)",
"SLIDESHOW_INTERVAL_TITLE": "Intervalo da Presentación",

View File

@@ -11,16 +11,19 @@ Classes:
interacts with the ImagePreloader.
"""
import os
import logging
import math
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt
from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform
from xmpmanager import XmpManager
from constants import (
APP_CONFIG, AVAILABLE_FACE_ENGINES, AVAILABLE_PET_ENGINES,
APP_CONFIG, AVAILABLE_FACE_ENGINES, AVAILABLE_PET_ENGINES, AVAILABLE_BODY_ENGINES,
MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, MEDIAPIPE_OBJECT_MODEL_PATH,
MEDIAPIPE_OBJECT_MODEL_URL, RATING_XATTR_NAME, XATTR_NAME, UITexts
)
from metadatamanager import XattrManager
from metadatamanager import XattrManager, load_common_metadata
logger = logging.getLogger(__name__)
class ImagePreloader(QThread):
@@ -78,21 +81,6 @@ class ImagePreloader(QThread):
self.mutex.unlock()
self.wait()
def _load_metadata(self, path):
"""Loads tag and rating data for a path."""
tags = []
raw_tags = XattrManager.get_attribute(path, XATTR_NAME)
if raw_tags:
tags = sorted(list(set(t.strip()
for t in raw_tags.split(',') if t.strip())))
raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0")
try:
rating = int(raw_rating)
except ValueError:
rating = 0
return tags, rating
def run(self):
"""
The main execution loop for the thread.
@@ -124,10 +112,10 @@ class ImagePreloader(QThread):
img = reader.read()
if not img.isNull():
# Load tags and rating here to avoid re-reading in main thread
tags, rating = self._load_metadata(path)
tags, rating = load_common_metadata(path)
self.image_ready.emit(idx, path, img, tags, rating)
except Exception:
pass
except Exception as e:
logger.warning(f"ImagePreloader failed to load {path}: {e}")
class ImageController(QObject):
@@ -157,6 +145,8 @@ class ImageController(QObject):
self.faces = []
self._current_tags = initial_tags if initial_tags is not None else []
self._current_rating = initial_rating
self._current_metadata_path = None
self._loaded_path = None
self.show_faces = False
# Preloading
@@ -169,6 +159,12 @@ class ImageController(QObject):
def cleanup(self):
"""Stops the background preloader thread."""
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):
"""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.
"""
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._loaded_path = None
self.rotation = 0
self.flip_h = False
self._current_tags = []
self._current_rating = 0
self.flip_v = False
self.faces = []
if not path:
return False
return False, False
# Check cache
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
self._current_tags = self._cached_next_tags
self._current_rating = self._cached_next_rating
self._current_metadata_path = path
self._cached_next_image = None
self._cached_next_index = -1
self._cached_next_tags = None
@@ -246,15 +253,18 @@ class ImageController(QObject):
image = reader.read()
if image.isNull():
self._trigger_preload()
return False
return False, False
self.pixmap_original = QPixmap.fromImage(image)
# Load tags and rating if not from cache
self._current_tags, self._current_rating = self._load_metadata(path)
# Load tags and rating if not already set for this path
if self._current_metadata_path != path:
self._current_tags, self._current_rating = load_common_metadata(path)
self._current_metadata_path = path
self._loaded_path = path
self.load_faces()
self._trigger_preload()
return True
return True, True
def load_faces(self):
"""
@@ -422,6 +432,38 @@ class ImageController(QObject):
face_data['h'] = h
return face_data
def _create_region_from_pixels(self, x, y, w, h, img_w, img_h, region_type):
"""
Creates a normalized region dictionary from pixel coordinates.
Args:
x (float): Top-left x coordinate in pixels.
y (float): Top-left y coordinate in pixels.
w (float): Width in pixels.
h (float): Height in pixels.
img_w (int): Image width in pixels.
img_h (int): Image height in pixels.
region_type (str): The type of region (Face, Pet, Body).
Returns:
dict: Validated normalized region or None.
"""
if img_w <= 0 or img_h <= 0:
return None
if w <= 0 or h <= 0:
return None
new_region = {
'name': '',
'x': (x + w / 2) / img_w,
'y': (y + h / 2) / img_h,
'w': w / img_w,
'h': h / img_h,
'type': region_type
}
return self._clamp_and_validate_face(new_region)
def _detect_faces_face_recognition(self, path):
"""Detects faces using the 'face_recognition' library."""
import face_recognition
@@ -433,12 +475,9 @@ class ImageController(QObject):
for (top, right, bottom, left) in face_locations:
box_w = right - left
box_h = bottom - top
new_face = {
'name': '',
'x': (left + box_w / 2) / w, 'y': (top + box_h / 2) / h,
'w': box_w / w, 'h': box_h / h, 'type': 'Face'
}
validated_face = self._clamp_and_validate_face(new_face)
validated_face = self._create_region_from_pixels(
left, top, box_w, box_h, w, h, 'Face'
)
if validated_face:
new_faces.append(validated_face)
except Exception as e:
@@ -484,15 +523,10 @@ class ImageController(QObject):
img_h, img_w = mp_image.height, mp_image.width
for detection in detection_result.detections:
bbox = detection.bounding_box # This is in pixels
new_face = {
'name': '',
'x': (bbox.origin_x + bbox.width / 2) / img_w,
'y': (bbox.origin_y + bbox.height / 2) / img_h,
'w': bbox.width / img_w,
'h': bbox.height / img_h,
'type': 'Face'
}
validated_face = self._clamp_and_validate_face(new_face)
validated_face = self._create_region_from_pixels(
bbox.origin_x, bbox.origin_y, bbox.width, bbox.height,
img_w, img_h, 'Face'
)
if validated_face:
new_faces.append(validated_face)
@@ -500,19 +534,27 @@ class ImageController(QObject):
print(f"Error during MediaPipe detection: {e}")
return new_faces
def _detect_pets_mediapipe(self, path):
"""Detects pets using the 'mediapipe' library object detection."""
def _detect_objects_mediapipe(self, path, allowlist, max_results, region_type):
"""
Generic method to detect objects using MediaPipe ObjectDetector.
Args:
path (str): Path to image file.
allowlist (list): List of category names to detect.
max_results (int): Maximum number of results to return.
region_type (str): The 'type' label for the detected regions.
"""
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision
new_pets = []
new_regions = []
if not os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH):
print(f"MediaPipe model not found at: {MEDIAPIPE_OBJECT_MODEL_PATH}")
print("Please download 'efficientdet_lite0.tflite' and place it there.")
print(f"URL: {MEDIAPIPE_OBJECT_MODEL_URL}")
return new_pets
return new_regions
try:
base_options = python.BaseOptions(
@@ -520,8 +562,8 @@ class ImageController(QObject):
options = vision.ObjectDetectorOptions(
base_options=base_options,
score_threshold=0.5,
max_results=5,
category_allowlist=["cat", "dog"]) # Detect cats and dogs
max_results=max_results,
category_allowlist=allowlist)
# Silence MediaPipe warnings (stderr) during initialization
stderr_fd = 2
@@ -542,21 +584,24 @@ class ImageController(QObject):
img_h, img_w = mp_image.height, mp_image.width
for detection in detection_result.detections:
bbox = detection.bounding_box
new_pet = {
'name': '',
'x': (bbox.origin_x + bbox.width / 2) / img_w,
'y': (bbox.origin_y + bbox.height / 2) / img_h,
'w': bbox.width / img_w,
'h': bbox.height / img_h,
'type': 'Pet'
}
validated_pet = self._clamp_and_validate_face(new_pet)
if validated_pet:
new_pets.append(validated_pet)
validated_region = self._create_region_from_pixels(
bbox.origin_x, bbox.origin_y, bbox.width, bbox.height,
img_w, img_h, region_type
)
if validated_region:
new_regions.append(validated_region)
except Exception as e:
print(f"Error during MediaPipe pet detection: {e}")
return new_pets
print(f"Error during MediaPipe {region_type} detection: {e}")
return new_regions
def _detect_pets_mediapipe(self, path):
"""Detects pets using the 'mediapipe' library object detection."""
return self._detect_objects_mediapipe(path, ["cat", "dog"], 5, "Pet")
def _detect_bodies_mediapipe(self, path):
"""Detects bodies using the 'mediapipe' library object detection."""
return self._detect_objects_mediapipe(path, ["person"], 10, "Body")
def detect_faces(self):
"""
@@ -615,6 +660,21 @@ class ImageController(QObject):
return []
def detect_bodies(self):
"""
Detects bodies using a configured or available detection engine.
"""
path = self.get_current_path()
if not path:
return []
engine = APP_CONFIG.get("body_detection_engine", "mediapipe")
if engine == "mediapipe" and "mediapipe" in AVAILABLE_BODY_ENGINES:
return self._detect_bodies_mediapipe(path)
return []
def get_display_pixmap(self):
"""
Applies current transformations (rotation, zoom, flip) to the original
@@ -709,30 +769,27 @@ class ImageController(QObject):
elif self.index < 0:
self.index = 0
# Update current image metadata if provided
self._current_tags = current_image_tags \
if current_image_tags is not None else []
self._current_rating = current_image_rating
# Update current image metadata
if current_image_tags is not None:
self._current_tags = current_image_tags
self._current_rating = current_image_rating
self._current_metadata_path = self.get_current_path()
else:
# Reload from disk if not provided to ensure consistency
path = self.get_current_path()
if path:
self._current_tags, self._current_rating = load_common_metadata(path)
self._current_metadata_path = path
else:
self._current_tags = []
self._current_rating = 0
self._current_metadata_path = None
self._cached_next_image = None
self._cached_next_index = -1
self._trigger_preload()
self.list_updated.emit(self.index)
def _load_metadata(self, path):
"""Loads tag and rating data for a path."""
tags = []
raw_tags = XattrManager.get_attribute(path, XATTR_NAME)
if raw_tags:
tags = sorted(list(set(t.strip()
for t in raw_tags.split(',') if t.strip())))
raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0")
try:
rating = int(raw_rating)
except ValueError:
rating = 0
return tags, rating
def update_list_on_exists(self, new_list, new_index=None):
"""
Updates the list only if the old list is a subset of the new one.
@@ -749,8 +806,17 @@ class ImageController(QObject):
self.index = new_index
if self.index >= len(self.image_list):
self.index = max(0, len(self.image_list) - 1)
self._current_tags = [] # Clear current tags/rating, will be reloaded
self._current_rating = 0
# Reload metadata for the current image to avoid stale/empty state
path = self.get_current_path()
if path:
self._current_tags, self._current_rating = load_common_metadata(path)
self._current_metadata_path = path
else:
self._current_tags = []
self._current_rating = 0
self._current_metadata_path = None
self._cached_next_image = None
self._cached_next_index = -1
self._trigger_preload()

View File

@@ -28,35 +28,221 @@ import collections
from pathlib import Path
from contextlib import contextmanager
import lmdb
from PySide6.QtCore import (QObject, QThread, Signal, QMutex, QReadWriteLock, QSize,
QWaitCondition, QByteArray, QBuffer, QIODevice, Qt, QTimer,
QRunnable, QThreadPool)
from PySide6.QtCore import (
QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition,
QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile
)
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES,
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 metadatamanager import XattrManager
try:
# Attempt to import bagheerasearch for direct integration
from bagheera_search_lib import BagheeraSearcher
HAVE_BAGHEERASEARCH_LIB = True
except ImportError:
HAVE_BAGHEERASEARCH_LIB = False
if HAVE_BAGHEERASEARCH_LIB:
try:
from bagheera_search_lib import BagheeraSearcher
except ImportError:
HAVE_BAGHEERASEARCH_LIB = False
pass
# Set up logging for better debugging
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 "
f"{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 "
f"{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."""
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.
# This drastically reduces memory usage and CPU time for large images
@@ -130,6 +316,10 @@ class CacheWriter(QThread):
if not self._running:
return
# Ensure we don't accept new items if stopping, especially when block=False
if not self._running:
return
# --- Soft Cleaning: Deduplication ---
# Remove redundant pending updates for the same image/size (e.g.
# rapid rotations)
@@ -154,7 +344,7 @@ class CacheWriter(QThread):
def stop(self):
self._mutex.lock()
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_space_available.wakeAll()
self._mutex.unlock()
@@ -187,11 +377,8 @@ class CacheWriter(QThread):
# Gather a batch of items
# Adaptive batch size: if queue is backing up, increase transaction size
# to improve throughput.
if not self._running:
# Flush everything if stopping
batch_limit = len(self._queue)
else:
batch_limit = self._max_size
# Respect max size even during shutdown to avoid OOM or huge transactions
batch_limit = self._max_size
batch = []
while self._queue and len(batch) < batch_limit:
@@ -1046,45 +1233,6 @@ class CacheCleaner(QThread):
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):
"""
Background thread to generate thumbnails for a specific size for a list of
@@ -1097,34 +1245,38 @@ class ThumbnailGenerator(QThread):
"""Helper to emit signals from runnables to the main thread."""
progress_tick = Signal()
def emit_progress(self):
self.progress_tick.emit()
def emit_progress(self):
self.progress_tick.emit()
def __init__(self, cache, paths, size):
def __init__(self, cache, paths, size, thread_pool_manager):
super().__init__()
self.cache = cache
self.paths = paths
self.size = size
self._abort = False
self.thread_pool_manager = thread_pool_manager
self._workers = []
self._workers_mutex = QMutex()
def stop(self):
"""Stops the worker thread gracefully."""
self._abort = True
self._workers_mutex.lock()
for worker in self._workers:
worker.shutdown()
self._workers_mutex.unlock()
self.wait()
def run(self):
"""
Main execution loop. Uses a thread pool to process paths in parallel.
"""
pool = QThreadPool()
max_threads = APP_CONFIG.get(
"generation_threads",
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4))
pool.setMaxThreadCount(max_threads)
pool = self.thread_pool_manager.get_pool()
emitter = self.SignalEmitter()
processed_count = 0
total = len(self.paths)
sem = QSemaphore(0)
def on_tick():
nonlocal processed_count
@@ -1138,14 +1290,34 @@ class ThumbnailGenerator(QThread):
# The signal/slot mechanism handles thread safety automatically.
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
started_count = 0
for path in self.paths:
if self._abort:
break
runnable = ThumbnailRunnable(self.cache, path, self.size, emitter)
pool.start(runnable)
runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
load_metadata=False, signal_emitter=emitter,
semaphore=sem)
runnable.setAutoDelete(False)
pool.waitForDone()
self.generation_complete.emit()
self._workers_mutex.lock()
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):
@@ -1160,7 +1332,8 @@ class ImageScanner(QThread):
finished_scan = Signal(int) # Total images found
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
if not paths or not isinstance(paths, (list, tuple)):
logger.warning("ImageScanner initialized with empty or invalid paths")
@@ -1168,6 +1341,7 @@ class ImageScanner(QThread):
super().__init__()
self.cache = cache
self.all_files = []
self.thread_pool_manager = thread_pool_manager
self._viewers = viewers
self._seen_files = set()
self._is_file_list = is_file_list
@@ -1196,12 +1370,23 @@ class ImageScanner(QThread):
self.pending_tasks = []
self._priority_queue = collections.deque()
self._processed_paths = set()
self._current_workers = []
self._current_workers_mutex = QMutex()
# Initial load
self.pending_tasks.append((0, APP_CONFIG.get(
"scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"])))
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")
def set_auto_load(self, enabled):
@@ -1404,8 +1589,8 @@ class ImageScanner(QThread):
return None, []
def _search(self, query):
engine = APP_CONFIG.get("search_engine", "Native")
if HAVE_BAGHEERASEARCH_LIB and (engine == "Native" or not SEARCH_CMD):
engine = APP_CONFIG.get("search_engine", "Bagheera")
if HAVE_BAGHEERASEARCH_LIB and (engine == "Bagheera" or not SEARCH_CMD):
query_text, main_options, other_options = self._parse_query(query)
try:
searcher = BagheeraSearcher()
@@ -1455,84 +1640,129 @@ class ImageScanner(QThread):
self.finished_scan.emit(self.count)
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
batch = []
while i < len(self.all_files):
if not self._is_running:
return
self.msleep(1) # Force yield to UI thread per item
while self._paused and self._is_running:
self.msleep(100)
# 1. Check priority queue first
priority_path = None
# Collect paths for this chunk to process in parallel
chunk_size = max_threads * 2
tasks = [] # List of (path, is_from_priority_queue)
# 1. Drain priority queue up to chunk size
self.mutex.lock()
while self._priority_queue:
while len(tasks) < chunk_size and self._priority_queue:
p = self._priority_queue.popleft()
if p not in self._processed_paths and p in self._seen_files:
priority_path = p
break
tasks.append((p, True))
self.mutex.unlock()
# 2. Determine file to process
if priority_path:
f_path = priority_path
# Don't increment 'i' yet, we are processing out of order
else:
f_path = self.all_files[i]
i += 1 # Only advance sequential index if processing sequentially
# 2. Fill remaining chunk space with sequential files
temp_i = i
while len(tasks) < chunk_size and temp_i < len(self.all_files):
p = self.all_files[temp_i]
# Skip if already processed (e.g. via priority earlier)
if p not in self._processed_paths \
and Path(p).suffix.lower() in IMAGE_EXTENSIONS:
tasks.append((p, False))
temp_i += 1
if f_path not in self._processed_paths \
and Path(f_path).suffix.lower() in IMAGE_EXTENSIONS:
# Pass the batch list to store result instead of emitting immediately
was_loaded = self._process_single_image(f_path, batch)
if not tasks:
# If no tasks found but still have files (e.g. all skipped extensions),
# update index and continue loop
i = temp_i
continue
# Emit batch if size is enough (responsiveness optimization)
# Dynamic batching: Start small for instant feedback.
# Keep batches small enough to prevent UI starvation during rapid cache
# reads.
if self.count <= 100:
target_batch_size = 20
else:
target_batch_size = 200
# Submit tasks to thread pool
sem = QSemaphore(0)
runnables = []
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)
batch = []
# Yield briefly to let the main thread process the emitted batch
# (update UI), preventing UI freeze during fast cache reading.
self.msleep(10)
for f_path, _ in tasks:
r = ScannerWorker(self.cache, f_path, semaphore=sem)
r.setAutoDelete(False)
runnables.append(r)
self._current_workers.append(r)
self.pool.start(r)
self._current_workers_mutex.unlock()
if was_loaded:
self._processed_paths.add(f_path)
# Wait only for this chunk to finish using semaphore
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
if images_loaded >= to_load and to_load > 0:
if batch: # Emit remaining items
self.images_found.emit(batch)
next_index = i + 1
total_files = len(self.all_files)
self.index = next_index
self.progress_msg.emit(UITexts.LOADED_PARTIAL.format(
self.count, total_files - next_index))
# Clean up runnables
runnables.clear()
if total_files > 0:
percent = int((self.count / total_files) * 100)
self.progress_percent.emit(percent)
# Advance sequential index
i = temp_i
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
# Emit batch if size is enough (responsiveness optimization)
if self.count <= 100:
target_batch_size = 20
else:
target_batch_size = 200
if len(batch) >= target_batch_size:
self.images_found.emit(batch)
batch = []
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
self.progress_msg.emit(
@@ -1547,88 +1777,17 @@ class ImageScanner(QThread):
self.progress_percent.emit(100)
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):
logger.info("ImageScanner stop requested")
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.condition.wakeAll()
self.mutex.unlock()

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ except ImportError:
exiv2 = None
HAVE_EXIV2 = False
from utils import preserve_mtime
from constants import RATING_XATTR_NAME, XATTR_NAME
def notify_baloo(path):
@@ -40,6 +41,24 @@ def notify_baloo(path):
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
def load_common_metadata(path):
"""
Loads tag and rating data for a path using extended attributes.
"""
tags = []
raw_tags = XattrManager.get_attribute(path, XATTR_NAME)
if raw_tags:
tags = sorted(list(set(t.strip()
for t in raw_tags.split(',') if t.strip())))
raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0")
try:
rating = int(raw_rating)
except ValueError:
rating = 0
return tags, rating
class MetadataManager:
"""Manages reading EXIF, IPTC, and XMP metadata."""
@@ -136,3 +155,33 @@ class XattrManager:
except Exception as e:
raise IOError(f"Could not save xattr '{attr_name}' "
"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
interface.
"""
import os
from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
@@ -18,14 +17,40 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import (
QImageReader, QIcon, QColor
)
from PySide6.QtCore import (
Qt, QFileInfo, QLocale
)
from PySide6.QtCore import (QThread, Signal, Qt, QFileInfo, QLocale)
from constants import (
RATING_XATTR_NAME, XATTR_NAME, UITexts
)
from metadatamanager import MetadataManager, HAVE_EXIV2, notify_baloo
from utils import preserve_mtime
from metadatamanager import MetadataManager, HAVE_EXIV2, XattrManager
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):
@@ -51,6 +76,7 @@ class PropertiesDialog(QDialog):
self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating
self.loader = None
self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
@@ -128,7 +154,8 @@ class PropertiesDialog(QDialog):
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
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)
tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"),
UITexts.PROPERTIES_METADATA_TAB)
@@ -159,7 +186,8 @@ class PropertiesDialog(QDialog):
# This is a disk read.
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)
tabs.addTab(exif_widget, QIcon.fromTheme("view-details"),
@@ -173,10 +201,18 @@ class PropertiesDialog(QDialog):
btn_box.rejected.connect(self.close)
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
extended attributes (xattrs) into the metadata table.
Updates the metadata table with extended attributes.
Merges initial tags/rating with loaded xattrs.
"""
self.table.blockSignals(True)
self.table.setRowCount(0)
@@ -188,26 +224,11 @@ class PropertiesDialog(QDialog):
if self._initial_rating > 0:
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
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))
@@ -224,11 +245,34 @@ class PropertiesDialog(QDialog):
row += 1
self.table.blockSignals(False)
def load_exif_data(self):
"""Loads EXIF, XMP, and IPTC metadata using the MetadataManager."""
def reload_metadata(self):
"""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.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:
self.exif_table.setRowCount(1)
error_color = QColor("red")
@@ -243,8 +287,6 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False)
return
exif_data = MetadataManager.read_all_metadata(self.path)
if not exif_data:
self.exif_table.setRowCount(1)
item = QTableWidgetItem(UITexts.INFO)
@@ -291,16 +333,11 @@ class PropertiesDialog(QDialog):
if item.column() == 1:
key = self.table.item(item.row(), 0).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:
with preserve_mtime(self.path):
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)
XattrManager.set_attribute(self.path, key, val_to_set)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
@@ -361,10 +398,8 @@ class PropertiesDialog(QDialog):
key))
if ok2:
try:
with preserve_mtime(self.path):
os.setxattr(self.path, key, val.encode('utf-8'))
notify_baloo(self.path)
self.load_metadata()
XattrManager.set_attribute(self.path, key, val)
self.reload_metadata()
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e))
@@ -378,9 +413,7 @@ class PropertiesDialog(QDialog):
"""
key = self.table.item(row, 0).text()
try:
with preserve_mtime(self.path):
os.removexattr(self.path, key)
notify_baloo(self.path)
XattrManager.set_attribute(self.path, key, None)
self.table.removeRow(row)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,

View File

@@ -25,7 +25,8 @@ from constants import (
APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR,
DEFAULT_PET_BOX_COLOR, DEFAULT_OBJECT_BOX_COLOR, DEFAULT_LANDMARK_BOX_COLOR,
FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL,
AVAILABLE_PET_ENGINES, MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL,
AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR,
MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_FILENAME_LINES_DEFAULT,
THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT,
@@ -34,7 +35,7 @@ from constants import (
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT,
THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_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_pet_color = DEFAULT_PET_BOX_COLOR
self.current_body_color = DEFAULT_BODY_BOX_COLOR
self.current_object_color = DEFAULT_OBJECT_BOX_COLOR
self.current_landmark_color = DEFAULT_LANDMARK_BOX_COLOR
self.current_thumbs_bg_color = THUMBNAILS_BG_COLOR_DEFAULT
@@ -293,9 +295,9 @@ class SettingsDialog(QDialog):
search_engine_layout = QHBoxLayout()
search_engine_label = QLabel(UITexts.SETTINGS_SCANNER_SEARCH_ENGINE_LABEL)
self.search_engine_combo = QComboBox()
self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_NATIVE, "Native")
self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_NATIVE, "Bagheera")
if SEARCH_CMD:
self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_BALOO, "baloosearch")
self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_BALOO, "Baloo")
search_engine_layout.addWidget(search_engine_label)
search_engine_layout.addWidget(self.search_engine_combo)
@@ -462,6 +464,53 @@ class SettingsDialog(QDialog):
self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP)
faces_layout.addLayout(pet_history_layout)
# --- Body Section ---
faces_layout.addSpacing(10)
body_header = QLabel("Body")
body_header.setFont(QFont("Sans", 10, QFont.Bold))
faces_layout.addWidget(body_header)
body_tags_layout = QHBoxLayout()
body_tags_label = QLabel(UITexts.SETTINGS_BODY_TAGS_LABEL)
self.body_tags_edit = QLineEdit()
self.body_tags_edit.setPlaceholderText("tag1, tag2, tag3/subtag")
self.body_tags_edit.setClearButtonEnabled(True)
body_tags_layout.addWidget(body_tags_label)
body_tags_layout.addWidget(self.body_tags_edit)
body_tags_label.setToolTip(UITexts.SETTINGS_BODY_TAGS_TOOLTIP)
self.body_tags_edit.setToolTip(UITexts.SETTINGS_BODY_TAGS_TOOLTIP)
faces_layout.addLayout(body_tags_layout)
# body_engine_layout = QHBoxLayout()
# body_engine_label = QLabel(UITexts.SETTINGS_BODY_ENGINE_LABEL)
# self.body_engine_combo = QComboBox()
# self.body_engine_combo.addItems(AVAILABLE_BODY_ENGINES)
# body_engine_layout.addWidget(body_engine_label)
# body_engine_layout.addWidget(self.body_engine_combo, 1)
# body_engine_label.setToolTip(UITexts.SETTINGS_BODY_ENGINE_TOOLTIP)
# self.body_engine_combo.setToolTip(UITexts.SETTINGS_BODY_ENGINE_TOOLTIP)
# faces_layout.addLayout(body_engine_layout)
body_color_layout = QHBoxLayout()
body_color_label = QLabel(UITexts.SETTINGS_BODY_COLOR_LABEL)
self.body_color_btn = QPushButton()
self.body_color_btn.clicked.connect(self.choose_body_color)
body_color_layout.addWidget(body_color_label)
body_color_layout.addWidget(self.body_color_btn)
body_color_label.setToolTip(UITexts.SETTINGS_BODY_COLOR_TOOLTIP)
self.body_color_btn.setToolTip(UITexts.SETTINGS_BODY_COLOR_TOOLTIP)
faces_layout.addLayout(body_color_layout)
body_history_layout = QHBoxLayout()
self.body_history_spin = QSpinBox()
self.body_history_spin.setRange(5, 100)
body_hist_label = QLabel(UITexts.SETTINGS_BODY_HISTORY_COUNT_LABEL)
body_history_layout.addWidget(body_hist_label)
body_history_layout.addWidget(self.body_history_spin)
body_hist_label.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP)
self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP)
faces_layout.addLayout(body_history_layout)
# --- Object Section ---
faces_layout.addSpacing(10)
object_header = QLabel("Object")
@@ -593,7 +642,7 @@ class SettingsDialog(QDialog):
# Add tabs in the new order
tabs.addTab(thumbs_tab, UITexts.SETTINGS_GROUP_THUMBNAILS)
tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER)
tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_FACES)
tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS)
tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER)
# --- Button Box ---
@@ -625,16 +674,19 @@ class SettingsDialog(QDialog):
person_tags = APP_CONFIG.get(
"person_tags", SCANNER_SETTINGS_DEFAULTS["person_tags"])
pet_tags = APP_CONFIG.get("pet_tags", "")
body_tags = APP_CONFIG.get("body_tags", "")
object_tags = APP_CONFIG.get("object_tags", "")
landmark_tags = APP_CONFIG.get("landmark_tags", "")
face_detection_engine = APP_CONFIG.get("face_detection_engine")
pet_detection_engine = APP_CONFIG.get("pet_detection_engine")
body_detection_engine = APP_CONFIG.get("body_detection_engine")
object_detection_engine = APP_CONFIG.get("object_detection_engine")
landmark_detection_engine = APP_CONFIG.get("landmark_detection_engine")
face_color = APP_CONFIG.get("face_box_color", DEFAULT_FACE_BOX_COLOR)
pet_color = APP_CONFIG.get("pet_box_color", DEFAULT_PET_BOX_COLOR)
body_color = APP_CONFIG.get("body_box_color", DEFAULT_BODY_BOX_COLOR)
object_color = APP_CONFIG.get("object_box_color", DEFAULT_OBJECT_BOX_COLOR)
landmark_color = APP_CONFIG.get("landmark_box_color",
DEFAULT_LANDMARK_BOX_COLOR)
@@ -645,6 +697,8 @@ class SettingsDialog(QDialog):
"faces_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
pet_history_count = APP_CONFIG.get(
"pets_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
body_history_count = APP_CONFIG.get(
"body_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
object_history_count = APP_CONFIG.get(
"object_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
landmark_history_count = APP_CONFIG.get(
@@ -687,19 +741,36 @@ class SettingsDialog(QDialog):
self.threads_spin.setValue(scan_threads)
# Set search engine
index = self.search_engine_combo.findData(search_engine)
if index != -1:
self.search_engine_combo.setCurrentIndex(index)
if HAVE_BAGHEERASEARCH_LIB:
self.search_engine_combo.setEnabled(True)
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.person_tags_edit.setText(person_tags)
self.pet_tags_edit.setText(pet_tags)
self.body_tags_edit.setText(body_tags)
self.object_tags_edit.setText(object_tags)
self.landmark_tags_edit.setText(landmark_tags)
self.set_button_color(face_color)
self.set_pet_button_color(pet_color)
self.set_body_button_color(body_color)
self.set_object_button_color(object_color)
self.set_landmark_button_color(landmark_color)
@@ -709,6 +780,8 @@ class SettingsDialog(QDialog):
if self.pet_engine_combo and pet_detection_engine in AVAILABLE_PET_ENGINES:
self.pet_engine_combo.setCurrentText(pet_detection_engine)
if body_detection_engine and hasattr(self, "body_detection_engine_combo"):
self.body_engine_combo.setCurrentText(body_detection_engine)
if object_detection_engine and hasattr(self, "object_engine_combo"):
self.object_engine_combo.setCurrentText(object_detection_engine)
if landmark_detection_engine and hasattr(self, "landmark_engine_combo"):
@@ -717,6 +790,7 @@ class SettingsDialog(QDialog):
self.mru_tags_spin.setValue(mru_tags_count)
self.face_history_spin.setValue(face_history_count)
self.pet_history_spin.setValue(pet_history_count)
self.body_history_spin.setValue(body_history_count)
self.object_history_spin.setValue(object_history_count)
self.landmark_history_spin.setValue(landmark_history_count)
@@ -771,6 +845,18 @@ class SettingsDialog(QDialog):
if color.isValid():
self.set_pet_button_color(color.name())
def set_body_button_color(self, color_str):
"""Sets the background color of the body button and stores the value."""
self.body_color_btn.setStyleSheet(
f"background-color: {color_str}; border: 1px solid gray;")
self.current_body_color = color_str
def choose_body_color(self):
"""Opens a color picker dialog for body box."""
color = QColorDialog.getColor(QColor(self.current_body_color), self)
if color.isValid():
self.set_body_button_color(color.name())
def set_object_button_color(self, color_str):
"""Sets the background color of the object button."""
self.object_color_btn.setStyleSheet(
@@ -938,19 +1024,23 @@ class SettingsDialog(QDialog):
APP_CONFIG["scan_max_level"] = self.scan_max_level_spin.value()
APP_CONFIG["generation_threads"] = self.threads_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["person_tags"] = self.person_tags_edit.text()
APP_CONFIG["pet_tags"] = self.pet_tags_edit.text()
APP_CONFIG["body_tags"] = self.body_tags_edit.text()
APP_CONFIG["object_tags"] = self.object_tags_edit.text()
APP_CONFIG["landmark_tags"] = self.landmark_tags_edit.text()
APP_CONFIG["face_box_color"] = self.current_face_color
APP_CONFIG["pet_box_color"] = self.current_pet_color
APP_CONFIG["body_box_color"] = self.current_body_color
APP_CONFIG["object_box_color"] = self.current_object_color
APP_CONFIG["landmark_box_color"] = self.current_landmark_color
APP_CONFIG["tags_menu_max_items"] = self.mru_tags_spin.value()
APP_CONFIG["faces_menu_max_items"] = self.face_history_spin.value()
APP_CONFIG["pets_menu_max_items"] = self.pet_history_spin.value()
APP_CONFIG["body_menu_max_items"] = self.body_history_spin.value()
APP_CONFIG["object_menu_max_items"] = self.object_history_spin.value()
APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value()
@@ -975,9 +1065,10 @@ class SettingsDialog(QDialog):
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value()
APP_CONFIG["viewer_auto_resize_window"] = \
self.viewer_auto_resize_check.isChecked()
if self.face_engine_combo:
APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText()
APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText()
APP_CONFIG["pet_detection_engine"] = self.pet_engine_combo.currentText()
if hasattr(self, "object_engine_combo"):
APP_CONFIG["body_detection_engine"] = self.body_engine_combo.currentText()
if hasattr(self, "object_engine_combo"):
APP_CONFIG["object_detection_engine"] = \
self.object_engine_combo.currentText()

View File

@@ -1121,6 +1121,9 @@ class FaceNameInputWidget(QWidget):
if self.region_type == "Pet":
max_items = APP_CONFIG.get("pets_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
elif self.region_type == "Body":
max_items = APP_CONFIG.get("body_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
elif self.region_type == "Object":
max_items = APP_CONFIG.get("object_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
@@ -1188,6 +1191,12 @@ class FaceNameInputWidget(QWidget):
parent_tags_str = "Pet"
dialog_title = UITexts.NEW_PET_TAG_TITLE
dialog_text = UITexts.NEW_PET_TAG_TEXT
elif self.region_type == "Body":
parent_tags_str = APP_CONFIG.get("body_tags", "Body")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Body"
dialog_title = UITexts.NEW_BODY_TAG_TITLE
dialog_text = UITexts.NEW_BODY_TAG_TEXT
elif self.region_type == "Object":
parent_tags_str = APP_CONFIG.get("object_tags", "Object")
if not parent_tags_str or not parent_tags_str.strip():
@@ -1273,6 +1282,10 @@ class FaceNameInputWidget(QWidget):
parent_tags_str = APP_CONFIG.get("pet_tags", "Pet")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Pet"
elif self.region_type == "Body":
parent_tags_str = APP_CONFIG.get("body_tags", "Body")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Body"
elif self.region_type == "Object":
parent_tags_str = APP_CONFIG.get("object_tags", "Object")
if not parent_tags_str or not parent_tags_str.strip():