# 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°. - Pipelines: [`src/registration_pipelines.py`](src/registration_pipelines.py) - Benchmark harness: [`scripts/bench_registration.py`](scripts/bench_registration.py) - Results: `results/bench_registration/` (per-pipeline JSON, `summary.json`, `summary.md`, `bench_registration.png`) ## Unified contract Every pipeline returns a SimpleITK transform `T` such that ```python 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 ```bash # 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 `, `--synthmorph-extent `, `--impact-model `, `--impact-gpu `, `--impact-iters N` (default 2000), `--impact-samples N` (default 5000), `--out-dir `, `--qa-images` / `--qa-out ` (visual QA, below). ## Visual QA images (checkerboard + color overlay) `--qa-images` writes a `checkreg`-style PNG per (pipeline, case) to `/qa/__.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: ```bash 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`](https://hub.docker.com/r/freesurfer/synthmorph) image: [SynthMorph](https://synthmorph.io) (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): ```bash # 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](https://github.com/KCL-BMEIS/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): ```bash # 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 `; 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`](https://hub.docker.com/r/vboussot/elastix_impact) image: **Elastix 5.2.0** with the [IMPACT](https://arxiv.org/abs/2503.24121) *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): ```bash # 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 `; see the [impact-torchscript-models](https://huggingface.co/VBoussot/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).