Skip to content

Registration of two sessions from the same subject

This example shows how to align two power Doppler images acquired from the same subject in different sessions. We use register_volume with a rigid transform, which is appropriate when the imaged anatomy is the same but the probe placement differs slightly between the two recordings.

We pick two angio acquisitions from the Cybis Pereira 2026 dataset using fetch_cybis_pereira_2026: subject rat75, slice slice32, recorded on consecutive days (sessions 20220523 and 20220524).

Fetch and load both recordings

Each recording is a single power Doppler image of one slice. We convert it to decibels for both display and registration, which is usually more stable on the log-compressed dynamic range.

from pathlib import Path

import matplotlib as mpl
import matplotlib.pyplot as plt
import xarray as xr

import confusius as cf
from confusius.datasets import fetch_cybis_pereira_2026
from confusius.registration import register_volume

# Adapt background color to the current Matplotlib style.
bg_color = mpl.colors.to_hex(mpl.rcParams["figure.facecolor"])

xr.set_options(display_expand_data=False)

sessions = ["20220523", "20220524"]
acq = "slice32"
bids_root = fetch_cybis_pereira_2026(
    datasets="rawdata",
    subjects="rat75",
    sessions=sessions,
    datatypes="angio",
    acqs=acq,
)


def _load_angio_for_registration(session: str) -> xr.DataArray:
    """Load an angio acquisition and scale its intensity for registration."""
    path = (
        Path(bids_root)
        / "sub-rat75"
        / f"ses-{session}"
        / "angio"
        / f"sub-rat75_ses-{session}_acq-{acq}_rec-minframe2d_pwd.nii.gz"
    )
    return cf.load(path).fusi.scale.db().compute()


fixed = _load_angio_for_registration(sessions[0])
moving = _load_angio_for_registration(sessions[1])

fixed
/tmp/ipykernel_3966/3805360382.py:36: UserWarning: fUSI-BIDS validation warning:
  - ClutterFilters.str: Input should be a valid string
  - ClutterFilters.list[str].0: Input should be a valid string
  return cf.load(path).fusi.scale.db().compute()
/tmp/ipykernel_3966/3805360382.py:36: UserWarning: fUSI-BIDS validation warning:
  - ClutterFilters.str: Input should be a valid string
  - ClutterFilters.list[str].0: Input should be a valid string
  return cf.load(path).fusi.scale.db().compute()
moving

Inspect the misalignment before registration

The two images share anatomy but live on slightly different grids because the probe was re-positioned between sessions. We visualise the alignment with plot_composite, which resamples moving onto fixed's grid and draws the two as a red/cyan composite: matched anatomy appear in white, while any residual red/cyan fringe reveals the displacement that register_volume will correct.

One subtlety: the two angio recordings sit at slightly different z coordinates in physical space, so resampling moving onto fixed's grid would place every voxel outside the fixed slab and return an empty image. We force moving.z to match fixed.z before plotting so the overlay actually has something to show. The remaining in-plane misalignment is what we're after.

moving.coords["z"] = fixed.z
cf.plotting.plot_composite(fixed, moving, bg_color=bg_color)
<confusius.plotting.image.VolumePlotter at 0x7fcb7114ecf0>

Example output from cell 6, image 1Example output from cell 6, image 1

Run the registration

A rigid transform captures the rotation and translation difference between the two sessions. register_volume returns three values:

  1. the moving image (only aligned to the fixed grid if resample=True is used);
  2. the rigid transform matrix that maps fixed-physical coordinates to moving-physical coordinates;
  3. a RegistrationDiagnostics dataclass holding the per-iteration metric values and the optimizer stop condition, which we use below to plot the convergence curve.

Watch registration progress live

Pass show_progress=True to register_volume to follow the optimization in real time. A live matplotlib window opens during the call and updates at every iteration with both the similarity-metric curve and a fixed/moving composite overlay. It is the fastest way to tell whether the optimizer is making progress, stuck in a local minimum, or diverging—and to decide which arguments to tweak from the warning above.

Registration is sensitive to its arguments

The result depends heavily on the choice of transform_type, metric, learning_rate, number_of_iterations, convergence_window_size, centering_initialization, and the multi-resolution settings (use_multi_resolution, shrink_factors, smoothing_sigmas). The values used in this example were empirically found to work well in this case, but you should definitely try different arguments (start with the default!) if the result is not satisfactory—inspect the RegistrationDiagnostics convergence curve and the post-registration overlay, and sweep these arguments until you get a stable, well-converged result.

registered, transform, diagnostics = register_volume(
    moving=moving,
    fixed=fixed,
    transform_type="rigid",
    show_progress=True,
    number_of_iterations=500,
    convergence_window_size=100,
    learning_rate=30,
)

print(f"Iterations: {diagnostics.n_iterations}")
print(f"Final metric: {diagnostics.final_metric_value:.4f}")
print(f"Stop condition: {diagnostics.stop_condition}")
transform

Example output from cell 8, image 0Example output from cell 8, image 0

Iterations: 363
Final metric: -0.8102
Stop condition: GradientDescentOptimizerv4Template: Convergence checker passed at iteration 363.
array([[ 1.00000000e+00, -1.50275483e-23, -9.15836291e-24,
        -1.24687096e-30],
       [ 1.44710827e-23,  9.98316682e-01, -5.79982953e-02,
        -4.09198007e+01],
       [ 1.00145187e-23,  5.79982953e-02,  9.98316682e-01,
         1.67666294e+02],
       [ 0.00000000e+00,  0.00000000e+00,  0.00000000e+00,
         1.00000000e+00]])

Check the alignment after registration

Plotting the same fixed/moving overlay before and after registration makes the correction obvious: the red/cyan fringe in the first panel should be replaced by a more uniformly desaturated grey in the second.

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
fig.patch.set_facecolor(bg_color)

for ax, moving_view, title in [
    (axes[0], moving, "Before"),
    (axes[1], registered, "After"),
]:
    cf.plotting.plot_composite(fixed, moving_view, axes=ax, bg_color=bg_color)
    ax.set_title(title)

_ = fig.suptitle("Fixed (red) / moving (cyan)")

Example output from cell 10, image 0Example output from cell 10, image 0

Inspect convergence with the registration diagnostics

diagnostics.metric_values holds the optimizer's similarity-metric value at each iteration. With the default metric="correlation", SimpleITK minimizes the negative normalized cross-correlation, so a lower (more negative) value means a better fit. The curve typically drops sharply at the start and then plateaus.

fig, ax = plt.subplots(figsize=(7, 3))
fig.patch.set_facecolor(bg_color)
ax.plot(diagnostics.metric_values, color="#d93a54")
ax.set_xlabel("Iteration")
ax.set_ylabel(f"Similarity metric ({diagnostics.metric})")
_ = ax.set_title(diagnostics.stop_condition)

Example output from cell 12, image 0Example output from cell 12, image 0

The resulting rigid transform is encoded in physical units and can be reused, composed with other transforms, or applied to additional volumes from the same session with resample_volume.


Total running time: 80.2 s

Launch in Binder Download .py Download .ipynb