Source code for fsleyes.plugins.controls.annotationpanel

#
# annotationpanel.py - The AnnotationPanel
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module contains the :class:`AnnotationPanel` class, a FSLeyes control
panel which allows the user to annotate the canvases of an
:class:`.OrthoPanel`.
"""


import copy

import wx

import fsleyes_widgets.elistbox                      as elb
import fsleyes_props                                 as props
import fsleyes_widgets.widgetlist                    as widgetlist
import fsleyes_widgets.bitmapradio                   as bmpradio
import fsleyes.actions                               as actions
import fsleyes.strings                               as strings
import fsleyes.icons                                 as fslicons
import fsleyes.tooltips                              as tooltips
import fsleyes.views.orthopanel                      as orthopanel
import fsleyes.controls.controlpanel                 as ctrlpanel
import fsleyes.plugins.profiles.orthoannotateprofile as annotprofile
import fsleyes.plugins.tools.saveannotations         as saveannotations
import fsleyes.gl.annotations                        as annotations


[docs]class AnnotationPanel(ctrlpanel.ControlPanel): """The ``AnnotationPanel`` contains controls allowing the user to add annotations to the canvases in an :class:`.OrthoPanel`. The user is able to add points, lines, rectangles, ellipses, and text to any canvas, and can adjust the properties (e.g. colour, thickness) of each annotation. When a user selects an annotation to add, the :class:`.OrthoAnnotateProfile` is enabled on the ``OrthoPanel``, which provides user interaction. The :class:`.SaveAnnotationsAction` and :class:`.LoadAnnotationsAction` actions, for saving/loading annotations to/from files, are bound to buttons in the ``AnnotationPanel``. """ # These properties are used to synchronise property # values between the ortho edit profile, and newly # added or selected annotation objects colour = copy.copy(annotprofile.OrthoAnnotateProfile.colour) fontSize = copy.copy(annotprofile.OrthoAnnotateProfile.fontSize) lineWidth = copy.copy(annotprofile.OrthoAnnotateProfile.lineWidth) filled = copy.copy(annotprofile.OrthoAnnotateProfile.filled) border = copy.copy(annotprofile.OrthoAnnotateProfile.border) honourZLimits = copy.copy(annotprofile.OrthoAnnotateProfile.honourZLimits) alpha = copy.copy(annotprofile.OrthoAnnotateProfile.alpha) # Just used as a convenience DISPLAY_PROPERTIES = ['colour', 'fontSize', 'lineWidth', 'filled', 'border', 'honourZLimits', 'alpha']
[docs] @staticmethod def supportedViews(): """Overrides :meth:`.ControlMixin.supportedViews`. The ``CropImagePanel`` is only intended to be added to :class:`.OrthoPanel` views. """ return [orthopanel.OrthoPanel]
[docs] @staticmethod def profileCls(): """Returns the :class:`.OrthoAnnotateProfile` class, which needs to be activated in conjunction with the ``AnnotationPanel``. """ return annotprofile.OrthoAnnotateProfile
[docs] @staticmethod def defaultLayout(): """Returns a dictionary containing layout settings to be passed to :class:`.ViewPanel.togglePanel`. """ return {'location' : wx.LEFT}
[docs] def __init__(self, parent, overlayList, displayCtx, ortho): """Create an ``AnnotationPanel``. :arg parent: The :mod:`wx` parent object. :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. :arg ortho: The :class:`.OrthoPanel` instance. """ ctrlpanel.ControlPanel.__init__( self, parent, overlayList, displayCtx, ortho) self.__ortho = ortho # load/save actions are bound to buttons below self.loadAnnotations = saveannotations.LoadAnnotationsAction( overlayList, displayCtx, ortho) self.saveAnnotations = saveannotations.SaveAnnotationsAction( overlayList, displayCtx, ortho) # The interface comprises a list of annotations, # a set of controls below the list, a column of # buttons down the side, and a row of buttons # along the bottom self.__mainSizer = wx.BoxSizer(wx.VERTICAL) self.__listSizer = wx.BoxSizer(wx.HORIZONTAL) self.__sideButtonSizer = wx.BoxSizer(wx.VERTICAL) # Listbox containing all annotations that # have been added to all canvases, sorted # by creation time. We add our own buttons # to remove/reorder anontations below self.__annotList = elb.EditableListBox( self, style=(elb.ELB_NO_ADD | elb.ELB_NO_REMOVE | elb.ELB_NO_MOVE | elb.ELB_REVERSE)) # Buttons running down the side of the list, # for loading/saving annotations, and for # removing/reordering annotations from/within # the list loadSpec = actions.ActionButton( 'loadAnnotations', icon=fslicons.findImageFile('folder16'), tooltip=tooltips.actions[self, 'saveAnnotations']) saveSpec = actions.ActionButton( 'saveAnnotations', icon=fslicons.findImageFile('floppydisk16'), tooltip=tooltips.actions[self, 'loadAnnotations']) self.__loadButton = props.buildGUI(self, self, loadSpec) self.__saveButton = props.buildGUI(self, self, saveSpec) self.__upButton = wx.Button(self, style=wx.BU_EXACTFIT) self.__downButton = wx.Button(self, style=wx.BU_EXACTFIT) self.__removeButton = wx.Button(self, style=wx.BU_EXACTFIT) upicon = wx.Bitmap(fslicons.findImageFile('up16')) downicon = wx.Bitmap(fslicons.findImageFile('down16')) removeicon = wx.Bitmap(fslicons.findImageFile('remove16')) self.__upButton .SetBitmapLabel(upicon) self.__downButton .SetBitmapLabel(downicon) self.__removeButton.SetBitmapLabel(removeicon) self.__sideButtonSizer.Add(self.__loadButton) self.__sideButtonSizer.Add(self.__saveButton) self.__sideButtonSizer.Add(self.__upButton) self.__sideButtonSizer.Add(self.__downButton) self.__sideButtonSizer.Add(self.__removeButton) self.__sideButtonSizer.Add((1, 1), proportion=1) # Stores a reference to the most recently # selected annotation object, as we need # to bind/unbind property listeners on it self.__selected = None # Widgets that can be used to set display # properties for newly added annotations, # and for the existing currently selected # annotation in the listbox self.__widgets = widgetlist.WidgetList(self, minHeight=24) self.__colour = props.makeWidget(self.__widgets, self, 'colour') self.__filled = props.makeWidget(self.__widgets, self, 'filled') self.__border = props.makeWidget(self.__widgets, self, 'border') self.__honourZLimits = props.makeWidget(self.__widgets, self, 'honourZLimits') self.__fontSize = props.makeWidget(self.__widgets, self, 'fontSize', slider=False) self.__lineWidth = props.makeWidget(self.__widgets, self, 'lineWidth', slider=False) self.__alpha = props.makeWidget(self.__widgets, self, 'alpha', spin=False) self.__widgets.AddWidget(self.__colour, strings.labels[self, 'colour']) self.__widgets.AddWidget(self.__alpha, strings.labels[self, 'alpha']) self.__widgets.AddWidget(self.__fontSize, strings.labels[self, 'fontSize']) self.__widgets.AddWidget(self.__lineWidth, strings.labels[self, 'lineWidth']) self.__widgets.AddWidget(self.__filled, strings.labels[self, 'filled']) self.__widgets.AddWidget(self.__border, strings.labels[self, 'border']) self.__widgets.AddWidget(self.__honourZLimits, strings.labels[self, 'honourZLimits']) # Radio box buttons allowing the user to # select an annotation type to draw self.__annotOptions = bmpradio.BitmapRadioBox( self, style=bmpradio.BMPRADIO_ALLOW_DESELECTED) icons = { 'text' : [fslicons.loadBitmap('textAnnotationHighlight24'), fslicons.loadBitmap('textAnnotation24')], 'rect' : [fslicons.loadBitmap('rectAnnotationHighlight24'), fslicons.loadBitmap('rectAnnotation24')], 'line' : [fslicons.loadBitmap('lineAnnotationHighlight24'), fslicons.loadBitmap('lineAnnotation24')], 'arrow' : [fslicons.loadBitmap('arrowAnnotationHighlight24'), fslicons.loadBitmap('arrowAnnotation24')], 'point' : [fslicons.loadBitmap('pointAnnotationHighlight24'), fslicons.loadBitmap('pointAnnotation24')], 'ellipse' : [fslicons.loadBitmap('ellipseAnnotationHighlight24'), fslicons.loadBitmap('ellipseAnnotation24')], } for option, icons in icons.items(): self.__annotOptions.AddChoice(*icons, clientData=option) self.__listSizer.Add(self.__sideButtonSizer) self.__listSizer.Add(self.__annotList, flag=wx.EXPAND, proportion=1) self.__mainSizer.Add(self.__listSizer, flag=wx.EXPAND, proportion=1) self.__mainSizer.Add(self.__widgets, flag=wx.EXPAND) self.__mainSizer.Add(self.__annotOptions, flag=wx.EXPAND) self.SetSizer(self.__mainSizer) self.__upButton .Bind(wx.EVT_BUTTON, self.__onMoveUp) self.__downButton .Bind(wx.EVT_BUTTON, self.__onMoveDown) self.__removeButton.Bind(wx.EVT_BUTTON, self.__onRemove) self.__annotOptions.Bind(bmpradio.EVT_BITMAP_RADIO_EVENT, self.__onAnnotOption) self.__annotList.Bind(elb.EVT_ELB_SELECT_EVENT, self.__annotListItemSelected) for propName in self.DISPLAY_PROPERTIES: self.addListener(propName, self.name, self.__displayPropertyChanged) for canvas in ortho.getGLCanvases(): canvas.getAnnotations().addListener( 'annotations', self.name, self.__annotationsChanged) # populate annotations listbox self.__annotationsChanged()
[docs] def destroy(self): """Must be called when this ``AnnoationPanel`` is no longer needed. Removes property listeners and clears references. """ super().destroy() for canvas in self.__ortho.getGLCanvases(): canvas.getAnnotations().removeListener('annotations', self.name) self.__annotListItemSelected() self.__ortho = None
def __annotationsChanged(self, *a): """Called when the :attr:`.Annotations.annotations` list of any :class:`.SliceCanvas` changes. Makes sure that the contents of the annotations list box is up to date, and selects the most recently added annotation. """ # This is a little awkward. Each XYZ canvas has its own # separate list of annotation objects, but we display # all objects in a single list, and the user is able to # arbitrarily re-order the objects, independently of # which canvas they are from (even though reordering # two objects from different canvases has no effect on # how they are drawn). # # The __onMove method keeps the ordering of the GUI list # and canvas lists in sync. But this method (which is # typically called when new annotations are added) just # clears and re-creates the GUI list from the canvas # lists. This keeps the code simple, but does mean that # new annotations will appear after the last annotation # *from the same canvas*, meaning that they may appear # in the middle of the GUI list. alist = self.__annotList allAnnots = [] for canvas in self.__ortho.getGLCanvases(): allAnnots.extend(canvas.getAnnotations().annotations) listAnnots = alist.GetData() alist.Clear() for obj in allAnnots: canvas = 'XYZ'[obj.annot.canvas.opts.zax] if isinstance(obj, annotations.TextAnnotation): name = obj.text else: name = strings.labels[self, obj] alist.Append('[{}] {}'.format(canvas, name), obj) # select the most recently added annotation # (we just take the first one which was not # originally in the GUI list) and bind it # to the display settings widgets for i, obj in enumerate(allAnnots): if obj not in listAnnots: alist.SetSelection(i) self.__annotListItemSelected(obj=obj) break def __displayPropertyChanged(self, *a): """Called when any display property changes. Refreshes all canvases. """ if self.__selected is not None: self.__ortho.Refresh() def __onRemove(self, ev): """Called when the "remove item" button is pushed. Removes the item from the list box widget, and from the corresponding :attr:`.Annotations.annotations` list. """ idx = self.__annotList.GetSelection() if idx == wx.NOT_FOUND: return obj = self.__annotList.GetItemData(idx) annot = obj.annot self.__annotList.Delete(idx) with props.suppress(annot, 'annotations'): annot.dequeue(obj, hold=True, fixed=False) obj.annot.canvas.Refresh() if self.__annotList.GetCount() > 0: self.__annotListItemSelected(idx - 1) def __onMoveUp(self, ev): """Called when the "move item up" button is pushed. """ self.__onMove(-1) def __onMoveDown(self, ev): """Called when the "move item down" button is pushed. """ self.__onMove(1) def __onMove(self, offset): """Called when one of the move buttons is pushed. Moves the item in the list box, then syncs the new item order on the corresponding :attr:`.Annotations.annotations` list. """ alist = self.__annotList idx = alist.GetSelection() if idx == wx.NOT_FOUND: return obj = alist.GetItemData(idx) annot = obj.annot alist.MoveItem(offset) # Sync the order of the canvas annotations # list with the new order in the GUI list with props.suppress(annot, 'annotations'): allobjs = [o for o in alist.GetData() if o.annot is annot] annot.annotations[:] = list(allobjs) annot.canvas.Refresh() def __annotListItemSelected(self, ev=None, obj=None): """Called when an item is selected in the annotations listbox, or programmatically at various times. If the properties of an :class:`.AnnotationObject` are bound to the display settings widgets, they are unbound. If an annotation has been selected in the annotations list box, the display settings widgets are bound to its properties. """ if ev is not None: obj = ev.data prev = self.__selected self.__selected = obj for propName in self.DISPLAY_PROPERTIES: if prev is not None and hasattr(prev, propName): self.unbindProps(propName, prev) if obj is not None and hasattr(obj, propName): self.bindProps(propName, obj) def __onAnnotOption(self, ev): """Called when the user selects an annnotation type to draw. Changes the :class:`.OrthoAnnotateProfile` mode. """ self.__annotList.ClearSelection() self.__annotListItemSelected() profile = self.__ortho.currentProfile if ev.value: profile.mode = ev.clientData for propName in self.DISPLAY_PROPERTIES: profile.bindProps(propName, self) else: profile.mode = 'nav'