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_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."

View File

@ -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."

View File

@ -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

View File

@ -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,6 +1018,8 @@ 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})")
# 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: