diff options
Diffstat (limited to 'silx/gui/plot/items/roi.py')
-rw-r--r-- | silx/gui/plot/items/roi.py | 1438 |
1 files changed, 38 insertions, 1400 deletions
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 |