Skip to content
Snippets Groups Projects
Commit f9e3798e authored by Paul McCarthy's avatar Paul McCarthy
Browse files

New 'ImageWrapper' class which handles reading from nibabel images (and

hopefully will handle writing too). Read sort of working. Major
disruptive changes to Image class. Things breaking bad.
parent 10f40bf7
No related branches found
No related tags found
No related merge requests found
...@@ -41,11 +41,11 @@ import six ...@@ -41,11 +41,11 @@ import six
import numpy as np import numpy as np
import fsl.utils.transform as transform import fsl.utils.transform as transform
import fsl.utils.status as status import fsl.utils.notifier as notifier
import fsl.utils.notifier as notifier import fsl.utils.path as fslpath
import fsl.utils.path as fslpath import fsl.data.constants as constants
import fsl.data.constants as constants import fsl.data.imagewrapper as imagewrapper
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -292,70 +292,97 @@ class Image(Nifti1, notifier.Notifier): ...@@ -292,70 +292,97 @@ class Image(Nifti1, notifier.Notifier):
.. todo:: If the image appears to be large, it is loaded using the .. todo:: If the image appears to be large, it is loaded using the
:mod:`indexed_gzip` module. Implement this, and also write :mod:`indexed_gzip` module. Implement this, and also write
a note about what it means. a note here about what it means.
In addition to the attributes added by the :meth:`Nifti1.__init__` method, In addition to the attributes added by the :meth:`Nifti1.__init__` method,
the following attributes are present on an ``Image`` instance: the following attributes are present on an ``Image`` instance as
properties (https://docs.python.org/2/library/functions.html#property):
================= ==================================================== ============== ======================================================
``name`` The name of this ``Image`` - defaults to the image ``name`` The name of this ``Image`` - defaults to the image
file name, sans-suffix. file name, sans-suffix.
``dataSource`` The data source of this ``Image`` - the name of the ``dataSource`` The data source of this ``Image`` - the name of the
file from where it was loaded, or some other string file from where it was loaded, or some other string
describing its origin. describing its origin.
================= ====================================================
``nibImage`` A reference to the ``nibabel.Nifti1Image`` object.
``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. This may not
be accurate, and may also change as more image data
is loaded from disk.
============== ======================================================
The ``Image`` class implements the :class:`.Notifier` interface -
listeners may register to be notified on the following topics (see
the :class:`.Notifier` class documentation):
The ``Image`` class implements the :class:`.Notifier` interface -
listeners may register to be notified of changes to the above properties,
by registering on the following _topic_ names (see the :class:`.Notifier`
class documentation):
=============== ====================================================== =============== ======================================================
``'data'`` This topic is notified whenever the image data changes ``'data'`` This topic is notified whenever the image data changes
(via the :meth:`applyChange` method). (via the :meth:`__setitem__` method).
``'saveState'`` This topic is notified whenever the saved state of the ``'saveState'`` This topic is notified whenever the saved state of the
image changes (i.e. it is edited, or saved to disk). image changes (i.e. it is edited, or saved to disk).
``'dataRange'`` This topic is notified whenever the image data range ``'dataRange'`` This topic is notified whenever the image data range
is changed/adjusted. is changed/adjusted.
=============== ====================================================== =============== ======================================================
""" """
def __init__(self, image, name=None, header=None, xform=None): def __init__(self,
image,
name=None,
header=None,
xform=None,
loadData=True):
"""Create an ``Image`` object with the given image data or file name. """Create an ``Image`` object with the given image data or file name.
:arg image: A string containing the name of an image file to load, :arg image: A string containing the name of an image file to load,
or a :mod:`numpy` array, or a :mod:`nibabel` image or a :mod:`numpy` array, or a :mod:`nibabel` image
object. object.
:arg name: A name for the image. :arg name: A name for the image.
:arg header: If not ``None``, assumed to be a :arg header: If not ``None``, assumed to be a
:class:`nibabel.nifti1.Nifti1Header` to be used as the :class:`nibabel.nifti1.Nifti1Header` to be used as the
image header. Not applied to images loaded from file, image header. Not applied to images loaded from file,
or existing :mod:`nibabel` images. or existing :mod:`nibabel` images.
:arg xform: A :math:`4\\times 4` affine transformation matrix :arg xform: A :math:`4\\times 4` affine transformation matrix
which transforms voxel coordinates into real world which transforms voxel coordinates into real world
coordinates. Only used if ``image`` is a ``numpy`` coordinates. Only used if ``image`` is a ``numpy``
array, and ``header`` is ``None``. array, and ``header`` is ``None``.
:arg loadData: If ``True`` (the default) the image data is loaded
immediately (although see the note above about large
compressed files). Otherwise, only the image header
information is read - the data may be loaded later on
via the :meth:`loadData` method. In this case, the
reported ``dataRange`` will be ``(None, None)``, and
``data`` will be ``None``, until the image data is
loaded via a call to :meth:``loadData``.
""" """
import nibabel as nib import nibabel as nib
self.name = None nibImage = None
self.dataSource = None dataSource = None
# The image parameter may be the name of an image file # The image parameter may be the name of an image file
if isinstance(image, six.string_types): if isinstance(image, six.string_types):
image = op.abspath(addExt(image)) image = op.abspath(addExt(image))
self.nibImage = loadImage(image) nibImage = nib.load(image)
self.dataSource = image dataSource = image
# Or a numpy array - we wrap it in a nibabel image, # Or a numpy array - we wrap it in a nibabel image,
# with an identity transformation (each voxel maps # with an identity transformation (each voxel maps
...@@ -365,34 +392,39 @@ class Image(Nifti1, notifier.Notifier): ...@@ -365,34 +392,39 @@ class Image(Nifti1, notifier.Notifier):
if header is not None: xform = header.get_best_affine() if header is not None: xform = header.get_best_affine()
elif xform is None: xform = np.identity(4) elif xform is None: xform = np.identity(4)
self.nibImage = nib.nifti1.Nifti1Image(image, nibImage = nib.nifti1.Nifti1Image(image,
xform, xform,
header=header) header=header)
# otherwise, we assume that it is a nibabel image # otherwise, we assume that it is a nibabel image
else: else:
self.nibImage = image nibImage = image
# Figure out the name of this image. # Figure out the name of this image. If it has
# It might have been explicitly passed in # not beenbeen explicitly passed in, and this
if name is not None: # image was loaded from disk, use the file name.
self.name = name if name is None and isinstance(image, six.string_types):
name = removeExt(op.basename(image))
# Or, if this image was loaded
# from disk, use the file name
elif isinstance(image, six.string_types):
self.name = removeExt(op.basename(self.dataSource))
# Or the image was created from a numpy array # Or the image was created from a numpy array
elif isinstance(image, np.ndarray): elif isinstance(image, np.ndarray):
self.name = 'Numpy array' name = 'Numpy array'
# Or image from a nibabel image # Or image from a nibabel image
else: else:
self.name = 'Nibabel image' name = 'Nibabel image'
Nifti1.__init__(self, self.nibImage.get_header()) Nifti1.__init__(self, nibImage.get_header())
self.__name = name
self.__dataSource = dataSource
self.__nibImage = nibImage
self.__saveState = dataSource is not None
self.__imageWrapper = None
if loadData:
self.loadData()
def __hash__(self): def __hash__(self):
"""Returns a number which uniquely idenfities this ``Image`` instance """Returns a number which uniquely idenfities this ``Image`` instance
...@@ -413,147 +445,109 @@ class Image(Nifti1, notifier.Notifier): ...@@ -413,147 +445,109 @@ class Image(Nifti1, notifier.Notifier):
return self.__str__() return self.__str__()
def loadData(self): @property
"""Overrides :meth:`Nifti1.loadData`. Calls that method, and def name(self):
calculates initial values for :attr:`dataRange`.
""" """
"""
return self.__name
Nifti1.loadData(self)
@property
status.update('Calculating minimum/maximum ' def dataSource(self):
'for {}...'.format(self.dataSource), None) """
"""
return self.__dataSource
dataMin = np.nanmin(self.data)
dataMax = np.nanmax(self.data) @property
def nibImage(self):
"""
"""
return self.__nibImage
log.debug('Calculated data range for {}: [{} - {}]'.format(
self.dataSource, dataMin, dataMax)) @property
def saveState(self):
if np.any(np.isnan((dataMin, dataMax))): """Returns ``True`` if this ``Image`` has been saved to disk, ``False``
dataMin = 0 otherwise.
dataMax = 0 """
return self.__saveState
status.update('{} range: [{} - {}]'.format(
self.dataSource, dataMin, dataMax)) @property
def dataRange(self):
"""
"""
if self.__imageWrapper is None: return (None, None)
else: return self.__imageWrapper.dataRange
self.dataRange.x = [dataMin, dataMax]
def __dataRangeChanged(self, *args, **kwargs):
def applyChange(self, offset, newVals, vol=None): self.notify('dataRange')
"""Changes the image data according to the given new values.
Any listeners registered on the :attr:`data` property will be
notified of the change.
:arg offset: A tuple of three values, containing the xyz def loadData(self):
offset of the image region to be changed. """Calculates initial values for :attr:`dataRange`.
:arg newVals: A 3D numpy array containing the new image values.
:arg vol: If this is a 4D image, the volume index.
""" """
if self.is4DImage() and vol is None:
raise ValueError('Volume must be specified for 4D images')
newVals = np.array(newVals) if self.__imageWrapper is not None:
raise RuntimeError('loadData can only be called once')
if newVals.size == 0: self.__imageWrapper = imagewrapper.ImageWrapper(
return self.nibImage, self.name)
data = self.data self.__imageWrapper.register('{}_{}'.format(id(self), self.name),
xlo, ylo, zlo = offset self.__dataRangeChanged)
xhi = xlo + newVals.shape[0]
yhi = ylo + newVals.shape[1] # How big is this image? If it's not too big,
zhi = zlo + newVals.shape[2] # then we'll calculate the actual data range
# right now.
log.debug('Image {} data change - offset: {}, shape: {}, ' #
'volume: {}'.format(self.name, offset, newVals.shape, vol)) # This hard-coded threshold is equal to twice
# the number of voxels in the MNI152 T1 0.5mm
try: # standard image. Any bigger than this, and
data.flags.writeable = True # we'll calculate the range from a sample:
#
if self.is4DImage(): oldVals = data[xlo:xhi, ylo:yhi, zlo:zhi, vol] # The ImageWrapper automatically calculates
else: oldVals = data[xlo:xhi, ylo:yhi, zlo:zhi] # the range of the specified slice, whenever
# it gets indexed. All we have to do is
if self.is4DImage(): data[xlo:xhi, ylo:yhi, zlo:zhi, vol] = newVals # access a portion of the data to trigger the
else: data[xlo:xhi, ylo:yhi, zlo:zhi] = newVals # range calculation.
#
data.flags.writeable = False # Note: Only 3D/4D images supported here
# if np.prod(self.shape) < 115536512: self.__imageWrapper[:]
except: # elif len(self.shape) >= 4: self.__imageWrapper[:, :, :, 0]
data.flags.writeable = False # else: self.__imageWrapper[:, :, 0]
raise
def __getitem__(self, sliceobj):
return self.__imageWrapper.__getitem__(sliceobj)
newMin, newMax = self.__calculateDataRange(oldVals, newVals)
log.debug('Image {} new data range: {} - {}'.format( # def __setitem__(self, sliceobj, values):
self.name, newMin, newMax)) # """Changes the image data according to the given new values.
# Any listeners registered on the :attr:`data` property will be
# notified of the change.
# Make sure the dataRange is up to date # :arg sliceobj: Something with which the image array can be sliced.
self.dataRange.x = [newMin, newMax]
# Force a notification on the 'data' property # :arg values: A numpy array containing the new image values.
# by assigning its value back to itself # """
self.data = data
self.saved = False # if values.size == 0:
# return
def save(self):
"""Convenience method to save any changes made to the :attr:`data` of
this :class:`Image` instance.
See the :func:`saveImage` function.
"""
return saveImage(self)
def __calculateDataRange(self, oldVals, newVals):
"""Called by :meth:`applyChange`. Re-calculates the image data range,
and returns a tuple containing the ``(min, max)`` values.
"""
data = self.data
status.update('Calculating minimum/maximum '
'for {}...'.format(self.dataSource), None)
# The old image wide data range.
oldMin = self.dataRange.xlo
oldMax = self.dataRange.xhi
# The data range of the changed sub-array.
newValMin = np.nanmin(newVals)
newValMax = np.nanmax(newVals)
# Has the entire image been updated?
wholeImage = tuple(newVals.shape) == tuple(data.shape[:3])
# If the minimum of the new values
# is less than the old image minimum,
# then it becomes the new minimum.
if (newValMin <= oldMin) or wholeImage: newMin = newValMin
# Or, if the old minimum is being
# replaced by the new values, we
# need to re-calculate the minimum
elif np.nanmin(oldVals) == oldMin: newMin = np.nanmin(data)
# Otherwise, the image minimum
# has not changed.
else: newMin = oldMin
# The same logic applies to the maximum # self.__imageWrapper.__setitem__(sliceobj, values)
if (newValMax >= oldMax) or wholeImage: newMax = newValMax # self.__saveState = False
elif np.nanmax(oldVals) == oldMax: newMax = np.nanmax(data)
else: newMax = oldMax
if np.isnan(newMin): newMin = 0
if np.isnan(newMax): newMax = 0
status.update('{} range: [{} - {}]'.format( # def save(self):
self.dataSource, newMin, newMax)) # """Convenience method to save any changes made to the :attr:`data` of
# this :class:`Image` instance.
return newMin, newMax # See the :func:`saveImage` function.
# """
# return saveImage(self)
class ProxyImage(Image): class ProxyImage(Image):
...@@ -648,16 +642,6 @@ def addExt(prefix, mustExist=True): ...@@ -648,16 +642,6 @@ def addExt(prefix, mustExist=True):
DEFAULT_EXTENSION) DEFAULT_EXTENSION)
def loadImage(filename):
"""
"""
log.debug('Loading image from {}'.format(filename))
import nibabel as nib
return nib.load(filename)
def saveImage(image, fromDir=None): def saveImage(image, fromDir=None):
"""Convenience function for interactively saving changes to an image. """Convenience function for interactively saving changes to an image.
......
#!/usr/bin/env python
#
# imagewrapper.py - The ImageWrapper class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`ImageWrapper` class,
"""
import logging
import numpy as np
import nibabel as nib
import fsl.utils.notifier as notifier
import fsl.utils.memoize as memoize
log = logging.getLogger(__name__)
class ImageWrapper(notifier.Notifier):
def __init__(self, image, name=None):
"""
:arg image: A ``nibabel.Nifti1Image``.
:arg name: A name for this ``ImageWrapper``, solely used for debug
log messages.
"""
self.__image = image
self.__name = name
# The current known image data range. This
# gets updated as more image data gets read.
self.__range = None, None
# We record the portions of the image that have
# been included in the data range calculation, so
# we do not unnecessarily re-calculate ranges on
# the same part of the image. This is a list of
# (low, high) pairs, one pair for each dimension
# in the image data.
self.__rangeCover = [[-1, -1] for i in range(len(image.shape))]
@property
def dataRange(self):
return tuple(self.__range)
def __rangeCovered(self, slices):
"""Returns ``True`` if the range for the image data calculated by
the given ``slices` has already been calculated, ``False`` otherwise.
"""
if self.__range == (None, None):
return False
# TODO You could adjust the slice so that
# it only spans the portion of the
# image that has not yet been covered.
for dim, size in enumerate(self.__image.shape):
lowCover, highCover = self.__rangeCover[dim]
if lowCover == -1 or highCover == -1:
return False
lowSlice, highSlice = slices[dim]
if lowSlice is None: lowSlice = 0
if highSlice is None: highSlice = self.__image.shape[dim]
if lowSlice < lowCover: return False
if highSlice > highCover: return False
return True
def __updateCoveredRange(self, slices):
"""
"""
for dim, (lowSlice, highSlice) in enumerate(slices):
lowCover, highCover = self.__rangeCover[dim]
if lowSlice is None: lowSlice = 0
if highSlice is None: highSlice = self.__image.shape[dim]
if lowSlice < lowCover: lowCover = lowSlice
if highSlice > highCover: highCover = highSlice
self.__rangeCover[dim] = [lowCover, highCover]
@memoize.Instanceify(memoize.memoize(args=[0]))
def __updateRangeOnRead(self, slices, data):
oldmin, oldmax = self.__range
dmin = np.nanmin(data)
dmax = np.nanmax(data)
if oldmin is None: oldmin = dmin
if oldmax is None: oldmax = dmax
if dmin < oldmin: newmin = dmin
else: newmin = oldmin
if dmax > oldmax: newmax = dmax
else: newmax = oldmax
self.__range = (newmin, newmax)
self.__updateCoveredRange(slices)
if newmin != oldmin or newmax != oldmax:
log.debug('Image {} data range adjusted: {} - {}'.format(
self.__name, newmin, newmax))
self.notify()
# def __updateRangeOnWrite(self, oldvals, newvals):
# """Called by :meth:`__setitem__`. Re-calculates the image data
# range, and returns a tuple containing the ``(min, max)`` values.
# """
# # The old known image wide data range.
# oldmin, oldmax = self.dataRange
# # The data range of the changed sub-array.
# newvalmin = np.nanmin(newvals)
# newvalmax = np.nanmax(newvals)
# # Has the entire image been updated?
# wholeImage = tuple(newvals.shape) == tuple(self.image.shape)
# # If the minimum of the new values
# # is less than the old image minimum,
# # then it becomes the new minimum.
# if (newvalmin <= oldmin) or wholeImage:
# newmin = newvalmin
# # Or, if the old minimum is being
# # replaced by the new values, we
# # need to re-calculate the minimum
# # from scratch.
# elif np.nanmin(oldvals) == oldmin:
# newmin = None
# # Otherwise, the image minimum
# # has not changed.
# else:
# newmin = oldmin
# # The same logic applies to the maximum.
# if (newvalmax >= oldmax) or wholeImage: newmax = newvalmax
# elif np.nanmax(oldvals) == oldmax: newmax = None
# else: newmax = oldmax
# if newmin is not None and np.isnan(newmin): newmin = oldmin
# if newmax is not None and np.isnan(newmax): newmax = oldmax
# if newmin != oldmin or newmax != oldmax:
# log.debug('Image {} data range adjusted: {} - {}'.format(
# self.__name, newmin, newmax))
# self.notify()
def __getitem__(self, sliceobj):
"""
"""
sliceobj = nib.fileslice.canonical_slicers(
sliceobj, self.__image.shape)
# If the image has noy been loaded
# into memory, we can use the nibabel
# ArrayProxy. Otheriwse if it is in
# memory, we can access it directly.
#
# Furthermore, if it is in memory and
# has been modified, the ArrayProxy
# will give us out-of-date values (as
# the ArrayProxy reads from disk). So
# we have to read from the in-memory
# array.
if self.__image.in_memory: data = self.__image.get_data()[sliceobj]
else: data = self.__image.dataobj[ sliceobj]
slices = tuple((s.start, s.stop) if isinstance(s, slice)
else (s, s + 1)
for s in sliceobj)
if not self.__rangeCovered(slices):
self.__updateRangeOnRead(slices, data)
return data
# def __setitem__(self, sliceobj, values):
# sliceobj = nib.fileslice.canonical_slicers(
# sliceobj, self.__image.shape)
# # This will cause the whole image to be
# # loaded into memory and cached by nibabel
# # (if it has not already done so).
# self.__image.get_data()[sliceobj] = values
# self.__updateRangeOnWrite(values)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment