From 71843960e2b6ced19c74e0a8356dd566429d151e Mon Sep 17 00:00:00 2001
From: Paul McCarthy <pauld.mccarthy@gmail.com>
Date: Thu, 12 Jan 2017 15:49:24 +0000
Subject: [PATCH] Nifti instances now allow voxToWorldMat to be edited, and
 notify listeners when it is. New functions in transform module.

---
 fsl/data/image.py      | 171 +++++++++++++++++++++++++++++++----------
 fsl/utils/transform.py | 104 +++++++++++++++++++++++++
 2 files changed, 233 insertions(+), 42 deletions(-)

diff --git a/fsl/data/image.py b/fsl/data/image.py
index eae93b74e..86b16d908 100644
--- a/fsl/data/image.py
+++ b/fsl/data/image.py
@@ -51,7 +51,7 @@ import fsl.data.imagewrapper as imagewrapper
 log = logging.getLogger(__name__)
 
 
-class Nifti(object):
+class Nifti(notifier.Notifier):
     """The ``Nifti`` class is intended to be used as a base class for
     things which either are, or are associated with, a NIFTI image.
     The ``Nifti`` class is intended to represent information stored in
@@ -104,6 +104,15 @@ class Nifti(object):
         :attr:`.constants.NIFTI_XFORM_ANALYZE`.
 
 
+    The ``Nifti`` class implements the :class:`.Notifier` interface - 
+    listeners may register to be notified on the following topics:
+
+    =============== ========================================================
+    ``'transform'`` The affine transformation matrix has changed. This topic
+                    will occur when the ``voxToWorldMat`` is changed.
+    =============== ========================================================
+
+
     .. warning:: The ``header`` field may either be a ``nifti1``, ``nifti2``,
                  or ``analyze`` header object. Make sure to take this into
                  account if you are writing code that should work with all
@@ -144,37 +153,16 @@ class Nifti(object):
         voxToWorldMat = self.__determineTransform(header)
         worldToVoxMat = transform.invert(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
-
-
-    @property
-    def niftiVersion(self):
-        """Returns the NIFTI file version:
-
-           - ``0`` for ANALYZE
-           - ``1`` for NIFTI1
-           - ``2`` for NIFTI2
-        """
-
-        import nibabel as nib
+        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
 
-        # nib.Nifti2 is a subclass of Nifti1,
-        # and Nifti1 a subclass of Analyze,
-        # so we have to check in order
-        if   isinstance(self.header, nib.nifti2.Nifti2Header):   return 2
-        elif isinstance(self.header, nib.nifti1.Nifti1Header):   return 1
-        elif isinstance(self.header, nib.analyze.AnalyzeHeader): return 0
-
-        else: raise RuntimeError('Unrecognised header: {}'.format(self.header))
-
-        
+    
     def __determineTransform(self, header):
         """Called by :meth:`__init__`. Figures out the voxel-to-world
         coordinate transformation matrix that is associated with this
@@ -248,6 +236,87 @@ class Nifti(object):
         return origShape, shape, pixdims
 
 
+    @property
+    def niftiVersion(self):
+        """Returns the NIFTI file version:
+
+           - ``0`` for ANALYZE
+           - ``1`` for NIFTI1
+           - ``2`` for NIFTI2
+        """
+
+        import nibabel as nib
+
+        # nib.Nifti2 is a subclass of Nifti1,
+        # and Nifti1 a subclass of Analyze,
+        # so we have to check in order
+        if   isinstance(self.header, nib.nifti2.Nifti2Header):   return 2
+        elif isinstance(self.header, nib.nifti1.Nifti1Header):   return 1
+        elif isinstance(self.header, nib.analyze.AnalyzeHeader): return 0
+
+        else: raise RuntimeError('Unrecognised header: {}'.format(self.header))
+
+
+    @property
+    def shape(self):
+        """Returns a tuple containing the image data shape. """
+        return tuple(self.__shape)
+
+    
+    @property
+    def pixdim(self):
+        """Returns a tuple containing the image pixdims (voxel sizes)."""
+        return tuple(self.__pixdim)
+
+
+    @property
+    def intent(self):
+        """Returns the NIFTI intent code of this image.
+        """
+        return self.__intent
+
+
+    @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)
+
+
+    @property
+    def voxToWorldMat(self):
+        """Returns a ``numpy`` array of shape ``(4, 4)`` containing an
+        affine transformation from voxel coordinates to world coordinates.
+        """
+        return np.array(self.__voxToWorldMat)
+
+
+    @voxToWorldMat.setter
+    def voxToWorldMat(self, xform):
+        """Update the ``voxToWorldMat``. The ``worldToVoxMat`` and ``pixdim``
+        values are also updated. This will result in notification on the
+        ``'transform'`` topic.
+        """
+
+        header = self.header
+
+        header.set_qform(xform)
+        header.set_sform(xform)
+
+        self.__voxToWorldMat = self.__determineTransform(header)
+        self.__worldToVoxMat = transform.invert(self.__voxToWorldMat)
+        self.__pixdim        = header.get_zooms()
+
+        log.debug('{} affine changed:\npixdims: '
+                  '{}\nsform: {}\nqform: {}'.format(
+                      header.get_zooms(),
+                      header.get_sform(),
+                      header.get_qform()))
+
+        self.notify(topic='transform')
+
+
     def mapIndices(self, sliceobj):
         """Adjusts the given slice object so that it may be used to index the
         underlying ``nibabel`` NIFTI image object.
@@ -267,7 +336,7 @@ class Nifti(object):
     # TODO: Remove this method, and use the shape attribute directly
     def is4DImage(self):
         """Returns ``True`` if this image is 4D, ``False`` otherwise. """
-        return len(self.shape) > 3 and self.shape[3] > 1 
+        return len(self.__shape) > 3 and self.__shape[3] > 1 
 
     
     def getXFormCode(self, code=None):
@@ -350,7 +419,7 @@ class Nifti(object):
         _the_transformation_parameters.3F
         """
         import numpy.linalg as npla
