From 6094d2261d273c9c4192df9ca4e6c89efd5c944a Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauld.mccarthy@gmail.com> Date: Fri, 16 Oct 2015 17:09:44 +0100 Subject: [PATCH] GEDMode is now fully operational. A new DisplayContext property, 'displaySpace' allows the user to control the Image transform for all loaded image overlays, switching between 'world', 'pixdim', and the space for a specified reference image. --- fsl/data/strings.py | 7 +- fsl/fsleyes/controls/canvassettingspanel.py | 32 +++- fsl/fsleyes/controls/lightboxtoolbar.py | 36 ++++- fsl/fsleyes/controls/orthotoolbar.py | 22 ++- fsl/fsleyes/displaycontext/displaycontext.py | 157 ++++++++++++++++--- fsl/fsleyes/displaycontext/volumeopts.py | 2 + fsl/fsleyes/tooltips.py | 5 + 7 files changed, 228 insertions(+), 33 deletions(-) diff --git a/fsl/data/strings.py b/fsl/data/strings.py index f346ff53c..84de93380 100644 --- a/fsl/data/strings.py +++ b/fsl/data/strings.py @@ -334,7 +334,9 @@ labels = TypeDict({ properties = TypeDict({ - 'Profile.mode' : 'Profile', + 'Profile.mode' : 'Profile', + + 'DisplayContext.displaySpace' : 'Display space', 'CanvasPanel.syncLocation' : 'Sync location', 'CanvasPanel.syncOverlayOrder' : 'Sync overlay order', @@ -470,6 +472,9 @@ properties = TypeDict({ choices = TypeDict({ + 'DisplayContext.displaySpace' : {'world' : 'World coordinates', + 'pixdim' : 'Scaled voxels'}, + 'SceneOpts.colourBarLocation' : {'top' : 'Top', 'bottom' : 'Bottom', 'left' : 'Left', diff --git a/fsl/fsleyes/controls/canvassettingspanel.py b/fsl/fsleyes/controls/canvassettingspanel.py index a4ac500b6..95a0877d4 100644 --- a/fsl/fsleyes/controls/canvassettingspanel.py +++ b/fsl/fsleyes/controls/canvassettingspanel.py @@ -16,6 +16,7 @@ import props import pwidgets.widgetlist as widgetlist import fsl.data.strings as strings +import fsl.data.image as fslimage import fsl.fsleyes.panel as fslpanel import fsl.fsleyes.tooltips as fsltooltips @@ -51,6 +52,7 @@ class CanvasSettingsPanel(fslpanel.FSLEyesPanel): _CANVASPANEL_PROPS _SCENEOPTS_PROPS + _DISPLAYCTX_PROPS _ORTHOOPTS_PROPS _LIGHTBOXOPTS_PROPS """ @@ -114,7 +116,19 @@ class CanvasSettingsPanel(fslpanel.FSLEyesPanel): widget, displayName=strings.properties[opts, dispProp.key], tooltip=fsltooltips.properties[opts, dispProp.key], - groupName='scene') + groupName='scene') + + for dispProp in _DISPLAYCTX_PROPS: + widget = props.buildGUI(self.__widgets, + displayCtx, + dispProp, + showUnlink=False) + + self.__widgets.AddWidget( + widget, + displayName=strings.properties[displayCtx, dispProp.key], + tooltip=fsltooltips.properties[displayCtx, dispProp.key], + groupName='scene') for dispProp in panelProps: @@ -168,6 +182,22 @@ display for :class:`.SceneOpts` properties. """ +def _displaySpaceOptionName(opt): + + if isinstance(opt, fslimage.Image): + return opt.name + else: + return strings.choices['DisplayContext.displaySpace'][opt] + + +_DISPLAYCTX_PROPS = [ + props.Widget('displaySpace', labels=_displaySpaceOptionName) +] +"""A list of :class:`props.Widget` items defining controls to +display for :class:`.DisplayContext` properties. +""" + + _ORTHOOPTS_PROPS = [ props.Widget('layout', labels=strings.choices['OrthoOpts.layout']), props.Widget('zoom', spin=False, showLimits=False), diff --git a/fsl/fsleyes/controls/lightboxtoolbar.py b/fsl/fsleyes/controls/lightboxtoolbar.py index 0e4289f6e..fc1d5e3b4 100644 --- a/fsl/fsleyes/controls/lightboxtoolbar.py +++ b/fsl/fsleyes/controls/lightboxtoolbar.py @@ -18,6 +18,7 @@ import fsl.fsleyes.actions as actions import fsl.fsleyes.icons as fslicons import fsl.fsleyes.tooltips as fsltooltips import fsl.data.strings as strings +import fsl.data.image as fslimage class LightBoxToolBar(fsltoolbar.FSLEyesToolBar): @@ -75,7 +76,16 @@ class LightBoxToolBar(fsltoolbar.FSLEyesToolBar): 'sliceSpacing' : fsltooltips.properties[lbOpts, 'sliceSpacing'], 'zrange' : fsltooltips.properties[lbOpts, 'zrange'], 'zoom' : fsltooltips.properties[lbOpts, 'zoom'], + 'displaySpace' : fsltooltips.properties[displayCtx, + 'displaySpace'] } + + def displaySpaceOptionName(opt): + + if isinstance(opt, fslimage.Image): + return opt.name + else: + return strings.choices['DisplayContext.displaySpace'][opt] specs = { @@ -117,6 +127,11 @@ class LightBoxToolBar(fsltoolbar.FSLEyesToolBar): spin=False, showLimits=False, tooltip=tooltips['zoom']), + + 'displaySpace' : props.Widget( + 'displaySpace', + labels=displaySpaceOptionName, + tooltip=tooltips['displaySpace']) } # Slice spacing and zoom go on a single panel @@ -124,25 +139,30 @@ class LightBoxToolBar(fsltoolbar.FSLEyesToolBar): sizer = wx.FlexGridSizer(2, 2) panel.SetSizer(sizer) - more = props.buildGUI(self, self, specs['more']) - screenshot = props.buildGUI(self, lb, specs['screenshot']) - movieMode = props.buildGUI(self, lb, specs['movieMode']) - zax = props.buildGUI(self, lbOpts, specs['zax']) - zrange = props.buildGUI(self, lbOpts, specs['zrange']) - zoom = props.buildGUI(panel, lbOpts, specs['zoom']) - spacing = props.buildGUI(panel, lbOpts, specs['sliceSpacing']) + more = props.buildGUI(self, self, specs['more']) + screenshot = props.buildGUI(self, lb, specs['screenshot']) + movieMode = props.buildGUI(self, lb, specs['movieMode']) + zax = props.buildGUI(self, lbOpts, specs['zax']) + zrange = props.buildGUI(self, lbOpts, specs['zrange']) + zoom = props.buildGUI(panel, lbOpts, specs['zoom']) + spacing = props.buildGUI(panel, lbOpts, specs['sliceSpacing']) + displaySpace = props.buildGUI(panel, displayCtx, specs['displaySpace']) zoomLabel = wx.StaticText(panel) spacingLabel = wx.StaticText(panel) zoomLabel .SetLabel(strings.properties[lbOpts, 'zoom']) spacingLabel.SetLabel(strings.properties[lbOpts, 'sliceSpacing']) + displaySpace = self.MakeLabelledTool( + displaySpace, + strings.properties[displayCtx, 'displaySpace']) + sizer.Add(zoomLabel) sizer.Add(zoom, flag=wx.EXPAND) sizer.Add(spacingLabel) sizer.Add(spacing, flag=wx.EXPAND) - tools = [more, screenshot, zax, movieMode, zrange, panel] + tools = [more, screenshot, zax, movieMode, displaySpace, zrange, panel] self.SetTools(tools) diff --git a/fsl/fsleyes/controls/orthotoolbar.py b/fsl/fsleyes/controls/orthotoolbar.py index 8ad584f39..62813a43e 100644 --- a/fsl/fsleyes/controls/orthotoolbar.py +++ b/fsl/fsleyes/controls/orthotoolbar.py @@ -11,6 +11,7 @@ import props +import fsl.data.image as fslimage import fsl.fsleyes.toolbar as fsltoolbar import fsl.fsleyes.icons as fslicons import fsl.fsleyes.tooltips as fsltooltips @@ -78,7 +79,8 @@ class OrthoToolBar(fsltoolbar.FSLEyesToolBar): Re-creates all tools shown on this ``OrthoToolBar``. """ - + + dctx = self._displayCtx ortho = self.orthoPanel orthoOpts = ortho.getSceneOptions() profile = ortho.getCurrentProfile() @@ -109,9 +111,11 @@ class OrthoToolBar(fsltoolbar.FSLEyesToolBar): 'showXCanvas' : fsltooltips.properties[orthoOpts, 'showXCanvas'], 'showYCanvas' : fsltooltips.properties[orthoOpts, 'showYCanvas'], 'showZCanvas' : fsltooltips.properties[orthoOpts, 'showZCanvas'], + 'displaySpace' : fsltooltips.properties[dctx, 'displaySpace'], 'resetZoom' : fsltooltips.actions[ profile, 'resetZoom'], 'centreCursor' : fsltooltips.actions[ profile, 'centreCursor'], 'more' : fsltooltips.actions[ self, 'more'], + } targets = {'screenshot' : ortho, @@ -121,10 +125,18 @@ class OrthoToolBar(fsltoolbar.FSLEyesToolBar): 'showXCanvas' : orthoOpts, 'showYCanvas' : orthoOpts, 'showZCanvas' : orthoOpts, + 'displaySpace' : dctx, 'resetZoom' : profile, 'centreCursor' : profile, 'more' : self} + def displaySpaceOptionName(opt): + + if isinstance(opt, fslimage.Image): + return opt.name + else: + return strings.choices['DisplayContext.displaySpace'][opt] + toolSpecs = [ @@ -155,7 +167,9 @@ class OrthoToolBar(fsltoolbar.FSLEyesToolBar): actions.ActionButton('centreCursor', icon=icons['centreCursor'], tooltip=tooltips['centreCursor']), - + props.Widget( 'displaySpace', + labels=displaySpaceOptionName, + tooltip=tooltips['displaySpace']), props.Widget( 'zoom', spin=False, showLimits=False, @@ -167,10 +181,10 @@ class OrthoToolBar(fsltoolbar.FSLEyesToolBar): for spec in toolSpecs: widget = props.buildGUI(self, targets[spec.key], spec) - if spec.key == 'zoom': + if spec.key in ('zoom', 'displaySpace'): widget = self.MakeLabelledTool( widget, - strings.properties[targets[spec.key], 'zoom']) + strings.properties[targets[spec.key], spec.key]) tools.append(widget) diff --git a/fsl/fsleyes/displaycontext/displaycontext.py b/fsl/fsleyes/displaycontext/displaycontext.py index 53d6217f9..c547ded1f 100644 --- a/fsl/fsleyes/displaycontext/displaycontext.py +++ b/fsl/fsleyes/displaycontext/displaycontext.py @@ -14,7 +14,9 @@ import logging import props -import display as fsldisplay +import display as fsldisplay +import fsl.data.image as fslimage +import fsl.utils.transform as transform log = logging.getLogger(__name__) @@ -35,11 +37,11 @@ class DisplayContext(props.SyncableHasProperties): A ``DisplayContext`` instance is responsible for creating and destroying - :class:`.Display` instances for every overlay in the ``OverlayList``. These - ``Display`` instances, and the corresponding ``DisplayOpts`` instances - (which, in turn, are created/destroyed by ``Display`` instances) can be - accessed with the :meth:`getDisplay` and :meth:`getOpts` method - respectively. + :class:`.Display` instances for every overlay in the + ``OverlayList``. These ``Display`` instances, and the corresponding + :class:`.DisplayOpts` instances (which, in turn, are created/destroyed by + ``Display`` instances) can be accessed with the :meth:`getDisplay` and + :meth:`getOpts` method respectively. A number of other useful methods are provided by a ``DisplayContext`` @@ -79,7 +81,7 @@ class DisplayContext(props.SyncableHasProperties): bounds = props.Bounds(ndims=3) """This property contains the min/max values of a bounding box (in display coordinates) which is big enough to contain all of the overlays in the - :attr:`overlays` list. + :class:`.OverlayList`. .. warning:: This property shouid be treated as read-only. """ @@ -113,6 +115,41 @@ class DisplayContext(props.SyncableHasProperties): """ + displaySpace = props.Choice(('pixdim', 'world'), default='pixdim') + """The *space* in which overlays are displayed. This property globally + controls the :attr:`.ImageOpts.transform` property of all :class:`.Image` + overlays. It has three settings, described below. + + + 1. **Scaled voxel** space (a.k.a. ``pixdim``) + + All :class:`.Image` overlays are displayed with scaled voxels - the + :attr:`.ImageOpts.transform` property for every ``Image`` overlay is + set to ``pixdim``. + + 2. **World** space (a.k.a. ``world``) + + All :class:`.Image` overlays are displayed in the space defined by + their affine transformation matrix - the :attr:`.ImageOpts.transform` + property for every ``Image`` overlay is set to ``affine``. + + 3. **Reference image** space + + A single :class:`.Image` overlay is selected as a *reference* image, + and is displayed in scaled voxel space (:attr:`.ImageOpts.transform` is + set to ``pixdim``). All other ``Image`` overlays are transformed into + this reference space - their :attr:`.ImageOpts.transform` property is + set to ``custom``, and their :attr:`.ImageOpts.customXform` matrix is + set such that it transforms from the image voxel space to the scaled + voxel space of the reference image. + + .. note:: The :attr:`.ImageOpts.transform` property of any :class:`.Image` + overlay can be set independently of this property. However, + whenever this property changes, it will change the ``transform`` + property for every ``Image``, in the manner described above. + """ + + def __init__(self, overlayList, parent=None): """Create a ``DisplayContext``. @@ -126,7 +163,7 @@ class DisplayContext(props.SyncableHasProperties): props.SyncableHasProperties.__init__( self, parent=parent, - nounbind=['overlayGroups'], + nounbind=['overlayGroups', 'displaySpace'], nobind=[ 'syncOverlayDisplay']) self.__overlayList = overlayList @@ -154,12 +191,11 @@ class DisplayContext(props.SyncableHasProperties): if parent is None: self.__prevOverlayListLen = 0 else: self.__prevOverlayListLen = len(overlayList) - - # Ensure that a Display object exists - # for every overlay, and that the display - # bounds property is initialised + # The overlayListChanged and displaySpaceChanged + # methods do important things - check them out self.__displays = {} self.__overlayListChanged() + self.__displaySpaceChanged() overlayList.addListener('overlays', self.__name, @@ -168,6 +204,9 @@ class DisplayContext(props.SyncableHasProperties): self.addListener('syncOverlayDisplay', self.__name, self.__syncOverlayDisplayChanged) + self.addListener('displaySpace', + self.__name, + self.__displaySpaceChanged) log.memory('{}.init ({})'.format(type(self).__name__, id(self))) @@ -374,17 +413,38 @@ class DisplayContext(props.SyncableHasProperties): # property is valid self.__syncOverlayOrder() - # Ensure that the bounds property is accurate + # Ensure that the bounds + # property is accurate self.__updateBounds() - # If the overlay list was empty, and is - # now non-empty, centre the currently - # selected location (but see the comments - # in __init__ about this). + # Ensure that the displaySpace + # property options are in sync + # with the overlay list + self.__updateDisplaySpaceOptions() + + # Initliase the transform property + # of any Image overlays which have + # just been added to the list, + oldList = self.__overlayList.getLastValue('overlays')[:] + for overlay in self.__overlayList: + if isinstance(overlay, fslimage.Image) and \ + (overlay not in oldList): + self.__setTransform(overlay) + + # If the overlay list was empty, + # and is now non-empty ... if (self.__prevOverlayListLen == 0) and (len(self.__overlayList) > 0): + + # Set the displaySpace to + # the first new image + for overlay in self.__overlayList: + if isinstance(overlay, fslimage.Image): + self.displaySpace = overlay + break - # initialise the location to be - # the centre of the world + # Centre the currently selected + # location (but see the comments + # in __init__ about this). b = self.bounds self.location.xyz = [ b.xlo + b.xlen / 2.0, @@ -403,6 +463,65 @@ class DisplayContext(props.SyncableHasProperties): self.setConstraint('selectedOverlay', 'maxval', 0) + def __updateDisplaySpaceOptions(self): + """Updates the :attr:`displaySpace` property so it is synchronised with + the current contents of the :class:`.OverlayList` + + This method is called by the :meth:`__overlayListChanged` method. + """ + + choiceProp = self.getProp('displaySpace') + choices = ['pixdim', 'world'] + + for overlay in self.__overlayList: + if isinstance(overlay, fslimage.Image): + choices.append(overlay) + + choiceProp.setChoices(choices, instance=self) + + + def __setTransform(self, image): + """Sets the :attr:`.ImageOpts.transform` property associated with + the given :class:`.Image` overlay to a sensible value, given the + current value of the :attr:`.displaySpace` property. + + Called by the :meth:`__displaySpaceChanged` method, and by + :meth:`__overlayListChanged` for any :class:`.Image` overlays which + have been newly added to the :class:`.OverlayList`. + + :arg image: An :class:`.Image` overlay. + """ + + space = self.displaySpace + opts = self.getOpts(image) + + if space == 'pixdim': opts.transform = 'pixdim' + elif space == 'world': opts.transform = 'affine' + elif image is space: opts.transform = 'pixdim' + else: + refOpts = self.getOpts(space) + xform = transform.concat( + opts .getTransform('voxel', 'world'), + refOpts.getTransform('world', 'pixdim')) + + opts.customXform = xform + opts.transform = 'custom' + + + def __displaySpaceChanged(self, *a): + """Called when the :attr:`displaySpace` property changes. Updates the + :attr:`.ImageOpts.transform` property for all :class:`.Image` overlays + in the :class:`.OverlayList`. + """ + + for overlay in self.__overlayList: + + if not isinstance(overlay, fslimage.Image): + continue + + self.__setTransform(overlay) + + def __syncOverlayOrder(self): """Ensures that the :attr:`overlayOrder` property is up to date with respect to the :class:`.OverlayList`. diff --git a/fsl/fsleyes/displaycontext/volumeopts.py b/fsl/fsleyes/displaycontext/volumeopts.py index 61966f8f6..8dbad5234 100644 --- a/fsl/fsleyes/displaycontext/volumeopts.py +++ b/fsl/fsleyes/displaycontext/volumeopts.py @@ -166,6 +166,8 @@ class ImageOpts(fsldisplay.DisplayOpts): def destroy(self): """Calls the :meth:`.DisplayOpts.destroy` method. """ + self.removeListener('transform', self.name) + self.removeListener('customXform', self.name) fsldisplay.DisplayOpts.destroy(self) diff --git a/fsl/fsleyes/tooltips.py b/fsl/fsleyes/tooltips.py index 02d5542a5..6878df2f5 100644 --- a/fsl/fsleyes/tooltips.py +++ b/fsl/fsleyes/tooltips.py @@ -22,6 +22,11 @@ from fsl.utils.typedict import TypeDict properties = TypeDict({ + # DisplayContext + + 'DisplayContext.displaySpace' : 'The space in which overlays are ' + 'displayed.', + # Overlay Display 'Display.name' : 'The name of this overlay.', -- GitLab