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])
|
return -float(sras.angles_deg[angle_idx] - sras.angles_deg[ref_idx])
|
||||||
|
|
||||||
|
|
||||||
def _mask_centroid_mm(sras: SrasFile, angle_idx: int,
|
def _signal_centroid_mm(sras: SrasFile, angle_idx: int,
|
||||||
mask: np.ndarray) -> tuple[float, float]:
|
dc4_mv: np.ndarray) -> tuple[float, float]:
|
||||||
"""Centroid (mean x, mean y) of *mask*'s True pixels, in angle_idx's own
|
"""Intensity-weighted centroid (mean x, mean y, weighted by CH4 signal
|
||||||
local mm frame — the alignment pivot used in place of the raw scan-
|
after subtracting this angle's own minimum) in angle_idx's own local mm
|
||||||
window bbox center (see compute_pivot_points_mm). Falls back to the bbox
|
frame — the alignment pivot used in place of the raw scan-window bbox
|
||||||
center if the mask has no pixels above threshold, since a centroid of
|
center (see compute_pivot_points_mm).
|
||||||
nothing is undefined."""
|
|
||||||
if not mask.any():
|
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)
|
return _bbox_center_mm(sras, angle_idx)
|
||||||
x = sras.x_axis_mm(angle_idx)
|
x = sras.x_axis_mm(angle_idx)
|
||||||
y = sras.y_positions_mm(angle_idx)
|
y = sras.y_positions_mm(angle_idx)
|
||||||
rows, cols = np.nonzero(mask)
|
cx = float((x * weights.sum(axis=0)).sum() / total)
|
||||||
return float(x[cols].mean()), float(y[rows].mean())
|
cy = float((y * weights.sum(axis=1)).sum() / total)
|
||||||
|
return cx, cy
|
||||||
|
|
||||||
|
|
||||||
def compute_pivot_points_mm(sras: SrasFile, dc_threshold_mv: float,
|
def compute_pivot_points_mm(sras: SrasFile,
|
||||||
masks: dict[int, np.ndarray] | None = None
|
dc4_mv: dict[int, np.ndarray] | None = None
|
||||||
) -> dict[int, tuple[float, float]]:
|
) -> dict[int, tuple[float, float]]:
|
||||||
"""Per-angle alignment pivot, in each angle's own local mm frame: the
|
"""Per-angle alignment pivot, in each angle's own local mm frame: the
|
||||||
centroid of its own CH4-threshold mask (its own sample footprint),
|
CH4-signal-weighted centroid of its own footprint (see
|
||||||
rather than the raw scan-window bbox center.
|
_signal_centroid_mm), rather than the raw scan-window bbox center.
|
||||||
|
|
||||||
Pivoting on each angle's own content — instead of on wherever its
|
Pivoting on each angle's own content — instead of on wherever its
|
||||||
scanned window happened to sit in microscope/global XY space — is what
|
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
|
leaves a residual orbital motion between angles that a content-centroid
|
||||||
pivot does not.
|
pivot does not.
|
||||||
|
|
||||||
*masks* lets a caller that has already computed CH4 masks at this
|
Deliberately independent of dc_threshold_mv (the RF-mask / overlay
|
||||||
threshold (compute_angle_alignment's Step 1) reuse them instead of
|
threshold): that value is a display/masking choice and must never
|
||||||
recomputing the DC image; angles missing from it are computed fresh via
|
silently change where alignment pivots.
|
||||||
dc_image_mv, which prefers a stored v5/v7 cache over recomputing from
|
|
||||||
raw waveforms.
|
*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]] = {}
|
pivots: dict[int, tuple[float, float]] = {}
|
||||||
for a in range(sras.n_angles):
|
for a in range(sras.n_angles):
|
||||||
mask = masks.get(a)
|
img = dc4_mv.get(a)
|
||||||
if mask is None:
|
if img is None:
|
||||||
mask = dc_image_mv(sras, a, CH4_IDX) >= dc_threshold_mv
|
img = dc_image_mv(sras, a, CH4_IDX)
|
||||||
else:
|
pivots[a] = _signal_centroid_mm(sras, a, img)
|
||||||
mask = mask > 0 # compute_angle_alignment's masks are float32 0.0/1.0
|
|
||||||
pivots[a] = _mask_centroid_mm(sras, a, mask)
|
|
||||||
return pivots
|
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*
|
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-
|
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
|
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
|
(pivot_mm — see compute_pivot_points_mm; the CH4-signal-weighted
|
||||||
CH4-threshold mask, not the raw scan-window bbox center — this keeps
|
centroid of its own footprint, not the raw scan-window bbox center —
|
||||||
the sample itself centered post-rotation, minimizing required canvas
|
this keeps the sample itself centered post-rotation, minimizing
|
||||||
padding and, more importantly, keeping alignment relative to the sample
|
required canvas padding and, more importantly, keeping alignment
|
||||||
rather than to wherever the scan window sat in microscope/global XY
|
relative to the sample rather than to wherever the scan window sat in
|
||||||
space), and A_out/D are the index<->mm scaling matrices for the canvas
|
microscope/global XY space), and A_out/D are the index<->mm scaling
|
||||||
pitch and this angle's own native pitch respectively.
|
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
|
theta = _theta_deg(sras, angle_idx, ref_idx) if theta_deg is None else theta_deg
|
||||||
Rinv = _rotation_matrix(theta).T
|
Rinv = _rotation_matrix(theta).T
|
||||||
@@ -671,21 +688,25 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
|
|||||||
_ticks.append(1)
|
_ticks.append(1)
|
||||||
progress_cb(base + int(min(len(_ticks), n) / n * span))
|
progress_cb(base + int(min(len(_ticks), n) / n * span))
|
||||||
|
|
||||||
# ---- Step 1: binarized CH4 mask per angle, native per-angle grid -----
|
# ---- Step 1: CH4 DC image + binarized mask per angle, native grid -----
|
||||||
def mask_for(a: int) -> np.ndarray:
|
def dc4_for(a: int) -> np.ndarray:
|
||||||
dc4 = adc_to_mv(
|
dc4 = adc_to_mv(
|
||||||
compute_dc_image(sras, a, CH4_IDX, max_workers=1, budget=angle_budget),
|
compute_dc_image(sras, a, CH4_IDX, max_workers=1, budget=angle_budget),
|
||||||
*sras.cal(CH4_IDX))
|
*sras.cal(CH4_IDX))
|
||||||
m = (dc4 >= dc_threshold_mv).astype(np.float32)
|
|
||||||
tick(0, 25)
|
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
|
# Each angle's own alignment pivot — the CH4-signal-weighted centroid,
|
||||||
# the raw scan-window bbox center (see compute_pivot_points_mm) — reuses
|
# not the raw scan-window bbox center (see compute_pivot_points_mm).
|
||||||
# the masks just computed above, so this is free (no extra DC compute).
|
# Reuses the dc4_mv images just computed above, so this is free, and is
|
||||||
pivot_mm = compute_pivot_points_mm(sras, dc_threshold_mv, masks=masks)
|
# 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)
|
# ---- Step 2: coarse correlation stage (downsample first, then rotate)
|
||||||
# Downsampling before affine_transform (not after) is what keeps this
|
# 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.
|
never be transformed.
|
||||||
|
|
||||||
Pass an already-computed *pivot_mm* (e.g. ManualAlignmentDialog's own
|
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
|
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
|
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).
|
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
|
n = sras.n_angles
|
||||||
dx_ref, dy_ref = _pixel_pitch_mm(sras, ref_angle_idx)
|
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 = {a: per_angle_params.get(a, ManualAngleParams()) for a in range(n)}
|
||||||
params[ref_angle_idx] = ManualAngleParams()
|
params[ref_angle_idx] = ManualAngleParams()
|
||||||
if pivot_mm is None:
|
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(
|
canvas_origin_mm, canvas_shape = union_canvas_mm(
|
||||||
sras, ref_angle_idx, dx_ref, dy_ref, params, pivot_mm, margin_frac=0.0)
|
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")
|
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,
|
def save_manual_alignment(sras: SrasFile, ref_angle_idx: int,
|
||||||
dc_threshold_mv: float,
|
dc_threshold_mv: float,
|
||||||
per_angle: dict[int, ManualAngleParams]) -> Path:
|
per_angle: dict[int, ManualAngleParams]) -> Path:
|
||||||
"""Write the sidecar JSON for sras.path (overwriting any existing one)
|
"""Write the sidecar JSON for sras.path (overwriting any existing one)
|
||||||
and return the path written.
|
and return the path written.
|
||||||
|
|
||||||
Schema (schema_version 1):
|
Schema (schema_version 2):
|
||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"ref_angle_idx": <int>,
|
"ref_angle_idx": <int>,
|
||||||
"dc_threshold_mv": <float>,
|
"dc_threshold_mv": <float>,
|
||||||
"per_angle": {
|
"per_angle": {
|
||||||
@@ -925,7 +960,7 @@ def save_manual_alignment(sras: SrasFile, ref_angle_idx: int,
|
|||||||
"""
|
"""
|
||||||
path = sidecar_path(sras.path)
|
path = sidecar_path(sras.path)
|
||||||
payload = {
|
payload = {
|
||||||
"schema_version": 1,
|
"schema_version": _SIDECAR_SCHEMA_VERSION,
|
||||||
"ref_angle_idx": ref_angle_idx,
|
"ref_angle_idx": ref_angle_idx,
|
||||||
"dc_threshold_mv": dc_threshold_mv,
|
"dc_threshold_mv": dc_threshold_mv,
|
||||||
"per_angle": {
|
"per_angle": {
|
||||||
@@ -949,7 +984,7 @@ def load_manual_alignment(sras: SrasFile) -> ManualAlignmentSidecar | None:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text())
|
raw = json.loads(path.read_text())
|
||||||
if raw.get("schema_version") != 1:
|
if raw.get("schema_version") != _SIDECAR_SCHEMA_VERSION:
|
||||||
return None
|
return None
|
||||||
per_angle = {
|
per_angle = {
|
||||||
int(a): ManualAngleParams(
|
int(a): ManualAngleParams(
|
||||||
|
|||||||
+9
-16
@@ -1064,7 +1064,12 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
max_dim = max(max(img.shape) for img in self._dc4_mv.values())
|
max_dim = max(max(img.shape) for img in self._dc4_mv.values())
|
||||||
self._downsample_factor = max(1, int(np.ceil(max_dim / self._MAX_PREVIEW_DIM)))
|
self._downsample_factor = max(1, int(np.ceil(max_dim / self._MAX_PREVIEW_DIM)))
|
||||||
self._recompute_masks_small()
|
self._recompute_masks_small()
|
||||||
self._recompute_pivot_mm()
|
# 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.")
|
||||||
@@ -1072,7 +1077,9 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
def _recompute_masks_small(self):
|
def _recompute_masks_small(self):
|
||||||
"""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
|
||||||
|
depend on this threshold (see _pivot_mm / compute_pivot_points_mm)."""
|
||||||
threshold = self.spin_mask_threshold_mv.value()
|
threshold = self.spin_mask_threshold_mv.value()
|
||||||
factor = self._downsample_factor
|
factor = self._downsample_factor
|
||||||
self._masks_small = {
|
self._masks_small = {
|
||||||
@@ -1081,19 +1088,6 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
for a, img in self._dc4_mv.items()
|
for a, img in self._dc4_mv.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
def _recompute_pivot_mm(self):
|
|
||||||
"""Each angle's alignment pivot: the centroid of its own full-
|
|
||||||
resolution CH4-threshold mask (its own sample footprint), not the
|
|
||||||
raw scan-window bbox center — see compute.compute_pivot_points_mm.
|
|
||||||
Recomputed alongside _recompute_masks_small whenever the mask
|
|
||||||
threshold changes, from the full-res masks (not the downsampled
|
|
||||||
preview ones) since this feeds the canonical geometry, not just the
|
|
||||||
preview."""
|
|
||||||
threshold = self.spin_mask_threshold_mv.value()
|
|
||||||
masks = {a: img >= threshold for a, img in self._dc4_mv.items()}
|
|
||||||
self._pivot_mm = compute.compute_pivot_points_mm(
|
|
||||||
self._sras, threshold, masks=masks)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Preview canvas: full rebuild vs. incremental single-layer refresh
|
# Preview canvas: full rebuild vs. incremental single-layer refresh
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1227,7 +1221,6 @@ class ManualAlignmentDialog(QDialog):
|
|||||||
if not self._masks_ready:
|
if not self._masks_ready:
|
||||||
return
|
return
|
||||||
self._recompute_masks_small()
|
self._recompute_masks_small()
|
||||||
self._recompute_pivot_mm()
|
|
||||||
self._rebuild_preview_canvas()
|
self._rebuild_preview_canvas()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
+32
-14
@@ -240,27 +240,36 @@ 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 the mask centroid, not the raw bbox center -----
|
# --- Alignment pivot is a signal-weighted centroid, not the raw bbox --
|
||||||
corner_mask = np.zeros(s.image_shape(0), dtype=bool)
|
# center, and is independent of any DC threshold (so a threshold that
|
||||||
corner_mask[0, 0] = True
|
# 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]))
|
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)
|
centroid = compute._signal_centroid_mm(s, 0, corner_signal)
|
||||||
check("mask centroid of a single corner pixel is that corner exactly",
|
check("signal-weighted centroid of a single spike pixel is that pixel exactly",
|
||||||
np.allclose(centroid, expected_corner), f"{centroid} vs {expected_corner}")
|
np.allclose(centroid, expected_corner), f"{centroid} vs {expected_corner}")
|
||||||
bbox_center = compute._bbox_center_mm(s, 0)
|
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),
|
not np.allclose(centroid, bbox_center),
|
||||||
f"centroid {centroid} vs bbox center {bbox_center}")
|
f"centroid {centroid} vs bbox center {bbox_center}")
|
||||||
|
|
||||||
# A threshold that would yield an *empty* mask if recomputed from scratch
|
# compute_pivot_points_mm should reuse a pre-computed dc4_mv dict rather
|
||||||
# (real DC values never reach 999 mV) — so this only matches expected_corner
|
# than recomputing from the real DC4 image (which has no such spike and
|
||||||
# if compute_pivot_points_mm actually reused the pre-computed masks dict
|
# would give a different answer if silently recomputed).
|
||||||
# instead of silently recomputing (and falling back to the bbox center).
|
reused_pivot = compute.compute_pivot_points_mm(s, dc4_mv={0: corner_signal})[0]
|
||||||
reused_pivot = compute.compute_pivot_points_mm(
|
check("compute_pivot_points_mm reuses a pre-computed dc4_mv dict",
|
||||||
s, 999.0, masks={0: corner_mask.astype(np.float32)})[0]
|
|
||||||
check("compute_pivot_points_mm reuses a pre-computed masks dict",
|
|
||||||
np.allclose(reused_pivot, expected_corner))
|
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 ----
|
# --- Rotation sign convention: negative of the raw angles_deg delta ----
|
||||||
check("_theta_deg negates the raw angles_deg delta (GR stage's positive "
|
check("_theta_deg negates the raw angles_deg delta (GR stage's positive "
|
||||||
"angle is the opposite rotational sense from this module's CCW "
|
"angle is the opposite rotational sense from this module's CCW "
|
||||||
@@ -339,7 +348,8 @@ def main():
|
|||||||
sidecar = compute.sidecar_path(s.path)
|
sidecar = compute.sidecar_path(s.path)
|
||||||
check("sidecar file written", sidecar.exists())
|
check("sidecar file written", sidecar.exists())
|
||||||
raw = json.loads(sidecar.read_text()) if sidecar.exists() else {}
|
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",
|
check("sidecar per_angle round-trips the dialog's resolved params",
|
||||||
all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
|
all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
|
||||||
== dlg._angle_params[a].rotation_deg for a in range(s.n_angles)))
|
== 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",
|
check("Aligned View auto-enabled after Save",
|
||||||
win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked())
|
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) --------------------------------------------
|
# --- Clear (with confirmation) --------------------------------------------
|
||||||
with patch("sras_viewer.QMessageBox.question",
|
with patch("sras_viewer.QMessageBox.question",
|
||||||
return_value=QMessageBox.StandardButton.Yes):
|
return_value=QMessageBox.StandardButton.Yes):
|
||||||
|
|||||||
Reference in New Issue
Block a user