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:
@@ -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...).
|
||||
|
||||
Reference in New Issue
Block a user