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:
+202
-62
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user