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:
+32
-14
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user