"""Main PySide6 window emulating the legacy Tk interface with translations and themes.""" from __future__ import annotations import re import os import time import urllib.request import urllib.error import csv import json import concurrent.futures from pathlib import Path from typing import Callable, Dict, List, Tuple from PIL import Image from PySide6 import QtCore, QtGui, QtWidgets from app.i18n import I18nMixin from app.logic import SUPPORTED_IMAGE_EXTENSIONS from .image_processor import QtImageProcessor from .pattern_puller import PatternPullerDialog DEFAULT_COLOR = "#763e92" DEFAULT_OVERLAY_HEX = "#ff0000" PRESET_COLORS: List[Tuple[str, str]] = [ ("palette.swatch.red", "#ff3b30"), ("palette.swatch.orange", "#ff9500"), ("palette.swatch.yellow", "#ffd60a"), ("palette.swatch.green", "#34c759"), ("palette.swatch.teal", "#5ac8fa"), ("palette.swatch.blue", "#0a84ff"), ("palette.swatch.violet", "#af52de"), ("palette.swatch.magenta", "#ff2d55"), ("palette.swatch.white", "#ffffff"), ("palette.swatch.grey", "#8e8e93"), ("palette.swatch.black", "#000000"), ] SLIDER_SPECS: List[Tuple[str, str, int, int]] = [ ("sliders.hue_min", "hue_min", 0, 360), ("sliders.hue_max", "hue_max", 0, 360), ("sliders.sat_min", "sat_min", 0, 100), ("sliders.val_min", "val_min", 0, 100), ("sliders.val_max", "val_max", 0, 100), ("sliders.alpha", "alpha", 0, 255), ] THEMES: Dict[str, Dict[str, str]] = { "dark": { "window_bg": "#111216", "panel_bg": "#16171d", "text": "#f7f7fb", "text_muted": "rgba(255,255,255,0.68)", "text_dim": "rgba(255,255,255,0.45)", "accent": "#5168ff", "accent_secondary": "#9a4dff", "titlebar_bg": "#16171d", "border": "rgba(255,255,255,0.08)", "highlight": "#e6b84b", }, "light": { "window_bg": "#f3f4fb", "panel_bg": "#ffffff", "text": "#1d1e24", "text_muted": "rgba(29,30,36,0.78)", "text_dim": "rgba(29,30,36,0.55)", "accent": "#5168ff", "accent_secondary": "#9a4dff", "titlebar_bg": "#e9ebf5", "border": "rgba(29,30,36,0.12)", "highlight": "#c56217", }, } class ColorSwatch(QtWidgets.QPushButton): """Clickable palette swatch.""" def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None): super().__init__(parent) self.hex_code = hex_code self.name_key = name self.callback = callback self.setCursor(QtCore.Qt.PointingHandCursor) self.setFixedSize(28, 28) self._apply_color(hex_code) self.clicked.connect(lambda: callback(hex_code, self.name_key)) def _apply_color(self, hex_code: str) -> None: self.setStyleSheet( f""" QPushButton {{ background-color: {hex_code}; border: 2px solid rgba(255, 255, 255, 0.18); border-radius: 6px; }} QPushButton:hover {{ border-color: rgba(255, 255, 255, 0.45); }} """ ) def apply_theme(self, colors: Dict[str, str]) -> None: self.setStyleSheet( f""" QPushButton {{ background-color: {self.hex_code}; border: 2px solid {colors['border']}; border-radius: 6px; }} QPushButton:hover {{ border-color: {colors['accent']}; }} """ ) class SliderControl(QtWidgets.QWidget): """Slider with header and editable live value input.""" value_changed = QtCore.Signal(str, int) def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int): super().__init__() self.key = key self._minimum = minimum self._maximum = maximum layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) header = QtWidgets.QHBoxLayout() header.setContentsMargins(0, 0, 0, 0) self.title_label = QtWidgets.QLabel(title) header.addWidget(self.title_label) header.addStretch(1) self.value_edit = QtWidgets.QLineEdit(str(initial)) self.value_edit.setFixedWidth(44) self.value_edit.setAlignment(QtCore.Qt.AlignRight) self.value_edit.setValidator(QtGui.QIntValidator(minimum, maximum, self)) self.value_edit.editingFinished.connect(self._commit_edit) header.addWidget(self.value_edit) layout.addLayout(header) self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.slider.setRange(minimum, maximum) self.slider.setValue(initial) self.slider.setCursor(QtCore.Qt.PointingHandCursor) self.slider.valueChanged.connect(self._sync_value) layout.addWidget(self.slider) def _sync_value(self, value: int) -> None: self.value_edit.setText(str(value)) self.value_changed.emit(self.key, value) def _commit_edit(self) -> None: text = self.value_edit.text().strip() try: value = int(text) except ValueError: value = self.slider.value() value = max(self._minimum, min(self._maximum, value)) self.slider.setValue(value) # triggers _sync_value -> signal def set_value(self, value: int) -> None: self.slider.blockSignals(True) self.slider.setValue(value) self.slider.blockSignals(False) self.value_edit.setText(str(value)) def apply_theme(self, colors: Dict[str, str]) -> None: self.title_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") self.value_edit.setStyleSheet( f"color: {colors['text_dim']}; background: transparent; " f"border: 1px solid {colors['border']}; border-radius: 4px; padding: 0 2px;" ) self.slider.setStyleSheet( f""" QSlider::groove:horizontal {{ border: 1px solid {colors['border']}; height: 6px; background: rgba(255,255,255,0.14); border-radius: 4px; }} QSlider::handle:horizontal {{ background: {colors['accent_secondary']}; border: 1px solid rgba(255,255,255,0.2); width: 14px; margin: -5px 0; border-radius: 7px; }} """ ) class CanvasView(QtWidgets.QGraphicsView): """Interactive canvas for drawing exclusion shapes over the preview image.""" shapes_changed = QtCore.Signal(list) pixel_clicked = QtCore.Signal(int, int) # x, y in image coordinates def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setMouseTracking(True) self._scene = QtWidgets.QGraphicsScene(self) self.setScene(self._scene) self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None self._shape_items: list[QtWidgets.QGraphicsItem] = [] self._rubber_item: QtWidgets.QGraphicsRectItem | None = None self._stroke_item: QtWidgets.QGraphicsPathItem | None = None self.shapes: list[dict[str, object]] = [] self.mode: str = "rect" self.pick_mode: bool = False self._drawing = False self._start_pos = QtCore.QPointF() self._last_pos = QtCore.QPointF() self._path = QtGui.QPainterPath() self._accent = QtGui.QColor("#ffd700") def set_pixmap(self, pixmap: QtGui.QPixmap) -> None: self._scene.clear() self._shape_items.clear() self._pixmap_item = self._scene.addPixmap(pixmap) self._scene.setSceneRect(pixmap.rect()) self.resetTransform() self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio) self._redraw_shapes() def clear_canvas(self) -> None: if self._scene: self._scene.clear() self._pixmap_item = None self._shape_items.clear() self.shapes = [] def set_shapes(self, shapes: list[dict[str, object]]) -> None: self.shapes = shapes self._redraw_shapes() def set_mode(self, mode: str) -> None: self.mode = mode def set_accent(self, color: str) -> None: self._accent = QtGui.QColor(color) self._redraw_shapes() def undo_last(self) -> None: if not self.shapes: return self.shapes.pop() self._redraw_shapes() self.shapes_changed.emit(self.shapes.copy()) def clear_shapes(self) -> None: self.shapes = [] self._redraw_shapes() self.shapes_changed.emit([]) # event handling -------------------------------------------------------- def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: if event.button() == QtCore.Qt.LeftButton and self.pick_mode and self._pixmap_item: scene_pos = self.mapToScene(event.position().toPoint()) clamped = self._clamp_to_image(scene_pos) self.pixel_clicked.emit(int(clamped.x()), int(clamped.y())) event.accept() return if event.button() == QtCore.Qt.RightButton and self._pixmap_item: self._drawing = True scene_pos = self.mapToScene(event.position().toPoint()) self._start_pos = self._clamp_to_image(scene_pos) if self.mode == "rect": pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) brush = QtGui.QBrush(QtCore.Qt.NoBrush) self._rubber_item = self._scene.addRect(QtCore.QRectF(self._start_pos, self._start_pos), pen, brush) else: pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) self._path = QtGui.QPainterPath(self._start_pos) self._stroke_item = self._scene.addPath(self._path, pen) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: if self._drawing and self._pixmap_item: scene_pos = self.mapToScene(event.position().toPoint()) self._last_pos = self._clamp_to_image(scene_pos) if self.mode == "rect" and self._rubber_item: rect = QtCore.QRectF(self._start_pos, self._last_pos).normalized() self._rubber_item.setRect(rect) elif self.mode == "free" and self._stroke_item: self._path.lineTo(self._last_pos) self._stroke_item.setPath(self._path) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: if self._drawing and event.button() == QtCore.Qt.RightButton and self._pixmap_item: scene_pos = self.mapToScene(event.position().toPoint()) end_pos = self._clamp_to_image(scene_pos) if self.mode == "rect" and self._rubber_item: rect = QtCore.QRectF(self._start_pos, end_pos).normalized() if rect.width() > 2 and rect.height() > 2: shape = { "kind": "rect", "coords": ( int(rect.left()), int(rect.top()), int(rect.right()), int(rect.bottom()), ), } self.shapes.append(shape) self._scene.removeItem(self._rubber_item) self._rubber_item = None elif self.mode == "free" and self._stroke_item: self._path.lineTo(self._start_pos) points = [(int(pt.x()), int(pt.y())) for pt in self._path.toFillPolygon()] if len(points) >= 3: shape = {"kind": "polygon", "points": points} self.shapes.append(shape) self._scene.removeItem(self._stroke_item) self._stroke_item = None self._drawing = False self._redraw_shapes() self.shapes_changed.emit(self.shapes.copy()) event.accept() return super().mouseReleaseEvent(event) # helpers ---------------------------------------------------------------- def _clamp_to_image(self, pos: QtCore.QPointF) -> QtCore.QPointF: if not self._pixmap_item: return pos pixmap = self._pixmap_item.pixmap() x = min(max(0.0, pos.x()), float(pixmap.width() - 1)) y = min(max(0.0, pos.y()), float(pixmap.height() - 1)) return QtCore.QPointF(x, y) def _redraw_shapes(self) -> None: for item in self._shape_items: self._scene.removeItem(item) self._shape_items.clear() if not self._pixmap_item: return pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) pen.setCosmetic(True) for shape in self.shapes: kind = shape.get("kind") if kind == "rect": x0, y0, x1, y1 = shape["coords"] # type: ignore[index] rect_item = self._scene.addRect(QtCore.QRectF(x0, y0, x1 - x0, y1 - y0), pen) self._shape_items.append(rect_item) elif kind == "polygon": points = shape.get("points", []) if len(points) < 2: continue path = QtGui.QPainterPath() first = QtCore.QPointF(*points[0]) path.moveTo(first) for px, py in points[1:]: path.lineTo(px, py) path.closeSubpath() path_item = self._scene.addPath(path, pen) self._shape_items.append(path_item) class OverlayCanvas(QtWidgets.QGraphicsView): """Read-only QGraphicsView for displaying the color-match overlay.""" def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self._scene = QtWidgets.QGraphicsScene(self) self.setScene(self._scene) self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None def set_pixmap(self, pixmap: QtGui.QPixmap) -> None: self._scene.clear() self._pixmap_item = self._scene.addPixmap(pixmap) self._scene.setSceneRect(pixmap.rect()) self.resetTransform() self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio) def clear_canvas(self) -> None: self._scene.clear() self._pixmap_item = None def resizeEvent(self, event: QtGui.QResizeEvent) -> None: # type: ignore[override] super().resizeEvent(event) if self._pixmap_item: self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio) class TitleBar(QtWidgets.QWidget): """Custom title bar with native window controls.""" HEIGHT = 40 def __init__(self, window: "MainWindow") -> None: super().__init__(window) self.window = window self.setFixedHeight(self.HEIGHT) self.setCursor(QtCore.Qt.ArrowCursor) self.setAutoFillBackground(True) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(12, 8, 12, 8) layout.setSpacing(8) logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png" if logo_path.exists(): pixmap = QtGui.QPixmap(str(logo_path)) self.logo_label = QtWidgets.QLabel() self.logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) layout.addWidget(self.logo_label) else: self.logo_label = None self.title_label = QtWidgets.QLabel() layout.addWidget(self.title_label) layout.addStretch(1) self.min_btn = self._create_button("–", "Minimise") self.min_btn.clicked.connect(window.showMinimized) layout.addWidget(self.min_btn) self.max_btn = self._create_button("❐", "Maximise / Restore") self.max_btn.clicked.connect(window.toggle_maximise) layout.addWidget(self.max_btn) self.close_btn = self._create_button("✕", "Close") self.close_btn.clicked.connect(window.close) layout.addWidget(self.close_btn) def _create_button(self, text: str, tooltip: str) -> QtWidgets.QPushButton: btn = QtWidgets.QPushButton(text) btn.setToolTip(tooltip) btn.setFixedSize(36, 24) btn.setCursor(QtCore.Qt.ArrowCursor) btn.setStyleSheet( """ QPushButton { background-color: transparent; color: #f7f7fb; border: none; padding: 4px 10px; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.1); } """ ) return btn def apply_theme(self, colors: Dict[str, str]) -> None: palette = self.palette() palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colors["titlebar_bg"])) self.setPalette(palette) self.title_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;") hover_bg = "#d0342c" if colors["titlebar_bg"] != "#e9ebf5" else "#e6675a" self.close_btn.setStyleSheet( f""" QPushButton {{ background-color: transparent; color: {colors['text']}; border: none; padding: 4px 10px; }} QPushButton:hover {{ background-color: {hover_bg}; color: #ffffff; }} """ ) for btn in (self.min_btn, self.max_btn): btn.setStyleSheet( f""" QPushButton {{ background-color: transparent; color: {colors['text']}; border: none; padding: 4px 10px; }} QPushButton:hover {{ background-color: rgba(0, 0, 0, 0.1); }} """ ) def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None: if event.button() == QtCore.Qt.LeftButton: self.window.toggle_maximise() event.accept() def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: if event.button() == QtCore.Qt.LeftButton: self.window.start_system_move(event.globalPosition()) event.accept() super().mousePressEvent(event) class MainWindow(QtWidgets.QMainWindow, I18nMixin): """Main application window containing all controls.""" def __init__(self, language: str, defaults: dict, reset_exclusions: bool, overlay_color: str | None = None) -> None: super().__init__() self.init_i18n(language) self.setWindowTitle(self._t("app.title")) self.setWindowFlag(QtCore.Qt.FramelessWindowHint) self.setWindowFlag(QtCore.Qt.Window) self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False) self.setMinimumSize(1100, 680) container = QtWidgets.QWidget() container_layout = QtWidgets.QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) container_layout.setSpacing(0) self.title_bar = TitleBar(self) self.title_bar.title_label.setText(self._t("app.title")) container_layout.addWidget(self.title_bar) self.content = QtWidgets.QWidget() self.processor = QtImageProcessor() self.processor.set_defaults(defaults) self.processor.reset_exclusions_on_switch = reset_exclusions # Always use red for the overlay regardless of the target color self.processor.set_overlay_color(DEFAULT_OVERLAY_HEX) self.content_layout = QtWidgets.QVBoxLayout(self.content) self.content_layout.setContentsMargins(24, 0, 24, 24) self.content_layout.setSpacing(18) self.content_layout.addWidget(self._build_menu_bar()) self.content_layout.addLayout(self._build_palette()) self.content_layout.addLayout(self._build_sliders()) self.content_layout.addWidget(self._build_previews(), 1) self.content_layout.addLayout(self._build_status_section()) container_layout.addWidget(self.content, 1) self.setCentralWidget(container) self._is_maximised = False self._current_image_path: Path | None = None self._current_color = DEFAULT_COLOR self._toolbar_actions: Dict[str, Callable[[], None]] = {} self._register_default_actions() self.exclude_mode = "rect" self._pick_mode = False self.image_view.set_mode(self.exclude_mode) self.image_view.shapes_changed.connect(self._on_shapes_changed) self.image_view.pixel_clicked.connect(self._on_pixel_picked) self._sync_sliders_from_processor() self._update_color_display(DEFAULT_COLOR, self._t("palette.current")) self.current_theme = "dark" self._apply_theme(self.current_theme) # Drag-and-drop self.setAcceptDrops(True) # Keyboard shortcuts self._setup_shortcuts() # Slider debounce timer self._slider_timer = QtCore.QTimer(self) self._slider_timer.setSingleShot(True) self._slider_timer.setInterval(80) self._slider_timer.timeout.connect(self._refresh_overlay_only) # Restore window geometry self._settings = QtCore.QSettings("ICRA", "MainWindow") geometry = self._settings.value("geometry") if geometry: self.restoreGeometry(geometry) # Window control helpers ------------------------------------------------- def toggle_maximise(self) -> None: handle = self.windowHandle() if handle is None: return if self._is_maximised: self.showNormal() self._is_maximised = False self.title_bar.max_btn.setText("❐") else: self.showMaximized() self._is_maximised = True self.title_bar.max_btn.setText("⧉") def start_system_move(self, _global_position: QtCore.QPointF) -> None: handle = self.windowHandle() if handle: handle.startSystemMove() # UI builders ------------------------------------------------------------ def _build_menu_bar(self) -> QtWidgets.QMenuBar: self.menu_bar = QtWidgets.QMenuBar(self) # File Menu file_menu = self.menu_bar.addMenu(self._t("menu.file")) file_menu.addAction("🖼 " + self._t("toolbar.open_image"), lambda: self._invoke_action("open_image"), "Ctrl+O") file_menu.addSeparator() file_menu.addAction("📂 " + self._t("toolbar.open_folder"), lambda: self._invoke_action("open_folder"), "Ctrl+Shift+O") file_menu.addAction("📊 " + self._t("toolbar.export_folder"), lambda: self._invoke_action("export_folder")) file_menu.addSeparator() file_menu.addAction("📤 " + self._t("toolbar.export_settings"), lambda: self._invoke_action("export_settings"), "Ctrl+E") file_menu.addAction("📥 " + self._t("toolbar.import_settings"), lambda: self._invoke_action("import_settings"), "Ctrl+I") file_menu.addSeparator() file_menu.addAction("💾 " + self._t("toolbar.save_overlay"), lambda: self._invoke_action("save_overlay"), "Ctrl+S") # 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")) return self.menu_bar def _build_palette(self) -> QtWidgets.QHBoxLayout: layout = QtWidgets.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(16) current_group = QtWidgets.QHBoxLayout() current_group.setSpacing(8) self.current_label = QtWidgets.QLabel(self._t("palette.current")) current_group.addWidget(self.current_label) self.current_color_swatch = QtWidgets.QLabel() self.current_color_swatch.setFixedSize(28, 28) self.current_color_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOR}; border-radius: 6px;") current_group.addWidget(self.current_color_swatch) self.current_color_label = QtWidgets.QLabel(f"({DEFAULT_COLOR})") current_group.addWidget(self.current_color_label) layout.addLayout(current_group) self.more_label = QtWidgets.QLabel(self._t("palette.more")) layout.addWidget(self.more_label) swatch_container = QtWidgets.QHBoxLayout() swatch_container.setSpacing(8) self.swatch_buttons: List[ColorSwatch] = [] for name_key, hex_code in PRESET_COLORS: swatch = ColorSwatch(self._t(name_key), hex_code, self._update_color_display) swatch_container.addWidget(swatch) self.swatch_buttons.append(swatch) layout.addLayout(swatch_container) layout.addStretch(1) layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight) return layout def _build_sliders(self) -> QtWidgets.QHBoxLayout: layout = QtWidgets.QHBoxLayout() layout.setSpacing(16) self._slider_controls: Dict[str, SliderControl] = {} for key, attr, minimum, maximum in SLIDER_SPECS: initial = int(getattr(self.processor, attr)) control = SliderControl(self._t(key), attr, minimum, maximum, initial) control.value_changed.connect(self._on_slider_change) layout.addWidget(control) self._slider_controls[attr] = control return layout def _build_previews(self) -> QtWidgets.QWidget: container = QtWidgets.QWidget() layout = QtWidgets.QGridLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setHorizontalSpacing(16) self.prev_button = QtWidgets.QToolButton() self.prev_button.setCursor(QtCore.Qt.PointingHandCursor) self.prev_button.setAutoRaise(True) self.prev_button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) self.prev_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowBack)) self.prev_button.setIconSize(QtCore.QSize(20, 20)) self.prev_button.setFixedSize(38, 38) self.prev_button.clicked.connect(lambda: self._invoke_action("show_previous_image")) layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter) self.image_view = CanvasView() layout.addWidget(self.image_view, 0, 1) self.overlay_view = OverlayCanvas() layout.addWidget(self.overlay_view, 0, 2) self.next_button = QtWidgets.QToolButton() self.next_button.setCursor(QtCore.Qt.PointingHandCursor) self.next_button.setAutoRaise(True) self.next_button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) self.next_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowForward)) self.next_button.setIconSize(QtCore.QSize(20, 20)) self.next_button.setFixedSize(38, 38) self.next_button.clicked.connect(lambda: self._invoke_action("show_next_image")) layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter) layout.setColumnStretch(1, 1) layout.setColumnStretch(2, 1) return container def _build_status_section(self) -> QtWidgets.QVBoxLayout: layout = QtWidgets.QVBoxLayout() layout.setSpacing(8) layout.setContentsMargins(0, 0, 0, 0) # Status row container status_row_layout = QtWidgets.QHBoxLayout() status_row_layout.setSpacing(4) status_row_layout.setAlignment(QtCore.Qt.AlignCenter) self.filename_prefix_label = QtWidgets.QLabel(self._t("status.loaded", name="", dimensions="", position="").split("{name}")[0]) self.filename_prefix_label.setStyleSheet("color: " + THEMES["dark"]["text_muted"] + "; font-weight: 500;") status_row_layout.addWidget(self.filename_prefix_label) self.pattern_input = QtWidgets.QLineEdit("—") self.pattern_input.setAlignment(QtCore.Qt.AlignCenter) self.pattern_input.setFixedWidth(100) self.pattern_input.setStyleSheet( "QLineEdit {" " background: rgba(255, 255, 255, 0.05);" " border: 1px solid rgba(255, 255, 255, 0.1);" " border-radius: 4px;" " color: " + THEMES["dark"]["text"] + ";" " font-weight: 600;" "}" "QLineEdit:focus {" " border: 1px solid " + THEMES["dark"]["accent"] + ";" "}" ) self.pattern_input.returnPressed.connect(self._jump_to_pattern) status_row_layout.addWidget(self.pattern_input) self.filename_suffix_label = QtWidgets.QLabel("") self.filename_suffix_label.setStyleSheet("color: " + THEMES["dark"]["text"] + "; font-weight: 600;") status_row_layout.addWidget(self.filename_suffix_label) layout.addLayout(status_row_layout) self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder")) self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(self.ratio_label) return layout # Action wiring ---------------------------------------------------------- def _register_default_actions(self) -> None: self._toolbar_actions = { "open_image": self.open_image, "open_folder": self.open_folder, "export_folder": self.export_folder, "choose_color": self.choose_color, "pick_from_image": self.pick_from_image, "save_overlay": self.save_overlay, "export_settings": self.export_settings, "import_settings": self.import_settings, "toggle_free_draw": self.toggle_free_draw, "clear_excludes": self.clear_exclusions, "undo_exclude": self.undo_exclusion, "reset_sliders": self._reset_sliders, "toggle_theme": self.toggle_theme, "toggle_prefer_dark": self.toggle_prefer_dark, "open_app_folder": self.open_app_folder, "show_previous_image": self.show_previous_image, "show_next_image": self.show_next_image, "pull_patterns": self.open_pattern_puller, } def _invoke_action(self, key: str) -> None: action = self._toolbar_actions.get(key) if action: action() def _jump_to_pattern(self) -> None: if not self.processor.preview_paths: return target_text = self.pattern_input.text().strip() if not target_text: # Restore current text if empty if self._current_image_path: self.pattern_input.setText(self._current_image_path.stem) return # Try to find exactly this name or stem target_stem_lower = target_text.lower() found_idx = -1 for i, path in enumerate(self.processor.preview_paths): if path.stem.lower() == target_stem_lower or path.name.lower() == target_stem_lower: found_idx = i break if found_idx != -1: # Found it, jump! self.processor.current_index = found_idx try: loaded_path = self.processor._load_image_at_current() self._current_image_path = loaded_path self._refresh_views() except Exception as e: QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e)) else: # Not found, just restore text if self._current_image_path: self.pattern_input.setText(self._current_image_path.stem) QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), f"Pattern '{target_text}' not found in current folder.") # Image handling --------------------------------------------------------- def open_image(self) -> None: filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" default_dir = str(Path("images").absolute()) if Path("images").exists() else "" path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, self._t("dialog.open_image_title"), default_dir, filters) if not path_str: return path = Path(path_str) try: loaded_path = self.processor.load_single_image(path) except Exception as exc: # pragma: no cover - user feedback QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc)) return self._current_image_path = loaded_path if self.processor.reset_exclusions_on_switch: self.image_view.clear_shapes() self.processor.set_exclusions([]) self._refresh_views() def open_folder(self) -> None: default_dir = str(Path("images").absolute()) if Path("images").exists() else "" directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title"), default_dir) if not directory: return folder = Path(directory) paths = sorted( (p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()), key=lambda p: p.name.lower(), ) if not paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.folder_empty")) return try: loaded_path = self.processor.load_folder(paths) except ValueError as exc: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc)) return self._current_image_path = loaded_path self._refresh_views() def export_settings(self) -> None: item_name = "" if self._current_image_path: # Try to get folder name first, otherwise file name if self._current_image_path.parent.name and self._current_image_path.parent.name != "images": item_name = self._current_image_path.parent.name else: item_name = self._current_image_path.stem default_filename = f"icra_settings_{item_name}.json" if item_name else "icra_settings.json" default_dir = str(Path("images").absolute()) if Path("images").exists() else "" path_str, _ = QtWidgets.QFileDialog.getSaveFileName( self, self._t("dialog.export_settings_title"), str(Path(default_dir) / default_filename), self._t("dialog.json_filter") ) if not path_str: return settings = { "hue_min": self.processor.hue_min, "hue_max": self.processor.hue_max, "sat_min": self.processor.sat_min, "val_min": self.processor.val_min, "val_max": self.processor.val_max, "alpha": self.processor.alpha, "current_color": self._current_color, "exclude_ref_size": self.processor.exclude_ref_size, "shapes": self.image_view.shapes } try: with open(path_str, "w", encoding="utf-8") as f: json.dump(settings, f, indent=4) self.status_label.setText(self._t("status.settings_exported", path=Path(path_str).name)) except Exception as e: QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e)) def import_settings(self) -> None: default_dir = str(Path("images").absolute()) if Path("images").exists() else "" path_str, _ = QtWidgets.QFileDialog.getOpenFileName( self, self._t("dialog.import_settings_title"), default_dir, self._t("dialog.json_filter") ) if not path_str: return try: with open(path_str, "r", encoding="utf-8") as f: settings = json.load(f) # 1. Apply color (UI ONLY) if "current_color" in settings: self._current_color = settings["current_color"] # Specifically NOT setting processor color to keep it RED self._update_color_display(self._current_color, self._t("palette.current")) # 2. Apply slider values keys = ["hue_min", "hue_max", "sat_min", "val_min", "val_max", "alpha"] for key in keys: if key in settings: setattr(self.processor, key, settings[key]) # 3. Apply shapes and reference size ref_size = None if "exclude_ref_size" in settings and settings["exclude_ref_size"]: ref_size = tuple(settings["exclude_ref_size"]) if "shapes" in settings: self.image_view.set_shapes(settings["shapes"]) self.processor.set_exclusions(settings["shapes"], ref_size=ref_size) else: # Force rebuild even if no shapes to pick up sliders/color if self.processor.preview_img: self.processor._rebuild_overlay() self._sync_sliders_from_processor() self._refresh_views() self.status_label.setText(self._t("status.settings_imported")) except Exception as e: QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e)) def export_folder(self) -> None: if not self.processor.preview_paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) return folder_path = self.processor.preview_paths[0].parent default_filename = f"icra_stats_{folder_path.name}.csv" csv_path, _ = QtWidgets.QFileDialog.getSaveFileName( self, self._t("dialog.export_stats_title"), str(folder_path / default_filename), self._t("dialog.csv_filter") ) if not csv_path: return total = len(self.processor.preview_paths) # Hardcoded to EU format as requested: ; delimiter, , decimal delimiter = ";" decimal = "," brightness_col = "Darkness Score" if self.processor.prefer_dark else "Brightness Score" headers = [ "Filename", "Color", "Matching Pixels", "Matching Pixels w/ Exclusions", "Excluded Pixels", brightness_col, "Composite Score" ] rows = [headers] def process_image(img_path): try: img = Image.open(img_path) s = self.processor.get_stats_headless(img) pct_all = (s.matches_all / s.total_all * 100) if s.total_all else 0.0 pct_keep = (s.matches_keep / s.total_keep * 100) if s.total_keep else 0.0 pct_excl = (s.total_excl / s.total_all * 100) if s.total_all else 0.0 pct_all_str = f"{pct_all:.2f}".replace(".", decimal) pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal) pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal) brightness_str = f"{s.effective_brightness:.2f}".replace(".", decimal) composite_str = f"{s.composite_score:.2f}".replace(".", decimal) img.close() return [ img_path.name, self._current_color, pct_all_str, pct_keep_str, pct_excl_str, brightness_str, composite_str ] except Exception: return [img_path.name, self._current_color, "Error", "Error", "Error", "Error", "Error"] results = [None] * total with concurrent.futures.ThreadPoolExecutor() as executor: future_to_idx = {executor.submit(process_image, p): i for i, p in enumerate(self.processor.preview_paths)} done_count = 0 for future in concurrent.futures.as_completed(future_to_idx): idx = future_to_idx[future] results[idx] = future.result() done_count += 1 if done_count % 10 == 0 or done_count == total: self.status_label.setText(self._t("status.exporting", current=str(done_count), total=str(total))) QtWidgets.QApplication.processEvents() rows.extend(results) # Compute max width per column for alignment, plus extra space so it's not cramped col_widths = [max(len(str(item)) for item in col) + 4 for col in zip(*rows)] # Excel on Windows prefers utf-8-sig (with BOM) to identify the encoding correctly. with open(csv_path, mode="w", newline="", encoding="utf-8-sig") as f: for row in rows: # Manual formatting to support both alignment for text editors AND valid CSV for Excel. # We pad the strings but keep the delimiter clean. padded_cells = [f"{str(item):>{width}}" for item, width in zip(row, col_widths)] f.write(delimiter.join(padded_cells) + "\n") # Restore overlay state for currently viewed image self.processor._rebuild_overlay() self.status_label.setText(self._t("status.export_done", path=csv_path)) def show_previous_image(self) -> None: if not self.processor.preview_paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) return path = self.processor.previous_image() if path is None: return self._current_image_path = path if self.processor.reset_exclusions_on_switch: self.image_view.clear_shapes() self.processor.set_exclusions([]) self._refresh_views() def show_next_image(self) -> None: if not self.processor.preview_paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) return path = self.processor.next_image() if path is None: return self._current_image_path = path if self.processor.reset_exclusions_on_switch: self.image_view.clear_shapes() self.processor.set_exclusions([]) self._refresh_views() # Helpers ---------------------------------------------------------------- def _update_color_display(self, hex_code: str, label: str) -> None: self._current_color = hex_code self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;") self.current_color_label.setText(f"({hex_code})") # Do NOT call self.processor.set_overlay_color here to keep overlay RED if label: self.status_label.setText(f"{label}: {hex_code}") def _on_slider_change(self, key: str, value: int) -> None: self.processor.set_threshold(key, value) label = self._slider_title(key) self.status_label.setText(f"{label}: {value}") self._slider_timer.start() def _reset_sliders(self) -> None: for _, attr, _, _ in SLIDER_SPECS: control = self._slider_controls.get(attr) if control: default_value = int(self.processor.defaults.get(attr, getattr(self.processor, attr))) control.set_value(default_value) self.processor.set_threshold(attr, default_value) self.status_label.setText(self._t("status.defaults_restored")) self._refresh_overlay_only() def _coming_soon(self) -> None: QtWidgets.QMessageBox.information( self, self._t("dialog.info_title"), "Feature coming soon in the PySide6 migration.", ) # Shortcuts -------------------------------------------------------------- def _setup_shortcuts(self) -> None: shortcuts = [ (QtGui.QKeySequence.Open, self.open_image), (QtGui.QKeySequence("Ctrl+Shift+O"), self.open_folder), (QtGui.QKeySequence.Save, self.save_overlay), (QtGui.QKeySequence.Undo, self.undo_exclusion), (QtGui.QKeySequence("Ctrl+R"), self._reset_sliders), (QtGui.QKeySequence(QtCore.Qt.Key_Left), self.show_previous_image), (QtGui.QKeySequence(QtCore.Qt.Key_Right), self.show_next_image), (QtGui.QKeySequence(QtCore.Qt.Key_Escape), self._exit_pick_mode), ] for seq, slot in shortcuts: sc = QtGui.QShortcut(seq, self) sc.activated.connect(slot) # Pick-mode / eyedropper ------------------------------------------------- def pick_from_image(self) -> None: if self.processor.preview_img is None: QtWidgets.QMessageBox.information( self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded") ) return self._pick_mode = True self.image_view.pick_mode = True self.image_view.setCursor(QtCore.Qt.CrossCursor) self.status_label.setText(self._t("status.pick_mode_ready")) def _exit_pick_mode(self) -> None: self._pick_mode = False self.image_view.pick_mode = False self.image_view.setCursor(QtCore.Qt.ArrowCursor) self.status_label.setText(self._t("status.pick_mode_ended")) def _on_pixel_picked(self, x: int, y: int) -> None: result = self.processor.pick_color(x, y) if result is None: self._exit_pick_mode() return hue, sat, val = result # Derive a narrow hue range (±15°) and minimum sat/val from the pixel margin = 15 hue_min = max(0, int(hue) - margin) hue_max = min(360, int(hue) + margin) sat_min = max(0, int(sat) - 20) val_min = max(0, int(val) - 30) val_max = 100 for attr, value in [ ("hue_min", hue_min), ("hue_max", hue_max), ("sat_min", sat_min), ("val_min", val_min), ("val_max", val_max), ]: ctrl = self._slider_controls.get(attr) if ctrl: ctrl.set_value(value) self.processor.set_threshold(attr, value) # Update color swatch to the picked pixel color h_norm = hue / 360.0 s_norm = sat / 100.0 v_norm = val / 100.0 import colorsys r, g, b = colorsys.hsv_to_rgb(h_norm, s_norm, v_norm) hex_code = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255)) self._update_color_display(hex_code, "") self.status_label.setText( self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val) ) self._refresh_overlay_only() def open_pattern_puller(self) -> None: dialog = PatternPullerDialog(self.language, parent=self) dialog.exec() # Drag-and-drop ---------------------------------------------------------- def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None: if event.mimeData().hasUrls(): event.acceptProposedAction() def dropEvent(self, event: QtGui.QDropEvent) -> None: urls = event.mimeData().urls() if not urls: return path = Path(urls[0].toLocalFile()) if path.is_dir(): paths = sorted( (p for p in path.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()), key=lambda p: p.name.lower(), ) if not paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.folder_empty")) return try: loaded_path = self.processor.load_folder(paths) except ValueError as exc: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc)) return self._current_image_path = loaded_path elif path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS: try: loaded_path = self.processor.load_single_image(path) except Exception as exc: QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc)) return self._current_image_path = loaded_path if self.processor.reset_exclusions_on_switch: self.image_view.clear_shapes() self.processor.set_exclusions([]) else: return self._refresh_views() event.acceptProposedAction() # Window lifecycle ------------------------------------------------------- def closeEvent(self, event: QtGui.QCloseEvent) -> None: self._settings.setValue("geometry", self.saveGeometry()) super().closeEvent(event) def choose_color(self) -> None: color = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_color_title")) if not color.isValid(): return hex_code = color.name() self._update_color_display(hex_code, self._t("dialog.choose_color_title")) def save_overlay(self) -> None: pixmap = self.processor.overlay_pixmap() if pixmap.isNull(): QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_preview_available")) return filename, _ = QtWidgets.QFileDialog.getSaveFileName( self, self._t("dialog.save_overlay_title"), "overlay.png", "PNG (*.png)", ) if not filename: return if not pixmap.save(filename, "PNG"): QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), self._t("dialog.image_open_failed", error="Unable to save file")) return self.status_label.setText(self._t("dialog.overlay_saved", path=filename)) def toggle_free_draw(self) -> None: self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect" self.image_view.set_mode(self.exclude_mode) self.free_draw_action.setChecked(self.exclude_mode == "free") message_key = "status.free_draw_enabled" if self.exclude_mode == "free" else "status.free_draw_disabled" self.status_label.setText(self._t(message_key)) def toggle_prefer_dark(self) -> None: self.processor.prefer_dark = not self.processor.prefer_dark self.prefer_dark_action.setChecked(self.processor.prefer_dark) if self.processor.preview_img: self.processor._rebuild_overlay() self._refresh_overlay_only() def clear_exclusions(self) -> None: self.image_view.clear_shapes() self.processor.set_exclusions([]) self.status_label.setText(self._t("toolbar.clear_excludes")) self._refresh_overlay_only() def undo_exclusion(self) -> None: self.image_view.undo_last() self.status_label.setText(self._t("toolbar.undo_exclude")) def toggle_theme(self) -> None: self.current_theme = "light" if self.current_theme == "dark" else "dark" self._apply_theme(self.current_theme) def open_app_folder(self) -> None: path = os.getcwd() QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(path)) def _apply_theme(self, mode: str) -> None: colors = THEMES[mode] self.content.setStyleSheet(f"background-color: {colors['window_bg']};") self.image_view.setStyleSheet( f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;" ) self.image_view.set_accent(colors["highlight"]) self.overlay_view.setStyleSheet( f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;" ) self.status_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") self.current_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") self.current_color_label.setStyleSheet(f"color: {colors['text_dim']};") self.more_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") self.filename_prefix_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;") self.filename_suffix_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;") self.pattern_input.setStyleSheet( f"QLineEdit {{" f" background: rgba(255, 255, 255, 0.05);" f" border: 1px solid {colors['border']};" f" border-radius: 4px;" f" color: {colors['text']};" f" font-weight: 600;" f"}}" f"QLineEdit:focus {{" f" border: 1px solid {colors['accent']};" f"}}" ) self.ratio_label.setStyleSheet(f"color: {colors['highlight']}; font-weight: 600;") # Style MenuBar self.menu_bar.setStyleSheet( f""" QMenuBar {{ background-color: {colors['window_bg']}; color: {colors['text']}; font-weight: 500; font-size: 13px; border-bottom: 1px solid {colors['border']}; }} QMenuBar::item {{ spacing: 8px; padding: 6px 12px; background: transparent; border-radius: 4px; }} QMenuBar::item:selected {{ background: rgba(128, 128, 128, 0.2); }} QMenu {{ background-color: {colors['panel_bg']}; color: {colors['text']}; border: 1px solid {colors['border']}; }} QMenu::item {{ padding: 6px 24px; }} QMenu::item:selected {{ background-color: {colors['highlight']}; color: #ffffff; }} """ ) for swatch in self.swatch_buttons: swatch.apply_theme(colors) for control in self._slider_controls.values(): control.apply_theme(colors) self._style_nav_button(self.prev_button) self._style_nav_button(self.next_button) self.title_bar.apply_theme(colors) def _sync_sliders_from_processor(self) -> None: for _, attr, _, _ in SLIDER_SPECS: control = self._slider_controls.get(attr) if control: control.set_value(int(getattr(self.processor, attr))) def _slider_title(self, key: str) -> str: for title_key, attr, _, _ in SLIDER_SPECS: if attr == key: return self._t(title_key) return key def _style_nav_button(self, button: QtWidgets.QToolButton) -> None: colors = THEMES[self.current_theme] button.setStyleSheet( f"QToolButton {{ border-radius: 19px; background-color: {colors['panel_bg']}; " f"border: 1px solid {colors['border']}; color: {colors['text']}; }}" f"QToolButton:hover {{ background-color: {colors['accent_secondary']}; color: white; }}" ) button.setIconSize(QtCore.QSize(20, 20)) if button is getattr(self, "prev_button", None): button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowBack)) elif button is getattr(self, "next_button", None): button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowForward)) def _refresh_views(self) -> None: preview_pix = self.processor.preview_pixmap() if preview_pix.isNull(): self.image_view.clear_canvas() self.image_view.set_shapes([]) self.overlay_view.clear_canvas() else: self.image_view.set_pixmap(preview_pix) self.image_view.set_shapes(self.processor.exclude_shapes.copy()) overlay_pix = self.processor.overlay_pixmap() overlay_pix = self._overlay_with_outlines(overlay_pix) self.overlay_view.set_pixmap(overlay_pix) if self._current_image_path and self.processor.preview_img: width, height = self.processor.preview_img.size total = len(self.processor.preview_paths) position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else "" dimensions = f"{width}×{height}" # Status label for top right layout self.status_label.setText( self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) ) # Pattern input self.pattern_input.setText(self._current_image_path.stem) # Update suffix label suffix_text = f"{self._current_image_path.suffix} — {dimensions}{position}" self.filename_suffix_label.setText(suffix_text) # Update prefix translation correctly prefix = self._t("status.loaded", name="X", dimensions="Y", position="Z").split("X")[0] self.filename_prefix_label.setText(prefix) self.ratio_label.setText(self.processor.stats.summary(self._t)) def _refresh_overlay_only(self) -> None: if self.processor.preview_img is None: return pix = self.processor.overlay_pixmap() if pix.isNull(): self.overlay_view.clear_canvas() else: self.overlay_view.set_pixmap(self._overlay_with_outlines(pix)) self.ratio_label.setText(self.processor.stats.summary(self._t)) def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None: self.processor.set_exclusions(shapes) self._refresh_overlay_only() def _overlay_with_outlines(self, pixmap: QtGui.QPixmap) -> QtGui.QPixmap: if pixmap.isNull() or not self.processor.exclude_shapes: return pixmap result = QtGui.QPixmap(pixmap) painter = QtGui.QPainter(result) color = QtGui.QColor(THEMES[self.current_theme]["highlight"]) pen = QtGui.QPen(color) pen.setWidth(3) pen.setCosmetic(True) pen.setCapStyle(QtCore.Qt.RoundCap) pen.setJoinStyle(QtCore.Qt.RoundJoin) painter.setPen(pen) for shape in self.processor.exclude_shapes: kind = shape.get("kind") if kind == "rect": x0, y0, x1, y1 = shape["coords"] # type: ignore[index] painter.drawRect(QtCore.QRectF(x0, y0, x1 - x0, y1 - y0)) elif kind == "polygon": points = shape.get("points", []) if len(points) >= 2: path = QtGui.QPainterPath() first = QtCore.QPointF(*points[0]) path.moveTo(first) for px, py in points[1:]: path.lineTo(px, py) path.closeSubpath() painter.drawPath(path) painter.end() return result