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/__init__.py3
-rw-r--r--silx/gui/plot/items/_arc_roi.py873
-rw-r--r--silx/gui/plot/items/_pick.py2
-rw-r--r--silx/gui/plot/items/_roi_base.py835
-rw-r--r--silx/gui/plot/items/complex.py15
-rw-r--r--silx/gui/plot/items/core.py189
-rw-r--r--silx/gui/plot/items/curve.py23
-rw-r--r--silx/gui/plot/items/histogram.py35
-rw-r--r--silx/gui/plot/items/image.py79
-rw-r--r--silx/gui/plot/items/roi.py1438
-rw-r--r--silx/gui/plot/items/scatter.py19
-rw-r--r--silx/gui/plot/items/shape.py35
12 files changed, 1979 insertions, 1567 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py
index 4d4eac0..0484025 100644
--- a/silx/gui/plot/items/__init__.py
+++ b/silx/gui/plot/items/__init__.py
@@ -32,7 +32,8 @@ __authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "22/06/2017"
-from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa
+from .core import (Item, DataItem, # noqa
+ LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa
SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa
AlphaMixIn, LineMixIn, ScatterVisualizationMixIn, # noqa
ComplexMixIn, ItemChangedType, PointsBase) # noqa
diff --git a/silx/gui/plot/items/_arc_roi.py b/silx/gui/plot/items/_arc_roi.py
new file mode 100644
index 0000000..a22cc3d
--- /dev/null
+++ b/silx/gui/plot/items/_arc_roi.py
@@ -0,0 +1,873 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 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 Arc ROI item for the :class:`~silx.gui.plot.PlotWidget`.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+import numpy
+
+from ... import utils
+from .. import items
+from ...colors import rgba
+from ....utils.proxy import docstring
+from ._roi_base import HandleBasedROI
+from ._roi_base import InteractionModeMixIn
+from ._roi_base import RoiInteractionMode
+
+
+class _ArcGeometry:
+ """
+ Non-mutable object to store the geometry of the arc ROI.
+
+ The aim is is to switch between consistent state without dealing with
+ intermediate values.
+ """
+ def __init__(self, center, startPoint, endPoint, radius,
+ weight, startAngle, endAngle, closed=False):
+ """Constructor for a consistent arc geometry.
+
+ There is also specific class method to create different kind of arc
+ geometry.
+ """
+ self.center = center
+ self.startPoint = startPoint
+ self.endPoint = endPoint
+ self.radius = radius
+ self.weight = weight
+ self.startAngle = startAngle
+ self.endAngle = endAngle
+ self._closed = closed
+
+ @classmethod
+ def createEmpty(cls):
+ """Create an arc geometry from an empty shape
+ """
+ zero = numpy.array([0, 0])
+ return cls(zero, zero.copy(), zero.copy(), 0, 0, 0, 0)
+
+ @classmethod
+ def createRect(cls, startPoint, endPoint, weight):
+ """Create an arc geometry from a definition of a rectangle
+ """
+ return cls(None, startPoint, endPoint, None, weight, None, None, False)
+
+ @classmethod
+ def createCircle(cls, center, startPoint, endPoint, radius,
+ weight, startAngle, endAngle):
+ """Create an arc geometry from a definition of a circle
+ """
+ return cls(center, startPoint, endPoint, radius,
+ weight, startAngle, endAngle, True)
+
+ def withWeight(self, weight):
+ """Return a new geometry based on this object, with a specific weight
+ """
+ return _ArcGeometry(self.center, self.startPoint, self.endPoint,
+ self.radius, weight,
+ self.startAngle, self.endAngle, self._closed)
+
+ def withRadius(self, radius):
+ """Return a new geometry based on this object, with a specific radius.
+
+ The weight and the center is conserved.
+ """
+ startPoint = self.center + (self.startPoint - self.center) / self.radius * radius
+ endPoint = self.center + (self.endPoint - self.center) / self.radius * radius
+ return _ArcGeometry(self.center, startPoint, endPoint,
+ radius, self.weight,
+ self.startAngle, self.endAngle, self._closed)
+
+ def withStartAngle(self, startAngle):
+ """Return a new geometry based on this object, with a specific start angle
+ """
+ vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
+ startPoint = self.center + vector * self.radius
+
+ # Never add more than 180 to maintain coherency
+ deltaAngle = startAngle - self.startAngle
+ if deltaAngle > numpy.pi:
+ deltaAngle -= numpy.pi * 2
+ elif deltaAngle < -numpy.pi:
+ deltaAngle += numpy.pi * 2
+
+ startAngle = self.startAngle + deltaAngle
+ return _ArcGeometry(
+ self.center,
+ startPoint,
+ self.endPoint,
+ self.radius,
+ self.weight,
+ startAngle,
+ self.endAngle,
+ self._closed,
+ )
+
+ def withEndAngle(self, endAngle):
+ """Return a new geometry based on this object, with a specific end angle
+ """
+ vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
+ endPoint = self.center + vector * self.radius
+
+ # Never add more than 180 to maintain coherency
+ deltaAngle = endAngle - self.endAngle
+ if deltaAngle > numpy.pi:
+ deltaAngle -= numpy.pi * 2
+ elif deltaAngle < -numpy.pi:
+ deltaAngle += numpy.pi * 2
+
+ endAngle = self.endAngle + deltaAngle
+ return _ArcGeometry(
+ self.center,
+ self.startPoint,
+ endPoint,
+ self.radius,
+ self.weight,
+ self.startAngle,
+ endAngle,
+ self._closed,
+ )
+
+ def translated(self, dx, dy):
+ """Return the translated geometry by dx, dy"""
+ delta = numpy.array([dx, dy])
+ center = None if self.center is None else self.center + delta
+ startPoint = None if self.startPoint is None else self.startPoint + delta
+ endPoint = None if self.endPoint is None else self.endPoint + delta
+ return _ArcGeometry(center, startPoint, endPoint,
+ self.radius, self.weight,
+ self.startAngle, self.endAngle, self._closed)
+
+ def getKind(self):
+ """Returns the kind of shape defined"""
+ if self.center is None:
+ return "rect"
+ elif numpy.isnan(self.startAngle):
+ return "point"
+ elif self.isClosed():
+ if self.weight <= 0 or self.weight * 0.5 >= self.radius:
+ return "circle"
+ else:
+ return "donut"
+ else:
+ if self.weight * 0.5 < self.radius:
+ return "arc"
+ else:
+ return "camembert"
+
+ def isClosed(self):
+ """Returns True if the geometry is a circle like"""
+ if self._closed is not None:
+ return self._closed
+ delta = numpy.abs(self.endAngle - self.startAngle)
+ self._closed = numpy.isclose(delta, numpy.pi * 2)
+ return self._closed
+
+ def __str__(self):
+ return str((self.center,
+ self.startPoint,
+ self.endPoint,
+ self.radius,
+ self.weight,
+ self.startAngle,
+ self.endAngle,
+ self._closed))
+
+
+class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn):
+ """A ROI identifying an arc of a circle with a width.
+
+ This ROI provides
+ - 3 handle to control the curvature
+ - 1 handle to control the weight
+ - 1 anchor to translate the shape.
+ """
+
+ ICON = 'add-shape-arc'
+ NAME = 'arc ROI'
+ SHORT_NAME = "arc"
+ """Metadata for this kind of ROI"""
+
+ _plotShape = "line"
+ """Plot shape which is used for the first interaction"""
+
+ ThreePointMode = RoiInteractionMode("3 points", "Provides 3 points to define the main radius circle")
+ PolarMode = RoiInteractionMode("Polar", "Provides anchors to edit the ROI in polar coords")
+ # FIXME: MoveMode was designed cause there is too much anchors
+ # FIXME: It would be good replace it by a dnd on the shape
+ MoveMode = RoiInteractionMode("Translation", "Provides anchors to only move the ROI")
+
+ def __init__(self, parent=None):
+ HandleBasedROI.__init__(self, parent=parent)
+ items.LineMixIn.__init__(self)
+ InteractionModeMixIn.__init__(self)
+
+ self._geometry = _ArcGeometry.createEmpty()
+ self._handleLabel = self.addLabelHandle()
+
+ self._handleStart = self.addHandle()
+ self._handleMid = self.addHandle()
+ self._handleEnd = self.addHandle()
+ self._handleWeight = self.addHandle()
+ self._handleWeight._setConstraint(self._arcCurvatureMarkerConstraint)
+ self._handleMove = self.addTranslateHandle()
+
+ shape = items.Shape("polygon")
+ shape.setPoints([[0, 0], [0, 0]])
+ shape.setColor(rgba(self.getColor()))
+ shape.setFill(False)
+ shape.setOverlay(True)
+ shape.setLineStyle(self.getLineStyle())
+ shape.setLineWidth(self.getLineWidth())
+ self.__shape = shape
+ self.addItem(shape)
+
+ self._initInteractionMode(self.ThreePointMode)
+ self._interactiveModeUpdated(self.ThreePointMode)
+
+ def availableInteractionModes(self):
+ """Returns the list of available interaction modes
+
+ :rtype: List[RoiInteractionMode]
+ """
+ return [self.ThreePointMode, self.PolarMode, self.MoveMode]
+
+ def _interactiveModeUpdated(self, modeId):
+ """Set the interaction mode.
+
+ :param RoiInteractionMode modeId:
+ """
+ if modeId is self.ThreePointMode:
+ self._handleStart.setSymbol("s")
+ self._handleMid.setSymbol("s")
+ self._handleEnd.setSymbol("s")
+ self._handleWeight.setSymbol("d")
+ self._handleMove.setSymbol("+")
+ elif modeId is self.PolarMode:
+ self._handleStart.setSymbol("o")
+ self._handleMid.setSymbol("o")
+ self._handleEnd.setSymbol("o")
+ self._handleWeight.setSymbol("d")
+ self._handleMove.setSymbol("+")
+ elif modeId is self.MoveMode:
+ self._handleStart.setSymbol("")
+ self._handleMid.setSymbol("+")
+ self._handleEnd.setSymbol("")
+ self._handleWeight.setSymbol("")
+ self._handleMove.setSymbol("+")
+ else:
+ assert False
+ if self._geometry.isClosed():
+ if modeId != self.MoveMode:
+ self._handleStart.setSymbol("x")
+ self._handleEnd.setSymbol("x")
+ self._updateHandles()
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.VISIBLE:
+ self._updateItemProperty(event, self, self.__shape)
+ super(ArcROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ super(ArcROI, self)._updatedStyle(event, style)
+ self.__shape.setColor(style.getColor())
+ self.__shape.setLineStyle(style.getLineStyle())
+ self.__shape.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ """"Initialize the ROI using the points from the first interaction.
+
+ This interaction is constrained by the plot API and only supports few
+ shapes.
+ """
+ # The first shape is a line
+ point0 = points[0]
+ point1 = points[1]
+
+ # Compute a non collinear point for the curvature
+ center = (point1 + point0) * 0.5
+ normal = point1 - center
+ normal = numpy.array((normal[1], -normal[0]))
+ defaultCurvature = numpy.pi / 5.0
+ weightCoef = 0.20
+ mid = center - normal * defaultCurvature
+ distance = numpy.linalg.norm(point0 - point1)
+ weight = distance * weightCoef
+
+ geometry = self._createGeometryFromControlPoints(point0, mid, point1, weight)
+ self._geometry = geometry
+ self._updateHandles()
+
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
+
+ def _updateMidHandle(self):
+ """Keep the same geometry, but update the location of the control
+ points.
+
+ So calling this function do not trigger sigRegionChanged.
+ """
+ geometry = self._geometry
+
+ if geometry.isClosed():
+ start = numpy.array(self._handleStart.getPosition())
+ midPos = geometry.center + geometry.center - start
+ else:
+ if geometry.center is None:
+ midPos = geometry.startPoint * 0.5 + geometry.endPoint * 0.5
+ else:
+ midAngle = geometry.startAngle * 0.5 + geometry.endAngle * 0.5
+ vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
+ midPos = geometry.center + geometry.radius * vector
+
+ with utils.blockSignals(self._handleMid):
+ self._handleMid.setPosition(*midPos)
+
+ def _updateWeightHandle(self):
+ geometry = self._geometry
+ if geometry.center is None:
+ # rectangle
+ center = (geometry.startPoint + geometry.endPoint) * 0.5
+ normal = geometry.endPoint - geometry.startPoint
+ normal = numpy.array((normal[1], -normal[0]))
+ distance = numpy.linalg.norm(normal)
+ if distance != 0:
+ normal = normal / distance
+ weightPos = center + normal * geometry.weight * 0.5
+ else:
+ if geometry.isClosed():
+ midAngle = geometry.startAngle + numpy.pi * 0.5
+ elif geometry.center is not None:
+ midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
+ vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
+ weightPos = geometry.center + (geometry.radius + geometry.weight * 0.5) * vector
+
+ with utils.blockSignals(self._handleWeight):
+ self._handleWeight.setPosition(*weightPos)
+
+ def _getWeightFromHandle(self, weightPos):
+ geometry = self._geometry
+ if geometry.center is None:
+ # rectangle
+ center = (geometry.startPoint + geometry.endPoint) * 0.5
+ return numpy.linalg.norm(center - weightPos) * 2
+ else:
+ distance = numpy.linalg.norm(geometry.center - weightPos)
+ return abs(distance - geometry.radius) * 2
+
+ def _updateHandles(self):
+ geometry = self._geometry
+ with utils.blockSignals(self._handleStart):
+ self._handleStart.setPosition(*geometry.startPoint)
+ with utils.blockSignals(self._handleEnd):
+ self._handleEnd.setPosition(*geometry.endPoint)
+
+ self._updateMidHandle()
+ self._updateWeightHandle()
+ self._updateShape()
+
+ def _updateCurvature(self, start, mid, end, updateCurveHandles, checkClosed=False, updateStart=False):
+ """Update the curvature using 3 control points in the curve
+
+ :param bool updateCurveHandles: If False curve handles are already at
+ the right location
+ """
+ if checkClosed:
+ closed = self._isCloseInPixel(start, end)
+ else:
+ closed = self._geometry.isClosed()
+ if closed:
+ if updateStart:
+ start = end
+ else:
+ end = start
+
+ if updateCurveHandles:
+ with utils.blockSignals(self._handleStart):
+ self._handleStart.setPosition(*start)
+ with utils.blockSignals(self._handleMid):
+ self._handleMid.setPosition(*mid)
+ with utils.blockSignals(self._handleEnd):
+ self._handleEnd.setPosition(*end)
+
+ weight = self._geometry.weight
+ geometry = self._createGeometryFromControlPoints(start, mid, end, weight, closed=closed)
+ self._geometry = geometry
+
+ self._updateWeightHandle()
+ self._updateShape()
+
+ def _updateCloseInAngle(self, geometry, updateStart):
+ azim = numpy.abs(geometry.endAngle - geometry.startAngle)
+ if numpy.pi < azim < 3 * numpy.pi:
+ closed = self._isCloseInPixel(geometry.startPoint, geometry.endPoint)
+ geometry._closed = closed
+ if closed:
+ sign = 1 if geometry.startAngle < geometry.endAngle else -1
+ if updateStart:
+ geometry.startPoint = geometry.endPoint
+ geometry.startAngle = geometry.endAngle - sign * 2*numpy.pi
+ else:
+ geometry.endPoint = geometry.startPoint
+ geometry.endAngle = geometry.startAngle + sign * 2*numpy.pi
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ modeId = self.getInteractionMode()
+ if handle is self._handleStart:
+ if modeId is self.ThreePointMode:
+ mid = numpy.array(self._handleMid.getPosition())
+ end = numpy.array(self._handleEnd.getPosition())
+ self._updateCurvature(
+ current, mid, end, checkClosed=True, updateStart=True,
+ updateCurveHandles=False
+ )
+ elif modeId is self.PolarMode:
+ v = current - self._geometry.center
+ startAngle = numpy.angle(complex(v[0], v[1]))
+ geometry = self._geometry.withStartAngle(startAngle)
+ self._updateCloseInAngle(geometry, updateStart=True)
+ self._geometry = geometry
+ self._updateHandles()
+ elif handle is self._handleMid:
+ if modeId is self.ThreePointMode:
+ if self._geometry.isClosed():
+ radius = numpy.linalg.norm(self._geometry.center - current)
+ self._geometry = self._geometry.withRadius(radius)
+ self._updateHandles()
+ else:
+ start = numpy.array(self._handleStart.getPosition())
+ end = numpy.array(self._handleEnd.getPosition())
+ self._updateCurvature(start, current, end, updateCurveHandles=False)
+ elif modeId is self.PolarMode:
+ radius = numpy.linalg.norm(self._geometry.center - current)
+ self._geometry = self._geometry.withRadius(radius)
+ self._updateHandles()
+ elif modeId is self.MoveMode:
+ delta = current - previous
+ self.translate(*delta)
+ elif handle is self._handleEnd:
+ if modeId is self.ThreePointMode:
+ start = numpy.array(self._handleStart.getPosition())
+ mid = numpy.array(self._handleMid.getPosition())
+ self._updateCurvature(
+ start, mid, current, checkClosed=True, updateStart=False,
+ updateCurveHandles=False
+ )
+ elif modeId is self.PolarMode:
+ v = current - self._geometry.center
+ endAngle = numpy.angle(complex(v[0], v[1]))
+ geometry = self._geometry.withEndAngle(endAngle)
+ self._updateCloseInAngle(geometry, updateStart=False)
+ self._geometry = geometry
+ self._updateHandles()
+ elif handle is self._handleWeight:
+ weight = self._getWeightFromHandle(current)
+ self._geometry = self._geometry.withWeight(weight)
+ self._updateShape()
+ elif handle is self._handleMove:
+ delta = current - previous
+ self.translate(*delta)
+
+ def _isCloseInPixel(self, point1, point2):
+ manager = self.parent()
+ if manager is None:
+ return False
+ plot = manager.parent()
+ if plot is None:
+ return False
+ point1 = plot.dataToPixel(*point1)
+ if point1 is None:
+ return False
+ point2 = plot.dataToPixel(*point2)
+ if point2 is None:
+ return False
+ return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1]) < 15
+
+ def _normalizeGeometry(self):
+ """Keep the same phisical geometry, but with normalized parameters.
+ """
+ geometry = self._geometry
+ if geometry.weight * 0.5 >= geometry.radius:
+ radius = (geometry.weight * 0.5 + geometry.radius) * 0.5
+ geometry = geometry.withRadius(radius)
+ geometry = geometry.withWeight(radius * 2)
+ self._geometry = geometry
+ return True
+ return False
+
+ def handleDragFinished(self, handle, origin, current):
+ modeId = self.getInteractionMode()
+ if handle in [self._handleStart, self._handleMid, self._handleEnd]:
+ if modeId is self.ThreePointMode:
+ self._normalizeGeometry()
+ self._updateHandles()
+
+ if self._geometry.isClosed():
+ if modeId is self.MoveMode:
+ self._handleStart.setSymbol("")
+ self._handleEnd.setSymbol("")
+ else:
+ self._handleStart.setSymbol("x")
+ self._handleEnd.setSymbol("x")
+ else:
+ if modeId is self.ThreePointMode:
+ self._handleStart.setSymbol("s")
+ self._handleEnd.setSymbol("s")
+ elif modeId is self.PolarMode:
+ self._handleStart.setSymbol("o")
+ self._handleEnd.setSymbol("o")
+ if modeId is self.MoveMode:
+ self._handleStart.setSymbol("")
+ self._handleEnd.setSymbol("")
+
+ def _createGeometryFromControlPoints(self, start, mid, end, weight, closed=None):
+ """Returns the geometry of the object"""
+ if closed or (closed is None and numpy.allclose(start, end)):
+ # Special arc: It's a closed circle
+ center = (start + mid) * 0.5
+ radius = numpy.linalg.norm(start - center)
+ v = start - center
+ startAngle = numpy.angle(complex(v[0], v[1]))
+ endAngle = startAngle + numpy.pi * 2.0
+ return _ArcGeometry.createCircle(
+ center, start, end, radius, weight, startAngle, endAngle
+ )
+
+ elif numpy.linalg.norm(numpy.cross(mid - start, end - start)) < 1e-5:
+ # Degenerated arc, it's a rectangle
+ return _ArcGeometry.createRect(start, end, weight)
+ else:
+ center, radius = self._circleEquation(start, mid, end)
+ v = start - center
+ startAngle = numpy.angle(complex(v[0], v[1]))
+ v = mid - center
+ midAngle = numpy.angle(complex(v[0], v[1]))
+ v = end - center
+ endAngle = numpy.angle(complex(v[0], v[1]))
+
+ # Is it clockwise or anticlockwise
+ relativeMid = (endAngle - midAngle + 2 * numpy.pi) % (2 * numpy.pi)
+ relativeEnd = (endAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi)
+ if relativeMid < relativeEnd:
+ if endAngle < startAngle:
+ endAngle += 2 * numpy.pi
+ else:
+ if endAngle > startAngle:
+ endAngle -= 2 * numpy.pi
+
+ return _ArcGeometry(center, start, end,
+ radius, weight, startAngle, endAngle)
+
+ def _createShapeFromGeometry(self, geometry):
+ kind = geometry.getKind()
+ if kind == "rect":
+ # It is not an arc
+ # but we can display it as an intermediate 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])
+ elif kind == "point":
+ # It is not an arc
+ # but we can display it as an intermediate shape
+ # NOTE: At least 2 points are expected
+ points = numpy.array([geometry.startPoint, geometry.startPoint])
+ elif kind == "circle":
+ outerRadius = geometry.radius + geometry.weight * 0.5
+ angles = numpy.linspace(0, 2 * numpy.pi, num=50)
+ # 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)
+ points = numpy.array(points)
+ elif kind == "donut":
+ innerRadius = geometry.radius - geometry.weight * 0.5
+ outerRadius = geometry.radius + geometry.weight * 0.5
+ angles = numpy.linspace(0, 2 * numpy.pi, num=50)
+ # 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")]))
+ points = numpy.array(points)
+ else:
+ innerRadius = geometry.radius - geometry.weight * 0.5
+ outerRadius = geometry.radius + geometry.weight * 0.5
+
+ 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)
+
+ if kind == "camembert":
+ # 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)
+ elif kind == "arc":
+ # 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)
+ else:
+ assert False
+
+ points = numpy.array(points)
+
+ return points
+
+ def _updateShape(self):
+ geometry = self._geometry
+ points = self._createShapeFromGeometry(geometry)
+ self.__shape.setPoints(points)
+
+ index = numpy.nanargmin(points[:, 1])
+ pos = points[index]
+ with utils.blockSignals(self._handleLabel):
+ self._handleLabel.setPosition(pos[0], pos[1])
+
+ if geometry.center is None:
+ movePos = geometry.startPoint * 0.34 + geometry.endPoint * 0.66
+ else:
+ movePos = geometry.center
+
+ with utils.blockSignals(self._handleMove):
+ self._handleMove.setPosition(*movePos)
+
+ self.sigRegionChanged.emit()
+
+ def getGeometry(self):
+ """Returns a tuple containing the geometry of this ROI
+
+ It is a symmetric function 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 represented as section of
+ a circle
+ """
+ geometry = self._geometry
+ 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
+ """
+ return self._geometry.isClosed()
+
+ 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
+ """
+ return self._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
+ """
+ return self._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
+ """
+ return self._geometry.endAngle
+
+ def getInnerRadius(self):
+ """Returns the radius of the smaller arc used to draw this ROI.
+
+ :rtype: float
+ """
+ geometry = self._geometry
+ 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._geometry
+ 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
+
+ vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
+ startPoint = center + vector * radius
+ vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
+ endPoint = center + vector * radius
+
+ geometry = _ArcGeometry(center, startPoint, endPoint,
+ radius, weight,
+ startAngle, endAngle, closed=None)
+ self._geometry = geometry
+ self._updateHandles()
+
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ # first check distance, fastest
+ center = self.getCenter()
+ distance = numpy.sqrt((position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2)
+ is_in_distance = self.getInnerRadius() <= distance <= self.getOuterRadius()
+ if not is_in_distance:
+ return False
+ rel_pos = position[1] - center[1], position[0] - center[0]
+ angle = numpy.arctan2(*rel_pos)
+ # angle is inside [-pi, pi]
+
+ # Normalize the start angle between [-pi, pi]
+ # with a positive angle range
+ start_angle = self.getStartAngle()
+ end_angle = self.getEndAngle()
+ azim_range = end_angle - start_angle
+ if azim_range < 0:
+ start_angle = end_angle
+ azim_range = -azim_range
+ start_angle = numpy.mod(start_angle + numpy.pi, 2 * numpy.pi) - numpy.pi
+
+ if angle < start_angle:
+ angle += 2 * numpy.pi
+ return start_angle <= angle <= start_angle + azim_range
+
+ def translate(self, x, y):
+ self._geometry = self._geometry.translated(x, y)
+ self._updateHandles()
+
+ def _arcCurvatureMarkerConstraint(self, x, y):
+ """Curvature marker remains on perpendicular bisector"""
+ geometry = self._geometry
+ if geometry.center is None:
+ center = (geometry.startPoint + geometry.endPoint) * 0.5
+ vector = geometry.startPoint - geometry.endPoint
+ vector = numpy.array((vector[1], -vector[0]))
+ vdist = numpy.linalg.norm(vector)
+ if vdist != 0:
+ normal = numpy.array((vector[1], -vector[0])) / vdist
+ else:
+ normal = numpy.array((0, 0))
+ else:
+ if geometry.isClosed():
+ midAngle = geometry.startAngle + numpy.pi * 0.5
+ else:
+ midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
+ normal = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
+ center = geometry.center
+ dist = numpy.dot(normal, (numpy.array((x, y)) - center))
+ dist = numpy.clip(dist, geometry.radius, geometry.radius * 2)
+ x, y = center + dist * 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 numpy.array((-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/_pick.py b/silx/gui/plot/items/_pick.py
index 4ddf4f6..8c8e781 100644
--- a/silx/gui/plot/items/_pick.py
+++ b/silx/gui/plot/items/_pick.py
@@ -48,7 +48,7 @@ class PickingResult(object):
self._indices = None
else:
# Indices is set to None if indices array is empty
- indices = numpy.array(indices, copy=False, dtype=numpy.int)
+ indices = numpy.array(indices, copy=False, dtype=numpy.int64)
self._indices = None if indices.size == 0 else indices
def getItem(self):
diff --git a/silx/gui/plot/items/_roi_base.py b/silx/gui/plot/items/_roi_base.py
new file mode 100644
index 0000000..3eb6cf4
--- /dev/null
+++ b/silx/gui/plot/items/_roi_base.py
@@ -0,0 +1,835 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 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 base components to create ROI item for
+the :class:`~silx.gui.plot.PlotWidget`.
+
+.. inheritance-diagram::
+ silx.gui.plot.items.roi
+ :parts: 1
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import logging
+import numpy
+import weakref
+
+from ....utils.weakref import WeakList
+from ... import qt
+from .. import items
+from ..items import core
+from ...colors import rgba
+import silx.utils.deprecation
+from ....utils.proxy import docstring
+
+
+logger = logging.getLogger(__name__)
+
+
+class _RegionOfInterestBase(qt.QObject):
+ """Base class of 1D and 2D region of interest
+
+ :param QObject parent: See QObject
+ :param str name: The name of the ROI
+ """
+
+ sigAboutToBeRemoved = qt.Signal()
+ """Signal emitted just before this ROI is removed from its manager."""
+
+ sigItemChanged = qt.Signal(object)
+ """Signal emitted when item has changed.
+
+ It provides a flag describing which property of the item has changed.
+ See :class:`ItemChangedType` for flags description.
+ """
+
+ def __init__(self, parent=None):
+ qt.QObject.__init__(self, parent=parent)
+ self.__name = ''
+
+ def getName(self):
+ """Returns the name of the ROI
+
+ :return: name of the region of interest
+ :rtype: str
+ """
+ return self.__name
+
+ def setName(self, name):
+ """Set the name of the ROI
+
+ :param str name: name of the region of interest
+ """
+ name = str(name)
+ if self.__name != name:
+ self.__name = name
+ self._updated(items.ItemChangedType.NAME)
+
+ def _updated(self, event=None, checkVisibility=True):
+ """Implement Item mix-in update method by updating the plot items
+
+ See :class:`~silx.gui.plot.items.Item._updated`
+ """
+ self.sigItemChanged.emit(event)
+
+ def contains(self, position):
+ """Returns True if the `position` is in this ROI.
+
+ :param tuple[float,float] position: position to check
+ :return: True if the value / point is consider to be in the region of
+ interest.
+ :rtype: bool
+ """
+ return False # Override in subclass to perform actual test
+
+
+class RoiInteractionMode(object):
+ """Description of an interaction mode.
+
+ An interaction mode provide a specific kind of interaction for a ROI.
+ A ROI can implement many interaction.
+ """
+
+ def __init__(self, label, description=None):
+ self._label = label
+ self._description = description
+
+ @property
+ def label(self):
+ return self._label
+
+ @property
+ def description(self):
+ return self._description
+
+
+class InteractionModeMixIn(object):
+ """Mix in feature which can be implemented by a ROI object.
+
+ This provides user interaction to switch between different
+ interaction mode to edit the ROI.
+
+ This ROI modes have to be described using `RoiInteractionMode`,
+ and taken into account during interation with handles.
+ """
+
+ sigInteractionModeChanged = qt.Signal(object)
+
+ def __init__(self):
+ self.__modeId = None
+
+ def _initInteractionMode(self, modeId):
+ """Set the mode without updating anything.
+
+ Must be one of the returned :meth:`availableInteractionModes`.
+
+ :param RoiInteractionMode modeId: Mode to use
+ """
+ self.__modeId = modeId
+
+ def availableInteractionModes(self):
+ """Returns the list of available interaction modes
+
+ Must be implemented when inherited to provide all available modes.
+
+ :rtype: List[RoiInteractionMode]
+ """
+ raise NotImplementedError()
+
+ def setInteractionMode(self, modeId):
+ """Set the interaction mode.
+
+ :param RoiInteractionMode modeId: Mode to use
+ """
+ self.__modeId = modeId
+ self._interactiveModeUpdated(modeId)
+ self.sigInteractionModeChanged.emit(modeId)
+
+ def _interactiveModeUpdated(self, modeId):
+ """Called directly after an update of the mode.
+
+ The signal `sigInteractionModeChanged` is triggered after this
+ call.
+
+ Must be implemented when inherited to take care of the change.
+ """
+ raise NotImplementedError()
+
+ def getInteractionMode(self):
+ """Returns the interaction mode.
+
+ Must be one of the returned :meth:`availableInteractionModes`.
+
+ :rtype: RoiInteractionMode
+ """
+ return self.__modeId
+
+
+class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn):
+ """Object describing a region of interest in a plot.
+
+ :param QObject parent:
+ The RegionOfInterestManager that created this object
+ """
+
+ _DEFAULT_LINEWIDTH = 1.
+ """Default line width of the curve"""
+
+ _DEFAULT_LINESTYLE = '-'
+ """Default line style of the curve"""
+
+ _DEFAULT_HIGHLIGHT_STYLE = items.CurveStyle(linewidth=2)
+ """Default highlight style of the item"""
+
+ ICON, NAME, SHORT_NAME = None, None, None
+ """Metadata to describe the ROI in labels, tooltips and widgets
+
+ Should be set by inherited classes to custom the ROI manager widget.
+ """
+
+ sigRegionChanged = qt.Signal()
+ """Signal emitted everytime the shape or position of the ROI changes"""
+
+ sigEditingStarted = qt.Signal()
+ """Signal emitted when the user start editing the roi"""
+
+ sigEditingFinished = qt.Signal()
+ """Signal emitted when the region edition is finished. During edition
+ sigEditionChanged will be emitted several times and
+ sigRegionEditionFinished only at end"""
+
+ def __init__(self, parent=None):
+ # Avoid circular dependency
+ from ..tools import roi as roi_tools
+ assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager)
+ _RegionOfInterestBase.__init__(self, parent)
+ core.HighlightedMixIn.__init__(self)
+ self._color = rgba('red')
+ self._editable = False
+ self._selectable = False
+ self._focusProxy = None
+ self._visible = True
+ self._child = WeakList()
+
+ def _connectToPlot(self, plot):
+ """Called after connection to a plot"""
+ for item in self.getItems():
+ # This hack is needed to avoid reentrant call from _disconnectFromPlot
+ # to the ROI manager. It also speed up the item tests in _itemRemoved
+ item._roiGroup = True
+ plot.addItem(item)
+
+ def _disconnectFromPlot(self, plot):
+ """Called before disconnection from a plot"""
+ for item in self.getItems():
+ # The item could be already be removed by the plot
+ if item.getPlot() is not None:
+ del item._roiGroup
+ plot.removeItem(item)
+
+ def _setItemName(self, item):
+ """Helper to generate a unique id to a plot item"""
+ legend = "__ROI-%d__%d" % (id(self), id(item))
+ item.setName(legend)
+
+ def setParent(self, parent):
+ """Set the parent of the RegionOfInterest
+
+ :param Union[None,RegionOfInterestManager] parent: The new parent
+ """
+ # Avoid circular dependency
+ from ..tools import roi as roi_tools
+ if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)):
+ raise ValueError('Unsupported parent')
+
+ previousParent = self.parent()
+ if previousParent is not None:
+ previousPlot = previousParent.parent()
+ if previousPlot is not None:
+ self._disconnectFromPlot(previousPlot)
+ super(RegionOfInterest, self).setParent(parent)
+ if parent is not None:
+ plot = parent.parent()
+ if plot is not None:
+ self._connectToPlot(plot)
+
+ def addItem(self, item):
+ """Add an item to the set of this ROI children.
+
+ This item will be added and removed to the plot used by the ROI.
+
+ If the ROI is already part of a plot, the item will also be added to
+ the plot.
+
+ It the item do not have a name already, a unique one is generated to
+ avoid item collision in the plot.
+
+ :param silx.gui.plot.items.Item item: A plot item
+ """
+ assert item is not None
+ self._child.append(item)
+ if item.getName() == '':
+ self._setItemName(item)
+ manager = self.parent()
+ if manager is not None:
+ plot = manager.parent()
+ if plot is not None:
+ item._roiGroup = True
+ plot.addItem(item)
+
+ def removeItem(self, item):
+ """Remove an item from this ROI children.
+
+ If the item is part of a plot it will be removed too.
+
+ :param silx.gui.plot.items.Item item: A plot item
+ """
+ assert item is not None
+ self._child.remove(item)
+ plot = item.getPlot()
+ if plot is not None:
+ del item._roiGroup
+ plot.removeItem(item)
+
+ def getItems(self):
+ """Returns the list of PlotWidget items of this RegionOfInterest.
+
+ :rtype: List[~silx.gui.plot.items.Item]
+ """
+ return tuple(self._child)
+
+ @classmethod
+ def _getShortName(cls):
+ """Return an human readable kind of ROI
+
+ :rtype: str
+ """
+ if hasattr(cls, "SHORT_NAME"):
+ name = cls.SHORT_NAME
+ if name is None:
+ name = cls.__name__
+ return name
+
+ def getColor(self):
+ """Returns the color of this ROI
+
+ :rtype: QColor
+ """
+ return qt.QColor.fromRgbF(*self._color)
+
+ 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
+ self._updated(items.ItemChangedType.COLOR)
+
+ @silx.utils.deprecation.deprecated(reason='API modification',
+ replacement='getName()',
+ since_version=0.12)
+ def getLabel(self):
+ """Returns the label displayed for this ROI.
+
+ :rtype: str
+ """
+ return self.getName()
+
+ @silx.utils.deprecation.deprecated(reason='API modification',
+ replacement='setName(name)',
+ since_version=0.12)
+ def setLabel(self, label):
+ """Set the label displayed with this ROI.
+
+ :param str label: The text label to display
+ """
+ self.setName(name=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
+ self._updated(items.ItemChangedType.EDITABLE)
+
+ def isSelectable(self):
+ """Returns whether the ROI is selectable by the user or not.
+
+ :rtype: bool
+ """
+ return self._selectable
+
+ def setSelectable(self, selectable):
+ """Set whether the ROI can be selected interactively.
+
+ :param bool selectable: True to allow selection by the user,
+ False to disable.
+ """
+ selectable = bool(selectable)
+ if self._selectable != selectable:
+ self._selectable = selectable
+ self._updated(items.ItemChangedType.SELECTABLE)
+
+ def getFocusProxy(self):
+ """Returns the ROI which have to be selected when this ROI is selected,
+ else None if no proxy specified.
+
+ :rtype: RegionOfInterest
+ """
+ proxy = self._focusProxy
+ if proxy is None:
+ return None
+ proxy = proxy()
+ if proxy is None:
+ self._focusProxy = None
+ return proxy
+
+ def setFocusProxy(self, roi):
+ """Set the real ROI which will be selected when this ROI is selected,
+ else None to remove the proxy already specified.
+
+ :param RegionOfInterest roi: A ROI
+ """
+ if roi is not None:
+ self._focusProxy = weakref.ref(roi)
+ else:
+ self._focusProxy = None
+
+ def isVisible(self):
+ """Returns whether the ROI is visible in the plot.
+
+ .. note::
+ This does not take into account whether or not the plot
+ widget itself is visible (unlike :meth:`QWidget.isVisible` which
+ checks the visibility of all its parent widgets up to the window)
+
+ :rtype: bool
+ """
+ return self._visible
+
+ def setVisible(self, visible):
+ """Set whether the plot items associated with this ROI are
+ visible in the plot.
+
+ :param bool visible: True to show the ROI in the plot, False to
+ hide it.
+ """
+ visible = bool(visible)
+ if self._visible != visible:
+ self._visible = visible
+ self._updated(items.ItemChangedType.VISIBLE)
+
+ @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 False
+
+ @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 constrained by the plot API and only supports few
+ shapes.
+ """
+ raise NotImplementedError()
+
+ def creationStarted(self):
+ """"Called when the ROI creation interaction was started.
+ """
+ pass
+
+ def creationFinalized(self):
+ """"Called when the ROI creation interaction was finalized.
+ """
+ pass
+
+ def _updateItemProperty(self, event, source, destination):
+ """Update the item property of a destination from an item source.
+
+ :param items.ItemChangedType event: Property type to update
+ :param silx.gui.plot.items.Item source: The reference for the data
+ :param event Union[Item,List[Item]] destination: The item(s) to update
+ """
+ if not isinstance(destination, (list, tuple)):
+ destination = [destination]
+ if event == items.ItemChangedType.NAME:
+ value = source.getName()
+ for d in destination:
+ d.setName(value)
+ elif event == items.ItemChangedType.EDITABLE:
+ value = source.isEditable()
+ for d in destination:
+ d.setEditable(value)
+ elif event == items.ItemChangedType.SELECTABLE:
+ value = source.isSelectable()
+ for d in destination:
+ d._setSelectable(value)
+ elif event == items.ItemChangedType.COLOR:
+ value = rgba(source.getColor())
+ for d in destination:
+ d.setColor(value)
+ elif event == items.ItemChangedType.LINE_STYLE:
+ value = self.getLineStyle()
+ for d in destination:
+ d.setLineStyle(value)
+ elif event == items.ItemChangedType.LINE_WIDTH:
+ value = self.getLineWidth()
+ for d in destination:
+ d.setLineWidth(value)
+ elif event == items.ItemChangedType.SYMBOL:
+ value = self.getSymbol()
+ for d in destination:
+ d.setSymbol(value)
+ elif event == items.ItemChangedType.SYMBOL_SIZE:
+ value = self.getSymbolSize()
+ for d in destination:
+ d.setSymbolSize(value)
+ elif event == items.ItemChangedType.VISIBLE:
+ value = self.isVisible()
+ for d in destination:
+ d.setVisible(value)
+ else:
+ assert False
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.HIGHLIGHTED:
+ style = self.getCurrentStyle()
+ self._updatedStyle(event, style)
+ else:
+ styleEvents = [items.ItemChangedType.COLOR,
+ items.ItemChangedType.LINE_STYLE,
+ items.ItemChangedType.LINE_WIDTH,
+ items.ItemChangedType.SYMBOL,
+ items.ItemChangedType.SYMBOL_SIZE]
+ if self.isHighlighted():
+ styleEvents.append(items.ItemChangedType.HIGHLIGHTED_STYLE)
+
+ if event in styleEvents:
+ style = self.getCurrentStyle()
+ self._updatedStyle(event, style)
+
+ super(RegionOfInterest, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ """Called when the current displayed style of the ROI was changed.
+
+ :param event: The event responsible of the change of the style
+ :param items.CurveStyle style: The current style
+ """
+ pass
+
+ def getCurrentStyle(self):
+ """Returns the current curve style.
+
+ Curve style depends on curve highlighting
+
+ :rtype: CurveStyle
+ """
+ baseColor = rgba(self.getColor())
+ if isinstance(self, core.LineMixIn):
+ baseLinestyle = self.getLineStyle()
+ baseLinewidth = self.getLineWidth()
+ else:
+ baseLinestyle = self._DEFAULT_LINESTYLE
+ baseLinewidth = self._DEFAULT_LINEWIDTH
+ if isinstance(self, core.SymbolMixIn):
+ baseSymbol = self.getSymbol()
+ baseSymbolsize = self.getSymbolSize()
+ else:
+ baseSymbol = 'o'
+ baseSymbolsize = 1
+
+ if self.isHighlighted():
+ style = self.getHighlightedStyle()
+ color = style.getColor()
+ linestyle = style.getLineStyle()
+ linewidth = style.getLineWidth()
+ symbol = style.getSymbol()
+ symbolsize = style.getSymbolSize()
+
+ return items.CurveStyle(
+ color=baseColor if color is None else color,
+ linestyle=baseLinestyle if linestyle is None else linestyle,
+ linewidth=baseLinewidth if linewidth is None else linewidth,
+ symbol=baseSymbol if symbol is None else symbol,
+ symbolsize=baseSymbolsize if symbolsize is None else symbolsize)
+ else:
+ return items.CurveStyle(color=baseColor,
+ linestyle=baseLinestyle,
+ linewidth=baseLinewidth,
+ symbol=baseSymbol,
+ symbolsize=baseSymbolsize)
+
+ def _editingStarted(self):
+ assert self._editable is True
+ self.sigEditingStarted.emit()
+
+ def _editingFinished(self):
+ self.sigEditingFinished.emit()
+
+
+class HandleBasedROI(RegionOfInterest):
+ """Manage a ROI based on a set of handles"""
+
+ def __init__(self, parent=None):
+ RegionOfInterest.__init__(self, parent=parent)
+ self._handles = []
+ self._posOrigin = None
+ self._posPrevious = None
+
+ def addUserHandle(self, item=None):
+ """
+ Add a new free handle to the ROI.
+
+ This handle do nothing. It have to be managed by the ROI
+ implementing this class.
+
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
+ """
+ return self.addHandle(item, role="user")
+
+ def addLabelHandle(self, item=None):
+ """
+ Add a new label handle to the ROI.
+
+ This handle is not draggable nor selectable.
+
+ It is displayed without symbol, but it is always visible anyway
+ the ROI is editable, in order to display text.
+
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
+ """
+ return self.addHandle(item, role="label")
+
+ def addTranslateHandle(self, item=None):
+ """
+ Add a new translate handle to the ROI.
+
+ Dragging translate handles affect the position position of the ROI
+ but not the shape itself.
+
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
+ """
+ return self.addHandle(item, role="translate")
+
+ def addHandle(self, item=None, role="default"):
+ """
+ Add a new handle to the ROI.
+
+ Dragging handles while affect the position or the shape of the
+ ROI.
+
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
+ """
+ if item is None:
+ item = items.Marker()
+ color = rgba(self.getColor())
+ color = self._computeHandleColor(color)
+ item.setColor(color)
+ if role == "default":
+ item.setSymbol("s")
+ elif role == "user":
+ pass
+ elif role == "translate":
+ item.setSymbol("+")
+ elif role == "label":
+ item.setSymbol("")
+
+ if role == "user":
+ pass
+ elif role == "label":
+ item._setSelectable(False)
+ item._setDraggable(False)
+ item.setVisible(True)
+ else:
+ self.__updateEditable(item, self.isEditable(), remove=False)
+ item._setSelectable(False)
+
+ self._handles.append((item, role))
+ self.addItem(item)
+ return item
+
+ def removeHandle(self, handle):
+ data = [d for d in self._handles if d[0] is handle][0]
+ self._handles.remove(data)
+ role = data[1]
+ if role not in ["user", "label"]:
+ if self.isEditable():
+ self.__updateEditable(handle, False)
+ self.removeItem(handle)
+
+ def getHandles(self):
+ """Returns the list of handles of this HandleBasedROI.
+
+ :rtype: List[~silx.gui.plot.items.Marker]
+ """
+ return tuple(data[0] for data in self._handles)
+
+ def _updated(self, event=None, checkVisibility=True):
+ """Implement Item mix-in update method by updating the plot items
+
+ See :class:`~silx.gui.plot.items.Item._updated`
+ """
+ if event == items.ItemChangedType.NAME:
+ self._updateText(self.getName())
+ elif event == items.ItemChangedType.VISIBLE:
+ for item, role in self._handles:
+ visible = self.isVisible()
+ editionVisible = visible and self.isEditable()
+ if role not in ["user", "label"]:
+ item.setVisible(editionVisible)
+ else:
+ item.setVisible(visible)
+ elif event == items.ItemChangedType.EDITABLE:
+ for item, role in self._handles:
+ editable = self.isEditable()
+ if role not in ["user", "label"]:
+ self.__updateEditable(item, editable)
+ super(HandleBasedROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ super(HandleBasedROI, self)._updatedStyle(event, style)
+
+ # Update color of shape items in the plot
+ color = rgba(self.getColor())
+ handleColor = self._computeHandleColor(color)
+ for item, role in self._handles:
+ if role == 'user':
+ pass
+ elif role == 'label':
+ item.setColor(color)
+ else:
+ item.setColor(handleColor)
+
+ def __updateEditable(self, handle, editable, remove=True):
+ # NOTE: visibility change emit a position update event
+ handle.setVisible(editable and self.isVisible())
+ handle._setDraggable(editable)
+ if editable:
+ handle.sigDragStarted.connect(self._handleEditingStarted)
+ handle.sigItemChanged.connect(self._handleEditingUpdated)
+ handle.sigDragFinished.connect(self._handleEditingFinished)
+ else:
+ if remove:
+ handle.sigDragStarted.disconnect(self._handleEditingStarted)
+ handle.sigItemChanged.disconnect(self._handleEditingUpdated)
+ handle.sigDragFinished.disconnect(self._handleEditingFinished)
+
+ def _handleEditingStarted(self):
+ super(HandleBasedROI, self)._editingStarted()
+ handle = self.sender()
+ self._posOrigin = numpy.array(handle.getPosition())
+ self._posPrevious = numpy.array(self._posOrigin)
+ self.handleDragStarted(handle, self._posOrigin)
+
+ def _handleEditingUpdated(self):
+ if self._posOrigin is None:
+ # Avoid to handle events when visibility change
+ return
+ handle = self.sender()
+ current = numpy.array(handle.getPosition())
+ self.handleDragUpdated(handle, self._posOrigin, self._posPrevious, current)
+ self._posPrevious = current
+
+ def _handleEditingFinished(self):
+ handle = self.sender()
+ current = numpy.array(handle.getPosition())
+ self.handleDragFinished(handle, self._posOrigin, current)
+ self._posPrevious = None
+ self._posOrigin = None
+ super(HandleBasedROI, self)._editingFinished()
+
+ def isHandleBeingDragged(self):
+ """Returns True if one of the handles is currently being dragged.
+
+ :rtype: bool
+ """
+ return self._posOrigin is not None
+
+ def handleDragStarted(self, handle, origin):
+ """Called when an handler drag started"""
+ pass
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ """Called when an handle drag position changed"""
+ pass
+
+ def handleDragFinished(self, handle, origin, current):
+ """Called when an handle drag finished"""
+ pass
+
+ def _computeHandleColor(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 _updateText(self, text):
+ """Update the text displayed by this ROI
+
+ :param str text: A text
+ """
+ pass
diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py
index 8f0694d..0e492a0 100644
--- a/silx/gui/plot/items/complex.py
+++ b/silx/gui/plot/items/complex.py
@@ -124,10 +124,9 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
"""Overrides supported ComplexMode"""
def __init__(self):
- ImageBase.__init__(self)
+ ImageBase.__init__(self, numpy.zeros((0, 0), dtype=numpy.complex64))
ColormapMixIn.__init__(self)
ComplexMixIn.__init__(self)
- self._data = numpy.zeros((0, 0), dtype=numpy.complex64)
self._dataByModesCache = {}
self._amplitudeRangeInfo = None, 2
@@ -264,17 +263,9 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
'Image is not complex, converting it to complex to plot it.')
data = numpy.array(data, dtype=numpy.complex64)
- self._data = data
self._dataByModesCache = {}
self._setColormappedData(self.getData(copy=False), copy=False)
-
- # TODO hackish data range implementation
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
- self._updated(ItemChangedType.DATA)
+ super().setData(data)
def getComplexData(self, copy=True):
"""Returns the image complex data
@@ -283,7 +274,7 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
False to use internal representation (do not modify!)
:rtype: numpy.ndarray of complex
"""
- return numpy.array(self._data, copy=copy)
+ return super().getData(copy=copy)
def getData(self, copy=True, mode=None):
"""Returns the image data corresponding to (current) mode.
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 9426a13..edc6d89 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -37,6 +37,7 @@ except ImportError: # Python2 support
from copy import deepcopy
import logging
import enum
+from typing import Optional, Tuple
import warnings
import weakref
@@ -44,7 +45,9 @@ import numpy
import six
from ....utils.deprecation import deprecated
+from ....utils.proxy import docstring
from ....utils.enum import Enum as _Enum
+from ....math.combo import min_max
from ... import qt
from ... import colors
from ...colors import Colormap
@@ -164,6 +167,13 @@ class Item(qt.QObject):
See :class:`ItemChangedType` for flags description.
"""
+ _sigVisibleBoundsChanged = qt.Signal()
+ """Signal emitted when the visible extent of the item in the plot has changed.
+
+ This signal is emitted only if visible extent tracking is enabled
+ (see :meth:`_setVisibleBoundsTracking`).
+ """
+
def __init__(self):
qt.QObject.__init__(self)
self._dirty = True
@@ -176,6 +186,9 @@ class Item(qt.QObject):
self._ylabel = None
self.__name = ''
+ self.__visibleBoundsTracking = False
+ self.__previousVisibleBounds = None
+
self._backendRenderer = None
def getPlot(self):
@@ -194,7 +207,9 @@ class Item(qt.QObject):
"""
if plot is not None and self._plotRef is not None:
raise RuntimeError('Trying to add a node at two places.')
+ self.__disconnectFromPlotWidget()
self._plotRef = None if plot is None else weakref.ref(plot)
+ self.__connectToPlotWidget()
self._updated()
def getBounds(self): # TODO return a Bounds object rather than a tuple
@@ -300,6 +315,97 @@ class Item(qt.QObject):
info = deepcopy(info)
self._info = info
+ def getVisibleBounds(self) -> Optional[Tuple[float,float,float,float]]:
+ """Returns visible bounds of the item bounding box in the plot area.
+
+ :returns:
+ (xmin, xmax, ymin, ymax) in data coordinates of the visible area or
+ None if item is not visible in the plot area.
+ :rtype: Union[List[float],None]
+ """
+ plot = self.getPlot()
+ bounds = self.getBounds()
+ if plot is None or bounds is None or not self.isVisible():
+ return None
+
+ xmin, xmax = numpy.clip(bounds[:2], *plot.getXAxis().getLimits())
+ ymin, ymax = numpy.clip(
+ bounds[2:], *plot.getYAxis(self.__getYAxis()).getLimits())
+
+ if xmin == xmax or ymin == ymax: # Outside the plot area
+ return None
+ else:
+ return xmin, xmax, ymin, ymax
+
+ def _isVisibleBoundsTracking(self) -> bool:
+ """Returns True if visible bounds changes are tracked.
+
+ When enabled, :attr:`_sigVisibleBoundsChanged` is emitted upon changes.
+ :rtype: bool
+ """
+ return self.__visibleBoundsTracking
+
+ def _setVisibleBoundsTracking(self, enable: bool) -> None:
+ """Set whether or not to track visible bounds changes.
+
+ :param bool enable:
+ """
+ if enable != self.__visibleBoundsTracking:
+ self.__disconnectFromPlotWidget()
+ self.__previousVisibleBounds = None
+ self.__visibleBoundsTracking = enable
+ self.__connectToPlotWidget()
+
+ def __getYAxis(self) -> str:
+ """Returns current Y axis ('left' or 'right')"""
+ return self.getYAxis() if isinstance(self, YAxisMixIn) else 'left'
+
+ def __connectToPlotWidget(self) -> None:
+ """Connect to PlotWidget signals and install event filter"""
+ if not self._isVisibleBoundsTracking():
+ return
+
+ plot = self.getPlot()
+ if plot is not None:
+ for axis in (plot.getXAxis(), plot.getYAxis(self.__getYAxis())):
+ axis.sigLimitsChanged.connect(self._visibleBoundsChanged)
+
+ plot.installEventFilter(self)
+
+ self._visibleBoundsChanged()
+
+ def __disconnectFromPlotWidget(self) -> None:
+ """Disconnect from PlotWidget signals and remove event filter"""
+ if not self._isVisibleBoundsTracking():
+ return
+
+ plot = self.getPlot()
+ if plot is not None:
+ for axis in (plot.getXAxis(), plot.getYAxis(self.__getYAxis())):
+ axis.sigLimitsChanged.disconnect(self._visibleBoundsChanged)
+
+ plot.removeEventFilter(self)
+
+ def _visibleBoundsChanged(self, *args) -> None:
+ """Check if visible extent actually changed and emit signal"""
+ if not self._isVisibleBoundsTracking():
+ return # No visible extent tracking
+
+ plot = self.getPlot()
+ if plot is None or not plot.isVisible():
+ return # No plot or plot not visible
+
+ extent = self.getVisibleBounds()
+ if extent != self.__previousVisibleBounds:
+ self.__previousVisibleBounds = extent
+ self._sigVisibleBoundsChanged.emit()
+
+ def eventFilter(self, watched, event):
+ """Event filter to handle PlotWidget show events"""
+ if watched is self.getPlot() and event.type() == qt.QEvent.Show:
+ self._visibleBoundsChanged()
+ return super().eventFilter(watched, event)
+
def _updated(self, event=None, checkVisibility=True):
"""Mark the item as dirty (i.e., needing update).
@@ -375,6 +481,29 @@ class Item(qt.QObject):
return PickingResult(self, indices)
+class DataItem(Item):
+ """Item with a data extent in the plot"""
+
+ def _boundsChanged(self, checkVisibility: bool=True) -> None:
+ """Call this method in subclass when data bounds has changed.
+
+ :param bool checkVisibility:
+ """
+ if not checkVisibility or self.isVisible():
+ self._visibleBoundsChanged()
+
+ # TODO hackish data range implementation
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ @docstring(Item)
+ def setVisible(self, visible: bool):
+ if visible != self.isVisible():
+ self._boundsChanged(checkVisibility=False)
+ super().setVisible(visible)
+
+
# Mix-in classes ##############################################################
class ItemMixInBase(object):
@@ -836,6 +965,22 @@ class YAxisMixIn(ItemMixInBase):
assert yaxis in ('left', 'right')
if yaxis != self._yaxis:
self._yaxis = yaxis
+ # Handle data extent changed for DataItem
+ if isinstance(self, DataItem):
+ self._boundsChanged()
+
+ # Handle visible extent changed
+ if self._isVisibleBoundsTracking():
+ # Switch Y axis signal connection
+ plot = self.getPlot()
+ if plot is not None:
+ previousYAxis = 'left' if self.getXAxis() == 'right' else 'right'
+ plot.getYAxis(previousYAxis).sigLimitsChanged.disconnect(
+ self._visibleBoundsChanged)
+ plot.getYAxis(self.getYAxis()).sigLimitsChanged.connect(
+ self._visibleBoundsChanged)
+ self._visibleBoundsChanged()
+
self._updated(ItemChangedType.YAXIS)
@@ -1066,6 +1211,16 @@ class ScatterVisualizationMixIn(ItemMixInBase):
Available reduction functions are: 'mean' (default), 'count', 'sum'.
"""
+ DATA_BOUNDS_HINT = 'data_bounds_hint'
+ """The expected bounds of the data in data coordinates.
+
+ A 2-tuple of 2-tuple: ((ymin, ymax), (xmin, xmax)).
+ This provides a hint for the data ranges in both dimensions.
+ It is eventually enlarged with actually data ranges.
+
+ WARNING: dimension 0 i.e., Y first.
+ """
+
_SUPPORTED_VISUALIZATION_PARAMETER_VALUES = {
VisualizationParameter.GRID_MAJOR_ORDER: ('row', 'column'),
VisualizationParameter.BINNED_STATISTIC_FUNCTION: ('mean', 'count', 'sum'),
@@ -1191,7 +1346,7 @@ class ScatterVisualizationMixIn(ItemMixInBase):
return self.getVisualizationParameter(parameter)
-class PointsBase(Item, SymbolMixIn, AlphaMixIn):
+class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
"""Base class for :class:`Curve` and :class:`Scatter`"""
# note: _logFilterData must be overloaded if you overload
# getData to change its signature
@@ -1201,7 +1356,7 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
on top of images."""
def __init__(self):
- Item.__init__(self)
+ DataItem.__init__(self)
SymbolMixIn.__init__(self)
AlphaMixIn.__init__(self)
self._x = ()
@@ -1244,18 +1399,18 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
# expand errorbars to 2xN
if error.size == 1: # Scalar
error = numpy.full(
- (2, len(value)), error, dtype=numpy.float)
+ (2, len(value)), error, dtype=numpy.float64)
elif error.ndim == 1: # N array
newError = numpy.empty((2, len(value)),
- dtype=numpy.float)
+ dtype=numpy.float64)
newError[0, :] = error
newError[1, :] = error
error = newError
elif error.size == 2 * len(value): # 2xN array
error = numpy.array(
- error, copy=True, dtype=numpy.float)
+ error, copy=True, dtype=numpy.float64)
else:
_logger.error("Unhandled error array")
@@ -1309,9 +1464,9 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
if numpy.any(clipped):
# copy to keep original array and convert to float
- x = numpy.array(x, copy=True, dtype=numpy.float)
+ x = numpy.array(x, copy=True, dtype=numpy.float64)
x[clipped] = numpy.nan
- y = numpy.array(y, copy=True, dtype=numpy.float)
+ y = numpy.array(y, copy=True, dtype=numpy.float64)
y[clipped] = numpy.nan
if xPositive and xerror is not None:
@@ -1347,15 +1502,11 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
else:
x, y, _xerror, _yerror = data
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
- # Ignore All-NaN slice encountered
- self._boundsCache[(xPositive, yPositive)] = (
- numpy.nanmin(x),
- numpy.nanmax(x),
- numpy.nanmin(y),
- numpy.nanmax(y)
- )
+ xmin, xmax = min_max(x, finite=True)
+ ymin, ymax = min_max(y, finite=True)
+ self._boundsCache[(xPositive, yPositive)] = tuple([
+ (bound if bound is not None else numpy.nan)
+ for bound in (xmin, xmax, ymin, ymax)])
return self._boundsCache[(xPositive, yPositive)]
def _getCachedData(self):
@@ -1477,11 +1628,7 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
self._filteredCache = {} # Reset cached filtered data
self._clippedCache = {} # Reset cached clipped bool array
- # TODO hackish data range implementation
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
+ self._boundsChanged()
self._updated(ItemChangedType.DATA)
diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py
index 7922fa1..75e7f01 100644
--- a/silx/gui/plot/items/curve.py
+++ b/silx/gui/plot/items/curve.py
@@ -185,15 +185,6 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
self._setBaseline(Curve._DEFAULT_BASELINE)
- self.sigItemChanged.connect(self.__itemChanged)
-
- def __itemChanged(self, event):
- if event == ItemChangedType.YAXIS:
- # TODO hackish data range implementation
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
def _addBackendRenderer(self, backend):
"""Update backend renderer"""
# Filter-out values <= 0
@@ -251,20 +242,6 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
else:
raise IndexError("Index out of range: %s", str(item))
- def setVisible(self, visible):
- """Set visibility of item.
-
- :param bool visible: True to display it, False otherwise
- """
- visible = bool(visible)
- # TODO hackish data range implementation
- if self.isVisible() != visible:
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
- super(Curve, self).setVisible(visible)
-
@deprecated(replacement='Curve.getHighlightedStyle().getColor()',
since_version='0.9.0')
def getHighlightedColor(self):
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
index 935f8d5..5941cc6 100644
--- a/silx/gui/plot/items/histogram.py
+++ b/silx/gui/plot/items/histogram.py
@@ -38,7 +38,7 @@ try:
except ImportError: # Python2 support
import collections as abc
-from .core import (Item, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn,
+from .core import (DataItem, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn,
LineMixIn, YAxisMixIn, ItemChangedType)
_logger = logging.getLogger(__name__)
@@ -100,7 +100,7 @@ def _getHistogramCurve(histogram, edges):
# TODO: Yerror, test log scale
-class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
+class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn,
LineMixIn, YAxisMixIn, BaselineMixIn):
"""Description of an histogram"""
@@ -119,7 +119,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
_DEFAULT_BASELINE = None
def __init__(self):
- Item.__init__(self)
+ DataItem.__init__(self)
AlphaMixIn.__init__(self)
BaselineMixIn.__init__(self)
ColorMixIn.__init__(self)
@@ -157,8 +157,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
(x <= 0) if xPositive else False,
(y <= 0) if yPositive else False)
# Make a copy and replace negative points by NaN
- x = numpy.array(x, dtype=numpy.float)
- y = numpy.array(y, dtype=numpy.float)
+ x = numpy.array(x, dtype=numpy.float64)
+ y = numpy.array(y, dtype=numpy.float64)
x[clipped] = numpy.nan
y[clipped] = numpy.nan
@@ -187,17 +187,17 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
yPositive = False
if xPositive or yPositive:
- values = numpy.array(values, copy=True, dtype=numpy.float)
+ values = numpy.array(values, copy=True, dtype=numpy.float64)
if xPositive:
# Replace edges <= 0 by NaN and corresponding values by NaN
clipped_edges = (edges <= 0)
- edges = numpy.array(edges, copy=True, dtype=numpy.float)
+ edges = numpy.array(edges, copy=True, dtype=numpy.float64)
edges[clipped_edges] = numpy.nan
clipped_values = numpy.logical_or(clipped_edges[:-1],
clipped_edges[1:])
else:
- clipped_values = numpy.zeros_like(values, dtype=numpy.bool)
+ clipped_values = numpy.zeros_like(values, dtype=bool)
if yPositive:
# Replace values <= 0 by NaN, do not modify edges
@@ -219,19 +219,6 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
min(0, numpy.nanmin(values)),
max(0, numpy.nanmax(values)))
- def setVisible(self, visible):
- """Set visibility of item.
-
- :param bool visible: True to display it, False otherwise
- """
- visible = bool(visible)
- # TODO hackish data range implementation
- if self.isVisible() != visible:
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
- super(Histogram, self).setVisible(visible)
-
def getValueData(self, copy=True):
"""The values of the histogram
@@ -314,11 +301,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
self._alignement = align
self._setBaseline(baseline)
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
+ self._boundsChanged()
self._updated(ItemChangedType.DATA)
def getAlignment(self):
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
index 91c051d..fda4245 100644
--- a/silx/gui/plot/items/image.py
+++ b/silx/gui/plot/items/image.py
@@ -40,7 +40,7 @@ import logging
import numpy
from ....utils.proxy import docstring
-from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn,
+from .core import (DataItem, LabelsMixIn, DraggableMixIn, ColormapMixIn,
AlphaMixIn, ItemChangedType)
@@ -87,15 +87,20 @@ def _convertImageToRgba32(image, copy=True):
return numpy.array(image, copy=copy)
-class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
- """Description of an image"""
+class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
+ """Description of an image
- def __init__(self):
- Item.__init__(self)
+ :param numpy.ndarray data: Initial image data
+ """
+
+ def __init__(self, data=None):
+ DataItem.__init__(self)
LabelsMixIn.__init__(self)
DraggableMixIn.__init__(self)
AlphaMixIn.__init__(self)
- self._data = numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+ if data is None:
+ data = numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+ self._data = data
self._origin = (0., 0.)
self._scale = (1., 1.)
@@ -129,19 +134,6 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
else:
raise IndexError("Index out of range: %s" % str(item))
- def setVisible(self, visible):
- """Set visibility of item.
-
- :param bool visible: True to display it, False otherwise
- """
- visible = bool(visible)
- # TODO hackish data range implementation
- if self.isVisible() != visible:
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
- super(ImageBase, self).setVisible(visible)
-
def _isPlotLinear(self, plot):
"""Return True if plot only uses linear scale for both of x and y
axes."""
@@ -189,6 +181,15 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
"""
return numpy.array(self._data, copy=copy)
+ def setData(self, data):
+ """Set the image data
+
+ :param numpy.ndarray data:
+ """
+ self._data = data
+ self._boundsChanged()
+ self._updated(ItemChangedType.DATA)
+
def getRgbaImageData(self, copy=True):
"""Get the displayed RGB(A) image
@@ -215,13 +216,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
origin = float(origin), float(origin)
if origin != self._origin:
self._origin = origin
-
- # TODO hackish data range implementation
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
+ self._boundsChanged()
self._updated(ItemChangedType.POSITION)
def getScale(self):
@@ -244,13 +239,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
if scale != self._scale:
self._scale = scale
-
- # TODO hackish data range implementation
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
+ self._boundsChanged()
self._updated(ItemChangedType.SCALE)
@@ -258,9 +247,8 @@ class ImageData(ImageBase, ColormapMixIn):
"""Description of a data image with a colormap"""
def __init__(self):
- ImageBase.__init__(self)
+ ImageBase.__init__(self, numpy.zeros((0, 0), dtype=numpy.float32))
ColormapMixIn.__init__(self)
- self._data = numpy.zeros((0, 0), dtype=numpy.float32)
self._alternativeImage = None
self.__alpha = None
@@ -370,7 +358,6 @@ class ImageData(ImageBase, ColormapMixIn):
_logger.warning(
'Converting complex image to absolute value to plot it.')
data = numpy.absolute(data)
- self._data = data
self._setColormappedData(data, copy=False)
if alternative is not None:
@@ -389,20 +376,14 @@ class ImageData(ImageBase, ColormapMixIn):
alpha = numpy.clip(alpha, 0., 1.)
self.__alpha = alpha
- # TODO hackish data range implementation
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
- self._updated(ItemChangedType.DATA)
+ super().setData(data)
class ImageRgba(ImageBase):
"""Description of an RGB(A) image"""
def __init__(self):
- ImageBase.__init__(self)
+ ImageBase.__init__(self, numpy.zeros((0, 0, 4), dtype=numpy.uint8))
def _addBackendRenderer(self, backend):
"""Update backend renderer"""
@@ -440,15 +421,7 @@ class ImageRgba(ImageBase):
data = numpy.array(data, copy=copy)
assert data.ndim == 3
assert data.shape[-1] in (3, 4)
- self._data = data
-
- # TODO hackish data range implementation
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
- self._updated(ItemChangedType.DATA)
+ super().setData(data)
class MaskImageData(ImageData):
diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py
index ff73fe6..38a1424 100644
--- a/silx/gui/plot/items/roi.py
+++ b/silx/gui/plot/items/roi.py
@@ -36,729 +36,25 @@ __date__ = "28/06/2018"
import logging
import numpy
-import weakref
-from silx.image.shapes import Polygon
-from ....utils.weakref import WeakList
-from ... import qt
from ... import utils
from .. import items
-from ..items import core
from ...colors import rgba
-import silx.utils.deprecation
+from silx.image.shapes import Polygon
from silx.image._boundingbox import _BoundingBox
from ....utils.proxy import docstring
from ..utils.intersections import segments_intersection
+from ._roi_base import _RegionOfInterestBase
+# He following imports have to be exposed by this module
+from ._roi_base import RegionOfInterest
+from ._roi_base import HandleBasedROI
+from ._arc_roi import ArcROI # noqa
+from ._roi_base import InteractionModeMixIn # noqa
+from ._roi_base import RoiInteractionMode # noqa
-logger = logging.getLogger(__name__)
-
-
-class _RegionOfInterestBase(qt.QObject):
- """Base class of 1D and 2D region of interest
-
- :param QObject parent: See QObject
- :param str name: The name of the ROI
- """
-
- sigAboutToBeRemoved = qt.Signal()
- """Signal emitted just before this ROI is removed from its manager."""
-
- sigItemChanged = qt.Signal(object)
- """Signal emitted when item has changed.
-
- It provides a flag describing which property of the item has changed.
- See :class:`ItemChangedType` for flags description.
- """
-
- def __init__(self, parent=None):
- qt.QObject.__init__(self, parent=parent)
- self.__name = ''
-
- def getName(self):
- """Returns the name of the ROI
-
- :return: name of the region of interest
- :rtype: str
- """
- return self.__name
-
- def setName(self, name):
- """Set the name of the ROI
-
- :param str name: name of the region of interest
- """
- name = str(name)
- if self.__name != name:
- self.__name = name
- self._updated(items.ItemChangedType.NAME)
-
- def _updated(self, event=None, checkVisibility=True):
- """Implement Item mix-in update method by updating the plot items
-
- See :class:`~silx.gui.plot.items.Item._updated`
- """
- self.sigItemChanged.emit(event)
-
- def contains(self, position):
- """Returns True if the `position` is in this ROI.
-
- :param tuple[float,float] position: position to check
- :return: True if the value / point is consider to be in the region of
- interest.
- :rtype: bool
- """
- raise NotImplementedError("Base class")
-
-
-class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn):
- """Object describing a region of interest in a plot.
-
- :param QObject parent:
- The RegionOfInterestManager that created this object
- """
-
- _DEFAULT_LINEWIDTH = 1.
- """Default line width of the curve"""
-
- _DEFAULT_LINESTYLE = '-'
- """Default line style of the curve"""
-
- _DEFAULT_HIGHLIGHT_STYLE = items.CurveStyle(linewidth=2)
- """Default highlight style of the item"""
-
- ICON, NAME, SHORT_NAME = None, None, None
- """Metadata to describe the ROI in labels, tooltips and widgets
-
- Should be set by inherited classes to custom the ROI manager widget.
- """
-
- sigRegionChanged = qt.Signal()
- """Signal emitted everytime the shape or position of the ROI changes"""
-
- sigEditingStarted = qt.Signal()
- """Signal emitted when the user start editing the roi"""
-
- sigEditingFinished = qt.Signal()
- """Signal emitted when the region edition is finished. During edition
- sigEditionChanged will be emitted several times and
- sigRegionEditionFinished only at end"""
-
- def __init__(self, parent=None):
- # Avoid circular dependency
- from ..tools import roi as roi_tools
- assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager)
- _RegionOfInterestBase.__init__(self, parent)
- core.HighlightedMixIn.__init__(self)
- self._color = rgba('red')
- self._editable = False
- self._selectable = False
- self._focusProxy = None
- self._visible = True
- self._child = WeakList()
-
- def _connectToPlot(self, plot):
- """Called after connection to a plot"""
- for item in self.getItems():
- # This hack is needed to avoid reentrant call from _disconnectFromPlot
- # to the ROI manager. It also speed up the item tests in _itemRemoved
- item._roiGroup = True
- plot.addItem(item)
-
- def _disconnectFromPlot(self, plot):
- """Called before disconnection from a plot"""
- for item in self.getItems():
- # The item could be already be removed by the plot
- if item.getPlot() is not None:
- del item._roiGroup
- plot.removeItem(item)
-
- def _setItemName(self, item):
- """Helper to generate a unique id to a plot item"""
- legend = "__ROI-%d__%d" % (id(self), id(item))
- item.setName(legend)
-
- def setParent(self, parent):
- """Set the parent of the RegionOfInterest
-
- :param Union[None,RegionOfInterestManager] parent: The new parent
- """
- # Avoid circular dependency
- from ..tools import roi as roi_tools
- if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)):
- raise ValueError('Unsupported parent')
-
- previousParent = self.parent()
- if previousParent is not None:
- previousPlot = previousParent.parent()
- if previousPlot is not None:
- self._disconnectFromPlot(previousPlot)
- super(RegionOfInterest, self).setParent(parent)
- if parent is not None:
- plot = parent.parent()
- if plot is not None:
- self._connectToPlot(plot)
-
- def addItem(self, item):
- """Add an item to the set of this ROI children.
-
- This item will be added and removed to the plot used by the ROI.
-
- If the ROI is already part of a plot, the item will also be added to
- the plot.
-
- It the item do not have a name already, a unique one is generated to
- avoid item collision in the plot.
-
- :param silx.gui.plot.items.Item item: A plot item
- """
- assert item is not None
- self._child.append(item)
- if item.getName() == '':
- self._setItemName(item)
- manager = self.parent()
- if manager is not None:
- plot = manager.parent()
- if plot is not None:
- item._roiGroup = True
- plot.addItem(item)
-
- def removeItem(self, item):
- """Remove an item from this ROI children.
-
- If the item is part of a plot it will be removed too.
-
- :param silx.gui.plot.items.Item item: A plot item
- """
- assert item is not None
- self._child.remove(item)
- plot = item.getPlot()
- if plot is not None:
- del item._roiGroup
- plot.removeItem(item)
-
- def getItems(self):
- """Returns the list of PlotWidget items of this RegionOfInterest.
-
- :rtype: List[~silx.gui.plot.items.Item]
- """
- return tuple(self._child)
-
- @classmethod
- def _getShortName(cls):
- """Return an human readable kind of ROI
-
- :rtype: str
- """
- if hasattr(cls, "SHORT_NAME"):
- name = cls.SHORT_NAME
- if name is None:
- name = cls.__name__
- return name
-
- def getColor(self):
- """Returns the color of this ROI
-
- :rtype: QColor
- """
- return qt.QColor.fromRgbF(*self._color)
-
- 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
- self._updated(items.ItemChangedType.COLOR)
-
- @silx.utils.deprecation.deprecated(reason='API modification',
- replacement='getName()',
- since_version=0.12)
- def getLabel(self):
- """Returns the label displayed for this ROI.
-
- :rtype: str
- """
- return self.getName()
-
- @silx.utils.deprecation.deprecated(reason='API modification',
- replacement='setName(name)',
- since_version=0.12)
- def setLabel(self, label):
- """Set the label displayed with this ROI.
-
- :param str label: The text label to display
- """
- self.setName(name=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
- self._updated(items.ItemChangedType.EDITABLE)
-
- def isSelectable(self):
- """Returns whether the ROI is selectable by the user or not.
-
- :rtype: bool
- """
- return self._selectable
-
- def setSelectable(self, selectable):
- """Set whether the ROI can be selected interactively.
-
- :param bool selectable: True to allow selection by the user,
- False to disable.
- """
- selectable = bool(selectable)
- if self._selectable != selectable:
- self._selectable = selectable
- self._updated(items.ItemChangedType.SELECTABLE)
-
- def getFocusProxy(self):
- """Returns the ROI which have to be selected when this ROI is selected,
- else None if no proxy specified.
-
- :rtype: RegionOfInterest
- """
- proxy = self._focusProxy
- if proxy is None:
- return None
- proxy = proxy()
- if proxy is None:
- self._focusProxy = None
- return proxy
-
- def setFocusProxy(self, roi):
- """Set the real ROI which will be selected when this ROI is selected,
- else None to remove the proxy already specified.
-
- :param RegionOfInterest roi: A ROI
- """
- if roi is not None:
- self._focusProxy = weakref.ref(roi)
- else:
- self._focusProxy = None
-
- def isVisible(self):
- """Returns whether the ROI is visible in the plot.
-
- .. note::
- This does not take into account whether or not the plot
- widget itself is visible (unlike :meth:`QWidget.isVisible` which
- checks the visibility of all its parent widgets up to the window)
-
- :rtype: bool
- """
- return self._visible
-
- def setVisible(self, visible):
- """Set whether the plot items associated with this ROI are
- visible in the plot.
-
- :param bool visible: True to show the ROI in the plot, False to
- hide it.
- """
- visible = bool(visible)
- if self._visible != visible:
- self._visible = visible
- self._updated(items.ItemChangedType.VISIBLE)
-
- @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 False
-
- @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 constrained by the plot API and only supports few
- shapes.
- """
- raise NotImplementedError()
-
- def creationStarted(self):
- """"Called when the ROI creation interaction was started.
- """
- pass
-
- @docstring(_RegionOfInterestBase)
- def contains(self, position):
- raise NotImplementedError("Base class")
-
- def creationFinalized(self):
- """"Called when the ROI creation interaction was finalized.
- """
- pass
-
- def _updateItemProperty(self, event, source, destination):
- """Update the item property of a destination from an item source.
-
- :param items.ItemChangedType event: Property type to update
- :param silx.gui.plot.items.Item source: The reference for the data
- :param event Union[Item,List[Item]] destination: The item(s) to update
- """
- if not isinstance(destination, (list, tuple)):
- destination = [destination]
- if event == items.ItemChangedType.NAME:
- value = source.getName()
- for d in destination:
- d.setName(value)
- elif event == items.ItemChangedType.EDITABLE:
- value = source.isEditable()
- for d in destination:
- d.setEditable(value)
- elif event == items.ItemChangedType.SELECTABLE:
- value = source.isSelectable()
- for d in destination:
- d._setSelectable(value)
- elif event == items.ItemChangedType.COLOR:
- value = rgba(source.getColor())
- for d in destination:
- d.setColor(value)
- elif event == items.ItemChangedType.LINE_STYLE:
- value = self.getLineStyle()
- for d in destination:
- d.setLineStyle(value)
- elif event == items.ItemChangedType.LINE_WIDTH:
- value = self.getLineWidth()
- for d in destination:
- d.setLineWidth(value)
- elif event == items.ItemChangedType.SYMBOL:
- value = self.getSymbol()
- for d in destination:
- d.setSymbol(value)
- elif event == items.ItemChangedType.SYMBOL_SIZE:
- value = self.getSymbolSize()
- for d in destination:
- d.setSymbolSize(value)
- elif event == items.ItemChangedType.VISIBLE:
- value = self.isVisible()
- for d in destination:
- d.setVisible(value)
- else:
- assert False
-
- def _updated(self, event=None, checkVisibility=True):
- if event == items.ItemChangedType.HIGHLIGHTED:
- style = self.getCurrentStyle()
- self._updatedStyle(event, style)
- else:
- hilighted = self.isHighlighted()
- if hilighted:
- if event == items.ItemChangedType.HIGHLIGHTED_STYLE:
- style = self.getCurrentStyle()
- self._updatedStyle(event, style)
- else:
- if event in [items.ItemChangedType.COLOR,
- items.ItemChangedType.LINE_STYLE,
- items.ItemChangedType.LINE_WIDTH,
- items.ItemChangedType.SYMBOL,
- items.ItemChangedType.SYMBOL_SIZE]:
- style = self.getCurrentStyle()
- self._updatedStyle(event, style)
- super(RegionOfInterest, self)._updated(event, checkVisibility)
-
- def _updatedStyle(self, event, style):
- """Called when the current displayed style of the ROI was changed.
-
- :param event: The event responsible of the change of the style
- :param items.CurveStyle style: The current style
- """
- pass
-
- def getCurrentStyle(self):
- """Returns the current curve style.
-
- Curve style depends on curve highlighting
-
- :rtype: CurveStyle
- """
- baseColor = rgba(self.getColor())
- if isinstance(self, core.LineMixIn):
- baseLinestyle = self.getLineStyle()
- baseLinewidth = self.getLineWidth()
- else:
- baseLinestyle = self._DEFAULT_LINESTYLE
- baseLinewidth = self._DEFAULT_LINEWIDTH
- if isinstance(self, core.SymbolMixIn):
- baseSymbol = self.getSymbol()
- baseSymbolsize = self.getSymbolSize()
- else:
- baseSymbol = 'o'
- baseSymbolsize = 1
-
- if self.isHighlighted():
- style = self.getHighlightedStyle()
- color = style.getColor()
- linestyle = style.getLineStyle()
- linewidth = style.getLineWidth()
- symbol = style.getSymbol()
- symbolsize = style.getSymbolSize()
-
- return items.CurveStyle(
- color=baseColor if color is None else color,
- linestyle=baseLinestyle if linestyle is None else linestyle,
- linewidth=baseLinewidth if linewidth is None else linewidth,
- symbol=baseSymbol if symbol is None else symbol,
- symbolsize=baseSymbolsize if symbolsize is None else symbolsize)
- else:
- return items.CurveStyle(color=baseColor,
- linestyle=baseLinestyle,
- linewidth=baseLinewidth,
- symbol=baseSymbol,
- symbolsize=baseSymbolsize)
-
- def _editingStarted(self):
- assert self._editable is True
- self.sigEditingStarted.emit()
-
- def _editingFinished(self):
- self.sigEditingFinished.emit()
-
-
-class HandleBasedROI(RegionOfInterest):
- """Manage a ROI based on a set of handles"""
-
- def __init__(self, parent=None):
- RegionOfInterest.__init__(self, parent=parent)
- self._handles = []
- self._posOrigin = None
- self._posPrevious = None
-
- def addUserHandle(self, item=None):
- """
- Add a new free handle to the ROI.
-
- This handle do nothing. It have to be managed by the ROI
- implementing this class.
-
- :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
- add, else None to create a default marker.
- :rtype: silx.gui.plot.items.Marker
- """
- return self.addHandle(item, role="user")
-
- def addLabelHandle(self, item=None):
- """
- Add a new label handle to the ROI.
-
- This handle is not draggable nor selectable.
-
- It is displayed without symbol, but it is always visible anyway
- the ROI is editable, in order to display text.
-
- :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
- add, else None to create a default marker.
- :rtype: silx.gui.plot.items.Marker
- """
- return self.addHandle(item, role="label")
-
- def addTranslateHandle(self, item=None):
- """
- Add a new translate handle to the ROI.
-
- Dragging translate handles affect the position position of the ROI
- but not the shape itself.
-
- :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
- add, else None to create a default marker.
- :rtype: silx.gui.plot.items.Marker
- """
- return self.addHandle(item, role="translate")
-
- def addHandle(self, item=None, role="default"):
- """
- Add a new handle to the ROI.
-
- Dragging handles while affect the position or the shape of the
- ROI.
-
- :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
- add, else None to create a default marker.
- :rtype: silx.gui.plot.items.Marker
- """
- if item is None:
- item = items.Marker()
- color = rgba(self.getColor())
- color = self._computeHandleColor(color)
- item.setColor(color)
- if role == "default":
- item.setSymbol("s")
- elif role == "user":
- pass
- elif role == "translate":
- item.setSymbol("+")
- elif role == "label":
- item.setSymbol("")
-
- if role == "user":
- pass
- elif role == "label":
- item._setSelectable(False)
- item._setDraggable(False)
- item.setVisible(True)
- else:
- self.__updateEditable(item, self.isEditable(), remove=False)
- item._setSelectable(False)
-
- self._handles.append((item, role))
- self.addItem(item)
- return item
-
- def removeHandle(self, handle):
- data = [d for d in self._handles if d[0] is handle][0]
- self._handles.remove(data)
- role = data[1]
- if role not in ["user", "label"]:
- if self.isEditable():
- self.__updateEditable(handle, False)
- self.removeItem(handle)
-
- def getHandles(self):
- """Returns the list of handles of this HandleBasedROI.
-
- :rtype: List[~silx.gui.plot.items.Marker]
- """
- return tuple(data[0] for data in self._handles)
-
- def _updated(self, event=None, checkVisibility=True):
- """Implement Item mix-in update method by updating the plot items
-
- See :class:`~silx.gui.plot.items.Item._updated`
- """
- if event == items.ItemChangedType.NAME:
- self._updateText(self.getName())
- elif event == items.ItemChangedType.VISIBLE:
- for item, role in self._handles:
- visible = self.isVisible()
- editionVisible = visible and self.isEditable()
- if role not in ["user", "label"]:
- item.setVisible(editionVisible)
- else:
- item.setVisible(visible)
- elif event == items.ItemChangedType.EDITABLE:
- for item, role in self._handles:
- editable = self.isEditable()
- if role not in ["user", "label"]:
- self.__updateEditable(item, editable)
- super(HandleBasedROI, self)._updated(event, checkVisibility)
-
- def _updatedStyle(self, event, style):
- super(HandleBasedROI, self)._updatedStyle(event, style)
-
- # Update color of shape items in the plot
- color = rgba(self.getColor())
- handleColor = self._computeHandleColor(color)
- for item, role in self._handles:
- if role == 'user':
- pass
- elif role == 'label':
- item.setColor(color)
- else:
- item.setColor(handleColor)
-
- def __updateEditable(self, handle, editable, remove=True):
- # NOTE: visibility change emit a position update event
- handle.setVisible(editable and self.isVisible())
- handle._setDraggable(editable)
- if editable:
- handle.sigDragStarted.connect(self._handleEditingStarted)
- handle.sigItemChanged.connect(self._handleEditingUpdated)
- handle.sigDragFinished.connect(self._handleEditingFinished)
- else:
- if remove:
- handle.sigDragStarted.disconnect(self._handleEditingStarted)
- handle.sigItemChanged.disconnect(self._handleEditingUpdated)
- handle.sigDragFinished.disconnect(self._handleEditingFinished)
-
- def _handleEditingStarted(self):
- super(HandleBasedROI, self)._editingStarted()
- handle = self.sender()
- self._posOrigin = numpy.array(handle.getPosition())
- self._posPrevious = numpy.array(self._posOrigin)
- self.handleDragStarted(handle, self._posOrigin)
-
- def _handleEditingUpdated(self):
- if self._posOrigin is None:
- # Avoid to handle events when visibility change
- return
- handle = self.sender()
- current = numpy.array(handle.getPosition())
- self.handleDragUpdated(handle, self._posOrigin, self._posPrevious, current)
- self._posPrevious = current
-
- def _handleEditingFinished(self):
- handle = self.sender()
- current = numpy.array(handle.getPosition())
- self.handleDragFinished(handle, self._posOrigin, current)
- self._posPrevious = None
- self._posOrigin = None
- super(HandleBasedROI, self)._editingFinished()
-
- def isHandleBeingDragged(self):
- """Returns True if one of the handles is currently being dragged.
-
- :rtype: bool
- """
- return self._posOrigin is not None
-
- def handleDragStarted(self, handle, origin):
- """Called when an handler drag started"""
- pass
-
- def handleDragUpdated(self, handle, origin, previous, current):
- """Called when an handle drag position changed"""
- pass
-
- def handleDragFinished(self, handle, origin, current):
- """Called when an handle drag finished"""
- pass
-
- def _computeHandleColor(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 _updateText(self, text):
- """Update the text displayed by this ROI
-
- :param str text: A text
- """
- pass
+logger = logging.getLogger(__name__)
class PointROI(RegionOfInterest, items.SymbolMixIn):
@@ -821,7 +117,8 @@ class PointROI(RegionOfInterest, items.SymbolMixIn):
@docstring(_RegionOfInterestBase)
def contains(self, position):
- raise NotImplementedError('Base class')
+ roiPos = self.getPosition()
+ return position[0] == roiPos[0] and position[1] == roiPos[1]
def _pointPositionChanged(self, event):
"""Handle position changed events of the marker"""
@@ -1022,11 +319,12 @@ class LineROI(HandleBasedROI, items.LineMixIn):
top_left = position[0], position[1] + 1
top_right = position[0] + 1, position[1] + 1
- line_pt1 = self._points[0]
- line_pt2 = self._points[1]
+ points = self.__shape.getPoints()
+ line_pt1 = points[0]
+ line_pt2 = points[1]
- bb1 = _BoundingBox.from_points(self._points)
- if bb1.contains(position) is False:
+ bb1 = _BoundingBox.from_points(points)
+ if not bb1.contains(position):
return False
return (
@@ -1038,7 +336,7 @@ class LineROI(HandleBasedROI, items.LineMixIn):
seg2_start_pt=top_right, seg2_end_pt=top_left) or
segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2,
seg2_start_pt=top_left, seg2_end_pt=bottom_left)
- )
+ ) is not None
def __str__(self):
start, end = self.getEndPoints()
@@ -1106,7 +404,7 @@ class HorizontalLineROI(RegionOfInterest, items.LineMixIn):
@docstring(_RegionOfInterestBase)
def contains(self, position):
- return position[1] == self.getPosition()[1]
+ return position[1] == self.getPosition()
def _linePositionChanged(self, event):
"""Handle position changed events of the marker"""
@@ -1175,7 +473,7 @@ class VerticalLineROI(RegionOfInterest, items.LineMixIn):
@docstring(RegionOfInterest)
def contains(self, position):
- return position[0] == self.getPosition()[0]
+ return position[0] == self.getPosition()
def _linePositionChanged(self, event):
"""Handle position changed events of the marker"""
@@ -1515,6 +813,10 @@ class CircleROI(HandleBasedROI, items.LineMixIn):
center = self.getCenter()
self.setRadius(numpy.linalg.norm(center - current))
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ return numpy.linalg.norm(self.getCenter() - position) <= self.getRadius()
+
def __str__(self):
center = self.getCenter()
radius = self.getRadius()
@@ -1726,7 +1028,7 @@ class EllipseROI(HandleBasedROI, items.LineMixIn):
orientation = self.getOrientation()
if self._radius[1] > self._radius[0]:
# _handleAxis1 is the major axis
- orientation -= numpy.pi/2
+ orientation -= numpy.pi / 2
point0 = numpy.array([center[0] + self._radius[0] * numpy.cos(orientation),
center[1] + self._radius[0] * numpy.sin(orientation)])
@@ -1760,13 +1062,13 @@ class EllipseROI(HandleBasedROI, items.LineMixIn):
if handle is self._handleAxis1:
if self._radius[0] > distance:
# _handleAxis1 is not the major axis, rotate -90 degrees
- orientation -= numpy.pi/2
+ orientation -= numpy.pi / 2
radius = self._radius[0], distance
else: # _handleAxis0
if self._radius[1] > distance:
# _handleAxis0 is not the major axis, rotate +90 degrees
- orientation += numpy.pi/2
+ orientation += numpy.pi / 2
radius = distance, self._radius[1]
self.setGeometry(radius=radius, orientation=orientation)
@@ -1776,6 +1078,14 @@ class EllipseROI(HandleBasedROI, items.LineMixIn):
if event is items.ItemChangedType.POSITION:
self._updateGeometry()
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ major, minor = self.getMajorRadius(), self.getMinorRadius()
+ delta = self.getOrientation()
+ x, y = position - self.getCenter()
+ return ((x*numpy.cos(delta) + y*numpy.sin(delta))**2/major**2 +
+ (x*numpy.sin(delta) - y*numpy.cos(delta))**2/minor**2) <= 1
+
def __str__(self):
center = self.getCenter()
major = self.getMajorRadius()
@@ -1987,682 +1297,6 @@ class PolygonROI(HandleBasedROI, items.LineMixIn):
self._polygon_shape = None
-class ArcROI(HandleBasedROI, items.LineMixIn):
- """A ROI identifying an arc of a circle with a width.
-
- This ROI provides
- - 3 handle to control the curvature
- - 1 handle to control the weight
- - 1 anchor to translate the shape.
- """
-
- ICON = 'add-shape-arc'
- NAME = 'arc ROI'
- SHORT_NAME = "arc"
- """Metadata for this kind of ROI"""
-
- _plotShape = "line"
- """Plot shape which is used for the first interaction"""
-
- class _Geometry:
- def __init__(self):
- self.center = None
- self.startPoint = None
- self.endPoint = None
- self.radius = None
- self.weight = None
- self.startAngle = None
- self.endAngle = None
- self._closed = None
-
- @classmethod
- def createEmpty(cls):
- zero = numpy.array([0, 0])
- return cls.create(zero, zero.copy(), zero.copy(), 0, 0, 0, 0)
-
- @classmethod
- def createRect(cls, startPoint, endPoint, weight):
- return cls.create(None, startPoint, endPoint, None, weight, None, None, False)
-
- @classmethod
- def createCircle(cls, center, startPoint, endPoint, radius,
- weight, startAngle, endAngle):
- return cls.create(center, startPoint, endPoint, radius,
- weight, startAngle, endAngle, True)
-
- @classmethod
- def create(cls, center, startPoint, endPoint, radius,
- weight, startAngle, endAngle, closed=False):
- g = cls()
- g.center = center
- g.startPoint = startPoint
- g.endPoint = endPoint
- g.radius = radius
- g.weight = weight
- g.startAngle = startAngle
- g.endAngle = endAngle
- g._closed = closed
- return g
-
- def withWeight(self, weight):
- """Create a new geometry with another weight
- """
- return self.create(self.center, self.startPoint, self.endPoint,
- self.radius, weight,
- self.startAngle, self.endAngle, self._closed)
-
- def withRadius(self, radius):
- """Create a new geometry with another radius.
-
- The weight and the center is conserved.
- """
- startPoint = self.center + (self.startPoint - self.center) / self.radius * radius
- endPoint = self.center + (self.endPoint - self.center) / self.radius * radius
- return self.create(self.center, startPoint, endPoint,
- radius, self.weight,
- self.startAngle, self.endAngle, self._closed)
-
- def translated(self, x, y):
- delta = numpy.array([x, y])
- center = None if self.center is None else self.center + delta
- startPoint = None if self.startPoint is None else self.startPoint + delta
- endPoint = None if self.endPoint is None else self.endPoint + delta
- return self.create(center, startPoint, endPoint,
- self.radius, self.weight,
- self.startAngle, self.endAngle, self._closed)
-
- def getKind(self):
- """Returns the kind of shape defined"""
- if self.center is None:
- return "rect"
- elif numpy.isnan(self.startAngle):
- return "point"
- elif self.isClosed():
- if self.weight <= 0 or self.weight * 0.5 >= self.radius:
- return "circle"
- else:
- return "donut"
- else:
- if self.weight * 0.5 < self.radius:
- return "arc"
- else:
- return "camembert"
-
- def isClosed(self):
- """Returns True if the geometry is a circle like"""
- if self._closed is not None:
- return self._closed
- delta = numpy.abs(self.endAngle - self.startAngle)
- self._closed = numpy.isclose(delta, numpy.pi * 2)
- return self._closed
-
- def __str__(self):
- return str((self.center,
- self.startPoint,
- self.endPoint,
- self.radius,
- self.weight,
- self.startAngle,
- self.endAngle,
- self._closed))
-
- def __init__(self, parent=None):
- HandleBasedROI.__init__(self, parent=parent)
- items.LineMixIn.__init__(self)
- self._geometry = self._Geometry.createEmpty()
- self._handleLabel = self.addLabelHandle()
-
- self._handleStart = self.addHandle()
- self._handleStart.setSymbol("o")
- self._handleMid = self.addHandle()
- self._handleMid.setSymbol("o")
- self._handleEnd = self.addHandle()
- self._handleEnd.setSymbol("o")
- self._handleWeight = self.addHandle()
- self._handleWeight._setConstraint(self._arcCurvatureMarkerConstraint)
- self._handleMove = self.addTranslateHandle()
-
- shape = items.Shape("polygon")
- shape.setPoints([[0, 0], [0, 0]])
- shape.setColor(rgba(self.getColor()))
- shape.setFill(False)
- shape.setOverlay(True)
- shape.setLineStyle(self.getLineStyle())
- shape.setLineWidth(self.getLineWidth())
- self.__shape = shape
- self.addItem(shape)
-
- def _updated(self, event=None, checkVisibility=True):
- if event == items.ItemChangedType.VISIBLE:
- self._updateItemProperty(event, self, self.__shape)
- super(ArcROI, self)._updated(event, checkVisibility)
-
- def _updatedStyle(self, event, style):
- super(ArcROI, self)._updatedStyle(event, style)
- self.__shape.setColor(style.getColor())
- self.__shape.setLineStyle(style.getLineStyle())
- self.__shape.setLineWidth(style.getLineWidth())
-
- def setFirstShapePoints(self, points):
- """"Initialize the ROI using the points from the first interaction.
-
- This interaction is constrained by the plot API and only supports few
- shapes.
- """
- # The first shape is a line
- point0 = points[0]
- point1 = points[1]
-
- # Compute a non collinear point for the curvature
- center = (point1 + point0) * 0.5
- normal = point1 - center
- normal = numpy.array((normal[1], -normal[0]))
- defaultCurvature = numpy.pi / 5.0
- weightCoef = 0.20
- mid = center - normal * defaultCurvature
- distance = numpy.linalg.norm(point0 - point1)
- weight = distance * weightCoef
-
- geometry = self._createGeometryFromControlPoints(point0, mid, point1, weight)
- self._geometry = geometry
- self._updateHandles()
-
- def _updateText(self, text):
- self._handleLabel.setText(text)
-
- def _updateMidHandle(self):
- """Keep the same geometry, but update the location of the control
- points.
-
- So calling this function do not trigger sigRegionChanged.
- """
- geometry = self._geometry
-
- if geometry.isClosed():
- start = numpy.array(self._handleStart.getPosition())
- geometry.endPoint = start
- with utils.blockSignals(self._handleEnd):
- self._handleEnd.setPosition(*start)
- midPos = geometry.center + geometry.center - start
- else:
- if geometry.center is None:
- midPos = geometry.startPoint * 0.66 + geometry.endPoint * 0.34
- else:
- midAngle = geometry.startAngle * 0.66 + geometry.endAngle * 0.34
- vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
- midPos = geometry.center + geometry.radius * vector
-
- with utils.blockSignals(self._handleMid):
- self._handleMid.setPosition(*midPos)
-
- def _updateWeightHandle(self):
- geometry = self._geometry
- if geometry.center is None:
- # rectangle
- center = (geometry.startPoint + geometry.endPoint) * 0.5
- normal = geometry.endPoint - geometry.startPoint
- normal = numpy.array((normal[1], -normal[0]))
- distance = numpy.linalg.norm(normal)
- if distance != 0:
- normal = normal / distance
- weightPos = center + normal * geometry.weight * 0.5
- else:
- if geometry.isClosed():
- midAngle = geometry.startAngle + numpy.pi * 0.5
- elif geometry.center is not None:
- midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
- vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
- weightPos = geometry.center + (geometry.radius + geometry.weight * 0.5) * vector
-
- with utils.blockSignals(self._handleWeight):
- self._handleWeight.setPosition(*weightPos)
-
- def _getWeightFromHandle(self, weightPos):
- geometry = self._geometry
- if geometry.center is None:
- # rectangle
- center = (geometry.startPoint + geometry.endPoint) * 0.5
- return numpy.linalg.norm(center - weightPos) * 2
- else:
- distance = numpy.linalg.norm(geometry.center - weightPos)
- return abs(distance - geometry.radius) * 2
-
- def _updateHandles(self):
- geometry = self._geometry
- with utils.blockSignals(self._handleStart):
- self._handleStart.setPosition(*geometry.startPoint)
- with utils.blockSignals(self._handleEnd):
- self._handleEnd.setPosition(*geometry.endPoint)
-
- self._updateMidHandle()
- self._updateWeightHandle()
-
- self._updateShape()
-
- def _updateCurvature(self, start, mid, end, updateCurveHandles, checkClosed=False):
- """Update the curvature using 3 control points in the curve
-
- :param bool updateCurveHandles: If False curve handles are already at
- the right location
- """
- if updateCurveHandles:
- with utils.blockSignals(self._handleStart):
- self._handleStart.setPosition(*start)
- with utils.blockSignals(self._handleMid):
- self._handleMid.setPosition(*mid)
- with utils.blockSignals(self._handleEnd):
- self._handleEnd.setPosition(*end)
-
- if checkClosed:
- closed = self._isCloseInPixel(start, end)
- else:
- closed = self._geometry.isClosed()
-
- weight = self._geometry.weight
- geometry = self._createGeometryFromControlPoints(start, mid, end, weight, closed=closed)
- self._geometry = geometry
-
- self._updateWeightHandle()
- self._updateShape()
-
- def handleDragUpdated(self, handle, origin, previous, current):
- if handle is self._handleStart:
- mid = numpy.array(self._handleMid.getPosition())
- end = numpy.array(self._handleEnd.getPosition())
- self._updateCurvature(current, mid, end,
- checkClosed=True, updateCurveHandles=False)
- elif handle is self._handleMid:
- if self._geometry.isClosed():
- radius = numpy.linalg.norm(self._geometry.center - current)
- self._geometry = self._geometry.withRadius(radius)
- self._updateHandles()
- else:
- start = numpy.array(self._handleStart.getPosition())
- end = numpy.array(self._handleEnd.getPosition())
- self._updateCurvature(start, current, end, updateCurveHandles=False)
- elif handle is self._handleEnd:
- start = numpy.array(self._handleStart.getPosition())
- mid = numpy.array(self._handleMid.getPosition())
- self._updateCurvature(start, mid, current,
- checkClosed=True, updateCurveHandles=False)
- elif handle is self._handleWeight:
- weight = self._getWeightFromHandle(current)
- self._geometry = self._geometry.withWeight(weight)
- self._updateShape()
- elif handle is self._handleMove:
- delta = current - previous
- self.translate(*delta)
-
- def _isCloseInPixel(self, point1, point2):
- manager = self.parent()
- if manager is None:
- return False
- plot = manager.parent()
- if plot is None:
- return False
- point1 = plot.dataToPixel(*point1)
- if point1 is None:
- return False
- point2 = plot.dataToPixel(*point2)
- if point2 is None:
- return False
- return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1]) < 15
-
- def _normalizeGeometry(self):
- """Keep the same phisical geometry, but with normalized parameters.
- """
- geometry = self._geometry
- if geometry.weight * 0.5 >= geometry.radius:
- radius = (geometry.weight * 0.5 + geometry.radius) * 0.5
- geometry = geometry.withRadius(radius)
- geometry = geometry.withWeight(radius * 2)
- self._geometry = geometry
- return True
- return False
-
- def handleDragFinished(self, handle, origin, current):
- if handle in [self._handleStart, self._handleMid, self._handleEnd]:
- if self._normalizeGeometry():
- self._updateHandles()
- else:
- self._updateMidHandle()
- if self._geometry.isClosed():
- self._handleStart.setSymbol("x")
- self._handleEnd.setSymbol("x")
- else:
- self._handleStart.setSymbol("o")
- self._handleEnd.setSymbol("o")
-
- def _createGeometryFromControlPoints(self, start, mid, end, weight, closed=None):
- """Returns the geometry of the object"""
- if closed or (closed is None and numpy.allclose(start, end)):
- # Special arc: It's a closed circle
- center = (start + mid) * 0.5
- radius = numpy.linalg.norm(start - center)
- v = start - center
- startAngle = numpy.angle(complex(v[0], v[1]))
- endAngle = startAngle + numpy.pi * 2.0
- return self._Geometry.createCircle(center, start, end, radius,
- weight, startAngle, endAngle)
-
- elif numpy.linalg.norm(numpy.cross(mid - start, end - start)) < 1e-5:
- # Degenerated arc, it's a rectangle
- return self._Geometry.createRect(start, end, weight)
- else:
- center, radius = self._circleEquation(start, mid, end)
- v = start - center
- startAngle = numpy.angle(complex(v[0], v[1]))
- v = mid - center
- midAngle = numpy.angle(complex(v[0], v[1]))
- v = end - center
- endAngle = numpy.angle(complex(v[0], v[1]))
-
- # Is it clockwise or anticlockwise
- relativeMid = (endAngle - midAngle + 2 * numpy.pi) % (2 * numpy.pi)
- relativeEnd = (endAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi)
- if relativeMid < relativeEnd:
- if endAngle < startAngle:
- endAngle += 2 * numpy.pi
- else:
- if endAngle > startAngle:
- endAngle -= 2 * numpy.pi
-
- return self._Geometry.create(center, start, end,
- radius, weight, startAngle, endAngle)
-
- def _createShapeFromGeometry(self, geometry):
- kind = geometry.getKind()
- if kind == "rect":
- # It is not an arc
- # but we can display it as an intermediate 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])
- elif kind == "point":
- # It is not an arc
- # but we can display it as an intermediate shape
- # NOTE: At least 2 points are expected
- points = numpy.array([geometry.startPoint, geometry.startPoint])
- elif kind == "circle":
- outerRadius = geometry.radius + geometry.weight * 0.5
- angles = numpy.arange(0, 2 * numpy.pi, 0.1)
- # 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)
- points = numpy.array(points)
- elif kind == "donut":
- innerRadius = geometry.radius - geometry.weight * 0.5
- outerRadius = geometry.radius + geometry.weight * 0.5
- angles = numpy.arange(0, 2 * numpy.pi, 0.1)
- # 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")]))
- points = numpy.array(points)
- else:
- innerRadius = geometry.radius - geometry.weight * 0.5
- outerRadius = geometry.radius + geometry.weight * 0.5
-
- 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)
-
- if kind == "camembert":
- # 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)
- elif kind == "arc":
- # 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)
- else:
- assert False
-
- points = numpy.array(points)
-
- return points
-
- def _updateShape(self):
- geometry = self._geometry
- points = self._createShapeFromGeometry(geometry)
- self.__shape.setPoints(points)
-
- index = numpy.nanargmin(points[:, 1])
- pos = points[index]
- with utils.blockSignals(self._handleLabel):
- self._handleLabel.setPosition(pos[0], pos[1])
-
- if geometry.center is None:
- movePos = geometry.startPoint * 0.34 + geometry.endPoint * 0.66
- elif (geometry.isClosed()
- or abs(geometry.endAngle - geometry.startAngle) > numpy.pi * 0.7):
- movePos = geometry.center
- else:
- moveAngle = geometry.startAngle * 0.34 + geometry.endAngle * 0.66
- vector = numpy.array([numpy.cos(moveAngle), numpy.sin(moveAngle)])
- movePos = geometry.center + geometry.radius * vector
-
- with utils.blockSignals(self._handleMove):
- self._handleMove.setPosition(*movePos)
-
- self.sigRegionChanged.emit()
-
- def getGeometry(self):
- """Returns a tuple containing the geometry of this ROI
-
- It is a symmetric function 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 represented as section of
- a circle
- """
- geometry = self._geometry
- 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
- """
- return self._geometry.isClosed()
-
- 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
- """
- return self._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
- """
- return self._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
- """
- return self._geometry.endAngle
-
- def getInnerRadius(self):
- """Returns the radius of the smaller arc used to draw this ROI.
-
- :rtype: float
- """
- geometry = self._geometry
- 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._geometry
- 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
-
- vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
- startPoint = center + vector * radius
- vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
- endPoint = center + vector * radius
-
- geometry = self._Geometry.create(center, startPoint, endPoint,
- radius, weight,
- startAngle, endAngle, closed=None)
- self._geometry = geometry
- self._updateHandles()
-
- @docstring(HandleBasedROI)
- def contains(self, position):
- # first check distance, fastest
- center = self.getCenter()
- distance = numpy.sqrt((position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2)
- is_in_distance = self.getInnerRadius() <= distance <= self.getOuterRadius()
- if not is_in_distance:
- return False
- rel_pos = position[1] - center[1], position[0] - center[0]
- angle = numpy.arctan2(*rel_pos)
- start_angle = self.getStartAngle()
- end_angle = self.getEndAngle()
-
- if start_angle < end_angle:
- # I never succeed to find a condition where start_angle < end_angle
- # so this is untested
- is_in_angle = start_angle <= angle <= end_angle
- else:
- if end_angle < -numpy.pi and angle > 0:
- angle = angle - (numpy.pi *2.0)
- is_in_angle = end_angle <= angle <= start_angle
- return is_in_angle
-
- def translate(self, x, y):
- self._geometry = self._geometry.translated(x, y)
- self._updateHandles()
-
- def _arcCurvatureMarkerConstraint(self, x, y):
- """Curvature marker remains on perpendicular bisector"""
- geometry = self._geometry
- if geometry.center is None:
- center = (geometry.startPoint + geometry.endPoint) * 0.5
- vector = geometry.startPoint - geometry.endPoint
- vector = numpy.array((vector[1], -vector[0]))
- vdist = numpy.linalg.norm(vector)
- if vdist != 0:
- normal = numpy.array((vector[1], -vector[0])) / vdist
- else:
- normal = numpy.array((0, 0))
- else:
- if geometry.isClosed():
- midAngle = geometry.startAngle + numpy.pi * 0.5
- else:
- midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
- normal = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
- center = geometry.center
- dist = numpy.dot(normal, (numpy.array((x, y)) - center))
- dist = numpy.clip(dist, geometry.radius, geometry.radius * 2)
- x, y = center + dist * 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 numpy.array((-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)
-
-
class HorizontalRangeROI(RegionOfInterest, items.LineMixIn):
"""A ROI identifying an horizontal range in a 1D plot."""
@@ -2875,6 +1509,10 @@ class HorizontalRangeROI(RegionOfInterest, items.LineMixIn):
marker = self.sender()
self.setCenter(marker.getXPosition())
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ return self.getMin() <= position[0] <= self.getMax()
+
def __str__(self):
vrange = self.getRange()
params = 'min: %f; max: %f' % vrange
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
index 5e7d65b..fd7cfae 100644
--- a/silx/gui/plot/items/scatter.py
+++ b/silx/gui/plot/items/scatter.py
@@ -332,6 +332,8 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
@docstring(ScatterVisualizationMixIn)
def setVisualizationParameter(self, parameter, value):
+ parameter = self.VisualizationParameter.from_value(parameter)
+
if super(Scatter, self).setVisualizationParameter(parameter, value):
if parameter in (self.VisualizationParameter.GRID_BOUNDS,
self.VisualizationParameter.GRID_MAJOR_ORDER,
@@ -339,8 +341,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
self.__cacheRegularGridInfo = None
if parameter in (self.VisualizationParameter.BINNED_STATISTIC_SHAPE,
- self.VisualizationParameter.BINNED_STATISTIC_FUNCTION):
- if parameter == self.VisualizationParameter.BINNED_STATISTIC_SHAPE:
+ self.VisualizationParameter.BINNED_STATISTIC_FUNCTION,
+ self.VisualizationParameter.DATA_BOUNDS_HINT):
+ if parameter in (self.VisualizationParameter.BINNED_STATISTIC_SHAPE,
+ self.VisualizationParameter.DATA_BOUNDS_HINT):
self.__cacheHistogramInfo = None # Clean-up cache
if self.getVisualization() is self.Visualization.BINNED_STATISTIC:
self._updateColormappedData()
@@ -351,7 +355,8 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
@docstring(ScatterVisualizationMixIn)
def getCurrentVisualizationParameter(self, parameter):
value = self.getVisualizationParameter(parameter)
- if value is not None:
+ if (parameter is self.VisualizationParameter.DATA_BOUNDS_HINT or
+ value is not None):
return value # Value has been set, return it
elif parameter is self.VisualizationParameter.GRID_BOUNDS:
@@ -452,6 +457,12 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
ranges = (tuple(min_max(y, finite=True)),
tuple(min_max(x, finite=True)))
+ rangesHint = self.getVisualizationParameter(
+ self.VisualizationParameter.DATA_BOUNDS_HINT)
+ if rangesHint is not None:
+ ranges = tuple((min(dataMin, hintMin), max(dataMax, hintMax))
+ for (dataMin, dataMax), (hintMin, hintMax) in zip(ranges, rangesHint))
+
points = numpy.transpose(numpy.array((y, x)))
counts, sums, bin_edges = Histogramnd(
points,
@@ -850,7 +861,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
if numpy.any(clipped):
# copy to keep original array and convert to float
- value = numpy.array(value, copy=True, dtype=numpy.float)
+ value = numpy.array(value, copy=True, dtype=numpy.float64)
value[clipped] = numpy.nan
x, y, xerror, yerror = PointsBase._logFilterData(self, xPositive, yPositive)
diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py
index 26aa03b..955dfe3 100644
--- a/silx/gui/plot/items/shape.py
+++ b/silx/gui/plot/items/shape.py
@@ -36,7 +36,9 @@ import numpy
import six
from ... import colors
-from .core import Item, ColorMixIn, FillMixIn, ItemChangedType, LineMixIn, YAxisMixIn
+from .core import (
+ Item, DataItem,
+ ColorMixIn, FillMixIn, ItemChangedType, LineMixIn, YAxisMixIn)
_logger = logging.getLogger(__name__)
@@ -154,7 +156,7 @@ class Shape(Item, ColorMixIn, FillMixIn, LineMixIn):
self._updated(ItemChangedType.LINE_BG_COLOR)
-class BoundingRect(Item, YAxisMixIn):
+class BoundingRect(DataItem, YAxisMixIn):
"""An invisible shape which enforce the plot view to display the defined
space on autoscale.
@@ -166,21 +168,10 @@ class BoundingRect(Item, YAxisMixIn):
"""
def __init__(self):
- Item.__init__(self)
+ DataItem.__init__(self)
YAxisMixIn.__init__(self)
self.__bounds = None
- def _updated(self, event=None, checkVisibility=True):
- if event in (ItemChangedType.YAXIS,
- ItemChangedType.VISIBLE,
- ItemChangedType.DATA):
- # TODO hackish data range implementation
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
- super(BoundingRect, self)._updated(event, checkVisibility)
-
def setBounds(self, rect):
"""Set the bounding box of this item in data coordinates
@@ -193,6 +184,7 @@ class BoundingRect(Item, YAxisMixIn):
if rect != self.__bounds:
self.__bounds = rect
+ self._boundsChanged()
self._updated(ItemChangedType.DATA)
def _getBounds(self):
@@ -217,7 +209,7 @@ class BoundingRect(Item, YAxisMixIn):
return self.__bounds
-class _BaseExtent(Item):
+class _BaseExtent(DataItem):
"""Base class for :class:`XAxisExtent` and :class:`YAxisExtent`.
:param str axis: Either 'x' or 'y'.
@@ -225,20 +217,10 @@ class _BaseExtent(Item):
def __init__(self, axis='x'):
assert axis in ('x', 'y')
- Item.__init__(self)
+ DataItem.__init__(self)
self.__axis = axis
self.__range = 1., 100.
- def _updated(self, event=None, checkVisibility=True):
- if event in (ItemChangedType.VISIBLE,
- ItemChangedType.DATA):
- # TODO hackish data range implementation
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
-
- super(_BaseExtent, self)._updated(event, checkVisibility)
-
def setRange(self, min_, max_):
"""Set the range of the extent of this item in data coordinates.
@@ -254,6 +236,7 @@ class _BaseExtent(Item):
if range_ != self.__range:
self.__range = range_
+ self._boundsChanged()
self._updated(ItemChangedType.DATA)
def getRange(self):