From 0932788edcd0e11c0bbaaecee0437b72799143eb Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Mon, 6 Jun 2022 12:19:34 -0400 Subject: [PATCH 1/5] ENH: Add simple nib-convert tool --- nibabel/cmdline/convert.py | 66 ++++++++++++++++++++++++++++++++++++++ setup.cfg | 1 + 2 files changed, 67 insertions(+) create mode 100644 nibabel/cmdline/convert.py diff --git a/nibabel/cmdline/convert.py b/nibabel/cmdline/convert.py new file mode 100644 index 0000000000..b4f5c6b1ad --- /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="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/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 From d33336ad37edd1a377ef8e261c67528ab7731341 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Tue, 7 Jun 2022 09:09:41 -0400 Subject: [PATCH 2/5] TEST: Test nib-convert functionality --- nibabel/cmdline/tests/test_convert.py | 152 ++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 nibabel/cmdline/tests/test_convert.py diff --git a/nibabel/cmdline/tests/test_convert.py b/nibabel/cmdline/tests/test_convert.py new file mode 100644 index 0000000000..6e140272c0 --- /dev/null +++ b/nibabel/cmdline/tests/test_convert.py @@ -0,0 +1,152 @@ +#!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() + + with pytest.raises(FileExistsError): + convert.main([str(infile), str(outfile)]) + + convert.main([str(infile), str(outfile), '--force']) + assert outfile.is_file() + + +@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 From 3751f614a31b5cde58b5ceeeecde609f323a637e Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Tue, 7 Jun 2022 10:13:32 -0400 Subject: [PATCH 3/5] FIX: Finalize NIfTI dtype before calling Analyze.to_file_map --- nibabel/nifti1.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 From ddce80019417c7f08b3615ecc846ce0f344c7d8d Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Mon, 13 Jun 2022 13:13:42 -0400 Subject: [PATCH 4/5] TEST: Verify --force overwrites --- nibabel/cmdline/tests/test_convert.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nibabel/cmdline/tests/test_convert.py b/nibabel/cmdline/tests/test_convert.py index 6e140272c0..487bfb7401 100644 --- a/nibabel/cmdline/tests/test_convert.py +++ b/nibabel/cmdline/tests/test_convert.py @@ -32,12 +32,22 @@ def test_convert_noop(tmp_path): 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): From 9a9a5903d48f119a6f27e6c6593150f7db600e83 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Mon, 13 Jun 2022 13:14:21 -0400 Subject: [PATCH 5/5] UI: Better describe --force option --- nibabel/cmdline/convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nibabel/cmdline/convert.py b/nibabel/cmdline/convert.py index b4f5c6b1ad..8f1042c71d 100644 --- a/nibabel/cmdline/convert.py +++ b/nibabel/cmdline/convert.py @@ -33,7 +33,7 @@ def _get_parser(): "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="Ignore warnings if possible") + 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