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
+6 -7
View File
@@ -1466,7 +1466,7 @@ def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
pitch; the manual-alignment preview passes a coarser pitch and snap=False.
margin_frac pads the box on every side: 0 for a final canvas, nonzero for
ManualAlignmentDialog's preview canvas, which needs headroom so an ordinary
the wizard's preview canvas, which needs headroom so an ordinary
translation nudge never has to trigger a full canvas resize (an extreme
nudge can still push content past this padding; accepted, and cheap to
recover from by re-opening the dialog).
@@ -1694,7 +1694,7 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
"""Top-level alignment driver: register every angle onto *ref_angle_idx* by
content, then lay them all out on that angle's own coordinate grid.
Runs on a background thread (see AngleAlignmentWorker) — deliberately
Meant for a background thread — deliberately
recomputes CH4 DC images from scratch rather than reading the GUI-thread
_dc_cache dict, since background-thread workers must not touch
GUI-thread-owned caches.
@@ -1757,7 +1757,7 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
# with no per-pixel image work at all, so build_manual_alignment is cheap enough
# to call synchronously on the GUI thread on every edit. The only genuinely
# expensive per-pixel operation anywhere in this flow is reproject_mask, and
# only ManualAlignmentDialog's own downsampled preview calls that per keystroke
# only the wizard's own downsampled mask stack calls that per keystroke
# — see that class's docstring for how it limits each nudge to reprojecting only
# the actively-edited angle.
# ---------------------------------------------------------------------------
@@ -1770,9 +1770,8 @@ def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
canvas_shape: tuple[int, int],
*, src_downsample: tuple[int, int] = (1, 1)) -> np.ndarray:
"""Resample one angle's binary/float mask onto an arbitrary canvas via an
explicit rotation+shift — the single building block ManualAlignmentDialog's
live preview repeatedly calls (once per keystroke, for only the
actively-nudged angle). *src_downsample* must match the (rows, cols)
explicit rotation+shift — the single building block the alignment wizard's
live mask stack repeatedly calls (once per angle per edit). *src_downsample* must match the (rows, cols)
block-mean factor already applied to *mask*, or the reprojection lands at
the wrong scale. order=0 (nearest) matches apply_alignment's own reasoning:
a binary mask must never be blended with zero-padding."""
@@ -1885,7 +1884,7 @@ def delete_manual_alignment(sras: SrasFile) -> bool:
"""Delete the sidecar if present. Returns whether a file actually existed
to delete, so Clear Alignment's status message can say so. Genuine I/O
errors (permission denied, read-only share) propagate — the caller
(ManualAlignmentDialog._on_clear) surfaces them rather than silently
(the wizard's Clear path) surfaces them rather than silently
pretending the destructive action succeeded."""
path = sidecar_path(sras.path)
try: