Skip to content

Commit 04e6465

Browse files
committed
Merge pull request #286 from matthew-brett/refactor-parrec
MRG: refactoring parrec2nii while adding tests Refactoring parrec2nii to make it possible to save nifti images in LAS voxel orientation. * add tests for minmax, scaling options, writing PAR in nifti extension; * fix writing PAR into nifti extension; * add reorientation to LAS; * test reorientation to LAS in voxel and bvecs.
2 parents b91336e + e625347 commit 04e6465

File tree

3 files changed

+151
-73
lines changed

3 files changed

+151
-73
lines changed

bin/parrec2nii

Lines changed: 52 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ from __future__ import division, print_function, absolute_import
55

66
from optparse import OptionParser, Option
77
import numpy as np
8+
import numpy.linalg as npl
89
import sys
910
import os
10-
import gzip
1111
import nibabel
1212
import nibabel.parrec as pr
1313
from nibabel.parrec import one_line
1414
from nibabel.mriutils import calculate_dwell_time, MRIError
1515
import nibabel.nifti1 as nifti1
1616
from nibabel.filename_parser import splitext_addext
17-
from nibabel.volumeutils import array_to_file, fname_ext_ul_case
17+
from nibabel.volumeutils import fname_ext_ul_case
18+
from nibabel.orientations import (io_orientation, inv_ornt_aff,
19+
apply_orientation)
20+
from nibabel.affines import apply_affine, from_matvec, to_matvec
1821

1922

2023
def get_opt_parser():
@@ -136,82 +139,84 @@ def proc_file(infile, opts):
136139
'overwrite it' % outfilename)
137140

138141
# load the PAR header and data
139-
scaling = None if opts.scaling == 'off' else opts.scaling
142+
scaling = 'dv' if opts.scaling == 'off' else opts.scaling
140143
infile = fname_ext_ul_case(infile)
141144
pr_img = pr.load(infile,
142145
permit_truncated=opts.permit_truncated,
143146
scaling=scaling)
144147
pr_hdr = pr_img.header
145-
raw_data = pr_img.dataobj.get_unscaled()
146148
affine = pr_hdr.get_affine(origin=opts.origin)
147-
nimg = nifti1.Nifti1Image(raw_data, affine, pr_hdr)
149+
slope, intercept = pr_hdr.get_data_scaling(scaling)
150+
if opts.scaling != 'off':
151+
verbose('Using data scaling "%s"' % opts.scaling)
152+
# get original scaling, and decide if we scale in-place or not
153+
if opts.scaling == 'off':
154+
slope = np.array([1.])
155+
intercept = np.array([0.])
156+
in_data = pr_img.dataobj.get_unscaled()
157+
out_dtype = pr_hdr.get_data_dtype()
158+
elif not np.any(np.diff(slope)) and not np.any(np.diff(intercept)):
159+
# Single scalefactor case
160+
slope = slope.ravel()[0]
161+
intercept = intercept.ravel()[0]
162+
in_data = pr_img.dataobj.get_unscaled()
163+
out_dtype = pr_hdr.get_data_dtype()
164+
else:
165+
# Multi scalefactor case
166+
slope = np.array([1.])
167+
intercept = np.array([0.])
168+
in_data = np.array(pr_img.dataobj)
169+
out_dtype = np.float64
170+
# Reorient data block to LAS+ if necessary
171+
ornt = io_orientation(np.diag([-1, 1, 1, 1]).dot(affine))
172+
if np.all(ornt == [[0, 1],
173+
[1, 1],
174+
[2, 1]]): # already in LAS+
175+
t_aff = np.eye(4)
176+
else: # Not in LAS+
177+
t_aff = inv_ornt_aff(ornt, pr_img.shape)
178+
affine = np.dot(affine, t_aff)
179+
in_data = apply_orientation(in_data, ornt)
180+
# Make corresponding NIfTI image
181+
nimg = nifti1.Nifti1Image(in_data, affine, pr_hdr)
148182
nhdr = nimg.header
183+
nhdr.set_data_dtype(out_dtype)
184+
nhdr.set_slope_inter(slope, intercept)
149185

