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_preview_available" = "Keine Preview vorhanden."
|
||||
"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_preview_available" = "No preview available."
|
||||
"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
|
||||
|
||||
|
||||
_CONFIG_DATA = _load_config_data()
|
||||
|
||||
DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)}
|
||||
LANGUAGE = _extract_language(_CONFIG_DATA)
|
||||
OPTIONS = {**_OPTION_DEFAULTS, **_extract_options(_CONFIG_DATA)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
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:
|
||||
"""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)
|
||||
|
||||
def _rebuild_overlay(self) -> None:
|
||||
"""Build colour-match overlay using vectorized NumPy operations."""
|
||||
if self.preview_img is None:
|
||||
self.overlay_img = None
|
||||
self.stats = Stats()
|
||||
return
|
||||
|
||||
base = self.preview_img.convert("RGBA")
|
||||
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
||||
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
|
||||
arr = np.asarray(base, dtype=np.float32) # (H, W, 4)
|
||||
|
||||
mask = self._build_exclusion_mask(base.size)
|
||||
mask_px = mask.load() if mask else None
|
||||
rgb = arr[..., :3] / 255.0
|
||||
alpha_ch = arr[..., 3] # alpha channel of the image
|
||||
|
||||
for y in range(height):
|
||||
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
|
||||
hsv = _rgb_to_hsv_numpy(rgb) # (H, W, 3): H°, S%, V%
|
||||
|
||||
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:
|
||||
total_keep += 1
|
||||
if match:
|
||||
draw.point((x, y), fill=highlight)
|
||||
matches_keep += 1
|
||||
if match:
|
||||
matches_all += 1
|
||||
hue_ok = (hue >= hue_min) | (hue <= hue_max)
|
||||
|
||||
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(
|
||||
matches_all=matches_all,
|
||||
total_all=total_all,
|
||||
|
|
@ -182,6 +228,7 @@ class QtImageProcessor:
|
|||
# helpers ----------------------------------------------------------------
|
||||
|
||||
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)
|
||||
hue = (h * 360.0) % 360.0
|
||||
if self.hue_min <= self.hue_max:
|
||||
|
|
@ -192,6 +239,20 @@ class QtImageProcessor:
|
|||
val_ok = self.val_min <= v * 100.0 <= self.val_max
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
def preview_pixmap(self) -> QtGui.QPixmap:
|
||||
|
|
@ -241,3 +302,13 @@ class QtImageProcessor:
|
|||
if len(points) >= 3:
|
||||
draw.polygon(points, fill=255)
|
||||
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())
|
||||
width = metrics.horizontalAdvance(text) + 28
|
||||
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)
|
||||
|
||||
def apply_theme(self, colours: Dict[str, str]) -> None:
|
||||
|
|
@ -159,13 +141,15 @@ class ColourSwatch(QtWidgets.QPushButton):
|
|||
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
|
@ -176,47 +160,46 @@ class SliderControl(QtWidgets.QWidget):
|
|||
self.title_label = QtWidgets.QLabel(title)
|
||||
header.addWidget(self.title_label)
|
||||
header.addStretch(1)
|
||||
self.value_label = QtWidgets.QLabel(str(initial))
|
||||
header.addWidget(self.value_label)
|
||||
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.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)
|
||||
layout.addWidget(self.slider)
|
||||
|
||||
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)
|
||||
|
||||
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_label.setText(str(value))
|
||||
self.value_edit.setText(str(value))
|
||||
|
||||
def apply_theme(self, colours: Dict[str, str]) -> None:
|
||||
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(
|
||||
f"""
|
||||
QSlider::groove:horizontal {{
|
||||
|
|
@ -240,6 +223,7 @@ 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)
|
||||
|
|
@ -257,6 +241,7 @@ class CanvasView(QtWidgets.QGraphicsView):
|
|||
|
||||
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()
|
||||
|
|
@ -305,6 +290,12 @@ class CanvasView(QtWidgets.QGraphicsView):
|
|||
# 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())
|
||||
|
|
@ -407,6 +398,35 @@ class CanvasView(QtWidgets.QGraphicsView):
|
|||
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):
|
||||
"""Custom title bar with native window controls."""
|
||||
|
||||
|
|
@ -561,8 +581,10 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
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_colour_display(DEFAULT_COLOUR, self._t("palette.current"))
|
||||
|
|
@ -570,6 +592,18 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
self.current_theme = "dark"
|
||||
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 -------------------------------------------------
|
||||
|
||||
def toggle_maximise(self) -> None:
|
||||
|
|
@ -684,9 +718,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
self.image_view = CanvasView()
|
||||
layout.addWidget(self.image_view, 0, 1)
|
||||
|
||||
self.overlay_view = QtWidgets.QLabel("<No image loaded>")
|
||||
self.overlay_view.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self.overlay_view.setScaledContents(True)
|
||||
self.overlay_view = OverlayCanvas()
|
||||
layout.addWidget(self.overlay_view, 0, 2)
|
||||
|
||||
self.next_button = QtWidgets.QToolButton()
|
||||
|
|
@ -723,7 +755,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
"open_image": self.open_image,
|
||||
"open_folder": self.open_folder,
|
||||
"choose_color": self.choose_colour,
|
||||
"pick_from_image": self._coming_soon,
|
||||
"pick_from_image": self.pick_from_image,
|
||||
"save_overlay": self.save_overlay,
|
||||
"toggle_free_draw": self.toggle_free_draw,
|
||||
"clear_excludes": self.clear_exclusions,
|
||||
|
|
@ -835,6 +867,127 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
"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:
|
||||
colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title"))
|
||||
if not colour.isValid():
|
||||
|
|
@ -888,7 +1041,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
)
|
||||
self.image_view.set_accent(colours["highlight"])
|
||||
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;")
|
||||
|
|
@ -938,18 +1091,16 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
|
||||
def _refresh_views(self) -> None:
|
||||
preview_pix = self.processor.preview_pixmap()
|
||||
overlay_pix = self.processor.overlay_pixmap()
|
||||
if preview_pix.isNull():
|
||||
self.image_view.clear_canvas()
|
||||
self.image_view.set_shapes([])
|
||||
self.overlay_view.setText("<No image loaded>")
|
||||
self.overlay_view.setPixmap(QtGui.QPixmap())
|
||||
self.overlay_view.clear_canvas()
|
||||
else:
|
||||
self.image_view.set_pixmap(preview_pix)
|
||||
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)
|
||||
self.overlay_view.setPixmap(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)
|
||||
|
|
@ -968,11 +1119,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
return
|
||||
pix = self.processor.overlay_pixmap()
|
||||
if pix.isNull():
|
||||
self.overlay_view.setText("<No overlay>")
|
||||
self.overlay_view.setPixmap(QtGui.QPixmap())
|
||||
self.overlay_view.clear_canvas()
|
||||
else:
|
||||
self.overlay_view.setText("")
|
||||
self.overlay_view.setPixmap(self._overlay_with_outlines(pix))
|
||||
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:
|
||||
|
|
|
|||
|
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"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"numpy>=1.26",
|
||||
"pillow>=10.0.0",
|
||||
"PySide6>=6.7",
|
||||
]
|
||||
|
|
|
|||