Wire PySide6 UI to new image processor

This commit is contained in:
lm 2025-10-19 19:23:01 +02:00
parent 825bdcebe0
commit 0ca5607fc7
2 changed files with 240 additions and 12 deletions

164
app/qt/image_processor.py Normal file
View File

@ -0,0 +1,164 @@
"""Minimal image processing pipeline adapted for the Qt frontend."""
from __future__ import annotations
import colorsys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Tuple
from PIL import Image, ImageDraw
from PySide6 import QtGui
from app.logic import PREVIEW_MAX_SIZE
@dataclass
class Stats:
matches_all: int = 0
total_all: int = 0
matches_keep: int = 0
total_keep: int = 0
def summary(self) -> str:
if self.total_all == 0:
return "Matches with exclusions: —"
with_pct = (self.matches_keep / self.total_keep * 100) if self.total_keep else 0.0
without_pct = (self.matches_all / self.total_all * 100) if self.total_all else 0.0
return (
f"Matches with exclusions: {with_pct:.1f}% · "
f"Matches overall: {without_pct:.1f}%"
)
class QtImageProcessor:
"""Process images and build overlays for the Qt UI."""
def __init__(self) -> None:
self.orig_img: Image.Image | None = None
self.preview_img: Image.Image | None = None
self.overlay_img: Image.Image | None = None
self.preview_paths: list[Path] = []
self.current_index: int = -1
self.stats = Stats()
# HSV thresholds and overlay alpha
self.hue_min = 0
self.hue_max = 360
self.sat_min = 25
self.val_min = 15
self.val_max = 100
self.alpha = 120
self.exclude_shapes: list[dict[str, object]] = []
# thresholds -------------------------------------------------------------
def set_threshold(self, key: str, value: int) -> None:
setattr(self, key, value)
if self.preview_img is not None:
self._rebuild_overlay()
# image handling --------------------------------------------------------
def load_single_image(self, path: Path) -> None:
image = Image.open(path).convert("RGBA")
self.orig_img = image
self.preview_paths = [path]
self.current_index = 0
self._build_preview()
self._rebuild_overlay()
def load_folder(self, paths: Iterable[Path], start_index: int = 0) -> None:
self.preview_paths = list(paths)
if not self.preview_paths:
raise ValueError("No images in folder.")
self.current_index = max(0, min(start_index, len(self.preview_paths) - 1))
self._load_current()
def next_image(self) -> None:
if not self.preview_paths:
return
self.current_index = (self.current_index + 1) % len(self.preview_paths)
self._load_current()
def previous_image(self) -> None:
if not self.preview_paths:
return
self.current_index = (self.current_index - 1) % len(self.preview_paths)
self._load_current()
def _load_current(self) -> None:
path = self.preview_paths[self.current_index]
self.load_single_image(path)
# preview/overlay -------------------------------------------------------
def _build_preview(self) -> None:
if self.orig_img is None:
self.preview_img = None
return
width, height = self.orig_img.size
max_w, max_h = PREVIEW_MAX_SIZE
scale = min(max_w / width, max_h / height)
if scale <= 0:
scale = 1.0
size = (max(1, int(width * scale)), max(1, int(height * scale)))
self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
def _rebuild_overlay(self) -> None:
if self.preview_img is None:
self.overlay_img = None
return
base = self.preview_img.convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
pixels = base.load()
width, height = base.size
highlight = (255, 0, 0, int(self.alpha))
matches_all = total_all = 0
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if a == 0:
continue
total_all += 1
if self._matches(r, g, b):
draw.point((x, y), fill=highlight)
matches_all += 1
self.overlay_img = overlay
self.stats = Stats(matches_all=matches_all, total_all=total_all, matches_keep=matches_all, total_keep=total_all)
# helpers ----------------------------------------------------------------
def _matches(self, r: int, g: int, b: int) -> bool:
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
hue = (h * 360.0) % 360.0
if self.hue_min <= self.hue_max:
hue_ok = self.hue_min <= hue <= self.hue_max
else:
hue_ok = hue >= self.hue_min or hue <= self.hue_max
sat_ok = s * 100.0 >= self.sat_min
val_ok = self.val_min <= v * 100.0 <= self.val_max
return hue_ok and sat_ok and val_ok
# exported data ----------------------------------------------------------
def preview_pixmap(self) -> QtGui.QPixmap:
return self._to_pixmap(self.preview_img)
def overlay_pixmap(self) -> QtGui.QPixmap:
if self.preview_img is None or self.overlay_img is None:
return QtGui.QPixmap()
merged = Image.alpha_composite(self.preview_img.convert("RGBA"), self.overlay_img)
return self._to_pixmap(merged)
@staticmethod
def _to_pixmap(image: Image.Image | None) -> QtGui.QPixmap:
if image is None:
return QtGui.QPixmap()
buffer = image.tobytes("raw", "RGBA")
qt_image = QtGui.QImage(buffer, image.width, image.height, QtGui.QImage.Format_RGBA8888)
return QtGui.QPixmap.fromImage(qt_image)

