Skip to content

Commit 9959c74

Browse files
committed
Merge pull request #391 from MarcCote/streamlines_api
MRG: Streamlines API Provides a general API to manage different streamlines file format (the code can all be found in `nibabel.streamlines.`). This is related to this somewhat outdated [BIAP5](https://github.com/nipy/nibabel/wiki/BIAP5). This new streamline API is divided in two main parts: 1. Representation of streamlines in memory (`Tractogram` and `ArraySequence` classes) 2. Loading and saving streamlines from/to different file format (`TractogramFile` class) *We use the term 'Tractogram' to refer to a set of streamlines, usually the output of a tractography algorithm.* ## ArraySequence The `ArraySequence` class needs to be explained first as it is at the heart of the streamlines representation. I guess it could be seen as a sort of jagged/ragged array to store a list of 2D arrays that have different length but a same number of dimensions (e.g. a list of list of points). For efficiency, we store a list of streamlines contiguously in memory using what we call a `ArraySequence`. This was needed as @Garyfallidis and I realized that a list of numpy arrays has too much overhead in term of memory when handling >1M streamlines (loading a tractogram of 2.5Gb could take up to 5Gb in RAM!). An `ArraySequence` behaves exactly like a `list` of `2D ndarray`. That is, it supports appending, extending, indexing (supports slicing too), iterating, etc. as a `list` object. For example, indexing a `ArraySequence` object will return a 2D `ndarray`, as one would expect, representing the 3D points of the selected streamline. Note that, while `ArraySequence` does not support deletion (i.e. no `del` nor `remove`), one can efficiently use slicing to achieve the same goal. An `ArraySequence` object can be instantiated using any iterable yielding `2D array`: ``` import numpy as np import nibabel as nib data = [np.random.rand(10, 3), np.random.rand(3, 3)] seq = nib.streamlines.ArraySequence(data) ``` Slicing a `ArraySequence` object returns a new `ArraySequence` object acting as a view (*no memory duplication*). Of course, one can use the `copy` method to "force" the memory duplication. ## Tractogram This part handles streamlines in memory using the Python class `Tractogram`. This class provides a mechanism to keep data associated to points of every streamline (`data_per_point` property). It can also keep data associated to each streamline (`data_per_streamline` property). Regarding the reference space we used for the streamlines of a tractogram live in, we decided to follow the NiBabel convention: the streamlines are assumed to be in **RAS+**, points are expressed in **millimeter** and the coordinate of a voxel (eg. (0,0,0) or (1,2,3)) refers to the **center of that voxel**. A `Tractogram` objects supports indexing, slicing and iterating. For example, indexing a `Tractogram` object will return a `TractogramItem` object, representing a single streamline with its associated data. Note that, while `Tractogram` does not support deletion (i.e. no `del` nor `remove`), one can efficiently use slicing to achieve the same goal. Again, slicing a `Tractogram` object returns a new `Tractogram` object acting as a view (*no memory duplication*). Here a small example of instantiating a `Tractogram` object: ``` import numpy as np import nibabel as nib data = [np.random.rand(10, 3), np.random.rand(3, 3)] tractogram = nib.streamlines.Tractogram(streamlines=data) colors = [(1, 0, 0), (0, 1, 0)] tractogram.data_per_streamlines['colors'] = colors ``` ## Tractogram File This part handles the saving and loading of `Tractogram` objects from/to the disk. Right now, as a proof of concept, only the TRK file format is supported (support for TCK is already in progress and will be added in a following PR). The `TractogramFile` class is abstract. Each file format should have its own class inheriting `TractogramFile` (e.g., `TrkFile`). ## Realistic example Here a small example of loading a TRK file, computing the length of each streamlines, adding that information to the tractogram and saving it back: ``` import nibabel as nib trk = nib.streamlines.load('my_streamlines.trk') from dipy.tracking.streamline import length lengths = length(trk.tractogram.streamlines) trk.tractogram.data_per_streamline['lengths'] = lengths new_trk = nib.streamlines.TrkFile(trk.tractogram, trk.header) nib.streamlines.save(new_trk, "my_streamlines.trk") ```
2 parents da0b54b + 0a22b93 commit 9959c74

29 files changed

+4294
-11
lines changed

Changelog

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ References like "pr/298" refer to github pull request numbers.
3636
are raising a DataError if the track is truncated when ``strict=True``
3737
(the default), rather than a TypeError when trying to create the points
3838
array.
39+
* New API for managing streamlines and their different file formats. This
40+
adds a new module ``nibabel.streamlines`` that will eventually deprecate
41+
the current trackvis reader found in ``nibabel.trackvis``.
3942

4043
* 2.0.2 (Monday 23 November 2015)
4144

@@ -251,7 +254,7 @@ References like "pr/298" refer to github pull request numbers.
251254
the ability to transform to the image with data closest to the cononical
252255
image orientation (first axis left-to-right, second back-to-front, third
253256
down-to-up) (MB, Jonathan Taylor)
254-
* Gifti format read and write support (preliminary) (Stephen Gerhard)
257+
* Gifti format read and write support (preliminary) (Stephen Gerhard)
255258
* Added utilities to use nipy-style data packages, by rip then edit of nipy
256259
data package code (MB)
257260
* Some improvements to release support (Jarrod Millman, MB, Fernando Perez)
@@ -469,7 +472,7 @@ visiting the URL::
469472

470473
* Removed functionality for "NiftiImage.save() raises an IOError
471474
exception when writing the image file fails." (Yaroslav Halchenko)
472-
* Added ability to force a filetype when setting the filename or saving
475+
* Added ability to force a filetype when setting the filename or saving
473476
a file.
474477
* Reverse the order of the 'header' and 'load' argument in the NiftiImage
475478
constructor. 'header' is now first as it seems to be used more often.
@@ -481,7 +484,7 @@ visiting the URL::
481484

482485
* 0.20070301.2 (Thu, 1 Mar 2007)
483486

484-
* Fixed wrong link to the source tarball in README.html.
487+
* Fixed wrong link to the source tarball in README.html.
485488

486489

487490
* 0.20070301.1 (Thu, 1 Mar 2007)

nibabel/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from .imageclasses import class_map, ext_map, all_image_classes
6565
from . import trackvis
6666
from . import mriutils
67+
from . import streamlines
6768
from . import viewers
6869

6970
# be friendly on systems with ancient numpy -- no tests, but at least
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
""" Benchmarks for load and save of streamlines
2+
3+
Run benchmarks with::
4+
5+
import nibabel as nib
6+
nib.bench()
7+
8+
If you have doctests enabled by default in nose (with a noserc file or
9+
environment variable), and you have a numpy version <= 1.6.1, this will also run
10+
the doctests, let's hope they pass.
11+
12+
Run this benchmark with:
13+
14+
nosetests -s --match '(?:^|[\\b_\\.//-])[Bb]ench' /path/to/bench_streamlines.py
15+
"""
16+
from __future__ import division, print_function
17+
18+
import numpy as np
19+
20+
from nibabel.externals.six.moves import zip
21+
from nibabel.tmpdirs import InTemporaryDirectory
22+
23+
from numpy.testing import assert_array_equal
24+
from nibabel.streamlines import Tractogram
25+
from nibabel.streamlines import TrkFile
26+
27+
import nibabel as nib
28+
import nibabel.trackvis as tv
29+
30+
from numpy.testing import measure
31+
32+
33+
def bench_load_trk():
34+
rng = np.random.RandomState(42)
35+
dtype = 'float32'
36+
NB_STREAMLINES = 5000
37+
NB_POINTS = 1000
38+
points = [rng.rand(NB_POINTS, 3).astype(dtype)
39+
for i in range(NB_STREAMLINES)]
40+
scalars = [rng.rand(NB_POINTS, 10).astype(dtype)
41+
for i in range(NB_STREAMLINES)]
42+
43+
repeat = 10
44+
45+
with InTemporaryDirectory():
46+
trk_file = "tmp.trk"
47+
tractogram = Tractogram(points, affine_to_rasmm=np.eye(4))
48+
TrkFile(tractogram).save(trk_file)
49+
50+
streamlines_old = [d[0] - 0.5
51+
for d in tv.read(trk_file, points_space="rasmm")[0]]
52+
mtime_old = measure('tv.read(trk_file, points_space="rasmm")', repeat)
53+
print("Old: Loaded {:,} streamlines in {:6.2f}".format(NB_STREAMLINES,
54+
mtime_old))
55+
56+
trk = nib.streamlines.load(trk_file, lazy_load=False)
57+
streamlines_new = trk.streamlines
58+
mtime_new = measure('nib.streamlines.load(trk_file, lazy_load=False)',
59+
repeat)
60+
print("\nNew: Loaded {:,} streamlines in {:6.2}".format(NB_STREAMLINES,
61+
mtime_new))
62+
print("Speedup of {:.2f}".format(mtime_old / mtime_new))
63+
for s1, s2 in zip(streamlines_new, streamlines_old):
64+
assert_array_equal(s1, s2)
65+
66+
# Points and scalars
67+
with InTemporaryDirectory():
68+
69+
trk_file = "tmp.trk"
70+
tractogram = Tractogram(points,
71+
data_per_point={'scalars': scalars},
72+
affine_to_rasmm=np.eye(4))
73+
TrkFile(tractogram).save(trk_file)
74+
75+
streamlines_old = [d[0] - 0.5
76+
for d in tv.read(trk_file, points_space="rasmm")[0]]
77+
78+
scalars_old = [d[1]
79+
for d in tv.read(trk_file, points_space="rasmm")[0]]
80+
mtime_old = measure('tv.read(trk_file, points_space="rasmm")', repeat)
81+
msg = "Old: Loaded {:,} streamlines with scalars in {:6.2f}"
82+
print(msg.format(NB_STREAMLINES, mtime_old))
83+
84+
trk = nib.streamlines.load(trk_file, lazy_load=False)
85+
scalars_new = trk.tractogram.data_per_point['scalars']
86+
mtime_new = measure('nib.streamlines.load(trk_file, lazy_load=False)',
87+
repeat)
88+
msg = "New: Loaded {:,} streamlines with scalars in {:6.2f}"
89+
print(msg.format(NB_STREAMLINES, mtime_new))
90+
print("Speedup of {:2f}".format(mtime_old / mtime_new))
91+
for s1, s2 in zip(scalars_new, scalars_old):
92+
assert_array_equal(s1, s2)
93+
94+
if __name__ == '__main__':
95+
bench_load_trk()

nibabel/externals/six.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ class _MovedItems(types.ModuleType):
143143
MovedAttribute("StringIO", "StringIO", "io"),
144144
MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"),
145145
MovedAttribute("zip", "itertools", "builtins", "izip", "zip"),
146+
MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"),
146147

147148
MovedModule("builtins", "__builtin__"),
148149
MovedModule("configparser", "ConfigParser"),

nibabel/streamlines/__init__.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import os
2+
import warnings
3+
from ..externals.six import string_types
4+
5+
from .header import Field
6+
from .array_sequence import ArraySequence
7+
from .tractogram import Tractogram, LazyTractogram
8+
from .tractogram_file import ExtensionWarning
9+
10+
from .trk import TrkFile
11+
12+
# List of all supported formats
13+
FORMATS = {".trk": TrkFile}
14+
15+
16+
def is_supported(fileobj):
17+
""" Checks if the file-like object if supported by NiBabel.
18+
19+
Parameters
20+
----------
21+
fileobj : string or file-like object
22+
If string, a filename; otherwise an open file-like object pointing
23+
to a streamlines file (and ready to read from the beginning of the
24+
header)
25+
26+
Returns
27+
-------
28+
is_supported : boolean
29+
"""
30+
return detect_format(fileobj) is not None
31+
32+
33+
def detect_format(fileobj):
34+
""" Returns the StreamlinesFile object guessed from the file-like object.
35+
36+
Parameters
37+
----------
38+
fileobj : string or file-like object
39+
If string, a filename; otherwise an open file-like object pointing
40+
to a tractogram file (and ready to read from the beginning of the
41+
header)
42+
43+
Returns
44+
-------
45+
tractogram_file : :class:`TractogramFile` class
46+
The class type guessed from the content of `fileobj`.
47+
"""
48+
for format in FORMATS.values():
49+
try:
50+
if format.is_correct_format(fileobj):
51+
return format
52+
except IOError:
53+
pass
54+
55+
if isinstance(fileobj, string_types):
56+
_, ext = os.path.splitext(fileobj)
57+
return FORMATS.get(ext.lower())
58+
59+
return None
60+
61+
62+
def load(fileobj, lazy_load=False):
63+
""" Loads streamlines in *RAS+* and *mm* space from a file-like object.
64+
65+
Parameters
66+
----------
67+
fileobj : string or file-like object
68+
If string, a filename; otherwise an open file-like object
69+
pointing to a streamlines file (and ready to read from the beginning
70+
of the streamlines file's header).
71+
lazy_load : {False, True}, optional
72+
If True, load streamlines in a lazy manner i.e. they will not be kept
73+
in memory and only be loaded when needed.
74+
Otherwise, load all streamlines in memory.
75+
76+
Returns
77+
-------
78+
tractogram_file : :class:`TractogramFile` object
79+
Returns an instance of a :class:`TractogramFile` containing data and
80+
metadata of the tractogram loaded from `fileobj`.
81+
82+
Notes
83+
-----
84+
The streamline coordinate (0,0,0) refers to the center of the voxel.
85+
"""
86+
tractogram_file = detect_format(fileobj)
87+
88+
if tractogram_file is None:
89+
raise ValueError("Unknown format for 'fileobj': {}".format(fileobj))
90+
91+
return tractogram_file.load(fileobj, lazy_load=lazy_load)
92+
93+
94+
def save(tractogram, filename, **kwargs):
95+
""" Saves a tractogram to a file.
96+
97+
Parameters
98+
----------
99+
tractogram : :class:`Tractogram` object or :class:`TractogramFile` object
100+
If :class:`Tractogram` object, the file format will be guessed from
101+
`filename` and a :class:`TractogramFile` object will be created using
102+
provided keyword arguments.
103+
If :class:`TractogramFile` object, the file format is known and will
104+
be used to save its content to `filename`.
105+
filename : str
106+
Name of the file where the tractogram will be saved.
107+
\*\*kwargs : keyword arguments
108+
Keyword arguments passed to :class:`TractogramFile` constructor.
109+
Should not be specified if `tractogram` is already an instance of
110+
:class:`TractogramFile`.
111+
"""
112+
tractogram_file_class = detect_format(filename)
113+
if isinstance(tractogram, Tractogram):
114+
if tractogram_file_class is None:
115+
msg = "Unknown tractogram file format: '{}'".format(filename)
116+
raise ValueError(msg)
117+
118+
tractogram_file = tractogram_file_class(tractogram, **kwargs)
119+
120+
else: # Assume it's a TractogramFile object.
121+
tractogram_file = tractogram
122+
if (tractogram_file_class is None or
123+
not isinstance(tractogram_file, tractogram_file_class)):
124+
msg = ("The extension you specified is unusual for the provided"
125+
" 'TractogramFile' object.")
126+
warnings.warn(msg, ExtensionWarning)
127+
128+
if len(kwargs) > 0:
129+
msg = ("A 'TractogramFile' object was provided, no need for"
130+
" keyword arguments.")
131+
raise ValueError(msg)
132+
133+
tractogram_file.save(filename)

0 commit comments

Comments
 (0)