feat(benchmark): add ANTs deformable variant pipelines
Extend registration_pipelines and bench_registration with stock antspyx ANTs presets (SyNRA, SyN, Repro sr/br, QuickRepro sr/br) plus the legacy Rigid -> SyNOnly workflow, and update the deformable benchmark documentation with follow-up ANTs-variant runs and results.
This commit is contained in:
parent
aa7ac26f4c
commit
bec0e9834f
3 changed files with 418 additions and 10 deletions
|
|
@ -9,7 +9,12 @@ contract, and the same metrics.
|
||||||
|
|
||||||
Run date: 2026-10-02 (main run 05:43–07:52 local; serial `synthmorph`
|
Run date: 2026-10-02 (main run 05:43–07:52 local; serial `synthmorph`
|
||||||
re-run 09:58–10:07 local; accelerated `simpleitk` re-run 15:31–16:26
|
re-run 09:58–10:07 local; accelerated `simpleitk` re-run 15:31–16:26
|
||||||
local). 10 M6 test cases, 3× NVIDIA A100 80GB PCIe, 96-core host, conda
|
local). Follow-up ANTs-variant runs 2026-10-03 (08:44–09:12 local: five
|
||||||
|
stock antspyx presets — direct `SyNRA` / `SyN`, `Repro[sr|br]`,
|
||||||
|
`QuickRepro[sr|br]`; 11:00–11:26 local: the legacy two-call
|
||||||
|
`Rigid → SyNOnly` workflow; 12:44–12:49 local: re-run of the stock `SyN`
|
||||||
|
preset as `ants_syn`, replacing the initial `SyNOnly` row) on the same
|
||||||
|
10 cases (see the *ANTs variants* section). 10 M6 test cases, 3× NVIDIA A100 80GB PCIe, 96-core host, conda
|
||||||
env `longitudinal-313`. Main run: 6 workers (24 threads each, 2 workers
|
env `longitudinal-313`. Main run: 6 workers (24 threads each, 2 workers
|
||||||
per GPU). `synthmorph` was re-run serially (1 worker, one container per
|
per GPU). `synthmorph` was re-run serially (1 worker, one container per
|
||||||
GPU at a time) because the concurrent run lost 6/9 cases to cuDNN
|
GPU at a time) because the concurrent run lost 6/9 cases to cuDNN
|
||||||
|
|
@ -143,8 +148,9 @@ for the case):
|
||||||
### Interpretation
|
### Interpretation
|
||||||
|
|
||||||
- **`antspyx` (Affine + SyN) leads on achieved MI** (median 0.723; best on
|
- **`antspyx` (Affine + SyN) leads on achieved MI** (median 0.723; best on
|
||||||
7 of 9 cases and within 0.02 of best on the other 2) at the best
|
8 of 9 cases and within 0.002 of best on the other —
|
||||||
median ImpactLoss of the gradient-based tools (0.158, within 0.002 of
|
`m6_26TYQI3C`, 0.799 vs `simpleitk`'s 0.801) at the best median
|
||||||
|
ImpactLoss of the gradient-based tools (0.158, within 0.002 of
|
||||||
`synthmorph`'s 0.156), and it is the fastest full-workflow pipeline
|
`synthmorph`'s 0.156), and it is the fastest full-workflow pipeline
|
||||||
(31.7 s/case) — the best time/quality trade of the six.
|
(31.7 s/case) — the best time/quality trade of the six.
|
||||||
- **`simpleitk` is second on MI** (0.698; it keeps its per-case win on
|
- **`simpleitk` is second on MI** (0.698; it keeps its per-case win on
|
||||||
|
|
@ -213,10 +219,11 @@ for the case):
|
||||||
0.156–0.164, ~5 % spread; per-case bests spread across five tools,
|
0.156–0.164, ~5 % spread; per-case bests spread across five tools,
|
||||||
`elastix` with 0/9), whereas the MI scale spans 0.553–0.723
|
`elastix` with 0/9), whereas the MI scale spans 0.553–0.723
|
||||||
(0.17 nats absolute) and the per-case bests go 8/9 `antspyx`, 1/9
|
(0.17 nats absolute) and the per-case bests go 8/9 `antspyx`, 1/9
|
||||||
`simpleitk`, 0/9 the rest. The deformable stage's main effect is fine intensity alignment on
|
`simpleitk`, 0/9 the rest. The deformable stage's main effect is fine
|
||||||
top of the gross anatomy the rigid stages already place — exactly what
|
intensity alignment on top of the gross anatomy the rigid stages
|
||||||
MI measures and ImpactLoss is less sensitive to. For ranking the
|
already place — exactly what MI measures and ImpactLoss is less
|
||||||
deformable workflows, achieved MI is the more discriminative score.
|
sensitive to. For ranking the deformable workflows, achieved MI is
|
||||||
|
the more discriminative score.
|
||||||
- **`synthmorph` infrastructure note.** In the main concurrent run (6
|
- **`synthmorph` infrastructure note.** In the main concurrent run (6
|
||||||
workers, 2 containers per GPU) 6 of 9 cases failed with `CUDNN failed
|
workers, 2 containers per GPU) 6 of 9 cases failed with `CUDNN failed
|
||||||
to allocate the scratch space ... [Op:Conv3D]` inside the joint model's
|
to allocate the scratch space ... [Op:Conv3D]` inside the joint model's
|
||||||
|
|
@ -236,6 +243,135 @@ for the case):
|
||||||
term. The same weak correlation with perceived alignment noted in the
|
term. The same weak correlation with perceived alignment noted in the
|
||||||
rigid benchmark, so it is reported for reference only.
|
rigid benchmark, so it is reported for reference only.
|
||||||
|
|
||||||
|
## ANTs variants: direct SyNRA / SyN and the Repro / QuickRepro workflows
|
||||||
|
|
||||||
|
The current `antspyx` row above is antspyx's stock **`SyN` preset** (Affine
|
||||||
|
2100×1200×1200×0 + SyN[0.2,3,0] 40×20×0, Mattes MI 32, center-of-mass init).
|
||||||
|
As a follow-up (2026-10-03, 6 workers as the main run) seven further
|
||||||
|
ANTs configurations were benchmarked on the same 10 cases and the same
|
||||||
|
contract — the question: is there a better stock ANTs deformable workflow
|
||||||
|
for CT→T1c than the `SyN` preset?
|
||||||
|
|
||||||
|
| pipeline | antspyx preset | stages (all COM init unless noted) |
|
||||||
|
|---|---|---|
|
||||||
|
| **ants_rigid_synonly** | legacy two-call (`Rigid` + `SyNOnly`) | Rigid[0.25] (Mattes 32, 500×500×200, 4×2×1, 3×2×0) → SyNOnly SyN[0.2,3,0] (Mattes 32, 100×100×0, 4×2×1, 2×1×0) initialized from the Rigid stage; `Composite.h5` read-back |
|
||||||
|
| **ants_synra** | `SyNRA` | Rigid[0.25] + Affine[0.25] (each Mattes 32, 2100×1200×1200×0, 4×2×2×1, 3×2×1×0) + SyN[0.2,3,0] (Mattes 32, 40×20×0, 4×2×1) |
|
||||||
|
| **ants_syn** | `SyN` | Affine[0.25] (Mattes 32, 2100×1200×1200×0, 4×2×2×1, 3×2×1×0) + SyN[0.2,3,0] (Mattes 32, 40×20×0, 4×2×1); COM init — the stock preset, identical configuration to the existing `antspyx` row |
|
||||||
|
| **ants_synreprosr** | `antsRegistrationSyNRepro[sr]` | Rigid[0.1] (GC 1/1/Regular/0.25, 1000×500×250×100, 8×4×2×1, 3×2×1×0vox) + SyN[0.2,3,0] (CC 1/2, 50×20, 2×1, 1×0vox) |
|
||||||
|
| **ants_synreprobr** | `antsRegistrationSyNRepro[br]` | the `Repro` Rigid[0.1] stage + BSplineSyN[0.2,26,0,3] (CC 1/2, 50×20, 2×1, 1×0vox) |
|
||||||
|
| **ants_synquickreprosr** | `antsRegistrationSyNQuickRepro[sr]` | Rigid[0.1] (GC, 1000×500×250×0, 8×4×2×1) + SyN[0.2,3,0] (CC 1/2, 50×0, 2×1) |
|
||||||
|
| **ants_synquickreprobr** | `antsRegistrationSyNQuickRepro[br]` | Rigid[0.1] (GC, 1000×500×250×0, 8×4×2×1) + BSplineSyN[0.2,26,0,3] (CC 1/2, 50×0, 2×1) |
|
||||||
|
|
||||||
|
`sr`/`br` select the deformable stage (SyN vs BSplineSyN, antspyx preset
|
||||||
|
naming — not a brain mask); these are the same stage configurations the
|
||||||
|
`antsRegistrationSyNRepro*.sh` / `antsRegistrationSyNQuickRepro*.sh`
|
||||||
|
workflows use. `ants_rigid_synonly` is the two-call workflow that an early
|
||||||
|
version of this benchmark used: a Rigid stage, then a
|
||||||
|
*deeper* SyNOnly stage (100 iterations at every level incl. full
|
||||||
|
resolution, vs the `SyN` preset's 40×20×0) initialized from the Rigid
|
||||||
|
stage's transform. Full volumes, no CT clip, each toolkit preset's own
|
||||||
|
metrics (GC/CC/Mattes as above). As for the other rows, the stage files
|
||||||
|
are rasterised to the T1c-grid displacement field and scored with the
|
||||||
|
shared code; the composite read-back was cross-checked per case against
|
||||||
|
ANTs' own resampling (**NCC 1.0 on all 63 scored cases**,
|
||||||
|
`own_warp_ncc_head` in the JSONs). One read-back subtlety: when ANTs runs Rigid + Affine as separate
|
||||||
|
linear stages (`SyNRA`), it writes the merged linear stage as a *binary*
|
||||||
|
`0GenericAffine.mat` that SimpleITK cannot parse; the pipeline recovers it
|
||||||
|
by point-probing the transform (exact for affine maps, verified to
|
||||||
|
2×10⁻⁴ mm).
|
||||||
|
|
||||||
|
All six variants registered 9/9 (the 10th is the known incomplete-CT data
|
||||||
|
issue). **Bold** = best for the column; `antspyx` is the existing row,
|
||||||
|
re-listed for reference.
|
||||||
|
|
||||||
|
| pipeline | registered | s/reg (med) | ImpactLoss (med/mean) | MI (med/mean, nats) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **ants_rigid_synonly** | 9 | 208.4 | 0.156 / 0.155 | **0.753 / 0.763** |
|
||||||
|
| **antspyx** (existing) | 9 | 31.7 | 0.158 / 0.156 | 0.723 / 0.729 |
|
||||||
|
| **ants_synra** | 9 | 43.6 | 0.158 / 0.156 | 0.722 / 0.729 |
|
||||||
|
| **ants_syn** | 9 | 33.8 | 0.158 / 0.156 | 0.722 / 0.729 |
|
||||||
|
| **ants_synreprosr** | 9 | 294.7 | **0.151 / 0.153** | 0.530 / 0.529 |
|
||||||
|
| **ants_synreprobr** | 9 | 370.3 | 0.156 / 0.157 | 0.541 / 0.537 |
|
||||||
|
| **ants_synquickreprosr** | 9 | 83.7 | 0.154 / 0.156 | 0.542 / 0.534 |
|
||||||
|
| **ants_synquickreprobr** | 9 | 96.0 | 0.158 / 0.158 | 0.549 / 0.538 |
|
||||||
|
|
||||||
|
Per-case MI (nats, unclipped score; **bold** = best for the case):
|
||||||
|
|
||||||
|
| case | antspyx | ants_rigid_synonly | ants_synra | ants_syn | ants_synreprosr | ants_synreprobr | ants_synquickreprosr | ants_synquickreprobr |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| m6_22GD5VL2_20230724 | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT |
|
||||||
|
| m6_22IRRKU6_20240115 | 0.679 | **0.741** | 0.681 | 0.681 | 0.623 | 0.646 | 0.630 | 0.627 |
|
||||||
|
| m6_23XIMO4M_20230222 | 0.723 | **0.760** | 0.722 | 0.722 | 0.530 | 0.544 | 0.529 | 0.530 |
|
||||||
|
| m6_24V62QQM_20230223 | 0.789 | **0.824** | 0.789 | 0.787 | 0.520 | 0.528 | 0.542 | 0.549 |
|
||||||
|
| m6_24V62QQM_20231017 | **0.720** | 0.716 | **0.720** | **0.720** | 0.473 | 0.475 | 0.469 | 0.462 |
|
||||||
|
| m6_24V62QQM_20240820 | 0.738 | **0.753** | 0.738 | 0.738 | 0.524 | 0.525 | 0.517 | 0.524 |
|
||||||
|
| m6_26TYQI3C_20221027 | 0.799 | **0.857** | 0.798 | 0.799 | 0.557 | 0.541 | 0.599 | 0.632 |
|
||||||
|
| m6_2BBN5XA4_20250411 | 0.665 | **0.696** | 0.665 | 0.666 | 0.230 | 0.303 | 0.235 | 0.297 |
|
||||||
|
| m6_2C4TKI6H_20240506 | 0.719 | **0.751** | 0.719 | 0.719 | 0.672 | 0.649 | 0.654 | 0.621 |
|
||||||
|
| m6_2CDNN7BH_20240102 | 0.726 | **0.765** | 0.726 | 0.725 | 0.629 | 0.622 | 0.631 | 0.599 |
|
||||||
|
|
||||||
|
Per-case ImpactLoss (semantic, unclipped score; **bold** = best (lowest) for
|
||||||
|
the case):
|
||||||
|
|
||||||
|
| case | antspyx | ants_rigid_synonly | ants_synra | ants_syn | ants_synreprosr | ants_synreprobr | ants_synquickreprosr | ants_synquickreprobr |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| m6_22GD5VL2_20230724 | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT |
|
||||||
|
| m6_22IRRKU6_20240115 | 0.1541 | 0.1530 | 0.1542 | 0.1540 | **0.1442** | 0.1482 | 0.1474 | 0.1493 |
|
||||||
|
| m6_23XIMO4M_20230222 | 0.1581 | 0.1565 | 0.1581 | 0.1581 | **0.1505** | 0.1549 | 0.1535 | 0.1585 |
|
||||||
|
| m6_24V62QQM_20230223 | 0.1473 | **0.1464** | 0.1472 | 0.1472 | 0.1515 | 0.1553 | 0.1535 | 0.1560 |
|
||||||
|
| m6_24V62QQM_20231017 | 0.1518 | **0.1512** | 0.1516 | 0.1519 | 0.1559 | 0.1575 | 0.1577 | 0.1581 |
|
||||||
|
| m6_24V62QQM_20240820 | **0.1545** | **0.1545** | 0.1547 | 0.1546 | 0.1553 | 0.1578 | 0.1586 | 0.1586 |
|
||||||
|
| m6_26TYQI3C_20221027 | 0.1580 | 0.1562 | 0.1578 | 0.1579 | **0.1561** | 0.1574 | 0.1602 | 0.1610 |
|
||||||
|
| m6_2BBN5XA4_20250411 | 0.1624 | **0.1611** | 0.1624 | 0.1624 | 0.1680 | 0.1695 | 0.1690 | 0.1707 |
|
||||||
|
| m6_2C4TKI6H_20240506 | 0.1594 | 0.1582 | 0.1594 | 0.1593 | **0.1490** | 0.1526 | 0.1517 | 0.1545 |
|
||||||
|
| m6_2CDNN7BH_20240102 | 0.1590 | 0.1582 | 0.1589 | 0.1589 | **0.1493** | 0.1560 | 0.1538 | 0.1580 |
|
||||||
|
|
||||||
|
### Interpretation (ANTs variants)
|
||||||
|
|
||||||
|
- **`ants_rigid_synonly` is the best ANTs configuration of the eight** —
|
||||||
|
the only one that beats the current `antspyx` row: median MI **0.753**
|
||||||
|
(per-case best on 8 of 9; its 0.857 on `m6_26TYQI3C` is the highest MI
|
||||||
|
of the whole benchmark, ahead of the previous 0.801) at ImpactLoss
|
||||||
|
0.156 (second-best median after `synthmorph`'s 0.156). The gain over
|
||||||
|
the `SyN` preset comes from the *deeper* SyN stage: 100 iterations at
|
||||||
|
every level **including full resolution** (100×100×0 vs 40×20×0)
|
||||||
|
refining the Rigid stage's pose. The cost is 208.4 s/case (~6.6× the
|
||||||
|
preset) — the full-resolution SyN iterations dominate.
|
||||||
|
- **`ants_synra` is indistinguishable from the current `antspyx` row** on
|
||||||
|
quality (median MI 0.722 vs 0.723, ImpactLoss 0.158 vs 0.158; per-case
|
||||||
|
deltas within 0.002 nats, ties on 4 of 9) at **+38 % wall time**
|
||||||
|
(43.6 s vs 31.7 s): the explicit Rigid stage ahead of the Affine stage
|
||||||
|
buys nothing — on these pairs the 12-DOF stage from the center-of-mass
|
||||||
|
init already finds the gross pose, so the extra 6-DOF stage just
|
||||||
|
re-solves it.
|
||||||
|
- **`ants_syn` reproduces the existing `antspyx` row** (median MI 0.722
|
||||||
|
vs 0.723, ImpactLoss 0.158, s/reg 33.8 vs 31.7; per-case deltas within
|
||||||
|
0.002 nats) — as expected, since it is the *same stock `SyN` preset*
|
||||||
|
(Affine + SyN[0.2,3,0], COM init) re-run in this follow-up batch: it
|
||||||
|
serves as a consistency check that the shared read-back / scoring path
|
||||||
|
is configuration-stable.
|
||||||
|
- **The Repro / QuickRepro family is semantically competitive but weaker on
|
||||||
|
intensity:** median MI 0.530–0.549 (vs 0.723 for the `SyN` preset),
|
||||||
|
driven by the GC-based Rigid stage and the short 2-level SyN/BSplineSyN
|
||||||
|
refinement (50×20 or 50×0 iters, 2×1 pyramid, CC 2-bin metric) — a
|
||||||
|
workflow tuned for intra-modality T1w-T1w. `ants_synreprosr` still takes
|
||||||
|
the **best ImpactLoss median of all thirteen pipelines here** (0.151;
|
||||||
|
per-case wins on 4 of 9), so the semantic/intensity split of the main
|
||||||
|
table carries over to the stock ANTs workflows.
|
||||||
|
- **Time tracks budget, not quality:** the full Repro Rigid stage
|
||||||
|
(1000×500×250×100, 8×4×2×1, GC) dominates — `ants_synreprobr` is the
|
||||||
|
slowest ANTs configuration (370 s/case) at the same (low) MI as the
|
||||||
|
84–96 s `QuickRepro` variants (which drop the 4th rigid level and the
|
||||||
|
2nd SyN level, changing the MI by < 0.02). Only `ants_rigid_synonly`
|
||||||
|
challenges the `SyN` preset's quality (better MI at 6.6× the time);
|
||||||
|
every other variant is worse for more or for the same.
|
||||||
|
- **Production skull-ring QA (reference only):** acceptance 4/9
|
||||||
|
`ants_syn` / `ants_synra` / `ants_rigid_synonly` (= baseline), 2/9
|
||||||
|
`ants_synreprobr`, 1/9 the rest; rotational dispersion worst in the
|
||||||
|
`Repro` / `QuickRepro` family (24–29 mm std) vs 14–15 mm for
|
||||||
|
`ants_syn` / `ants_synra` / `ants_rigid_synonly` — consistent with
|
||||||
|
their alignment.
|
||||||
|
|
||||||
## Transform conventions (deformable-specific)
|
## Transform conventions (deformable-specific)
|
||||||
|
|
||||||
Unlike the rigid benchmark (where the elastix-style toolkits came out in the
|
Unlike the rigid benchmark (where the elastix-style toolkits came out in the
|
||||||
|
|
@ -287,6 +423,16 @@ python scripts/bench_registration.py --deformable --workers 6 --threads 24 --qa-
|
||||||
# single pipeline / case subset
|
# single pipeline / case subset
|
||||||
python scripts/bench_registration.py --deformable --pipelines simpleitk impact --n-cases 3
|
python scripts/bench_registration.py --deformable --pipelines simpleitk impact --n-cases 3
|
||||||
|
|
||||||
|
# the ANTs-variant follow-up (direct SyNRA / SyN + Repro / QuickRepro
|
||||||
|
# sr/br; results go to the same results/bench_registration_deformable/ dir,
|
||||||
|
# 2026-10-03 run, 6 workers, ~28 min wall; `ants_syn` is the re-run of the
|
||||||
|
# stock SyN preset that replaced the initial SyNOnly row)
|
||||||
|
python scripts/bench_registration.py --deformable --pipelines "ants_synra ants_syn ants_synreprosr ants_synreprobr ants_synquickreprosr ants_synquickreprobr" --workers 6 --threads 24 --qa-images
|
||||||
|
|
||||||
|
# the legacy two-call Rigid -> SyNOnly(100,100,100) workflow (2026-10-03,
|
||||||
|
# 6 workers, ~25 min wall; dominated by the full-resolution SyN iters)
|
||||||
|
python scripts/bench_registration.py --deformable --pipelines "ants_rigid_synonly" --workers 6 --threads 24 --qa-images
|
||||||
|
|
||||||
# serial SynthMorph re-run (the 2026-10-02 final numbers): one container per
|
# serial SynthMorph re-run (the 2026-10-02 final numbers): one container per
|
||||||
# GPU at a time; two concurrent containers on one A100 hit cuDNN
|
# GPU at a time; two concurrent containers on one A100 hit cuDNN
|
||||||
# scratch-space OOM in the joint model's 256-ch 128^3 hyperconvolution
|
# scratch-space OOM in the joint model's 256-ch 128^3 hyperconvolution
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
Pipelines (see src/registration_pipelines.py for the shared transform contract):
|
Pipelines (see src/registration_pipelines.py for the shared transform contract):
|
||||||
simpleitk | elastix | antspyx | synthmorph | impact | niftyreg
|
simpleitk | elastix | antspyx | synthmorph | impact | niftyreg
|
||||||
|
+ the ANTs variants (deformable only): ants_synra | ants_syn |
|
||||||
|
ants_synreprosr | ants_synreprobr | ants_synquickreprosr |
|
||||||
|
ants_synquickreprobr
|
||||||
|
|
||||||
Each pipeline registers a head-and-neck CT to a tightly-cropped T1c MRI
|
Each pipeline registers a head-and-neck CT to a tightly-cropped T1c MRI
|
||||||
(cross-modality, inter-session tilt up to ~30 deg) and the GTV is warped onto
|
(cross-modality, inter-session tilt up to ~30 deg) and the GTV is warped onto
|
||||||
|
|
@ -298,6 +301,10 @@ def _call(pipeline, ct, t1c, tv, args):
|
||||||
threads=args.threads)
|
threads=args.threads)
|
||||||
if pipeline == "antspyx":
|
if pipeline == "antspyx":
|
||||||
return RP.register_antspyx_deformable(ct, t1c)
|
return RP.register_antspyx_deformable(ct, t1c)
|
||||||
|
if pipeline in RP.ANT_VARIANTS:
|
||||||
|
return RP.register_ants_variant_deformable(ct, t1c, variant=pipeline)
|
||||||
|
if pipeline == "ants_rigid_synonly":
|
||||||
|
return RP.register_ants_rigid_synonly_deformable(ct, t1c)
|
||||||
if pipeline == "synthmorph":
|
if pipeline == "synthmorph":
|
||||||
return RP.register_synthmorph_deformable(ct, t1c,
|
return RP.register_synthmorph_deformable(ct, t1c,
|
||||||
gpu=args.synthmorph_gpu,
|
gpu=args.synthmorph_gpu,
|
||||||
|
|
@ -445,7 +452,7 @@ def _fmt(v, spec="{}", default="-"):
|
||||||
|
|
||||||
|
|
||||||
def print_table(rows):
|
def print_table(rows):
|
||||||
order = [p for p in RP.ALL_PIPELINES if any(r["pipeline"] == p for r in rows)]
|
order = [p for p in RP.ALL_PIPELINE_NAMES if any(r["pipeline"] == p for r in rows)]
|
||||||
print("\n" + "=" * 96)
|
print("\n" + "=" * 96)
|
||||||
hdr = (f"{'pipeline':10s} {'avail':6s} {'toolkit':22s} {'n':>3s} {'ok':>3s} {'err':>4s} "
|
hdr = (f"{'pipeline':10s} {'avail':6s} {'toolkit':22s} {'n':>3s} {'ok':>3s} {'err':>4s} "
|
||||||
f"{'s/reg':>7s} {'ImpactLoss':>10s} {'MI':>7s}")
|
f"{'s/reg':>7s} {'ImpactLoss':>10s} {'MI':>7s}")
|
||||||
|
|
@ -510,7 +517,7 @@ def write_plot(rows, out_png, label="rigid"):
|
||||||
def merge(args):
|
def merge(args):
|
||||||
out_dir = os.path.join(args.out_dir)
|
out_dir = os.path.join(args.out_dir)
|
||||||
rows = []
|
rows = []
|
||||||
for p in RP.ALL_PIPELINES:
|
for p in RP.ALL_PIPELINE_NAMES:
|
||||||
fp = os.path.join(out_dir, p + ".json")
|
fp = os.path.join(out_dir, p + ".json")
|
||||||
if os.path.exists(fp):
|
if os.path.exists(fp):
|
||||||
with open(fp) as f:
|
with open(fp) as f:
|
||||||
|
|
@ -651,7 +658,7 @@ def main():
|
||||||
args.qa_out = os.path.abspath(args.qa_out or os.path.join(args.out_dir, "qa"))
|
args.qa_out = os.path.abspath(args.qa_out or os.path.join(args.out_dir, "qa"))
|
||||||
os.makedirs(args.qa_out, exist_ok=True)
|
os.makedirs(args.qa_out, exist_ok=True)
|
||||||
pipelines = [p.lower() for p in args.pipelines.split()]
|
pipelines = [p.lower() for p in args.pipelines.split()]
|
||||||
unknown = set(pipelines) - set(RP.ALL_PIPELINES)
|
unknown = set(pipelines) - set(RP.ALL_PIPELINE_NAMES)
|
||||||
if unknown:
|
if unknown:
|
||||||
raise SystemExit(f"unknown pipeline(s): {unknown}")
|
raise SystemExit(f"unknown pipeline(s): {unknown}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1402,6 +1402,247 @@ def register_antspyx_deformable(ct, t1c):
|
||||||
return T, meta
|
return T, meta
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ANTs variants: direct SyNRA / SyN presets + the reproducible
|
||||||
|
# Repro / QuickRepro workflows (sr = SyN stage, br = BSplineSyN stage), all
|
||||||
|
# driven through antspyx's stock presets (see
|
||||||
|
# REGISTRATION_BENCHMARK_DEFORMABLE.md, "ANTs variants").
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# pipeline name -> (antspyx type_of_transform, stock-preset description)
|
||||||
|
ANT_VARIANTS = {
|
||||||
|
"ants_synra": (
|
||||||
|
"SyNRA",
|
||||||
|
"direct SyNRA preset: Rigid[0.25] + Affine[0.25] (both Mattes 32, "
|
||||||
|
"2100x1200x1200x0, 4x2x2x1, 3x2x1x0) + SyN[0.2,3,0] (Mattes 32, "
|
||||||
|
"40x20x0, 4x2x1); COM init"),
|
||||||
|
"ants_syn": (
|
||||||
|
"SyN",
|
||||||
|
"direct SyN (affine + deformable): Affine[0.25] (Mattes 32, "
|
||||||
|
"2100x1200x1200x0, 4x2x2x1, 3x2x1x0) + SyN[0.2,3,0] (Mattes 32, "
|
||||||
|
"40x20x0, 4x2x1, 2x1x0); COM init -- the stock preset, same "
|
||||||
|
"configuration as the existing antspyx row"),
|
||||||
|
"ants_synreprosr": (
|
||||||
|
"antsRegistrationSyNRepro[sr]",
|
||||||
|
"Repro (sr): Rigid[0.1] (GC 1/1/Regular/0.25, 1000x500x250x100, "
|
||||||
|
"8x4x2x1, 3x2x1x0vox) + SyN[0.2,3,0] (CC 1/2, 50x20, 2x1, 1x0vox); "
|
||||||
|
"COM init"),
|
||||||
|
"ants_synreprobr": (
|
||||||
|
"antsRegistrationSyNRepro[br]",
|
||||||
|
"Repro (br): Rigid[0.1] (GC 1/1/Regular/0.25, 1000x500x250x100, "
|
||||||
|
"8x4x2x1, 3x2x1x0vox) + BSplineSyN[0.2,26,0,3] (CC 1/2, 50x20, 2x1, "
|
||||||
|
"1x0vox); COM init"),
|
||||||
|
"ants_synquickreprosr": (
|
||||||
|
"antsRegistrationSyNQuickRepro[sr]",
|
||||||
|
"QuickRepro (sr): Rigid[0.1] (GC 1/1/Regular/0.25, 1000x500x250x0, "
|
||||||
|
"8x4x2x1, 3x2x1x0vox) + SyN[0.2,3,0] (CC 1/2, 50x0, 2x1, 1x0vox); "
|
||||||
|
"COM init"),
|
||||||
|
"ants_synquickreprobr": (
|
||||||
|
"antsRegistrationSyNQuickRepro[br]",
|
||||||
|
"QuickRepro (br): Rigid[0.1] (GC 1/1/Regular/0.25, 1000x500x250x0, "
|
||||||
|
"8x4x2x1, 3x2x1x0vox) + BSplineSyN[0.2,26,0,3] (CC 1/2, 50x0, 2x1, "
|
||||||
|
"1x0vox); COM init"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ants_stage_to_sitk(path, ref_img):
|
||||||
|
"""Read one ANTs stage file into a SimpleITK transform. A Warp.nii.gz
|
||||||
|
vector field becomes a DisplacementFieldTransform on ``ref_img``'s grid;
|
||||||
|
a .mat becomes its affine. When two linear stages (Rigid + Affine) are
|
||||||
|
run in one registration, ANTs writes them as a single *binary*
|
||||||
|
'0GenericAffine.mat' (the merged rigid-then-affine mapping); that format
|
||||||
|
is not SimpleITK-readable, so it is recovered by point-probing the ANTs
|
||||||
|
transform (exact for affine maps: verified to 2e-4 mm against ANTs' own
|
||||||
|
point mapping and NCC 1.0 against its warpedmovout)."""
|
||||||
|
base = os.path.basename(path)
|
||||||
|
if "Warp.nii.gz" in base and "Inverse" not in base:
|
||||||
|
w = sitk.ReadImage(path)
|
||||||
|
arr = sitk.GetArrayFromImage(w)
|
||||||
|
if arr.ndim == 4 and arr.shape[0] == 3:
|
||||||
|
arr = np.transpose(arr, (1, 2, 3, 0))
|
||||||
|
return _field_transform(arr, ref_img)
|
||||||
|
try:
|
||||||
|
return sitk.ReadTransform(path)
|
||||||
|
except Exception: # noqa: binary ANTs transform file
|
||||||
|
pass
|
||||||
|
import ants
|
||||||
|
tx = ants.read_transform(path)
|
||||||
|
o = np.array(ants.apply_ants_transform_to_point(tx, [0.0, 0.0, 0.0]),
|
||||||
|
dtype=float)
|
||||||
|
M = np.empty((3, 3), dtype=float)
|
||||||
|
for i in range(3):
|
||||||
|
p = [0.0, 0.0, 0.0]
|
||||||
|
p[i] = 1.0
|
||||||
|
M[:, i] = np.array(ants.apply_ants_transform_to_point(tx, p),
|
||||||
|
dtype=float) - o
|
||||||
|
t = sitk.AffineTransform(3)
|
||||||
|
t.SetMatrix(M.ravel())
|
||||||
|
t.SetTranslation(o)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def register_ants_variant_deformable(ct, t1c, variant):
|
||||||
|
"""Run one stock antspyx ANTs preset (:data:`ANT_VARIANTS`) as a
|
||||||
|
deformable pipeline under the unified contract (fixed = T1c, moving = CT,
|
||||||
|
full volumes, no clip). The stage files ANTs writes (in numeric order =
|
||||||
|
the order it applies them) are composed and rasterised to the T1c-grid
|
||||||
|
displacement field of the contract, exactly like
|
||||||
|
:func:`register_antspyx_deformable`. A per-case cross-check (NCC of the
|
||||||
|
composite's own resampling against ANTs' ``warpedmovout`` over the MR
|
||||||
|
head) is recorded in the meta.
|
||||||
|
|
||||||
|
OMP threads are left at the system default (uncapped) to match the
|
||||||
|
antspyx baseline row, which runs ``ants.registration`` the same way."""
|
||||||
|
if variant not in ANT_VARIANTS:
|
||||||
|
raise KeyError(f"unknown ANTs variant {variant!r} "
|
||||||
|
f"(one of {list(ANT_VARIANTS)})")
|
||||||
|
tx_type, desc = ANT_VARIANTS[variant]
|
||||||
|
try:
|
||||||
|
import ants
|
||||||
|
except Exception as e: # noqa
|
||||||
|
raise PipelineUnavailable(f"antspyx: {e!r}")
|
||||||
|
import glob
|
||||||
|
import time
|
||||||
|
fx, mv = fixed_moving(ct, t1c)
|
||||||
|
af = ants.from_sitk(sitk.Cast(fx, sitk.sitkFloat32))
|
||||||
|
am = ants.from_sitk(sitk.Cast(mv, sitk.sitkFloat32))
|
||||||
|
tmp = tempfile.mkdtemp(prefix=f"ants_{variant}_")
|
||||||
|
dt = 0.0
|
||||||
|
stage_names = []
|
||||||
|
crosscheck = None
|
||||||
|
try:
|
||||||
|
t0 = time.time()
|
||||||
|
r = ants.registration(fixed=af, moving=am, type_of_transform=tx_type,
|
||||||
|
outprefix=tmp + "/")
|
||||||
|
dt = time.time() - t0
|
||||||
|
files = glob.glob(os.path.join(tmp, "*"))
|
||||||
|
stage_files = [f for f in files if f.endswith(".mat") or
|
||||||
|
("Warp.nii.gz" in f and "Inverse" not in f)]
|
||||||
|
if not stage_files:
|
||||||
|
raise RuntimeError(f"{variant}: no stage transform files in {tmp}")
|
||||||
|
stage_files.sort(key=lambda p: int(re.match(
|
||||||
|
r"(\d+)", os.path.basename(p)).group(1)))
|
||||||
|
stage_names = [os.path.basename(p) for p in stage_files]
|
||||||
|
stages = [_ants_stage_to_sitk(p, fx) for p in stage_files]
|
||||||
|
T = stages[0] if len(stages) == 1 else sitk.CompositeTransform(stages)
|
||||||
|
T = _rasterize_to_tdf(T, fx)
|
||||||
|
# read-back cross-check against ANTs' own resampling (best effort)
|
||||||
|
try:
|
||||||
|
wo = os.path.join(tmp, "warpedmovout_check.nii.gz")
|
||||||
|
ants.image_write(r["warpedmovout"], wo)
|
||||||
|
own = sitk.GetArrayFromImage(sitk.ReadImage(wo))
|
||||||
|
mine = sitk.GetArrayFromImage(sitk.Resample(mv, fx, T, sitk.sitkLinear))
|
||||||
|
if own.shape == mine.shape:
|
||||||
|
from src.common import head_mask_from_image
|
||||||
|
head = head_mask_from_image(
|
||||||
|
sitk.GetArrayFromImage(fx).astype("float32"))
|
||||||
|
if head.sum() > 1000:
|
||||||
|
crosscheck = round(float(np.corrcoef(
|
||||||
|
mine[head].ravel(), own[head].ravel())[0, 1]), 4)
|
||||||
|
except Exception: # noqa
|
||||||
|
crosscheck = None
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
ha = sitk.GetArrayFromImage(t1c)
|
||||||
|
gap = _skull_gap(_warp_ct(ct, t1c, T), ha)
|
||||||
|
meta = {
|
||||||
|
"pipeline": variant,
|
||||||
|
"toolkit": f"ANTsPy {getattr(ants, '__version__', '?')}",
|
||||||
|
"metric": "ANTs stock preset metrics (Mattes MI / GC / CC per stage, "
|
||||||
|
"see transform description)",
|
||||||
|
"transform": f"Deformable (antspyx preset '{tx_type}'): {desc}",
|
||||||
|
"convention": "T1c->CT (verified vs ANTs warpedmovout)",
|
||||||
|
"auto_inverted": False,
|
||||||
|
"fixed": _modality_name(fx), "moving": _modality_name(mv),
|
||||||
|
"seconds_reg": round(dt, 1),
|
||||||
|
"stage_files": stage_names,
|
||||||
|
"own_warp_ncc_head": crosscheck,
|
||||||
|
"skull_ring_gap_mm": gap,
|
||||||
|
}
|
||||||
|
return T, meta
|
||||||
|
|
||||||
|
|
||||||
|
def register_ants_rigid_synonly_deformable(ct, t1c):
|
||||||
|
"""The legacy two-call antspyx workflow (src/registration_pipelines_
|
||||||
|
deformable.py's original `antspyx` row, kept for comparison): a `Rigid`
|
||||||
|
stage (Mattes 32, 500x500x200 iters, shrink 4x2x1, smoothing 3x2x0, COM
|
||||||
|
init) followed by a `SyNOnly` stage (SyN[0.2,3,0], Mattes 32, 100x100x0
|
||||||
|
iters, shrink 4x2x1, smoothing 2x1x0) **initialised from the Rigid
|
||||||
|
stage's transform** (`initial_transform`). The SyNOnly run is asked for
|
||||||
|
its `Composite.h5` (write_composite_transform=True), which folds the
|
||||||
|
init in, and is rasterised to the T1c-grid displacement field of the
|
||||||
|
contract like the other antspyx pipelines. Cross-checked per case
|
||||||
|
against ANTs' own resampling (NCC in the meta).
|
||||||
|
|
||||||
|
OMP threads are left at the system default (uncapped), matching the
|
||||||
|
antspyx baseline row."""
|
||||||
|
try:
|
||||||
|
import ants
|
||||||
|
except Exception as e: # noqa
|
||||||
|
raise PipelineUnavailable(f"antspyx: {e!r}")
|
||||||
|
import time
|
||||||
|
fx, mv = fixed_moving(ct, t1c)
|
||||||
|
af = ants.from_sitk(sitk.Cast(fx, sitk.sitkFloat32))
|
||||||
|
am = ants.from_sitk(sitk.Cast(mv, sitk.sitkFloat32))
|
||||||
|
tmp = tempfile.mkdtemp(prefix="ants_rigid_synonly_")
|
||||||
|
dt = 0.0
|
||||||
|
crosscheck = None
|
||||||
|
try:
|
||||||
|
t0 = time.time()
|
||||||
|
r1 = ants.registration(fixed=af, moving=am, type_of_transform="Rigid",
|
||||||
|
aff_metric="mattes",
|
||||||
|
aff_iterations=(500, 500, 200),
|
||||||
|
aff_shrink_factors=(4, 2, 1),
|
||||||
|
aff_smoothing_sigmas=(3, 2, 0),
|
||||||
|
outprefix=tmp + "/rigid_")
|
||||||
|
rig_path = r1["fwdtransforms"][0] if isinstance(r1["fwdtransforms"],
|
||||||
|
list) \
|
||||||
|
else r1["fwdtransforms"]
|
||||||
|
r2 = ants.registration(fixed=af, moving=am, type_of_transform="SyNOnly",
|
||||||
|
initial_transform=rig_path,
|
||||||
|
reg_iterations=(100, 100, 100),
|
||||||
|
write_composite_transform=True,
|
||||||
|
outprefix=tmp + "/syn_")
|
||||||
|
dt = time.time() - t0
|
||||||
|
# Composite.h5 = the -r init + the SyN stage (fixed -> moving chain)
|
||||||
|
T = sitk.ReadTransform(r2["fwdtransforms"])
|
||||||
|
T = _rasterize_to_tdf(T, fx)
|
||||||
|
try:
|
||||||
|
wo = os.path.join(tmp, "warpedmovout_check.nii.gz")
|
||||||
|
ants.image_write(r2["warpedmovout"], wo)
|
||||||
|
own = sitk.GetArrayFromImage(sitk.ReadImage(wo))
|
||||||
|
mine = sitk.GetArrayFromImage(sitk.Resample(mv, fx, T, sitk.sitkLinear))
|
||||||
|
if own.shape == mine.shape:
|
||||||
|
from src.common import head_mask_from_image
|
||||||
|
head = head_mask_from_image(
|
||||||
|
sitk.GetArrayFromImage(fx).astype("float32"))
|
||||||
|
if head.sum() > 1000:
|
||||||
|
crosscheck = round(float(np.corrcoef(
|
||||||
|
mine[head].ravel(), own[head].ravel())[0, 1]), 4)
|
||||||
|
except Exception: # noqa
|
||||||
|
crosscheck = None
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
ha = sitk.GetArrayFromImage(t1c)
|
||||||
|
gap = _skull_gap(_warp_ct(ct, t1c, T), ha)
|
||||||
|
meta = {
|
||||||
|
"pipeline": "ants_rigid_synonly",
|
||||||
|
"toolkit": f"ANTsPy {getattr(ants, '__version__', '?')}",
|
||||||
|
"metric": "Mattes MI (rigid 500x500x200, regular 20% sampling; "
|
||||||
|
"SyNOnly 100x100x0, full volume)",
|
||||||
|
"transform": ("Deformable (legacy two-call: Rigid[0.25] 500x500x200 "
|
||||||
|
"4x2x1 3x2x0 -> SyNOnly SyN[0.2,3,0] 100x100x0 4x2x1 "
|
||||||
|
"2x1x0, init = rigid stage; Composite.h5 read-back)"),
|
||||||
|
"convention": "T1c->CT (verified vs ANTs warpedmovout)",
|
||||||
|
"auto_inverted": False,
|
||||||
|
"fixed": _modality_name(fx), "moving": _modality_name(mv),
|
||||||
|
"seconds_reg": round(dt, 1),
|
||||||
|
"own_warp_ncc_head": crosscheck,
|
||||||
|
"skull_ring_gap_mm": gap,
|
||||||
|
}
|
||||||
|
return T, meta
|
||||||
|
|
||||||
|
|
||||||
def register_elastix_deformable(ct, t1c, workdir=None, threads=8):
|
def register_elastix_deformable(ct, t1c, workdir=None, threads=8):
|
||||||
"""ITK-Elastix two-stage deformable (the toolkit's canonical deformable
|
"""ITK-Elastix two-stage deformable (the toolkit's canonical deformable
|
||||||
workflow): stage 1 is the rigid benchmark's EulerTransform map (Mattes MI,
|
workflow): stage 1 is the rigid benchmark's EulerTransform map (Mattes MI,
|
||||||
|
|
@ -2052,6 +2293,12 @@ def register_synthmorph_deformable(ct, t1c, gpu=None, threads=None, extent=256,
|
||||||
return T, meta
|
return T, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _ants_variant_register(name):
|
||||||
|
def _reg(ct, t1c):
|
||||||
|
return register_ants_variant_deformable(ct, t1c, variant=name)
|
||||||
|
return _reg
|
||||||
|
|
||||||
|
|
||||||
REGISTERS_DEFORMABLE = {
|
REGISTERS_DEFORMABLE = {
|
||||||
"antspyx": register_antspyx_deformable,
|
"antspyx": register_antspyx_deformable,
|
||||||
"elastix": register_elastix_deformable,
|
"elastix": register_elastix_deformable,
|
||||||
|
|
@ -2060,7 +2307,12 @@ REGISTERS_DEFORMABLE = {
|
||||||
"simpleitk": register_simpleitk_deformable,
|
"simpleitk": register_simpleitk_deformable,
|
||||||
"synthmorph": register_synthmorph_deformable,
|
"synthmorph": register_synthmorph_deformable,
|
||||||
}
|
}
|
||||||
|
REGISTERS_DEFORMABLE.update({n: _ants_variant_register(n) for n in ANT_VARIANTS})
|
||||||
|
REGISTERS_DEFORMABLE["ants_rigid_synonly"] = register_ants_rigid_synonly_deformable
|
||||||
ALL_PIPELINES_DEFORMABLE = list(REGISTERS_DEFORMABLE)
|
ALL_PIPELINES_DEFORMABLE = list(REGISTERS_DEFORMABLE)
|
||||||
|
# every pipeline name the harness may dispatch / merge on (rigid + variants)
|
||||||
|
ALL_PIPELINE_NAMES = (ALL_PIPELINES + list(ANT_VARIANTS)
|
||||||
|
+ ["ants_rigid_synonly"])
|
||||||
|
|
||||||
|
|
||||||
def availability():
|
def availability():
|
||||||
|
|
@ -2121,4 +2373,7 @@ def availability():
|
||||||
f"static config)")
|
f"static config)")
|
||||||
except Exception as e: # noqa
|
except Exception as e: # noqa
|
||||||
out[name] = (False, repr(e))
|
out[name] = (False, repr(e))
|
||||||
|
# the ANTs variants all run through antspyx (same availability as antspyx)
|
||||||
|
for name in list(ANT_VARIANTS) + ["ants_rigid_synonly"]:
|
||||||
|
out[name] = out.get("antspyx", (False, "antspyx not checked"))
|
||||||
return out
|
return out
|
||||||
Loading…
Reference in a new issue