From 159ef14fb9e198bb0066ea14e6b980f065de63dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Picca=20Fr=C3=A9d=C3=A9ric-Emmanuel?= Date: Tue, 31 Jul 2018 16:22:25 +0200 Subject: New upstream version 0.8.0+dfsg --- silx/gui/plot/items/roi.py | 1416 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1416 insertions(+) create mode 100644 silx/gui/plot/items/roi.py (limited to 'silx/gui/plot/items/roi.py') diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py new file mode 100644 index 0000000..f55ef91 --- /dev/null +++ b/silx/gui/plot/items/roi.py @@ -0,0 +1,1416 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides ROI item for the :class:`~silx.gui.plot.PlotWidget`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import functools +import itertools +import logging +import collections +import numpy + +from ....utils.weakref import WeakList +from ... import qt +from .. import items +from ...colors import rgba + + +logger = logging.getLogger(__name__) + + +class RegionOfInterest(qt.QObject): + """Object describing a region of interest in a plot. + + :param QObject parent: + The RegionOfInterestManager that created this object + """ + + _kind = None + """Label for this kind of ROI. + + Should be setted by inherited classes to custom the ROI manager widget. + """ + + sigRegionChanged = qt.Signal() + """Signal emitted everytime the shape or position of the ROI changes""" + + def __init__(self, parent=None): + # Avoid circular dependancy + from ..tools import roi as roi_tools + assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) + super(RegionOfInterest, self).__init__(parent) + self._color = rgba('red') + self._items = WeakList() + self._editAnchors = WeakList() + self._points = None + self._label = '' + self._labelItem = None + self._editable = False + + def __del__(self): + # Clean-up plot items + self._removePlotItems() + + def setParent(self, parent): + """Set the parent of the RegionOfInterest + + :param Union[None,RegionOfInterestManager] parent: + """ + # Avoid circular dependancy + from ..tools import roi as roi_tools + if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)): + raise ValueError('Unsupported parent') + + self._removePlotItems() + super(RegionOfInterest, self).setParent(parent) + self._createPlotItems() + + @classmethod + def _getKind(cls): + """Return an human readable kind of ROI + + :rtype: str + """ + return cls._kind + + def getColor(self): + """Returns the color of this ROI + + :rtype: QColor + """ + return qt.QColor.fromRgbF(*self._color) + + def _getAnchorColor(self, color): + """Returns the anchor color from the base ROI color + + :param Union[numpy.array,Tuple,List]: color + :rtype: Union[numpy.array,Tuple,List] + """ + return color[:3] + (0.5,) + + def setColor(self, color): + """Set the color used for this ROI. + + :param color: The color to use for ROI shape as + either a color name, a QColor, a list of uint8 or float in [0, 1]. + """ + color = rgba(color) + if color != self._color: + self._color = color + + # Update color of shape items in the plot + rgbaColor = rgba(color) + for item in list(self._items): + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + item = self._getLabelItem() + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + + rgbaColor = self._getAnchorColor(rgbaColor) + for item in list(self._editAnchors): + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + + def getLabel(self): + """Returns the label displayed for this ROI. + + :rtype: str + """ + return self._label + + def setLabel(self, label): + """Set the label displayed with this ROI. + + :param str label: The text label to display + """ + label = str(label) + if label != self._label: + self._label = label + self._updateLabelItem(label) + + def isEditable(self): + """Returns whether the ROI is editable by the user or not. + + :rtype: bool + """ + return self._editable + + def setEditable(self, editable): + """Set whether the ROI can be changed interactively. + + :param bool editable: True to allow edition by the user, + False to disable. + """ + editable = bool(editable) + if self._editable != editable: + self._editable = editable + # Recreate plot items + # This can be avoided once marker.setDraggable is public + self._createPlotItems() + + def _getControlPoints(self): + """Returns the current ROI control points. + + It returns an empty tuple if there is currently no ROI. + + :return: Array of (x, y) position in plot coordinates + :rtype: numpy.ndarray + """ + return None if self._points is None else numpy.array(self._points) + + @classmethod + def showFirstInteractionShape(cls): + """Returns True if the shape created by the first interaction and + managed by the plot have to be visible. + + :rtype: bool + """ + return True + + @classmethod + def getFirstInteractionShape(cls): + """Returns the shape kind which will be used by the very first + interaction with the plot. + + This interactions are hardcoded inside the plot + + :rtype: str + """ + return cls._plotShape + + def setFirstShapePoints(self, points): + """"Initialize the ROI using the points from the first interaction. + + This interaction is constains by the plot API and only supports few + shapes. + """ + points = self._createControlPointsFromFirstShape(points) + self._setControlPoints(points) + + def _createControlPointsFromFirstShape(self, points): + """Returns the list of control points from the very first shape + provided. + + This shape is provided by the plot interaction and constained by the + class of the ROI itself. + """ + return points + + def _setControlPoints(self, points): + """Set this ROI control points. + + :param points: Iterable of (x, y) control points + """ + points = numpy.array(points) + + nbPointsChanged = (self._points is None or + points.shape != self._points.shape) + + if nbPointsChanged or not numpy.all(numpy.equal(points, self._points)): + self._points = points + + self._updateShape() + if self._items and not nbPointsChanged: # Update plot items + item = self._getLabelItem() + if item is not None: + markerPos = self._getLabelPosition() + item.setPosition(*markerPos) + + if self._editAnchors: # Update anchors + for anchor, point in zip(self._editAnchors, points): + old = anchor.blockSignals(True) + anchor.setPosition(*point) + anchor.blockSignals(old) + + else: # No items or new point added + # re-create plot items + self._createPlotItems() + + self.sigRegionChanged.emit() + + def _updateShape(self): + """Called when shape must be updated. + + Must be reimplemented if a shape item have to be updated. + """ + return + + def _getLabelPosition(self): + """Compute position of the label + + :return: (x, y) position of the marker + """ + return None + + def _createPlotItems(self): + """Create items displaying the ROI in the plot. + + It first removes any existing plot items. + """ + roiManager = self.parent() + if roiManager is None: + return + plot = roiManager.parent() + + self._removePlotItems() + + legendPrefix = "__RegionOfInterest-%d__" % id(self) + itemIndex = 0 + + controlPoints = self._getControlPoints() + + if self._labelItem is None: + self._labelItem = self._createLabelItem() + if self._labelItem is not None: + self._labelItem._setLegend(legendPrefix + "label") + plot._add(self._labelItem) + + self._items = WeakList() + plotItems = self._createShapeItems(controlPoints) + for item in plotItems: + item._setLegend(legendPrefix + str(itemIndex)) + plot._add(item) + self._items.append(item) + itemIndex += 1 + + self._editAnchors = WeakList() + if self.isEditable(): + plotItems = self._createAnchorItems(controlPoints) + color = rgba(self.getColor()) + color = self._getAnchorColor(color) + for index, item in enumerate(plotItems): + item._setLegend(legendPrefix + str(itemIndex)) + item.setColor(color) + plot._add(item) + item.sigItemChanged.connect(functools.partial( + self._controlPointAnchorChanged, index)) + self._editAnchors.append(item) + itemIndex += 1 + + def _updateLabelItem(self, label): + """Update the marker displaying the label. + + Inherite this method to custom the way the ROI display the label. + + :param str label: The new label to use + """ + item = self._getLabelItem() + if item is not None: + item.setText(label) + + def _createLabelItem(self): + """Returns a created marker which will be used to dipslay the label of + this ROI. + + Inherite this method to return nothing if no new items have to be + created, or your own marker. + + :rtype: Union[None,Marker] + """ + # Add label marker + markerPos = self._getLabelPosition() + marker = items.Marker() + marker.setPosition(*markerPos) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker.setSymbol('') + marker._setDraggable(False) + return marker + + def _getLabelItem(self): + """Returns the marker displaying the label of this ROI. + + Inherite this method to choose your own item. In case this item is also + a control point. + """ + return self._labelItem + + def _createShapeItems(self, points): + """Create shape items from the current control points. + + :rtype: List[PlotItem] + """ + return [] + + def _createAnchorItems(self, points): + """Create anchor items from the current control points. + + :rtype: List[Marker] + """ + return [] + + def _controlPointAnchorChanged(self, index, event): + """Handle update of position of an edition anchor + + :param int index: Index of the anchor + :param ItemChangedType event: Event type + """ + if event == items.ItemChangedType.POSITION: + anchor = self._editAnchors[index] + previous = self._points[index].copy() + current = anchor.getPosition() + self._controlPointAnchorPositionChanged(index, current, previous) + + def _controlPointAnchorPositionChanged(self, index, current, previous): + """Called when an anchor is manually edited. + + This function have to be inherited to change the behaviours of the + control points. This function have to call :meth:`_getControlPoints` to + reach the previous state of the control points. Updated the positions + of the changed control points. Then call :meth:`_setControlPoints` to + update the anchors and send signals. + """ + points = self._getControlPoints() + points[index] = current + self._setControlPoints(points) + + def _removePlotItems(self): + """Remove items from their plot.""" + for item in itertools.chain(list(self._items), + list(self._editAnchors)): + + plot = item.getPlot() + if plot is not None: + plot._remove(item) + self._items = WeakList() + self._editAnchors = WeakList() + + if self._labelItem is not None: + item = self._labelItem + plot = item.getPlot() + if plot is not None: + plot._remove(item) + self._labelItem = None + + def __str__(self): + """Returns parameters of the ROI as a string.""" + points = self._getControlPoints() + params = '; '.join('(%f; %f)' % (pt[0], pt[1]) for pt in points) + return "%s(%s)" % (self.__class__.__name__, params) + + +class PointROI(RegionOfInterest): + """A ROI identifying a point in a 2D plot.""" + + _kind = "Point" + """Label for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self._points[0].copy() + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + controlPoints = numpy.array([pos]) + self._setControlPoints(controlPoints) + + def _createLabelItem(self): + return None + + def _updateLabelItem(self, label): + if self.isEditable(): + item = self._editAnchors[0] + else: + item = self._items[0] + item.setText(label) + + def _createShapeItems(self, points): + if self.isEditable(): + return [] + marker = items.Marker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker._setDraggable(False) + return [marker] + + def _createAnchorItems(self, points): + marker = items.Marker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker._setDraggable(self.isEditable()) + return [marker] + + def __str__(self): + points = self._getControlPoints() + params = '%f %f' % (points[0, 0], points[0, 1]) + return "%s(%s)" % (self.__class__.__name__, params) + + +class LineROI(RegionOfInterest): + """A ROI identifying a line in a 2D plot. + + This ROI provides 1 anchor for each boundary of the line, plus an center + in the center to translate the full ROI. + """ + + _kind = "Line" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + center = numpy.mean(points, axis=0) + controlPoints = numpy.array([points[0], points[1], center]) + return controlPoints + + def setEndPoints(self, startPoint, endPoint): + """Set this line location using the endding points + + :param numpy.ndarray startPoint: Staring bounding point of the line + :param numpy.ndarray endPoint: Endding bounding point of the line + """ + assert(startPoint.shape == (2,) and endPoint.shape == (2,)) + shapePoints = numpy.array([startPoint, endPoint]) + controlPoints = self._createControlPointsFromFirstShape(shapePoints) + self._setControlPoints(controlPoints) + + def getEndPoints(self): + """Returns bounding points of this ROI. + + :rtype: Tuple(numpy.ndarray,numpy.ndarray) + """ + startPoint = self._points[0].copy() + endPoint = self._points[1].copy() + return (startPoint, endPoint) + + def _getLabelPosition(self): + points = self._getControlPoints() + return points[-1] + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + points = self._getShapeFromControlPoints(points) + shape.setPoints(points) + + def _getShapeFromControlPoints(self, points): + # Remove the center from the control points + return points[0:2] + + def _createShapeItems(self, points): + shapePoints = self._getShapeFromControlPoints(points) + item = items.Shape("polylines") + item.setPoints(shapePoints) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + anchors = [] + for point in points[0:-1]: + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol('s') + anchor._setDraggable(True) + anchors.append(anchor) + + # Add an anchor to the center of the rectangle + center = numpy.mean(points, axis=0) + anchor = items.Marker() + anchor.setPosition(*center) + anchor.setText('') + anchor.setSymbol('+') + anchor._setDraggable(True) + anchors.append(anchor) + + return anchors + + def _controlPointAnchorPositionChanged(self, index, current, previous): + if index == len(self._editAnchors) - 1: + # It is the center anchor + points = self._getControlPoints() + center = numpy.mean(points[0:-1], axis=0) + offset = current - previous + points[-1] = current + points[0:-1] = points[0:-1] + offset + self._setControlPoints(points) + else: + # Update the center + points = self._getControlPoints() + points[index] = current + center = numpy.mean(points[0:-1], axis=0) + points[-1] = center + self._setControlPoints(points) + + def __str__(self): + points = self._getControlPoints() + params = points[0][0], points[0][1], points[1][0], points[1][1] + params = 'start: %f %f; end: %f %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class HorizontalLineROI(RegionOfInterest): + """A ROI identifying an horizontal line in a 2D plot.""" + + _kind = "HLine" + """Label for this kind of ROI""" + + _plotShape = "hline" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + points = numpy.array([(float('nan'), points[0, 1])], + dtype=numpy.float64) + return points + + def getPosition(self): + """Returns the position of this line if the horizontal axis + + :rtype: float + """ + return self._points[0, 1] + + def setPosition(self, pos): + """Set the position of this ROI + + :param float pos: Horizontal position of this line + """ + controlPoints = numpy.array([[float('nan'), pos]]) + self._setControlPoints(controlPoints) + + def _createLabelItem(self): + return None + + def _updateLabelItem(self, label): + if self.isEditable(): + item = self._editAnchors[0] + else: + item = self._items[0] + item.setText(label) + + def _updateShape(self): + if not self.isEditable(): + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def _createShapeItems(self, points): + if self.isEditable(): + return [] + marker = items.YMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker._setDraggable(False) + return [marker] + + def _createAnchorItems(self, points): + marker = items.YMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker._setDraggable(self.isEditable()) + return [marker] + + def __str__(self): + points = self._getControlPoints() + params = 'y: %f' % points[0, 1] + return "%s(%s)" % (self.__class__.__name__, params) + + +class VerticalLineROI(RegionOfInterest): + """A ROI identifying a vertical line in a 2D plot.""" + + _kind = "VLine" + """Label for this kind of ROI""" + + _plotShape = "vline" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + points = numpy.array([(points[0, 0], float('nan'))], + dtype=numpy.float64) + return points + + def getPosition(self): + """Returns the position of this line if the horizontal axis + + :rtype: float + """ + return self._points[0, 0] + + def setPosition(self, pos): + """Set the position of this ROI + + :param float pos: Horizontal position of this line + """ + controlPoints = numpy.array([[pos, float('nan')]]) + self._setControlPoints(controlPoints) + + def _createLabelItem(self): + return None + + def _updateLabelItem(self, label): + if self.isEditable(): + item = self._editAnchors[0] + else: + item = self._items[0] + item.setText(label) + + def _updateShape(self): + if not self.isEditable(): + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def _createShapeItems(self, points): + if self.isEditable(): + return [] + marker = items.XMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker._setDraggable(False) + return [marker] + + def _createAnchorItems(self, points): + marker = items.XMarker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker._setDraggable(self.isEditable()) + return [marker] + + def __str__(self): + points = self._getControlPoints() + params = 'x: %f' % points[0, 0] + return "%s(%s)" % (self.__class__.__name__, params) + + +class RectangleROI(RegionOfInterest): + """A ROI identifying a rectangle in a 2D plot. + + This ROI provides 1 anchor for each corner, plus an anchor in the + center to translate the full ROI. + """ + + _kind = "Rectangle" + """Label for this kind of ROI""" + + _plotShape = "rectangle" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + point0 = points[0] + point1 = points[1] + + # 4 corners + controlPoints = numpy.array([ + point0[0], point0[1], + point0[0], point1[1], + point1[0], point1[1], + point1[0], point0[1], + ]) + # Central + center = numpy.mean(points, axis=0) + controlPoints = numpy.append(controlPoints, center) + controlPoints.shape = -1, 2 + return controlPoints + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + return numpy.mean(self._points, axis=0) + + def getOrigin(self): + """Returns the corner point with the smaller coordinates + + :rtype: numpy.ndarray([float,float]) + """ + return numpy.min(self._points, axis=0) + + def getSize(self): + """Returns the size of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + minPoint = numpy.min(self._points, axis=0) + maxPoint = numpy.max(self._points, axis=0) + return maxPoint - minPoint + + def setOrigin(self, position): + """Set the origin position of this ROI + + :param numpy.ndarray position: Location of the smaller corner of the ROI + """ + size = self.getSize() + self.setGeometry(origin=position, size=size) + + def setSize(self, size): + """Set the size of this ROI + + :param numpy.ndarray size: Size of the center of the ROI + """ + origin = self.getOrigin() + self.setGeometry(origin=origin, size=size) + + def setCenter(self, position): + """Set the size of this ROI + + :param numpy.ndarray position: Location of the center of the ROI + """ + size = self.getSize() + self.setGeometry(center=position, size=size) + + def setGeometry(self, origin=None, size=None, center=None): + """Set the geometry of the ROI + """ + if origin is not None: + origin = numpy.array(origin) + size = numpy.array(size) + points = numpy.array([origin, origin + size]) + controlPoints = self._createControlPointsFromFirstShape(points) + elif center is not None: + center = numpy.array(center) + size = numpy.array(size) + points = numpy.array([center - size * 0.5, center + size * 0.5]) + controlPoints = self._createControlPointsFromFirstShape(points) + else: + raise ValueError("Origin or cengter expected") + self._setControlPoints(controlPoints) + + def _getLabelPosition(self): + points = self._getControlPoints() + return points.min(axis=0) + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + points = self._getShapeFromControlPoints(points) + shape.setPoints(points) + + def _getShapeFromControlPoints(self, points): + minPoint = points.min(axis=0) + maxPoint = points.max(axis=0) + return numpy.array([minPoint, maxPoint]) + + def _createShapeItems(self, points): + shapePoints = self._getShapeFromControlPoints(points) + item = items.Shape("rectangle") + item.setPoints(shapePoints) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + # Remove the center control point + points = points[0:-1] + + anchors = [] + for point in points: + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol('s') + anchor._setDraggable(True) + anchors.append(anchor) + + # Add an anchor to the center of the rectangle + center = numpy.mean(points, axis=0) + anchor = items.Marker() + anchor.setPosition(*center) + anchor.setText('') + anchor.setSymbol('+') + anchor._setDraggable(True) + anchors.append(anchor) + + return anchors + + def _controlPointAnchorPositionChanged(self, index, current, previous): + if index == len(self._editAnchors) - 1: + # It is the center anchor + points = self._getControlPoints() + center = numpy.mean(points[0:-1], axis=0) + offset = current - previous + points[-1] = current + points[0:-1] = points[0:-1] + offset + self._setControlPoints(points) + else: + # Fix other corners + constrains = [(1, 3), (0, 2), (3, 1), (2, 0)] + constrains = constrains[index] + points = self._getControlPoints() + points[index] = current + points[constrains[0]][0] = current[0] + points[constrains[1]][1] = current[1] + # Update the center + center = numpy.mean(points[0:-1], axis=0) + points[-1] = center + self._setControlPoints(points) + + def __str__(self): + origin = self.getOrigin() + w, h = self.getSize() + params = origin[0], origin[1], w, h + params = 'origin: %f %f; width: %f; height: %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class PolygonROI(RegionOfInterest): + """A ROI identifying a closed polygon in a 2D plot. + + This ROI provides 1 anchor for each point of the polygon. + """ + + _kind = "Polygon" + """Label for this kind of ROI""" + + _plotShape = "polygon" + """Plot shape which is used for the first interaction""" + + def getPoints(self): + """Returns the list of the points of this polygon. + + :rtype: numpy.ndarray + """ + return self._points.copy() + + def setPoints(self, points): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + assert(len(points.shape) == 2 and points.shape[1] == 2) + if len(points) > 0: + controlPoints = numpy.array(points) + else: + controlPoints = numpy.empty((0, 2)) + self._setControlPoints(controlPoints) + + def _getLabelPosition(self): + points = self._getControlPoints() + if len(points) == 0: + # FIXME: we should return none, this polygon have no location + return numpy.array([0, 0]) + return points[numpy.argmin(points[:, 1])] + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + shape.setPoints(points) + + def _createShapeItems(self, points): + if len(points) == 0: + return [] + else: + item = items.Shape("polygon") + item.setPoints(points) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + anchors = [] + for point in points: + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol('s') + anchor._setDraggable(True) + anchors.append(anchor) + return anchors + + def __str__(self): + points = self._getControlPoints() + params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points) + return "%s(%s)" % (self.__class__.__name__, params) + + +class ArcROI(RegionOfInterest): + """A ROI identifying an arc of a circle with a width. + + This ROI provides 3 anchors to control the curvature, 1 anchor to control + the weigth, and 1 anchor to translate the shape. + """ + + _kind = "Arc" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + _ArcGeometry = collections.namedtuple('ArcGeometry', ['center', + 'startPoint', 'endPoint', + 'radius', 'weight', + 'startAngle', 'endAngle']) + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + self._geometry = None + + def _getInternalGeometry(self): + """Returns the object storing the internal geometry of this ROI. + + This geometry is derived from the control points and cached for + efficiency. Calling :meth:`_setControlPoints` invalidate the cache. + """ + if self._geometry is None: + controlPoints = self._getControlPoints() + self._geometry = self._createGeometryFromControlPoint(controlPoints) + return self._geometry + + @classmethod + def showFirstInteractionShape(cls): + return False + + def _getLabelPosition(self): + points = self._getControlPoints() + return points.min(axis=0) + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + points = self._getShapeFromControlPoints(points) + shape.setPoints(points) + + def _controlPointAnchorPositionChanged(self, index, current, previous): + controlPoints = self._getControlPoints() + currentWeigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 + + if index in [0, 2]: + # Moving start or end will maintain the same curvature + # Then we have to custom the curvature control point + startPoint = controlPoints[0] + endPoint = controlPoints[2] + center = (startPoint + endPoint) * 0.5 + normal = (endPoint - startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + # Compute the coeficient which have to be constrained + if distance != 0: + normal /= distance + midVector = controlPoints[1] - center + constainedCoef = numpy.dot(midVector, normal) / distance + else: + constainedCoef = 1.0 + + # Compute the location of the curvature point + controlPoints[index] = current + startPoint = controlPoints[0] + endPoint = controlPoints[2] + center = (startPoint + endPoint) * 0.5 + normal = (endPoint - startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + # BTW we dont need to divide by the distance here + # Cause we compute normal * distance after all + normal /= distance + midPoint = center + normal * constainedCoef * distance + controlPoints[1] = midPoint + + # The weight have to be fixed + self._updateWeightControlPoint(controlPoints, currentWeigth) + self._setControlPoints(controlPoints) + + elif index == 1: + # The weight have to be fixed + controlPoints[index] = current + self._updateWeightControlPoint(controlPoints, currentWeigth) + self._setControlPoints(controlPoints) + else: + super(ArcROI, self)._controlPointAnchorPositionChanged(index, current, previous) + + def _updateWeightControlPoint(self, controlPoints, weigth): + startPoint = controlPoints[0] + midPoint = controlPoints[1] + endPoint = controlPoints[2] + normal = (endPoint - startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + normal /= distance + controlPoints[3] = midPoint + normal * weigth * 0.5 + + def _createGeometryFromControlPoint(self, controlPoints): + """Returns the geometry of the object""" + weigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 + if numpy.allclose(controlPoints[0], controlPoints[2]): + # Special arc: It's a closed circle + center = (controlPoints[0] + controlPoints[1]) * 0.5 + radius = numpy.linalg.norm(controlPoints[0] - center) + v = controlPoints[0] - center + startAngle = numpy.angle(complex(v[0], v[1])) + endAngle = startAngle + numpy.pi * 2.0 + return self._ArcGeometry(center, controlPoints[0], controlPoints[2], + radius, weigth, startAngle, endAngle) + + elif numpy.linalg.norm( + numpy.cross(controlPoints[1] - controlPoints[0], + controlPoints[2] - controlPoints[0])) < 1e-5: + # Degenerated arc, it's a rectangle + return self._ArcGeometry(None, controlPoints[0], controlPoints[2], + None, weigth, None, None) + else: + center, radius = self._circleEquation(*controlPoints[:3]) + v = controlPoints[0] - center + startAngle = numpy.angle(complex(v[0], v[1])) + v = controlPoints[1] - center + midAngle = numpy.angle(complex(v[0], v[1])) + v = controlPoints[2] - center + endAngle = numpy.angle(complex(v[0], v[1])) + # Is it clockwise or anticlockwise + if (midAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi) <= numpy.pi: + if endAngle < startAngle: + endAngle += 2 * numpy.pi + else: + if endAngle > startAngle: + endAngle -= 2 * numpy.pi + + return self._ArcGeometry(center, controlPoints[0], controlPoints[2], + radius, weigth, startAngle, endAngle) + + def _isCircle(self, geometry): + """Returns True if the geometry is a closed circle""" + delta = numpy.abs(geometry.endAngle - geometry.startAngle) + return numpy.isclose(delta, numpy.pi * 2) + + def _getShapeFromControlPoints(self, controlPoints): + geometry = self._createGeometryFromControlPoint(controlPoints) + if geometry.center is None: + # It is not an arc + # but we can display it as an the intermediat shape + normal = (geometry.endPoint - geometry.startPoint) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + normal /= distance + points = numpy.array([ + geometry.startPoint + normal * geometry.weight * 0.5, + geometry.endPoint + normal * geometry.weight * 0.5, + geometry.endPoint - normal * geometry.weight * 0.5, + geometry.startPoint - normal * geometry.weight * 0.5]) + else: + innerRadius = geometry.radius - geometry.weight * 0.5 + outerRadius = geometry.radius + geometry.weight * 0.5 + + if numpy.isnan(geometry.startAngle): + # Degenerated, it's a point + # At least 2 points are expected + return numpy.array([geometry.startPoint, geometry.startPoint]) + + delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 + if geometry.startAngle == geometry.endAngle: + # Degenerated, it's a line (single radius) + angle = geometry.startAngle + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points = [] + points.append(geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + return numpy.array(points) + + angles = numpy.arange(geometry.startAngle, geometry.endAngle, delta) + if angles[-1] != geometry.endAngle: + angles = numpy.append(angles, geometry.endAngle) + + isCircle = self._isCircle(geometry) + + if isCircle: + if innerRadius <= 0: + # It's a circle + points = [] + numpy.append(angles, angles[-1]) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.append(geometry.center + direction * outerRadius) + else: + # It's a donut + points = [] + # NOTE: NaN value allow to create 2 separated circle shapes + # using a single plot item. It's a kind of cheat + points.append(numpy.array([float("nan"), float("nan")])) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.insert(0, geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + points.append(numpy.array([float("nan"), float("nan")])) + else: + if innerRadius <= 0: + # It's a part of camembert + points = [] + points.append(geometry.center) + points.append(geometry.startPoint) + delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.append(geometry.center + direction * outerRadius) + points.append(geometry.endPoint) + points.append(geometry.center) + else: + # It's a part of donut + points = [] + points.append(geometry.startPoint) + for angle in angles: + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + points.insert(0, geometry.center + direction * innerRadius) + points.append(geometry.center + direction * outerRadius) + points.insert(0, geometry.endPoint) + points.append(geometry.endPoint) + points = numpy.array(points) + + return points + + def _setControlPoints(self, points): + # Invalidate the geometry + self._geometry = None + RegionOfInterest._setControlPoints(self, points) + + def getGeometry(self): + """Returns a tuple containing the geometry of this ROI + + It is a symetric fonction of :meth:`setGeometry`. + + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + + :rtype: Tuple[numpy.ndarray,float,float,float,float] + :raise ValueError: In case the ROI can't be representaed as section of + a circle + """ + geometry = self._getInternalGeometry() + if geometry.center is None: + raise ValueError("This ROI can't be represented as a section of circle") + return geometry.center, self.getInnerRadius(), self.getOuterRadius(), geometry.startAngle, geometry.endAngle + + def isClosed(self): + """Returns true if the arc is a closed shape, like a circle or a donut. + + :rtype: bool + """ + geometry = self._getInternalGeometry() + return self._isCircle(geometry) + + def getCenter(self): + """Returns the center of the circle used to draw arcs of this ROI. + + This center is usually outside the the shape itself. + + :rtype: numpy.ndarray + """ + geometry = self._getInternalGeometry() + return geometry.center + + def getStartAngle(self): + """Returns the angle of the start of the section of this ROI (in radian). + + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + + :rtype: float + """ + geometry = self._getInternalGeometry() + return geometry.startAngle + + def getEndAngle(self): + """Returns the angle of the end of the section of this ROI (in radian). + + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + + :rtype: float + """ + geometry = self._getInternalGeometry() + return geometry.endAngle + + def getInnerRadius(self): + """Returns the radius of the smaller arc used to draw this ROI. + + :rtype: float + """ + geometry = self._getInternalGeometry() + radius = geometry.radius - geometry.weight * 0.5 + if radius < 0: + radius = 0 + return radius + + def getOuterRadius(self): + """Returns the radius of the bigger arc used to draw this ROI. + + :rtype: float + """ + geometry = self._getInternalGeometry() + radius = geometry.radius + geometry.weight * 0.5 + return radius + + def setGeometry(self, center, innerRadius, outerRadius, startAngle, endAngle): + """ + Set the geometry of this arc. + + :param numpy.ndarray center: Center of the circle. + :param float innerRadius: Radius of the smaller arc of the section. + :param float outerRadius: Weight of the bigger arc of the section. + It have to be bigger than `innerRadius` + :param float startAngle: Location of the start of the section (in radian) + :param float endAngle: Location of the end of the section (in radian). + If `startAngle` is smaller than `endAngle` the rotation is clockwise, + else the rotation is anticlockwise. + """ + assert(innerRadius <= outerRadius) + assert(numpy.abs(startAngle - endAngle) <= 2 * numpy.pi) + center = numpy.array(center) + radius = (innerRadius + outerRadius) * 0.5 + weight = outerRadius - innerRadius + geometry = self._ArcGeometry(center, None, None, radius, weight, startAngle, endAngle) + controlPoints = self._createControlPointsFromGeometry(geometry) + self._setControlPoints(controlPoints) + + def _createControlPointsFromGeometry(self, geometry): + if geometry.startPoint or geometry.endPoint: + # Duplication with the angles + raise NotImplementedError("This general case is not implemented") + + angle = geometry.startAngle + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + startPoint = geometry.center + direction * geometry.radius + + angle = geometry.endAngle + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + endPoint = geometry.center + direction * geometry.radius + + angle = (geometry.startAngle + geometry.endAngle) * 0.5 + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + curvaturePoint = geometry.center + direction * geometry.radius + weightPoint = curvaturePoint + direction * geometry.weight * 0.5 + + return numpy.array([startPoint, curvaturePoint, endPoint, weightPoint]) + + def _createControlPointsFromFirstShape(self, points): + # The first shape is a line + point0 = points[0] + point1 = points[1] + + # Compute a non colineate point for the curvature + center = (point1 + point0) * 0.5 + normal = point1 - center + normal = numpy.array((normal[1], -normal[0])) + defaultCurvature = numpy.pi / 5.0 + defaultWeight = 0.20 # percentage + curvaturePoint = center - normal * defaultCurvature + weightPoint = center - normal * defaultCurvature * (1.0 + defaultWeight) + + # 3 corners + controlPoints = numpy.array([ + point0, + curvaturePoint, + point1, + weightPoint + ]) + return controlPoints + + def _createShapeItems(self, points): + shapePoints = self._getShapeFromControlPoints(points) + item = items.Shape("polygon") + item.setPoints(shapePoints) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + anchors = [] + symbols = ['o', 'o', 'o', 's'] + + for index, point in enumerate(points): + if index in [1, 3]: + constraint = self._arcCurvatureMarkerConstraint + else: + constraint = None + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol(symbols[index]) + anchor._setDraggable(True) + if constraint is not None: + anchor._setConstraint(constraint) + anchors.append(anchor) + + return anchors + + def _arcCurvatureMarkerConstraint(self, x, y): + """Curvature marker remains on "mediatrice" """ + start = self._points[0] + end = self._points[2] + midPoint = (start + end) / 2. + normal = (end - start) + normal = numpy.array((normal[1], -normal[0])) + distance = numpy.linalg.norm(normal) + if distance != 0: + normal /= distance + v = numpy.dot(normal, (numpy.array((x, y)) - midPoint)) + x, y = midPoint + v * normal + return x, y + + @staticmethod + def _circleEquation(pt1, pt2, pt3): + """Circle equation from 3 (x, y) points + + :return: Position of the center of the circle and the radius + :rtype: Tuple[Tuple[float,float],float] + """ + x, y, z = complex(*pt1), complex(*pt2), complex(*pt3) + w = z - x + w /= y - x + c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x + return ((-c.real, -c.imag), abs(c + x)) + + def __str__(self): + try: + center, innerRadius, outerRadius, startAngle, endAngle = self.getGeometry() + params = center[0], center[1], innerRadius, outerRadius, startAngle, endAngle + params = 'center: %f %f; radius: %f %f; angles: %f %f' % params + except ValueError: + params = "invalid" + return "%s(%s)" % (self.__class__.__name__, params) -- cgit v1.2.3