Skip to content

Commit 4c2ffe8

Browse files
committed
Merge branch 'to-output-space' into main-master
* to-output-space: RF: change to mapped_voxels as vox2out_vox input DOC: docstring and comment changes NF: function to make affine for selecting slice NF: add routine to make output space bounding box DOC: reformat affine docstring
2 parents 9f60922 + 8bf7bf5 commit 4c2ffe8

File tree

3 files changed

+259
-7
lines changed

3 files changed

+259
-7
lines changed

nibabel/affines.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,24 @@ def apply_affine(aff, pts):
1515
coordinate dimension of `pts` should be the last.
1616
1717
For the 3D case, `aff` will be shape (4,4) and `pts` will have final axis
18-
length 3 - maybe it will just be N by 3. The return value is the transformed
19-
points, in this case::
18+
length 3 - maybe it will just be N by 3. The return value is the
19+
transformed points, in this case::
2020
2121
res = np.dot(aff[:3,:3], pts.T) + aff[:3,3:4]
2222
transformed_pts = res.T
2323
24-
Notice though, that this routine is more general, in that `aff` can have any
25-
shape (N,N), and `pts` can have any shape, as long as the last dimension is
26-
for the coordinates, and is therefore length N-1.
24+
This routine is more general than 3D, in that `aff` can have any shape
25+
(N,N), and `pts` can have any shape, as long as the last dimension is for
26+
the coordinates, and is therefore length N-1.
2727
2828
Parameters
2929
----------
3030
aff : (N, N) array-like
3131
Homogenous affine, for 3D points, will be 4 by 4. Contrary to first
3232
appearance, the affine will be applied on the left of `pts`.
3333
pts : (..., N-1) array-like
34-
Points, where the last dimension contains the coordinates of each point.
35-
For 3D, the last dimension will be length 3.
34+
Points, where the last dimension contains the coordinates of each
35+
point. For 3D, the last dimension will be length 3.
3636
3737
Returns
3838
-------

