Add setup script for IMPACT registration pipeline shim

This commit introduces a new script, `setup_impact_konfai.sh`, which facilitates a one-time setup for the IMPACT registration pipeline (impact-reg-konfai). The script builds a self-contained shim that includes:

- A glibc 2.35 loader and libraries extracted from the niftyreg:2.1.1-ubuntu Docker image.
- A self-contained LibTorch 2.8.0 (cu128) library.
- A wrapper script that runs the stock elastix-impact binary through the glibc 2.35 loader with the appropriate library paths.

The setup ensures compatibility with hosts that do not meet the required glibc and LibTorch versions, allowing the pipeline to function correctly. The script also verifies the installation and provides usage instructions.
This commit is contained in:
Furen Xiao 2026-09-30 18:21:38 +08:00
parent 73234db39e
commit 5e525c07c8
6 changed files with 807 additions and 624 deletions

View file

@ -10,7 +10,7 @@ Activate with:
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313 source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
``` ```
Key packages: torch 2.14 (+cu126), torchvision, monai 1.6 (pip, Pipeline C), numpy, scipy, pandas, scikit-learn, scikit-image, matplotlib, nibabel, SimpleITK. Registration toolkits (benchmark, see REGISTRATION_BENCHMARK.md): `itk` + `itk-elastix`, `antspyx`, niftyreg 1.5.69 (`conda install -c conda-forge niftyreg`, provides `reg_aladin`), plus Docker images `freesurfer/synthmorph` and `vboussot/elastix_impact`. Key packages: torch 2.14 (+cu126), torchvision, monai 1.6 (pip, Pipeline C), numpy, scipy, pandas, scikit-learn, scikit-image, matplotlib, nibabel, SimpleITK. Registration toolkits (benchmark, see REGISTRATION_BENCHMARK.md): `itk` + `itk-elastix`, `antspyx`, niftyreg 1.5.69 (`conda install -c conda-forge niftyreg`, provides `reg_aladin`), `impact-reg-konfai` (pip; IMPACT — on this host the elastix binary needs the one-time shim from `scripts/setup_impact_konfai.sh`), plus the Docker image `freesurfer/synthmorph`.
## Project ## Project

View file

@ -44,8 +44,6 @@ data/
pseudo/roundK/ in-house pseudo-labels (rows.jsonl, masks, summary.json) pseudo/roundK/ in-house pseudo-labels (rows.jsonl, masks, summary.json)
pseudo_nnu/roundK/ nnU-Net pseudo-labels (rows.jsonl, masks, summary.json) pseudo_nnu/roundK/ nnU-Net pseudo-labels (rows.jsonl, masks, summary.json)
pseudo_monai/roundK/ MONAI pseudo-labels (rows.jsonl, masks, summary.json) pseudo_monai/roundK/ MONAI pseudo-labels (rows.jsonl, masks, summary.json)
impact_models/ IMPACT TorchScript feature-extractor cache (registration
`impact` pipeline, see REGISTRATION_BENCHMARK.md)
src/ U-Net, dataset, losses, training/eval helpers src/ U-Net, dataset, losses, training/eval helpers
scripts/ Pipeline A (in-house 3D U-Net) scripts/ Pipeline A (in-house 3D U-Net)
scripts_nnu/ Pipeline B (nnU-Net) scripts_nnu/ Pipeline B (nnU-Net)

View file

