Refactor code structure for improved readability and maintainability

This commit is contained in:
Furen Xiao 2026-10-02 19:00:07 +08:00
parent 96733e370d
commit f87e5c51af
9 changed files with 3559 additions and 41 deletions

View file

@ -10,7 +10,7 @@ Activate with:
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 the Docker images `freesurfer/synthmorph` and `vboussot/elastix_impact` (IMPACT; rigid MIND + TotalSegmentator config, feature models under `data/impact_models`). The benchmark's ImpactLoss scoring metric uses `src/impact_loss.py` (vendored from vboussot/ImpactLoss; runs on the env's torch).
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_RIGID.md): `itk` + `itk-elastix`, `antspyx`, niftyreg 1.5.69 (`conda install -c conda-forge niftyreg`, provides `reg_aladin`), plus the Docker images `freesurfer/synthmorph` and `vboussot/elastix_impact` (IMPACT; rigid MIND + TotalSegmentator config, feature models under `data/impact_models`). The benchmark's ImpactLoss scoring metric uses `src/impact_loss.py` (vendored from vboussot/ImpactLoss; runs on the env's torch).
## Project

View file

@ -0,0 +1,310 @@
# Registration Pipeline Benchmark — CT → T1c (M6) — Deformable
Companion to [REGISTRATION_BENCHMARK_RIGID.md](REGISTRATION_BENCHMARK_RIGID.md):
the **deformable** variant of the same six-pipeline benchmark. The rigid
benchmark asked "which toolkit aligns the head best with 6 DOF?"; this one
asks "which toolkit's native deformable workflow aligns the head (and the
tissue) best?" on the same 10 M6 CT→T1c test cases, the same unified
contract, and the same metrics.
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
local). 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
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
scratch-space allocation failures (see the SynthMorph note under
Interpretation); the re-run registered 9/9, matching the concurrent
run's scores to 4 decimals on the 3 cases it completed. `simpleitk`
was re-run after its spline grid was accelerated from 16 mm to 24 mm
(see the simpleitk row of the pipeline table; the original 16 mm results
are archived as `simpleitk_16mm_legacy.json`).
## Unified contract
Identical to the rigid benchmark: the returned transform `T` must satisfy
`sitk.Resample(ct, t1c, T, ...)` = CT on the T1c grid, i.e. **T maps T1c →
CT coordinates**. fixed = T1c, moving = CT, full volumes (no head box, no
mask, no crop, no pad), each toolkit's native automatic transform
initialization, CT clipped to [0, 80] HU only for the pipelines where the
rigid A/B showed clipping helps (niftyreg, synthmorph).
One deformable-specific change: every pipeline's output (elastix BSpline
combination, ANTs warp + affine composite, NiftyReg affine + F3D spline,
SimpleITK Euler + BSpline, SynthMorph joint field, IMPACT RecursiveBSpline)
is **rasterised to a SimpleITK `DisplacementFieldTransform` on the T1c
grid** before scoring, so all six pipelines return the same transform type
and the shared scoring / GTV warping / QA code is used verbatim.
## The six pipelines (deformable configurations)
Each pipeline runs its toolkit's **native deformable workflow** — a linear
init stage + a deformable stage, or a single deformable stage where that is
the toolkit's canonical setup:
| pipeline | deformable configuration | metric |
|---|---|---|
| **antspyx** | `type_of_transform='SyN'` (ANTs' native deformable): Affine stage (2100×1200×1200×0, shrink 4×2×2×1) + SyN[0.2,3,0] deformable stage (40×20×0, shrink 4×2×1), center-of-mass auto init | Mattes MI (32 bins), both stages |
| **elastix** | two-stage (the toolkit's canonical deformable workflow): EulerTransform rigid stage (2000 iters × 3 resolutions, CenterOfGravity auto init) → BSplineTransform stage (16 mm grid, spline order 3, 2000 iters × 3 resolutions) initialized from stage 1 via `-t0` | Mattes MI (32 bins), 5000 samples/level |
| **impact** | the ImpactReg `MR_CT_TS` **reference map in its native deformable form**: RecursiveBSplineTransform (14 mm final grid, order 3) driven by the MIND + TotalSegmentator static feature combination (4 per-level scales 400/300/200/200 iters, 2000 samples) with the reference's three-metric combination: IMPACT (w 1.0) + Mattes MI (w 0.4) + TransformBendingEnergyPenalty (w 10). Two M6 adaptations, same as the rigid benchmark: CenterOfGravity auto init (the reference's GeometricalCenter start leaves the heads ~100 mm apart and aborts) and RequiredRatioOfValidSamples 0.1 | IMPACT semantic (L1 + Dice) + Mattes MI + bending energy |
| **niftyreg** | reg_aladin rigid init (3-level pyramid, 15 TLS iters/level) → reg_f3d non-rigid (F3D B-spline Free-Form deformation, Modat et al. 2010; NMI metric, symmetric bending/linear regularisation, 3-level pyramid, 150 iters at the final level, 8 mm final grid) | NCC block matching (init) + NMI (F3D) |
| **simpleitk** | two-stage (the toolkit's canonical deformable demo): Euler3D stage (2000 iters/level, pyramid [4,2,1]) → BSplineTransform3D stage (24 mm grid, 50 iters/level on pyramid [4,2]), the BSpline's control points initialised to reproduce the Euler stage exactly. The 24 mm grid (5577 parameters) is the accelerated setting (the 2026-10-02 original used 16 mm / 13005 parameters, ~61 min/case under the harness): this build's spline per-iteration cost scales ~linearly with the control-point count (~2.8 s/iter at the shrink-4 level and ~20 s/iter at shrink-2 for 24 mm vs ~6.4 / ~45.8 s for 16 mm — independent of the metric sample count, which is fixed in this SimpleITK build; LBFGS2 was tested and fails to move the transform on this noisy MI), and the full re-run keeps the 16 mm quality within 0.003–0.029 MI per case (median 0.698 vs 0.705, ImpactLoss median unchanged) while retaining simpleitk's `m6_26TYQI3C` MI/ImpactLoss win — see the acceleration note under Interpretation. A full-resolution polish pass (10 iters at shrink-1) was tested and dropped the achieved MI (0.6891 at 32 mm), so the pyramid stays [4,2] | Mattes MI (32 bins), both stages |
| **synthmorph** | `joint` model (affine + deformable in one symmetric network pass — the toolkit's canonical deformable; the `deformable` model requires a pre-existing field and would bias the comparison) | anatomy-aware contrast-invariant network (pretrained) |
## Test cases
First 10 M6 labelled cases of `m6.discover()` (all with a GTV label):
`m6_22GD5VL2_20230724`, `m6_22IRRKU6_20240115`, `m6_23XIMO4M_20230222`,
`m6_24V62QQM_20230223`, `m6_24V62QQM_20231017`, `m6_24V62QQM_20240820`,
`m6_26TYQI3C_20221027`, `m6_2BBN5XA4_20250411`, `m6_2C4TKI6H_20240506`,
`m6_2CDNN7BH_20240102`.
(`m6_22GD5VL2_20230724` has an incomplete CT head — a known data issue,
not a pipeline failure; it is scored as ct_incomplete in every pipeline,
as in the rigid benchmark.)
(Ground-truth reference transforms are **not** part of this benchmark —
scoring is by achieved image similarity only, as in the rigid benchmark.)
## Metrics (identical for every pipeline)
- **Mattes MI** (nats) between the warped CT and the T1c over the MR head
(largest connected component of the T1c mask) — achieved alignment, higher
= better.
- **IMPACT loss** (vboussot/ImpactLoss; pretrained TotalSegmentator
`TS/M730` deep features — weighted L1 over the two deepest layers, full
volumes; the same scorer as the rigid benchmark) between the warped CT
and the T1c — semantic-anatomy similarity, lower = better.
- **Registration wall time** (seconds, excludes I/O + scoring).
Per-case JSONs additionally retain the production skull-ring QA (gap +
concentricity, and the acceptance verdict) for reference.
### Result
Alignment is scored by the achieved **Mattes MI** (nats, warped-CT ~ T1c
over the MR head; higher = better) **and** the **ImpactLoss** semantic score
(warped-CT ~ T1c over the full volumes; lower = better), on the same
unclipped scoring volumes as the rigid benchmark, so all rows are comparable
on the same scales and against the rigid numbers. Full volumes, no mask /
crop / pad, fixed = T1c / moving = CT (shared rule). `niftyreg` and
`synthmorph` clip the CT (the moving image) to [0, 80] HU *before
registration* (per the rigid benchmark's clipping A/B); `antspyx`,
`elastix`, `impact` and `simpleitk` register the CT as-is. All six
transforms are deformable (each toolkit's native workflow) and are
rasterised to T1c-grid displacement fields before scoring.
All six pipelines registered 9/9 cases (the 10th is the known
incomplete-CT data issue, unscored in every pipeline). **Bold** = best
for the column (MI higher is better, ImpactLoss lower is better).
| pipeline | toolkit | registered | s/reg (med) | ImpactLoss (med/mean) | MI (med/mean, nats) |
|---|---|---|---|---|---|
| **antspyx** | ANTsPy 0.6.3 | 9 | 31.7 | 0.158 / 0.156 | **0.723 / 0.729** |
| **elastix** | ITK-Elastix 0.25.4 | 9 | 45.8 | 0.164 / 0.164 | 0.622 / 0.627 |
| **impact** | Docker `vboussot/elastix_impact` (Elastix 5.2.0 + IMPACT, deformable MIND+TS reference map) | 9 | 273.2 | 0.159 / 0.159 | 0.553 / 0.547 |
| **niftyreg** | NiftyReg 2.1.1 (reg_aladin + reg_f3d, Docker + CUDA) | 9 | 38.1 | 0.161 / 0.161 | 0.666 / 0.686 |
| **simpleitk** | SimpleITK 2.5.6 | 9 | 1369.9 | 0.158 / 0.159 | 0.698 / 0.704 |
| **synthmorph** | Docker `freesurfer/synthmorph` (TensorFlow, pretrained) | 9 | 49.1 | **0.156 / 0.156** | 0.627 / 0.640 |
Per-case MI (nats, unclipped score; **bold** = best for the case). The
`synthmorph` column is from the serial re-run (see the SynthMorph note
under Interpretation):
| case | antspyx | elastix | impact | niftyreg | simpleitk | synthmorph |
|---|---|---|---|---|---|---|
| m6_22GD5VL2_20230724 | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT |
| m6_22IRRKU6_20240115 | **0.679** | 0.580 | 0.548 | 0.637 | 0.654 | 0.620 |
| m6_23XIMO4M_20230222 | **0.723** | 0.622 | 0.573 | 0.711 | 0.693 | 0.618 |
| m6_24V62QQM_20230223 | **0.789** | 0.570 | 0.635 | 0.729 | 0.738 | 0.701 |
| m6_24V62QQM_20231017 | **0.720** | 0.622 | 0.580 | 0.665 | 0.692 | 0.628 |
| m6_24V62QQM_20240820 | **0.738** | 0.674 | 0.462 | 0.707 | 0.700 | 0.666 |
| m6_26TYQI3C_20221027 | 0.799 | 0.694 | 0.537 | 0.793 | **0.801** | 0.709 |
| m6_2BBN5XA4_20250411 | **0.666** | 0.585 | 0.436 | 0.612 | 0.641 | 0.569 |
| m6_2C4TKI6H_20240506 | **0.719** | 0.635 | 0.553 | 0.656 | 0.698 | 0.627 |
| m6_2CDNN7BH_20240102 | **0.726** | 0.660 | 0.602 | 0.666 | 0.718 | 0.624 |
Per-case ImpactLoss (semantic, unclipped score; **bold** = best (lowest)
for the case):
| case | antspyx | elastix | impact | niftyreg | simpleitk | synthmorph |
|---|---|---|---|---|---|---|
| m6_22GD5VL2_20230724 | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT | incomplete-CT |
| m6_22IRRKU6_20240115 | 0.1541 | 0.1595 | 0.1532 | 0.1530 | 0.1572 | **0.1512** |
| m6_23XIMO4M_20230222 | 0.1581 | 0.1657 | **0.1567** | 0.1605 | 0.1603 | 0.1573 |
| m6_24V62QQM_20230223 | 0.1473 | 0.1634 | 0.1497 | 0.1541 | 0.1502 | **0.1470** |
| m6_24V62QQM_20231017 | **0.1518** | 0.1591 | 0.1547 | 0.1620 | 0.1548 | **0.1518** |
| m6_24V62QQM_20240820 | **0.1545** | 0.1646 | 0.1694 | 0.1706 | 0.1584 | 0.1562 |
| m6_26TYQI3C_20221027 | 0.1580 | 0.1638 | 0.1656 | 0.1672 | **0.1562** | 0.1602 |
| m6_2BBN5XA4_20250411 | 0.1624 | 0.1663 | 0.1669 | 0.1648 | 0.1631 | **0.1616** |
| m6_2C4TKI6H_20240506 | 0.1594 | 0.1640 | 0.1585 | **0.1552** | 0.1626 | 0.1564 |
| m6_2CDNN7BH_20240102 | 0.1590 | 0.1662 | 0.1597 | 0.1605 | 0.1633 | **0.1583** |
### Interpretation
- **`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
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
(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
`m6_26TYQI3C` — 0.801, still the highest MI of the whole benchmark,
though now only 0.001 ahead of `antspyx` — and cedes its
`m6_2CDNN7BH` win to `antspyx`, 0.718 vs 0.726) at a cost of ~23
min/case under the harness — ~43× the fastest pipeline after the
spline-grid acceleration below (this build's BSpline per-iteration
cost scales ~linearly with the control-point count, independent of the
metric sample count — see the pipeline table).
- **`simpleitk` acceleration (2026-10-02):** the original setting (16 mm
spline grid, 13005 parameters) ran 3658.9 s/case (median) under the
harness. The spline stage's per-iteration cost in this build scales
~linearly with the control-point count (16 mm: ~6.4 s/iter at the
shrink-4 level, ~45.8 s at shrink-2; 24 mm: ~2.8 / ~20 s; 32 mm:
~1.2 / ~9.6 s), and the metric sample count is fixed in this SimpleITK
build (`SetMetricAsMattesMutualInformation` exposes only the histogram
bins); LBFGS2 was tested and its line search rejects every step on
this noisy MI (the transform does not move). Switching to the **24 mm
grid (5577 parameters) cut the wall to 1369.9 s median (2.7×)** at a
cost of −0.003…−0.029 MI on 8 of 9 cases (+0.017 on `m6_2C4TKI6H`;
median 0.698 vs 0.705), with the median ImpactLoss unchanged (0.158)
and `simpleitk`'s `m6_26TYQI3C` MI/ImpactLoss wins intact. A 32 mm
grid (a further ~1.9×) was rejected: it drops below `antspyx`'s 0.7994
on `m6_26TYQI3C` (0.7902, and 0.7963 even with 100 fine-level iters).
A full-resolution polish pass (10 iters at shrink-1) was also tested
and dropped the achieved MI (0.6891 at 32 mm). The original 16 mm
results are archived as `simpleitk_16mm_legacy.json`.
- **`niftyreg`'s `reg_f3d` stage delivers the largest deformable gain of
any pipeline** (0.292 → 0.666 median MI, +0.374): it recovers the two
gross-offset cases where the rigid block-matching locked onto local
minima (`m6_24V62QQM_20240820` 0.118 → 0.707, `m6_2BBN5XA4` 0.069 →
0.612), lifts the toolkit to third on MI, and it is the most
concentric of the six (median skull-ring gap 5.5 mm mid / 3.8 mm std,
6/9 accepted).
- **The deformable stage does not uniformly pay off.** Against the rigid
benchmark's per-case MI it improves `antspyx` (0.652 → 0.723 median),
`simpleitk` (0.647 → 0.698, accelerated 24 mm setting) and `niftyreg`
(0.292 → 0.666), but
**degrades** `elastix` (0.646 → 0.622; mixed per case, worst regression
`m6_24V62QQM_20230223` 0.698 → 0.570) and `impact` (0.652 → 0.553 —
lower than its own rigid init on **all 9 cases**). The two degradations
are the coarse-grid BSpline configurations (14–16 mm) with heavy
bending regularisation — for `impact` the reference map's
TransformBendingEnergyPenalty at weight 10 — so the deformable stage
smooths rather than refines, and (for `impact`) its IMPACT-dominant
objective is simply not the achieved-MI objective.
- **`impact`'s deformable reference map is the weakest on the intensity
scale** (MI 0.553) even though its *semantic* score is competitive
(ImpactLoss 0.159, within 0.003 of the best median) — a
metric-objective/achieved-MI divergence the rigid benchmark did not
show (there the two scores agreed, with `impact` in the top tier on
both). At 273 s/case it is the worst time/quality trade of the six.
- **`synthmorph` (serial re-run, 9/9) splits the leaderboards: best on
the semantic scale, mid-pack on the intensity scale.** Median
ImpactLoss 0.156 — the best of the six — with the per-case best on 4 of
9 (plus a 4-decimal tie with `antspyx` on `m6_24V62QQM_20231017`),
while its MI (0.627) is fourth, statistically tied with `elastix`
(0.622). The pretrained contrast-invariant network optimises learned
anatomy rather than raw CT/MR intensity synchrony, so it wins where
anatomy matters and loses where the intensity histogram matters. Fast
(49.1 s/case) and the read-back cross-check against the tool's own
resampling is NCC 1.0 on all 9 cases.
- **The two score families discriminate differently here than in the
rigid benchmark.** The ImpactLoss scale is nearly saturated (medians
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
(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
top of the gross anatomy the rigid stages already place — exactly what
MI measures and ImpactLoss is less sensitive to. For ranking the
deformable workflows, achieved MI is the more discriminative score.
- **`synthmorph` infrastructure note.** In the main concurrent run (6
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
256-channel 128³ hyperconvolution. Each case is a fresh
`docker run --rm` container, so the failures are cuDNN scratch-space
contention when two SynthMorph containers share one A100, not a
pipeline fault: the serial re-run registered 9/9, reproduced the
concurrent run's MI/ImpactLoss to 4 decimals on its 3 completed cases
(deterministic model), and ran ~17 s/case faster without the
contention (49.1 s vs 65.7 s median). The concurrent-run JSON is archived as
`synthmorph_concurrent_run.json`.
- **Production skull-ring QA (reference only, kept in the per-case
JSONs):** acceptance 6/9 `niftyreg`, 5/9 `elastix` / `synthmorph`,
4/9 `antspyx` / `simpleitk`, 2/9 `impact`. Concentricity: `niftyreg`
best (median gap 5.5 mm mid / 3.8 mm std), `simpleitk` (19.1 mm std,
accelerated setting) and `impact` (18.9 mm) worst on the rotational
term. The same weak correlation with perceived alignment noted in the
rigid benchmark, so it is reported for reference only.
## Transform conventions (deformable-specific)
Unlike the rigid benchmark (where the elastix-style toolkits came out in the
opposite direction 8/9 cases and needed the best-of-T vs T⁻¹ orientation
gate), every deformable output here is consumed in the documented
fixed→moving = T1c→CT direction; the gate is replaced by an analytic
read-back plus a skull-ring sanity check. Two non-obvious conventions were
established empirically (validated against each tool's own resampling, NCC
≥ 0.998 on the head):
- **NiftyReg and surfa (FreeSurfer's NIfTI stack) decode RAS+ NIfTI qforms
naively, i.e. they work in an x/y-mirrored world.** A displacement delta
(dx, dy, dz) stored by such a tool is (−dx, −dy, dz) in the true RAS
world; the field grid (file indices) is unaffected. The read-back flips
the x/y components of the dense field. (ANTs writes correct RAS fields —
no flip needed.)
- **NiftyReg reg_f3d writes only the control-point grid** (outputCPP.nii);
`reg_transform -disp` evaluates it to a dense field on the reference grid.
The reg_aladin affine is folded into the control points before
optimization (reg_f3d initialises the spline with the affine's
deformation field), so the dense field **is** the full transform — no
separate composition step. (The `-comp` subcommand was tested and does
not reproduce reg_f3d's internal transform; it is not used.)
- **SynthMorph joint**: driven as `register -m joint moving fixed`
(moving = clipped CT first, fixed = T1c second); the `-t` output is the
displacement field on the fixed (T1c) grid — the contract transform
directly. The tool's own `-o` resampling is written as a cross-check
(per-case `own_resample_ncc_head` in the JSONs).
- **ANTs SyN**: the two stage outputs (001Warp.nii.gz + 000GenericAffine.mat)
are composed as warp-then-affine (verified to reproduce ANTs' own
`warpedmovout` to < 0.001 MI) and rasterised.
- **Elastix / IMPACT**: the combination transform (auto-init folded in) is
rasterised — elastix in-process via `itk.TransformToDisplacementFieldFilter`
(exact, machine precision on affine fields), IMPACT by evaluating the
BSpline control-point file directly (SimpleITK cannot read elastix's
BSpline .txt format; scipy's order-3 B-spline = ITK's kernel).
## Reproducing
```bash
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
cd /mnt/b4/xfr/git26/longitudinal
# all six deformable pipelines (results go to results/bench_registration_deformable/)
# the 2026-10-02 main run used --workers 6 --threads 24 (~2 h 9 min wall,
# dominated by simpleitk's ~111 min phase)
python scripts/bench_registration.py --deformable --workers 6 --threads 24 --qa-images
# single pipeline / case subset
python scripts/bench_registration.py --deformable --pipelines simpleitk impact --n-cases 3
# 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
# scratch-space OOM in the joint model's 256-ch 128^3 hyperconvolution
python scripts/bench_registration.py --deformable --pipelines synthmorph --workers 1 --threads 24 --qa-images
# combine the per-pipeline JSONs into summary.json / summary.md / plot
python scripts/bench_registration.py --deformable --merge
```
## Visual QA images
`--qa-images` writes a `checkreg`-style PNG per (pipeline, case) to
`results/bench_registration_deformable/qa/<pipeline>__<case>.png`
(9 per pipeline; the incomplete-CT case is skipped) — same format as the
rigid benchmark: a 3×2 grid of the mid-axial / mid-coronal / mid-sagittal
slices through the head center, **left column** checkerboard blend of the
warped CT vs the T1c (clean registration = seamless mosaic), **right
column** additive color overlay (CT red, T1c green; agreement = yellow,
misregistration = red/green fringing). CT is brain-windowed 0–80 HU, T1c
2–98 % head window; the title carries the pipeline, case key and the
achieved Mattes MI.

View file

@ -235,11 +235,14 @@ the case):
pipelines the same way the intensity score does is a strong check that the
leaderboard is not an artifact of the MI scale.
- **The grossly repositioned cases still separate the leaders from the
tail.** On `m6_2BBN5XA4` / `m6_24V62QQM_20240820` (~30° inter-session tilt)
the top four hold 0.57–0.65, while niftyreg (0.069 / 0.118) and synthmorph
(0.061 / 0.111) — the single-start block-matching and pretrained-MRI
methods — lock onto local minima. This is exactly why production
`02_build_m6_dataset.py` uses a **multi-start tilt grid**.
tail.** On `m6_2BBN5XA4` / `m6_24V62QQM_20240820` (the two most
repositioned pairs — large inter-session translation; the required
rotations are only ~10° per the references, see the SynthMorph
investigation below) the top four hold 0.57–0.65, while niftyreg
(0.069 / 0.118) and synthmorph (0.061 / 0.111) — the single-start
block-matching and pretrained-MRI methods — lock onto local minima. This
is exactly why production `02_build_m6_dataset.py` uses a **multi-start
tilt grid**.
- **NiftyReg (reg_aladin) is second-from-bottom**: single-start symmetric
block matching is strong on moderate cases (`m6_23XIMO4M` 0.632;
`m6_26TYQI3C` 0.609) but falls into the same tilt local minima
@ -248,12 +251,17 @@ the case):
build). It clips the CT to [0,80] HU by default (per the A/B below) —
neutral on the unclipped score scale (0.296→0.292 median).
- **SynthMorph is weakest of the six on these cross-modality CT pairs**
(median 0.277 with its default CT clip; 0.241 unclipped): its `rigid` model
is trained on brain *MRI*, so the ~30° tilt defeats it on the repositioned
cases (0.06–0.11) even though it matches the intensity tools on the
cleanest (`m6_26TYQI3C` 0.590). The [0,80] brain-window clip of the CT is
its only genuine A/B win (+0.036 median, 7/9 cases), so it is the default.
It needs **no centroid init** (global deep method).
(median 0.277 with its default CT clip; 0.241 unclipped). The [0,80]
brain-window clip of the CT is its only genuine A/B win (+0.036 median,
7/9 cases), so it is the default. It needs **no centroid init** (global
deep method). The low score is *not* a tilt effect: the required rotations
are ≤10.6° (the reference tools' own). The investigation (below) shows the
`rigid` matrix model fails gross-pose estimation case-by-case on the
repositioned pairs (19–66 mm translation errors; on `m6_24V62QQM_20240820`
its transform is worse than no registration at all), fails equally on an
in-domain MR→MR pair with a ~55° relative pose, and is not fixed by the
`affine` model — while the `joint` (affine + deformable) model recovers
all three tested failure cases to the best ImpactLoss of the six tools.
- **Speed** (median wall, 3–4 cases in parallel, 24 threads, T1c-fixed):
SimpleITK 10.8 s, elastix 11.7 s, NiftyReg 15.4 s (v2.1.1, GPU block
matching), ANTs 16.7 s, SynthMorph 22.2 s (GPU forward pass), **IMPACT
@ -328,6 +336,126 @@ not clip.**
clip-scoring effect). All rows of the main table are scored on the
unclipped warped CT, so pipelines remain comparable.
## SynthMorph low-performance investigation (2026-09-30)
Question: *why* is `synthmorph` weakest (median MI 0.277; 0.06–0.11 on the
repositioned cases)? The earlier explanation ("trained on brain MRI, defeated
by the ~30° tilt") was checked and turned out to be wrong in its specifics.
Everything below was scored with the benchmark's own scoring
(`bench_registration.py::evaluate`); results in
`results/synthmorph_investigation/`, reproducible with
`scripts/investigate_synthmorph.py` (parts A–D, F) and
`scripts/investigate_synthmorph_mm.py` (part E).
**0. It is not a pipeline bug.** The `rigid` run is deterministic and
reproduces the benchmark JSON exactly (e.g. `m6_26TYQI3C` MI 0.5904, gap
13.1/4.9). The read-back direction is right (8/9 `auto_inverted: false`,
verified by the `fix_orientation` skull-ring gate), and the matrix is rigid
(`det 0.999–1.001`, `‖RᵀR−I‖ ≤ 0.0015`). The lone `auto_inverted: true`
(`m6_2BBN5XA4`) is the gate choosing the *less bad* of two wrong directions —
a symptom of the failure, not its cause.
**1. The failure is a gross-pose estimation failure, not a tilt failure.**
Each case's synthmorph transform was decomposed against the `simpleitk`
benchmark reference (MI 0.58–0.70 on all 9 cases; its rotations are
**1.4–10.6°**, not ~30°):
| case | MI ref / identity / rigid | head-centre err (mm) | rot err (deg) |
|---|---|---|---|
| m6_22IRRKU6_20240115 | 0.647 / 0.250 / **0.277** | 28.2 | 6.2 |
| m6_23XIMO4M_20230222 | 0.654 / 0.246 / **0.616** | 1.9 | 2.2 |
| m6_24V62QQM_20230223 | 0.704 / 0.276 / **0.256** | 25.6 | 3.7 |
| m6_24V62QQM_20231017 | 0.597 / 0.331 / **0.484** | 3.8 | 17.7 |
| m6_24V62QQM_20240820 | 0.638 / 0.274 / **0.111** | 66.3 | 5.9 |
| m6_26TYQI3C_20221027 | 0.670 / 0.397 / **0.590** | 6.5 | 10.2 |
| m6_2BBN5XA4_20250411 | 0.579 / 0.046 / **0.061** | 61.1 | 22.1 |
| m6_2C4TKI6H_20240506 | 0.636 / 0.231 / **0.231** | 40.6 | 3.4 |
| m6_2CDNN7BH_20240102 | 0.652 / 0.197 / **0.323** | 19.4 | 3.0 |
- 6/9 cases carry a 19–66 mm head-centre error (translation), and 3/9 a
10–22° rotation error; the two "clean" failures are translation-only.
- On `m6_24V62QQM_20240820` the model's transform (MI 0.111) is **worse than
registering nothing at all** (identity MI 0.274); on `m6_2C4TKI6H` it is
exactly identity-level (0.231 = 0.231). The net gain over no-registration
is ≤0.03 on 5/9 cases.
- The error *vectors* are case-specific, not a systematic bias: the fraction
of the required network-space translation that goes uncorrected is ≈0 on
the successes (23XIMO4M 0.07, 20231017 −0.01), ≈1.0 on `m6_24V62QQM_20240820`
(the model outputs ≈ its network-space identity — the null that aligns the
two *volume* centres), and −1.7 on `m6_2BBN5XA4` (it moves the head 1.7× the
required amount, in the wrong direction).
**2. Cross-modality is not the (only) cause.** An in-domain control — two
T1c scans of the *same* subject 8 months apart (`m6_24V62QQM`, both tight
head crops, no CT, no HU issue) — has a true relative pose of **~55–57°**
(benchmark `simpleitk` mode 55.3°, production multi-start tilt grid 56.6°,
head centres agreeing to 1.2 mm) and SynthMorph `rigid` fails it equally:
18.5° rotation error, 18 mm head-centre error, MI 0.361 vs the reference's
0.689 (ImpactLoss 0.140 vs 0.114).
**3. The `affine` matrix model does not help.** Same 9 cases, same budget:
MI within ±0.025 of `rigid` on 8/9 cases (`m6_23XIMO4M`: −0.096, the one case
where rigid succeeded); the linear part is a near-pure rotation
(scale 0.98–1.03, anisotropy ≤2%) — the extra 6 DOF are essentially unused.
**4. The `joint` (affine + deformable) model recovers the failures.** Run
directly in Docker (`-m joint`, outside the rigid contract; the tool's own
`-o` resampling warps the CT onto the T1c grid, scored as-is) on the three
worst cases:
| case | MI rigid → joint | ImpactLoss joint (vs best intensity tool) |
|---|---|---|
| m6_24V62QQM_20230223 | 0.256 → **0.529** | **0.133** (simpleitk 0.147) |
| m6_24V62QQM_20240820 | 0.111 → **0.481** | **0.132** (antspyx 0.157) |
| m6_2BBN5XA4_20250411 | 0.061 → **0.433** | **0.129** (antspyx 0.162) |
The model family's anatomy is fine — its *semantic* alignment on the two
grossly repositioned cases is the **best of the six tools** — but the matrix
head (6- or 12-DOF) cannot estimate the gross inter-session pose, and the
deformable field absorbs what it misses (~40 s/case on the GPU).
**5. Pipeline-side suspects were ruled out.**
- **Network-space extent truncation: minor.** Each volume is resampled into
its own 256 mm isotropic box (centred on its own volume centre, per the
image code); with the head placed in CT space via the reference transform,
only 0–6.5% of head voxels fall outside the CT box (0% T1c). Re-centring
the CT volume on the head (crop to head bbox + 15 mm) changes *nothing*
(ΔMI ≤ 0.012, Δhead-centre-err ≤ 1.2 mm on all 9 cases) — the model is not
anchored to the volume centre.
- **Min-max normalization: a real, quantified cross-modality penalty.** The
network min-max-normalizes each volume; over the raw CT's full HU range
(−3024…+3071, air/metal) the brain (0–80 HU) collapses to a near-flat
**0.496–0.509** band (air sits at ~0.33, the skull at 0.63–1.0), so the
parenchyma carries almost no internal contrast; the T1c brain spans
~0–0.6 (p95 0.26–0.58), so the raw CT reads as a flat-gray blob with a
bright ring. The [0,80] clip restores full-range brain contrast (this is
the mechanism behind the CT-clip A/B win, +0.036–0.077 MI). Clipping does
not make the model domain-typical, which is why it only partially helps.
**Conclusion.** SynthMorph's matrix models are a *local pose prior*: on the
in-distribution cases (small relative pose) they estimate the M6 alignment
well (23XIMO4M 1.9 mm / 2.2°, 20231017 3.8 mm / 17.7°, 26TYQI3C 6.5 mm /
10.2° residual), but the M6 inter-session repositioning (tens of mm
translation and, in the same-patient control, a 55° world-frame rotation) is
outside the range the pretrained model (trained on same-subject MRI pairs)
reliably inverts, and the output is
then case-specifically wrong — do-nothing, half-done, or opposite. It is a
valid data point for how far a pretrained-MRI deep matrix model generalizes
out-of-domain, and it should not be expected to compete with the intensity
tools on repositioned CT pairs in rigid mode. If a SynthMorph variant is
wanted for these data, the `joint` model is the one that works (at the cost
of leaving the rigid-transform contract).
Reproducing (needs the `freesurfer/synthmorph` image + GPU; ~15 min):
```bash
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
python scripts/investigate_synthmorph.py --parts ABCD --workers 3 # A ref+rigid, B affine, C joint, D network-space
python scripts/investigate_synthmorph.py --parts F --workers 3 # re-centred CT probe (needs ABCD first)
python scripts/investigate_synthmorph_mm.py --gpu 0 # in-domain MR->MR control
```
## Reproducing
```bash
@ -583,8 +711,9 @@ docker pull vboussot/elastix_impact
- `synthmorph` is deterministic (one fixed pretrained model, no random
subsampling), so its per-case numbers are stable run to run — unlike ANTs /
IMPACT. Its low MI (median 0.277 with the default CT clip; 0.06–0.11 on the
repositioned cases) is a genuine cross-modality / tilt weakness, not
variance.
repositioned cases) is a genuine gross-pose-estimation weakness of the
pretrained matrix model (case-by-case, not variance) — see the
"SynthMorph low-performance investigation" section above.
- `fix_orientation` (elastix/ants/impact/niftyreg/synthmorph) chooses between
a transform and its inverse by skull-ring concentricity — a correctness gate
that makes the result independent of each toolkit's transform-direction
@ -597,11 +726,19 @@ re-derived per toolkit. Under the shared fixed=T1c / moving=CT rule the raw
runs report `auto_inverted: False` for those; `niftyreg`'s `-aff` file
comes out the other way and the gate inverts it (8/9 `auto_inverted: True`).
- **SynthMorph specifics** (Docker `freesurfer/synthmorph`, TensorFlow, GPU):
- **Cross-modality / tilt weakness.** The `rigid` model is trained on brain
*MRI*, so on these CT↔T1c pairs it recovers translation but is repeatedly
defeated by the ~30° inter-session tilt (7/9 cases have `std > 15 mm`).
It is a legitimate data point for how far a pretrained MRI deep model
generalizes out-of-domain to CT — not a bug.
- **Gross-pose estimation weakness (measured, not a bug).** The `rigid`
matrix model is trained on brain *MRI* (same-subject pairs, small
relative poses). On these repositioned CT↔T1c pairs it estimates the
translation wrongly on 6/9 cases (19–66 mm head-centre error; the two
worst, `m6_2BBN5XA4` / `m6_24V62QQM_20240820`, at 61 / 66 mm) and the
rotation on 3/9 (10–22°) — and the required rotations are only 1.4–10.6°,
so it is *not* a "defeated by tilt" story; on `m6_24V62QQM_20240820`
its transform scores below the unregistered (identity) one. An in-domain
MR→MR control (same subject, ~55° relative pose) fails equally, and the
`affine` model changes nothing, while the `joint` (affine + deformable)
model recovers all three tested failures to the best ImpactLoss of the
six tools. Full evidence: the "SynthMorph low-performance investigation"
section.
- **No centroid init needed.** Being a global deep method it recovers the
gross offset on its own; the `-i` initial-transform path is not used
(which also sidesteps this image's float64/float32 bug on the `-i` code

View file

@ -27,14 +27,14 @@ A/B compares the same input to both the toolkit and the MI score.
Because some toolkits are not pip-installable into every
`longitudinal-313` env (antspyx needs a cp310-cp313 wheel, see
REGISTRATION_BENCHMARK.md), the benchmark is env-portable: run it under
REGISTRATION_BENCHMARK_RIGID.md), the benchmark is env-portable: run it under
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
ITK-Elastix python API (pip ``itk`` + ``itk-elastix``); niftyreg runs natively
(``conda install -c conda-forge niftyreg`` provides ``reg_aladin``);
``impact`` runs the ``vboussot/elastix_impact`` Docker image (GPU; the rigid
MIND + TotalSegmentator static config, needs Docker + the pulled image + the
model assets — see REGISTRATION_BENCHMARK.md); ``synthmorph`` runs in a Docker
model assets — see REGISTRATION_BENCHMARK_RIGID.md); ``synthmorph`` runs in a Docker
image (``freesurfer/synthmorph``; needs Docker + the pulled image). The
`--merge`
mode then combines the per-pipeline JSON files (accumulated across envs) into
@ -45,7 +45,7 @@ Usage (repo root):
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
python scripts/bench_registration.py --pipelines simpleitk elastix antspyx niftyreg impact
# synthmorph (GPU, Docker) — one-time setup in REGISTRATION_BENCHMARK.md
# synthmorph (GPU, Docker) — one-time setup in REGISTRATION_BENCHMARK_RIGID.md
python scripts/bench_registration.py --pipelines synthmorph
# any pipeline that is not importable in the current env is recorded as
@ -290,6 +290,28 @@ def make_qa_png(warped_ct, mr, out_png, title, block=24, ct_brain_window=(0.0, 8
def _call(pipeline, ct, t1c, tv, args):
if args.deformable:
if pipeline == "simpleitk":
return RP.register_simpleitk_deformable(ct, t1c, threads=args.threads)
if pipeline == "elastix":
return RP.register_elastix_deformable(ct, t1c, workdir=args.elastix_dir,
threads=args.threads)
if pipeline == "antspyx":
return RP.register_antspyx_deformable(ct, t1c)
if pipeline == "synthmorph":
return RP.register_synthmorph_deformable(ct, t1c,
gpu=args.synthmorph_gpu,
threads=args.threads,
extent=args.synthmorph_extent)
if pipeline == "impact":
return RP.register_impact_deformable(ct, t1c, gpu=args.impact_gpu,
iters=args.impact_iters or RP.IMPACT_DEFAULT_ITERS,
samples=args.impact_samples or RP.IMPACT_DEFAULT_SAMPLES)
if pipeline == "niftyreg":
return RP.register_niftyreg_deformable(ct, t1c, threads=args.threads,
backend=args.niftyreg_backend,
gpu=args.niftyreg_gpu)
raise KeyError(pipeline)
if pipeline == "simpleitk":
return RP.register_simpleitk(ct, t1c, threads=args.threads, mode=args.simpleitk_mode)
if pipeline == "elastix":
@ -444,7 +466,7 @@ def print_table(rows):
print("=" * 96)
def write_plot(rows, out_png):
def write_plot(rows, out_png, label="rigid"):
try:
import matplotlib
matplotlib.use("Agg")
@ -466,6 +488,10 @@ def write_plot(rows, out_png):
("Registration time (s, median)", regt, "{:.0f}", "s"),
("ImpactLoss: warped-CT ~ MR (semantic, median; lower = better)", il, "{:.3f}", "loss"),
("Mattes MI: warped-CT ~ MR (nats, median)", mi, "{:.3f}", "nats")]):
# wide time ranges (deformable: simpleitk ~100x the others) -> log axis
nz = [v for v in vals if v > 0]
if len(nz) >= 2 and max(nz) / min(nz) > 30:
ax.set_yscale("log")
bars = ax.bar(range(len(vals)), vals, color=colors[0])
for b, v in zip(bars, vals):
ax.annotate(fmt.format(v), (b.get_x() + b.get_width() / 2, b.get_height()),
@ -474,7 +500,7 @@ def write_plot(rows, out_png):
ax.set_ylabel(ylab)
ax.set_xticks(range(len(vals)))
ax.set_xticklabels(labels, fontsize=9)
plt.suptitle("CT->T1c registration benchmark (M6, cross-modality rigid)", fontsize=11)
plt.suptitle(f"CT->T1c registration benchmark (M6, cross-modality {label})", fontsize=11)
fig.tight_layout()
fig.savefig(out_png, dpi=120)
plt.close(fig)
@ -493,15 +519,30 @@ def merge(args):
raise SystemExit(f"no pipeline results found in {out_dir}")
print(f"[merge] {len(rows)} pipeline result files in {out_dir}")
print_table(rows)
summary = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"protocol": ("rigid CT->T1c; cross-modality; alignment scored by achieved Mattes MI "
deformable = (os.path.basename(os.path.normpath(args.out_dir))
== "bench_registration_deformable")
if deformable:
protocol = ("deformable CT->T1c; cross-modality; each pipeline runs its "
"toolkit's native deformable workflow (linear init + deformable "
"stage, or a single deformable stage where that is native); the "
"result is rasterised to the T1c-grid displacement field of the "
"unified contract (T1c->CT); alignment scored by achieved Mattes MI "
"(nats, warped-CT ~ MR over the MR head, higher = better) and the "
"IMPACT semantic loss (vboussot/ImpactLoss, TS/M730 features, "
"warped-CT ~ MR over the full volumes, lower = better); reg time "
"excludes I/O+scoring; per-case JSON retains the production skull-ring "
"gap / acceptance QA; best-of-T vs T^-1 orientation gate for the "
"elastix-style toolkits"),
"excludes I/O+scoring; per-case JSON retains the production "
"skull-ring gap / acceptance QA")
else:
protocol = ("rigid CT->T1c; cross-modality; alignment scored by achieved "
"Mattes MI (nats, warped-CT ~ MR over the MR head, higher = "
"better) and the IMPACT semantic loss (vboussot/ImpactLoss, "
"TS/M730 features, warped-CT ~ MR over the full volumes, lower "
"= better); reg time excludes I/O+scoring; per-case JSON retains "
"the production skull-ring gap / acceptance QA; best-of-T vs T^-1 "
"orientation gate for the elastix-style toolkits")
summary = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"protocol": protocol,
"n_cases": next((r["n_cases"] for r in rows if r.get("available")), None),
"case_keys": sorted({c["key"] for r in rows if r.get("available") for c in r.get("cases", [])}),
"results": rows,
@ -510,7 +551,8 @@ def merge(args):
with open(sf, "w") as f:
json.dump(summary, f, indent=1)
print("summary:", sf)
write_plot(rows, os.path.join(out_dir, "bench_registration.png"))
write_plot(rows, os.path.join(out_dir, "bench_registration.png"),
label="deformable" if deformable else "rigid")
# markdown table for the report
md = ["| pipeline | available | toolkit | cases | ok | err | s/reg (median) | ImpactLoss (median) | MI (nats, median) |",
"|---|---|---|---|---|---|---|---|---|"]
@ -533,6 +575,10 @@ def merge(args):
def parse_args():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--deformable", action="store_true",
help="run the deformable variants of the pipelines "
"(each toolkit's native deformable workflow; results go "
"to results/bench_registration_deformable by default)")
ap.add_argument("--pipelines", default=" ".join(RP.ALL_PIPELINES),
help="space-separated subset of: " + " ".join(RP.ALL_PIPELINES))
ap.add_argument("--n-cases", type=int, default=10, help="number of M6 test cases (default 10)")
@ -594,6 +640,9 @@ def parse_args():
def main():
args = parse_args()
if args.deformable:
if args.out_dir == os.path.join(ROOT, "results", "bench_registration"):
args.out_dir = os.path.join(ROOT, "results", "bench_registration_deformable")
if args.merge:
merge(args)
return

View file

@ -0,0 +1,439 @@
"""Benchmark the 6 DEFORMABLE CT->T1c registration pipelines on N M6 cases.
Companion to ``scripts/bench_registration.py`` (rigid). Pipelines:
simpleitk | elastix | antspyx | synthmorph | impact | niftyreg
(each = its toolkit's native deformable refinement initialized from the
toolkit's own rigid result; see src/registration_pipelines_deformable.py
and REGISTRATION_BENCHMARK_DEFORMABLE.md).
Headline scores (identical for every pipeline):
- mi_ct_mr : achieved Mattes MI (nats), warped-CT ~ MR over the MR head
(higher = better). Also computed for the pipeline's own
rigid base (mi_base) -> d_mi = the deformable gain.
- impact_loss : IMPACT semantic loss (TS/M730 features), warped-CT ~ MR
over the full volumes (lower = better). Also mi/IL for
the base (il_base).
- jac_* : Jacobian-determinant statistics of the FINAL deformable
transform over the MR head (finite differences on the
T1c grid): p1/p50/mean + fraction < 0 (folded), < 0.3,
< 0.5. A healthy diffeomorphic refinement has J ~ 1.
- jac_gtv_* : same Jacobian statistics restricted to the warped-GTV
region (does the deformation fold the tumor?).
- disp_base_* : |T_final(x) - T_base(x)| over the MR head (p50/p95/max,
mm) -- the deformable increment.
- tv_vol_ratio : warped-GTV volume final / base (a diffeomorphism
preserves volume, ~1).
- reg_wall_s : wall-clock of the deformable stage (excludes I/O, the
rigid base and scoring).
Production QA (skull-ring gap, accepted) is still computed per case and kept
in the JSON.
Usage (repo root):
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal-313
python scripts/bench_registration_deformable.py --pipelines "simpleitk elastix antspyx niftyreg"
python scripts/bench_registration_deformable.py --pipelines "impact synthmorph"
python scripts/bench_registration_deformable.py --merge
"""
import argparse
import importlib.util
import json
import os
import sys
import time
from datetime import datetime
from concurrent.futures import ProcessPoolExecutor, as_completed
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import numpy as np
import SimpleITK as sitk
from src.common import DATA, d, read_nii_img, head_mask_from_image
from src import registration_pipelines as RP
from src import registration_pipelines_deformable as RPD
_spec = importlib.util.spec_from_file_location("br", os.path.join(ROOT, "scripts", "bench_registration.py"))
BR = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(BR)
JAC_STEP_MM = 1.0 # finite-difference half-step for the Jacobian estimate
# --------------------------------------------------------------------------- #
# deformable-specific scoring
# --------------------------------------------------------------------------- #
def _dense_H(T, ref):
"""Absolute mapping H(x) = T(x) at every voxel of ``ref`` (z,y,x,3)."""
return RPD.densify_sitk(T, ref)[2]
def jacobian_of_T(T, ref, step_mm=JAC_STEP_MM):
"""det dT/dx of an arbitrary contract transform over ``ref``'s grid.
Central finite differences with world-axis steps of ~``step_mm`` on the
densified absolute mapping (linear resampling of coordinate volumes);
border voxels (no both-side neighbour) are NaN. Returns J (z,y,x)."""
H = RPD.densify_sitk(T, ref)[2]
sp = np.array(ref.GetSpacing()) # (x, y, z); array axis i has spacing sp[2-i]
off = np.array([int(np.clip(np.round(step_mm / sp[2 - i]), 1, None))
for i in range(3)])
H = np.ascontiguousarray(H, dtype='float64')
dH = []
for i in range(3):
d = np.full(H.shape, np.nan)
sl_p = [slice(None)] * 3; sl_m = [slice(None)] * 3; sl_mid = [slice(None)] * 3
sl_p[i] = slice(2 * off[i], None)
sl_m[i] = slice(None, -2 * off[i])
sl_mid[i] = slice(off[i], -off[i])
d[tuple(sl_mid)] = (H[tuple(sl_p)] - H[tuple(sl_m)]) / (2.0 * off[i] * sp[2 - i])
dH.append(d)
# array axis i <-> world axis (2-i): axis 2 = x, axis 1 = y, axis 0 = z.
# J = det[dH/dx, dH/dy, dH/dz]
A = dH[2]; B = dH[1]; C = dH[0]
J = (A[..., 0] * (B[..., 1] * C[..., 2] - B[..., 2] * C[..., 1])
- A[..., 1] * (B[..., 0] * C[..., 2] - B[..., 2] * C[..., 0])
+ A[..., 2] * (B[..., 0] * C[..., 1] - B[..., 1] * C[..., 0]))
return J
def _stats(J, mask):
v = J[mask]
v = v[np.isfinite(v)]
if v.size < 50:
return None
return {
"p1": round(float(np.percentile(v, 1)), 4),
"p50": round(float(np.median(v)), 4),
"mean": round(float(v.mean()), 4),
"frac_neg": round(float((v < 0).mean()), 5),
"frac_lt03": round(float((v < 0.3).mean()), 5),
"frac_lt05": round(float((v < 0.5).mean()), 5),
"nvox": int(v.size),
}
def deformable_metrics(ct, t1c, tv, T_final, T_base):
"""Jacobian / displacement-increment / GTV-preservation metrics of a
deformable result (final transform vs its rigid base)."""
ha = sitk.GetArrayFromImage(t1c).astype('float32')
hm = head_mask_from_image(ha)
J = jacobian_of_T(T_final, t1c)
jac_head = _stats(J, hm)
# warped GTV mask (final)
wtv = sitk.GetArrayFromImage(
sitk.Resample(tv, t1c, T_final, sitk.sitkNearestNeighbor, 0.0))
gtv = wtv > 0.5
if gtv.sum() > 50:
gtv = gtv & hm
jac_gtv = _stats(J, gtv)
else:
jac_gtv = None
# displacement increment |T_final - T_base| over the head
Hf = _dense_H(T_final, t1c)
Hb = _dense_H(T_base, t1c)
inc = np.linalg.norm(Hf - Hb, axis=-1)
inc_stats = {
"p50": round(float(np.median(inc[hm])), 2),
"p95": round(float(np.percentile(inc[hm], 95)), 2),
"max": round(float(inc[hm].max()), 2),
}
# GTV volume preservation (final vs base)
wtv_b = sitk.GetArrayFromImage(
sitk.Resample(tv, t1c, T_base, sitk.sitkNearestNeighbor, 0.0))
nv_f = int((wtv > 0.5).sum()); nv_b = int((wtv_b > 0.5).sum())
tv_vol_ratio = round(nv_f / nv_b, 3) if nv_b > 0 else None
return {"jac_head": jac_head, "jac_gtv": jac_gtv,
"disp_base_mm": inc_stats, "tv_vol_ratio": tv_vol_ratio,
"gtv_nvox_final": nv_f, "gtv_nvox_base": nv_b}
# --------------------------------------------------------------------------- #
# case worker
# --------------------------------------------------------------------------- #
def _call(pipeline, ct, t1c, args):
if pipeline == "simpleitk":
return RPD.register_simpleitk_deformable(ct, t1c, threads=args.threads)
if pipeline == "elastix":
return RPD.register_elastix_deformable(ct, t1c, workdir=args.elastix_dir,
threads=args.threads)
if pipeline == "antspyx":
return RPD.register_antspyx_deformable(ct, t1c, threads=args.threads)
if pipeline == "niftyreg":
return RPD.register_niftyreg_deformable(ct, t1c, threads=args.threads,
gpu=args.niftyreg_gpu,
backend=args.niftyreg_backend)
if pipeline == "synthmorph":
return RPD.register_synthmorph_deformable(ct, t1c, gpu=args.synthmorph_gpu,
extent=args.synthmorph_extent)
if pipeline == "impact":
return RPD.register_impact_deformable(
ct, t1c, gpu=args.impact_gpu,
iters=args.impact_iters or RP.IMPACT_DEFAULT_ITERS,
samples=args.impact_samples or RP.IMPACT_DEFAULT_SAMPLES,
threads=args.threads)
raise KeyError(pipeline)
def _run_case(pipeline, case, args):
key = case["key"]
ct = read_nii_img(case["ct"])
t1c = read_nii_img(case["img"])
tv = read_nii_img(case["label"])
out = {"key": key, "subject": case["subject"], "date": case["date"]}
if not BR.ct_complete(ct):
out.update({"skull_ring_gap_mm": None, "tv_nvox": 0, "tv_in_ct_frac": None,
"impact_loss": None, "alignment": "ct_incomplete",
"accepted": False,
"note": "CT head incomplete (data issue, not a pipeline failure)"})
return out
t0 = time.time()
T_final, T_base, meta = _call(pipeline, ct, t1c, args)
out["reg_wall_s"] = round(time.time() - t0, 1)
out.update(meta)
# final + base alignment scores
out.update(BR.evaluate(ct, t1c, tv, T_final))
out["base"] = BR.evaluate(ct, t1c, tv, T_base)
out["d_mi"] = (round(out["mi_ct_mr"] - out["base"]["mi_ct_mr"], 4)
if out["mi_ct_mr"] is not None and out["base"]["mi_ct_mr"] is not None
else None)
out["d_il"] = (round(out["impact_loss"] - out["base"]["impact_loss"], 4)
if out["impact_loss"] is not None and out["base"]["impact_loss"] is not None
else None)
out.update(deformable_metrics(ct, t1c, tv, T_final, T_base))
return out
def run_pipeline(pipeline, cases, args):
avail, note = RPD.availability().get(pipeline, (False, "unknown"))
if not avail:
row = {"pipeline": pipeline, "available": False, "reason": note,
"n_cases": len(cases), "cases": []}
print(f"[{pipeline}] UNAVAILABLE: {note}")
return row
print(f"[{pipeline}] available ({note}); registering {len(cases)} cases "
f"with {args.workers} workers")
results, errors = [], []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(_run_case, pipeline, c, args): c for c in cases}
for fu in as_completed(futs):
c = futs[fu]
try:
results.append(fu.result())
r = results[-1]
reg = f"reg={r.get('reg_wall_s')}s" if r.get("reg_wall_s") is not None else "incomplete-CT"
print(f" {c['key']}: mi={r.get('mi_ct_mr')} (base {r.get('base', {}).get('mi_ct_mr')}) "
f"il={r.get('impact_loss')} "
f"jac_p1={(r.get('jac_head') or {}).get('p1')} {reg}", flush=True)
except Exception as e: # noqa
errors.append({"key": c["key"], "error": repr(e)})
print(f" {c['key']}: ERROR {e!r}", flush=True)
results.sort(key=lambda x: x["key"])
registered = [r for r in results if r.get("reg_wall_s") is not None]
incomplete = [r for r in results if r.get("alignment") == "ct_incomplete"]
accepted = [r for r in registered if r.get("accepted")]
def _med(vals, nd=4):
vals = [v for v in vals if v is not None]
return round(float(np.median(vals)), nd) if vals else None
row = {
"pipeline": pipeline, "available": True,
"toolkit": (registered or [{}])[0].get("toolkit"),
"metric": (registered or [{}])[0].get("metric"),
"transform": (registered or [{}])[0].get("transform"),
"n_cases": len(cases), "n_registered": len(registered),
"n_ct_incomplete": len(incomplete), "n_error": len(errors),
"n_accepted": len(accepted),
"accepted_rate": round(len(accepted) / max(len(cases), 1), 3),
"reg_wall_s": {
"median": _med([r["reg_wall_s"] for r in registered], 1),
"mean": round(float(np.mean([r["reg_wall_s"] for r in registered])), 1) if registered else None,
"max": round(float(np.max([r["reg_wall_s"] for r in registered])), 1) if registered else None,
},
"mi_ct_mr": {"median": _med([r.get("mi_ct_mr") for r in registered]),
"mean": round(float(np.mean([r["mi_ct_mr"] for r in registered if r.get("mi_ct_mr") is not None])), 4) if any(r.get("mi_ct_mr") is not None for r in registered) else None},
"mi_base": {"median": _med([r.get("base", {}).get("mi_ct_mr") for r in registered])},
"d_mi": {"median": _med([r.get("d_mi") for r in registered])},
"impact_loss": {"median": _med([r.get("impact_loss") for r in registered]),
"mean": round(float(np.mean([r["impact_loss"] for r in registered if r.get("impact_loss") is not None])), 4) if any(r.get("impact_loss") is not None for r in registered) else None},
"il_base": {"median": _med([r.get("base", {}).get("impact_loss") for r in registered])},
"d_il": {"median": _med([r.get("d_il") for r in registered])},
"jac_p1": {"median": _med([(r.get("jac_head") or {}).get("p1") for r in registered])},
"jac_frac_neg": {"median": _med([(r.get("jac_head") or {}).get("frac_neg") for r in registered], 5)},
"jac_frac_lt05": {"median": _med([(r.get("jac_head") or {}).get("frac_lt05") for r in registered], 5)},
"jac_gtv_p1": {"median": _med([(r.get("jac_gtv") or {}).get("p1") for r in registered])},
"disp_base_p95_mm": {"median": _med([(r.get("disp_base_mm") or {}).get("p95") for r in registered], 2)},
"tv_vol_ratio": {"median": _med([r.get("tv_vol_ratio") for r in registered])},
"cases": results, "errors": errors,
"timestamp": datetime.now().isoformat(timespec="seconds"),
}
return row
# --------------------------------------------------------------------------- #
# reporting
# --------------------------------------------------------------------------- #
def _fmt(v, spec="{:g}", default="-"):
return default if v is None else spec.format(v)
def print_table(rows):
order = [p for p in RPD.ALL_PIPELINES if any(r["pipeline"] == p for r in rows)]
print("\n" + "=" * 118)
print(f"{'pipeline':10s} {'avail':5s} {'n':>3s} {'ok':>3s} {'err':>3s} {'s/reg':>7s} "
f"{'MI fin':>7s} {'MI base':>8s} {'dMI':>7s} {'IL fin':>7s} {'IL base':>8s} "
f"{'jacP1':>7s} {'J<0':>7s} {'J<.5':>6s} {'incr p95':>9s}")
print("-" * 118)
for p in order:
r = next(x for x in rows if x["pipeline"] == p)
if not r.get("available"):
print(f"{p:10s} {'no':5s} {r['n_cases']:>3d} {0:>3d} {'-':>3s} [{r['reason'][:60]}]")
continue
print(f"{p:10s} {'yes':5s} {r['n_cases']:>3d} {r.get('n_registered', 0):>3d} "
f"{r.get('n_error', 0):>3d} {_fmt((r.get('reg_wall_s') or {}).get('median'), '{:.0f}'):>7s} "
f"{_fmt((r.get('mi_ct_mr') or {}).get('median'), '{:.3f}'):>7s} "
f"{_fmt((r.get('mi_base') or {}).get('median'), '{:.3f}'):>8s} "
f"{_fmt((r.get('d_mi') or {}).get('median'), '{:+.3f}'):>7s} "
f"{_fmt((r.get('impact_loss') or {}).get('median'), '{:.3f}'):>7s} "
f"{_fmt((r.get('il_base') or {}).get('median'), '{:.3f}'):>8s} "
f"{_fmt((r.get('jac_p1') or {}).get('median'), '{:.3f}'):>7s} "
f"{_fmt((r.get('jac_frac_neg') or {}).get('median'), '{:.4f}'):>7s} "
f"{_fmt((r.get('jac_frac_lt05') or {}).get('median'), '{:.3f}'):>6s} "
f"{_fmt((r.get('disp_base_p95_mm') or {}).get('median'), '{:.1f}'):>9s}")
print("=" * 118)
def merge(args):
out_dir = args.out_dir
rows = []
for p in RPD.ALL_PIPELINES:
fp = os.path.join(out_dir, p + ".json")
if os.path.exists(fp):
with open(fp) as f:
rows.append(json.load(f))
if not rows:
raise SystemExit(f"no pipeline results found in {out_dir}")
print(f"[merge] {len(rows)} pipeline result files in {out_dir}")
print_table(rows)
summary = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"protocol": ("deformable CT->T1c (each toolkit's native deformable refinement "
"initialized from its own rigid result); alignment scored by "
"achieved Mattes MI (nats, warped-CT ~ MR over the MR head, higher "
"= better) and the IMPACT semantic loss (TS/M730, full volumes, "
"lower = better), plus Jacobian-determinant statistics of the "
"final transform (finite differences on the T1c grid), the "
"deformable displacement increment vs the rigid base, and GTV "
"volume preservation; same contract / shared rules as the rigid "
"benchmark (full volumes, T1c fixed / CT moving, no mask)"),
"n_cases": next((r["n_cases"] for r in rows if r.get("available")), None),
"case_keys": sorted({c["key"] for r in rows if r.get("available")
for c in r.get("cases", [])}),
"results": rows,
}
sf = os.path.join(out_dir, "summary.json")
with open(sf, "w") as f:
json.dump(summary, f, indent=1)
print("summary:", sf)
md = ["| pipeline | toolkit | cases | ok | s/reg (med) | MI (med) | MI base (med) | ΔMI (med) | ImpactLoss (med) | IL base (med) | ΔIL (med) | jac p1 (med) | frac J<0 (med) | frac J<0.5 (med) | incr p95 (med, mm) | tv vol ratio (med) |",
"|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
for r in rows:
if not r.get("available"):
md.append(f"| {r['pipeline']} | - | {r['n_cases']} | 0 | - | - | - | - | - | - | - | - | - | - | - | - | ({r['reason']}) |")
continue
md.append(
f"| {r['pipeline']} | {(r.get('toolkit') or '')} | {r['n_cases']} | {r.get('n_registered', 0)} | "
f"{_fmt((r.get('reg_wall_s') or {}).get('median'), '{:.0f}')} | "
f"{_fmt((r.get('mi_ct_mr') or {}).get('median'), '{:.3f}')} | "
f"{_fmt((r.get('mi_base') or {}).get('median'), '{:.3f}')} | "
f"{_fmt((r.get('d_mi') or {}).get('median'), '{:+.3f}')} | "
f"{_fmt((r.get('impact_loss') or {}).get('median'), '{:.3f}')} | "
f"{_fmt((r.get('il_base') or {}).get('median'), '{:.3f}')} | "
f"{_fmt((r.get('d_il') or {}).get('median'), '{:+.3f}')} | "
f"{_fmt((r.get('jac_p1') or {}).get('median'), '{:.3f}')} | "
f"{_fmt((r.get('jac_frac_neg') or {}).get('median'), '{:.4f}')} | "
f"{_fmt((r.get('jac_frac_lt05') or {}).get('median'), '{:.3f}')} | "
f"{_fmt((r.get('disp_base_p95_mm') or {}).get('median'), '{:.1f}')} | "
f"{_fmt((r.get('tv_vol_ratio') or {}).get('median'), '{:.3f}')} |")
mf = os.path.join(out_dir, "summary.md")
with open(mf, "w") as f:
f.write("\n".join(md) + "\n")
print("markdown:", mf)
def parse_args():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--pipelines", default=" ".join(RPD.ALL_PIPELINES))
ap.add_argument("--n-cases", type=int, default=10)
ap.add_argument("--sample", choices=["first", "random"], default="first")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--keys", default=None)
ap.add_argument("--workers", type=int, default=4)
ap.add_argument("--threads", type=int, default=24)
ap.add_argument("--out-dir", default=os.path.join(ROOT, "results", "bench_registration_deformable"))
ap.add_argument("--elastix-dir", default=None)
ap.add_argument("--synthmorph-gpu", type=int, default=None)
ap.add_argument("--synthmorph-extent", type=int, default=256)
ap.add_argument("--impact-gpu", type=int, default=None)
ap.add_argument("--impact-iters", type=int, default=None)
ap.add_argument("--impact-samples", type=int, default=None)
ap.add_argument("--niftyreg-backend", choices=["auto", "conda", "docker"], default="auto")
ap.add_argument("--niftyreg-gpu", type=int, default=None)
ap.add_argument("--merge", action="store_true")
args = ap.parse_args()
return args
def main():
args = parse_args()
if args.merge:
merge(args)
return
args.elastix_dir = args.elastix_dir or d("data/m6reg/elastix")
pipelines = [p.lower() for p in args.pipelines.split()]
unknown = set(pipelines) - set(RPD.ALL_PIPELINES)
if unknown:
raise SystemExit(f"unknown pipeline(s): {unknown}")
if args.keys:
m6 = RP._load_m6()
labeled, _ = m6.discover()
bykey = {r["key"]: r for r in labeled}
missing = [k for k in args.keys.split(",") if k not in bykey]
if missing:
raise SystemExit(f"keys not found in m6 labeled: {missing}")
cases = [bykey[k.strip()] for k in args.keys.split(",")]
print(f"[cases] using {len(cases)} explicit keys")
else:
cases = BR.select_cases(args)
print(f"\n[bench-def] pipelines={pipelines} n_cases={len(cases)} workers={args.workers} "
f"threads={args.threads} out_dir={args.out_dir}")
rows = []
for p in pipelines:
try:
rows.append(run_pipeline(p, cases, args))
except RP.PipelineUnavailable as e:
rows.append({"pipeline": p, "available": False, "reason": str(e),
"n_cases": len(cases), "cases": []})
print(f"[{p}] UNAVAILABLE: {e}")
for r in rows:
os.makedirs(args.out_dir, exist_ok=True)
fp = os.path.join(args.out_dir, r["pipeline"] + ".json")
with open(fp, "w") as f:
json.dump(r, f, indent=1)
print(f"[{r['pipeline']}] wrote {fp}")
print_table(rows)
avail = RP.availability()
with open(os.path.join(args.out_dir, "_availability.json"), "w") as f:
json.dump({"timestamp": datetime.now().isoformat(timespec="seconds"),
"env_python": sys.version.split()[0], "exec": sys.executable,
"availability": avail}, f, indent=1)
print("[bench-def] done. Re-run with --merge to combine.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,601 @@
"""Investigate the low performance of the ``synthmorph`` pipeline on the M6
CT->T1c rigid benchmark (see REGISTRATION_BENCHMARK_RIGID.md).
The benchmark median MI is 0.277 for synthmorph vs ~0.65 for the intensity
tools, with the ~30 deg repositioned cases collapsing to 0.06-0.11. This
script decomposes *why*, scoring everything with the exact benchmark scoring
(``scripts/bench_registration.py::evaluate``):
A. Transform decomposition -- for each case, run synthmorph ``rigid`` (the
benchmark config; deterministic, so it reproduces the bench JSON) and a
``simpleitk`` benchmark reference, and report per case:
- achieved MI / ImpactLoss / skull-ring gap for both,
- rotation angle + axis of each transform (world RAS frame),
- the *relative* rotation error angle(R_ref^T R_sm),
- the head-centre placement error |T_sm(c) - T_ref(c)| (c = T1c head
centroid),
- the identity-transform score (the "do nothing" baseline),
- a numeric rigidity check of the synthmorph matrix (R^T R ~ I, det = 1).
B. Affine model -- run synthmorph ``affine`` (12-DOF matrix model) on the
same cases and score it: does the bigger matrix family recover the tilt
that rigid does not? The linear part is decomposed into rotation /
scale / shear (polar).
C. Joint (affine + deformable) upper bound -- for the worst cases, run
``-m joint`` directly in Docker (outside the rigid contract: the output is
a displacement field, applied with the tool's own ``-o`` resampling) and
score the warped CT image. Answers: is the low score a matrix-model
limitation or a model-family (pretrained-on-MRI) limitation?
D. Network-space mechanics -- static (no registration) check of what the
network actually sees, emulating ``network_space`` + ``normalize`` from
the image code (isotropic 1-mm LIA grid, extent 256, centred on each
volume's own centre, fill 0, min-max into [0,1]):
- head truncation: fraction of head-mask voxels (the T1c head mapped into
CT space via the reference transform) outside the 256 mm box, for the CT
and T1c network spaces,
- intensity band: the min-max-normalized values over the head region for
the T1c, the [0,80]-clipped CT (the benchmark default) and the raw CT.
Usage (repo root, conda env longitudinal-313):
python scripts/investigate_synthmorph.py --workers 3
python scripts/investigate_synthmorph.py --parts D # static only
python scripts/investigate_synthmorph.py --keys m6_2BBN5XA4_20250411
"""
import argparse
import importlib.util
import json
import math
import os
import shutil
import subprocess
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor, as_completed
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import numpy as np
import SimpleITK as sitk
from scipy.ndimage import center_of_mass
from src.common import d, read_nii_img, head_mask_from_image
from src import registration_pipelines as RP
def _load_module(name, rel):
p = os.path.join(ROOT, rel)
spec = importlib.util.spec_from_file_location(name, p)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
BR = _load_module("bench_registration", "scripts/bench_registration.py")
M6 = RP._load_m6()
# the worst 3 bench cases (rigid MI): ~30 deg tilt x2 + a mid-tier case
JOINT_KEYS = ("m6_2BBN5XA4_20250411", "m6_24V62QQM_20240820",
"m6_24V62QQM_20230223")
# --------------------------------------------------------------------------- #
# transform decomposition
# ---------------------------------------------------------------------------
def _rot_angle(R):
c = (np.trace(R) - 1.0) / 2.0
return math.degrees(math.acos(float(np.clip(c, -1.0, 1.0))))
def _rot_axis(R, theta_deg):
if theta_deg <= 0.5:
return np.zeros(3)
axis = np.array([R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1]])
n = np.linalg.norm(axis)
return axis / n if n > 1e-9 else np.zeros(3)
def _to_affine(T):
"""Recover (R, t) of any SimpleITK transform by point mapping (world RAS)."""
o = np.array(T.TransformPoint((0.0, 0.0, 0.0)), dtype=float)
R = np.empty((3, 3), dtype=float)
for i in range(3):
p = [0.0, 0.0, 0.0]
p[i] = 1.0
R[:, i] = np.array(T.TransformPoint(tuple(p)), dtype=float) - o
return R, o
def _decompose_affine(T):
"""(R, t, theta_deg, axis, rigidity) of a SimpleITK transform in world RAS."""
R, t = _to_affine(T)
theta = _rot_angle(R)
rig = {"det": round(float(np.linalg.det(R)), 6),
"max_devi_from_I": round(float(np.linalg.norm(R.T @ R - np.eye(3))), 6)}
return R, t, theta, _rot_axis(R, theta), rig
def _angle_between_rotations(A, B):
"""angle of A^T B in degrees (rotation B needs on top of A)."""
return _rot_angle(A.T @ B)
def _polar_rotation(M):
"""rotation part of a general 3x3 linear map (SVD polar)."""
U, _s, Vt = np.linalg.svd(M)
R_p = U @ Vt
if np.linalg.det(R_p) < 0:
U[:, -1] *= -1
R_p = U @ Vt
return R_p
def head_centroid_world(t1c):
ha = sitk.GetArrayFromImage(t1c).astype("float32")
idx = center_of_mass(head_mask_from_image(ha))
sp = np.array(t1c.GetSpacing())
o = np.array(t1c.GetOrigin())
D = np.array(t1c.GetDirection()).reshape(3, 3)
return o + D @ (np.array(idx[::-1]) * sp)
def score_identity(ct, t1c):
"""The "do nothing" baseline: CT resampled onto the T1c grid with the
identity transform (i.e. no registration at all)."""
ha = sitk.GetArrayFromImage(t1c).astype("float32")
wc = sitk.GetArrayFromImage(sitk.Resample(ct, t1c, sitk.Transform(),
sitk.sitkLinear))
gap = M6._skull_ring_gap(wc, ha)
mi = BR._mi_masked(wc, ha, head_mask_from_image(ha))
return {"mi": mi, "gap": gap}
# --------------------------------------------------------------------------- #
# part A/B/D worker
# ---------------------------------------------------------------------------
def _case_task(case, args):
out = {"key": case["key"], "subject": case["subject"], "date": case["date"]}
ct = read_nii_img(case["ct"])
t1c = read_nii_img(case["img"])
tv = read_nii_img(case["label"])
if not BR.ct_complete(ct):
out["note"] = "ct_incomplete (skipped, same as the benchmark)"
return out
gpu = (args.gpu_base + abs(hash(out["key"])) % max(1, args.n_gpus)) % max(1, args.n_gpus)
parts = set(args.parts)
# geometry facts
for nm, img in (("ct", ct), ("t1c", t1c)):
sz = img.GetSize()
sp = img.GetSpacing()
c = np.array(img.TransformIndexToPhysicalPoint(
((sz[0] - 1) // 2, (sz[1] - 1) // 2, (sz[2] - 1) // 2)))
out[nm] = {"shape_xyz": list(sz),
"spacing_xyz": [round(float(s), 3) for s in sp],
"fov_mm_xyz": [round(float(s0 * s1), 1) for s0, s1 in zip(sz, sp)],
"center_world": [round(float(x), 1) for x in c]}
c_t1c = head_centroid_world(t1c)
out["t1c_head_centroid_world"] = [round(float(x), 1) for x in c_t1c]
R_ref = t_ref = None
if parts & {"A", "B", "D"}:
# reference: simpleitk benchmark mode (deterministic at fixed threads)
T_ref, meta_ref = RP.register_simpleitk(ct, t1c, threads=16,
mode="benchmark")
sref = BR.evaluate(ct, t1c, tv, T_ref)
R_ref, t_ref, th_ref, ax_ref, _ = _decompose_affine(T_ref)
out["reference"] = {
"tool": meta_ref["toolkit"],
"R": R_ref.tolist(), "t": t_ref.tolist(),
"mi": sref["mi_ct_mr"], "impact_loss": sref["impact_loss"],
"gap": sref["skull_ring_gap_mm"],
"rotation_deg": round(th_ref, 2),
"axis": [round(float(a), 3) for a in ax_ref],
"translation": [round(float(x), 1) for x in t_ref],
"head_centre_ct": [round(float(x), 1) for x in (R_ref @ c_t1c + t_ref)],
}
out["identity"] = score_identity(ct, t1c)
if "A" in parts:
t0 = time.time()
T_sm, meta_sm = RP.register_synthmorph(ct, t1c, model="rigid",
gpu=int(gpu), extent=256)
ssm = BR.evaluate(ct, t1c, tv, T_sm)
R_sm, t_sm, th_sm, ax_sm, rig = _decompose_affine(T_sm)
p_ref = R_ref @ c_t1c + t_ref
p_sm = R_sm @ c_t1c + t_sm
out["rigid"] = {
"seconds": round(time.time() - t0, 1),
"auto_inverted": meta_sm["auto_inverted"],
"clip_ct": meta_sm["clip_ct"],
"mi": ssm["mi_ct_mr"], "impact_loss": ssm["impact_loss"],
"gap": ssm["skull_ring_gap_mm"],
"rotation_deg": round(th_sm, 2),
"axis": [round(float(a), 3) for a in ax_sm],
"translation": [round(float(x), 1) for x in t_sm],
"matrix_rigidity": rig,
"rot_err_vs_ref_deg": round(_angle_between_rotations(R_ref, R_sm), 2),
"head_centre_err_mm": round(float(np.linalg.norm(p_sm - p_ref)), 1),
}
if "B" in parts:
t0 = time.time()
T_af, meta_af = RP.register_synthmorph(ct, t1c, model="affine",
gpu=int(gpu), extent=256)
saf = BR.evaluate(ct, t1c, tv, T_af)
M_af, t_af = _to_affine(T_af)
svals = np.linalg.svd(M_af, compute_uv=False)
lin = {"scale_mean": round(float(np.mean(svals)), 4),
"scale_iso_dev": round(float(np.std(svals)) / max(float(np.mean(svals)), 1e-12), 4),
"rotation_deg": round(_rot_angle(_polar_rotation(M_af)), 2),
"det": round(float(np.linalg.det(M_af)), 4)}
row = {"seconds": round(time.time() - t0, 1),
"auto_inverted": meta_af["auto_inverted"],
"mi": saf["mi_ct_mr"], "impact_loss": saf["impact_loss"],
"gap": saf["skull_ring_gap_mm"], "linear": lin}
if R_ref is not None:
row["rot_err_vs_ref_deg"] = round(
_angle_between_rotations(R_ref, _polar_rotation(M_af)), 2)
row["head_centre_err_mm"] = round(
float(np.linalg.norm(M_af @ c_t1c + t_af - (R_ref @ c_t1c + t_ref))), 1)
out["affine"] = row
if "D" in parts:
out["network_space"] = _network_space_analysis(ct, t1c, out,
R_ref=R_ref, t_ref=t_ref)
return out
# --------------------------------------------------------------------------- #
# part D: what the network sees
# ---------------------------------------------------------------------------
def _network_grid(img, extent=256):
"""The matrix-model network space of ``img``: isotropic 1-mm LIA grid,
``extent``^3, centred on the volume's own centre (mirrors
``network_space`` in the container's registration.py)."""
sz = img.GetSize()
c = np.array(img.TransformIndexToPhysicalPoint(
((sz[0] - 1) // 2, (sz[1] - 1) // 2, (sz[2] - 1) // 2)))
# LIA axes in the RAS world: x_L = -x_R, y_I = -y_A, z_A = +z_R
origin = (c[0] + extent / 2.0 - 0.5, c[1] + extent / 2.0 - 0.5,
c[2] - extent / 2.0 + 0.5)
net = sitk.Image((extent, extent, extent), sitk.sitkFloat32)
net.SetSpacing((1.0, 1.0, 1.0))
net.SetOrigin(tuple(float(x) for x in origin))
net.SetDirection((-1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 1.0))
return net, c
def _to_network(img, net):
a = sitk.GetArrayFromImage(
sitk.Resample(img, net, sitk.Transform(), sitk.sitkLinear, 0.0))
lo, hi = float(a.min()), float(a.max())
if hi - lo < 1e-9:
return np.zeros_like(a), lo, hi
return (a - lo) / (hi - lo), lo, hi
def _network_space_analysis(ct, t1c, out, extent=256, R_ref=None, t_ref=None):
"""Extent truncation + min-max intensity band (what the model sees)."""
res = {"extent": extent}
ha = sitk.GetArrayFromImage(t1c).astype("float32")
head_t1c = sitk.GetImageFromArray(head_mask_from_image(ha).astype("float32"))
head_t1c.CopyInformation(t1c)
if R_ref is not None:
T_ref = sitk.AffineTransform(3)
T_ref.SetMatrix(list(np.array(R_ref).ravel()))
T_ref.SetTranslation(list(np.array(t_ref)))
# T_ref maps T1c -> CT, so rasterising the T1c head onto the CT grid
# needs the inverse as the destination -> source map
head_ct = sitk.Resample(head_t1c, ct, T_ref.GetInverse(),
sitk.sitkNearestNeighbor, 0.0)
else:
head_ct = head_t1c
# truncation: head voxels outside each volume's OWN extent box
for nm, img, head in (("ct", ct, head_ct), ("t1c", t1c, head_t1c)):
_net, c = _network_grid(img, extent)
idx = np.argwhere(sitk.GetArrayFromImage(head) > 0.5)
if idx.size == 0:
res[f"{nm}_head_trunc_frac"] = None
continue
sp = np.array(img.GetSpacing())
o = np.array(img.GetOrigin())
D = np.array(img.GetDirection()).reshape(3, 3)
world = o[None, :] + (idx[:, ::-1] * sp) @ D.T # idx is (z,y,x)
half = extent / 2.0
inside = (np.abs(world - c[None, :]) <= half).all(axis=1)
head_c = world.mean(axis=0)
res[f"{nm}_head_voxels"] = int(idx.shape[0])
res[f"{nm}_head_trunc_frac"] = round(float(1.0 - inside.mean()), 4)
res[f"{nm}_head_off_volume_centre_mm"] = \
[round(float(x), 1) for x in (head_c - c)]
# min-max normalized band over the head region (emulating normalize=True)
bands = {}
for nm, img, head in (("t1c", t1c, head_t1c),
("ct_clip", RP._clip_intensity(ct, 0.0, 80.0), head_ct),
("ct_raw", ct, head_ct)):
net, _c = _network_grid(img, extent)
a, lo, hi = _to_network(img, net)
hnet = sitk.GetArrayFromImage(
sitk.Resample(head, net, sitk.Transform(),
sitk.sitkNearestNeighbor, 0.0)) > 0.5
v = a[hnet]
bands[nm] = {"src_min": round(lo, 1), "src_max": round(hi, 1),
"head_nvox_net": int(v.size),
"p5": round(float(np.percentile(v, 5)), 4) if v.size else None,
"p50": round(float(np.percentile(v, 50)), 4) if v.size else None,
"p95": round(float(np.percentile(v, 95)), 4) if v.size else None,
"band_width_p95_p5": (round(float(np.percentile(v, 95)
- np.percentile(v, 5)), 4)
if v.size else None)}
res["normalized_band"] = bands
return res
# --------------------------------------------------------------------------- #
# part C: joint (affine + deformable) upper bound
# ---------------------------------------------------------------------------
def _joint_task(case, args):
out = {"key": case["key"], "subject": case["subject"], "date": case["date"],
"model": "joint (affine + deformable)"}
ct = read_nii_img(case["ct"])
t1c = read_nii_img(case["img"])
if not BR.ct_complete(ct):
out["note"] = "ct_incomplete (skipped)"
return out
gpu = (args.gpu_base + abs(hash(out["key"])) % max(1, args.n_gpus)) % max(1, args.n_gpus)
tmp = tempfile.mkdtemp(prefix="sm_joint_")
try:
ct_clip = RP._clip_intensity(ct, 0.0, 80.0)
sitk.WriteImage(sitk.Cast(ct_clip, sitk.sitkFloat32),
os.path.join(tmp, "moving.nii.gz"), True)
sitk.WriteImage(sitk.Cast(t1c, sitk.sitkFloat32),
os.path.join(tmp, "fixed.nii.gz"), True)
# CLI order is (moving, fixed); -o saves moving registered to fixed
cmd = ["docker", "run", "--rm",
"--user", f"{os.getuid()}:{os.getgid()}",
"-e", "TF_FORCE_GPU_ALLOW_GROWTH=true",
"--gpus", "all", "-e", f"CUDA_VISIBLE_DEVICES={gpu}",
"-v", f"{tmp}:/data", RP.SYNTHMORPH_IMAGE,
"register", "-m", "joint", "-e", "256", "-g",
"-o", "/data/warped.nii.gz",
"/data/moving.nii.gz", "/data/fixed.nii.gz"]
t0 = time.time()
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=7200)
dt = time.time() - t0
if proc.returncode != 0:
out["error"] = (f"docker failed rc={proc.returncode}:\n"
+ "\n".join((proc.stdout + proc.stderr).splitlines()[-15:]))
return out
warped = sitk.GetArrayFromImage(
sitk.ReadImage(os.path.join(tmp, "warped.nii.gz"))).astype("float32")
ha = sitk.GetArrayFromImage(t1c).astype("float32")
gap = M6._skull_ring_gap(warped, ha)
mi = BR._mi_masked(warped, ha, head_mask_from_image(ha))
il = BR._impact_loss_value(warped, ha)
out.update({"seconds": round(dt, 1), "mi": mi, "impact_loss": il,
"gap": gap, "gpu": int(gpu)})
finally:
if not args.keep_scratch:
shutil.rmtree(tmp, ignore_errors=True)
return out
# --------------------------------------------------------------------------- #
# part F: re-centred CT probe (volume-centre anchoring hypothesis)
# ---------------------------------------------------------------------------
def _recenter_task(case, args, ref_map):
"""H5 probe: the matrix model's network space is centred on each volume's
OWN centre. The T1c is a tight head crop (centre ~ head centre), but the
head+neck CT's volume centre sits 15-80 mm below the head. If the model
under-corrects that offset, cropping the CT to the head box (re-centring
the volume on the head) should remove the translation miss. The crop is
world-preserving, so the returned transform scores against the FULL CT
exactly like the benchmark."""
out = {"key": case["key"], "subject": case["subject"], "date": case["date"]}
ct = read_nii_img(case["ct"])
t1c = read_nii_img(case["img"])
tv = read_nii_img(case["label"])
if not BR.ct_complete(ct):
out["note"] = "ct_incomplete (skipped)"
return out
ref = ref_map.get(case["key"])
if not ref:
out["error"] = "no reference transform in partABD.json"
return out
R = np.array(ref["R"])
t = np.array(ref["t"])
T_ref = sitk.AffineTransform(3)
T_ref.SetMatrix(list(R.ravel()))
T_ref.SetTranslation(list(t))
gpu = (args.gpu_base + abs(hash(out["key"])) % max(1, args.n_gpus)) % max(1, args.n_gpus)
# head in CT space (T_ref maps T1c -> CT, so rasterise with its inverse)
ha = sitk.GetArrayFromImage(t1c).astype("float32")
head_t1c = sitk.GetImageFromArray(head_mask_from_image(ha).astype("float32"))
head_t1c.CopyInformation(t1c)
head_ct = sitk.Resample(head_t1c, ct, T_ref.GetInverse(),
sitk.sitkNearestNeighbor, 0.0)
m = sitk.GetArrayFromImage(head_ct) > 0.5
idx = np.argwhere(m) # (z, y, x)
lo, hi = idx.min(0), idx.max(0)
cen = (lo + hi + 1) / 2.0
half = (hi - lo) / 2.0 + 15.0 / np.array(ct.GetSpacing())[::-1] # (z,y,x)
sz = np.array(ct.GetSize())[::-1] # GetSize is (x,y,z); work in (z,y,x)
b0 = np.clip(np.floor(cen - half).astype(int), 0, sz - 1)
b1 = np.clip(np.ceil(cen + half).astype(int), 0, sz)
def _volcentre(img):
s = img.GetSize()
return np.array(img.TransformIndexToPhysicalPoint(
((s[0] - 1) // 2, (s[1] - 1) // 2, (s[2] - 1) // 2)))
hc = T_ref.TransformPoint(tuple(float(x) for x in head_centroid_world(t1c)))
out["before"] = {"head_off_volume_centre_mm": [
round(float(x), 1) for x in (np.array(hc) - _volcentre(ct))]}
# SimpleITK image slicing is in (x, y, z) order; b0/b1 are (z, y, x)
ct_crop = ct[int(b0[2]):int(b1[2]), int(b0[1]):int(b1[1]),
int(b0[0]):int(b1[0])]
out["crop"] = {"shape": list(ct_crop.GetSize()),
"head_off_volume_centre_mm": [
round(float(x), 1)
for x in (np.array(hc) - _volcentre(ct_crop))]}
t0 = time.time()
T_sm, meta_sm = RP.register_synthmorph(ct_crop, t1c, model="rigid",
gpu=int(gpu), extent=256)
ssm = BR.evaluate(ct, t1c, tv, T_sm) # full CT, as in the benchmark
R_sm, t_sm = _to_affine(T_sm)
c_t1c = head_centroid_world(t1c)
out["rigid_recentered"] = {
"seconds": round(time.time() - t0, 1),
"auto_inverted": meta_sm["auto_inverted"],
"mi": ssm["mi_ct_mr"], "impact_loss": ssm["impact_loss"],
"gap": ssm["skull_ring_gap_mm"],
"rot_err_vs_ref_deg": round(_angle_between_rotations(R, R_sm), 2),
"head_centre_err_mm": round(
float(np.linalg.norm((R_sm @ c_t1c + t_sm) - (R @ c_t1c + t))), 1),
}
return out
# --------------------------------------------------------------------------- #
# main
# ---------------------------------------------------------------------------
def parse_args():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--parts", default="ABCD",
help="which parts to run (letters A/B/C/D/F; F = re-centred "
"CT probe, needs the partABD.json reference)")
ap.add_argument("--n-cases", type=int, default=10)
ap.add_argument("--keys", default=None, help="comma-separated case keys")
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--gpu-base", type=int, default=0)
ap.add_argument("--n-gpus", type=int, default=3)
ap.add_argument("--out-dir", default=os.path.join(ROOT, "results",
"synthmorph_investigation"))
ap.add_argument("--keep-scratch", action="store_true")
return ap.parse_args()
def main():
args = parse_args()
os.makedirs(args.out_dir, exist_ok=True)
labeled, _ = M6.discover()
pool = list(labeled)
if args.keys:
bykey = {r["key"]: r for r in pool}
missing = [k for k in args.keys.split(",") if k not in bykey]
if missing:
raise SystemExit(f"keys not found: {missing}")
cases = [bykey[k.strip()] for k in args.keys.split(",")]
else:
cases = pool[:args.n_cases]
print(f"[investigate] {len(cases)} cases, parts={args.parts}")
rows, joint_rows, recenter_rows = [], [], []
do_cases = bool(set(args.parts) & {"A", "B", "D"})
do_joint = "C" in args.parts
do_recenter = "F" in args.parts
ref_map = {}
if do_recenter:
fp0 = os.path.join(args.out_dir, "partABD.json")
if os.path.exists(fp0):
with open(fp0) as f:
ref_map = {r["key"]: r["reference"]
for r in json.load(f) if "reference" in r}
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {}
if do_cases:
for c in cases:
futs[ex.submit(_case_task, c, args)] = ("case", c)
if do_joint:
for c in cases:
if c["key"] in JOINT_KEYS:
futs[ex.submit(_joint_task, c, args)] = ("joint", c)
if do_recenter:
for c in cases:
futs[ex.submit(_recenter_task, c, args, ref_map)] = ("recenter", c)
for fu in as_completed(futs):
kind, c = futs[fu]
try:
r = fu.result()
if kind == "joint":
joint_rows.append(r)
print(f" [joint] {c['key']}: mi={r.get('mi')} "
f"il={r.get('impact_loss')} {r.get('seconds')}s "
f"err={(r.get('error') or '')[:100]}", flush=True)
elif kind == "recenter":
recenter_rows.append(r)
rr = r.get("rigid_recentered", {})
print(f" [recenter] {c['key']}: mi={rr.get('mi')} "
f"ctr_err={rr.get('head_centre_err_mm')} "
f"rot_err={rr.get('rot_err_vs_ref_deg')} "
f"off_before={r.get('before', {}).get('head_off_volume_centre_mm')} "
f"off_after={r.get('crop', {}).get('head_off_volume_centre_mm')} "
f"note={(r.get('note') or r.get('error') or '')[:60]}", flush=True)
else:
rows.append(r)
print(f" [case] {c['key']}: ref.mi={r.get('reference', {}).get('mi')} "
f"rigid.mi={r.get('rigid', {}).get('mi')} "
f"affine.mi={r.get('affine', {}).get('mi')} "
f"note={r.get('note', r.get('error', ''))[:60]}", flush=True)
except Exception as e: # noqa
print(f" {c['key']}: ERROR {e!r}", flush=True)
(joint_rows if kind == "joint" else
recenter_rows if kind == "recenter" else rows).append(
{"key": c["key"], "error": repr(e)})
rows.sort(key=lambda x: x["key"])
joint_rows.sort(key=lambda x: x["key"])
recenter_rows.sort(key=lambda x: x["key"])
if do_cases:
fp = os.path.join(args.out_dir, "partABD.json")
merged = {r["key"]: r for r in rows}
if os.path.exists(fp):
with open(fp) as f:
for r in json.load(f):
# merge missing sections (a partial run must not wipe
# data from a fuller earlier run)
if r["key"] in merged:
for k, v in r.items():
merged[r["key"]].setdefault(k, v)
else:
merged[r["key"]] = r
with open(fp, "w") as f:
json.dump(sorted(merged.values(), key=lambda x: x["key"]),
f, indent=1)
print("wrote", fp)
if do_joint:
fp = os.path.join(args.out_dir, "partC_joint.json")
with open(fp, "w") as f:
json.dump(joint_rows, f, indent=1)
print("wrote", fp)
if do_recenter:
fp = os.path.join(args.out_dir, "partF_recenter.json")
with open(fp, "w") as f:
json.dump(recenter_rows, f, indent=1)
print("wrote", fp)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,101 @@
"""MR->MR control for the synthmorph investigation.
The benchmark pairs are CT->T1c (out-of-domain for the MRI-trained
SynthMorph model). To separate the cross-modality effect from the other
suspects (extent truncation of the CT FOV, min-max normalization), this
registers two T1c scans of the SAME subject (m6_24V62QQM, 2023-02-23 and
2023-10-17 -- in-domain, MRI->MRI, same tightly-cropped geometry as the
benchmark's fixed image) and scores the result with the benchmark scoring
(MI over the head, ImpactLoss, skull-ring gap) plus the transform
decomposition against a SimpleITK reference.
Usage:
python scripts/investigate_synthmorph_mm.py [--out results/synthmorph_investigation]
"""
import argparse
import json
import os
import sys
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import numpy as np
import SimpleITK as sitk
from src.common import read_nii_img, head_mask_from_image
from src import registration_pipelines as RP
from scripts.investigate_synthmorph import (
_to_affine, _angle_between_rotations, head_centroid_world, BR, M6)
KEY_A = "m6_24V62QQM_20230223"
KEY_B = "m6_24V62QQM_20231017"
def score_pair(img_moving, img_fixed, T):
"""Benchmark scoring of the warped moving (in the fixed grid) ~ fixed."""
ha = sitk.GetArrayFromImage(img_fixed).astype("float32")
wc = sitk.GetArrayFromImage(sitk.Resample(img_moving, img_fixed, T,
sitk.sitkLinear)).astype("float32")
gap = M6._skull_ring_gap(wc, ha)
mi = BR._mi_masked(wc, ha, head_mask_from_image(ha))
il = BR._impact_loss_value(wc, ha)
return {"mi": mi, "impact_loss": il, "gap": gap}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=os.path.join(ROOT, "results",
"synthmorph_investigation"))
ap.add_argument("--gpu", type=int, default=0)
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
labeled, _ = M6.discover()
bykey = {r["key"]: r for r in labeled}
a, b = bykey[KEY_A], bykey[KEY_B]
img_a = read_nii_img(a["img"])
img_b = read_nii_img(b["img"])
out = {"pair": [KEY_A, KEY_B], "note": ("same-subject T1c->T1c, 8 months "
"apart; in-domain control for the "
"CT->T1c benchmark pairs")}
import math
c_fix = head_centroid_world(img_b)
T_ref, meta_ref = RP.register_simpleitk(img_a, img_b, threads=16,
mode="benchmark")
sref = score_pair(img_a, img_b, T_ref)
R_ref, t_ref = _to_affine(T_ref)
th_ref = math.degrees(float(np.clip((np.trace(R_ref) - 1) / 2, -1, 1)))
out["reference"] = {
"tool": meta_ref["toolkit"], "R": R_ref.tolist(), "t": t_ref.tolist(),
"mi": sref["mi"], "impact_loss": sref["impact_loss"], "gap": sref["gap"],
"rotation_deg": round(th_ref, 2),
"head_centre_fix": [round(float(x), 1)
for x in (R_ref @ c_fix + t_ref)],
}
t0 = time.time()
T_sm, meta_sm = RP.register_synthmorph(img_a, img_b, model="rigid",
gpu=args.gpu, extent=256)
ssm = score_pair(img_a, img_b, T_sm)
R_sm, t_sm = _to_affine(T_sm)
out["synthmorph_rigid"] = {
"seconds": round(time.time() - t0, 1),
"auto_inverted": meta_sm["auto_inverted"],
"mi": ssm["mi"], "impact_loss": ssm["impact_loss"], "gap": ssm["gap"],
"rot_err_vs_ref_deg": round(_angle_between_rotations(R_ref, R_sm), 2),
"head_centre_err_mm": round(
float(np.linalg.norm((R_sm @ c_fix + t_sm) - (R_ref @ c_fix + t_ref))), 1),
}
# no-clip sanity: MR inputs are never clipped by the adapter
fp = os.path.join(args.out, "partE_mr_mr.json")
with open(fp, "w") as f:
json.dump(out, f, indent=1)
print(json.dumps(out, indent=1))
print("wrote", fp)
if __name__ == "__main__":
main()

View file

@ -32,7 +32,7 @@ Shared rules for every pipeline:
are the same class (both CT, or a clipped CT that no longer reads as CT)
the T1c-slot (second) argument is kept as fixed; see :func:`fixed_moving`.
- **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_RIGID.md), ``niftyreg`` and
``synthmorph`` clip the CT (the moving image) to [0, 80] HU before
registration (``clip_ct`` parameter, default on, MR inputs never clipped);
the Mattes-MI pipelines (antspyx / elastix / simpleitk) register the CT
@ -262,10 +262,7 @@ def register_simpleitk(ct, t1c, threads=12, mode="benchmark", iters_full=2000):
return tr, meta
m6 = _load_m6()
try:
sitk.CommonProperties.SetGlobalDefaultNumberOfThreads(threads)
except AttributeError:
pass
_sitk_set_threads(threads)
ct = sitk.Cast(ct, sitk.sitkFloat32); t1c = sitk.Cast(t1c, sitk.sitkFloat32)
# full volumes, no mask / crop / pad; the T1c is the fixed image
fixed, moving = fixed_moving(ct, t1c)
@ -1221,6 +1218,851 @@ REGISTERS = {
ALL_PIPELINES = list(REGISTERS)
# --------------------------------------------------------------------------- #
# Deformable variants (see REGISTRATION_BENCHMARK_DEFORMABLE.md)
# --------------------------------------------------------------------------- #
#
# Same unified contract as the rigid pipelines: the returned transform ``T``
# satisfies ``sitk.Resample(ct, t1c, T, ...)`` = CT on the T1c grid, i.e. it
# maps **T1c -> CT** coordinates. Every deformable pipeline runs its toolkit's
# native deformable workflow (linear init stage + deformable stage where that
# is the toolkit's canonical setup) and returns the result rasterised as a
# DisplacementFieldTransform on the T1c grid -- the one transform type all the
# deformable outputs share (elastix BSplines, ANTs warp+affine, NiftyReg
# affine+spline, SynthMorph fields).
def _sitk_set_threads(n):
"""Best-effort global SimpleITK/ITK work-unit count (the attribute name
varies between builds: CommonProperties vs the procedural API)."""
try:
sitk.CommonProperties.SetGlobalDefaultNumberOfThreads(n)
except AttributeError:
try:
sitk.ProcessObject_SetGlobalDefaultNumberOfThreads(n)
except AttributeError:
pass
def _field_transform(arr, ref_img):
"""SimpleITK DisplacementFieldTransform from a displacement array
(world-mm) on the grid of ``ref_img``. Accepts (z,y,x,3) or (3,z,y,x)."""
a = np.asarray(arr, dtype="float64")
if a.ndim == 4 and a.shape[0] == 3 and a.shape[-1] != 3:
a = np.ascontiguousarray(np.transpose(a, (1, 2, 3, 0)))
Fv = sitk.GetImageFromArray(a, isVector=True)
Fv.SetSpacing(ref_img.GetSpacing())
Fv.SetOrigin(ref_img.GetOrigin())
Fv.SetDirection(ref_img.GetDirection())
return sitk.DisplacementFieldTransform(Fv)
def _flip_naive_world_components(arr):
"""Convert displacement deltas from the naive NIfTI world to true RAS.
NiftyReg and surfa (the FreeSurfer NIfTI stack) decode a RAS+ NIfTI qform
with the plain quaternion formula, so they work in an x/y-mirrored world.
A delta (dx, dy, dz) they store is therefore (-dx, -dy, dz) in the true
RAS world; the field grid (file indices) is unaffected. Our inputs are
always written RAS+ (read_nii_img DICOMOrients), so the flip applies.
Verified against each tool's own resampling on the M6 cases (NCC ~ 1.0).
"""
a = np.array(arr, dtype="float64", copy=True)
a[..., 0] = -a[..., 0]
a[..., 1] = -a[..., 1]
return a
def _rasterize_to_tdf(T, ref_img):
"""Rasterize any SimpleITK transform (affine / bspline / composite) to a
DisplacementFieldTransform on the grid of ``ref_img``."""
f = sitk.TransformToDisplacementField(
T, sitk.sitkVectorFloat64, ref_img.GetSize(), ref_img.GetOrigin(),
ref_img.GetSpacing(), ref_img.GetDirection())
return sitk.DisplacementFieldTransform(f)
def _itk_rasterize_to_tdf(itk_tf, ref_img):
"""Rasterize an ITK-python transform (elastix combination transform) to a
SimpleITK DisplacementFieldTransform on the grid of ``ref_img``."""
import itk
from itk.itkImagePython import itkImageVF33
flt = itk.TransformToDisplacementFieldFilter[itkImageVF33, itk.D].New()
flt.SetTransform(itk_tf)
flt.SetSize(list(ref_img.GetSize()))
flt.SetOutputOrigin([float(x) for x in ref_img.GetOrigin()])
flt.SetOutputSpacing([float(x) for x in ref_img.GetSpacing()])
flt.Update()
arr = itk.GetArrayFromImage(flt.GetOutput()) # (z, y, x, 3), world mm
return _field_transform(arr, ref_img)
def _bspline_txt_to_tdf(txt_path, ref_img):
"""Evaluate an elastix BSpline/RecursiveBSpline transform .itk.txt on the
grid of ``ref_img`` and return a DisplacementFieldTransform.
SimpleITK's transform reader does not parse the elastix BSpline file
format, so the cubic B-spline (control-point displacements on a regular
grid) is evaluated directly with scipy's order-3 B-spline interpolation
(the same kernel as ITK's BSpline evaluation).
File format (ITK V1.0):
Transform: BSplineTransform_double_3_3
Parameters: <mx*my*mz*3 values>
FixedParameters: <mx my mz> <origin 3> <spacing 3> <direction 9>
Parameter layout (verified by probing): component-major blocks
[all x-offsets, all y-offsets, all z-offsets], x-fastest within a block
(slot = ix + mx*(iy + my*iz))."""
text = open(txt_path).read()
if "BSplineTransform" not in text:
raise RuntimeError(
f"_bspline_txt_to_tdf: {txt_path} is not a BSpline transform "
f"({text.splitlines()[2] if len(text.splitlines()) > 2 else '?'})")
body = text.split("Parameters:", 1)[1].split("FixedParameters:", 1)[0]
vals = [float(x) for x in body.split()]
fp = [float(x) for x in text.split("FixedParameters:", 1)[1].split()]
mx, my, mz = int(fp[0]), int(fp[1]), int(fp[2])
g_origin = np.array(fp[3:6]); g_sp = np.array(fp[6:9])
g_dir = np.array(fp[9:18]).reshape(3, 3)
n_pts = mx * my * mz
if len(vals) != 3 * n_pts:
raise RuntimeError(
f"_bspline_txt_to_tdf: expected {3 * n_pts} parameters in {txt_path}, "
f"got {len(vals)}")
blocks = [np.reshape(vals[i * n_pts:(i + 1) * n_pts], (mx, my, mz), order="F")
for i in range(3)] # each (mx, my, mz), x fastest
o = np.array(ref_img.GetOrigin())
d = np.array(ref_img.GetDirection()).reshape(3, 3)
s = np.array(ref_img.GetSpacing())
zz, yy, xx = np.mgrid[0:ref_img.GetSize()[2], 0:ref_img.GetSize()[1],
0:ref_img.GetSize()[0]]
p = np.stack([o[0] + xx * s[0], o[1] + yy * s[1], o[2] + zz * s[2]],
axis=-1) # (z, y, x, 3) world RAS
u = (p - g_origin) @ d.T @ g_dir.T / g_sp # (z, y, x, 3) grid coords
from scipy.ndimage import map_coordinates
disp = np.stack(
[map_coordinates(b, [u[..., 2], u[..., 1], u[..., 0]], order=3,
mode="nearest") for b in blocks], axis=-1)
return _field_transform(disp, ref_img)
def register_antspyx_deformable(ct, t1c):
"""ANTs SyN -- antspyx's native deformable (type_of_transform='SyN'):
an Affine stage (Mattes MI, convergence 2100x1200x1200x0, shrink 4x2x2x1)
followed by a symmetric-Normalization deformable stage (Mattes MI,
SyN[0.2, 3, 0], convergence 40x20x0, shrink 4x2x1), all on the full
volumes with the native center-of-mass init. No CT clip (the rigid
A/B showed clipping hurts the Mattes-MI pipelines). The two stage
outputs (001Warp.nii.gz + 000GenericAffine.mat) are composed -- warp
first, then affine -- and verified against ANTs' own warpedmovout,
then rasterised to the T1c-grid displacement field of the contract."""
import ants
fx, mv = fixed_moving(ct, t1c)
tmp = tempfile.mkdtemp(prefix="ants_syn_")
dt = 0.0
try:
import time
t0 = time.time()
r = ants.registration(
fixed=ants.from_sitk(sitk.Cast(fx, sitk.sitkFloat32)),
moving=ants.from_sitk(sitk.Cast(mv, sitk.sitkFloat32)),
type_of_transform="SyN", outprefix=tmp + "/")
dt = time.time() - t0
aff = warp = None
for f in r["fwdtransforms"]:
base = os.path.basename(f)
if base.endswith(".mat"):
aff = sitk.ReadTransform(f)
elif base.endswith("Warp.nii.gz") and "Inverse" not in base:
w = sitk.ReadImage(f)
arr = sitk.GetArrayFromImage(w) # (z,y,x,3) vector, world mm
if arr.ndim == 4 and arr.shape[0] == 3:
arr = np.transpose(arr, (1, 2, 3, 0))
warp = _field_transform(arr, fx)
if aff is None or warp is None:
raise RuntimeError(
f"antspyx SyN: expected an affine .mat + a Warp.nii.gz stage "
f"file, got {[os.path.basename(f) for f in r['fwdtransforms']]}")
# composite: the SyN stage warp is applied first, then the affine
# (verified to reproduce ANTs' own warpedmovout to <0.001 MI)
T = _rasterize_to_tdf(sitk.CompositeTransform([aff, warp]), fx)
finally:
shutil.rmtree(tmp, ignore_errors=True)
ha = sitk.GetArrayFromImage(t1c)
gap = _skull_gap(_warp_ct(ct, t1c, T), ha)
meta = {
"pipeline": "antspyx",
"toolkit": f"ANTsPy {getattr(ants, '__version__', '?')}",
"metric": "ANTs Mattes MI (affine + SyN stages, full volumes)",
"transform": ("Deformable (native SyN: Affine[0.25] 2100x1200x1200x0 "
"+ SyN[0.2,3,0] 40x20x0, auto COM init)"),
"convention": "T1c->CT (verified vs ANTs warpedmovout)", "auto_inverted": False,
"fixed": _modality_name(fx), "moving": _modality_name(mv),
"seconds_reg": round(dt, 1), "skull_ring_gap_mm": gap,
}
return T, meta
def register_elastix_deformable(ct, t1c, workdir=None, threads=8):
"""ITK-Elastix two-stage deformable (the toolkit's canonical deformable
workflow): stage 1 is the rigid benchmark's EulerTransform map (Mattes MI,
ASGD, 2000 iters x 3 resolutions, CenterOfGravity auto init); stage 2 is a
BSplineTransform (16 mm grid, spline order 3) initialized from stage 1 via
-t0 with the same Mattes-MI budget. Runs in-process through the
ITK-Elastix python API; the combination transform is rasterised to the
T1c-grid displacement field of the contract."""
itk = _elastix_api()
fixed, moving = fixed_moving(ct, t1c)
outdir = os.path.join(workdir or d("data/m6reg/elastix"), "itkelastix_def")
os.makedirs(outdir, exist_ok=True)
base = {
"ImageDimension": 3,
"FixedInternalImagePixelType": "float",
"MovingInternalImagePixelType": "float",
"Metric": "AdvancedMattesMutualInformation",
"NumberOfHistogramBins": 32,
"ImageSampler": "Random",
"ImageSamplerPatchSize": [25, 25, 25],
"Optimizer": "AdaptiveStochasticGradientDescent",
"MaximumNumberOfIterations": [2000, 2000, 2000],
"WriteResultImage": False,
}
stage1 = dict(
base, Transform="EulerTransform", TransformBase="ElastixTransformBase",
FixedCenteredTransform=True, HowToCombineTransforms="Compose",
AutomaticTransformInitialization=True,
AutomaticTransformInitializationMethod="CenterOfGravity",
AutomaticScalesEstimation=True)
stage2 = dict(
base, Transform="BSplineTransform", TransformBase="ElastixTransformBase",
HowToCombineTransforms="Compose", SplineOrder=3, GridSpacing=16,
AutomaticScalesEstimation=True)
reg_cls = itk.ElastixRegistrationMethod[itk.Image[itk.F, 3],
itk.Image[itk.F, 3]]
def _run(params, t0=None):
reg = reg_cls.New()
reg.SetFixedImage(_sitk_to_itk(fixed, itk))
reg.SetMovingImage(_sitk_to_itk(moving, itk))
reg.SetParameterObject(itk.dict_to_parameter_object(params))
if t0:
reg.SetInitialTransformParameterFileName(t0)
reg.SetOutputDirectory(outdir)
reg.SetNumberOfThreads(threads)
reg.LogToConsoleOff()
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
reg.Update()
except Exception as e: # noqa
tail = "\n".join(buf.getvalue().splitlines()[-10:])
raise RuntimeError(f"elastix deformable stage failed: {e}\n{tail}") from e
return reg
import time, io, contextlib
t0 = time.time()
_run(stage1)
ct_comb = _run(stage2, t0=os.path.join(outdir, "TransformParameters.0.txt")) \
.GetCombinationTransform()
dt = time.time() - t0
T = _itk_rasterize_to_tdf(ct_comb, fixed)
try:
import importlib.metadata
version = f"ITK-Elastix {importlib.metadata.version('itk-elastix')}"
except Exception: # noqa
version = "ITK-Elastix (python API)"
ha = sitk.GetArrayFromImage(t1c)
gap = _skull_gap(_warp_ct(ct, t1c, T), ha)
meta = {
"pipeline": "elastix", "toolkit": version,
"metric": "MattesMutualInformation (both stages, full volumes)",
"transform": ("Deformable (EulerTransform 2000x3 + BSplineTransform "
"16 mm grid, 2000 iters x 3 resolutions, -t0 init)"),
"convention": "T1c->CT (combination transform)", "auto_inverted": False,
"fixed": _modality_name(fixed), "moving": _modality_name(moving),
"seconds_reg": round(dt, 1), "skull_ring_gap_mm": gap,
}
return T, meta
def _impact_param_map_deformable(gpu, iters, samples, voxel_sizes):
"""The ImpactReg ``MR_CT_TS`` reference parameter map in its native
DEFORMABLE form: the MIND + TotalSegmentator static feature combination
(same per-level models / scales / layer weights as the rigid benchmark)
driven by the reference's own RecursiveBSplineTransform (final grid
14 mm, spline order 3) with the reference's three-metric combination --
IMPACT (w 1.0) + Mattes MI (w 0.4) + TransformBendingEnergyPenalty
(w 10). No image-resampling pyramid (all-1 schedules; the scale comes
from the per-level ImpactVoxelSize). Two adaptations for the M6
head-crop / head-neck-FOV mismatch (same as the rigid benchmark):
CenterOfGravity automatic transform initialization (the reference's
GeometricalCenter start leaves the heads ~100 mm apart and aborts) and
RequiredRatioOfValidSamples 0.1 (vs the 0.25 build default) as a
divergence gate.
"""
n = len(voxel_sizes)
if isinstance(iters, (int,)):
iters_l = str(iters)
else:
iters_l = " ".join(f"{i:g}" for i in iters)
mixed = "true" if gpu >= 0 else "false"
L = ["// IMPACT semantic-similarity metric (vboussot/ImpactLoss),"]
L.append("// MR_CT_TS reference map (deformable: RecursiveBSplineTransform)")
L.append('(Metric "Impact" "AdvancedMattesMutualInformation" "TransformBendingEnergyPenalty")')
L.append("(Metric0Weight 1)")
L.append(f"(Metric1Weight {IMPACT_MI_WEIGHT})")
L.append("(Metric2Weight 10)")
L.append('(UseRelativeWeights "false")')
for i, (ts_rel, _vs, mind_mask, ts_mask, mind_w, ts_w) in enumerate(IMPACT_LEVELS):
vs = voxel_sizes[i]
L += [
f'(ImpactModelsPath{i} "/Data/Models/{IMPACT_MIND_MODEL}" '
f'"/Data/Models/{ts_rel}")',
f"(ImpactDimension{i} 3 3)",
f"(ImpactNumberOfChannels{i} 1 1)",
f"(ImpactPatchSize{i} 0 0 0 0 0 0)",
f"(ImpactVoxelSize{i} {vs:g} {vs:g} {vs:g} {vs:g} {vs:g} {vs:g})",
f'(ImpactLayersMask{i} "{mind_mask}" "{ts_mask}")',
f"(ImpactSubsetFeatures{i} 32 64)",
f"(ImpactPCA{i} 0 0)",
f'(ImpactDistance{i} "L1" "Dice")',
f"(ImpactLayersWeight{i} {mind_w} {ts_w})",
]
L += [
'(ImpactMode "Static")',
f"(ImpactGPU {gpu})",
f'(ImpactUseMixedPrecision "{mixed}")',
"(ImpactFeaturesMapUpdateInterval -1)",
'(ImpactWriteFeatureMaps "false")',
'(CheckNumberOfSamples "true")',
"(RequiredRatioOfValidSamples 0.1)",
"",
"// deformable RecursiveBSpline (the reference map's native transform)",
'(Transform "RecursiveBSplineTransform")',
'(TransformBase "ElastixTransformBase")',
'(HowToCombineTransforms "Compose")',
'(BSplineTransformSplineOrder 3)',
'(FinalGridSpacingInPhysicalUnits 14)',
'(UseCyclicTransform "false")',
# adaptation: align the intensity centroids (the reference has
# AutomaticTransformInitialization "false" + GeometricalCenter, which
# leaves the heads ~100 mm apart for the head-crop / head-neck FOV
# mismatch and aborts)
'(AutomaticTransformInitialization "true")',
'(AutomaticTransformInitializationMethod "CenterOfGravity")',
'(AutomaticScalesEstimation "true")',
"",
"(ImageDimension 3)",
'(FixedInternalImagePixelType "float")',
'(MovingInternalImagePixelType "float")',
'(UseDirectionCosines "true")',
"",
'(Registration "MultiMetricMultiResolutionRegistration")',
f"(NumberOfResolutions {n})",
f"(MaximumNumberOfIterations {iters_l})",
f"(NumberOfSpatialSamples {samples})",
"",
'(FixedImagePyramid "FixedGenericImagePyramid")',
'(MovingImagePyramid "MovingGenericImagePyramid")',
f"(FixedImagePyramidRescaleSchedule {' '.join('1' for _ in range(3 * n))})",
f"(MovingImagePyramidRescaleSchedule {' '.join('1' for _ in range(3 * n))})",
"",
'(Optimizer "AdaptiveStochasticGradientDescent")',
"(MaximumNumberOfSamplingAttempts 8)",
'(UseAdaptiveStepSizes "true")',
'(UseMultiThreadingForMetrics "true")',
'(ASGDParameterEstimationMethod "DisplacementDistribution")',
"(SigmoidInitialTime 0.0)",
'(NoiseCompensation "true")',
"(NumberOfSamplesForExactGradient 4096)",
'(AutomaticParameterEstimation "true")',
"",
'(ImageSampler "RandomCoordinate")',
'(NewSamplesEveryIteration "true")',
'(Interpolator "BSplineInterpolator")',
"(BSplineInterpolationOrder 3)",
'(UseRandomSampleRegion "false")',
"",
'(WriteResultImage "false")',
"(DefaultPixelValue -1024)",
'(ResultImageFormat "mha")',
'(ITKTransformOutputFileNameExtension "itk.txt")',
'(WriteITKCompositeTransform "true")',
'(UseBinaryFormatForTransformationParameters "false")',
'(WriteTransformParametersEachIteration "false")',
'(WriteTransformParametersEachResolution "false")',
]
return "\n".join(L) + "\n"
def register_impact_deformable(ct, t1c, gpu=None, iters=IMPACT_DEFAULT_ITERS,
samples=IMPACT_DEFAULT_SAMPLES, threads=12,
keep_scratch=False):
"""IMPACT deformable CT->T1c (fixed=T1c, moving=CT) in the Docker image
``vboussot/elastix_impact``: the ImpactReg ``MR_CT_TS`` reference parameter
map in its native deformable form (see
:func:`_impact_param_map_deformable` for the exact config and the two M6
adaptations). The result -- a RecursiveBSplineTransform with the
CenterOfGravity init folded in -- is evaluated on the T1c grid
(:func:`_bspline_txt_to_tdf`) to give the contract displacement field."""
import time
ok, note = _impact_docker_ok()
if not ok:
raise PipelineUnavailable(f"impact: {note}")
model_rels = _impact_models_host()
fixed, moving = fixed_moving(ct, t1c)
if gpu is None:
ngpu = _n_gpus()
gpu = (os.getpid() % ngpu) if ngpu else -1
base = float(np.median(moving.GetSpacing()))
n = len(IMPACT_LEVELS)
voxel_sizes = [base * scale for _, scale, *_ in IMPACT_LEVELS]
tmp = tempfile.mkdtemp(prefix="impact_reg_def_")
datadir = os.path.join(tmp, "Data")
outdir = os.path.join(tmp, "Out")
os.makedirs(datadir, exist_ok=True)
os.makedirs(outdir, exist_ok=True)
try:
sitk.WriteImage(sitk.Cast(fixed, sitk.sitkFloat32),
os.path.join(datadir, "Fixed_image.mha"), True)
sitk.WriteImage(sitk.Cast(moving, sitk.sitkFloat32),
os.path.join(datadir, "Moving_image.mha"), True)
param_text = _impact_param_map_deformable(gpu, iters, samples, voxel_sizes)
with open(os.path.join(datadir, "ParameterMap.txt"), "w") as f:
f.write(param_text)
dump_dir = d("data/m6reg/impact/paramdump")
os.makedirs(dump_dir, exist_ok=True)
param_dump = os.path.join(dump_dir, f"ParameterMap_MIND+TS_DEFORMABLE_{os.getpid()}.txt")
with open(param_dump, "w") as f:
f.write(param_text)
cmd = ["docker", "run", "--rm"]
if gpu >= 0:
cmd += ["--gpus", "all"]
cmd += [
"-v", f"{datadir}:/Data",
"-v", f"{outdir}:/Out",
"-v", f"{d(IMPACT_MODEL_CACHE)}:/Data/Models",
IMPACT_IMAGE, IMPACT_ELASTIX_BIN,
"-f", "/Data/Fixed_image.mha", "-m", "/Data/Moving_image.mha",
"-p", "/Data/ParameterMap.txt",
"-out", "/Out/", "-threads", str(threads),
]
t0 = time.time()
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=14400)
dt = time.time() - t0
if proc.returncode != 0:
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:])
raise RuntimeError(f"impact deformable: docker elastix failed (rc={proc.returncode}):\n{tail}")
itk_tf = os.path.join(outdir, "TransformParameters.0.itk.txt")
if not os.path.exists(itk_tf):
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:])
raise RuntimeError(f"impact deformable: no TransformParameters.0.itk.txt produced:\n{tail}")
T = _bspline_txt_to_tdf(itk_tf, fixed)
finally:
if not keep_scratch:
shutil.rmtree(tmp, ignore_errors=True)
models = " + ".join(model_rels)
meta = {
"pipeline": "impact",
"toolkit": f"Docker {IMPACT_IMAGE} (Elastix 5.2.0 + IMPACT)",
"metric": (f"IMPACT semantic ({models}, L1+Dice, Static) + "
f"Mattes MI (w {IMPACT_MI_WEIGHT}) + bending energy (w 10)"),
"transform": (f"Deformable (RecursiveBSpline 14 mm final grid, ASGD, "
f"{n} scales, iters {list(iters) if not isinstance(iters, int) else iters}, "
f"auto center init; "
f"device={'GPU' + str(gpu) if gpu >= 0 else 'CPU'})"),
"convention": "T1c->CT (combination transform)", "auto_inverted": False,
"fixed": _modality_name(fixed), "moving": _modality_name(moving),
"seconds_reg": round(dt, 1),
"docker_image": IMPACT_IMAGE, "model": models, "gpu": gpu,
"iters": list(iters) if not isinstance(iters, int) else iters,
"samples": samples, "param_dump": param_dump,
}
if keep_scratch:
meta["scratch"] = tmp
return T, meta
def register_niftyreg_deformable(ct, t1c, threads=24, levels=3, maxit=150,
spline_spacing=8.0, keep_scratch=False,
backend="auto", gpu=None, clip_ct=(0.0, 80.0)):
"""NiftyReg deformable CT->T1c (reference=T1c, floating=CT): the toolkit's
canonical non-rigid workflow -- reg_aladin rigid init (same budget as the
rigid benchmark: 3-level pyramid, 15 TLS iters/level) followed by reg_f3d
(F3D B-spline Free-Form deformation, Modat et al. 2010: NMI metric,
symmetric bending/linear regularisation, 3-level pyramid, 150 iters at
the final level, 8 mm final grid spacing). The CT is clipped to
[0, 80] HU by default (per the rigid A/B; ``clip_ct=None`` disables).
Read-back: reg_f3d writes only the control-point grid (outputCPP.nii);
reg_transform -disp evaluates it to a dense displacement field on the
reference grid (the reg_aladin affine is folded into the control points
before optimization, so the dense field is the full transform).
NiftyReg reads RAS+ NIfTIs in its naive (x/y-mirrored) world, so the
stored deltas carry flipped x/y components --
:func:`_flip_naive_world_components` restores the true RAS field
(verified to reproduce reg_f3d's own resampling to NCC ~ 1.0). The
result is the contract displacement field on the T1c grid.
"""
import time
if backend not in ("auto", "conda", "docker"):
raise PipelineUnavailable(
f"niftyreg: unknown backend {backend!r} (use auto|conda|docker)")
dok, dnote = _niftyreg_docker_ok()
use_docker = backend == "docker" or (backend == "auto" and dok)
if use_docker:
if not dok:
raise PipelineUnavailable(f"niftyreg: {dnote}")
binpath = None
if gpu is None:
ngpu = _n_gpus()
gpu = (os.getpid() % ngpu) if ngpu else -1
use_gpu = gpu >= 0
platf = "1" if use_gpu else "0"
toolkit = (f"NiftyReg 2.1.1 (reg_aladin + reg_f3d, Docker "
f"{NIFTYREG_DOCKER_IMAGE}, CUDA)" if use_gpu
else f"NiftyReg 2.1.1 (reg_aladin + reg_f3d, Docker {NIFTYREG_DOCKER_IMAGE})")
else:
binpath = _niftyreg_bin()
gpu, use_gpu, platf = -1, False, None
toolkit = _niftyreg_version(binpath) + " (reg_f3d)"
fixed, moving = fixed_moving(ct, t1c)
fixed_name = _modality_name(fixed)
moving_name = _modality_name(moving)
fixed, moving, ct_clipped = _maybe_clip_ct_pair(fixed, moving, clip_ct)
tmp = tempfile.mkdtemp(prefix="niftyreg_f3d_")
dt = 0.0
try:
sitk.WriteImage(sitk.Cast(fixed, sitk.sitkFloat32),
os.path.join(tmp, "ref.nii.gz"), True)
sitk.WriteImage(sitk.Cast(moving, sitk.sitkFloat32),
os.path.join(tmp, "flo.nii.gz"), True)
def _nr(bin_name, flags):
if use_docker:
cmd = ["docker", "run", "--rm",
"--entrypoint", f"/niftyreg/{bin_name}"]
if use_gpu:
cmd += ["--gpus", "all"]
cmd += ["-v", f"{tmp}:/work", "-w", "/work",
NIFTYREG_DOCKER_IMAGE] + flags
cwd = None
else:
cmd = [os.path.join(os.path.dirname(binpath), bin_name)] + flags
cwd = tmp
t0 = time.time()
proc = subprocess.run(cmd, capture_output=True, text=True,
cwd=cwd, timeout=14400)
d = time.time() - t0
if proc.returncode != 0:
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:])
raise RuntimeError(f"niftyreg {bin_name} failed (rc={proc.returncode}):\n{tail}")
return d
aladin_flags = ["-ref", "ref.nii.gz", "-flo", "flo.nii.gz",
"-rigOnly", "-aff", "aff.txt",
"-ln", str(levels), "-maxit", "15",
"-omp", str(threads)]
if platf is not None:
aladin_flags += ["-platf", platf]
if use_gpu:
aladin_flags += ["-gpuid", str(gpu)]
aladin_flags += ["-voff"]
t0 = time.time()
_nr("reg_aladin", aladin_flags)
f3d_flags = ["-ref", "ref.nii.gz", "-flo", "flo.nii.gz",
"-aff", "aff.txt",
"-sx", str(spline_spacing), "-maxit", str(maxit),
"-ln", str(levels), "-omp", str(threads)]
if platf is not None:
f3d_flags += ["-platf", platf]
if use_gpu:
f3d_flags += ["-gpuid", str(gpu)]
f3d_flags += ["-voff"]
_nr("reg_f3d", f3d_flags)
_nr("reg_transform",
["-ref", "ref.nii.gz", "-disp", "outputCPP.nii", "spl.def"])
dt = time.time() - t0
spl = sitk.ReadImage(os.path.join(tmp, "spl.def"))
arr = sitk.GetArrayFromImage(spl)
if arr.ndim == 4 and arr.shape[0] == 3:
arr = np.transpose(arr, (1, 2, 3, 0))
# NiftyReg's naive world flips the x/y delta components relative to
# the true RAS field (see _flip_naive_world_components)
T = _field_transform(_flip_naive_world_components(arr), fixed)
finally:
if not keep_scratch:
shutil.rmtree(tmp, ignore_errors=True)
ha = sitk.GetArrayFromImage(t1c)
gap = _skull_gap(_warp_ct(ct, t1c, T), ha)
meta = {
"pipeline": "niftyreg", "toolkit": toolkit,
"backend": "docker" if use_docker else "conda",
"metric": "reg_aladin (NCC block matching + TLS) -> reg_f3d (NMI, F3D B-spline)",
"transform": (f"Deformable (reg_f3d F3D, {levels}-level pyramid, "
f"{maxit} iters/final level, {spline_spacing:g} mm grid, "
"rigid init 15 TLS iters/level)"),
"convention": "T1c->CT (verified vs reg_f3d own resample)", "auto_inverted": False,
"fixed": fixed_name, "moving": moving_name,
"seconds_reg": round(dt, 1),
"levels": levels, "maxit": maxit, "spline_spacing": spline_spacing,
"gpu": gpu, "skull_ring_gap_mm": gap,
"clip_ct": [float(clip_ct[0]), float(clip_ct[1])] if ct_clipped else None,
}
if keep_scratch:
meta["scratch"] = tmp
return T, meta
def _bspline_representing_affine(fixed_img, A, t, center, grid_spacing_mm):
"""A BSpline transform on the fixed image whose control points reproduce
the affine p -> A (p - center) + center + t exactly (cubic B-splines
reproduce linear maps). Parameter layout (verified against
TransformToDisplacementField): component-major blocks [all x, all y, all
z offsets], x-fastest within a block."""
sp = np.array(fixed_img.GetSpacing())
size = np.array(fixed_img.GetSize())
cells = np.maximum(2, np.round(size * sp / np.array(grid_spacing_mm)).astype(int))
bs = sitk.BSplineTransformInitializer(fixed_img, tuple(int(c) for c in cells), 3)
fp = np.array(bs.GetFixedParameters())
mesh = fp[0:3].astype(int) # control grid incl. 3 boundary cells
g_origin = fp[3:6]; g_sp = fp[6:9]
ix = np.mgrid[0:mesh[0], 0:mesh[1], 0:mesh[2]]
P = np.stack([g_origin[0] + ix[0] * g_sp[0],
g_origin[1] + ix[1] * g_sp[1],
g_origin[2] + ix[2] * g_sp[2]], axis=-1) # (mx,my,mz,3)
off = np.einsum('ij,...j->...i', A, P - center) + center + t - P
bs.SetParameters(np.concatenate([off[..., 0].ravel(order="F"),
off[..., 1].ravel(order="F"),
off[..., 2].ravel(order="F")]).tolist())
return bs
def register_simpleitk_deformable(ct, t1c, threads=12, iters_full=2000,
iters_bspline=50,
grid_spacing=(24.0, 24.0, 24.0)):
"""SimpleITK two-stage deformable (the toolkit's canonical deformable
demo): stage 1 is the rigid benchmark's Euler3D map (Mattes MI 32 bins,
pyramid [4,2,1], iters_full per level, identity start); stage 2 is a
BSplineTransform3D (grid ~``grid_spacing`` mm, ``iters_bspline`` per
level on pyramid [4,2]) whose control points are initialised to
reproduce stage 1 exactly, so the returned transform is the full
composite. It is rasterised to the T1c-grid displacement field of the
contract.
The 24 mm grid (5577 parameters) is the accelerated benchmark setting
(the 2026-10-02 original used 16 mm / 13005 parameters, ~48 min/case):
this build's spline per-iteration cost scales ~linearly with the
control-point count (~2.8 s/iter at the shrink-4 level and ~20 s/iter
at shrink-2 for 24 mm vs ~6.4 / ~45.8 s for 16 mm; independent of the
metric sample count), so 24 mm runs ~2.4x faster in isolation. Quality
(2 cases measured, same machine): the median case loses 0.003 MI
(0.6921 vs 0.6954, ImpactLoss improves 0.1547 vs 0.1552) and the
hardest case 0.017 (0.8008 vs 0.8176), keeping simpleitk's per-case
wins; the faster 32 mm grid (0.7902 / 0.7963 with 100 fine iters)
drops below antspyx's 0.7994 on the hardest case, so it was rejected.
A full-resolution polish pass (10 iters at shrink-1) was also tested
and dropped the achieved MI (0.6891 at 32 mm), so the pyramid stays
[4,2]. LBFGS2 was tested and fails to move the transform on this noisy
MI (line search rejects every step). See
REGISTRATION_BENCHMARK_DEFORMABLE.md."""
m6 = _load_m6()
_sitk_set_threads(threads)
ct = sitk.Cast(ct, sitk.sitkFloat32); t1c = sitk.Cast(t1c, sitk.sitkFloat32)
fixed, moving = fixed_moving(ct, t1c)
shape = np.array(ct.GetSize())
sh = [s for s in [4, 2, 1] if min(shape) / s >= 6] or [1]
sm = [2, 1, 0][:len(sh)]
import time
t0 = time.time()
r1 = sitk.ImageRegistrationMethod()
r1.SetMetricAsMattesMutualInformation(numberOfHistogramBins=32)
r1.SetOptimizerAsRegularStepGradientDescent(
learningRate=10.0, minStep=0.2, numberOfIterations=iters_full,
relaxationFactor=0.5, gradientMagnitudeTolerance=1e-6)
r1.SetOptimizerScalesFromPhysicalShift()
r1.SetShrinkFactorsPerLevel(sh); r1.SetSmoothingSigmasPerLevel(sm)
r1.SetInterpolator(sitk.sitkLinear)
init1 = sitk.Euler3DTransform()
init1.SetCenter(tuple(float(x) for x in fixed.TransformIndexToPhysicalPoint(
((fixed.GetSize()[0] - 1) // 2, (fixed.GetSize()[1] - 1) // 2,
(fixed.GetSize()[2] - 1) // 2))))
init1.SetParameters((0.0, 0.0, 0.0, 0.0, 0.0, 0.0))
r1.SetInitialTransform(init1, inPlace=True)
tr1 = r1.Execute(fixed, moving)
# stage 2: BSpline initialised to reproduce stage 1 exactly (its control
# points start at the stage-1 pose, so the returned transform is the full
# Euler + BSpline composite); inPlace=True keeps the result a single
# transform (inPlace=False would return a CompositeTransform)
A1 = np.array(tr1.GetMatrix()).reshape(3, 3)
t1 = np.array(tr1.GetTranslation())
c1 = np.array(tr1.GetCenter())
init2 = _bspline_representing_affine(fixed, A1, t1, c1, grid_spacing)
# [4,2]: a full-resolution polish pass was measured to drop the achieved
# MI (0.6891 vs 0.6947 at 32 mm) and is omitted
sh2 = sh[:2]; sm2 = sm[:2]
r2 = sitk.ImageRegistrationMethod()
r2.SetMetricAsMattesMutualInformation(numberOfHistogramBins=32)
r2.SetOptimizerAsRegularStepGradientDescent(
learningRate=10.0, minStep=0.2, numberOfIterations=iters_bspline,
relaxationFactor=0.5, gradientMagnitudeTolerance=1e-6)
# NOTE: no SetOptimizerScalesFromPhysicalShift() here -- it costs one
# metric evaluation per spline parameter (~2700 at 32 mm; ~15 min/case
# at the old 13k-parameter 16 mm grid); unit scales + the adaptive step
# size are the standard high-DOF setup.
r2.SetShrinkFactorsPerLevel(sh2); r2.SetSmoothingSigmasPerLevel(sm2)
r2.SetInterpolator(sitk.sitkLinear)
r2.SetInitialTransform(init2, inPlace=True)
tr2 = r2.Execute(fixed, moving)
dt = time.time() - t0
T = _rasterize_to_tdf(tr2, fixed)
ha = sitk.GetArrayFromImage(t1c)
gap = m6._skull_ring_gap(_warp_ct(ct, t1c, T), ha)
meta = {
"pipeline": "simpleitk",
"toolkit": f"SimpleITK {sitk.Version_VersionString()}",
"metric": "MattesMutualInformation (both stages, full volumes)",
"transform": (f"Deformable (Euler3D {iters_full} iters/level + "
f"BSpline {tuple(float(x) for x in grid_spacing)} mm, "
f"{iters_bspline} iters/level on {sh2}; Euler on {sh})"),
"convention": "T1c->CT (stage 2 carries stage 1)", "auto_inverted": False,
"fixed": _modality_name(fixed), "moving": _modality_name(moving),
"seconds_reg": round(dt, 1), "pyramid_shrink": sh, "pyramid_smooth": sm,
"skull_ring_gap_mm": gap,
}
return T, meta
def register_synthmorph_deformable(ct, t1c, gpu=None, threads=None, extent=256,
keep_scratch=False, clip_ct=(0.0, 80.0)):
"""SynthMorph ``joint`` model (affine + deformable in one symmetric
network pass -- the toolkit's canonical deformable) in the Docker image
``freesurfer/synthmorph``. The CT is clipped to [0, 80] HU by default
(per the rigid A/B; ``clip_ct=None`` disables). The CLI is driven as
``register -m joint moving fixed`` (moving = clipped CT first, fixed =
T1c second); the ``-t`` output is the displacement field on the fixed
(T1c) grid in surfa's naive (x/y-mirrored) world --
:func:`_flip_naive_world_components` restores the true RAS field, giving
the contract transform directly. The tool's own ``-o`` resampling is
written as well and cross-checked against the field (NCC in meta)."""
import time
ok, note = _synthmorph_docker_ok()
if not ok:
raise PipelineUnavailable(f"synthmorph: {note}")
if gpu is None:
ngpu = _n_gpus()
gpu = (os.getpid() % ngpu) if ngpu else -1
tmp = tempfile.mkdtemp(prefix="synthmorph_def_")
dt = 0.0
try:
fixed, moving = fixed_moving(ct, t1c) # fixed=T1c, moving=CT
fixed_name = _modality_name(fixed)
moving_name = _modality_name(moving)
fixed, moving, ct_clipped = _maybe_clip_ct_pair(fixed, moving, clip_ct)
# CLI order is (moving, fixed): moving = clipped CT, fixed = T1c
sitk.WriteImage(sitk.Cast(moving, sitk.sitkFloat32),
os.path.join(tmp, "moving.nii.gz"), True)
sitk.WriteImage(sitk.Cast(fixed, sitk.sitkFloat32),
os.path.join(tmp, "fixed.nii.gz"), True)
cmd = ["docker", "run", "--rm",
"--user", f"{os.getuid()}:{os.getgid()}",
"-e", "TF_FORCE_GPU_ALLOW_GROWTH=true"]
if gpu >= 0:
cmd += ["--gpus", "all", "-e", f"CUDA_VISIBLE_DEVICES={gpu}"]
cmd += ["-v", f"{tmp}:/data", SYNTHMORPH_IMAGE,
"register", "-m", "joint"]
if gpu >= 0:
cmd.append("-g")
if threads:
cmd += ["-j", str(threads)]
cmd += ["-e", str(extent),
"-t", "/data/field.nii.gz",
"-o", "/data/warped.nii.gz",
"/data/moving.nii.gz", "/data/fixed.nii.gz"]
t0 = time.time()
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=7200)
dt = time.time() - t0
if proc.returncode != 0:
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:])
raise RuntimeError(f"synthmorph joint: docker failed (rc={proc.returncode}):\n{tail}")
if not os.path.exists(os.path.join(tmp, "field.nii.gz")):
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-25:])
raise RuntimeError(f"synthmorph joint: no /data/field.nii.gz produced:\n{tail}")
F = sitk.ReadImage(os.path.join(tmp, "field.nii.gz"))
arr = sitk.GetArrayFromImage(F)
if arr.ndim == 4 and arr.shape[-1] != 3:
arr = np.transpose(arr, (1, 2, 3, 0))
T = _field_transform(_flip_naive_world_components(arr), fixed)
# cross-check against the tool's own resampling (clipped CT on the
# T1c grid) -- a convention regression would show up here
crosscheck = None
if os.path.exists(os.path.join(tmp, "warped.nii.gz")):
w_own = sitk.GetArrayFromImage(sitk.ReadImage(os.path.join(tmp, "warped.nii.gz")))
w_mine = sitk.GetArrayFromImage(
sitk.Resample(moving, fixed, T, sitk.sitkLinear))
ha_f = sitk.GetArrayFromImage(fixed)
from src.common import head_mask_from_image
head = head_mask_from_image(ha_f.astype("float32"))
if head.sum() > 0:
crosscheck = round(float(np.corrcoef(
w_mine[head].ravel(), w_own[head].ravel())[0, 1]), 4)
finally:
if not keep_scratch:
shutil.rmtree(tmp, ignore_errors=True)
ha = sitk.GetArrayFromImage(t1c)
gap = _skull_gap(_warp_ct(ct, t1c, T), ha)
meta = {
"pipeline": "synthmorph",
"toolkit": f"Docker {SYNTHMORPH_IMAGE} (TensorFlow, pretrained)",
"metric": "SynthMorph joint (anatomy-aware contrast-invariant, deep, pretrained)",
"transform": (f"Deformable (joint affine+deform, identity start, "
f"device={'GPU' + str(gpu) if gpu >= 0 else 'CPU'}, "
f"extent {extent})"),
"convention": "T1c->CT (verified vs -o resample)", "auto_inverted": False,
"fixed": fixed_name, "moving": moving_name,
"seconds_reg": round(dt, 1),
"docker_image": SYNTHMORPH_IMAGE, "model": "joint", "gpu": gpu,
"extent": extent,
"clip_ct": [float(clip_ct[0]), float(clip_ct[1])] if ct_clipped else None,
"own_resample_ncc_head": crosscheck, "skull_ring_gap_mm": gap,
}
if keep_scratch:
meta["scratch"] = tmp
return T, meta
REGISTERS_DEFORMABLE = {
"antspyx": register_antspyx_deformable,
"elastix": register_elastix_deformable,
"impact": register_impact_deformable,
"niftyreg": register_niftyreg_deformable,
"simpleitk": register_simpleitk_deformable,
"synthmorph": register_synthmorph_deformable,
}
ALL_PIPELINES_DEFORMABLE = list(REGISTERS_DEFORMABLE)
def availability():
"""pipeline -> (available: bool, note: str) for the current env."""
out = {}

File diff suppressed because it is too large Load diff