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
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
if a == 0:
|
|
||||||
continue
|
|
||||||
match = self._matches(r, g, b)
|
|
||||||
excluded = bool(mask_px and mask_px[x, y])
|
|
||||||
total_all += 1
|
|
||||||
if excluded:
|
|
||||||
total_excl += 1
|
|
||||||
if match:
|
|
||||||
matches_excl += 1
|
|
||||||
else:
|
|
||||||
total_keep += 1
|
|
||||||
if match:
|
|
||||||
draw.point((x, y), fill=highlight)
|
|
||||||
matches_keep += 1
|
|
||||||
if match:
|
|
||||||
matches_all += 1
|
|
||||||
|
|
||||||
self.overlay_img = overlay
|
hue = hsv[..., 0]
|
||||||
|
sat = hsv[..., 1]
|
||||||
|
val = hsv[..., 2]
|
||||||
|
|
||||||
|
hue_min = float(self.hue_min)
|
||||||
|
hue_max = float(self.hue_max)
|
||||||
|
if hue_min <= hue_max:
|
||||||
|
hue_ok = (hue >= hue_min) & (hue <= hue_max)
|
||||||
|
else:
|
||||||
|
hue_ok = (hue >= hue_min) | (hue <= hue_max)
|
||||||
|
|
||||||
|
match_mask = (
|
||||||
|
hue_ok
|
||||||
|
& (sat >= float(self.sat_min))
|
||||||
|
& (val >= float(self.val_min))
|
||||||
|
& (val <= float(self.val_max))
|
||||||
|
& (alpha_ch > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 464 KiB After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 376 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 380 KiB After Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 456 KiB After Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 465 KiB After Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 393 KiB After Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 375 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 441 KiB After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 389 KiB After Width: | Height: | Size: 389 KiB |
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||