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:
@@ -72,6 +72,68 @@ one thread under the pool so the refinement gemm cannot oversubscribe.
|
||||
`compute_rf_image(exact=True)` (or `SRAS_FFT_EXACT=1`) keeps the reference
|
||||
full-padded path for audits.
|
||||
|
||||
## Row-averaged FFT: same-row, distance-weighted SNR cleanup (`sras_compute.py`)
|
||||
|
||||
`compute_rf_image`'s `row_avg_n` parameter averages each pixel's CH1
|
||||
waveform with its up-to-n same-row neighbors before the FFT peak search, to
|
||||
improve SNR on noisy scans. Never crosses rows: pixel pitch is strongly
|
||||
anisotropic and varies by scan (5 µm × 50 µm on a typical scan, but as
|
||||
stretched as 5 µm × 1 mm on others), so a physically meaningful "neighbor"
|
||||
set can't be a fixed-shape 2-D window — but the X pitch *within one row* is
|
||||
a single file-wide constant (`SrasFile.pixel_x_mm`), so restricting to the
|
||||
row axis sidesteps the anisotropy question entirely rather than solving it
|
||||
with an elliptical or physically-scaled 2-D kernel.
|
||||
|
||||
`_row_average_weights` is a Gaussian in pixel-index distance, not physical
|
||||
mm distance — deliberately: within one row those are the same function up
|
||||
to a fixed scale factor (`pixel_x_mm` is constant along a row), so the
|
||||
kernel itself needs no pitch at all. `pixel_x_mm` is used for real exactly
|
||||
once, in the GUI's options dialog, to show the window's physical width —
|
||||
not in the kernel math, where it would only ever cancel out.
|
||||
|
||||
`_row_average_waveforms` is a masked/renormalized convolution (two
|
||||
`correlate1d` calls, numerator and denominator, divided) rather than a
|
||||
single fixed-normalized convolution, because a masked neighbor must
|
||||
contribute *zero weight*, not a zero-amplitude sample at full weight — the
|
||||
latter would bias every average near a masked run or a row's own edge
|
||||
toward zero. The same two-correlation trick handles row-edge truncation for
|
||||
free: `mode="constant", cval=0.0` zero-pads both the numerator and the
|
||||
denominator beyond a row's own ends, so the output renormalizes by whatever
|
||||
weight sum actually landed inside the row, no separate edge case.
|
||||
|
||||
Background subtraction stays exactly where it already was (subtracted once
|
||||
from the fully-assembled `waves` buffer) rather than being threaded into the
|
||||
per-neighbor gather. This is exact, not an approximation: because
|
||||
`_row_average_waveforms`'s denominator is always the *actual* sum of
|
||||
included, valid weights (never a fixed total), `Σwᵢ·(rawᵢ−bg) / Σwᵢ`
|
||||
distributes to `avg − bg·(Σwᵢ/Σwᵢ) = avg − bg` regardless of which or how
|
||||
many neighbors were included — subtracting background from the averaged
|
||||
waveform is identical to subtracting it from every neighbor first, for any
|
||||
window, at any row edge, with any number of masked-out neighbors.
|
||||
|
||||
No cross-row halo is needed: `compute_rf_image`'s chunk loop already splits
|
||||
on rows only, and `read_row` already reads one row's complete
|
||||
`(n_frames, spf)` slice at a time — averaging happens entirely inside that
|
||||
one row's own frame axis, so a chunk boundary (which falls between rows)
|
||||
can never truncate a window. Only a row's own start/end can, and that's the
|
||||
same edge case the masked convolution already handles.
|
||||
|
||||
The averaging step doubles the live per-row scratch memory (a full-width
|
||||
`(n_frames, spf)` buffer on top of the existing compacted `waves` buffer),
|
||||
so `compute_rf_image` halves its byte budget when `row_avg_n > 0` before
|
||||
`_plan_fft_rows`/the exact-path sizing runs — see "Memory budget and row
|
||||
chunking" above. On the largest real scans `_plan_chunks` is already
|
||||
clamped to its floor of one row regardless, so this costs no concurrency
|
||||
where it matters most; it mainly protects moderate-sized scans from an
|
||||
unexpected regression.
|
||||
|
||||
Persistence: `cached_rf_image` (the extracted fast-path check) requires
|
||||
`sras.precomputed_row_avg_n == row_avg_n` exactly, so a raw request can
|
||||
never be silently served a row-averaged cache or vice versa, and a request
|
||||
at one window size can never be served a cache at another — see
|
||||
`scan_format.md`'s Cache Tail / CACH tail version history sections for the
|
||||
on-disk `row_avg_n` field this depends on.
|
||||
|
||||
## Angle alignment coordinate frames (`sras_compute.py`)
|
||||
|
||||
Alignment puts every angle's images onto one shared, zero-padded pixel grid
|
||||
|
||||
Reference in New Issue
Block a user