Compare commits

...

8 Commits
master ... dev

Author SHA1 Message Date
lukas daf226a80f del images 2026-03-13 18:42:42 +01:00
lukas 7f219885bf Feature: Advanced Navigation & Composite Scoring
- Added direct pattern jump input field (Ctrl+J behavior without hotkey)

- Implemented Composite Score: 35% match, 55% excl match, 10% brightness

- Added 'Prefer darkness' toggle to invert brightness score

- Added checkmarks to 'Prefer darkness' and 'Toggle free-draw' menus

- Added dynamic Darkness/Brightness text parsing to UI and CSV export
2026-03-11 14:48:24 +01:00
lukas acfcf99d15 Feature: Settings Import/Export and UI Polish
- Implemented JSON-based settings import/export with smart scaling

- Locked image overlay color to Red (#ff0000) permanently

- Decoupled analyzer target color from display mask color

- Added menu separators for better organization

- Fixed overlay synchronization and scaling bugs during import
2026-03-10 18:32:14 +01:00
lukas c278ddf458 Optimize: export speed, fix scaling bug, and improve workflow
- Parallelized folder export for massive speedup

- Implemented exclusion mask caching

- Fixed statistics discrepancy by scaling exclusion coordinates

- Hardcoded CSV format to semicolon separator and comma decimal

- Defaulted file/folder dialogs to images/ directory

- Added unit test for coordinate scaling
2026-03-10 17:59:49 +01:00
lukas 49b436a2f6 Update .gitignore 2026-03-10 17:33:42 +01:00
lukas ac79d0e5dc Feature: add pattern scraper and optimize batch export
- Added Pull Pattern Images tool with parallel background downloading

- Optimized Export Folder Stats to run headlessly (massive speedup)

- Dynamically name exported CSVs based on source folder

- Fixed German CSV localization and UTF-8 BOM

- Updated README and walkthrough
2026-03-10 17:28:15 +01:00
lukas 635b65b7e1 Docs: rewrite README.md for complete PySide6 transition 2026-03-10 16:56:01 +01:00
lukas 551f5a6b8f Replace toolbar with categorized menu system, standardize 'color', and improve CSV export
- Migrated toolbar to QMenuBar to fix UI crowding

- Categorized actions into File, Edit, Tools, and View

- Added dynamic theming to QMenuBar and QMenu

- Localized Export CSV delimiter and decimals for German Excel

- Padded exported CSV values for clean plain-text alignment

- Globally standardized the spelling of 'color'

- Removed duplicate code and old Tkinter codebase
2026-03-10 16:54:23 +01:00
56 changed files with 1043 additions and 2122 deletions

3
.gitignore vendored
View File

@ -156,3 +156,6 @@ uv/
*.merge_file_*
.git/modules/
.git/worktrees/
# ICRA specific
images/

View File

@ -1,24 +1,29 @@
<div style="display:flex; gap:16px; align-items:center;">
<img src="app/assets/logo.png" alt="ICRA" width="140"/>
<div>
<strong>Interactive Color Range Analyzer</strong> is being reimagined with a <em>PySide6</em> user interface.<br/>
This branch focuses on building a native desktop shell with modern window controls before porting the colour-analysis features.
<strong>Interactive Color Range Analyzer (ICRA)</strong><br/>
A fully-featured, PySide6-powered desktop application for precise color matching, image analysis, and statistics generation.
</div>
</div>
## 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.
## Features
- **High-Performance Image Processing:** Native, vectorized NumPy operations for lightning-fast HSV conversion and color matching.
- **Batch Folder Processing:** Load a folder of images and instantly export an aggregated `icra_stats.csv` with localized formatting (dot or comma decimals depending on your language).
- **Eyedropper Tool:** Quickly pick target matching colors directly from the image canvas.
- **Advanced Selection:** Support for exclusion zones (rectangles and free-draw polygons) overlaid on the image, all dynamically rendered via `QGraphicsView`.
- **Modern UI & UX:**
- Drag-and-drop support for files and folders.
- Custom dark and light themes with native-feeling borderless titlebars and a categorized menu bar.
- Window size and position persistence between launches.
- Keyboard shortcuts for rapid workflow.
- **Configurable:** Uses `config.toml` to drive everything from overlay matching colors to default sliders and application language (`en`/`de`).
## Requirements
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) for dependency management
- Windows 10/11 recommended (PySide6 build included; Linux/macOS should work but are untested in this branch)
- Works across Windows, macOS, and Linux (requires PySide6 and numpy)
## Setup with uv (PowerShell example)
## Setup with uv
```bash
git clone https://git.lukasmahler.de/lm/ICRA.git
cd ICRA
@ -28,24 +33,25 @@ uv pip install .
uv run icra
```
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.
## Running the Test Suite
The core image processing logic and UI data models are rigorously tested using `pytest`.
```bash
uv run pytest tests/ -v
```
## 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
## Project Layout
```
app/
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)
lang/ # Localization strings (TOML format)
logic/ # Configuration loading and application constants
qt/ # PySide6 implementation (main_window, custom UI widgets, vectorized image processing)
tests/ # Pytest unit tests
config.toml # Configurable overlay colors, slider defaults
main.py # Entry point -> PySide6 launcher
```
## Development notes
## Development Notes
- The legacy `Tkinter` codebase has been completely removed in favor of `PySide6`.
- 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.
- Contributions welcome!

View File

@ -2,14 +2,8 @@
from __future__ import annotations
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"]
__all__ = ["create_qt_app", "run_qt_app", "start_app"]

View File

@ -1,176 +0,0 @@
"""Application composition root."""
from __future__ import annotations
import ctypes
import platform
import tkinter as tk
from importlib import resources
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
from .i18n import I18nMixin
from .logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE, ImageProcessingMixin, ResetMixin
class ICRAApp(
I18nMixin,
ThemeMixin,
UIBuilderMixin,
ImageProcessingMixin,
ExclusionMixin,
ColorPickerMixin,
ResetMixin,
):
"""Tkinter based application for isolating configurable colour ranges."""
def __init__(self, root: tk.Tk):
self.root = root
self.init_i18n(LANGUAGE)
self.root.title(self._t("app.title"))
self._setup_window()
# Theme and styling
self.init_theme()
# Tkinter state variables
self.DEFAULTS = DEFAULTS.copy()
self.hue_min = tk.DoubleVar(value=self.DEFAULTS["hue_min"])
self.hue_max = tk.DoubleVar(value=self.DEFAULTS["hue_max"])
self.sat_min = tk.DoubleVar(value=self.DEFAULTS["sat_min"])
self.val_min = tk.DoubleVar(value=self.DEFAULTS["val_min"])
self.val_max = tk.DoubleVar(value=self.DEFAULTS["val_max"])
self.alpha = tk.IntVar(value=self.DEFAULTS["alpha"])
self.ref_hue = None
# Debounce for heavy preview updates
self.update_delay_ms = 400
self._update_job = None
# Exclusion rectangles (preview coordinates)
self.exclude_shapes: list[dict[str, object]] = []
self._rubber_start = None
self._rubber_id = None
self._stroke_preview_id = None
self.exclude_mode = "rect"
self.reset_exclusions_on_switch = RESET_EXCLUSIONS_ON_IMAGE_CHANGE
self._exclude_mask = None
self._exclude_mask_dirty = True
self._exclude_mask_px = None
self._exclude_canvas_ids: list[int] = []
self._current_stroke: list[tuple[int, int]] | None = None
self.free_draw_width = 14
self.pick_mode = False
# Image references
self.image_path = None
self.orig_img = None
self.preview_img = None
self.preview_tk = None
self.overlay_tk = None
self.image_paths = []
self.current_image_index = -1
# Build UI
self.setup_ui()
self._init_copy_menu()
self.bring_to_front()
def _setup_window(self) -> None:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
default_width = int(screen_width * 0.8)
default_height = int(screen_height * 0.8)
default_x = (screen_width - default_width) // 2
default_y = (screen_height - default_height) // 4
self._window_geometry = f"{default_width}x{default_height}+{default_x}+{default_y}"
self._is_maximized = True
self._use_overrideredirect = True
self.root.geometry(f"{screen_width}x{screen_height}+0+0")
self.root.configure(bg="#f2f2f7")
try:
self.root.overrideredirect(True)
except Exception:
try:
self.root.attributes("-type", "splash")
except Exception:
pass
self._window_icon_ref = None
self._apply_window_icon()
self._init_window_chrome()
def _ensure_taskbar_entry(self) -> None:
"""Force the borderless window to show up in the Windows taskbar."""
try:
if platform.system() != "Windows":
return
hwnd = self.root.winfo_id()
if not hwnd:
self.root.after(50, self._ensure_taskbar_entry)
return
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
WS_EX_APPWINDOW = 0x00040000
SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
SWP_NOZORDER = 0x0004
SWP_FRAMECHANGED = 0x0020
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
shell32 = ctypes.windll.shell32 # type: ignore[attr-defined]
style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
new_style = (style & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
if new_style != style:
user32.SetWindowLongW(hwnd, GWL_EXSTYLE, new_style)
user32.SetWindowPos(
hwnd,
0,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED,
)
app_id = ctypes.c_wchar_p("ICRA.App")
shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
except Exception:
pass
def _apply_window_icon(self) -> None:
try:
icon_resource = resources.files("app.assets").joinpath("logo.png")
with resources.as_file(icon_resource) as icon_path:
icon = tk.PhotoImage(file=str(icon_path))
self.root.iconphoto(False, icon)
self._window_icon_ref = icon
except Exception:
self._window_icon_ref = None
def _init_window_chrome(self) -> None:
"""Configure a borderless window while retaining a taskbar entry."""
try:
self.root.bind("<Map>", self._restore_borderless)
self.root.after(0, self._restore_borderless)
self.root.after(0, self._ensure_taskbar_entry)
except Exception:
pass
def _restore_borderless(self, _event=None) -> None:
try:
if self._use_overrideredirect:
self.root.overrideredirect(True)
self._ensure_taskbar_entry()
except Exception:
pass
def start_app() -> None:
"""Entry point used by the CLI script."""
root = tk.Tk()
app = ICRAApp(root)
root.mainloop()
__all__ = ["ICRAApp", "start_app"]

View File

@ -1,13 +0,0 @@
"""GUI-related mixins and helpers for the application."""
from .color_picker import ColorPickerMixin
from .exclusions import ExclusionMixin
from .theme import ThemeMixin
from .ui import UIBuilderMixin
__all__ = [
"ColorPickerMixin",
"ExclusionMixin",
"ThemeMixin",
"UIBuilderMixin",
]

View File

@ -1,158 +0,0 @@
"""Color selection utilities."""
from __future__ import annotations
import colorsys
from tkinter import colorchooser, messagebox
class ColorPickerMixin:
"""Handles colour selection from dialogs and mouse clicks."""
ref_hue: float | None
hue_span: float = 45.0 # degrees around the picked hue
selected_colour: tuple[int, int, int] | None = None
def choose_color(self):
title = self._t("dialog.choose_colour_title") if hasattr(self, "_t") else "Choose colour"
rgb, hex_colour = colorchooser.askcolor(title=title)
if rgb is None:
return
r, g, b = (int(round(channel)) for channel in rgb)
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
label = hex_colour or f"RGB({r}, {g}, {b})"
message = self._t(
"status.color_selected",
label=label,
hue=hue_deg,
saturation=sat_pct,
value=val_pct,
)
self.status.config(text=message)
self._update_selected_colour(r, g, b)
def apply_sample_colour(self, hex_colour: str, name: str | None = None) -> None:
"""Apply a predefined colour preset."""
rgb = self._parse_hex_colour(hex_colour)
if rgb is None:
return
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(*rgb)
if self.pick_mode:
self.pick_mode = False
label = name or hex_colour.upper()
message = self._t(
"status.sample_colour",
label=label,
hex_code=hex_colour,
hue=hue_deg,
saturation=sat_pct,
value=val_pct,
)
self.status.config(text=message)
self._update_selected_colour(*rgb)
def enable_pick_mode(self):
if self.preview_img is None:
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.load_image_first"),
)
return
self.pick_mode = True
self.status.config(text=self._t("status.pick_mode_ready"))
def disable_pick_mode(self, event=None):
if self.pick_mode:
self.pick_mode = False
self.status.config(text=self._t("status.pick_mode_ended"))
def on_canvas_click(self, event):
if not self.pick_mode or self.preview_img is None:
return
x = int(event.x)
y = int(event.y)
if x < 0 or y < 0 or x >= self.preview_img.width or y >= self.preview_img.height:
return
r, g, b, a = self.preview_img.getpixel((x, y))
if a == 0:
return
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
self.disable_pick_mode()
self.status.config(
text=self._t(
"status.pick_mode_from_image",
hue=hue_deg,
saturation=sat_pct,
value=val_pct,
)
)
self._update_selected_colour(r, g, b)
def _apply_rgb_selection(self, r: int, g: int, b: int) -> tuple[float, float, float]:
"""Update slider ranges based on an RGB colour and return HSV summary."""
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
hue_deg = (h * 360.0) % 360.0
self.ref_hue = hue_deg
self._set_slider_targets(hue_deg, s, v)
self.update_preview()
return hue_deg, s * 100.0, v * 100.0
def _update_selected_colour(self, r: int, g: int, b: int) -> None:
self.selected_colour = (r, g, b)
hex_colour = f"#{r:02x}{g:02x}{b:02x}"
if hasattr(self, "current_colour_sw"):
try:
self.current_colour_sw.configure(background=hex_colour)
except Exception:
pass
if hasattr(self, "current_colour_label"):
try:
self.current_colour_label.configure(text=f"({hex_colour})")
except Exception:
pass
def _set_slider_targets(self, hue_deg: float, saturation: float, value: float) -> None:
span = getattr(self, "hue_span", 45.0)
self.hue_min.set((hue_deg - span) % 360)
self.hue_max.set((hue_deg + span) % 360)
sat_pct = saturation * 100.0
sat_margin = 35.0
sat_min = max(0.0, min(100.0, sat_pct - sat_margin))
if saturation <= 0.05:
sat_min = 0.0
self.sat_min.set(sat_min)
v_pct = value * 100.0
val_margin = 35.0
val_min = max(0.0, v_pct - val_margin)
val_max = min(100.0, v_pct + val_margin)
if value <= 0.15:
val_max = min(45.0, max(val_max, 25.0))
if value >= 0.85:
val_min = max(55.0, min(val_min, 80.0))
if val_max <= val_min:
val_max = min(100.0, val_min + 10.0)
self.val_min.set(val_min)
self.val_max.set(val_max)
@staticmethod
def _parse_hex_colour(hex_colour: str | None) -> tuple[int, int, int] | None:
if not hex_colour:
return None
value = hex_colour.strip().lstrip("#")
if len(value) == 3:
value = "".join(ch * 2 for ch in value)
if len(value) != 6:
return None
try:
r = int(value[0:2], 16)
g = int(value[2:4], 16)
b = int(value[4:6], 16)
except ValueError:
return None
return r, g, b
__all__ = ["ColorPickerMixin"]

