From acfcf99d1535db98850adca54e8a90df79c4410a Mon Sep 17 00:00:00 2001 From: lukas Date: Tue, 10 Mar 2026 18:32:14 +0100 Subject: [PATCH] Feature: Settings Import/Export and UI Polish - Implemented JSON-based settings import/export with smart scaling - Locked image overlay color to Red (#ff0000) permanently - Decoupled analyzer target color from display mask color - Added menu separators for better organization - Fixed overlay synchronization and scaling bugs during import --- app/lang/de.toml | 7 +++ app/lang/en.toml | 7 +++ app/qt/image_processor.py | 13 +++-- app/qt/main_window.py | 103 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/app/lang/de.toml b/app/lang/de.toml index bdedbeb..a67115d 100644 --- a/app/lang/de.toml +++ b/app/lang/de.toml @@ -59,6 +59,13 @@ "dialog.no_image_loaded" = "Kein Bild geladen." "dialog.no_preview_available" = "Keine Preview vorhanden." "dialog.overlay_saved" = "Overlay gespeichert: {path}" +"dialog.json_filter" = "JSON-Dateien (*.json)" +"dialog.export_settings_title" = "Einstellungen als JSON exportieren" +"dialog.import_settings_title" = "Einstellungen aus JSON importieren" +"status.settings_exported" = "Einstellungen exportiert: {path}" +"status.settings_imported" = "Einstellungen importiert." +"toolbar.export_settings" = "Einstellungen exportieren (JSON)" +"toolbar.import_settings" = "Einstellungen importieren (JSON)" "dialog.export_stats_title" = "Ordner-Statistiken exportieren (CSV)" "dialog.csv_filter" = "CSV-Dateien (*.csv)" "status.drag_drop" = "Bild oder Ordner hier ablegen." diff --git a/app/lang/en.toml b/app/lang/en.toml index 68f9391..bb9b5be 100644 --- a/app/lang/en.toml +++ b/app/lang/en.toml @@ -59,6 +59,13 @@ "dialog.no_image_loaded" = "No image loaded." "dialog.no_preview_available" = "No preview available." "dialog.overlay_saved" = "Overlay saved: {path}" +"dialog.json_filter" = "JSON Files (*.json)" +"dialog.export_settings_title" = "Export settings to JSON" +"dialog.import_settings_title" = "Import settings from JSON" +"status.settings_exported" = "Settings exported: {path}" +"status.settings_imported" = "Settings imported." +"toolbar.export_settings" = "Export settings (JSON)" +"toolbar.import_settings" = "Import settings (JSON)" "dialog.export_stats_title" = "Export Folder Statistics (CSV)" "dialog.csv_filter" = "CSV Files (*.csv)" "status.drag_drop" = "Drop an image or folder here to open it." diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py index c19f8cd..a6d1d0f 100644 --- a/app/qt/image_processor.py +++ b/app/qt/image_processor.py @@ -316,8 +316,10 @@ class QtImageProcessor: return self._to_pixmap(self.preview_img) def overlay_pixmap(self) -> QtGui.QPixmap: - if self.preview_img is None or self.overlay_img is None: + if self.preview_img is None: return QtGui.QPixmap() + if self.overlay_img is None: + return self.preview_pixmap() merged = Image.alpha_composite(self.preview_img.convert("RGBA"), self.overlay_img) return self._to_pixmap(merged) @@ -331,7 +333,7 @@ class QtImageProcessor: # exclusions ------------------------------------------------------------- - def set_exclusions(self, shapes: list[dict[str, object]]) -> None: + def set_exclusions(self, shapes: list[dict[str, object]], ref_size: Tuple[int, int] | None = None) -> None: copied: list[dict[str, object]] = [] for shape in shapes: kind = shape.get("kind") @@ -342,7 +344,10 @@ class QtImageProcessor: pts = shape.get("points", []) copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]}) self.exclude_shapes = copied - if self.preview_img: + + if ref_size: + self.exclude_ref_size = ref_size + elif self.preview_img: self.exclude_ref_size = self.preview_img.size else: self.exclude_ref_size = None @@ -382,6 +387,8 @@ class QtImageProcessor: 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) + if self.preview_img: + self._rebuild_overlay() except ValueError: pass diff --git a/app/qt/main_window.py b/app/qt/main_window.py index b86d71d..7c0e6b4 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -6,6 +6,7 @@ import time import urllib.request import urllib.error import csv +import json import concurrent.futures from pathlib import Path from typing import Callable, Dict, List, Tuple @@ -19,6 +20,7 @@ from .image_processor import QtImageProcessor from .pattern_puller import PatternPullerDialog DEFAULT_COLOR = "#763e92" +DEFAULT_OVERLAY_HEX = "#ff0000" PRESET_COLORS: List[Tuple[str, str]] = [ ("palette.swatch.red", "#ff3b30"), @@ -534,8 +536,8 @@ 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) + # Always use red for the overlay regardless of the target color + self.processor.set_overlay_color(DEFAULT_OVERLAY_HEX) self.content_layout = QtWidgets.QVBoxLayout(self.content) self.content_layout.setContentsMargins(24, 0, 24, 24) @@ -615,9 +617,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): # 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.addSeparator() 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.export_settings"), lambda: self._invoke_action("export_settings"), "Ctrl+E") + file_menu.addAction("📥 " + self._t("toolbar.import_settings"), lambda: self._invoke_action("import_settings"), "Ctrl+I") + file_menu.addSeparator() file_menu.addAction("💾 " + self._t("toolbar.save_overlay"), lambda: self._invoke_action("save_overlay"), "Ctrl+S") # Edit Menu @@ -750,6 +756,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): "choose_color": self.choose_color, "pick_from_image": self.pick_from_image, "save_overlay": self.save_overlay, + "export_settings": self.export_settings, + "import_settings": self.import_settings, "toggle_free_draw": self.toggle_free_draw, "clear_excludes": self.clear_exclusions, "undo_exclude": self.undo_exclusion, @@ -806,6 +814,93 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): self._current_image_path = loaded_path self._refresh_views() + def export_settings(self) -> None: + item_name = "" + if self._current_image_path: + # Try to get folder name first, otherwise file name + if self._current_image_path.parent.name and self._current_image_path.parent.name != "images": + item_name = self._current_image_path.parent.name + else: + item_name = self._current_image_path.stem + + default_filename = f"icra_settings_{item_name}.json" if item_name else "icra_settings.json" + + default_dir = str(Path("images").absolute()) if Path("images").exists() else "" + path_str, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + self._t("dialog.export_settings_title"), + str(Path(default_dir) / default_filename), + self._t("dialog.json_filter") + ) + if not path_str: + return + + settings = { + "hue_min": self.processor.hue_min, + "hue_max": self.processor.hue_max, + "sat_min": self.processor.sat_min, + "val_min": self.processor.val_min, + "val_max": self.processor.val_max, + "alpha": self.processor.alpha, + "current_color": self._current_color, + "exclude_ref_size": self.processor.exclude_ref_size, + "shapes": self.image_view.shapes + } + + try: + with open(path_str, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=4) + self.status_label.setText(self._t("status.settings_exported", path=Path(path_str).name)) + except Exception as e: + QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e)) + + def import_settings(self) -> None: + default_dir = str(Path("images").absolute()) if Path("images").exists() else "" + path_str, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + self._t("dialog.import_settings_title"), + default_dir, + self._t("dialog.json_filter") + ) + if not path_str: + return + + try: + with open(path_str, "r", encoding="utf-8") as f: + settings = json.load(f) + + # 1. Apply color (UI ONLY) + if "current_color" in settings: + self._current_color = settings["current_color"] + # Specifically NOT setting processor color to keep it RED + self._update_color_display(self._current_color, self._t("palette.current")) + + # 2. Apply slider values + keys = ["hue_min", "hue_max", "sat_min", "val_min", "val_max", "alpha"] + for key in keys: + if key in settings: + setattr(self.processor, key, settings[key]) + + # 3. Apply shapes and reference size + ref_size = None + if "exclude_ref_size" in settings and settings["exclude_ref_size"]: + ref_size = tuple(settings["exclude_ref_size"]) + + if "shapes" in settings: + self.image_view.set_shapes(settings["shapes"]) + self.processor.set_exclusions(settings["shapes"], ref_size=ref_size) + else: + # Force rebuild even if no shapes to pick up sliders/color + if self.processor.preview_img: + self.processor._rebuild_overlay() + + self._sync_sliders_from_processor() + self._refresh_views() + self.status_label.setText(self._t("status.settings_imported")) + + except Exception as e: + QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e)) + 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")) @@ -923,7 +1018,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): 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}") + # Do NOT call self.processor.set_overlay_color here to keep overlay RED + if label: + self.status_label.setText(f"{label}: {hex_code}") def _on_slider_change(self, key: str, value: int) -> None: self.processor.set_threshold(key, value)