longitudinal/REGISTRATION_BENCHMARK.md
Furen Xiao 16b9828d15 Enhance registration pipelines with CT clipping and NiftyReg Docker support
- Added `--clip-ct LO HI` argument to clip CT intensities before registration and scoring, improving A/B comparisons.
- Implemented `_clip_image` function to handle intensity clipping of images.
- Updated registration functions to utilize CT clipping where beneficial, specifically in `register_niftyreg` and `register_synthmorph`.
- Enhanced `fixed_moving` function to ensure CT is always treated as the fixed image.
- Introduced Docker support for NiftyReg v2.1.1, including a Dockerfile for building the image.
- Updated documentation and help messages to reflect new features and usage instructions.
- Adjusted evaluation criteria for registration quality to account for intensity clipping.
2026-09-29 17:31:24 +08:00

36 KiB
Raw Blame History

Registration Pipeline Benchmark — CT → T1c (M6)

Benchmarks six rigid, cross-modality registration pipelines that align a head-and-neck CT to a tightly-cropped T1c MRI so the GTV can be warped onto the T1c (MR) native grid. This is the registration step of scripts/02_build_m6_dataset.py. The pairs are hard: different modality (CT Hounsfield vs MR intensity) and the patient is repositioned between the CT and MR sessions, so the inter-session head tilt can reach ~30°.

Unified contract

Every pipeline returns a SimpleITK transform T such that

sitk.Resample(ct, t1c, T, sitk.sitkLinear)              # CT  onto the MR grid
sitk.Resample(tv,  t1c, T, sitk.sitkNearestNeighbor, 0) # GTV onto the MR grid

places the CT / GTV concentric with the MR head. In Resample's destination→source convention this T maps T1c → CT. Shared rules for every pipeline:

  • No mask, no crop / pad: registration runs on the full input volumes exactly as given (no FixedMask / MovingMask, no head-box resampling, no padding).
  • CT is always the fixed image (moving = T1c); when both inputs are MR the first argument is kept as fixed.

All pipelines also use each toolkit's native automatic transform initialization (no manual centroid offset): elastix / impact use AutomaticTransformInitialization (CenterOfGravity), antspyx its default center-of-mass alignment, simpleitk starts from identity, niftyreg (reg_aladin) initializes from the image centres, synthmorph needs none. One consequence of the no-mask rule: the IMPACT metric only evaluates samples that map inside the moving buffer, so with the CT (head-neck, ~300×300×251 mm) as the fixed image and the tight T1c head crop (~220×220×150 mm) as moving, most of the CT buffer maps outside the T1c buffer and impact aborts on the M6 pairs ("too many samples map outside moving image buffer", ~14% of samples in-buffer at the auto-init) — see the IMPACT section. The comparison is on each toolkit's native optimizer / metric / global-search capability.

The six pipelines

Listed alphabetically. All register on the full volumes (no head box, no mask, no crop / pad) and use each toolkit's native automatic transform initialization (no manual centroid offset): elastix / impact use elastix's AutomaticTransformInitialization (CenterOfGravity), antspyx uses its default center-of-mass alignment, simpleitk starts from identity, niftyreg initializes from the image centres, and synthmorph needs none (global deep method). All use fixed = CT, moving = T1c (shared rule above; when both inputs are MR the first argument is fixed). niftyreg and synthmorph clip the CT to [0, 80] HU before registration (per the CT-clipping A/B below; clip_ct=None disables it).

# pipeline fixed / moving init metric transform status in this env
1 antspyx CT / T1c auto COM Mattes MI (multi-res) ANTs Rigid ✅ ran (main env; stochastic MI subsampling)
2 elastix CT / T1c auto CenterOfGravity Mattes MI (32 bins) EulerTransform rigid (5.x), ASGD, 3 resolutions ✅ ran (main env, in-process python API — no CLI)
3 impact CT / T1c auto CenterOfGravity IMPACT semantic (TotalSegmentator TS/M730.pt, L2, Jacobian/GPU) EulerTransform rigid, ASGD, 4 scales ⛔ aborts on M6 (no-mask rule; see IMPACT section)
4 niftyreg CT / T1c image centres NCC block matching + trimmed least squares (symmetric) reg_aladin -rigOnly, 3-level pyramid ✅ ran (Docker niftyreg:2.1.1-ubuntu, GPU -platf 1)
5 simpleitk CT / T1c identity Mattes MI (32 bins) Euler3D rigid, pyramid [4,2,1] ✅ ran
6 synthmorph CT / T1c none (global) SynthMorph anatomy-aware contrast-invariant (deep, pretrained) 6-DOF rigid matrix ✅ ran (main env, Docker + GPU)

