Compare commits
No commits in common. "1c48a53c19ea6a877b7a0ba45313d10a9ed76d84" and "daf226a80f8491c20e3f15e0f981850b7a7f8898" have entirely different histories.
1c48a53c19
...
daf226a80f
|
|
@ -158,4 +158,4 @@ uv/
|
|||
.git/worktrees/
|
||||
|
||||
# ICRA specific
|
||||
analyses/
|
||||
images/
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"toolbar.toggle_theme" = "Theme umschalten"
|
||||
"toolbar.open_app_folder" = "Programmordner öffnen"
|
||||
"toolbar.prefer_dark" = "Dunkelheit bevorzugen"
|
||||
"toolbar.exclude_bg" = "Hintergrund ausblenden ({color})"
|
||||
"status.no_file" = "Keine Datei geladen."
|
||||
"status.defaults_restored" = "Standardwerte aktiv."
|
||||
"status.free_draw_enabled" = "Freihand-Ausschluss aktiviert."
|
||||
|
|
@ -40,7 +39,6 @@
|
|||
"sliders.hue_min" = "Hue Min (°)"
|
||||
"sliders.hue_max" = "Hue Max (°)"
|
||||
"sliders.sat_min" = "Sättigung Min (%)"
|
||||
"sliders.sat_max" = "Sättigung Max (%)"
|
||||
"sliders.val_min" = "Helligkeit Min (%)"
|
||||
"sliders.val_max" = "Helligkeit Max (%)"
|
||||
"sliders.alpha" = "Overlay Alpha"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"toolbar.toggle_theme" = "Toggle theme"
|
||||
"toolbar.open_app_folder" = "Open application folder"
|
||||
"toolbar.prefer_dark" = "Prefer darkness"
|
||||
"toolbar.exclude_bg" = "Exclude Background ({color})"
|
||||
"status.no_file" = "No file loaded."
|
||||
"status.defaults_restored" = "Defaults restored."
|
||||
"status.free_draw_enabled" = "Free-draw exclusion mode enabled."
|
||||
|
|
@ -40,7 +39,6 @@
|
|||
"sliders.hue_min" = "Hue min (°)"
|
||||
"sliders.hue_max" = "Hue max (°)"
|
||||
"sliders.sat_min" = "Saturation min (%)"
|
||||
"sliders.sat_max" = "Saturation max (%)"
|
||||
"sliders.val_min" = "Value min (%)"
|
||||
"sliders.val_max" = "Value max (%)"
|
||||
"sliders.alpha" = "Overlay alpha"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ from .constants import (
|
|||
IMAGES_DIR,
|
||||
LANGUAGE,
|
||||
OVERLAY_COLOR,
|
||||
EXCLUDE_BG_COLOR,
|
||||
EXCLUDE_BG_TOLERANCE,
|
||||
PREVIEW_MAX_SIZE,
|
||||
RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
|
||||
SUPPORTED_IMAGE_EXTENSIONS,
|
||||
|
|
@ -19,8 +17,6 @@ __all__ = [
|
|||
"IMAGES_DIR",
|
||||
"LANGUAGE",
|
||||
"OVERLAY_COLOR",
|
||||
"EXCLUDE_BG_COLOR",
|
||||
"EXCLUDE_BG_TOLERANCE",
|
||||
"PREVIEW_MAX_SIZE",
|
||||
"RESET_EXCLUSIONS_ON_IMAGE_CHANGE",
|
||||
"SUPPORTED_IMAGE_EXTENSIONS",
|
||||
|
|
|
|||
|
|
@ -94,12 +94,7 @@ def _extract_language(data: dict[str, Any]) -> str:
|
|||
|
||||
_CONFIG_DATA = _load_config_data()
|
||||
|
||||
_OPTION_DEFAULTS = {
|
||||
"reset_exclusions_on_image_change": False,
|
||||
"overlay_color": "#ff0000",
|
||||
"exclude_bg_color": "#1f2937",
|
||||
"exclude_bg_tolerance": 5,
|
||||
}
|
||||
_OPTION_DEFAULTS = {"reset_exclusions_on_image_change": False, "overlay_color": "#ff0000"}
|
||||
|
||||
|
||||
def _extract_options(data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -113,12 +108,6 @@ def _extract_options(data: dict[str, Any]) -> dict[str, Any]:
|
|||
color = section.get("overlay_color")
|
||||
if isinstance(color, str) and color.startswith("#") and len(color) in (7, 9):
|
||||
result["overlay_color"] = color
|
||||
exclude_bg = section.get("exclude_bg_color")
|
||||
if isinstance(exclude_bg, str) and exclude_bg.startswith("#") and len(exclude_bg) in (7, 9):
|
||||
result["exclude_bg_color"] = exclude_bg
|
||||
tolerance = section.get("exclude_bg_tolerance")
|
||||
if isinstance(tolerance, int):
|
||||
result["exclude_bg_tolerance"] = max(0, min(255, tolerance))
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -127,5 +116,3 @@ LANGUAGE = _extract_language(_CONFIG_DATA)
|
|||
OPTIONS = {**_OPTION_DEFAULTS, **_extract_options(_CONFIG_DATA)}
|
||||
RESET_EXCLUSIONS_ON_IMAGE_CHANGE = OPTIONS["reset_exclusions_on_image_change"]
|
||||
OVERLAY_COLOR = OPTIONS["overlay_color"]
|
||||
EXCLUDE_BG_COLOR = OPTIONS["exclude_bg_color"]
|
||||
EXCLUDE_BG_TOLERANCE = OPTIONS["exclude_bg_tolerance"]
|
||||
|
|
|
|||
|
|
@ -46,14 +46,12 @@ def create_application() -> QtWidgets.QApplication:
|
|||
def run() -> int:
|
||||
"""Run the PySide6 GUI."""
|
||||
app = create_application()
|
||||
from app.logic import OVERLAY_COLOR, EXCLUDE_BG_COLOR, EXCLUDE_BG_TOLERANCE
|
||||
from app.logic import OVERLAY_COLOR
|
||||
window = MainWindow(
|
||||
language=LANGUAGE,
|
||||
defaults=DEFAULTS.copy(),
|
||||
reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
|
||||
overlay_color=OVERLAY_COLOR,
|
||||
exclude_bg_color=EXCLUDE_BG_COLOR,
|
||||
exclude_bg_tolerance=EXCLUDE_BG_TOLERANCE,
|
||||
)
|
||||
|
||||
# Respect saved geometry from QSettings; fall back to maximised on first launch
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ class QtImageProcessor:
|
|||
"hue_min": 0,
|
||||
"hue_max": 360,
|
||||
"sat_min": 25,
|
||||
"sat_max": 100,
|
||||
"val_min": 15,
|
||||
"val_max": 100,
|
||||
"alpha": 120,
|
||||
|
|
@ -114,7 +113,6 @@ class QtImageProcessor:
|
|||
self.hue_min = self.defaults["hue_min"]
|
||||
self.hue_max = self.defaults["hue_max"]
|
||||
self.sat_min = self.defaults["sat_min"]
|
||||
self.sat_max = self.defaults["sat_max"]
|
||||
self.val_min = self.defaults["val_min"]
|
||||
self.val_max = self.defaults["val_max"]
|
||||
self.alpha = self.defaults["alpha"]
|
||||
|
|
@ -127,9 +125,6 @@ class QtImageProcessor:
|
|||
self._cached_mask_size: Tuple[int, int] | None = None
|
||||
self.exclude_ref_size: Tuple[int, int] | None = None
|
||||
self.prefer_dark: bool = False
|
||||
self.exclude_bg: bool = True
|
||||
self.exclude_bg_rgb: Tuple[int, int, int] = (31, 41, 55)
|
||||
self.exclude_bg_tolerance: int = 5
|
||||
|
||||
def set_defaults(self, defaults: dict) -> None:
|
||||
for key in self.defaults:
|
||||
|
|
@ -187,29 +182,13 @@ class QtImageProcessor:
|
|||
if self.orig_img is None:
|
||||
self.preview_img = None
|
||||
return
|
||||
|
||||
img_to_process = self.orig_img.convert("RGBA")
|
||||
if self.exclude_bg:
|
||||
# Mask the background color with tolerance on the original image before resizing
|
||||
# this prevents interpolation artifacts from leaving a background 'halo'
|
||||
arr = np.array(img_to_process)
|
||||
r_bg, g_bg, b_bg = self.exclude_bg_rgb
|
||||
tol = self.exclude_bg_tolerance
|
||||
bg_mask = (
|
||||
(np.abs(arr[..., 0].astype(np.int16) - r_bg) <= tol) &
|
||||
(np.abs(arr[..., 1].astype(np.int16) - g_bg) <= tol) &
|
||||
(np.abs(arr[..., 2].astype(np.int16) - b_bg) <= tol)
|
||||
)
|
||||
arr[bg_mask, 3] = 0
|
||||
img_to_process = Image.fromarray(arr, "RGBA")
|
||||
|
||||
width, height = img_to_process.size
|
||||
width, height = self.orig_img.size
|
||||
max_w, max_h = PREVIEW_MAX_SIZE
|
||||
scale = min(max_w / width, max_h / height)
|
||||
if scale <= 0:
|
||||
scale = 1.0
|
||||
size = (max(1, int(width * scale)), max(1, int(height * scale)))
|
||||
self.preview_img = img_to_process.resize(size, Image.LANCZOS)
|
||||
self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
|
||||
|
||||
def _rebuild_overlay(self) -> None:
|
||||
"""Build color-match overlay using vectorized NumPy operations."""
|
||||
|
|
@ -222,18 +201,7 @@ class QtImageProcessor:
|
|||
arr = np.asarray(base, dtype=np.float32) # (H, W, 4)
|
||||
|
||||
rgb = arr[..., :3] / 255.0
|
||||
alpha_ch = arr[..., 3].copy() # alpha channel of the image
|
||||
|
||||
if self.exclude_bg:
|
||||
# Exclude specific background color
|
||||
r_bg, g_bg, b_bg = self.exclude_bg_rgb
|
||||
tol = self.exclude_bg_tolerance
|
||||
bg_mask = (
|
||||
(np.abs(arr[..., 0] - r_bg) <= tol) &
|
||||
(np.abs(arr[..., 1] - g_bg) <= tol) &
|
||||
(np.abs(arr[..., 2] - b_bg) <= tol)
|
||||
)
|
||||
alpha_ch[bg_mask] = 0
|
||||
alpha_ch = arr[..., 3] # alpha channel of the image
|
||||
|
||||
hsv = _rgb_to_hsv_numpy(rgb) # (H, W, 3): H°, S%, V%
|
||||
|
||||
|
|
@ -251,10 +219,9 @@ class QtImageProcessor:
|
|||
match_mask = (
|
||||
hue_ok
|
||||
& (sat >= float(self.sat_min))
|
||||
& (sat <= float(self.sat_max))
|
||||
& (val >= float(self.val_min))
|
||||
& (val <= float(self.val_max))
|
||||
& (alpha_ch >= 128)
|
||||
& (alpha_ch > 0)
|
||||
)
|
||||
|
||||
# Exclusion mask (same pixel space as preview)
|
||||
|
|
@ -262,7 +229,8 @@ class QtImageProcessor:
|
|||
|
||||
keep_match = match_mask & ~excl_mask
|
||||
excl_match = match_mask & excl_mask
|
||||
visible = alpha_ch >= 128
|
||||
visible = alpha_ch > 0
|
||||
|
||||
matches_all = int(match_mask[visible].sum())
|
||||
total_all = int(visible.sum())
|
||||
matches_keep = int(keep_match[visible].sum())
|
||||
|
|
@ -299,18 +267,7 @@ class QtImageProcessor:
|
|||
arr = np.asarray(base, dtype=np.float32)
|
||||
|
||||
rgb = arr[..., :3] / 255.0
|
||||
alpha_ch = arr[..., 3].copy()
|
||||
|
||||
if self.exclude_bg:
|
||||
# Exclude background color with tolerance
|
||||
r_bg, g_bg, b_bg = self.exclude_bg_rgb
|
||||
tol = self.exclude_bg_tolerance
|
||||
bg_mask = (
|
||||
(np.abs(arr[..., 0] - r_bg) <= tol) &
|
||||
(np.abs(arr[..., 1] - g_bg) <= tol) &
|
||||
(np.abs(arr[..., 2] - b_bg) <= tol)
|
||||
)
|
||||
alpha_ch[bg_mask] = 0
|
||||
alpha_ch = arr[..., 3]
|
||||
|
||||
hsv = _rgb_to_hsv_numpy(rgb)
|
||||
|
||||
|
|
@ -328,18 +285,17 @@ class QtImageProcessor:
|
|||
match_mask = (
|
||||
hue_ok
|
||||
& (sat >= float(self.sat_min))
|
||||
& (sat <= float(self.sat_max))
|
||||
& (val >= float(self.val_min))
|
||||
& (val <= float(self.val_max))
|
||||
& (alpha_ch >= 128)
|
||||
& (alpha_ch > 0)
|
||||
)
|
||||
|
||||
excl_mask = self._build_exclusion_mask_numpy(base.size)
|
||||
|
||||
keep_match = match_mask & ~excl_mask
|
||||
excl_match = match_mask & excl_mask
|
||||
visible = alpha_ch > 0
|
||||
|
||||
visible = alpha_ch >= 128
|
||||
matches_keep_count = int(keep_match[visible].sum())
|
||||
keep_visible = visible & ~excl_mask
|
||||
brightness = float(val[keep_visible].mean()) if keep_visible.any() else 0.0
|
||||
|
|
@ -365,7 +321,7 @@ class QtImageProcessor:
|
|||
hue_ok = self.hue_min <= hue <= self.hue_max
|
||||
else:
|
||||
hue_ok = hue >= self.hue_min or hue <= self.hue_max
|
||||
sat_ok = self.sat_min <= s * 100.0 <= self.sat_max
|
||||
sat_ok = s * 100.0 >= self.sat_min
|
||||
val_ok = self.val_min <= v * 100.0 <= self.val_max
|
||||
return hue_ok and sat_ok and val_ok
|
||||
|
||||
|
|
@ -483,21 +439,3 @@ class QtImageProcessor:
|
|||
self._cached_mask = mask
|
||||
self._cached_mask_size = size
|
||||
return mask
|
||||
|
||||
def set_exclude_bg_color(self, hex_code: str, tolerance: int = 5) -> None:
|
||||
"""Set the RGB channels for background exclusion from a hex string."""
|
||||
self.exclude_bg_tolerance = tolerance
|
||||
if not hex_code.startswith("#") or len(hex_code) not in (7, 9):
|
||||
return
|
||||
try:
|
||||
r = int(hex_code[1:3], 16)
|
||||
g = int(hex_code[3:5], 16)
|
||||
b = int(hex_code[5:7], 16)
|
||||
self.exclude_bg_rgb = (r, g, b)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def exclude_bg_color_hex(self) -> str:
|
||||
r, g, b = self.exclude_bg_rgb
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ SLIDER_SPECS: List[Tuple[str, str, int, int]] = [
|
|||
("sliders.hue_min", "hue_min", 0, 360),
|
||||
("sliders.hue_max", "hue_max", 0, 360),
|
||||
("sliders.sat_min", "sat_min", 0, 100),
|
||||
("sliders.sat_max", "sat_max", 0, 100),
|
||||
("sliders.val_min", "val_min", 0, 100),
|
||||
("sliders.val_max", "val_max", 0, 100),
|
||||
("sliders.alpha", "alpha", 0, 255),
|
||||
|
|
@ -516,7 +515,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, overlay_color: str | None = None, exclude_bg_color: str | None = None, exclude_bg_tolerance: int = 5) -> 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"))
|
||||
|
|
@ -540,8 +539,6 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
self.processor.reset_exclusions_on_switch = reset_exclusions
|
||||
# Always use red for the overlay regardless of the target color
|
||||
self.processor.set_overlay_color(DEFAULT_OVERLAY_HEX)
|
||||
if exclude_bg_color:
|
||||
self.processor.set_exclude_bg_color(exclude_bg_color, tolerance=exclude_bg_tolerance)
|
||||
|
||||
self.content_layout = QtWidgets.QVBoxLayout(self.content)
|
||||
self.content_layout.setContentsMargins(24, 0, 24, 24)
|
||||
|
|
@ -657,13 +654,6 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
self.prefer_dark_action.setChecked(False)
|
||||
self.prefer_dark_action.triggered.connect(lambda: self._invoke_action("toggle_prefer_dark"))
|
||||
view_menu.addAction(self.prefer_dark_action)
|
||||
|
||||
self.exclude_bg_action = QtGui.QAction("🖼 " + self._t("toolbar.exclude_bg", color=self.processor.exclude_bg_color_hex), self)
|
||||
self.exclude_bg_action.setCheckable(True)
|
||||
self.exclude_bg_action.setChecked(True)
|
||||
self.exclude_bg_action.triggered.connect(lambda: self._invoke_action("toggle_exclude_bg"))
|
||||
view_menu.addAction(self.exclude_bg_action)
|
||||
|
||||
view_menu.addSeparator()
|
||||
view_menu.addAction("📁 " + self._t("toolbar.open_app_folder"), lambda: self._invoke_action("open_app_folder"))
|
||||
|
||||
|
|
@ -815,7 +805,6 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
"reset_sliders": self._reset_sliders,
|
||||
"toggle_theme": self.toggle_theme,
|
||||
"toggle_prefer_dark": self.toggle_prefer_dark,
|
||||
"toggle_exclude_bg": self.toggle_exclude_bg,
|
||||
"open_app_folder": self.open_app_folder,
|
||||
"show_previous_image": self.show_previous_image,
|
||||
"show_next_image": self.show_next_image,
|
||||
|
|
@ -866,7 +855,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
|
||||
def open_image(self) -> None:
|
||||
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)"
|
||||
default_dir = str(Path("analyses").absolute()) if Path("analyses").exists() else ""
|
||||
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
|
||||
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, self._t("dialog.open_image_title"), default_dir, filters)
|
||||
if not path_str:
|
||||
return
|
||||
|
|
@ -883,7 +872,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
self._refresh_views()
|
||||
|
||||
def open_folder(self) -> None:
|
||||
default_dir = str(Path("analyses").absolute()) if Path("analyses").exists() else ""
|
||||
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
|
||||
directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title"), default_dir)
|
||||
if not directory:
|
||||
return
|
||||
|
|
@ -905,28 +894,20 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
|
||||
def export_settings(self) -> None:
|
||||
item_name = ""
|
||||
default_dir = Path("analyses").absolute() if Path("analyses").exists() else Path()
|
||||
|
||||
if self._current_image_path:
|
||||
if self._current_image_path.parent.name == "images":
|
||||
root_dir = self._current_image_path.parent.parent
|
||||
item_name = root_dir.name
|
||||
default_dir = root_dir / "settings"
|
||||
elif self._current_image_path.parent.name and self._current_image_path.parent.name != "images":
|
||||
root_dir = self._current_image_path.parent
|
||||
item_name = root_dir.name
|
||||
default_dir = root_dir / "settings"
|
||||
# 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_dir = self._current_image_path.parent / "settings"
|
||||
|
||||
default_dir.mkdir(parents=True, exist_ok=True)
|
||||
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(default_dir / default_filename),
|
||||
str(Path(default_dir) / default_filename),
|
||||
self._t("dialog.json_filter")
|
||||
)
|
||||
if not path_str:
|
||||
|
|
@ -936,7 +917,6 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
"hue_min": self.processor.hue_min,
|
||||
"hue_max": self.processor.hue_max,
|
||||
"sat_min": self.processor.sat_min,
|
||||
"sat_max": self.processor.sat_max,
|
||||
"val_min": self.processor.val_min,
|
||||
"val_max": self.processor.val_max,
|
||||
"alpha": self.processor.alpha,
|
||||
|
|
@ -953,25 +933,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(e))
|
||||
|
||||
def import_settings(self) -> None:
|
||||
default_dir = Path("analyses").absolute() if Path("analyses").exists() else Path()
|
||||
|
||||
if self._current_image_path:
|
||||
if self._current_image_path.parent.name == "images":
|
||||
root_dir = self._current_image_path.parent.parent
|
||||
default_dir = root_dir / "settings"
|
||||
elif self._current_image_path.parent.name and self._current_image_path.parent.name != "images":
|
||||
root_dir = self._current_image_path.parent
|
||||
default_dir = root_dir / "settings"
|
||||
else:
|
||||
default_dir = self._current_image_path.parent / "settings"
|
||||
|
||||
if not default_dir.exists():
|
||||
default_dir = Path("analyses").absolute() if Path("analyses").exists() else Path()
|
||||
|
||||
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
|
||||
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self,
|
||||
self._t("dialog.import_settings_title"),
|
||||
str(default_dir),
|
||||
default_dir,
|
||||
self._t("dialog.json_filter")
|
||||
)
|
||||
if not path_str:
|
||||
|
|
@ -988,12 +954,10 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
self._update_color_display(self._current_color, self._t("palette.current"))
|
||||
|
||||
# 2. Apply slider values
|
||||
keys = ["hue_min", "hue_max", "sat_min", "sat_max", "val_min", "val_max", "alpha"]
|
||||
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])
|
||||
else:
|
||||
setattr(self.processor, key, self.processor.defaults.get(key, 0))
|
||||
|
||||
# 3. Apply shapes and reference size
|
||||
ref_size = None
|
||||
|
|
@ -1021,22 +985,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
return
|
||||
|
||||
folder_path = self.processor.preview_paths[0].parent
|
||||
|
||||
if folder_path.name == "images":
|
||||
root_dir = folder_path.parent
|
||||
item_name = root_dir.name
|
||||
else:
|
||||
root_dir = folder_path
|
||||
item_name = root_dir.name
|
||||
|
||||
target_dir = root_dir / "results"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
default_filename = f"icra_results_{item_name}.csv" if item_name else "icra_results.csv"
|
||||
default_filename = f"icra_stats_{folder_path.name}.csv"
|
||||
|
||||
csv_path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self,
|
||||
self._t("dialog.export_stats_title"),
|
||||
str(target_dir / default_filename),
|
||||
str(folder_path / default_filename),
|
||||
self._t("dialog.csv_filter")
|
||||
)
|
||||
if not csv_path:
|
||||
|
|
@ -1223,15 +1177,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
margin = 15
|
||||
hue_min = max(0, int(hue) - margin)
|
||||
hue_max = min(360, int(hue) + margin)
|
||||
# For a picked color, give a window of ±20% for saturation and -30% for value
|
||||
sat_min = max(0, int(sat) - 20)
|
||||
sat_max = min(100, int(sat) + 20)
|
||||
val_min = max(0, int(val) - 30)
|
||||
val_max = 100
|
||||
|
||||
for attr, value in [
|
||||
("hue_min", hue_min), ("hue_max", hue_max),
|
||||
("sat_min", sat_min), ("sat_max", sat_max), ("val_min", val_min), ("val_max", val_max),
|
||||
("sat_min", sat_min), ("val_min", val_min), ("val_max", val_max),
|
||||
]:
|
||||
ctrl = self._slider_controls.get(attr)
|
||||
if ctrl:
|
||||
|
|
@ -1341,15 +1293,6 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|||
self.processor._rebuild_overlay()
|
||||
self._refresh_overlay_only()
|
||||
|
||||
def toggle_exclude_bg(self) -> None:
|
||||
self.processor.exclude_bg = not self.processor.exclude_bg
|
||||
self.exclude_bg_action.setChecked(self.processor.exclude_bg)
|
||||
if self._current_image_path:
|
||||
# Re-scale/re-apply transparency
|
||||
self.processor._build_preview()
|
||||
self.processor._rebuild_overlay()
|
||||
self._refresh_views()
|
||||
|
||||
def clear_exclusions(self) -> None:
|
||||
self.image_view.clear_shapes()
|
||||
self.processor.set_exclusions([])
|
||||
|
|
|
|||
|
|
@ -30,32 +30,17 @@ class PatternDownloadWorker(QtCore.QThread):
|
|||
filename = self.save_dir / f"{seed}.png"
|
||||
|
||||
if filename.exists():
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(filename) as img:
|
||||
img.verify()
|
||||
return True, None
|
||||
except Exception:
|
||||
filename.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 ICRA/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(response.read())
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(filename) as img:
|
||||
img.verify()
|
||||
return True, None
|
||||
except Exception:
|
||||
filename.unlink(missing_ok=True)
|
||||
return False, "Downloaded file is invalid/corrupt"
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, f"HTTP {e.code}"
|
||||
except Exception as e:
|
||||
filename.unlink(missing_ok=True)
|
||||
return False, f"Network error: {e}"
|
||||
|
||||
def run(self) -> None:
|
||||
|
|
@ -155,7 +140,7 @@ class PatternPullerDialog(QtWidgets.QDialog, I18nMixin):
|
|||
QtWidgets.QMessageBox.warning(self, "Error", self._t("dialog.puller_invalid_url", default="Invalid URL format."))
|
||||
return
|
||||
|
||||
save_dir = Path("analyses") / slug / "images"
|
||||
save_dir = Path("images") / slug
|
||||
|
||||
self.start_btn.setEnabled(False)
|
||||
self.url_input.setEnabled(False)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ language = "en"
|
|||
reset_exclusions_on_image_change = false
|
||||
# Hex color code for the match overlay (e.g. "#ff0000" for Red, "#00ff00" for Green)
|
||||
overlay_color = "#ff0000"
|
||||
# Hex color code for the background to be excluded (default #1f2937)
|
||||
exclude_bg_color = "#1f2937"
|
||||
# Tolerance for background color matching (0-255, default 5)
|
||||
exclude_bg_tolerance = 5
|
||||
|
||||
[defaults]
|
||||
# Override any of the following keys to tweak the initial slider values:
|
||||
|
|
@ -19,7 +15,6 @@ exclude_bg_tolerance = 5
|
|||
hue_min = 250.0
|
||||
hue_max = 310.0
|
||||
sat_min = 15.0
|
||||
sat_max = 100.0
|
||||
val_min = 15.0
|
||||
val_max = 100.0
|
||||
alpha = 120
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from PySide6 import QtWidgets
|
||||
from app.qt.main_window import MainWindow
|
||||
from app.qt.pattern_puller import PatternDownloadWorker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
import sys
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
app = QApplication(sys.argv)
|
||||
yield app
|
||||
|
||||
|
||||
def test_export_settings_path_generation(qt_app, tmp_path):
|
||||
mock_widget = QtWidgets.QWidget()
|
||||
mock_widget.title_label = MagicMock()
|
||||
mock_widget.apply_theme = MagicMock()
|
||||
with patch('app.qt.main_window.TitleBar', return_value=mock_widget):
|
||||
window = MainWindow(language="en", defaults={}, reset_exclusions=False)
|
||||
|
||||
with patch("PySide6.QtWidgets.QFileDialog.getSaveFileName", return_value=("", "")) as mock_get_save:
|
||||
|
||||
# Test case 1: New subfolder structure (analyses/m4a1-s/images/1.png)
|
||||
root = tmp_path / "analyses" / "m4a1_s"
|
||||
img_dir = root / "images"
|
||||
img_dir.mkdir(parents=True)
|
||||
img_path = img_dir / "1.png"
|
||||
|
||||
window._current_image_path = img_path
|
||||
window.export_settings()
|
||||
|
||||
# Verify settings directory was created
|
||||
assert (root / "settings").exists()
|
||||
|
||||
# Verify default path given to QFileDialog
|
||||
args, kwargs = mock_get_save.call_args
|
||||
# args[2] is the default path string
|
||||
expected_path = str(root / "settings" / "icra_settings_m4a1_s.json")
|
||||
assert args[2] == expected_path
|
||||
|
||||
|
||||
def test_export_folder_path_generation(qt_app, tmp_path):
|
||||
mock_widget = QtWidgets.QWidget()
|
||||
mock_widget.title_label = MagicMock()
|
||||
mock_widget.apply_theme = MagicMock()
|
||||
with patch('app.qt.main_window.TitleBar', return_value=mock_widget):
|
||||
window = MainWindow(language="en", defaults={}, reset_exclusions=False)
|
||||
|
||||
with patch("PySide6.QtWidgets.QFileDialog.getSaveFileName", return_value=("", "")) as mock_get_save:
|
||||
# Mock processor paths
|
||||
root = tmp_path / "analyses" / "m4a1_s"
|
||||
img_dir = root / "images"
|
||||
img_dir.mkdir(parents=True)
|
||||
window.processor.preview_paths = [img_dir / "1.png"]
|
||||
|
||||
window.export_folder()
|
||||
|
||||
assert (root / "results").exists()
|
||||
args, kwargs = mock_get_save.call_args
|
||||
expected_path = str(root / "results" / "icra_results_m4a1_s.csv")
|
||||
assert args[2] == expected_path
|
||||
|
||||
|
||||
def test_pattern_download_worker_dir(tmp_path):
|
||||
worker = PatternDownloadWorker(slug="test-slug", save_dir=tmp_path / "analyses" / "test-slug" / "images")
|
||||
assert worker.save_dir == tmp_path / "analyses" / "test-slug" / "images"
|
||||
|
|
@ -12,18 +12,18 @@ def test_stats_summary():
|
|||
matches_excl=10, total_excl=20
|
||||
)
|
||||
|
||||
# Mock translator
|
||||
def mock_t(key, **kwargs):
|
||||
if key == "stats.placeholder":
|
||||
return "Placeholder"
|
||||
if not kwargs:
|
||||
return key
|
||||
return f"{kwargs['with_pct']:.1f} {kwargs['without_pct']:.1f} {kwargs['excluded_pct']:.1f}"
|
||||
return f"{kwargs['with_pct']:.1f} {kwargs['without_pct']:.1f} {kwargs['excluded_pct']:.1f} {kwargs['excluded_match_pct']:.1f}"
|
||||
|
||||
res = s.summary(mock_t)
|
||||
# with_pct: 40/80 = 50.0
|
||||
# without_pct: 50/100 = 50.0
|
||||
# excluded_pct: 20/100 = 20.0
|
||||
assert res == "50.0 50.0 20.0"
|
||||
# excluded_match_pct: 10/20 = 50.0
|
||||
assert res == "50.0 50.0 20.0 50.0"
|
||||
|
||||
def test_stats_empty():
|
||||
s = Stats()
|
||||
|
|
|
|||
Loading…
Reference in New Issue