Skip to content

Elastix transforms

Elastix registrations bridge the Drosophila VNC and whole-CNS template spaces — FANC → JRCVNC2018F, BANC → JRC2018F, and friends. A TransformParameters file holds a linear transform, a cubic B-spline warp, or a chain of both.

ElastixTransform reads one and applies it to points. Elastix itself does not need to be installed: unlike navis, this does not shell out to the transformix binary — so there is no subprocess, no temporary directory, no LD_LIBRARY_PATH to set, and no copy_files to marshal.

import navis_fastcore as fastcore

xf = fastcore.ElastixTransform("TransformParameters.FixedFANC.txt")

out  = xf.xform(points)          # (N, 3) -> (N, 3)
back = xf.xform_inv(out)         # ...and back, which Elastix cannot do at all

A TransformParameters file is already a chain: its InitialTransformParametersFileName is followed recursively, resolved relative to that file's own directory. BANC's is four deep (3_Bspline_fine → 2_Bspline_coarse → 1_affine → 0_manual_affine) and loads from the outermost file alone.

Elastix records that name as it was on the machine that ran the registration — routinely an absolute path like /home/someone/scratch/TransformParameters.0.txt, which of course is not there when you receive the files. transformix simply fails. If the recorded path does not resolve, we look for its basename in the naming file's own directory instead, which is what navis's copy_files achieves by copying everything into one place. A path that does resolve still wins, so this can only rescue a lookup that would otherwise have failed.

Supported transform types: AffineTransform, TranslationTransform, EulerTransform, SimilarityTransform, and BSplineTransform / RecursiveBSplineTransform. Both HowToCombineTransforms modes (Compose and Add) work going forwards.

Chaining, and direction

Pass a list to compose transforms, applied left to right.

Direction is chosen per call, not per object. The transform you load holds only the parse, so one instance serves every direction. That is worth caring about here: BANC's BANC_to_template.txt is 56 MB, and re-reading it just to walk it backwards would be absurd.

chain = fastcore.ElastixTransform(["A_to_B.txt", "B_to_C.txt"])   # A -> B -> C

chain.xform(points)                            # forwards
chain.xform_inv(points)                        # the whole composition, backwards
chain.xform(points, invert=[False, True])      # hop 0 forwards, hop 1 backwards

Those last two are not the same knob, and the difference bites on a chain. xform_inv inverts the whole composition — it reverses the order and flips every hop. invert flips hops in place, keeping the order. For a single transform they agree; for a chain they do not, and only invert can express a mixed-direction traversal — which is exactly what a bridging graph hands you, since an edge may be stored in either direction:

# A -> B -> C, where the second transform happens to be stored as C->B
chain = fastcore.ElastixTransform(["A_to_B.txt", "C_to_B.txt"])
chain.xform(points, invert=[False, True])

Outside the grid, points come back unchanged

A B-spline warp is only defined over its control-point grid. Outside it, Elastix returns the point untouched — the identity, never a failure. That is the exact opposite of CMTK, which reports such points as FAILED (and which CmtkRegistration surfaces as NaN).

We reproduce Elastix's behaviour by default, because swapping fastcore in must not silently change anyone's existing results. But it is worth knowing what that default costs you: it is silent. A neuron straddling the edge of the registered volume comes back partly transformed and looks perfectly fine.

faithful = xf.xform(points)                          # unchanged outside the grid, like transformix
strict   = xf.xform(points, out_of_bounds="nan")     # NaN instead, so you can see the boundary

The inverse

Elastix has no inverse — transformix only goes forwards. That is precisely why navis-flybrains ships two separate registration files per brain pair and registers four one-way BANC edges.

xform_inv provides one. Linear steps are inverted exactly; each B-spline is solved per point by damped Gauss-Newton against the analytic Jacobian.

What is guaranteed is forward-consistency: xform(xform_inv(y)) == y.

What is not guaranteed is that xform_inv(xform(p)) == p. A B-spline warp need not be injective: because the forward map warps inside the grid and is the identity outside it, it is discontinuous at the boundary, and a strongly deforming registration also folds. Several points can map to the same place, and no inverse can recover which one you meant. Pass initial_guess if you know which preimage you want — it breaks the tie. Points with no recoverable preimage at all come back as NaN.

Measured on the six real B-spline registrations navis-flybrains ships, over points spanning each fixed image, f(xform_inv(y)) == y holds to ~1e-13 and four of the six invert with zero failures. The exception is BANC's BANC_to_template.txt, whose median displacement is 163 µm — ten grid cells — and which folds: ~0.5% of its points have no recoverable preimage. (Nothing needs its inverse in practice: flybrains ships a dedicated reverse file, which inverts cleanly.)

