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
This commit is contained in:
lukas 2026-03-10 17:59:49 +01:00
parent 49b436a2f6
commit c278ddf458
3 changed files with 115 additions and 38 deletions

View File

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

View File

@ -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)
# 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 = (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)]

View File

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