diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ebb88428d9544339a06c4ba56797bbcfeb2bb763..54fcdefa352f5ea5e00a1fde425a01c3ed368d65 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,18 @@ Added
 
 * New :mod:`.image.roi` module, for exracting an ROI of an image, or expanding
   its field-of-view.
+* New :meth:`.Image.getAffine` method, for retrieving an affine between any of
+  the voxel, FSL, or world coordinate systems.
+* New :mod:`fsl.transforms` package, which contains classes and functions for
+  working with linear and non-linear FLIRT and FNIRT transformations.
+* New static methods :meth:`.Nifti.determineShape`,
+  :meth:`.Nifti.determineAffine`, :meth:`.Nifti.generateAffines`, and
+  :meth:`.Nifti.identifyAffine`.
+* New prototype :mod:`fsl.transforms.x5`  module, for reading/writing linear
+  and non-linear X5 files (*preliminary release, subject to change*).
+* New prototype :mod:`.fsl_convert_x5` :mod:`.fsl_apply_x5` programs, for
+  working with X5 transformations (*preliminary release, subject to change*).
+
 
 
 Changed
@@ -20,6 +32,16 @@ Changed
 
 * The :mod:`.resample_image` script has been updated to support resampling of
   images with more than 3 dimensions.
+* `h5py <https://www.h5py.org/>`_ has been added to the ``fslpy`` dependencies.
+
+
+Deprecated
+^^^^^^^^^^
+
+
+* The :mod:`fsl.utils.transform` module has been deprecated its functions can
+  now be found in the :mod:`fsl.transforms.affine` and
+  :mod:`fsl.transform.flirt` modules.
 
 
 2.3.1 (Friday July 5th 2019)
@@ -169,7 +191,7 @@ Added
 * Simple built-in :mod:`.deprecated` decorator.
 * New :mod:`fsl.data.utils` module, which currently contains one function
   :func:`.guessType`, which guesses the data type of a file/directory path.
-* New `.commonBase` function for finding the common prefix of a set of
+* New :func:`.commonBase` function for finding the common prefix of a set of
   file/directory paths.
 
 
@@ -209,7 +231,7 @@ Fixed
 ^^^^^
 
 
-* Fixed an issue with the `.dicom.loadSeries` using memory-mapping for
+* Fixed an issue with the :func:`.dicom.loadSeries` using memory-mapping for
   image files that would subsequently be deleted.
 * Fixed an issue in the :class:`.GiftiMesh` class, where
   ``numpy``/``nibabel`` was returning read-only index arrays.
diff --git a/README.rst b/README.rst
index ac2f6b895b93c2ab54cd6db2ec719754e7200bfe..6b0f069bb8212acae7a37cab9ba5dc18b0ba4ca7 100644
--- a/README.rst
+++ b/README.rst
@@ -91,6 +91,10 @@ The ``rtree`` library assumes that ``libspatialindex`` is installed on
 your system.
 
 
+The :mod:`fsl.transform.x5` module uses `h5py <https://www.h5py.org/>`_,
+which requires ``libhdf5``.
+
+
 Documentation
 -------------
 
