Skip to content

Commit 9caabb9

Browse files
committed
Merge remote-tracking branch 'upstream/master' into enh/cifti2
* upstream/master: (23 commits) Better test. Fix. Test for new quad file. Fix to surface reading with new quad. TST: add freesurfer quad file for testing FIX: typo FIX: Copy affine_to_rasmm when slicing a Tractogram object Added a test. Test coverage. General format. Fix. String to bytes. _serialize_volume_info Address comments. Fixes. Changed from strings to np.arrays. Allow head == 20. Fix Reading of extra info. don't count warnings address comments ...
2 parents 97942fa + 2a9e96f commit 9caabb9

File tree

5 files changed

+183
-18
lines changed

5 files changed

+183
-18
lines changed

nibabel-data/nitest-freesurfer

Submodule nitest-freesurfer updated from df9ccc6 to 0d30786

nibabel/freesurfer/io.py

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from __future__ import division, print_function, absolute_import
22

3+
import warnings
34
import numpy as np
45
import getpass
56
import time
67

7-
8+
from ..externals import OrderedDict
89
from ..externals.six.moves import xrange
910
from ..openers import Opener
1011

@@ -44,28 +45,79 @@ def _fread3_many(fobj, n):
4445
return (b1 << 16) + (b2 << 8) + b3
4546

4647

47-
def read_geometry(filepath):
48+
def _read_volume_info(fobj):
49+
"""Helper for reading the footer from a surface file."""
50+
volume_info = OrderedDict()
51+
head = np.fromfile(fobj, '>i4', 1)
52+
if not np.array_equal(head, [20]): # Read two bytes more
53+
head = np.concatenate([head, np.fromfile(fobj, '>i4', 2)])
54+
if not np.array_equal(head, [2, 0, 20]):
55+
warnings.warn("Unknown extension code.")
56+
return volume_info
57+
58+
volume_info['head'] = head
59+
for key in ['valid', 'filename', 'volume', 'voxelsize', 'xras', 'yras',
60+
'zras', 'cras']:
61+
pair = fobj.readline().decode('utf-8').split('=')
62+
if pair[0].strip() != key or len(pair) != 2:
63+
raise IOError('Error parsing volume info.')
64+
if key in ('valid', 'filename'):
65+
volume_info[key] = pair[1].strip()
66+
elif key == 'volume':
67+
volume_info[key] = np.array(pair[1].split()).astype(int)
68+
else:
69+
volume_info[key] = np.array(pair[1].split()).astype(float)
70+
# Ignore the rest
71+
return volume_info
72+
73+
74+
def read_geometry(filepath, read_metadata=False, read_stamp=False):
4875
"""Read a triangular format Freesurfer surface mesh.
4976
5077
Parameters
5178
----------
5279
filepath : str
5380
Path to surface file
81+
read_metadata : bool
82+
Read metadata as key-value pairs.
83+
Valid keys:
84+
* 'head' : array of int
85+
* 'valid' : str
86+
* 'filename' : str
87+
* 'volume' : array of int, shape (3,)
88+
* 'voxelsize' : array of float, shape (3,)
89+
* 'xras' : array of float, shape (3,)
90+
* 'yras' : array of float, shape (3,)
91+
* 'zras' : array of float, shape (3,)
92+
* 'cras' : array of float, shape (3,)
93+
read_stamp : bool
94+
Return the comment from the file
5495
5596
Returns
5697
-------
5798
coords : numpy array
5899
nvtx x 3 array of vertex (x, y, z) coordinates
59100
faces : numpy array
60101
nfaces x 3 array of defining mesh triangles
102+
volume_info : OrderedDict
103+
If read_metadata is true, key-value pairs found in the geometry file
104+
create_stamp : str
105+
If read_stamp is true, the comment added by the program that saved
106+
the file
61107
"""
108+
volume_info = OrderedDict()
109+
110+
TRIANGLE_MAGIC = 16777214
111+
QUAD_MAGIC = 16777215
112+
NEW_QUAD_MAGIC = 16777213
62113
with open(filepath, "rb") as fobj:
63114
magic = _fread3(fobj)
64-
if magic == 16777215: # Quad file
115+
if magic in (QUAD_MAGIC, NEW_QUAD_MAGIC): # Quad file
65116
nvert = _fread3(fobj)
66117
nquad = _fread3(fobj)
67-
coords = np.fromfile(fobj, ">i2", nvert * 3).astype(np.float)
68-
coords = coords.reshape(-1, 3) / 100.0
118+
(fmt, div) = (">i2", 100.) if magic == QUAD_MAGIC else (">f4", 1.)
119+
coords = np.fromfile(fobj, fmt, nvert * 3).astype(np.float) / div
120+
coords = coords.reshape(-1, 3)
69121
quads = _fread3_many(fobj, nquad * 4)
70122
quads = quads.reshape(nquad, 4)
71123
#
@@ -85,21 +137,34 @@ def read_geometry(filepath):
85137
faces[nface] = quad[0], quad[2], quad[3]
86138
nface += 1
87139

