Skip to content

Commit 2ab68b3

Browse files
authored
Merge pull request #17 from mgxd/enh/lta
ENH: FreeSurfer LTA file support
2 parents fd9690b + 1aee2c4 commit 2ab68b3

File tree

10 files changed

+494
-15
lines changed

10 files changed

+494
-15
lines changed

nitransforms/io.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import numpy as np
2+
from nibabel.volumeutils import Recoder
3+
from nibabel.affines import voxel_sizes
4+
5+
from .patched import LabeledWrapStruct
6+
7+
transform_codes = Recoder((
8+
(0, 'LINEAR_VOX_TO_VOX'),
9+
(1, 'LINEAR_RAS_TO_RAS'),
10+
(2, 'LINEAR_PHYSVOX_TO_PHYSVOX'),
11+
(14, 'REGISTER_DAT'),
12+
(21, 'LINEAR_COR_TO_COR')),
13+
fields=('code', 'label'))
14+
15+
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+
31+
class VolumeGeometry(StringBasedStruct):
32+
template_dtype = np.dtype([
33+
('valid', 'i4'), # Valid values: 0, 1
34+
('volume', 'i4', (3, 1)), # width, height, depth
35+
('voxelsize', 'f4', (3, 1)), # xsize, ysize, zsize
36+
('xras', 'f4', (3, 1)), # x_r, x_a, x_s
37+
('yras', 'f4', (3, 1)), # y_r, y_a, y_s
38+
('zras', 'f4', (3, 1)), # z_r, z_a, z_s
39+
('cras', 'f4', (3, 1)), # c_r, c_a, c_s
40+
('filename', 'U1024')]) # Not conformant (may be >1024 bytes)
41+
dtype = template_dtype
42+
43+
def as_affine(self):
44+
affine = np.eye(4)
45+
sa = self.structarr
46+
A = np.hstack((sa['xras'], sa['yras'], sa['zras'])) * sa['voxelsize']
47+
b = sa['cras'] - A.dot(sa['volume']) / 2
48+
affine[:3, :3] = A
49+
affine[:3, [3]] = b
50+
return affine
51+
52+
def to_string(self):
53+
sa = self.structarr
54+
lines = [
55+
'valid = {} # volume info {:s}valid'.format(
56+
sa['valid'], '' if sa['valid'] else 'in'),
57+
'filename = {}'.format(sa['filename']),
58+
'volume = {:d} {:d} {:d}'.format(*sa['volume'].flatten()),
59+
'voxelsize = {:.15e} {:.15e} {:.15e}'.format(
60+
*sa['voxelsize'].flatten()),
61+
'xras = {:.15e} {:.15e} {:.15e}'.format(*sa['xras'].flatten()),
62+
'yras = {:.15e} {:.15e} {:.15e}'.format(*sa['yras'].flatten()),
63+
'zras = {:.15e} {:.15e} {:.15e}'.format(*sa['zras'].flatten()),
64+
'cras = {:.15e} {:.15e} {:.15e}'.format(*sa['cras'].flatten()),
65+
]
66+
return '\n'.join(lines)
67+
68+
@classmethod
69+
def from_image(klass, img):
70+
volgeom = klass()
71+
sa = volgeom.structarr
72+
sa['valid'] = 1
73+
sa['volume'][:, 0] = img.shape[:3] # Assumes xyzt-ordered image
74+
sa['voxelsize'][:, 0] = voxel_sizes(img.affine)[:3]
75+
A = img.affine[:3, :3]
76+
b = img.affine[:3, [3]]
77+
cols = A / sa['voxelsize']
78+
sa['xras'] = cols[:, [0]]
79+
sa['yras'] = cols[:, [1]]
80+
sa['zras'] = cols[:, [2]]
81+
sa['cras'] = b + A.dot(sa['volume']) / 2
82+
try:
83+
sa['filename'] = img.file_map['image'].filename
84+
except Exception:
85+
pass
86+
87+
return volgeom
88+
89+
@classmethod
90+
def from_string(klass, string):
91+
volgeom = klass()
92+
sa = volgeom.structarr
93+
lines = string.splitlines()
94+
for key in ('valid', 'filename', 'volume', 'voxelsize',
95+
'xras', 'yras', 'zras', 'cras'):
96+
label, valstring = lines.pop(0).split(' = ')
97+
assert label.strip() == key
98+
99+
val = np.genfromtxt([valstring.encode()],
100+
dtype=klass.dtype[key])
101+
sa[key] = val.reshape(sa[key].shape) if val.size else ''
102+
103+
return volgeom
104+
105+
106+
class LinearTransform(StringBasedStruct):
107+
template_dtype = np.dtype([
108+
('mean', 'f4', (3, 1)), # x0, y0, z0
109+
('sigma', 'f4'),
110+
('m_L', 'f4', (4, 4)),
111+
('m_dL', 'f4', (4, 4)),
112+
('m_last_dL', 'f4', (4, 4)),
113+
('src', VolumeGeometry),
114+
('dst', VolumeGeometry),
115+
('label', 'i4')])
116+
dtype = template_dtype
117+
118+
def __getitem__(self, idx):
119+
val = super(LinearTransform, self).__getitem__(idx)
120+
if idx in ('src', 'dst'):
121+
val = VolumeGeometry(val)
122+
return val
123+
124+
def to_string(self):
125+
sa = self.structarr
126+
lines = [
127+
'mean = {:6.4f} {:6.4f} {:6.4f}'.format(
128+
*sa['mean'].flatten()),
129+
'sigma = {:6.4f}'.format(float(sa['sigma'])),
130+
'1 4 4',
131+
('{:18.15e} ' * 4).format(*sa['m_L'][0]),
132+
('{:18.15e} ' * 4).format(*sa['m_L'][1]),
133+
('{:18.15e} ' * 4).format(*sa['m_L'][2]),
134+
('{:18.15e} ' * 4).format(*sa['m_L'][3]),
135+
'src volume info',
136+
self['src'].to_string(),
137+
'dst volume info',
138+
self['dst'].to_string(),
139+
]
140+
return '\n'.join(lines)
141+
142+
@classmethod
143+
def from_string(klass, string):
144+
lt = klass()
145+
sa = lt.structarr
146+
lines = string.splitlines()
147+
for key in ('mean', 'sigma'):
148+
label, valstring = lines.pop(0).split(' = ')
149+
assert label.strip() == key
150+
151+
val = np.genfromtxt([valstring.encode()],
152+
dtype=klass.dtype[key])
153+
sa[key] = val.reshape(sa[key].shape)
154+
assert lines.pop(0) == '1 4 4' # xforms, shape + 1, shape + 1
155+
val = np.genfromtxt([valstring.encode() for valstring in lines[:4]],
156+
dtype='f4')
157+
sa['m_L'] = val
158+
lines = lines[4:]
159+
assert lines.pop(0) == 'src volume info'
160+
sa['src'] = np.asanyarray(VolumeGeometry.from_string('\n'.join(lines[:8])))
161+
lines = lines[8:]
162+
assert lines.pop(0) == 'dst volume info'
163+
sa['dst'] = np.asanyarray(VolumeGeometry.from_string('\n'.join(lines)))
164+
return lt
165+
166+
167+
class LinearTransformArray(StringBasedStruct):
168+
template_dtype = np.dtype([
169+
('type', 'i4'),
170+
('nxforms', 'i4'),
171+
('subject', 'U1024'),
172+
('fscale', 'f4')])
173+
dtype = template_dtype
174+
_xforms = None
175+
176+
def __init__(self,
177+
binaryblock=None,
178+
endianness=None,
179+
check=True):
180+
super(LinearTransformArray, self).__init__(binaryblock, endianness, check)
181+
self._xforms = [LinearTransform()
182+
for _ in range(self.structarr['nxforms'])]
183+
184+
def __getitem__(self, idx):
185+
if idx == 'xforms':
186+
return self._xforms
187+
if idx == 'nxforms':
188+
return len(self._xforms)
189+
return super(LinearTransformArray, self).__getitem__(idx)
190+
191+
def to_string(self):
192+
code = int(self['type'])
193+
header = [
194+
'type = {} # {}'.format(code, transform_codes.label[code]),
195+
'nxforms = {}'.format(self['nxforms'])]
196+
xforms = [xfm.to_string() for xfm in self._xforms]
197+
footer = [
198+
'subject {}'.format(self['subject']),
199+
'fscale {:.6f}'.format(float(self['fscale']))]
200+
return '\n'.join(header + xforms + footer)
201+
202+
@classmethod
203+
def from_string(klass, string):
204+
lta = klass()
205+
sa = lta.structarr
206+
lines = string.splitlines()
207+
for key in ('type', 'nxforms'):
208+
label, valstring = lines.pop(0).split(' = ')
209+
assert label.strip() == key
210+
211+
val = np.genfromtxt([valstring.encode()],
212+
dtype=klass.dtype[key])
213+
sa[key] = val.reshape(sa[key].shape) if val.size else ''
214+
for _ in range(sa['nxforms']):
215+
lta._xforms.append(
216+
LinearTransform.from_string('\n'.join(lines[:25])))
217+
lines = lines[25:]
218+
if lines:
219+
for key in ('subject', 'fscale'):
220+
# Optional keys
221+
if not lines[0].startswith(key):
222+
continue
223+
label, valstring = lines.pop(0).split(' ')
224+
assert label.strip() == key
225+
226+
val = np.genfromtxt([valstring.encode()],
227+
dtype=klass.dtype[key])
228+
sa[key] = val.reshape(sa[key].shape) if val.size else ''
229+
230+
assert len(lta._xforms) == sa['nxforms']
231+
return lta
232+
233+
@classmethod
234+
def from_fileobj(klass, fileobj, check=True):
235+
return klass.from_string(fileobj.read())
236+
237+
def set_type(self, target):
238+
"""
239+
Convert the internal transformation matrix to a different type inplace
240+
241+
Parameters
242+
----------
243+
target : str, int
244+
Tranformation type
245+
"""
246+
assert self['nxforms'] == 1, "Cannot convert multiple transformations"
247+
xform = self['xforms'][0]
248+
src = xform['src']
249+
dst = xform['dst']
250+
current = self['type']
251+
if isinstance(target, str):
252+
target = transform_codes.code[target]
253+
254+
# VOX2VOX -> RAS2RAS
255+
if current == 0 and target == 1:
256+
M = np.linalg.inv(src.as_affine()).dot(xform['m_L']).dot(dst.as_affine())
257+
else:
258+
raise NotImplementedError(
259+
"Converting {0} to {1} is not yet available".format(
260+
transform_codes.label[current],
261+
transform_codes.label[target]
262+
)
263+
)
264+
xform['m_L'] = M
265+
self['type'] = target

