Skip to content

NF: nib-convert CLI tool #1113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions nibabel/cmdline/convert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!python
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
#
# See COPYING file distributed along with the NiBabel package for the
# copyright and license terms.
#
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
"""
Convert neuroimaging file to new parameters
"""

import argparse
from pathlib import Path
import warnings

import nibabel as nib


def _get_parser():
"""Return command-line argument parser."""
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("infile",
help="Neuroimaging volume to convert")
p.add_argument("outfile",
help="Name of output file")
p.add_argument("--out-dtype", action="store",
help="On-disk data type; valid argument to numpy.dtype()")
p.add_argument("--image-type", action="store",
help="Name of NiBabel image class to create, e.g. Nifti1Image. "
"If specified, will be used prior to setting dtype. If unspecified, "
"a new image like `infile` will be created and converted to a type "
"matching the extension of `outfile`.")
p.add_argument("-f", "--force", action="store_true",
help="Overwrite output file if it exists, and ignore warnings if possible")
p.add_argument("-V", "--version", action="version", version=f"{p.prog} {nib.__version__}")

return p


def main(args=None):
"""Main program function."""
parser = _get_parser()
opts = parser.parse_args(args)
orig = nib.load(opts.infile)

if not opts.force and Path(opts.outfile).exists():
raise FileExistsError(f"Output file exists: {opts.outfile}")

if opts.image_type:
klass = getattr(nib, opts.image_type)
else:
klass = orig.__class__

out_img = klass.from_image(orig)
if opts.out_dtype:
try:
out_img.set_data_dtype(opts.out_dtype)
except Exception as e:
if opts.force:
warnings.warn(f"Ignoring error: {e!r}")
else:
raise

nib.save(out_img, opts.outfile)
162 changes: 162 additions & 0 deletions nibabel/cmdline/tests/test_convert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!python
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
#
# See COPYING file distributed along with the NiBabel package for the
# copyright and license terms.
#
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##

import pytest

import numpy as np

import nibabel as nib
from nibabel.testing import test_data
from nibabel.cmdline import convert


def test_convert_noop(tmp_path):
infile = test_data(fname='anatomical.nii')
outfile = tmp_path / 'output.nii.gz'

orig = nib.load(infile)
assert not outfile.exists()

convert.main([str(infile), str(outfile)])
assert outfile.is_file()

converted = nib.load(outfile)
assert np.allclose(converted.affine, orig.affine)
assert converted.shape == orig.shape
assert converted.get_data_dtype() == orig.get_data_dtype()

infile = test_data(fname='resampled_anat_moved.nii')

with pytest.raises(FileExistsError):
convert.main([str(infile), str(outfile)])

convert.main([str(infile), str(outfile), '--force'])
assert outfile.is_file()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too important, but is there a way to make sure that this is the file that was created in the forced run, and not the one that existed there before? You might worry that the force flag would somehow make the program do nothing.


# Verify that we did overwrite
converted2 = nib.load(outfile)
assert not (
converted2.shape == converted.shape
and np.allclose(converted2.affine, converted.affine)
and np.allclose(converted2.get_fdata(), converted.get_fdata())
)


@pytest.mark.parametrize('data_dtype', ('u1', 'i2', 'float32', 'float', 'int64'))
def test_convert_dtype(tmp_path, data_dtype):
infile = test_data(fname='anatomical.nii')
outfile = tmp_path / 'output.nii.gz'

orig = nib.load(infile)
assert not outfile.exists()

# np.dtype() will give us the dtype for the system endianness if that
# mismatches the data file, we will fail equality, so get the dtype that
# matches the requested precision but in the endianness of the file
expected_dtype = np.dtype(data_dtype).newbyteorder(orig.header.endianness)

convert.main([str(infile), str(outfile), '--out-dtype', data_dtype])
assert outfile.is_file()

converted = nib.load(outfile)
assert np.allclose(converted.affine, orig.affine)
assert converted.shape == orig.shape
assert converted.get_data_dtype() == expected_dtype


