Stop discarding precomputed FFT data on view switches

The display path hardcoded row_avg_n=0 when checking a file's stored FFT
cache, since the main window has no row-averaging control (only the
batch-only RowAverageFftOptionsDialog). Once a file was batch-computed with
row-averaging, every view switch saw a phantom provenance mismatch and
silently launched a full raw recompute, discarding the precomputed data.

The display now asks for the stored image at the settings it was actually
computed under (bg-sub/pad/row-averaging), rather than the window's live
controls, so a stored or already-computed image is always shown once
present. Those controls now only affect a first-time compute for an
uncached angle or an explicit batch recompute -- never what's already on
screen. DC threshold is unaffected: it stays live, since re-masking a
cached image is free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-09 18:21:34 -05:00
parent c30c8b1815
commit d5914b5793
4 changed files with 153 additions and 54 deletions
+14 -5
View File
@@ -222,16 +222,25 @@ def test_threshold_change_recomputes(ctx):
def test_bg_sub_toggle(ctx):
"""bg-sub no longer gates the display: it only affects a future live
compute for an angle with nothing cached yet, or an explicit batch
recompute. Toggling it on an angle that already has an FFT image must
leave that image on screen, untouched."""
win = ctx.win
n_before = len(win._fft_cache)
img_before = win._current_image
win.chk_bg_sub.setChecked(False)
assert wait_until(
lambda: not win._job_running("compute") and len(win._fft_cache) > n_before), \
"recomputed without bg-sub"
win.chk_bg_sub.setChecked(True)
pump(200)
assert not win._job_running("compute"), \
"returning to bg-sub was a cache hit (no recompute)"
"toggling bg-sub alone must not dispatch a recompute"
assert len(win._fft_cache) == n_before, "no new cache entry from the toggle"
assert np.array_equal(win._current_image, img_before), \
"displayed image unchanged by the bg-sub toggle"
win.chk_bg_sub.setChecked(True)
pump(200)
assert not win._job_running("compute")
assert len(win._fft_cache) == n_before
assert np.array_equal(win._current_image, img_before)
def test_roi_and_csv_export(ctx):
+53 -16
View File
@@ -256,11 +256,16 @@ def test_viewer_shows_stored_angles_without_computing(rig, no_fft, monkeypatch):
rig.fresh[0] * win.spin_grating_um.value(), atol=1e-3)
assert dispatched == [] and not no_fft
# ...but a setting the stored image cannot serve must still recompute,
# or the fast path would be showing the wrong picture.
# ...and a live control that no longer matches the stored image's own
# provenance must NOT force a recompute either — the stored image is
# shown as-is; only an explicit batch recompute changes what's shown.
win.combo_channel.setCurrentIndex(CH1_IDX)
pump(60)
win.chk_bg_sub.setChecked(False)
assert wait_until(lambda: not win._job_running("compute") and bool(no_fft)), \
"bg-sub off falls through to a real FFT"
pump(200)
assert not win._job_running("compute") and not no_fft, \
"bg-sub off still shows the stored image, no real FFT"
assert np.allclose(win._current_image, rig.fresh[0], atol=1e-3)
finally:
win.close()
pump(300)
@@ -322,16 +327,21 @@ def test_batch_caches_at_the_viewers_pad_factor(tmp_path, no_fft, monkeypatch):
f"no recompute at pad 10 (jobs={dispatched}, fft={no_fft})"
assert "unusable" not in win.lbl_frame_warn.text()
# Change the pad and the cache legitimately stops applying — and the
# info panel has to say so rather than leave it a mystery.
# Change the live pad control so it no longer matches the stored
# image's own provenance — the info panel has to say so rather than
# leave it a mystery, but the stored pad-10 image keeps displaying;
# only an explicit batch recompute would ever produce a pad-4 one.
win._fft_pad_factor = 4
win._update_scan_info_labels()
assert "Cached FFT unusable" in win.lbl_frame_warn.text(), \
assert "differ from the stored cache" in win.lbl_frame_warn.text(), \
win.lbl_frame_warn.text()
assert "pad 10x" in win.lbl_frame_warn.text()
last_angle = win._current_angle
win._refresh_display()
assert wait_until(lambda: not win._job_running("compute") and bool(no_fft)), \
"pad 4 recomputes rather than reusing the pad-10 cache"
assert wait_until(lambda: not win._job_running("compute")), "settled"
assert not no_fft, "no real FFT ran — the pad-10 cache still served the view"
assert np.allclose(win._current_image, expected[last_angle], atol=1e-3), \
"pad-10 cache still shown after the live pad control diverged"
finally:
win.close()
pump(300)
@@ -500,16 +510,17 @@ def test_cach_v1_backward_compat_defaults_row_avg_n_zero(tmp_path):
"a v1 tail (predating this feature) can never satisfy a row-averaged request"
def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch):
def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch, no_fft):
"""Driving the new 'Batch Compute Row-Averaged FFT and Store' action
end-to-end through the real menu handler: dialog values reach the
worker, the worker reaches cache_file, and the written file is
self-describing afterward. Deliberately does not assert anything about
whether viewing an angle afterward dispatches a compute job -- that is
a separate, pre-existing gap in _refresh_display shared with the plain
DC/FFT batch actions (see test_viewer_shows_stored_angles_without_computing
/ test_viewer_shows_stored_dc_without_computing above), not something
row-averaging introduces or is responsible for fixing."""
self-describing afterward. Also the exact scenario the row-averaged-FFT
recompute bug reported: before the fix, the display always asked for
row_avg_n=0 regardless of what the file actually had stored, so viewing
an angle after this batch action saw a phantom mismatch and launched a
full raw recompute on every view switch. This asserts that no longer
happens -- the stored row-averaged image is shown directly, with no
dispatched compute job and no real FFT."""
path = tmp_path / "rowavg_gui.sras"
gen.write(path, n_angles=2, seed=26, samples_per_frame=128)
@@ -531,6 +542,12 @@ def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch):
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
dispatched = []
original_start = type(win)._start_compute
monkeypatch.setattr(type(win), "_start_compute",
lambda self: (dispatched.append(self.spin_angle.value()),
original_start(self))[1])
try:
win._load_file(str(path))
assert wait_until(lambda: win._sras is not None), "file loaded"
@@ -553,6 +570,26 @@ def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch):
apply_bg_sub=win.chk_bg_sub.isChecked(),
row_avg_n=6)
assert expected is not None, "the batch write left a readable row-averaged cache"
# The regression this batch action used to leave unfixed: viewing an
# angle afterward must show the stored row-averaged image directly,
# never fall through to a real (raw) recompute.
no_fft.clear()
dispatched.clear()
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
for a in range(win._sras.n_angles):
win.spin_angle.setValue(a)
pump(60)
assert np.allclose(win._current_image,
compute.cached_rf_image(
win._sras, a,
dc_threshold_mv=win.spin_threshold_mv.value(),
apply_bg_sub=win.chk_bg_sub.isChecked(),
row_avg_n=6), atol=1e-3), \
f"angle {a} shows the stored row-averaged image"
assert dispatched == [] and not no_fft, \
f"no recompute for row-averaged angles (jobs={dispatched}, fft={no_fft})"
finally:
win.close()
pump(300)