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:
Thomas Ales [M S E]
2026-08-12 10:17:43 -05:00
parent c54cce453c
commit a8b962e317
13 changed files with 1442 additions and 153 deletions
+18 -2
View File
@@ -157,6 +157,18 @@ by `cached_rf_image` before it hands the image back. Getting this wrong is
not a slow display, it is a *wrong* display, which is why the check is a
single predicate in one place rather than spread across callers.
The min peak frequency floor is the fourth recorded setting, and it sits
between the DC threshold (fully re-applicable at display time) and the
baked-in three: it is *tighten-only* re-applicable. A floor at or above the
stored one is served by masking stored pixels below it to the 0.0 "no valid
peak" sentinel — invalidated, not re-resolved; only a real recompute can
find their true above-floor peak. A floor *below* the stored one is a
genuine mismatch: the stored search never looked at those bins. Because a
served-then-masked image is a lossy stand-in for a real floored compute,
`cache_file` passes `use_stored=False` so a batch recompute always runs the
real FFT — otherwise re-batching a cached file would silently bake the
masked copy of its own old image back into the store.
Padding is the subtlest of the three, because a padded FFT looks like it
should be a refinement of the unpadded one. It isn't: zero-padding
interpolates between the natural bins, so it resolves a different peak
@@ -183,8 +195,12 @@ recompute. The *display* path (`_stored_fft_image`) never asks
bg-sub/pad/row-averaging controls — it asks whether the image matches its
*own* recorded settings (`sras.precomputed_bg_sub`/`precomputed_pad_factor`/
`precomputed_row_avg_n`), which is always true whenever a stored image
exists. So presence alone decides whether it's shown; the live controls
never gate it. They still matter for two things: a genuinely never-computed
exists. Two settings are taken live instead: the DC threshold and the min
peak frequency floor, both cheaply re-appliable as masks on serve. So for
bg-sub/pad/row-avg, presence alone decides whether a stored image is shown;
the floor is the one case where a live control can gate it — a live floor
*below* the stored one can't be answered by masking, so `cached_rf_image`
declines and the caller falls through to a real compute. They still matter for two things: a genuinely never-computed
angle's first live compute, and an explicit batch recompute — both of which
read the live controls and produce new stored data, at which point it's the
new data's *own* settings that get self-matched from then on. This is what
+1
View File
@@ -28,6 +28,7 @@ sras-viewer = "sras_viewer.main_window:main"
py-modules = [
"sras_format",
"sras_compute",
"sras_render",
"sras_workers",
"sras_align_export",
"sras_average",
+31 -13
View File
@@ -255,7 +255,7 @@ actions.
| Offset | Size | Type | Field | Description |
|--------|------|------|-------|-------------|
| 0 | 4 | `char[4]` | `cach_magic` | `CACH` (ASCII). Missing/wrong magic → treat file as having no cache. |
| 4 | 1 | `u8` | `cach_version` | Cache format version. Currently `3`; readers also accept `1` and `2` (each older tail simply lacks the fields added since — see the `SFFT` block below and [CACH tail version history](#cach-tail-version-history)). Any other value → treat the file as uncached (unlike v5's `PREC` section, which read but never validated its version byte). |
| 4 | 1 | `u8` | `cach_version` | Cache format version. Currently `4`; readers also accept `1`–`3` (each older tail simply lacks the fields added since — see the `SFFT` block below and [CACH tail version history](#cach-tail-version-history)). Any other value → treat the file as uncached (unlike v5's `PREC` section, which read but never validated its version byte). |
| 5 | 1 | `u8` | `block_flags` | Bit 0 = DC block (`SDCB`) follows. Bit 1 = FFT block (`SFFT`) follows, immediately after the DC block if both are present. Bits 2–7 reserved, must be zero on write. |
### DC block `SDCB` (present iff `block_flags & 0x01`)
@@ -292,13 +292,16 @@ never shifts an existing offset:
- **`cach_version` 1**: 7 bytes, format `">4sBH"` — magic, flags, n_stored.
- **`cach_version` 2**: 8 bytes, format `">4sBHB"` — + `row_avg_n`.
- **`cach_version` 3**: 10 bytes, format `">4sBHBH"` — + `pad_factor`.
- **`cach_version` 4**: 14 bytes, format `">4sBHBHI"` — + `min_freq_khz`.
Always written by current code.
An older tail is read with its absent fields taken as the only value such a
tail can describe: `row_avg_n = 0` for a `cach_version` 1 tail, which
predates row-averaged FFT caching, and `pad_factor = 1` for `cach_version`
1 or 2, which predate padded caching and are therefore natural-resolution.
Files cached before either change keep working with no recompute.
predates row-averaged FFT caching, `pad_factor = 1` for `cach_version`
1 or 2, which predate padded caching and are therefore natural-resolution,
and `min_freq_khz = 0` (no floor) for `cach_version` 1–3, which predate the
min peak frequency floor and therefore searched every bin above DC.
Files cached before any of these changes keep working with no recompute.
| Offset (rel) | Size | Type | Field | Description |
|--------------|------|------|-------|-------------|
@@ -306,7 +309,8 @@ Files cached before either change keep working with no recompute.
| 4 | 1 | `u8` | `flags` | Bit 0 = `bg_sub_applied` — background waveform was subtracted from CH1 before the FFT when these images were computed. Bit 1 = `row_averaged` — `peak_freq_mhz` came from same-row, distance-weighted averaged CH1 waveforms rather than raw per-pixel ones; `row_avg_n` (below) is the neighbor half-width used. Bits 2–7 reserved. |
| 5 | 2 | `u16` | `n_stored` | Number of angle entries that follow |
| 7 | 1 | `u8` | `row_avg_n` | *`cach_version` ≥ 2 only.* Same-row neighbor half-width, in pixels, that `peak_freq_mhz` was averaged over before its FFT; `0` = raw (unaveraged). Meaningful only when `flags` bit 1 is set — a `cach_version` 1 tail has no such byte and is always `row_avg_n = 0`. |
| 8 | 2 | `u16` | `pad_factor` | *`cach_version` 3 only.* Zero-padding factor the stored `peak_freq_mhz` was resolved at: `n_fft = pad_factor × samples_per_frame`, so `1` = natural resolution. Never `0`; a `cach_version` 1 or 2 tail has no such field and is always `pad_factor = 1`. |
| 8 | 2 | `u16` | `pad_factor` | *`cach_version` ≥ 3 only.* Zero-padding factor the stored `peak_freq_mhz` was resolved at: `n_fft = pad_factor × samples_per_frame`, so `1` = natural resolution. Never `0`; a `cach_version` 1 or 2 tail has no such field and is always `pad_factor = 1`. |
| 10 | 4 | `u32` | `min_freq_khz` | *`cach_version` ≥ 4 only.* Min peak frequency floor the stored peak search excluded bins below, fixed-point in units of 0.001 MHz (kHz); `0` = no floor. Fixed-point rather than `f32` so a value that round-trips through the file compares exactly against the same value re-requested by a reader (the viewer's floor control has 0.001 MHz granularity). A `cach_version` 1–3 tail has no such field and is always `min_freq_khz = 0`. |
followed by `n_stored` entries, each:
@@ -341,14 +345,27 @@ Readers must fall back to real-time FFT computation (ignoring stored
the reader is asking for: time-domain gating is active, the reader's
requested `n_fft` doesn't equal `pad_factor × samples_per_frame`, the
reader's background-subtraction setting doesn't match
`flags.bg_sub_applied`, or the reader's requested `row_avg_n` doesn't match
the stored value exactly. A raw request must never be served a row-averaged
store, or vice versa; a request at one row-averaging window size must never
be served a store at another; and a request at one padding must never be
served a store at another, since a padded FFT interpolates between the
natural bins and so resolves genuinely different peak frequencies. An
`n_fft` that is not a whole multiple of `samples_per_frame` can never match
any store, because only an integer `pad_factor` is representable.
`flags.bg_sub_applied`, the reader's requested `row_avg_n` doesn't match
the stored value exactly, or the reader's requested min peak frequency
floor is *below* the stored `min_freq_khz`. A raw request must never be
served a row-averaged store, or vice versa; a request at one row-averaging
window size must never be served a store at another; and a request at one
padding must never be served a store at another, since a padded FFT
interpolates between the natural bins and so resolves genuinely different
peak frequencies. An `n_fft` that is not a whole multiple of
`samples_per_frame` can never match any store, because only an integer
`pad_factor` is representable.
The min peak frequency floor is the one asymmetric provenance field. A
request at a floor *below* the stored one cannot be served: the stored
search never looked at bins below its floor, so the stored numbers cannot
say what a lower-floored search would have found. A request at a floor at
or *above* the stored one **is** servable — the difference is re-applied at
display time by masking every pixel whose stored `peak_freq_mhz` is below
the requested floor to `0` (the same sentinel as the DC threshold mask;
a genuine peak can never be `0`, since bin 0 is always excluded from the
search). Such masked pixels are *invalid*, not re-resolved — only a real
recompute can recover the strongest peak above the floor for them.
### In-place write ordering
@@ -374,6 +391,7 @@ which has stayed `7` since the Cache Tail was introduced — this is the inner
| 1 | Initial Cache Tail: `SDCB` (DC) and `SFFT` (FFT, 7-byte header) blocks. |
| 2 | `SFFT` header grows one byte, `row_avg_n` — the same-row neighbor half-width the stored `peak_freq_mhz` was averaged over before its FFT, `0` = raw. Readers still accept a `cach_version` 1 tail, treated as `row_avg_n = 0` for every angle it stores, so files cached before this change keep working without a recompute. |
| 3 | `SFFT` header grows a `u16` `pad_factor` — the zero-padding factor the stored `peak_freq_mhz` was resolved at, `1` = natural resolution. Before this, a padded view could never use a stored cache at all (the store was pad 1 by definition and readers rejected any `n_fft ≠ samples_per_frame`), so a user working at a pad factor got no benefit from batch-computing a file. Recording the factor lets such a view be served, while still refusing a store resolved at a *different* pad. Readers accept `cach_version` 1 and 2 tails as `pad_factor = 1`. |
| 4 | `SFFT` header grows a `u32` `min_freq_khz` — the min peak frequency floor the stored peak search excluded bins below, in 0.001 MHz units, `0` = no floor. The floor exists because a pixel that passes the DC-bias threshold but carries only weak real signal can otherwise resolve to the un-subtracted background's DC-leakage skirt — an implausibly-near-zero frequency (and so an implausibly slow velocity) for a pixel that has a genuine peak higher up. Recording the floor is what makes it enforceable against a store: without it, a stored image silently bypassed the floor entirely. Unlike the other provenance fields it is asymmetric — a *higher* requested floor is servable by masking stored pixels below it, only a *lower* one forces a recompute (see above). Readers accept `cach_version` 1–3 tails as `min_freq_khz = 0`. |
A reader that does not know a `cach_version` must treat the file as
uncached — not attempt a partial parse — and the file still reads as an
+79 -14
View File
@@ -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,15 +557,25 @@ 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 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
@@ -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)
+78 -24
View File
@@ -60,15 +60,18 @@ 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 = 3 # written on every fresh write
CACH_VERSIONS_READABLE = (1, 2, 3) # accepted on read — see
CACH_VERSION = 4 # written on every fresh write
CACH_VERSIONS_READABLE = (1, 2, 3, 4) # accepted on read — see
# _read_sfft_block. Each bump only
# appended a field, and every older
# tail has a well-defined reading:
# v1 predates row-averaged FFT
# caching (row_avg_n=0) and v1/v2
# caching (row_avg_n=0), v1/v2
# predate padded caching, so both
# are natural-resolution (pad 1).
# are natural-resolution (pad 1),
# and v1-v3 predate the min peak
# frequency floor (min_freq 0 =
# no floor).
CACH_FLAG_DC = 0x01
CACH_FLAG_FFT = 0x02
@@ -79,11 +82,20 @@ SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
SFFT_MAGIC = b"SFFT"
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored (cach_version 1)
SFFT_HDR_FMT_V2 = ">4sBHB" # + row_avg_n (cach_version 2)
SFFT_HDR_FMT = ">4sBHBH" # + pad_factor (cach_version 3)
SFFT_HDR_FMT_V3 = ">4sBHBH" # + pad_factor (cach_version 3)
SFFT_HDR_FMT = ">4sBHBHI" # + min_freq_khz (cach_version 4)
SFFT_HDR_SIZE_V1 = struct.calcsize(SFFT_HDR_FMT_V1)
SFFT_HDR_SIZE_V2 = struct.calcsize(SFFT_HDR_FMT_V2)
SFFT_HDR_SIZE_V3 = struct.calcsize(SFFT_HDR_FMT_V3)
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
MAX_PAD_FACTOR = 0xFFFF # the H field above
# min_freq_khz is fixed-point (u32, units of 0.001 MHz), not a float32:
# the viewer's floor spinbox has 0.001 MHz granularity, and
# round(mhz * 1000) / 1000.0 reproduces the exact float64 the user typed,
# so the accept rule in sras_compute.cache_mismatch_reasons can compare
# with plain integer ordering. A ">f" float32 of e.g. 20.1 would read back
# as 20.10000038… > 20.1 and report a spurious mismatch forever.
MAX_MIN_FREQ_KHZ = 0xFFFFFFFF # the I field above; 0 = no floor
SFFT_FLAG_BG_SUB = 0x01
SFFT_FLAG_ROW_AVG = 0x02 # peak_freq_mhz came from same-row,
# distance-weighted averaged CH1
@@ -134,6 +146,13 @@ def adc_to_mv(adc, ymult_mv: float = _FALLBACK_YMULT_MV,
return (adc - yoff_adc) * ymult_mv + yzero_mv
def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]:
"""Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
renders at the top."""
return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
# ---------------------------------------------------------------------------
# Binary read helpers
# ---------------------------------------------------------------------------
@@ -188,9 +207,10 @@ class SrasFile:
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
per angle, ``None`` where that angle was never stored) regardless of
source version. The scalars ``precomputed_bg_sub`` /
``precomputed_row_avg_n`` / ``precomputed_pad_factor`` record the
settings the stored FFT images were computed under, so a reader can tell
whether they answer the question it is actually asking.
``precomputed_row_avg_n`` / ``precomputed_pad_factor`` /
``precomputed_min_freq_mhz`` record the settings the stored FFT images
were computed under, so a reader can tell whether they answer the
question it is actually asking.
"""
def __init__(self, path: str):
@@ -253,6 +273,13 @@ class SrasFile:
# FFT resolves peaks a padded view would, and only such a view can
# be served from it — see sras_compute.cached_rf_image.
self.precomputed_pad_factor: int = 1
# Min peak frequency floor (MHz) the stored peak search excluded
# bins below; 0.0 = no floor. Unlike bg-sub/pad/row-avg it is
# tighten-only re-applicable: a *higher* floor can be re-imposed on
# a stored image by masking pixels below it, but bins below the
# stored floor were never searched, so a lower floor needs a real
# recompute — see sras_compute.cache_mismatch_reasons.
self.precomputed_min_freq_mhz: float = 0.0
def encoded_preambles(self) -> bytes:
"""This file's Preamble Blocks section, as bytes a writer can emit.
@@ -595,30 +622,35 @@ class SrasFile:
store[angle_idx] = _read_f32_image(f, shape)
return flags
def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int, int] | None:
def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int, int, float] | None:
"""Read the SFFT block header — its layout depends on cach_version,
since each bump appended a trailing field (v2 row_avg_n, v3
pad_factor) — then n_stored per-angle peak_freq_mhz entries
(unchanged across versions).
pad_factor, v4 min_freq_khz) — then n_stored per-angle
peak_freq_mhz entries (unchanged across versions).
Returns (flags, row_avg_n, pad_factor), or None if the block is
malformed. The absent fields of an older tail take the value that
describes what such a tail can only have been: row_avg_n=0 for v1,
which predates row-averaged FFT caching, and pad_factor=1 for v1/v2,
which predate padded caching and so are natural-resolution.
Returns (flags, row_avg_n, pad_factor, min_freq_mhz), or None if
the block is malformed. The absent fields of an older tail take the
value that describes what such a tail can only have been:
row_avg_n=0 for v1, which predates row-averaged FFT caching;
pad_factor=1 for v1/v2, which predate padded caching and so are
natural-resolution; and min_freq_mhz=0.0 for v1-v3, which predate
the min peak frequency floor and so searched every bin above DC.
"""
hdr_fmt = {1: SFFT_HDR_FMT_V1, 2: SFFT_HDR_FMT_V2}.get(
cach_version, SFFT_HDR_FMT)
hdr_fmt = {1: SFFT_HDR_FMT_V1, 2: SFFT_HDR_FMT_V2,
3: SFFT_HDR_FMT_V3}.get(cach_version, SFFT_HDR_FMT)
raw = f.read(struct.calcsize(hdr_fmt))
if len(raw) < struct.calcsize(hdr_fmt):
return None
row_avg_n, pad_factor = 0, 1
row_avg_n, pad_factor, min_freq_khz = 0, 1, 0
if cach_version == 1:
magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
elif cach_version == 2:
magic, flags, n_stored, row_avg_n = struct.unpack(hdr_fmt, raw)
else:
elif cach_version == 3:
magic, flags, n_stored, row_avg_n, pad_factor = struct.unpack(hdr_fmt, raw)
else:
(magic, flags, n_stored, row_avg_n, pad_factor,
min_freq_khz) = struct.unpack(hdr_fmt, raw)
if magic != SFFT_MAGIC:
return None
for _ in range(n_stored):
@@ -627,7 +659,7 @@ class SrasFile:
break
self.precomputed_freq_mhz[angle_idx] = _read_f32_image(
f, self.image_shape(angle_idx))
return flags, row_avg_n, pad_factor
return flags, row_avg_n, pad_factor, min_freq_khz / 1000.0
def _parse_cach_section(self, offset: int):
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
@@ -650,10 +682,13 @@ class SrasFile:
result = self._read_sfft_block(f, cach_version)
if result is None:
return
flags, row_avg_n, pad_factor = result
flags, row_avg_n, pad_factor, min_freq_mhz = 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
self.precomputed_pad_factor = max(1, pad_factor)
# No flag bit gates the floor: 0 (= no floor) is already the
# value every pre-v4 tail reads as.
self.precomputed_min_freq_mhz = min_freq_mhz
def write_v7_cache(self, *,
new_dc3_mv: list[np.ndarray | None] | None = None,
@@ -661,7 +696,8 @@ class SrasFile:
new_freq_mhz: list[np.ndarray | None] | None = None,
new_bg_sub: bool | None = None,
new_row_avg_n: int | None = None,
new_pad_factor: int | None = None):
new_pad_factor: int | None = None,
new_min_freq_mhz: float | 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
@@ -681,6 +717,13 @@ class SrasFile:
different pad resolves different peaks, so recording it is what lets
a reader refuse the cache instead of showing the wrong numbers.
*new_min_freq_mhz* is the min peak frequency floor the passed
*new_freq_mhz*'s peak search excluded bins below (0.0 = no floor),
carried forward the same way. It is stored fixed-point (whole kHz),
so the value is quantized to 0.001 MHz on write and
``precomputed_min_freq_mhz`` is updated to the quantized value —
what a reload would see, never a float the header can't represent.
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.
@@ -697,11 +740,21 @@ class SrasFile:
else self.precomputed_row_avg_n)
final_pad_factor = (new_pad_factor if new_pad_factor is not None
else self.precomputed_pad_factor)
final_min_freq_mhz = (new_min_freq_mhz if new_min_freq_mhz is not None
else self.precomputed_min_freq_mhz)
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}")
if not (1 <= final_pad_factor <= MAX_PAD_FACTOR):
raise ValueError(
f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {final_pad_factor}")
if not (np.isfinite(final_min_freq_mhz) and final_min_freq_mhz >= 0):
raise ValueError(
f"min_freq_mhz must be a finite value >= 0, got {final_min_freq_mhz}")
final_min_freq_khz = int(round(final_min_freq_mhz * 1000))
if final_min_freq_khz > MAX_MIN_FREQ_KHZ:
raise ValueError(
f"min_freq_mhz too large for the u32 kHz header field: "
f"{final_min_freq_mhz}")
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]
@@ -724,7 +777,7 @@ class SrasFile:
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,
final_pad_factor)
final_pad_factor, final_min_freq_khz)
for a in fft_entries:
payload += struct.pack(">H", a)
payload += final_freq[a].astype(">f4").tobytes()
@@ -754,6 +807,7 @@ class SrasFile:
self.precomputed_bg_sub = final_bg_sub
self.precomputed_row_avg_n = final_row_avg_n
self.precomputed_pad_factor = final_pad_factor
self.precomputed_min_freq_mhz = final_min_freq_khz / 1000.0
# ------------------------------------------------------------------
# Axes helpers
+153
View File
@@ -0,0 +1,153 @@
"""Pure-matplotlib rendering of a displayed SRAS image: imshow + colorbar +
axis/title labeling, shared by the interactive Qt canvas
(sras_viewer.canvases.ImageCanvas, which supplies its own already-Qt-backed
Figure/Axes) and the headless batch image-export worker (which builds a
throwaway Agg Figure per file and never touches Qt) -- so an exported PNG
can never quietly start looking different from what the GUI actually shows.
Deliberately no PyQt6 import anywhere in this module: BatchExportImagesWorker
(sras_workers.py) may run export_view_image inside a spawned
ProcessPoolExecutor subprocess, exactly like sras_compute.cache_file, and
importing anything under the sras_viewer package would run its __init__.py
and pull in the whole Qt widget tree for no reason.
"""
from pathlib import Path
import matplotlib as mpl
import numpy as np
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
from sras_compute import compute_rf_image, dc_image_mv
from sras_format import CH4_IDX, CH_NAMES, SrasFile, _axes_extent
def draw_view_image(ax, fig, img: np.ndarray, extent: list[float], cmap,
vmin: float, vmax: float, xlabel: str, ylabel: str,
title: str, colorbar_label: str = "", cb_ticks=None,
norm=None, bad_color=None):
"""imshow + colorbar + labels onto an already-created (ax, fig) pair.
*cmap* may be a name or a Colormap instance. *norm* (which overrides
vmin/vmax) and *cb_ticks* let a caller draw a discrete integer image
with whole-number colorbar bands instead of a continuous shade.
*bad_color*, if given, is the fill for NaN pixels -- a copy of *cmap* is
made so a shared, registered instance is never mutated.
Shared by ImageCanvas.show_image (Qt-backed ax/fig) and
export_view_image (headless Agg ax/fig) so the two can never drift into
showing different things for the same settings.
"""
if bad_color is not None:
cmap = (cmap if hasattr(cmap, "with_extremes")
else mpl.colormaps[cmap]).with_extremes(bad=bad_color)
kw = ({"norm": norm} if norm is not None
else {"vmin": vmin, "vmax": vmax})
im = ax.imshow(
img, aspect="auto", origin="upper",
extent=extent, cmap=cmap, interpolation="nearest", **kw,
)
cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, ticks=cb_ticks)
if colorbar_label:
cb.set_label(colorbar_label)
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
return im
def export_view_image(path: str, *, out_dir: str, angle_idx: int, ch_idx: int,
is_fft_mode: bool, is_velocity: bool,
dc_threshold_mv: float, apply_bg_sub: bool,
pad_factor: int, min_freq_mhz: float, grating_um: float,
cmap: str, auto_scale: bool, vmin: float, vmax: float,
highlight_masked: bool, mode_str: str,
colorbar_label: str, mask_color: str = "magenta",
max_workers: int | None = None,
dpi: int = 150) -> tuple[str, str]:
"""One file's contribution to Batch Export View as Images: renders
(angle_idx, ch_idx) at the given display settings to a PNG under
*out_dir*, via draw_view_image -- so a batch export is a folder of what
ImageCanvas.show_image would have put on screen for these settings, not
a raw data dump.
Module-level and picklable, like sras_compute.cache_file, so it can run
in a ProcessPoolExecutor -- see BatchExportImagesWorker. Unlike
cache_file this never writes to *path*: export is a read of the file's
own data, not a cache conversion, so any version SrasFile can open
works, with no v6/v7 precondition.
*pad_factor* (not n_fft) travels across files deliberately: n_fft
depends on samples_per_frame, which can differ between files in the
same batch, so n_fft is derived per file, here, from *this* file's own
value -- the same reason sras_compute.cache_file does the same thing.
Returns (error, out_name). error is "" on success. out_name is the
filename this call targeted -- set as soon as it's known, even on most
failures -- so the caller can flag same-stem collisions across the
batch without any cross-process bookkeeping.
"""
out_name = ""
try:
sras = SrasFile(path)
if angle_idx >= sras.n_angles:
return (f"angle {angle_idx} out of range "
f"(file has {sras.n_angles} angle(s))", out_name)
out_name = f"{Path(path).stem}_angle{angle_idx}_{CH_NAMES[ch_idx]}.png"
if is_fft_mode:
n_fft = (sras.samples_per_frame * pad_factor
if pad_factor > 1 else None)
freq = compute_rf_image(
sras, angle_idx, dc_threshold_mv=dc_threshold_mv,
apply_bg_sub=apply_bg_sub, n_fft=n_fft,
min_freq_mhz=min_freq_mhz, max_workers=max_workers)
img = freq * grating_um if is_velocity else freq
else:
img = dc_image_mv(sras, angle_idx, ch_idx, max_workers=max_workers)
display_img, bad_color = img, None
if highlight_masked and is_fft_mode:
# Mirrors the viewer's _redraw_image rule: DC-masked pixels and
# value-0 pixels (the "no valid peak" sentinel — DC-masked,
# below the min-freq floor, or empty spectrum; the grating
# multiply above preserves zeros, so this holds for Velocity
# too) both render in the highlight color.
dc4 = dc_image_mv(sras, angle_idx, CH4_IDX, max_workers=max_workers)
valid = dc4 >= dc_threshold_mv
if valid.shape == display_img.shape:
valid &= display_img != 0.0
display_img = display_img.astype(np.float32, copy=True)
display_img[~valid] = np.nan
bad_color = mask_color
if auto_scale:
v0, v1 = float(np.nanmin(display_img)), float(np.nanmax(display_img))
if not np.isfinite(v0):
v0, v1 = 0.0, 0.0 # every pixel masked out
else:
v0, v1 = vmin, vmax
x_axis = sras.x_axis_mm(angle_idx)
y_axis = sras.y_positions_mm(angle_idx)
dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else sras.pixel_x_mm
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
extent = _axes_extent(x_axis, y_axis, dx, dy)
title = (f"{CH_NAMES[ch_idx]} | {mode_str} | "
f"{sras.angles_deg[angle_idx]:.1f}°")
fig = Figure(figsize=(7, 5), tight_layout=True)
FigureCanvasAgg(fig) # Agg-only: never registered with pyplot
ax = fig.add_subplot(111)
draw_view_image(ax, fig, display_img, extent, cmap, v0, v1,
"X (mm)", "Y (mm)", title, colorbar_label,
bad_color=bad_color)
fig.savefig(str(Path(out_dir) / out_name), dpi=dpi)
return ("", out_name)
except Exception as exc:
return (str(exc), out_name)
+30 -19
View File
@@ -12,6 +12,7 @@ from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import QSizePolicy
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv
from sras_render import draw_view_image
def count_colormap(n_angles: int):
"""(cmap, norm, ticks) for an integer "how many angles cover this pixel"
@@ -182,24 +183,12 @@ class ImageCanvas(FigureCanvasQTAgg):
self._extent = extent
self._img_shape = img.shape
if bad_color is not None:
cmap = (cmap if hasattr(cmap, "with_extremes")
else mpl.colormaps[cmap]).with_extremes(bad=bad_color)
kw = ({"norm": norm} if norm is not None
else {"vmin": vmin, "vmax": vmax})
im = self.ax.imshow(
img, aspect="auto", origin="upper",
extent=extent, cmap=cmap, interpolation="nearest", **kw,
)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
ticks=cb_ticks)
if colorbar_label:
cb.set_label(colorbar_label)
self.ax.set_xlabel(xlabel)
self.ax.set_ylabel(ylabel)
self.ax.set_title(title)
# Shared with the headless batch image-export worker (sras_render.py)
# so an exported PNG can never quietly drift from what this canvas
# shows on screen for the same settings.
draw_view_image(self.ax, self.figure, img, extent, cmap, vmin, vmax,
xlabel, ylabel, title, colorbar_label, cb_ticks, norm,
bad_color)
# Re-draw the ROI (if any) on top of the fresh image so it persists
# unchanged across mode / angle / channel switches.
@@ -440,13 +429,22 @@ class WaveformCanvas(FigureCanvasQTAgg):
def show_rf_waveform(self, sras: SrasFile, angle_idx: int,
row_idx: int, frame_idx: int,
apply_bg_sub: bool = True):
apply_bg_sub: bool = True,
min_freq_mhz: float = 0.0):
"""CH1 RF: time-domain + FFT spectrum.
If apply_bg_sub is True and sras.background is not None, the background
waveform is overlaid on the time-domain plot and the FFT is computed
on the subtracted signal. The unsubtracted FFT is also shown faintly
for comparison.
*min_freq_mhz* > 0 restricts the labeled peak to bins at or above
it — the same floor the image's peak search uses, so the label
explains the map pixel instead of contradicting it — and shades the
excluded band on the spectrum. The spectrum curves themselves stay
complete (they are the evidence for choosing the floor). The peak
can still legitimately differ from a padded or row-averaged map:
this panel is always a single waveform at natural resolution.
"""
data = sras.data[angle_idx]
waveform = data[row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
@@ -487,8 +485,21 @@ class WaveformCanvas(FigureCanvasQTAgg):
# FFT of the (possibly subtracted) waveform
power_sub = np.abs(np.fft.rfft(waveform_plot)) ** 2
power_sub[0] = 0.0
# First bin at or above the floor, exactly as the image peak search
# picks it (bin 0 always excluded). If the floor excludes every bin,
# fall back to the unrestricted peak rather than indexing past the
# end — the label is informational, not a mask.
lo = max(1, int(np.searchsorted(f_mhz, min_freq_mhz)))
if lo < len(power_sub):
peak_mhz = f_mhz[lo + int(np.argmax(power_sub[lo:]))]
else:
peak_mhz = f_mhz[int(np.argmax(power_sub))]
if min_freq_mhz > 0.0:
self.ax_right.axvspan(0, min_freq_mhz, color="#888888",
alpha=0.15, zorder=0,
label=f"< {min_freq_mhz:g} MHz excluded")
if bg is not None:
# Also show the unsubtracted FFT for reference
power_raw = np.abs(np.fft.rfft(waveform)) ** 2
+1 -8
View File
@@ -6,7 +6,7 @@ from PyQt6.QtWidgets import (
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, _axes_extent # noqa: F401 (re-exported)
# ---------------------------------------------------------------------------
# Display constants
@@ -112,13 +112,6 @@ def _combo(items=(), *, min_chars: int = 10) -> QComboBox:
return combo
def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]:
"""Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
renders at the top."""
return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
def _wrap_label(text: str = "", css: str | None = None) -> QLabel:
"""A word-wrapped QLabel that reports its *wrapped* height to the layout.
+202 -62
View File
@@ -1,6 +1,7 @@
"""The SrasViewerWindow main window and application entry point."""
import sys
from collections import Counter
from pathlib import Path
import numpy as np
@@ -23,7 +24,8 @@ from sras_format import (
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
)
from sras_workers import (
BatchCacheWorker, ComputeWorker, DcPrecomputeWorker, LoadWorker,
BatchCacheWorker, BatchExportImagesWorker, ComputeWorker,
DcPrecomputeWorker, LoadWorker,
)
from .canvases import ImageCanvas, WaveformCanvas
@@ -92,13 +94,13 @@ class SrasViewerWindow(QMainWindow):
# computed lazily (with a progress popup) the first time an
# angle/threshold combination is viewed — using the cached DC4
# image to skip the FFT entirely for masked-out pixels — and
# cached per (angle, threshold) so revisiting the same combination
# is free. bg-sub/pad are deliberately not part of the key: once an
# angle has any FFT image (live or from the file's own stored
# cache), it stays displayed regardless of those controls — see
# _fft_cache_key.
# cached per (angle, threshold, min peak freq) so revisiting the
# same combination is free. bg-sub/pad are deliberately not part of
# the key: once an angle has any FFT image (live or from the file's
# own stored cache), it stays displayed regardless of those
# controls — see _fft_cache_key.
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
self._fft_cache: dict[tuple[int, float], np.ndarray] = {}
self._fft_cache: dict[tuple[int, float, float], np.ndarray] = {}
self._dc_generation: int = 0
# Angle alignment ("Fusion" menu)
@@ -287,9 +289,12 @@ class SrasViewerWindow(QMainWindow):
"is real, valid data. Raising this floor above that skirt forces\n"
"the search to report the strongest peak that is plausibly real\n"
"signal instead.\n"
"Only affects a fresh/live compute — it cannot change an image\n"
"already shown, or one already stored in this file's own cache\n"
"(use Batch Compute to regenerate those)."
"Raising the floor also re-masks images already shown or stored\n"
"in this file's cache: pixels whose stored peak falls below it\n"
"display as invalid. Lowering it below a stored cache's own\n"
"floor needs a recompute (bins below that floor were never\n"
"searched). Batch Compute FFT records the floor in the file and\n"
"re-resolves masked pixels to their true above-floor peak."
)
self.spin_min_freq_mhz.editingFinished.connect(self._on_min_freq_changed)
min_freq_form.addRow("Min peak freq:", self.spin_min_freq_mhz)
@@ -518,6 +523,20 @@ class SrasViewerWindow(QMainWindow):
self._batch_fft_rowavg_act.triggered.connect(self._on_batch_compute_row_avg)
convert_menu.addAction(self._batch_fft_rowavg_act)
convert_menu.addSeparator()
self._batch_export_images_act = QAction(
"Batch Export View as &Images…", self)
self._batch_export_images_act.setStatusTip(
"Select .sras files and export the currently selected view "
"(channel, angle, threshold, colormap, etc.) as one PNG per "
"file, rendered the same way it is shown on screen. Read-only "
"— never modifies the source files. Aligned View is ignored "
"even if enabled, since an alignment result belongs to one "
"specific file's geometry.")
self._batch_export_images_act.triggered.connect(
self._on_batch_export_images)
convert_menu.addAction(self._batch_export_images_act)
# ------------------------------------------------------------------
# Drag-and-drop
# ------------------------------------------------------------------
@@ -661,10 +680,13 @@ class SrasViewerWindow(QMainWindow):
if s.precomputed_row_avg_n else "")
pad_note = (f", pad {s.precomputed_pad_factor}x"
if s.precomputed_pad_factor > 1 else "")
floor_note = (f", floor ≥ {s.precomputed_min_freq_mhz:g} MHz"
if s.precomputed_min_freq_mhz > 0 else "")
notes.append(
f"Cached images: DC {n_dc}/{s.n_angles} angles, "
f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''}"
f"{avg_note if n_fft else ''}{pad_note if n_fft else ''} "
f"{avg_note if n_fft else ''}{pad_note if n_fft else ''}"
f"{floor_note if n_fft else ''} "
"— display is instant for cached angles")
notes += self._cache_mismatch_notes()
elif s.version == 7:
@@ -673,17 +695,24 @@ class SrasViewerWindow(QMainWindow):
def _cache_mismatch_notes(self) -> list[str]:
"""Informational only: whether the file's stored FFT cache was
computed under different bg-sub/pad settings than these controls
currently say. The display always shows the stored image as-is
regardless (see _stored_fft_image) — these controls only affect a
future live compute for an angle with nothing cached yet, or an
explicit batch recompute, never what's already on screen.
computed under different bg-sub/pad/min-freq settings than these
controls currently say. The display always shows the stored image
(re-masked for a raised floor) regardless — see _stored_fft_image;
these controls only affect a future live compute for an angle with
nothing cached yet, or an explicit batch recompute, never what's
already on screen.
row_avg_n is compared against the file's own recorded value (a
self-match), so it never contributes a reason here — there's no
live control for it to diverge from, and the "Cached images" line
above already reports it.
The min peak frequency floor gets two directions: a live floor
*below* the stored one is a genuine mismatch reason (the stored
search never looked below its floor), while a live floor *above*
the stored one is servable — pixels under it are shown as masked —
so that direction gets its own softer note.
Asks compute for the reasons rather than restating the accept rule,
so a new provenance field can only be added in one place.
"""
@@ -691,15 +720,26 @@ class SrasViewerWindow(QMainWindow):
if s is None or all(x is None for x in s.precomputed_freq_mhz):
return []
live_floor = self.spin_min_freq_mhz.value()
notes = []
reasons = compute.cache_mismatch_reasons(
s, n_fft=self._current_n_fft(),
apply_bg_sub=self.chk_bg_sub.isChecked(),
row_avg_n=s.precomputed_row_avg_n)
if not reasons:
return []
return ["Note: current bg-sub/pad controls differ from the stored "
"cache — " + "; ".join(reasons) + ". Shown as stored; use "
"Batch Compute to recompute with these settings."]
row_avg_n=s.precomputed_row_avg_n,
min_freq_mhz=live_floor)
if reasons:
notes.append(
"Note: current bg-sub/pad/min-freq controls differ from the "
"stored cache — " + "; ".join(reasons) + ". Shown as stored; "
"use Batch Compute to recompute with these settings.")
if round(live_floor * 1000) > round(s.precomputed_min_freq_mhz * 1000):
notes.append(
f"Note: Min peak freq ({live_floor:g} MHz) is above the "
f"stored cache's floor ({s.precomputed_min_freq_mhz:g} MHz) — "
f"stored pixels whose peak falls below {live_floor:g} MHz are "
"shown as masked; Batch Compute FFT re-resolves them above "
"the floor instead.")
return notes
# ------------------------------------------------------------------
# Controls
@@ -774,11 +814,13 @@ class SrasViewerWindow(QMainWindow):
self._refresh_display()
def _on_min_freq_changed(self):
# Like the DC threshold, this changes what the FFT itself produces —
# a genuine cache-key change — but unlike the threshold it can't be
# re-applied to an already-computed image; it only takes effect on a
# fresh compute (see _fft_cache_key / compute_rf_image's docstring).
# Like the DC threshold, this is a genuine cache-key change that can
# be cheaply re-applied to a stored image — tighten-only: raising the
# floor masks stored pixels below it, while lowering it below the
# stored cache's own floor can only be answered by a fresh compute
# (see cached_rf_image / cache_mismatch_reasons).
if self._is_fft_mode():
self._update_scan_info_labels()
self._refresh_display()
def _on_autoscale_toggled(self, checked: bool):
@@ -1072,11 +1114,10 @@ class SrasViewerWindow(QMainWindow):
image for that angle regardless of later bg-sub/pad toggles; those
only affect a future live compute for an angle with nothing cached
yet, or an explicit batch recompute (see _stored_fft_image).
Threshold and min-freq stay in the key so revisiting a combination
already computed this session is instant — even though only
threshold can be cheaply re-applied to a stored image; a min-freq
change against a stored image still falls through to
_stored_fft_image, which ignores it (see _on_min_freq_changed)."""
Threshold and min-freq are both in the key because both are cheaply
re-applied when a stored image is served (_stored_fft_image takes
them live), so the cached value genuinely reflects every component
of its key and revisiting a combination is instant."""
return (angle_idx, self.spin_threshold_mv.value(),
self.spin_min_freq_mhz.value())
@@ -1155,10 +1196,13 @@ class SrasViewerWindow(QMainWindow):
controls never gate whether it's used, only what a *future* compute
produces. See _cache_mismatch_notes for the informational (non-
blocking) note when the live controls diverge from what's shown.
Only the DC threshold is taken live: re-masking a stored image
against it is free, unlike bg-sub/pad/row-averaging — or the min
peak frequency floor — which are baked irreversibly into the stored
numbers.
The DC threshold and the min peak frequency floor are taken live:
re-masking a stored image against either is free, unlike
bg-sub/pad/row-averaging, which are baked irreversibly into the
stored numbers. The floor is tighten-only, though — a live floor
*below* the stored cache's own floor is the one case where a stored
image genuinely can't answer (cached_rf_image refuses it), and the
caller falls through to a real compute.
allow_dc_recompute=False keeps this off the I/O path: if the mask
would mean reading a whole CH4 channel, this declines and the caller
@@ -1175,7 +1219,8 @@ class SrasViewerWindow(QMainWindow):
n_fft=n_fft,
row_avg_n=s.precomputed_row_avg_n,
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
allow_dc_recompute=False)
allow_dc_recompute=False,
min_freq_mhz=self.spin_min_freq_mhz.value())
def _cached_value_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""The already-available (no compute) image for (angle, channel):
@@ -1289,22 +1334,27 @@ class SrasViewerWindow(QMainWindow):
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
extent = _axes_extent(x_axis, y_axis, dx, dy)
# Masked-out (below-threshold) pixels are stored as a plain 0, the
# same value a real but low-frequency pixel can legitimately have -
# the two are indistinguishable once both land near the bottom of a
# linear colormap. Pull masked pixels out to NaN (drawn in a
# distinct highlight color, excluded from the auto-scale range) so a
# real low-frequency pixel keeps its own true shade instead of
# disappearing into the same black as "no data".
# Masked-out pixels are stored as a plain 0, the same value near
# the bottom of a linear colormap as a real low-frequency pixel -
# the two are indistinguishable there. Pull masked pixels out to
# NaN (drawn in a distinct highlight color, excluded from the
# auto-scale range) so a real pixel keeps its own true shade
# instead of disappearing into the same black as "no data". In an
# FFT-derived mode a value of exactly 0 is itself the "no valid
# peak" sentinel (DC-masked, below the min-freq floor, or an empty
# spectrum — bin 0 is always excluded, so no genuine peak is ever
# 0), so those pixels are masked by value too; that covers
# floor-masked pixels even when no DC4 image is cached yet.
highlight_masked = (ch_idx in CH1_DERIVED_MODES
and self.chk_highlight_masked.isChecked())
mask_valid = (self._dc_validity_mask(angle_idx, aligned)
if highlight_masked else None)
if mask_valid is not None and mask_valid.shape == display_img.shape:
display_img = display_img.astype(np.float32, copy=True)
display_img[~mask_valid] = np.nan
else:
if highlight_masked:
mask_valid = self._dc_validity_mask(angle_idx, aligned)
if mask_valid is not None and mask_valid.shape != display_img.shape:
mask_valid = None
display_img = display_img.astype(np.float32, copy=True)
if mask_valid is not None:
display_img[~mask_valid] = np.nan
display_img[display_img == 0.0] = np.nan
if self.chk_auto.isChecked():
vmin, vmax = float(np.nanmin(display_img)), float(np.nanmax(display_img))
@@ -1333,7 +1383,7 @@ class SrasViewerWindow(QMainWindow):
vmin=vmin, vmax=vmax,
xlabel="X (mm)", ylabel="Y (mm)",
title=title, colorbar_label=colorbar_label,
bad_color=_MASKED_HIGHLIGHT_COLOR if mask_valid is not None else None,
bad_color=_MASKED_HIGHLIGHT_COLOR if highlight_masked else None,
)
self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
@@ -1493,7 +1543,8 @@ class SrasViewerWindow(QMainWindow):
if self._current_ch in CH1_DERIVED_MODES:
self.wave_canvas.show_rf_waveform(
self._sras, angle_idx, row_idx, frame_idx,
apply_bg_sub=self.chk_bg_sub.isChecked())
apply_bg_sub=self.chk_bg_sub.isChecked(),
min_freq_mhz=self.spin_min_freq_mhz.value())
else:
self.wave_canvas.show_dc_waveform(
self._sras, angle_idx, self._current_ch, row_idx, frame_idx)
@@ -1545,7 +1596,8 @@ class SrasViewerWindow(QMainWindow):
# Cache the FFT at the pad the viewer is actually displaying at,
# otherwise the batch stores images this window can never use.
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(),
pad_factor=self._fft_pad_factor)
pad_factor=self._fft_pad_factor,
min_freq_mhz=self.spin_min_freq_mhz.value())
started = self._run_worker(
Jobs.BATCH, worker,
connect=(
@@ -1558,9 +1610,7 @@ class SrasViewerWindow(QMainWindow):
if not started:
return # a second trigger snuck in while the file dialog was open
self._batch_dc_act.setEnabled(False)
self._batch_fft_act.setEnabled(False)
self._batch_fft_rowavg_act.setEnabled(False)
self._set_batch_actions_enabled(False)
self._show_progress(
Jobs.BATCH, f"Batch computing {label} for {len(paths)} file(s)…",
maximum=100)
@@ -1587,7 +1637,8 @@ class SrasViewerWindow(QMainWindow):
self._batch_errors = []
worker = BatchCacheWorker(paths, "fft_rowavg", self.chk_bg_sub.isChecked(),
dc_threshold_mv=threshold_mv, row_avg_n=n,
pad_factor=self._fft_pad_factor)
pad_factor=self._fft_pad_factor,
min_freq_mhz=self.spin_min_freq_mhz.value())
started = self._run_worker(
Jobs.BATCH, worker,
connect=(
@@ -1600,9 +1651,7 @@ class SrasViewerWindow(QMainWindow):
if not started:
return # a second trigger snuck in while a dialog was open
self._batch_dc_act.setEnabled(False)
self._batch_fft_act.setEnabled(False)
self._batch_fft_rowavg_act.setEnabled(False)
self._set_batch_actions_enabled(False)
self._show_progress(
Jobs.BATCH,
f"Batch computing row-averaged FFT (n={n}, threshold={threshold_mv:.1f} mV) "
@@ -1633,9 +1682,100 @@ class SrasViewerWindow(QMainWindow):
self._load_file(str(self._sras.path))
def _after_batch(self):
self._batch_dc_act.setEnabled(True)
self._batch_fft_act.setEnabled(True)
self._batch_fft_rowavg_act.setEnabled(True)
self._set_batch_actions_enabled(True)
def _set_batch_actions_enabled(self, enabled: bool):
"""All four Convert-menu batch actions share one Jobs.BATCH slot;
grey out every sibling while one runs, rather than leaving one
clickable but silently no-op'd by the busy guard."""
for act in (self._batch_dc_act, self._batch_fft_act,
self._batch_fft_rowavg_act, self._batch_export_images_act):
act.setEnabled(enabled)
# ------------------------------------------------------------------
# Batch export view as images
# ------------------------------------------------------------------
def _on_batch_export_images(self):
if self._job_running(Jobs.BATCH):
return
paths, _ = QFileDialog.getOpenFileNames(
self, "Select .sras files to export the current view from", "",
"SRAS files (*.sras);;All files (*)")
if not paths:
return
out_dir = QFileDialog.getExistingDirectory(
self, "Select output folder for exported images")
if not out_dir:
return
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
is_fft_mode = ch_idx in CH1_DERIVED_MODES
mode_str, _unit, colorbar_label = _CHANNEL_DISPLAY[ch_idx]
self._batch_errors = []
self._batch_export_names = []
worker = BatchExportImagesWorker(
paths,
out_dir=out_dir, angle_idx=angle_idx, ch_idx=ch_idx,
is_fft_mode=is_fft_mode, is_velocity=(ch_idx == VELOCITY_MODE_IDX),
dc_threshold_mv=self.spin_threshold_mv.value(),
apply_bg_sub=self.chk_bg_sub.isChecked(),
pad_factor=self._fft_pad_factor,
min_freq_mhz=self.spin_min_freq_mhz.value(),
grating_um=self.spin_grating_um.value(),
cmap=self.combo_cmap.currentText(),
auto_scale=self.chk_auto.isChecked(),
vmin=self.spin_vmin.value(), vmax=self.spin_vmax.value(),
highlight_masked=self.chk_highlight_masked.isChecked(),
mode_str=mode_str, colorbar_label=colorbar_label,
mask_color=_MASKED_HIGHLIGHT_COLOR,
)
started = self._run_worker(
Jobs.BATCH, worker,
connect=(
("progress", lambda pct: self._set_progress(Jobs.BATCH, pct)),
("file_done", self._on_batch_export_file_done),
("finished",
lambda p=paths, d=out_dir: self._on_batch_export_finished(p, d)),
),
on_done=self._after_batch,
)
if not started:
return # a second trigger snuck in while a dialog was open
self._set_batch_actions_enabled(False)
self._show_progress(
Jobs.BATCH, f"Exporting images for {len(paths)} file(s)…",
maximum=100)
def _on_batch_export_file_done(self, path: str, err: str, out_name: str):
if err:
self._batch_errors.append(f"{Path(path).name} — {err}")
else:
self._batch_export_names.append(out_name)
self._show_progress(Jobs.BATCH, f"Exported {Path(path).name}…")
def _on_batch_export_finished(self, paths: list[str], out_dir: str):
self._close_progress(Jobs.BATCH)
n_total = len(paths)
n_failed = len(self._batch_errors)
n_ok = n_total - n_failed
summary = (f"Batch export: {n_ok}/{n_total} image(s) written to "
f"{Path(out_dir).name}")
if n_failed:
summary += f", {n_failed} failed: {'; '.join(self._batch_errors)}"
dupes = sum(1 for _name, count in Counter(self._batch_export_names).items()
if count > 1)
if dupes:
summary += (f" | {dupes} filename collision(s) — later file(s) "
"overwrote earlier ones with the same output name")
self.statusBar().showMessage(summary)
self._batch_errors = []
self._batch_export_names = []
# No reload: unlike Batch Compute, export never touches the source files.
# ------------------------------------------------------------------
# Fusion: angle alignment
+116 -4
View File
@@ -18,6 +18,7 @@ import sras_compute as compute
from sras_align_export import write_aligned_sras
from sras_compute import cache_file, compute_rf_image, dc_image_mv
from sras_format import CH3_IDX, CH4_IDX, SrasFile
from sras_render import export_view_image
# Concurrency caps. Batch conversion runs one process per file, and each of
# those processes threads internally, so the two must be divided rather than
@@ -197,7 +198,9 @@ class BatchCacheWorker(QObject):
Both FFT modes cache at *pad_factor*, which the caller sets from the
viewer's own padding — a cache stored at a pad the user is not viewing
at is one the display can never use.
at is one the display can never use. *min_freq_mhz* travels the same
way: the viewer's live min-peak-freq floor, recorded in the store as
provenance so a reader knows which bins the peak search considered.
Files are processed one per subprocess: they are fully independent, each
opens its own memmap and writes only its own bytes, and only path strings
@@ -211,7 +214,7 @@ class BatchCacheWorker(QObject):
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool,
dc_threshold_mv: float | None = None, row_avg_n: int = 0,
pad_factor: int = 1):
pad_factor: int = 1, min_freq_mhz: float = 0.0):
super().__init__()
self._paths = paths
self._mode = mode
@@ -219,6 +222,7 @@ class BatchCacheWorker(QObject):
self._dc_threshold = dc_threshold_mv
self._row_avg_n = row_avg_n
self._pad_factor = pad_factor
self._min_freq_mhz = min_freq_mhz
def _report(self, path: str, err: str, done: int, total: int):
self.file_done.emit(path, err)
@@ -246,7 +250,8 @@ class BatchCacheWorker(QObject):
per_proc_workers,
pad_factor=self._pad_factor,
dc_threshold_mv=self._dc_threshold,
row_avg_n=self._row_avg_n): p
row_avg_n=self._row_avg_n,
min_freq_mhz=self._min_freq_mhz): p
for p in paths
}
for fut in as_completed(futures):
@@ -272,7 +277,8 @@ class BatchCacheWorker(QObject):
compute.default_max_workers(),
pad_factor=self._pad_factor,
dc_threshold_mv=self._dc_threshold,
row_avg_n=self._row_avg_n)
row_avg_n=self._row_avg_n,
min_freq_mhz=self._min_freq_mhz)
except Exception as exc:
err = str(exc)
done += 1
@@ -310,6 +316,112 @@ class BatchCacheWorker(QObject):
self.finished.emit()
class BatchExportImagesWorker(QObject):
"""Renders the view settings captured by the caller at trigger time
(angle/channel/threshold/etc.) to one PNG per file in *paths*, via
sras_render.export_view_image.
Same process-pool-with-inline-fallback strategy as BatchCacheWorker
above (same _BATCH_MAX_PROCS / _BATCH_POOL_MIN_BYTES thresholds): an
FFT-derived (CH1/Velocity) view is exactly the same expensive per-file
compute Batch Compute FFT already parallelizes this way. Never mutates
*paths* — each file is opened read-only — so unlike BatchCacheWorker
there is no version gate.
Emits progress(int) (0-100 by files completed), file_done(str, str, str)
(path, error message or "", output filename this file targeted — set
even on most failures so the caller can flag same-stem collisions across
the batch without any cross-process bookkeeping), and finished().
"""
progress = pyqtSignal(int)
file_done = pyqtSignal(str, str, str)
finished = pyqtSignal()
def __init__(self, paths: list[str], **render_kwargs):
"""*render_kwargs* is exactly export_view_image's keyword-only
settings (out_dir, angle_idx, ch_idx, is_fft_mode, is_velocity,
dc_threshold_mv, apply_bg_sub, pad_factor, min_freq_mhz, grating_um,
cmap, auto_scale, vmin, vmax, highlight_masked, mode_str,
colorbar_label, mask_color) — bundled rather than repeated as
positional params across __init__/_run_pooled/_run_inline."""
super().__init__()
self._paths = paths
self._kw = render_kwargs
def _report(self, path: str, err: str, out_name: str, done: int, total: int):
self.file_done.emit(path, err, out_name)
self.progress.emit(int(done / max(1, total) * 100))
def _run_pooled(self, paths: list[str], n_procs: int) -> list[str]:
"""Same contract as BatchCacheWorker._run_pooled: returns the paths
that never got a real answer because the pool itself died, so the
caller can retry them in-process."""
per_proc_workers = max(1, (os.cpu_count() or 4) // n_procs)
unresolved: list[str] = []
done = 0
with ProcessPoolExecutor(max_workers=n_procs) as executor:
futures = {
executor.submit(export_view_image, p,
max_workers=per_proc_workers, **self._kw): p
for p in paths
}
for fut in as_completed(futures):
path = futures[fut]
try:
err, out_name = fut.result()
except BrokenProcessPool:
unresolved.append(path)
continue
except Exception as exc:
err, out_name = str(exc), ""
done += 1
self._report(path, err, out_name, done, len(paths))
return unresolved
def _run_inline(self, paths: list[str], done: int, total: int):
for path in paths:
try:
err, out_name = export_view_image(
path, max_workers=compute.default_max_workers(), **self._kw)
except Exception as exc:
err, out_name = str(exc), ""
done += 1
self._report(path, err, out_name, done, total)
def _worth_pooling(self, paths: list[str]) -> bool:
if len(paths) < 2:
return False
total = 0
for p in paths:
try:
total += os.path.getsize(p)
except OSError:
pass # unreadable files are reported by export_view_image
return total >= _BATCH_POOL_MIN_BYTES
def run(self):
paths = self._paths
n_procs = max(1, min(_BATCH_MAX_PROCS, len(paths)))
if not self._worth_pooling(paths):
self._run_inline(paths, 0, len(paths))
self.finished.emit()
return
try:
unresolved = self._run_pooled(paths, n_procs)
except Exception:
# The pool could not be created or collapsed wholesale.
unresolved = list(paths)
if unresolved:
self._run_inline(unresolved, len(paths) - len(unresolved), len(paths))
self.finished.emit()
class Ch4MaskWorker(_PooledWorker):
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for the
alignment wizard's initial threshold-mask stack.
+426
View File
@@ -0,0 +1,426 @@
"""Batch Export View as Images: does the exported PNG actually match what
the live view would show, and does the batch dispatch (menu action ->
worker -> per-file render) behave like Batch Compute's proven pattern?
sras_render.export_view_image is tested directly (no Qt) for the plumbing
that decides *what* gets rendered -- pad_factor derived per file, masked-
pixel NaN fill, per-file auto-scale, velocity scaling -- via a
draw_view_image spy rather than pixel-diffing PNGs, the same "spy on the
seam, don't inspect the rendered artifact" approach test_highlight_masked_
pixels (tests/test_gui.py) uses for the live canvas.
The GUI-dispatch half drives SrasViewerWindow._on_batch_export_images()
end-to-end with patched file dialogs, the same shape as
test_stored_cache.py's test_viewer_batch_row_average_dispatch.
"""
from pathlib import Path
from unittest.mock import patch
import numpy as np
import pytest
from PyQt6.QtCore import QEventLoop, QTimer
from PyQt6.QtWidgets import QApplication
import sras_render
from sras_compute import compute_rf_image, dc_image_mv
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile
from sras_render import export_view_image
from sras_viewer import SrasViewerWindow, VELOCITY_MODE_IDX
from sras_workers import BatchExportImagesWorker
import tools.make_test_sras as gen
_THRESHOLD_MV = 50.0
def pump(ms: int = 200):
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def wait_until(pred, timeout_ms: int = 20000, step: int = 100) -> bool:
waited = 0
while waited < timeout_ms:
if pred():
return True
pump(step)
waited += step
return pred()
# ---------------------------------------------------------------------------
# sras_render.export_view_image -- pure function, no Qt
# ---------------------------------------------------------------------------
_DEFAULT_KW = dict(
is_fft_mode=False, is_velocity=False, dc_threshold_mv=_THRESHOLD_MV,
apply_bg_sub=True, pad_factor=1, min_freq_mhz=0.0, grating_um=1.0,
cmap="viridis", auto_scale=True, vmin=0.0, vmax=1.0,
highlight_masked=False, mode_str="DC", colorbar_label="mV",
)
def _kw(**overrides):
kw = dict(_DEFAULT_KW)
kw.update(overrides)
return kw
def _spy_draw(monkeypatch):
"""Patches sras_render.draw_view_image to record the image array and
vmin/vmax/bad_color it was called with, then delegates to the real
implementation so the PNG is still written -- lets a test check *what*
export_view_image computed without depending on rendered PNG pixels."""
orig = sras_render.draw_view_image
captured = {}
def spy(ax, fig, img, extent, cmap, vmin, vmax, xlabel, ylabel, title,
colorbar_label="", cb_ticks=None, norm=None, bad_color=None):
captured["img"] = np.array(img, copy=True)
captured["vmin"] = vmin
captured["vmax"] = vmax
captured["bad_color"] = bad_color
return orig(ax, fig, img, extent, cmap, vmin, vmax, xlabel, ylabel,
title, colorbar_label, cb_ticks, norm, bad_color)
monkeypatch.setattr(sras_render, "draw_view_image", spy)
return captured
def test_export_dc_channel_writes_png(tmp_path):
path = tmp_path / "dc.sras"
gen.write(path, n_angles=2, seed=1, samples_per_frame=64)
out_dir = tmp_path / "out"
out_dir.mkdir()
err, out_name = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX, **_kw())
assert err == ""
assert out_name == f"dc_angle0_{CH_NAMES[CH4_IDX]}.png"
out_path = out_dir / out_name
assert out_path.exists() and out_path.stat().st_size > 0
assert out_path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n", "not a valid PNG"
def test_export_out_of_range_angle(tmp_path):
path = tmp_path / "short.sras"
gen.write(path, n_angles=2, seed=2, samples_per_frame=64)
out_dir = tmp_path / "out"
out_dir.mkdir()
err, out_name = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=5, ch_idx=CH4_IDX, **_kw())
assert err != "" and "2" in err, "error should mention the file's actual angle count"
assert out_name == ""
assert list(out_dir.iterdir()) == [], "no file written for a failed export"
def test_export_fft_mode_matches_compute_rf_image(tmp_path, monkeypatch):
path = tmp_path / "fft.sras"
gen.write(path, n_angles=1, seed=3, samples_per_frame=128)
out_dir = tmp_path / "out"
out_dir.mkdir()
captured = _spy_draw(monkeypatch)
err, _ = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX,
**_kw(is_fft_mode=True))
assert err == ""
sras = SrasFile(str(path))
expected = compute_rf_image(sras, 0, dc_threshold_mv=_THRESHOLD_MV,
apply_bg_sub=True, n_fft=None, min_freq_mhz=0.0)
assert np.array_equal(captured["img"], expected)
def test_export_velocity_scales_frequency(tmp_path, monkeypatch):
path = tmp_path / "vel.sras"
gen.write(path, n_angles=1, seed=4, samples_per_frame=128)
out_dir = tmp_path / "out"
out_dir.mkdir()
captured = _spy_draw(monkeypatch)
grating_um = 3.5
err, _ = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=VELOCITY_MODE_IDX,
**_kw(is_fft_mode=True, is_velocity=True, grating_um=grating_um))
assert err == ""
sras = SrasFile(str(path))
freq = compute_rf_image(sras, 0, dc_threshold_mv=_THRESHOLD_MV,
apply_bg_sub=True, n_fft=None, min_freq_mhz=0.0)
assert np.array_equal(captured["img"], freq * grating_um)
def test_pad_factor_uses_each_files_own_samples_per_frame(tmp_path, monkeypatch):
"""n_fft must be derived per file from that file's own samples_per_frame,
never a value carried over from whichever file the caller had open --
otherwise every file but one in a batch gets silently mis-padded."""
orig_draw = sras_render.draw_view_image
out_dir = tmp_path / "out"
out_dir.mkdir()
for i, spf in enumerate((64, 256)):
path = tmp_path / f"pad_{spf}.sras"
gen.write(path, n_angles=1, seed=5 + i, samples_per_frame=spf)
captured = {}
def spy(ax, fig, img, *a, __c=captured, **kw):
__c["img"] = np.array(img, copy=True)
return orig_draw(ax, fig, img, *a, **kw)
monkeypatch.setattr(sras_render, "draw_view_image", spy)
err, _ = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX,
**_kw(is_fft_mode=True, pad_factor=4))
assert err == ""
sras = SrasFile(str(path))
expected = compute_rf_image(sras, 0, dc_threshold_mv=_THRESHOLD_MV,
apply_bg_sub=True, n_fft=spf * 4,
min_freq_mhz=0.0)
assert np.array_equal(captured["img"], expected), \
f"samples_per_frame={spf}: n_fft must use this file's own value"
def test_highlight_masked_sets_nan_and_bad_color(tmp_path, monkeypatch):
path = tmp_path / "mask.sras"
gen.write(path, n_angles=1, seed=7, samples_per_frame=128)
out_dir = tmp_path / "out"
out_dir.mkdir()
sras = SrasFile(str(path))
dc4 = dc_image_mv(sras, 0, CH4_IDX)
threshold = float(np.median(dc4))
expect_masked = dc4 < threshold
assert expect_masked.any() and not expect_masked.all(), \
"fixture threshold should mask some but not all pixels"
captured = _spy_draw(monkeypatch)
err, _ = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX,
**_kw(is_fft_mode=True, dc_threshold_mv=threshold,
highlight_masked=True, mask_color="magenta"))
assert err == ""
assert captured["bad_color"] == "magenta"
# The export masks by value too (0 == the "no valid peak" sentinel, same
# rule as the viewer's _redraw_image); on this fixture every
# above-threshold pixel has a nonzero peak, so the value mask coincides
# with the DC mask and the NaN set is exactly expect_masked.
assert np.array_equal(np.isnan(captured["img"]), expect_masked)
valid_vals = captured["img"][~expect_masked]
assert not np.isnan(valid_vals).any() and (valid_vals != 0).all(), \
"fixture precondition: every valid pixel has a nonzero peak"
captured2 = _spy_draw(monkeypatch)
err, _ = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX,
**_kw(is_fft_mode=True, dc_threshold_mv=threshold, highlight_masked=False))
assert err == ""
assert captured2["bad_color"] is None
assert not np.isnan(captured2["img"]).any()
def test_auto_scale_uses_per_file_min_max(tmp_path, monkeypatch):
path = tmp_path / "scale.sras"
gen.write(path, n_angles=1, seed=8, samples_per_frame=64)
out_dir = tmp_path / "out"
out_dir.mkdir()
captured = _spy_draw(monkeypatch)
err, _ = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX,
**_kw(auto_scale=True))
assert err == ""
img = captured["img"]
assert captured["vmin"] == pytest.approx(float(np.nanmin(img)))
assert captured["vmax"] == pytest.approx(float(np.nanmax(img)))
captured2 = _spy_draw(monkeypatch)
err, _ = export_view_image(
str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX,
**_kw(auto_scale=False, vmin=-5.0, vmax=5.0))
assert err == ""
assert captured2["vmin"] == -5.0
assert captured2["vmax"] == 5.0
# ---------------------------------------------------------------------------
# GUI dispatch: SrasViewerWindow._on_batch_export_images end-to-end
# ---------------------------------------------------------------------------
def _make_window(path) -> SrasViewerWindow:
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
win._load_file(str(path))
assert wait_until(lambda: win._sras is not None), "file loaded"
assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
for a in range(win._sras.n_angles))), \
"DC precompute finished"
return win
def test_batch_export_images_writes_one_png_per_file(tmp_path):
path = tmp_path / "src.sras"
gen.write(path, n_angles=2, seed=10, samples_per_frame=64)
paths = [str(path)]
for i in range(2):
p2 = tmp_path / f"other{i}.sras"
gen.write(p2, n_angles=2, seed=20 + i, samples_per_frame=64)
paths.append(str(p2))
out_dir = tmp_path / "images"
out_dir.mkdir()
win = _make_window(path)
try:
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=(paths, "")), \
patch("sras_viewer.main_window.QFileDialog.getExistingDirectory",
return_value=str(out_dir)):
win._on_batch_export_images()
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
angle = win.spin_angle.value()
ch_name = CH_NAMES[win.combo_channel.currentIndex()]
expected_names = {f"{Path(p).stem}_angle{angle}_{ch_name}.png" for p in paths}
actual_names = {p.name for p in out_dir.iterdir()}
assert actual_names == expected_names
assert "Batch export: 3/3 image(s)" in win.statusBar().currentMessage()
finally:
win.close()
pump(200)
def test_batch_export_out_of_range_angle_reports_error_continues(tmp_path):
good_path = tmp_path / "good.sras"
short_path = tmp_path / "short.sras"
gen.write(good_path, n_angles=3, seed=30, samples_per_frame=64)
gen.write(short_path, n_angles=1, seed=31, samples_per_frame=64)
out_dir = tmp_path / "images"
out_dir.mkdir()
win = _make_window(good_path)
try:
win.spin_angle.setValue(2) # valid for good_path, out of range for short_path
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=([str(good_path), str(short_path)], "")), \
patch("sras_viewer.main_window.QFileDialog.getExistingDirectory",
return_value=str(out_dir)):
win._on_batch_export_images()
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
msg = win.statusBar().currentMessage()
assert "Batch export: 1/2 image(s)" in msg, msg
assert "1 failed" in msg, msg
assert len(list(out_dir.iterdir())) == 1, \
"the batch must not abort — the good file still exports"
finally:
win.close()
pump(200)
def test_batch_export_busy_guard_skips_dialogs(tmp_path, monkeypatch):
path = tmp_path / "busy.sras"
gen.write(path, n_angles=1, seed=40, samples_per_frame=64)
win = _make_window(path)
try:
monkeypatch.setattr(win, "_job_running", lambda key: True)
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames") as mock_dlg:
win._on_batch_export_images()
assert mock_dlg.call_count == 0, \
"the Jobs.BATCH busy guard must return before opening any dialog"
finally:
win.close()
pump(200)
def test_batch_export_ignores_aligned_view_toggle(tmp_path):
"""Aligned View is geometry specific to whichever single file the
Alignment Wizard last ran against and cannot be meaningfully applied
across a batch of different files -- _on_batch_export_images must not
read chk_aligned_view / self._alignment_result at all, regardless of
what's checked in the live view."""
path = tmp_path / "aligned.sras"
gen.write(path, n_angles=1, seed=41, samples_per_frame=64)
out_dir = tmp_path / "images"
out_dir.mkdir()
win = _make_window(path)
try:
captured_kwargs = []
orig_init = BatchExportImagesWorker.__init__
def spy_init(self, paths, **kw):
captured_kwargs.append(kw)
return orig_init(self, paths, **kw)
win.chk_aligned_view.setChecked(True)
with patch.object(BatchExportImagesWorker, "__init__", spy_init), \
patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=([str(path)], "")), \
patch("sras_viewer.main_window.QFileDialog.getExistingDirectory",
return_value=str(out_dir)):
win._on_batch_export_images()
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
assert len(captured_kwargs) == 1
assert not any("align" in k.lower() for k in captured_kwargs[0]), \
captured_kwargs[0].keys()
finally:
win.close()
pump(200)
def test_batch_export_filename_collision_note(tmp_path):
dir_a, dir_b = tmp_path / "dir_a", tmp_path / "dir_b"
dir_a.mkdir()
dir_b.mkdir()
path_a, path_b = dir_a / "dup.sras", dir_b / "dup.sras"
gen.write(path_a, n_angles=1, seed=50, samples_per_frame=64)
gen.write(path_b, n_angles=1, seed=51, samples_per_frame=64)
out_dir = tmp_path / "images"
out_dir.mkdir()
win = _make_window(path_a)
try:
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=([str(path_a), str(path_b)], "")), \
patch("sras_viewer.main_window.QFileDialog.getExistingDirectory",
return_value=str(out_dir)):
win._on_batch_export_images()
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
msg = win.statusBar().currentMessage()
assert "Batch export: 2/2 image(s)" in msg, msg
assert "collision" in msg, msg
assert len(list(out_dir.iterdir())) == 1, \
"same-stem inputs silently overwrite to one output file"
finally:
win.close()
pump(200)
def test_batch_export_does_not_modify_source_files(tmp_path):
path = tmp_path / "untouched.sras"
gen.write(path, n_angles=1, seed=60, samples_per_frame=64)
before = path.read_bytes()
out_dir = tmp_path / "images"
out_dir.mkdir()
win = _make_window(path)
try:
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=([str(path)], "")), \
patch("sras_viewer.main_window.QFileDialog.getExistingDirectory",
return_value=str(out_dir)):
win._on_batch_export_images()
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
finally:
win.close()
pump(200)
assert path.read_bytes() == before, "export must never write to the source file"
+10 -2
View File
@@ -246,8 +246,16 @@ def test_highlight_masked_pixels(ctx):
win._redraw_image(win._current_image)
shown, bad_color = calls[-1]
assert bad_color is not None, "highlight color set while checkbox is on"
assert np.array_equal(np.isnan(shown), expect_masked), \
"NaN exactly where DC4 is below threshold, nowhere else"
# The highlight masks by value too: in an FFT mode, exactly 0 is the
# "no valid peak" sentinel (DC-masked, below the min-freq floor, or an
# empty spectrum), so the NaN set is the union of the DC mask and the
# zero-valued pixels. On this fixture every above-threshold pixel has a
# nonzero peak, so the union equals the DC mask alone.
expect_nan = expect_masked | (win._current_image == 0)
assert np.array_equal(np.isnan(shown), expect_nan), \
"NaN where DC4 is below threshold or the value-0 sentinel, nowhere else"
assert np.array_equal(expect_nan, expect_masked), \
"fixture precondition: every valid pixel has a nonzero peak"
win.chk_highlight_masked.setChecked(False)
calls.clear()
+294 -2
View File
@@ -180,12 +180,18 @@ def test_cach_v1_reads_as_natural_resolution(tmp_path):
# Rewrite the tail as a genuine CACH v1 block (old header, no pad field).
v2 = SrasFile(str(path))
freq, entries = v2.precomputed_freq_mhz, list(range(v2.n_angles))
tail_offset = v2._cache_tail_offset()
payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 1, fmt.CACH_FLAG_FFT)
payload += struct.pack(fmt.SFFT_HDR_FMT_V1, fmt.SFFT_MAGIC,
fmt.SFFT_FLAG_BG_SUB, len(entries))
for a in entries:
payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes()
head = path.read_bytes()[:v2._cache_tail_offset()]
# Windows refuses to truncate a file with a live mapping (write_bytes
# opens 'wb'), and every SrasFile holds its waveform memmaps for life —
# drop the instance first. The parsed freq arrays are plain copies and
# stay usable.
del v2
head = path.read_bytes()[:tail_offset]
path.write_bytes(head + payload)
v1 = SrasFile(str(path))
@@ -493,12 +499,16 @@ def test_cach_v1_backward_compat_defaults_row_avg_n_zero(tmp_path):
v2 = SrasFile(str(path))
freq, entries = v2.precomputed_freq_mhz, list(range(v2.n_angles))
tail_offset = v2._cache_tail_offset()
payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 1, fmt.CACH_FLAG_FFT)
payload += struct.pack(fmt.SFFT_HDR_FMT_V1, fmt.SFFT_MAGIC,
fmt.SFFT_FLAG_BG_SUB, len(entries))
for a in entries:
payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes()
head = path.read_bytes()[:v2._cache_tail_offset()]
# See test_cach_v1_reads_as_natural_resolution: release the memmaps
# before write_bytes truncates, or Windows raises EINVAL.
del v2
head = path.read_bytes()[:tail_offset]
path.write_bytes(head + payload)
v1 = SrasFile(str(path))
@@ -510,6 +520,288 @@ def test_cach_v1_backward_compat_defaults_row_avg_n_zero(tmp_path):
"a v1 tail (predating this feature) can never satisfy a row-averaged request"
# ---------------------------------------------------------------------------
# Min peak frequency floor: serve-time masking, on-disk provenance, batch
# ---------------------------------------------------------------------------
def _biting_floor(stored: np.ndarray) -> float:
"""A floor that zeroes some-but-not-all of *stored*'s positive peaks:
the median distinct positive value, so pixels below it get masked and
pixels at/above it survive (the mask is a strict <)."""
positive = np.unique(stored[stored > 0])
assert len(positive) >= 2, "fixture must have varied peak frequencies"
return float(positive[len(positive) // 2])
def test_stored_image_served_with_raised_floor_masked(rig, no_fft):
"""The core of the 5 m/s bug fix: a floor above the stored one (here 0)
is re-applied when the stored image is served — pixels whose stored
peak falls below it come back as the 0.0 invalid sentinel, everything
else passes through, and no FFT runs. Both directly through
cached_rf_image and through compute_rf_image's fast path."""
assert rig.sras.precomputed_min_freq_mhz == 0.0, "batched without a floor"
stored = rig.sras.precomputed_freq_mhz[0]
floor = _biting_floor(stored)
expect = np.where(stored < floor, np.float32(0.0), stored)
img = cached_rf_image(rig.sras, 0, None, apply_bg_sub=True,
min_freq_mhz=floor)
assert img is not None, "an equal-or-higher floor is servable"
assert np.array_equal(img, expect)
assert (img == 0).any() and (img > 0).any(), \
"the floor bites some pixels but not all"
via_compute = compute_rf_image(rig.sras, 0, dc_threshold_mv=None,
apply_bg_sub=True, min_freq_mhz=floor)
assert np.array_equal(via_compute, expect), \
"compute_rf_image's fast path applies the same serve-time mask"
assert not no_fft, f"serving + masking must not run an FFT: {no_fft}"
def test_min_freq_floor_round_trips_and_gates_serving(tmp_path):
"""cache_file records the floor in the SFFT header and the accept rule
is asymmetric: an equal-or-higher request is servable, a lower one is
refused (the stored search never looked below its floor). 20.1 pins the
fixed-point kHz encoding — a float32 header field would read back as
20.10000038…, above the requested 20.1, and mismatch forever."""
path = tmp_path / "floor_roundtrip.sras"
gen.write(path, n_angles=2, seed=31, samples_per_frame=128)
floor = 20.1
assert cache_file(str(path), "fft", True, min_freq_mhz=floor) == ""
sras = SrasFile(str(path))
assert sras.precomputed_min_freq_mhz == floor, "exact fixed-point round-trip"
assert all(((img == 0) | (img >= floor)).all()
for img in sras.precomputed_freq_mhz), \
"no stored peak below the floor"
def reasons(f):
return compute.cache_mismatch_reasons(
sras, n_fft=None, apply_bg_sub=True, row_avg_n=0, min_freq_mhz=f)
assert reasons(floor) == []
assert reasons(floor + 5.0) == [], "a higher request is servable (masked)"
low = reasons(0.0)
assert low and "min-peak-freq floor" in low[0], \
"a lower request cannot be answered by the stored search"
assert cached_rf_image(sras, 0, None, apply_bg_sub=True) is None, \
"default floor-0 request refused against a floored store"
assert cached_rf_image(sras, 0, None, apply_bg_sub=True,
min_freq_mhz=floor) is not None
def test_batch_recompute_resolves_not_masks(tmp_path, no_fft):
"""Re-batching an already-cached file at a raised floor must run the
real FFT and store re-resolved peaks — never let compute_rf_image's
fast path serve the file's own stale cache back to it and bake the
masked copy in as if it were a recompute (silent, permanent data
loss: a masked pixel's true above-floor peak is unrecoverable)."""
path = tmp_path / "rebatch.sras"
gen.write(path, n_angles=2, seed=32, samples_per_frame=256)
assert cache_file(str(path), "fft", True) == ""
first = SrasFile(str(path))
n_angles = first.n_angles
# On the header's kHz grid up front (as the spinbox value would be), so
# the recorded floor reads back equal — cache_file quantizes whatever it
# is given, and this test wants that to be the identity.
floor = round(_biting_floor(first.precomputed_freq_mhz[0]) * 1000) / 1000.0
bites = [img < floor for img in first.precomputed_freq_mhz]
assert bites[0].any(), "the floor must actually bite this fixture"
# What a real floored compute gives, from a view blinded to the cache.
first.precomputed_freq_mhz = [None] * n_angles
expected = [compute_rf_image(first, a, dc_threshold_mv=None,
apply_bg_sub=True, min_freq_mhz=floor)
for a in range(n_angles)]
del first # release memmaps before cache_file rewrites the tail
no_fft.clear()
assert cache_file(str(path), "fft", True, min_freq_mhz=floor) == ""
assert no_fft, "the re-batch ran a real FFT"
after = SrasFile(str(path))
assert after.precomputed_min_freq_mhz == floor
for a in range(n_angles):
assert np.array_equal(after.precomputed_freq_mhz[a], expected[a]), \
f"angle {a}: stored image is a real floored recompute"
assert (after.precomputed_freq_mhz[a][bites[a]] >= floor).all(), \
f"angle {a}: bitten pixels re-resolved above the floor, not zeroed"
def test_min_freq_carries_forward_through_dc_write(tmp_path):
"""A later DC-only write must leave the FFT block's recorded floor
untouched, like row_avg_n and pad_factor."""
path = tmp_path / "floor_carry.sras"
gen.write(path, n_angles=2, seed=33, samples_per_frame=128)
assert cache_file(str(path), "fft", True, min_freq_mhz=75.0) == ""
assert cache_file(str(path), "dc", True) == ""
after = SrasFile(str(path))
assert after.precomputed_min_freq_mhz == 75.0, \
"floor survives a DC-only write"
def test_cach_v3_backward_compat_defaults_floor_zero(tmp_path):
"""A v3 CACH tail predates the min peak frequency floor entirely (no
min_freq_khz field) — readers must still parse it in full, treating it
as floor 0: servable as-is at floor 0, and serve-maskable at any higher
one. This is what protects existing real-world v7 caches from silently
becoming unusable after the v4 bump ships."""
path = tmp_path / "v3_floor.sras"
gen.write(path, n_angles=2, seed=34, samples_per_frame=128)
assert cache_file(str(path), "fft", True) == ""
# Rewrite the tail as a genuine CACH v3 block (no min_freq field).
v4 = SrasFile(str(path))
freq, entries = v4.precomputed_freq_mhz, list(range(v4.n_angles))
tail_offset = v4._cache_tail_offset()
payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 3, fmt.CACH_FLAG_FFT)
payload += struct.pack(fmt.SFFT_HDR_FMT_V3, fmt.SFFT_MAGIC,
fmt.SFFT_FLAG_BG_SUB, len(entries), 0, 1)
for a in entries:
payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes()
# See test_cach_v1_reads_as_natural_resolution: release the memmaps
# before write_bytes truncates, or Windows raises EINVAL.
del v4
head = path.read_bytes()[:tail_offset]
path.write_bytes(head + payload)
v3 = SrasFile(str(path))
assert v3.precomputed_min_freq_mhz == 0.0
assert v3.precomputed_pad_factor == 1 and v3.precomputed_bg_sub is True
assert all(np.array_equal(v3.precomputed_freq_mhz[a], freq[a])
for a in entries), "v3 images read back unchanged"
assert cached_rf_image(v3, 0, None, apply_bg_sub=True) is not None
floor = _biting_floor(freq[0])
served = cached_rf_image(v3, 0, None, apply_bg_sub=True,
min_freq_mhz=floor)
assert served is not None
assert np.array_equal(served,
np.where(freq[0] < floor, np.float32(0.0), freq[0]))
def test_min_freq_validation(tmp_path):
path = tmp_path / "floor_bad.sras"
gen.write(path, n_angles=1, seed=35, samples_per_frame=64)
assert cache_file(str(path), "fft", True, min_freq_mhz=-1.0), \
"negative floor must be an error, not a write"
assert cache_file(str(path), "fft", True, min_freq_mhz=float("nan")), \
"NaN floor must be an error, not a write"
sras = SrasFile(str(path))
with pytest.raises(ValueError):
sras.write_v7_cache(new_min_freq_mhz=-0.5)
def test_viewer_reapplies_floor_to_stored_images_without_computing(
rig, no_fft, monkeypatch):
"""The session-cache poisoning bug behind the '95 MHz peak but 5 m/s'
report: changing 'Min peak freq' against a stored cache used to re-file
the identical un-floored image under a key claiming the new floor — the
UI looked updated, the pixels weren't. Now the floor really is
re-applied on serve (masked, still no compute), and clearing it
restores the unmasked image, still without computing."""
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
dispatched = []
original_start = type(win)._start_compute
monkeypatch.setattr(type(win), "_start_compute",
lambda self: (dispatched.append(self.spin_angle.value()),
original_start(self))[1])
try:
win._load_file(str(rig.path))
assert wait_until(lambda: win._sras is not None), "file loaded"
assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
for a in range(rig.n_angles))), \
"DC precompute finished"
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
assert np.allclose(win._current_image, rig.fresh[0], atol=1e-3)
# Round to the spinbox's 3-decimal granularity; the chosen bin value
# still survives its own (strict-<) floor after rounding down.
floor = round(_biting_floor(rig.fresh[0]), 3)
expect = np.where(rig.fresh[0] < floor, np.float32(0.0), rig.fresh[0])
dispatched.clear()
no_fft.clear()
win.spin_min_freq_mhz.setValue(floor)
win._on_min_freq_changed()
pump(120)
assert np.allclose(win._current_image, expect, atol=1e-3), \
"raised floor re-masks the stored image on serve"
assert (win._current_image == 0).any() and (win._current_image > 0).any()
assert dispatched == [] and not no_fft, \
f"re-masked serve needs no compute (jobs={dispatched}, fft={no_fft})"
key = (0, win.spin_threshold_mv.value(), win.spin_min_freq_mhz.value())
assert key in win._fft_cache
assert np.allclose(win._fft_cache[key], expect, atol=1e-3), \
"the session cache holds the value its key claims"
win.spin_min_freq_mhz.setValue(0.0)
win._on_min_freq_changed()
pump(120)
assert np.allclose(win._current_image, rig.fresh[0], atol=1e-3), \
"clearing the floor restores the unmasked stored image"
assert dispatched == [] and not no_fft
finally:
win.close()
pump(300)
def test_viewer_batch_fft_records_the_floor(tmp_path, no_fft, monkeypatch):
"""Convert → Batch Compute FFT with a floor set: the live spinbox value
reaches cache_file, lands in the reloaded file's provenance and the
info panel, and the viewer then serves the floored cache without
recomputing — the tooltip's promised remedy, end to end."""
path = tmp_path / "floor_gui.sras"
gen.write(path, n_angles=2, seed=36, samples_per_frame=256)
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
dispatched = []
original_start = type(win)._start_compute
monkeypatch.setattr(type(win), "_start_compute",
lambda self: (dispatched.append(self.spin_angle.value()),
original_start(self))[1])
try:
win._load_file(str(path))
assert wait_until(lambda: win._sras is not None), "file loaded"
assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
for a in range(win._sras.n_angles))), \
"DC precompute finished"
floor = 100.0
win.spin_min_freq_mhz.setValue(floor)
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=([str(path)], "")):
win._on_batch_compute("fft")
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
assert wait_until(lambda: win._sras is not None
and win._sras.version == 7), "file reloaded as v7"
pump(200)
assert win._sras.precomputed_min_freq_mhz == floor, \
"the viewer's floor reached the stored provenance"
assert "floor ≥ 100 MHz" in win.lbl_frame_warn.text()
no_fft.clear()
dispatched.clear()
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
pump(120)
assert dispatched == [] and not no_fft, \
f"floored cache serves the view directly (jobs={dispatched}, fft={no_fft})"
img = win._current_image
assert ((img == 0) | (img >= floor)).all(), \
"no displayed peak below the floor"
finally:
win.close()
pump(300)
def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch, no_fft):
"""Driving the new 'Batch Compute Row-Averaged FFT and Store' action
end-to-end through the real menu handler: dialog values reach the