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:
|
except ImportError:
|
||||||
threadpool_limits = None
|
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):
|
def set_fft_backend(name: str):
|
||||||
"""Select the rfft implementation. Module-level state, so it must be set
|
"""Select the rfft implementation. Module-level state, so it must be set
|
||||||
explicitly inside each multiprocessing child — it does not survive a
|
explicitly inside each multiprocessing child — it does not survive a
|
||||||
spawn."""
|
spawn. "numpy" is accepted as a legacy alias for "scipy"."""
|
||||||
global _fft_backend
|
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:
|
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,
|
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,
|
"""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.
|
converting v6 → v7 in place. Returns "" on success or an error message.
|
||||||
|
|
||||||
Module-level and picklable so it can run in a ProcessPoolExecutor. The
|
Module-level and picklable so it can run in a ProcessPoolExecutor. The
|
||||||
FFT backend and worker cap are passed explicitly because module globals
|
FFT backend and worker cap are passed explicitly because module globals
|
||||||
do not survive a spawn.
|
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
|
global _MAX_WORKERS
|
||||||
try:
|
try:
|
||||||
@@ -621,17 +627,26 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
|||||||
"can be batch-cached")
|
"can be batch-cached")
|
||||||
|
|
||||||
n = sras.n_angles
|
n = sras.n_angles
|
||||||
|
n_workers, angle_budget = plan_angle_level(sras)
|
||||||
if mode == "dc":
|
if mode == "dc":
|
||||||
dc3 = [adc_to_mv(compute_dc_image(sras, a, CH3_IDX), *sras.cal(CH3_IDX))
|
dc3 = _parallel_map(
|
||||||
for a in range(n)]
|
lambda a: adc_to_mv(
|
||||||
dc4 = [adc_to_mv(compute_dc_image(sras, a, CH4_IDX), *sras.cal(CH4_IDX))
|
compute_dc_image(sras, a, CH3_IDX, max_workers=1,
|
||||||
for a in range(n)]
|
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)
|
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
|
||||||
else:
|
else:
|
||||||
effective_bg = apply_bg_sub and sras.background is not None
|
effective_bg = apply_bg_sub and sras.background is not None
|
||||||
# dc_threshold_mv=None: store unmasked images and mask at display
|
# dc_threshold_mv=None: store unmasked images and mask at display
|
||||||
# time (same convention as v5's PREC block). Skipping the mask
|
# 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,
|
freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
|
||||||
apply_bg_sub=effective_bg)
|
apply_bg_sub=effective_bg)
|
||||||
for a in range(n)]
|
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.figure import Figure
|
||||||
from matplotlib.patches import Polygon
|
from matplotlib.patches import Polygon
|
||||||
from matplotlib.path import Path as MplPath
|
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.QtGui import QAction, QKeyEvent
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||||
@@ -655,22 +655,22 @@ class FftOptionsDialog(QDialog):
|
|||||||
grp_backend = QGroupBox("FFT Backend")
|
grp_backend = QGroupBox("FFT Backend")
|
||||||
bl = QVBoxLayout(grp_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(
|
self._btn_pyfftw = QRadioButton(
|
||||||
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
|
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
|
||||||
else "pyFFTW (not installed — run: pip install pyfftw)")
|
else "pyFFTW (not installed — run: pip install pyfftw)")
|
||||||
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
|
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
|
||||||
|
|
||||||
self._backend_group = QButtonGroup(self)
|
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)
|
self._backend_group.addButton(self._btn_pyfftw, id=1)
|
||||||
|
|
||||||
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
|
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
|
||||||
self._btn_pyfftw.setChecked(True)
|
self._btn_pyfftw.setChecked(True)
|
||||||
else:
|
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)
|
bl.addWidget(self._btn_pyfftw)
|
||||||
layout.addWidget(grp_backend)
|
layout.addWidget(grp_backend)
|
||||||
|
|
||||||
@@ -737,7 +737,7 @@ class FftOptionsDialog(QDialog):
|
|||||||
f"(at grating = {self._grating_um:.2f} µm)")
|
f"(at grating = {self._grating_um:.2f} µm)")
|
||||||
|
|
||||||
def get_backend(self) -> str:
|
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:
|
def get_pad_factor(self) -> int:
|
||||||
return max(1, self._spin_pad.value())
|
return max(1, self._spin_pad.value())
|
||||||
@@ -1451,8 +1451,18 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._jobs: dict[str, tuple] = {}
|
self._jobs: dict[str, tuple] = {}
|
||||||
self._progress_dlgs: dict[str, QProgressDialog] = {}
|
self._progress_dlgs: dict[str, QProgressDialog] = {}
|
||||||
|
|
||||||
# FFT settings (configured via FFT Options dialog)
|
# FFT settings (configured via FFT Options dialog, persisted across
|
||||||
self._fft_pad_factor: int = 1 # 1 = no padding
|
# 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)
|
# Convert menu: batch DC/FFT compute-and-store (v6 -> v7)
|
||||||
self._batch_errors: list[str] = []
|
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 = QAction("Batch Compute FFT and Sto&re…", self)
|
||||||
self._batch_fft_act.setStatusTip(
|
self._batch_fft_act.setStatusTip(
|
||||||
"Select .sras files and compute+store FFT peak-frequency images "
|
"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"))
|
self._batch_fft_act.triggered.connect(lambda: self._on_batch_compute("fft"))
|
||||||
convert_menu.addAction(self._batch_fft_act)
|
convert_menu.addAction(self._batch_fft_act)
|
||||||
|
|
||||||
@@ -2748,6 +2759,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
compute.set_fft_backend(dlg.get_backend())
|
compute.set_fft_backend(dlg.get_backend())
|
||||||
self._fft_pad_factor = dlg.get_pad_factor()
|
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
|
# Pad factor changes the FFT bin count, so it genuinely invalidates
|
||||||
# the cached raw FFT (part of the cache key) — _refresh_display()
|
# the cached raw FFT (part of the cache key) — _refresh_display()
|
||||||
# recomputes only on a cache miss.
|
# 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 os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
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)
|
dc4 = dc_image_mv(sras, 0, CH4_IDX)
|
||||||
thr = float(np.median(dc4))
|
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:
|
for backend in backends:
|
||||||
monkeypatch.setattr(compute, "_fft_backend", backend)
|
monkeypatch.setattr(compute, "_fft_backend", backend)
|
||||||
for pad in (4, 8, 40):
|
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("--rows", type=int, default=8)
|
||||||
p.add_argument("--frames", type=int, default=1024)
|
p.add_argument("--frames", type=int, default=1024)
|
||||||
p.add_argument("--backends", default=None,
|
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", help="path to a real .sras file")
|
||||||
p.add_argument("--real-rows", type=int, default=32,
|
p.add_argument("--real-rows", type=int, default=32,
|
||||||
help="rows of angle 0 to use from the real file")
|
help="rows of angle 0 to use from the real file")
|
||||||
@@ -87,7 +87,7 @@ def main():
|
|||||||
if args.backends:
|
if args.backends:
|
||||||
backends = args.backends.split(",")
|
backends = args.backends.split(",")
|
||||||
else:
|
else:
|
||||||
backends = ["numpy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
|
backends = ["scipy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
|
||||||
|
|
||||||
if args.real:
|
if args.real:
|
||||||
sras = row_slice(SrasFile(args.real), 0, args.real_rows)
|
sras = row_slice(SrasFile(args.real), 0, args.real_rows)
|
||||||
|
|||||||
Reference in New Issue
Block a user