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_* *.merge_file_*
.git/modules/ .git/modules/
.git/worktrees/ .git/worktrees/
# ICRA specific
images/

View File

@ -1,24 +1,29 @@
<div style="display:flex; gap:16px; align-items:center;"> <div style="display:flex; gap:16px; align-items:center;">
<img src="app/assets/logo.png" alt="ICRA" width="140"/> <img src="app/assets/logo.png" alt="ICRA" width="140"/>
<div> <div>
<strong>Interactive Color Range Analyzer</strong> is being reimagined with a <em>PySide6</em> user interface.<br/> <strong>Interactive Color Range Analyzer (ICRA)</strong><br/>
This branch focuses on building a native desktop shell with modern window controls before porting the colour-analysis features. A fully-featured, PySide6-powered desktop application for precise color matching, image analysis, and statistics generation.
</div> </div>
</div> </div>
## Current prototype ## Features
- Custom frameless window with minimise / maximise / close controls that hook into Windows natively - **High-Performance Image Processing:** Native, vectorized NumPy operations for lightning-fast HSV conversion and color matching.
- Dark themed layout and basic image preview powered by Qt - **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).
- “Open image” workflow that displays the selected asset scaled to the viewport - **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`.
> ⚠️ Legacy Tk features (sliders, exclusions, folder navigation, stats) are not wired up yet. The goal here is to validate the PySide6 shell first. - **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 ## Requirements
- Python 3.11+ - Python 3.11+
- [uv](https://github.com/astral-sh/uv) for dependency management - [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 ```bash
git clone https://git.lukasmahler.de/lm/ICRA.git git clone https://git.lukasmahler.de/lm/ICRA.git
cd ICRA cd ICRA
@ -28,24 +33,25 @@ uv pip install .
uv run icra 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) ## Project Layout
1. Port hue/saturation/value controls to Qt widgets
2. Re-implement exclusion drawing using QPainter overlays
3. Integrate existing image-processing logic (`app/logic`) with the new UI
## Project layout
``` ```
app/ app/
assets/ # Shared branding assets/ # Shared branding
gui/, logic/ # Legacy Tk code kept for reference lang/ # Localization strings (TOML format)
qt/ # New PySide6 implementation (main_window, app bootstrap) logic/ # Configuration loading and application constants
config.toml # Historical defaults (unused in the prototype) 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 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` - Quick syntax check: `uv run python -m compileall app main.py`
- Uploaded images are not persisted; the preview uses Qt pixmaps only. - Contributions welcome!
- Contributions welcome—please target this branch with PySide6-specific improvements.

View File

