Add aligned/cropped .sras export and the compute pieces behind it

The alignment machinery never modified a scan: it produced an
AlignmentResult and every consumer resampled on the fly. That is right
for a viewer but means the aligned stack cannot leave the process, so no
other tool can read it and reopening the scan redoes the registration.

sras_align_export.write_aligned_sras bakes an alignment into a new v6
file: each angle is gathered onto the shared canvas by nearest neighbour,
so every output angle shares one grid and the file opens already aligned.
Registering the export against itself returns identity, which is the test
that pins the whole index chain.

Three details that are easy to get wrong and are now covered:

  * Rounding must be floor(x + 0.5), not np.rint. scipy's order=0 rounds
    halves away from zero, and the canvas is snapped to the reference's
    pixel grid, so exact halves are common rather than hypothetical.
  * Out-of-bounds must be tested on the fractional coordinate against
    [0, n-1], not on the rounded index, or a one-pixel rim gets real data
    everywhere the Aligned View shows padding.
  * ...but with a tolerance, because the mm-space affine chain lands an
    exactly-integer transform a few times 1e-13 off. A bare >= 0 drops
    the *reference* angle's entire first row and last column.

Padding is the per-channel ADC code nearest 0 mV, not zero: zero ADC
decodes to ~+100 mV on real calibration and would masquerade as sample.

Source rows are served from sliding in-RAM bands. A rotated angle maps
one output row to a diagonal across the source, so indexing a memmap in
output order refaults nearly the whole angle per row — terabytes of
paging for a gigabyte of data.

Also in compute: crop_alignment_result (a crop is pure index translation,
so it folds into the affine's offset rather than becoming a second
transform), overlap_stats, largest_rect_at_least for a crop that stays
inside the overlap region, and seed/sign/refine knobs on
register_angle_to_reference whose defaults leave existing behaviour and
tests byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-07 22:34:18 -05:00
parent 3989c2a1b8
commit 6d0e30b9ce
8 changed files with 1389 additions and 36 deletions
+14 -3
View File
@@ -1143,16 +1143,27 @@ class SrasViewerWindow(QMainWindow):
# Progress dialogs
# ------------------------------------------------------------------
def _show_progress(self, key: str, message: str, maximum: int = 0):
def _show_progress(self, key: str, message: str, maximum: int = 0,
on_cancel=None):
"""Show (or relabel) the progress dialog under *key*. maximum=0 gives
an indeterminate busy indicator."""
an indeterminate busy indicator.
*on_cancel* adds a Cancel button wired to it. Almost every job here is
short enough that a cancel button would only invite a click that does
nothing, hence the no-button default; the aligned export is the
exception, since it can spend minutes writing gigabytes.
"""
dlg = self._progress_dlgs.get(key)
if dlg is not None:
dlg.setLabelText(message)
return
dlg = QProgressDialog(message, "", 0, maximum, self)
dlg.setWindowTitle("Please wait…")
dlg.setCancelButton(None)
if on_cancel is None:
dlg.setCancelButton(None)
else:
dlg.setCancelButtonText("Cancel")
dlg.canceled.connect(on_cancel)
dlg.setWindowModality(Qt.WindowModality.WindowModal)
dlg.setMinimumDuration(300) # only appears if it takes > 300 ms
dlg.show()