-        return npla.det(self.voxToWorldMat) > 0
+        return npla.det(self.__voxToWorldMat) > 0
 
 
     def sameSpace(self, other):
@@ -358,9 +427,12 @@ class Nifti(object):
         :class:`Nifti` instance) has the same dimensions and is in the
         same space as this image.
         """
-        return np.all(np.isclose(self.shape[:3],     other.shape[:3])) and \
-               np.all(np.isclose(self.pixdim,        other.pixdim))    and \
-               np.all(np.isclose(self.voxToWorldMat, other.voxToWorldMat))
+        return np.all(np.isclose(self .__shape[:3],
+                                 other.__shape[:3])) and \
+               np.all(np.isclose(self .__pixdim,
+                                 other.__pixdim))    and \
+               np.all(np.isclose(self .__voxToWorldMat,
+                                 other.__voxToWorldMat))
 
 
     def getOrientation(self, axis, xform):
@@ -405,7 +477,7 @@ class Nifti(object):
         return code 
 
 
-class Image(Nifti, notifier.Notifier):
+class Image(Nifti):
     """Class which represents a 3D/4D NIFTI image. Internally, the image is
     loaded/stored using a :mod:`nibabel.nifti1.Nifti1Image` or
     :mod:`nibabel.nifti2.Nifti2Image`, and data access managed by a
@@ -439,10 +511,10 @@ class Image(Nifti, notifier.Notifier):
     ============== ===========================================================
 
     
-    The ``Image`` class implements the :class:`.Notifier` interface -
-    listeners may register to be notified of changes to the above properties,
-    by registering on the following _topic_ names (see the :class:`.Notifier`
-    class documentation):
+    The ``Image`` class adds some :class:`.Notifier` topics to those which are
+    already provided by the :class:`Nifti` class - listeners may register to
+    be notified of changes to the above properties, by registering on the
+    following _topic_ names (see the :class:`.Notifier` class documentation):
 
     
     =============== ======================================================
@@ -453,7 +525,8 @@ class Image(Nifti, notifier.Notifier):
                     value (see :meth:`.Notifier.notify`).
     
     ``'saveState'`` This topic is notified whenever the saved state of the
-                    image changes (i.e. it is edited, or saved to disk).
+                    image changes (i.e. data or ``voxToWorldMat`` is
+                    edited, or the image saved to disk).
     
     ``'dataRange'`` This topic is notified whenever the image data range
                     is changed/adjusted.
@@ -596,6 +669,11 @@ class Image(Nifti, notifier.Notifier):
                                                              loadData=loadData,
                                                              threaded=threaded)
 
+        # Listen to ourself for changes
+        # to the voxToWorldMat, so we
+        # can update the saveState.
+        self.register(self.name, self.__transformChanged, topic='transform')
+
         if calcRange:
             self.calcRange()
 
@@ -688,6 +766,15 @@ class Image(Nifti, notifier.Notifier):
         return self.__nibImage.dataobj[tuple(coords)].dtype
 
 