150186
if 'parse' in opts.minmax:
151187
# need to get the scaled data
152188
verbose('Loading (and scaling) the data to determine value range')
153-
if opts.scaling == 'off':
154-
scaled_data = raw_data
155-
else:
156-
slope, intercept = pr_hdr.get_data_scaling(method=opts.scaling)
157-
scaled_data = slope * raw_data + intercept
158189
if opts.minmax[0] == 'parse':
159-
nhdr.structarr['cal_min'] = scaled_data.min()
190+
nhdr['cal_min'] = in_data.min() * slope + intercept
160191
else:
161-
nhdr.structarr['cal_min'] = float(opts.minmax[0])
192+
nhdr['cal_min'] = float(opts.minmax[0])
162193
if opts.minmax[1] == 'parse':
163-
nhdr.structarr['cal_max'] = scaled_data.max()
194+
nhdr['cal_max'] = in_data.max() * slope + intercept
164195
else:
165-
nhdr.structarr['cal_max'] = float(opts.minmax[1])
196+
nhdr['cal_max'] = float(opts.minmax[1])
166197

167198
# container for potential NIfTI1 header extensions
168199
if opts.store_header:
169-
exts = nifti1.Nifti1Extensions()
170200
# dump the full PAR header content into an extension
171201
with open(infile, 'r') as fobj:
172202
hdr_dump = fobj.read()
173203
dump_ext = nifti1.Nifti1Extension('comment', hdr_dump)
174-
exts.append(dump_ext)
175-
# finalize the header: set proper data offset, pixdims, ...
176-
nimg.update_header()
177-
178-
# prep a file
179-
if opts.compressed:
180-
verbose('Using gzip compression')
181-
outfile = gzip.open(outfilename, 'wb')
182-
else:
183-
outfile = open(outfilename, 'wb')
204+
nhdr.extensions.append(dump_ext)
184205

185-
# get original scaling, and decide if we scale in-place or not
186-
if opts.scaling == 'off':
187-
slope = np.array([1.])
188-
intercept = np.array([0.])
189-
else:
190-
verbose('Using data scaling "%s"' % opts.scaling)
191-
slope, intercept = pr_hdr.get_data_scaling(method=opts.scaling)
192206
verbose('Writing %s' % outfilename)
193-
if not np.any(np.diff(slope)) and not np.any(np.diff(intercept)):
194-
# Single scalefactor case
195-
nhdr.set_slope_inter(slope.ravel()[0], intercept.ravel()[0])
196-
data_obj = raw_data
197-
else:
198-
# Multi scalefactor case
199-
nhdr.set_slope_inter(1, 0)
200-
nhdr.set_data_dtype(np.float64)
201-
data_obj = pr_img.dataobj
202-
nhdr.write_to(outfile)
203-
offset = nhdr.get_data_offset()
204-
array_to_file(data_obj,
205-
outfile,
206-
out_dtype=nhdr.get_data_dtype(), # for endianness
207-
offset=offset)
207+
nibabel.save(nimg, outfilename)
208+
208209
# write out bvals/bvecs if requested
209210
if opts.bvs:
210211
bvals, bvecs = pr_hdr.get_bvals_bvecs()
211212
if bvals is None and bvecs is None:
212213
verbose('No DTI volumes detected, bvals and bvecs not written')
213214
else:
214215
verbose('Writing .bvals and .bvecs files')
216+
# Transform bvecs with reorientation affine
217+
orig2new = npl.inv(t_aff)
218+
bv_reorient = from_matvec(to_matvec(orig2new)[0], [0, 0, 0])
219+
bvecs = apply_affine(bv_reorient, bvecs)
215220
with open(basefilename + '.bvals', 'w') as fid:
216221
# np.savetxt could do this, but it's just a loop anyway
217222
for val in bvals:

nibabel/parrec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -929,7 +929,7 @@ def get_data_scaling(self, method="dv"):
929929
slope = 1.0 / scale_slope
930930
intercept = rescale_intercept / (rescale_slope * scale_slope)
931931
else:
932-
raise ValueError("Unknown scling method '%s'." % method)
932+
raise ValueError("Unknown scaling method '%s'." % method)
933933
reorder = self.get_sorted_slice_indices()
934934
slope = slope[reorder]
935935
intercept = intercept[reorder]

nibabel/tests/test_scripts.py

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,20 @@
1616

1717
from ..tmpdirs import InTemporaryDirectory
1818
from ..loadsave import load
19+
from ..orientations import flip_axis, aff2axcodes, inv_ornt_aff
1920

2021
from nose.tools import (assert_true, assert_false, assert_not_equal,
2122
assert_equal)
2223

23-
from numpy.testing import assert_almost_equal
24+
from numpy.testing import assert_almost_equal, assert_array_equal
2425

