First commit

This commit is contained in:
Ignacio Serantes
2026-03-22 18:16:51 +01:00
commit a402828d1a
23 changed files with 14768 additions and 0 deletions

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# BagheeraView
BagheeraView is an image viewer specifically designed for the KDE ecosystem. Built with **Python** and **PySide6**, it leverages Baloo to deliver a powerful, agile, and fluid metadata-based image management experience without abandoning classic folder management.
## 🚀 Key Features
- **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**.
- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering both **Flat View, Date View** and **Folder View** modes.
- **Face Detection:** Integrated computer vision to detect faces within your photos and assign person names.
- **Metadata:** A basic viewer for **EXIF, IPTC, and XMP** data.
- **Tagging & Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes.
- **Smart State Persistence:** The application remembers your workflow. Your **last used sort order** and view settings are automatically saved and restored upon startup.
## 🛠 Technical Stack
- **Language:** Python 3
- **GUI Framework:** PySide6 (Qt for Python)
- **KDE Integration:** Baloo search and management
- **Metadata Handling:** Advanced image header manipulation to store faces and support to file extended attributes
## 🌐 Internationalization (i18n)
BagheeraView is designed for a global audience with localized interface support. Initial supported languages include:
- **English** (Base development language)
- **Galician**
- **Spanish**
> **Note:** Following internal configuration standards, all source code labels and developer logs are maintained in English for technical consistency.
## ⚙️ Configuration & Persistence
BagheeraView is built for workflow continuity. The application stores the user's environment state in the local configuration:
- **Restore Last Layout:** Last layout and preferences are automatically saved and restored every time you launch it.
- **Keyboard configuration:** All hotkeys can be parametriced by the user.
- **Interface Language:** The application automatically detects the system locale and applies the corresponding translation on startup or user can decide main language.
## 📥 Installation (Development)
Ensure you have the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system.
Bash
```
# Clone the repository
git clone https://github.com/youruser/BagheeraView.git
cd BagheeraView
# Install dependencies
pip install -r requirements.txt
# Run the application
python main.py
```
## 🤝 Contributing
We follow an **English-first policy** for the codebase and documentation.
1. **Fork** the project.
2. Create your **Feature Branch** (`git checkout -b feature/AmazingFeature`).
3. **Commit** your changes (`git commit -m 'Add some AmazingFeature'`).
4. **Push** to the branch (`git push origin feature/AmazingFeature`).
5. Open a **Pull Request**.

1
bagheera_query_parser_lib Symbolic link
View File

@@ -0,0 +1 @@
/home/ignacio/devel/bagheera/bagheerasearch/bagheera_query_parser_lib

1
bagheera_search_lib Symbolic link
View File

@@ -0,0 +1 @@
/home/ignacio/devel/bagheera/bagheerasearch/bagheera_search_lib

1
bagheeraview Symbolic link
View File

@@ -0,0 +1 @@
/home/ignacio/.config/iserantes/bagheeraview

23
bagheeraview.desktop Executable file
View File

@@ -0,0 +1,23 @@
[Desktop Entry]
Categories=Graphics;RasterGraphics;Viewer;
Comment[en_US]=Bagheera Image Viewer
Comment=Bagheera Image Viewer
Encoding=UTF-8
Exec=bagheeraview %u
GenericName[en_US]=Bagheera Image Viewer
GenericName=Bagheera Image Viewer
Icon=bagheeraview
MimeType=inode/directory;image/x-xbitmap;image/x-tga;image/x-portable-pixmap;image/x-portable-graymap;image/x-portable-bitmap;image/x-pict;image/webp;image/vnd.zbrush.pcx;image/vnd.adobe.photoshop;image/tiff;image/png;image/jpeg;image/gif;image/bmp;
Name[en_US]=Bagheera Image Viewer
Name=Bagheera Image Viewer
NoDisplay=false
Path=
StartupNotify=true
Terminal=false
TerminalOptions=
Type=Application
X-DBUS-ServiceName=
X-DBUS-StartupType=
X-DCOP-ServiceType=
X-KDE-SubstituteUID=false
X-KDE-Username=

4313
bagheeraview.py Executable file

File diff suppressed because it is too large Load Diff

23
bagheeraview_devel.desktop Executable file
View File

@@ -0,0 +1,23 @@
[Desktop Entry]
Categories=Graphics;RasterGraphics;Viewer;
Comment[en_US]=Bagheera Image Viewer Devel
Comment=Bagheera Image Viewer Devel
Encoding=UTF-8
Exec=/home/ignacio/devel/bagheera/bagheeraview/bagheeraview.py %u
GenericName[en_US]=Bagheera Image Viewer Devl
GenericName=Bagheera Image Viewer Devel
Icon=/home/ignacio/devel/bagheera/icons/Gemini_Generated_Image_qgn3p4qgn3p4qgn3.png
MimeType=inode/directory;image/x-xbitmap;image/x-tga;image/x-portable-pixmap;image/x-portable-graymap;image/x-portable-bitmap;image/x-pict;image/webp;image/vnd.zbrush.pcx;image/vnd.adobe.photoshop;image/tiff;image/png;image/jpeg;image/gif;image/bmp;
Name[en_US]=Bagheera Image Viewer Devel
Name=Bagheera Image Viewer Devel
NoDisplay=false
Path=
StartupNotify=true
Terminal=false
TerminalOptions=
Type=Application
X-DBUS-ServiceName=
X-DBUS-StartupType=
X-DCOP-ServiceType=
X-KDE-SubstituteUID=false
X-KDE-Username=

1
baloo_tools Symbolic link
View File

@@ -0,0 +1 @@
/home/ignacio/devel/bagheera/bagheerasearch/baloo_tools

47
build.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
#source .venv/bin/activate
case $1 in
-v)
shift
case $1 in
3.8) PYINSTALLER=pyinstaller-3.8;;
3.9) PYINSTALLER=pyinstaller-3.9;;
3.10) PYINSTALLER=pyinstaller-3.10;;
3.11) PYINSTALLER=pyinstaller-3.11;;
3.12) PYINSTALLER=pyinstaller-3.12;;
3.13) PYINSTALLER=pyinstaller-3.13;;
3.14) PYINSTALLER=pyinstaller-3.14;;
*) PYINSTALLER=pyinstaller;;
esac
;;
--version=3.8) PYINSTALLER=pyinstaller-3.8;;
--version=3.9) PYINSTALLER=pyinstaller-3.9;;
--version=3.10) PYINSTALLER=pyinstaller-3.10;;
--version=3.11) PYINSTALLER=pyinstaller-3.11;;
--version=3.12) PYINSTALLER=pyinstaller-3.12;;
--version=3.13) PYINSTALLER=pyinstaller-3.13;;
--version=3.14) PYINSTALLER=pyinstaller-3.14;;
*) PYINSTALLER=pyinstaller;;
esac
# $PYINSTALLER \
# --add-binary 'desktop/Desktogram.png:desktop' \
# --add-binary 'locale/en/LC_MESSAGES/messages.mo:locale/en/LC_MESSAGES' \
# --add-binary 'locale/es/LC_MESSAGES/messages.mo:locale/es/LC_MESSAGES' \
# --add-binary 'locale/gl/LC_MESSAGES/messages.mo:locale/gl/LC_MESSAGES' \
# --add-data 'js/downloader.js:js' \
# --noconsole \
# -F tagmanager.py
# Sólo en windows.
# --icon=desktop/TagsManager.png \
$PYINSTALLER \
--onefile \
--noconsole \
--windowed \
-F bagheeraview.py
#deactivate

