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:
+287
-47
@@ -713,33 +713,45 @@ class LoadWorker(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)
|
||||
|
||||
def __init__(self, sras: SrasFile, angle_idx: int,
|
||||
ch_idx: int, dc_threshold_mv: float,
|
||||
grating_um: float = 25,
|
||||
ch_idx: int,
|
||||
apply_bg_sub: bool = True,
|
||||
n_fft: int | None = None):
|
||||
super().__init__()
|
||||
self._sras = sras
|
||||
self._angle = angle_idx
|
||||
self._ch = ch_idx
|
||||
self._threshold = dc_threshold_mv
|
||||
self._grating_um = grating_um
|
||||
self._apply_bg_sub = apply_bg_sub
|
||||
self._n_fft = n_fft
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self._ch == CH1_IDX:
|
||||
img = compute_rf_image(self._sras, self._angle, self._threshold,
|
||||
self._apply_bg_sub, n_fft=self._n_fft)
|
||||
elif self._ch == VELOCITY_MODE_IDX:
|
||||
# velocity (m/s) = freq (MHz) × grating (µm) [units cancel to m/s]
|
||||
img = compute_rf_image(self._sras, self._angle, self._threshold,
|
||||
self._apply_bg_sub, n_fft=self._n_fft)
|
||||
img = img * self._grating_um
|
||||
if self._ch in (CH1_IDX, VELOCITY_MODE_IDX):
|
||||
raw_freq_mhz = compute_rf_image(
|
||||
self._sras, self._angle, dc_threshold_mv=-1e9,
|
||||
apply_bg_sub=self._apply_bg_sub, n_fft=self._n_fft)
|
||||
dc4_adc = compute_dc_image(self._sras, self._angle, CH4_IDX)
|
||||
dc4_mv = adc_to_mv(dc4_adc,
|
||||
self._sras.ch_ymult_mv[CH4_IDX],
|
||||
self._sras.ch_yoff_adc[CH4_IDX],
|
||||
self._sras.ch_yzero_mv[CH4_IDX])
|
||||
self.finished.emit((raw_freq_mhz, dc4_mv))
|
||||
else:
|
||||
# DC channels: convert ADC counts → mV
|
||||
adc_img = compute_dc_image(self._sras, self._angle, self._ch)
|
||||
@@ -752,6 +764,50 @@ class ComputeWorker(QObject):
|
||||
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:
|
||||
self.error.emit(str(exc))
|
||||
|
||||
|
||||
class PreprocessWorker(QObject):
|
||||
"""Compute all-angle FFT and DC images and write a v5 file.
|
||||
|
||||
@@ -1421,8 +1477,6 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._compute_thread: QThread | None = None
|
||||
self._pending_angle: 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._progress_dlg: QProgressDialog | None = None
|
||||
|
||||
@@ -1432,6 +1486,19 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
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()
|
||||
|
||||
if initial_path:
|
||||
@@ -1484,6 +1551,11 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.lbl_frame_warn.setWordWrap(True)
|
||||
self.lbl_frame_warn.setStyleSheet("color: #e07000; font-size: 11px;")
|
||||
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)
|
||||
|
||||
# View settings
|
||||
@@ -1632,7 +1704,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.combo_cmap.addItems(CMAPS)
|
||||
self.combo_cmap.setCurrentText("gray")
|
||||
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)
|
||||
dl.addLayout(cmr)
|
||||
|
||||
@@ -1777,6 +1849,12 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._sras = sras
|
||||
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
|
||||
# geometry, so discard it on every load.
|
||||
self.image_canvas.clear_roi()
|
||||
@@ -1788,11 +1866,23 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.spin_angle.setValue(0)
|
||||
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)
|
||||
# Refresh the ADC-count label now that we have file calibration
|
||||
self._on_threshold_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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1877,13 +1967,19 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._on_view_changed()
|
||||
|
||||
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.combo_channel.currentIndex() in CH1_DERIVED_MODES:
|
||||
self._start_compute()
|
||||
self._refresh_display()
|
||||
|
||||
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:
|
||||
self._start_compute()
|
||||
self._refresh_display()
|
||||
|
||||
def _on_export_csv(self):
|
||||
if self._current_image is None or self._sras is None:
|
||||
@@ -2029,8 +2125,10 @@ class SrasViewerWindow(QMainWindow):
|
||||
else:
|
||||
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")
|
||||
# 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:
|
||||
self._start_compute()
|
||||
self._refresh_display()
|
||||
|
||||
def _on_autoscale_toggled(self, checked: bool):
|
||||
manual = not checked
|
||||
@@ -2043,18 +2141,82 @@ class SrasViewerWindow(QMainWindow):
|
||||
if not self.chk_auto.isChecked() and self._current_image is not None:
|
||||
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):
|
||||
if self._sras is None:
|
||||
return
|
||||
idx = self.spin_angle.value()
|
||||
self.lbl_angle_deg.setText(f"({self._sras.angles_deg[idx]:.1f}°)")
|
||||
self._update_scan_info_labels()
|
||||
self._start_compute()
|
||||
self._refresh_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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):
|
||||
if self._sras is None:
|
||||
return
|
||||
@@ -2063,17 +2225,12 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
angle_idx = self.spin_angle.value()
|
||||
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()
|
||||
pad_factor = self._fft_pad_factor
|
||||
n_fft = (self._sras.samples_per_frame * pad_factor
|
||||
if pad_factor > 1 else None)
|
||||
n_fft = self._current_n_fft()
|
||||
|
||||
self._pending_angle = angle_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_fft_pad_factor = pad_factor
|
||||
|
||||
@@ -2085,8 +2242,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
# running) that second thread's QThread object — which aborts the
|
||||
# process. Assigning immediately closes that window.
|
||||
self._compute_worker = ComputeWorker(
|
||||
self._sras, angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
|
||||
n_fft=n_fft,
|
||||
self._sras, angle_idx, ch_idx, apply_bg_sub, n_fft=n_fft,
|
||||
)
|
||||
self._compute_thread = QThread()
|
||||
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_thread.finished.connect(self._on_compute_thread_finished)
|
||||
|
||||
self.statusBar().showMessage("Computing image…")
|
||||
self._show_progress("Computing image…")
|
||||
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
|
||||
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()
|
||||
|
||||
@@ -2114,25 +2278,95 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._compute_thread = None
|
||||
angle_idx = self.spin_angle.value()
|
||||
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()
|
||||
if (angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
|
||||
self._fft_pad_factor) != (
|
||||
if (angle_idx, ch_idx, apply_bg_sub, self._fft_pad_factor) != (
|
||||
self._pending_angle, self._pending_ch,
|
||||
self._pending_threshold, self._pending_grating_um,
|
||||
self._pending_bg_sub,
|
||||
self._pending_fft_pad_factor):
|
||||
self._start_compute()
|
||||
self._pending_bg_sub, self._pending_fft_pad_factor):
|
||||
# Settings changed while this compute was running — re-dispatch
|
||||
# through the cache-aware path in case that now-current
|
||||
# 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._current_image = img
|
||||
self._current_angle = self._pending_angle
|
||||
self._current_ch = self._pending_ch
|
||||
self.btn_export_csv.setEnabled(self._pending_ch in CH1_DERIVED_MODES)
|
||||
self._redraw_image(img)
|
||||
self._update_roi_ui()
|
||||
angle_idx = self._pending_angle
|
||||
ch_idx = self._pending_ch
|
||||
|
||||
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
|
||||
raw_freq_mhz, dc4_mv = result
|
||||
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):
|
||||
s = self._sras
|
||||
@@ -2323,14 +2557,20 @@ class SrasViewerWindow(QMainWindow):
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||||
_fft_backend = dlg.get_backend()
|
||||
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 (
|
||||
CH1_IDX, VELOCITY_MODE_IDX):
|
||||
self._start_compute()
|
||||
self._refresh_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
if t is not None:
|
||||
t.quit()
|
||||
|
||||
Reference in New Issue
Block a user