Skip to content

Commit 669a030

Browse files
authored
Merge pull request #1113 from effigies/enh/nib-convert
NF: nib-convert CLI tool
2 parents b7bbf0e + 9a9a590 commit 669a030

File tree

4 files changed

+247
-0
lines changed

4 files changed

+247
-0
lines changed

nibabel/cmdline/convert.py

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!python
2+
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
3+
# vi: set ft=python sts=4 ts=4 sw=4 et:
4+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
5+
#
6+
# See COPYING file distributed along with the NiBabel package for the
7+
# copyright and license terms.
8+
#
9+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
10+
"""
11+
Convert neuroimaging file to new parameters
12+
"""
13+
14+
import argparse
15+
from pathlib import Path
16+
import warnings
17+
18+
import nibabel as nib
19+
20+
21+
def _get_parser():
22+
"""Return command-line argument parser."""
23+
p = argparse.ArgumentParser(description=__doc__)
24+
p.add_argument("infile",
25+
help="Neuroimaging volume to convert")
26+
p.add_argument("outfile",
27+
help="Name of output file")
28+
p.add_argument("--out-dtype", action="store",
29+
help="On-disk data type; valid argument to numpy.dtype()")
30+
p.add_argument("--image-type", action="store",
31+
help="Name of NiBabel image class to create, e.g. Nifti1Image. "
32+
"If specified, will be used prior to setting dtype. If unspecified, "
33+
"a new image like `infile` will be created and converted to a type "
34+
"matching the extension of `outfile`.")
35+
p.add_argument("-f", "--force", action="store_true",
36+
help="Overwrite output file if it exists, and ignore warnings if possible")
37+
p.add_argument("-V", "--version", action="version", version=f"{p.prog} {nib.__version__}")
38+
39+
return p
40+
41+
42+
def main(args=None):
43+
"""Main program function."""
44+
parser = _get_parser()
45+
opts = parser.parse_args(args)
46+
orig = nib.load(opts.infile)
47+
48+
if not opts.force and Path(opts.outfile).exists():
49+
raise FileExistsError(f"Output file exists: {opts.outfile}")
50+
51+
if opts.image_type:
52+
klass = getattr(nib, opts.image_type)
53+
else:
54+
klass = orig.__class__
55+
56+
out_img = klass.from_image(orig)
57+
if opts.out_dtype:
58+
try:
59+
out_img.set_data_dtype(opts.out_dtype)
60+
except Exception as e:
61+
if opts.force:
62+
warnings.warn(f"Ignoring error: {e!r}")
63+
else:
64+
raise
65+
66+
nib.save(out_img, opts.outfile)

nibabel/cmdline/tests/test_convert.py

