Skip to content

Commit 449d396

Browse files
committed
enh: ITK's coordinate system deciphered
1 parent 5ba9a97 commit 449d396

File tree

3 files changed

+98
-27
lines changed

3 files changed

+98
-27
lines changed

nitransforms/io/itk.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def from_string(cls, string):
196196
lines = lines[1:] # Drop banner with version
197197

198198
parameters = np.eye(4, dtype="f4")
199-
sa["index"] = int(lines[0][lines[0].index("T"):].split()[1])
199+
sa["index"] = int(lines[0][lines[0].index("T") :].split()[1])
200200
sa["offset"] = np.genfromtxt(
201201
[lines[3].split(":")[-1].encode()], dtype=cls.dtype["offset"]
202202
)
@@ -329,7 +329,15 @@ def from_h5obj(cls, fileobj, check=True):
329329

330330

331331
class ITKDisplacementsField(DisplacementsField):
332-
"""A data structure representing displacements fields."""
332+
"""
333+
A data structure representing displacements fields.
334+
335+
Note that ITK's world coordinates are LPS:
336+
http://sourceforge.net/p/advants/discussion/840261/thread/2a1e9307/.
337+
This translates into the fact that `antsApplyTransformsToPoints` expects LPS coordinates,
338+
and therefore, points must correct for the x and y directions before feeding into the tool.
339+
340+
"""
333341

334342
@classmethod
335343
def from_image(cls, imgobj):
@@ -347,24 +355,47 @@ def from_image(cls, imgobj):
347355
warnings.warn("Incorrect intent identified.")
348356
hdr.set_intent("vector")
349357

350-
field = np.squeeze(np.asanyarray(imgobj.dataobj))
351-
affine = imgobj.affine
352-
midindex = (np.array(field.shape[:3]) - 1) * 0.5
353-
offset = (LPS @ affine - affine) @ (*midindex, 1)
354-
affine[:3, 3] += offset[:3]
355-
return imgobj.__class__(np.flip(field, axis=(0, 1)), imgobj.affine, hdr)
358+
affine, qcode = hdr.get_qform(coded=True)
359+
360+
if qcode != 1:
361+
warnings.warn(
362+
"Displacements field has qform code={qcode}, which is not ITK-like. "
363+
"Setting it to 1 (aligned with the image)."
364+
)
365+
affine = imgobj.affine
366+
hdr.set_qform(imgobj.affine, code=1)
367+
hdr.set_sform(imgobj.affine, code=0)
368+
369+
# ITK uses LPS coordinates, so first we patch the affine's offset
370+
# This patch was developed under gh-266, by adapting
371+
# nitransforms/tests/test_nonlinear.py::test_densefield_map_vs_ants[True]
372+
# until the same delta maps were obtained with `antsApplyTransformsToPoints`
373+
# and our NiTransforms transform's `.map()`.
374+
mid_ijk = 0.5 * (np.array(imgobj.shape[:3]) - 1)
375+
offset = (affine - LPS @ affine) @ (*mid_ijk, 1.0)
376+
affine[:3, 3] -= offset[:3]
377+
378+
# Create a NIfTI image with the patched affine
379+
data = np.squeeze(imgobj.get_fdata(dtype="float32"))
380+
refmap = imgobj.__class__(np.flip(data, axis=(0, 1)), affine, hdr)
381+
382+
return refmap
356383

357384
@classmethod
358385
def to_image(cls, imgobj):
359386
"""Export a displacements field from a nibabel object."""
360387

361388
hdr = imgobj.header.copy()
362389
hdr.set_intent("vector")
363-
364-
field = imgobj.get_fdata()
365-
field = field.transpose(2, 1, 0, 3)[..., None, :]
366-
field[..., (0, 1)] *= 1.0
367-
return imgobj.__class__(field, LPS @ imgobj.affine, hdr)
390+
hdr.set_data_dtype("float32")
391+
affine = LPS @ imgobj.affine @ LPS
392+
hdr.set_qform(affine, code=1)
393+
hdr.set_sform(affine, code=0)
394+
hdr.set_xyzt_units("mm", None)
395+
396+
return imgobj.__class__(
397+
imgobj.get_fdata(dtype="float32")[..., None, :], affine, hdr
398+
)
368399

369400

370401
class ITKCompositeH5:

nitransforms/tests/test_io.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,41 @@ def test_itk_disp_load_intent():
706706

707707

