Skip to content

Commit 4b593c4

Browse files
committed
ENH: Register a CT to an MR from Python, with the settings that decide the result
ImpactRigidBSplineExample.py drives both stages of ITK's own registration pipeline with the same itk.ImpactImageToImageMetricv4: Euler3D with a scales estimator, then BSplineTransform with LBFGSBOptimizerv4, as in Examples/RegistrationITKv4/DeformableRegistration12.cxx. The composed transform can be written out to carry the alignment to a segmentation or another sequence. The rotation centre comes from the fixed image, the domain a moving transform maps from, and goes through TransformIndexToPhysicalPoint so the direction cosines are applied. Computed as origin + 0.5 * spacing * size it ignores them: on an image stored LPS rather than RAS it names a point hundreds of millimetres outside the anatomy, and every degree of rotation then arrives with that much lever arm. The distance is L2 across the examples, NCC being an ablation rather than a setting to reach for. The mask a caller passes decides the result more than anything else these examples expose. A whole-body mask is mostly wall and fat; those agree well between MR and CT and outvote the organs, and the registration settles two centimetres away from the anatomy. MakeRegistrationImages.py builds a mask around the organs with --mask-model, by segmenting the fixed image with the model matching its modality, so the figures need no ground truth. Both figures carry axial and coronal, before against after in each plane. One plane cannot settle an alignment: a cranio-caudal offset leaves an axial slice looking aligned while every organ has moved. The coronal panels drop the checkerboard, since this MR has a limited field of view and the tiles there alternate between anatomy and black bands. The figures are measured on the pair as delivered. The synthetic offset they used to apply is off by default: the delivered pair is close to aligned but not aligned, and the registration improves on it, so there is something to recover without inventing any.
1 parent ddf9301 commit 4b593c4

7 files changed

Lines changed: 442 additions & 70 deletions

examples/ImpactMetricExample.cxx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ main(int argc, char * argv[])
8787
auto metric = MetricType::New();
8888
std::vector<itk::ModelConfiguration> models{ config };
8989
metric->SetModelsConfiguration(models);
90-
metric->SetDistance({ "NCC" }); // per-layer loss: L1, L2, NCC, Cosine, Dice, ...
90+
metric->SetDistance({ "L2" }); // per-layer loss: L1, L2, NCC, Cosine, Dice, ...
9191
metric->SetLayersWeight({ 1.0f });
9292
metric->SetSubsetFeatures({ 4 }); // random channel subset for speed (0 = all)
9393
metric->SetPCA({ 0 });

examples/ImpactMetricExample.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
metric.AddModelConfiguration(
6161
itk.ModelConfiguration(path, Dimension, 1, [0, 0, 0], voxel, 0, [True], False)
6262
)
63-
metric.SetDistance(["NCC"] * len(args.models)) # modality-invariant, smooth
63+
metric.SetDistance(["L2"] * len(args.models)) # the distance to reach for; NCC is an ablation
6464
metric.SetLayersWeight([1.0] * len(args.models))
6565
metric.SetSubsetFeatures([12] * len(args.models)) # channels compared per model
6666
metric.SetPCA([0] * len(args.models))
@@ -70,16 +70,24 @@
7070
# Air agrees between modalities everywhere, so a whole-image domain measures mostly
7171
# background: on an abdominal MR-CT pair the similarity varies by 1e-4 over a 2 cm offset,
7272
# which no optimizer can follow, against 5e-2 for the same offset inside the body.
73+
#
74+
# How tight the mask is decides the result more than any other setting here. A whole-body
75+
# mask is mostly wall and fat, which agree well between the two modalities and outvote the
76+
# organs: on the pair below it costs 0.14 of organ Dice against a mask around the organs
77+
# themselves, and no distance, model or optimizer setting recovers that. A mask of the
78+
# region you want aligned is worth more than a better similarity.
7379
mask = itk.ImageMaskSpatialObject[Dimension].New()
7480
mask.SetImage(itk.imread(args.mask, itk.UC))
7581
mask.Update()
7682
metric.SetFixedImageMask(mask)
7783

