Skip to content

Commit 3aeb18e

Browse files
committed
enh: ITK's coordinate system deciphered
1 parent 09ab48b commit 3aeb18e

File tree

2 files changed

+51
-19
lines changed

2 files changed

+51
-19
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_nonlinear.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def test_densefield_map_vs_ants(testdata_path, tmp_path, ongrid):
196196
)
197197
if not warpfile.exists():
198198
pytest.skip("Composite transform test data not available")
199-
199+
200200
nii = ITKDisplacementsField.from_filename(warpfile)
201201

202202
# Get sampling indices
@@ -225,13 +225,14 @@ def test_densefield_map_vs_ants(testdata_path, tmp_path, ongrid):
225225
ants_mapped_xyz = ants_pts.reshape(*shape, 3)
226226
nit_mapped_xyz = mapped.reshape(*shape, 3)
227227

228+
nb.load(warpfile).to_filename(tmp_path / "original_ants_deltas.nii.gz")
229+
228230
nb.Nifti1Image(coords_map, ref_affine, None).to_filename(
229-
tmp_path / "baseline_field.nii.gz"
231+
tmp_path / "baseline_positions.nii.gz"
230232
)
231233

232-
nb.Nifti1Image(ants_mapped_xyz, ref_affine, None).to_filename(
233-
tmp_path / "ants_deformation_xyz.nii.gz"
234-
)
234+
nii.to_filename(tmp_path / "original_interpreted_deltas.nii.gz")
235+
235236
nb.Nifti1Image(nit_mapped_xyz, ref_affine, None).to_filename(
236237
tmp_path / "nit_deformation_xyz.nii.gz"
237238
)
@@ -260,7 +261,6 @@ def test_constant_field_vs_ants(tmp_path, get_testdata, image_orientation, ongri
260261
)
261262

262263
coords_map = grid_xyz.reshape(*shape, 3)
263-
gold_mapped_xyz = coords_map + deltas
264264

265265
deltas = np.hstack(
266266
(
@@ -269,6 +269,7 @@ def test_constant_field_vs_ants(tmp_path, get_testdata, image_orientation, ongri
269269
np.linspace(-50, 50, num=np.prod(shape)),
270270
)
271271
).reshape(shape + (3,))
272+
gold_mapped_xyz = coords_map + deltas
272273

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

0 commit comments

Comments
 (0)