Benchmark budget (best-results settings): elastix, impact and simpleitk run with MaximumNumberOfIterations = 2000 (per resolution / per pyramid level), with the sampler and metric left at their defaults (NumberOfSpatialSamples 5000, NumberOfHistogramBins 32). antspyx, niftyreg and synthmorph keep their native defaults.

simpleitk has two budgets: --simpleitk-mode benchmark (single pyramid, identity start — the default, comparable to the other tools) and --simpleitk-mode production (the full multi-start tilt grid from 02_build_m6_dataset.py, now also on the full volumes per the no-crop rule, ~20× slower, the most robust but not used for the head-to-head).

Test cases

First 10 labeled M6 cases (--n-cases 10 --sample first), reproducible. 9 have a complete CT; 1 (m6_22GD5VL2_20230724) has an incomplete CT head and is reported as incomplete-CT for every pipeline (a data issue, not a pipeline failure).

m6_22GD5VL2_20230724  (incomplete CT)
m6_22IRRKU6_20240115  m6_23XIMO4M_20230222  m6_24V62QQM_20230223
m6_24V62QQM_20231017  m6_24V62QQM_20240820  m6_26TYQI3C_20221027
m6_2BBN5XA4_20250411  m6_2C4TKI6H_20240506  m6_2CDNN7BH_20240102

Metrics (identical for every pipeline)

The headline alignment score is mi_ct_mr — the registration's achieved Mattes MI, read off as a standalone score:

  • mi_ct_mr — mean Mattes MI (nats) between the CT inverse-transformed into MR coordinates (the warped CT on the T1c grid) and the original MR, restricted to the MR head and each image percentile-binned independently (SimpleITK Mattes-MI style). Higher = the warped-CT and MR intensities are better in-sync = better aligned; it collapses toward 0 when the CT is rotated/translated away from the MR. Tables and plots report this (plus registration time and GTV-in-CT coverage).
  • tv_in_ct_frac — fraction of the dilated warped GTV inside the warped-CT head tissue (the slab must straddle the lesion).
  • reg_wall_s — wall-clock registration time (excludes I/O + scoring).

The production geometric QA is still computed per case and kept in the JSON (it drives dataset acceptance in 02_build_m6_dataset.py, but its summary numbers were found to correlate poorly with perceived alignment, so they are no longer reported in the tables/plots):

  • skull_ring_gap_mm — march rays from the MR head center; the gap between the MR head silhouette boundary and the warped-CT skull ring (p97 of positive CT) at mid and low levels, plus the angular std. mid measures translational concentricity; std measures rotational concentricity.
  • tv_nvox — warped GTV volume.
  • accepted — tv_nvox > 0 and tv_in_ct_frac ≥ 0.5 and aligned (the production acceptance rule; aligned ⇔ |mid| ≤ 20 mm and std ≤ 12 mm — a small negative mid is fine: on full-volume registration the CT skull ring can land slightly inside the MR silhouette, which is equally concentric).

Results (10 cases, no clip)

Alignment is scored by the achieved Mattes MI (nats, warped-CT ~ MR over the MR head; higher = better) with the unclipped CT, so all rows are comparable on the same score scale. Full volumes, no mask / crop / pad, fixed = CT. niftyreg and synthmorph clip the CT to [0, 80] HU before registration (per the CT-clipping A/B below; clip_ct=None disables it); antspyx, elastix and simpleitk register the CT as-is. niftyreg is the v2.1.1 Docker build on the GPU (-platf 1); impact cannot run on these pairs under the no-mask rule (see the IMPACT section).

pipeline toolkit registered s/reg (med) tv-in-CT MI (med/mean, nats)
antspyx ANTsPy 0.6.3 9 36.6 0.93 0.653 / 0.644
elastix ITK-Elastix 0.25.4 9 6.0 0.93 0.517 / 0.520
impact Docker Elastix 5.2 + IMPACT 0 - - - (aborts, no mask)
niftyreg NiftyReg 2.1.1 (reg_aladin, Docker + CUDA) 9 20.8 0.93 0.292 / 0.338
simpleitk SimpleITK 2.5.6 9 10.6 0.93 0.651 / 0.641
synthmorph Docker SynthMorph (TensorFlow) 9 24.5 0.93 0.277 / 0.328

Per-case MI (nats, unclipped score; bold = best for the case):

