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:
+63
-8
@@ -60,7 +60,11 @@ PREC_FLAG_BG_SUB = 0x01
|
||||
CACH_MAGIC = b"CACH"
|
||||
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
|
||||
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
|
||||
CACH_VERSION = 1
|
||||
CACH_VERSION = 2 # written on every fresh write
|
||||
CACH_VERSIONS_READABLE = (1, 2) # accepted on read — see
|
||||
# _read_sfft_block: a v1 tail
|
||||
# predates row-averaged FFT
|
||||
# caching and reads as row_avg_n=0
|
||||
CACH_FLAG_DC = 0x01
|
||||
CACH_FLAG_FFT = 0x02
|
||||
|
||||
@@ -69,9 +73,16 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored
|
||||
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
|
||||
|
||||
SFFT_MAGIC = b"SFFT"
|
||||
SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored
|
||||
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored (cach_version 1)
|
||||
SFFT_HDR_FMT = ">4sBHB" # + row_avg_n (cach_version 2)
|
||||
SFFT_HDR_SIZE_V1 = struct.calcsize(SFFT_HDR_FMT_V1)
|
||||
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
|
||||
SFFT_FLAG_BG_SUB = 0x01
|
||||
SFFT_FLAG_ROW_AVG = 0x02 # peak_freq_mhz came from same-row,
|
||||
# distance-weighted averaged CH1
|
||||
# waveforms, not raw per-pixel ones;
|
||||
# row_avg_n is the neighbor half-width
|
||||
# (pixels) used. Bits 2-7 reserved.
|
||||
|
||||
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
|
||||
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
|
||||
@@ -226,6 +237,7 @@ class SrasFile:
|
||||
self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles
|
||||
self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles
|
||||
self.precomputed_bg_sub: bool = False
|
||||
self.precomputed_row_avg_n: int = 0
|
||||
|
||||
def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
|
||||
"""A stored DC image (already in mV) for (angle, channel), or None."""
|
||||
@@ -517,6 +529,34 @@ class SrasFile:
|
||||
store[angle_idx] = _read_f32_image(f, shape)
|
||||
return flags
|
||||
|
||||
def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int] | None:
|
||||
"""Read the SFFT block header — its layout depends on cach_version,
|
||||
since v2 appended a trailing row_avg_n byte — then n_stored per-angle
|
||||
peak_freq_mhz entries (unchanged across versions).
|
||||
|
||||
Returns (flags, row_avg_n), or None if the block is malformed.
|
||||
row_avg_n is always 0 for a v1 tail, which predates row-averaged FFT
|
||||
caching entirely.
|
||||
"""
|
||||
hdr_fmt = SFFT_HDR_FMT_V1 if cach_version == 1 else SFFT_HDR_FMT
|
||||
raw = f.read(struct.calcsize(hdr_fmt))
|
||||
if len(raw) < struct.calcsize(hdr_fmt):
|
||||
return None
|
||||
if cach_version == 1:
|
||||
magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
|
||||
row_avg_n = 0
|
||||
else:
|
||||
magic, flags, n_stored, row_avg_n = struct.unpack(hdr_fmt, raw)
|
||||
if magic != SFFT_MAGIC:
|
||||
return None
|
||||
for _ in range(n_stored):
|
||||
(angle_idx,) = _read_struct(f, ">H")
|
||||
if angle_idx >= self.n_angles:
|
||||
break
|
||||
self.precomputed_freq_mhz[angle_idx] = _read_f32_image(
|
||||
f, self.image_shape(angle_idx))
|
||||
return flags, row_avg_n
|
||||
|
||||
def _parse_cach_section(self, offset: int):
|
||||
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
|
||||
with open(self.path, "rb") as f:
|
||||
@@ -525,7 +565,7 @@ class SrasFile:
|
||||
if len(header_raw) < CACH_HDR_SIZE:
|
||||
return
|
||||
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
|
||||
if magic != CACH_MAGIC or cach_version != CACH_VERSION:
|
||||
if magic != CACH_MAGIC or cach_version not in CACH_VERSIONS_READABLE:
|
||||
return
|
||||
|
||||
if block_flags & CACH_FLAG_DC:
|
||||
@@ -535,17 +575,19 @@ class SrasFile:
|
||||
return
|
||||
|
||||
if block_flags & CACH_FLAG_FFT:
|
||||
flags = self._read_cache_block(
|
||||
f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz])
|
||||
if flags is None:
|
||||
result = self._read_sfft_block(f, cach_version)
|
||||
if result is None:
|
||||
return
|
||||
flags, row_avg_n = result
|
||||
self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB)
|
||||
self.precomputed_row_avg_n = row_avg_n if (flags & SFFT_FLAG_ROW_AVG) else 0
|
||||
|
||||
def write_v7_cache(self, *,
|
||||
new_dc3_mv: list[np.ndarray | None] | None = None,
|
||||
new_dc4_mv: list[np.ndarray | None] | None = None,
|
||||
new_freq_mhz: list[np.ndarray | None] | None = None,
|
||||
new_bg_sub: bool | None = None):
|
||||
new_bg_sub: bool | None = None,
|
||||
new_row_avg_n: int | None = None):
|
||||
"""Store computed DC and/or FFT images into this file's CACH tail,
|
||||
in place, converting a v6 source to v7 (or updating an existing v7
|
||||
file). Only the block(s) passed in are recomputed; whichever block
|
||||
@@ -553,6 +595,12 @@ class SrasFile:
|
||||
``SrasFile`` already has in memory (from parsing, or a prior write
|
||||
in this same session) — its bytes are never re-read from disk.
|
||||
|
||||
*new_row_avg_n* is the same-row neighbor half-width (pixels) the
|
||||
passed *new_freq_mhz* was averaged over before its FFT, 0 for a raw
|
||||
(unaveraged) compute — carried forward like *new_bg_sub* when None.
|
||||
It describes the whole stored FFT block, not per-angle, mirroring
|
||||
how bg-sub has never been tracked per-angle either.
|
||||
|
||||
The waveform data itself is never touched: the cache tail always
|
||||
starts at ``_cache_tail_offset()``, a fixed offset derived from the
|
||||
header and geometry table alone.
|
||||
@@ -565,6 +613,10 @@ class SrasFile:
|
||||
final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv
|
||||
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
|
||||
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
||||
final_row_avg_n = (new_row_avg_n if new_row_avg_n is not None
|
||||
else self.precomputed_row_avg_n)
|
||||
if not (0 <= final_row_avg_n <= 255):
|
||||
raise ValueError(f"row_avg_n must fit in a byte (0-255), got {final_row_avg_n}")
|
||||
|
||||
dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None]
|
||||
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
||||
@@ -584,7 +636,9 @@ class SrasFile:
|
||||
|
||||
if fft_entries:
|
||||
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
|
||||
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries))
|
||||
fft_flags |= SFFT_FLAG_ROW_AVG if final_row_avg_n else 0
|
||||
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags,
|
||||
len(fft_entries), final_row_avg_n)
|
||||
for a in fft_entries:
|
||||
payload += struct.pack(">H", a)
|
||||
payload += final_freq[a].astype(">f4").tobytes()
|
||||
@@ -612,6 +666,7 @@ class SrasFile:
|
||||
self.precomputed_dc4_mv = final_dc4
|
||||
self.precomputed_freq_mhz = final_freq
|
||||
self.precomputed_bg_sub = final_bg_sub
|
||||
self.precomputed_row_avg_n = final_row_avg_n
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Axes helpers
|
||||
|
||||
Reference in New Issue
Block a user