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
import numpy as np
import fsl.utils.transform as transform
import fsl.utils.status as status
import fsl.utils.notifier as notifier
import fsl.utils.path as fslpath
import fsl.data.constants as constants
import fsl.utils.transform as transform
import fsl.utils.notifier as notifier
import fsl.utils.path as fslpath
import fsl.data.constants as constants
import fsl.data.imagewrapper as imagewrapper
log = logging.getLogger(__name__)
......@@ -292,70 +292,97 @@ class Image(Nifti1, notifier.Notifier):
.. todo:: If the image appears to be large, it is loaded using the
: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,
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
file name, sans-suffix.
============== ======================================================
``name`` The name of this ``Image`` - defaults to the image
file name, sans-suffix.
``dataSource`` The data source of this ``Image`` - the name of the
file from where it was loaded, or some other string
describing its origin.
================= ====================================================
``dataSource`` The data source of this ``Image`` - the name of the
file from where it was loaded, or some other string
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
(via the :meth:`applyChange` method).
(via the :meth:`__setitem__` method).
``'saveState'`` This topic is notified whenever the saved state of the
image changes (i.e. it is edited, or saved to disk).
``'dataRange'`` This topic is notified whenever the image data range
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.
: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 name: A name for the image.
:arg header: If not ``None``, assumed to be a
:class:`nibabel.nifti1.Nifti1Header` to be used as the
image header. Not applied to images loaded from file,
or existing :mod:`nibabel` images.
:arg xform: A :math:`4\\times 4` affine transformation matrix
which transforms voxel coordinates into real world
coordinates. Only used if ``image`` is a ``numpy``
array, and ``header`` is ``None``.
: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 name: A name for the image.
:arg header: If not ``None``, assumed to be a
:class:`nibabel.nifti1.Nifti1Header` to be used as the
image header. Not applied to images loaded from file,
or existing :mod:`nibabel` images.
:arg xform: A :math:`4\\times 4` affine transformation matrix
which transforms voxel coordinates into real world
coordinates. Only used if ``image`` is a ``numpy``
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
self.name = None
self.dataSource = None
nibImage = None
dataSource = None
# The image parameter may be the name of an image file
if isinstance(image, six.string_types):
image = op.abspath(addExt(image))
self.nibImage = loadImage(image)
self.dataSource = image
image = op.abspath(addExt(image))
nibImage = nib.load(image)
dataSource = image
# Or a numpy array - we wrap it in a nibabel image,
# with an identity transformation (each voxel maps
......@@ -365,34 +392,39 @@ class Image(Nifti1, notifier.Notifier):
if header is not None: xform = header.get_best_affine()
elif xform is None: xform = np.identity(4)
self.nibImage = nib.nifti1.Nifti1Image(image,
xform,
header=header)
nibImage = nib.nifti1.Nifti1Image(image,
xform,
header=header)
# otherwise, we assume that it is a nibabel image
else:
self.nibImage = image
nibImage = image
# Figure out the name of this image.
# It might have been explicitly passed in
if name is not None:
self.name = name
# 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))
# Figure out the name of this image. If it has
# not beenbeen explicitly passed in, and this
# image was loaded from disk, use the file name.
if name is None and isinstance(image, six.string_types):
name = removeExt(op.basename(image))
# Or the image was created from a numpy array
elif isinstance(image, np.ndarray):
self.name = 'Numpy array'
name = 'Numpy array'
# Or image from a nibabel image
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):
"""Returns a number which uniquely idenfities this ``Image`` instance
......@@ -413,147 +445,109 @@ class Image(Nifti1, notifier.Notifier):
return self.__str__()
def loadData(self):
"""Overrides :meth:`Nifti1.loadData`. Calls that method, and
calculates initial values for :attr:`dataRange`.
@property
def name(self):
"""
"""
return self.__name
Nifti1.loadData(self)
status.update('Calculating minimum/maximum '
'for {}...'.format(self.dataSource), None)
@property
def dataSource(self):
"""
"""
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))
if np.any(np.isnan((dataMin, dataMax))):
dataMin = 0
dataMax = 0
@property
def saveState(self):
"""Returns ``True`` if this ``Image`` has been saved to disk, ``False``
otherwise.
"""
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 applyChange(self, offset, newVals, vol=None):
"""Changes the image data according to the given new values.
Any listeners registered on the :attr:`data` property will be
notified of the change.
def __dataRangeChanged(self, *args, **kwargs):
self.notify('dataRange')
:arg offset: A tuple of three values, containing the xyz
offset of the image region to be changed.
:arg newVals: A 3D numpy array containing the new image values.
:arg vol: If this is a 4D image, the volume index.
def loadData(self):
"""Calculates initial values for :attr:`dataRange`.
"""
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:
return
self.__imageWrapper = imagewrapper.ImageWrapper(
self.nibImage, self.name)
data = self.data
xlo, ylo, zlo = offset
xhi = xlo + newVals.shape[0]
yhi = ylo + newVals.shape[1]
zhi = zlo + newVals.shape[2]
log.debug('Image {} data change - offset: {}, shape: {}, '
'volume: {}'.format(self.name, offset, newVals.shape, vol))
try:
data.flags.writeable = True
if self.is4DImage(): oldVals = data[xlo:xhi, ylo:yhi, zlo:zhi, vol]
else: oldVals = data[xlo:xhi, ylo:yhi, zlo:zhi]
if self.is4DImage(): data[xlo:xhi, ylo:yhi, zlo:zhi, vol] = newVals
else: data[xlo:xhi, ylo:yhi, zlo:zhi] = newVals
data.flags.writeable = False
except:
data.flags.writeable = False
raise
self.__imageWrapper.register('{}_{}'.format(id(self), self.name),
self.__dataRangeChanged)
# How big is this image? If it's not too big,
# then we'll calculate the actual data range
# right now.
#
# This hard-coded threshold is equal to twice
# the number of voxels in the MNI152 T1 0.5mm
# standard image. Any bigger than this, and
# we'll calculate the range from a sample:
#
# 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.
#
# Note: Only 3D/4D images supported here
# if np.prod(self.shape) < 115536512: self.__imageWrapper[:]
# elif len(self.shape) >= 4: self.__imageWrapper[:, :, :, 0]
# else: self.__imageWrapper[:, :, 0]
def __getitem__(self, sliceobj):
return self.__imageWrapper.__getitem__(sliceobj)
newMin, newMax = self.__calculateDataRange(oldVals, newVals)
log.debug('Image {} new data range: {} - {}'.format(
self.name, newMin, newMax))
# def __setitem__(self, sliceobj, values):
# """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
self.dataRange.x = [newMin, newMax]
# :arg sliceobj: Something with which the image array can be sliced.
# Force a notification on the 'data' property
# by assigning its value back to itself
self.data = data
self.saved = False
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
# :arg values: A numpy array containing the new image values.
# """
# if values.size == 0:
# return
# The same logic applies to the maximum
if (newValMax >= oldMax) or wholeImage: newMax = newValMax
elif np.nanmax(oldVals) == oldMax: newMax = np.nanmax(data)
else: newMax = oldMax
# self.__imageWrapper.__setitem__(sliceobj, values)
# self.__saveState = False
if np.isnan(newMin): newMin = 0
if np.isnan(newMax): newMax = 0
status.update('{} range: [{} - {}]'.format(
self.dataSource, newMin, newMax))
# def save(self):
# """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):
......@@ -648,16 +642,6 @@ def addExt(prefix, mustExist=True):
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):
"""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