Compare commits

...

2 Commits

Author SHA1 Message Date
lukas 1c48a53c19 Implement background exclusion and refactor folder structure
- Added configurable background exclusion (#1f2937) with tolerance
- Implemented alpha thresholding (>= 128) to eliminate edge artifacts
- Refactored folder structure into analyses/[slug]/images, settings, and results
- Updated pattern puller to skip existing images and handle network errors
- Updated .gitignore and automated tests for path integrity
2026-03-22 20:09:05 +01:00
lukas ff9dec0eff Add saturation max slider to UI and image processing logic 2026-03-22 17:42:26 +01:00
12 changed files with 269 additions and 35 deletions

2
.gitignore vendored
View File

@ -158,4 +158,4 @@ uv/
.git/worktrees/
# ICRA specific
images/
analyses/

View File

@ -12,6 +12,7 @@
"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."
@ -39,6 +40,7 @@
"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"

View File

@ -12,6 +12,7 @@
"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."
@ -39,6 +40,7 @@
"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"

View File

@ -6,6 +6,8 @@ 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,
@ -17,6 +19,8 @@ __all__ = [
"IMAGES_DIR",
"LANGUAGE",
"OVERLAY_COLOR",
"EXCLUDE_BG_COLOR",
"EXCLUDE_BG_TOLERANCE",
"PREVIEW_MAX_SIZE",
"RESET_EXCLUSIONS_ON_IMAGE_CHANGE",
"SUPPORTED_IMAGE_EXTENSIONS",

View File

@ -94,7 +94,12 @@ def _extract_language(data: dict[str, Any]) -> str:
_CONFIG_DATA = _load_config_data()
_OPTION_DEFAULTS = {"reset_exclusions_on_image_change": False, "overlay_color": "#ff0000"}
_OPTION_DEFAULTS = {
"reset_exclusions_on_image_change": False,
"overlay_color": "#ff0000",
"exclude_bg_color": "#1f2937",
"exclude_bg_tolerance": 5,
}
def _extract_options(data: dict[str, Any]) -> dict[str, Any]:
@ -108,6 +113,12 @@ 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
@ -116,3 +127,5 @@ 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"]

View File

@ -46,12 +46,14 @@ def create_application() -> QtWidgets.QApplication:
def run() -> int:
"""Run the PySide6 GUI."""
app = create_application()
from app.logic import OVERLAY_COLOR
from app.logic import OVERLAY_COLOR, EXCLUDE_BG_COLOR, EXCLUDE_BG_TOLERANCE
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

View File

@ -106,6 +106,7 @@ class QtImageProcessor:
"hue_min": 0,
"hue_max": 360,
"sat_min": 25,
"sat_max": 100,
"val_min": 15,
"val_max": 100,
"alpha": 120,
@ -113,6 +114,7 @@ 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"]
@ -125,6 +127,9 @@ 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:
@ -182,13 +187,29 @@ class QtImageProcessor:
if self.orig_img is None:
self.preview_img = None
return
width, height = self.orig_img.size
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
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 = self.orig_img.resize(size, Image.LANCZOS)
self.preview_img = img_to_process.resize(size, Image.LANCZOS)
def _rebuild_overlay(self) -> None:
"""Build color-match overlay using vectorized NumPy operations."""
@ -201,7 +222,18 @@ class QtImageProcessor:
arr = np.asarray(base, dtype=np.float32) # (H, W, 4)
rgb = arr[..., :3] / 255.0
alpha_ch = arr[..., 3] # alpha channel of the image
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
hsv = _rgb_to_hsv_numpy(rgb) # (H, W, 3): H°, S%, V%
@ -219,9 +251,10 @@ 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 > 0)
& (alpha_ch >= 128)
)
# Exclusion mask (same pixel space as preview)
@ -229,8 +262,7 @@ class QtImageProcessor:
keep_match = match_mask & ~excl_mask
excl_match = match_mask & excl_mask
visible = alpha_ch > 0
visible = alpha_ch >= 128
matches_all = int(match_mask[visible].sum())
total_all = int(visible.sum())
matches_keep = int(keep_match[visible].sum())
@ -267,7 +299,18 @@ class QtImageProcessor:
arr = np.asarray(base, dtype=np.float32)
rgb = arr[..., :3] / 255.0
alpha_ch = arr[..., 3]
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
hsv = _rgb_to_hsv_numpy(rgb)
@ -285,17 +328,18 @@ 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 > 0)
& (alpha_ch >= 128)
)
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
@ -321,7 +365,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 = s * 100.0 >= self.sat_min
sat_ok = self.sat_min <= s * 100.0 <= self.sat_max
val_ok = self.val_min <= v * 100.0 <= self.val_max
return hue_ok and sat_ok and val_ok
@ -439,3 +483,21 @@ 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}"

