Skip to content

NF: Conformation function and CLI tool to apply shape, orientation and zooms #853

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 30 commits into from
Apr 19, 2020
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b897935
add `conform` and `_transform_range` funcs
Dec 11, 2019
76e9aed
Merge remote-tracking branch 'upstream/master' into add/fs-conform
Dec 11, 2019
9895eb0
add documentation and fix style
Dec 12, 2019
49e4ada
clean up documentation + only conform 3D
Dec 13, 2019
57c3648
add tests for `conform` and `_transform_range`
Dec 13, 2019
a681bdd
only test `conform` if scipy installed
Dec 13, 2019
4e62b7c
add `nib-conform` console script
kaczmarj Apr 8, 2020
e19b022
tighten scope of conform function
kaczmarj Apr 8, 2020
12ea136
Merge remote-tracking branch 'upstream/master' into add/fs-conform
kaczmarj Apr 8, 2020
89eedc5
add `nib-conform`
kaczmarj Apr 8, 2020
348f838
use proper labels for orientation
kaczmarj Apr 8, 2020
9491806
add non-3d tests
kaczmarj Apr 8, 2020
a9ce73b
fix style
kaczmarj Apr 8, 2020
3911610
make voxel size and out shape int type
kaczmarj Apr 8, 2020
0d8843b
add tests for `nib-conform` command
kaczmarj Apr 8, 2020
3e4da11
skip tests if scipy not available
kaczmarj Apr 8, 2020
8b712ca
use `nb.save(img, filename)` instead of `img.save(...)`
kaczmarj Apr 8, 2020
67ace2f
keep input class by default in `conform`
kaczmarj Apr 8, 2020
527400d
do not error on non-3d inputs
kaczmarj Apr 8, 2020
6e19298
clean up code
kaczmarj Apr 8, 2020
07fa254
correct the re-orientation of the output image in `conform`
kaczmarj Apr 8, 2020
00825c7
make `to_img` the same image/header classes as input image
kaczmarj Apr 8, 2020
4ca32ba
make pep8 gods happy
kaczmarj Apr 8, 2020
a536ed3
test for errors on non-3d inputs and arguments
kaczmarj Apr 8, 2020
3af4bd8
test that file is not overwritten without `--force`
kaczmarj Apr 8, 2020
3658170
remove bin/nib-conform because it is unused
kaczmarj Apr 8, 2020
eb097f4
copy header from input image in `conform`
kaczmarj Apr 11, 2020
241f58f
NF: Add nibabel.affines.rescale_affine function
effigies Apr 15, 2020
f77fbb5
RF: Update conformation to reorient, rescale and resample
effigies Apr 15, 2020
2177a59
Merge pull request #1 from effigies/add/fs-conform
kaczmarj Apr 15, 2020
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
17 changes: 17 additions & 0 deletions bin/nib-conform
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!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.
#
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
"""
Conform a volume to a new shape and/or voxel size.
"""

from nibabel.cmdline.conform import main

if __name__ == '__main__':
main()
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we install these files anywhere...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

should I remove this file?

Copy link
Member

Choose a reason for hiding this comment

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

I think so. I'd try removing and make sure pip install . produces a working nib-conform command.

65 changes: 65 additions & 0 deletions nibabel/cmdline/conform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!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.
#
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
"""
Conform neuroimaging volume to arbitrary shape and voxel size.
"""

import argparse
from pathlib import Path
import sys

from nibabel import __version__
from nibabel.loadsave import load
from nibabel.processing import conform


def _get_parser():
"""Return command-line argument parser."""
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("infile",
help="Neuroimaging volume to conform.")
p.add_argument("outfile",
help="Name of output file.")
p.add_argument("--out-shape", nargs=3, default=(256, 256, 256), type=int,
help="Shape of the conformed output.")
p.add_argument("--voxel-size", nargs=3, default=(1, 1, 1), type=int,
help="Voxel size in millimeters of the conformed output.")
p.add_argument("--orientation", default="RAS",
help="Orientation of the conformed output.")
p.add_argument("-f", "--force", action="store_true",
help="Overwrite existing output files.")
p.add_argument("-V", "--version", action="version", version="{} {}".format(p.prog, __version__))

return p


def main(args=None):
"""Main program function."""
parser = _get_parser()
if args is None:
namespace = parser.parse_args(sys.argv[1:])
else:
namespace = parser.parse_args(args)

kwargs = vars(namespace)
from_img = load(kwargs["infile"])