@pytest.mark.parametrize('ext,img_class', [
('mgh', nib.MGHImage),
('img', nib.Nifti1Pair),
])
def test_convert_by_extension(tmp_path, ext, img_class):
infile = test_data(fname='anatomical.nii')
outfile = tmp_path / f'output.{ext}'

orig = nib.load(infile)
assert not outfile.exists()

convert.main([str(infile), str(outfile)])
assert outfile.is_file()

converted = nib.load(outfile)
assert np.allclose(converted.affine, orig.affine)
assert converted.shape == orig.shape
assert converted.__class__ == img_class


@pytest.mark.parametrize('ext,img_class', [
('mgh', nib.MGHImage),
('img', nib.Nifti1Pair),
('nii', nib.Nifti2Image),
])
def test_convert_imgtype(tmp_path, ext, img_class):
infile = test_data(fname='anatomical.nii')
outfile = tmp_path / f'output.{ext}'

orig = nib.load(infile)
assert not outfile.exists()

convert.main([str(infile), str(outfile), '--image-type', img_class.__name__])
assert outfile.is_file()

converted = nib.load(outfile)
assert np.allclose(converted.affine, orig.affine)
assert converted.shape == orig.shape
assert converted.__class__ == img_class


def test_convert_nifti_int_fail(tmp_path):
infile = test_data(fname='anatomical.nii')
outfile = tmp_path / f'output.nii'

orig = nib.load(infile)
assert not outfile.exists()

with pytest.raises(ValueError):
convert.main([str(infile), str(outfile), '--out-dtype', 'int'])
assert not outfile.exists()

with pytest.warns(UserWarning):
convert.main([str(infile), str(outfile), '--out-dtype', 'int', '--force'])
assert outfile.is_file()

converted = nib.load(outfile)
assert np.allclose(converted.affine, orig.affine)
assert converted.shape == orig.shape
# Note: '--force' ignores the error, but can't interpret it enough to do
# the cast anyway
assert converted.get_data_dtype() == orig.get_data_dtype()


@pytest.mark.parametrize('orig_dtype,alias,expected_dtype', [
('int64', 'mask', 'uint8'),
('int64', 'compat', 'int32'),
('int64', 'smallest', 'uint8'),
('float64', 'mask', 'uint8'),
('float64', 'compat', 'float32'),
])
def test_convert_aliases(tmp_path, orig_dtype, alias, expected_dtype):
orig_fname = tmp_path / 'orig.nii'
out_fname = tmp_path / 'out.nii'

arr = np.arange(24).reshape((2, 3, 4))
img = nib.Nifti1Image(arr, np.eye(4), dtype=orig_dtype)
img.to_filename(orig_fname)

assert orig_fname.exists()
assert not out_fname.exists()

convert.main([str(orig_fname), str(out_fname), '--out-dtype', alias])
assert out_fname.is_file()

expected_dtype = np.dtype(expected_dtype).newbyteorder(img.header.endianness)

converted = nib.load(out_fname)
assert converted.get_data_dtype() == expected_dtype
18 changes: 18 additions & 0 deletions nibabel/nifti1.py
Original file line number Diff line number Diff line change
Expand Up @@ -2180,6 +2180,24 @@ def get_data_dtype(self, finalize=False):
self.set_data_dtype(datatype) # Clears the alias
return super().get_data_dtype()

def to_file_map(self, file_map=None, dtype=None):
""" Write image to `file_map` or contained ``self.file_map``

Parameters
----------
file_map : None or mapping, optional
files mapping. If None (default) use object's ``file_map``
attribute instead
dtype : dtype-like, optional
The on-disk data type to coerce the data array.
"""
img_dtype = self.get_data_dtype()
self.get_data_dtype(finalize=True)
try:
super().to_file_map(file_map, dtype)
finally:
self.set_data_dtype(img_dtype)

def as_reoriented(self, ornt):
"""Apply an orientation change and return a new image

Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ all =
[options.entry_points]
console_scripts =
nib-conform=nibabel.cmdline.conform:main
nib-convert=nibabel.cmdline.convert:main
nib-ls=nibabel.cmdline.ls:main
nib-dicomfs=nibabel.cmdline.dicomfs:main
nib-diff=nibabel.cmdline.diff:main
Expand Down