View File

@ -41,6 +41,7 @@ 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),
@ -515,7 +516,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) -> None:
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:
super().__init__()
self.init_i18n(language)
self.setWindowTitle(self._t("app.title"))
@ -539,6 +540,8 @@ 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)
@ -654,6 +657,13 @@ 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"))
@ -805,6 +815,7 @@ 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,
@ -855,7 +866,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def open_image(self) -> None:
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)"
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
default_dir = str(Path("analyses").absolute()) if Path("analyses").exists() else ""
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, self._t("dialog.open_image_title"), default_dir, filters)
if not path_str:
return
@ -872,7 +883,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._refresh_views()
def open_folder(self) -> None:
default_dir = str(Path("images").absolute()) if Path("images").exists() else ""
default_dir = str(Path("analyses").absolute()) if Path("analyses").exists() else ""
directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title"), default_dir)
if not directory:
return
@ -894,20 +905,28 @@ 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:
# 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
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"
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(Path(default_dir) / default_filename),
str(default_dir / default_filename),
self._t("dialog.json_filter")
)
if not path_str:
@ -917,6 +936,7 @@ 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,
@ -933,11 +953,25 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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 ""
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()
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
self._t("dialog.import_settings_title"),
default_dir,
str(default_dir),
self._t("dialog.json_filter")
)
if not path_str:
@ -954,10 +988,12 @@ 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", "val_min", "val_max", "alpha"]
keys = ["hue_min", "hue_max", "sat_min", "sat_max", "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
@ -985,12 +1021,22 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return
folder_path = self.processor.preview_paths[0].parent
default_filename = f"icra_stats_{folder_path.name}.csv"
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"
csv_path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
self._t("dialog.export_stats_title"),
str(folder_path / default_filename),
str(target_dir / default_filename),
self._t("dialog.csv_filter")
)
if not csv_path:
@ -1177,13 +1223,15 @@ 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), ("val_min", val_min), ("val_max", val_max),
("sat_min", sat_min), ("sat_max", sat_max), ("val_min", val_min), ("val_max", val_max),
]:
ctrl = self._slider_controls.get(attr)
if ctrl:
@ -1293,6 +1341,15 @@ 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([])

View File

@ -30,17 +30,32 @@ class PatternDownloadWorker(QtCore.QThread):
filename = self.save_dir / f"{seed}.png"
if filename.exists():
return True, None
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())
return True, None
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:
@ -140,7 +155,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("images") / slug
save_dir = Path("analyses") / slug / "images"
self.start_btn.setEnabled(False)
self.url_input.setEnabled(False)

View File

@ -7,6 +7,10 @@ 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:
@ -15,6 +19,7 @@ overlay_color = "#ff0000"
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

72
tests/test_file_paths.py Normal file
View File

@ -0,0 +1,72 @@
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"

View File

@ -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"
return f"{kwargs['with_pct']:.1f} {kwargs['without_pct']:.1f} {kwargs['excluded_pct']:.1f} {kwargs['excluded_match_pct']:.1f}"
if not kwargs:
return key
return f"{kwargs['with_pct']:.1f} {kwargs['without_pct']:.1f} {kwargs['excluded_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
# excluded_match_pct: 10/20 = 50.0
assert res == "50.0 50.0 20.0 50.0"
assert res == "50.0 50.0 20.0"
def test_stats_empty():
s = Stats()