if not kwargs["force"] and Path(kwargs["outfile"]).exists():
raise FileExistsError("Output file exists: {}".format(kwargs["outfile"]))

out_img = conform(
from_img=from_img,
out_shape=kwargs["out_shape"],
voxel_size=kwargs["voxel_size"],
order=3,
cval=0.0,
orientation=kwargs["orientation"])

out_img.to_filename(kwargs["outfile"])
58 changes: 58 additions & 0 deletions nibabel/cmdline/tests/test_conform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!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 unittest

import pytest

import nibabel as nib
from nibabel.testing import test_data
from nibabel.cmdline.conform import main
from nibabel.optpkg import optional_package

_, have_scipy, _ = optional_package('scipy.ndimage')
needs_scipy = unittest.skipUnless(have_scipy, 'These tests need scipy')


@needs_scipy
def test_default(tmpdir):
infile = test_data(fname="anatomical.nii")
outfile = tmpdir / "output.nii.gz"
main([str(infile), str(outfile)])
assert outfile.isfile()
c = nib.load(outfile)
assert c.shape == (256, 256, 256)
assert c.header.get_zooms() == (1, 1, 1)
assert nib.orientations.aff2axcodes(c.affine) == ('R', 'A', 'S')


@needs_scipy
def test_nondefault(tmpdir):
infile = test_data(fname="anatomical.nii")
outfile = tmpdir / "output.nii.gz"
out_shape = (100, 100, 150)
voxel_size = (1, 2, 4)
orientation = "LAS"
args = "{} {} --out-shape {} --voxel-size {} --orientation {}".format(
infile, outfile, " ".join(map(str, out_shape)), " ".join(map(str, voxel_size)), orientation)
main(args.split())
assert outfile.isfile()
c = nib.load(outfile)
assert c.shape == out_shape
assert c.header.get_zooms() == voxel_size
assert nib.orientations.aff2axcodes(c.affine) == tuple(orientation)


@needs_scipy
def test_non3d(tmpdir):
infile = test_data(fname="functional.nii")
outfile = tmpdir / "output.nii.gz"
with pytest.raises(ValueError):
main([str(infile), str(outfile)])
80 changes: 79 additions & 1 deletion nibabel/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@

from .affines import AffineError, to_matvec, from_matvec, append_diag
from .spaces import vox2out_vox
from .nifti1 import Nifti1Image
from .nifti1 import Nifti1Header, Nifti1Image
from .orientations import axcodes2ornt
from .imageclasses import spatial_axes_first

SIGMA2FWHM = np.sqrt(8 * np.log(2))
Expand Down Expand Up @@ -310,3 +311,80 @@ def smooth_image(img,
mode=mode,
cval=cval)
return out_class(sm_data, img.affine, img.header)


def conform(from_img,
out_shape=(256, 256, 256),
voxel_size=(1.0, 1.0, 1.0),
order=3,
cval=0.0,
orientation='RAS',
out_class=Nifti1Image):
""" Resample image to ``out_shape`` with voxels of size ``voxel_size``.

Using the default arguments, this function is meant to replicate most parts
of FreeSurfer's ``mri_convert --conform`` command. Specifically, this
function:
- Resamples data to ``output_shape``
- Resamples voxel sizes to ``voxel_size``
- Reorients to RAS (``mri_convert --conform`` reorients to LIA)

Unlike ``mri_convert --conform``, this command does not:
- Transform data to range [0, 255]
- Cast to unsigned eight-bit integer

Parameters
----------
from_img : object
Object having attributes ``dataobj``, ``affine``, ``header`` and
``shape``. If `out_class` is not None, ``img.__class__`` should be able
to construct an image from data, affine and header.
out_shape : sequence, optional
The shape of the output volume. Default is (256, 256, 256).
voxel_size : sequence, optional
The size in millimeters of the voxels in the resampled output. Default
is 1mm isotropic.
order : int, optional
The order of the spline interpolation, default is 3. The order has to
be in the range 0-5 (see ``scipy.ndimage.affine_transform``)
cval : scalar, optional
Value used for points outside the boundaries of the input if
``mode='constant'``. Default is 0.0 (see
``scipy.ndimage.affine_transform``)
orientation : str, optional
Orientation of output image. Default is "RAS".
out_class : None or SpatialImage class, optional
Class of output image. If None, use ``from_img.__class__``.

Returns
-------
out_img : object
Image of instance specified by `out_class`, containing data output from
resampling `from_img` into axes aligned to the output space of
``from_img.affine``
"""
# Only support 3D images. This can be made more general in the future, once tests
# are written.
required_ndim = 3
if from_img.ndim != required_ndim:
raise ValueError("Only 3D images are supported.")
elif len(out_shape) != required_ndim:
raise ValueError("`out_shape` must have {} values".format(required_ndim))
elif len(voxel_size) != required_ndim:
raise ValueError("`voxel_size` must have {} values".format(required_ndim))

