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