256
changelog.txt Normal file
View File

@@ -0,0 +1,256 @@
v0.9.11 -
· Hacer que el image viewer standalone admita múltiles 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
· Me gustaría que al restaurar un layout, si una imagen no existe, se muestre un aviso en lugar de simplemente omitirla. ¿Puedes implementarlo?
. Me gustaría que el ajuste "Scan Max Level" muestre una advertencia visual si se establece en un valor muy alto (por ejemplo > 5).
· ¿Puedes hacer que el diálogo de selección de etiquetas (cuando hay múltiples coincidencias) muestre una vista previa de una imagen que ya tenga esa etiqueta?
· Me gustaría que el estado del filtro de la vista de miniaturas (tags seleccionados, texto de búsqueda) también se guarde en los layouts. ¿Puedes implementarlo?
· ¿Podrías añadir un botón "Exportar a CSV" en el diálogo de propiedades para guardar todos los metadatos en un archivo?
· 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.
cuando se hace una nueva búsqueda que no se refresquen los tags, ni filtros, ni nada hasta que venga la primera imagen de la búsqueda nueva. Actualizar algo que se está destruyendo no tiene sentido. Lo mismo aplica si se cambia la agrupación, paramos las actualizaciones y luego, cuando acabe la agrupación activamos de nuevo los tags y los filtros y todo lo que implique un refresco de pantalla.
¿Puedes comprobar si la lógica de `ThumbnailSortFilterProxyModel` puede optimizarse aún más, quizás cacheando los resultados de `filterAcceptsRow` para evitar comprobaciones repetitivas cuando no cambian los filtros?
Me gustaría que el scanner pudiera detectar cambios en el sistema de archivos (inotify/watchdog) y actualizar la vista automáticamente si se añaden imágenes a la carpeta actual.
Genera una estructura de código segura para PropertiesDialog que cargue los metadatos de forma asíncrona.
¿Cómo puedo mover la comprobación de animaciones en load_and_fit_image a un hilo secundario para evitar el bloqueo?
¿Cómo puedo asegurame de que la ventana del visor se abra centrada en la pantalla correcta tras el redimensionado?
Check if the `CacheWriter` batch processing logic correctly handles empty batches or exceptions to prevent data loss.
Verifica si el manejo de excepciones en _process_single_image es lo suficientemente robusto para evitar que el hilo de escaneo muera por un archivo corrupto.
How can I implement a bulk rename feature for the selected pet or face tags?
¿Cómo puedo añadir una opción "Abrir con otra aplicación..." al final del submenú que lance el selector de aplicaciones del sistema?
¿Cómo puedo añadir soporte para arrastrar y soltar imágenes desde el visor (ImageViewer) a otras aplicaciones?
¿Por qué la selección de imágenes se pierde o cambia incorrectamente al cambiar el modo de agrupación?
¿Por qué al cambiar de "Separar por Carpeta" a "Plano" la selección se pierde a veces?
Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligente de imágenes grandes en el visor basada en la dirección del movimiento del ratón?
¿Cómo puedo limitar el tamaño total de la caché en disco a un valor específico (ej. 5GB) y borrar automáticamente las entradas más antiguas (LRU)?
¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista?
v0.9.10 - Eleven step to 1.0
· Slideshow inverso
· Más mejoras de rendimiento y seguridad
· Mejorado el desplazamiento de la imagen en el image viewer
v0.9.9 - Ten stet o 1.0
· Added pets support
· Nueva opción de abrir con otra aplicación
· Mejoras en la configuración
v0.9.8 - Nine step to 1.0
· Crop mode
· Muchos cambios y correcciones de bug.
v0.9.7 - Eight stetp to 1.0
· Nuevo parámetro --x11 que fuerza que la aplicación use X11 en vez de Xorg.
· Si no estamos en --x11 layout no estará disponible.
v0.9.6 - Seven step to 1.0
· Más cambios hechos por la IA para mejorar velocidad y reducir acceso a disco.
· El menú open ahora es mucho mejor y se ha añadido también al image viewer.
v0.9.5 - Six step to 1.0
· Alguna mejora y más velocidad, en teoría mucha más velocidad y optimizaciones.
· Nuevas opciones para añadir tags con AND y con OR
· Una porrada de cambios hecho por la IA, a ver en que acaba esta versión al final.
v0.9.4 - Five step to 1.0
· Nueva opciones en el menú de ImageViewer.
· Corregido un problema al ocultar la ventana principal sin imágen seleccionada.
· En teoría, mejorada la velocidad en procesos con muchos thumbnails.
v0.9.3 - Four step to 1.0
· Cambiado balooctl por una llamada a DBus.
· Baloo search is configurable.
· Fixed bad typo, is "user.xdg.comment" not "user.comment".
· File comment control uses all space available.
· Added to text control delete icon.
v0.9.2 - Third step to 1.0
· Added BagueeraSearch lib support
v0.9.1 - Second step to 1.0
· Empty comments delete tag instead of saving empty values.
· Se puede decidir que se muestra debajo de los thumbnails.
· Shortcuts code refactorized.
· Más opciones de parametrización.
v0.9.0 - First step to 1.0
· Added spport to avoid duplicates on face detection.
· Fixed rename face delete tag even when exists other faces with same name.
· Fixed delete face does not delete associated tag.
· Added tooltip to thumbnails showing full path.
· Minor changes and improvements on properties form.
v0.1.25 - Last alpha version
· Configuration
v0.1.24 - Best resolution guess
· Best resolution guess for image viewer
· Initial configuration form
v0.1.23 - Thumbnails view improved
· New group for day, month, year and rating
v0.1.22 - More changes.
· Filmstrip position can be changed to top, right, left and bottom.
· Mejora de los menús. Queda el menú open por arreglar.
v0.1.21 - More changes
· Improve scanning to make applications more responsive
· Fixed rating not updated on thumbnails
· Show faces state is shared on image viewers and saved
· Several changes in image viewer menu
· Added suport to animate gif images
v0.1.20 - Optimización
· Optimizada la carga de thumbnails
v0.1.19 - Better thumbnail generation
· Cambio en la forma en la que se cargan los thumbnails con tramos de 128, 256 y 512
· Filmstrip no actualizaba la selección a la imagen visualizada
v0.1.18 - Mediapipe
· Fast menu seleccionado el primer elemento por defecto
· Cambio de API en mediapipe, a partir de ahora se necesit un fichero
· Fixed if filter is active if tags are changed view and thumbnails must be refreshed
· Fix: al activar los thumbnails si el filtro está activo no muestra nada
v0.1.17 - Polished
· Fixed issues with fast tag menu
· Improved shortcuts handling
· Fixed hags becouse thumbnail viewe was requesting more images than available
v0.1.16 - Multilanguage
· Multilanguage: en, es, gl
· Face and tags history items number managed by constact correctly
v0.1.15 - Better faces and tags handling
· Added new method to name faces with history
· Added menu to fast tag in image viewer
· Fixed same keys does not work on input text controls
· Improved layout save and restore: status bar, film strip, main dock position
v0.1.14 - Minor improvements
· Added baloosearch as a fallback for bagheerasearch
· Added confirmation to clear cache and clear delete database to relinquish
· Added shortcuts help
· Shortcuts can be changed and saved
· After face detection name is asked to user
· Fixed navigation on thumbnails using page-up and page-down
v0.1.13 - Minor changesenv
· Fixed filter tab not refreshed if selected
· In properties form grid columns are resizeable
· Added counters to filter tags
· Baloo notified on metadata change
v0.1.12 - New folder view
· New Folder view in thumbnails form
· Fixed new tag requesting two times the name and ignoring first one
· Fixed faced name must intercept al keys
v0.1.11 - Face recognition
· Added face recognition initial support
v0.1.10 - Gui improvements
· Added faces initial support
· New thumbnails form really fast
v0.1.9 - Scanner and search
· Scanner and search merged
v0.1.8 - High optimization
· Optimized imageviewer load by disabling all thumbnails generation
v0.1.7 - So many changes
· Added support to EXIV2 metatada
· Fixed Home/End in image viewer
· Refactoring
· Added LMDB as thumbnails cache
· Shift-Q close all open viewers
· Added comments and ratings
· Added filter count label
· Added filter by filename
v0.1.6 - To smooth things over
· Buttons to load thumbnails
· Docker size, position and state saved
· Option to mirror in image viewer
· Text string extraction. First step to multilanguage
· Not filter
v0.1.5 - Gui improvements
· Layout and history grid are resizeable and sortable
· Change named buttons for icon buttons on history and layout
· Fixed fit on load with status bar enabled
· Treeview fixed?
· Rename on image viewer
v0.1.4 - New features
· Saving/loading thumbnails cache to disk
· Add layout tab
· Add hystory tab
· Limit search combo to default 25 entries
· Fixed loading layouts
v0.1.3 - Speed improvements
· Tag management speed improved
· Save cache on exit and load on start
· Imageviewer refactorized
· Added filmstrip on imageviewer with drag&drop to other applications
v0.1.2 - Tags
· Added edit tags to dock
· Added multiple selection to thumbnails
· Added status bar to viewer
v0.1.1 - Drag to outside
· Added drag from thumbnails
· Fixed KDE properties call
· Added Move to and Copy to options to thumbnails menu
· Added new properties window with metadata basic management, system properties window call was removed
· Added slideshow to image viewer
· Added tags dock with filter
v0.1.0 - First version
· New proyect using Karousel source code.
· Proyecto comenzado el 21/02/2026.
BUGS:
· Al cambiar los tags se releen de nuevo lo que produce valores desactualizados.
· Move sólo funciona en X11.
· Si el layout no existe cuando se pasa como parámetro el programa no se cierra.
· Al lanzar una búsqueda a veces aparecen imágenes fantasma anteriores. Thumbnails, lista de ficheros, tags, etc. ¿solucionado?
· ¿¿¿Del no está funcionando bien en el visor, no está borrando lo que está mostrando. No le he reproducido.???1
· ¿¿¿Está aplicando el scalado del monitor a las imágenes. ¿Es esto realmente un bug????
IMPROVEMENTS:
·