case antspyx elastix impact niftyreg simpleitk synthmorph
m6_22GD5VL2_20230724 incomplete-CT incomplete-CT incomplete-CT incomplete-CT incomplete-CT incomplete-CT
m6_22IRRKU6_20240115 0.649 0.472 abort 0.292 0.642 0.277
m6_23XIMO4M_20230222 0.655 0.416 abort 0.632 0.652 0.616
m6_24V62QQM_20230223 0.704 0.701 abort 0.260 0.663 0.256
m6_24V62QQM_20231017 0.601 0.590 abort 0.493 0.602 0.484
m6_24V62QQM_20240820 0.653 0.644 abort 0.118 0.652 0.111
m6_26TYQI3C_20221027 0.659 0.653 abort 0.609 0.695 0.590
m6_2BBN5XA4_20250411 0.577 0.287 abort 0.069 0.577 0.061
m6_2C4TKI6H_20240506 0.645 0.401 abort 0.233 0.634 0.231
m6_2CDNN7BH_20240102 0.655 0.516 abort 0.340 0.651 0.323

Interpretation

  • SimpleITK and ANTsPy tie for best (median MI 0.651 / 0.652): on the well-positioned cases they cluster at 0.60–0.70 and both survive the harder repositioned cases (0.58–0.65 on m6_2BBN5XA4 / m6_22IRRKU6). SimpleITK's per-case MI is unchanged from the previous 150-iteration budget — RSGD converges well before the 2000-iteration cap, so the cap is now headroom rather than a constraint.
  • The grossly repositioned cases define the ranking. On m6_2BBN5XA4 and m6_24V62QQM_20240820 (~30° inter-session tilt) the tools split: ANTs/SimpleITK hold 0.58–0.65, elastix drops to 0.29–0.64, niftyreg 0.07–0.12, synthmorph 0.06–0.10 — single-start optimizers locking onto local minima. This is exactly why production 02_build_m6_dataset.py uses a multi-start tilt grid.
  • Elastix is second at the new budget (2000 iters per resolution, 32 bins, 5000 samples): median 0.516, below the 0.641 recorded at the old 500/500/250
    • 10-bin setting. The long ASGD run nails the clean cases (0.64–0.70) but its single CenterOfGravity start still locks onto local minima on the repositioned ones (m6_2BBN5XA4 0.287, m6_2C4TKI6H 0.401, m6_23XIMO4M 0.416).
  • IMPACT (semantic metric) does not run on these pairs under the no-mask rule — earlier (masked) runs showed it strongest on the hardest cases (m6_22IRRKU6 0.624, m6_2C4TKI6H 0.626, m6_2CDNN7BH 0.618, median 0.511) with a stochastic draw per run; it also was by far the slowest (~14 min/case at the 500/2000 budget, hours/case at 2000/5000). With the CT head-neck FOV as the fixed volume it aborts ("too many samples map outside moving image buffer", ~10–20% in-buffer) — see the IMPACT section.
  • NiftyReg (reg_aladin) is mid-pack: single-start symmetric block matching is strong on moderate cases (m6_23XIMO4M 0.632, best in class; m6_26TYQI3C 0.609) but falls into the same tilt local minima (m6_2BBN5XA4 0.069, m6_24V62QQM_20240820 0.118). The v2.1.1 Docker build on the GPU (-platf 1) is ~21 s/case, ~4× slower than elastix (vs ~4 min CPU for the conda 1.5.69 build). It clips the CT to [0,80] HU by default (per the A/B below) — neutral on the unclipped score scale (0.296→0.292 median).
  • SynthMorph is weakest of the six on these cross-modality CT pairs (median 0.277 with its default CT clip; 0.241 unclipped): its rigid model is trained on brain MRI, so the ~30° tilt defeats it on the repositioned cases (0.06–0.11) even though it matches the intensity tools on the cleanest (m6_26TYQI3C 0.590). The [0,80] brain-window clip of the CT is its only genuine A/B win (+0.036 median, 7/9 cases), so it is the default. It needs no centroid init (global deep method).
  • tv-in-CT is ~0.93 for every pipeline: the warped GTV lands inside the CT head everywhere, so coverage is not the discriminator — MI is.
  • Speed (median wall, 4 cases in parallel, 24 threads): elastix 6.0 s, SimpleITK 10.6 s, NiftyReg 20.8 s (v2.1.1, GPU block matching), SynthMorph 24.5 s (GPU forward pass), ANTs 36.6 s. (IMPACT does not run on M6 under the no-mask rule; when it did — masked, 500/2000 budget — it was ~833 s/case, GPU feature extraction with backprop per iteration.)
  • Stochasticity: ANTs' Mattes-MI subsamples the image at random, so its per-case numbers carry run-to-run variance (this table is one draw); SimpleITK's MI (and registration) is deterministic at fixed thread count; the in-process Elastix ASGD is deterministic given its fixed seed; SynthMorph is deterministic; NiftyReg is deterministic on the CPU (conda 1.5.69) but shows small run-to-run variation on the GPU (v2.1.1, ~±0.01–0.05 MI on the weak cases). IMPACT is the most stochastic: its Jacobian mode draws random patches every iteration, so the final pose changes run to run. Treat its per-case numbers as one sample from a distribution.