88-
elif magic == 16777214: # Triangle file
89-
fobj.readline() # create_stamp
140+
elif magic == TRIANGLE_MAGIC: # Triangle file
141+
create_stamp = fobj.readline().rstrip(b'\n').decode('utf-8')
90142
fobj.readline()
91143
vnum = np.fromfile(fobj, ">i4", 1)[0]
92144
fnum = np.fromfile(fobj, ">i4", 1)[0]
93145
coords = np.fromfile(fobj, ">f4", vnum * 3).reshape(vnum, 3)
94146
faces = np.fromfile(fobj, ">i4", fnum * 3).reshape(fnum, 3)
147+
148+
if read_metadata:
149+
volume_info = _read_volume_info(fobj)
95150
else:
96151
raise ValueError("File does not appear to be a Freesurfer surface")
97152

98153
coords = coords.astype(np.float) # XXX: due to mayavi bug on mac 32bits
99-
return coords, faces
100154

155+
ret = (coords, faces)
156+
if read_metadata:
157+
if len(volume_info) == 0:
158+
warnings.warn('No volume information contained in the file')
159+
ret += (volume_info,)
160+
if read_stamp:
161+
ret += (create_stamp,)
101162

102-
def write_geometry(filepath, coords, faces, create_stamp=None):
163+
return ret
164+
165+
166+
def write_geometry(filepath, coords, faces, create_stamp=None,
167+
volume_info=None):
103168
"""Write a triangular format Freesurfer surface mesh.
104169
105170
Parameters
@@ -112,6 +177,19 @@ def write_geometry(filepath, coords, faces, create_stamp=None):
112177
nfaces x 3 array of defining mesh triangles
113178
create_stamp : str
114179
User/time stamp (default: "created by <user> on <ctime>")
180+
volume_info : dict-like or None
181+
Key-value pairs to encode at the end of the file.
182+
Valid keys:
183+
* 'head' : array of int
184+
* 'valid' : str
185+
* 'filename' : str
186+
* 'volume' : array of int, shape (3,)
187+
* 'voxelsize' : array of float, shape (3,)
188+
* 'xras' : array of float, shape (3,)
189+
* 'yras' : array of float, shape (3,)
190+
* 'zras' : array of float, shape (3,)
191+
* 'cras' : array of float, shape (3,)
192+
115193
"""
116194
magic_bytes = np.array([255, 255, 254], dtype=np.uint8)
117195

@@ -129,6 +207,10 @@ def write_geometry(filepath, coords, faces, create_stamp=None):
129207
coords.astype('>f4').reshape(-1).tofile(fobj)
130208
faces.astype('>i4').reshape(-1).tofile(fobj)
131209

210+
# Add volume info, if given
211+
if volume_info is not None and len(volume_info) > 0:
212+
fobj.write(_serialize_volume_info(volume_info))
213+
132214

