Make alignment pivot independent of the DC mask threshold

The pivot was still derived from the binary CH4 >= dc_threshold_mv mask,
so a threshold that happens to leave a real angle's mask empty (signal
levels vary scan to scan across a many-angle acquisition) silently fell
back to the raw scan-window bbox center — reproducing the exact "aligned
to the scan window, not the sample" scatter the centroid pivot exists to
fix, without any visible error.

Replace the binary-mask centroid with an intensity-weighted centroid of
the continuous CH4 signal, which is always well-defined regardless of the
RF-mask threshold in effect. The threshold still controls what the manual
dialog's overlay and CH1 masking show; it no longer has any bearing on
where alignment pivots.

Also bump the sidecar schema version: a file saved before today's pivot
and rotation-sign fixes stores numbers for a since-corrected transform, so
loading it unchanged would reintroduce the same scatter. Old sidecars are
now treated as absent rather than silently reused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-31 09:58:15 -05:00
parent ca0c736c28
commit 8312668c02
3 changed files with 124 additions and 78 deletions
+83 -48
View File
@@ -455,27 +455,41 @@ def _theta_deg(sras: SrasFile, angle_idx: int, ref_idx: int) -> float:
return -float(sras.angles_deg[angle_idx] - sras.angles_deg[ref_idx])
def _mask_centroid_mm(sras: SrasFile, angle_idx: int,
mask: np.ndarray) -> tuple[float, float]:
"""Centroid (mean x, mean y) of *mask*'s True pixels, in angle_idx's own
local mm frame — the alignment pivot used in place of the raw scan-
window bbox center (see compute_pivot_points_mm). Falls back to the bbox
center if the mask has no pixels above threshold, since a centroid of
nothing is undefined."""
if not mask.any():
def _signal_centroid_mm(sras: SrasFile, angle_idx: int,
dc4_mv: np.ndarray) -> tuple[float, float]:
"""Intensity-weighted centroid (mean x, mean y, weighted by CH4 signal
after subtracting this angle's own minimum) in angle_idx's own local mm
frame — the alignment pivot used in place of the raw scan-window bbox
center (see compute_pivot_points_mm).
Weighting by the continuous DC signal, rather than a binary >=
dc_threshold_mv mask, means the pivot never depends on how well one
shared threshold happens to suit this particular angle: real signal
levels vary scan to scan, so a threshold tuned for one angle can leave
another angle's binary mask empty — and a centroid of an empty mask has
nothing to fall back to *except* the raw bbox center, silently
reproducing the exact "aligned to the scan window, not the sample"
problem this pivot exists to avoid. Falls back to the bbox center only
in the fully-degenerate case of a perfectly flat signal (nothing to
weight by at all).
"""
weights = dc4_mv - dc4_mv.min()
total = float(weights.sum())
if total <= 0.0:
return _bbox_center_mm(sras, angle_idx)
x = sras.x_axis_mm(angle_idx)
y = sras.y_positions_mm(angle_idx)
rows, cols = np.nonzero(mask)
return float(x[cols].mean()), float(y[rows].mean())
cx = float((x * weights.sum(axis=0)).sum() / total)
cy = float((y * weights.sum(axis=1)).sum() / total)
return cx, cy
def compute_pivot_points_mm(sras: SrasFile, dc_threshold_mv: float,
masks: dict[int, np.ndarray] | None = None
def compute_pivot_points_mm(sras: SrasFile,
dc4_mv: dict[int, np.ndarray] | None = None
) -> dict[int, tuple[float, float]]:
"""Per-angle alignment pivot, in each angle's own local mm frame: the
centroid of its own CH4-threshold mask (its own sample footprint),
rather than the raw scan-window bbox center.
CH4-signal-weighted centroid of its own footprint (see
_signal_centroid_mm), rather than the raw scan-window bbox center.
Pivoting on each angle's own content — instead of on wherever its
scanned window happened to sit in microscope/global XY space — is what
@@ -486,21 +500,23 @@ def compute_pivot_points_mm(sras: SrasFile, dc_threshold_mv: float,
leaves a residual orbital motion between angles that a content-centroid
pivot does not.
*masks* lets a caller that has already computed CH4 masks at this
threshold (compute_angle_alignment's Step 1) reuse them instead of
recomputing the DC image; angles missing from it are computed fresh via
dc_image_mv, which prefers a stored v5/v7 cache over recomputing from
raw waveforms.
Deliberately independent of dc_threshold_mv (the RF-mask / overlay
threshold): that value is a display/masking choice and must never
silently change where alignment pivots.
*dc4_mv* lets a caller that has already computed each angle's CH4 mV
image (compute_angle_alignment's Step 1, or ManualAlignmentDialog's own
cache) reuse it instead of recomputing; angles missing from it are
computed fresh via dc_image_mv, which prefers a stored v5/v7 cache over
recomputing from raw waveforms.
"""
masks = masks or {}
dc4_mv = dc4_mv or {}
pivots: dict[int, tuple[float, float]] = {}
for a in range(sras.n_angles):
mask = masks.get(a)
if mask is None:
mask = dc_image_mv(sras, a, CH4_IDX) >= dc_threshold_mv
else:
mask = mask > 0 # compute_angle_alignment's masks are float32 0.0/1.0
pivots[a] = _mask_centroid_mm(sras, a, mask)
img = dc4_mv.get(a)
if img is None:
img = dc_image_mv(sras, a, CH4_IDX)
pivots[a] = _signal_centroid_mm(sras, a, img)
return pivots
@@ -544,13 +560,14 @@ def _build_affine_canvas_to_raw(sras: SrasFile, angle_idx: int, ref_idx: int,
where theta is _theta_deg(angle_idx, ref_idx) unless *theta_deg*
overrides it (see _corners_in_ref_frame's note, used by the manual-
alignment path), c_ref/c_a are each angle's own alignment pivot
(pivot_mm — see compute_pivot_points_mm; the centroid of its own
CH4-threshold mask, not the raw scan-window bbox center — this keeps
the sample itself centered post-rotation, minimizing required canvas
padding and, more importantly, keeping alignment relative to the sample
rather than to wherever the scan window sat in microscope/global XY
space), and A_out/D are the index<->mm scaling matrices for the canvas
pitch and this angle's own native pitch respectively.
(pivot_mm — see compute_pivot_points_mm; the CH4-signal-weighted
centroid of its own footprint, not the raw scan-window bbox center —
this keeps the sample itself centered post-rotation, minimizing
required canvas padding and, more importantly, keeping alignment
relative to the sample rather than to wherever the scan window sat in
microscope/global XY space), and A_out/D are the index<->mm scaling
matrices for the canvas pitch and this angle's own native pitch
respectively.
"""
theta = _theta_deg(sras, angle_idx, ref_idx) if theta_deg is None else theta_deg
Rinv = _rotation_matrix(theta).T
@@ -671,21 +688,25 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
_ticks.append(1)
progress_cb(base + int(min(len(_ticks), n) / n * span))
# ---- Step 1: binarized CH4 mask per angle, native per-angle grid -----
def mask_for(a: int) -> np.ndarray:
# ---- Step 1: CH4 DC image + binarized mask per angle, native grid -----
def dc4_for(a: int) -> np.ndarray:
dc4 = adc_to_mv(
compute_dc_image(sras, a, CH4_IDX, max_workers=1, budget=angle_budget),
*sras.cal(CH4_IDX))
m = (dc4 >= dc_threshold_mv).astype(np.float32)
tick(0, 25)
return m
return dc4
masks = dict(enumerate(_parallel_map(mask_for, range(n), n_workers)))
dc4_mv = dict(enumerate(_parallel_map(dc4_for, range(n), n_workers)))
masks = {a: (img >= dc_threshold_mv).astype(np.float32)
for a, img in dc4_mv.items()}
# Each angle's own alignment pivot — the centroid of its own mask, not
# the raw scan-window bbox center (see compute_pivot_points_mm) — reuses
# the masks just computed above, so this is free (no extra DC compute).
pivot_mm = compute_pivot_points_mm(sras, dc_threshold_mv, masks=masks)
# Each angle's own alignment pivot — the CH4-signal-weighted centroid,
# not the raw scan-window bbox center (see compute_pivot_points_mm).
# Reuses the dc4_mv images just computed above, so this is free, and is
# deliberately independent of dc_threshold_mv (see that function's
# docstring) so a threshold that happens to leave some angle's binary
# mask empty can't silently degrade the pivot back to the bbox center.
pivot_mm = compute_pivot_points_mm(sras, dc4_mv=dc4_mv)
# ---- Step 2: coarse correlation stage (downsample first, then rotate)
# Downsampling before affine_transform (not after) is what keeps this
@@ -852,17 +873,20 @@ def build_manual_alignment(sras: SrasFile, ref_angle_idx: int,
never be transformed.
Pass an already-computed *pivot_mm* (e.g. ManualAlignmentDialog's own
cache, built once from its live CH4 masks) to skip recomputing every
cache, built once from its live CH4 images) to skip recomputing every
angle's DC image here; otherwise it's computed fresh via
compute_pivot_points_mm, which is still fine for a one-off Save or
sidecar restore (just not free on a very large, not-yet-cached scan).
dc_threshold_mv itself plays no part in the pivot (see that function's
docstring) — it's stored on the returned AlignmentResult purely as a
record of the RF-mask threshold in effect at the time.
"""
n = sras.n_angles
dx_ref, dy_ref = _pixel_pitch_mm(sras, ref_angle_idx)
params = {a: per_angle_params.get(a, ManualAngleParams()) for a in range(n)}
params[ref_angle_idx] = ManualAngleParams()
if pivot_mm is None:
pivot_mm = compute_pivot_points_mm(sras, dc_threshold_mv)
pivot_mm = compute_pivot_points_mm(sras)
canvas_origin_mm, canvas_shape = union_canvas_mm(
sras, ref_angle_idx, dx_ref, dy_ref, params, pivot_mm, margin_frac=0.0)
@@ -904,15 +928,26 @@ def sidecar_path(sras_path) -> Path:
return p.with_name(p.name + ".align.json")
# Bumped from 1 -> 2 when the rotation pivot changed from the raw scan-
# window bbox center to a content-derived centroid, *and* _theta_deg's sign
# convention was corrected — either change alone makes a version-1 file's
# stored rotation_deg/shift_mm numbers describe a different (and, for the
# pivot bug, actively wrong) transform than they would today. Loading one
# unchanged would silently reproduce exactly the "scans show up everywhere"
# symptom these fixes address, so version-1 sidecars are treated as absent
# rather than migrated.
_SIDECAR_SCHEMA_VERSION = 2
def save_manual_alignment(sras: SrasFile, ref_angle_idx: int,
dc_threshold_mv: float,
per_angle: dict[int, ManualAngleParams]) -> Path:
"""Write the sidecar JSON for sras.path (overwriting any existing one)
and return the path written.
Schema (schema_version 1):
Schema (schema_version 2):
{
"schema_version": 1,
"schema_version": 2,
"ref_angle_idx": <int>,
"dc_threshold_mv": <float>,
"per_angle": {
@@ -925,7 +960,7 @@ def save_manual_alignment(sras: SrasFile, ref_angle_idx: int,
"""
path = sidecar_path(sras.path)
payload = {
"schema_version": 1,
"schema_version": _SIDECAR_SCHEMA_VERSION,
"ref_angle_idx": ref_angle_idx,
"dc_threshold_mv": dc_threshold_mv,
"per_angle": {
@@ -949,7 +984,7 @@ def load_manual_alignment(sras: SrasFile) -> ManualAlignmentSidecar | None:
return None
try:
raw = json.loads(path.read_text())
if raw.get("schema_version") != 1:
if raw.get("schema_version") != _SIDECAR_SCHEMA_VERSION:
return None
per_angle = {
int(a): ManualAngleParams(