1598
constants.py Normal file

File diff suppressed because it is too large Load Diff

757
imagecontroller.py Normal file
View File

@@ -0,0 +1,757 @@
"""
Image Controller Module for Bagheera.
This module provides the core logic for managing image state, including navigation,
loading, transformations (zoom, rotation), and look-ahead preloading for a smooth
user experience.
Classes:
ImagePreloader: A QThread worker that loads the next image in the background.
ImageController: A QObject that manages the image list, current state, and
interacts with the ImagePreloader.
"""
import os
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,
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
class ImagePreloader(QThread):
"""
A worker thread to preload the next image in the sequence.
This class runs in the background to load an image before it is needed,
reducing perceived loading times during navigation.
Signals:
image_ready(int, str, QImage): Emitted when an image has been successfully
preloaded, providing its index, path, and the QImage.
"""
image_ready = Signal(int, str, QImage, list, int) # Now emits tags and rating
def __init__(self):
"""Initializes the preloader thread."""
super().__init__()
self.path = None
self.index = -1
self.mutex = QMutex()
self.condition = QWaitCondition()
self._stop_flag = False
self.current_processing_path = None
def request_load(self, path, index):
"""
Requests the thread to load a specific image.
Args:
path (str): The file path of the image to load.
index (int): The index of the image in the main list.
"""
self.mutex.lock()
if self.current_processing_path == path:
self.path = None
self.mutex.unlock()
return
if self.path == path:
self.index = index
self.mutex.unlock()
return
self.path = path
self.index = index
self.condition.wakeOne()
self.mutex.unlock()
def stop(self):
"""Stops the worker thread gracefully."""
self.mutex.lock()
self._stop_flag = True
self.condition.wakeOne()
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.
Waits for a load request, reads the image file, and emits the
`image_ready` signal upon success.
"""
while True:
self.mutex.lock()
self.current_processing_path = None
while self.path is None and not self._stop_flag:
self.condition.wait(self.mutex)
if self._stop_flag:
self.mutex.unlock()
return
path = self.path
idx = self.index
self.path = None
self.current_processing_path = path
self.mutex.unlock()
# Ensure file exists before trying to read
if path and os.path.exists(path):
try:
reader = QImageReader(path)
reader.setAutoTransform(True)
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)
self.image_ready.emit(idx, path, img, tags, rating)
except Exception:
pass
class ImageController(QObject):
"""
Manages image list navigation, state, and loading logic.
This controller is the central point for handling the currently displayed
image. It manages the list of images, the current index, zoom/rotation/flip
state, and uses an `ImagePreloader` to implement a look-ahead cache for
the next image to provide a smoother user experience.
"""
metadata_changed = Signal(str, dict)
list_updated = Signal(int)
def __init__(self, image_list, current_index, initial_tags=None, initial_rating=0):
"""
Initializes the ImageController.
"""
super().__init__()
self.image_list = image_list
self.index = current_index
self.zoom_factor = 1.0
self.rotation = 0
self.flip_h = False
self.flip_v = False
self.pixmap_original = QPixmap()
self.faces = []
self._current_tags = initial_tags if initial_tags is not None else []
self._current_rating = initial_rating
self.show_faces = False
# Preloading
self.preloader = ImagePreloader()
self.preloader.image_ready.connect(self._handle_preloaded_image)
self.preloader.start()
self._cached_next_image = None
self._cached_next_index = -1
def cleanup(self):
"""Stops the background preloader thread."""
self.preloader.stop()
def _trigger_preload(self):
"""Identifies the next image in the list and asks the preloader to load it."""
if not self.image_list:
return
next_idx = (self.index + 1) % len(self.image_list)
if next_idx == self.index:
return
if next_idx != self._cached_next_index:
self.preloader.request_load(self.image_list[next_idx], next_idx)
def _handle_preloaded_image(self, index, path, image, tags, rating):
"""Slot to receive and cache the image and its metadata from the preloader.
Args:
index (int): The index of the preloaded image.
path (str): The file path of the preloaded image.
image (QImage): The preloaded image data.
tags (list): Preloaded tags for the image.
rating (int): Preloaded rating for the image.
"""
# The signal now emits (index, path, QImage, tags, rating)
# Verify if the loaded path still corresponds to the next index
if self.image_list:
next_idx = (self.index + 1) % len(self.image_list)
if self.image_list[next_idx] == path:
self._cached_next_index = next_idx
self._cached_next_image = image
# Store preloaded metadata
self._cached_next_tags = tags
self._cached_next_rating = rating
def get_current_path(self):
"""
Gets the file path of the current image.
Returns:
str or None: The path of the current image, or None if the list is empty.
"""
if 0 <= self.index < len(self.image_list):
return self.image_list[self.index]
return None
def load_image(self):
"""
Loads the current image into the controller's main pixmap.
"""
path = self.get_current_path()
self.pixmap_original = QPixmap()
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
# Check cache
if self.index == self._cached_next_index and self._cached_next_image:
self.pixmap_original = QPixmap.fromImage(self._cached_next_image)
# 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._cached_next_image = None
self._cached_next_index = -1
self._cached_next_tags = None
self._cached_next_rating = None
else:
reader = QImageReader(path) # This is a disk read
reader.setAutoTransform(True)
image = reader.read()
if image.isNull():
self._trigger_preload()
return False
self.pixmap_original = QPixmap.fromImage(image)
# Load tags and rating if not from cache
self._current_tags, self._current_rating = self._load_metadata(path)
self.load_faces()
self._trigger_preload()
return True
def load_faces(self):
"""
Loads face regions from XMP metadata and resolves short names to full
tag paths.
"""
path = self.get_current_path()
faces_from_xmp = XmpManager.load_faces(path)
if not faces_from_xmp:
self.faces = []
return
resolved_faces = []
seen_faces = set()
for face in faces_from_xmp:
# Validate geometry to discard malformed regions
if not self._clamp_and_validate_face(face):
continue
# Check for exact duplicates based on geometry and name
face_sig = (face.get('x'), face.get('y'), face.get('w'),
face.get('h'), face.get('name'))
if face_sig in seen_faces:
continue
seen_faces.add(face_sig)
short_name = face.get('name', '')
# If name is a short name (no slash) and we have tags on the image
if short_name and '/' not in short_name and self._current_tags:
# Find all full tags on the image that match this short name
possible_matches = [
tag for tag in self._current_tags
if tag.split('/')[-1] == short_name
]
if len(possible_matches) >= 1:
# If multiple matches, pick the first. This is an ambiguity,
# but it's the best we can do. e.g. if image has both
# 'Person/Joe' and 'Friends/Joe' and face is named 'Joe'.
face['name'] = possible_matches[0]
resolved_faces.append(face)
self.faces = resolved_faces
def save_faces(self):
"""
Saves the current faces list to XMP metadata, storing only the short name.
"""
path = self.get_current_path()
if not path:
return
# Create a temporary list of faces with short names for saving to XMP
faces_to_save = []
seen_faces = set()
for face in self.faces:
face_copy = face.copy()
# If the name is a hierarchical tag, save only the last part
if 'name' in face_copy and face_copy['name']:
face_copy['name'] = face_copy['name'].split('/')[-1]
# Deduplicate to prevent file bloat
face_sig = (
face_copy.get('x'), face_copy.get('y'),
face_copy.get('w'), face_copy.get('h'),
face_copy.get('name')
)
if face_sig in seen_faces:
continue
seen_faces.add(face_sig)
faces_to_save.append(face_copy)
XmpManager.save_faces(path, faces_to_save)
def add_face(self, name, x, y, w, h, region_type="Face"):
"""Adds a new face. The full tag path should be passed as 'name'."""
new_face = {
'name': name, # Expecting full tag path
'x': x, 'y': y, 'w': w, 'h': h,
'type': region_type
}
validated_face = self._clamp_and_validate_face(new_face)
if validated_face:
self.faces.append(validated_face)
self.save_faces()
def remove_face(self, face):
"""Removes a face and saves metadata."""
if face in self.faces:
self.faces.remove(face)
self.save_faces()
def toggle_tag(self, tag_name, add_tag):
"""Adds or removes a tag from the current image's xattrs."""
current_path = self.get_current_path()
if not current_path:
return
tags_set = set(self._current_tags)
tag_changed = False
if add_tag and tag_name not in tags_set:
tags_set.add(tag_name)
tag_changed = True
elif not add_tag and tag_name in tags_set:
tags_set.remove(tag_name)
tag_changed = True
if tag_changed:
new_tags_list = sorted(list(tags_set))
new_tags_str = ",".join(new_tags_list) if new_tags_list else None
try:
XattrManager.set_attribute(current_path, XATTR_NAME, new_tags_str)
self._current_tags = new_tags_list # Update internal state
self.metadata_changed.emit(current_path,
{'tags': new_tags_list,
'rating': self._current_rating})
except IOError as e:
print(f"Error setting tags for {current_path}: {e}")
def set_rating(self, new_rating):
current_path = self.get_current_path()
if not current_path:
return
try:
XattrManager.set_attribute(current_path, RATING_XATTR_NAME, str(new_rating))
self._current_rating = new_rating # Update internal state
self.metadata_changed.emit(current_path,
{'tags': self._current_tags,
'rating': new_rating})
except IOError as e:
print(f"Error setting tags for {current_path}: {e}")
def _clamp_and_validate_face(self, face_data):
"""
Clamps face coordinates to be within the [0, 1] range and ensures validity.
Returns a validated face dictionary or None if invalid.
"""
x = face_data.get('x', 0.5)
y = face_data.get('y', 0.5)
w = face_data.get('w', 0.0)
h = face_data.get('h', 0.0)
# Ensure all values are finite numbers to prevent propagation of NaN/Inf
if not all(math.isfinite(val) for val in (x, y, w, h)):
return None
# Basic validation: width and height must be positive
if w <= 0 or h <= 0:
return None
# Clamp width and height to be at most 1.0
w = min(w, 1.0)
h = min(h, 1.0)
# Clamp center coordinates to ensure the box is fully within the image
face_data['x'] = max(w / 2.0, min(x, 1.0 - w / 2.0))
face_data['y'] = max(h / 2.0, min(y, 1.0 - h / 2.0))
face_data['w'] = w
face_data['h'] = h
return face_data
def _detect_faces_face_recognition(self, path):
"""Detects faces using the 'face_recognition' library."""
import face_recognition
new_faces = []
try:
image = face_recognition.load_image_file(path)
face_locations = face_recognition.face_locations(image)
h, w, _ = image.shape
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)
if validated_face:
new_faces.append(validated_face)
except Exception as e:
print(f"Error during face_recognition detection: {e}")
return new_faces
def _detect_faces_mediapipe(self, path):
"""Detects faces using the 'mediapipe' library with the new Tasks API."""
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision
new_faces = []
if not os.path.exists(MEDIAPIPE_FACE_MODEL_PATH):
print(f"MediaPipe model not found at: {MEDIAPIPE_FACE_MODEL_PATH}")
print("Please download 'blaze_face_short_range.tflite' and place it there.")
print(f"URL: {MEDIAPIPE_FACE_MODEL_URL}")
return new_faces
try:
base_options = python.BaseOptions(
model_asset_path=MEDIAPIPE_FACE_MODEL_PATH)
options = vision.FaceDetectorOptions(base_options=base_options,
min_detection_confidence=0.5)
# Silence MediaPipe warnings (stderr) during initialization
stderr_fd = 2
null_fd = os.open(os.devnull, os.O_WRONLY)
save_fd = os.dup(stderr_fd)
try:
os.dup2(null_fd, stderr_fd)
detector = vision.FaceDetector.create_from_options(options)
finally:
os.dup2(save_fd, stderr_fd)
os.close(null_fd)
os.close(save_fd)
mp_image = mp.Image.create_from_file(path)
detection_result = detector.detect(mp_image)
if detection_result.detections:
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)
if validated_face:
new_faces.append(validated_face)
except Exception as e:
print(f"Error during MediaPipe detection: {e}")
return new_faces
def _detect_pets_mediapipe(self, path):
"""Detects pets using the 'mediapipe' library object detection."""
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision
new_pets = []
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
try:
base_options = python.BaseOptions(
model_asset_path=MEDIAPIPE_OBJECT_MODEL_PATH)
options = vision.ObjectDetectorOptions(
base_options=base_options,
score_threshold=0.5,
max_results=5,
category_allowlist=["cat", "dog"]) # Detect cats and dogs
# Silence MediaPipe warnings (stderr) during initialization
stderr_fd = 2
null_fd = os.open(os.devnull, os.O_WRONLY)
save_fd = os.dup(stderr_fd)
try:
os.dup2(null_fd, stderr_fd)
detector = vision.ObjectDetector.create_from_options(options)
finally:
os.dup2(save_fd, stderr_fd)
os.close(null_fd)
os.close(save_fd)
mp_image = mp.Image.create_from_file(path)
detection_result = detector.detect(mp_image)
if detection_result.detections:
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)
except Exception as e:
print(f"Error during MediaPipe pet detection: {e}")
return new_pets
def detect_faces(self):
"""
Detects faces using a configured or available detection engine.
The detection order is determined by the user's configuration and
library availability, with a fallback mechanism.
"""
path = self.get_current_path()
if not path:
return []
if not AVAILABLE_FACE_ENGINES:
print(UITexts.NO_FACE_LIBS)
return []
preferred_engine = APP_CONFIG.get("face_detection_engine")
# Create an ordered list of engines to try, starting with the preferred one.
engines_to_try = []
if preferred_engine in AVAILABLE_FACE_ENGINES:
engines_to_try.append(preferred_engine)
# Add other available engines as fallbacks.
for engine in AVAILABLE_FACE_ENGINES:
if engine not in engines_to_try:
engines_to_try.append(engine)
all_faces = []
for engine in engines_to_try:
if engine == "mediapipe":
all_faces = self._detect_faces_mediapipe(path)
elif engine == "face_recognition":
all_faces = self._detect_faces_face_recognition(path)
if all_faces:
break # Stop after the first successful detection.
return all_faces
def detect_pets(self):
"""
Detects pets using a configured or available detection engine.
"""
path = self.get_current_path()
if not path:
return []
if not AVAILABLE_PET_ENGINES:
print("No pet detection libraries found.")
return []
engine = APP_CONFIG.get("pet_detection_engine", "mediapipe")
if engine == "mediapipe":
return self._detect_pets_mediapipe(path)
return []
def get_display_pixmap(self):
"""
Applies current transformations (rotation, zoom, flip) to the original
pixmap.
Returns:
QPixmap: The transformed pixmap ready for display.
"""
if self.pixmap_original.isNull():
return QPixmap()
transform = QTransform().rotate(self.rotation)
transformed_pixmap = self.pixmap_original.transformed(
transform,
Qt.SmoothTransformation
)
new_size = transformed_pixmap.size() * self.zoom_factor
scaled_pixmap = transformed_pixmap.scaled(new_size, Qt.KeepAspectRatio,
Qt.SmoothTransformation)
if self.flip_h:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(-1, 1))
if self.flip_v:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(1, -1))
return scaled_pixmap
def rotate(self, angle):
"""
Adds to the current rotation angle.
Args:
angle (int): The angle in degrees to add (e.g., 90 or -90).
"""
self.rotation += angle
def toggle_flip_h(self):
"""Toggles the horizontal flip state of the image."""
self.flip_h = not self.flip_h
def toggle_flip_v(self):
"""Toggles the vertical flip state of the image."""
self.flip_v = not self.flip_v
def first(self):
"""Navigates to the first image in the list."""
if not self.image_list:
return
self.index = 0
def last(self):
"""Navigates to the last image in the list."""
if not self.image_list:
return
self.index = max(0, len(self.image_list) - 1)
def next(self):
"""Navigates to the next image, wrapping around if at the end."""
if not self.image_list:
return
self.index = (self.index + 1) % len(self.image_list)
def prev(self):
"""Navigates to the previous image, wrapping around if at the beginning."""
if not self.image_list:
return
self.index = (self.index - 1) % len(self.image_list)
def update_list(self, new_list, new_index=None, current_image_tags=None,
current_image_rating=0):
"""
Updates the internal image list and optionally the current index.
This method is used to refresh the list of images the controller works
with, for example, after a filter is applied in the main window.
Args:
new_list (list): The new list of image paths.
new_index (int, optional): The new index to set. If None, the
controller tries to maintain the current
index, adjusting if it's out of bounds.
Defaults to None.
"""
self.image_list = new_list
if new_index is not None:
self.index = new_index
if not self.image_list:
self.index = -1
elif self.index >= len(self.image_list):
self.index = max(0, len(self.image_list) - 1)
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
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.
This is a specialized update method used to prevent jarring navigation
changes. For instance, when a single image is opened directly, the initial
list contains only that image. When the rest of the directory is scanned
in the background, this method ensures the list is only updated if the
original image is still present, making the transition seamless.
"""
if set(self.image_list) <= set(new_list):
self.image_list = new_list
if new_index is not None:
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
self._cached_next_image = None
self._cached_next_index = -1
self._trigger_preload()
self.list_updated.emit(self.index)

1635
imagescanner.py Normal file

File diff suppressed because it is too large Load Diff

2703
imageviewer.py Normal file

File diff suppressed because it is too large Load Diff

138
metadatamanager.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Metadata Manager Module for Bagheera.
This module provides a dedicated class for handling various metadata formats
like EXIF, IPTC, and XMP, using the exiv2 library.
Classes:
MetadataManager: A class with static methods to read metadata from files.
"""
import os
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
try:
import exiv2
HAVE_EXIV2 = True
except ImportError:
exiv2 = None
HAVE_EXIV2 = False
from utils import preserve_mtime
def notify_baloo(path):
"""
Notifies the Baloo file indexer about a file change using DBus.
This is an asynchronous, non-blocking call. It's more efficient than
calling `balooctl` via subprocess.
Args:
path (str): The absolute path of the file that was modified.
"""
if not path:
return
# Use QDBusMessage directly for robust calling
msg = QDBusMessage.createMethodCall(
"org.kde.baloo.file", "/org/kde/baloo/file",
"org.kde.baloo.file.indexer", "indexFile"
)
msg.setArguments([path])
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
class MetadataManager:
"""Manages reading EXIF, IPTC, and XMP metadata."""
@staticmethod
def read_all_metadata(path):
"""
Reads all available EXIF, IPTC, and XMP metadata from a file.
Args:
path (str): The path to the image file.
Returns:
dict: A dictionary containing all found metadata key-value pairs.
Returns an empty dictionary if exiv2 is not available or on error.
"""
if not HAVE_EXIV2:
return {}
all_metadata = {}
try:
image = exiv2.ImageFactory.open(path)
image.readMetadata()
# EXIF
for datum in image.exifData():
if datum.toString():
all_metadata[datum.key()] = datum.toString()
# IPTC
for datum in image.iptcData():
if datum.toString():
all_metadata[datum.key()] = datum.toString()
# XMP
for datum in image.xmpData():
if datum.toString():
all_metadata[datum.key()] = datum.toString()
except Exception as e:
print(f"Error reading metadata for {path}: {e}")
return all_metadata
class XattrManager:
"""A manager class to handle reading and writing extended attributes (xattrs)."""
@staticmethod
def get_attribute(path_or_fd, attr_name, default_value=""):
"""
Gets a string value from a file's extended attribute. This is a disk read.
Args:
path_or_fd (str or int): The path to the file or a file descriptor.
attr_name (str): The name of the extended attribute.
default_value (any): The value to return if the attribute is not found.
Returns:
str: The attribute value or the default value.
"""
if path_or_fd is None or path_or_fd == "":
return default_value
try:
return os.getxattr(path_or_fd, attr_name).decode('utf-8')
except (OSError, AttributeError):
return default_value
@staticmethod
def set_attribute(file_path, attr_name, value):
"""
Sets a string value for a file's extended attribute.
If the value is None or an empty string, the attribute is removed.
Args:
file_path (str): The path to the file.
attr_name (str): The name of the extended attribute.
value (str or None): The value to set.
Raises:
IOError: If the attribute could not be saved.
"""
if not file_path:
return
try:
with preserve_mtime(file_path):
if value:
os.setxattr(file_path, attr_name, str(value).encode('utf-8'))
else:
try:
os.removexattr(file_path, attr_name)
except OSError:
pass
notify_baloo(file_path)
except Exception as e:
raise IOError(f"Could not save xattr '{attr_name}' "
"for {file_path}: {e}") from e