@ -2,14 +2,8 @@
from __future__ import annotations 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 from .qt import create_application as create_qt_app, run as run_qt_app
start_app = 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.undo_exclude" = "Letzten Ausschluss entfernen"
"toolbar.reset_sliders" = "Slider zurücksetzen" "toolbar.reset_sliders" = "Slider zurücksetzen"
"toolbar.toggle_theme" = "Theme umschalten" "toolbar.toggle_theme" = "Theme umschalten"
"toolbar.open_app_folder" = "Programmordner öffnen"
"toolbar.prefer_dark" = "Dunkelheit bevorzugen"
"status.no_file" = "Keine Datei geladen." "status.no_file" = "Keine Datei geladen."
"status.defaults_restored" = "Standardwerte aktiv." "status.defaults_restored" = "Standardwerte aktiv."
"status.free_draw_enabled" = "Freihand-Ausschluss aktiviert." "status.free_draw_enabled" = "Freihand-Ausschluss aktiviert."
@ -17,7 +19,7 @@
"status.loaded" = "Geladen: {name} — {dimensions}{position}" "status.loaded" = "Geladen: {name} — {dimensions}{position}"
"status.filename_label" = "{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.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_ready" = "Pick-Modus: Klicke links ins Bild, um Farbe zu wählen (Esc beendet)"
"status.pick_mode_ended" = "Pick-Modus 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}%" "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.val_max" = "Helligkeit Max (%)"
"sliders.alpha" = "Overlay Alpha" "sliders.alpha" = "Overlay Alpha"
"stats.placeholder" = "Markierungen (mit Ausschlüssen): —" "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" "menu.copy" = "Kopieren"
"dialog.info_title" = "Info" "dialog.info_title" = "Info"
"dialog.error_title" = "Fehler" "dialog.error_title" = "Fehler"
@ -49,7 +53,7 @@
"dialog.open_image_title" = "Bild wählen" "dialog.open_image_title" = "Bild wählen"
"dialog.open_folder_title" = "Ordner mit Bildern wählen" "dialog.open_folder_title" = "Ordner mit Bildern wählen"
"dialog.save_overlay_title" = "Overlay speichern als" "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.images_filter" = "Bilder"
"dialog.folder_not_found" = "Der Ordner wurde nicht gefunden." "dialog.folder_not_found" = "Der Ordner wurde nicht gefunden."
"dialog.folder_empty" = "Keine unterstützten Bilder im Ordner gefunden." "dialog.folder_empty" = "Keine unterstützten Bilder im Ordner gefunden."
@ -59,4 +63,28 @@
"dialog.no_image_loaded" = "Kein Bild geladen." "dialog.no_image_loaded" = "Kein Bild geladen."
"dialog.no_preview_available" = "Keine Preview vorhanden." "dialog.no_preview_available" = "Keine Preview vorhanden."
"dialog.overlay_saved" = "Overlay gespeichert: {path}" "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.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" "app.title" = "Interactive Color Range Analyzer"
"toolbar.open_image" = "Open image" "toolbar.open_image" = "Open image"
"toolbar.open_folder" = "Open folder" "toolbar.open_folder" = "Open folder"
"toolbar.choose_color" = "Choose colour" "toolbar.choose_color" = "Choose color"
"toolbar.pick_from_image" = "Pick from image" "toolbar.pick_from_image" = "Pick from image"
"toolbar.save_overlay" = "Save overlay" "toolbar.save_overlay" = "Save overlay"
"toolbar.clear_excludes" = "Clear exclusions" "toolbar.clear_excludes" = "Clear exclusions"
@ -10,19 +10,21 @@
"toolbar.undo_exclude" = "Undo last exclusion" "toolbar.undo_exclude" = "Undo last exclusion"
"toolbar.reset_sliders" = "Reset sliders" "toolbar.reset_sliders" = "Reset sliders"
"toolbar.toggle_theme" = "Toggle theme" "toolbar.toggle_theme" = "Toggle theme"
"toolbar.open_app_folder" = "Open application folder"
"toolbar.prefer_dark" = "Prefer darkness"
"status.no_file" = "No file loaded." "status.no_file" = "No file loaded."
"status.defaults_restored" = "Defaults restored." "status.defaults_restored" = "Defaults restored."
"status.free_draw_enabled" = "Free-draw exclusion mode enabled." "status.free_draw_enabled" = "Free-draw exclusion mode enabled."
"status.free_draw_disabled" = "Rectangle exclusion mode enabled." "status.free_draw_disabled" = "Rectangle exclusion mode enabled."
"status.loaded" = "Loaded: {name} — {dimensions}{position}" "status.loaded" = "Loaded: {name} — {dimensions}{position}"
"status.filename_label" = "{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.color_selected" = "Color 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.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 colour (Esc exits)" "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_ended" = "Pick mode ended."
"status.pick_mode_from_image" = "Colour picked from image: Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%" "status.pick_mode_from_image" = "Color picked from image: Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"palette.current" = "Colour:" "palette.current" = "Color:"
"palette.more" = "More colours:" "palette.more" = "More colors:"
"palette.swatch.red" = "Red" "palette.swatch.red" = "Red"
"palette.swatch.orange" = "Orange" "palette.swatch.orange" = "Orange"
"palette.swatch.yellow" = "Yellow" "palette.swatch.yellow" = "Yellow"
@ -41,7 +43,9 @@
"sliders.val_max" = "Value max (%)" "sliders.val_max" = "Value max (%)"
"sliders.alpha" = "Overlay alpha" "sliders.alpha" = "Overlay alpha"
"stats.placeholder" = "Matches (with exclusions): —" "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" "menu.copy" = "Copy"
"dialog.info_title" = "Info" "dialog.info_title" = "Info"
"dialog.error_title" = "Error" "dialog.error_title" = "Error"
@ -49,7 +53,7 @@
"dialog.open_image_title" = "Select image" "dialog.open_image_title" = "Select image"
"dialog.open_folder_title" = "Select folder" "dialog.open_folder_title" = "Select folder"
"dialog.save_overlay_title" = "Save overlay as" "dialog.save_overlay_title" = "Save overlay as"
"dialog.choose_colour_title" = "Choose colour" "dialog.choose_color_title" = "Choose color"
"dialog.images_filter" = "Images" "dialog.images_filter" = "Images"
"dialog.folder_not_found" = "The folder could not be found." "dialog.folder_not_found" = "The folder could not be found."
"dialog.folder_empty" = "No supported images were found in the folder." "dialog.folder_empty" = "No supported images were found in the folder."
@ -59,4 +63,28 @@
"dialog.no_image_loaded" = "No image loaded." "dialog.no_image_loaded" = "No image loaded."
"dialog.no_preview_available" = "No preview available." "dialog.no_preview_available" = "No preview available."
"dialog.overlay_saved" = "Overlay saved: {path}" "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.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 ( from .constants import (
BASE_DIR, BASE_DIR,
DEFAULTS, DEFAULTS,
IMAGES_DIR, IMAGES_DIR,
LANGUAGE, LANGUAGE,
OVERLAY_COLOR,
PREVIEW_MAX_SIZE, PREVIEW_MAX_SIZE,
RESET_EXCLUSIONS_ON_IMAGE_CHANGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_IMAGE_EXTENSIONS,
) )
from .image_processing import ImageProcessingMixin
from .reset import ResetMixin
__all__ = [ __all__ = [
"BASE_DIR", "BASE_DIR",
"DEFAULTS", "DEFAULTS",
"IMAGES_DIR", "IMAGES_DIR",
"LANGUAGE", "LANGUAGE",
"OVERLAY_COLOR",
"PREVIEW_MAX_SIZE", "PREVIEW_MAX_SIZE",
"RESET_EXCLUSIONS_ON_IMAGE_CHANGE", "RESET_EXCLUSIONS_ON_IMAGE_CHANGE",
"SUPPORTED_IMAGE_EXTENSIONS", "SUPPORTED_IMAGE_EXTENSIONS",
"ImageProcessingMixin",
"ResetMixin",
] ]

