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
This commit is contained in:
lukas 2026-03-10 18:32:14 +01:00
parent c278ddf458
commit acfcf99d15
4 changed files with 124 additions and 6 deletions

View File

@ -59,6 +59,13 @@
"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}"
"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.export_stats_title" = "Ordner-Statistiken exportieren (CSV)"
"dialog.csv_filter" = "CSV-Dateien (*.csv)" "dialog.csv_filter" = "CSV-Dateien (*.csv)"
"status.drag_drop" = "Bild oder Ordner hier ablegen." "status.drag_drop" = "Bild oder Ordner hier ablegen."

View File

@ -59,6 +59,13 @@
"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}"
"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.export_stats_title" = "Export Folder Statistics (CSV)"
"dialog.csv_filter" = "CSV Files (*.csv)" "dialog.csv_filter" = "CSV Files (*.csv)"
"status.drag_drop" = "Drop an image or folder here to open it." "status.drag_drop" = "Drop an image or folder here to open it."

View File

@ -316,8 +316,10 @@ class QtImageProcessor:
return self._to_pixmap(self.preview_img) return self._to_pixmap(self.preview_img)
def overlay_pixmap(self) -> QtGui.QPixmap: 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() 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) merged = Image.alpha_composite(self.preview_img.convert("RGBA"), self.overlay_img)
return self._to_pixmap(merged) return self._to_pixmap(merged)
@ -331,7 +333,7 @@ class QtImageProcessor:
# exclusions ------------------------------------------------------------- # 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]] = [] copied: list[dict[str, object]] = []
for shape in shapes: for shape in shapes:
kind = shape.get("kind") kind = shape.get("kind")
@ -342,7 +344,10 @@ class QtImageProcessor:
pts = shape.get("points", []) pts = shape.get("points", [])
copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]}) copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]})
self.exclude_shapes = copied 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 self.exclude_ref_size = self.preview_img.size
else: else:
self.exclude_ref_size = None self.exclude_ref_size = None
@ -382,6 +387,8 @@ class QtImageProcessor:
self.overlay_r = int(hex_code[1:3], 16) self.overlay_r = int(hex_code[1:3], 16)
self.overlay_g = int(hex_code[3:5], 16) self.overlay_g = int(hex_code[3:5], 16)
self.overlay_b = int(hex_code[5:7], 16) self.overlay_b = int(hex_code[5:7], 16)
if self.preview_img:
self._rebuild_overlay()
except ValueError: except ValueError:
pass pass

View File

@ -6,6 +6,7 @@ import time
import urllib.request import urllib.request
import urllib.error import urllib.error
import csv import csv
import json
import concurrent.futures import concurrent.futures
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Tuple from typing import Callable, Dict, List, Tuple
@ -19,6 +20,7 @@ from .image_processor import QtImageProcessor
from .pattern_puller import PatternPullerDialog from .pattern_puller import PatternPullerDialog
DEFAULT_COLOR = "#763e92" DEFAULT_COLOR = "#763e92"
DEFAULT_OVERLAY_HEX = "#ff0000"
PRESET_COLORS: List[Tuple[str, str]] = [ PRESET_COLORS: List[Tuple[str, str]] = [
("palette.swatch.red", "#ff3b30"), ("palette.swatch.red", "#ff3b30"),
@ -534,8 +536,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.processor = QtImageProcessor() self.processor = QtImageProcessor()
self.processor.set_defaults(defaults) self.processor.set_defaults(defaults)
self.processor.reset_exclusions_on_switch = reset_exclusions self.processor.reset_exclusions_on_switch = reset_exclusions
if overlay_color: # Always use red for the overlay regardless of the target color
self.processor.set_overlay_color(overlay_color) self.processor.set_overlay_color(DEFAULT_OVERLAY_HEX)
self.content_layout = QtWidgets.QVBoxLayout(self.content) self.content_layout = QtWidgets.QVBoxLayout(self.content)
self.content_layout.setContentsMargins(24, 0, 24, 24) self.content_layout.setContentsMargins(24, 0, 24, 24)
@ -615,9 +617,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# File Menu # File Menu
file_menu = self.menu_bar.addMenu(self._t("menu.file")) 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_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.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.addAction("📊 " + self._t("toolbar.export_folder"), lambda: self._invoke_action("export_folder"))
file_menu.addSeparator() 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") file_menu.addAction("💾 " + self._t("toolbar.save_overlay"), lambda: self._invoke_action("save_overlay"), "Ctrl+S")
# Edit Menu # Edit Menu
@ -750,6 +756,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"choose_color": self.choose_color, "choose_color": self.choose_color,
"pick_from_image": self.pick_from_image, "pick_from_image": self.pick_from_image,
"save_overlay": self.save_overlay, "save_overlay": self.save_overlay,
"export_settings": self.export_settings,
"import_settings": self.import_settings,
"toggle_free_draw": self.toggle_free_draw, "toggle_free_draw": self.toggle_free_draw,
"clear_excludes": self.clear_exclusions, "clear_excludes": self.clear_exclusions,
"undo_exclude": self.undo_exclusion, "undo_exclude": self.undo_exclusion,
@ -806,6 +814,93 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._current_image_path = loaded_path self._current_image_path = loaded_path
self._refresh_views() 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: def export_folder(self) -> None:
if not self.processor.preview_paths: if not self.processor.preview_paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) 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 = hex_code
self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;") self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
self.current_color_label.setText(f"({hex_code})") 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: def _on_slider_change(self, key: str, value: int) -> None:
self.processor.set_threshold(key, value) self.processor.set_threshold(key, value)