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:
Thomas Ales
2026-08-06 09:35:30 -05:00
parent bbb075ff34
commit d5028db445
8 changed files with 1619 additions and 597 deletions
+60 -69
View File
@@ -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()