From 0b1b35bb7f31df4bf4043112f2f19d7bdb063f6e Mon Sep 17 00:00:00 2001
From: Paul McCarthy <pauldmccarthy@gmail.com>
Date: Tue, 9 Apr 2019 12:13:32 +0100
Subject: [PATCH] RF,ENH: Refactor Nifti class - it now pre-calculates affines
 between voxel, fsl, and world coordinate systems. New getAffine method allows
 you to get an affine between any of the coordinate systems

---
 fsl/data/image.py | 288 +++++++++++++++++++++++++++-------------------
 1 file changed, 170 insertions(+), 118 deletions(-)

diff --git a/fsl/data/image.py b/fsl/data/image.py
index 1bf901c28..6832e560f 100644
--- a/fsl/data/image.py
+++ b/fsl/data/image.py
@@ -135,13 +135,32 @@ class Nifti(notifier.Notifier, meta.Meta):
     methods.
 
 
-    **The affine transformation**
+    **Affine transformations**
 
 
-    The :meth:`voxToWorldMat` and :meth:`worldToVoxMat` attributes contain
-    transformation matrices for transforming between voxel and world
-    coordinates. The ``Nifti`` class follows the same process as ``nibabel``
-    in selecting the affine (see
+    The ``Nifti`` class is aware of three coordinate systems:
+
+      - The ``voxel`` coordinate system, used to access image data
+
+      - The ``world`` coordinate system, where voxel coordinates are
+        transformed into a millimetre coordinate system, defined by the
+        ``sform`` and/or ``qform`` elements of the NIFTI header.
+
+      - The ``fsl`` coordinate system, where voxel coordinated are scaled by
+        the ``pixdim`` values in the NIFTI header, and the X axis is inverted
+        if the voxel-to-world affine has a positive determinant.
+
+
+    The :meth:`getAffine` method is a simple means of acquiring an affine
+    which will transform between any of these coordinate systems.
+
+
+    See `here <http://fsl.fmrib.ox.ac.uk/fsl/fslwiki/FLIRT/FAQ#What_is_the_format_of_the_matrix_used_by_FLIRT.2C_and_how_does_it_relate_to_the_transformation_parameters.3F>`_
+    for more details on the ``fsl`` coordinate system..
+
+
+    The ``Nifti`` class follows the same process as ``nibabel`` in determining
+    the ``voxel`` to ``world`` affine (see
     http://nipy.org/nibabel/nifti_images.html#the-nifti-affines):
 
 
@@ -227,20 +246,51 @@ class Nifti(notifier.Notifier, meta.Meta):
         header                   = header
         origShape, shape, pixdim = self.__determineShape(header)
 
-        voxToWorldMat = self.__determineTransform(header)
-        worldToVoxMat = transform.invert(voxToWorldMat)
+        self.header      = header
+        self.__shape     = shape
+        self.__origShape = origShape
+        self.__pixdim    = pixdim
+        self.__intent    = header.get('intent_code',
+                                      constants.NIFTI_INTENT_NONE)
+
+        voxToWorldMat    = self.__determineAffine(header)
+        affines, isneuro = self.__generateAffines(voxToWorldMat)
 
-        self.header          = header
-        self.__shape         = shape
-        self.__intent        = header.get('intent_code',
-                                          constants.NIFTI_INTENT_NONE)
-        self.__origShape     = origShape
-        self.__pixdim        = pixdim
-        self.__voxToWorldMat = voxToWorldMat
-        self.__worldToVoxMat = worldToVoxMat
+        self.__affines        = affines
+        self.__isNeurological = isneuro
 
 
-    def __determineTransform(self, header):
+    def __determineShape(self, header):
+        """This method is called by :meth:`__init__`. It figures out the actual
+        shape of the image data, and the zooms/pixdims for each data axis. Any
+        empty trailing dimensions are squeezed, but the returned shape is
+        guaranteed to be at least 3 dimensions. Returns:
+
+         - A sequence/tuple containing the image shape, as reported in the
+           header.
+         - A sequence/tuple containing the effective image shape.
+         - A sequence/tuple containing the zooms/pixdims.
+        """
+
+        # The canonicalShape function figures out
+        # the data shape that we should use.
+        origShape = list(header.get_data_shape())
+        shape     = canonicalShape(origShape)
+        pixdims   = list(header.get_zooms())
+
+        # if get_zooms() doesn't return at
+        # least len(shape) values, we'll
+        # fall back to the pixdim field in
+        # the header.
+        if len(pixdims) < len(shape):
+            pixdims = header['pixdim'][1:]
+
+        pixdims = pixdims[:len(shape)]
+
+        return origShape, shape, pixdims
+
+
+    def __determineAffine(self, header):
         """Called by :meth:`__init__`. Figures out the voxel-to-world
         coordinate transformation matrix that is associated with this
         ``Nifti`` instance.
@@ -250,26 +300,9 @@ class Nifti(notifier.Notifier, meta.Meta):
         # specially, as FNIRT clobbers the
         # sform section of the NIFTI header
         # to store other data.
-        #
-        # TODO Nibabel <= 2.1.0 has a bug in header.get
-        #      for fields with a value of 0. When this
-        #      bug gets fixed, we can replace the if-else
-        #      block below with this:
-        #
-        #          intent = header.get('intent_code', -1)
-        #          qform  = header.get('qform_code',  -1)
-        #          sform  = header.get('sform_code',  -1)
-        #
-        # TODO Change this in fslpy 2.0.0
-        #
-        if isinstance(header, nib.nifti1.Nifti1Header):
-            intent = header['intent_code']
-            qform  = header['qform_code']
-            sform  = header['sform_code']
-        else:
-            intent = -1
-            qform  = -1
-            sform  = -1
+        intent = header.get('intent_code', -1)
+        qform  = header.get('qform_code',  -1)
+        sform  = header.get('sform_code',  -1)
 
         if intent in (constants.FSL_FNIRT_DISPLACEMENT_FIELD,
                       constants.FSL_CUBIC_SPLINE_COEFFICIENTS,
@@ -300,34 +333,47 @@ class Nifti(notifier.Notifier, meta.Meta):
         return voxToWorldMat
 
 
-    def __determineShape(self, header):
-        """This method is called by :meth:`__init__`. It figures out the actual
-        shape of the image data, and the zooms/pixdims for each data axis. Any
-        empty trailing dimensions are squeezed, but the returned shape is
-        guaranteed to be at least 3 dimensions. Returns:
+    def __generateAffines(self, voxToWorldMat):
+        """Called by :meth:`__init__`, and the :meth:`voxToWorldMat` setter.
+        Generates and returns a dictionary containing affine transformations
+        between the ``voxel``, ``fsl``, and ``world`` coordinate
+        systems. These affines are accessible via the :meth:`getAffine`
+        method.
 
-         - A sequence/tuple containing the image shape, as reported in the
-           header.
-         - A sequence/tuple containing the effective image shape.
-         - A sequence/tuple containing the zooms/pixdims.
+        :arg voxToWorldMat: The voxel-to-world affine transformation
+        :returns:           A tuple containing:
+
+                             - a dictionary of affine transformations between
+                               each pair of coordinate systems
+                             - ``True`` if the image is to be considered
+                               "neurological", ``False`` otherwise - see the
+                               :meth:`isNeurological` method.
         """
 
-        # The canonicalShape function figures out
-        # the data shape that we should use.
-        origShape = list(header.get_data_shape())
-        shape     = canonicalShape(origShape)
-        pixdims   = list(header.get_zooms())
+        import numpy.linalg as npla
 
-        # if get_zooms() doesn't return at
-        # least len(shape) values, we'll
-        # fall back to the pixdim field in
-        # the header.
-        if len(pixdims) < len(shape):
-            pixdims = header['pixdim'][1:]
+        affines = {}
+        shape             = list(self.shape[ :3])
+        pixdim            = list(self.pixdim[:3])
+        voxToScaledVoxMat = np.diag(pixdim + [1.0])
+        isneuro           = npla.det(voxToWorldMat) > 0
 
-        pixdims = pixdims[:len(shape)]
+        if isneuro:
+            x                 = (shape[0] - 1) * pixdim[0]
+            flip              = transform.scaleOffsetXform([-1, 1, 1],
+                                                           [ x, 0, 0])
+            voxToScaledVoxMat = transform.concat(flip, voxToScaledVoxMat)
 
-        return origShape, shape, pixdims
+        affines['voxel', 'world'] = voxToWorldMat
+        affines['world', 'voxel'] = transform.invert(voxToWorldMat)
+        affines['voxel', 'fsl']   = voxToScaledVoxMat
+        affines['fsl',   'voxel'] = transform.invert(voxToScaledVoxMat)
+        affines['fsl',   'world'] = transform.concat(affines['voxel', 'world'],
+                                                     affines['fsl',   'voxel'])
+        affines['world', 'fsl']   = transform.concat(affines['fsl',   'voxel'],
+                                                     affines['voxel', 'world'])
+
+        return affines, isneuro
 
 
     def strval(self, key):
@@ -382,6 +428,16 @@ class Nifti(notifier.Notifier, meta.Meta):
         return tuple(self.__shape)
 
 
+    @property
+    def ndim(self):
+        """Returns the number of dimensions in this image. This number may not
+        match the number of dimensions specified in the NIFTI header, as
+        trailing dimensions of length 1 are ignored. But it is guaranteed to be
+        at least 3.
+        """
+        return len(self.__shape)
+
+
     @property
     def pixdim(self):
         """Returns a tuple containing the image pixdims (voxel sizes)."""
@@ -426,12 +482,39 @@ class Nifti(notifier.Notifier, meta.Meta):
         return units
 
 
+    def getAffine(self, from_, to):
+        """Return an affine transformation which can be used to transform
+        coordinates from ``from_`` to ``to``.
+
+        Valid values for the ``from_`` and ``to`` arguments are:
+
+         - ``'voxel'``: The voxel coordinate system
+         - ``'world'``: The world coordinate system, as defined by the image
+           sform/qform
+         - ``'fsl'``: The FSL coordinate system (scaled voxels, with a
+           left-right flip if the sform/qform has a positive determinant)
+
+        :arg from_: Source coordinate system
+        :arg to:    Destination coordinate system
+        :returns:   A ``numpy`` array of shape ``(4, 4)``
+        """
+        from_ = from_.lower()
+        to    = to   .lower()
+
+        if from_ not in ('voxel', 'fsl', 'world') or \
+           to    not in ('voxel', 'fsl', 'world'):
+            raise ValueError('Invalid source/reference spaces: '
+                             '{} -> {}'.format(from_, to))
+
+        return np.copy(self.__affines[from_, to])
+
+
     @property
     def worldToVoxMat(self):
         """Returns a ``numpy`` array of shape ``(4, 4)`` containing an
         affine transformation from world coordinates to voxel coordinates.
         """
-        return np.array(self.__worldToVoxMat)
+        return self.getAffine('world', 'voxel')
 
 
     @property
@@ -439,7 +522,7 @@ class Nifti(notifier.Notifier, meta.Meta):
         """Returns a ``numpy`` array of shape ``(4, 4)`` containing an
         affine transformation from voxel coordinates to world coordinates.
         """
-        return np.array(self.__voxToWorldMat)
+        return self.getAffine('voxel', 'world')
 
 
     @voxToWorldMat.setter
@@ -463,8 +546,10 @@ class Nifti(notifier.Notifier, meta.Meta):
 
         header.set_sform(xform, code=sformCode)
 
-        self.__voxToWorldMat = self.__determineTransform(header)
-        self.__worldToVoxMat = transform.invert(self.__voxToWorldMat)
+        affines, isneuro = self.__generateAffines(xform)
+
+        self.__affines        = affines
+        self.__isNeurological = isneuro
 
         log.debug('Affine changed:\npixdims: '
                   '%s\nsform: %s\nqform: %s',
@@ -475,6 +560,27 @@ class Nifti(notifier.Notifier, meta.Meta):
         self.notify(topic='transform')
 
 
+    @property
+    def voxToScaledVoxMat(self):
+        """Returns a transformation matrix which transforms from voxel
+        coordinates into scaled voxel coordinates, with a left-right flip
+        if the image appears to be stored in neurological order.
+
+        See http://fsl.fmrib.ox.ac.uk/fsl/fslwiki/FLIRT/FAQ#What_is_the\
+        _format_of_the_matrix_used_by_FLIRT.2C_and_how_does_it_relate_to\
+        _the_transformation_parameters.3F
+        """
+        return self.getAffine('voxel', 'fsl')
+
+
+    @property
+    def scaledVoxToVoxMat(self):
+        """Returns a transformation matrix which transforms from scaled voxels
+        into voxels, the inverse of the :meth:`voxToScaledVoxMat` transform.
+        """
+        return self.getAffine('fsl', 'voxel')
+
+
     def mapIndices(self, sliceobj):
         """Adjusts the given slice object so that it may be used to index the
         underlying ``nibabel`` NIFTI image object.
@@ -490,16 +596,6 @@ class Nifti(notifier.Notifier, meta.Meta):
         return fileslice.canonical_slicers(sliceobj, self.__origShape)
 
 
-    @property
-    def ndim(self):
-        """Returns the number of dimensions in this image. This number may not
-        match the number of dimensions specified in the NIFTI header, as
-        trailing dimensions of length 1 are ignored. But it is guaranteed to be
-        at least 3.
-        """
-        return len(self.__shape)
-
-
     def getXFormCode(self, code=None):
         """This method returns the code contained in the NIFTI header,
         indicating the space to which the (transformed) image is oriented.
@@ -546,6 +642,7 @@ class Nifti(notifier.Notifier, meta.Meta):
 
         return int(code)
 
+
     # TODO Check what has worse performance - hashing
     #      a 4x4 array (via memoizeMD5), or the call
     #      to aff2axcodes. I'm guessing the latter,
@@ -563,7 +660,6 @@ class Nifti(notifier.Notifier, meta.Meta):
         return nib.orientations.aff2axcodes(xform, inaxes)
 
 
-    @memoize.Instanceify(memoize.memoize)
     def isNeurological(self):
         """Returns ``True`` if it looks like this ``Nifti`` object has a
         neurological voxel orientation, ``False`` otherwise. This test is
@@ -582,51 +678,7 @@ class Nifti(notifier.Notifier, meta.Meta):
         _format_of_the_matrix_used_by_FLIRT.2C_and_how_does_it_relate_to\
         _the_transformation_parameters.3F
         """
-        import numpy.linalg as npla
-        return npla.det(self.__voxToWorldMat) > 0
-
-
-    @property
-    def voxToScaledVoxMat(self):
-        """Returns a transformation matrix which transforms from voxel
-        coordinates into scaled voxel coordinates, with a left-right flip
-        if the image appears to be stored in neurological order.
-
-        See http://fsl.fmrib.ox.ac.uk/fsl/fslwiki/FLIRT/FAQ#What_is_the\
-        _format_of_the_matrix_used_by_FLIRT.2C_and_how_does_it_relate_to\
-        _the_transformation_parameters.3F
-        """
-        return self.__voxToScaledVoxMat()
-
-
-    @memoize.Instanceify(memoize.memoize)
-    def __voxToScaledVoxMat(self):
-        """See :meth:`voxToScaledVoxMat`. """
-
-        shape          = list(self.shape[ :3])
-        pixdim         = list(self.pixdim[:3])
-        voxToPixdimMat = np.diag(pixdim + [1.0])
-
-        if self.isNeurological():
-            x              = (shape[0] - 1) * pixdim[0]
-            flip           = transform.scaleOffsetXform([-1, 1, 1], [x, 0, 0])
-            voxToPixdimMat = transform.concat(flip, voxToPixdimMat)
-
-        return voxToPixdimMat
-
-
-    @property
-    def scaledVoxToVoxMat(self):
-        """Returns a transformation matrix which transforms from scaled voxels
-        into voxels, the inverse of the :meth:`voxToScaledVoxMat` transform.
-        """
-        return self.__scaledVoxToVoxMat()
-
-
-    @memoize.Instanceify(memoize.memoize)
-    def __scaledVoxToVoxMat(self):
-        """See :meth:`scaledVoxToVoxMat`. """
-        return transform.invert(self.voxToScaledVoxMat)
+        return self.__isNeurological
 
 
     def sameSpace(self, other):
-- 
GitLab