View File

@ -94,7 +94,7 @@ def _extract_language(data: dict[str, Any]) -> str:
_CONFIG_DATA = _load_config_data() _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]: 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") value = section.get("reset_exclusions_on_image_change")
if isinstance(value, bool): if isinstance(value, bool):
result["reset_exclusions_on_image_change"] = value 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 return result
@ -112,3 +115,4 @@ DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)}
LANGUAGE = _extract_language(_CONFIG_DATA) LANGUAGE = _extract_language(_CONFIG_DATA)
OPTIONS = {**_OPTION_DEFAULTS, **_extract_options(_CONFIG_DATA)} OPTIONS = {**_OPTION_DEFAULTS, **_extract_options(_CONFIG_DATA)}
RESET_EXCLUSIONS_ON_IMAGE_CHANGE = OPTIONS["reset_exclusions_on_image_change"] 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 import sys
from pathlib import Path 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 app.logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE
from .main_window import MainWindow from .main_window import MainWindow
@ -46,16 +46,21 @@ def create_application() -> QtWidgets.QApplication:
def run() -> int: def run() -> int:
"""Run the PySide6 GUI.""" """Run the PySide6 GUI."""
app = create_application() app = create_application()
from app.logic import OVERLAY_COLOR
window = MainWindow( window = MainWindow(
language=LANGUAGE, language=LANGUAGE,
defaults=DEFAULTS.copy(), defaults=DEFAULTS.copy(),
reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE, reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
overlay_color=OVERLAY_COLOR,
) )
primary_screen = app.primaryScreen()
if primary_screen is not None: # Respect saved geometry from QSettings; fall back to maximised on first launch
geometry = primary_screen.availableGeometry() settings = QtCore.QSettings("ICRA", "MainWindow")
window.setGeometry(geometry) if settings.value("geometry"):
window.showMaximized() window.show()
else: else:
primary_screen = app.primaryScreen()
if primary_screen is not None:
window.setGeometry(primary_screen.availableGeometry())
window.showMaximized() window.showMaximized()
return app.exec() return app.exec()

View File