Accuracy

Validated against the transformix binary itself (Elastix 5.2.0) on 5000 points per file, spanning each control-point grid and well beyond it, for all seven registration files navis-flybrains ships — including both four-deep BANC chains. Agreement is 5e-7, which is transformix's own print precision, and the set of points it leaves untouched is reproduced exactly.

Speed

Measured against transformix on the same machine (14 cores), FANC's warp:

transformix fastcore (1 core) fastcore (all cores)
Forward, 100k points 955 ms 5.4 ms (177×) 1.2 ms (796×)
Inverse, 100k points not possible 1975 ms 179 ms

transformix is a separate process that writes the points to a temp file, spawns, and parses text back — so it also pays a fixed ~0.9 s per call regardless of how few points you give it. Parsing the registration costs us 38 ms once; every subsequent transform is free of it.

Asking whether a file inverts, without parsing it

A transform is invertible unless some step in its chain combines via Add. That fact lives in one short key — but the key sits after the coefficient array, which is 56 MB in BANC's warp, so reading it honestly used to mean parsing the whole file.

probe_elastix_invertible walks the same chain and skips only the numbers:

fastcore.probe_elastix_invertible("TransformParameters.FixedFANC.txt")   # -> True
full parse probe
All 13 registrations navis-flybrains ships 648 ms 31 ms (21×)
BANC's BANC_to_template.txt (54 MB) 409 ms 19.6 ms (21×)

That is the difference between a bridging graph that can afford to label its edges honestly and one that has to guess per backend. It raises rather than returning True for anything that would not load at all — missing, not elastix, unsupported kind, binary parameters, circular chain — so a True is a promise.

API

An Elastix transform - or a chain of them - ready to apply to points.

