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
+32 -14
View File
@@ -240,27 +240,36 @@ def main():
print("\nmanual alignment (Fusion)")
check("manual alignment action enabled", win._manual_align_act.isEnabled())
# --- Alignment pivot is the mask centroid, not the raw bbox center -----
corner_mask = np.zeros(s.image_shape(0), dtype=bool)
corner_mask[0, 0] = True
# --- Alignment pivot is a signal-weighted centroid, not the raw bbox --
# center, and is independent of any DC threshold (so a threshold that
# happens to leave a real angle's binary mask empty can't silently
# degrade the pivot back to the bbox center).
corner_signal = np.zeros(s.image_shape(0), dtype=np.float32)
corner_signal[0, 0] = 1.0 # single spike -> weighted centroid is exact
expected_corner = (float(s.x_axis_mm(0)[0]), float(s.y_positions_mm(0)[0]))
centroid = compute._mask_centroid_mm(s, 0, corner_mask)
check("mask centroid of a single corner pixel is that corner exactly",
centroid = compute._signal_centroid_mm(s, 0, corner_signal)
check("signal-weighted centroid of a single spike pixel is that pixel exactly",
np.allclose(centroid, expected_corner), f"{centroid} vs {expected_corner}")
bbox_center = compute._bbox_center_mm(s, 0)
check("mask centroid differs from the raw scan-window bbox center",
check("signal centroid differs from the raw scan-window bbox center",
not np.allclose(centroid, bbox_center),
f"centroid {centroid} vs bbox center {bbox_center}")
# A threshold that would yield an *empty* mask if recomputed from scratch
# (real DC values never reach 999 mV) — so this only matches expected_corner
# if compute_pivot_points_mm actually reused the pre-computed masks dict
# instead of silently recomputing (and falling back to the bbox center).
reused_pivot = compute.compute_pivot_points_mm(
s, 999.0, masks={0: corner_mask.astype(np.float32)})[0]
check("compute_pivot_points_mm reuses a pre-computed masks dict",
# compute_pivot_points_mm should reuse a pre-computed dc4_mv dict rather
# than recomputing from the real DC4 image (which has no such spike and
# would give a different answer if silently recomputed).
reused_pivot = compute.compute_pivot_points_mm(s, dc4_mv={0: corner_signal})[0]
check("compute_pivot_points_mm reuses a pre-computed dc4_mv dict",
np.allclose(reused_pivot, expected_corner))
# A perfectly flat signal carries no information to weight by, so it
# falls back to the bbox center rather than producing a NaN/degenerate
# centroid.
flat_signal = np.full(s.image_shape(0), 5.0, dtype=np.float32)
flat_centroid = compute._signal_centroid_mm(s, 0, flat_signal)
check("a perfectly flat signal falls back to the bbox center",
np.allclose(flat_centroid, bbox_center))
# --- Rotation sign convention: negative of the raw angles_deg delta ----
check("_theta_deg negates the raw angles_deg delta (GR stage's positive "
"angle is the opposite rotational sense from this module's CCW "
@@ -339,7 +348,8 @@ def main():
sidecar = compute.sidecar_path(s.path)
check("sidecar file written", sidecar.exists())
raw = json.loads(sidecar.read_text()) if sidecar.exists() else {}
check("sidecar schema_version is 1", raw.get("schema_version") == 1)
check("sidecar schema_version is current",
raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION)
check("sidecar per_angle round-trips the dialog's resolved params",
all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
== dlg._angle_params[a].rotation_deg for a in range(s.n_angles)))
@@ -350,6 +360,14 @@ def main():
check("Aligned View auto-enabled after Save",
win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked())
# --- An old-schema sidecar (pre-pivot/sign fix) is treated as absent ------
stale = dict(raw)
stale["schema_version"] = compute._SIDECAR_SCHEMA_VERSION - 1
sidecar.write_text(json.dumps(stale))
check("a sidecar with an old schema_version is not loaded",
compute.load_manual_alignment(s) is None)
sidecar.write_text(json.dumps(raw)) # restore for the rest of this section
# --- Clear (with confirmation) --------------------------------------------
with patch("sras_viewer.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes):