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):
|
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)
|
||||||
@@ -752,6 +764,50 @@ class ComputeWorker(QObject):
|
|||||||
self.error.emit(str(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))
|
||||||
|
|
||||||
|
|
||||||
class PreprocessWorker(QObject):
|
class PreprocessWorker(QObject):
|
||||||
"""Compute all-angle FFT and DC images and write a v5 file.
|
"""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._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()
|
||||||
|
|||||||
Reference in New Issue
Block a user