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