Add min peak frequency floor (CACH v4) and Batch Export View as Images
Two strands of in-progress work, committed together because they overlap in sras_workers.py and main_window.py. Min peak frequency floor: - CACH tail bumped to version 4, adding u32 min_freq_khz provenance in fixed-point kHz (a float32 20.1 reads back as 20.10000038 and would report a spurious mismatch forever). v1-v3 tails read as no floor. - Stored FFT caches are accepted when the reader's floor is at or above the stored one, since a higher floor is re-applicable by masking. - Floor plumbed through compute_rf_image, BatchCacheWorker and the viewer. Batch Export View as Images: - New sras_render.py holds draw_view_image, shared by the Qt canvas and the headless exporter so a PNG cannot drift from what the GUI shows. Deliberately Qt-free so it is importable in a pool subprocess. - BatchExportImagesWorker renders the current view settings across many files, process-pooled with an inline fallback, reporting per-file output names so the caller can flag same-stem collisions. - _axes_extent extracted into sras_format for both render paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+81
-16
@@ -386,7 +386,8 @@ def pad_factor_for(sras: SrasFile, n_fft: int | None) -> int:
|
||||
|
||||
|
||||
def cache_mismatch_reasons(sras: SrasFile, *, n_fft: int | None,
|
||||
apply_bg_sub: bool, row_avg_n: int) -> list[str]:
|
||||
apply_bg_sub: bool, row_avg_n: int,
|
||||
min_freq_mhz: float = 0.0) -> list[str]:
|
||||
"""Why this file's stored FFT images can't answer a request, as human-
|
||||
readable phrases; empty means they can.
|
||||
|
||||
@@ -396,8 +397,23 @@ def cache_mismatch_reasons(sras: SrasFile, *, n_fft: int | None,
|
||||
serves a stored image iff this returns nothing, and callers that want to
|
||||
*explain* the miss (rather than silently recompute) format these same
|
||||
strings, so the two can't drift apart.
|
||||
|
||||
The min peak frequency floor is the asymmetric case: a stored floor
|
||||
*above* the request is a genuine mismatch (bins below the stored floor
|
||||
were never searched, so the stored numbers can't answer a request that
|
||||
wants them considered), but a stored floor at or *below* the request is
|
||||
servable — the difference is re-applied as a display-time mask by
|
||||
cached_rf_image (pixels whose stored peak falls below the requested
|
||||
floor are marked invalid rather than re-resolved). Compared in whole
|
||||
kHz, the header field's own fixed-point grid, so a value that
|
||||
round-trips through the file can never miscompare against itself.
|
||||
"""
|
||||
reasons = []
|
||||
if round(sras.precomputed_min_freq_mhz * 1000) > round(min_freq_mhz * 1000):
|
||||
reasons.append(
|
||||
f"stored with a {sras.precomputed_min_freq_mhz:g} MHz "
|
||||
f"min-peak-freq floor, requested {min_freq_mhz:g} MHz "
|
||||
f"(bins below the stored floor were never searched)")
|
||||
pad = pad_factor_for(sras, n_fft)
|
||||
if pad != sras.precomputed_pad_factor:
|
||||
want = f"pad {pad}x" if pad else "a ragged n_fft"
|
||||
@@ -421,7 +437,8 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||
n_fft: int | None = None,
|
||||
dc4_mv: np.ndarray | None = None,
|
||||
row_avg_n: int = 0,
|
||||
allow_dc_recompute: bool = True) -> np.ndarray | None:
|
||||
allow_dc_recompute: bool = True,
|
||||
min_freq_mhz: float = 0.0) -> 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
|
||||
@@ -449,10 +466,20 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||
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.
|
||||
|
||||
*min_freq_mhz* above the stored floor is re-imposed here as a mask:
|
||||
pixels whose stored peak is below it are set to 0.0 — the same sentinel
|
||||
the DC mask uses, and unambiguous, since a genuine peak can never be
|
||||
0.0 (bin 0 is always excluded from the search). Masked-below-floor
|
||||
pixels are marked invalid, not re-resolved; only a real recompute can
|
||||
recover the strongest peak *above* the floor for them. A request whose
|
||||
floor is *below* the stored one can't be answered at all (see
|
||||
cache_mismatch_reasons) and returns None like any other mismatch.
|
||||
"""
|
||||
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
||||
if cached_freq is None or cache_mismatch_reasons(
|
||||
sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n):
|
||||
sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n,
|
||||
min_freq_mhz=min_freq_mhz):
|
||||
return None
|
||||
dc4_img = None
|
||||
if dc_threshold_mv is not None:
|
||||
@@ -472,6 +499,10 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||
freq_img = cached_freq.copy()
|
||||
if dc4_img is not None:
|
||||
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
||||
if min_freq_mhz > 0.0:
|
||||
# Strict <, matching the compute path: the first bin at or above
|
||||
# the floor survives searchsorted there, so it must survive here.
|
||||
freq_img[freq_img < min_freq_mhz] = 0.0
|
||||
return freq_img
|
||||
|
||||
|
||||
@@ -484,7 +515,8 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
budget: int | None = None,
|
||||
should_stop=None,
|
||||
row_avg_n: int = 0,
|
||||
min_freq_mhz: float = 0.0) -> np.ndarray:
|
||||
min_freq_mhz: float = 0.0,
|
||||
use_stored: bool = True) -> 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
|
||||
@@ -525,17 +557,27 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
Fast path: if the file has a precomputed peak-frequency image for this
|
||||
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. *min_freq_mhz* plays no part in that
|
||||
match — a stored image was baked without any floor, so it is returned
|
||||
as-is; the floor only ever affects a real (re)compute.
|
||||
FFT is run. See cached_rf_image. A *min_freq_mhz* at or above the
|
||||
stored floor is part of that service: pixels whose stored peak falls
|
||||
below it come back masked to 0.0 rather than re-resolved. A request
|
||||
*below* the stored floor is a genuine mismatch and falls through to
|
||||
the real FFT here.
|
||||
|
||||
*use_stored=False* skips the fast path entirely and always runs the
|
||||
real FFT. Batch recompute (cache_file) needs this: served-then-masked
|
||||
pixels written back into the store would permanently replace peaks a
|
||||
real recompute re-resolves above the floor.
|
||||
"""
|
||||
n_rows, n_frames = sras.image_shape(angle_idx)
|
||||
data = sras.data[angle_idx]
|
||||
|
||||
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
|
||||
if use_stored:
|
||||
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,
|
||||
min_freq_mhz=min_freq_mhz)
|
||||
if fast is not None:
|
||||
return fast
|
||||
|
||||
# ---- Chunked FFT path --------------------------------------------------
|
||||
spf = sras.samples_per_frame
|
||||
@@ -653,7 +695,8 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
max_workers: int = 0,
|
||||
pad_factor: int = 1,
|
||||
dc_threshold_mv: float | None = None,
|
||||
row_avg_n: int = 0) -> str:
|
||||
row_avg_n: int = 0,
|
||||
min_freq_mhz: float = 0.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.
|
||||
|
||||
@@ -675,6 +718,16 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
using, since a pad-1 cache is dead weight to a padded view and vice
|
||||
versa (cached_rf_image refuses the mismatch rather than showing peaks
|
||||
resolved at the wrong resolution).
|
||||
|
||||
*min_freq_mhz* is the peak-search floor (see compute_rf_image), also
|
||||
recorded in the SFFT block; it is quantized up front to the header
|
||||
field's 0.001 MHz grid so the stored images and the recorded floor
|
||||
can never disagree. Both FFT modes force use_stored=False on
|
||||
compute_rf_image: a batch recompute must always run the real FFT, never
|
||||
serve this file's own existing cache back to itself — with a floor (or
|
||||
a changed DC threshold in fft_rowavg mode) that would silently bake a
|
||||
masked copy of the old image into the store in place of a genuine
|
||||
recompute.
|
||||
"""
|
||||
global _MAX_WORKERS
|
||||
try:
|
||||
@@ -685,6 +738,12 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
# between a bad argument costing nothing and costing the whole run.
|
||||
if not (1 <= pad_factor <= MAX_PAD_FACTOR):
|
||||
return f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {pad_factor}"
|
||||
if not (np.isfinite(min_freq_mhz) and min_freq_mhz >= 0):
|
||||
return f"min_freq_mhz must be a finite value >= 0, got {min_freq_mhz}"
|
||||
# Quantize to the SFFT header's fixed-point grid (whole kHz) before
|
||||
# computing, so the floor the FFT actually ran with is exactly the
|
||||
# floor the header records.
|
||||
min_freq_mhz = round(min_freq_mhz * 1000) / 1000.0
|
||||
if max_workers:
|
||||
_MAX_WORKERS = max_workers
|
||||
|
||||
@@ -716,13 +775,16 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
# internally over blocks, so angles run one at a time with the
|
||||
# full budget.
|
||||
freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
|
||||
apply_bg_sub=effective_bg, n_fft=n_fft)
|
||||
apply_bg_sub=effective_bg, n_fft=n_fft,
|
||||
min_freq_mhz=min_freq_mhz,
|
||||
use_stored=False)
|
||||
for a in range(n)]
|
||||
# new_row_avg_n=0 explicitly: these images are raw per-pixel FFTs,
|
||||
# and carrying forward a row_avg_n left by an earlier fft_rowavg
|
||||
# write would label them as something they are not.
|
||||
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
|
||||
new_row_avg_n=0, new_pad_factor=pad_factor)
|
||||
new_row_avg_n=0, new_pad_factor=pad_factor,
|
||||
new_min_freq_mhz=min_freq_mhz)
|
||||
else: # "fft_rowavg"
|
||||
if row_avg_n <= 0:
|
||||
return "row_avg_n must be a positive neighbor half-width for fft_rowavg mode"
|
||||
@@ -732,11 +794,14 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
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, n_fft=n_fft,
|
||||
row_avg_n=row_avg_n)
|
||||
row_avg_n=row_avg_n,
|
||||
min_freq_mhz=min_freq_mhz,
|
||||
use_stored=False)
|
||||
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,
|
||||
new_pad_factor=pad_factor)
|
||||
new_pad_factor=pad_factor,
|
||||
new_min_freq_mhz=min_freq_mhz)
|
||||
return ""
|
||||
except Exception as exc:
|
||||
return str(exc)
|
||||
|
||||
Reference in New Issue
Block a user