Wire PySide6 UI to new image processor
This commit is contained in:
parent
825bdcebe0
commit
0ca5607fc7
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in New Issue