+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!python
2+
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
3+
# vi: set ft=python sts=4 ts=4 sw=4 et:
4+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
5+
#
6+
# See COPYING file distributed along with the NiBabel package for the
7+
# copyright and license terms.
8+
#
9+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
10+
11+
import pytest
12+
13+
import numpy as np
14+
15+
import nibabel as nib
16+
from nibabel.testing import test_data
17+
from nibabel.cmdline import convert
18+
19+
20+
def test_convert_noop(tmp_path):
21+
infile = test_data(fname='anatomical.nii')
22+
outfile = tmp_path / 'output.nii.gz'
23+
24+
orig = nib.load(infile)
25+
assert not outfile.exists()
26+
27+
convert.main([str(infile), str(outfile)])
28+
assert outfile.is_file()
29+
30+
converted = nib.load(outfile)
31+
assert np.allclose(converted.affine, orig.affine)
32+
assert converted.shape == orig.shape
33+
assert converted.get_data_dtype() == orig.get_data_dtype()
34+
35+
infile = test_data(fname='resampled_anat_moved.nii')
36+
37+
with pytest.raises(FileExistsError):
38+
convert.main([str(infile), str(outfile)])
39+
40+
convert.main([str(infile), str(outfile), '--force'])
41+
assert outfile.is_file()
42+
43+
# Verify that we did overwrite
44+
converted2 = nib.load(outfile)
45+
assert not (
46+
converted2.shape == converted.shape
47+
and np.allclose(converted2.affine, converted.affine)
48+
and np.allclose(converted2.get_fdata(), converted.get_fdata())
49+
)
50+
51+
52+
@pytest.mark.parametrize('data_dtype', ('u1', 'i2', 'float32', 'float', 'int64'))
53+
def test_convert_dtype(tmp_path, data_dtype):
54+
infile = test_data(fname='anatomical.nii')
55+
outfile = tmp_path / 'output.nii.gz'
56+
57+
orig = nib.load(infile)
58+
assert not outfile.exists()
59+
60+
# np.dtype() will give us the dtype for the system endianness if that
61+
# mismatches the data file, we will fail equality, so get the dtype that
62+
# matches the requested precision but in the endianness of the file
63+
expected_dtype = np.dtype(data_dtype).newbyteorder(orig.header.endianness)
64+
65+
convert.main([str(infile), str(outfile), '--out-dtype', data_dtype])
66+
assert outfile.is_file()
67+
68+
converted = nib.load(outfile)
69+
assert np.allclose(converted.affine, orig.affine)
70+
assert converted.shape == orig.shape
71+
assert converted.get_data_dtype() == expected_dtype
72+
73+
74+
@pytest.mark.parametrize('ext,img_class', [
75+
('mgh', nib.MGHImage),
76+
('img', nib.Nifti1Pair),
77+
])
78+
def test_convert_by_extension(tmp_path, ext, img_class):
79+
infile = test_data(fname='anatomical.nii')
80+
outfile = tmp_path / f'output.{ext}'
81+
82+
orig = nib.load(infile)
83+
assert not outfile.exists()
84+
85+
convert.main([str(infile), str(outfile)])
86+
assert outfile.is_file()
87+
88+
converted = nib.load(outfile)
89+
assert np.allclose(converted.affine, orig.affine)
90+
assert converted.shape == orig.shape
91+
assert converted.__class__ == img_class
92+
93+
94+
@pytest.mark.parametrize('ext,img_class', [
95+
('mgh', nib.MGHImage),
96+
('img', nib.Nifti1Pair),
97+
('nii', nib.Nifti2Image),
98+
])
99+
def test_convert_imgtype(tmp_path, ext, img_class):
100+
infile = test_data(fname='anatomical.nii')
101+
outfile = tmp_path / f'output.{ext}'
102+
103+
orig = nib.load(infile)
104+
assert not outfile.exists()
105+
106+
convert.main([str(infile), str(outfile), '--image-type', img_class.__name__])
107+
assert outfile.is_file()
108+
109+
converted = nib.load(outfile)
110+
assert np.allclose(converted.affine, orig.affine)
111+
assert converted.shape == orig.shape
112+
assert converted.__class__ == img_class
113+
114+
115+
def test_convert_nifti_int_fail(tmp_path):
116+
infile = test_data(fname='anatomical.nii')
117+
outfile = tmp_path / f'output.nii'
118+
119+
orig = nib.load(infile)
120+
assert not outfile.exists()
121+
122+
with pytest.raises(ValueError):
123+
convert.main([str(infile), str(outfile), '--out-dtype', 'int'])
124+
assert not outfile.exists()
125+
126+
with pytest.warns(UserWarning):
127+
convert.main([str(infile), str(outfile), '--out-dtype', 'int', '--force'])
128+
assert outfile.is_file()
129+
130+
converted = nib.load(outfile)
131+
assert np.allclose(converted.affine, orig.affine)
132+
assert converted.shape == orig.shape
133+
# Note: '--force' ignores the error, but can't interpret it enough to do
134+
# the cast anyway
135+
assert converted.get_data_dtype() == orig.get_data_dtype()
136+
137+
138+
@pytest.mark.parametrize('orig_dtype,alias,expected_dtype', [
139+
('int64', 'mask', 'uint8'),
140+
('int64', 'compat', 'int32'),
141+
('int64', 'smallest', 'uint8'),
142+
('float64', 'mask', 'uint8'),
143+
('float64', 'compat', 'float32'),
144+
])
145+
def test_convert_aliases(tmp_path, orig_dtype, alias, expected_dtype):
146+
orig_fname = tmp_path / 'orig.nii'
147+
out_fname = tmp_path / 'out.nii'
148+
149+
arr = np.arange(24).reshape((2, 3, 4))
150+
img = nib.Nifti1Image(arr, np.eye(4), dtype=orig_dtype)
151+
img.to_filename(orig_fname)
152+
153+
assert orig_fname.exists()
154+
assert not out_fname.exists()
155+
156+
convert.main([str(orig_fname), str(out_fname), '--out-dtype', alias])
157+
assert out_fname.is_file()
158+
159+
expected_dtype = np.dtype(expected_dtype).newbyteorder(img.header.endianness)
160+
161+
converted = nib.load(out_fname)
162+
assert converted.get_data_dtype() == expected_dtype

nibabel/nifti1.py

+18
Original file line numberDiff line numberDiff line change
@@ -2180,6 +2180,24 @@ def get_data_dtype(self, finalize=False):
21802180
self.set_data_dtype(datatype) # Clears the alias
21812181
return super().get_data_dtype()
21822182

2183+
def to_file_map(self, file_map=None, dtype=None):
2184+
""" Write image to `file_map` or contained ``self.file_map``
2185+
2186+
Parameters
2187+
----------
2188+
file_map : None or mapping, optional
2189+
files mapping. If None (default) use object's ``file_map``
2190+
attribute instead
2191+
dtype : dtype-like, optional
2192+
The on-disk data type to coerce the data array.
2193+
"""
2194+
img_dtype = self.get_data_dtype()
2195+
self.get_data_dtype(finalize=True)
2196+
try:
2197+
super().to_file_map(file_map, dtype)
2198+
finally:
2199+
self.set_data_dtype(img_dtype)
2200+
21832201
def as_reoriented(self, ornt):
21842202
"""Apply an orientation change and return a new image
21852203

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ all =
7676
[options.entry_points]
7777
console_scripts =
7878
nib-conform=nibabel.cmdline.conform:main
79+
nib-convert=nibabel.cmdline.convert:main
7980
nib-ls=nibabel.cmdline.ls:main
8081
nib-dicomfs=nibabel.cmdline.dicomfs:main
8182
nib-diff=nibabel.cmdline.diff:main

0 commit comments

Comments
 (0)