Skip to content

Commit 8a64bf8

Browse files
committed
rf: converging i/o to a base, common API - updated tests
1 parent 65c49cd commit 8a64bf8

14 files changed

+185
-158
lines changed

nitransforms/io/afni.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ def from_string(cls, string):
6464
if '3dvolreg matrices' in lines[0]:
6565
lines = lines[1:] # Drop header
6666

67-
parameters = np.eye(4, dtype='f4')
68-
parameters[:3, :] = np.genfromtxt(
69-
[lines[0].encode()], dtype=cls.dtype['parameters'])
67+
parameters = np.vstack((
68+
np.genfromtxt([lines[0].encode()],
69+
dtype='f8').reshape((3, 4)),
70+
(0., 0., 0., 1.)))
7071
sa['parameters'] = parameters
7172
return tf
7273

nitransforms/io/base.py

+14
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ def to_ras(self):
6868
"""Return a nitransforms internal RAS+ matrix."""
6969
raise NotImplementedError
7070

71+
@classmethod
72+
def from_filename(cls, filename):
73+
"""Read the struct from a file given its path."""
74+
with open(str(filename)) as f:
75+
string = f.read()
76+
return cls.from_string(string)
77+
7178
@classmethod
7279
def from_fileobj(cls, fileobj, check=True):
7380
"""Read the struct from a file object."""
@@ -127,6 +134,13 @@ def to_string(self):
127134
"""Convert to a string directly writeable to file."""
128135
raise NotImplementedError
129136

137+
@classmethod
138+
def from_filename(cls, filename):
139+
"""Read the struct from a file given its path."""
140+
with open(str(filename)) as f:
141+
string = f.read()
142+
return cls.from_string(string)
143+
130144
@classmethod
131145
def from_fileobj(cls, fileobj, check=True):
132146
"""Read the struct from a file object."""

nitransforms/io/fsl.py

+6-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"""Read/write FSL's transforms."""
2-
from io import StringIO
32
import numpy as np
43
from nibabel.affines import voxel_sizes
54

@@ -11,16 +10,13 @@ class FSLLinearTransform(LinearParameters):
1110

1211
def __str__(self):
1312
"""Generate a string representation."""
14-
param = self.structarr['parameters']
15-
return '\t'.join(['%g' % p for p in param[:3, :].reshape(-1)])
13+
lines = [' '.join(['%g' % col for col in row])
14+
for row in self.structarr['parameters']]
15+
return '\n'.join(lines + [''])
1616

1717
def to_string(self):
1818
"""Convert to a string directly writeable to file."""
19-
with StringIO() as f:
20-
np.savetxt(f, self.structarr['parameters'],
21-
delimiter=' ', fmt='%g')
22-
string = f.getvalue()
23-
return string
19+
return self.__str__()
2420

2521
@classmethod
2622
def from_ras(cls, ras, moving, reference):
@@ -48,8 +44,8 @@ def from_string(cls, string):
4844
"""Read the struct from string."""
4945
tf = cls()
5046
sa = tf.structarr
51-
parameters = np.genfromtxt(string, dtype=cls.dtype['parameters'])
52-
sa['parameters'] = parameters
47+
sa['parameters'] = np.genfromtxt(
48+
[string], dtype=cls.dtype['parameters'])
5349
return tf
5450

5551

nitransforms/io/itk.py

+11
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ def from_binary(cls, byte_stream):
180180
"""Read the struct from a matlab binary file."""
181181
raise TransformFileError("Please use the ITK's new .h5 format.")
182182

183+
@classmethod
184+
def from_filename(cls, filename):
185+
"""Read the struct from a file given its path."""
186+
if str(filename).endswith('.mat'):
187+
with open(str(filename), 'b') as f:
188+
return cls.from_binary(f)
189+
190+
with open(str(filename)) as f:
191+
string = f.read()
192+
return cls.from_string(string)
193+
183194
@classmethod
184195
def from_fileobj(cls, fileobj, check=True):
185196
"""Read the struct from a file object."""

