diff --git a/README.md b/README.md index 0cd1c9f..99cf33f 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,51 @@
ICRA
- Interactive Color Range Analyzer is a Tkinter-based desktop tool for highlighting customised colour ranges in images.
- Load a single photo or an entire folder, fine-tune hue/saturation/value sliders, and export overlays complete with quick statistics. + Interactive Color Range Analyzer is being reimagined with a PySide6 user interface.
+ This branch focuses on building a native desktop shell with modern window controls before porting the colour-analysis features.
-## Features -- Two synced previews (original + overlay) -- Hue/Sat/Value sliders with presets and image colour picker -- Exclusion rectangles or freehand polygons that persist while browsing -- Theme toggle (light/dark) with rounded toolbar buttons and accent-aware highlights -- Folder support with wrap-around previous/next navigation -- Quick overlay export (PNG) with configurable defaults and language settings via `config.toml` +## Current prototype +- Custom frameless window with minimise / maximise / close controls that hook into Windows natively +- Dark themed layout and basic image preview powered by Qt +- “Open image” workflow that displays the selected asset scaled to the viewport + +> ⚠️ Legacy Tk features (sliders, exclusions, folder navigation, stats) are not wired up yet. The goal here is to validate the PySide6 shell first. ## Requirements -- Python 3.11+ (3.10 works with `tomli`) +- Python 3.11+ - [uv](https://github.com/astral-sh/uv) for dependency management -- Tkinter (install separately on some Linux distros) +- Windows 10/11 recommended (PySide6 build included; Linux/macOS should work but are untested in this branch) -## Setup with uv (Windows PowerShell) +## Setup with uv (PowerShell example) ```bash git clone https://git.lukasmahler.de/lm/ICRA.git cd ICRA uv venv -source .venv/Scripts/activate +source .venv/Scripts/activate # macOS/Linux: source .venv/bin/activate uv pip install . uv run icra ``` -The launcher copies Tcl/Tk resources into the virtualenv on first run, so no manual environment tweaks are needed. -On macOS/Linux activate with `source .venv/bin/activate` instead. -## Workflow -1. Load an image (`🖼`) or a folder (`📂`). -2. Pick a colour (`🎨` dialog, `🖱️` image click, or preset swatch). -3. Fine‑tune sliders; watch the overlay update on the right. -4. Toggle freehand mode (`△`) or stick with rectangles and mark areas to exclude (right mouse drag). -5. Move through folder images with `⬅️` / `➡️`; exclusions stay put unless you opt into automatic resets. -6. Save an overlay (`💾`) when ready. +The app launches directly as a PySide6 GUI—no browser or local web server involved. Use the “Open Image…” button to load a file and test resizing/snap behaviour. -## Project Layout +## Roadmap (branch scope) +1. Port hue/saturation/value controls to Qt widgets +2. Re-implement exclusion drawing using QPainter overlays +3. Integrate existing image-processing logic (`app/logic`) with the new UI + +## Project layout ``` app/ - app.py # main app assembly - gui/ # UI, theme, picker mixins - logic/ # image ops, defaults, config helpers - lang/ # localisation TOML files -config.toml # optional defaults -main.py # entry point + assets/ # Shared branding + gui/, logic/ # Legacy Tk code kept for reference + qt/ # New PySide6 implementation (main_window, app bootstrap) +config.toml # Historical defaults (unused in the prototype) +main.py # Entry point -> PySide6 launcher ``` -## Localisation -- English and German translations ship in `app/lang`. Set the desired language via the top-level `language` key in `config.toml`. - -## Development -- Quick check: `uv run python -m compileall app main.py` -- Contributions welcome; include screenshots for UI tweaks. +## Development notes +- Quick syntax check: `uv run python -m compileall app main.py` +- Uploaded images are not persisted; the preview uses Qt pixmaps only. +- Contributions welcome—please target this branch with PySide6-specific improvements. diff --git a/app/__init__.py b/app/__init__.py index c6cbc33..f3c94c3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,15 @@ -"""Application package.""" +"""Application package exposing the PySide6 entry points.""" -from .app import ICRAApp, start_app +from __future__ import annotations -__all__ = ["ICRAApp", "start_app"] +try: # Legacy Tk support remains optional + from .app import ICRAApp, start_app as start_tk_app # type: ignore[attr-defined] +except Exception: # pragma: no cover + ICRAApp = None # type: ignore[assignment] + start_tk_app = None # type: ignore[assignment] + +from .qt import create_application as create_qt_app, run as run_qt_app + +start_app = run_qt_app + +__all__ = ["ICRAApp", "start_tk_app", "create_qt_app", "run_qt_app", "start_app"] diff --git a/app/lang/de.toml b/app/lang/de.toml index 6d692fa..54179e4 100644 --- a/app/lang/de.toml +++ b/app/lang/de.toml @@ -59,3 +59,4 @@ "dialog.no_image_loaded" = "Kein Bild geladen." "dialog.no_preview_available" = "Keine Preview vorhanden." "dialog.overlay_saved" = "Overlay gespeichert: {path}" +"status.drag_drop" = "Bild oder Ordner hier ablegen." diff --git a/app/lang/en.toml b/app/lang/en.toml index 3eec912..1857249 100644 --- a/app/lang/en.toml +++ b/app/lang/en.toml @@ -59,3 +59,4 @@ "dialog.no_image_loaded" = "No image loaded." "dialog.no_preview_available" = "No preview available." "dialog.overlay_saved" = "Overlay saved: {path}" +"status.drag_drop" = "Drop an image or folder here to open it." diff --git a/app/launcher.py b/app/launcher.py index 290f4f5..71138b8 100644 --- a/app/launcher.py +++ b/app/launcher.py @@ -1,49 +1,12 @@ -"""Launcher ensuring Tcl/Tk resources are available before starting ICRA.""" +"""Launcher for the PySide6 ICRA application.""" from __future__ import annotations -import os -import shutil -import subprocess -import sys -from pathlib import Path - - -def _copy_tcl_runtime(venv_root: Path) -> tuple[Path, Path] | None: - """Copy Tcl/Tk directories from the base interpreter into the venv if needed.""" - - base_prefix = Path(getattr(sys, "base_prefix", sys.prefix)) - base_tcl_dir = base_prefix / "tcl" - if not base_tcl_dir.exists(): - return None - - tcl_src = base_tcl_dir / "tcl8.6" - tk_src = base_tcl_dir / "tk8.6" - if not tcl_src.exists() or not tk_src.exists(): - return None - - target_root = venv_root / "tcl" - tcl_dest = target_root / "tcl8.6" - tk_dest = target_root / "tk8.6" - - if not tcl_dest.exists(): - shutil.copytree(tcl_src, tcl_dest, dirs_exist_ok=True) - if not tk_dest.exists(): - shutil.copytree(tk_src, tk_dest, dirs_exist_ok=True) - - return tcl_dest, tk_dest +from .qt import run def main() -> int: - venv_root = Path(sys.prefix) - tcl_paths = _copy_tcl_runtime(venv_root) - - env = os.environ.copy() - if tcl_paths: - env.setdefault("TCL_LIBRARY", str(tcl_paths[0])) - env.setdefault("TK_LIBRARY", str(tcl_paths[1])) - - return subprocess.call([sys.executable, "main.py"], env=env) + return run() if __name__ == "__main__": diff --git a/app/logic/constants.py b/app/logic/constants.py index 34c5076..4ef98fd 100644 --- a/app/logic/constants.py +++ b/app/logic/constants.py @@ -108,8 +108,6 @@ def _extract_options(data: dict[str, Any]) -> dict[str, Any]: return result -_CONFIG_DATA = _load_config_data() - DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)} LANGUAGE = _extract_language(_CONFIG_DATA) OPTIONS = {**_OPTION_DEFAULTS, **_extract_options(_CONFIG_DATA)} diff --git a/app/qt/__init__.py b/app/qt/__init__.py new file mode 100644 index 0000000..86cac02 --- /dev/null +++ b/app/qt/__init__.py @@ -0,0 +1,7 @@ +"""PySide6 application entry points.""" + +from __future__ import annotations + +from .app import create_application, run + +__all__ = ["create_application", "run"] diff --git a/app/qt/app.py b/app/qt/app.py new file mode 100644 index 0000000..84bf2a5 --- /dev/null +++ b/app/qt/app.py @@ -0,0 +1,61 @@ +"""Application bootstrap for the PySide6 GUI.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6 import QtGui, QtWidgets + +from app.logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE +from .main_window import MainWindow + + +def create_application() -> QtWidgets.QApplication: + """Create the Qt application instance with customised styling.""" + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication(sys.argv) + + app.setOrganizationName("ICRA") + app.setApplicationName("Interactive Color Range Analyzer") + app.setApplicationDisplayName("ICRA") + + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#111216")) + palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor("#f5f5f5")) + palette.setColor(QtGui.QPalette.Base, QtGui.QColor("#1a1b21")) + palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor("#20212a")) + palette.setColor(QtGui.QPalette.Button, QtGui.QColor("#20212a")) + palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor("#f5f5f5")) + palette.setColor(QtGui.QPalette.Text, QtGui.QColor("#f5f5f5")) + palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor("#5168ff")) + palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor("#ffffff")) + app.setPalette(palette) + + font = QtGui.QFont("Segoe UI", 10) + app.setFont(font) + + logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png" + if logo_path.exists(): + app.setWindowIcon(QtGui.QIcon(str(logo_path))) + + return app + + +def run() -> int: + """Run the PySide6 GUI.""" + app = create_application() + window = MainWindow( + language=LANGUAGE, + defaults=DEFAULTS.copy(), + reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE, + ) + primary_screen = app.primaryScreen() + if primary_screen is not None: + geometry = primary_screen.availableGeometry() + window.setGeometry(geometry) + window.showMaximized() + else: + window.showMaximized() + return app.exec() diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py new file mode 100644 index 0000000..d89219a --- /dev/null +++ b/app/qt/image_processor.py @@ -0,0 +1,314 @@ +"""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 Dict, Iterable, Tuple + +import numpy as np +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 + matches_excl: int = 0 + total_excl: int = 0 + + def summary(self, translate) -> str: + if self.total_all == 0: + return translate("stats.placeholder") + 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 + excluded_pct = (self.total_excl / self.total_all * 100) if self.total_all else 0.0 + excluded_match_pct = (self.matches_excl / self.total_excl * 100) if self.total_excl else 0.0 + return translate( + "stats.summary", + with_pct=with_pct, + without_pct=without_pct, + excluded_pct=excluded_pct, + excluded_match_pct=excluded_match_pct, + ) + + +def _rgb_to_hsv_numpy(arr: np.ndarray) -> np.ndarray: + """Vectorized RGB→HSV conversion. arr shape: (H, W, 3), dtype float32, range [0,1]. + Returns array of same shape with channels [H(0-360), S(0-100), V(0-100)]. + """ + r = arr[..., 0] + g = arr[..., 1] + b = arr[..., 2] + + cmax = np.maximum(np.maximum(r, g), b) + cmin = np.minimum(np.minimum(r, g), b) + delta = cmax - cmin + + # Value + v = cmax + + # Saturation + s = np.where(cmax > 0, delta / cmax, 0.0) + + # Hue + h = np.zeros_like(r) + mask_r = (delta > 0) & (cmax == r) + mask_g = (delta > 0) & (cmax == g) + mask_b = (delta > 0) & (cmax == b) + h[mask_r] = (60.0 * ((g[mask_r] - b[mask_r]) / delta[mask_r])) % 360.0 + h[mask_g] = (60.0 * ((b[mask_g] - r[mask_g]) / delta[mask_g]) + 120.0) % 360.0 + h[mask_b] = (60.0 * ((r[mask_b] - g[mask_b]) / delta[mask_b]) + 240.0) % 360.0 + + return np.stack([h, s * 100.0, v * 100.0], axis=-1) + + +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() + + self.defaults: Dict[str, int] = { + "hue_min": 0, + "hue_max": 360, + "sat_min": 25, + "val_min": 15, + "val_max": 100, + "alpha": 120, + } + self.hue_min = self.defaults["hue_min"] + self.hue_max = self.defaults["hue_max"] + self.sat_min = self.defaults["sat_min"] + self.val_min = self.defaults["val_min"] + self.val_max = self.defaults["val_max"] + self.alpha = self.defaults["alpha"] + + self.exclude_shapes: list[dict[str, object]] = [] + self.reset_exclusions_on_switch: bool = False + + def set_defaults(self, defaults: dict) -> None: + for key in self.defaults: + if key in defaults: + self.defaults[key] = int(defaults[key]) + for key, value in self.defaults.items(): + setattr(self, key, value) + self._rebuild_overlay() + + # 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, *, reset_collection: bool = True) -> Path: + image = Image.open(path).convert("RGBA") + self.orig_img = image + if reset_collection: + self.preview_paths = [path] + self.current_index = 0 + self._build_preview() + self._rebuild_overlay() + return path + + def load_folder(self, paths: Iterable[Path], start_index: int = 0) -> Path: + 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)) + return self._load_image_at_current() + + def next_image(self) -> Path | None: + if not self.preview_paths: + return None + self.current_index = (self.current_index + 1) % len(self.preview_paths) + return self._load_image_at_current() + + def previous_image(self) -> Path | None: + if not self.preview_paths: + return None + self.current_index = (self.current_index - 1) % len(self.preview_paths) + return self._load_image_at_current() + + def _load_image_at_current(self) -> Path: + path = self.preview_paths[self.current_index] + return self.load_single_image(path, reset_collection=False) + + # 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: + """Build colour-match overlay using vectorized NumPy operations.""" + if self.preview_img is None: + self.overlay_img = None + self.stats = Stats() + return + + base = self.preview_img.convert("RGBA") + arr = np.asarray(base, dtype=np.float32) # (H, W, 4) + + rgb = arr[..., :3] / 255.0 + alpha_ch = arr[..., 3] # alpha channel of the image + + hsv = _rgb_to_hsv_numpy(rgb) # (H, W, 3): H°, S%, V% + + hue = hsv[..., 0] + sat = hsv[..., 1] + val = hsv[..., 2] + + hue_min = float(self.hue_min) + hue_max = float(self.hue_max) + if hue_min <= hue_max: + hue_ok = (hue >= hue_min) & (hue <= hue_max) + else: + hue_ok = (hue >= hue_min) | (hue <= hue_max) + + match_mask = ( + hue_ok + & (sat >= float(self.sat_min)) + & (val >= float(self.val_min)) + & (val <= float(self.val_max)) + & (alpha_ch > 0) + ) + + # Exclusion mask (same pixel space as preview) + excl_mask = self._build_exclusion_mask_numpy(base.size) # bool (H,W) + + keep_match = match_mask & ~excl_mask + excl_match = match_mask & excl_mask + visible = alpha_ch > 0 + + matches_all = int(match_mask[visible].sum()) + total_all = int(visible.sum()) + matches_keep = int(keep_match[visible].sum()) + total_keep = int((visible & ~excl_mask).sum()) + matches_excl = int(excl_match[visible].sum()) + total_excl = int((visible & excl_mask).sum()) + + # Build overlay image + overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8) + overlay_arr[keep_match, 0] = 255 + overlay_arr[keep_match, 3] = int(self.alpha) + + self.overlay_img = Image.fromarray(overlay_arr, "RGBA") + self.stats = Stats( + matches_all=matches_all, + total_all=total_all, + matches_keep=matches_keep, + total_keep=total_keep, + matches_excl=matches_excl, + total_excl=total_excl, + ) + + # helpers ---------------------------------------------------------------- + + def _matches(self, r: int, g: int, b: int) -> bool: + """Single-pixel match — kept for compatibility / eyedropper use.""" + 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 + + def pick_colour(self, x: int, y: int) -> Tuple[float, float, float] | None: + """Return (hue°, sat%, val%) of the preview pixel at (x, y), or None.""" + if self.preview_img is None: + return None + img = self.preview_img.convert("RGBA") + try: + r, g, b, a = img.getpixel((x, y)) + except IndexError: + return None + if a == 0: + return None + h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + return (h * 360.0) % 360.0, s * 100.0, v * 100.0 + + # 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) + + # exclusions ------------------------------------------------------------- + + def set_exclusions(self, shapes: list[dict[str, object]]) -> None: + copied: list[dict[str, object]] = [] + for shape in shapes: + kind = shape.get("kind") + if kind == "rect": + coords = tuple(shape.get("coords", (0, 0, 0, 0))) # type: ignore[assignment] + copied.append({"kind": "rect", "coords": tuple(int(c) for c in coords)}) + elif kind == "polygon": + pts = shape.get("points", []) + copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]}) + self.exclude_shapes = copied + self._rebuild_overlay() + + def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None: + if not self.exclude_shapes: + return None + mask = Image.new("L", size, 0) + draw = ImageDraw.Draw(mask) + for shape in self.exclude_shapes: + kind = shape.get("kind") + if kind == "rect": + x0, y0, x1, y1 = shape["coords"] # type: ignore[index] + draw.rectangle([x0, y0, x1, y1], fill=255) + elif kind == "polygon": + points = shape.get("points", []) + if len(points) >= 3: + draw.polygon(points, fill=255) + return mask + + def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray: + """Return a boolean (H, W) mask — True where pixels are excluded.""" + w, h = size + if not self.exclude_shapes: + return np.zeros((h, w), dtype=bool) + pil_mask = self._build_exclusion_mask(size) + if pil_mask is None: + return np.zeros((h, w), dtype=bool) + return np.asarray(pil_mask, dtype=bool) diff --git a/app/qt/main_window.py b/app/qt/main_window.py new file mode 100644 index 0000000..0c6129f --- /dev/null +++ b/app/qt/main_window.py @@ -0,0 +1,1159 @@ +"""Main PySide6 window emulating the legacy Tk interface with translations and themes.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Dict, List, Tuple + +from PySide6 import QtCore, QtGui, QtWidgets + +from app.i18n import I18nMixin +from app.logic import SUPPORTED_IMAGE_EXTENSIONS +from .image_processor import QtImageProcessor + +DEFAULT_COLOUR = "#763e92" + +PRESET_COLOURS: List[Tuple[str, str]] = [ + ("palette.swatch.red", "#ff3b30"), + ("palette.swatch.orange", "#ff9500"), + ("palette.swatch.yellow", "#ffd60a"), + ("palette.swatch.green", "#34c759"), + ("palette.swatch.teal", "#5ac8fa"), + ("palette.swatch.blue", "#0a84ff"), + ("palette.swatch.violet", "#af52de"), + ("palette.swatch.magenta", "#ff2d55"), + ("palette.swatch.white", "#ffffff"), + ("palette.swatch.grey", "#8e8e93"), + ("palette.swatch.black", "#000000"), +] + +SLIDER_SPECS: List[Tuple[str, str, int, int]] = [ + ("sliders.hue_min", "hue_min", 0, 360), + ("sliders.hue_max", "hue_max", 0, 360), + ("sliders.sat_min", "sat_min", 0, 100), + ("sliders.val_min", "val_min", 0, 100), + ("sliders.val_max", "val_max", 0, 100), + ("sliders.alpha", "alpha", 0, 255), +] + +THEMES: Dict[str, Dict[str, str]] = { + "dark": { + "window_bg": "#111216", + "panel_bg": "#16171d", + "text": "#f7f7fb", + "text_muted": "rgba(255,255,255,0.68)", + "text_dim": "rgba(255,255,255,0.45)", + "accent": "#5168ff", + "accent_secondary": "#9a4dff", + "titlebar_bg": "#16171d", + "border": "rgba(255,255,255,0.08)", + "highlight": "#e6b84b", + }, + "light": { + "window_bg": "#f3f4fb", + "panel_bg": "#ffffff", + "text": "#1d1e24", + "text_muted": "rgba(29,30,36,0.78)", + "text_dim": "rgba(29,30,36,0.55)", + "accent": "#5168ff", + "accent_secondary": "#9a4dff", + "titlebar_bg": "#e9ebf5", + "border": "rgba(29,30,36,0.12)", + "highlight": "#c56217", + }, +} + + +class ToolbarButton(QtWidgets.QPushButton): + """Rounded toolbar button inspired by the legacy design.""" + + def __init__(self, icon_text: str, label: str, callback: Callable[[], None], parent: QtWidgets.QWidget | None = None): + text = f"{icon_text} {label}" + super().__init__(text, parent) + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setFixedHeight(32) + metrics = QtGui.QFontMetrics(self.font()) + width = metrics.horizontalAdvance(text) + 28 + self.setMinimumWidth(width) + self.clicked.connect(callback) + + def apply_theme(self, colours: Dict[str, str]) -> None: + self.setStyleSheet( + f""" + QPushButton {{ + padding: 8px 16px; + border-radius: 10px; + border: 1px solid {colours['border']}; + background-color: rgba(255, 255, 255, 0.04); + color: {colours['text']}; + font-weight: 600; + }} + QPushButton:hover {{ + background-color: rgba(255, 255, 255, 0.12); + }} + QPushButton:pressed {{ + background-color: rgba(255, 255, 255, 0.18); + }} + """ + ) + + +class ColourSwatch(QtWidgets.QPushButton): + """Clickable palette swatch.""" + + def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.hex_code = hex_code + self.name_key = name + self.callback = callback + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setFixedSize(28, 28) + self._apply_colour(hex_code) + self.clicked.connect(lambda: callback(hex_code, self.name_key)) + + def _apply_colour(self, hex_code: str) -> None: + self.setStyleSheet( + f""" + QPushButton {{ + background-color: {hex_code}; + border: 2px solid rgba(255, 255, 255, 0.18); + border-radius: 6px; + }} + QPushButton:hover {{ + border-color: rgba(255, 255, 255, 0.45); + }} + """ + ) + + def apply_theme(self, colours: Dict[str, str]) -> None: + self.setStyleSheet( + f""" + QPushButton {{ + background-color: {self.hex_code}; + border: 2px solid {colours['border']}; + border-radius: 6px; + }} + QPushButton:hover {{ + border-color: {colours['accent']}; + }} + """ + ) + + +class SliderControl(QtWidgets.QWidget): + """Slider with header and editable live value input.""" + + value_changed = QtCore.Signal(str, int) + + def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int): + super().__init__() + self.key = key + self._minimum = minimum + self._maximum = maximum + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + header = QtWidgets.QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + self.title_label = QtWidgets.QLabel(title) + header.addWidget(self.title_label) + header.addStretch(1) + self.value_edit = QtWidgets.QLineEdit(str(initial)) + self.value_edit.setFixedWidth(44) + self.value_edit.setAlignment(QtCore.Qt.AlignRight) + self.value_edit.setValidator(QtGui.QIntValidator(minimum, maximum, self)) + self.value_edit.editingFinished.connect(self._commit_edit) + header.addWidget(self.value_edit) + layout.addLayout(header) + + self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.slider.setRange(minimum, maximum) + self.slider.setValue(initial) + self.slider.setCursor(QtCore.Qt.PointingHandCursor) + self.slider.valueChanged.connect(self._sync_value) + layout.addWidget(self.slider) + + def _sync_value(self, value: int) -> None: + self.value_edit.setText(str(value)) + self.value_changed.emit(self.key, value) + + def _commit_edit(self) -> None: + text = self.value_edit.text().strip() + try: + value = int(text) + except ValueError: + value = self.slider.value() + value = max(self._minimum, min(self._maximum, value)) + self.slider.setValue(value) # triggers _sync_value -> signal + + def set_value(self, value: int) -> None: + self.slider.blockSignals(True) + self.slider.setValue(value) + self.slider.blockSignals(False) + self.value_edit.setText(str(value)) + + def apply_theme(self, colours: Dict[str, str]) -> None: + self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") + self.value_edit.setStyleSheet( + f"color: {colours['text_dim']}; background: transparent; " + f"border: 1px solid {colours['border']}; border-radius: 4px; padding: 0 2px;" + ) + self.slider.setStyleSheet( + f""" + QSlider::groove:horizontal {{ + border: 1px solid {colours['border']}; + height: 6px; + background: rgba(255,255,255,0.14); + border-radius: 4px; + }} + QSlider::handle:horizontal {{ + background: {colours['accent_secondary']}; + border: 1px solid rgba(255,255,255,0.2); + width: 14px; + margin: -5px 0; + border-radius: 7px; + }} + """ + ) + + +class CanvasView(QtWidgets.QGraphicsView): + """Interactive canvas for drawing exclusion shapes over the preview image.""" + + shapes_changed = QtCore.Signal(list) + pixel_clicked = QtCore.Signal(int, int) # x, y in image coordinates + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setMouseTracking(True) + self._scene = QtWidgets.QGraphicsScene(self) + self.setScene(self._scene) + + self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None + self._shape_items: list[QtWidgets.QGraphicsItem] = [] + self._rubber_item: QtWidgets.QGraphicsRectItem | None = None + self._stroke_item: QtWidgets.QGraphicsPathItem | None = None + + self.shapes: list[dict[str, object]] = [] + self.mode: str = "rect" + self.pick_mode: bool = False + self._drawing = False + self._start_pos = QtCore.QPointF() + self._last_pos = QtCore.QPointF() + self._path = QtGui.QPainterPath() + self._accent = QtGui.QColor("#ffd700") + + def set_pixmap(self, pixmap: QtGui.QPixmap) -> None: + self._scene.clear() + self._shape_items.clear() + self._pixmap_item = self._scene.addPixmap(pixmap) + self._scene.setSceneRect(pixmap.rect()) + self.resetTransform() + self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio) + self._redraw_shapes() + + def clear_canvas(self) -> None: + if self._scene: + self._scene.clear() + self._pixmap_item = None + self._shape_items.clear() + self.shapes = [] + + def set_shapes(self, shapes: list[dict[str, object]]) -> None: + self.shapes = shapes + self._redraw_shapes() + + def set_mode(self, mode: str) -> None: + self.mode = mode + + def set_accent(self, colour: str) -> None: + self._accent = QtGui.QColor(colour) + self._redraw_shapes() + + def undo_last(self) -> None: + if not self.shapes: + return + self.shapes.pop() + self._redraw_shapes() + self.shapes_changed.emit(self.shapes.copy()) + + def clear_shapes(self) -> None: + self.shapes = [] + self._redraw_shapes() + self.shapes_changed.emit([]) + + # event handling -------------------------------------------------------- + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + if event.button() == QtCore.Qt.LeftButton and self.pick_mode and self._pixmap_item: + scene_pos = self.mapToScene(event.position().toPoint()) + clamped = self._clamp_to_image(scene_pos) + self.pixel_clicked.emit(int(clamped.x()), int(clamped.y())) + event.accept() + return + if event.button() == QtCore.Qt.RightButton and self._pixmap_item: + self._drawing = True + scene_pos = self.mapToScene(event.position().toPoint()) + self._start_pos = self._clamp_to_image(scene_pos) + if self.mode == "rect": + pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) + brush = QtGui.QBrush(QtCore.Qt.NoBrush) + self._rubber_item = self._scene.addRect(QtCore.QRectF(self._start_pos, self._start_pos), pen, brush) + else: + pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) + self._path = QtGui.QPainterPath(self._start_pos) + self._stroke_item = self._scene.addPath(self._path, pen) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: + if self._drawing and self._pixmap_item: + scene_pos = self.mapToScene(event.position().toPoint()) + self._last_pos = self._clamp_to_image(scene_pos) + if self.mode == "rect" and self._rubber_item: + rect = QtCore.QRectF(self._start_pos, self._last_pos).normalized() + self._rubber_item.setRect(rect) + elif self.mode == "free" and self._stroke_item: + self._path.lineTo(self._last_pos) + self._stroke_item.setPath(self._path) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: + if self._drawing and event.button() == QtCore.Qt.RightButton and self._pixmap_item: + scene_pos = self.mapToScene(event.position().toPoint()) + end_pos = self._clamp_to_image(scene_pos) + if self.mode == "rect" and self._rubber_item: + rect = QtCore.QRectF(self._start_pos, end_pos).normalized() + if rect.width() > 2 and rect.height() > 2: + shape = { + "kind": "rect", + "coords": ( + int(rect.left()), + int(rect.top()), + int(rect.right()), + int(rect.bottom()), + ), + } + self.shapes.append(shape) + self._scene.removeItem(self._rubber_item) + self._rubber_item = None + elif self.mode == "free" and self._stroke_item: + self._path.lineTo(self._start_pos) + points = [(int(pt.x()), int(pt.y())) for pt in self._path.toFillPolygon()] + if len(points) >= 3: + shape = {"kind": "polygon", "points": points} + self.shapes.append(shape) + self._scene.removeItem(self._stroke_item) + self._stroke_item = None + self._drawing = False + self._redraw_shapes() + self.shapes_changed.emit(self.shapes.copy()) + event.accept() + return + super().mouseReleaseEvent(event) + + # helpers ---------------------------------------------------------------- + + def _clamp_to_image(self, pos: QtCore.QPointF) -> QtCore.QPointF: + if not self._pixmap_item: + return pos + pixmap = self._pixmap_item.pixmap() + x = min(max(0.0, pos.x()), float(pixmap.width() - 1)) + y = min(max(0.0, pos.y()), float(pixmap.height() - 1)) + return QtCore.QPointF(x, y) + + def _redraw_shapes(self) -> None: + for item in self._shape_items: + self._scene.removeItem(item) + self._shape_items.clear() + if not self._pixmap_item: + return + pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) + pen.setCosmetic(True) + for shape in self.shapes: + kind = shape.get("kind") + if kind == "rect": + x0, y0, x1, y1 = shape["coords"] # type: ignore[index] + rect_item = self._scene.addRect(QtCore.QRectF(x0, y0, x1 - x0, y1 - y0), pen) + self._shape_items.append(rect_item) + elif kind == "polygon": + points = shape.get("points", []) + if len(points) < 2: + continue + path = QtGui.QPainterPath() + first = QtCore.QPointF(*points[0]) + path.moveTo(first) + for px, py in points[1:]: + path.lineTo(px, py) + path.closeSubpath() + path_item = self._scene.addPath(path, pen) + self._shape_items.append(path_item) + + +class OverlayCanvas(QtWidgets.QGraphicsView): + """Read-only QGraphicsView for displaying the colour-match overlay.""" + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self._scene = QtWidgets.QGraphicsScene(self) + self.setScene(self._scene) + self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None + + def set_pixmap(self, pixmap: QtGui.QPixmap) -> None: + self._scene.clear() + self._pixmap_item = self._scene.addPixmap(pixmap) + self._scene.setSceneRect(pixmap.rect()) + self.resetTransform() + self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio) + + def clear_canvas(self) -> None: + self._scene.clear() + self._pixmap_item = None + + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: # type: ignore[override] + super().resizeEvent(event) + if self._pixmap_item: + self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio) + + +class TitleBar(QtWidgets.QWidget): + """Custom title bar with native window controls.""" + + HEIGHT = 40 + + def __init__(self, window: "MainWindow") -> None: + super().__init__(window) + self.window = window + self.setFixedHeight(self.HEIGHT) + self.setCursor(QtCore.Qt.ArrowCursor) + self.setAutoFillBackground(True) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(8) + + logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png" + if logo_path.exists(): + pixmap = QtGui.QPixmap(str(logo_path)) + self.logo_label = QtWidgets.QLabel() + self.logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) + layout.addWidget(self.logo_label) + else: + self.logo_label = None + + self.title_label = QtWidgets.QLabel() + layout.addWidget(self.title_label) + layout.addStretch(1) + + self.min_btn = self._create_button("–", "Minimise") + self.min_btn.clicked.connect(window.showMinimized) + layout.addWidget(self.min_btn) + + self.max_btn = self._create_button("❐", "Maximise / Restore") + self.max_btn.clicked.connect(window.toggle_maximise) + layout.addWidget(self.max_btn) + + self.close_btn = self._create_button("✕", "Close") + self.close_btn.clicked.connect(window.close) + layout.addWidget(self.close_btn) + + def _create_button(self, text: str, tooltip: str) -> QtWidgets.QPushButton: + btn = QtWidgets.QPushButton(text) + btn.setToolTip(tooltip) + btn.setFixedSize(36, 24) + btn.setCursor(QtCore.Qt.ArrowCursor) + btn.setStyleSheet( + """ + QPushButton { + background-color: transparent; + color: #f7f7fb; + border: none; + padding: 4px 10px; + } + QPushButton:hover { + background-color: rgba(255, 255, 255, 0.1); + } + """ + ) + return btn + + def apply_theme(self, colours: Dict[str, str]) -> None: + palette = self.palette() + palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colours["titlebar_bg"])) + self.setPalette(palette) + self.title_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;") + hover_bg = "#d0342c" if colours["titlebar_bg"] != "#e9ebf5" else "#e6675a" + self.close_btn.setStyleSheet( + f""" + QPushButton {{ + background-color: transparent; + color: {colours['text']}; + border: none; + padding: 4px 10px; + }} + QPushButton:hover {{ + background-color: {hover_bg}; + color: #ffffff; + }} + """ + ) + for btn in (self.min_btn, self.max_btn): + btn.setStyleSheet( + f""" + QPushButton {{ + background-color: transparent; + color: {colours['text']}; + border: none; + padding: 4px 10px; + }} + QPushButton:hover {{ + background-color: rgba(0, 0, 0, 0.1); + }} + """ + ) + + def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None: + if event.button() == QtCore.Qt.LeftButton: + self.window.toggle_maximise() + event.accept() + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + if event.button() == QtCore.Qt.LeftButton: + self.window.start_system_move(event.globalPosition()) + event.accept() + super().mousePressEvent(event) + + +class MainWindow(QtWidgets.QMainWindow, I18nMixin): + """Main application window containing all controls.""" + + def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None: + super().__init__() + self.init_i18n(language) + self.setWindowTitle(self._t("app.title")) + self.setWindowFlag(QtCore.Qt.FramelessWindowHint) + self.setWindowFlag(QtCore.Qt.Window) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False) + self.setMinimumSize(1100, 680) + + container = QtWidgets.QWidget() + container_layout = QtWidgets.QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(0) + + self.title_bar = TitleBar(self) + self.title_bar.title_label.setText(self._t("app.title")) + container_layout.addWidget(self.title_bar) + + self.content = QtWidgets.QWidget() + self.processor = QtImageProcessor() + self.processor.set_defaults(defaults) + self.processor.reset_exclusions_on_switch = reset_exclusions + + self.content_layout = QtWidgets.QVBoxLayout(self.content) + self.content_layout.setContentsMargins(24, 24, 24, 24) + self.content_layout.setSpacing(18) + + self.content_layout.addLayout(self._build_toolbar()) + self.content_layout.addLayout(self._build_palette()) + self.content_layout.addLayout(self._build_sliders()) + self.content_layout.addWidget(self._build_previews(), 1) + self.content_layout.addLayout(self._build_status_section()) + + container_layout.addWidget(self.content, 1) + self.setCentralWidget(container) + + self._is_maximised = False + self._current_image_path: Path | None = None + self._current_colour = DEFAULT_COLOUR + self._toolbar_actions: Dict[str, Callable[[], None]] = {} + self._register_default_actions() + + self.exclude_mode = "rect" + self._pick_mode = False + self.image_view.set_mode(self.exclude_mode) + self.image_view.shapes_changed.connect(self._on_shapes_changed) + self.image_view.pixel_clicked.connect(self._on_pixel_picked) + + self._sync_sliders_from_processor() + self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current")) + + self.current_theme = "dark" + self._apply_theme(self.current_theme) + + # Drag-and-drop + self.setAcceptDrops(True) + + # Keyboard shortcuts + self._setup_shortcuts() + + # Restore window geometry + self._settings = QtCore.QSettings("ICRA", "MainWindow") + geometry = self._settings.value("geometry") + if geometry: + self.restoreGeometry(geometry) + + # Window control helpers ------------------------------------------------- + + def toggle_maximise(self) -> None: + handle = self.windowHandle() + if handle is None: + return + if self._is_maximised: + self.showNormal() + self._is_maximised = False + self.title_bar.max_btn.setText("❐") + else: + self.showMaximized() + self._is_maximised = True + self.title_bar.max_btn.setText("⧉") + + def start_system_move(self, _global_position: QtCore.QPointF) -> None: + handle = self.windowHandle() + if handle: + handle.startSystemMove() + + # UI builders ------------------------------------------------------------ + + def _build_toolbar(self) -> QtWidgets.QHBoxLayout: + layout = QtWidgets.QHBoxLayout() + layout.setSpacing(12) + + buttons = [ + ("open_image", "🖼", "toolbar.open_image"), + ("open_folder", "📂", "toolbar.open_folder"), + ("choose_color", "🎨", "toolbar.choose_color"), + ("pick_from_image", "🖱", "toolbar.pick_from_image"), + ("save_overlay", "💾", "toolbar.save_overlay"), + ("toggle_free_draw", "△", "toolbar.toggle_free_draw"), + ("clear_excludes", "🧹", "toolbar.clear_excludes"), + ("undo_exclude", "↩", "toolbar.undo_exclude"), + ("reset_sliders", "🔄", "toolbar.reset_sliders"), + ("toggle_theme", "🌓", "toolbar.toggle_theme"), + ] + self._toolbar_buttons: Dict[str, ToolbarButton] = {} + for key, icon_txt, text_key in buttons: + label = self._t(text_key) + button = ToolbarButton(icon_txt, label, lambda _checked=False, k=key: self._invoke_action(k)) + layout.addWidget(button) + self._toolbar_buttons[key] = button + + layout.addStretch(1) + self.status_label = QtWidgets.QLabel(self._t("status.no_file")) + layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight) + return layout + + def _build_palette(self) -> QtWidgets.QHBoxLayout: + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(16) + + current_group = QtWidgets.QHBoxLayout() + current_group.setSpacing(8) + self.current_label = QtWidgets.QLabel(self._t("palette.current")) + current_group.addWidget(self.current_label) + + self.current_colour_swatch = QtWidgets.QLabel() + self.current_colour_swatch.setFixedSize(28, 28) + self.current_colour_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOUR}; border-radius: 6px;") + current_group.addWidget(self.current_colour_swatch) + + self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})") + current_group.addWidget(self.current_colour_label) + layout.addLayout(current_group) + + self.more_label = QtWidgets.QLabel(self._t("palette.more")) + layout.addWidget(self.more_label) + + swatch_container = QtWidgets.QHBoxLayout() + swatch_container.setSpacing(8) + self.swatch_buttons: List[ColourSwatch] = [] + for name_key, hex_code in PRESET_COLOURS: + swatch = ColourSwatch(self._t(name_key), hex_code, self._update_colour_display) + swatch_container.addWidget(swatch) + self.swatch_buttons.append(swatch) + layout.addLayout(swatch_container) + layout.addStretch(1) + return layout + + def _build_sliders(self) -> QtWidgets.QHBoxLayout: + layout = QtWidgets.QHBoxLayout() + layout.setSpacing(16) + self._slider_controls: Dict[str, SliderControl] = {} + for key, attr, minimum, maximum in SLIDER_SPECS: + initial = int(getattr(self.processor, attr)) + control = SliderControl(self._t(key), attr, minimum, maximum, initial) + control.value_changed.connect(self._on_slider_change) + layout.addWidget(control) + self._slider_controls[attr] = control + return layout + + def _build_previews(self) -> QtWidgets.QWidget: + container = QtWidgets.QWidget() + layout = QtWidgets.QGridLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setHorizontalSpacing(16) + + self.prev_button = QtWidgets.QToolButton() + self.prev_button.setCursor(QtCore.Qt.PointingHandCursor) + self.prev_button.setAutoRaise(True) + self.prev_button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) + self.prev_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowBack)) + self.prev_button.setIconSize(QtCore.QSize(20, 20)) + self.prev_button.setFixedSize(38, 38) + self.prev_button.clicked.connect(lambda: self._invoke_action("show_previous_image")) + layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter) + + self.image_view = CanvasView() + layout.addWidget(self.image_view, 0, 1) + + self.overlay_view = OverlayCanvas() + layout.addWidget(self.overlay_view, 0, 2) + + self.next_button = QtWidgets.QToolButton() + self.next_button.setCursor(QtCore.Qt.PointingHandCursor) + self.next_button.setAutoRaise(True) + self.next_button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) + self.next_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowForward)) + self.next_button.setIconSize(QtCore.QSize(20, 20)) + self.next_button.setFixedSize(38, 38) + self.next_button.clicked.connect(lambda: self._invoke_action("show_next_image")) + layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter) + layout.setColumnStretch(1, 1) + layout.setColumnStretch(2, 1) + return container + + def _build_status_section(self) -> QtWidgets.QVBoxLayout: + layout = QtWidgets.QVBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(0, 0, 0, 0) + + self.filename_label = QtWidgets.QLabel("—") + self.filename_label.setAlignment(QtCore.Qt.AlignCenter) + layout.addWidget(self.filename_label) + + self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder")) + self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) + layout.addWidget(self.ratio_label) + return layout + + # Action wiring ---------------------------------------------------------- + + def _register_default_actions(self) -> None: + self._toolbar_actions = { + "open_image": self.open_image, + "open_folder": self.open_folder, + "choose_color": self.choose_colour, + "pick_from_image": self.pick_from_image, + "save_overlay": self.save_overlay, + "toggle_free_draw": self.toggle_free_draw, + "clear_excludes": self.clear_exclusions, + "undo_exclude": self.undo_exclusion, + "reset_sliders": self._reset_sliders, + "toggle_theme": self.toggle_theme, + "show_previous_image": self.show_previous_image, + "show_next_image": self.show_next_image, + } + + def _invoke_action(self, key: str) -> None: + action = self._toolbar_actions.get(key) + if action: + action() + + # Image handling --------------------------------------------------------- + + def open_image(self) -> None: + filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" + path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, self._t("dialog.open_image_title"), "", filters) + if not path_str: + return + path = Path(path_str) + try: + loaded_path = self.processor.load_single_image(path) + except Exception as exc: # pragma: no cover - user feedback + QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc)) + return + self._current_image_path = loaded_path + if self.processor.reset_exclusions_on_switch: + self.image_view.clear_shapes() + self.processor.set_exclusions([]) + self._refresh_views() + + def open_folder(self) -> None: + directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title")) + 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, self._t("dialog.info_title"), self._t("dialog.folder_empty")) + return + try: + loaded_path = self.processor.load_folder(paths) + except ValueError as exc: + QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc)) + return + self._current_image_path = loaded_path + self._refresh_views() + + def show_previous_image(self) -> None: + if not self.processor.preview_paths: + QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) + return + path = self.processor.previous_image() + if path is None: + return + self._current_image_path = path + if self.processor.reset_exclusions_on_switch: + self.image_view.clear_shapes() + self.processor.set_exclusions([]) + self._refresh_views() + + def show_next_image(self) -> None: + if not self.processor.preview_paths: + QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) + return + path = self.processor.next_image() + if path is None: + return + self._current_image_path = path + if self.processor.reset_exclusions_on_switch: + self.image_view.clear_shapes() + self.processor.set_exclusions([]) + self._refresh_views() + + # Helpers ---------------------------------------------------------------- + + def _update_colour_display(self, hex_code: str, label: str) -> None: + self._current_colour = hex_code + self.current_colour_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;") + self.current_colour_label.setText(f"({hex_code})") + self.status_label.setText(f"{label}: {hex_code}") + + def _on_slider_change(self, key: str, value: int) -> None: + self.processor.set_threshold(key, value) + label = self._slider_title(key) + self.status_label.setText(f"{label}: {value}") + self._refresh_overlay_only() + + def _reset_sliders(self) -> None: + for _, attr, _, _ in SLIDER_SPECS: + control = self._slider_controls.get(attr) + if control: + default_value = int(self.processor.defaults.get(attr, getattr(self.processor, attr))) + control.set_value(default_value) + self.processor.set_threshold(attr, default_value) + self.status_label.setText(self._t("status.defaults_restored")) + self._refresh_overlay_only() + + def _coming_soon(self) -> None: + QtWidgets.QMessageBox.information( + self, + self._t("dialog.info_title"), + "Feature coming soon in the PySide6 migration.", + ) + + # Shortcuts -------------------------------------------------------------- + + def _setup_shortcuts(self) -> None: + shortcuts = [ + (QtGui.QKeySequence.Open, self.open_image), + (QtGui.QKeySequence("Ctrl+Shift+O"), self.open_folder), + (QtGui.QKeySequence.Save, self.save_overlay), + (QtGui.QKeySequence.Undo, self.undo_exclusion), + (QtGui.QKeySequence("Ctrl+R"), self._reset_sliders), + (QtGui.QKeySequence(QtCore.Qt.Key_Left), self.show_previous_image), + (QtGui.QKeySequence(QtCore.Qt.Key_Right), self.show_next_image), + (QtGui.QKeySequence(QtCore.Qt.Key_Escape), self._exit_pick_mode), + ] + for seq, slot in shortcuts: + sc = QtGui.QShortcut(seq, self) + sc.activated.connect(slot) + + # Pick-mode / eyedropper ------------------------------------------------- + + def pick_from_image(self) -> None: + if self.processor.preview_img is None: + QtWidgets.QMessageBox.information( + self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded") + ) + return + self._pick_mode = True + self.image_view.pick_mode = True + self.image_view.setCursor(QtCore.Qt.CrossCursor) + self.status_label.setText(self._t("status.pick_mode_ready")) + + def _exit_pick_mode(self) -> None: + self._pick_mode = False + self.image_view.pick_mode = False + self.image_view.setCursor(QtCore.Qt.ArrowCursor) + self.status_label.setText(self._t("status.pick_mode_ended")) + + def _on_pixel_picked(self, x: int, y: int) -> None: + result = self.processor.pick_colour(x, y) + if result is None: + self._exit_pick_mode() + return + hue, sat, val = result + + # Derive a narrow hue range (±15°) and minimum sat/val from the pixel + margin = 15 + hue_min = max(0, int(hue) - margin) + hue_max = min(360, int(hue) + margin) + sat_min = max(0, int(sat) - 20) + val_min = max(0, int(val) - 30) + val_max = 100 + + for attr, value in [ + ("hue_min", hue_min), ("hue_max", hue_max), + ("sat_min", sat_min), ("val_min", val_min), ("val_max", val_max), + ]: + ctrl = self._slider_controls.get(attr) + if ctrl: + ctrl.set_value(value) + self.processor.set_threshold(attr, value) + + # Update colour swatch to the picked pixel colour + h_norm = hue / 360.0 + s_norm = sat / 100.0 + v_norm = val / 100.0 + import colorsys + r, g, b = colorsys.hsv_to_rgb(h_norm, s_norm, v_norm) + hex_code = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255)) + self._update_colour_display(hex_code, "") + + self.status_label.setText( + self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val) + ) + self._exit_pick_mode() + self._refresh_overlay_only() + + # Drag-and-drop ---------------------------------------------------------- + + def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event: QtGui.QDropEvent) -> None: + urls = event.mimeData().urls() + if not urls: + return + path = Path(urls[0].toLocalFile()) + if path.is_dir(): + paths = sorted( + (p for p in path.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, self._t("dialog.info_title"), self._t("dialog.folder_empty")) + return + try: + loaded_path = self.processor.load_folder(paths) + except ValueError as exc: + QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc)) + return + self._current_image_path = loaded_path + elif path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS: + try: + loaded_path = self.processor.load_single_image(path) + except Exception as exc: + QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc)) + return + self._current_image_path = loaded_path + if self.processor.reset_exclusions_on_switch: + self.image_view.clear_shapes() + self.processor.set_exclusions([]) + else: + return + self._refresh_views() + event.acceptProposedAction() + + # Window lifecycle ------------------------------------------------------- + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + self._settings.setValue("geometry", self.saveGeometry()) + super().closeEvent(event) + + def choose_colour(self) -> None: + colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title")) + if not colour.isValid(): + return + hex_code = colour.name() + self._update_colour_display(hex_code, self._t("dialog.choose_colour_title")) + + def save_overlay(self) -> None: + pixmap = self.processor.overlay_pixmap() + if pixmap.isNull(): + QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_preview_available")) + return + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + self._t("dialog.save_overlay_title"), + "overlay.png", + "PNG (*.png)", + ) + if not filename: + return + if not pixmap.save(filename, "PNG"): + QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), self._t("dialog.image_open_failed", error="Unable to save file")) + return + self.status_label.setText(self._t("dialog.overlay_saved", path=filename)) + + def toggle_free_draw(self) -> None: + self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect" + self.image_view.set_mode(self.exclude_mode) + message_key = "status.free_draw_enabled" if self.exclude_mode == "free" else "status.free_draw_disabled" + self.status_label.setText(self._t(message_key)) + + def clear_exclusions(self) -> None: + self.image_view.clear_shapes() + self.processor.set_exclusions([]) + self.status_label.setText(self._t("toolbar.clear_excludes")) + self._refresh_overlay_only() + + def undo_exclusion(self) -> None: + self.image_view.undo_last() + self.status_label.setText(self._t("toolbar.undo_exclude")) + + def toggle_theme(self) -> None: + self.current_theme = "light" if self.current_theme == "dark" else "dark" + self._apply_theme(self.current_theme) + + def _apply_theme(self, mode: str) -> None: + colours = THEMES[mode] + self.content.setStyleSheet(f"background-color: {colours['window_bg']};") + self.image_view.setStyleSheet( + f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;" + ) + self.image_view.set_accent(colours["highlight"]) + self.overlay_view.setStyleSheet( + f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;" + ) + + self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") + self.current_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") + self.current_colour_label.setStyleSheet(f"color: {colours['text_dim']};") + self.more_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") + self.filename_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;") + self.ratio_label.setStyleSheet(f"color: {colours['highlight']}; font-weight: 600;") + + for button in self._toolbar_buttons.values(): + button.apply_theme(colours) + for swatch in self.swatch_buttons: + swatch.apply_theme(colours) + for control in self._slider_controls.values(): + control.apply_theme(colours) + + self._style_nav_button(self.prev_button) + self._style_nav_button(self.next_button) + + self.title_bar.apply_theme(colours) + + def _sync_sliders_from_processor(self) -> None: + for _, attr, _, _ in SLIDER_SPECS: + control = self._slider_controls.get(attr) + if control: + control.set_value(int(getattr(self.processor, attr))) + + def _slider_title(self, key: str) -> str: + for title_key, attr, _, _ in SLIDER_SPECS: + if attr == key: + return self._t(title_key) + return key + + def _style_nav_button(self, button: QtWidgets.QToolButton) -> None: + colours = THEMES[self.current_theme] + button.setStyleSheet( + f"QToolButton {{ border-radius: 19px; background-color: {colours['panel_bg']}; " + f"border: 1px solid {colours['border']}; color: {colours['text']}; }}" + f"QToolButton:hover {{ background-color: {colours['accent_secondary']}; color: white; }}" + ) + button.setIconSize(QtCore.QSize(20, 20)) + if button is getattr(self, "prev_button", None): + button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowBack)) + elif button is getattr(self, "next_button", None): + button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowForward)) + + + def _refresh_views(self) -> None: + preview_pix = self.processor.preview_pixmap() + if preview_pix.isNull(): + self.image_view.clear_canvas() + self.image_view.set_shapes([]) + self.overlay_view.clear_canvas() + else: + self.image_view.set_pixmap(preview_pix) + self.image_view.set_shapes(self.processor.exclude_shapes.copy()) + overlay_pix = self.processor.overlay_pixmap() + overlay_pix = self._overlay_with_outlines(overlay_pix) + self.overlay_view.set_pixmap(overlay_pix) + if self._current_image_path and self.processor.preview_img: + width, height = self.processor.preview_img.size + total = len(self.processor.preview_paths) + position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else "" + dimensions = f"{width}×{height}" + self.status_label.setText( + self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) + ) + self.filename_label.setText( + self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position) + ) + self.ratio_label.setText(self.processor.stats.summary(self._t)) + + def _refresh_overlay_only(self) -> None: + if self.processor.preview_img is None: + return + pix = self.processor.overlay_pixmap() + if pix.isNull(): + self.overlay_view.clear_canvas() + else: + self.overlay_view.set_pixmap(self._overlay_with_outlines(pix)) + self.ratio_label.setText(self.processor.stats.summary(self._t)) + + def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None: + self.processor.set_exclusions(shapes) + self._refresh_overlay_only() + + def _overlay_with_outlines(self, pixmap: QtGui.QPixmap) -> QtGui.QPixmap: + if pixmap.isNull() or not self.processor.exclude_shapes: + return pixmap + result = QtGui.QPixmap(pixmap) + painter = QtGui.QPainter(result) + colour = QtGui.QColor(THEMES[self.current_theme]["highlight"]) + pen = QtGui.QPen(colour) + pen.setWidth(3) + pen.setCosmetic(True) + pen.setCapStyle(QtCore.Qt.RoundCap) + pen.setJoinStyle(QtCore.Qt.RoundJoin) + painter.setPen(pen) + for shape in self.processor.exclude_shapes: + kind = shape.get("kind") + if kind == "rect": + x0, y0, x1, y1 = shape["coords"] # type: ignore[index] + painter.drawRect(QtCore.QRectF(x0, y0, x1 - x0, y1 - y0)) + elif kind == "polygon": + points = shape.get("points", []) + if len(points) >= 2: + path = QtGui.QPainterPath() + first = QtCore.QPointF(*points[0]) + path.moveTo(first) + for px, py in points[1:]: + path.lineTo(px, py) + path.closeSubpath() + painter.drawPath(path) + painter.end() + return result diff --git a/images/ingame/271.jpg b/images/ingame/271.jpg new file mode 100644 index 0000000..facb12f Binary files /dev/null and b/images/ingame/271.jpg differ diff --git a/images/ingame/296.jpg b/images/ingame/296.jpg new file mode 100644 index 0000000..a11b96e Binary files /dev/null and b/images/ingame/296.jpg differ diff --git a/images/ingame/328.jpg b/images/ingame/328.jpg new file mode 100644 index 0000000..44f4173 Binary files /dev/null and b/images/ingame/328.jpg differ diff --git a/images/ingame/460.jpg b/images/ingame/460.jpg new file mode 100644 index 0000000..9b94004 Binary files /dev/null and b/images/ingame/460.jpg differ diff --git a/images/ingame/487.jpg b/images/ingame/487.jpg new file mode 100644 index 0000000..0d20a35 Binary files /dev/null and b/images/ingame/487.jpg differ diff --git a/images/ingame/552.jpg b/images/ingame/552.jpg new file mode 100644 index 0000000..a076fb0 Binary files /dev/null and b/images/ingame/552.jpg differ diff --git a/images/ingame/572.jpg b/images/ingame/572.jpg new file mode 100644 index 0000000..2ae589b Binary files /dev/null and b/images/ingame/572.jpg differ diff --git a/images/ingame/583.jpg b/images/ingame/583.jpg new file mode 100644 index 0000000..5137977 Binary files /dev/null and b/images/ingame/583.jpg differ diff --git a/images/ingame/654.jpg b/images/ingame/654.jpg new file mode 100644 index 0000000..38bbfba Binary files /dev/null and b/images/ingame/654.jpg differ diff --git a/images/ingame/696.jpg b/images/ingame/696.jpg new file mode 100644 index 0000000..c94d636 Binary files /dev/null and b/images/ingame/696.jpg differ diff --git a/images/ingame/70.jpg b/images/ingame/70.jpg new file mode 100644 index 0000000..4de06f4 Binary files /dev/null and b/images/ingame/70.jpg differ diff --git a/images/ingame/705.jpg b/images/ingame/705.jpg new file mode 100644 index 0000000..cdd2087 Binary files /dev/null and b/images/ingame/705.jpg differ diff --git a/images/ingame/83.jpg b/images/ingame/83.jpg new file mode 100644 index 0000000..0b0500c Binary files /dev/null and b/images/ingame/83.jpg differ diff --git a/images/ingame/858.jpg b/images/ingame/858.jpg new file mode 100644 index 0000000..edfeef1 Binary files /dev/null and b/images/ingame/858.jpg differ diff --git a/images/ingame/86.jpg b/images/ingame/86.jpg new file mode 100644 index 0000000..581dcbf Binary files /dev/null and b/images/ingame/86.jpg differ diff --git a/images/ingame/862.jpg b/images/ingame/862.jpg new file mode 100644 index 0000000..7c86960 Binary files /dev/null and b/images/ingame/862.jpg differ diff --git a/images/ingame/911.jpg b/images/ingame/911.jpg new file mode 100644 index 0000000..63ae397 Binary files /dev/null and b/images/ingame/911.jpg differ diff --git a/images/271.webp b/images/render/271.webp similarity index 100% rename from images/271.webp rename to images/render/271.webp diff --git a/images/296.webp b/images/render/296.webp similarity index 100% rename from images/296.webp rename to images/render/296.webp diff --git a/images/328.webp b/images/render/328.webp similarity index 100% rename from images/328.webp rename to images/render/328.webp diff --git a/images/460.webp b/images/render/460.webp similarity index 100% rename from images/460.webp rename to images/render/460.webp diff --git a/images/487.webp b/images/render/487.webp similarity index 100% rename from images/487.webp rename to images/render/487.webp diff --git a/images/552.webp b/images/render/552.webp similarity index 100% rename from images/552.webp rename to images/render/552.webp diff --git a/images/572.webp b/images/render/572.webp similarity index 100% rename from images/572.webp rename to images/render/572.webp diff --git a/images/583.webp b/images/render/583.webp similarity index 100% rename from images/583.webp rename to images/render/583.webp diff --git a/images/654.webp b/images/render/654.webp similarity index 100% rename from images/654.webp rename to images/render/654.webp diff --git a/images/696.webp b/images/render/696.webp similarity index 100% rename from images/696.webp rename to images/render/696.webp diff --git a/images/70.webp b/images/render/70.webp similarity index 100% rename from images/70.webp rename to images/render/70.webp diff --git a/images/705.webp b/images/render/705.webp similarity index 100% rename from images/705.webp rename to images/render/705.webp diff --git a/images/83.webp b/images/render/83.webp similarity index 100% rename from images/83.webp rename to images/render/83.webp diff --git a/images/858.webp b/images/render/858.webp similarity index 100% rename from images/858.webp rename to images/render/858.webp diff --git a/images/86.webp b/images/render/86.webp similarity index 100% rename from images/86.webp rename to images/render/86.webp diff --git a/images/862.webp b/images/render/862.webp similarity index 100% rename from images/862.webp rename to images/render/862.webp diff --git a/images/911.webp b/images/render/911.webp similarity index 100% rename from images/911.webp rename to images/render/911.webp diff --git a/pyproject.toml b/pyproject.toml index adedbd6..4e96982 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,15 @@ [project] name = "icra" version = "0.1.0" -description = "Interactive Color Range Analyzer (ICRA) for Tkinter" +description = "Interactive Color Range Analyzer (ICRA) desktop app (PySide6)" readme = "README.md" authors = [{ name = "ICRA contributors" }] license = "MIT" requires-python = ">=3.10" dependencies = [ + "numpy>=1.26", "pillow>=10.0.0", + "PySide6>=6.7", ] [project.scripts]