Replace the two Fusion alignment actions with a three-step wizard

Alignment was two disconnected menu actions. `Angle Alignment` ran the
registration with ref_angle_idx hard-coded to 0, no exposed parameters and
no way to retry; `Manual Alignment...` was a separate dialog that
deliberately refused to inherit the automatic result, so a bad fit meant
starting over by hand. Neither told you whether the alignment was any
good, and neither produced anything durable beyond an in-memory result.

Fusion -> Alignment Wizard... now covers all of it in three steps:

  1. Correlate. Reference angle, DC threshold, source, rotation seed and
     sign, search window, coarse step and fine grid are all on the page,
     and Run/Re-run is repeatable. Angles start pre-rotated from the
     stage angles in the file, so the page is informative before any
     correlation runs. The verdict is a picture: every angle's DC mask
     reprojected onto the shared canvas and summed, coloured by how many
     angles cover each pixel, so a good alignment reads as one saturated
     plateau and a bad one as a fringe of low-count halos. A per-angle
     fit table flags angles that did not register or that disagree with
     their stage angle by more than a degree.
  2. Crop. An axis-aligned rectangle on that canvas, with numeric
     canvas-pixel boxes synced both ways and a live size estimate.
     "Fit to full overlap" uses a largest-rectangle sweep rather than a
     bounding box: the overlap region of several rotated scans is roughly
     a disc, whose bounding box has corners no angle covers.
  3. Save. Writes the aligned, cropped stack to a new .sras.

Because the wizard replaces both actions it absorbs the old dialog's
by-eye nudge editor — without it, a scan the search cannot fit would
have no fallback at all. ManualAlignmentDialog is therefore deleted
rather than left orphaned, and its tests move to the wizard.

Two bugs found while driving it end to end and fixed here: QSpinBox
setRange clamps and emits valueChanged, which committed a 1x1 crop
before the default preset could run; and the mm round trip returns an
exact pixel boundary as 11.000000000000002, so a bare ceil() added a
spurious column on every rectangle edit.

Verified: 103 pass, the 6 test_stored_cache failures are pre-existing on
main, and tools/check_equivalence.py is byte-identical to main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-07 23:00:59 -05:00
parent 6d0e30b9ce
commit f40c965b74
15 changed files with 2037 additions and 964 deletions
+34
View File
@@ -406,6 +406,40 @@ def test_largest_rect_at_least():
assert compute.largest_rect_at_least(np.full((3, 4), 2), 2) == (0, 0, 3, 4)
def test_largest_rect_matches_brute_force():
"""Randomized check against an O(n^4) reference.
The histogram sweep is short and easy to get subtly wrong — an off-by-one in
the stack unwind yields rectangles that are merely large, and "large but not
maximal" is invisible by eye on real data.
"""
def brute(good):
n_rows, n_cols = good.shape
best = 0
for r0 in range(n_rows):
for r1 in range(r0 + 1, n_rows + 1):
run = 0
for g in good[r0:r1].all(axis=0):
run = run + 1 if g else 0
best = max(best, run * (r1 - r0))
return best
rng = np.random.default_rng(0)
for _ in range(200):
counts = rng.integers(0, 3, size=(int(rng.integers(1, 9)),
int(rng.integers(1, 9))))
got = compute.largest_rect_at_least(counts, 2)
expected = brute(counts >= 2)
if got is None:
assert expected == 0
continue
row0, col0, nr, nc = got
assert (counts[row0:row0 + nr, col0:col0 + nc] >= 2).all(), \
f"rectangle is not pure:\n{counts}\n{got}"
assert nr * nc == expected, \
f"not maximal ({nr * nc} < {expected}):\n{counts}\n{got}"
def test_largest_rect_is_pure_on_the_real_fixture(rig):
"""On real overlap counts the returned rectangle must contain only
full-overlap pixels — the property a bounding box would violate."""
+1 -1
View File
@@ -155,7 +155,7 @@ def test_all_angles_stack(rig):
def test_downsampled_preview_lands_with_full_res(rig):
# ManualAlignmentDialog reprojects block-mean-downsampled masks, so the
# The wizard reprojects block-mean-downsampled masks, so the
# affine has to account for the factor. When it did not, every preview
# layer came out magnified by that factor and offset — the overlay showed a
# blown-up crop of each mask, which is not something you can align by eye.
+282 -170
View File
@@ -3,8 +3,8 @@ 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, manual angle alignment, aligned view, ROI
draw/move, and CSV export.
changes, the alignment wizard end to end (pre-rotation, correlation, manual
nudging, crop, export), aligned view, ROI draw/move, and CSV export.
NOTE: this module is one ordered integration sequence over a single shared
window — the tests build on each other's state and must run in definition
@@ -218,44 +218,16 @@ def test_roi_survives_switches(ctx):
"ROI still present after channel switch"
def test_angle_alignment(ctx):
win, s = ctx.win, ctx.s
win.spin_angle.setValue(0)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
assert win._alignment_act.isEnabled(), "alignment action enabled"
win._on_angle_alignment()
assert wait_until(
lambda: win._alignment_result is not None and not win._job_running("align"),
timeout_ms=60000), "alignment completed"
r = win._alignment_result
assert len(r.per_angle) == s.n_angles, "transform for every angle"
assert 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)), \
f"canvas is at least as large as any single angle: {r.canvas_shape}"
assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \
"reference angle has zero shift"
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \
"Aligned View auto-enabled and checked"
pump(200)
assert 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)
assert win.image_canvas._img_shape == s.image_shape(0), \
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
def test_manual_alignment_geometry(ctx):
def test_alignment_geometry_is_stage_independent(ctx):
"""Local mm is anchored on each angle's array center, not its stage
position: that is what makes a scan's placement independent of where its
window happened to sit. (Registration accuracy itself is covered by
tests/test_alignment.py, which has a synthetic sample to register.)"""
win, s = ctx.win, ctx.s
assert win._manual_align_act.isEnabled(), "manual alignment action enabled"
win.spin_angle.setValue(0)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
assert win._wizard_act.isEnabled(), "alignment wizard action enabled"
n_rows, n_frames = s.image_shape(0)
assert np.allclose(compute._center_idx(s, 0),
@@ -276,7 +248,7 @@ def test_manual_alignment_geometry(ctx):
("moving every non-reference angle's scan window must leave the canvas "
f"unchanged: {origin_a} {shape_a} vs {origin_b} {shape_b}")
# Both signs of the stage's reported angle are searched.
# Both signs of the stage's reported angle are searched by default.
cands = compute._rotation_candidates(30.0, 6.0, 2.0)
assert min(cands) < -29.0 and max(cands) > 29.0, f"{min(cands)}..{max(cands)}"
@@ -289,128 +261,285 @@ def test_manual_alignment_geometry(ctx):
"_shift_into moves content by exactly the requested offset"
def test_manual_dialog_opens_at_identity(ctx):
"""Open must NOT seed from the still-live automatic AlignmentResult.
Manual mode exists to fix up whatever the automatic registration got
wrong, so it must start from identity (every angle centered on the
reference, no rotation) regardless of whatever the automatic run last
computed. Only a previously *saved manual* alignment (sidecar) should
ever seed this dialog."""
def test_wizard_opens_prerotated(ctx):
"""The wizard shows a mask stack before any correlation has run, built from
the stage angles in the file — the "pre-rotate" step. Nothing may seed from
the still-live automatic result; only a saved sidecar."""
win, s = ctx.win, ctx.s
win._on_manual_alignment()
assert win._manual_align_dialog is not None, "dialog opened"
ctx.dlg = dlg = win._manual_align_dialog
assert not win._job_running("manual_align_masks"), \
win._on_alignment_wizard()
assert win._align_wizard is not None, "wizard opened"
ctx.wiz = wiz = win._align_wizard
ctx.p1 = p1 = wiz.page(wiz.PAGE_CORRELATE)
assert not win._job_running("align_masks"), \
"mask prep needed no background worker (already DC-cached)"
assert all(dlg._angle_params[a] == compute.ManualAngleParams()
for a in range(s.n_angles)), \
"no manual sidecar yet -> dialog starts at identity, not the automatic result"
assert wait_until(lambda: p1.isComplete()), "masks ready, Next enabled"
assert not win._wizard_act.isEnabled(), \
"wizard action disabled while a wizard is open"
st = wiz.state
assert st.result is not None, "an AlignmentResult exists from pre-rotation alone"
assert st.counts is not None and st.counts.shape == st.preview_shape
assert 0 <= st.counts.max() <= s.n_angles
assert not st.fits, "no fits before a correlation has run"
for a in range(s.n_angles):
nominal = compute.nominal_delta_deg(s, a, st.ref_angle_idx)
expected = 0.0 if a == st.ref_angle_idx else nominal
assert abs(st.params[a].rotation_deg - expected) < 1e-9, \
f"angle {a} not pre-rotated to its stage angle"
assert st.params[a].shift_mm == (0.0, 0.0), \
"pre-rotation must not invent a translation"
def test_reference_angle_is_locked(ctx):
dlg = ctx.dlg
dlg.combo_active_angle.setCurrentIndex(dlg._ref_angle_idx)
def test_wizard_reference_angle_is_locked(ctx):
p1, wiz = ctx.p1, ctx.wiz
p1.combo_active.setCurrentIndex(wiz.state.ref_angle_idx)
pump(30)
before_ref = dlg._angle_params[dlg._ref_angle_idx]
dlg._on_nudge_translate(1, 0, False)
dlg._on_nudge_rotate(1, False)
assert not dlg.grp_manual_adjust.isEnabled(), "reference angle group disabled"
assert dlg._angle_params[dlg._ref_angle_idx] == before_ref, \
before = wiz.state.params[wiz.state.ref_angle_idx]
p1._on_nudge_translate(1, 0, False)
p1._on_nudge_rotate(1, False)
assert wiz.state.params[wiz.state.ref_angle_idx] == before, \
"reference angle untouched by nudge attempts"
def test_nudges(ctx):
"""Nudging a real angle (fine + coarse, translate + rotate)."""
dlg, s = ctx.dlg, ctx.s
def test_wizard_nudges(ctx):
"""Manual correction, which the wizard absorbed from the old dialog."""
p1, wiz, s = ctx.p1, ctx.wiz, ctx.s
ctx.active = active = 1 if s.n_angles > 1 else 0
dlg.combo_active_angle.setCurrentIndex(active)
p1.combo_active.setCurrentIndex(active)
pump(30)
before = dlg._angle_params[active].shift_mm
dlg._on_nudge_translate(1, 0, False) # fine +X
fine_step = dlg.spin_step_translate_mm.value()
assert abs(dlg._angle_params[active].shift_mm[0] - (before[0] + fine_step)) < 1e-9, \
before = wiz.state.params[active].shift_mm
p1._on_nudge_translate(1, 0, False)
fine = p1.spin_step_translate.value()
assert abs(wiz.state.params[active].shift_mm[0] - (before[0] + fine)) < 1e-9, \
"fine translate nudge moved shift_x by exactly one fine step"
before = dlg._angle_params[active].shift_mm
dlg._on_nudge_translate(0, -1, True) # coarse -Y
coarse_step = fine_step * dlg.spin_step_multiplier.value()
assert abs(dlg._angle_params[active].shift_mm[1] - (before[1] - coarse_step)) < 1e-9, \
before = wiz.state.params[active].shift_mm
p1._on_nudge_translate(0, -1, True)
coarse = fine * p1.spin_step_mult.value()
assert abs(wiz.state.params[active].shift_mm[1] - (before[1] - coarse)) < 1e-9, \
"coarse translate nudge uses the multiplier"
before_rot = dlg._angle_params[active].rotation_deg
dlg._on_nudge_rotate(1, False)
assert dlg._angle_params[active].rotation_deg != before_rot, \
"rotate nudge changed rotation_deg"
assert len(dlg._preview_layers) == s.n_angles, \
"preview canvas rebuilt for every angle after a rotation nudge"
before_rot = wiz.state.params[active].rotation_deg
p1._on_nudge_rotate(1, False)
assert wiz.state.params[active].rotation_deg != before_rot
assert len(wiz.state.layers) == s.n_angles, \
"stack rebuilt for every angle after a rotation nudge"
# Real key-event wiring (proves keyPressEvent -> signal -> slot).
before = dlg._angle_params[active].shift_mm
QTest.keyClick(dlg.canvas, Qt.Key.Key_Right)
assert dlg._angle_params[active].shift_mm[0] > before[0], \
# Real key-event wiring (keyPressEvent -> signal -> slot).
before = wiz.state.params[active].shift_mm
QTest.keyClick(p1.canvas, Qt.Key.Key_Right)
assert wiz.state.params[active].shift_mm[0] > before[0], \
"a real Right-arrow key event nudged shift_x"
def test_auto_derotate(ctx):
"""Auto De-rotate: seeds rotation from the stage angle, no translation."""
dlg, s, active = ctx.dlg, ctx.s, ctx.active
shift_before_derotate = dlg._angle_params[active].shift_mm
dlg._on_auto_derotate()
nominal = compute.nominal_delta_deg(s, active, dlg._ref_angle_idx)
assert abs(dlg._angle_params[active].rotation_deg - nominal) < 1e-6, \
"auto de-rotate seeded rotation from the stage's reported angle"
assert dlg._angle_params[active].shift_mm == shift_before_derotate, \
"auto de-rotate left translation untouched"
assert dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0, \
"reference angle stays identity after auto de-rotate"
# Clicking again offers the other sign, since which one lines the scans up
# is not knowable from the file.
dlg._on_auto_derotate()
assert abs(dlg._angle_params[active].rotation_deg + nominal) < 1e-6, \
"auto de-rotate offers the opposite sign on a second click"
# Both views render from the same reprojected layers.
p1.combo_view.setCurrentIndex(1)
pump(50)
p1.combo_view.setCurrentIndex(0)
pump(50)
def test_auto_cross_correlate(ctx):
"""Auto Cross-Correlate: searches rotation *and* translation."""
win, dlg, s = ctx.win, ctx.dlg, ctx.s
assert dlg.btn_auto_correlate.isEnabled(), \
"cross-correlate action enabled once masks are ready"
for label_idx, (label, _sources) in enumerate(dlg._CORRELATE_SOURCES):
dlg.combo_correlate_source.setCurrentIndex(label_idx)
dlg._on_auto_correlate()
assert wait_until(
lambda: not win._job_running("manual_align_correlate"),
timeout_ms=60000), f"auto cross-correlate completed ({label})"
assert all(a in dlg._fit_notes for a in range(s.n_angles)
if a != dlg._ref_angle_idx), \
def test_wizard_correlate(ctx):
"""Cross-correlation, for every source option, retryable."""
win, p1, wiz, s = ctx.win, ctx.p1, ctx.wiz, ctx.s
from sras_viewer.align_wizard import _CORRELATE_SOURCES
for idx, (label, _sources) in enumerate(_CORRELATE_SOURCES):
p1.combo_source.setCurrentIndex(idx)
p1.btn_correlate.click()
assert not p1.isComplete(), \
f"Next must be disabled while correlating ({label})"
assert wait_until(lambda: not win._job_running("align_correlate"),
timeout_ms=60000), f"correlation finished ({label})"
assert p1.isComplete(), f"Next re-enabled ({label})"
assert all(a in wiz.state.fits for a in range(s.n_angles)
if a != wiz.state.ref_angle_idx), \
f"every non-reference angle got a fit ({label})"
assert dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams(), \
"auto cross-correlate reference angle stays identity"
assert dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled(), \
"auto cross-correlate re-enabled controls when done"
assert len(dlg._preview_layers) == s.n_angles, \
"preview canvas rebuilt after cross-correlate"
assert dlg._fit_report(), "fit quality is reported per angle"
assert wiz.state.params[wiz.state.ref_angle_idx] == compute.ManualAngleParams(), \
"reference angle stays identity after correlation"
assert p1.btn_correlate.isEnabled(), "controls re-enabled when done"
assert p1.table.rowCount() == s.n_angles and p1.table.item(0, 0) is not None, \
"per-angle fit table populated"
assert p1.lbl_overlap.text(), "overlap summary reported"
r = wiz.state.result
assert len(r.per_angle) == s.n_angles, "transform for every angle"
assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \
"reference angle has zero shift"
def test_save_sidecar(ctx):
win, dlg, s, active = ctx.win, ctx.dlg, ctx.s, ctx.active
dlg._on_save()
sidecar = compute.sidecar_path(s.path)
assert sidecar.exists(), "sidecar file written"
def test_wizard_retry_changes_geometry(ctx):
"""Editing a parameter and re-running is the retry path, and it must
invalidate anything indexed against the old canvas."""
p1, wiz = ctx.p1, ctx.wiz
gen_before = wiz.state.geometry_generation
p1.spin_threshold.setValue(p1.spin_threshold.value() + 5.0)
p1.spin_threshold.editingFinished.emit()
pump(60)
assert wiz.state.geometry_generation > gen_before, \
"a threshold change rebuilt the geometry"
# Reset drops the fits and returns to pre-rotation only.
p1.btn_reset.click()
pump(60)
assert not wiz.state.fits, "reset cleared the fits"
nominal = compute.nominal_delta_deg(ctx.s, ctx.active, wiz.state.ref_angle_idx)
assert abs(wiz.state.params[ctx.active].rotation_deg - nominal) < 1e-9
assert wiz.state.params[ctx.active].shift_mm == (0.0, 0.0), \
"reset also drops nudged translation"
# Put a real correlation back for the pages that follow.
p1.btn_correlate.click()
assert wait_until(lambda: not ctx.win._job_running("align_correlate"),
timeout_ms=60000)
def test_wizard_roi_page(ctx):
"""The crop page: presets, and two-way sync between the drawn rectangle and
the numeric canvas-pixel boxes."""
wiz = ctx.wiz
wiz.next()
pump(150)
assert wiz.currentId() == wiz.PAGE_ROI, "advanced to the ROI page"
ctx.p2 = p2 = wiz.page(wiz.PAGE_ROI)
st = wiz.state
assert st.crop is not None and p2.isComplete(), \
"a default crop is offered on entry"
n_rows, n_cols = st.result.canvas_shape
assert st.crop[2] > 1 or n_rows == 1, \
f"default crop must not collapse to a single row: {st.crop}"
p2.btn_whole.click()
pump(50)
assert st.crop == (0, 0, n_rows, n_cols), "whole-canvas preset"
p2.btn_fit_union.click()
pump(50)
assert st.counts[st.crop[0]:st.crop[0] + st.crop[2],
st.crop[1]:st.crop[1] + st.crop[3]].sum() == st.counts.sum(), \
"fit-to-union must keep every covered pixel"
if p2.btn_fit_overlap.isEnabled():
p2.btn_fit_overlap.click()
pump(50)
row0, col0, nr, nc = st.crop
assert (st.counts[row0:row0 + nr, col0:col0 + nc] >= 1).all(), \
"full-overlap crop must not include uncovered pixels"
# Numeric -> drawn rectangle.
p2.btn_whole.click()
pump(50)
target = (0, 0, max(1, n_rows // 2), max(1, n_cols // 2))
p2.spin_rows.setValue(target[2])
p2.spin_cols.setValue(target[3])
pump(50)
assert st.crop == target, f"spin boxes drive the crop: {st.crop} vs {target}"
# Drawn rectangle -> numeric, round-tripping exactly.
x0, y0 = wiz.canvas_to_mm(target[1] - 0.5, target[0] - 0.5)
x1, y1 = wiz.canvas_to_mm(target[1] + target[3] - 0.5,
target[0] + target[2] - 0.5)
p2.canvas.set_roi(RoiQuad.from_bbox(min(x0, x1), min(y0, y1),
max(x0, x1), max(y0, y1)))
pump(80)
assert st.crop == target, \
f"drawn rectangle round-trips to the same crop: {st.crop} vs {target}"
# A degenerate crop blocks Next.
st.crop = None
p2.completeChanged.emit()
assert not p2.isComplete(), "an absent crop blocks Next"
p2._set_crop(*target)
assert p2.isComplete()
ctx.crop = target
def test_wizard_crop_dropped_when_going_back(ctx):
"""A crop is canvas-pixel indexed, so it cannot survive a re-correlation."""
wiz, p2 = ctx.wiz, ctx.p2
wiz.back()
pump(120)
assert wiz.currentId() == wiz.PAGE_CORRELATE
assert wiz.state.crop is None, "cleanupPage discarded the stale crop"
assert wiz.state.cropped_result is None
wiz.next()
pump(150)
assert wiz.state.crop is not None, "a fresh default crop is offered again"
p2._set_crop(*ctx.crop)
def test_wizard_export(ctx):
"""Writing the file: Finish stays unavailable until a write succeeds."""
win, wiz = ctx.win, ctx.wiz
out = ctx.tmpdir / "wizard_aligned.sras"
with patch("sras_viewer.align_wizard.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes):
wiz.next()
pump(150)
assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page"
ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE)
assert wiz.state.cropped_result is not None, "crop applied on leaving page 2"
assert wiz.state.cropped_result.canvas_shape == ctx.crop[2:], \
"cropped result carries the chosen shape"
assert not p3.isComplete(), "Finish unavailable before anything is written"
assert p3.lbl_summary.text(), "a summary of what will be written is shown"
with patch("sras_viewer.align_wizard.QFileDialog.getSaveFileName",
return_value=(str(out), "")):
p3.btn_browse.click()
assert wiz.state.out_path == str(out)
p3.btn_export.click()
assert wait_until(lambda: not win._job_running("align_export"),
timeout_ms=60000), "export finished"
assert wiz.state.exported_path == str(out), p3.lbl_status.text()
assert p3.isComplete(), "Finish available once the file exists"
assert out.exists()
written = SrasFile(str(out))
ctx.written = written
assert written.version == 6, "export is a v6 file"
assert written.n_angles == ctx.s.n_angles
assert all(written.image_shape(a) == ctx.crop[2:]
for a in range(written.n_angles)), \
"every angle shares the cropped grid"
assert not out.with_name(out.name + ".part").exists(), \
"no staging file left behind"
def test_wizard_finish_applies_and_persists(ctx):
"""Finish makes the session match the file: Aligned View shows the exported
extent, and the sidecar records it for the input scan."""
win, wiz = ctx.win, ctx.wiz
wiz.accept()
pump(250)
assert win._align_wizard is None, "wizard reference released"
assert win._wizard_act.isEnabled(), "wizard action available again"
assert win._alignment_result is not None
assert win._alignment_result.canvas_shape == ctx.crop[2:], \
"the *cropped* result is what the view now uses"
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked()
pump(200)
assert win.image_canvas._img_shape == ctx.crop[2:], \
f"canvas shows the cropped extent: {win.image_canvas._img_shape}"
sidecar = compute.sidecar_path(ctx.s.path)
assert sidecar.exists(), "sidecar written for the input scan"
ctx.sidecar = sidecar
ctx.sidecar_raw = raw = json.loads(sidecar.read_text())
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION, \
"sidecar schema_version is current"
assert all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
== dlg._angle_params[a].rotation_deg for a in range(s.n_angles)), \
"sidecar per_angle round-trips the dialog's resolved params"
assert (win._alignment_result is not None
and win._alignment_result.per_angle[active].rotation_deg
== dlg._angle_params[active].rotation_deg), \
"main window's alignment_result replaced by the manual build"
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \
"Aligned View auto-enabled after Save"
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION
assert all(raw["per_angle"][str(a)]["rotation_deg"]
== win._alignment_result.per_angle[a].rotation_deg
for a in range(ctx.s.n_angles)), \
"sidecar round-trips the applied rotations"
win.chk_aligned_view.setChecked(False)
pump(200)
assert win.image_canvas._img_shape == ctx.s.image_shape(0), \
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
def test_stale_schema_sidecar_ignored(ctx):
@@ -424,56 +553,39 @@ def test_stale_schema_sidecar_ignored(ctx):
sidecar.write_text(json.dumps(raw)) # restore for the rest of the sequence
def test_clear_with_confirmation(ctx):
win, dlg, s = ctx.win, ctx.dlg, ctx.s
with patch("sras_viewer.dialogs.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes):
dlg._on_clear()
assert not ctx.sidecar.exists(), "sidecar file deleted"
assert all(dlg._angle_params[a] == compute.ManualAngleParams()
for a in range(s.n_angles)), "dialog params reset to identity"
assert win._alignment_result is None, "main window alignment_result cleared"
assert (not win.chk_aligned_view.isEnabled()
and not win.chk_aligned_view.isChecked()), \
"Aligned View disabled after Clear"
dlg.close()
pump(150)
assert win._manual_align_dialog is None, "dialog reference released on close"
def test_sidecar_restored_on_reload(ctx):
win, active = ctx.win, ctx.active
win._on_manual_alignment()
dlg = win._manual_align_dialog
dlg.combo_active_angle.setCurrentIndex(active)
pump(30)
dlg._on_auto_derotate()
dlg._on_nudge_translate(1, 1, True)
saved_rotation = dlg._angle_params[active].rotation_deg
saved_shift = dlg._angle_params[active].shift_mm
dlg._on_save()
dlg.close()
pump(150)
saved = json.loads(ctx.sidecar.read_text())["per_angle"][str(active)]
old_sras_id = id(win._sras)
win._load_file(str(ctx.path)) # reload the same file fresh
assert wait_until(
lambda: win._sras is not None and id(win._sras) != old_sras_id), \
"file reloaded"
ctx.s = win._sras
assert win._manual_align_dialog is None, \
"manual dialog force-closed by a reload"
assert win._align_wizard is None, "no wizard left open across a reload"
assert win._alignment_result is not None, \
"reload restores the saved manual alignment automatically"
"reload restores the saved alignment automatically"
assert abs(win._alignment_result.per_angle[active].rotation_deg
- saved_rotation) < 1e-9, "restored rotation matches what was saved"
assert win._alignment_result.per_angle[active].shift_mm == saved_shift, \
"restored shift matches what was saved"
- saved["rotation_deg"]) < 1e-9, \
"restored rotation matches what was saved"
assert win.chk_aligned_view.isChecked(), \
"Aligned View auto-checked after restoring a saved alignment"
def test_wizard_closes_with_a_reload(ctx):
"""An open wizard belongs to the file it was opened on."""
win = ctx.win
win._on_alignment_wizard()
assert win._align_wizard is not None
assert wait_until(
lambda: win._align_wizard.page(win._align_wizard.PAGE_CORRELATE).isComplete())
win._load_file(str(ctx.path))
assert wait_until(lambda: not win._job_running("load"))
pump(200)
assert win._align_wizard is None, "wizard force-closed by a reload"
ctx.s = win._sras
def test_pixel_inspector(ctx):
win = ctx.win
win.chk_aligned_view.setChecked(False)