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:
+44
-2
@@ -15,6 +15,7 @@ import numpy as np
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
import sras_compute as compute
|
||||
from sras_align_export import write_aligned_sras
|
||||
from sras_compute import (
|
||||
cache_file, compute_angle_alignment, compute_rf_image, dc_image_mv,
|
||||
)
|
||||
@@ -381,7 +382,11 @@ class CrossCorrelateWorker(_PooledWorker):
|
||||
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
|
||||
dc4_mv: dict[int, np.ndarray], *,
|
||||
sources: tuple[str, ...], dc_threshold_mv: float,
|
||||
search_deg: float):
|
||||
search_deg: float, reg_kwargs: dict | None = None):
|
||||
"""*reg_kwargs* is splatted into register_angle_to_reference on top of
|
||||
the named arguments — the wizard's rotation-search controls (seed,
|
||||
signs, refine, grid sizes) go through here, so exposing another knob
|
||||
needs no change to this class."""
|
||||
super().__init__()
|
||||
self._sras = sras
|
||||
self._ref = ref_angle_idx
|
||||
@@ -390,6 +395,7 @@ class CrossCorrelateWorker(_PooledWorker):
|
||||
self._sources = sources
|
||||
self._threshold = dc_threshold_mv
|
||||
self._search_deg = search_deg
|
||||
self._reg_kwargs = dict(reg_kwargs or {})
|
||||
|
||||
def _plan(self) -> int:
|
||||
return compute.registration_workers(self._sras)
|
||||
@@ -401,9 +407,45 @@ class CrossCorrelateWorker(_PooledWorker):
|
||||
return a, compute.register_angle_to_reference(
|
||||
self._sras, a, self._ref, self._dc4_mv,
|
||||
dc_threshold_mv=self._threshold, sources=self._sources,
|
||||
search_deg=self._search_deg)
|
||||
search_deg=self._search_deg, **self._reg_kwargs)
|
||||
|
||||
def _emit(self, result):
|
||||
a, fit = result
|
||||
self.angle_done.emit(a, fit.rotation_deg, fit.shift_mm[0],
|
||||
fit.shift_mm[1], fit.score, fit.source)
|
||||
|
||||
|
||||
class AlignedExportWorker(CancellableWorker):
|
||||
"""Writes the aligned, cropped .sras on a background thread.
|
||||
|
||||
Unlike every other worker here this one produces a *file*, which changes
|
||||
what cancellation has to mean: write_aligned_sras stages into a ".part"
|
||||
sibling and removes it when should_stop() fires, so a cancelled or crashed
|
||||
export leaves nothing behind. That matters more than it sounds — a
|
||||
truncated .sras is not detectably broken, since the v6 parser reads a short
|
||||
file as an aborted scan and opens it happily.
|
||||
|
||||
Cancellation is polled per output row chunk, the same granularity
|
||||
CancellableWorker's docstring justifies, so closing the window never waits
|
||||
on a multi-gigabyte write.
|
||||
"""
|
||||
progress = pyqtSignal(int) # 0-100
|
||||
finished = pyqtSignal(str, str) # written path ("" = none), error
|
||||
|
||||
def __init__(self, sras: SrasFile, result, out_path: str):
|
||||
super().__init__()
|
||||
self._sras = sras
|
||||
self._result = result
|
||||
self._out_path = out_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
written = write_aligned_sras(
|
||||
self._sras, self._result, self._out_path,
|
||||
progress_cb=self.progress.emit, should_stop=self._stopped)
|
||||
if self._stopped():
|
||||
self.finished.emit("", "") # cancelled: no file, no error
|
||||
else:
|
||||
self.finished.emit(str(written), "")
|
||||
except Exception as exc:
|
||||
self.finished.emit("", str(exc))
|
||||
|
||||
Reference in New Issue
Block a user