diff options
Diffstat (limited to 'silx/gui/plot/items/roi.py')
-rw-r--r-- | silx/gui/plot/items/roi.py | 3025 |
1 files changed, 2182 insertions, 843 deletions
diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py index dcad943..ff73fe6 100644 --- a/silx/gui/plot/items/roi.py +++ b/silx/gui/plot/items/roi.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# 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 @@ -23,6 +23,10 @@ # # ###########################################################################*/ """This module provides ROI item for the :class:`~silx.gui.plot.PlotWidget`. + +.. inheritance-diagram:: + silx.gui.plot.items.roi + :parts: 1 """ __authors__ = ["T. Vincent"] @@ -30,18 +34,21 @@ __license__ = "MIT" __date__ = "28/06/2018" -import functools -import itertools import logging -import collections 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.utils.proxy import docstring +from silx.image._boundingbox import _BoundingBox +from ....utils.proxy import docstring +from ..utils.intersections import segments_intersection logger = logging.getLogger(__name__) @@ -54,6 +61,9 @@ class _RegionOfInterestBase(qt.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. @@ -61,9 +71,9 @@ class _RegionOfInterestBase(qt.QObject): See :class:`ItemChangedType` for flags description. """ - def __init__(self, parent=None, name=''): - qt.QObject.__init__(self) - self.__name = str(name) + def __init__(self, parent=None): + qt.QObject.__init__(self, parent=parent) + self.__name = '' def getName(self): """Returns the name of the ROI @@ -81,18 +91,44 @@ class _RegionOfInterestBase(qt.QObject): name = str(name) if self.__name != name: self.__name = name - self.sigItemChanged.emit(items.ItemChangedType.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. -class RegionOfInterest(_RegionOfInterestBase): + :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 """ - _kind = None - """Label for this kind of ROI. + _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. """ @@ -100,50 +136,125 @@ class RegionOfInterest(_RegionOfInterestBase): 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 dependancy + # Avoid circular dependency from ..tools import roi as roi_tools assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) - _RegionOfInterestBase.__init__(self, parent, '') + _RegionOfInterestBase.__init__(self, parent) + core.HighlightedMixIn.__init__(self) self._color = rgba('red') - self._items = WeakList() - self._editAnchors = WeakList() - self._points = None - self._labelItem = None self._editable = False + self._selectable = False + self._focusProxy = None self._visible = True - self.sigItemChanged.connect(self.__itemChanged) - - def __itemChanged(self, event): - """Handle name change""" - if event == items.ItemChangedType.NAME: - self._updateLabelItem(self.getName()) - - def __del__(self): - # Clean-up plot items - self._removePlotItems() + 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: + :param Union[None,RegionOfInterestManager] parent: The new parent """ - # Avoid circular dependancy + # 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') - self._removePlotItems() + previousParent = self.parent() + if previousParent is not None: + previousPlot = previousParent.parent() + if previousPlot is not None: + self._disconnectFromPlot(previousPlot) super(RegionOfInterest, self).setParent(parent) - self._createPlotItems() + 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 _getKind(cls): + def _getShortName(cls): """Return an human readable kind of ROI :rtype: str """ - return cls._kind + 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 @@ -152,14 +263,6 @@ class RegionOfInterest(_RegionOfInterestBase): """ 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. @@ -169,22 +272,7 @@ class RegionOfInterest(_RegionOfInterestBase): 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) - - self.sigItemChanged.emit(items.ItemChangedType.COLOR) + self._updated(items.ItemChangedType.COLOR) @silx.utils.deprecation.deprecated(reason='API modification', replacement='getName()', @@ -222,10 +310,50 @@ class RegionOfInterest(_RegionOfInterestBase): editable = bool(editable) if self._editable != editable: self._editable = editable - # Recreate plot items - # This can be avoided once marker.setDraggable is public - self._createPlotItems() - self.sigItemChanged.emit(items.ItemChangedType.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. @@ -249,21 +377,7 @@ class RegionOfInterest(_RegionOfInterestBase): visible = bool(visible) if self._visible != visible: self._visible = visible - if self._labelItem is not None: - self._labelItem.setVisible(visible) - for item in self._items + self._editAnchors: - item.setVisible(visible) - self.sigItemChanged.emit(items.ItemChangedType.VISIBLE) - - 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) + self._updated(items.ItemChangedType.VISIBLE) @classmethod def showFirstInteractionShape(cls): @@ -272,7 +386,7 @@ class RegionOfInterest(_RegionOfInterestBase): :rtype: bool """ - return True + return False @classmethod def getFirstInteractionShape(cls): @@ -291,226 +405,369 @@ class RegionOfInterest(_RegionOfInterestBase): This interaction is constrained 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. + raise NotImplementedError() - This shape is provided by the plot interaction and constained by the - class of the ROI itself. + def creationStarted(self): + """"Called when the ROI creation interaction was started. """ - return points + pass - def _setControlPoints(self, points): - """Set this ROI control points. + @docstring(_RegionOfInterestBase) + def contains(self, position): + raise NotImplementedError("Base class") - :param points: Iterable of (x, y) control points + def creationFinalized(self): + """"Called when the ROI creation interaction was finalized. """ - points = numpy.array(points) + pass - nbPointsChanged = (self._points is None or - points.shape != self._points.shape) + def _updateItemProperty(self, event, source, destination): + """Update the item property of a destination from an item source. - 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. + :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 """ - return - - def _getLabelPosition(self): - """Compute position of the label + 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 - :return: (x, y) position of the marker + 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 """ - return None + pass + + def getCurrentStyle(self): + """Returns the current curve style. - def _createPlotItems(self): - """Create items displaying the ROI in the plot. + Curve style depends on curve highlighting - It first removes any existing plot items. + :rtype: CurveStyle """ - roiManager = self.parent() - if roiManager is None: - return - plot = roiManager.parent() + 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) - self._removePlotItems() + def _editingStarted(self): + assert self._editable is True + self.sigEditingStarted.emit() - legendPrefix = "__RegionOfInterest-%d__" % id(self) - itemIndex = 0 + def _editingFinished(self): + self.sigEditingFinished.emit() - 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._labelItem.setVisible(self.isVisible()) +class HandleBasedROI(RegionOfInterest): + """Manage a ROI based on a set of handles""" - self._items = WeakList() - plotItems = self._createShapeItems(controlPoints) - for item in plotItems: - item._setLegend(legendPrefix + str(itemIndex)) - plot._add(item) - item.setVisible(self.isVisible()) - self._items.append(item) - itemIndex += 1 + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + self._handles = [] + self._posOrigin = None + self._posPrevious = None - 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) - item.setVisible(self.isVisible()) - plot._add(item) - item.sigItemChanged.connect(functools.partial( - self._controlPointAnchorChanged, index)) - self._editAnchors.append(item) - itemIndex += 1 + def addUserHandle(self, item=None): + """ + Add a new free handle to the ROI. - def _updateLabelItem(self, label): - """Update the marker displaying the label. + This handle do nothing. It have to be managed by the ROI + implementing this class. - Inherite this method to custom the way the ROI display the label. + :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") - :param str label: The new label to use + def addLabelHandle(self, item=None): """ - item = self._getLabelItem() - if item is not None: - item.setText(label) + Add a new label handle to the ROI. - def _createLabelItem(self): - """Returns a created marker which will be used to dipslay the label of - this ROI. + This handle is not draggable nor selectable. - Inherite this method to return nothing if no new items have to be - created, or your own marker. + It is displayed without symbol, but it is always visible anyway + the ROI is editable, in order to display text. - :rtype: Union[None,Marker] + :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 """ - # Add label marker - markerPos = self._getLabelPosition() - marker = items.Marker() - marker.setPosition(*markerPos) - marker.setText(self.getName()) - marker.setColor(rgba(self.getColor())) - marker.setSymbol('') - marker._setDraggable(False) - return marker + return self.addHandle(item, role="label") - 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. + def addTranslateHandle(self, item=None): """ - return self._labelItem + Add a new translate handle to the ROI. - def _createShapeItems(self, points): - """Create shape items from the current control points. + Dragging translate handles affect the position position of the ROI + but not the shape itself. - :rtype: List[PlotItem] + :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 [] - - def _createAnchorItems(self, points): - """Create anchor items from the current control points. + return self.addHandle(item, role="translate") - :rtype: List[Marker] + def addHandle(self, item=None, role="default"): """ - return [] + Add a new handle to the ROI. - def _controlPointAnchorChanged(self, index, event): - """Handle update of position of an edition anchor + Dragging handles while affect the position or the shape of the + ROI. - :param int index: Index of the anchor - :param ItemChangedType event: Event type + :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] """ - if event == items.ItemChangedType.POSITION: - anchor = self._editAnchors[index] - previous = self._points[index].copy() - current = anchor.getPosition() - self._controlPointAnchorPositionChanged(index, current, previous) + return tuple(data[0] for data in self._handles) - def _controlPointAnchorPositionChanged(self, index, current, previous): - """Called when an anchor is manually edited. + def _updated(self, event=None, checkVisibility=True): + """Implement Item mix-in update method by updating the plot items - 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. + See :class:`~silx.gui.plot.items.Item._updated` """ - points = self._getControlPoints() - points[index] = current - self._setControlPoints(points) + 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. - def _removePlotItems(self): - """Remove items from their plot.""" - for item in itertools.chain(list(self._items), - list(self._editAnchors)): + :rtype: bool + """ + return self._posOrigin is not None - plot = item.getPlot() - if plot is not None: - plot._remove(item) - self._items = WeakList() - self._editAnchors = WeakList() + def handleDragStarted(self, handle, origin): + """Called when an handler drag started""" + pass - if self._labelItem is not None: - item = self._labelItem - plot = item.getPlot() - if plot is not None: - plot._remove(item) - self._labelItem = None + def handleDragUpdated(self, handle, origin, previous, current): + """Called when an handle drag position changed""" + pass - def _updated(self, event=None, checkVisibility=True): - """Implement Item mix-in update method by updating the plot items + def handleDragFinished(self, handle, origin, current): + """Called when an handle drag finished""" + pass - See :class:`~silx.gui.plot.items.Item._updated` + 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] """ - self._createPlotItems() + return color[:3] + (0.5,) - 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) + def _updateText(self, text): + """Update the text displayed by this ROI + + :param str text: A text + """ + pass class PointROI(RegionOfInterest, items.SymbolMixIn): """A ROI identifying a point in a 2D plot.""" - _kind = "Point" - """Label for this kind of ROI""" + ICON = 'add-shape-point' + NAME = 'point markers' + SHORT_NAME = "point" + """Metadata for this kind of ROI""" _plotShape = "point" """Plot shape which is used for the first interaction""" @@ -522,82 +779,186 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): """ def __init__(self, parent=None): - items.SymbolMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) + items.SymbolMixIn.__init__(self) + self._marker = items.Marker() + self._marker.sigItemChanged.connect(self._pointPositionChanged) + self._marker.setSymbol(self._DEFAULT_SYMBOL) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) + + def setFirstShapePoints(self, points): + self.setPosition(points[0]) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(PointROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) def getPosition(self): """Returns the position of this ROI :rtype: numpy.ndarray """ - return self._points[0].copy() + return self._marker.getPosition() 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 + self._marker.setPosition(*pos) - def _updateLabelItem(self, label): - self._items[0].setText(label) - - def _updateShape(self): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) + @docstring(_RegionOfInterestBase) + def contains(self, position): + raise NotImplementedError('Base class') - def __positionChanged(self, event): + def _pointPositionChanged(self, event): """Handle position changed events of the marker""" if event is items.ItemChangedType.POSITION: - marker = self.sender() - if isinstance(marker, items.Marker): - self.setPosition(marker.getPosition()) - - def _createShapeItems(self, points): - marker = items.Marker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getName()) - marker.setSymbol(self.getSymbol()) - marker.setSymbolSize(self.getSymbolSize()) - marker.setColor(rgba(self.getColor())) - marker._setDraggable(self.isEditable()) - if self.isEditable(): - marker.sigItemChanged.connect(self.__positionChanged) - return [marker] + self.sigRegionChanged.emit() def __str__(self): - points = self._getControlPoints() - params = '%f %f' % (points[0, 0], points[0, 1]) + params = '%f %f' % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) -class LineROI(RegionOfInterest, items.LineMixIn): +class CrossROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a point in a 2D plot and displayed as a cross + """ + + ICON = 'add-shape-cross' + NAME = 'cross marker' + SHORT_NAME = "cross" + """Metadata for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._handle = self.addHandle() + self._handle.sigItemChanged.connect(self._handlePositionChanged) + self._handleLabel = self.addLabelHandle() + self._vmarker = self.addUserHandle(items.YMarker()) + self._vmarker._setSelectable(False) + self._vmarker._setDraggable(False) + self._vmarker.setPosition(*self.getPosition()) + self._hmarker = self.addUserHandle(items.XMarker()) + self._hmarker._setSelectable(False) + self._hmarker._setDraggable(False) + self._hmarker.setPosition(*self.getPosition()) + + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + markers = (self._vmarker, self._hmarker) + self._updateItemProperty(event, self, markers) + super(CrossROI, self)._updated(event, checkVisibility) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def _updatedStyle(self, event, style): + super(CrossROI, self)._updatedStyle(event, style) + for marker in [self._vmarker, self._hmarker]: + marker.setColor(style.getColor()) + marker.setLineStyle(style.getLineStyle()) + marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0] + self.setPosition(pos) + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self._handle.getPosition() + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + self._handle.setPosition(*pos) + + def _handlePositionChanged(self, event): + """Handle center marker position updates""" + if event is items.ItemChangedType.POSITION: + position = self.getPosition() + self._handleLabel.setPosition(*position) + self._vmarker.setPosition(*position) + self._hmarker.setPosition(*position) + self.sigRegionChanged.emit() + + @docstring(HandleBasedROI) + def contains(self, position): + roiPos = self.getPosition() + return position[0] == roiPos[0] or position[1] == roiPos[1] + + +class LineROI(HandleBasedROI, items.LineMixIn): """A ROI identifying a line in a 2D plot. This ROI provides 1 anchor for each boundary of the line, plus an center in the center to translate the full ROI. """ - _kind = "Line" - """Label for this kind of ROI""" + ICON = 'add-shape-diagonal' + NAME = 'line ROI' + SHORT_NAME = "line" + """Metadata for this kind of ROI""" _plotShape = "line" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) items.LineMixIn.__init__(self) - RegionOfInterest.__init__(self, parent=parent) + self._handleStart = self.addHandle() + self._handleEnd = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("polylines") + shape.setPoints([[0, 0], [0, 0]]) + shape.setColor(rgba(self.getColor())) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + self.__shape = shape + self.addItem(shape) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(LineROI, self)._updated(event, checkVisibility) - def _createControlPointsFromFirstShape(self, points): - center = numpy.mean(points, axis=0) - controlPoints = numpy.array([points[0], points[1], center]) - return controlPoints + def _updatedStyle(self, event, style): + super(LineROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self.setEndPoints(points[0], points[1]) + + def _updateText(self, text): + self._handleLabel.setText(text) def setEndPoints(self, startPoint, endPoint): """Set this line location using the ending points @@ -605,88 +966,83 @@ class LineROI(RegionOfInterest, items.LineMixIn): :param numpy.ndarray startPoint: Staring bounding point of the line :param numpy.ndarray endPoint: Ending 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) + if not numpy.array_equal((startPoint, endPoint), self.getEndPoints()): + self.__updateEndPoints(startPoint, endPoint) + + def __updateEndPoints(self, startPoint, endPoint): + """Update marker and shape to match given end points + + :param numpy.ndarray startPoint: Staring bounding point of the line + :param numpy.ndarray endPoint: Ending bounding point of the line + """ + startPoint = numpy.array(startPoint) + endPoint = numpy.array(endPoint) + center = (startPoint + endPoint) * 0.5 + + with utils.blockSignals(self._handleStart): + self._handleStart.setPosition(startPoint[0], startPoint[1]) + with utils.blockSignals(self._handleEnd): + self._handleEnd.setPosition(endPoint[0], endPoint[1]) + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(center[0], center[1]) + + line = numpy.array((startPoint, endPoint)) + self.__shape.setPoints(line) + self.sigRegionChanged.emit() def getEndPoints(self): """Returns bounding points of this ROI. :rtype: Tuple(numpy.ndarray,numpy.ndarray) """ - startPoint = self._points[0].copy() - endPoint = self._points[1].copy() + startPoint = numpy.array(self._handleStart.getPosition()) + endPoint = numpy.array(self._handleEnd.getPosition()) 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) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - 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 handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleStart: + _start, end = self.getEndPoints() + self.__updateEndPoints(current, end) + elif handle is self._handleEnd: + start, _end = self.getEndPoints() + self.__updateEndPoints(start, current) + elif handle is self._handleCenter: + start, end = self.getEndPoints() + delta = current - previous + start += delta + end += delta + self.setEndPoints(start, end) + + @docstring(_RegionOfInterestBase) + def contains(self, position): + bottom_left = position[0], position[1] + bottom_right = position[0] + 1, position[1] + top_left = position[0], position[1] + 1 + top_right = position[0] + 1, position[1] + 1 + + line_pt1 = self._points[0] + line_pt2 = self._points[1] + + bb1 = _BoundingBox.from_points(self._points) + if bb1.contains(position) is False: + return False + + return ( + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=bottom_left, seg2_end_pt=bottom_right) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=bottom_right, seg2_end_pt=top_right) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=top_right, seg2_end_pt=top_left) or + segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, + seg2_start_pt=top_left, seg2_end_pt=bottom_left) + ) def __str__(self): - points = self._getControlPoints() - params = points[0][0], points[0][1], points[1][0], points[1][1] + start, end = self.getEndPoints() + params = start[0], start[1], end[0], end[1] params = 'start: %f %f; end: %f %f' % params return "%s(%s)" % (self.__class__.__name__, params) @@ -694,199 +1050,230 @@ class LineROI(RegionOfInterest, items.LineMixIn): class HorizontalLineROI(RegionOfInterest, items.LineMixIn): """A ROI identifying an horizontal line in a 2D plot.""" - _kind = "HLine" - """Label for this kind of ROI""" + ICON = 'add-shape-horizontal' + NAME = 'horizontal line ROI' + SHORT_NAME = "hline" + """Metadata for this kind of ROI""" _plotShape = "hline" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): - items.LineMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._marker = items.YMarker() + self._marker.sigItemChanged.connect(self._linePositionChanged) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) - def _createControlPointsFromFirstShape(self, points): - points = numpy.array([(float('nan'), points[0, 1])], - dtype=numpy.float64) - return points + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(HorizontalLineROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) + self._marker.setLineStyle(style.getLineStyle()) + self._marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0, 1] + if pos == self.getPosition(): + return + self.setPosition(pos) def getPosition(self): """Returns the position of this line if the horizontal axis :rtype: float """ - return self._points[0, 1] + pos = self._marker.getPosition() + return pos[1] def setPosition(self, pos): """Set the position of this ROI :param float pos: Horizontal position of this line """ - controlPoints = numpy.array([[float('nan'), pos]]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None - - def _updateLabelItem(self, label): - self._items[0].setText(label) + self._marker.setPosition(0, pos) - def _updateShape(self): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) + @docstring(_RegionOfInterestBase) + def contains(self, position): + return position[1] == self.getPosition()[1] - def __positionChanged(self, event): + def _linePositionChanged(self, event): """Handle position changed events of the marker""" if event is items.ItemChangedType.POSITION: - marker = self.sender() - if isinstance(marker, items.YMarker): - self.setPosition(marker.getYPosition()) - - def _createShapeItems(self, points): - marker = items.YMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getName()) - marker.setColor(rgba(self.getColor())) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) - marker._setDraggable(self.isEditable()) - if self.isEditable(): - marker.sigItemChanged.connect(self.__positionChanged) - return [marker] + self.sigRegionChanged.emit() def __str__(self): - points = self._getControlPoints() - params = 'y: %f' % points[0, 1] + params = 'y: %f' % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) class VerticalLineROI(RegionOfInterest, items.LineMixIn): """A ROI identifying a vertical line in a 2D plot.""" - _kind = "VLine" - """Label for this kind of ROI""" + ICON = 'add-shape-vertical' + NAME = 'vertical line ROI' + SHORT_NAME = "vline" + """Metadata for this kind of ROI""" _plotShape = "vline" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): - items.LineMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._marker = items.XMarker() + self._marker.sigItemChanged.connect(self._linePositionChanged) + self._marker.sigDragStarted.connect(self._editingStarted) + self._marker.sigDragFinished.connect(self._editingFinished) + self.addItem(self._marker) - def _createControlPointsFromFirstShape(self, points): - points = numpy.array([(points[0, 0], float('nan'))], - dtype=numpy.float64) - return points + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + label = self.getName() + self._marker.setText(label) + elif event == items.ItemChangedType.EDITABLE: + self._marker._setDraggable(self.isEditable()) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + self._updateItemProperty(event, self, self._marker) + super(VerticalLineROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + self._marker.setColor(style.getColor()) + self._marker.setLineStyle(style.getLineStyle()) + self._marker.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + pos = points[0, 0] + self.setPosition(pos) def getPosition(self): """Returns the position of this line if the horizontal axis :rtype: float """ - return self._points[0, 0] + pos = self._marker.getPosition() + return pos[0] def setPosition(self, pos): """Set the position of this ROI :param float pos: Horizontal position of this line """ - controlPoints = numpy.array([[pos, float('nan')]]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None + self._marker.setPosition(pos, 0) - def _updateLabelItem(self, label): - self._items[0].setText(label) + @docstring(RegionOfInterest) + def contains(self, position): + return position[0] == self.getPosition()[0] - def _updateShape(self): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) - - def __positionChanged(self, event): + def _linePositionChanged(self, event): """Handle position changed events of the marker""" if event is items.ItemChangedType.POSITION: - marker = self.sender() - if isinstance(marker, items.XMarker): - self.setPosition(marker.getXPosition()) - - def _createShapeItems(self, points): - marker = items.XMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getName()) - marker.setColor(rgba(self.getColor())) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) - marker._setDraggable(self.isEditable()) - if self.isEditable(): - marker.sigItemChanged.connect(self.__positionChanged) - return [marker] + self.sigRegionChanged.emit() def __str__(self): - points = self._getControlPoints() - params = 'x: %f' % points[0, 0] + params = 'x: %f' % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) -class RectangleROI(RegionOfInterest, items.LineMixIn): +class RectangleROI(HandleBasedROI, items.LineMixIn): """A ROI identifying a rectangle in a 2D plot. This ROI provides 1 anchor for each corner, plus an anchor in the center to translate the full ROI. """ - _kind = "Rectangle" - """Label for this kind of ROI""" + ICON = 'add-shape-rectangle' + NAME = 'rectangle ROI' + SHORT_NAME = "rectangle" + """Metadata for this kind of ROI""" _plotShape = "rectangle" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) items.LineMixIn.__init__(self) - RegionOfInterest.__init__(self, parent=parent) + self._handleTopLeft = self.addHandle() + self._handleTopRight = self.addHandle() + self._handleBottomLeft = self.addHandle() + self._handleBottomRight = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("rectangle") + shape.setPoints([[0, 0], [0, 0]]) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + shape.setColor(rgba(self.getColor())) + self.__shape = shape + self.addItem(shape) - def _createControlPointsFromFirstShape(self, points): - point0 = points[0] - point1 = points[1] + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + self._updateItemProperty(event, self, self.__shape) + super(RectangleROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(RectangleROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setBound(points) - # 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 _setBound(self, points): + """Initialize the rectangle from a bunch of points""" + top = max(points[:, 1]) + bottom = min(points[:, 1]) + left = min(points[:, 0]) + right = max(points[:, 0]) + size = right - left, top - bottom + self._updateGeometry(origin=(left, bottom), size=size) + + def _updateText(self, text): + self._handleLabel.setText(text) def getCenter(self): """Returns the central point of this rectangle :rtype: numpy.ndarray([float,float]) """ - return numpy.mean(self._points, axis=0) + pos = self._handleCenter.getPosition() + return numpy.array(pos) def getOrigin(self): """Returns the corner point with the smaller coordinates :rtype: numpy.ndarray([float,float]) """ - return numpy.min(self._points, axis=0) + pos = self._handleBottomLeft.getPosition() + return numpy.array(pos) 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 + vmin = self._handleBottomLeft.getPosition() + vmax = self._handleTopRight.getPosition() + vmin, vmax = numpy.array(vmin), numpy.array(vmax) + return vmax - vmin def setOrigin(self, position): """Set the origin position of this ROI @@ -915,93 +1302,80 @@ class RectangleROI(RegionOfInterest, items.LineMixIn): def setGeometry(self, origin=None, size=None, center=None): """Set the geometry of the ROI """ + if ((origin is None or numpy.array_equal(origin, self.getOrigin())) and + (center is None or numpy.array_equal(center, self.getCenter())) and + numpy.array_equal(size, self.getSize())): + return # Nothing has changed + + self._updateGeometry(origin, size, center) + + def _updateGeometry(self, origin=None, size=None, center=None): + """Forced update of the geometry of the ROI""" if origin is not None: origin = numpy.array(origin) size = numpy.array(size) points = numpy.array([origin, origin + size]) - controlPoints = self._createControlPointsFromFirstShape(points) + center = origin + size * 0.5 elif center is not None: center = numpy.array(center) size = numpy.array(size) points = numpy.array([center - size * 0.5, center + size * 0.5]) - 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) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - 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: + raise ValueError("Origin or center expected") + + with utils.blockSignals(self._handleBottomLeft): + self._handleBottomLeft.setPosition(points[0, 0], points[0, 1]) + with utils.blockSignals(self._handleBottomRight): + self._handleBottomRight.setPosition(points[1, 0], points[0, 1]) + with utils.blockSignals(self._handleTopLeft): + self._handleTopLeft.setPosition(points[0, 0], points[1, 1]) + with utils.blockSignals(self._handleTopRight): + self._handleTopRight.setPosition(points[1, 0], points[1, 1]) + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(points[0, 0], points[0, 1]) + + self.__shape.setPoints(points) + self.sigRegionChanged.emit() + + @docstring(HandleBasedROI) + def contains(self, position): + assert isinstance(position, (tuple, list, numpy.array)) + points = self.__shape.getPoints() + bb1 = _BoundingBox.from_points(points) + return bb1.contains(position) + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleCenter: # It is the center anchor - 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) + size = self.getSize() + self._updateGeometry(center=current, size=size) 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) + opposed = { + self._handleBottomLeft: self._handleTopRight, + self._handleTopRight: self._handleBottomLeft, + self._handleBottomRight: self._handleTopLeft, + self._handleTopLeft: self._handleBottomRight, + } + handle2 = opposed[handle] + current2 = handle2.getPosition() + points = numpy.array([current, current2]) + + # Switch handles if they were crossed by interaction + if self._handleBottomLeft.getXPosition() > self._handleBottomRight.getXPosition(): + self._handleBottomLeft, self._handleBottomRight = self._handleBottomRight, self._handleBottomLeft + + if self._handleTopLeft.getXPosition() > self._handleTopRight.getXPosition(): + self._handleTopLeft, self._handleTopRight = self._handleTopRight, self._handleTopLeft + + if self._handleBottomLeft.getYPosition() > self._handleTopLeft.getYPosition(): + self._handleBottomLeft, self._handleTopLeft = self._handleTopLeft, self._handleBottomLeft + + if self._handleBottomRight.getYPosition() > self._handleTopRight.getYPosition(): + self._handleBottomRight, self._handleTopRight = self._handleTopRight, self._handleBottomRight + + self._setBound(points) def __str__(self): origin = self.getOrigin() @@ -1011,21 +1385,504 @@ class RectangleROI(RegionOfInterest, items.LineMixIn): return "%s(%s)" % (self.__class__.__name__, params) -class PolygonROI(RegionOfInterest, items.LineMixIn): +class CircleROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying a circle in a 2D plot. + + This ROI provides 1 anchor at the center to translate the circle, + and one anchor on the perimeter to change the radius. + """ + + ICON = 'add-shape-circle' + NAME = 'circle ROI' + SHORT_NAME = "circle" + """Metadata for this kind of ROI""" + + _kind = "Circle" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + items.LineMixIn.__init__(self) + HandleBasedROI.__init__(self, parent=parent) + self._handlePerimeter = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleCenter.sigItemChanged.connect(self._centerPositionChanged) + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("polygon") + shape.setPoints([[0, 0], [0, 0]]) + shape.setColor(rgba(self.getColor())) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + self.__shape = shape + self.addItem(shape) + + self.__radius = 0 + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(CircleROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(CircleROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setRay(points) + + def _setRay(self, points): + """Initialize the circle from the center point and a + perimeter point.""" + center = points[0] + radius = numpy.linalg.norm(points[0] - points[1]) + self.setGeometry(center=center, radius=radius) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleCenter.getPosition() + return numpy.array(pos) + + def getRadius(self): + """Returns the radius of this circle + + :rtype: float + """ + return self.__radius + + def setCenter(self, position): + """Set the center point of this ROI + + :param numpy.ndarray position: Location of the center of the circle + """ + self._handleCenter.setPosition(*position) + + def setRadius(self, radius): + """Set the size of this ROI + + :param float size: Radius of the circle + """ + radius = float(radius) + if radius != self.__radius: + self.__radius = radius + self._updateGeometry() + + def setGeometry(self, center, radius): + """Set the geometry of the ROI + """ + if numpy.array_equal(center, self.getCenter()): + self.setRadius(radius) + else: + self.__radius = float(radius) # Update radius directly + self.setCenter(center) # Calls _updateGeometry + + def _updateGeometry(self): + """Update the handles and shape according to given parameters""" + center = self.getCenter() + perimeter_point = numpy.array([center[0] + self.__radius, center[1]]) + + self._handlePerimeter.setPosition(perimeter_point[0], perimeter_point[1]) + self._handleLabel.setPosition(center[0], center[1]) + + nbpoints = 27 + angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints + circleShape = numpy.array((numpy.cos(angles) * self.__radius, + numpy.sin(angles) * self.__radius)).T + circleShape += center + self.__shape.setPoints(circleShape) + self.sigRegionChanged.emit() + + def _centerPositionChanged(self, event): + """Handle position changed events of the center marker""" + if event is items.ItemChangedType.POSITION: + self._updateGeometry() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handlePerimeter: + center = self.getCenter() + self.setRadius(numpy.linalg.norm(center - current)) + + def __str__(self): + center = self.getCenter() + radius = self.getRadius() + params = center[0], center[1], radius + params = 'center: %f %f; radius: %f;' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class EllipseROI(HandleBasedROI, items.LineMixIn): + """A ROI identifying an oriented ellipse in a 2D plot. + + This ROI provides 1 anchor at the center to translate the circle, + and two anchors on the perimeter to modify the major-radius and + minor-radius. These two anchors also allow to change the orientation. + """ + + ICON = 'add-shape-ellipse' + NAME = 'ellipse ROI' + SHORT_NAME = "ellipse" + """Metadata for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + items.LineMixIn.__init__(self) + HandleBasedROI.__init__(self, parent=parent) + self._handleAxis0 = self.addHandle() + self._handleAxis1 = self.addHandle() + self._handleCenter = self.addTranslateHandle() + self._handleCenter.sigItemChanged.connect(self._centerPositionChanged) + self._handleLabel = self.addLabelHandle() + + shape = items.Shape("polygon") + shape.setPoints([[0, 0], [0, 0]]) + shape.setColor(rgba(self.getColor())) + shape.setFill(False) + shape.setOverlay(True) + shape.setLineStyle(self.getLineStyle()) + shape.setLineWidth(self.getLineWidth()) + self.__shape = shape + self.addItem(shape) + + self._radius = 0., 0. + self._orientation = 0. # angle in radians between the X-axis and the _handleAxis0 + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.VISIBLE: + self._updateItemProperty(event, self, self.__shape) + super(EllipseROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(EllipseROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + + def setFirstShapePoints(self, points): + assert len(points) == 2 + self._setRay(points) + + @staticmethod + def _calculateOrientation(p0, p1): + """return angle in radians between the vector p0-p1 + and the X axis + + :param p0: first point coordinates (x, y) + :param p1: second point coordinates + :return: + """ + vector = (p1[0] - p0[0], p1[1] - p0[1]) + x_unit_vector = (1, 0) + norm = numpy.linalg.norm(vector) + if norm != 0: + theta = numpy.arccos(numpy.dot(vector, x_unit_vector) / norm) + else: + theta = 0 + if vector[1] < 0: + # arccos always returns values in range [0, pi] + theta = 2 * numpy.pi - theta + return theta + + def _setRay(self, points): + """Initialize the circle from the center point and a + perimeter point.""" + center = points[0] + radius = numpy.linalg.norm(points[0] - points[1]) + orientation = self._calculateOrientation(points[0], points[1]) + self.setGeometry(center=center, + radius=(radius, radius), + orientation=orientation) + + def _updateText(self, text): + self._handleLabel.setText(text) + + def getCenter(self): + """Returns the central point of this rectangle + + :rtype: numpy.ndarray([float,float]) + """ + pos = self._handleCenter.getPosition() + return numpy.array(pos) + + def getMajorRadius(self): + """Returns the half-diameter of the major axis. + + :rtype: float + """ + return max(self._radius) + + def getMinorRadius(self): + """Returns the half-diameter of the minor axis. + + :rtype: float + """ + return min(self._radius) + + def getOrientation(self): + """Return angle in radians between the horizontal (X) axis + and the major axis of the ellipse in [0, 2*pi[ + + :rtype: float: + """ + return self._orientation + + def setCenter(self, center): + """Set the center point of this ROI + + :param numpy.ndarray position: Coordinates (X, Y) of the center + of the ellipse + """ + self._handleCenter.setPosition(*center) + + def setMajorRadius(self, radius): + """Set the half-diameter of the major axis of the ellipse. + + :param float radius: + Major radius of the ellipsis. Must be a positive value. + """ + if self._radius[0] > self._radius[1]: + newRadius = radius, self._radius[1] + else: + newRadius = self._radius[0], radius + self.setGeometry(radius=newRadius) + + def setMinorRadius(self, radius): + """Set the half-diameter of the minor axis of the ellipse. + + :param float radius: + Minor radius of the ellipsis. Must be a positive value. + """ + if self._radius[0] > self._radius[1]: + newRadius = self._radius[0], radius + else: + newRadius = radius, self._radius[1] + self.setGeometry(radius=newRadius) + + def setOrientation(self, orientation): + """Rotate the ellipse + + :param float orientation: Angle in radians between the horizontal and + the major axis. + :return: + """ + self.setGeometry(orientation=orientation) + + def setGeometry(self, center=None, radius=None, orientation=None): + """ + + :param center: (X, Y) coordinates + :param float majorRadius: + :param float minorRadius: + :param float orientation: angle in radians between the major axis and the + horizontal + :return: + """ + if center is None: + center = self.getCenter() + + if radius is None: + radius = self._radius + else: + radius = float(radius[0]), float(radius[1]) + + if orientation is None: + orientation = self._orientation + else: + # ensure that we store the orientation in range [0, 2*pi + orientation = numpy.mod(orientation, 2 * numpy.pi) + + if (numpy.array_equal(center, self.getCenter()) or + radius != self._radius or + orientation != self._orientation): + + # Update parameters directly + self._radius = radius + self._orientation = orientation + + if numpy.array_equal(center, self.getCenter()): + self._updateGeometry() + else: + # This will call _updateGeometry + self.setCenter(center) + + def _updateGeometry(self): + """Update shape and markers""" + center = self.getCenter() + + orientation = self.getOrientation() + if self._radius[1] > self._radius[0]: + # _handleAxis1 is the major axis + orientation -= numpy.pi/2 + + point0 = numpy.array([center[0] + self._radius[0] * numpy.cos(orientation), + center[1] + self._radius[0] * numpy.sin(orientation)]) + point1 = numpy.array([center[0] - self._radius[1] * numpy.sin(orientation), + center[1] + self._radius[1] * numpy.cos(orientation)]) + with utils.blockSignals(self._handleAxis0): + self._handleAxis0.setPosition(*point0) + with utils.blockSignals(self._handleAxis1): + self._handleAxis1.setPosition(*point1) + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(*center) + + nbpoints = 27 + angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints + X = (self._radius[0] * numpy.cos(angles) * numpy.cos(orientation) + - self._radius[1] * numpy.sin(angles) * numpy.sin(orientation)) + Y = (self._radius[0] * numpy.cos(angles) * numpy.sin(orientation) + + self._radius[1] * numpy.sin(angles) * numpy.cos(orientation)) + + ellipseShape = numpy.array((X, Y)).T + ellipseShape += center + self.__shape.setPoints(ellipseShape) + self.sigRegionChanged.emit() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle in (self._handleAxis0, self._handleAxis1): + center = self.getCenter() + orientation = self._calculateOrientation(center, current) + distance = numpy.linalg.norm(center - current) + + if handle is self._handleAxis1: + if self._radius[0] > distance: + # _handleAxis1 is not the major axis, rotate -90 degrees + orientation -= numpy.pi/2 + radius = self._radius[0], distance + + else: # _handleAxis0 + if self._radius[1] > distance: + # _handleAxis0 is not the major axis, rotate +90 degrees + orientation += numpy.pi/2 + radius = distance, self._radius[1] + + self.setGeometry(radius=radius, orientation=orientation) + + def _centerPositionChanged(self, event): + """Handle position changed events of the center marker""" + if event is items.ItemChangedType.POSITION: + self._updateGeometry() + + def __str__(self): + center = self.getCenter() + major = self.getMajorRadius() + minor = self.getMinorRadius() + orientation = self.getOrientation() + params = center[0], center[1], major, minor, orientation + params = 'center: %f %f; major radius: %f: minor radius: %f; orientation: %f' % params + return "%s(%s)" % (self.__class__.__name__, params) + + +class PolygonROI(HandleBasedROI, items.LineMixIn): """A ROI identifying a closed polygon in a 2D plot. This ROI provides 1 anchor for each point of the polygon. """ - _kind = "Polygon" - """Label for this kind of ROI""" + ICON = 'add-shape-polygon' + NAME = 'polygon ROI' + SHORT_NAME = "polygon" + """Metadata for this kind of ROI""" _plotShape = "polygon" """Plot shape which is used for the first interaction""" def __init__(self, parent=None): + HandleBasedROI.__init__(self, parent=parent) items.LineMixIn.__init__(self) - RegionOfInterest.__init__(self, parent=parent) + self._handleLabel = self.addLabelHandle() + self._handleCenter = self.addTranslateHandle() + self._handlePoints = [] + self._points = numpy.empty((0, 2)) + self._handleClose = None + + self._polygon_shape = None + shape = self.__createShape() + self.__shape = shape + self.addItem(shape) + + def _updated(self, event=None, checkVisibility=True): + if event in [items.ItemChangedType.VISIBLE]: + self._updateItemProperty(event, self, self.__shape) + super(PolygonROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + super(PolygonROI, self)._updatedStyle(event, style) + self.__shape.setColor(style.getColor()) + self.__shape.setLineStyle(style.getLineStyle()) + self.__shape.setLineWidth(style.getLineWidth()) + if self._handleClose is not None: + color = self._computeHandleColor(style.getColor()) + self._handleClose.setColor(color) + + def __createShape(self, interaction=False): + kind = "polygon" if not interaction else "polylines" + shape = items.Shape(kind) + shape.setPoints([[0, 0], [0, 0]]) + shape.setFill(False) + shape.setOverlay(True) + style = self.getCurrentStyle() + shape.setLineStyle(style.getLineStyle()) + shape.setLineWidth(style.getLineWidth()) + shape.setColor(rgba(style.getColor())) + return shape + + def setFirstShapePoints(self, points): + if self._handleClose is not None: + self._handleClose.setPosition(*points[0]) + self.setPoints(points) + + def creationStarted(self): + """"Called when the ROI creation interaction was started. + """ + # Handle to see where to close the polygon + self._handleClose = self.addUserHandle() + self._handleClose.setSymbol("o") + color = self._computeHandleColor(rgba(self.getColor())) + self._handleClose.setColor(color) + + # Hide the center while creating the first shape + self._handleCenter.setSymbol("") + + # In interaction replace the polygon by a line, to display something unclosed + self.removeItem(self.__shape) + self.__shape = self.__createShape(interaction=True) + self.__shape.setPoints(self._points) + self.addItem(self.__shape) + + def isBeingCreated(self): + """Returns true if the ROI is in creation step""" + return self._handleClose is not None + + def creationFinalized(self): + """"Called when the ROI creation interaction was finalized. + """ + self.removeHandle(self._handleClose) + self._handleClose = None + self.removeItem(self.__shape) + self.__shape = self.__createShape() + self.__shape.setPoints(self._points) + self.addItem(self.__shape) + # Hide the center while creating the first shape + self._handleCenter.setSymbol("+") + for handle in self._handlePoints: + handle.setSymbol("s") + + def _updateText(self, text): + self._handleLabel.setText(text) def getPoints(self): """Returns the list of the points of this polygon. @@ -1040,213 +1897,484 @@ class PolygonROI(RegionOfInterest, items.LineMixIn): :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])] + if numpy.array_equal(points, self._points): + return # Nothing has changed - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - shape.setPoints(points) + self._polygon_shape = None + + # Update the needed handles + while len(self._handlePoints) != len(points): + if len(self._handlePoints) < len(points): + handle = self.addHandle() + self._handlePoints.append(handle) + if self.isBeingCreated(): + handle.setSymbol("") + else: + handle = self._handlePoints.pop(-1) + self.removeHandle(handle) + + for handle, position in zip(self._handlePoints, points): + with utils.blockSignals(handle): + handle.setPosition(position[0], position[1]) + + if len(points) > 0: + if not self.isHandleBeingDragged(): + vmin = numpy.min(points, axis=0) + vmax = numpy.max(points, axis=0) + center = (vmax + vmin) * 0.5 + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) + + num = numpy.argmin(points[:, 1]) + pos = points[num] + with utils.blockSignals(self._handleLabel): + self._handleLabel.setPosition(pos[0], pos[1]) - def _createShapeItems(self, points): if len(points) == 0: - return [] + self._points = numpy.empty((0, 2)) + else: + self._points = points + self.__shape.setPoints(self._points) + self.sigRegionChanged.emit() + + def translate(self, x, y): + points = self.getPoints() + delta = numpy.array([x, y]) + self.setPoints(points) + self.setPoints(points + delta) + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self._handleCenter: + delta = current - previous + self.translate(delta[0], delta[1]) else: - item = items.Shape("polygon") - item.setPoints(points) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - 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 + points = self.getPoints() + num = self._handlePoints.index(handle) + points[num] = current + self.setPoints(points) + + def handleDragFinished(self, handle, origin, current): + points = self._points + if len(points) > 0: + # Only update the center at the end + # To avoid to disturb the interaction + vmin = numpy.min(points, axis=0) + vmax = numpy.max(points, axis=0) + center = (vmax + vmin) * 0.5 + with utils.blockSignals(self._handleCenter): + self._handleCenter.setPosition(center[0], center[1]) def __str__(self): - points = self._getControlPoints() + points = self._points params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points) return "%s(%s)" % (self.__class__.__name__, params) + @docstring(HandleBasedROI) + def contains(self, position): + bb1 = _BoundingBox.from_points(self.getPoints()) + if bb1.contains(position) is False: + return False + + if self._polygon_shape is None: + self._polygon_shape = Polygon(vertices=self.getPoints()) + + # warning: both the polygon and the value are inverted + return self._polygon_shape.is_inside(row=position[0], col=position[1]) + + def _setControlPoints(self, points): + RegionOfInterest._setControlPoints(self, points=points) + self._polygon_shape = None -class ArcROI(RegionOfInterest, items.LineMixIn): + +class ArcROI(HandleBasedROI, items.LineMixIn): """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. + This ROI provides + - 3 handle to control the curvature + - 1 handle to control the weight + - 1 anchor to translate the shape. """ - _kind = "Arc" - """Label for this kind of ROI""" + 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""" - _ArcGeometry = collections.namedtuple('ArcGeometry', ['center', - 'startPoint', 'endPoint', - 'radius', 'weight', - 'startAngle', 'endAngle']) + 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) - RegionOfInterest.__init__(self, parent=parent) - self._geometry = None + 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 _getInternalGeometry(self): - """Returns the object storing the internal geometry of this ROI. + 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()) - This geometry is derived from the control points and cached for - efficiency. Calling :meth:`_setControlPoints` invalidate the cache. + 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. """ - if self._geometry is None: - controlPoints = self._getControlPoints() - self._geometry = self._createGeometryFromControlPoint(controlPoints) - return self._geometry + # The first shape is a line + point0 = points[0] + point1 = points[1] - @classmethod - def showFirstInteractionShape(cls): - return False + # 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 - def _getLabelPosition(self): - points = self._getControlPoints() - return points.min(axis=0) + geometry = self._createGeometryFromControlPoints(point0, mid, point1, weight) + self._geometry = geometry + self._updateHandles() - 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 + 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: - 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) + 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: - # 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) + normal = normal / distance + weightPos = center + normal * geometry.weight * 0.5 else: - super(ArcROI, self)._controlPointAnchorPositionChanged(index, current, previous) + 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 _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 _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() - def _createGeometryFromControlPoint(self, controlPoints): + 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""" - weigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 - if numpy.allclose(controlPoints[0], controlPoints[2]): + if closed or (closed is None and numpy.allclose(start, end)): # 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 + 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._ArcGeometry(center, controlPoints[0], controlPoints[2], - radius, weigth, startAngle, endAngle) + return self._Geometry.createCircle(center, start, end, radius, + weight, startAngle, endAngle) - elif numpy.linalg.norm( - numpy.cross(controlPoints[1] - controlPoints[0], - controlPoints[2] - controlPoints[0])) < 1e-5: + elif numpy.linalg.norm(numpy.cross(mid - start, end - start)) < 1e-5: # Degenerated arc, it's a rectangle - return self._ArcGeometry(None, controlPoints[0], controlPoints[2], - None, weigth, None, None) + return self._Geometry.createRect(start, end, weight) else: - center, radius = self._circleEquation(*controlPoints[:3]) - v = controlPoints[0] - center + center, radius = self._circleEquation(start, mid, end) + v = start - center startAngle = numpy.angle(complex(v[0], v[1])) - v = controlPoints[1] - center + v = mid - center midAngle = numpy.angle(complex(v[0], v[1])) - v = controlPoints[2] - center + v = end - 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: + 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._ArcGeometry(center, controlPoints[0], controlPoints[2], - radius, weigth, startAngle, endAngle) + return self._Geometry.create(center, start, end, + radius, weight, 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: + def _createShapeFromGeometry(self, geometry): + kind = geometry.getKind() + if kind == "rect": # It is not an arc - # but we can display it as an the intermediat shape + # 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) @@ -1257,15 +2385,40 @@ class ArcROI(RegionOfInterest, items.LineMixIn): 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 - 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) @@ -1280,57 +2433,58 @@ class ArcROI(RegionOfInterest, items.LineMixIn): 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")])) + 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: - 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) + assert False + points = numpy.array(points) return points - def _setControlPoints(self, points): - # Invalidate the geometry - self._geometry = None - RegionOfInterest._setControlPoints(self, 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 @@ -1344,7 +2498,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :raise ValueError: In case the ROI can't be represented as section of a circle """ - geometry = self._getInternalGeometry() + 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 @@ -1354,8 +2508,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: bool """ - geometry = self._getInternalGeometry() - return self._isCircle(geometry) + return self._geometry.isClosed() def getCenter(self): """Returns the center of the circle used to draw arcs of this ROI. @@ -1364,8 +2517,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: numpy.ndarray """ - geometry = self._getInternalGeometry() - return geometry.center + return self._geometry.center def getStartAngle(self): """Returns the angle of the start of the section of this ROI (in radian). @@ -1375,8 +2527,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: float """ - geometry = self._getInternalGeometry() - return geometry.startAngle + return self._geometry.startAngle def getEndAngle(self): """Returns the angle of the end of the section of this ROI (in radian). @@ -1386,15 +2537,14 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: float """ - geometry = self._getInternalGeometry() - return geometry.endAngle + return self._geometry.endAngle def getInnerRadius(self): """Returns the radius of the smaller arc used to draw this ROI. :rtype: float """ - geometry = self._getInternalGeometry() + geometry = self._geometry radius = geometry.radius - geometry.weight * 0.5 if radius < 0: radius = 0 @@ -1405,7 +2555,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): :rtype: float """ - geometry = self._getInternalGeometry() + geometry = self._geometry radius = geometry.radius + geometry.weight * 0.5 return radius @@ -1427,96 +2577,67 @@ class ArcROI(RegionOfInterest, items.LineMixIn): 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] + 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 - # 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) - item.setLineStyle(self.getLineStyle()) - item.setLineWidth(self.getLineWidth()) - 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 translate(self, x, y): + self._geometry = self._geometry.translated(x, y) + self._updateHandles() 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 + """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 @@ -1530,7 +2651,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn): 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)) + return numpy.array((-c.real, -c.imag)), abs(c + x) def __str__(self): try: @@ -1540,3 +2661,221 @@ class ArcROI(RegionOfInterest, items.LineMixIn): 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.""" + + ICON = 'add-range-horizontal' + NAME = 'horizontal range ROI' + SHORT_NAME = "hrange" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + RegionOfInterest.__init__(self, parent=parent) + items.LineMixIn.__init__(self) + self._markerMin = items.XMarker() + self._markerMax = items.XMarker() + self._markerCen = items.XMarker() + self._markerCen.setLineStyle(" ") + self._markerMin._setConstraint(self.__positionMinConstraint) + self._markerMax._setConstraint(self.__positionMaxConstraint) + self._markerMin.sigDragStarted.connect(self._editingStarted) + self._markerMin.sigDragFinished.connect(self._editingFinished) + self._markerMax.sigDragStarted.connect(self._editingStarted) + self._markerMax.sigDragFinished.connect(self._editingFinished) + self._markerCen.sigDragStarted.connect(self._editingStarted) + self._markerCen.sigDragFinished.connect(self._editingFinished) + self.addItem(self._markerCen) + self.addItem(self._markerMin) + self.addItem(self._markerMax) + self.__filterReentrant = utils.LockReentrant() + + def setFirstShapePoints(self, points): + vmin = min(points[:, 0]) + vmax = max(points[:, 0]) + self._updatePos(vmin, vmax) + + def _updated(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + self._updateText() + elif event == items.ItemChangedType.EDITABLE: + self._updateEditable() + self._updateText() + elif event == items.ItemChangedType.LINE_STYLE: + markers = [self._markerMin, self._markerMax] + self._updateItemProperty(event, self, markers) + elif event in [items.ItemChangedType.VISIBLE, + items.ItemChangedType.SELECTABLE]: + markers = [self._markerMin, self._markerMax, self._markerCen] + self._updateItemProperty(event, self, markers) + super(HorizontalRangeROI, self)._updated(event, checkVisibility) + + def _updatedStyle(self, event, style): + markers = [self._markerMin, self._markerMax, self._markerCen] + for m in markers: + m.setColor(style.getColor()) + m.setLineWidth(style.getLineWidth()) + + def _updateText(self): + text = self.getName() + if self.isEditable(): + self._markerMin.setText("") + self._markerCen.setText(text) + else: + self._markerMin.setText(text) + self._markerCen.setText("") + + def _updateEditable(self): + editable = self.isEditable() + self._markerMin._setDraggable(editable) + self._markerMax._setDraggable(editable) + self._markerCen._setDraggable(editable) + if self.isEditable(): + self._markerMin.sigItemChanged.connect(self._minPositionChanged) + self._markerMax.sigItemChanged.connect(self._maxPositionChanged) + self._markerCen.sigItemChanged.connect(self._cenPositionChanged) + self._markerCen.setLineStyle(":") + else: + self._markerMin.sigItemChanged.disconnect(self._minPositionChanged) + self._markerMax.sigItemChanged.disconnect(self._maxPositionChanged) + self._markerCen.sigItemChanged.disconnect(self._cenPositionChanged) + self._markerCen.setLineStyle(" ") + + def _updatePos(self, vmin, vmax, force=False): + """Update marker position and emit signal. + + :param float vmin: + :param float vmax: + :param bool force: + True to update even if already at the right position. + """ + if not force and numpy.array_equal((vmin, vmax), self.getRange()): + return # Nothing has changed + + center = (vmin + vmax) * 0.5 + with self.__filterReentrant: + with utils.blockSignals(self._markerMin): + self._markerMin.setPosition(vmin, 0) + with utils.blockSignals(self._markerCen): + self._markerCen.setPosition(center, 0) + with utils.blockSignals(self._markerMax): + self._markerMax.setPosition(vmax, 0) + self.sigRegionChanged.emit() + + def setRange(self, vmin, vmax): + """Set the range of this ROI. + + :param float vmin: Staring location of the range + :param float vmax: Ending location of the range + """ + if vmin is None or vmax is None: + err = "Can't set vmin or vmax to None" + raise ValueError(err) + if vmin > vmax: + err = "Can't set vmin and vmax because vmin >= vmax " \ + "vmin = %s, vmax = %s" % (vmin, vmax) + raise ValueError(err) + self._updatePos(vmin, vmax) + + def getRange(self): + """Returns the range of this ROI. + + :rtype: Tuple[float,float] + """ + vmin = self.getMin() + vmax = self.getMax() + return vmin, vmax + + def setMin(self, vmin): + """Set the min of this ROI. + + :param float vmin: New min + """ + vmax = self.getMax() + self._updatePos(vmin, vmax) + + def getMin(self): + """Returns the min value of this ROI. + + :rtype: float + """ + return self._markerMin.getPosition()[0] + + def setMax(self, vmax): + """Set the max of this ROI. + + :param float vmax: New max + """ + vmin = self.getMin() + self._updatePos(vmin, vmax) + + def getMax(self): + """Returns the max value of this ROI. + + :rtype: float + """ + return self._markerMax.getPosition()[0] + + def setCenter(self, center): + """Set the center of this ROI. + + :param float center: New center + """ + vmin, vmax = self.getRange() + previousCenter = (vmin + vmax) * 0.5 + delta = center - previousCenter + self._updatePos(vmin + delta, vmax + delta) + + def getCenter(self): + """Returns the center location of this ROI. + + :rtype: float + """ + vmin, vmax = self.getRange() + return (vmin + vmax) * 0.5 + + def __positionMinConstraint(self, x, y): + """Constraint of the min marker""" + if self.__filterReentrant.locked(): + # Ignore the constraint when we set an explicit value + return x, y + vmax = self.getMax() + if vmax is None: + return x, y + return min(x, vmax), y + + def __positionMaxConstraint(self, x, y): + """Constraint of the max marker""" + if self.__filterReentrant.locked(): + # Ignore the constraint when we set an explicit value + return x, y + vmin = self.getMin() + if vmin is None: + return x, y + return max(x, vmin), y + + def _minPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self._updatePos(marker.getXPosition(), self.getMax(), force=True) + + def _maxPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self._updatePos(self.getMin(), marker.getXPosition(), force=True) + + def _cenPositionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + self.setCenter(marker.getXPosition()) + + def __str__(self): + vrange = self.getRange() + params = 'min: %f; max: %f' % vrange + return "%s(%s)" % (self.__class__.__name__, params) |