Merge the Documents/sras-viewer working copy into this repo

The two clones had diverged from origin/main (191d1b8) and both had
grown uncommitted work. This merges the other clone's three commits and
reconciles four conflicting files.

Both clones independently grew a feature called "batch export", but they
are different sweeps and both are kept:
- BatchExportWorker (this clone) — every angle x channel of the one open
  file, fixed colorbar per channel, under a new Export menu.
- BatchExportImagesWorker (other clone) — the current view across many
  selected files, process-pooled, under Convert.

Conflict resolutions of note:
- sras_average.py: the other clone's memory-bounded rewrite had silently
  reverted this clone's float32 -> int16 accumulator fix, which exists to
  keep averaged output from landing 1 ADC count off the original tool.
  Kept the rewrite, re-applied the fix, and corrected the two docstrings
  that still claimed float32.
- tests/test_compute.py: kept the other clone's meta["waveforms"] key
  (meta["data"] no longer exists for this fixture and would KeyError)
  and its laser_freq_hz assertions, plus this clone's int16 expectation
  and its dropped subprocess returncode assertions.
- ComputeWorker: row_avg_n and min_freq_mhz were added independently on
  either side; both are now threaded through.
- compute_rf_image's `exact` parameter is gone, removed by the other
  clone's PyFFTW peak-search rewrite. It had no remaining callers.