CT clipping A/B (--clip-ct 0 80)

Question: does clipping the CT to a brain window ([0, 80] HU — skull and air removed) before registration improve the result? The harness clips the CT before registration AND before scoring (percentile-binned MI is scale-free, so the A/B compares like for like). Same 10 cases, same budgets.

Median achieved MI (nats) and wall-clock (9 complete-CT cases):

pipeline no clip clip [0,80] Δ (median MI) clip wins (cases by MI)
antspyx 0.653 / 36.6 s 0.466 / 34.7 s −0.186 0/9
elastix 0.517 / 6.0 s 0.425 / 5.9 s −0.092 1/9
niftyreg 0.296 / 23.8 s 0.320 / 20.2 s +0.024 5/9
simpleitk 0.651 / 10.6 s 0.436 / 11.1 s −0.215 0/9
synthmorph 0.241 / 26.0 s 0.318 / 25.6 s +0.077 5/9
impact abort abort - - (still aborts; clipping does not fix the FOV geometry, 194–388/2000 samples in-buffer)

Verdict: no — clipping the CT to [0, 80] HU does not improve the result; do not clip.

  • The headline alignment score drops for all three leading intensity pipelines: antspyx 0.653→0.466, simpleitk 0.651→0.436, elastix 0.517→0.425 (median nats; simpleitk and antspyx lose on all 9 cases, elastix on 8/9).
  • The two weaker pipelines show small median-MI gains (niftyreg +0.024, synthmorph +0.077) but only mixed case-by-case (5/9), and their geometric QA does not support a real improvement.
  • Geometric concentricity (skull-ring gap) is a wash: mean mid/std move by ~1–5 mm in either direction (niftyreg mid 23.6→18.1 the only clear gain; antspyx std 16.4→18.0 the clear loss). Production acceptance even falls (no-clip 4/4/2/4/2 → clip 1/0/1/0/2).
  • Mechanism. Clipping is a saturating (non-invertible) intensity map: the skull atom (800+ HU → 80) removes the strongest intensity anchor (skull ↔ MR scalp) and compresses the CT rank distribution, so the percentile-binned MI between the warped CT and the MR is inherently lower for the same pose. The pose itself does not systematically degrade — the head is rigid, so skull-ring concentricity is its fingerprint, and that is unchanged. The MI drop is therefore mostly a score-scale effect, but it is exactly the score the benchmark reports, and there is no geometric benefit to pay it for.
  • Scoring note: _skull_ring_gap tests the ring with >= p97 (not >) so the orientation gate and the gap QA also work on a clipped CT (where p97 sits exactly on the clipped-skull atom).
  • Decision / adopted default. The pipelines were updated: niftyreg and synthmorph clip the CT to [0, 80] HU before registration (clip_ct=(0.0, 80.0) default, clip_ct=None disables it; MR inputs are never clipped); antspyx / elastix / simpleitk register the CT as-is. Re-checked on the unclipped score scale (the protocol used by the table above): synthmorph keeps a genuine gain (0.241→0.277 median, 7/9 cases), niftyreg is neutral (0.296→0.292; its apparent +0.024 above is mostly the clip-scoring effect). All rows of the main table are scored on the unclipped warped CT, so pipelines remain comparable.

Reproducing

# main env — SimpleITK + Elastix + ANTsPy + NiftyReg (niftyreg via Docker v2.1.1)
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
python scripts/bench_registration.py --pipelines simpleitk elastix antspyx niftyreg \
    --n-cases 10 --workers 4 --threads 24 --simpleitk-mode benchmark
# force the conda 1.5.69 CPU build: --niftyreg-backend conda

# Docker pipelines (need the pulled image; see the SynthMorph / IMPACT
# sections below for the one-time setup)
python scripts/bench_registration.py --pipelines synthmorph --n-cases 10 \
    --workers 4 --threads 24
# NOTE: impact aborts on the M6 pairs under the shared no-mask rule (the CT
# head-neck FOV maps mostly outside the T1c head crop) — see the IMPACT section
python scripts/bench_registration.py --pipelines impact --n-cases 10 \
    --workers 4 --threads 24

