Improve performance, UX, and code quality

- Replace Python pixel loop with NumPy vectorized HSV processing (~50-100x faster overlay rebuilds)
- Add OverlayCanvas (QGraphicsView) to replace plain QLabel overlay panel
- Add editable QLineEdit value input to SliderControl (supports typing exact values)
- Implement eyedropper pick-from-image: left-click sets hue/sat/val sliders from pixel colour
- Add keyboard shortcuts: Ctrl+O, Ctrl+Shift+O, Ctrl+S, Ctrl+Z, Ctrl+R, arrow keys, Esc
- Add drag-and-drop support for image files and folders
- Persist and restore window geometry via QSettings
- Remove duplicate _CONFIG_DATA load in constants.py
- Remove dead constructor stylesheet from ToolbarButton
- Add numpy>=1.26 dependency
This commit is contained in:
lukas 2026-03-10 16:07:52 +01:00
parent 2d4531013d
commit 78af24103c
40 changed files with 309 additions and 88 deletions

View File

@ -59,3 +59,4 @@
"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}"
"status.drag_drop" = "Bild oder Ordner hier ablegen."

View File

@ -59,3 +59,4 @@
"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}"
"status.drag_drop" = "Drop an image or folder here to open it."

View File

@ -108,8 +108,6 @@ def _extract_options(data: dict[str, Any]) -> dict[str, Any]:
return result return result
_CONFIG_DATA = _load_config_data()
DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)} 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)}

View File