View File

@ -1,207 +0,0 @@
"""Mouse handlers for exclusion shapes."""
from __future__ import annotations
class ExclusionMixin:
"""Manage exclusion shapes (rectangles and freehand strokes) on the preview canvas."""
def _exclude_start(self, event):
if self.preview_img is None:
return
mode = getattr(self, "exclude_mode", "rect")
x = max(0, min(self.preview_img.width - 1, int(event.x)))
y = max(0, min(self.preview_img.height - 1, int(event.y)))
if mode == "free":
self._current_stroke = [(x, y)]
preview_id = getattr(self, "_stroke_preview_id", None)
if preview_id:
try:
self.canvas_orig.delete(preview_id)
except Exception:
pass
accent = self._exclusion_preview_colour()
self._stroke_preview_id = self.canvas_orig.create_line(
x,
y,
x,
y,
fill=accent,
width=2,
smooth=True,
capstyle="round",
joinstyle="round",
)
self._rubber_start = None
return
self._rubber_start = (x, y)
if self._rubber_id:
try:
self.canvas_orig.delete(self._rubber_id)
except Exception:
pass
accent = self._exclusion_preview_colour()
self._rubber_id = self.canvas_orig.create_rectangle(x, y, x, y, outline=accent, width=2)
def _exclude_drag(self, event):
mode = getattr(self, "exclude_mode", "rect")
if mode == "free":
stroke = getattr(self, "_current_stroke", None)
if not stroke:
return
x = max(0, min(self.preview_img.width - 1, int(event.x)))
y = max(0, min(self.preview_img.height - 1, int(event.y)))
if stroke[-1] != (x, y):
stroke.append((x, y))
preview_id = getattr(self, "_stroke_preview_id", None)
if preview_id:
coords = [coord for point in stroke for coord in point]
self.canvas_orig.coords(preview_id, *coords)
return
if not self._rubber_start:
return
x0, y0 = self._rubber_start
x1 = max(0, min(self.preview_img.width - 1, int(event.x)))
y1 = max(0, min(self.preview_img.height - 1, int(event.y)))
self.canvas_orig.coords(self._rubber_id, x0, y0, x1, y1)
def _exclude_end(self, event):
mode = getattr(self, "exclude_mode", "rect")
if mode == "free":
stroke = getattr(self, "_current_stroke", None)
if stroke and len(stroke) > 2:
polygon = self._close_polygon(self._compress_stroke(stroke))
if len(polygon) >= 3:
shape = {
"kind": "polygon",
"points": polygon,
}
self.exclude_shapes.append(shape)
stamper = getattr(self, "_stamp_shape_on_mask", None)
if callable(stamper):
stamper(shape)
else:
self._exclude_mask_dirty = True
self._current_stroke = None
preview_id = getattr(self, "_stroke_preview_id", None)
if preview_id:
try:
self.canvas_orig.delete(preview_id)
except Exception:
pass
self._stroke_preview_id = None
self.update_preview()
return
if not self._rubber_start:
return
x0, y0 = self._rubber_start
x1 = max(0, min(self.preview_img.width - 1, int(event.x)))
y1 = max(0, min(self.preview_img.height - 1, int(event.y)))
rx0, rx1 = sorted((x0, x1))
ry0, ry1 = sorted((y0, y1))
if (rx1 - rx0) > 0 and (ry1 - ry0) > 0:
shape = {"kind": "rect", "coords": (rx0, ry0, rx1, ry1)}
self.exclude_shapes.append(shape)
stamper = getattr(self, "_stamp_shape_on_mask", None)
if callable(stamper):
stamper(shape)
else:
self._exclude_mask_dirty = True
if self._rubber_id:
try:
self.canvas_orig.delete(self._rubber_id)
except Exception:
pass
self._rubber_start = None
self._rubber_id = None
self.update_preview()
def clear_excludes(self):
self.exclude_shapes = []
self._rubber_start = None
self._current_stroke = None
if self._rubber_id:
try:
self.canvas_orig.delete(self._rubber_id)
except Exception:
pass
self._rubber_id = None
if self._stroke_preview_id:
try:
self.canvas_orig.delete(self._stroke_preview_id)
except Exception:
pass
self._stroke_preview_id = None
for item in getattr(self, "_exclude_canvas_ids", []):
try:
self.canvas_orig.delete(item)
except Exception:
pass
self._exclude_canvas_ids = []
self._exclude_mask = None
self._exclude_mask_px = None
self._exclude_mask_dirty = True
self.update_preview()
def undo_exclude(self):
if not getattr(self, "exclude_shapes", None):
return
self.exclude_shapes.pop()
self._exclude_mask_dirty = True
self.update_preview()
def toggle_exclusion_mode(self):
current = getattr(self, "exclude_mode", "rect")
next_mode = "free" if current == "rect" else "rect"
self.exclude_mode = next_mode
self._current_stroke = None
if next_mode == "free":
if self._rubber_id:
try:
self.canvas_orig.delete(self._rubber_id)
except Exception:
pass
self._rubber_id = None
self._rubber_start = None
else:
if self._stroke_preview_id:
try:
self.canvas_orig.delete(self._stroke_preview_id)
except Exception:
pass
self._stroke_preview_id = None
self._rubber_id = None
message_key = "status.free_draw_enabled" if next_mode == "free" else "status.free_draw_disabled"
if hasattr(self, "status"):
try:
self.status.config(text=self._t(message_key))
except Exception:
pass
@staticmethod
def _compress_stroke(points: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Reduce duplicate points without altering the drawn path too much."""
if not points:
return []
compressed: list[tuple[int, int]] = [points[0]]
for point in points[1:]:
if point != compressed[-1]:
compressed.append(point)
return compressed
def _exclusion_preview_colour(self) -> str:
is_dark = getattr(self, "theme", "light") == "dark"
return "#ffd700" if is_dark else "#c56217"
@staticmethod
def _close_polygon(points: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Ensure the polygon is closed by repeating the start if necessary."""
if len(points) < 3:
return points
closed = list(points)
if closed[0] != closed[-1]:
closed.append(closed[0])
return closed
__all__ = ["ExclusionMixin"]

View File

@ -1,106 +0,0 @@
"""Theme and window helpers."""
from __future__ import annotations
import platform
from tkinter import ttk
try:
import winreg
except Exception: # pragma: no cover - platform-specific
winreg = None # type: ignore
class ThemeMixin:
"""Provides theme handling utilities for the main application."""
theme: str
style: ttk.Style
scale_style: str
def init_theme(self) -> None:
"""Initialise ttk style handling and apply the detected theme."""
self.style = ttk.Style()
self.style.theme_use("clam")
self.theme = "light"
self.apply_theme(self.detect_system_theme())
def apply_theme(self, mode: str) -> None:
"""Apply light/dark theme including widget palette."""
mode = (mode or "light").lower()
self.theme = "dark" if mode == "dark" else "light"
self.scale_style = "Horizontal.TScale"
if self.theme == "dark":
bg, fg = "#0f0f10", "#f1f1f1"
status_fg = "#f5f5f5"
highlight_fg = "#f2c744"
else:
bg, fg = "#ffffff", "#202020"
status_fg = "#1c1c1c"
highlight_fg = "#c56217"
self.root.configure(bg=bg) # type: ignore[attr-defined]
s = self.style
s.configure("TFrame", background=bg)
s.configure("TLabel", background=bg, foreground=fg, font=("Segoe UI", 10))
s.configure(
"TButton", padding=8, relief="flat", background="#e0e0e0", foreground=fg, font=("Segoe UI", 10)
)
s.map("TButton", background=[("active", "#d0d0d0")])
button_refresher = getattr(self, "_refresh_toolbar_buttons_theme", None)
if callable(button_refresher):
button_refresher()
nav_refresher = getattr(self, "_refresh_navigation_buttons_theme", None)
if callable(nav_refresher):
nav_refresher()
status_refresher = getattr(self, "_refresh_status_palette", None)
if callable(status_refresher) and hasattr(self, "status"):
status_refresher(status_fg)
accent_refresher = getattr(self, "_refresh_accent_labels", None)
if callable(accent_refresher) and hasattr(self, "filename_label"):
accent_refresher(highlight_fg)
canvas_refresher = getattr(self, "_refresh_canvas_backgrounds", None)
if callable(canvas_refresher):
canvas_refresher()
def detect_system_theme(self) -> str:
"""Best-effort detection of the OS theme preference."""
try:
if platform.system() == "Windows" and winreg is not None:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
)
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
return "light" if int(value) == 1 else "dark"
except Exception:
pass
return "light"
def bring_to_front(self) -> None:
"""Try to focus the window and raise it to the foreground."""
try:
self.root.lift()
self.root.focus_force()
self.root.attributes("-topmost", True)
self.root.update()
self.root.attributes("-topmost", False)
except Exception:
pass
def toggle_theme(self) -> None:
"""Toggle between light and dark themes."""
next_mode = "dark" if self.theme == "light" else "light"
self.apply_theme(next_mode)
self.update_preview() # type: ignore[attr-defined]
__all__ = ["ThemeMixin"]

View File

