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:
+739
-425
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -821,18 +821,18 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
misalignment is visible by eye. Reference angle (always index 0) is
|
misalignment is visible by eye. Reference angle (always index 0) is
|
||||||
ground truth and never moves; every other angle is aligned to it. The
|
ground truth and never moves; every other angle is aligned to it. The
|
||||||
user picks an "active" angle and nudges its rotation+translation with
|
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 keyboard; Auto Cross-Correlate finds every non-reference angle's
|
||||||
the known, analytic scan-angle delta without touching any translation;
|
rotation *and* translation by registering its image against the
|
||||||
Auto Cross-Correlate does the same rotation and additionally sets
|
reference's (see compute.register_angle_to_reference) — meant to get every
|
||||||
translation to the FFT-phase-correlation best fit against the reference
|
angle stacked on top of each other so keyboard nudging only has to make
|
||||||
(see compute.correlate_translation_mm) — meant to get every angle roughly
|
small corrections, not find an alignment from scratch; Auto De-rotate is
|
||||||
stacked on top of each other so keyboard nudging only has to make small
|
the weaker fallback that just seeds rotation from the stage's reported
|
||||||
corrections, not find a coarse alignment from scratch. Save writes a
|
angle, leaving translation alone. Save writes a JSON sidecar next to the
|
||||||
JSON sidecar next to the .sras file and hands a freshly-built, full-
|
.sras file and hands a freshly-built, full-resolution AlignmentResult back
|
||||||
resolution AlignmentResult back to the main window — the exact same
|
to the main window — the exact same object shape compute_angle_alignment
|
||||||
object shape compute_angle_alignment produces, so every existing
|
produces, so every existing Aligned-View code path (apply_alignment,
|
||||||
Aligned-View code path (apply_alignment, _aligned_canvas_axes, the
|
_aligned_canvas_axes, the pixel-inspector inverse-transform) works
|
||||||
pixel-inspector inverse-transform) works completely unmodified.
|
completely unmodified.
|
||||||
|
|
||||||
Non-modal by design (shown via .show(), never .exec() or setModal(True))
|
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
|
so the user can still interact with the main window. Talks back to
|
||||||
@@ -853,6 +853,16 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
_ACTIVE_ALPHA = 0.75
|
_ACTIVE_ALPHA = 0.75
|
||||||
_MAX_PREVIEW_DIM = 1024
|
_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, *,
|
def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *,
|
||||||
ref_angle_idx: int, dc_threshold_mv: float,
|
ref_angle_idx: int, dc_threshold_mv: float,
|
||||||
seed_per_angle: dict[int, ManualAngleParams] | None,
|
seed_per_angle: dict[int, ManualAngleParams] | None,
|
||||||
@@ -861,15 +871,16 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
self._parent = parent
|
self._parent = parent
|
||||||
self._sras = sras
|
self._sras = sras
|
||||||
self._ref_angle_idx = ref_angle_idx
|
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._dc4_mv: dict[int, np.ndarray] = {}
|
||||||
self._masks_small: 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_layers: dict[int, np.ndarray] = {}
|
||||||
self._preview_origin_mm = (0.0, 0.0)
|
self._preview_origin_mm = (0.0, 0.0)
|
||||||
self._preview_shape = (1, 1)
|
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._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.setWindowTitle(f"Manual Alignment — {sras.path.name}")
|
||||||
self.resize(1150, 760)
|
self.resize(1150, 760)
|
||||||
@@ -1006,25 +1017,27 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
self.grp_correlate, cl = _group("Cross-Correlate (FFT)")
|
self.grp_correlate, cl = _group("Cross-Correlate (FFT)")
|
||||||
cform = _form()
|
cform = _form()
|
||||||
self.combo_correlate_source = QComboBox()
|
self.combo_correlate_source = QComboBox()
|
||||||
self.combo_correlate_source.addItems(
|
for label, sources in self._CORRELATE_SOURCES:
|
||||||
["Raw signal (recommended)", "Thresholded mask"])
|
self.combo_correlate_source.addItem(label, sources)
|
||||||
cform.addRow("Correlate on:", self.combo_correlate_source)
|
cform.addRow("Correlate on:", self.combo_correlate_source)
|
||||||
|
|
||||||
self.spin_correlate_margin = QDoubleSpinBox()
|
self.spin_correlate_search_deg = QDoubleSpinBox()
|
||||||
self.spin_correlate_margin.setRange(0.05, 2.0)
|
self.spin_correlate_search_deg.setRange(0.0, 180.0)
|
||||||
self.spin_correlate_margin.setSingleStep(0.05)
|
self.spin_correlate_search_deg.setSingleStep(1.0)
|
||||||
self.spin_correlate_margin.setDecimals(2)
|
self.spin_correlate_search_deg.setDecimals(1)
|
||||||
self.spin_correlate_margin.setValue(0.30)
|
self.spin_correlate_search_deg.setSuffix(" °")
|
||||||
self.spin_correlate_margin.setMinimumWidth(_SPIN_MIN_W)
|
self.spin_correlate_search_deg.setValue(6.0)
|
||||||
cform.addRow("Search margin (× extent):", self.spin_correlate_margin)
|
self.spin_correlate_search_deg.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
cform.addRow("Rotation search (±):", self.spin_correlate_search_deg)
|
||||||
cl.addLayout(cform)
|
cl.addLayout(cform)
|
||||||
self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)")
|
self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)")
|
||||||
cl.addWidget(self.btn_auto_correlate)
|
cl.addWidget(self.btn_auto_correlate)
|
||||||
cl.addWidget(_wrap_label(
|
cl.addWidget(_wrap_label(
|
||||||
"Sets rotation to the known scan angle and translation to the "
|
"Finds each non-reference angle's rotation *and* translation by "
|
||||||
"FFT-correlated best fit for every non-reference angle. Run this "
|
"cross-correlating its image against the reference's — the stage's "
|
||||||
"first, then use manual nudging only for small corrections.",
|
"reported angle is only the starting point of the search, and both "
|
||||||
_CSS_HINT))
|
"of its signs are tried. Run this first, then nudge only for small "
|
||||||
|
"corrections.", _CSS_HINT))
|
||||||
panel_l.addWidget(self.grp_correlate)
|
panel_l.addWidget(self.grp_correlate)
|
||||||
|
|
||||||
# ---- Actions ------------------------------------------------------
|
# ---- Actions ------------------------------------------------------
|
||||||
@@ -1091,15 +1104,16 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
def _finish_mask_prep(self):
|
def _finish_mask_prep(self):
|
||||||
if len(self._dc4_mv) < self._sras.n_angles:
|
if len(self._dc4_mv) < self._sras.n_angles:
|
||||||
return # a mask-worker error left some angles unfetched
|
return # a mask-worker error left some angles unfetched
|
||||||
max_dim = max(max(img.shape) for img in self._dc4_mv.values())
|
# Rows and columns get their own factor. A real scan is ~7500 frames
|
||||||
self._downsample_factor = max(1, int(np.ceil(max_dim / self._MAX_PREVIEW_DIM)))
|
# 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()
|
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._rebuild_preview_canvas()
|
||||||
self._set_controls_enabled(True)
|
self._set_controls_enabled(True)
|
||||||
self.lbl_status.setText("Ready.")
|
self.lbl_status.setText("Ready.")
|
||||||
@@ -1108,13 +1122,12 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
"""Threshold + downsample every angle's already-in-memory full-res
|
"""Threshold + downsample every angle's already-in-memory full-res
|
||||||
CH4 mV image. Cheap (a compare + block-mean), so this re-runs in
|
CH4 mV image. Cheap (a compare + block-mean), so this re-runs in
|
||||||
full whenever the mask-threshold spin box changes — no re-fetch.
|
full whenever the mask-threshold spin box changes — no re-fetch.
|
||||||
Purely for the overlay's visuals — the alignment pivot does not
|
Purely for the overlay's visuals: no alignment geometry depends on this
|
||||||
depend on this threshold (see _pivot_mm / compute_pivot_points_mm)."""
|
threshold, only which pixels the overlay paints."""
|
||||||
threshold = self.spin_mask_threshold_mv.value()
|
threshold = self.spin_mask_threshold_mv.value()
|
||||||
factor = self._downsample_factor
|
fy, fx = self._downsample
|
||||||
self._masks_small = {
|
self._masks_small = {
|
||||||
a: compute._block_mean_downsample(
|
a: compute._block_mean_2d((img >= threshold).astype(np.float32), fy, fx)
|
||||||
(img >= threshold).astype(np.float32), factor)
|
|
||||||
for a, img in self._dc4_mv.items()
|
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
|
active angle. NOT triggered by a translation-only nudge — see
|
||||||
_refresh_active_preview_layer."""
|
_refresh_active_preview_layer."""
|
||||||
dx_ref, dy_ref = compute._pixel_pitch_mm(self._sras, self._ref_angle_idx)
|
dx_ref, dy_ref = compute._pixel_pitch_mm(self._sras, self._ref_angle_idx)
|
||||||
factor = self._downsample_factor
|
fy, fx = self._downsample
|
||||||
dx_c, dy_c = dx_ref * factor, dy_ref * factor
|
pitch = (dx_ref * fx, dy_ref * fy)
|
||||||
origin, shape = compute.union_canvas_mm(
|
origin, shape = compute.canvas_for_params(
|
||||||
self._sras, self._ref_angle_idx, dx_c, dy_c, self._angle_params,
|
self._sras, self._ref_angle_idx, pitch, self._angle_params,
|
||||||
self._pivot_mm, margin_frac=self._PREVIEW_MARGIN_FRAC)
|
margin_frac=self._PREVIEW_MARGIN_FRAC, snap=False)
|
||||||
self._preview_origin_mm, self._preview_shape = origin, shape
|
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 = {
|
self._preview_layers = {
|
||||||
a: compute.reproject_mask(
|
a: self._reproject(a) for a in range(self._sras.n_angles)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
self._redraw_overlay()
|
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):
|
def _refresh_active_preview_layer(self):
|
||||||
"""Cheap path for a translation-only nudge/edit of the active angle:
|
"""Cheap path for a translation-only nudge/edit of the active angle:
|
||||||
reproject just that one angle's downsampled mask onto the *existing*
|
reproject just that one angle's downsampled mask onto the *existing*
|
||||||
preview canvas — every other angle's cached layer is untouched."""
|
preview canvas — every other angle's cached layer is untouched."""
|
||||||
a = self._active_angle
|
self._preview_layers[self._active_angle] = self._reproject(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._redraw_overlay()
|
self._redraw_overlay()
|
||||||
|
|
||||||
def _redraw_overlay(self):
|
def _redraw_overlay(self):
|
||||||
@@ -1182,7 +1198,7 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
rgba[..., 3] = fg_a + rgba[..., 3] * (1 - fg_a)
|
rgba[..., 3] = fg_a + rgba[..., 3] * (1 - fg_a)
|
||||||
|
|
||||||
x0, y0 = self._preview_origin_mm
|
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
|
x_axis = x0 + np.arange(n_cols) * dx
|
||||||
y_axis = y0 + np.arange(n_rows) * dy
|
y_axis = y0 + np.arange(n_rows) * dy
|
||||||
extent = [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
|
extent = [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
|
||||||
@@ -1258,18 +1274,29 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _on_auto_derotate(self):
|
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
|
n_changed = 0
|
||||||
for a in range(self._sras.n_angles):
|
for a in range(self._sras.n_angles):
|
||||||
if a == self._ref_angle_idx:
|
if a == self._ref_angle_idx:
|
||||||
continue
|
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)
|
self._sras, a, self._ref_angle_idx)
|
||||||
n_changed += 1
|
n_changed += 1
|
||||||
self._sync_active_spinboxes()
|
self._sync_active_spinboxes()
|
||||||
self._rebuild_preview_canvas()
|
self._rebuild_preview_canvas()
|
||||||
self.lbl_status.setText(
|
self.lbl_status.setText(
|
||||||
f"Rotation set to the known scan angle for {n_changed} angle(s) "
|
f"Rotation set to the stage angle ({'−' if sign < 0 else '+'}delta) "
|
||||||
"(translation left untouched).")
|
f"for {n_changed} angle(s); translation untouched. Click again to "
|
||||||
|
"try the opposite sign.")
|
||||||
|
|
||||||
def _on_auto_correlate(self):
|
def _on_auto_correlate(self):
|
||||||
if not self._masks_ready:
|
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]
|
angles = [a for a in range(self._sras.n_angles) if a != self._ref_angle_idx]
|
||||||
if not angles:
|
if not angles:
|
||||||
return
|
return
|
||||||
use_mask = self.combo_correlate_source.currentIndex() == 1
|
|
||||||
worker = CrossCorrelateWorker(
|
worker = CrossCorrelateWorker(
|
||||||
self._sras, self._ref_angle_idx, angles, self._dc4_mv, self._pivot_mm,
|
self._sras, self._ref_angle_idx, angles, self._dc4_mv,
|
||||||
use_mask=use_mask, dc_threshold_mv=self.spin_mask_threshold_mv.value(),
|
sources=self.combo_correlate_source.currentData(),
|
||||||
margin_frac=self.spin_correlate_margin.value())
|
dc_threshold_mv=self.spin_mask_threshold_mv.value(),
|
||||||
|
search_deg=self.spin_correlate_search_deg.value())
|
||||||
self._correlate_done_count = 0
|
self._correlate_done_count = 0
|
||||||
self._correlate_total = len(angles)
|
self._correlate_total = len(angles)
|
||||||
|
self._fit_notes = {}
|
||||||
self._set_controls_enabled(False)
|
self._set_controls_enabled(False)
|
||||||
self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…")
|
self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…")
|
||||||
started = self._parent._run_worker(
|
started = self._parent._run_worker(
|
||||||
@@ -1298,8 +1326,10 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
self.lbl_status.setText("Could not start cross-correlation (busy) — try again.")
|
self.lbl_status.setText("Could not start cross-correlation (busy) — try again.")
|
||||||
|
|
||||||
def _on_correlate_angle_done(self, angle_idx: int, rotation_deg: float,
|
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._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._correlate_done_count += 1
|
||||||
self.lbl_status.setText(
|
self.lbl_status.setText(
|
||||||
f"Cross-correlating: {self._correlate_done_count}/{self._correlate_total} angle(s)…")
|
f"Cross-correlating: {self._correlate_done_count}/{self._correlate_total} angle(s)…")
|
||||||
@@ -1311,20 +1341,47 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
self._sync_active_spinboxes()
|
self._sync_active_spinboxes()
|
||||||
self._rebuild_preview_canvas()
|
self._rebuild_preview_canvas()
|
||||||
self._set_controls_enabled(True)
|
self._set_controls_enabled(True)
|
||||||
source = "thresholded mask" if self.combo_correlate_source.currentIndex() == 1 \
|
|
||||||
else "raw signal"
|
|
||||||
self.lbl_status.setText(
|
self.lbl_status.setText(
|
||||||
f"Cross-correlated {self._correlate_done_count} angle(s) against "
|
f"Cross-correlated {self._correlate_done_count} angle(s) against "
|
||||||
f"Angle {self._ref_angle_idx} using the {source}. Nudge from here "
|
f"Angle {self._ref_angle_idx}.\n" + self._fit_report())
|
||||||
"for any remaining fine correction.")
|
|
||||||
|
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):
|
def _on_save(self):
|
||||||
threshold = self.spin_mask_threshold_mv.value()
|
threshold = self.spin_mask_threshold_mv.value()
|
||||||
resolved = dict(self._angle_params) # already concrete floats
|
resolved = dict(self._angle_params) # already concrete floats
|
||||||
try:
|
try:
|
||||||
path = save_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved)
|
path = save_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved)
|
||||||
result = build_manual_alignment(self._sras, self._ref_angle_idx, threshold,
|
result = build_manual_alignment(self._sras, self._ref_angle_idx,
|
||||||
resolved, self._pivot_mm)
|
threshold, resolved)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
QMessageBox.warning(self, "Save Alignment Failed", str(exc))
|
QMessageBox.warning(self, "Save Alignment Failed", str(exc))
|
||||||
return
|
return
|
||||||
@@ -1348,6 +1405,7 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
f"Could not delete the saved alignment file: {exc}")
|
f"Could not delete the saved alignment file: {exc}")
|
||||||
return
|
return
|
||||||
self._angle_params = {a: ManualAngleParams() for a in range(self._sras.n_angles)}
|
self._angle_params = {a: ManualAngleParams() for a in range(self._sras.n_angles)}
|
||||||
|
self._fit_notes = {}
|
||||||
self._sync_active_spinboxes()
|
self._sync_active_spinboxes()
|
||||||
self._rebuild_preview_canvas()
|
self._rebuild_preview_canvas()
|
||||||
self.lbl_status.setText(
|
self.lbl_status.setText(
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
PyQt6==6.10.2
|
PyQt6==6.10.2
|
||||||
numpy==2.4.1
|
numpy==2.4.1
|
||||||
matplotlib==3.10.8
|
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
@@ -282,10 +282,10 @@ class BatchCacheWorker(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class AngleAlignmentWorker(QObject):
|
class AngleAlignmentWorker(QObject):
|
||||||
"""Computes rotation+translation alignment for every angle in *sras*,
|
"""Computes the rigid (rotation + translation, never scale) alignment for
|
||||||
referenced to *ref_angle_idx*, from each angle's binarized CH4 mask.
|
every angle in *sras* against *ref_angle_idx*, by cross-correlating each
|
||||||
Rotation is analytic (from sras.angles_deg); only translation is found by
|
angle's CH4 image against the reference's. Both the rotation and the
|
||||||
phase correlation.
|
translation are found from image content — see compute_angle_alignment.
|
||||||
"""
|
"""
|
||||||
progress = pyqtSignal(int) # 0–100
|
progress = pyqtSignal(int) # 0–100
|
||||||
finished = pyqtSignal(object, str) # AlignmentResult|None, error ("" = success)
|
finished = pyqtSignal(object, str) # AlignmentResult|None, error ("" = success)
|
||||||
@@ -349,52 +349,53 @@ class Ch4MaskWorker(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class CrossCorrelateWorker(QObject):
|
class CrossCorrelateWorker(QObject):
|
||||||
"""FFT phase-correlation translation for each of *angle_indices* against
|
"""Rigid registration (rotation + translation, never scale) of each of
|
||||||
*ref_angle_idx*, for ManualAlignmentDialog's Auto Cross-Correlate button.
|
*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
|
Runs on a background thread — registering a real many-angle,
|
||||||
correlation (even at its downsampled working resolution) can take long
|
high-resolution scan takes long enough that doing it on the GUI thread
|
||||||
enough that doing all of them on the GUI thread would visibly freeze the
|
would visibly freeze the dialog. Rotation is *searched*, not taken from the
|
||||||
dialog. Rotation is set to the same analytic scan-angle delta Auto
|
stage's reported angle: see compute.register_angle_to_reference, which
|
||||||
De-rotate uses alongside the correlated shift, since a translation
|
seeds from that angle but scores both of its signs and refines from there.
|
||||||
search is only meaningful once both angles' content is already oriented
|
dc4_mv is the dialog's own already-in-memory per-angle CH4 image — this
|
||||||
the same way. dc4_mv/pivot_mm are the dialog's own already-in-memory
|
worker does no fetching of its own.
|
||||||
per-angle images/pivots — 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()
|
finished = pyqtSignal()
|
||||||
error = pyqtSignal(str)
|
error = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
|
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]],
|
dc4_mv: dict[int, np.ndarray], *,
|
||||||
*, use_mask: bool, dc_threshold_mv: float, margin_frac: float):
|
sources: tuple[str, ...], dc_threshold_mv: float,
|
||||||
|
search_deg: float):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._sras = sras
|
self._sras = sras
|
||||||
self._ref = ref_angle_idx
|
self._ref = ref_angle_idx
|
||||||
self._angles = angle_indices
|
self._angles = angle_indices
|
||||||
self._dc4_mv = dc4_mv
|
self._dc4_mv = dc4_mv
|
||||||
self._pivot_mm = pivot_mm
|
self._sources = sources
|
||||||
self._use_mask = use_mask
|
|
||||||
self._threshold = dc_threshold_mv
|
self._threshold = dc_threshold_mv
|
||||||
self._margin = margin_frac
|
self._search_deg = search_deg
|
||||||
|
|
||||||
def _one(self, a: int) -> tuple[int, float, float, float]:
|
def _one(self, a: int) -> tuple[int, compute.RigidFit]:
|
||||||
theta = compute._theta_deg(self._sras, a, self._ref)
|
return a, compute.register_angle_to_reference(
|
||||||
dx, dy = compute.correlate_translation_mm(
|
self._sras, a, self._ref, self._dc4_mv,
|
||||||
self._sras, a, self._ref, self._dc4_mv, self._pivot_mm,
|
dc_threshold_mv=self._threshold, sources=self._sources,
|
||||||
use_mask=self._use_mask, dc_threshold_mv=self._threshold,
|
search_deg=self._search_deg)
|
||||||
margin_frac=self._margin)
|
|
||||||
return a, theta, dx, dy
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
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))
|
pool = ThreadPoolExecutor(max_workers=max(1, n_workers))
|
||||||
try:
|
try:
|
||||||
futures = [pool.submit(self._one, a) for a in self._angles]
|
futures = [pool.submit(self._one, a) for a in self._angles]
|
||||||
for fut in as_completed(futures):
|
for fut in as_completed(futures):
|
||||||
a, theta, dx, dy = fut.result()
|
a, fit = fut.result()
|
||||||
self.angle_done.emit(a, theta, dx, dy)
|
self.angle_done.emit(a, fit.rotation_deg, fit.shift_mm[0],
|
||||||
|
fit.shift_mm[1], fit.score, fit.source)
|
||||||
finally:
|
finally:
|
||||||
pool.shutdown(wait=True)
|
pool.shutdown(wait=True)
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|||||||
@@ -125,6 +125,138 @@ def write(path: Path, n_angles: int = 3, seed: int = 0,
|
|||||||
return meta
|
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"
|
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))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
import sras_compute as compute # noqa: E402
|
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
|
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402
|
||||||
import tools.make_test_sras as gen # noqa: E402
|
import tools.make_test_sras as gen # noqa: E402
|
||||||
|
|
||||||
@@ -240,60 +240,49 @@ def main():
|
|||||||
print("\nmanual alignment (Fusion)")
|
print("\nmanual alignment (Fusion)")
|
||||||
check("manual alignment action enabled", win._manual_align_act.isEnabled())
|
check("manual alignment action enabled", win._manual_align_act.isEnabled())
|
||||||
|
|
||||||
# --- Alignment pivot is a signal-weighted centroid, not the raw bbox --
|
# --- Local mm is anchored on each angle's array center, not its stage --
|
||||||
# center, and is independent of any DC threshold (so a threshold that
|
# position: that is what makes a scan's placement independent of where its
|
||||||
# happens to leave a real angle's binary mask empty can't silently
|
# window happened to sit. (Registration accuracy itself is covered by
|
||||||
# degrade the pivot back to the bbox center).
|
# tools/test_alignment.py, which has a synthetic sample to register.)
|
||||||
corner_signal = np.zeros(s.image_shape(0), dtype=np.float32)
|
n_rows, n_frames = s.image_shape(0)
|
||||||
corner_signal[0, 0] = 1.0 # single spike -> weighted centroid is exact
|
check("array center is the geometric center of the pixel grid",
|
||||||
expected_corner = (float(s.x_axis_mm(0)[0]), float(s.y_positions_mm(0)[0]))
|
np.allclose(compute._center_idx(s, 0),
|
||||||
centroid = compute._signal_centroid_mm(s, 0, corner_signal)
|
[(n_rows - 1) / 2, (n_frames - 1) / 2]))
|
||||||
check("signal-weighted centroid of a single spike pixel is that pixel exactly",
|
dx0, dy0 = compute._pixel_pitch_mm(s, 0)
|
||||||
np.allclose(centroid, expected_corner), f"{centroid} vs {expected_corner}")
|
check("local half-extent is derived from shape and pitch alone",
|
||||||
bbox_center = compute._bbox_center_mm(s, 0)
|
np.allclose(compute._local_half_extent_mm(s, 0),
|
||||||
check("signal centroid differs from the raw scan-window bbox center",
|
[(n_frames - 1) / 2 * abs(dx0), (n_rows - 1) / 2 * abs(dy0)]))
|
||||||
not np.allclose(centroid, bbox_center),
|
identity = {a: compute.ManualAngleParams() for a in range(s.n_angles)}
|
||||||
f"centroid {centroid} vs bbox center {bbox_center}")
|
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
|
# --- Both signs of the stage's reported angle are searched --------------
|
||||||
# than recomputing from the real DC4 image (which has no such spike and
|
cands = compute._rotation_candidates(30.0, 6.0, 2.0)
|
||||||
# would give a different answer if silently recomputed).
|
check("rotation candidates bracket both signs of the stage angle",
|
||||||
reused_pivot = compute.compute_pivot_points_mm(s, dc4_mv={0: corner_signal})[0]
|
min(cands) < -29.0 and max(cands) > 29.0, f"{min(cands)}..{max(cands)}")
|
||||||
check("compute_pivot_points_mm reuses a pre-computed dc4_mv dict",
|
|
||||||
np.allclose(reused_pivot, expected_corner))
|
|
||||||
|
|
||||||
# A perfectly flat signal carries no information to weight by, so it
|
# --- Whole-pixel translation must not wrap content around the edge ------
|
||||||
# falls back to the bbox center rather than producing a NaN/degenerate
|
arr = np.zeros((6, 6), dtype=np.float32)
|
||||||
# centroid.
|
arr[0, 0] = 1.0
|
||||||
flat_signal = np.full(s.image_shape(0), 5.0, dtype=np.float32)
|
check("_shift_into zero-fills rather than wrapping",
|
||||||
flat_centroid = compute._signal_centroid_mm(s, 0, flat_signal)
|
compute._shift_into(arr, -1, -1).sum() == 0.0)
|
||||||
check("a perfectly flat signal falls back to the bbox center",
|
check("_shift_into moves content by exactly the requested offset",
|
||||||
np.allclose(flat_centroid, bbox_center))
|
compute._shift_into(arr, 2, 3)[2, 3] == 1.0)
|
||||||
|
|
||||||
# --- 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)}")
|
|
||||||
|
|
||||||
# --- Open: must NOT seed from the still-live automatic AlignmentResult --
|
# --- Open: must NOT seed from the still-live automatic AlignmentResult --
|
||||||
# The automatic result's translation comes from FFT phase correlation --
|
# Manual mode exists to fix up whatever the automatic registration got
|
||||||
# the very thing manual mode exists to work around -- so manual mode
|
# wrong, so it must start from identity (every angle centered on the
|
||||||
# must start from identity (centroids coincide, zero shift) regardless
|
# reference, no rotation) regardless of whatever the automatic run last
|
||||||
# of whatever the automatic run last computed. Only a previously *saved
|
# computed. Only a previously *saved manual* alignment (sidecar) should
|
||||||
# manual* alignment (sidecar) should ever seed this dialog.
|
# ever seed this dialog.
|
||||||
win._on_manual_alignment()
|
win._on_manual_alignment()
|
||||||
check("dialog opened", win._manual_align_dialog is not None)
|
check("dialog opened", win._manual_align_dialog is not None)
|
||||||
dlg = win._manual_align_dialog
|
dlg = win._manual_align_dialog
|
||||||
@@ -343,39 +332,41 @@ def main():
|
|||||||
check("a real Right-arrow key event nudged shift_x",
|
check("a real Right-arrow key event nudged shift_x",
|
||||||
dlg._angle_params[active].shift_mm[0] > before[0])
|
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
|
shift_before_derotate = dlg._angle_params[active].shift_mm
|
||||||
dlg._on_auto_derotate()
|
dlg._on_auto_derotate()
|
||||||
expected_theta = compute._theta_deg(s, active, dlg._ref_angle_idx)
|
nominal = compute._nominal_delta_deg(s, active, dlg._ref_angle_idx)
|
||||||
check("auto de-rotate set the known analytic angle",
|
check("auto de-rotate seeded rotation from the stage's reported angle",
|
||||||
abs(dlg._angle_params[active].rotation_deg - expected_theta) < 1e-6)
|
abs(dlg._angle_params[active].rotation_deg - nominal) < 1e-6)
|
||||||
check("auto de-rotate left translation untouched",
|
check("auto de-rotate left translation untouched",
|
||||||
dlg._angle_params[active].shift_mm == shift_before_derotate)
|
dlg._angle_params[active].shift_mm == shift_before_derotate)
|
||||||
check("reference angle stays identity after auto de-rotate",
|
check("reference angle stays identity after auto de-rotate",
|
||||||
dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0)
|
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",
|
check("cross-correlate action enabled once masks are ready",
|
||||||
dlg.btn_auto_correlate.isEnabled())
|
dlg.btn_auto_correlate.isEnabled())
|
||||||
dlg._on_auto_correlate()
|
for label_idx, (label, _sources) in enumerate(dlg._CORRELATE_SOURCES):
|
||||||
check("auto cross-correlate completed", wait_until(
|
dlg.combo_correlate_source.setCurrentIndex(label_idx)
|
||||||
lambda: not win._job_running("manual_align_correlate"), timeout_ms=30000))
|
dlg._on_auto_correlate()
|
||||||
check("auto cross-correlate set the known analytic angle for every angle",
|
check(f"auto cross-correlate completed ({label})", wait_until(
|
||||||
all(abs(dlg._angle_params[a].rotation_deg
|
lambda: not win._job_running("manual_align_correlate"), timeout_ms=60000))
|
||||||
- compute._theta_deg(s, a, dlg._ref_angle_idx)) < 1e-6
|
check(f"every non-reference angle got a fit ({label})",
|
||||||
for a in range(s.n_angles) if a != dlg._ref_angle_idx))
|
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",
|
check("auto cross-correlate reference angle stays identity",
|
||||||
dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams())
|
dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams())
|
||||||
check("auto cross-correlate re-enabled controls when done",
|
check("auto cross-correlate re-enabled controls when done",
|
||||||
dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled())
|
dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled())
|
||||||
check("preview canvas rebuilt after cross-correlate",
|
check("preview canvas rebuilt after cross-correlate",
|
||||||
len(dlg._preview_layers) == s.n_angles)
|
len(dlg._preview_layers) == s.n_angles)
|
||||||
|
check("fit quality is reported per angle", bool(dlg._fit_report()),
|
||||||
# The thresholded-mask option should also work end to end.
|
dlg._fit_report())
|
||||||
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))
|
|
||||||
|
|
||||||
# --- Save -----------------------------------------------------------------
|
# --- Save -----------------------------------------------------------------
|
||||||
dlg._on_save()
|
dlg._on_save()
|
||||||
|
|||||||
Reference in New Issue
Block a user