Implement row-averaged FFT feature for same-row SNR cleanup
Add row-averaged FFT feature with configurable window size (row_avg_n parameter) for improved signal-to-noise ratio on noisy scans. Includes: - Gaussian-weighted same-row neighbor averaging (never crosses rows) - Masked/renormalized convolution handling for edge cases and masked samples - Cache format v2 with row_avg_n tracking to prevent silent cache mismatches - GUI dialog option for row-average window configuration - Comprehensive tests validating kernel properties, background subtraction invariance, and cache dispatch Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,5 +20,7 @@ faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
||||
|
||||
from .canvases import ImageCanvas, RoiQuad, WaveformCanvas # noqa: E402,F401
|
||||
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
|
||||
from .dialogs import FftOptionsDialog, ManualAlignmentDialog # noqa: E402,F401
|
||||
from .dialogs import ( # noqa: E402,F401
|
||||
FftOptionsDialog, ManualAlignmentDialog, RowAverageFftOptionsDialog,
|
||||
)
|
||||
from .main_window import SrasViewerWindow, main # noqa: E402,F401
|
||||
|
||||
@@ -152,6 +152,105 @@ class FftOptionsDialog(QDialog):
|
||||
return max(1, self._spin_pad.value())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row-Averaged FFT Options dialog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RowAverageFftOptionsDialog(QDialog):
|
||||
"""Configure the same-row, distance-weighted neighbor averaging applied
|
||||
to each pixel's CH1 waveform before 'Batch Compute Row-Averaged FFT and
|
||||
Store' re-runs the FFT peak search — a same-row SNR cleanup pass, never
|
||||
mixing across rows/Y (see sras_compute._row_average_waveforms).
|
||||
|
||||
Unlike the plain FFT batch action (which stores unmasked and defers
|
||||
masking to display time), the DC threshold here is required up front:
|
||||
it decides which same-row neighbors are eligible to contribute to a
|
||||
pixel's average, so it can't be deferred.
|
||||
|
||||
Changes take effect only when the user clicks Apply. Cancel discards
|
||||
all pending edits.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, *,
|
||||
current_n: int,
|
||||
current_threshold_mv: float,
|
||||
pixel_x_mm: float | None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Row-Averaged FFT Options")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(380)
|
||||
|
||||
self._pixel_x_mm = pixel_x_mm
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# ---- Neighbor window ---------------------------------------------
|
||||
grp_window = QGroupBox("Same-Row Neighbor Window")
|
||||
wl = QVBoxLayout(grp_window)
|
||||
|
||||
n_row = QHBoxLayout()
|
||||
n_row.addWidget(QLabel("Neighbor half-width (n):"))
|
||||
self._spin_n = QSpinBox()
|
||||
self._spin_n.setRange(1, 50)
|
||||
self._spin_n.setValue(max(1, current_n))
|
||||
self._spin_n.setToolTip(
|
||||
"Each pixel's CH1 waveform is averaged with up to n same-row\n"
|
||||
"neighbors on each side, distance-weighted (Gaussian) and\n"
|
||||
"counting only neighbors that already pass the DC threshold\n"
|
||||
"below. Never mixes across rows/Y.")
|
||||
self._spin_n.valueChanged.connect(self._update_info)
|
||||
n_row.addWidget(self._spin_n)
|
||||
wl.addLayout(n_row)
|
||||
|
||||
self._lbl_width = QLabel()
|
||||
self._lbl_width.setStyleSheet(_CSS_HINT)
|
||||
wl.addWidget(self._lbl_width)
|
||||
|
||||
layout.addWidget(grp_window)
|
||||
|
||||
# ---- DC threshold ------------------------------------------------
|
||||
grp_thr = QGroupBox("Neighbor Validity")
|
||||
tl = QVBoxLayout(grp_thr)
|
||||
thr_row = QHBoxLayout()
|
||||
thr_row.addWidget(QLabel("DC threshold:"))
|
||||
self._spin_threshold = _make_dspin(-500.0, 500.0, 3, suffix=" mV",
|
||||
value=current_threshold_mv, step=0.025)
|
||||
self._spin_threshold.setToolTip(
|
||||
"A same-row neighbor only contributes to a pixel's average if\n"
|
||||
"its own CH4 signal is at or above this threshold -- the same\n"
|
||||
"test used for RF mask display. A pixel below threshold stays\n"
|
||||
"masked, exactly as today; it is never rescued by its neighbors.")
|
||||
thr_row.addWidget(self._spin_threshold)
|
||||
tl.addLayout(thr_row)
|
||||
layout.addWidget(grp_thr)
|
||||
|
||||
# ---- Buttons -----------------------------------------------------
|
||||
buttons = QDialogButtonBox()
|
||||
buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole
|
||||
).clicked.connect(self.accept)
|
||||
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
|
||||
).clicked.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self._update_info()
|
||||
|
||||
def _update_info(self):
|
||||
n = self._spin_n.value()
|
||||
if self._pixel_x_mm is None:
|
||||
self._lbl_width.setText("Load a file to preview the window's physical width.")
|
||||
return
|
||||
width_um = 2 * n * self._pixel_x_mm * 1e3
|
||||
self._lbl_width.setText(
|
||||
f"Window: ±{n} px = {width_um:.2f} µm full width "
|
||||
f"(pixel pitch {self._pixel_x_mm * 1e3:.3g} µm)")
|
||||
|
||||
def get_half_width(self) -> int:
|
||||
return self._spin_n.value()
|
||||
|
||||
def get_threshold_mv(self) -> float:
|
||||
return self._spin_threshold.value()
|
||||
|
||||
|
||||
|
||||
class ManualAlignmentDialog(QDialog):
|
||||
"""Non-modal manual angle-alignment editor (Fusion -> Manual Alignment...).
|
||||
|
||||
@@ -33,7 +33,7 @@ from .common import (
|
||||
_CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W,
|
||||
_RIGHT_PANEL_W, Jobs, _form, _group, _make_dspin, _scroll_panel, _wrap_label,
|
||||
)
|
||||
from .dialogs import FftOptionsDialog, ManualAlignmentDialog
|
||||
from .dialogs import FftOptionsDialog, ManualAlignmentDialog, RowAverageFftOptionsDialog
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main window
|
||||
@@ -73,6 +73,11 @@ class SrasViewerWindow(QMainWindow):
|
||||
except (TypeError, ValueError):
|
||||
pad = 1
|
||||
self._fft_pad_factor: int = max(1, min(256, pad)) # 1 = no padding
|
||||
try:
|
||||
row_avg_n = int(self._settings.value("fft/row_avg_n", 3))
|
||||
except (TypeError, ValueError):
|
||||
row_avg_n = 3
|
||||
self._pending_row_avg_n: int = max(1, min(50, row_avg_n))
|
||||
|
||||
# Convert menu: batch DC/FFT compute-and-store (v6 -> v7)
|
||||
self._batch_errors: list[str] = []
|
||||
@@ -459,6 +464,17 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._batch_fft_act.triggered.connect(lambda: self._on_batch_compute("fft"))
|
||||
convert_menu.addAction(self._batch_fft_act)
|
||||
|
||||
self._batch_fft_rowavg_act = QAction(
|
||||
"Batch Compute Row-A&veraged FFT and Store…", self)
|
||||
self._batch_fft_rowavg_act.setStatusTip(
|
||||
"Select .sras files and compute+store a same-row, distance-weighted "
|
||||
"smoothed FFT peak-frequency image for every angle — improves SNR "
|
||||
"on noisy regions. DC images and raw waveform data are never "
|
||||
"touched; the raw (unsmoothed) FFT is always a recompute away. "
|
||||
"Converts v6 files to v7 in place.")
|
||||
self._batch_fft_rowavg_act.triggered.connect(self._on_batch_compute_row_avg)
|
||||
convert_menu.addAction(self._batch_fft_rowavg_act)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Drag-and-drop
|
||||
# ------------------------------------------------------------------
|
||||
@@ -598,9 +614,12 @@ class SrasViewerWindow(QMainWindow):
|
||||
n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None)
|
||||
if n_dc or n_fft:
|
||||
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
|
||||
avg_note = (f", row-averaged n={s.precomputed_row_avg_n}"
|
||||
if s.precomputed_row_avg_n 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"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''}"
|
||||
f"{avg_note if n_fft else ''} "
|
||||
"— display is instant for cached angles")
|
||||
elif s.version == 7:
|
||||
notes.append("v7 format: no cache blocks stored yet")
|
||||
@@ -640,6 +659,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
can_batch = not self._job_running(Jobs.BATCH)
|
||||
self._batch_dc_act.setEnabled(can_batch)
|
||||
self._batch_fft_act.setEnabled(can_batch)
|
||||
self._batch_fft_rowavg_act.setEnabled(can_batch)
|
||||
|
||||
self._alignment_act.setEnabled(
|
||||
has_file and s.n_angles > 1 and not self._job_running(Jobs.ALIGN))
|
||||
@@ -1178,10 +1198,53 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
self._batch_dc_act.setEnabled(False)
|
||||
self._batch_fft_act.setEnabled(False)
|
||||
self._batch_fft_rowavg_act.setEnabled(False)
|
||||
self._show_progress(
|
||||
Jobs.BATCH, f"Batch computing {label} for {len(paths)} file(s)…",
|
||||
maximum=100)
|
||||
|
||||
def _on_batch_compute_row_avg(self):
|
||||
if self._job_running(Jobs.BATCH):
|
||||
return
|
||||
dlg = RowAverageFftOptionsDialog(
|
||||
self, current_n=self._pending_row_avg_n,
|
||||
current_threshold_mv=self.spin_threshold_mv.value(),
|
||||
pixel_x_mm=self._sras.pixel_x_mm if self._sras is not None else None)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
n, threshold_mv = dlg.get_half_width(), dlg.get_threshold_mv()
|
||||
self._pending_row_avg_n = n
|
||||
self._settings.setValue("fft/row_avg_n", n)
|
||||
|
||||
paths, _ = QFileDialog.getOpenFileNames(
|
||||
self, "Select .sras files to batch-compute Row-Averaged FFT", "",
|
||||
"SRAS files (*.sras);;All files (*)")
|
||||
if not paths:
|
||||
return
|
||||
|
||||
self._batch_errors = []
|
||||
worker = BatchCacheWorker(paths, "fft_rowavg", self.chk_bg_sub.isChecked(),
|
||||
dc_threshold_mv=threshold_mv, row_avg_n=n)
|
||||
started = self._run_worker(
|
||||
Jobs.BATCH, worker,
|
||||
connect=(
|
||||
("progress", lambda pct: self._set_progress(Jobs.BATCH, pct)),
|
||||
("file_done", self._on_batch_file_done),
|
||||
("finished", lambda p=paths: self._on_batch_finished(p)),
|
||||
),
|
||||
on_done=self._after_batch,
|
||||
)
|
||||
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._show_progress(
|
||||
Jobs.BATCH,
|
||||
f"Batch computing row-averaged FFT (n={n}, threshold={threshold_mv:.1f} mV) "
|
||||
f"for {len(paths)} file(s)…", maximum=100)
|
||||
|
||||
def _on_batch_file_done(self, path: str, err: str):
|
||||
if err:
|
||||
self._batch_errors.append(f"{Path(path).name} — {err}")
|
||||
@@ -1209,6 +1272,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
def _after_batch(self):
|
||||
self._batch_dc_act.setEnabled(True)
|
||||
self._batch_fft_act.setEnabled(True)
|
||||
self._batch_fft_rowavg_act.setEnabled(True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fusion: angle alignment
|
||||
|
||||
Reference in New Issue
Block a user