@ -1,731 +0,0 @@
"""UI helpers and reusable Tk callbacks."""
from __future__ import annotations
import colorsys
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk
class UIBuilderMixin:
"""Constructs the Tkinter UI and common widgets."""
def setup_ui(self) -> None:
self._create_titlebar()
toolbar = ttk.Frame(self.root)
toolbar.pack(fill=tk.X, padx=12, pady=(4, 2))
buttons = [
("🖼", self._t("toolbar.open_image"), self.load_image),
("📂", self._t("toolbar.open_folder"), self.load_folder),
("🎨", self._t("toolbar.choose_color"), self.choose_color),
("🖱", self._t("toolbar.pick_from_image"), self.enable_pick_mode),
("💾", self._t("toolbar.save_overlay"), self.save_overlay),
("", self._t("toolbar.toggle_free_draw"), self.toggle_exclusion_mode),
("🧹", self._t("toolbar.clear_excludes"), self.clear_excludes),
("", self._t("toolbar.undo_exclude"), self.undo_exclude),
("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
]
self._toolbar_buttons: list[dict[str, object]] = []
self._nav_buttons: list[tk.Button] = []
buttons_frame = ttk.Frame(toolbar)
buttons_frame.pack(side=tk.LEFT)
for icon, label, command in buttons:
self._add_toolbar_button(buttons_frame, icon, label, command)
status_container = ttk.Frame(toolbar)
status_container.pack(side=tk.RIGHT, expand=True, fill=tk.X)
self.status = ttk.Label(
status_container,
text=self._t("status.no_file"),
anchor="e",
foreground="#efefef",
)
self.status.pack(fill=tk.X)
self._attach_copy_menu(self.status)
self.status_default_text = self.status.cget("text")
self._status_palette = {"fg": self.status.cget("foreground")}
palette_frame = ttk.Frame(self.root)
palette_frame.pack(fill=tk.X, padx=12, pady=(6, 8))
default_colour = self._default_colour_hex()
current_frame = ttk.Frame(palette_frame)
current_frame.pack(side=tk.LEFT, padx=(0, 16))
ttk.Label(current_frame, text=self._t("palette.current")).pack(side=tk.LEFT, padx=(0, 6))
self.current_colour_sw = tk.Canvas(
current_frame,
width=24,
height=24,
highlightthickness=0,
background=default_colour,
bd=0,
)
self.current_colour_sw.pack(side=tk.LEFT, pady=2)
self.current_colour_label = ttk.Label(current_frame, text=f"({default_colour})")
self.current_colour_label.pack(side=tk.LEFT, padx=(6, 0))
ttk.Label(palette_frame, text=self._t("palette.more")).pack(side=tk.LEFT, padx=(0, 8))
swatch_container = ttk.Frame(palette_frame)
swatch_container.pack(side=tk.LEFT)
for name, hex_code in self._preset_colours():
self._add_palette_swatch(swatch_container, name, hex_code)
sliders_frame = ttk.Frame(self.root)
sliders_frame.pack(fill=tk.X, padx=12, pady=4)
sliders = [
(self._t("sliders.hue_min"), self.hue_min, 0, 360),
(self._t("sliders.hue_max"), self.hue_max, 0, 360),
(self._t("sliders.sat_min"), self.sat_min, 0, 100),
(self._t("sliders.val_min"), self.val_min, 0, 100),
(self._t("sliders.val_max"), self.val_max, 0, 100),
(self._t("sliders.alpha"), self.alpha, 0, 255),
]
for index, (label, variable, minimum, maximum) in enumerate(sliders):
self.add_slider_with_value(sliders_frame, label, variable, minimum, maximum, column=index)
sliders_frame.grid_columnconfigure(index, weight=1)
main = ttk.Frame(self.root)
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
left_column = ttk.Frame(main)
left_column.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 6))
left_column.grid_columnconfigure(1, weight=1)
left_column.grid_rowconfigure(0, weight=1)
self._create_navigation_button(left_column, "", self.show_previous_image, column=0)
self.canvas_orig = tk.Canvas(
left_column,
bg=self._canvas_background_colour(),
highlightthickness=0,
relief="flat",
)
self.canvas_orig.grid(row=0, column=1, sticky="nsew")
self.canvas_orig.bind("<Button-1>", self.on_canvas_click)
self.canvas_orig.bind("<ButtonPress-3>", self._exclude_start)
self.canvas_orig.bind("<B3-Motion>", self._exclude_drag)
self.canvas_orig.bind("<ButtonRelease-3>", self._exclude_end)
right_column = ttk.Frame(main)
right_column.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(6, 0))
right_column.grid_columnconfigure(0, weight=1)
right_column.grid_rowconfigure(0, weight=1)
self.canvas_overlay = tk.Canvas(
right_column,
bg=self._canvas_background_colour(),
highlightthickness=0,
relief="flat",
)
self.canvas_overlay.grid(row=0, column=0, sticky="nsew")
self._create_navigation_button(right_column, "", self.show_next_image, column=1)
info_frame = ttk.Frame(self.root)
info_frame.pack(fill=tk.X, padx=12, pady=(0, 12))
self.filename_label = ttk.Label(
info_frame,
text="",
font=("Segoe UI", 10, "bold"),
anchor="center",
justify="center",
)
self.filename_label.pack(anchor="center")
self._attach_copy_menu(self.filename_label)
self.ratio_label = ttk.Label(
info_frame,
text=self._t("stats.placeholder"),
font=("Segoe UI", 10, "bold"),
anchor="center",
justify="center",
)
self.ratio_label.pack(anchor="center", pady=(4, 0))
self._attach_copy_menu(self.ratio_label)
self.root.bind("<Escape>", self.disable_pick_mode)
self.root.bind("<ButtonPress-1>", self._maybe_focus_window)
def add_slider_with_value(self, parent, text, var, minimum, maximum, column=0):
cell = ttk.Frame(parent)
cell.grid(row=0, column=column, sticky="we", padx=6)
header = ttk.Frame(cell)
header.pack(fill="x")
name_lbl = ttk.Label(header, text=text)
name_lbl.pack(side="left")
self._attach_copy_menu(name_lbl)
val_lbl = ttk.Label(header, text=f"{float(var.get()):.0f}")
val_lbl.pack(side="right")
self._attach_copy_menu(val_lbl)
style_name = getattr(self, "scale_style", "Horizontal.TScale")
ttk.Scale(
cell,
from_=minimum,
to=maximum,
orient="horizontal",
variable=var,
style=style_name,
command=self.on_slider_change,
).pack(fill="x", pady=(2, 8))
def on_var_change(*_):
val_lbl.config(text=f"{float(var.get()):.0f}")
try:
var.trace_add("write", on_var_change)
except Exception:
var.trace("w", lambda *_: on_var_change()) # type: ignore[attr-defined]
def on_slider_change(self, *_):
if self._update_job is not None:
try:
self.root.after_cancel(self._update_job)
except Exception:
pass
self._update_job = self.root.after(self.update_delay_ms, self.update_preview)
def _preset_colours(self):
return [
(self._t("palette.swatch.red"), "#ff3b30"),
(self._t("palette.swatch.orange"), "#ff9500"),
(self._t("palette.swatch.yellow"), "#ffd60a"),
(self._t("palette.swatch.green"), "#34c759"),
(self._t("palette.swatch.teal"), "#5ac8fa"),
(self._t("palette.swatch.blue"), "#0a84ff"),
(self._t("palette.swatch.violet"), "#af52de"),
(self._t("palette.swatch.magenta"), "#ff2d55"),
(self._t("palette.swatch.white"), "#ffffff"),
(self._t("palette.swatch.grey"), "#8e8e93"),
(self._t("palette.swatch.black"), "#000000"),
]
def _add_palette_swatch(self, parent, name: str, hex_code: str) -> None:
swatch = tk.Canvas(
parent,
width=24,
height=24,
highlightthickness=0,
background=hex_code,
bd=0,
relief="flat",
takefocus=1,
cursor="hand2",
)
swatch.pack(side=tk.LEFT, padx=4, pady=2)
def trigger(_event=None, colour=hex_code, label=name):
self.apply_sample_colour(colour, label)
swatch.bind("<Button-1>", trigger)
swatch.bind("<space>", trigger)
swatch.bind("<Return>", trigger)
swatch.bind("<Enter>", lambda _e: swatch.configure(cursor="hand2"))
swatch.bind("<Leave>", lambda _e: swatch.configure(cursor="arrow"))
def _add_toolbar_button(self, parent, icon: str, label: str, command) -> None:
font = tkfont.Font(root=self.root, family="Segoe UI", size=9)
padding_x = 12
gap = font.measure(" ")
icon_width = font.measure(icon) or font.measure(" ")
label_width = font.measure(label)
width = padding_x * 2 + icon_width + gap + label_width
height = 28
radius = 9
bg = self.root.cget("bg") if hasattr(self.root, "cget") else "#f2f2f7"
canvas = tk.Canvas(
parent,
width=width,
height=height,
bd=0,
highlightthickness=0,
bg=bg,
relief="flat",
cursor="hand2",
takefocus=1,
)
canvas.pack(side=tk.LEFT, padx=4, pady=1)
palette = self._toolbar_palette()
rect_id = self._create_round_rect(
canvas,
1,
1,
width - 1,
height - 1,
radius,
fill=palette["normal"],
outline=palette["outline"],
width=1,
)
icon_id = canvas.create_text(
padding_x,
height / 2,
text=icon,
font=font,
fill=palette["text"],
anchor="w",
)
label_id = canvas.create_text(
padding_x + icon_width + gap,
height / 2,
text=label,
font=font,
fill=palette["text"],
anchor="w",
)
button_data = {
"canvas": canvas,
"rect": rect_id,
"text_ids": (icon_id, label_id),
"command": command,
"palette": palette.copy(),
"dimensions": (width, height, radius),
}
self._toolbar_buttons.append(button_data)
def set_fill(state: str) -> None:
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
canvas.itemconfigure(rect_id, fill=pal[state]) # type: ignore[index]
def execute():
command()
def on_press(_event=None):
set_fill("active")
def on_release(event=None):
if event is not None and (
event.x < 0 or event.y < 0 or event.x > width or event.y > height
):
set_fill("normal")
return
set_fill("hover")
self.root.after_idle(execute)
def on_enter(_event):
set_fill("hover")
def on_leave(_event):
set_fill("normal")
def on_focus_in(_event):
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
canvas.itemconfigure(rect_id, outline=pal["outline_focus"]) # type: ignore[index]
def on_focus_out(_event):
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
canvas.itemconfigure(rect_id, outline=pal["outline"]) # type: ignore[index]
def invoke_keyboard(_event=None):
set_fill("active")
canvas.after(120, lambda: set_fill("hover"))
self.root.after_idle(execute)
canvas.bind("<ButtonPress-1>", on_press)
canvas.bind("<ButtonRelease-1>", on_release)
canvas.bind("<Enter>", on_enter)
canvas.bind("<Leave>", on_leave)
canvas.bind("<FocusIn>", on_focus_in)
canvas.bind("<FocusOut>", on_focus_out)
canvas.bind("<space>", invoke_keyboard)
canvas.bind("<Return>", invoke_keyboard)
@staticmethod
def _create_round_rect(canvas: tk.Canvas, x1, y1, x2, y2, radius, **kwargs):
points = [
x1 + radius,
y1,
x2 - radius,
y1,
x2,
y1,
x2,
y1 + radius,
x2,
y2 - radius,
x2,
y2,
x2 - radius,
y2,
x1 + radius,
y2,
x1,
y2,
x1,
y2 - radius,
x1,
y1 + radius,
x1,
y1,
]
return canvas.create_polygon(points, smooth=True, splinesteps=24, **kwargs)
def _create_navigation_button(self, container, symbol: str, command, *, column: int) -> None:
palette = self._navigation_palette()
bg = palette["bg"]
fg = palette["fg"]
container.grid_rowconfigure(0, weight=1)
btn = tk.Button(
container,
text=symbol,
command=command,
font=("Segoe UI", 26, "bold"),
relief="flat",
borderwidth=0,
background=bg,
activebackground=bg,
highlightthickness=0,
fg=fg,
activeforeground=fg,
cursor="hand2",
width=2,
)
btn.grid(row=0, column=column, sticky="ns", padx=6)
self._nav_buttons.append(btn)
def _create_titlebar(self) -> None:
bar_bg = "#1f1f1f"
title_bar = tk.Frame(self.root, bg=bar_bg, relief="flat", height=34)
title_bar.pack(fill=tk.X, side=tk.TOP)
title_bar.pack_propagate(False)
logo = None
try:
from PIL import Image, ImageTk # type: ignore
from importlib import resources
logo_resource = resources.files("app.assets").joinpath("logo.png")
with resources.as_file(logo_resource) as logo_path:
image = Image.open(logo_path).convert("RGBA")
image.thumbnail((26, 26))
logo = ImageTk.PhotoImage(image)
except Exception:
logo = None
if logo is not None:
logo_label = tk.Label(title_bar, image=logo, bg=bar_bg)
logo_label.image = logo # keep reference
logo_label.pack(side=tk.LEFT, padx=(10, 6), pady=4)
title_label = tk.Label(
title_bar,
text=self._t("app.title"),
bg=bar_bg,
fg="#f5f5f5",
font=("Segoe UI", 11, "bold"),
anchor="w",
)
title_label.pack(side=tk.LEFT, padx=6)
btn_kwargs = {
"bg": bar_bg,
"fg": "#f5f5f5",
"activebackground": "#3a3a40",
"activeforeground": "#ffffff",
"borderwidth": 0,
"highlightthickness": 0,
"relief": "flat",
"font": ("Segoe UI", 10, "bold"),
"cursor": "hand2",
"width": 3,
}
close_btn = tk.Button(title_bar, text="", command=self._close_app, **btn_kwargs)
close_btn.pack(side=tk.RIGHT, padx=6, pady=4)
close_btn.bind("<Enter>", lambda _e: close_btn.configure(bg="#cf212f"))
close_btn.bind("<Leave>", lambda _e: close_btn.configure(bg=bar_bg))
max_btn = tk.Button(title_bar, text="", command=self._toggle_maximize_window, **btn_kwargs)
max_btn.pack(side=tk.RIGHT, padx=0, pady=4)
max_btn.bind("<Enter>", lambda _e: max_btn.configure(bg="#2c2c32"))
max_btn.bind("<Leave>", lambda _e: max_btn.configure(bg=bar_bg))
self._max_button = max_btn
min_btn = tk.Button(title_bar, text="", command=self._minimize_window, **btn_kwargs)
min_btn.pack(side=tk.RIGHT, padx=0, pady=4)
min_btn.bind("<Enter>", lambda _e: min_btn.configure(bg="#2c2c32"))
min_btn.bind("<Leave>", lambda _e: min_btn.configure(bg=bar_bg))
for widget in (title_bar, title_label):
widget.bind("<ButtonPress-1>", self._start_window_drag)
widget.bind("<B1-Motion>", self._perform_window_drag)
widget.bind("<Double-Button-1>", lambda _e: self._toggle_maximize_window())
self._update_maximize_button()
def _close_app(self) -> None:
try:
self.root.destroy()
except Exception:
pass
def _start_window_drag(self, event) -> None:
if getattr(self, "_is_maximized", False):
cursor_x, cursor_y = event.x_root, event.y_root
self._toggle_maximize_window(force_state=False)
self.root.update_idletasks()
new_x = self.root.winfo_rootx()
new_y = self.root.winfo_rooty()
self._drag_offset = (cursor_x - new_x, cursor_y - new_y)
return
self._drag_offset = (event.x_root - self.root.winfo_rootx(), event.y_root - self.root.winfo_rooty())
def _perform_window_drag(self, event) -> None:
offset = getattr(self, "_drag_offset", None)
if offset is None:
return
x = event.x_root - offset[0]
y = event.y_root - offset[1]
self.root.geometry(f"+{x}+{y}")
if not getattr(self, "_is_maximized", False):
self._remember_window_geometry()
def _remember_window_geometry(self) -> None:
try:
self._window_geometry = self.root.geometry()
except Exception:
pass
def _monitor_work_area(self) -> tuple[int, int, int, int] | None:
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
root_x = self.root.winfo_rootx()
root_y = self.root.winfo_rooty()
width = max(self.root.winfo_width(), 1)
height = max(self.root.winfo_height(), 1)
center_x = root_x + width // 2
center_y = root_y + height // 2
class MONITORINFO(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD),
]
monitor = user32.MonitorFromPoint(
wintypes.POINT(center_x, center_y), 2 # MONITOR_DEFAULTTONEAREST
)
info = MONITORINFO()
info.cbSize = ctypes.sizeof(MONITORINFO)
if not user32.GetMonitorInfoW(monitor, ctypes.byref(info)):
return None
work = info.rcWork
return work.left, work.top, work.right, work.bottom
except Exception:
return None
def _maximize_window(self) -> None:
self._remember_window_geometry()
work_area = self._monitor_work_area()
if work_area is None:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
left = 0
top = 0
width = screen_width
height = screen_height
else:
left, top, right, bottom = work_area
width = max(1, right - left)
height = max(1, bottom - top)
self.root.geometry(f"{width}x{height}+{left}+{top}")
self._is_maximized = True
self._update_maximize_button()
def _restore_window(self) -> None:
geometry = getattr(self, "_window_geometry", None)
if not geometry:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
width = int(screen_width * 0.8)
height = int(screen_height * 0.8)
x = (screen_width - width) // 2
y = (screen_height - height) // 4
geometry = f"{width}x{height}+{x}+{y}"
self.root.geometry(geometry)
self._is_maximized = False
self._update_maximize_button()
def _toggle_maximize_window(self, force_state: bool | None = None) -> None:
desired = force_state if force_state is not None else not getattr(self, "_is_maximized", False)
if desired:
self._maximize_window()
else:
self._restore_window()
def _minimize_window(self) -> None:
try:
self._remember_window_geometry()
use_or = getattr(self, "_use_overrideredirect", False)
if use_or and hasattr(self.root, "overrideredirect"):
try:
self.root.overrideredirect(False)
except Exception:
pass
self.root.iconify()
if use_or:
restorer = getattr(self, "_restore_borderless", None)
if callable(restorer):
self.root.after(120, restorer)
elif hasattr(self.root, "overrideredirect"):
self.root.after(120, lambda: self.root.overrideredirect(True)) # type: ignore[arg-type]
except Exception:
pass
def _update_maximize_button(self) -> None:
button = getattr(self, "_max_button", None)
if button is None:
return
symbol = "" if getattr(self, "_is_maximized", False) else ""
button.configure(text=symbol)
def _maybe_focus_window(self, _event) -> None:
try:
self.root.focus_set()
except Exception:
pass
def _toolbar_palette(self) -> dict[str, str]:
is_dark = getattr(self, "theme", "light") == "dark"
if is_dark:
return {
"normal": "#2f2f35",
"hover": "#3a3a40",
"active": "#1f1f25",
"outline": "#4d4d50",
"outline_focus": "#7c7c88",
"text": "#f1f1f5",
}
return {
"normal": "#ffffff",
"hover": "#ededf4",
"active": "#dcdce6",
"outline": "#d0d0d8",
"outline_focus": "#a9a9b2",
"text": "#1f1f1f",
}
def _navigation_palette(self) -> dict[str, str]:
is_dark = getattr(self, "theme", "light") == "dark"
default_bg = "#0f0f10" if is_dark else "#ededf2"
bg = self.root.cget("bg") if hasattr(self.root, "cget") else default_bg
fg = "#f5f5f5" if is_dark else "#1f1f1f"
return {"bg": bg, "fg": fg}
def _refresh_toolbar_buttons_theme(self) -> None:
if not getattr(self, "_toolbar_buttons", None):
return
bg = self.root.cget("bg") if hasattr(self.root, "cget") else "#f2f2f7"
palette = self._toolbar_palette()
for data in self._toolbar_buttons:
canvas = data["canvas"] # type: ignore[index]
rect_id = data["rect"] # type: ignore[index]
text_ids = data["text_ids"] # type: ignore[index]
data["palette"] = palette.copy()
canvas.configure(bg=bg)
canvas.itemconfigure(rect_id, fill=palette["normal"], outline=palette["outline"])
for text_id in text_ids:
canvas.itemconfigure(text_id, fill=palette["text"])
def _refresh_navigation_buttons_theme(self) -> None:
if not getattr(self, "_nav_buttons", None):
return
palette = self._navigation_palette()
for btn in self._nav_buttons:
btn.configure(
background=palette["bg"],
activebackground=palette["bg"],
fg=palette["fg"],
activeforeground=palette["fg"],
)
def _canvas_background_colour(self) -> str:
return "#0f0f10" if getattr(self, "theme", "light") == "dark" else "#ffffff"
def _refresh_canvas_backgrounds(self) -> None:
bg = self._canvas_background_colour()
for attr in ("canvas_orig", "canvas_overlay"):
canvas = getattr(self, attr, None)
if canvas is not None:
try:
canvas.configure(bg=bg)
except Exception:
pass
def _refresh_status_palette(self, fg: str) -> None:
self.status.configure(foreground=fg)
self._status_palette["fg"] = fg
def _refresh_accent_labels(self, colour: str) -> None:
try:
self.filename_label.configure(foreground=colour)
self.ratio_label.configure(foreground=colour)
except Exception:
pass
def _default_colour_hex(self) -> str:
defaults = getattr(self, "DEFAULTS", {})
hue_min = float(defaults.get("hue_min", 0.0))
hue_max = float(defaults.get("hue_max", hue_min))
if hue_min <= hue_max:
hue = (hue_min + hue_max) / 2.0
else:
span = ((hue_max + 360.0) - hue_min) / 2.0
hue = (hue_min + span) % 360.0
sat_min = float(defaults.get("sat_min", 0.0))
saturation = (sat_min + 100.0) / 2.0
val_min = float(defaults.get("val_min", 0.0))
val_max = float(defaults.get("val_max", 100.0))
value = (val_min + val_max) / 2.0
r, g, b = colorsys.hsv_to_rgb(hue / 360.0, saturation / 100.0, value / 100.0)
return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
def _init_copy_menu(self):
self._copy_target = None
self.copy_menu = tk.Menu(self.root, tearoff=0)
label = self._t("menu.copy") if hasattr(self, "_t") else "Copy"
self.copy_menu.add_command(label=label, command=self._copy_current_label)
def _attach_copy_menu(self, widget):
widget.bind("<Button-3>", lambda event, w=widget: self._show_copy_menu(event, w))
widget.bind("<Control-c>", lambda event, w=widget: self._copy_widget_text(w))
def _show_copy_menu(self, event, widget):
self._copy_target = widget
try:
self.copy_menu.tk_popup(event.x_root, event.y_root)
finally:
self.copy_menu.grab_release()
def _copy_current_label(self):
if self._copy_target is not None:
self._copy_widget_text(self._copy_target)
def _copy_widget_text(self, widget):
try:
text = widget.cget("text")
except Exception:
text = ""
if not text:
return
try:
self.root.clipboard_clear()
self.root.clipboard_append(text)
except Exception:
pass
__all__ = ["UIBuilderMixin"]

View File

@ -10,6 +10,8 @@
"toolbar.undo_exclude" = "Letzten Ausschluss entfernen"
"toolbar.reset_sliders" = "Slider zurücksetzen"
"toolbar.toggle_theme" = "Theme umschalten"
"toolbar.open_app_folder" = "Programmordner öffnen"
"toolbar.prefer_dark" = "Dunkelheit bevorzugen"
"status.no_file" = "Keine Datei geladen."
"status.defaults_restored" = "Standardwerte aktiv."
"status.free_draw_enabled" = "Freihand-Ausschluss aktiviert."
@ -17,7 +19,7 @@
"status.loaded" = "Geladen: {name} — {dimensions}{position}"
"status.filename_label" = "{name} — {dimensions}{position}"
"status.color_selected" = "Farbe gewählt: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.sample_colour" = "Beispielfarbe gewählt: {label} ({hex_code}) — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.sample_color" = "Beispielfarbe gewählt: {label} ({hex_code}) — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.pick_mode_ready" = "Pick-Modus: Klicke links ins Bild, um Farbe zu wählen (Esc beendet)"
"status.pick_mode_ended" = "Pick-Modus beendet."
"status.pick_mode_from_image" = "Farbe vom Bild gewählt: Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
@ -41,7 +43,9 @@
"sliders.val_max" = "Helligkeit Max (%)"
"sliders.alpha" = "Overlay Alpha"
"stats.placeholder" = "Markierungen (mit Ausschlüssen): —"
"stats.summary" = "Markierungen (mit Ausschlüssen): {with_pct:.2f}% | Markierungen (ohne Ausschlüsse): {without_pct:.2f}% | Ausgeschlossen: {excluded_pct:.2f}% der Pixel, davon {excluded_match_pct:.2f}% markiert"
"stats.summary" = "Score: {score:.2f}% | Markierungen (m. Ausschl.): {with_pct:.2f}% | Markierungen: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Ausgeschlossen: {excluded_pct:.2f}%"
"stats.brightness_label" = "Helligkeit"
"stats.darkness_label" = "Dunkelheit"
"menu.copy" = "Kopieren"
"dialog.info_title" = "Info"
"dialog.error_title" = "Fehler"
@ -49,7 +53,7 @@
"dialog.open_image_title" = "Bild wählen"
"dialog.open_folder_title" = "Ordner mit Bildern wählen"
"dialog.save_overlay_title" = "Overlay speichern als"
"dialog.choose_colour_title" = "Farbe wählen"
"dialog.choose_color_title" = "Farbe wählen"
"dialog.images_filter" = "Bilder"
"dialog.folder_not_found" = "Der Ordner wurde nicht gefunden."
"dialog.folder_empty" = "Keine unterstützten Bilder im Ordner gefunden."
@ -59,4 +63,28 @@
"dialog.no_image_loaded" = "Kein Bild geladen."
"dialog.no_preview_available" = "Keine Preview vorhanden."
"dialog.overlay_saved" = "Overlay gespeichert: {path}"
"dialog.json_filter" = "JSON-Dateien (*.json)"
"dialog.export_settings_title" = "Einstellungen als JSON exportieren"
"dialog.import_settings_title" = "Einstellungen aus JSON importieren"
"status.settings_exported" = "Einstellungen exportiert: {path}"
"status.settings_imported" = "Einstellungen importiert."
"toolbar.export_settings" = "Einstellungen exportieren (JSON)"
"toolbar.import_settings" = "Einstellungen importieren (JSON)"
"dialog.export_stats_title" = "Ordner-Statistiken exportieren (CSV)"
"dialog.csv_filter" = "CSV-Dateien (*.csv)"
"status.drag_drop" = "Bild oder Ordner hier ablegen."
"status.exporting" = "Statistiken werden exportiert... ({current}/{total})"
"status.export_done" = "Export abgeschlossen: {path}"
"toolbar.export_folder" = "Ordner-Statistik"
"menu.file" = "Datei"
"menu.edit" = "Bearbeiten"
"menu.view" = "Ansicht"
"menu.tools" = "Werkzeuge"
"toolbar.pull_patterns" = "Muster-Bilder herunterladen"
"dialog.puller_title" = "Muster-Bilder herunterladen"
"dialog.puller_instruction" = "CSGOSkins.gg Artikel-URL einfügen:"
"dialog.puller_start" = "Download starten"
"dialog.puller_cancel" = "Abbrechen"
"dialog.puller_invalid_url" = "Ungültiges URL-Format."
"dialog.puller_success" = "Alle Muster erfolgreich heruntergeladen!"

View File

@ -2,7 +2,7 @@
"app.title" = "Interactive Color Range Analyzer"
"toolbar.open_image" = "Open image"
"toolbar.open_folder" = "Open folder"
"toolbar.choose_color" = "Choose colour"
"toolbar.choose_color" = "Choose color"
"toolbar.pick_from_image" = "Pick from image"
"toolbar.save_overlay" = "Save overlay"
"toolbar.clear_excludes" = "Clear exclusions"
@ -10,19 +10,21 @@
"toolbar.undo_exclude" = "Undo last exclusion"
"toolbar.reset_sliders" = "Reset sliders"
"toolbar.toggle_theme" = "Toggle theme"
"toolbar.open_app_folder" = "Open application folder"
"toolbar.prefer_dark" = "Prefer darkness"
"status.no_file" = "No file loaded."
"status.defaults_restored" = "Defaults restored."
"status.free_draw_enabled" = "Free-draw exclusion mode enabled."
"status.free_draw_disabled" = "Rectangle exclusion mode enabled."
"status.loaded" = "Loaded: {name} — {dimensions}{position}"
"status.filename_label" = "{name} — {dimensions}{position}"
"status.color_selected" = "Colour chosen: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.sample_colour" = "Sample colour applied: {label} ({hex_code}) — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.pick_mode_ready" = "Pick mode: Click the left image to choose a colour (Esc exits)"
"status.color_selected" = "Color chosen: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.sample_color" = "Sample color applied: {label} ({hex_code}) — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.pick_mode_ready" = "Pick mode: Click the left image to choose a color (Esc exits)"
"status.pick_mode_ended" = "Pick mode ended."
"status.pick_mode_from_image" = "Colour picked from image: Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"palette.current" = "Colour:"
"palette.more" = "More colours:"
"status.pick_mode_from_image" = "Color picked from image: Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"palette.current" = "Color:"
"palette.more" = "More colors:"
"palette.swatch.red" = "Red"
"palette.swatch.orange" = "Orange"
"palette.swatch.yellow" = "Yellow"
@ -41,7 +43,9 @@
"sliders.val_max" = "Value max (%)"
"sliders.alpha" = "Overlay alpha"
"stats.placeholder" = "Matches (with exclusions): —"
"stats.summary" = "Matches (with exclusions): {with_pct:.2f}% | Matches (without exclusions): {without_pct:.2f}% | Excluded: {excluded_pct:.2f}% of pixels, {excluded_match_pct:.2f}% marked"
"stats.summary" = "Score: {score:.2f}% | Matches (w/ excl.): {with_pct:.2f}% | Matches: {without_pct:.2f}% | {brightness_label}: {brightness:.1f}% | Excluded: {excluded_pct:.2f}%"
"stats.brightness_label" = "Brightness"
"stats.darkness_label" = "Darkness"
"menu.copy" = "Copy"
"dialog.info_title" = "Info"
"dialog.error_title" = "Error"
@ -49,7 +53,7 @@
"dialog.open_image_title" = "Select image"
"dialog.open_folder_title" = "Select folder"
"dialog.save_overlay_title" = "Save overlay as"
"dialog.choose_colour_title" = "Choose colour"
"dialog.choose_color_title" = "Choose color"
"dialog.images_filter" = "Images"
"dialog.folder_not_found" = "The folder could not be found."
"dialog.folder_empty" = "No supported images were found in the folder."
@ -59,4 +63,28 @@
"dialog.no_image_loaded" = "No image loaded."
"dialog.no_preview_available" = "No preview available."
"dialog.overlay_saved" = "Overlay saved: {path}"
"dialog.json_filter" = "JSON Files (*.json)"
"dialog.export_settings_title" = "Export settings to JSON"
"dialog.import_settings_title" = "Import settings from JSON"
"status.settings_exported" = "Settings exported: {path}"
"status.settings_imported" = "Settings imported."
"toolbar.export_settings" = "Export settings (JSON)"
"toolbar.import_settings" = "Import settings (JSON)"
"dialog.export_stats_title" = "Export Folder Statistics (CSV)"
"dialog.csv_filter" = "CSV Files (*.csv)"
"status.drag_drop" = "Drop an image or folder here to open it."
"status.exporting" = "Exporting statistics... ({current}/{total})"
"status.export_done" = "Export complete: {path}"
"toolbar.export_folder" = "Export Folder Stats"
"menu.file" = "File"
"menu.edit" = "Edit"
"menu.view" = "View"
"menu.tools" = "Tools"
"toolbar.pull_patterns" = "Pull Pattern Images"
"dialog.puller_title" = "Pull Pattern Images"
"dialog.puller_instruction" = "Paste a CSGOSkins.gg item URL:"
"dialog.puller_start" = "Start Download"
"dialog.puller_cancel" = "Cancel"
"dialog.puller_invalid_url" = "Invalid URL format."
"dialog.puller_success" = "All patterns downloaded successfully!"

View File

@ -1,25 +1,23 @@
"""Logic utilities and mixins for processing and configuration."""
"""Logic utilities and configuration constants."""
from .constants import (
BASE_DIR,
DEFAULTS,
IMAGES_DIR,
LANGUAGE,
OVERLAY_COLOR,
PREVIEW_MAX_SIZE,
RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
SUPPORTED_IMAGE_EXTENSIONS,
)
from .image_processing import ImageProcessingMixin
from .reset import ResetMixin
__all__ = [
"BASE_DIR",
"DEFAULTS",
"IMAGES_DIR",
"LANGUAGE",
"OVERLAY_COLOR",
"PREVIEW_MAX_SIZE",
"RESET_EXCLUSIONS_ON_IMAGE_CHANGE",
"SUPPORTED_IMAGE_EXTENSIONS",
"ImageProcessingMixin",
"ResetMixin",
]

View File

@ -94,7 +94,7 @@ def _extract_language(data: dict[str, Any]) -> str:
_CONFIG_DATA = _load_config_data()
_OPTION_DEFAULTS = {"reset_exclusions_on_image_change": False}
_OPTION_DEFAULTS = {"reset_exclusions_on_image_change": False, "overlay_color": "#ff0000"}
def _extract_options(data: dict[str, Any]) -> dict[str, Any]:
@ -105,6 +105,9 @@ def _extract_options(data: dict[str, Any]) -> dict[str, Any]:
value = section.get("reset_exclusions_on_image_change")
if isinstance(value, bool):
result["reset_exclusions_on_image_change"] = value
color = section.get("overlay_color")
if isinstance(color, str) and color.startswith("#") and len(color) in (7, 9):
result["overlay_color"] = color
return result
@ -112,3 +115,4 @@ DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)}
LANGUAGE = _extract_language(_CONFIG_DATA)
OPTIONS = {**_OPTION_DEFAULTS, **_extract_options(_CONFIG_DATA)}
RESET_EXCLUSIONS_ON_IMAGE_CHANGE = OPTIONS["reset_exclusions_on_image_change"]
OVERLAY_COLOR = OPTIONS["overlay_color"]

View File

