# 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**. All pipelines register on the **full CT / T1c volumes** (no head box) and 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. All use **fixed = CT, moving = T1c** except `impact`, which keeps **fixed = T1c, moving = CT**: the CT FOV (~300×300×251 mm) is much larger than the T1c crop (~220×220×150 mm), so with CT as the fixed image too many CT samples map outside the T1c buffer and the IMPACT metric aborts ("too many samples map outside moving image buffer"). 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) 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** except `impact`, which keeps **fixed = T1c, moving = CT** (swapping breaks its TotalSegmentator metric — see below). | # | 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` | **T1c / CT** | auto CenterOfGravity | **IMPACT semantic** (TotalSegmentator `TS/M730.pt`, L2, Jacobian/GPU) | `EulerTransform` rigid, ASGD, 4 scales | ✅ ran (main env, Docker + GPU) | | 4 | `niftyreg` | CT / T1c | image centres | NCC block matching + trimmed least squares (symmetric) | `reg_aladin -rigOnly`, 3-level pyramid | ✅ ran (main env, conda-forge `reg_aladin` 1.5.69) | | 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, centered start — the default, comparable to the other tools) and `--simpleitk-mode production` (the full multi-start tilt grid from `02_build_m6_dataset.py`, ~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 ⇔ `0 ≤ mid ≤ 20 mm` and `std ≤ 12 mm`). ## Results (10 cases) Alignment is scored by the achieved **Mattes MI** (nats, warped-CT ~ MR over the MR head; higher = better). | pipeline | toolkit | registered | s/reg (med) | tv-in-CT | MI (med/mean, nats) | |---|---|---|---|---|---| | **antspyx** | ANTsPy 0.6.3 | 9 | 35.0 | 0.93 | **0.652 / 0.644** | | **elastix** | ITK-Elastix 0.25.4 | 9 | 6.0 | 0.93 | **0.516 / 0.520** | | **impact**\* | Docker Elastix 5.2 + IMPACT | 9 | 833.5 | 0.93 | **0.511 / 0.493** | | **niftyreg** | NiftyReg 1.5.69 (reg_aladin) | 9 | 240.1 | 0.93 | **0.296 / 0.339** | | **simpleitk** | SimpleITK 2.5.6 | 9 | 9.4 | 0.93 | **0.651 / 0.641** | | **synthmorph** | Docker SynthMorph (TensorFlow) | 9 | 25.1 | 0.93 | **0.238 / 0.308** | \* `impact` at its **previous** 500-iter / 2000-sample budget; the new default (MaximumNumberOfIterations 2000, NumberOfSpatialSamples 5000) is ~10× slower (hours/case) and was not re-run. Per-case MI (nats; **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 | **0.624** | 0.296 | 0.642 | 0.235 | | m6_23XIMO4M_20230222 | 0.655 | 0.416 | 0.474 | **0.634** | 0.652 | 0.510 | | m6_24V62QQM_20230223 | 0.703 | **0.701** | 0.554 | 0.260 | 0.663 | 0.238 | | m6_24V62QQM_20231017 | 0.601 | 0.590 | 0.419 | 0.493 | **0.602** | 0.516 | | m6_24V62QQM_20240820 | 0.652 | 0.643 | 0.458 | 0.118 | **0.652** | 0.096 | | m6_26TYQI3C_20221027 | 0.657 | 0.653 | 0.511 | 0.608 | **0.695** | 0.604 | | m6_2BBN5XA4_20250411 | 0.576 | 0.287 | 0.155 | 0.069 | **0.577** | 0.060 | | m6_2C4TKI6H_20240506 | 0.645 | 0.401 | **0.626** | 0.231 | 0.634 | 0.209 | | m6_2CDNN7BH_20240102 | 0.655 | 0.516 | **0.618** | 0.344 | 0.651 | 0.301 | ### 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) is the strongest on the hardest cases** — top MI on `m6_22IRRKU6` (0.624), `m6_2C4TKI6H` (0.626) and `m6_2CDNN7BH` (0.618), where anatomical-segmentation similarity out-resolves intensity MI — but its median (0.511) is dragged by stochastic draws (`m6_2BBN5XA4` 0.155). It is also by far the slowest (~14 min/case at the shown budget; the new 2000/5000 budget would be on the order of hours/case). - **NiftyReg (reg_aladin) is mid-pack**: single-start symmetric block matching is strong on moderate cases (`m6_23XIMO4M` 0.634, best in class; `m6_26TYQI3C` 0.608) but falls into the same tilt local minima (`m6_2BBN5XA4` 0.069, `m6_24V62QQM_20240820` 0.118). CPU-only and ~40× slower than elastix at full volumes. - **SynthMorph is weakest of the six on these cross-modality CT pairs** (median 0.238): its `rigid` model is trained on brain *MRI*, so the ~30° tilt defeats it on the repositioned cases (0.06–0.10) even though it matches the intensity tools on the cleanest (`m6_26TYQI3C` 0.604). It needs **no centroid init** (global deep method) and is deterministic. - **`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 9.4 s, SynthMorph 25.1 s (GPU forward pass), ANTs 35.0 s, **NiftyReg 240.1 s** (CPU block matching), **IMPACT 833.5 s** (previous budget; 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 and NiftyReg are deterministic. **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. ## Reproducing ```bash # main env — SimpleITK + Elastix + ANTsPy + NiftyReg (pip/conda-installed here) 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 # 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 python scripts/bench_registration.py --pipelines impact --n-cases 10 \ --workers 4 --threads 24 # 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 --n-cases 10 --qa-images python scripts/bench_registration.py \ --pipelines synthmorph --n-cases 10 --qa-images python scripts/bench_registration.py \ --pipelines impact --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 `-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 — the release zips for v2.x need a newer glibc, so this env uses the conda-forge build): **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. Prerequisites (check with `RP.availability()`; a missing piece raises `PipelineUnavailable` with the exact command): ```bash # one-time, in the benchmark env (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 3` cubic internal warping, default initialisation (image centres). 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 is also the one pipeline that keeps **fixed = T1c, moving = CT** (the reverse of the others): the CT FOV (~300×300×251 mm) is much larger than the T1c crop (~220×220×150 mm), so with CT as the fixed image too many CT samples map outside the T1c buffer and the IMPACT metric aborts ("too many samples map outside moving image buffer"). 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=T1c and moving=CT are used as-is (no head box). T1c must be the fixed image: the CT FOV is much larger, so with CT fixed too many CT samples map outside the T1c buffer and the metric aborts. 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) 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. - **CPU-only, thread-bound** (`-omp`); ~4 min/case median (120–440 s range) at full volumes with 24 threads. - **Version note.** The upstream KCL-BMEIS release zips (v2.x) need glibc ≥ 2.33; this host (Ubuntu 20.04, glibc 2.31) uses the conda-forge build (1.5.69) instead. - **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. - **Keeps fixed=T1c/moving=CT.** IMPACT is the one pipeline that does NOT follow the shared fixed=CT/moving=T1c convention: the CT FOV (~300×300×251 mm) is much larger than the T1c crop (~220×220×150 mm), so with CT as the fixed image too many CT samples map outside the T1c buffer and the metric aborts ("too many samples map outside moving image buffer"). The returned transform still satisfies the contract (T1c→CT). - **Auto init.** `AutomaticTransformInitialization` (CenterOfGravity) aligns the intensity centroids; no manual `-t0` translation is used. - **Rugged single-init convergence.** The semantic landscape has more local minima than Mattes-MI: on full volumes it no longer nails the hardest repositioned case (`m6_22IRRKU6`, now mid 22.7 / std 27.0 — the head box used to get it to mid 8.3 / std 13.2). A multi-start or higher-iteration budget would likely raise its acceptance; that is out of scope for the single-init head-to-head. - **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).