diff options
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/axis.py | 154 | ||||
-rw-r--r-- | silx/gui/plot/items/complex.py | 8 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 38 | ||||
-rw-r--r-- | silx/gui/plot/items/curve.py | 8 | ||||
-rw-r--r-- | silx/gui/plot/items/histogram.py | 37 | ||||
-rw-r--r-- | silx/gui/plot/items/roi.py | 1416 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 6 |
7 files changed, 1612 insertions, 55 deletions
diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py index d7e6eff..3d9fe14 100644 --- a/silx/gui/plot/items/axis.py +++ b/silx/gui/plot/items/axis.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -29,12 +29,24 @@ __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "06/12/2017" +import datetime as dt import logging + +import dateutil.tz + from ... import qt +from silx.third_party import enum + _logger = logging.getLogger(__name__) +class TickMode(enum.Enum): + """Determines if ticks are regular number or datetimes.""" + DEFAULT = 0 # Ticks are regular numbers + TIME_SERIES = 1 # Ticks are datetime objects + + class Axis(qt.QObject): """This class describes and controls a plot axis. @@ -82,7 +94,23 @@ class Axis(qt.QObject): # Store currently displayed labels # Current label can differ from input one with active curve handling self._currentLabel = '' - self._plot = plot + + def _getPlot(self): + """Returns the PlotWidget this Axis belongs to. + + :rtype: PlotWidget + """ + plot = self.parent() + if plot is None: + raise RuntimeError("Axis no longer attached to a PlotWidget") + return plot + + def _getBackend(self): + """Returns the backend + + :rtype: BackendBase + """ + return self._getPlot()._backend def getLimits(self): """Get the limits of this axis. @@ -102,7 +130,7 @@ class Axis(qt.QObject): return self._internalSetLimits(vmin, vmax) - self._plot._setDirtyPlot() + self._getPlot()._setDirtyPlot() self._emitLimitsChanged() @@ -110,7 +138,7 @@ class Axis(qt.QObject): """Emit axis sigLimitsChanged and PlotWidget limitsChanged event""" vmin, vmax = self.getLimits() self.sigLimitsChanged.emit(vmin, vmax) - self._plot._notifyLimitsChanged(emitSignal=False) + self._getPlot()._notifyLimitsChanged(emitSignal=False) def _checkLimits(self, vmin, vmax): """Makes sure axis range is not empty @@ -172,7 +200,7 @@ class Axis(qt.QObject): """ self._defaultLabel = label self._setCurrentLabel(label) - self._plot._setDirtyPlot() + self._getPlot()._setDirtyPlot() def _setCurrentLabel(self, label): """Define the label currently displayed. @@ -207,6 +235,14 @@ class Axis(qt.QObject): # For the backward compatibility signal emitLog = self._scale == self.LOGARITHMIC or scale == self.LOGARITHMIC + self._scale = scale + + # TODO hackish way of forcing update of curves and images + plot = self._getPlot() + for item in plot._getItems(withhidden=True): + item._updated() + plot._invalidateDataRange() + if scale == self.LOGARITHMIC: self._internalSetLogarithmic(True) elif scale == self.LINEAR: @@ -214,13 +250,7 @@ class Axis(qt.QObject): else: raise ValueError("Scale %s unsupported" % scale) - self._scale = scale - - # TODO hackish way of forcing update of curves and images - for item in self._plot._getItems(withhidden=True): - item._updated() - self._plot._invalidateDataRange() - self._plot._forceResetZoom() + plot._forceResetZoom() self.sigScaleChanged.emit(self._scale) if emitLog: @@ -241,6 +271,40 @@ class Axis(qt.QObject): flag = bool(flag) self.setScale(self.LOGARITHMIC if flag else self.LINEAR) + def getTimeZone(self): + """Sets tzinfo that is used if this axis plots date times. + + None means the datetimes are interpreted as local time. + + :rtype: datetime.tzinfo of None. + """ + raise NotImplementedError() + + def setTimeZone(self, tz): + """Sets tzinfo that is used if this axis' tickMode is TIME_SERIES + + The tz must be a descendant of the datetime.tzinfo class, "UTC" or None. + Use None to let the datetimes be interpreted as local time. + Use the string "UTC" to let the date datetimes be in UTC time. + + :param tz: datetime.tzinfo, "UTC" or None. + """ + raise NotImplementedError() + + def getTickMode(self): + """Determines if axis ticks are number or datetimes. + + :rtype: TickMode enum. + """ + raise NotImplementedError() + + def setTickMode(self, tickMode): + """Determines if axis ticks are number or datetimes. + + :param TickMode tickMode: tick mode enum. + """ + raise NotImplementedError() + def isAutoScale(self): """Return True if axis is automatically adjusting its limits. @@ -271,7 +335,7 @@ class Axis(qt.QObject): """ updated = self._setLimitsConstraints(minPos, maxPos) if updated: - plot = self._plot + plot = self._getPlot() xMin, xMax = plot.getXAxis().getLimits() yMin, yMax = plot.getYAxis().getLimits() y2Min, y2Max = plot.getYAxis('right').getLimits() @@ -294,7 +358,7 @@ class Axis(qt.QObject): """ updated = self._setRangeConstraints(minRange, maxRange) if updated: - plot = self._plot + plot = self._getPlot() xMin, xMax = plot.getXAxis().getLimits() yMin, yMax = plot.getYAxis().getLimits() y2Min, y2Max = plot.getYAxis('right').getLimits() @@ -308,25 +372,51 @@ class XAxis(Axis): # TODO With some changes on the backend, it will be able to remove all this # specialised implementations (prefixel by '_internal') + def getTimeZone(self): + return self._getBackend().getXAxisTimeZone() + + def setTimeZone(self, tz): + if isinstance(tz, str) and tz.upper() == "UTC": + tz = dateutil.tz.tzutc() + elif not(tz is None or isinstance(tz, dt.tzinfo)): + raise TypeError("tz must be a dt.tzinfo object, None or 'UTC'.") + + self._getBackend().setXAxisTimeZone(tz) + self._getPlot()._setDirtyPlot() + + def getTickMode(self): + if self._getBackend().isXAxisTimeSeries(): + return TickMode.TIME_SERIES + else: + return TickMode.DEFAULT + + def setTickMode(self, tickMode): + if tickMode == TickMode.DEFAULT: + self._getBackend().setXAxisTimeSeries(False) + elif tickMode == TickMode.TIME_SERIES: + self._getBackend().setXAxisTimeSeries(True) + else: + raise ValueError("Unexpected TickMode: {}".format(tickMode)) + def _internalSetCurrentLabel(self, label): - self._plot._backend.setGraphXLabel(label) + self._getBackend().setGraphXLabel(label) def _internalGetLimits(self): - return self._plot._backend.getGraphXLimits() + return self._getBackend().getGraphXLimits() def _internalSetLimits(self, xmin, xmax): - self._plot._backend.setGraphXLimits(xmin, xmax) + self._getBackend().setGraphXLimits(xmin, xmax) def _internalSetLogarithmic(self, flag): - self._plot._backend.setXAxisLogarithmic(flag) + self._getBackend().setXAxisLogarithmic(flag) def _setLimitsConstraints(self, minPos=None, maxPos=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(xMin=minPos, xMax=maxPos) return updated def _setRangeConstraints(self, minRange=None, maxRange=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(minXRange=minRange, maxXRange=maxRange) return updated @@ -338,16 +428,16 @@ class YAxis(Axis): # specialised implementations (prefixel by '_internal') def _internalSetCurrentLabel(self, label): - self._plot._backend.setGraphYLabel(label, axis='left') + self._getBackend().setGraphYLabel(label, axis='left') def _internalGetLimits(self): - return self._plot._backend.getGraphYLimits(axis='left') + return self._getBackend().getGraphYLimits(axis='left') def _internalSetLimits(self, ymin, ymax): - self._plot._backend.setGraphYLimits(ymin, ymax, axis='left') + self._getBackend().setGraphYLimits(ymin, ymax, axis='left') def _internalSetLogarithmic(self, flag): - self._plot._backend.setYAxisLogarithmic(flag) + self._getBackend().setYAxisLogarithmic(flag) def setInverted(self, flag=True): """Set the axis orientation. @@ -358,8 +448,8 @@ class YAxis(Axis): False for Y axis going from bottom to top """ flag = bool(flag) - self._plot._backend.setYAxisInverted(flag) - self._plot._setDirtyPlot() + self._getBackend().setYAxisInverted(flag) + self._getPlot()._setDirtyPlot() self.sigInvertedChanged.emit(flag) def isInverted(self): @@ -368,15 +458,15 @@ class YAxis(Axis): :rtype: bool """ - return self._plot._backend.isYAxisInverted() + return self._getBackend().isYAxisInverted() def _setLimitsConstraints(self, minPos=None, maxPos=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(yMin=minPos, yMax=maxPos) return updated def _setRangeConstraints(self, minRange=None, maxRange=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(minYRange=minRange, maxYRange=maxRange) return updated @@ -419,13 +509,13 @@ class YRightAxis(Axis): return self.__mainAxis.sigAutoScaleChanged def _internalSetCurrentLabel(self, label): - self._plot._backend.setGraphYLabel(label, axis='right') + self._getBackend().setGraphYLabel(label, axis='right') def _internalGetLimits(self): - return self._plot._backend.getGraphYLimits(axis='right') + return self._getBackend().getGraphYLimits(axis='right') def _internalSetLimits(self, ymin, ymax): - self._plot._backend.setGraphYLimits(ymin, ymax, axis='right') + self._getBackend().setGraphYLimits(ymin, ymax, axis='right') def setInverted(self, flag=True): """Set the Y axis orientation. diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py index ba57e85..535b0a9 100644 --- a/silx/gui/plot/items/complex.py +++ b/silx/gui/plot/items/complex.py @@ -29,7 +29,7 @@ from __future__ import absolute_import __authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] __license__ = "MIT" -__date__ = "19/01/2018" +__date__ = "14/06/2018" import logging @@ -37,7 +37,7 @@ import numpy from silx.third_party import enum -from ..Colormap import Colormap +from ...colors import Colormap from .core import ColormapMixIn, ItemChangedType from .image import ImageBase @@ -229,7 +229,7 @@ class ImageComplexData(ImageBase, ColormapMixIn): def setColormap(self, colormap, mode=None): """Set the colormap for this specific mode. - :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap + :param ~silx.gui.colors.Colormap colormap: The colormap :param Mode mode: If specified, set the colormap of this specific mode. Default: current mode. @@ -249,7 +249,7 @@ class ImageComplexData(ImageBase, ColormapMixIn): :param Mode mode: If specified, get the colormap of this specific mode. Default: current mode. - :rtype: ~silx.gui.plot.Colormap.Colormap + :rtype: ~silx.gui.colors.Colormap """ if mode is None: mode = self.getVisualizationMode() diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index bcb6dd1..4ed0914 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -27,18 +27,19 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "14/06/2018" import collections from copy import deepcopy import logging +import warnings import weakref import numpy from silx.third_party import six, enum from ... import qt -from .. import Colors -from ..Colormap import Colormap +from ... import colors +from ...colors import Colormap _logger = logging.getLogger(__name__) @@ -409,7 +410,7 @@ class ColormapMixIn(ItemMixInBase): def setColormap(self, colormap): """Set the colormap of this image - :param silx.gui.plot.Colormap.Colormap colormap: colormap description + :param silx.gui.colors.Colormap colormap: colormap description """ if isinstance(colormap, dict): colormap = Colormap._fromDict(colormap) @@ -619,17 +620,17 @@ class ColorMixIn(ItemMixInBase): :param color: color(s) to be used :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py :param bool copy: True (Default) to get a copy, False to use internal representation (do not modify!) """ if isinstance(color, six.string_types): - color = Colors.rgba(color) + color = colors.rgba(color) else: color = numpy.array(color, copy=copy) # TODO more checks + improve color array support if color.ndim == 1: # Single RGBA color - color = Colors.rgba(color) + color = colors.rgba(color) else: # Array of colors assert color.ndim == 2 @@ -767,7 +768,10 @@ class Points(Item, SymbolMixIn, AlphaMixIn): error = numpy.ravel(error) # Supports error being scalar, N or 2xN array - errorClipped = (value - numpy.atleast_2d(error)[0]) <= 0 + valueMinusError = value - numpy.atleast_2d(error)[0] + errorClipped = numpy.isnan(valueMinusError) + mask = numpy.logical_not(errorClipped) + errorClipped[mask] = valueMinusError[mask] <= 0 if numpy.any(errorClipped): # Need filtering @@ -805,10 +809,20 @@ class Points(Item, SymbolMixIn, AlphaMixIn): """ assert xPositive or yPositive if (xPositive, yPositive) not in self._clippedCache: - x = self.getXData(copy=False) - y = self.getYData(copy=False) - xclipped = (x <= 0) if xPositive else False - yclipped = (y <= 0) if yPositive else False + xclipped, yclipped = False, False + + if xPositive: + x = self.getXData(copy=False) + with warnings.catch_warnings(): # Ignore NaN warnings + warnings.simplefilter('ignore', category=RuntimeWarning) + xclipped = x <= 0 + + if yPositive: + y = self.getYData(copy=False) + with warnings.catch_warnings(): # Ignore NaN warnings + warnings.simplefilter('ignore', category=RuntimeWarning) + yclipped = y <= 0 + self._clippedCache[(xPositive, yPositive)] = \ numpy.logical_or(xclipped, yclipped) return self._clippedCache[(xPositive, yPositive)] diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py index 0ba475d..50ad86d 100644 --- a/silx/gui/plot/items/curve.py +++ b/silx/gui/plot/items/curve.py @@ -27,13 +27,13 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "06/03/2017" +__date__ = "24/04/2018" import logging import numpy -from .. import Colors +from ... import colors from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn, ItemChangedType) @@ -170,9 +170,9 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): :param color: color(s) to be used for highlight :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py """ - color = Colors.rgba(color) + color = colors.rgba(color) if color != self._highlightColor: self._highlightColor = color self._updated(ItemChangedType.HIGHLIGHTED_COLOR) diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py index ad89677..3545345 100644 --- a/silx/gui/plot/items/histogram.py +++ b/silx/gui/plot/items/histogram.py @@ -29,7 +29,6 @@ __authors__ = ["H. Payno", "T. Vincent"] __license__ = "MIT" __date__ = "27/06/2017" - import logging import numpy @@ -37,7 +36,6 @@ import numpy from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn, LineMixIn, YAxisMixIn, ItemChangedType) - _logger = logging.getLogger(__name__) @@ -290,5 +288,40 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, self._histogram = histogram self._edges = edges + self._alignement = align self._updated(ItemChangedType.DATA) + + def getAlignment(self): + """ + + :return: histogram alignement. Value in ('center', 'left', 'right'). + """ + return self._alignement + + def _revertComputeEdges(self, x, histogramType): + """Compute the edges from a set of xs and a rule to generate the edges + + :param x: the x value of the curve to transform into an histogram + :param histogramType: the type of histogram we wan't to generate. + This define the way to center the histogram values compared to the + curve value. Possible values can be:: + + - 'left' + - 'right' + - 'center' + + :return: the edges for the given x and the histogramType + """ + # for now we consider that the spaces between xs are constant + edges = x.copy() + if histogramType is 'left': + return edges[1:] + if histogramType is 'center': + edges = (edges[1:] + edges[:-1]) / 2.0 + if histogramType is 'right': + width = 1 + if len(x) > 1: + width = x[-1] + x[-2] + edges = edges[:-1] + return edges diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py new file mode 100644 index 0000000..f55ef91 --- /dev/null +++ b/silx/gui/plot/items/roi.py @@ -0,0 +1,1416 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides ROI item for the :class:`~silx.gui.plot.PlotWidget`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import functools +import itertools +import logging +import collections +import numpy + +from ....utils.weakref import WeakList +from ... import qt +from .. import items +from ...colors import rgba + + +logger = logging.getLogger(__name__) + + +class RegionOfInterest(qt.QObject): + """Object describing a region of interest in a plot. + + :param QObject parent: + The RegionOfInterestManager that created this object + """ + + _kind = None + """Label for this kind of ROI. + + Should be setted by inherited classes to custom the ROI manager widget. + """ + + sigRegionChanged = qt.Signal() + """Signal emitted everytime the shape or position of the ROI changes""" + + def __init__(self, parent=None): + # Avoid circular dependancy + from ..tools import roi as roi_tools + assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) + super(RegionOfInterest, self).__init__(parent) + self._color = rgba('red') + self._items = WeakList() + self._editAnchors = WeakList() + self._points = None + self._label = '' + self._labelItem = None + self._editable = False + + def __del__(self): + # Clean-up plot items + self._removePlotItems() + + def setParent(self, parent): + """Set the parent of the RegionOfInterest + + :param Union[None,RegionOfInterestManager] parent: + """ + # Avoid circular dependancy + from ..tools import roi as roi_tools + if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)): + raise ValueError('Unsupported parent') + + self._removePlotItems() + super(RegionOfInterest, self).setParent(parent) + self._createPlotItems() + + @classmethod + def _getKind(cls): + """Return an human readable kind of ROI + + :rtype: str + """ + return cls._kind + + def getColor(self): + """Returns the color of this ROI + + :rtype: QColor + """ + return qt.QColor.fromRgbF(*self._color) + + def _getAnchorColor(self, color): + """Returns the anchor color from the base ROI color + + :param Union[numpy.array,Tuple,List]: color + :rtype: Union[numpy.array,Tuple,List] + """ + return color[:3] + (0.5,) + + def setColor(self, color): + """Set the color used for this ROI. + + :param color: The color to use for ROI shape as + either a color name, a QColor, a list of uint8 or float in [0, 1]. + """ + color = rgba(color) + if color != self._color: + self._color = color + + # Update color of shape items in the plot + rgbaColor = rgba(color) + for item in list(self._items): + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + item = self._getLabelItem() + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + + rgbaColor = self._getAnchorColor(rgbaColor) + for item in list(self._editAnchors): + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + + def getLabel(self): + """Returns the label displayed for this ROI. + + :rtype: str + """ + return self._label + + def setLabel(self, label): + """Set the label displayed with this ROI. + + :param str label: The text label to display + """ + label = str(label) + if label != self._label: + self._label = label + self._updateLabelItem(label) + + def isEditable(self): + """Returns whether the ROI is editable by the user or not. + + :rtype: bool + """ + return self._editable + + def setEditable(self, editable): + """Set whether the ROI can be changed interactively. + + :param bool editable: True to allow edition by the user, + False to disable. + """ + editable = bool(editable) + if self._editable != editable: + self._editable = editable + # Recreate plot items + # This can be avoided once marker.setDraggable is public + self._createPlotItems() + + def _getControlPoints(self): + """Returns the current ROI control points. + + It returns an empty tuple if there is currently no ROI. + + :return: Array of (x, y) position in plot coordinates + :rtype: numpy.ndarray + """ + return None if self._points is None else numpy.array(self._points) + + @classmethod + def showFirstInteractionShape(cls): + """Returns True if the shape created by the first interaction and + managed by the plot have to be visible. + + :rtype: bool + """ + return True + + @classmethod + def getFirstInteractionShape(cls): + """Returns the shape kind which will be used by the very first + interaction with the plot. + + This interactions are hardcoded inside the plot + + :rtype: str + """ + return cls._plotShape + + def setFirstShapePoints(self, points): + """"Initialize the ROI using the points from the first interaction. + + This interaction is constains by the plot API and only supports few + shapes. + """ + points = self._createControlPointsFromFirstShape(points) + self._setControlPoints(points) + + def _createControlPointsFromFirstShape(self, points): + """Returns the list of control points from the very first shape + provided. + + This shape is provided by the plot interaction and constained by the + class of the ROI itself. + """ + return points + + def _setControlPoints(self, points): + """Set this ROI control points. + + :param points: Iterable of (x, y) control points + """ + points = numpy.array(points) + + nbPointsChanged = (self._points is None or + points.shape != self._points.shape) + + if nbPointsChanged or not numpy.all(numpy.equal(points, self._points)): + self._points = points + + self._updateShape() + if self._items and not nbPointsChanged: # Update plot items + item = self._getLabelItem() + if item is not None: + markerPos = self._getLabelPosition() + item.setPosition(*markerPos) + + if self._editAnchors: # Update anchors + for anchor, point in zip(self._editAnchors, points): + old = anchor.blockSignals(True) + anchor.setPosition(*point) + anchor.blockSignals(old) + + else: # No items or new point added + # re-create plot items + self._createPlotItems() + + self.sigRegionChanged.emit() + + def _updateShape(self): + """Called when shape must be updated. + + Must be reimplemented if a shape item have to be updated. + """ + return + + def _getLabelPosition(self): + """Compute position of the label + + :return: (x, y) position of the marker + """ + return None + + def _createPlotItems(self): + """Create items displaying the ROI in the plot. + + It first removes any existing plot items. + """ + roiManager = self.parent() + if roiManager is None: + return + plot = roiManager.parent() + + self._removePlotItems() + + legendPrefix = "__RegionOfInterest-%d__" % id(self) + itemIndex = 0 + + controlPoints = self._getControlPoints() + + if self._labelItem is None: + self._labelItem = self._createLabelItem() + if self._labelItem is not None: + self._labelItem._setLegend(legendPrefix + "label") + plot._add(self._labelItem) + + self._items = WeakList() + plotItems = self._createShapeItems(controlPoints) + for item in plotItems: + item._setLegend(legendPrefix + str(itemIndex)) + plot._add(item) + self._items.append(item) + itemIndex += 1 + + self._editAnchors = WeakList() + if self.isEditable(): + plotItems = self._createAnchorItems(controlPoints) + color = rgba(self.getColor()) + color = self._getAnchorColor(color) + for index, item in enumerate(plotItems): + item._setLegend(legendPrefix + str(itemIndex)) + item.setColor(color) + plot._add(item) + item.sigItemChanged.connect(functools.partial( + self._controlPointAnchorChanged, index)) + self._editAnchors.append(item) + itemIndex += 1 + + def _updateLabelItem(self, label): + """Update the marker displaying the label. + + Inherite this method to custom the way the ROI display the label. + + :param str label: The new label to use + """ + item = self._getLabelItem() + if item is not None: + item.setText(label) + + def _createLabelItem(self): + """Returns a created marker which will be used to dipslay the label of + this ROI. + + Inherite this method to return nothing if no new items have to be + created, or your own marker. + + :rtype: Union[None,Marker] + """ + # Add label marker + markerPos = self._getLabelPosition() + marker = items.Marker() + marker.setPosition(*markerPos) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker.setSymbol('') + marker._setDraggable(False) + return marker + + def _getLabelItem(self): + """Returns the marker displaying the label of this ROI. + + Inherite this method to choose your own item. In case this item is also + a control point. + """ + return self._labelItem + + def _createShapeItems(self, points): + """Create shape items from the current control points. + + :rtype: List[PlotItem] + """ + return [] + + def _createAnchorItems(self, points): + """Create anchor items from the current control points. + + :rtype: List[Marker] + """ + return [] + + def _controlPointAnchorChanged(self, index, event): + """Handle update of position of an edition anchor + + :param int index: Index of the anchor + :param ItemChangedType event: Event type + """ + if event == items.ItemChangedType.POSITION: + anchor = self._editAnchors[index] + previous = self._points[index].copy() + current = anchor.getPosition() + self._controlPointAnchorPositionChanged(index, current, previous) + + def _controlPointAnchorPositionChanged(self, index, current, previous): + """Called when an anchor is manually edited. + + This function have to be inherited to change the behaviours of the + control points. This function have to call :meth:`_getControlPoints` to + reach the previous state of the control points. Updated the positions + of the changed control points. Then call :meth:`_setControlPoints` to + update the anchors and send signals. + """ + points = self._getControlPoints() + points[index] = current + self._setControlPoints(points) + + def _removePlotItems(self): + """Remove items from their plot.""" + for item in itertools.chain(list(self._items), + list(self._editAnchors)): + + plot = item.getPlot() + if plot is not None: + plot._remove(item) + self._items = WeakList() + self._editAnchors = WeakList() + + if self._labelItem is not None: + item = self._labelItem + plot = item.getPlot() + if plot is not None: + plot._remove(item) + self._labelItem = None + + def __str__(self): + """Returns parameters of the ROI as a string.""" + points = self._getControlPoints() + params = '; '.join('(%f; %f)' % (pt[0], pt[1]) for pt in points) + return "%s(%s)" % (self.__class__.__name__, params) + + +class PointROI(RegionOfInterest): + """A ROI identifying a point in a 2D plot.""" + + _kind = "Point" + """Label for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self._points[0].copy() + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + controlPoints = numpy.array([pos]) + self._setControlPoints(controlPoints) + + def _createLabelItem(self): + return None + + def _updateLabelItem(self, label): + if self.isEditable(): + item = self._editAnchors[0] + else: + item = self._items[0] + item.setText(label) + + def _createShapeItems(self, points): + if self.isEditable(): + return [] + marker = items.Marker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker._setDraggable(False) + return [marker] + + def _createAnchorItems(self, points): + marker = items.Marker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker._setDraggable(self.isEditable()) + return [marker] + + def __str__(self): + points = self._getControlPoints() + params = '%f %f' % (points[0, 0], points[0, 1]) + return "%s(%s)" % (self.__class__.__name__, params) + + +class LineROI(RegionOfInterest): + """A ROI identifying a line in a 2D plot. + + This ROI provides 1 anchor for each boundary of the line, plus an center + in the center to translate the full ROI. + """ + + _kind = "Line" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + center = numpy.mean(points, axis=0) + controlPoints = numpy.array([points[0], points[1], center]) + return controlPoints + + def setEndPoints(self, startPoint, endPoint): + """Set this line location using the endding points + + :param numpy.ndarray startPoint: Staring bounding point of the line + :param numpy.ndarray endPoint: Endding bounding point of the line + """ + assert(startPoint.shape == (2,) and endPoint.shape == (2,)) + shapePoints = numpy.array([startPoint, endPoint]) + controlPoints = self._createControlPointsFromFirstShape(shapePoints) + self._setControlPoints(controlPoints) + + def getEndPoints(self): + """Returns bounding points of this ROI. + + :rtype: Tuple(numpy.ndarray,numpy.ndarray) + """ + startPoint = self._points[0].copy() + endPoint = self._points[1].copy() + return (startPoint, endPoint) + + def _getLabelPosition(self): + points = self._getControlPoints() + return points[-1] + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + points = self._getShapeFromControlPoints(points) + shape.setPoints(points) + + def _getShapeFromControlPoints(self, points): + # Remove the center from the control points + return points[0:2] + + def _createShapeItems(self, points): + shapePoints = self._getShapeFromControlPoints(points) + item = items.Shape("polylines") + item.setPoints(shapePoints) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + anchors = [] + for point in points[0:-1]: + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol('s') + anchor._setDraggable(True) + anchors.append(anchor) + + # Add an anchor to the center of the rectangle + center = numpy.mean(points, axis=0) + anchor = items.Marker() + anchor.setPosition(*center) + anchor.setText('') + anchor.setSymbol('+') + anchor._setDraggable(True) + anchors.append(anchor) + + return anchors + + def _controlPointAnchorPositionChanged(self, index, current, previous): + if index == len(self._editAnchors) - 1: + # It is the center anchor + points = self._getControlPoints() + center = numpy.mean(points[0:-1], axis=0) + offset = current - previous + points[-1] = current + points[0:-1] = points[0:-1] + offset + self._setControlPoints(points) + else: + # Update the center + points = self._getControlPoints() + points[index] = current + center = numpy.mean(points[0:-1], axis=0) + points[-1] = center + self._setControlPoints(points) + + def __str__(self): + points = self._getControlPoints() + params = points[0][0], points[0][1], points[1][0], points[1][1] + params = 'start: %f %f; end: %f %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class HorizontalLineROI(RegionOfInterest): + """A ROI identifying an horizontal line in a 2D plot.""" + + _kind = "HLine" + """Label for this kind of ROI""" + + _plotShape = "hline" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + points = numpy.array([(float('nan'), points[0, 1])], + dtype=numpy.float64) + return points + + def getPosition(self): + """Returns the position of this line if the horizontal axis + + :rtype: float + """ + return self._points[0, 1] + + def setPosition(self, pos): + """Set the position of this ROI + + :param float pos: Horizontal position of this line + """ + controlPoints = numpy.array([[float('nan'), pos]]) + self._setControlPoints(controlPoints) + + def _createLabelItem(self): + return None + + def _updateLabelItem(self, label): + if self.isEditable(): + item = self._editAnchors[0] + else: + item = self._items[0] + item.setText(label) + + def _updateShape(self): + if not self.isEditable(): + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def _createShapeItems(self, points): + if self.isEditable(): + return [] + marker = items.YMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker._setDraggable(False) + return [marker] + + def _createAnchorItems(self, points): + marker = items.YMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker._setDraggable(self.isEditable()) + return [marker] + + def __str__(self): + points = self._getControlPoints() + params = 'y: %f' % points[0, 1] + return "%s(%s)" % (self.__class__.__name__, params) + + +class VerticalLineROI(RegionOfInterest): + """A ROI identifying a vertical line in a 2D plot.""" + + _kind = "VLine" + """Label for this kind of ROI""" + + _plotShape = "vline" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + points = numpy.array([(points[0, 0], float('nan'))], + dtype=numpy.float64) + return points + + def getPosition(self): + """Returns the position of this line if the horizontal axis + + :rtype: float + """ + return self._points[0, 0] + + def setPosition(self, pos): + """Set the position of this ROI + + :param float pos: Horizontal position of this line + """ + controlPoints = numpy.array([[pos, float('nan')]]) + self._setControlPoints(controlPoints) + + def _createLabelItem(self): + return None + + def _updateLabelItem(self, label): + if self.isEditable(): + item = self._editAnchors[0] + else: + item = self._items[0] + item.setText(label) + + def _updateShape(self): + if not self.isEditable(): + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def _createShapeItems(self, points): + if self.isEditable(): + return [] + marker = items.XMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker._setDraggable(False) + return [marker] + + def _createAnchorItems(self, points): + marker = items.XMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker._setDraggable(self.isEditable()) + return [marker] + + def __str__(self): + points = self._getControlPoints() + params = 'x: %f' % points[0, 0] + return "%s(%s)" % (self.__class__.__name__, params) + + +class RectangleROI(RegionOfInterest): + """A ROI identifying a rectangle in a 2D plot. + + This ROI provides 1 anchor for each corner, plus an anchor in the + center to translate the full ROI. + """ + + _kind = "Rectangle" + """Label for this kind of ROI""" + + _plotShape = "rectangle" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + point0 = points[0] + point1 = points[1] + + # 4 corners + controlPoints = numpy.array([ + point0[0], point0[1], + point0[0], point1[1], + point1[0], point1[1], + point1[0], point0[1], + ]) + # Central + center = numpy.mean(points, axis=0) + controlPoints = numpy.append(controlPoints, center) + controlPoints.shape = -1, 2 + return controlPoints + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + return numpy.mean(self._points, axis=0) + + def getOrigin(self): + """Returns the corner point with the smaller coordinates + + :rtype: numpy.ndarray([float,float]) + """ + return numpy.min(self._points, axis=0) + + def getSize(self): + """Returns the size of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + minPoint = numpy.min(self._points, axis=0) + maxPoint = numpy.max(self._points, axis=0) + return maxPoint - minPoint + + def setOrigin(self, position): + """Set the origin position of this ROI + + :param numpy.ndarray position: Location of the smaller corner of the ROI + """ + size = self.getSize() + self.setGeometry(origin=position, size=size) + + def setSize(self, size): + """Set the size of this ROI + + :param numpy.ndarray size: Size of the center of the ROI + """ + origin = self.getOrigin() + self.setGeometry(origin=origin, size=size) + + def setCenter(self, position): + """Set the size of this ROI + + :param numpy.ndarray position: Location of the center of the ROI + """ + size = self.getSize() + self.setGeometry(center=position, size=size) + + def setGeometry(self, origin=None, size=None, center=None): + """Set the geometry of the ROI + """ + if origin is not None: + origin = numpy.array(origin) + size = numpy.array(size) + points = numpy.array([origin, origin + size]) + controlPoints = self._createControlPointsFromFirstShape(points) + elif center is not None: + center = numpy.array(center) + size = numpy.array(size) + points = numpy.array([center - size * 0.5, center + size * 0.5]) + controlPoints = self._createControlPointsFromFirstShape(points) + else: + raise ValueError("Origin or cengter expected") + self._setControlPoints(controlPoints) + + def _getLabelPosition(self): + points = self._getControlPoints() + return points.min(axis=0) + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + points = self._getShapeFromControlPoints(points) + shape.setPoints(points) + + def _getShapeFromControlPoints(self, points): + minPoint = points.min(axis=0) + maxPoint = points.max(axis=0) + return numpy.array([minPoint, maxPoint]) + + def _createShapeItems(self, points): + shapePoints = self._getShapeFromControlPoints(points) + item = items.Shape("rectangle") + item.setPoints(shapePoints) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + # Remove the center control point + points = points[0:-1] + + anchors = [] + for point in points: + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol('s') + anchor._setDraggable(True) + anchors.append(anchor) + + # Add an anchor to the center of the rectangle + center = numpy.mean(points, axis=0) + anchor = items.Marker() + anchor.setPosition(*center) + anchor.setText('') + anchor.setSymbol('+') + anchor._setDraggable(True) + anchors.append(anchor) + + return anchors + + def _controlPointAnchorPositionChanged(self, index, current, previous): + if index == len(self._editAnchors) - 1: + # It is the center anchor + points = self._getControlPoints() + center = numpy.mean(points[0:-1], axis=0) + offset = current - previous + points[-1] = current + points[0:-1] = points[0:-1] + offset + self._setControlPoints(points) + else: + # Fix other corners + constrains = [(1, 3), (0, 2), (3, 1), (2, 0)] + constrains = constrains[index] + points = self._getControlPoints() + points[index] = current + points[constrains[0]][0] = current[0] + points[constrains[1]][1] = current[1] + # Update the center + center = numpy.mean(points[0:-1], axis=0) + points[-1] = center + self._setControlPoints(points) + + def __str__(self): + origin = self.getOrigin() + w, h = self.getSize() + params = origin[0], origin[1], w, h + params = 'origin: %f %f; width: %f; height: %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class PolygonROI(RegionOfInterest): + """A ROI identifying a closed polygon in a 2D plot. + + This ROI provides 1 anchor for each point of the polygon. + """ + + _kind = "Polygon" + """Label for this kind of ROI""" + + _plotShape = "polygon" + """Plot shape which is used for the first interaction""" + + def getPoints(self): + """Returns the list of the points of this polygon. + + :rtype: numpy.ndarray + """ + return self._points.copy() + + def setPoints(self, points): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + assert(len(points.shape) == 2 and points.shape[1] == 2) + if len(points) > 0: + controlPoints = numpy.array(points) + else: + controlPoints = numpy.empty((0, 2)) + self._setControlPoints(controlPoints) + + def _getLabelPosition(self): + points = self._getControlPoints() + if len(points) == 0: + # FIXME: we should return none, this polygon have no location + return numpy.array([0, 0]) + return points[numpy.argmin(points[:, 1])] + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + shape.setPoints(points) + + def _createShapeItems(self, points): + if len(points) == 0: + return [] + else: + item = items.Shape("polygon") + item.setPoints(points) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + anchors = [] + for point in points: + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol('s') + anchor._setDraggable(True) + anchors.append(anchor) + return anchors + + def __str__(self): + points = self._getControlPoints() + params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points) + return "%s(%s)" % (self.__class__.__name__, params) + + +class ArcROI(RegionOfInterest): + """A ROI identifying an arc of a circle with a width. + + This ROI provides 3 anchors to control the curvature, 1 anchor to control + the weigth, and 1 anchor to translate the shape. + """ + + _kind = "Arc" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + _ArcGeometry = collections.namedtuple('ArcGeometry', ['center', + 'startPoint', 'endPoint', + 'radius', 'weight', + 'startAngle', 'endAngle']) + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + self._geometry = None + + def _getInternalGeometry(self): + """Returns the object storing the internal geometry of this ROI. + + This geometry is derived from the control points and cached for + efficiency. Calling :meth:`_setControlPoints` invalidate the cache. + """ + if self._geometry is None: + controlPoints = self._getControlPoints() + self._geometry = self._createGeometryFromControlPoint(controlPoints) + return self._geometry + + @classmethod + def showFirstInteractionShape(cls): + return False + + def _getLabelPosition(self): + points = self._getControlPoints() + return points.min(axis=0) + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + points = self._getShapeFromControlPoints(points) + shape.setPoints(points) + + def _controlPointAnchorPositionChanged(self, index, current, previous): + controlPoints = self._getControlPoints() + currentWeigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 + + if index in [0, 2]: + # Moving start or end will maintain the same curvature + # Then we have to custom the curvature control point + startPoint = controlPoints[0] + endPoint = controlPoints[2] + center = (startPoint + endPoint) * 0.5 + normal = (endPoint - startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + # Compute the coeficient which have to be constrained + if distance != 0: + normal /= distance + midVector = controlPoints[1] - center + constainedCoef = numpy.dot(midVector, normal) / distance + else: + constainedCoef = 1.0 + + # Compute the location of the curvature point + controlPoints[index] = current + startPoint = controlPoints[0] + endPoint = controlPoints[2] + center = (startPoint + endPoint) * 0.5 + normal = (endPoint - startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + # BTW we dont need to divide by the distance here + # Cause we compute normal * distance after all + normal /= distance + midPoint = center + normal * constainedCoef * distance + controlPoints[1] = midPoint + + # The weight have to be fixed + self._updateWeightControlPoint(controlPoints, currentWeigth) + self._setControlPoints(controlPoints) + + elif index == 1: + # The weight have to be fixed + controlPoints[index] = current + self._updateWeightControlPoint(controlPoints, currentWeigth) + self._setControlPoints(controlPoints) + else: + super(ArcROI, self)._controlPointAnchorPositionChanged(index, current, previous) + + def _updateWeightControlPoint(self, controlPoints, weigth): + startPoint = controlPoints[0] + midPoint = controlPoints[1] + endPoint = controlPoints[2] + normal = (endPoint - startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + normal /= distance + controlPoints[3] = midPoint + normal * weigth * 0.5 + + def _createGeometryFromControlPoint(self, controlPoints): + """Returns the geometry of the object""" + weigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 + if numpy.allclose(controlPoints[0], controlPoints[2]): + # Special arc: It's a closed circle + center = (controlPoints[0] + controlPoints[1]) * 0.5 + radius = numpy.linalg.norm(controlPoints[0] - center) + v = controlPoints[0] - center + startAngle = numpy.angle(complex(v[0], v[1])) + endAngle = startAngle + numpy.pi * 2.0 + return self._ArcGeometry(center, controlPoints[0], controlPoints[2], + radius, weigth, startAngle, endAngle) + + elif numpy.linalg.norm( + numpy.cross(controlPoints[1] - controlPoints[0], + controlPoints[2] - controlPoints[0])) < 1e-5: + # Degenerated arc, it's a rectangle + return self._ArcGeometry(None, controlPoints[0], controlPoints[2], + None, weigth, None, None) + else: + center, radius = self._circleEquation(*controlPoints[:3]) + v = controlPoints[0] - center + startAngle = numpy.angle(complex(v[0], v[1])) + v = controlPoints[1] - center + midAngle = numpy.angle(complex(v[0], v[1])) + v = controlPoints[2] - center + endAngle = numpy.angle(complex(v[0], v[1])) + # Is it clockwise or anticlockwise + if (midAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi) <= numpy.pi: + if endAngle < startAngle: + endAngle += 2 * numpy.pi + else: + if endAngle > startAngle: + endAngle -= 2 * numpy.pi + + return self._ArcGeometry(center, controlPoints[0], controlPoints[2], + radius, weigth, startAngle, endAngle) + + def _isCircle(self, geometry): + """Returns True if the geometry is a closed circle""" + delta = numpy.abs(geometry.endAngle - geometry.startAngle) + return numpy.isclose(delta, numpy.pi * 2) + + def _getShapeFromControlPoints(self, controlPoints): + geometry = self._createGeometryFromControlPoint(controlPoints) + if geometry.center is None: + # It is not an arc + # but we can display it as an the intermediat shape + normal = (geometry.endPoint - geometry.startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + normal /= distance + points = numpy.array([ + geometry.startPoint + normal * geometry.weight * 0.5, + geometry.endPoint + normal * geometry.weight * 0.5, + geometry.endPoint - normal * geometry.weight * 0.5, + geometry.startPoint - normal * geometry.weight * 0.5]) + else: + innerRadius = geometry.radius - geometry.weight * 0.5 + outerRadius = geometry.radius + geometry.weight * 0.5 + + if numpy.isnan(geometry.startAngle): + # Degenerated, it's a point + # At least 2 points are expected + return numpy.array([geometry.startPoint, geometry.startPoint]) + + delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 + if geometry.startAngle == geometry.endAngle: + # Degenerated, it's a line (single radius) + angle = geometry.startAngle + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points = [] + points.append(geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + return numpy.array(points) + + angles = numpy.arange(geometry.startAngle, geometry.endAngle, delta) + if angles[-1] != geometry.endAngle: + angles = numpy.append(angles, geometry.endAngle) + + isCircle = self._isCircle(geometry) + + if isCircle: + if innerRadius <= 0: + # It's a circle + points = [] + numpy.append(angles, angles[-1]) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.append(geometry.center + direction * outerRadius) + else: + # It's a donut + points = [] + # NOTE: NaN value allow to create 2 separated circle shapes + # using a single plot item. It's a kind of cheat + points.append(numpy.array([float("nan"), float("nan")])) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.insert(0, geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + points.append(numpy.array([float("nan"), float("nan")])) + else: + if innerRadius <= 0: + # It's a part of camembert + points = [] + points.append(geometry.center) + points.append(geometry.startPoint) + delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.append(geometry.center + direction * outerRadius) + points.append(geometry.endPoint) + points.append(geometry.center) + else: + # It's a part of donut + points = [] + points.append(geometry.startPoint) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.insert(0, geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + points.insert(0, geometry.endPoint) + points.append(geometry.endPoint) + points = numpy.array(points) + + return points + + def _setControlPoints(self, points): + # Invalidate the geometry + self._geometry = None + RegionOfInterest._setControlPoints(self, points) + + def getGeometry(self): + """Returns a tuple containing the geometry of this ROI + + It is a symetric fonction of :meth:`setGeometry`. + + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + + :rtype: Tuple[numpy.ndarray,float,float,float,float] + :raise ValueError: In case the ROI can't be representaed as section of + a circle + """ + geometry = self._getInternalGeometry() + if geometry.center is None: + raise ValueError("This ROI can't be represented as a section of circle") + return geometry.center, self.getInnerRadius(), self.getOuterRadius(), geometry.startAngle, geometry.endAngle + + def isClosed(self): + """Returns true if the arc is a closed shape, like a circle or a donut. + + :rtype: bool + """ + geometry = self._getInternalGeometry() + return self._isCircle(geometry) + + def getCenter(self): + """Returns the center of the circle used to draw arcs of this ROI. + + This center is usually outside the the shape itself. + + :rtype: numpy.ndarray + """ + geometry = self._getInternalGeometry() + return geometry.center + + def getStartAngle(self): + """Returns the angle of the start of the section of this ROI (in radian). + + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + + :rtype: float + """ + geometry = self._getInternalGeometry() + return geometry.startAngle + + def getEndAngle(self): + """Returns the angle of the end of the section of this ROI (in radian). + + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + + :rtype: float + """ + geometry = self._getInternalGeometry() + return geometry.endAngle + + def getInnerRadius(self): + """Returns the radius of the smaller arc used to draw this ROI. + + :rtype: float + """ + geometry = self._getInternalGeometry() + radius = geometry.radius - geometry.weight * 0.5 + if radius < 0: + radius = 0 + return radius + + def getOuterRadius(self): + """Returns the radius of the bigger arc used to draw this ROI. + + :rtype: float + """ + geometry = self._getInternalGeometry() + radius = geometry.radius + geometry.weight * 0.5 + return radius + + def setGeometry(self, center, innerRadius, outerRadius, startAngle, endAngle): + """ + Set the geometry of this arc. + + :param numpy.ndarray center: Center of the circle. + :param float innerRadius: Radius of the smaller arc of the section. + :param float outerRadius: Weight of the bigger arc of the section. + It have to be bigger than `innerRadius` + :param float startAngle: Location of the start of the section (in radian) + :param float endAngle: Location of the end of the section (in radian). + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + """ + assert(innerRadius <= outerRadius) + assert(numpy.abs(startAngle - endAngle) <= 2 * numpy.pi) + center = numpy.array(center) + radius = (innerRadius + outerRadius) * 0.5 + weight = outerRadius - innerRadius + geometry = self._ArcGeometry(center, None, None, radius, weight, startAngle, endAngle) + controlPoints = self._createControlPointsFromGeometry(geometry) + self._setControlPoints(controlPoints) + + def _createControlPointsFromGeometry(self, geometry): + if geometry.startPoint or geometry.endPoint: + # Duplication with the angles + raise NotImplementedError("This general case is not implemented") + + angle = geometry.startAngle + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + startPoint = geometry.center + direction * geometry.radius + + angle = geometry.endAngle + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + endPoint = geometry.center + direction * geometry.radius + + angle = (geometry.startAngle + geometry.endAngle) * 0.5 + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + curvaturePoint = geometry.center + direction * geometry.radius + weightPoint = curvaturePoint + direction * geometry.weight * 0.5 + + return numpy.array([startPoint, curvaturePoint, endPoint, weightPoint]) + + def _createControlPointsFromFirstShape(self, points): + # The first shape is a line + point0 = points[0] + point1 = points[1] + + # Compute a non colineate point for the curvature + center = (point1 + point0) * 0.5 + normal = point1 - center + normal = numpy.array((normal[1], -normal[0])) + defaultCurvature = numpy.pi / 5.0 + defaultWeight = 0.20 # percentage + curvaturePoint = center - normal * defaultCurvature + weightPoint = center - normal * defaultCurvature * (1.0 + defaultWeight) + + # 3 corners + controlPoints = numpy.array([ + point0, + curvaturePoint, + point1, + weightPoint + ]) + return controlPoints + + def _createShapeItems(self, points): + shapePoints = self._getShapeFromControlPoints(points) + item = items.Shape("polygon") + item.setPoints(shapePoints) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + anchors = [] + symbols = ['o', 'o', 'o', 's'] + + for index, point in enumerate(points): + if index in [1, 3]: + constraint = self._arcCurvatureMarkerConstraint + else: + constraint = None + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol(symbols[index]) + anchor._setDraggable(True) + if constraint is not None: + anchor._setConstraint(constraint) + anchors.append(anchor) + + return anchors + + def _arcCurvatureMarkerConstraint(self, x, y): + """Curvature marker remains on "mediatrice" """ + start = self._points[0] + end = self._points[2] + midPoint = (start + end) / 2. + normal = (end - start) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + normal /= distance + v = numpy.dot(normal, (numpy.array((x, y)) - midPoint)) + x, y = midPoint + v * normal + return x, y + + @staticmethod + def _circleEquation(pt1, pt2, pt3): + """Circle equation from 3 (x, y) points + + :return: Position of the center of the circle and the radius + :rtype: Tuple[Tuple[float,float],float] + """ + x, y, z = complex(*pt1), complex(*pt2), complex(*pt3) + w = z - x + w /= y - x + c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x + return ((-c.real, -c.imag), abs(c + x)) + + def __str__(self): + try: + center, innerRadius, outerRadius, startAngle, endAngle = self.getGeometry() + params = center[0], center[1], innerRadius, outerRadius, startAngle, endAngle + params = 'center: %f %f; radius: %f %f; angles: %f %f' % params + except ValueError: + params = "invalid" + return "%s(%s)" % (self.__class__.__name__, params) diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py index 98ed473..72b8496 100644 --- a/silx/gui/plot/items/scatter.py +++ b/silx/gui/plot/items/scatter.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -42,6 +42,10 @@ _logger = logging.getLogger(__name__) class Scatter(Points, ColormapMixIn): """Description of a scatter""" + + _DEFAULT_SELECTABLE = True + """Default selectable state for scatter plots""" + _DEFAULT_SYMBOL = 'o' """Default symbol of the scatter plots""" |