Verified: 149 passed with a complete dependency set. The failures seen
with this clone's own .venv are a missing scikit-image, which predates
this merge and reproduces identically on the pre-merge commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-08-12 10:36:25 -05:00
19 changed files with 2130 additions and 617 deletions
+291 -56
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,16 +24,16 @@ from sras_format import (
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
)
from sras_workers import (
BatchCacheWorker, BatchExportWorker, ComputeWorker, DcPrecomputeWorker,
LoadWorker,
BatchCacheWorker, BatchExportImagesWorker, BatchExportWorker,
ComputeWorker, DcPrecomputeWorker, LoadWorker,
)
from .canvases import ImageCanvas, WaveformCanvas
from .common import (
CH1_DERIVED_MODES, CH_LABELS, CMAPS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY,
_CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W,
_RIGHT_PANEL_W, Jobs, _combo, _form, _group, _make_dspin, _scroll_panel,
_wrap_label,
_MASKED_HIGHLIGHT_COLOR, _RIGHT_PANEL_W, Jobs, _combo, _form, _group, _make_dspin,
_scroll_panel, _wrap_label,
)
from .align_wizard import AlignmentWizard
from .dialogs import (
@@ -60,6 +61,7 @@ class SrasViewerWindow(QMainWindow):
self._pending_ch: int = 0
self._pending_bg_sub: bool = True
self._pending_threshold: float = 50.0 # mV
self._pending_min_freq_mhz: float = 0.0
self._pending_fft_pad_factor: int = 1
# Live background jobs, keyed by role — see _run_worker.
@@ -72,7 +74,6 @@ class SrasViewerWindow(QMainWindow):
self._settings = QSettings(QSettings.Format.IniFormat,
QSettings.Scope.UserScope,
"sras-viewer", "sras-viewer")
compute.set_fft_backend(str(self._settings.value("fft/backend", "scipy")))
try:
pad = int(self._settings.value("fft/pad_factor", 1))
except (TypeError, ValueError):
@@ -98,13 +99,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)
@@ -278,6 +279,31 @@ class SrasViewerWindow(QMainWindow):
self.lbl_threshold_adc = _wrap_label(
f"≈ {mv_to_adc(50.0):.1f} ADC counts", _CSS_MUTED)
tl.addWidget(self.lbl_threshold_adc)
min_freq_form = _form()
self.spin_min_freq_mhz = _make_dspin(0.0, 10000.0, 3, suffix=" MHz",
value=0.0, step=1.0)
self.spin_min_freq_mhz.setEnabled(False)
self.spin_min_freq_mhz.setToolTip(
"Excludes every FFT bin below this frequency from the peak\n"
"search (0 = off, only true DC is excluded). A pixel can pass\n"
"the DC threshold above yet still carry only weak real signal —\n"
"when that happens, the un-subtracted background's DC-leakage\n"
"skirt can have more power than the genuine signal, so the peak\n"
"search resolves to a near-zero frequency even though the pixel\n"
"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"
"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)
tl.addLayout(min_freq_form)
vl.addWidget(self.grp_threshold)
# Background subtraction (v4+ files only)
@@ -423,6 +449,22 @@ class SrasViewerWindow(QMainWindow):
self.chk_auto.toggled.connect(self._on_autoscale_toggled)
dl.addWidget(self.chk_auto)
self.chk_highlight_masked = QCheckBox("Highlight masked (no-data) pixels")
self.chk_highlight_masked.setChecked(True)
self.chk_highlight_masked.setEnabled(False)
self.chk_highlight_masked.setToolTip(
"RF/Velocity only. A pixel below the DC threshold has no FFT\n"
"result at all and is otherwise shown as 0 on the same grayscale\n"
"ramp as a real, valid pixel whose peak just happens to be a low\n"
"frequency — the two look identical (both near-black). When\n"
"checked, masked pixels are drawn in a distinct highlight color\n"
"instead, excluded from the colormap's data range, so real\n"
"low-frequency pixels keep their own true shade. Uncheck to\n"
"restore the old behavior where both blend together."
)
self.chk_highlight_masked.toggled.connect(self._on_highlight_masked_toggled)
dl.addWidget(self.chk_highlight_masked)
range_form = _form()
for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")):
spin = _make_dspin(-1e9, 1e9, 4)
@@ -446,7 +488,7 @@ class SrasViewerWindow(QMainWindow):
fft_menu = menubar.addMenu("&FFT")
fft_act = QAction("FFT &Options…", self)
fft_act.setStatusTip("Configure FFT backend and zero-padding")
fft_act.setStatusTip("Configure FFT zero-padding")
fft_act.triggered.connect(self._on_fft_options)
fft_menu.addAction(fft_act)
@@ -486,6 +528,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)
export_menu = menubar.addMenu("&Export")
self._batch_export_act = QAction("&Batch Export Images…", self)
self._batch_export_act.setStatusTip(
@@ -638,10 +694,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:
@@ -650,17 +709,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.
"""
@@ -668,15 +734,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
@@ -699,7 +776,9 @@ class SrasViewerWindow(QMainWindow):
# Threshold and bg-sub apply to all CH1 modes
self.spin_threshold_mv.setEnabled(is_ch1)
self.spin_min_freq_mhz.setEnabled(is_ch1)
self.chk_bg_sub.setEnabled(has_file and s.background is not None and is_ch1)
self.chk_highlight_masked.setEnabled(is_ch1)
self.spin_grating_um.setEnabled(is_vel)
self.grp_velocity.setVisible(is_vel)
@@ -750,6 +829,16 @@ class SrasViewerWindow(QMainWindow):
if self._is_fft_mode():
self._refresh_display()
def _on_min_freq_changed(self):
# 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):
manual = not checked
self.spin_vmin.setEnabled(manual and self._sras is not None)
@@ -757,6 +846,10 @@ class SrasViewerWindow(QMainWindow):
if self._sras is not None and self._current_image is not None:
self._redraw_image(self._current_image)
def _on_highlight_masked_toggled(self, checked: bool):
if self._sras is not None and self._current_image is not None:
self._redraw_image(self._current_image)
def _on_manual_range_changed(self):
if not self.chk_auto.isChecked() and self._current_image is not None:
self._redraw_image(self._current_image)
@@ -1031,15 +1124,18 @@ class SrasViewerWindow(QMainWindow):
return freq_mhz
def _fft_cache_key(self, angle_idx: int) -> tuple:
"""Keyed by angle and DC threshold only. Once any FFT image exists
for an angle this session — live-computed or pulled from the file's
own stored cache — it stays the displayed 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 stays in the key
because re-masking against it is free and meant to stay interactive
(see _on_threshold_changed)."""
return (angle_idx, self.spin_threshold_mv.value())
"""Keyed by angle, DC threshold, and min peak frequency only. Once
any FFT image exists for an angle this session — live-computed or
pulled from the file's own stored cache — it stays the displayed
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 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())
def _aligned_cache_key(self, angle_idx: int, ch_idx: int) -> tuple:
"""Mirrors _fft_cache's key granularity so a stale aligned image is
@@ -1116,9 +1212,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 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
@@ -1135,7 +1235,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):
@@ -1211,6 +1312,24 @@ class SrasViewerWindow(QMainWindow):
self._redraw_image(img)
self._update_roi_ui()
def _dc_validity_mask(self, angle_idx: int, aligned: bool) -> np.ndarray | None:
"""True where a CH1/Velocity pixel passed the DC threshold and so
actually has a real FFT result, on the same grid as display_img.
None if the CH4 DC image for this angle isn't cached yet — the
background DC precompute hasn't reached it, and this is deliberately
not worth a synchronous recompute just to redraw."""
dc4 = self._dc_cache.get((angle_idx, CH4_IDX))
if dc4 is None:
return None
valid = dc4 >= self.spin_threshold_mv.value()
if aligned:
# apply_alignment defaults to nearest-neighbor (order=0), so a
# 0.0/1.0 float warp stays exactly 0 or 1 - no blending at mask
# edges to second-guess with a >= 0.5 cutoff.
valid = apply_alignment(self._alignment_result, angle_idx,
valid.astype(np.float32)) >= 0.5
return valid
def _redraw_image(self, img: np.ndarray):
s = self._sras
angle_idx = self._current_angle
@@ -1231,8 +1350,32 @@ 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 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())
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)
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(display_img.min()), float(display_img.max())
vmin, vmax = float(np.nanmin(display_img)), float(np.nanmax(display_img))
if not np.isfinite(vmin):
vmin, vmax = 0.0, 0.0 # every pixel masked out
for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)):
with QSignalBlocker(spin):
spin.setValue(val)
@@ -1256,6 +1399,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 highlight_masked else None,
)
self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
@@ -1279,6 +1423,7 @@ class SrasViewerWindow(QMainWindow):
self._pending_ch = ch_idx
self._pending_bg_sub = self.chk_bg_sub.isChecked()
self._pending_threshold = self.spin_threshold_mv.value()
self._pending_min_freq_mhz = self.spin_min_freq_mhz.value()
self._pending_fft_pad_factor = self._fft_pad_factor
worker = ComputeWorker(
@@ -1295,6 +1440,7 @@ class SrasViewerWindow(QMainWindow):
# eligible even when this fallback compute is the one that ends
# up serving it (e.g. before DC precompute reaches this angle).
row_avg_n=self._sras.precomputed_row_avg_n,
min_freq_mhz=self._pending_min_freq_mhz,
)
if not self._run_worker(
Jobs.COMPUTE, worker,
@@ -1323,9 +1469,10 @@ class SrasViewerWindow(QMainWindow):
already be cached."""
if (self.spin_angle.value(), self.combo_channel.currentIndex(),
self.chk_bg_sub.isChecked(), self.spin_threshold_mv.value(),
self._fft_pad_factor) != (
self.spin_min_freq_mhz.value(), self._fft_pad_factor) != (
self._pending_angle, self._pending_ch, self._pending_bg_sub,
self._pending_threshold, self._pending_fft_pad_factor):
self._pending_threshold, self._pending_min_freq_mhz,
self._pending_fft_pad_factor):
self._refresh_display()
def _on_compute_done(self, result):
@@ -1336,7 +1483,8 @@ class SrasViewerWindow(QMainWindow):
ch_idx = self._pending_ch
if ch_idx in CH1_DERIVED_MODES:
self._fft_cache[(angle_idx, self._pending_threshold)] = result
key = (angle_idx, self._pending_threshold, self._pending_min_freq_mhz)
self._fft_cache[key] = result
img = self._scale_for_display(result, ch_idx)
else:
img = result
@@ -1415,7 +1563,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)
@@ -1467,7 +1616,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=(
@@ -1480,9 +1630,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)
@@ -1509,7 +1657,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=(
@@ -1522,9 +1671,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) "
@@ -1555,9 +1702,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.
# ------------------------------------------------------------------
# Export menu: batch image export
@@ -1741,7 +1979,6 @@ class SrasViewerWindow(QMainWindow):
def _on_fft_options(self):
dlg = FftOptionsDialog(
self,
current_backend=compute.get_fft_backend(),
current_pad_factor=self._fft_pad_factor,
samples_per_frame=self._sras.samples_per_frame if self._sras else None,
sample_rate_hz=self._sras.sample_rate_hz if self._sras else None,
@@ -1749,9 +1986,7 @@ class SrasViewerWindow(QMainWindow):
)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
compute.set_fft_backend(dlg.get_backend())
self._fft_pad_factor = dlg.get_pad_factor()
self._settings.setValue("fft/backend", compute.get_fft_backend())
self._settings.setValue("fft/pad_factor", self._fft_pad_factor)
# Pad factor no longer gates the display: it only affects a future
# live compute for an angle with nothing cached yet, or an explicit