nitransforms/io/lta.py

+81-54
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from nibabel.volumeutils import Recoder
44
from nibabel.affines import voxel_sizes
55

6-
from .base import StringBasedStruct, TransformFileError
6+
from .base import BaseLinearTransformList, StringBasedStruct, TransformFileError
77

88

99
transform_codes = Recoder((
@@ -16,6 +16,8 @@
1616

1717

1818
class VolumeGeometry(StringBasedStruct):
19+
"""Data structure for regularly gridded images."""
20+
1921
template_dtype = np.dtype([
2022
('valid', 'i4'), # Valid values: 0, 1
2123
('volume', 'i4', (3, 1)), # width, height, depth
@@ -28,6 +30,7 @@ class VolumeGeometry(StringBasedStruct):
2830
dtype = template_dtype
2931

3032
def as_affine(self):
33+
"""Return the internal affine of this regular grid."""
3134
affine = np.eye(4)
3235
sa = self.structarr
3336
A = np.hstack((sa['xras'], sa['yras'], sa['zras'])) * sa['voxelsize']
@@ -36,7 +39,8 @@ def as_affine(self):
3639
affine[:3, [3]] = b
3740
return affine
3841

39-
def to_string(self):
42+
def __str__(self):
43+
"""Format the structure as a text file."""
4044
sa = self.structarr
4145
lines = [
4246
'valid = {} # volume info {:s}valid'.format(
@@ -52,8 +56,13 @@ def to_string(self):
5256
]
5357
return '\n'.join(lines)
5458

59+
def to_string(self):
60+
"""Format the structure as a text file."""
61+
return self.__str__()
62+
5563
@classmethod
5664
def from_image(klass, img):
65+
"""Create struct from an image."""
5766
volgeom = klass()
5867
sa = volgeom.structarr
5968
sa['valid'] = 1
@@ -75,6 +84,7 @@ def from_image(klass, img):
7584

7685
@classmethod
7786
def from_string(klass, string):
87+
"""Create a volume structure off of text."""
7888
volgeom = klass()
7989
sa = volgeom.structarr
8090
lines = string.splitlines()
@@ -83,15 +93,21 @@ def from_string(klass, string):
8393
label, valstring = lines.pop(0).split(' =')
8494
assert label.strip() == key
8595

86-
val = np.genfromtxt([valstring.encode()],
87-
dtype=klass.dtype[key])
88-
sa[key] = val.reshape(sa[key].shape) if val.size else ''
89-
96+
val = ''
97+
if valstring.strip():
98+
parsed = np.genfromtxt([valstring.encode()], autostrip=True,
99+
dtype=klass.dtype[key])
100+
if parsed.size:
101+
val = parsed.reshape(sa[key].shape)
102+
sa[key] = val
90103
return volgeom
91104

92105

93106
class LinearTransform(StringBasedStruct):
107+
"""Represents a single LTA's transform structure."""
108+
94109
template_dtype = np.dtype([
110+
('type', 'i4'),
95111
('mean', 'f4', (3, 1)), # x0, y0, z0
96112
('sigma', 'f4'),
97113
('m_L', 'f8', (4, 4)),
@@ -103,12 +119,53 @@ class LinearTransform(StringBasedStruct):
103119
dtype = template_dtype
104120

105121
def __getitem__(self, idx):
122+
"""Implement dictionary access."""
106123
val = super(LinearTransform, self).__getitem__(idx)
107124
if idx in ('src', 'dst'):
108125
val = VolumeGeometry(val)
109126
return val
110127

128+
def set_type(self, new_type):
129+
"""
130+
Convert the internal transformation matrix to a different type inplace.
131+
132+
Parameters
133+
----------
134+
new_type : str, int
135+
Tranformation type
136+
137+
"""
138+
sa = self.structarr
139+
src = VolumeGeometry(sa['src'])
140+
dst = VolumeGeometry(sa['dst'])
141+
current = sa['type']
142+
if isinstance(new_type, str):
143+
new_type = transform_codes.code[new_type]
144+
145+
if current == new_type:
146+
return
147+
148+
# VOX2VOX -> RAS2RAS
149+
if (current, new_type) == (0, 1):
150+
M = dst.as_affine().dot(sa['m_L'].dot(np.linalg.inv(src.as_affine())))
151+
sa['m_L'] = M
152+
sa['type'] = new_type
153+
return
154+
155+
raise NotImplementedError(
156+
"Converting {0} to {1} is not yet available".format(
157+
transform_codes.label[current],
158+
transform_codes.label[new_type]
159+
)
160+
)
161+
162+
def to_ras(self):
163+
"""Return a nitransforms internal RAS+ matrix."""
164+
self.set_type(1)
165+
return self.structarr['m_L']
166+
111167
def to_string(self):
168+
"""Convert this transform to text."""
112169
sa = self.structarr
113170
lines = [
114171
'mean = {:6.4f} {:6.4f} {:6.4f}'.format(
@@ -120,14 +177,15 @@ def to_string(self):
120177
('{:18.15e} ' * 4).format(*sa['m_L'][2]),
121178
('{:18.15e} ' * 4).format(*sa['m_L'][3]),
122179
'src volume info',
123-
self['src'].to_string(),
180+
'%s' % self['src'],
124181
'dst volume info',
125-
self['dst'].to_string(),
182+
'%s' % self['dst'],
126183
]
127184
return '\n'.join(lines)
128185

129186
@classmethod
130187
def from_string(klass, string):
188+
"""Read a transform from text."""
131189
lt = klass()
132190
sa = lt.structarr
133191
lines = string.splitlines()
@@ -151,31 +209,32 @@ def from_string(klass, string):
151209
return lt
152210

153211

154-
class LinearTransformArray(StringBasedStruct):
212+
class LinearTransformArray(BaseLinearTransformList):
213+
"""A list of linear transforms generated by FreeSurfer."""
214+
155215
template_dtype = np.dtype([
156216
('type', 'i4'),
157217
('nxforms', 'i4'),
158218
('subject', 'U1024'),
159219
('fscale', 'f4')])
160220
dtype = template_dtype
161-
_xforms = None
162-
163-
def __init__(self,
164-
binaryblock=None,
165-
endianness=None,
166-
check=True):
167-
super(LinearTransformArray, self).__init__(binaryblock, endianness, check)
168-
self._xforms = [LinearTransform()
169-
for _ in range(self.structarr['nxforms'])]
221+
_inner_type = LinearTransform
170222

171223
def __getitem__(self, idx):
224+
"""Allow dictionary access to the transforms."""
172225
if idx == 'xforms':
173226
return self._xforms
174227
if idx == 'nxforms':
175228
return len(self._xforms)
176-
return super(LinearTransformArray, self).__getitem__(idx)
229+
return self.structarr[idx]
230+
231+
def to_ras(self, moving=None, reference=None):
232+
"""Set type to RAS2RAS and return the new matrix."""
233+
self.structarr['type'] = 1
234+
return [xfm.to_ras() for xfm in self.xforms]
177235

178236
def to_string(self):
237+
"""Convert this LTA into text format."""
179238
code = int(self['type'])
180239
header = [
181240
'type = {} # {}'.format(code, transform_codes.label[code]),
@@ -188,6 +247,7 @@ def to_string(self):
188247

189248
@classmethod
190249
def from_string(klass, string):
250+
"""Read this LTA from a text string."""
191251
lta = klass()
192252
sa = lta.structarr
193253
lines = [l.strip() for l in string.splitlines()
@@ -203,7 +263,8 @@ def from_string(klass, string):
203263
sa[key] = val.reshape(sa[key].shape) if val.size else ''
204264
for _ in range(sa['nxforms']):
205265
lta._xforms.append(
206-
LinearTransform.from_string('\n'.join(lines[:25])))
266+
klass._inner_type.from_string('\n'.join(lines[:25])))
267+
lta._xforms[-1].structarr['type'] = sa['type']
207268
lines = lines[25:]
208269
if lines:
209270
for key in ('subject', 'fscale'):
@@ -223,37 +284,3 @@ def from_string(klass, string):
223284

224285
assert len(lta._xforms) == sa['nxforms']
225286
return lta
226-
227-
@classmethod
228-
def from_fileobj(klass, fileobj, check=True):
229-
return klass.from_string(fileobj.read())
230-
231-
def set_type(self, target):
232-
"""
233-
Convert the internal transformation matrix to a different type inplace
234-
235-
Parameters
236-
----------
237-
target : str, int
238-
Tranformation type
239-
"""
240-
assert self['nxforms'] == 1, "Cannot convert multiple transformations"
241-
xform = self['xforms'][0]
242-
src = xform['src']
243-
dst = xform['dst']
244-
current = self['type']
245-
if isinstance(target, str):
246-
target = transform_codes.code[target]
247-
248-
# VOX2VOX -> RAS2RAS
249-
if current == 0 and target == 1:
250-
M = dst.as_affine().dot(xform['m_L'].dot(np.linalg.inv(src.as_affine())))
251-
else:
252-
raise NotImplementedError(
253-
"Converting {0} to {1} is not yet available".format(
254-
transform_codes.label[current],
255-
transform_codes.label[target]
256-
)
257-
)
258-
xform['m_L'] = M
259-
self['type'] = target

nitransforms/linear.py

+15-25
Original file line numberDiff line numberDiff line change
@@ -174,30 +174,20 @@ def to_filename(self, filename, fmt='X5', moving=None):
174174

175175
raise NotImplementedError
176176

177+
@classmethod
178+
def from_filename(cls, filename, fmt='X5',
179+
reference=None, moving=None):
180+
"""Create an affine from a transform file."""
181+
if fmt.lower() in ('itk', 'ants', 'elastix'):
182+
_factory = io.itk.ITKLinearTransformArray
183+
elif fmt.lower() in ('lta', 'fs'):
184+
_factory = io.LinearTransformArray
185+
else:
186+
raise NotImplementedError
177187

178-
def load(filename, fmt='X5', reference=None):
179-
"""Load a linear transform."""
180-
if fmt.lower() in ('itk', 'ants', 'elastix'):
181-
with open(filename) as itkfile:
182-
itkxfm = io.itk.ITKLinearTransformArray.from_fileobj(
183-
itkfile)
184-
matrix = itkxfm.to_ras()
185-
# elif fmt.lower() == 'afni':
186-
# parameters = LPS.dot(self.matrix.dot(LPS))
187-
# parameters = parameters[:3, :].reshape(-1).tolist()
188-
elif fmt.lower() == 'fs':
189-
with open(filename) as ltafile:
190-
lta = io.LinearTransformArray.from_fileobj(ltafile)
191-
if lta['nxforms'] > 1:
192-
raise NotImplementedError("Multiple transforms are not yet supported.")
193-
if lta['type'] != 1:
194-
# To make transforms generalize across use-cases, LTA transforms
195-
# are converted to RAS-to-RAS.
196-
lta.set_type(1)
197-
matrix = lta['xforms'][0]['m_L']
198-
elif fmt.lower() in ('x5', 'bids'):
199-
raise NotImplementedError
200-
else:
201-
raise NotImplementedError
188+
struct = _factory.from_filename(filename)
189+
matrix = struct.to_ras(reference=reference, moving=moving)
190+
191+
return cls(matrix, reference=reference)
202192

203-
return Affine(matrix, reference=reference)
193+
load = Affine.from_filename

0 commit comments

Comments
 (0)