Persist FFT settings, fix backend naming, parallelise batch DC caching

- FFT backend and pad factor persist across sessions via QSettings
  (IniFormat; tests redirect the settings path for hermeticity).
- The default backend was labelled "NumPy FFT" but always dispatched to
  scipy.fft — rename the canonical value to "scipy" ("numpy" stays as a
  legacy alias) and fix the dialog label.
- cache_file: DC caching fans out over angles via _parallel_map with
  per-angle budgets (the DcPrecomputeWorker pattern); FFT caching stays
  serial per angle because compute_rf_image now parallelises internally
  over blocks. Documented that the v7 FFT cache is natural-resolution
  (pad 1) by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-06 10:26:50 -05:00
parent 11ff3b62e2
commit 9a9a2557d6
5 changed files with 58 additions and 22 deletions
+24 -9
View File
@@ -35,15 +35,17 @@ try:
except ImportError:
threadpool_limits = None
_fft_backend = "numpy" # "numpy" (scipy.fft) or "pyfftw"; set via set_fft_backend()
_fft_backend = "scipy" # "scipy" or "pyfftw"; set via set_fft_backend()
def set_fft_backend(name: str):
"""Select the rfft implementation. Module-level state, so it must be set
explicitly inside each multiprocessing child — it does not survive a
spawn."""
spawn. "numpy" is accepted as a legacy alias for "scipy"."""
global _fft_backend
_fft_backend = name if (name != "pyfftw" or PYFFTW_AVAILABLE) else "numpy"
if name == "numpy":
name = "scipy"
_fft_backend = name if (name == "pyfftw" and PYFFTW_AVAILABLE) else "scipy"
def get_fft_backend() -> str:
@@ -599,13 +601,17 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
# ---------------------------------------------------------------------------
def cache_file(path: str, mode: str, apply_bg_sub: bool,
fft_backend: str = "numpy", max_workers: int = 0) -> 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 and worker cap 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.
"""
global _MAX_WORKERS
try:
@@ -621,17 +627,26 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
"can be batch-cached")
n = sras.n_angles
n_workers, angle_budget = plan_angle_level(sras)
if mode == "dc":
dc3 = [adc_to_mv(compute_dc_image(sras, a, CH3_IDX), *sras.cal(CH3_IDX))
for a in range(n)]
dc4 = [adc_to_mv(compute_dc_image(sras, a, CH4_IDX), *sras.cal(CH4_IDX))
for a in range(n)]
dc3 = _parallel_map(
lambda a: adc_to_mv(
compute_dc_image(sras, a, CH3_IDX, max_workers=1,
budget=angle_budget), *sras.cal(CH3_IDX)),
range(n), n_workers)
dc4 = _parallel_map(
lambda a: adc_to_mv(
compute_dc_image(sras, a, CH4_IDX, max_workers=1,
budget=angle_budget), *sras.cal(CH4_IDX)),
range(n), n_workers)
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
else:
effective_bg = apply_bg_sub and sras.background is not None
# 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.
# 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)
for a in range(n)]
+22 -9
View File
@@ -25,7 +25,7 @@ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolb
from matplotlib.figure import Figure
from matplotlib.patches import Polygon
from matplotlib.path import Path as MplPath
from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal
from PyQt6.QtCore import QObject, QSettings, Qt, QThread, pyqtSignal
from PyQt6.QtGui import QAction, QKeyEvent
from PyQt6.QtWidgets import (
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
@@ -655,22 +655,22 @@ class FftOptionsDialog(QDialog):
grp_backend = QGroupBox("FFT Backend")
bl = QVBoxLayout(grp_backend)
self._btn_numpy = QRadioButton("NumPy FFT (always available)")
self._btn_scipy = QRadioButton("SciPy FFT (pocketfft) (always available)")
self._btn_pyfftw = QRadioButton(
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
else "pyFFTW (not installed — run: pip install pyfftw)")
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
self._backend_group = QButtonGroup(self)
self._backend_group.addButton(self._btn_numpy, id=0)
self._backend_group.addButton(self._btn_scipy, id=0)
self._backend_group.addButton(self._btn_pyfftw, id=1)
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
self._btn_pyfftw.setChecked(True)
else:
self._btn_numpy.setChecked(True)
self._btn_scipy.setChecked(True)
bl.addWidget(self._btn_numpy)
bl.addWidget(self._btn_scipy)
bl.addWidget(self._btn_pyfftw)
layout.addWidget(grp_backend)
@@ -737,7 +737,7 @@ class FftOptionsDialog(QDialog):
f"(at grating = {self._grating_um:.2f} µm)")
def get_backend(self) -> str:
return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "numpy"
return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "scipy"
def get_pad_factor(self) -> int:
return max(1, self._spin_pad.value())
@@ -1451,8 +1451,18 @@ class SrasViewerWindow(QMainWindow):
self._jobs: dict[str, tuple] = {}
self._progress_dlgs: dict[str, QProgressDialog] = {}
# FFT settings (configured via FFT Options dialog)
self._fft_pad_factor: int = 1 # 1 = no padding
# FFT settings (configured via FFT Options dialog, persisted across
# sessions). IniFormat: predictable cross-platform and redirectable
# in tests.
self._settings = QSettings(QSettings.Format.IniFormat,
QSettings.Scope.UserScope,
"sras-viewer", "sras-viewer")
compute.set_fft_backend(str(self._settings.value("fft/backend", "scipy")))
try:
pad = int(self._settings.value("fft/pad_factor", 1))
except (TypeError, ValueError):
pad = 1
self._fft_pad_factor: int = max(1, min(256, pad)) # 1 = no padding
# Convert menu: batch DC/FFT compute-and-store (v6 -> v7)
self._batch_errors: list[str] = []
@@ -1846,7 +1856,8 @@ class SrasViewerWindow(QMainWindow):
self._batch_fft_act = QAction("Batch Compute FFT and Sto&re…", self)
self._batch_fft_act.setStatusTip(
"Select .sras files and compute+store FFT peak-frequency images "
"for every angle, converting v6 files to v7 in place.")
"for every angle, converting v6 files to v7 in place. Stored "
"images are natural-resolution (pad 1); padded views compute live.")
self._batch_fft_act.triggered.connect(lambda: self._on_batch_compute("fft"))
convert_menu.addAction(self._batch_fft_act)
@@ -2748,6 +2759,8 @@ class SrasViewerWindow(QMainWindow):
return
compute.set_fft_backend(dlg.get_backend())
self._fft_pad_factor = dlg.get_pad_factor()
self._settings.setValue("fft/backend", compute.get_fft_backend())
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.
+9 -1
View File
@@ -1,8 +1,16 @@
"""Shared test setup: repo-root imports and the offscreen Qt platform."""
"""Shared test setup: repo-root imports, the offscreen Qt platform, and
hermetic QSettings (tests must not read or write the user's real viewer
settings)."""
import os
import sys
import tempfile
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from PyQt6.QtCore import QSettings # noqa: E402
QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope,
tempfile.mkdtemp(prefix="sras_qsettings_"))
+1 -1
View File
@@ -165,7 +165,7 @@ def test_zoom_identity(tmp_path, monkeypatch, spf, bps):
dc4 = dc_image_mv(sras, 0, CH4_IDX)
thr = float(np.median(dc4))
backends = ["numpy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
backends = ["scipy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
for backend in backends:
monkeypatch.setattr(compute, "_fft_backend", backend)
for pad in (4, 8, 40):
+2 -2
View File
@@ -77,7 +77,7 @@ def main():
p.add_argument("--rows", type=int, default=8)
p.add_argument("--frames", type=int, default=1024)
p.add_argument("--backends", default=None,
help="comma-separated (default: numpy,pyfftw if available)")
help="comma-separated (default: scipy,pyfftw if available)")
p.add_argument("--real", help="path to a real .sras file")
p.add_argument("--real-rows", type=int, default=32,
help="rows of angle 0 to use from the real file")
@@ -87,7 +87,7 @@ def main():
if args.backends:
backends = args.backends.split(",")
else:
backends = ["numpy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
backends = ["scipy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
if args.real:
sras = row_slice(SrasFile(args.real), 0, args.real_rows)