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:
lm
2026-03-10 18:32:14 +01:00
parent c278ddf458
commit acfcf99d15
4 changed files with 124 additions and 6 deletions
+10 -3
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
+100 -3
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,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)