@ -1,485 +0,0 @@
"""Image loading, processing, and statistics logic."""
from __future__ import annotations
import colorsys
from pathlib import Path
from typing import Iterable, Sequence, Tuple
from tkinter import filedialog, messagebox
from PIL import Image, ImageDraw, ImageTk
from .constants import IMAGES_DIR, PREVIEW_MAX_SIZE, SUPPORTED_IMAGE_EXTENSIONS
class ImageProcessingMixin:
"""Handles all image related operations."""
image_path: Path | None
orig_img: Image.Image | None
preview_img: Image.Image | None
preview_tk: ImageTk.PhotoImage | None
overlay_tk: ImageTk.PhotoImage | None
image_paths: list[Path]
current_image_index: int
def load_image(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
path = filedialog.askopenfilename(
title=self._t("dialog.open_image_title"),
filetypes=[(self._t("dialog.images_filter"), "*.webp *.png *.jpg *.jpeg *.bmp")],
initialdir=str(default_dir),
)
if not path:
return
self._set_image_collection([Path(path)], 0)
def load_folder(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
directory = filedialog.askdirectory(
title=self._t("dialog.open_folder_title"),
initialdir=str(default_dir),
)
if not directory:
return
folder = Path(directory)
if not folder.exists():
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.folder_not_found"),
)
return
image_files = sorted(
(
path
for path in folder.iterdir()
if path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and path.is_file()
),
key=lambda item: item.name.lower(),
)
if not image_files:
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.folder_empty"),
)
return
self._set_image_collection(image_files, 0)
def show_next_image(self, event=None) -> None:
if not getattr(self, "image_paths", None):
return
if not self.image_paths:
return
current = getattr(self, "current_image_index", -1)
next_index = (current + 1) % len(self.image_paths)
self._display_image_by_index(next_index)
def show_previous_image(self, event=None) -> None:
if not getattr(self, "image_paths", None):
return
if not self.image_paths:
return
current = getattr(self, "current_image_index", -1)
prev_index = (current - 1) % len(self.image_paths)
self._display_image_by_index(prev_index)
def _set_image_collection(self, paths: Sequence[Path], start_index: int) -> None:
self.image_paths = list(paths)
if not self.image_paths:
return
self.exclude_shapes = []
self._rubber_start = None
self._rubber_id = None
self._stroke_preview_id = None
self._exclude_canvas_ids = []
self._exclude_mask = None
self._exclude_mask_px = None
self._exclude_mask_dirty = True
self.current_image_index = -1
self._display_image_by_index(max(0, start_index))
def _display_image_by_index(self, index: int) -> None:
if not self.image_paths:
return
if index < 0 or index >= len(self.image_paths):
return
path = self.image_paths[index]
if not path.exists():
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.file_missing", path=path),
)
return
try:
image = Image.open(path).convert("RGBA")
except Exception as exc:
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.image_open_failed", error=exc),
)
return
self.image_path = path
self.orig_img = image
if getattr(self, "reset_exclusions_on_switch", False):
self.exclude_shapes = []
self._rubber_start = None
self._rubber_id = None
self._stroke_preview_id = None
self._exclude_canvas_ids = []
self._exclude_mask = None
self._exclude_mask_px = None
self._exclude_mask_dirty = True
self.pick_mode = False
self.prepare_preview()
self.update_preview()
dimensions = f"{self.orig_img.width}x{self.orig_img.height}"
suffix = f" [{index + 1}/{len(self.image_paths)}]" if len(self.image_paths) > 1 else ""
status_text = self._t("status.loaded", name=path.name, dimensions=dimensions, position=suffix)
self.status.config(text=status_text)
self.status_default_text = status_text
if hasattr(self, "filename_label"):
filename_text = self._t(
"status.filename_label",
name=path.name,
dimensions=dimensions,
position=suffix,
)
self.filename_label.config(text=filename_text)
self.current_image_index = index
def save_overlay(self) -> None:
if self.orig_img is None:
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.no_image_loaded"),
)
return
if self.preview_img is None:
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.no_preview_available"),
)
return
overlay = self._build_overlay_image(
self.orig_img,
tuple(self.exclude_shapes),
alpha=int(self.alpha.get()),
scale_from_preview=self.preview_img.size,
is_match_fn=self.matches_target_color,
)
merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay)
out_path = filedialog.asksaveasfilename(
defaultextension=".png",
filetypes=[("PNG", "*.png")],
title=self._t("dialog.save_overlay_title"),
)
if not out_path:
return
merged.save(out_path)
messagebox.showinfo(
self._t("dialog.saved_title"),
self._t("dialog.overlay_saved", path=out_path),
)
def prepare_preview(self) -> None:
if self.orig_img is 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)
self.preview_tk = ImageTk.PhotoImage(self.preview_img)
self.canvas_orig.delete("all")
self.canvas_orig.config(width=size[0], height=size[1])
self.canvas_overlay.config(width=size[0], height=size[1])
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
self._exclude_mask = None
self._exclude_mask_px = None
self._exclude_mask_dirty = True
if getattr(self, "exclude_shapes", None):
self._ensure_exclude_mask()
def update_preview(self) -> None:
if self.preview_img is None:
return
self._ensure_exclude_mask()
merged = self.create_overlay_preview()
if merged is None:
return
self.overlay_tk = ImageTk.PhotoImage(merged)
self.canvas_overlay.delete("all")
self.canvas_overlay.create_image(0, 0, anchor="nw", image=self.overlay_tk)
self.canvas_orig.delete("all")
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
self._render_exclusion_overlays()
stats = self.compute_stats_preview()
if stats:
matches_all, total_all = stats["all"]
matches_keep, total_keep = stats["keep"]
matches_ex, total_ex = stats["excl"]
r_with = (matches_keep / total_keep * 100) if total_keep else 0.0
r_no = (matches_all / total_all * 100) if total_all else 0.0
excl_share = (total_ex / total_all * 100) if total_all else 0.0
excl_match = (matches_ex / total_ex * 100) if total_ex else 0.0
self.ratio_label.config(
text=self._t(
"stats.summary",
with_pct=r_with,
without_pct=r_no,
excluded_pct=excl_share,
excluded_match_pct=excl_match,
)
)
refresher = getattr(self, "_refresh_canvas_backgrounds", None)
if callable(refresher):
refresher()
else:
bg = "#0f0f10" if self.theme == "dark" else "#ffffff"
self.canvas_orig.configure(bg=bg)
self.canvas_overlay.configure(bg=bg)
def create_overlay_preview(self) -> Image.Image | None:
if self.preview_img is None:
return None
self._ensure_exclude_mask()
base = self.preview_img.convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
pixels = base.load()
mask_px = self._exclude_mask_px
width, height = base.size
alpha = int(self.alpha.get())
for y in range(height):
for x in range(width):
if mask_px is not None and mask_px[x, y]:
continue
r, g, b, a = pixels[x, y]
if a == 0:
continue
if self.matches_target_color(r, g, b):
draw.point((x, y), fill=(255, 0, 0, alpha))
merged = Image.alpha_composite(base, overlay)
outline = ImageDraw.Draw(merged)
accent_dark = (255, 215, 0, 200)
accent_light = (197, 98, 23, 200)
accent = accent_dark if getattr(self, "theme", "light") == "dark" else accent_light
for shape in getattr(self, "exclude_shapes", []):
if shape.get("kind") == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
outline.rectangle([x0, y0, x1, y1], outline=accent, width=3)
elif shape.get("kind") == "polygon":
points = shape.get("points", [])
if len(points) < 2:
continue
path = points if points[0] == points[-1] else points + [points[0]]
outline.line(path, fill=accent, width=2, joint="round")
return merged
def compute_stats_preview(self):
if self.preview_img is None:
return None
self._ensure_exclude_mask()
px = self.preview_img.convert("RGBA").load()
mask_px = self._exclude_mask_px
width, height = self.preview_img.size
matches_all = total_all = 0
matches_keep = total_keep = 0
matches_excl = total_excl = 0
for y in range(height):
for x in range(width):
r, g, b, a = px[x, y]
if a == 0:
continue
excluded = bool(mask_px and mask_px[x, y])
total_all += 1
if self.matches_target_color(r, g, b):
matches_all += 1
if not excluded:
total_keep += 1
if self.matches_target_color(r, g, b):
matches_keep += 1
else:
total_excl += 1
if self.matches_target_color(r, g, b):
matches_excl += 1
return {
"all": (matches_all, total_all),
"keep": (matches_keep, total_keep),
"excl": (matches_excl, total_excl),
}
def matches_target_color(self, r, g, b) -> bool:
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
hue = h * 360.0
hmin = float(self.hue_min.get())
hmax = float(self.hue_max.get())
smin = float(self.sat_min.get()) / 100.0
vmin = float(self.val_min.get()) / 100.0
vmax = float(self.val_max.get()) / 100.0
if hmin <= hmax:
hue_ok = hmin <= hue <= hmax
else:
hue_ok = (hue >= hmin) or (hue <= hmax)
return hue_ok and (s >= smin) and (v >= vmin) and (v <= vmax)
def _is_excluded(self, x: int, y: int) -> bool:
self._ensure_exclude_mask()
if self._exclude_mask_px is None:
return False
try:
return bool(self._exclude_mask_px[x, y])
except Exception:
return False
@classmethod
def _build_overlay_image(
cls,
image: Image.Image,
shapes: Iterable[dict[str, object]],
*,
alpha: int,
scale_from_preview: Tuple[int, int],
is_match_fn,
) -> Image.Image:
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
pixels = image.load()
width, height = image.size
mask = cls._build_exclude_mask_for_size(tuple(shapes), scale_from_preview, image.size)
mask_px = mask.load() if mask else None
for y in range(height):
for x in range(width):
if mask_px is not None and mask_px[x, y]:
continue
r, g, b, a = pixels[x, y]
if a == 0:
continue
if is_match_fn(r, g, b):
draw.point((x, y), fill=(255, 0, 0, alpha))
return overlay
@classmethod
def _build_exclude_mask_for_size(
cls,
shapes: Iterable[dict[str, object]],
preview_size: Tuple[int, int],
target_size: Tuple[int, int],
) -> Image.Image | None:
if not preview_size or not target_size or preview_size[0] == 0 or preview_size[1] == 0:
return None
mask = Image.new("L", target_size, 0)
draw = ImageDraw.Draw(mask)
scale_x = target_size[0] / preview_size[0]
scale_y = target_size[1] / preview_size[1]
for shape in shapes:
kind = shape.get("kind")
cls._draw_shape_on_mask(draw, shape, scale_x=scale_x, scale_y=scale_y)
return mask
def _ensure_exclude_mask(self) -> None:
if self.preview_img is None:
return
size = self.preview_img.size
if (
self._exclude_mask is None
or self._exclude_mask.size != size
or getattr(self, "_exclude_mask_dirty", False)
):
self._exclude_mask = Image.new("L", size, 0)
draw = ImageDraw.Draw(self._exclude_mask)
for shape in getattr(self, "exclude_shapes", []):
self._draw_shape_on_mask(draw, shape, scale_x=1.0, scale_y=1.0)
self._exclude_mask_px = self._exclude_mask.load()
self._exclude_mask_dirty = False
elif self._exclude_mask_px is None:
self._exclude_mask_px = self._exclude_mask.load()
def _stamp_shape_on_mask(self, shape: dict[str, object]) -> None:
if self.preview_img is None:
return
if self._exclude_mask is None or self._exclude_mask.size != self.preview_img.size:
self._exclude_mask_dirty = True
return
draw = ImageDraw.Draw(self._exclude_mask)
self._draw_shape_on_mask(draw, shape, scale_x=1.0, scale_y=1.0)
self._exclude_mask_px = self._exclude_mask.load()
@staticmethod
def _draw_shape_on_mask(
draw: ImageDraw.ImageDraw,
shape: dict[str, object],
*,
scale_x: float,
scale_y: float,
) -> None:
kind = shape.get("kind")
if kind == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
draw.rectangle(
[
x0 * scale_x,
y0 * scale_y,
x1 * scale_x,
y1 * scale_y,
],
fill=255,
)
elif kind == "polygon":
points = shape.get("points")
if not points or len(points) < 2:
return
scaled = [(px * scale_x, py * scale_y) for px, py in points] # type: ignore[misc]
draw.polygon(scaled, fill=255)
def _render_exclusion_overlays(self) -> None:
if not hasattr(self, "canvas_orig"):
return
for item in getattr(self, "_exclude_canvas_ids", []):
try:
self.canvas_orig.delete(item)
except Exception:
pass
self._exclude_canvas_ids = []
accent_dark = "#ffd700"
accent_light = "#c56217"
accent = accent_dark if getattr(self, "theme", "light") == "dark" else accent_light
for shape in getattr(self, "exclude_shapes", []):
kind = shape.get("kind")
if kind == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
item = self.canvas_orig.create_rectangle(
x0, y0, x1, y1, outline=accent, width=3
)
self._exclude_canvas_ids.append(item)
elif kind == "polygon":
points = shape.get("points")
if not points or len(points) < 2:
continue
closed = points if points[0] == points[-1] else points + [points[0]] # type: ignore[operator]
coords = [coord for point in closed for coord in point] # type: ignore[misc]
item = self.canvas_orig.create_line(
*coords,
fill=accent,
width=2,
smooth=True,
capstyle="round",
joinstyle="round",
)
self._exclude_canvas_ids.append(item)
__all__ = ["ImageProcessingMixin"]

View File

@ -1,36 +0,0 @@
"""Utility mixin for restoring default slider values."""
from __future__ import annotations
class ResetMixin:
def reset_sliders(self):
self.hue_min.set(self.DEFAULTS["hue_min"])
self.hue_max.set(self.DEFAULTS["hue_max"])
self.sat_min.set(self.DEFAULTS["sat_min"])
self.val_min.set(self.DEFAULTS["val_min"])
self.val_max.set(self.DEFAULTS["val_max"])
self.alpha.set(self.DEFAULTS["alpha"])
self.update_preview()
try:
default_hex = self._default_colour_hex() # type: ignore[attr-defined]
except Exception:
default_hex = None
if default_hex and hasattr(self, "_parse_hex_colour") and hasattr(self, "_update_selected_colour"):
try:
rgb = self._parse_hex_colour(default_hex) # type: ignore[attr-defined]
except Exception:
rgb = None
if rgb:
try:
self._update_selected_colour(*rgb) # type: ignore[arg-type,attr-defined]
except Exception:
pass
default_text = getattr(self, "status_default_text", None)
if default_text is None:
default_text = self._t("status.defaults_restored") if hasattr(self, "_t") else "Defaults restored."
if hasattr(self, "status"):
self.status.config(text=default_text)
__all__ = ["ResetMixin"]

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
from PySide6 import QtCore, QtGui, QtWidgets
from app.logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE
from .main_window import MainWindow
@ -46,16 +46,21 @@ def create_application() -> QtWidgets.QApplication:
def run() -> int:
"""Run the PySide6 GUI."""
app = create_application()
from app.logic import OVERLAY_COLOR
window = MainWindow(
language=LANGUAGE,
defaults=DEFAULTS.copy(),
reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
overlay_color=OVERLAY_COLOR,
)
primary_screen = app.primaryScreen()
if primary_screen is not None:
geometry = primary_screen.availableGeometry()
window.setGeometry(geometry)
window.showMaximized()
# Respect saved geometry from QSettings; fall back to maximised on first launch
settings = QtCore.QSettings("ICRA", "MainWindow")
if settings.value("geometry"):
window.show()
else:
primary_screen = app.primaryScreen()
if primary_screen is not None:
window.setGeometry(primary_screen.availableGeometry())
window.showMaximized()
return app.exec()

