ICRA/app/qt/main_window.py

1276 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Main PySide6 window emulating the legacy Tk interface with translations and themes."""
from __future__ import annotations
import re
import time
import urllib.request
import urllib.error
import csv
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"
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
if overlay_color:
self.processor.set_overlay_color(overlay_color)
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.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.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"))
tools_menu.addAction("" + self._t("toolbar.toggle_free_draw"), lambda: self._invoke_action("toggle_free_draw"))
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"))
# 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)
self.filename_label = QtWidgets.QLabel("")
self.filename_label.setAlignment(QtCore.Qt.AlignCenter)
layout.addWidget(self.filename_label)
self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder"))
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
layout.addWidget(self.ratio_label)
return layout
# Action wiring ----------------------------------------------------------
def _register_default_actions(self) -> None:
self._toolbar_actions = {
"open_image": self.open_image,
"open_folder": self.open_folder,
"export_folder": self.export_folder,
"choose_color": self.choose_color,
"pick_from_image": self.pick_from_image,
"save_overlay": self.save_overlay,
"toggle_free_draw": self.toggle_free_draw,
"clear_excludes": self.clear_exclusions,
"undo_exclude": self.undo_exclusion,
"reset_sliders": self._reset_sliders,
"toggle_theme": self.toggle_theme,
"show_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image,
"pull_patterns": self.open_pattern_puller,
}
def _invoke_action(self, key: str) -> None:
action = self._toolbar_actions.get(key)
if action:
action()
# Image handling ---------------------------------------------------------
def open_image(self) -> None:
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)"
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_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 = ","
headers = [
"Filename",
"Color",
"Matching Pixels",
"Matching Pixels w/ Exclusions",
"Excluded Pixels"
]
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)
img.close()
return [
img_path.name,
self._current_color,
pct_all_str,
pct_keep_str,
pct_excl_str
]
except Exception:
return [img_path.name, self._current_color, "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})")
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)
message_key = "status.free_draw_enabled" if self.exclude_mode == "free" else "status.free_draw_disabled"
self.status_label.setText(self._t(message_key))
def clear_exclusions(self) -> None:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
self.status_label.setText(self._t("toolbar.clear_excludes"))
self._refresh_overlay_only()
def undo_exclusion(self) -> None:
self.image_view.undo_last()
self.status_label.setText(self._t("toolbar.undo_exclude"))
def toggle_theme(self) -> None:
self.current_theme = "light" if self.current_theme == "dark" else "dark"
self._apply_theme(self.current_theme)
def _apply_theme(self, mode: str) -> None:
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_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;")
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}"
self.status_label.setText(
self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position)
)
self.filename_label.setText(
self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position)
)
self.ratio_label.setText(self.processor.stats.summary(self._t))
def _refresh_overlay_only(self) -> None:
if self.processor.preview_img is None:
return
pix = self.processor.overlay_pixmap()
if pix.isNull():
self.overlay_view.clear_canvas()
else:
self.overlay_view.set_pixmap(self._overlay_with_outlines(pix))
self.ratio_label.setText(self.processor.stats.summary(self._t))
def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None:
self.processor.set_exclusions(shapes)
self._refresh_overlay_only()
def _overlay_with_outlines(self, pixmap: QtGui.QPixmap) -> QtGui.QPixmap:
if pixmap.isNull() or not self.processor.exclude_shapes:
return pixmap
result = QtGui.QPixmap(pixmap)
painter = QtGui.QPainter(result)
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