summaryrefslogtreecommitdiff
path: root/silx/gui/plot/items
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r--silx/gui/plot/items/axis.py154
-rw-r--r--silx/gui/plot/items/complex.py8
-rw-r--r--silx/gui/plot/items/core.py38
-rw-r--r--silx/gui/plot/items/curve.py8
-rw-r--r--silx/gui/plot/items/histogram.py37
-rw-r--r--silx/gui/plot/items/roi.py1416
-rw-r--r--silx/gui/plot/items/scatter.py6
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"""