View File

@ -22,6 +22,20 @@ class Stats:
total_keep: int = 0
matches_excl: int = 0
total_excl: int = 0
brightness_score: float = 0.0
prefer_dark: bool = False
@property
def effective_brightness(self) -> float:
"""Returns inverted brightness when prefer_dark is on."""
return (100.0 - self.brightness_score) if self.prefer_dark else self.brightness_score
@property
def composite_score(self) -> float:
"""Weighted composite: 35% match_all + 55% match_keep + 10% brightness."""
pct_all = (self.matches_all / self.total_all * 100) if self.total_all else 0.0
pct_keep = (self.matches_keep / self.total_keep * 100) if self.total_keep else 0.0
return 0.35 * pct_all + 0.55 * pct_keep + 0.10 * self.effective_brightness
def summary(self, translate) -> str:
if self.total_all == 0:
@ -29,13 +43,15 @@ class Stats:
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
brightness_label = translate("stats.darkness_label") if self.prefer_dark else translate("stats.brightness_label")
return translate(
"stats.summary",
score=self.composite_score,
with_pct=with_pct,
without_pct=without_pct,
brightness_label=brightness_label,
brightness=self.effective_brightness,
excluded_pct=excluded_pct,
excluded_match_pct=excluded_match_pct,
)
@ -55,7 +71,8 @@ def _rgb_to_hsv_numpy(arr: np.ndarray) -> np.ndarray:
v = cmax
# Saturation
s = np.where(cmax > 0, delta / cmax, 0.0)
s = np.zeros_like(r)
np.divide(delta, cmax, out=s, where=cmax > 0)
# Hue
h = np.zeros_like(r)
@ -80,6 +97,11 @@ class QtImageProcessor:
self.current_index: int = -1
self.stats = Stats()
# Overlay tint color
self.overlay_r = 255
self.overlay_g = 0
self.overlay_b = 0
self.defaults: Dict[str, int] = {
"hue_min": 0,
"hue_max": 360,
@ -98,6 +120,12 @@ class QtImageProcessor:
self.exclude_shapes: list[dict[str, object]] = []
self.reset_exclusions_on_switch: bool = False
# Mask caching
self._cached_mask: np.ndarray | None = None
self._cached_mask_size: Tuple[int, int] | None = None
self.exclude_ref_size: Tuple[int, int] | None = None
self.prefer_dark: bool = False
def set_defaults(self, defaults: dict) -> None:
for key in self.defaults:
if key in defaults:
@ -163,7 +191,7 @@ class QtImageProcessor:
self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
def _rebuild_overlay(self) -> None:
"""Build colour-match overlay using vectorized NumPy operations."""
"""Build color-match overlay using vectorized NumPy operations."""
if self.preview_img is None:
self.overlay_img = None
self.stats = Stats()
@ -210,9 +238,15 @@ class QtImageProcessor:
matches_excl = int(excl_match[visible].sum())
total_excl = int((visible & excl_mask).sum())
# Brightness: mean Value (0-100) of ALL non-excluded visible pixels
keep_visible = visible & ~excl_mask
brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0
# 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, 0] = self.overlay_r
overlay_arr[keep_match, 1] = self.overlay_g
overlay_arr[keep_match, 2] = self.overlay_b
overlay_arr[keep_match, 3] = int(self.alpha)
self.overlay_img = Image.fromarray(overlay_arr, "RGBA")
@ -223,6 +257,58 @@ class QtImageProcessor:
total_keep=total_keep,
matches_excl=matches_excl,
total_excl=total_excl,
brightness_score=brightness,
prefer_dark=self.prefer_dark,
)
def get_stats_headless(self, image: Image.Image) -> Stats:
"""Calculate color-match statistics natively without building UI elements or scaling."""
base = image.convert("RGBA")
arr = np.asarray(base, dtype=np.float32)
rgb = arr[..., :3] / 255.0
alpha_ch = arr[..., 3]
hsv = _rgb_to_hsv_numpy(rgb)
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)
)
excl_mask = self._build_exclusion_mask_numpy(base.size)
keep_match = match_mask & ~excl_mask
excl_match = match_mask & excl_mask
visible = alpha_ch > 0
matches_keep_count = int(keep_match[visible].sum())
keep_visible = visible & ~excl_mask
brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0
return Stats(
matches_all=int(match_mask[visible].sum()),
total_all=int(visible.sum()),
matches_keep=matches_keep_count,
total_keep=int((visible & ~excl_mask).sum()),
matches_excl=int(excl_match[visible].sum()),
total_excl=int((visible & excl_mask).sum()),
brightness_score=brightness,
prefer_dark=self.prefer_dark,
)
# helpers ----------------------------------------------------------------
@ -239,7 +325,7 @@ class QtImageProcessor:
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:
def pick_color(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
@ -259,8 +345,10 @@ class QtImageProcessor:
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:
if self.preview_img is None:
return QtGui.QPixmap()
if self.overlay_img is None:
return self.preview_pixmap()
merged = Image.alpha_composite(self.preview_img.convert("RGBA"), self.overlay_img)
return self._to_pixmap(merged)
@ -274,7 +362,7 @@ class QtImageProcessor:
# exclusions -------------------------------------------------------------
def set_exclusions(self, shapes: list[dict[str, object]]) -> None:
def set_exclusions(self, shapes: list[dict[str, object]], ref_size: Tuple[int, int] | None = None) -> None:
copied: list[dict[str, object]] = []
for shape in shapes:
kind = shape.get("kind")
@ -285,30 +373,69 @@ class QtImageProcessor:
pts = shape.get("points", [])
copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]})
self.exclude_shapes = copied
if ref_size:
self.exclude_ref_size = ref_size
elif self.preview_img:
self.exclude_ref_size = self.preview_img.size
else:
self.exclude_ref_size = None
self._cached_mask = None # Invalidate cache
self._cached_mask_size = None
self._rebuild_overlay()
def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None:
if not self.exclude_shapes:
return None
target_w, target_h = size
ref_w, ref_h = self.exclude_ref_size or size
sx = target_w / ref_w if ref_w > 0 else 1.0
sy = target_h / ref_h if ref_h > 0 else 1.0
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)
draw.rectangle([x0 * sx, y0 * sy, x1 * sx, y1 * sy], fill=255)
elif kind == "polygon":
points = shape.get("points", [])
if len(points) >= 3:
draw.polygon(points, fill=255)
scaled_pts = [(int(x * sx), int(y * sy)) for x, y in points]
draw.polygon(scaled_pts, fill=255)
return mask
def set_overlay_color(self, hex_code: str) -> None:
"""Set the RGB channels for the match overlay from a hex string."""
if not hex_code.startswith("#") or len(hex_code) not in (7, 9):
return
try:
self.overlay_r = int(hex_code[1:3], 16)
self.overlay_g = int(hex_code[3:5], 16)
self.overlay_b = int(hex_code[5:7], 16)
if self.preview_img:
self._rebuild_overlay()
except ValueError:
pass
def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray:
"""Return a boolean (H, W) mask — True where pixels are excluded."""
if self._cached_mask is not None and self._cached_mask_size == size:
return self._cached_mask
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)
mask = np.zeros((h, w), dtype=bool)
else:
pil_mask = self._build_exclusion_mask(size)
if pil_mask is None:
mask = np.zeros((h, w), dtype=bool)
else:
mask = np.asarray(pil_mask, dtype=bool)
self._cached_mask = mask
self._cached_mask_size = size
return mask

View File

