Replace zoom FFT peak search with a budget-bounded PyFFTW direct transform

Drop the coarse+fine zoom refinement, the SciPy FFT backend, and the
exact= audit path in favor of a single always-on full-transform peak
search (_peak_bins). Block size is now derived from a per-thread memory
budget (_fft_block_for/SRAS_FFT_PLAN_BUDGET_MB) instead of a fixed
constant, so the existing block-parallel PyFFTW pool stays memory-safe
at high pad factors without the zoom algorithm's bookkeeping. Also
removes the now-unused threadpoolctl dependency and the FFT backend
selector from the UI.

Also includes a pre-existing min_freq_mhz peak-search floor (excludes
bins below a caller-supplied frequency from the argmax) that was
already implemented and tested in the working tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-08-10 14:14:38 -05:00
parent 191d1b8946
commit 1caf6373cb
13 changed files with 489 additions and 418 deletions
+118 -22
View File
@@ -30,8 +30,8 @@ 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 (
@@ -58,6 +58,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.
@@ -70,7 +71,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):
@@ -272,6 +272,28 @@ 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"
"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)."
)
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)
@@ -417,6 +439,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)
@@ -440,7 +478,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)
@@ -684,7 +722,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)
@@ -733,6 +773,14 @@ class SrasViewerWindow(QMainWindow):
if self._is_fft_mode():
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).
if self._is_fft_mode():
self._refresh_display()
def _on_autoscale_toggled(self, checked: bool):
manual = not checked
self.spin_vmin.setEnabled(manual and self._sras is not None)
@@ -740,6 +788,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)
@@ -1014,15 +1066,19 @@ 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 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)."""
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
@@ -1100,8 +1156,9 @@ class SrasViewerWindow(QMainWindow):
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.
against it is free, unlike bg-sub/pad/row-averaging — or the min
peak frequency floor — which are baked irreversibly into the stored
numbers.
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
@@ -1194,6 +1251,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
@@ -1214,8 +1289,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".
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:
display_img = display_img.astype(np.float32, copy=True)
display_img[~mask_valid] = np.nan
else:
mask_valid = None
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)
@@ -1239,6 +1333,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,
)
self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
@@ -1262,6 +1357,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(
@@ -1274,6 +1370,7 @@ class SrasViewerWindow(QMainWindow):
# need to re-read the CH4 channel from disk.
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
is_fft_mode=is_fft,
min_freq_mhz=self._pending_min_freq_mhz,
)
if not self._run_worker(
Jobs.COMPUTE, worker,
@@ -1302,9 +1399,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):
@@ -1315,7 +1413,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
@@ -1620,7 +1719,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,
@@ -1628,9 +1726,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