From 7f7dd1ed0f7c623214ea87aed69dd589384e2b6a Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauld.mccarthy@gmail.com> Date: Sun, 29 Jun 2014 11:22:07 +0100 Subject: [PATCH] Documented fsl.data package. --- fsl/data/__init__.py | 7 + fsl/data/fslimage.py | 359 +++++++++++++++++++++++++----------------- fsl/data/imagefile.py | 59 +++++-- 3 files changed, 264 insertions(+), 161 deletions(-) diff --git a/fsl/data/__init__.py b/fsl/data/__init__.py index e69de29bb..27a9696ea 100644 --- a/fsl/data/__init__.py +++ b/fsl/data/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# +# __init__.py - fsl.data package. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""Data structures and models.""" diff --git a/fsl/data/fslimage.py b/fsl/data/fslimage.py index 23d563938..2a284ecf7 100644 --- a/fsl/data/fslimage.py +++ b/fsl/data/fslimage.py @@ -5,6 +5,9 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +"""Classes for representing 3D/4D images, display properties of images, and +collections of images. +""" import os import sys @@ -28,13 +31,13 @@ log = logging.getLogger(__name__) def _loadImageFile(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. 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). + """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. 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). """ # If we have a GUI, we can display a dialog @@ -48,71 +51,113 @@ def _loadImageFile(filename): pass realFilename = filename + mbytes = op.getsize(filename) / 1048576.0 - if filename.endswith('.nii.gz'): - - mbytes = op.getsize(filename) / 1048576.0 + # The mbytes limit is arbitrary + if filename.endswith('.nii.gz') and mbytes > 512: - # This limit is arbitrary - if mbytes > 512: + unzipped, filename = tempfile.mkstemp(suffix='.nii') - unzipped, filename = tempfile.mkstemp(suffix='.nii') + unzipped = os.fdopen(unzipped) - unzipped = os.fdopen(unzipped) + msg = '{} is a large file ({} MB) - decompressing ' \ + 'to {}, to allow memory mapping...'.format(realFilename, + mbytes, + filename) - msg = '{} is a large file ({} MB) - decompressing ' \ - 'to {}, to allow memory mapping...'.format(realFilename, - mbytes, - filename) - - if not haveGui: - log.info(msg) - else: - busyDlg = wx.BusyInfo(msg, wx.GetTopLevelWindows()[0]) + if not haveGui: + log.info(msg) + else: + busyDlg = wx.BusyInfo(msg, wx.GetTopLevelWindows()[0]) - gzip = ['gzip', '-d', '-c', realFilename] - log.debug('Running {} > {}'.format(' '.join(gzip), filename)) + 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() + # 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 + 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 - if haveGui: - busyDlg.Destroy() + if haveGui: + busyDlg.Destroy() return nib.load(filename), filename class Image(props.HasProperties): - """ - Class which represents a 3D/4D image. Internally, the image is - loaded/stored using nibabel. + """Class which represents a 3D/4D image. Internally, the image is + loaded/stored using :mod:`nibabel`. + + Arbitrary data may be associated with an :class:`Image` object, via the + :meth:`getAttribute` and :meth:`setAttribute` methods (which are just + front end wrappers around an internal ``dict`` object). + + The following attributes are present on an :class:`Image` object: + + :ivar nibImage: The :mod:`nibabel` image object. + + :ivar data: A reference to the image data, stored as a + :mod`numpy` array. + + :ivar display: A :class:`ImageDisplay` object, defining how this + image should be displayed. + + :ivar shape: A list/tuple containing the number of voxels + along each image dimension. + + :ivar pixdim: A list/tuple containing the size of one voxel + alon each image dimension. + + :ivar voxToWorldMat: A 4*4 array specifying the affine transformation + for transforming voxel coordinates into real world + coordinates. + + :ivar worldToVoxMat: A 4*4 array specifying the affine transformation + for transforming real world coordinates into voxel + coordinates. + + :ivar imageFile: The name of the file that the image was loaded from. + + :ivar tempFile: The name of the temporary file which was created (in + the event that the image was large and was gzipped - + see :func:`_loadImageFile`). """ - # How the image should be transformd into real world space. transform = props.Choice( collections.OrderedDict([ ('affine', 'Use qform/sform transformation matrix'), ('pixdim', 'Use pixdims only'), ('id', 'Do not use qform/sform or pixdims')]), default='affine') - - name = props.String() - imageFile = props.FilePath() - tempFile = props.FilePath() + """This property defines how the image should be transformd into real world + space. + + - ``affine``: Use the affine transformation matrix stored in the image + (the ``qform``/``sform`` fields in NIFTI1 headers). + + - ``pixdim``: Scale voxel sizes by the ``pixdim`` fields in the image + header. + - ``id``: Perform no scaling or transformation - voxels will be + displayed as :math:`1mm^3` isotropic. + """ + + name = props.String() + """The name of this image.""" + def __init__(self, image): - """ - Initialise an Image object with the given image data or file name. + """Initialise 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. """ # The image parameter may be the name of an image file @@ -172,24 +217,27 @@ class Image(props.HasProperties): def _transformChanged(self, *a): - """ + """This method is called when the :attr:`transform` property value + changes. It updates the :attr:`voxToWorldMat`, :attr:`worldToVoxMat`, + and :attr:`pixdim` attributes to reflect the new transformation + type. """ if self.transform == 'affine': voxToWorldMat = self.nibImage.get_affine() - pixdims = self.nibImage.get_header().get_zooms() + pixdim = self.nibImage.get_header().get_zooms() elif self.transform == 'pixdim': - pixdims = self.nibImage.get_header().get_zooms() - voxToWorldMat = np.diag([pixdims[0], pixdims[1], pixdims[2], 1.0]) + pixdim = self.nibImage.get_header().get_zooms() + voxToWorldMat = np.diag([pixdim[0], pixdim[1], pixdim[2], 1.0]) elif self.transform == 'id': voxToWorldMat = np.identity(4) - pixdims = [1.0, 1.0, 1.0] + pixdim = [1.0, 1.0, 1.0] self.voxToWorldMat = np.array(voxToWorldMat, dtype=np.float32) self.worldToVoxMat = linalg.inv(self.voxToWorldMat) - self.pixdims = pixdims + self.pixdim = pixdim self.voxToWorldMat = self.voxToWorldMat.transpose() self.worldToVoxMat = self.worldToVoxMat.transpose() @@ -198,7 +246,7 @@ class Image(props.HasProperties): # location (0, 0) to map to voxel location (0, 0) if self.transform in ['pixdim', 'id']: for i in range(3): - self.voxToWorldMat[3, i] = self.pixdims[i] * 0.5 + self.voxToWorldMat[3, i] = self.pixdim[i] * 0.5 self.worldToVoxMat[3, i] = -0.5 log.debug('Image {} transformation matrix changed: {}'.format( @@ -207,9 +255,8 @@ class Image(props.HasProperties): def imageBounds(self, axis): - """ - Return the bounds (min, max) of the image, in real world - coordinates, along the specified axis. + """Return the bounds (min, max) of the image, in real world + coordinates, along the specified 0-indexed axis. """ x, y, z = self.shape[:3] @@ -238,24 +285,23 @@ class Image(props.HasProperties): def worldToVox(self, p, axes=None): - """ - Transforms the given set of points in voxel coordinates to - points in world coordinates, according to the affine - transformation specified in the image file. The returned array - is either a numpy.float64 array, or a single integer value, - depending on the input. There is no guarantee that the returned - array of voxel coordinates is within the bounds of the image - shape. Parameters: + """Transforms the given set of points in voxel coordinates to points + in world coordinates, according to the current :attr:`transform`. + + The returned array is either a :class:`numpy.float64` array, or a + single ``int`` value, depending on the input. There is no guarantee + that the returned array of voxel coordinates is within the bounds of + the image shape. Parameters: - - p: N*A array, where N is the number of points, and A - is the number of axes to consider (default: 3) + :arg p: N*A array, where N is the number of points, and A + is the number of axes to consider (default: 3). - - axes: If None, it is assumed that the input p is a N*3 - array, with each point being specified by x,y,z - coordinates. If a single value in the range (0-2), - it is assumed that p is a 1D array. Or, if a - sequence of 2 or 3 values, p must be an array of - N*2 or N*3, respectively. + :arg axes: If ``None``, it is assumed that the input p is a N*3 + array, with each point being specified by x,y,z + coordinates. If a single value in the range (0-2), + it is assumed that p is a 1D array. Or, if a + sequence of 2 or 3 values, p must be an array of + N*2 or N*3, respectively. """ voxp = self._transform(p, self.worldToVoxMat, axes) @@ -273,13 +319,13 @@ class Image(props.HasProperties): def voxToWorld(self, p, axes=None): - """ - Transforms the given set of points in world coordinates to - points in voxel coordinates, according to the affine - transformation specified in the image file. The returned - array is either a numpy.float64 array, or a single float - value, depending on the input. See the worldToVox - docstring for more details. + """Transforms the given set of points in world coordinates to + points in voxel coordinates, according to the current + :attr:`transform`. + + The returned array is either a :class:`numpy.float64` array, + or a single ``float`` value, depending on the input. See the + :meth:`worldToVox` method for more details. """ worldp = self._transform(p, self.voxToWorldMat, axes) @@ -289,11 +335,11 @@ class Image(props.HasProperties): def _transform(self, p, a, axes): - """ - Transforms the given set of points p according to the given - affine transformation a. The transformed points are returned - as a numpy.float64 array. See the worldToVox docstring for - more details. + """Used by the :meth:`worldToVox` and :meth:`voxToWorld` methods. + + Transforms the given set of points ``p`` according to the given affine + transformation ``a``. The transformed points are returned as a + :class:``numpy.float64`` array. """ p = self._fillPoints(p, axes) @@ -313,9 +359,8 @@ class Image(props.HasProperties): def _fillPoints(self, p, axes): - """ - Used by the _transform method. 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 + """Used by the :meth:`_transform` method. 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. """ @@ -347,16 +392,12 @@ class Image(props.HasProperties): def getAttribute(self, name): - """ - Retrieve the attribute with the given name. - """ + """Retrieve the attribute with the given name.""" return self._attributes[name] def setAttribute(self, name, value): - """ - Set an attribute with the given name and the given value. - """ + """Set an attribute with the given name and the given value.""" self._attributes[name] = value log.debug('Attribute set on {}: {} = {}'.format( @@ -364,31 +405,48 @@ class Image(props.HasProperties): class ImageDisplay(props.HasProperties): - """ - A class which describes how an image should be displayed. There should - be no need to manually instantiate ImageDisplay objects - one is created - for each Image object, and is accessed via the Image.display attribute. - If a single image needs to be displayed in different ways, then create - away, and manage your own ImageDisplay objects. + """A class which describes how an image should be displayed. + + There should be no need to manually instantiate :class:`ImageDisplay` + objects - one is created for each :class:`Image` object, and is accessed + via the :attr:`Image.display` instance attribute. If a single image needs + to be displayed in different ways, then create away, and manage your own + :class:`ImageDisplay` objects. This class doesn't have any functionality - it is up to things which - actually display an Image to adhere to the properties stored in the - associated ImageDisplay object. + actually display an :class:`Image` to adhere to the properties stored in + the associated :class:`ImageDisplay` object. """ - enabled = props.Boolean(default=True) - alpha = props.Real(minval=0.0, maxval=1.0, default=1.0) + enabled = props.Boolean(default=True) + """Should this image be displayed at all?""" + + alpha = props.Real(minval=0.0, maxval=1.0, default=1.0) + """Transparency - 1.0 is fully opaque, and 0.0 is fully transparent.""" + displayRange = props.Bounds(ndims=1, editLimits=True, labels=['Min.', 'Max.']) + """Image values which map to the minimum and maximum colour map colours.""" + samplingRate = props.Int(minval=1, maxval=16, default=1, clamped=True) - rangeClip = props.Boolean(default=False) - cmap = props.ColourMap(default=mplcm.Greys_r) - volume = props.Int(minval=0, maxval=0, default=0, clamped=True) + """Only display every Nth voxel (a performance tweak).""" + rangeClip = props.Boolean(default=False) + """If ``True``, don't display voxel values which are beyond the + :attr:`displayRange`. + """ + + cmap = props.ColourMap(default=mplcm.Greys_r) + """The colour map, a :class:`matplotlib.colors.Colourmap` instance.""" + + volume = props.Int(minval=0, maxval=0, default=0, clamped=True) + """If a 4D image, the current volume to display.""" def is4DImage(self): + """Returns ``True`` if this image is 4D, ``False`` otherwise. + """ return len(self.image.shape) > 3 and self.image.shape[3] > 1 - + _view = props.VGroup(('enabled', props.Widget('volume', enabledWhen=is4DImage), 'displayRange', @@ -420,9 +478,9 @@ class ImageDisplay(props.HasProperties): def __init__(self, image): - """ - Create an ImageDisplay for the specified image. The image - parameter should be an Image object (defined above). + """Create an :class:`ImageDisplay` for the specified image. + + :arg image: A :class:`Image` object. """ self.image = image @@ -453,44 +511,58 @@ class ImageDisplay(props.HasProperties): class ImageList(props.HasProperties): - """ - Class representing a collection of images to be displayed together. - Contains a List property containing Image objects, and some other - properties on which listeners may register themselves to be notified - when the properties of the image collection changes (e.g. image - bounds). + """Class representing a collection of images to be displayed together. + + Contains a :class:`props.properties_types.List` property containing + :class:`Image` objects, and some other properties on which listeners may + register themselves to be notified when the properties of the image + collection changes (e.g. image bounds). + + An :class:`ImageList` object has a few wrapper methods around the + :attr:`images` property, allowing the :class:`ImageList` to be used + as if it were a list itself. """ def _validateImage(self, atts, images): + """Returns ``True`` if all objects in the given ``images`` list are + :class:`Image` objects, ``False`` otherwise. + """ return all(map(lambda img: isinstance(img, Image), images)) - # The images property contains a list of Image objects + images = props.List(validateFunc=_validateImage, allowInvalid=False) + """A list of :class:`Image` objects. to be displayed""" - # Index of the currently 'selected' image. This property - # is not used by the ImageList, but is provided so that - # other things can control and listen for changes to - # the currently selected image + selectedImage = props.Int(minval=0, clamped=True) + """Index of the currently 'selected' image. This property is not used by + the :class:`ImageList`, but is provided so that other things can control + and listen for changes to the currently selected image + """ - # The bounds property contains the min/max values of - # a bounding box (in real world coordinates) which - # is big enough to contain all of the images in the - # 'images' list. This property shouid be read-only, - # but I don't have a way to enforce it (yet). + bounds = props.Bounds(ndims=3) + """This property contains the min/max values of + a bounding box (in real world coordinates) which + is big enough to contain all of the images in the + :attr:`images` list. This property shouid be + read-only, but I don't have a way to enforce it + (yet). + """ - # The location property contains the currently 'selected' - # 3D location in the image list space. This property - # is not used directly by the ImageList object, but it - # is here so that the location selection can be synchronised - # across multiple displays. + location = props.Point(ndims=3, labels=('X', 'Y', 'Z')) + """The location property contains the currently 'selected' + 3D location in the image list space. This property + is not used directly by the ImageList object, but it + is here so that the location selection can be synchronised + across multiple displays. + """ + def __init__(self, images=None): - """ - Create an ImageList object from the given sequence of Image objects. - """ + """Create an ImageList object from the given sequence of + :class:`Image` objects.""" if images is None: images = [] @@ -514,10 +586,9 @@ class ImageList(props.HasProperties): def _imageListChanged(self, *a): - """ - Called whenever an item is added or removed from the list. Registers - listeners with the properties of each image, and updates the image - bounds + """Called whenever an item is added or removed from the :attr:`images` + list. Registers listeners with the properties of each image, and + calls the :meth:`_updateImageBounds` method. """ for img in self.images: @@ -535,9 +606,9 @@ class ImageList(props.HasProperties): def _updateImageBounds(self, *a): - """ - Called whenever an item is added or removed from the list, or an - image property changes. Updates the xyz bounds. + """Called whenever an item is added or removed from the + :attr:`images` list, or an image property changes. Updates + the :attr:`bounds` property. """ if len(self.images) == 0: @@ -574,7 +645,7 @@ class ImageList(props.HasProperties): def __iter__( self): return self.images.__iter__() def __contains__(self, item): return self.images.__contains__(item) def __setitem__( self, key, val): return self.images.__setitem__(key, val) - def __delitem( self, key): return self.images.__delitem__(key) + def __delitem__( self, key): return self.images.__delitem__(key) def index( self, item): return self.images.index(item) def count( self, item): return self.images.count(item) def append( self, item): return self.images.append(item) diff --git a/fsl/data/imagefile.py b/fsl/data/imagefile.py index 6b7a51f2e..f11c78054 100644 --- a/fsl/data/imagefile.py +++ b/fsl/data/imagefile.py @@ -5,28 +5,38 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +"""Convenience functions for adding/stripping supported +file extensions to/from image file names. +""" import os import os.path as op -# The file extensions which we understand. This list is used -# as the default if if the allowedExts parameter is not passed -# to any of the functions in this module. + _allowedExts = ['.nii', '.img', '.hdr', '.nii.gz', '.img.gz'] +"""The file extensions which we understand. This list is used as the default +if if the ``allowedExts`` parameter is not passed to any of the functions in +this module. +""" _descriptions = ['NIFTI1 images', 'ANALYZE75 images', 'NIFTI1/ANALYZE75 headers', 'Compressed NIFTI1 images', 'Compressed ANALYZE75/NIFTI1 images'] +"""Descriptions for each of the extensions in :data:`_allowedExts`. """ -# The default file extension (TODO read this from $FSLOUTPUTTYPE) _defaultExt = '.nii.gz' +"""The default file extension (TODO read this from ``$FSLOUTPUTTYPE``).""" def wildcard(allowedExts=None): - """ + """Returns a wildcard string for use in a file dialog, to limit + the acceptable file types. + + :arg allowedExts: A list of strings containing the allowed file + extensions. """ if allowedExts is None: @@ -38,7 +48,7 @@ def wildcard(allowedExts=None): exts = ['*{}'.format(ext) for ext in allowedExts] - wcParts = ['|'.join((desc,ext)) for (desc,ext) in zip(descs, exts)] + wcParts = ['|'.join((desc, ext)) for (desc, ext) in zip(descs, exts)] print '|'.join(wcParts) return '|'.join(wcParts) @@ -47,8 +57,13 @@ def wildcard(allowedExts=None): def isSupported(filename, allowedExts=None): """ - Returns True if the given file has a supported extension, False + Returns ``True`` if the given file has a supported extension, ``False`` otherwise. + + :arg filename: The file name to test. + + :arg allowedExts: A list of strings containing the allowed file + extensions. """ if allowedExts is None: allowedExts = _allowedExts @@ -58,8 +73,13 @@ def isSupported(filename, allowedExts=None): def removeExt(filename, allowedExts=None): """ - Removes the extension from the given file name. Raises a ValueError + Removes the extension from the given file name. Raises a :exc:`ValueError` if the file has an unsupported extension. + + :arg filename: The file name to strip. + + :arg allowedExts: A list of strings containing the allowed file + extensions. """ if allowedExts is None: allowedExts = _allowedExts @@ -85,22 +105,27 @@ def addExt( mustExist=False, allowedExts=None, defaultExt=None): - """ - Adds a file extension to the given file prefix. If mustExist is False - (the default), and the file does not already have a supported - extension, the default extension is appended and the new file name - returned. If the prefix already has a supported extension, it is - returned unchanged. + """Adds a file extension to the given file ``prefix``. - If mustExist is True, the function checks to see if any files exist - that have the given prefix, and a supported file extension. A - ValueError is raised if: + If ``mustExist`` is False (the default), and the file does not already + have a supported extension, the default extension is appended and the new + file name returned. If the prefix already has a supported extension, + it is returned unchanged. + + If ``mustExist`` is ``True``, the function checks to see if any files + exist that have the given prefix, and a supported file extension. A + :exc:`ValueError` is raised if: - No files exist with the given prefix and a supported extension. - More than one file exists with the given prefix, and a supported extension. Otherwise the full file name is returned. + + :arg prefix: The file name refix to modify. + :arg mustExist: Whether the file must exist or not. + :arg allowedExts: List of allowed file extensions. + :arg defaultExt: Default file extension to use. """ if allowedExts is None: allowedExts = _allowedExts -- GitLab