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:
parent
49b436a2f6
commit
c278ddf458
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue