f40c965b74
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>
647 lines
28 KiB
Python
647 lines
28 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_matches_brute_force():
|
|
"""Randomized check against an O(n^4) reference.
|
|
|
|
The histogram sweep is short and easy to get subtly wrong — an off-by-one in
|
|
the stack unwind yields rectangles that are merely large, and "large but not
|
|
maximal" is invisible by eye on real data.
|
|
"""
|
|
def brute(good):
|
|
n_rows, n_cols = good.shape
|
|
best = 0
|
|
for r0 in range(n_rows):
|
|
for r1 in range(r0 + 1, n_rows + 1):
|
|
run = 0
|
|
for g in good[r0:r1].all(axis=0):
|
|
run = run + 1 if g else 0
|
|
best = max(best, run * (r1 - r0))
|
|
return best
|
|
|
|
rng = np.random.default_rng(0)
|
|
for _ in range(200):
|
|
counts = rng.integers(0, 3, size=(int(rng.integers(1, 9)),
|
|
int(rng.integers(1, 9))))
|
|
got = compute.largest_rect_at_least(counts, 2)
|
|
expected = brute(counts >= 2)
|
|
if got is None:
|
|
assert expected == 0
|
|
continue
|
|
row0, col0, nr, nc = got
|
|
assert (counts[row0:row0 + nr, col0:col0 + nc] >= 2).all(), \
|
|
f"rectangle is not pure:\n{counts}\n{got}"
|
|
assert nr * nc == expected, \
|
|
f"not maximal ({nr * nc} < {expected}):\n{counts}\n{got}"
|
|
|
|
|
|
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
|