@ -1,19 +1,29 @@
"""Main PySide6 window emulating the legacy Tk interface with translations and themes."""
from __future__ import annotations
import re
import os
import time
import urllib.request
import urllib.error
import csv
import json
import concurrent.futures
from pathlib import Path
from typing import Callable, Dict, List, Tuple
from PIL import Image
from PySide6 import QtCore, QtGui, QtWidgets
from app.i18n import I18nMixin
from app.logic import SUPPORTED_IMAGE_EXTENSIONS
from .image_processor import QtImageProcessor
from .pattern_puller import PatternPullerDialog
DEFAULT_COLOUR = "#763e92"
DEFAULT_COLOR = "#763e92"
DEFAULT_OVERLAY_HEX = "#ff0000"
PRESET_COLOURS: List[Tuple[str, str]] = [
PRESET_COLORS: List[Tuple[str, str]] = [
("palette.swatch.red", "#ff3b30"),
("palette.swatch.orange", "#ff9500"),
("palette.swatch.yellow", "#ffd60a"),
@ -64,41 +74,8 @@ THEMES: Dict[str, Dict[str, str]] = {
}
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):
class ColorSwatch(QtWidgets.QPushButton):
"""Clickable palette swatch."""
def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None):
@ -108,10 +85,10 @@ class ColourSwatch(QtWidgets.QPushButton):
self.callback = callback
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedSize(28, 28)
self._apply_colour(hex_code)
self._apply_color(hex_code)
self.clicked.connect(lambda: callback(hex_code, self.name_key))
def _apply_colour(self, hex_code: str) -> None:
def _apply_color(self, hex_code: str) -> None:
self.setStyleSheet(
f"""
QPushButton {{
@ -125,16 +102,16 @@ class ColourSwatch(QtWidgets.QPushButton):
"""
)
def apply_theme(self, colours: Dict[str, str]) -> None:
def apply_theme(self, colors: Dict[str, str]) -> None:
self.setStyleSheet(
f"""
QPushButton {{
background-color: {self.hex_code};
border: 2px solid {colours['border']};
border: 2px solid {colors['border']};
border-radius: 6px;
}}
QPushButton:hover {{
border-color: {colours['accent']};
border-color: {colors['accent']};
}}
"""
)
@ -194,22 +171,22 @@ class SliderControl(QtWidgets.QWidget):
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;")
def apply_theme(self, colors: Dict[str, str]) -> None:
self.title_label.setStyleSheet(f"color: {colors['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;"
f"color: {colors['text_dim']}; background: transparent; "
f"border: 1px solid {colors['border']}; border-radius: 4px; padding: 0 2px;"
)
self.slider.setStyleSheet(
f"""
QSlider::groove:horizontal {{
border: 1px solid {colours['border']};
border: 1px solid {colors['border']};
height: 6px;
background: rgba(255,255,255,0.14);
border-radius: 4px;
}}
QSlider::handle:horizontal {{
background: {colours['accent_secondary']};
background: {colors['accent_secondary']};
border: 1px solid rgba(255,255,255,0.2);
width: 14px;
margin: -5px 0;
@ -271,8 +248,8 @@ class CanvasView(QtWidgets.QGraphicsView):
def set_mode(self, mode: str) -> None:
self.mode = mode
def set_accent(self, colour: str) -> None:
self._accent = QtGui.QColor(colour)
def set_accent(self, color: str) -> None:
self._accent = QtGui.QColor(color)
self._redraw_shapes()
def undo_last(self) -> None:
@ -399,7 +376,7 @@ class CanvasView(QtWidgets.QGraphicsView):
class OverlayCanvas(QtWidgets.QGraphicsView):
"""Read-only QGraphicsView for displaying the colour-match overlay."""
"""Read-only QGraphicsView for displaying the color-match overlay."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
@ -488,17 +465,17 @@ class TitleBar(QtWidgets.QWidget):
)
return btn
def apply_theme(self, colours: Dict[str, str]) -> None:
def apply_theme(self, colors: Dict[str, str]) -> None:
palette = self.palette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colours["titlebar_bg"]))
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colors["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.title_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;")
hover_bg = "#d0342c" if colors["titlebar_bg"] != "#e9ebf5" else "#e6675a"
self.close_btn.setStyleSheet(
f"""
QPushButton {{
background-color: transparent;
color: {colours['text']};
color: {colors['text']};
border: none;
padding: 4px 10px;
}}
@ -513,7 +490,7 @@ class TitleBar(QtWidgets.QWidget):
f"""
QPushButton {{
background-color: transparent;
color: {colours['text']};
color: {colors['text']};
border: none;
padding: 4px 10px;
}}
@ -538,7 +515,7 @@ class TitleBar(QtWidgets.QWidget):
class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"""Main application window containing all controls."""
def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None:
def __init__(self, language: str, defaults: dict, reset_exclusions: bool, overlay_color: str | None = None) -> None:
super().__init__()
self.init_i18n(language)
self.setWindowTitle(self._t("app.title"))
@ -560,12 +537,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.processor = QtImageProcessor()
self.processor.set_defaults(defaults)
self.processor.reset_exclusions_on_switch = reset_exclusions
# Always use red for the overlay regardless of the target color
self.processor.set_overlay_color(DEFAULT_OVERLAY_HEX)
self.content_layout = QtWidgets.QVBoxLayout(self.content)
self.content_layout.setContentsMargins(24, 24, 24, 24)
self.content_layout.setContentsMargins(24, 0, 24, 24)
self.content_layout.setSpacing(18)
self.content_layout.addLayout(self._build_toolbar())
self.content_layout.addWidget(self._build_menu_bar())
self.content_layout.addLayout(self._build_palette())
self.content_layout.addLayout(self._build_sliders())
self.content_layout.addWidget(self._build_previews(), 1)
@ -576,7 +556,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._is_maximised = False
self._current_image_path: Path | None = None
self._current_colour = DEFAULT_COLOUR
self._current_color = DEFAULT_COLOR
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
self._register_default_actions()
@ -587,7 +567,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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._update_color_display(DEFAULT_COLOR, self._t("palette.current"))
self.current_theme = "dark"
self._apply_theme(self.current_theme)
@ -598,6 +578,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# Keyboard shortcuts
self._setup_shortcuts()
# Slider debounce timer
self._slider_timer = QtCore.QTimer(self)
self._slider_timer.setSingleShot(True)
self._slider_timer.setInterval(80)
self._slider_timer.timeout.connect(self._refresh_overlay_only)
# Restore window geometry
self._settings = QtCore.QSettings("ICRA", "MainWindow")
geometry = self._settings.value("geometry")
@ -626,33 +612,55 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# UI builders ------------------------------------------------------------
def _build_toolbar(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(12)
def _build_menu_bar(self) -> QtWidgets.QMenuBar:
self.menu_bar = QtWidgets.QMenuBar(self)
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
# File Menu
file_menu = self.menu_bar.addMenu(self._t("menu.file"))
file_menu.addAction("🖼 " + self._t("toolbar.open_image"), lambda: self._invoke_action("open_image"), "Ctrl+O")
file_menu.addSeparator()
file_menu.addAction("📂 " + self._t("toolbar.open_folder"), lambda: self._invoke_action("open_folder"), "Ctrl+Shift+O")
file_menu.addAction("📊 " + self._t("toolbar.export_folder"), lambda: self._invoke_action("export_folder"))
file_menu.addSeparator()
file_menu.addAction("📤 " + self._t("toolbar.export_settings"), lambda: self._invoke_action("export_settings"), "Ctrl+E")
file_menu.addAction("📥 " + self._t("toolbar.import_settings"), lambda: self._invoke_action("import_settings"), "Ctrl+I")
file_menu.addSeparator()
file_menu.addAction("💾 " + self._t("toolbar.save_overlay"), lambda: self._invoke_action("save_overlay"), "Ctrl+S")
layout.addStretch(1)
# Edit Menu
edit_menu = self.menu_bar.addMenu(self._t("menu.edit"))
edit_menu.addAction("" + self._t("toolbar.undo_exclude"), lambda: self._invoke_action("undo_exclude"), "Ctrl+Z")
edit_menu.addAction("🧹 " + self._t("toolbar.clear_excludes"), lambda: self._invoke_action("clear_excludes"))
edit_menu.addSeparator()
edit_menu.addAction("🔄 " + self._t("toolbar.reset_sliders"), lambda: self._invoke_action("reset_sliders"), "Ctrl+R")
# Tools Menu
tools_menu = self.menu_bar.addMenu(self._t("menu.tools"))
tools_menu.addAction("🎨 " + self._t("toolbar.choose_color"), lambda: self._invoke_action("choose_color"))
tools_menu.addAction("🖱 " + self._t("toolbar.pick_from_image"), lambda: self._invoke_action("pick_from_image"))
self.free_draw_action = QtGui.QAction("" + self._t("toolbar.toggle_free_draw"), self)
self.free_draw_action.setCheckable(True)
self.free_draw_action.setChecked(False)
self.free_draw_action.triggered.connect(lambda: self._invoke_action("toggle_free_draw"))
tools_menu.addAction(self.free_draw_action)
tools_menu.addSeparator()
tools_menu.addAction("📥 " + self._t("toolbar.pull_patterns"), lambda: self._invoke_action("pull_patterns"))
# View Menu
view_menu = self.menu_bar.addMenu(self._t("menu.view"))
view_menu.addAction("🌓 " + self._t("toolbar.toggle_theme"), lambda: self._invoke_action("toggle_theme"))
self.prefer_dark_action = QtGui.QAction("🌑 " + self._t("toolbar.prefer_dark"), self)
self.prefer_dark_action.setCheckable(True)
self.prefer_dark_action.setChecked(False)
self.prefer_dark_action.triggered.connect(lambda: self._invoke_action("toggle_prefer_dark"))
view_menu.addAction(self.prefer_dark_action)
view_menu.addSeparator()
view_menu.addAction("📁 " + self._t("toolbar.open_app_folder"), lambda: self._invoke_action("open_app_folder"))
# Status label logic remains but moved to palette layout or kept minimal
# We will add it to the palette layout so that it stays on top
self.status_label = QtWidgets.QLabel(self._t("status.no_file"))
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout
return self.menu_bar
def _build_palette(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
@ -664,13 +672,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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_color_swatch = QtWidgets.QLabel()
self.current_color_swatch.setFixedSize(28, 28)
self.current_color_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOR}; border-radius: 6px;")
current_group.addWidget(self.current_color_swatch)
self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})")
current_group.addWidget(self.current_colour_label)
self.current_color_label = QtWidgets.QLabel(f"({DEFAULT_COLOR})")
current_group.addWidget(self.current_color_label)
layout.addLayout(current_group)
self.more_label = QtWidgets.QLabel(self._t("palette.more"))
@ -678,13 +686,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
self.swatch_buttons: List[ColorSwatch] = []
for name_key, hex_code in PRESET_COLORS:
swatch = ColorSwatch(self._t(name_key), hex_code, self._update_color_display)
swatch_container.addWidget(swatch)
self.swatch_buttons.append(swatch)
layout.addLayout(swatch_container)
layout.addStretch(1)
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout
def _build_sliders(self) -> QtWidgets.QHBoxLayout:
@ -739,9 +749,38 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
# Status row container
status_row_layout = QtWidgets.QHBoxLayout()
status_row_layout.setSpacing(4)
status_row_layout.setAlignment(QtCore.Qt.AlignCenter)
self.filename_prefix_label = QtWidgets.QLabel(self._t("status.loaded", name="", dimensions="", position="").split("{name}")[0])
self.filename_prefix_label.setStyleSheet("color: " + THEMES["dark"]["text_muted"] + "; font-weight: 500;")
status_row_layout.addWidget(self.filename_prefix_label)
self.pattern_input = QtWidgets.QLineEdit("")
self.pattern_input.setAlignment(QtCore.Qt.AlignCenter)
self.pattern_input.setFixedWidth(100)
self.pattern_input.setStyleSheet(
"QLineEdit {"
" background: rgba(255, 255, 255, 0.05);"
" border: 1px solid rgba(255, 255, 255, 0.1);"
" border-radius: 4px;"
" color: " + THEMES["dark"]["text"] + ";"
" font-weight: 600;"
"}"
"QLineEdit:focus {"
" border: 1px solid " + THEMES["dark"]["accent"] + ";"
"}"
)
self.pattern_input.returnPressed.connect(self._jump_to_pattern)
status_row_layout.addWidget(self.pattern_input)
self.filename_suffix_label = QtWidgets.QLabel("")
self.filename_suffix_label.setStyleSheet("color: " + THEMES["dark"]["text"] + "; font-weight: 600;")
status_row_layout.addWidget(self.filename_suffix_label)
layout.addLayout(status_row_layout)
self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder"))
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
@ -754,16 +793,22 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._toolbar_actions = {
"open_image": self.open_image,
"open_folder": self.open_folder,
"choose_color": self.choose_colour,
"export_folder": self.export_folder,
"choose_color": self.choose_color,
"pick_from_image": self.pick_from_image,
"save_overlay": self.save_overlay,
"export_settings": self.export_settings,
"import_settings": self.import_settings,
"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,
"toggle_prefer_dark": self.toggle_prefer_dark,
"open_app_folder": self.open_app_folder,
"show_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image,
"pull_patterns": self.open_pattern_puller,
}
def _invoke_action(self, key: str) -> None:
@ -771,11 +816,47 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
if action:
action()
def _jump_to_pattern(self) -> None:
if not self.processor.preview_paths:
return
target_text = self.pattern_input.text().strip()
if not target_text:
# Restore current text if empty
if self._current_image_path:
self.pattern_input.setText(self._current_image_path.stem)
return
# Try to find exactly this name or stem
target_stem_lower = target_text.lower()
found_idx = -1
for i, path in enumerate(self.processor.preview_paths):
if path.stem.lower() == target_stem_lower or path.name.lower() == target_stem_lower:
found_idx = i
break
if found_idx != -1:
# Found it, jump!
self.processor.current_index = found_idx
try:
loaded_path = self.processor._load_image_at_current()
self._current_image_path = loaded_path
self._refresh_views()
except Exception as e:
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e))
else:
# Not found, just restore text
if self._current_image_path:
self.pattern_input.setText(self._current_image_path.stem)
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), f"Pattern '{target_text}' not found in current folder.")
# 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)
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, self._t("dialog.open_image_title"), default_dir, filters)
if not path_str:
return
path = Path(path_str)
@ -791,7 +872,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._refresh_views()
def open_folder(self) -> None:
directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title"))
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title"), default_dir)
if not directory:
return
folder = Path(directory)
@ -810,6 +892,185 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._current_image_path = loaded_path
self._refresh_views()
def export_settings(self) -> None:
item_name = ""
if self._current_image_path:
# Try to get folder name first, otherwise file name
if self._current_image_path.parent.name and self._current_image_path.parent.name != "images":
item_name = self._current_image_path.parent.name
else:
item_name = self._current_image_path.stem
default_filename = f"icra_settings_{item_name}.json" if item_name else "icra_settings.json"
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
path_str, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
self._t("dialog.export_settings_title"),
str(Path(default_dir) / default_filename),
self._t("dialog.json_filter")
)
if not path_str:
return
settings = {
"hue_min": self.processor.hue_min,
"hue_max": self.processor.hue_max,
"sat_min": self.processor.sat_min,
"val_min": self.processor.val_min,
"val_max": self.processor.val_max,
"alpha": self.processor.alpha,
"current_color": self._current_color,
"exclude_ref_size": self.processor.exclude_ref_size,
"shapes": self.image_view.shapes
}
try:
with open(path_str, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=4)
self.status_label.setText(self._t("status.settings_exported", path=Path(path_str).name))
except Exception as e:
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e))
def import_settings(self) -> None:
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
self._t("dialog.import_settings_title"),
default_dir,
self._t("dialog.json_filter")
)
if not path_str:
return
try:
with open(path_str, "r", encoding="utf-8") as f:
settings = json.load(f)
# 1. Apply color (UI ONLY)
if "current_color" in settings:
self._current_color = settings["current_color"]
# Specifically NOT setting processor color to keep it RED
self._update_color_display(self._current_color, self._t("palette.current"))
# 2. Apply slider values
keys = ["hue_min", "hue_max", "sat_min", "val_min", "val_max", "alpha"]
for key in keys:
if key in settings:
setattr(self.processor, key, settings[key])
# 3. Apply shapes and reference size
ref_size = None
if "exclude_ref_size" in settings and settings["exclude_ref_size"]:
ref_size = tuple(settings["exclude_ref_size"])
if "shapes" in settings:
self.image_view.set_shapes(settings["shapes"])
self.processor.set_exclusions(settings["shapes"], ref_size=ref_size)
else:
# Force rebuild even if no shapes to pick up sliders/color
if self.processor.preview_img:
self.processor._rebuild_overlay()
self._sync_sliders_from_processor()
self._refresh_views()
self.status_label.setText(self._t("status.settings_imported"))
except Exception as e:
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e))
def export_folder(self) -> None:
if not self.processor.preview_paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded"))
return
folder_path = self.processor.preview_paths[0].parent
default_filename = f"icra_stats_{folder_path.name}.csv"
csv_path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
self._t("dialog.export_stats_title"),
str(folder_path / default_filename),
self._t("dialog.csv_filter")
)
if not csv_path:
return
total = len(self.processor.preview_paths)
# Hardcoded to EU format as requested: ; delimiter, , decimal
delimiter = ";"
decimal = ","
brightness_col = "Darkness Score" if self.processor.prefer_dark else "Brightness Score"
headers = [
"Filename",
"Color",
"Matching Pixels",
"Matching Pixels w/ Exclusions",
"Excluded Pixels",
brightness_col,
"Composite Score"
]
rows = [headers]
def process_image(img_path):
try:
img = Image.open(img_path)
s = self.processor.get_stats_headless(img)
pct_all = (s.matches_all / s.total_all * 100) if s.total_all else 0.0
pct_keep = (s.matches_keep / s.total_keep * 100) if s.total_keep else 0.0
pct_excl = (s.total_excl / s.total_all * 100) if s.total_all else 0.0
pct_all_str = f"{pct_all:.2f}".replace(".", decimal)
pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal)
pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal)
brightness_str = f"{s.effective_brightness:.2f}".replace(".", decimal)
composite_str = f"{s.composite_score:.2f}".replace(".", decimal)
img.close()
return [
img_path.name,
self._current_color,
pct_all_str,
pct_keep_str,
pct_excl_str,
brightness_str,
composite_str
]
except Exception:
return [img_path.name, self._current_color, "Error", "Error", "Error", "Error", "Error"]
results = [None] * total
with concurrent.futures.ThreadPoolExecutor() as executor:
future_to_idx = {executor.submit(process_image, p): i for i, p in enumerate(self.processor.preview_paths)}
done_count = 0
for future in concurrent.futures.as_completed(future_to_idx):
idx = future_to_idx[future]
results[idx] = future.result()
done_count += 1
if done_count % 10 == 0 or done_count == total:
self.status_label.setText(self._t("status.exporting", current=str(done_count), total=str(total)))
QtWidgets.QApplication.processEvents()
rows.extend(results)
# Compute max width per column for alignment, plus extra space so it's not cramped
col_widths = [max(len(str(item)) for item in col) + 4 for col in zip(*rows)]
# Excel on Windows prefers utf-8-sig (with BOM) to identify the encoding correctly.
with open(csv_path, mode="w", newline="", encoding="utf-8-sig") as f:
for row in rows:
# Manual formatting to support both alignment for text editors AND valid CSV for Excel.
# We pad the strings but keep the delimiter clean.
padded_cells = [f"{str(item):>{width}}" for item, width in zip(row, col_widths)]
f.write(delimiter.join(padded_cells) + "\n")
# Restore overlay state for currently viewed image
self.processor._rebuild_overlay()
self.status_label.setText(self._t("status.export_done", path=csv_path))
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"))
@ -838,17 +1099,19 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# 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 _update_color_display(self, hex_code: str, label: str) -> None:
self._current_color = hex_code
self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
self.current_color_label.setText(f"({hex_code})")
# Do NOT call self.processor.set_overlay_color here to keep overlay RED
if label:
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()
self._slider_timer.start()
def _reset_sliders(self) -> None:
for _, attr, _, _ in SLIDER_SPECS:
@ -904,7 +1167,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
result = self.processor.pick_color(x, y)
if result is None:
self._exit_pick_mode()
return
@ -927,21 +1190,24 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
ctrl.set_value(value)
self.processor.set_threshold(attr, value)
# Update colour swatch to the picked pixel colour
# Update color swatch to the picked pixel color
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._update_color_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()
def open_pattern_puller(self) -> None:
dialog = PatternPullerDialog(self.language, parent=self)
dialog.exec()
# Drag-and-drop ----------------------------------------------------------
def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None:
@ -988,12 +1254,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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():
def choose_color(self) -> None:
color = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_color_title"))
if not color.isValid():
return
hex_code = colour.name()
self._update_colour_display(hex_code, self._t("dialog.choose_colour_title"))
hex_code = color.name()
self._update_color_display(hex_code, self._t("dialog.choose_color_title"))
def save_overlay(self) -> None:
pixmap = self.processor.overlay_pixmap()
@ -1016,9 +1282,17 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
self.free_draw_action.setChecked(self.exclude_mode == "free")
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 toggle_prefer_dark(self) -> None:
self.processor.prefer_dark = not self.processor.prefer_dark
self.prefer_dark_action.setChecked(self.processor.prefer_dark)
if self.processor.preview_img:
self.processor._rebuild_overlay()
self._refresh_overlay_only()
def clear_exclusions(self) -> None:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
@ -1033,35 +1307,84 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.current_theme = "light" if self.current_theme == "dark" else "dark"
self._apply_theme(self.current_theme)
def open_app_folder(self) -> None:
path = os.getcwd()
QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(path))
def _apply_theme(self, mode: str) -> None:
colours = THEMES[mode]
self.content.setStyleSheet(f"background-color: {colours['window_bg']};")
colors = THEMES[mode]
self.content.setStyleSheet(f"background-color: {colors['window_bg']};")
self.image_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;"
)
self.image_view.set_accent(colours["highlight"])
self.image_view.set_accent(colors["highlight"])
self.overlay_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['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;")
self.status_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.current_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.current_color_label.setStyleSheet(f"color: {colors['text_dim']};")
self.more_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.filename_prefix_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.filename_suffix_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;")
self.pattern_input.setStyleSheet(
f"QLineEdit {{"
f" background: rgba(255, 255, 255, 0.05);"
f" border: 1px solid {colors['border']};"
f" border-radius: 4px;"
f" color: {colors['text']};"
f" font-weight: 600;"
f"}}"
f"QLineEdit:focus {{"
f" border: 1px solid {colors['accent']};"
f"}}"
)
self.ratio_label.setStyleSheet(f"color: {colors['highlight']}; font-weight: 600;")
# Style MenuBar
self.menu_bar.setStyleSheet(
f"""
QMenuBar {{
background-color: {colors['window_bg']};
color: {colors['text']};
font-weight: 500;
font-size: 13px;
border-bottom: 1px solid {colors['border']};
}}
QMenuBar::item {{
spacing: 8px;
padding: 6px 12px;
background: transparent;
border-radius: 4px;
}}
QMenuBar::item:selected {{
background: rgba(128, 128, 128, 0.2);
}}
QMenu {{
background-color: {colors['panel_bg']};
color: {colors['text']};
border: 1px solid {colors['border']};
}}
QMenu::item {{
padding: 6px 24px;
}}
QMenu::item:selected {{
background-color: {colors['highlight']};
color: #ffffff;
}}
"""
)
for button in self._toolbar_buttons.values():
button.apply_theme(colours)
for swatch in self.swatch_buttons:
swatch.apply_theme(colours)
swatch.apply_theme(colors)
for control in self._slider_controls.values():
control.apply_theme(colours)
control.apply_theme(colors)
self._style_nav_button(self.prev_button)
self._style_nav_button(self.next_button)
self.title_bar.apply_theme(colours)
self.title_bar.apply_theme(colors)
def _sync_sliders_from_processor(self) -> None:
for _, attr, _, _ in SLIDER_SPECS:
@ -1076,11 +1399,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return key
def _style_nav_button(self, button: QtWidgets.QToolButton) -> None:
colours = THEMES[self.current_theme]
colors = 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; }}"
f"QToolButton {{ border-radius: 19px; background-color: {colors['panel_bg']}; "
f"border: 1px solid {colors['border']}; color: {colors['text']}; }}"
f"QToolButton:hover {{ background-color: {colors['accent_secondary']}; color: white; }}"
)
button.setIconSize(QtCore.QSize(20, 20))
if button is getattr(self, "prev_button", None):
@ -1106,12 +1429,22 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
total = len(self.processor.preview_paths)
position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else ""
dimensions = f"{width}×{height}"
# Status label for top right layout
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)
)
# Pattern input
self.pattern_input.setText(self._current_image_path.stem)
# Update suffix label
suffix_text = f"{self._current_image_path.suffix}{dimensions}{position}"
self.filename_suffix_label.setText(suffix_text)
# Update prefix translation correctly
prefix = self._t("status.loaded", name="X", dimensions="Y", position="Z").split("X")[0]
self.filename_prefix_label.setText(prefix)
self.ratio_label.setText(self.processor.stats.summary(self._t))
def _refresh_overlay_only(self) -> None:
@ -1133,8 +1466,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return pixmap
result = QtGui.QPixmap(pixmap)
painter = QtGui.QPainter(result)
colour = QtGui.QColor(THEMES[self.current_theme]["highlight"])
pen = QtGui.QPen(colour)
color = QtGui.QColor(THEMES[self.current_theme]["highlight"])
pen = QtGui.QPen(color)
pen.setWidth(3)
pen.setCosmetic(True)
pen.setCapStyle(QtCore.Qt.RoundCap)

