Add scan-editing CLI and alignment tests; extend Manual Alignment correlation
Continues the Manual Alignment work: refines the FFT cross-correlation and mask handling, adds sras_edit_scans.py (drop/renumber bad angle scans), tools/test_alignment.py (registration ground-truth suite), and a rotating test fixture in make_test_sras.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,138 @@ def write(path: Path, n_angles: int = 3, seed: int = 0,
|
||||
return meta
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rotating-sample scan: one shape, imaged at several known rotations
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The scan the angle-alignment path actually has to solve: every angle images
|
||||
# the *same* sample at a different known rotation and offset, and a correct
|
||||
# alignment stacks them all back into one shape. Two properties are
|
||||
# deliberately hostile:
|
||||
#
|
||||
# * every angle gets a different window size and a different, meaningless
|
||||
# stage x_start / y0 — alignment must ignore per-angle stage coordinates
|
||||
# entirely, so any code that reads them will visibly fail here;
|
||||
# * the pixel grid is strongly anisotropic (5 µm along x, 50 µm along y),
|
||||
# like the real instrument, so any registration that rotates raw indices
|
||||
# instead of millimetres shears the image and cannot converge.
|
||||
|
||||
_ROT_DX_MM = 0.005 # x pitch, from velocity/laser_freq below
|
||||
_ROT_DY_MM = 0.05 # row spacing
|
||||
_ROT_BG_MV = 4.0
|
||||
_ROT_FG_MV = 160.0
|
||||
|
||||
|
||||
# How far the sample sits from the rotation axis. Non-zero on purpose: on the
|
||||
# real instrument every angle's scan window is centred on the rotation axis
|
||||
# while the sample is not, so each scan sees the sample somewhere else along a
|
||||
# circle. That offset is exactly what a wrong rotation pivot turns into a ring
|
||||
# of scans instead of a stack, so a centred test sample would hide the bug.
|
||||
_ROT_SAMPLE_OFFSET_MM = (0.55, 0.40)
|
||||
|
||||
|
||||
def _sample_shape_mv(u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
||||
"""An asymmetric test sample in its own mm frame, chirally distinct at
|
||||
every rotation (no 180° ambiguity) and with structure at several radii so
|
||||
rotation is well determined."""
|
||||
u = u - _ROT_SAMPLE_OFFSET_MM[0]
|
||||
v = v - _ROT_SAMPLE_OFFSET_MM[1]
|
||||
img = np.full(u.shape, _ROT_BG_MV, dtype=np.float32)
|
||||
img[((u / 0.85) ** 2 + (v / 0.40) ** 2) <= 1.0] = _ROT_FG_MV # bar
|
||||
img[(np.abs(u - 0.55) <= 0.22) & (np.abs(v - 0.62) <= 0.22)] = _ROT_FG_MV # nub
|
||||
img[((u + 0.75) ** 2 + (v + 0.30) ** 2) <= 0.20 ** 2] = _ROT_FG_MV # dot
|
||||
return img
|
||||
|
||||
|
||||
def _rot(theta_deg: float) -> np.ndarray:
|
||||
t = np.radians(theta_deg)
|
||||
c, s = np.cos(t), np.sin(t)
|
||||
return np.array([[c, -s], [s, c]])
|
||||
|
||||
|
||||
def write_rotating(path: Path, n_angles: int = 5, samples_per_frame: int = 4,
|
||||
seed: int = 0) -> dict:
|
||||
"""Write a v6 file whose CH4 DC image is one sample seen at n_angles known
|
||||
rotations, and return the ground truth each angle should register to.
|
||||
|
||||
``truth[a] = (rotation_deg, (shift_x_mm, shift_y_mm))`` is the rigid map
|
||||
from angle *a*'s local mm (origin at its own array center) to angle 0's —
|
||||
exactly what ``register_angle_to_reference`` is supposed to recover.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
n_ch, bps = 3, 1
|
||||
cal = [(1.5625e-3, -87.04, 0.0), (2.0e-3, -60.0, 1.0e-3), (2.5e-3, -40.0, -2.0e-3)]
|
||||
ymult_mv, yoff, yzero_mv = cal[2][0] * 1000, cal[2][1], cal[2][2] * 1000
|
||||
|
||||
stage_angles, geom, x_starts, y_starts, thetas, offsets = [], [], [], [], [], []
|
||||
for a in range(n_angles):
|
||||
stage = -37.0 * a # what the rotation stage reports
|
||||
stage_angles.append(stage)
|
||||
# The true image rotation is the negative of the stage's reported
|
||||
# angle: the stage's positive sense is the opposite of math-positive
|
||||
# (x toward y) in scan mm. Nothing may depend on knowing that — the
|
||||
# registration search tries both signs.
|
||||
thetas.append(-stage)
|
||||
offsets.append((0.0, 0.0) if a == 0
|
||||
else (float(rng.uniform(-0.3, 0.3)), float(rng.uniform(-0.3, 0.3))))
|
||||
# A different window per angle, all centred on the same array center —
|
||||
# the real instrument grows each angle's axis-aligned bounding box to
|
||||
# cover the rotated ROI. Sized so the off-axis sample stays inside every
|
||||
# window at every angle, keeping the expected result unambiguous.
|
||||
geom.append((88 + 8 * a, 780 + 60 * a))
|
||||
# Meaningless per-angle stage positions: correct alignment never reads
|
||||
# them, so scattering them proves it.
|
||||
x_starts.append(float(20.0 + rng.uniform(-6.0, 6.0)))
|
||||
y_starts.append(float(30.0 + rng.uniform(-6.0, 6.0)))
|
||||
|
||||
out = bytearray()
|
||||
out += struct.pack(
|
||||
HDR_FMT_V6, b"SRAS", 6, n_angles,
|
||||
x_starts[0], y_starts[0], 1.0, 1.0, _ROT_DY_MM,
|
||||
_VELOCITY_MM_S, _VELOCITY_MM_S / _ROT_DX_MM, # velocity/freq -> 5 µm pitch
|
||||
samples_per_frame, _SAMPLE_RATE_HZ, bps, n_ch,
|
||||
)
|
||||
out += np.array(stage_angles, dtype=">f4").tobytes()
|
||||
for a, (n_rows, n_frames) in enumerate(geom):
|
||||
out += struct.pack(GEO_FMT_V6, x_starts[a], 1.0, n_frames, n_rows)
|
||||
for a, (n_rows, _) in enumerate(geom):
|
||||
out += (y_starts[a] + np.arange(n_rows) * _ROT_DY_MM).astype(">f4").tobytes()
|
||||
for ymult_v, yoff_a, yzero_v in cal:
|
||||
p = _preamble(ymult_v, yoff_a, yzero_v)
|
||||
out += struct.pack(">H", len(p)) + p
|
||||
background = rng.integers(-8, 9, size=samples_per_frame, dtype=np.int8)
|
||||
out += struct.pack(">I", samples_per_frame) + background.tobytes()
|
||||
|
||||
truth, dc4_images = {}, []
|
||||
for a, (n_rows, n_frames) in enumerate(geom):
|
||||
# Local mm of every pixel, measured from this angle's own array center.
|
||||
lx = (np.arange(n_frames) - (n_frames - 1) / 2.0) * _ROT_DX_MM
|
||||
ly = (np.arange(n_rows) - (n_rows - 1) / 2.0) * _ROT_DY_MM
|
||||
gx, gy = np.meshgrid(lx, ly)
|
||||
# local = R(theta) @ sample + offset, so sample = R(theta)^T @ (local - offset)
|
||||
rel = np.stack([gx - offsets[a][0], gy - offsets[a][1]], axis=-1)
|
||||
s = rel @ _rot(thetas[a]) # == rel @ R^T.T == R^T @ rel
|
||||
dc4 = _sample_shape_mv(s[..., 0], s[..., 1])
|
||||
dc4_images.append(dc4)
|
||||
|
||||
inv = _rot(-thetas[a])
|
||||
truth[a] = (-thetas[a],
|
||||
tuple(float(v) for v in -(inv @ np.array(offsets[a]))))
|
||||
|
||||
adc4 = np.clip(np.round((dc4 - yzero_mv) / ymult_mv + yoff), -128, 127).astype(np.int8)
|
||||
block = np.zeros((n_rows, n_ch, n_frames, samples_per_frame), dtype=np.int8)
|
||||
block[:, 2] = adc4[:, :, None] # CH4 carries the sample
|
||||
block[:, 1] = 10 # CH3 flat
|
||||
block[:, 0] = rng.integers(-40, 41, size=(n_rows, n_frames, samples_per_frame),
|
||||
dtype=np.int8) # CH1 noise
|
||||
out += block.tobytes()
|
||||
|
||||
path.write_bytes(bytes(out))
|
||||
return {"n_angles": n_angles, "geometry": geom, "stage_angles_deg": stage_angles,
|
||||
"truth": truth, "dc4_mv": dc4_images, "x_starts": x_starts,
|
||||
"y_starts": y_starts, "dx_mm": _ROT_DX_MM, "dy_mm": _ROT_DY_MM}
|
||||
|
||||
|
||||
HDR_FMT_LEGACY = ">4sBHHffffIIdBB"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Angle-alignment tests: does registration actually stack the scans?
|
||||
|
||||
Builds a synthetic scan in which one sample is imaged at several *known*
|
||||
rotations and offsets (tools/make_test_sras.write_rotating) and checks that the
|
||||
alignment path recovers them, that the shared canvas is angle 0's own pixel
|
||||
grid extended, and that nothing in the result depends on any other angle's
|
||||
stage coordinates.
|
||||
|
||||
No Qt — this exercises sras_compute directly. See tools/test_gui.py for the
|
||||
dialog and Aligned-View plumbing.
|
||||
|
||||
Usage: python tools/test_alignment.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import sras_compute as compute # noqa: E402
|
||||
from sras_format import CH4_IDX, SrasFile, adc_to_mv # noqa: E402
|
||||
import tools.make_test_sras as gen # noqa: E402
|
||||
|
||||
# Registration is limited by how far a feature moves per degree: with this
|
||||
# sample's ~1 mm radius and a ~16 µm registration pitch, a quarter degree is
|
||||
# already sub-pixel, so it is the floor of what any metric can resolve here.
|
||||
_ROT_TOL_DEG = 0.5
|
||||
_SHIFT_TOL_MM = 0.02
|
||||
_STACK_IOU_MIN = 0.90
|
||||
_THRESHOLD_MV = 80.0
|
||||
|
||||
_failures: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, detail: str = ""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""))
|
||||
if not ok:
|
||||
_failures.append(name)
|
||||
|
||||
|
||||
def dc4_images(sras: SrasFile) -> dict[int, np.ndarray]:
|
||||
return {a: adc_to_mv(compute.compute_dc_image(sras, a, CH4_IDX), *sras.cal(CH4_IDX))
|
||||
for a in range(sras.n_angles)}
|
||||
|
||||
|
||||
def mm_transform(sras: SrasFile, result, angle_idx: int) -> np.ndarray:
|
||||
"""Recover the pure mm-space rotation from a canvas->raw affine.
|
||||
|
||||
matrix == D @ R^T @ A_out, where A_out and D only carry the canvas and
|
||||
per-angle pixel pitches; undoing both must leave something orthonormal, or
|
||||
the transform is smuggling in a scale or a shear.
|
||||
"""
|
||||
dx_a, dy_a = compute._pixel_pitch_mm(sras, angle_idx)
|
||||
A_out = np.array([[0.0, result.canvas_dx_mm], [result.canvas_dy_mm, 0.0]])
|
||||
D = np.array([[0.0, 1.0 / dy_a], [1.0 / dx_a, 0.0]])
|
||||
return np.linalg.inv(D) @ result.per_angle[angle_idx].matrix @ np.linalg.inv(A_out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="sras_align_"))
|
||||
path = tmpdir / "rotating.sras"
|
||||
meta = gen.write_rotating(path, n_angles=5)
|
||||
sras = SrasFile(str(path))
|
||||
truth = meta["truth"]
|
||||
|
||||
print(f"\nrotating-sample scan: {sras.n_angles} angles, "
|
||||
f"shapes {[sras.image_shape(a) for a in range(sras.n_angles)]}")
|
||||
|
||||
print("\nper-angle rigid registration (rotation + translation, no scale)")
|
||||
dc4 = dc4_images(sras)
|
||||
fits = {a: compute.register_angle_to_reference(
|
||||
sras, a, 0, dc4, dc_threshold_mv=_THRESHOLD_MV)
|
||||
for a in range(sras.n_angles)}
|
||||
for a, fit in fits.items():
|
||||
t_rot, t_shift = truth[a]
|
||||
rot_err = abs(fit.rotation_deg - t_rot)
|
||||
shift_err = float(np.hypot(fit.shift_mm[0] - t_shift[0],
|
||||
fit.shift_mm[1] - t_shift[1]))
|
||||
check(f"angle {a} rotation within {_ROT_TOL_DEG}° of truth",
|
||||
rot_err <= _ROT_TOL_DEG,
|
||||
f"got {fit.rotation_deg:.3f}°, truth {t_rot:.3f}° (err {rot_err:.3f}°)")
|
||||
check(f"angle {a} translation within {_SHIFT_TOL_MM} mm of truth",
|
||||
shift_err <= _SHIFT_TOL_MM, f"err {shift_err:.4f} mm")
|
||||
check("reference angle registers as exact identity",
|
||||
fits[0] == compute.RigidFit(0.0, (0.0, 0.0), 1.0, "reference"))
|
||||
|
||||
# The stage's rotational sense relative to this module's math-positive
|
||||
# convention is not knowable from the file, and the old code hardcoded a
|
||||
# guess. Flipping every reported angle must therefore change nothing: the
|
||||
# search scores both signs and the images decide.
|
||||
flipped = SrasFile(str(path))
|
||||
flipped.angles_deg = -flipped.angles_deg
|
||||
flipped_fits = {a: compute.register_angle_to_reference(
|
||||
flipped, a, 0, dc4, dc_threshold_mv=_THRESHOLD_MV)
|
||||
for a in range(1, flipped.n_angles)}
|
||||
check("negating every reported stage angle changes no fit",
|
||||
all(flipped_fits[a] == fits[a] for a in flipped_fits),
|
||||
str({a: (flipped_fits[a].rotation_deg, fits[a].rotation_deg)
|
||||
for a in flipped_fits if flipped_fits[a] != fits[a]}))
|
||||
|
||||
print("\nper-angle stage coordinates are not consulted")
|
||||
# Move every non-reference angle's scan window somewhere else entirely.
|
||||
# Only angle 0's coordinates may matter, so every fit must be untouched.
|
||||
moved = SrasFile(str(path))
|
||||
for a in range(1, moved.n_angles):
|
||||
moved.x_start_mm[a] += 13.5 * a
|
||||
moved._y_pos_per_angle[a] = moved._y_pos_per_angle[a] - 9.25 * a
|
||||
moved_dc4 = dc4_images(moved)
|
||||
moved_fits = {a: compute.register_angle_to_reference(
|
||||
moved, a, 0, moved_dc4, dc_threshold_mv=_THRESHOLD_MV)
|
||||
for a in range(1, moved.n_angles)}
|
||||
check("relocating every other angle's scan window changes no fit",
|
||||
all(moved_fits[a] == fits[a] for a in moved_fits),
|
||||
str({a: (round(moved_fits[a].rotation_deg, 4), fits[a].rotation_deg)
|
||||
for a in moved_fits if moved_fits[a] != fits[a]}))
|
||||
|
||||
print("\nshared canvas is angle 0's own pixel grid, extended")
|
||||
result = compute.compute_angle_alignment(sras, 0, _THRESHOLD_MV)
|
||||
t0 = result.per_angle[0]
|
||||
check("angle 0's transform has no rotation, scale or shear",
|
||||
np.allclose(t0.matrix, np.eye(2)), str(t0.matrix))
|
||||
check("angle 0 lands on whole canvas pixels (no resampling of the reference)",
|
||||
np.allclose(t0.offset, np.round(t0.offset)), str(t0.offset))
|
||||
check("canvas pitch is angle 0's own pitch",
|
||||
(result.canvas_dx_mm, result.canvas_dy_mm)
|
||||
== compute._pixel_pitch_mm(sras, 0))
|
||||
|
||||
n_rows, n_cols = result.canvas_shape
|
||||
x_axis = result.canvas_origin_mm[0] + np.arange(n_cols) * result.canvas_dx_mm
|
||||
y_axis = result.canvas_origin_mm[1] + np.arange(n_rows) * result.canvas_dy_mm
|
||||
row0, col0 = int(round(-t0.offset[0])), int(round(-t0.offset[1]))
|
||||
a0_rows, a0_cols = sras.image_shape(0)
|
||||
check("canvas X axis reproduces angle 0's own X coordinates",
|
||||
np.allclose(x_axis[col0:col0 + a0_cols], sras.x_axis_mm(0)))
|
||||
check("canvas Y axis reproduces angle 0's own Y coordinates",
|
||||
np.allclose(y_axis[row0:row0 + a0_rows], sras.y_positions_mm(0)))
|
||||
check("canvas covers every angle's footprint",
|
||||
n_rows >= max(int(sras.n_rows[a]) for a in range(sras.n_angles))
|
||||
and n_cols >= max(int(sras.n_frames[a]) for a in range(sras.n_angles)),
|
||||
str(result.canvas_shape))
|
||||
|
||||
print("\nno scaling anywhere in the per-angle transforms")
|
||||
for a in range(sras.n_angles):
|
||||
R = mm_transform(sras, result, a)
|
||||
check(f"angle {a}'s mm-space transform is a pure rotation",
|
||||
np.allclose(R @ R.T, np.eye(2), atol=1e-9)
|
||||
and abs(abs(np.linalg.det(R)) - 1.0) < 1e-9,
|
||||
f"det={np.linalg.det(R):.6f}")
|
||||
|
||||
print("\nall angles stack into one shape")
|
||||
aligned = {a: compute.apply_alignment(result, a, dc4[a])
|
||||
for a in range(sras.n_angles)}
|
||||
base = aligned[0] >= _THRESHOLD_MV
|
||||
for a in range(1, sras.n_angles):
|
||||
other = aligned[a] >= _THRESHOLD_MV
|
||||
iou = float((base & other).sum()) / max(1, int((base | other).sum()))
|
||||
check(f"angle {a}'s aligned sample overlaps angle 0's (IoU >= {_STACK_IOU_MIN})",
|
||||
iou >= _STACK_IOU_MIN, f"IoU {iou:.4f}")
|
||||
|
||||
print("\ndownsampled preview lands where the full-resolution image does")
|
||||
# ManualAlignmentDialog reprojects block-mean-downsampled masks, so the
|
||||
# affine has to account for the factor. When it did not, every preview
|
||||
# layer came out magnified by that factor and offset — the overlay showed a
|
||||
# blown-up crop of each mask, which is not something you can align by eye.
|
||||
pitch = (result.canvas_dx_mm, result.canvas_dy_mm)
|
||||
a = sras.n_angles - 1
|
||||
p = result.per_angle[a]
|
||||
full_mask = (dc4[a] >= _THRESHOLD_MV).astype(np.float32)
|
||||
full = compute.reproject_mask(
|
||||
sras, a, 0, full_mask, p.rotation_deg, p.shift_mm, pitch,
|
||||
result.canvas_origin_mm, result.canvas_shape)
|
||||
fy, fx = 4, 16
|
||||
small = compute.reproject_mask(
|
||||
sras, a, 0, compute._block_mean_2d(full_mask, fy, fx),
|
||||
p.rotation_deg, p.shift_mm, (pitch[0] * fx, pitch[1] * fy),
|
||||
result.canvas_origin_mm,
|
||||
(result.canvas_shape[0] // fy, result.canvas_shape[1] // fx),
|
||||
src_downsample=(fy, fx))
|
||||
# Compare in mm, via each layer's own center of mass.
|
||||
def com_mm(layer, px, py):
|
||||
rows, cols = np.nonzero(layer > 0.5)
|
||||
return np.array([cols.mean() * px, rows.mean() * py])
|
||||
d = com_mm(small, pitch[0] * fx, pitch[1] * fy) - com_mm(full, *pitch)
|
||||
check("a downsampled preview layer lands within a preview pixel of the "
|
||||
"full-resolution one",
|
||||
abs(d[0]) <= abs(pitch[0] * fx) and abs(d[1]) <= abs(pitch[1] * fy),
|
||||
f"offset {d[0]:+.4f}, {d[1]:+.4f} mm")
|
||||
|
||||
print("\nmanual path reproduces the same geometry")
|
||||
params = {a: compute.ManualAngleParams(t.rotation_deg, t.shift_mm)
|
||||
for a, t in result.per_angle.items()}
|
||||
manual = compute.build_manual_alignment(sras, 0, _THRESHOLD_MV, params)
|
||||
check("build_manual_alignment matches compute_angle_alignment for the same params",
|
||||
manual.canvas_shape == result.canvas_shape
|
||||
and np.allclose(manual.canvas_origin_mm, result.canvas_origin_mm)
|
||||
and all(np.allclose(manual.per_angle[a].matrix, result.per_angle[a].matrix)
|
||||
and np.allclose(manual.per_angle[a].offset, result.per_angle[a].offset)
|
||||
for a in range(sras.n_angles)))
|
||||
|
||||
print("\nsidecar round-trip")
|
||||
compute.save_manual_alignment(sras, 0, _THRESHOLD_MV, params)
|
||||
loaded = compute.load_manual_alignment(sras)
|
||||
check("sidecar reloads every angle's params",
|
||||
loaded is not None
|
||||
and all(np.isclose(loaded.per_angle[a].rotation_deg, params[a].rotation_deg)
|
||||
and np.allclose(loaded.per_angle[a].shift_mm, params[a].shift_mm)
|
||||
for a in range(sras.n_angles)))
|
||||
check("sidecar deletes cleanly", compute.delete_manual_alignment(sras))
|
||||
|
||||
print()
|
||||
if _failures:
|
||||
print(f"{len(_failures)} FAILURE(S): " + ", ".join(_failures))
|
||||
return 1
|
||||
print("All alignment checks passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+60
-69
@@ -27,7 +27,7 @@ from PyQt6.QtWidgets import QApplication, QMessageBox # noqa: E402
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import sras_compute as compute # noqa: E402
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX # noqa: E402
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile # noqa: E402
|
||||
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402
|
||||
import tools.make_test_sras as gen # noqa: E402
|
||||
|
||||
@@ -240,60 +240,49 @@ def main():
|
||||
print("\nmanual alignment (Fusion)")
|
||||
check("manual alignment action enabled", win._manual_align_act.isEnabled())
|
||||
|
||||
# --- Alignment pivot is a signal-weighted centroid, not the raw bbox --
|
||||
# center, and is independent of any DC threshold (so a threshold that
|
||||
# happens to leave a real angle's binary mask empty can't silently
|
||||
# degrade the pivot back to the bbox center).
|
||||
corner_signal = np.zeros(s.image_shape(0), dtype=np.float32)
|
||||
corner_signal[0, 0] = 1.0 # single spike -> weighted centroid is exact
|
||||
expected_corner = (float(s.x_axis_mm(0)[0]), float(s.y_positions_mm(0)[0]))
|
||||
centroid = compute._signal_centroid_mm(s, 0, corner_signal)
|
||||
check("signal-weighted centroid of a single spike pixel is that pixel exactly",
|
||||
np.allclose(centroid, expected_corner), f"{centroid} vs {expected_corner}")
|
||||
bbox_center = compute._bbox_center_mm(s, 0)
|
||||
check("signal centroid differs from the raw scan-window bbox center",
|
||||
not np.allclose(centroid, bbox_center),
|
||||
f"centroid {centroid} vs bbox center {bbox_center}")
|
||||
# --- Local mm is anchored on each angle's array center, not its stage --
|
||||
# position: that is what makes a scan's placement independent of where its
|
||||
# window happened to sit. (Registration accuracy itself is covered by
|
||||
# tools/test_alignment.py, which has a synthetic sample to register.)
|
||||
n_rows, n_frames = s.image_shape(0)
|
||||
check("array center is the geometric center of the pixel grid",
|
||||
np.allclose(compute._center_idx(s, 0),
|
||||
[(n_rows - 1) / 2, (n_frames - 1) / 2]))
|
||||
dx0, dy0 = compute._pixel_pitch_mm(s, 0)
|
||||
check("local half-extent is derived from shape and pitch alone",
|
||||
np.allclose(compute._local_half_extent_mm(s, 0),
|
||||
[(n_frames - 1) / 2 * abs(dx0), (n_rows - 1) / 2 * abs(dy0)]))
|
||||
identity = {a: compute.ManualAngleParams() for a in range(s.n_angles)}
|
||||
origin_a, shape_a = compute.canvas_for_params(s, 0, (dx0, dy0), identity)
|
||||
moved = SrasFile(str(path))
|
||||
for a in range(1, moved.n_angles):
|
||||
moved.x_start_mm[a] += 7.5
|
||||
moved._y_pos_per_angle[a] = moved._y_pos_per_angle[a] + 3.25
|
||||
origin_b, shape_b = compute.canvas_for_params(moved, 0, (dx0, dy0), identity)
|
||||
check("moving every non-reference angle's scan window leaves the canvas "
|
||||
"unchanged (only angle 0's coordinates are used)",
|
||||
shape_a == shape_b and np.allclose(origin_a, origin_b),
|
||||
f"{origin_a} {shape_a} vs {origin_b} {shape_b}")
|
||||
|
||||
# compute_pivot_points_mm should reuse a pre-computed dc4_mv dict rather
|
||||
# than recomputing from the real DC4 image (which has no such spike and
|
||||
# would give a different answer if silently recomputed).
|
||||
reused_pivot = compute.compute_pivot_points_mm(s, dc4_mv={0: corner_signal})[0]
|
||||
check("compute_pivot_points_mm reuses a pre-computed dc4_mv dict",
|
||||
np.allclose(reused_pivot, expected_corner))
|
||||
# --- Both signs of the stage's reported angle are searched --------------
|
||||
cands = compute._rotation_candidates(30.0, 6.0, 2.0)
|
||||
check("rotation candidates bracket both signs of the stage angle",
|
||||
min(cands) < -29.0 and max(cands) > 29.0, f"{min(cands)}..{max(cands)}")
|
||||
|
||||
# A perfectly flat signal carries no information to weight by, so it
|
||||
# falls back to the bbox center rather than producing a NaN/degenerate
|
||||
# centroid.
|
||||
flat_signal = np.full(s.image_shape(0), 5.0, dtype=np.float32)
|
||||
flat_centroid = compute._signal_centroid_mm(s, 0, flat_signal)
|
||||
check("a perfectly flat signal falls back to the bbox center",
|
||||
np.allclose(flat_centroid, bbox_center))
|
||||
|
||||
# --- Rotation sign convention: negative of the raw angles_deg delta ----
|
||||
check("_theta_deg negates the raw angles_deg delta (GR stage's positive "
|
||||
"angle is the opposite rotational sense from this module's CCW "
|
||||
"math convention)",
|
||||
all(np.isclose(compute._theta_deg(s, a, 0),
|
||||
-(float(s.angles_deg[a]) - float(s.angles_deg[0])))
|
||||
for a in range(s.n_angles)))
|
||||
|
||||
# --- FFT phase correlation recovers a known synthetic pixel shift ------
|
||||
rng = np.random.default_rng(0)
|
||||
corr_ref = np.zeros((40, 50), dtype=np.float32)
|
||||
corr_ref[10:25, 15:35] = 1.0
|
||||
corr_ref += 0.05 * rng.standard_normal(corr_ref.shape).astype(np.float32)
|
||||
corr_mov = np.roll(corr_ref, shift=(4, -7), axis=(0, 1))
|
||||
dr, dc = compute._phase_correlate_shift(corr_ref, corr_mov)
|
||||
check("phase correlation recovers the shift that aligns mov onto ref",
|
||||
(dr, dc) == (-4, 7), f"got (dr, dc)={(dr, dc)}")
|
||||
# --- Whole-pixel translation must not wrap content around the edge ------
|
||||
arr = np.zeros((6, 6), dtype=np.float32)
|
||||
arr[0, 0] = 1.0
|
||||
check("_shift_into zero-fills rather than wrapping",
|
||||
compute._shift_into(arr, -1, -1).sum() == 0.0)
|
||||
check("_shift_into moves content by exactly the requested offset",
|
||||
compute._shift_into(arr, 2, 3)[2, 3] == 1.0)
|
||||
|
||||
# --- Open: must NOT seed from the still-live automatic AlignmentResult --
|
||||
# The automatic result's translation comes from FFT phase correlation --
|
||||
# the very thing manual mode exists to work around -- so manual mode
|
||||
# must start from identity (centroids coincide, zero shift) regardless
|
||||
# of whatever the automatic run last computed. Only a previously *saved
|
||||
# manual* alignment (sidecar) should ever seed this dialog.
|
||||
# Manual mode exists to fix up whatever the automatic registration got
|
||||
# wrong, so it must start from identity (every angle centered on the
|
||||
# reference, no rotation) regardless of whatever the automatic run last
|
||||
# computed. Only a previously *saved manual* alignment (sidecar) should
|
||||
# ever seed this dialog.
|
||||
win._on_manual_alignment()
|
||||
check("dialog opened", win._manual_align_dialog is not None)
|
||||
dlg = win._manual_align_dialog
|
||||
@@ -343,39 +332,41 @@ def main():
|
||||
check("a real Right-arrow key event nudged shift_x",
|
||||
dlg._angle_params[active].shift_mm[0] > before[0])
|
||||
|
||||
# --- Auto De-rotate: rotation only, translation untouched ---------------
|
||||
# --- Auto De-rotate: seeds rotation from the stage angle, no translation -
|
||||
shift_before_derotate = dlg._angle_params[active].shift_mm
|
||||
dlg._on_auto_derotate()
|
||||
expected_theta = compute._theta_deg(s, active, dlg._ref_angle_idx)
|
||||
check("auto de-rotate set the known analytic angle",
|
||||
abs(dlg._angle_params[active].rotation_deg - expected_theta) < 1e-6)
|
||||
nominal = compute._nominal_delta_deg(s, active, dlg._ref_angle_idx)
|
||||
check("auto de-rotate seeded rotation from the stage's reported angle",
|
||||
abs(dlg._angle_params[active].rotation_deg - nominal) < 1e-6)
|
||||
check("auto de-rotate left translation untouched",
|
||||
dlg._angle_params[active].shift_mm == shift_before_derotate)
|
||||
check("reference angle stays identity after auto de-rotate",
|
||||
dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0)
|
||||
# Clicking again offers the other sign, since which one lines the scans up
|
||||
# is not knowable from the file.
|
||||
dlg._on_auto_derotate()
|
||||
check("auto de-rotate offers the opposite sign on a second click",
|
||||
abs(dlg._angle_params[active].rotation_deg + nominal) < 1e-6)
|
||||
|
||||
# --- Auto Cross-Correlate: rotation + FFT-correlated shift, backgrounded -
|
||||
# --- Auto Cross-Correlate: searches rotation *and* translation ----------
|
||||
check("cross-correlate action enabled once masks are ready",
|
||||
dlg.btn_auto_correlate.isEnabled())
|
||||
dlg._on_auto_correlate()
|
||||
check("auto cross-correlate completed", wait_until(
|
||||
lambda: not win._job_running("manual_align_correlate"), timeout_ms=30000))
|
||||
check("auto cross-correlate set the known analytic angle for every angle",
|
||||
all(abs(dlg._angle_params[a].rotation_deg
|
||||
- compute._theta_deg(s, a, dlg._ref_angle_idx)) < 1e-6
|
||||
for a in range(s.n_angles) if a != dlg._ref_angle_idx))
|
||||
for label_idx, (label, _sources) in enumerate(dlg._CORRELATE_SOURCES):
|
||||
dlg.combo_correlate_source.setCurrentIndex(label_idx)
|
||||
dlg._on_auto_correlate()
|
||||
check(f"auto cross-correlate completed ({label})", wait_until(
|
||||
lambda: not win._job_running("manual_align_correlate"), timeout_ms=60000))
|
||||
check(f"every non-reference angle got a fit ({label})",
|
||||
all(a in dlg._fit_notes for a in range(s.n_angles)
|
||||
if a != dlg._ref_angle_idx))
|
||||
check("auto cross-correlate reference angle stays identity",
|
||||
dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams())
|
||||
check("auto cross-correlate re-enabled controls when done",
|
||||
dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled())
|
||||
check("preview canvas rebuilt after cross-correlate",
|
||||
len(dlg._preview_layers) == s.n_angles)
|
||||
|
||||
# The thresholded-mask option should also work end to end.
|
||||
dlg.combo_correlate_source.setCurrentIndex(1) # thresholded mask
|
||||
dlg._on_auto_correlate()
|
||||
check("auto cross-correlate (thresholded-mask option) completed", wait_until(
|
||||
lambda: not win._job_running("manual_align_correlate"), timeout_ms=30000))
|
||||
check("fit quality is reported per angle", bool(dlg._fit_report()),
|
||||
dlg._fit_report())
|
||||
|
||||
# --- Save -----------------------------------------------------------------
|
||||
dlg._on_save()
|
||||
|
||||
Reference in New Issue
Block a user