diff --git a/README.md b/README.md
index 0cd1c9f..99cf33f 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,51 @@
- 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]