diff --git a/fsl/data/image.py b/fsl/data/image.py
index 80cffb6a97efb7964c32d846af9715c19d3f5d0a..12deb658e26c6cb2bb166a8ac548b32a082a2ba3 100644
--- a/fsl/data/image.py
+++ b/fsl/data/image.py
@@ -47,14 +47,14 @@ import numpy             as np
 import nibabel           as nib
 import nibabel.fileslice as fileslice
 
-import fsl.utils.meta        as meta
-import fsl.transform.affine  as affine
-import fsl.utils.notifier    as notifier
-import fsl.utils.memoize     as memoize
-import fsl.utils.path        as fslpath
-import fsl.utils.bids        as fslbids
-import fsl.data.constants    as constants
-import fsl.data.imagewrapper as imagewrapper
+import fsl.utils.meta       as meta
+import fsl.utils.deprecated as deprecated
+import fsl.transform.affine as affine
+import fsl.utils.notifier   as notifier
+import fsl.utils.memoize    as memoize
+import fsl.utils.path       as fslpath
+import fsl.utils.bids       as fslbids
+import fsl.data.constants   as constants
 
 
 PathLike    = Union[str, Path]
@@ -93,6 +93,31 @@ Made available in this module for convenience.
 """
 
 
+class DataManager(notifier.Notifier):
+    """The ``DataManager`` defines an interface which may be used by
+    :class:`Image` instances for managing access and modification of
+    data in a ``nibabel.Nifti1Image`` image.
+    """
+
+
+    def copy(self, nibImage : nib.Nifti1Image):
+        """Return a copy of this ``DataManager``, associated with the
+        given ``nibImage``,
+        """
+        raise NotImplementedError()
+
+
+    def __getitem__(self, slc):
+        """Return data at ``slc``. """
+        raise NotImplementedError()
+
+
+    def __setitem__(self, slc, val):
+        """Set data at ``slc`` to ``val``. """
+        raise NotImplementedError()
+
+
+
 class Nifti(notifier.Notifier, meta.Meta):
     """The ``Nifti`` class is intended to be used as a base class for
     things which either are, or are associated with, a NIFTI image.
@@ -941,8 +966,9 @@ class Nifti(notifier.Notifier, meta.Meta):
 class Image(Nifti):
     """Class which represents a NIFTI image. Internally, the image is
     loaded/stored using a :mod:`nibabel.nifti1.Nifti1Image` or
-    :mod:`nibabel.nifti2.Nifti2Image`, and data access managed by a
-    :class:`.ImageWrapper`.
+    :mod:`nibabel.nifti2.Nifti2Image`. This class adds functionality for
+    loading metadata from JSON sidecar files, and for keeping track of
+    modifications to the image data.
 
 
     In addition to the attributes added by the :meth:`Nifti.__init__` method,
@@ -963,15 +989,43 @@ class Image(Nifti):
     ``saveState``  A boolean value which is ``True`` if this image is
                    saved to disk, ``False`` if it is in-memory, or has
                    been edited.
-
-    ``dataRange``  The minimum/maximum values in the image. Depending upon
-                   the value of the ``calcRange`` parameter to
-                   :meth:`__init__`, this may be calculated when the ``Image``
-                   is created, or may be incrementally updated as more image
-                   data is loaded from disk.
     ============== ===========================================================
 
 
+    The ``Image`` class supports access to and assignment of the image data
+    via the ``[]`` slice operator, e.g.::
+
+        img             = Image('image.nii.gz')
+        val             = img[20, 30, 25]
+        img[30, 40, 20] = 999
+
+    Internally, the image data is managed using one of the following methods:
+
+     1. For read-only access, the ``Image`` class delegates
+        entirely to the underlying ``nibabel`` ``Nifti1Image``
+        instance - refer to
+        https://nipy.org/nibabel/nibabel_images.html#the-image-data-array
+        for more details.
+
+     2. As soon as any data is modified, the ``Image`` class will
+        load the image data as a numpy array into memory and will maintain its
+        own reference to the array for subsequent access.
+
+     3. For more complicated requirements, a :class:`DataManager`,
+        implementing custom data access management logic, can be provided when
+        an ``Image`` is created, . If a ``DataManager``is provided, an
+        internal reference to the data (see 2 above) will **not** be created or
+        maintained.
+
+
+    It is also possible to obtain a reference to a numpy array containing
+    the image data via the :meth:`data` method. However, modifications to
+    the returned array:
+
+      - will not result in any notifications (described below)
+      - will not affect the value of :meth:`saveState`
+
+
     The ``Image`` class adds some :class:`.Notifier` topics to those which are
     already provided by the :class:`Nifti` class - listeners may register to
     be notified of changes to the above properties, by registering on the
@@ -989,8 +1043,7 @@ class Image(Nifti):
                     image changes (i.e. data or ``voxToWorldMat`` is
                     edited, or the image saved to disk).
 
-    ``'dataRange'`` This topic is notified whenever the image data range
-                    is changed/adjusted.
+    ``'dataRange'`` Deprecated - No notifications are made on this topic.
     =============== ======================================================
     """
 
