diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 63c63cafd6b800500622fe1c18b293e8b5d6d9a1..019e6e6eff6f74ab7c951eeb9f32b435507fb3fb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,16 @@ order. ------------------------- +Added +^^^^^ + + +* New :meth:`.Nifti.adjust` method, for creating a copy of a :class:`.Nifti` + header with adjusted shape, pixdims, and affine. This can be useful for + creating a resampling reference. +* New :func:`.affine.rescale` function, for adjusting a scaling matrix. + + Fixed ^^^^^ @@ -14,6 +24,14 @@ Fixed * Improved the algorithm used by the :func:`.mesh.needsFixing` function. +Deprecated +^^^^^^^^^^ + + +* :func:`.calculateMatrix` - its functionality has been moved to the + :func:`.affine.rescale` function. + + 2.7.0 (Wednesday 6th November 2019) ----------------------------------- diff --git a/fsl/data/image.py b/fsl/data/image.py index 4cde726519f32c255c17a37dfc27f9452f4f67da..39f1f7facdd789e197340177f8e73dea77507c31 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -196,7 +196,7 @@ class Nifti(notifier.Notifier, meta.Meta): A ``Nifti`` instance expects to be passed either a ``nibabel.nifti1.Nifti1Header`` or a ``nibabel.nifti2.Nifti2Header``, but - can als encapsulate a ``nibabel.analyze.AnalyzeHeader``. In this case: + can also encapsulate a ``nibabel.analyze.AnalyzeHeader``. In this case: - The image voxel orientation is assumed to be R->L, P->A, I->S. @@ -839,6 +839,73 @@ class Nifti(notifier.Notifier, meta.Meta): return code + def adjust(self, pixdim=None, shape=None, origin=None): + """Return a new ``Nifti`` object with the specified ``pixdim`` or + ``shape``. The affine of the new ``Nifti`` is adjusted accordingly. + + Only one of ``pixdim`` or ``shape`` can be specified. + + See :func:`.affine.rescale` for the meaning of the ``origin`` argument. + + Only the spatial dimensions may be adjusted - use the functions in + the :mod:`.image.resample` module if you need to adjust non-spatial + dimensions. + + :arg pixdim: New voxel dimensions + :arg shape: New image shape + :arg origin: Voxel grid alignment - either ``'centre'`` (the default) + or ``'corner'`` + :returns: A new ``Nifti`` object based on this one, with adjusted + pixdims, shape and affine. + """ + + if ((pixdim is not None) and (shape is not None)) or \ + ((pixdim is None) and (shape is None)): + raise ValueError('Exactly one of pixdim or ' + 'shape must be specified') + + if shape is not None: ndim = len(shape) + else: ndim = len(pixdim) + + # We only allow adjustment of + # the spatial dimensions + if ndim != 3: + raise ValueError('Three dimensions must be specified') + + oldShape = np.array(self.shape[ :ndim]) + oldPixdim = np.array(self.pixdim[:ndim]) + newShape = shape + newPixdim = pixdim + + # if pixdims were specified, + # convert them into a shape, + # and vice versa + if newPixdim is not None: + newShape = oldShape * (oldPixdim / newPixdim) + else: + newPixdim = oldPixdim * (oldShape / newShape) + + # Rescale the voxel-to-world affine + xform = affine.rescale(oldShape, newShape, origin) + xform = affine.concat(self.getAffine('voxel', 'world'), xform) + + # Now that we've got our spatial + # scaling/offset matrix, pad the + # new shape/pixdims with those + # from any non-spatial dimensions + newShape = list(newShape) + list(self.shape[ 3:]) + newPixdim = list(newPixdim) + list(self.pixdim[3:]) + + # And create the new header + # and we're away + header = self.header.copy() + header.set_data_shape(newShape) + header.set_zooms( newPixdim) + header.set_sform( xform) + header.set_qform( xform) + return Nifti(header) + + class Image(Nifti): """Class which represents a NIFTI image. Internally, the image is loaded/stored using a :mod:`nibabel.nifti1.Nifti1Image` or diff --git a/fsl/transform/affine.py b/fsl/transform/affine.py index e0364bf94030c76d6df99a5222ce6439bed8df38..c7fb71f2a0571f8a1318a7e7b84749cfa498a9f2 100644 --- a/fsl/transform/affine.py +++ b/fsl/transform/affine.py @@ -21,6 +21,7 @@ transformations. The following functions are available: axisAnglesToRotMat axisBounds rmsdev + rescale And a few more functions are provided for working with vectors: @@ -582,3 +583,59 @@ def rmsdev(T1, T2, R=None, xc=None): erms = np.sqrt(erms) return erms + + +def rescale(oldShape, newShape, origin=None): + """Calculates an affine matrix to use for resampling. + + This function generates an affine transformation matrix that can be used + to resample an N-D array from ``oldShape`` to ``newShape`` using, for + example, ``scipy.ndimage.affine_transform``. + + The matrix will contain scaling factors derived from the ``oldShape / + newShape`` ratio, and an offset determined by the ``origin``. + + The default value for ``origin`` (``'centre'``) causes the corner voxel of + the output to have the same centre as the corner voxel of the input. If + the origin is ``'corner'``, we apply an offset which effectively causes + the voxel grid corners of the input and output to be aligned. + + :arg oldShape: Shape of input data + :arg newShape: Shape to resample data to + :arg origin: Voxel grid alignment - either ``'centre'`` (the default) or + ``'corner'`` + :returns: An affine resampling matrix + """ + + if origin is None: + origin = 'centre' + + oldShape = np.array(oldShape, dtype=np.float) + newShape = np.array(newShape, dtype=np.float) + ndim = len(oldShape) + + if len(oldShape) != len(newShape): + raise ValueError('Shape mismatch') + + # shapes are the same - no rescaling needed + if np.all(np.isclose(oldShape, newShape)): + return np.eye(ndim + 1) + + # Otherwise we calculate a scaling + # matrix from the old/new shape + # ratio, and specify an offset + # according to the origin + ratio = oldShape / newShape + scale = np.diag(ratio) + + # Calculate an offset from the origin + if origin == 'centre': offset = [0] * ndim + elif origin == 'corner': offset = (ratio - 1) / 2 + + # combine the scales and translations + # to form thte final affine + xform = np.eye(ndim + 1) + xform[:ndim, :ndim] = scale + xform[:ndim, -1] = offset + + return xform diff --git a/fsl/utils/image/resample.py b/fsl/utils/image/resample.py index 50dc0c4022cc5b32f763d8afd43f3d7fb31fba47..7679c56b02a4b31ff91e4fd3b499f63015d38792 100644 --- a/fsl/utils/image/resample.py +++ b/fsl/utils/image/resample.py @@ -10,17 +10,15 @@ to resample an :class:`.Image` object to a different resolution. The :func:`resampleToPixdims` and :func:`resampleToReference` functions are convenience wrappers around :func:`resample`. -The :func:`applySmoothing` and :func:`calculateMatrix` functions are -sub-functions of :func:`resample`. +The :func:`applySmoothing` function is a sub-function of :func:`resample`. """ -import collections.abc as abc - import numpy as np import scipy.ndimage as ndimage import fsl.transform.affine as affine +import fsl.utils.deprecated as deprecated def resampleToPixdims(image, newPixdims, **kwargs): @@ -204,12 +202,11 @@ def resample(image, # old/new shape ratio and the origin # setting. if matrix is None: - matrix = calculateMatrix(data.shape, newShape, origin) + matrix = affine.rescale(data.shape, newShape, origin) - # calculateMatrix will return None - # if it decides that the image + # identity matrix? the image # doesn't need to be resampled - if matrix is None: + if np.all(np.isclose(matrix, np.eye(len(newShape) + 1))): return data, image.voxToWorldMat newShape = np.array(np.round(newShape), dtype=np.int) @@ -230,9 +227,9 @@ def resample(image, # Construct an affine transform which # puts the resampled image into the # same world coordinate system as this - # image. The calculateMatrix function - # might not return a 4x4 matrix, so we - # make sure it is valid. + # image. We may be working with >3D data, + # so here we discard the non-spatial + # parts of the resampling matrix if matrix.shape != (4, 4): rotmat = matrix[:3, :3] offsets = matrix[:3, -1] @@ -286,53 +283,11 @@ def applySmoothing(data, matrix, newShape): return ndimage.gaussian_filter(data, sigma) +@deprecated.deprecated('2.8.0', '3.0.0', + 'Use fsl.transform.affine.rescale instead') def calculateMatrix(oldShape, newShape, origin): - """Calculates an affine matrix to use for resampling. - - Called by :func:`resample`. The matrix will contain scaling factors - determined from the ``oldShape / newShape`` ratio, and an offset - determined from the ``origin``. - - :arg oldShape: Shape of input data - :arg newShape: Shape to resample data to - :arg origin: Voxel grid alignment - either ``'centre'`` or ``'corner'`` - :returns: An affine matrix that can be passed to - ``scipy.ndimage.affine_transform``. - """ - - oldShape = np.array(oldShape, dtype=np.float) - newShape = np.array(newShape, dtype=np.float) - - if np.all(np.isclose(oldShape, newShape)): + """Deprecated - use :func:`.affine.rescale` instead. """ + xform = affine.rescale(oldShape, newShape, origin) + if np.all(np.isclose(xform, np.eye(len(oldShape) + 1))): return None - - # Otherwise we calculate a - # scaling matrix from the - # old/new shape ratio, and - # specify an offset - # according to the origin - else: - ratio = oldShape / newShape - scale = np.diag(ratio) - - # Calculate an offset from the - # origin - the default behaviour - # (centre) causes the corner voxel - # of the output to have the same - # centre as the corner voxel of - # the input. If the origin is - # 'corner', we apply an offset - # which effectively causes the - # voxel grids of the input and - # output to be aligned. - if origin == 'centre': offset = 0 - elif origin == 'corner': offset = list((ratio - 1) / 2) - - if not isinstance(offset, abc.Sequence): - offset = [offset] * len(newShape) - - # ndimage.affine_transform will accept - # a matrix of shape (ndim, ndim + 1) - matrix = np.hstack((scale, np.atleast_2d(offset).T)) - - return matrix + return xform[:-1, :] diff --git a/tests/test_image.py b/tests/test_image.py index ace4dcb510006bc71acf2c9aceba4493d2944c9c..3bb77d01a3d8f6e7d559fc79796db7097c04615e 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1430,3 +1430,28 @@ def test_loadMetadata(): gotmeta = fslimage.loadMetadata(img) assert gotmeta == meta + + +def test_adjust(): + + with tempdir(): + make_image('image.nii', dims=(10, 10, 10), pixdims=(1, 1, 1)) + + img = fslimage.Image('image.nii') + + assert img.sameSpace(img.adjust(pixdim=(1, 1, 1))) + assert img.sameSpace(img.adjust(shape=(10, 10, 10))) + + adj = img.adjust(shape=(20, 20, 20)) + assert adj.shape == (20, 20, 20) + assert adj.pixdim == (0.5, 0.5, 0.5) + + adj = img.adjust(pixdim=(0.5, 0.5, 0.5)) + assert adj.shape == (20, 20, 20) + assert adj.pixdim == (0.5, 0.5, 0.5) + + + adj = img.adjust(shape=(8, 7, 11), origin='corner') + imgb = affine.axisBounds(img.shape, img.voxToWorldMat) + adjb = affine.axisBounds(adj.shape, adj.voxToWorldMat) + assert np.all(np.isclose(imgb, adjb, rtol=1e-5, atol=1e-5)) diff --git a/tests/test_transform/test_affine.py b/tests/test_transform/test_affine.py index 7a69b86e4d79531f53d52be991ba5afe994b248a..79a17fba6f9a3895b124b06f279be1c9b9f1b0f9 100644 --- a/tests/test_transform/test_affine.py +++ b/tests/test_transform/test_affine.py @@ -528,3 +528,48 @@ def test_rmsdev(): assert result < lastdist lastdist = result + + +def test_rescale(): + + with pytest.raises(ValueError): + affine.rescale((5, 5), (10, 10, 10)) + + assert np.all(affine.rescale((5, 5), (5, 5)) == np.eye(3)) + assert np.all(affine.rescale((5, 5, 5), (5, 5, 5)) == np.eye(4)) + assert np.all(affine.rescale((5, 5, 5, 5), (5, 5, 5, 5)) == np.eye(5)) + + # (old shape, new shape, origin, expect) + tests = [ + ((5, 5), (10, 10), 'centre', np.array([[0.5, 0, 0], + [0, 0.5, 0], + [0, 0, 1]])), + ((5, 5), (10, 10), 'corner', np.array([[0.5, 0, -0.25], + [0, 0.5, -0.25], + [0, 0, 1]])), + ((5, 5, 5), (10, 10, 10), 'centre', np.array([[0.5, 0, 0, 0], + [0, 0.5, 0, 0], + [0, 0, 0.5, 0], + [0, 0, 0, 1]])), + ((5, 5, 5), (10, 10, 10), 'corner', np.array([[0.5, 0, 0, -0.25], + [0, 0.5, 0, -0.25], + [0, 0, 0.5, -0.25], + [0, 0, 0, 1]])), + ((5, 5, 5, 5), (10, 10, 10, 10), 'centre', np.array([[0.5, 0, 0, 0, 0], + [0, 0.5, 0, 0, 0], + [0, 0, 0.5, 0, 0], + [0, 0, 0, 0.5, 0], + [0, 0, 0, 0, 1]])), + ((5, 5, 5, 5), (10, 10, 10, 10), 'corner', np.array([[0.5, 0, 0, 0, -0.25], + [0, 0.5, 0, 0, -0.25], + [0, 0, 0.5, 0, -0.25], + [0, 0, 0, 0.5, -0.25], + [0, 0, 0, 0, 1]])), + ] + + for oldshape, newshape, origin, expect in tests: + + got = affine.rescale(oldshape, newshape, origin) + assert np.all(np.isclose(got, expect)) + got = affine.rescale(newshape, oldshape, origin) + assert np.all(np.isclose(got, affine.invert(expect)))