2526
from .scriptrunner import ScriptRunner
2627
from .nibabel_data import needs_nibabel_data
27-
from .test_parrec import DTI_PAR_BVECS, DTI_PAR_BVALS
28+
from ..testing import assert_dt_equal
29+
from .test_parrec import (DTI_PAR_BVECS, DTI_PAR_BVALS,
30+
EXAMPLE_IMAGES as PARREC_EXAMPLES)
2831
from .test_parrec_data import BALLS, AFF_OFF
32+
from .test_helpers import assert_data_similar
2933

3034

3135
def _proc_stdout(stdout):
@@ -83,31 +87,88 @@ def vox_size(affine):
8387
return np.sqrt(np.sum(affine[:3,:3] ** 2, axis=0))
8488

8589

90+
def check_conversion(cmd, pr_data, out_fname):
91+
run_command(cmd)
92+
img = load(out_fname)
93+
# Check orientations always LAS
94+
assert_equal(aff2axcodes(img.affine), tuple('LAS'))
95+
data = img.get_data()
96+
assert_true(np.allclose(data, pr_data))
97+
assert_true(np.allclose(img.header['cal_min'], data.min()))
98+
assert_true(np.allclose(img.header['cal_max'], data.max()))
99+
# Check minmax options
100+
run_command(cmd + ['--minmax', '1', '2'])
101+
img = load(out_fname)
102+
assert_true(np.allclose(data, pr_data))
103+
assert_true(np.allclose(img.header['cal_min'], 1))
104+
assert_true(np.allclose(img.header['cal_max'], 2))
105+
run_command(cmd + ['--minmax', 'parse', '2'])
106+
img = load(out_fname)
107+
assert_true(np.allclose(data, pr_data))
108+
assert_true(np.allclose(img.header['cal_min'], data.min()))
109+
assert_true(np.allclose(img.header['cal_max'], 2))
110+
run_command(cmd + ['--minmax', '1', 'parse'])
111+
img = load(out_fname)
112+
assert_true(np.allclose(data, pr_data))
113+
assert_true(np.allclose(img.header['cal_min'], 1))
114+
assert_true(np.allclose(img.header['cal_max'], data.max()))
115+
116+
86117
@script_test
87118
def test_parrec2nii():
88119
# Test parrec2nii script
89120
cmd = ['parrec2nii', '--help']
90121
code, stdout, stderr = run_command(cmd)
91122
assert_true(stdout.startswith('Usage'))
92-
in_fname = pjoin(DATA_PATH, 'phantom_EPI_asc_CLEAR_2_1.PAR')
93-
out_froot = 'phantom_EPI_asc_CLEAR_2_1.nii'
94123
with InTemporaryDirectory():
95-
run_command(['parrec2nii', in_fname])
96-
img = load(out_froot)
97-
assert_equal(img.shape, (64, 64, 9, 3))
98-
assert_equal(img.get_data_dtype(), np.dtype(np.uint16))
99-
# Check against values from Philips converted nifti image
100-
data = img.get_data()
101-
assert_true(np.allclose(
102-
(data.min(), data.max(), data.mean()),
103-
(0.0, 2299.4110643863678, 194.95876256117265)))
104-
assert_almost_equal(vox_size(img.get_affine()), (3.75, 3.75, 8))
124+
for eg_dict in PARREC_EXAMPLES:
125+
fname = eg_dict['fname']
126+
run_command(['parrec2nii', fname])
127+
out_froot = splitext(basename(fname))[0] + '.nii'
128+
img = load(out_froot)
129+
assert_equal(img.shape, eg_dict['shape'])
130+
assert_dt_equal(img.get_data_dtype(), eg_dict['dtype'])
131+
# Check against values from Philips converted nifti image
132+
data = img.get_data()
133+
assert_data_similar(data, eg_dict)
134+
assert_almost_equal(img.header.get_zooms(), eg_dict['zooms'])
135+
# Standard save does not save extensions
136+
assert_equal(len(img.header.extensions), 0)
137+
# Does not overwrite unless option given
138+
code, stdout, stderr = run_command(
139+
['parrec2nii', fname], check_code=False)
140+
assert_equal(code, 1)
141+
# Default scaling is dv
142+
pr_img = load(fname)
143+
flipped_data = flip_axis(pr_img.get_data(), 1)
144+
base_cmd = ['parrec2nii', '--overwrite', fname]
145+
check_conversion(base_cmd, flipped_data, out_froot)
146+
check_conversion(base_cmd + ['--scaling=dv'],
147+
flipped_data,
148+
out_froot)
149+
# fp
150+
pr_img = load(fname, scaling='fp')
151+
flipped_data = flip_axis(pr_img.get_data(), 1)
152+
check_conversion(base_cmd + ['--scaling=fp'],
153+
flipped_data,
154+
out_froot)
155+
# no scaling
156+
unscaled_flipped = flip_axis(pr_img.dataobj.get_unscaled(), 1)
157+
check_conversion(base_cmd + ['--scaling=off'],
158+
unscaled_flipped,
159+
out_froot)
160+
# Save extensions
161+
run_command(base_cmd + ['--store-header'])
162+
img = load(out_froot)
163+
assert_equal(len(img.header.extensions), 1)
105164

