A bunch of changes

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

View File

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