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