Source code for fsleyes.plugins.tools.saveannotations

#
# saveannotationsaction.py - Load/save annotations displayed on an OrthoPanel
#                            to file
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`LoadAnnotationsAction` and
:class:`SaveAnnotationsAction` classes, both FSLeyes actions which can be used
to save/load :mod:`.annotations` to/from a file.  This module is tightly
coupled to the implementations of the specific :class:`.AnnotationObject`
types that are supported:

 - :class:`.Point`
 - :class:`.Line`
 - :class:`.Arrow`
 - :class:`.Rect`
 - :class:`.Ellipse`
 - :class:`.TextAnnotation`


The ``SaveAnnotationsAction`` class is an action which is added to the
FSLeyes Tools menu, and which allows the user to save all annotations that
have been added to the canvases of an :class:`.OrthoPanel` to a file. This
file can then be loaded back in via the ``LoadAnnotationsAction``.


The logic for serialising and deserialising annotations to/from string
representations is also implemented in this module.


A FSLeyes annotations file is a plain text file where each line contains
a description of one :class:`.AnnotationObject`. An example of a FSLeyes
annotation file is::

    X Line lineWidth=4 colour=#0090a0 alpha=100 honourZLimits=False zmin=89.0  zmax=90.0  x1=48.7 y1=117.4 x2=39.1 y2=65.5
    Y Rect lineWidth=4 colour=#ff0800 alpha=100 honourZLimits=False zmin=107.0 zmax=108.0 filled=False border=True x=43.3 y=108.1 w=71.5 h=-62.7
    Y TextAnnotation lineWidth=5 colour=#e383ff alpha=100 honourZLimits=False zmin=107.0 zmax=108.0 text='' fontSize=20 x=10.9 y=154.2


Each line has the form::

    <canvas> <type> key=value [key=value ...]

where:

 - ``<canvas>`` is one of ``X``, ``Y`` or ``Z``, indicating the ortho canvas
   that the annotation is drawn on

 - ``<type>`` is the annotation type, one of ``Point``, ``Line``, ``Arrow``,
   ``Rect``, ``Ellipse`` or ``TextAnnotation``.

 - ``key=value`` contains the name and value of one property of the annotation.

The following key-value pairs are set for all annotation types:

 * ``colour`` - Annotation colour, as string of the form ``#RRGGBB``
 * ``lineWidth`` - Line width in pixels
 * ``alpha`` - Transparency, between 0 and 10
 * ``honourZLimits`` - ``True`` or ``False``, whether ``zmin`` and ``zmax``
   should be applied
 * ``zmin`` - Minimum depth value, as a floating point number
 * ``zmax`` - Maximum depth value, as a floating point number


The following additional key-value pairs are set for specific annotation
types.  All coordinates and lengths are relative to the display coordinate
system:

 * ``Point``
  * ``x`` X coordinate
  * ``y`` Y coordinate
 * ``Line`` and ``Arrow``
  * ``x1`` X coordinate of first point
  * ``y1`` Y coordinate of first point
  * ``x2`` X coordinate of second point
  * ``y2`` Y coordinate of second point
 * ``Rect`` and ``Ellipse``
  * ``filled`` ``True`` or ``False``, whether the rectangle/ellipse is
    filled
  * ``border`` ``True`` or ``False``, whether the rectangle/ellipse is
    drawn with a border
  * ``x`` X coordinate of one corner of the rectangle, or the ellipse centre
  * ``y`` Y coordinate of one corner of the rectangle, or the ellipse centre
  * ``w`` Rectangle width, relative to ``x``, or horizontal radius of elliipse
  * ``h`` Rectangle height, relative to ``y``, or vertical radius of elliipse
 * ``TextAnnotation``
  * ``text`` Displayed text, quoted with ``shlex.quote``
  * ``fontSize`` Font size in points (relative to the canvas scaling that
    was in place at the time that the text was created)
  * ``x`` Bottom left X coordinate of text
  * ``y`` Bottom left Y coordinate of text