# A/B: does clipping the CT to a brain window help? (--clip-ct clips the CT
# for ALL pipelines, before registration and scoring; niftyreg/synthmorph
# already clip by default, so this only affects antspyx/elastix/simpleitk)
python scripts/bench_registration.py --n-cases 10 --workers 4 --threads 24 \
    --out-dir results/bench_reg_noclip
python scripts/bench_registration.py --n-cases 10 --workers 4 --threads 24 \
    --clip-ct 0 80 --out-dir results/bench_reg_clip0_80
python scripts/bench_registration.py --merge --out-dir results/bench_reg_noclip
python scripts/bench_registration.py --merge --out-dir results/bench_reg_clip0_80

# combine the per-pipeline JSONs accumulated across envs into one table + plot
python scripts/bench_registration.py --merge

Other flags: --sample random --seed 0 (reproducible random 10), --keys k1,k2,... (explicit cases), --n-cases N, --workers W, --threads T (also the OpenMP thread count for reg_aladin), --simpleitk-mode {benchmark,production}, --synthmorph-model {rigid,affine}, --synthmorph-gpu <int|-1>, --synthmorph-extent <int>, --impact-model <rel|abs>, --impact-gpu <int|-1>, --impact-iters N (default 2000), --impact-samples N (default 5000), --out-dir <dir>, --qa-images / --qa-out <dir> (visual QA, below).

Visual QA images (checkerboard + color overlay)

--qa-images writes a checkreg-style PNG per (pipeline, case) to <out-dir>/qa/<pipeline>__<case>.png for eyeballing the alignment. Each image is a 3×2 grid: rows are the three orthogonal mid-slices through the head center (mid-axial, mid-coronal, mid-sagittal), and columns are:

  • left — checkerboard blend: alternating blocks of the CT inverse-transformed into MR coordinates and the original MR; a clean registration gives a seamless mosaic, a misregistration shows hard edges at the block boundaries.
  • right — additive color overlay: CT in red, MR in green. Structures that coincide blend to yellow; rotation/translation error appears as red/green fringing at the skull and brain edges.

The CT is shown in a brain window (L40/W80 = 0–80 HU) so the brain parenchyma is visible and comparable to the MR (the skull, ~800+ HU, clips to white); the MR uses a 2–98 % head window. The coronal and sagittal views are oriented superior-up to match the axial view.

The title carries the pipeline, case key and the achieved Mattes MI, so a glance tells you whether the visuals agree with the score. Generate for the available pipelines with:

source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
python scripts/bench_registration.py \
    --pipelines simpleitk elastix antspyx niftyreg --n-cases 10 --qa-images
python scripts/bench_registration.py \
    --pipelines synthmorph --n-cases 10 --qa-images

(9 images each; the incomplete-CT case is skipped. impact produces none on M6 — it aborts under the no-mask rule, see the IMPACT section.)

SynthMorph pipeline (Docker) — one-time setup

The synthmorph pipeline runs the freesurfer/synthmorph image: SynthMorph (Hoffmann et al., IEEE TMI 2022), an anatomy-aware, acquisition-agnostic deep registration (TensorFlow VxmAffineFeatureDetector with the pretrained synthmorph.rigid.1.h5 weights). No skull-stripping or intensity preprocessing is needed: each volume is normalized to an isotropic 1-mm LIA network space internally. It is the second Docker-based pipeline and the only deep-learning one.

Prerequisites (check with RP.availability(); a missing piece raises PipelineUnavailable with the exact command):

# 1. Docker (with NVIDIA container toolkit so --gpus works)
docker --version

# 2. Pull the image (~11 GB, one-time)
docker pull freesurfer/synthmorph

No extra model assets: the rigid weights ship inside the image (/freesurfer/models).

How the adapter drives the image

  1. Rigid matrix model, no init — register -m rigid on GPU when one is visible (auto-spread over the host GPUs by pid; --synthmorph-gpu -1 forces CPU). SynthMorph is a global deep method, so it recovers the gross inter-session offset on its own and needs no initial transform.
  2. Read-back — the -T LTA (a LINEAR_RAS_TO_RAS 4×4 in shared world space; with fixed=CT/moving=T1c the -T matrix is the T1c→CT contract transform) is parsed into a SimpleITK AffineTransform and run through the usual fix_orientation gate.
  3. GPU sharing — the container is run as the host uid (clean scratch removal, TF still reaches the GPU) with TF_FORCE_GPU_ALLOW_GROWTH set so concurrent benchmark workers can share a GPU.

NiftyReg pipeline — one-time setup

