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
+738 -424
View File
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""
sras_edit_scans.py — Remove one or more angle scans from a .sras file.
A .sras file holds one or more "angles" (rotation positions); the viewer
cross-correlates each non-reference angle against the reference to align
them. If one angle's acquisition went wrong (stage glitch, bad trigger,
laser dropout, ...) it throws off that alignment for the whole file. This
tool drops the bad angle(s) and renumbers the rest, writing a new .sras file
with everything else — waveform samples, calibration preambles, background
waveform, row/geometry tables — carried over byte-for-byte.
Handles v2-v7. Any precomputed FFT/DC cache (v5 PREC tail, v7 CACH tail) is
dropped on write, since it's indexed by angle and would be stale/misaligned
after renumbering; the viewer just recomputes it next time the file opens.
Usage:
python sras_edit_scans.py input.sras --list
python sras_edit_scans.py input.sras output.sras --drop 2,5
python sras_edit_scans.py input.sras output.sras --keep 0,1,3,4,6
"""
import argparse
import struct
import sys
from pathlib import Path
from sras_format import (
GEO_FMT_V6, GEO_SIZE_V6, HDR_FMT, HDR_FMT_V6, HDR_SIZE, HDR_SIZE_V6,
SrasFile,
)
_LEGACY_VERSIONS = (2, 3, 4, 5)
_V6_VERSIONS = (6, 7)
def parse_args():
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("input", help="Input .sras file")
p.add_argument("output", nargs="?", help="Output .sras file (omit with --list)")
p.add_argument("--list", action="store_true",
help="Print each angle's index/degrees/geometry and exit")
g = p.add_mutually_exclusive_group()
g.add_argument("--drop", metavar="I,J,...",
help="Comma-separated angle indices to remove")
g.add_argument("--keep", metavar="I,J,...",
help="Comma-separated angle indices to keep (all others dropped)")
return p.parse_args()
def _parse_index_list(s: str, n_angles: int) -> set[int]:
out = set()
for piece in s.split(","):
piece = piece.strip()
if not piece:
continue
i = int(piece)
if not (0 <= i < n_angles):
raise ValueError(f"angle index {i} out of range [0, {n_angles - 1}]")
out.add(i)
return out
def print_listing(sras: SrasFile):
print(f"\n{'idx':>4} {'angle_deg':>10} {'x_start_mm':>11} {'rows':>6} {'frames':>7}")
for a in range(sras.n_angles):
print(f"{a:>4} {sras.angles_deg[a]:>10.4f} {sras.x_start_mm[a]:>11.4f} "
f"{int(sras.n_rows[a]):>6} {int(sras.n_frames[a]):>7}")
def _copy_range(fin, fout, offset: int, nbytes: int, chunk: int = 64 * 1024 * 1024):
"""Stream *nbytes* raw bytes from *fin* at *offset* into *fout*, without
ever holding more than one chunk in memory (waveform blocks can be
hundreds of MB to low GB each)."""
fin.seek(offset)
remaining = nbytes
while remaining:
buf = fin.read(min(chunk, remaining))
if not buf:
raise IOError("unexpected EOF while copying waveform data")
fout.write(buf)
remaining -= len(buf)
# ---------------------------------------------------------------------------
# Legacy (v2-v5): uniform geometry across angles, one flat waveform block
# ---------------------------------------------------------------------------
def _write_legacy(sras: SrasFile, keep: list[int], out_path: Path):
n_rows = int(sras.n_rows[0])
n_frames = int(sras.n_frames[0]) # uniform across angles for v2-v5
n_ch = sras.n_channels
spf = sras.samples_per_frame
bps = sras.bytes_per_sample
header = struct.pack(
HDR_FMT, b"SRAS", sras.version, len(keep), n_rows,
float(sras.x_start_mm[0]), float(sras.x_delta_mm),
sras.velocity_mm_s, sras.laser_freq_hz,
n_frames, spf, sras.sample_rate_hz, bps, n_ch,
)
# Row table + preambles + background sit right after the angle table and
# don't vary per angle — copy that whole span through unmodified.
angle_table_size = sras.n_angles * 4
with open(sras.path, "rb") as f:
f.seek(HDR_SIZE + angle_table_size)
shared_mid = f.read(sras._data_offset - (HDR_SIZE + angle_table_size))
angle_bytes = n_rows * n_ch * n_frames * spf * bps
with open(sras.path, "rb") as fin, open(out_path, "wb") as fout:
fout.write(header)
fout.write(sras.angles_deg[keep].astype(">f4").tobytes())
fout.write(shared_mid)
for a in keep:
_copy_range(fin, fout, sras._data_offset + a * angle_bytes, angle_bytes)
# ---------------------------------------------------------------------------
# v6/v7: per-angle geometry, ragged waveform blocks
# ---------------------------------------------------------------------------
def _read_v6_sections(path: Path) -> dict:
"""Raw, low-level read of everything before the waveform data.
SrasFile._parse_v6 reads and discards each angle's x_delta (it's not
part of the display-facing geometry it exposes), so a round-trip through
SrasFile would silently drop that field. Re-parsing here keeps every
byte of the Per-Angle Geometry Table intact.
"""
with open(path, "rb") as f:
hdr_raw = f.read(HDR_SIZE_V6)
(magic, ver, n_angles_declared, x_start_nom, y_start_nom, x_delta_nom,
y_delta_nom, row_spacing, vel, freq, spf, sr, bps,
n_ch) = struct.unpack(HDR_FMT_V6, hdr_raw)
angles_deg = list(struct.unpack(f">{n_angles_declared}f",
f.read(n_angles_declared * 4)))
geo = [struct.unpack(GEO_FMT_V6, f.read(GEO_SIZE_V6))
for _ in range(n_angles_declared)] # (x_start, x_delta, n_frames, n_rows)
row_table = [f.read(geo[a][3] * 4) for a in range(n_angles_declared)]
preamble_start = f.tell()
for _ in range(n_ch):
(length,) = struct.unpack(">H", f.read(2))
f.read(length)
preambles_raw = _reread_span(f, preamble_start)
bg_start = f.tell()
(n_bg,) = struct.unpack(">I", f.read(4))
f.read(n_bg)
background_raw = _reread_span(f, bg_start)
data_offset = f.tell()
return {
"version": ver, "n_angles_declared": n_angles_declared,
"x_start_nom": x_start_nom, "y_start_nom": y_start_nom,
"x_delta_nom": x_delta_nom, "y_delta_nom": y_delta_nom,
"row_spacing": row_spacing, "vel": vel, "freq": freq,
"spf": spf, "sr": sr, "bps": bps, "n_ch": n_ch,
"angles_deg": angles_deg, "geo": geo, "row_table": row_table,
"preambles_raw": preambles_raw, "background_raw": background_raw,
"data_offset": data_offset,
}
def _reread_span(f, start: int) -> bytes:
end = f.tell()
f.seek(start)
span = f.read(end - start)
f.seek(end)
return span
def _v6_angle_offsets(sections: dict, file_size: int) -> list[tuple[int, int]]:
"""(offset, nbytes) of each declared angle's waveform block, stopping at
the first angle whose data isn't fully on disk (aborted scan)."""
n_ch, spf, bps = sections["n_ch"], sections["spf"], sections["bps"]
offsets = []
offset = sections["data_offset"]
for xs, xd, nf, nr in sections["geo"]:
nbytes = nr * n_ch * nf * spf * bps
if offset + nbytes > file_size:
break
offsets.append((offset, nbytes))
offset += nbytes
return offsets
def _write_v6(in_path: Path, sections: dict, keep: list[int], out_path: Path):
file_size = in_path.stat().st_size
offsets = _v6_angle_offsets(sections, file_size)
geo = sections["geo"]
header = struct.pack(
HDR_FMT_V6, b"SRAS", sections["version"], len(keep),
sections["x_start_nom"], sections["y_start_nom"],
sections["x_delta_nom"], sections["y_delta_nom"],
sections["row_spacing"], sections["vel"], sections["freq"],
sections["spf"], sections["sr"], sections["bps"], sections["n_ch"],
)
with open(in_path, "rb") as fin, open(out_path, "wb") as fout:
fout.write(header)
fout.write(struct.pack(f">{len(keep)}f",
*[sections["angles_deg"][i] for i in keep]))
for i in keep:
fout.write(struct.pack(GEO_FMT_V6, *geo[i]))
for i in keep:
fout.write(sections["row_table"][i])
fout.write(sections["preambles_raw"])
fout.write(sections["background_raw"])
for i in keep:
off, nbytes = offsets[i]
_copy_range(fin, fout, off, nbytes)
def main():
args = parse_args()
in_path = Path(args.input)
if not in_path.exists():
print(f"Error: input file not found: {in_path}", file=sys.stderr)
sys.exit(1)
print(f"Reading {in_path} ...", flush=True)
try:
sras = SrasFile(str(in_path))
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if sras.version not in (*_LEGACY_VERSIONS, *_V6_VERSIONS):
print(f"Error: unsupported .sras version: {sras.version}", file=sys.stderr)
sys.exit(1)
aborted_note = " (scan aborted; trailing angle(s) already excluded)" if sras.scan_aborted else ""
print(f" Version : v{sras.version}", flush=True)
print(f" Angles : {sras.n_angles}{aborted_note}", flush=True)
if args.list:
print_listing(sras)
return
if not args.output:
print("Error: output path required unless --list is given.", file=sys.stderr)
sys.exit(1)
if not (args.drop or args.keep):
print("Error: specify --drop or --keep (see --list for indices).", file=sys.stderr)
sys.exit(1)
out_path = Path(args.output)
if out_path.resolve() == in_path.resolve():
print("Error: output path must differ from input path.", file=sys.stderr)
sys.exit(1)
try:
if args.drop:
drop = _parse_index_list(args.drop, sras.n_angles)
keep = [a for a in range(sras.n_angles) if a not in drop]
else:
keep = sorted(_parse_index_list(args.keep, sras.n_angles))
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if not keep:
print("Error: at least one angle must remain.", file=sys.stderr)
sys.exit(1)
dropped = [a for a in range(sras.n_angles) if a not in keep]
print(f"\nDropping angle(s): {dropped}")
print(f"Keeping angle(s) : {keep} ({len(keep)} of {sras.n_angles})")
print(f"\nWriting {out_path} ...", flush=True)
if sras.version in _LEGACY_VERSIONS:
_write_legacy(sras, keep, out_path)
else:
_write_v6(in_path, _read_v6_sections(in_path), keep, out_path)
in_mb = in_path.stat().st_size / 1024**2
out_mb = out_path.stat().st_size / 1024**2
print(f" Input size : {in_mb:.1f} MB")
print(f" Output size: {out_mb:.1f} MB")
print("Done.")
print("Note: any precomputed FFT/DC cache was dropped (it's indexed by "
"angle); the viewer will recompute it next time this file opens.")
if __name__ == "__main__":
main()
+131 -73
View File
@@ -821,18 +821,18 @@ class ManualAlignmentDialog(QDialog):
misalignment is visible by eye. Reference angle (always index 0) is
ground truth and never moves; every other angle is aligned to it. The
user picks an "active" angle and nudges its rotation+translation with
the keyboard; Auto De-rotate sets every non-reference angle's rotation to
the known, analytic scan-angle delta without touching any translation;
Auto Cross-Correlate does the same rotation and additionally sets
translation to the FFT-phase-correlation best fit against the reference
(see compute.correlate_translation_mm) — meant to get every angle roughly
stacked on top of each other so keyboard nudging only has to make small
corrections, not find a coarse alignment from scratch. Save writes a
JSON sidecar next to the .sras file and hands a freshly-built, full-
resolution AlignmentResult back to the main window — the exact same
object shape compute_angle_alignment produces, so every existing
Aligned-View code path (apply_alignment, _aligned_canvas_axes, the
pixel-inspector inverse-transform) works completely unmodified.
the keyboard; Auto Cross-Correlate finds every non-reference angle's
rotation *and* translation by registering its image against the
reference's (see compute.register_angle_to_reference) — meant to get every
angle stacked on top of each other so keyboard nudging only has to make
small corrections, not find an alignment from scratch; Auto De-rotate is
the weaker fallback that just seeds rotation from the stage's reported
angle, leaving translation alone. Save writes a JSON sidecar next to the
.sras file and hands a freshly-built, full-resolution AlignmentResult back
to the main window — the exact same object shape compute_angle_alignment
produces, so every existing Aligned-View code path (apply_alignment,
_aligned_canvas_axes, the pixel-inspector inverse-transform) works
completely unmodified.
Non-modal by design (shown via .show(), never .exec() or setModal(True))
so the user can still interact with the main window. Talks back to
@@ -853,6 +853,16 @@ class ManualAlignmentDialog(QDialog):
_ACTIVE_ALPHA = 0.75
_MAX_PREVIEW_DIM = 1024
# (label, sources passed to compute.register_angle_to_reference). "Both"
# registers on each and keeps whichever scores higher per angle, which
# costs roughly double but removes the failure mode where the single
# chosen source is the one that happens to be uninformative for one angle.
_CORRELATE_SOURCES = (
("Both, keep best (recommended)", ("signal", "mask")),
("Raw signal", ("signal",)),
("Thresholded mask", ("mask",)),
)
def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *,
ref_angle_idx: int, dc_threshold_mv: float,
seed_per_angle: dict[int, ManualAngleParams] | None,
@@ -861,15 +871,16 @@ class ManualAlignmentDialog(QDialog):
self._parent = parent
self._sras = sras
self._ref_angle_idx = ref_angle_idx
self._downsample_factor = 1
self._downsample = (1, 1) # (rows, cols) block-mean factors
self._dc4_mv: dict[int, np.ndarray] = {}
self._masks_small: dict[int, np.ndarray] = {}
self._pivot_mm: dict[int, tuple[float, float]] = {}
self._preview_layers: dict[int, np.ndarray] = {}
self._preview_origin_mm = (0.0, 0.0)
self._preview_shape = (1, 1)
self._preview_dx_mm = self._preview_dy_mm = 1.0
self._preview_pitch_mm = (1.0, 1.0)
self._masks_ready = False
self._fit_notes: dict[int, tuple[float, str]] = {}
self._derotate_sign_flipped = False
self.setWindowTitle(f"Manual Alignment — {sras.path.name}")
self.resize(1150, 760)
@@ -1006,25 +1017,27 @@ class ManualAlignmentDialog(QDialog):
self.grp_correlate, cl = _group("Cross-Correlate (FFT)")
cform = _form()
self.combo_correlate_source = QComboBox()
self.combo_correlate_source.addItems(
["Raw signal (recommended)", "Thresholded mask"])
for label, sources in self._CORRELATE_SOURCES:
self.combo_correlate_source.addItem(label, sources)
cform.addRow("Correlate on:", self.combo_correlate_source)
self.spin_correlate_margin = QDoubleSpinBox()
self.spin_correlate_margin.setRange(0.05, 2.0)
self.spin_correlate_margin.setSingleStep(0.05)
self.spin_correlate_margin.setDecimals(2)
self.spin_correlate_margin.setValue(0.30)
self.spin_correlate_margin.setMinimumWidth(_SPIN_MIN_W)
cform.addRow("Search margin (× extent):", self.spin_correlate_margin)
self.spin_correlate_search_deg = QDoubleSpinBox()
self.spin_correlate_search_deg.setRange(0.0, 180.0)
self.spin_correlate_search_deg.setSingleStep(1.0)
self.spin_correlate_search_deg.setDecimals(1)
self.spin_correlate_search_deg.setSuffix(" °")
self.spin_correlate_search_deg.setValue(6.0)
self.spin_correlate_search_deg.setMinimumWidth(_SPIN_MIN_W)
cform.addRow("Rotation search (±):", self.spin_correlate_search_deg)
cl.addLayout(cform)
self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)")
cl.addWidget(self.btn_auto_correlate)
cl.addWidget(_wrap_label(
"Sets rotation to the known scan angle and translation to the "
"FFT-correlated best fit for every non-reference angle. Run this "
"first, then use manual nudging only for small corrections.",
_CSS_HINT))
"Finds each non-reference angle's rotation *and* translation by "
"cross-correlating its image against the reference's — the stage's "
"reported angle is only the starting point of the search, and both "
"of its signs are tried. Run this first, then nudge only for small "
"corrections.", _CSS_HINT))
panel_l.addWidget(self.grp_correlate)
# ---- Actions ------------------------------------------------------
@@ -1091,15 +1104,16 @@ class ManualAlignmentDialog(QDialog):
def _finish_mask_prep(self):
if len(self._dc4_mv) < self._sras.n_angles:
return # a mask-worker error left some angles unfetched
max_dim = max(max(img.shape) for img in self._dc4_mv.values())
self._downsample_factor = max(1, int(np.ceil(max_dim / self._MAX_PREVIEW_DIM)))
# Rows and columns get their own factor. A real scan is ~7500 frames
# wide but only ~750 rows tall, so one shared factor sized for the
# frames would throw away 8x more row detail than the preview needs and
# leave the overlay too coarse in y to judge alignment by eye.
max_rows = max(img.shape[0] for img in self._dc4_mv.values())
max_cols = max(img.shape[1] for img in self._dc4_mv.values())
self._downsample = (
max(1, int(np.ceil(max_rows / self._MAX_PREVIEW_DIM))),
max(1, int(np.ceil(max_cols / self._MAX_PREVIEW_DIM))))
self._recompute_masks_small()
# Alignment pivot: the CH4-signal-weighted centroid of each angle's
# own footprint (see compute.compute_pivot_points_mm) — computed once
# from the full-res CH4 images and deliberately independent of the
# mask threshold, so it never needs recomputing when that changes
# (unlike _masks_small, which is purely for the overlay's visuals).
self._pivot_mm = compute.compute_pivot_points_mm(self._sras, self._dc4_mv)
self._rebuild_preview_canvas()
self._set_controls_enabled(True)
self.lbl_status.setText("Ready.")
@@ -1108,13 +1122,12 @@ class ManualAlignmentDialog(QDialog):
"""Threshold + downsample every angle's already-in-memory full-res
CH4 mV image. Cheap (a compare + block-mean), so this re-runs in
full whenever the mask-threshold spin box changes — no re-fetch.
Purely for the overlay's visuals — the alignment pivot does not
depend on this threshold (see _pivot_mm / compute_pivot_points_mm)."""
Purely for the overlay's visuals: no alignment geometry depends on this
threshold, only which pixels the overlay paints."""
threshold = self.spin_mask_threshold_mv.value()
factor = self._downsample_factor
fy, fx = self._downsample
self._masks_small = {
a: compute._block_mean_downsample(
(img >= threshold).astype(np.float32), factor)
a: compute._block_mean_2d((img >= threshold).astype(np.float32), fy, fx)
for a, img in self._dc4_mv.items()
}
@@ -1131,32 +1144,35 @@ class ManualAlignmentDialog(QDialog):
active angle. NOT triggered by a translation-only nudge — see
_refresh_active_preview_layer."""
dx_ref, dy_ref = compute._pixel_pitch_mm(self._sras, self._ref_angle_idx)
factor = self._downsample_factor
dx_c, dy_c = dx_ref * factor, dy_ref * factor
origin, shape = compute.union_canvas_mm(
self._sras, self._ref_angle_idx, dx_c, dy_c, self._angle_params,
self._pivot_mm, margin_frac=self._PREVIEW_MARGIN_FRAC)
fy, fx = self._downsample
pitch = (dx_ref * fx, dy_ref * fy)
origin, shape = compute.canvas_for_params(
self._sras, self._ref_angle_idx, pitch, self._angle_params,
margin_frac=self._PREVIEW_MARGIN_FRAC, snap=False)
self._preview_origin_mm, self._preview_shape = origin, shape
self._preview_dx_mm, self._preview_dy_mm = dx_c, dy_c
self._preview_pitch_mm = pitch
self._preview_layers = {
a: compute.reproject_mask(
self._sras, a, self._ref_angle_idx, self._masks_small[a],
self._angle_params[a].rotation_deg, self._angle_params[a].shift_mm,
dx_c, dy_c, origin, shape, self._pivot_mm)
for a in range(self._sras.n_angles)
a: self._reproject(a) for a in range(self._sras.n_angles)
}
self._redraw_overlay()
def _reproject(self, angle_idx: int) -> np.ndarray:
"""One angle's downsampled mask on the current preview canvas.
src_downsample must match _masks_small's block-mean factors, or the
layer lands magnified and offset instead of where the alignment
actually puts it."""
p = self._angle_params[angle_idx]
return compute.reproject_mask(
self._sras, angle_idx, self._ref_angle_idx,
self._masks_small[angle_idx], p.rotation_deg, p.shift_mm,
self._preview_pitch_mm, self._preview_origin_mm, self._preview_shape,
src_downsample=self._downsample)
def _refresh_active_preview_layer(self):
"""Cheap path for a translation-only nudge/edit of the active angle:
reproject just that one angle's downsampled mask onto the *existing*
preview canvas — every other angle's cached layer is untouched."""
a = self._active_angle
self._preview_layers[a] = compute.reproject_mask(
self._sras, a, self._ref_angle_idx, self._masks_small[a],
self._angle_params[a].rotation_deg, self._angle_params[a].shift_mm,
self._preview_dx_mm, self._preview_dy_mm,
self._preview_origin_mm, self._preview_shape, self._pivot_mm)
self._preview_layers[self._active_angle] = self._reproject(self._active_angle)
self._redraw_overlay()
def _redraw_overlay(self):
@@ -1182,7 +1198,7 @@ class ManualAlignmentDialog(QDialog):
rgba[..., 3] = fg_a + rgba[..., 3] * (1 - fg_a)
x0, y0 = self._preview_origin_mm
dx, dy = self._preview_dx_mm, self._preview_dy_mm
dx, dy = self._preview_pitch_mm
x_axis = x0 + np.arange(n_cols) * dx
y_axis = y0 + np.arange(n_rows) * dy
extent = [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
@@ -1258,18 +1274,29 @@ class ManualAlignmentDialog(QDialog):
# ------------------------------------------------------------------
def _on_auto_derotate(self):
"""Seed every angle's rotation from the stage's reported angle.
A starting point for nudging by eye, not an alignment: the stage's
sign convention relative to this module's is not knowable from the
file, so the sign that lines the scans up is whichever of the two looks
right in the overlay. Auto Cross-Correlate decides that from the images
instead, and is the button to reach for first.
"""
sign = -1.0 if self._derotate_sign_flipped else 1.0
self._derotate_sign_flipped = not self._derotate_sign_flipped
n_changed = 0
for a in range(self._sras.n_angles):
if a == self._ref_angle_idx:
continue
self._angle_params[a].rotation_deg = compute._theta_deg(
self._angle_params[a].rotation_deg = sign * compute._nominal_delta_deg(
self._sras, a, self._ref_angle_idx)
n_changed += 1
self._sync_active_spinboxes()
self._rebuild_preview_canvas()
self.lbl_status.setText(
f"Rotation set to the known scan angle for {n_changed} angle(s) "
"(translation left untouched).")
f"Rotation set to the stage angle ({'−' if sign < 0 else '+'}delta) "
f"for {n_changed} angle(s); translation untouched. Click again to "
"try the opposite sign.")
def _on_auto_correlate(self):
if not self._masks_ready:
@@ -1277,13 +1304,14 @@ class ManualAlignmentDialog(QDialog):
angles = [a for a in range(self._sras.n_angles) if a != self._ref_angle_idx]
if not angles:
return
use_mask = self.combo_correlate_source.currentIndex() == 1
worker = CrossCorrelateWorker(
self._sras, self._ref_angle_idx, angles, self._dc4_mv, self._pivot_mm,
use_mask=use_mask, dc_threshold_mv=self.spin_mask_threshold_mv.value(),
margin_frac=self.spin_correlate_margin.value())
self._sras, self._ref_angle_idx, angles, self._dc4_mv,
sources=self.combo_correlate_source.currentData(),
dc_threshold_mv=self.spin_mask_threshold_mv.value(),
search_deg=self.spin_correlate_search_deg.value())
self._correlate_done_count = 0
self._correlate_total = len(angles)
self._fit_notes = {}
self._set_controls_enabled(False)
self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…")
started = self._parent._run_worker(
@@ -1298,8 +1326,10 @@ class ManualAlignmentDialog(QDialog):
self.lbl_status.setText("Could not start cross-correlation (busy) — try again.")
def _on_correlate_angle_done(self, angle_idx: int, rotation_deg: float,
shift_x_mm: float, shift_y_mm: float):
shift_x_mm: float, shift_y_mm: float,
score: float, source: str):
self._angle_params[angle_idx] = ManualAngleParams(rotation_deg, (shift_x_mm, shift_y_mm))
self._fit_notes[angle_idx] = (score, source)
self._correlate_done_count += 1
self.lbl_status.setText(
f"Cross-correlating: {self._correlate_done_count}/{self._correlate_total} angle(s)…")
@@ -1311,20 +1341,47 @@ class ManualAlignmentDialog(QDialog):
self._sync_active_spinboxes()
self._rebuild_preview_canvas()
self._set_controls_enabled(True)
source = "thresholded mask" if self.combo_correlate_source.currentIndex() == 1 \
else "raw signal"
self.lbl_status.setText(
f"Cross-correlated {self._correlate_done_count} angle(s) against "
f"Angle {self._ref_angle_idx} using the {source}. Nudge from here "
"for any remaining fine correction.")
f"Angle {self._ref_angle_idx}.\n" + self._fit_report())
def _fit_report(self) -> str:
"""Per-angle registration quality, worst first.
Surfaced rather than buried because a single bad acquisition (stage
glitch, laser dropout) registers poorly and would otherwise be fused in
silently — seeing which angle it is, is what makes dropping it with
sras_edit_scans.py actionable. The deviation from the stage's own
reported angle is shown alongside: a large one means the search and the
stage disagree, which is either a genuine mechanical error or a sign
that this angle's fit is not to be trusted.
"""
if not self._fit_notes:
return ""
rows = sorted(self._fit_notes.items(), key=lambda kv: kv[1][0])
worst = rows[0]
lines = [f"Worst fit: angle {worst[0]} (score {worst[1][0]:.3f}, "
f"{worst[1][1]})."]
drifted = []
for a, _note in rows:
nominal = compute._nominal_delta_deg(self._sras, a, self._ref_angle_idx)
got = self._angle_params[a].rotation_deg
dev = min(abs(got - nominal), abs(got + nominal))
if dev > 1.0:
drifted.append(f"{a} ({dev:.2f}°)")
if drifted:
lines.append("Rotation differs from the stage angle by >1° for "
"angle(s) " + ", ".join(drifted) + ".")
lines.append("Nudge from here for any remaining fine correction.")
return " ".join(lines)
def _on_save(self):
threshold = self.spin_mask_threshold_mv.value()
resolved = dict(self._angle_params) # already concrete floats
try:
path = save_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved)
result = build_manual_alignment(self._sras, self._ref_angle_idx, threshold,
resolved, self._pivot_mm)
result = build_manual_alignment(self._sras, self._ref_angle_idx,
threshold, resolved)
except OSError as exc:
QMessageBox.warning(self, "Save Alignment Failed", str(exc))
return
@@ -1348,6 +1405,7 @@ class ManualAlignmentDialog(QDialog):
f"Could not delete the saved alignment file: {exc}")
return
self._angle_params = {a: ManualAngleParams() for a in range(self._sras.n_angles)}
self._fit_notes = {}
self._sync_active_spinboxes()
self._rebuild_preview_canvas()
self.lbl_status.setText(
+8
View File
@@ -1,3 +1,11 @@
PyQt6==6.10.2
numpy==2.4.1
matplotlib==3.10.8
scipy==1.18.0
# Angle alignment only: masked FFT phase correlation, which registers scans
# whose valid (scanned) regions differ in shape — see sras_compute's
# _masked_shift.
scikit-image==0.26.0
# Optional: a faster rfft backend for the RF/FFT images (FFT Options -> pyFFTW).
# The viewer falls back to scipy.fft when it is not installed.
pyFFTW==0.15.1
+31 -30
View File
@@ -282,10 +282,10 @@ class BatchCacheWorker(QObject):
class AngleAlignmentWorker(QObject):
"""Computes rotation+translation alignment for every angle in *sras*,
referenced to *ref_angle_idx*, from each angle's binarized CH4 mask.
Rotation is analytic (from sras.angles_deg); only translation is found by
phase correlation.
"""Computes the rigid (rotation + translation, never scale) alignment for
every angle in *sras* against *ref_angle_idx*, by cross-correlating each
angle's CH4 image against the reference's. Both the rotation and the
translation are found from image content — see compute_angle_alignment.
"""
progress = pyqtSignal(int) # 0–100
finished = pyqtSignal(object, str) # AlignmentResult|None, error ("" = success)
@@ -349,52 +349,53 @@ class Ch4MaskWorker(QObject):
class CrossCorrelateWorker(QObject):
"""FFT phase-correlation translation for each of *angle_indices* against
*ref_angle_idx*, for ManualAlignmentDialog's Auto Cross-Correlate button.
"""Rigid registration (rotation + translation, never scale) of each of
*angle_indices* against *ref_angle_idx*, for ManualAlignmentDialog's Auto
Cross-Correlate button.
Runs on a background thread — a real many-angle, high-resolution scan's
correlation (even at its downsampled working resolution) can take long
enough that doing all of them on the GUI thread would visibly freeze the
dialog. Rotation is set to the same analytic scan-angle delta Auto
De-rotate uses alongside the correlated shift, since a translation
search is only meaningful once both angles' content is already oriented
the same way. dc4_mv/pivot_mm are the dialog's own already-in-memory
per-angle images/pivots — this worker does no fetching of its own.
Runs on a background thread — registering a real many-angle,
high-resolution scan takes long enough that doing it on the GUI thread
would visibly freeze the dialog. Rotation is *searched*, not taken from the
stage's reported angle: see compute.register_angle_to_reference, which
seeds from that angle but scores both of its signs and refines from there.
dc4_mv is the dialog's own already-in-memory per-angle CH4 image — this
worker does no fetching of its own.
"""
angle_done = pyqtSignal(int, float, float, float) # angle_idx, rotation_deg, shift_x_mm, shift_y_mm
# angle_idx, rotation_deg, shift_x_mm, shift_y_mm, score, source
angle_done = pyqtSignal(int, float, float, float, float, str)
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
dc4_mv: dict[int, np.ndarray], pivot_mm: dict[int, tuple[float, float]],
*, use_mask: bool, dc_threshold_mv: float, margin_frac: float):
dc4_mv: dict[int, np.ndarray], *,
sources: tuple[str, ...], dc_threshold_mv: float,
search_deg: float):
super().__init__()
self._sras = sras
self._ref = ref_angle_idx
self._angles = angle_indices
self._dc4_mv = dc4_mv
self._pivot_mm = pivot_mm
self._use_mask = use_mask
self._sources = sources
self._threshold = dc_threshold_mv
self._margin = margin_frac
self._search_deg = search_deg
def _one(self, a: int) -> tuple[int, float, float, float]:
theta = compute._theta_deg(self._sras, a, self._ref)
dx, dy = compute.correlate_translation_mm(
self._sras, a, self._ref, self._dc4_mv, self._pivot_mm,
use_mask=self._use_mask, dc_threshold_mv=self._threshold,
margin_frac=self._margin)
return a, theta, dx, dy
def _one(self, a: int) -> tuple[int, compute.RigidFit]:
return a, compute.register_angle_to_reference(
self._sras, a, self._ref, self._dc4_mv,
dc_threshold_mv=self._threshold, sources=self._sources,
search_deg=self._search_deg)
def run(self):
try:
n_workers, _budget = compute.plan_angle_level(self._sras)
n_workers = compute._registration_workers(
self._sras, compute._DEFAULT_FINE_DIM)
pool = ThreadPoolExecutor(max_workers=max(1, n_workers))
try:
futures = [pool.submit(self._one, a) for a in self._angles]
for fut in as_completed(futures):
a, theta, dx, dy = fut.result()
self.angle_done.emit(a, theta, dx, dy)
a, fit = fut.result()
self.angle_done.emit(a, fit.rotation_deg, fit.shift_mm[0],
fit.shift_mm[1], fit.score, fit.source)
finally:
pool.shutdown(wait=True)
self.finished.emit()
+132
View File
@@ -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"
+223
View File
@@ -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())
+59 -68
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())
for label_idx, (label, _sources) in enumerate(dlg._CORRELATE_SOURCES):
dlg.combo_correlate_source.setCurrentIndex(label_idx)
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))
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()