177
app/qt/pattern_puller.py Normal file
View File

@ -0,0 +1,177 @@
"""Dialog and worker thread for batch downloading CSGOSkins patterns."""
from __future__ import annotations
import re
import time
import urllib.request
import urllib.error
from pathlib import Path
from PySide6 import QtCore, QtWidgets, QtGui
from app.i18n import I18nMixin
import concurrent.futures
class PatternDownloadWorker(QtCore.QThread):
progress = QtCore.Signal(int, int) # current, total
status = QtCore.Signal(str) # textual update
finished = QtCore.Signal(bool) # True if Success, False if Interrupted/Error
error = QtCore.Signal(str) # Error message
def __init__(self, slug: str, save_dir: Path, parent: QtCore.QObject | None = None) -> None:
super().__init__(parent)
self.slug = slug
self.save_dir = save_dir
self.total_seeds = 1000
def _download_seed(self, seed: int) -> tuple[bool, str | None]:
url = f"https://cdn.csgoskins.gg/public/images/patterns/v1/{self.slug}/{seed}.png"
filename = self.save_dir / f"{seed}.png"
if filename.exists():
return True, None
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 ICRA/1.0'})
with urllib.request.urlopen(req, timeout=10) as response:
with open(filename, 'wb') as f:
f.write(response.read())
return True, None
except urllib.error.HTTPError as e:
return False, f"HTTP {e.code}"
except Exception as e:
return False, f"Network error: {e}"
def run(self) -> None:
self.save_dir.mkdir(parents=True, exist_ok=True)
completed = 0
# Validate seed 1 synchronously first to avoid spawning 1000 threads for invalid slugs
success, error_msg = self._download_seed(1)
if not success and error_msg in ("HTTP 403", "HTTP 404"):
self.error.emit(f"Failed to fetch seed 1. Does '{self.slug}' have patterns?")
self.finished.emit(False)
return
completed += 1
self.progress.emit(completed, self.total_seeds)
# Download the rest concurrently
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
future_to_seed = {executor.submit(self._download_seed, seed): seed for seed in range(2, self.total_seeds + 1)}
for future in concurrent.futures.as_completed(future_to_seed):
if self.isInterruptionRequested():
self.status.emit("Download cancelled. Waiting for threads to finish...")
executor.shutdown(wait=False, cancel_futures=True)
self.finished.emit(False)
return
seed = future_to_seed[future]
completed += 1
success, error = future.result()
if success:
self.status.emit(f"Downloaded seed {seed}/{self.total_seeds}")
else:
self.status.emit(f"Skipped seed {seed} ({error})")
self.progress.emit(completed, self.total_seeds)
self.status.emit("Download complete!")
self.finished.emit(True)
class PatternPullerDialog(QtWidgets.QDialog, I18nMixin):
"""Dialog for extracting patterns from CSGOSkins.gg URLs."""
def __init__(self, language: str, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.init_i18n(language)
self.setWindowTitle(self._t("dialog.puller_title", default="Pull Pattern Images"))
self.setMinimumWidth(450)
self._worker: PatternDownloadWorker | None = None
self._build_ui()
def _build_ui(self) -> None:
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(12)
instruction_label = QtWidgets.QLabel(self._t("dialog.puller_instruction", default="Paste a CSGOSkins.gg item URL:"))
layout.addWidget(instruction_label)
self.url_input = QtWidgets.QLineEdit()
self.url_input.setPlaceholderText("https://csgoskins.gg/items/glock-18-trace-lock")
layout.addWidget(self.url_input)
self.status_label = QtWidgets.QLabel("")
self.status_label.setStyleSheet("color: palette(window-text); font-style: italic;")
layout.addWidget(self.status_label)
self.progress_bar = QtWidgets.QProgressBar()
self.progress_bar.setRange(0, 1000)
self.progress_bar.setValue(0)
layout.addWidget(self.progress_bar)
button_layout = QtWidgets.QHBoxLayout()
self.start_btn = QtWidgets.QPushButton(self._t("dialog.puller_start", default="Start Download"))
self.cancel_btn = QtWidgets.QPushButton(self._t("dialog.puller_cancel", default="Cancel"))
self.start_btn.clicked.connect(self._on_start_clicked)
self.cancel_btn.clicked.connect(self._on_cancel_clicked)
button_layout.addWidget(self.start_btn)
button_layout.addWidget(self.cancel_btn)
layout.addLayout(button_layout)
def _extract_slug(self, url: str) -> str | None:
# Match https://csgoskins.gg/items/SLUG
match = re.search(r"csgoskins\.gg/items/([^/?#]+)", url)
if match:
return match.group(1).lower()
return None
def _on_start_clicked(self) -> None:
url = self.url_input.text().strip()
slug = self._extract_slug(url)
if not slug:
QtWidgets.QMessageBox.warning(self, "Error", self._t("dialog.puller_invalid_url", default="Invalid URL format."))
return
save_dir = Path("images") / slug
self.start_btn.setEnabled(False)
self.url_input.setEnabled(False)
self.progress_bar.setValue(0)
self.status_label.setText("Starting download...")
self._worker = PatternDownloadWorker(slug=slug, save_dir=save_dir, parent=self)
self._worker.progress.connect(self._on_progress)
self._worker.status.connect(self.status_label.setText)
self._worker.error.connect(self._on_error)
self._worker.finished.connect(self._on_finished)
self._worker.start()
def _on_cancel_clicked(self) -> None:
if self._worker and self._worker.isRunning():
self._worker.requestInterruption()
self.cancel_btn.setEnabled(False)
self.status_label.setText("Cancelling...")
else:
self.reject() # Close dialog if not downloading
def _on_progress(self, current: int, total: int) -> None:
self.progress_bar.setValue(current)
def _on_error(self, message: str) -> None:
QtWidgets.QMessageBox.warning(self, "Error", message)
def _on_finished(self, success: bool) -> None:
self.start_btn.setEnabled(True)
self.url_input.setEnabled(True)
self.cancel_btn.setEnabled(True)
self._worker = None
if success:
QtWidgets.QMessageBox.information(self, "Done", self._t("dialog.puller_success", default="All patterns downloaded successfully!"))

View File

@ -5,6 +5,8 @@ language = "en"
[options]
# Set to true to clear exclusion shapes whenever the image changes.
reset_exclusions_on_image_change = false
# Hex color code for the match overlay (e.g. "#ff0000" for Red, "#00ff00" for Green)
overlay_color = "#ff0000"
[defaults]
# Override any of the following keys to tweak the initial slider values:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

View File

@ -23,3 +23,8 @@ include = ["app"]
[tool.setuptools.package-data]
"app" = ["assets/logo.png", "lang/*.toml"]
[dependency-groups]
dev = [
"pytest>=9.0.2",
]

View File

@ -0,0 +1,123 @@
import numpy as np
import pytest
from PIL import Image
from app.qt.image_processor import Stats, _rgb_to_hsv_numpy, QtImageProcessor
def test_stats_summary():
s = Stats(
matches_all=50, total_all=100,
matches_keep=40, total_keep=80,
matches_excl=10, total_excl=20
)
# Mock translator
def mock_t(key, **kwargs):
if key == "stats.placeholder":
return "Placeholder"
return f"{kwargs['with_pct']:.1f} {kwargs['without_pct']:.1f} {kwargs['excluded_pct']:.1f} {kwargs['excluded_match_pct']:.1f}"
res = s.summary(mock_t)
# with_pct: 40/80 = 50.0
# without_pct: 50/100 = 50.0
# excluded_pct: 20/100 = 20.0
# excluded_match_pct: 10/20 = 50.0
assert res == "50.0 50.0 20.0 50.0"
def test_stats_empty():
s = Stats()
assert s.summary(lambda k, **kw: "Empty") == "Empty"
def test_rgb_to_hsv_numpy():
# Test red
arr = np.array([[[1.0, 0.0, 0.0]]], dtype=np.float32)
hsv = _rgb_to_hsv_numpy(arr)
assert np.allclose(hsv[0, 0], [0.0, 100.0, 100.0])
# Test green
arr = np.array([[[0.0, 1.0, 0.0]]], dtype=np.float32)
hsv = _rgb_to_hsv_numpy(arr)
assert np.allclose(hsv[0, 0], [120.0, 100.0, 100.0])
# Test blue
arr = np.array([[[0.0, 0.0, 1.0]]], dtype=np.float32)
hsv = _rgb_to_hsv_numpy(arr)
assert np.allclose(hsv[0, 0], [240.0, 100.0, 100.0])
# Test white
arr = np.array([[[1.0, 1.0, 1.0]]], dtype=np.float32)
hsv = _rgb_to_hsv_numpy(arr)
assert np.allclose(hsv[0, 0], [0.0, 0.0, 100.0])
# Test black
arr = np.array([[[0.0, 0.0, 0.0]]], dtype=np.float32)
hsv = _rgb_to_hsv_numpy(arr)
assert np.allclose(hsv[0, 0], [0.0, 0.0, 0.0])
def test_qt_processor_matches_legacy():
proc = QtImageProcessor()
proc.hue_min = 350
proc.hue_max = 10
proc.sat_min = 50
proc.val_min = 50
proc.val_max = 100
# Red wraps around 360, so H=0 -> ok
assert proc._matches(255, 0, 0) is True
# Green H=120 -> fail
assert proc._matches(0, 255, 0) is False
# Dark red S=100, V=25 -> fail because val_min=50
assert proc._matches(64, 0, 0) is False
def test_set_overlay_color():
proc = QtImageProcessor()
# default red
assert proc.overlay_r == 255
assert proc.overlay_g == 0
assert proc.overlay_b == 0
proc.set_overlay_color("#00ff00")
assert proc.overlay_r == 0
assert proc.overlay_g == 255
assert proc.overlay_b == 0
# invalid hex does nothing
proc.set_overlay_color("blue")
assert proc.overlay_r == 0
def test_coordinate_scaling():
proc = QtImageProcessor()
# Create a 200x200 image where everything is red
red_img_small = Image.new("RGBA", (200, 200), (255, 0, 0, 255))
proc.orig_img = red_img_small # satisfy preview logic
proc.preview_img = red_img_small
# All red. Thresholds cover all red.
proc.hue_min = 0
proc.hue_max = 360
proc.sat_min = 10
proc.val_min = 10
# Exclude the right half (100-200)
proc.set_exclusions([{"kind": "rect", "coords": (100, 0, 200, 200)}])
# Verify small stats
s_small = proc.get_stats_headless(red_img_small)
# total=40000, keep=20000, excl=20000
assert s_small.total_all == 40000
assert s_small.total_keep == 20000
assert s_small.total_excl == 20000
# Now check on a 1000x1000 image (5x scale)
red_img_large = Image.new("RGBA", (1000, 1000), (255, 0, 0, 255))
s_large = proc.get_stats_headless(red_img_large)
# total=1,000,000. If scaling works, keep=500,000, excl=500,000.
# If scaling FAILED, the mask is still 100x200 (20,000 px) -> excl=20,000.
assert s_large.total_all == 1000000
assert s_large.total_keep == 500000
assert s_large.total_excl == 500000