f40c965b74
Alignment was two disconnected menu actions. `Angle Alignment` ran the
registration with ref_angle_idx hard-coded to 0, no exposed parameters and
no way to retry; `Manual Alignment...` was a separate dialog that
deliberately refused to inherit the automatic result, so a bad fit meant
starting over by hand. Neither told you whether the alignment was any
good, and neither produced anything durable beyond an in-memory result.
Fusion -> Alignment Wizard... now covers all of it in three steps:
1. Correlate. Reference angle, DC threshold, source, rotation seed and
sign, search window, coarse step and fine grid are all on the page,
and Run/Re-run is repeatable. Angles start pre-rotated from the
stage angles in the file, so the page is informative before any
correlation runs. The verdict is a picture: every angle's DC mask
reprojected onto the shared canvas and summed, coloured by how many
angles cover each pixel, so a good alignment reads as one saturated
plateau and a bad one as a fringe of low-count halos. A per-angle
fit table flags angles that did not register or that disagree with
their stage angle by more than a degree.
2. Crop. An axis-aligned rectangle on that canvas, with numeric
canvas-pixel boxes synced both ways and a live size estimate.
"Fit to full overlap" uses a largest-rectangle sweep rather than a
bounding box: the overlap region of several rotated scans is roughly
a disc, whose bounding box has corners no angle covers.
3. Save. Writes the aligned, cropped stack to a new .sras.
Because the wizard replaces both actions it absorbs the old dialog's
by-eye nudge editor — without it, a scan the search cannot fit would
have no fallback at all. ManualAlignmentDialog is therefore deleted
rather than left orphaned, and its tests move to the wizard.
Two bugs found while driving it end to end and fixed here: QSpinBox
setRange clamps and emits valueChanged, which committed a 1x1 crop
before the default preset could run; and the mm round trip returns an
exact pixel boundary as 11.000000000000002, so a bare ceil() added a
spurious column on every rectangle edit.
Verified: 103 pass, the 6 test_stored_cache failures are pre-existing on
main, and tools/check_equivalence.py is byte-identical to main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
9.0 KiB
Python
238 lines
9.0 KiB
Python
"""FFT option dialogs.
|
||
|
||
Angle alignment used to live here too, as ManualAlignmentDialog; it is now the
|
||
alignment wizard's first page (see align_wizard.py), which needed the same
|
||
mask-overlay editor plus the crop and export steps.
|
||
"""
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QButtonGroup, QDialog, QDialogButtonBox, QGroupBox, QHBoxLayout, QLabel,
|
||
QRadioButton, QSpinBox, QVBoxLayout,
|
||
)
|
||
|
||
from sras_compute import PYFFTW_AVAILABLE
|
||
|
||
from .common import _CSS_HINT, _make_dspin
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FFT Options dialog
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class FftOptionsDialog(QDialog):
|
||
"""Configure FFT backend and zero-padding.
|
||
|
||
Changes take effect only when the user clicks Apply. Cancel discards
|
||
all pending edits. The live 'frequency resolution' label updates as
|
||
the user adjusts the pad factor so they can see the trade-off before
|
||
committing.
|
||
"""
|
||
|
||
def __init__(self, parent=None, *,
|
||
current_backend: str,
|
||
current_pad_factor: int,
|
||
samples_per_frame: int | None,
|
||
sample_rate_hz: float | None,
|
||
grating_um: float):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("FFT Options")
|
||
self.setModal(True)
|
||
self.setMinimumWidth(380)
|
||
|
||
self._samples_per_frame = samples_per_frame
|
||
self._sample_rate_hz = sample_rate_hz
|
||
self._grating_um = grating_um
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
# ---- Backend ---------------------------------------------------
|
||
grp_backend = QGroupBox("FFT Backend")
|
||
bl = QVBoxLayout(grp_backend)
|
||
|
||
self._btn_scipy = QRadioButton("SciPy FFT (pocketfft) (always available)")
|
||
self._btn_pyfftw = QRadioButton(
|
||
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
|
||
else "pyFFTW (not installed — run: pip install pyfftw)")
|
||
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
|
||
|
||
self._backend_group = QButtonGroup(self)
|
||
self._backend_group.addButton(self._btn_scipy, id=0)
|
||
self._backend_group.addButton(self._btn_pyfftw, id=1)
|
||
|
||
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
|
||
self._btn_pyfftw.setChecked(True)
|
||
else:
|
||
self._btn_scipy.setChecked(True)
|
||
|
||
bl.addWidget(self._btn_scipy)
|
||
bl.addWidget(self._btn_pyfftw)
|
||
layout.addWidget(grp_backend)
|
||
|
||
# ---- Zero-padding ----------------------------------------------
|
||
grp_zp = QGroupBox("Zero-Padding")
|
||
zl = QVBoxLayout(grp_zp)
|
||
|
||
pad_row = QHBoxLayout()
|
||
pad_row.addWidget(QLabel("Pad factor:"))
|
||
self._spin_pad = QSpinBox()
|
||
self._spin_pad.setRange(1, 256)
|
||
self._spin_pad.setValue(max(1, current_pad_factor))
|
||
self._spin_pad.setToolTip(
|
||
"Multiply the waveform length by this factor via zero-padding\n"
|
||
"before computing the FFT.\n"
|
||
"1 = no padding (natural length).\n"
|
||
"Powers of 2 (2, 4, 8 …) give the best performance."
|
||
)
|
||
self._spin_pad.valueChanged.connect(self._update_info)
|
||
pad_row.addWidget(self._spin_pad)
|
||
zl.addLayout(pad_row)
|
||
|
||
self._lbl_nfft = QLabel()
|
||
self._lbl_freq_res = QLabel()
|
||
self._lbl_vel_res = QLabel()
|
||
for lbl in (self._lbl_nfft, self._lbl_freq_res, self._lbl_vel_res):
|
||
lbl.setStyleSheet(_CSS_HINT)
|
||
zl.addWidget(lbl)
|
||
|
||
layout.addWidget(grp_zp)
|
||
|
||
# ---- 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):
|
||
spf = self._samples_per_frame
|
||
sr = self._sample_rate_hz
|
||
pad = self._spin_pad.value()
|
||
|
||
if spf is None or sr is None:
|
||
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
|
||
self._lbl_freq_res.setText("")
|
||
self._lbl_vel_res.setText("")
|
||
return
|
||
|
||
n_fft = spf * pad
|
||
freq_res_hz = sr / n_fft
|
||
freq_res_mhz = freq_res_hz / 1e6
|
||
# v (m/s) = freq (MHz) × grating (µm)
|
||
vel_res_ms = freq_res_mhz * self._grating_um
|
||
|
||
self._lbl_nfft.setText(f"FFT points: {spf} × {pad} = {n_fft:,}")
|
||
self._lbl_freq_res.setText(
|
||
f"Frequency bin: {freq_res_mhz:.4f} MHz ({freq_res_hz / 1e3:.2f} kHz)")
|
||
self._lbl_vel_res.setText(
|
||
f"Velocity bin: {vel_res_ms:.3f} m/s "
|
||
f"(at grating = {self._grating_um:.2f} µm)")
|
||
|
||
def get_backend(self) -> str:
|
||
return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "scipy"
|
||
|
||
def get_pad_factor(self) -> int:
|
||
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()
|
||
|