The niftyreg pipeline runs reg_aladin from the NiftyReg suite (KCL-BMEIS, the actively-maintained NiftyReg): symmetric block matching + trimmed least squares (Modat et al., J. Med. Img. 1(2) 024003, 2014). Instead of a histogram MI objective it matches 4-voxel image blocks by normalised cross-correlation at each level of a 3-level Gaussian pyramid and solves a robust (trimmed) least-squares for the rigid transform — a genuinely different algorithmic family, and cross-modality tolerant because NCC is invariant to intensity scaling.

Default backend: the official v2.1.1 release in Docker. The v2.x release binaries need GLIBC_2.34, which this host (Ubuntu 20.04, glibc 2.31) lacks, so the v2.1.1 Ubuntu zip is packaged in the niftyreg:2.1.1-ubuntu image (nvidia/cuda ubuntu22.04 base for the CUDA plugin). GPU runs use docker run --gpus all + -platf 1 -gpuid N and are ~20× faster than CPU; the v2.1.1 CUDA path supports linear interpolation only (-interp 1), so GPU runs use linear and CPU/conda runs use cubic (-interp 3) — the poses they recover on these cases agree to ~4 decimal places. The conda-forge build (1.5.69, CPU) remains the fallback when the Docker image is absent (--niftyreg-backend conda).

Prerequisites (check with RP.availability(); a missing piece raises PipelineUnavailable with the exact command):

# default: build the v2.1.1 Docker image (one-time)
docker build -t niftyreg:2.1.1-ubuntu niftyreg/   # from NiftyReg-Ubuntu-v2.1.1.zip

# fallback: conda-forge build (provides reg_aladin + reg_f3d, NiftyReg 1.5.69)
conda install -c conda-forge niftyreg

