From a1698720c40dccd48f6b1b8300248980892faa9c Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauld.mccarthy@gmail.com> Date: Fri, 13 Feb 2015 14:31:15 +0000 Subject: [PATCH] Atlas panel is slightly functional. User can choose which atlases to display information for, and can show/hide label masks for individual regions Fixes to GLVolume/GLMask colour map creation in situations where the image data only has a single value. --- fsl/data/atlases.py | 53 +++++-- fsl/data/image.py | 26 +++- fsl/data/strings.py | 5 +- fsl/fslview/controls/atlaspanel.py | 200 ++++++++++++++++++++++--- fsl/fslview/displaycontext/maskopts.py | 2 +- fsl/fslview/gl/glmask.py | 13 +- fsl/fslview/gl/glvolume.py | 5 +- fsl/fslview/layouts.py | 1 + fsl/fslview/views/canvaspanel.py | 13 ++ 9 files changed, 267 insertions(+), 51 deletions(-) diff --git a/fsl/data/atlases.py b/fsl/data/atlases.py index b03012ed7..9a4287f80 100644 --- a/fsl/data/atlases.py +++ b/fsl/data/atlases.py @@ -10,6 +10,8 @@ Instances of the :class:`Atlas` class is a +MNI152 + <atlas> <header> @@ -75,7 +77,18 @@ def listAtlases(): atlasFiles = glob.glob(op.join(ATLAS_DIR, '*.xml')) atlasDescs = map(AtlasDescription, atlasFiles) - return {d.key: d for d in atlasDescs} + return {d.atlasID: d for d in atlasDescs} + + +def loadAtlas(atlasDesc, loadSummary=False): + + if loadSummary or atlasDesc.atlasType == 'label': + return LabelAtlas(atlasDesc) + + if atlasDesc.atlasType == 'probabilistic': + return ProbabilisticAtlas(atlasDesc) + else: + raise ValueError('Unknown atlas type: {}'.format(atlasDesc.atlasType)) class AtlasDescription(object): @@ -92,9 +105,13 @@ class AtlasDescription(object): header = root.find('header') data = root.find('data') - self.key = op.splitext(op.basename(filename))[0] + self.atlasID = op.splitext(op.basename(filename))[0] self.name = header.find('name').text - self.atlasType = header.find('type').text + self.atlasType = header.find('type').text.lower() + + # Spelling error in some of the atlas.xml files. + if self.atlasType == 'probabalistic': + self.atlasType = 'probabilistic' images = header.findall('images') self.images = [] @@ -141,11 +158,12 @@ class AtlasDescription(object): # Load the appropriate transformation matrix # and transform all those voxel coordinates xform = fslimage.Image(self.images[0], loadData=False).voxToWorldMat - coords = transform.transform(coords, xform) + coords = transform.transform(coords, xform.T) # Update the coordinates # in our label objects for i, label in enumerate(self.labels): + label.x, label.y, label.z = coords[i] @@ -164,34 +182,39 @@ class Atlas(fslimage.Image): if imgRes < minImageRes: minImageRes = imgRes imageIdx = i - + if isLabel: imageFile = atlasDesc.summaryImages[imageIdx] else: imageFile = atlasDesc.images[ imageIdx] fslimage.Image.__init__(self, imageFile) + self.desc = atlasDesc + -class LabelAtlas(fslimage.Image): +class LabelAtlas(Atlas): def __init__(self, atlasDesc): Atlas.__init__(self, atlasDesc, isLabel=True) - def label(self, voxelLoc): + def label(self, worldLoc): + + voxelLoc = transform.transform([worldLoc], self.worldToVoxMat.T)[0] + val = self.data[voxelLoc[0], voxelLoc[1], voxelLoc[2]] - if self.atlasDesc.atlasType == 'Label': - return self.atlasDesc.label[val] + if self.desc.atlasType == 'label': + return val - elif self.atlasDesc.atlasType == 'Probabilistic': - return self.atlasDesc.label[val - 1] + elif self.desc.atlasType == 'probabilistic': + return val - 1 -class ProbabilisticAtlas(fslimage.Image): +class ProbabilisticAtlas(Atlas): def __init__(self, atlasDesc): Atlas.__init__(self, atlasDesc, isLabel=False) - def proportions(self, voxelLoc): - props = self.data[voxelLoc[0], voxelLoc[1], voxelLoc[2], :] - return zip(self.atlasDesc.labels, props) + def proportions(self, worldLoc): + voxelLoc = transform.transform([worldLoc], self.worldToVoxMat.T)[0] + return self.data[voxelLoc[0], voxelLoc[1], voxelLoc[2], :] diff --git a/fsl/data/image.py b/fsl/data/image.py index f5c8d04c6..5a7b68a08 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -252,7 +252,6 @@ class Image(props.HasProperties): def getXFormCode(self): """This method returns the code contained in the NIFTI1 header, indicating the space to which the (transformed) image is oriented. - """ sform_code = self.nibImage.get_header()['sform_code'] @@ -272,13 +271,13 @@ class Image(props.HasProperties): This method returns one of the following values, indicating the direction in which coordinates along the specified axis increase: - - :attr:`~fsl.data.image.ORIENT_L2R`: Left to right - - :attr:`~fsl.data.image.ORIENT_R2L`: Right to left - - :attr:`~fsl.data.image.ORIENT_A2P`: Anterior to posterior - - :attr:`~fsl.data.image.ORIENT_P2A`: Posterior to anterior - - :attr:`~fsl.data.image.ORIENT_I2S`: Inferior to superior - - :attr:`~fsl.data.image.ORIENT_S2I`: Superior to inferior - - :attr:`~fsl.data.image.ORIENT_UNKNOWN`: Orientation is unknown + - :attr:`~fsl.data.constants.ORIENT_L2R`: Left to right + - :attr:`~fsl.data.constants.ORIENT_R2L`: Right to left + - :attr:`~fsl.data.constants.ORIENT_A2P`: Anterior to posterior + - :attr:`~fsl.data.constants.ORIENT_P2A`: Posterior to anterior + - :attr:`~fsl.data.constants.ORIENT_I2S`: Inferior to superior + - :attr:`~fsl.data.constants.ORIENT_S2I`: Superior to inferior + - :attr:`~fsl.data.constants.ORIENT_UNKNOWN`: Orientation is unknown The returned value is dictated by the XForm code contained in the image file header (see the :meth:`getXFormCode` method). Basically, @@ -385,6 +384,16 @@ class ImageList(props.HasProperties): return iio.addImages(self, fromDir, addToEnd) + def find(self, name): + """Returns the first image with the given name, or ``None`` if + there is no image with said name. + """ + for image in self.images: + if image.name == name: + return image + return None + + # Wrappers around the images list property, allowing this # ImageList object to be used as if it is actually a list. def __len__( self): return self.images.__len__() @@ -400,6 +409,7 @@ class ImageList(props.HasProperties): def extend( self, iterable): return self.images.extend(iterable) def pop( self, index=-1): return self.images.pop(index) def move( self, from_, to): return self.images.move(from_, to) + def remove( self, item): return self.images.remove(item) def insert( self, index, item): return self.images.insert(index, item) def insertAll( self, index, items): return self.images.insertAll(index, diff --git a/fsl/data/strings.py b/fsl/data/strings.py index 25ca89ef9..13044da23 100644 --- a/fsl/data/strings.py +++ b/fsl/data/strings.py @@ -29,7 +29,9 @@ messages = TypeDict({ 'actions.loadcolourmap.invalidname' : 'Please use only letters, ' 'numbers, and underscores.', 'actions.loadcolourmap.installerror' : 'An error occurred while ' - 'installing the colour map', + 'installing the colour map', + + 'atlaspanel.unknownLocation' : 'Unknown location' }) @@ -59,6 +61,7 @@ actions = TypeDict({ 'CanvasPanel.toggleImageList' : 'Show/hide image list', 'CanvasPanel.toggleDisplayProperties' : 'Show/hide display properties', 'CanvasPanel.toggleLocationPanel' : 'Show/hide location panel', + 'CanvasPanel.toggleAtlasPanel' : 'Show/hide atlas panel', 'CanvasPanel.toggleCanvasProperties' : 'Show/hide canvas properties', diff --git a/fsl/fslview/controls/atlaspanel.py b/fsl/fslview/controls/atlaspanel.py index e5e5dc4ea..0dcaf6e72 100644 --- a/fsl/fslview/controls/atlaspanel.py +++ b/fsl/fslview/controls/atlaspanel.py @@ -7,15 +7,18 @@ import logging -import wx +import wx +import wx.html as wxhtml +import numpy as np import pwidgets.elistbox as elistbox import pwidgets.notebook as notebook import fsl.utils.transform as transform +import fsl.data.image as fslimage import fsl.data.atlases as atlases import fsl.data.strings as strings -import fsl.fslview.widgets.swappanel as swappanel +import fsl.data.constants as constants import fsl.fslview.panel as fslpanel @@ -24,25 +27,29 @@ log = logging.getLogger(__name__) class AtlasListWidget(wx.Panel): - def __init__(self, parent, atlasDesc, imageList, displayCtx, listBox): + def __init__(self, parent, atlasDesc, atlasPanel): wx.Panel.__init__(self, parent) - self.atlasDesc = atlasDesc - - self.enableBox = wx.CheckBox(self) + self.atlasDesc = atlasDesc + self.atlasPanel = atlasPanel + self.enableBox = wx.CheckBox(self) self.sizer = wx.BoxSizer(wx.HORIZONTAL) self.sizer.Add(self.enableBox, flag=wx.EXPAND) + self.enableBox.Bind(wx.EVT_CHECKBOX, self.onEnable) -class AtlasPanel(fslpanel.FSLViewPanel): + def onEnable(self, ev): + + if self.enableBox.GetValue(): + self.atlasPanel.enableAtlasInfo(self.atlasDesc.atlasID) + else: + self.atlasPanel.disableAtlasInfo(self.atlasDesc.atlasID) + - # actions - def addSummaryOverlay( self): pass - def addProbabilisticOverlay(self): pass - def addLabelOverlay( self): pass +class AtlasPanel(fslpanel.FSLViewPanel): def __init__(self, parent, imageList, displayCtx): @@ -58,13 +65,12 @@ class AtlasPanel(fslpanel.FSLViewPanel): # Info panel, containing atlas-based regional # proportions/labels for the current location - self.infoPanel = wx.TextCtrl(self.notebook, style=(wx.TE_MULTILINE | - wx.TE_READONLY)) + self.infoPanel = wxhtml.HtmlWindow(self.notebook) # Atlas list, containing a list of atlases # that the user can choose from self.atlasList = elistbox.EditableListBox( - self.atlasListPanel, + self.notebook, style=(elistbox.ELB_NO_ADD | elistbox.ELB_NO_REMOVE | elistbox.ELB_NO_MOVE)) @@ -73,9 +79,163 @@ class AtlasPanel(fslpanel.FSLViewPanel): # allowing the user to add/remove overlays self.overlayPanel = wx.Panel(self.notebook) - self.notebook.Add(self.infoPanel, - strings.labels['AtlasPanel.infoPanel']) - self.notebook.Add(self.atlasListPanel, - strings.labels['AtlasPanel.atlasListPanel']) - self.notebook.Add(self.overlayPanel, - strings.labels['AtlasPanel.overlayPanel']) + self.notebook.AddPage(self.infoPanel, + strings.labels['AtlasPanel.infoPanel']) + self.notebook.AddPage(self.atlasList, + strings.labels['AtlasPanel.atlasListPanel']) + self.notebook.AddPage(self.overlayPanel, + strings.labels['AtlasPanel.overlayPanel']) + + # The info panel contains clickable links + # for the currently displayed regions - + # when a link is clicked, the location + # is centred at the corresponding region + self.infoPanel.Bind(wxhtml.EVT_HTML_LINK_CLICKED, + self._infoPanelLinkClicked) + + + # Set up the list of atlases to choose from + self.atlasDescs = atlases.listAtlases() + self.enabledAtlases = {} + + listItems = sorted(self.atlasDescs.items(), key=lambda (a, d): d.name) + + for i, (atlasID, desc) in enumerate(listItems): + + self.atlasList.Append(desc.name, atlasID) + widget = AtlasListWidget(self.atlasList, desc, self) + self.atlasList.SetItemWidget(i, widget) + + displayCtx.addListener('location', self._name, self._locationChanged) + + + def _infoPanelLinkClicked(self, ev): + + atlasID, labelIndex = ev.GetLinkInfo().GetHref().split() + labelIndex = int(labelIndex) + atlas = self.enabledAtlases[atlasID] + label = atlas.desc.labels[labelIndex] + + log.debug('{}/{} clicked'.format(atlasID, label.name)) + + if isinstance(atlas, atlases.ProbabilisticAtlas): + pass + + elif isinstance(atlas, atlases.LabelAtlas): + self.toggleLabelOverlay(atlasID, labelIndex) + + + + def enableAtlasInfo(self, atlasID): + + desc = self.atlasDescs[atlasID] + atlasImage = atlases.loadAtlas(desc) + + self.enabledAtlases[atlasID] = atlasImage + + self._locationChanged() + + + def disableAtlasInfo(self, atlasID): + + self.enabledAtlases.pop(atlasID, None) + self._locationChanged() + + + def toggleSummaryOverlay(self, atlasID): + pass + + + def toggleProbabilisticOverlay(self, atlasID, labelIndex): + pass + + + def toggleLabelOverlay(self, atlasID, labelIndex): + + desc = self.atlasDescs[atlasID] + overlayName = '{}/{}'.format(atlasID, desc.labels[labelIndex].name) + overlay = self._imageList.find(overlayName) + + if overlay is not None: + self._imageList.remove(overlay) + + log.debug('Removing overlay {}'.format(overlayName)) + + else: + atlas = self.enabledAtlases.get(atlasID, None) + if atlas is None: + atlas = atlases.loadAtlas(self.atlasDescs[atlasID], True) + + if desc.atlasType == 'probabilistic': labelVal = labelIndex + 1 + elif desc.atlasType == 'label': labelVal = labelIndex + + mask = np.zeros(atlas.shape, dtype=np.uint8) + mask[atlas.data == labelIndex] = labelVal + + overlay = fslimage.Image( + mask, + atlas.voxToWorldMat, + name=overlayName) + overlay.imageType = 'mask' + + log.debug('Adding overlay {}'.format(overlayName)) + + self._imageList.append(overlay) + + display = self._displayCtx.getDisplayProperties(overlay) + display.getDisplayOpts().colour = np.random.random(3) + + + + + + def _locationChanged(self, *a): + image = self._displayCtx.getSelectedImage() + display = self._displayCtx.getDisplayProperties(image) + loc = self._displayCtx.location + text = self.infoPanel + + loc = transform.transform([loc], display.displayToWorldMat)[0] + + if image.getXFormCode() != constants.NIFTI_XFORM_MNI_152: + text.SetPage(strings.messages['atlaspanel.unknownLocation']) + return + + lines = [] + + labelTemplate = """{} + (<a href="{} {}">Show/Hide</a>) + """ + probTemplate = """ + {:0.2f}% {} + (<a href="{} {}">Show/Hide</a>) + """ + + for atlasID, atlas in self.enabledAtlases.items(): + + lines.append('<b>{}</b>'.format(atlas.desc.name)) + + if isinstance(atlas, atlases.ProbabilisticAtlas): + proportions = atlas.proportions(loc) + + for label, prop in zip(atlas.desc.labels, proportions): + if prop == 0.0: + continue + lines.append(probTemplate.format(prop, + label.name, + atlasID, + label.index, + atlasID, + label.index)) + + elif isinstance(atlas, atlases.LabelAtlas): + + labelVal = atlas.label(loc) + label = atlas.desc.labels[labelVal] + lines.append(labelTemplate.format(label.name, + atlasID, + label.index, + atlasID, + label.index)) + + text.SetPage('<br>'.join(lines)) diff --git a/fsl/fslview/displaycontext/maskopts.py b/fsl/fslview/displaycontext/maskopts.py index daabf5076..864004543 100644 --- a/fsl/fslview/displaycontext/maskopts.py +++ b/fsl/fslview/displaycontext/maskopts.py @@ -45,5 +45,5 @@ class MaskOpts(fsldisplay.DisplayOpts): self.threshold.setMin( 0, self.dataMin - 0.5 * dRangeLen) self.threshold.setMax( 0, self.dataMax + 0.5 * dRangeLen) - self.threshold.setRange(0, self.dataMin, self.dataMax) + self.threshold.setRange(0, 0.1, self.dataMax + 0.1) self.setConstraint('threshold', 'minDistance', dMinDistance) diff --git a/fsl/fslview/gl/glmask.py b/fsl/fslview/gl/glmask.py index a2502b593..36231c7c0 100644 --- a/fsl/fslview/gl/glmask.py +++ b/fsl/fslview/gl/glmask.py @@ -52,7 +52,7 @@ class GLMask(glvolume.GLVolume): self.setAxes(self.xax, self.yax) def colourUpdate(*a): - self.refreshColourTexture(self.colourResolution) + self.refreshColourTexture() lnrName = '{}_{}'.format(type(self).__name__, id(self)) @@ -109,17 +109,20 @@ class GLMask(glvolume.GLVolume): # or above the current display range will be mapped # to texture coordinate values less than 0.0 or greater # than 1.0 respectively. + if imax == imin: scale = 1 + else: scale = imax - imin + cmapXform = np.identity(4, dtype=np.float32) - cmapXform[0, 0] = 1.0 / (imax - imin) + cmapXform[0, 0] = 1.0 / scale cmapXform[3, 0] = -imin * cmapXform[0, 0] self.colourMapXform = cmapXform if opts.invert: - colourmap = np.tile([[0.0, 0.0, 0.0, 0.0]], (2, 1)) + colourmap = np.tile([[0.0, 0.0, 0.0, 0.0]], (16, 1)) border = np.array(opts.colour, dtype=np.float32) else: - colourmap = np.tile([[opts.colour]], (2, 1)) + colourmap = np.tile([[opts.colour]], (16, 1)) border = np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32) colourmap = np.floor(colourmap * 255) @@ -145,7 +148,7 @@ class GLMask(glvolume.GLVolume): gl.glTexImage1D(gl.GL_TEXTURE_1D, 0, gl.GL_RGBA8, - 2, + 16, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, diff --git a/fsl/fslview/gl/glvolume.py b/fsl/fslview/gl/glvolume.py index 2ae2f42cf..9cad22cc8 100644 --- a/fsl/fslview/gl/glvolume.py +++ b/fsl/fslview/gl/glvolume.py @@ -249,8 +249,11 @@ class GLVolume(globject.GLImageObject): # or above the current display range will be mapped # to texture coordinate values less than 0.0 or greater # than 1.0 respectively. + if imax == imin: scale = 1 + else: scale = imax - imin + cmapXform = np.identity(4, dtype=np.float32) - cmapXform[0, 0] = 1.0 / (imax - imin) + cmapXform[0, 0] = 1.0 / scale cmapXform[3, 0] = -imin * cmapXform[0, 0] self.colourMapXform = cmapXform diff --git a/fsl/fslview/layouts.py b/fsl/fslview/layouts.py index c5caa7aaa..c11f70bb7 100644 --- a/fsl/fslview/layouts.py +++ b/fsl/fslview/layouts.py @@ -103,6 +103,7 @@ CanvasPanelActionLayout = props.HGroup( actionButton('toggleImageList', CanvasPanel), actionButton('toggleDisplayProperties', CanvasPanel), actionButton('toggleLocationPanel', CanvasPanel), + actionButton('toggleAtlasPanel', CanvasPanel), actionButton('toggleCanvasProperties', CanvasPanel)), wrap=True, showLabels=False) diff --git a/fsl/fslview/views/canvaspanel.py b/fsl/fslview/views/canvaspanel.py index ec7cd847e..10f103d39 100644 --- a/fsl/fslview/views/canvaspanel.py +++ b/fsl/fslview/views/canvaspanel.py @@ -30,6 +30,7 @@ import fsl.fslview.displaycontext as displayctx import fsl.fslview.controls.imagelistpanel as imagelistpanel import fsl.fslview.controls.imagedisplaypanel as imagedisplaypanel import fsl.fslview.controls.locationpanel as locationpanel +import fsl.fslview.controls.atlaspanel as atlaspanel import fsl.fslview.widgets.togglepanel as togp import colourbarpanel @@ -183,6 +184,7 @@ class CanvasPanel(fslpanel.FSLViewPanel): 'screenshot' : self.screenshot, 'toggleColourBar' : self.toggleColourBar, 'toggleImageList' : self.toggleImageList, + 'toggleAtlasPanel' : self.toggleAtlasPanel, 'toggleDisplayProperties' : self.toggleDisplayProperties, 'toggleLocationPanel' : self.toggleLocationPanel, 'toggleCanvasProperties' : self.toggleCanvasProperties} @@ -245,6 +247,9 @@ class CanvasPanel(fslpanel.FSLViewPanel): self.__listLocContainer, imageList, displayCtx) self.__locationPanel = locationpanel.LocationPanel( + self.__listLocContainer, imageList, displayCtx) + + self.__atlasPanel = atlaspanel.AtlasPanel( self.__listLocContainer, imageList, displayCtx) self.__displayPropsPanel = imagedisplaypanel.ImageDisplayPanel( @@ -259,6 +264,9 @@ class CanvasPanel(fslpanel.FSLViewPanel): self.__listLocSizer.Add(self.__locationPanel, flag=wx.EXPAND, proportion=1) + self.__listLocSizer.Add(self.__atlasPanel, + flag=wx.EXPAND, + proportion=1) self.__dispSetSizer = wx.BoxSizer(wx.HORIZONTAL) self.__dispSetContainer.SetSizer(self.__dispSetSizer) @@ -289,6 +297,7 @@ class CanvasPanel(fslpanel.FSLViewPanel): self.__locationPanel .Show(False) self.__canvasPropsPanel .Show(False) self.__displayPropsPanel.Show(False) + self.__atlasPanel .Show(False) # Use a different listener name so that subclasses # can register on the same properties with self._name @@ -398,6 +407,10 @@ class CanvasPanel(fslpanel.FSLViewPanel): def toggleLocationPanel(self, *a): self.__locationPanel.Show(not self.__locationPanel.IsShown()) self.__layout() + + def toggleAtlasPanel(self, *a): + self.__atlasPanel.Show(not self.__atlasPanel.IsShown()) + self.__layout() def toggleDisplayProperties(self, *a): self.__displayPropsPanel.Show(not self.__displayPropsPanel.IsShown()) -- GitLab