Skip to content

Commit f39e6fd

Browse files
authored
Merge pull request #31 from oesteban/enh/19
ENH: Rewrite load/save utilities for ITK's MatrixOffsetBased transforms in ``io``
2 parents ef62bfb + cdc7502 commit f39e6fd

File tree

9 files changed

+270
-51
lines changed

9 files changed

+270
-51
lines changed

nitransforms/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"""
1919
from .linear import Affine
2020
from .nonlinear import DisplacementsFieldTransform
21+
from .io import itk
2122

2223

23-
__all__ = ['Affine', 'DisplacementsFieldTransform']
24+
__all__ = ['itk', 'Affine', 'DisplacementsFieldTransform']

nitransforms/io/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
2+
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
4+
#
5+
# See COPYING file distributed along with the NiBabel package for the
6+
# copyright and license terms.
7+
#
8+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
9+
"""
10+
Read and write transforms.
11+
12+
.. currentmodule:: nitransforms.io
13+
14+
.. autosummary::
15+
:toctree: ../generated
16+
17+
io
18+
"""
19+
from .lta import LinearTransform, LinearTransformArray, VolumeGeometry
20+
21+
22+
__all__ = ['LinearTransform', 'LinearTransformArray', 'VolumeGeometry']

nitransforms/io/base.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Read/write linear transforms."""
2+
import numpy as np
3+
from nibabel.wrapstruct import LabeledWrapStruct as LWS
4+
5+
6+
class LabeledWrapStruct(LWS):
7+
def __setitem__(self, item, value):
8+
self._structarr[item] = np.asanyarray(value)
9+
10+
11+
class StringBasedStruct(LabeledWrapStruct):
12+
def __init__(self,
13+
binaryblock=None,
14+
endianness=None,
15+
check=True):
16+
if binaryblock is not None and getattr(binaryblock, 'dtype',
17+
None) == self.dtype:
18+
self._structarr = binaryblock.copy()
19+
return
20+
super(StringBasedStruct, self).__init__(binaryblock, endianness, check)
21+
22+
def __array__(self):
23+
return self._structarr

