Source code for fsleyes.plugins.controls.atlaspanel.atlaspanel

#
# atlaspanel.py - The AtlasPanel class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`AtlasPanel`, a *FSLeyes control* panel
which allows the user to browse the FSL atlas images. See the
:mod:`~fsleyes` package documentation for more details on control panels,
and the :mod:`.atlases` module for more details on the atlases available in
FSL.
"""


import          logging

import numpy as np
import          wx

import fsl.data.image                as fslimage
import fsl.data.atlases              as atlases
import fsl.data.constants            as constants
import fsl.utils.idle                as idle

import fsleyes_props                 as props
import fsleyes_widgets.notebook      as notebook
import fsleyes_widgets.utils.status  as status

import fsleyes.views.canvaspanel     as canvaspanel
import fsleyes.controls.controlpanel as ctrlpanel
import fsleyes.strings               as strings
from . import                           atlasmanagementpanel
from . import                           atlasoverlaypanel
from . import                           atlasinfopanel


log = logging.getLogger(__name__)


[docs]class AtlasPanel(ctrlpanel.ControlPanel): """An ``AtlasPanel`` is a :class:`.ControlPanel` which allows the user to view atlas information, and to browse through the atlases that come shipped with FSL. The ``AtlasPanel`` interface is provided by some sub-panels, which are displayed in a :class:`fsleyes_widgets.Notebook` panel. The ``AtlasPanel`` itself provides a number of convenience methods that are used by these sub-panels: ============================== =========================================== :class:`.AtlasInfoPanel` Displays information for the current :attr:`.DisplayContext.location` from atlases selected by the user. :class:`.AtlasOverlayPanel` Allows the user to search through all atlases for specific regions, and to toggle on/off overlays for those regions. :class:`.AtlasManagementPanel` Allows the user to add/remove atlases. ============================== =========================================== **Loading atlases** The :class:`AtlasPanel` class provides the :meth:`loadAtlas` method, which is used by sub-panels to load atlas images. .. _atlas-panel-atlas-overlays: **Toggling atlas overlays** Both of the sub-panels allow the user to add/remove overlays to/from the :class:`.OverlayList`. The following overlay types can be added: - A complete summary :class:`.LabelAtlas`, which is a 3D image where each region has a discrete integer label. These images are added with a :attr:`.Display.overlayType` of ``label``. - A mask image containing a single region, extracted from a :class:`.LabelAtlas`. These images are added with a :attr:`.Display.overlayType` of ``mask``. - A 3D image containing the statistic image for a single region, extracted from a :class:`.StatisticAtlas`. These images are added with a :attr:`.Display.overlayType` of ``volume``. The following methods allow these overlays to be toggled on/off, and to query their state: .. autosummary:: :nosignatures: toggleOverlay getOverlayName getOverlayState .. _atlas-panel-overlay-names: **Atlas overlay names** When an atlas overlay is added, its :attr:`.Image.name` (and subsequently its :attr:`.Display.name`) are set to a name which has the following structure:: atlasID/overlayType/regionName where: - ``atlasID`` is the atlas identifier (see the :mod:`.atlases` module). - ``overlayType`` is either ``label``, ``prob``, or ``stat``, depending on whether the overlay is a discrete label image, a probaility image, or a statistic image.. - ``regionName`` is the name of the region, or ``all`` if the overlay is a complete :class:`.LabelAtlas`. .. image:: images/atlaspanel_overlay_names.png :scale: 50% :align: center This name is used by the ``AtlasPanel`` to identify the overlay in the :class:`.OverlayList`. .. warning:: If the name of these overlays is changed, the ``AtlasPanel`` will not be able to find them in the :class:`.OverlayList`, and the :meth:`toggleOverlay` and :meth:`getOverlayState` methods will stop working properly. So don't change the atlas overlay names! **Locating regions** Finally, the :meth:`locateRegion` method allows the :attr:`.DisplayContext.location` to be moved to the location of a specific region in a specific atlas. """
[docs] @staticmethod def supportedViews(): """The ``MelodicClassificationPanel`` is restricted for use with :class:`.OrthoPanel`, :class:`.LightBoxPanel` and :class:`.Scene3DPanel` viewws. """ return [canvaspanel.CanvasPanel]
[docs] @staticmethod def defaultLayout(): """Returns a dictionary of arguments to be passed to the :meth:`.ViewPanel.togglePanel` method. """ return {'location' : wx.BOTTOM}
[docs] def __init__(self, parent, overlayList, displayCtx, viewPanel): """Create an ``AtlasPanel``. :arg parent: The :mod:`wx` parent object. :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. :arg viewPanel: The :class:`.ViewPanel` instance. """ ctrlpanel.ControlPanel.__init__( self, parent, overlayList, displayCtx, viewPanel) # Make sure the atlas # registry is up to date atlases.rescanAtlases() # See the enableAtlasPanel method # for info about this attribute. self.__atlasPanelEnableStack = 0 # Cache of loaded atlases # and enabled atlas overlays. self.__enabledOverlays = {} self.__loadedAtlases = {} self.__notebook = notebook.Notebook(self) self.__sizer = wx.BoxSizer(wx.HORIZONTAL) self.__sizer.Add(self.__notebook, flag=wx.EXPAND, proportion=1) self.SetSizer(self.__sizer) self.__infoPanel = atlasinfopanel.AtlasInfoPanel( self.__notebook, overlayList, displayCtx, self) # Overlay panel, containing a list of regions, # allowing the user to add/remove overlays self.__overlayPanel = atlasoverlaypanel.AtlasOverlayPanel( self.__notebook, overlayList, displayCtx, self) self.__managePanel = atlasmanagementpanel.AtlasManagementPanel( self.__notebook, overlayList, displayCtx, self) self.__notebook.AddPage(self.__infoPanel, strings.titles[self.__infoPanel]) self.__notebook.AddPage(self.__overlayPanel, strings.titles[self.__overlayPanel]) self.__notebook.AddPage(self.__managePanel, strings.titles[self.__managePanel]) self.overlayList.addListener('overlays', self.name, self.__overlayListChanged) self.Layout() self.SetMinSize(self.__sizer.GetMinSize())
[docs] def destroy(self): """Must be called on when this ``AtlasPanel`` is no longer needed. Calls the ``destroy`` methods of the :class:`.AtlasInfoPanel` and :class:`.AtlasOverlayPanel`, and then calls :meth:`.ControlPanel.destroy`. """ self.__loadedAtlases = None self.__enabledOverlays = None self.__infoPanel .destroy() self.__overlayPanel .destroy() self.__managePanel .destroy() self.overlayList.removeListener('overlays', self.name) ctrlpanel.ControlPanel.destroy(self)
[docs] def Enable(self, enable=True): """Enables/disables this ``AtlasPanel``. """ self.__infoPanel .Enable(enable) self.__overlayPanel.Enable(enable) self.__managePanel .Enable(enable)
[docs] def Disable(self): """Disables this ``AtlasPanel``. """ self.Enable(False)
[docs] def enableAtlasPanel(self, enable=True): """Disables/enables the :class:`.AtlasPanel` which contains this ``AtlasOverlayPanel``. This method is used by :class:`OverlayListWidget` instances. This method keeps a count of the number of times that it has been called - the count is increased every time a request is made to disable the ``AtlasPanel``, and decreased on requests to enable it. The ``AtlasPanel`` is only enabled when the count reaches 0. This ugly method solves an awkward problem - the ``AtlasOverlayPanel`` disables the ``AtlasPanel`` when an atlas overlay is toggled on/off (via an ``OverlayListWidget``), and when an atlas region list is being generated (via the :meth:`__onAtlasSelect` method). If both of these things occur at the same time, the ``AtlasPanel`` could be prematurely re-enabled. This method overcomes this problem. """ count = self.__atlasPanelEnableStack log.debug('enableAtlasPanel({}, count={})'.format(enable, count)) if enable: count -= 1 if count <= 0: count = 0 self.Enable() else: count += 1 self.Disable() self.__atlasPanelEnableStack = count
[docs] def loadAtlas(self, atlasID, summary, onLoad=None, onError=None, matchResolution=True): """Loads the atlas image with the specified ID. The atlas is loaded asynchronously (via the :mod:`.idle` module), as it can take some time. Use the `onLoad` argument if you need to do something when the atlas has been loaded. :arg onLoad: Optional. A function which is called when the atlas has been loaded, and which is passed the loaded :class:`.Atlas` image. :arg onError: Optional. A function which is called if the atlas loading job raises an error. Passed the ``Exception`` that was raised. :arg matchResolution: If ``True`` (the default), the version of the atlas with the most suitable resolution, based on the current contents of the :class:`.OverlayList`, is loaded. See the :func:`.atlases.loadAtlas` function for details on the other arguments. """ # Get the atlas description, and the # most suitable resolution to load. desc = atlases.getAtlasDescription(atlasID) res = self.__getSuitableResolution(desc, matchResolution) if desc.atlasType == 'label': summary = True atlas = self.__loadedAtlases.get((atlasID, summary, res), None) if atlas is None: log.debug('Loading atlas {}/{}'.format( atlasID, 'label' if summary else 'prob')) status.update('Loading atlas {}...'.format(atlasID), timeout=None) def load(): # the panel might get destroyed # before this function is called if self.destroyed: return atlas = atlases.loadAtlas(atlasID, summary, resolution=res) # The atlas panel may be destroyed # before the atlas is loaded. if not self or self.destroyed: return self.__loadedAtlases[atlasID, summary, res] = atlas status.update('Atlas {} loaded.'.format(atlasID)) if onLoad is not None: idle.idle(onLoad, atlas) idle.run(load, onError=onError) # If the atlas has already been loaded, # pass it straight to the onload function elif onLoad is not None: onLoad(atlas)
def __getSuitableResolution(self, desc, matchResolution=True): """Used by the :meth:`loadAtlas` method. Determines a suitable atlas resolution to load, based on the current contents of the :class:`.OverlayList`. """ niftis = [o for o in self.overlayList if (isinstance(o, fslimage.Nifti) and o.getXFormCode() == constants.NIFTI_XFORM_MNI_152)] # No overlays to match resolution against if len(niftis) == 0: matchResolution = False # If we don't need to match resolution, # return the highest available resolution # (the lowest value). if not matchResolution: return np.concatenate(desc.pixdims).min() # Find the highest resolution # in the overlay list pixdims = [o.pixdim[:3] for o in niftis] res = np.concatenate(pixdims).min() # identify the atlas with the # nearest resolution to the # requested resolution reses = np.concatenate(desc.pixdims) res = reses[np.argmin(np.abs(reses - res))] return res
[docs] def getOverlayName(self, atlasID, labelIdx, summary): """Returns a name to be used for the specified atlas (see the section on :ref:`atlas names <atlas-panel-overlay-names>`). :arg atlasID: Atlas identifier :arg labelIdx: Label index, or ``None`` for a complete atlas. :arg summary: ``True`` corresponds to a label atlas, ``False`` to a probabilistic atlas. """ atlasDesc = atlases.getAtlasDescription(atlasID) if atlasDesc.atlasType == 'summary' or labelIdx is None: summary = True if summary: overlayType = 'label' else: overlayType = 'prob' if labelIdx is None: overlayName = '{}/{}/all'.format(atlasID, overlayType) else: overlayName = '{}/{}/{}' .format(atlasID, overlayType, atlasDesc.labels[labelIdx].name) return overlayName, summary
[docs] def getOverlayState(self, atlasID, labelIdx, summary): """Returns ``True`` if the specified atlas overlay is in the :class:`.OverlayList`, ``False`` otherwise. See :meth:`getOverlayName` for details on the arguments. """ name, _ = self.getOverlayName(atlasID, labelIdx, summary) return self.overlayList.find(name) is not None
[docs] def toggleOverlay(self, atlasID, labelIdx, summary, onLoad=None, onError=None): """Adds or removes the specified overlay to/from the :class:`.OverlayList`. :arg onLoad: Optional function to be called when the overlay has been added/removed. :arg onError: Optional function to be called if an error occurs while loading an overlay. See :meth:`getOverlayName` for details on the other arguments. """ atlasDesc = atlases.getAtlasDescription(atlasID) overlayName, summary = self.getOverlayName(atlasID, labelIdx, summary) overlay = self.overlayList.find(overlayName) if overlay is not None: self.overlayList.disableListener('overlays', self.name) self.overlayList.remove(overlay) self.overlayList.enableListener('overlays', self.name) self.__enabledOverlays.pop(overlayName, None) self.__overlayPanel.setOverlayState( atlasDesc, labelIdx, summary, False) log.debug('Removed overlay {}'.format(overlayName)) if onLoad is not None: onLoad() return def realOnLoad(atlas): initprops = {} # label image if labelIdx is None: overlay = fslimage.Image(atlas) initprops['overlayType'] = 'label' else: # regional label image if summary: overlay = atlas.get(index=labelIdx, binary=False) initprops['overlayType'] = 'mask' initprops['colour'] = np.random.random(3) # regional statistic/probability image else: overlay = atlas.get(index=labelIdx) initprops['overlayType'] = 'volume' initprops['cmap'] = 'hot' initprops['displayRange'] = (atlasDesc.lower, atlasDesc.upper) initprops['clippingRange'] = (atlasDesc.lower, atlasDesc.upper) overlay.name = overlayName with props.suppress(self.overlayList, 'overlays', self.name): self.overlayList.append(overlay, **initprops) self.__overlayPanel.setOverlayState( atlasDesc, labelIdx, summary, True) self.__enabledOverlays[overlayName] = (overlay, atlasID, labelIdx, summary) log.debug('Added overlay {}'.format(overlayName)) if onLoad is not None: onLoad() self.loadAtlas(atlasID, summary, onLoad=realOnLoad, onError=onError)
[docs] def locateRegion(self, atlasID, labelIdx): """Moves the :attr:`.DisplayContext.location` to the specified region in the specified atlas. See the :class:`.AtlasDescription` class for details on atlas identifiers/label indices. :arg atlasID: Atlas identifier :arg labelIdx: Label index """ atlasDesc = atlases.getAtlasDescription(atlasID) label = atlasDesc.labels[labelIdx] overlay = self.displayCtx.getReferenceImage( self.displayCtx.getSelectedOverlay()) if overlay is None: log.warn('No reference image available - cannot locate region') opts = self.displayCtx.getOpts(overlay) worldLoc = (label.x, label.y, label.z) dispLoc = opts.transformCoords([worldLoc], 'world', 'display')[0] self.displayCtx.location.xyz = dispLoc
def __overlayListChanged(self, *a): """Called when the :class:`.OverlayList` changes. Makes sure that the :class:`.AtlasOverlayPanel` state is up to date - see the :meth:`.AtlasOverlayPanel.setOverlayState` method. """ for overlayName in list(self.__enabledOverlays.keys()): overlay, atlasID, labelIdx, summary = \ self.__enabledOverlays[overlayName] if overlay not in self.overlayList: self.__enabledOverlays.pop(overlayName) atlasDesc = atlases.getAtlasDescription(atlasID) self.__overlayPanel.setOverlayState( atlasDesc, labelIdx, summary, False)