Skip to content

Commit aadcf57

Browse files
committed
Fixed affine transformation of LazyTractogram
1 parent e331486 commit aadcf57

File tree

3 files changed

+94
-70
lines changed

3 files changed

+94
-70
lines changed

nibabel/streamlines/tests/test_tractogram.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -571,18 +571,21 @@ def test_lazy_tractogram_apply_affine(self):
571571
tractogram = DATA['lazy_tractogram'].copy()
572572

573573
transformed_tractogram = tractogram.apply_affine(affine)
574-
assert_true(transformed_tractogram is tractogram)
575-
assert_array_equal(tractogram._affine_to_apply, affine)
576-
assert_array_equal(tractogram.get_affine_to_rasmm(),
574+
assert_true(transformed_tractogram is not tractogram)
575+
assert_array_equal(tractogram._affine_to_apply, np.eye(4))
576+
assert_array_equal(tractogram.get_affine_to_rasmm(), np.eye(4))
577+
assert_array_equal(transformed_tractogram._affine_to_apply, affine)
578+
assert_array_equal(transformed_tractogram.get_affine_to_rasmm(),
577579
np.dot(np.eye(4), np.linalg.inv(affine)))
578-
check_tractogram(tractogram,
580+
check_tractogram(transformed_tractogram,
579581
streamlines=[s*scaling for s in DATA['streamlines']],
580582
data_per_streamline=DATA['data_per_streamline'],
581583
data_per_point=DATA['data_per_point'])
582584

583585
# Apply affine again and check the affine_to_rasmm.
584-
transformed_tractogram = tractogram.apply_affine(affine)
585-
assert_array_equal(tractogram._affine_to_apply, np.dot(affine, affine))
586+
transformed_tractogram = transformed_tractogram.apply_affine(affine)
587+
assert_array_equal(transformed_tractogram._affine_to_apply,
588+
np.dot(affine, affine))
586589
assert_array_equal(transformed_tractogram.get_affine_to_rasmm(),
587590
np.dot(np.eye(4), np.dot(np.linalg.inv(affine),
588591
np.linalg.inv(affine))))
@@ -594,21 +597,21 @@ def test_tractogram_to_world(self):
594597

595598
# Apply the affine to the streamlines, then bring them back
596599
# to world space in a lazy manner.
597-
tractogram.apply_affine(affine)
598-
assert_array_equal(tractogram.get_affine_to_rasmm(),
600+
transformed_tractogram = tractogram.apply_affine(affine)
601+
assert_array_equal(transformed_tractogram.get_affine_to_rasmm(),
599602
np.linalg.inv(affine))
600603

601-
tractogram_world = tractogram.to_world()
602-
assert_true(tractogram_world is tractogram)
603-
assert_array_almost_equal(tractogram.get_affine_to_rasmm(),
604+
tractogram_world = transformed_tractogram.to_world()
605+
assert_true(tractogram_world is not transformed_tractogram)
606+
assert_array_almost_equal(tractogram_world.get_affine_to_rasmm(),
604607
np.eye(4))
605-
for s1, s2 in zip(tractogram.streamlines, DATA['streamlines']):
608+
for s1, s2 in zip(tractogram_world.streamlines, DATA['streamlines']):
606609
assert_array_almost_equal(s1, s2)
607610

608611
# Calling to_world twice should do nothing.
609-
tractogram.to_world()
610-
assert_array_almost_equal(tractogram.get_affine_to_rasmm(), np.eye(4))
611-
for s1, s2 in zip(tractogram.streamlines, DATA['streamlines']):
612+
tractogram_world = tractogram_world.to_world()
613+
assert_array_almost_equal(tractogram_world.get_affine_to_rasmm(), np.eye(4))
614+
for s1, s2 in zip(tractogram_world.streamlines, DATA['streamlines']):
612615
assert_array_almost_equal(s1, s2)
613616

614617
def test_lazy_tractogram_copy(self):

nibabel/streamlines/tractogram.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ def apply_affine(self, affine, lazy=False):
320320
----------
321321
affine : ndarray of shape (4, 4)
322322
Transformation that will be applied to every streamline.
323-
lazy_load : {False, True}, optional
323+
lazy : {False, True}, optional
324324
If True, streamlines are *not* transformed in-place and a
325325
:class:`LazyTractogram` object is returned. Otherwise, streamlines
326326
are modified in-place.
@@ -336,8 +336,7 @@ def apply_affine(self, affine, lazy=False):
336336
"""
337337
if lazy:
338338
lazy_tractogram = LazyTractogram.from_tractogram(self)
339-
lazy_tractogram.apply_affine(affine)
340-
return lazy_tractogram
339+
return lazy_tractogram.apply_affine(affine)
341340

342341
if len(self.streamlines) == 0:
343342
return self
@@ -361,7 +360,7 @@ def to_world(self, lazy=False):
361360
362361
Parameters
363362
----------
364-
lazy_load : {False, True}, optional
363+
lazy : {False, True}, optional
365364
If True, streamlines are *not* transformed in-place and a
366365
:class:`LazyTractogram` object is returned. Otherwise, streamlines
367366
are modified in-place.
@@ -641,7 +640,7 @@ def copy(self):
641640
tractogram._affine_to_apply = self._affine_to_apply.copy()
642641
return tractogram
643642

644-
def apply_affine(self, affine):
643+
def apply_affine(self, affine, lazy=True):
645644
""" Applies an affine transformation to the streamlines.
646645
647646
The transformation given by the `affine` matrix is applied after any
@@ -651,29 +650,46 @@ def apply_affine(self, affine):
651650
----------
652651
affine : 2D array (4,4)
653652
Transformation matrix that will be applied on each streamline.
653+
lazy : True, optional
654+
Should always be True for :class:`LazyTractogram` object. Doing
655+
otherwise will raise a ValueError.
654656
655657
Returns
656658
-------
657659
lazy_tractogram : :class:`LazyTractogram` object
658-
Reference to this instance of :class:`LazyTractogram`.
660+
A copy of this :class:`LazyTractogram` instance but with a
661+
transformation to be applied on the streamlines.
659662
"""
663+
if not lazy:
664+
msg = "LazyTractogram only supports lazy transformations."
665+
raise ValueError(msg)
666+
667+
tractogram = self.copy() # New instance.
668+
660669
# Update the affine that will be applied when returning streamlines.
661-
self._affine_to_apply = np.dot(affine, self._affine_to_apply)
670+
tractogram._affine_to_apply = np.dot(affine, self._affine_to_apply)
662671

663672
# Update the affine that brings back the streamlines to RASmm.
664-
self._affine_to_rasmm = np.dot(self._affine_to_rasmm,
665-
np.linalg.inv(affine))
666-
return self
673+
tractogram._affine_to_rasmm = np.dot(self._affine_to_rasmm,
674+
np.linalg.inv(affine))
675+
return tractogram
667676

668-
def to_world(self):
677+
def to_world(self, lazy=True):
669678
""" Brings the streamlines to world space (i.e. RAS+ and mm).
670679
671-
The transformation will be applied just before returning the
672-
streamlines.
680+
The transformation is applied after any other pending transformations
681+
to the streamline points.
682+
683+
Parameters
684+
----------
685+
lazy : True, optional
686+
Should always be True for :class:`LazyTractogram` object. Doing
687+
otherwise will raise a ValueError.
673688
674689
Returns
675690
-------
676691
lazy_tractogram : :class:`LazyTractogram` object
677-
Reference to this instance of :class:`LazyTractogram`.
692+
A copy of this :class:`LazyTractogram` instance but with a
693+
transformation to be applied on the streamlines.
678694
"""
679-
return self.apply_affine(self._affine_to_rasmm)
695+
return self.apply_affine(self._affine_to_rasmm, lazy=lazy)

nibabel/streamlines/trk.py

Lines changed: 45 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,43 @@ def __init__(self, fileobj, header):
270270

271271
self.file.write(self.header.tostring())
272272

273+
# `Tractogram` streamlines are in RAS+ and mm space, we will compute
274+
# the affine matrix that will bring them back to 'voxelmm' as required
275+
# by the TRK format.
276+
affine = np.eye(4)
277+
278+
# Applied the inverse of the affine found in the TRK header.
279+
# rasmm -> voxel
280+
affine = np.dot(np.linalg.inv(self.header[Field.VOXEL_TO_RASMM]),
281+
affine)
282+
283+
# If the voxel order implied by the affine does not match the voxel
284+
# order in the TRK header, change the orientation.
285+
# voxel (affine) -> voxel (header)
286+
header_ornt = asstr(self.header[Field.VOXEL_ORDER])
287+
affine_ornt = "".join(aff2axcodes(self.header[Field.VOXEL_TO_RASMM]))
288+
header_ornt = axcodes2ornt(header_ornt)
289+
affine_ornt = axcodes2ornt(affine_ornt)
290+
ornt = nib.orientations.ornt_transform(affine_ornt, header_ornt)
291+
M = nib.orientations.inv_ornt_aff(ornt, self.header[Field.DIMENSIONS])
292+
affine = np.dot(M, affine)
293+
294+
# TrackVis considers coordinate (0,0,0) to be the corner of the
295+
# voxel whereas `Tractogram` streamlines assume (0,0,0) is the
296+
# center of the voxel. Thus, streamlines are shifted of half a voxel.
297+
offset = np.eye(4)
298+
offset[:-1, -1] += 0.5
299+
affine = np.dot(offset, affine)
300+
301+
# Finally send the streamlines in mm space.
302+
# voxel -> voxelmm
303+
scale = np.eye(4)
304+
scale[range(3), range(3)] *= self.header[Field.VOXEL_SIZES]
305+
affine = np.dot(scale, affine)
306+
307+
# The TRK format uses float32 as the data type for points.
308+
self._affine_rasmm_to_voxmm = affine.astype(np.float32)
309+
273310
def write(self, tractogram):
274311
i4_dtype = np.dtype("<i4") # Always save in little-endian.
275312
f4_dtype = np.dtype("<f4") # Always save in little-endian.
@@ -350,50 +387,17 @@ def write(self, tractogram):
350387

351388
self.header['scalar_name'][i] = scalar_name
352389

353-
# `Tractogram` streamlines are in RAS+ and mm space, we will compute
354-
# the affine matrix that will bring them back to 'voxelmm' as required
355-
# by the TRK format.
356-
affine = np.eye(4)
357-
358-
# Applied the inverse of the affine found in the TRK header.
359-
# rasmm -> voxel
360-
affine = np.dot(np.linalg.inv(self.header[Field.VOXEL_TO_RASMM]),
361-
affine)
362-
363-
# If the voxel order implied by the affine does not match the voxel
364-
# order in the TRK header, change the orientation.
365-
# voxel (affine) -> voxel (header)
366-
header_ornt = asstr(self.header[Field.VOXEL_ORDER])
367-
affine_ornt = "".join(aff2axcodes(self.header[Field.VOXEL_TO_RASMM]))
368-
header_ornt = axcodes2ornt(header_ornt)
369-
affine_ornt = axcodes2ornt(affine_ornt)
370-
ornt = nib.orientations.ornt_transform(affine_ornt, header_ornt)
371-
M = nib.orientations.inv_ornt_aff(ornt, self.header[Field.DIMENSIONS])
372-
affine = np.dot(M, affine)
373-
374-
# TrackVis considers coordinate (0,0,0) to be the corner of the
375-
# voxel whereas `Tractogram` streamlines assume (0,0,0) is the
376-
# center of the voxel. Thus, streamlines are shifted of half a voxel.
377-
offset = np.eye(4)
378-
offset[:-1, -1] += 0.5
379-
affine = np.dot(offset, affine)
380-
381-
# Finally send the streamlines in mm space.
382-
# voxel -> voxelmm
383-
scale = np.eye(4)
384-
scale[range(3), range(3)] *= self.header[Field.VOXEL_SIZES]
385-
affine = np.dot(scale, affine)
386-
387-
# The TRK format uses float32 as the data type for points.
388-
affine = affine.astype(np.float32)
390+
# Make sure streamlines are in rasmm then send them to voxmm.
391+
tractogram = tractogram.to_world(lazy=True)
392+
tractogram = tractogram.apply_affine(self._affine_rasmm_to_voxmm,
393+
lazy=True)
389394

390395
for t in tractogram:
391396
if any((len(d) != len(t.streamline)
392397
for d in t.data_for_points.values())):
393398
raise DataError("Missing scalars for some points!")
394399

395-
points = apply_affine(affine,
396-
np.asarray(t.streamline, dtype=f4_dtype))
400+
points = np.asarray(t.streamline, dtype=f4_dtype)
397401
scalars = [np.asarray(t.data_for_points[k], dtype=f4_dtype)
398402
for k in data_for_points_keys]
399403
scalars = np.concatenate([np.ndarray((len(points), 0),
@@ -747,8 +751,9 @@ def _read():
747751
for name, slice_ in data_per_streamline_slice.items():
748752
tractogram.data_per_streamline[name] = properties[:, slice_]
749753

750-
# Bring tractogram to RAS+ and mm space
751-
tractogram.apply_affine(affine.astype(np.float32))
754+
# Bring tractogram to RAS+ and mm space.
755+
tractogram = tractogram.apply_affine(affine.astype(np.float32))
756+
tractogram._affine_to_rasmm = np.eye(4)
752757

753758
return cls(tractogram, header=hdr)
754759

0 commit comments

Comments
 (0)