@@ -1000,11 +1053,12 @@ class Image(Nifti):
                  name       : str              = None,
                  header     : nib.Nifti1Header = None,
                  xform      : np.ndarray       = None,
-                 loadData   : bool             = True,
-                 calcRange  : bool             = True,
-                 threaded   : bool             = False,
+                 loadData   : bool             = None,
+                 calcRange  : bool             = None,
+                 threaded   : bool             = None,
                  dataSource : PathLike         = None,
                  loadMeta   : bool             = False,
+                 dataMgr    : DataManager      = None,
                  **kwargs):
         """Create an ``Image`` object with the given image data or file name.
 
@@ -1030,24 +1084,11 @@ class Image(Nifti):
                          ``header`` are provided, the ``xform`` is used in
                          preference to the header transformation.
 
-        :arg loadData:   If ``True`` (the default) the image data is loaded
-                         in to memory.  Otherwise, only the image header
-                         information is read, and the image data is kept
-                         from disk. In either case, the image data is
-                         accessed through an :class:`.ImageWrapper` instance.
-                         The data may be loaded into memory later on via the
-                         :meth:`loadData` method.
-
-        :arg calcRange:  If ``True`` (the default), the image range is
-                         calculated immediately (vi a call to
-                         :meth:`calcRange`). Otherwise, the image range is
-                         incrementally updated as more data is read from memory
-                         or disk. If ``loadData=False``, ``calcRange`` is also
-                         set to ``False``.
-
-        :arg threaded:   If ``True``, the :class:`.ImageWrapper` will use a
-                         separate thread for data range calculation. Defaults
-                         to ``False``. Ignored if ``loadData`` is ``True``.
+        :arg loadData:   Deprecated, has no effect
+
+        :arg calcRange:  Deprecated, has no effect
+
+        :arg threaded:   Deprecated, has no effect
 
         :arg dataSource: If ``image`` is not a file name, this argument may be
                          used to specify the file from which the image was
@@ -1059,19 +1100,26 @@ class Image(Nifti):
                          can be loaded at a later stage via the
                          :func:`loadMeta` function. Defaults to ``False``.
 
+        :arg dataMgr:    Object implementing the :class:`DataManager`
+                         interface, for managing access to the image data.
+
         All other arguments are passed through to the ``nibabel.load`` function
         (if it is called).
         """
 
+        if threaded is not None:
+            deprecated.warn('Image(threadd)', vin='3.9.0', rin='4.0.0',
+                            msg='The threaded option has no effect')
+        if loadData is not None:
+            deprecated.warn('Image(loadData)', vin='3.9.0', rin='4.0.0',
+                            msg='The loadData option has no effect')
+        if calcRange is not None:
+            deprecated.warn('Image(calcRange)', vin='3.9.0', rin='4.0.0',
+                            msg='The calcRange option has no effect')
+
         nibImage = None
         saved    = False
 
