# Registration Pipeline Benchmark — CT → T1c (M6) Benchmarks **six cross-modality registration pipelines** (five rigid; `impact` is the native deformable B-spline preset of the ImpactReg package) 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). - **The T1c (MR) is always the fixed image** (moving = CT) for every pipeline. `impact`'s ImpactReg **MR_CT** convention coincides with this rule: its output displacement field (defined on the fixed grid) is the contract transform T1c→CT directly — see the IMPACT section. (A CT clipped to a non-negative window reads as MR, so the T1c slot decides; both-CT pairs keep the given order.) 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. With the ImpactReg MR_CT direction the samples are drawn from the tight T1c head crop (fixed, ~220×220×150 mm) and map mostly inside the large CT head-neck FOV (moving, ~300×300×251 mm), so the gate rarely fires; the pipeline still sets `RequiredRatioOfValidSamples 0.1` (vs the build default 0.25) as a safety override for the FOV mismatch — 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 = T1c, moving = CT** (shared rule above; `impact`'s ImpactReg MR_CT convention coincides with it). `niftyreg` and `synthmorph` clip the CT (the moving image) 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` | T1c / CT | auto COM | Mattes MI (multi-res) | ANTs `Rigid` | ✅ ran (main env; stochastic MI subsampling) | | 2 | `elastix` | T1c / CT | auto CenterOfGravity | Mattes MI (32 bins) | `EulerTransform` rigid (5.x), ASGD, 3 resolutions | ✅ ran (main env, in-process python API — no CLI) | | 3 | `impact` | **T1c / CT** (MR_CT) | auto CenterOfGravity (override) | **IMPACT semantic** (MIND `R1D2_3D` L1 + TotalSegmentator `M730` deepest-layer Dice, per-level weighted, Static) + Mattes MI (w 0.4) + bending energy | B-spline DVF (preset native), ASGD, 4 scales | ✅ ran (impact-reg-konfai + GPU; `Elastix_IMPACT_Static` preset — see IMPACT section) | | 4 | `niftyreg` | T1c / CT | 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` | T1c / CT | identity | Mattes MI (32 bins) | `Euler3D` rigid, pyramid `[4,2,1]` | ✅ ran | | 6 | `synthmorph` | T1c / CT | none (global) | **SynthMorph** anatomy-aware contrast-invariant (deep, pretrained) | 6-DOF rigid matrix | ✅ ran (main env, Docker + GPU) | Benchmark budget (best-results settings): `elastix` and `simpleitk` run with **MaximumNumberOfIterations = 2000** (per resolution / per pyramid level), with the sampler and metric left at their defaults (**NumberOfSpatialSamples 5000**, **NumberOfHistogramBins 32**). `impact` runs the published ImpactReg preset **`Elastix_IMPACT_Static`** (the `impact-reg-konfai` package) — an elastix B-spline (4 scales, FinalGridSpacing 14 mm, per-level **MaximumNumberOfIterations 400/300/200/200**, **NumberOfSpatialSamples 2000**) driven by the IMPACT metric: a per-level **MIND (L1) + TotalSegmentator M730 (deepest layer, Dice)** combination, layer weights 0.2/0.8 → 0.7/0.3, Static mode, + Mattes MI weight 0.4 + bending energy — with CenterOfGravity auto-init and `RequiredRatioOfValidSamples 0.1` applied as raw-elastix overrides for the M6 pairs. `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 = T1c / moving = CT (shared rule; `impact`'s MR_CT convention coincides with it). `niftyreg` and `synthmorph` clip the CT (the moving image) 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` runs the `impact-reg-konfai` package on the GPU (see the IMPACT section). | pipeline | toolkit | registered | s/reg (med) | tv-in-CT | MI (med/mean, nats) | |---|---|---|---|---|---| | **antspyx** | ANTsPy 0.63 | 9 | 15.5 | 0.93 | **0.652 / 0.650** | | **elastix** | ITK-Elastix 0.25.4 | 9 | 9.8 | 0.93 | **0.646 / 0.638** | | **impact** | impact-reg-konfai (Elastix 5.2 + IMPACT, `Elastix_IMPACT_Static` preset, MIND+M730, deformable) | 9 | 216.4 | 0.93 | **0.578 / 0.558** | | **niftyreg** | NiftyReg 2.1.1 (reg_aladin, Docker + CUDA) | 9 | 16.2 | 0.93 | **0.292 / 0.339** | | **simpleitk** | SimpleITK 2.5.6 | 9 | 9.6 | 0.93 | **0.647 / 0.642** | | **synthmorph** | Docker SynthMorph (TensorFlow) | 9 | 27.9 | 0.93 | **0.277 / 0.328** | Per-case MI (nats, unclipped score; **bold** = best for the case). All rows are re-runs under the **fixed = T1c / moving = CT** convention (the shared rule); the CT-fixed-era numbers are in the git history. The `impact` column is the `impact-reg-konfai` deformable `Elastix_IMPACT_Static` preset (the retired rigid Docker config is archived in `results/bench_registration/impact_docker_legacy.json`). | 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.648** | 0.644 | 0.542 | 0.292 | 0.647 | 0.277 | | m6_23XIMO4M_20230222 | **0.654** | 0.652 | 0.543 | 0.632 | **0.654** | 0.616 | | m6_24V62QQM_20230223 | 0.703 | 0.698 | 0.667 | 0.260 | **0.704** | 0.256 | | m6_24V62QQM_20231017 | **0.601** | 0.590 | 0.587 | 0.493 | 0.597 | 0.484 | | m6_24V62QQM_20240820 | **0.652** | 0.646 | 0.612 | 0.118 | 0.638 | 0.111 | | m6_26TYQI3C_20221027 | **0.718** | 0.650 | 0.717 | 0.609 | 0.670 | 0.590 | | m6_2BBN5XA4_20250411 | 0.577 | 0.577 | 0.258 | 0.069 | **0.579** | 0.061 | | m6_2C4TKI6H_20240506 | **0.645** | 0.638 | 0.578 | 0.233 | 0.636 | 0.231 | | m6_2CDNN7BH_20240102 | **0.655** | 0.650 | 0.523 | 0.340 | 0.652 | 0.323 | ### Interpretation - **ANTsPy leads, with SimpleITK and Elastix within 0.006 of it** (median MI 0.652 / 0.647 / 0.646): on the well-positioned cases the three cluster at 0.60–0.70 and all now survive the harder repositioned cases (0.58–0.65 on `m6_2BBN5XA4` / `m6_22IRRKU6`). - **Switching to fixed = T1c / moving = CT lifted Elastix sharply** (0.517 → 0.646 median, and it is now #3 rather than #4). The Mattes-MI pipelines sample spatial pairs from the fixed buffer and discard the ones that map outside the moving image; with the small T1c head crop as fixed and the large CT head+neck FOV as moving, (nearly) all fixed samples are in-buffer, versus only a minority when the big CT was fixed. Elastix's repositioned cases jumped (`m6_2BBN5XA4` 0.287→0.577, `m6_2C4TKI6H` 0.401→0.638, `m6_23XIMO4M` 0.416→0.652). The other pipelines are essentially unchanged by the swap (antspyx 0.653→0.652, simpleitk 0.651→0.647, niftyreg 0.292→0.292, synthmorph 0.277→0.277) — they were not sample-coverage limited. - **The grossly repositioned cases still separate the leaders from the tail.** On `m6_2BBN5XA4` / `m6_24V62QQM_20240820` (~30° inter-session tilt) ANTs/SimpleITK/Elastix hold 0.58–0.65, while niftyreg (0.07–0.12) and synthmorph (0.06–0.11) — the single-start block-matching and pretrained-MRI methods — lock onto local minima. This is exactly why production `02_build_m6_dataset.py` uses a **multi-start tilt grid**. - **IMPACT now runs the published `Elastix_IMPACT_Static` preset** (`impact-reg-konfai` package; the old Docker backend is retired): per-level **MIND + TotalSegmentator M730** combination (L1 + deepest-layer Dice, weights 0.2/0.8 → 0.7/0.3, Static mode, whole-image features), the preset's native deformable B-spline (400/300/200/200 iterations, 2000 samples, + Mattes MI 0.4 + bending energy), registered in the MR_CT direction (fixed=T1c) with CenterOfGravity auto-init and the 0.1 sample-validity gate as overrides. It is the best of the IMPACT variants on M6 and now **second overall at median MI 0.578** (behind SimpleITK/ANTsPy 0.65; ahead of elastix 0.52), a clear step up from the old rigid Docker config (median 0.467, archived in `impact_docker_legacy.json`): the deformable refinement + MR_CT direction lifts the repositioned cases hardest (`m6_2C4TKI6H` 0.334→0.578, `m6_23XIMO4M` 0.362→0.543, `m6_22IRRKU6` 0.467→0.542, `m6_2CDNN7BH` 0.332→0.523; `m6_26TYQI3C` 0.717 — now the best tool on that case) at a small cost on two well-aligned ones (`m6_24V62QQM_20230223` 0.700→0.667, `m6_24V62QQM_20240820` 0.646→0.612, still accepted). Concentricity also improved (median mid 12.0 / std 21.9 mm vs 21.1 / 28.0; 3/9 accepted). It is the slowest of the six (~216 s/case, the deformable optimization + KonfAI wrapper; the old rigid config was ~78 s). The `m6_2BBN5XA4` ~30° tilt case is still its weakest (0.258) — the single CenterOfGravity start remains the limiting factor. Earlier Docker-era variants: MIND+MRSegmentator median 0.417 (~72 s), single-model MIND median 0.406 (~37 s), MRSegmentator-only Jacobian configs 0.631 (500/2000) and 0.591 (2000/5000) at ~5×/~35× the cost (archived: `results/bench_registration/impact_marseg_*.json`, `impact_2000_5000.json`, `impact_500_2000_explicit_smoothing.json`, `impact_mind_500_ref.json`). - **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, T1c-fixed re-run): SimpleITK 9.6 s, elastix 9.8 s, **ANTs 15.5 s** (down from 36.6 s — the smaller fixed image also shaves ANTs' per-level work), **NiftyReg 16.2 s** (v2.1.1, GPU block matching), SynthMorph 27.9 s (GPU forward pass), **IMPACT 216.4 s** (`Elastix_IMPACT_Static` preset — Static feature mode, no backprop, but the deformable B-spline optimization at 1100 total iterations + the KonfAI wrapper; the retired rigid Docker config was 78.0 s). The earlier MRSegmentator Jacobian configs were far slower still: ~354 s/case at 500/2000, 2534 s/case at 2000/5000 (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 in the Jacobian preset (`Elastix_IMPACT_Jacobian`) is the most stochastic**: it draws random patches through the network every iteration, so the final pose changes run to run — treat such numbers as one sample from a distribution. The default Static preset re-samples coordinates every iteration as well, but the feature maps are deterministic given the pose. ## 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 | - | - (historical: at the time both aborted on the old 0.25 sample-validity gate; the gate is now 0.1, A/B to be re-done) | **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 # impact (pip; GPU; ~120-220 s/case; one-time elastix shim on hosts too old # for the stock binary — see the IMPACT section). --impact-preset selects # another published preset (e.g. Elastix_IMPACT_Jacobian, Generic_Rigid); # --impact-iters / --impact-samples are no-ops for the default preset's # per-resolution matrix python scripts/bench_registration.py --pipelines impact --n-cases 10 \ --workers 3 --threads 24 # synthmorph (Docker, needs the pulled image; see the SynthMorph section) python scripts/bench_registration.py --pipelines synthmorph --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-preset ` (default `Elastix_IMPACT_Static`), `--impact-gpu `, `--impact-iters N` (preset max_iterations override; no-op for matrix presets), `--impact-samples N` (preset spatial_samples override), `--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.) ## 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 model estimates first→second, so the pair is passed as `(fixed=T1c, moving=CT)` and the `-T` LTA (a `LINEAR_RAS_TO_RAS` 4×4 in shared world space) is the T1c→CT contract transform directly; it 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 T1c -flo CT -rigOnly` (the T1c is the reference/fixed image, the CT the floating/moving one; the CT is clipped to [0,80] HU first). 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`; the adapter parses it into a SimpleITK `AffineTransform` and runs it through the usual `fix_orientation` gate (skull-ring concentricity), which resolves the direction empirically rather than trusting the docs. Under `-ref T1c -flo CT` the file's affine comes out the other way, so the gate inverts it on the M6 cases (8/9 `auto_inverted: True`) to the contract transform (T1c→CT). ## IMPACT pipeline (impact-reg-konfai) — one-time setup The `impact` pipeline runs the [impact-reg-konfai](https://pypi.org/project/impact-reg-konfai/) package: [IMPACT](https://arxiv.org/abs/2503.24121) registration presets from the [`VBoussot/ImpactReg`](https://huggingface.co/VBoussot/ImpactReg) Hugging Face repo, driven through the [KonfAI](https://github.com/fideus-labs/KonfAI) runtime — no Docker. The default preset is **`Elastix_IMPACT_Static`**: Elastix 5.2 with the IMPACT *semantic* similarity metric — a per-level combination of the **MIND** handcrafted descriptor (`MIND/R1D2_3D.pt`, L1, subset 32) and **TotalSegmentator** `TS/M730.pt` (deepest layer, Dice, subset 64), layer weights 0.2/0.8 → 0.7/0.3 from coarse to fine, **Static** mode (whole-image features, no backprop), a 4-scale B-spline (FinalGridSpacing 14 mm), 400/300/200/200 iterations per level, 2000 spatial samples, + Mattes MI (w 0.4) + bending energy — the published successor of the `MR_CT_TS` reference parameter map this repo used to hand-build (and replace the `vboussot/elastix_impact` Docker image). Other presets are selectable with `--impact-preset` (`Elastix_IMPACT_Jacobian`, `Generic_Rigid`, `Generic_Rigid_BSpline`, `FireANTs_Anatomix/IMPACT/SyN`, `ConvexAdam_Coarse/Composite`; the live database is listed by `impact-reg-konfai register --help`). It is GPU-based (like `synthmorph`), but pip-installed. Prerequisites (check with `RP.availability()`; a missing piece raises `PipelineUnavailable` with the exact command): ```bash # in the longitudinal-313 env (Python >= 3.11): pip install impact-reg-konfai # CLI + KonfAI runtime (pulls h5py etc.) bash scripts/setup_impact_konfai.sh # one-time, this host only: the elastix shim ``` The CLI then auto-downloads the one-time assets on first `register`: the preset app (HF `VBoussot/ImpactReg`), the TorchScript feature models (HF `VBoussot/impact-torchscript-models`: MIND ~15 KB + TS/M730 ~200 MB) and the **elastix-impact 1.0.0** binary (GitHub `vboussot/ImpactElastix`, ~40 MB) into `~/.cache` (HF cache + `~/.cache/konfai`). ### Why the elastix shim (this host only) The stock binary needs **glibc ≥ 2.35** and links **LibTorch 2.8.0**; this host (Ubuntu 20.04, glibc 2.31, env torch 2.14) satisfies neither, so `scripts/setup_impact_konfai.sh` builds a self-contained shim at `~/.cache/konfai/elastix-impact-shim`: the glibc 2.35 loader + libs extracted from the `niftyreg:2.1.1-ubuntu` image (docker is needed once for that copy only), the self-contained LibTorch 2.8.0 cu128 from download.pytorch.org (~3.8 GB zip), and a wrapper `bin/elastix` that runs the stock binary through the shim loader with the LibTorch 2.8.0 library path (stripping the inherited `LD_LIBRARY_PATH`, so the env's torch 2.14 cannot leak in). The pipeline selects the shim via `KONFAI_ELASTIX_DIR` (`_impact_elastix_root`); a host new enough to run the stock binary directly needs no shim at all. ### How the adapter drives the CLI 1. **Full volumes, fixed=T1c (MR), moving=CT** — the ImpactReg **MR_CT** convention, which coincides with the shared rule (the T1c is the fixed image): the preset's output is a displacement field on the **fixed** grid mapping fixed→moving, so the field is the contract transform T1c→CT **directly** (displacement fields cannot be inverted with `GetInverse`, which a fixed=CT direction would require). It also keeps the metric's sample-validity gate healthy: samples drawn from the tight T1c head crop (~220×220×150 mm) map mostly inside the large CT head-neck FOV (~300×300×251 mm), unlike fixed=CT where only ~14% were in-buffer at the auto-init. 2. **M6 robustness overrides** — the preset's map is tuned for well-aligned pairs, so the adapter forwards raw elastix lines through the package's `parameter_overrides` knob: `AutomaticTransformInitialization=true` + `AutomaticTransformInitializationMethod=CenterOfGravity` (the preset's GeometricalCenter start leaves the intensity centroids ~50-100 mm apart on the head+neck FOV mismatch) and `RequiredRatioOfValidSamples 0.1` (build default 0.25; keeps the divergence gate while letting the FOV-mismatched runs proceed). 3. **`--fields-only`** — the adapter derives the warp itself (the package's derived "Moved" image would be an extra full-size resample of the same content). 4. **GPU auto-spread** across the host GPUs by pid (`--impact-gpu -1` forces CPU; the engine then turns mixed precision off). 5. **Read-back** — the transform comes from `/P000/Transform.h5` (an ITK-form HDF5 `DisplacementFieldTransform` on the T1c grid; the SimpleITK build here has no ITK HDF5 transform reader, so it is parsed with h5py) and direction-checked by `_orient_impact`: the skull-ring gap of the field vs a sign-flipped (first-order reverse) proxy — the `fix_orientation` analogue for a field with no `GetInverse`. 6. **Intensity normalization happens inside the metric / model** (no pipeline-side normalization): the MIND descriptor normalizes each 5-voxel neighbourhood to [0, 1] locally (modality-agnostic by construction); the segmentation models (TS/MRSeg) are standardized to zero mean / unit variance per the model card. Pre-normalizing the volumes would be an exact no-op for the z-scored models and would only corrupt the HU scale used by MI scoring and the QA brain-window images. ## 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) 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, so the fixed/moving swap is absorbed by the gate rather than re-derived per toolkit. Under the shared fixed=T1c / moving=CT rule the raw transforms come out in the contract direction for elastix (combination transform = fixed→moving), antspyx (ANTs forward transform) and synthmorph (the `-T` LTA, model estimates first→second) — completed runs report `auto_inverted: False` for those; `niftyreg`'s `-aff` file comes out the other way and the gate inverts it (8/9 `auto_inverted: True`). `impact` returns a displacement field (no `GetInverse` in ITK), so it uses `_orient_impact` — the same skull-ring decision against a sign-flipped (first-order reverse) proxy field; its MR_CT field is in the contract direction (`auto_inverted: False`). - **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 (~216 s, deformable preset). - **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=T1c/moving=CT the combination transform is T1c→CT — the contract direction — so `fix_orientation` keeps it as-is (`auto_inverted: False`). 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** (`impact-reg-konfai` 1.8.6, KonfAI runtime, elastix-impact 5.2, GPU; preset `Elastix_IMPACT_Static`): - **Runs on M6 with the T1c-fixed direction + relaxed sample-validity gate.** Full volumes, no mask, no crop / pad, fixed=T1c/moving=CT (the shared rule; the ImpactReg MR_CT convention coincides with it): the preset's output field is on the fixed grid, so that direction is the contract transform (T1c→CT) directly, and the metric's samples (drawn from the tight T1c crop) map mostly inside the large CT FOV. The package's generated map still defaults to `RequiredRatioOfValidSamples 0.25`, so the adapter overrides it to 0.1 (a genuinely diverging transform, <10% in-buffer, still aborts). In the old Docker era (fixed=CT) the gate aborted the no-mask M6 pairs until the same 0.1 relaxation. The transform contract holds; `_orient_impact` decides the direction as usual (completed runs report `auto_inverted: False` — the MR_CT field is already the contract). - **Auto init.** `AutomaticTransformInitialization` (CenterOfGravity) is applied as an override (the preset's GeometricalCenter start is too crude for the head+neck FOV mismatch); no manual translation is used. - **Deformable, not rigid.** The preset's native transform is a RecursiveBSpline (FinalGridSpacing 14 mm, 4 scales) regularized by the bending-energy metric; the old pipeline forced a rigid `EulerTransform` in place of it. The benchmark contract (warp the CT concentric with the MR head) is transform-agnostic, so this is a native-config comparison point like every other row. - **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). The no-mask MRSegmentator runs showed the same pattern (0.635 MI on `m6_22IRRKU6` at 2000/5000 but mid 26.1 / std 28.6 there; accepted on `m6_2BBN5XA4`, mid 15.1 / std 10.7). The single CenterOfGravity start on the ~30° tilt pairs remains IMPACT's limiting factor — a multi-start (or `--tta` flips through the preset app) would be the next step. - **Stochastic.** The Jacobian preset (`Elastix_IMPACT_Jacobian`) resamples patches through the network each iteration, so results vary run to run; the default Static preset re-samples coordinates each iteration but the feature maps are deterministic given the pose (see Stochasticity). - **The elastix shim (this host).** The stock binary needs glibc ≥ 2.35 + LibTorch 2.8.0; the host (glibc 2.31, torch 2.14) runs it through `~/.cache/konfai/elastix-impact-shim` (built by `scripts/setup_impact_konfai.sh`, selected via `KONFAI_ELASTIX_DIR`). Same glibc story as the niftyreg v2.x binaries. - **Models / presets are not in the repo.** They auto-download from HF (`VBoussot/ImpactReg`, `VBoussot/impact-torchscript-models`) into `~/.cache/huggingface` on first use; the old `data/impact_models/` cache is obsolete. - **CPU fallback:** `--impact-gpu -1` (no `--gpu` flag; the engine runs the feature models on CPU with mixed precision off — much slower).