diff --git a/fsl/data/image.py b/fsl/data/image.py index fbebad5330e82b39fe01e6e93f875bda4cf0f867..4ddf5028a16a61d3b035b7a85899d38e5f6fe2cc 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -33,20 +33,17 @@ file names: import logging -import tempfile import string import os import os.path as op -import subprocess as sp import six import numpy as np -import nibabel as nib -import props 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 @@ -57,6 +54,9 @@ log = logging.getLogger(__name__) class Nifti1(object): """The ``Nifti1`` class is intended to be used as a base class for things which either are, or are associated with, a NIFTI1 image. + The ``Nifti1`` class is intended to represent information stored in + the header of a NIFTI1 file - if you want to load the data from + a file, use the :class:`Image` class instead. When a ``Nifti1`` instance is created, it adds the following attributes @@ -64,13 +64,11 @@ class Nifti1(object): ================= ==================================================== - ``nibImage`` The :mod:`nibabel` image object. - ``dataSource`` The name of the file that the image was loaded from. - ``tempFile`` The name of the temporary file which was created (in - the event that the image was large and was gzipped - - see :func:`loadImage`). + ``header`` The :mod:`nibabel.Nifti1Header` object. ``shape`` A list/tuple containing the number of voxels along - each image dimension. + each image dimension. + ``pixdim`` A list/tuple containing the length of one voxel + along each image dimension. ``voxToWorldMat`` A 4*4 array specifying the affine transformation for transforming voxel coordinates into real world coordinates. @@ -78,80 +76,26 @@ class Nifti1(object): for transforming real world coordinates into voxel coordinates. ================= ==================================================== + + + .. note:: The ``shape`` attribute may not precisely match the image shape + as reported in the NIFTI1 header, because trailing dimensions of + size 1 are squeezed out. See the :meth:`__determineShape` and + :meth:`mapIndices` methods. """ - def __init__(self, - image, - xform=None, - header=None, - loadData=True): + def __init__(self, header): """Create a ``Nifti1`` object. - :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 xform: A :math:`4\\times 4` affine transformation matrix - which transforms voxel coordinates into real world - coordinates. - - :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 loadData: Defaults to ``True``. If ``False``, the image data is - not loaded - this is useful if you're only interested - in the header data, as the file will be loaded much - more quickly. The image data may subsequently be loaded - via the :meth:`loadData` method. + :arg header: A :class:`nibabel.nifti1.Nifti1Header` to be used as + the image header. """ - - self.nibImage = None - self.dataSource = None - self.tempFile = None - self.shape = None - self.pixdim = None - self.voxToWorldMat = None - self.worldToVoxMat = None - - if header is not None: - header = header.copy() - - # The image parameter may be the name of an image file - if isinstance(image, six.string_types): - - nibImage, filename = loadImage(addExt(image)) - self.nibImage = nibImage - self.dataSource = op.abspath(image) - - # if the returned file name is not the same as - # the provided file name, that means that the - # image was opened from a temporary file - if filename != image: - self.tempFile = nibImage.get_filename() - - # Or a numpy array - we wrap it in a nibabel image, - # with an identity transformation (each voxel maps - # to 1mm^3 in real world space) - elif isinstance(image, np.ndarray): - if xform is None: - if header is None: xform = np.identity(4) - else: xform = header.get_best_affine() - - self.nibImage = nib.nifti1.Nifti1Image(image, - xform, - header=header) - - # otherwise, we assume that it is a nibabel image - else: - self.nibImage = image + header = header.copy() + origShape, shape, pixdim = self.__determineShape(header) - shape, pixdim = self.__determineShape(self.nibImage) - - self.shape = shape - self.pixdim = pixdim + if len(shape) < 3 or len(shape) > 4: + raise RuntimeError('Only 3D or 4D images are supported') # We have to treat FSL/FNIRT images # specially, as FNIRT clobbers the @@ -159,37 +103,41 @@ class Nifti1(object): # to store other data. The hard coded # numbers here are the intent codes # output by FNIRT. - intent = self.nibImage.get_header().get('intent_code', -1) + intent = header.get('intent_code', -1) if intent in (2006, 2007, 2008, 2009): log.debug('FNIRT output image detected - using qform matrix') - self.voxToWorldMat = self.nibImage.get_qform() + voxToWorldMat = np.array(header.get_qform()) # Otherwise we let nibabel decide # which transform to use. else: - self.voxToWorldMat = np.array(self.nibImage.get_affine()) + voxToWorldMat = np.array(header.get_best_affine()) - self.worldToVoxMat = transform.invert(self.voxToWorldMat) + worldToVoxMat = transform.invert(voxToWorldMat) - if loadData: - self.loadData() - else: - self.data = None + self.header = header + self.shape = shape + self.__origShape = origShape + self.pixdim = pixdim + self.voxToWorldMat = voxToWorldMat + self.worldToVoxMat = worldToVoxMat - if len(self.shape) < 3 or len(self.shape) > 4: - raise RuntimeError('Only 3D or 4D images are supported') + 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: - def __determineShape(self, nibImage): - """This method is called by :meth:`__init__`. It figures out the 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. + - 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. """ - nibHdr = nibImage.get_header() - shape = list(nibImage.shape) - pixdims = list(nibHdr.get_zooms()) + origShape = list(header.get_data_shape()) + shape = list(origShape) + pixdims = list(header.get_zooms()) # Squeeze out empty dimensions, as # 3D image can sometimes be listed @@ -198,7 +146,8 @@ class Nifti1(object): if shape[i] == 1: shape = shape[:i] else: break - # But make sure the shape is 3D + # But make sure the shape + # has at 3 least dimensions if len(shape) < 3: shape = shape + [1] * (3 - len(shape)) @@ -206,38 +155,28 @@ class Nifti1(object): # doesn't return at least 3 values, we'll fall # back to the pixdim field in the header. if len(pixdims) < 3: - pixdims = nibHdr['pixdim'][1:] + pixdims = header['pixdim'][1:] pixdims = pixdims[:len(shape)] - return shape, pixdims - - - def loadData(self): - """Loads the image data from the file. This method only needs to - be called if the ``loadData`` parameter passed to :meth:`__init__` - was ``False``. - """ + return origShape, shape, pixdims - # Get the data, and reshape it according - # to the shape that the __determineShape - # method figured out. - data = self.nibImage.get_data() - origShape = data.shape - data = data.reshape(self.shape) - # Tell numpy to make the - # data array read-only - data.flags.writeable = False - - self.data = data + def mapIndices(self, sliceobj): + """Adjusts the given slice object so that it may be used to index the + underlying ``nibabel.Nifti1Image` object. + + See the :meth:`__determineShape` method. - log.debug('Loaded image data ({}) - original ' - 'shape {}, squeezed shape {}'.format( - self.dataSource, - origShape, - data.shape)) + :arg sliceobj: Something that can be used to slice a + multi-dimensional array, e.g. ``arr[sliceobj]``. + """ + # How convenient - nibabel has a function + # that does the dirty work for us. + import nibabel.fileslice as fileslice + return fileslice.canonical_slicers(sliceobj, self.__origShape) + # TODO: Remove this method, and use the shape attribute directly def is4DImage(self): @@ -265,7 +204,7 @@ class Nifti1(object): elif code == 'qform' : code = 'qform_code' else: raise ValueError('code must be None, sform, or qform') - code = self.nibImage.get_header()[code] + code = self.header[code] # Invalid values if code > 4: code = constants.NIFTI_XFORM_UNKNOWN @@ -346,54 +285,47 @@ class Nifti1(object): return code -class Image(Nifti1, props.HasProperties): +class Image(Nifti1, notifier.Notifier): """Class which represents a 3D/4D NIFTI1 image. Internally, the image is loaded/stored using :mod:`nibabel`. - In addition to the :attr:`data`, and :attr:`saved` properties, and - the attributes added by the :meth:`Nifti1.__init__` method, the - following attributes are present on an ``Image`` instance: - + .. 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. - ================= ==================================================== - ``name`` the name of this ``Image`` - defaults to the image - file name, sans-suffix. - - ``dataMin`` Minimum value in the image data. This is only - calculated if the ``loadData`` parameter to - :meth:`__init__` is ``True``, or when the - :meth:`loadData` method is called. If this is not - the case, ``dataMin`` will be ``None``. The - ``dataMin`` value is updated on every call to - :meth:`applyChange`. - ``dataMax`` Maximum value in the image data. This is calculated - alongside ``dataMin``. - ================= ==================================================== - """ + In addition to the attributes added by the :meth:`Nifti1.__init__` method, + the following attributes are present on an ``Image`` instance: - data = props.Object() - """The image data. This is a read-only :mod:`numpy` array - all changes - to the image data must be via the :meth:`applyChange` method. - """ + ================= ==================================================== + ``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. + ================= ==================================================== - saved = props.Boolean(default=False) - """A read-only property (not enforced) which is ``True`` if the image, - as stored in memory, is saved to disk, ``False`` otherwise. - """ + The ``Image`` class implements the :class:`.Notifier` interface - + listeners may register to be notified on the following topics (see + the :class:`.Notifier` class documentation): + - dataRange = props.Bounds(ndims=1, default=(np.inf, -np.inf)) - """A read-only property (not enforced) which contains the image - data range. This property is updated every time the image data - is changed (via :meth:`applyChange`). + =============== ====================================================== + ``'data'`` This topic is notified whenever the image data changes + (via the :meth:`applyChange` 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, **kwargs): + def __init__(self, image, name=None, header=None, xform=None): """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, @@ -402,10 +334,44 @@ class Image(Nifti1, props.HasProperties): :arg name: A name for the image. - All other arguments are passed through to :meth:`Nifti1.__init__`. + :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``. """ + + import nibabel as nib + + self.name = None + self.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 + + # Or a numpy array - we wrap it in a nibabel image, + # with an identity transformation (each voxel maps + # to 1mm^3 in real world space) + elif isinstance(image, np.ndarray): + + if header is not None: xform = header.get_best_affine() + elif xform is None: xform = np.identity(4) - Nifti1.__init__(self, image, **kwargs) + self.nibImage = nib.nifti1.Nifti1Image(image, + xform, + header=header) + + # otherwise, we assume that it is a nibabel image + else: + self.nibImage = image # Figure out the name of this image. # It might have been explicitly passed in @@ -416,7 +382,6 @@ class Image(Nifti1, props.HasProperties): # from disk, use the file name elif isinstance(image, six.string_types): self.name = removeExt(op.basename(self.dataSource)) - self.saved = True # Or the image was created from a numpy array elif isinstance(image, np.ndarray): @@ -425,6 +390,8 @@ class Image(Nifti1, props.HasProperties): # Or image from a nibabel image else: self.name = 'Nibabel image' + + Nifti1.__init__(self, self.nibImage.get_header()) def __hash__(self): @@ -682,61 +649,13 @@ def addExt(prefix, mustExist=True): def loadImage(filename): - """Given the name of an image file, loads it using nibabel. - - If the file is large, and is gzipped, it is decompressed to a temporary - location, so that it can be memory-mapped. - - In any case, a tuple is returned, consisting of the nibabel image object, - and the name of the file that it was loaded from (either the passed-in - file name, or the name of the temporary decompressed file). - - .. note:: The decompressing logic has been disabled for the time being. """ - - # realFilename = filename - # mbytes = op.getsize(filename) / 1048576.0 - - # # The mbytes limit is arbitrary - # if filename.endswith('.gz') and mbytes > 512: - - # unzipped, filename = tempfile.mkstemp(suffix='.nii') - - # unzipped = os.fdopen(unzipped) - - # msg = fslstrings.messages['image.loadImage.decompress'] - # msg = msg.format(op.basename(realFilename), mbytes, filename) - - # status.update(msg, None) - - # gzip = ['gzip', '-d', '-c', realFilename] - # log.debug('Running {} > {}'.format(' '.join(gzip), filename)) - - # # If the gzip call fails, revert to loading from the gzipped file - # try: - # sp.call(gzip, stdout=unzipped) - # unzipped.close() - - # except OSError as e: - # log.warn('gzip call failed ({}) - cannot memory ' - # 'map file: {}'.format(e, realFilename), - # exc_info=True) - # unzipped.close() - # os.remove(filename) - # filename = realFilename - + """ + log.debug('Loading image from {}'.format(filename)) import nibabel as nib - - # if mbytes > 512: - # msg = fslstrings.messages['image.loadImage.largeFile'] - # msg = msg.format(op.basename(filename), mbytes) - # status.update(msg) - - image = nib.load(filename) - - return image, filename + return nib.load(filename) def saveImage(image, fromDir=None):