diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py new file mode 100644 index 0000000..571d612 --- /dev/null +++ b/app/qt/image_processor.py @@ -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) diff --git a/app/qt/main_window.py b/app/qt/main_window.py index a5d62ec..eea37fa 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -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())