Skip to content

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.
  • MlsTransformmoving 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:

tps = fastcore.TpsTransform.from_coefs(source, W, A)

W and A follow the same convention as morphops.tps_coefs and navis.transforms.TPStransform.W / .A, so a navis transform converts directly:

fast = fastcore.TpsTransform.from_coefs(navis_tps.source, navis_tps.W, navis_tps.A)

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

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
            Source landmarks as x/y/z coordinates. A pandas ``DataFrame`` with
            x/y/z columns is also accepted.

TYPE: (M, 3) array-like

landmarks_target
            Target landmarks, one per source landmark.

TYPE: (M, 3) array-like

ATTRIBUTE DESCRIPTION
source

The source landmarks.

TYPE: (M, 3) np.ndarray

W

Weights of the non-affine part of the spline.

TYPE: (M, 3) np.ndarray

A

Coefficients of the affine part; row 0 is the translation.

TYPE: (4, 3) np.ndarray

matrix_affine

The affine part as a homogeneous matrix - what the spline converges to far from the landmarks.

TYPE: (4, 4) np.ndarray

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

Coefficients of the affine part, as a (4, 3) array.

Weights of the non-affine part of the spline, as an (M, 3) array.

The affine part as a (4, 4) homogeneous matrix.

The source landmarks, as an (M, 3) array.

The target landmarks, or None if built from coefficients without them.

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.

Return a copy. Shares the fit rather than repeating it.

Build from coefficients fitted elsewhere, skipping the fit.

PARAMETER DESCRIPTION
landmarks_source
            The source landmarks the coefficients belong to.

TYPE: (M, 3) array-like

W
            Weights of the non-affine part.

TYPE: (M, 3) array-like

A
            Coefficients of the affine part.

TYPE: (4, 3) array-like

landmarks_target
            The target landmarks. Not needed to transform points, but
            without them :meth:`__neg__` cannot refit the inverse.

TYPE: (M, 3) array-like DEFAULT: None

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

Transform points.

PARAMETER DESCRIPTION
points
    Coordinates to transform. A single ``(3,)`` point is accepted and
    returns a ``(3,)`` point; a ``DataFrame`` with x/y/z columns also works.

TYPE: (N, 3) array-like

n_cores
    Number of threads. ``None`` (default) uses all cores.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
(N, 3) np.ndarray

Transformed coordinates.

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
            Source landmarks as x/y/z coordinates. A pandas ``DataFrame`` with
            x/y/z columns is also accepted.

TYPE: (M, 3) array-like

landmarks_target
            Target landmarks, one per source landmark.

TYPE: (M, 3) array-like

direction
            ``"inverse"`` treats the target as the source and vice versa.
            Note this fits the warp in the opposite direction; it is not an
            exact inverse, which moving least squares does not have.

TYPE: "forward" | "inverse" DEFAULT: 'forward'

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

"forward" or "inverse".

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.

The landmarks this transform maps from, honouring direction.

The landmarks this transform maps to, honouring direction.

Flip the direction.

Return a copy.

Transform points.

PARAMETER DESCRIPTION
points
    Coordinates to transform. A single ``(3,)`` point is accepted and
    returns a ``(3,)`` point; a ``DataFrame`` with x/y/z columns also works.

TYPE: (N, 3) array-like

n_cores
    Number of threads. ``None`` (default) uses all cores.

TYPE: int DEFAULT: None

reverse
    Override this transform's ``direction`` for this call only:
    ``False`` maps source -> target, ``True`` target -> source.
    ``None`` (default) uses ``direction``. The underlying fit is
    direction-agnostic, so this saves rebuilding the object just
    to run it the other way.

TYPE: bool DEFAULT: None

RETURNS DESCRIPTION
(N, 3) np.ndarray

Transformed coordinates.