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:
+134
@@ -171,6 +171,140 @@ for the same reason, and every affine maps shared-grid index → mm → undo
|
||||
rotation/shift → that angle's own local mm → that angle's own raw index,
|
||||
matching the output→input convention `scipy.ndimage.affine_transform` wants.
|
||||
|
||||
### Cropping the canvas is index translation, not a second transform
|
||||
|
||||
`crop_alignment_result` restricts an `AlignmentResult` to a rectangular window
|
||||
of its canvas by folding the crop into each angle's existing affine rather than
|
||||
composing a new one. From `_affine_out_to_src`, `matrix = D @ Rinv @ A_out`
|
||||
depends only on the pitches and the rotation, and `A_out @ [row0, col0]` is
|
||||
exactly the mm displacement of the new origin, so
|
||||
|
||||
```
|
||||
matrix @ [r', c'] + (offset + matrix @ [row0, col0])
|
||||
== matrix @ [r' + row0, c' + col0] + offset
|
||||
```
|
||||
|
||||
identically. `matrix` is untouched and `offset` — which already absorbs the
|
||||
origin — absorbs the crop too.
|
||||
|
||||
Two things follow, and both are relied on. `apply_alignment`, `reproject_mask`
|
||||
and the aligned exporter all work on a cropped result with no special-casing:
|
||||
resampling a cropped result is *exactly* a slice of resampling the full one
|
||||
(`tests/test_align_export.py::test_crop_is_a_window_of_the_full_canvas` asserts
|
||||
bit equality). And because the crop offset is a whole number of canvas pixels,
|
||||
`canvas_for_params`' snap invariant — the reference angle lands on integer
|
||||
canvas pixels — survives the crop, which is what keeps the reference exportable
|
||||
as a verbatim block.
|
||||
|
||||
## Aligned export (`sras_align_export.py`)
|
||||
|
||||
`write_aligned_sras` bakes an alignment into a new v6 file: every angle
|
||||
resampled onto the cropped shared canvas, so all of them end up with identical
|
||||
geometry and the file opens already aligned. It is the only place in the
|
||||
codebase that *resamples* waveform data — `sras_edit_scans` and `sras_average`
|
||||
copy waveform bytes verbatim — which is why it is its own top-level module
|
||||
rather than part of `sras_format` (scoped to the versioned binary spec, per the
|
||||
sidecar section's own rule) or `sras_compute` (imported by every
|
||||
multiprocessing child).
|
||||
|
||||
**Nearest neighbour, never interpolation.** Each output pixel gets exactly one
|
||||
source pixel's three waveforms, verbatim. Averaging two neighbouring CH1
|
||||
packets would synthesise a waveform the instrument never measured, whose FFT
|
||||
peak is the peak of neither — meaningless for a technique whose entire output is
|
||||
that peak frequency. The cost is that some source pixels are duplicated and
|
||||
others dropped, which is the same trade `apply_alignment`'s `order=0` already
|
||||
makes for the display.
|
||||
|
||||
**The rounding rule is `floor(x + 0.5)`, not `np.rint`.** `scipy.ndimage`'s
|
||||
`order=0` rounds halves away from zero while `np.rint` rounds them to even. The
|
||||
canvas is snapped to the reference's own pixel grid, so an angle whose row pitch
|
||||
differs from the reference's lands on exact half-integers across whole rows —
|
||||
this is the common case, not a corner case. Getting it wrong shifts those rows
|
||||
by one source pixel relative to what the Aligned View drew.
|
||||
|
||||
**Out-of-bounds is tested on the fractional coordinate, not the rounded index.**
|
||||
`scipy`'s `mode="constant"` writes `cval` wherever the coordinate leaves the
|
||||
range of sample *centres*, `[0, n-1]` — a coordinate of −0.4 rounds to a
|
||||
perfectly valid index 0 and is still padding. Testing the rounded index instead
|
||||
puts a one-pixel rim of real data everywhere the preview shows padding.
|
||||
|
||||
**...but with a tolerance (`_EDGE_TOL`).** The affine is built from a chain of
|
||||
mm-space multiplications, so an exactly-integer transform comes out a few times
|
||||
1e-13 off: the reference angle's offset is `-20 - 7e-15`, not `-20`. A bare
|
||||
`>= 0.0` therefore rejects that angle's entire first row, and `<= n-1` its last
|
||||
column — for the *reference* angle, whose whole job is to pass through as an
|
||||
exact integer crop. The tolerance is ~7 orders of magnitude above that noise and
|
||||
~7 below the half-pixel scale at which a rounding decision means anything, so it
|
||||
can only ever change pixels whose scipy answer was itself decided by noise.
|
||||
|
||||
**Padding is the per-channel ADC code nearest 0 mV, not 0.** Zero ADC decodes to
|
||||
`(0 - yoff) * ymult + yzero`, which on real calibration is around +100 mV —
|
||||
above any sensible CH4 mask threshold, so a zero fill would paint a solid
|
||||
rectangle of "valid" pixels around the sample and corrupt every DC image and ROI
|
||||
statistic downstream.
|
||||
|
||||
**Source rows are served from sliding in-RAM bands** (`_SourceReader`). A
|
||||
rotated angle maps one output row to a *diagonal* across the source array, so
|
||||
the pixels of a single output row come from hundreds of different source rows —
|
||||
~1.4 MB each on a full-size scan. Indexing a memmap pixel-by-pixel in output
|
||||
order re-faults nearly the whole angle per output row: terabytes of paging for a
|
||||
gigabyte of data. Reading a contiguous band per output chunk, with the band
|
||||
advancing monotonically, costs roughly 2× the source size in total reads.
|
||||
|
||||
**Writes go to `.part` and are `os.replace`d into position.** Not politeness: a
|
||||
truncated .sras is not detectably broken, because `_parse_v6` drops incomplete
|
||||
trailing angle blocks and opens what is left as an aborted scan. A half-written
|
||||
export left in place would silently look like a real file with fewer angles.
|
||||
|
||||
**The Angle Table is carried over unchanged.** Alignment removes the *spatial*
|
||||
rotation of the sample; it does not change which acoustic propagation direction
|
||||
each angle measured, and that direction is the scientific content of a
|
||||
multi-angle scan. Zeroing the table would make the export self-consistent for
|
||||
re-registration and useless for anisotropy work. The consequence is that
|
||||
re-registering an export needs `seed_deg=0.0` to put 0° inside the coarse sweep,
|
||||
since `nominal_delta_deg` is still non-zero — which is exactly what the seed
|
||||
parameter exists for.
|
||||
|
||||
## Alignment wizard (`sras_viewer/align_wizard.py`)
|
||||
|
||||
A `QWizard` rather than another dialog because the three steps are genuinely
|
||||
sequential and the last one is destructive: correlate, choose a crop, write a
|
||||
file. It replaces both former Fusion actions, so it also absorbs the old
|
||||
`ManualAlignmentDialog`'s by-eye nudge editor — otherwise a scan the search
|
||||
cannot fit would have no fallback at all.
|
||||
|
||||
Shared state lives on the wizard object, not in `registerField`: the pages pass
|
||||
numpy arrays, `ManualAngleParams` and an `AlignmentResult` between them, none of
|
||||
which are scalar widget properties.
|
||||
|
||||
`IndependentPages` is deliberately left **off**. With it set Qt never calls
|
||||
`cleanupPage`, and `cleanupPage` is how the ROI page discards a crop when the
|
||||
user goes back to re-correlate — a crop is indexed in canvas pixels, and a new
|
||||
rotation means a different canvas, so stale indices would silently be
|
||||
reinterpreted against the wrong grid. `geometry_generation` is the belt-and-
|
||||
braces check for the same hazard.
|
||||
|
||||
The mask-stack preview shares the **final** canvas's origin and uses a pitch
|
||||
that is an integer multiple of it, unlike the old manual dialog's padded,
|
||||
unsnapped preview canvas. That is what lets the crop page convert a rectangle
|
||||
drawn in millimetres into an exact integer window of the real canvas, with no
|
||||
second coordinate frame to reconcile.
|
||||
|
||||
"Fit to full overlap" uses `largest_rect_at_least`, a largest-rectangle sweep,
|
||||
not a bounding box of the fully-covered pixels. The full-overlap region of
|
||||
several rotated scans is roughly a disc, and its bounding box has corners no
|
||||
angle covers — offering that as the crop would hand the user the padding they
|
||||
were trying to avoid.
|
||||
|
||||
Every background launch follows the two rules `_run_worker`'s docstring
|
||||
establishes: disable the trigger *before* the call (so a re-entrant click cannot
|
||||
start a second thread over the first), and never ignore the returned bool.
|
||||
Progress is an inline `QProgressBar` on the page rather than a `QProgressDialog`
|
||||
— a window-modal popup over a wizard both looks wrong and reintroduces the
|
||||
event-loop pumping hazard that ordering exists to avoid. `reject()` refuses to
|
||||
close while a job is in flight, since the running worker's signals are connected
|
||||
to bound methods of the pages Qt would be deleting.
|
||||
|
||||
## Manual-alignment sidecar (`sras_compute.py`)
|
||||
|
||||
`<name>.sras.align.json` lives next to the scan file. The code lives in
|
||||
|
||||
Reference in New Issue
Block a user