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:
+255
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user