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