133215
def read_morph_data(filepath):
134216
"""Read a Freesurfer morphometry data file.
@@ -369,3 +451,32 @@ def read_label(filepath, read_scalars=False):
369451
scalar_array = np.loadtxt(filepath, skiprows=2, usecols=[-1])
370452
return label_array, scalar_array
371453
return label_array
454+
455+
456+
def _serialize_volume_info(volume_info):
457+
"""Helper for serializing the volume info."""
458+
keys = ['head', 'valid', 'filename', 'volume', 'voxelsize', 'xras', 'yras',
459+
'zras', 'cras']
460+
diff = set(volume_info.keys()).difference(keys)
461+
if len(diff) > 0:
462+
raise ValueError('Invalid volume info: %s.' % diff.pop())
463+
464+
strings = list()
465+
for key in keys:
466+
if key == 'head':
467+
if not (np.array_equal(volume_info[key], [20]) or np.array_equal(
468+
volume_info[key], [2, 0, 20])):
469+
warnings.warn("Unknown extension code.")
470+
strings.append(np.array(volume_info[key], dtype='>i4').tostring())
471+
elif key in ('valid', 'filename'):
472+
val = volume_info[key]
473+
strings.append('{0} = {1}\n'.format(key, val).encode('utf-8'))
474+
elif key == 'volume':
475+
val = volume_info[key]
476+
strings.append('{0} = {1} {2} {3}\n'.format(
477+
key, val[0], val[1], val[2]).encode('utf-8'))
478+
else:
479+
val = volume_info[key]
480+
strings.append('{0} = {1:0.10g} {2:0.10g} {3:0.10g}\n'.format(
481+
key.ljust(6), val[0], val[1], val[2]).encode('utf-8'))
482+
return b''.join(strings)

nibabel/freesurfer/tests/test_io.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,21 @@
44
import getpass
55
import time
66
import hashlib
7+
import warnings
78

89

910
from ...tmpdirs import InTemporaryDirectory
1011

1112
from nose.tools import assert_true
1213
import numpy as np
13-
from numpy.testing import assert_equal, assert_raises, dec
14+
from numpy.testing import assert_equal, assert_raises, dec, assert_allclose
1415

1516
from .. import (read_geometry, read_morph_data, read_annot, read_label,
1617
write_geometry, write_morph_data, write_annot)
1718

18-
from ...tests.nibabel_data import get_nibabel_data
19+
from ...tests.nibabel_data import get_nibabel_data, needs_nibabel_data
1920
from ...fileslice import strided_scalar
20-
21+
from ...testing import clear_and_catch_warnings
2122

2223
DATA_SDIR = 'fsaverage'
2324

@@ -54,26 +55,53 @@ def test_geometry():
5455
assert_equal(0, faces.min())
5556
assert_equal(coords.shape[0], faces.max() + 1)
5657

57-
# Test quad with sphere
5858
surf_path = pjoin(data_path, "surf", "%s.%s" % ("lh", "sphere"))
59-
coords, faces = read_geometry(surf_path)
59+
coords, faces, volume_info, create_stamp = read_geometry(
60+
surf_path, read_metadata=True, read_stamp=True)
61+
6062
assert_equal(0, faces.min())
6163
assert_equal(coords.shape[0], faces.max() + 1)
64+
assert_equal(9, len(volume_info))
65+
assert_equal([2, 0, 20], volume_info['head'])
66+
assert_equal(u'created by greve on Thu Jun 8 19:17:51 2006',
67+
create_stamp)
6268

6369
# Test equivalence of freesurfer- and nibabel-generated triangular files
6470
# with respect to read_geometry()
6571
with InTemporaryDirectory():
6672
surf_path = 'test'
6773
create_stamp = "created by %s on %s" % (getpass.getuser(),
6874
time.ctime())
69-
write_geometry(surf_path, coords, faces, create_stamp)
75+
volume_info['cras'] = [1., 2., 3.]
76+
write_geometry(surf_path, coords, faces, create_stamp, volume_info)
7077

71-
coords2, faces2 = read_geometry(surf_path)
78+
coords2, faces2, volume_info2 = \
79+
read_geometry(surf_path, read_metadata=True)
7280

81+
for key in ('xras', 'yras', 'zras', 'cras'):
82+
assert_allclose(volume_info2[key], volume_info[key],
83+
rtol=1e-7, atol=1e-30)
84+
assert_equal(volume_info2['cras'], volume_info['cras'])
7385
with open(surf_path, 'rb') as fobj:
7486
np.fromfile(fobj, ">u1", 3)
7587
read_create_stamp = fobj.readline().decode().rstrip('\n')
7688

89+
# now write an incomplete file
90+
write_geometry(surf_path, coords, faces)
91+
with clear_and_catch_warnings() as w:
92+
warnings.filterwarnings('always', category=DeprecationWarning)
93+
read_geometry(surf_path, read_metadata=True)
94+
assert_true(any('volume information contained' in str(ww.message)
95+
for ww in w))
96+
assert_true(any('extension code' in str(ww.message) for ww in w))
97+
volume_info['head'] = [1, 2]
98+
with clear_and_catch_warnings() as w:
99+
write_geometry(surf_path, coords, faces, create_stamp, volume_info)
100+
assert_true(any('Unknown extension' in str(ww.message) for ww in w))
101+
volume_info['a'] = 0
102+
assert_raises(ValueError, write_geometry, surf_path, coords,
103+
faces, create_stamp, volume_info)
104+
77105
assert_equal(create_stamp, read_create_stamp)
78106

79107
np.testing.assert_array_equal(coords, coords2)
@@ -86,6 +114,23 @@ def test_geometry():
86114
np.testing.assert_array_equal(faces_swapped, faces)
87115

88116

117+
@freesurfer_test
118+
@needs_nibabel_data('nitest-freesurfer')
119+
def test_quad_geometry():
120+
"""Test IO of freesurfer quad files."""
121+
new_quad = pjoin(get_nibabel_data(), 'nitest-freesurfer', 'subjects',
122+
'bert', 'surf', 'lh.inflated.nofix')
123+
coords, faces = read_geometry(new_quad)
124+
assert_equal(0, faces.min())
125+
assert_equal(coords.shape[0], faces.max() + 1)
126+
with InTemporaryDirectory():
127+
new_path = 'test'
128+
write_geometry(new_path, coords, faces)
129+
coords2, faces2 = read_geometry(new_path)
130+
assert_equal(coords, coords2)
131+
assert_equal(faces, faces2)
132+
133+
89134
@freesurfer_test
90135
def test_morph_data():
91136
"""Test IO of morphometry data file (eg. curvature)."""

nibabel/streamlines/tests/test_tractogram.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
def setup():
2020
global DATA
21+
DATA['rng'] = np.random.RandomState(1234)
2122
DATA['streamlines'] = [np.arange(1*3, dtype="f4").reshape((1, 3)),
2223
np.arange(2*3, dtype="f4").reshape((2, 3)),
2324
np.arange(5*3, dtype="f4").reshape((5, 3))]
@@ -373,6 +374,13 @@ def test_tractogram_getitem(self):
373374
DATA['tractogram'].data_per_streamline[::-1],
374375
DATA['tractogram'].data_per_point[::-1])
375376

377+
# Make sure slicing conserves the affine_to_rasmm property.
378+
tractogram = DATA['tractogram'].copy()
379+
tractogram.affine_to_rasmm = DATA['rng'].rand(4, 4)
380+
tractogram_view = tractogram[::2]
381+
assert_array_equal(tractogram_view.affine_to_rasmm,
382+
tractogram.affine_to_rasmm)
383+
376384
def test_tractogram_add_new_data(self):
377385
# Tractogram with only streamlines
378386
t = DATA['simple_tractogram'].copy()

nibabel/streamlines/tractogram.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,8 @@ def __getitem__(self, idx):
335335
if isinstance(idx, (numbers.Integral, np.integer)):
336336
return TractogramItem(pts, data_per_streamline, data_per_point)
337337

338-
return Tractogram(pts, data_per_streamline, data_per_point)
338+
return Tractogram(pts, data_per_streamline, data_per_point,
339+
affine_to_rasmm=self.affine_to_rasmm)
339340

340341
def __len__(self):
341342
return len(self.streamlines)

0 commit comments

Comments
 (0)