nitransforms/io/itk.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Read/write ITK transforms."""
2+
import numpy as np
3+
from .base import StringBasedStruct
4+
5+
6+
class ITKLinearTransform(StringBasedStruct):
7+
"""A string-based structure for ITK linear transforms."""
8+
9+
template_dtype = np.dtype([
10+
('type', 'i4'),
11+
('index', 'i4'),
12+
('parameters', 'f4', (4, 4)),
13+
('offset', 'f4', 3), # Center of rotation
14+
])
15+
dtype = template_dtype
16+
17+
def __init__(self):
18+
"""Initialize with default offset and index."""
19+
super().__init__()
20+
self.structarr['offset'] = [0, 0, 0]
21+
self.structarr['index'] = 1
22+
self.structarr['parameters'] = np.eye(4)
23+
24+
def __str__(self):
25+
"""Generate a string representation."""
26+
sa = self.structarr
27+
lines = [
28+
'#Transform {:d}'.format(sa['index']),
29+
'Transform: MatrixOffsetTransformBase_double_3_3',
30+
'Parameters: {}'.format(' '.join(
31+
['%g' % p
32+
for p in sa['parameters'][:3, :3].reshape(-1).tolist() +
33+
sa['parameters'][:3, 3].tolist()])),
34+
'FixedParameters: {:g} {:g} {:g}'.format(*sa['offset']),
35+
'',
36+
]
37+
return '\n'.join(lines)
38+
39+
def to_string(self, banner=None):
40+
"""Convert to a string directly writeable to file."""
41+
string = '%s'
42+
43+
if banner is None:
44+
banner = self.structarr['index'] == 1
45+
46+
if banner:
47+
string = '#Insight Transform File V1.0\n%s'
48+
return string % self
49+
50+
@classmethod
51+
def from_string(klass, string):
52+
"""Read the struct from string."""
53+
tf = klass()
54+
sa = tf.structarr
55+
lines = [l for l in string.splitlines()
56+
if l.strip()]
57+
assert lines[0][0] == '#'
58+
if lines[1][0] == '#':
59+
lines = lines[1:] # Drop banner with version
60+
61+
parameters = np.eye(4, dtype='f4')
62+
sa['index'] = int(lines[0][lines[0].index('T'):].split()[1])
63+
sa['offset'] = np.genfromtxt([lines[3].split(':')[-1].encode()],
64+
dtype=klass.dtype['offset'])
65+
vals = np.genfromtxt([lines[2].split(':')[-1].encode()],
66+
dtype='f4')
67+
parameters[:3, :3] = vals[:-3].reshape((3, 3))
68+
parameters[:3, 3] = vals[-3:]
69+
sa['parameters'] = parameters
70+
return tf
71+
72+
@classmethod
73+
def from_fileobj(klass, fileobj, check=True):
74+
"""Read the struct from a file object."""
75+
return klass.from_string(fileobj.read())
76+
77+
78+
class ITKLinearTransformArray(StringBasedStruct):
79+
"""A string-based structure for series of ITK linear transforms."""
80+
81+
template_dtype = np.dtype([('nxforms', 'i4')])
82+
dtype = template_dtype
83+
_xforms = None
84+
85+
def __init__(self,
86+
xforms=None,
87+
binaryblock=None,
88+
endianness=None,
89+
check=True):
90+
"""Initialize with (optionally) a list of transforms."""
91+
super().__init__(binaryblock, endianness, check)
92+
self._xforms = []
93+
for i, mat in enumerate(xforms or []):
94+
xfm = ITKLinearTransform()
95+
xfm['parameters'] = mat
96+
xfm['index'] = i + 1
97+
self._xforms.append(xfm)
98+
99+
def __getitem__(self, idx):
100+
"""Allow dictionary access to the transforms."""
101+
if idx == 'xforms':
102+
return self._xforms
103+
if idx == 'nxforms':
104+
return len(self._xforms)
105+
return super().__getitem__(idx)
106+
107+
def to_string(self):
108+
"""Convert to a string directly writeable to file."""
109+
strings = []
110+
for i, xfm in enumerate(self._xforms):
111+
xfm.structarr['index'] = i + 1
112+
strings.append(xfm.to_string())
113+
return '\n'.join(strings)
114+
115+
@classmethod
116+
def from_string(klass, string):
117+
"""Read the struct from string."""
118+
_self = klass()
119+
lines = [l.strip() for l in string.splitlines()
120+
if l.strip()]
121+
122+
if lines[0][0] != '#' or 'Insight Transform File V1.0' not in lines[0]:
123+
raise ValueError('Unknown Insight Transform File format.')
124+
125+
string = '\n'.join(lines[1:])
126+
for xfm in string.split('#')[1:]:
127+
_self._xforms.append(ITKLinearTransform.from_string(
128+
'#%s' % xfm))
129+
return _self
130+
131+
@classmethod
132+
def from_fileobj(klass, fileobj, check=True):
133+
"""Read the struct from a file object."""
134+
return klass.from_string(fileobj.read())

nitransforms/io.py renamed to nitransforms/io/lta.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
"""Read/write linear transforms."""
12
import numpy as np
23
from nibabel.volumeutils import Recoder
34
from nibabel.affines import voxel_sizes
45

5-
from .patched import LabeledWrapStruct
6+
from .base import StringBasedStruct
7+
68

79
transform_codes = Recoder((
810
(0, 'LINEAR_VOX_TO_VOX'),
@@ -13,21 +15,6 @@
1315
fields=('code', 'label'))
1416

1517

16-
class StringBasedStruct(LabeledWrapStruct):
17-
def __init__(self,
18-
binaryblock=None,
19-
endianness=None,
20-
check=True):
21-
if binaryblock is not None and getattr(binaryblock, 'dtype',
22-
None) == self.dtype:
23-
self._structarr = binaryblock.copy()
24-
return
25-
super(StringBasedStruct, self).__init__(binaryblock, endianness, check)
26-
27-
def __array__(self):
28-
return self._structarr
29-
30-
3118
class VolumeGeometry(StringBasedStruct):
3219
template_dtype = np.dtype([
3320
('valid', 'i4'), # Valid values: 0, 1

nitransforms/linear.py

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -133,19 +133,10 @@ def _to_hdf5(self, x5_root):
133133
def to_filename(self, filename, fmt='X5', moving=None):
134134
"""Store the transform in BIDS-Transforms HDF5 file format (.x5)."""
135135
if fmt.lower() in ['itk', 'ants', 'elastix']:
136+
itkobj = io.itk.ITKLinearTransformArray(
137+
xforms=[LPS.dot(m.dot(LPS)) for m in self.matrix])
136138
with open(filename, 'w') as f:
137-
f.write('#Insight Transform File V1.0\n')
138-
139-
for i in range(self.matrix.shape[0]):
140-
parameters = LPS.dot(self.matrix[i].dot(LPS))
141-
parameters = parameters[:3, :3].reshape(-1).tolist() + \
142-
parameters[:3, 3].tolist()
143-
itkfmt = """\
144-
#Transform {0}
145-
Transform: MatrixOffsetTransformBase_double_3_3
146-
Parameters: {1}
147-
FixedParameters: 0 0 0\n""".format
148-
f.write(itkfmt(i, ' '.join(['%g' % p for p in parameters])))
139+
f.write(itkobj.to_string())
149140
return filename
150141

151142
if fmt.lower() == 'afni':
@@ -239,23 +230,16 @@ def load(filename, fmt='X5', reference=None):
239230
"""Load a linear transform."""
240231
if fmt.lower() in ['itk', 'ants', 'elastix', 'nifty']:
241232
with open(filename) as itkfile:
242-
itkxfm = itkfile.read().splitlines()
243-
244-
if 'Insight Transform File' not in itkxfm[0]:
245-
raise ValueError(
246-
"File %s does not look like an ITK transform text file." % filename)
233+
itkxfm = io.itk.ITKLinearTransformArray.from_fileobj(
234+
itkfile)
247235

248236
matlist = []
249-
for nxfm in range(len(itkxfm[1:]) // 4):
250-
parameters = np.fromstring(itkxfm[nxfm * 4 + 3].split(':')[-1].strip(),
251-
dtype=float, sep=' ')
252-
offset = np.fromstring(itkxfm[nxfm * 4 + 4].split(':')[-1].strip(),
253-
dtype=float, sep=' ')
254-
if len(parameters) == 12:
255-
matrix = from_matvec(parameters[:9].reshape((3, 3)), parameters[9:])
256-
c_neg = from_matvec(np.eye(3), offset * -1.0)
257-
c_pos = from_matvec(np.eye(3), offset)
258-
matlist.append(LPS.dot(c_pos.dot(matrix.dot(c_neg.dot(LPS)))))
237+
for xfm in itkxfm['xforms']:
238+
matrix = xfm['parameters']
239+
offset = xfm['offset']
240+
c_neg = from_matvec(np.eye(3), offset * -1.0)
241+
c_pos = from_matvec(np.eye(3), offset)
242+
matlist.append(LPS.dot(c_pos.dot(matrix.dot(c_neg.dot(LPS)))))
259243
matrix = np.stack(matlist)
260244
# elif fmt.lower() == 'afni':
261245
# parameters = LPS.dot(self.matrix.dot(LPS))

nitransforms/patched.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import numpy as np
2-
from nibabel.wrapstruct import LabeledWrapStruct as LWS
32

43

54
def shape_zoom_affine(shape, zooms, x_flip=True, y_flip=False):
@@ -64,8 +63,3 @@ def shape_zoom_affine(shape, zooms, x_flip=True, y_flip=False):
6463
aff[:3, :3] = np.diag(zooms)
6564
aff[:3, -1] = -origin * zooms
6665
return aff
67-
68-
69-
class LabeledWrapStruct(LWS):
70-
def __setitem__(self, item, value):
71-
self._structarr[item] = np.asanyarray(value)

nitransforms/tests/data/itktflist.tfm

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#Insight Transform File V1.0
2+
#Transform 1
3+
Transform: MatrixOffsetTransformBase_double_3_3
4+
Parameters: 1 0 0 0 1 0 0 0 1 0 0 0
5+
FixedParameters: 10 10 10
6+
7+
#Transform 2
8+
Transform: MatrixOffsetTransformBase_double_3_3
9+
Parameters: 1 0 0 0 1 0 0 0 1 0 0 0
10+
FixedParameters: 10 10 10
11+
12+
#Transform 3
13+
Transform: MatrixOffsetTransformBase_double_3_3
14+
Parameters: 1 0 0 0 1 0 0 0 1 0 0 0
15+
FixedParameters: 10 10 10
16+
17+
#Transform 4
18+
Transform: MatrixOffsetTransformBase_double_3_3
19+
Parameters: 1 0 0 0 1 0 0 0 1 0 0 0
20+
FixedParameters: 10 10 10
21+
22+
#Transform 5
23+
Transform: MatrixOffsetTransformBase_double_3_3
24+
Parameters: -1.53626 0.71973 -0.639856 -0.190759 -1.80082 -0.915885 0.502537 1.12532 0.275748 0.393413 1.13855 0.761131
25+
FixedParameters: -0.0993171 0.364984 1.99264
26+
27+
#Transform 6
28+
Transform: MatrixOffsetTransformBase_double_3_3
29+
Parameters: -0.130507 -1.03017 2.08189 -1.51723 1.37849 -0.0890962 -0.656323 0.242694 2.15801 -1.26689 0.367131 1.23616
30+
FixedParameters: 0.626607 0.15351 1.24982
31+
32+
#Transform 7
33+
Transform: MatrixOffsetTransformBase_double_3_3
34+
Parameters: -1.55395 -0.36383 -0.17749 1.3387 -0.384534 -0.901462 -1.06598 -0.448228 -1.07535 1.92599 0.454696 0.576697
35+
FixedParameters: -0.425602 0.333406 -1.14957
36+
37+
#Transform 8
38+
Transform: MatrixOffsetTransformBase_double_3_3
39+
Parameters: 0.723719 -1.05617 -0.800562 -2.47048 -1.76301 -1.4447 -0.749896 1.29774 -1.48893 1.02789 0.65017 -1.48326
40+
FixedParameters: 0.800882 -1.20202 1.25495
41+
42+
#Transform 9
43+
Transform: MatrixOffsetTransformBase_double_3_3
44+
Parameters: 1.24025 -0.77628 0.618013 -0.523829 1.09471 1.66921 0.73753 -1.33588 -0.627659 -0.449913 -0.00124181 0.21433
45+
FixedParameters: -0.226504 -0.877893 0.2608

nitransforms/tests/test_io.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
import numpy as np
55

66
from ..io import (
7+
itk,
78
VolumeGeometry as VG,
89
LinearTransform as LT,
910
LinearTransformArray as LTA,
1011
)
1112

12-
1313
def test_VolumeGeometry(tmpdir, get_testdata):
1414
vg = VG()
1515
assert vg['valid'] == 0
@@ -55,3 +55,32 @@ def test_LinearTransformArray(tmpdir, data_path):
5555
with open(outlta) as fp:
5656
lta2 = LTA.from_fileobj(fp)
5757
assert np.allclose(lta['xforms'][0]['m_L'], lta2['xforms'][0]['m_L'])
58+
59+
60+
def test_ITKLinearTransformArray(tmpdir, data_path):
61+
tmpdir.chdir()
62+
63+
with open(str(data_path / 'itktflist.tfm')) as f:
64+
text = f.read()
65+
f.seek(0)
66+
itklist = itk.ITKLinearTransformArray.from_fileobj(f)
67+
68+
assert itklist['nxforms'] == 9
69+
assert text == itklist.to_string()
70+
71+
itklist = itk.ITKLinearTransformArray(
72+
xforms=[np.around(np.random.normal(size=(4, 4)), decimals=5)
73+
for _ in range(4)])
74+
75+
assert itklist['nxforms'] == 4
76+
assert itklist['xforms'][1].structarr['index'] == 2
77+
78+
xfm = itklist['xforms'][1]
79+
xfm['index'] = 1
80+
with open('extracted.tfm', 'w') as f:
81+
f.write(xfm.to_string())
82+
83+
with open('extracted.tfm') as f:
84+
xfm2 = itk.ITKLinearTransform.from_fileobj(f)
85+
assert np.allclose(xfm.structarr['parameters'][:3, ...],
86+
xfm2.structarr['parameters'][:3, ...])

0 commit comments

Comments
 (0)