View File

@ -2,12 +2,14 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List, Tuple
from PySide6 import QtCore, QtGui, QtWidgets
from app.logic import SUPPORTED_IMAGE_EXTENSIONS
from .image_processor import QtImageProcessor
DEFAULT_COLOUR = "#763e92"
PRESET_COLOURS: List[Tuple[str, str]] = [
("Red", "#ff3b30"),
@ -299,6 +301,8 @@ class MainWindow(QtWidgets.QMainWindow):
self._register_default_actions()
self._update_colour_display(DEFAULT_COLOUR, "Default colour")
self.processor = QtImageProcessor()
# Window control helpers -------------------------------------------------
def toggle_maximise(self) -> None:
@ -461,7 +465,7 @@ class MainWindow(QtWidgets.QMainWindow):
def _register_default_actions(self) -> None:
self._toolbar_actions = {
"open_image": self.open_image,
"open_folder": self._coming_soon,
"open_folder": self.open_folder,
"choose_color": self._coming_soon,
"pick_from_image": self._coming_soon,
"save_overlay": self._coming_soon,
@ -470,8 +474,8 @@ class MainWindow(QtWidgets.QMainWindow):
"undo_exclude": self._coming_soon,
"reset_sliders": self._reset_sliders,
"toggle_theme": self._coming_soon,
"show_previous_image": self._coming_soon,
"show_next_image": self._coming_soon,
"show_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image,
}
def _invoke_action(self, key: str) -> None:
@ -487,16 +491,49 @@ class MainWindow(QtWidgets.QMainWindow):
if not path_str:
return
path = Path(path_str)
pixmap = QtGui.QPixmap(str(path))
if pixmap.isNull():
QtWidgets.QMessageBox.warning(self, "ICRA", "Unable to open the selected image.")
try:
self.processor.load_single_image(path)
except Exception as exc:
QtWidgets.QMessageBox.warning(self, "ICRA", f"Unable to open the selected image.\n{exc}")
return
self.image_view.set_image(pixmap)
self.overlay_view.set_image(None)
self._current_image_path = path
self.status_label.setText(f"{path.name} · {pixmap.width()}×{pixmap.height()}")
self.filename_label.setText(f"{path.name} ({pixmap.width()}×{pixmap.height()})")
self.ratio_label.setText("Matches with exclusions: pending")
self._refresh_views()
def open_folder(self) -> None:
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select image folder")
if not directory:
return
folder = Path(directory)
paths = sorted(
(p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()),
key=lambda p: p.name.lower(),
)
if not paths:
QtWidgets.QMessageBox.information(self, "ICRA", "No supported image files found in the selected folder.")
return
try:
self.processor.load_folder(paths)
except ValueError as exc:
QtWidgets.QMessageBox.information(self, "ICRA", str(exc))
return
self._current_image_path = paths[0]
self._refresh_views()
def show_previous_image(self) -> None:
if not self.processor.preview_paths:
self._coming_soon()
return
self.processor.previous_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index]
self._refresh_views()
def show_next_image(self) -> None:
if not self.processor.preview_paths:
self._coming_soon()
return
self.processor.next_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index]
self._refresh_views()
# Helpers ----------------------------------------------------------------
@ -508,14 +545,18 @@ class MainWindow(QtWidgets.QMainWindow):
def _on_slider_change(self, key: str, value: int) -> None:
formatted = key.replace("_", " ").title()
self.processor.set_threshold(key, value)
self.status_label.setText(f"{formatted}{value}")
self._refresh_overlay_only()
def _reset_sliders(self) -> None:
for _, key, _, _, initial in SLIDER_SPECS:
control = self._slider_controls.get(key)
if control:
control.set_value(initial)
self.processor.set_threshold(key, initial)
self.status_label.setText("Sliders reset to defaults")
self._refresh_overlay_only()
def _coming_soon(self) -> None:
QtWidgets.QMessageBox.information(
@ -523,3 +564,26 @@ class MainWindow(QtWidgets.QMainWindow):
"Coming soon",
"This action is not yet wired up in the PySide6 migration prototype.",
)
# view refresh -----------------------------------------------------------
def _refresh_views(self) -> None:
preview_pix = self.processor.preview_pixmap()
overlay_pix = self.processor.overlay_pixmap()
self.image_view.set_image(preview_pix)
self.overlay_view.set_image(overlay_pix)
if self._current_image_path and self.processor.preview_img:
width, height = self.processor.preview_img.size
self.status_label.setText(
f"{self._current_image_path.name} · {width}×{height}"
)
self.filename_label.setText(
f"{self._current_image_path.name} ({width}×{height})"
)
self.ratio_label.setText(self.processor.stats.summary())
def _refresh_overlay_only(self) -> None:
if self.processor.preview_img is None:
return
self.overlay_view.set_image(self.processor.overlay_pixmap())
self.ratio_label.setText(self.processor.stats.summary())