@ -7,6 +7,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, Iterable, Tuple from typing import Dict, Iterable, Tuple
import numpy as np
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from PySide6 import QtGui from PySide6 import QtGui
@ -38,6 +39,36 @@ class Stats:
) )
def _rgb_to_hsv_numpy(arr: np.ndarray) -> np.ndarray:
"""Vectorized RGB→HSV conversion. arr shape: (H, W, 3), dtype float32, range [0,1].
Returns array of same shape with channels [H(0-360), S(0-100), V(0-100)].
"""
r = arr[..., 0]
g = arr[..., 1]
b = arr[..., 2]
cmax = np.maximum(np.maximum(r, g), b)
cmin = np.minimum(np.minimum(r, g), b)
delta = cmax - cmin
# Value
v = cmax
# Saturation
s = np.where(cmax > 0, delta / cmax, 0.0)
# Hue
h = np.zeros_like(r)
mask_r = (delta > 0) & (cmax == r)
mask_g = (delta > 0) & (cmax == g)
mask_b = (delta > 0) & (cmax == b)
h[mask_r] = (60.0 * ((g[mask_r] - b[mask_r]) / delta[mask_r])) % 360.0
h[mask_g] = (60.0 * ((b[mask_g] - r[mask_g]) / delta[mask_g]) + 120.0) % 360.0
h[mask_b] = (60.0 * ((r[mask_b] - g[mask_b]) / delta[mask_b]) + 240.0) % 360.0
return np.stack([h, s * 100.0, v * 100.0], axis=-1)
class QtImageProcessor: class QtImageProcessor:
"""Process images and build overlays for the Qt UI.""" """Process images and build overlays for the Qt UI."""
@ -132,44 +163,59 @@ 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."""
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()
return return
base = self.preview_img.convert("RGBA") base = self.preview_img.convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) arr = np.asarray(base, dtype=np.float32) # (H, W, 4)
draw = ImageDraw.Draw(overlay)
pixels = base.load()
width, height = base.size
highlight = (255, 0, 0, int(self.alpha))
matches_all = total_all = 0
matches_keep = total_keep = 0
matches_excl = total_excl = 0
mask = self._build_exclusion_mask(base.size) rgb = arr[..., :3] / 255.0
mask_px = mask.load() if mask else None alpha_ch = arr[..., 3] # alpha channel of the image
for y in range(height): hsv = _rgb_to_hsv_numpy(rgb) # (H, W, 3): H°, S%, V%
for x in range(width):
r, g, b, a = pixels[x, y] hue = hsv[..., 0]
if a == 0: sat = hsv[..., 1]
continue val = hsv[..., 2]
match = self._matches(r, g, b)
excluded = bool(mask_px and mask_px[x, y]) hue_min = float(self.hue_min)
total_all += 1 hue_max = float(self.hue_max)
if excluded: if hue_min <= hue_max:
total_excl += 1 hue_ok = (hue >= hue_min) & (hue <= hue_max)
if match:
matches_excl += 1
else: else:
total_keep += 1 hue_ok = (hue >= hue_min) | (hue <= hue_max)
if match:
draw.point((x, y), fill=highlight)
matches_keep += 1
if match:
matches_all += 1
self.overlay_img = overlay match_mask = (
hue_ok
& (sat >= float(self.sat_min))
& (val >= float(self.val_min))
& (val <= float(self.val_max))
& (alpha_ch > 0)
)
# Exclusion mask (same pixel space as preview)
excl_mask = self._build_exclusion_mask_numpy(base.size) # bool (H,W)
keep_match = match_mask & ~excl_mask
excl_match = match_mask & excl_mask
visible = alpha_ch > 0
matches_all = int(match_mask[visible].sum())
total_all = int(visible.sum())
matches_keep = int(keep_match[visible].sum())
total_keep = int((visible & ~excl_mask).sum())
matches_excl = int(excl_match[visible].sum())
total_excl = int((visible & excl_mask).sum())
# Build overlay image
overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8)
overlay_arr[keep_match, 0] = 255
overlay_arr[keep_match, 3] = int(self.alpha)
self.overlay_img = Image.fromarray(overlay_arr, "RGBA")
self.stats = Stats( self.stats = Stats(
matches_all=matches_all, matches_all=matches_all,
total_all=total_all, total_all=total_all,
@ -182,6 +228,7 @@ class QtImageProcessor:
# helpers ---------------------------------------------------------------- # helpers ----------------------------------------------------------------
def _matches(self, r: int, g: int, b: int) -> bool: def _matches(self, r: int, g: int, b: int) -> bool:
"""Single-pixel match — kept for compatibility / eyedropper use."""
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
hue = (h * 360.0) % 360.0 hue = (h * 360.0) % 360.0
if self.hue_min <= self.hue_max: if self.hue_min <= self.hue_max:
@ -192,6 +239,20 @@ 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:
"""Return (hue°, sat%, val%) of the preview pixel at (x, y), or None."""
if self.preview_img is None:
return None
img = self.preview_img.convert("RGBA")
try:
r, g, b, a = img.getpixel((x, y))
except IndexError:
return None
if a == 0:
return None
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
return (h * 360.0) % 360.0, s * 100.0, v * 100.0
# exported data ---------------------------------------------------------- # exported data ----------------------------------------------------------
def preview_pixmap(self) -> QtGui.QPixmap: def preview_pixmap(self) -> QtGui.QPixmap:
@ -241,3 +302,13 @@ class QtImageProcessor:
if len(points) >= 3: if len(points) >= 3:
draw.polygon(points, fill=255) draw.polygon(points, fill=255)
return mask return mask
def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray:
"""Return a boolean (H, W) mask — True where pixels are excluded."""
w, h = size
if not self.exclude_shapes:
return np.zeros((h, w), dtype=bool)
pil_mask = self._build_exclusion_mask(size)
if pil_mask is None:
return np.zeros((h, w), dtype=bool)
return np.asarray(pil_mask, dtype=bool)

View File

@ -75,24 +75,6 @@ class ToolbarButton(QtWidgets.QPushButton):
metrics = QtGui.QFontMetrics(self.font()) metrics = QtGui.QFontMetrics(self.font())
width = metrics.horizontalAdvance(text) + 28 width = metrics.horizontalAdvance(text) + 28
self.setMinimumWidth(width) self.setMinimumWidth(width)
self.setStyleSheet(
"""
QPushButton {
padding: 8px 16px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(255, 255, 255, 0.04);
color: #f7f7fb;
font-weight: 600;
}
QPushButton:hover {
background-color: rgba(255, 255, 255, 0.12);
}
QPushButton:pressed {
background-color: rgba(255, 255, 255, 0.18);
}
"""
)
self.clicked.connect(callback) self.clicked.connect(callback)
def apply_theme(self, colours: Dict[str, str]) -> None: def apply_theme(self, colours: Dict[str, str]) -> None:
@ -159,13 +141,15 @@ class ColourSwatch(QtWidgets.QPushButton):
class SliderControl(QtWidgets.QWidget): class SliderControl(QtWidgets.QWidget):
"""Slider with header and live value label.""" """Slider with header and editable live value input."""
value_changed = QtCore.Signal(str, int) value_changed = QtCore.Signal(str, int)
def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int): def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int):
super().__init__() super().__init__()
self.key = key self.key = key
self._minimum = minimum
self._maximum = maximum
layout = QtWidgets.QVBoxLayout(self) layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@ -176,47 +160,46 @@ class SliderControl(QtWidgets.QWidget):
self.title_label = QtWidgets.QLabel(title) self.title_label = QtWidgets.QLabel(title)
header.addWidget(self.title_label) header.addWidget(self.title_label)
header.addStretch(1) header.addStretch(1)
self.value_label = QtWidgets.QLabel(str(initial)) self.value_edit = QtWidgets.QLineEdit(str(initial))
header.addWidget(self.value_label) 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) layout.addLayout(header)
self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.slider.setRange(minimum, maximum) self.slider.setRange(minimum, maximum)
self.slider.setValue(initial) self.slider.setValue(initial)
self.slider.setCursor(QtCore.Qt.PointingHandCursor) self.slider.setCursor(QtCore.Qt.PointingHandCursor)
self.slider.setStyleSheet(
"""
QSlider::groove:horizontal {
border: 1px solid rgba(255,255,255,0.08);
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #9a4dff;
border: 1px solid rgba(255,255,255,0.2);
width: 14px;
margin: -5px 0;
border-radius: 7px;
}
"""
)
self.slider.valueChanged.connect(self._sync_value) self.slider.valueChanged.connect(self._sync_value)
layout.addWidget(self.slider) layout.addWidget(self.slider)
def _sync_value(self, value: int) -> None: def _sync_value(self, value: int) -> None:
self.value_label.setText(str(value)) self.value_edit.setText(str(value))
self.value_changed.emit(self.key, 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: def set_value(self, value: int) -> None:
self.slider.blockSignals(True) self.slider.blockSignals(True)
self.slider.setValue(value) self.slider.setValue(value)
self.slider.blockSignals(False) self.slider.blockSignals(False)
self.value_label.setText(str(value)) self.value_edit.setText(str(value))
def apply_theme(self, colours: Dict[str, str]) -> None: def apply_theme(self, colours: Dict[str, str]) -> None:
self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
self.value_label.setStyleSheet(f"color: {colours['text_dim']};") self.value_edit.setStyleSheet(
f"color: {colours['text_dim']}; background: transparent; "
f"border: 1px solid {colours['border']}; border-radius: 4px; padding: 0 2px;"
)
self.slider.setStyleSheet( self.slider.setStyleSheet(
f""" f"""
QSlider::groove:horizontal {{ QSlider::groove:horizontal {{
@ -240,6 +223,7 @@ class CanvasView(QtWidgets.QGraphicsView):
"""Interactive canvas for drawing exclusion shapes over the preview image.""" """Interactive canvas for drawing exclusion shapes over the preview image."""
shapes_changed = QtCore.Signal(list) 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: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -257,6 +241,7 @@ class CanvasView(QtWidgets.QGraphicsView):
self.shapes: list[dict[str, object]] = [] self.shapes: list[dict[str, object]] = []
self.mode: str = "rect" self.mode: str = "rect"
self.pick_mode: bool = False
self._drawing = False self._drawing = False
self._start_pos = QtCore.QPointF() self._start_pos = QtCore.QPointF()
self._last_pos = QtCore.QPointF() self._last_pos = QtCore.QPointF()
@ -305,6 +290,12 @@ class CanvasView(QtWidgets.QGraphicsView):
# event handling -------------------------------------------------------- # event handling --------------------------------------------------------
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: 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: if event.button() == QtCore.Qt.RightButton and self._pixmap_item:
self._drawing = True self._drawing = True
scene_pos = self.mapToScene(event.position().toPoint()) scene_pos = self.mapToScene(event.position().toPoint())
@ -407,6 +398,35 @@ class CanvasView(QtWidgets.QGraphicsView):
self._shape_items.append(path_item) self._shape_items.append(path_item)
class OverlayCanvas(QtWidgets.QGraphicsView):
"""Read-only QGraphicsView for displaying the colour-match overlay."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self._scene = QtWidgets.QGraphicsScene(self)
self.setScene(self._scene)
self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None
def set_pixmap(self, pixmap: QtGui.QPixmap) -> None:
self._scene.clear()
self._pixmap_item = self._scene.addPixmap(pixmap)
self._scene.setSceneRect(pixmap.rect())
self.resetTransform()
self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)
def clear_canvas(self) -> None:
self._scene.clear()
self._pixmap_item = None
def resizeEvent(self, event: QtGui.QResizeEvent) -> None: # type: ignore[override]
super().resizeEvent(event)
if self._pixmap_item:
self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)
class TitleBar(QtWidgets.QWidget): class TitleBar(QtWidgets.QWidget):
"""Custom title bar with native window controls.""" """Custom title bar with native window controls."""
@ -561,8 +581,10 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._register_default_actions() self._register_default_actions()
self.exclude_mode = "rect" self.exclude_mode = "rect"
self._pick_mode = False
self.image_view.set_mode(self.exclude_mode) self.image_view.set_mode(self.exclude_mode)
self.image_view.shapes_changed.connect(self._on_shapes_changed) 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._sync_sliders_from_processor()
self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current")) self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current"))
@ -570,6 +592,18 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.current_theme = "dark" self.current_theme = "dark"
self._apply_theme(self.current_theme) self._apply_theme(self.current_theme)
# Drag-and-drop
self.setAcceptDrops(True)
# Keyboard shortcuts
self._setup_shortcuts()
# Restore window geometry
self._settings = QtCore.QSettings("ICRA", "MainWindow")
geometry = self._settings.value("geometry")
if geometry:
self.restoreGeometry(geometry)
# Window control helpers ------------------------------------------------- # Window control helpers -------------------------------------------------
def toggle_maximise(self) -> None: def toggle_maximise(self) -> None:
@ -684,9 +718,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.image_view = CanvasView() self.image_view = CanvasView()
layout.addWidget(self.image_view, 0, 1) layout.addWidget(self.image_view, 0, 1)
self.overlay_view = QtWidgets.QLabel("<No image loaded>") self.overlay_view = OverlayCanvas()
self.overlay_view.setAlignment(QtCore.Qt.AlignCenter)
self.overlay_view.setScaledContents(True)
layout.addWidget(self.overlay_view, 0, 2) layout.addWidget(self.overlay_view, 0, 2)
self.next_button = QtWidgets.QToolButton() self.next_button = QtWidgets.QToolButton()
@ -723,7 +755,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"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, "choose_color": self.choose_colour,
"pick_from_image": self._coming_soon, "pick_from_image": self.pick_from_image,
"save_overlay": self.save_overlay, "save_overlay": self.save_overlay,
"toggle_free_draw": self.toggle_free_draw, "toggle_free_draw": self.toggle_free_draw,
"clear_excludes": self.clear_exclusions, "clear_excludes": self.clear_exclusions,
@ -835,6 +867,127 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"Feature coming soon in the PySide6 migration.", "Feature coming soon in the PySide6 migration.",
) )
# Shortcuts --------------------------------------------------------------
def _setup_shortcuts(self) -> None:
shortcuts = [
(QtGui.QKeySequence.Open, self.open_image),
(QtGui.QKeySequence("Ctrl+Shift+O"), self.open_folder),
(QtGui.QKeySequence.Save, self.save_overlay),
(QtGui.QKeySequence.Undo, self.undo_exclusion),
(QtGui.QKeySequence("Ctrl+R"), self._reset_sliders),
(QtGui.QKeySequence(QtCore.Qt.Key_Left), self.show_previous_image),
(QtGui.QKeySequence(QtCore.Qt.Key_Right), self.show_next_image),
(QtGui.QKeySequence(QtCore.Qt.Key_Escape), self._exit_pick_mode),
]
for seq, slot in shortcuts:
sc = QtGui.QShortcut(seq, self)
sc.activated.connect(slot)
# Pick-mode / eyedropper -------------------------------------------------
def pick_from_image(self) -> None:
if self.processor.preview_img is None:
QtWidgets.QMessageBox.information(
self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")
)
return
self._pick_mode = True
self.image_view.pick_mode = True
self.image_view.setCursor(QtCore.Qt.CrossCursor)
self.status_label.setText(self._t("status.pick_mode_ready"))
def _exit_pick_mode(self) -> None:
self._pick_mode = False
self.image_view.pick_mode = False
self.image_view.setCursor(QtCore.Qt.ArrowCursor)
self.status_label.setText(self._t("status.pick_mode_ended"))
def _on_pixel_picked(self, x: int, y: int) -> None:
result = self.processor.pick_colour(x, y)
if result is None:
self._exit_pick_mode()
return
hue, sat, val = result
# Derive a narrow hue range (±15°) and minimum sat/val from the pixel
margin = 15
hue_min = max(0, int(hue) - margin)
hue_max = min(360, int(hue) + margin)
sat_min = max(0, int(sat) - 20)
val_min = max(0, int(val) - 30)
val_max = 100
for attr, value in [
("hue_min", hue_min), ("hue_max", hue_max),
("sat_min", sat_min), ("val_min", val_min), ("val_max", val_max),
]:
ctrl = self._slider_controls.get(attr)
if ctrl:
ctrl.set_value(value)
self.processor.set_threshold(attr, value)
# Update colour swatch to the picked pixel colour
h_norm = hue / 360.0
s_norm = sat / 100.0
v_norm = val / 100.0
import colorsys
r, g, b = colorsys.hsv_to_rgb(h_norm, s_norm, v_norm)
hex_code = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
self._update_colour_display(hex_code, "")
self.status_label.setText(
self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val)
)
self._exit_pick_mode()
self._refresh_overlay_only()
# Drag-and-drop ----------------------------------------------------------
def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QtGui.QDropEvent) -> None:
urls = event.mimeData().urls()
if not urls:
return
path = Path(urls[0].toLocalFile())
if path.is_dir():
paths = sorted(
(p for p in path.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()),
key=lambda p: p.name.lower(),
)
if not paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.folder_empty"))
return
try:
loaded_path = self.processor.load_folder(paths)
except ValueError as exc:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc))
return
self._current_image_path = loaded_path
elif path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS:
try:
loaded_path = self.processor.load_single_image(path)
except Exception as exc:
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc))
return
self._current_image_path = loaded_path
if self.processor.reset_exclusions_on_switch:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
else:
return
self._refresh_views()
event.acceptProposedAction()
# Window lifecycle -------------------------------------------------------
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
self._settings.setValue("geometry", self.saveGeometry())
super().closeEvent(event)
def choose_colour(self) -> None: def choose_colour(self) -> None:
colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title")) colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title"))
if not colour.isValid(): if not colour.isValid():
@ -888,7 +1041,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
) )
self.image_view.set_accent(colours["highlight"]) self.image_view.set_accent(colours["highlight"])
self.overlay_view.setStyleSheet( self.overlay_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};" f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
) )
self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
@ -938,18 +1091,16 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def _refresh_views(self) -> None: def _refresh_views(self) -> None:
preview_pix = self.processor.preview_pixmap() preview_pix = self.processor.preview_pixmap()
overlay_pix = self.processor.overlay_pixmap()
if preview_pix.isNull(): if preview_pix.isNull():
self.image_view.clear_canvas() self.image_view.clear_canvas()
self.image_view.set_shapes([]) self.image_view.set_shapes([])
self.overlay_view.setText("<No image loaded>") self.overlay_view.clear_canvas()
self.overlay_view.setPixmap(QtGui.QPixmap())
else: else:
self.image_view.set_pixmap(preview_pix) self.image_view.set_pixmap(preview_pix)
self.image_view.set_shapes(self.processor.exclude_shapes.copy()) self.image_view.set_shapes(self.processor.exclude_shapes.copy())
self.overlay_view.setText("") overlay_pix = self.processor.overlay_pixmap()
overlay_pix = self._overlay_with_outlines(overlay_pix) overlay_pix = self._overlay_with_outlines(overlay_pix)
self.overlay_view.setPixmap(overlay_pix) self.overlay_view.set_pixmap(overlay_pix)
if self._current_image_path and self.processor.preview_img: if self._current_image_path and self.processor.preview_img:
width, height = self.processor.preview_img.size width, height = self.processor.preview_img.size
total = len(self.processor.preview_paths) total = len(self.processor.preview_paths)
@ -968,11 +1119,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return return
pix = self.processor.overlay_pixmap() pix = self.processor.overlay_pixmap()
if pix.isNull(): if pix.isNull():
self.overlay_view.setText("<No overlay>") self.overlay_view.clear_canvas()
self.overlay_view.setPixmap(QtGui.QPixmap())
else: else:
self.overlay_view.setText("") self.overlay_view.set_pixmap(self._overlay_with_outlines(pix))
self.overlay_view.setPixmap(self._overlay_with_outlines(pix))
self.ratio_label.setText(self.processor.stats.summary(self._t)) self.ratio_label.setText(self.processor.stats.summary(self._t))
def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None: def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None:

BIN
images/ingame/271.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/296.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/328.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/460.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/487.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/552.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/572.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/583.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/654.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/696.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/70.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/705.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/83.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/858.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/ingame/86.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/862.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/911.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 464 KiB

View File

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 369 KiB

View File

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 376 KiB

View File

Before

Width:  |  Height:  |  Size: 380 KiB

After

Width:  |  Height:  |  Size: 380 KiB

View File

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 387 KiB

View File

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 456 KiB

View File

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 465 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 378 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 393 KiB

View File

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 375 KiB

View File

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 441 KiB

View File

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 389 KiB

View File

@ -7,6 +7,7 @@ authors = [{ name = "ICRA contributors" }]
license = "MIT" license = "MIT"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"numpy>=1.26",
"pillow>=10.0.0", "pillow>=10.0.0",
"PySide6>=6.7", "PySide6>=6.7",
] ]