How the adapter drives the tool

  1. Rigid 6-DOF, full volumes — reg_aladin -ref CT -flo T1c -rigOnly (symmetric variant on by default, which matters: the non-symmetric mode drifts grossly on these repositioned pairs). -ln 3 pyramid, -maxit 15 TLS iterations per level (the tool's default of 5 is too coarse), -interp 1 (GPU) / -interp 3 (CPU) internal warping, default initialisation (image centres). GPU: --gpus all, -platf 1, -gpuid <auto by pid>; CPU/conda: -platf 0 (v2.1.1) or no -platf (1.5.69).
  2. Read-back — the output affine (-aff) is a 4×4 row-major matrix, documented as Affine * Reference = Floating (CT→T1c); the adapter parses it into a SimpleITK AffineTransform and runs it through the usual fix_orientation gate (skull-ring concentricity). On the M6 cases the gate keeps the file's affine as-is (auto_inverted: False), i.e. it is already the contract transform (T1c→CT).

IMPACT pipeline (Docker) — one-time setup

The impact pipeline runs the vboussot/elastix_impact image: Elastix 5.2.0 with the IMPACT semantic similarity metric compiled in as a native Elastix metric, plus a TorchScript TotalSegmentator feature extractor. It is GPU-based and Docker-based (alongside synthmorph). It follows the shared rules (full volumes, no mask, fixed = CT, moving = T1c) — which means it aborts on the M6 pairs: the IMPACT metric only evaluates samples that map inside the moving buffer, and the CT head-neck FOV (~300×300×251 mm) is much larger than the T1c head crop (~220×220×150 mm), so ~14% of the samples are in-buffer at the auto-init and elastix raises "too many samples map outside moving image buffer" (see the IMPACT specifics below).

Prerequisites (check with RP.availability(); a missing piece raises PipelineUnavailable with the exact command):

# 1. Docker (with NVIDIA container toolkit so --gpus works)
docker --version

# 2. Pull the image (~23.5 GB, one-time)
docker pull vboussot/elastix_impact

# 3. Fetch the TorchScript model (~123 MB, one-time) into the repo's model
#    cache (data/impact_models/). M730.pt is the recommended TotalSegmentator.
mkdir -p /mnt/t24/Public/xfr/longitudinal/impact_models/TS
curl -L -o /mnt/t24/Public/xfr/longitudinal/impact_models/TS/M730.pt \
    https://huggingface.co/VBoussot/impact-torchscript-models/resolve/main/TS/M730.pt

Other models (M258/M291/M850, MIND, SAM2.1, …) are selectable with --impact-model <HF-relative-path-or-abs>; see the impact-torchscript-models repo for the full list and FOV/layer tables.

How the adapter drives the image

The image's CMD.sh is a generic launcher, so the adapter calls the in-image binary directly (/lib/elastix-install/bin/elastix):

  1. Full volumes, fixed=CT, moving=T1c — both volumes are used as-is (no head box, no mask, no crop / pad, per the shared rules above). On the M6 pairs this aborts: the IMPACT metric only evaluates spatial samples that map inside the moving buffer, and the CT FOV (~300×300×251 mm, head+neck) is much larger than the tight T1c head crop (~220×220×150 mm), so at the CenterOfGravity auto-init only ~14% of the 2000 samples are in-buffer and elastix raises "Too many samples map outside moving image buffer: 277 / 2000". IMPACT therefore needs a fixed volume whose FOV is comparable to the moving crop (e.g. a head-only CT) to work under the no-mask rule.
  2. Auto init (no -t0) — the parameter map sets AutomaticTransformInitialization "true" with method CenterOfGravity, so elastix aligns the intensity centroids; AutomaticScalesEstimation sets the optimizer scales.
  3. Parameter map — rigid EulerTransform (6-DOF), Impact metric in Jacobian mode (differentiable, per the recommended config), 4 semantic scales ImpactVoxelSize 6/3/1.5/1 mm (scaled to the T1c base spacing) with no image resampling (all-1 pyramid schedules, as the IMPACT docs require), ASGD, Random sampler. GPU auto-spread across the host GPUs by pid (--impact-gpu -1 forces CPU).
  4. Read-back — the result TransformParameters.0.itk.txt is an Euler3DTransform in the world physical frame (the AutomaticTransformInitialization is folded in by elastix); the adapter runs it through the usual fix_orientation gate.

Notes / caveats

  • The benchmark compares each toolkit in its native configuration: all register on the full volumes (no head box, no mask, no crop / pad) and use each toolkit's native automatic transform initialization (no manual centroid_offset) — elastix / impact AutomaticTransformInitialization (CenterOfGravity), antspyx's center-of-mass alignment, simpleitk identity, synthmorph none. Production uses --simpleitk-mode production's multi-start tilt grid, which raises SimpleITK's acceptance at ~20× the cost; that is out of scope for the head-to-head but is the config the dataset actually ships with.
  • synthmorph is deterministic (one fixed pretrained model, no random subsampling), so its per-case numbers are stable run to run — unlike ANTs / IMPACT. Its low MI (median 0.238; 0.06–0.10 on the repositioned cases) is a genuine cross-modality / tilt weakness, not variance.
  • fix_orientation (elastix/ants/niftyreg/synthmorph/impact) chooses between a transform and its inverse by skull-ring concentricity — a correctness gate that makes the result independent of each toolkit's transform-direction convention. With the fixed=CT/moving=T1c swap, elastix's combination transform is now the inverse of the contract, so it reports auto_inverted: True (the gate corrects it); antspyx/synthmorph/impact return the contract direction directly (auto_inverted: False). niftyreg's -aff file is documented as CT→T1c but the gate keeps it as-is on these cases (auto_inverted: False) — the empirical gate, not the docs, decides.
  • SynthMorph specifics (Docker freesurfer/synthmorph, TensorFlow, GPU):
    • Cross-modality / tilt weakness. The rigid model is trained on brain MRI, so on these CT↔T1c pairs it recovers translation but is repeatedly defeated by the ~30° inter-session tilt (7/9 cases have std > 15 mm). It is a legitimate data point for how far a pretrained MRI deep model generalizes out-of-domain to CT — not a bug.
    • No centroid init needed. Being a global deep method it recovers the gross offset on its own; the -i initial-transform path is not used (which also sidesteps this image's float64/float32 bug on the -i code path).
    • GPU sharing. The container runs as the host uid with TF_FORCE_GPU_ALLOW_GROWTH so concurrent benchmark workers share a GPU.
    • ~25 s/case (GPU: one pretrained forward pass), between SimpleITK (~10 s) / ANTs (~37 s) and IMPACT (~834 s).
  • NiftyReg specifics (conda-forge niftyreg 1.5.69, reg_aladin, symmetric block matching + TLS, CPU/OpenMP):
    • Different algorithmic family. No histogram MI objective: 4-voxel blocks are matched by normalised cross-correlation and the rigid transform is solved by a trimmed least squares, iterated per pyramid level (-ln 3 -maxit 15). NCC's scale invariance is what makes it cross-modality tolerant.
    • Symmetric mode matters. The default symmetric variant (optimising forward and backward transforms concurrently) is the robust one on these repositioned pairs; -noSym drifts grossly (gap std 45 mm on a clean case every other tool passes).
    • -maxit 5 (the tool default) is too coarse — on the clean case m6_26TYQI3C it reaches MI 0.41 vs 0.61 at 15 TLS iterations/level; 15 is the benchmark setting.
    • GPU is the default now (v2.1.1, -platf 1 via docker run --gpus all): ~20–25 s/case on the full volumes vs ~4 min CPU (120–440 s, -omp thread-bound) for the conda 1.5.69 build. --niftyreg-gpu -1 forces the CPU platform (-platf 0).
    • Version note. The upstream KCL-BMEIS release binaries (v2.x) need glibc ≥ 2.34; this host (Ubuntu 20.04, glibc 2.31) runs them in the niftyreg:2.1.1-ubuntu Docker image (nvidia/cuda ubuntu22.04 base; the CUDA plugin needs libcusolver.so.11, provided by the image). The conda-forge 1.5.69 build remains as the no-Docker fallback and gives essentially identical poses (MI matches to ±0.001 on the 10 cases).
  • Elastix specifics (ITK-Elastix 0.25.4, elastix 5.3.1, in-process python API — no CLI binary involved):
    • Elastix 5.x renamed components vs the classic parameter files: the rigid 3D transform is EulerTransform (6-DOF, Rigid3DTransform is gone from the component set), the metric key is Metric (not ImageMetric), the random sampler is Random (not RandomSampler). The adapter uses the 5.x names and is not compatible with elastix ≤ 4.x parameter files.
    • AutomaticTransformInitialization "true" (CenterOfGravity) provides the start (no manual centroid_offset); AutomaticScalesEstimation sets the optimizer scales. An output directory is still set (SetOutputDirectory) for the C++ filter to run; the adapter uses a scratch dir under data/m6reg/elastix/itkelastix/.
    • WriteResultImage must be the string "false" (Python False); the default (absent) makes the filter raise "Could not read result image" after a successful registration.
    • With fixed=CT/moving=T1c the combination transform is CT→T1c (the inverse of the contract), so fix_orientation reports auto_inverted: True and inverts it. The result is recovered by point-mapping (GetCombinationTransform().TransformPoint) into an affine — independent of elastix's Euler-angle parameterization — and wrapped in a SimpleITK AffineTransform(3).
    • Benchmark budget: MaximumNumberOfIterations 2000 per resolution (ASGD 2000/2000/2000 over 3 resolutions) with the sampler/metric at their defaults (NumberOfSpatialSamples 5000, NumberOfHistogramBins 32).
  • IMPACT specifics (Docker vboussot/elastix_impact, Elastix 5.2.0, GPU, Jacobian mode):
  • Slow, by design. Every ASGD iteration runs the TotalSegmentator extractor on the sampled patches with backpropagation. The benchmark budget (MaximumNumberOfIterations 2000, NumberOfSpatialSamples 5000 = the elastix default) is ~10× the authors' 500-iter / 2000-sample Jacobian budget, i.e. on the order of hours per case on full volumes — the results shown for impact are from the previous 500/2000 budget and were not re-run at the new budget. --impact-iters / --impact-samples trade accuracy for speed.
  • Aborts on M6 under the no-mask rule. Like every other pipeline it runs fixed=CT/moving=T1c on the full volumes (no mask, no crop / pad). The CT FOV (~300×300×251 mm) is much larger than the T1c crop (~220×220×150 mm), so most of the CT buffer maps outside the T1c moving buffer and the metric aborts ("Too many samples map outside moving image buffer: 277 / 2000" on M6 at the CenterOfGravity auto-init). The transform contract (T1c→CT) still holds when a run completes; for M6 pairs a head-only CT as the fixed volume would be needed.
    • Auto init. AutomaticTransformInitialization (CenterOfGravity) aligns the intensity centroids; no manual -t0 translation is used.
  • History: single-init convergence. The semantic landscape has more local minima than Mattes-MI; earlier (masked, fixed=CT) runs no longer nailed the hardest repositioned case (m6_22IRRKU6 mid 22.7 / std 27.0; the pre-mask head-box flow got it to mid 8.3 / std 13.2). Under the current no-mask rule it does not run on M6 at all (see above).
    • Stochastic. Jacobian mode resamples patches each iteration, so results vary run to run (see Stochasticity).
    • Elastix 5.x differences vs the in-process elastix pipeline: the image bundles 5.2.0, so the in-file multi-Transform chain and UseScalingFactors are gone. The result is read back as an Euler3DTransform (.itk.txt). The image's own CMD.sh is a generic launcher, so the adapter invokes /lib/elastix-install/bin/elastix directly.
    • Models are not in the image. The TorchScript model(s) must be present under the mounted /Data/Models (the repo caches them in data/impact_models/, bind-mounted read-only).
    • CPU fallback: --impact-gpu -1 runs without --gpus and with ImpactGPU -1 / mixed precision off (much slower).