diff --git a/doc/_static/theme_overrides.css b/doc/_static/theme_overrides.css
new file mode 100644
index 0000000000000000000000000000000000000000..7c1a5202231086af91e2bd4b388d8de4b064f27f
--- /dev/null
+++ b/doc/_static/theme_overrides.css
@@ -0,0 +1,10 @@
+/* override table width restrictions */
+.wy-table-responsive table td, .wy-table-responsive table th {
+    white-space: normal;
+}
+
+.wy-table-responsive {
+    margin-bottom: 24px;
+    max-width: 100%;
+    overflow: visible;
+}
diff --git a/doc/conf.py b/doc/conf.py
index a7fb2b4d202b8f8256f827ccad8cf34c42860d32..90254c22f1cee57d5fc65a2cdfe395e0a27f3a93 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -33,7 +33,8 @@ date = datetime.date.today()
 # ones.
 extensions = [
     'sphinx.ext.autodoc',
-    'sphinx.ext.autosummary', 
+    'sphinx.ext.viewcode',
+    'sphinx.ext.autosummary',
     'sphinx.ext.mathjax',
     'sphinx.ext.graphviz',
     'sphinx.ext.todo',
@@ -121,6 +122,7 @@ pygments_style = 'sphinx'
 # a list of builtin themes.
 html_theme = 'sphinx_rtd_theme'
 
+
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
@@ -148,7 +150,13 @@ html_theme = 'sphinx_rtd_theme'
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = []
+html_static_path = ['_static']
+
+html_context = {
+    'css_files': [
+        '_static/theme_overrides.css',  # overrides for wide tables in RTD theme
+        ],
+    }
 
 # Add any extra paths that contain custom files (such as robots.txt or
 # .htaccess) here, relative to this directory. These files are copied
@@ -354,7 +362,7 @@ epub_exclude_files = ['search.html']
 autoclass_content = 'class'
 
 # Document private members and special members (e.g. __init__)
-autodocsourc_default_flags = ['private-members', 'special-members']
+autodoc_default_flags = ['private-members', 'special-members']
 
 # Documentation for python modules is in the same order
 # as the source code.
@@ -368,7 +376,7 @@ def autodoc_skip_member(app, what, name, obj, skip, options):
     if what == 'class':
         attName = name.split('.')[-1]
         return skip or attName.startswith('_sync_')
-    
+
     return skip or False
 
 
diff --git a/doc/contributing.rst b/doc/contributing.rst
index 4e76856b7505fe98a204090b96afa0dcb6cd825a..ef710683b31c8094be90fe39a44d85d2e3e20be4 100644
--- a/doc/contributing.rst
+++ b/doc/contributing.rst
@@ -34,7 +34,7 @@ the following labels (this convention has been inherited from `nibabel
 
   * *BF*  : bug fix
   * *RF*  : refactoring
-  * *NF*  : new feature
+  * *ENH*:  enhancement/new feature
   * *BW*  : addresses backward-compatibility
   * *OPT* : optimization
   * *BK*  : breaks something and/or tests fail
@@ -102,7 +102,7 @@ Unit and integration tests are currently run with ``py.test`` and
 ``coverage``.
 
 - Aim for 100% code coverage.
-- Tests must pass on python 2.7, 3.4, 3.5, and 3.6
+- Tests must pass on python 3.5, 3.6, and 3.7.
 
 
 Coding conventions
diff --git a/doc/fsl.rst b/doc/fsl.rst
index fb702bca8580962ba9fd1da2a06c2e895efa767a..c92138a86ddb95c109a747fa3a4624d6d74613d5 100644
--- a/doc/fsl.rst
+++ b/doc/fsl.rst
@@ -7,6 +7,7 @@
    fsl.data
    fsl.scripts
    fsl.utils
+   fsl.transform
    fsl.version
    fsl.wrappers
 
diff --git a/doc/fsl.scripts.fsl_convert_x5.rst b/doc/fsl.scripts.fsl_convert_x5.rst
new file mode 100644
index 0000000000000000000000000000000000000000..827d483b40e1ef773866243d763abd32df530c27
--- /dev/null
+++ b/doc/fsl.scripts.fsl_convert_x5.rst
@@ -0,0 +1,7 @@
+``fsl.scripts.fsl_convert_x5``
+==============================
+
+.. automodule:: fsl.scripts.fsl_convert_x5
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/doc/fsl.scripts.rst b/doc/fsl.scripts.rst
index ece0e2b6e0731daa11612a583244a955bfcf0f99..743a3c12db30828d94c0ec0559a03b1248ae5744 100644
--- a/doc/fsl.scripts.rst
+++ b/doc/fsl.scripts.rst
@@ -5,6 +5,7 @@
    :hidden:
 
    fsl.scripts.atlasq
+   fsl.scripts.fsl_convert_x5
    fsl.scripts.fsl_ents
    fsl.scripts.imcp
    fsl.scripts.imglob
diff --git a/doc/fsl.transform.affine.rst b/doc/fsl.transform.affine.rst
new file mode 100644
index 0000000000000000000000000000000000000000..93792be10f033d6b36801012d2a223dd05d75409
--- /dev/null
+++ b/doc/fsl.transform.affine.rst
@@ -0,0 +1,7 @@
+``fsl.transform.affine``
+========================
+
+.. automodule:: fsl.transform.affine
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/doc/fsl.utils.transform.rst b/doc/fsl.transform.flirt.rst
similarity index 57%
rename from doc/fsl.utils.transform.rst
rename to doc/fsl.transform.flirt.rst
index b21d270e979bf113d60e5966879b1586d4292d65..836a82f49332eecf65e5ce70ca1778619a1f20d0 100644
--- a/doc/fsl.utils.transform.rst
+++ b/doc/fsl.transform.flirt.rst
@@ -1,7 +1,7 @@
-``fsl.utils.transform``
+``fsl.transform.flirt``
 =======================
 
-.. automodule:: fsl.utils.transform
+.. automodule:: fsl.transform.flirt
     :members:
     :undoc-members:
     :show-inheritance:
diff --git a/doc/fsl.transform.fnirt.rst b/doc/fsl.transform.fnirt.rst
new file mode 100644
index 0000000000000000000000000000000000000000..4a1efe50fc5ed17c2844bf49a8838bd467dc245e
--- /dev/null
+++ b/doc/fsl.transform.fnirt.rst
@@ -0,0 +1,7 @@
+``fsl.transform.fnirt``
+=======================
+
+.. automodule:: fsl.transform.fnirt
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/doc/fsl.transform.nonlinear.rst b/doc/fsl.transform.nonlinear.rst
new file mode 100644
index 0000000000000000000000000000000000000000..d137a81f30c54cb5e436047307feca854353cea6
--- /dev/null
+++ b/doc/fsl.transform.nonlinear.rst
@@ -0,0 +1,7 @@
+``fsl.transform.nonlinear``
+===========================
+
+.. automodule:: fsl.transform.nonlinear
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/doc/fsl.transform.rst b/doc/fsl.transform.rst
new file mode 100644
index 0000000000000000000000000000000000000000..fdbd859adaa5828574327c49463d3084d0cd9769
--- /dev/null
+++ b/doc/fsl.transform.rst
@@ -0,0 +1,16 @@
+``fsl.transform``
+=================
+
+.. toctree::
+   :hidden:
+
+   fsl.transform.affine
+   fsl.transform.flirt
+   fsl.transform.fnirt
+   fsl.transform.nonlinear
+   fsl.transform.x5
+
+.. automodule:: fsl.transform
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/doc/fsl.transform.x5.rst b/doc/fsl.transform.x5.rst
new file mode 100644
index 0000000000000000000000000000000000000000..9b6978cf742e613b850e3157aa78cc489661066e
--- /dev/null
+++ b/doc/fsl.transform.x5.rst
@@ -0,0 +1,7 @@
+``fsl.transform.x5``
+====================
+
+.. automodule:: fsl.transform.x5
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/doc/fsl.utils.rst b/doc/fsl.utils.rst
index 5722c7eb4f726f3b17c1ce32f7edee2f85779e4a..f253bac7e11914f3d5ccebcd152a576cd326ce66 100644
--- a/doc/fsl.utils.rst
+++ b/doc/fsl.utils.rst
@@ -23,7 +23,6 @@
    fsl.utils.run
    fsl.utils.settings
    fsl.utils.tempdir
-   fsl.utils.transform
    fsl.utils.weakfuncref
 
 .. automodule:: fsl.utils
diff --git a/doc/images/fnirt_coefficient_field.png b/doc/images/fnirt_coefficient_field.png
new file mode 100644
index 0000000000000000000000000000000000000000..b4d5f548dfae121874b4e24b7a058c8e16fc4938
Binary files /dev/null and b/doc/images/fnirt_coefficient_field.png differ
diff --git a/doc/images/fnirt_coefficient_field.xcf b/doc/images/fnirt_coefficient_field.xcf
new file mode 100644
index 0000000000000000000000000000000000000000..0470fe8bd4bd418e9c2ffcd720ef80bcc3198a14
Binary files /dev/null and b/doc/images/fnirt_coefficient_field.xcf differ
diff --git a/doc/images/fnirt_warp_field.png b/doc/images/fnirt_warp_field.png
new file mode 100644
index 0000000000000000000000000000000000000000..734f30b5aa97e0c09f1512119daa5c8620b999f9
Binary files /dev/null and b/doc/images/fnirt_warp_field.png differ
diff --git a/doc/images/fnirt_warp_field.xcf b/doc/images/fnirt_warp_field.xcf
new file mode 100644
index 0000000000000000000000000000000000000000..2d1a9cca1b475652801c2344b54ab225c999ab61
Binary files /dev/null and b/doc/images/fnirt_warp_field.xcf differ
diff --git a/doc/images/nonlinear_registration_process.png b/doc/images/nonlinear_registration_process.png
new file mode 100644
index 0000000000000000000000000000000000000000..3232f02f0805a34c5a49f8bf353308f6b2fc13b8
Binary files /dev/null and b/doc/images/nonlinear_registration_process.png differ
diff --git a/doc/images/nonlinear_registration_process.xcf b/doc/images/nonlinear_registration_process.xcf
new file mode 100644
index 0000000000000000000000000000000000000000..6bd91d952323fe9135105878c442879adc87eed3
Binary files /dev/null and b/doc/images/nonlinear_registration_process.xcf differ
diff --git a/doc/mock_modules.txt b/doc/mock_modules.txt
index 8bb6c230a3289007c59d160f6b6ec1cb7572b809..a3d52e045b97b85ef44d551318e5bb84a5b2b6ff 100644
--- a/doc/mock_modules.txt
+++ b/doc/mock_modules.txt
@@ -1,4 +1,5 @@
 deprecation
+h5py
 nibabel
 nibabel.fileslice
 nibabel.freesurfer
@@ -6,3 +7,4 @@ numpy
 numpy.linalg
 scipy
 scipy.ndimage
+scipy.ndimage.interpolation
diff --git a/fsl/__init__.py b/fsl/__init__.py
index d0a5a517ec659fafefaa8b1d582ff5369c2e55fc..1a3917dc763ec2e28bb5f21fe00ebe71154590fe 100644
--- a/fsl/__init__.py
+++ b/fsl/__init__.py
@@ -13,6 +13,7 @@ following sub-packages:
    fsl.data
    fsl.utils
    fsl.scripts
+   fsl.transform
    fsl.version
    fsl.wrappers
 
@@ -22,4 +23,4 @@ following sub-packages:
           for details.
 """
 
-__path__ = __import__('pkgutil').extend_path(__path__, __name__)
+__path__ = __import__('pkgutil').extend_path(__path__, __name__)  # noqa
diff --git a/fsl/data/atlases.py b/fsl/data/atlases.py
index b28eb952c98ccbda29f2097ca8eda9ae660cba4c..8d5dad9bd679b4bd8786de4a5a8e41c21db3d310 100644
--- a/fsl/data/atlases.py
+++ b/fsl/data/atlases.py
@@ -54,7 +54,7 @@ import fsl.data.image                     as fslimage
 import fsl.data.constants                 as constants
 from   fsl.utils.platform import platform as platform
 import fsl.utils.image.resample           as resample
-import fsl.utils.transform                as transform
+import fsl.transform.affine               as affine
 import fsl.utils.notifier                 as notifier
 import fsl.utils.settings                 as fslsettings
 
@@ -572,7 +572,7 @@ class AtlasDescription(object):
         # Load the appropriate transformation matrix
         # and transform all those voxel coordinates
         # into world coordinates
-        coords = transform.transform(coords, self.xforms[0])
+        coords = affine.transform(coords, self.xforms[0])
 
         # Update the coordinates
         # in our label objects
@@ -810,7 +810,7 @@ class LabelAtlas(Atlas):
         """
 
         if not voxel:
-            loc = transform.transform([loc], self.worldToVoxMat)[0]
+            loc = affine.transform([loc], self.worldToVoxMat)[0]
             loc = [int(v) for v in loc.round()]
 
         if loc[0] <  0             or \
@@ -983,7 +983,7 @@ class ProbabilisticAtlas(Atlas):
         """
 
         if not voxel:
-            loc = transform.transform([loc], self.worldToVoxMat)[0]
+            loc = affine.transform([loc], self.worldToVoxMat)[0]
             loc = [int(v) for v in loc.round()]
 
         if loc[0] <  0             or \
diff --git a/fsl/data/featanalysis.py b/fsl/data/featanalysis.py
index 7c38eb7773465fa79cc952e4038c38451e7e3b97..718ef679b0502337ecf5aba68a481b336fedd0e6 100644
--- a/fsl/data/featanalysis.py
+++ b/fsl/data/featanalysis.py
@@ -42,16 +42,15 @@ The following functions return the names of various files of interest:
 """
 
 
-import                        collections
-import                        logging
-import os.path             as op
-import numpy               as np
+import                   collections
+import                   logging
+import os.path        as op
+import numpy          as np
 
-import fsl.utils.path      as fslpath
-import fsl.utils.transform as transform
-
-from . import image as fslimage
-from . import          featdesign
+import fsl.utils.path       as fslpath
+import fsl.transform.affine as affine
+from . import image         as fslimage
+from . import                  featdesign
 
 
 log = logging.getLogger(__name__)
@@ -467,9 +466,9 @@ def loadClusterResults(featdir, settings, contrast):
         zcog    = [c.zcogx,    c.zcogy,    c.zcogz]
         copemax = [c.copemaxx, c.copemaxy, c.copemaxz]
 
-        zmax    = transform.transform([zmax],    coordXform)[0].round()
-        zcog    = transform.transform([zcog],    coordXform)[0].round()
-        copemax = transform.transform([copemax], coordXform)[0].round()
+        zmax    = affine.transform([zmax],    coordXform)[0].round()
+        zcog    = affine.transform([zcog],    coordXform)[0].round()
+        copemax = affine.transform([copemax], coordXform)[0].round()
 
         c.zmaxx,   c.zmaxy,    c.zmaxz    = zmax
         c.zcogx,   c.zcogy,    c.zcogz    = zcog
diff --git a/fsl/data/image.py b/fsl/data/image.py
index 1bf901c287d5c2ba3ea033e11f08fdb79cc55e3d..dd423d4b5f8fc5341ea104378e5cf1d58e3aed1f 100644
--- a/fsl/data/image.py
+++ b/fsl/data/image.py
@@ -34,6 +34,7 @@ and file names:
 
 import                      os
 import os.path           as op
+import itertools         as it
 import                      string
 import                      logging
 import                      tempfile
@@ -46,7 +47,7 @@ import nibabel           as nib
 import nibabel.fileslice as fileslice
 
 import fsl.utils.meta        as meta
-import fsl.utils.transform   as transform
+import fsl.transform.affine  as affine
 import fsl.utils.notifier    as notifier
 import fsl.utils.memoize     as memoize
 import fsl.utils.path        as fslpath
@@ -135,13 +136,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 coordinates 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):
 
 
@@ -204,9 +224,11 @@ class Nifti(notifier.Notifier, meta.Meta):
 
     =============== ========================================================
     ``'transform'`` The affine transformation matrix has changed. This topic
-                    will occur when the ``voxToWorldMat`` is changed.
+                    will occur when the :meth:`voxToWorldMat` is changed.
+    ``'header'``    A header field has changed. This will occur when the
+                    :meth:`intent` is changed.
     =============== ========================================================
-    """
+    """  # noqa
 
 
     def __init__(self, header):
@@ -225,22 +247,59 @@ class Nifti(notifier.Notifier, meta.Meta):
             raise ValueError('Unrecognised header: {}'.format(header))
 
         header                   = header
-        origShape, shape, pixdim = self.__determineShape(header)
+        origShape, shape, pixdim = Nifti.determineShape(header)
+        voxToWorldMat            = Nifti.determineAffine(header)
+        affines, isneuro         = Nifti.generateAffines(voxToWorldMat,
+                                                         shape,
+                                                         pixdim)
+
+        self.header           = header
+        self.__shape          = shape
+        self.__origShape      = origShape
+        self.__pixdim         = pixdim
+        self.__affines        = affines
+        self.__isNeurological = isneuro
+
+
+    @staticmethod
+    def determineShape(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.
+        """
 
-        voxToWorldMat = self.__determineTransform(header)
-        worldToVoxMat = transform.invert(voxToWorldMat)
+        # 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())
 
-        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
+        # 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)]
+
+        # should never happen, but if we only
+        # have zoom values for the original
+        # (< 3D) shape, pad them with 1s.
+        if len(pixdims) < len(shape):
+            pixdims = pixdims + [1] * (len(shape) - len(pixdims))
 
-    def __determineTransform(self, header):
+        return origShape, shape, pixdims
+
+
+    @staticmethod
+    def determineAffine(header):
         """Called by :meth:`__init__`. Figures out the voxel-to-world
         coordinate transformation matrix that is associated with this
         ``Nifti`` instance.
@@ -250,33 +309,40 @@ 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
-
-        if intent in (constants.FSL_FNIRT_DISPLACEMENT_FIELD,
+        intent = header.get('intent_code', -1)
+        qform  = header.get('qform_code',  -1)
+        sform  = header.get('sform_code',  -1)
+
+        # FNIRT non-linear coefficient files
+        # clobber the sform/qform/intent
+        # and pixdims of the nifti header,
+        # so we can't correctly place it in
+        # the world coordinate system. See
+        # $FSLDIR/src/fnirt/fnirt_file_writer.cpp
+        # and fsl.transform.nonlinear for more
+        # details.
+        if intent in (constants.FSL_DCT_COEFFICIENTS,
                       constants.FSL_CUBIC_SPLINE_COEFFICIENTS,
-                      constants.FSL_DCT_COEFFICIENTS,
-                      constants.FSL_QUADRATIC_SPLINE_COEFFICIENTS):
-            log.debug('FNIRT output image detected - using qform matrix')
-            voxToWorldMat = np.array(header.get_qform())
+                      constants.FSL_QUADRATIC_SPLINE_COEFFICIENTS,
+                      constants.FSL_TOPUP_CUBIC_SPLINE_COEFFICIENTS,
+                      constants.FSL_TOPUP_QUADRATIC_SPLINE_COEFFICIENTS):
+
+            log.debug('FNIRT coefficient field detected - generating affine')
+
+            # Knot spacing is stored in the pixdims
+            # (specified in terms of reference image
+            # voxels), and reference image pixdims
+            # are stored as intent code parameters.
+            # If we combine the two, we can at least
+            # get the shape/size of the coefficient
+            # field about right
+            knotpix       =  header.get_zooms()[:3]
+            refpix        = (header.get('intent_p1', 1) or 1,
+                             header.get('intent_p2', 1) or 1,
+                             header.get('intent_p3', 1) or 1)
+            voxToWorldMat = affine.concat(
+                affine.scaleOffsetXform(refpix,  0),
+                affine.scaleOffsetXform(knotpix, 0))
 
         # If the qform or sform codes are unknown,
         # then we can't assume that the transform
@@ -290,7 +356,7 @@ class Nifti(notifier.Notifier, meta.Meta):
         # should just be a straight scaling matrix.
         elif qform == 0 and sform == 0:
             pixdims       = header.get_zooms()
-            voxToWorldMat = transform.scaleOffsetXform(pixdims, 0)
+            voxToWorldMat = affine.scaleOffsetXform(pixdims, 0)
 
         # Otherwise we let nibabel decide
         # which transform to use.
@@ -300,34 +366,102 @@ 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:
+    @staticmethod
+    def generateAffines(voxToWorldMat, shape, pixdim):
+        """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.
+
+        :arg voxToWorldMat: The voxel-to-world affine transformation
+        :arg shape:         Image shape (number of voxels along each dimension
+        :arg pixdim:        Image pixdims (size of one voxel along each
+                            dimension)
+        :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.
+        """
 
-         - 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.
+        import numpy.linalg as npla
+
+        affines = {}
+        shape             = list(shape[ :3])
+        pixdim            = list(pixdim[:3])
+        voxToScaledVoxMat = np.diag(pixdim + [1.0])
+        isneuro           = npla.det(voxToWorldMat) > 0
+
+        if isneuro:
+            x                 = (shape[0] - 1) * pixdim[0]
+            flip              = affine.scaleOffsetXform([-1, 1, 1],
+                                                        [ x, 0, 0])
+            voxToScaledVoxMat = affine.concat(flip, voxToScaledVoxMat)
+
+        affines['fsl',   'fsl']   = np.eye(4)
+        affines['voxel', 'voxel'] = np.eye(4)
+        affines['world', 'world'] = np.eye(4)
+        affines['voxel', 'world'] = voxToWorldMat
+        affines['world', 'voxel'] = affine.invert(voxToWorldMat)
+        affines['voxel', 'fsl']   = voxToScaledVoxMat
+        affines['fsl',   'voxel'] = affine.invert(voxToScaledVoxMat)
+        affines['fsl',   'world'] = affine.concat(affines['voxel', 'world'],
+                                                  affines['fsl',   'voxel'])
+        affines['world', 'fsl']   = affine.concat(affines['voxel',   'fsl'],
+                                                  affines['world', 'voxel'])
+
+        return affines, isneuro
+
+
+    @staticmethod
+    def identifyAffine(image, xform, from_=None, to=None):
+        """Attempt to identify the source or destination space for the given
+        affine.
+
+        ``xform`` is assumed to be an affine transformation which can be used
+        to transform coordinates between two coordinate systems associated with
+        ``image``.
+
+        If one of ``from_`` or ``to`` is provided, the other will be derived.
+        If neither are provided, both will be derived. See the
+        :meth:`.Nifti.getAffine` method for details on the valild values that
+        ``from_`` and ``to`` may take.
+
+        :arg image: :class:`.Nifti` instance associated with the affine.
+
+        :arg xform: ``(4, 4)`` ``numpy`` array encoding an affine
+                    transformation
+
+        :arg from_: Label specifying the coordinate system which ``xform``
+                    takes as input
+
+        :arg to:    Label specifying the coordinate system which ``xform``
+                    produces as output
+
+        :returns:   A tuple containing:
+                      - A label for the ``from_`` coordinate system
+                      - A label for the ``to`` coordinate system
         """
 
-        # 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 (from_ is not None) and (to is not None):
+            return from_, to
 
-        # 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:]
+        if from_ is not None: froms = [from_]
+        else:                 froms = ['voxel', 'fsl', 'world']
+        if to    is not None: tos   = [to]
+        else:                 tos   = ['voxel', 'fsl', 'world']
 
-        pixdims = pixdims[:len(shape)]
+        for from_, to in it.product(froms, tos):
 
-        return origShape, shape, pixdims
+            candidate = image.getAffine(from_, to)
+
+            if np.all(np.isclose(candidate, xform)):
+                return from_, to
+
+        raise ValueError('Could not identify affine')
 
 
     def strval(self, key):
@@ -382,6 +516,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)."""
@@ -390,9 +534,17 @@ class Nifti(notifier.Notifier, meta.Meta):
 
     @property
     def intent(self):
-        """Returns the NIFTI intent code of this image.
-        """
-        return self.__intent
+        """Returns the NIFTI intent code of this image. """
+        return self.header.get('intent_code', constants.NIFTI_INTENT_NONE)
+
+
+    @intent.setter
+    def intent(self, val):
+        """Sets the NIFTI intent code of this image. """
+        # analyze has no intent
+        if (self.niftiVersion > 0) and (val != self.intent):
+            self.header.set_intent(val, allow_unknown=True)
+            self.notify(topic='header')
 
 
     @property
@@ -426,12 +578,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 +618,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 +642,12 @@ 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 = Nifti.generateAffines(xform,
+                                                 self.shape,
+                                                 self.pixdim)
+
+        self.__affines        = affines
+        self.__isNeurological = isneuro
 
         log.debug('Affine changed:\npixdims: '
                   '%s\nsform: %s\nqform: %s',
@@ -475,6 +658,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 +694,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 +740,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 +758,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 +776,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):
@@ -879,7 +1029,13 @@ class Image(Nifti):
 
             nibImage = ctr(image, xform, header=header)
 
-        # otherwise, we assume that it is a nibabel image
+        # If it's an Image object, we
+        # just take the nibabel image
+        elif isinstance(image, Image):
+            nibImage = image.nibImage
+
+        # otherwise, we assume that
+        # it is a nibabel image
         else:
             nibImage = image
 
@@ -914,9 +1070,10 @@ class Image(Nifti):
                                                         threaded=threaded)
 
         # Listen to ourself for changes
-        # to the voxToWorldMat, so we
+        # to header attributse so we
         # can update the saveState.
-        self.register(self.name, self.__transformChanged, topic='transform')
+        self.register(self.name, self.__headerChanged, topic='transform')
+        self.register(self.name, self.__headerChanged, topic='header')
 
         if calcRange:
             self.calcRange()
@@ -1055,8 +1212,8 @@ class Image(Nifti):
         self.__nibImage.set_sform(xform, code)
 
 
-    def __transformChanged(self, *args, **kwargs):
-        """Called when the ``voxToWorldMat`` of this :class:`Nifti` instance
+    def __headerChanged(self, *args, **kwargs):
+        """Called when header properties of this :class:`Nifti` instance
         changes. Updates the :attr:`saveState` accordinbgly.
         """
         if self.__saveState:
@@ -1137,7 +1294,9 @@ class Image(Nifti):
         # We save the image out to a temp file,
         # then close the old image, move the
         # temp file to the real destination,
-        # then re-open the file.
+        # then re-open the file. This is done
+        # to ensure that all references to the
+        # old file are destroyed.
         tmphd, tmpfname = tempfile.mkstemp(suffix=getExt(filename))
         os.close(tmphd)
 
diff --git a/fsl/data/mesh.py b/fsl/data/mesh.py
index f3d1fb93bfdeaff3a0fb3afac2cd2dd9a0bb38cf..c15e49ced5f972e39e01af4c520053ecdb6b809a 100644
--- a/fsl/data/mesh.py
+++ b/fsl/data/mesh.py
@@ -33,9 +33,9 @@ import collections
 import os.path as op
 import numpy   as np
 
-import fsl.utils.meta      as meta
-import fsl.utils.notifier  as notifier
-import fsl.utils.transform as transform
+import fsl.utils.meta       as meta
+import fsl.utils.notifier   as notifier
+import fsl.transform.affine as affine
 
 
 log = logging.getLogger(__name__)
@@ -693,7 +693,7 @@ def calcFaceNormals(vertices, indices):
     v2 = vertices[indices[:, 2]]
 
     fnormals = np.cross((v1 - v0), (v2 - v0))
-    fnormals = transform.normalise(fnormals)
+    fnormals = affine.normalise(fnormals)
 
     return fnormals
 
@@ -724,7 +724,7 @@ def calcVertexNormals(vertices, indices, fnormals):
         vnormals[v2, :] += fnormals[i]
 
     # normalise to unit length
-    return transform.normalise(vnormals)
+    return affine.normalise(vnormals)
 
 
 def needsFixing(vertices, indices, fnormals, loBounds, hiBounds):
@@ -769,4 +769,4 @@ def needsFixing(vertices, indices, fnormals, loBounds, hiBounds):
     # vertex to the camera is positive
     # If it isn't, we need to flip the
     # triangle winding order.
-    return np.dot(n, transform.normalise(camera - vert)) < 0
+    return np.dot(n, affine.normalise(camera - vert)) < 0
diff --git a/fsl/data/mghimage.py b/fsl/data/mghimage.py
index 6c36340d923c8df61ddc8a5ee0ca2d308bde54dc..a143e06ce1e3ce9a2367444c7e67408459e3cd36 100644
--- a/fsl/data/mghimage.py
+++ b/fsl/data/mghimage.py
@@ -14,9 +14,9 @@ import os.path as op
 import            six
 import nibabel as nib
 
-import fsl.utils.path      as fslpath
-import fsl.utils.transform as transform
-import fsl.data.image      as fslimage
+import fsl.utils.path       as fslpath
+import fsl.transform.affine as affine
+import fsl.data.image       as fslimage
 
 
 ALLOWED_EXTENSIONS = ['.mgz', '.mgh']
@@ -55,12 +55,12 @@ class MGHImage(fslimage.Image):
             filename = None
 
         data     = image.get_data()
-        affine   = image.affine
+        xform    = image.affine
         vox2surf = image.header.get_vox2ras_tkr()
 
         fslimage.Image.__init__(self,
                                 data,
-                                xform=affine,
+                                xform=xform,
                                 name=name,
                                 dataSource=filename)
 
@@ -68,9 +68,9 @@ class MGHImage(fslimage.Image):
             self.setMeta('mghImageFile', filename)
 
         self.__voxToSurfMat   = vox2surf
-        self.__surfToVoxMat   = transform.invert(vox2surf)
-        self.__surfToWorldMat = transform.concat(affine, self.__surfToVoxMat)
-        self.__worldToSurfMat = transform.invert(self.__surfToWorldMat)
+        self.__surfToVoxMat   = affine.invert(vox2surf)
+        self.__surfToWorldMat = affine.concat(xform, self.__surfToVoxMat)
+        self.__worldToSurfMat = affine.invert(self.__surfToWorldMat)
 
 
     def save(self, filename=None):
diff --git a/fsl/scripts/fsl_apply_x5.py b/fsl/scripts/fsl_apply_x5.py
new file mode 100644
index 0000000000000000000000000000000000000000..0981d960863ca18673501ebe4b7422fb1e68ded1
--- /dev/null
+++ b/fsl/scripts/fsl_apply_x5.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python
+#
+# fsl_apply_x5.py - Apply an X5 transformation to an image.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""The ``fsl_apply_x5`` script can be used to apply an X5 transformation file
+to resample an image.
+"""
+
+
+import functools as ft
+import              sys
+import              argparse
+
+import fsl.transform.x5         as x5
+import fsl.transform.nonlinear  as nonlinear
+import fsl.utils.parse_data     as parse_data
+import fsl.utils.image.resample as resample
+import fsl.data.image           as fslimage
+
+
+def parseArgs(args):
+    """Parses command-line arguments.
+
+    :arg args: Sequence of command-line arguments. If ``None``, ``sys.argv``
+               is used
+    :returns:  An ``argparse.Namespace`` object containing parsed arguments
+    """
+
+    parser = argparse.ArgumentParser('fsl_apply_x5')
+    flags  = {
+        'input'  : ('input',),
+        'xform'  : ('xform',),
+        'output' : ('output',),
+        'interp' : ('-i', '--interp'),
+        'ref'    : ('-r', '--ref'),
+    }
+
+    helps  = {
+        'input'  : 'Input image',
+        'xform'  : 'X5 transformation file',
+        'output' : 'Output image',
+        'interp' : 'Interpolation (default: linear)',
+        'ref'    : 'Alternate reference image (default: '
+                   'reference specified in X5 file)',
+    }
+    opts = {
+        'input'  : dict(help=helps['input'],
+                        type=parse_data.Image),
+        'xform'  : dict(help=helps['xform']),
+        'output' : dict(help=helps['output'],
+                        type=parse_data.ImageOut),
+        'interp' : dict(help=helps['interp'],
+                        choices=('nearest', 'linear', 'cubic'),
+                        default='linear'),
+        'ref'    : dict(help=helps['ref'],
+                        type=ft.partial(parse_data.Image, loadData=False)),
+    }
+
+    parser.add_argument(*flags['input'],  **opts['input'])
+    parser.add_argument(*flags['xform'],  **opts['xform'])
+    parser.add_argument(*flags['output'], **opts['output'])
+    parser.add_argument(*flags['interp'], **opts['interp'])
+    parser.add_argument(*flags['ref'],    **opts['ref'])
+
+    if len(args) == 0:
+        parser.print_help()
+        sys.exit(0)
+
+    args = parser.parse_args(args)
+
+    if   args.interp == 'nearest': args.interp = 0
+    elif args.interp == 'linear':  args.interp = 1
+    elif args.interp == 'cubic':   args.interp = 3
+
+    return args
+
+
+def applyLinear(args):
+    """Applies a linear X5 transformation file to the input.
+
+    :arg args: ``argparse.Namespace`` object
+    :returns:  The transformed input as an :class:`.Image` object
+    """
+
+    input           = args.input
+    xform, src, ref = x5.readLinearX5(args.xform)
+
+    if args.ref is not None:
+        ref = args.ref
+
+    res, xform = resample.resampleToReference(input,
+                                              ref,
+                                              matrix=xform,
+                                              order=args.interp)
+
+    return fslimage.Image(res, xform=xform, header=ref.header)
+
+
+def applyNonlinear(args):
+    """Applies a non-linear X5 transformation file to the input.
+
+    :arg args: ``argparse.Namespace`` object
+    :returns:  The transformed input as an :class:`.Image` object
+    """
+
+    field = x5.readNonLinearX5(args.xform)
+
+    if args.ref is None: ref = field.ref
+    else:                ref = args.ref
+
+    result = nonlinear.applyDeformation(args.input,
+                                        field,
+                                        ref=ref,
+                                        order=args.interp,
+                                        mode='constant')
+
+    return fslimage.Image(result, header=ref.header)
+
+
+def main(args=None):
+    """Entry point. Parse command-line arguments, then calls
+    :func:`applyLinear` or :func:`applyNonlinear` depending on the x5 file
+    type.
+    """
+
+    if args is None:
+        args = sys.argv[1:]
+
+    print()
+    print('Warning: this version of fsl_apply_x5 is a development release.\n'
+          'Interface, behaviour, and input/output formats of future versions\n'
+          'may differ substantially from this version.')
+    print()
+
+    args = parseArgs(args)
+
+    if x5.inferType(args.xform) == 'linear':
+        result = applyLinear(args)
+    else:
+        result = applyNonlinear(args)
+
+    result.save(args.output)
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/fsl/scripts/fsl_convert_x5.py b/fsl/scripts/fsl_convert_x5.py
new file mode 100644
index 0000000000000000000000000000000000000000..d70b78d670091ba54787e009e8ea4f9810cbb5b4
--- /dev/null
+++ b/fsl/scripts/fsl_convert_x5.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python
+#
+# fsl_convert_x5.py - Convert between FSL and X5 transformation files.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This script can be used to convert between FSL and X5 linear and non-linear
+transformation file formats.
+"""
+
+
+import os.path   as op
+import functools as ft
+import textwrap  as tw
+import              sys
+import              shutil
+import              logging
+import              argparse
+
+import fsl.data.image       as fslimage
+import fsl.utils.parse_data as parse_data
+import fsl.transform.flirt  as flirt
+import fsl.transform.fnirt  as fnirt
+import fsl.transform.x5     as x5
+
+
+log = logging.getLogger(__name__)
+
+
+def parseArgs(args):
+    """Create an argument parser and parse the given ``args``.
+
+    :arg args: Sequence of commane line arguments.
+    :return:   An ``argparse.Namespace`` object containing parsed arguments.
+    """
+
+    helps = {
+        'input'         : 'Input file',
+        'output'        : 'Output file',
+        'source'        : 'Source image',
+        'reference'     : 'Reference image',
+        'input_format'  : 'Input format - if not provided, the input format '
+                          'is automatically inferred from the input file '
+                          'name.',
+        'output_format' : 'Output format - if not provided, the output format '
+                          'is automatically inferred from the output file '
+                          'name.',
+    }
+
+    epilog = tw.dedent("""
+    If the input and output file formats are the same, the input file
+    is simply copied to the output file.
+    """).strip()
+
+    parser     = argparse.ArgumentParser('fsl_convert_x5')
+    subparsers = parser.add_subparsers(dest='ctype')
+    flirt      = subparsers.add_parser('flirt', epilog=epilog)
+    fnirt      = subparsers.add_parser('fnirt', epilog=epilog)
+    imgtype    = ft.partial(parse_data.Image, loadData=False)
+
+    flirt.add_argument('input',                  help=helps['input'])
+    flirt.add_argument('output',                 help=helps['output'])
+    flirt.add_argument('-s',  '--source',        help=helps['source'],
+                       type=imgtype)
+    flirt.add_argument('-r',  '--reference',     help=helps['reference'],
+                       type=imgtype)
+    flirt.add_argument('-if', '--input_format',  help=helps['input_format'],
+                       choices=('x5', 'mat'))
+    flirt.add_argument('-of', '--output_format', help=helps['output_format'],
+                       choices=('x5', 'mat'))
+
+    fnirt  .add_argument('input',                  help=helps['input'])
+    fnirt  .add_argument('output',                 help=helps['output'])
+    fnirt  .add_argument('-s',  '--source',        help=helps['source'],
+                         type=imgtype)
+    fnirt  .add_argument('-r',  '--reference',     help=helps['reference'],
+                         type=imgtype)
+    fnirt  .add_argument('-if', '--input_format',  help=helps['input_format'],
+                         choices=('x5', 'nii'))
+    fnirt  .add_argument('-of', '--output_format', help=helps['output_format'],
+                         choices=('x5', 'nii'))
+
+    if len(args) == 0:
+        parser.print_help()
+        sys.exit(0)
+
+    if len(args) == 1:
+        if   args[0] == 'flirt':
+            flirt.print_help()
+            sys.exit(0)
+        elif args[0] == 'fnirt':
+            fnirt.print_help()
+            sys.exit(0)
+
+    args = parser.parse_args(args)
+
+    # If input/output formats were not
+    # specified, infer them from the
+    # file names
+    def getfmt(arg, fname, exist):
+        ext = op.splitext(fname)[1]
+        if ext in ('.mat', '.x5'):
+            return ext[1:]
+
+        fname = fslimage.addExt(fname, mustExist=exist)
+
+        if fslimage.looksLikeImage(fname):
+            return 'nii'
+        parser.error('Could not infer format from '
+                     'filename: {}'.format(args.input))
+
+    if args.input_format  is None:
+        args.input_format  = getfmt('input',  args.input, True)
+    if args.output_format is None:
+        args.output_format = getfmt('output', args.output, False)
+
+    # The source and reference arguments are
+    # required if the input is a FLIRT matrix
+    # or a FNIRT displacement/coefficient field.
+    if args.input_format in ('mat', 'nii') and \
+       (args.source is None or args.reference is None):
+        parser.error('You must specify a source and reference '
+                     'when the input is not an X5 file!')
+
+    return args
+
+
+def flirtToX5(args):
+    """Convert a linear FLIRT transformation matrix to an X5 transformation
+    file.
+    """
+    src   = args.source
+    ref   = args.reference
+    xform = flirt.readFlirt(args.input)
+    xform = flirt.fromFlirt(xform, src, ref, 'world', 'world')
+    x5.writeLinearX5(args.output, xform, src, ref)
+
+
+def X5ToFlirt(args):
+    """Convert a linear X5 transformation file to a FLIRT matrix. """
+    xform, src, ref = x5.readLinearX5(args.input)
+    xform           = flirt.toFlirt(xform, src, ref, 'world', 'world')
+    flirt.writeFlirt(xform, args.output)
+
+
+def fnirtToX5(args):
+    """Convert a non-linear FNIRT transformation into an X5 transformation
+    file.
+    """
+    src   = args.source
+    ref   = args.reference
+    field = fnirt.readFnirt(args.input, src=src, ref=ref)
+    field = fnirt.fromFnirt(field, 'world', 'world')
+    x5.writeNonLinearX5(args.output, field)
+
+
+def X5ToFnirt(args):
+    """Convert a non-linear X5 transformation file to a FNIRT-style
+    transformation file.
+    """
+    field = x5.readNonLinearX5(args.input)
+    field = fnirt.toFnirt(field)
+    field.save(args.output)
+
+
+def doFlirt(args):
+    """Converts a linear FIRT transformation file to or from the X5 format. """
+    infmt  = args.input_format
+    outfmt = args.output_format
+
+    if   (infmt, outfmt) == ('x5', 'mat'): X5ToFlirt(args)
+    elif (infmt, outfmt) == ('mat', 'x5'): flirtToX5(args)
+    else: shutil.copy(args.input, args.output)
+
+
+def doFnirt(args):
+    """Converts a non-linear FNIRT transformation file to or from the X5
+    format.
+    """
+    infmt  = args.input_format
+    outfmt = args.output_format
+
+    if   (infmt, outfmt) == ('x5', 'nii'): X5ToFnirt(args)
+    elif (infmt, outfmt) == ('nii', 'x5'): fnirtToX5(args)
+    else: shutil.copy(args.input, args.output)
+
+
+def main(args=None):
+    """Entry point. Calls :func:`doFlirt` or :func:`doFnirt` depending on
+    the sub-command specified in the arguments.
+
+    :arg args: Sequence of command-line arguments. If not provided,
+               ``sys.argv`` is used.
+    """
+
+    if args is None:
+        args = sys.argv[1:]
+
+    print()
+    print('Warning: this version of fsl_convert_x5 is a development release.\n'
+          'Interface, behaviour, and input/output formats of future versions\n'
+          'may differ substantially from this version.')
+    print()
+
+    args = parseArgs(args)
+
+    if   args.ctype == 'flirt': doFlirt(args)
+    elif args.ctype == 'fnirt': doFnirt(args)
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/fsl/transform/__init__.py b/fsl/transform/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..31bad1d68dcd5676ffa319f42a906ce92170fca4
--- /dev/null
+++ b/fsl/transform/__init__.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+#
+# __init__.py - Functions for working with linear and non-linear FSL
+#               transformations.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module contains functions for working with linear and non-linear FSL
+transformations. Functionality is split across the following modules:
+
+.. autosummary::
+   :nosignatures:
+
+   ~fsl.transform.affine
+   ~fsl.transform.flirt
+   ~fsl.transform.fnirt
+   ~fsl.transform.nonlinear
+   ~fsl.transform.x5
+"""
diff --git a/fsl/transform/affine.py b/fsl/transform/affine.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0364bf94030c76d6df99a5222ce6439bed8df38
--- /dev/null
+++ b/fsl/transform/affine.py
@@ -0,0 +1,584 @@
+#!/usr/bin/env python
+#
+# affine.py - Utility functions for working with affine transformations.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module contains utility functions for working with affine
+transformations. The following functions are available:
+
+.. autosummary::
+   :nosignatures:
+
+   transform
+   scaleOffsetXform
+   invert
+   concat
+   compose
+   decompose
+   rotMatToAffine
+   rotMatToAxisAngles
+   axisAnglesToRotMat
+   axisBounds
+   rmsdev
+
+And a few more functions are provided for working with vectors:
+
+.. autosummary::
+   :nosignatures:
+
+   veclength
+   normalise
+   transformNormal
+"""
+
+
+import collections.abc as abc
+import numpy           as np
+import numpy.linalg    as linalg
+
+
+def invert(x):
+    """Inverts the given matrix using ``numpy.linalg.inv``. """
+    return linalg.inv(x)
+
+
+def concat(*xforms):
+    """Combines the given matrices (returns the dot product)."""
+
+    result = xforms[0]
+
+    for i in range(1, len(xforms)):
+        result = np.dot(result, xforms[i])
+
+    return result
+
+
+def veclength(vec):
+    """Returns the length of the given vector(s).
+
+    Multiple vectors may be passed in, with a shape of ``(n, 3)``.
+    """
+    vec = np.array(vec, copy=False).reshape(-1, 3)
+    return np.sqrt(np.einsum('ij,ij->i', vec, vec))
+
+
+def normalise(vec):
+    """Normalises the given vector(s) to unit length.
+
+    Multiple vectors may be passed in, with a shape of ``(n, 3)``.
+    """
+    vec = np.array(vec, copy=False).reshape(-1, 3)
+    n   = (vec.T / veclength(vec)).T
+
+    if n.size == 3:
+        n = n[0]
+
+    return n
+
+
+def scaleOffsetXform(scales, offsets):
+    """Creates and returns an affine transformation matrix which encodes
+    the specified scale(s) and offset(s).
+
+
+    :arg scales:  A tuple of up to three values specifying the scale factors
+                  for each dimension. If less than length 3, is padded with
+                  ``1.0``.
+
+    :arg offsets: A tuple of up to three values specifying the offsets for
+                  each dimension. If less than length 3, is padded with
+                  ``0.0``.
+
+    :returns:     A ``numpy.float32`` array of size :math:`4 \\times 4`.
+    """
+
+    oktypes = (abc.Sequence, np.ndarray)
+
+    if not isinstance(scales,  oktypes): scales  = [scales]
+    if not isinstance(offsets, oktypes): offsets = [offsets]
+    if not isinstance(scales,  list):    scales  = list(scales)
+    if not isinstance(offsets, list):    offsets = list(offsets)
+
+    lens = len(scales)
+    leno = len(offsets)
+
+    if lens < 3: scales  = scales  + [1.0] * (3 - lens)
+    if leno < 3: offsets = offsets + [0.0] * (3 - leno)
+
+    xform = np.eye(4, dtype=np.float64)
+
+    xform[0, 0] = scales[0]
+    xform[1, 1] = scales[1]
+    xform[2, 2] = scales[2]
+
+    xform[0, 3] = offsets[0]
+    xform[1, 3] = offsets[1]
+    xform[2, 3] = offsets[2]
+
+    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, or
+                    a rotation matrix of shape ``(3, 3)``.
+
+    :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)
+
+    rotations = np.array(rotations)
+
+    if rotations.shape == (3,):
+        rotations = axisAnglesToRotMat(*rotations)
+
+    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] = rotations
+
+    return concat(offset, postRotate, rotate, preRotate, scale)
+
+
+def decompose(xform, angles=True):
+    """Decomposes the given transformation matrix into separate offsets,
+    scales, and rotations, according to the algorithm described in:
+
+    Spencer W. Thomas, Decomposing a matrix into simple transformations, pp
+    320-323 in *Graphics Gems II*, James Arvo (editor), Academic Press, 1991,
+    ISBN: 0120644819.
+
+    It is assumed that the given transform has no perspective components. Any
+    shears in the affine are discarded.
+
+    :arg xform:  A ``(3, 3)`` or ``(4, 4)`` affine transformation matrix.
+
+    :arg angles: If ``True`` (the default), the rotations are returned
+                 as axis-angles, in radians. Otherwise, the rotation matrix
+                 is returned.
+
+    :returns: The following:
+
+               - A sequence of three scales
+               - A sequence of three translations (all ``0`` if ``xform``
+                 was a ``(3, 3)`` matrix)
+               - A sequence of three rotations, in radians. Or, if
+                 ``angles is False``, a rotation matrix.
+    """
+
+    # The inline comments in the code below are taken verbatim from
+    # the referenced article, [except for notes in square brackets].
+
+    # The next step is to extract the translations. This is trivial;
+    # we find t_x = M_{4,1}, t_y = M_{4,2}, and t_z = M_{4,3}. At this
+    # point we are left with a 3*3 matrix M' = M_{1..3,1..3}.
+    xform = xform.T
+
+    if xform.shape == (4, 4):
+        translations = xform[ 3, :3]
+        xform        = xform[:3, :3]
+    else:
+        translations = np.array([0, 0, 0])
+
+    M1 = xform[0]
+    M2 = xform[1]
+    M3 = xform[2]
+
+    # The process of finding the scaling factors and shear parameters
+    # is interleaved. First, find s_x = |M'_1|.
+    sx = np.sqrt(np.dot(M1, M1))
+
+    # Then, compute an initial value for the xy shear factor,
+    # s_xy = M'_1 * M'_2. (this is too large by the y scaling factor).
+    sxy = np.dot(M1, M2)
+
+    # The second row of the matrix is made orthogonal to the first by
+    # setting M'_2 = M'_2 - s_xy * M'_1.
+    M2 = M2 - sxy * M1
+
+    # Then the y scaling factor, s_y, is the length of the modified
+    # second row.
+    sy = np.sqrt(np.dot(M2, M2))
+
+    # The second row is normalized, and s_xy is divided by s_y to
+    # get its final value.
+    M2  = M2  / sy
+    sxy = sxy / sy
+
+    # The xz and yz shear factors are computed as in the preceding,
+    sxz = np.dot(M1, M3)
+    syz = np.dot(M2, M3)
+
+    # the third row is made orthogonal to the first two rows,
+    M3 = M3 - sxz * M1 - syz * M2
+
+    # the z scaling factor is computed,
+    sz = np.sqrt(np.dot(M3, M3))
+
+    # the third row is normalized, and the xz and yz shear factors are
+    # rescaled.
+    M3  = M3  / sz
+    sxz = sxz / sz
+    syz = sxz / sz
+
+    # The resulting matrix now is a pure rotation matrix, except that it
+    # might still include a scale factor of -1. If the determinant of the
+    # matrix is -1, negate the matrix and all three scaling factors. Call
+    # the resulting matrix R.
+    #
+    # [We do things different here - if the rotation matrix has negative
+    #  determinant, the flip is encoded in the x scaling factor.]
+    R = np.array([M1, M2, M3])
+    if linalg.det(R) < 0:
+        R[0] = -R[0]
+        sx   = -sx
+
+    # Finally, we need to decompose the rotation matrix into a sequence
+    # of rotations about the x, y, and z axes. [This is done in the
+    # rotMatToAxisAngles function]
+    if angles: rotations = rotMatToAxisAngles(R.T)
+    else:      rotations = R.T
+
+    return np.array([sx, sy, sz]), translations, rotations
+
+
+def rotMatToAffine(rotmat, origin=None):
+    """Convenience function which encodes the given ``(3, 3)`` rotation
+    matrix into a ``(4, 4)`` affine.
+    """
+    return compose([1, 1, 1], [0, 0, 0], rotmat, origin)
+
+
+def rotMatToAxisAngles(rotmat):
+    """Given a ``(3, 3)`` rotation matrix, decomposes the rotations into
+    an angle in radians about each axis.
+    """
+
+    yrot = np.sqrt(rotmat[0, 0] ** 2 + rotmat[1, 0] ** 2)
+
+    if np.isclose(yrot, 0):
+        xrot = np.arctan2(-rotmat[1, 2], rotmat[1, 1])
+        yrot = np.arctan2(-rotmat[2, 0], yrot)
+        zrot = 0
+    else:
+        xrot = np.arctan2( rotmat[2, 1], rotmat[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,
+               origin='centre',
+               boundary='high',
+               offset=1e-4):
+    """Returns the ``(lo, hi)`` bounds of the specified axis/axes in the
+    world coordinate system defined by ``xform``.
+
+    If the ``origin`` parameter is set to  ``centre`` (the default),
+    this function assumes that voxel indices correspond to the voxel
+    centre. For example, the voxel at ``(4, 5, 6)`` covers the space:
+
+      ``[3.5 - 4.5, 4.5 - 5.5, 5.5 - 6.5]``
+
+    So the bounds of the specified shape extends from the corner at
+
+      ``(-0.5, -0.5, -0.5)``
+
+    to the corner at
+
+      ``(shape[0] - 0.5, shape[1] - 0.5, shape[1] - 0.5)``
+
+    If the ``origin`` parameter is set to ``corner``, this function
+    assumes that voxel indices correspond to the voxel corner. In this
+    case, a voxel  at ``(4, 5, 6)`` covers the space:
+
+      ``[4 - 5, 5 - 6, 6 - 7]``
+
+    So the bounds of the specified shape extends from the corner at
+
+      ``(0, 0, 0)``
+
+    to the corner at
+
+      ``(shape[0], shape[1], shape[1])``.
+
+
+    If the ``boundary`` parameter is set to ``high``, the high voxel bounds
+    are reduced by a small amount (specified by the ``offset`` parameter)
+    before they are transformed to the world coordinate system.  If
+    ``boundary`` is set to ``low``, the low bounds are increased by a small
+    amount.  The ``boundary`` parameter can also be set to ``'both'``, or
+    ``None``. This option is provided so that you can ensure that the
+    resulting bounds will always be contained within the image space.
+
+    :arg shape:    The ``(x, y, z)`` shape of the data.
+
+    :arg xform:    Transformation matrix which transforms voxel coordinates
+                   to the world coordinate system.
+
+    :arg axes:     The world coordinate system axis bounds to calculate.
+
+    :arg origin:   Either ``'centre'`` (the default) or ``'corner'``.
+
+    :arg boundary: Either ``'high'`` (the default), ``'low'``, ''`both'``,
+                   or ``None``.
+
+    :arg offset:   Amount by which the boundary voxel coordinates should be
+                   offset. Defaults to ``1e-4``.
+
+    :returns:      A tuple containing the ``(low, high)`` bounds for each
+                   requested world coordinate system axis.
+    """
+
+    origin = origin.lower()
+
+    # lousy US spelling
+    if origin == 'center':
+        origin = 'centre'
+
+    if origin not in ('centre', 'corner'):
+        raise ValueError('Invalid origin value: {}'.format(origin))
+    if boundary not in ('low', 'high', 'both', None):
+        raise ValueError('Invalid boundary value: {}'.format(boundary))
+
+    scalar = False
+
+    if axes is None:
+        axes = [0, 1, 2]
+
+    elif not isinstance(axes, abc.Iterable):
+        scalar = True
+        axes   = [axes]
+
+    x, y, z = shape[:3]
+
+    points = np.zeros((8, 3), dtype=np.float32)
+
+    if origin == 'centre':
+        x0 = -0.5
+        y0 = -0.5
+        z0 = -0.5
+        x -=  0.5
+        y -=  0.5
+        z -=  0.5
+    else:
+        x0 = 0
+        y0 = 0
+        z0 = 0
+
+    if boundary in ('low', 'both'):
+        x0 += offset
+        y0 += offset
+        z0 += offset
+
+    if boundary in ('high', 'both'):
+        x  -= offset
+        y  -= offset
+        z  -= offset
+
+    points[0, :] = [x0, y0, z0]
+    points[1, :] = [x0, y0,  z]
+    points[2, :] = [x0,  y, z0]
+    points[3, :] = [x0,  y,  z]
+    points[4, :] = [x,  y0, z0]
+    points[5, :] = [x,  y0,  z]
+    points[6, :] = [x,   y, z0]
+    points[7, :] = [x,   y,  z]
+
+    tx = transform(points, xform)
+
+    lo = tx[:, axes].min(axis=0)
+    hi = tx[:, axes].max(axis=0)
+
+    if scalar: return (lo[0], hi[0])
+    else:      return (lo,    hi)
+
+
+def transform(p, xform, axes=None, vector=False):
+    """Transforms the given set of points ``p`` according to the given affine
+    transformation ``xform``.
+
+
+    :arg p:      A sequence or array of points of shape :math:`N \\times  3`.
+
+    :arg xform:  A ``(4, 4)`` affine transformation matrix with which to
+                 transform the points in ``p``.
+
+    :arg axes:   If you are only interested in one or two axes, and the source
+                 axes are orthogonal to the target axes (see the note below),
+                 you may pass in a 1D, ``N*1``, or ``N*2`` array as ``p``, and
+                 use this argument to specify which axis/axes that the data in
+                 ``p`` correspond to.
+
+    :arg vector: Defaults to ``False``. If ``True``, the points are treated
+                 as vectors - the translation component of the transformation
+                 is not applied. If you set this flag, you pass in a ``(3, 3)``
+                 transformation matrix.
+
+    :returns:    The points in ``p``, transformed by ``xform``, as a ``numpy``
+                 array with the same data type as the input.
+
+
+    .. note:: The ``axes`` argument should only be used if the source
+              coordinate system (the points in ``p``) axes are orthogonal
+              to the target coordinate system (defined by the ``xform``).
+
+              In other words, you can only use the ``axes`` argument if
+              the ``xform`` matrix consists solely of translations and
+              scalings.
+    """
+
+    p  = _fillPoints(p, axes)
+    t  = np.dot(xform[:3, :3], p.T).T
+
+    if not vector:
+        t = t + xform[:3, 3]
+
+    if axes is not None:
+        t = t[:, axes]
+
+    if t.size == 1: return t[0]
+    else:           return t
+
+
+def transformNormal(p, xform, axes=None):
+    """Transforms the given point(s), under the assumption that they
+    are normal vectors. In this case, the points are transformed by
+    ``invert(xform[:3, :3]).T``.
+    """
+    return transform(p, invert(xform[:3, :3]).T, axes, vector=True)
+
+
+def _fillPoints(p, axes):
+    """Used by the :func:`transform` function. Turns the given array p into
+    a ``N*3`` array of ``x,y,z`` coordinates. The array p may be a 1D array,
+    or an ``N*2`` or ``N*3`` array.
+    """
+
+    if not isinstance(p, abc.Iterable): p = [p]
+
+    p = np.array(p)
+
+    if axes is None: return p
+
+    if not isinstance(axes, abc.Iterable): axes = [axes]
+
+    if p.ndim == 1:
+        p = p.reshape((len(p), 1))
+
+    if p.ndim != 2:
+        raise ValueError('Points array must be either one or two '
+                         'dimensions')
+
+    if len(axes) != p.shape[1]:
+        raise ValueError('Points array shape does not match specified '
+                         'number of axes')
+
+    newp = np.zeros((len(p), 3), dtype=p.dtype)
+
+    for i, ax in enumerate(axes):
+        newp[:, ax] = p[:, i]
+
+    return newp
+
+
+def rmsdev(T1, T2, R=None, xc=None):
+    """Calculates the RMS deviation of the given affine transforms ``T1`` and
+    ``T2``. This can be used as a measure of the 'distance' between two
+    affines.
+
+    The ``T1`` and ``T2`` arguments may be either full ``(4, 4)`` affines, or
+    ``(3, 3)`` rotation matrices.
+
+    See FMRIB technical report TR99MJ1, available at:
+
+    https://www.fmrib.ox.ac.uk/datasets/techrep/
+
+    :arg T1:  First affine
+    :arg T2:  Second affine
+    :arg R:   Sphere radius
+    :arg xc:  Sphere centre
+    :returns: The RMS deviation between ``T1`` and ``T2``.
+    """
+
+    if R is None:
+        R = 1
+
+    if xc is None:
+        xc = np.zeros(3)
+
+    # rotations only
+    if T1.shape == (3, 3):
+        M = np.dot(T2, invert(T1)) - np.eye(3)
+        A = M[:3, :3]
+        t = np.zeros(3)
+
+    # full affine
+    else:
+        M = np.dot(T2, invert(T1)) - np.eye(4)
+        A = M[:3, :3]
+        t = M[:3,  3]
+
+    Axc = np.dot(A, xc)
+
+    erms = np.dot((t + Axc).T, t + Axc)
+    erms = 0.2 * R ** 2 * np.dot(A.T, A).trace() + erms
+    erms = np.sqrt(erms)
+
+    return erms
diff --git a/fsl/transform/flirt.py b/fsl/transform/flirt.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1c84b7617e23eda40aac4692fe586d4942de372
--- /dev/null
+++ b/fsl/transform/flirt.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python
+#
+# flirt.py - Functions for working with FLIRT matrices.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module contains functions for working with FLIRT affine transformation
+matrices. The following functions are available:
+
+.. autosummary::
+   :nosignatures:
+
+   readFlirt
+   writeFlirt
+   fromFlirt
+   toFlirt
+   flirtMatrixToSform
+   sformToFlirtMatrix
+
+
+
+FLIRT transformation matrices are affine matrices of shape ``(4, 4)`` which
+encode a linear transformation from a source image to a reference image. FLIRT
+matrices are defined in terms of *FSL coordinates*, which is a coordinate
+system where voxels are scaled by pixdims, and with a left-right flip if the
+image ``sform`` has a positive determinant.
+
+
+FLIRT matrices thus encode a transformation from source image FSL coordinates
+to reference image FSL coordinates.
+"""
+
+
+import numpy as np
+
+from .affine import concat
+
+
+def readFlirt(fname):
+    """Reads a FLIRT matrix from a file. """
+    return np.loadtxt(fname)
+
+
+def writeFlirt(xform, fname):
+    """Writes the given FLIRT matrix to a file. """
+    np.savetxt(fname, xform, fmt='%1.15g')
+
+
+def fromFlirt(xform, src, ref, from_='voxel', to='world'):
+    """Convert a FLIRT affine matrix into another affine.
+
+    Given a FLIRT matrix, and the source and reference images with which the
+    FLIRT matrix is associated, converts the matrix into an affine matrix
+    which can transform coordinates from the source image ``from_`` coordinate
+    system to the reference image ``to`` coordinate system.
+
+    The ``from_`` and ``to`` arguments specify the desired spaces that the
+    returned affine should transform between. The default values
+    (``from_='voxel'`` and ``to='world'`` will generate an affine which
+    transforms from voxels in the source image to world-coordinates in the
+    reference image.
+
+    Valid values for the ``from_`` and ``to`` arguments are:
+
+     - ``voxel``: The voxel coordinate system
+     - ``fsl``: The FSL coordiante system (voxels scaled by pixdims, with the
+       X axis inverted if the image sform/qform has a positive determinant)
+     - ``world``  The world coordinate system
+
+    See the :class:`.Nifti` class documentation and the
+    :meth:`.Nifti.getAffine` method for more details.
+
+    :arg xform: ``numpy`` array of shape ``(4, 4)`` containing a FLIRT
+                transformation matrix.
+    :arg src:   :class:`.Nifti` object, the ``xform`` source image
+    :arg ref:   :class:`.Nifti` object, the ``xform`` reference image
+    :arg from_: Desired source coordinate system
+    :arg to:    Desired reference coordinate system
+    :returns:   ``numpy`` array of shape ``(4, 4)`` containing a matrix
+                encoding a transformation from the source ``from_`` to
+                the reference ``to`` coordinate systems.
+    """
+    premat  = src.getAffine(from_, 'fsl')
+    postmat = ref.getAffine('fsl', to)
+    return concat(postmat, xform, premat)
+
+
+def toFlirt(xform, src, ref, from_='voxel', to='world'):
+    """Convert an affine matrix into a FLIRT matrix.
+
+    :returns:   ``numpy`` array of shape ``(4, 4)`` containing a matrix
+                encoding a transformation from the source ``from_`` to
+                the reference ``to`` coordinate systems.
+    :arg src:   :class:`.Nifti` object, the ``xform`` source image
+    :arg ref:   :class:`.Nifti` object, the ``xform`` reference image
+    :arg from_: ``xform`` source coordinate system
+    :arg to:    ``xform`` target coordinate system
+    :returns:   A ``numpy`` array of shape ``(4, 4)`` containing a FLIRT
+                transformation matrix from ``src`` to ``ref``.
+    """
+    premat  = src.getAffine('fsl', from_)
+    postmat = ref.getAffine(to, 'fsl')
+    return concat(postmat, xform, premat)
+
+
+def flirtMatrixToSform(flirtMat, srcImage, refImage):
+    """Converts the given ``FLIRT`` transformation matrix into a
+    transformation from the source image voxel coordinate system to
+    the reference image world coordinate system.
+
+    To construct a transformation from source image voxel coordinates into
+    reference image world coordinates, we need to combine the following:
+
+      1. Source voxels -> Source scaled voxels
+      2. Source scaled voxels -> Reference scaled voxels (the FLIRT matrix)
+      3. Reference scaled voxels -> Reference voxels
+      4. Reference voxels -> Reference world (the reference image sform)
+
+    :arg flirtMat: A ``(4, 4)`` transformation matrix
+    :arg srcImage: Source :class:`.Image`
+    :arg refImage: Reference :class:`.Image`
+    """
+    return fromFlirt(flirtMat, srcImage, refImage, 'voxel', 'world')
+
+
+def sformToFlirtMatrix(srcImage, refImage, srcXform=None):
+    """Under the assumption that the given ``srcImage`` and ``refImage`` share a
+    common world coordinate system (defined by their
+    :attr:`.Nifti.voxToWorldMat` attributes), this function will calculate and
+    return a transformation matrix from the ``srcImage`` FSL coordinate system
+    to the ``refImage`` FSL coordinate system, that can be saved to disk and
+    used with FLIRT, to resample the source image to the space of the
+    reference image.
+
+    :arg srcImage: Source :class:`.Image`
+    :arg refImage: Reference :class:`.Image`
+    :arg srcXform: Optionally used in place of the ``srcImage``
+                   :attr:`.Nifti.voxToWorldMat`
+    """
+
+    srcScaledVoxToVoxMat = srcImage.scaledVoxToVoxMat
+    srcVoxToWorldMat     = srcImage.voxToWorldMat
+    refWorldToVoxMat     = refImage.worldToVoxMat
+    refVoxToScaledVoxMat = refImage.voxToScaledVoxMat
+
+    if srcXform is not None:
+        srcVoxToWorldMat = srcXform
+
+    return concat(refVoxToScaledVoxMat,
+                  refWorldToVoxMat,
+                  srcVoxToWorldMat,
+                  srcScaledVoxToVoxMat)
diff --git a/fsl/transform/fnirt.py b/fsl/transform/fnirt.py
new file mode 100644
index 0000000000000000000000000000000000000000..2a2351a0ed8f7cd1aa5ff2d48b62626361845964
--- /dev/null
+++ b/fsl/transform/fnirt.py
@@ -0,0 +1,388 @@
+#!/usr/bin/env python
+#
+# fnirt.py - Functions for working with FNIRT non-linear transformations.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module contains functions for working with FNIRT non-linear
+transformations. The following functions are available:
+
+.. autosummary::
+   :nosignatures:
+
+   readFnirt
+   toFnirt
+   fromFnirt
+
+
+Non-linear registration using FNIRT
+-----------------------------------
+
+
+FNIRT is used to calculate a non-linear registration from a source image to a
+reference image. FNIRT outputs the resulting non-linear transformation as
+either:
+
+ - A deformation/warp field which contains displacements or coordinates.
+ - A coefficient field which can be used to generate a warp field.
+
+
+Non-linear registration using FNIRT generally follows the process depicted
+here:
+
+
+.. image:: images/nonlinear_registration_process.png
+   :width: 80%
+   :align: center
+
+
+First, an initial linear registration is performed from the source image to
+the reference image using FLIRT; this provides an initial global alignment
+which can be used as the starting point for the non-linear registration. Next,
+FNIRT is used to non-linearly register the aligned source image to the
+reference image.  Importantly, both of these steps are performed using FSL
+coordinates.
+
+
+Note that we have three spaces, and three sets of coordinate systems, to
+consider:
+
+ 1. Source image space - the source image, before initial linear registration
+    to the reference image
+
+ 2. Aligned-source image space - the source image, after it has been linearly
+    transformed to the reference image space
+
+ 3. Reference image space
+
+
+The initial affine registration calculates a transformation between spaces 1
+and 2, and the non-linear registration calculates a transformation between
+spaces 2 and 3. Note that the fields-of-view for spaces 2 and 3 are
+equivalent.
+
+
+The non-linear transformation file generated by FNIRT will contain the initial
+linear registration, with it either being encoded directly into the warps (for
+a warp field), or being stored in the NIfTI header (for a coefficient field).
+
+
+FNIRT warp fields
+^^^^^^^^^^^^^^^^^
+
+
+A FNIRT deformation field (a.k.a. warp field) is defined in the same space as
+the reference image, and may contain:
+
+ - *relative displacements*, where each voxel in the warp field contains an
+   offset which can be added to the reference image coordinates for that
+   voxel, in order to calculate the corresponding source image coordinates.
+
+ - *absolute coordinates*, where each voxel in the warp field simply contains
+   the source image coordinates which correspond to those reference image
+   coordinates.
+
+
+If an initial linear registration was used as the starting point for FNIRT,
+this is encoded into the displacements/coordinates themselves, so they can be
+used to transform from the reference image to the *original* source image
+space.
+
+
+.. image:: images/fnirt_warp_field.png
+   :width: 80%
+   :align: center
+
+
+FNIRT coefficient fields
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+A FNIRT coefficient field contains the coefficients of a set of quadratic or
+cubic B-spline functions defined on a regular 3D grid overlaid on the
+reference image voxel coordinate system. Each coefficient in this grid may be
+referred to as a *control point* or a *knot*.
+
+
+Evaluating the spline functions at a particular location in the grid will
+result in a relative displacement which can be added to that location's
+reference image coordinates, in order to determine the corresponding source
+image coordinates.
+
+
+If an initial linear registration was used as the starting point for FNIRT,
+the generated displacement field will encode a transformation to *aligned*
+source image coordinates, and the initial affine will be stored in the NIfTI
+header of the coefficient field file.
+
+
+.. image:: images/fnirt_coefficient_field.png
+   :width: 80%
+   :align: center
+"""
+
+
+import logging
+
+import nibabel as nib
+import numpy   as np
+
+import fsl.data.constants as constants
+import fsl.data.image     as fslimage
+from . import                affine
+from . import                nonlinear
+
+
+log = logging.getLogger(__name__)
+
+
+def _readFnirtDeformationField(fname, img, src, ref, defType=None):
+    """Loads ``fname``, assumed to be a FNIRT deformation field.
+
+    :arg fname:   File name of FNIRT deformation field
+
+    :arg img:     ``fname`` loaded as an :class:`.Image`
+
+    :arg src:     Source image
+
+    :arg ref:     Reference image
+
+    :arg defType: Deformation type - either ``'absolute'`` or ``'relative'``.
+                  If not provided, is automatically inferred from the data.
+
+    :return:      A :class:`.DeformationField` object
+    """
+    return nonlinear.DeformationField(fname,
+                                      src,
+                                      ref,
+                                      srcSpace='fsl',
+                                      refSpace='fsl',
+                                      defType=defType)
+
+
+def _readFnirtCoefficientField(fname, img, src, ref):
+    """Loads ``fname``, assumed to be a FNIRT coefficient field.
+
+    :arg fname: File name of FNIRT coefficient field
+    :arg img:   ``fname`` loaded as an :class:`.Image`
+    :arg src:   Source image
+    :arg ref:   Reference image
+    :return:    A :class:`.CoefficientField`
+    """
+
+    # FNIRT uses NIFTI header fields in
+    # non-standard ways to store some
+    # additional information about the
+    # coefficient field. See
+    # $FSLDIR/src/fnirt/fnirt_file_writer.cpp
+    # for more details.
+
+    # The field type (quadratic, cubic,
+    # or discrete-cosine-transform) is
+    # inferred from the intent. There is
+    # no support in this implementation
+    # for DCT fields
+    cubics = (constants.FSL_CUBIC_SPLINE_COEFFICIENTS,
+              constants.FSL_TOPUP_CUBIC_SPLINE_COEFFICIENTS)
+    quads  = (constants.FSL_QUADRATIC_SPLINE_COEFFICIENTS,
+              constants.FSL_TOPUP_QUADRATIC_SPLINE_COEFFICIENTS)
+
+    if img.intent in cubics:
+        fieldType = 'cubic'
+    elif img.intent in quads:
+        fieldType = 'quadratic'
+    else:
+        fieldType = 'cubic'
+        log.warning('Unrecognised/unsupported coefficient '
+                    'field type (assuming cubic b-spline): '
+                    '{}'.format(img.intent))
+
+    # Knot spacing (in voxels) is
+    # stored in the pixdims
+    knotSpacing = img.pixdim[:3]
+
+    # The sform contains an initial
+    # global src-to-ref affine
+    # (the starting point for the
+    # non-linear registration). This
+    # is encoded as a flirt matrix,
+    # i.e. it transforms from
+    # source-scaled-voxels to
+    # ref-scaled-voxels
+    srcToRefMat = img.header.get_sform()
+
+    # The fieldToRefMat affine tells
+    # the CoefficientField class how
+    # to transform coefficient field
+    # voxel coordinates into
+    # displacement field/reference
+    # image voxel coordinates.
+    fieldToRefMat = affine.scaleOffsetXform(knotSpacing, 0)
+
+    return nonlinear.CoefficientField(fname,
+                                      src,
+                                      ref,
+                                      srcSpace='fsl',
+                                      refSpace='fsl',
+                                      fieldType=fieldType,
+                                      knotSpacing=knotSpacing,
+                                      srcToRefMat=srcToRefMat,
+                                      fieldToRefMat=fieldToRefMat)
+
+
+def readFnirt(fname, src, ref, defType=None):
+    """Reads a non-linear FNIRT transformation image, returning
+    a :class:`.DeformationField` or :class:`.CoefficientField` depending
+    on the file type.
+
+    :arg fname:    File name of FNIRT transformation
+    :arg src:      Source image
+    :arg ref:      Reference image
+    :arg defType:  Deformation type - either ``'absolute'`` or ``'relative'``.
+                   Only used if the file is a deformation field. If not
+                   provided, is automatically inferred from the data.
+    """
+
+    # Figure out whether the file
+    # is a deformation field or
+    # a coefficient field
+
+    img   = fslimage.Image(fname, loadData=False)
+    disps = (constants.FSL_FNIRT_DISPLACEMENT_FIELD,
+             constants.FSL_TOPUP_FIELD)
+    coefs = (constants.FSL_CUBIC_SPLINE_COEFFICIENTS,
+             constants.FSL_DCT_COEFFICIENTS,
+             constants.FSL_QUADRATIC_SPLINE_COEFFICIENTS,
+             constants.FSL_TOPUP_CUBIC_SPLINE_COEFFICIENTS,
+             constants.FSL_TOPUP_QUADRATIC_SPLINE_COEFFICIENTS)
+
+    if img.intent in disps:
+        return _readFnirtDeformationField(fname, img, src, ref, defType)
+    elif img.intent in coefs:
+        return _readFnirtCoefficientField(fname, img, src, ref)
+    else:
+        raise ValueError('Cannot determine type of nonlinear '
+                         'file {}'.format(fname))
+
+
+def toFnirt(field):
+    """Convert a :class:`.NonLinearTransform` to a FNIRT-compatible
+    :class:`.DeformationField` or :class:`.CoefficientField`.
+
+    :arg field: :class:`.NonLinearTransform` to convert
+    :return:    A FNIRT-compatible :class:`.DeformationField` or
+                :class:`.CoefficientField`.
+    """
+
+    # If we have a coefficient field
+    # which transforms between fsl
+    # space, we can just create a copy.
+    if isinstance(field, nonlinear.CoefficientField) and \
+       (field.srcSpace == 'fsl' and field.refSpace == 'fsl'):
+
+        # We start with a nibabel image,
+        # as we need to mess with the header
+        # fields to store all of the FNIRT
+        # coefficient field information
+        fieldBase = nib.nifti1.Nifti1Image(field.data, None)
+
+        # Set the intent
+        if field.fieldType == 'cubic':
+            intent = constants.FSL_CUBIC_SPLINE_COEFFICIENTS
+        elif field.fieldType == 'quadratic':
+            intent = constants.FSL_QUADRATIC_SPLINE_COEFFICIENTS
+        fieldBase.header['intent_code'] = intent
+
+        # Reference image pixdims are
+        # stored in the intent code
+        # parameters.
+        fieldBase.header['intent_p1'] = field.ref.pixdim[0]
+        fieldBase.header['intent_p2'] = field.ref.pixdim[1]
+        fieldBase.header['intent_p3'] = field.ref.pixdim[2]
+
+        # Pixdims are used to
+        # store the knot spacing,
+        pixdims      = list(field.knotSpacing) + [1]
+        qform        = np.diag(pixdims)
+
+        # The sform is used to store the
+        # initial src-to-ref affine
+        if field.srcToRefMat is not None: sform = field.srcToRefMat
+        else:                             sform = np.eye(4)
+
+        # The qform offsets are
+        # used to store the
+        # reference image shape
+        qform[:3, 3] = field.ref.shape[:3]
+
+        fieldBase.header.set_zooms(pixdims)
+        fieldBase.set_sform(sform, 1)
+        fieldBase.set_qform(qform, 1)
+        fieldBase.update_header()
+
+        field = nonlinear.CoefficientField(
+            fieldBase,
+            src=field.src,
+            ref=field.ref,
+            srcSpace='fsl',
+            refSpace='fsl',
+            fieldType=field.fieldType,
+            knotSpacing=field.knotSpacing,
+            fieldToRefMat=field.fieldToRefMat,
+            srcToRefMat=field.srcToRefMat)
+
+    # Otherwise we have a non-FSL coefficient
+    # field, or a deformation field.
+    else:
+
+        # We can't convert a CoefficientField
+        # which doesn't transform in FSL
+        # coordinates, because the coefficients
+        # will have been calculated between some
+        # other source/reference coordinate
+        # systems, and we can't adjust the
+        # coefficients to encode an FSL->FSL
+        # deformation.
+        if isinstance(field, nonlinear.CoefficientField):
+            field = nonlinear.coefficientFieldToDeformationField(field)
+
+        # Again, if we have a displacement
+        # field which transforms between
+        # fsl spaces, we can just take a copy
+        if field.srcSpace == 'fsl' and field.refSpace == 'fsl':
+            field = nonlinear.DeformationField(
+                field.data,
+                header=field.header,
+                src=field.src,
+                ref=field.ref,
+                defType=field.deformationType)
+
+        # Otherwise we have to adjust the
+        # displacements so they transform
+        # between fsl coordinates.
+        field = nonlinear.convertDeformationSpace(
+            field, from_='fsl', to='fsl')
+
+        field.header['intent_code'] = constants.FSL_FNIRT_DISPLACEMENT_FIELD
+
+    return field
+
+
+def fromFnirt(field, from_='world', to='world'):
+    """Convert a FNIRT-style :class:`.NonLinearTransform` to a generic
+    :class:`.DeformationField`.
+
+    :arg field: A FNIRT-style :class:`.CoefficientField` or
+                :class:`.DeformationField`
+
+    :arg from_: Desired reference image coordinate system
+
+    :arg to:    Desired source image coordinate system
+
+    :return:    A :class:`.DeformationField` which contains displacements
+                from the reference image ``from_`` cordinate system to the
+                source image ``to`` coordinate syste.
+    """
+    if isinstance(field, nonlinear.CoefficientField):
+        field = nonlinear.coefficientFieldToDeformationField(field)
+    return nonlinear.convertDeformationSpace(field, from_=from_, to=to)
diff --git a/fsl/transform/nonlinear.py b/fsl/transform/nonlinear.py
new file mode 100644
index 0000000000000000000000000000000000000000..3010cda86f246e1e8697a4cfaee412a000fc91b5
--- /dev/null
+++ b/fsl/transform/nonlinear.py
@@ -0,0 +1,886 @@
+#!/usr/bin/env python
+#
+# nonlinear.py - Functions/classes for non-linear transformations.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module contains data structures and functions for working with
+FNIRT-style nonlinear transformations.
+
+
+The :class:`DeformationField` and :class:`CoefficientField` can be used to
+load and interact with FNIRT-style transformation images. The following
+utility functions are also available:
+
+
+.. autosummary::
+   :nosignatures:
+
+   detectDeformationType
+   convertDeformationType
+   convertDeformationSpace
+   applyDeformation
+   coefficientFieldToDeformationField
+"""
+
+
+import                                logging
+import itertools                   as it
+
+import numpy                       as np
+import scipy.ndimage.interpolation as ndinterp
+
+import fsl.utils.memoize           as memoize
+import fsl.data.image              as fslimage
+import fsl.utils.image.resample    as resample
+from . import                         affine
+
+
+log = logging.getLogger(__name__)
+
+
+class NonLinearTransform(fslimage.Image):
+    """Class which represents a nonlinear transformation. This is just a base
+    class for the :class:`DeformationField` and :class:`CoefficientField`
+    classes.
+
+
+    A nonlinear transformation is an :class:`.Image` which contains
+    some mapping between a source image coordinate system and a reference image
+    coordinate system.
+
+
+    In FSL, non-linear transformations are defined in terms of the reference
+    image coordinate system.  At a given location in the reference image
+    space, the non-linear mapping at that location can be used to calculate
+    the corresponding location in the source image space. Therefore, these
+    non-linear transformation effectively encode a transformation *from* the
+    reference image *to* the (unwarped) source image.
+    """
+
+
+    def __init__(self,
+                 image,
+                 src,
+                 ref,
+                 srcSpace=None,
+                 refSpace=None,
+                 **kwargs):
+        """Create a ``NonLinearTransform``. See the :meth:`.Nifti.getAffine`
+        method for an overview of the values that ``srcSpace`` and ``refSpace``
+        may take.
+
+        :arg image:       A string containing the name of an image file to
+                          load, or a :mod:`numpy` array, or a :mod:`nibabel`
+                          image object.
+
+        :arg src:         :class:`.Nifti` representing the source image.
+
+        :arg ref:         :class:`.Nifti` representing the reference image.
+
+        :arg srcSpace:    Coordinate system in the source image that this
+                          ``NonLinearTransform`` maps to. Defaults to
+                          ``'fsl'``.
+
+        :arg refSpace:    Coordinate system in the reference image that this
+                          ``NonLinearTransform`` maps from. Defaults to
+                          ``'fsl'``.
+
+        All other arguments are passed through to :meth:`.Image.__init__`.
+        """
+
+        # TODO Could make more general by replacing
+        # srcSpace and refSpace with src/ref affines,
+        # which transform tofrom (e.g.) source/ref
+        # voxels to/from the space required by the
+        # deformation field
+
+        if srcSpace is None: srcSpace = 'fsl'
+        if refSpace is None: refSpace = 'fsl'
+
+        if srcSpace not in ('fsl', 'voxel', 'world') or \
+           refSpace not in ('fsl', 'voxel', 'world'):
+            raise ValueError('Invalid source/reference space: {} -> {}'.format(
+                srcSpace, refSpace))
+
+        fslimage.Image.__init__(self, image, **kwargs)
+
+        self.__src      = fslimage.Nifti(src.header.copy())
+        self.__ref      = fslimage.Nifti(ref.header.copy())
+        self.__srcSpace = srcSpace
+        self.__refSpace = refSpace
+
+
+    @property
+    def src(self):
+        """Return a reference to the :class:`.Nifti` instance representing
+        the source image.
+        """
+        return self.__src
+
+
+    @property
+    def ref(self):
+        """Return a reference to the :class:`.Nifti` instance representing
+        the reference image.
+        """
+        return self.__ref
+
+
+    @property
+    def srcSpace(self):
+        """Return the source image coordinate system this
+        ``NonLinearTransform`` maps from - see :meth:`.Nifti.getAffine`.
+        """
+        return self.__srcSpace
+
+
+    @property
+    def refSpace(self):
+        """Return the reference image coordinate system this
+        ``NonLinearTransform`` maps to - see :meth:`.Nifti.getAffine`.
+        """
+        return self.__refSpace
+
+
+    def transform(self, coords, from_=None, to=None):
+        """Transform coordinates from the reference image space to the source
+        image space. Implemented by sub-classes.
+
+        See the :meth:`.Nifti.getAffine` method for an overview of the values
+        that ``from_`` and ``to`` may take.
+
+        :arg coords: A sequence of XYZ coordinates, or ``numpy`` array of shape
+                     ``(n, 3)`` containing ``n`` sets of coordinates in the
+                     reference space.
+
+        :arg from_:  Reference image space that ``coords`` are defined in
+
+        :arg to:     Source image space to transform ``coords`` into
+
+        :returns:    The corresponding coordinates in the source image space.
+        """
+        raise NotImplementedError()
+
+
+class DeformationField(NonLinearTransform):
+    """Class which represents a deformation (a.k.a. warp) field which, at each
+    voxel, contains either:
+
+      - a relative displacement from the reference image space to the source
+        image space, or
+      - absolute coordinates in the source space
+
+
+    It is assumed that the a ``DeformationField`` is aligned with the
+    reference image in their world coordinate systems (i.e. their ``sform``
+    affines project the reference image and the deformation field into
+    alignment).
+    """
+
+
+    def __init__(self, image, src, ref=None, **kwargs):
+        """Create a ``DisplacementField``.
+
+        :arg ref:     Optional. If not provided, it is assumed that the
+                      reference is defined in the same space as ``image``.
+
+        :arg defType: Either ``'absolute'`` or ``'relative'``, indicating
+                      the type of this displacement field. If not provided,
+                      will be inferred via the :func:`detectDeformationType`
+                      function.
+
+        All other arguments are passed through to
+        :meth:`NonLinearTransform.__init__`.
+        """
+
+        if ref is None:
+            ref = self
+
+        defType = kwargs.pop('defType', None)
+
+        if defType not in (None, 'relative', 'absolute'):
+            raise ValueError('Invalid value for defType: {}'.format(defType))
+
+        NonLinearTransform.__init__(self, image, src, ref, **kwargs)
+
+        self.__defType = defType
+
+
+    @property
+    def deformationType(self):
+        """The type of this ``DeformationField`` - ``'absolute'`` or
+        ``'relative'``.
+        """
+        if self.__defType is None:
+            self.__defType = detectDeformationType(self)
+        return self.__defType
+
+
+    @property
+    def absolute(self):
+        """``True`` if this ``DeformationField`` contains absolute
+        coordinates.
+        """
+        return self.deformationType == 'absolute'
+
+
+    @property
+    def relative(self):
+        """``True`` if this ``DeformationField`` contains relative
+        displacements.
+        """
+        return self.deformationType == 'relative'
+
+
+    def transform(self, coords, from_=None, to=None):
+        """Transform the given XYZ coordinates from the reference image space
+        to the source image space.
+
+        :arg coords: A sequence of XYZ coordinates, or ``numpy`` array of shape
+                     ``(n, 3)`` containing ``n`` sets of coordinates in the
+                     reference space.
+
+        :arg from_:  Reference image space that ``coords`` are defined in
+
+        :arg to:     Source image space to transform ``coords`` into
+
+        :returns:    ``coords``, transformed into the source image space
+        """
+
+        if from_ is None: from_ = self.refSpace
+        if to    is None: to    = self.srcSpace
+
+        coords = np.asanyarray(coords)
+
+        # We may need to pre-transform the
+        # coordinates so they are in the
+        # same reference image space as the
+        # displacements
+        if from_ != self.refSpace:
+            xform  = self.ref.getAffine(from_, self.refSpace)
+            coords = affine.transform(coords, xform)
+
+        # We also need to get the coordinates
+        # in field voxels, so we can look up
+        # the displacements/coordinates. We
+        # can get this through the assumption
+        # that field and ref are aligned in
+        # the world coordinate system
+        xform = affine.concat(self    .getAffine('world',       'voxel'),
+                              self.ref.getAffine(self.refSpace, 'world'))
+
+        if np.all(np.isclose(xform, np.eye(4))):
+            voxels = coords
+        else:
+            voxels = affine.transform(coords, xform)
+
+        # Mask out the coordinates
+        # that are out of bounds of
+        # the deformation field
+        voxels  = np.round(voxels).astype(np.int)
+        voxmask = (voxels >= [0, 0, 0]) & (voxels < self.shape[:3])
+        voxmask = voxmask.all(axis=1)
+        voxels  = voxels[voxmask]
+
+        xs, ys, zs = voxels.T
+
+        if self.absolute: disps = self.data[xs, ys, zs, :]
+        else:             disps = self.data[xs, ys, zs, :] + coords[voxmask]
+
+        # Make sure the coordinates are in
+        # the requested source image space
+        if to != self.srcSpace:
+            xform = self.src.getAffine(self.srcSpace, to)
+            disps = affine.transform(disps, xform)
+
+        # Nans for input coordinates which
+        # were outside of the field
+        outcoords          = np.full(coords.shape, np.nan)
+        outcoords[voxmask] = disps
+
+        return outcoords
+
+
+class CoefficientField(NonLinearTransform):
+    """Class which represents a B-spline coefficient field generated by FNIRT.
+
+    The :meth:`displacements` method can be used to calculate relative
+    displacements for a set of reference space voxel coordinates.
+
+
+    A FNIRT nonlinear transformation often contains a *premat*, a global
+    affine transformation from the source space to the reference space, which
+    was calculated with FLIRT, and used as the starting point for the
+    non-linear optimisation performed by FNIRT.
+
+
+    This affine may be provided when creating a ``CoefficientField`` as the
+    ``srcToRefMat`` argument to :meth:`__init__`, and is subsequently accessed
+    via the :meth:`srcToRefMat` attribute.
+    """
+
+
+    def __init__(self,
+                 image,
+                 src,
+                 ref,
+                 srcSpace=None,
+                 refSpace=None,
+                 fieldType='cubic',
+                 knotSpacing=None,
+                 fieldToRefMat=None,
+                 srcToRefMat=None,
+                 **kwargs):
+        """Create a ``CoefficientField``.
+
+        :arg fieldType:     Must currently be ``'cubic'``
+
+        :arg knotSpacing:   A tuple containing the spline knot spacings along
+                            each axis.
+
+        :arg fieldToRefMat: Affine transformation which can transform reference
+                            image voxel coordinates into coefficient field
+                            voxel coordinates.
+
+        :arg srcToRefMat:   Optional initial global affine transformation from
+                            the source image to the reference image. This is
+                            assumed to be a FLIRT-style matrix, i.e. it
+                            transforms from source image ``srcSpace``
+                            coordinates into reference image ``refSpace``
+                            coordinates (typically ``'fsl'`` coordinates, i.e.
+                            scaled voxels potentially with a left-right flip).
+
+        See the :class:`NonLinearTransform` class for details on the other
+        arguments.
+        """
+
+        if fieldType not in ('cubic',):
+            raise ValueError('Unsupported field type: {}'.format(fieldType))
+
+        if srcToRefMat   is not None: srcToRefMat   = np.copy(srcToRefMat)
+        if fieldToRefMat is     None: fieldToRefMat = np.eye(4)
+
+        NonLinearTransform.__init__(self,
+                                    image,
+                                    src,
+                                    ref,
+                                    srcSpace,
+                                    refSpace,
+                                    **kwargs)
+
+        self.__fieldType     = fieldType
+        self.__knotSpacing   = tuple(knotSpacing)
+        self.__refToSrcMat   = None
+        self.__srcToRefMat   = srcToRefMat
+        self.__fieldToRefMat = np.copy(fieldToRefMat)
+        self.__refToFieldMat = affine.invert(self.__fieldToRefMat)
+
+        if srcToRefMat is not None:
+            self.__refToSrcMat = affine.invert(srcToRefMat)
+
+
+    @property
+    def fieldType(self):
+        """Return the type of the coefficient field, currently always
+        ``'cubic'``.
+        """
+        return self.__fieldType
+
+
+    @property
+    def knotSpacing(self):
+        """Return a tuple containing spline knot spacings along the x, y, and
+        z axes.
+        """
+        return self.__knotSpacing
+
+
+    @property
+    def fieldToRefMat(self):
+        """Return an affine transformation which can transform coefficient
+        field voxel coordinates into reference image voxel coordinates.
+        """
+        return np.copy(self.__fieldToRefMat)
+
+
+    @property
+    def refToFieldMat(self):
+        """Return an affine transformation which can transform reference
+        image voxel coordinates into coefficient field voxel coordinates.
+        """
+        return np.copy(self.__refToFieldMat)
+
+
+    @property
+    def srcToRefMat(self):
+        """Return the initial source-to-reference affine, or ``None`` if
+        there isn't one.
+        """
+        return self.__srcToRefMat
+
+
+    @property
+    def refToSrcMat(self):
+        """Return the inverse of the initial source-to-reference affine, or
+        ``None`` if there isn't one.
+        """
+        return self.__refToSrcMat
+
+
+    @memoize.Instanceify(memoize.memoize)
+    def asDeformationField(self, defType='relative', premat=True):
+        """Convert this ``CoefficientField`` to a :class:`DeformationField`.
+
+        This method is a wrapper around
+        :func:`coefficientFieldToDeformationField`
+        """
+        return coefficientFieldToDeformationField(self, defType, premat)
+
+
+    def transform(self, coords, from_=None, to=None, premat=True):
+        """Overrides :meth:`NonLinearTransform.transform`. Transforms the
+        given ``coords`` from the reference image space into the source image
+        space.
+
+        :arg coords: A sequence of XYZ coordinates, or ``numpy`` array of shape
+                     ``(n, 3)`` containing ``n`` sets of coordinates in the
+                     reference space.
+
+        :arg from_:  Reference image space that ``coords`` are defined in
+
+        :arg to:     Source image space to transform ``coords`` into
+
+        :arg premat: If ``True``, the inverse :meth:`srcToRefMat` is applied
+                     to the coordinates after the displacements have been
+                     added.
+
+        :returns:    ``coords``, transformed into the source image space
+        """
+        df = self.asDeformationField(premat=premat)
+        return df.transform(coords, from_, to)
+
+
+    def displacements(self, coords):
+        """Calculate the relative displacements for the given coordinates.
+
+        :arg coords: ``(N, 3)`` array of reference image voxel coordinates.
+        :return:      A ``(N, 3)`` array  of relative displacements to the
+                      source image for ``coords``
+        """
+
+        if self.fieldType != 'cubic':
+            raise NotImplementedError()
+
+        # See
+        #   https://www.cs.jhu.edu/~cis/cista/746/papers/\
+        #     RueckertFreeFormBreastMRI.pdf
+        #   https://www.fmrib.ox.ac.uk/datasets/techrep/tr07ja2/tr07ja2.pdf
+
+        # Cubic b-spline basis functions
+        def b0(u):
+            return ((1 - u) ** 3) / 6
+
+        def b1(u):
+            return (3 * (u ** 3) - 6 * (u ** 2) + 4) / 6
+
+        def b2(u):
+            return (-3 * (u ** 3) + 3 * (u ** 2)  + 3 * u + 1) / 6
+
+        def b3(u):
+            return (u ** 3) / 6
+
+        b = [b0, b1, b2, b3]
+
+        fdata      = self.data
+        nx, ny, nz = self.shape[:3]
+        ix, iy, iz = self.ref.shape[:3]
+
+        # Convert the given voxel coordinates
+        # into the corresponding coefficient
+        # field voxel coordinates
+        x, y, z    = coords.T
+        i, j, k    = affine.transform(coords, self.refToFieldMat).T
+
+        # i, j, k: coefficient field indices
+        # u, v, w: position of the ref voxel
+        #          on the current spline
+        u = np.remainder(i, 1)
+        v = np.remainder(j, 1)
+        w = np.remainder(k, 1)
+        i = np.floor(i).astype(np.int)
+        j = np.floor(j).astype(np.int)
+        k = np.floor(k).astype(np.int)
+
+        disps = np.zeros(coords.shape)
+
+        for l, m, n in it.product(range(4), range(4), range(4)):
+            il   = i + l
+            jm   = j + m
+            kn   = k + n
+            mask = (il >= 0)  & \
+                   (il <  nx) & \
+                   (jm >= 0)  & \
+                   (jm <  ny) & \
+                   (kn >= 0)  & \
+                   (kn <  nz)
+
+            il = il[mask]
+            jm = jm[mask]
+            kn = kn[mask]
+            uu = u[ mask]
+            vv = v[ mask]
+            ww = w[ mask]
+
+            cx, cy, cz = fdata[il, jm, kn, :].T
+            c          = b[l](uu) * b[m](vv) * b[n](ww)
+
+            disps[mask, 0] += c * cx
+            disps[mask, 1] += c * cy
+            disps[mask, 2] += c * cz
+
+        return disps
+
+
+def detectDeformationType(field):
+    """Attempt to automatically determine whether a deformation field is
+    specified in absolute or relative coordinates.
+
+    :arg field: A :class:`DeformationField`
+
+    :returns:   ``'absolute'`` if it looks like ``field`` contains absolute
+                coordinates, ``'relative'`` otherwise.
+    """
+
+    # This test is based on the assumption
+    # that a deformation field containing
+    # absolute coordinates will have a
+    # greater standard deviation than one
+    # which contains relative coordinates.
+    absdata = field[:]
+    reldata = convertDeformationType(field, 'relative')
+    stdabs  = absdata.std(axis=(0, 1, 2)).sum()
+    stdrel  = reldata.std(axis=(0, 1, 2)).sum()
+
+    if stdabs > stdrel: return 'absolute'
+    else:               return 'relative'
+
+
+def convertDeformationType(field, defType=None):
+    """Convert a deformation field between storing absolute coordinates or
+    relative displacements.
+
+    :arg field:   A :class:`DeformationField` instance
+    :arg defType: Either ``'absolute'`` or ``'relative'``. If not provided,
+                  the opposite type to ``field.deformationType`` is used.
+    :returns:     A ``numpy.array`` containing the adjusted deformation field.
+    """
+
+    if defType is None:
+        if field.deformationType == 'absolute': defType = 'relative'
+        else:                                   defType = 'absolute'
+
+    # Regardless of the conversion direction,
+    # we need the coordinates of every voxel
+    # in the reference coordinate system.
+    dx, dy, dz = field.shape[:3]
+    xform      = affine.concat(field.ref.getAffine('world', field.refSpace),
+                               field    .getAffine('voxel', 'world'))
+
+    coords     = np.meshgrid(np.arange(dx),
+                             np.arange(dy),
+                             np.arange(dz), indexing='ij')
+    coords     = np.array(coords).transpose((1, 2, 3, 0))
+    coords     = affine.transform(coords.reshape((-1, 3)), xform)
+    coords     = coords.reshape((dx, dy, dz, 3))
+
+    # If converting from relative to absolute,
+    # we just add the coordinates to (what is
+    # assumed to be) the relative shift. Or,
+    # to convert from absolute to relative,
+    # we subtract the reference image voxels.
+    if   defType == 'absolute': return field.data + coords
+    elif defType == 'relative': return field.data - coords
+
+
+def convertDeformationSpace(field, from_, to):
+    """Adjust the source and/or reference spaces of the given deformation
+    field. See the :meth:`.Nifti.getAffine` method for the valid values for
+    the ``from_`` and ``to`` arguments.
+
+    :arg field: A :class:`DeformationField` instance
+    :arg from_: New reference image coordinate system
+    :arg to:    New source image coordinate system
+
+    :returns:   A new :class:`DeformationField` which transforms between
+                the reference ``from_`` coordinate system and the source ``to``
+                coordinate system.
+    """
+
+    if field.srcSpace == to and field.refSpace == from_:
+        return field
+
+    # Get the field in absolute coordinates
+    # if necessary - these are our source
+    # coordinates in the original "to" space.
+    fieldcoords = field.data
+    if field.relative: srccoords = convertDeformationType(field)
+    else:              srccoords = fieldcoords
+
+    srccoords = srccoords.reshape((-1, 3))
+
+    # Now transform those source coordinates
+    # from the original source space to the
+    # source space specified by "to"
+    if to != field.srcSpace:
+
+        srcmat    = field.src.getAffine(field.srcSpace, to)
+        srccoords = affine.transform(srccoords, srcmat)
+
+    # If we have been asked to return
+    # absolute coordinates, the
+    # reference "from_" coordinate
+    # system is irrelevant - we're done.
+    if field.absolute:
+        fieldcoords = srccoords
+
+    # Otherwise our deformation field
+    # will contain relative displacements
+    # between the reference image "from_"
+    # coordinate system and the source
+    # image "to" coordinate system. We
+    # need to re-calculate the relative
+    # displacements between the new
+    # reference "from_" space and source
+    # "to" space.
+    else:
+        refcoords = np.meshgrid(np.arange(field.shape[0]),
+                                np.arange(field.shape[1]),
+                                np.arange(field.shape[2]), indexing='ij')
+        refcoords = np.array(refcoords)
+        refcoords = refcoords.transpose((1, 2, 3, 0)).reshape((-1, 3))
+
+        xform = affine.concat(
+            field.ref.getAffine('world', from_),
+            field    .getAffine('voxel', 'world'))
+
+        if not np.all(np.isclose(xform, np.eye(4))):
+            refcoords = affine.transform(refcoords, xform)
+
+        fieldcoords = srccoords - refcoords
+
+    return DeformationField(
+        fieldcoords.reshape(field.shape),
+        header=field.header,
+        src=field.src,
+        ref=field.ref,
+        srcSpace=to,
+        refSpace=from_,
+        defType=field.deformationType)
+
+
+def applyDeformation(image, field, ref=None, order=1, mode=None, cval=None):
+    """Applies a :class:`DeformationField` to an :class:`.Image`.
+
+    The image is transformed into the space of the field's reference image
+    space. See the ``scipy.ndimage.interpolation.map_coordinates`` function
+    for details on the ``order``, ``mode`` and ``cval`` options.
+
+    If an alternate reference image is provided via the ``ref`` argument,
+    the deformation field is resampled into its space, and then applied to
+    the input image. It is therefore assumed that an alternate ``ref`` is
+    aligned in world coordinates with the field's actual reference image.
+
+    :arg image: :class:`.Image` to be transformed
+
+    :arg field: :class:`DeformationField` to use
+
+    :arg ref:   Alternate reference image - if not provided, ``field.ref``
+                is used
+
+    :arg order: Spline interpolation order, passed through to the
+                ``scipy.ndimage.affine_transform`` function - ``0``
+                corresponds to nearest neighbour interpolation, ``1``
+                (the default) to linear interpolation, and ``3`` to
+                cubic interpolation.
+
+    :arg mode:  How to handle regions which are outside of the image FOV.
+                Defaults to `''nearest'``.
+
+    :arg cval:  Constant value to use when ``mode='constant'``.
+
+    :return:    ``numpy.array`` containing the transformed image data.
+    """
+
+    if order is None: order = 1
+    if mode  is None: mode  = 'nearest'
+    if cval  is None: cval  = 0
+    if ref   is None: ref   = field.ref
+
+    # We need the field to contain
+    # absolute source image voxel
+    # coordinates
+    field = convertDeformationSpace(field, 'voxel', 'voxel')
+    if field.deformationType != 'absolute':
+        field = DeformationField(convertDeformationType(field, 'absolute'),
+                                 header=field.header,
+                                 src=field.src,
+                                 ref=field.ref,
+                                 srcSpace='voxel',
+                                 refSpace='voxel',
+                                 defType='absolute')
+
+    # If the field is not voxel-aligned
+    # to the reference, we need to
+    # resample the field itself into the
+    # reference image space (assumed to
+    # be world-aligned). If field and ref
+    # are not not world  aligned, regions
+    # of the field outside of the
+    # reference image space will contain
+    # -1s, so will be detected as out of
+    # bounds by map_coordinates below.
+    #
+    # This will potentially result in
+    # truncation at the field boundaries,
+    # but there's nothing we can do about
+    # that.
+    src = field.src
+
+    if not field.sameSpace(ref):
+        field = resample.resampleToReference(field,
+                                             ref,
+                                             order=order,
+                                             mode='constant',
+                                             cval=-1)[0]
+    else:
+        field = field.data
+
+    # If the input image is in a
+    # different space to the field
+    # source space, we need to
+    # adjust the resampling matrix.
+    # We assume world-world alignment
+    # between the original source
+    # and the image to be resampled
+    if not image.sameSpace(src):
+        post  = affine.concat(image.getAffine('world', 'voxel'),
+                              src  .getAffine('voxel', 'world'))
+        shape = field.shape
+        field = field.reshape((-1, 3))
+        field = affine.transform(field, post)
+        field = field.reshape(shape)
+
+    field = field.transpose((3, 0, 1, 2))
+    return ndinterp.map_coordinates(image.data,
+                                    field,
+                                    order=order,
+                                    mode=mode,
+                                    cval=cval)
+
+
+def coefficientFieldToDeformationField(field, defType='relative', premat=True):
+    """Convert a :class:`CoefficientField` into a :class:`DeformationField`.
+
+    :arg field:   :class:`CoefficientField` to convert
+
+    :arg defType: The type of deformation field - either ``'relative'`` (the
+                  default) or ``'absolute'``.
+
+    :arg premat:  If ``True`` (the default), the :meth:`srcToRefMat` is
+                  encoded into the deformation field.
+
+    :return:      :class:`DeformationField` calculated from ``field``.
+    """
+
+    # Generate coordinates for every
+    # voxel in the reference image
+    ix, iy, iz = field.ref.shape[:3]
+    x,  y,  z  = np.meshgrid(np.arange(ix),
+                             np.arange(iy),
+                             np.arange(iz), indexing='ij')
+    x          = x.flatten()
+    y          = y.flatten()
+    z          = z.flatten()
+    xyz        = np.vstack((x, y, z)).T
+
+    # There are three spaces to consider here:
+    #
+    #  - ref space:         Reference image scaled voxels ("fsl" space)
+    #
+    #  - aligned-src space: Source image scaled voxels, after the
+    #                       source image has been linearly aligned to
+    #                       the reference via the field.srcToRefMat
+    #                       This will typically be equivalent to ref
+    #                       space
+    #
+    #  - orig-src space:    Source image scaled voxels, in the coordinate
+    #                       system of the original source image, without
+    #                       linear alignment to the reference image
+
+    # The displacements method will
+    # return relative displacements
+    # from ref space to aligned-src
+    # space.
+    disps   = field.displacements(xyz).reshape((ix, iy, iz, 3))
+    rdfield = DeformationField(disps,
+                               header=field.ref.header,
+                               src=field.src,
+                               ref=field.ref,
+                               srcSpace=field.srcSpace,
+                               refSpace=field.refSpace,
+                               defType='relative')
+
+    if (defType == 'relative') and (not premat):
+        return rdfield
+
+    # Convert to absolute - the
+    # deformations will now be
+    # absolute coordinates in
+    # aligned-src space
+    disps = convertDeformationType(rdfield)
+
+    # Apply the premat if requested -
+    # this will transform the coordinates
+    # from aligned-src to orig-src space.
+    if premat and field.srcToRefMat is not None:
+
+        # We apply the premat in the same way
+        # that fnirtfileutils does - applying
+        # the inverse affine to every ref space
+        # voxel coordinate, then adding it to
+        # the existing displacements.
+        shape  = disps.shape
+        disps  = disps.reshape(-1, 3)
+        premat = affine.concat(field.refToSrcMat - np.eye(4),
+                               field.ref.getAffine('voxel', 'fsl'))
+        disps  = disps + affine.transform(xyz, premat)
+        disps  = disps.reshape(shape)
+
+        # note that convertwarp applies a premat
+        # differently - its method is equivalent
+        # to directly transforming the existing
+        # absolute displacements, i.e.:
+        #
+        #   disps = affine.transform(disps, refToSrc)
+
+    adfield = DeformationField(disps,
+                               header=field.ref.header,
+                               src=field.src,
+                               ref=field.ref,
+                               srcSpace=field.srcSpace,
+                               refSpace=field.refSpace,
+                               defType='absolute')
+
+    # Not either return absolute displacements,
+    # or convert back to relative displacements
+    if defType == 'absolute':
+        return adfield
+    else:
+        return DeformationField(convertDeformationType(adfield),
+                                src=field.src,
+                                ref=field.ref,
+                                srcSpace=field.srcSpace,
+                                refSpace=field.refSpace,
+                                header=field.ref.header,
+                                defType='relative')
diff --git a/fsl/transform/x5.py b/fsl/transform/x5.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ffe032821a5c3aeb231cf2835011fe5d67449bb
--- /dev/null
+++ b/fsl/transform/x5.py
@@ -0,0 +1,607 @@
+#!/usr/bin/env python
+#
+# x5.py - Functions for working with BIDS X5 files.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module contains functions for reading/writing linear/non-linear
+transformations from/to BIDS X5 files. The following functions are available:
+
+.. autosummary::
+   :nosignatures:
+
+   inferType
+   readLinearX5
+   writeLinearX5
+   readNonLinearX5
+   writeNonLinearX5
+
+
+An X5 file is a HDF5 container file which stores a linear or non-linear
+transformation from one NIfTI image to another.
+
+
+Several terms may be used to refer to these images, such as **source** and
+**reference**, **moving** and **fixed**, **from** and **to**, etc.  In an X5
+file, the two images are simply referred to as **A** and **B**, where **A**
+refers to the starting point of the transformation, and **B** to the end
+point.
+
+
+X5 files enable a transformation from the **world coordinate system** of image
+**A** to the **world coordinate system** of image **B**.  The **world
+coordinate system** of an image is defined by its ``sform`` or ``qform``
+(hereafter referred to as the ``sform``), which is contained in its NIfTI
+header.
+
+
+Custom HDF5 groups
+==================
+
+
+HDF5 files are composed primarily of *groups*, *attributes*, and
+*datasets*:
+
+
+ - *Groups* are simply containers for attributes, datasets, and other groups.
+ - *Datasets* are strongly-typed, structured N-dimensional arrays.
+ - *Attributes* are strongly-typed scalar values associated with a group or
+   dataset.
+
+
+To simplify the file format definitions below, we shall first define a few
+custom HDF5 groups. In the file format definitions, a HDF5 group which is
+listed as being of one of these custom types shall contain the
+attributes/datasets that are listed here.
+
+
+*affine*
+--------
+
+
+A HDF5 group which is listed as being of type *affine* contains an affine
+transformation, which can be used to transform coordinates from one space into
+another. Groups of type *affine* have the following fields:
+
+
++-------------+-----------+---------------------------------------------------+
+| **Name**    | **Type**  | **Value/Description**                             |
++-------------+-----------+---------------------------------------------------+
+| ``Type``    | attribute | ``'affine'``                                      |
++-------------+-----------+---------------------------------------------------+
+| ``Matrix``  | dataset   | The affine transformation matrix - a ``float64``  |
+|             |           | array of shape ``(4, 4)``                         |
++-------------+-----------+---------------------------------------------------+
+| ``Inverse`` | dataset   | Optional pre-calculated inverse                   |
++-------------+-----------+---------------------------------------------------+
+
+
+*space*
+-------
+
+
+A HDF5 group which is listed as being of type *space* contains all of the
+information required to define the space of a NIfTI image, including its
+shape, dimensions, and voxel-to-world affine transformation.
+
+
+Groups of type *space* have the following fields:
+
+
++--------------+-----------+--------------------------------------------------+
+| **Name**     | **Type**  | **Value/Description**                            |
++--------------+-----------+--------------------------------------------------+
+| ``Type``     | attribute | ``'image'``                                      |
++--------------+-----------+--------------------------------------------------+
+| ``Size``     | attribute | ``uint64`` ``(X, Y, Z)`` voxel dimensions        |
++--------------+-----------+--------------------------------------------------+
+| ``Scales``   | attribute | ``float64`` ``(X, Y, Z)`` voxel pixdims          |
++--------------+-----------+--------------------------------------------------+
+| ``Mapping/`` | affine    | The image voxel-to-world transformation (its     |
+|              |           | ``sform``)                                       |
++--------------+-----------+--------------------------------------------------+
+
+
+*deformation*
+-------------
+
+
+A HDF5 group which is listed as being of type *deformation* contains a
+non-linear transformation, which can be used to transform coordinates from
+one space (space **A**) into another (space **B**).
+
+
+The transformation is represented as a 3D **deformation field** which, at each
+voxel within the field, may contain:
+
+ - *relative displacements* from space **A** to space **B** (i.e. for a given
+   location in space **A**, you can add the displacement values to the
+   coordinates of that location to obtain the coordinates of the corresponding
+   location in space **B**).
+
+ - *absolute coordinates* in space **B**.
+
+
+The ``Mapping`` affine can be used to calculate a correspondence between the
+deformation field coordinate system and the coordinate system of space **A** -
+it is assumed that space **A** and the deformation field share a common world
+coordinate system.
+
+
+Groups of type *deformation* have the following fields:
+
+
++--------------+-----------+--------------------------------------------------+
+| **Name**     | **Type**  | **Value/Description**                            |
++--------------+-----------+--------------------------------------------------+
+| ``Type``     | attribute | ``'deformation'``                                |
++--------------+-----------+--------------------------------------------------+
+| ``SubType``  | attribute | ``'absolute'`` or ``'relative'``.                |
++--------------+-----------+--------------------------------------------------+
+| ``Matrix``   | dataset   | The deformation field - a ``float64`` array of   |
+|              |           | shape ``(X, Y, Z, 3)``                           |
++--------------+-----------+--------------------------------------------------+
+| ``Mapping/`` | affine    | The field voxel-to-world transformation (its     |
+|              |           | ``sform``)                                       |
++--------------+-----------+--------------------------------------------------+
+
+
+Linear X5 files
+===============
+
+
+Linear X5 transformation files contain an affine transformation matrix of
+shape ``(4, 4)``, which can be used to transform image **A** world
+coordinates into image **B** world coordinates.
+
+
+Linear X5 transformation files are assumed to adhere to the HDF5 structure
+defined in the table below.  All fields are required unless otherwise noted.
+
+
++-----------------+-----------+-----------------------------------------------+
+| **Name**        | **Type**  | **Value/Description**                         |
++-----------------+-----------+-----------------------------------------------+
+| *Metadata*                                                                  |
++-----------------+-----------+-----------------------------------------------+
+| ``/Format``     | attribute | ``'X5'``                                      |
++-----------------+-----------+-----------------------------------------------+
+| ``/Version``    | attribute | ``'0.0.1'``                                   |
++-----------------+-----------+-----------------------------------------------+
+| ``/Metadata``   | attribute | JSON string containing unstructured metadata. |
++-----------------+-----------+-----------------------------------------------+
+| *Transformation*                                                            |
++-----------------+-----------+-----------------------------------------------+
+| ``/Type``       | attribute | ``'linear'``                                  |
++-----------------+-----------+-----------------------------------------------+
+| ``/Transform/`` | affine    | Affine transformation from image **A** world  |
+|                 |           | coordinates to image **B** world coordinates  |
++-----------------+-----------+-----------------------------------------------+
+| ``/A/``         | space     | Image **A** space                             |
++-----------------+-----------+-----------------------------------------------+
+| ``/B/``         | space     | Image **B** space                             |
++-----------------+-----------+-----------------------------------------------+
+
+
+Storage of FSL FLIRT matrices in linear X5 files
+------------------------------------------------
+
+
+FLIRT outputs the result of a linear registration from a source image to a
+reference image as an affine matrix of shape ``(4, 4)``. This matrix encodes a
+transformation from source image **FSL coordinates** to reference image **FSL
+coordinates** [*]_.
+
+
+In contrast, X5 matrices encode a transformation in **world coordinates**,
+i.e. they can be used to transform coordinates from the source image to the
+reference image, after both images have been transformed into a common
+coordinate system via their respective ``sform`` affines.
+
+
+The :mod:`fsl.transform.flirt` module contains functions for converting
+between FLIRT-style matrices and X5 style matrices.
+
+
+.. [*] For a given image, FSL coordinates are voxel coordinates scaled by the
+       ``pixdim`` values in the NIFTI header, with an inversion along the X
+       axis if the voxel-to-world affine (the ``sform``) has a positive
+       determinant.
+
+
+Non-linear X5 files
+===================
+
+
+Non-linear X5 transformation files contain a non-linear transformation from
+image **A** world coordinates to image **B** world coordinates. The
+transformation is represented as a 3D **deformation field** which, at each
+voxel within the field, may contain:
+
+ - *relative displacements* from image **A** to image **B** (i.e. for a given
+   location in the image **A** world coordinate system, add the displacement
+   values to the coordinates to obtain the corresponding location in the
+   image **B** world coordinate system).
+
+ - *absolute coordinates* in the image **B** world coordinate system.
+
+
+File format specification
+-------------------------
+
+
+Non-linear X5 transformation files are assumed to adhere to the following
+HDF5 structure. All fields are required unless otherwise noted.
+
+
++---------------+-----------+-------------------------------------------------+
+| **Name**      | **Type**  | **Value/Description**                           |
++---------------+-----------+-------------------------------------------------+
+| *Metadata*                                                                  |
++---------------+-----------+-------------------------------------------------+
+| ``/Format``   | attribute | ``'X5'``                                        |
++---------------+-----------+-------------------------------------------------+
+| ``/Version``  | attribute | ``'0.0.1'``                                     |
++---------------+-----------+-------------------------------------------------+
+| ``/Metadata`` | attribute | JSON string containing unstructured metadata.   |
++---------------+-----------+-------------------------------------------------+
+| *Transformation*                                                            |
++-----------------+-------------+---------------------------------------------+
+| **Name**        | **Type**    | **Value/Description**                       |
++-----------------+-------------+---------------------------------------------+
+| ``/Type``       | attribute   | ``'nonlinear'``                             |
++-----------------+-------------+---------------------------------------------+
+| ``/Transform/`` | deformation | The deformation field, encoding a nonlinear |
+|                 |             | transformation from image **A** to image    |
+|                 |             | **B**                                       |
++-----------------+-------------+---------------------------------------------+
+| ``/Inverse/``   | deformation | Optional pre-calculated inverse, encoding a |
+|                 |             | nonlinear transformation from image **B**   |
+|                 |             | to image **A**                              |
++-----------------+-------------+---------------------------------------------+
+| ``/A/``         | space       | Image **A** space                           |
++-----------------+-------------+---------------------------------------------+
+| ``/B/``         | space       | Image **B** space                           |
++-----------------+-------------+---------------------------------------------+
+
+
+Storage of FSL FNIRT warp fields in non-linear X5 files
+-------------------------------------------------------
+
+
+FLIRT outputs the result of a non-linear registration between a source image
+and a reference image as either a warp field, or a coefficient field which can
+be used to generate a warp field. A warp field is defined in terms of the
+reference image - the warp field has the same shape and FOV as the reference
+image, and contains either:
+
+ - relative displacements from the corresponding reference image location to
+   the unwarped source image location
+ - absolute unwarped source image coordinates
+
+
+The reference image for a FNIRT warp field thus corresponds to image **A** in
+a X5 non-linear transform, and the FNIRT source image to image **B**.
+
+
+FNIRT warp fields are defined in FSL coordinates - a relative warp contains
+displacements from reference image FSL coordinates to source image FSL
+coordinates, and an absolute warp contains source image FSL coordinates.
+
+
+When a FNIRT warp field is stored in an X5 file, the displacements/coordinates
+must be adjusted so that they encode a transformation from reference image
+world coordinates to source image world coordinates.
+
+
+Conversion of FNIRT coefficient fields
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+A FNIRT coefficient field can be used to generate a deformation field which
+contains relative displacements from reference image FSL coordinates to source
+image FSL coordinates. If an initial affine registration was used as the
+starting point for FNIRT, this generated displacement field contains relative
+displacements from the reference image to the *aligned* source image,
+i.e. after it has been transformed by the initial affine alignment.
+
+
+When a FNIRT coefficient field is stored in an X5 file, it must first be
+converted to a displacement field. The displacements must then be adjusted so
+that they take into account the initial affine alignment (if relevant), and
+so that they encode displacements from reference image world coordinates
+to source image world coordinates.
+
+
+The :mod:`fsl.transform.fnirt` module contains functions which can be used to
+perform all of the conversions and adjustments required to store FNIRT
+transformations as X5 files.
+
+"""
+
+
+import json
+
+import numpy   as np
+import nibabel as nib
+import h5py
+
+import fsl.version    as fslversion
+import fsl.data.image as fslimage
+from . import            affine
+from . import            nonlinear
+
+
+X5_FORMAT  = 'X5'
+X5_VERSION = '0.1.0'
+
+
+class X5Error(Exception):
+    """Error raised if an invalid/incompatible file is detected. """
+    pass
+
+
+def inferType(fname):
+    """Return the type of the given X5 file - either ``'linear'`` or
+    ``'nonlinear'``.
+
+    :arg fname: Name of a X5 file
+    :returns:   ``'linear'`` or ``'nonlinear'``
+    """
+    with h5py.File(fname, 'r') as f:
+
+        ftype = f.attrs.get('Type')
+
+        if ftype not in ('linear', 'nonlinear'):
+            raise X5Error('Unknown type: {}'.format(ftype))
+
+    return ftype
+
+
+def readLinearX5(fname):
+    """Read a linear X5 transformation file from ``fname``.
+
+    :arg fname: File name to read from
+    :returns:    A tuple containing:
+
+                  - A ``(4, 4)`` ``numpy`` array containing the affine
+                    transformation
+                  - A :class:`.Nifti` instance representing the source space
+                  - A :class:`.Nifti` instance representing the reference space
+    """
+
+    with h5py.File(fname, 'r') as f:
+
+        if f.attrs['Type'] != 'linear':
+            raise X5Error('Not a linear transform')
+
+        _readMetadata(      f['/'])
+        xform = _readAffine(f['/Transform'])
+        src   = _readSpace( f['/A'])
+        ref   = _readSpace( f['/B'])
+
+    return xform, src, ref
+
+
+def writeLinearX5(fname, xform, src, ref):
+    """Write a linear transformation to ``fname``.
+
+    :arg fname: File name to write to
+    :arg xform: ``(4, 4)`` ``numpy`` array containing the affine transformation
+    :arg src:   :class:`.Nifti` image representing the source space
+    :arg ref:   :class:`.Nifti` image representing the reference
+    """
+
+    with h5py.File(fname, 'w') as f:
+
+        f.attrs['Type'] = 'linear'
+
+        _writeMetadata(f)
+        _writeAffine(  f.create_group('/Transform'), xform)
+        _writeSpace(   f.create_group('/A'),         src)
+        _writeSpace(   f.create_group('/B'),         ref)
+
+
+def readNonLinearX5(fname):
+    """Read a nonlinear X5 transformation file from ``fname``.
+
+    :arg fname: File name to read from
+    :returns:   A :class:`.DeformationField`
+    """
+
+    with h5py.File(fname, 'r') as f:
+
+        if f.attrs.get('Type') != 'nonlinear':
+            raise X5Error('Not a nonlinear transform')
+
+        _readMetadata(f)
+
+        ref                   = _readSpace(      f['/A'])
+        src                   = _readSpace(      f['/B'])
+        field, xform, defType = _readDeformation(f['/Transform'])
+
+    return nonlinear.DeformationField(field,
+                                      xform=xform,
+                                      src=src,
+                                      ref=ref,
+                                      srcSpace='world',
+                                      refSpace='world',
+                                      defType=defType)
+
+
+def writeNonLinearX5(fname, field):
+    """Write a nonlinear X5 transformation to ``fname``.
+
+    :arg fname: File name to write to
+    :arg field: A :class:`.DeformationField`
+    """
+
+    with h5py.File(fname, 'w') as f:
+
+        f.attrs['Type'] = 'nonlinear'
+
+        _writeMetadata(f)
+        _writeSpace(      f.create_group('/A'),         field.ref)
+        _writeSpace(      f.create_group('/B'),         field.src)
+        _writeDeformation(f.create_group('/Transform'), field)
+
+
+def _readMetadata(group):
+    """Reads a metadata block from the given group, and raises a :exc:`X5Error`
+    if it does not look valid.
+
+    :arg group: A ``h5py.Group`` object.
+    :returns:   A ``dict`` containing the metadata.
+    """
+
+    format    = group.attrs.get('Format')
+    version   = group.attrs.get('Version')
+    meta      = group.attrs.get('Metadata')
+
+    parserver = fslversion.parseVersionString(X5_VERSION)
+    filever   = fslversion.parseVersionString(version)
+
+    if (format != X5_FORMAT) or (filever[0] != parserver[0]):
+        raise X5Error('Incompatible format/version (required: {}/{}, '
+                      'present: {}/{})'.format(X5_FORMAT, X5_VERSION,
+                                               format, version))
+
+    meta             = {}
+    meta['Format']   = format
+    meta['Version']  = version
+    meta['Metadata'] = meta
+
+    return meta
+
+
+def _writeMetadata(group):
+    """Writes a metadata block into the given group.
+
+    :arg group: A ``h5py.Group`` object.
+    """
+    group.attrs['Format']   = X5_FORMAT
+    group.attrs['Version']  = X5_VERSION
+    group.attrs['Metadata'] = json.dumps({'fslpy' : fslversion.__version__})
+
+
+def _readAffine(group):
+    """Reads an *affine* from the given group.
+
+    :arg group: A ``h5py.Group`` object.
+    :returns:   ``numpy`` array containing a ``(4, 4)`` affine transformation.
+    """
+
+    if group.attrs.get('Type') != 'affine':
+        raise X5Error('Not an affine')
+
+    xform = group['Matrix']
+
+    if xform.shape != (4, 4):
+        raise X5Error('Not an affine')
+
+    return np.array(xform)
+
+
+def _writeAffine(group, xform):
+    """Writes the given affine transformation and its inverse to the given
+    group.
+
+    :arg group: A ``h5py.Group`` object.
+    :arg xform: ``numpy`` array containing a ``(4, 4)`` affine transformation.
+    """
+
+    xform = np.asarray(xform,                dtype=np.float64)
+    inv   = np.asarray(affine.invert(xform), dtype=np.float64)
+
+    group.attrs['Type'] = 'affine'
+    group.create_dataset('Matrix',  data=xform)
+    group.create_dataset('Inverse', data=inv)
+
+
+def _readSpace(group):
+    """Reads a *space* group, defining a source or reference space.
+
+    :arg group: A ``h5py.Group`` object.
+    :returns:   :class:`.Nifti` object. defining the mapping
+    """
+
+    if group.attrs.get('Type') != 'image':
+        raise X5Error('Not an image space')
+
+    shape  = group.attrs['Size']
+    pixdim = group.attrs['Scales']
+    xform  = _readAffine(group['Mapping'])
+
+    hdr = nib.Nifti2Header()
+    hdr.set_data_shape(shape)
+    hdr.set_zooms(     pixdim)
+    hdr.set_sform(     xform, 'aligned')
+    return fslimage.Nifti(hdr)
+
+
+def _writeSpace(group, img):
+    """Writes a space specified by ``img`` to the given group.
+
+    :arg group: A ``h5py.Group`` object.
+    :arg img:   :class:`.Nifti` object. defining the mapping
+    """
+    group.attrs['Type']   = 'image'
+    group.attrs['Size']   = np.asarray(img.shape[ :3], np.uint32)
+    group.attrs['Scales'] = np.asarray(img.pixdim[:3], np.float32)
+
+    mapping = group.create_group('Mapping')
+    _writeAffine(mapping, img.getAffine('voxel', 'world'))
+
+
+def _readDeformation(group):
+    """Reads a *deformation* from the given group.
+
+    :arg group: A ``h5py.Group`` object
+    :returns:   A tuple containing
+
+                 - A ``numpy.array`` containing the deformation field
+
+                 - A ``numpy.array`` of shape ``(4, 4)`` containing the
+                   voxel to world affine for the deformation field
+
+                 - The deformation type - either ``'absolute'`` or
+                   ``'relative'``
+    """
+
+    type = group.attrs.get('Type')
+    subtype = group.attrs['SubType']
+
+    if type != 'deformation':
+        raise X5Error('Not a deformation')
+
+    if subtype not in ('absolute', 'relative'):
+        raise X5Error('Unknown deformation type: {}'.format(subtype))
+
+    mapping = _readAffine(group['Mapping'])
+    field   = group['Matrix']
+
+    if len(field.shape) != 4 or field.shape[3] != 3:
+        raise X5Error('Invalid shape for deformation field')
+
+    return np.array(field), mapping, subtype
+
+
+def _writeDeformation(group, field):
+    """Write a deformation field to the given group.
+
+    :arg group: A ``h5py.Group`` object
+    :arg field: A :class:`.DeformationField` object
+    """
+
+    if field.srcSpace != 'world' or \
+       field.refSpace != 'world':
+        raise X5Error('Deformation field must encode a '
+                      'world<->world transformation')
+
+    group.attrs['Type']    = 'deformation'
+    group.attrs['SubType'] = field.deformationType
+
+    mapping = group.create_group('Mapping')
+
+    group.create_dataset('Matrix', data=field.data)
+    _writeAffine(mapping, field.getAffine('voxel', 'world'))
diff --git a/fsl/utils/deprecated.py b/fsl/utils/deprecated.py
index c79dddfeb9073420c4d5449edd42ed50d9783b40..64a0ad90885cd72fe550b61826983b66e171231e 100644
--- a/fsl/utils/deprecated.py
+++ b/fsl/utils/deprecated.py
@@ -6,8 +6,10 @@
 #
 """This module provides the :func:`deprecated` function, a simple decorator
 for deprecating functions and methods.
-"""
 
+The :func:`warn` function can also be called directly, to emit a
+``DeprecationWarning``
+"""
 
 
 import functools as ft
@@ -16,14 +18,20 @@ import              warnings
 
 
 _warned_cache = set()
-"""Used by the :func:`deprecated` function to keep track of whether a warning
-has already been emitted for the use of a deprecated item.
+"""Used by to keep track of whether a warning has already been emitted for the
+use of a deprecated item.
 """
 
 
-def deprecated(vin=None, rin=None, msg=None):
-    """Decorator to mark a function or method as deprecated. A
-    ``DeprecationWarning`` is raised via the standard ``warnings`` module.
+def resetWarningCache():
+    """Clears the internal warning cache, so that the same line of code
+    may emit another deprecation warning.
+    """
+    _warned_cache.clear()
+
+
+def _buildMessageFormat(vin=None, rin=None, msg=None):
+    """Builds a deprecation warning message from the arguments.
 
     :arg vin: Optional version - the warning message will mention that the
               function is deprecated from this version.
@@ -32,9 +40,9 @@ def deprecated(vin=None, rin=None, msg=None):
               function will be removed in this version.
 
     :arg msg: Optional message to use in the warning.
-    """
-
 
+    :returns: A format string which needs to be formatted with a ``{name}``.
+    """
     if vin is not None and rin is not None:
         msgfmt = '{{name}} is deprecated from version {vin} and will be ' \
                  'removed in {rin}.'.format(vin=vin, rin=rin)
@@ -49,21 +57,62 @@ def deprecated(vin=None, rin=None, msg=None):
     if msg is not None:
         msgfmt = msgfmt + ' ' + msg
 
-    def wrapper(thing):
-        name = thing.__name__
+    return msgfmt
 
-        def decorator(*args, **kwargs):
 
-            frame = inspect.stack()[1]
-            ident = '{}:{}'.format(frame.filename, frame.lineno)
+def _buildWarningSourceIdentity(stacklevel=2):
+    """Creates a string to be used as an identifier for the calling code.
+
+    :arg stacklevel: How far up the calling stack the calling code is.
+    :returns:        A string which can be used as an identifier for the
+                     calling code.
+    """
+    frame = inspect.stack()[stacklevel]
+    ident = '{}:{}'.format(frame.filename, frame.lineno)
+    return ident
+
+
+def warn(name, vin=None, rin=None, msg=None, stacklevel=1):
+    """Emit a deprecation warning.
+
+    :arg name:       Name of the thing (class, function, module, etc) that is
+                     deprecated.
+
+    :arg vin:        Optional version - the warning message will mention that
+                     the function is deprecated from this version.
+
+    :arg rin:        Optional version - the warning message will mention that
+                     the function will be removed in this version.
 
-            if ident not in _warned_cache:
-                warnings.warn(msgfmt.format(name=name),
-                              category=DeprecationWarning,
-                              stacklevel=2)
-                _warned_cache.add(ident)
+    :arg msg:        Optional message to use in the warning.
 
+    :arg stacklevel: How far up the stack the calling code is.
+    """
+
+    msgfmt = _buildMessageFormat(vin=vin, rin=rin, msg=msg)
+    ident  = _buildWarningSourceIdentity()
+    if ident not in _warned_cache:
+        warnings.warn(msgfmt.format(name=name),
+                      category=DeprecationWarning,
+                      stacklevel=stacklevel + 1)
+        _warned_cache.add(ident)
+
+
+def deprecated(vin=None, rin=None, msg=None):
+    """Decorator to mark a function or method as deprecated. A
+    ``DeprecationWarning`` is raised via the standard ``warnings`` module.
+
+    :arg vin: Optional version - the warning message will mention that the
+              function is deprecated from this version.
+
+    :arg rin: Optional version - the warning message will mention that the
+              function will be removed in this version.
+
+    :arg msg: Optional message to use in the warning.
+    """
+    def wrapper(thing):
+        def decorator(*args, **kwargs):
+            warn(thing.__name__, vin=vin, rin=rin, msg=msg, stacklevel=2)
             return thing(*args, **kwargs)
         return ft.update_wrapper(decorator, thing)
-
     return wrapper
diff --git a/fsl/utils/idle.py b/fsl/utils/idle.py
index 1425ba191dd6d954c074369088c0a33d532dfeff..c1513708ebb401227dd1d0adb7cf9e0efd067631 100644
--- a/fsl/utils/idle.py
+++ b/fsl/utils/idle.py
@@ -278,7 +278,7 @@ class IdleTask(object):
 
 def _wxIdleLoop(ev):
     """Function which is called on ``wx.EVT_IDLE`` events, and occasionally
-    on ``wx.EVT_TIMER` events via the :attr:`_idleTimer`. If there
+    on ``wx.EVT_TIMER`` events via the :attr:`_idleTimer`. If there
     is a function on the :attr:`_idleQueue`, it is popped and called.
 
     .. note:: The ``wx.EVT_IDLE`` event is only triggered on user interaction
diff --git a/fsl/utils/image/__init__.py b/fsl/utils/image/__init__.py
index 36bde925ddd642203560caf5ac58ff99a364d386..54dde4b2a7c40e1c97f8b7fcfaa4f86d1b5781b5 100644
--- a/fsl/utils/image/__init__.py
+++ b/fsl/utils/image/__init__.py
@@ -13,4 +13,5 @@ The following modules are available:
    :nosignature
 
    .image.resample
+   .image.roi
 """
diff --git a/fsl/utils/image/resample.py b/fsl/utils/image/resample.py
index 91e79e6c0416cc01187bc4c636f59ec56aa23668..50dc0c4022cc5b32f763d8afd43f3d7fb31fba47 100644
--- a/fsl/utils/image/resample.py
+++ b/fsl/utils/image/resample.py
@@ -15,12 +15,12 @@ sub-functions of :func:`resample`.
 """
 
 
-import collections.abc     as abc
+import collections.abc      as abc
 
-import numpy               as np
-import scipy.ndimage       as ndimage
+import numpy                as np
+import scipy.ndimage        as ndimage
 
-import fsl.utils.transform as transform
+import fsl.transform.affine as affine
 
 
 def resampleToPixdims(image, newPixdims, **kwargs):
@@ -39,7 +39,7 @@ def resampleToPixdims(image, newPixdims, **kwargs):
     return resample(image, newShape, **kwargs)
 
 
-def resampleToReference(image, reference, **kwargs):
+def resampleToReference(image, reference, matrix=None, **kwargs):
     """Resample ``image`` into the space of the ``reference``.
 
     This is a wrapper around :func:`resample` - refer to its documenttion
@@ -49,6 +49,7 @@ def resampleToReference(image, reference, **kwargs):
     along the spatial (first three) dimensions.
 
     :arg image:     :class:`.Image` to resample
+    :arg matrix:    Optional world-to-world affine alignment matrix
     :arg reference: :class:`.Nifti` defining the space to resample ``image``
                     into
     """
@@ -56,6 +57,9 @@ def resampleToReference(image, reference, **kwargs):
     oldShape = list(image.shape)
     newShape = list(reference.shape[:3])
 
+    if matrix is None:
+        matrix = np.eye(4)
+
     # If the input image is >3D, pad the
     # new shape so that we only resample
     # along the first 3 dimensions.
@@ -63,8 +67,12 @@ def resampleToReference(image, reference, **kwargs):
         newShape = newShape + oldShape[len(newShape):]
 
     # Align the two images together
-    # via their vox-to-world affines.
-    matrix = transform.concat(image.worldToVoxMat, reference.voxToWorldMat)
+    # via their vox-to-world affines,
+    # and the world-to-world affine
+    # if provided
+    matrix = affine.concat(image.worldToVoxMat,
+                           affine.invert(matrix),
+                           reference.voxToWorldMat)
 
     # If the input image is >3D, we
     # have to adjust the resampling
@@ -82,7 +90,12 @@ def resampleToReference(image, reference, **kwargs):
     kwargs['newShape'] = newShape
     kwargs['matrix']   = matrix
 
-    return resample(image, **kwargs)
+    data = resample(image, **kwargs)[0]
+
+    # The image is now in the same space
+    # as the reference, so it inherits
+    # ref's voxel-to-world affine
+    return data, reference.voxToWorldMat
 
 
 def resample(image,
@@ -91,9 +104,9 @@ def resample(image,
              dtype=None,
              order=1,
              smooth=True,
-             origin='centre',
+             origin=None,
              matrix=None,
-             mode='nearest',
+             mode=None,
              cval=0):
     """Returns a copy of the data in the ``image``, resampled to the specified
     ``newShape``.
@@ -113,6 +126,12 @@ def resample(image,
     particularly on the ``order``, ``matrix``, ``mode`` and
     ``cval`` arguments.
 
+    .. note:: If a custom resampling ``matrix`` is specified, the adjusted
+              voxel-to-world afffine cannot be calculated by this function,
+              so ``None`` will be returned instead.
+
+    :arg image:    :class:`.Image` object to resample
+
     :arg newShape: Desired shape. May containg floating point values, in which
                    case the resampled image will have shape
                    ``round(newShape)``, but the voxel sizes will have scales
@@ -159,13 +178,18 @@ def resample(image,
 
                - A ``numpy`` array of shape ``(4, 4)``, containing the
                  adjusted voxel-to-world transformation for the spatial
-                 dimensions of the resampled data.
+                 dimensions of the resampled data, or ``None`` if a ``matrix``
+                 was provided.
     """
 
     if sliceobj is None:     sliceobj = slice(None)
     if dtype    is None:     dtype    = image.dtype
+    if origin   is None:     origin   = 'centre'
+    if mode     is None:     mode     = 'nearest'
     if origin   == 'center': origin   = 'centre'
 
+    ownMatrix = matrix is None
+
     if origin not in ('centre', 'corner'):
         raise ValueError('Invalid value for origin: {}'.format(origin))
 
@@ -216,7 +240,15 @@ def resample(image,
         matrix[:3, :3] = rotmat
         matrix[:3, -1] = offsets
 
-    matrix = transform.concat(image.voxToWorldMat, matrix)
+    # We can only adjust the v2w affine if
+    # the input space and resampling space
+    # are aligned (e.g. if we're just
+    # resampling to different dimensions).
+    # We can't assume this when a custom
+    # matrix is specified, so we just give
+    # up and return None.
+    if ownMatrix: matrix = affine.concat(image.voxToWorldMat, matrix)
+    else:         matrix = None
 
     return data, matrix
 
@@ -239,7 +271,7 @@ def applySmoothing(data, matrix, newShape):
     :returns:      A smoothed copy of ``data``.
     """
 
-    ratio = transform.decompose(matrix[:3, :3])[0]
+    ratio = affine.decompose(matrix[:3, :3])[0]
 
     if len(newShape) > 3:
         ratio = np.concatenate((
diff --git a/fsl/utils/image/roi.py b/fsl/utils/image/roi.py
index 84aee2434a30a6d16d19c3729466ca10845c4185..0b26d79d82fd93c3f2920da30b79a73ef4cb981f 100644
--- a/fsl/utils/image/roi.py
+++ b/fsl/utils/image/roi.py
@@ -11,8 +11,8 @@ a region-of-interest from, or expand the field-of-view of, an :class:`.Image`.
 
 import numpy as np
 
-import fsl.data.image      as fslimage
-import fsl.utils.transform as transform
+import fsl.data.image       as fslimage
+import fsl.transform.affine as affine
 
 
 def _normaliseBounds(shape, bounds):
@@ -97,8 +97,8 @@ def roi(image, bounds):
     # each spatial dimension
     oldaff = image.voxToWorldMat
     offset = [lo for lo, hi in bounds[:3]]
-    offset = transform.scaleOffsetXform([1, 1, 1], offset)
-    newaff = transform.concat(oldaff, offset)
+    offset = affine.scaleOffsetXform([1, 1, 1], offset)
+    newaff = affine.concat(oldaff, offset)
 
     return fslimage.Image(newdata,
                           xform=newaff,
diff --git a/fsl/utils/parse_data.py b/fsl/utils/parse_data.py
index 20e51d75218a87a850c3ce291ba75ef22a39c4ad..1c16e97dee1d8e3dc4b67618610461701d182a93 100644
--- a/fsl/utils/parse_data.py
+++ b/fsl/utils/parse_data.py
@@ -8,8 +8,9 @@
 
 Argparse is the built-in python library for resolving command line arguments.
 
-The functions in this module can be passed on to the ``type`` argument in the ``ArgumentParser.add_command`` method
-to interpret command line arguments as neuroimageing objects (.e.g, NIFTI image files)
+The functions in this module can be passed on to the ``type`` argument in the
+``ArgumentParser.add_command`` method to interpret command line arguments as
+neuroimaging objects (.e.g, NIFTI image files)
 
 
 .. autosummary::
@@ -27,18 +28,21 @@ from fsl.utils import path
 import argparse
 
 
-def Image(filename):
+def Image(filename, *args, **kwargs):
     """
     Reads in an image from a NIFTI or Analyze file.
 
     :arg filename: filename provided by the user
     :return: fsl.data.image.Image object
+
+    All other arguments are passed through to the :class:`.Image` upon
+    creation.
     """
     try:
         full_filename = image.addExt(filename)
     except path.PathError as e:
         raise argparse.ArgumentTypeError(*e.args)
-    return image.Image(full_filename)
+    return image.Image(full_filename, *args, **kwargs)
 
 
 def ImageOut(basename):
diff --git a/fsl/utils/transform.py b/fsl/utils/transform.py
index 8c9be2c440a139851371d9f1e86454915b11fb7a..b3d51afb21f0e5a5a861458d4fde9e2e62daebc2 100644
--- a/fsl/utils/transform.py
+++ b/fsl/utils/transform.py
@@ -1,647 +1,21 @@
 #!/usr/bin/env python
 #
-# transform.py - Functions for working with affine transformation matrices.
+# transforms.py - Deprecated
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
-"""This module provides functions related to 3D image transformations and
-spaces. The following functions are provided:
-
-.. autosummary::
-   :nosignatures:
-
-   transform
-   transformNormal
-   scaleOffsetXform
-   invert
-   concat
-   compose
-   decompose
-   rotMatToAffine
-   rotMatToAxisAngles
-   axisAnglesToRotMat
-   axisBounds
-   flirtMatrixToSform
-   sformToFlirtMatrix
-   rmsdev
-
-And a few more functions are provided for working with vectors:
-
-.. autosummary::
-   :nosignatures:
-
-   veclength
-   normalise
+"""The ``fsl.utils.transform`` module is deprecated - use the
+:mod:`fsl.transform` module instead.
 """
 
-import numpy           as np
-import numpy.linalg    as linalg
-import collections.abc as abc
-
-
-def invert(x):
-    """Inverts the given matrix using ``numpy.linalg.inv``. """
-    return linalg.inv(x)
-
-
-def concat(*xforms):
-    """Combines the given matrices (returns the dot product)."""
-
-    result = xforms[0]
-
-    for i in range(1, len(xforms)):
-        result = np.dot(result, xforms[i])
-
-    return result
-
-
-def veclength(vec):
-    """Returns the length of the given vector(s).
-
-    Multiple vectors may be passed in, with a shape of ``(n, 3)``.
-    """
-    vec = np.array(vec, copy=False).reshape(-1, 3)
-    return np.sqrt(np.einsum('ij,ij->i', vec, vec))
-
-
-def normalise(vec):
-    """Normalises the given vector(s) to unit length.
-
-    Multiple vectors may be passed in, with a shape of ``(n, 3)``.
-    """
-    vec = np.array(vec, copy=False).reshape(-1, 3)
-    n   = (vec.T / veclength(vec)).T
-
-    if n.size == 3:
-        n = n[0]
-
-    return n
-
-
-def scaleOffsetXform(scales, offsets):
-    """Creates and returns an affine transformation matrix which encodes
-    the specified scale(s) and offset(s).
-
-
-    :arg scales:  A tuple of up to three values specifying the scale factors
-                  for each dimension. If less than length 3, is padded with
-                  ``1.0``.
-
-    :arg offsets: A tuple of up to three values specifying the offsets for
-                  each dimension. If less than length 3, is padded with
-                  ``0.0``.
-
-    :returns:     A ``numpy.float32`` array of size :math:`4 \\times 4`.
-    """
-
-    oktypes = (abc.Sequence, np.ndarray)
-
-    if not isinstance(scales,  oktypes): scales  = [scales]
-    if not isinstance(offsets, oktypes): offsets = [offsets]
-    if not isinstance(scales,  list):    scales  = list(scales)
-    if not isinstance(offsets, list):    offsets = list(offsets)
-
-    lens = len(scales)
-    leno = len(offsets)
-
-    if lens < 3: scales  = scales  + [1.0] * (3 - lens)
-    if leno < 3: offsets = offsets + [0.0] * (3 - leno)
-
-    xform = np.eye(4, dtype=np.float64)
-
-    xform[0, 0] = scales[0]
-    xform[1, 1] = scales[1]
-    xform[2, 2] = scales[2]
-
-    xform[0, 3] = offsets[0]
-    xform[1, 3] = offsets[1]
-    xform[2, 3] = offsets[2]
-
-    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, or
-                    a rotation matrix of shape ``(3, 3)``.
-
-    :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)
-
-    rotations = np.array(rotations)
-
-    if rotations.shape == (3,):
-        rotations = axisAnglesToRotMat(*rotations)
-
-    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] = rotations
-
-    return concat(offset, postRotate, rotate, preRotate, scale)
-
-
-def decompose(xform, angles=True):
-    """Decomposes the given transformation matrix into separate offsets,
-    scales, and rotations, according to the algorithm described in:
-
-    Spencer W. Thomas, Decomposing a matrix into simple transformations, pp
-    320-323 in *Graphics Gems II*, James Arvo (editor), Academic Press, 1991,
-    ISBN: 0120644819.
-
-    It is assumed that the given transform has no perspective components. Any
-    shears in the affine are discarded.
-
-    :arg xform:  A ``(3, 3)`` or ``(4, 4)`` affine transformation matrix.
-
-    :arg angles: If ``True`` (the default), the rotations are returned
-                 as axis-angles, in radians. Otherwise, the rotation matrix
-                 is returned.
-
-    :returns: The following:
-
-               - A sequence of three scales
-               - A sequence of three translations (all ``0`` if ``xform``
-                 was a ``(3, 3)`` matrix)
-               - A sequence of three rotations, in radians. Or, if
-                 ``angles is False``, a rotation matrix.
-    """
-
-    # The inline comments in the code below are taken verbatim from
-    # the referenced article, [except for notes in square brackets].
-
-    # The next step is to extract the translations. This is trivial;
-    # we find t_x = M_{4,1}, t_y = M_{4,2}, and t_z = M_{4,3}. At this
-    # point we are left with a 3*3 matrix M' = M_{1..3,1..3}.
-    xform = xform.T
-
-    if xform.shape == (4, 4):
-        translations = xform[ 3, :3]
-        xform        = xform[:3, :3]
-    else:
-        translations = np.array([0, 0, 0])
-
-    M1 = xform[0]
-    M2 = xform[1]
-    M3 = xform[2]
-
-    # The process of finding the scaling factors and shear parameters
-    # is interleaved. First, find s_x = |M'_1|.
-    sx = np.sqrt(np.dot(M1, M1))
-
-    # Then, compute an initial value for the xy shear factor,
-    # s_xy = M'_1 * M'_2. (this is too large by the y scaling factor).
-    sxy = np.dot(M1, M2)
-
-    # The second row of the matrix is made orthogonal to the first by
-    # setting M'_2 = M'_2 - s_xy * M'_1.
-    M2 = M2 - sxy * M1
-
-    # Then the y scaling factor, s_y, is the length of the modified
-    # second row.
-    sy = np.sqrt(np.dot(M2, M2))
-
-    # The second row is normalized, and s_xy is divided by s_y to
-    # get its final value.
-    M2  = M2  / sy
-    sxy = sxy / sy
-
-    # The xz and yz shear factors are computed as in the preceding,
-    sxz = np.dot(M1, M3)
-    syz = np.dot(M2, M3)
-
-    # the third row is made orthogonal to the first two rows,
-    M3 = M3 - sxz * M1 - syz * M2
-
-    # the z scaling factor is computed,
-    sz = np.sqrt(np.dot(M3, M3))
-
-    # the third row is normalized, and the xz and yz shear factors are
-    # rescaled.
-    M3  = M3  / sz
-    sxz = sxz / sz
-    syz = sxz / sz
-
-    # The resulting matrix now is a pure rotation matrix, except that it
-    # might still include a scale factor of -1. If the determinant of the
-    # matrix is -1, negate the matrix and all three scaling factors. Call
-    # the resulting matrix R.
-    #
-    # [We do things different here - if the rotation matrix has negative
-    #  determinant, the flip is encoded in the x scaling factor.]
-    R = np.array([M1, M2, M3])
-    if linalg.det(R) < 0:
-        R[0] = -R[0]
-        sx   = -sx
-
-    # Finally, we need to decompose the rotation matrix into a sequence
-    # of rotations about the x, y, and z axes. [This is done in the
-    # rotMatToAxisAngles function]
-    if angles: rotations = rotMatToAxisAngles(R.T)
-    else:      rotations = R.T
-
-    return np.array([sx, sy, sz]), translations, rotations
-
-
-def rotMatToAffine(rotmat, origin=None):
-    """Convenience function which encodes the given ``(3, 3)`` rotation
-    matrix into a ``(4, 4)`` affine.
-    """
-    return compose([1, 1, 1], [0, 0, 0], rotmat, origin)
-
-
-def rotMatToAxisAngles(rotmat):
-    """Given a ``(3, 3)`` rotation matrix, decomposes the rotations into
-    an angle in radians about each axis.
-    """
-
-    yrot = np.sqrt(rotmat[0, 0] ** 2 + rotmat[1, 0] ** 2)
-
-    if np.isclose(yrot, 0):
-        xrot = np.arctan2(-rotmat[1, 2], rotmat[1, 1])
-        yrot = np.arctan2(-rotmat[2, 0], yrot)
-        zrot = 0
-    else:
-        xrot = np.arctan2( rotmat[2, 1], rotmat[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,
-               origin='centre',
-               boundary='high',
-               offset=1e-4):
-    """Returns the ``(lo, hi)`` bounds of the specified axis/axes in the
-    world coordinate system defined by ``xform``.
-
-    If the ``origin`` parameter is set to  ``centre`` (the default),
-    this function assumes that voxel indices correspond to the voxel
-    centre. For example, the voxel at ``(4, 5, 6)`` covers the space:
-
-      ``[3.5 - 4.5, 4.5 - 5.5, 5.5 - 6.5]``
-
-    So the bounds of the specified shape extends from the corner at
-
-      ``(-0.5, -0.5, -0.5)``
-
-    to the corner at
-
-      ``(shape[0] - 0.5, shape[1] - 0.5, shape[1] - 0.5)``
-
-    If the ``origin`` parameter is set to ``corner``, this function
-    assumes that voxel indices correspond to the voxel corner. In this
-    case, a voxel  at ``(4, 5, 6)`` covers the space:
-
-      ``[4 - 5, 5 - 6, 6 - 7]``
-
-    So the bounds of the specified shape extends from the corner at
-
-      ``(0, 0, 0)``
-
-    to the corner at
-
-      ``(shape[0], shape[1], shape[1])``.
-
-
-    If the ``boundary`` parameter is set to ``high``, the high voxel bounds
-    are reduced by a small amount (specified by the ``offset`` parameter)
-    before they are transformed to the world coordinate system.  If
-    ``boundary`` is set to ``low``, the low bounds are increased by a small
-    amount.  The ``boundary`` parameter can also be set to ``'both'``, or
-    ``None``. This option is provided so that you can ensure that the
-    resulting bounds will always be contained within the image space.
-
-    :arg shape:    The ``(x, y, z)`` shape of the data.
-
-    :arg xform:    Transformation matrix which transforms voxel coordinates
-                   to the world coordinate system.
-
-    :arg axes:     The world coordinate system axis bounds to calculate.
-
-    :arg origin:   Either ``'centre'`` (the default) or ``'corner'``.
-
-    :arg boundary: Either ``'high'`` (the default), ``'low'``, ''`both'``,
-                   or ``None``.
-
-    :arg offset:   Amount by which the boundary voxel coordinates should be
-                   offset. Defaults to ``1e-4``.
-
-    :returns:      A tuple containing the ``(low, high)`` bounds for each
-                   requested world coordinate system axis.
-    """
-
-    origin = origin.lower()
-
-    # lousy US spelling
-    if origin == 'center':
-        origin = 'centre'
-
-    if origin not in ('centre', 'corner'):
-        raise ValueError('Invalid origin value: {}'.format(origin))
-    if boundary not in ('low', 'high', 'both', None):
-        raise ValueError('Invalid boundary value: {}'.format(boundary))
-
-    scalar = False
-
-    if axes is None:
-        axes = [0, 1, 2]
-
-    elif not isinstance(axes, abc.Iterable):
-        scalar = True
-        axes   = [axes]
-
-    x, y, z = shape[:3]
-
-    points = np.zeros((8, 3), dtype=np.float32)
-
-    if origin == 'centre':
-        x0 = -0.5
-        y0 = -0.5
-        z0 = -0.5
-        x -=  0.5
-        y -=  0.5
-        z -=  0.5
-    else:
-        x0 = 0
-        y0 = 0
-        z0 = 0
-
-    if boundary in ('low', 'both'):
-        x0 += offset
-        y0 += offset
-        z0 += offset
-
-    if boundary in ('high', 'both'):
-        x  -= offset
-        y  -= offset
-        z  -= offset
-
-    points[0, :] = [x0, y0, z0]
-    points[1, :] = [x0, y0,  z]
-    points[2, :] = [x0,  y, z0]
-    points[3, :] = [x0,  y,  z]
-    points[4, :] = [x,  y0, z0]
-    points[5, :] = [x,  y0,  z]
-    points[6, :] = [x,   y, z0]
-    points[7, :] = [x,   y,  z]
-
-    tx = transform(points, xform)
-
-    lo = tx[:, axes].min(axis=0)
-    hi = tx[:, axes].max(axis=0)
-
-    if scalar: return (lo[0], hi[0])
-    else:      return (lo,    hi)
-
-
-def transform(p, xform, axes=None, vector=False):
-    """Transforms the given set of points ``p`` according to the given affine
-    transformation ``xform``.
-
-
-    :arg p:      A sequence or array of points of shape :math:`N \\times  3`.
-
-    :arg xform:  A ``(4, 4)`` affine transformation matrix with which to
-                 transform the points in ``p``.
-
-    :arg axes:   If you are only interested in one or two axes, and the source
-                 axes are orthogonal to the target axes (see the note below),
-                 you may pass in a 1D, ``N*1``, or ``N*2`` array as ``p``, and
-                 use this argument to specify which axis/axes that the data in
-                 ``p`` correspond to.
-
-    :arg vector: Defaults to ``False``. If ``True``, the points are treated
-                 as vectors - the translation component of the transformation
-                 is not applied. If you set this flag, you pass in a ``(3, 3)``
-                 transformation matrix.
-
-    :returns:    The points in ``p``, transformed by ``xform``, as a ``numpy``
-                 array with the same data type as the input.
-
-
-    .. note:: The ``axes`` argument should only be used if the source
-              coordinate system (the points in ``p``) axes are orthogonal
-              to the target coordinate system (defined by the ``xform``).
-
-              In other words, you can only use the ``axes`` argument if
-              the ``xform`` matrix consists solely of translations and
-              scalings.
-    """
-
-    p  = _fillPoints(p, axes)
-    t  = np.dot(xform[:3, :3], p.T).T
-
-    if not vector:
-        t = t + xform[:3, 3]
-
-    if axes is not None:
-        t = t[:, axes]
-
-    if t.size == 1: return t[0]
-    else:           return t
-
-
-def transformNormal(p, xform, axes=None):
-    """Transforms the given point(s), under the assumption that they
-    are normal vectors. In this case, the points are transformed by
-    ``invert(xform[:3, :3]).T``.
-    """
-    return transform(p, invert(xform[:3, :3]).T, axes, vector=True)
-
-
-def _fillPoints(p, axes):
-    """Used by the :func:`transform` function. Turns the given array p into
-    a ``N*3`` array of ``x,y,z`` coordinates. The array p may be a 1D array,
-    or an ``N*2`` or ``N*3`` array.
-    """
-
-    if not isinstance(p, abc.Iterable): p = [p]
-
-    p = np.array(p)
-
-    if axes is None: return p
-
-    if not isinstance(axes, abc.Iterable): axes = [axes]
-
-    if p.ndim == 1:
-        p = p.reshape((len(p), 1))
-
-    if p.ndim != 2:
-        raise ValueError('Points array must be either one or two '
-                         'dimensions')
-
-    if len(axes) != p.shape[1]:
-        raise ValueError('Points array shape does not match specified '
-                         'number of axes')
-
-    newp = np.zeros((len(p), 3), dtype=p.dtype)
-
-    for i, ax in enumerate(axes):
-        newp[:, ax] = p[:, i]
-
-    return newp
-
-
-def flirtMatrixToSform(flirtMat, srcImage, refImage):
-    """Converts the given ``FLIRT`` transformation matrix into a
-    transformation from the source image voxel coordinate system to
-    the reference image world coordinate system.
-
-    FLIRT transformation matrices transform from the source image scaled voxel
-    coordinate system into the reference image scaled voxel coordinate system
-    (voxels scaled by pixdims, with a left-right flip if the image sform has a
-    positive determinant).
-
-    So to construct a transformation from source image voxel coordinates
-    into reference image world coordinates, we need to combine the following:
-
-      1. Source voxels -> Source scaled voxels
-      2. Source scaled voxels -> Reference scaled voxels (the FLIRT matrix)
-      3. Reference scaled voxels -> Reference voxels
-      4. Reference voxels -> Reference world (the reference image sform)
-
-    :arg flirtMat: A ``(4, 4)`` transformation matrix
-    :arg srcImage: Source :class:`.Image`
-    :arg refImage: Reference :class:`.Image`
-    """
-
-    srcScaledVoxelMat    = srcImage.voxToScaledVoxMat
-    refInvScaledVoxelMat = refImage.scaledVoxToVoxMat
-    refVoxToWorldMat     = refImage.voxToWorldMat
-
-    return concat(refVoxToWorldMat,
-                  refInvScaledVoxelMat,
-                  flirtMat,
-                  srcScaledVoxelMat)
-
-
-def sformToFlirtMatrix(srcImage, refImage, srcXform=None):
-    """Under the assumption that the given ``srcImage`` and ``refImage`` share a
-    common world coordinate system (defined by their
-    :attr:`.Nifti.voxToWorldMat` attributes), this function will calculate and
-    return a transformation matrix from the ``srcImage`` scaled voxel
-    coordinate system to the ``refImage`` scaled voxel coordinate system, that
-    can be saved to disk and used with FLIRT, to resample the source image to
-    the reference image.
-
-    :arg srcImage: Source :class:`.Image`
-    :arg refImage: Reference :class:`.Image`
-    :arg srcXform: Optionally used in place of the ``srcImage``
-                   :attr:`.Nifti.voxToWorldMat`
-    """
-
-    srcScaledVoxToVoxMat = srcImage.scaledVoxToVoxMat
-    srcVoxToWorldMat     = srcImage.voxToWorldMat
-    refWorldToVoxMat     = refImage.worldToVoxMat
-    refVoxToScaledVoxMat = refImage.voxToScaledVoxMat
-
-    if srcXform is not None:
-        srcVoxToWorldMat = srcXform
-
-    return concat(refVoxToScaledVoxMat,
-                  refWorldToVoxMat,
-                  srcVoxToWorldMat,
-                  srcScaledVoxToVoxMat)
-
-
-def rmsdev(T1, T2, R=None, xc=None):
-    """Calculates the RMS deviation of the given affine transforms ``T1`` and
-    ``T2``. This can be used as a measure of the 'distance' between two
-    affines.
-
-    The ``T1`` and ``T2`` arguments may be either full ``(4, 4)`` affines, or
-    ``(3, 3)`` rotation matrices.
-
-    See FMRIB technical report TR99MJ1, available at:
-
-    https://www.fmrib.ox.ac.uk/datasets/techrep/
-
-    :arg T1:  First affine
-    :arg T2:  Second affine
-    :arg R:   Sphere radius
-    :arg xc:  Sphere centre
-    :returns: The RMS deviation between ``T1`` and ``T2``.
-    """
-
-    if R is None:
-        R = 1
-
-    if xc is None:
-        xc = np.zeros(3)
-
-    # rotations only
-    if T1.shape == (3, 3):
-        M = np.dot(T2, invert(T1)) - np.eye(3)
-        A = M[:3, :3]
-        t = np.zeros(3)
-
-    # full affine
-    else:
-        M = np.dot(T2, invert(T1)) - np.eye(4)
-        A = M[:3, :3]
-        t = M[:3,  3]
 
-    Axc = np.dot(A, xc)
+import fsl.utils.deprecated as     deprecated
+from   fsl.transform.affine import *                     # noqa
+from   fsl.transform.flirt  import (flirtMatrixToSform,  # noqa
+                                    sformToFlirtMatrix)
 
-    erms = np.dot((t + Axc).T, t + Axc)
-    erms = 0.2 * R ** 2 * np.dot(A.T, A).trace() + erms
-    erms = np.sqrt(erms)
 
-    return erms
+deprecated.warn('fsl.utils.transform',
+                vin='2.4.0',
+                rin='3.0.0',
+                msg='Use the fsl.transform module instead')
diff --git a/fsl/wrappers/wrapperutils.py b/fsl/wrappers/wrapperutils.py
index 373bcf08b1a0c53af416a074cc4c439cf534f792..c89afb2aadb002e1e6838235578ce124ce4caf41 100644
--- a/fsl/wrappers/wrapperutils.py
+++ b/fsl/wrappers/wrapperutils.py
@@ -446,9 +446,9 @@ class _FileOrThing(object):
 
     Functions decorated with a ``_FileOrThing`` decorator will always return a
     ``dict``-like object, where the function's actual return value is
-    accessible via an attribute called `output`. All output arguments with a
+    accessible via an attribute called ``output``. All output arguments with a
     value of ``LOAD`` will be present as dictionary entries, with the keyword
-    argument names used as keys. Any ``LOAD``ed output arguments which were not
+    argument names used as keys. Any ``LOAD`` output arguments which were not
     generated by the function will not be present in the dictionary.
 
 
diff --git a/requirements.txt b/requirements.txt
index ef041ad9cd02c2d53af3875cb4cecf7d2d077d5f..f0abcb98be82381e843f9d303367b66ec73f898b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
-six==1.*
+h5py>=2.9
+nibabel>=2.3.1
 numpy==1.*
 scipy>=0.18
-nibabel>=2.3.1
+six==1.*
diff --git a/setup.py b/setup.py
index 8693d37c5eef988bc747e9b81c42afc5e852b81b..6b5ca7dd9d824851b44e1b4db37a91bb48abbf54 100644
--- a/setup.py
+++ b/setup.py
@@ -127,6 +127,8 @@ setup(
             'atlasquery     = fsl.scripts.atlasq:atlasquery_emulation',
             'fsl_ents       = fsl.scripts.fsl_ents:main',
             'resample_image = fsl.scripts.resample_image:main',
+            'fsl_convert_x5 = fsl.scripts.fsl_convert_x5:main',
+            'fsl_apply_x5   = fsl.scripts.fsl_apply_x5:main'
         ]
     }
 )
diff --git a/tests/test_atlases.py b/tests/test_atlases.py
index b2dfec113a00349dea0601f781f565561fa18b2c..99e656e10f3a0e138645e92d2c2aa53c66d97a73 100644
--- a/tests/test_atlases.py
+++ b/tests/test_atlases.py
@@ -17,10 +17,10 @@ import mock
 import pytest
 
 import tests
-import fsl.utils.transform      as transform
 import fsl.utils.image.resample as resample
 import fsl.data.atlases         as atlases
 import fsl.data.image           as fslimage
+import fsl.transform.affine     as affine
 
 
 datadir = op.join(op.dirname(__file__), 'testdata')
@@ -326,7 +326,7 @@ def test_prepareMask():
             np.random.random(list(ashape) + [2]))
         wrongspace    = fslimage.Image(
             np.random.random((20, 20, 20)),
-            xform=transform.concat(atlas.voxToWorldMat, np.diag([2, 2, 2, 1])))
+            xform=affine.concat(atlas.voxToWorldMat, np.diag([2, 2, 2, 1])))
 
         with pytest.raises(atlases.MaskError):
             atlas.prepareMask(wrongdims)
diff --git a/tests/test_atlases_query.py b/tests/test_atlases_query.py
index 693366a2e65000685196f4c7067304cd8a59fad9..051f9cd6b7bf5a22234c31ebd3273f6d924033db 100644
--- a/tests/test_atlases_query.py
+++ b/tests/test_atlases_query.py
@@ -11,11 +11,11 @@ import numpy           as np
 import                    pytest
 
 
-import fsl.data.atlases    as fslatlases
-import fsl.data.image      as fslimage
-import fsl.utils.transform as transform
+import fsl.data.atlases         as fslatlases
+import fsl.data.image           as fslimage
+import fsl.transform.affine     as affine
 import fsl.utils.image.resample as resample
-import fsl.utils.cache     as cache
+import fsl.utils.cache          as cache
 
 from . import (testdir, make_random_mask)
 
@@ -155,7 +155,7 @@ def _gen_coord_voxel_query(atlas, qtype, qin, **kwargs):
             dlo = (0, 0, 0)
             dhi = atlas.shape
         else:
-            dlo, dhi = transform.axisBounds(atlas.shape, atlas.voxToWorldMat)
+            dlo, dhi = affine.axisBounds(atlas.shape, atlas.voxToWorldMat)
 
         dlen = [hi - lo for lo, hi in zip(dlo, dhi)]
 
@@ -190,7 +190,7 @@ def _gen_coord_voxel_query(atlas, qtype, qin, **kwargs):
         coords = np.array(coords, dtype=dtype)
 
         if not voxel:
-            coords = transform.transform(coords, atlas.voxToWorldMat)
+            coords = affine.transform(coords, atlas.voxToWorldMat)
 
     return tuple([dtype(c) for c in coords])
 
@@ -200,7 +200,7 @@ def _eval_coord_voxel_query(atlas, query, qtype, qin):
     voxel = qtype == 'voxel'
 
     if voxel: vx, vy, vz = query
-    else:     vx, vy, vz = transform.transform(query, atlas.worldToVoxMat)
+    else:     vx, vy, vz = affine.transform(query, atlas.worldToVoxMat)
 
     vx, vy, vz = [int(round(v)) for v in [vx, vy, vz]]
 
diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py
new file mode 100644
index 0000000000000000000000000000000000000000..307818960cbdac0f67b7c1f11c8de0c44bc9ebf1
--- /dev/null
+++ b/tests/test_deprecated.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python
+
+
+import warnings
+import pytest
+
+import fsl.utils.deprecated as deprecated
+
+
+# the line number of the warning is hard coded in
+# the unit tests below. Don't change the line number!
+def emit_warning():
+    deprecated.warn('blag', vin='1.0.0', rin='2.0.0', msg='yo')
+
+WARNING_LINE_NUMBER = 13
+
+
+@deprecated.deprecated(vin='1.0.0', rin='2.0.0', msg='yo')
+def depfunc():
+    pass
+
+def call_dep_func():
+    depfunc()
+
+DEPRECATED_LINE_NUMBER = 23
+
+
+def _check_warning(w, name, lineno):
+    assert issubclass(w.category, DeprecationWarning)
+    assert '{} is deprecated'.format(name) in str(w.message)
+    assert 'test_deprecated.py' in str(w.filename)
+    assert w.lineno == lineno
+
+def test_warn():
+    deprecated.resetWarningCache()
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter('always')
+        emit_warning()
+        assert len(w) == 1
+        _check_warning(w[0], 'blag', WARNING_LINE_NUMBER)
+
+    # warning should only be emitted once
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter('always')
+        emit_warning()
+        assert len(w) == 0
+
+
+def test_deprecated():
+    deprecated.resetWarningCache()
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter('always')
+        call_dep_func()
+        assert len(w) == 1
+        _check_warning(w[0], 'depfunc', DEPRECATED_LINE_NUMBER)
+
+    # warning should only be emitted once
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter('always')
+        call_dep_func()
+        assert len(w) == 0
diff --git a/tests/test_image.py b/tests/test_image.py
index 5b1ba4c7ff15c2e7573fd2f577108fc7399fc23e..58d9fc625d5e107385c7280088b1c0c3d595af91 100644
--- a/tests/test_image.py
+++ b/tests/test_image.py
@@ -19,11 +19,10 @@ import nibabel      as nib
 
 from nibabel.spatialimages import ImageFileError
 
-import fsl.data.constants    as constants
-import fsl.data.image        as fslimage
-import fsl.data.imagewrapper as imagewrapper
-import fsl.utils.path        as fslpath
-import fsl.utils.transform   as transform
+import fsl.data.constants   as constants
+import fsl.data.image       as fslimage
+import fsl.utils.path       as fslpath
+import fsl.transform.affine as affine
 
 from fsl.utils.tempdir import tempdir
 
@@ -743,6 +742,48 @@ def _test_Image_changeXform(imgtype, sformcode=None, qformcode=None):
         image = None
 
 
+def  test_Image_changeIntent_analyze(): _test_Image_changeIntent(0)
+def  test_Image_changeIntent_nifti1():  _test_Image_changeIntent(1)
+def  test_Image_changeIntent_nifti2():  _test_Image_changeIntent(2)
+def _test_Image_changeIntent(imgtype):
+    """Test changing the Nifti.intent attribute. """
+
+    with tempdir() as testdir:
+        imagefile = op.join(testdir, 'image')
+
+        image = make_image(imagefile, imgtype)
+        if imgtype > 0:
+            image.header.set_intent(constants.NIFTI_INTENT_NONE)
+        nib.save(image, imagefile)
+
+        notified = {}
+        def onHdr( *a): notified['header'] = True
+        def onSave(*a): notified['save']   = True
+
+        img = fslimage.Image(imagefile)
+
+        img.register('name1', onHdr,  'header')
+        img.register('name2', onSave, 'saveState')
+
+        assert img.intent == constants.NIFTI_INTENT_NONE
+        img.intent = constants.NIFTI_INTENT_BETA
+
+        if imgtype == 0: exp = constants.NIFTI_INTENT_NONE
+        else:            exp = constants.NIFTI_INTENT_BETA
+
+        assert img.intent == exp
+
+        if imgtype > 0:
+            assert img         .header.get_intent('code')[0] == exp
+            assert img.nibImage.header.get_intent('code')[0] == exp
+
+            assert notified.get('header', False)
+            assert notified.get('save',   False)
+
+
+
+
+
 def  test_Image_changeData_analyze(seed): _test_Image_changeData(0)
 def  test_Image_changeData_nifti1(seed):  _test_Image_changeData(1)
 def  test_Image_changeData_nifti2(seed):  _test_Image_changeData(2)
@@ -1072,12 +1113,12 @@ def _test_Image_init_xform(imgtype):
 
     with tempdir() as td:
 
-        sform = transform.compose(np.random.random(3),
-                                  np.random.random(3),
-                                  np.random.random(3))
-        qform = transform.compose(np.random.random(3),
-                                  np.random.random(3),
-                                  np.random.random(3))
+        sform = affine.compose(np.random.random(3),
+                               np.random.random(3),
+                               np.random.random(3))
+        qform = affine.compose(np.random.random(3),
+                               np.random.random(3),
+                               np.random.random(3))
 
         sform_code = 3
         qform_code = 4
@@ -1128,9 +1169,9 @@ def _test_Image_init_xform(imgtype):
         # to the xform. and its
         # s/q form codes the same
         # as what is in the header
-        rxform = transform.compose(np.random.random(3),
-                                   np.random.random(3),
-                                   np.random.random(3))
+        rxform = affine.compose(np.random.random(3),
+                                np.random.random(3),
+                                np.random.random(3))
         fimg = fslimage.Image(img.get_data(),
                               header=img.header,
                               xform=rxform)
@@ -1173,3 +1214,158 @@ def test_rgb_image():
 
         assert img.nvals     == 3
         assert img.dataRange == (0, 255)
+
+
+def test_determineShape():
+    class MockHeader(object):
+        def __init__(self, shape, zooms):
+            self.shape = shape
+            self.zooms = zooms
+        def __getitem__(self, key):
+            return [len(self.zooms)] + self.zooms
+        def get_data_shape(self):
+            return self.shape
+        def get_zooms(self):
+            return self.zooms
+
+    # inshape, inzooms, outshape, outzooms)
+    tests = [
+        ([10],         [2, 2, 2], [10,  1,  1], [2, 2, 2]),
+        ([10],         [2],       [10,  1,  1], [2, 1, 1]),
+        ([10],         [2, 2, 2], [10,  1,  1], [2, 2, 2]),
+        ([10, 10],     [2, 2],    [10, 10,  1], [2, 2, 1]),
+        ([10, 10],     [2, 2, 2], [10, 10,  1], [2, 2, 2]),
+        ([10, 10, 10], [2, 2, 2], [10, 10, 10], [2, 2, 2]),
+
+        ([10, 10, 10, 10], [2, 2, 2, 2],
+         [10, 10, 10, 10], [2, 2, 2, 2]),
+        ([10, 10, 10, 10, 10], [2, 2, 2, 2, 2],
+         [10, 10, 10, 10, 10], [2, 2, 2, 2, 2]),
+    ]
+
+    for inshape, inzooms, outshape, outzooms in tests:
+
+        hdr = MockHeader(inshape, inzooms)
+        origshape, gotshape, gotzooms = fslimage.Nifti.determineShape(hdr)
+
+        assert origshape == inshape
+        assert gotshape  == outshape
+        assert gotzooms  == outzooms
+
+
+def test_determineAffine():
+
+    # sformcode, qformcode, intent, expaff
+    tests = [
+        (constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.NIFTI_INTENT_NONE,
+         'sform'),
+        (constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.NIFTI_XFORM_UNKNOWN,
+         constants.NIFTI_INTENT_NONE,
+         'sform'),
+        (constants.NIFTI_XFORM_UNKNOWN,
+         constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.NIFTI_INTENT_NONE,
+         'qform'),
+        (constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.FSL_FNIRT_DISPLACEMENT_FIELD,
+         'sform'),
+        (constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.NIFTI_XFORM_ALIGNED_ANAT,
+         constants.FSL_CUBIC_SPLINE_COEFFICIENTS,
+         'scaling'),
+        (constants.NIFTI_XFORM_UNKNOWN,
+         constants.NIFTI_XFORM_UNKNOWN,
+         constants.NIFTI_INTENT_NONE,
+         'scaling'),
+    ]
+
+    for sformcode, qformcode, intent, exp in tests:
+
+        sform   = affine.compose(np.random.random(3),
+                                 np.random.random(3),
+                                 np.random.random(3))
+        qform   = affine.compose(np.random.random(3),
+                                 np.random.random(3),
+                                 np.random.random(3))
+        pixdims = np.random.randint(1, 10, 3)
+
+        hdr = nib.Nifti1Header()
+        hdr.set_data_shape((10, 10, 10))
+        hdr.set_sform(sform, sformcode)
+        hdr.set_qform(qform, qformcode)
+        hdr.set_intent(intent)
+        hdr.set_zooms(pixdims)
+
+        # the randomly generated qform might
+        # not be fully representable, so let
+        # nibabel fix it for us
+        sform = hdr.get_sform()
+        qform = hdr.get_qform()
+
+        got = fslimage.Nifti.determineAffine(hdr)
+
+        if   exp == 'sform':   exp = sform
+        elif exp == 'qform':   exp = qform
+        elif exp == 'scaling': exp = affine.scaleOffsetXform(pixdims, 0)
+
+        assert np.all(np.isclose(got, exp))
+
+
+def test_generateAffines():
+
+    v2w = affine.compose(np.random.random(3),
+                         np.random.random(3),
+                         np.random.random(3))
+    shape = (10, 10, 10)
+    pixdim = (1, 1, 1)
+
+    got, isneuro = fslimage.Nifti.generateAffines(v2w, shape, pixdim)
+
+    w2v = npla.inv(v2w)
+
+    assert isneuro == (npla.det(v2w) > 0)
+
+    if not isneuro:
+        v2f = np.eye(4)
+        f2v = np.eye(4)
+        f2w = v2w
+        w2f = w2v
+    else:
+        v2f = affine.scaleOffsetXform([-1, 1, 1], [9, 0, 0])
+        f2v = npla.inv(v2f)
+        f2w = affine.concat(v2w, f2v)
+        w2f = affine.concat(v2f, w2v)
+
+    assert np.all(np.isclose(v2w, got['voxel', 'world']))
+    assert np.all(np.isclose(w2v, got['world', 'voxel']))
+    assert np.all(np.isclose(v2f, got['voxel', 'fsl']))
+    assert np.all(np.isclose(f2v, got['fsl',   'voxel']))
+    assert np.all(np.isclose(f2w, got['fsl'  , 'world']))
+    assert np.all(np.isclose(w2f, got['world', 'fsl']))
+
+
+def test_identifyAffine():
+
+    identify = fslimage.Nifti.identifyAffine
+
+    assert identify(None, None, 'ho', 'hum') == ('ho', 'hum')
+
+    xform = affine.compose(0.1        + 5     * np.random.random(3),
+                           -10        + 20    * np.random.random(3),
+                           -np.pi / 2 + np.pi * np.random.random(3))
+
+    img = fslimage.Image(make_random_image(None, xform=xform))
+
+    for from_, to in it.permutations(('voxel', 'fsl', 'world'), 2):
+        assert identify(img, img.getAffine(from_, to)) == (from_, to)
+
+    assert identify(img, img.getAffine('voxel', 'world'), from_='voxel') == ('voxel', 'world')
+    assert identify(img, img.getAffine('voxel', 'world'), to='world')    == ('voxel', 'world')
+
+    rubbish = np.random.random((4, 4))
+    with pytest.raises(ValueError):
+        identify(img, rubbish)
diff --git a/tests/test_image_resample.py b/tests/test_image_resample.py
index d9f474bf45330e5691535a545faa7ddcac9159a4..8fb488760ed74a14a0714c92d7815ac2a87f43f6 100644
--- a/tests/test_image_resample.py
+++ b/tests/test_image_resample.py
@@ -263,3 +263,28 @@ def test_resampleToReference3():
     exp     = np.array([refdata[..., 0]] * 15).transpose((1, 2, 3, 0))
     resampled, xform = resample.resampleToReference(img, ref, order=0, mode='nearest')
     assert np.all(resampled == exp)
+
+
+def test_resampleToReference4():
+
+    # the image and ref are out of
+    # alignment, but this affine
+    # will bring them into alignment
+    img2ref = transform.scaleOffsetXform([2, 2, 2], [10, 10, 10])
+
+    imgdata = np.random.randint(0, 65536, (5, 5, 5))
+    refdata = np.zeros((5, 5, 5))
+    img     = fslimage.Image(imgdata)
+    ref     = fslimage.Image(refdata, xform=img2ref)
+
+    # Without the affine, the image
+    # will be out of the FOV of the
+    # reference
+    resampled, xform = resample.resampleToReference(img, ref)
+    assert np.all(resampled == 0)
+
+    # But applying the affine will
+    # cause them to overlap
+    # perfectly in world coordinates
+    resampled, xform = resample.resampleToReference(img, ref, matrix=img2ref)
+    assert np.all(resampled == imgdata)
diff --git a/tests/test_mesh.py b/tests/test_mesh.py
index 243944b2e3feb1b476792a3fa319a595a8143ae7..c5841df9d38338c601cc9e2a6ee4c5552d18f7b4 100644
--- a/tests/test_mesh.py
+++ b/tests/test_mesh.py
@@ -6,13 +6,13 @@
 #
 
 
-import os.path as op
-import numpy   as np
-import            mock
-import            pytest
+import os.path  as     op
+import numpy    as     np
+from   unittest import mock
+import                 pytest
 
-import fsl.utils.transform as transform
-import fsl.data.mesh       as fslmesh
+import fsl.transform.affine as affine
+import fsl.data.mesh        as fslmesh
 
 from . import tempdir
 
@@ -58,7 +58,7 @@ CUBE_CCW_VERTEX_NORMALS = np.zeros((8, 3))
 for i in range(8):
     faces = np.where(CUBE_TRIANGLES_CCW == i)[0]
     CUBE_CCW_VERTEX_NORMALS[i] = CUBE_CCW_FACE_NORMALS[faces].sum(axis=0)
-CUBE_CCW_VERTEX_NORMALS = transform.normalise(CUBE_CCW_VERTEX_NORMALS)
+CUBE_CCW_VERTEX_NORMALS = affine.normalise(CUBE_CCW_VERTEX_NORMALS)
 
 
 def test_mesh_create():
@@ -257,6 +257,13 @@ def test_needsFixing():
 
 def test_trimesh_no_trimesh():
 
+    # Make sure trimesh and rtree
+    # are imported before messing
+    # with sys.modules, otherwise
+    # weird things can happen.
+    import trimesh
+    import rtree
+
     mods = ['trimesh', 'rtree']
 
     for mod in mods:
diff --git a/tests/test_mghimage.py b/tests/test_mghimage.py
index 2496ac4eb80cff687f6d03d522281dcd777eb45f..f34f635c17759eb9803d34c0405d4a2d93e4a9eb 100644
--- a/tests/test_mghimage.py
+++ b/tests/test_mghimage.py
@@ -12,10 +12,10 @@ import            shutil
 import numpy   as np
 import nibabel as nib
 
-import fsl.utils.tempdir   as tempdir
-import fsl.utils.transform as transform
-import fsl.data.mghimage   as fslmgh
-import fsl.data.image      as fslimage
+import fsl.utils.tempdir    as tempdir
+import fsl.transform.affine as affine
+import fsl.data.mghimage    as fslmgh
+import fsl.data.image       as fslimage
 
 
 datadir = op.join(op.abspath(op.dirname(__file__)), 'testdata')
@@ -29,14 +29,14 @@ def test_MGHImage():
     img      = fslmgh.MGHImage(testfile)
     nbimg    = nib.load(testfile)
     v2s      = nbimg.header.get_vox2ras_tkr()
-    w2s      = transform.concat(v2s, transform.invert(nbimg.affine))
+    w2s      = affine.concat(v2s, affine.invert(nbimg.affine))
 
     assert np.all(np.isclose(img[:],             nbimg.get_data()))
     assert np.all(np.isclose(img.voxToWorldMat,  nbimg.affine))
     assert np.all(np.isclose(img.voxToSurfMat,   v2s))
-    assert np.all(np.isclose(img.surfToVoxMat,   transform.invert(v2s)))
+    assert np.all(np.isclose(img.surfToVoxMat,   affine.invert(v2s)))
     assert np.all(np.isclose(img.worldToSurfMat, w2s))
-    assert np.all(np.isclose(img.surfToWorldMat, transform.invert(w2s)))
+    assert np.all(np.isclose(img.surfToWorldMat, affine.invert(w2s)))
 
     assert img.name         == op.basename(testfile)
     assert img.dataSource   == testfile
diff --git a/tests/test_scripts/test_atlasq_query.py b/tests/test_scripts/test_atlasq_query.py
index a36e1eb5fa4f086b0e28bcd4440f08958ebb3d05..def92c956a08098afd779102a591044aa63740cf 100644
--- a/tests/test_scripts/test_atlasq_query.py
+++ b/tests/test_scripts/test_atlasq_query.py
@@ -15,11 +15,11 @@ import scipy.ndimage as ndi
 
 import                  pytest
 
-import fsl.utils.transform as transform
+import fsl.transform.affine     as affine
+import fsl.data.atlases         as fslatlases
 import fsl.utils.image.resample as resample
-import fsl.data.atlases    as fslatlases
-import fsl.data.image      as fslimage
-import fsl.scripts.atlasq  as fslatlasq
+import fsl.data.image           as fslimage
+import fsl.scripts.atlasq       as fslatlasq
 
 from .. import (tempdir,
                 make_random_mask,
@@ -152,7 +152,7 @@ def _gen_coord_voxel_query(atlas, use_label, q_type, q_in, res):
             dlo = (0, 0, 0)
             dhi = a_img.shape
         else:
-            dlo, dhi = transform.axisBounds(a_img.shape, a_img.voxToWorldMat)
+            dlo, dhi = affine.axisBounds(a_img.shape, a_img.voxToWorldMat)
 
         dlen = [hi - lo for lo, hi in zip(dlo, dhi)]
 
@@ -187,7 +187,7 @@ def _gen_coord_voxel_query(atlas, use_label, q_type, q_in, res):
         coords = np.array(coords, dtype=dtype)
 
         if not voxel:
-            coords = transform.transform(coords, a_img.voxToWorldMat)
+            coords = affine.transform(coords, a_img.voxToWorldMat)
 
     return tuple([dtype(c) for c in coords])
 
@@ -453,8 +453,8 @@ def test_bad_mask(seed):
                             dtype=np.float32))
             wrongspace = fslimage.Image(
                 np.random.random((20, 20, 20)),
-                xform=transform.concat(atlas.voxToWorldMat,
-                                       np.diag([2, 2, 2, 1])))
+                xform=affine.concat(atlas.voxToWorldMat,
+                                    np.diag([2, 2, 2, 1])))
 
             print(wrongdims.shape)
             print(wrongspace.shape)
diff --git a/tests/test_scripts/test_fsl_apply_x5.py b/tests/test_scripts/test_fsl_apply_x5.py
new file mode 100644
index 0000000000000000000000000000000000000000..dbe2bd3a7ec6cfae76fbb07e8a7e663fc5b135ab
--- /dev/null
+++ b/tests/test_scripts/test_fsl_apply_x5.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python
+
+
+import numpy as np
+
+import pytest
+
+import fsl.scripts.fsl_apply_x5 as fsl_apply_x5
+import fsl.data.image           as fslimage
+import fsl.utils.image.resample as resample
+import fsl.utils.image.roi      as roi
+import fsl.transform.x5         as x5
+import fsl.transform.affine     as affine
+import fsl.utils.tempdir        as tempdir
+
+from ..test_transform.test_nonlinear import _affine_field
+
+
+def _random_affine():
+    return affine.compose(
+        np.random.randint(2, 5, 3),
+        np.random.randint(1, 10, 3),
+        np.random.random(3))
+
+
+def _random_image(aff, shape=None):
+
+    if shape is None:
+        shape = np.random.randint(10, 50, 3)
+
+    data = (np.random.random(shape) - 0.5) * 10
+
+    return fslimage.Image(data, xform=aff)
+
+
+def test_help():
+    def run(args):
+        with pytest.raises(SystemExit) as e:
+            fsl_apply_x5.main(args)
+        assert e.value.code == 0
+
+    run([])
+    run(['-h'])
+
+
+
+def test_linear():
+    with tempdir.tempdir():
+
+        src2ref = _random_affine()
+        src     = _random_image(np.eye(4))
+        ref     = _random_image(src2ref)
+
+        x5.writeLinearX5('xform.x5', src2ref, src, ref)
+
+        src.save('src')
+        ref.save('ref')
+
+        fsl_apply_x5.main('src xform.x5 out'.split())
+
+        result = fslimage.Image('out')
+        expect = resample.resampleToReference(src, ref, matrix=src2ref)[0]
+
+        assert result.sameSpace(ref)
+        assert np.all(np.isclose(result.data, expect))
+
+
+def test_nonlinear():
+    with tempdir.tempdir():
+
+        src2ref = _random_affine()
+        ref2src = affine.invert(src2ref)
+        src     = _random_image(np.eye(4))
+        ref     = _random_image(src2ref)
+        field   = _affine_field(src, ref, ref2src, 'world', 'world')
+
+        x5.writeNonLinearX5('xform.x5', field)
+
+        src.save('src')
+
+        fsl_apply_x5.main('src xform.x5 out'.split())
+
+        result = fslimage.Image('out')
+        expect = resample.resampleToReference(src, ref, matrix=src2ref)[0]
+
+        assert result.sameSpace(ref)
+
+        # We might get truncation on the edges
+        result = result.data[1:-1, 1:-1, 1:-1]
+        expect = expect[     1:-1, 1:-1, 1:-1]
+
+        assert np.all(np.isclose(result, expect))
+
+
+def test_linear_altref():
+    with tempdir.tempdir():
+
+        src2ref = affine.scaleOffsetXform([1, 1, 1], [5,  5,  5])
+        altv2w  = affine.scaleOffsetXform([1, 1, 1], [10, 10, 10])
+
+        srcdata = np.random.randint(1, 65536, (10, 10, 10))
+        src     = fslimage.Image(srcdata,  xform=np.eye(4))
+        ref     = fslimage.Image(src.data, xform=src2ref)
+        altref  = fslimage.Image(src.data, xform=altv2w)
+
+        src   .save('src')
+        ref   .save('ref')
+        altref.save('altref')
+
+        x5.writeLinearX5('xform.x5', src2ref, src, ref)
+
+        fsl_apply_x5.main('src xform.x5 out -r altref'.split())
+
+        result = fslimage.Image('out')
+        expect = np.zeros(srcdata.shape)
+        expect[:5, :5, :5] = srcdata[5:, 5:, 5:]
+
+        assert result.sameSpace(altref)
+        assert np.all(result.data == expect)
+
+
+def test_nonlinear_altref():
+    with tempdir.tempdir():
+
+        src2ref = affine.scaleOffsetXform([1, 1, 1], [5,  5,  5])
+        ref2src = affine.invert(src2ref)
+        altv2w  = affine.scaleOffsetXform([1, 1, 1], [10, 10, 10])
+
+        srcdata = np.random.randint(1, 65536, (10, 10, 10))
+        src     = fslimage.Image(srcdata,  xform=np.eye(4))
+        ref     = fslimage.Image(src.data, xform=src2ref)
+        altref  = fslimage.Image(src.data, xform=altv2w)
+
+        field   = _affine_field(src, ref, ref2src, 'world', 'world')
+
+        src   .save('src')
+        ref   .save('ref')
+        altref.save('altref')
+
+        x5.writeNonLinearX5('xform.x5', field)
+
+        fsl_apply_x5.main('src xform.x5 out -r altref'.split())
+
+        result = fslimage.Image('out')
+        expect = np.zeros(srcdata.shape)
+        expect[:5, :5, :5] = srcdata[5:, 5:, 5:]
+
+        assert result.sameSpace(altref)
+        assert np.all(result.data == expect)
+
+
+def test_linear_altsrc():
+    with tempdir.tempdir():
+
+        src2ref = _random_affine()
+        src     = _random_image(np.eye(4), (20, 20, 20))
+        ref     = _random_image(src2ref)
+
+        x5.writeLinearX5('xform.x5', src2ref, src, ref)
+
+        src.save('src')
+        ref.save('ref')
+
+        srclo, xf = resample.resample(src, (10, 10, 10))
+        srclo = fslimage.Image(srclo, xform=xf)
+        srchi, xf = resample.resample(src, (40, 40, 40))
+        srchi = fslimage.Image(srchi, xform=xf)
+
+        srcoff = roi.roi(src, [(-10, 10), (-10, 10), (-10, 10)])
+
+        srclo .save('srclo')
+        srchi .save('srchi')
+        srcoff.save('srcoff')
+
+        fsl_apply_x5.main('src    xform.x5 out'   .split())
+        fsl_apply_x5.main('srclo  xform.x5 outlo' .split())
+        fsl_apply_x5.main('srchi  xform.x5 outhi' .split())
+        fsl_apply_x5.main('srcoff xform.x5 outoff'.split())
+
+        out    = fslimage.Image('out')
+        outlo  = fslimage.Image('outlo')
+        outhi  = fslimage.Image('outhi')
+        outoff = fslimage.Image('outoff')
+
+        exp    = resample.resampleToReference(src,    ref, matrix=src2ref)[0]
+        explo  = resample.resampleToReference(srclo,  ref, matrix=src2ref)[0]
+        exphi  = resample.resampleToReference(srchi,  ref, matrix=src2ref)[0]
+        expoff = resample.resampleToReference(srcoff, ref, matrix=src2ref)[0]
+
+        assert out   .sameSpace(ref)
+        assert outlo .sameSpace(ref)
+        assert outhi .sameSpace(ref)
+        assert outoff.sameSpace(ref)
+
+        assert np.all(np.isclose(out   .data, exp))
+        assert np.all(np.isclose(outlo .data, explo))
+        assert np.all(np.isclose(outhi .data, exphi))
+        assert np.all(np.isclose(outoff.data, expoff))
+
+
+def test_nonlinear_altsrc():
+    with tempdir.tempdir():
+
+        src2ref = _random_affine()
+        ref2src = affine.invert(src2ref)
+        src     = _random_image(np.eye(4), (20, 20, 20))
+        ref     = _random_image(src2ref,   (20, 20, 20))
+
+        field   = _affine_field(src, ref, ref2src, 'world', 'world')
+
+        x5.writeNonLinearX5('xform.x5', field)
+
+        src.save('src')
+        ref.save('ref')
+
+        srclo, xf = resample.resample(src, (10, 10, 10), origin='corner')
+        srclo = fslimage.Image(srclo, xform=xf)
+        srchi, xf = resample.resample(src, (40, 40, 40), origin='corner')
+        srchi = fslimage.Image(srchi, xform=xf)
+
+        srcoff = roi.roi(src, [(-10, 10), (-10, 10), (-10, 10)])
+
+        srclo .save('srclo')
+        srchi .save('srchi')
+        srcoff.save('srcoff')
+
+        # The up-sampled source image is subject to
+        # an unavoidable interpolation, as the
+        # deformation field coordinates are affine-
+        # transformed to the space of the alternate
+        # source image. So in this test case we use
+        # nearest-neighbour interp, so that we can
+        # get the same output as a standard linear
+        # resample
+        fsl_apply_x5.main('src    xform.x5 out'             .split())
+        fsl_apply_x5.main('srclo  xform.x5 outlo'           .split())
+        fsl_apply_x5.main('srchi  xform.x5 outhi -i nearest'.split())
+        fsl_apply_x5.main('srcoff xform.x5 outoff'          .split())
+
+        out    = fslimage.Image('out')
+        outlo  = fslimage.Image('outlo')
+        outhi  = fslimage.Image('outhi')
+        outoff = fslimage.Image('outoff')
+
+        exp,    x1 = resample.resampleToReference(src,    ref, matrix=src2ref, mode='constant')
+        explo,  x2 = resample.resampleToReference(srclo,  ref, matrix=src2ref, mode='constant')
+        exphi,  x3 = resample.resampleToReference(srchi,  ref, matrix=src2ref, mode='constant', order=0)
+        expoff, x4 = resample.resampleToReference(srcoff, ref, matrix=src2ref, mode='constant')
+
+        assert out   .sameSpace(ref)
+        assert outlo .sameSpace(ref)
+        assert outhi .sameSpace(ref)
+        assert outoff.sameSpace(ref)
+
+        # We get boundary issues just at the first
+        # voxel, so I'm masking that voxel out
+        for img in (out, outlo, outhi, outoff,
+                    exp, explo, exphi, expoff):
+            img[0, 0, 0] = 0
+
+        assert np.all(np.isclose(out   .data, exp))
+        assert np.all(np.isclose(outlo .data, explo))
+        assert np.all(np.isclose(outhi .data, exphi))
+        assert np.all(np.isclose(outoff.data, expoff))
diff --git a/tests/test_scripts/test_fsl_convert_x5.py b/tests/test_scripts/test_fsl_convert_x5.py
new file mode 100644
index 0000000000000000000000000000000000000000..b27cffa872b3919f0da939d175ff306b81f3cd04
--- /dev/null
+++ b/tests/test_scripts/test_fsl_convert_x5.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python
+
+
+import os.path as op
+import hashlib
+
+import pytest
+
+import numpy as np
+
+import fsl.utils.tempdir    as tempdir
+import fsl.transform.affine as affine
+import fsl.transform.flirt  as flirt
+import fsl.transform.fnirt  as fnirt
+import fsl.transform.x5     as x5
+import fsl.data.image       as fslimage
+
+import fsl.scripts.fsl_convert_x5 as fsl_convert_x5
+
+
+
+def random_image():
+    vx, vy, vz = np.random.randint(10, 50, 3)
+    dx, dy, dz = np.random.randint( 1, 10, 3)
+    data       = (np.random.random((vx, vy, vz)) - 0.5) * 10
+    aff        = affine.compose(
+        (dx, dy, dz),
+        np.random.randint(1, 100, 3),
+        np.random.random(3) * np.pi / 2)
+
+    return fslimage.Image(data, xform=aff)
+
+
+def test_convert_flirt_help():
+
+    def run(args):
+        with pytest.raises(SystemExit) as e:
+            fsl_convert_x5.main(args)
+        assert e.value.code == 0
+
+    run([])
+    run(['-h'])
+    run(['flirt'])
+    run(['fnirt'])
+    run(['flirt', '-h'])
+    run(['fnirt', '-h'])
+
+
+def test_convert_flirt():
+    with tempdir.tempdir():
+        src = random_image()
+        ref = random_image()
+        src.save('src')
+        ref.save('ref')
+
+        xform = affine.compose(
+            np.random.randint(1, 10, 3),
+            np.random.randint(-100, 100, 3),
+            (np.random.random(3) - 0.5) * np.pi)
+
+        np.savetxt('src2ref.mat', xform)
+
+        fsl_convert_x5.main('flirt -s src -r ref '
+                            'src2ref.mat src2ref.x5'.split())
+        expxform = affine.concat(
+            ref.getAffine('fsl', 'world'),
+            xform,
+            src.getAffine('world', 'fsl'))
+        gotxform, gotsrc, gotref = x5.readLinearX5('src2ref.x5')
+        assert np.all(np.isclose(gotxform, expxform))
+        assert src.sameSpace(gotsrc)
+        assert ref.sameSpace(gotref)
+
+        fsl_convert_x5.main('flirt src2ref.x5 src2ref_copy.mat'.split())
+
+        gotxform = flirt.readFlirt('src2ref_copy.mat')
+        assert np.all(np.isclose(gotxform, xform))
+
+
+def test_convert_flirt_sameformat():
+    with tempdir.tempdir():
+        src = random_image()
+        ref = random_image()
+        src.save('src')
+        ref.save('ref')
+
+        xform = affine.compose(
+            np.random.randint(1, 10, 3),
+            np.random.randint(-100, 100, 3),
+            (np.random.random(3) - 0.5) * np.pi)
+
+        np.savetxt('src2ref.mat', xform)
+
+        # test both .mat and .x5
+        fsl_convert_x5.main('flirt -s src -r ref '
+                            'src2ref.mat src2ref.x5'.split())
+
+        # mat -> mat
+        fsl_convert_x5.main('flirt -s src -r ref '
+                            'src2ref.mat copy.mat'.split())
+
+        # x5 -> x5
+        fsl_convert_x5.main('flirt -s src -r ref '
+                            'src2ref.x5 copy.x5'.split())
+
+        with open('src2ref.mat', 'rb') as f: origmat = hashlib.md5(f.read()).digest()
+        with open('copy.mat',    'rb') as f: copymat = hashlib.md5(f.read()).digest()
+        with open('src2ref.x5',  'rb') as f: origx5  = hashlib.md5(f.read()).digest()
+        with open('copy.x5',     'rb') as f: copyx5  = hashlib.md5(f.read()).digest()
+
+        assert origmat == copymat
+        assert origx5  == copyx5
+
+
+
+def test_convert_fnirt_deformation_field():
+
+    datadir = op.join(op.dirname(__file__), '..',
+                      'test_transform', 'testdata', 'nonlinear')
+    srcfile = op.join(datadir, 'src.nii.gz')
+    reffile = op.join(datadir, 'ref.nii.gz')
+    dffile  = op.join(datadir, 'displacementfield.nii.gz')
+
+    with tempdir.tempdir():
+
+        # nii -> x5
+        fsl_convert_x5.main('fnirt -s {} -r {} {} disp.x5'.format(
+            srcfile, reffile, dffile).split())
+
+        # x5 -> nii
+        fsl_convert_x5.main('fnirt disp.x5 disp.nii.gz'.split())
+
+        src   = fslimage.Image(srcfile)
+        ref   = fslimage.Image(reffile)
+        df    = fnirt.readFnirt(dffile, src, ref)
+        dfnii = fnirt.readFnirt('disp.nii.gz', src, ref)
+
+        assert dfnii.src.sameSpace(src)
+        assert dfnii.ref.sameSpace(ref)
+        assert dfnii.srcSpace == df.srcSpace
+        assert dfnii.refSpace == df.refSpace
+        assert dfnii.deformationType == df.deformationType
+        assert np.all(np.isclose(dfnii.data, df.data))
+
+
+def test_convert_fnirt_coefficient_field():
+
+    datadir = op.join(op.dirname(__file__), '..',
+                      'test_transform', 'testdata', 'nonlinear')
+    srcfile = op.join(datadir, 'src.nii.gz')
+    reffile = op.join(datadir, 'ref.nii.gz')
+    cffile  = op.join(datadir, 'coefficientfield.nii.gz')
+    dffile  = op.join(datadir, 'displacementfield.nii.gz')
+
+    with tempdir.tempdir():
+
+        # nii -> x5
+        fsl_convert_x5.main('fnirt -s {} -r {} {} coef.x5'.format(
+            srcfile, reffile, cffile).split())
+
+        # x5 -> nii
+        fsl_convert_x5.main('fnirt coef.x5 coef.nii.gz'.split())
+
+        src   = fslimage.Image(srcfile)
+        ref   = fslimage.Image(reffile)
+        df    = fnirt.readFnirt(dffile, src, ref)
+        dfnii = fnirt.readFnirt('coef.nii.gz', src, ref)
+
+        assert dfnii    .sameSpace(df)
+        assert dfnii.src.sameSpace(src)
+        assert dfnii.ref.sameSpace(ref)
+
+        assert dfnii.srcSpace        == df.srcSpace
+        assert dfnii.refSpace        == df.refSpace
+        assert dfnii.deformationType == 'relative'
+
+        diff = np.abs(dfnii.data -  df.data)
+        tols = {'rtol' : 1e-5, 'atol' : 1e-5}
+        assert np.all(np.isclose(dfnii.data, df.data, **tols))
+
+
+def test_convert_fnirt_sameformat():
+
+    datadir = op.join(op.dirname(__file__), '..',
+                      'test_transform', 'testdata', 'nonlinear')
+    srcfile = op.join(datadir, 'src.nii.gz')
+    reffile = op.join(datadir, 'ref.nii.gz')
+    dffile  = op.join(datadir, 'displacementfield.nii.gz')
+
+    with tempdir.tempdir():
+
+        base = list('fnirt -s {} -r {}'.format(srcfile, reffile).split())
+
+        # test both .mat and .x5
+        fsl_convert_x5.main(base + [dffile, 'src2ref.x5'])
+
+        # nii -> nii
+        fsl_convert_x5.main(base + [dffile, 'copy.nii.gz'])
+
+        # x5 -> x5
+        fsl_convert_x5.main(base + ['src2ref.x5', 'copy.x5'])
+
+        with open(dffile,        'rb') as f: origdef = hashlib.md5(f.read()).digest()
+        with open('copy.nii.gz', 'rb') as f: copydef = hashlib.md5(f.read()).digest()
+        with open('src2ref.x5',  'rb') as f: origx5  = hashlib.md5(f.read()).digest()
+        with open('copy.x5',     'rb') as f: copyx5  = hashlib.md5(f.read()).digest()
+
+        assert origdef == copydef
+        assert origx5  == copyx5
diff --git a/tests/test_transform/__init__.py b/tests/test_transform/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/test_transform.py b/tests/test_transform/test_affine.py
similarity index 72%
rename from tests/test_transform.py
rename to tests/test_transform/test_affine.py
index 70b86019aae9a7374a906dda830fd0a8f7c0966d..7a69b86e4d79531f53d52be991ba5afe994b248a 100644
--- a/tests/test_transform.py
+++ b/tests/test_transform/test_affine.py
@@ -19,8 +19,7 @@ import six
 
 import pytest
 
-import fsl.utils.transform as transform
-import fsl.data.image      as fslimage
+import fsl.transform.affine  as affine
 
 
 datadir = op.join(op.dirname(__file__), 'testdata')
@@ -57,7 +56,7 @@ def test_invert():
 
         x      = testdata[i * 4:i * 4 + 4, 0:4]
         invx   = testdata[i * 4:i * 4 + 4, 4:8]
-        result = transform.invert(x)
+        result = affine.invert(x)
 
         assert np.all(np.isclose(invx, result))
 
@@ -87,7 +86,7 @@ def test_concat():
 
     for inputs, expected in tests:
 
-        result = transform.concat(*inputs)
+        result = affine.concat(*inputs)
 
         assert np.all(np.isclose(result, expected))
 
@@ -109,10 +108,10 @@ def test_veclength(seed):
         vtype = random.choice((list, tuple, np.array))
         v     = vtype(v)
 
-        assert np.isclose(transform.veclength(v), l(v))
+        assert np.isclose(affine.veclength(v), l(v))
 
     # Multiple vectors in parallel
-    result   = transform.veclength(vectors)
+    result   = affine.veclength(vectors)
     expected = l(vectors)
     assert np.all(np.isclose(result, expected))
 
@@ -122,8 +121,8 @@ def test_normalise(seed):
     vectors = -100 + 200 * np.random.random((200, 3))
 
     def parallel(v1, v2):
-        v1 = v1 / transform.veclength(v1)
-        v2 = v2 / transform.veclength(v2)
+        v1 = v1 / affine.veclength(v1)
+        v2 = v2 / affine.veclength(v2)
 
         return np.isclose(np.dot(v1, v2), 1)
 
@@ -131,16 +130,16 @@ def test_normalise(seed):
 
         vtype = random.choice((list, tuple, np.array))
         v     = vtype(v)
-        vn    = transform.normalise(v)
-        vl    = transform.veclength(vn)
+        vn    = affine.normalise(v)
+        vl    = affine.veclength(vn)
 
         assert np.isclose(vl, 1.0)
         assert parallel(v, vn)
 
     # normalise should also be able
     # to do multiple vectors at once
-    results = transform.normalise(vectors)
-    lengths = transform.veclength(results)
+    results = affine.normalise(vectors)
+    lengths = affine.veclength(results)
     pars    = np.zeros(200)
     for i in range(200):
 
@@ -172,7 +171,7 @@ def test_scaleOffsetXform():
         expected = [[float(v) for v in l.split()] for l in expected]
         expected = np.array(expected)
 
-        result = transform.scaleOffsetXform(scales, offsets)
+        result = affine.scaleOffsetXform(scales, offsets)
 
         assert np.all(np.isclose(result, expected))
 
@@ -208,11 +207,11 @@ def test_scaleOffsetXform():
 
     for (scale, expected) in stests:
         expected = np.array(expected).reshape(4, 4)
-        result   = transform.scaleOffsetXform(scale, 0)
+        result   = affine.scaleOffsetXform(scale, 0)
         assert np.all(np.isclose(result, expected))
     for (offset, expected) in otests:
         expected = np.array(expected).reshape(4, 4)
-        result   = transform.scaleOffsetXform(1, offset)
+        result   = affine.scaleOffsetXform(1, offset)
         assert np.all(np.isclose(result, expected))
 
 
@@ -227,8 +226,8 @@ def test_compose_and_decompose():
         xform                      = lines[i * 4: i * 4 + 4]
         xform                      = np.genfromtxt(xform)
 
-        scales, offsets, rotations = transform.decompose(xform)
-        result = transform.compose(scales, offsets, rotations)
+        scales, offsets, rotations = affine.decompose(xform)
+        result = affine.compose(scales, offsets, rotations)
 
         assert np.all(np.isclose(xform, result, atol=1e-5))
 
@@ -236,22 +235,22 @@ def test_compose_and_decompose():
         # different rotation origin, but we test
         # explicitly passing the origin for
         # completeness
-        scales, offsets, rotations = transform.decompose(xform)
-        result = transform.compose(scales, offsets, rotations, [0, 0, 0])
+        scales, offsets, rotations = affine.decompose(xform)
+        result = affine.compose(scales, offsets, rotations, [0, 0, 0])
 
         assert np.all(np.isclose(xform, result, atol=1e-5))
 
     # compose should also accept a rotation matrix
     rots = [np.pi / 5, np.pi / 4, np.pi / 3]
-    rmat  = transform.axisAnglesToRotMat(*rots)
-    xform = transform.compose([1, 1, 1], [0, 0, 0], rmat)
+    rmat  = affine.axisAnglesToRotMat(*rots)
+    xform = affine.compose([1, 1, 1], [0, 0, 0], rmat)
 
     # And the angles flag should cause decompose
     # to return the rotation matrix, instead of
     # the axis angls
-    sc,   of,   rot   = transform.decompose(xform)
-    scat, ofat, rotat = transform.decompose(xform, angles=True)
-    scaf, ofaf, rotaf = transform.decompose(xform, angles=False)
+    sc,   of,   rot   = affine.decompose(xform)
+    scat, ofat, rotat = affine.decompose(xform, angles=True)
+    scaf, ofaf, rotaf = affine.decompose(xform, angles=False)
 
     sc,   of,   rot   = np.array(sc),   np.array(of),   np.array(rot)
     scat, ofat, rotat = np.array(scat), np.array(ofat), np.array(rotat)
@@ -270,8 +269,8 @@ def test_compose_and_decompose():
 
     # decompose should accept a 3x3
     # affine, and return translations of 0
-    transform.decompose(xform[:3, :3])
-    sc,   of,   rot   = transform.decompose(xform[:3, :3])
+    affine.decompose(xform[:3, :3])
+    sc,   of,   rot   = affine.decompose(xform[:3, :3])
     sc,   of,   rot   = np.array(sc), np.array(of), np.array(rot)
     assert np.all(np.isclose(sc,    [1, 1, 1]))
     assert np.all(np.isclose(of,    [0, 0, 0]))
@@ -291,8 +290,8 @@ def test_rotMatToAxisAngles(seed):
                 -pi2 + 2 * pi2 * np.random.random(),
                 -pi  + 2 * pi  * np.random.random()]
 
-        rmat    = transform.axisAnglesToRotMat(*rots)
-        gotrots = transform.rotMatToAxisAngles(rmat)
+        rmat    = affine.axisAnglesToRotMat(*rots)
+        gotrots = affine.rotMatToAxisAngles(rmat)
 
         assert np.all(np.isclose(rots, gotrots))
 
@@ -311,9 +310,9 @@ def test_rotMatToAffine(seed):
         if np.random.random() < 0.5: origin = None
         else:                        origin = np.random.random(3)
 
-        rmat   = transform.axisAnglesToRotMat(*rots)
-        mataff = transform.rotMatToAffine(rmat, origin)
-        rotaff = transform.rotMatToAffine(rots, origin)
+        rmat   = affine.axisAnglesToRotMat(*rots)
+        mataff = affine.rotMatToAffine(rmat, origin)
+        rotaff = affine.rotMatToAffine(rots, origin)
 
         exp         = np.eye(4)
         exp[:3, :3] = rmat
@@ -350,7 +349,7 @@ def test_axisBounds():
         shape, origin, boundary, xform, expected = readTest(i)
 
         for axes in allAxes:
-            result = transform.axisBounds(shape,
+            result = affine.axisBounds(shape,
                                           xform,
                                           axes=axes,
                                           origin=origin,
@@ -372,14 +371,14 @@ def test_axisBounds():
     # US-spelling
     assert np.all(np.isclose(
         expected,
-        transform.axisBounds(
+        affine.axisBounds(
             shape, xform, origin='center', boundary=boundary)))
 
     # Bad origin/boundary values
     with pytest.raises(ValueError):
-        transform.axisBounds(shape, xform, origin='Blag', boundary=boundary)
+        affine.axisBounds(shape, xform, origin='Blag', boundary=boundary)
     with pytest.raises(ValueError):
-        transform.axisBounds(shape, xform, origin=origin, boundary='Blufu')
+        affine.axisBounds(shape, xform, origin=origin, boundary='Blufu')
 
 
 def test_transform():
@@ -413,7 +412,7 @@ def test_transform():
         lines    = readlines(testfile)
         xform    = np.genfromtxt(lines[:4])
         expected = np.genfromtxt(lines[ 4:])
-        result   = transform.transform(testcoords, xform)
+        result   = affine.transform(testcoords, xform)
 
         assert np.all(np.isclose(expected, result))
 
@@ -423,7 +422,7 @@ def test_transform():
         for axes in allAxes:
             atestcoords = testcoords[:, axes]
             aexpected   = expected[  :, axes]
-            aresult     = transform.transform(atestcoords, xform, axes=axes)
+            aresult     = affine.transform(atestcoords, xform, axes=axes)
 
             assert np.all(np.isclose(aexpected, aresult))
 
@@ -434,26 +433,26 @@ def test_transform():
     coords    = badcoords[:, :3]
 
     with pytest.raises(IndexError):
-        transform.transform(coords, badxform)
+        affine.transform(coords, badxform)
 
     with pytest.raises(ValueError):
-        transform.transform(badcoords, xform)
+        affine.transform(badcoords, xform)
 
     with pytest.raises(ValueError):
-        transform.transform(badcoords.reshape(5, 2, 4), xform)
+        affine.transform(badcoords.reshape(5, 2, 4), xform)
 
     with pytest.raises(ValueError):
-        transform.transform(badcoords.reshape(5, 2, 4), xform, axes=1)
+        affine.transform(badcoords.reshape(5, 2, 4), xform, axes=1)
 
     with pytest.raises(ValueError):
-        transform.transform(badcoords[:, (1, 2, 3)], xform, axes=[1, 2])
+        affine.transform(badcoords[:, (1, 2, 3)], xform, axes=[1, 2])
 
 
 def test_transform_vector(seed):
 
     # Some transform with a
     # translation component
-    xform = transform.compose([1, 2, 3],
+    xform = affine.compose([1, 2, 3],
                               [5, 10, 15],
                               [np.pi / 2, np.pi / 2, 0])
 
@@ -464,9 +463,9 @@ def test_transform_vector(seed):
         vecExpected = np.dot(xform, list(v) + [0])[:3]
         ptExpected  = np.dot(xform, list(v) + [1])[:3]
 
-        vecResult   = transform.transform(v, xform,         vector=True)
-        vec33Result = transform.transform(v, xform[:3, :3], vector=True)
-        ptResult    = transform.transform(v, xform,         vector=False)
+        vecResult   = affine.transform(v, xform,         vector=True)
+        vec33Result = affine.transform(v, xform[:3, :3], vector=True)
+        ptResult    = affine.transform(v, xform,         vector=False)
 
         assert np.all(np.isclose(vecExpected, vecResult))
         assert np.all(np.isclose(vecExpected, vec33Result))
@@ -489,83 +488,33 @@ def test_transformNormal(seed):
         rotations = -np.pi + np.random.random(3) * 2 * np.pi
         origin    = -100   + np.random.random(3) * 200
 
-        xform = transform.compose(scales,
+        xform = affine.compose(scales,
                                   offsets,
                                   rotations,
                                   origin)
 
         expected = tn(n, xform)
-        result   = transform.transformNormal(n, xform)
+        result   = affine.transformNormal(n, xform)
 
         assert np.all(np.isclose(expected, result))
 
 
-def test_flirtMatrixToSform():
-
-    testfile = op.join(datadir, 'test_transform_test_flirtMatrixToSform.txt')
-    lines    = readlines(testfile)
-    ntests   = len(lines) // 18
-
-    for i in range(ntests):
-        tlines    = lines[i * 18: i * 18 + 18]
-        srcShape  = [int(  w) for w in tlines[0].split()]
-        srcXform  = np.genfromtxt(tlines[1:5])
-        refShape  = [int(  w) for w in tlines[5].split()]
-        refXform  = np.genfromtxt(tlines[6:10])
-        flirtMat  = np.genfromtxt(tlines[10:14])
-        expected  = np.genfromtxt(tlines[14:18])
-
-        srcImg = fslimage.Image(np.zeros(srcShape), xform=srcXform)
-        refImg = fslimage.Image(np.zeros(refShape), xform=refXform)
-
-        result = transform.flirtMatrixToSform(flirtMat, srcImg, refImg)
-
-        assert np.all(np.isclose(result, expected))
-
-
-def test_sformToFlirtMatrix():
-    testfile = op.join(datadir, 'test_transform_test_flirtMatrixToSform.txt')
-    lines    = readlines(testfile)
-    ntests   = len(lines) // 18
-
-    for i in range(ntests):
-        tlines      = lines[i * 18: i * 18 + 18]
-        srcShape    = [int(  w) for w in tlines[0].split()]
-        srcXform    = np.genfromtxt(tlines[1:5])
-        refShape    = [int(  w) for w in tlines[5].split()]
-        refXform    = np.genfromtxt(tlines[6:10])
-        expected    = np.genfromtxt(tlines[10:14])
-        srcXformOvr = np.genfromtxt(tlines[14:18])
-
-        srcImg1 = fslimage.Image(np.zeros(srcShape), xform=srcXform)
-        srcImg2 = fslimage.Image(np.zeros(srcShape), xform=srcXform)
-        refImg  = fslimage.Image(np.zeros(refShape), xform=refXform)
-
-        srcImg2.voxToWorldMat = srcXformOvr
-
-        result1 = transform.sformToFlirtMatrix(srcImg1, refImg, srcXformOvr)
-        result2 = transform.sformToFlirtMatrix(srcImg2, refImg)
-
-        assert np.all(np.isclose(result1, expected))
-        assert np.all(np.isclose(result2, expected))
-
-
 def test_rmsdev():
 
     t1 = np.eye(4)
-    t2 = transform.scaleOffsetXform([1, 1, 1], [2, 0, 0])
+    t2 = affine.scaleOffsetXform([1, 1, 1], [2, 0, 0])
 
-    assert np.isclose(transform.rmsdev(t1, t2), 2)
-    assert np.isclose(transform.rmsdev(t1, t2, R=2), 2)
-    assert np.isclose(transform.rmsdev(t1, t2, R=2, xc=(1, 1, 1)), 2)
+    assert np.isclose(affine.rmsdev(t1, t2), 2)
+    assert np.isclose(affine.rmsdev(t1, t2, R=2), 2)
+    assert np.isclose(affine.rmsdev(t1, t2, R=2, xc=(1, 1, 1)), 2)
 
     t1       = np.eye(3)
     lastdist = 0
 
     for i in range(1, 11):
         rot    = np.pi * i / 10.0
-        t2     = transform.axisAnglesToRotMat(rot, 0, 0)
-        result = transform.rmsdev(t1, t2)
+        t2     = affine.axisAnglesToRotMat(rot, 0, 0)
+        result = affine.rmsdev(t1, t2)
 
         assert result > lastdist
 
@@ -573,8 +522,8 @@ def test_rmsdev():
 
     for i in range(11, 20):
         rot    = np.pi * i / 10.0
-        t2     = transform.axisAnglesToRotMat(rot, 0, 0)
-        result = transform.rmsdev(t1, t2)
+        t2     = affine.axisAnglesToRotMat(rot, 0, 0)
+        result = affine.rmsdev(t1, t2)
 
         assert result < lastdist
 
diff --git a/tests/test_transform/test_flirt.py b/tests/test_transform/test_flirt.py
new file mode 100644
index 0000000000000000000000000000000000000000..d2dd68e29b114ca9813ebd1eb8511df825d8edaf
--- /dev/null
+++ b/tests/test_transform/test_flirt.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python
+#
+# test_transform_flirt.py -
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+
+import itertools as it
+import os.path as op
+
+import numpy as np
+
+import fsl.data.image       as fslimage
+import fsl.transform.flirt  as flirt
+import fsl.transform.affine as affine
+import fsl.utils.tempdir    as tempdir
+
+from .test_affine import readlines
+
+from .. import make_random_image
+
+
+datadir = op.join(op.dirname(__file__), 'testdata')
+
+
+def test_read_write():
+    with tempdir.tempdir():
+        aff = np.random.random((4, 4))
+        flirt.writeFlirt(aff, 'aff.mat')
+        got = flirt.readFlirt('aff.mat')
+        assert np.all(np.isclose(aff, got))
+
+
+def test_fromFlirt():
+
+    src = affine.compose(np.random.randint( 1,  5,  3),
+                         np.random.randint(-20, 20, 3),
+                         np.random.random(3) - 0.5)
+    ref = affine.compose(np.random.randint( 1,  5,  3),
+                         np.random.randint(-20, 20, 3),
+                         np.random.random(3) - 0.5)
+
+    src      = fslimage.Image(make_random_image(xform=src))
+    ref      = fslimage.Image(make_random_image(xform=ref))
+    flirtmat = affine.concat(ref.getAffine('world', 'fsl'),
+                             src.getAffine('fsl',   'world'))
+
+
+    spaces = it.permutations(('voxel', 'fsl', 'world'), 2)
+
+    for from_, to in spaces:
+        got = flirt.fromFlirt(flirtmat, src, ref, from_, to)
+        exp = affine.concat(ref.getAffine('fsl', to),
+                            flirtmat,
+                            src.getAffine(from_, 'fsl'))
+
+        assert np.all(np.isclose(got, exp))
+
+
+def test_toFlirt():
+
+    src = affine.compose(np.random.randint( 1,  5,  3),
+                         np.random.randint(-20, 20, 3),
+                         np.random.random(3) - 0.5)
+    ref = affine.compose(np.random.randint( 1,  5,  3),
+                         np.random.randint(-20, 20, 3),
+                         np.random.random(3) - 0.5)
+
+    src      = fslimage.Image(make_random_image(xform=src))
+    ref      = fslimage.Image(make_random_image(xform=ref))
+    flirtmat = affine.concat(ref.getAffine('world', 'fsl'),
+                             src.getAffine('fsl',   'world'))
+
+
+    spaces = it.permutations(('voxel', 'fsl', 'world'), 2)
+
+    for from_, to in spaces:
+        xform = affine.concat(ref.getAffine('world', to),
+                              src.getAffine(from_, 'world'))
+        got = flirt.toFlirt(xform, src, ref, from_, to)
+
+        assert np.all(np.isclose(got, flirtmat))
+
+
+def test_flirtMatrixToSform():
+
+    testfile = op.join(datadir, 'test_transform_test_flirtMatrixToSform.txt')
+    lines    = readlines(testfile)
+    ntests   = len(lines) // 18
+
+    for i in range(ntests):
+        tlines    = lines[i * 18: i * 18 + 18]
+        srcShape  = [int(  w) for w in tlines[0].split()]
+        srcXform  = np.genfromtxt(tlines[1:5])
+        refShape  = [int(  w) for w in tlines[5].split()]
+        refXform  = np.genfromtxt(tlines[6:10])
+        flirtMat  = np.genfromtxt(tlines[10:14])
+        expected  = np.genfromtxt(tlines[14:18])
+
+        srcImg = fslimage.Image(np.zeros(srcShape), xform=srcXform)
+        refImg = fslimage.Image(np.zeros(refShape), xform=refXform)
+
+        result = flirt.flirtMatrixToSform(flirtMat, srcImg, refImg)
+
+        assert np.all(np.isclose(result, expected))
+
+
+def test_sformToFlirtMatrix():
+    testfile = op.join(datadir, 'test_transform_test_flirtMatrixToSform.txt')
+    lines    = readlines(testfile)
+    ntests   = len(lines) // 18
+
+    for i in range(ntests):
+        tlines      = lines[i * 18: i * 18 + 18]
+        srcShape    = [int(  w) for w in tlines[0].split()]
+        srcXform    = np.genfromtxt(tlines[1:5])
+        refShape    = [int(  w) for w in tlines[5].split()]
+        refXform    = np.genfromtxt(tlines[6:10])
+        expected    = np.genfromtxt(tlines[10:14])
+        srcXformOvr = np.genfromtxt(tlines[14:18])
+
+        srcImg1 = fslimage.Image(np.zeros(srcShape), xform=srcXform)
+        srcImg2 = fslimage.Image(np.zeros(srcShape), xform=srcXform)
+        refImg  = fslimage.Image(np.zeros(refShape), xform=refXform)
+
+        srcImg2.voxToWorldMat = srcXformOvr
+
+        result1 = flirt.sformToFlirtMatrix(srcImg1, refImg, srcXformOvr)
+        result2 = flirt.sformToFlirtMatrix(srcImg2, refImg)
+
+        assert np.all(np.isclose(result1, expected))
+        assert np.all(np.isclose(result2, expected))
diff --git a/tests/test_transform/test_fnirt.py b/tests/test_transform/test_fnirt.py
new file mode 100644
index 0000000000000000000000000000000000000000..eefc68b64e471dc052ff4fb92b637f56a599a8fa
--- /dev/null
+++ b/tests/test_transform/test_fnirt.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python
+#
+# test_fnirt.py -
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+
+import os.path as op
+import itertools as it
+
+import numpy as np
+
+import pytest
+
+import fsl.data.image          as fslimage
+import fsl.transform.affine    as affine
+import fsl.transform.nonlinear as nonlinear
+import fsl.transform.fnirt     as fnirt
+
+from .test_nonlinear import _random_affine_field
+
+
+datadir = op.join(op.dirname(__file__), 'testdata', 'nonlinear')
+
+
+def test_readFnirt():
+
+    src  = op.join(datadir, 'src')
+    ref  = op.join(datadir, 'ref')
+    coef = op.join(datadir, 'coefficientfield')
+    disp = op.join(datadir, 'displacementfield')
+
+    src  = fslimage.Image(src)
+    ref  = fslimage.Image(ref)
+    coef = fnirt.readFnirt(coef, src, ref)
+    disp = fnirt.readFnirt(disp, src, ref)
+
+    with pytest.raises(ValueError):
+        fnirt.readFnirt(src, src, ref)
+
+    assert isinstance(coef, nonlinear.CoefficientField)
+    assert isinstance(disp, nonlinear.DeformationField)
+
+    assert coef.src.sameSpace(src)
+    assert coef.ref.sameSpace(ref)
+    assert disp.src.sameSpace(src)
+    assert disp.ref.sameSpace(ref)
+    assert coef.srcSpace == 'fsl'
+    assert coef.refSpace == 'fsl'
+    assert disp.srcSpace == 'fsl'
+    assert disp.refSpace == 'fsl'
+
+
+def test_toFnirt():
+
+    def check(got, exp):
+        tol = dict(atol=1e-5, rtol=1e-5)
+        assert np.all(np.isclose(got.data, exp.data, **tol))
+        assert got.src.sameSpace(exp.src)
+        assert got.ref.sameSpace(exp.ref)
+        assert got.srcSpace == 'fsl'
+        assert got.refSpace == 'fsl'
+
+    basefield, xform = _random_affine_field()
+    src = basefield.src
+    ref = basefield.ref
+
+    spaces = it.permutations(('voxel', 'fsl', 'world'), 2)
+
+    for from_, to in spaces:
+        field = nonlinear.convertDeformationSpace(basefield, from_, to)
+        got = fnirt.toFnirt(field)
+        check(got, basefield)
+
+    src  = fslimage.Image(op.join(datadir, 'src'))
+    ref  = fslimage.Image(op.join(datadir, 'ref'))
+    coef = fnirt.readFnirt(op.join(datadir, 'coefficientfield'),  src, ref)
+    got  = fnirt.toFnirt(coef)
+    check(got, coef)
+
+
+def test_fromFnirt():
+
+    basefield, basexform = _random_affine_field()
+    src = basefield.src
+    ref = basefield.ref
+    spaces = list(it.permutations(('voxel', 'fsl', 'world'), 2))
+
+    for from_, to in spaces:
+
+        got = fnirt.fromFnirt(basefield, from_, to)
+
+        assert got.srcSpace == to
+        assert got.refSpace == from_
+
+        coords = [np.random.randint(0, basefield.shape[0], 5),
+                  np.random.randint(0, basefield.shape[1], 5),
+                  np.random.randint(0, basefield.shape[2], 5)]
+        coords = np.array(coords).T
+
+        coords = affine.transform(coords, ref.getAffine('voxel', from_))
+
+        aff = affine.concat(src.getAffine('fsl', to),
+                            basexform,
+                            ref.getAffine(from_, 'fsl'))
+
+        got = got.transform(coords)
+        exp = affine.transform(coords, aff)
+
+        enan = np.isnan(exp)
+        gnan = np.isnan(got)
+
+        assert np.all(np.isclose(enan, gnan))
+        assert np.all(np.isclose(exp[~enan], got[~gnan]))
+
+    # Converting from a FNIRT coefficient field
+    src  = fslimage.Image(op.join(datadir, 'src'))
+    ref  = fslimage.Image(op.join(datadir, 'ref'))
+    coef = fnirt.readFnirt(op.join(datadir, 'coefficientfield'),  src, ref)
+    disp = fnirt.readFnirt(op.join(datadir, 'displacementfield'), src, ref)
+
+    for from_, to in spaces:
+
+        cnv = fnirt.fromFnirt(coef, from_, to)
+        exp = nonlinear.convertDeformationSpace(disp, from_, to)
+        tol = dict(atol=1e-5, rtol=1e-5)
+        assert np.all(np.isclose(cnv.data, exp.data, **tol))
diff --git a/tests/test_transform/test_nonlinear.py b/tests/test_transform/test_nonlinear.py
new file mode 100644
index 0000000000000000000000000000000000000000..694162c3b3496cec470c68d57eb3142506e25cf0
--- /dev/null
+++ b/tests/test_transform/test_nonlinear.py
@@ -0,0 +1,481 @@
+#!/usr/bin/env python
+
+import itertools as it
+import os.path   as op
+
+import numpy   as np
+
+import fsl.data.image           as fslimage
+import fsl.utils.image.resample as resample
+import fsl.utils.image.roi      as roi
+import fsl.transform.affine     as affine
+import fsl.transform.nonlinear  as nonlinear
+import fsl.transform.fnirt      as fnirt
+
+
+datadir = op.join(op.dirname(__file__), 'testdata')
+
+
+def _random_image():
+    vx, vy, vz = np.random.randint(10, 50, 3)
+    dx, dy, dz = np.random.randint( 1, 10, 3)
+    data       = (np.random.random((vx, vy, vz)) - 0.5) * 10
+    aff        = affine.compose(
+        (dx, dy, dz),
+        np.random.randint(1, 100, 3),
+        np.random.random(3) * np.pi / 2)
+
+    return fslimage.Image(data, xform=aff)
+
+
+def _random_field():
+
+    src        = _random_image()
+    vx, vy, vz = np.random.randint(10, 50, 3)
+    dx, dy, dz = np.random.randint( 1, 10, 3)
+
+    field = (np.random.random((vx, vy, vz, 3)) - 0.5) * 10
+    aff   = affine.compose(
+        (dx, dy, dz),
+        np.random.randint(1, 100, 3),
+        np.random.random(3) * np.pi / 2)
+
+    return nonlinear.DeformationField(field, src=src, xform=aff)
+
+
+def _affine_field(src, ref, xform, srcSpace, refSpace, shape=None, fv2w=None):
+
+    if shape is None: shape = ref.shape[:3]
+    if fv2w  is None: fv2w  = ref.getAffine('voxel', 'world')
+
+    rx, ry, rz = np.meshgrid(np.arange(shape[0]),
+                             np.arange(shape[1]),
+                             np.arange(shape[2]), indexing='ij')
+
+    rvoxels  = np.vstack((rx.flatten(), ry.flatten(), rz.flatten())).T
+    f2r      = affine.concat(ref.getAffine('world', refSpace), fv2w)
+    rcoords  = affine.transform(rvoxels, f2r)
+    scoords  = affine.transform(rcoords, xform)
+
+    field    = np.zeros(list(shape[:3]) + [3])
+    field[:] = (scoords - rcoords).reshape(*it.chain(shape, [3]))
+    field    = nonlinear.DeformationField(field, src, ref,
+                                          srcSpace=srcSpace,
+                                          refSpace=refSpace,
+                                          xform=fv2w,
+                                          header=ref.header,
+                                          defType='relative')
+    return field
+
+
+def _random_affine_field():
+
+    src = _random_image()
+    ref = _random_image()
+
+    # our test field just encodes an affine
+    xform = affine.compose(
+        np.random.randint(2, 5, 3),
+        np.random.randint(1, 10, 3),
+        np.random.random(3))
+
+    rx, ry, rz = np.meshgrid(np.arange(ref.shape[0]),
+                             np.arange(ref.shape[1]),
+                             np.arange(ref.shape[2]), indexing='ij')
+
+    rvoxels  = np.vstack((rx.flatten(), ry.flatten(), rz.flatten())).T
+    rcoords  = affine.transform(rvoxels, ref.voxToScaledVoxMat)
+    scoords  = affine.transform(rcoords, xform)
+
+    field    = np.zeros(list(ref.shape[:3]) + [3])
+    field[:] = (scoords - rcoords).reshape(*it.chain(ref.shape, [3]))
+    field    = nonlinear.DeformationField(field, src, ref,
+                                          header=ref.header,
+                                          defType='relative')
+    return field, xform
+
+
+def _field_coords(field):
+    vx, vy, vz = field.shape[ :3]
+    coords     = np.meshgrid(np.arange(vx),
+                             np.arange(vy),
+                             np.arange(vz), indexing='ij')
+    coords = np.array(coords).transpose((1, 2, 3, 0))
+    return affine.transform(
+        coords.reshape(-1, 3),
+        field.getAffine('voxel', 'fsl')).reshape(field.shape)
+
+
+def test_detectDeformationType():
+    relfield = _random_field()
+    coords   = _field_coords(relfield)
+    absfield = nonlinear.DeformationField(
+        relfield.data + coords,
+        src=relfield.src,
+        xform=relfield.voxToWorldMat)
+
+    assert nonlinear.detectDeformationType(relfield) == 'relative'
+    assert nonlinear.detectDeformationType(absfield) == 'absolute'
+
+
+def test_convertDeformationType():
+
+    relfield = _random_field()
+    coords   = _field_coords(relfield)
+    absfield = nonlinear.DeformationField(
+        relfield.data + coords,
+        src=relfield.src,
+        xform=relfield.voxToWorldMat)
+
+    gotconvrel1 = nonlinear.convertDeformationType(relfield)
+    gotconvabs1 = nonlinear.convertDeformationType(absfield)
+    gotconvrel2 = nonlinear.convertDeformationType(relfield, 'absolute')
+    gotconvabs2 = nonlinear.convertDeformationType(absfield, 'relative')
+
+    tol = dict(atol=1e-5, rtol=1e-5)
+
+    assert np.all(np.isclose(gotconvrel1, absfield.data, **tol))
+    assert np.all(np.isclose(gotconvabs1, relfield.data, **tol))
+    assert np.all(np.isclose(gotconvrel2, absfield.data, **tol))
+    assert np.all(np.isclose(gotconvabs2, relfield.data, **tol))
+
+
+def test_convertDeformationSpace():
+
+    basefield, xform = _random_affine_field()
+    src              = basefield.src
+    ref              = basefield.ref
+
+    # generate reference fsl->fsl coordinate mappings
+
+    # For each combination of srcspace->tospace
+    # Generate random coordinates, check that
+    # displacements are correct
+    spaces = ['fsl', 'voxel', 'world']
+    spaces = list(it.combinations_with_replacement(spaces, 2))
+    spaces = spaces + [(r, s) for s, r in spaces]
+    spaces = list(set(spaces))
+
+    for from_, to in spaces:
+
+        refcoords = [np.random.randint(0, basefield.shape[0], 5),
+                     np.random.randint(0, basefield.shape[1], 5),
+                     np.random.randint(0, basefield.shape[2], 5)]
+        refcoords = np.array(refcoords, dtype=np.int).T
+        refcoords = affine.transform(refcoords, ref.voxToScaledVoxMat)
+        srccoords = basefield.transform(refcoords)
+
+        field   = nonlinear.convertDeformationSpace(basefield, from_, to)
+        premat  = ref.getAffine('fsl', from_)
+        postmat = src.getAffine('fsl', to)
+
+        input  = affine.transform(refcoords, premat)
+        expect = affine.transform(srccoords, postmat)
+
+        got  = field.transform(input)
+        enan = np.isnan(expect)
+        gnan = np.isnan(got)
+
+        assert np.all(np.isclose(enan, gnan))
+        assert np.all(np.isclose(expect[~enan], got[~gnan]))
+
+
+def test_DeformationField_transform():
+
+    relfield, xform = _random_affine_field()
+    src             = relfield.src
+    ref             = relfield.ref
+
+    rx, ry, rz = np.meshgrid(np.arange(ref.shape[0]),
+                             np.arange(ref.shape[1]),
+                             np.arange(ref.shape[2]), indexing='ij')
+    rvoxels  = np.vstack((rx.flatten(), ry.flatten(), rz.flatten())).T
+    rcoords  = affine.transform(rvoxels, ref.voxToScaledVoxMat)
+    scoords  = affine.transform(rcoords, xform)
+    svoxels  = affine.transform(scoords, src.scaledVoxToVoxMat)
+
+    absfield    = np.zeros(list(ref.shape[:3]) + [3])
+    absfield[:] = scoords.reshape(*it.chain(ref.shape, [3]))
+    absfield    = nonlinear.DeformationField(absfield, src, ref,
+                                             header=ref.header,
+                                             defType='absolute')
+
+    got = relfield.transform(rcoords)
+    assert np.all(np.isclose(got, scoords))
+    got = absfield.transform(rcoords)
+    assert np.all(np.isclose(got, scoords))
+
+    got = relfield.transform(rvoxels, from_='voxel', to='voxel')
+    assert np.all(np.isclose(got, svoxels))
+    got = absfield.transform(rvoxels, from_='voxel', to='voxel')
+    assert np.all(np.isclose(got, svoxels))
+
+    # test out of bounds are returned as nan
+    rvoxels = np.array([[-1, -1, -1],
+                        [ 0,  0,  0]])
+    rcoords  = affine.transform(rvoxels, ref.voxToScaledVoxMat)
+    scoords  = affine.transform(rcoords, xform)
+    svoxels  = affine.transform(scoords, src.scaledVoxToVoxMat)
+
+    got = relfield.transform(rcoords)
+    assert np.all(np.isnan(got[0, :]))
+    assert np.all(np.isclose(got[1, :], scoords[1, :]))
+    got = absfield.transform(rcoords)
+    assert np.all(np.isnan(got[0, :]))
+    assert np.all(np.isclose(got[1, :], scoords[1, :]))
+
+
+def test_CoefficientField_displacements():
+
+    nldir = op.join(datadir, 'nonlinear')
+    src   = op.join(nldir, 'src.nii.gz')
+    ref   = op.join(nldir, 'ref.nii.gz')
+    cf    = op.join(nldir, 'coefficientfield.nii.gz')
+    df    = op.join(nldir, 'displacementfield_no_premat.nii.gz')
+
+    src = fslimage.Image(src)
+    ref = fslimage.Image(ref)
+    cf  = fnirt.readFnirt(cf, src, ref)
+    df  = fnirt.readFnirt(df, src, ref)
+
+    ix, iy, iz = ref.shape[:3]
+    x,  y,  z  = np.meshgrid(np.arange(ix),
+                             np.arange(iy),
+                             np.arange(iz), indexing='ij')
+    x          = x.flatten()
+    y          = y.flatten()
+    z          = z.flatten()
+    xyz        = np.vstack((x, y, z)).T
+
+    disps = cf.displacements(xyz)
+    disps = disps.reshape(df.shape)
+
+    tol = dict(atol=1e-5, rtol=1e-5)
+    assert np.all(np.isclose(disps, df.data, **tol))
+
+
+def test_CoefficientField_transform():
+    nldir = op.join(datadir, 'nonlinear')
+    src   = op.join(nldir, 'src.nii.gz')
+    ref   = op.join(nldir, 'ref.nii.gz')
+    cf    = op.join(nldir, 'coefficientfield.nii.gz')
+    df    = op.join(nldir, 'displacementfield.nii.gz')
+    dfnp  = op.join(nldir, 'displacementfield_no_premat.nii.gz')
+
+    src  = fslimage.Image(src)
+    ref  = fslimage.Image(ref)
+    cf   = fnirt.readFnirt(cf,   src, ref)
+    df   = fnirt.readFnirt(df,   src, ref)
+    dfnp = fnirt.readFnirt(dfnp, src, ref)
+
+    spaces = ['fsl', 'voxel', 'world']
+    spaces = list(it.combinations_with_replacement(spaces, 2))
+    spaces = spaces + [(r, s) for s, r in spaces]
+    spaces = list(set(spaces))
+
+    rx, ry, rz = np.meshgrid(np.arange(ref.shape[0]),
+                             np.arange(ref.shape[1]),
+                             np.arange(ref.shape[2]), indexing='ij')
+    rvoxels  = np.vstack((rx.flatten(), ry.flatten(), rz.flatten())).T
+
+    refcoords = {
+        'voxel' : rvoxels,
+        'fsl'   : affine.transform(rvoxels, ref.getAffine('voxel', 'fsl')),
+        'world' : affine.transform(rvoxels, ref.getAffine('voxel', 'world'))
+    }
+
+    srccoords = refcoords['fsl'] + df.data.reshape(-1, 3)
+    srccoords = {
+        'voxel' : affine.transform(srccoords, src.getAffine('fsl', 'voxel')),
+        'fsl'   : srccoords,
+        'world' : affine.transform(srccoords, src.getAffine('fsl', 'world'))
+    }
+
+    srccoordsnp = refcoords['fsl'] + dfnp.data.reshape(-1, 3)
+    srccoordsnp = {
+        'voxel' : affine.transform(srccoordsnp, src.getAffine('fsl', 'voxel')),
+        'fsl'   : srccoordsnp,
+        'world' : affine.transform(srccoordsnp, src.getAffine('fsl', 'world'))
+    }
+
+    tol = dict(atol=1e-5, rtol=1e-5)
+    for srcspace, refspace in spaces:
+        got   = cf.transform(refcoords[refspace], refspace, srcspace)
+        gotnp = cf.transform(refcoords[refspace], refspace, srcspace,
+                             premat=False)
+        assert np.all(np.isclose(got,   srccoords[  srcspace], **tol))
+        assert np.all(np.isclose(gotnp, srccoordsnp[srcspace], **tol))
+
+
+def test_coefficientFieldToDeformationField():
+
+    nldir = op.join(datadir, 'nonlinear')
+    src   = op.join(nldir, 'src.nii.gz')
+    ref   = op.join(nldir, 'ref.nii.gz')
+    cf    = op.join(nldir, 'coefficientfield.nii.gz')
+    df    = op.join(nldir, 'displacementfield.nii.gz')
+    dfnp  = op.join(nldir, 'displacementfield_no_premat.nii.gz')
+
+    src   = fslimage.Image(src)
+    ref   = fslimage.Image(ref)
+    cf    = fnirt.readFnirt(cf,   src, ref)
+    rdf   = fnirt.readFnirt(df,   src, ref)
+    rdfnp = fnirt.readFnirt(dfnp, src, ref)
+    adf   = nonlinear.convertDeformationType(rdf)
+    adfnp = nonlinear.convertDeformationType(rdfnp)
+
+    rcnv   = nonlinear.coefficientFieldToDeformationField(cf)
+    acnv   = nonlinear.coefficientFieldToDeformationField(cf,
+                                                          defType='absolute')
+    acnvnp = nonlinear.coefficientFieldToDeformationField(cf,
+                                                          defType='absolute',
+                                                          premat=False)
+    rcnvnp = nonlinear.coefficientFieldToDeformationField(cf,
+                                                          premat=False)
+
+    tol = dict(atol=1e-5, rtol=1e-5)
+    assert np.all(np.isclose(rcnv.data,   rdf  .data, **tol))
+    assert np.all(np.isclose(acnv.data,   adf  .data, **tol))
+    assert np.all(np.isclose(rcnvnp.data, rdfnp.data, **tol))
+    assert np.all(np.isclose(acnvnp.data, adfnp.data, **tol))
+
+
+def test_applyDeformation():
+
+    src2ref = affine.compose(
+        np.random.randint(2, 5, 3),
+        np.random.randint(1, 10, 3),
+        np.random.random(3))
+    ref2src = affine.invert(src2ref)
+
+    srcdata = np.random.randint(1, 65536, (10, 10, 10))
+    refdata = np.random.randint(1, 65536, (10, 10, 10))
+
+    src   = fslimage.Image(srcdata)
+    ref   = fslimage.Image(refdata, xform=src2ref)
+    field = _affine_field(src, ref, ref2src, 'world', 'world')
+
+    expect, xf = resample.resampleToReference(
+        src, ref, matrix=src2ref, order=1, mode='nearest')
+    result = nonlinear.applyDeformation(
+        src, field, order=1, mode='nearest')
+
+    assert np.all(np.isclose(expect, result))
+
+
+def test_applyDeformation_altsrc():
+
+    src2ref = affine.compose(
+        np.random.randint(2, 5, 3),
+        np.random.randint(1, 10, 3),
+        [0, 0, 0])
+    ref2src = affine.invert(src2ref)
+
+    srcdata = np.random.randint(1, 65536, (10, 10, 10))
+    refdata = np.random.randint(1, 65536, (10, 10, 10))
+
+    src   = fslimage.Image(srcdata)
+    ref   = fslimage.Image(refdata, xform=src2ref)
+    field = _affine_field(src, ref, ref2src, 'world', 'world')
+
+    # First try a down-sampled version
+    # of the original source image
+    altsrc, xf = resample.resample(src, (5, 5, 5), origin='corner')
+    altsrc     = fslimage.Image(altsrc, xform=xf, header=src.header)
+    expect, xf = resample.resampleToReference(
+        altsrc, ref, matrix=src2ref, order=1, mode='nearest')
+    result = nonlinear.applyDeformation(
+        altsrc, field, order=1, mode='nearest')
+    assert np.all(np.isclose(expect, result))
+
+    # Now try a down-sampled ROI
+    # of the original source image
+    altsrc     = roi.roi(src, [(2, 9), (2, 9), (2, 9)])
+    altsrc, xf = resample.resample(altsrc, (4, 4, 4))
+    altsrc     = fslimage.Image(altsrc, xform=xf, header=src.header)
+    expect, xf = resample.resampleToReference(
+        altsrc, ref, matrix=src2ref, order=1, mode='nearest')
+    result = nonlinear.applyDeformation(
+        altsrc, field, order=1, mode='nearest')
+    assert np.all(np.isclose(expect, result))
+
+    # down-sampled and offset ROI
+    # of the original source image
+    altsrc     = roi.roi(src, [(-5, 8), (-5, 8), (-5, 8)])
+    altsrc, xf = resample.resample(altsrc, (6, 6, 6))
+    altsrc     = fslimage.Image(altsrc, xform=xf, header=src.header)
+    expect, xf = resample.resampleToReference(
+        altsrc, ref, matrix=src2ref, order=1, mode='nearest')
+    result = nonlinear.applyDeformation(
+        altsrc, field, order=1, mode='nearest')
+    assert np.all(np.isclose(expect, result))
+
+
+def test_applyDeformation_altref():
+    src2ref = affine.compose(
+        np.random.randint(2, 5, 3),
+        np.random.randint(1, 10, 3),
+        np.random.random(3))
+    ref2src = affine.invert(src2ref)
+
+    srcdata = np.random.randint(1, 65536, (10, 10, 10))
+    refdata = np.random.randint(1, 65536, (10, 10, 10))
+
+    src   = fslimage.Image(srcdata)
+    ref   = fslimage.Image(refdata, xform=src2ref)
+    field = _affine_field(src, ref, ref2src, 'world', 'world')
+
+    altrefxform = affine.concat(
+        src2ref,
+        affine.scaleOffsetXform([1, 1, 1], [5, 0, 0]))
+
+    altref = fslimage.Image(refdata, xform=altrefxform)
+
+    expect, xf = resample.resampleToReference(
+        src, altref, matrix=src2ref, order=1, mode='constant', cval=0)
+    result = nonlinear.applyDeformation(
+        src, field, ref=altref, order=1, mode='constant', cval=0)
+
+    # boundary voxels can get truncated
+    # (4 is the altref-ref overlap boundary)
+    expect[4, :, :] = 0
+    result[4, :, :] = 0
+    expect = expect[1:-1, 1:-1, 1:-1]
+    result = result[1:-1, 1:-1, 1:-1]
+
+    assert np.all(np.isclose(expect, result))
+
+
+# test when reference/field
+# are not voxel-aligned
+def test_applyDeformation_worldAligned():
+    refv2w   = affine.scaleOffsetXform([1, 1, 1], [10,   10,   10])
+    fieldv2w = affine.scaleOffsetXform([2, 2, 2], [10.5, 10.5, 10.5])
+    src2ref  = refv2w
+    ref2src  = affine.invert(src2ref)
+
+    srcdata = np.random.randint(1, 65536, (10, 10, 10))
+
+    src   = fslimage.Image(srcdata)
+    ref   = fslimage.Image(srcdata, xform=src2ref)
+    field = _affine_field(src, ref, ref2src, 'world', 'world',
+                          shape=(5, 5, 5), fv2w=fieldv2w)
+
+    field = nonlinear.DeformationField(
+        nonlinear.convertDeformationType(field, 'absolute'),
+        header=field.header,
+        src=src,
+        ref=ref,
+        srcSpace='world',
+        refSpace='world',
+        defType='absolute')
+
+    expect, xf = resample.resampleToReference(
+        src, ref, matrix=src2ref, order=1, mode='constant', cval=0)
+    result = nonlinear.applyDeformation(
+        src, field, order=1, mode='constant', cval=0)
+
+    expect = expect[1:-1, 1:-1, 1:-1]
+    result = result[1:-1, 1:-1, 1:-1]
+
+    assert np.all(np.isclose(expect, result))
diff --git a/tests/test_transform/test_x5.py b/tests/test_transform/test_x5.py
new file mode 100644
index 0000000000000000000000000000000000000000..3a92ece655197f3fa77051c858b9fcd03bc7bb57
--- /dev/null
+++ b/tests/test_transform/test_x5.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python
+#
+# test_x5.py -
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+
+
+import os.path as op
+import numpy as np
+
+import pytest
+
+import h5py
+
+import fsl.data.image          as fslimage
+import fsl.utils.tempdir       as tempdir
+import fsl.transform.affine    as affine
+import fsl.transform.fnirt     as fnirt
+import fsl.transform.nonlinear as nonlinear
+import fsl.transform.x5        as x5
+
+from .. import make_random_image
+
+
+def _check_metadata(group):
+    assert group.attrs['Format']  == x5.X5_FORMAT
+    assert group.attrs['Version'] == x5.X5_VERSION
+
+
+def _check_affine(group, xform):
+    assert group.attrs['Type'] == 'affine'
+    gotxform = np.array(group['Matrix'])
+    assert np.all(np.isclose(gotxform, xform))
+
+
+def _check_space(group, img):
+    assert group.attrs['Type'] == 'image'
+    assert np.all(np.isclose(group.attrs['Size'],   img.shape[ :3]))
+    assert np.all(np.isclose(group.attrs['Scales'], img.pixdim[:3]))
+    _check_affine(group['Mapping'], img.voxToWorldMat)
+
+
+def _check_deformation(group, field):
+    assert group.attrs['Type'] == 'deformation'
+    assert group.attrs['SubType'] == field.deformationType
+    xform = np.array(group['Matrix'])
+    assert np.all(np.isclose(xform, field.data))
+    _check_affine(group['Mapping'], field.voxToWorldMat)
+
+
+def test_readWriteLinearX5():
+    with tempdir.tempdir():
+        make_random_image('src.nii')
+        make_random_image('ref.nii')
+        xform = affine.compose(
+            np.random.randint(1, 5, 3),
+            np.random.randint(-10, 10, 3),
+            -np.pi / 4 + np.random.random(3) * np.pi / 2)
+
+        src = fslimage.Image('src.nii')
+        ref = fslimage.Image('ref.nii')
+
+        x5.writeLinearX5('linear.x5', xform, src, ref)
+
+        gotxform, gotsrc, gotref = x5.readLinearX5('linear.x5')
+        assert np.all(np.isclose(gotxform, xform))
+        assert gotsrc.sameSpace(src)
+        assert gotref.sameSpace(ref)
+
+        with h5py.File('linear.x5', 'r') as f:
+            _check_metadata(f)
+            assert f.attrs['Type'] == 'linear'
+            _check_affine(f['/Transform'], xform)
+            _check_space( f['/A'],         src)
+            _check_space( f['/B'],         ref)
+
+
+def test_readWriteNonLinearX5():
+    datadir = op.join(op.dirname(__file__), 'testdata', 'nonlinear')
+    dffile  = op.join(datadir, 'displacementfield.nii.gz')
+    srcfile = op.join(datadir, 'src.nii.gz')
+    reffile = op.join(datadir, 'ref.nii.gz')
+
+    src     = fslimage.Image(srcfile)
+    ref     = fslimage.Image(reffile)
+    dfield  = fnirt.readFnirt(dffile, src, ref)
+    wdfield = nonlinear.convertDeformationSpace(dfield, 'world', 'world')
+
+    with tempdir.tempdir():
+
+        # field must be world->world
+        with pytest.raises(x5.X5Error):
+            x5.writeNonLinearX5('nonlinear.x5', dfield)
+
+        x5.writeNonLinearX5('nonlinear.x5', wdfield)
+
+        gotdfield = x5.readNonLinearX5('nonlinear.x5')
+
+        assert gotdfield.src.sameSpace(src)
+        assert gotdfield.ref.sameSpace(ref)
+        assert gotdfield.srcSpace == wdfield.srcSpace
+        assert gotdfield.refSpace == wdfield.refSpace
+        assert gotdfield.deformationType == wdfield.deformationType
+        assert np.all(np.isclose(gotdfield.data, wdfield.data))
+
+        with h5py.File('nonlinear.x5', 'r') as f:
+            assert f.attrs['Type'] == 'nonlinear'
+            _check_metadata(f)
+            _check_deformation(f['/Transform'], wdfield)
+            _check_space(      f['/A'],         ref)
+            _check_space(      f['/B'],         src)
diff --git a/tests/test_transform/testdata/nonlinear/coefficientfield.nii.gz b/tests/test_transform/testdata/nonlinear/coefficientfield.nii.gz
new file mode 100644
index 0000000000000000000000000000000000000000..8d4b58668d2f100819ccf6263a1e2c77286b1bd0
Binary files /dev/null and b/tests/test_transform/testdata/nonlinear/coefficientfield.nii.gz differ
diff --git a/tests/test_transform/testdata/nonlinear/displacementfield.nii.gz b/tests/test_transform/testdata/nonlinear/displacementfield.nii.gz
new file mode 100644
index 0000000000000000000000000000000000000000..f1e28a1bd1c072de4720add4401699b8dcb3a62e
Binary files /dev/null and b/tests/test_transform/testdata/nonlinear/displacementfield.nii.gz differ
diff --git a/tests/test_transform/testdata/nonlinear/displacementfield_no_premat.nii.gz b/tests/test_transform/testdata/nonlinear/displacementfield_no_premat.nii.gz
new file mode 100644
index 0000000000000000000000000000000000000000..26578f9dba2911634789e2d8d2ebac8ad8d5224f
Binary files /dev/null and b/tests/test_transform/testdata/nonlinear/displacementfield_no_premat.nii.gz differ
diff --git a/tests/test_transform/testdata/nonlinear/ref.nii.gz b/tests/test_transform/testdata/nonlinear/ref.nii.gz
new file mode 100644
index 0000000000000000000000000000000000000000..ee4f0a98a8cd4cc12a5d1039b36a73419fdcffdc
Binary files /dev/null and b/tests/test_transform/testdata/nonlinear/ref.nii.gz differ
diff --git a/tests/test_transform/testdata/nonlinear/src.nii.gz b/tests/test_transform/testdata/nonlinear/src.nii.gz
new file mode 100644
index 0000000000000000000000000000000000000000..01b6290f590a8d5110e71978e4dbbfe90fc475ed
Binary files /dev/null and b/tests/test_transform/testdata/nonlinear/src.nii.gz differ
diff --git a/tests/test_transform/testdata/nonlinear/src2ref.mat b/tests/test_transform/testdata/nonlinear/src2ref.mat
new file mode 100644
index 0000000000000000000000000000000000000000..521dd0b7411a1fe958d9d4feb910be9442647f68
--- /dev/null
+++ b/tests/test_transform/testdata/nonlinear/src2ref.mat
@@ -0,0 +1,4 @@
+1.130654884  -0.02090760163  0.05273442572  -0.523881946  
+0.01777234539  0.9990179929  0.2039857328  5.806280637  
+-0.003319001082  -0.2740303429  1.071017063  0.4915523243  
+0  0  0  1  
diff --git a/tests/testdata/test_transform_test_axisBounds.txt b/tests/test_transform/testdata/test_transform_test_axisBounds.txt
similarity index 100%
rename from tests/testdata/test_transform_test_axisBounds.txt
rename to tests/test_transform/testdata/test_transform_test_axisBounds.txt
diff --git a/tests/testdata/test_transform_test_compose.txt b/tests/test_transform/testdata/test_transform_test_compose.txt
similarity index 100%
rename from tests/testdata/test_transform_test_compose.txt
rename to tests/test_transform/testdata/test_transform_test_compose.txt
diff --git a/tests/testdata/test_transform_test_concat.txt b/tests/test_transform/testdata/test_transform_test_concat.txt
similarity index 100%
rename from tests/testdata/test_transform_test_concat.txt
rename to tests/test_transform/testdata/test_transform_test_concat.txt
diff --git a/tests/testdata/test_transform_test_flirtMatrixToSform.txt b/tests/test_transform/testdata/test_transform_test_flirtMatrixToSform.txt
similarity index 100%
rename from tests/testdata/test_transform_test_flirtMatrixToSform.txt
rename to tests/test_transform/testdata/test_transform_test_flirtMatrixToSform.txt
diff --git a/tests/testdata/test_transform_test_invert.txt b/tests/test_transform/testdata/test_transform_test_invert.txt
similarity index 100%
rename from tests/testdata/test_transform_test_invert.txt
rename to tests/test_transform/testdata/test_transform_test_invert.txt
diff --git a/tests/testdata/test_transform_test_scaleoffsetxform.txt b/tests/test_transform/testdata/test_transform_test_scaleoffsetxform.txt
similarity index 100%
rename from tests/testdata/test_transform_test_scaleoffsetxform.txt
rename to tests/test_transform/testdata/test_transform_test_scaleoffsetxform.txt
diff --git a/tests/testdata/test_transform_test_transform_00.txt b/tests/test_transform/testdata/test_transform_test_transform_00.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_00.txt
rename to tests/test_transform/testdata/test_transform_test_transform_00.txt
diff --git a/tests/testdata/test_transform_test_transform_01.txt b/tests/test_transform/testdata/test_transform_test_transform_01.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_01.txt
rename to tests/test_transform/testdata/test_transform_test_transform_01.txt
diff --git a/tests/testdata/test_transform_test_transform_02.txt b/tests/test_transform/testdata/test_transform_test_transform_02.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_02.txt
rename to tests/test_transform/testdata/test_transform_test_transform_02.txt
diff --git a/tests/testdata/test_transform_test_transform_03.txt b/tests/test_transform/testdata/test_transform_test_transform_03.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_03.txt
rename to tests/test_transform/testdata/test_transform_test_transform_03.txt
diff --git a/tests/testdata/test_transform_test_transform_04.txt b/tests/test_transform/testdata/test_transform_test_transform_04.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_04.txt
rename to tests/test_transform/testdata/test_transform_test_transform_04.txt
diff --git a/tests/testdata/test_transform_test_transform_05.txt b/tests/test_transform/testdata/test_transform_test_transform_05.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_05.txt
rename to tests/test_transform/testdata/test_transform_test_transform_05.txt
diff --git a/tests/testdata/test_transform_test_transform_06.txt b/tests/test_transform/testdata/test_transform_test_transform_06.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_06.txt
rename to tests/test_transform/testdata/test_transform_test_transform_06.txt
diff --git a/tests/testdata/test_transform_test_transform_07.txt b/tests/test_transform/testdata/test_transform_test_transform_07.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_07.txt
rename to tests/test_transform/testdata/test_transform_test_transform_07.txt
diff --git a/tests/testdata/test_transform_test_transform_08.txt b/tests/test_transform/testdata/test_transform_test_transform_08.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_08.txt
rename to tests/test_transform/testdata/test_transform_test_transform_08.txt
diff --git a/tests/testdata/test_transform_test_transform_09.txt b/tests/test_transform/testdata/test_transform_test_transform_09.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_09.txt
rename to tests/test_transform/testdata/test_transform_test_transform_09.txt
diff --git a/tests/testdata/test_transform_test_transform_10.txt b/tests/test_transform/testdata/test_transform_test_transform_10.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_10.txt
rename to tests/test_transform/testdata/test_transform_test_transform_10.txt
diff --git a/tests/testdata/test_transform_test_transform_11.txt b/tests/test_transform/testdata/test_transform_test_transform_11.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_11.txt
rename to tests/test_transform/testdata/test_transform_test_transform_11.txt
diff --git a/tests/testdata/test_transform_test_transform_12.txt b/tests/test_transform/testdata/test_transform_test_transform_12.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_12.txt
rename to tests/test_transform/testdata/test_transform_test_transform_12.txt
diff --git a/tests/testdata/test_transform_test_transform_13.txt b/tests/test_transform/testdata/test_transform_test_transform_13.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_13.txt
rename to tests/test_transform/testdata/test_transform_test_transform_13.txt
diff --git a/tests/testdata/test_transform_test_transform_14.txt b/tests/test_transform/testdata/test_transform_test_transform_14.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_14.txt
rename to tests/test_transform/testdata/test_transform_test_transform_14.txt
diff --git a/tests/testdata/test_transform_test_transform_15.txt b/tests/test_transform/testdata/test_transform_test_transform_15.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_15.txt
rename to tests/test_transform/testdata/test_transform_test_transform_15.txt
diff --git a/tests/testdata/test_transform_test_transform_16.txt b/tests/test_transform/testdata/test_transform_test_transform_16.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_16.txt
rename to tests/test_transform/testdata/test_transform_test_transform_16.txt
diff --git a/tests/testdata/test_transform_test_transform_17.txt b/tests/test_transform/testdata/test_transform_test_transform_17.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_17.txt
rename to tests/test_transform/testdata/test_transform_test_transform_17.txt
diff --git a/tests/testdata/test_transform_test_transform_18.txt b/tests/test_transform/testdata/test_transform_test_transform_18.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_18.txt
rename to tests/test_transform/testdata/test_transform_test_transform_18.txt
diff --git a/tests/testdata/test_transform_test_transform_19.txt b/tests/test_transform/testdata/test_transform_test_transform_19.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_19.txt
rename to tests/test_transform/testdata/test_transform_test_transform_19.txt
diff --git a/tests/testdata/test_transform_test_transform_20.txt b/tests/test_transform/testdata/test_transform_test_transform_20.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_20.txt
rename to tests/test_transform/testdata/test_transform_test_transform_20.txt
diff --git a/tests/testdata/test_transform_test_transform_coords.txt b/tests/test_transform/testdata/test_transform_test_transform_coords.txt
similarity index 100%
rename from tests/testdata/test_transform_test_transform_coords.txt
rename to tests/test_transform/testdata/test_transform_test_transform_coords.txt