2 Commits

6 changed files with 56 additions and 295 deletions
+28 -77
View File
@@ -18,9 +18,7 @@ import numpy as np
import scipy.fft as scipy_fft import scipy.fft as scipy_fft
import scipy.ndimage as scipy_ndimage import scipy.ndimage as scipy_ndimage
from sras_format import ( from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile, adc_to_mv
CH1_IDX, CH3_IDX, CH4_IDX, PAD_FACTOR_MAX, SrasFile, adc_to_mv,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FFT backend # FFT backend
@@ -422,56 +420,6 @@ def dc_image_mv(sras: SrasFile, angle_idx: int, ch_idx: int,
*sras.cal(ch_idx)) *sras.cal(ch_idx))
def cached_rf_image(sras: SrasFile, angle_idx: int,
dc_threshold_mv: float | None,
apply_bg_sub: bool = True,
n_fft: int | None = None,
dc4_mv: np.ndarray | None = None,
allow_dc_recompute: bool = True) -> np.ndarray | None:
"""The stored peak-frequency image for one angle (v5 PREC or v7 CACH),
masked and ready to display — or None if no stored image can serve these
settings and a real FFT is needed.
Stored images are unmasked, so they serve any threshold — but they carry
one bg-sub state and one zero-padding, and a view asking for either of the
others is asking for different numbers, not a different rendering of the
same ones. Padding is the sharp edge: a pad-10 view resolves peaks on a
10x finer bin grid, so handing back a pad-1 image would quietly undo the
setting. v5 PREC and CACH v1 tails record no pad and are natural
resolution by construction.
Cheap enough to call on the GUI thread — a copy plus a comparison —
*except* when the CH4 mask has to be read from disk to build it. Pass
*allow_dc_recompute* False to return None in that case instead, and leave
the whole-channel read to a worker.
"""
cached_freq = sras.precomputed_freq_mhz[angle_idx]
# n_fft None means "no padding", i.e. exactly samples_per_frame points.
want_n_fft = n_fft if n_fft is not None else sras.samples_per_frame
if (cached_freq is None
or want_n_fft != sras.precomputed_pad_factor * sras.samples_per_frame
or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None)):
return None
if dc_threshold_mv is None:
return cached_freq.copy()
# DC4 mask, in priority order: stored DC block, caller-supplied image,
# or a fresh (cheap — no FFT) recompute.
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
if dc4_img is None:
dc4_img = dc4_mv
if dc4_img is None:
if not allow_dc_recompute:
return None
dc4_img = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX),
*sras.cal(CH4_IDX))
freq_img = cached_freq.copy()
freq_img[dc4_img < dc_threshold_mv] = 0.0
return freq_img
def compute_rf_image(sras: SrasFile, angle_idx: int, def compute_rf_image(sras: SrasFile, angle_idx: int,
dc_threshold_mv: float | None, dc_threshold_mv: float | None,
apply_bg_sub: bool = True, apply_bg_sub: bool = True,
@@ -507,20 +455,28 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
Fast path: if the file has a precomputed peak-frequency image for this Fast path: if the file has a precomputed peak-frequency image for this
angle (v5 PREC or v7 CACH), zero-padding is off, and the bg-sub flag angle (v5 PREC or v7 CACH), zero-padding is off, and the bg-sub flag
matches, the stored image is used directly — no FFT is run. That test is matches, the stored image is used directly — no FFT is run.
cached_rf_image, which the viewer also applies before it ever dispatches
a compute at all.
""" """
# ---- Fast path: precomputed image (v5 PREC or v7 CACH) ----------------
cached = cached_rf_image(sras, angle_idx, dc_threshold_mv,
apply_bg_sub=apply_bg_sub, n_fft=n_fft,
dc4_mv=dc4_mv)
if cached is not None:
return cached
# ---- Chunked FFT path --------------------------------------------------
n_rows, n_frames = sras.image_shape(angle_idx) n_rows, n_frames = sras.image_shape(angle_idx)
data = sras.data[angle_idx] data = sras.data[angle_idx]
# ---- Fast path: precomputed image (v5 PREC or v7 CACH) ----------------
cached_freq = sras.precomputed_freq_mhz[angle_idx]
if (cached_freq is not None
and n_fft is None # no custom zero-padding
and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None)):
freq_img = cached_freq.copy()
if dc_threshold_mv is not None:
# DC4 mask, in priority order: already-cached DC block, caller-
# supplied image, or a fresh (cheap — no FFT) recompute.
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
if dc4_img is None:
dc4_img = dc4_mv if dc4_mv is not None else adc_to_mv(
compute_dc_image(sras, angle_idx, CH4_IDX), *sras.cal(CH4_IDX))
freq_img[dc4_img < dc_threshold_mv] = 0.0
return freq_img
# ---- Chunked FFT path --------------------------------------------------
exact = exact or _FFT_EXACT_ENV exact = exact or _FFT_EXACT_ENV
spf = sras.samples_per_frame spf = sras.samples_per_frame
n_len = n_fft if n_fft is not None else spf n_len = n_fft if n_fft is not None else spf
@@ -633,19 +589,17 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def cache_file(path: str, mode: str, apply_bg_sub: bool, def cache_file(path: str, mode: str, apply_bg_sub: bool,
fft_backend: str = "scipy", max_workers: int = 0, fft_backend: str = "scipy", max_workers: int = 0) -> str:
pad_factor: int = 1) -> str:
"""Compute and store DC or FFT images for every angle of one file, """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. converting v6 → v7 in place. Returns "" on success or an error message.
Module-level and picklable so it can run in a ProcessPoolExecutor. The Module-level and picklable so it can run in a ProcessPoolExecutor. The
FFT backend, worker cap and pad factor are passed explicitly because FFT backend and worker cap are passed explicitly because module globals
module globals do not survive a spawn. do not survive a spawn.
*pad_factor* is stored alongside the images: a cache is only usable by a The stored FFT cache is always natural-resolution (pad 1): the v7 SFFT
view asking for the same padding, so caching at the viewer's own setting block records no pad factor, and padded views compute live fast enough
is the difference between a batch that pays off and one that can never be (see _peak_bins_zoom) that caching them is not worth a format change.
read back (see cached_rf_image).
""" """
global _MAX_WORKERS global _MAX_WORKERS
try: try:
@@ -676,18 +630,15 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4) sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
else: else:
effective_bg = apply_bg_sub and sras.background is not None effective_bg = apply_bg_sub and sras.background is not None
pad = max(1, min(PAD_FACTOR_MAX, int(pad_factor)))
n_fft = None if pad <= 1 else sras.samples_per_frame * pad
# dc_threshold_mv=None: store unmasked images and mask at display # dc_threshold_mv=None: store unmasked images and mask at display
# time (same convention as v5's PREC block). Skipping the mask # time (same convention as v5's PREC block). Skipping the mask
# also skips reading CH4 entirely. The FFT path parallelises # also skips reading CH4 entirely. The FFT path parallelises
# internally over blocks, so angles run one at a time with the # internally over blocks, so angles run one at a time with the
# full budget. # full budget.
freq = [compute_rf_image(sras, a, dc_threshold_mv=None, freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
apply_bg_sub=effective_bg, n_fft=n_fft) apply_bg_sub=effective_bg)
for a in range(n)] for a in range(n)]
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg, sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg)
new_pad_factor=pad)
return "" return ""
except Exception as exc: except Exception as exc:
return str(exc) return str(exc)
+13 -42
View File
@@ -60,12 +60,7 @@ PREC_FLAG_BG_SUB = 0x01
CACH_MAGIC = b"CACH" CACH_MAGIC = b"CACH"
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT) CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
# v2 added the SFFT pad factor. Writers always emit v2; readers still accept CACH_VERSION = 1
# v1, whose FFT block is natural-resolution by construction. An older reader
# meeting a v2 tail rejects the whole section on the version check and
# recomputes — no cache, but no misreading either.
CACH_VERSION = 2
CACH_VERSION_MIN = 1
CACH_FLAG_DC = 0x01 CACH_FLAG_DC = 0x01
CACH_FLAG_FFT = 0x02 CACH_FLAG_FFT = 0x02
@@ -74,12 +69,9 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT) SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
SFFT_MAGIC = b"SFFT" SFFT_MAGIC = b"SFFT"
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored
SFFT_HDR_FMT = ">4sBHH" # magic, flags, n_stored, pad_factor
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT) SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
SFFT_FLAG_BG_SUB = 0x01 SFFT_FLAG_BG_SUB = 0x01
# The viewer clamps its pad factor to this, and the field is a uint16.
PAD_FACTOR_MAX = 256
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC) # Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2 CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
@@ -177,9 +169,7 @@ class SrasFile:
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``, ``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
per angle, ``None`` where that angle was never stored) regardless of per angle, ``None`` where that angle was never stored) regardless of
source version. The settings the FFT images were computed under travel source version.
with them as ``precomputed_bg_sub`` / ``precomputed_pad_factor``, since a
stored image is only usable by a view asking for the same two.
""" """
def __init__(self, path: str): def __init__(self, path: str):
@@ -236,9 +226,6 @@ class SrasFile:
self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles
self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles
self.precomputed_bg_sub: bool = False self.precomputed_bg_sub: bool = False
# Zero-padding the stored FFT images were computed at; 1 = natural
# resolution, which is all a v5 PREC or CACH v1 tail can hold.
self.precomputed_pad_factor: int = 1
def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None: def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""A stored DC image (already in mV) for (angle, channel), or None.""" """A stored DC image (already in mV) for (angle, channel), or None."""
@@ -509,18 +496,16 @@ class SrasFile:
return end return end
def _read_cache_block(self, f, hdr_fmt: str, magic: bytes, def _read_cache_block(self, f, hdr_fmt: str, magic: bytes,
stores: list[list]) -> tuple | None: stores: list[list]) -> int | None:
"""Read one CACH sub-block header, then its per-angle image entries """Read one CACH sub-block header, then its per-angle image entries
into *stores* (one list per image the block stores per angle). into *stores* (one list per image the block stores per angle).
Every sub-block header is (magic, flags, n_stored, *extras). Returns Returns the header's flags byte, or None if the block is malformed.
everything after the magic, so a caller that has extras knows how to
read them, or None if the block is malformed.
""" """
raw = f.read(struct.calcsize(hdr_fmt)) raw = f.read(struct.calcsize(hdr_fmt))
if len(raw) < struct.calcsize(hdr_fmt): if len(raw) < struct.calcsize(hdr_fmt):
return None return None
block_magic, flags, n_stored, *extras = struct.unpack(hdr_fmt, raw) block_magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
if block_magic != magic: if block_magic != magic:
return None return None
for _ in range(n_stored): for _ in range(n_stored):
@@ -530,7 +515,7 @@ class SrasFile:
shape = self.image_shape(angle_idx) shape = self.image_shape(angle_idx)
for store in stores: for store in stores:
store[angle_idx] = _read_f32_image(f, shape) store[angle_idx] = _read_f32_image(f, shape)
return (flags, *extras) return flags
def _parse_cach_section(self, offset: int): def _parse_cach_section(self, offset: int):
"""Parse the v7 CACH tail that holds precomputed DC/FFT images.""" """Parse the v7 CACH tail that holds precomputed DC/FFT images."""
@@ -540,8 +525,7 @@ class SrasFile:
if len(header_raw) < CACH_HDR_SIZE: if len(header_raw) < CACH_HDR_SIZE:
return return
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw) magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
if (magic != CACH_MAGIC if magic != CACH_MAGIC or cach_version != CACH_VERSION:
or not CACH_VERSION_MIN <= cach_version <= CACH_VERSION):
return return
if block_flags & CACH_FLAG_DC: if block_flags & CACH_FLAG_DC:
@@ -551,23 +535,17 @@ class SrasFile:
return return
if block_flags & CACH_FLAG_FFT: if block_flags & CACH_FLAG_FFT:
# v1 has no pad field: those images are natural-resolution. flags = self._read_cache_block(
hdr_fmt = SFFT_HDR_FMT if cach_version >= 2 else SFFT_HDR_FMT_V1 f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz])
header = self._read_cache_block( if flags is None:
f, hdr_fmt, SFFT_MAGIC, [self.precomputed_freq_mhz])
if header is None:
return return
flags, *extras = header
self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB) self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB)
self.precomputed_pad_factor = (
min(max(1, extras[0]), PAD_FACTOR_MAX) if extras else 1)
def write_v7_cache(self, *, def write_v7_cache(self, *,
new_dc3_mv: list[np.ndarray | None] | None = None, new_dc3_mv: list[np.ndarray | None] | None = None,
new_dc4_mv: list[np.ndarray | None] | None = None, new_dc4_mv: list[np.ndarray | None] | None = None,
new_freq_mhz: list[np.ndarray | None] | None = None, new_freq_mhz: list[np.ndarray | None] | None = None,
new_bg_sub: bool | None = None, new_bg_sub: bool | None = None):
new_pad_factor: int | None = None):
"""Store computed DC and/or FFT images into this file's CACH tail, """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 in place, converting a v6 source to v7 (or updating an existing v7
file). Only the block(s) passed in are recomputed; whichever block file). Only the block(s) passed in are recomputed; whichever block
@@ -587,11 +565,6 @@ class SrasFile:
final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
final_pad = (new_pad_factor if new_pad_factor is not None
else self.precomputed_pad_factor)
if not 1 <= final_pad <= PAD_FACTOR_MAX:
raise ValueError(
f"pad factor {final_pad} outside 1..{PAD_FACTOR_MAX}")
dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None] 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] fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
@@ -611,8 +584,7 @@ class SrasFile:
if fft_entries: if fft_entries:
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0 fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries))
len(fft_entries), final_pad)
for a in fft_entries: for a in fft_entries:
payload += struct.pack(">H", a) payload += struct.pack(">H", a)
payload += final_freq[a].astype(">f4").tobytes() payload += final_freq[a].astype(">f4").tobytes()
@@ -640,7 +612,6 @@ class SrasFile:
self.precomputed_dc4_mv = final_dc4 self.precomputed_dc4_mv = final_dc4
self.precomputed_freq_mhz = final_freq self.precomputed_freq_mhz = final_freq
self.precomputed_bg_sub = final_bg_sub self.precomputed_bg_sub = final_bg_sub
self.precomputed_pad_factor = final_pad
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Axes helpers # Axes helpers
+1 -18
View File
@@ -48,8 +48,7 @@ class FftOptionsDialog(QDialog):
current_pad_factor: int, current_pad_factor: int,
samples_per_frame: int | None, samples_per_frame: int | None,
sample_rate_hz: float | None, sample_rate_hz: float | None,
grating_um: float, grating_um: float):
cached_pad_factor: int | None = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("FFT Options") self.setWindowTitle("FFT Options")
self.setModal(True) self.setModal(True)
@@ -58,10 +57,6 @@ class FftOptionsDialog(QDialog):
self._samples_per_frame = samples_per_frame self._samples_per_frame = samples_per_frame
self._sample_rate_hz = sample_rate_hz self._sample_rate_hz = sample_rate_hz
self._grating_um = grating_um self._grating_um = grating_um
# The pad the open file's stored FFT cache was computed at, if it has
# one — picking anything else here makes that cache unreadable, which
# is worth saying before Apply rather than after.
self._cached_pad_factor = cached_pad_factor
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -114,11 +109,6 @@ class FftOptionsDialog(QDialog):
lbl.setStyleSheet(_CSS_HINT) lbl.setStyleSheet(_CSS_HINT)
zl.addWidget(lbl) zl.addWidget(lbl)
self._lbl_cache = QLabel()
self._lbl_cache.setWordWrap(True)
self._lbl_cache.setStyleSheet(_CSS_WARN)
zl.addWidget(self._lbl_cache)
layout.addWidget(grp_zp) layout.addWidget(grp_zp)
# ---- Buttons --------------------------------------------------- # ---- Buttons ---------------------------------------------------
@@ -136,13 +126,6 @@ class FftOptionsDialog(QDialog):
sr = self._sample_rate_hz sr = self._sample_rate_hz
pad = self._spin_pad.value() pad = self._spin_pad.value()
self._lbl_cache.setText(
"" if self._cached_pad_factor in (None, pad) else
f"! This file's stored FFT cache was computed at pad "
f"{self._cached_pad_factor}x, so at {pad}x it can't be used and "
f"CH1/Velocity will recompute. Re-run Convert -> Batch Compute FFT "
f"to cache at {pad}x.")
if spf is None or sr is None: if spf is None or sr is None:
self._lbl_nfft.setText("Load a file to preview FFT parameters.") self._lbl_nfft.setText("Load a file to preview FFT parameters.")
self._lbl_freq_res.setText("") self._lbl_freq_res.setText("")
+9 -101
View File
@@ -79,15 +79,11 @@ class SrasViewerWindow(QMainWindow):
# Display-only settings (colormap, grating) never trigger a # Display-only settings (colormap, grating) never trigger a
# recompute — they're applied to cached data on redraw. DC images # recompute — they're applied to cached data on redraw. DC images
# (CH3/CH4) are cheap: they come straight from the file's stored # (CH3/CH4) are cheap and precomputed for every angle in the
# cache when Batch Compute DC has been run for it (see # background right after load. CH1/Velocity FFT images are
# _stored_dc_image), and are otherwise precomputed for every angle # computed lazily (with a progress popup) the first time an
# in the background right after load. CH1/Velocity FFT images come # angle/threshold combination is viewed — using the cached DC4
# from the file's own stored cache when Batch Compute FFT has been # image to skip the FFT entirely for masked-out pixels — and
# run for it (see _stored_fft_image), and are otherwise 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. Either way the result is
# cached per (angle, bg_sub, n_fft, threshold) so revisiting the # cached per (angle, bg_sub, n_fft, threshold) so revisiting the
# same combination is free. # same combination is free.
self._dc_cache: dict[tuple[int, int], np.ndarray] = {} self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
@@ -223,14 +219,7 @@ class SrasViewerWindow(QMainWindow):
self.spin_angle.setRange(0, 0) self.spin_angle.setRange(0, 0)
self.spin_angle.setEnabled(False) self.spin_angle.setEnabled(False)
self.spin_angle.setMinimumWidth(64) self.spin_angle.setMinimumWidth(64)
# Keyboard tracking off is what makes valueChanged safe to act on: it self.spin_angle.editingFinished.connect(self._on_view_changed)
# stops the signal firing per keystroke, so typing "12" is one change
# to angle 12 (on Return or focus-out) rather than a trip to angle 1
# first. Arrow clicks and the wheel still emit immediately, which
# editingFinished alone did not — that is why stepping the angle used
# to leave the plot on the previous one until the box lost focus.
self.spin_angle.setKeyboardTracking(False)
self.spin_angle.valueChanged.connect(self._on_view_changed)
self.lbl_angle_deg = QLabel("—") self.lbl_angle_deg = QLabel("—")
angle_field = QWidget() angle_field = QWidget()
ar = QHBoxLayout(angle_field) ar = QHBoxLayout(angle_field)
@@ -609,32 +598,12 @@ class SrasViewerWindow(QMainWindow):
n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None) n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None)
if n_dc or n_fft: if n_dc or n_fft:
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)" bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
pad_note = (f", pad {s.precomputed_pad_factor}x"
if n_fft and s.precomputed_pad_factor > 1 else "")
notes.append( notes.append(
f"Cached images: DC {n_dc}/{s.n_angles} angles, " f"Cached images: DC {n_dc}/{s.n_angles} angles, "
f"FFT {n_fft}/{s.n_angles} angles" f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''} "
f"{bg_note + pad_note if n_fft else ''} "
"— display is instant for cached angles") "— display is instant for cached angles")
elif s.version == 7: elif s.version == 7:
notes.append("v7 format: no cache blocks stored yet") notes.append("v7 format: no cache blocks stored yet")
# A stored FFT cache the current settings can't read back is the one
# failure here with no other symptom: the images are right there in
# the file and every angle still recomputes. Say why.
if n_fft:
reasons = []
if s.precomputed_pad_factor != self._fft_pad_factor:
reasons.append(f"stored at pad {s.precomputed_pad_factor}x, "
f"FFT Options is set to {self._fft_pad_factor}x")
if s.precomputed_bg_sub != (self.chk_bg_sub.isChecked()
and s.background is not None):
reasons.append("stored with bg-sub "
f"{'on' if s.precomputed_bg_sub else 'off'}")
if reasons:
notes.append(f"! Cached FFT unusable as configured ({'; '.join(reasons)})"
" — CH1/Velocity will recompute. Re-run Convert -> "
"Batch Compute FFT to cache at the current settings.")
self.lbl_frame_warn.setText("\n".join(notes)) self.lbl_frame_warn.setText("\n".join(notes))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -687,9 +656,6 @@ class SrasViewerWindow(QMainWindow):
# Background subtraction changes the FFT input, so it genuinely # Background subtraction changes the FFT input, so it genuinely
# invalidates the cached raw FFT (the cache key includes it) — # invalidates the cached raw FFT (the cache key includes it) —
# _refresh_display() recomputes only on a miss for the new state. # _refresh_display() recomputes only on a miss for the new state.
# It can also decide whether the file's stored cache is readable, so
# the info panel's verdict on that has to be redrawn with it.
self._update_scan_info_labels()
if self._is_fft_mode(): if self._is_fft_mode():
self._refresh_display() self._refresh_display()
@@ -872,14 +838,6 @@ class SrasViewerWindow(QMainWindow):
return None return None
return self._sras.samples_per_frame * self._fft_pad_factor return self._sras.samples_per_frame * self._fft_pad_factor
def _cached_fft_pad_factor(self) -> int | None:
"""The pad factor the open file's stored FFT images were computed at,
or None if it has none stored."""
if self._sras is None or not any(x is not None
for x in self._sras.precomputed_freq_mhz):
return None
return self._sras.precomputed_pad_factor
def _is_fft_mode(self) -> bool: def _is_fft_mode(self) -> bool:
"""Is the selected channel an FFT-derived (CH1/Velocity) mode?""" """Is the selected channel an FFT-derived (CH1/Velocity) mode?"""
return (self._sras is not None return (self._sras is not None
@@ -919,45 +877,6 @@ class SrasViewerWindow(QMainWindow):
self._aligned_cache[key] = cached self._aligned_cache[key] = cached
return cached return cached
def _stored_fft_image(self, angle_idx: int) -> np.ndarray | None:
"""The file's own batch-computed FFT image for this angle, masked for
the current threshold — or None if it can't serve the current settings.
Batch Compute FFT stores a peak-frequency image per angle in the file
itself, and the whole point of paying for that once is that display is
then instant. Without this check the viewer only ever found the stored
image deep inside ComputeWorker, so every angle change after a batch
still dispatched a background job behind a "Computing FFT…" popup for
an image that was already sitting on disk.
"""
img = compute.cached_rf_image(
self._sras, angle_idx,
dc_threshold_mv=self.spin_threshold_mv.value(),
apply_bg_sub=self.chk_bg_sub.isChecked(),
n_fft=self._current_n_fft(),
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
# Masking is a copy plus a comparison, but *reading* CH4 to build
# the mask is a whole-channel read — that case belongs on a worker,
# so it returns None here and falls through to _start_compute.
allow_dc_recompute=False)
if img is not None:
self._fft_cache[self._fft_cache_key(angle_idx)] = img
return img
def _stored_dc_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""The file's own batch-computed DC image for (angle, channel), if it
has one — the CH3/CH4 half of _stored_fft_image.
The worker this saves would have handed back the very same stored
array (dc_image_mv prefers it over recomputing), so all it ever cost
was a thread and a progress popup — but that is exactly what made
"Batch Compute DC and Store" look like it had done nothing.
"""
img = self._sras.cached_dc_mv(angle_idx, ch_idx)
if img is not None:
self._dc_cache[(angle_idx, ch_idx)] = img
return img
def _refresh_display(self): def _refresh_display(self):
"""Show the image for the current angle/channel/threshold, using """Show the image for the current angle/channel/threshold, using
cached data whenever possible and only falling back to a background cached data whenever possible and only falling back to a background
@@ -969,16 +888,12 @@ class SrasViewerWindow(QMainWindow):
if ch_idx in CH1_DERIVED_MODES: if ch_idx in CH1_DERIVED_MODES:
raw = self._fft_cache.get(self._fft_cache_key(angle_idx)) raw = self._fft_cache.get(self._fft_cache_key(angle_idx))
if raw is None:
raw = self._stored_fft_image(angle_idx)
if raw is not None: if raw is not None:
self._show_image_now(self._scale_for_display(raw, ch_idx), self._show_image_now(self._scale_for_display(raw, ch_idx),
angle_idx, ch_idx) angle_idx, ch_idx)
return return
else: else:
cached = self._dc_cache.get((angle_idx, ch_idx)) cached = self._dc_cache.get((angle_idx, ch_idx))
if cached is None:
cached = self._stored_dc_image(angle_idx, ch_idx)
if cached is not None: if cached is not None:
self._show_image_now(cached, angle_idx, ch_idx) self._show_image_now(cached, angle_idx, ch_idx)
return return
@@ -1248,11 +1163,7 @@ class SrasViewerWindow(QMainWindow):
return return
self._batch_errors = [] self._batch_errors = []
# Cache at the pad factor currently configured, not a fixed pad 1 — worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked())
# a cache stored at any other padding is one this viewer can never
# read back (see _stored_fft_image).
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(),
self._fft_pad_factor)
started = self._run_worker( started = self._run_worker(
Jobs.BATCH, worker, Jobs.BATCH, worker,
connect=( connect=(
@@ -1427,7 +1338,6 @@ class SrasViewerWindow(QMainWindow):
samples_per_frame=self._sras.samples_per_frame if self._sras else None, samples_per_frame=self._sras.samples_per_frame if self._sras else None,
sample_rate_hz=self._sras.sample_rate_hz if self._sras else None, sample_rate_hz=self._sras.sample_rate_hz if self._sras else None,
grating_um=self.spin_grating_um.value(), grating_um=self.spin_grating_um.value(),
cached_pad_factor=self._cached_fft_pad_factor(),
) )
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@@ -1437,9 +1347,7 @@ class SrasViewerWindow(QMainWindow):
self._settings.setValue("fft/pad_factor", self._fft_pad_factor) self._settings.setValue("fft/pad_factor", self._fft_pad_factor)
# Pad factor changes the FFT bin count, so it genuinely invalidates # Pad factor changes the FFT bin count, so it genuinely invalidates
# the cached raw FFT (part of the cache key) — _refresh_display() # the cached raw FFT (part of the cache key) — _refresh_display()
# recomputes only on a cache miss. It also decides whether the file's # recomputes only on a cache miss.
# stored cache can be read back at all, which the info panel reports.
self._update_scan_info_labels()
if self._is_fft_mode(): if self._is_fft_mode():
self._refresh_display() self._refresh_display()
+4 -11
View File
@@ -189,10 +189,7 @@ class BatchCacheWorker(QObject):
*mode* is ``"dc"`` (CH3/CH4 mean images) or ``"fft"`` (CH1 peak-frequency *mode* is ``"dc"`` (CH3/CH4 mean images) or ``"fft"`` (CH1 peak-frequency
images, unmasked — masking is applied at display time, same as v5's PREC images, unmasked — masking is applied at display time, same as v5's PREC
convention). FFT images are computed and stored at *pad_factor*, the convention).
viewer's own zero-padding setting: a stored image only serves a view
asking for the same padding, so caching at any other one produces a file
the viewer will never read back.
Files are processed one per subprocess: they are fully independent, each 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 opens its own memmap and writes only its own bytes, and only path strings
@@ -204,13 +201,11 @@ class BatchCacheWorker(QObject):
file_done = pyqtSignal(str, str) file_done = pyqtSignal(str, str)
finished = pyqtSignal() finished = pyqtSignal()
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool, def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool):
pad_factor: int = 1):
super().__init__() super().__init__()
self._paths = paths self._paths = paths
self._mode = mode self._mode = mode
self._apply_bg_sub = apply_bg_sub self._apply_bg_sub = apply_bg_sub
self._pad_factor = pad_factor
def _report(self, path: str, err: str, done: int, total: int): def _report(self, path: str, err: str, done: int, total: int):
self.file_done.emit(path, err) self.file_done.emit(path, err)
@@ -235,8 +230,7 @@ class BatchCacheWorker(QObject):
with ProcessPoolExecutor(max_workers=n_procs) as executor: with ProcessPoolExecutor(max_workers=n_procs) as executor:
futures = { futures = {
executor.submit(cache_file, p, self._mode, self._apply_bg_sub, executor.submit(cache_file, p, self._mode, self._apply_bg_sub,
compute.get_fft_backend(), per_proc_workers, compute.get_fft_backend(), per_proc_workers): p
self._pad_factor): p
for p in paths for p in paths
} }
for fut in as_completed(futures): for fut in as_completed(futures):
@@ -260,8 +254,7 @@ class BatchCacheWorker(QObject):
try: try:
err = cache_file(path, self._mode, self._apply_bg_sub, err = cache_file(path, self._mode, self._apply_bg_sub,
compute.get_fft_backend(), compute.get_fft_backend(),
compute.default_max_workers(), compute.default_max_workers())
self._pad_factor)
except Exception as exc: except Exception as exc:
err = str(exc) err = str(exc)
done += 1 done += 1
+1 -46
View File
@@ -94,18 +94,11 @@ def test_dc_precompute_all_angles(ctx):
def test_angle_switching_from_cache(ctx): def test_angle_switching_from_cache(ctx):
"""Setting the angle must redraw on its own — no nudge from the test.
The spinbox used to be wired to editingFinished alone, so stepping it with
the arrows changed the number and left the plot on the previous angle until
the box happened to lose focus.
"""
win, s = ctx.win, ctx.s win, s = ctx.win, ctx.s
for a in range(s.n_angles): for a in range(s.n_angles):
win.spin_angle.setValue(a) win.spin_angle.setValue(a)
win._on_view_changed()
pump(60) pump(60)
assert win._current_angle == a, \
f"angle {a} redrawn from the spinbox alone, showing {win._current_angle}"
expected = win._sras.image_shape(a) expected = win._sras.image_shape(a)
assert win._current_image.shape == expected, \ assert win._current_image.shape == expected, \
f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}" f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}"
@@ -113,44 +106,6 @@ def test_angle_switching_from_cache(ctx):
"no compute job needed for cached DC angles" "no compute job needed for cached DC angles"
def test_angle_steps_and_typing(ctx):
"""The arrows step and redraw one angle at a time; typing commits once,
without a stop at every intermediate value."""
win, s = ctx.win, ctx.s
assert s.n_angles > 2, "fixture needs several angles"
win.spin_angle.setValue(0)
pump(60)
seen = []
win.spin_angle.valueChanged.connect(seen.append)
try:
win.spin_angle.stepUp()
pump(60)
assert win._current_angle == 1, f"arrow stepped to 1, showing {win._current_angle}"
win.spin_angle.stepDown()
pump(60)
assert win._current_angle == 0, f"arrow stepped to 0, showing {win._current_angle}"
# Typing must not redraw per keystroke — that is what editingFinished
# bought and what keyboard tracking has to keep buying.
seen.clear()
last = s.n_angles - 1
win.spin_angle.lineEdit().selectAll()
QTest.keyClicks(win.spin_angle.lineEdit(), str(last))
pump(30)
assert seen == [] and win._current_angle == 0, \
f"half-typed text must not redraw ({seen}, showing {win._current_angle})"
QTest.keyClick(win.spin_angle, Qt.Key.Key_Return)
pump(60)
assert seen == [last], f"committed exactly once on Return: {seen}"
assert win._current_angle == last
finally:
win.spin_angle.valueChanged.disconnect(seen.append)
win.spin_angle.clearFocus()
win.spin_angle.setValue(0)
pump(60)
def test_channel_switching(ctx): def test_channel_switching(ctx):
win = ctx.win win = ctx.win
win.spin_angle.setValue(0) win.spin_angle.setValue(0)