Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a8187138f |
+76
-27
@@ -18,7 +18,9 @@ import numpy as np
|
||||
import scipy.fft as scipy_fft
|
||||
import scipy.ndimage as scipy_ndimage
|
||||
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile, adc_to_mv
|
||||
from sras_format import (
|
||||
CH1_IDX, CH3_IDX, CH4_IDX, PAD_FACTOR_MAX, SrasFile, adc_to_mv,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FFT backend
|
||||
@@ -420,6 +422,56 @@ def dc_image_mv(sras: SrasFile, angle_idx: int, ch_idx: int,
|
||||
*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,
|
||||
dc_threshold_mv: float | None,
|
||||
apply_bg_sub: bool = True,
|
||||
@@ -455,28 +507,20 @@ 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), zero-padding is off, and the bg-sub flag
|
||||
matches, the stored image is used directly — no FFT is run.
|
||||
matches, the stored image is used directly — no FFT is run. That test is
|
||||
cached_rf_image, which the viewer also applies before it ever dispatches
|
||||
a compute at all.
|
||||
"""
|
||||
n_rows, n_frames = sras.image_shape(angle_idx)
|
||||
data = sras.data[angle_idx]
|
||||
|
||||
# ---- Fast path: precomputed image (v5 PREC or v7 CACH) ----------------
|
||||
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
||||
if (cached_freq is not None
|
||||
and n_fft is None # no custom zero-padding
|
||||
and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None)):
|
||||
freq_img = cached_freq.copy()
|
||||
if dc_threshold_mv is not None:
|
||||
# DC4 mask, in priority order: already-cached DC block, caller-
|
||||
# supplied image, or a fresh (cheap — no FFT) recompute.
|
||||
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
||||
if dc4_img is None:
|
||||
dc4_img = dc4_mv if dc4_mv is not None else adc_to_mv(
|
||||
compute_dc_image(sras, angle_idx, CH4_IDX), *sras.cal(CH4_IDX))
|
||||
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
||||
return freq_img
|
||||
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)
|
||||
data = sras.data[angle_idx]
|
||||
exact = exact or _FFT_EXACT_ENV
|
||||
spf = sras.samples_per_frame
|
||||
n_len = n_fft if n_fft is not None else spf
|
||||
@@ -589,17 +633,19 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
fft_backend: str = "scipy", max_workers: int = 0) -> str:
|
||||
fft_backend: str = "scipy", max_workers: int = 0,
|
||||
pad_factor: int = 1) -> 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.
|
||||
|
||||
Module-level and picklable so it can run in a ProcessPoolExecutor. The
|
||||
FFT backend and worker cap are passed explicitly because module globals
|
||||
do not survive a spawn.
|
||||
FFT backend, worker cap and pad factor are passed explicitly because
|
||||
module globals do not survive a spawn.
|
||||
|
||||
The stored FFT cache is always natural-resolution (pad 1): the v7 SFFT
|
||||
block records no pad factor, and padded views compute live fast enough
|
||||
(see _peak_bins_zoom) that caching them is not worth a format change.
|
||||
*pad_factor* is stored alongside the images: a cache is only usable by a
|
||||
view asking for the same padding, so caching at the viewer's own setting
|
||||
is the difference between a batch that pays off and one that can never be
|
||||
read back (see cached_rf_image).
|
||||
"""
|
||||
global _MAX_WORKERS
|
||||
try:
|
||||
@@ -630,15 +676,18 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
|
||||
else:
|
||||
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
|
||||
# time (same convention as v5's PREC block). Skipping the mask
|
||||
# also skips reading CH4 entirely. The FFT path parallelises
|
||||
# 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)
|
||||
apply_bg_sub=effective_bg, n_fft=n_fft)
|
||||
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 ""
|
||||
except Exception as exc:
|
||||
return str(exc)
|
||||
|
||||
+42
-13
@@ -60,7 +60,12 @@ PREC_FLAG_BG_SUB = 0x01
|
||||
CACH_MAGIC = b"CACH"
|
||||
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
|
||||
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
|
||||
CACH_VERSION = 1
|
||||
# v2 added the SFFT pad factor. Writers always emit v2; readers still accept
|
||||
# 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_FFT = 0x02
|
||||
|
||||
@@ -69,9 +74,12 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored
|
||||
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
|
||||
|
||||
SFFT_MAGIC = b"SFFT"
|
||||
SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored
|
||||
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored
|
||||
SFFT_HDR_FMT = ">4sBHH" # magic, flags, n_stored, pad_factor
|
||||
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
|
||||
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)
|
||||
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
|
||||
@@ -169,7 +177,9 @@ class SrasFile:
|
||||
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
|
||||
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.
|
||||
source version. The settings the FFT images were computed under travel
|
||||
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):
|
||||
@@ -226,6 +236,9 @@ class SrasFile:
|
||||
self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles
|
||||
self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles
|
||||
self.precomputed_bg_sub: bool = False
|
||||
# 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:
|
||||
"""A stored DC image (already in mV) for (angle, channel), or None."""
|
||||
@@ -496,16 +509,18 @@ class SrasFile:
|
||||
return end
|
||||
|
||||
def _read_cache_block(self, f, hdr_fmt: str, magic: bytes,
|
||||
stores: list[list]) -> int | None:
|
||||
stores: list[list]) -> tuple | None:
|
||||
"""Read one CACH sub-block header, then its per-angle image entries
|
||||
into *stores* (one list per image the block stores per angle).
|
||||
|
||||
Returns the header's flags byte, or None if the block is malformed.
|
||||
Every sub-block header is (magic, flags, n_stored, *extras). Returns
|
||||
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))
|
||||
if len(raw) < struct.calcsize(hdr_fmt):
|
||||
return None
|
||||
block_magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
|
||||
block_magic, flags, n_stored, *extras = struct.unpack(hdr_fmt, raw)
|
||||
if block_magic != magic:
|
||||
return None
|
||||
for _ in range(n_stored):
|
||||
@@ -515,7 +530,7 @@ class SrasFile:
|
||||
shape = self.image_shape(angle_idx)
|
||||
for store in stores:
|
||||
store[angle_idx] = _read_f32_image(f, shape)
|
||||
return flags
|
||||
return (flags, *extras)
|
||||
|
||||
def _parse_cach_section(self, offset: int):
|
||||
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
|
||||
@@ -525,7 +540,8 @@ class SrasFile:
|
||||
if len(header_raw) < CACH_HDR_SIZE:
|
||||
return
|
||||
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
|
||||
if magic != CACH_MAGIC or cach_version != CACH_VERSION:
|
||||
if (magic != CACH_MAGIC
|
||||
or not CACH_VERSION_MIN <= cach_version <= CACH_VERSION):
|
||||
return
|
||||
|
||||
if block_flags & CACH_FLAG_DC:
|
||||
@@ -535,17 +551,23 @@ class SrasFile:
|
||||
return
|
||||
|
||||
if block_flags & CACH_FLAG_FFT:
|
||||
flags = self._read_cache_block(
|
||||
f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz])
|
||||
if flags is None:
|
||||
# v1 has no pad field: those images are natural-resolution.
|
||||
hdr_fmt = SFFT_HDR_FMT if cach_version >= 2 else SFFT_HDR_FMT_V1
|
||||
header = self._read_cache_block(
|
||||
f, hdr_fmt, SFFT_MAGIC, [self.precomputed_freq_mhz])
|
||||
if header is None:
|
||||
return
|
||||
flags, *extras = header
|
||||
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, *,
|
||||
new_dc3_mv: list[np.ndarray | None] | None = None,
|
||||
new_dc4_mv: list[np.ndarray | None] | None = None,
|
||||
new_freq_mhz: list[np.ndarray | None] | None = None,
|
||||
new_bg_sub: bool | None = None):
|
||||
new_bg_sub: bool | None = None,
|
||||
new_pad_factor: int | None = None):
|
||||
"""Store computed DC and/or FFT images into this file's CACH tail,
|
||||
in place, converting a v6 source to v7 (or updating an existing v7
|
||||
file). Only the block(s) passed in are recomputed; whichever block
|
||||
@@ -565,6 +587,11 @@ class SrasFile:
|
||||
final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv
|
||||
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
|
||||
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
||||
final_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]
|
||||
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
||||
@@ -584,7 +611,8 @@ class SrasFile:
|
||||
|
||||
if fft_entries:
|
||||
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
|
||||
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries))
|
||||
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags,
|
||||
len(fft_entries), final_pad)
|
||||
for a in fft_entries:
|
||||
payload += struct.pack(">H", a)
|
||||
payload += final_freq[a].astype(">f4").tobytes()
|
||||
@@ -612,6 +640,7 @@ class SrasFile:
|
||||
self.precomputed_dc4_mv = final_dc4
|
||||
self.precomputed_freq_mhz = final_freq
|
||||
self.precomputed_bg_sub = final_bg_sub
|
||||
self.precomputed_pad_factor = final_pad
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Axes helpers
|
||||
|
||||
+18
-1
@@ -48,7 +48,8 @@ class FftOptionsDialog(QDialog):
|
||||
current_pad_factor: int,
|
||||
samples_per_frame: int | None,
|
||||
sample_rate_hz: float | None,
|
||||
grating_um: float):
|
||||
grating_um: float,
|
||||
cached_pad_factor: int | None = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("FFT Options")
|
||||
self.setModal(True)
|
||||
@@ -57,6 +58,10 @@ class FftOptionsDialog(QDialog):
|
||||
self._samples_per_frame = samples_per_frame
|
||||
self._sample_rate_hz = sample_rate_hz
|
||||
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)
|
||||
|
||||
@@ -109,6 +114,11 @@ class FftOptionsDialog(QDialog):
|
||||
lbl.setStyleSheet(_CSS_HINT)
|
||||
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)
|
||||
|
||||
# ---- Buttons ---------------------------------------------------
|
||||
@@ -126,6 +136,13 @@ class FftOptionsDialog(QDialog):
|
||||
sr = self._sample_rate_hz
|
||||
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:
|
||||
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
|
||||
self._lbl_freq_res.setText("")
|
||||
|
||||
+101
-9
@@ -79,11 +79,15 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
# Display-only settings (colormap, grating) never trigger a
|
||||
# recompute — they're applied to cached data on redraw. DC images
|
||||
# (CH3/CH4) are cheap and precomputed for every angle in the
|
||||
# background right after load. CH1/Velocity FFT images are
|
||||
# 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
|
||||
# (CH3/CH4) are cheap: they come straight from the file's stored
|
||||
# cache when Batch Compute DC has been run for it (see
|
||||
# _stored_dc_image), and are otherwise precomputed for every angle
|
||||
# in the background right after load. CH1/Velocity FFT images come
|
||||
# from the file's own stored cache when Batch Compute FFT has been
|
||||
# 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
|
||||
# same combination is free.
|
||||
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
|
||||
@@ -219,7 +223,14 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.spin_angle.setRange(0, 0)
|
||||
self.spin_angle.setEnabled(False)
|
||||
self.spin_angle.setMinimumWidth(64)
|
||||
self.spin_angle.editingFinished.connect(self._on_view_changed)
|
||||
# Keyboard tracking off is what makes valueChanged safe to act on: it
|
||||
# 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("—")
|
||||
angle_field = QWidget()
|
||||
ar = QHBoxLayout(angle_field)
|
||||
@@ -598,12 +609,32 @@ class SrasViewerWindow(QMainWindow):
|
||||
n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None)
|
||||
if n_dc or n_fft:
|
||||
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(
|
||||
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"FFT {n_fft}/{s.n_angles} angles"
|
||||
f"{bg_note + pad_note if n_fft else ''} "
|
||||
"— display is instant for cached angles")
|
||||
elif s.version == 7:
|
||||
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))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -656,6 +687,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
# Background subtraction changes the FFT input, so it genuinely
|
||||
# invalidates the cached raw FFT (the cache key includes it) —
|
||||
# _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():
|
||||
self._refresh_display()
|
||||
|
||||
@@ -838,6 +872,14 @@ class SrasViewerWindow(QMainWindow):
|
||||
return None
|
||||
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:
|
||||
"""Is the selected channel an FFT-derived (CH1/Velocity) mode?"""
|
||||
return (self._sras is not None
|
||||
@@ -877,6 +919,45 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._aligned_cache[key] = 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):
|
||||
"""Show the image for the current angle/channel/threshold, using
|
||||
cached data whenever possible and only falling back to a background
|
||||
@@ -888,12 +969,16 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
if ch_idx in CH1_DERIVED_MODES:
|
||||
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:
|
||||
self._show_image_now(self._scale_for_display(raw, ch_idx),
|
||||
angle_idx, ch_idx)
|
||||
return
|
||||
else:
|
||||
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:
|
||||
self._show_image_now(cached, angle_idx, ch_idx)
|
||||
return
|
||||
@@ -1163,7 +1248,11 @@ class SrasViewerWindow(QMainWindow):
|
||||
return
|
||||
|
||||
self._batch_errors = []
|
||||
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked())
|
||||
# Cache at the pad factor currently configured, not a fixed pad 1 —
|
||||
# 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(
|
||||
Jobs.BATCH, worker,
|
||||
connect=(
|
||||
@@ -1338,6 +1427,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
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,
|
||||
grating_um=self.spin_grating_um.value(),
|
||||
cached_pad_factor=self._cached_fft_pad_factor(),
|
||||
)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
@@ -1347,7 +1437,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._settings.setValue("fft/pad_factor", self._fft_pad_factor)
|
||||
# Pad factor changes the FFT bin count, so it genuinely invalidates
|
||||
# the cached raw FFT (part of the cache key) — _refresh_display()
|
||||
# recomputes only on a cache miss.
|
||||
# recomputes only on a cache miss. It also decides whether the file's
|
||||
# stored cache can be read back at all, which the info panel reports.
|
||||
self._update_scan_info_labels()
|
||||
if self._is_fft_mode():
|
||||
self._refresh_display()
|
||||
|
||||
|
||||
+11
-4
@@ -189,7 +189,10 @@ class BatchCacheWorker(QObject):
|
||||
|
||||
*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
|
||||
convention).
|
||||
convention). FFT images are computed and stored at *pad_factor*, the
|
||||
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
|
||||
opens its own memmap and writes only its own bytes, and only path strings
|
||||
@@ -201,11 +204,13 @@ class BatchCacheWorker(QObject):
|
||||
file_done = pyqtSignal(str, str)
|
||||
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__()
|
||||
self._paths = paths
|
||||
self._mode = mode
|
||||
self._apply_bg_sub = apply_bg_sub
|
||||
self._pad_factor = pad_factor
|
||||
|
||||
def _report(self, path: str, err: str, done: int, total: int):
|
||||
self.file_done.emit(path, err)
|
||||
@@ -230,7 +235,8 @@ class BatchCacheWorker(QObject):
|
||||
with ProcessPoolExecutor(max_workers=n_procs) as executor:
|
||||
futures = {
|
||||
executor.submit(cache_file, p, self._mode, self._apply_bg_sub,
|
||||
compute.get_fft_backend(), per_proc_workers): p
|
||||
compute.get_fft_backend(), per_proc_workers,
|
||||
self._pad_factor): p
|
||||
for p in paths
|
||||
}
|
||||
for fut in as_completed(futures):
|
||||
@@ -254,7 +260,8 @@ class BatchCacheWorker(QObject):
|
||||
try:
|
||||
err = cache_file(path, self._mode, self._apply_bg_sub,
|
||||
compute.get_fft_backend(),
|
||||
compute.default_max_workers())
|
||||
compute.default_max_workers(),
|
||||
self._pad_factor)
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
done += 1
|
||||
|
||||
+46
-1
@@ -94,11 +94,18 @@ def test_dc_precompute_all_angles(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
|
||||
for a in range(s.n_angles):
|
||||
win.spin_angle.setValue(a)
|
||||
win._on_view_changed()
|
||||
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)
|
||||
assert win._current_image.shape == expected, \
|
||||
f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}"
|
||||
@@ -106,6 +113,44 @@ def test_angle_switching_from_cache(ctx):
|
||||
"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):
|
||||
win = ctx.win
|
||||
win.spin_angle.setValue(0)
|
||||
|
||||
Reference in New Issue
Block a user