Skip to content

Commit 07b172a

Browse files
authored
Merge pull request #853 from kaczmarj/add/fs-conform
NF: Conformation function and CLI tool to apply shape, orientation and zooms
2 parents 4e408dd + 2177a59 commit 07b172a

File tree

7 files changed

+291
-4
lines changed

7 files changed

+291
-4
lines changed

nibabel/affines.py

+40
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,43 @@ def obliquity(affine):
323323
vs = voxel_sizes(affine)
324324
best_cosines = np.abs(affine[:-1, :-1] / vs).max(axis=1)
325325
return np.arccos(best_cosines)
326+
327+
328+
def rescale_affine(affine, shape, zooms, new_shape=None):
329+
""" Return a new affine matrix with updated voxel sizes (zooms)
330+
331+
This function preserves the rotations and shears of the original
332+
affine, as well as the RAS location of the central voxel of the
333+
image.
334+
335+
Parameters
336+
----------
337+
affine : (N, N) array-like
338+
NxN transform matrix in homogeneous coordinates representing an affine
339+
transformation from an (N-1)-dimensional space to an (N-1)-dimensional
340+
space. An example is a 4x4 transform representing rotations and
341+
translations in 3 dimensions.
342+
shape : (N-1,) array-like
343+
The extent of the (N-1) dimensions of the original space
344+
zooms : (N-1,) array-like
345+
The size of voxels of the output affine
346+
new_shape : (N-1,) array-like, optional
347+
The extent of the (N-1) dimensions of the space described by the
348+
new affine. If ``None``, use ``shape``.
349+
350+
Returns
351+
-------
352+
affine : (N, N) array
353+
A new affine transform with the specified voxel sizes
354+
355+
"""
356+
shape = np.array(shape, copy=False)
357+
new_shape = np.array(new_shape if new_shape is not None else shape)
358+
359+
s = voxel_sizes(affine)
360+
rzs_out = affine[:3, :3] * zooms / s
361+
362+
# Using xyz = A @ ijk, determine translation
363+
centroid = apply_affine(affine, (shape - 1) // 2)
364+
t_out = centroid - rzs_out @ ((new_shape - 1) // 2)
365+
return from_matvec(rzs_out, t_out)

nibabel/cmdline/conform.py

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
Conform neuroimaging volume to arbitrary shape and voxel size.
12+
"""
13+
14+
import argparse
15+
from pathlib import Path
16+
17+
from nibabel import __version__
18+
from nibabel.loadsave import load, save
19+
from nibabel.processing import conform
20+
21+
22+
def _get_parser():
23+
"""Return command-line argument parser."""
24+
p = argparse.ArgumentParser(description=__doc__)
25+
p.add_argument("infile",
26+
help="Neuroimaging volume to conform.")
27+
p.add_argument("outfile",
28+
help="Name of output file.")
29+
p.add_argument("--out-shape", nargs=3, default=(256, 256, 256), type=int,
30+
help="Shape of the conformed output.")
31+
p.add_argument("--voxel-size", nargs=3, default=(1, 1, 1), type=int,
32+
help="Voxel size in millimeters of the conformed output.")
33+
p.add_argument("--orientation", default="RAS",
34+
help="Orientation of the conformed output.")
35+
p.add_argument("-f", "--force", action="store_true",
36+
help="Overwrite existing output files.")
37+
p.add_argument("-V", "--version", action="version", version="{} {}".format(p.prog, __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+
from_img = load(opts.infile)
47+
48+
if not opts.force and Path(opts.outfile).exists():
49+
raise FileExistsError("Output file exists: {}".format(opts.outfile))
50+
51+
out_img = conform(
52+
from_img=from_img,
53+
out_shape=opts.out_shape,
54+
voxel_size=opts.voxel_size,
55+
order=3,
56+
cval=0.0,
57+
orientation=opts.orientation)
58+
59+
save(out_img, opts.outfile)

nibabel/cmdline/tests/test_conform.py

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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 unittest
12+
13+
import pytest
14+
15+
import nibabel as nib
16+
from nibabel.testing import test_data
17+
from nibabel.cmdline.conform import main
18+
from nibabel.optpkg import optional_package
19+
20+
_, have_scipy, _ = optional_package('scipy.ndimage')
21+
needs_scipy = unittest.skipUnless(have_scipy, 'These tests need scipy')
22+
23+
24+
@needs_scipy
25+
def test_default(tmpdir):
26+
infile = test_data(fname="anatomical.nii")
27+
outfile = tmpdir / "output.nii.gz"
28+
main([str(infile), str(outfile)])
29+
assert outfile.isfile()
30+
c = nib.load(outfile)
31+
assert c.shape == (256, 256, 256)
32+
assert c.header.get_zooms() == (1, 1, 1)
33+
assert nib.orientations.aff2axcodes(c.affine) == ('R', 'A', 'S')
34+
35+
with pytest.raises(FileExistsError):
36+
main([str(infile), str(outfile)])
37+
38+
main([str(infile), str(outfile), "--force"])
39+
assert outfile.isfile()
40+
41+
42+
@needs_scipy
43+
def test_nondefault(tmpdir):
44+
infile = test_data(fname="anatomical.nii")
45+
outfile = tmpdir / "output.nii.gz"
46+
out_shape = (100, 100, 150)
47+
voxel_size = (1, 2, 4)
48+
orientation = "LAS"
49+
args = "{} {} --out-shape {} --voxel-size {} --orientation {}".format(
50+
infile, outfile, " ".join(map(str, out_shape)), " ".join(map(str, voxel_size)), orientation)
51+
main(args.split())
52+
assert outfile.isfile()
53+
c = nib.load(outfile)
54+
assert c.shape == out_shape
55+
assert c.header.get_zooms() == voxel_size
56+
assert nib.orientations.aff2axcodes(c.affine) == tuple(orientation)

nibabel/processing.py

+79-1
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@
2121
from .optpkg import optional_package
2222
spnd, _, _ = optional_package('scipy.ndimage')
2323

24-
from .affines import AffineError, to_matvec, from_matvec, append_diag
24+
from .affines import AffineError, to_matvec, from_matvec, append_diag, rescale_affine
2525
from .spaces import vox2out_vox
2626
from .nifti1 import Nifti1Image
27+
from .orientations import axcodes2ornt, io_orientation, ornt_transform
2728
from .imageclasses import spatial_axes_first
2829

2930
SIGMA2FWHM = np.sqrt(8 * np.log(2))
@@ -310,3 +311,80 @@ def smooth_image(img,
310311
mode=mode,
311312
cval=cval)
312313
return out_class(sm_data, img.affine, img.header)
314+
315+
316+
def conform(from_img,
317+
out_shape=(256, 256, 256),
318+
voxel_size=(1.0, 1.0, 1.0),
319+
order=3,
320+
cval=0.0,
321+
orientation='RAS',
322+
out_class=None):
323+
""" Resample image to ``out_shape`` with voxels of size ``voxel_size``.
324+
325+
Using the default arguments, this function is meant to replicate most parts
326+
of FreeSurfer's ``mri_convert --conform`` command. Specifically, this
327+
function:
328+
- Resamples data to ``output_shape``
329+
- Resamples voxel sizes to ``voxel_size``
330+
- Reorients to RAS (``mri_convert --conform`` reorients to LIA)
331+
332+
Unlike ``mri_convert --conform``, this command does not:
333+
- Transform data to range [0, 255]
334+
- Cast to unsigned eight-bit integer
335+
336+
Parameters
337+
----------
338+
from_img : object
339+
Object having attributes ``dataobj``, ``affine``, ``header`` and
340+
``shape``. If `out_class` is not None, ``img.__class__`` should be able
341+
to construct an image from data, affine and header.
342+
out_shape : sequence, optional
343+
The shape of the output volume. Default is (256, 256, 256).
344+
voxel_size : sequence, optional
345+
The size in millimeters of the voxels in the resampled output. Default
346+
is 1mm isotropic.
347+
order : int, optional
348+
The order of the spline interpolation, default is 3. The order has to
349+
be in the range 0-5 (see ``scipy.ndimage.affine_transform``)
350+
cval : scalar, optional
351+
Value used for points outside the boundaries of the input if
352+
``mode='constant'``. Default is 0.0 (see
353+
``scipy.ndimage.affine_transform``)
354+
orientation : str, optional
355+
Orientation of output image. Default is "RAS".
356+
out_class : None or SpatialImage class, optional
357+
Class of output image. If None, use ``from_img.__class__``.
358+
359+
Returns
360+
-------
361+
out_img : object
362+
Image of instance specified by `out_class`, containing data output from
363+
resampling `from_img` into axes aligned to the output space of
364+
``from_img.affine``
365+
"""
366+
# Only support 3D images. This can be made more general in the future, once tests
367+
# are written.
368+
required_ndim = 3
369+
if from_img.ndim != required_ndim:
370+
raise ValueError("Only 3D images are supported.")
371+
elif len(out_shape) != required_ndim:
372+
raise ValueError("`out_shape` must have {} values".format(required_ndim))
373+
elif len(voxel_size) != required_ndim:
374+
raise ValueError("`voxel_size` must have {} values".format(required_ndim))
375+
376+
start_ornt = io_orientation(from_img.affine)
377+
end_ornt = axcodes2ornt(orientation)
378+
transform = ornt_transform(start_ornt, end_ornt)
379+
380+
# Reorient first to ensure shape matches expectations
381+
reoriented = from_img.as_reoriented(transform)
382+
383+
out_aff = rescale_affine(reoriented.affine, reoriented.shape, voxel_size, out_shape)
384+
385+
# Resample input image.
386+
out_img = resample_from_to(
387+
from_img=from_img, to_vox_map=(out_shape, out_aff), order=order, mode="constant",
388+
cval=cval, out_class=out_class)
389+
390+
return out_img

nibabel/tests/test_affines.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
from ..eulerangles import euler2mat
99
from ..affines import (AffineError, apply_affine, append_diag, to_matvec,
10-
from_matvec, dot_reduce, voxel_sizes, obliquity)
10+
from_matvec, dot_reduce, voxel_sizes, obliquity, rescale_affine)
11+
from ..orientations import aff2axcodes
1112

1213

1314
import pytest
@@ -192,3 +193,22 @@ def test_obliquity():
192193
assert_almost_equal(obliquity(aligned), [0.0, 0.0, 0.0])
193194
assert_almost_equal(obliquity(oblique) * 180 / pi,
194195
[0.0810285, 5.1569949, 5.1569376])
196+
197+
198+
def test_rescale_affine():
199+
rng = np.random.RandomState(20200415)
200+
orig_shape = rng.randint(low=20, high=512, size=(3,))
201+
orig_aff = np.eye(4)
202+
orig_aff[:3, :] = rng.normal(size=(3, 4))
203+
orig_zooms = voxel_sizes(orig_aff)
204+
orig_axcodes = aff2axcodes(orig_aff)
205+
orig_centroid = apply_affine(orig_aff, (orig_shape - 1) // 2)
206+
207+
for new_shape in (None, tuple(orig_shape), (256, 256, 256), (64, 64, 40)):
208+
for new_zooms in ((1, 1, 1), (2, 2, 3), (0.5, 0.5, 0.5)):
209+
new_aff = rescale_affine(orig_aff, orig_shape, new_zooms, new_shape)
210+
assert aff2axcodes(new_aff) == orig_axcodes
211+
if new_shape is None:
212+
new_shape = tuple(orig_shape)
213+
new_centroid = apply_affine(new_aff, (np.array(new_shape) - 1) // 2)
214+
assert_almost_equal(new_centroid, orig_centroid)

nibabel/tests/test_processing.py

+35-2
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919

2020
import nibabel as nib
2121
from nibabel.processing import (sigma2fwhm, fwhm2sigma, adapt_affine,
22-
resample_from_to, resample_to_output, smooth_image)
22+
resample_from_to, resample_to_output, smooth_image,
23+
conform)
2324
from nibabel.nifti1 import Nifti1Image
2425
from nibabel.nifti2 import Nifti2Image
25-
from nibabel.orientations import flip_axis, inv_ornt_aff
26+
from nibabel.orientations import aff2axcodes, flip_axis, inv_ornt_aff
2627
from nibabel.affines import (AffineError, from_matvec, to_matvec, apply_affine,
2728
voxel_sizes)
2829
from nibabel.eulerangles import euler2mat
@@ -420,3 +421,35 @@ def test_against_spm_resample():
420421
moved2output = resample_to_output(moved_anat, 4, order=1, cval=np.nan)
421422
spm2output = nib.load(pjoin(DATA_DIR, 'reoriented_anat_moved.nii'))
422423
assert_spm_resampling_close(moved_anat, moved2output, spm2output);
424+
425+
426+
@needs_scipy
427+
def test_conform():
428+
anat = nib.load(pjoin(DATA_DIR, 'anatomical.nii'))
429+
430+
# Test with default arguments.
431+
c = conform(anat)
432+
assert c.shape == (256, 256, 256)
433+
assert c.header.get_zooms() == (1, 1, 1)
434+
assert c.dataobj.dtype.type == anat.dataobj.dtype.type
435+
assert aff2axcodes(c.affine) == ('R', 'A', 'S')
436+
assert isinstance(c, Nifti1Image)
437+
438+
# Test with non-default arguments.
439+
c = conform(anat, out_shape=(100, 100, 200), voxel_size=(2, 2, 1.5),
440+
orientation="LPI", out_class=Nifti2Image)
441+
assert c.shape == (100, 100, 200)
442+
assert c.header.get_zooms() == (2, 2, 1.5)
443+
assert c.dataobj.dtype.type == anat.dataobj.dtype.type
444+
assert aff2axcodes(c.affine) == ('L', 'P', 'I')
445+
assert isinstance(c, Nifti2Image)
446+
447+
# TODO: support nD images in `conform` in the future, but for now, test that we get
448+
# errors on non-3D images.
449+
func = nib.load(pjoin(DATA_DIR, 'functional.nii'))
450+
with pytest.raises(ValueError):
451+
conform(func)
452+
with pytest.raises(ValueError):
453+
conform(anat, out_shape=(100, 100))
454+
with pytest.raises(ValueError):
455+
conform(anat, voxel_size=(2, 2))

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ all =
6969

7070
[options.entry_points]
7171
console_scripts =
72+
nib-conform=nibabel.cmdline.conform:main
7273
nib-ls=nibabel.cmdline.ls:main
7374
nib-dicomfs=nibabel.cmdline.dicomfs:main
7475
nib-diff=nibabel.cmdline.diff:main

0 commit comments

Comments
 (0)