diff --git a/nibabel/cmdline/convert.py b/nibabel/cmdline/convert.py new file mode 100644 index 0000000000..8f1042c71d --- /dev/null +++ b/nibabel/cmdline/convert.py @@ -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) diff --git a/nibabel/cmdline/tests/test_convert.py b/nibabel/cmdline/tests/test_convert.py new file mode 100644 index 0000000000..487bfb7401 --- /dev/null +++ b/nibabel/cmdline/tests/test_convert.py @@ -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() + + # 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 diff --git a/nibabel/nifti1.py b/nibabel/nifti1.py index 91ed8a2903..5be146a89c 100644 --- a/nibabel/nifti1.py +++ b/nibabel/nifti1.py @@ -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 diff --git a/setup.cfg b/setup.cfg index e81b1db10b..4defb7eb14 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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