diff --git a/fsl/data/strings.py b/fsl/data/strings.py index 41e200cf746562b9d4236982e13674215c5093a3..75e0d41a7bbf7041a73a7db4ba47bda38a524cc6 100644 --- a/fsl/data/strings.py +++ b/fsl/data/strings.py @@ -195,17 +195,17 @@ properties = TypeDict({ 'OrthoEditProfile.selectionOverlayColour' : 'Selection overlay', 'OrthoEditProfile.selectionCursorColour' : 'Selection cursor', - 'Display.name' : 'Overlay name', + 'Display.overlayType' : 'Overlay data type', 'Display.enabled' : 'Enabled', 'Display.alpha' : 'Opacity', 'Display.brightness' : 'Brightness', 'Display.contrast' : 'Contrast', 'Display.interpolation' : 'Interpolation', - 'Display.resolution' : 'Resolution', - 'Display.volume' : 'Volume', - 'Display.transform' : 'Overlay transform', - 'Display.overlayType' : 'Overlay data type', + + 'ImageOpts.resolution' : 'Resolution', + 'ImageOpts.transform' : 'Image transform', + 'ImageOpts.volume' : 'Volume', 'VolumeOpts.displayRange' : 'Display range', 'VolumeOpts.clippingRange' : 'Clipping range', @@ -228,6 +228,9 @@ properties = TypeDict({ 'LineVectorOpts.directed' : 'Interpret vectors as directed', 'LineVectorOpts.lineWidth' : 'Line width', + + 'ModelOpts.colour' : 'Colour', + 'ModelOpts.outline' : 'Show outline only', }) @@ -255,7 +258,6 @@ modes = TypeDict({ }) - choices = TypeDict({ 'SceneOpts.colourBarLocation.top' : 'Top', @@ -285,14 +287,20 @@ choices = TypeDict({ 'VectorOpts.displayType.rgb' : 'RGB', 'VectorOpts.modulate.none' : 'No modulation', - - 'Display.transform.affine' : 'Use qform/sform transformation matrix', - 'Display.transform.pixdim' : 'Use pixdims only', - 'Display.transform.id' : 'Do not use qform/sform or pixdims', + + 'ImageOpts.transform.affine' : 'Use qform/sform transformation matrix', + 'ImageOpts.transform.pixdim' : 'Use pixdims only', + 'ImageOpts.transform.id' : 'Do not use qform/sform or pixdims', 'Display.interpolation.none' : 'No interpolation', 'Display.interpolation.linear' : 'Linear interpolation', - 'Display.interpolation.spline' : 'Spline interpolation', + 'Display.interpolation.spline' : 'Spline interpolation', + + 'Display.overlayType.volume' : '3D/4D volume', + 'Display.overlayType.mask' : '3D/4D mask image', + 'Display.overlayType.rgbvector' : '3-direction vector image (RGB)', + 'Display.overlayType.linevector' : '3-direction vector image (Line)', + 'Display.overlayType.model' : '3D model' }) diff --git a/fsl/fslview/displaycontext/maskopts.py b/fsl/fslview/displaycontext/maskopts.py index f88038ce9665ccccb9943f9ecff54269bc47142f..67ad27eb097b14b62b5de1d9fae370d09981dd69 100644 --- a/fsl/fslview/displaycontext/maskopts.py +++ b/fsl/fslview/displaycontext/maskopts.py @@ -1,21 +1,19 @@ #!/usr/bin/env python # -# maskdisplay.py - +# maskopts.py - # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # - import numpy as np - import props -import fsl.data.image as fslimage import fsl.data.strings as strings -import display as fsldisplay +import volumeopts -class MaskOpts(fsldisplay.DisplayOpts): + +class MaskOpts(volumeopts.ImageOpts): colour = props.Colour() invert = props.Boolean(default=False) @@ -24,11 +22,7 @@ class MaskOpts(fsldisplay.DisplayOpts): labels=[strings.choices['VolumeOpts.displayRange.min'], strings.choices['VolumeOpts.displayRange.max']]) - def __init__(self, overlay, display, overlayList, displayCtx, parent=None): - - if not isinstance(overlay, fslimage.Image): - raise RuntimeError('{} can only be used with an {} overlay'.format( - type(self).__name__, fslimage.Image.__name__)) + def __init__(self, overlay, *args, **kwargs): if np.prod(overlay.shape) > 2 ** 30: sample = overlay.data[..., overlay.shape[-1] / 2] @@ -56,9 +50,4 @@ class MaskOpts(fsldisplay.DisplayOpts): self.threshold.xhi = self.dataMax + dMinDistance self.setConstraint('threshold', 'minDistance', dMinDistance) - fsldisplay.DisplayOpts.__init__(self, - overlay, - display, - overlayList, - displayCtx, - parent) + volumeopts.ImageOpts.__init__(self, overlay, *args, **kwargs) diff --git a/fsl/fslview/displaycontext/modelopts.py b/fsl/fslview/displaycontext/modelopts.py index 93acf16835f3e146e85745d4f80b2d4f4d26c6aa..b0370c212046f3bdd04ea864b2095eba13fba90a 100644 --- a/fsl/fslview/displaycontext/modelopts.py +++ b/fsl/fslview/displaycontext/modelopts.py @@ -15,7 +15,7 @@ import display as fsldisplay class ModelOpts(fsldisplay.DisplayOpts): colour = props.Colour() - outline = props.Boolean(default=True) + outline = props.Boolean(default=False) def __init__(self, *args, **kwargs): diff --git a/fsl/fslview/displaycontext/vectoropts.py b/fsl/fslview/displaycontext/vectoropts.py index 6c957f5797d6bafc73acb6d7f1c6a7be543ee469..44a8cd1e7393acf3b8da322627b1f99569f596f5 100644 --- a/fsl/fslview/displaycontext/vectoropts.py +++ b/fsl/fslview/displaycontext/vectoropts.py @@ -12,10 +12,10 @@ import props import fsl.data.image as fslimage import fsl.data.strings as strings -import display as fsldisplay +import volumeopts -class VectorOpts(fsldisplay.DisplayOpts): +class VectorOpts(volumeopts.ImageOpts): xColour = props.Colour(default=(1.0, 0.0, 0.0)) @@ -53,38 +53,29 @@ class VectorOpts(fsldisplay.DisplayOpts): """Hide voxels for which the modulation value is below this threshold.""" - def __init__(self, - overlay, - display, - overlayList, - displayCtx, - parent=None, - *args, - **kwargs): + def __init__(self, *args, **kwargs): """Create a ``VectorOpts`` instance for the given image. - See the :class:`.DisplayOpts` documentation for more details. + See the :class:`.ImageOpts` documentation for more details. """ + + volumeopts.ImageOpts.__init__(self, *args, **kwargs) - if not isinstance(overlay, fslimage.Image): - raise RuntimeError('{} can only be used with an {} overlay'.format( - type(self).__name__, fslimage.Image.__name__)) + self.overlayList.addListener('overlays', + self.name, + self.__overlayListChanged) - fsldisplay.DisplayOpts.__init__(self, - overlay, - display, - overlayList, - displayCtx, - parent, - *args, - **kwargs) - - overlayList.addListener('overlays', - self.name, - self.__overlayListChanged) self.__overlayListChanged() + def destroy(self): + volumeopts.ImageOpts.destroy(self) + self.overlayList.removeListener('overlays', self.name) + + for overlay in self.overlayList: + overlay.removeListeneR('name', self.name) + + def __overlayListChanged(self, *a): """Called when the overlay list changes. Updates the ``modulate`` property so that it contains a list of overlays which could be used @@ -136,9 +127,6 @@ class VectorOpts(fsldisplay.DisplayOpts): else: self.modulate = 'none' -# TODO RGBVector/LineVector subclasses for any type -# specific options (e.g. line width for linevector) - class LineVectorOpts(VectorOpts): lineWidth = props.Int(minval=1, maxval=10, default=1) diff --git a/fsl/fslview/displaycontext/volumeopts.py b/fsl/fslview/displaycontext/volumeopts.py index 9da91ca50c485379d6a812e989c74bebbc8caf6e..5f1f295614d4cc8b0252016e00e1371b4e58cd66 100644 --- a/fsl/fslview/displaycontext/volumeopts.py +++ b/fsl/fslview/displaycontext/volumeopts.py @@ -16,6 +16,7 @@ import props import fsl.data.image as fslimage import fsl.data.strings as strings +import fsl.utils.transform as transform import fsl.fslview.colourmaps as fslcm import display as fsldisplay @@ -24,17 +25,180 @@ import display as fsldisplay log = logging.getLogger(__name__) -# TODO Define a super/mixin class which -# has a displayRange and colour map. This -# will allow other bits of code which -# need display range/cmap options to -# test for their presence without having -# to explicitly test against the VolumeOpts -# class (and other future *Opts classes -# which have a display range/cmap). +class ImageOpts(fsldisplay.DisplayOpts): + """A class which describes how an :class:`.Image` should be displayed. + """ + + + resolution = props.Real(maxval=10, default=1, clamped=True) + """Data resolution in world space. The minimum value is set in __init__.""" + + + volume = props.Int(minval=0, maxval=0, default=0, clamped=True) + """If the data is 4D , the current volume to display.""" + + + transform = props.Choice( + ('affine', 'pixdim', 'id'), + labels=[strings.choices['ImageOpts.transform.affine'], + strings.choices['ImageOpts.transform.pixdim'], + strings.choices['ImageOpts.transform.id']], + default='pixdim') + """This property defines how the overlay should be transformd into the display + coordinate system. + + - ``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 + interpreted as :math:`1mm^3` isotropic, with the origin at voxel + (0,0,0). + """ + + + def __init__(self, *args, **kwargs): + + nounbind = kwargs.get('nounbind', []) + nounbind.extend(('transform', 'resolution', 'volume')) + + kwargs['nounbind'] = nounbind + + fsldisplay.DisplayOpts.__init__(self, *args, **kwargs) + + overlay = self.overlay + + # The display<->* transformation matrices + # are created in the _transformChanged method + self.__xforms = {} + self.__setupTransforms() + + # is this a 4D volume? + if self.is4D(): + self.setConstraint('volume', 'maxval', overlay.shape[3] - 1) + + self.addListener('transform', self.name, self.__transformChanged) + + self.__oldTransform = None + self.__transform = self.transform + self.__transformChanged() + + # limit resolution to the image dimensions + self.resolution = min(overlay.pixdim[:3]) + self.setConstraint('resolution', 'minval', self.resolution) + + + def destroy(self): + self.removeListener('transform', self.name) + + + def __setupTransforms(self): + """Calculates transformation matrices between all of the possible + spaces in which the overlay may be displayed. + + These matrices are accessible via the :meth:`getTransform` method. + """ + + # TODO This is obviously volumetric specific + + if not isinstance(self.__overlay, fslimage.Image): + log.warn('Non-volumetric types not supported yet') + return + image = self.__overlay -class VolumeOpts(fsldisplay.DisplayOpts): + voxToIdMat = np.eye(4) + voxToPixdimMat = np.diag(list(image.pixdim[:3]) + [1.0]) + voxToAffineMat = image.voxToWorldMat.T + + idToVoxMat = transform.invert(voxToIdMat) + idToPixdimMat = transform.concat(idToVoxMat, voxToPixdimMat) + idToAffineMat = transform.concat(idToVoxMat, voxToAffineMat) + + pixdimToVoxMat = transform.invert(voxToPixdimMat) + pixdimToIdMat = transform.concat(pixdimToVoxMat, voxToIdMat) + pixdimToAffineMat = transform.concat(pixdimToVoxMat, voxToAffineMat) + + affineToVoxMat = image.worldToVoxMat.T + affineToIdMat = transform.concat(affineToVoxMat, voxToIdMat) + affineToPixdimMat = transform.concat(affineToVoxMat, voxToPixdimMat) + + self.__xforms['id', 'id'] = np.eye(4) + self.__xforms['id', 'pixdim'] = idToPixdimMat + self.__xforms['id', 'affine'] = idToAffineMat + + self.__xforms['pixdim', 'pixdim'] = np.eye(4) + self.__xforms['pixdim', 'id'] = pixdimToIdMat + self.__xforms['pixdim', 'affine'] = pixdimToAffineMat + + self.__xforms['affine', 'affine'] = np.eye(4) + self.__xforms['affine', 'id'] = affineToIdMat + self.__xforms['affine', 'pixdim'] = affineToPixdimMat + + + def getTransform(self, from_, to, xform=None): + """Return a matrix which may be used to transform coordinates + from ``from_`` to ``to``. Valid values for ``from_`` and ``to`` + are: + - ``id``: Voxel coordinates + + - ``pixdim``: Voxel coordinates, scaled by voxel dimensions + + - ``affine``: World coordinates, as defined by the NIFTI1 + ``qform``/``sform``. See + :attr:`~fsl.data.image.Image.voxToWorldMat`. + + - ``voxel``: Equivalent to ``id``. + + - ``display``: Equivalent to the current value of :attr:`transform`. + + - ``world``; Equivalent to ``affine``. + + If the ``xform`` parameter is provided, and one of ``from_`` or ``to`` + is ``display``, the value of ``xform`` is used instead of the current + value of :attr:`transform`. + """ + + # TODO non-volumetric types + if not isinstance(self.__overlay, fslimage.Image): + raise RuntimeError('Non-volumetric types not supported yet') + + if xform is None: + xform = self.transform + + if from_ == 'display': from_ = xform + elif from_ == 'world': from_ = 'affine' + elif from_ == 'voxel': from_ = 'id' + + if to == 'display': to = xform + elif to == 'world': to = 'affine' + elif to == 'voxel': to = 'id' + + return self.__xforms[from_, to] + + + def getLastTransform(self): + """Returns the most recent value of the :attr:`transform` property, + before its current value. + """ + return self.__oldTransform + + + def __transformChanged(self, *a): + """Called when the :attr:`transform` property is changed.""" + + # Store references to the previous display related transformation + # matrices, just in case anything (hint the DisplayContext object) + # needs them for any particular reason (hint: so the DisplayContext + # can preserve the current display location, in terms of image world + # space, when the transform of the selected image changes) + self.__oldTransform = self.__transform + self.__transform = self.transform + + +class VolumeOpts(ImageOpts): """A class which describes how an :class:`.Image` should be displayed. This class doesn't have much functionality - it is up to things which @@ -93,10 +257,6 @@ class VolumeOpts(fsldisplay.DisplayOpts): def __init__(self, overlay, display, overlayList, displayCtx, parent=None): """Create a :class:`VolumeOpts` instance for the specified image.""" - if not isinstance(overlay, fslimage.Image): - raise RuntimeError('{} can only be used with an {} overlay'.format( - type(self).__name__, fslimage.Image.__name__)) - # Attributes controlling image display. Only # determine the real min/max for small images - # if it's memory mapped, we have no idea how big @@ -133,12 +293,12 @@ class VolumeOpts(fsldisplay.DisplayOpts): self.setConstraint('displayRange', 'minDistance', dMinDistance) - fsldisplay.DisplayOpts.__init__(self, - overlay, - display, - overlayList, - displayCtx, - parent) + ImageOpts.__init__(self, + overlay, + display, + overlayList, + displayCtx, + parent) # The displayRange property of every child VolumeOpts # instance is linked to the corresponding @@ -166,6 +326,8 @@ class VolumeOpts(fsldisplay.DisplayOpts): def destroy(self): + ImageOpts.destroy(self) + if self.getParent() is not None: display = self.display display.removeListener('brightness', self.name)