@ -22,6 +22,20 @@ class Stats:
total_keep: int = 0 total_keep: int = 0
matches_excl: int = 0 matches_excl: int = 0
total_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: def summary(self, translate) -> str:
if self.total_all == 0: 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 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 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_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( return translate(
"stats.summary", "stats.summary",
score=self.composite_score,
with_pct=with_pct, with_pct=with_pct,
without_pct=without_pct, without_pct=without_pct,
brightness_label=brightness_label,
brightness=self.effective_brightness,
excluded_pct=excluded_pct, 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 v = cmax
# Saturation # 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 # Hue
h = np.zeros_like(r) h = np.zeros_like(r)
@ -80,6 +97,11 @@ class QtImageProcessor:
self.current_index: int = -1 self.current_index: int = -1
self.stats = Stats() self.stats = Stats()
# Overlay tint color
self.overlay_r = 255
self.overlay_g = 0
self.overlay_b = 0
self.defaults: Dict[str, int] = { self.defaults: Dict[str, int] = {
"hue_min": 0, "hue_min": 0,
"hue_max": 360, "hue_max": 360,
@ -98,6 +120,12 @@ class QtImageProcessor:
self.exclude_shapes: list[dict[str, object]] = [] self.exclude_shapes: list[dict[str, object]] = []
self.reset_exclusions_on_switch: bool = False 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: def set_defaults(self, defaults: dict) -> None:
for key in self.defaults: for key in self.defaults:
if key in defaults: if key in defaults:
@ -163,7 +191,7 @@ class QtImageProcessor:
self.preview_img = self.orig_img.resize(size, Image.LANCZOS) self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
def _rebuild_overlay(self) -> None: 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: if self.preview_img is None:
self.overlay_img = None self.overlay_img = None
self.stats = Stats() self.stats = Stats()
@ -210,9 +238,15 @@ class QtImageProcessor:
matches_excl = int(excl_match[visible].sum()) matches_excl = int(excl_match[visible].sum())
total_excl = int((visible & excl_mask).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 # Build overlay image
overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8) 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) overlay_arr[keep_match, 3] = int(self.alpha)
self.overlay_img = Image.fromarray(overlay_arr, "RGBA") self.overlay_img = Image.fromarray(overlay_arr, "RGBA")
@ -223,6 +257,58 @@ class QtImageProcessor:
total_keep=total_keep, total_keep=total_keep,
matches_excl=matches_excl, matches_excl=matches_excl,
total_excl=total_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 ---------------------------------------------------------------- # helpers ----------------------------------------------------------------
@ -239,7 +325,7 @@ class QtImageProcessor:
val_ok = self.val_min <= v * 100.0 <= self.val_max val_ok = self.val_min <= v * 100.0 <= self.val_max
return hue_ok and sat_ok and val_ok 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.""" """Return (hue°, sat%, val%) of the preview pixel at (x, y), or None."""
if self.preview_img is None: if self.preview_img is None:
return None return None
@ -259,8 +345,10 @@ class QtImageProcessor:
return self._to_pixmap(self.preview_img) return self._to_pixmap(self.preview_img)
def overlay_pixmap(self) -> QtGui.QPixmap: 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() 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) merged = Image.alpha_composite(self.preview_img.convert("RGBA"), self.overlay_img)
return self._to_pixmap(merged) return self._to_pixmap(merged)
@ -274,7 +362,7 @@ class QtImageProcessor:
# exclusions ------------------------------------------------------------- # 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]] = [] copied: list[dict[str, object]] = []
for shape in shapes: for shape in shapes:
kind = shape.get("kind") kind = shape.get("kind")
@ -285,30 +373,69 @@ class QtImageProcessor:
pts = shape.get("points", []) pts = shape.get("points", [])
copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]}) copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]})
self.exclude_shapes = copied 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() self._rebuild_overlay()
def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None: def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None:
if not self.exclude_shapes: if not self.exclude_shapes:
return None 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) mask = Image.new("L", size, 0)
draw = ImageDraw.Draw(mask) draw = ImageDraw.Draw(mask)
for shape in self.exclude_shapes: for shape in self.exclude_shapes:
kind = shape.get("kind") kind = shape.get("kind")
if kind == "rect": if kind == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index] 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": elif kind == "polygon":
points = shape.get("points", []) points = shape.get("points", [])
if len(points) >= 3: 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 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: def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray:
"""Return a boolean (H, W) mask — True where pixels are excluded.""" """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 w, h = size
if not self.exclude_shapes: if not self.exclude_shapes:
return np.zeros((h, w), dtype=bool) mask = np.zeros((h, w), dtype=bool)
pil_mask = self._build_exclusion_mask(size) else:
if pil_mask is None: pil_mask = self._build_exclusion_mask(size)
return np.zeros((h, w), dtype=bool) if pil_mask is None:
return np.asarray(pil_mask, dtype=bool) 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.""" """Main PySide6 window emulating the legacy Tk interface with translations and themes."""
from __future__ import annotations 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 pathlib import Path
from typing import Callable, Dict, List, Tuple from typing import Callable, Dict, List, Tuple
from PIL import Image
from PySide6 import QtCore, QtGui, QtWidgets from PySide6 import QtCore, QtGui, QtWidgets
from app.i18n import I18nMixin from app.i18n import I18nMixin
from app.logic import SUPPORTED_IMAGE_EXTENSIONS from app.logic import SUPPORTED_IMAGE_EXTENSIONS
from .image_processor import QtImageProcessor 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.red", "#ff3b30"),
("palette.swatch.orange", "#ff9500"), ("palette.swatch.orange", "#ff9500"),
("palette.swatch.yellow", "#ffd60a"), ("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): class ColorSwatch(QtWidgets.QPushButton):
text = f"{icon_text} {label}"
super().__init__(text, parent)
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedHeight(32)
metrics = QtGui.QFontMetrics(self.font())
width = metrics.horizontalAdvance(text) + 28
self.setMinimumWidth(width)
self.clicked.connect(callback)
def apply_theme(self, colours: Dict[str, str]) -> None:
self.setStyleSheet(
f"""
QPushButton {{
padding: 8px 16px;
border-radius: 10px;
border: 1px solid {colours['border']};
background-color: rgba(255, 255, 255, 0.04);
color: {colours['text']};
font-weight: 600;
}}
QPushButton:hover {{
background-color: rgba(255, 255, 255, 0.12);
}}
QPushButton:pressed {{
background-color: rgba(255, 255, 255, 0.18);
}}
"""
)
class ColourSwatch(QtWidgets.QPushButton):
"""Clickable palette swatch.""" """Clickable palette swatch."""
def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None): 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.callback = callback
self.setCursor(QtCore.Qt.PointingHandCursor) self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedSize(28, 28) self.setFixedSize(28, 28)
self._apply_colour(hex_code) self._apply_color(hex_code)
self.clicked.connect(lambda: callback(hex_code, self.name_key)) 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( self.setStyleSheet(
f""" f"""
QPushButton {{ 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( self.setStyleSheet(
f""" f"""
QPushButton {{ QPushButton {{
background-color: {self.hex_code}; background-color: {self.hex_code};
border: 2px solid {colours['border']}; border: 2px solid {colors['border']};
border-radius: 6px; border-radius: 6px;
}} }}
QPushButton:hover {{ QPushButton:hover {{
border-color: {colours['accent']}; border-color: {colors['accent']};
}} }}
""" """
) )
@ -194,22 +171,22 @@ class SliderControl(QtWidgets.QWidget):
self.slider.blockSignals(False) self.slider.blockSignals(False)
self.value_edit.setText(str(value)) self.value_edit.setText(str(value))
def apply_theme(self, colours: Dict[str, str]) -> None: def apply_theme(self, colors: Dict[str, str]) -> None:
self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.title_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.value_edit.setStyleSheet( self.value_edit.setStyleSheet(
f"color: {colours['text_dim']}; background: transparent; " f"color: {colors['text_dim']}; background: transparent; "
f"border: 1px solid {colours['border']}; border-radius: 4px; padding: 0 2px;" f"border: 1px solid {colors['border']}; border-radius: 4px; padding: 0 2px;"
) )
self.slider.setStyleSheet( self.slider.setStyleSheet(
f""" f"""
QSlider::groove:horizontal {{ QSlider::groove:horizontal {{
border: 1px solid {colours['border']}; border: 1px solid {colors['border']};
height: 6px; height: 6px;
background: rgba(255,255,255,0.14); background: rgba(255,255,255,0.14);
border-radius: 4px; border-radius: 4px;
}} }}
QSlider::handle:horizontal {{ QSlider::handle:horizontal {{
background: {colours['accent_secondary']}; background: {colors['accent_secondary']};
border: 1px solid rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.2);
width: 14px; width: 14px;
margin: -5px 0; margin: -5px 0;
@ -271,8 +248,8 @@ class CanvasView(QtWidgets.QGraphicsView):
def set_mode(self, mode: str) -> None: def set_mode(self, mode: str) -> None:
self.mode = mode self.mode = mode
def set_accent(self, colour: str) -> None: def set_accent(self, color: str) -> None:
self._accent = QtGui.QColor(colour) self._accent = QtGui.QColor(color)
self._redraw_shapes() self._redraw_shapes()
def undo_last(self) -> None: def undo_last(self) -> None:
@ -399,7 +376,7 @@ class CanvasView(QtWidgets.QGraphicsView):
class OverlayCanvas(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: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -488,17 +465,17 @@ class TitleBar(QtWidgets.QWidget):
) )
return btn return btn
def apply_theme(self, colours: Dict[str, str]) -> None: def apply_theme(self, colors: Dict[str, str]) -> None:
palette = self.palette() 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.setPalette(palette)
self.title_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;") self.title_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;")
hover_bg = "#d0342c" if colours["titlebar_bg"] != "#e9ebf5" else "#e6675a" hover_bg = "#d0342c" if colors["titlebar_bg"] != "#e9ebf5" else "#e6675a"
self.close_btn.setStyleSheet( self.close_btn.setStyleSheet(
f""" f"""
QPushButton {{ QPushButton {{
background-color: transparent; background-color: transparent;
color: {colours['text']}; color: {colors['text']};
border: none; border: none;
padding: 4px 10px; padding: 4px 10px;
}} }}
@ -513,7 +490,7 @@ class TitleBar(QtWidgets.QWidget):
f""" f"""
QPushButton {{ QPushButton {{
background-color: transparent; background-color: transparent;
color: {colours['text']}; color: {colors['text']};
border: none; border: none;
padding: 4px 10px; padding: 4px 10px;
}} }}
@ -538,7 +515,7 @@ class TitleBar(QtWidgets.QWidget):
class MainWindow(QtWidgets.QMainWindow, I18nMixin): class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"""Main application window containing all controls.""" """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__() super().__init__()
self.init_i18n(language) self.init_i18n(language)
self.setWindowTitle(self._t("app.title")) self.setWindowTitle(self._t("app.title"))
@ -560,12 +537,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.processor = QtImageProcessor() self.processor = QtImageProcessor()
self.processor.set_defaults(defaults) self.processor.set_defaults(defaults)
self.processor.reset_exclusions_on_switch = reset_exclusions 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 = 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.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_palette())
self.content_layout.addLayout(self._build_sliders()) self.content_layout.addLayout(self._build_sliders())
self.content_layout.addWidget(self._build_previews(), 1) self.content_layout.addWidget(self._build_previews(), 1)
@ -576,7 +556,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._is_maximised = False self._is_maximised = False
self._current_image_path: Path | None = None 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._toolbar_actions: Dict[str, Callable[[], None]] = {}
self._register_default_actions() self._register_default_actions()
@ -587,7 +567,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.image_view.pixel_clicked.connect(self._on_pixel_picked) self.image_view.pixel_clicked.connect(self._on_pixel_picked)
self._sync_sliders_from_processor() 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.current_theme = "dark"
self._apply_theme(self.current_theme) self._apply_theme(self.current_theme)
@ -598,6 +578,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# Keyboard shortcuts # Keyboard shortcuts
self._setup_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 # Restore window geometry
self._settings = QtCore.QSettings("ICRA", "MainWindow") self._settings = QtCore.QSettings("ICRA", "MainWindow")
geometry = self._settings.value("geometry") geometry = self._settings.value("geometry")
@ -626,33 +612,55 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# UI builders ------------------------------------------------------------ # UI builders ------------------------------------------------------------
def _build_toolbar(self) -> QtWidgets.QHBoxLayout: def _build_menu_bar(self) -> QtWidgets.QMenuBar:
layout = QtWidgets.QHBoxLayout() self.menu_bar = QtWidgets.QMenuBar(self)
layout.setSpacing(12)
buttons = [ # File Menu
("open_image", "🖼", "toolbar.open_image"), file_menu = self.menu_bar.addMenu(self._t("menu.file"))
("open_folder", "📂", "toolbar.open_folder"), file_menu.addAction("🖼 " + self._t("toolbar.open_image"), lambda: self._invoke_action("open_image"), "Ctrl+O")
("choose_color", "🎨", "toolbar.choose_color"), file_menu.addSeparator()
("pick_from_image", "🖱", "toolbar.pick_from_image"), file_menu.addAction("📂 " + self._t("toolbar.open_folder"), lambda: self._invoke_action("open_folder"), "Ctrl+Shift+O")
("save_overlay", "💾", "toolbar.save_overlay"), file_menu.addAction("📊 " + self._t("toolbar.export_folder"), lambda: self._invoke_action("export_folder"))
("toggle_free_draw", "", "toolbar.toggle_free_draw"), file_menu.addSeparator()
("clear_excludes", "🧹", "toolbar.clear_excludes"), file_menu.addAction("📤 " + self._t("toolbar.export_settings"), lambda: self._invoke_action("export_settings"), "Ctrl+E")
("undo_exclude", "", "toolbar.undo_exclude"), file_menu.addAction("📥 " + self._t("toolbar.import_settings"), lambda: self._invoke_action("import_settings"), "Ctrl+I")
("reset_sliders", "🔄", "toolbar.reset_sliders"), file_menu.addSeparator()
("toggle_theme", "🌓", "toolbar.toggle_theme"), file_menu.addAction("💾 " + self._t("toolbar.save_overlay"), lambda: self._invoke_action("save_overlay"), "Ctrl+S")
]
self._toolbar_buttons: Dict[str, ToolbarButton] = {}
for key, icon_txt, text_key in buttons:
label = self._t(text_key)
button = ToolbarButton(icon_txt, label, lambda _checked=False, k=key: self._invoke_action(k))
layout.addWidget(button)
self._toolbar_buttons[key] = button
layout.addStretch(1) # 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")) self.status_label = QtWidgets.QLabel(self._t("status.no_file"))
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight) return self.menu_bar
return layout
def _build_palette(self) -> QtWidgets.QHBoxLayout: def _build_palette(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout() layout = QtWidgets.QHBoxLayout()
@ -664,13 +672,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.current_label = QtWidgets.QLabel(self._t("palette.current")) self.current_label = QtWidgets.QLabel(self._t("palette.current"))
current_group.addWidget(self.current_label) current_group.addWidget(self.current_label)
self.current_colour_swatch = QtWidgets.QLabel() self.current_color_swatch = QtWidgets.QLabel()
self.current_colour_swatch.setFixedSize(28, 28) self.current_color_swatch.setFixedSize(28, 28)
self.current_colour_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOUR}; border-radius: 6px;") self.current_color_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOR}; border-radius: 6px;")
current_group.addWidget(self.current_colour_swatch) current_group.addWidget(self.current_color_swatch)
self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})") self.current_color_label = QtWidgets.QLabel(f"({DEFAULT_COLOR})")
current_group.addWidget(self.current_colour_label) current_group.addWidget(self.current_color_label)
layout.addLayout(current_group) layout.addLayout(current_group)
self.more_label = QtWidgets.QLabel(self._t("palette.more")) self.more_label = QtWidgets.QLabel(self._t("palette.more"))
@ -678,13 +686,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
swatch_container = QtWidgets.QHBoxLayout() swatch_container = QtWidgets.QHBoxLayout()
swatch_container.setSpacing(8) swatch_container.setSpacing(8)
self.swatch_buttons: List[ColourSwatch] = [] self.swatch_buttons: List[ColorSwatch] = []
for name_key, hex_code in PRESET_COLOURS: for name_key, hex_code in PRESET_COLORS:
swatch = ColourSwatch(self._t(name_key), hex_code, self._update_colour_display) swatch = ColorSwatch(self._t(name_key), hex_code, self._update_color_display)
swatch_container.addWidget(swatch) swatch_container.addWidget(swatch)
self.swatch_buttons.append(swatch) self.swatch_buttons.append(swatch)
layout.addLayout(swatch_container) layout.addLayout(swatch_container)
layout.addStretch(1) layout.addStretch(1)
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout return layout
def _build_sliders(self) -> QtWidgets.QHBoxLayout: def _build_sliders(self) -> QtWidgets.QHBoxLayout:
@ -739,9 +749,38 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
layout.setSpacing(8) layout.setSpacing(8)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
self.filename_label = QtWidgets.QLabel("") # Status row container
self.filename_label.setAlignment(QtCore.Qt.AlignCenter) status_row_layout = QtWidgets.QHBoxLayout()
layout.addWidget(self.filename_label) 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 = QtWidgets.QLabel(self._t("stats.placeholder"))
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
@ -754,16 +793,22 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._toolbar_actions = { self._toolbar_actions = {
"open_image": self.open_image, "open_image": self.open_image,
"open_folder": self.open_folder, "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, "pick_from_image": self.pick_from_image,
"save_overlay": self.save_overlay, "save_overlay": self.save_overlay,
"export_settings": self.export_settings,
"import_settings": self.import_settings,
"toggle_free_draw": self.toggle_free_draw, "toggle_free_draw": self.toggle_free_draw,
"clear_excludes": self.clear_exclusions, "clear_excludes": self.clear_exclusions,
"undo_exclude": self.undo_exclusion, "undo_exclude": self.undo_exclusion,
"reset_sliders": self._reset_sliders, "reset_sliders": self._reset_sliders,
"toggle_theme": self.toggle_theme, "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_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image, "show_next_image": self.show_next_image,
"pull_patterns": self.open_pattern_puller,
} }
def _invoke_action(self, key: str) -> None: def _invoke_action(self, key: str) -> None:
@ -771,11 +816,47 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
if action: if action:
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 --------------------------------------------------------- # Image handling ---------------------------------------------------------
def open_image(self) -> None: def open_image(self) -> None:
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" 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: if not path_str:
return return
path = Path(path_str) path = Path(path_str)
@ -791,7 +872,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._refresh_views() self._refresh_views()
def open_folder(self) -> None: 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: if not directory:
return return
folder = Path(directory) folder = Path(directory)
@ -810,6 +892,185 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._current_image_path = loaded_path self._current_image_path = loaded_path
self._refresh_views() 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: def show_previous_image(self) -> None:
if not self.processor.preview_paths: if not self.processor.preview_paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) 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 ---------------------------------------------------------------- # Helpers ----------------------------------------------------------------
def _update_colour_display(self, hex_code: str, label: str) -> None: def _update_color_display(self, hex_code: str, label: str) -> None:
self._current_colour = hex_code self._current_color = hex_code
self.current_colour_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;") self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
self.current_colour_label.setText(f"({hex_code})") self.current_color_label.setText(f"({hex_code})")
self.status_label.setText(f"{label}: {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: def _on_slider_change(self, key: str, value: int) -> None:
self.processor.set_threshold(key, value) self.processor.set_threshold(key, value)
label = self._slider_title(key) label = self._slider_title(key)
self.status_label.setText(f"{label}: {value}") self.status_label.setText(f"{label}: {value}")
self._refresh_overlay_only() self._slider_timer.start()
def _reset_sliders(self) -> None: def _reset_sliders(self) -> None:
for _, attr, _, _ in SLIDER_SPECS: for _, attr, _, _ in SLIDER_SPECS:
@ -904,7 +1167,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.status_label.setText(self._t("status.pick_mode_ended")) self.status_label.setText(self._t("status.pick_mode_ended"))
def _on_pixel_picked(self, x: int, y: int) -> None: 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: if result is None:
self._exit_pick_mode() self._exit_pick_mode()
return return
@ -927,21 +1190,24 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
ctrl.set_value(value) ctrl.set_value(value)
self.processor.set_threshold(attr, 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 h_norm = hue / 360.0
s_norm = sat / 100.0 s_norm = sat / 100.0
v_norm = val / 100.0 v_norm = val / 100.0
import colorsys import colorsys
r, g, b = colorsys.hsv_to_rgb(h_norm, s_norm, v_norm) 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)) 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.status_label.setText(
self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val) self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val)
) )
self._exit_pick_mode()
self._refresh_overlay_only() self._refresh_overlay_only()
def open_pattern_puller(self) -> None:
dialog = PatternPullerDialog(self.language, parent=self)
dialog.exec()
# Drag-and-drop ---------------------------------------------------------- # Drag-and-drop ----------------------------------------------------------
def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None: def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None:
@ -988,12 +1254,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._settings.setValue("geometry", self.saveGeometry()) self._settings.setValue("geometry", self.saveGeometry())
super().closeEvent(event) super().closeEvent(event)
def choose_colour(self) -> None: def choose_color(self) -> None:
colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title")) color = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_color_title"))
if not colour.isValid(): if not color.isValid():
return return
hex_code = colour.name() hex_code = color.name()
self._update_colour_display(hex_code, self._t("dialog.choose_colour_title")) self._update_color_display(hex_code, self._t("dialog.choose_color_title"))
def save_overlay(self) -> None: def save_overlay(self) -> None:
pixmap = self.processor.overlay_pixmap() pixmap = self.processor.overlay_pixmap()
@ -1016,9 +1282,17 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def toggle_free_draw(self) -> None: def toggle_free_draw(self) -> None:
self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect" self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect"
self.image_view.set_mode(self.exclude_mode) 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" message_key = "status.free_draw_enabled" if self.exclude_mode == "free" else "status.free_draw_disabled"
self.status_label.setText(self._t(message_key)) 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: def clear_exclusions(self) -> None:
self.image_view.clear_shapes() self.image_view.clear_shapes()
self.processor.set_exclusions([]) 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.current_theme = "light" if self.current_theme == "dark" else "dark"
self._apply_theme(self.current_theme) 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: def _apply_theme(self, mode: str) -> None:
colours = THEMES[mode] colors = THEMES[mode]
self.content.setStyleSheet(f"background-color: {colours['window_bg']};") self.content.setStyleSheet(f"background-color: {colors['window_bg']};")
self.image_view.setStyleSheet( 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( 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.status_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.current_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.current_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.current_colour_label.setStyleSheet(f"color: {colours['text_dim']};") self.current_color_label.setStyleSheet(f"color: {colors['text_dim']};")
self.more_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.more_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.filename_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;") self.filename_prefix_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
self.ratio_label.setStyleSheet(f"color: {colours['highlight']}; font-weight: 600;") 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: for swatch in self.swatch_buttons:
swatch.apply_theme(colours) swatch.apply_theme(colors)
for control in self._slider_controls.values(): 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.prev_button)
self._style_nav_button(self.next_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: def _sync_sliders_from_processor(self) -> None:
for _, attr, _, _ in SLIDER_SPECS: for _, attr, _, _ in SLIDER_SPECS:
@ -1076,11 +1399,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return key return key
def _style_nav_button(self, button: QtWidgets.QToolButton) -> None: def _style_nav_button(self, button: QtWidgets.QToolButton) -> None:
colours = THEMES[self.current_theme] colors = THEMES[self.current_theme]
button.setStyleSheet( button.setStyleSheet(
f"QToolButton {{ border-radius: 19px; background-color: {colours['panel_bg']}; " f"QToolButton {{ border-radius: 19px; background-color: {colors['panel_bg']}; "
f"border: 1px solid {colours['border']}; color: {colours['text']}; }}" f"border: 1px solid {colors['border']}; color: {colors['text']}; }}"
f"QToolButton:hover {{ background-color: {colours['accent_secondary']}; color: white; }}" f"QToolButton:hover {{ background-color: {colors['accent_secondary']}; color: white; }}"
) )
button.setIconSize(QtCore.QSize(20, 20)) button.setIconSize(QtCore.QSize(20, 20))
if button is getattr(self, "prev_button", None): if button is getattr(self, "prev_button", None):
@ -1106,12 +1429,22 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
total = len(self.processor.preview_paths) total = len(self.processor.preview_paths)
position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else "" position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else ""
dimensions = f"{width}×{height}" dimensions = f"{width}×{height}"
# Status label for top right layout
self.status_label.setText( self.status_label.setText(
self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) 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)) self.ratio_label.setText(self.processor.stats.summary(self._t))
def _refresh_overlay_only(self) -> None: def _refresh_overlay_only(self) -> None:
@ -1133,8 +1466,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return pixmap return pixmap
result = QtGui.QPixmap(pixmap) result = QtGui.QPixmap(pixmap)
painter = QtGui.QPainter(result) painter = QtGui.QPainter(result)
colour = QtGui.QColor(THEMES[self.current_theme]["highlight"]) color = QtGui.QColor(THEMES[self.current_theme]["highlight"])
pen = QtGui.QPen(colour) pen = QtGui.QPen(color)
pen.setWidth(3) pen.setWidth(3)
pen.setCosmetic(True) pen.setCosmetic(True)
pen.setCapStyle(QtCore.Qt.RoundCap) 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] [options]
# Set to true to clear exclusion shapes whenever the image changes. # Set to true to clear exclusion shapes whenever the image changes.
reset_exclusions_on_image_change = false 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] [defaults]
# Override any of the following keys to tweak the initial slider values: # 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] [tool.setuptools.package-data]
"app" = ["assets/logo.png", "lang/*.toml"] "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