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.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
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue