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:
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user