The transform is parsed once, on construction, and can then be applied any number of times. A TransformParameters file is already a chain: its InitialTransformParametersFileName is followed recursively (resolved relative to that file's own directory), so a four-deep affine -> affine -> B-spline -> B-spline stack loads from its outermost file alone.

Unlike navis, this does not shell out to the transformix binary: Elastix does not need to be installed, and there is no subprocess, no temporary directory and no copy_files dance. Results match transformix to ~5e-7 - its own print precision.

It also gives you an inverse, which Elastix has no way to compute. See :meth:~navis_fastcore.ElastixTransform.xform_inv.

PARAMETER DESCRIPTION
path
    Path to a ``TransformParameters.*.txt`` file. Pass a list to build a chain,
    applied in order: ``points -> path[0] -> path[1] -> ... -> output``.

TYPE: str | pathlib.Path | list thereof

Direction

direction

ATTRIBUTE DESCRIPTION
affine

The matrix of the first linear step of the first transform, if it has one; None otherwise.

TYPE: (4, 4) np.ndarray

kinds

The resolved step kinds of each transform, initial first - e.g. [["linear", "bspline"]].

TYPE: list of list of str

grid_size

Control-point grid size of every B-spline in the chain.

TYPE: (k, 3) np.ndarray

grid_spacing

Control-point spacing of every B-spline in the chain.

TYPE: (k, 3) np.ndarray

grid_origin

Control-point grid origin of every B-spline in the chain.

TYPE: (k, 3) np.ndarray

Examples:

>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> xf = fastcore.ElastixTransform("TransformParameters.FixedFANC.txt")
>>> pts = np.array([[50.0, 50.0, 50.0], [100.0, 200.0, 40.0]])
>>> out = xf.xform(pts)
>>> back = xf.xform_inv(out)
>>> np.allclose(xf.xform(back), out, atol=1e-3)
True

The (4, 4) matrix of the first linear step, or None.

Control-point grid origin of every B-spline in the chain, (k, 3).

Control-point grid size of every B-spline in the chain, (k, 3).

Control-point spacing of every B-spline in the chain, (k, 3).

Whether xform_inv can run. False only for a chain carrying an Add step.

The step kinds of each transform in the chain, initial first.

The files this transform was loaded from.

Transform points forward.

PARAMETER DESCRIPTION
points
            Coordinates to transform.

TYPE: (N, 3) array | (3,) array

out_of_bounds
            What to do with points that fall outside a B-spline's
            control-point grid. ``"identity"`` (the default) returns them
            **unchanged**, which is exactly what ``transformix`` does.
            ``"nan"`` returns ``NaN`` instead.

            The default is silent by nature: a neuron straddling the grid
            edge comes back partly transformed and looks perfectly fine.
            Use ``"nan"`` when you would rather see the boundary than trust
            it.

TYPE: "identity" | "nan" DEFAULT: 'identity'

invert
            Traverse a transform backwards. A single bool applies to every
            hop; pass a list to set them per transform. This is what you need
            when routing through a bridging graph, where an edge may be walked
            in either direction.

            Note this is **not** the same as :meth:`xform_inv`, which inverts
            the whole composition (reversing the order *and* flipping every
            hop). For a single transform the two agree; for a chain they do
            not, and only ``invert`` can express a mixed-direction traversal.

TYPE: bool | list of bool DEFAULT: False

n_cores
            Cap the thread pool. ``None`` uses all cores.

TYPE: int DEFAULT: None

progress
            Show a progress bar.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
(N, 3) np.ndarray

Transformed coordinates. A (3,) input gives a (3,) output.

Transform points backwards - something Elastix itself cannot do.

Linear steps are inverted exactly. Each B-spline warp has no closed-form inverse and is solved per point by damped Gauss-Newton against the analytic Jacobian.

What is guaranteed is forward-consistency: xform(xform_inv(y)) == y, to within accuracy. What is not guaranteed is that xform_inv(xform(p)) == p, because a B-spline warp need not be injective - a strongly folded registration maps several points to the same place, and no inverse can recover which one you meant. Points with no preimage at all come back as NaN.

PARAMETER DESCRIPTION
points
            Coordinates to transform.

TYPE: (N, 3) array | (3,) array

out_of_bounds
            See :meth:`~navis_fastcore.ElastixTransform.xform`.

TYPE: "identity" | "nan" DEFAULT: 'identity'

initial_guess
            Starting points for the solver. Rarely needed: it seeds itself
            with a fixed-point iteration, which is what makes it converge
            even where the deformation is large.

TYPE: (N, 3) array DEFAULT: None

max_iter
            Solver budget per point.

TYPE: int DEFAULT: 50

seed_iter
            Rounds of the fixed-point pre-seed. Zero starts the solver at
            the target, which fails wherever the deformation is large.

TYPE: int DEFAULT: 8

tolerance
            Step-size convergence threshold.

TYPE: float DEFAULT: 1e-09

accuracy
            Accept a solution only if its residual is within this of the
            target, in world units. Otherwise the row is ``NaN``.

TYPE: float DEFAULT: 0.001

lattice_points
            Size of the global seed lattice - the last-resort start for the
            few points the cheap seeds fail on. Built once per call, and only
            consulted by points that have already failed, so it costs almost
            nothing on a well-behaved registration. Set to 0 to disable.

TYPE: int DEFAULT: 16000

invert
            The same per-hop flags as on
            :meth:`~navis_fastcore.ElastixTransform.xform`, composed with this
            whole-chain inversion: hop ``i`` runs *forwards* here exactly when
            ``invert[i]`` is set.

TYPE: bool | list of bool DEFAULT: False

n_cores
            Cap the thread pool. ``None`` uses all cores.

TYPE: int DEFAULT: None

progress
            Show a progress bar.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
(N, 3) np.ndarray

Coordinates in the source space. Rows with no preimage are NaN.

Load one or more Elastix transforms.

A convenience wrapper around :class:~navis_fastcore.ElastixTransform.

PARAMETER DESCRIPTION
path
    Path to a ``TransformParameters.*.txt`` file, or several to chain.

TYPE: str | pathlib.Path | list thereof

RETURNS DESCRIPTION
ElastixTransform

Can this Elastix transform be inverted? Answered without reading its coefficients.

A transform is not invertible exactly when some step in its chain combines via Add: T(x) = T_initial(x) + T_this(x) - x does not decompose into invertible hops. That fact lives in one short key - but the key sits after a coefficient array that runs to 56 MB in BANC's warp, so answering it honestly used to mean parsing the whole file.

This walks the same chain and applies the same validation, skipping only the numbers. Across the registrations navis-flybrains ships it is ~20x faster than a full parse (31 ms for all of them, against 648 ms), and ~200x on the largest.

Use it when you must label many files up front and cannot afford to parse them - building a bridging graph, say, where each edge needs to know whether it can be walked backwards.

PARAMETER DESCRIPTION
path
Path to a ``TransformParameters.*.txt`` file. Its initial-transform chain is followed,
however deep.

TYPE: str | pathlib.Path

RETURNS DESCRIPTION
bool

Whether :meth:~navis_fastcore.ElastixTransform.xform_inv would work on it.

RAISES DESCRIPTION
ValueError

If the file would not load at all: missing, not an Elastix transform, an unsupported transform kind, binary parameters, or a circular chain. So True is a promise, not an optimistic guess.

Examples:

>>> import navis_fastcore as fastcore
>>> fastcore.probe_elastix_invertible("TransformParameters.FixedFANC.txt")
True