6d0e30b9ce
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>
613 lines
27 KiB
Python
613 lines
27 KiB
Python
"""Aligned/cropped .sras export: does the written file actually hold the
|
|
alignment the viewer showed?
|
|
|
|
The export is the one place an alignment stops being a transform applied on the
|
|
fly and becomes bytes on disk, so these tests care about two things above all:
|
|
the file's geometry describes what was written, and the pixels in it are the
|
|
same pixels apply_alignment would have drawn. The strongest check is the
|
|
round-trip — register the exported file against itself and demand identity,
|
|
which no amount of self-consistent-but-wrong index math can fake.
|
|
|
|
No Qt: this exercises sras_align_export and sras_compute directly.
|
|
"""
|
|
|
|
import struct
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import sras_align_export as export
|
|
import sras_compute as compute
|
|
from sras_format import CH3_IDX, CH4_IDX, HDR_SIZE_V6, SrasFile, adc_to_mv, mv_to_adc
|
|
import tools.make_test_sras as gen
|
|
|
|
_THRESHOLD_MV = 80.0
|
|
# Same reasoning as tests/test_alignment.py: a quarter degree is already
|
|
# sub-pixel for this sample at the registration pitch.
|
|
_ROT_TOL_DEG = 0.5
|
|
_SHIFT_TOL_MM = 0.02
|
|
|
|
|
|
def dc_mv(sras: SrasFile, angle_idx: int, ch: int = CH4_IDX) -> np.ndarray:
|
|
return adc_to_mv(compute.compute_dc_image(sras, angle_idx, ch), *sras.cal(ch))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(tmp_path_factory):
|
|
"""The rotating-sample scan, its truth alignment, and its export."""
|
|
tmpdir = tmp_path_factory.mktemp("sras_export")
|
|
src_path = tmpdir / "rotating.sras"
|
|
meta = gen.write_rotating(src_path, n_angles=4)
|
|
sras = SrasFile(str(src_path))
|
|
|
|
params = {a: compute.ManualAngleParams(rot, shift)
|
|
for a, (rot, shift) in meta["truth"].items()}
|
|
result = compute.build_manual_alignment(sras, 0, _THRESHOLD_MV, params)
|
|
|
|
out_path = tmpdir / "rotating_aligned.sras"
|
|
export.write_aligned_sras(sras, result, out_path)
|
|
return type("Rig", (), dict(
|
|
tmpdir=tmpdir, src_path=src_path, sras=sras, meta=meta,
|
|
result=result, out_path=out_path, out=SrasFile(str(out_path))))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Geometry and file structure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_output_is_v6_with_uniform_geometry(rig):
|
|
out, result = rig.out, rig.result
|
|
n_rows, n_cols = result.canvas_shape
|
|
|
|
assert out.version == 6
|
|
assert out.n_angles == rig.sras.n_angles
|
|
assert set(out.n_rows) == {n_rows}, "every angle must share the canvas rows"
|
|
assert set(out.n_frames) == {n_cols}, "every angle must share the canvas frames"
|
|
assert np.allclose(out.x_start_mm, result.canvas_origin_mm[0])
|
|
# x_delta must stay velocity/laser_freq or x_axis_mm() contradicts the
|
|
# geometry table; the canvas pitch is the reference angle's own pitch, so
|
|
# this is exact rather than approximate.
|
|
assert np.allclose(out.x_delta_mm_per_angle, rig.sras.pixel_x_mm)
|
|
assert out.pixel_x_mm == pytest.approx(rig.sras.pixel_x_mm)
|
|
|
|
|
|
def test_row_table_matches_the_canvas(rig):
|
|
expected = (rig.result.canvas_origin_mm[1]
|
|
+ np.arange(rig.result.canvas_shape[0]) * rig.result.canvas_dy_mm)
|
|
for a in range(rig.out.n_angles):
|
|
assert rig.out.y_positions_mm(a) == pytest.approx(expected, abs=1e-4)
|
|
|
|
|
|
def test_angle_table_and_calibration_round_trip(rig):
|
|
assert rig.out.angles_deg == pytest.approx(rig.sras.angles_deg)
|
|
for ch in range(rig.sras.n_channels):
|
|
assert rig.out.cal(ch) == pytest.approx(rig.sras.cal(ch))
|
|
assert rig.out.samples_per_frame == rig.sras.samples_per_frame
|
|
assert rig.out.bytes_per_sample == rig.sras.bytes_per_sample
|
|
assert rig.out.n_channels == rig.sras.n_channels
|
|
assert rig.out.background == pytest.approx(rig.sras.background)
|
|
|
|
|
|
def test_no_cache_tail(rig):
|
|
"""File ends exactly at the waveform data — nothing trailing.
|
|
|
|
A stale cache tail would be indexed by the *input's* grid, so the export
|
|
must not carry one; asserting on the exact file size is what proves it,
|
|
since a v7 tail would simply be ignored by a v6 parser.
|
|
"""
|
|
end = max(off + n for _, off, n in rig.out.iter_angle_blocks())
|
|
assert rig.out_path.stat().st_size == end
|
|
assert all(img is None for img in rig.out.precomputed_dc4_mv)
|
|
|
|
|
|
def test_declared_header_size_is_v6(rig):
|
|
raw = rig.out_path.read_bytes()[:HDR_SIZE_V6]
|
|
magic, version, n_angles = struct.unpack(">4sBH", raw[:7])
|
|
assert (magic, version, n_angles) == (b"SRAS", 6, rig.sras.n_angles)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The pixels themselves
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_export_matches_apply_alignment(rig):
|
|
"""The exported waveforms decode to the same DC image the viewer drew —
|
|
over the *whole* canvas, padding included.
|
|
|
|
Two rules have to be exactly right for this, and each fails differently:
|
|
* rounding must be floor(x + 0.5), not np.rint, or pixels on exact
|
|
half-integer boundaries pick the neighbouring source pixel;
|
|
* 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
|
|
where the preview shows padding.
|
|
Comparing every pixel rather than only the interior is what catches the
|
|
second one, since a rim discrepancy hides inside a `preview != 0` mask.
|
|
"""
|
|
for a in range(rig.sras.n_angles):
|
|
preview = compute.apply_alignment(rig.result, a, dc_mv(rig.sras, a))
|
|
actual = dc_mv(rig.out, a)
|
|
assert actual.shape == preview.shape
|
|
# Padding matches to within half an ADC step: apply_alignment pads with
|
|
# literal 0.0 mV, the export with the nearest integer ADC code to 0 mV.
|
|
tol = abs(rig.sras.cal(CH4_IDX)[0]) / 2.0 + 1e-4
|
|
# Exclude the epsilon rim the export deliberately keeps and scipy drops
|
|
# (see test_edge_tolerance_only_affects_the_epsilon_rim).
|
|
sr, sc = export._src_coords(rig.result.per_angle[a],
|
|
np.arange(preview.shape[0]), preview.shape[1])
|
|
rim = export._in_bounds(sr, sc, *rig.sras.image_shape(a)) & (preview == 0.0)
|
|
cmp = ~rim
|
|
assert actual[cmp] == pytest.approx(preview[cmp], abs=tol), \
|
|
f"angle {a}: exported pixels differ from the aligned preview"
|
|
# And exactly, wherever there is real data.
|
|
inside = (preview != 0.0)
|
|
assert inside.any(), f"angle {a}: preview is entirely padding"
|
|
assert actual[inside] == pytest.approx(preview[inside], abs=1e-6), \
|
|
f"angle {a}: exported data pixels are not bit-equal to the preview"
|
|
|
|
|
|
def test_reference_angle_is_exported_whole(rig):
|
|
"""The reference angle must survive as a complete, exact integer crop.
|
|
|
|
It is the coordinate authority — its transform is the identity with an
|
|
integer offset by construction — so every one of its source pixels has to
|
|
appear in the export. This is what _EDGE_TOL exists for: that offset comes
|
|
out of the mm-space affine chain as -20 - 7e-15, and a bare `>= 0` bounds
|
|
test silently drops the angle's entire first row and last column.
|
|
"""
|
|
ref = rig.result.ref_angle_idx
|
|
src_rows, src_frames = rig.sras.image_shape(ref)
|
|
plan = export.plan_export(rig.sras, rig.result)
|
|
assert plan.valid_px[ref] == src_rows * src_frames, \
|
|
"reference angle lost pixels to the in-bounds test"
|
|
|
|
# And the values themselves land as an exact, unrotated block.
|
|
src_img = dc_mv(rig.sras, ref)
|
|
out_img = dc_mv(rig.out, ref)
|
|
t = rig.result.per_angle[ref]
|
|
row0, col0 = (int(round(-t.offset[0])), int(round(-t.offset[1])))
|
|
assert np.array_equal(out_img[row0:row0 + src_rows, col0:col0 + src_frames],
|
|
src_img), \
|
|
"reference angle is not a verbatim block in the export"
|
|
|
|
|
|
def test_edge_tolerance_only_affects_the_epsilon_rim(rig):
|
|
"""Where the export's bounds test and scipy's disagree, the coordinate must
|
|
be within _EDGE_TOL of the boundary — i.e. only pixels whose scipy answer
|
|
was itself decided by float noise, never a real half-pixel decision."""
|
|
for a in range(rig.sras.n_angles):
|
|
t = rig.result.per_angle[a]
|
|
src_rows, src_frames = rig.sras.image_shape(a)
|
|
n_rows, n_cols = rig.result.canvas_shape
|
|
|
|
ones = np.ones((src_rows, src_frames), dtype=np.float32)
|
|
scipy_valid = compute.apply_alignment(rig.result, a, ones) > 0.5
|
|
sr, sc = export._src_coords(t, np.arange(n_rows), n_cols)
|
|
ours = export._in_bounds(sr, sc, src_rows, src_frames)
|
|
|
|
differ = ours != scipy_valid
|
|
assert not (scipy_valid & ~ours).any(), \
|
|
f"angle {a}: export drops pixels scipy keeps"
|
|
if differ.any():
|
|
# Every disagreement sits within the tolerance of an edge.
|
|
near = (np.abs(sr) <= export._EDGE_TOL)
|
|
near |= (np.abs(sr - (src_rows - 1)) <= export._EDGE_TOL)
|
|
near |= (np.abs(sc) <= export._EDGE_TOL)
|
|
near |= (np.abs(sc - (src_frames - 1)) <= export._EDGE_TOL)
|
|
assert near[differ].all(), \
|
|
f"angle {a}: bounds differ away from the epsilon rim"
|
|
|
|
|
|
def test_export_matches_apply_alignment_on_ch3(rig):
|
|
"""Channel-agnostic: the gather moves whole pixels, not per-channel images."""
|
|
for a in range(rig.sras.n_angles):
|
|
preview = compute.apply_alignment(rig.result, a,
|
|
dc_mv(rig.sras, a, CH3_IDX))
|
|
actual = dc_mv(rig.out, a, CH3_IDX)
|
|
inside = preview != 0.0
|
|
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
|
|
|
|
|
|
def test_padding_is_zero_mv_not_zero_adc(rig):
|
|
"""Unreachable canvas pixels must read as ~0 mV on every channel.
|
|
|
|
Filling with literal zero ADC would decode to (0 - yoff) * ymult + yzero —
|
|
for this fixture's CH4 calibration that is +100 mV, well above any sensible
|
|
mask threshold, so the padding would masquerade as valid sample everywhere.
|
|
"""
|
|
a = rig.sras.n_angles - 1
|
|
t = rig.result.per_angle[a]
|
|
n_rows, n_cols = rig.result.canvas_shape
|
|
src_rows, src_frames = rig.sras.image_shape(a)
|
|
|
|
sr, sc = export._src_coords(t, np.arange(n_rows), n_cols)
|
|
outside = ~export._in_bounds(sr, sc, src_rows, src_frames)
|
|
assert outside.any(), "rotated angle should leave unreachable canvas corners"
|
|
|
|
for ch in (CH3_IDX, CH4_IDX):
|
|
img = dc_mv(rig.out, a, ch)
|
|
half_step = abs(rig.sras.cal(ch)[0]) / 2.0
|
|
assert np.abs(img[outside]).max() <= half_step + 1e-6, \
|
|
f"CH{ch} padding is not within half an ADC step of 0 mV"
|
|
|
|
# And the sanity check that makes the above meaningful: zero ADC would not
|
|
# have passed it.
|
|
assert abs(adc_to_mv(0, *rig.sras.cal(CH4_IDX))) > 10.0
|
|
|
|
|
|
def test_reregistering_the_export_is_identity(rig):
|
|
"""The export really is aligned: registering it against its own angle 0
|
|
recovers no rotation and no shift.
|
|
|
|
The end-to-end check — it fails for any index error, sign flip, wrong pivot
|
|
or origin mistake anywhere in crop/affine/gather, in a way the
|
|
self-consistency tests above cannot.
|
|
"""
|
|
dc4 = {a: dc_mv(rig.out, a) for a in range(rig.out.n_angles)}
|
|
for a in range(1, rig.out.n_angles):
|
|
fit = compute.register_angle_to_reference(
|
|
rig.out, a, 0, dc4, dc_threshold_mv=_THRESHOLD_MV,
|
|
seed_deg=0.0, seed_signs=(1,))
|
|
assert abs(fit.rotation_deg) <= _ROT_TOL_DEG, \
|
|
f"angle {a} still rotated by {fit.rotation_deg:.3f}° after export"
|
|
assert float(np.hypot(*fit.shift_mm)) <= _SHIFT_TOL_MM, \
|
|
f"angle {a} still shifted by {fit.shift_mm} mm after export"
|
|
|
|
|
|
def test_export_of_int16_input(rig, tmp_path):
|
|
"""bps=2 inputs keep their big-endian int16 dtype through the gather."""
|
|
src_path = tmp_path / "i16.sras"
|
|
gen.write(src_path, n_angles=2, samples_per_frame=16, bps=2)
|
|
sras = SrasFile(str(src_path))
|
|
result = compute.build_manual_alignment(sras, 0, 0.0, {})
|
|
|
|
out_path = tmp_path / "i16_aligned.sras"
|
|
export.write_aligned_sras(sras, result, out_path)
|
|
out = SrasFile(str(out_path))
|
|
|
|
assert out.bytes_per_sample == 2
|
|
assert out.data[0].dtype == np.dtype(">i2")
|
|
for a in range(sras.n_angles):
|
|
preview = compute.apply_alignment(result, a, dc_mv(sras, a))
|
|
actual = dc_mv(out, a)
|
|
inside = preview != 0.0
|
|
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cropping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_crop_is_a_window_of_the_full_canvas(rig):
|
|
"""crop_alignment_result must resample exactly the sub-rectangle it names.
|
|
|
|
Asserted as bit-exact equality, not approximately: the crop composes into
|
|
the affine's offset by an integer number of canvas pixels, so anything but
|
|
an exact match means the composition is wrong.
|
|
"""
|
|
n_rows, n_cols = rig.result.canvas_shape
|
|
row0, col0 = n_rows // 5, n_cols // 4
|
|
nr, nc = n_rows // 2, n_cols // 3
|
|
cropped = compute.crop_alignment_result(rig.result, row0, col0, nr, nc)
|
|
|
|
assert cropped.canvas_shape == (nr, nc)
|
|
assert cropped.canvas_origin_mm[0] == pytest.approx(
|
|
rig.result.canvas_origin_mm[0] + col0 * rig.result.canvas_dx_mm)
|
|
assert cropped.canvas_origin_mm[1] == pytest.approx(
|
|
rig.result.canvas_origin_mm[1] + row0 * rig.result.canvas_dy_mm)
|
|
|
|
for a in range(rig.sras.n_angles):
|
|
img = dc_mv(rig.sras, a)
|
|
full = compute.apply_alignment(rig.result, a, img)
|
|
assert np.array_equal(
|
|
compute.apply_alignment(cropped, a, img),
|
|
full[row0:row0 + nr, col0:col0 + nc]), \
|
|
f"angle {a}: cropped resample is not the same window"
|
|
# Rotation/shift are properties of the angle, not of the canvas.
|
|
assert cropped.per_angle[a].rotation_deg == rig.result.per_angle[a].rotation_deg
|
|
assert cropped.per_angle[a].shift_mm == rig.result.per_angle[a].shift_mm
|
|
|
|
|
|
def test_cropped_export_round_trips(rig, tmp_path):
|
|
n_rows, n_cols = rig.result.canvas_shape
|
|
row0, col0, nr, nc = n_rows // 4, n_cols // 4, n_rows // 2, n_cols // 2
|
|
cropped = compute.crop_alignment_result(rig.result, row0, col0, nr, nc)
|
|
|
|
out_path = tmp_path / "cropped.sras"
|
|
export.write_aligned_sras(rig.sras, cropped, out_path)
|
|
out = SrasFile(str(out_path))
|
|
|
|
assert set(out.n_rows) == {nr} and set(out.n_frames) == {nc}
|
|
assert out.x_start_mm[0] == pytest.approx(cropped.canvas_origin_mm[0], abs=1e-4)
|
|
for a in range(rig.sras.n_angles):
|
|
preview = compute.apply_alignment(cropped, a, dc_mv(rig.sras, a))
|
|
actual = dc_mv(out, a)
|
|
inside = preview != 0.0
|
|
if inside.any():
|
|
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
|
|
|
|
|
|
def test_crop_rejects_empty_window(rig):
|
|
with pytest.raises(ValueError, match="empty crop"):
|
|
compute.crop_alignment_result(rig.result, 0, 0, 0, 10)
|
|
with pytest.raises(ValueError, match="empty crop"):
|
|
compute.crop_alignment_result(rig.result, 0, 0, 10, -1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# plan_export and overlap_stats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_plan_export_matches_what_was_written(rig):
|
|
plan = export.plan_export(rig.sras, rig.result)
|
|
n_rows, n_cols = rig.result.canvas_shape
|
|
assert (plan.n_rows, plan.n_frames) == (n_rows, n_cols)
|
|
assert plan.n_angles == rig.sras.n_angles
|
|
|
|
data_bytes = sum(n for _, _, n in rig.out.iter_angle_blocks())
|
|
assert plan.total_bytes == data_bytes
|
|
assert plan.bytes_per_angle * plan.n_angles == plan.total_bytes
|
|
|
|
# Coverage must agree with the pixels that actually carry data. The
|
|
# reference angle is unrotated, so its whole footprint lands inside.
|
|
ref_px = np.prod(rig.sras.image_shape(0))
|
|
assert plan.valid_px[0] == ref_px
|
|
for a in range(1, rig.sras.n_angles):
|
|
assert 0 < plan.valid_px[a] <= n_rows * n_cols
|
|
assert 0.0 < plan.coverage_frac(a) < 1.0
|
|
|
|
|
|
def test_plan_export_flags_a_crop_that_misses_an_angle(rig):
|
|
"""A crop over a corner the rotated angles cannot reach must warn, and the
|
|
export must still succeed by writing that angle as padding."""
|
|
n_rows, n_cols = rig.result.canvas_shape
|
|
corner = compute.crop_alignment_result(rig.result, 0, 0,
|
|
max(1, n_rows // 12),
|
|
max(1, n_cols // 12))
|
|
plan = export.plan_export(rig.sras, corner)
|
|
empty = [a for a in range(rig.sras.n_angles) if plan.valid_px[a] == 0]
|
|
assert empty, "top-left canvas corner should be unreachable for some angle"
|
|
assert any("all padding" in w for w in plan.warnings)
|
|
|
|
|
|
def test_overlap_stats():
|
|
counts = np.array([[0, 1, 2], [3, 3, 0], [0, 2, 3]])
|
|
stats = compute.overlap_stats(counts, 3)
|
|
assert stats["union_px"] == 6
|
|
assert stats["full_px"] == 3
|
|
assert stats["full_frac"] == pytest.approx(0.5)
|
|
assert stats["max_count"] == 3
|
|
assert stats["mean_count"] == pytest.approx((1 + 2 + 3 + 3 + 2 + 3) / 6)
|
|
assert stats["empty"] is False
|
|
|
|
empty = compute.overlap_stats(np.zeros((4, 4), dtype=int), 3)
|
|
assert empty["empty"] is True
|
|
assert empty["full_frac"] == 0.0 and empty["mean_count"] == 0.0
|
|
|
|
|
|
def test_largest_rect_at_least():
|
|
# A 2x3 block of 3s with a notch that a bounding box would swallow.
|
|
counts = np.array([
|
|
[0, 0, 0, 0, 0],
|
|
[0, 3, 3, 3, 0],
|
|
[0, 3, 3, 3, 0],
|
|
[0, 3, 0, 3, 0],
|
|
])
|
|
row0, col0, nr, nc = compute.largest_rect_at_least(counts, 3)
|
|
assert (nr * nc) == 6 and (row0, col0, nr, nc) == (1, 1, 2, 3)
|
|
assert (counts[row0:row0 + nr, col0:col0 + nc] >= 3).all()
|
|
|
|
# A column taller than the wide block is the better rectangle.
|
|
tall = np.array([[3, 3], [3, 0], [3, 0], [3, 0]])
|
|
r0, c0, nr2, nc2 = compute.largest_rect_at_least(tall, 3)
|
|
assert (r0, c0, nr2, nc2) == (0, 0, 4, 1)
|
|
|
|
assert compute.largest_rect_at_least(np.zeros((3, 3), dtype=int), 1) is None
|
|
# Whole-array case: no notch, so the answer is the array itself.
|
|
assert compute.largest_rect_at_least(np.full((3, 4), 2), 2) == (0, 0, 3, 4)
|
|
|
|
|
|
def test_largest_rect_is_pure_on_the_real_fixture(rig):
|
|
"""On real overlap counts the returned rectangle must contain only
|
|
full-overlap pixels — the property a bounding box would violate."""
|
|
n = rig.sras.n_angles
|
|
masks = {a: (dc_mv(rig.sras, a) >= _THRESHOLD_MV).astype(np.float32)
|
|
for a in range(n)}
|
|
counts = sum(compute.apply_alignment(rig.result, a, masks[a]) > 0.5
|
|
for a in range(n)).astype(int)
|
|
assert counts.max() == n, "fixture alignment should have a full-overlap region"
|
|
|
|
rect = compute.largest_rect_at_least(counts, n)
|
|
assert rect is not None
|
|
row0, col0, nr, nc = rect
|
|
assert (counts[row0:row0 + nr, col0:col0 + nc] == n).all(), \
|
|
"convenience crop must not include pixels some angle misses"
|
|
|
|
# And it must beat the naive bounding box, which here is impure.
|
|
rr, cc = np.nonzero(counts == n)
|
|
bbox_pure = (counts[rr.min():rr.max() + 1, cc.min():cc.max() + 1] == n).all()
|
|
assert not bbox_pure, "fixture no longer exercises the bounding-box hazard"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy inputs, validation and durability
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("version", [2, 4])
|
|
def test_legacy_input_exports_as_v6(version, tmp_path):
|
|
"""v2-v5 inputs keep no verbatim preamble/background spans, so those
|
|
sections have to be re-encoded. v2 additionally has neither."""
|
|
src_path = tmp_path / f"legacy_v{version}.sras"
|
|
gen.write_legacy(src_path, version=version, n_angles=2)
|
|
sras = SrasFile(str(src_path))
|
|
result = compute.build_manual_alignment(sras, 0, 0.0, {})
|
|
|
|
out_path = tmp_path / f"legacy_v{version}_aligned.sras"
|
|
export.write_aligned_sras(sras, result, out_path)
|
|
out = SrasFile(str(out_path))
|
|
|
|
assert out.version == 6
|
|
assert out.n_angles == sras.n_angles
|
|
# A zero background rather than a zero-length one: consumers subtract it
|
|
# from a (spf,)-shaped row, which a length-0 array cannot broadcast against.
|
|
assert out.background is not None
|
|
assert out.background.size == sras.samples_per_frame
|
|
if sras.background is None:
|
|
assert np.all(out.background == 0)
|
|
assert any("no background" in w for w in
|
|
export.plan_export(sras, result).warnings)
|
|
# Calibration must survive: v2 has no preambles and falls back to the
|
|
# hardcoded scope constants, and the re-encoded empty preambles must land on
|
|
# exactly the same fallback.
|
|
for ch in range(sras.n_channels):
|
|
assert out.cal(ch) == pytest.approx(sras.cal(ch))
|
|
for a in range(sras.n_angles):
|
|
preview = compute.apply_alignment(result, a, dc_mv(sras, a))
|
|
actual = dc_mv(out, a)
|
|
inside = preview != 0.0
|
|
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
|
|
|
|
|
|
def test_too_many_rows_is_rejected_before_writing(rig, tmp_path):
|
|
"""The geometry table stores n_rows as a u16; silently truncating would
|
|
write a file whose header disagrees with its own waveform block."""
|
|
huge = compute.crop_alignment_result(rig.result, 0, 0, 70000, 4)
|
|
out_path = tmp_path / "huge.sras"
|
|
with pytest.raises(ValueError, match="exceeds the .sras per-angle geometry"):
|
|
export.write_aligned_sras(rig.sras, huge, out_path)
|
|
assert not out_path.exists()
|
|
assert not out_path.with_name(out_path.name + ".part").exists()
|
|
|
|
|
|
def test_missing_transform_is_rejected(rig, tmp_path):
|
|
broken = compute.crop_alignment_result(rig.result, 0, 0,
|
|
*rig.result.canvas_shape)
|
|
del broken.per_angle[1]
|
|
with pytest.raises(ValueError, match="no transform for angle"):
|
|
export.write_aligned_sras(rig.sras, broken, tmp_path / "broken.sras")
|
|
|
|
|
|
def test_cancelled_export_leaves_nothing_behind(rig, tmp_path):
|
|
out_path = tmp_path / "cancelled.sras"
|
|
written = export.write_aligned_sras(rig.sras, rig.result, out_path,
|
|
should_stop=lambda: True)
|
|
assert written == out_path
|
|
assert not out_path.exists(), "cancelled export must not leave an output file"
|
|
assert not out_path.with_name(out_path.name + ".part").exists()
|
|
|
|
|
|
def test_failed_write_leaves_nothing_behind(rig, tmp_path):
|
|
"""An exception mid-write must remove the partial file: a short .sras is
|
|
not detectably broken — the v6 parser reads it as an aborted scan."""
|
|
out_path = tmp_path / "boom.sras"
|
|
|
|
def explode(_pct):
|
|
raise RuntimeError("boom")
|
|
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
export.write_aligned_sras(rig.sras, rig.result, out_path,
|
|
progress_cb=explode)
|
|
assert not out_path.exists()
|
|
assert not out_path.with_name(out_path.name + ".part").exists()
|
|
|
|
|
|
def test_progress_is_monotonic_and_completes(rig, tmp_path):
|
|
seen: list[int] = []
|
|
export.write_aligned_sras(rig.sras, rig.result, tmp_path / "prog.sras",
|
|
progress_cb=seen.append)
|
|
assert seen and seen[-1] == 100
|
|
assert seen == sorted(seen)
|
|
assert all(0 <= p <= 100 for p in seen)
|
|
|
|
|
|
def test_band_reader_path_is_byte_identical(rig, tmp_path, monkeypatch):
|
|
"""A source block too large to hold in RAM is served from sliding bands
|
|
instead. That path only runs on multi-gigabyte scans, so force it with a
|
|
tiny budget and demand the same bytes — otherwise the one code path that
|
|
matters on real data is the one never tested."""
|
|
whole = tmp_path / "whole.sras"
|
|
export.write_aligned_sras(rig.sras, rig.result, whole)
|
|
|
|
monkeypatch.setattr(compute, "_TOTAL_BYTES_BUDGET", 4096)
|
|
banded = tmp_path / "banded.sras"
|
|
export.write_aligned_sras(rig.sras, rig.result, banded)
|
|
|
|
assert banded.read_bytes() == whole.read_bytes()
|
|
|
|
|
|
def test_row_chunking_is_invariant(rig, tmp_path, monkeypatch):
|
|
"""Output must not depend on how many rows are buffered per write."""
|
|
base = tmp_path / "base.sras"
|
|
export.write_aligned_sras(rig.sras, rig.result, base)
|
|
|
|
monkeypatch.setattr(export, "_ROW_CHUNK", 1)
|
|
one = tmp_path / "one.sras"
|
|
export.write_aligned_sras(rig.sras, rig.result, one)
|
|
assert one.read_bytes() == base.read_bytes()
|
|
|
|
|
|
def test_refuses_to_overwrite_the_source(rig):
|
|
"""The source's waveform blocks are live read-only memmaps; writing over
|
|
the file would corrupt the reads the gather is making from it."""
|
|
with pytest.raises(ValueError, match="refusing to export onto the source"):
|
|
export.write_aligned_sras(rig.sras, rig.result, rig.src_path)
|
|
assert SrasFile(str(rig.src_path)).n_angles == rig.sras.n_angles
|
|
|
|
|
|
def test_overwrites_an_existing_file(rig, tmp_path):
|
|
out_path = tmp_path / "existing.sras"
|
|
out_path.write_bytes(b"not a scan")
|
|
export.write_aligned_sras(rig.sras, rig.result, out_path)
|
|
assert SrasFile(str(out_path)).version == 6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The registration knobs the wizard exposes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_locked_rotation_returns_exactly_the_seed(rig):
|
|
"""search_deg=0 + one sign + refine=False pins rotation to the seed, which
|
|
is what "lock rotation to the stage angle" means on the wizard's first
|
|
page. Only the translation may be searched."""
|
|
dc4 = {a: dc_mv(rig.sras, a) for a in range(rig.sras.n_angles)}
|
|
for a in range(1, rig.sras.n_angles):
|
|
nominal = compute.nominal_delta_deg(rig.sras, a, 0)
|
|
fit = compute.register_angle_to_reference(
|
|
rig.sras, a, 0, dc4, dc_threshold_mv=_THRESHOLD_MV,
|
|
search_deg=0.0, coarse_step_deg=2.0, seed_signs=(-1,), refine=False)
|
|
assert fit.rotation_deg == pytest.approx(-nominal)
|
|
|
|
|
|
def test_seed_deg_overrides_the_stage_angle(rig):
|
|
"""seed_deg=0.0 searches around no rotation at all, so a scan whose angles
|
|
are genuinely ~37° apart must fail to find them within a ±2° window —
|
|
proving the seed is what positions the search."""
|
|
dc4 = {a: dc_mv(rig.sras, a) for a in range(rig.sras.n_angles)}
|
|
fit = compute.register_angle_to_reference(
|
|
rig.sras, 1, 0, dc4, dc_threshold_mv=_THRESHOLD_MV,
|
|
search_deg=2.0, seed_deg=0.0, seed_signs=(1,), refine=False)
|
|
truth_rot = rig.meta["truth"][1][0]
|
|
assert abs(fit.rotation_deg) <= 2.0
|
|
assert abs(fit.rotation_deg - truth_rot) > 10.0
|
|
|
|
|
|
def test_rotation_candidates_signs():
|
|
both = compute._rotation_candidates(10.0, 2.0, 2.0)
|
|
assert both == compute._rotation_candidates(10.0, 2.0, 2.0, (-1, 1)), \
|
|
"default must stay the both-signs sweep"
|
|
assert compute._rotation_candidates(10.0, 0.0, 2.0, (1,)) == [10.0]
|
|
assert compute._rotation_candidates(10.0, 0.0, 2.0, (-1,)) == [-10.0]
|
|
# A zero seed collapses the two windows; the dedupe must keep one copy.
|
|
assert compute._rotation_candidates(0.0, 2.0, 2.0) == [-2.0, 0.0, 2.0]
|
|
|
|
|
|
def test_zero_mv_fill_code_is_clipped_to_dtype():
|
|
"""mv_to_adc is unclamped, so the fill code must be clipped or the int8
|
|
cast wraps around to a large-magnitude value."""
|
|
fake = type("S", (), dict(
|
|
n_channels=1, samples_per_frame=2,
|
|
cal=lambda self, ch: (1e-6, 0.0, 5000.0)))()
|
|
row = export._fill_row(fake, 3, np.dtype(np.int8))
|
|
assert row.shape == (1, 3, 2)
|
|
assert row.min() == row.max() == np.iinfo(np.int8).min
|
|
assert mv_to_adc(0.0, 1e-6, 0.0, 5000.0) < np.iinfo(np.int8).min
|