Landmark transforms¶
When no image registration exists between two template spaces, what you usually have instead is a set of matched landmarks — points a human (or another algorithm) has identified as corresponding. Two classical methods turn those into a warp:
TpsTransform— a thin-plate spline. Interpolates the landmarks exactly and, between them, minimises the integral bending norm: the smoothest warp consistent with the data.MlsTransform— moving least squares (the affine flavour of Schaefer et al. 2006). Gives every point its own affine, solved on the fly from all landmarks weighted by inverse squared distance.
These mirror navis.transforms.TPStransform and
navis.transforms.MovingLeastSquaresTransform, and agree with them to ~1e-14 relative.
import navis_fastcore as fastcore
tps = fastcore.TpsTransform(landmarks_source, landmarks_target)
xf = tps.xform(points) # (N, 3) -> (N, 3)
mls = fastcore.MlsTransform(landmarks_source, landmarks_target)
xf = mls.xform(points)
Landmarks and points may be (N, 3) arrays or DataFrames with x/y/z columns. A single
(3,) point is accepted and returns a (3,) point.
Which one?¶
They give similar but not identical results, and which suits a given pair of spaces is worth checking empirically. As a rule of thumb:
| thin-plate spline | moving least squares | |
|---|---|---|
| Landmarks | reproduced exactly | reproduced exactly |
| Cost | expensive fit, cheap to apply | no fit, ~4× more expensive to apply |
| Far from landmarks | converges to the global affine | converges to the global affine |
| Reusing across many point sets | strongly favours it — fit once | no advantage |
Both fall back to a sensible global affine far outside the landmark hull, exposed as
.matrix_affine.
Direction¶
Neither method has a closed-form inverse, so "backwards" means fitting the other way
round, not inverting. Both spell it -transform:
back = (-tps).xform(points) # refits target -> source; costs another fit
back = (-mls).xform(points) # free - MLS has no fit to redo
For MLS you can also ask at construction: MlsTransform(src, trg, direction="inverse").
No batch_size¶
The navis versions take a batch_size because both reference implementations build an
intermediate sized by points × landmarks. That is what makes them expensive, and for MLS
it is a hard ceiling: molesq allocates (3, M, N) arrays, so 3,400 landmarks at the
default batch size wants ~23 GB — which means
MovingLeastSquaresTransform cannot run at the landmark counts real registrations use.
Here that intermediate never exists. Each output row depends only on its own row of the distance matrix, so the distance and its contribution to the result are fused into one streaming pass over landmarks. Peak memory is the output array, independent of the landmark count, and there is nothing to tune.
Transforming 1M points through a 3,390-landmark spline:
| peak memory | |
|---|---|
navis (already batched) |
5.6 GB |
| fastcore | 231 MB |
Speed¶
Measured on 14 cores against the reference implementations (morphops for TPS, molesq
for MLS — these are what navis calls), 1M points:
500 landmarks¶
| reference | fastcore (1 core) | fastcore (all cores) | |
|---|---|---|---|
| TPS | 0.56 µs/pt | 0.405 µs/pt (1.4×) | 0.039 µs/pt (14×) |
| MLS | 7.03 µs/pt | 1.902 µs/pt (3.7×) | 0.186 µs/pt (38×) |
3,390 landmarks (a real flybrains mirroring registration)¶
| reference | fastcore (1 core) | fastcore (all cores) | |
|---|---|---|---|
| TPS | 3.67 µs/pt | 2.793 µs/pt (1.3×) | 0.270 µs/pt (14×) |
| MLS | out of memory (~23 GB) | — | 1.269 µs/pt |
Worth reading honestly: TPS's win is almost entirely parallelism. Single-threaded we are
only ~1.3× ahead, because cdist plus a BLAS matmul is already good code. What we add is
scaling across cores and the removal of the memory ceiling.
MLS is a genuine algorithmic win — 3.7× before any parallelism — because molesq
expresses the reduction as a chain of einsums over materialised per-landmark arrays.
The one place we are slower: fitting a spline¶
The TPS fit solves an (M+4) square system, so it is cubic in the landmark count. numpy
sends that to hardware LAPACK (on Apple silicon, the AMX coprocessor); we use a blocked LU
in portable Rust, which cannot match it:
| landmarks | numpy (LAPACK) | fastcore |
|---|---|---|
| 500 | 1 ms | 13 ms |
| 1,793 | 29 ms | 174 ms |
| 3,390 | 141 ms | 553 ms |
This is a one-off cost per registration, against an xform that is 14× faster and can
be called thousands of times, so it is very rarely the thing to optimise. If it is, fit with
numpy and hand the coefficients over:
W and A follow the same convention as morphops.tps_coefs and
navis.transforms.TPStransform.W / .A, so a navis transform converts directly:
MLS has no fit at all, so none of this applies to it.
Pickling¶
Both transforms pickle. TpsTransform ships its coefficients rather than just the
landmarks, so unpickling in a multiprocessing worker does not repeat the fit.
API¶
navis_fastcore.TpsTransform
¶
A thin-plate spline transform, fitted to landmark pairs.
The spline interpolates the source landmarks onto the target landmarks exactly, and between them it minimises the integral bending norm - the smoothest warp consistent with the landmarks.
The fit happens once, here, and the transform can then be applied any number of times.
| PARAMETER | DESCRIPTION |
|---|---|
landmarks_source
|
TYPE:
|
landmarks_target
|
TYPE:
|
| ATTRIBUTE | DESCRIPTION |
|---|---|
source |
The source landmarks.
TYPE:
|
W |
Weights of the non-affine part of the spline.
TYPE:
|
A |
Coefficients of the affine part; row 0 is the translation.
TYPE:
|
matrix_affine |
The affine part as a homogeneous matrix - what the spline converges to far from the landmarks.
TYPE:
|
Notes
Unlike navis.transforms.TPStransform there is no batch_size: the distance
matrix is fused into the accumulation rather than built, so peak memory is the output
array regardless of how many points or landmarks are involved.
The fit is cubic in the landmark count and runs through a blocked LU. At a few thousand
landmarks it is somewhat slower than numpy.linalg.solve (which reaches hardware
LAPACK); :meth:from_coefs exists if you would rather fit with numpy and only use this
class to apply the result.
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> src = np.array([[0, 0, 0], [10, 10, 10], [100, 100, 100], [80, 10, 30]])
>>> trg = np.array([[1, 15, 5], [9, 18, 21], [80, 99, 120], [5, 10, 80]])
>>> tr = fastcore.TpsTransform(src, trg)
>>> tr.xform(np.array([[0, 0, 0], [50, 50, 50]]))
array([[ 1. , 15. , 5. ],
[40.55555556, 54. , 65. ]])
>>> # Landmarks are reproduced exactly
>>> np.allclose(tr.xform(src), trg)
True
>>> # Negation refits in the opposite direction
>>> np.allclose((-tr).xform(trg), src)
True
A
property
¶
Coefficients of the affine part, as a (4, 3) array.
W
property
¶
Weights of the non-affine part of the spline, as an (M, 3) array.
matrix_affine
property
¶
The affine part as a (4, 4) homogeneous matrix.
source
property
¶
The source landmarks, as an (M, 3) array.
target
property
¶
The target landmarks, or None if built from coefficients without them.
__neg__()
¶
Fit the spline in the opposite direction.
This is a fresh fit of target onto source, not an inversion of this one - a thin plate spline has no closed-form inverse. Requires the target landmarks.
copy()
¶
Return a copy. Shares the fit rather than repeating it.
from_coefs(landmarks_source, W, A, landmarks_target=None)
classmethod
¶
Build from coefficients fitted elsewhere, skipping the fit.
| PARAMETER | DESCRIPTION |
|---|---|
landmarks_source
|
TYPE:
|
W
|
TYPE:
|
A
|
TYPE:
|
landmarks_target
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
TpsTransform
|
|
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> src = np.array([[0, 0, 0], [10, 10, 10], [100, 100, 100], [80, 10, 30]])
>>> trg = np.array([[1, 15, 5], [9, 18, 21], [80, 99, 120], [5, 10, 80]])
>>> tr = fastcore.TpsTransform(src, trg)
>>> same = fastcore.TpsTransform.from_coefs(tr.source, tr.W, tr.A)
>>> np.allclose(same.xform(src), trg)
True
xform(points, n_cores=None)
¶
Transform points.
| PARAMETER | DESCRIPTION |
|---|---|
points
|
TYPE:
|
n_cores
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
(N, 3) np.ndarray
|
Transformed coordinates. |
navis_fastcore.MlsTransform
¶
A moving least squares transform, defined by landmark pairs.
The affine flavour of the algorithm published in
Schaefer et al. (2006). Unlike a
thin-plate spline there is no fit: every point gets its own affine, solved on the fly
from all landmarks weighted by inverse squared distance. That makes construction free
and :meth:xform the entire cost.
| PARAMETER | DESCRIPTION |
|---|---|
landmarks_source
|
TYPE:
|
landmarks_target
|
TYPE:
|
direction
|
TYPE:
|
Notes
navis.transforms.MovingLeastSquaresTransform (via molesq) builds
(3, M, N)-shaped intermediates, which is why it takes a batch_size - and why in
practice it cannot run at the landmark counts real registrations use: 3400 landmarks at
the default batch size needs ~23 GB. Here everything but the result is a reduction over
landmarks, so peak memory is the output array and the landmark count is unbounded.
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> src = np.array([[0, 0, 0], [10, 10, 10], [100, 100, 100], [80, 10, 30]])
>>> trg = np.array([[1, 15, 5], [9, 18, 21], [80, 99, 120], [5, 10, 80]])
>>> tr = fastcore.MlsTransform(src, trg)
>>> # A point sitting on a landmark maps to its partner
>>> np.allclose(tr.xform(src), trg)
True
>>> # Negation swaps the direction
>>> np.allclose((-tr).xform(trg), src)
True
direction
property
¶
"forward" or "inverse".
matrix_affine
property
¶
The global affine as a (4, 4) homogeneous matrix.
Moving least squares is locally weighted - every point effectively gets its own affine - so there is no single matrix describing it. This is the least-squares fit of source onto target landmarks, which is what the warp converges to far from them.
source
property
¶
The landmarks this transform maps from, honouring direction.
target
property
¶
The landmarks this transform maps to, honouring direction.
__neg__()
¶
Flip the direction.
copy()
¶
Return a copy.
xform(points, n_cores=None, reverse=None)
¶
Transform points.
| PARAMETER | DESCRIPTION |
|---|---|
points
|
TYPE:
|
n_cores
|
TYPE:
|
reverse
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
(N, 3) np.ndarray
|
Transformed coordinates. |