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:
+181
-27
@@ -420,6 +420,118 @@ def dc_image_mv(sras: SrasFile, angle_idx: int, ch_idx: int,
|
||||
*sras.cal(ch_idx))
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row-averaged FFT: same-row, distance-weighted CH1 waveform smoothing
|
||||
#
|
||||
# Averages a pixel's CH1 waveform with its 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, but the X pitch within
|
||||
# one row is a single file-wide constant (SrasFile.pixel_x_mm), so a
|
||||
# Gaussian in pixel-index distance along a row and one in true physical mm
|
||||
# distance are the same function up to that constant scale factor — the
|
||||
# kernel itself needs no pitch, only the GUI's physical-width hint label
|
||||
# does. Rationale and the masked-average/background-subtraction proofs:
|
||||
# docs/design.md ("Row-averaged FFT").
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ROW_AVG_SIGMA_FRAC = 0.5 # sigma = n * this; edge weight (distance n) is
|
||||
# exp(-1/(2*frac**2)) ~= 0.135 of the center tap
|
||||
|
||||
|
||||
def _row_average_weights(row_avg_n: int) -> np.ndarray:
|
||||
"""(2n+1,) float32 Gaussian weights for distance-weighted row averaging,
|
||||
symmetric around the center tap. Not pre-normalized to sum to 1 —
|
||||
_row_average_waveforms renormalizes per output pixel by the actual sum
|
||||
of included, valid, in-window neighbor weights, not a fixed total."""
|
||||
n = max(0, int(row_avg_n))
|
||||
if n == 0:
|
||||
return np.ones(1, dtype=np.float32)
|
||||
d = np.arange(-n, n + 1, dtype=np.float64)
|
||||
sigma = n * _ROW_AVG_SIGMA_FRAC
|
||||
return np.exp(-0.5 * (d / sigma) ** 2).astype(np.float32)
|
||||
|
||||
|
||||
def _row_average_waveforms(masked_waves: np.ndarray, valid: np.ndarray,
|
||||
weights: np.ndarray) -> np.ndarray:
|
||||
"""Distance-weighted mean of each row position's CH1 waveform with its
|
||||
same-row neighbors, counting only neighbors where *valid* is True.
|
||||
|
||||
masked_waves: (n_frames, spf) float32 — CH1 samples at valid[f]
|
||||
positions; must already be 0.0 elsewhere (the caller must never
|
||||
have read the raw memmap at an invalid position).
|
||||
valid: (n_frames,) bool.
|
||||
weights: (2n+1,) float32 from _row_average_weights.
|
||||
|
||||
Returns (n_frames, spf) float32, meaningful only where valid is True
|
||||
(the caller compacts by that same mask right after, matching
|
||||
compute_rf_image's existing masked-compaction contract).
|
||||
|
||||
Two 1-D correlations along the frame axis — the numerator against the
|
||||
raw masked-zero waveforms, the denominator against the validity mask
|
||||
itself — so a masked neighbor contributes *zero weight* rather than a
|
||||
zero-amplitude sample at full weight, and a window truncated at a row's
|
||||
edge renormalizes correctly with no separate edge case: mode="constant",
|
||||
cval=0.0 pads both convolutions with zero beyond the row's own ends.
|
||||
"""
|
||||
num = scipy_ndimage.correlate1d(masked_waves, weights, axis=0,
|
||||
mode="constant", cval=0.0)
|
||||
den = scipy_ndimage.correlate1d(valid.astype(np.float32), weights, axis=0,
|
||||
mode="constant", cval=0.0)
|
||||
den_safe = np.where(valid, den, np.float32(1.0))
|
||||
return num / den_safe[:, None]
|
||||
|
||||
|
||||
def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||
dc_threshold_mv: float | None,
|
||||
apply_bg_sub: bool = True,
|
||||
n_fft: int | None = None,
|
||||
dc4_mv: np.ndarray | None = None,
|
||||
row_avg_n: int = 0,
|
||||
allow_dc_recompute: bool = True) -> np.ndarray | None:
|
||||
"""The precomputed-cache fast path for compute_rf_image: a ready-to-
|
||||
display peak-frequency image if this file already has one matching
|
||||
every setting the caller cares about, else None (caller must run a
|
||||
real FFT).
|
||||
|
||||
A stored image stands in only when *all* hold: this angle actually has
|
||||
a stored image (v5 PREC or v7 CACH); no custom zero-padding is
|
||||
requested (n_fft is None) — the store is always natural-resolution;
|
||||
the stored bg-sub flag matches what the caller wants; and
|
||||
sras.precomputed_row_avg_n == row_avg_n exactly (0 == "raw") — this
|
||||
last check is what stops a raw request from ever being silently served
|
||||
a row-averaged image, or vice versa, or a request at one window size
|
||||
being served a cache stored at a different one.
|
||||
|
||||
If *allow_dc_recompute* is False and no DC4 image is already cached or
|
||||
supplied via *dc4_mv*, applying the mask would mean reading a whole
|
||||
channel on the caller's behalf; this returns None instead so a caller
|
||||
that wants to stay off the I/O path (e.g. a GUI thread) can choose to
|
||||
fall through to a real compute rather than block.
|
||||
"""
|
||||
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
||||
if (cached_freq is None
|
||||
or n_fft is not None
|
||||
or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None)
|
||||
or sras.precomputed_row_avg_n != row_avg_n):
|
||||
return None
|
||||
freq_img = cached_freq.copy()
|
||||
if dc_threshold_mv is not None:
|
||||
# DC4 mask, in priority order: already-cached DC block, caller-
|
||||
# supplied image, or a fresh (cheap — no FFT) recompute.
|
||||
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
||||
if dc4_img is None:
|
||||
if dc4_mv is not None:
|
||||
dc4_img = dc4_mv
|
||||
elif allow_dc_recompute:
|
||||
dc4_img = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX),
|
||||
*sras.cal(CH4_IDX))
|
||||
else:
|
||||
return None
|
||||
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
||||
return freq_img
|
||||
|
||||
|
||||
def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
dc_threshold_mv: float | None,
|
||||
apply_bg_sub: bool = True,
|
||||
@@ -428,7 +540,8 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
max_workers: int | None = None,
|
||||
budget: int | None = None,
|
||||
should_stop=None,
|
||||
exact: bool = False) -> np.ndarray:
|
||||
exact: bool = False,
|
||||
row_avg_n: int = 0) -> np.ndarray:
|
||||
"""FFT of each CH1 waveform; pixel = peak frequency in MHz.
|
||||
|
||||
Pixels where CH4_dc < dc_threshold_mv are set to 0 — and the FFT is
|
||||
@@ -453,28 +566,23 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
tests, audits, and the SRAS_FFT_EXACT=1 escape hatch, and is slow and
|
||||
memory-hungry at high pad.
|
||||
|
||||
*row_avg_n* > 0 averages each pixel's CH1 waveform with its up-to-n
|
||||
same-row neighbors (distance-weighted, valid-neighbors-only per the
|
||||
same dc_threshold_mv mask) before the FFT runs — see
|
||||
_row_average_waveforms. 0 (default) is the raw, unaveraged behavior.
|
||||
|
||||
Fast path: if the file has a precomputed peak-frequency image for this
|
||||
angle (v5 PREC or v7 CACH), zero-padding is off, and the bg-sub flag
|
||||
matches, the stored image is used directly — no FFT is run.
|
||||
angle (v5 PREC or v7 CACH) matching every one of the caller's settings
|
||||
— including row_avg_n exactly — the stored image is used directly, no
|
||||
FFT is run. See cached_rf_image.
|
||||
"""
|
||||
n_rows, n_frames = sras.image_shape(angle_idx)
|
||||
data = sras.data[angle_idx]
|
||||
|
||||
# ---- Fast path: precomputed image (v5 PREC or v7 CACH) ----------------
|
||||
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
||||
if (cached_freq is not None
|
||||
and n_fft is None # no custom zero-padding
|
||||
and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None)):
|
||||
freq_img = cached_freq.copy()
|
||||
if dc_threshold_mv is not None:
|
||||
# DC4 mask, in priority order: already-cached DC block, caller-
|
||||
# supplied image, or a fresh (cheap — no FFT) recompute.
|
||||
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
||||
if dc4_img is None:
|
||||
dc4_img = dc4_mv if dc4_mv is not None else adc_to_mv(
|
||||
compute_dc_image(sras, angle_idx, CH4_IDX), *sras.cal(CH4_IDX))
|
||||
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
||||
return freq_img
|
||||
fast = cached_rf_image(sras, angle_idx, dc_threshold_mv, apply_bg_sub=apply_bg_sub,
|
||||
n_fft=n_fft, dc4_mv=dc4_mv, row_avg_n=row_avg_n)
|
||||
if fast is not None:
|
||||
return fast
|
||||
|
||||
# ---- Chunked FFT path --------------------------------------------------
|
||||
exact = exact or _FFT_EXACT_ENV
|
||||
@@ -484,11 +592,18 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
img = np.zeros((n_rows, n_frames), dtype=np.float32)
|
||||
background = sras.background if (apply_bg_sub and sras.background is not None) else None
|
||||
cal4 = sras.cal(CH4_IDX)
|
||||
row_avg_weights = _row_average_weights(row_avg_n) if row_avg_n > 0 else None
|
||||
|
||||
zp = (_zoom_plan(spf, n_fft)
|
||||
if not exact and n_fft is not None and n_fft >= _ZOOM_MIN_PAD * spf
|
||||
else None)
|
||||
total = _TOTAL_BYTES_BUDGET if budget is None else max(1, budget)
|
||||
if row_avg_n > 0:
|
||||
# One extra same-sized transient buffer (the pre-averaging full-row
|
||||
# scratch array) is live per in-flight row; halve the budget so
|
||||
# _plan_fft_rows/the exact-path sizing accounts for it rather than
|
||||
# relying on _plan_fft_rows's existing 2x slack to happen to cover it.
|
||||
total = max(1, total // 2)
|
||||
cap = max_workers if max_workers is not None else _MAX_WORKERS
|
||||
if exact:
|
||||
# The reference path materialises the full padded spectrum, so rows
|
||||
@@ -523,13 +638,31 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
def read_row(i: int):
|
||||
if should_stop is not None and should_stop():
|
||||
return
|
||||
# Index the raw memmap slice with the boolean mask *before*
|
||||
# converting dtype — this is a lazy view until touched, so only
|
||||
# the selected elements are actually read from disk; masked-out
|
||||
# pixels' pages are never paged in at all.
|
||||
raw = data[r0 + i, CH1_IDX]
|
||||
dst = waves[offs[i]:offs[i + 1]]
|
||||
dst[:] = raw[valid[i]] if valid is not None else raw
|
||||
row_valid = valid[i] if valid is not None else None
|
||||
if row_avg_weights is not None:
|
||||
v = row_valid if row_valid is not None else np.ones(n_frames, dtype=bool)
|
||||
# A plain cast + broadcast multiply, not a boolean-indexed
|
||||
# scatter into a zeroed buffer: numpy's fancy indexing holds
|
||||
# the GIL for its whole duration (measured zero speedup
|
||||
# across threads, and net negative once threads outnumber
|
||||
# physical cores), while a cast and a multiply are ordinary
|
||||
# ufuncs that release it — this is what lets read_row actually
|
||||
# parallelize across the pool instead of serializing on the
|
||||
# scatter/gather. Trade-off: every sample in the row is read,
|
||||
# valid or not (row averaging needs broad neighbor context
|
||||
# regardless, unlike the plain path below, which still skips
|
||||
# masked-out pixels entirely).
|
||||
full = raw.astype(np.float32) * v[:, None]
|
||||
avg = _row_average_waveforms(full, v, row_avg_weights)
|
||||
dst[:] = avg[v] if row_valid is not None else avg
|
||||
else:
|
||||
# Index the raw memmap slice with the boolean mask *before*
|
||||
# converting dtype — this is a lazy view until touched, so
|
||||
# only the selected elements are actually read from disk;
|
||||
# masked-out pixels' pages are never paged in at all.
|
||||
dst[:] = raw[row_valid] if row_valid is not None else raw
|
||||
if background is not None:
|
||||
dst -= background # background is 1-D (spf,)
|
||||
|
||||
@@ -589,7 +722,9 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
fft_backend: str = "scipy", max_workers: int = 0) -> str:
|
||||
fft_backend: str = "scipy", max_workers: int = 0,
|
||||
dc_threshold_mv: float | None = None,
|
||||
row_avg_n: int = 0) -> str:
|
||||
"""Compute and store DC or FFT images for every angle of one file,
|
||||
converting v6 → v7 in place. Returns "" on success or an error message.
|
||||
|
||||
@@ -597,14 +732,21 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
FFT backend and worker cap are passed explicitly because module globals
|
||||
do not survive a spawn.
|
||||
|
||||
mode is "dc", "fft" (raw per-pixel FFT, natural-resolution, unmasked —
|
||||
masking applied at display time), or "fft_rowavg" (same-row,
|
||||
distance-weighted CH1 averaging before the FFT — see compute_rf_image's
|
||||
row_avg_n). fft_rowavg needs *dc_threshold_mv* up front, unlike plain
|
||||
"fft": neighbor validity is baked into the stored numbers, so it can't
|
||||
be deferred to display time the way plain masking can.
|
||||
|
||||
The stored FFT cache is always natural-resolution (pad 1): the v7 SFFT
|
||||
block records no pad factor, and padded views compute live fast enough
|
||||
(see _peak_bins_zoom) that caching them is not worth a format change.
|
||||
"""
|
||||
global _MAX_WORKERS
|
||||
try:
|
||||
if mode not in ("dc", "fft"):
|
||||
return f"unknown cache mode {mode!r} (expected 'dc' or 'fft')"
|
||||
if mode not in ("dc", "fft", "fft_rowavg"):
|
||||
return f"unknown cache mode {mode!r} (expected 'dc', 'fft', or 'fft_rowavg')"
|
||||
set_fft_backend(fft_backend)
|
||||
if max_workers:
|
||||
_MAX_WORKERS = max_workers
|
||||
@@ -628,7 +770,7 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
budget=angle_budget), *sras.cal(CH4_IDX)),
|
||||
range(n), n_workers)
|
||||
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
|
||||
else:
|
||||
elif mode == "fft":
|
||||
effective_bg = apply_bg_sub and sras.background is not None
|
||||
# dc_threshold_mv=None: store unmasked images and mask at display
|
||||
# time (same convention as v5's PREC block). Skipping the mask
|
||||
@@ -639,6 +781,18 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
apply_bg_sub=effective_bg)
|
||||
for a in range(n)]
|
||||
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg)
|
||||
else: # "fft_rowavg"
|
||||
if row_avg_n <= 0:
|
||||
return "row_avg_n must be a positive neighbor half-width for fft_rowavg mode"
|
||||
if dc_threshold_mv is None:
|
||||
return ("fft_rowavg mode requires a DC threshold "
|
||||
"(neighbor validity depends on it)")
|
||||
effective_bg = apply_bg_sub and sras.background is not None
|
||||
freq = [compute_rf_image(sras, a, dc_threshold_mv=dc_threshold_mv,
|
||||
apply_bg_sub=effective_bg, row_avg_n=row_avg_n)
|
||||
for a in range(n)]
|
||||
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
|
||||
new_row_avg_n=row_avg_n)
|
||||
return ""
|
||||
except Exception as exc:
|
||||
return str(exc)
|
||||
|
||||
Reference in New Issue
Block a user