Bound nested parallelism, make compute cancellable, harden batch pool
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>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user