@ -1,8 +1,9 @@
# Registration Pipeline Benchmark — CT → T1c (M6) # Registration Pipeline Benchmark — CT → T1c (M6)
Benchmarks **six rigid, cross-modality registration pipelines** that align a Benchmarks **six cross-modality registration pipelines** (five rigid;
head-and-neck CT to a tightly-cropped T1c MRI so the GTV can be warped onto the `impact` is the native deformable B-spline preset of the ImpactReg package)
T1c (MR) native grid. This is the registration step of 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 `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 Hounsfield vs MR intensity) and the patient is repositioned between the CT and
MR sessions, so the inter-session head tilt can reach ~30°. MR sessions, so the inter-session head tilt can reach ~30°.
@ -28,8 +29,12 @@ every pipeline:
- **No mask, no crop / pad**: registration runs on the full input volumes - **No mask, no crop / pad**: registration runs on the full input volumes
exactly as given (no `FixedMask` / `MovingMask`, no head-box resampling, no exactly as given (no `FixedMask` / `MovingMask`, no head-box resampling, no
padding). padding).
- **CT is always the fixed image** (moving = T1c); when both inputs are MR - **The T1c (MR) is always the fixed image** (moving = CT) for every
the first argument is kept as fixed. 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 All pipelines also use each toolkit's **native automatic transform
initialization** (no manual centroid offset): elastix / impact use initialization** (no manual centroid offset): elastix / impact use
@ -37,13 +42,11 @@ initialization** (no manual centroid offset): elastix / impact use
center-of-mass alignment, simpleitk starts from identity, niftyreg center-of-mass alignment, simpleitk starts from identity, niftyreg
(reg_aladin) initializes from the image centres, synthmorph needs none. One (reg_aladin) initializes from the image centres, synthmorph needs none. One
consequence of the no-mask rule: the IMPACT metric only evaluates samples that 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 map inside the moving buffer. With the ImpactReg MR_CT direction the samples
the fixed image and the tight T1c head crop (~220×220×150 mm) as moving, most are drawn from the tight T1c head crop (fixed, ~220×220×150 mm) and map mostly
of the CT buffer maps outside the T1c buffer (~14% of samples in-buffer at the inside the large CT head-neck FOV (moving, ~300×300×251 mm), so the gate
auto-init). Elastix's sample-validity gate (build default rarely fires; the pipeline still sets `RequiredRatioOfValidSamples 0.1` (vs
`RequiredRatioOfValidSamples 0.25`) used to abort the M6 pairs on that ("too the build default 0.25) as a safety override for the FOV mismatch — see the
many samples map outside moving image buffer"); the parameter map now sets
`RequiredRatioOfValidSamples 0.1` so the FOV-mismatched runs proceed — see the
IMPACT section. The comparison is on each toolkit's native optimizer / metric IMPACT section. The comparison is on each toolkit's native optimizer / metric
/ global-search capability. / global-search capability.
@ -55,25 +58,33 @@ initialization** (no manual centroid offset): elastix / impact use elastix's
`AutomaticTransformInitialization` (CenterOfGravity), antspyx uses its default `AutomaticTransformInitialization` (CenterOfGravity), antspyx uses its default
center-of-mass alignment, simpleitk starts from identity, niftyreg center-of-mass alignment, simpleitk starts from identity, niftyreg
initializes from the image centres, and synthmorph needs none (global deep initializes from the image centres, and synthmorph needs none (global deep
method). **All use fixed = CT, moving = T1c** (shared rule above; when both method). **All use fixed = T1c, moving = CT** (shared rule above;
inputs are MR the first argument is fixed). `niftyreg` and `synthmorph` `impact`'s ImpactReg MR_CT convention coincides with it). `niftyreg` and
clip the CT to [0, 80] HU before registration (per the CT-clipping A/B `synthmorph` clip the CT (the moving image) to [0, 80] HU before
below; `clip_ct=None` disables it). registration (per the CT-clipping A/B below; `clip_ct=None` disables it).
| # | pipeline | fixed / moving | init | metric | transform | status in this env | | # | 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) | | 1 | `antspyx` | T1c / CT | 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) | | 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` | **CT / T1c** | auto CenterOfGravity | **IMPACT semantic** (MRSegmentator `MRSeg/MRSeg.pt`, L2, Jacobian/GPU) | `EulerTransform` rigid, ASGD, 4 scales | ✅ ran (Docker + GPU; sample gate 0.1 — see IMPACT section) | | 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` | 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`) | | 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` | CT / T1c | identity | Mattes MI (32 bins) | `Euler3D` rigid, pyramid `[4,2,1]` | ✅ ran | | 5 | `simpleitk` | T1c / CT | 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) | | 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`, `impact` and `simpleitk` Benchmark budget (best-results settings): `elastix` and `simpleitk` run with
run with **MaximumNumberOfIterations = 2000** (per resolution / per pyramid **MaximumNumberOfIterations = 2000** (per resolution / per pyramid level),
level), with the sampler and metric left at their defaults with the sampler and metric left at their defaults (**NumberOfSpatialSamples
(**NumberOfSpatialSamples 5000**, **NumberOfHistogramBins 32**). `antspyx`, 5000**, **NumberOfHistogramBins 32**). `impact` runs the published ImpactReg
`niftyreg` and `synthmorph` keep their native defaults. 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, `simpleitk` has two budgets: `--simpleitk-mode benchmark` (single pyramid,
identity start — the default, comparable to the other tools) and identity start — the default, comparable to the other tools) and
@ -132,69 +143,92 @@ longer reported in the tables/plots):
Alignment is scored by the achieved **Mattes MI** (nats, warped-CT ~ MR over 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 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, 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 fixed = T1c / moving = CT (shared rule; `impact`'s MR_CT convention
registration* (per the CT-clipping A/B below; `clip_ct=None` disables it); coincides with it). `niftyreg` and `synthmorph` clip the CT (the moving
`antspyx`, `elastix` and `simpleitk` register the CT as-is. `niftyreg` is the image) to [0, 80] HU *before registration* (per the CT-clipping A/B below;
v2.1.1 Docker build on the GPU (`-platf 1`); `impact` cannot run on these `clip_ct=None` disables it); `antspyx`, `elastix` and `simpleitk` register
pairs under the no-mask rule (see the IMPACT section). 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) | | 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** | | **antspyx** | ANTsPy 0.63 | 9 | 15.5 | 0.93 | **0.652 / 0.650** |
| **elastix** | ITK-Elastix 0.25.4 | 9 | 6.0 | 0.93 | **0.517 / 0.520** | | **elastix** | ITK-Elastix 0.25.4 | 9 | 9.8 | 0.93 | **0.646 / 0.638** |
| **impact** | Docker Elastix 5.2 + IMPACT (MRSegmentator) | 8 | 2534 | 0.93 | **0.591 / 0.575** | | **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 | 20.8 | 0.93 | **0.292 / 0.338** | | **niftyreg** | NiftyReg 2.1.1 (reg_aladin, Docker + CUDA) | 9 | 16.2 | 0.93 | **0.292 / 0.339** |
| **simpleitk** | SimpleITK 2.5.6 | 9 | 10.6 | 0.93 | **0.651 / 0.641** | | **simpleitk** | SimpleITK 2.5.6 | 9 | 9.6 | 0.93 | **0.647 / 0.642** |
| **synthmorph** | Docker SynthMorph (TensorFlow) | 9 | 24.5 | 0.93 | **0.277 / 0.328** | | **synthmorph** | Docker SynthMorph (TensorFlow) | 9 | 27.9 | 0.93 | **0.277 / 0.328** |
Per-case MI (nats, unclipped score; **bold** = best for the case): 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 | | case | antspyx | elastix | impact | niftyreg | simpleitk | synthmorph |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| m6_22GD5VL2_20230724 | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | | m6_22GD5VL2_20230724 | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT |
| m6_22IRRKU6_20240115 | **0.649** | 0.472 | 0.635 | 0.292 | 0.642 | 0.277 | | m6_22IRRKU6_20240115 | **0.648** | 0.644 | 0.542 | 0.292 | 0.647 | 0.277 |
| m6_23XIMO4M_20230222 | **0.655** | 0.416 | 0.478 | 0.632 | 0.652 | 0.616 | | m6_23XIMO4M_20230222 | **0.654** | 0.652 | 0.543 | 0.632 | **0.654** | 0.616 |
| m6_24V62QQM_20230223 | **0.704** | 0.701 | 0.697 | 0.260 | 0.663 | 0.256 | | m6_24V62QQM_20230223 | 0.703 | 0.698 | 0.667 | 0.260 | **0.704** | 0.256 |
| m6_24V62QQM_20231017 | 0.601 | 0.590 | 0.556 | 0.493 | **0.602** | 0.484 | | m6_24V62QQM_20231017 | **0.601** | 0.590 | 0.587 | 0.493 | 0.597 | 0.484 |
| m6_24V62QQM_20240820 | **0.653** | 0.644 | 0.531 | 0.118 | 0.652 | 0.111 | | m6_24V62QQM_20240820 | **0.652** | 0.646 | 0.612 | 0.118 | 0.638 | 0.111 |
| m6_26TYQI3C_20221027 | 0.659 | 0.653 | gate-abort | 0.609 | **0.695** | 0.590 | | m6_26TYQI3C_20221027 | **0.718** | 0.650 | 0.717 | 0.609 | 0.670 | 0.590 |
| m6_2BBN5XA4_20250411 | **0.577** | 0.287 | 0.450 | 0.069 | **0.577** | 0.061 | | m6_2BBN5XA4_20250411 | 0.577 | 0.577 | 0.258 | 0.069 | **0.579** | 0.061 |
| m6_2C4TKI6H_20240506 | **0.645** | 0.401 | 0.629 | 0.233 | 0.634 | 0.231 | | m6_2C4TKI6H_20240506 | **0.645** | 0.638 | 0.578 | 0.233 | 0.636 | 0.231 |
| m6_2CDNN7BH_20240102 | **0.655** | 0.516 | 0.625 | 0.340 | 0.651 | 0.323 | | m6_2CDNN7BH_20240102 | **0.655** | 0.650 | 0.523 | 0.340 | 0.652 | 0.323 |
### Interpretation ### Interpretation
- **SimpleITK and ANTsPy tie for best** (median MI 0.651 / 0.652): on the - **ANTsPy leads, with SimpleITK and Elastix within 0.006 of it** (median MI
well-positioned cases they cluster at 0.60–0.70 and both survive the harder 0.652 / 0.647 / 0.646): on the well-positioned cases the three cluster at
repositioned cases (0.58–0.65 on `m6_2BBN5XA4` / `m6_22IRRKU6`). SimpleITK's 0.60–0.70 and all now survive the harder repositioned cases (0.58–0.65 on
per-case MI is **unchanged** from the previous 150-iteration budget — RSGD `m6_2BBN5XA4` / `m6_22IRRKU6`).
converges well before the 2000-iteration cap, so the cap is now headroom - **Switching to fixed = T1c / moving = CT lifted Elastix sharply**
rather than a constraint. (0.517 → 0.646 median, and it is now #3 rather than #4). The Mattes-MI
- **The grossly repositioned cases define the ranking.** On pipelines sample spatial pairs from the fixed buffer and discard the ones
`m6_2BBN5XA4` and `m6_24V62QQM_20240820` (~30° inter-session tilt) the tools that map outside the moving image; with the small T1c head crop as fixed and
split: ANTs/SimpleITK hold 0.58–0.65, elastix drops to 0.29–0.64, niftyreg the large CT head+neck FOV as moving, (nearly) all fixed samples are
0.07–0.12, synthmorph 0.06–0.10 — single-start optimizers locking onto local in-buffer, versus only a minority when the big CT was fixed. Elastix's
minima. This is exactly why production `02_build_m6_dataset.py` uses a repositioned cases jumped (`m6_2BBN5XA4` 0.287→0.577, `m6_2C4TKI6H`
**multi-start tilt grid**. 0.401→0.638, `m6_23XIMO4M` 0.416→0.652). The other pipelines are essentially
- **Elastix is second at the new budget** (2000 iters per resolution, 32 bins, unchanged by the swap (antspyx 0.653→0.652, simpleitk 0.651→0.647, niftyreg
5000 samples): median 0.516, below the 0.641 recorded at the old 500/500/250 0.292→0.292, synthmorph 0.277→0.277) — they were not sample-coverage
+ 10-bin setting. The long ASGD run nails the clean cases (0.64–0.70) but its limited.
single CenterOfGravity start still locks onto local minima on the - **The grossly repositioned cases still separate the leaders from the
repositioned ones (`m6_2BBN5XA4` 0.287, `m6_2C4TKI6H` 0.401, `m6_23XIMO4M` tail.** On `m6_2BBN5XA4` / `m6_24V62QQM_20240820` (~30° inter-session tilt)
0.416). ANTs/SimpleITK/Elastix hold 0.58–0.65, while niftyreg (0.07–0.12) and
- **IMPACT (semantic metric) now runs on these pairs** — the no-mask abort was synthmorph (0.06–0.11) — the single-start block-matching and pretrained-MRI
elastix's sample-validity gate (build default `RequiredRatioOfValidSamples` methods — lock onto local minima. This is exactly why production
0.25) rejecting the ~14% in-buffer fraction of the CT head-neck FOV vs the `02_build_m6_dataset.py` uses a **multi-start tilt grid**.
tight T1c crop; the parameter map sets it to 0.1 (see the IMPACT section). - **IMPACT now runs the published `Elastix_IMPACT_Static` preset**
With MRSegmentator at the 2000/5000 budget it is top-three in MI on 7 of the (`impact-reg-konfai` package; the old Docker backend is retired): per-level
8 registered cases (median 0.591): 0.635 on the hardest `m6_22IRRKU6` **MIND + TotalSegmentator M730** combination (L1 + deepest-layer Dice,
(masked-era 0.624), 0.697 on `m6_24V62QQM_20230223` (near-best), 0.629 on weights 0.2/0.8 → 0.7/0.3, Static mode, whole-image features), the preset's
`m6_2C4TKI6H` — and it shares the production-acceptance of the grossly native deformable B-spline (400/300/200/200 iterations, 2000 samples,
repositioned `m6_2BBN5XA4` (std 10.7 mm) with antspyx/simpleitk, the only + Mattes MI 0.4 + bending energy), registered in the MR_CT direction
non-intensity tool to do so. One run diverges mid-registration (fixed=T1c) with CenterOfGravity auto-init and the 0.1 sample-validity gate
(`m6_26TYQI3C`: the in-buffer fraction falls below 10% at iteration 256 and as overrides. It is the best of the IMPACT variants on M6 and now **second
the gate aborts — a stochastic-draw outcome). It remains by far the slowest overall at median MI 0.578** (behind SimpleITK/ANTsPy 0.65; ahead of
(~42 min/case at 2000/5000). 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 - **NiftyReg (reg_aladin) is mid-pack**: single-start symmetric block matching
is strong on moderate cases (`m6_23XIMO4M` 0.632, best in class; 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_26TYQI3C` 0.609) but falls into the same tilt local minima
@ -212,23 +246,28 @@ Per-case MI (nats, unclipped score; **bold** = best for the case):
It needs **no centroid init** (global deep method). It needs **no centroid init** (global deep method).
- **`tv-in-CT` is ~0.93 for every pipeline**: the warped GTV lands inside the - **`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. CT head everywhere, so coverage is not the discriminator — MI is.
- **Speed** (median wall, 4 cases in parallel, 24 threads): elastix 6.0 s, - **Speed** (median wall, 4 cases in parallel, 24 threads, T1c-fixed re-run):
SimpleITK 10.6 s, **NiftyReg 20.8 s** (v2.1.1, GPU block matching), SimpleITK 9.6 s, elastix 9.8 s, **ANTs 15.5 s** (down from 36.6 s — the
SynthMorph 24.5 s (GPU forward pass), ANTs 36.6 s. (IMPACT is by far the smaller fixed image also shaves ANTs' per-level work), **NiftyReg 16.2 s**
slowest — 2534 s/case median at the 2000/5000 benchmark budget, ~276 s/case (v2.1.1, GPU block matching), SynthMorph 27.9 s (GPU forward pass),
at the authors' 500/2000; GPU feature extraction with backprop per **IMPACT 216.4 s** (`Elastix_IMPACT_Static` preset — Static feature mode, no
iteration. No-mask runs are faster per iteration than the masked-era backprop, but the deformable B-spline optimization at 1100 total iterations
~833 s/case @500/2000 because only the ~14% in-buffer samples are scored.) + 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 - **Stochasticity**: ANTs' Mattes-MI subsamples the image at random, so its
per-case numbers carry run-to-run variance (this table is one draw); per-case numbers carry run-to-run variance (this table is one draw);
SimpleITK's MI (and registration) is deterministic at fixed thread count; SimpleITK's MI (and registration) is deterministic at fixed thread count;
the in-process Elastix ASGD is deterministic given its fixed seed; the in-process Elastix ASGD is deterministic given its fixed seed;
SynthMorph is deterministic; NiftyReg is deterministic on the CPU 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, (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**: ~±0.01–0.05 MI on the weak cases). **IMPACT in the Jacobian preset
its Jacobian mode draws random patches every iteration, so the final pose (`Elastix_IMPACT_Jacobian`) is the most stochastic**: it draws random
changes run to run. Treat its per-case numbers as one sample from a patches through the network every iteration, so the final pose changes run
distribution. 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`) ## CT clipping A/B (`--clip-ct 0 80`)
@ -293,15 +332,16 @@ python scripts/bench_registration.py --pipelines simpleitk elastix antspyx nifty
--n-cases 10 --workers 4 --threads 24 --simpleitk-mode benchmark --n-cases 10 --workers 4 --threads 24 --simpleitk-mode benchmark
# force the conda 1.5.69 CPU build: --niftyreg-backend conda # force the conda 1.5.69 CPU build: --niftyreg-backend conda
# Docker pipelines (need the pulled image; see the SynthMorph / IMPACT # impact (pip; GPU; ~120-220 s/case; one-time elastix shim on hosts too old
# sections below for the one-time setup) # for the stock binary — see the IMPACT section). --impact-preset selects
python scripts/bench_registration.py --pipelines synthmorph --n-cases 10 \ # another published preset (e.g. Elastix_IMPACT_Jacobian, Generic_Rigid);
--workers 4 --threads 24 # --impact-iters / --impact-samples are no-ops for the default preset's
# NOTE: impact now runs on the M6 pairs (the parameter map relaxes the # per-resolution matrix
# sample-validity gate to RequiredRatioOfValidSamples 0.1) but is by far the
# slowest: ~5 min/case at 500/2000, ~42 min/case (2534 s median) at 2000/5000
# (--impact-iters / --impact-samples) — see the IMPACT section
python scripts/bench_registration.py --pipelines impact --n-cases 10 \ 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 --workers 4 --threads 24
# A/B: does clipping the CT to a brain window help? (--clip-ct clips the CT # A/B: does clipping the CT to a brain window help? (--clip-ct clips the CT
@ -323,9 +363,10 @@ Other flags: `--sample random --seed 0` (reproducible random 10),
`--threads T` (also the OpenMP thread count for `reg_aladin`), `--threads T` (also the OpenMP thread count for `reg_aladin`),
`--simpleitk-mode {benchmark,production}`, `--simpleitk-mode {benchmark,production}`,
`--synthmorph-model {rigid,affine}`, `--synthmorph-gpu <int|-1>`, `--synthmorph-model {rigid,affine}`, `--synthmorph-gpu <int|-1>`,
`--synthmorph-extent <int>`, `--impact-model <rel|abs>`, `--synthmorph-extent <int>`, `--impact-preset <name>` (default
`--impact-gpu <int|-1>`, `--impact-iters N` (default 2000), `Elastix_IMPACT_Static`), `--impact-gpu <int|-1>`, `--impact-iters N`
`--impact-samples N` (default 5000), (preset max_iterations override; no-op for matrix presets),
`--impact-samples N` (preset spatial_samples override),
`--out-dir <dir>`, `--qa-images` / `--qa-out <dir>` (visual QA, below). `--out-dir <dir>`, `--qa-images` / `--qa-out <dir>` (visual QA, below).
## Visual QA images (checkerboard + color overlay) ## Visual QA images (checkerboard + color overlay)
@ -391,10 +432,11 @@ No extra model assets: the rigid weights ship inside the image
visible (auto-spread over the host GPUs by pid; `--synthmorph-gpu -1` forces 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 CPU). SynthMorph is a global deep method, so it recovers the gross
inter-session offset on its own and needs **no** initial transform. 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 2. **Read-back** — the model estimates first→second, so the pair is passed as
space; with fixed=CT/moving=T1c the `-T` matrix is the T1c→CT contract `(fixed=T1c, moving=CT)` and the `-T` LTA (a `LINEAR_RAS_TO_RAS` 4×4 in
transform) is parsed into a SimpleITK `AffineTransform` and run through the shared world space) is the T1c→CT contract transform directly; it is
usual `fix_orientation` gate. 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 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 removal, TF still reaches the GPU) with `TF_FORCE_GPU_ALLOW_GROWTH` set so
concurrent benchmark workers can share a GPU. concurrent benchmark workers can share a GPU.
@ -434,112 +476,114 @@ conda install -c conda-forge niftyreg
### How the adapter drives the tool ### How the adapter drives the tool
1. **Rigid 6-DOF, full volumes** — `reg_aladin -ref CT -flo T1c -rigOnly` 1. **Rigid 6-DOF, full volumes** — `reg_aladin -ref T1c -flo CT -rigOnly`
(symmetric variant on by default, which matters: the non-symmetric mode (the T1c is the reference/fixed image, the CT the floating/moving one;
drifts grossly on these repositioned pairs). `-ln 3` pyramid, the CT is clipped to [0,80] HU first). Symmetric variant on by default,
`-maxit 15` TLS iterations per level (the tool's default of 5 is too which matters: the non-symmetric mode drifts grossly on these
coarse), `-interp 1` (GPU) / `-interp 3` (CPU) internal warping, default repositioned pairs. `-ln 3` pyramid, `-maxit 15` TLS iterations per level
initialisation (image centres). GPU: `--gpus all`, `-platf 1`, (the tool's default of 5 is too coarse), `-interp 1` (GPU) / `-interp 3`
`-gpuid <auto by pid>`; CPU/conda: `-platf 0` (v2.1.1) or no `-platf` (CPU) internal warping, default initialisation (image centres). GPU:
(1.5.69). `--gpus all`, `-platf 1`, `-gpuid <auto by pid>`; CPU/conda: `-platf 0`
(v2.1.1) or no `-platf` (1.5.69).
2. **Read-back** — the output affine (`-aff`) is a 4×4 row-major matrix, 2. **Read-back** — the output affine (`-aff`) is a 4×4 row-major matrix,
documented as `Affine * Reference = Floating` (CT→T1c); the adapter parses documented as `Affine * Reference = Floating`; the adapter parses it into
it into a SimpleITK `AffineTransform` and runs it through the usual a SimpleITK `AffineTransform` and runs it through the usual
`fix_orientation` gate (skull-ring concentricity). On the M6 cases the gate `fix_orientation` gate (skull-ring concentricity), which resolves the
keeps the file's affine as-is (`auto_inverted: False`), i.e. it is already direction empirically rather than trusting the docs. Under
the contract transform (T1c→CT). `-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 (Docker) — one-time setup ## IMPACT pipeline (impact-reg-konfai) — one-time setup
The `impact` pipeline runs the [`vboussot/elastix_impact`](https://hub.docker.com/r/vboussot/elastix_impact) The `impact` pipeline runs the
image: **Elastix 5.2.0** with the [IMPACT](https://arxiv.org/abs/2503.24121) [impact-reg-konfai](https://pypi.org/project/impact-reg-konfai/) package:
*semantic* similarity metric compiled in as a native Elastix metric, plus a [IMPACT](https://arxiv.org/abs/2503.24121) registration presets from the
TorchScript segmentation feature extractor (**MRSegmentator** `MRSeg/MRSeg.pt` [`VBoussot/ImpactReg`](https://huggingface.co/VBoussot/ImpactReg) Hugging Face
by default; TotalSegmentator `TS/M730.pt` and other models from repo, driven through the [KonfAI](https://github.com/fideus-labs/KonfAI)
`VBoussot/impact-torchscript-models` are selectable). It is GPU-based and runtime — no Docker. The default preset is **`Elastix_IMPACT_Static`**:
Docker-based (alongside `synthmorph`). It follows the shared rules (full Elastix 5.2 with the IMPACT *semantic* similarity metric — a per-level
volumes, no mask, fixed = CT, moving = T1c). The metric only evaluates samples combination of the **MIND** handcrafted descriptor (`MIND/R1D2_3D.pt`, L1,
that map inside the moving buffer, and the CT head-neck FOV (~300×300×251 mm) subset 32) and **TotalSegmentator** `TS/M730.pt` (deepest layer, Dice, subset
is much larger than the T1c head crop (~220×220×150 mm), so ~14% of the samples 64), layer weights 0.2/0.8 → 0.7/0.3 from coarse to fine, **Static** mode
are in-buffer at the auto-init. The image build's sample-validity gate (whole-image features, no backprop), a 4-scale B-spline (FinalGridSpacing
defaults to `RequiredRatioOfValidSamples 0.25` and used to abort the M6 pairs 14 mm), 400/300/200/200 iterations per level, 2000 spatial samples, + Mattes
on that ("too many samples map outside moving image buffer"); the parameter MI (w 0.4) + bending energy — the published successor of the `MR_CT_TS`
map now sets it to `0.1` (see the IMPACT specifics below). 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 Prerequisites (check with `RP.availability()`; a missing piece raises
`PipelineUnavailable` with the exact command): `PipelineUnavailable` with the exact command):
```bash ```bash
# 1. Docker (with NVIDIA container toolkit so --gpus works) # in the longitudinal-313 env (Python >= 3.11):
docker --version 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
# 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/). MRSeg.pt (MRSegmentator) is the default:
# it is trained on both MRI and CT, the right prior for this
# cross-modality CT->T1c task (all models share the same 2^l+3 FOV
# architecture, so the parameter map is unchanged between them).
mkdir -p /mnt/t24/Public/xfr/longitudinal/impact_models/MRSeg
curl -L -o /mnt/t24/Public/xfr/longitudinal/impact_models/MRSeg/MRSeg.pt \
https://huggingface.co/VBoussot/impact-torchscript-models/resolve/main/MRSeg/MRSeg.pt
``` ```
Other models (TotalSegmentator `TS/M730.pt`, `TS/M258/M291/M850`, MIND, SAM2.1, The CLI then auto-downloads the one-time assets on first `register`: the
Anatomix, …) are selectable with `--impact-model <HF-relative-path-or-abs>`; preset app (HF `VBoussot/ImpactReg`), the TorchScript feature models (HF
see the `VBoussot/impact-torchscript-models`: MIND ~15 KB + TS/M730 ~200 MB) and the
[impact-torchscript-models](https://huggingface.co/VBoussot/impact-torchscript-models) **elastix-impact 1.0.0** binary (GitHub `vboussot/ImpactElastix`, ~40 MB)
repo for the full list and FOV/layer tables. The model choice does not affect into `~/.cache` (HF cache + `~/.cache/konfai`).
the sample-validity gate (below): the "too many samples outside moving buffer"
check is geometric (CT head-neck FOV vs the tight T1c crop) and fires at the
first sample evaluation, before feature extraction.
### How the adapter drives the image ### Why the elastix shim (this host only)
The image's `CMD.sh` is a generic launcher, so the adapter calls the in-image The stock binary needs **glibc ≥ 2.35** and links **LibTorch 2.8.0**; this
binary directly (`/lib/elastix-install/bin/elastix`): 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.
1. **Full volumes, fixed=CT, moving=T1c** — both volumes are used as-is (no ### How the adapter drives the CLI
head box, no mask, no crop / pad, per the shared rules above). The metric
only evaluates spatial samples that map inside the moving buffer, and the 1. **Full volumes, fixed=T1c (MR), moving=CT** — the ImpactReg **MR_CT**
CT FOV (~300×300×251 mm, head+neck) is much larger than the tight T1c head convention, which coincides with the shared rule (the T1c is the fixed
crop (~220×220×150 mm), so at the CenterOfGravity auto-init only ~14% of image): the preset's output is a displacement field on the **fixed** grid
the samples are in-buffer. The image build's default validity gate mapping fixed→moving, so the field is the contract transform T1c→CT
(`RequiredRatioOfValidSamples 0.25`) aborted on that ("Too many samples map **directly** (displacement fields cannot be inverted with `GetInverse`,
outside moving image buffer: 704 / 5000"); the parameter map therefore sets which a fixed=CT direction would require). It also keeps
`(RequiredRatioOfValidSamples 0.1)` so the M6 pairs run on their in-buffer the metric's sample-validity gate healthy: samples drawn from the tight
samples while a genuinely diverging transform (<10% in-buffer) still T1c head crop (~220×220×150 mm) map mostly inside the large CT head-neck
aborts. FOV (~300×300×251 mm), unlike fixed=CT where only ~14% were in-buffer at
2. **Auto init (no `-t0`)** — the parameter map sets the auto-init.
`AutomaticTransformInitialization "true"` with method `CenterOfGravity`, so 2. **M6 robustness overrides** — the preset's map is tuned for well-aligned
elastix aligns the intensity centroids; `AutomaticScalesEstimation` sets the pairs, so the adapter forwards raw elastix lines through the package's
optimizer scales. `parameter_overrides` knob: `AutomaticTransformInitialization=true` +
3. **Parameter map** — rigid `EulerTransform` (6-DOF), `Impact` metric in `AutomaticTransformInitializationMethod=CenterOfGravity` (the preset's
**Jacobian** mode (differentiable, per the recommended config), 4 semantic GeometricalCenter start leaves the intensity centroids ~50-100 mm apart on
scales `ImpactVoxelSize` 6/3/1.5/1 mm (scaled to the T1c base spacing) with the head+neck FOV mismatch) and `RequiredRatioOfValidSamples 0.1` (build
**no image resampling** (all-1 pyramid schedules, as the IMPACT docs require), default 0.25; keeps the divergence gate while letting the FOV-mismatched
ASGD, `Random` sampler, `(RequiredRatioOfValidSamples 0.1)`. A copy of the runs proceed).
exact parameter map is dumped to 3. **`--fields-only`** — the adapter derives the warp itself (the package's
`data/m6reg/impact/paramdump/ParameterMap_<model>_<pid>.txt` before every derived "Moved" image would be an extra full-size resample of the same
docker run (also recorded as `param_dump` in the result JSON). GPU content).
auto-spread across the host GPUs by pid (`--impact-gpu -1` forces CPU). 4. **GPU auto-spread** across the host GPUs by pid (`--impact-gpu -1` forces
4. **Intensity standardization happens inside the metric** (no pipeline-side CPU; the engine then turns mixed precision off).
normalization — the model card's "standardize intensities to zero mean and 5. **Read-back** — the transform comes from `<out>/P000/Transform.h5` (an
unit variance" is already applied): at init the metric computes ITK-form HDF5 `DisplacementFieldTransform` on the T1c grid; the SimpleITK
whole-volume `[min, max, mean, sigma]` for each image build here has no ITK HDF5 transform reader, so it is parsed with h5py)
(`itk::StatisticsImageFilter`) and passes it to the model, whose and direction-checked by `_orient_impact`: the skull-ring gap of the field
`normalize` branch rescales every extracted patch with the image's own vs a sign-flipped (first-order reverse) proxy — the `fix_orientation`
`(mean, sigma)`. It is computed over the whole buffer (CT air included) and analogue for a field with no `GetInverse`.
identically for fixed and moving, so both modalities' features sit on the 6. **Intensity normalization happens inside the metric / model** (no
same normalized scale. Pre-standardizing the MHA files in the pipeline pipeline-side normalization): the MIND descriptor normalizes each 5-voxel
would be an exact no-op (z-scoring is affine-invariant: the metric neighbourhood to [0, 1] locally (modality-agnostic by construction); the
recomputes the stats of the image it receives) and would only corrupt the segmentation models (TS/MRSeg) are standardized to zero mean / unit
HU scale used by MI scoring and the QA brain-window images. variance per the model card. Pre-normalizing the volumes would be an
5. **Read-back** — the result `TransformParameters.0.itk.txt` is an exact no-op for the z-scored models and would only corrupt the HU scale
`Euler3DTransform` in the world physical frame (the used by MI scoring and the QA brain-window images.
`AutomaticTransformInitialization` is folded in by elastix); the adapter
runs it through the usual `fix_orientation` gate.
## Notes / caveats ## Notes / caveats
@ -556,15 +600,20 @@ HU scale used by MI scoring and the QA brain-window images.
subsampling), so its per-case numbers are stable run to run — unlike ANTs / 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 IMPACT. Its low MI (median 0.238; 0.06–0.10 on the repositioned cases) is a
genuine cross-modality / tilt weakness, not variance. genuine cross-modality / tilt weakness, not variance.
- `fix_orientation` (elastix/ants/niftyreg/synthmorph/impact) chooses between a - `fix_orientation` (elastix/ants/niftyreg/synthmorph) chooses between a
transform and its inverse by skull-ring concentricity — a correctness gate transform and its inverse by skull-ring concentricity — a correctness gate
that makes the result independent of each toolkit's transform-direction that makes the result independent of each toolkit's transform-direction
convention. With the fixed=CT/moving=T1c swap, `elastix`'s combination convention, so the fixed/moving swap is absorbed by the gate rather than
transform is now the inverse of the contract, so it reports re-derived per toolkit. Under the shared fixed=T1c / moving=CT rule the raw
`auto_inverted: True` (the gate corrects it); antspyx/synthmorph/impact transforms come out in the contract direction for elastix (combination
return the contract direction directly (`auto_inverted: False`). `niftyreg`'s transform = fixed→moving), antspyx (ANTs forward transform) and synthmorph
`-aff` file is documented as CT→T1c but the gate keeps it as-is on these (the `-T` LTA, model estimates first→second) — completed runs report
cases (`auto_inverted: False`) — the empirical gate, not the docs, decides. `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): - **SynthMorph specifics** (Docker `freesurfer/synthmorph`, TensorFlow, GPU):
- **Cross-modality / tilt weakness.** The `rigid` model is trained on brain - **Cross-modality / tilt weakness.** The `rigid` model is trained on brain
*MRI*, so on these CT↔T1c pairs it recovers translation but is repeatedly *MRI*, so on these CT↔T1c pairs it recovers translation but is repeatedly
@ -577,8 +626,8 @@ HU scale used by MI scoring and the QA brain-window images.
path). path).
- **GPU sharing.** The container runs as the host uid with - **GPU sharing.** The container runs as the host uid with
`TF_FORCE_GPU_ALLOW_GROWTH` so concurrent benchmark workers share a GPU. `TF_FORCE_GPU_ALLOW_GROWTH` so concurrent benchmark workers share a GPU.
- **~25 s/case** (GPU: one pretrained forward pass), between SimpleITK - **~25 s/case** (GPU: one pretrained forward pass), between SimpleITK
(~10 s) / ANTs (~37 s) and IMPACT (~834 s). (~10 s) / ANTs (~37 s) and IMPACT (~216 s, deformable preset).
- **NiftyReg specifics** (conda-forge `niftyreg` 1.5.69, `reg_aladin`, - **NiftyReg specifics** (conda-forge `niftyreg` 1.5.69, `reg_aladin`,
symmetric block matching + TLS, CPU/OpenMP): symmetric block matching + TLS, CPU/OpenMP):
- **Different algorithmic family.** No histogram MI objective: 4-voxel - **Different algorithmic family.** No histogram MI objective: 4-voxel
@ -618,61 +667,60 @@ HU scale used by MI scoring and the QA brain-window images.
- `WriteResultImage` must be the string `"false"` (Python `False`); the - `WriteResultImage` must be the string `"false"` (Python `False`); the
default (absent) makes the filter raise "Could not read result image" default (absent) makes the filter raise "Could not read result image"
after a *successful* registration. after a *successful* registration.
- With fixed=CT/moving=T1c the combination transform is CT→T1c (the inverse - With fixed=T1c/moving=CT the combination transform is T1c→CT — the
of the contract), so `fix_orientation` reports `auto_inverted: True` and contract direction — so `fix_orientation` keeps it as-is
inverts it. The result is recovered by point-mapping (`auto_inverted: False`). The result is recovered by point-mapping
(`GetCombinationTransform().TransformPoint`) into an affine — independent (`GetCombinationTransform().TransformPoint`) into an affine — independent
of elastix's Euler-angle parameterization — and wrapped in a SimpleITK of elastix's Euler-angle parameterization — and wrapped in a SimpleITK
`AffineTransform(3)`. `AffineTransform(3)`.
- Benchmark budget: `MaximumNumberOfIterations` 2000 per resolution - Benchmark budget: `MaximumNumberOfIterations` 2000 per resolution
(ASGD 2000/2000/2000 over 3 resolutions) with the sampler/metric at their (ASGD 2000/2000/2000 over 3 resolutions) with the sampler/metric at their
defaults (`NumberOfSpatialSamples` 5000, `NumberOfHistogramBins` 32). defaults (`NumberOfSpatialSamples` 5000, `NumberOfHistogramBins` 32).
- **IMPACT specifics** (Docker `vboussot/elastix_impact`, Elastix 5.2.0, - **IMPACT specifics** (`impact-reg-konfai` 1.8.6, KonfAI runtime,
GPU, Jacobian mode): elastix-impact 5.2, GPU; preset `Elastix_IMPACT_Static`):
- **Slow, by design.** Every ASGD iteration runs the segmentation - **Runs on M6 with the T1c-fixed direction + relaxed sample-validity
extractor (MRSegmentator by default) on the sampled patches with gate.** Full volumes, no mask, no crop / pad, fixed=T1c/moving=CT (the
backpropagation. The benchmark shared rule; the ImpactReg MR_CT convention coincides with it): the
budget (MaximumNumberOfIterations 2000, NumberOfSpatialSamples 5000 = preset's output field is on the fixed grid, so that direction is the
the elastix default) is ~10× the authors' 500-iter / 2000-sample contract transform (T1c→CT) directly, and the metric's samples (drawn
Jacobian budget: measured 2534 s/case (median) on the no-mask M6 from the tight T1c crop) map mostly inside the large CT FOV. The package's generated map still defaults to
pairs, ~276 s/case at 500/2000. `--impact-iters` / `--impact-samples` `RequiredRatioOfValidSamples 0.25`, so the adapter overrides it to 0.1
trade accuracy for speed. (a genuinely diverging transform, <10% in-buffer, still aborts). In the
- **Runs on M6 with a relaxed sample-validity gate.** Like every other old Docker era (fixed=CT) the gate aborted the no-mask M6 pairs until
pipeline it runs fixed=CT/moving=T1c on the full volumes (no mask, no the same 0.1 relaxation. The transform contract holds; `_orient_impact`
crop / pad). The CT FOV (~300×300×251 mm) is much larger than the T1c decides the direction as usual (completed runs report
crop (~220×220×150 mm), so at the CenterOfGravity auto-init only ~14% of `auto_inverted: False` — the MR_CT field is already the contract).
the samples map inside the T1c moving buffer. The metric calls - **Auto init.** `AutomaticTransformInitialization` (CenterOfGravity) is
`CheckNumberOfSamples()` after every evaluation and the image build applied as an override (the preset's GeometricalCenter start is too
defaults to `RequiredRatioOfValidSamples 0.25`, so it used to abort crude for the head+neck FOV mismatch); no manual translation is used.
("Too many samples map outside moving image buffer: 704 / 5000"). The - **Deformable, not rigid.** The preset's native transform is a
parameter map now sets `(RequiredRatioOfValidSamples 0.1)`: the gate RecursiveBSpline (FinalGridSpacing 14 mm, 4 scales) regularized by the
still aborts a genuinely diverging transform (<10% in-buffer) while the bending-energy metric; the old pipeline forced a rigid `EulerTransform`
FOV-mismatched M6 pairs run on their ~14% in-buffer samples — exactly in place of it. The benchmark contract (warp the CT concentric with the
the native "only in-buffer samples are evaluated" behaviour. In the MR head) is transform-agnostic, so this is a native-config comparison
2000/5000 re-run the gate still fired once, mid-registration: on point like every other row.
`m6_26TYQI3C` the transform drifted until only 468/5000 samples (9.4%) - **History: single-init convergence.** The semantic landscape has more
stayed in-buffer at iteration 256 and the run aborted. The transform
contract (T1c→CT) holds; `fix_orientation` decides the direction as
usual (all 8 completed runs report `auto_inverted: True`).
- **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 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; 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 the pre-mask head-box flow got it to mid 8.3 / std 13.2). The no-mask
MRSegmentator runs show the same pattern: strong MI (0.635 on MRSegmentator runs showed the same pattern (0.635 MI on `m6_22IRRKU6`
`m6_22IRRKU6` at 2000/5000) but the pose still misses the concentricity at 2000/5000 but mid 26.1 / std 28.6 there; accepted on `m6_2BBN5XA4`,
acceptance rule there (mid 26.1 / std 28.6); it *is* accepted on mid 15.1 / std 10.7). The single CenterOfGravity start on the ~30°
`m6_2BBN5XA4` (mid 15.1 / std 10.7). tilt pairs remains IMPACT's limiting factor — a multi-start (or
- **Stochastic.** Jacobian mode resamples patches each iteration, so results `--tta` flips through the preset app) would be the next step.
vary run to run (see Stochasticity). - **Stochastic.** The Jacobian preset (`Elastix_IMPACT_Jacobian`)
- **Elastix 5.x differences vs the in-process `elastix` pipeline:** the image resamples patches through the network each iteration, so results vary
bundles 5.2.0, so the in-file multi-`Transform` chain and `UseScalingFactors` run to run; the default Static preset re-samples coordinates each
are gone. The result is read back as an `Euler3DTransform` (`.itk.txt`). iteration but the feature maps are deterministic given the pose (see
The image's own `CMD.sh` is a generic launcher, so the adapter invokes Stochasticity).
`/lib/elastix-install/bin/elastix` directly. - **The elastix shim (this host).** The stock binary needs glibc ≥ 2.35
- **Models are not in the image.** The TorchScript model(s) must be present + LibTorch 2.8.0; the host (glibc 2.31, torch 2.14) runs it through
under the mounted `/Data/Models` (the repo caches them in `~/.cache/konfai/elastix-impact-shim` (built by
`data/impact_models/`, bind-mounted read-only). `scripts/setup_impact_konfai.sh`, selected via `KONFAI_ELASTIX_DIR`).
- **CPU fallback:** `--impact-gpu -1` runs without `--gpus` and with Same glibc story as the niftyreg v2.x binaries.
`ImpactGPU -1` / mixed precision off (much slower). - **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).

View file

@ -31,18 +31,20 @@ whichever conda env has a given toolkit installed, and it records only the
pipelines that are importable there. elastix runs in the main env through the pipelines that are importable there. elastix runs in the main env through the
ITK-Elastix python API (pip ``itk`` + ``itk-elastix``); niftyreg runs natively ITK-Elastix python API (pip ``itk`` + ``itk-elastix``); niftyreg runs natively
(``conda install -c conda-forge niftyreg`` provides ``reg_aladin``); (``conda install -c conda-forge niftyreg`` provides ``reg_aladin``);
``synthmorph`` and ``impact`` run in Docker images (``freesurfer/synthmorph`` ``impact`` runs the ``impact-reg-konfai`` package (pip; GPU; one-time elastix
and ``vboussot/elastix_impact`` respectively; needs Docker + the pulled image, shim setup on hosts too old for the stock binary — see
see REGISTRATION_BENCHMARK.md). The `--merge` mode then combines the REGISTRATION_BENCHMARK.md); ``synthmorph`` runs in a Docker image
per-pipeline JSON files (accumulated across envs) into one summary + plot. (``freesurfer/synthmorph``; needs Docker + the pulled image). The `--merge`
mode then combines the per-pipeline JSON files (accumulated across envs) into
one summary + plot.
Usage (repo root): Usage (repo root):
# main env (SimpleITK + elastix + antspyx + niftyreg) # main env (SimpleITK + elastix + antspyx + niftyreg + impact)
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313 source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
python scripts/bench_registration.py --pipelines simpleitk elastix antspyx niftyreg python scripts/bench_registration.py --pipelines simpleitk elastix antspyx niftyreg impact
# synthmorph / impact (GPU, Docker) — one-time setup in REGISTRATION_BENCHMARK.md # synthmorph (GPU, Docker) — one-time setup in REGISTRATION_BENCHMARK.md
python scripts/bench_registration.py --pipelines synthmorph impact python scripts/bench_registration.py --pipelines synthmorph
# any pipeline that is not importable in the current env is recorded as # any pipeline that is not importable in the current env is recorded as
# unavailable, so the script is env-portable; --merge combines the # unavailable, so the script is env-portable; --merge combines the
@ -246,7 +248,7 @@ def _call(pipeline, ct, t1c, tv, args):
gpu=args.synthmorph_gpu, threads=args.threads, gpu=args.synthmorph_gpu, threads=args.threads,
extent=args.synthmorph_extent) extent=args.synthmorph_extent)
if pipeline == "impact": if pipeline == "impact":
return RP.register_impact(ct, t1c, model=args.impact_model, gpu=args.impact_gpu, return RP.register_impact(ct, t1c, preset=args.impact_preset, gpu=args.impact_gpu,
iters=args.impact_iters, samples=args.impact_samples) iters=args.impact_iters, samples=args.impact_samples)
if pipeline == "niftyreg": if pipeline == "niftyreg":
return RP.register_niftyreg(ct, t1c, threads=args.threads, return RP.register_niftyreg(ct, t1c, threads=args.threads,
@ -494,18 +496,24 @@ def parse_args():
ap.add_argument("--synthmorph-extent", type=int, default=256, ap.add_argument("--synthmorph-extent", type=int, default=256,
help="SynthMorph isotropic registration-space extent in mm " help="SynthMorph isotropic registration-space extent in mm "
"(192 or 256; default 256)") "(192 or 256; default 256)")
ap.add_argument("--impact-model", default=None, ap.add_argument("--impact-preset", default=RP.IMPACT_DEFAULT_PRESET,
help="IMPACT TorchScript model, path relative to the HF " help="ImpactReg preset from the published database "
f"layout (default {RP.IMPACT_DEFAULT_MODEL}) or an absolute host path") f"(default: {RP.IMPACT_DEFAULT_PRESET}; e.g. "
"Elastix_IMPACT_Jacobian, Generic_Rigid, "
"Generic_Rigid_BSpline, FireANTs_SyN, FireANTs_IMPACT, "
"ConvexAdam_Coarse/Composite; list live with: "
"impact-reg-konfai register --help)")
ap.add_argument("--impact-gpu", type=int, default=None, ap.add_argument("--impact-gpu", type=int, default=None,
help="GPU device index for IMPACT (default: auto-spread over " help="GPU device index for IMPACT (default: auto-spread over "
"the host GPUs by pid; -1 forces CPU)") "the host GPUs by pid; -1 forces CPU)")
ap.add_argument("--impact-iters", type=int, default=2000, ap.add_argument("--impact-iters", type=int, default=None,
help="IMPACT MaximumNumberOfIterations (default 2000, the benchmark " help="preset max_iterations override, one value for all "
"budget; ~10x the authors' 500-iter Jacobian budget, much slower)") "resolutions (only effective for presets without a "
ap.add_argument("--impact-samples", type=int, default=5000, "per-resolution model matrix; default: the preset's "
help="IMPACT NumberOfSpatialSamples per iteration (default 5000, the " "own budget, 400/300/200/200 for Elastix_IMPACT_Static)")
"elastix default)") ap.add_argument("--impact-samples", type=int, default=None,
help="preset spatial_samples override (NumberOfSpatialSamples; "
"default: the preset's own value, 2000)")
ap.add_argument("--niftyreg-backend", choices=["auto", "conda", "docker"], ap.add_argument("--niftyreg-backend", choices=["auto", "conda", "docker"],
default="auto", default="auto",
help="niftyreg reg_aladin build: docker = official v2.1.1 in the " help="niftyreg reg_aladin build: docker = official v2.1.1 in the "

View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
# One-time host setup for the IMPACT registration pipeline (impact-reg-konfai).
#
# The stock elastix-impact 1.0.0 binary (vboussot/ImpactElastix) 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 this script builds a self-contained
# shim at ~/.cache/konfai/elastix-impact-shim:
# * glibc 2.35 loader + libs, extracted from the niftyreg:2.1.1-ubuntu image
# (ubuntu 22.04 base) -- docker is needed ONLY for this one-time copy;
# * LibTorch 2.8.0 cu128, self-contained (its own CUDA runtime), from
# download.pytorch.org;
# * a wrapper bin/elastix that runs the stock binary through the glibc 2.35
# loader with the LibTorch 2.8.0 library path (ignoring the inherited
# LD_LIBRARY_PATH, so the env's torch 2.14 cannot leak in).
# The pipeline selects the shim via KONFAI_ELASTIX_DIR
# (src/registration_pipelines.py::_impact_elastix_root).
#
# Run from the repo root, inside the conda env:
# source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
# pip install impact-reg-konfai # the CLI itself
# bash scripts/setup_impact_konfai.sh # this script
#
# First `register` afterwards auto-downloads from HF/GitHub: the preset app
# (VBoussot/ImpactReg), the feature models (VBoussot/impact-torchscript-models)
# and, on hosts that CAN run the stock binary directly, the binary itself.
set -euo pipefail
KONFAI_CACHE="${KONFAI_CACHE:-$HOME/.cache/konfai}"
SHIM="$KONFAI_CACHE/elastix-impact-shim"
STOCK="$KONFAI_CACHE/elastix-impact"
GLIBC_SRC_DIR="${GLIBC_SRC_DIR:-glibc-2.35}"
GLIBC_IMG="${GLIBC_IMG:-niftyreg:2.1.1-ubuntu}"
LT_VER="2.8.0"
LT_DIR="libtorch-${LT_VER}-cu128"
LT_URL="https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-${LT_VER}%2Bcu128.zip"
echo "[1/5] stock elastix-impact binary (package auto-install)"
if [ ! -x "$STOCK/bin/elastix" ]; then
python - <<'PY'
from pathlib import Path
from impact_reg_konfai.models.elastix_install import (
ELASTIX_CACHE, get_elastix_bin, install_elastix_impact)
if not get_elastix_bin(ELASTIX_CACHE).exists():
# flavor auto-detected (cu128 on this host); downloads + extracts the zip
install_elastix_impact(ELASTIX_CACHE, force_cuda=False, force_cpu=False)
PY
fi
[ -x "$STOCK/bin/elastix" ] || { echo "stock binary missing: $STOCK/bin/elastix"; exit 1; }
echo "[2/5] glibc 2.35 from $GLIBC_IMG (docker, one-time)"
if [ ! -x "$KONFAI_CACHE/$GLIBC_SRC_DIR/ld-linux-x86-64.so.2" ]; then
docker run --rm --entrypoint sh -v "$KONFAI_CACHE":/dest "$GLIBC_IMG" -c \
"rm -rf /dest/$GLIBC_SRC_DIR && mkdir -p /dest/$GLIBC_SRC_DIR && \
cp -a /lib/x86_64-linux-gnu/. /dest/$GLIBC_SRC_DIR/ && \
cp -a /usr/lib/x86_64-linux-gnu/libstdc++.so.6* /dest/$GLIBC_SRC_DIR/ 2>/dev/null || true"
fi
echo "[3/5] LibTorch ${LT_VER} cu128 (self-contained)"
if [ ! -f "$SHIM/lib/$LT_DIR/libtorch/lib/libc10.so" ]; then
mkdir -p "$SHIM/lib"
curl -L --fail -o "$KONFAI_CACHE/$LT_DIR.zip" "$LT_URL"
unzip -q "$KONFAI_CACHE/$LT_DIR.zip" -d "$SHIM/lib/$LT_DIR"
rm "$KONFAI_CACHE/$LT_DIR.zip"
fi
# the with-deps zip carries the CUDA libs under hashed names; expose the public
# soname elastix links directly
LT_LIB="$SHIM/lib/$LT_DIR/libtorch/lib"
CUDART="$(ls "$LT_LIB"/libcudart-*.so.12 | head -1)"
ln -sf "$(basename "$CUDART")" "$LT_LIB/libcudart.so.12"
echo "[4/5] shim layout + wrapper"
mkdir -p "$SHIM/bin"
cp "$STOCK/bin/elastix" "$SHIM/bin/elastix-real"
[ -d "$STOCK/lib" ] && cp -a "$STOCK/lib/." "$SHIM/lib/"
[ -d "$KONFAI_CACHE/$GLIBC_SRC_DIR" ] && cp -a "$KONFAI_CACHE/$GLIBC_SRC_DIR" "$SHIM/lib/$GLIBC_SRC_DIR"
cat > "$SHIM/bin/elastix" <<'WRAP'
#!/bin/sh
# elastix-impact 1.0.0 shim: the host glibc (2.31) is too old for the binary
# (built against glibc 2.35) and the binary expects LibTorch 2.8.0, which the
# conda env's torch (2.14) does not provide. Run it through the bundled
# glibc 2.35 loader with the bundled self-contained LibTorch 2.8.0 (cu128).
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LT="$ROOT/lib/libtorch-2.8.0-cu128/libtorch/lib"
GLIBC="$ROOT/lib/glibc-2.35"
exec env -u LD_LIBRARY_PATH -u LD_PRELOAD \
"$GLIBC/ld-linux-x86-64.so.2" \
--library-path "$LT:$ROOT/lib:$GLIBC" \
"$ROOT/bin/elastix-real" "$@"
WRAP
chmod +x "$SHIM/bin/elastix"
echo "[5/5] verify"
KONFAI_ELASTIX_DIR="$SHIM" python - <<'PY'
import os
from pathlib import Path
from impact_reg_konfai.models.elastix_install import get_elastix_bin, loader_env, try_elastix
import subprocess
root = Path(os.environ["KONFAI_ELASTIX_DIR"])
try_elastix(root)
r = subprocess.run([str(get_elastix_bin(root).resolve()), "--extended-version"],
capture_output=True, text=True, env=loader_env(root))
assert r.returncode == 0, r.stderr
print(r.stdout.splitlines()[0] if r.stdout else "elastix OK")
print("elastix-impact shim OK:", root)
PY
echo
echo "Done. The impact pipeline picks the shim up automatically"
echo "(src/registration_pipelines.py::_impact_elastix_root), e.g.:"
echo " python scripts/bench_registration.py --pipelines impact --n-cases 2"

View file

@ -28,14 +28,17 @@ Shared rules for every pipeline:
- **No mask, no crop / pad**: registration always runs on the full input - **No mask, no crop / pad**: registration always runs on the full input
volumes exactly as given (no ``FixedMask`` / ``MovingMask``, no head-box volumes exactly as given (no ``FixedMask`` / ``MovingMask``, no head-box
resampling, no padding). resampling, no padding).
- **CT is always the fixed image** (moving = T1c). When both inputs are MR - **The T1c (MR) is always the fixed image** (moving = CT). When both inputs
(no CT present) the first argument is kept as fixed (see are the same class (both CT, or a clipped CT that no longer reads as CT)
:func:`fixed_moving`). the T1c-slot (second) argument is kept as fixed; see :func:`fixed_moving`.
Every pipeline -- including ``impact``, whose ImpactReg MR_CT convention
coincides with this rule -- registers fixed=T1c / moving=CT.
- **CT brain-window clip for the pipelines that benefit from it**: per the - **CT brain-window clip for the pipelines that benefit from it**: per the
CT-clipping A/B (see REGISTRATION_BENCHMARK.md), ``niftyreg`` and CT-clipping A/B (see REGISTRATION_BENCHMARK.md), ``niftyreg`` and
``synthmorph`` clip the CT to [0, 80] HU before registration ``synthmorph`` clip the CT (the moving image) to [0, 80] HU before
(``clip_ct`` parameter, default on, MR inputs never clipped); the registration (``clip_ct`` parameter, default on, MR inputs never clipped);
Mattes-MI pipelines (antspyx / elastix / simpleitk) register the CT as-is. the Mattes-MI pipelines (antspyx / elastix / simpleitk) register the CT
as-is.
Each pipeline uses its toolkit's **native automatic transform initialization** Each pipeline uses its toolkit's **native automatic transform initialization**
(no manual ``centroid_offset``): elastix / impact use elastix's (no manual ``centroid_offset``): elastix / impact use elastix's
@ -50,12 +53,18 @@ default center-of-mass alignment, simpleitk starts from identity, niftyreg
- ``register_elastix`` : ITK-Elastix Python API (pip ``itk`` + - ``register_elastix`` : ITK-Elastix Python API (pip ``itk`` +
``itk-elastix``, elastix 5.x: ``EulerTransform`` rigid + Mattes MI, ASGD). ``itk-elastix``, elastix 5.x: ``EulerTransform`` rigid + Mattes MI, ASGD).
``AutomaticTransformInitialization`` (CenterOfGravity) start. ``AutomaticTransformInitialization`` (CenterOfGravity) start.
- ``register_impact`` : IMPACT (semantic-similarity metric) inside the - ``register_impact`` : IMPACT (semantic-similarity metric) via the
``vboussot/elastix_impact`` Docker image (Elastix 5.2 + a TorchScript ``impact-reg-konfai`` package (pip; the KonfAI runtime drives the
TotalSegmentator feature extractor as a native Elastix metric). Rigid 6-DOF, elastix-impact 5.2 binary -- no Docker). Default preset
GPU (Jacobian mode). full volumes, fixed=CT/moving=T1c (no mask -- shared ``Elastix_IMPACT_Static``: the published successor of the ImpactReg
rules above); elastix ``AutomaticTransformInitialization`` (CenterOfGravity) ``MR_CT_TS`` reference map (elastix B-spline + per-level MIND +
provides the start. Needs Docker + the image + a model asset. TotalSegmentator M730, Static mode). Registered fixed=T1c/moving=CT (the
ImpactReg MR_CT convention) so the preset's output field (on the fixed
grid) is directly the T1c->CT contract transform; CenterOfGravity auto
init + the 0.1 sample-validity gate as raw-elastix overrides. GPU.
Full volumes, no mask (shared rules above). First use auto-downloads the
preset app, models and binary into ``~/.cache``; on hosts too old for the
stock binary, ``scripts/setup_impact_konfai.sh`` builds the shim.
- ``register_niftyreg`` : NiftyReg (KCL-BMEIS suite) ``reg_aladin`` -- - ``register_niftyreg`` : NiftyReg (KCL-BMEIS suite) ``reg_aladin`` --
symmetric block-matching + trimmed least squares (Modat et al., J. Med. symmetric block-matching + trimmed least squares (Modat et al., J. Med.
Img. 2014), NCC-based block similarity (cross-modality tolerant). Rigid Img. 2014), NCC-based block similarity (cross-modality tolerant). Rigid
@ -84,8 +93,10 @@ per toolkit. :func:`fix_orientation` resolves this by warping the CT with both
``T`` and ``T^-1`` and keeping whichever gives the smaller skull-ring ``T`` and ``T^-1`` and keeping whichever gives the smaller skull-ring
concentricity gap (a pure correctness gate -- a wrongly-oriented rigid transform concentricity gap (a pure correctness gate -- a wrongly-oriented rigid transform
lands the head on the far side of the box and yields a pathological gap). It is lands the head on the far side of the box and yields a pathological gap). It is
applied to elastix / antspyx / synthmorph / impact, not to the trusted applied to elastix / antspyx / synthmorph, not to the trusted production
production SimpleITK path. SimpleITK path. ``impact`` returns a displacement field (which has no
``GetInverse``), so it uses :func:`_orient_impact` -- the same skull-ring
decision against a sign-flipped (first-order reverse) proxy field.
""" """
import os import os
import re import re
@ -145,14 +156,17 @@ def _modality_name(img):
def fixed_moving(img_a, img_b): def fixed_moving(img_a, img_b):
"""Shared fixed-image rule: the CT is always the fixed image. When both """Shared fixed-image rule: the T1c (MR) is always the fixed image
inputs are MR (or both CT) the given order is kept, i.e. ``img_a`` is the (moving = CT). Returns ``(fixed, moving)``.
fixed image. Returns ``(fixed, moving)``.
Note: a CT that has been intensity-clipped to a non-negative window (e.g. Callers pass the CT first (``img_a``) and the T1c second (``img_b``).
[0, 80] HU) is no longer detectable as CT, so the given order is kept -- A CT that has been intensity-clipped to a non-negative window (e.g.
callers should pass the CT first (as all pipelines do).""" [0, 80] HU) is no longer detectable as CT, so when neither input looks
if _is_ct(img_b) and not _is_ct(img_a): like CT the second (T1c-slot) image is the fixed one; when both look
like CT the given order is kept (``img_a`` fixed)."""
if _is_ct(img_a) and _is_ct(img_b):
return img_a, img_b
if _is_ct(img_a) or not _is_ct(img_b):
return img_b, img_a return img_b, img_a
return img_a, img_b return img_a, img_b
@ -165,17 +179,21 @@ def _clip_intensity(img, lo, hi):
return out return out
def _maybe_clip_ct(fixed, clip_ct): def _maybe_clip_ct_pair(fixed, moving, clip_ct):
"""Apply the pipeline's CT brain-window clip to the fixed image. """Apply the pipeline's CT brain-window clip to the CT of the pair
(whichever side it is on).
The A/B benchmark (``--clip-ct 0 80``) showed clipping the CT to a brain The A/B benchmark (``--clip-ct 0 80``) showed clipping the CT to a brain
window helps the block-matching / deep pipelines (niftyreg, synthmorph) window helps the block-matching / deep pipelines (niftyreg, synthmorph)
and hurts the Mattes-MI ones (antspyx, elastix, simpleitk), so those two and hurts the Mattes-MI ones (antspyx / elastix / simpleitk), so those two
pipelines clip the CT by default. Returns ``(fixed, clipped: bool)``; pipelines clip the CT by default. Under the T1c-fixed rule the CT is the
MR fixed images (both-MR pairs) are never clipped.""" moving image; MR / undetectable images are never clipped. Returns
``(fixed, moving, clipped: bool)``."""
if clip_ct and _is_ct(moving):
return fixed, _clip_intensity(moving, *clip_ct), True
if clip_ct and _is_ct(fixed): if clip_ct and _is_ct(fixed):
return _clip_intensity(fixed, *clip_ct), True return _clip_intensity(fixed, *clip_ct), moving, True
return fixed, False return fixed, moving, False
def fix_orientation(ct, t1c, cand): def fix_orientation(ct, t1c, cand):
@ -258,13 +276,12 @@ def register_simpleitk(ct, t1c, threads=12, mode="benchmark", iters_full=2000):
except AttributeError: except AttributeError:
pass pass
ct = sitk.Cast(ct, sitk.sitkFloat32); t1c = sitk.Cast(t1c, sitk.sitkFloat32) ct = sitk.Cast(ct, sitk.sitkFloat32); t1c = sitk.Cast(t1c, sitk.sitkFloat32)
# full volumes, no mask / crop / pad; CT is the fixed image (or, if both # full volumes, no mask / crop / pad; the T1c is the fixed image
# inputs are MR, the first argument is)
fixed, moving = fixed_moving(ct, t1c) fixed, moving = fixed_moving(ct, t1c)
shape = np.array(ct.GetSize()) shape = np.array(ct.GetSize())
# Identity start (no centroid init); the multi-resolution Mattes-MI pyramid # Identity start (no centroid init); the multi-resolution Mattes-MI pyramid
# recovers the gross inter-session offset. The Euler center is the fixed # recovers the gross inter-session offset. The Euler center is the fixed
# (CT) geometric center. # (T1c) geometric center.
init = sitk.Euler3DTransform() init = sitk.Euler3DTransform()
init.SetCenter(tuple(float(x) for x in fixed.TransformIndexToPhysicalPoint( init.SetCenter(tuple(float(x) for x in fixed.TransformIndexToPhysicalPoint(
((fixed.GetSize()[0] - 1) // 2, (fixed.GetSize()[1] - 1) // 2, ((fixed.GetSize()[0] - 1) // 2, (fixed.GetSize()[1] - 1) // 2,
@ -284,7 +301,10 @@ def register_simpleitk(ct, t1c, threads=12, mode="benchmark", iters_full=2000):
r.SetInterpolator(sitk.sitkLinear); r.SetInitialTransform(init, inPlace=False) r.SetInterpolator(sitk.sitkLinear); r.SetInitialTransform(init, inPlace=False)
tr0 = r.Execute(fixed, moving) tr0 = r.Execute(fixed, moving)
dt = time.time() - t0 dt = time.time() - t0
tr = tr0.GetInverse() # contract: T1c -> CT # Execute(fixed, moving) returns the transform that resamples moving onto
# the fixed grid (fixed -> moving). With fixed=T1c / moving=CT (the
# shared rule) that is T1c -> CT -- already the contract transform.
tr = tr0
ha = sitk.GetArrayFromImage(t1c) ha = sitk.GetArrayFromImage(t1c)
gap = m6._skull_ring_gap(_warp_ct(ct, t1c, tr), ha) gap = m6._skull_ring_gap(_warp_ct(ct, t1c, tr), ha)
meta = { meta = {
@ -352,7 +372,7 @@ def _itk_transform_to_sitk_rigid(itk_tf):
def register_elastix(ct, t1c, workdir=None, threads=8): def register_elastix(ct, t1c, workdir=None, threads=8):
"""ITK-Elastix rigid CT->T1c (fixed=CT, moving=T1c), Mattes MI, full images. """ITK-Elastix rigid CT->T1c (fixed=T1c, moving=CT), Mattes MI, full images.
Runs in-process through the ITK-Elastix python API (pip ``itk`` + Runs in-process through the ITK-Elastix python API (pip ``itk`` +
``itk-elastix``, elastix 5.x). Rigid 6-DOF via ``EulerTransform`` (the ``itk-elastix``, elastix 5.x). Rigid 6-DOF via ``EulerTransform`` (the
@ -372,7 +392,7 @@ def register_elastix(ct, t1c, workdir=None, threads=8):
after a successful registration. after a successful registration.
""" """
itk = _elastix_api() itk = _elastix_api()
# full volumes, no mask / crop / pad; CT is the fixed image # full volumes, no mask / crop / pad; the T1c is the fixed image
fixed, moving = fixed_moving(ct, t1c) fixed, moving = fixed_moving(ct, t1c)
params = { params = {
"ImageDimension": 3, "ImageDimension": 3,
@ -414,9 +434,9 @@ def register_elastix(ct, t1c, workdir=None, threads=8):
tail = "\n".join(buf.getvalue().splitlines()[-10:]) tail = "\n".join(buf.getvalue().splitlines()[-10:])
raise RuntimeError(f"elastix registration failed: {e}\n{tail}") from e raise RuntimeError(f"elastix registration failed: {e}\n{tail}") from e
dt = time.time() - t0 dt = time.time() - t0
# The combination transform maps fixed(CT) -> moving(T1c) (elastix # The combination transform maps fixed -> moving (elastix convention);
# convention), i.e. the inverse of the contract (T1c->CT); # with fixed=T1c / moving=CT that is T1c -> CT, the contract direction.
# fix_orientation resolves the direction against the skull ring. # fix_orientation verifies it against the skull ring.
cand = _itk_transform_to_sitk_rigid(reg.GetCombinationTransform()) cand = _itk_transform_to_sitk_rigid(reg.GetCombinationTransform())
T, inverted = fix_orientation(ct, t1c, cand) T, inverted = fix_orientation(ct, t1c, cand)
try: try:
@ -442,7 +462,7 @@ def register_elastix(ct, t1c, workdir=None, threads=8):
def register_antspyx(ct, t1c, transform_type="Rigid", aff_iterations=None, def register_antspyx(ct, t1c, transform_type="Rigid", aff_iterations=None,
aff_shrink_factors=None, aff_smoothing_sigmas=None, aff_shrink_factors=None, aff_smoothing_sigmas=None,
aff_metric="mattes"): aff_metric="mattes"):
"""ANTs rigid CT->T1c via antspyx (fixed=CT, moving=T1c), full volumes. """ANTs rigid CT->T1c via antspyx (fixed=T1c, moving=CT), full volumes.
antspyx 0.6.3: ``ants.registration(fixed, moving, type_of_transform='Rigid', antspyx 0.6.3: ``ants.registration(fixed, moving, type_of_transform='Rigid',
aff_metric='mattes', aff_iterations=..., aff_shrink_factors=..., aff_metric='mattes', aff_iterations=..., aff_shrink_factors=...,
@ -465,7 +485,7 @@ def register_antspyx(ct, t1c, transform_type="Rigid", aff_iterations=None,
aff_iterations = tuple(aff_iterations) if aff_iterations else (500, 500, 200) aff_iterations = tuple(aff_iterations) if aff_iterations else (500, 500, 200)
aff_shrink_factors = tuple(aff_shrink_factors) if aff_shrink_factors else (4, 2, 1) aff_shrink_factors = tuple(aff_shrink_factors) if aff_shrink_factors else (4, 2, 1)
aff_smoothing_sigmas = tuple(aff_smoothing_sigmas) if aff_smoothing_sigmas else (3, 2, 0) aff_smoothing_sigmas = tuple(aff_smoothing_sigmas) if aff_smoothing_sigmas else (3, 2, 0)
# full volumes, no mask / crop / pad; CT is the fixed image # full volumes, no mask / crop / pad; the T1c is the fixed image
fx, mv = fixed_moving(ct, t1c) fx, mv = fixed_moving(ct, t1c)
import time import time
t0 = time.time() t0 = time.time()
@ -478,11 +498,10 @@ def register_antspyx(ct, t1c, transform_type="Rigid", aff_iterations=None,
aff_shrink_factors=aff_shrink_factors, aff_shrink_factors=aff_shrink_factors,
aff_smoothing_sigmas=aff_smoothing_sigmas) aff_smoothing_sigmas=aff_smoothing_sigmas)
dt = time.time() - t0 dt = time.time() - t0
# r['fwdtransforms'][0] is the ANTs forward transform: fixed(CT) -> moving # r['fwdtransforms'][0] is the ANTs forward transform: fixed -> moving.
# (T1c). The contract is T1c -> CT, i.e. its inverse; fix_orientation # With fixed=T1c / moving=CT that is T1c -> CT, the contract direction;
# verifies the direction against the skull ring. # fix_orientation verifies it against the skull ring.
T_ants = sitk.ReadTransform(r["fwdtransforms"][0]) cand = sitk.ReadTransform(r["fwdtransforms"][0])
cand = T_ants.GetInverse()
T, inverted = fix_orientation(ct, t1c, cand) T, inverted = fix_orientation(ct, t1c, cand)
meta = { meta = {
"pipeline": "antspyx", "toolkit": f"ANTsPy {getattr(ants, '__version__', '?')}", "pipeline": "antspyx", "toolkit": f"ANTsPy {getattr(ants, '__version__', '?')}",
@ -544,7 +563,7 @@ def _lta_to_sitk(path):
def register_synthmorph(ct, t1c, model="rigid", gpu=None, threads=None, def register_synthmorph(ct, t1c, model="rigid", gpu=None, threads=None,
extent=256, keep_scratch=False, extent=256, keep_scratch=False,
clip_ct=(0.0, 80.0)): clip_ct=(0.0, 80.0)):
"""SynthMorph CT->T1c (fixed=CT, moving=T1c) in the Docker image """SynthMorph CT->T1c (fixed=T1c, moving=CT) in the Docker image
``freesurfer/synthmorph`` (TensorFlow ``VxmAffineFeatureDetector``, ``freesurfer/synthmorph`` (TensorFlow ``VxmAffineFeatureDetector``,
pretrained ``synthmorph.rigid.1.h5`` weights). pretrained ``synthmorph.rigid.1.h5`` weights).
@ -586,11 +605,12 @@ def register_synthmorph(ct, t1c, model="rigid", gpu=None, threads=None,
# per-case scratch on a local (non-NFS) tmp fs # per-case scratch on a local (non-NFS) tmp fs
tmp = tempfile.mkdtemp(prefix="synthmorph_reg_") tmp = tempfile.mkdtemp(prefix="synthmorph_reg_")
try: try:
# full volumes, no mask / crop / pad; CT is the fixed image # full volumes, no mask / crop / pad; the T1c is the fixed image
fixed, moving = fixed_moving(ct, t1c) fixed, moving = fixed_moving(ct, t1c)
fixed_name = _modality_name(fixed) # before clipping (a clipped CT fixed_name = _modality_name(fixed)
moving_name = _modality_name(moving) # before clipping (a clipped CT
# is no longer intensity-detectable) # is no longer intensity-detectable)
fixed, ct_clipped = _maybe_clip_ct(fixed, clip_ct) fixed, moving, ct_clipped = _maybe_clip_ct_pair(fixed, moving, clip_ct)
sitk.WriteImage(sitk.Cast(fixed, sitk.sitkFloat32), sitk.WriteImage(sitk.Cast(fixed, sitk.sitkFloat32),
os.path.join(tmp, "fixed.nii.gz"), True) os.path.join(tmp, "fixed.nii.gz"), True)
sitk.WriteImage(sitk.Cast(moving, sitk.sitkFloat32), sitk.WriteImage(sitk.Cast(moving, sitk.sitkFloat32),
@ -609,9 +629,11 @@ def register_synthmorph(ct, t1c, model="rigid", gpu=None, threads=None,
cmd.append("-g") cmd.append("-g")
if threads: if threads:
cmd += ["-j", str(threads)] cmd += ["-j", str(threads)]
# the model estimates first -> second, so (fixed, moving) puts the
# -T LTA in the fixed->moving = T1c->CT contract direction
cmd += ["-e", str(extent), cmd += ["-e", str(extent),
"-T", "/data/f2m.lta", "-T", "/data/f2m.lta",
"/data/moving.nii.gz", "/data/fixed.nii.gz"] "/data/fixed.nii.gz", "/data/moving.nii.gz"]
t0 = time.time() t0 = time.time()
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=7200) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=7200)
dt = time.time() - t0 dt = time.time() - t0
@ -636,7 +658,7 @@ def register_synthmorph(ct, t1c, model="rigid", gpu=None, threads=None,
f"device={'GPU' + str(gpu) if gpu >= 0 else 'CPU'}, " f"device={'GPU' + str(gpu) if gpu >= 0 else 'CPU'}, "
f"extent {extent})"), f"extent {extent})"),
"convention": "T1c->CT (verified)", "auto_inverted": inverted, "convention": "T1c->CT (verified)", "auto_inverted": inverted,
"fixed": fixed_name, "moving": _modality_name(moving), "fixed": fixed_name, "moving": moving_name,
"seconds_reg": round(dt, 1), "seconds_reg": round(dt, 1),
"docker_image": SYNTHMORPH_IMAGE, "model": model, "gpu": gpu, "docker_image": SYNTHMORPH_IMAGE, "model": model, "gpu": gpu,
"extent": extent, "extent": extent,
@ -648,31 +670,91 @@ def register_synthmorph(ct, t1c, model="rigid", gpu=None, threads=None,
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 5. IMPACT (Docker image vboussot/elastix_impact) # 5. IMPACT (impact-reg-konfai -- KonfAI runtime, no Docker)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
IMPACT_IMAGE = "vboussot/elastix_impact" IMPACT_CLI = "impact-reg-konfai"
IMPACT_ELASTIX_BIN = "/lib/elastix-install/bin/elastix" # default preset from the published ImpactReg database (VBoussot/ImpactReg,
IMPACT_MODEL_CACHE = "data/impact_models" # resolved by the CLI): elastix B-spline driven by the IMPACT semantic metric
IMPACT_DEFAULT_MODEL = "MRSeg/MRSeg.pt" # -- per-level MIND (L1) + TotalSegmentator M730 (deepest layer, Dice), Static
IMPACT_HF_MODEL_URL = ( # mode (whole-image features, no backprop), 4 scales, 400/300/200/200
"https://huggingface.co/VBoussot/impact-torchscript-models/resolve/main/{rel}" # iterations, 2000 spatial samples, + Mattes MI (w 0.4) + bending energy.
# The published successor of the old MR_CT_TS reference parameter map.
IMPACT_DEFAULT_PRESET = "Elastix_IMPACT_Static"
# M6 robustness overrides -- raw elastix parameter lines the package applies
# on top of the preset's generated map (the ``parameter_overrides`` knob):
# * CenterOfGravity auto-init (instead of the preset's GeometricalCenter):
# aligns the intensity centroids -- the T1c head-crop and the CT head+neck
# FOV have their buffer centres ~50-100 mm apart, and the GeometricalCenter
# start leaves the metric with almost nothing in-buffer;
# * RequiredRatioOfValidSamples 0.1 (build default 0.25): the FOV-mismatched
# pairs keep only a minority of the random spatial samples in-buffer at the
# init; 0.1 keeps the divergence gate while letting the runs proceed (a
# genuinely diverging transform, <10% in-buffer, still aborts).
IMPACT_PARAM_OVERRIDES = (
"AutomaticTransformInitialization=true",
"AutomaticTransformInitializationMethod=CenterOfGravity",
"RequiredRatioOfValidSamples=0.1",
) )
# The stock elastix-impact 1.0.0 binary (vboussot/ImpactElastix) needs
# glibc >= 2.35 and links LibTorch 2.8.0; a host that cannot run it directly
# (this one: glibc 2.31 + env torch 2.14) builds a self-contained shim with
# scripts/setup_impact_konfai.sh, selected via KONFAI_ELASTIX_DIR.
IMPACT_ELASTIX_SHIM = os.path.join(
os.path.expanduser("~"), ".cache", "konfai", "elastix-impact-shim")
IMPACT_ELASTIX_STOCK = os.path.join(
os.path.expanduser("~"), ".cache", "konfai", "elastix-impact")
# orientation-gate threshold: a candidate DVF is kept as-is when its
# skull-ring gap score (std_mm + mid_mm) is below this. A misdirected field
# lands the head on the far side of the box (gap ~100-300 mm), so the margin
# is wide; see _orient_impact.
IMPACT_GAP_ACCEPT = 60.0
def _impact_docker_ok(): def _impact_cli_ok():
"""Return (ok, note): is the Docker CLI + the IMPACT image available?""" """Return (ok, note): is the impact-reg-konfai CLI installed?"""
if shutil.which("docker") is None: if shutil.which(IMPACT_CLI) is None:
return False, "docker CLI not found on PATH" return False, f"{IMPACT_CLI} not on PATH (pip install impact-reg-konfai)"
try: try:
r = subprocess.run(["docker", "image", "inspect", IMPACT_IMAGE], import importlib.metadata
capture_output=True, text=True, timeout=30) ver = importlib.metadata.version("impact-reg-konfai")
if r.returncode != 0: except Exception: # noqa
return False, (f"docker image {IMPACT_IMAGE} not pulled " ver = "?"
f"(run: docker pull {IMPACT_IMAGE})") return True, f"{IMPACT_CLI} {ver}"
except Exception as e: # noqa
return False, f"docker image check failed ({type(e).__name__}: {e})"
return True, IMPACT_IMAGE _ELASTIX_ROOT_CACHE = None
def _impact_elastix_root():
"""The elastix-impact install root that runs on this host, or None.
Tried in order: a caller-set ``KONFAI_ELASTIX_DIR``, the setup-script shim
(glibc 2.35 loader + LibTorch 2.8.0, for hosts too old for the stock
binary), and the package's own auto-install cache (populated by the first
run when the host can run the stock binary directly). Each candidate is
actually executed (``elastix -h``) so a stale/broken install is skipped."""
global _ELASTIX_ROOT_CACHE
if _ELASTIX_ROOT_CACHE is not None:
return _ELASTIX_ROOT_CACHE
from pathlib import Path
from impact_reg_konfai.models import elastix_install as _ei
root = os.environ.get("KONFAI_ELASTIX_DIR", "")
candidates = [c for c in (root, IMPACT_ELASTIX_SHIM, IMPACT_ELASTIX_STOCK)
if c]
found = None
for c in candidates:
if not os.path.exists(os.path.join(c, "bin", "elastix")):
continue
try:
_ei.try_elastix(Path(c))
found = c
break
except Exception: # noqa
continue
_ELASTIX_ROOT_CACHE = found
return found
_NGPUS_CACHE = None _NGPUS_CACHE = None
@ -691,200 +773,156 @@ def _n_gpus():
return _NGPUS_CACHE return _NGPUS_CACHE
def _impact_model_host(model): def _impact_dvf_from_h5(path):
"""Resolve a model to a file under the IMPACT model cache, returning """An ITK-form HDF5 transform (the ``:itktransform`` form the KonfAI
``(host_path, rel)`` where ``rel`` is the path relative to the cache (which presets write) read with h5py -- the SimpleITK build in this env has no
the pipeline bind-mounts at ``/Data/Models`` inside the container). ITK HDF5 transform reader. Returns ``(DisplacementFieldTransform,
dvf_image)``; the field is in mm, channel-last ``(z, y, x, 3)``."""
``model`` is either a path relative to the HF model layout (e.g. import h5py
``TS/M730.pt``) or an absolute host path. An absolute path outside the with h5py.File(str(path), "r") as f:
cache is copied into it (once) so the single ``/Data/Models`` mount covers grp = f["TransformGroup/0"]
it. Raises :class:`PipelineUnavailable` if the model is missing, with the ttype = grp["TransformType"][()][0]
exact download command for the HF-relative case. if isinstance(ttype, bytes):
""" ttype = ttype.decode()
rel = model or IMPACT_DEFAULT_MODEL fixed = grp["TransformFixedParameters"][()]
cache = d(IMPACT_MODEL_CACHE) params = grp["TransformParameters"][()]
if os.path.isabs(rel): if not str(ttype).startswith("DisplacementFieldTransform"):
src = rel raise RuntimeError(f"impact: unsupported h5 transform type {ttype!r}")
dst = os.path.join(cache, os.path.basename(rel)) size = fixed[:3][::-1].astype(int) # stored (x, y, z) -> array (z, y, x)
if not os.path.exists(dst): arr = params.reshape(int(size[0]), int(size[1]), int(size[2]), 3)
if not os.path.exists(src): img = sitk.GetImageFromArray(arr)
raise PipelineUnavailable(f"impact: model file not found: {src}") img.SetOrigin(tuple(float(v) for v in fixed[3:6]))
os.makedirs(os.path.dirname(dst), exist_ok=True) img.SetSpacing(tuple(float(v) for v in fixed[6:9]))
shutil.copy2(src, dst) img.SetDirection(tuple(float(v) for v in fixed[9:18]))
rel = os.path.basename(rel) # this SimpleITK build invalidates the image wrapper handed to the DVF
host = os.path.join(cache, rel) # constructor (it comes back as an empty uint8 image), so build the
if not os.path.exists(host): # transform from a copy and keep ``img`` for the caller
url = IMPACT_HF_MODEL_URL.format(rel=rel) return sitk.DisplacementFieldTransform(sitk.Image(img)), img
raise PipelineUnavailable(
f"impact: model not found at {host}. Download it once with:\n"
f" mkdir -p {os.path.dirname(host)} && curl -L -o {host} {url}\n"
f"(or `sh Data/Models/download_models.sh` from the ImpactLoss repo).")
return host, rel
def _impact_param_map(model_cont_path, gpu, iters, samples, voxel_sizes, def _impact_read_transform(path):
patch=11, subset=64, rescale=None, smoothing=None): """The preset's single-case transform output as
"""Build the Elastix-5.2 parameter file for a RIGID (Euler) IMPACT ``(sitk.Transform, dvf_image_or_None)``.
registration with a multi-scale semantic metric. By default the image
pyramid is NOT resampled (all-1 schedules) and the scale comes from the KonfAI presets write the registration in whatever form the preset
per-level ImpactVoxelSize (as the IMPACT docs recommend); pass explicit declares: an ITK-form HDF5 DisplacementFieldTransform (``Transform.h5``)
``rescale``/``smoothing`` space-separated schedules to use a real by default, or an ITK transform file (``.itk.txt`` / ``.tfm``) or a vector
coarse-to-fine shrinking pyramid instead.""" image (``.mha``) for other presets."""
n = len(voxel_sizes) from pathlib import Path
ones = " ".join(["1"] * (n * 3)) p = Path(path)
rescale = rescale or ones if p.name.endswith(".itk.txt") or p.suffix in (".h5", ".tfm"):
smoothing = smoothing or ones if p.suffix == ".h5":
mixed = "true" if gpu >= 0 else "false" return _impact_dvf_from_h5(p)
L = ["// IMPACT semantic-similarity metric (vboussot/ImpactLoss)"] return sitk.ReadTransform(str(p)), None
L.append('(Metric "Impact")') img = sitk.ReadImage(str(p))
for i, vs in enumerate(voxel_sizes): return (sitk.DisplacementFieldTransform(
L += [ sitk.Cast(img, sitk.sitkVectorFloat64)), img)
f'(ImpactModelsPath{i} "{model_cont_path}")',
f"(ImpactDimension{i} 3)",
f"(ImpactNumberOfChannels{i} 1)",
f"(ImpactPatchSize{i} {patch} {patch} {patch})",
f"(ImpactVoxelSize{i} {vs:g} {vs:g} {vs:g})",
f'(ImpactLayersMask{i} "01")',
f"(ImpactPCA{i} 0)",
f"(ImpactSubsetFeatures{i} {subset})",
f'(ImpactDistance{i} "L2")',
f"(ImpactLayersWeight{i} 1)",
]
L += [
'(ImpactMode "Jacobian")',
f"(ImpactGPU {gpu})",
f'(ImpactUseMixedPrecision "{mixed}")',
"(ImpactFeaturesMapUpdateInterval -1)",
'(ImpactWriteFeatureMaps "false")',
# safety gate: abort if < ratio of the spatial samples map inside the
# moving buffer. The build default is 0.25, but the no-mask M6 pairs
# (CT head-neck FOV vs the tight T1c crop) only have ~14% in-buffer at
# the CenterOfGravity auto-init, so elastix aborts at the first metric
# evaluation. 0.1 keeps the gate (a diverging transform still aborts)
# while letting the FOV-mismatched runs proceed -- the metric natively
# evaluates only the in-buffer samples.
"(RequiredRatioOfValidSamples 0.1)",
"",
"// rigid 6-DOF to be optimized (auto center init, no -t0)",
'(Transform "EulerTransform")',
'(TransformBase "ElastixTransformBase")',
'(TransformParameterReadoutFormat "AllParametersOneLine")',
'(FixedCenteredTransform "true")',
'(HowToCombineTransforms "Compose")',
'(AutomaticTransformInitialization "true")',
# align intensity centroids (heads), not buffer corners -- the CT FOV is
# much larger than the T1c crop, so GeometricalCenter leaves the heads
# ~100 mm apart and the metric aborts ("too many samples outside buffer")
'(AutomaticTransformInitializationMethod "CenterOfGravity")',
'(AutomaticScalesEstimation "true")',
"",
"(ImageDimension 3)",
'(FixedInternalImagePixelType "float")',
'(MovingInternalImagePixelType "float")',
'(UseDirectionCosines "true")',
]
L += [
'(Registration "MultiMetricMultiResolutionRegistration")',
f"(NumberOfResolutions {n})",
f"(MaximumNumberOfIterations {iters})",
f"(NumberOfSpatialSamples {samples})",
"",
'(FixedImagePyramid "FixedGenericImagePyramid")',
'(MovingImagePyramid "MovingGenericImagePyramid")',
f"(FixedImagePyramidRescaleSchedule {rescale})",
f"(MovingImagePyramidRescaleSchedule {rescale})",
f"(FixedImagePyramidSmoothingSchedule {smoothing})",
f"(MovingImagePyramidSmoothingSchedule {smoothing})",
"",
'(Optimizer "AdaptiveStochasticGradientDescent")',
'(UseAdaptiveStepSizes "true")',
'(UseMultiThreadingForMetrics "true")',
"(MaximumNumberOfSamplingAttempts 8)",
"(NumberOfSamplesForExactGradient 4096)",
"(SigmoidInitialTime 0.0)",
'(NoiseCompensation "true")',
'(ASGDParameterEstimationMethod "DisplacementDistribution")',
"",
'(ImageSampler "Random")',
'(NewSamplesEveryIteration "true")',
'(Interpolator "BSplineInterpolator")',
"(BSplineInterpolationOrder 3)",
"",
'(WriteResultImage "false")',
"(DefaultPixelValue -1024)",
'(ResultImageFormat "mha")',
'(ITKTransformOutputFileNameExtension "itk.txt")',
'(WriteTransformParametersEachIteration "false")',
'(WriteTransformParametersEachResolution "false")',
]
return "\n".join(L) + "\n"
def register_impact(ct, t1c, model=None, gpu=None, iters=2000, samples=5000, def _orient_impact(ct, t1c, cand, dvf_img):
threads=12, keep_scratch=False, scales=(6.0, 3.0, 1.5, 1.0), """``(T, inverted)`` in the contract direction, for an impact candidate.
downsample=False):
"""IMPACT rigid CT->T1c (fixed=CT, moving=T1c) in the Docker image ``cand`` is nominally T1c->CT: the preset ran with fixed=T1c / moving=CT
``vboussot/elastix_impact`` (Elastix 5.2.0 + IMPACT semantic metric). and its output field is on the fixed (T1c) grid. Displacement fields
cannot be inverted with ``GetInverse`` (ITK), so the direction gate does
not compare ``cand`` with ``cand^-1`` (see :func:`fix_orientation`); it
compares the skull-ring gap of ``cand`` against a reverse proxy -- the
sign-flipped field (first-order inverse, exact enough for the decision:
a misdirected DVF lands the head on the far side of the box, gap
~100+ mm instead of tens of mm) -- and falls back to it only when the
nominal direction is clearly wrong."""
ha = sitk.GetArrayFromImage(t1c).astype("float32")
s0 = _gap_score(_skull_gap(_warp_ct(ct, t1c, cand), ha))
if s0 <= IMPACT_GAP_ACCEPT:
return cand, False
if dvf_img is not None:
img = sitk.GetImageFromArray(-sitk.GetArrayViewFromImage(dvf_img))
img.CopyInformation(dvf_img)
rev = sitk.DisplacementFieldTransform(img)
else:
try:
rev = cand.GetInverse()
except Exception: # noqa: parametric transform with no inverse
return cand, False
s1 = _gap_score(_skull_gap(_warp_ct(ct, t1c, rev), ha))
if s1 < s0:
return rev, True
return cand, False
def register_impact(ct, t1c, preset=None, gpu=None, iters=None, samples=None,
keep_scratch=False):
"""IMPACT (semantic-similarity metric) CT->T1c via the
``impact-reg-konfai`` package (pip; KonfAI runtime driving the
elastix-impact 5.2 binary; no Docker).
IMPACT (arXiv:2503.24121) replaces the intensity metric with a *semantic* IMPACT (arXiv:2503.24121) replaces the intensity metric with a *semantic*
one: a TorchScript segmentation model (``MRSeg/MRSeg.pt`` by default, one. The default preset ``Elastix_IMPACT_Static`` is the published
MRSegmentator -- robust multi-modality segmentation on MRI *and* CT, the successor of the old ImpactReg ``MR_CT_TS`` reference parameter map: an
right prior for this cross-modality CT->T1c task; TotalSegmentator elastix **B-spline** (4 scales, FinalGridSpacing 14 mm, 400/300/200/200
``TS/M730.pt`` and the other models in the HF repo iterations, 2000 spatial samples, bending-energy regularizer) driven by
``VBoussot/impact-torchscript-models`` work too via ``model=``) is run on the IMPACT metric -- a per-level **MIND** (L1) + **TotalSegmentator M730**
randomly sampled patches of the fixed and moving images and the (deepest layer, Dice) combination in **Static** mode (whole-image
registration minimizes the distance between the extracted feature maps features, no backprop), plus a Mattes-MI secondary metric (w 0.4). Other
(L2, per the recommended config). Intensity standardization (zero mean / presets (``Elastix_IMPACT_Jacobian``, ``Generic_Rigid``,
unit variance per the model card) is applied *inside the metric*: at init ``Generic_Rigid_BSpline``, ``FireANTs_*``, ``ConvexAdam_*``) are
it computes whole-volume [min, max, mean, sigma] per image and the model's selectable with ``preset=``; the live database is listed by
normalize branch rescales every patch with that image's own (mean, sigma), ``impact-reg-konfai register --help``.
so no pipeline-side pre-normalization is needed (and would be an exact
no-op -- z-scoring is affine-invariant). This is a genuinely different
metric from the Mattes-MI pipelines above -- it is the cross-modality tool
the image was built for.
The pipeline registers on the full CT / T1c volumes (no head box) with no Fixed/moving: the pair is registered in the ImpactReg **MR_CT**
manual centroid init: elastix's ``AutomaticTransformInitialization`` convention (fixed=T1c, moving=CT), which coincides with the shared
(CenterOfGravity) aligns the intensity centroids. It uses a rigid 6-DOF fixed-image rule (the T1c is the fixed image). The preset's output is a
``EulerTransform`` (the 5.x name for the classic 3D rigid), ASGD, a 4-level displacement field on the **fixed** grid mapping fixed->moving, so the
scale pyramid (no image resampling). field comes out directly in the contract direction T1c->CT (displacement
fields cannot be inverted with ``GetInverse``, which the fixed=CT
direction would have required). That also keeps the metric's
sample-validity gate healthy: samples drawn from the tight T1c head crop
(fixed) map mostly inside the large CT head+neck FOV (moving), unlike
fixed=CT where only ~14% of the samples are in-buffer at the auto-init.
The transform is the preset's native deformable B-spline (a DVF) -- the
contract (warp the CT concentric with the MR head) is unchanged.
Like every other pipeline this runs with **fixed=CT, moving=T1c** on the M6 robustness overrides (raw elastix lines the package applies on top of
full volumes: no mask, no crop / pad (shared rules above). The IMPACT the preset's generated map): CenterOfGravity auto init (the preset's
metric draws random spatial samples from the fixed (CT) buffer and GeometricalCenter start leaves the intensity centroids ~50-100 mm apart
evaluates them only where they map inside the moving (T1c) buffer; when on the head+neck FOV mismatch) and ``RequiredRatioOfValidSamples 0.1``
too few samples are in-buffer, elastix aborts ("too many samples map (the build default 0.25 aborts the FOV-mismatched pairs at the init; 0.1
outside moving image buffer"). On the M6 head-neck CTs (FOV keeps the divergence gate).
~300x300x251 mm) vs the tight T1c head crop (~220x220x150 mm) only ~14%
of the samples are in-buffer at the CenterOfGravity auto-init, so the
M6 runs abort -- IMPACT needs a fixed volume whose FOV is comparable to
the moving crop (e.g. a head-only CT). The returned transform satisfies
the unified contract (T1c->CT).
Budget: the default ``iters=2000`` (MaximumNumberOfIterations) /
``samples=5000`` (NumberOfSpatialSamples, the elastix default) runs the
full benchmark budget for best results -- roughly 10x the authors'
500/2000 Jacobian budget, i.e. hours per case on full volumes. Lower
``iters``/``samples`` to trade accuracy for speed.
The result is read back from ``TransformParameters.0.itk.txt``
(an ``Euler3DTransform`` in the world physical frame) and checked by
:func:`fix_orientation`.
Preconditions (else :class:`PipelineUnavailable`): the ``docker`` CLI, the The package auto-downloads its one-time assets from HF/GitHub on first
``vboussot/elastix_impact`` image, and the model asset under the cache. A use: the preset app (``VBoussot/ImpactReg``), the TorchScript feature
GPU is used when one is visible (auto-spread over the host GPUs by pid); models (``VBoussot/impact-torchscript-models``) and the elastix-impact
pass ``gpu=-1`` to force CPU. binary (``vboussot/ImpactElastix``) into ``~/.cache``. The binary needs
glibc >= 2.35 and LibTorch 2.8.0; on a host that cannot run it directly
(this one: glibc 2.31 + torch 2.14) ``scripts/setup_impact_konfai.sh``
builds a self-contained shim, selected via ``KONFAI_ELASTIX_DIR``.
Budget: ``iters`` / ``samples`` forward to the preset's
``max_iterations`` / ``spatial_samples`` knobs (only effective for
presets without a per-resolution model matrix; the default preset's
matrix wins). A GPU is used when one is visible (auto-spread over the
host GPUs by pid); pass ``gpu=-1`` to force CPU (the engine then turns
mixed precision off). The result is read back from
``<out>/P000/<group>.h5`` (an ITK-form DisplacementFieldTransform on the
T1c grid) and direction-checked by :func:`_orient_impact` (skull-ring
gap vs the reverse-proxy field).
""" """
import time import time
ok, note = _impact_docker_ok() ok, note = _impact_cli_ok()
if not ok: if not ok:
raise PipelineUnavailable(f"impact: {note}") raise PipelineUnavailable(f"impact: {note}")
model_host, model_rel = _impact_model_host(model) root = _impact_elastix_root()
if root is None:
raise PipelineUnavailable(
"impact: no runnable elastix-impact binary on this host (the stock "
"build needs glibc >= 2.35 + LibTorch 2.8.0). Run "
"scripts/setup_impact_konfai.sh once, or point KONFAI_ELASTIX_DIR "
"at a working install.")
preset = preset or IMPACT_DEFAULT_PRESET
# full volumes, no mask / crop / pad; CT is the fixed image (or, if both # fixed=T1c (MR), moving=CT: see the docstring (contract direction comes
# inputs are MR, the first argument). No manual init -- elastix's # out of the preset directly; MR_CT convention = the shared rule)
# AutomaticTransformInitialization aligns the image centers.
fixed, moving = fixed_moving(ct, t1c) fixed, moving = fixed_moving(ct, t1c)
# GPU: auto-spread across the host GPUs by pid, or forced value # GPU: auto-spread across the host GPUs by pid, or forced value
@ -892,105 +930,78 @@ def register_impact(ct, t1c, model=None, gpu=None, iters=2000, samples=5000,
ngpu = _n_gpus() ngpu = _n_gpus()
gpu = (os.getpid() % ngpu) if ngpu else -1 gpu = (os.getpid() % ngpu) if ngpu else -1
# per-level feature-extraction scales (recommended 6/3/1.5/1 mm), scaled so
# the finest level matches the moving image's base spacing
base = float(np.median(moving.GetSpacing()))
n = len(scales)
voxel_sizes = [base * s for s in scales]
# image pyramid: IMPACT's docs recommend NO image resampling (all-1 schedules)
# with the scale carried by ImpactVoxelSize; `downsample` instead uses a real
# shrinking pyramid (2x per level) with ImpactVoxelSize matching the resampled
# spacing (classical coarse-to-fine).
if downsample:
rescale = []
smoothing = []
for i in range(n):
f = 2.0 ** (n - 1 - i)
rescale += [f, f, f]
s = n - 1 - i
smoothing += [s, s, s]
schedule = " ".join(f"{x:g}" for x in rescale)
smoothe = " ".join(f"{x:g}" for x in smoothing)
voxel_sizes = [base * (2.0 ** (n - 1 - i)) for i in range(n)]
else:
schedule = " ".join(["1"] * (n * 3))
smoothe = schedule
# per-case scratch on a local (non-NFS) tmp fs: Data/ + Out/
tmp = tempfile.mkdtemp(prefix="impact_reg_") tmp = tempfile.mkdtemp(prefix="impact_reg_")
datadir = os.path.join(tmp, "Data") tail = ""
outdir = os.path.join(tmp, "Out")
os.makedirs(datadir, exist_ok=True)
os.makedirs(outdir, exist_ok=True)
try: try:
sitk.WriteImage(sitk.Cast(fixed, sitk.sitkFloat32), fixed_path = os.path.join(tmp, "Fixed.mha")
os.path.join(datadir, "Fixed_image.mha"), True) moving_path = os.path.join(tmp, "Moving.mha")
sitk.WriteImage(sitk.Cast(moving, sitk.sitkFloat32), outdir = os.path.join(tmp, "Out")
os.path.join(datadir, "Moving_image.mha"), True) sitk.WriteImage(sitk.Cast(fixed, sitk.sitkFloat32), fixed_path, True)
param_text = _impact_param_map(os.path.join("/Data/Models", model_rel), sitk.WriteImage(sitk.Cast(moving, sitk.sitkFloat32), moving_path, True)
gpu, iters, samples, voxel_sizes,
rescale=schedule, smoothing=smoothe)
with open(os.path.join(datadir, "ParameterMap.txt"), "w") as f:
f.write(param_text)
# debug aid: keep a persistent copy of the exact parameter map elastix cmd = [IMPACT_CLI, "register", preset,
# is about to run, written BEFORE the docker call so an abort leaves it "-f", fixed_path, "-m", moving_path,
# behind for inspection (per-pid name: parallel workers do not clobber "-o", outdir,
# each other's dumps) "--fields-only", # we derive the warp ourselves
dump_dir = d("data/m6reg/impact/paramdump") "--tmp-dir", os.path.join(tmp, "Work")]
os.makedirs(dump_dir, exist_ok=True)
family = os.path.basename(os.path.dirname(model_rel)) or "model"
param_dump = os.path.join(dump_dir, f"ParameterMap_{family}_{os.getpid()}.txt")
with open(param_dump, "w") as f:
f.write(param_text)
cmd = ["docker", "run", "--rm"]
if gpu >= 0: if gpu >= 0:
cmd += ["--gpus", "all"] cmd += ["--gpu", str(gpu)]
cmd += [ # preset tuning (YAML values, forwarded by the CLI to the preset app):
"-v", f"{datadir}:/Data", # the M6 robustness overrides, always; the budget knobs, when set
"-v", f"{outdir}:/Out", # (no-op for presets whose per-resolution matrix wins)
"-v", f"{d(IMPACT_MODEL_CACHE)}:/Data/Models", ov = "[" + ",".join(f'"{o}"' for o in IMPACT_PARAM_OVERRIDES) + "]"
IMPACT_IMAGE, IMPACT_ELASTIX_BIN, cmd += ["--set", f"parameter_overrides={ov}"]
"-f", "/Data/Fixed_image.mha", "-m", "/Data/Moving_image.mha", if iters is not None:
"-p", "/Data/ParameterMap.txt", cmd += ["--set", f"max_iterations={int(iters)}"]
"-out", "/Out/", "-threads", str(threads), if samples is not None:
] cmd += ["--set", f"spatial_samples={int(samples)}"]
env = os.environ.copy()
env["KONFAI_ELASTIX_DIR"] = root
t0 = time.time() t0 = time.time()
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=7200) proc = subprocess.run(cmd, capture_output=True, text=True,
timeout=7200, env=env)
dt = time.time() - t0 dt = time.time() - t0
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:])
if proc.returncode != 0: if proc.returncode != 0:
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:]) raise RuntimeError(f"impact: {IMPACT_CLI} failed "
raise RuntimeError(f"impact: docker elastix failed (rc={proc.returncode}):\n{tail}") f"(rc={proc.returncode}):\n{tail}")
itk_tf = os.path.join(outdir, "TransformParameters.0.itk.txt") case_dirs = sorted(p for p in os.listdir(outdir)
if not os.path.exists(itk_tf): if os.path.isdir(os.path.join(outdir, p)))
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:]) if not case_dirs:
raise RuntimeError(f"impact: no TransformParameters.0.itk.txt produced:\n{tail}") raise RuntimeError(f"impact: no case output under {outdir}:\n{tail}")
case_dir = os.path.join(outdir, case_dirs[0])
# the optimized Euler (world frame) is the full combination transform entries = [p for p in os.listdir(case_dir)
# (AutomaticTransformInitialization is folded in by elastix); elastix if os.path.isfile(os.path.join(case_dir, p))
# maps moving(T1c) -> fixed(CT), which is the contract direction; and not p.startswith("Moved")]
# fix_orientation verifies it against the skull ring. if len(entries) != 1:
T_main = sitk.ReadTransform(itk_tf) raise RuntimeError(
T, inverted = fix_orientation(ct, t1c, T_main) f"impact: expected exactly one transform output in {case_dir}, "
f"got {entries}:\n{tail}")
T_main, dvf_img = _impact_read_transform(os.path.join(case_dir, entries[0]))
T, inverted = _orient_impact(ct, t1c, T_main, dvf_img)
finally: finally:
if not keep_scratch: if not keep_scratch:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
family = os.path.basename(os.path.dirname(model_rel)) \
or os.path.splitext(os.path.basename(model_rel))[0]
meta = { meta = {
"pipeline": "impact", "pipeline": "impact",
"toolkit": f"Docker {IMPACT_IMAGE} (Elastix 5.2.0 + IMPACT)", "toolkit": (f"impact-reg-konfai (KonfAI; elastix-impact 5.2, "
"metric": f"IMPACT semantic ({family} {os.path.basename(model_rel)}, L2, Jacobian)", f"preset {preset})"),
"transform": (f"Rigid (EulerTransform, ASGD, {n} scales, auto center init; " "metric": ("IMPACT semantic (MIND R1D2_3D L1 + TotalSegmentator M730 "
"deepest-layer Dice, per-level weights, Static) + "
"Mattes MI (w 0.4) + bending energy"),
"transform": (f"Deformable (preset-native B-spline DVF on the T1c grid; "
f"CenterOfGravity auto init; "
f"device={'GPU' + str(gpu) if gpu >= 0 else 'CPU'})"), f"device={'GPU' + str(gpu) if gpu >= 0 else 'CPU'})"),
"convention": "T1c->CT (verified)", "auto_inverted": inverted, "convention": "T1c->CT (verified)", "auto_inverted": inverted,
"fixed": _modality_name(fixed), "moving": _modality_name(moving), "fixed": _modality_name(fixed), "moving": _modality_name(moving),
"seconds_reg": round(dt, 1), "seconds_reg": round(dt, 1),
"docker_image": IMPACT_IMAGE, "model": model_rel, "gpu": gpu, "preset": preset, "gpu": gpu, "iters": iters, "samples": samples,
"iters": iters, "samples": samples, "param_dump": param_dump, "parameter_overrides": list(IMPACT_PARAM_OVERRIDES),
"elastix_root": os.path.basename(root),
} }
if keep_scratch: if keep_scratch:
meta["scratch"] = tmp meta["scratch"] = tmp
@ -1064,7 +1075,7 @@ def _parse_niftyreg_affine(path):
def register_niftyreg(ct, t1c, threads=24, levels=3, maxit=15, keep_scratch=False, def register_niftyreg(ct, t1c, threads=24, levels=3, maxit=15, keep_scratch=False,
backend="auto", gpu=None, clip_ct=(0.0, 80.0)): backend="auto", gpu=None, clip_ct=(0.0, 80.0)):
"""NiftyReg rigid CT->T1c (reference=CT, floating=T1c) via ``reg_aladin``. """NiftyReg rigid CT->T1c (reference=T1c, floating=CT) via ``reg_aladin``.
reg_aladin (Modat et al., J. Med. Img. 1(2) 024003, 2014) is a global reg_aladin (Modat et al., J. Med. Img. 1(2) 024003, 2014) is a global
block-matching registration: at each level of a ``levels``-level Gaussian block-matching registration: at each level of a ``levels``-level Gaussian
@ -1133,11 +1144,12 @@ def register_niftyreg(ct, t1c, threads=24, levels=3, maxit=15, keep_scratch=Fals
gpu, use_gpu, platf = -1, False, None gpu, use_gpu, platf = -1, False, None
interp = "3" interp = "3"
toolkit = _niftyreg_version(binpath) toolkit = _niftyreg_version(binpath)
# full volumes, no mask / crop / pad; reference (fixed) = CT, floating = T1c # full volumes, no mask / crop / pad; reference (fixed) = T1c, floating = CT
fixed, moving = fixed_moving(ct, t1c) fixed, moving = fixed_moving(ct, t1c)
fixed_name = _modality_name(fixed) # before clipping (a clipped CT is no fixed_name = _modality_name(fixed)
# longer intensity-detectable) moving_name = _modality_name(moving) # before clipping (a clipped CT is
fixed, ct_clipped = _maybe_clip_ct(fixed, clip_ct) # no longer intensity-detectable)
fixed, moving, ct_clipped = _maybe_clip_ct_pair(fixed, moving, clip_ct)
tmp = tempfile.mkdtemp(prefix="niftyreg_reg_") tmp = tempfile.mkdtemp(prefix="niftyreg_reg_")
dt = 0.0 dt = 0.0
try: try:
@ -1178,9 +1190,10 @@ def register_niftyreg(ct, t1c, threads=24, levels=3, maxit=15, keep_scratch=Fals
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:]) tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:])
raise RuntimeError(f"niftyreg: no affine output produced:\n{tail}") raise RuntimeError(f"niftyreg: no affine output produced:\n{tail}")
T = _parse_niftyreg_affine(aff) T = _parse_niftyreg_affine(aff)
# aff.txt is documented as CT -> T1c (Affine*Reference=Floating); # aff.txt is documented as Affine * Reference = Floating (ref -> flo);
# the contract is T1c -> CT. fix_orientation resolves the direction # with reference=T1c / floating=CT the direction the file actually
# empirically (on these cases it keeps the file's affine as-is). # holds is resolved empirically by fix_orientation against the skull
# ring (see _parse_niftyreg_affine).
T, inverted = fix_orientation(ct, t1c, T) T, inverted = fix_orientation(ct, t1c, T)
finally: finally:
if not keep_scratch: if not keep_scratch:
@ -1194,7 +1207,7 @@ def register_niftyreg(ct, t1c, threads=24, levels=3, maxit=15, keep_scratch=Fals
"transform": (f"Rigid (reg_aladin -rigOnly, {levels}-level pyramid, " "transform": (f"Rigid (reg_aladin -rigOnly, {levels}-level pyramid, "
f"{maxit} TLS iters/level, cubic interp)"), f"{maxit} TLS iters/level, cubic interp)"),
"convention": "T1c->CT (verified)", "auto_inverted": inverted, "convention": "T1c->CT (verified)", "auto_inverted": inverted,
"fixed": fixed_name, "moving": _modality_name(moving), "fixed": fixed_name, "moving": moving_name,
"seconds_reg": round(dt, 1), "seconds_reg": round(dt, 1),
"levels": levels, "maxit": maxit, "interp": interp, "gpu": gpu, "levels": levels, "maxit": maxit, "interp": interp, "gpu": gpu,
"clip_ct": [float(clip_ct[0]), float(clip_ct[1])] if ct_clipped else None, "clip_ct": [float(clip_ct[0]), float(clip_ct[1])] if ct_clipped else None,
@ -1259,15 +1272,21 @@ def availability():
else: else:
out[name] = (True, f"Docker {SYNTHMORPH_IMAGE}") out[name] = (True, f"Docker {SYNTHMORPH_IMAGE}")
elif name == "impact": elif name == "impact":
ok, note = _impact_docker_ok() ok, note = _impact_cli_ok()
if not ok: if not ok:
out[name] = (False, note) out[name] = (False, note)
continue continue
try: root = _impact_elastix_root()
host, rel = _impact_model_host(None) if root is None:
out[name] = (True, f"Docker {IMPACT_IMAGE} + model {rel}") out[name] = (False,
except Exception as e: # noqa note + "; no runnable elastix-impact binary "
out[name] = (False, str(e)) "(needs glibc >= 2.35 + LibTorch 2.8.0) -- run "
"scripts/setup_impact_konfai.sh once")
else:
out[name] = (True,
note + "; elastix-impact under "
f"{os.path.basename(root)}; preset "
f"{IMPACT_DEFAULT_PRESET}")
except Exception as e: # noqa except Exception as e: # noqa
out[name] = (False, repr(e)) out[name] = (False, repr(e))
return out return out