Stop recomputing images on display-only setting changes; cache and lazily compute

Colormap, DC threshold, velocity grating, and FFT pad-factor changes were
all routing through a full recompute, which is unworkable on large v6
scans where a single angle's FFT can take minutes. These are now applied
as cheap post-processing on cached data instead:

- ComputeWorker computes CH1/Velocity as an unmasked raw FFT plus its DC4
  mask image, cached per (angle, bg_sub, n_fft). Threshold masking and
  grating scaling are applied to the cached array on redraw, so neither
  needs a recompute; colormap changes only touch matplotlib.
- New DcPrecomputeWorker fills a per-angle DC (CH3/CH4) cache in the
  background right after load, since DC images are cheap (mean, no FFT)
  and make switching angles instant once cached. Progress is shown in the
  Scan Info panel.
- New _refresh_display()/_show_image_now() route every settings-changed
  handler through the cache first, falling back to the existing threaded
  _start_compute() (with a progress popup, now FFT- vs DC-specific) only
  on a genuine cache miss.
- File load now defaults to a DC channel instead of CH1/FFT, so opening a
  large file shows something in seconds instead of minutes.

Verified against a real 532 GB / 17-angle file: DC compute for one angle
takes ~100s+ (I/O-bound over USB) the first time, but switching to an
already-cached angle takes 0.4s with no compute thread spun up at all.

