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