78-
# Rigid transform, centred on the moving image.
79-
center = [
80-
o + 0.5 * s * n
81-
for o, s, n in zip(moving.GetOrigin(), moving.GetSpacing(), moving.GetLargestPossibleRegion().GetSize())
82-
]
84+
# Rigid transform, centred on the fixed image, which is the domain the moving transform maps
85+
# from. The centre goes through the direction cosines: computing it as origin + 0.5 * spacing *
86+
# size ignores them, and on an image stored LPS rather than RAS that lands hundreds of
87+
# millimetres outside the anatomy. Every degree of rotation then arrives with a lever arm, and
88+
# the registration settles on a transform that translates the organs instead of turning them.
89+
size = fixed.GetLargestPossibleRegion().GetSize()
90+
center = list(fixed.TransformIndexToPhysicalPoint([size[0] // 2, size[1] // 2, size[2] // 2]))
8391
transform = itk.Euler3DTransform[itk.D].New()
8492
transform.SetIdentity()
8593
transform.SetCenter(center)
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env python
2+
# ==========================================================================
3+
#
4+
# Copyright NumFOCUS
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# https://www.apache.org/licenses/LICENSE-2.0.txt
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# ==========================================================================
19+
20+
"""IMPACT with the standard ITK registration pipeline: rigid, then B-spline.
21+
22+
The same `itk.ImpactImageToImageMetricv4` drives both stages; only the transform and the
23+
optimizer change. The layout follows ITK's own examples: Euler3D with a scales estimator for the
24+
rigid stage, `BSplineTransform` with `LBFGSBOptimizerv4` for the deformable one, as in
25+
`Examples/RegistrationITKv4/DeformableRegistration12.cxx`.
26+
27+
Three settings are worth knowing about, because each is easy to get wrong and each decides
28+
whether the result is an alignment or a wander.
29+
30+
**Mask the region you want aligned**, not the whole body. Wall and fat agree well between
31+
modalities and outvote the organs, so a body mask sends the registration somewhere else
32+
entirely. No labels are needed: segment the fixed image with the model matching its modality and
33+
dilate what it does not call background.
34+
35+
**The B-spline stage uses no scales estimator.** A B-spline coefficient is already a displacement
36+
in millimetres, so every coefficient has the same physical meaning and there is nothing to
37+
equalise. Asked anyway, the estimator returns about 6.7e-07 for each of them against 1 for a
38+
translation parameter, and a gradient descent divides its update by that. LBFGSB is quasi-Newton
39+
and builds its own conditioning, which is why ITK pairs it with the B-spline transform.
40+
41+
**One resolution level.** ITK's shrink factors never reach the model: `ImageToFeaturesMap`
42+
resamples whatever it is handed to the `ModelConfiguration` voxel size, so a shrunk image is
43+
interpolated straight back up before the network sees it, leaving the smoothing and the lost
44+
detail. Coarsening the model's voxel size is the real knob, and for a local descriptor like MIND
45+
it describes different anatomy rather than the same anatomy more coarsely.
46+
47+
A finer B-spline mesh is not reached by chaining two registrations: ITK cannot compose a B-spline
48+
as a moving initial transform (`ComputeJacobianWithRespectToPosition` is not implemented for it),
49+
which is what `itk::BSplineTransformParametersAdaptor` exists for. That class is not currently
50+
wrapped for Python, so a coarse-to-fine schedule needs C++.
51+
52+
Run: ./ImpactRigidBSplineExample.py fixed.nii.gz moving.nii.gz mind.pt \\
53+
--mask organs.nii.gz --grid-spacing 40 --out moved.nii.gz --device cuda:0
54+
"""
55+
56+
import argparse
57+
58+
import itk
59+
60+
parser = argparse.ArgumentParser(description="IMPACT rigid then B-spline registration.")
61+
parser.add_argument("fixed_image")
62+
parser.add_argument("moving_image")
63+
parser.add_argument("models", nargs="+", help="one or more TorchScript feature models (.pt)")
64+
parser.add_argument("--mask", default=None,
65+
help="fixed-image mask, around the organs rather than the whole body; "
66+
"see the docstring, it is the setting that matters most")
67+
parser.add_argument("--out", default="moved.nii.gz", help="warped moving image")
68+
parser.add_argument("--voxel", type=float, default=2.0, help="feature voxel size in mm")
69+
parser.add_argument("--grid-spacing", type=float, default=20.0,
70+
help="distance between B-spline control points, in mm. This is the knob that\n"
71+
"decides what the deformation can express: too coarse and one control\n"
72+
"point drags a whole region, which shows up as one structure improving\n"
73+
"while its neighbour is pulled apart. elastix targets 10 mm for this\n"
74+
"kind of abdominal case (FinalGridSpacingInPhysicalUnits)")
75+
parser.add_argument("--sampling", type=float, default=0.10, help="fraction of voxels sampled")
76+
parser.add_argument("--shrink-factors", type=int, nargs="+", default=[1],
77+
help="one shrink factor per resolution level, coarsest first")
78+
parser.add_argument("--smoothing-sigmas", type=float, nargs="+", default=[0.0],
79+
help="Gaussian sigma per level, in millimetres, matching --shrink-factors")
80+
parser.add_argument("--rigid-iterations", type=int, default=300)
81+
parser.add_argument("--bspline-iterations", type=int, default=200,
82+
help="also caps the LBFGSB function evaluations; the cost of the run is "
83+
"roughly this times the sampled fraction of the volume")
84+
parser.add_argument("--save-transform", default=None,
85+
help="write the composed rigid+B-spline transform (.tfm/.h5), so the same\n"
86+
"alignment can be applied to a segmentation or another sequence")
87+
parser.add_argument("--device", default="cuda:0", help='"cpu", "cuda", "cuda:0", ...')
88+
args = parser.parse_args()
89+
90+
if len(args.shrink_factors) != len(args.smoothing_sigmas):
91+
parser.error("--shrink-factors and --smoothing-sigmas carry one entry per level, "
92+
f"got {len(args.shrink_factors)} and {len(args.smoothing_sigmas)}")
93+
94+
Dimension = 3
95+
SplineOrder = 3
96+
ImageType = itk.Image[itk.F, Dimension]
97+
98+
fixed = itk.imread(args.fixed_image, itk.F)
99+
moving = itk.imread(args.moving_image, itk.F)
100+
101+
# --- the metric, shared by both stages ---------------------------------------------------
102+
metric = itk.ImpactImageToImageMetricv4[ImageType, ImageType].New()
103+
for path in args.models:
104+
metric.AddModelConfiguration(
105+
itk.ModelConfiguration(path, Dimension, 1, [0, 0, 0], [args.voxel] * Dimension, 0, [True], False)
106+
)
107+
metric.SetDistance(["L2"] * len(args.models))
108+
metric.SetLayersWeight([1.0] * len(args.models))
109+
metric.SetSubsetFeatures([12] * len(args.models))
110+
metric.SetPCA([0] * len(args.models))
111+
metric.SetMode("Static")
112+
metric.SetDevice(args.device)
113+
114+
if args.mask:
115+
# Air agrees between modalities everywhere, so a whole-image domain measures mostly
116+
# background and the similarity barely varies with the transform.
117+
mask = itk.ImageMaskSpatialObject[Dimension].New()
118+
mask.SetImage(itk.imread(args.mask, itk.UC))
119+
mask.Update()
120+
metric.SetFixedImageMask(mask)
121+
122+
identity = itk.IdentityTransform[itk.D, Dimension].New()
123+
124+
125+
def pyramid(registration, shrink_factors, smoothing_sigmas):
126+
"""Resolution levels and sampling: see the module docstring."""
127+
registration.SetNumberOfLevels(len(shrink_factors))
128+
registration.SetShrinkFactorsPerLevel(shrink_factors)
129+
registration.SetSmoothingSigmasPerLevel(smoothing_sigmas)
130+
registration.SetMetricSamplingStrategy(2) # 0 NONE, 1 REGULAR, 2 RANDOM
131+
registration.SetMetricSamplingPercentage(args.sampling)
132+
133+
134+
# --- 1. rigid ------------------------------------------------------------------------------
135+
size = fixed.GetLargestPossibleRegion().GetSize()
136+
centre = list(fixed.TransformIndexToPhysicalPoint([size[0] // 2, size[1] // 2, size[2] // 2]))
137+
138+
rigid = itk.Euler3DTransform[itk.D].New()
139+
rigid.SetIdentity()
140+
rigid.SetCenter(centre)
141+
142+
rigid_optimizer = itk.RegularStepGradientDescentOptimizerv4[itk.D].New()
143+
rigid_optimizer.SetLearningRate(2.0)
144+
rigid_optimizer.SetMinimumStepLength(1e-4)
145+
rigid_optimizer.SetRelaxationFactor(0.8)
146+
rigid_optimizer.SetNumberOfIterations(args.rigid_iterations)
147+
# A radian and a millimetre are not comparable; let ITK derive the ratio from the physical
148+
# shift each parameter causes, measured against this metric's own gradient.
149+
rigid_scales = itk.RegistrationParameterScalesFromPhysicalShift[type(metric)].New()
150+
rigid_scales.SetMetric(metric)
151+
rigid_optimizer.SetScalesEstimator(rigid_scales)
152+
153+
rigid_registration = itk.ImageRegistrationMethodv4[ImageType, ImageType].New()
154+
rigid_registration.SetFixedImage(fixed)
155+
rigid_registration.SetMovingImage(moving)
156+
rigid_registration.SetMetric(metric)
157+
rigid_registration.SetOptimizer(rigid_optimizer)
158+
rigid_registration.SetInitialTransform(rigid)
159+
pyramid(rigid_registration, args.shrink_factors, args.smoothing_sigmas)
160+
rigid_registration.Update()
161+
print("rigid :", rigid_optimizer.GetStopConditionDescription())
162+
163+
# --- 2. B-spline, on top of the rigid ------------------------------------------------------
164+
# The transform domain covers the fixed image, as ITK's example sets it out.
165+
physical_dimensions = [
166+
fixed.GetSpacing()[i] * (fixed.GetLargestPossibleRegion().GetSize()[i] - 1) for i in range(Dimension)
167+
]
168+
bspline = itk.BSplineTransform[itk.D, Dimension, SplineOrder].New()
169+
bspline.SetTransformDomainOrigin(fixed.GetOrigin())
170+
bspline.SetTransformDomainPhysicalDimensions(physical_dimensions)
171+
bspline.SetTransformDomainDirection(fixed.GetDirection())
172+
# Mesh size from the requested physical spacing, so the grid does not silently change
173+
# meaning when the image geometry does.
174+
mesh_size = [max(1, int(round(physical_dimensions[i] / args.grid_spacing))) for i in range(Dimension)]
175+
bspline.SetTransformDomainMeshSize(mesh_size)
176+
print(f"bspline grid: mesh {mesh_size}, "
177+
f"{[round(physical_dimensions[i] / mesh_size[i]) for i in range(Dimension)]} mm apart, "
178+
f"{bspline.GetNumberOfParameters()} parameters")
179+
180+
number_of_parameters = bspline.GetNumberOfParameters()
181+
bspline_optimizer = itk.LBFGSBOptimizerv4.New()
182+
# All bounds deselected, so the coefficients are free: LBFGSB is used here for its
183+
# quasi-Newton conditioning, not for its bounds.
184+
bspline_optimizer.SetBoundSelection(itk.Array[itk.SL]([0] * number_of_parameters))
185+
bspline_optimizer.SetLowerBound(itk.Array[itk.D]([0.0] * number_of_parameters))
186+
bspline_optimizer.SetUpperBound(itk.Array[itk.D]([0.0] * number_of_parameters))
187+
bspline_optimizer.SetCostFunctionConvergenceFactor(1e7)
188+
bspline_optimizer.SetGradientConvergenceTolerance(1e-35)
189+
bspline_optimizer.SetNumberOfIterations(args.bspline_iterations)
190+
bspline_optimizer.SetMaximumNumberOfFunctionEvaluations(args.bspline_iterations)
191+
bspline_optimizer.SetMaximumNumberOfCorrections(7)
192+
# Deliberately no scales estimator: see the module docstring.
193+
194+
bspline_registration = itk.ImageRegistrationMethodv4[ImageType, ImageType].New()
195+
bspline_registration.SetFixedImage(fixed)
196+
bspline_registration.SetMovingImage(moving)
197+
bspline_registration.SetMetric(metric)
198+
bspline_registration.SetOptimizer(bspline_optimizer)
199+
bspline_registration.SetInitialTransform(bspline)
200+
# The rigid result is held in front of the B-spline rather than folded into it, which is how
201+
# ITKv4 composes a prior stage. A rigid transform can sit here; a B-spline cannot.
202+
bspline_registration.SetMovingInitialTransform(rigid_registration.GetTransform())
203+
# One level, unlike the rigid stage. A pyramid only helps a deformable stage if the control
204+
# grid is coarsened along with the image; held at its final spacing while the image shrinks by
205+
# four, the grid is fine relative to the blurred content and fits it, which the finer levels
206+
# then inherit. Coarsening it is what BSplineTransformParametersAdaptor does, and that class is
207+
# not wrapped for Python.
208+
pyramid(bspline_registration, [1], [0.0])
209+
bspline_registration.Update()
210+
print("bspline :", bspline_optimizer.GetStopConditionDescription())
211+
212+
# --- 3. resample through both ---------------------------------------------------------------
213+
# CompositeTransform applies the last added first, so this is rigid(bspline(point)) -- the
214+
# same composition ITKv4 made internally during the second stage.
215+
composite = itk.CompositeTransform[itk.D, Dimension].New()
216+
composite.AddTransform(rigid_registration.GetTransform())
217+
composite.AddTransform(bspline)
218+
219+
itk.imwrite(
220+
itk.resample_image_filter(
221+
moving, transform=composite, use_reference_image=True, reference_image=fixed,
222+
default_pixel_value=-1024.0,
223+
),
224+
args.out,
225+
)
226+
if args.save_transform:
227+
itk.transformwrite([composite], args.save_transform)
228+
print("wrote", args.save_transform)
229+
print("wrote", args.out)

0 commit comments

Comments
 (0)