nibabel/spaces.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
2+
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
4+
#
5+
# See COPYING file distributed along with the NiBabel package for the
6+
# copyright and license terms.
7+
#
8+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
9+
""" Routines to work with spaces
10+
11+
A space is defined by coordinate axes.
12+
13+
A voxel space can be expressed by a shape implying an array, where the axes are
14+
the axes of the array.
15+
16+
A mapped voxel space (mapped voxels) is either:
17+
18+
* an image, with attributes ``shape`` (the voxel space) and ``affine`` (the
19+
mapping), or
20+
* a length 2 sequence with the same information (shape, affine).
21+
"""
22+
23+
from itertools import product
24+
25+
import numpy as np
26+
27+
from .affines import apply_affine
28+
29+
30+
def vox2out_vox(mapped_voxels, voxel_sizes=None):
31+
""" output-aligned shape, affine for input implied by `mapped_voxels`
32+
33+
The input (voxel) space, and the affine mapping to output space, are given
34+
in `mapped_voxels`.
35+
36+
The output space is implied by the affine, we don't need to know what that
37+
is, we just return something with the same (implied) output space.
38+
39+
Our job is to work out another voxel space where the voxel array axes and
40+
the output axes are aligned (top left 3 x 3 of affine is diagonal with all
41+
positive entries) and which contains all the voxels of the implied input
42+
image at their correct output space positions, once resampled into the
43+
output voxel space.
44+
45+
Parameters
46+
----------
47+
mapped_voxels : object or length 2 sequence
48+
If object, has attributes ``shape`` giving input voxel shape, and
49+
``affine`` giving mapping of input voxels to output space. If length 2
50+
sequence, elements are (shape, affine) with same meaning as above. The
51+
affine is a (4, 4) array-like.
52+
voxel_sizes : None or sequence
53+
Gives the diagonal entries of `output_affine` (except the trailing 1
54+
for the homogenous coordinates) (``output_affine == np.diag(voxel_sizes
55+
+ [1])``). If None, return identity `output_affine`.
56+
57+
Returns
58+
-------
59+
output_shape : sequence
60+
Shape of output image that has voxel axes aligned to original image
61+
output space axes, and encloses all the voxel data from the original
62+
image implied by input shape.
63+
output_affine : (4, 4) array
64+
Affine of output image that has voxel axes aligned to the output axes
65+
implied by input affine. Top-left 3 x 3 part of affine is diagonal with
66+
all positive entries. The entries come from `voxel_sizes` if
67+
specified, or are all 1. If the image is < 3D, then the missing
68+
dimensions will have a 1 in the matching diagonal.
69+
"""
70+
try:
71+
in_shape, in_affine = mapped_voxels.shape, mapped_voxels.affine
72+
except AttributeError:
73+
in_shape, in_affine = mapped_voxels
74+
n_axes = len(in_shape)
75+
if n_axes > 3:
76+
raise ValueError('This function can only deal with 3D images')
77+
if n_axes < 3:
78+
in_shape += (1,) * (3 - n_axes)
79+
out_vox = np.ones((3,))
80+
if not voxel_sizes is None:
81+
if not len(voxel_sizes) == n_axes:
82+
raise ValueError('voxel sizes length should match shape')
83+
if not np.all(np.array(voxel_sizes) > 0):
84+
raise ValueError('voxel sizes should all be positive')
85+
out_vox[:n_axes] = voxel_sizes
86+
in_mn_mx = zip([0, 0, 0], np.array(in_shape) - 1)
87+
in_corners = list(product(*in_mn_mx))
88+
out_corners = apply_affine(in_affine, in_corners)
89+
out_mn = out_corners.min(axis=0)
90+
out_mx = out_corners.max(axis=0)
91+
out_shape = np.ceil((out_mx - out_mn) / out_vox) + 1
92+
out_affine = np.diag(list(out_vox) + [1])
93+
out_affine[:3, 3] = out_mn
94+
return tuple(int(i) for i in out_shape[:n_axes]), out_affine
95+
96+
97+
def slice2volume(index, axis, shape=None):
98+
""" Affine expressing selection of a single slice from 3D volume
99+
100+
Imagine we have taken a slice from an image data array, ``s = data[:, :,
101+
index]``. This function returns the affine to map the array coordinates of
102+
``s`` to the array coordinates of ``data``.
103+
104+
This can be useful for resampling a single slice from a volume. For
105+
example, to resample slice ``k`` in the space of ``img1`` from the matching
106+
spatial voxel values in ``img2``, you might do something like::
107+
108+
slice_shape = img1.shape[:2]
109+
slice_aff = slice2volume(k, 2)
110+
whole_aff = np.linalg.inv(img2.affine).dot(img1.affine.dot(slice_aff))
111+
112+
and then use ``whole_aff`` in ``scipy.ndimage.affine_transform``:
113+
114+
rzs, trans = to_matvec(whole_aff)
115+
data = img2.get_data()
116+
new_slice = scipy.ndimage.affine_transform(data, rzs, trans, slice_shape)
117+
118+
Parameters
119+
----------
120+
index : int
121+
index of selected slice
122+
axis : {0, 1, 2}
123+
axis to which `index` applies
124+
125+
Returns
126+
-------
127+
slice_aff : shape (4, 3) affine
128+
Affine relating input coordinates in a slice to output coordinates in
129+
the embedded volume
130+
"""
131+
if index < 0:
132+
raise ValueError("Cannot handle negative index")
133+
if not 0 <= axis <= 2:
134+
raise ValueError("Axis should be between 0 and 2")
135+
axes = list(range(4))
136+
axes.remove(axis)
137+
slice_aff = np.eye(4)[:, axes]
138+
slice_aff[axis, -1] = index
139+
return slice_aff