nitransforms/linear.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
import numpy as np
1212
from scipy import ndimage as ndi
1313
from pathlib import Path
14+
import warnings
1415

1516
from nibabel.loadsave import load as loadimg
1617
from nibabel.affines import from_matvec, voxel_sizes, obliquity
1718
from .base import TransformBase
1819
from .patched import shape_zoom_affine
20+
from . import io
1921

2022

2123
LPS = np.diag([-1, -1, 1, 1])
@@ -126,8 +128,7 @@ def resample(self, moving, order=3, mode='constant', cval=0.0, prefilter=True,
126128
try:
127129
reference = self.reference
128130
except ValueError:
129-
print('Warning: no reference space defined, using moving as reference',
130-
file=sys.stderr)
131+
warnings.warn('No reference space defined, using moving as reference')
131132
reference = moving
132133

133134
nvols = 1
@@ -150,8 +151,7 @@ def resample(self, moving, order=3, mode='constant', cval=0.0, prefilter=True,
150151
singlemat = np.linalg.inv(movaff).dot(self._matrix[0].dot(reference.affine))
151152

152153
if singlemat is not None and nvols > nmats:
153-
print('Warning: resampling a 4D volume with a single affine matrix',
154-
file=sys.stderr)
154+
warnings.warn('Resampling a 4D volume with a single affine matrix')
155155

156156
# Compose an index to index affine matrix
157157
moved = []
@@ -270,13 +270,13 @@ def to_filename(self, filename, fmt='X5', moving=None):
270270
3dvolreg matrices (DICOM-to-DICOM, row-by-row):""", fmt='%g')
271271
return filename
272272

273-
if fmt.lower() == 'fsl':
274-
if not moving:
275-
moving = self.reference
276-
277-
if isinstance(moving, str):
278-
moving = loadimg(moving)
273+
# for FSL / FS information
274+
if not moving:
275+
moving = self.reference
276+
if isinstance(moving, str):
277+
moving = loadimg(moving)
279278

279+
if fmt.lower() == 'fsl':
280280
# Adjust for reference image offset and orientation
281281
refswp, refspc = _fsl_aff_adapt(self.reference)
282282
pre = self.reference.affine.dot(
@@ -298,6 +298,22 @@ def to_filename(self, filename, fmt='X5', moving=None):
298298
else:
299299
np.savetxt(filename, mat[0], delimiter=' ', fmt='%g')
300300
return filename
301+
elif fmt.lower() == 'fs':
302+
# xform info
303+
lt = io.LinearTransform()
304+
lt['sigma'] = 1.
305+
lt['m_L'] = self.matrix
306+
lt['src'] = io.VolumeGeometry.from_image(moving)
307+
lt['dst'] = io.VolumeGeometry.from_image(self.reference)
308+
# to make LTA file format
309+
lta = io.LinearTransformArray()
310+
lta['type'] = 1 # RAS2RAS
311+
lta['xforms'].append(lt)
312+
313+
with open(filename, 'w') as f:
314+
f.write(lta.to_string())
315+
return filename
316+
301317
return super(Affine, self).to_filename(filename, fmt=fmt)
302318

303319

@@ -326,6 +342,14 @@ def load(filename, fmt='X5', reference=None):
326342
# elif fmt.lower() == 'afni':
327343
# parameters = LPS.dot(self.matrix.dot(LPS))
328344
# parameters = parameters[:3, :].reshape(-1).tolist()
345+
elif fmt.lower() == 'fs':
346+
with open(filename) as ltafile:
347+
lta = io.LinearTransformArray.from_fileobj(ltafile)
348+
if lta['nxforms'] > 1:
349+
raise NotImplementedError("Multiple transforms are not yet supported.")
350+
if lta['type'] != 1:
351+
lta.set_type(1)
352+
matrix = lta['xforms'][0]['m_L']
329353
elif fmt.lower() in ('x5', 'bids'):
330354
raise NotImplementedError
331355
else:

nitransforms/patched.py

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

34

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

0 commit comments

Comments
 (0)