pre merge cleanup

This commit is contained in:
Thomas Ales
2026-08-06 21:20:35 -05:00
parent 8154c57066
commit 1a8187138f
6 changed files with 294 additions and 55 deletions
+18 -1
View File
@@ -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
View File
@@ -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()