9a7cc396e7
Three defects found by testing the parallel paths end to end.
Nested parallelism was unbounded. DcPrecomputeWorker and the alignment
driver both parallelise over angles, and each angle's compute_dc_image
then parallelised over rows again: 14 x 14 threads, and — worse — every
angle sized its chunk against the *whole* memory budget, so worst-case
live buffers were ~13.9 GB against a 1 GB budget. Capping the inner
worker count alone does not fix it; the chunk is still sized against the
full budget. plan_angle_level now hands each angle both max_workers=1
and its share of the budget, which bounds the real file at 1002 MB.
Chunk planning could not produce concurrency at all. _plan_chunks sized
chunk_rows first, and _chunk_rows_for spends whatever budget it is given
— so a single chunk consumed the lot and budget//chunk_bytes came back
as one worker. It now picks the worker count first and sizes the chunk to
it, giving 7-16 workers on the real file instead of 1.
Cancellation was too coarse to shut down. stop() was only checked between
angles, and one angle of the real scan is ~40 s, so closing the window
sat through it and returned with threads still running. should_stop is
now polled per row chunk and closeEvent signals every worker before
waiting: close went from 4.0 s with two live threads to 1.04 s with both
finished. A cancelled ComputeWorker emits None so a half-filled image is
never cached.
Also: BatchCacheWorker no longer reports every file as failed when the
process pool itself dies (unguarded __main__, frozen build, sandbox) —
it retries those files in-process. Batches below 512 MB skip the pool
entirely, since interpreter startup would otherwise make small jobs
slower. cache_file rejects an unknown mode instead of silently treating
it as "fft".
Measured on the real 496 GB scan, angle 3, 32 rows:
DC 2.94s -> 0.75s RF 6.10s -> 1.99s peak RSS 1.40 -> 1.93 GB
Batch convert, 24 files / 1 GB: 4.03s -> 2.36s.
Memory budget defaults to 1024 MB (SRAS_MEM_BUDGET_MB), the measured knee.
Testing: tools/test_refactor.py (70 checks: cache round-trip and block
carry-forward, partial-cache handling, parallel-vs-serial identity,
no-mask path, ROI mask vs full-grid, v2/v3/v4 parsing, sras_average
round-trip incl. remainder handling) and tools/test_gui.py (46 checks
driving the real widgets and workers headlessly). check_equivalence.py
still reports all 167 outputs identical to ed0eba4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
11 KiB
Python
271 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Headless GUI test: drives SrasViewerWindow through the real Qt widgets,
|
|
signals and worker threads under the offscreen platform plugin.
|
|
|
|
Covers the interactions a manual smoke test would: load, switch angles and
|
|
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
|
|
changes, angle alignment, aligned view, ROI draw/move, and CSV export.
|
|
|
|
Usage: QT_QPA_PLATFORM=offscreen python tools/test_gui.py [file.sras]
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
import numpy as np # noqa: E402
|
|
from PyQt6.QtCore import QEventLoop, QTimer # noqa: E402
|
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX # noqa: E402
|
|
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402
|
|
import tools.make_test_sras as gen # noqa: E402
|
|
|
|
_failures: list[str] = []
|
|
|
|
|
|
def check(name: str, ok: bool, detail: str = ""):
|
|
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""))
|
|
if not ok:
|
|
_failures.append(name)
|
|
|
|
|
|
def pump(ms: int = 250):
|
|
"""Run the event loop for a while so queued signals and worker threads
|
|
make progress."""
|
|
loop = QEventLoop()
|
|
QTimer.singleShot(ms, loop.quit)
|
|
loop.exec()
|
|
|
|
|
|
def wait_until(pred, timeout_ms: int = 20000, step: int = 100) -> bool:
|
|
waited = 0
|
|
while waited < timeout_ms:
|
|
if pred():
|
|
return True
|
|
pump(step)
|
|
waited += step
|
|
return pred()
|
|
|
|
|
|
def main():
|
|
app = QApplication(sys.argv)
|
|
errors: list[str] = []
|
|
|
|
tmpdir = Path(tempfile.mkdtemp(prefix="sras_gui_"))
|
|
path = Path(sys.argv[1]) if len(sys.argv) > 1 else tmpdir / "gui.sras"
|
|
if len(sys.argv) <= 1:
|
|
gen.write(path, n_angles=4, seed=11, samples_per_frame=256)
|
|
|
|
print(f"\nloading {path.name}")
|
|
win = SrasViewerWindow()
|
|
win.show()
|
|
# Capture anything the app reports as an error via the status bar.
|
|
win.statusBar().messageChanged.connect(
|
|
lambda m: errors.append(m) if m and "error" in m.lower() else None)
|
|
|
|
win._load_file(str(path))
|
|
check("file loaded", wait_until(lambda: win._sras is not None))
|
|
s = win._sras
|
|
check("parsed as v6", s.version == 6, f"v{s.version}")
|
|
check("defaults to CH4", win.combo_channel.currentIndex() == CH4_IDX)
|
|
check("image displayed", win._current_image is not None)
|
|
check("angle spinbox ranges over all angles",
|
|
win.spin_angle.maximum() == s.n_angles - 1)
|
|
check("scan info populated",
|
|
win._info["Angles"].text() == f"Angles: {s.n_angles}",
|
|
win._info["Angles"].text())
|
|
|
|
print("\nbackground DC precompute (all angles)")
|
|
ok = wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
|
|
and (a, CH3_IDX) in win._dc_cache
|
|
for a in range(s.n_angles)))
|
|
check("every angle cached for CH3 and CH4", ok,
|
|
f"{len(win._dc_cache)} entries")
|
|
check("status label reports completion",
|
|
"ready for all angles" in win.lbl_dc_precompute.text(),
|
|
win.lbl_dc_precompute.text())
|
|
|
|
print("\nangle switching (DC, should be served from cache)")
|
|
for a in range(s.n_angles):
|
|
win.spin_angle.setValue(a)
|
|
win._on_view_changed()
|
|
pump(60)
|
|
expected = win._sras.image_shape(a)
|
|
check(f"angle {a} shows its own geometry {expected}",
|
|
win._current_image.shape == expected,
|
|
str(win._current_image.shape))
|
|
check("no compute job needed for cached DC angles",
|
|
not win._job_running("compute"))
|
|
|
|
print("\nchannel switching")
|
|
win.spin_angle.setValue(0)
|
|
win._on_view_changed()
|
|
pump(60)
|
|
win.combo_channel.setCurrentIndex(CH3_IDX)
|
|
check("CH3 displayed", wait_until(lambda: win._current_ch == CH3_IDX))
|
|
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
check("CH1 (FFT) computed", wait_until(
|
|
lambda: win._current_ch == CH1_IDX and not win._job_running("compute")))
|
|
check("FFT result cached", len(win._fft_cache) > 0, f"{len(win._fft_cache)} keys")
|
|
rf_img = win._current_image
|
|
check("FFT image is non-degenerate", len(np.unique(rf_img)) > 1,
|
|
f"{len(np.unique(rf_img))} unique values")
|
|
|
|
print("\nvelocity mode (pure post-multiply, no recompute)")
|
|
n_fft_before = len(win._fft_cache)
|
|
win.combo_channel.setCurrentIndex(VELOCITY_MODE_IDX)
|
|
check("velocity displayed", wait_until(
|
|
lambda: win._current_ch == VELOCITY_MODE_IDX and not win._job_running("compute")))
|
|
grating = win.spin_grating_um.value()
|
|
check("velocity == freq x grating",
|
|
np.allclose(win._current_image, rf_img * grating, atol=1e-3))
|
|
check("velocity reused the cached FFT", len(win._fft_cache) == n_fft_before,
|
|
f"{n_fft_before} -> {len(win._fft_cache)}")
|
|
check("grating spinbox visible in velocity mode", win.grp_velocity.isVisible())
|
|
|
|
print("\nthreshold change (genuine cache-key change)")
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
wait_until(lambda: not win._job_running("compute"))
|
|
dc4 = win._dc_cache[(0, CH4_IDX)]
|
|
win.spin_threshold_mv.setValue(float(np.median(dc4)))
|
|
win._on_threshold_changed()
|
|
check("recomputed at new threshold", wait_until(
|
|
lambda: not win._job_running("compute") and len(win._fft_cache) > n_fft_before))
|
|
check("masking zeroed some pixels",
|
|
int((win._current_image == 0).sum()) > 0,
|
|
f"{int((win._current_image == 0).sum())} of {win._current_image.size}")
|
|
|
|
print("\nbackground subtraction toggle")
|
|
n_before = len(win._fft_cache)
|
|
win.chk_bg_sub.setChecked(False)
|
|
check("recomputed without bg-sub", wait_until(
|
|
lambda: not win._job_running("compute") and len(win._fft_cache) > n_before))
|
|
win.chk_bg_sub.setChecked(True)
|
|
pump(200)
|
|
check("returning to bg-sub was a cache hit (no recompute)",
|
|
not win._job_running("compute"))
|
|
|
|
print("\nROI")
|
|
x = s.x_axis_mm(0)
|
|
y = s.y_positions_mm(0)
|
|
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]),
|
|
float(x[-2]), float(y[-2]))
|
|
win.image_canvas.set_roi(roi)
|
|
pump(120)
|
|
check("ROI registered", win.image_canvas.get_roi() is not None)
|
|
check("pixel count reported",
|
|
"pixels inside" in win.lbl_roi_npix.text()
|
|
and win.lbl_roi_npix.text() != "pixels inside: —",
|
|
win.lbl_roi_npix.text())
|
|
npix = int(win.lbl_roi_npix.text().split(":")[1])
|
|
check("ROI pixel count is plausible",
|
|
0 < npix <= win._current_image.size, f"{npix}")
|
|
check("Export ROI enabled", win.btn_export_roi.isEnabled())
|
|
|
|
csv_path = tmpdir / "roi.csv"
|
|
from unittest.mock import patch
|
|
with patch("sras_viewer.QFileDialog.getSaveFileName",
|
|
return_value=(str(csv_path), "")):
|
|
win._on_export_roi_csv()
|
|
check("ROI CSV written", csv_path.exists())
|
|
if csv_path.exists():
|
|
body = [l for l in csv_path.read_text().splitlines() if not l.startswith("#")]
|
|
check("ROI CSV has header + one line per pixel",
|
|
len(body) == npix + 1, f"{len(body)} lines for {npix} pixels")
|
|
|
|
img_csv = tmpdir / "img.csv"
|
|
with patch("sras_viewer.QFileDialog.getSaveFileName",
|
|
return_value=(str(img_csv), "")):
|
|
win._on_export_csv()
|
|
check("image CSV written", img_csv.exists())
|
|
if img_csv.exists():
|
|
arr = np.loadtxt(img_csv, delimiter=",")
|
|
check("image CSV round-trips the displayed image",
|
|
arr.shape == win._current_image.shape
|
|
and np.allclose(arr, win._current_image, rtol=1e-5, atol=1e-4))
|
|
|
|
print("\nROI survives angle and channel switches")
|
|
win.spin_angle.setValue(1)
|
|
win._on_view_changed()
|
|
wait_until(lambda: not win._job_running("compute"))
|
|
check("ROI still present after angle switch",
|
|
win.image_canvas.get_roi() is not None)
|
|
win.combo_channel.setCurrentIndex(CH4_IDX)
|
|
wait_until(lambda: win._current_ch == CH4_IDX)
|
|
check("ROI still present after channel switch",
|
|
win.image_canvas.get_roi() is not None)
|
|
|
|
print("\nangle alignment (Fusion)")
|
|
win.spin_angle.setValue(0)
|
|
win._on_view_changed()
|
|
wait_until(lambda: not win._job_running("compute"))
|
|
check("alignment action enabled", win._alignment_act.isEnabled())
|
|
win._on_angle_alignment()
|
|
check("alignment completed", wait_until(
|
|
lambda: win._alignment_result is not None and not win._job_running("align"),
|
|
timeout_ms=60000))
|
|
if win._alignment_result is not None:
|
|
r = win._alignment_result
|
|
check("transform for every angle", len(r.per_angle) == s.n_angles)
|
|
check("canvas is at least as large as any single angle",
|
|
all(r.canvas_shape[0] >= int(s.n_rows[a])
|
|
and r.canvas_shape[1] >= int(s.n_frames[a])
|
|
for a in range(s.n_angles)), str(r.canvas_shape))
|
|
check("reference angle has zero shift",
|
|
r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0))
|
|
check("Aligned View auto-enabled and checked",
|
|
win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked())
|
|
pump(200)
|
|
check("displayed image is on the alignment canvas",
|
|
win.image_canvas._img_shape == r.canvas_shape,
|
|
f"{win.image_canvas._img_shape} vs {r.canvas_shape}")
|
|
|
|
win.chk_aligned_view.setChecked(False)
|
|
pump(200)
|
|
check("unchecking returns to the raw per-angle grid",
|
|
win.image_canvas._img_shape == s.image_shape(0),
|
|
str(win.image_canvas._img_shape))
|
|
|
|
print("\npixel inspector")
|
|
win.chk_aligned_view.setChecked(False)
|
|
pump(100)
|
|
win._on_pixel_clicked(0, 0)
|
|
pump(150)
|
|
check("waveform hint hidden after a click", win.lbl_wave_hint.isHidden())
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
wait_until(lambda: not win._job_running("compute"))
|
|
win._on_pixel_clicked(1, 1)
|
|
pump(150)
|
|
check("RF waveform panel rendered",
|
|
len(win.wave_canvas.ax_wave.lines) > 0,
|
|
f"{len(win.wave_canvas.ax_wave.lines)} lines")
|
|
|
|
print("\nshutdown")
|
|
win.close()
|
|
pump(400)
|
|
check("all background jobs released", len(win._jobs) == 0,
|
|
f"{list(win._jobs)}")
|
|
|
|
print()
|
|
unexpected = [e for e in errors if e]
|
|
if unexpected:
|
|
print(f"status-bar errors seen: {unexpected}")
|
|
_failures.append("status-bar errors")
|
|
|
|
if _failures:
|
|
print(f"{len(_failures)} FAILURE(S): " + ", ".join(_failures))
|
|
return 1
|
|
print("All GUI checks passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|