"""  # noqa: E501


import os
import shlex
import logging
import pathlib

from typing import Dict, List, Union

import wx

import fsleyes_widgets.utils.status as status
import fsl.utils.settings           as fslsettings
import fsleyes.views.orthopanel     as orthopanel
import fsleyes.actions              as actions
import fsleyes.strings              as strings
import fsleyes.gl.annotations       as annotations


log = logging.getLogger(__name__)


[docs]class SaveAnnotationsAction(actions.Action): """The ``SaveAnnotationsAction`` allos the user to save annotations that have been added to an :class:`.OrthoPanel` to a file. """
[docs] @staticmethod def ignoreTool(): """This action is not intended to be loaded as a FSLeyes plugin. Rather, it is used directly by the :class:`.AnnotationPanel` class. """ return True
[docs] @staticmethod def supportedViews(): """This action is only intended to work with :class:`.OrthoPanel` views. """ return [orthopanel.OrthoPanel]
[docs] def __init__(self, overlayList, displayCtx, ortho): """Create a ``SaveAnnotationsAction``. :arg overlayList: The :class:`.OverlayList` :arg displayCtx: The :class:`.DisplayContext` :arg ortho: The :class:`.OrthoPanel`. """ actions.Action.__init__(self, overlayList, displayCtx, self.__saveAnnotations) self.__ortho = ortho
def __saveAnnotations(self): """Show a dialog prompting the user for a file to save to, then serialises all annotations, and saves them to that file. """ ortho = self.__ortho msg = strings.messages[self, 'saveFile'] fromDir = fslsettings.read('loadSaveOverlayDir', os.getcwd()) dlg = wx.FileDialog(wx.GetApp().GetTopWindow(), message=msg, defaultDir=fromDir, defaultFile='annotations.txt', style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) if dlg.ShowModal() != wx.ID_OK: return filePath = dlg.GetPath() errtitle = strings.titles[ self, 'saveFileError'] errmsg = strings.messages[self, 'saveFileError'] with status.reportIfError(errtitle, errmsg, raiseError=False): saveAnnotations(ortho, filePath)
[docs]class LoadAnnotationsAction(actions.Action): """The ``LoadAnnotationsAction`` allos the user to load annotations from a file into an :class:`.OrthoPanel`. """
[docs] @staticmethod def ignoreTool(): """This action is not intended to be loaded as a FSLeyes plugin. Rather, it is used directly by the :class:`.AnnotationPanel` class. """ return True
[docs] @staticmethod def supportedViews(): """This action is only intended to work with :class:`.OrthoPanel` views. """ return [orthopanel.OrthoPanel]
[docs] def __init__(self, overlayList, displayCtx, ortho): """Create a ``SaveAnnotationsAction``. :arg overlayList: The :class:`.OverlayList` :arg displayCtx: The :class:`.DisplayContext` :arg ortho: The :class:`.OrthoPanel`. """ actions.Action.__init__(self, overlayList, displayCtx, self.__loadAnnotations) self.__ortho = ortho
def __loadAnnotations(self): """Show a dialog prompting the user for a file to load, then loads the annotations contained in the file and adds them to the :class:`OrthoPanel`. """ ortho = self.__ortho msg = strings.messages[self, 'loadFile'] fromDir = fslsettings.read('loadSaveOverlayDir', os.getcwd()) dlg = wx.FileDialog(wx.GetApp().GetTopWindow(), message=msg, defaultDir=fromDir, style=wx.FD_OPEN) if dlg.ShowModal() != wx.ID_OK: return filePath = dlg.GetPath() errtitle = strings.titles[ self, 'loadFileError'] errmsg = strings.messages[self, 'loadFileError'] with status.reportIfError(errtitle, errmsg, raiseError=False): loadAnnotations(ortho, filePath)
[docs]def saveAnnotations(ortho : orthopanel.OrthoPanel, filename : Union[pathlib.Path, str]): """Saves annotations on the canvases of the :class:`.OrthoPanel` to the specified ``filename``. """ annots = {'X' : ortho.getXCanvas().getAnnotations().annotations, 'Y' : ortho.getYCanvas().getAnnotations().annotations, 'Z' : ortho.getZCanvas().getAnnotations().annotations} with open(filename, 'wt') as f: f.write(serialiseAnnotations(annots))
[docs]def loadAnnotations(ortho : orthopanel.OrthoPanel, filename : Union[pathlib.Path, str]): """Loads annotations from the specified ``filename``, and add them to the canvases of the :class:`.OrthoPanel`. """ with open(filename, 'rt') as f: s = f.read().strip() annots = {'X' : ortho.getXCanvas().getAnnotations(), 'Y' : ortho.getYCanvas().getAnnotations(), 'Z' : ortho.getZCanvas().getAnnotations()} allObjs = deserialiseAnnotations(s, annots) for canvas, objs in allObjs.items(): annots[canvas].annotations.extend(objs)
[docs]def serialiseAnnotations( allAnnots : Dict[str, List[annotations.AnnotationObject]]) -> str: """Serialise all of the annotations for each canvas of an :class:`.OrthoPanel` to a string representation. :arg allAnnots: Dictionary where the keys are one of ``'X'``, ``'Y'`` or ``'Z'``, and the values are lists of :class:`.AnnotationObject` instances to be serialised. :returns: String containing serialised annotations """ serialised = [] for canvas, annots in allAnnots.items(): for obj in annots: serialised.append(serialiseAnnotation(obj, canvas)) return '\n'.join(serialised)
[docs]def serialiseAnnotation(obj : annotations.AnnotationObject, canvas : str) -> str: """Convert the given :class:`.AnnotationObject` to a string representation. """ # All properties on all annotation # types that are to be serialised keys = ['colour', 'lineWidth', 'alpha', 'honourZLimits', 'zmin', 'zmax', 'filled', 'border', 'fontSize', 'coordinates', 'text', 'x', 'y', 'w', 'h', 'x1', 'y1', 'x2', 'y2'] # Custom formatters for some properties def formatColour(colour): colour = [int(round(c * 255)) for c in colour[:3]] return '#{:02x}{:02x}{:02x}'.format(*colour) formatters = { 'colour' : formatColour, 'text' : shlex.quote } kvpairs = [] for key in keys: val = getattr(obj, key, None) if val is not None: val = formatters.get(key, str)(val) kvpairs.append('{}={}'.format(key, val)) otype = type(obj).__name__ return '{} {} {}'.format(canvas, otype, ' '.join(kvpairs))
[docs]def deserialiseAnnotations( s : str, annots : Dict[str, annotations.Annotations] ) -> Dict[str, List[annotations.AnnotationObject]]: """Deserialise all of the annotation specifications in the string ``s``, and create :class:`.AnnotationObject` instances from them. The ``AnnotationObject`` instances are created, but not added to the :class:`.Annotations`. :arg s: String containing serialised annotations :arg annots: Dictionary where the keys are one of ``'X'``, ``'Y'`` or ``'Z'``, and the values are the :class:`.Annotations` instances for each :class:`.OrthoPanel` canvas. :returns: Dictionary of ``{canvas : [AnnotationObject]}`` mappings. """ objs = {'X' : [], 'Y' : [], 'Z' : []} for line in s.split('\n'): try: obj, canvas = deserialiseAnnotation(line, annots) objs[canvas].append(obj) except Exception as e: log.warning('Error parsing annotation (%s): %s ', e, line) return objs
[docs]def deserialiseAnnotation( s : str, annots : Dict[str, annotations.Annotations] ) -> annotations.AnnotationObject: """Deserialises the annotation specification in the provided string, and creates an :class:`.AnnotationObject` instance. :arg s: String containing serialised annotation :arg annots: Dictionary where the keys are one of ``'X'``, ``'Y'`` or ``'Z'``, and the values are the :class:`.Annotations` instance for each :class:`.OrthoPanel` canvas. :returns: Tuple containing: - An :class:`.AnnotationObject` instance - ``'X'``, ``'Y'`` or ``'Z'``, denoting the canvas that the :class:`.AnnotationObject` is to be drawn on. """ # Parser functions for some property types # (default for unlisted properties is float) def tobool(v): return v.lower() in 'true' parsers = { 'colour' : str, 'honourZLimits' : tobool, 'filled' : tobool, 'border' : tobool, 'text' : str, 'coordinates' : str, } canvas, otype, kvpairs = s.strip().split(maxsplit=2) canvas = canvas.upper() cls = getattr(annotations, otype) kvpairs = dict([kv.split('=') for kv in shlex.split(kvpairs)]) if canvas not in 'XYZ': raise ValueError('Canvas is not one of X, Y, ' 'or Z ({})'.format(canvas)) for k, v in kvpairs.items(): kvpairs[k] = parsers.get(k, float)(v) return cls(annots[canvas], **kvpairs), canvas