403
propertiesdialog.py Normal file
View File

@@ -0,0 +1,403 @@
"""
Properties Dialog Module for Bagheera Image Viewer.
This module provides the properties dialog for the application, which displays
detailed information about an image file across several tabs: general file
info, editable metadata (extended attributes), and EXIF/XMP/IPTC data.
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,
QFormLayout, QDialogButtonBox, QApplication
)
from PySide6.QtGui import (
QImageReader, QIcon, QColor
)
from PySide6.QtCore import (
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
class PropertiesDialog(QDialog):
"""
A dialog window to display detailed properties of an image file.
This dialog features multiple tabs:
- General: Basic file information (size, dates, dimensions). This involves os.stat
and QImageReader.
- Metadata: Editable key-value pairs, primarily for extended attributes (xattrs).
- EXIF: Detailed EXIF, IPTC, and XMP metadata, loaded via the exiv2 library.
"""
def __init__(self, path, initial_tags=None, initial_rating=0, parent=None):
"""
Initializes the PropertiesDialog.
Args:
path (str): The absolute path to the image file.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.path = path
self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating
self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
layout = QVBoxLayout(self)
tabs = QTabWidget()
layout.addWidget(tabs)
# --- General Tab ---
general_widget = QWidget()
form_layout = QFormLayout(general_widget)
form_layout.setLabelAlignment(Qt.AlignRight)
form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop)
form_layout.setContentsMargins(20, 20, 20, 20)
form_layout.setSpacing(10)
info = QFileInfo(path)
reader = QImageReader(path)
reader.setAutoTransform(True)
# Basic info
form_layout.addRow(UITexts.PROPERTIES_FILENAME, QLabel(info.fileName()))
form_layout.addRow(UITexts.PROPERTIES_LOCATION, QLabel(info.path()))
form_layout.addRow(UITexts.PROPERTIES_SIZE,
QLabel(self.format_size(info.size())))
# Dates
form_layout.addRow(UITexts.PROPERTIES_CREATED,
QLabel(QLocale.system().toString(info.birthTime(),
QLocale.ShortFormat)))
form_layout.addRow(UITexts.PROPERTIES_MODIFIED,
QLabel(QLocale.system().toString(info.lastModified(),
QLocale.ShortFormat)))
# Image info
size = reader.size()
fmt = reader.format().data().decode('utf-8').upper()
if size.isValid():
form_layout.addRow(UITexts.PROPERTIES_DIMENSIONS,
QLabel(f"{size.width()} x {size.height()} px"))
megapixels = (size.width() * size.height()) / 1_000_000
form_layout.addRow(UITexts.PROPERTIES_MEGAPIXELS,
QLabel(f"{megapixels:.2f} MP"))
# Read image to get depth
img = reader.read()
if not img.isNull():
form_layout.addRow(UITexts.PROPERTIES_COLOR_DEPTH,
QLabel(f"{img.depth()} {UITexts.BITS}"))
if fmt:
form_layout.addRow(UITexts.PROPERTIES_FORMAT, QLabel(fmt))
tabs.addTab(general_widget, QIcon.fromTheme("dialog-information"),
UITexts.PROPERTIES_GENERAL_TAB)
# --- Metadata Tab ---
meta_widget = QWidget()
meta_layout = QVBoxLayout(meta_widget)
self.table = QTableWidget()
self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive)
self.table.horizontalHeader().setSectionResizeMode(1,
QHeaderView.ResizeToContents)
self.table.setColumnWidth(0, self.width() * 0.4)
self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QTableWidget.DoubleClicked |
QTableWidget.EditKeyPressed |
QTableWidget.SelectedClicked)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.itemChanged.connect(self.on_item_changed)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu)
self.load_metadata()
meta_layout.addWidget(self.table)
tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"),
UITexts.PROPERTIES_METADATA_TAB)
# --- EXIF Tab ---
exif_widget = QWidget()
exif_layout = QVBoxLayout(exif_widget)
self.exif_table = QTableWidget()
# This table will display EXIF/XMP/IPTC data.
# Reading this data involves opening the file with exiv2, which is a disk read.
# This is generally acceptable for a properties dialog, as it's an explicit
# user request for detailed information. Caching all possible EXIF data
# for every image might be too memory intensive if not frequently accessed.
# Therefore, this disk read is considered necessary and not easily optimizable
# without a significant architectural change (e.g., a dedicated metadata DB).
self.exif_table.setColumnCount(2)
self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.exif_table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeToContents)
self.exif_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.exif_table.verticalHeader().setVisible(False)
self.exif_table.setAlternatingRowColors(True)
self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.exif_table.setSelectionBehavior(QTableWidget.SelectRows)
self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu)
# This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
self.load_exif_data()
exif_layout.addWidget(self.exif_table)
tabs.addTab(exif_widget, QIcon.fromTheme("view-details"),
UITexts.PROPERTIES_EXIF_TAB)
# Buttons
btn_box = QDialogButtonBox(QDialogButtonBox.Close)
close_button = btn_box.button(QDialogButtonBox.Close)
if close_button:
close_button.setIcon(QIcon.fromTheme("window-close"))
btn_box.rejected.connect(self.close)
layout.addWidget(btn_box)
def load_metadata(self):
"""
Loads metadata from the file's text keys (via QImageReader) and
extended attributes (xattrs) into the metadata table.
"""
self.table.blockSignals(True)
self.table.setRowCount(0)
# Use pre-loaded tags and rating if available
preloaded_xattrs = {}
if self._initial_tags:
preloaded_xattrs[XATTR_NAME] = ", ".join(self._initial_tags)
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}
self.table.setRowCount(len(all_xattrs))
row = 0
# Display all xattrs
for key, val in all_xattrs.items():
# QImageReader.textKeys() is not used here as it's not xattr.
k_item = QTableWidgetItem(key)
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
v_item = QTableWidgetItem(val)
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.table.setItem(row, 0, k_item)
self.table.setItem(row, 1, v_item)
row += 1
self.table.blockSignals(False)
def load_exif_data(self):
"""Loads EXIF, XMP, and IPTC metadata using the MetadataManager."""
self.exif_table.blockSignals(True)
self.exif_table.setRowCount(0)
if not HAVE_EXIV2:
self.exif_table.setRowCount(1)
error_color = QColor("red")
item = QTableWidgetItem(UITexts.ERROR)
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
item.setForeground(error_color)
self.exif_table.setItem(0, 0, item)
msg_item = QTableWidgetItem(UITexts.EXIV2_NOT_INSTALLED)
msg_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
msg_item.setForeground(error_color)
self.exif_table.setItem(0, 1, msg_item)
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)
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.exif_table.setItem(0, 0, item)
msg_item = QTableWidgetItem(UITexts.NO_METADATA_FOUND)
msg_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.exif_table.setItem(0, 1, msg_item)
self.exif_table.blockSignals(False)
return
self.exif_table.setRowCount(len(exif_data))
error_color = QColor("red")
error_text_lower = UITexts.ERROR.lower()
warning_text_lower = UITexts.WARNING.lower()
for row, (key, value) in enumerate(sorted(exif_data.items())):
k_item = QTableWidgetItem(str(key))
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
v_item = QTableWidgetItem(str(value))
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
key_str_lower = str(key).lower()
val_str_lower = str(value).lower()
if (error_text_lower in key_str_lower or warning_text_lower
in key_str_lower or
error_text_lower in val_str_lower
or warning_text_lower in val_str_lower):
k_item.setForeground(error_color)
v_item.setForeground(error_color)
self.exif_table.setItem(row, 0, k_item)
self.exif_table.setItem(row, 1, v_item)
self.exif_table.blockSignals(False)
def on_item_changed(self, item):
"""
Slot that triggers when an item in the metadata table is changed.
Args:
item (QTableWidgetItem): The item that was changed.
"""
if item.column() == 1:
key = self.table.item(item.row(), 0).text()
val = item.text()
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)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
def show_context_menu(self, pos):
"""
Displays a context menu in the metadata table.
Args:
pos (QPoint): The position where the context menu was requested.
"""
menu = QMenu()
add_action = menu.addAction(QIcon.fromTheme("list-add"),
UITexts.PROPERTIES_ADD_ATTR)
item = self.table.itemAt(pos)
copy_action = None
delete_action = None
if item:
copy_action = menu.addAction(QIcon.fromTheme("edit-copy"),
UITexts.COPY)
val_item = self.table.item(item.row(), 1)
if val_item.flags() & Qt.ItemIsEditable:
delete_action = menu.addAction(QIcon.fromTheme("list-remove"),
UITexts.PROPERTIES_DELETE_ATTR)
action = menu.exec(self.table.mapToGlobal(pos))
if action == add_action:
self.add_attribute()
elif copy_action and action == copy_action:
val = self.table.item(item.row(), 1).text()
QApplication.clipboard().setText(val)
elif delete_action and action == delete_action:
self.delete_attribute(item.row())
def show_exif_context_menu(self, pos):
"""Displays a context menu in the EXIF table (Copy only)."""
menu = QMenu()
item = self.exif_table.itemAt(pos)
if item:
copy_action = menu.addAction(QIcon.fromTheme("edit-copy"), UITexts.COPY)
action = menu.exec(self.exif_table.mapToGlobal(pos))
if action == copy_action:
val = self.exif_table.item(item.row(), 1).text()
QApplication.clipboard().setText(val)
def add_attribute(self):
"""
Opens dialogs to get a key and value for a new extended attribute and applies
it.
"""
key, ok = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR,
UITexts.PROPERTIES_ADD_ATTR_NAME)
if ok and key:
val, ok2 = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR,
UITexts.PROPERTIES_ADD_ATTR_VALUE.format(
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()
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e))
def delete_attribute(self, row):
"""
Deletes the extended attribute corresponding to the given table row.
Args:
row (int): The row index of the attribute to delete.
"""
key = self.table.item(row, 0).text()
try:
with preserve_mtime(self.path):
os.removexattr(self.path, key)
notify_baloo(self.path)
self.table.removeRow(row)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_DELETE_ATTR.format(e))
def format_size(self, size):
"""
Formats a size in bytes into a human-readable string (B, KiB, MiB, etc.).
Args:
size (int): The size in bytes.
Returns:
str: The formatted size string.
"""
for unit in ['B', 'KiB', 'MiB', 'GiB']:
if size < 1024:
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TiB"