708708
# Added tests for displacements fields orientations (ANTs/ITK)
709+
def test_itk_densefield_read(testdata_path, tmp_path):
710+
"""Map points with DenseFieldTransform and compare to ANTs."""
711+
warpfile = (
712+
testdata_path
713+
/ "regressions"
714+
/ ("01_ants_t1_to_mniComposite_DisplacementFieldTransform.nii.gz")
715+
)
716+
if not warpfile.exists():
717+
pytest.skip("Composite transform test data not available")
718+
719+
nib_from_itk = nb.load(warpfile)
720+
nit_from_itk = itk.ITKDisplacementsField.from_filename(warpfile)
721+
itk_from_nit = itk.ITKDisplacementsField.to_image(nit_from_itk)
722+
723+
# Enable for debugging
724+
# nib_from_itk.to_filename(tmp_path / "nib_from_itk.nii.gz")
725+
# itk_from_nit.to_filename(tmp_path / "itk_from_nit.nii.gz")
726+
# nit_from_itk.to_filename(tmp_path / "nit_from_itk.nii.gz")
727+
728+
# import pdb; pdb.set_trace()
729+
730+
np.testing.assert_allclose(
731+
nib_from_itk.dataobj, itk_from_nit.dataobj, rtol=1e-5, atol=1e-5
732+
)
733+
np.testing.assert_allclose(
734+
nit_from_itk.dataobj, np.squeeze(itk_from_nit.dataobj), rtol=1e-5, atol=1e-5
735+
)
736+
np.testing.assert_allclose(
737+
nib_from_itk.affine, itk_from_nit.affine, rtol=1e-5, atol=1e-5
738+
)
739+
np.testing.assert_allclose(
740+
nit_from_itk.affine, itk_from_nit.affine, rtol=1e-5, atol=1e-5
741+
)
742+
743+
709744
@pytest.mark.parametrize("image_orientation", ["RAS", "LAS", "LPS", "oblique"])
710745
@pytest.mark.parametrize("field_is_random", [False, True])
711746
def test_itk_displacements(tmp_path, get_testdata, image_orientation, field_is_random):
@@ -745,6 +780,15 @@ def test_itk_displacements(tmp_path, get_testdata, image_orientation, field_is_r
745780
itk_nit_nii = itk.ITKDisplacementsField.from_filename(itk_file)
746781
itk_nii = nb.load(itk_file)
747782

783+
# Test round trip
784+
assert itk_nit_nii.shape == field.shape
785+
np.testing.assert_allclose(itk_nit_nii.dataobj, field)
786+
np.testing.assert_allclose(
787+
itk_nit_nii.dataobj.transpose(2, 1, 0, 3)[..., None, :], nit_nii.dataobj
788+
)
789+
np.testing.assert_allclose(itk_nit_nii.affine, ref_affine)
790+
np.testing.assert_allclose(LPS @ itk_nit_nii.affine, nit_nii.affine)
791+
748792
assert nit_nii.shape == itk_nii.shape, (
749793
"ITK-generated and nitransforms-generated field shapes are different"
750794
)
@@ -754,14 +798,9 @@ def test_itk_displacements(tmp_path, get_testdata, image_orientation, field_is_r
754798
# Check ITK-generated field has LPS-rotated affine
755799
np.testing.assert_allclose(itk_nii.affine, LPS @ ref_affine)
756800
# Test ITK-generated dataobject vs. original field
757-
np.testing.assert_allclose(itk_nii.dataobj, field.transpose(2, 1, 0, 3)[..., None, :])
758-
759-
# Test round trip
760-
assert itk_nit_nii.shape == field.shape
761-
np.testing.assert_allclose(itk_nit_nii.dataobj, field)
762-
np.testing.assert_allclose(itk_nit_nii.dataobj.transpose(2, 1, 0, 3)[..., None, :], nit_nii.dataobj)
763-
np.testing.assert_allclose(itk_nit_nii.affine, ref_affine)
764-
np.testing.assert_allclose(LPS @ itk_nit_nii.affine, nit_nii.affine)
801+
np.testing.assert_allclose(
802+
itk_nii.dataobj, field.transpose(2, 1, 0, 3)[..., None, :]
803+
)
765804

766805

767806
# Added tests for h5 orientation bug

nitransforms/tests/test_nonlinear.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def test_densefield_map_vs_ants(testdata_path, tmp_path, ongrid):
189189
)
190190
if not warpfile.exists():
191191
pytest.skip("Composite transform test data not available")
192-
192+
193193
nii = ITKDisplacementsField.from_filename(warpfile)
194194

195195
# Get sampling indices
@@ -218,13 +218,14 @@ def test_densefield_map_vs_ants(testdata_path, tmp_path, ongrid):
218218
ants_mapped_xyz = ants_pts.reshape(*shape, 3)
219219
nit_mapped_xyz = mapped.reshape(*shape, 3)
220220

221+
nb.load(warpfile).to_filename(tmp_path / "original_ants_deltas.nii.gz")
222+
221223
nb.Nifti1Image(coords_map, ref_affine, None).to_filename(
222-
tmp_path / "baseline_field.nii.gz"
224+
tmp_path / "baseline_positions.nii.gz"
223225
)
224226

225-
nb.Nifti1Image(ants_mapped_xyz, ref_affine, None).to_filename(
226-
tmp_path / "ants_deformation_xyz.nii.gz"
227-
)
227+
nii.to_filename(tmp_path / "original_interpreted_deltas.nii.gz")
228+
228229
nb.Nifti1Image(nit_mapped_xyz, ref_affine, None).to_filename(
229230
tmp_path / "nit_deformation_xyz.nii.gz"
230231
)
@@ -253,7 +254,6 @@ def test_constant_field_vs_ants(tmp_path, get_testdata, image_orientation, ongri
253254
)
254255

255256
coords_map = grid_xyz.reshape(*shape, 3)
256-
gold_mapped_xyz = coords_map + deltas
257257

258258
deltas = np.hstack(
259259
(
@@ -262,6 +262,7 @@ def test_constant_field_vs_ants(tmp_path, get_testdata, image_orientation, ongri
262262
np.linspace(-50, 50, num=np.prod(shape)),
263263
)
264264
).reshape(shape + (3,))
265+
gold_mapped_xyz = coords_map + deltas
265266

266267
fieldnii = nb.Nifti1Image(deltas, ref_affine, None)
267268
warpfile = tmp_path / "itk_transform.nii.gz"

0 commit comments

Comments
 (0)