diff options
Diffstat (limited to 'src/silx/gui/plot/items')
-rw-r--r-- | src/silx/gui/plot/items/__init__.py | 53 | ||||
-rw-r--r-- | src/silx/gui/plot/items/_arc_roi.py | 878 | ||||
-rw-r--r-- | src/silx/gui/plot/items/_pick.py | 72 | ||||
-rw-r--r-- | src/silx/gui/plot/items/_roi_base.py | 835 | ||||
-rw-r--r-- | src/silx/gui/plot/items/axis.py | 560 | ||||
-rw-r--r-- | src/silx/gui/plot/items/complex.py | 386 | ||||
-rw-r--r-- | src/silx/gui/plot/items/core.py | 1733 | ||||
-rw-r--r-- | src/silx/gui/plot/items/curve.py | 325 | ||||
-rw-r--r-- | src/silx/gui/plot/items/histogram.py | 389 | ||||
-rw-r--r-- | src/silx/gui/plot/items/image.py | 641 | ||||
-rw-r--r-- | src/silx/gui/plot/items/image_aggregated.py | 229 | ||||
-rwxr-xr-x | src/silx/gui/plot/items/marker.py | 281 | ||||
-rw-r--r-- | src/silx/gui/plot/items/roi.py | 1519 | ||||
-rw-r--r-- | src/silx/gui/plot/items/scatter.py | 1002 | ||||
-rw-r--r-- | src/silx/gui/plot/items/shape.py | 287 |
15 files changed, 9190 insertions, 0 deletions
diff --git a/src/silx/gui/plot/items/__init__.py b/src/silx/gui/plot/items/__init__.py new file mode 100644 index 0000000..0fe29c2 --- /dev/null +++ b/src/silx/gui/plot/items/__init__.py @@ -0,0 +1,53 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 package provides classes that describes :class:`.PlotWidget` content. + +Instances of those classes are returned by :class:`.PlotWidget` methods that give +access to its content such as :meth:`.PlotWidget.getCurve`, :meth:`.PlotWidget.getImage`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "22/06/2017" + +from .core import (Item, DataItem, # noqa + LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa + SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa + AlphaMixIn, LineMixIn, ScatterVisualizationMixIn, # noqa + ComplexMixIn, ItemChangedType, PointsBase) # noqa +from .complex import ImageComplexData # noqa +from .curve import Curve, CurveStyle # noqa +from .histogram import Histogram # noqa +from .image import ImageBase, ImageData, ImageDataBase, ImageRgba, ImageStack, MaskImageData # noqa +from .image_aggregated import ImageDataAggregated # noqa +from .shape import Shape, BoundingRect, XAxisExtent, YAxisExtent # noqa +from .scatter import Scatter # noqa +from .marker import MarkerBase, Marker, XMarker, YMarker # noqa +from .axis import Axis, XAxis, YAxis, YRightAxis + +DATA_ITEMS = (ImageComplexData, Curve, Histogram, ImageBase, Scatter, + BoundingRect, XAxisExtent, YAxisExtent) +"""Classes of items representing data and to consider to compute data bounds. +""" diff --git a/src/silx/gui/plot/items/_arc_roi.py b/src/silx/gui/plot/items/_arc_roi.py new file mode 100644 index 0000000..23416ec --- /dev/null +++ b/src/silx/gui/plot/items/_arc_roi.py @@ -0,0 +1,878 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 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 logging +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 + + +logger = logging.getLogger(__name__) + + +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. + """ + if innerRadius > outerRadius: + logger.error("inner radius larger than outer radius") + innerRadius, outerRadius = outerRadius, innerRadius + 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/src/silx/gui/plot/items/_pick.py b/src/silx/gui/plot/items/_pick.py new file mode 100644 index 0000000..8c8e781 --- /dev/null +++ b/src/silx/gui/plot/items/_pick.py @@ -0,0 +1,72 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019-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 classes supporting item picking.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/06/2019" + +import numpy + + +class PickingResult(object): + """Class to access picking information in a :class:`PlotWidget`""" + + def __init__(self, item, indices=None): + """Init + + :param item: The picked item + :param numpy.ndarray indices: Array-like of indices of picked data. + Either 1D or 2D with dim0: data dimension and dim1: indices. + No copy is made. + """ + self._item = item + + if indices is None or len(indices) == 0: + self._indices = None + else: + # Indices is set to None if indices array is empty + indices = numpy.array(indices, copy=False, dtype=numpy.int64) + self._indices = None if indices.size == 0 else indices + + def getItem(self): + """Returns the item this results corresponds to.""" + return self._item + + def getIndices(self, copy=True): + """Returns indices of picked data. + + If data is 1D, it returns a numpy.ndarray, otherwise + it returns a tuple with as many numpy.ndarray as there are + dimensions in the data. + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray,List[numpy.ndarray]] + """ + if self._indices is None: + return None + indices = numpy.array(self._indices, copy=copy) + return indices if indices.ndim == 1 else tuple(indices) diff --git a/src/silx/gui/plot/items/_roi_base.py b/src/silx/gui/plot/items/_roi_base.py new file mode 100644 index 0000000..3eb6cf4 --- /dev/null +++ b/src/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/src/silx/gui/plot/items/axis.py b/src/silx/gui/plot/items/axis.py new file mode 100644 index 0000000..c73323e --- /dev/null +++ b/src/silx/gui/plot/items/axis.py @@ -0,0 +1,560 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 the class for axes of the :class:`PlotWidget`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "22/11/2018" + +import datetime as dt +import enum +import logging + +import dateutil.tz +import numpy + +from ... import qt +from .. import _utils + + +_logger = logging.getLogger(__name__) + + +class TickMode(enum.Enum): + """Determines if ticks are regular number or datetimes.""" + DEFAULT = 0 # Ticks are regular numbers + TIME_SERIES = 1 # Ticks are datetime objects + + +class Axis(qt.QObject): + """This class describes and controls a plot axis. + + Note: This is an abstract class. + """ + # States are half-stored on the backend of the plot, and half-stored on this + # object. + # TODO It would be good to store all the states of an axis in this object. + # i.e. vmin and vmax + + LINEAR = "linear" + """Constant defining a linear scale""" + + LOGARITHMIC = "log" + """Constant defining a logarithmic scale""" + + _SCALES = set([LINEAR, LOGARITHMIC]) + + sigInvertedChanged = qt.Signal(bool) + """Signal emitted when axis orientation has changed""" + + sigScaleChanged = qt.Signal(str) + """Signal emitted when axis scale has changed""" + + _sigLogarithmicChanged = qt.Signal(bool) + """Signal emitted when axis scale has changed to or from logarithmic""" + + sigAutoScaleChanged = qt.Signal(bool) + """Signal emitted when axis autoscale has changed""" + + sigLimitsChanged = qt.Signal(float, float) + """Signal emitted when axis limits have changed""" + + def __init__(self, plot): + """Constructor + + :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this + axis + """ + qt.QObject.__init__(self, parent=plot) + self._scale = self.LINEAR + self._isAutoScale = True + # Store default labels provided to setGraph[X|Y]Label + self._defaultLabel = '' + # Store currently displayed labels + # Current label can differ from input one with active curve handling + self._currentLabel = '' + + def _getPlot(self): + """Returns the PlotWidget this Axis belongs to. + + :rtype: PlotWidget + """ + plot = self.parent() + if plot is None: + raise RuntimeError("Axis no longer attached to a PlotWidget") + return plot + + def _getBackend(self): + """Returns the backend + + :rtype: BackendBase + """ + return self._getPlot()._backend + + def getLimits(self): + """Get the limits of this axis. + + :return: Minimum and maximum values of this axis as tuple + """ + return self._internalGetLimits() + + def setLimits(self, vmin, vmax): + """Set this axis limits. + + :param float vmin: minimum axis value + :param float vmax: maximum axis value + """ + vmin, vmax = self._checkLimits(vmin, vmax) + if self.getLimits() == (vmin, vmax): + return + + self._internalSetLimits(vmin, vmax) + self._getPlot()._setDirtyPlot() + + self._emitLimitsChanged() + + def _emitLimitsChanged(self): + """Emit axis sigLimitsChanged and PlotWidget limitsChanged event""" + vmin, vmax = self.getLimits() + self.sigLimitsChanged.emit(vmin, vmax) + self._getPlot()._notifyLimitsChanged(emitSignal=False) + + def _checkLimits(self, vmin, vmax): + """Makes sure axis range is not empty and within supported range. + + :param float vmin: Min axis value + :param float vmax: Max axis value + :return: (min, max) making sure min < max + :rtype: 2-tuple of float + """ + return _utils.checkAxisLimits( + vmin, vmax, isLog=self._isLogarithmic(), name=self._defaultLabel) + + def isInverted(self): + """Return True if the axis is inverted (top to bottom for the y-axis), + False otherwise. It is always False for the X axis. + + :rtype: bool + """ + return False + + def setInverted(self, isInverted): + """Set the axis orientation. + + This is only available for the Y axis. + + :param bool flag: True for Y axis going from top to bottom, + False for Y axis going from bottom to top + """ + if isInverted == self.isInverted(): + return + raise NotImplementedError() + + def getLabel(self): + """Return the current displayed label of this axis. + + :param str axis: The Y axis for which to get the label (left or right) + :rtype: str + """ + return self._currentLabel + + def setLabel(self, label): + """Set the label displayed on the plot for this axis. + + The provided label can be temporarily replaced by the label of the + active curve if any. + + :param str label: The axis label + """ + self._defaultLabel = label + self._setCurrentLabel(label) + self._getPlot()._setDirtyPlot() + + def _setCurrentLabel(self, label): + """Define the label currently displayed. + + If the label is None or empty the default label is used. + + :param str label: Currently displayed label + """ + if label is None or label == '': + label = self._defaultLabel + if label is None: + label = '' + self._currentLabel = label + self._internalSetCurrentLabel(label) + + def getScale(self): + """Return the name of the scale used by this axis. + + :rtype: str + """ + return self._scale + + def setScale(self, scale): + """Set the scale to be used by this axis. + + :param str scale: Name of the scale ("log", or "linear") + """ + assert(scale in self._SCALES) + if self._scale == scale: + return + + # For the backward compatibility signal + emitLog = self._scale == self.LOGARITHMIC or scale == self.LOGARITHMIC + + self._scale = scale + + # TODO hackish way of forcing update of curves and images + plot = self._getPlot() + for item in plot.getItems(): + item._updated() + plot._invalidateDataRange() + + if scale == self.LOGARITHMIC: + self._internalSetLogarithmic(True) + elif scale == self.LINEAR: + self._internalSetLogarithmic(False) + else: + raise ValueError("Scale %s unsupported" % scale) + + plot._forceResetZoom() + + self.sigScaleChanged.emit(self._scale) + if emitLog: + self._sigLogarithmicChanged.emit(self._scale == self.LOGARITHMIC) + + def _isLogarithmic(self): + """Return True if this axis scale is logarithmic, False if linear. + + :rtype: bool + """ + return self._scale == self.LOGARITHMIC + + def _setLogarithmic(self, flag): + """Set the scale of this axes (either linear or logarithmic). + + :param bool flag: True to use a logarithmic scale, False for linear. + """ + flag = bool(flag) + self.setScale(self.LOGARITHMIC if flag else self.LINEAR) + + def getTimeZone(self): + """Sets tzinfo that is used if this axis plots date times. + + None means the datetimes are interpreted as local time. + + :rtype: datetime.tzinfo of None. + """ + raise NotImplementedError() + + def setTimeZone(self, tz): + """Sets tzinfo that is used if this axis' tickMode is TIME_SERIES + + The tz must be a descendant of the datetime.tzinfo class, "UTC" or None. + Use None to let the datetimes be interpreted as local time. + Use the string "UTC" to let the date datetimes be in UTC time. + + :param tz: datetime.tzinfo, "UTC" or None. + """ + raise NotImplementedError() + + def getTickMode(self): + """Determines if axis ticks are number or datetimes. + + :rtype: TickMode enum. + """ + raise NotImplementedError() + + def setTickMode(self, tickMode): + """Determines if axis ticks are number or datetimes. + + :param TickMode tickMode: tick mode enum. + """ + raise NotImplementedError() + + def isAutoScale(self): + """Return True if axis is automatically adjusting its limits. + + :rtype: bool + """ + return self._isAutoScale + + def setAutoScale(self, flag=True): + """Set the axis limits adjusting behavior of :meth:`resetZoom`. + + :param bool flag: True to resize limits automatically, + False to disable it. + """ + self._isAutoScale = bool(flag) + self.sigAutoScaleChanged.emit(self._isAutoScale) + + def _setLimitsConstraints(self, minPos=None, maxPos=None): + raise NotImplementedError() + + def setLimitsConstraints(self, minPos=None, maxPos=None): + """ + Set a constraint on the position of the axes. + + :param float minPos: Minimum allowed axis value. + :param float maxPos: Maximum allowed axis value. + :return: True if the constaints was updated + :rtype: bool + """ + updated = self._setLimitsConstraints(minPos, maxPos) + if updated: + plot = self._getPlot() + xMin, xMax = plot.getXAxis().getLimits() + yMin, yMax = plot.getYAxis().getLimits() + y2Min, y2Max = plot.getYAxis('right').getLimits() + plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) + return updated + + def _setRangeConstraints(self, minRange=None, maxRange=None): + raise NotImplementedError() + + def setRangeConstraints(self, minRange=None, maxRange=None): + """ + Set a constraint on the position of the axes. + + :param float minRange: Minimum allowed left-to-right span across the + view + :param float maxRange: Maximum allowed left-to-right span across the + view + :return: True if the constaints was updated + :rtype: bool + """ + updated = self._setRangeConstraints(minRange, maxRange) + if updated: + plot = self._getPlot() + xMin, xMax = plot.getXAxis().getLimits() + yMin, yMax = plot.getYAxis().getLimits() + y2Min, y2Max = plot.getYAxis('right').getLimits() + plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) + return updated + + +class XAxis(Axis): + """Axis class defining primitives for the X axis""" + + # TODO With some changes on the backend, it will be able to remove all this + # specialised implementations (prefixel by '_internal') + + def getTimeZone(self): + return self._getBackend().getXAxisTimeZone() + + def setTimeZone(self, tz): + if isinstance(tz, str) and tz.upper() == "UTC": + tz = dateutil.tz.tzutc() + elif not(tz is None or isinstance(tz, dt.tzinfo)): + raise TypeError("tz must be a dt.tzinfo object, None or 'UTC'.") + + self._getBackend().setXAxisTimeZone(tz) + self._getPlot()._setDirtyPlot() + + def getTickMode(self): + if self._getBackend().isXAxisTimeSeries(): + return TickMode.TIME_SERIES + else: + return TickMode.DEFAULT + + def setTickMode(self, tickMode): + if tickMode == TickMode.DEFAULT: + self._getBackend().setXAxisTimeSeries(False) + elif tickMode == TickMode.TIME_SERIES: + self._getBackend().setXAxisTimeSeries(True) + else: + raise ValueError("Unexpected TickMode: {}".format(tickMode)) + + def _internalSetCurrentLabel(self, label): + self._getBackend().setGraphXLabel(label) + + def _internalGetLimits(self): + return self._getBackend().getGraphXLimits() + + def _internalSetLimits(self, xmin, xmax): + self._getBackend().setGraphXLimits(xmin, xmax) + + def _internalSetLogarithmic(self, flag): + self._getBackend().setXAxisLogarithmic(flag) + + def _setLimitsConstraints(self, minPos=None, maxPos=None): + constrains = self._getPlot()._getViewConstraints() + updated = constrains.update(xMin=minPos, xMax=maxPos) + return updated + + def _setRangeConstraints(self, minRange=None, maxRange=None): + constrains = self._getPlot()._getViewConstraints() + updated = constrains.update(minXRange=minRange, maxXRange=maxRange) + return updated + + +class YAxis(Axis): + """Axis class defining primitives for the Y axis""" + + # TODO With some changes on the backend, it will be able to remove all this + # specialised implementations (prefixel by '_internal') + + def _internalSetCurrentLabel(self, label): + self._getBackend().setGraphYLabel(label, axis='left') + + def _internalGetLimits(self): + return self._getBackend().getGraphYLimits(axis='left') + + def _internalSetLimits(self, ymin, ymax): + self._getBackend().setGraphYLimits(ymin, ymax, axis='left') + + def _internalSetLogarithmic(self, flag): + self._getBackend().setYAxisLogarithmic(flag) + + def setInverted(self, flag=True): + """Set the axis orientation. + + This is only available for the Y axis. + + :param bool flag: True for Y axis going from top to bottom, + False for Y axis going from bottom to top + """ + flag = bool(flag) + if self.isInverted() == flag: + return + self._getBackend().setYAxisInverted(flag) + self._getPlot()._setDirtyPlot() + self.sigInvertedChanged.emit(flag) + + def isInverted(self): + """Return True if the axis is inverted (top to bottom for the y-axis), + False otherwise. It is always False for the X axis. + + :rtype: bool + """ + return self._getBackend().isYAxisInverted() + + def _setLimitsConstraints(self, minPos=None, maxPos=None): + constrains = self._getPlot()._getViewConstraints() + updated = constrains.update(yMin=minPos, yMax=maxPos) + return updated + + def _setRangeConstraints(self, minRange=None, maxRange=None): + constrains = self._getPlot()._getViewConstraints() + updated = constrains.update(minYRange=minRange, maxYRange=maxRange) + return updated + + +class YRightAxis(Axis): + """Proxy axis for the secondary Y axes. It manages it own label and limit + but share the some state like scale and direction with the main axis.""" + + # TODO With some changes on the backend, it will be able to remove all this + # specialised implementations (prefixel by '_internal') + + def __init__(self, plot, mainAxis): + """Constructor + + :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this + axis + :param Axis mainAxis: Axis which sharing state with this axis + """ + Axis.__init__(self, plot) + self.__mainAxis = mainAxis + + @property + def sigInvertedChanged(self): + """Signal emitted when axis orientation has changed""" + return self.__mainAxis.sigInvertedChanged + + @property + def sigScaleChanged(self): + """Signal emitted when axis scale has changed""" + return self.__mainAxis.sigScaleChanged + + @property + def _sigLogarithmicChanged(self): + """Signal emitted when axis scale has changed to or from logarithmic""" + return self.__mainAxis._sigLogarithmicChanged + + @property + def sigAutoScaleChanged(self): + """Signal emitted when axis autoscale has changed""" + return self.__mainAxis.sigAutoScaleChanged + + def _internalSetCurrentLabel(self, label): + self._getBackend().setGraphYLabel(label, axis='right') + + def _internalGetLimits(self): + return self._getBackend().getGraphYLimits(axis='right') + + def _internalSetLimits(self, ymin, ymax): + self._getBackend().setGraphYLimits(ymin, ymax, axis='right') + + def setInverted(self, flag=True): + """Set the Y axis orientation. + + :param bool flag: True for Y axis going from top to bottom, + False for Y axis going from bottom to top + """ + return self.__mainAxis.setInverted(flag) + + def isInverted(self): + """Return True if Y axis goes from top to bottom, False otherwise.""" + return self.__mainAxis.isInverted() + + def getScale(self): + """Return the name of the scale used by this axis. + + :rtype: str + """ + return self.__mainAxis.getScale() + + def setScale(self, scale): + """Set the scale to be used by this axis. + + :param str scale: Name of the scale ("log", or "linear") + """ + self.__mainAxis.setScale(scale) + + def _isLogarithmic(self): + """Return True if Y axis scale is logarithmic, False if linear.""" + return self.__mainAxis._isLogarithmic() + + def _setLogarithmic(self, flag): + """Set the Y axes scale (either linear or logarithmic). + + :param bool flag: True to use a logarithmic scale, False for linear. + """ + return self.__mainAxis._setLogarithmic(flag) + + def isAutoScale(self): + """Return True if Y axes are automatically adjusting its limits.""" + return self.__mainAxis.isAutoScale() + + def setAutoScale(self, flag=True): + """Set the Y axis limits adjusting behavior of :meth:`PlotWidget.resetZoom`. + + :param bool flag: True to resize limits automatically, + False to disable it. + """ + return self.__mainAxis.setAutoScale(flag) diff --git a/src/silx/gui/plot/items/complex.py b/src/silx/gui/plot/items/complex.py new file mode 100644 index 0000000..abb64ad --- /dev/null +++ b/src/silx/gui/plot/items/complex.py @@ -0,0 +1,386 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 the :class:`ImageComplexData` of the :class:`Plot`. +""" + +from __future__ import absolute_import + +__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] +__license__ = "MIT" +__date__ = "14/06/2018" + + +import logging + +import numpy + +from ....utils.proxy import docstring +from ....utils.deprecation import deprecated +from ...colors import Colormap +from .core import ColormapMixIn, ComplexMixIn, ItemChangedType +from .image import ImageBase + + +_logger = logging.getLogger(__name__) + + +# Complex colormap functions + +def _phase2rgb(colormap, data): + """Creates RGBA image with colour-coded phase. + + :param Colormap colormap: The colormap to use + :param numpy.ndarray data: The data to convert + :return: Array of RGBA colors + :rtype: numpy.ndarray + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + phase = numpy.angle(data) + return colormap.applyToData(phase) + + +def _complex2rgbalog(phaseColormap, data, amin=0., dlogs=2, smax=None): + """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha. + + :param Colormap phaseColormap: Colormap to use for the phase + :param numpy.ndarray data: the complex data array to convert to RGBA + :param float amin: the minimum value for the alpha channel + :param float dlogs: amplitude range displayed, in log10 units + :param float smax: + if specified, all values above max will be displayed with an alpha=1 + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + rgba = _phase2rgb(phaseColormap, data) + sabs = numpy.absolute(data) + if smax is not None: + sabs[sabs > smax] = smax + a = numpy.log10(sabs + 1e-20) + a -= a.max() - dlogs # display dlogs orders of magnitude + rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0)) + return rgba + + +def _complex2rgbalin(phaseColormap, data, gamma=1.0, smax=None): + """Returns RGBA colors: colour-coded phase and linear amplitude in alpha. + + :param Colormap phaseColormap: Colormap to use for the phase + :param numpy.ndarray data: + :param float gamma: Optional exponent gamma applied to the amplitude + :param float smax: + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + rgba = _phase2rgb(phaseColormap, data) + a = numpy.absolute(data) + if smax is not None: + a[a > smax] = smax + a /= a.max() + rgba[..., 3] = 255 * a**gamma + return rgba + + +class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): + """Specific plot item to force colormap when using complex colormap. + + This is returning the specific colormap when displaying + colored phase + amplitude. + """ + + _SUPPORTED_COMPLEX_MODES = ( + ComplexMixIn.ComplexMode.ABSOLUTE, + ComplexMixIn.ComplexMode.PHASE, + ComplexMixIn.ComplexMode.REAL, + ComplexMixIn.ComplexMode.IMAGINARY, + ComplexMixIn.ComplexMode.AMPLITUDE_PHASE, + ComplexMixIn.ComplexMode.LOG10_AMPLITUDE_PHASE, + ComplexMixIn.ComplexMode.SQUARE_AMPLITUDE) + """Overrides supported ComplexMode""" + + def __init__(self): + ImageBase.__init__(self, numpy.zeros((0, 0), dtype=numpy.complex64)) + ColormapMixIn.__init__(self) + ComplexMixIn.__init__(self) + self._dataByModesCache = {} + self._amplitudeRangeInfo = None, 2 + + # Use default from ColormapMixIn + colormap = super(ImageComplexData, self).getColormap() + + phaseColormap = Colormap( + name='hsv', + vmin=-numpy.pi, + vmax=numpy.pi) + + self._colormaps = { # Default colormaps for all modes + self.ComplexMode.ABSOLUTE: colormap, + self.ComplexMode.PHASE: phaseColormap, + self.ComplexMode.REAL: colormap, + self.ComplexMode.IMAGINARY: colormap, + self.ComplexMode.AMPLITUDE_PHASE: phaseColormap, + self.ComplexMode.LOG10_AMPLITUDE_PHASE: phaseColormap, + self.ComplexMode.SQUARE_AMPLITUDE: colormap, + } + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if not self._isPlotLinear(plot): + # Do not render with non linear scales + return None + + mode = self.getComplexMode() + if mode in (self.ComplexMode.AMPLITUDE_PHASE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE): + # For those modes, compute RGBA image here + colormap = None + data = self.getRgbaImageData(copy=False) + else: + colormap = self.getColormap() + if colormap.isAutoscale(): + # Avoid backend to compute autoscale: use item cache + colormap = colormap.copy() + colormap.setVRange(*colormap.getColormapRange(self)) + + data = self.getData(copy=False) + + if data.size == 0: + return None # No data to display + + return backend.addImage(data, + origin=self.getOrigin(), + scale=self.getScale(), + colormap=colormap, + alpha=self.getAlpha()) + + @docstring(ComplexMixIn) + def setComplexMode(self, mode): + changed = super(ImageComplexData, self).setComplexMode(mode) + if changed: + self._valueDataChanged() + + # Backward compatibility + self._updated(ItemChangedType.VISUALIZATION_MODE) + + # Update ColormapMixIn colormap + colormap = self._colormaps[self.getComplexMode()] + if colormap is not super(ImageComplexData, self).getColormap(): + super(ImageComplexData, self).setColormap(colormap) + + # Send data updated as value returned by getData has changed + self._updated(ItemChangedType.DATA) + return changed + + def _setAmplitudeRangeInfo(self, max_=None, delta=2): + """Set the amplitude range to display for 'log10_amplitude_phase' mode. + + :param max_: Max of the amplitude range. + If None it autoscales to data max. + :param float delta: Delta range in log10 to display + """ + self._amplitudeRangeInfo = max_, float(delta) + self._updated(ItemChangedType.VISUALIZATION_MODE) + + def _getAmplitudeRangeInfo(self): + """Returns the amplitude range to use for 'log10_amplitude_phase' mode. + + :return: (max, delta), if max is None, then it autoscales to data max + :rtype: 2-tuple""" + return self._amplitudeRangeInfo + + def setColormap(self, colormap, mode=None): + """Set the colormap for this specific mode. + + :param ~silx.gui.colors.Colormap colormap: The colormap + :param Union[ComplexMode,str] mode: + If specified, set the colormap of this specific mode. + Default: current mode. + """ + if mode is None: + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) + + self._colormaps[mode] = colormap + if mode is self.getComplexMode(): + super(ImageComplexData, self).setColormap(colormap) + else: + self._updated(ItemChangedType.COLORMAP) + + def getColormap(self, mode=None): + """Get the colormap for the (current) mode. + + :param Union[ComplexMode,str] mode: + If specified, get the colormap of this specific mode. + Default: current mode. + :rtype: ~silx.gui.colors.Colormap + """ + if mode is None: + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) + + return self._colormaps[mode] + + def setData(self, data, copy=True): + """"Set the image complex data + + :param numpy.ndarray data: 2D array of complex with 2 dimensions (h, w) + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + if not numpy.issubdtype(data.dtype, numpy.complexfloating): + _logger.warning( + 'Image is not complex, converting it to complex to plot it.') + data = numpy.array(data, dtype=numpy.complex64) + + # Compute current mode data and set colormap data + mode = self.getComplexMode() + dataForMode = self.__convertComplexData(data, self.getComplexMode()) + self._dataByModesCache = {mode: dataForMode} + + super().setData(data) + + def _updated(self, event=None, checkVisibility=True): + # Synchronizes colormapped data if changed + # ItemChangedType.COMPLEX_MODE triggers ItemChangedType.DATA + # No need to handle it twice. + if event in (ItemChangedType.DATA, ItemChangedType.MASK): + # Color-mapped data is NOT the `getValueData` for some modes + if self.getComplexMode() in ( + self.ComplexMode.AMPLITUDE_PHASE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE): + data = self.getData(copy=False, mode=self.ComplexMode.PHASE) + mask = self.getMaskData(copy=False) + if mask is not None: + data = numpy.copy(data) + data[mask != 0] = numpy.nan + else: + data = self.getValueData(copy=False) + self._setColormappedData(data, copy=False) + super()._updated(event=event, checkVisibility=checkVisibility) + + def getComplexData(self, copy=True): + """Returns the image complex data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray of complex + """ + return super().getData(copy=copy) + + def __convertComplexData(self, data, mode): + """Convert complex data to given mode. + + :param numpy.ndarray data: + :param Union[ComplexMode,str] mode: + :rtype: numpy.ndarray of float + """ + if mode is self.ComplexMode.PHASE: + return numpy.angle(data) + elif mode is self.ComplexMode.REAL: + return numpy.real(data) + elif mode is self.ComplexMode.IMAGINARY: + return numpy.imag(data) + elif mode in (self.ComplexMode.ABSOLUTE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE, + self.ComplexMode.AMPLITUDE_PHASE): + return numpy.absolute(data) + elif mode is self.ComplexMode.SQUARE_AMPLITUDE: + return numpy.absolute(data) ** 2 + else: + _logger.error( + 'Unsupported conversion mode: %s, fallback to absolute', + str(mode)) + return numpy.absolute(data) + + def getData(self, copy=True, mode=None): + """Returns the image data corresponding to (current) mode. + + The returned data is always floats, to get the complex data, use + :meth:`getComplexData`. + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :param Union[ComplexMode,str] mode: + If specified, get data corresponding to the mode. + Default: Current mode. + :rtype: numpy.ndarray of float + """ + if mode is None: + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) + + if mode not in self._dataByModesCache: + self._dataByModesCache[mode] = self.__convertComplexData( + self.getComplexData(copy=False), mode) + + return numpy.array(self._dataByModesCache[mode], copy=copy) + + def getRgbaImageData(self, copy=True, mode=None): + """Get the displayed RGB(A) image for (current) mode + + :param bool copy: Ignored for this class + :param Union[ComplexMode,str] mode: + If specified, get data corresponding to the mode. + Default: Current mode. + :rtype: numpy.ndarray of uint8 of shape (height, width, 4) + """ + if mode is None: + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) + + colormap = self.getColormap(mode=mode) + if mode is self.ComplexMode.AMPLITUDE_PHASE: + data = self.getComplexData(copy=False) + return _complex2rgbalin(colormap, data) + elif mode is self.ComplexMode.LOG10_AMPLITUDE_PHASE: + data = self.getComplexData(copy=False) + max_, delta = self._getAmplitudeRangeInfo() + return _complex2rgbalog(colormap, data, dlogs=delta, smax=max_) + else: + data = self.getData(copy=False, mode=mode) + return colormap.applyToData(data) + + # Backward compatibility + + Mode = ComplexMixIn.ComplexMode + + @deprecated(replacement='setComplexMode', since_version='0.11.0') + def setVisualizationMode(self, mode): + return self.setComplexMode(mode) + + @deprecated(replacement='getComplexMode', since_version='0.11.0') + def getVisualizationMode(self): + return self.getComplexMode() diff --git a/src/silx/gui/plot/items/core.py b/src/silx/gui/plot/items/core.py new file mode 100644 index 0000000..fa3b8cf --- /dev/null +++ b/src/silx/gui/plot/items/core.py @@ -0,0 +1,1733 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 the base class for items of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/12/2020" + +import collections +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc +from copy import deepcopy +import logging +import enum +from typing import Optional, Tuple +import warnings +import weakref + +import numpy + +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 +from ._pick import PickingResult + +from silx import config + +_logger = logging.getLogger(__name__) + + +@enum.unique +class ItemChangedType(enum.Enum): + """Type of modification provided by :attr:`Item.sigItemChanged` signal.""" + # Private setters and setInfo are not emitting sigItemChanged signal. + # Signals to consider: + # COLORMAP_SET emitted when setColormap is called but not forward colormap object signal + # CURRENT_COLOR_CHANGED emitted current color changed because highlight changed, + # highlighted color changed or color changed depending on hightlight state. + + VISIBLE = 'visibleChanged' + """Item's visibility changed flag.""" + + ZVALUE = 'zValueChanged' + """Item's Z value changed flag.""" + + COLORMAP = 'colormapChanged' # Emitted when set + forward events from the colormap object + """Item's colormap changed flag. + + This is emitted both when setting a new colormap and + when the current colormap object is updated. + """ + + SYMBOL = 'symbolChanged' + """Item's symbol changed flag.""" + + SYMBOL_SIZE = 'symbolSizeChanged' + """Item's symbol size changed flag.""" + + LINE_WIDTH = 'lineWidthChanged' + """Item's line width changed flag.""" + + LINE_STYLE = 'lineStyleChanged' + """Item's line style changed flag.""" + + COLOR = 'colorChanged' + """Item's color changed flag.""" + + LINE_BG_COLOR = 'lineBgColorChanged' + """Item's line background color changed flag.""" + + YAXIS = 'yAxisChanged' + """Item's Y axis binding changed flag.""" + + FILL = 'fillChanged' + """Item's fill changed flag.""" + + ALPHA = 'alphaChanged' + """Item's transparency alpha changed flag.""" + + DATA = 'dataChanged' + """Item's data changed flag""" + + MASK = 'maskChanged' + """Item's mask changed flag""" + + HIGHLIGHTED = 'highlightedChanged' + """Item's highlight state changed flag.""" + + HIGHLIGHTED_COLOR = 'highlightedColorChanged' + """Deprecated, use HIGHLIGHTED_STYLE instead.""" + + HIGHLIGHTED_STYLE = 'highlightedStyleChanged' + """Item's highlighted style changed flag.""" + + SCALE = 'scaleChanged' + """Item's scale changed flag.""" + + TEXT = 'textChanged' + """Item's text changed flag.""" + + POSITION = 'positionChanged' + """Item's position changed flag. + + This is emitted when a marker position changed and + when an image origin changed. + """ + + OVERLAY = 'overlayChanged' + """Item's overlay state changed flag.""" + + VISUALIZATION_MODE = 'visualizationModeChanged' + """Item's visualization mode changed flag.""" + + COMPLEX_MODE = 'complexModeChanged' + """Item's complex data visualization mode changed flag.""" + + NAME = 'nameChanged' + """Item's name changed flag.""" + + EDITABLE = 'editableChanged' + """Item's editable state changed flags.""" + + SELECTABLE = 'selectableChanged' + """Item's selectable state changed flags.""" + + +class Item(qt.QObject): + """Description of an item of the plot""" + + _DEFAULT_Z_LAYER = 0 + """Default layer for overlay rendering""" + + _DEFAULT_SELECTABLE = False + """Default selectable state of items""" + + sigItemChanged = qt.Signal(object) + """Signal emitted when the item has changed. + + It provides a flag describing which property of the item has changed. + 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 + self._plotRef = None + self._visible = True + self._selectable = self._DEFAULT_SELECTABLE + self._z = self._DEFAULT_Z_LAYER + self._info = None + self._xlabel = None + self._ylabel = None + self.__name = '' + + self.__visibleBoundsTracking = False + self.__previousVisibleBounds = None + + self._backendRenderer = None + + def getPlot(self): + """Returns the ~silx.gui.plot.PlotWidget this item belongs to. + + :rtype: Union[~silx.gui.plot.PlotWidget,None] + """ + return None if self._plotRef is None else self._plotRef() + + def _setPlot(self, plot): + """Set the plot this item belongs to. + + WARNING: This should only be called from the Plot. + + :param Union[~silx.gui.plot.PlotWidget,None] plot: The Plot instance. + """ + 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 + """Returns the bounding box of this item in data coordinates + + :returns: (xmin, xmax, ymin, ymax) or None + :rtype: 4-tuple of float or None + """ + return self._getBounds() + + def _getBounds(self): + """:meth:`getBounds` implementation to override by sub-class""" + return None + + def isVisible(self): + """True if item is visible, False otherwise + + :rtype: bool + """ + return self._visible + + def setVisible(self, visible): + """Set visibility of item. + + :param bool visible: True to display it, False otherwise + """ + visible = bool(visible) + if visible != self._visible: + self._visible = visible + # When visibility has changed, always mark as dirty + self._updated(ItemChangedType.VISIBLE, + checkVisibility=False) + + def isOverlay(self): + """Return true if item is drawn as an overlay. + + :rtype: bool + """ + return False + + def getName(self): + """Returns the name of the item which is used as legend. + + :rtype: str + """ + return self.__name + + def setName(self, name): + """Set the name of the item which is used as legend. + + :param str name: New name of the item + :raises RuntimeError: If item belongs to a PlotWidget. + """ + name = str(name) + if self.__name != name: + if self.getPlot() is not None: + raise RuntimeError( + "Cannot change name while item is in a PlotWidget") + + self.__name = name + self._updated(ItemChangedType.NAME) + + def getLegend(self): # Replaced by getName for API consistency + return self.getName() + + @deprecated(replacement='setName', since_version='0.13') + def _setLegend(self, legend): + legend = str(legend) if legend is not None else '' + self.setName(legend) + + def isSelectable(self): + """Returns true if item is selectable (bool)""" + return self._selectable + + def _setSelectable(self, selectable): # TODO support update + """Set whether item is selectable or not. + + This is private for now as change is not handled. + + :param bool selectable: True to make item selectable + """ + self._selectable = bool(selectable) + + def getZValue(self): + """Returns the layer on which to draw this item (int)""" + return self._z + + def setZValue(self, z): + z = int(z) if z is not None else self._DEFAULT_Z_LAYER + if z != self._z: + self._z = z + self._updated(ItemChangedType.ZVALUE) + + def getInfo(self, copy=True): + """Returns the info associated to this item + + :param bool copy: True to get a deepcopy, False otherwise. + """ + return deepcopy(self._info) if copy else self._info + + def setInfo(self, info, copy=True): + if copy: + 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). + + This also triggers Plot.replot. + + :param event: The event to send to :attr:`sigItemChanged` signal. + :param bool checkVisibility: True to only mark as dirty if visible, + False to always mark as dirty. + """ + if not checkVisibility or self.isVisible(): + if not self._dirty: + self._dirty = True + # TODO: send event instead of explicit call + plot = self.getPlot() + if plot is not None: + plot._itemRequiresUpdate(self) + if event is not None: + self.sigItemChanged.emit(event) + + def _update(self, backend): + """Called by Plot to update the backend for this item. + + This is meant to be called asynchronously from _updated. + This optimizes the number of call to _update. + + :param backend: The backend to update + """ + if self._dirty: + # Remove previous renderer from backend if any + self._removeBackendRenderer(backend) + + # If not visible, do not add renderer to backend + if self.isVisible(): + self._backendRenderer = self._addBackendRenderer(backend) + + self._dirty = False + + def _addBackendRenderer(self, backend): + """Override in subclass to add specific backend renderer. + + :param BackendBase backend: The backend to update + :return: The renderer handle to store or None if no renderer in backend + """ + return None + + def _removeBackendRenderer(self, backend): + """Override in subclass to remove specific backend renderer. + + :param BackendBase backend: The backend to update + """ + if self._backendRenderer is not None: + backend.remove(self._backendRenderer) + self._backendRenderer = None + + def pick(self, x, y): + """Run picking test on this item + + :param float x: The x pixel coord where to pick. + :param float y: The y pixel coord where to pick. + :return: None if not picked, else the picked position information + :rtype: Union[None,PickingResult] + """ + if not self.isVisible() or self._backendRenderer is None: + return None + plot = self.getPlot() + if plot is None: + return None + + indices = plot._backend.pickItem(x, y, self._backendRenderer) + if indices is None: + return None + else: + 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): + """Base class for Item mix-in""" + + def _updated(self, event=None, checkVisibility=True): + """This is implemented in :class:`Item`. + + Mark the item as dirty (i.e., needing update). + This also triggers Plot.replot. + + :param event: The event to send to :attr:`sigItemChanged` signal. + :param bool checkVisibility: True to only mark as dirty if visible, + False to always mark as dirty. + """ + raise RuntimeError( + "Issue with Mix-In class inheritance order") + + +class LabelsMixIn(ItemMixInBase): + """Mix-in class for items with x and y labels + + Setters are private, otherwise it needs to check the plot + current active curve and access the internal current labels. + """ + + def __init__(self): + self._xlabel = None + self._ylabel = None + + def getXLabel(self): + """Return the X axis label associated to this curve + + :rtype: str or None + """ + return self._xlabel + + def _setXLabel(self, label): + """Set the X axis label associated with this curve + + :param str label: The X axis label + """ + self._xlabel = str(label) + + def getYLabel(self): + """Return the Y axis label associated to this curve + + :rtype: str or None + """ + return self._ylabel + + def _setYLabel(self, label): + """Set the Y axis label associated with this curve + + :param str label: The Y axis label + """ + self._ylabel = str(label) + + +class DraggableMixIn(ItemMixInBase): + """Mix-in class for draggable items""" + + def __init__(self): + self._draggable = False + + def isDraggable(self): + """Returns true if image is draggable + + :rtype: bool + """ + return self._draggable + + def _setDraggable(self, draggable): # TODO support update + """Set if image is draggable or not. + + This is private for not as it does not support update. + + :param bool draggable: + """ + self._draggable = bool(draggable) + + def drag(self, from_, to): + """Perform a drag of the item. + + :param List[float] from_: (x, y) previous position in data coordinates + :param List[float] to: (x, y) current position in data coordinates + """ + raise NotImplementedError("Must be implemented in subclass") + + +class ColormapMixIn(ItemMixInBase): + """Mix-in class for items with colormap""" + + def __init__(self): + self._colormap = Colormap() + self._colormap.sigChanged.connect(self._colormapChanged) + self.__data = None + self.__cacheColormapRange = {} # Store {normalization: range} + + def getColormap(self): + """Return the used colormap""" + return self._colormap + + def setColormap(self, colormap): + """Set the colormap of this item + + :param silx.gui.colors.Colormap colormap: colormap description + """ + if self._colormap is colormap: + return + if isinstance(colormap, dict): + colormap = Colormap._fromDict(colormap) + + if self._colormap is not None: + self._colormap.sigChanged.disconnect(self._colormapChanged) + self._colormap = colormap + if self._colormap is not None: + self._colormap.sigChanged.connect(self._colormapChanged) + self._colormapChanged() + + def _colormapChanged(self): + """Handle updates of the colormap""" + self._updated(ItemChangedType.COLORMAP) + + def _setColormappedData(self, data, copy=True, + min_=None, minPositive=None, max_=None): + """Set the data used to compute the colormapped display. + + It also resets the cache of data ranges. + + This method MUST be called by inheriting classes when data is updated. + + :param Union[None,numpy.ndarray] data: + :param Union[None,float] min_: Minimum value of the data + :param Union[None,float] minPositive: + Minimum of strictly positive values of the data + :param Union[None,float] max_: Maximum value of the data + """ + self.__data = None if data is None else numpy.array(data, copy=copy) + self.__cacheColormapRange = {} # Reset cache + + # Fill-up colormap range cache if values are provided + if max_ is not None and numpy.isfinite(max_): + if min_ is not None and numpy.isfinite(min_): + self.__cacheColormapRange[Colormap.LINEAR, Colormap.MINMAX] = min_, max_ + if minPositive is not None and numpy.isfinite(minPositive): + self.__cacheColormapRange[Colormap.LOGARITHM, Colormap.MINMAX] = minPositive, max_ + + colormap = self.getColormap() + if None in (colormap.getVMin(), colormap.getVMax()): + self._colormapChanged() + + def getColormappedData(self, copy=True): + """Returns the data used to compute the displayed colors + + :param bool copy: True to get a copy, + False to get internal data (do not modify!). + :rtype: Union[None,numpy.ndarray] + """ + if self.__data is None: + return None + else: + return numpy.array(self.__data, copy=copy) + + def _getColormapAutoscaleRange(self, colormap=None): + """Returns the autoscale range for current data and colormap. + + :param Union[None,~silx.gui.colors.Colormap] colormap: + The colormap for which to compute the autoscale range. + If None, the default, the colormap of the item is used + :return: (vmin, vmax) range (vmin and /or vmax might be `None`) + """ + if colormap is None: + colormap = self.getColormap() + + data = self.getColormappedData(copy=False) + if colormap is None or data is None: + return None, None + + normalization = colormap.getNormalization() + autoscaleMode = colormap.getAutoscaleMode() + key = normalization, autoscaleMode + vRange = self.__cacheColormapRange.get(key, None) + if vRange is None: + vRange = colormap._computeAutoscaleRange(data) + self.__cacheColormapRange[key] = vRange + return vRange + + +class SymbolMixIn(ItemMixInBase): + """Mix-in class for items with symbol type""" + + _DEFAULT_SYMBOL = None + """Default marker of the item""" + + _DEFAULT_SYMBOL_SIZE = config.DEFAULT_PLOT_SYMBOL_SIZE + """Default marker size of the item""" + + _SUPPORTED_SYMBOLS = collections.OrderedDict(( + ('o', 'Circle'), + ('d', 'Diamond'), + ('s', 'Square'), + ('+', 'Plus'), + ('x', 'Cross'), + ('.', 'Point'), + (',', 'Pixel'), + ('|', 'Vertical line'), + ('_', 'Horizontal line'), + ('tickleft', 'Tick left'), + ('tickright', 'Tick right'), + ('tickup', 'Tick up'), + ('tickdown', 'Tick down'), + ('caretleft', 'Caret left'), + ('caretright', 'Caret right'), + ('caretup', 'Caret up'), + ('caretdown', 'Caret down'), + (u'\u2665', 'Heart'), + ('', 'None'))) + """Dict of supported symbols""" + + def __init__(self): + if self._DEFAULT_SYMBOL is None: # Use default from config + self._symbol = config.DEFAULT_PLOT_SYMBOL + else: + self._symbol = self._DEFAULT_SYMBOL + + if self._DEFAULT_SYMBOL_SIZE is None: # Use default from config + self._symbol_size = config.DEFAULT_PLOT_SYMBOL_SIZE + else: + self._symbol_size = self._DEFAULT_SYMBOL_SIZE + + @classmethod + def getSupportedSymbols(cls): + """Returns the list of supported symbol names. + + :rtype: tuple of str + """ + return tuple(cls._SUPPORTED_SYMBOLS.keys()) + + @classmethod + def getSupportedSymbolNames(cls): + """Returns the list of supported symbol human-readable names. + + :rtype: tuple of str + """ + return tuple(cls._SUPPORTED_SYMBOLS.values()) + + def getSymbolName(self, symbol=None): + """Returns human-readable name for a symbol. + + :param str symbol: The symbol from which to get the name. + Default: current symbol. + :rtype: str + :raise KeyError: if symbol is not in :meth:`getSupportedSymbols`. + """ + if symbol is None: + symbol = self.getSymbol() + return self._SUPPORTED_SYMBOLS[symbol] + + def getSymbol(self): + """Return the point marker type. + + Marker type:: + + - 'o' circle + - '.' point + - ',' pixel + - '+' cross + - 'x' x-cross + - 'd' diamond + - 's' square + + :rtype: str + """ + return self._symbol + + def setSymbol(self, symbol): + """Set the marker type + + See :meth:`getSymbol`. + + :param str symbol: Marker type or marker name + """ + if symbol is None: + symbol = self._DEFAULT_SYMBOL + + elif symbol not in self.getSupportedSymbols(): + for symbolCode, name in self._SUPPORTED_SYMBOLS.items(): + if name.lower() == symbol.lower(): + symbol = symbolCode + break + else: + raise ValueError('Unsupported symbol %s' % str(symbol)) + + if symbol != self._symbol: + self._symbol = symbol + self._updated(ItemChangedType.SYMBOL) + + def getSymbolSize(self): + """Return the point marker size in points. + + :rtype: float + """ + return self._symbol_size + + def setSymbolSize(self, size): + """Set the point marker size in points. + + See :meth:`getSymbolSize`. + + :param str symbol: Marker type + """ + if size is None: + size = self._DEFAULT_SYMBOL_SIZE + if size != self._symbol_size: + self._symbol_size = size + self._updated(ItemChangedType.SYMBOL_SIZE) + + +class LineMixIn(ItemMixInBase): + """Mix-in class for item with line""" + + _DEFAULT_LINEWIDTH = 1. + """Default line width""" + + _DEFAULT_LINESTYLE = '-' + """Default line style""" + + _SUPPORTED_LINESTYLE = '', ' ', '-', '--', '-.', ':', None + """Supported line styles""" + + def __init__(self): + self._linewidth = self._DEFAULT_LINEWIDTH + self._linestyle = self._DEFAULT_LINESTYLE + + @classmethod + def getSupportedLineStyles(cls): + """Returns list of supported line styles. + + :rtype: List[str,None] + """ + return cls._SUPPORTED_LINESTYLE + + def getLineWidth(self): + """Return the curve line width in pixels + + :rtype: float + """ + return self._linewidth + + def setLineWidth(self, width): + """Set the width in pixel of the curve line + + See :meth:`getLineWidth`. + + :param float width: Width in pixels + """ + width = float(width) + if width != self._linewidth: + self._linewidth = width + self._updated(ItemChangedType.LINE_WIDTH) + + def getLineStyle(self): + """Return the type of the line + + Type of line:: + + - ' ' no line + - '-' solid line + - '--' dashed line + - '-.' dash-dot line + - ':' dotted line + + :rtype: str + """ + return self._linestyle + + def setLineStyle(self, style): + """Set the style of the curve line. + + See :meth:`getLineStyle`. + + :param str style: Line style + """ + style = str(style) + assert style in self.getSupportedLineStyles() + if style is None: + style = self._DEFAULT_LINESTYLE + if style != self._linestyle: + self._linestyle = style + self._updated(ItemChangedType.LINE_STYLE) + + +class ColorMixIn(ItemMixInBase): + """Mix-in class for item with color""" + + _DEFAULT_COLOR = (0., 0., 0., 1.) + """Default color of the item""" + + def __init__(self): + self._color = self._DEFAULT_COLOR + + def getColor(self): + """Returns the RGBA color of the item + + :rtype: 4-tuple of float in [0, 1] or array of colors + """ + return self._color + + def setColor(self, color, copy=True): + """Set item color + + :param color: color(s) to be used + :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or + one of the predefined color names defined in colors.py + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + if isinstance(color, str): + color = colors.rgba(color) + elif isinstance(color, qt.QColor): + color = colors.rgba(color) + else: + color = numpy.array(color, copy=copy) + # TODO more checks + improve color array support + if color.ndim == 1: # Single RGBA color + color = colors.rgba(color) + else: # Array of colors + assert color.ndim == 2 + + self._color = color + self._updated(ItemChangedType.COLOR) + + +class YAxisMixIn(ItemMixInBase): + """Mix-in class for item with yaxis""" + + _DEFAULT_YAXIS = 'left' + """Default Y axis the item belongs to""" + + def __init__(self): + self._yaxis = self._DEFAULT_YAXIS + + def getYAxis(self): + """Returns the Y axis this curve belongs to. + + Either 'left' or 'right'. + + :rtype: str + """ + return self._yaxis + + def setYAxis(self, yaxis): + """Set the Y axis this curve belongs to. + + :param str yaxis: 'left' or 'right' + """ + yaxis = str(yaxis) + 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) + + +class FillMixIn(ItemMixInBase): + """Mix-in class for item with fill""" + + def __init__(self): + self._fill = False + + def isFill(self): + """Returns whether the item is filled or not. + + :rtype: bool + """ + return self._fill + + def setFill(self, fill): + """Set whether to fill the item or not. + + :param bool fill: + """ + fill = bool(fill) + if fill != self._fill: + self._fill = fill + self._updated(ItemChangedType.FILL) + + +class AlphaMixIn(ItemMixInBase): + """Mix-in class for item with opacity""" + + def __init__(self): + self._alpha = 1. + + def getAlpha(self): + """Returns the opacity of the item + + :rtype: float in [0, 1.] + """ + return self._alpha + + def setAlpha(self, alpha): + """Set the opacity of the item + + .. note:: + + If the colormap already has some transparency, this alpha + adds additional transparency. The alpha channel of the colormap + is multiplied by this value. + + :param alpha: Opacity of the item, between 0 (full transparency) + and 1. (full opacity) + :type alpha: float + """ + alpha = float(alpha) + alpha = max(0., min(alpha, 1.)) # Clip alpha to [0., 1.] range + if alpha != self._alpha: + self._alpha = alpha + self._updated(ItemChangedType.ALPHA) + + +class ComplexMixIn(ItemMixInBase): + """Mix-in class for complex data mode""" + + _SUPPORTED_COMPLEX_MODES = None + """Override to only support a subset of all ComplexMode""" + + class ComplexMode(_Enum): + """Identify available display mode for complex""" + NONE = 'none' + ABSOLUTE = 'amplitude' + PHASE = 'phase' + REAL = 'real' + IMAGINARY = 'imaginary' + AMPLITUDE_PHASE = 'amplitude_phase' + LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase' + SQUARE_AMPLITUDE = 'square_amplitude' + + def __init__(self): + self.__complex_mode = self.ComplexMode.ABSOLUTE + + def getComplexMode(self): + """Returns the current complex visualization mode. + + :rtype: ComplexMode + """ + return self.__complex_mode + + def setComplexMode(self, mode): + """Set the complex visualization mode. + + :param ComplexMode mode: The visualization mode in: + 'real', 'imaginary', 'phase', 'amplitude' + :return: True if value was set, False if is was already set + :rtype: bool + """ + mode = self.ComplexMode.from_value(mode) + assert mode in self.supportedComplexModes() + + if mode != self.__complex_mode: + self.__complex_mode = mode + self._updated(ItemChangedType.COMPLEX_MODE) + return True + else: + return False + + def _convertComplexData(self, data, mode=None): + """Convert complex data to the specific mode. + + :param Union[ComplexMode,None] mode: + The kind of value to compute. + If None (the default), the current complex mode is used. + :return: The converted dataset + :rtype: Union[numpy.ndarray[float],None] + """ + if data is None: + return None + + if mode is None: + mode = self.getComplexMode() + + if mode is self.ComplexMode.REAL: + return numpy.real(data) + elif mode is self.ComplexMode.IMAGINARY: + return numpy.imag(data) + elif mode is self.ComplexMode.ABSOLUTE: + return numpy.absolute(data) + elif mode is self.ComplexMode.PHASE: + return numpy.angle(data) + elif mode is self.ComplexMode.SQUARE_AMPLITUDE: + return numpy.absolute(data) ** 2 + else: + raise ValueError('Unsupported conversion mode: %s', str(mode)) + + @classmethod + def supportedComplexModes(cls): + """Returns the list of supported complex visualization modes. + + See :class:`ComplexMode` and :meth:`setComplexMode`. + + :rtype: List[ComplexMode] + """ + if cls._SUPPORTED_COMPLEX_MODES is None: + return cls.ComplexMode.members() + else: + return cls._SUPPORTED_COMPLEX_MODES + + +class ScatterVisualizationMixIn(ItemMixInBase): + """Mix-in class for scatter plot visualization modes""" + + _SUPPORTED_SCATTER_VISUALIZATION = None + """Allows to override supported Visualizations""" + + @enum.unique + class Visualization(_Enum): + """Different modes of scatter plot visualizations""" + + POINTS = 'points' + """Display scatter plot as a point cloud""" + + LINES = 'lines' + """Display scatter plot as a wireframe. + + This is based on Delaunay triangulation + """ + + SOLID = 'solid' + """Display scatter plot as a set of filled triangles. + + This is based on Delaunay triangulation + """ + + REGULAR_GRID = 'regular_grid' + """Display scatter plot as an image. + + It expects the points to be the intersection of a regular grid, + and the order of points following that of an image. + First line, then second one, and always in the same direction + (either all lines from left to right or all from right to left). + """ + + IRREGULAR_GRID = 'irregular_grid' + """Display scatter plot as contiguous quadrilaterals. + + It expects the points to be the intersection of an irregular grid, + and the order of points following that of an image. + First line, then second one, and always in the same direction + (either all lines from left to right or all from right to left). + """ + + BINNED_STATISTIC = 'binned_statistic' + """Display scatter plot as 2D binned statistic (i.e., generalized histogram). + """ + + @enum.unique + class VisualizationParameter(_Enum): + """Different parameter names for scatter plot visualizations""" + + GRID_MAJOR_ORDER = 'grid_major_order' + """The major order of points in the regular grid. + + Either 'row' (row-major, fast X) or 'column' (column-major, fast Y). + """ + + GRID_BOUNDS = 'grid_bounds' + """The expected range in data coordinates of the regular grid. + + A 2-tuple of 2-tuple: (begin (x, y), end (x, y)). + This provides the data coordinates of the first point and the expected + last on. + As for `GRID_SHAPE`, this can be wider than the current data. + """ + + GRID_SHAPE = 'grid_shape' + """The expected size of the regular grid (height, width). + + The given shape can be wider than the number of points, + in which case the grid is not fully filled. + """ + + BINNED_STATISTIC_SHAPE = 'binned_statistic_shape' + """The number of bins in each dimension (height, width). + """ + + BINNED_STATISTIC_FUNCTION = 'binned_statistic_function' + """The reduction function to apply to each bin (str). + + 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'), + } + """Supported visualization parameter values. + + Defined for parameters with a set of acceptable values. + """ + + def __init__(self): + self.__visualization = self.Visualization.POINTS + self.__parameters = dict(# Init parameters to None + (parameter, None) for parameter in self.VisualizationParameter) + self.__parameters[self.VisualizationParameter.BINNED_STATISTIC_FUNCTION] = 'mean' + + @classmethod + def supportedVisualizations(cls): + """Returns the list of supported scatter visualization modes. + + See :meth:`setVisualization` + + :rtype: List[Visualization] + """ + if cls._SUPPORTED_SCATTER_VISUALIZATION is None: + return cls.Visualization.members() + else: + return cls._SUPPORTED_SCATTER_VISUALIZATION + + @classmethod + def supportedVisualizationParameterValues(cls, parameter): + """Returns the list of supported scatter visualization modes. + + See :meth:`VisualizationParameters` + + :param VisualizationParameter parameter: + This parameter for which to retrieve the supported values. + :returns: tuple of supported of values or None if not defined. + """ + parameter = cls.VisualizationParameter(parameter) + return cls._SUPPORTED_VISUALIZATION_PARAMETER_VALUES.get( + parameter, None) + + def setVisualization(self, mode): + """Set the scatter plot visualization mode to use. + + See :class:`Visualization` for all possible values, + and :meth:`supportedVisualizations` for supported ones. + + :param Union[str,Visualization] mode: + The visualization mode to use. + :return: True if value was set, False if is was already set + :rtype: bool + """ + mode = self.Visualization.from_value(mode) + assert mode in self.supportedVisualizations() + + if mode != self.__visualization: + self.__visualization = mode + + self._updated(ItemChangedType.VISUALIZATION_MODE) + return True + else: + return False + + def getVisualization(self): + """Returns the scatter plot visualization mode in use. + + :rtype: Visualization + """ + return self.__visualization + + def setVisualizationParameter(self, parameter, value=None): + """Set the given visualization parameter. + + :param Union[str,VisualizationParameter] parameter: + The name of the parameter to set + :param value: The value to use for this parameter + Set to None to automatically set the parameter + :raises ValueError: If parameter is not supported + :return: True if parameter was set, False if is was already set + :rtype: bool + :raise ValueError: If value is not supported + """ + parameter = self.VisualizationParameter.from_value(parameter) + + if self.__parameters[parameter] != value: + validValues = self.supportedVisualizationParameterValues(parameter) + if validValues is not None and value not in validValues: + raise ValueError("Unsupported parameter value: %s" % str(value)) + + self.__parameters[parameter] = value + self._updated(ItemChangedType.VISUALIZATION_MODE) + return True + return False + + def getVisualizationParameter(self, parameter): + """Returns the value of the given visualization parameter. + + This method returns the parameter as set by + :meth:`setVisualizationParameter`. + + :param parameter: The name of the parameter to retrieve + :returns: The value previously set or None if automatically set + :raises ValueError: If parameter is not supported + """ + if parameter not in self.VisualizationParameter: + raise ValueError("parameter not supported: %s", parameter) + + return self.__parameters[parameter] + + def getCurrentVisualizationParameter(self, parameter): + """Returns the current value of the given visualization parameter. + + If the parameter was set by :meth:`setVisualizationParameter` to + a value that is not None, this value is returned; + else the current value that is automatically computed is returned. + + :param parameter: The name of the parameter to retrieve + :returns: The current value (either set or automatically computed) + :raises ValueError: If parameter is not supported + """ + # Override in subclass to provide automatically computed parameters + return self.getVisualizationParameter(parameter) + + +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 + + _DEFAULT_Z_LAYER = 1 + """Default overlay layer for points, + on top of images.""" + + def __init__(self): + DataItem.__init__(self) + SymbolMixIn.__init__(self) + AlphaMixIn.__init__(self) + self._x = () + self._y = () + self._xerror = None + self._yerror = None + + # Store filtered data for x > 0 and/or y > 0 + self._filteredCache = {} + self._clippedCache = {} + + # Store bounds depending on axes filtering >0: + # key is (isXPositiveFilter, isYPositiveFilter) + self._boundsCache = {} + + @staticmethod + def _logFilterError(value, error): + """Filter/convert error values if they go <= 0. + + Replace error leading to negative values by nan + + :param numpy.ndarray value: 1D array of values + :param numpy.ndarray error: + Array of errors: scalar, N, Nx1 or 2xN or None. + :return: Filtered error so error bars are never negative + """ + if error is not None: + # Convert Nx1 to N + if error.ndim == 2 and error.shape[1] == 1 and len(value) != 1: + error = numpy.ravel(error) + + # Supports error being scalar, N or 2xN array + valueMinusError = value - numpy.atleast_2d(error)[0] + errorClipped = numpy.isnan(valueMinusError) + mask = numpy.logical_not(errorClipped) + errorClipped[mask] = valueMinusError[mask] <= 0 + + if numpy.any(errorClipped): # Need filtering + + # expand errorbars to 2xN + if error.size == 1: # Scalar + error = numpy.full( + (2, len(value)), error, dtype=numpy.float64) + + elif error.ndim == 1: # N array + newError = numpy.empty((2, len(value)), + 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.float64) + + else: + _logger.error("Unhandled error array") + return error + + error[0, errorClipped] = numpy.nan + + return error + + def _getClippingBoolArray(self, xPositive, yPositive): + """Compute a boolean array to filter out points with negative + coordinates on log axes. + + :param bool xPositive: True to filter arrays according to X coords. + :param bool yPositive: True to filter arrays according to Y coords. + :rtype: boolean numpy.ndarray + """ + assert xPositive or yPositive + if (xPositive, yPositive) not in self._clippedCache: + xclipped, yclipped = False, False + + if xPositive: + x = self.getXData(copy=False) + with numpy.errstate(invalid='ignore'): # Ignore NaN warnings + xclipped = x <= 0 + + if yPositive: + y = self.getYData(copy=False) + with numpy.errstate(invalid='ignore'): # Ignore NaN warnings + yclipped = y <= 0 + + self._clippedCache[(xPositive, yPositive)] = \ + numpy.logical_or(xclipped, yclipped) + return self._clippedCache[(xPositive, yPositive)] + + def _logFilterData(self, xPositive, yPositive): + """Filter out values with x or y <= 0 on log axes + + :param bool xPositive: True to filter arrays according to X coords. + :param bool yPositive: True to filter arrays according to Y coords. + :return: The filter arrays or unchanged object if filtering not needed + :rtype: (x, y, xerror, yerror) + """ + x = self.getXData(copy=False) + y = self.getYData(copy=False) + xerror = self.getXErrorData(copy=False) + yerror = self.getYErrorData(copy=False) + + if xPositive or yPositive: + clipped = self._getClippingBoolArray(xPositive, yPositive) + + if numpy.any(clipped): + # copy to keep original array and convert to float + x = numpy.array(x, copy=True, dtype=numpy.float64) + x[clipped] = numpy.nan + y = numpy.array(y, copy=True, dtype=numpy.float64) + y[clipped] = numpy.nan + + if xPositive and xerror is not None: + xerror = self._logFilterError(x, xerror) + + if yPositive and yerror is not None: + yerror = self._logFilterError(y, yerror) + + return x, y, xerror, yerror + + def _getBounds(self): + if self.getXData(copy=False).size == 0: # Empty data + return None + + plot = self.getPlot() + if plot is not None: + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() + else: + xPositive = False + yPositive = False + + # TODO bounds do not take error bars into account + if (xPositive, yPositive) not in self._boundsCache: + # use the getData class method because instance method can be + # overloaded to return additional arrays + data = PointsBase.getData(self, copy=False, displayed=True) + if len(data) == 5: + # hack to avoid duplicating caching mechanism in Scatter + # (happens when cached data is used, caching done using + # Scatter._logFilterData) + x, y, _xerror, _yerror = data[0], data[1], data[3], data[4] + else: + x, y, _xerror, _yerror = data + + 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): + """Return cached filtered data if applicable, + i.e. if any axis is in log scale. + Return None if caching is not applicable.""" + plot = self.getPlot() + if plot is not None: + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() + if xPositive or yPositive: + # At least one axis has log scale, filter data + if (xPositive, yPositive) not in self._filteredCache: + self._filteredCache[(xPositive, yPositive)] = \ + self._logFilterData(xPositive, yPositive) + return self._filteredCache[(xPositive, yPositive)] + return None + + def getData(self, copy=True, displayed=False): + """Returns the x, y values of the curve points and xerror, yerror + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :param bool displayed: True to only get curve points that are displayed + in the plot. Default: False + Note: If plot has log scale, negative points + are not displayed. + :returns: (x, y, xerror, yerror) + :rtype: 4-tuple of numpy.ndarray + """ + if displayed: # filter data according to plot state + cached_data = self._getCachedData() + if cached_data is not None: + return cached_data + + return (self.getXData(copy), + self.getYData(copy), + self.getXErrorData(copy), + self.getYErrorData(copy)) + + def getXData(self, copy=True): + """Returns the x coordinates of the data points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._x, copy=copy) + + def getYData(self, copy=True): + """Returns the y coordinates of the data points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._y, copy=copy) + + def getXErrorData(self, copy=True): + """Returns the x error of the points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray, float or None + """ + if isinstance(self._xerror, numpy.ndarray): + return numpy.array(self._xerror, copy=copy) + else: + return self._xerror # float or None + + def getYErrorData(self, copy=True): + """Returns the y error of the points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray, float or None + """ + if isinstance(self._yerror, numpy.ndarray): + return numpy.array(self._yerror, copy=copy) + else: + return self._yerror # float or None + + def setData(self, x, y, xerror=None, yerror=None, copy=True): + """Set the data of the curve. + + :param numpy.ndarray x: The data corresponding to the x coordinates. + :param numpy.ndarray y: The data corresponding to the y coordinates. + :param xerror: Values with the uncertainties on the x values + :type xerror: A float, or a numpy.ndarray of float32. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for positive errors, + row 1 for negative errors. + :param yerror: Values with the uncertainties on the y values. + :type yerror: A float, or a numpy.ndarray of float32. See xerror. + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + x = numpy.array(x, copy=copy) + y = numpy.array(y, copy=copy) + assert len(x) == len(y) + assert x.ndim == y.ndim == 1 + + # Convert complex data + if numpy.iscomplexobj(x): + _logger.warning( + 'Converting x data to absolute value to plot it.') + x = numpy.absolute(x) + if numpy.iscomplexobj(y): + _logger.warning( + 'Converting y data to absolute value to plot it.') + y = numpy.absolute(y) + + if xerror is not None: + if isinstance(xerror, abc.Iterable): + xerror = numpy.array(xerror, copy=copy) + if numpy.iscomplexobj(xerror): + _logger.warning( + 'Converting xerror data to absolute value to plot it.') + xerror = numpy.absolute(xerror) + else: + xerror = float(xerror) + if yerror is not None: + if isinstance(yerror, abc.Iterable): + yerror = numpy.array(yerror, copy=copy) + if numpy.iscomplexobj(yerror): + _logger.warning( + 'Converting yerror data to absolute value to plot it.') + yerror = numpy.absolute(yerror) + else: + yerror = float(yerror) + # TODO checks on xerror, yerror + self._x, self._y = x, y + self._xerror, self._yerror = xerror, yerror + + self._boundsCache = {} # Reset cached bounds + self._filteredCache = {} # Reset cached filtered data + self._clippedCache = {} # Reset cached clipped bool array + + self._boundsChanged() + self._updated(ItemChangedType.DATA) + + +class BaselineMixIn(object): + """Base class for Baseline mix-in""" + + def __init__(self, baseline=None): + self._baseline = baseline + + def _setBaseline(self, baseline): + """ + Set baseline value + + :param baseline: baseline value(s) + :type: Union[None,float,numpy.ndarray] + """ + if (isinstance(baseline, abc.Iterable)): + baseline = numpy.array(baseline) + self._baseline = baseline + + def getBaseline(self, copy=True): + """ + + :param bool copy: + :return: histogram baseline + :rtype: Union[None,float,numpy.ndarray] + """ + if isinstance(self._baseline, numpy.ndarray): + return numpy.array(self._baseline, copy=True) + else: + return self._baseline + + +class _Style: + """Object which store styles""" + + +class HighlightedMixIn(ItemMixInBase): + + def __init__(self): + self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE + self._highlighted = False + + def isHighlighted(self): + """Returns True if curve is highlighted. + + :rtype: bool + """ + return self._highlighted + + def setHighlighted(self, highlighted): + """Set the highlight state of the curve + + :param bool highlighted: + """ + highlighted = bool(highlighted) + if highlighted != self._highlighted: + self._highlighted = highlighted + # TODO inefficient: better to use backend's setCurveColor + self._updated(ItemChangedType.HIGHLIGHTED) + + def getHighlightedStyle(self): + """Returns the highlighted style in use + + :rtype: CurveStyle + """ + return self._highlightStyle + + def setHighlightedStyle(self, style): + """Set the style to use for highlighting + + :param CurveStyle style: New style to use + """ + previous = self.getHighlightedStyle() + if style != previous: + assert isinstance(style, _Style) + self._highlightStyle = style + self._updated(ItemChangedType.HIGHLIGHTED_STYLE) + + # Backward compatibility event + if previous.getColor() != style.getColor(): + self._updated(ItemChangedType.HIGHLIGHTED_COLOR) diff --git a/src/silx/gui/plot/items/curve.py b/src/silx/gui/plot/items/curve.py new file mode 100644 index 0000000..7cbe26e --- /dev/null +++ b/src/silx/gui/plot/items/curve.py @@ -0,0 +1,325 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 the :class:`Curve` item of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + + +import logging + +import numpy + +from ....utils.deprecation import deprecated +from ... import colors +from .core import (PointsBase, LabelsMixIn, ColorMixIn, YAxisMixIn, + FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType, + BaselineMixIn, HighlightedMixIn, _Style) + + +_logger = logging.getLogger(__name__) + + +class CurveStyle(_Style): + """Object storing the style of a curve. + + Set a value to None to use the default + + :param color: Color + :param Union[str,None] linestyle: Style of the line + :param Union[float,None] linewidth: Width of the line + :param Union[str,None] symbol: Symbol for markers + :param Union[float,None] symbolsize: Size of the markers + """ + + def __init__(self, color=None, linestyle=None, linewidth=None, + symbol=None, symbolsize=None): + if color is None: + self._color = None + else: + if isinstance(color, str): + color = colors.rgba(color) + else: # array-like expected + color = numpy.array(color, copy=False) + if color.ndim == 1: # Array is 1D, this is a single color + color = colors.rgba(color) + self._color = color + + if linestyle is not None: + assert linestyle in LineMixIn.getSupportedLineStyles() + self._linestyle = linestyle + + self._linewidth = None if linewidth is None else float(linewidth) + + if symbol is not None: + assert symbol in SymbolMixIn.getSupportedSymbols() + self._symbol = symbol + + self._symbolsize = None if symbolsize is None else float(symbolsize) + + def getColor(self, copy=True): + """Returns the color or None if not set. + + :param bool copy: True to get a copy (default), + False to get internal representation (do not modify!) + + :rtype: Union[List[float],None] + """ + if isinstance(self._color, numpy.ndarray): + return numpy.array(self._color, copy=copy) + else: + return self._color + + def getLineStyle(self): + """Return the type of the line or None if not set. + + Type of line:: + + - ' ' no line + - '-' solid line + - '--' dashed line + - '-.' dash-dot line + - ':' dotted line + + :rtype: Union[str,None] + """ + return self._linestyle + + def getLineWidth(self): + """Return the curve line width in pixels or None if not set. + + :rtype: Union[float,None] + """ + return self._linewidth + + def getSymbol(self): + """Return the point marker type. + + Marker type:: + + - 'o' circle + - '.' point + - ',' pixel + - '+' cross + - 'x' x-cross + - 'd' diamond + - 's' square + + :rtype: Union[str,None] + """ + return self._symbol + + def getSymbolSize(self): + """Return the point marker size in points. + + :rtype: Union[float,None] + """ + return self._symbolsize + + def __eq__(self, other): + if isinstance(other, CurveStyle): + return (numpy.array_equal(self.getColor(), other.getColor()) and + self.getLineStyle() == other.getLineStyle() and + self.getLineWidth() == other.getLineWidth() and + self.getSymbol() == other.getSymbol() and + self.getSymbolSize() == other.getSymbolSize()) + else: + return False + + +class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, + LineMixIn, BaselineMixIn, HighlightedMixIn): + """Description of a curve""" + + _DEFAULT_Z_LAYER = 1 + """Default overlay layer for curves""" + + _DEFAULT_SELECTABLE = True + """Default selectable state for curves""" + + _DEFAULT_LINEWIDTH = 1. + """Default line width of the curve""" + + _DEFAULT_LINESTYLE = '-' + """Default line style of the curve""" + + _DEFAULT_HIGHLIGHT_STYLE = CurveStyle(color='black') + """Default highlight style of the item""" + + _DEFAULT_BASELINE = None + + def __init__(self): + PointsBase.__init__(self) + ColorMixIn.__init__(self) + YAxisMixIn.__init__(self) + FillMixIn.__init__(self) + LabelsMixIn.__init__(self) + LineMixIn.__init__(self) + BaselineMixIn.__init__(self) + HighlightedMixIn.__init__(self) + + self._setBaseline(Curve._DEFAULT_BASELINE) + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + # Filter-out values <= 0 + xFiltered, yFiltered, xerror, yerror = self.getData( + copy=False, displayed=True) + + if len(xFiltered) == 0 or not numpy.any(numpy.isfinite(xFiltered)): + return None # No data to display, do not add renderer to backend + + style = self.getCurrentStyle() + + return backend.addCurve(xFiltered, yFiltered, + color=style.getColor(), + symbol=style.getSymbol(), + linestyle=style.getLineStyle(), + linewidth=style.getLineWidth(), + yaxis=self.getYAxis(), + xerror=xerror, + yerror=yerror, + fill=self.isFill(), + alpha=self.getAlpha(), + symbolsize=style.getSymbolSize(), + baseline=self.getBaseline(copy=False)) + + def __getitem__(self, item): + """Compatibility with PyMca and silx <= 0.4.0""" + if isinstance(item, slice): + return [self[index] for index in range(*item.indices(5))] + elif item == 0: + return self.getXData(copy=False) + elif item == 1: + return self.getYData(copy=False) + elif item == 2: + return self.getName() + elif item == 3: + info = self.getInfo(copy=False) + return {} if info is None else info + elif item == 4: + params = { + 'info': self.getInfo(), + 'color': self.getColor(), + 'symbol': self.getSymbol(), + 'linewidth': self.getLineWidth(), + 'linestyle': self.getLineStyle(), + 'xlabel': self.getXLabel(), + 'ylabel': self.getYLabel(), + 'yaxis': self.getYAxis(), + 'xerror': self.getXErrorData(copy=False), + 'yerror': self.getYErrorData(copy=False), + 'z': self.getZValue(), + 'selectable': self.isSelectable(), + 'fill': self.isFill(), + } + return params + else: + raise IndexError("Index out of range: %s", str(item)) + + @deprecated(replacement='Curve.getHighlightedStyle().getColor()', + since_version='0.9.0') + def getHighlightedColor(self): + """Returns the RGBA highlight color of the item + + :rtype: 4-tuple of float in [0, 1] + """ + return self.getHighlightedStyle().getColor() + + @deprecated(replacement='Curve.setHighlightedStyle()', + since_version='0.9.0') + def setHighlightedColor(self, color): + """Set the color to use when highlighted + + :param color: color(s) to be used for highlight + :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or + one of the predefined color names defined in colors.py + """ + self.setHighlightedStyle(CurveStyle(color)) + + def getCurrentStyle(self): + """Returns the current curve style. + + Curve style depends on curve highlighting + + :rtype: CurveStyle + """ + if self.isHighlighted(): + style = self.getHighlightedStyle() + color = style.getColor() + linestyle = style.getLineStyle() + linewidth = style.getLineWidth() + symbol = style.getSymbol() + symbolsize = style.getSymbolSize() + + return CurveStyle( + color=self.getColor() if color is None else color, + linestyle=self.getLineStyle() if linestyle is None else linestyle, + linewidth=self.getLineWidth() if linewidth is None else linewidth, + symbol=self.getSymbol() if symbol is None else symbol, + symbolsize=self.getSymbolSize() if symbolsize is None else symbolsize) + + else: + return CurveStyle(color=self.getColor(), + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + symbol=self.getSymbol(), + symbolsize=self.getSymbolSize()) + + @deprecated(replacement='Curve.getCurrentStyle()', + since_version='0.9.0') + def getCurrentColor(self): + """Returns the current color of the curve. + + This color is either the color of the curve or the highlighted color, + depending on the highlight state. + + :rtype: 4-tuple of float in [0, 1] + """ + return self.getCurrentStyle().getColor() + + def setData(self, x, y, xerror=None, yerror=None, baseline=None, copy=True): + """Set the data of the curve. + + :param numpy.ndarray x: The data corresponding to the x coordinates. + :param numpy.ndarray y: The data corresponding to the y coordinates. + :param xerror: Values with the uncertainties on the x values + :type xerror: A float, or a numpy.ndarray of float32. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for positive errors, + row 1 for negative errors. + :param yerror: Values with the uncertainties on the y values. + :type yerror: A float, or a numpy.ndarray of float32. See xerror. + :param baseline: curve baseline + :type baseline: Union[None,float,numpy.ndarray] + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + PointsBase.setData(self, x=x, y=y, xerror=xerror, yerror=yerror, + copy=copy) + self._setBaseline(baseline=baseline) diff --git a/src/silx/gui/plot/items/histogram.py b/src/silx/gui/plot/items/histogram.py new file mode 100644 index 0000000..16bbefa --- /dev/null +++ b/src/silx/gui/plot/items/histogram.py @@ -0,0 +1,389 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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::t +# +# 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 the :class:`Histogram` item of the :class:`Plot`. +""" + +__authors__ = ["H. Payno", "T. Vincent"] +__license__ = "MIT" +__date__ = "28/08/2018" + +import logging +import typing + +import numpy +from collections import OrderedDict, namedtuple +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc + +from ....utils.proxy import docstring +from .core import (DataItem, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn, + LineMixIn, YAxisMixIn, ItemChangedType, Item) +from ._pick import PickingResult + +_logger = logging.getLogger(__name__) + + +def _computeEdges(x, histogramType): + """Compute the edges from a set of xs and a rule to generate the edges + + :param x: the x value of the curve to transform into an histogram + :param histogramType: the type of histogram we wan't to generate. + This define the way to center the histogram values compared to the + curve value. Possible values can be:: + + - 'left' + - 'right' + - 'center' + + :return: the edges for the given x and the histogramType + """ + # for now we consider that the spaces between xs are constant + edges = x.copy() + if histogramType == 'left': + width = 1 + if len(x) > 1: + width = x[1] - x[0] + edges = numpy.append(x[0] - width, edges) + if histogramType == 'center': + edges = _computeEdges(edges, 'right') + widths = (edges[1:] - edges[0:-1]) / 2.0 + widths = numpy.append(widths, widths[-1]) + edges = edges - widths + if histogramType == 'right': + width = 1 + if len(x) > 1: + width = x[-1] - x[-2] + edges = numpy.append(edges, x[-1] + width) + + return edges + + +def _getHistogramCurve(histogram, edges): + """Returns the x and y value of a curve corresponding to the histogram + + :param numpy.ndarray histogram: The values of the histogram + :param numpy.ndarray edges: The bin edges of the histogram + :return: a tuple(x, y) which contains the value of the curve to use + to display the histogram + """ + assert len(histogram) + 1 == len(edges) + x = numpy.empty(len(histogram) * 2, dtype=edges.dtype) + y = numpy.empty(len(histogram) * 2, dtype=histogram.dtype) + # Make a curve with stairs + x[:-1:2] = edges[:-1] + x[1::2] = edges[1:] + y[:-1:2] = histogram + y[1::2] = histogram + + return x, y + + +# TODO: Yerror, test log scale +class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, + LineMixIn, YAxisMixIn, BaselineMixIn): + """Description of an histogram""" + + _DEFAULT_Z_LAYER = 1 + """Default overlay layer for histograms""" + + _DEFAULT_SELECTABLE = False + """Default selectable state for histograms""" + + _DEFAULT_LINEWIDTH = 1. + """Default line width of the histogram""" + + _DEFAULT_LINESTYLE = '-' + """Default line style of the histogram""" + + _DEFAULT_BASELINE = None + + def __init__(self): + DataItem.__init__(self) + AlphaMixIn.__init__(self) + BaselineMixIn.__init__(self) + ColorMixIn.__init__(self) + FillMixIn.__init__(self) + LineMixIn.__init__(self) + YAxisMixIn.__init__(self) + + self._histogram = () + self._edges = () + self._setBaseline(Histogram._DEFAULT_BASELINE) + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + values, edges, baseline = self.getData(copy=False) + + if values.size == 0: + return None # No data to display, do not add renderer + + if values.size == 0: + return None # No data to display, do not add renderer to backend + + x, y = _getHistogramCurve(values, edges) + + # Filter-out values <= 0 + plot = self.getPlot() + if plot is not None: + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() + else: + xPositive = False + yPositive = False + + if xPositive or yPositive: + clipped = numpy.logical_or( + (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.float64) + y = numpy.array(y, dtype=numpy.float64) + x[clipped] = numpy.nan + y[clipped] = numpy.nan + + return backend.addCurve(x, y, + color=self.getColor(), + symbol='', + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + yaxis=self.getYAxis(), + xerror=None, + yerror=None, + fill=self.isFill(), + alpha=self.getAlpha(), + baseline=baseline, + symbolsize=1) + + def _getBounds(self): + values, edges, baseline = self.getData(copy=False) + + plot = self.getPlot() + if plot is not None: + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() + else: + xPositive = False + yPositive = False + + if xPositive or yPositive: + 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.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=bool) + + if yPositive: + # Replace values <= 0 by NaN, do not modify edges + clipped_values = numpy.logical_or(clipped_values, values <= 0) + + values[clipped_values] = numpy.nan + + if yPositive: + return (numpy.nanmin(edges), + numpy.nanmax(edges), + numpy.nanmin(values), + numpy.nanmax(values)) + + else: # No log scale on y axis, include 0 in bounds + if numpy.all(numpy.isnan(values)): + return None + return (numpy.nanmin(edges), + numpy.nanmax(edges), + min(0, numpy.nanmin(values)), + max(0, numpy.nanmax(values))) + + def __pickFilledHistogram(self, x: float, y: float) -> typing.Optional[PickingResult]: + """Picking implementation for filled histogram + + :param x: X position in pixels + :param y: Y position in pixels + """ + if not self.isFill(): + return None + + plot = self.getPlot() + if plot is None: + return None + + xData, yData = plot.pixelToData(x, y, axis=self.getYAxis()) + xmin, xmax, ymin, ymax = self.getBounds() + if not xmin < xData < xmax or not ymin < yData < ymax: + return None # Outside bounding box + + # Check x + edges = self.getBinEdgesData(copy=False) + index = numpy.searchsorted(edges, (xData,), side='left')[0] - 1 + # Safe indexing in histogram values + index = numpy.clip(index, 0, len(edges) - 2) + + # Check y + baseline = self.getBaseline(copy=False) + if baseline is None: + baseline = 0 # Default value + + value = self.getValueData(copy=False)[index] + if ((baseline <= value and baseline <= yData <= value) or + (value < baseline and value <= yData <= baseline)): + return PickingResult(self, numpy.array([index])) + else: + return None + + @docstring(DataItem) + def pick(self, x, y): + if self.isFill(): + return self.__pickFilledHistogram(x, y) + else: + result = super().pick(x, y) + if result is None: + return None + else: # Convert from curve indices to histogram indices + return PickingResult(self, numpy.unique(result.getIndices() // 2)) + + def getValueData(self, copy=True): + """The values of the histogram + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: The values of the histogram + :rtype: numpy.ndarray + """ + return numpy.array(self._histogram, copy=copy) + + def getBinEdgesData(self, copy=True): + """The bin edges of the histogram (number of histogram values + 1) + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: The bin edges of the histogram + :rtype: numpy.ndarray + """ + return numpy.array(self._edges, copy=copy) + + def getData(self, copy=True): + """Return the histogram values, bin edges and baseline + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: (N histogram value, N+1 bin edges) + :rtype: 2-tuple of numpy.nadarray + """ + return (self.getValueData(copy), + self.getBinEdgesData(copy), + self.getBaseline(copy)) + + def setData(self, histogram, edges, align='center', baseline=None, + copy=True): + """Set the histogram values and bin edges. + + :param numpy.ndarray histogram: The values of the histogram. + :param numpy.ndarray edges: + The bin edges of the histogram. + If histogram and edges have the same length, the bin edges + are computed according to the align parameter. + :param str align: + In case histogram values and edges have the same length N, + the N+1 bin edges are computed according to the alignment in: + 'center' (default), 'left', 'right'. + :param baseline: histogram baseline + :type baseline: Union[None,float,numpy.ndarray] + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + histogram = numpy.array(histogram, copy=copy) + edges = numpy.array(edges, copy=copy) + + assert histogram.ndim == 1 + assert edges.ndim == 1 + assert edges.size in (histogram.size, histogram.size + 1) + assert align in ('center', 'left', 'right') + + if histogram.size == 0: # No data + self._histogram = () + self._edges = () + else: + if edges.size == histogram.size: # Compute true bin edges + edges = _computeEdges(edges, align) + + # Check that bin edges are monotonic + edgesDiff = numpy.diff(edges) + edgesDiff = edgesDiff[numpy.logical_not(numpy.isnan(edgesDiff))] + assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0) + # manage baseline + if (isinstance(baseline, abc.Iterable)): + baseline = numpy.array(baseline) + if baseline.size == histogram.size: + new_baseline = numpy.empty(baseline.shape[0] * 2) + for i_value, value in enumerate(baseline): + new_baseline[i_value*2:i_value*2+2] = value + baseline = new_baseline + self._histogram = histogram + self._edges = edges + self._alignement = align + self._setBaseline(baseline) + + self._boundsChanged() + self._updated(ItemChangedType.DATA) + + def getAlignment(self): + """ + + :return: histogram alignement. Value in ('center', 'left', 'right'). + """ + return self._alignement + + def _revertComputeEdges(self, x, histogramType): + """Compute the edges from a set of xs and a rule to generate the edges + + :param x: the x value of the curve to transform into an histogram + :param histogramType: the type of histogram we wan't to generate. + This define the way to center the histogram values compared to the + curve value. Possible values can be:: + + - 'left' + - 'right' + - 'center' + + :return: the edges for the given x and the histogramType + """ + # for now we consider that the spaces between xs are constant + edges = x.copy() + if histogramType == 'left': + return edges[1:] + if histogramType == 'center': + edges = (edges[1:] + edges[:-1]) / 2.0 + if histogramType == 'right': + width = 1 + if len(x) > 1: + width = x[-1] + x[-2] + edges = edges[:-1] + return edges diff --git a/src/silx/gui/plot/items/image.py b/src/silx/gui/plot/items/image.py new file mode 100644 index 0000000..5cc719b --- /dev/null +++ b/src/silx/gui/plot/items/image.py @@ -0,0 +1,641 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 the :class:`ImageData` and :class:`ImageRgba` items +of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/12/2020" + +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc +import logging + +import numpy + +from ....utils.proxy import docstring +from .core import (DataItem, LabelsMixIn, DraggableMixIn, ColormapMixIn, + AlphaMixIn, ItemChangedType) + +_logger = logging.getLogger(__name__) + + +def _convertImageToRgba32(image, copy=True): + """Convert an RGB or RGBA image to RGBA32. + + It converts from floats in [0, 1], bool, integer and uint in [0, 255] + + If the input image is already an RGBA32 image, + the returned image shares the same data. + + :param image: Image to convert to + :type image: numpy.ndarray with 3 dimensions: height, width, color channels + :param bool copy: True (Default) to get a copy, False, avoid copy if possible + :return: The image converted to RGBA32 with dimension: (height, width, 4) + :rtype: numpy.ndarray of uint8 + """ + assert image.ndim == 3 + assert image.shape[-1] in (3, 4) + + # Convert type to uint8 + if image.dtype.name != 'uint8': + if image.dtype.kind == 'f': # Float in [0, 1] + image = (numpy.clip(image, 0., 1.) * 255).astype(numpy.uint8) + elif image.dtype.kind == 'b': # boolean + image = image.astype(numpy.uint8) * 255 + elif image.dtype.kind in ('i', 'u'): # int, uint + image = numpy.clip(image, 0, 255).astype(numpy.uint8) + else: + raise ValueError('Unsupported image dtype: %s', image.dtype.name) + copy = False # A copy as already been done, avoid next one + + # Convert RGB to RGBA + if image.shape[-1] == 3: + new_image = numpy.empty((image.shape[0], image.shape[1], 4), + dtype=numpy.uint8) + new_image[:,:,:3] = image + new_image[:,:, 3] = 255 + return new_image # This is a copy anyway + else: + return numpy.array(image, copy=copy) + + +class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): + """Description of an image + + :param numpy.ndarray data: Initial image data + """ + + def __init__(self, data=None, mask=None): + DataItem.__init__(self) + LabelsMixIn.__init__(self) + DraggableMixIn.__init__(self) + AlphaMixIn.__init__(self) + if data is None: + data = numpy.zeros((0, 0, 4), dtype=numpy.uint8) + self._data = data + self._mask = mask + self.__valueDataCache = None # Store default data + self._origin = (0., 0.) + self._scale = (1., 1.) + + def __getitem__(self, item): + """Compatibility with PyMca and silx <= 0.4.0""" + if isinstance(item, slice): + return [self[index] for index in range(*item.indices(5))] + elif item == 0: + return self.getData(copy=False) + elif item == 1: + return self.getName() + elif item == 2: + info = self.getInfo(copy=False) + return {} if info is None else info + elif item == 3: + return None + elif item == 4: + params = { + 'info': self.getInfo(), + 'origin': self.getOrigin(), + 'scale': self.getScale(), + 'z': self.getZValue(), + 'selectable': self.isSelectable(), + 'draggable': self.isDraggable(), + 'colormap': None, + 'xlabel': self.getXLabel(), + 'ylabel': self.getYLabel(), + } + return params + else: + raise IndexError("Index out of range: %s" % str(item)) + + def _isPlotLinear(self, plot): + """Return True if plot only uses linear scale for both of x and y + axes.""" + linear = plot.getXAxis().LINEAR + if plot.getXAxis().getScale() != linear: + return False + if plot.getYAxis().getScale() != linear: + return False + return True + + def _getBounds(self): + if self.getData(copy=False).size == 0: # Empty data + return None + + height, width = self.getData(copy=False).shape[:2] + origin = self.getOrigin() + scale = self.getScale() + # Taking care of scale might be < 0 + xmin, xmax = origin[0], origin[0] + width * scale[0] + if xmin > xmax: + xmin, xmax = xmax, xmin + # Taking care of scale might be < 0 + ymin, ymax = origin[1], origin[1] + height * scale[1] + if ymin > ymax: + ymin, ymax = ymax, ymin + + plot = self.getPlot() + if plot is not None and not self._isPlotLinear(plot): + return None + else: + return xmin, xmax, ymin, ymax + + @docstring(DraggableMixIn) + def drag(self, from_, to): + origin = self.getOrigin() + self.setOrigin((origin[0] + to[0] - from_[0], + origin[1] + to[1] - from_[1])) + + def getData(self, copy=True): + """Returns the image data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._data, copy=copy) + + def setData(self, data): + """Set the image data + + :param numpy.ndarray data: + """ + previousShape = self._data.shape + self._data = data + self._valueDataChanged() + self._boundsChanged() + self._updated(ItemChangedType.DATA) + + if (self.getMaskData(copy=False) is not None and + previousShape != self._data.shape): + # Data shape changed, so mask shape changes. + # Send event, mask is lazily updated in getMaskData + self._updated(ItemChangedType.MASK) + + def getMaskData(self, copy=True): + """Returns the mask data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: Union[None,numpy.ndarray] + """ + if self._mask is None: + return None + + # Update mask if it does not match data shape + shape = self.getData(copy=False).shape[:2] + if self._mask.shape != shape: + # Clip/extend mask to match data + newMask = numpy.zeros(shape, dtype=self._mask.dtype) + newMask[:self._mask.shape[0], :self._mask.shape[1]] = self._mask[:shape[0], :shape[1]] + self._mask = newMask + + return numpy.array(self._mask, copy=copy) + + def setMaskData(self, mask, copy=True): + """Set the image data + + :param numpy.ndarray data: + :param bool copy: True (Default) to make a copy, + False to use as is (do not modify!) + """ + if mask is not None: + mask = numpy.array(mask, copy=copy) + + shape = self.getData(copy=False).shape[:2] + if mask.shape != shape: + _logger.warning("Inconsistent shape between mask and data %s, %s", mask.shape, shape) + # Clip/extent is done lazily in getMaskData + elif self._mask is None: + return # No update + + self._mask = mask + self._valueDataChanged() + self._updated(ItemChangedType.MASK) + + def _valueDataChanged(self): + """Clear cache of default data array""" + self.__valueDataCache = None + + def _getValueData(self, copy=True): + """Return data used by :meth:`getValueData` + + :param bool copy: + :rtype: numpy.ndarray + """ + return self.getData(copy=copy) + + def getValueData(self, copy=True): + """Return data (converted to int or float) with mask applied. + + Masked values are set to Not-A-Number. + It returns a 2D array of values (int or float). + + :param bool copy: + :rtype: numpy.ndarray + """ + if self.__valueDataCache is None: + data = self._getValueData(copy=False) + mask = self.getMaskData(copy=False) + if mask is not None: + if numpy.issubdtype(data.dtype, numpy.floating): + dtype = data.dtype + else: + dtype = numpy.float64 + data = numpy.array(data, dtype=dtype, copy=True) + data[mask != 0] = numpy.NaN + self.__valueDataCache = data + return numpy.array(self.__valueDataCache, copy=copy) + + def getRgbaImageData(self, copy=True): + """Get the displayed RGB(A) image + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :returns: numpy.ndarray of uint8 of shape (height, width, 4) + """ + raise NotImplementedError('This MUST be implemented in sub-class') + + def getOrigin(self): + """Returns the offset from origin at which to display the image. + + :rtype: 2-tuple of float + """ + return self._origin + + def setOrigin(self, origin): + """Set the offset from origin at which to display the image. + + :param origin: (ox, oy) Offset from origin + :type origin: float or 2-tuple of float + """ + if isinstance(origin, abc.Sequence): + origin = float(origin[0]), float(origin[1]) + else: # single value origin + origin = float(origin), float(origin) + if origin != self._origin: + self._origin = origin + self._boundsChanged() + self._updated(ItemChangedType.POSITION) + + def getScale(self): + """Returns the scale of the image in data coordinates. + + :rtype: 2-tuple of float + """ + return self._scale + + def setScale(self, scale): + """Set the scale of the image + + :param scale: (sx, sy) Scale of the image + :type scale: float or 2-tuple of float + """ + if isinstance(scale, abc.Sequence): + scale = float(scale[0]), float(scale[1]) + else: # single value scale + scale = float(scale), float(scale) + + if scale != self._scale: + self._scale = scale + self._boundsChanged() + self._updated(ItemChangedType.SCALE) + + +class ImageDataBase(ImageBase, ColormapMixIn): + """Base class for colormapped 2D data image""" + + def __init__(self): + ImageBase.__init__(self, numpy.zeros((0, 0), dtype=numpy.float32)) + ColormapMixIn.__init__(self) + + def _getColormapForRendering(self): + colormap = self.getColormap() + if colormap.isAutoscale(): + # Avoid backend to compute autoscale: use item cache + colormap = colormap.copy() + colormap.setVRange(*colormap.getColormapRange(self)) + return colormap + + def getRgbaImageData(self, copy=True): + """Get the displayed RGB(A) image + + :returns: Array of uint8 of shape (height, width, 4) + :rtype: numpy.ndarray + """ + return self.getColormap().applyToData(self) + + def setData(self, data, copy=True): + """"Set the image data + + :param numpy.ndarray data: Data array with 2 dimensions (h, w) + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + if data.dtype.kind == 'b': + _logger.warning( + 'Converting boolean image to int8 to plot it.') + data = numpy.array(data, copy=False, dtype=numpy.int8) + elif numpy.iscomplexobj(data): + _logger.warning( + 'Converting complex image to absolute value to plot it.') + data = numpy.absolute(data) + super().setData(data) + + def _updated(self, event=None, checkVisibility=True): + # Synchronizes colormapped data if changed + if event in (ItemChangedType.DATA, ItemChangedType.MASK): + self._setColormappedData(self.getValueData(copy=False), copy=False) + super()._updated(event=event, checkVisibility=checkVisibility) + + +class ImageData(ImageDataBase): + """Description of a data image with a colormap""" + + def __init__(self): + ImageDataBase.__init__(self) + self._alternativeImage = None + self.__alpha = None + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if not self._isPlotLinear(plot): + # Do not render with non linear scales + return None + + if (self.getAlternativeImageData(copy=False) is not None or + self.getAlphaData(copy=False) is not None): + dataToUse = self.getRgbaImageData(copy=False) + else: + dataToUse = self.getData(copy=False) + + if dataToUse.size == 0: + return None # No data to display + + return backend.addImage(dataToUse, + origin=self.getOrigin(), + scale=self.getScale(), + colormap=self._getColormapForRendering(), + alpha=self.getAlpha()) + + def __getitem__(self, item): + """Compatibility with PyMca and silx <= 0.4.0""" + if item == 3: + return self.getAlternativeImageData(copy=False) + + params = ImageBase.__getitem__(self, item) + if item == 4: + params['colormap'] = self.getColormap() + + return params + + def getRgbaImageData(self, copy=True): + """Get the displayed RGB(A) image + + :returns: Array of uint8 of shape (height, width, 4) + :rtype: numpy.ndarray + """ + alternative = self.getAlternativeImageData(copy=False) + if alternative is not None: + return _convertImageToRgba32(alternative, copy=copy) + else: + image = super().getRgbaImageData(copy=copy) + alphaImage = self.getAlphaData(copy=False) + if alphaImage is not None: + # Apply transparency + image[:,:, 3] = image[:,:, 3] * alphaImage + return image + + def getAlternativeImageData(self, copy=True): + """Get the optional RGBA image that is displayed instead of the data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: Union[None,numpy.ndarray] + """ + if self._alternativeImage is None: + return None + else: + return numpy.array(self._alternativeImage, copy=copy) + + def getAlphaData(self, copy=True): + """Get the optional transparency image applied on the data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: Union[None,numpy.ndarray] + """ + if self.__alpha is None: + return None + else: + return numpy.array(self.__alpha, copy=copy) + + def setData(self, data, alternative=None, alpha=None, copy=True): + """"Set the image data and optionally an alternative RGB(A) representation + + :param numpy.ndarray data: Data array with 2 dimensions (h, w) + :param alternative: RGB(A) image to display instead of data, + shape: (h, w, 3 or 4) + :type alternative: Union[None,numpy.ndarray] + :param alpha: An array of transparency value in [0, 1] to use for + display with shape: (h, w) + :type alpha: Union[None,numpy.ndarray] + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + + if alternative is not None: + alternative = numpy.array(alternative, copy=copy) + assert alternative.ndim == 3 + assert alternative.shape[2] in (3, 4) + assert alternative.shape[:2] == data.shape[:2] + self._alternativeImage = alternative + + if alpha is not None: + alpha = numpy.array(alpha, copy=copy) + assert alpha.shape == data.shape + if alpha.dtype.kind != 'f': + alpha = alpha.astype(numpy.float32) + if numpy.any(numpy.logical_or(alpha < 0., alpha > 1.)): + alpha = numpy.clip(alpha, 0., 1.) + self.__alpha = alpha + + super().setData(data) + + +class ImageRgba(ImageBase): + """Description of an RGB(A) image""" + + def __init__(self): + ImageBase.__init__(self, numpy.zeros((0, 0, 4), dtype=numpy.uint8)) + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if not self._isPlotLinear(plot): + # Do not render with non linear scales + return None + + data = self.getData(copy=False) + + if data.size == 0: + return None # No data to display + + return backend.addImage(data, + origin=self.getOrigin(), + scale=self.getScale(), + colormap=None, + alpha=self.getAlpha()) + + def getRgbaImageData(self, copy=True): + """Get the displayed RGB(A) image + + :returns: numpy.ndarray of uint8 of shape (height, width, 4) + """ + return _convertImageToRgba32(self.getData(copy=False), copy=copy) + + def setData(self, data, copy=True): + """Set the image data + + :param data: RGB(A) image data to set + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 3 + assert data.shape[-1] in (3, 4) + super().setData(data) + + def _getValueData(self, copy=True): + """Compute the intensity of the RGBA image as default data. + + Conversion: https://en.wikipedia.org/wiki/YCbCr#ITU-R_BT.601_conversion + + :param bool copy: + """ + rgba = self.getRgbaImageData(copy=False).astype(numpy.float32) + intensity = (rgba[:, :, 0] * 0.299 + + rgba[:, :, 1] * 0.587 + + rgba[:, :, 2] * 0.114) + intensity *= rgba[:, :, 3] / 255. + return intensity + + +class MaskImageData(ImageData): + """Description of an image used as a mask. + + This class is used to flag mask items. This information is used to improve + internal silx widgets. + """ + pass + + +class ImageStack(ImageData): + """Item to store a stack of images and to show it in the plot as one + of the images of the stack. + + The stack is a 3D array ordered this way: `frame id, y, x`. + So the first image of the stack can be reached this way: `stack[0, :, :]` + """ + + def __init__(self): + ImageData.__init__(self) + self.__stack = None + """A 3D numpy array (or a mimic one, see ListOfImages)""" + self.__stackPosition = None + """Displayed position in the cube""" + + def setStackData(self, stack, position=None, copy=True): + """Set the stack data + + :param stack: A 3D numpy array like + :param int position: The position of the displayed image in the stack + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + if self.__stack is stack: + return + if copy: + stack = numpy.array(stack) + assert stack.ndim == 3 + self.__stack = stack + if position is not None: + self.__stackPosition = position + if self.__stackPosition is None: + self.__stackPosition = 0 + self.__updateDisplayedData() + + def getStackData(self, copy=True): + """Get the stored stack array. + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: A 3D numpy array, or numpy array like + """ + if copy: + return numpy.array(self.__stack) + else: + return self.__stack + + def setStackPosition(self, pos): + """Set the displayed position on the stack. + + This function will clamp the stack position according to + the real size of the first axis of the stack. + + :param int pos: A position on the first axis of the stack. + """ + if self.__stackPosition == pos: + return + self.__stackPosition = pos + self.__updateDisplayedData() + + def getStackPosition(self): + """Get the displayed position of the stack. + + :rtype: int + """ + return self.__stackPosition + + def __updateDisplayedData(self): + """Update the displayed frame whenever the stack or the stack + position are updated.""" + if self.__stack is None or self.__stackPosition is None: + empty = numpy.array([]).reshape(0, 0) + self.setData(empty, copy=False) + return + size = len(self.__stack) + self.__stackPosition = numpy.clip(self.__stackPosition, 0, size) + self.setData(self.__stack[self.__stackPosition], copy=False) diff --git a/src/silx/gui/plot/items/image_aggregated.py b/src/silx/gui/plot/items/image_aggregated.py new file mode 100644 index 0000000..75fdd59 --- /dev/null +++ b/src/silx/gui/plot/items/image_aggregated.py @@ -0,0 +1,229 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2021 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 the :class:`ImageDataAggregated` items of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "07/07/2021" + +import enum +import logging +from typing import Tuple, Union + +import numpy + +from ....utils.enum import Enum as _Enum +from ....utils.proxy import docstring +from .axis import Axis +from .core import ItemChangedType +from .image import ImageDataBase +from ._pick import PickingResult + + +_logger = logging.getLogger(__name__) + + +class ImageDataAggregated(ImageDataBase): + """Item displaying an image as a density map.""" + + @enum.unique + class Aggregation(_Enum): + NONE = "none" + "Do not aggregate data, display as is (default)" + + MAX = "max" + "Aggregates elements with max (ignore NaNs)" + + MEAN = "mean" + "Aggregates elements with mean (ignore NaNs)" + + MIN = "min" + "Aggregates elements with min (ignore NaNs)" + + def __init__(self): + super().__init__() + self.__cacheLODData = {} + self.__currentLOD = 0, 0 + self.__aggregationMode = self.Aggregation.NONE + + def setAggregationMode(self, mode: Union[str,Aggregation]): + """Set the aggregation method used to reduce the data to screen resolution. + + :param Aggregation mode: The aggregation method + """ + aggregationMode = self.Aggregation.from_value(mode) + if aggregationMode != self.__aggregationMode: + self.__aggregationMode = aggregationMode + self.__cacheLODData = {} # Clear cache + self._updated(ItemChangedType.VISUALIZATION_MODE) + + def getAggregationMode(self) -> Aggregation: + """Returns the currently used aggregation method.""" + return self.__aggregationMode + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if not self._isPlotLinear(plot): + # Do not render with non linear scales + return None + + data = self.getData(copy=False) + if data.size == 0: + return None # No data to display + + aggregationMode = self.getAggregationMode() + if aggregationMode == self.Aggregation.NONE: # Pass data as it is + displayedData = data + scale = self.getScale() + + else: # Aggregate data according to level of details + if aggregationMode == self.Aggregation.MAX: + aggregator = numpy.nanmax + elif aggregationMode == self.Aggregation.MEAN: + aggregator = numpy.nanmean + elif aggregationMode == self.Aggregation.MIN: + aggregator = numpy.nanmin + else: + _logger.error("Unsupported aggregation mode") + return None + + lodx, lody = self._getLevelOfDetails() + + if (lodx, lody) not in self.__cacheLODData: + height, width = data.shape + self.__cacheLODData[(lodx, lody)] = aggregator( + data[: (height // lody) * lody, : (width // lodx) * lodx].reshape( + height // lody, lody, width // lodx, lodx + ), + axis=(1, 3), + ) + + self.__currentLOD = lodx, lody + displayedData = self.__cacheLODData[self.__currentLOD] + + sx, sy = self.getScale() + scale = sx * lodx, sy * lody + + return backend.addImage( + displayedData, + origin=self.getOrigin(), + scale=scale, + colormap=self._getColormapForRendering(), + alpha=self.getAlpha(), + ) + + def _getPixelSizeInData(self, axis="left"): + """Returns the size of a pixel in plot data coordinates + + :param str axis: Y axis to use in: 'left' (default), 'right' + :return: + Size (width, height) of a Qt pixel in data coordinates. + Size is None if it cannot be computed + :rtype: Union[List[float],None] + """ + assert axis in ("left", "right") + plot = self.getPlot() + if plot is None: + return None + + xaxis = plot.getXAxis() + yaxis = plot.getYAxis(axis) + + if ( + xaxis.getScale() != Axis.LINEAR + or yaxis.getScale() != Axis.LINEAR + ): + raise RuntimeError("Only available with linear axes") + + xmin, xmax = xaxis.getLimits() + ymin, ymax = yaxis.getLimits() + width, height = plot.getPlotBoundsInPixels()[2:] + if width == 0 or height == 0: + return None + else: + return (xmax - xmin) / width, (ymax - ymin) / height + + def _getLevelOfDetails(self) -> Tuple[int, int]: + """Return current level of details the image is displayed with.""" + plot = self.getPlot() + if plot is None or not self._isPlotLinear(plot): + return 1, 1 # Fallback to bas LOD + + sx, sy = self.getScale() + xUnitPerPixel, yUnitPerPixel = self._getPixelSizeInData() + lodx = max(1, int(numpy.ceil(xUnitPerPixel / sx))) + lody = max(1, int(numpy.ceil(yUnitPerPixel / sy))) + return lodx, lody + + @docstring(ImageDataBase) + def setData(self, data, copy=True): + self.__cacheLODData = {} # Reset cache + super().setData(data) + + @docstring(ImageDataBase) + def _setPlot(self, plot): + """Refresh image when plot limits change""" + previousPlot = self.getPlot() + if previousPlot is not None: + for axis in (previousPlot.getXAxis(), previousPlot.getYAxis()): + axis.sigLimitsChanged.disconnect(self.__plotLimitsChanged) + + super()._setPlot(plot) + + if plot is not None: + for axis in (plot.getXAxis(), plot.getYAxis()): + axis.sigLimitsChanged.connect(self.__plotLimitsChanged) + + def __plotLimitsChanged(self): + """Trigger update if level of details has changed""" + if (self.getAggregationMode() != self.Aggregation.NONE and + self.__currentLOD != self._getLevelOfDetails()): + self._updated() + + @docstring(ImageDataBase) + def pick(self, x, y): + result = super().pick(x, y) + if result is None: + return None + + # Compute indices in initial data + plot = self.getPlot() + if plot is None: + return None + dataPos = plot.pixelToData(x, y, axis="left", check=True) + if dataPos is None: + return None # Outside plot area + + ox, oy = self.getOrigin() + sx, sy = self.getScale() + col = int((dataPos[0] - ox) / sx) + row = int((dataPos[1] - oy) / sy) + height, width = self.getData(copy=False).shape[:2] + if 0 <= col < width and 0 <= row < height: + return PickingResult(self, ((row,), (col,))) + return None diff --git a/src/silx/gui/plot/items/marker.py b/src/silx/gui/plot/items/marker.py new file mode 100755 index 0000000..50d070c --- /dev/null +++ b/src/silx/gui/plot/items/marker.py @@ -0,0 +1,281 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-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 markers item of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2017" + + +import logging + +from ....utils.proxy import docstring +from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, + ItemChangedType, YAxisMixIn) +from silx.gui import qt + +_logger = logging.getLogger(__name__) + + +class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): + """Base class for markers""" + + sigDragStarted = qt.Signal() + """Signal emitted when the marker is pressed""" + sigDragFinished = qt.Signal() + """Signal emitted when the marker is released""" + + _DEFAULT_COLOR = (0., 0., 0., 1.) + """Default color of the markers""" + + def __init__(self): + Item.__init__(self) + DraggableMixIn.__init__(self) + ColorMixIn.__init__(self) + YAxisMixIn.__init__(self) + + self._text = '' + self._x = None + self._y = None + self._constraint = self._defaultConstraint + self.__isBeingDragged = False + + def _addRendererCall(self, backend, + symbol=None, linestyle='-', linewidth=1): + """Perform the update of the backend renderer""" + return backend.addMarker( + x=self.getXPosition(), + y=self.getYPosition(), + text=self.getText(), + color=self.getColor(), + symbol=symbol, + linestyle=linestyle, + linewidth=linewidth, + constraint=self.getConstraint(), + yaxis=self.getYAxis()) + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + raise NotImplementedError() + + @docstring(DraggableMixIn) + def drag(self, from_, to): + self.setPosition(to[0], to[1]) + + def isOverlay(self): + """Returns True: A marker is always rendered as an overlay. + + :rtype: bool + """ + return True + + def getText(self): + """Returns marker text. + + :rtype: str + """ + return self._text + + def setText(self, text): + """Set the text of the marker. + + :param str text: The text to use + """ + text = str(text) + if text != self._text: + self._text = text + self._updated(ItemChangedType.TEXT) + + def getXPosition(self): + """Returns the X position of the marker line in data coordinates + + :rtype: float or None + """ + return self._x + + def getYPosition(self): + """Returns the Y position of the marker line in data coordinates + + :rtype: float or None + """ + return self._y + + def getPosition(self): + """Returns the (x, y) position of the marker in data coordinates + + :rtype: 2-tuple of float or None + """ + return self._x, self._y + + def setPosition(self, x, y): + """Set marker position in data coordinates + + Constraint are applied if any. + + :param float x: X coordinates in data frame + :param float y: Y coordinates in data frame + """ + x, y = self.getConstraint()(x, y) + x, y = float(x), float(y) + if x != self._x or y != self._y: + self._x, self._y = x, y + self._updated(ItemChangedType.POSITION) + + def getConstraint(self): + """Returns the dragging constraint of this item""" + return self._constraint + + def _setConstraint(self, constraint): # TODO support update + """Set the constraint. + + This is private for now as update is not handled. + + :param callable constraint: + :param constraint: A function filtering item displacement by + dragging operations or None for no filter. + This function is called each time the item is + moved. + This is only used if isDraggable returns True. + :type constraint: None or a callable that takes the coordinates of + the current cursor position in the plot as input + and that returns the filtered coordinates. + """ + if constraint is None: + constraint = self._defaultConstraint + assert callable(constraint) + self._constraint = constraint + + @staticmethod + def _defaultConstraint(*args): + """Default constraint not doing anything""" + return args + + def _startDrag(self): + self.__isBeingDragged = True + self.sigDragStarted.emit() + + def _endDrag(self): + self.__isBeingDragged = False + self.sigDragFinished.emit() + + def isBeingDragged(self) -> bool: + """Returns whether the marker is currently dragged by the user.""" + return self.__isBeingDragged + + +class Marker(MarkerBase, SymbolMixIn): + """Description of a marker""" + + _DEFAULT_SYMBOL = '+' + """Default symbol of the marker""" + + def __init__(self): + MarkerBase.__init__(self) + SymbolMixIn.__init__(self) + + self._x = 0. + self._y = 0. + + def _addBackendRenderer(self, backend): + return self._addRendererCall(backend, symbol=self.getSymbol()) + + def _setConstraint(self, constraint): + """Set the constraint function of the marker drag. + + It also supports 'horizontal' and 'vertical' str as constraint. + + :param constraint: The constraint of the dragging of this marker + :type: constraint: callable or str + """ + if constraint == 'horizontal': + constraint = self._horizontalConstraint + elif constraint == 'vertical': + constraint = self._verticalConstraint + + super(Marker, self)._setConstraint(constraint) + + def _horizontalConstraint(self, _, y): + return self.getXPosition(), y + + def _verticalConstraint(self, x, _): + return x, self.getYPosition() + + +class _LineMarker(MarkerBase, LineMixIn): + """Base class for line markers""" + + def __init__(self): + MarkerBase.__init__(self) + LineMixIn.__init__(self) + + def _addBackendRenderer(self, backend): + return self._addRendererCall(backend, + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth()) + + +class XMarker(_LineMarker): + """Description of a marker""" + + def __init__(self): + _LineMarker.__init__(self) + self._x = 0. + + def setPosition(self, x, y): + """Set marker line position in data coordinates + + Constraint are applied if any. + + :param float x: X coordinates in data frame + :param float y: Y coordinates in data frame + """ + x, _ = self.getConstraint()(x, y) + x = float(x) + if x != self._x: + self._x = x + self._updated(ItemChangedType.POSITION) + + +class YMarker(_LineMarker): + """Description of a marker""" + + def __init__(self): + _LineMarker.__init__(self) + self._y = 0. + + def setPosition(self, x, y): + """Set marker line position in data coordinates + + Constraint are applied if any. + + :param float x: X coordinates in data frame + :param float y: Y coordinates in data frame + """ + _, y = self.getConstraint()(x, y) + y = float(y) + if y != self._y: + self._y = y + self._updated(ItemChangedType.POSITION) diff --git a/src/silx/gui/plot/items/roi.py b/src/silx/gui/plot/items/roi.py new file mode 100644 index 0000000..38a1424 --- /dev/null +++ b/src/silx/gui/plot/items/roi.py @@ -0,0 +1,1519 @@ +# 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 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 + +from ... import utils +from .. import items +from ...colors import rgba +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 PointROI(RegionOfInterest, items.SymbolMixIn): + """A ROI identifying a point in a 2D plot.""" + + ICON = 'add-shape-point' + NAME = 'point markers' + SHORT_NAME = "point" + """Metadata for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + _DEFAULT_SYMBOL = '+' + """Default symbol of the PointROI + + It overwrite the `SymbolMixIn` class attribte. + """ + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + items.SymbolMixIn.__init__(self) + self._marker = items.Marker() + self._marker.sigItemChanged.connect(self._pointPositionChanged) + self._marker.setSymbol(self._DEFAULT_SYMBOL) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) + + def setFirstShapePoints(self, points): + self.setPosition(points[0]) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(PointROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self._marker.getPosition() + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + self._marker.setPosition(*pos) + + @docstring(_RegionOfInterestBase) + def contains(self, position): + roiPos = self.getPosition() + return position[0] == roiPos[0] and position[1] == roiPos[1] + + def _pointPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + self.sigRegionChanged.emit() + + def __str__(self): + params = '%f %f' % self.getPosition() + return "%s(%s)" % (self.__class__.__name__, params) + + +class CrossROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a point in a 2D plot and displayed as a cross + """ + + ICON = 'add-shape-cross' + NAME = 'cross marker' + SHORT_NAME = "cross" + """Metadata for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._handle = self.addHandle() + self._handle.sigItemChanged.connect(self._handlePositionChanged) + self._handleLabel = self.addLabelHandle() + self._vmarker = self.addUserHandle(items.YMarker()) + self._vmarker._setSelectable(False) + self._vmarker._setDraggable(False) + self._vmarker.setPosition(*self.getPosition()) + self._hmarker = self.addUserHandle(items.XMarker()) + self._hmarker._setSelectable(False) + self._hmarker._setDraggable(False) + self._hmarker.setPosition(*self.getPosition()) + + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + markers = (self._vmarker, self._hmarker) + self._updateItemProperty(event, self, markers) + super(CrossROI, self)._updated(event, checkVisibility) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def _updatedStyle(self, event, style): + super(CrossROI, self)._updatedStyle(event, style) + for marker in [self._vmarker, self._hmarker]: + marker.setColor(style.getColor()) + marker.setLineStyle(style.getLineStyle()) + marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0] + self.setPosition(pos) + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self._handle.getPosition() + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + self._handle.setPosition(*pos) + + def _handlePositionChanged(self, event): + """Handle center marker position updates""" + if event is items.ItemChangedType.POSITION: + position = self.getPosition() + self._handleLabel.setPosition(*position) + self._vmarker.setPosition(*position) + self._hmarker.setPosition(*position) + self.sigRegionChanged.emit() + + @docstring(HandleBasedROI) + def contains(self, position): + roiPos = self.getPosition() + return position[0] == roiPos[0] or position[1] == roiPos[1] + + +class LineROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a line in a 2D plot. + + This ROI provides 1 anchor for each boundary of the line, plus an center + in the center to translate the full ROI. + """ + + ICON = 'add-shape-diagonal' + NAME = 'line ROI' + SHORT_NAME = "line" + """Metadata for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._handleStart = self.addHandle() + self._handleEnd = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("polylines") + 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(LineROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(LineROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self.setEndPoints(points[0], points[1]) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def setEndPoints(self, startPoint, endPoint): + """Set this line location using the ending points + + :param numpy.ndarray startPoint: Staring bounding point of the line + :param numpy.ndarray endPoint: Ending bounding point of the line + """ + if not numpy.array_equal((startPoint, endPoint), self.getEndPoints()): + self.__updateEndPoints(startPoint, endPoint) + + def __updateEndPoints(self, startPoint, endPoint): + """Update marker and shape to match given end points + + :param numpy.ndarray startPoint: Staring bounding point of the line + :param numpy.ndarray endPoint: Ending bounding point of the line + """ + startPoint = numpy.array(startPoint) + endPoint = numpy.array(endPoint) + center = (startPoint + endPoint) * 0.5 + + with utils.blockSignals(self._handleStart): + self._handleStart.setPosition(startPoint[0], startPoint[1]) + with utils.blockSignals(self._handleEnd): + self._handleEnd.setPosition(endPoint[0], endPoint[1]) + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(center[0], center[1]) + + line = numpy.array((startPoint, endPoint)) + self.__shape.setPoints(line) + self.sigRegionChanged.emit() + + def getEndPoints(self): + """Returns bounding points of this ROI. + + :rtype: Tuple(numpy.ndarray,numpy.ndarray) + """ + startPoint = numpy.array(self._handleStart.getPosition()) + endPoint = numpy.array(self._handleEnd.getPosition()) + return (startPoint, endPoint) + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleStart: + _start, end = self.getEndPoints() + self.__updateEndPoints(current, end) + elif handle is self._handleEnd: + start, _end = self.getEndPoints() + self.__updateEndPoints(start, current) + elif handle is self._handleCenter: + start, end = self.getEndPoints() + delta = current - previous + start += delta + end += delta + self.setEndPoints(start, end) + + @docstring(_RegionOfInterestBase) + def contains(self, position): + bottom_left = position[0], position[1] + bottom_right = position[0] + 1, position[1] + top_left = position[0], position[1] + 1 + top_right = position[0] + 1, position[1] + 1 + + points = self.__shape.getPoints() + line_pt1 = points[0] + line_pt2 = points[1] + + bb1 = _BoundingBox.from_points(points) + if not bb1.contains(position): + return False + + return ( + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=bottom_left, seg2_end_pt=bottom_right) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=bottom_right, seg2_end_pt=top_right) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + 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() + params = start[0], start[1], end[0], end[1] + params = 'start: %f %f; end: %f %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class HorizontalLineROI(RegionOfInterest, items.LineMixIn): + """A ROI identifying an horizontal line in a 2D plot.""" + + ICON = 'add-shape-horizontal' + NAME = 'horizontal line ROI' + SHORT_NAME = "hline" + """Metadata for this kind of ROI""" + + _plotShape = "hline" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._marker = items.YMarker() + self._marker.sigItemChanged.connect(self._linePositionChanged) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(HorizontalLineROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) + self._marker.setLineStyle(style.getLineStyle()) + self._marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0, 1] + if pos == self.getPosition(): + return + self.setPosition(pos) + + def getPosition(self): + """Returns the position of this line if the horizontal axis + + :rtype: float + """ + pos = self._marker.getPosition() + return pos[1] + + def setPosition(self, pos): + """Set the position of this ROI + + :param float pos: Horizontal position of this line + """ + self._marker.setPosition(0, pos) + + @docstring(_RegionOfInterestBase) + def contains(self, position): + return position[1] == self.getPosition() + + def _linePositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + self.sigRegionChanged.emit() + + def __str__(self): + params = 'y: %f' % self.getPosition() + return "%s(%s)" % (self.__class__.__name__, params) + + +class VerticalLineROI(RegionOfInterest, items.LineMixIn): + """A ROI identifying a vertical line in a 2D plot.""" + + ICON = 'add-shape-vertical' + NAME = 'vertical line ROI' + SHORT_NAME = "vline" + """Metadata for this kind of ROI""" + + _plotShape = "vline" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._marker = items.XMarker() + self._marker.sigItemChanged.connect(self._linePositionChanged) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(VerticalLineROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) + self._marker.setLineStyle(style.getLineStyle()) + self._marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0, 0] + self.setPosition(pos) + + def getPosition(self): + """Returns the position of this line if the horizontal axis + + :rtype: float + """ + pos = self._marker.getPosition() + return pos[0] + + def setPosition(self, pos): + """Set the position of this ROI + + :param float pos: Horizontal position of this line + """ + self._marker.setPosition(pos, 0) + + @docstring(RegionOfInterest) + def contains(self, position): + return position[0] == self.getPosition() + + def _linePositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + self.sigRegionChanged.emit() + + def __str__(self): + params = 'x: %f' % self.getPosition() + return "%s(%s)" % (self.__class__.__name__, params) + + +class RectangleROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a rectangle in a 2D plot. + + This ROI provides 1 anchor for each corner, plus an anchor in the + center to translate the full ROI. + """ + + ICON = 'add-shape-rectangle' + NAME = 'rectangle ROI' + SHORT_NAME = "rectangle" + """Metadata for this kind of ROI""" + + _plotShape = "rectangle" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._handleTopLeft = self.addHandle() + self._handleTopRight = self.addHandle() + self._handleBottomLeft = self.addHandle() + self._handleBottomRight = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("rectangle") + shape.setPoints([[0, 0], [0, 0]]) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + shape.setColor(rgba(self.getColor())) + self.__shape = shape + self.addItem(shape) + + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + self._updateItemProperty(event, self, self.__shape) + super(RectangleROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(RectangleROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setBound(points) + + def _setBound(self, points): + """Initialize the rectangle from a bunch of points""" + top = max(points[:, 1]) + bottom = min(points[:, 1]) + left = min(points[:, 0]) + right = max(points[:, 0]) + size = right - left, top - bottom + self._updateGeometry(origin=(left, bottom), size=size) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleCenter.getPosition() + return numpy.array(pos) + + def getOrigin(self): + """Returns the corner point with the smaller coordinates + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleBottomLeft.getPosition() + return numpy.array(pos) + + def getSize(self): + """Returns the size of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + vmin = self._handleBottomLeft.getPosition() + vmax = self._handleTopRight.getPosition() + vmin, vmax = numpy.array(vmin), numpy.array(vmax) + return vmax - vmin + + def setOrigin(self, position): + """Set the origin position of this ROI + + :param numpy.ndarray position: Location of the smaller corner of the ROI + """ + size = self.getSize() + self.setGeometry(origin=position, size=size) + + def setSize(self, size): + """Set the size of this ROI + + :param numpy.ndarray size: Size of the center of the ROI + """ + origin = self.getOrigin() + self.setGeometry(origin=origin, size=size) + + def setCenter(self, position): + """Set the size of this ROI + + :param numpy.ndarray position: Location of the center of the ROI + """ + size = self.getSize() + self.setGeometry(center=position, size=size) + + def setGeometry(self, origin=None, size=None, center=None): + """Set the geometry of the ROI + """ + if ((origin is None or numpy.array_equal(origin, self.getOrigin())) and + (center is None or numpy.array_equal(center, self.getCenter())) and + numpy.array_equal(size, self.getSize())): + return # Nothing has changed + + self._updateGeometry(origin, size, center) + + def _updateGeometry(self, origin=None, size=None, center=None): + """Forced update of the geometry of the ROI""" + if origin is not None: + origin = numpy.array(origin) + size = numpy.array(size) + points = numpy.array([origin, origin + size]) + center = origin + size * 0.5 + elif center is not None: + center = numpy.array(center) + size = numpy.array(size) + points = numpy.array([center - size * 0.5, center + size * 0.5]) + else: + raise ValueError("Origin or center expected") + + with utils.blockSignals(self._handleBottomLeft): + self._handleBottomLeft.setPosition(points[0, 0], points[0, 1]) + with utils.blockSignals(self._handleBottomRight): + self._handleBottomRight.setPosition(points[1, 0], points[0, 1]) + with utils.blockSignals(self._handleTopLeft): + self._handleTopLeft.setPosition(points[0, 0], points[1, 1]) + with utils.blockSignals(self._handleTopRight): + self._handleTopRight.setPosition(points[1, 0], points[1, 1]) + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(points[0, 0], points[0, 1]) + + self.__shape.setPoints(points) + self.sigRegionChanged.emit() + + @docstring(HandleBasedROI) + def contains(self, position): + assert isinstance(position, (tuple, list, numpy.array)) + points = self.__shape.getPoints() + bb1 = _BoundingBox.from_points(points) + return bb1.contains(position) + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleCenter: + # It is the center anchor + size = self.getSize() + self._updateGeometry(center=current, size=size) + else: + opposed = { + self._handleBottomLeft: self._handleTopRight, + self._handleTopRight: self._handleBottomLeft, + self._handleBottomRight: self._handleTopLeft, + self._handleTopLeft: self._handleBottomRight, + } + handle2 = opposed[handle] + current2 = handle2.getPosition() + points = numpy.array([current, current2]) + + # Switch handles if they were crossed by interaction + if self._handleBottomLeft.getXPosition() > self._handleBottomRight.getXPosition(): + self._handleBottomLeft, self._handleBottomRight = self._handleBottomRight, self._handleBottomLeft + + if self._handleTopLeft.getXPosition() > self._handleTopRight.getXPosition(): + self._handleTopLeft, self._handleTopRight = self._handleTopRight, self._handleTopLeft + + if self._handleBottomLeft.getYPosition() > self._handleTopLeft.getYPosition(): + self._handleBottomLeft, self._handleTopLeft = self._handleTopLeft, self._handleBottomLeft + + if self._handleBottomRight.getYPosition() > self._handleTopRight.getYPosition(): + self._handleBottomRight, self._handleTopRight = self._handleTopRight, self._handleBottomRight + + self._setBound(points) + + def __str__(self): + origin = self.getOrigin() + w, h = self.getSize() + params = origin[0], origin[1], w, h + params = 'origin: %f %f; width: %f; height: %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class CircleROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a circle in a 2D plot. + + This ROI provides 1 anchor at the center to translate the circle, + and one anchor on the perimeter to change the radius. + """ + + ICON = 'add-shape-circle' + NAME = 'circle ROI' + SHORT_NAME = "circle" + """Metadata for this kind of ROI""" + + _kind = "Circle" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + items.LineMixIn.__init__(self) + HandleBasedROI.__init__(self, parent=parent) + self._handlePerimeter = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleCenter.sigItemChanged.connect(self._centerPositionChanged) + self._handleLabel = self.addLabelHandle() + + 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.__radius = 0 + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(CircleROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(CircleROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setRay(points) + + def _setRay(self, points): + """Initialize the circle from the center point and a + perimeter point.""" + center = points[0] + radius = numpy.linalg.norm(points[0] - points[1]) + self.setGeometry(center=center, radius=radius) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleCenter.getPosition() + return numpy.array(pos) + + def getRadius(self): + """Returns the radius of this circle + + :rtype: float + """ + return self.__radius + + def setCenter(self, position): + """Set the center point of this ROI + + :param numpy.ndarray position: Location of the center of the circle + """ + self._handleCenter.setPosition(*position) + + def setRadius(self, radius): + """Set the size of this ROI + + :param float size: Radius of the circle + """ + radius = float(radius) + if radius != self.__radius: + self.__radius = radius + self._updateGeometry() + + def setGeometry(self, center, radius): + """Set the geometry of the ROI + """ + if numpy.array_equal(center, self.getCenter()): + self.setRadius(radius) + else: + self.__radius = float(radius) # Update radius directly + self.setCenter(center) # Calls _updateGeometry + + def _updateGeometry(self): + """Update the handles and shape according to given parameters""" + center = self.getCenter() + perimeter_point = numpy.array([center[0] + self.__radius, center[1]]) + + self._handlePerimeter.setPosition(perimeter_point[0], perimeter_point[1]) + self._handleLabel.setPosition(center[0], center[1]) + + nbpoints = 27 + angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints + circleShape = numpy.array((numpy.cos(angles) * self.__radius, + numpy.sin(angles) * self.__radius)).T + circleShape += center + self.__shape.setPoints(circleShape) + self.sigRegionChanged.emit() + + def _centerPositionChanged(self, event): + """Handle position changed events of the center marker""" + if event is items.ItemChangedType.POSITION: + self._updateGeometry() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handlePerimeter: + 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() + params = center[0], center[1], radius + params = 'center: %f %f; radius: %f;' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class EllipseROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying an oriented ellipse in a 2D plot. + + This ROI provides 1 anchor at the center to translate the circle, + and two anchors on the perimeter to modify the major-radius and + minor-radius. These two anchors also allow to change the orientation. + """ + + ICON = 'add-shape-ellipse' + NAME = 'ellipse ROI' + SHORT_NAME = "ellipse" + """Metadata for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + items.LineMixIn.__init__(self) + HandleBasedROI.__init__(self, parent=parent) + self._handleAxis0 = self.addHandle() + self._handleAxis1 = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleCenter.sigItemChanged.connect(self._centerPositionChanged) + self._handleLabel = self.addLabelHandle() + + 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._radius = 0., 0. + self._orientation = 0. # angle in radians between the X-axis and the _handleAxis0 + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(EllipseROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(EllipseROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setRay(points) + + @staticmethod + def _calculateOrientation(p0, p1): + """return angle in radians between the vector p0-p1 + and the X axis + + :param p0: first point coordinates (x, y) + :param p1: second point coordinates + :return: + """ + vector = (p1[0] - p0[0], p1[1] - p0[1]) + x_unit_vector = (1, 0) + norm = numpy.linalg.norm(vector) + if norm != 0: + theta = numpy.arccos(numpy.dot(vector, x_unit_vector) / norm) + else: + theta = 0 + if vector[1] < 0: + # arccos always returns values in range [0, pi] + theta = 2 * numpy.pi - theta + return theta + + def _setRay(self, points): + """Initialize the circle from the center point and a + perimeter point.""" + center = points[0] + radius = numpy.linalg.norm(points[0] - points[1]) + orientation = self._calculateOrientation(points[0], points[1]) + self.setGeometry(center=center, + radius=(radius, radius), + orientation=orientation) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleCenter.getPosition() + return numpy.array(pos) + + def getMajorRadius(self): + """Returns the half-diameter of the major axis. + + :rtype: float + """ + return max(self._radius) + + def getMinorRadius(self): + """Returns the half-diameter of the minor axis. + + :rtype: float + """ + return min(self._radius) + + def getOrientation(self): + """Return angle in radians between the horizontal (X) axis + and the major axis of the ellipse in [0, 2*pi[ + + :rtype: float: + """ + return self._orientation + + def setCenter(self, center): + """Set the center point of this ROI + + :param numpy.ndarray position: Coordinates (X, Y) of the center + of the ellipse + """ + self._handleCenter.setPosition(*center) + + def setMajorRadius(self, radius): + """Set the half-diameter of the major axis of the ellipse. + + :param float radius: + Major radius of the ellipsis. Must be a positive value. + """ + if self._radius[0] > self._radius[1]: + newRadius = radius, self._radius[1] + else: + newRadius = self._radius[0], radius + self.setGeometry(radius=newRadius) + + def setMinorRadius(self, radius): + """Set the half-diameter of the minor axis of the ellipse. + + :param float radius: + Minor radius of the ellipsis. Must be a positive value. + """ + if self._radius[0] > self._radius[1]: + newRadius = self._radius[0], radius + else: + newRadius = radius, self._radius[1] + self.setGeometry(radius=newRadius) + + def setOrientation(self, orientation): + """Rotate the ellipse + + :param float orientation: Angle in radians between the horizontal and + the major axis. + :return: + """ + self.setGeometry(orientation=orientation) + + def setGeometry(self, center=None, radius=None, orientation=None): + """ + + :param center: (X, Y) coordinates + :param float majorRadius: + :param float minorRadius: + :param float orientation: angle in radians between the major axis and the + horizontal + :return: + """ + if center is None: + center = self.getCenter() + + if radius is None: + radius = self._radius + else: + radius = float(radius[0]), float(radius[1]) + + if orientation is None: + orientation = self._orientation + else: + # ensure that we store the orientation in range [0, 2*pi + orientation = numpy.mod(orientation, 2 * numpy.pi) + + if (numpy.array_equal(center, self.getCenter()) or + radius != self._radius or + orientation != self._orientation): + + # Update parameters directly + self._radius = radius + self._orientation = orientation + + if numpy.array_equal(center, self.getCenter()): + self._updateGeometry() + else: + # This will call _updateGeometry + self.setCenter(center) + + def _updateGeometry(self): + """Update shape and markers""" + center = self.getCenter() + + orientation = self.getOrientation() + if self._radius[1] > self._radius[0]: + # _handleAxis1 is the major axis + orientation -= numpy.pi / 2 + + point0 = numpy.array([center[0] + self._radius[0] * numpy.cos(orientation), + center[1] + self._radius[0] * numpy.sin(orientation)]) + point1 = numpy.array([center[0] - self._radius[1] * numpy.sin(orientation), + center[1] + self._radius[1] * numpy.cos(orientation)]) + with utils.blockSignals(self._handleAxis0): + self._handleAxis0.setPosition(*point0) + with utils.blockSignals(self._handleAxis1): + self._handleAxis1.setPosition(*point1) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(*center) + + nbpoints = 27 + angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints + X = (self._radius[0] * numpy.cos(angles) * numpy.cos(orientation) + - self._radius[1] * numpy.sin(angles) * numpy.sin(orientation)) + Y = (self._radius[0] * numpy.cos(angles) * numpy.sin(orientation) + + self._radius[1] * numpy.sin(angles) * numpy.cos(orientation)) + + ellipseShape = numpy.array((X, Y)).T + ellipseShape += center + self.__shape.setPoints(ellipseShape) + self.sigRegionChanged.emit() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle in (self._handleAxis0, self._handleAxis1): + center = self.getCenter() + orientation = self._calculateOrientation(center, current) + distance = numpy.linalg.norm(center - current) + + if handle is self._handleAxis1: + if self._radius[0] > distance: + # _handleAxis1 is not the major axis, rotate -90 degrees + 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 + radius = distance, self._radius[1] + + self.setGeometry(radius=radius, orientation=orientation) + + def _centerPositionChanged(self, event): + """Handle position changed events of the center marker""" + 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() + minor = self.getMinorRadius() + orientation = self.getOrientation() + params = center[0], center[1], major, minor, orientation + params = 'center: %f %f; major radius: %f: minor radius: %f; orientation: %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class PolygonROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a closed polygon in a 2D plot. + + This ROI provides 1 anchor for each point of the polygon. + """ + + ICON = 'add-shape-polygon' + NAME = 'polygon ROI' + SHORT_NAME = "polygon" + """Metadata for this kind of ROI""" + + _plotShape = "polygon" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._handleLabel = self.addLabelHandle() + self._handleCenter = self.addTranslateHandle() + self._handlePoints = [] + self._points = numpy.empty((0, 2)) + self._handleClose = None + + self._polygon_shape = None + shape = self.__createShape() + self.__shape = shape + self.addItem(shape) + + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + self._updateItemProperty(event, self, self.__shape) + super(PolygonROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(PolygonROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + if self._handleClose is not None: + color = self._computeHandleColor(style.getColor()) + self._handleClose.setColor(color) + + def __createShape(self, interaction=False): + kind = "polygon" if not interaction else "polylines" + shape = items.Shape(kind) + shape.setPoints([[0, 0], [0, 0]]) + shape.setFill(False) + shape.setOverlay(True) + style = self.getCurrentStyle() + shape.setLineStyle(style.getLineStyle()) + shape.setLineWidth(style.getLineWidth()) + shape.setColor(rgba(style.getColor())) + return shape + + def setFirstShapePoints(self, points): + if self._handleClose is not None: + self._handleClose.setPosition(*points[0]) + self.setPoints(points) + + def creationStarted(self): + """"Called when the ROI creation interaction was started. + """ + # Handle to see where to close the polygon + self._handleClose = self.addUserHandle() + self._handleClose.setSymbol("o") + color = self._computeHandleColor(rgba(self.getColor())) + self._handleClose.setColor(color) + + # Hide the center while creating the first shape + self._handleCenter.setSymbol("") + + # In interaction replace the polygon by a line, to display something unclosed + self.removeItem(self.__shape) + self.__shape = self.__createShape(interaction=True) + self.__shape.setPoints(self._points) + self.addItem(self.__shape) + + def isBeingCreated(self): + """Returns true if the ROI is in creation step""" + return self._handleClose is not None + + def creationFinalized(self): + """"Called when the ROI creation interaction was finalized. + """ + self.removeHandle(self._handleClose) + self._handleClose = None + self.removeItem(self.__shape) + self.__shape = self.__createShape() + self.__shape.setPoints(self._points) + self.addItem(self.__shape) + # Hide the center while creating the first shape + self._handleCenter.setSymbol("+") + for handle in self._handlePoints: + handle.setSymbol("s") + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getPoints(self): + """Returns the list of the points of this polygon. + + :rtype: numpy.ndarray + """ + return self._points.copy() + + def setPoints(self, points): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + assert(len(points.shape) == 2 and points.shape[1] == 2) + + if numpy.array_equal(points, self._points): + return # Nothing has changed + + self._polygon_shape = None + + # Update the needed handles + while len(self._handlePoints) != len(points): + if len(self._handlePoints) < len(points): + handle = self.addHandle() + self._handlePoints.append(handle) + if self.isBeingCreated(): + handle.setSymbol("") + else: + handle = self._handlePoints.pop(-1) + self.removeHandle(handle) + + for handle, position in zip(self._handlePoints, points): + with utils.blockSignals(handle): + handle.setPosition(position[0], position[1]) + + if len(points) > 0: + if not self.isHandleBeingDragged(): + vmin = numpy.min(points, axis=0) + vmax = numpy.max(points, axis=0) + center = (vmax + vmin) * 0.5 + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + + num = numpy.argmin(points[:, 1]) + pos = points[num] + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(pos[0], pos[1]) + + if len(points) == 0: + self._points = numpy.empty((0, 2)) + else: + self._points = points + self.__shape.setPoints(self._points) + self.sigRegionChanged.emit() + + def translate(self, x, y): + points = self.getPoints() + delta = numpy.array([x, y]) + self.setPoints(points) + self.setPoints(points + delta) + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleCenter: + delta = current - previous + self.translate(delta[0], delta[1]) + else: + points = self.getPoints() + num = self._handlePoints.index(handle) + points[num] = current + self.setPoints(points) + + def handleDragFinished(self, handle, origin, current): + points = self._points + if len(points) > 0: + # Only update the center at the end + # To avoid to disturb the interaction + vmin = numpy.min(points, axis=0) + vmax = numpy.max(points, axis=0) + center = (vmax + vmin) * 0.5 + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + + def __str__(self): + points = self._points + params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points) + return "%s(%s)" % (self.__class__.__name__, params) + + @docstring(HandleBasedROI) + def contains(self, position): + bb1 = _BoundingBox.from_points(self.getPoints()) + if bb1.contains(position) is False: + return False + + if self._polygon_shape is None: + self._polygon_shape = Polygon(vertices=self.getPoints()) + + # warning: both the polygon and the value are inverted + return self._polygon_shape.is_inside(row=position[0], col=position[1]) + + def _setControlPoints(self, points): + RegionOfInterest._setControlPoints(self, points=points) + self._polygon_shape = None + + +class HorizontalRangeROI(RegionOfInterest, items.LineMixIn): + """A ROI identifying an horizontal range in a 1D plot.""" + + ICON = 'add-range-horizontal' + NAME = 'horizontal range ROI' + SHORT_NAME = "hrange" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._markerMin = items.XMarker() + self._markerMax = items.XMarker() + self._markerCen = items.XMarker() + self._markerCen.setLineStyle(" ") + self._markerMin._setConstraint(self.__positionMinConstraint) + self._markerMax._setConstraint(self.__positionMaxConstraint) + self._markerMin.sigDragStarted.connect(self._editingStarted) + self._markerMin.sigDragFinished.connect(self._editingFinished) + self._markerMax.sigDragStarted.connect(self._editingStarted) + self._markerMax.sigDragFinished.connect(self._editingFinished) + self._markerCen.sigDragStarted.connect(self._editingStarted) + self._markerCen.sigDragFinished.connect(self._editingFinished) + self.addItem(self._markerCen) + self.addItem(self._markerMin) + self.addItem(self._markerMax) + self.__filterReentrant = utils.LockReentrant() + + def setFirstShapePoints(self, points): + vmin = min(points[:, 0]) + vmax = max(points[:, 0]) + self._updatePos(vmin, vmax) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + self._updateText() + elif event == items.ItemChangedType.EDITABLE: + self._updateEditable() + self._updateText() + elif event == items.ItemChangedType.LINE_STYLE: + markers = [self._markerMin, self._markerMax] + self._updateItemProperty(event, self, markers) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + markers = [self._markerMin, self._markerMax, self._markerCen] + self._updateItemProperty(event, self, markers) + super(HorizontalRangeROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + markers = [self._markerMin, self._markerMax, self._markerCen] + for m in markers: + m.setColor(style.getColor()) + m.setLineWidth(style.getLineWidth()) + + def _updateText(self): + text = self.getName() + if self.isEditable(): + self._markerMin.setText("") + self._markerCen.setText(text) + else: + self._markerMin.setText(text) + self._markerCen.setText("") + + def _updateEditable(self): + editable = self.isEditable() + self._markerMin._setDraggable(editable) + self._markerMax._setDraggable(editable) + self._markerCen._setDraggable(editable) + if self.isEditable(): + self._markerMin.sigItemChanged.connect(self._minPositionChanged) + self._markerMax.sigItemChanged.connect(self._maxPositionChanged) + self._markerCen.sigItemChanged.connect(self._cenPositionChanged) + self._markerCen.setLineStyle(":") + else: + self._markerMin.sigItemChanged.disconnect(self._minPositionChanged) + self._markerMax.sigItemChanged.disconnect(self._maxPositionChanged) + self._markerCen.sigItemChanged.disconnect(self._cenPositionChanged) + self._markerCen.setLineStyle(" ") + + def _updatePos(self, vmin, vmax, force=False): + """Update marker position and emit signal. + + :param float vmin: + :param float vmax: + :param bool force: + True to update even if already at the right position. + """ + if not force and numpy.array_equal((vmin, vmax), self.getRange()): + return # Nothing has changed + + center = (vmin + vmax) * 0.5 + with self.__filterReentrant: + with utils.blockSignals(self._markerMin): + self._markerMin.setPosition(vmin, 0) + with utils.blockSignals(self._markerCen): + self._markerCen.setPosition(center, 0) + with utils.blockSignals(self._markerMax): + self._markerMax.setPosition(vmax, 0) + self.sigRegionChanged.emit() + + def setRange(self, vmin, vmax): + """Set the range of this ROI. + + :param float vmin: Staring location of the range + :param float vmax: Ending location of the range + """ + if vmin is None or vmax is None: + err = "Can't set vmin or vmax to None" + raise ValueError(err) + if vmin > vmax: + err = "Can't set vmin and vmax because vmin >= vmax " \ + "vmin = %s, vmax = %s" % (vmin, vmax) + raise ValueError(err) + self._updatePos(vmin, vmax) + + def getRange(self): + """Returns the range of this ROI. + + :rtype: Tuple[float,float] + """ + vmin = self.getMin() + vmax = self.getMax() + return vmin, vmax + + def setMin(self, vmin): + """Set the min of this ROI. + + :param float vmin: New min + """ + vmax = self.getMax() + self._updatePos(vmin, vmax) + + def getMin(self): + """Returns the min value of this ROI. + + :rtype: float + """ + return self._markerMin.getPosition()[0] + + def setMax(self, vmax): + """Set the max of this ROI. + + :param float vmax: New max + """ + vmin = self.getMin() + self._updatePos(vmin, vmax) + + def getMax(self): + """Returns the max value of this ROI. + + :rtype: float + """ + return self._markerMax.getPosition()[0] + + def setCenter(self, center): + """Set the center of this ROI. + + :param float center: New center + """ + vmin, vmax = self.getRange() + previousCenter = (vmin + vmax) * 0.5 + delta = center - previousCenter + self._updatePos(vmin + delta, vmax + delta) + + def getCenter(self): + """Returns the center location of this ROI. + + :rtype: float + """ + vmin, vmax = self.getRange() + return (vmin + vmax) * 0.5 + + def __positionMinConstraint(self, x, y): + """Constraint of the min marker""" + if self.__filterReentrant.locked(): + # Ignore the constraint when we set an explicit value + return x, y + vmax = self.getMax() + if vmax is None: + return x, y + return min(x, vmax), y + + def __positionMaxConstraint(self, x, y): + """Constraint of the max marker""" + if self.__filterReentrant.locked(): + # Ignore the constraint when we set an explicit value + return x, y + vmin = self.getMin() + if vmin is None: + return x, y + return max(x, vmin), y + + def _minPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self._updatePos(marker.getXPosition(), self.getMax(), force=True) + + def _maxPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self._updatePos(self.getMin(), marker.getXPosition(), force=True) + + def _cenPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + 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 + return "%s(%s)" % (self.__class__.__name__, params) diff --git a/src/silx/gui/plot/items/scatter.py b/src/silx/gui/plot/items/scatter.py new file mode 100644 index 0000000..fdc66f7 --- /dev/null +++ b/src/silx/gui/plot/items/scatter.py @@ -0,0 +1,1002 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 the :class:`Scatter` item of the :class:`Plot`. +""" + +from __future__ import division + + +__authors__ = ["T. Vincent", "P. Knobel"] +__license__ = "MIT" +__date__ = "29/03/2017" + + +from collections import namedtuple +import logging +import threading +import numpy + +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, CancelledError + +from ....utils.proxy import docstring +from ....math.combo import min_max +from ....math.histogram import Histogramnd +from ....utils.weakref import WeakList +from .._utils.delaunay import delaunay +from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn +from .axis import Axis +from ._pick import PickingResult + + +_logger = logging.getLogger(__name__) + + +class _GreedyThreadPoolExecutor(ThreadPoolExecutor): + """:class:`ThreadPoolExecutor` with an extra :meth:`submit_greedy` method. + """ + + def __init__(self, *args, **kwargs): + super(_GreedyThreadPoolExecutor, self).__init__(*args, **kwargs) + self.__futures = defaultdict(WeakList) + self.__lock = threading.RLock() + + def submit_greedy(self, queue, fn, *args, **kwargs): + """Same as :meth:`submit` but cancel previous tasks in given queue. + + This means that when a new task is submitted for a given queue, + all other pending tasks of that queue are cancelled. + + :param queue: Identifier of the queue. This must be hashable. + :param callable fn: The callable to call with provided extra arguments + :return: Future corresponding to this task + :rtype: concurrent.futures.Future + """ + with self.__lock: + # Cancel previous tasks in given queue + for future in self.__futures.pop(queue, []): + if not future.done(): + future.cancel() + + future = super(_GreedyThreadPoolExecutor, self).submit( + fn, *args, **kwargs) + self.__futures[queue].append(future) + + return future + + +# Functions to guess grid shape from coordinates + +def _get_z_line_length(array): + """Return length of line if array is a Z-like 2D regular grid. + + :param numpy.ndarray array: The 1D array of coordinates to check + :return: 0 if no line length could be found, + else the number of element per line. + :rtype: int + """ + sign = numpy.sign(numpy.diff(array)) + if len(sign) == 0 or sign[0] == 0: # We don't handle that + return 0 + # Check this way to account for 0 sign (i.e., diff == 0) + beginnings = numpy.where(sign == - sign[0])[0] + 1 + if len(beginnings) == 0: + return 0 + length = beginnings[0] + if numpy.all(numpy.equal(numpy.diff(beginnings), length)): + return length + return 0 + + +def _guess_z_grid_shape(x, y): + """Guess the shape of a grid from (x, y) coordinates. + + The grid might contain more elements than x and y, + as the last line might be partly filled. + + :param numpy.ndarray x: + :paran numpy.ndarray y: + :returns: (order, (height, width)) of the regular grid, + or None if could not guess one. + 'order' is 'row' if X (i.e., column) is the fast dimension, else 'column'. + :rtype: Union[List(str,int),None] + """ + width = _get_z_line_length(x) + if width != 0: + return 'row', (int(numpy.ceil(len(x) / width)), width) + else: + height = _get_z_line_length(y) + if height != 0: + return 'column', (height, int(numpy.ceil(len(y) / height))) + return None + + +def is_monotonic(array): + """Returns whether array is monotonic (increasing or decreasing). + + :param numpy.ndarray array: 1D array-like container. + :returns: 1 if array is monotonically increasing, + -1 if array is monotonically decreasing, + 0 if array is not monotonic + :rtype: int + """ + diff = numpy.diff(numpy.ravel(array)) + with numpy.errstate(invalid='ignore'): + if numpy.all(diff >= 0): + return 1 + elif numpy.all(diff <= 0): + return -1 + else: + return 0 + + +def _guess_grid(x, y): + """Guess a regular grid from the points. + + Result convention is (x, y) + + :param numpy.ndarray x: X coordinates of the points + :param numpy.ndarray y: Y coordinates of the points + :returns: (order, (height, width) + order is 'row' or 'column' + :rtype: Union[List[str,List[int]],None] + """ + x, y = numpy.ravel(x), numpy.ravel(y) + + guess = _guess_z_grid_shape(x, y) + if guess is not None: + return guess + + else: + # Cannot guess a regular grid + # Let's assume it's a single line + order = 'row' # or 'column' doesn't matter for a single line + y_monotonic = is_monotonic(y) + if is_monotonic(x) or y_monotonic: # we can guess a line + x_min, x_max = min_max(x) + y_min, y_max = min_max(y) + + if not y_monotonic or x_max - x_min >= y_max - y_min: + # x only is monotonic or both are and X varies more + # line along X + shape = 1, len(x) + else: + # y only is monotonic or both are and Y varies more + # line along Y + shape = len(y), 1 + + else: # Cannot guess a line from the points + return None + + return order, shape + + +def _quadrilateral_grid_coords(points): + """Compute an irregular grid of quadrilaterals from a set of points + + The input points are expected to lie on a grid. + + :param numpy.ndarray points: + 3D data set of 2D input coordinates (height, width, 2) + height and width must be at least 2. + :return: 3D dataset of 2D coordinates of the grid (height+1, width+1, 2) + """ + assert points.ndim == 3 + assert points.shape[0] >= 2 + assert points.shape[1] >= 2 + assert points.shape[2] == 2 + + dim0, dim1 = points.shape[:2] + grid_points = numpy.zeros((dim0 + 1, dim1 + 1, 2), dtype=numpy.float64) + + # Compute inner points as mean of 4 neighbours + neighbour_view = numpy.lib.stride_tricks.as_strided( + points, + shape=(dim0 - 1, dim1 - 1, 2, 2, points.shape[2]), + strides=points.strides[:2] + points.strides[:2] + points.strides[-1:], writeable=False) + inner_points = numpy.mean(neighbour_view, axis=(2, 3)) + grid_points[1:-1, 1:-1] = inner_points + + # Compute 'vertical' sides + # Alternative: grid_points[1:-1, [0, -1]] = points[:-1, [0, -1]] + points[1:, [0, -1]] - inner_points[:, [0, -1]] + grid_points[1:-1, [0, -1], 0] = points[:-1, [0, -1], 0] + points[1:, [0, -1], 0] - inner_points[:, [0, -1], 0] + grid_points[1:-1, [0, -1], 1] = inner_points[:, [0, -1], 1] + + # Compute 'horizontal' sides + grid_points[[0, -1], 1:-1, 0] = inner_points[[0, -1], :, 0] + grid_points[[0, -1], 1:-1, 1] = points[[0, -1], :-1, 1] + points[[0, -1], 1:, 1] - inner_points[[0, -1], :, 1] + + # Compute corners + d0, d1 = [0, 0, -1, -1], [0, -1, -1, 0] + grid_points[d0, d1] = 2 * points[d0, d1] - inner_points[d0, d1] + return grid_points + + +def _quadrilateral_grid_as_triangles(points): + """Returns the points and indices to make a grid of quadirlaterals + + :param numpy.ndarray points: + 3D array of points (height, width, 2) + :return: triangle corners (4 * N, 2), triangle indices (2 * N, 3) + With N = height * width, the number of input points + """ + nbpoints = numpy.prod(points.shape[:2]) + + grid = _quadrilateral_grid_coords(points) + coords = numpy.empty((4 * nbpoints, 2), dtype=grid.dtype) + coords[::4] = grid[:-1, :-1].reshape(-1, 2) + coords[1::4] = grid[1:, :-1].reshape(-1, 2) + coords[2::4] = grid[:-1, 1:].reshape(-1, 2) + coords[3::4] = grid[1:, 1:].reshape(-1, 2) + + indices = numpy.empty((2 * nbpoints, 3), dtype=numpy.uint32) + indices[::2, 0] = numpy.arange(0, 4 * nbpoints, 4) + indices[::2, 1] = numpy.arange(1, 4 * nbpoints, 4) + indices[::2, 2] = numpy.arange(2, 4 * nbpoints, 4) + indices[1::2, 0] = indices[::2, 1] + indices[1::2, 1] = indices[::2, 2] + indices[1::2, 2] = numpy.arange(3, 4 * nbpoints, 4) + + return coords, indices + + +_RegularGridInfo = namedtuple( + '_RegularGridInfo', ['bounds', 'origin', 'scale', 'shape', 'order']) + + +_HistogramInfo = namedtuple( + '_HistogramInfo', ['mean', 'count', 'sum', 'origin', 'scale', 'shape']) + + +class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): + """Description of a scatter""" + + _DEFAULT_SELECTABLE = True + """Default selectable state for scatter plots""" + + _SUPPORTED_SCATTER_VISUALIZATION = ( + ScatterVisualizationMixIn.Visualization.POINTS, + ScatterVisualizationMixIn.Visualization.SOLID, + ScatterVisualizationMixIn.Visualization.REGULAR_GRID, + ScatterVisualizationMixIn.Visualization.IRREGULAR_GRID, + ScatterVisualizationMixIn.Visualization.BINNED_STATISTIC, + ) + """Overrides supported Visualizations""" + + def __init__(self): + PointsBase.__init__(self) + ColormapMixIn.__init__(self) + ScatterVisualizationMixIn.__init__(self) + self._value = () + self.__alpha = None + # Cache Delaunay triangulation future object + self.__delaunayFuture = None + # Cache interpolator future object + self.__interpolatorFuture = None + self.__executor = None + + # Cache triangles: x, y, indices + self.__cacheTriangles = None, None, None + + # Cache regular grid and histogram info + self.__cacheRegularGridInfo = None + self.__cacheHistogramInfo = None + + def _updateColormappedData(self): + """Update the colormapped data, to be called when changed""" + if self.getVisualization() is self.Visualization.BINNED_STATISTIC: + histoInfo = self.__getHistogramInfo() + if histoInfo is None: + data = None + else: + data = getattr( + histoInfo, + self.getVisualizationParameter( + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION)) + else: + data = self.getValueData(copy=False) + self._setColormappedData(data, copy=False) + + @docstring(ScatterVisualizationMixIn) + def setVisualization(self, mode): + previous = self.getVisualization() + if super().setVisualization(mode): + if (bool(mode is self.Visualization.BINNED_STATISTIC) ^ + bool(previous is self.Visualization.BINNED_STATISTIC)): + self._updateColormappedData() + return True + else: + return False + + @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, + self.VisualizationParameter.GRID_SHAPE): + self.__cacheRegularGridInfo = None + + if parameter in (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() + return True + else: + return False + + @docstring(ScatterVisualizationMixIn) + def getCurrentVisualizationParameter(self, parameter): + value = self.getVisualizationParameter(parameter) + 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: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.bounds + + elif parameter is self.VisualizationParameter.GRID_MAJOR_ORDER: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.order + + elif parameter is self.VisualizationParameter.GRID_SHAPE: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.shape + + elif parameter is self.VisualizationParameter.BINNED_STATISTIC_SHAPE: + info = self.__getHistogramInfo() + return None if info is None else info.shape + + else: + raise NotImplementedError() + + def __getRegularGridInfo(self): + """Get grid info""" + if self.__cacheRegularGridInfo is None: + shape = self.getVisualizationParameter( + self.VisualizationParameter.GRID_SHAPE) + order = self.getVisualizationParameter( + self.VisualizationParameter.GRID_MAJOR_ORDER) + if shape is None or order is None: + guess = _guess_grid(self.getXData(copy=False), + self.getYData(copy=False)) + if guess is None: + _logger.warning( + 'Cannot guess a grid: Cannot display as regular grid image') + return None + if shape is None: + shape = guess[1] + if order is None: + order = guess[0] + + nbpoints = len(self.getXData(copy=False)) + if nbpoints > shape[0] * shape[1]: + # More data points that provided grid shape: enlarge grid + _logger.warning( + "More data points than provided grid shape size: extends grid") + dim0, dim1 = shape + if order == 'row': # keep dim1, enlarge dim0 + dim0 = nbpoints // dim1 + (1 if nbpoints % dim1 else 0) + else: # keep dim0, enlarge dim1 + dim1 = nbpoints // dim0 + (1 if nbpoints % dim0 else 0) + shape = dim0, dim1 + + bounds = self.getVisualizationParameter( + self.VisualizationParameter.GRID_BOUNDS) + if bounds is None: + x, y = self.getXData(copy=False), self.getYData(copy=False) + min_, max_ = min_max(x) + xRange = (min_, max_) if (x[0] - min_) < (max_ - x[0]) else (max_, min_) + min_, max_ = min_max(y) + yRange = (min_, max_) if (y[0] - min_) < (max_ - y[0]) else (max_, min_) + bounds = (xRange[0], yRange[0]), (xRange[1], yRange[1]) + + begin, end = bounds + scale = ((end[0] - begin[0]) / max(1, shape[1] - 1), + (end[1] - begin[1]) / max(1, shape[0] - 1)) + if scale[0] == 0 and scale[1] == 0: + scale = 1., 1. + elif scale[0] == 0: + scale = scale[1], scale[1] + elif scale[1] == 0: + scale = scale[0], scale[0] + + origin = begin[0] - 0.5 * scale[0], begin[1] - 0.5 * scale[1] + + self.__cacheRegularGridInfo = _RegularGridInfo( + bounds=bounds, origin=origin, scale=scale, shape=shape, order=order) + + return self.__cacheRegularGridInfo + + def __getHistogramInfo(self): + """Get histogram info""" + if self.__cacheHistogramInfo is None: + shape = self.getVisualizationParameter( + self.VisualizationParameter.BINNED_STATISTIC_SHAPE) + if shape is None: + shape = 100, 100 # TODO compute auto shape + + x, y, values = self.getData(copy=False)[:3] + if len(x) == 0: # No histogram + return None + + if not numpy.issubdtype(x.dtype, numpy.floating): + x = x.astype(numpy.float64) + if not numpy.issubdtype(y.dtype, numpy.floating): + y = y.astype(numpy.float64) + if not numpy.issubdtype(values.dtype, numpy.floating): + values = values.astype(numpy.float64) + + 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, + histo_range=ranges, + n_bins=shape, + weights=values) + yEdges, xEdges = bin_edges + origin = xEdges[0], yEdges[0] + scale = ((xEdges[-1] - xEdges[0]) / (len(xEdges) - 1), + (yEdges[-1] - yEdges[0]) / (len(yEdges) - 1)) + + with numpy.errstate(divide='ignore', invalid='ignore'): + histo = sums / counts + + self.__cacheHistogramInfo = _HistogramInfo( + mean=histo, count=counts, sum=sums, + origin=origin, scale=scale, shape=shape) + + return self.__cacheHistogramInfo + + def __applyColormapToData(self): + """Compute colors by applying colormap to values. + + :returns: Array of RGBA colors + """ + cmap = self.getColormap() + rgbacolors = cmap.applyToData(self) + + if self.__alpha is not None: + rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8) + return rgbacolors + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + # Filter-out values <= 0 + xFiltered, yFiltered, valueFiltered, xerror, yerror = self.getData( + copy=False, displayed=True) + + # Remove not finite numbers (this includes filtered out x, y <= 0) + mask = numpy.logical_and(numpy.isfinite(xFiltered), numpy.isfinite(yFiltered)) + xFiltered = xFiltered[mask] + yFiltered = yFiltered[mask] + + if len(xFiltered) == 0: + return None # No data to display, do not add renderer to backend + + visualization = self.getVisualization() + + if visualization is self.Visualization.BINNED_STATISTIC: + plot = self.getPlot() + if (plot is None or + plot.getXAxis().getScale() != Axis.LINEAR or + plot.getYAxis().getScale() != Axis.LINEAR): + # Those visualizations are not available with log scaled axes + return None + + histoInfo = self.__getHistogramInfo() + if histoInfo is None: + return None + data = getattr(histoInfo, self.getVisualizationParameter( + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION)) + + return backend.addImage( + data=data, + origin=histoInfo.origin, + scale=histoInfo.scale, + colormap=self.getColormap(), + alpha=self.getAlpha()) + + elif visualization is self.Visualization.POINTS: + rgbacolors = self.__applyColormapToData() + return backend.addCurve(xFiltered, yFiltered, + color=rgbacolors[mask], + symbol=self.getSymbol(), + linewidth=0, + linestyle="", + yaxis='left', + xerror=xerror, + yerror=yerror, + fill=False, + alpha=self.getAlpha(), + symbolsize=self.getSymbolSize(), + baseline=None) + + else: + plot = self.getPlot() + if (plot is None or + plot.getXAxis().getScale() != Axis.LINEAR or + plot.getYAxis().getScale() != Axis.LINEAR): + # Those visualizations are not available with log scaled axes + return None + + if visualization is self.Visualization.SOLID: + triangulation = self._getDelaunay().result() + if triangulation is None: + _logger.warning( + 'Cannot get a triangulation: Cannot display as solid surface') + return None + else: + rgbacolors = self.__applyColormapToData() + triangles = triangulation.simplices.astype(numpy.int32) + return backend.addTriangles(xFiltered, + yFiltered, + triangles, + color=rgbacolors[mask], + alpha=self.getAlpha()) + + elif visualization is self.Visualization.REGULAR_GRID: + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + dim0, dim1 = gridInfo.shape + if gridInfo.order == 'column': # transposition needed + dim0, dim1 = dim1, dim0 + + values = self.getValueData(copy=False) + if self.__alpha is None and len(values) == dim0 * dim1: + image = values.reshape(dim0, dim1) + else: + # The points do not fill the whole image + if (self.__alpha is None and + numpy.issubdtype(values.dtype, numpy.floating)): + image = numpy.empty(dim0 * dim1, dtype=values.dtype) + image[:len(values)] = values + image[len(values):] = float('nan') # Transparent pixels + image.shape = dim0, dim1 + else: # Per value alpha or no NaN, so convert to RGBA + rgbacolors = self.__applyColormapToData() + image = numpy.empty((dim0 * dim1, 4), dtype=numpy.uint8) + image[:len(rgbacolors)] = rgbacolors + image[len(rgbacolors):] = (0, 0, 0, 0) # Transparent pixels + image.shape = dim0, dim1, 4 + + if gridInfo.order == 'column': + if image.ndim == 2: + image = numpy.transpose(image) + else: + image = numpy.transpose(image, axes=(1, 0, 2)) + + if image.ndim == 2: + colormap = self.getColormap() + if colormap.isAutoscale(): + # Avoid backend to compute autoscale: use item cache + colormap = colormap.copy() + colormap.setVRange(*colormap.getColormapRange(self)) + else: + colormap = None + + return backend.addImage( + data=image, + origin=gridInfo.origin, + scale=gridInfo.scale, + colormap=colormap, + alpha=self.getAlpha()) + + elif visualization is self.Visualization.IRREGULAR_GRID: + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + shape = gridInfo.shape + if shape is None: # No shape, no display + return None + + rgbacolors = self.__applyColormapToData() + + nbpoints = len(xFiltered) + if nbpoints == 1: + # single point, render as a square points + return backend.addCurve(xFiltered, yFiltered, + color=rgbacolors[mask], + symbol='s', + linewidth=0, + linestyle="", + yaxis='left', + xerror=None, + yerror=None, + fill=False, + alpha=self.getAlpha(), + symbolsize=7, + baseline=None) + + # Make shape include all points + gridOrder = gridInfo.order + if nbpoints != numpy.prod(shape): + if gridOrder == 'row': + shape = int(numpy.ceil(nbpoints / shape[1])), shape[1] + else: # column-major order + shape = shape[0], int(numpy.ceil(nbpoints / shape[0])) + + if shape[0] < 2 or shape[1] < 2: # Single line, at least 2 points + points = numpy.ones((2, nbpoints, 2), dtype=numpy.float64) + # Use row/column major depending on shape, not on info value + gridOrder = 'row' if shape[0] == 1 else 'column' + + if gridOrder == 'row': + points[0, :, 0] = xFiltered + points[0, :, 1] = yFiltered + else: # column-major order + points[0, :, 0] = yFiltered + points[0, :, 1] = xFiltered + + # Add a second line that will be clipped in the end + points[1, :-1] = points[0, :-1] + numpy.cross( + points[0, 1:] - points[0, :-1], (0., 0., 1.))[:, :2] + points[1, -1] = points[0, -1] + numpy.cross( + points[0, -1] - points[0, -2], (0., 0., 1.))[:2] + + points.shape = 2, nbpoints, 2 # Use same shape for both orders + coords, indices = _quadrilateral_grid_as_triangles(points) + + elif gridOrder == 'row': # row-major order + if nbpoints != numpy.prod(shape): + points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64) + points[:nbpoints, 0] = xFiltered + points[:nbpoints, 1] = yFiltered + # Index of last element of last fully filled row + index = (nbpoints // shape[1]) * shape[1] + points[nbpoints:, 0] = xFiltered[index - (numpy.prod(shape) - nbpoints):index] + points[nbpoints:, 1] = yFiltered[-1] + else: + points = numpy.transpose((xFiltered, yFiltered)) + points.shape = shape[0], shape[1], 2 + + else: # column-major order + if nbpoints != numpy.prod(shape): + points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64) + points[:nbpoints, 0] = yFiltered + points[:nbpoints, 1] = xFiltered + # Index of last element of last fully filled column + index = (nbpoints // shape[0]) * shape[0] + points[nbpoints:, 0] = yFiltered[index - (numpy.prod(shape) - nbpoints):index] + points[nbpoints:, 1] = xFiltered[-1] + else: + points = numpy.transpose((yFiltered, xFiltered)) + points.shape = shape[1], shape[0], 2 + + coords, indices = _quadrilateral_grid_as_triangles(points) + + # Remove unused extra triangles + coords = coords[:4*nbpoints] + indices = indices[:2*nbpoints] + + if gridOrder == 'row': + x, y = coords[:, 0], coords[:, 1] + else: # column-major order + y, x = coords[:, 0], coords[:, 1] + + rgbacolors = rgbacolors[mask] # Filter-out not finite points + gridcolors = numpy.empty( + (4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype) + for first in range(4): + gridcolors[first::4] = rgbacolors[:nbpoints] + + return backend.addTriangles(x, + y, + indices, + color=gridcolors, + alpha=self.getAlpha()) + + else: + _logger.error("Unhandled visualization %s", visualization) + return None + + @docstring(PointsBase) + def pick(self, x, y): + result = super(Scatter, self).pick(x, y) + + if result is not None: + visualization = self.getVisualization() + + if visualization is self.Visualization.IRREGULAR_GRID: + # Specific handling of picking for the irregular grid mode + index = result.getIndices(copy=False)[0] // 4 + result = PickingResult(self, (index,)) + + elif visualization is self.Visualization.REGULAR_GRID: + # Specific handling of picking for the regular grid mode + picked = result.getIndices(copy=False) + if picked is None: + return None + row, column = picked[0][0], picked[1][0] + + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + if gridInfo.order == 'row': + index = row * gridInfo.shape[1] + column + else: + index = row + column * gridInfo.shape[0] + if index >= len(self.getXData(copy=False)): # OK as long as not log scale + return None # Image can be larger than scatter + + result = PickingResult(self, (index,)) + + elif visualization is self.Visualization.BINNED_STATISTIC: + picked = result.getIndices(copy=False) + if picked is None or len(picked) == 0 or len(picked[0]) == 0: + return None + row, col = picked[0][0], picked[1][0] + histoInfo = self.__getHistogramInfo() + if histoInfo is None: + return None + sx, sy = histoInfo.scale + ox, oy = histoInfo.origin + xdata = self.getXData(copy=False) + ydata = self.getYData(copy=False) + indices = numpy.nonzero(numpy.logical_and( + numpy.logical_and(xdata >= ox + sx * col, xdata < ox + sx * (col + 1)), + numpy.logical_and(ydata >= oy + sy * row, ydata < oy + sy * (row + 1))))[0] + result = None if len(indices) == 0 else PickingResult(self, indices) + + return result + + def __getExecutor(self): + """Returns async greedy executor + + :rtype: _GreedyThreadPoolExecutor + """ + if self.__executor is None: + self.__executor = _GreedyThreadPoolExecutor(max_workers=2) + return self.__executor + + def _getDelaunay(self): + """Returns a :class:`Future` which result is the Delaunay object. + + :rtype: concurrent.futures.Future + """ + if self.__delaunayFuture is None or self.__delaunayFuture.cancelled(): + # Need to init a new delaunay + x, y = self.getData(copy=False)[:2] + # Remove not finite points + mask = numpy.logical_and(numpy.isfinite(x), numpy.isfinite(y)) + + self.__delaunayFuture = self.__getExecutor().submit_greedy( + 'delaunay', delaunay, x[mask], y[mask]) + + return self.__delaunayFuture + + @staticmethod + def __initInterpolator(delaunayFuture, values): + """Returns an interpolator for the given data points + + :param concurrent.futures.Future delaunayFuture: + Future object which result is a Delaunay object + :param numpy.ndarray values: The data value of valid points. + :rtype: Union[callable,None] + """ + # Wait for Delaunay to complete + try: + triangulation = delaunayFuture.result() + except CancelledError: + triangulation = None + + if triangulation is None: + interpolator = None # Error case + else: + # Lazy-loading of interpolator + try: + from scipy.interpolate import LinearNDInterpolator + except ImportError: + LinearNDInterpolator = None + + if LinearNDInterpolator is not None: + interpolator = LinearNDInterpolator(triangulation, values) + + # First call takes a while, do it here + interpolator([(0., 0.)]) + + else: + # Fallback using matplotlib interpolator + import matplotlib.tri + + x, y = triangulation.points.T + tri = matplotlib.tri.Triangulation( + x, y, triangles=triangulation.simplices) + mplInterpolator = matplotlib.tri.LinearTriInterpolator( + tri, values) + + # Wrap interpolator to have same API as scipy's one + def interpolator(points): + return mplInterpolator(*points.T) + + return interpolator + + def _getInterpolator(self): + """Returns a :class:`Future` which result is the interpolator. + + The interpolator is a callable taking an array Nx2 of points + as a single argument. + The :class:`Future` result is None in case the interpolator cannot + be initialized. + + :rtype: concurrent.futures.Future + """ + if (self.__interpolatorFuture is None or + self.__interpolatorFuture.cancelled()): + # Need to init a new interpolator + x, y, values = self.getData(copy=False)[:3] + # Remove not finite points + mask = numpy.logical_and(numpy.isfinite(x), numpy.isfinite(y)) + x, y, values = x[mask], y[mask], values[mask] + + self.__interpolatorFuture = self.__getExecutor().submit_greedy( + 'interpolator', + self.__initInterpolator, self._getDelaunay(), values) + return self.__interpolatorFuture + + def _logFilterData(self, xPositive, yPositive): + """Filter out values with x or y <= 0 on log axes + + :param bool xPositive: True to filter arrays according to X coords. + :param bool yPositive: True to filter arrays according to Y coords. + :return: The filtered arrays or unchanged object if not filtering needed + :rtype: (x, y, value, xerror, yerror) + """ + # overloaded from PointsBase to filter also value. + value = self.getValueData(copy=False) + + if xPositive or yPositive: + clipped = self._getClippingBoolArray(xPositive, yPositive) + + if numpy.any(clipped): + # copy to keep original array and convert to float + value = numpy.array(value, copy=True, dtype=numpy.float64) + value[clipped] = numpy.nan + + x, y, xerror, yerror = PointsBase._logFilterData(self, xPositive, yPositive) + + return x, y, value, xerror, yerror + + def getValueData(self, copy=True): + """Returns the value assigned to the scatter data points. + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self._value, copy=copy) + + def getAlphaData(self, copy=True): + """Returns the alpha (transparency) assigned to the scatter data points. + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray + """ + return numpy.array(self.__alpha, copy=copy) + + def getData(self, copy=True, displayed=False): + """Returns the x, y coordinates and the value of the data points + + :param copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :param bool displayed: True to only get curve points that are displayed + in the plot. Default: False. + Note: If plot has log scale, negative points + are not displayed. + :returns: (x, y, value, xerror, yerror) + :rtype: 5-tuple of numpy.ndarray + """ + if displayed: + data = self._getCachedData() + if data is not None: + assert len(data) == 5 + return data + + return (self.getXData(copy), + self.getYData(copy), + self.getValueData(copy), + self.getXErrorData(copy), + self.getYErrorData(copy)) + + # reimplemented from PointsBase to handle `value` + def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True): + """Set the data of the scatter. + + :param numpy.ndarray x: The data corresponding to the x coordinates. + :param numpy.ndarray y: The data corresponding to the y coordinates. + :param numpy.ndarray value: The data corresponding to the value of + the data points. + :param xerror: Values with the uncertainties on the x values + :type xerror: A float, or a numpy.ndarray of float32. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for positive errors, + row 1 for negative errors. + :param yerror: Values with the uncertainties on the y values + :type yerror: A float, or a numpy.ndarray of float32. See xerror. + :param alpha: Values with the transparency (between 0 and 1) + :type alpha: A float, or a numpy.ndarray of float32 + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + value = numpy.array(value, copy=copy) + assert value.ndim == 1 + assert len(x) == len(value) + + # Convert complex data + if numpy.iscomplexobj(value): + _logger.warning( + 'Converting value data to absolute value to plot it.') + value = numpy.absolute(value) + + # Reset triangulation and interpolator + if self.__delaunayFuture is not None: + self.__delaunayFuture.cancel() + self.__delaunayFuture = None + if self.__interpolatorFuture is not None: + self.__interpolatorFuture.cancel() + self.__interpolatorFuture = None + + # Data changed, this needs update + self.__cacheRegularGridInfo = None + self.__cacheHistogramInfo = None + + self._value = value + + if alpha is not None: + # Make sure alpha is an array of float in [0, 1] + alpha = numpy.array(alpha, copy=copy) + assert alpha.ndim == 1 + assert len(x) == len(alpha) + if alpha.dtype.kind != 'f': + alpha = alpha.astype(numpy.float32) + if numpy.any(numpy.logical_or(alpha < 0., alpha > 1.)): + alpha = numpy.clip(alpha, 0., 1.) + self.__alpha = alpha + + # set x, y, xerror, yerror + + # call self._updated + plot._invalidateDataRange() + PointsBase.setData(self, x, y, xerror, yerror, copy) + + self._updateColormappedData() diff --git a/src/silx/gui/plot/items/shape.py b/src/silx/gui/plot/items/shape.py new file mode 100644 index 0000000..00ac5f5 --- /dev/null +++ b/src/silx/gui/plot/items/shape.py @@ -0,0 +1,287 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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 the :class:`Shape` item of the :class:`Plot`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "21/12/2018" + + +import logging + +import numpy + +from ... import colors +from .core import ( + Item, DataItem, + ColorMixIn, FillMixIn, ItemChangedType, LineMixIn, YAxisMixIn) + + +_logger = logging.getLogger(__name__) + + +# TODO probably make one class for each kind of shape +# TODO check fill:polygon/polyline + fill = duplicated +class Shape(Item, ColorMixIn, FillMixIn, LineMixIn): + """Description of a shape item + + :param str type_: The type of shape in: + 'hline', 'polygon', 'rectangle', 'vline', 'polylines' + """ + + def __init__(self, type_): + Item.__init__(self) + ColorMixIn.__init__(self) + FillMixIn.__init__(self) + LineMixIn.__init__(self) + self._overlay = False + assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polylines') + self._type = type_ + self._points = () + self._lineBgColor = None + + self._handle = None + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + points = self.getPoints(copy=False) + x, y = points.T[0], points.T[1] + return backend.addShape(x, + y, + shape=self.getType(), + color=self.getColor(), + fill=self.isFill(), + overlay=self.isOverlay(), + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + linebgcolor=self.getLineBgColor()) + + def isOverlay(self): + """Return true if shape is drawn as an overlay + + :rtype: bool + """ + return self._overlay + + def setOverlay(self, overlay): + """Set the overlay state of the shape + + :param bool overlay: True to make it an overlay + """ + overlay = bool(overlay) + if overlay != self._overlay: + self._overlay = overlay + self._updated(ItemChangedType.OVERLAY) + + def getType(self): + """Returns the type of shape to draw. + + One of: 'hline', 'polygon', 'rectangle', 'vline', 'polylines' + + :rtype: str + """ + return self._type + + def getPoints(self, copy=True): + """Get the control points of the shape. + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :return: Array of point coordinates + :rtype: numpy.ndarray with 2 dimensions + """ + return numpy.array(self._points, copy=copy) + + def setPoints(self, points, copy=True): + """Set the point coordinates + + :param numpy.ndarray points: Array of point coordinates + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :return: + """ + self._points = numpy.array(points, copy=copy) + self._updated(ItemChangedType.DATA) + + def getLineBgColor(self): + """Returns the RGBA color of the item + :rtype: 4-tuple of float in [0, 1] or array of colors + """ + return self._lineBgColor + + def setLineBgColor(self, color, copy=True): + """Set item color + :param color: color(s) to be used + :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or + one of the predefined color names defined in colors.py + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + if color is not None: + if isinstance(color, str): + color = colors.rgba(color) + else: + color = numpy.array(color, copy=copy) + # TODO more checks + improve color array support + if color.ndim == 1: # Single RGBA color + color = colors.rgba(color) + else: # Array of colors + assert color.ndim == 2 + + self._lineBgColor = color + self._updated(ItemChangedType.LINE_BG_COLOR) + + +class BoundingRect(DataItem, YAxisMixIn): + """An invisible shape which enforce the plot view to display the defined + space on autoscale. + + This item do not display anything. But if the visible property is true, + this bounding box is used by the plot, if not, the bounding box is + ignored. That's the default behaviour for plot items. + + It can be applied on the "left" or "right" axes. Not both at the same time. + """ + + def __init__(self): + DataItem.__init__(self) + YAxisMixIn.__init__(self) + self.__bounds = None + + def setBounds(self, rect): + """Set the bounding box of this item in data coordinates + + :param Union[None,List[float]] rect: (xmin, xmax, ymin, ymax) or None + """ + if rect is not None: + rect = float(rect[0]), float(rect[1]), float(rect[2]), float(rect[3]) + assert rect[0] <= rect[1] + assert rect[2] <= rect[3] + + if rect != self.__bounds: + self.__bounds = rect + self._boundsChanged() + self._updated(ItemChangedType.DATA) + + def _getBounds(self): + if self.__bounds is None: + return None + plot = self.getPlot() + if plot is not None: + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() + if xPositive or yPositive: + bounds = list(self.__bounds) + if xPositive and bounds[1] <= 0: + return None + if xPositive and bounds[0] <= 0: + bounds[0] = bounds[1] + if yPositive and bounds[3] <= 0: + return None + if yPositive and bounds[2] <= 0: + bounds[2] = bounds[3] + return tuple(bounds) + + return self.__bounds + + +class _BaseExtent(DataItem): + """Base class for :class:`XAxisExtent` and :class:`YAxisExtent`. + + :param str axis: Either 'x' or 'y'. + """ + + def __init__(self, axis='x'): + assert axis in ('x', 'y') + DataItem.__init__(self) + self.__axis = axis + self.__range = 1., 100. + + def setRange(self, min_, max_): + """Set the range of the extent of this item in data coordinates. + + :param float min_: Lower bound of the extent + :param float max_: Upper bound of the extent + :raises ValueError: If min > max or not finite bounds + """ + range_ = float(min_), float(max_) + if not numpy.all(numpy.isfinite(range_)): + raise ValueError("min_ and max_ must be finite numbers.") + if range_[0] > range_[1]: + raise ValueError("min_ must be lesser or equal to max_") + + if range_ != self.__range: + self.__range = range_ + self._boundsChanged() + self._updated(ItemChangedType.DATA) + + def getRange(self): + """Returns the range (min, max) of the extent in data coordinates. + + :rtype: List[float] + """ + return self.__range + + def _getBounds(self): + min_, max_ = self.getRange() + + plot = self.getPlot() + if plot is not None: + axis = plot.getXAxis() if self.__axis == 'x' else plot.getYAxis() + if axis._isLogarithmic(): + if max_ <= 0: + return None + if min_ <= 0: + min_ = max_ + + if self.__axis == 'x': + return min_, max_, float('nan'), float('nan') + else: + return float('nan'), float('nan'), min_, max_ + + +class XAxisExtent(_BaseExtent): + """Invisible item with a settable horizontal data extent. + + This item do not display anything, but it behaves as a data + item with a horizontal extent regarding plot data bounds, i.e., + :meth:`PlotWidget.resetZoom` will take this horizontal extent into account. + """ + def __init__(self): + _BaseExtent.__init__(self, axis='x') + + +class YAxisExtent(_BaseExtent, YAxisMixIn): + """Invisible item with a settable vertical data extent. + + This item do not display anything, but it behaves as a data + item with a vertical extent regarding plot data bounds, i.e., + :meth:`PlotWidget.resetZoom` will take this vertical extent into account. + """ + + def __init__(self): + _BaseExtent.__init__(self, axis='y') + YAxisMixIn.__init__(self) |