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:
parent
73234db39e
commit
5e525c07c8
6 changed files with 807 additions and 624 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
110
scripts/setup_impact_konfai.sh
Normal file
110
scripts/setup_impact_konfai.sh
Normal 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"
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in a new issue