From 9a9a2557d6d47b0823592ea74bfcd220c15e0e61 Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Thu, 6 Aug 2026 10:26:50 -0500 Subject: [PATCH] Persist FFT settings, fix backend naming, parallelise batch DC caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- sras_compute.py | 33 ++++++++++++++++++++++++--------- sras_viewer.py | 31 ++++++++++++++++++++++--------- tests/conftest.py | 10 +++++++++- tests/test_compute.py | 2 +- tools/bench_fft.py | 4 ++-- 5 files changed, 58 insertions(+), 22 deletions(-) diff --git a/sras_compute.py b/sras_compute.py index 5700b02..fc7b30e 100644 --- a/sras_compute.py +++ b/sras_compute.py @@ -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)] diff --git a/sras_viewer.py b/sras_viewer.py index 54272c0..e28fa73 100644 --- a/sras_viewer.py +++ b/sras_viewer.py @@ -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. diff --git a/tests/conftest.py b/tests/conftest.py index c9df201..5486080 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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_")) diff --git a/tests/test_compute.py b/tests/test_compute.py index 408d79f..3c6e400 100644 --- a/tests/test_compute.py +++ b/tests/test_compute.py @@ -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): diff --git a/tools/bench_fft.py b/tools/bench_fft.py index d71285f..68a1cc3 100644 --- a/tools/bench_fft.py +++ b/tools/bench_fft.py @@ -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)