brain_deer.warp.rbf¶
Variable-radius Gaussian RBF scattered-data interpolation.
Given correspondences source_i -> target_i, each with its own radius, this
builds a displacement field d such that phi(x) = x + d(x) carries each
target_i back onto its source_i.
The basis functions are centred on the targets, and the field is always rebuilt from scratch from the full point set – there is no incremental state.
REFERENCE BEHAVIOUR¶
This reimplements Slicer’s FiducialRegistrationVariableRBF CLI (netstim/
SlicerNetstim), whose source was never vendored into Lead-DBS. The conventions
below were recovered empirically by probing the compiled binary; see
scripts/warp_golden.py and tests/test_warp_rbf.py.
basis phi_j(x) = exp(-||x - t_j||^2 / r_j^2) centred on TARGETS rhs source - target solve (K + lam*G) w = rhs, K[i][j] = phi_j(t_i), G = diag(C/r_i) evaluate d(x) = sum_j w_j phi_j(x) C = 15 * pi**1.5 / (2*sqrt(2)) ~ 29.5305
C is not a fudge factor: it is the exact bending energy of a unit Gaussian.
The regulariser is INT sum_ab (d2phi_i/dx_a dx_b)(d2phi_j/dx_a dx_b) dV,
which by Parseval equals (2 pi)^-3 INT |k|^4 phihat_i conj(phihat_j) d3k;
for a Gaussian of width a the diagonal evaluates to 15 (2 pi)^1.5 / (8 a)
= C / a. Verified against the binary to 16 significant figures.
DELIBERATE DIVERGENCES FROM THE REFERENCE¶
The reference builds its solve matrix as
K[i][j] = exp(-||t_i - t_j||^2 / r_i^2)– indexing the radius by ROW – while evaluating with the radius indexed by COLUMN. The two disagree whenever radii differ, so the reference does not actually interpolate: a landmark dragged 4mm can land ~2mm away from where the user put it. Under uniform radii (every real session inspected, and the 15mm default) the two indexings coincide and the schemes are identical. We use the consistent column indexing, which restores exact interpolation.The reference’s regulariser picks up off-diagonal structure under mixed radii that is neither symmetric nor the true bending-energy matrix, and is not identifiable from its output. We use the analytic diagonal.
Where the problem is well-posed these agree with the reference to ~3e-07 mm.
Where it is not – dense smudge trails put cond(K) around 1e6, so the
regulariser dominates – they agree to <0.2mm peak and ~0.003mm mean under
realistic coherent drags, well under imaging resolution.
Functions¶
|
Solve for RBF weights carrying each |
|
Evaluate |
|
Accumulate the field over a regular grid, one bounding box per centre. |
Module Contents¶
- brain_deer.warp.rbf.solve_weights(source: FloatArray, target: FloatArray, radii: FloatArray | float, stiffness: float = 0.1) FloatArray¶
Solve for RBF weights carrying each
targetback onto itssource.- Parameters:
source ((N,3) arrays of correspondences, in consistent units.)
target ((N,3) arrays of correspondences, in consistent units.)
radii (scalar, or (N,) per-landmark basis widths in the same units.)
stiffness (regularisation factor. 0 gives exact interpolation, which is) – ill-posed when landmarks nearly coincide – and they routinely do, since a dragged trail is sampled every ~1mm. Non-zero is strongly advised.
- Returns:
(N,3) weights, to be used with
evaluate_on_points()against the sametargetcentres andradii.
- brain_deer.warp.rbf.evaluate_on_points(centres: FloatArray, weights: FloatArray, radii: FloatArray | float, points: FloatArray) FloatArray¶
Evaluate
d(x) = sum_j w_j exp(-||x - c_j||^2 / r_j^2)atpoints.centresare the targets used insolve_weights().
- brain_deer.warp.rbf.evaluate_on_grid(centres: FloatArray, weights: FloatArray, radii: FloatArray | float, *, affine: FloatArray, shape: tuple[int, int, int], cutoff_radii: float = DEFAULT_CUTOFF_RADII) numpy.typing.NDArray[numpy.float32]¶
Accumulate the field over a regular grid, one bounding box per centre.
affinemaps voxel index -> world;shapeis the grid size. Returns an(I,J,K,3)float32 displacement array.Each Gaussian is evaluated only inside
cutoff_radii * r_jof its centre. At 3 radii the kernel is ~1.2e-4 of its peak, below float32 resolution on a millimetre displacement, so truncation is free. A bounding box answers “which voxels are near centre j” with no data structure and no query overhead; a KD-tree only pays off when centres are dense enough that the boxes overlap heavily, which does not happen at these radii. Passinfto disable.