Audited every widget signal connection for this pass; the spinboxes
already correctly used editingFinished rather than valueChanged — the
recompute-on-every-change bug was in the handlers, not the event type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-30 20:36:44 -05:00
parent 912ae2d15f
commit 158f60d2a7
+288 -48
View File
@@ -713,33 +713,45 @@ class LoadWorker(QObject):
class ComputeWorker(QObject): class ComputeWorker(QObject):
finished = pyqtSignal(np.ndarray) """Computes one displayable image for (angle, channel).
For CH1/Velocity (FFT-derived) channels, the FFT is run *unmasked*
(dc_threshold_mv=-1e9 → every pixel is computed) so the result is
reusable across threshold/grating/colormap changes without re-running
the FFT — those are then pure post-processing on the cached array.
The DC4 image needed to apply that mask at display time is computed
alongside it (cheap: a per-waveform mean, no FFT) and emitted with it
so the caller can cache both.
Emits a plain ``np.ndarray`` (already in display units) for DC
channels, or a ``(raw_freq_mhz, dc4_mv)`` tuple for CH1/Velocity.
"""
finished = pyqtSignal(object)
error = pyqtSignal(str) error = pyqtSignal(str)
def __init__(self, sras: SrasFile, angle_idx: int, def __init__(self, sras: SrasFile, angle_idx: int,
ch_idx: int, dc_threshold_mv: float, ch_idx: int,
grating_um: float = 25,
apply_bg_sub: bool = True, apply_bg_sub: bool = True,
n_fft: int | None = None): n_fft: int | None = None):
super().__init__() super().__init__()
self._sras = sras self._sras = sras
self._angle = angle_idx self._angle = angle_idx
self._ch = ch_idx self._ch = ch_idx
self._threshold = dc_threshold_mv
self._grating_um = grating_um
self._apply_bg_sub = apply_bg_sub self._apply_bg_sub = apply_bg_sub
self._n_fft = n_fft self._n_fft = n_fft
def run(self): def run(self):
try: try:
if self._ch == CH1_IDX: if self._ch in (CH1_IDX, VELOCITY_MODE_IDX):
img = compute_rf_image(self._sras, self._angle, self._threshold, raw_freq_mhz = compute_rf_image(
self._apply_bg_sub, n_fft=self._n_fft) self._sras, self._angle, dc_threshold_mv=-1e9,
elif self._ch == VELOCITY_MODE_IDX: apply_bg_sub=self._apply_bg_sub, n_fft=self._n_fft)
# velocity (m/s) = freq (MHz) × grating (µm) [units cancel to m/s] dc4_adc = compute_dc_image(self._sras, self._angle, CH4_IDX)
img = compute_rf_image(self._sras, self._angle, self._threshold, dc4_mv = adc_to_mv(dc4_adc,
self._apply_bg_sub, n_fft=self._n_fft) self._sras.ch_ymult_mv[CH4_IDX],
img = img * self._grating_um self._sras.ch_yoff_adc[CH4_IDX],
self._sras.ch_yzero_mv[CH4_IDX])
self.finished.emit((raw_freq_mhz, dc4_mv))
else: else:
# DC channels: convert ADC counts → mV # DC channels: convert ADC counts → mV
adc_img = compute_dc_image(self._sras, self._angle, self._ch) adc_img = compute_dc_image(self._sras, self._angle, self._ch)
@@ -747,7 +759,51 @@ class ComputeWorker(QObject):
self._sras.ch_ymult_mv[self._ch], self._sras.ch_ymult_mv[self._ch],
self._sras.ch_yoff_adc[self._ch], self._sras.ch_yoff_adc[self._ch],
self._sras.ch_yzero_mv[self._ch]) self._sras.ch_yzero_mv[self._ch])
self.finished.emit(img) self.finished.emit(img)
except Exception as exc:
self.error.emit(str(exc))
class DcPrecomputeWorker(QObject):
"""Computes CH3/CH4 DC images for every angle in the background.
DC images are cheap (a per-waveform mean, no FFT) compared to the
CH1/Velocity FFT, so precomputing them for the whole file right after
load makes switching angles instant while on a DC channel, and also
means the FFT masking step (which needs a DC4 image) rarely has to
wait on anything. Emits one ``angle_done`` signal per angle as it
completes rather than waiting for the whole file, so the cache fills
in progressively.
"""
angle_done = pyqtSignal(int, np.ndarray, np.ndarray) # angle_idx, dc3_mv, dc4_mv
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, sras: SrasFile):
super().__init__()
self._sras = sras
self._stop = False
def stop(self):
self._stop = True
def run(self):
try:
for a in range(self._sras.n_angles):
if self._stop:
break
dc3_mv = adc_to_mv(
compute_dc_image(self._sras, a, CH3_IDX),
self._sras.ch_ymult_mv[CH3_IDX],
self._sras.ch_yoff_adc[CH3_IDX],
self._sras.ch_yzero_mv[CH3_IDX])
dc4_mv = adc_to_mv(
compute_dc_image(self._sras, a, CH4_IDX),
self._sras.ch_ymult_mv[CH4_IDX],
self._sras.ch_yoff_adc[CH4_IDX],
self._sras.ch_yzero_mv[CH4_IDX])
self.angle_done.emit(a, dc3_mv, dc4_mv)
self.finished.emit()
except Exception as exc: except Exception as exc:
self.error.emit(str(exc)) self.error.emit(str(exc))
@@ -1421,8 +1477,6 @@ class SrasViewerWindow(QMainWindow):
self._compute_thread: QThread | None = None self._compute_thread: QThread | None = None
self._pending_angle: int = 0 self._pending_angle: int = 0
self._pending_ch: int = 0 self._pending_ch: int = 0
self._pending_threshold: float = 50.0 # mV
self._pending_grating_um: float = 25 # µm
self._pending_bg_sub: bool = True self._pending_bg_sub: bool = True
self._progress_dlg: QProgressDialog | None = None self._progress_dlg: QProgressDialog | None = None
@@ -1432,6 +1486,19 @@ class SrasViewerWindow(QMainWindow):
self._preprocess_thread: QThread | None = None self._preprocess_thread: QThread | None = None
# Display-only settings (colormap, threshold, grating) no longer
# 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
# is viewed in that mode, cached unmasked, and re-masked/re-scaled
# for free afterward.
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
self._fft_cache: dict[tuple[int, bool, int | None], np.ndarray] = {}
self._dc_precompute_thread: QThread | None = None
self._dc_precompute_worker: DcPrecomputeWorker | None = None
self._dc_generation: int = 0
self._build_ui() self._build_ui()
if initial_path: if initial_path:
@@ -1484,6 +1551,11 @@ class SrasViewerWindow(QMainWindow):
self.lbl_frame_warn.setWordWrap(True) self.lbl_frame_warn.setWordWrap(True)
self.lbl_frame_warn.setStyleSheet("color: #e07000; font-size: 11px;") self.lbl_frame_warn.setStyleSheet("color: #e07000; font-size: 11px;")
il.addWidget(self.lbl_frame_warn) il.addWidget(self.lbl_frame_warn)
# Background DC-precompute progress (hidden until a file is loaded)
self.lbl_dc_precompute = QLabel("")
self.lbl_dc_precompute.setWordWrap(True)
self.lbl_dc_precompute.setStyleSheet("color: #4a90d9; font-size: 11px;")
il.addWidget(self.lbl_dc_precompute)
panel_layout.addWidget(grp_info) panel_layout.addWidget(grp_info)
# View settings # View settings
@@ -1632,7 +1704,7 @@ class SrasViewerWindow(QMainWindow):
self.combo_cmap.addItems(CMAPS) self.combo_cmap.addItems(CMAPS)
self.combo_cmap.setCurrentText("gray") self.combo_cmap.setCurrentText("gray")
self.combo_cmap.setEnabled(False) self.combo_cmap.setEnabled(False)
self.combo_cmap.currentIndexChanged.connect(self._on_view_changed) self.combo_cmap.currentIndexChanged.connect(self._on_cmap_changed)
cmr.addWidget(self.combo_cmap) cmr.addWidget(self.combo_cmap)
dl.addLayout(cmr) dl.addLayout(cmr)
@@ -1777,6 +1849,12 @@ class SrasViewerWindow(QMainWindow):
self._sras = sras self._sras = sras
self._current_image = None self._current_image = None
# Caches (and any in-flight DC precompute) belong to the previous
# file's geometry — discard and start fresh.
self._dc_cache = {}
self._fft_cache = {}
self.lbl_dc_precompute.setText("")
# A ROI from the previous file no longer matches the new scan's # A ROI from the previous file no longer matches the new scan's
# geometry, so discard it on every load. # geometry, so discard it on every load.
self.image_canvas.clear_roi() self.image_canvas.clear_roi()
@@ -1788,11 +1866,23 @@ class SrasViewerWindow(QMainWindow):
self.spin_angle.setValue(0) self.spin_angle.setValue(0)
self.spin_angle.blockSignals(False) self.spin_angle.blockSignals(False)
# DC channels are cheap and give an instant, fluid overview of a
# scan; CH1/Velocity require an FFT per pixel that can take minutes
# on a large scan, so don't default to it.
self.combo_channel.blockSignals(True)
self.combo_channel.setCurrentIndex(CH4_IDX)
self.combo_channel.blockSignals(False)
self._update_controls_enabled(True) self._update_controls_enabled(True)
# Refresh the ADC-count label now that we have file calibration # Refresh the ADC-count label now that we have file calibration
self._on_threshold_changed() self._on_threshold_changed()
self._on_view_changed() self._on_view_changed()
# Precompute DC images for every angle in the background so
# switching angles while viewing a DC channel is instant, and the
# FFT masking step rarely has to wait on a DC4 image either.
self._start_dc_precompute()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Scan info panel # Scan info panel
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1877,13 +1967,19 @@ class SrasViewerWindow(QMainWindow):
self._on_view_changed() self._on_view_changed()
def _on_bg_sub_toggled(self): def _on_bg_sub_toggled(self):
# Background subtraction changes the FFT input, so it genuinely
# invalidates the cached raw FFT (the cache key includes it) —
# _refresh_display() will recompute only if there's no cache hit
# for the new bg-sub state.
if self._sras is not None: if self._sras is not None:
if self.combo_channel.currentIndex() in CH1_DERIVED_MODES: if self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._start_compute() self._refresh_display()
def _on_grating_changed(self): def _on_grating_changed(self):
# Grating is a pure post-multiply on the cached frequency image —
# never needs a recompute.
if self._sras is not None and self.combo_channel.currentIndex() == VELOCITY_MODE_IDX: if self._sras is not None and self.combo_channel.currentIndex() == VELOCITY_MODE_IDX:
self._start_compute() self._refresh_display()
def _on_export_csv(self): def _on_export_csv(self):
if self._current_image is None or self._sras is None: if self._current_image is None or self._sras is None:
@@ -2029,8 +2125,10 @@ class SrasViewerWindow(QMainWindow):
else: else:
ymult, yoff, yzero = _FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, 0.0 ymult, yoff, yzero = _FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, 0.0
self.lbl_threshold_adc.setText(f"{mv_to_adc(mv, ymult, yoff, yzero):.1f} ADC counts") self.lbl_threshold_adc.setText(f"{mv_to_adc(mv, ymult, yoff, yzero):.1f} ADC counts")
# Threshold is applied as a mask against the cached raw FFT + DC4
# image, not baked into the compute — never needs a recompute.
if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES: if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._start_compute() self._refresh_display()
def _on_autoscale_toggled(self, checked: bool): def _on_autoscale_toggled(self, checked: bool):
manual = not checked manual = not checked
@@ -2043,18 +2141,82 @@ class SrasViewerWindow(QMainWindow):
if not self.chk_auto.isChecked() and self._current_image is not None: if not self.chk_auto.isChecked() and self._current_image is not None:
self._redraw_image(self._current_image) self._redraw_image(self._current_image)
def _on_cmap_changed(self):
# Colormap is purely how the existing image is rendered — never
# needs a recompute.
if self._current_image is not None:
self._redraw_image(self._current_image)
def _on_view_changed(self): def _on_view_changed(self):
if self._sras is None: if self._sras is None:
return return
idx = self.spin_angle.value() idx = self.spin_angle.value()
self.lbl_angle_deg.setText(f"({self._sras.angles_deg[idx]:.1f}°)") self.lbl_angle_deg.setText(f"({self._sras.angles_deg[idx]:.1f}°)")
self._update_scan_info_labels() self._update_scan_info_labels()
self._start_compute() self._refresh_display()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Computation # Computation
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _current_n_fft(self) -> int | None:
pad_factor = self._fft_pad_factor
if pad_factor <= 1 or self._sras is None:
return None
return self._sras.samples_per_frame * pad_factor
def _apply_threshold_and_scale(self, raw_freq_mhz: np.ndarray,
dc4_mv: np.ndarray, ch_idx: int) -> np.ndarray:
"""Mask a raw (unmasked) FFT peak-frequency image against the
current DC threshold, and scale to velocity if needed. Both are
cheap array ops — never worth a recompute on their own."""
img = raw_freq_mhz.copy()
img[dc4_mv < self.spin_threshold_mv.value()] = 0.0
if ch_idx == VELOCITY_MODE_IDX:
img = img * self.spin_grating_um.value()
return img
def _refresh_display(self):
"""Show the image for the current angle/channel, using cached data
whenever possible and only falling back to a background compute
(with progress popup) when genuinely nothing is cached yet for
these settings."""
if self._sras is None:
return
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
if ch_idx in (CH3_IDX, CH4_IDX):
cached = self._dc_cache.get((angle_idx, ch_idx))
if cached is not None:
self._show_image_now(cached, angle_idx, ch_idx)
return
elif ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
apply_bg_sub = self.chk_bg_sub.isChecked()
key = (angle_idx, apply_bg_sub, self._current_n_fft())
raw = self._fft_cache.get(key)
dc4 = self._dc_cache.get((angle_idx, CH4_IDX))
if raw is not None and dc4 is not None:
img = self._apply_threshold_and_scale(raw, dc4, ch_idx)
self._show_image_now(img, angle_idx, ch_idx)
return
# Nothing cached for these settings — need a real compute.
self._start_compute()
def _show_image_now(self, img: np.ndarray, angle_idx: int, ch_idx: int):
"""Display an already-available image with no compute involved."""
self._current_image = img
self._current_angle = angle_idx
self._current_ch = ch_idx
self.btn_export_csv.setEnabled(ch_idx in CH1_DERIVED_MODES)
self._redraw_image(img)
self._update_roi_ui()
# ------------------------------------------------------------------
# Background compute (only reached on a genuine cache miss)
# ------------------------------------------------------------------
def _start_compute(self): def _start_compute(self):
if self._sras is None: if self._sras is None:
return return
@@ -2063,17 +2225,12 @@ class SrasViewerWindow(QMainWindow):
angle_idx = self.spin_angle.value() angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex() ch_idx = self.combo_channel.currentIndex()
threshold_mv = self.spin_threshold_mv.value()
grating_um = self.spin_grating_um.value()
apply_bg_sub = self.chk_bg_sub.isChecked() apply_bg_sub = self.chk_bg_sub.isChecked()
pad_factor = self._fft_pad_factor pad_factor = self._fft_pad_factor
n_fft = (self._sras.samples_per_frame * pad_factor n_fft = self._current_n_fft()
if pad_factor > 1 else None)
self._pending_angle = angle_idx self._pending_angle = angle_idx
self._pending_ch = ch_idx self._pending_ch = ch_idx
self._pending_threshold = threshold_mv
self._pending_grating_um = grating_um
self._pending_bg_sub = apply_bg_sub self._pending_bg_sub = apply_bg_sub
self._pending_fft_pad_factor = pad_factor self._pending_fft_pad_factor = pad_factor
@@ -2085,8 +2242,7 @@ class SrasViewerWindow(QMainWindow):
# running) that second thread's QThread object — which aborts the # running) that second thread's QThread object — which aborts the
# process. Assigning immediately closes that window. # process. Assigning immediately closes that window.
self._compute_worker = ComputeWorker( self._compute_worker = ComputeWorker(
self._sras, angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub, self._sras, angle_idx, ch_idx, apply_bg_sub, n_fft=n_fft,
n_fft=n_fft,
) )
self._compute_thread = QThread() self._compute_thread = QThread()
self._compute_worker.moveToThread(self._compute_thread) self._compute_worker.moveToThread(self._compute_thread)
@@ -2098,8 +2254,16 @@ class SrasViewerWindow(QMainWindow):
self._compute_worker.finished.connect(self._compute_thread.quit) self._compute_worker.finished.connect(self._compute_thread.quit)
self._compute_thread.finished.connect(self._on_compute_thread_finished) self._compute_thread.finished.connect(self._on_compute_thread_finished)
self.statusBar().showMessage("Computing image…") if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
self._show_progress("Computing image") self.statusBar().showMessage("Computing FFT")
self._show_progress(
f"Computing FFT for angle {angle_idx}\n"
"This can take a while on a large scan — result is cached "
"so revisiting this angle/mode will be instant."
)
else:
self.statusBar().showMessage("Computing DC image…")
self._show_progress(f"Computing DC image for angle {angle_idx}")
self._compute_thread.start() self._compute_thread.start()
@@ -2114,25 +2278,95 @@ class SrasViewerWindow(QMainWindow):
self._compute_thread = None self._compute_thread = None
angle_idx = self.spin_angle.value() angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex() ch_idx = self.combo_channel.currentIndex()
threshold_mv = self.spin_threshold_mv.value()
grating_um = self.spin_grating_um.value()
apply_bg_sub = self.chk_bg_sub.isChecked() apply_bg_sub = self.chk_bg_sub.isChecked()
if (angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub, if (angle_idx, ch_idx, apply_bg_sub, self._fft_pad_factor) != (
self._fft_pad_factor) != (
self._pending_angle, self._pending_ch, self._pending_angle, self._pending_ch,
self._pending_threshold, self._pending_grating_um, self._pending_bg_sub, self._pending_fft_pad_factor):
self._pending_bg_sub, # Settings changed while this compute was running — re-dispatch
self._pending_fft_pad_factor): # through the cache-aware path in case that now-current
self._start_compute() # combination happens to already be cached.
self._refresh_display()
def _on_compute_done(self, img: np.ndarray): def _on_compute_done(self, result):
self._close_progress() self._close_progress()
self._current_image = img angle_idx = self._pending_angle
self._current_angle = self._pending_angle ch_idx = self._pending_ch
self._current_ch = self._pending_ch
self.btn_export_csv.setEnabled(self._pending_ch in CH1_DERIVED_MODES) if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
self._redraw_image(img) raw_freq_mhz, dc4_mv = result
self._update_roi_ui() key = (angle_idx, self._pending_bg_sub, self._current_n_fft())
self._fft_cache[key] = raw_freq_mhz
self._dc_cache[(angle_idx, CH4_IDX)] = dc4_mv
img = self._apply_threshold_and_scale(raw_freq_mhz, dc4_mv, ch_idx)
else:
img = result
self._dc_cache[(angle_idx, ch_idx)] = img
self._show_image_now(img, angle_idx, ch_idx)
# ------------------------------------------------------------------
# Background DC precompute (all angles, so switching is fluid)
# ------------------------------------------------------------------
def _start_dc_precompute(self):
if self._sras is None:
return
self._dc_generation += 1
generation = self._dc_generation
n_angles = self._sras.n_angles
worker = DcPrecomputeWorker(self._sras)
thread = QThread()
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.angle_done.connect(
lambda a, dc3, dc4, g=generation: self._on_dc_precompute_angle_done(
g, a, dc3, dc4, n_angles)
)
worker.error.connect(
lambda msg: self.statusBar().showMessage(f"DC precompute error: {msg}", 5000)
)
worker.finished.connect(thread.quit)
worker.error.connect(thread.quit)
thread.finished.connect(lambda: self._on_dc_precompute_thread_finished(thread))
# See _start_compute for why the thread/worker are claimed on self
# before .start() rather than after.
self._dc_precompute_worker = worker
self._dc_precompute_thread = thread
thread.start()
def _on_dc_precompute_angle_done(self, generation: int, angle_idx: int,
dc3_mv: np.ndarray, dc4_mv: np.ndarray,
n_angles: int):
if generation != self._dc_generation:
return # stale result from a previously-loaded file — discard
self._dc_cache[(angle_idx, CH3_IDX)] = dc3_mv
self._dc_cache[(angle_idx, CH4_IDX)] = dc4_mv
done = sum(1 for a in range(n_angles) if (a, CH4_IDX) in self._dc_cache)
if done < n_angles:
self.lbl_dc_precompute.setText(
f"Precomputing DC images: {done}/{n_angles} angles ready…")
else:
self.lbl_dc_precompute.setText("DC images ready for all angles.")
# If we just finished the angle/channel the user is currently
# looking at and it wasn't shown yet (e.g. they switched here
# before precompute caught up and are still waiting), show it now.
if (self._sras is not None and angle_idx == self.spin_angle.value()
and self._compute_thread is None
and self.combo_channel.currentIndex() in (CH3_IDX, CH4_IDX)
and (self._current_angle != angle_idx
or self._current_ch != self.combo_channel.currentIndex())):
self._refresh_display()
def _on_dc_precompute_thread_finished(self, thread: QThread):
# See the comment in _on_compute_thread_finished: wait() before
# releasing the reference to avoid destroying a QThread whose OS
# thread hasn't fully joined yet.
thread.wait()
if self._dc_precompute_thread is thread:
self._dc_precompute_thread = None
self._dc_precompute_worker = None
def _redraw_image(self, img: np.ndarray): def _redraw_image(self, img: np.ndarray):
s = self._sras s = self._sras
@@ -2323,14 +2557,20 @@ class SrasViewerWindow(QMainWindow):
if dlg.exec() == QDialog.DialogCode.Accepted: if dlg.exec() == QDialog.DialogCode.Accepted:
_fft_backend = dlg.get_backend() _fft_backend = dlg.get_backend()
self._fft_pad_factor = dlg.get_pad_factor() self._fft_pad_factor = dlg.get_pad_factor()
# Pad factor changes the FFT bin count, so it genuinely
# invalidates the cached raw FFT (part of the cache key) —
# _refresh_display() will recompute only on a cache miss.
if self._sras is not None and self.combo_channel.currentIndex() in ( if self._sras is not None and self.combo_channel.currentIndex() in (
CH1_IDX, VELOCITY_MODE_IDX): CH1_IDX, VELOCITY_MODE_IDX):
self._start_compute() self._refresh_display()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def closeEvent(self, event): def closeEvent(self, event):
for attr in ("_load_thread", "_compute_thread", "_preprocess_thread"): if self._dc_precompute_worker is not None:
self._dc_precompute_worker.stop()
for attr in ("_load_thread", "_compute_thread", "_preprocess_thread",
"_dc_precompute_thread"):
t = getattr(self, attr, None) t = getattr(self, attr, None)
if t is not None: if t is not None:
t.quit() t.quit()