Add manual angle alignment mode with overlay, keyboard nudge, and save/clear

Automatic alignment's phase-correlation translation search is unreliable, so
add a Fusion -> Manual Alignment dialog: every angle's CH4 threshold mask
overlaid at once with distinct colors/opacity, a selector for the active
angle, arrow keys to nudge translation and Q/E to nudge rotation, an Auto
De-rotate button that snaps to the known scan angle, and Save/Clear
controls backed by a JSON sidecar that's restored automatically on reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-31 09:19:17 -05:00
parent 013c7739a1
commit 3c835f4592
4 changed files with 1092 additions and 20 deletions
+255 -10
View File
@@ -6,9 +6,11 @@ multiprocessing child can import it without loading Qt or matplotlib —
which matters because Python 3.14 on macOS spawns rather than forks.
"""
import json
import os
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import scipy.fft as scipy_fft
@@ -397,6 +399,21 @@ class AlignmentResult:
per_angle: dict[int, AngleTransform]
@dataclass
class ManualAngleParams:
"""One angle's manual-alignment state, independent of any canvas.
rotation_deg/shift_mm are exactly AngleTransform's non-derived fields —
the pair a canvas-bound AngleTransform's matrix/offset get built from
once a canvas is decided (build_manual_alignment). Defaults to identity
(no rotation, no shift): a fresh angle with no prior alignment is shown
raw, exactly as scanned — the same "fully unaligned" state Clear
Alignment resets back to.
"""
rotation_deg: float = 0.0
shift_mm: tuple[float, float] = (0.0, 0.0)
def _pixel_pitch_mm(sras: SrasFile, angle_idx: int) -> tuple[float, float]:
"""(dx, dy) mm/pixel for one angle: dx is the file-wide constant
pixel_x_mm; dy is this angle's own row spacing (assumed uniform, the same
@@ -429,10 +446,18 @@ def _theta_deg(sras: SrasFile, angle_idx: int, ref_idx: int) -> float:
def _corners_in_ref_frame(sras: SrasFile, angle_idx: int, ref_idx: int,
shift_mm=(0.0, 0.0)) -> np.ndarray:
shift_mm=(0.0, 0.0),
theta_deg: float | None = None) -> np.ndarray:
"""Angle *angle_idx*'s bbox corners, rotated about its own centroid into
the reference frame and translated by *shift_mm*. Shape (4, 2)."""
R = _rotation_matrix(_theta_deg(sras, angle_idx, ref_idx))
the reference frame and translated by *shift_mm*. Shape (4, 2).
theta_deg overrides the analytic angles_deg-derived rotation used by
default — the manual-alignment path (union_canvas_mm) passes a
user-chosen rotation here (which may differ from the known scan-angle
delta) without needing a parallel code path.
"""
theta = _theta_deg(sras, angle_idx, ref_idx) if theta_deg is None else theta_deg
R = _rotation_matrix(theta)
c_a = np.array(_bbox_center_mm(sras, angle_idx))
c_ref = np.array(_bbox_center_mm(sras, ref_idx))
shift = np.asarray(shift_mm, dtype=np.float64)
@@ -443,7 +468,8 @@ def _corners_in_ref_frame(sras: SrasFile, angle_idx: int, ref_idx: int,
def _build_affine_canvas_to_raw(sras: SrasFile, angle_idx: int, ref_idx: int,
shift_mm: tuple[float, float],
canvas_dx: float, canvas_dy: float,
canvas_origin_mm: tuple[float, float]
canvas_origin_mm: tuple[float, float],
theta_deg: float | None = None
) -> tuple[np.ndarray, np.ndarray]:
"""matrix, offset s.t. raw_index = matrix @ [row_out, col_out] + offset,
matching scipy.ndimage.affine_transform's output->input convention.
@@ -453,13 +479,16 @@ def _build_affine_canvas_to_raw(sras: SrasFile, angle_idx: int, ref_idx: int,
[lx;ly] = R(theta)^T @ ([X;Y]-c_ref-shift) + c_a # undo rotation+shift -> angle a's local mm
[row;col] = D @ ([lx;ly] - [x_start_a; y0_a]) # local mm -> angle a's raw idx
where theta = angles_deg[angle_idx] - angles_deg[ref_idx], c_ref/c_a are
each angle's own raw-bbox mm centroid (the rotation pivot — this keeps
rotated content centered, minimizing required canvas padding), and
A_out/D are the index<->mm scaling matrices for the canvas pitch and
this angle's own native pitch respectively.
where theta = angles_deg[angle_idx] - angles_deg[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 raw-bbox mm
centroid (the rotation pivot — this keeps rotated content centered,
minimizing required canvas padding), and A_out/D are the index<->mm
scaling matrices for the canvas pitch and this angle's own native pitch
respectively.
"""
Rinv = _rotation_matrix(_theta_deg(sras, angle_idx, ref_idx)).T
theta = _theta_deg(sras, angle_idx, ref_idx) if theta_deg is None else theta_deg
Rinv = _rotation_matrix(theta).T
cx_a, cy_a = _bbox_center_mm(sras, angle_idx)
cx_ref, cy_ref = _bbox_center_mm(sras, ref_idx)
dx_a, dy_a = _pixel_pitch_mm(sras, angle_idx)
@@ -648,3 +677,219 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
# Back-compat alias for the pre-split private name (used by tooling).
_compute_angle_alignment = compute_angle_alignment
# ---------------------------------------------------------------------------
# Manual alignment (Fusion menu -> Manual Alignment... dialog)
#
# Skips compute_angle_alignment's mask + phase-correlation search entirely:
# every angle's rotation_deg/shift_mm is supplied directly by the caller
# (nudged by eye against a live multi-angle mask overlay, or pre-seeded from
# a previous compute_angle_alignment run or a saved sidecar). Building the
# final AlignmentResult from already-known per-angle parameters is pure
# closed-form matrix math (union_canvas_mm + _build_affine_canvas_to_raw)
# with no per-pixel image work at all, so build_manual_alignment is cheap
# enough to call synchronously on the GUI thread on every edit. The only
# genuinely expensive per-pixel operation anywhere in this flow is
# reproject_mask, and only ManualAlignmentDialog's own downsampled preview
# calls that per keystroke — see that class's docstring for how it limits
# each nudge to reprojecting only the actively-edited angle.
# ---------------------------------------------------------------------------
def union_canvas_mm(sras: SrasFile, ref_angle_idx: int, dx: float, dy: float,
per_angle_params: dict[int, ManualAngleParams],
margin_frac: float = 0.0
) -> tuple[tuple[float, float], tuple[int, int]]:
"""Shared-canvas origin (mm) and (n_rows, n_cols) at pitch (dx, dy) that
contains every angle's footprint after applying its own rotation+shift —
the generalisation of compute_angle_alignment's Step 3 to arbitrary (not
just _theta_deg-analytic) per-angle rotation. Angles missing from
per_angle_params default to identity (e.g. a sidecar saved before a
rescan added more angles).
margin_frac pads the box on every side: 0 for a final canvas (this then
reproduces compute_angle_alignment's own Step-3 math exactly, when every
angle's rotation_deg equals the analytic delta and shift_mm matches);
nonzero for ManualAlignmentDialog's downsampled preview canvas, which
needs headroom so an ordinary translation nudge of the active angle never
has to trigger a full canvas resize (see that class's docstring — an
extreme nudge can still, in principle, push content past this padding;
accepted as a known edge case, same as _phase_correlate_shift's
wraparound risk note).
"""
n = sras.n_angles
corners = np.vstack([
_corners_in_ref_frame(
sras, a, ref_angle_idx,
per_angle_params.get(a, ManualAngleParams()).shift_mm,
theta_deg=per_angle_params.get(a, ManualAngleParams()).rotation_deg)
for a in range(n)])
x_min, y_min = corners.min(axis=0)
x_max, y_max = corners.max(axis=0)
if margin_frac:
pad_x, pad_y = (x_max - x_min) * margin_frac, (y_max - y_min) * margin_frac
x_min, x_max = x_min - pad_x, x_max + pad_x
y_min, y_max = y_min - pad_y, y_max + pad_y
n_cols = int(np.ceil((x_max - x_min) / dx)) + 1
n_rows = int(np.ceil((y_max - y_min) / abs(dy))) + 1
origin = (float(x_min), float(y_min if dy > 0 else y_max))
return origin, (n_rows, n_cols)
def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
mask: np.ndarray, rotation_deg: float,
shift_mm: tuple[float, float],
canvas_dx: float, canvas_dy: float,
canvas_origin_mm: tuple[float, float],
canvas_shape: tuple[int, int]) -> np.ndarray:
"""Resample one angle's binary/float mask onto an arbitrary canvas via an
explicit rotation+shift — the single building block
ManualAlignmentDialog's live preview repeatedly calls (once per
keystroke, for only the actively-nudged angle), since it bypasses
compute_angle_alignment's phase-correlation search entirely and just
takes rotation_deg/shift_mm as given. order=0 (nearest) matches
apply_alignment's own reasoning: a binary mask must never be blended with
zero-padding.
"""
matrix, offset = _build_affine_canvas_to_raw(
sras, angle_idx, ref_angle_idx, shift_mm, canvas_dx, canvas_dy,
canvas_origin_mm, theta_deg=rotation_deg)
return scipy_ndimage.affine_transform(
mask.astype(np.float32, copy=False), matrix, offset=offset,
output_shape=canvas_shape, order=0, mode="constant", cval=0.0)
def build_manual_alignment(sras: SrasFile, ref_angle_idx: int,
dc_threshold_mv: float,
per_angle_params: dict[int, ManualAngleParams]
) -> AlignmentResult:
"""Build a full, full-resolution AlignmentResult from user-supplied
per-angle rotation+shift — the Manual Alignment counterpart to
compute_angle_alignment, skipping its mask/phase-correlation search
entirely (every angle's transform here is exactly what the caller
supplied). Pure matrix/bbox math, no image data touched anywhere in this
function, so it is cheap enough to call synchronously on the GUI thread.
The reference angle's params are always forced to identity, regardless
of what per_angle_params holds for it — it defines the shared origin and
must never be transformed.
"""
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()
canvas_origin_mm, canvas_shape = union_canvas_mm(
sras, ref_angle_idx, dx_ref, dy_ref, params, margin_frac=0.0)
per_angle: dict[int, AngleTransform] = {}
for a in range(n):
p = params[a]
matrix, offset = _build_affine_canvas_to_raw(
sras, a, ref_angle_idx, p.shift_mm, dx_ref, dy_ref,
canvas_origin_mm, theta_deg=p.rotation_deg)
per_angle[a] = AngleTransform(p.rotation_deg, p.shift_mm, matrix, offset)
return AlignmentResult(ref_angle_idx, dc_threshold_mv, canvas_shape,
dx_ref, dy_ref, canvas_origin_mm, per_angle)
# ---- Sidecar persistence (<name>.sras.align.json) -------------------------
#
# Lives here, not sras_format.py: sras_format.py is scoped to the versioned
# binary .sras spec itself (see scan_format.md); a manual alignment is a
# viewer-computed *derived* artifact, analogous in kind to AlignmentResult —
# so it belongs with the alignment math it serialises, which already lives
# in this module. json + pathlib are both stdlib, so this doesn't add a new
# dependency to a module whose only load-bearing constraint is staying free
# of Qt/matplotlib for cheap multiprocessing-child imports.
@dataclass
class ManualAlignmentSidecar:
ref_angle_idx: int
dc_threshold_mv: float
per_angle: dict[int, ManualAngleParams]
def sidecar_path(sras_path) -> Path:
"""<name>.sras.align.json next to the scan file. A thin, independently
testable function since save/load/delete and the GUI's status messages
all need the identical path."""
p = Path(sras_path)
return p.with_name(p.name + ".align.json")
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_version": 1,
"ref_angle_idx": <int>,
"dc_threshold_mv": <float>,
"per_angle": {
"<angle_idx>": {"rotation_deg": <float>, "shift_mm": [<dx_mm>, <dy_mm>]},
...
}
}
Angle indices are JSON object keys, so they round-trip as strings —
load_manual_alignment converts them back to int.
"""
path = sidecar_path(sras.path)
payload = {
"schema_version": 1,
"ref_angle_idx": ref_angle_idx,
"dc_threshold_mv": dc_threshold_mv,
"per_angle": {
str(a): {"rotation_deg": p.rotation_deg, "shift_mm": list(p.shift_mm)}
for a, p in per_angle.items()
},
}
path.write_text(json.dumps(payload, indent=2))
return path
def load_manual_alignment(sras: SrasFile) -> ManualAlignmentSidecar | None:
"""Read <sras.path>'s sidecar JSON if present, else None — never raises:
a missing, corrupt, foreign, or version-mismatched JSON file must not
block opening the .sras file itself (see SrasViewerWindow._on_load_done).
Per-angle entries for an angle index no longer present in *sras* (e.g.
re-scanned with fewer angles) are silently dropped.
"""
path = sidecar_path(sras.path)
if not path.exists():
return None
try:
raw = json.loads(path.read_text())
if raw.get("schema_version") != 1:
return None
per_angle = {
int(a): ManualAngleParams(
float(v["rotation_deg"]),
(float(v["shift_mm"][0]), float(v["shift_mm"][1])))
for a, v in raw.get("per_angle", {}).items()
if int(a) < sras.n_angles
}
return ManualAlignmentSidecar(
ref_angle_idx=int(raw.get("ref_angle_idx", 0)),
dc_threshold_mv=float(raw.get("dc_threshold_mv", 0.0)),
per_angle=per_angle)
except (OSError, ValueError, KeyError, TypeError, IndexError,
json.JSONDecodeError):
return None
def delete_manual_alignment(sras: SrasFile) -> bool:
"""Delete the sidecar if present. Returns whether a file actually existed
to delete, so Clear Alignment's status message can say so. Genuine I/O
errors (permission denied, read-only share) propagate — the caller
(ManualAlignmentDialog._on_clear) surfaces them rather than silently
pretending the destructive action succeeded."""
path = sidecar_path(sras.path)
try:
path.unlink()
return True
except FileNotFoundError:
return False