From 158f60d2a7733ff4810231737d1364f033cf4449 Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Thu, 30 Jul 2026 20:36:44 -0500 Subject: [PATCH] Stop recomputing images on display-only setting changes; cache and lazily compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sras_viewer.py | 336 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 288 insertions(+), 48 deletions(-) diff --git a/sras_viewer.py b/sras_viewer.py index 0cb1231..3ec743c 100644 --- a/sras_viewer.py +++ b/sras_viewer.py @@ -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) @@ -747,7 +759,51 @@ class ComputeWorker(QObject): self._sras.ch_ymult_mv[self._ch], self._sras.ch_yoff_adc[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: self.error.emit(str(exc)) @@ -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()