106165

107166
@script_test
108167
@needs_nibabel_data('nitest-balls1')
109168
def test_parrec2nii_with_data():
110169
# Use nibabel-data to test conversion
170+
# Premultiplier to relate our affines to Philips conversion
171+
LAS2LPS = inv_ornt_aff([[0, 1], [1, -1], [2, 1]], (80, 80, 10))
111172
with InTemporaryDirectory():
112173
for par in glob(pjoin(BALLS, 'PARREC', '*.PAR')):
113174
par_root, ext = splitext(basename(par))
@@ -118,23 +179,31 @@ def test_parrec2nii_with_data():
118179
# Do conversion
119180
run_command(['parrec2nii', par])
120181
conved_img = load(par_root + '.nii')
182+
# Confirm parrec2nii conversions are LAS
183+
assert_equal(aff2axcodes(conved_img.affine), tuple('LAS'))
184+
# Shape same whether LPS or LAS
121185
assert_equal(conved_img.shape[:3], (80, 80, 10))
122-
# Test against converted NIfTI
186+
# Test against original converted NIfTI
123187
nifti_fname = pjoin(BALLS, 'NIFTI', par_root + '.nii.gz')
124188
if exists(nifti_fname):
125-
nimg = load(nifti_fname)
126-
assert_almost_equal(nimg.affine[:3, :3],
127-
conved_img.affine[:3, :3], 3)
189+
philips_img = load(nifti_fname)
190+
# Confirm Philips converted image always LPS
191+
assert_equal(aff2axcodes(philips_img.affine), tuple('LPS'))
192+
# Equivalent to Philips LPS affine
193+
equiv_affine = conved_img.affine.dot(LAS2LPS)
194+
assert_almost_equal(philips_img.affine[:3, :3],
195+
equiv_affine[:3, :3], 3)
128196
# The translation part is always off by the same ammout
129-
aff_off = conved_img.affine[:3, 3] - nimg.affine[:3, 3]
130-
assert_almost_equal(aff_off, AFF_OFF, 4)
197+
aff_off = equiv_affine[:3, 3] - philips_img.affine[:3, 3]
198+
assert_almost_equal(aff_off, AFF_OFF, 3)
131199
# The difference is max in the order of 0.5 voxel
132-
vox_sizes = np.sqrt((nimg.affine[:3, :3] ** 2).sum(axis=0))
200+
vox_sizes = vox_size(philips_img.affine)
133201
assert_true(np.all(np.abs(aff_off / vox_sizes) <= 0.5))
134202
# The data is very close, unless it's the fieldmap
135203
if par_root != 'fieldmap':
136-
assert_true(np.allclose(conved_img.dataobj,
137-
nimg.dataobj))
204+
conved_data_lps = flip_axis(conved_img.dataobj, 1)
205+
assert_true(np.allclose(conved_data_lps,
206+
philips_img.dataobj))
138207
with InTemporaryDirectory():
139208
# Test some options
140209
dti_par = pjoin(BALLS, 'PARREC', 'DTI.PAR')
@@ -149,8 +218,12 @@ def test_parrec2nii_with_data():
149218
# Writes bvals, bvecs files if asked
150219
run_command(['parrec2nii', '--overwrite', '--bvs', dti_par])
151220
assert_almost_equal(np.loadtxt('DTI.bvals'), DTI_PAR_BVALS)
152-
assert_almost_equal(np.loadtxt('DTI.bvecs'),
153-
DTI_PAR_BVECS[:, [2, 0, 1]].T)
221+
# Bvecs in header, transposed from PSL to LPS
222+
bvecs_LPS = DTI_PAR_BVECS[:, [2, 0, 1]]
223+
# Adjust for output flip of Y axis in data and bvecs
224+
bvecs_LAS = bvecs_LPS * [1, -1, 1]
225+
assert_almost_equal(np.loadtxt('DTI.bvecs'), bvecs_LAS.T)
226+
# Dwell time
154227
assert_false(exists('DTI.dwell_time'))
155228
# Need field strength if requesting dwell time
156229
code, _, _, = run_command(

0 commit comments

Comments
 (0)