-        # disable threaded access if loadData is True
-        threaded = threaded and (not loadData)
-
-        # don't calcRange if not loading data
-        calcRange = calcRange and loadData
-
         # Take a copy of the header if one has
         # been provided
         #
@@ -1161,16 +1209,13 @@ class Image(Nifti):
 
         Nifti.__init__(self, nibImage.header)
 
-        self.name           = name
-        self.__lName        = '{}_{}'.format(id(self), self.name)
-        self.__dataSource   = dataSource
-        self.__threaded     = threaded
-        self.__nibImage     = nibImage
-        self.__saveState    = saved
-        self.__imageWrapper = imagewrapper.ImageWrapper(self.nibImage,
-                                                        self.name,
-                                                        loadData=loadData,
-                                                        threaded=threaded)
+        self.name         = name
+        self.__lName      = '{}_{}'.format(id(self), self.name)
+        self.__dataSource = dataSource
+        self.__nibImage   = nibImage
+        self.__saveState  = saved
+        self.__dataMgr    = dataMgr
+        self.__data       = None
 
         # Listen to ourself for changes
         # to header attributse so we
@@ -1178,11 +1223,6 @@ class Image(Nifti):
         self.register(self.name, self.__headerChanged, topic='transform')
         self.register(self.name, self.__headerChanged, topic='header')
 
-        # calculate min/max
-        # of image data
-        if calcRange:
-            self.calcRange()
-
         # try and load metadata
         # from JSON sidecar files
         if self.dataSource is not None and loadMeta:
@@ -1192,8 +1232,6 @@ class Image(Nifti):
                 log.warning('Failed to load metadata for %s: %s',
                             self.dataSource, e)
 
-        self.__imageWrapper.register(self.__lName, self.__dataRangeChanged)
-
 
     def __hash__(self):
         """Returns a number which uniquely idenfities this ``Image`` instance
@@ -1216,17 +1254,31 @@ class Image(Nifti):
 
     def __del__(self):
         """Closes any open file handles, and clears some references. """
+
+        # Nifti class may have
+        # been GC'd at shutdown
         if Nifti is not None:
             Nifti.__del__(self)
-        self.__nibImage     = None
-        self.__imageWrapper = None
+        self.__nibImage = None
+        self.__dataMgr  = None
+        self.__data     = None
 
 
+    @deprecated.deprecated('3.9.0', '4.0.0',
+                           'The Image class no longer uses an ImageWrapper')
     def getImageWrapper(self):
         """Returns the :class:`.ImageWrapper` instance used to manage
         access to the image data.
         """
-        return self.__imageWrapper
+        return None
+
+
+    @property
+    def dataManager(self):
+        """Return the :class:`.DataManager` associated with this ``Image``,
+        if one was specified when it was created.
+        """
+        return self.__dataMgr
 
 
     @property
@@ -1251,11 +1303,17 @@ class Image(Nifti):
     def data(self):
         """Returns the image data as a ``numpy`` array.
 