61
pyproject.toml Normal file
View File

@@ -0,0 +1,61 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "bagheeraview"
version = "0.9.11"
authors = [
{ name = "Ignacio Serantes" }
]
description = "Bagheera Image Viewer - An image viewer for KDE with Baloo in mind"
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT License" }
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Topic :: Multimedia :: Graphics :: Viewers",
"Intended Audience :: End Users/Desktop",
]
dependencies = [
"PySide6",
"lmdb",
"exiv2",
"mediapipe",
"face_recognition",
"face_recognition_models",
"setuptools==80.0.0",
]
[project.optional-dependencies]
faces = [
"face-recognition",
"face_recognition_models",
"mediapipe"
]
exiv = [
"exiv2"
]
[project.scripts]
bagheeraview = "bagheeraview:main"
[tool.setuptools]
packages = { find = {} }
py-modules = [
"bagheeraview",
"constants",
"settings",
"imagescanner",
"imageviewer",
"imagecontroller",
"metadatamanager",
"propertiesdialog",
"thumbnailwidget",
"widgets",
"xmpmanager",
"utils"
]
zip-safe = false

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
PySide6
lmdb
exiv2
mediapipe
face_recognition
face_recognition_models
setuptools==80.0.0

1014
settings.py Normal file

File diff suppressed because it is too large Load Diff

