First commit
This commit is contained in:
83
README.md
Normal file
83
README.md
Normal 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
1
bagheera_query_parser_lib
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/ignacio/devel/bagheera/bagheerasearch/bagheera_query_parser_lib
|
||||||
1
bagheera_search_lib
Symbolic link
1
bagheera_search_lib
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/ignacio/devel/bagheera/bagheerasearch/bagheera_search_lib
|
||||||
1
bagheeraview
Symbolic link
1
bagheeraview
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/ignacio/.config/iserantes/bagheeraview
|
||||||
23
bagheeraview.desktop
Executable file
23
bagheeraview.desktop
Executable 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
4313
bagheeraview.py
Executable file
File diff suppressed because it is too large
Load Diff
23
bagheeraview_devel.desktop
Executable file
23
bagheeraview_devel.desktop
Executable 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
1
baloo_tools
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/ignacio/devel/bagheera/bagheerasearch/baloo_tools
|
||||||
47
build.sh
Executable file
47
build.sh
Executable 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
256
changelog.txt
Normal 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
1598
constants.py
Normal file
File diff suppressed because it is too large
Load Diff
757
imagecontroller.py
Normal file
757
imagecontroller.py
Normal 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
1635
imagescanner.py
Normal file
File diff suppressed because it is too large
Load Diff
2703
imageviewer.py
Normal file
2703
imageviewer.py
Normal file
File diff suppressed because it is too large
Load Diff
138
metadatamanager.py
Normal file
138
metadatamanager.py
Normal 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
403
propertiesdialog.py
Normal 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
61
pyproject.toml
Normal 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
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
PySide6
|
||||||
|
lmdb
|
||||||
|
exiv2
|
||||||
|
mediapipe
|
||||||
|
face_recognition
|
||||||
|
face_recognition_models
|
||||||
|
setuptools==80.0.0
|
||||||
1014
settings.py
Normal file
1014
settings.py
Normal file
File diff suppressed because it is too large
Load Diff
88
setup.py
Normal file
88
setup.py
Normal 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
45
utils.py
Normal 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
1402
widgets.py
Normal file
File diff suppressed because it is too large
Load Diff
168
xmpmanager.py
Normal file
168
xmpmanager.py
Normal 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
|
||||||
Reference in New Issue
Block a user