-        .. warning:: Calling this method will cause the entire image to be
+        .. warning:: Calling this method may cause the entire image to be
                      loaded into memory.
         """
-        self.__imageWrapper.loadData()
-        return self[:]
+
+        if self.__dataMgr is not None:
+            return self[:]
+
+        if self.__data is not None:
+            self.__data = self[:]
+
+        return self.__data
 
 
     @property
@@ -1267,27 +1325,10 @@ class Image(Nifti):
 
 
     @property
+    @deprecated.deprecated('3.9.0', '4.0.0', 'Use a DataManager')
     def dataRange(self):
-        """Returns the image data range as a  ``(min, max)`` tuple. If the
-        ``calcRange`` parameter to :meth:`__init__` was ``False``, these
-        values may not be accurate, and may change as more image data is
-        accessed.
-
-        If the data range has not been no data has been accessed,
-        ``(None, None)`` is returned.
-        """
-        if self.__imageWrapper is None: drange = (None, None)
-        else:                           drange = self.__imageWrapper.dataRange
-
-        # Fall back to the cal_min/max
-        # fields in the NIFTI header
-        # if we don't yet know anything
-        # about the image data range.
-        if drange[0] is None or drange[1] is None:
-            drange = (float(self.header['cal_min']),
-                      float(self.header['cal_max']))
-
-        return drange
+        """Deprecated, always returns ``(None, None)``. """
+        return None, None
 
 
     @property
@@ -1346,53 +1387,14 @@ class Image(Nifti):
             self.notify(topic='saveState')
 
 
-    def __dataRangeChanged(self, *args, **kwargs):
-        """Called when the :class:`.ImageWrapper` data range changes.
-        Notifies any listeners of this ``Image`` (registered through the
-        :class:`.Notifier` interface) on the ``'dataRange'`` topic.
-        """
-        self.notify(topic='dataRange')
-
-
-    def calcRange(self, sizethres=None):
-        """Forces calculation of the image data range.
-
-        :arg sizethres: If not ``None``, specifies an image size threshold
-                        (total number of bytes). If the number of bytes in
-                        the image is greater than this threshold, the range
-                        is calculated on a sample (the first volume for a
-                        4D image, or slice for a 3D image).
-        """
-
-        # The ImageWrapper automatically calculates
-        # the range of the specified slice, whenever
-        # it gets indexed. All we have to do is
-        # access a portion of the data to trigger the
-        # range calculation.
-        nbytes = np.prod(self.shape) * self.dtype.itemsize
-
-        # If an image size threshold has not been specified,
-        # then we'll calculate the full data range right now.
-        if sizethres is None or nbytes < sizethres:
-            log.debug('%s: Forcing calculation of full '
-                      'data range', self.name)
-            self.__imageWrapper[:]
-
-        else:
-            log.debug('%s: Calculating data range '
-                      'from sample', self.name)
-
-            # Otherwise if the number of values in the
-            # image is bigger than the size threshold,
-            # we'll calculate the range from a sample:
-            self.__imageWrapper[..., 0]
+    @deprecated.deprecated('3.9.0', '4.0.0', 'calcRange has no effect')
+    def calcRange(self, *args, **kwargs):
+        """Deprecated, has no effect """
 
 
+    @deprecated.deprecated('3.9.0', '4.0.0', 'loadData has no effect')
     def loadData(self):
-        """Makes sure that the image data is loaded into memory.
-        See :meth:`.ImageWrapper.loadData`.
-        """
-        self.__imageWrapper.loadData()
+        """Deprecated, has no effect """
 
 
     def save(self, filename=None):
@@ -1433,14 +1435,14 @@ class Image(Nifti):
             # First of all, the nibabel object won't know
             # about any image data modifications, so if
             # any have occurred, we need to create a new
-            # nibabel image using the data managed by the
-            # imagewrapper, and the old header.
+            # nibabel image using our copy of the data,
+            # and the old header.
             #
             # Assuming here that analyze/nifti1/nifti2
             # nibabel classes have an __init__ which
             # expects (data, affine, header)
             if not self.saveState:
-                self.__nibImage = type(self.__nibImage)(self[:],
+                self.__nibImage = type(self.__nibImage)(self.data,
                                                         None,
                                                         self.header)
                 self.header     = self.__nibImage.header
@@ -1458,18 +1460,11 @@ class Image(Nifti):
             os.remove(tmpfname)
 
         # Because we've created a new nibabel image,
-        # we have to create a new ImageWrapper
+        # we may have to create a new DataManager
         # instance too, as we have just destroyed
-        # the nibabel image we gave to the last
-        # one.
-        self.__imageWrapper.deregister(self.__lName)
-        self.__imageWrapper = imagewrapper.ImageWrapper(
-            self.nibImage,
-            self.name,
-            loadData=False,
-            dataRange=self.dataRange,
-            threaded=self.__threaded)
-        self.__imageWrapper.register(self.__lName, self.__dataRangeChanged)
+        # the nibabel image we gave to the last one.
+        if self.__dataMgr is not None:
+            self.__dataMgr = self.__dataMgr.copy(self.nibImage)
 
         self.__dataSource = filename
         self.__saveState  = True
@@ -1477,47 +1472,53 @@ class Image(Nifti):
         self.notify(topic='saveState')
 
 
-    def __getitem__(self, sliceobj):
+    def __getitem__(self, slc):
         """Access the image data with the specified ``sliceobj``.
 