88
setup.py Normal file
View File

@@ -0,0 +1,88 @@
from setuptools import setup, find_packages
setup(
name="bagheeraview",
version="0.9.11",
author="Ignacio Serantes",
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind",
long_description="A fast image viewer built with PySide6, featuring search and "
"metadata management.",
packages=find_packages(),
install_requires=[
"PySide6",
"lmdb",
"exiv2",
"mediapipe",
"face_recognition",
"face_recognition_models",
"setuptools==80.0.0",
],
entry_points={
'console_scripts': [
'bagheeraview=bagheeraview:main'
]
},
py_modules=[
"bagheeraview",
"constants",
"settings",
"imagescanner",
"imageviewer",
"imagecontroller",
"metadatamanager",
"propertiesdialog",
"thumbnailwidget",
"widgets",
"xmpmanager",
"utils"
],
# extras_require={
# 'faces': ["exiv2", "face-recognition", "face_recognition_models", "mediapipe"],
# },
# Classifiers to standardize the project
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Topic :: Multimedia :: Graphics :: Viewers",
"Intended Audience :: End Users/Desktop",
],
python_requires='>=3.8',
zip_safe=False,
)
# from setuptools import setup
#
#
# setup(
# name="bagheeraview",
# version="0.1.9",
# author="Ignacio Serantes",
# description="Bagheera Image Viewer",
# py_modules=[
# "bagheeraview",
# "constants",
# "imagescanner",
# "imagescanner2",
# "imageviewer",
# "imagecontroller",
# "propertiesdialog",
# "thumbnailwidget",
# "widgets"
# ],
# install_requires=[
# "PySide6",
# "lmdb",
# ],
# entry_points={
# 'console_scripts': ['bagheeraview=bagheeraview:main']
# },
# zip_safe=False,
# )

