From c278ddf45833eed714326b357d4df63775c96d57 Mon Sep 17 00:00:00 2001 From: lukas Date: Tue, 10 Mar 2026 17:59:49 +0100 Subject: [PATCH] Optimize: export speed, fix scaling bug, and improve workflow - Parallelized folder export for massive speedup - Implemented exclusion mask caching - Fixed statistics discrepancy by scaling exclusion coordinates - Hardcoded CSV format to semicolon separator and comma decimal - Defaulted file/folder dialogs to images/ directory - Added unit test for coordinate scaling --- app/qt/image_processor.py | 42 +++++++++++++++---- app/qt/main_window.py | 77 +++++++++++++++++++++-------------- tests/test_image_processor.py | 34 ++++++++++++++++ 3 files changed, 115 insertions(+), 38 deletions(-) diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py index d8dbe34..c19f8cd 100644 --- a/app/qt/image_processor.py +++ b/app/qt/image_processor.py @@ -104,6 +104,11 @@ class QtImageProcessor: self.exclude_shapes: list[dict[str, object]] = [] self.reset_exclusions_on_switch: bool = False + # Mask caching + self._cached_mask: np.ndarray | None = None + self._cached_mask_size: Tuple[int, int] | None = None + self.exclude_ref_size: Tuple[int, int] | None = None + def set_defaults(self, defaults: dict) -> None: for key in self.defaults: if key in defaults: @@ -337,22 +342,36 @@ 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: + self.exclude_ref_size = self.preview_img.size + else: + self.exclude_ref_size = None + + self._cached_mask = None # Invalidate cache + self._cached_mask_size = None self._rebuild_overlay() def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None: if not self.exclude_shapes: return None + + target_w, target_h = size + ref_w, ref_h = self.exclude_ref_size or size + sx = target_w / ref_w if ref_w > 0 else 1.0 + sy = target_h / ref_h if ref_h > 0 else 1.0 + mask = Image.new("L", size, 0) draw = ImageDraw.Draw(mask) for shape in self.exclude_shapes: kind = shape.get("kind") if kind == "rect": x0, y0, x1, y1 = shape["coords"] # type: ignore[index] - draw.rectangle([x0, y0, x1, y1], fill=255) + draw.rectangle([x0 * sx, y0 * sy, x1 * sx, y1 * sy], fill=255) elif kind == "polygon": points = shape.get("points", []) if len(points) >= 3: - draw.polygon(points, fill=255) + scaled_pts = [(int(x * sx), int(y * sy)) for x, y in points] + draw.polygon(scaled_pts, fill=255) return mask def set_overlay_color(self, hex_code: str) -> None: @@ -368,10 +387,19 @@ class QtImageProcessor: def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray: """Return a boolean (H, W) mask — True where pixels are excluded.""" + if self._cached_mask is not None and self._cached_mask_size == size: + return self._cached_mask + w, h = size if not self.exclude_shapes: - return np.zeros((h, w), dtype=bool) - pil_mask = self._build_exclusion_mask(size) - if pil_mask is None: - return np.zeros((h, w), dtype=bool) - return np.asarray(pil_mask, dtype=bool) + mask = np.zeros((h, w), dtype=bool) + else: + pil_mask = self._build_exclusion_mask(size) + if pil_mask is None: + mask = np.zeros((h, w), dtype=bool) + else: + mask = np.asarray(pil_mask, dtype=bool) + + self._cached_mask = mask + self._cached_mask_size = size + return mask diff --git a/app/qt/main_window.py b/app/qt/main_window.py index 9096210..b86d71d 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -1,8 +1,12 @@ """Main PySide6 window emulating the legacy Tk interface with translations and themes.""" from __future__ import annotations - +import re +import time +import urllib.request +import urllib.error import csv +import concurrent.futures from pathlib import Path from typing import Callable, Dict, List, Tuple @@ -765,7 +769,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): def open_image(self) -> None: filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" - path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, self._t("dialog.open_image_title"), "", filters) + 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 path = Path(path_str) @@ -781,7 +786,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): self._refresh_views() def open_folder(self) -> None: - directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title")) + 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 folder = Path(directory) @@ -819,13 +825,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): total = len(self.processor.preview_paths) - # Determine localization: German language or DE system locale defaults to EU format (; delimiter, , decimal) - import locale - sys_lang = QtCore.QLocale().name().split('_')[0].lower() - is_eu = (self.language == "de" or sys_lang == "de") - - delimiter = ";" if is_eu else "," - decimal = "," if is_eu else "." + # Hardcoded to EU format as requested: ; delimiter, , decimal + delimiter = ";" + decimal = "," headers = [ "Filename", @@ -836,30 +838,43 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin): ] rows = [headers] - for i, img_path in enumerate(self.processor.preview_paths): - self.status_label.setText(self._t("status.exporting", current=str(i+1), total=str(total))) - QtWidgets.QApplication.processEvents() # Keep UI vaguely responsive + def process_image(img_path): + try: + img = Image.open(img_path) + s = self.processor.get_stats_headless(img) + + pct_all = (s.matches_all / s.total_all * 100) if s.total_all else 0.0 + pct_keep = (s.matches_keep / s.total_keep * 100) if s.total_keep else 0.0 + pct_excl = (s.total_excl / s.total_all * 100) if s.total_all else 0.0 - # Process purely mathematically without breaking the active UI context - img = Image.open(img_path) - s = self.processor.get_stats_headless(img) - - pct_all = (s.matches_all / s.total_all * 100) if s.total_all else 0.0 - pct_keep = (s.matches_keep / s.total_keep * 100) if s.total_keep else 0.0 - pct_excl = (s.total_excl / s.total_all * 100) if s.total_all else 0.0 + pct_all_str = f"{pct_all:.2f}".replace(".", decimal) + pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal) + pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal) - pct_all_str = f"{pct_all:.2f}".replace(".", decimal) - pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal) - pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal) + img.close() + return [ + img_path.name, + self._current_color, + pct_all_str, + pct_keep_str, + pct_excl_str + ] + except Exception: + return [img_path.name, self._current_color, "Error", "Error", "Error"] - rows.append([ - img_path.name, - self._current_color, - pct_all_str, - pct_keep_str, - pct_excl_str - ]) - img.close() + results = [None] * total + with concurrent.futures.ThreadPoolExecutor() as executor: + future_to_idx = {executor.submit(process_image, p): i for i, p in enumerate(self.processor.preview_paths)} + done_count = 0 + for future in concurrent.futures.as_completed(future_to_idx): + idx = future_to_idx[future] + results[idx] = future.result() + done_count += 1 + if done_count % 10 == 0 or done_count == total: + self.status_label.setText(self._t("status.exporting", current=str(done_count), total=str(total))) + QtWidgets.QApplication.processEvents() + + rows.extend(results) # Compute max width per column for alignment, plus extra space so it's not cramped col_widths = [max(len(str(item)) for item in col) + 4 for col in zip(*rows)] diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py index e8037d2..9d3495f 100644 --- a/tests/test_image_processor.py +++ b/tests/test_image_processor.py @@ -87,3 +87,37 @@ def test_set_overlay_color(): # invalid hex does nothing proc.set_overlay_color("blue") assert proc.overlay_r == 0 + +def test_coordinate_scaling(): + proc = QtImageProcessor() + + # Create a 200x200 image where everything is red + red_img_small = Image.new("RGBA", (200, 200), (255, 0, 0, 255)) + proc.orig_img = red_img_small # satisfy preview logic + proc.preview_img = red_img_small + + # All red. Thresholds cover all red. + proc.hue_min = 0 + proc.hue_max = 360 + proc.sat_min = 10 + proc.val_min = 10 + + # Exclude the right half (100-200) + proc.set_exclusions([{"kind": "rect", "coords": (100, 0, 200, 200)}]) + + # Verify small stats + s_small = proc.get_stats_headless(red_img_small) + # total=40000, keep=20000, excl=20000 + assert s_small.total_all == 40000 + assert s_small.total_keep == 20000 + assert s_small.total_excl == 20000 + + # Now check on a 1000x1000 image (5x scale) + red_img_large = Image.new("RGBA", (1000, 1000), (255, 0, 0, 255)) + s_large = proc.get_stats_headless(red_img_large) + + # total=1,000,000. If scaling works, keep=500,000, excl=500,000. + # If scaling FAILED, the mask is still 100x200 (20,000 px) -> excl=20,000. + assert s_large.total_all == 1000000 + assert s_large.total_keep == 500000 + assert s_large.total_excl == 500000