Skip to content

Registration between visits

Relate two frames of reference so you can move points, boxes and predictions between them.

Two visits are two frames. A transform relates them, and — because both visits live in the same file — it is an object in that file rather than a convention between two filenames.

Write one

A transform relates two frames, and a timepoint has a frame only because its grids say so. Set frame_uid when you write the grids, and use those same values on the transform:

w.add_grid("ct_tp0", shape=..., spacing=..., timepoint="tp0",
           frame_uid="pseudo:frame-tp0")
w.add_grid("ct_tp1", shape=..., spacing=..., timepoint="tp1",
           frame_uid="pseudo:frame-tp1")

w.add_transform("tp0_to_tp1", kind="affine",
                from_frame="pseudo:frame-tp0", to_frame="pseudo:frame-tp1",
                matrix=matrix)

Without frame_uid on the grids, a timepoint resolves to no frames at all and transform_between("tp0", "tp1") returns None however carefully the transform was written — the transform names endpoints nothing else refers to.

Kinds: identity, affine, displacement, bspline, composite.

t = s.transform_between("tp0", "tp1")
t.kind, t.from_frame, t.to_frame, t.is_invertible
t.transform_points(points)         # world -> world, in mm
t.inverse()                        # the *stored* inverse, when the file has one

is_invertible says the mapping is invertible; inverse() returns another transform only when the file stores one under inverse_id. An affine computes its own inverse (AffineTransform.inverse_matrix(), inverse_points()); a displacement field does not, which is why the distinction exists.

transform_between searches the frame graph. It composes chains, uses an inverse where one can be evaluated — not merely where a transform declares invertible=True; see below — and returns None when no path exists. It never fabricates a transform to make a call succeed.

from medh5.transforms.apply import target_registration_error
target_registration_error(t, fixed_points, moving_points)   # {"mean", "max", ...}

A registration with no landmark pair has no TRE, and the file says so rather than reporting zero.

When there is no path

transform_between returns None rather than raising, and rather than composing something plausible. But None is two different answers, and they call for opposite responses:

  • The frames are already the same — nothing to apply, because the arrays are co-registered by construction. Two images acquired at one visit on one scanner usually land here.
  • No path exists — the frames are unrelated in this file, and any downstream number that treats them as comparable is invented.

The return value cannot tell them apart, so compare the frames first:

t = s.transform_between("ct", "pet")
if t is None:
    if s.grids["ct"].frame_uid == s.grids["pet"].frame_uid:
        pass       # already aligned — use the arrays directly
    else:
        ...        # register them yourself, or work per-frame

For timepoints rather than grids, frames_of_timepoint gives the frames a visit spans; a timepoint whose grids share a frame with the other visit's needs no transform either.

What decides whether a path resolves depends on the direction.

Forward, along the way the transforms were written, needs only that they exist and that their from_frame and to_frame name frames that exist. No link has to declare anything about inversion.

Backwards, across any link traversed against its direction, needs an inverse that can be evaluated — analytic for an affine or identity, otherwise a stored inverse_id. invertible=True on its own is not enough; see below.

When to store an inverse

invertible=True is the file's claim. What resolution needs is an inverse it can actually evaluate, and the two are not the same set.

A forward chain resolves whatever you declare. Going the other way, an affine or identity is inverted analytically, so nothing needs storing. A displacement or B-spline is not — inverting one is an optimisation, not an algebraic step — so invertible=True on a displacement field contributes no reverse edge at all, and the reverse direction comes back None:

Transform invertible=True, no inverse_id forward reverse
affine analytic inverse resolves resolves
displacement / B-spline claim only resolves None

So if you need the reverse direction for a non-affine registration, compute it at write time and store it as its own transform — a second add_transform with from_frame and to_frame swapped. It is then an ordinary forward edge, and both directions resolve:

w.add_transform("tp0_to_tp1", kind="displacement", field=fwd,
                from_frame="frame-tp0", to_frame="frame-tp1", field_grid="ct_tp0")
w.add_transform("tp1_to_tp0", kind="displacement", field=back,
                from_frame="frame-tp1", to_frame="frame-tp0", field_grid="ct_tp1")

Linking them with inverse_id is fine, and says more. Declare each as the other's inverse and both directions still resolve, each to its own stored transform: a transform and the stored inverse its inverse_id names are one route, not two (§10.1). The link is what lets t.inverse() hand you the stored inverse of a transform you already hold, and what the validator checks for consistency (E505: the inverse must map the other way, and a declaration must be mutual where both sides make one).

w.add_transform("tp0_to_tp1", kind="displacement", field=fwd,
                from_frame="frame-tp0", to_frame="frame-tp1", field_grid="ct_tp0",
                inverse_id="tp1_to_tp0")
w.add_transform("tp1_to_tp0", kind="displacement", field=back,
                from_frame="frame-tp1", to_frame="frame-tp0", field_grid="ct_tp1",
                inverse_id="tp0_to_tp1")
what you write forward reverse
invertible=True only resolves None
two independent transforms resolves resolves
two transforms linked with inverse_id resolves resolves

Before 1.4.2 the linked form raised E501 in both directions: the resolver counted the forward transform's delegated inverse as a second route beside the stored one, and refused to choose between them.