nibabel/tests/test_spaces.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
""" Tests for spaces module
2+
"""
3+
4+
import numpy as np
5+
import numpy.linalg as npl
6+
7+
from ..spaces import vox2out_vox, slice2volume
8+
from ..affines import apply_affine, from_matvec
9+
from ..nifti1 import Nifti1Image
10+
from ..eulerangles import euler2mat
11+
12+
13+
from numpy.testing import (assert_almost_equal,
14+
assert_array_equal)
15+
16+
from nose.tools import (assert_true, assert_false, assert_raises,
17+
assert_equal, assert_not_equal)
18+
19+
20+
21+
def assert_all_in(in_shape, in_affine, out_shape, out_affine):
22+
slices = tuple(slice(N) for N in in_shape)
23+
n_axes = len(in_shape)
24+
in_grid = np.mgrid[slices]
25+
in_grid = np.rollaxis(in_grid, 0, n_axes + 1)
26+
v2v = npl.inv(out_affine).dot(in_affine)
27+
if n_axes < 3: # reduced dimensions case
28+
new_v2v = np.eye(n_axes + 1)
29+
new_v2v[:n_axes, :n_axes] = v2v[:n_axes, :n_axes]
30+
new_v2v[:n_axes, -1] = v2v[:n_axes, -1]
31+
v2v = new_v2v
32+
out_grid = apply_affine(v2v, in_grid)
33+
TINY = 1e-12
34+
assert_true(np.all(out_grid > -TINY))
35+
assert_true(np.all(out_grid < np.array(out_shape) + TINY))
36+
37+
38+
def test_vox2out_vox():
39+
# Test world space bounding box
40+
# Test basic case, identity, no voxel sizes passed
41+
shape, aff = vox2out_vox(((2, 3, 4), np.eye(4)))
42+
assert_array_equal(shape, (2, 3, 4))
43+
assert_array_equal(aff, np.eye(4))
44+
# Some affines as input to the tests
45+
trans_123 = [[1, 0, 0, 1], [0, 1, 0, 2], [0, 0, 1, 3], [0, 0, 0, 1]]
46+
trans_m123 = [[1, 0, 0, -1], [0, 1, 0, -2], [0, 0, 1, -3], [0, 0, 0, 1]]
47+
rot_3 = from_matvec(euler2mat(np.pi / 4), [0, 0, 0])
48+
for in_shape, in_aff, vox, out_shape, out_aff in (
49+
# Identity
50+
((2, 3, 4), np.eye(4), None, (2, 3, 4), np.eye(4)),
51+
# Flip first axis
52+
((2, 3, 4), np.diag([-1, 1, 1, 1]), None,
53+
(2, 3, 4), [[1, 0, 0, -1], # axis reversed -> -ve offset
54+
[0, 1, 0, 0],
55+
[0, 0, 1, 0],
56+
[0, 0, 0, 1]]),
57+
# zooms for affine > 1 -> larger grid with default 1mm output voxels
58+
((2, 3, 4), np.diag([4, 5, 6, 1]), None,
59+
(5, 11, 19), np.eye(4)),
60+
# set output voxels to be same size as input. back to original shape
61+
((2, 3, 4), np.diag([4, 5, 6, 1]), (4, 5, 6),
62+
(2, 3, 4), np.diag([4, 5, 6, 1])),
63+
# Translation preserved in output
64+
((2, 3, 4), trans_123, None,
65+
(2, 3, 4), trans_123),
66+
((2, 3, 4), trans_m123, None,
67+
(2, 3, 4), trans_m123),
68+
# rotation around 3rd axis
69+
((2, 3, 4), rot_3, None,
70+
# x diff, y diff now 3 cos pi / 4 == 2.12, ceil to 3, add 1
71+
# most negative x now 2 cos pi / 4
72+
(4, 4, 4), [[1, 0, 0, -2 * np.cos(np.pi / 4)],
73+
[0, 1, 0, 0],
74+
[0, 0, 1, 0],
75+
[0, 0, 0, 1]]),
76+
# Less than 3 axes
77+
((2, 3), np.eye(4), None,
78+
(2, 3), np.eye(4)),
79+
((2,), np.eye(4), None,
80+
(2,), np.eye(4)),
81+
# Number of voxel sizes matches length
82+
((2, 3), np.diag([4, 5, 6, 1]), (4, 5),
83+
(2, 3), np.diag([4, 5, 1, 1])),
84+
):
85+
img = Nifti1Image(np.ones(in_shape), in_aff)
86+
for input in ((in_shape, in_aff), img):
87+
shape, aff = vox2out_vox(input, vox)
88+
assert_all_in(in_shape, in_aff, shape, aff)
89+
assert_equal(shape, out_shape)
90+
assert_almost_equal(aff, out_aff)
91+
assert_true(isinstance(shape, tuple))
92+
assert_true(isinstance(shape[0], int))
93+
# Enforce number of axes
94+
assert_raises(ValueError, vox2out_vox, ((2, 3, 4, 5), np.eye(4)))
95+
assert_raises(ValueError, vox2out_vox, ((2, 3, 4, 5, 6), np.eye(4)))
96+
# Voxel sizes must be positive
97+
assert_raises(ValueError, vox2out_vox, ((2, 3, 4), np.eye(4), [-1, 1, 1]))
98+
assert_raises(ValueError, vox2out_vox, ((2, 3, 4), np.eye(4), [1, 0, 1]))
99+
100+
101+
def test_slice2volume():
102+
# Get affine expressing selection of single slice from volume
103+
for axis, def_aff in zip((0, 1, 2), (
104+
[[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]],
105+
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [0, 0, 1]],
106+
[[1, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 1]])):
107+
for val in (0, 5, 10):
108+
exp_aff = np.array(def_aff)
109+
exp_aff[axis, -1] = val
110+
assert_array_equal(slice2volume(val, axis), exp_aff)
111+
assert_raises(ValueError, slice2volume, -1, 0)
112+
assert_raises(ValueError, slice2volume, 0, -1)
113+
assert_raises(ValueError, slice2volume, 0, 3)

0 commit comments

Comments
 (0)