+    def __transformChanged(self, *args, **kwargs):
+        """Called when the ``voxToWorldMat`` of this :class:`Nifti` instance
+        changes. Updates the :attr:`saveState` accordinbgly.
+        """
+        if self.__saveState:
+            self.__saveState = False
+            self.notify(topic='saveState')
+
+
     def __dataRangeChanged(self, *args, **kwargs):
         """Called when the :class:`.ImageWrapper` data range changes.
         Notifies any listeners of this ``Image`` (registered through the
diff --git a/fsl/utils/transform.py b/fsl/utils/transform.py
index bb7a96af5..ed90bf35e 100644
--- a/fsl/utils/transform.py
+++ b/fsl/utils/transform.py
@@ -14,6 +14,10 @@ spaces. The following functions are provided:
    scaleOffsetXform
    invert
    concat
+   compse
+   decompose
+   rotMatToAxisAngles
+   axisAnglesToRotMat
    axisBounds
 """
 
@@ -76,6 +80,106 @@ def scaleOffsetXform(scales, offsets):
     return xform
 
 
+def compose(scales, offsets, rotations, origin=None):
+    """Compose a transformation matrix out of the given scales, offsets
+    and axis rotations.
+
+    :arg scales:    Sequence of three scale values.
+    
+    :arg offsets:   Sequence of three offset values.
+    
+    :arg rotations: Sequence of three rotation values, in radians.
+    
+    :arg origin:    Origin of rotation - must be scaled by the ``scales``.
+                    If not provided, the rotation origin is ``(0, 0, 0)``.
+    """
+
+    preRotate  = np.eye(4)
+    postRotate = np.eye(4)
+    if origin is not None:
+        preRotate[ 0, 3] = -origin[0]
+        preRotate[ 1, 3] = -origin[1]
+        preRotate[ 2, 3] = -origin[2]
+        postRotate[0, 3] =  origin[0]
+        postRotate[1, 3] =  origin[1]
+        postRotate[2, 3] =  origin[2] 
+
+    scale  = np.eye(4, dtype=np.float64)
+    offset = np.eye(4, dtype=np.float64)
+    rotate = np.eye(4, dtype=np.float64)
+    
+    scale[  0,  0] = scales[ 0]
+    scale[  1,  1] = scales[ 1]
+    scale[  2,  2] = scales[ 2]
+    offset[ 0,  3] = offsets[0]
+    offset[ 1,  3] = offsets[1]
+    offset[ 2,  3] = offsets[2]
+    rotate[:3, :3] = axisAnglesToRotMat(*rotations)
+
+    return concat(offset, postRotate, rotate, preRotate, scale)
+
+
+def decompose(xform):
+    """Decomposes the given transformation matrix into separate offsets,
+    scales, and rotations.
+
+    .. note:: Shears are not yet supported, and may never be supported.
+    """
+
+    offsets = xform[:3, 3]
+    scales  = [np.sqrt(np.sum(xform[:3, 0] ** 2)),
+               np.sqrt(np.sum(xform[:3, 1] ** 2)),
+               np.sqrt(np.sum(xform[:3, 2] ** 2))]
+    
+    rotmat         = np.copy(xform[:3, :3])
+    rotmat[:, 0] /= scales[0]
+    rotmat[:, 1] /= scales[1]
+    rotmat[:, 2] /= scales[2]
+
+    rots = rotMatToAxisAngles(rotmat)
+
+    return scales, offsets, rots
+
+
+def rotMatToAxisAngles(rotmat):
+    """Given a ``(3, 3)`` rotation matrix, decomposes the rotations into
+    an angle in radians about each axis.
+    """
+    xrot = np.arctan2(rotmat[2, 1], rotmat[2, 2])
+    yrot = np.sqrt(   rotmat[2, 1] ** 2 + rotmat[2, 2] ** 2)
+    yrot = np.arctan2(rotmat[2, 0], yrot)
+    zrot = np.arctan2(rotmat[1, 0], rotmat[0, 0])
+
+    return [xrot, yrot, zrot]
+
+
+def axisAnglesToRotMat(xrot, yrot, zrot):
+    """Constructs a ``(3, 3)`` rotation matrix from the given angles, which
+    must be specified in radians.
+    """ 
+
+    xmat = np.eye(3)
+    ymat = np.eye(3)
+    zmat = np.eye(3)
+    
+    xmat[1, 1] =  np.cos(xrot)
+    xmat[1, 2] = -np.sin(xrot)
+    xmat[2, 1] =  np.sin(xrot)
+    xmat[2, 2] =  np.cos(xrot)
+
+    ymat[0, 0] =  np.cos(yrot)
+    ymat[0, 2] =  np.sin(yrot)
+    ymat[2, 0] = -np.sin(yrot)
+    ymat[2, 2] =  np.cos(yrot)
+
+    zmat[0, 0] =  np.cos(zrot)
+    zmat[0, 1] = -np.sin(zrot)
+    zmat[1, 0] =  np.sin(zrot)
+    zmat[1, 1] =  np.cos(zrot)
+
+    return concat(zmat, ymat, xmat)
+
+
 def axisBounds(shape,
                xform,
                axes=None,
-- 
GitLab