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.exclude_shapes: list[dict[str, object]] = []
self.reset_exclusions_on_switch: bool = False 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: def set_defaults(self, defaults: dict) -> None:
for key in self.defaults: for key in self.defaults:
if key in defaults: if key in defaults:
@ -337,22 +342,36 @@ class QtImageProcessor:
pts = shape.get("points", []) pts = shape.get("points", [])
copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]}) copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]})
self.exclude_shapes = copied 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() self._rebuild_overlay()
def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None: def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None:
if not self.exclude_shapes: if not self.exclude_shapes:
return None 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) mask = Image.new("L", size, 0)
draw = ImageDraw.Draw(mask) draw = ImageDraw.Draw(mask)
for shape in self.exclude_shapes: for shape in self.exclude_shapes:
kind = shape.get("kind") kind = shape.get("kind")
if kind == "rect": if kind == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index] 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": elif kind == "polygon":
points = shape.get("points", []) points = shape.get("points", [])
if len(points) >= 3: 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 return mask
def set_overlay_color(self, hex_code: str) -> None: 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: def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray:
"""Return a boolean (H, W) mask — True where pixels are excluded.""" """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 w, h = size
if not self.exclude_shapes: if not self.exclude_shapes:
return np.zeros((h, w), dtype=bool) mask = np.zeros((h, w), dtype=bool)
pil_mask = self._build_exclusion_mask(size) else:
if pil_mask is None: pil_mask = self._build_exclusion_mask(size)
return np.zeros((h, w), dtype=bool) if pil_mask is None:
return np.asarray(pil_mask, dtype=bool) 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.""" """Main PySide6 window emulating the legacy Tk interface with translations and themes."""
from __future__ import annotations from __future__ import annotations
import re
import time
import urllib.request
import urllib.error
import csv import csv
import concurrent.futures
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Tuple from typing import Callable, Dict, List, Tuple
@ -765,7 +769,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def open_image(self) -> None: def open_image(self) -> None:
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" 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: if not path_str:
return return
path = Path(path_str) path = Path(path_str)
@ -781,7 +786,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._refresh_views() self._refresh_views()
def open_folder(self) -> None: 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: if not directory:
return return
folder = Path(directory) folder = Path(directory)
@ -819,13 +825,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
total = len(self.processor.preview_paths) total = len(self.processor.preview_paths)
# Determine localization: German language or DE system locale defaults to EU format (; delimiter, , decimal) # Hardcoded to EU format as requested: ; delimiter, , decimal
import locale delimiter = ";"
sys_lang = QtCore.QLocale().name().split('_')[0].lower() decimal = ","
is_eu = (self.language == "de" or sys_lang == "de")
delimiter = ";" if is_eu else ","
decimal = "," if is_eu else "."
headers = [ headers = [
"Filename", "Filename",
@ -836,30 +838,43 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
] ]
rows = [headers] rows = [headers]
for i, img_path in enumerate(self.processor.preview_paths): def process_image(img_path):
self.status_label.setText(self._t("status.exporting", current=str(i+1), total=str(total))) try:
QtWidgets.QApplication.processEvents() # Keep UI vaguely responsive 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 pct_all_str = f"{pct_all:.2f}".replace(".", decimal)
img = Image.open(img_path) pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal)
s = self.processor.get_stats_headless(img) pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal)
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) img.close()
pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal) return [
pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal) 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([ results = [None] * total
img_path.name, with concurrent.futures.ThreadPoolExecutor() as executor:
self._current_color, future_to_idx = {executor.submit(process_image, p): i for i, p in enumerate(self.processor.preview_paths)}
pct_all_str, done_count = 0
pct_keep_str, for future in concurrent.futures.as_completed(future_to_idx):
pct_excl_str idx = future_to_idx[future]
]) results[idx] = future.result()
img.close() 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 # 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)] 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 # invalid hex does nothing
proc.set_overlay_color("blue") proc.set_overlay_color("blue")
assert proc.overlay_r == 0 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