Add min peak frequency floor (CACH v4) and Batch Export View as Images

Two strands of in-progress work, committed together because they overlap
in sras_workers.py and main_window.py.

Min peak frequency floor:
- CACH tail bumped to version 4, adding u32 min_freq_khz provenance in
  fixed-point kHz (a float32 20.1 reads back as 20.10000038 and would
  report a spurious mismatch forever). v1-v3 tails read as no floor.
- Stored FFT caches are accepted when the reader's floor is at or above
  the stored one, since a higher floor is re-applicable by masking.
- Floor plumbed through compute_rf_image, BatchCacheWorker and the viewer.

Batch Export View as Images:
- New sras_render.py holds draw_view_image, shared by the Qt canvas and
  the headless exporter so a PNG cannot drift from what the GUI shows.
  Deliberately Qt-free so it is importable in a pool subprocess.
- BatchExportImagesWorker renders the current view settings across many
  files, process-pooled with an inline fallback, reporting per-file
  output names so the caller can flag same-stem collisions.
- _axes_extent extracted into sras_format for both render paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-08-12 10:17:43 -05:00
parent c54cce453c
commit a8b962e317
13 changed files with 1442 additions and 153 deletions
+31 -20
View File
@@ -12,6 +12,7 @@ from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import QSizePolicy
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv
from sras_render import draw_view_image
def count_colormap(n_angles: int):
"""(cmap, norm, ticks) for an integer "how many angles cover this pixel"
@@ -182,24 +183,12 @@ class ImageCanvas(FigureCanvasQTAgg):
self._extent = extent
self._img_shape = img.shape
if bad_color is not None:
cmap = (cmap if hasattr(cmap, "with_extremes")
else mpl.colormaps[cmap]).with_extremes(bad=bad_color)
kw = ({"norm": norm} if norm is not None
else {"vmin": vmin, "vmax": vmax})
im = self.ax.imshow(
img, aspect="auto", origin="upper",
extent=extent, cmap=cmap, interpolation="nearest", **kw,
)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
ticks=cb_ticks)
if colorbar_label:
cb.set_label(colorbar_label)
self.ax.set_xlabel(xlabel)
self.ax.set_ylabel(ylabel)
self.ax.set_title(title)
# Shared with the headless batch image-export worker (sras_render.py)
# so an exported PNG can never quietly drift from what this canvas
# shows on screen for the same settings.
draw_view_image(self.ax, self.figure, img, extent, cmap, vmin, vmax,
xlabel, ylabel, title, colorbar_label, cb_ticks, norm,
bad_color)
# Re-draw the ROI (if any) on top of the fresh image so it persists
# unchanged across mode / angle / channel switches.
@@ -440,13 +429,22 @@ class WaveformCanvas(FigureCanvasQTAgg):
def show_rf_waveform(self, sras: SrasFile, angle_idx: int,
row_idx: int, frame_idx: int,
apply_bg_sub: bool = True):
apply_bg_sub: bool = True,
min_freq_mhz: float = 0.0):
"""CH1 RF: time-domain + FFT spectrum.
If apply_bg_sub is True and sras.background is not None, the background
waveform is overlaid on the time-domain plot and the FFT is computed
on the subtracted signal. The unsubtracted FFT is also shown faintly
for comparison.
*min_freq_mhz* > 0 restricts the labeled peak to bins at or above
it — the same floor the image's peak search uses, so the label
explains the map pixel instead of contradicting it — and shades the
excluded band on the spectrum. The spectrum curves themselves stay
complete (they are the evidence for choosing the floor). The peak
can still legitimately differ from a padded or row-averaged map:
this panel is always a single waveform at natural resolution.
"""
data = sras.data[angle_idx]
waveform = data[row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
@@ -487,7 +485,20 @@ class WaveformCanvas(FigureCanvasQTAgg):
# FFT of the (possibly subtracted) waveform
power_sub = np.abs(np.fft.rfft(waveform_plot)) ** 2
power_sub[0] = 0.0
peak_mhz = f_mhz[int(np.argmax(power_sub))]
# First bin at or above the floor, exactly as the image peak search
# picks it (bin 0 always excluded). If the floor excludes every bin,
# fall back to the unrestricted peak rather than indexing past the
# end — the label is informational, not a mask.
lo = max(1, int(np.searchsorted(f_mhz, min_freq_mhz)))
if lo < len(power_sub):
peak_mhz = f_mhz[lo + int(np.argmax(power_sub[lo:]))]
else:
peak_mhz = f_mhz[int(np.argmax(power_sub))]
if min_freq_mhz > 0.0:
self.ax_right.axvspan(0, min_freq_mhz, color="#888888",
alpha=0.15, zorder=0,
label=f"< {min_freq_mhz:g} MHz excluded")
if bg is not None:
# Also show the unsubtracted FFT for reference
+1 -8
View File
@@ -6,7 +6,7 @@ from PyQt6.QtWidgets import (
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, _axes_extent # noqa: F401 (re-exported)
# ---------------------------------------------------------------------------
# Display constants
@@ -112,13 +112,6 @@ def _combo(items=(), *, min_chars: int = 10) -> QComboBox:
return combo
def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]:
"""Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
renders at the top."""
return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
def _wrap_label(text: str = "", css: str | None = None) -> QLabel:
"""A word-wrapped QLabel that reports its *wrapped* height to the layout.
+202 -62
View File
@@ -1,6 +1,7 @@
"""The SrasViewerWindow main window and application entry point."""
import sys
from collections import Counter
from pathlib import Path
import numpy as np
@@ -23,7 +24,8 @@ from sras_format import (
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
)
from sras_workers import (
BatchCacheWorker, ComputeWorker, DcPrecomputeWorker, LoadWorker,
BatchCacheWorker, BatchExportImagesWorker, ComputeWorker,
DcPrecomputeWorker, LoadWorker,
)
from .canvases import ImageCanvas, WaveformCanvas
@@ -92,13 +94,13 @@ class SrasViewerWindow(QMainWindow):
# computed lazily (with a progress popup) the first time an
# angle/threshold combination is viewed — using the cached DC4
# image to skip the FFT entirely for masked-out pixels — and
# cached per (angle, threshold) so revisiting the same combination
# is free. bg-sub/pad are deliberately not part of the key: once an
# angle has any FFT image (live or from the file's own stored
# cache), it stays displayed regardless of those controls — see
# _fft_cache_key.
# cached per (angle, threshold, min peak freq) so revisiting the
# same combination is free. bg-sub/pad are deliberately not part of
# the key: once an angle has any FFT image (live or from the file's
# own stored cache), it stays displayed regardless of those
# controls — see _fft_cache_key.
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
self._fft_cache: dict[tuple[int, float], np.ndarray] = {}
self._fft_cache: dict[tuple[int, float, float], np.ndarray] = {}
self._dc_generation: int = 0
# Angle alignment ("Fusion" menu)
@@ -287,9 +289,12 @@ class SrasViewerWindow(QMainWindow):
"is real, valid data. Raising this floor above that skirt forces\n"
"the search to report the strongest peak that is plausibly real\n"
"signal instead.\n"
"Only affects a fresh/live compute — it cannot change an image\n"
"already shown, or one already stored in this file's own cache\n"
"(use Batch Compute to regenerate those)."
"Raising the floor also re-masks images already shown or stored\n"
"in this file's cache: pixels whose stored peak falls below it\n"
"display as invalid. Lowering it below a stored cache's own\n"
"floor needs a recompute (bins below that floor were never\n"
"searched). Batch Compute FFT records the floor in the file and\n"
"re-resolves masked pixels to their true above-floor peak."
)
self.spin_min_freq_mhz.editingFinished.connect(self._on_min_freq_changed)
min_freq_form.addRow("Min peak freq:", self.spin_min_freq_mhz)
@@ -518,6 +523,20 @@ class SrasViewerWindow(QMainWindow):
self._batch_fft_rowavg_act.triggered.connect(self._on_batch_compute_row_avg)
convert_menu.addAction(self._batch_fft_rowavg_act)
convert_menu.addSeparator()
self._batch_export_images_act = QAction(
"Batch Export View as &Images…", self)
self._batch_export_images_act.setStatusTip(
"Select .sras files and export the currently selected view "
"(channel, angle, threshold, colormap, etc.) as one PNG per "
"file, rendered the same way it is shown on screen. Read-only "
"— never modifies the source files. Aligned View is ignored "
"even if enabled, since an alignment result belongs to one "
"specific file's geometry.")
self._batch_export_images_act.triggered.connect(
self._on_batch_export_images)
convert_menu.addAction(self._batch_export_images_act)
# ------------------------------------------------------------------
# Drag-and-drop
# ------------------------------------------------------------------
@@ -661,10 +680,13 @@ class SrasViewerWindow(QMainWindow):
if s.precomputed_row_avg_n else "")
pad_note = (f", pad {s.precomputed_pad_factor}x"
if s.precomputed_pad_factor > 1 else "")
floor_note = (f", floor ≥ {s.precomputed_min_freq_mhz:g} MHz"
if s.precomputed_min_freq_mhz > 0 else "")
notes.append(
f"Cached images: DC {n_dc}/{s.n_angles} angles, "
f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''}"
f"{avg_note if n_fft else ''}{pad_note if n_fft else ''} "
f"{avg_note if n_fft else ''}{pad_note if n_fft else ''}"
f"{floor_note if n_fft else ''} "
"— display is instant for cached angles")
notes += self._cache_mismatch_notes()
elif s.version == 7:
@@ -673,17 +695,24 @@ class SrasViewerWindow(QMainWindow):
def _cache_mismatch_notes(self) -> list[str]:
"""Informational only: whether the file's stored FFT cache was
computed under different bg-sub/pad settings than these controls
currently say. The display always shows the stored image as-is
regardless (see _stored_fft_image) — these controls only affect a
future live compute for an angle with nothing cached yet, or an
explicit batch recompute, never what's already on screen.
computed under different bg-sub/pad/min-freq settings than these
controls currently say. The display always shows the stored image
(re-masked for a raised floor) regardless — see _stored_fft_image;
these controls only affect a future live compute for an angle with
nothing cached yet, or an explicit batch recompute, never what's
already on screen.
row_avg_n is compared against the file's own recorded value (a
self-match), so it never contributes a reason here — there's no
live control for it to diverge from, and the "Cached images" line
above already reports it.
The min peak frequency floor gets two directions: a live floor
*below* the stored one is a genuine mismatch reason (the stored
search never looked below its floor), while a live floor *above*
the stored one is servable — pixels under it are shown as masked —
so that direction gets its own softer note.
Asks compute for the reasons rather than restating the accept rule,
so a new provenance field can only be added in one place.
"""
@@ -691,15 +720,26 @@ class SrasViewerWindow(QMainWindow):
if s is None or all(x is None for x in s.precomputed_freq_mhz):
return []
live_floor = self.spin_min_freq_mhz.value()
notes = []
reasons = compute.cache_mismatch_reasons(
s, n_fft=self._current_n_fft(),
apply_bg_sub=self.chk_bg_sub.isChecked(),
row_avg_n=s.precomputed_row_avg_n)
if not reasons:
return []
return ["Note: current bg-sub/pad controls differ from the stored "
"cache — " + "; ".join(reasons) + ". Shown as stored; use "
"Batch Compute to recompute with these settings."]
row_avg_n=s.precomputed_row_avg_n,
min_freq_mhz=live_floor)
if reasons:
notes.append(
"Note: current bg-sub/pad/min-freq controls differ from the "
"stored cache — " + "; ".join(reasons) + ". Shown as stored; "
"use Batch Compute to recompute with these settings.")
if round(live_floor * 1000) > round(s.precomputed_min_freq_mhz * 1000):
notes.append(
f"Note: Min peak freq ({live_floor:g} MHz) is above the "
f"stored cache's floor ({s.precomputed_min_freq_mhz:g} MHz) — "
f"stored pixels whose peak falls below {live_floor:g} MHz are "
"shown as masked; Batch Compute FFT re-resolves them above "
"the floor instead.")
return notes
# ------------------------------------------------------------------
# Controls
@@ -774,11 +814,13 @@ class SrasViewerWindow(QMainWindow):
self._refresh_display()
def _on_min_freq_changed(self):
# Like the DC threshold, this changes what the FFT itself produces —
# a genuine cache-key change — but unlike the threshold it can't be
# re-applied to an already-computed image; it only takes effect on a
# fresh compute (see _fft_cache_key / compute_rf_image's docstring).
# Like the DC threshold, this is a genuine cache-key change that can
# be cheaply re-applied to a stored image — tighten-only: raising the
# floor masks stored pixels below it, while lowering it below the
# stored cache's own floor can only be answered by a fresh compute
# (see cached_rf_image / cache_mismatch_reasons).
if self._is_fft_mode():
self._update_scan_info_labels()
self._refresh_display()
def _on_autoscale_toggled(self, checked: bool):
@@ -1072,11 +1114,10 @@ class SrasViewerWindow(QMainWindow):
image for that angle regardless of later bg-sub/pad toggles; those
only affect a future live compute for an angle with nothing cached
yet, or an explicit batch recompute (see _stored_fft_image).
Threshold and min-freq stay in the key so revisiting a combination
already computed this session is instant — even though only
threshold can be cheaply re-applied to a stored image; a min-freq
change against a stored image still falls through to
_stored_fft_image, which ignores it (see _on_min_freq_changed)."""
Threshold and min-freq are both in the key because both are cheaply
re-applied when a stored image is served (_stored_fft_image takes
them live), so the cached value genuinely reflects every component
of its key and revisiting a combination is instant."""
return (angle_idx, self.spin_threshold_mv.value(),
self.spin_min_freq_mhz.value())
@@ -1155,10 +1196,13 @@ class SrasViewerWindow(QMainWindow):
controls never gate whether it's used, only what a *future* compute
produces. See _cache_mismatch_notes for the informational (non-
blocking) note when the live controls diverge from what's shown.
Only the DC threshold is taken live: re-masking a stored image
against it is free, unlike bg-sub/pad/row-averaging — or the min
peak frequency floor — which are baked irreversibly into the stored
numbers.
The DC threshold and the min peak frequency floor are taken live:
re-masking a stored image against either is free, unlike
bg-sub/pad/row-averaging, which are baked irreversibly into the
stored numbers. The floor is tighten-only, though — a live floor
*below* the stored cache's own floor is the one case where a stored
image genuinely can't answer (cached_rf_image refuses it), and the
caller falls through to a real compute.
allow_dc_recompute=False keeps this off the I/O path: if the mask
would mean reading a whole CH4 channel, this declines and the caller
@@ -1175,7 +1219,8 @@ class SrasViewerWindow(QMainWindow):
n_fft=n_fft,
row_avg_n=s.precomputed_row_avg_n,
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
allow_dc_recompute=False)
allow_dc_recompute=False,
min_freq_mhz=self.spin_min_freq_mhz.value())
def _cached_value_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""The already-available (no compute) image for (angle, channel):
@@ -1289,22 +1334,27 @@ class SrasViewerWindow(QMainWindow):
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
extent = _axes_extent(x_axis, y_axis, dx, dy)
# Masked-out (below-threshold) pixels are stored as a plain 0, the
# same value a real but low-frequency pixel can legitimately have -
# the two are indistinguishable once both land near the bottom of a
# linear colormap. Pull masked pixels out to NaN (drawn in a
# distinct highlight color, excluded from the auto-scale range) so a
# real low-frequency pixel keeps its own true shade instead of
# disappearing into the same black as "no data".
# Masked-out pixels are stored as a plain 0, the same value near
# the bottom of a linear colormap as a real low-frequency pixel -
# the two are indistinguishable there. Pull masked pixels out to
# NaN (drawn in a distinct highlight color, excluded from the
# auto-scale range) so a real pixel keeps its own true shade
# instead of disappearing into the same black as "no data". In an
# FFT-derived mode a value of exactly 0 is itself the "no valid
# peak" sentinel (DC-masked, below the min-freq floor, or an empty
# spectrum — bin 0 is always excluded, so no genuine peak is ever
# 0), so those pixels are masked by value too; that covers
# floor-masked pixels even when no DC4 image is cached yet.
highlight_masked = (ch_idx in CH1_DERIVED_MODES
and self.chk_highlight_masked.isChecked())
mask_valid = (self._dc_validity_mask(angle_idx, aligned)
if highlight_masked else None)
if mask_valid is not None and mask_valid.shape == display_img.shape:
if highlight_masked:
mask_valid = self._dc_validity_mask(angle_idx, aligned)
if mask_valid is not None and mask_valid.shape != display_img.shape:
mask_valid = None
display_img = display_img.astype(np.float32, copy=True)
display_img[~mask_valid] = np.nan
else:
mask_valid = None
if mask_valid is not None:
display_img[~mask_valid] = np.nan
display_img[display_img == 0.0] = np.nan
if self.chk_auto.isChecked():
vmin, vmax = float(np.nanmin(display_img)), float(np.nanmax(display_img))
@@ -1333,7 +1383,7 @@ class SrasViewerWindow(QMainWindow):
vmin=vmin, vmax=vmax,
xlabel="X (mm)", ylabel="Y (mm)",
title=title, colorbar_label=colorbar_label,
bad_color=_MASKED_HIGHLIGHT_COLOR if mask_valid is not None else None,
bad_color=_MASKED_HIGHLIGHT_COLOR if highlight_masked else None,
)
self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
@@ -1493,7 +1543,8 @@ class SrasViewerWindow(QMainWindow):
if self._current_ch in CH1_DERIVED_MODES:
self.wave_canvas.show_rf_waveform(
self._sras, angle_idx, row_idx, frame_idx,
apply_bg_sub=self.chk_bg_sub.isChecked())
apply_bg_sub=self.chk_bg_sub.isChecked(),
min_freq_mhz=self.spin_min_freq_mhz.value())
else:
self.wave_canvas.show_dc_waveform(
self._sras, angle_idx, self._current_ch, row_idx, frame_idx)
@@ -1545,7 +1596,8 @@ class SrasViewerWindow(QMainWindow):
# Cache the FFT at the pad the viewer is actually displaying at,
# otherwise the batch stores images this window can never use.
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(),
pad_factor=self._fft_pad_factor)
pad_factor=self._fft_pad_factor,
min_freq_mhz=self.spin_min_freq_mhz.value())
started = self._run_worker(
Jobs.BATCH, worker,
connect=(
@@ -1558,9 +1610,7 @@ class SrasViewerWindow(QMainWindow):
if not started:
return # a second trigger snuck in while the file dialog was open
self._batch_dc_act.setEnabled(False)
self._batch_fft_act.setEnabled(False)
self._batch_fft_rowavg_act.setEnabled(False)
self._set_batch_actions_enabled(False)
self._show_progress(
Jobs.BATCH, f"Batch computing {label} for {len(paths)} file(s)…",
maximum=100)
@@ -1587,7 +1637,8 @@ class SrasViewerWindow(QMainWindow):
self._batch_errors = []
worker = BatchCacheWorker(paths, "fft_rowavg", self.chk_bg_sub.isChecked(),
dc_threshold_mv=threshold_mv, row_avg_n=n,
pad_factor=self._fft_pad_factor)
pad_factor=self._fft_pad_factor,
min_freq_mhz=self.spin_min_freq_mhz.value())
started = self._run_worker(
Jobs.BATCH, worker,
connect=(
@@ -1600,9 +1651,7 @@ class SrasViewerWindow(QMainWindow):
if not started:
return # a second trigger snuck in while a dialog was open
self._batch_dc_act.setEnabled(False)
self._batch_fft_act.setEnabled(False)
self._batch_fft_rowavg_act.setEnabled(False)
self._set_batch_actions_enabled(False)
self._show_progress(
Jobs.BATCH,
f"Batch computing row-averaged FFT (n={n}, threshold={threshold_mv:.1f} mV) "
@@ -1633,9 +1682,100 @@ class SrasViewerWindow(QMainWindow):
self._load_file(str(self._sras.path))
def _after_batch(self):
self._batch_dc_act.setEnabled(True)
self._batch_fft_act.setEnabled(True)
self._batch_fft_rowavg_act.setEnabled(True)
self._set_batch_actions_enabled(True)
def _set_batch_actions_enabled(self, enabled: bool):
"""All four Convert-menu batch actions share one Jobs.BATCH slot;
grey out every sibling while one runs, rather than leaving one
clickable but silently no-op'd by the busy guard."""
for act in (self._batch_dc_act, self._batch_fft_act,
self._batch_fft_rowavg_act, self._batch_export_images_act):
act.setEnabled(enabled)
# ------------------------------------------------------------------
# Batch export view as images
# ------------------------------------------------------------------
def _on_batch_export_images(self):
if self._job_running(Jobs.BATCH):
return
paths, _ = QFileDialog.getOpenFileNames(
self, "Select .sras files to export the current view from", "",
"SRAS files (*.sras);;All files (*)")
if not paths:
return
out_dir = QFileDialog.getExistingDirectory(
self, "Select output folder for exported images")
if not out_dir:
return
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
is_fft_mode = ch_idx in CH1_DERIVED_MODES
mode_str, _unit, colorbar_label = _CHANNEL_DISPLAY[ch_idx]
self._batch_errors = []
self._batch_export_names = []
worker = BatchExportImagesWorker(
paths,
out_dir=out_dir, angle_idx=angle_idx, ch_idx=ch_idx,
is_fft_mode=is_fft_mode, is_velocity=(ch_idx == VELOCITY_MODE_IDX),
dc_threshold_mv=self.spin_threshold_mv.value(),
apply_bg_sub=self.chk_bg_sub.isChecked(),
pad_factor=self._fft_pad_factor,
min_freq_mhz=self.spin_min_freq_mhz.value(),
grating_um=self.spin_grating_um.value(),
cmap=self.combo_cmap.currentText(),
auto_scale=self.chk_auto.isChecked(),
vmin=self.spin_vmin.value(), vmax=self.spin_vmax.value(),
highlight_masked=self.chk_highlight_masked.isChecked(),
mode_str=mode_str, colorbar_label=colorbar_label,
mask_color=_MASKED_HIGHLIGHT_COLOR,
)
started = self._run_worker(
Jobs.BATCH, worker,
connect=(
("progress", lambda pct: self._set_progress(Jobs.BATCH, pct)),
("file_done", self._on_batch_export_file_done),
("finished",
lambda p=paths, d=out_dir: self._on_batch_export_finished(p, d)),
),
on_done=self._after_batch,
)
if not started:
return # a second trigger snuck in while a dialog was open
self._set_batch_actions_enabled(False)
self._show_progress(
Jobs.BATCH, f"Exporting images for {len(paths)} file(s)…",
maximum=100)
def _on_batch_export_file_done(self, path: str, err: str, out_name: str):
if err:
self._batch_errors.append(f"{Path(path).name} — {err}")
else:
self._batch_export_names.append(out_name)
self._show_progress(Jobs.BATCH, f"Exported {Path(path).name}…")
def _on_batch_export_finished(self, paths: list[str], out_dir: str):
self._close_progress(Jobs.BATCH)
n_total = len(paths)
n_failed = len(self._batch_errors)
n_ok = n_total - n_failed
summary = (f"Batch export: {n_ok}/{n_total} image(s) written to "
f"{Path(out_dir).name}")
if n_failed:
summary += f", {n_failed} failed: {'; '.join(self._batch_errors)}"
dupes = sum(1 for _name, count in Counter(self._batch_export_names).items()
if count > 1)
if dupes:
summary += (f" | {dupes} filename collision(s) — later file(s) "
"overwrote earlier ones with the same output name")
self.statusBar().showMessage(summary)
self._batch_errors = []
self._batch_export_names = []
# No reload: unlike Batch Compute, export never touches the source files.
# ------------------------------------------------------------------
# Fusion: angle alignment