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:
Furen Xiao 2026-10-03 13:26:12 +08:00
parent aa7ac26f4c
commit bec0e9834f
3 changed files with 418 additions and 10 deletions

View file

@ -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

View file

@ -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}")

View file

@ -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