# Create fake image of the image we want to resample to.
hdr = Nifti1Header()
hdr.set_data_shape(out_shape)
hdr.set_zooms(voxel_size)
dst_aff = hdr.get_best_affine()
to_img = Nifti1Image(np.empty(out_shape), affine=dst_aff, header=hdr)

# Resample input image.
out_img = resample_from_to(
from_img=from_img, to_vox_map=to_img, order=order, mode="constant",
cval=cval, out_class=out_class)

# Reorient to desired orientation.
ornt = axcodes2ornt(orientation, labels=list(zip('RPI', 'LAS')))
return out_img.as_reoriented(ornt)
38 changes: 36 additions & 2 deletions nibabel/tests/test_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@

import nibabel as nib
from nibabel.processing import (sigma2fwhm, fwhm2sigma, adapt_affine,
resample_from_to, resample_to_output, smooth_image)
resample_from_to, resample_to_output, smooth_image,
conform)
from nibabel.nifti1 import Nifti1Image
from nibabel.nifti2 import Nifti2Image
from nibabel.orientations import flip_axis, inv_ornt_aff
from nibabel.orientations import aff2axcodes, flip_axis, inv_ornt_aff
from nibabel.affines import (AffineError, from_matvec, to_matvec, apply_affine,
voxel_sizes)
from nibabel.eulerangles import euler2mat
Expand Down Expand Up @@ -420,3 +421,36 @@ def test_against_spm_resample():
moved2output = resample_to_output(moved_anat, 4, order=1, cval=np.nan)
spm2output = nib.load(pjoin(DATA_DIR, 'reoriented_anat_moved.nii'))
assert_spm_resampling_close(moved_anat, moved2output, spm2output);


@needs_scipy
def test_conform():
anat = nib.load(pjoin(DATA_DIR, 'anatomical.nii'))

# Test with default arguments.
c = conform(anat)
assert c.shape == (256, 256, 256)
assert c.header.get_zooms() == (1, 1, 1)
assert c.dataobj.dtype.type == anat.dataobj.dtype.type
assert aff2axcodes(c.affine) == ('R', 'A', 'S')
assert isinstance(c, Nifti1Image)

# Test with non-default arguments.
c = conform(anat, out_shape=(100, 100, 200), voxel_size=(2, 2, 1.5),
orientation="LPI", out_class=Nifti2Image)
assert c.shape == (100, 100, 200)
assert c.header.get_zooms() == (2, 2, 1.5)
assert c.dataobj.dtype.type == anat.dataobj.dtype.type
assert aff2axcodes(c.affine) == ('L', 'P', 'I')
assert isinstance(c, Nifti2Image)

# Error on non-3D arguments.
with pytest.raises(ValueError):
conform(anat, out_shape=(100, 100))
with pytest.raises(ValueError):
conform(anat, voxel_size=(2, 2))

# Error on non-3D images.
Copy link
Member

Choose a reason for hiding this comment

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

is that what freesurfer does? I thought it might be nice to have this one applicable to 4D (or 5D - AFNI etc) series, to "conform" the first 3 dimensions to desired resolution/orientation etc

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i don't think it is what freesurfer does, but for simplicity and due to my lack of knowledge, i made it this way. how should the output shape and zooms be changed to support 4- and 5-D images?

Copy link
Member

Choose a reason for hiding this comment

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

The output shape and zooms would not be affected by rolling the first three axes.

In any event, I'm okay with getting this in and adding 4/5D image support later, but if we're going to have a test, then we should mark it as one that we intend to relax. Otherwise it may look like a desired constraint to future developers.

func = nib.load(pjoin(DATA_DIR, 'functional.nii'))
with pytest.raises(ValueError):
conform(func)
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ all =

[options.entry_points]
console_scripts =
nib-conform=nibabel.cmdline.conform:main
nib-ls=nibabel.cmdline.ls:main
nib-dicomfs=nibabel.cmdline.dicomfs:main
nib-diff=nibabel.cmdline.diff:main
Expand Down