Files
Thomas Ales bbb075ff34 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>
2026-07-31 11:31:14 -05:00

1056 lines
48 KiB
Python

#!/usr/bin/env python3
"""Image computation and angle alignment for .sras scans.
Depends only on numpy/scipy (+ optional pyfftw) and sras_format, so a
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
import scipy.ndimage as scipy_ndimage
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile, adc_to_mv
# ---------------------------------------------------------------------------
# FFT backend
# ---------------------------------------------------------------------------
try:
import pyfftw
pyfftw.interfaces.cache.enable()
PYFFTW_AVAILABLE = True
except ImportError:
PYFFTW_AVAILABLE = False
_fft_backend = "numpy" # "numpy" or "pyfftw"; set via set_fft_backend()
def set_fft_backend(name: str):
"""Select the rfft implementation. Module-level state, so it must be set
explicitly inside each multiprocessing child — it does not survive a
spawn."""
global _fft_backend
_fft_backend = name if (name != "pyfftw" or PYFFTW_AVAILABLE) else "numpy"
def get_fft_backend() -> str:
return _fft_backend
def _do_rfft(x: np.ndarray, n: int | None = None, axis: int = -1,
workers: int = 1) -> np.ndarray:
"""Dispatch rfft to the selected backend with optional multithreading."""
if _fft_backend == "pyfftw" and PYFFTW_AVAILABLE:
return pyfftw.interfaces.numpy_fft.rfft(x, n=n, axis=axis, threads=workers)
return scipy_fft.rfft(x, n=n, axis=axis, workers=workers)
# ---------------------------------------------------------------------------
# Chunking / parallel budget
# ---------------------------------------------------------------------------
#
# Rows are batched so the float32 working buffers for one chunk stay under a
# memory budget. A fixed row count (the original design) works fine for small
# legacy scans but is catastrophic for a v6 scan with a large per-angle
# frame/sample count — e.g. a 7500-frame x 2500-sample angle needs ~2.4 GB for
# a single 32-row chunk.
#
# With chunks running concurrently the budget has to cover *all* live chunks at
# once. Note that on a large scan chunk_rows is already clamped to its floor of
# 1 row (one row alone is ~75 MB of float32 at 7507x2500), so shrinking the
# per-chunk size cannot buy more concurrency — the worker count must be derived
# from the budget instead. See _plan_chunks.
# 1024 MB is the measured knee on a 16-core machine against a 7507-frame x
# 2500-sample angle: 512 MB leaves ~20% of the FFT speedup on the table, and
# 1536+ MB costs ~0.4 GB more resident for no further gain. Override with
# SRAS_MEM_BUDGET_MB on a smaller machine.
_TOTAL_BYTES_BUDGET = int(os.environ.get("SRAS_MEM_BUDGET_MB", 1024)) * 1024 * 1024
_CHUNK_ROWS_MAX = 32 # cap for small scans (original behavior)
_MAX_WORKERS = int(os.environ.get("SRAS_MAX_WORKERS", 0)) or (os.cpu_count() or 4)
# An rfft chunk holds, live at once: the float32 input, the complex64
# transform, and the float32 power spectrum — roughly 3x the input buffer.
_FFT_LIVE_MULTIPLIER = 3
def _chunk_rows_for(n_frames: int, samples_per_frame: int,
budget: int = _TOTAL_BYTES_BUDGET) -> int:
bytes_per_row = max(1, n_frames * samples_per_frame * 4) # float32
return int(max(1, min(_CHUNK_ROWS_MAX, budget // bytes_per_row)))
def _plan_chunks(n_rows: int, n_frames: int, samples_per_frame: int,
live_multiplier: int = 1,
max_workers: int | None = None,
budget: int | None = None) -> tuple[int, int]:
"""(chunk_rows, n_workers) such that all concurrent chunks together fit
the budget: n_workers x live_multiplier x chunk_rows x n_frames x
samples x 4 bytes <= budget.
The worker count is chosen *first* and the chunk sized to it. Sizing the
chunk first is the trap: _chunk_rows_for spends whatever budget it is
given, so a single chunk would always consume the lot and leave room for
exactly one worker — no concurrency, on precisely the large scans that
need it most.
A caller that is itself running several of these concurrently must pass
*both* max_workers=1 and its share of the budget. Capping the workers
alone is not enough: the chunk would still be sized against the whole
budget, and N concurrent callers would each allocate all of it.
"""
total = _TOTAL_BYTES_BUDGET if budget is None else max(1, budget)
cap = max_workers if max_workers is not None else _MAX_WORKERS
bytes_per_row = max(1, live_multiplier * n_frames * samples_per_frame * 4)
# A chunk is at least one row, so that alone caps how many can be live.
n_workers = int(max(1, min(cap, n_rows, total // bytes_per_row)))
chunk_rows = _chunk_rows_for(n_frames, samples_per_frame,
max(1, total // (n_workers * live_multiplier)))
# With chunk_rows known, more workers than chunks buys nothing.
n_workers = int(max(1, min(n_workers, -(-n_rows // chunk_rows))))
return chunk_rows, n_workers
def _map_row_chunks(n_rows: int, chunk_rows: int, n_workers: int, fn,
should_stop=None):
"""Apply fn(r0, r1) over row chunks, in parallel when it pays.
Chunks write to disjoint output slices, so no locking is needed. numpy
ufuncs, scipy's pocketfft, and memmap page faults all release the GIL, so
threads give real parallelism here — and on a very large file they also
keep many more page-fault requests in flight, which is what the I/O path
wants.
*should_stop* is polled per chunk so a cancelled job abandons work within
one chunk rather than one whole angle — on a large scan an angle is
~40 s, which is far too long to block application shutdown.
"""
bounds = [(r0, min(r0 + chunk_rows, n_rows))
for r0 in range(0, n_rows, chunk_rows)]
def run(b):
if should_stop is not None and should_stop():
return
fn(*b)
if n_workers <= 1 or len(bounds) == 1:
for b in bounds:
run(b)
return
with ThreadPoolExecutor(max_workers=min(n_workers, len(bounds))) as pool:
list(pool.map(run, bounds))
# ---------------------------------------------------------------------------
# Image computation
# ---------------------------------------------------------------------------
def plan_angle_level(sras: SrasFile,
live_multiplier: int = 1) -> tuple[int, int]:
"""(n_angle_workers, per_angle_budget) for a caller that parallelises
over angles instead of over rows.
Callers that parallelise over angles must not also let each angle
parallelise over rows: the two levels multiply, in threads and in memory.
Each angle is therefore given max_workers=1 and the returned budget share,
which together keep total live buffers within _TOTAL_BYTES_BUDGET.
"""
biggest = max(range(sras.n_angles), key=lambda a: int(sras.n_frames[a]))
_, n_workers = _plan_chunks(int(sras.n_rows[biggest]),
int(sras.n_frames[biggest]),
sras.samples_per_frame,
live_multiplier=live_multiplier)
n_workers = max(1, min(n_workers, sras.n_angles))
return n_workers, max(1, _TOTAL_BYTES_BUDGET // n_workers)
def compute_dc_image(sras: SrasFile, angle_idx: int, ch_idx: int,
max_workers: int | None = None,
budget: int | None = None,
should_stop=None) -> np.ndarray:
"""Mean of each waveform → (n_rows, n_frames) float32, in ADC counts.
Pass max_workers=1 when the caller is already parallelising over angles.
If *should_stop* ever returns True the result is incomplete — the caller
is expected to be abandoning it.
"""
n_rows, n_frames = sras.image_shape(angle_idx)
data = sras.data[angle_idx]
chunk_rows, n_workers = _plan_chunks(n_rows, n_frames, sras.samples_per_frame,
max_workers=max_workers, budget=budget)
img = np.empty((n_rows, n_frames), dtype=np.float32)
def chunk(r0: int, r1: int):
img[r0:r1] = data[r0:r1, ch_idx, :, :].astype(np.float32).mean(axis=-1)
_map_row_chunks(n_rows, chunk_rows, n_workers, chunk, should_stop=should_stop)
return img
def dc_image_mv(sras: SrasFile, angle_idx: int, ch_idx: int,
max_workers: int | None = None, budget: int | None = None,
should_stop=None) -> np.ndarray:
"""DC image for (angle, channel) in mV, preferring a stored v5/v7 cache."""
cached = sras.cached_dc_mv(angle_idx, ch_idx)
if cached is not None:
return cached
return adc_to_mv(
compute_dc_image(sras, angle_idx, ch_idx, max_workers=max_workers,
budget=budget, should_stop=should_stop),
*sras.cal(ch_idx))
def compute_rf_image(sras: SrasFile, angle_idx: int,
dc_threshold_mv: float | None,
apply_bg_sub: bool = True,
n_fft: int | None = None,
dc4_mv: np.ndarray | None = None,
max_workers: int | None = None,
budget: int | None = None,
should_stop=None) -> np.ndarray:
"""FFT of each CH1 waveform; pixel = peak frequency in MHz.
Pixels where CH4_dc < dc_threshold_mv are set to 0 — and the FFT is
never run for them, since that's the expensive part. The masked-out
CH1 samples are also never *read*: the boolean mask is applied to the
raw memmap slice before any dtype conversion, so numpy only pages in
the bytes for pixels that pass the threshold (an untouched memmap page
is never read from disk).
*dc_threshold_mv* of None means "mask nothing", and skips reading and
averaging CH4 entirely — worth a third of the I/O when caching a whole
file, where the mask is applied later at display time.
If the DC4 image for this angle is already known (e.g. from the
DC-channel precompute cache), pass it as *dc4_mv* (mV, shape
(n_rows, n_frames)) to reuse it instead of re-reading CH4 here.
Fast path: if the file has a precomputed peak-frequency image for this
angle (v5 PREC or v7 CACH), zero-padding is off, and the bg-sub flag
matches, the stored image is used directly — no FFT is run.
"""
n_rows, n_frames = sras.image_shape(angle_idx)
data = sras.data[angle_idx]
# ---- Fast path: precomputed image (v5 PREC or v7 CACH) ----------------
cached_freq = sras.precomputed_freq_mhz[angle_idx]
if (cached_freq is not None
and n_fft is None # no custom zero-padding
and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None)):
freq_img = cached_freq.copy()
if dc_threshold_mv is not None:
# DC4 mask, in priority order: already-cached DC block, caller-
# supplied image, or a fresh (cheap — no FFT) recompute.
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
if dc4_img is None:
dc4_img = dc4_mv if dc4_mv is not None else adc_to_mv(
compute_dc_image(sras, angle_idx, CH4_IDX), *sras.cal(CH4_IDX))
freq_img[dc4_img < dc_threshold_mv] = 0.0
return freq_img
# ---- Chunked FFT path --------------------------------------------------
freq_axis = sras.freq_axis_mhz(n_fft)
img = np.zeros((n_rows, n_frames), dtype=np.float32)
n_fft_bins = n_fft if n_fft is not None else sras.samples_per_frame
chunk_rows, n_workers = _plan_chunks(
n_rows, n_frames, max(sras.samples_per_frame, n_fft_bins),
live_multiplier=_FFT_LIVE_MULTIPLIER, max_workers=max_workers,
budget=budget)
background = sras.background if (apply_bg_sub and sras.background is not None) else None
cal4 = sras.cal(CH4_IDX)
def chunk(r0: int, r1: int):
if dc_threshold_mv is None:
valid = None
else:
if dc4_mv is not None:
dc4_chunk = dc4_mv[r0:r1]
else:
dc4_chunk = adc_to_mv(
data[r0:r1, CH4_IDX, :, :].astype(np.float32).mean(axis=-1), *cal4)
valid = dc4_chunk >= dc_threshold_mv # True = run the FFT
if not valid.any():
return
# Index the raw memmap slice with the boolean mask *before*
# converting dtype — this is a lazy view until touched, so only the
# selected elements are actually read from disk; masked-out pixels'
# pages are never paged in at all.
raw = data[r0:r1, CH1_IDX, :, :]
waves = (raw[valid] if valid is not None else raw).astype(np.float32)
if background is not None:
waves -= background # background is 1-D (spf,)
# Parallelism comes from the outer chunk loop, so keep the inner
# transform single-threaded to avoid oversubscribing the machine.
spectrum = _do_rfft(waves, n=n_fft, axis=-1, workers=1)
del waves
# |z|^2 without np.abs()'s extra full-size temporary.
power = spectrum.real ** 2
power += spectrum.imag ** 2
del spectrum
# [..., 0] not [:, 0]: the unmasked path keeps the (rows, frames,
# bins) shape, where [:, 0] would blank a whole frame.
power[..., 0] = 0.0 # suppress DC bin
peak_bins = np.argmax(power, axis=-1)
del power
if valid is not None:
img[r0:r1][valid] = freq_axis[peak_bins]
else:
img[r0:r1] = freq_axis[peak_bins]
_map_row_chunks(n_rows, chunk_rows, n_workers, chunk, should_stop=should_stop)
return img
# ---------------------------------------------------------------------------
# Batch cache (Convert menu) — process-pool entry point
# ---------------------------------------------------------------------------
def cache_file(path: str, mode: str, apply_bg_sub: bool,
fft_backend: str = "numpy", max_workers: int = 0) -> str:
"""Compute and store DC or FFT images for every angle of one file,
converting v6 → v7 in place. Returns "" on success or an error message.
Module-level and picklable so it can run in a ProcessPoolExecutor. The
FFT backend and worker cap are passed explicitly because module globals
do not survive a spawn.
"""
global _MAX_WORKERS
try:
if mode not in ("dc", "fft"):
return f"unknown cache mode {mode!r} (expected 'dc' or 'fft')"
set_fft_backend(fft_backend)
if max_workers:
_MAX_WORKERS = max_workers
sras = SrasFile(path)
if sras.version not in (6, 7):
return (f"unsupported version {sras.version} — only v6/v7 files "
"can be batch-cached")
n = sras.n_angles
if mode == "dc":
dc3 = [adc_to_mv(compute_dc_image(sras, a, CH3_IDX), *sras.cal(CH3_IDX))
for a in range(n)]
dc4 = [adc_to_mv(compute_dc_image(sras, a, CH4_IDX), *sras.cal(CH4_IDX))
for a in range(n)]
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
else:
effective_bg = apply_bg_sub and sras.background is not None
# dc_threshold_mv=None: store unmasked images and mask at display
# time (same convention as v5's PREC block). Skipping the mask
# also skips reading CH4 entirely.
freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
apply_bg_sub=effective_bg)
for a in range(n)]
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg)
return ""
except Exception as exc:
return str(exc)
# ---------------------------------------------------------------------------
# Angle alignment (Fusion menu)
#
# Puts every angle's images onto one shared, zero-padded pixel grid using a
# rigid transform only (rotation + translation, never scale). Rotation for
# angle `a` is the *known* scan-angle delta relative to a reference angle —
# never searched. Only the residual translation is found, via FFT phase
# correlation of each angle's binarized CH4 ("dc-mask") image.
#
# Rotation is done in physical mm space rather than on raw pixel indices:
# the x-pixel pitch (SrasFile.pixel_x_mm) is file-wide constant but the
# y-pixel pitch (row spacing) can differ from it, and for v6 files can even
# vary per angle. Rotating the raw index grid directly would implicitly
# assume square pixels and shear a non-square-pixel image — an unwanted
# effective anisotropic scale. Instead each angle gets one affine that maps
# shared-canvas pixel index -> mm -> undo rotation/shift -> that angle's own
# local mm -> that angle's own raw pixel index, matching the output->input
# convention scipy.ndimage.affine_transform expects.
# ---------------------------------------------------------------------------
@dataclass
class AngleTransform:
rotation_deg: float
shift_mm: tuple[float, float] # (dx_mm, dy_mm) found by phase correlation
matrix: np.ndarray # (2,2): canvas (row,col) -> this angle's raw (row,col)
offset: np.ndarray # (2,)
@dataclass
class AlignmentResult:
ref_angle_idx: int
dc_threshold_mv: float
canvas_shape: tuple[int, int] # (n_rows, n_cols)
canvas_dx_mm: float
canvas_dy_mm: float
canvas_origin_mm: tuple[float, float] # mm at canvas pixel index (0, 0)
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
assumption _redraw_image makes when it builds the display extent)."""
y = sras.y_positions_mm(angle_idx)
return sras.pixel_x_mm, (float(y[1] - y[0]) if len(y) > 1 else 1.0)
def _bbox_center_mm(sras: SrasFile, angle_idx: int) -> tuple[float, float]:
x = sras.x_axis_mm(angle_idx)
y = sras.y_positions_mm(angle_idx)
return float((x[0] + x[-1]) / 2.0), float((y[0] + y[-1]) / 2.0)
def _bbox_corners_mm(sras: SrasFile, angle_idx: int) -> np.ndarray:
"""4 corners (x, y) of this angle's raw mm bounding box, shape (4, 2)."""
x = sras.x_axis_mm(angle_idx)
y = sras.y_positions_mm(angle_idx)
return np.array([[xx, yy] for xx in (x[0], x[-1]) for yy in (y[0], y[-1])])
def _rotation_matrix(theta_deg: float) -> np.ndarray:
t = np.radians(theta_deg)
c, s = np.cos(t), np.sin(t)
return np.array([[c, -s], [s, c]]) # CCW rotation acting on (x, y)
def _theta_deg(sras: SrasFile, angle_idx: int, ref_idx: int) -> float:
"""CCW rotation, in degrees and in _rotation_matrix's convention, that
maps angle_idx's own local mm frame onto ref_idx's.
This is the *negative* of the raw angles_deg delta: the GR rotation
stage's reported angle increases in the opposite rotational sense from
this module's math-positive (CCW, x toward y) convention in scan mm
space. Rotating by +(angles_deg[a] - angles_deg[ref]) therefore turns
misalignment the wrong way — confirmed empirically (Auto De-rotate made
real scans worse, not better, before this negation).
"""
return -float(sras.angles_deg[angle_idx] - sras.angles_deg[ref_idx])
def _signal_centroid_mm(sras: SrasFile, angle_idx: int,
dc4_mv: np.ndarray) -> tuple[float, float]:
"""Intensity-weighted centroid (mean x, mean y, weighted by CH4 signal
after subtracting this angle's own minimum) in angle_idx's own local mm
frame — the alignment pivot used in place of the raw scan-window bbox
center (see compute_pivot_points_mm).
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)
x = sras.x_axis_mm(angle_idx)
y = sras.y_positions_mm(angle_idx)
cx = float((x * weights.sum(axis=0)).sum() / total)
cy = float((y * weights.sum(axis=1)).sum() / total)
return cx, cy
def compute_pivot_points_mm(sras: SrasFile,
dc4_mv: dict[int, np.ndarray] | None = None
) -> dict[int, tuple[float, float]]:
"""Per-angle alignment pivot, in each angle's own local mm frame: the
CH4-signal-weighted centroid of its own footprint (see
_signal_centroid_mm), rather than the raw scan-window bbox center.
Pivoting on each angle's own content — instead of on wherever its
scanned window happened to sit in microscope/global XY space — is what
makes alignment purely relative *between scans* rather than to a global
coordinate system: the rotation stage's true mechanical axis need not
coincide with the scan window's geometric center, and the sample need
not be perfectly centered on that axis either, so a bbox-center pivot
leaves a residual orbital motion between angles that a content-centroid
pivot does not.
Deliberately independent of dc_threshold_mv (the RF-mask / overlay
threshold): that value is a display/masking choice and must never
silently change where alignment pivots.
*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.
"""
dc4_mv = dc4_mv or {}
pivots: dict[int, tuple[float, float]] = {}
for a in range(sras.n_angles):
img = dc4_mv.get(a)
if img is None:
img = dc_image_mv(sras, a, CH4_IDX)
pivots[a] = _signal_centroid_mm(sras, a, img)
return pivots
def _corners_in_ref_frame(sras: SrasFile, angle_idx: int, ref_idx: int,
pivot_mm: dict[int, tuple[float, float]],
shift_mm=(0.0, 0.0),
theta_deg: float | None = None) -> np.ndarray:
"""Angle *angle_idx*'s bbox corners, rotated about its own alignment
pivot (pivot_mm[angle_idx] — see compute_pivot_points_mm) into 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(pivot_mm[angle_idx])
c_ref = np.array(pivot_mm[ref_idx])
shift = np.asarray(shift_mm, dtype=np.float64)
return np.array([R @ (corner - c_a) + c_ref + shift
for corner in _bbox_corners_mm(sras, angle_idx)])
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],
pivot_mm: dict[int, 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.
Pipeline (all mm unless noted):
[X;Y] = A_out @ [row_out;col_out] + b_out # canvas idx -> ref-frame mm
[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 is _theta_deg(angle_idx, 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 alignment pivot
(pivot_mm — see compute_pivot_points_mm; the CH4-signal-weighted
centroid of its own footprint, not the raw scan-window bbox center —
this keeps the sample itself centered post-rotation, minimizing
required canvas padding and, more importantly, keeping alignment
relative to the sample rather than to wherever the scan window sat in
microscope/global XY space), and A_out/D are the index<->mm scaling
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
Rinv = _rotation_matrix(theta).T
cx_a, cy_a = pivot_mm[angle_idx]
cx_ref, cy_ref = pivot_mm[ref_idx]
dx_a, dy_a = _pixel_pitch_mm(sras, angle_idx)
x0_a = float(sras.x_start_mm[angle_idx])
y0_a = float(sras.y_positions_mm(angle_idx)[0])
A_out = np.array([[0.0, canvas_dx], [canvas_dy, 0.0]]) # [row,col] -> [X,Y]
b_out = np.array(canvas_origin_mm, dtype=np.float64)
D = np.array([[0.0, 1.0 / dy_a], [1.0 / dx_a, 0.0]]) # [x,y] -> [row,col]
shift = np.array(shift_mm, dtype=np.float64)
c_ref_v = np.array([cx_ref, cy_ref])
c_a_v = np.array([cx_a, cy_a])
origin_a = np.array([x0_a, y0_a])
matrix = D @ Rinv @ A_out
offset = D @ Rinv @ (b_out - c_ref_v - shift) + D @ (c_a_v - origin_a)
return matrix, offset
def apply_alignment(result: AlignmentResult, angle_idx: int, img: np.ndarray,
order: int = 0) -> np.ndarray:
"""Resample any already-computed 2D image for `angle_idx` (same shape as
that angle's raw (n_rows, n_frames)) onto the shared alignment canvas.
order=0 (nearest) avoids blending real data with zero-padding or with
masked-out (0-valued) CH1/velocity pixels at mask edges. Channel-
agnostic: the same per-angle transform (found from the CH4 mask) works
for any channel's image of that angle."""
t = result.per_angle[angle_idx]
return scipy_ndimage.affine_transform(
img.astype(np.float32, copy=False), t.matrix, offset=t.offset,
output_shape=result.canvas_shape, order=order,
mode="constant", cval=0.0)
def _block_mean_downsample(img: np.ndarray, factor: int) -> np.ndarray:
if factor <= 1:
return img
h, w = img.shape
h2, w2 = (h // factor) * factor, (w // factor) * factor
trimmed = img[:h2, :w2]
return trimmed.reshape(h2 // factor, factor, w2 // factor, factor).mean(axis=(1, 3))
def _phase_correlate_shift(ref_img: np.ndarray, mov_img: np.ndarray) -> tuple[int, int]:
"""FFT normalized cross-power-spectrum phase correlation. Returns the
integer (dr, dc) pixel shift of mov_img relative to ref_img; both must
be the same shape. Risk: if the true shift is near +/- half the array
size, wraparound can bias the peak — mitigated by the generous
margin_frac padding in _working_canvas_for_pair, which keeps the true
residual shift small relative to the correlation canvas."""
F1 = scipy_fft.fft2(ref_img.astype(np.float64), workers=-1)
F2 = scipy_fft.fft2(mov_img.astype(np.float64), workers=-1)
R = F1 * np.conj(F2)
R /= np.maximum(np.abs(R), 1e-12)
corr = scipy_fft.ifft2(R, workers=-1).real
dr, dc = np.unravel_index(np.argmax(corr), corr.shape)
h, w = corr.shape
if dr > h // 2:
dr -= h
if dc > w // 2:
dc -= w
return int(dr), int(dc)
def _working_canvas_for_pair(sras: SrasFile, ref_idx: int, a_idx: int,
dx: float, dy: float,
pivot_mm: dict[int, tuple[float, float]],
margin_frac: float = 0.3
) -> tuple[tuple[float, float], tuple[int, int]]:
"""Union of the reference's own raw bbox and angle a's raw bbox rotated
(about its own alignment pivot) into the ref frame with zero shift,
padded by margin_frac on each side — sized generously so the true
phase-correlation shift lands well inside the canvas (see
_phase_correlate_shift's wraparound note)."""
pts = np.vstack([_bbox_corners_mm(sras, ref_idx),
_corners_in_ref_frame(sras, a_idx, ref_idx, pivot_mm)])
x_min, y_min = pts.min(axis=0)
x_max, y_max = pts.max(axis=0)
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 = (x_min, y_min if dy > 0 else y_max)
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)
if n_workers <= 1 or len(items) <= 1:
return [fn(x) for x in items]
with ThreadPoolExecutor(max_workers=min(n_workers, len(items))) as pool:
return list(pool.map(fn, items))
def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
dc_threshold_mv: float,
progress_cb=None) -> AlignmentResult:
"""Top-level alignment driver. Runs on a background thread (see
AngleAlignmentWorker) — deliberately recomputes CH4 DC images from
scratch rather than reading the GUI-thread _dc_cache dict, since
background-thread workers must not touch GUI-thread-owned caches."""
n = sras.n_angles # already the *complete*-angle count for aborted v6 scans
dx_ref, dy_ref = _pixel_pitch_mm(sras, ref_angle_idx)
# Parallel over angles, serial within each — see plan_angle_level.
n_workers, angle_budget = plan_angle_level(sras)
# progress_cb fires from pool threads, so the counter behind it must be
# atomic. list.append is, and len() of a list is a consistent read.
_ticks: list[int] = []
def tick(base: int, span: int):
if progress_cb:
_ticks.append(1)
progress_cb(base + int(min(len(_ticks), n) / n * span))
# ---- Step 1: CH4 DC image + binarized mask per angle, native grid -----
def dc4_for(a: int) -> np.ndarray:
dc4 = adc_to_mv(
compute_dc_image(sras, a, CH4_IDX, max_workers=1, budget=angle_budget),
*sras.cal(CH4_IDX))
tick(0, 25)
return dc4
dc4_mv = dict(enumerate(_parallel_map(dc4_for, range(n), n_workers)))
# Each angle's own alignment pivot — the CH4-signal-weighted centroid,
# not the raw scan-window bbox center (see compute_pivot_points_mm).
# Reuses the dc4_mv images just computed above, so this is free, and is
# 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 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]:
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 shift
shifts_mm = dict(enumerate(_parallel_map(shift_for, range(n), n_workers)))
# ---- Step 3: union bounding box over all angles (rotation+shift applied)
corners = np.vstack([
_corners_in_ref_frame(sras, a, ref_angle_idx, pivot_mm, shifts_mm[a])
for a in range(n)])
x_min, y_min = corners.min(axis=0)
x_max, y_max = corners.max(axis=0)
n_cols = int(np.ceil((x_max - x_min) / dx_ref)) + 1
n_rows = int(np.ceil((y_max - y_min) / abs(dy_ref))) + 1
canvas_origin_mm = (float(x_min), float(y_min if dy_ref > 0 else y_max))
# ---- Step 4: final per-angle full-resolution affine (canvas -> raw idx)
per_angle: dict[int, AngleTransform] = {}
for a in range(n):
matrix, offset = _build_affine_canvas_to_raw(
sras, a, ref_angle_idx, shifts_mm[a], dx_ref, dy_ref, canvas_origin_mm,
pivot_mm)
per_angle[a] = AngleTransform(
_theta_deg(sras, a, ref_angle_idx), shifts_mm[a], matrix, offset)
if progress_cb:
progress_cb(75 + int((a + 1) / n * 25))
return AlignmentResult(ref_angle_idx, dc_threshold_mv, (n_rows, n_cols),
dx_ref, dy_ref, canvas_origin_mm, per_angle)
# 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],
pivot_mm: dict[int, tuple[float, float]],
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, pivot_mm,
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],
pivot_mm: dict[int, tuple[float, float]]) -> 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, pivot_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],
pivot_mm: dict[int, tuple[float, float]] | None = None
) -> 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 once pivot_mm is known, 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.
Pass an already-computed *pivot_mm* (e.g. ManualAlignmentDialog's own
cache, built once from its live CH4 images) to skip recomputing every
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
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
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()
if pivot_mm is None:
pivot_mm = compute_pivot_points_mm(sras)
canvas_origin_mm, canvas_shape = union_canvas_mm(
sras, ref_angle_idx, dx_ref, dy_ref, params, pivot_mm, 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, pivot_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")
# 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,
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 2):
{
"schema_version": 2,
"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": _SIDECAR_SCHEMA_VERSION,
"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") != _SIDECAR_SCHEMA_VERSION:
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