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:
+24
-9
@@ -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
@@ -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
@@ -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_"))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user