Replace toolbar with categorized menu system, standardize 'color', and improve CSV export
- Migrated toolbar to QMenuBar to fix UI crowding - Categorized actions into File, Edit, Tools, and View - Added dynamic theming to QMenuBar and QMenu - Localized Export CSV delimiter and decimals for German Excel - Padded exported CSV values for clean plain-text alignment - Globally standardized the spelling of 'color' - Removed duplicate code and old Tkinter codebase
This commit is contained in:
+11
-6
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6 import QtGui, QtWidgets
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from app.logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE
|
||||
from .main_window import MainWindow
|
||||
@@ -46,16 +46,21 @@ def create_application() -> QtWidgets.QApplication:
|
||||
def run() -> int:
|
||||
"""Run the PySide6 GUI."""
|
||||
app = create_application()
|
||||
from app.logic import OVERLAY_COLOR
|
||||
window = MainWindow(
|
||||
language=LANGUAGE,
|
||||
defaults=DEFAULTS.copy(),
|
||||
reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
|
||||
overlay_color=OVERLAY_COLOR,
|
||||
)
|
||||
primary_screen = app.primaryScreen()
|
||||
if primary_screen is not None:
|
||||
geometry = primary_screen.availableGeometry()
|
||||
window.setGeometry(geometry)
|
||||
window.showMaximized()
|
||||
|
||||
# Respect saved geometry from QSettings; fall back to maximised on first launch
|
||||
settings = QtCore.QSettings("ICRA", "MainWindow")
|
||||
if settings.value("geometry"):
|
||||
window.show()
|
||||
else:
|
||||
primary_screen = app.primaryScreen()
|
||||
if primary_screen is not None:
|
||||
window.setGeometry(primary_screen.availableGeometry())
|
||||
window.showMaximized()
|
||||
return app.exec()
|
||||
|
||||
@@ -55,7 +55,8 @@ def _rgb_to_hsv_numpy(arr: np.ndarray) -> np.ndarray:
|
||||
v = cmax
|
||||
|
||||
# Saturation
|
||||
s = np.where(cmax > 0, delta / cmax, 0.0)
|
||||
s = np.zeros_like(r)
|
||||
np.divide(delta, cmax, out=s, where=cmax > 0)
|
||||
|
||||
# Hue
|
||||
h = np.zeros_like(r)
|
||||
@@ -80,6 +81,11 @@ class QtImageProcessor:
|
||||
self.current_index: int = -1
|
||||
self.stats = Stats()
|
||||
|
||||
# Overlay tint color
|
||||
self.overlay_r = 255
|
||||
self.overlay_g = 0
|
||||
self.overlay_b = 0
|
||||
|
||||
self.defaults: Dict[str, int] = {
|
||||
"hue_min": 0,
|
||||
"hue_max": 360,
|
||||
@@ -163,7 +169,7 @@ 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."""
|
||||
"""Build color-match overlay using vectorized NumPy operations."""
|
||||
if self.preview_img is None:
|
||||
self.overlay_img = None
|
||||
self.stats = Stats()
|
||||
@@ -212,7 +218,9 @@ class QtImageProcessor:
|
||||
|
||||
# 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, 0] = self.overlay_r
|
||||
overlay_arr[keep_match, 1] = self.overlay_g
|
||||
overlay_arr[keep_match, 2] = self.overlay_b
|
||||
overlay_arr[keep_match, 3] = int(self.alpha)
|
||||
|
||||
self.overlay_img = Image.fromarray(overlay_arr, "RGBA")
|
||||
@@ -239,7 +247,7 @@ 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:
|
||||
def pick_color(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
|
||||
@@ -303,6 +311,17 @@ class QtImageProcessor:
|
||||
draw.polygon(points, fill=255)
|
||||
return mask
|
||||
|
||||
def set_overlay_color(self, hex_code: str) -> None:
|
||||
"""Set the RGB channels for the match overlay from a hex string."""
|
||||
if not hex_code.startswith("#") or len(hex_code) not in (7, 9):
|
||||
return
|
||||
try:
|
||||
self.overlay_r = int(hex_code[1:3], 16)
|
||||
self.overlay_g = int(hex_code[3:5], 16)
|
||||
self.overlay_b = int(hex_code[5:7], 16)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
+225
-130
@@ -2,18 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
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
|
||||
|
||||
DEFAULT_COLOUR = "#763e92"
|
||||
DEFAULT_COLOR = "#763e92"
|
||||
|
||||
PRESET_COLOURS: List[Tuple[str, str]] = [
|
||||
PRESET_COLORS: List[Tuple[str, str]] = [
|
||||
("palette.swatch.red", "#ff3b30"),
|
||||
("palette.swatch.orange", "#ff9500"),
|
||||
("palette.swatch.yellow", "#ffd60a"),
|
||||
@@ -64,41 +66,8 @@ THEMES: Dict[str, Dict[str, str]] = {
|
||||
}
|
||||
|
||||
|
||||
class ToolbarButton(QtWidgets.QPushButton):
|
||||
"""Rounded toolbar button inspired by the legacy design."""
|
||||
|
||||
def __init__(self, icon_text: str, label: str, callback: Callable[[], None], parent: QtWidgets.QWidget | None = None):
|
||||
text = f"{icon_text} {label}"
|
||||
super().__init__(text, parent)
|
||||
self.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
self.setFixedHeight(32)
|
||||
metrics = QtGui.QFontMetrics(self.font())
|
||||
width = metrics.horizontalAdvance(text) + 28
|
||||
self.setMinimumWidth(width)
|
||||
self.clicked.connect(callback)
|
||||
|
||||
def apply_theme(self, colours: Dict[str, str]) -> None:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid {colours['border']};
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
color: {colours['text']};
|
||||
font-weight: 600;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class ColourSwatch(QtWidgets.QPushButton):
|
||||
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):
|
||||
@@ -108,10 +77,10 @@ class ColourSwatch(QtWidgets.QPushButton):
|
||||
self.callback = callback
|
||||
self.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
self.setFixedSize(28, 28)
|
||||
self._apply_colour(hex_code)
|
||||
self._apply_color(hex_code)
|
||||
self.clicked.connect(lambda: callback(hex_code, self.name_key))
|
||||
|
||||
def _apply_colour(self, hex_code: str) -> None:
|
||||
def _apply_color(self, hex_code: str) -> None:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
@@ -125,16 +94,16 @@ class ColourSwatch(QtWidgets.QPushButton):
|
||||
"""
|
||||
)
|
||||
|
||||
def apply_theme(self, colours: Dict[str, str]) -> None:
|
||||
def apply_theme(self, colors: Dict[str, str]) -> None:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
background-color: {self.hex_code};
|
||||
border: 2px solid {colours['border']};
|
||||
border: 2px solid {colors['border']};
|
||||
border-radius: 6px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
border-color: {colours['accent']};
|
||||
border-color: {colors['accent']};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
@@ -194,22 +163,22 @@ class SliderControl(QtWidgets.QWidget):
|
||||
self.slider.blockSignals(False)
|
||||
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;")
|
||||
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: {colours['text_dim']}; background: transparent; "
|
||||
f"border: 1px solid {colours['border']}; border-radius: 4px; padding: 0 2px;"
|
||||
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 {colours['border']};
|
||||
border: 1px solid {colors['border']};
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.14);
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
background: {colours['accent_secondary']};
|
||||
background: {colors['accent_secondary']};
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
width: 14px;
|
||||
margin: -5px 0;
|
||||
@@ -271,8 +240,8 @@ class CanvasView(QtWidgets.QGraphicsView):
|
||||
def set_mode(self, mode: str) -> None:
|
||||
self.mode = mode
|
||||
|
||||
def set_accent(self, colour: str) -> None:
|
||||
self._accent = QtGui.QColor(colour)
|
||||
def set_accent(self, color: str) -> None:
|
||||
self._accent = QtGui.QColor(color)
|
||||
self._redraw_shapes()
|
||||
|
||||
def undo_last(self) -> None:
|
||||
@@ -399,7 +368,7 @@ class CanvasView(QtWidgets.QGraphicsView):
|
||||
|
||||
|
||||
class OverlayCanvas(QtWidgets.QGraphicsView):
|
||||
"""Read-only QGraphicsView for displaying the colour-match overlay."""
|
||||
"""Read-only QGraphicsView for displaying the color-match overlay."""
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -488,17 +457,17 @@ class TitleBar(QtWidgets.QWidget):
|
||||
)
|
||||
return btn
|
||||
|
||||
def apply_theme(self, colours: Dict[str, str]) -> None:
|
||||
def apply_theme(self, colors: Dict[str, str]) -> None:
|
||||
palette = self.palette()
|
||||
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colours["titlebar_bg"]))
|
||||
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colors["titlebar_bg"]))
|
||||
self.setPalette(palette)
|
||||
self.title_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;")
|
||||
hover_bg = "#d0342c" if colours["titlebar_bg"] != "#e9ebf5" else "#e6675a"
|
||||
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: {colours['text']};
|
||||
color: {colors['text']};
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
}}
|
||||
@@ -513,7 +482,7 @@ class TitleBar(QtWidgets.QWidget):
|
||||
f"""
|
||||
QPushButton {{
|
||||
background-color: transparent;
|
||||
color: {colours['text']};
|
||||
color: {colors['text']};
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
}}
|
||||
@@ -538,7 +507,7 @@ class TitleBar(QtWidgets.QWidget):
|
||||
class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
"""Main application window containing all controls."""
|
||||
|
||||
def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None:
|
||||
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"))
|
||||
@@ -560,12 +529,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
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, 24, 24, 24)
|
||||
self.content_layout.setContentsMargins(24, 0, 24, 24)
|
||||
self.content_layout.setSpacing(18)
|
||||
|
||||
self.content_layout.addLayout(self._build_toolbar())
|
||||
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)
|
||||
@@ -576,7 +548,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
|
||||
self._is_maximised = False
|
||||
self._current_image_path: Path | None = None
|
||||
self._current_colour = DEFAULT_COLOUR
|
||||
self._current_color = DEFAULT_COLOR
|
||||
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
|
||||
self._register_default_actions()
|
||||
|
||||
@@ -587,7 +559,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
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"))
|
||||
self._update_color_display(DEFAULT_COLOR, self._t("palette.current"))
|
||||
|
||||
self.current_theme = "dark"
|
||||
self._apply_theme(self.current_theme)
|
||||
@@ -598,6 +570,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
# 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")
|
||||
@@ -626,33 +604,38 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
|
||||
# UI builders ------------------------------------------------------------
|
||||
|
||||
def _build_toolbar(self) -> QtWidgets.QHBoxLayout:
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
layout.setSpacing(12)
|
||||
def _build_menu_bar(self) -> QtWidgets.QMenuBar:
|
||||
self.menu_bar = QtWidgets.QMenuBar(self)
|
||||
|
||||
buttons = [
|
||||
("open_image", "🖼", "toolbar.open_image"),
|
||||
("open_folder", "📂", "toolbar.open_folder"),
|
||||
("choose_color", "🎨", "toolbar.choose_color"),
|
||||
("pick_from_image", "🖱", "toolbar.pick_from_image"),
|
||||
("save_overlay", "💾", "toolbar.save_overlay"),
|
||||
("toggle_free_draw", "△", "toolbar.toggle_free_draw"),
|
||||
("clear_excludes", "🧹", "toolbar.clear_excludes"),
|
||||
("undo_exclude", "↩", "toolbar.undo_exclude"),
|
||||
("reset_sliders", "🔄", "toolbar.reset_sliders"),
|
||||
("toggle_theme", "🌓", "toolbar.toggle_theme"),
|
||||
]
|
||||
self._toolbar_buttons: Dict[str, ToolbarButton] = {}
|
||||
for key, icon_txt, text_key in buttons:
|
||||
label = self._t(text_key)
|
||||
button = ToolbarButton(icon_txt, label, lambda _checked=False, k=key: self._invoke_action(k))
|
||||
layout.addWidget(button)
|
||||
self._toolbar_buttons[key] = button
|
||||
# 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")
|
||||
|
||||
layout.addStretch(1)
|
||||
# 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"))
|
||||
|
||||
# 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"))
|
||||
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
|
||||
return layout
|
||||
return self.menu_bar
|
||||
|
||||
def _build_palette(self) -> QtWidgets.QHBoxLayout:
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
@@ -664,13 +647,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
self.current_label = QtWidgets.QLabel(self._t("palette.current"))
|
||||
current_group.addWidget(self.current_label)
|
||||
|
||||
self.current_colour_swatch = QtWidgets.QLabel()
|
||||
self.current_colour_swatch.setFixedSize(28, 28)
|
||||
self.current_colour_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOUR}; border-radius: 6px;")
|
||||
current_group.addWidget(self.current_colour_swatch)
|
||||
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_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})")
|
||||
current_group.addWidget(self.current_colour_label)
|
||||
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"))
|
||||
@@ -678,13 +661,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
|
||||
swatch_container = QtWidgets.QHBoxLayout()
|
||||
swatch_container.setSpacing(8)
|
||||
self.swatch_buttons: List[ColourSwatch] = []
|
||||
for name_key, hex_code in PRESET_COLOURS:
|
||||
swatch = ColourSwatch(self._t(name_key), hex_code, self._update_colour_display)
|
||||
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:
|
||||
@@ -754,7 +739,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
self._toolbar_actions = {
|
||||
"open_image": self.open_image,
|
||||
"open_folder": self.open_folder,
|
||||
"choose_color": self.choose_colour,
|
||||
"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,
|
||||
@@ -810,6 +796,83 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
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
|
||||
|
||||
csv_path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self,
|
||||
self._t("dialog.export_stats_title"),
|
||||
str(self.processor.preview_paths[0].parent / "icra_stats.csv"),
|
||||
self._t("dialog.csv_filter")
|
||||
)
|
||||
if not csv_path:
|
||||
return
|
||||
|
||||
total = len(self.processor.preview_paths)
|
||||
|
||||
is_eu = self.language == "de"
|
||||
delimiter = ";" if is_eu else ","
|
||||
decimal = "," if is_eu else "."
|
||||
|
||||
headers = [
|
||||
"Filename",
|
||||
"Color",
|
||||
"Matching Pixels",
|
||||
"Matching Pixels w/ Exclusions",
|
||||
"Excluded Pixels"
|
||||
]
|
||||
rows = [headers]
|
||||
|
||||
for i, img_path in enumerate(self.processor.preview_paths):
|
||||
self.status_label.setText(self._t("status.exporting", current=str(i+1), total=str(total)))
|
||||
QtWidgets.QApplication.processEvents() # Keep UI vaguely responsive
|
||||
|
||||
# Process without modifying the UI current_index
|
||||
img = Image.open(img_path)
|
||||
old_orig = self.processor.orig_img
|
||||
old_preview = self.processor.preview_img
|
||||
|
||||
self.processor.orig_img = img
|
||||
self.processor._build_preview()
|
||||
self.processor._rebuild_overlay()
|
||||
s = self.processor.stats
|
||||
|
||||
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)
|
||||
|
||||
rows.append([
|
||||
img_path.name,
|
||||
self._current_color,
|
||||
pct_all_str,
|
||||
pct_keep_str,
|
||||
pct_excl_str
|
||||
])
|
||||
img.close()
|
||||
|
||||
# Restore previous state
|
||||
self.processor.orig_img = old_orig
|
||||
self.processor.preview_img = old_preview
|
||||
|
||||
# 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)]
|
||||
|
||||
with open(csv_path, mode="w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f, delimiter=delimiter)
|
||||
for row in rows:
|
||||
padded_row = [f"{str(item):>{width}}" for item, width in zip(row, col_widths)]
|
||||
writer.writerow(padded_row)
|
||||
|
||||
# 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"))
|
||||
@@ -838,17 +901,17 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
|
||||
# Helpers ----------------------------------------------------------------
|
||||
|
||||
def _update_colour_display(self, hex_code: str, label: str) -> None:
|
||||
self._current_colour = hex_code
|
||||
self.current_colour_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
|
||||
self.current_colour_label.setText(f"({hex_code})")
|
||||
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._refresh_overlay_only()
|
||||
self._slider_timer.start()
|
||||
|
||||
def _reset_sliders(self) -> None:
|
||||
for _, attr, _, _ in SLIDER_SPECS:
|
||||
@@ -904,7 +967,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
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)
|
||||
result = self.processor.pick_color(x, y)
|
||||
if result is None:
|
||||
self._exit_pick_mode()
|
||||
return
|
||||
@@ -927,14 +990,14 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
ctrl.set_value(value)
|
||||
self.processor.set_threshold(attr, value)
|
||||
|
||||
# Update colour swatch to the picked pixel colour
|
||||
# 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_colour_display(hex_code, "")
|
||||
self._update_color_display(hex_code, "")
|
||||
|
||||
self.status_label.setText(
|
||||
self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val)
|
||||
@@ -988,12 +1051,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
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():
|
||||
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 = colour.name()
|
||||
self._update_colour_display(hex_code, self._t("dialog.choose_colour_title"))
|
||||
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()
|
||||
@@ -1034,34 +1097,66 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
self._apply_theme(self.current_theme)
|
||||
|
||||
def _apply_theme(self, mode: str) -> None:
|
||||
colours = THEMES[mode]
|
||||
self.content.setStyleSheet(f"background-color: {colours['window_bg']};")
|
||||
colors = THEMES[mode]
|
||||
self.content.setStyleSheet(f"background-color: {colors['window_bg']};")
|
||||
self.image_view.setStyleSheet(
|
||||
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
|
||||
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;"
|
||||
)
|
||||
self.image_view.set_accent(colours["highlight"])
|
||||
self.image_view.set_accent(colors["highlight"])
|
||||
self.overlay_view.setStyleSheet(
|
||||
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
|
||||
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;"
|
||||
)
|
||||
|
||||
self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
|
||||
self.current_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
|
||||
self.current_colour_label.setStyleSheet(f"color: {colours['text_dim']};")
|
||||
self.more_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
|
||||
self.filename_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;")
|
||||
self.ratio_label.setStyleSheet(f"color: {colours['highlight']}; font-weight: 600;")
|
||||
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 button in self._toolbar_buttons.values():
|
||||
button.apply_theme(colours)
|
||||
for swatch in self.swatch_buttons:
|
||||
swatch.apply_theme(colours)
|
||||
swatch.apply_theme(colors)
|
||||
for control in self._slider_controls.values():
|
||||
control.apply_theme(colours)
|
||||
control.apply_theme(colors)
|
||||
|
||||
self._style_nav_button(self.prev_button)
|
||||
self._style_nav_button(self.next_button)
|
||||
|
||||
self.title_bar.apply_theme(colours)
|
||||
self.title_bar.apply_theme(colors)
|
||||
|
||||
def _sync_sliders_from_processor(self) -> None:
|
||||
for _, attr, _, _ in SLIDER_SPECS:
|
||||
@@ -1076,11 +1171,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
return key
|
||||
|
||||
def _style_nav_button(self, button: QtWidgets.QToolButton) -> None:
|
||||
colours = THEMES[self.current_theme]
|
||||
colors = THEMES[self.current_theme]
|
||||
button.setStyleSheet(
|
||||
f"QToolButton {{ border-radius: 19px; background-color: {colours['panel_bg']}; "
|
||||
f"border: 1px solid {colours['border']}; color: {colours['text']}; }}"
|
||||
f"QToolButton:hover {{ background-color: {colours['accent_secondary']}; color: white; }}"
|
||||
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):
|
||||
@@ -1133,8 +1228,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
||||
return pixmap
|
||||
result = QtGui.QPixmap(pixmap)
|
||||
painter = QtGui.QPainter(result)
|
||||
colour = QtGui.QColor(THEMES[self.current_theme]["highlight"])
|
||||
pen = QtGui.QPen(colour)
|
||||
color = QtGui.QColor(THEMES[self.current_theme]["highlight"])
|
||||
pen = QtGui.QPen(color)
|
||||
pen.setWidth(3)
|
||||
pen.setCosmetic(True)
|
||||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||||
|
||||
Reference in New Issue
Block a user