diff options
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/__init__.py | 52 | ||||
-rw-r--r-- | silx/gui/plot/items/_arc_roi.py | 878 | ||||
-rw-r--r-- | silx/gui/plot/items/_pick.py | 72 | ||||
-rw-r--r-- | silx/gui/plot/items/_roi_base.py | 835 | ||||
-rw-r--r-- | silx/gui/plot/items/axis.py | 569 | ||||
-rw-r--r-- | silx/gui/plot/items/complex.py | 386 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 1734 | ||||
-rw-r--r-- | silx/gui/plot/items/curve.py | 326 | ||||
-rw-r--r-- | silx/gui/plot/items/histogram.py | 389 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 617 | ||||
-rwxr-xr-x | silx/gui/plot/items/marker.py | 281 | ||||
-rw-r--r-- | silx/gui/plot/items/roi.py | 1519 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 973 | ||||
-rw-r--r-- | silx/gui/plot/items/shape.py | 288 |
14 files changed, 0 insertions, 8919 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py deleted file mode 100644 index 0484025..0000000 --- a/silx/gui/plot/items/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -# 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 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, ImageRgba, ImageStack, MaskImageData # 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/silx/gui/plot/items/_arc_roi.py b/silx/gui/plot/items/_arc_roi.py deleted file mode 100644 index 23416ec..0000000 --- a/silx/gui/plot/items/_arc_roi.py +++ /dev/null @@ -1,878 +0,0 @@ -# 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/silx/gui/plot/items/_pick.py b/silx/gui/plot/items/_pick.py deleted file mode 100644 index 8c8e781..0000000 --- a/silx/gui/plot/items/_pick.py +++ /dev/null @@ -1,72 +0,0 @@ -# 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/silx/gui/plot/items/_roi_base.py b/silx/gui/plot/items/_roi_base.py deleted file mode 100644 index 3eb6cf4..0000000 --- a/silx/gui/plot/items/_roi_base.py +++ /dev/null @@ -1,835 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018-2020 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides base components to create ROI item for -the :class:`~silx.gui.plot.PlotWidget`. - -.. inheritance-diagram:: - silx.gui.plot.items.roi - :parts: 1 -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import logging -import numpy -import weakref - -from ....utils.weakref import WeakList -from ... import qt -from .. import items -from ..items import core -from ...colors import rgba -import silx.utils.deprecation -from ....utils.proxy import docstring - - -logger = logging.getLogger(__name__) - - -class _RegionOfInterestBase(qt.QObject): - """Base class of 1D and 2D region of interest - - :param QObject parent: See QObject - :param str name: The name of the ROI - """ - - sigAboutToBeRemoved = qt.Signal() - """Signal emitted just before this ROI is removed from its manager.""" - - sigItemChanged = qt.Signal(object) - """Signal emitted when item has changed. - - It provides a flag describing which property of the item has changed. - See :class:`ItemChangedType` for flags description. - """ - - def __init__(self, parent=None): - qt.QObject.__init__(self, parent=parent) - self.__name = '' - - def getName(self): - """Returns the name of the ROI - - :return: name of the region of interest - :rtype: str - """ - return self.__name - - def setName(self, name): - """Set the name of the ROI - - :param str name: name of the region of interest - """ - name = str(name) - if self.__name != name: - self.__name = name - self._updated(items.ItemChangedType.NAME) - - def _updated(self, event=None, checkVisibility=True): - """Implement Item mix-in update method by updating the plot items - - See :class:`~silx.gui.plot.items.Item._updated` - """ - self.sigItemChanged.emit(event) - - def contains(self, position): - """Returns True if the `position` is in this ROI. - - :param tuple[float,float] position: position to check - :return: True if the value / point is consider to be in the region of - interest. - :rtype: bool - """ - return False # Override in subclass to perform actual test - - -class RoiInteractionMode(object): - """Description of an interaction mode. - - An interaction mode provide a specific kind of interaction for a ROI. - A ROI can implement many interaction. - """ - - def __init__(self, label, description=None): - self._label = label - self._description = description - - @property - def label(self): - return self._label - - @property - def description(self): - return self._description - - -class InteractionModeMixIn(object): - """Mix in feature which can be implemented by a ROI object. - - This provides user interaction to switch between different - interaction mode to edit the ROI. - - This ROI modes have to be described using `RoiInteractionMode`, - and taken into account during interation with handles. - """ - - sigInteractionModeChanged = qt.Signal(object) - - def __init__(self): - self.__modeId = None - - def _initInteractionMode(self, modeId): - """Set the mode without updating anything. - - Must be one of the returned :meth:`availableInteractionModes`. - - :param RoiInteractionMode modeId: Mode to use - """ - self.__modeId = modeId - - def availableInteractionModes(self): - """Returns the list of available interaction modes - - Must be implemented when inherited to provide all available modes. - - :rtype: List[RoiInteractionMode] - """ - raise NotImplementedError() - - def setInteractionMode(self, modeId): - """Set the interaction mode. - - :param RoiInteractionMode modeId: Mode to use - """ - self.__modeId = modeId - self._interactiveModeUpdated(modeId) - self.sigInteractionModeChanged.emit(modeId) - - def _interactiveModeUpdated(self, modeId): - """Called directly after an update of the mode. - - The signal `sigInteractionModeChanged` is triggered after this - call. - - Must be implemented when inherited to take care of the change. - """ - raise NotImplementedError() - - def getInteractionMode(self): - """Returns the interaction mode. - - Must be one of the returned :meth:`availableInteractionModes`. - - :rtype: RoiInteractionMode - """ - return self.__modeId - - -class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): - """Object describing a region of interest in a plot. - - :param QObject parent: - The RegionOfInterestManager that created this object - """ - - _DEFAULT_LINEWIDTH = 1. - """Default line width of the curve""" - - _DEFAULT_LINESTYLE = '-' - """Default line style of the curve""" - - _DEFAULT_HIGHLIGHT_STYLE = items.CurveStyle(linewidth=2) - """Default highlight style of the item""" - - ICON, NAME, SHORT_NAME = None, None, None - """Metadata to describe the ROI in labels, tooltips and widgets - - Should be set by inherited classes to custom the ROI manager widget. - """ - - sigRegionChanged = qt.Signal() - """Signal emitted everytime the shape or position of the ROI changes""" - - sigEditingStarted = qt.Signal() - """Signal emitted when the user start editing the roi""" - - sigEditingFinished = qt.Signal() - """Signal emitted when the region edition is finished. During edition - sigEditionChanged will be emitted several times and - sigRegionEditionFinished only at end""" - - def __init__(self, parent=None): - # Avoid circular dependency - from ..tools import roi as roi_tools - assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) - _RegionOfInterestBase.__init__(self, parent) - core.HighlightedMixIn.__init__(self) - self._color = rgba('red') - self._editable = False - self._selectable = False - self._focusProxy = None - self._visible = True - self._child = WeakList() - - def _connectToPlot(self, plot): - """Called after connection to a plot""" - for item in self.getItems(): - # This hack is needed to avoid reentrant call from _disconnectFromPlot - # to the ROI manager. It also speed up the item tests in _itemRemoved - item._roiGroup = True - plot.addItem(item) - - def _disconnectFromPlot(self, plot): - """Called before disconnection from a plot""" - for item in self.getItems(): - # The item could be already be removed by the plot - if item.getPlot() is not None: - del item._roiGroup - plot.removeItem(item) - - def _setItemName(self, item): - """Helper to generate a unique id to a plot item""" - legend = "__ROI-%d__%d" % (id(self), id(item)) - item.setName(legend) - - def setParent(self, parent): - """Set the parent of the RegionOfInterest - - :param Union[None,RegionOfInterestManager] parent: The new parent - """ - # Avoid circular dependency - from ..tools import roi as roi_tools - if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)): - raise ValueError('Unsupported parent') - - previousParent = self.parent() - if previousParent is not None: - previousPlot = previousParent.parent() - if previousPlot is not None: - self._disconnectFromPlot(previousPlot) - super(RegionOfInterest, self).setParent(parent) - if parent is not None: - plot = parent.parent() - if plot is not None: - self._connectToPlot(plot) - - def addItem(self, item): - """Add an item to the set of this ROI children. - - This item will be added and removed to the plot used by the ROI. - - If the ROI is already part of a plot, the item will also be added to - the plot. - - It the item do not have a name already, a unique one is generated to - avoid item collision in the plot. - - :param silx.gui.plot.items.Item item: A plot item - """ - assert item is not None - self._child.append(item) - if item.getName() == '': - self._setItemName(item) - manager = self.parent() - if manager is not None: - plot = manager.parent() - if plot is not None: - item._roiGroup = True - plot.addItem(item) - - def removeItem(self, item): - """Remove an item from this ROI children. - - If the item is part of a plot it will be removed too. - - :param silx.gui.plot.items.Item item: A plot item - """ - assert item is not None - self._child.remove(item) - plot = item.getPlot() - if plot is not None: - del item._roiGroup - plot.removeItem(item) - - def getItems(self): - """Returns the list of PlotWidget items of this RegionOfInterest. - - :rtype: List[~silx.gui.plot.items.Item] - """ - return tuple(self._child) - - @classmethod - def _getShortName(cls): - """Return an human readable kind of ROI - - :rtype: str - """ - if hasattr(cls, "SHORT_NAME"): - name = cls.SHORT_NAME - if name is None: - name = cls.__name__ - return name - - def getColor(self): - """Returns the color of this ROI - - :rtype: QColor - """ - return qt.QColor.fromRgbF(*self._color) - - def setColor(self, color): - """Set the color used for this ROI. - - :param color: The color to use for ROI shape as - either a color name, a QColor, a list of uint8 or float in [0, 1]. - """ - color = rgba(color) - if color != self._color: - self._color = color - self._updated(items.ItemChangedType.COLOR) - - @silx.utils.deprecation.deprecated(reason='API modification', - replacement='getName()', - since_version=0.12) - def getLabel(self): - """Returns the label displayed for this ROI. - - :rtype: str - """ - return self.getName() - - @silx.utils.deprecation.deprecated(reason='API modification', - replacement='setName(name)', - since_version=0.12) - def setLabel(self, label): - """Set the label displayed with this ROI. - - :param str label: The text label to display - """ - self.setName(name=label) - - def isEditable(self): - """Returns whether the ROI is editable by the user or not. - - :rtype: bool - """ - return self._editable - - def setEditable(self, editable): - """Set whether the ROI can be changed interactively. - - :param bool editable: True to allow edition by the user, - False to disable. - """ - editable = bool(editable) - if self._editable != editable: - self._editable = editable - self._updated(items.ItemChangedType.EDITABLE) - - def isSelectable(self): - """Returns whether the ROI is selectable by the user or not. - - :rtype: bool - """ - return self._selectable - - def setSelectable(self, selectable): - """Set whether the ROI can be selected interactively. - - :param bool selectable: True to allow selection by the user, - False to disable. - """ - selectable = bool(selectable) - if self._selectable != selectable: - self._selectable = selectable - self._updated(items.ItemChangedType.SELECTABLE) - - def getFocusProxy(self): - """Returns the ROI which have to be selected when this ROI is selected, - else None if no proxy specified. - - :rtype: RegionOfInterest - """ - proxy = self._focusProxy - if proxy is None: - return None - proxy = proxy() - if proxy is None: - self._focusProxy = None - return proxy - - def setFocusProxy(self, roi): - """Set the real ROI which will be selected when this ROI is selected, - else None to remove the proxy already specified. - - :param RegionOfInterest roi: A ROI - """ - if roi is not None: - self._focusProxy = weakref.ref(roi) - else: - self._focusProxy = None - - def isVisible(self): - """Returns whether the ROI is visible in the plot. - - .. note:: - This does not take into account whether or not the plot - widget itself is visible (unlike :meth:`QWidget.isVisible` which - checks the visibility of all its parent widgets up to the window) - - :rtype: bool - """ - return self._visible - - def setVisible(self, visible): - """Set whether the plot items associated with this ROI are - visible in the plot. - - :param bool visible: True to show the ROI in the plot, False to - hide it. - """ - visible = bool(visible) - if self._visible != visible: - self._visible = visible - self._updated(items.ItemChangedType.VISIBLE) - - @classmethod - def showFirstInteractionShape(cls): - """Returns True if the shape created by the first interaction and - managed by the plot have to be visible. - - :rtype: bool - """ - return False - - @classmethod - def getFirstInteractionShape(cls): - """Returns the shape kind which will be used by the very first - interaction with the plot. - - This interactions are hardcoded inside the plot - - :rtype: str - """ - return cls._plotShape - - def setFirstShapePoints(self, points): - """"Initialize the ROI using the points from the first interaction. - - This interaction is constrained by the plot API and only supports few - shapes. - """ - raise NotImplementedError() - - def creationStarted(self): - """"Called when the ROI creation interaction was started. - """ - pass - - def creationFinalized(self): - """"Called when the ROI creation interaction was finalized. - """ - pass - - def _updateItemProperty(self, event, source, destination): - """Update the item property of a destination from an item source. - - :param items.ItemChangedType event: Property type to update - :param silx.gui.plot.items.Item source: The reference for the data - :param event Union[Item,List[Item]] destination: The item(s) to update - """ - if not isinstance(destination, (list, tuple)): - destination = [destination] - if event == items.ItemChangedType.NAME: - value = source.getName() - for d in destination: - d.setName(value) - elif event == items.ItemChangedType.EDITABLE: - value = source.isEditable() - for d in destination: - d.setEditable(value) - elif event == items.ItemChangedType.SELECTABLE: - value = source.isSelectable() - for d in destination: - d._setSelectable(value) - elif event == items.ItemChangedType.COLOR: - value = rgba(source.getColor()) - for d in destination: - d.setColor(value) - elif event == items.ItemChangedType.LINE_STYLE: - value = self.getLineStyle() - for d in destination: - d.setLineStyle(value) - elif event == items.ItemChangedType.LINE_WIDTH: - value = self.getLineWidth() - for d in destination: - d.setLineWidth(value) - elif event == items.ItemChangedType.SYMBOL: - value = self.getSymbol() - for d in destination: - d.setSymbol(value) - elif event == items.ItemChangedType.SYMBOL_SIZE: - value = self.getSymbolSize() - for d in destination: - d.setSymbolSize(value) - elif event == items.ItemChangedType.VISIBLE: - value = self.isVisible() - for d in destination: - d.setVisible(value) - else: - assert False - - def _updated(self, event=None, checkVisibility=True): - if event == items.ItemChangedType.HIGHLIGHTED: - style = self.getCurrentStyle() - self._updatedStyle(event, style) - else: - styleEvents = [items.ItemChangedType.COLOR, - items.ItemChangedType.LINE_STYLE, - items.ItemChangedType.LINE_WIDTH, - items.ItemChangedType.SYMBOL, - items.ItemChangedType.SYMBOL_SIZE] - if self.isHighlighted(): - styleEvents.append(items.ItemChangedType.HIGHLIGHTED_STYLE) - - if event in styleEvents: - style = self.getCurrentStyle() - self._updatedStyle(event, style) - - super(RegionOfInterest, self)._updated(event, checkVisibility) - - def _updatedStyle(self, event, style): - """Called when the current displayed style of the ROI was changed. - - :param event: The event responsible of the change of the style - :param items.CurveStyle style: The current style - """ - pass - - def getCurrentStyle(self): - """Returns the current curve style. - - Curve style depends on curve highlighting - - :rtype: CurveStyle - """ - baseColor = rgba(self.getColor()) - if isinstance(self, core.LineMixIn): - baseLinestyle = self.getLineStyle() - baseLinewidth = self.getLineWidth() - else: - baseLinestyle = self._DEFAULT_LINESTYLE - baseLinewidth = self._DEFAULT_LINEWIDTH - if isinstance(self, core.SymbolMixIn): - baseSymbol = self.getSymbol() - baseSymbolsize = self.getSymbolSize() - else: - baseSymbol = 'o' - baseSymbolsize = 1 - - if self.isHighlighted(): - style = self.getHighlightedStyle() - color = style.getColor() - linestyle = style.getLineStyle() - linewidth = style.getLineWidth() - symbol = style.getSymbol() - symbolsize = style.getSymbolSize() - - return items.CurveStyle( - color=baseColor if color is None else color, - linestyle=baseLinestyle if linestyle is None else linestyle, - linewidth=baseLinewidth if linewidth is None else linewidth, - symbol=baseSymbol if symbol is None else symbol, - symbolsize=baseSymbolsize if symbolsize is None else symbolsize) - else: - return items.CurveStyle(color=baseColor, - linestyle=baseLinestyle, - linewidth=baseLinewidth, - symbol=baseSymbol, - symbolsize=baseSymbolsize) - - def _editingStarted(self): - assert self._editable is True - self.sigEditingStarted.emit() - - def _editingFinished(self): - self.sigEditingFinished.emit() - - -class HandleBasedROI(RegionOfInterest): - """Manage a ROI based on a set of handles""" - - def __init__(self, parent=None): - RegionOfInterest.__init__(self, parent=parent) - self._handles = [] - self._posOrigin = None - self._posPrevious = None - - def addUserHandle(self, item=None): - """ - Add a new free handle to the ROI. - - This handle do nothing. It have to be managed by the ROI - implementing this class. - - :param Union[None,silx.gui.plot.items.Marker] item: The new marker to - add, else None to create a default marker. - :rtype: silx.gui.plot.items.Marker - """ - return self.addHandle(item, role="user") - - def addLabelHandle(self, item=None): - """ - Add a new label handle to the ROI. - - This handle is not draggable nor selectable. - - It is displayed without symbol, but it is always visible anyway - the ROI is editable, in order to display text. - - :param Union[None,silx.gui.plot.items.Marker] item: The new marker to - add, else None to create a default marker. - :rtype: silx.gui.plot.items.Marker - """ - return self.addHandle(item, role="label") - - def addTranslateHandle(self, item=None): - """ - Add a new translate handle to the ROI. - - Dragging translate handles affect the position position of the ROI - but not the shape itself. - - :param Union[None,silx.gui.plot.items.Marker] item: The new marker to - add, else None to create a default marker. - :rtype: silx.gui.plot.items.Marker - """ - return self.addHandle(item, role="translate") - - def addHandle(self, item=None, role="default"): - """ - Add a new handle to the ROI. - - Dragging handles while affect the position or the shape of the - ROI. - - :param Union[None,silx.gui.plot.items.Marker] item: The new marker to - add, else None to create a default marker. - :rtype: silx.gui.plot.items.Marker - """ - if item is None: - item = items.Marker() - color = rgba(self.getColor()) - color = self._computeHandleColor(color) - item.setColor(color) - if role == "default": - item.setSymbol("s") - elif role == "user": - pass - elif role == "translate": - item.setSymbol("+") - elif role == "label": - item.setSymbol("") - - if role == "user": - pass - elif role == "label": - item._setSelectable(False) - item._setDraggable(False) - item.setVisible(True) - else: - self.__updateEditable(item, self.isEditable(), remove=False) - item._setSelectable(False) - - self._handles.append((item, role)) - self.addItem(item) - return item - - def removeHandle(self, handle): - data = [d for d in self._handles if d[0] is handle][0] - self._handles.remove(data) - role = data[1] - if role not in ["user", "label"]: - if self.isEditable(): - self.__updateEditable(handle, False) - self.removeItem(handle) - - def getHandles(self): - """Returns the list of handles of this HandleBasedROI. - - :rtype: List[~silx.gui.plot.items.Marker] - """ - return tuple(data[0] for data in self._handles) - - def _updated(self, event=None, checkVisibility=True): - """Implement Item mix-in update method by updating the plot items - - See :class:`~silx.gui.plot.items.Item._updated` - """ - if event == items.ItemChangedType.NAME: - self._updateText(self.getName()) - elif event == items.ItemChangedType.VISIBLE: - for item, role in self._handles: - visible = self.isVisible() - editionVisible = visible and self.isEditable() - if role not in ["user", "label"]: - item.setVisible(editionVisible) - else: - item.setVisible(visible) - elif event == items.ItemChangedType.EDITABLE: - for item, role in self._handles: - editable = self.isEditable() - if role not in ["user", "label"]: - self.__updateEditable(item, editable) - super(HandleBasedROI, self)._updated(event, checkVisibility) - - def _updatedStyle(self, event, style): - super(HandleBasedROI, self)._updatedStyle(event, style) - - # Update color of shape items in the plot - color = rgba(self.getColor()) - handleColor = self._computeHandleColor(color) - for item, role in self._handles: - if role == 'user': - pass - elif role == 'label': - item.setColor(color) - else: - item.setColor(handleColor) - - def __updateEditable(self, handle, editable, remove=True): - # NOTE: visibility change emit a position update event - handle.setVisible(editable and self.isVisible()) - handle._setDraggable(editable) - if editable: - handle.sigDragStarted.connect(self._handleEditingStarted) - handle.sigItemChanged.connect(self._handleEditingUpdated) - handle.sigDragFinished.connect(self._handleEditingFinished) - else: - if remove: - handle.sigDragStarted.disconnect(self._handleEditingStarted) - handle.sigItemChanged.disconnect(self._handleEditingUpdated) - handle.sigDragFinished.disconnect(self._handleEditingFinished) - - def _handleEditingStarted(self): - super(HandleBasedROI, self)._editingStarted() - handle = self.sender() - self._posOrigin = numpy.array(handle.getPosition()) - self._posPrevious = numpy.array(self._posOrigin) - self.handleDragStarted(handle, self._posOrigin) - - def _handleEditingUpdated(self): - if self._posOrigin is None: - # Avoid to handle events when visibility change - return - handle = self.sender() - current = numpy.array(handle.getPosition()) - self.handleDragUpdated(handle, self._posOrigin, self._posPrevious, current) - self._posPrevious = current - - def _handleEditingFinished(self): - handle = self.sender() - current = numpy.array(handle.getPosition()) - self.handleDragFinished(handle, self._posOrigin, current) - self._posPrevious = None - self._posOrigin = None - super(HandleBasedROI, self)._editingFinished() - - def isHandleBeingDragged(self): - """Returns True if one of the handles is currently being dragged. - - :rtype: bool - """ - return self._posOrigin is not None - - def handleDragStarted(self, handle, origin): - """Called when an handler drag started""" - pass - - def handleDragUpdated(self, handle, origin, previous, current): - """Called when an handle drag position changed""" - pass - - def handleDragFinished(self, handle, origin, current): - """Called when an handle drag finished""" - pass - - def _computeHandleColor(self, color): - """Returns the anchor color from the base ROI color - - :param Union[numpy.array,Tuple,List]: color - :rtype: Union[numpy.array,Tuple,List] - """ - return color[:3] + (0.5,) - - def _updateText(self, text): - """Update the text displayed by this ROI - - :param str text: A text - """ - pass diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py deleted file mode 100644 index be85e6a..0000000 --- a/silx/gui/plot/items/axis.py +++ /dev/null @@ -1,569 +0,0 @@ -# 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 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 - -from ... import qt - - -_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 - - :param float vmin: Min axis value - :param float vmax: Max axis value - :return: (min, max) making sure min < max - :rtype: 2-tuple of float - """ - if vmax < vmin: - _logger.debug('%s axis: max < min, inverting limits.', self._defaultLabel) - vmin, vmax = vmax, vmin - elif vmax == vmin: - _logger.debug('%s axis: max == min, expanding limits.', self._defaultLabel) - if vmin == 0.: - vmin, vmax = -0.1, 0.1 - elif vmin < 0: - vmin, vmax = vmin * 1.1, vmin * 0.9 - else: # xmin > 0 - vmin, vmax = vmin * 0.9, vmin * 1.1 - - return vmin, vmax - - 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/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py deleted file mode 100644 index abb64ad..0000000 --- a/silx/gui/plot/items/complex.py +++ /dev/null @@ -1,386 +0,0 @@ -# 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/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py deleted file mode 100644 index 95a65ad..0000000 --- a/silx/gui/plot/items/core.py +++ /dev/null @@ -1,1734 +0,0 @@ -# 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 -import six - -from ....utils.deprecation import deprecated -from ....utils.proxy import docstring -from ....utils.enum import Enum as _Enum -from ....math.combo import min_max -from ... import qt -from ... import colors -from ...colors import Colormap -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, six.string_types): - 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/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py deleted file mode 100644 index 75e7f01..0000000 --- a/silx/gui/plot/items/curve.py +++ /dev/null @@ -1,326 +0,0 @@ -# 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 the :class:`Curve` item of the :class:`Plot`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "24/04/2018" - - -import logging - -import numpy -import six - -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, six.string_types): - 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/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py deleted file mode 100644 index 16bbefa..0000000 --- a/silx/gui/plot/items/histogram.py +++ /dev/null @@ -1,389 +0,0 @@ -# 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/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py deleted file mode 100644 index 0d9c9a4..0000000 --- a/silx/gui/plot/items/image.py +++ /dev/null @@ -1,617 +0,0 @@ -# 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 ImageData(ImageBase, ColormapMixIn): - """Description of a data image with a colormap""" - - def __init__(self): - ImageBase.__init__(self, numpy.zeros((0, 0), dtype=numpy.float32)) - ColormapMixIn.__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 - - colormap = self.getColormap() - if colormap.isAutoscale(): - # Avoid backend to compute autoscale: use item cache - colormap = colormap.copy() - colormap.setVRange(*colormap.getColormapRange(self)) - - return backend.addImage(dataToUse, - origin=self.getOrigin(), - scale=self.getScale(), - colormap=colormap, - 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: - # Apply colormap, in this case an new array is always returned - colormap = self.getColormap() - image = colormap.applyToData(self) - 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 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) - - 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) - - 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 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/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py deleted file mode 100755 index 50d070c..0000000 --- a/silx/gui/plot/items/marker.py +++ /dev/null @@ -1,281 +0,0 @@ -# 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/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py deleted file mode 100644 index 38a1424..0000000 --- a/silx/gui/plot/items/roi.py +++ /dev/null @@ -1,1519 +0,0 @@ -# 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/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py deleted file mode 100644 index 2d54223..0000000 --- a/silx/gui/plot/items/scatter.py +++ /dev/null @@ -1,973 +0,0 @@ -# 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 _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()) - - # Compute colors - cmap = self.getColormap() - rgbacolors = cmap.applyToData(self) - - if self.__alpha is not None: - rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8) - - visualization = self.getVisualization() - - if visualization is self.Visualization.POINTS: - 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: - 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 - - if len(rgbacolors) == dim0 * dim1: - image = rgbacolors.reshape(dim0, dim1, -1) - else: - # The points do not fill the whole image - image = numpy.empty((dim0 * dim1, 4), dtype=rgbacolors.dtype) - image[:len(rgbacolors)] = rgbacolors - image[len(rgbacolors):] = 0, 0, 0, 0 # Transparent pixels - image.shape = dim0, dim1, -1 - - if gridInfo.order == 'column': - image = numpy.transpose(image, axes=(1, 0, 2)) - - return backend.addImage( - data=image, - origin=gridInfo.origin, - scale=gridInfo.scale, - colormap=None, - 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 - - 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 - self._updateColormappedData() - - 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) diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py deleted file mode 100644 index 955dfe3..0000000 --- a/silx/gui/plot/items/shape.py +++ /dev/null @@ -1,288 +0,0 @@ -# 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 the :class:`Shape` item of the :class:`Plot`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "21/12/2018" - - -import logging - -import numpy -import six - -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, six.string_types): - 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) |