45
utils.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Utility Module for Bagheera.
This module contains general-purpose utility functions and context managers
used throughout the application, such as file system helpers.
"""
import os
from contextlib import contextmanager
@contextmanager
def preserve_mtime(path_or_fd):
"""
Context manager to preserve the modification time (mtime) of a file.
This is useful when performing operations that might inadvertently update
the file's modification time (like modifying extended attributes), but
where the original timestamp should be retained. Supports both file paths
and file descriptors.
Args:
path_or_fd (str | int): The file path or file descriptor.
Yields:
None: Control is yielded back to the caller context.
"""
mtime = None
try:
# Check for valid input (non-empty string or integer)
if path_or_fd is not None and (not isinstance(path_or_fd, str) or path_or_fd):
stat_result = os.stat(path_or_fd)
mtime = stat_result.st_mtime
except (OSError, ValueError, TypeError):
pass
yield
if mtime is not None:
try:
# Re-stat to get current atime, as reading might have updated it
stat_result = os.stat(path_or_fd)
atime = stat_result.st_atime
os.utime(path_or_fd, (atime, mtime))
except (OSError, ValueError, TypeError):
pass

1402
widgets.py Normal file

File diff suppressed because it is too large Load Diff

168
xmpmanager.py Normal file
View File

@@ -0,0 +1,168 @@
"""
XMP Manager Module for Bagheera.
This module provides a dedicated class for handling XMP metadata, specifically
for reading and writing face region information compliant with the Metadata
Working Group (MWG) standard. It relies on the `exiv2` library for all
metadata operations.
Classes:
XmpManager: A class with static methods to interact with XMP metadata.
Dependencies:
- python-exiv2: The Python binding for the exiv2 library. The module will
gracefully handle its absence by disabling its functionality.
- utils.preserve_mtime: A utility to prevent file modification times from
changing during metadata writes.
"""
import os
import re
from utils import preserve_mtime
from metadatamanager import notify_baloo
try:
import exiv2
except ImportError:
exiv2 = None
class XmpManager:
"""
A static class that provides methods to read and write face region data
to and from XMP metadata in image files.
"""
@staticmethod
def load_faces(path):
"""
Loads face regions from a file's XMP metadata (MWG Regions).
This method parses the XMP data structure for a `mwg-rs:RegionList`,
extracts all regions of type 'Face', and returns them as a list of
dictionaries. Each dictionary contains the face's name and its
normalized coordinates (center x, center y, width, height).
Args:
path (str): The path to the image file.
Returns:
list: A list of dictionaries, where each dictionary represents a face.
Returns an empty list if exiv2 is not available or on error.
"""
if not exiv2 or not path or not os.path.exists(path):
return []
faces = []
try:
img = exiv2.ImageFactory.open(path)
# readMetadata() is crucial to populate the data structures.
img.readMetadata()
xmp = img.xmpData()
regions = {}
for datum in xmp:
key = datum.key()
if "mwg-rs:RegionList" in key:
# Use regex to find the index of the region in the list,
# e.g., RegionList[1], RegionList[2], etc.
m = re.search(r'RegionList\[(\d+)\]', key)
if m:
idx = int(m.group(1))
if idx not in regions:
regions[idx] = {}
val = datum.toString()
if key.endswith("/mwg-rs:Name"):
regions[idx]['name'] = val
elif key.endswith("/stArea:x"):
regions[idx]['x'] = float(val)
elif key.endswith("/stArea:y"):
regions[idx]['y'] = float(val)
elif key.endswith("/stArea:w"):
regions[idx]['w'] = float(val)
elif key.endswith("/stArea:h"):
regions[idx]['h'] = float(val)
elif key.endswith("/mwg-rs:Type"):
regions[idx]['type'] = val
# Convert the structured dictionary into a flat list of faces,
# preserving all regions (including 'Pet', etc.) to avoid data loss.
for idx, data in sorted(regions.items()):
if 'x' in data and 'y' in data and 'w' in data and 'h' in data:
faces.append(data)
except Exception as e:
print(f"Error loading faces from XMP: {e}")
return faces
@staticmethod
def save_faces(path, faces):
"""
Saves a list of faces to a file's XMP metadata as MWG Regions.
This method performs a clean write by first removing all existing
face region metadata from the file and then writing the new data.
This method preserves the file's original modification time.
Args:
path (str): The path to the image file.
faces (list): A list of face dictionaries to save.
Returns:
bool: True on success, False on failure.
"""
if not exiv2 or not path:
return False
try:
# Register required XMP namespaces to ensure they are recognized.
exiv2.XmpProperties.registerNs(
"http://www.metadataworkinggroup.com/schemas/regions/", "mwg-rs")
exiv2.XmpProperties.registerNs(
"http://ns.adobe.com/xmp/sType/Area#", "stArea")
with preserve_mtime(path):
img = exiv2.ImageFactory.open(path)
img.readMetadata()
xmp = img.xmpData()
# 1) Remove all existing RegionList entries to prevent conflicts.
keys_to_delete = [
d.key() for d in xmp
if d.key().startswith("Xmp.mwg-rs.Regions/mwg-rs:RegionList")
]
for key in sorted(keys_to_delete, reverse=True):
try:
xmp_key = exiv2.XmpKey(key)
it = xmp.findKey(xmp_key)
if it != xmp.end():
xmp.erase(it)
except Exception:
pass
# 2) Recreate the RegionList from the provided faces list.
if faces:
# To initialize an XMP list (rdf:Bag), it is necessary to
# register the key as an array before it can be indexed.
# Failing to do so causes the "XMP Toolkit error 102:
# Indexing applied to non-array". A compatible way to do
# this with the python-exiv2 binding is to assign an
# XmpTextValue and specify its type as 'Bag', which
# correctly creates the empty array structure.
if exiv2 and hasattr(exiv2, 'XmpTextValue'):
xmp["Xmp.mwg-rs.Regions/mwg-rs:RegionList"] = \
exiv2.XmpTextValue("type=Bag")
for i, face in enumerate(faces):
# The index for XMP arrays is 1-based.
base = f"Xmp.mwg-rs.Regions/mwg-rs:RegionList[{i+1}]"
xmp[f"{base}/mwg-rs:Name"] = face.get('name', 'Unknown')
xmp[f"{base}/mwg-rs:Type"] = face.get('type', 'Face')
area_base = f"{base}/mwg-rs:Area"
xmp[f"{area_base}/stArea:x"] = str(face.get('x', 0))
xmp[f"{area_base}/stArea:y"] = str(face.get('y', 0))
xmp[f"{area_base}/stArea:w"] = str(face.get('w', 0))
xmp[f"{area_base}/stArea:h"] = str(face.get('h', 0))
xmp[f"{area_base}/stArea:unit"] = 'normalized'
img.writeMetadata()
notify_baloo(path)
return True
except Exception as e:
print(f"Error saving faces to XMP: {e}")
return False