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.ndimage as scipy_ndimage
from sras_format import (
CH1_IDX, CH3_IDX, CH4_IDX, PAD_FACTOR_MAX, SrasFile, adc_to_mv,
)
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile, adc_to_mv
# ---------------------------------------------------------------------------
# FFT backend
@@ -422,56 +420,6 @@ 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,
@@ -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
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
cached_rf_image, which the viewer also applies before it ever dispatches
a compute at all.
matches, the stored image is used directly — no FFT is run.
"""
# ---- 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)
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
spf = sras.samples_per_frame
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,
fft_backend: str = "scipy", max_workers: int = 0,
pad_factor: int = 1) -> str:
fft_backend: str = "scipy", max_workers: int = 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.
Module-level and picklable so it can run in a ProcessPoolExecutor. The
FFT backend, worker cap and pad factor are passed explicitly because
module globals do not survive a spawn.
FFT backend and worker cap are passed explicitly because module globals
do not survive a spawn.
*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).
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.
"""
global _MAX_WORKERS
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)
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, n_fft=n_fft)
apply_bg_sub=effective_bg)
for a in range(n)]
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
new_pad_factor=pad)
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg)
return ""
except Exception as exc:
return str(exc)
+13 -42
View File
@@ -60,12 +60,7 @@ 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)
# 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_VERSION = 1
CACH_FLAG_DC = 0x01
CACH_FLAG_FFT = 0x02
@@ -74,12 +69,9 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
SFFT_MAGIC = b"SFFT"
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored
SFFT_HDR_FMT = ">4sBHH" # magic, flags, n_stored, pad_factor
SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored
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
@@ -177,9 +169,7 @@ 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. 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.
source version.
"""
def __init__(self, path: str):
@@ -236,9 +226,6 @@ 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."""
@@ -509,18 +496,16 @@ class SrasFile:
return end
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
into *stores* (one list per image the block stores per angle).
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.
Returns the header's flags byte, 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, *extras = struct.unpack(hdr_fmt, raw)
block_magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
if block_magic != magic:
return None
for _ in range(n_stored):
@@ -530,7 +515,7 @@ class SrasFile:
shape = self.image_shape(angle_idx)
for store in stores:
store[angle_idx] = _read_f32_image(f, shape)
return (flags, *extras)
return flags
def _parse_cach_section(self, offset: int):
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
@@ -540,8 +525,7 @@ class SrasFile:
if len(header_raw) < CACH_HDR_SIZE:
return
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
if (magic != CACH_MAGIC
or not CACH_VERSION_MIN <= cach_version <= CACH_VERSION):
if magic != CACH_MAGIC or cach_version != CACH_VERSION:
return
if block_flags & CACH_FLAG_DC:
@@ -551,23 +535,17 @@ class SrasFile:
return
if block_flags & CACH_FLAG_FFT:
# 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:
flags = self._read_cache_block(
f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz])
if flags 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_pad_factor: int | None = None):
new_bg_sub: bool | 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
@@ -587,11 +565,6 @@ 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]
@@ -611,8 +584,7 @@ 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), final_pad)
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries))
for a in fft_entries:
payload += struct.pack(">H", a)
payload += final_freq[a].astype(">f4").tobytes()
@@ -640,7 +612,6 @@ 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
+1 -18
View File
@@ -48,8 +48,7 @@ class FftOptionsDialog(QDialog):
current_pad_factor: int,
samples_per_frame: int | None,
sample_rate_hz: float | None,
grating_um: float,
cached_pad_factor: int | None = None):
grating_um: float):
super().__init__(parent)
self.setWindowTitle("FFT Options")
self.setModal(True)
@@ -58,10 +57,6 @@ 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)
@@ -114,11 +109,6 @@ 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 ---------------------------------------------------
@@ -136,13 +126,6 @@ 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("")
+9 -101
View File
@@ -79,15 +79,11 @@ 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: 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
# (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
# cached per (angle, bg_sub, n_fft, threshold) so revisiting the
# same combination is free.
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.setEnabled(False)
self.spin_angle.setMinimumWidth(64)
# 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.spin_angle.editingFinished.connect(self._on_view_changed)
self.lbl_angle_deg = QLabel("—")
angle_field = QWidget()
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)
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"
f"{bg_note + pad_note if n_fft else ''} "
f"FFT {n_fft}/{s.n_angles} angles{bg_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))
# ------------------------------------------------------------------
@@ -687,9 +656,6 @@ 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()
@@ -872,14 +838,6 @@ 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
@@ -919,45 +877,6 @@ 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
@@ -969,16 +888,12 @@ 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
@@ -1248,11 +1163,7 @@ class SrasViewerWindow(QMainWindow):
return
self._batch_errors = []
# 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)
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked())
started = self._run_worker(
Jobs.BATCH, worker,
connect=(
@@ -1427,7 +1338,6 @@ 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
@@ -1437,9 +1347,7 @@ 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. 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()
# recomputes only on a cache miss.
if self._is_fft_mode():
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
images, unmasked — masking is applied at display time, same as v5's PREC
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.
convention).
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
@@ -204,13 +201,11 @@ class BatchCacheWorker(QObject):
file_done = pyqtSignal(str, str)
finished = pyqtSignal()
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool,
pad_factor: int = 1):
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool):
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)
@@ -235,8 +230,7 @@ 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,
self._pad_factor): p
compute.get_fft_backend(), per_proc_workers): p
for p in paths
}
for fut in as_completed(futures):
@@ -260,8 +254,7 @@ class BatchCacheWorker(QObject):
try:
err = cache_file(path, self._mode, self._apply_bg_sub,
compute.get_fft_backend(),
compute.default_max_workers(),
self._pad_factor)
compute.default_max_workers())
except Exception as exc:
err = str(exc)
done += 1
+1 -46
View File
@@ -94,18 +94,11 @@ 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}"
@@ -113,44 +106,6 @@ 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)