Add FFT cross-correlation to Manual Alignment, with tunable options

Manual mode previously offered only Auto De-rotate (rotation from known
scan angles) plus keyboard nudging, so every angle's translation had to
be found entirely by eye — fine for a small correction, unworkable for
the tens-of-mm scatter a real many-angle scan can have between angles
before any translation search runs at all.

Add an "Auto Cross-Correlate (vs Reference)" action that, for every
non-reference angle, sets rotation to the known analytic angle (same as
Auto De-rotate) and translation to the FFT-phase-correlation best fit
against angle #0 (always the reference/ground truth). This is meant to
land every angle's outline roughly stacked on the others so keyboard
nudging only has to make small corrections afterward, per the intended
workflow: cross-correlate first, then nudge.

Exposes two tunable options, since real data may correlate better one
way than the other: "Correlate on" (raw CH4 signal, minus its own
minimum -- the default, robust to a shared threshold not suiting every
angle's real signal level -- or the thresholded binary mask), and
"Search margin" (how far a shift the phase correlation searches before
wraparound bias becomes a risk).

Runs on a background thread (new CrossCorrelateWorker in
sras_workers.py) since correlating many high-resolution angles can take
long enough to visibly freeze the dialog otherwise.

The underlying per-angle correlation math is factored out of
compute_angle_alignment's Step 2 into a new, reusable
correlate_translation_mm, so the existing automatic Fusion -> Angle
Alignment action and the new manual button now share one implementation
instead of two copies of the same phase-correlation logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-31 11:31:14 -05:00
parent 52e91d46fd
commit bbb075ff34
4 changed files with 258 additions and 54 deletions
+74 -35
View File
@@ -658,6 +658,71 @@ def _working_canvas_for_pair(sras: SrasFile, ref_idx: int, a_idx: int,
return origin, (n_rows, n_cols)
def correlate_translation_mm(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
signal_mv: dict[int, np.ndarray],
pivot_mm: dict[int, tuple[float, float]], *,
use_mask: bool = False,
dc_threshold_mv: float = 0.0,
margin_frac: float = 0.3,
max_corr_dim: int = 1024) -> tuple[float, float]:
"""FFT phase-correlation translation (shift_x_mm, shift_y_mm) that best
lines up angle_idx's CH4 content onto ref_angle_idx's, given each angle's
own alignment pivot (pivot_mm — see compute_pivot_points_mm) already
rotated about that pivot by the analytic scan-angle delta with zero
shift. Returns (0.0, 0.0) unconditionally for the reference angle.
By default (use_mask=False) correlates on each angle's own raw CH4
signal minus its own minimum — the same weighting _signal_centroid_mm
uses — rather than a dc_threshold_mv binary mask: a shared threshold
that doesn't suit every angle's real signal level can empty or distort
one angle's mask (the same failure mode the alignment pivot was fixed
to avoid), and correlating on the raw signal also lets phase
correlation lock onto real internal sample structure rather than just
the scan window's silhouette. Subtracting each angle's own minimum
(rather than using the signal as-is) keeps the zero-padding surrounding
the rotated image from reading as a spurious high-contrast edge against
a nonzero DC baseline. use_mask=True switches to the binary
>= dc_threshold_mv mask instead (compute_angle_alignment's original
behavior), for cases where raw-signal correlation locks onto noise.
"""
if angle_idx == ref_angle_idx:
return (0.0, 0.0)
def corr_img(a: int) -> np.ndarray:
img = signal_mv[a]
if use_mask:
return (img >= dc_threshold_mv).astype(np.float32)
return (img - img.min()).astype(np.float32)
img_a = corr_img(angle_idx)
img_ref = corr_img(ref_angle_idx)
dx_ref, dy_ref = _pixel_pitch_mm(sras, ref_angle_idx)
max_dim = max(img_a.shape + img_ref.shape)
factor = max(1, int(np.ceil(max_dim / max_corr_dim)))
dx_c, dy_c = dx_ref * factor, dy_ref * factor
small_a = _block_mean_downsample(img_a, factor)
small_ref = _block_mean_downsample(img_ref, factor)
work_origin, work_shape = _working_canvas_for_pair(
sras, ref_angle_idx, angle_idx, dx_c, dy_c, pivot_mm, margin_frac=margin_frac)
m_a, o_a = _build_affine_canvas_to_raw(
sras, angle_idx, ref_angle_idx, (0.0, 0.0), dx_c, dy_c, work_origin, pivot_mm)
m_ref, o_ref = _build_affine_canvas_to_raw(
sras, ref_angle_idx, ref_angle_idx, (0.0, 0.0), dx_c, dy_c, work_origin, pivot_mm)
rotated_a = scipy_ndimage.affine_transform(
small_a, m_a / factor, offset=o_a / factor,
output_shape=work_shape, order=0, mode="constant", cval=0.0)
embedded_ref = scipy_ndimage.affine_transform(
small_ref, m_ref / factor, offset=o_ref / factor,
output_shape=work_shape, order=0, mode="constant", cval=0.0)
dr, dc = _phase_correlate_shift(embedded_ref, rotated_a)
return (dc * dx_c, dr * dy_c)
def _parallel_map(fn, items, n_workers: int) -> list:
"""fn over items, in order, threaded when it pays."""
items = list(items)
@@ -697,8 +762,6 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
return dc4
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 CH4-signal-weighted centroid,
# not the raw scan-window bbox center (see compute_pivot_points_mm).
@@ -708,41 +771,17 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
# 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)
# Downsampling before affine_transform (not after) is what keeps this
# tractable for a v6 scan with thousands of rows/frames per angle.
max_dim = max(max(m.shape) for m in masks.values())
factor = max(1, int(np.ceil(max_dim / 1024)))
dx_c, dy_c = dx_ref * factor, dy_ref * factor
masks_small = {a: _block_mean_downsample(m, factor) for a, m in masks.items()}
_ticks.clear()
# ---- Step 2: coarse translation via FFT phase correlation, on each
# angle's binarized CH4 mask against the reference's (see
# correlate_translation_mm — also the engine behind ManualAlignmentDialog's
# Auto Cross-Correlate button, there defaulting to the raw signal instead
# of a mask).
def shift_for(a: int) -> tuple[float, float]:
if a == ref_angle_idx:
tick(25, 50)
return (0.0, 0.0)
work_origin, work_shape = _working_canvas_for_pair(
sras, ref_angle_idx, a, dx_c, dy_c, pivot_mm, margin_frac=0.3)
# Coarse canvas->raw affine at native pitch, then rescale by /factor
# so it maps coarse-canvas idx -> coarse (downsampled) raw idx —
# exact for block-mean downsampling (up to the trimmed remainder).
m_a, o_a = _build_affine_canvas_to_raw(
sras, a, ref_angle_idx, (0.0, 0.0), dx_c, dy_c, work_origin, pivot_mm)
m_ref, o_ref = _build_affine_canvas_to_raw(
sras, ref_angle_idx, ref_angle_idx, (0.0, 0.0), dx_c, dy_c, work_origin,
pivot_mm)
rotated_a = scipy_ndimage.affine_transform(
masks_small[a], m_a / factor, offset=o_a / factor,
output_shape=work_shape, order=0, mode="constant", cval=0.0)
embedded_ref = scipy_ndimage.affine_transform(
masks_small[ref_angle_idx], m_ref / factor, offset=o_ref / factor,
output_shape=work_shape, order=0, mode="constant", cval=0.0)
dr, dc = _phase_correlate_shift(embedded_ref, rotated_a)
shift = correlate_translation_mm(
sras, a, ref_angle_idx, dc4_mv, pivot_mm,
use_mask=True, dc_threshold_mv=dc_threshold_mv)
tick(25, 50)
return (dc * dx_c, dr * dy_c)
return shift
shifts_mm = dict(enumerate(_parallel_map(shift_for, range(n), n_workers)))