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:
+83
-48
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user