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
+78 -10
View File
@@ -1,7 +1,9 @@
"""Matplotlib canvases and the ROI primitive."""
import matplotlib as mpl
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.figure import Figure
from matplotlib.patches import Polygon
from matplotlib.path import Path as MplPath
@@ -100,7 +102,15 @@ class ImageCanvas(FigureCanvasQTAgg):
_HANDLE_PX = 12
_CLICK_THRESH_PX = 4 # releases within this of press count as a click
def __init__(self, parent=None):
def __init__(self, parent=None, *, rect_only: bool = False):
"""*rect_only* constrains the ROI to an axis-aligned rectangle.
Used by the alignment wizard's crop page, where a free quadrilateral
would be actively misleading: v6 geometry can only express an
axis-aligned rectangle, so anything else the user drew would have to be
squared off behind their back. Default off, so the main window's
free-quad ROI is unaffected.
"""
fig = Figure(figsize=(7, 5), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
@@ -108,6 +118,7 @@ class ImageCanvas(FigureCanvasQTAgg):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._extent = None
self._img_shape = None
self._rect_only = rect_only
# ROI state
self._roi: RoiQuad | None = None
@@ -132,9 +143,12 @@ class ImageCanvas(FigureCanvasQTAgg):
# Public API
# ------------------------------------------------------------------
def show_image(self, img: np.ndarray, extent: list[float], cmap: str,
def show_image(self, img: np.ndarray, extent: list[float], cmap,
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
colorbar_label: str = ""):
colorbar_label: str = "", cb_ticks=None):
"""*cmap* may be a name or a Colormap instance; *cb_ticks* pins the
colorbar's ticks, which the wizard's integer overlap-count view needs so
each band reads as a whole number of angles rather than a shade."""
self.figure.clf()
self.ax = self.figure.add_subplot(111)
# Patches and lines are destroyed by figure.clf(); drop stale refs.
@@ -148,7 +162,8 @@ class ImageCanvas(FigureCanvasQTAgg):
extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
interpolation="nearest",
)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
ticks=cb_ticks)
if colorbar_label:
cb.set_label(colorbar_label)
@@ -302,10 +317,28 @@ class ImageCanvas(FigureCanvasQTAgg):
self._roi._pts = self._snapshot.corners() + delta
elif self._state == self._DRAG_CORNER:
self._roi._pts[self._drag_corner_idx] = [event.xdata, event.ydata]
if self._rect_only:
self._rectify_corner(self._drag_corner_idx)
self._draw_roi()
self.draw_idle()
def _rectify_corner(self, idx: int):
"""Re-square the quad after a corner drag, anchored on the *opposite*
corner.
Anchoring on the diagonal opposite (idx ^ 2, since corners run
BL, BR, TR, TL) rather than taking the bbox of all four points is what
lets the rectangle shrink: a bbox over the three stale corners plus the
new one is the union of the old rectangle and the new point, so dragging
inward would never make it smaller.
"""
pts = self._roi.corners()
ax_, ay = pts[idx ^ 2]
bx, by = pts[idx]
self._roi._pts = RoiQuad.from_bbox(min(ax_, bx), min(ay, by),
max(ax_, bx), max(ay, by)).corners()
def _on_release(self, event):
if event.button != 1 and self._press_button != 1:
return
@@ -475,14 +508,20 @@ class WaveformCanvas(FigureCanvasQTAgg):
self.draw()
class ManualAlignOverlayCanvas(FigureCanvasQTAgg):
"""Renders ManualAlignmentDialog's multi-angle mask overlay and turns
keyboard input into translate/rotate nudge requests for whichever angle
the dialog currently has active.
class AlignOverlayCanvas(FigureCanvasQTAgg):
"""Renders the alignment wizard's multi-angle mask views and turns keyboard
input into translate/rotate nudge requests for whichever angle is active.
Two views of the same reprojected masks, because they answer different
questions. show_counts colours each pixel by *how many* angles cover it,
which is the at-a-glance verdict on a correlation run: a good alignment is
one saturated plateau, a bad one is a fringe of low-count halos.
show_overlay gives each angle its own colour, which is what you need while
nudging a specific angle by hand.
A pure input+render widget — it holds no alignment state and never
touches SrasFile itself; ManualAlignmentDialog owns all of that and
decides, from these signals, whether a cheap single-layer refresh or a
touches SrasFile itself; the wizard page owns all of that and decides,
from these signals, whether a cheap single-layer refresh or a
full preview-canvas rebuild is needed.
FigureCanvasQTAgg is a real QWidget, so keyPressEvent works like on any
@@ -522,6 +561,35 @@ class ManualAlignOverlayCanvas(FigureCanvasQTAgg):
self.figure.clf()
self.ax = self.figure.add_subplot(111)
self.ax.imshow(rgba, extent=extent, origin="upper", aspect="auto")
self._finish(title)
def show_counts(self, counts: np.ndarray, n_angles: int,
extent: list[float], title: str):
"""The mask stack coloured by how many angles cover each pixel.
A discrete colormap with integer-ticked colorbar rather than a
continuous one: the judgement being made is "is this a plateau at N, or
a fan of partial overlaps", and a region covered by one angle too few
has to read as its own band rather than a slightly darker shade.
Uncovered pixels are transparent so they cannot be mistaken for a low
count.
"""
self.figure.clf()
self.ax = self.figure.add_subplot(111)
n = max(1, int(n_angles))
colors = [(0.0, 0.0, 0.0, 0.0)] # count 0: nothing
base = mpl.colormaps["viridis"].resampled(n)
colors += [base(i) for i in range(n)]
im = self.ax.imshow(
np.asarray(counts), extent=extent, origin="upper", aspect="auto",
interpolation="nearest", cmap=ListedColormap(colors),
norm=BoundaryNorm(np.arange(-0.5, n + 1), len(colors)))
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
ticks=np.arange(0, n + 1))
cb.set_label("angles overlapping")
self._finish(title)
def _finish(self, title: str):
self.ax.set_xlabel("X (mm)")
self.ax.set_ylabel("Y (mm)")
self.ax.set_title(title)