-        :arg sliceobj: Something which can slice the image data.
+        :arg slc: Something which can slice the image data.
         """
 
-        log.debug('%s: __getitem__ [%s]', self.name, sliceobj)
+        log.debug('%s: __getitem__ [%s]', self.name, slc)
 
-        return self.__imageWrapper.__getitem__(sliceobj)
+        if   self.__dataMgr is not None: return self.__dataMgr[slc]
+        elif self.__data    is not None: return self.__data[slc]
+        else:                            return self.__nibImage.dataobj[slc]
 
 
-    def __setitem__(self, sliceobj, values):
-        """Set the image data at ``sliceobj`` to ``values``.
+    def __setitem__(self, slc, values):
+        """Set the image data at ``slc`` to ``values``.
 
-        :arg sliceobj: Something which can slice the image data.
-        :arg values:   New image data.
+        :arg slc:    Something which can slice the image data.
+        :arg values: New image data.
 
-        .. note:: Modifying image data will force the entire image to be
+        .. note:: Modifying image data may force the entire image to be
                   loaded into memory if it has not already been loaded.
         """
         values = np.array(values)
 
-        log.debug('%s: __setitem__ [%s = %s]',
-                  self.name, sliceobj, values.shape)
-
-        with self.__imageWrapper.skip(self.__lName):
+        if values.size == 0:
+            return
 
-            oldRange = self.__imageWrapper.dataRange
-            self.__imageWrapper.__setitem__(sliceobj, values)
-            newRange = self.__imageWrapper.dataRange
+        log.debug('%s: __setitem__ [%s = %s]', self.name, slc, values.shape)
 
-        if values.size > 0:
+        # Use DataManager to manage data
+        # access if one has been specified
+        if self.__dataMgr is not None:
+            self.__dataMgr[slc] = values
 
-            self.notify(topic='data', value=sliceobj)
-
-            if self.__saveState:
-                self.__saveState = False
-                self.notify(topic='saveState')
+        # Use an internal numpy array
+        # to persist data changes
+        else:
+            # force-load data - see the data() method
+            if self.__data is None:
+                self.data
+            self.__data[slc] = values
 
-            if not np.all(np.isclose(oldRange, newRange)):
-                self.notify(topic='dataRange')
+        # Notify that data has changed/image is not saved
+        self.notify(topic='data', value=slc)
+        if self.__saveState:
+            self.__saveState = False
+            self.notify(topic='saveState')
 
 
 def canonicalShape(shape):
diff --git a/fsl/utils/notifier.py b/fsl/utils/notifier.py
index b294f6192ce01261275aed1f07213d3d0cb7a01d..5878b06288aa0a0d96e8378078a585889bbd7ade 100644
--- a/fsl/utils/notifier.py
+++ b/fsl/utils/notifier.py
@@ -73,7 +73,7 @@ class _Listener(object):
         return self.__str__()
 
 
-class Notifier(object):
+class Notifier:
     """The ``Notifier`` class is a mixin which provides simple notification
     capability. Listeners can be registered/deregistered to listen via the
     :meth:`register` and :meth:`deregister` methods, and notified via the