diff options
Diffstat (limited to 'src/silx/gui/plot/tools')
20 files changed, 8064 insertions, 0 deletions
diff --git a/src/silx/gui/plot/tools/CurveLegendsWidget.py b/src/silx/gui/plot/tools/CurveLegendsWidget.py new file mode 100644 index 0000000..4a517dd --- /dev/null +++ b/src/silx/gui/plot/tools/CurveLegendsWidget.py @@ -0,0 +1,247 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a widget to display :class:`PlotWidget` curve legends. +""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "20/07/2018" + + +import logging +import weakref + + +from ... import qt +from ...widgets.FlowLayout import FlowLayout as _FlowLayout +from ..LegendSelector import LegendIcon as _LegendIcon +from .. import items + + +_logger = logging.getLogger(__name__) + + +class _LegendWidget(qt.QWidget): + """Widget displaying curve style and its legend + + :param QWidget parent: See :class:`QWidget` + :param ~silx.gui.plot.items.Curve curve: Associated curve + """ + + def __init__(self, parent, curve): + super(_LegendWidget, self).__init__(parent) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(10, 0, 10, 0) + + curve.sigItemChanged.connect(self._curveChanged) + + icon = _LegendIcon(curve=curve) + layout.addWidget(icon) + + label = qt.QLabel(curve.getName()) + label.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter) + layout.addWidget(label) + + self._update() + + def getCurve(self): + """Returns curve associated to this widget + + :rtype: Union[~silx.gui.plot.items.Curve,None] + """ + icon = self.findChild(_LegendIcon) + return icon.getCurve() + + def _update(self): + """Update widget according to current curve state. + """ + curve = self.getCurve() + if curve is None: + _logger.error('Curve no more exists') + self.setVisible(False) + return + + self.setEnabled(curve.isVisible()) + + label = self.findChild(qt.QLabel) + if curve.isHighlighted(): + label.setStyleSheet("border: 1px solid black") + else: + label.setStyleSheet("") + + def _curveChanged(self, event): + """Handle update of curve item + + :param event: Kind of change + """ + if event in (items.ItemChangedType.VISIBLE, + items.ItemChangedType.HIGHLIGHTED, + items.ItemChangedType.HIGHLIGHTED_STYLE): + self._update() + + +class CurveLegendsWidget(qt.QWidget): + """Widget displaying curves legends in a plot + + :param QWidget parent: See :class:`QWidget` + """ + + sigCurveClicked = qt.Signal(object) + """Signal emitted when the legend of a curve is clicked + + It provides the corresponding curve. + """ + + def __init__(self, parent=None): + super(CurveLegendsWidget, self).__init__(parent) + self._clicked = None + self._legends = {} + self._plotRef = None + + def layout(self): + layout = super(CurveLegendsWidget, self).layout() + if layout is None: + # Lazy layout initialization to allow overloading + layout = _FlowLayout() + layout.setHorizontalSpacing(0) + self.setLayout(layout) + return layout + + def getPlotWidget(self): + """Returns the associated :class:`PlotWidget` + + :rtype: Union[~silx.gui.plot.PlotWidget,None] + """ + return None if self._plotRef is None else self._plotRef() + + def setPlotWidget(self, plot): + """Set the associated :class:`PlotWidget` + + :param ~silx.gui.plot.PlotWidget plot: Plot widget to attach + """ + previousPlot = self.getPlotWidget() + if previousPlot is not None: + previousPlot.sigItemAdded.disconnect( self._itemAdded) + previousPlot.sigItemAboutToBeRemoved.disconnect(self._itemRemoved) + for legend in list(self._legends.keys()): + self._removeLegend(legend) + + self._plotRef = None if plot is None else weakref.ref(plot) + + if plot is not None: + plot.sigItemAdded.connect(self._itemAdded) + plot.sigItemAboutToBeRemoved.connect(self._itemRemoved) + + for legend in plot.getAllCurves(just_legend=True): + self._addLegend(legend) + + def curveAt(self, *args): + """Returns the curve object represented at the given position + + Either takes a QPoint or x and y as input in widget coordinates. + + :rtype: Union[~silx.gui.plot.items.Curve,None] + """ + if len(args) == 1: + point = args[0] + elif len(args) == 2: + point = qt.QPoint(*args) + else: + raise ValueError('Unsupported arguments') + assert isinstance(point, qt.QPoint) + + widget = self.childAt(point) + while widget not in (self, None): + if isinstance(widget, _LegendWidget): + return widget.getCurve() + widget = widget.parent() + return None # No widget or not in _LegendWidget + + def _itemAdded(self, item): + """Handle item added to the plot content""" + if isinstance(item, items.Curve): + self._addLegend(item.getName()) + + def _itemRemoved(self, item): + """Handle item removed from the plot content""" + if isinstance(item, items.Curve): + self._removeLegend(item.getName()) + + def _addLegend(self, legend): + """Add a curve to the legends + + :param str legend: Curve's legend + """ + if legend in self._legends: + return # Can happen when changing curve's y axis + + plot = self.getPlotWidget() + if plot is None: + return None + + curve = plot.getCurve(legend) + if curve is None: + _logger.error('Curve not found: %s' % legend) + return + + widget = _LegendWidget(parent=self, curve=curve) + self.layout().addWidget(widget) + self._legends[legend] = widget + + def _removeLegend(self, legend): + """Remove a curve from the legends if it exists + + :param str legend: The curve's legend + """ + widget = self._legends.pop(legend, None) + if widget is None: + _logger.warning('Unknown legend: %s' % legend) + else: + self.layout().removeWidget(widget) + widget.setParent(None) + + def mousePressEvent(self, event): + if event.button() == qt.Qt.LeftButton: + self._clicked = event.pos() + + _CLICK_THRESHOLD = 5 + """Threshold for clicks""" + + def mouseMoveEvent(self, event): + if self._clicked is not None: + dx = abs(self._clicked.x() - event.pos().x()) + dy = abs(self._clicked.y() - event.pos().y()) + if dx > self._CLICK_THRESHOLD or dy > self._CLICK_THRESHOLD: + self._clicked = None # Click is cancelled + + def mouseReleaseEvent(self, event): + if event.button() == qt.Qt.LeftButton and self._clicked is not None: + curve = self.curveAt(event.pos()) + if curve is not None: + self.sigCurveClicked.emit(curve) + + self._clicked = None diff --git a/src/silx/gui/plot/tools/LimitsToolBar.py b/src/silx/gui/plot/tools/LimitsToolBar.py new file mode 100644 index 0000000..fc192a6 --- /dev/null +++ b/src/silx/gui/plot/tools/LimitsToolBar.py @@ -0,0 +1,131 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""A toolbar to display and edit limits of a PlotWidget +""" + + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent"] +__license__ = "MIT" +__date__ = "16/10/2017" + + +from ... import qt +from ...widgets.FloatEdit import FloatEdit + + +class LimitsToolBar(qt.QToolBar): + """QToolBar displaying and controlling the limits of a :class:`PlotWidget`. + + To run the following sample code, a QApplication must be initialized. + First, create a PlotWindow: + + >>> from silx.gui.plot import PlotWindow + >>> plot = PlotWindow() # Create a PlotWindow to add the toolbar to + + Then, create the LimitsToolBar and add it to the PlotWindow. + + >>> from silx.gui import qt + >>> from silx.gui.plot.tools import LimitsToolBar + + >>> toolbar = LimitsToolBar(plot=plot) # Create the toolbar + >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolbar) # Add it to the plot + >>> plot.show() # To display the PlotWindow with the limits toolbar + + :param parent: See :class:`QToolBar`. + :param plot: :class:`PlotWidget` instance on which to operate. + :param str title: See :class:`QToolBar`. + """ + + def __init__(self, parent=None, plot=None, title='Limits'): + super(LimitsToolBar, self).__init__(title, parent) + assert plot is not None + self._plot = plot + self._plot.sigPlotSignal.connect(self._plotWidgetSlot) + + self._initWidgets() + + @property + def plot(self): + """The :class:`PlotWidget` the toolbar is attached to.""" + return self._plot + + def _initWidgets(self): + """Create and init Toolbar widgets.""" + xMin, xMax = self.plot.getXAxis().getLimits() + yMin, yMax = self.plot.getYAxis().getLimits() + + self.addWidget(qt.QLabel('Limits: ')) + self.addWidget(qt.QLabel(' X: ')) + self._xMinFloatEdit = FloatEdit(self, xMin) + self._xMinFloatEdit.editingFinished[()].connect( + self._xFloatEditChanged) + self.addWidget(self._xMinFloatEdit) + + self._xMaxFloatEdit = FloatEdit(self, xMax) + self._xMaxFloatEdit.editingFinished[()].connect( + self._xFloatEditChanged) + self.addWidget(self._xMaxFloatEdit) + + self.addWidget(qt.QLabel(' Y: ')) + self._yMinFloatEdit = FloatEdit(self, yMin) + self._yMinFloatEdit.editingFinished[()].connect( + self._yFloatEditChanged) + self.addWidget(self._yMinFloatEdit) + + self._yMaxFloatEdit = FloatEdit(self, yMax) + self._yMaxFloatEdit.editingFinished[()].connect( + self._yFloatEditChanged) + self.addWidget(self._yMaxFloatEdit) + + def _plotWidgetSlot(self, event): + """Listen to :class:`PlotWidget` events.""" + if event['event'] not in ('limitsChanged',): + return + + xMin, xMax = self.plot.getXAxis().getLimits() + yMin, yMax = self.plot.getYAxis().getLimits() + + self._xMinFloatEdit.setValue(xMin) + self._xMaxFloatEdit.setValue(xMax) + self._yMinFloatEdit.setValue(yMin) + self._yMaxFloatEdit.setValue(yMax) + + def _xFloatEditChanged(self): + """Handle X limits changed from the GUI.""" + xMin, xMax = self._xMinFloatEdit.value(), self._xMaxFloatEdit.value() + if xMax < xMin: + xMin, xMax = xMax, xMin + + self.plot.getXAxis().setLimits(xMin, xMax) + + def _yFloatEditChanged(self): + """Handle Y limits changed from the GUI.""" + yMin, yMax = self._yMinFloatEdit.value(), self._yMaxFloatEdit.value() + if yMax < yMin: + yMin, yMax = yMax, yMin + + self.plot.getYAxis().setLimits(yMin, yMax) diff --git a/src/silx/gui/plot/tools/PositionInfo.py b/src/silx/gui/plot/tools/PositionInfo.py new file mode 100644 index 0000000..8b95fbc --- /dev/null +++ b/src/silx/gui/plot/tools/PositionInfo.py @@ -0,0 +1,373 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a widget displaying mouse coordinates in a PlotWidget. + +It can be configured to provide more information. +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent"] +__license__ = "MIT" +__date__ = "16/10/2017" + + +import logging +import numbers +import traceback +import weakref + +import numpy + +from ....utils.deprecation import deprecated +from ... import qt +from .. import items +from ...widgets.ElidedLabel import ElidedLabel + + +_logger = logging.getLogger(__name__) + + +class _PositionInfoLabel(ElidedLabel): + """QLabel with a default size larger than what is displayed.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + + def sizeHint(self): + hint = super().sizeHint() + width = self.fontMetrics().boundingRect('##############').width() + return qt.QSize(max(hint.width(), width), hint.height()) + + +# PositionInfo ################################################################ + +class PositionInfo(qt.QWidget): + """QWidget displaying coords converted from data coords of the mouse. + + Provide this widget with a list of couple: + + - A name to display before the data + - A function that takes (x, y) as arguments and returns something that + gets converted to a string. + If the result is a float it is converted with '%.7g' format. + + To run the following sample code, a QApplication must be initialized. + First, create a PlotWindow and add a QToolBar where to place the + PositionInfo widget. + + >>> from silx.gui.plot import PlotWindow + >>> from silx.gui import qt + + >>> plot = PlotWindow() # Create a PlotWindow to add the widget to + >>> toolBar = qt.QToolBar() # Create a toolbar to place the widget in + >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) # Add it to plot + + Then, create the PositionInfo widget and add it to the toolbar. + The PositionInfo widget is created with a list of converters, here + to display polar coordinates of the mouse position. + + >>> import numpy + >>> from silx.gui.plot.tools import PositionInfo + + >>> position = PositionInfo(plot=plot, converters=[ + ... ('Radius', lambda x, y: numpy.sqrt(x*x + y*y)), + ... ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x)))]) + >>> toolBar.addWidget(position) # Add the widget to the toolbar + <...> + >>> plot.show() # To display the PlotWindow with the position widget + + :param plot: The PlotWidget this widget is displaying data coords from. + :param converters: + List of 2-tuple: name to display and conversion function from (x, y) + in data coords to displayed value. + If None, the default, it displays X and Y. + :param parent: Parent widget + """ + + SNAP_THRESHOLD_DIST = 5 + + def __init__(self, parent=None, plot=None, converters=None): + assert plot is not None + self._plotRef = weakref.ref(plot) + self._snappingMode = self.SNAPPING_DISABLED + + super(PositionInfo, self).__init__(parent) + + if converters is None: + converters = (('X', lambda x, y: x), ('Y', lambda x, y: y)) + + self._fields = [] # To store (QLineEdit, name, function (x, y)->v) + + # Create a new layout with new widgets + layout = qt.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + # layout.setSpacing(0) + + # Create all QLabel and store them with the corresponding converter + for name, func in converters: + layout.addWidget(qt.QLabel('<b>' + name + ':</b>')) + + contentWidget = _PositionInfoLabel(self) + contentWidget.setText('------') + layout.addWidget(contentWidget) + self._fields.append((contentWidget, name, func)) + + layout.addStretch(1) + self.setLayout(layout) + + # Connect to Plot events + plot.sigPlotSignal.connect(self._plotEvent) + + def getPlotWidget(self): + """Returns the PlotWidget this widget is attached to or None. + + :rtype: Union[~silx.gui.plot.PlotWidget,None] + """ + return self._plotRef() + + @property + @deprecated(replacement='getPlotWidget', since_version='0.8.0') + def plot(self): + return self.getPlotWidget() + + def getConverters(self): + """Return the list of converters as 2-tuple (name, function).""" + return [(name, func) for _label, name, func in self._fields] + + def _plotEvent(self, event): + """Handle events from the Plot. + + :param dict event: Plot event + """ + if event['event'] == 'mouseMoved': + x, y = event['x'], event['y'] + xPixel, yPixel = event['xpixel'], event['ypixel'] + self._updateStatusBar(x, y, xPixel, yPixel) + + def updateInfo(self): + """Update displayed information""" + plot = self.getPlotWidget() + if plot is None: + _logger.error("Trying to update PositionInfo " + "while PlotWidget no longer exists") + return + + widget = plot.getWidgetHandle() + position = widget.mapFromGlobal(qt.QCursor.pos()) + xPixel, yPixel = position.x(), position.y() + dataPos = plot.pixelToData(xPixel, yPixel, check=True) + if dataPos is not None: # Inside plot area + x, y = dataPos + self._updateStatusBar(x, y, xPixel, yPixel) + + def _updateStatusBar(self, x, y, xPixel, yPixel): + """Update information from the status bar using the definitions. + + :param float x: Position-x in data + :param float y: Position-y in data + :param float xPixel: Position-x in pixels + :param float yPixel: Position-y in pixels + """ + plot = self.getPlotWidget() + if plot is None: + return + + styleSheet = "color: rgb(0, 0, 0);" # Default style + xData, yData = x, y + + snappingMode = self.getSnappingMode() + + # Snapping when crosshair either not requested or active + if (snappingMode & (self.SNAPPING_CURVE | self.SNAPPING_SCATTER) and + (not (snappingMode & self.SNAPPING_CROSSHAIR) or + plot.getGraphCursor())): + styleSheet = "color: rgb(255, 0, 0);" # Style far from item + + if snappingMode & self.SNAPPING_ACTIVE_ONLY: + selectedItems = [] + + if snappingMode & self.SNAPPING_CURVE: + activeCurve = plot.getActiveCurve() + if activeCurve: + selectedItems.append(activeCurve) + + if snappingMode & self.SNAPPING_SCATTER: + activeScatter = plot._getActiveItem(kind='scatter') + if activeScatter: + selectedItems.append(activeScatter) + + else: + kinds = [] + if snappingMode & self.SNAPPING_CURVE: + kinds.append(items.Curve) + kinds.append(items.Histogram) + if snappingMode & self.SNAPPING_SCATTER: + kinds.append(items.Scatter) + selectedItems = [item for item in plot.getItems() + if isinstance(item, tuple(kinds)) and item.isVisible()] + + # Compute distance threshold + window = plot.window() + windowHandle = window.windowHandle() + if windowHandle is not None: + ratio = windowHandle.devicePixelRatio() + else: + ratio = qt.QGuiApplication.primaryScreen().devicePixelRatio() + + # Baseline squared distance threshold + distInPixels = (self.SNAP_THRESHOLD_DIST * ratio)**2 + + for item in selectedItems: + if (snappingMode & self.SNAPPING_SYMBOLS_ONLY and ( + not isinstance(item, items.SymbolMixIn) or + not item.getSymbol())): + # Only handled if item symbols are visible + continue + + if isinstance(item, items.Histogram): + result = item.pick(xPixel, yPixel) + if result is not None: # Histogram picked + index = result.getIndices()[0] + edges = item.getBinEdgesData(copy=False) + + # Snap to bin center and value + xData = 0.5 * (edges[index] + edges[index + 1]) + yData = item.getValueData(copy=False)[index] + + # Update label style sheet + styleSheet = "color: rgb(0, 0, 0);" + break + + else: # Curve, Scatter + xArray = item.getXData(copy=False) + yArray = item.getYData(copy=False) + closestIndex = numpy.argmin( + pow(xArray - x, 2) + pow(yArray - y, 2)) + + xClosest = xArray[closestIndex] + yClosest = yArray[closestIndex] + + if isinstance(item, items.YAxisMixIn): + axis = item.getYAxis() + else: + axis = 'left' + + closestInPixels = plot.dataToPixel( + xClosest, yClosest, axis=axis) + if closestInPixels is not None: + curveDistInPixels = ( + (closestInPixels[0] - xPixel)**2 + + (closestInPixels[1] - yPixel)**2) + + if curveDistInPixels <= distInPixels: + # Update label style sheet + styleSheet = "color: rgb(0, 0, 0);" + + # if close enough, snap to data point coord + xData, yData = xClosest, yClosest + distInPixels = curveDistInPixels + + for label, name, func in self._fields: + label.setStyleSheet(styleSheet) + + try: + value = func(xData, yData) + text = self.valueToString(value) + label.setText(text) + except: + label.setText('Error') + _logger.error( + "Error while converting coordinates (%f, %f)" + "with converter '%s'" % (xPixel, yPixel, name)) + _logger.error(traceback.format_exc()) + + def valueToString(self, value): + if isinstance(value, (tuple, list)): + value = [self.valueToString(v) for v in value] + return ", ".join(value) + elif isinstance(value, numbers.Real): + # Use this for floats and int + return '%.7g' % value + else: + # Fallback for other types + return str(value) + + # Snapping mode + + SNAPPING_DISABLED = 0 + """No snapping occurs""" + + SNAPPING_CROSSHAIR = 1 << 0 + """Snapping only enabled when crosshair cursor is enabled""" + + SNAPPING_ACTIVE_ONLY = 1 << 1 + """Snapping only enabled for active item""" + + SNAPPING_SYMBOLS_ONLY = 1 << 2 + """Snapping only when symbols are visible""" + + SNAPPING_CURVE = 1 << 3 + """Snapping on curves""" + + SNAPPING_SCATTER = 1 << 4 + """Snapping on scatter""" + + def setSnappingMode(self, mode): + """Set the snapping mode. + + The mode is a mask. + + :param int mode: The mode to use + """ + if mode != self._snappingMode: + self._snappingMode = mode + self.updateInfo() + + def getSnappingMode(self): + """Returns the snapping mode as a mask + + :rtype: int + """ + return self._snappingMode + + _SNAPPING_LEGACY = (SNAPPING_CROSSHAIR | + SNAPPING_ACTIVE_ONLY | + SNAPPING_SYMBOLS_ONLY | + SNAPPING_CURVE | + SNAPPING_SCATTER) + """Legacy snapping mode""" + + @property + @deprecated(replacement="getSnappingMode", since_version="0.8") + def autoSnapToActiveCurve(self): + return self.getSnappingMode() == self._SNAPPING_LEGACY + + @autoSnapToActiveCurve.setter + @deprecated(replacement="setSnappingMode", since_version="0.8") + def autoSnapToActiveCurve(self, flag): + self.setSnappingMode( + self._SNAPPING_LEGACY if flag else self.SNAPPING_DISABLED) diff --git a/src/silx/gui/plot/tools/RadarView.py b/src/silx/gui/plot/tools/RadarView.py new file mode 100644 index 0000000..7076835 --- /dev/null +++ b/src/silx/gui/plot/tools/RadarView.py @@ -0,0 +1,361 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""QWidget displaying an overview of a 2D plot. + +This shows the available range of the data, and the current location of the +plot view. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "22/02/2021" + +import logging +import weakref +from ... import qt +from ...utils import LockReentrant + +_logger = logging.getLogger(__name__) + + +class _DraggableRectItem(qt.QGraphicsRectItem): + """RectItem which signals its change through visibleRectDragged.""" + def __init__(self, *args, **kwargs): + super(_DraggableRectItem, self).__init__( + *args, **kwargs) + + self._previousCursor = None + self.setFlag(qt.QGraphicsItem.ItemIsMovable) + self.setFlag(qt.QGraphicsItem.ItemSendsGeometryChanges) + self.setAcceptHoverEvents(True) + self._ignoreChange = False + self._constraint = 0, 0, 0, 0 + + def setConstraintRect(self, left, top, width, height): + """Set the constraint rectangle for dragging. + + The coordinates are in the _DraggableRectItem coordinate system. + + This constraint only applies to modification through interaction + (i.e., this constraint is not applied to change through API). + + If the _DraggableRectItem is smaller than the constraint rectangle, + the _DraggableRectItem remains within the constraint rectangle. + If the _DraggableRectItem is wider than the constraint rectangle, + the constraint rectangle remains within the _DraggableRectItem. + """ + self._constraint = left, left + width, top, top + height + + def setPos(self, *args, **kwargs): + """Overridden to ignore changes from API in itemChange.""" + self._ignoreChange = True + super(_DraggableRectItem, self).setPos(*args, **kwargs) + self._ignoreChange = False + + def moveBy(self, *args, **kwargs): + """Overridden to ignore changes from API in itemChange.""" + self._ignoreChange = True + super(_DraggableRectItem, self).moveBy(*args, **kwargs) + self._ignoreChange = False + + def itemChange(self, change, value): + """Callback called before applying changes to the item.""" + if (change == qt.QGraphicsItem.ItemPositionChange and + not self._ignoreChange): + # Makes sure that the visible area is in the data + # or that data is in the visible area if area is too wide + x, y = value.x(), value.y() + xMin, xMax, yMin, yMax = self._constraint + + if self.rect().width() <= (xMax - xMin): + if x < xMin: + value.setX(xMin) + elif x > xMax - self.rect().width(): + value.setX(xMax - self.rect().width()) + else: + if x > xMin: + value.setX(xMin) + elif x < xMax - self.rect().width(): + value.setX(xMax - self.rect().width()) + + if self.rect().height() <= (yMax - yMin): + if y < yMin: + value.setY(yMin) + elif y > yMax - self.rect().height(): + value.setY(yMax - self.rect().height()) + else: + if y > yMin: + value.setY(yMin) + elif y < yMax - self.rect().height(): + value.setY(yMax - self.rect().height()) + + if self.pos() != value: + # Notify change through signal + views = self.scene().views() + assert len(views) == 1 + views[0].visibleRectDragged.emit( + value.x() + self.rect().left(), + value.y() + self.rect().top(), + self.rect().width(), + self.rect().height()) + + return value + + return super(_DraggableRectItem, self).itemChange( + change, value) + + def hoverEnterEvent(self, event): + """Called when the mouse enters the rectangle area""" + self._previousCursor = self.cursor() + self.setCursor(qt.Qt.OpenHandCursor) + + def hoverLeaveEvent(self, event): + """Called when the mouse leaves the rectangle area""" + if self._previousCursor is not None: + self.setCursor(self._previousCursor) + self._previousCursor = None + + +class RadarView(qt.QGraphicsView): + """Widget presenting a synthetic view of a 2D area and + the current visible area. + + Coordinates are as in QGraphicsView: + x goes from left to right and y goes from top to bottom. + This widget preserves the aspect ratio of the areas. + + The 2D area and the visible area can be set with :meth:`setDataRect` + and :meth:`setVisibleRect`. + When the visible area has been dragged by the user, its new position + is signaled by the *visibleRectDragged* signal. + + It is possible to invert the direction of the axes by using the + :meth:`scale` method of QGraphicsView. + """ + + visibleRectDragged = qt.Signal(float, float, float, float) + """Signals that the visible rectangle has been dragged. + + It provides: left, top, width, height in data coordinates. + """ + + _DATA_PEN = qt.QPen(qt.QColor('white')) + _DATA_BRUSH = qt.QBrush(qt.QColor('light gray')) + _ACTIVEDATA_PEN = qt.QPen(qt.QColor('black')) + _ACTIVEDATA_BRUSH = qt.QBrush(qt.QColor('transparent')) + _ACTIVEDATA_PEN.setWidth(2) + _ACTIVEDATA_PEN.setCosmetic(True) + _VISIBLE_PEN = qt.QPen(qt.QColor('blue')) + _VISIBLE_PEN.setWidth(2) + _VISIBLE_PEN.setCosmetic(True) + _VISIBLE_BRUSH = qt.QBrush(qt.QColor(0, 0, 0, 0)) + _TOOLTIP = 'Radar View:\nRed contour: Visible area\nGray area: The image' + + _PIXMAP_SIZE = 256 + + def __init__(self, parent=None): + self.__plotRef = None + self._scene = qt.QGraphicsScene() + self._dataRect = self._scene.addRect(0, 0, 1, 1, + self._DATA_PEN, + self._DATA_BRUSH) + self._imageRect = self._scene.addRect(0, 0, 1, 1, + self._ACTIVEDATA_PEN, + self._ACTIVEDATA_BRUSH) + self._imageRect.setVisible(False) + self._scatterRect = self._scene.addRect(0, 0, 1, 1, + self._ACTIVEDATA_PEN, + self._ACTIVEDATA_BRUSH) + self._scatterRect.setVisible(False) + self._curveRect = self._scene.addRect(0, 0, 1, 1, + self._ACTIVEDATA_PEN, + self._ACTIVEDATA_BRUSH) + self._curveRect.setVisible(False) + + self._visibleRect = _DraggableRectItem(0, 0, 1, 1) + self._visibleRect.setPen(self._VISIBLE_PEN) + self._visibleRect.setBrush(self._VISIBLE_BRUSH) + self._scene.addItem(self._visibleRect) + + super(RadarView, self).__init__(self._scene, parent) + self.setHorizontalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff) + self.setFocusPolicy(qt.Qt.NoFocus) + self.setStyleSheet('border: 0px') + self.setToolTip(self._TOOLTIP) + + self.__reentrant = LockReentrant() + self.visibleRectDragged.connect(self._viewRectDragged) + + self.__timer = qt.QTimer(self) + self.__timer.timeout.connect(self._updateDataContent) + + def sizeHint(self): + # """Overridden to avoid sizeHint to depend on content size.""" + return self.minimumSizeHint() + + def wheelEvent(self, event): + # """Overridden to disable vertical scrolling with wheel.""" + event.ignore() + + def resizeEvent(self, event): + # """Overridden to fit current content to new size.""" + self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio) + super(RadarView, self).resizeEvent(event) + + def setDataRect(self, left, top, width, height): + """Set the bounds of the data rectangular area. + + This sets the coordinate system. + """ + self._dataRect.setRect(left, top, width, height) + self._visibleRect.setConstraintRect(left, top, width, height) + self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio) + + def setVisibleRect(self, left, top, width, height): + """Set the visible rectangular area. + + The coordinates are relative to the data rect. + """ + self.__visibleRect = left, top, width, height + self._visibleRect.setRect(0, 0, width, height) + self._visibleRect.setPos(left, top) + self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio) + + def __setVisibleRectFromPlot(self, plot): + """Update radar view visible area. + + Takes care of y coordinate conversion. + """ + xMin, xMax = plot.getXAxis().getLimits() + yMin, yMax = plot.getYAxis().getLimits() + self.setVisibleRect(xMin, yMin, xMax - xMin, yMax - yMin) + + def getPlotWidget(self): + """Returns the connected plot + + :rtype: Union[None,PlotWidget] + """ + if self.__plotRef is None: + return None + plot = self.__plotRef() + if plot is None: + self.__plotRef = None + return plot + + def setPlotWidget(self, plot): + """Set the PlotWidget this radar view connects to. + + As result `setDataRect` and `setVisibleRect` will be called + automatically. + + :param Union[None,PlotWidget] plot: + """ + previousPlot = self.getPlotWidget() + if previousPlot is not None: # Disconnect previous plot + plot.getXAxis().sigLimitsChanged.disconnect(self._xLimitChanged) + plot.getYAxis().sigLimitsChanged.disconnect(self._yLimitChanged) + plot.getYAxis().sigInvertedChanged.disconnect(self._updateYAxisInverted) + + # Reset plot and timer + # FIXME: It would be good to clean up the display here + self.__plotRef = None + self.__timer.stop() + + if plot is not None: # Connect new plot + self.__plotRef = weakref.ref(plot) + plot.getXAxis().sigLimitsChanged.connect(self._xLimitChanged) + plot.getYAxis().sigLimitsChanged.connect(self._yLimitChanged) + plot.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted) + self.__setVisibleRectFromPlot(plot) + self._updateYAxisInverted() + self.__timer.start(500) + + def _xLimitChanged(self, vmin, vmax): + plot = self.getPlotWidget() + self.__setVisibleRectFromPlot(plot) + + def _yLimitChanged(self, vmin, vmax): + plot = self.getPlotWidget() + self.__setVisibleRectFromPlot(plot) + + def _updateYAxisInverted(self, inverted=None): + """Sync radar view axis orientation.""" + plot = self.getPlotWidget() + if inverted is None: + # Do not perform this when called from plot signal + inverted = plot.getYAxis().isInverted() + # Use scale to invert radarView + # RadarView default Y direction is from top to bottom + # As opposed to Plot. So invert RadarView when Plot is NOT inverted. + self.resetTransform() + if not inverted: + self.scale(1., -1.) + self.update() + + def _viewRectDragged(self, left, top, width, height): + """Slot for radar view visible rectangle changes.""" + plot = self.getPlotWidget() + if plot is None: + return + + if self.__reentrant.locked(): + return + + with self.__reentrant: + plot.setLimits(left, left + width, top, top + height) + + def _updateDataContent(self): + """Update the content to the current data content""" + plot = self.getPlotWidget() + if plot is None: + return + ranges = plot.getDataRange() + xmin, xmax = ranges.x if ranges.x is not None else (0, 0) + ymin, ymax = ranges.y if ranges.y is not None else (0, 0) + self.setDataRect(xmin, ymin, xmax - xmin, ymax - ymin) + + self.__updateItem(self._imageRect, plot.getActiveImage()) + self.__updateItem(self._scatterRect, plot.getActiveScatter()) + self.__updateItem(self._curveRect, plot.getActiveCurve()) + + def __updateItem(self, rect, item): + """Sync rect with item bounds + + :param QGraphicsRectItem rect: + :param Item item: + """ + if item is None: + rect.setVisible(False) + return + ranges = item._getBounds() + if ranges is None: + rect.setVisible(False) + return + xmin, xmax, ymin, ymax = ranges + width = xmax - xmin + height = ymax - ymin + rect.setRect(xmin, ymin, width, height) + rect.setVisible(True) diff --git a/src/silx/gui/plot/tools/__init__.py b/src/silx/gui/plot/tools/__init__.py new file mode 100644 index 0000000..09f468c --- /dev/null +++ b/src/silx/gui/plot/tools/__init__.py @@ -0,0 +1,50 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This package provides a set of widgets working with :class:`PlotWidget`. + +It provides some QToolBar and QWidget: + +- :class:`InteractiveModeToolBar` +- :class:`OutputToolBar` +- :class:`ImageToolBar` +- :class:`CurveToolBar` +- :class:`LimitsToolBar` +- :class:`PositionInfo` + +It also provides a :mod:`~silx.gui.plot.tools.roi` module to handle +interactive region of interest on a :class:`~silx.gui.plot.PlotWidget`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/03/2018" + + +from .toolbars import InteractiveModeToolBar # noqa +from .toolbars import OutputToolBar # noqa +from .toolbars import ImageToolBar, CurveToolBar, ScatterToolBar # noqa + +from .LimitsToolBar import LimitsToolBar # noqa +from .PositionInfo import PositionInfo # noqa diff --git a/src/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/src/silx/gui/plot/tools/profile/ScatterProfileToolBar.py new file mode 100644 index 0000000..44187ef --- /dev/null +++ b/src/silx/gui/plot/tools/profile/ScatterProfileToolBar.py @@ -0,0 +1,54 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module profile tools for scatter plots. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +from silx.utils import deprecation +from . import toolbar + + +class ScatterProfileToolBar(toolbar.ProfileToolBar): + """QToolBar providing scatter plot profiling tools + + :param parent: See :class:`QToolBar`. + :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate. + :param str title: See :class:`QToolBar`. + """ + + def __init__(self, parent=None, plot=None, title=None): + super(ScatterProfileToolBar, self).__init__(parent, plot) + if title is not None: + deprecation.deprecated_warning("Attribute", + name="title", + reason="removed", + since_version="0.13.0", + only_once=True, + skip_backtrace_count=1) + self.setScheme("scatter") diff --git a/src/silx/gui/plot/tools/profile/__init__.py b/src/silx/gui/plot/tools/profile/__init__.py new file mode 100644 index 0000000..d91191e --- /dev/null +++ b/src/silx/gui/plot/tools/profile/__init__.py @@ -0,0 +1,38 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides tools to get profiles on plot data. + +It provides: + +- :class:`ScatterProfileToolBar`: a QToolBar to handle profile on scatter data + +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "07/06/2018" + + +from .ScatterProfileToolBar import ScatterProfileToolBar # noqa diff --git a/src/silx/gui/plot/tools/profile/core.py b/src/silx/gui/plot/tools/profile/core.py new file mode 100644 index 0000000..200f5cf --- /dev/null +++ b/src/silx/gui/plot/tools/profile/core.py @@ -0,0 +1,525 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module define core objects for profile tools. +""" + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno", "V. Valls"] +__license__ = "MIT" +__date__ = "17/04/2020" + +import collections +import numpy +import weakref + +from silx.image.bilinear import BilinearImage +from silx.gui import qt + + +CurveProfileData = collections.namedtuple( + 'CurveProfileData', [ + "coords", + "profile", + "title", + "xLabel", + "yLabel", + ]) + +RgbaProfileData = collections.namedtuple( + 'RgbaProfileData', [ + "coords", + "profile", + "profile_r", + "profile_g", + "profile_b", + "profile_a", + "title", + "xLabel", + "yLabel", + ]) + +ImageProfileData = collections.namedtuple( + 'ImageProfileData', [ + 'coords', + 'profile', + 'title', + 'xLabel', + 'yLabel', + 'colormap', + ]) + + +class ProfileRoiMixIn: + """Base mix-in for ROI which can be used to select a profile. + + This mix-in have to be applied to a :class:`~silx.gui.plot.items.roi.RegionOfInterest` + in order to be usable by a :class:`~silx.gui.plot.tools.profile.manager.ProfileManager`. + """ + + ITEM_KIND = None + """Define the plot item which can be used with this profile ROI""" + + sigProfilePropertyChanged = qt.Signal() + """Emitted when a property of this profile have changed""" + + sigPlotItemChanged = qt.Signal() + """Emitted when the plot item linked to this profile have changed""" + + def __init__(self, parent=None): + self.__profileWindow = None + self.__profileManager = None + self.__plotItem = None + self.setName("Profile") + self.setEditable(True) + self.setSelectable(True) + + def invalidateProfile(self): + """Must be called by the implementation when the profile have to be + recomputed.""" + profileManager = self.getProfileManager() + if profileManager is not None: + profileManager.requestUpdateProfile(self) + + def invalidateProperties(self): + """Must be called when a property of the profile have changed.""" + self.sigProfilePropertyChanged.emit() + + def _setPlotItem(self, plotItem): + """Specify the plot item to use with this profile + + :param `~silx.gui.plot.items.item.Item` plotItem: A plot item + """ + previousPlotItem = self.getPlotItem() + if previousPlotItem is plotItem: + return + self.__plotItem = weakref.ref(plotItem) + self.sigPlotItemChanged.emit() + + def getPlotItem(self): + """Returns the plot item used by this profile + + :rtype: `~silx.gui.plot.items.item.Item` + """ + if self.__plotItem is None: + return None + plotItem = self.__plotItem() + if plotItem is None: + self.__plotItem = None + return plotItem + + def _setProfileManager(self, profileManager): + self.__profileManager = profileManager + + def getProfileManager(self): + """ + Returns the profile manager connected to this ROI. + + :rtype: ~silx.gui.plot.tools.profile.manager.ProfileManager + """ + return self.__profileManager + + def getProfileWindow(self): + """ + Returns the windows associated to this ROI, else None. + + :rtype: ProfileWindow + """ + return self.__profileWindow + + def setProfileWindow(self, profileWindow): + """ + Associate a window to this ROI. Can be None. + + :param ProfileWindow profileWindow: A main window + to display the profile. + """ + if profileWindow is self.__profileWindow: + return + if self.__profileWindow is not None: + self.__profileWindow.sigClose.disconnect(self.__profileWindowAboutToClose) + self.__profileWindow.setRoiProfile(None) + self.__profileWindow = profileWindow + if self.__profileWindow is not None: + self.__profileWindow.sigClose.connect(self.__profileWindowAboutToClose) + self.__profileWindow.setRoiProfile(self) + + def __profileWindowAboutToClose(self): + profileManager = self.getProfileManager() + roiManager = profileManager.getRoiManager() + try: + roiManager.removeRoi(self) + except ValueError: + pass + + def computeProfile(self, item): + """ + Compute the profile which will be displayed. + + This method is not called from the main Qt thread, but from a thread + pool. + + :param ~silx.gui.plot.items.Item item: A plot item + :rtype: Union[CurveProfileData,ImageProfileData] + """ + raise NotImplementedError() + + +def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method): + """Get a profile along one axis on a stack of images + + :param numpy.ndarray data: 3D volume (stack of 2D images) + The first dimension is the image index. + :param origin: Origin of image in plot (ox, oy) + :param scale: Scale of image in plot (sx, sy) + :param float position: Position of profile line in plot coords + on the axis orthogonal to the profile direction. + :param int roiWidth: Width of the profile in image pixels. + :param int axis: 0 for horizontal profile, 1 for vertical. + :param str method: method to compute the profile. Can be 'mean' or 'sum' or + 'none' + :return: profile image + effective ROI area corners in plot coords + """ + assert axis in (0, 1) + assert len(data.shape) == 3 + assert method in ('mean', 'sum', 'none') + + # Convert from plot to image coords + imgPos = int((position - origin[1 - axis]) / scale[1 - axis]) + + if axis == 1: # Vertical profile + # Transpose image to always do a horizontal profile + data = numpy.transpose(data, (0, 2, 1)) + + nimages, height, width = data.shape + + roiWidth = min(height, roiWidth) # Clip roi width to image size + + # Get [start, end[ coords of the roi in the data + start = int(int(imgPos) + 0.5 - roiWidth / 2.) + start = min(max(0, start), height - roiWidth) + end = start + roiWidth + + if method == 'none': + profile = None + else: + if start < height and end > 0: + if method == 'mean': + fct = numpy.mean + elif method == 'sum': + fct = numpy.sum + else: + raise ValueError('method not managed') + profile = fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32) + else: + profile = numpy.zeros((nimages, width), dtype=numpy.float32) + + # Compute effective ROI in plot coords + profileBounds = numpy.array( + (0, width, width, 0), + dtype=numpy.float32) * scale[axis] + origin[axis] + roiBounds = numpy.array( + (start, start, end, end), + dtype=numpy.float32) * scale[1 - axis] + origin[1 - axis] + + if axis == 0: # Horizontal profile + area = profileBounds, roiBounds + else: # vertical profile + area = roiBounds, profileBounds + + return profile, area + + +def _alignedPartialProfile(data, rowRange, colRange, axis, method): + """Mean of a rectangular region (ROI) of a stack of images + along a given axis. + + Returned values and all parameters are in image coordinates. + + :param numpy.ndarray data: 3D volume (stack of 2D images) + The first dimension is the image index. + :param rowRange: [min, max[ of ROI rows (upper bound excluded). + :type rowRange: 2-tuple of int (min, max) with min < max + :param colRange: [min, max[ of ROI columns (upper bound excluded). + :type colRange: 2-tuple of int (min, max) with min < max + :param int axis: The axis along which to take the profile of the ROI. + 0: Sum rows along columns. + 1: Sum columns along rows. + :param str method: method to compute the profile. Can be 'mean' or 'sum' + :return: Profile image along the ROI as the mean of the intersection + of the ROI and the image. + """ + assert axis in (0, 1) + assert len(data.shape) == 3 + assert rowRange[0] < rowRange[1] + assert colRange[0] < colRange[1] + assert method in ('mean', 'sum') + + nimages, height, width = data.shape + + # Range aligned with the integration direction + profileRange = colRange if axis == 0 else rowRange + + profileLength = abs(profileRange[1] - profileRange[0]) + + # Subset of the image to use as intersection of ROI and image + rowStart = min(max(0, rowRange[0]), height) + rowEnd = min(max(0, rowRange[1]), height) + colStart = min(max(0, colRange[0]), width) + colEnd = min(max(0, colRange[1]), width) + + if method == 'mean': + _fct = numpy.mean + elif method == 'sum': + _fct = numpy.sum + else: + raise ValueError('method not managed') + + imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1, + dtype=numpy.float32) + + # Profile including out of bound area + profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32) + + # Place imgProfile in full profile + offset = - min(0, profileRange[0]) + profile[:, offset:offset + imgProfile.shape[1]] = imgProfile + + return profile + + +def createProfile(roiInfo, currentData, origin, scale, lineWidth, method): + """Create the profile line for the the given image. + + :param roiInfo: information about the ROI: start point, end point and + type ("X", "Y", "D") + :param numpy.ndarray currentData: the 2D image or the 3D stack of images + on which we compute the profile. + :param origin: (ox, oy) the offset from origin + :type origin: 2-tuple of float + :param scale: (sx, sy) the scale to use + :type scale: 2-tuple of float + :param int lineWidth: width of the profile line + :param str method: method to compute the profile. Can be 'mean' or 'sum' + or 'none': to compute everything except the profile + :return: `coords, profile, area, profileName, xLabel`, where: + - coords is the X coordinate to use to display the profile + - profile is a 2D array of the profiles of the stack of images. + For a single image, the profile is a curve, so this parameter + has a shape *(1, len(curve))* + - area is a tuple of two 1D arrays with 4 values each. They represent + the effective ROI area corners in plot coords. + - profileName is a string describing the ROI, meant to be used as + title of the profile plot + - xLabel the label for X in the profile window + + :rtype: tuple(ndarray,ndarray,(ndarray,ndarray),str) + """ + if currentData is None or roiInfo is None or lineWidth is None: + raise ValueError("createProfile called with invalide arguments") + + # force 3D data (stack of images) + if len(currentData.shape) == 2: + currentData3D = currentData.reshape((1,) + currentData.shape) + elif len(currentData.shape) == 3: + currentData3D = currentData + + roiWidth = max(1, lineWidth) + roiStart, roiEnd, lineProjectionMode = roiInfo + + if lineProjectionMode == 'X': # Horizontal profile on the whole image + profile, area = _alignedFullProfile(currentData3D, + origin, scale, + roiStart[1], roiWidth, + axis=0, + method=method) + + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[0] + origin[0] + + yMin, yMax = min(area[1]), max(area[1]) - 1 + if roiWidth <= 1: + profileName = '{ylabel} = %g' % yMin + else: + profileName = '{ylabel} = [%g, %g]' % (yMin, yMax) + xLabel = '{xlabel}' + + elif lineProjectionMode == 'Y': # Vertical profile on the whole image + profile, area = _alignedFullProfile(currentData3D, + origin, scale, + roiStart[0], roiWidth, + axis=1, + method=method) + + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[1] + origin[1] + + xMin, xMax = min(area[0]), max(area[0]) - 1 + if roiWidth <= 1: + profileName = '{xlabel} = %g' % xMin + else: + profileName = '{xlabel} = [%g, %g]' % (xMin, xMax) + xLabel = '{ylabel}' + + else: # Free line profile + + # Convert start and end points in image coords as (row, col) + startPt = ((roiStart[1] - origin[1]) / scale[1], + (roiStart[0] - origin[0]) / scale[0]) + endPt = ((roiEnd[1] - origin[1]) / scale[1], + (roiEnd[0] - origin[0]) / scale[0]) + + if (int(startPt[0]) == int(endPt[0]) or + int(startPt[1]) == int(endPt[1])): + # Profile is aligned with one of the axes + + # Convert to int + startPt = int(startPt[0]), int(startPt[1]) + endPt = int(endPt[0]), int(endPt[1]) + + # Ensure startPt <= endPt + if startPt[0] > endPt[0] or startPt[1] > endPt[1]: + startPt, endPt = endPt, startPt + + if startPt[0] == endPt[0]: # Row aligned + rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth), + int(startPt[0] + 0.5 + 0.5 * roiWidth)) + colRange = startPt[1], endPt[1] + 1 + if method == 'none': + profile = None + else: + profile = _alignedPartialProfile(currentData3D, + rowRange, colRange, + axis=0, + method=method) + + else: # Column aligned + rowRange = startPt[0], endPt[0] + 1 + colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth), + int(startPt[1] + 0.5 + 0.5 * roiWidth)) + if method == 'none': + profile = None + else: + profile = _alignedPartialProfile(currentData3D, + rowRange, colRange, + axis=1, + method=method) + # Convert ranges to plot coords to draw ROI area + area = ( + numpy.array( + (colRange[0], colRange[1], colRange[1], colRange[0]), + dtype=numpy.float32) * scale[0] + origin[0], + numpy.array( + (rowRange[0], rowRange[0], rowRange[1], rowRange[1]), + dtype=numpy.float32) * scale[1] + origin[1]) + + else: # General case: use bilinear interpolation + + # Ensure startPt <= endPt + if (startPt[1] > endPt[1] or ( + startPt[1] == endPt[1] and startPt[0] > endPt[0])): + startPt, endPt = endPt, startPt + + if method == 'none': + profile = None + else: + profile = [] + for slice_idx in range(currentData3D.shape[0]): + bilinear = BilinearImage(currentData3D[slice_idx, :, :]) + + profile.append(bilinear.profile_line( + (startPt[0] - 0.5, startPt[1] - 0.5), + (endPt[0] - 0.5, endPt[1] - 0.5), + roiWidth, + method=method)) + profile = numpy.array(profile) + + # Extend ROI with half a pixel on each end, and + # Convert back to plot coords (x, y) + length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 + + (endPt[1] - startPt[1]) ** 2) + dRow = (endPt[0] - startPt[0]) / length + dCol = (endPt[1] - startPt[1]) / length + + # Extend ROI with half a pixel on each end + roiStartPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol + roiEndPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol + + # Rotate deltas by 90 degrees to apply line width + dRow, dCol = dCol, -dRow + + area = ( + numpy.array((roiStartPt[1] - 0.5 * roiWidth * dCol, + roiStartPt[1] + 0.5 * roiWidth * dCol, + roiEndPt[1] + 0.5 * roiWidth * dCol, + roiEndPt[1] - 0.5 * roiWidth * dCol), + dtype=numpy.float32) * scale[0] + origin[0], + numpy.array((roiStartPt[0] - 0.5 * roiWidth * dRow, + roiStartPt[0] + 0.5 * roiWidth * dRow, + roiEndPt[0] + 0.5 * roiWidth * dRow, + roiEndPt[0] - 0.5 * roiWidth * dRow), + dtype=numpy.float32) * scale[1] + origin[1]) + + # Convert start and end points back to plot coords + y0 = startPt[0] * scale[1] + origin[1] + x0 = startPt[1] * scale[0] + origin[0] + y1 = endPt[0] * scale[1] + origin[1] + x1 = endPt[1] * scale[0] + origin[0] + + if startPt[1] == endPt[1]: + profileName = '{xlabel} = %g; {ylabel} = [%g, %g]' % (x0, y0, y1) + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[1] + y0 + xLabel = '{ylabel}' + + elif startPt[0] == endPt[0]: + profileName = '{ylabel} = %g; {xlabel} = [%g, %g]' % (y0, x0, x1) + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[0] + x0 + xLabel = '{xlabel}' + + else: + m = (y1 - y0) / (x1 - x0) + b = y0 - m * x0 + profileName = '{ylabel} = %g * {xlabel} %+g' % (m, b) + if method == 'none': + coords = None + else: + coords = numpy.linspace(x0, x1, len(profile[0]), + endpoint=True, + dtype=numpy.float32) + xLabel = '{xlabel}' + + return coords, profile, area, profileName, xLabel diff --git a/src/silx/gui/plot/tools/profile/editors.py b/src/silx/gui/plot/tools/profile/editors.py new file mode 100644 index 0000000..80e0452 --- /dev/null +++ b/src/silx/gui/plot/tools/profile/editors.py @@ -0,0 +1,307 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides editors which are used to custom profile ROI properties. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/06/2018" + +import logging + +from silx.gui import qt + +from silx.gui.utils import blockSignals +from silx.gui.plot.PlotToolButtons import ProfileOptionToolButton +from silx.gui.plot.PlotToolButtons import ProfileToolButton +from . import rois +from . import core + + +_logger = logging.getLogger(__name__) + + +class _NoProfileRoiEditor(qt.QWidget): + + sigDataCommited = qt.Signal() + + def setEditorData(self, roi): + pass + + def setRoiData(self, roi): + pass + + +class _DefaultImageProfileRoiEditor(qt.QWidget): + + sigDataCommited = qt.Signal() + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent=parent) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self._initLayout(layout) + + def _initLayout(self, layout): + self._lineWidth = qt.QSpinBox(self) + self._lineWidth.setRange(1, 1000) + self._lineWidth.setValue(1) + self._lineWidth.valueChanged[int].connect(self._widgetChanged) + + self._methodsButton = ProfileOptionToolButton(parent=self, plot=None) + self._methodsButton.sigMethodChanged.connect(self._widgetChanged) + + label = qt.QLabel('W:') + label.setToolTip("Line width in pixels") + layout.addWidget(label) + layout.addWidget(self._lineWidth) + layout.addWidget(self._methodsButton) + + def _widgetChanged(self, value=None): + self.commitData() + + def commitData(self): + self.sigDataCommited.emit() + + def setEditorData(self, roi): + with blockSignals(self._lineWidth): + self._lineWidth.setValue(roi.getProfileLineWidth()) + with blockSignals(self._methodsButton): + method = roi.getProfileMethod() + self._methodsButton.setMethod(method) + + def setRoiData(self, roi): + lineWidth = self._lineWidth.value() + roi.setProfileLineWidth(lineWidth) + method = self._methodsButton.getMethod() + roi.setProfileMethod(method) + + +class _DefaultImageStackProfileRoiEditor(_DefaultImageProfileRoiEditor): + + def _initLayout(self, layout): + super(_DefaultImageStackProfileRoiEditor, self)._initLayout(layout) + self._profileDim = ProfileToolButton(parent=self, plot=None) + self._profileDim.sigDimensionChanged.connect(self._widgetChanged) + layout.addWidget(self._profileDim) + + def setEditorData(self, roi): + super(_DefaultImageStackProfileRoiEditor, self).setEditorData(roi) + with blockSignals(self._profileDim): + kind = roi.getProfileType() + dim = {"1D": 1, "2D": 2}[kind] + self._profileDim.setDimension(dim) + + def setRoiData(self, roi): + super(_DefaultImageStackProfileRoiEditor, self).setRoiData(roi) + dim = self._profileDim.getDimension() + kind = {1: "1D", 2: "2D"}[dim] + roi.setProfileType(kind) + + +class _DefaultScatterProfileRoiEditor(qt.QWidget): + + sigDataCommited = qt.Signal() + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent=parent) + + self._nPoints = qt.QSpinBox(self) + self._nPoints.setRange(1, 9999) + self._nPoints.setValue(1024) + self._nPoints.valueChanged[int].connect(self.__widgetChanged) + + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + label = qt.QLabel('Samples:') + label.setToolTip("Number of sample points of the profile") + layout.addWidget(label) + layout.addWidget(self._nPoints) + + def __widgetChanged(self, value=None): + self.commitData() + + def commitData(self): + self.sigDataCommited.emit() + + def setEditorData(self, roi): + with blockSignals(self._nPoints): + self._nPoints.setValue(roi.getNPoints()) + + def setRoiData(self, roi): + nPoints = self._nPoints.value() + roi.setNPoints(nPoints) + + +class ProfileRoiEditorAction(qt.QWidgetAction): + """ + Action displaying GUI to edit the selected ROI. + + :param qt.QWidget parent: Parent widget + """ + def __init__(self, parent=None): + super(ProfileRoiEditorAction, self).__init__(parent) + self.__roiManager = None + self.__roi = None + self.__inhibiteReentance = None + + def createWidget(self, parent): + """Inherit the method to create a new editor""" + widget = qt.QWidget(parent) + layout = qt.QHBoxLayout(widget) + if isinstance(parent, qt.QMenu): + margins = layout.contentsMargins() + layout.setContentsMargins(margins.left(), 0, margins.right(), 0) + else: + layout.setContentsMargins(0, 0, 0, 0) + + editorClass = self.getEditorClass(self.__roi) + editor = editorClass(parent) + editor.setEditorData(self.__roi) + self.__setEditor(widget, editor) + return widget + + def deleteWidget(self, widget): + """Inherit the method to delete an editor""" + self.__setEditor(widget, None) + return qt.QWidgetAction.deleteWidget(self, widget) + + def _getEditor(self, widget): + """Returns the editor contained in the widget holder""" + layout = widget.layout() + if layout.count() == 0: + return None + return layout.itemAt(0).widget() + + def setRoiManager(self, roiManager): + """ + Connect this action to a ROI manager. + + :param RegionOfInterestManager roiManager: A ROI manager + """ + if self.__roiManager is roiManager: + return + if self.__roiManager is not None: + self.__roiManager.sigCurrentRoiChanged.disconnect(self.__currentRoiChanged) + self.__roiManager = roiManager + if self.__roiManager is not None: + self.__roiManager.sigCurrentRoiChanged.connect(self.__currentRoiChanged) + self.__currentRoiChanged(roiManager.getCurrentRoi()) + + def __currentRoiChanged(self, roi): + """Handle changes of the selected ROI""" + if roi is not None and not isinstance(roi, core.ProfileRoiMixIn): + return + self.setProfileRoi(roi) + + def setProfileRoi(self, roi): + """Set a profile ROI to edit. + + :param ProfileRoiMixIn roi: A profile ROI + """ + if self.__roi is roi: + return + if self.__roi is not None: + self.__roi.sigProfilePropertyChanged.disconnect(self.__roiPropertyChanged) + self.__roi = roi + if self.__roi is not None: + self.__roi.sigProfilePropertyChanged.connect(self.__roiPropertyChanged) + self._updateWidgets() + + def getRoiProfile(self): + """Returns the edited profile ROI. + + :rtype: ProfileRoiMixIn + """ + return self.__roi + + def __roiPropertyChanged(self): + """Handle changes on the property defining the ROI. + """ + self._updateWidgetValues() + + def __setEditor(self, widget, editor): + """Set the editor to display. + + :param qt.QWidget editor: The editor to display + """ + previousEditor = self._getEditor(widget) + if previousEditor is editor: + return + layout = widget.layout() + if previousEditor is not None: + previousEditor.sigDataCommited.disconnect(self._editorDataCommited) + layout.removeWidget(previousEditor) + previousEditor.deleteLater() + if editor is not None: + editor.sigDataCommited.connect(self._editorDataCommited) + layout.addWidget(editor) + + def getEditorClass(self, roi): + """Returns the editor class to use according to the ROI.""" + if roi is None: + editorClass = _NoProfileRoiEditor + elif isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn, + rois.ProfileImageStackCrossROI)): + # Must be done before the default image ROI + # Cause ImageStack ROIs inherit from Image ROIs + editorClass = _DefaultImageStackProfileRoiEditor + elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn, + rois.ProfileImageCrossROI)): + editorClass = _DefaultImageProfileRoiEditor + elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn, + rois.ProfileScatterCrossROI)): + editorClass = _DefaultScatterProfileRoiEditor + else: + # Unsupported + editorClass = _NoProfileRoiEditor + return editorClass + + def _updateWidgets(self): + """Update the kind of editor to display, according to the selected + profile ROI.""" + parent = self.parent() + editorClass = self.getEditorClass(self.__roi) + for widget in self.createdWidgets(): + editor = editorClass(parent) + editor.setEditorData(self.__roi) + self.__setEditor(widget, editor) + + def _updateWidgetValues(self): + """Update the content of the displayed editor, according to the + selected profile ROI.""" + for widget in self.createdWidgets(): + editor = self._getEditor(widget) + if self.__inhibiteReentance is editor: + continue + editor.setEditorData(self.__roi) + + def _editorDataCommited(self): + """Handle changes from the editor.""" + editor = self.sender() + if self.__roi is not None: + self.__inhibiteReentance = editor + editor.setRoiData(self.__roi) + self.__inhibiteReentance = None diff --git a/src/silx/gui/plot/tools/profile/manager.py b/src/silx/gui/plot/tools/profile/manager.py new file mode 100644 index 0000000..4a22bc0 --- /dev/null +++ b/src/silx/gui/plot/tools/profile/manager.py @@ -0,0 +1,1079 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a manager to compute and display profiles. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/06/2018" + +import logging +import weakref + +from silx.gui import qt +from silx.gui import colors +from silx.gui import utils + +from silx.utils.weakref import WeakMethodProxy +from silx.gui import icons +from silx.gui.plot import PlotWidget +from silx.gui.plot.tools.roi import RegionOfInterestManager +from silx.gui.plot.tools.roi import CreateRoiModeAction +from silx.gui.plot import items +from silx.gui.qt import silxGlobalThreadPool +from silx.gui.qt import inspect +from . import rois +from . import core +from . import editors + + +_logger = logging.getLogger(__name__) + + +class _RunnableComputeProfile(qt.QRunnable): + """Runner to process profiles + + :param qt.QThreadPool threadPool: The thread which will be used to + execute this runner. It is used to update the used signals + :param ~silx.gui.plot.items.Item item: Item in which the profile is + computed + :param ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn roi: ROI + defining the profile shape and other characteristics + """ + + class _Signals(qt.QObject): + """Signal holder""" + resultReady = qt.Signal(object, object) + runnerFinished = qt.Signal(object) + + def __init__(self, threadPool, item, roi): + """Constructor + """ + super(_RunnableComputeProfile, self).__init__() + self._signals = self._Signals() + self._signals.moveToThread(threadPool.thread()) + self._item = item + self._roi = roi + self._cancelled = False + + def _lazyCancel(self): + """Cancel the runner if it is not yet started. + + The threadpool will still execute the runner, but this will process + nothing. + + This is only used with Qt<5.9 where QThreadPool.tryTake is not available. + """ + self._cancelled = True + + def autoDelete(self): + return False + + def getRoi(self): + """Returns the ROI in which the runner will compute a profile. + + :rtype: ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn + """ + return self._roi + + @property + def resultReady(self): + """Signal emitted when the result of the computation is available. + + This signal provides 2 values: The ROI, and the computation result. + """ + return self._signals.resultReady + + @property + def runnerFinished(self): + """Signal emitted when runner have finished. + + This signal provides a single value: the runner itself. + """ + return self._signals.runnerFinished + + def run(self): + """Process the profile computation. + """ + if not self._cancelled: + try: + profileData = self._roi.computeProfile(self._item) + except Exception: + _logger.error("Error while computing profile", exc_info=True) + else: + self.resultReady.emit(self._roi, profileData) + self.runnerFinished.emit(self) + + +class ProfileWindow(qt.QMainWindow): + """ + Display a computed profile. + + The content can be described using :meth:`setRoiProfile` if the source of + the profile is a profile ROI, and :meth:`setProfile` for the data content. + """ + + sigClose = qt.Signal() + """Emitted by :meth:`closeEvent` (e.g. when the window is closed + through the window manager's close icon).""" + + def __init__(self, parent=None, backend=None): + qt.QMainWindow.__init__(self, parent=parent, flags=qt.Qt.Dialog) + + self.setWindowTitle('Profile window') + self._plot1D = None + self._plot2D = None + self._backend = backend + self._data = None + + widget = qt.QWidget() + self._layout = qt.QStackedLayout(widget) + self._layout.setContentsMargins(0, 0, 0, 0) + self.setCentralWidget(widget) + + def prepareWidget(self, roi): + """Called before the show to prepare the window to use with + a specific ROI.""" + if isinstance(roi, rois._DefaultImageStackProfileRoiMixIn): + profileType = roi.getProfileType() + else: + profileType = "1D" + if profileType == "1D": + self.getPlot1D() + elif profileType == "2D": + self.getPlot2D() + + def createPlot1D(self, parent, backend): + """Inherit this function to create your own plot to render 1D + profiles. The default value is a `Plot1D`. + + :param parent: The parent of this widget or None. + :param backend: The backend to use for the plot. + See :class:`PlotWidget` for the list of supported backend. + :rtype: PlotWidget + """ + # import here to avoid circular import + from ...PlotWindow import Plot1D + plot = Plot1D(parent=parent, backend=backend) + plot.setDataMargins(yMinMargin=0.1, yMaxMargin=0.1) + plot.setGraphYLabel('Profile') + plot.setGraphXLabel('') + return plot + + def createPlot2D(self, parent, backend): + """Inherit this function to create your own plot to render 2D + profiles. The default value is a `Plot2D`. + + :param parent: The parent of this widget or None. + :param backend: The backend to use for the plot. + See :class:`PlotWidget` for the list of supported backend. + :rtype: PlotWidget + """ + # import here to avoid circular import + from ...PlotWindow import Plot2D + return Plot2D(parent=parent, backend=backend) + + def getPlot1D(self, init=True): + """Return the current plot used to display curves and create it if it + does not yet exists and `init` is True. Else returns None.""" + if not init: + return self._plot1D + if self._plot1D is None: + self._plot1D = self.createPlot1D(self, self._backend) + self._layout.addWidget(self._plot1D) + return self._plot1D + + def _showPlot1D(self): + plot = self.getPlot1D() + self._layout.setCurrentWidget(plot) + + def getPlot2D(self, init=True): + """Return the current plot used to display image and create it if it + does not yet exists and `init` is True. Else returns None.""" + if not init: + return self._plot2D + if self._plot2D is None: + self._plot2D = self.createPlot2D(parent=self, backend=self._backend) + self._layout.addWidget(self._plot2D) + return self._plot2D + + def _showPlot2D(self): + plot = self.getPlot2D() + self._layout.setCurrentWidget(plot) + + def getCurrentPlotWidget(self): + return self._layout.currentWidget() + + def closeEvent(self, qCloseEvent): + self.sigClose.emit() + qCloseEvent.accept() + + def setRoiProfile(self, roi): + """Set the profile ROI which it the source of the following data + to display. + + :param ProfileRoiMixIn roi: The profile ROI data source + """ + if roi is None: + return + self.__color = colors.rgba(roi.getColor()) + + def _setImageProfile(self, data): + """ + Setup the window to display a new profile data which is represented + by an image. + + :param core.ImageProfileData data: Computed data profile + """ + plot = self.getPlot2D() + + plot.clear() + plot.setGraphTitle(data.title) + plot.getXAxis().setLabel(data.xLabel) + + + coords = data.coords + colormap = data.colormap + profileScale = (coords[-1] - coords[0]) / data.profile.shape[1], 1 + plot.addImage(data.profile, + legend="profile", + colormap=colormap, + origin=(coords[0], 0), + scale=profileScale) + plot.getYAxis().setLabel("Frame index (depth)") + + self._showPlot2D() + + def _setCurveProfile(self, data): + """ + Setup the window to display a new profile data which is represented + by a curve. + + :param core.CurveProfileData data: Computed data profile + """ + plot = self.getPlot1D() + + plot.clear() + plot.setGraphTitle(data.title) + plot.getXAxis().setLabel(data.xLabel) + plot.getYAxis().setLabel(data.yLabel) + + plot.addCurve(data.coords, + data.profile, + legend="level", + color=self.__color) + + self._showPlot1D() + + def _setRgbaProfile(self, data): + """ + Setup the window to display a new profile data which is represented + by a curve. + + :param core.RgbaProfileData data: Computed data profile + """ + plot = self.getPlot1D() + + plot.clear() + plot.setGraphTitle(data.title) + plot.getXAxis().setLabel(data.xLabel) + plot.getYAxis().setLabel(data.yLabel) + + self._showPlot1D() + + plot.addCurve(data.coords, data.profile, + legend="level", color="black") + plot.addCurve(data.coords, data.profile_r, + legend="red", color="red") + plot.addCurve(data.coords, data.profile_g, + legend="green", color="green") + plot.addCurve(data.coords, data.profile_b, + legend="blue", color="blue") + if data.profile_a is not None: + plot.addCurve(data.coords, data.profile_a, legend="alpha", color="gray") + + def clear(self): + """Clear the window profile""" + plot = self.getPlot1D(init=False) + if plot is not None: + plot.clear() + plot = self.getPlot2D(init=False) + if plot is not None: + plot.clear() + + def getProfile(self): + """Returns the profile data which is displayed""" + return self.__data + + def setProfile(self, data): + """ + Setup the window to display a new profile data. + + This method dispatch the result to a specific method according to the + data type. + + :param data: Computed data profile + """ + self.__data = data + if data is None: + self.clear() + elif isinstance(data, core.ImageProfileData): + self._setImageProfile(data) + elif isinstance(data, core.RgbaProfileData): + self._setRgbaProfile(data) + elif isinstance(data, core.CurveProfileData): + self._setCurveProfile(data) + else: + raise TypeError("Unsupported type %s" % type(data)) + + +class _ClearAction(qt.QAction): + """Action to clear the profile manager + + The action is only enabled if something can be cleaned up. + """ + + def __init__(self, parent, profileManager): + super(_ClearAction, self).__init__(parent) + self.__profileManager = weakref.ref(profileManager) + icon = icons.getQIcon('profile-clear') + self.setIcon(icon) + self.setText('Clear profile') + self.setToolTip('Clear the profiles') + self.setCheckable(False) + self.setEnabled(False) + self.triggered.connect(profileManager.clearProfile) + plot = profileManager.getPlotWidget() + roiManager = profileManager.getRoiManager() + plot.sigInteractiveModeChanged.connect(self.__modeUpdated) + roiManager.sigRoiChanged.connect(self.__roiListUpdated) + + def getProfileManager(self): + return self.__profileManager() + + def __roiListUpdated(self): + self.__update() + + def __modeUpdated(self, source): + self.__update() + + def __update(self): + profileManager = self.getProfileManager() + if profileManager is None: + return + roiManager = profileManager.getRoiManager() + if roiManager is None: + return + enabled = roiManager.isStarted() or len(roiManager.getRois()) > 0 + self.setEnabled(enabled) + + +class _StoreLastParamBehavior(qt.QObject): + """This object allow to store and restore the properties of the ROI + profiles""" + + def __init__(self, parent): + assert isinstance(parent, ProfileManager) + super(_StoreLastParamBehavior, self).__init__(parent=parent) + self.__properties = {} + self.__profileRoi = None + self.__filter = utils.LockReentrant() + + def _roi(self): + """Return the spied ROI""" + if self.__profileRoi is None: + return None + roi = self.__profileRoi() + if roi is None: + self.__profileRoi = None + return roi + + def setProfileRoi(self, roi): + """Set a profile ROI to spy. + + :param ProfileRoiMixIn roi: A profile ROI + """ + previousRoi = self._roi() + if previousRoi is roi: + return + if previousRoi is not None: + previousRoi.sigProfilePropertyChanged.disconnect(self._profilePropertyChanged) + self.__profileRoi = None if roi is None else weakref.ref(roi) + if roi is not None: + roi.sigProfilePropertyChanged.connect(self._profilePropertyChanged) + + def _profilePropertyChanged(self): + """Handle changes on the properties defining the profile ROI. + """ + if self.__filter.locked(): + return + roi = self.sender() + self.storeProperties(roi) + + def storeProperties(self, roi): + if isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn, + rois.ProfileImageStackCrossROI)): + self.__properties["method"] = roi.getProfileMethod() + self.__properties["line-width"] = roi.getProfileLineWidth() + self.__properties["type"] = roi.getProfileType() + elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn, + rois.ProfileImageCrossROI)): + self.__properties["method"] = roi.getProfileMethod() + self.__properties["line-width"] = roi.getProfileLineWidth() + elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn, + rois.ProfileScatterCrossROI)): + self.__properties["npoints"] = roi.getNPoints() + + def restoreProperties(self, roi): + with self.__filter: + if isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn, + rois.ProfileImageStackCrossROI)): + value = self.__properties.get("method", None) + if value is not None: + roi.setProfileMethod(value) + value = self.__properties.get("line-width", None) + if value is not None: + roi.setProfileLineWidth(value) + value = self.__properties.get("type", None) + if value is not None: + roi.setProfileType(value) + elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn, + rois.ProfileImageCrossROI)): + value = self.__properties.get("method", None) + if value is not None: + roi.setProfileMethod(value) + value = self.__properties.get("line-width", None) + if value is not None: + roi.setProfileLineWidth(value) + elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn, + rois.ProfileScatterCrossROI)): + value = self.__properties.get("npoints", None) + if value is not None: + roi.setNPoints(value) + + +class ProfileManager(qt.QObject): + """Base class for profile management tools + + :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate. + :param plot: :class:`~silx.gui.plot.tools.roi.RegionOfInterestManager` + on which to operate. + """ + def __init__(self, parent=None, plot=None, roiManager=None): + super(ProfileManager, self).__init__(parent) + + assert isinstance(plot, PlotWidget) + self._plotRef = weakref.ref( + plot, WeakMethodProxy(self.__plotDestroyed)) + + # Set-up interaction manager + if roiManager is None: + roiManager = RegionOfInterestManager(plot) + + self._roiManagerRef = weakref.ref(roiManager) + self._rois = [] + self._pendingRunners = [] + """List of ROIs which have to be updated""" + + self.__reentrantResults = {} + """Store reentrant result to avoid to skip some of them + cause the implementation uses a QEventLoop.""" + + self._profileWindowClass = ProfileWindow + """Class used to display the profile results""" + + self._computedProfiles = 0 + """Statistics for tests""" + + self.__itemTypes = [] + """Kind of items to use""" + + self.__tracking = False + """Is the plot active items are tracked""" + + self.__useColorFromCursor = True + """If true, force the ROI color with the colormap marker color""" + + self._item = None + """The selected item""" + + self.__singleProfileAtATime = True + """When it's true, only a single profile is displayed at a time.""" + + self._previousWindowGeometry = [] + + self._storeProperties = _StoreLastParamBehavior(self) + """If defined the profile properties of the last ROI are reused to the + new created ones""" + + # Listen to plot limits changed + plot.getXAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile) + plot.getYAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile) + + roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished) + roiManager.sigInteractiveRoiCreated.connect(self.__roiCreated) + roiManager.sigRoiAdded.connect(self.__roiAdded) + roiManager.sigRoiAboutToBeRemoved.connect(self.__roiRemoved) + + def setSingleProfile(self, enable): + """ + Enable or disable the single profile mode. + + In single mode, the manager enforce a single ROI at the same + time. A new one will remove the previous one. + + If this mode is not enabled, many ROIs can be created, and many + profile windows will be displayed. + """ + self.__singleProfileAtATime = enable + + def isSingleProfile(self): + """ + Returns true if the manager is in a single profile mode. + + :rtype: bool + """ + return self.__singleProfileAtATime + + def __interactionFinished(self): + """Handle end of interactive mode""" + pass + + def __roiAdded(self, roi): + """Handle new ROI""" + # Filter out non profile ROIs + if not isinstance(roi, core.ProfileRoiMixIn): + return + self.__addProfile(roi) + + def __roiRemoved(self, roi): + """Handle removed ROI""" + # Filter out non profile ROIs + if not isinstance(roi, core.ProfileRoiMixIn): + return + self.__removeProfile(roi) + + def createProfileAction(self, profileRoiClass, parent=None): + """Create an action from a class of ProfileRoi + + :param core.ProfileRoiMixIn profileRoiClass: A class of a profile ROI + :param qt.QObject parent: The parent of the created action. + :rtype: qt.QAction + """ + if not issubclass(profileRoiClass, core.ProfileRoiMixIn): + raise TypeError("Type %s not expected" % type(profileRoiClass)) + roiManager = self.getRoiManager() + action = CreateRoiModeAction(parent, roiManager, profileRoiClass) + if hasattr(profileRoiClass, "ICON"): + action.setIcon(icons.getQIcon(profileRoiClass.ICON)) + if hasattr(profileRoiClass, "NAME"): + def articulify(word): + """Add an an/a article in the front of the word""" + first = word[1] if word[0] == 'h' else word[0] + if first in "aeiou": + return "an " + word + return "a " + word + action.setText('Define %s' % articulify(profileRoiClass.NAME)) + action.setToolTip('Enables %s selection mode' % profileRoiClass.NAME) + action.setSingleShot(True) + return action + + def createClearAction(self, parent): + """Create an action to clean up the plot from the profile ROIs. + + :param qt.QObject parent: The parent of the created action. + :rtype: qt.QAction + """ + action = _ClearAction(parent, self) + return action + + def createImageActions(self, parent): + """Create actions designed for image items. This actions created + new ROIs. + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileImageHorizontalLineROI, + rois.ProfileImageVerticalLineROI, + rois.ProfileImageLineROI, + rois.ProfileImageDirectedLineROI, + rois.ProfileImageCrossROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createScatterActions(self, parent): + """Create actions designed for scatter items. This actions created + new ROIs. + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileScatterHorizontalLineROI, + rois.ProfileScatterVerticalLineROI, + rois.ProfileScatterLineROI, + rois.ProfileScatterCrossROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createScatterSliceActions(self, parent): + """Create actions designed for regular scatter items. This actions + created new ROIs. + + This ROIs was designed to use the input data without interpolation, + like you could do with an image. + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileScatterHorizontalSliceROI, + rois.ProfileScatterVerticalSliceROI, + rois.ProfileScatterCrossSliceROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createImageStackActions(self, parent): + """Create actions designed for stack image items. This actions + created new ROIs. + + This ROIs was designed to create both profile on the displayed image + and profile on the full stack (2D result). + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileImageStackHorizontalLineROI, + rois.ProfileImageStackVerticalLineROI, + rois.ProfileImageStackLineROI, + rois.ProfileImageStackCrossROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createEditorAction(self, parent): + """Create an action containing GUI to edit the selected profile ROI. + + :param qt.QObject parent: The parent of the created action. + :rtype: qt.QAction + """ + action = editors.ProfileRoiEditorAction(parent) + action.setRoiManager(self.getRoiManager()) + return action + + def setItemType(self, image=False, scatter=False): + """Set the item type to use and select the active one. + + :param bool image: Image item are allowed + :param bool scatter: Scatter item are allowed + """ + self.__itemTypes = [] + plot = self.getPlotWidget() + item = None + if image: + self.__itemTypes.append("image") + item = plot.getActiveImage() + if scatter: + self.__itemTypes.append("scatter") + if item is None: + item = plot.getActiveScatter() + self.setPlotItem(item) + + def setProfileWindowClass(self, profileWindowClass): + """Set the class which will be instantiated to display profile result. + """ + self._profileWindowClass = profileWindowClass + + def setActiveItemTracking(self, tracking): + """Enable/disable the tracking of the active item of the plot. + + :param bool tracking: Tracking mode + """ + if self.__tracking == tracking: + return + plot = self.getPlotWidget() + if self.__tracking: + plot.sigActiveImageChanged.disconnect(self._activeImageChanged) + plot.sigActiveScatterChanged.disconnect(self._activeScatterChanged) + self.__tracking = tracking + if self.__tracking: + plot.sigActiveImageChanged.connect(self.__activeImageChanged) + plot.sigActiveScatterChanged.connect(self.__activeScatterChanged) + + def setDefaultColorFromCursorColor(self, enabled): + """Enabled/disable the use of the colormap cursor color to display the + ROIs. + + If set, the manager will update the color of the profile ROIs using the + current colormap cursor color from the selected item. + """ + self.__useColorFromCursor = enabled + + def __activeImageChanged(self, previous, legend): + """Handle plot item selection""" + if "image" in self.__itemTypes: + plot = self.getPlotWidget() + item = plot.getImage(legend) + self.setPlotItem(item) + + def __activeScatterChanged(self, previous, legend): + """Handle plot item selection""" + if "scatter" in self.__itemTypes: + plot = self.getPlotWidget() + item = plot.getScatter(legend) + self.setPlotItem(item) + + def __roiCreated(self, roi): + """Handle ROI creation""" + # Filter out non profile ROIs + if isinstance(roi, core.ProfileRoiMixIn): + if self._storeProperties is not None: + # Initialize the properties with the previous ones + self._storeProperties.restoreProperties(roi) + + def __addProfile(self, profileRoi): + """Add a new ROI to the manager.""" + if profileRoi.getFocusProxy() is None: + if self._storeProperties is not None: + # Follow changes on properties + self._storeProperties.setProfileRoi(profileRoi) + if self.__singleProfileAtATime: + # FIXME: It would be good to reuse the windows to avoid blinking + self.clearProfile() + + profileRoi._setProfileManager(self) + self._updateRoiColor(profileRoi) + self._rois.append(profileRoi) + self.requestUpdateProfile(profileRoi) + + def __removeProfile(self, profileRoi): + """Remove a ROI from the manager.""" + window = self._disconnectProfileWindow(profileRoi) + if window is not None: + geometry = window.geometry() + if not geometry.isEmpty(): + self._previousWindowGeometry.append(geometry) + self.clearProfileWindow(window) + if profileRoi in self._rois: + self._rois.remove(profileRoi) + + def _disconnectProfileWindow(self, profileRoi): + """Handle profile window close.""" + window = profileRoi.getProfileWindow() + profileRoi.setProfileWindow(None) + return window + + def clearProfile(self): + """Clear the associated ROI profile""" + roiManager = self.getRoiManager() + for roi in list(self._rois): + if roi.getFocusProxy() is not None: + # Skip sub ROIs, it will be removed by their parents + continue + roiManager.removeRoi(roi) + + if not roiManager.isDrawing(): + # Clean the selected mode + roiManager.stop() + + def hasPendingOperations(self): + """Returns true if a thread is still computing or displaying a profile. + + :rtype: bool + """ + return len(self.__reentrantResults) > 0 or len(self._pendingRunners) > 0 + + def requestUpdateAllProfile(self): + """Request to update the profile of all the managed ROIs. + """ + for roi in self._rois: + self.requestUpdateProfile(roi) + + def requestUpdateProfile(self, profileRoi): + """Request to update a specific profile ROI. + + :param ~core.ProfileRoiMixIn profileRoi: + """ + if profileRoi.computeProfile is None: + return + threadPool = silxGlobalThreadPool() + + # Clean up deprecated runners + for runner in list(self._pendingRunners): + if not inspect.isValid(runner): + self._pendingRunners.remove(runner) + continue + if runner.getRoi() is profileRoi: + if hasattr(threadPool, "tryTake"): + if threadPool.tryTake(runner): + self._pendingRunners.remove(runner) + else: # Support Qt<5.9 + runner._lazyCancel() + + item = self.getPlotItem() + if item is None or not isinstance(item, profileRoi.ITEM_KIND): + # This item is not compatible with this profile + profileRoi._setPlotItem(None) + profileWindow = profileRoi.getProfileWindow() + if profileWindow is not None: + profileWindow.setProfile(None) + return + + profileRoi._setPlotItem(item) + runner = _RunnableComputeProfile(threadPool, item, profileRoi) + runner.runnerFinished.connect(self.__cleanUpRunner) + runner.resultReady.connect(self.__displayResult) + self._pendingRunners.append(runner) + threadPool.start(runner) + + def __cleanUpRunner(self, runner): + """Remove a thread pool runner from the list of hold tasks. + + Called at the termination of the runner. + """ + if runner in self._pendingRunners: + self._pendingRunners.remove(runner) + + def __displayResult(self, roi, profileData): + """Display the result of a ROI. + + :param ~core.ProfileRoiMixIn profileRoi: A managed ROI + :param ~core.CurveProfileData profileData: Computed data profile + """ + if roi in self.__reentrantResults: + # Store the data to process it in the main loop + # And not a sub loop created by initProfileWindow + # This also remove the duplicated requested + self.__reentrantResults[roi] = profileData + return + + self.__reentrantResults[roi] = profileData + self._computedProfiles = self._computedProfiles + 1 + window = roi.getProfileWindow() + if window is None: + plot = self.getPlotWidget() + window = self.createProfileWindow(plot, roi) + # roi.profileWindow have to be set before initializing the window + # Cause the initialization is using QEventLoop + roi.setProfileWindow(window) + self.initProfileWindow(window, roi) + window.show() + + lastData = self.__reentrantResults.pop(roi) + window.setProfile(lastData) + + def __plotDestroyed(self, ref): + """Handle finalization of PlotWidget + + :param ref: weakref to the plot + """ + self._plotRef = None + self._roiManagerRef = None + self._pendingRunners = [] + + def setPlotItem(self, item): + """Set the plot item focused by the profile manager. + + :param ~silx.gui.plot.items.Item item: A plot item + """ + previous = self.getPlotItem() + if previous is item: + return + if item is None: + self._item = None + else: + item.sigItemChanged.connect(self.__itemChanged) + self._item = weakref.ref(item) + self._updateRoiColors() + self.requestUpdateAllProfile() + + def getDefaultColor(self, item): + """Returns the default ROI color to use according to the given item. + + :param ~silx.gui.plot.items.item.Item item: AN item + :rtype: qt.QColor + """ + color = 'pink' + if isinstance(item, items.ColormapMixIn): + colormap = item.getColormap() + name = colormap.getName() + if name is not None: + color = colors.cursorColorForColormap(name) + color = colors.asQColor(color) + return color + + def _updateRoiColors(self): + """Update ROI color according to the item selection""" + if not self.__useColorFromCursor: + return + item = self.getPlotItem() + color = self.getDefaultColor(item) + for roi in self._rois: + roi.setColor(color) + + def _updateRoiColor(self, roi): + """Update a specific ROI according to the current selected item. + + :param RegionOfInterest roi: The ROI to update + """ + if not self.__useColorFromCursor: + return + item = self.getPlotItem() + color = self.getDefaultColor(item) + roi.setColor(color) + + def __itemChanged(self, changeType): + """Handle item changes. + """ + if changeType in (items.ItemChangedType.DATA, + items.ItemChangedType.MASK, + items.ItemChangedType.POSITION, + items.ItemChangedType.SCALE): + self.requestUpdateAllProfile() + elif changeType == (items.ItemChangedType.COLORMAP): + self._updateRoiColors() + + def getPlotItem(self): + """Returns the item focused by the profile manager. + + :rtype: ~silx.gui.plot.items.Item + """ + if self._item is None: + return None + item = self._item() + if item is None: + self._item = None + return item + + def getPlotWidget(self): + """The plot associated to the profile manager. + + :rtype: ~silx.gui.plot.PlotWidget + """ + if self._plotRef is None: + return None + plot = self._plotRef() + if plot is None: + self._plotRef = None + return plot + + def getCurrentRoi(self): + """Returns the currently selected ROI, else None. + + :rtype: core.ProfileRoiMixIn + """ + roiManager = self.getRoiManager() + if roiManager is None: + return None + roi = roiManager.getCurrentRoi() + if not isinstance(roi, core.ProfileRoiMixIn): + return None + return roi + + def getRoiManager(self): + """Returns the used ROI manager + + :rtype: RegionOfInterestManager + """ + return self._roiManagerRef() + + def createProfileWindow(self, plot, roi): + """Create a new profile window. + + :param ~core.ProfileRoiMixIn roi: The plot containing the raw data + :param ~core.ProfileRoiMixIn roi: A managed ROI + :rtype: ~ProfileWindow + """ + return self._profileWindowClass(plot) + + def initProfileWindow(self, profileWindow, roi): + """This function is called just after the profile window creation in + order to initialize the window location. + + :param ~ProfileWindow profileWindow: + The profile window to initialize. + """ + # Enforce the use of one of the widgets + # To have the correct window size + profileWindow.prepareWidget(roi) + profileWindow.adjustSize() + + # Trick to avoid blinking while retrieving the right window size + # Display the window, hide it and wait for some event loops + profileWindow.show() + profileWindow.hide() + eventLoop = qt.QEventLoop(self) + for _ in range(10): + if not eventLoop.processEvents(): + break + + profileWindow.show() + if len(self._previousWindowGeometry) > 0: + geometry = self._previousWindowGeometry.pop() + profileWindow.setGeometry(geometry) + return + + window = self.getPlotWidget().window() + winGeom = window.frameGeometry() + if qt.BINDING in ("PySide2", "PyQt5"): + qapp = qt.QApplication.instance() + desktop = qapp.desktop() + screenGeom = desktop.availableGeometry(window) + else: # Qt6 (and also Qt>=5.14) + screenGeom = window.screen().availableGeometry() + spaceOnLeftSide = winGeom.left() + spaceOnRightSide = screenGeom.width() - winGeom.right() + + profileGeom = profileWindow.frameGeometry() + profileWidth = profileGeom.width() + + # Align vertically to the center of the window + top = winGeom.top() + (winGeom.height() - profileGeom.height()) // 2 + + margin = 5 + if profileWidth < spaceOnRightSide: + # Place profile on the right + left = winGeom.right() + margin + elif profileWidth < spaceOnLeftSide: + # Place profile on the left + left = max(0, winGeom.left() - profileWidth - margin) + else: + # Move it as much as possible where there is more space + if spaceOnLeftSide > spaceOnRightSide: + left = 0 + else: + left = screenGeom.width() - profileGeom.width() + profileWindow.move(left, top) + + + def clearProfileWindow(self, profileWindow): + """Called when a profile window is not anymore needed. + + By default the window will be closed. But it can be + inherited to change this behavior. + """ + profileWindow.deleteLater() diff --git a/src/silx/gui/plot/tools/profile/rois.py b/src/silx/gui/plot/tools/profile/rois.py new file mode 100644 index 0000000..9eef622 --- /dev/null +++ b/src/silx/gui/plot/tools/profile/rois.py @@ -0,0 +1,1156 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module define ROIs for profile tools. + +.. inheritance-diagram:: + silx.gui.plot.tools.profile.rois + :top-classes: silx.gui.plot.tools.profile.core.ProfileRoiMixIn, silx.gui.plot.items.roi.RegionOfInterest + :parts: 1 + :private-bases: +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "01/12/2020" + +import numpy +import weakref +from concurrent.futures import CancelledError + +from silx.gui import colors + +from silx.gui.plot import items +from silx.gui.plot.items import roi as roi_items +from . import core +from silx.gui import utils +from .....utils.proxy import docstring + + +def _relabelAxes(plot, text): + """Relabel {xlabel} and {ylabel} from this text using the corresponding + plot axis label. If the axis label is empty, label it with "X" and "Y". + + :rtype: str + """ + xLabel = plot.getXAxis().getLabel() + if not xLabel: + xLabel = "X" + yLabel = plot.getYAxis().getLabel() + if not yLabel: + yLabel = "Y" + return text.format(xlabel=xLabel, ylabel=yLabel) + + +def _lineProfileTitle(x0, y0, x1, y1): + """Compute corresponding plot title + + This can be overridden to change title behavior. + + :param float x0: Profile start point X coord + :param float y0: Profile start point Y coord + :param float x1: Profile end point X coord + :param float y1: Profile end point Y coord + :return: Title to use + :rtype: str + """ + if x0 == x1: + title = '{xlabel} = %g; {ylabel} = [%g, %g]' % (x0, y0, y1) + elif y0 == y1: + title = '{ylabel} = %g; {xlabel} = [%g, %g]' % (y0, x0, x1) + else: + m = (y1 - y0) / (x1 - x0) + b = y0 - m * x0 + title = '{ylabel} = %g * {xlabel} %+g' % (m, b) + + return title + + +class _ImageProfileArea(items.Shape): + """This shape displays the location of pixels used to compute the + profile.""" + + def __init__(self, parentRoi): + items.Shape.__init__(self, "polygon") + color = colors.rgba(parentRoi.getColor()) + self.setColor(color) + self.setFill(True) + self.setOverlay(True) + self.setPoints([[0, 0], [0, 0]]) # Else it segfault + + self.__parentRoi = weakref.ref(parentRoi) + parentRoi.sigItemChanged.connect(self._updateAreaProperty) + parentRoi.sigRegionChanged.connect(self._updateArea) + parentRoi.sigProfilePropertyChanged.connect(self._updateArea) + parentRoi.sigPlotItemChanged.connect(self._updateArea) + + def getParentRoi(self): + if self.__parentRoi is None: + return None + parentRoi = self.__parentRoi() + if parentRoi is None: + self.__parentRoi = None + return parentRoi + + def _updateAreaProperty(self, event=None, checkVisibility=True): + parentRoi = self.sender() + if event == items.ItemChangedType.COLOR: + parentRoi._updateItemProperty(event, parentRoi, self) + elif event == items.ItemChangedType.VISIBLE: + if self.getPlotItem() is not None: + parentRoi._updateItemProperty(event, parentRoi, self) + + def _updateArea(self): + roi = self.getParentRoi() + item = roi.getPlotItem() + if item is None: + self.setVisible(False) + return + polygon = self._computePolygon(item) + self.setVisible(True) + polygon = numpy.array(polygon).T + self.setLineStyle("--") + self.setPoints(polygon, copy=False) + + def _computePolygon(self, item): + if not isinstance(item, items.ImageBase): + raise TypeError("Unexpected class %s" % type(item)) + + currentData = item.getValueData(copy=False) + + roi = self.getParentRoi() + origin = item.getOrigin() + scale = item.getScale() + _coords, _profile, area, _profileName, _xLabel = core.createProfile( + roiInfo=roi._getRoiInfo(), + currentData=currentData, + origin=origin, + scale=scale, + lineWidth=roi.getProfileLineWidth(), + method="none") + return area + + +class _SliceProfileArea(items.Shape): + """This shape displays the location a profile in a scatter. + + Each point used to compute the slice are linked together. + """ + + def __init__(self, parentRoi): + items.Shape.__init__(self, "polygon") + color = colors.rgba(parentRoi.getColor()) + self.setColor(color) + self.setFill(True) + self.setOverlay(True) + self.setPoints([[0, 0], [0, 0]]) # Else it segfault + + self.__parentRoi = weakref.ref(parentRoi) + parentRoi.sigItemChanged.connect(self._updateAreaProperty) + parentRoi.sigRegionChanged.connect(self._updateArea) + parentRoi.sigProfilePropertyChanged.connect(self._updateArea) + parentRoi.sigPlotItemChanged.connect(self._updateArea) + + def getParentRoi(self): + if self.__parentRoi is None: + return None + parentRoi = self.__parentRoi() + if parentRoi is None: + self.__parentRoi = None + return parentRoi + + def _updateAreaProperty(self, event=None, checkVisibility=True): + parentRoi = self.sender() + if event == items.ItemChangedType.COLOR: + parentRoi._updateItemProperty(event, parentRoi, self) + elif event == items.ItemChangedType.VISIBLE: + if self.getPlotItem() is not None: + parentRoi._updateItemProperty(event, parentRoi, self) + + def _updateArea(self): + roi = self.getParentRoi() + item = roi.getPlotItem() + if item is None: + self.setVisible(False) + return + polylines = self._computePolylines(roi, item) + if polylines is None: + self.setVisible(False) + return + self.setVisible(True) + self.setLineStyle("--") + self.setPoints(polylines, copy=False) + + def _computePolylines(self, roi, item): + slicing = roi._getSlice(item) + if slicing is None: + return None + xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False) + xx, yy = xx[slicing], yy[slicing] + polylines = numpy.array((xx, yy)).T + if len(polylines) == 0: + return None + return polylines + + +class _DefaultImageProfileRoiMixIn(core.ProfileRoiMixIn): + """Provide common behavior for silx default image profile ROI. + """ + + ITEM_KIND = items.ImageBase + + def __init__(self, parent=None): + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.__method = "mean" + self.__width = 1 + self.sigRegionChanged.connect(self.__regionChanged) + self.sigPlotItemChanged.connect(self.__updateArea) + self.__area = _ImageProfileArea(self) + self.addItem(self.__area) + + def __regionChanged(self): + self.invalidateProfile() + self.__updateArea() + + def setProfileMethod(self, method): + """ + :param str method: method to compute the profile. Can be 'mean' or 'sum' + """ + if self.__method == method: + return + self.__method = method + self.invalidateProperties() + self.invalidateProfile() + + def getProfileMethod(self): + return self.__method + + def setProfileLineWidth(self, width): + if self.__width == width: + return + self.__width = width + self.__updateArea() + self.invalidateProperties() + self.invalidateProfile() + + def getProfileLineWidth(self): + return self.__width + + def __updateArea(self): + plotItem = self.getPlotItem() + if plotItem is None: + self.setLineStyle("-") + else: + self.setLineStyle("--") + + def _getRoiInfo(self): + """Wrapper to allow to reuse the previous Profile code. + + It would be good to remove it at one point. + """ + if isinstance(self, roi_items.HorizontalLineROI): + lineProjectionMode = 'X' + y = self.getPosition() + roiStart = (0, y) + roiEnd = (1, y) + elif isinstance(self, roi_items.VerticalLineROI): + lineProjectionMode = 'Y' + x = self.getPosition() + roiStart = (x, 0) + roiEnd = (x, 1) + elif isinstance(self, roi_items.LineROI): + lineProjectionMode = 'D' + roiStart, roiEnd = self.getEndPoints() + else: + assert False + + return roiStart, roiEnd, lineProjectionMode + + def computeProfile(self, item): + if not isinstance(item, items.ImageBase): + raise TypeError("Unexpected class %s" % type(item)) + + origin = item.getOrigin() + scale = item.getScale() + method = self.getProfileMethod() + lineWidth = self.getProfileLineWidth() + + def createProfile2(currentData): + coords, profile, _area, profileName, xLabel = core.createProfile( + roiInfo=self._getRoiInfo(), + currentData=currentData, + origin=origin, + scale=scale, + lineWidth=lineWidth, + method=method) + return coords, profile, profileName, xLabel + + currentData = item.getValueData(copy=False) + + yLabel = "%s" % str(method).capitalize() + coords, profile, title, xLabel = createProfile2(currentData) + title = title + "; width = %d" % lineWidth + + # Use the axis names from the original plot + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + title = _relabelAxes(plot, title) + xLabel = _relabelAxes(plot, xLabel) + + if isinstance(item, items.ImageRgba): + rgba = item.getData(copy=False) + _coords, r, _profileName, _xLabel = createProfile2(rgba[..., 0]) + _coords, g, _profileName, _xLabel = createProfile2(rgba[..., 1]) + _coords, b, _profileName, _xLabel = createProfile2(rgba[..., 2]) + if rgba.shape[-1] == 4: + _coords, a, _profileName, _xLabel = createProfile2(rgba[..., 3]) + else: + a = [None] + data = core.RgbaProfileData( + coords=coords, + profile=profile[0], + profile_r=r[0], + profile_g=g[0], + profile_b=b[0], + profile_a=a[0], + title=title, + xLabel=xLabel, + yLabel=yLabel, + ) + else: + data = core.CurveProfileData( + coords=coords, + profile=profile[0], + title=title, + xLabel=xLabel, + yLabel=yLabel, + ) + return data + + +class ProfileImageHorizontalLineROI(roi_items.HorizontalLineROI, + _DefaultImageProfileRoiMixIn): + """ROI for an horizontal profile at a location of an image""" + + ICON = 'shape-horizontal' + NAME = 'horizontal line profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageVerticalLineROI(roi_items.VerticalLineROI, + _DefaultImageProfileRoiMixIn): + """ROI for a vertical profile at a location of an image""" + + ICON = 'shape-vertical' + NAME = 'vertical line profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageLineROI(roi_items.LineROI, + _DefaultImageProfileRoiMixIn): + """ROI for an image profile between 2 points. + + The X profile of this ROI is the projecting into one of the x/y axes, + using its scale and its orientation. + """ + + ICON = 'shape-diagonal' + NAME = 'line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageDirectedLineROI(roi_items.LineROI, + _DefaultImageProfileRoiMixIn): + """ROI for an image profile between 2 points. + + The X profile of the line is displayed projected into the line itself, + using its scale and its orientation. It's the distance from the origin. + """ + + ICON = 'shape-diagonal-directed' + NAME = 'directed line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + self._handleStart.setSymbol('o') + + def computeProfile(self, item): + if not isinstance(item, items.ImageBase): + raise TypeError("Unexpected class %s" % type(item)) + + from silx.image.bilinear import BilinearImage + + origin = item.getOrigin() + scale = item.getScale() + method = self.getProfileMethod() + lineWidth = self.getProfileLineWidth() + currentData = item.getValueData(copy=False) + + roiInfo = self._getRoiInfo() + roiStart, roiEnd, _lineProjectionMode = roiInfo + + startPt = ((roiStart[1] - origin[1]) / scale[1], + (roiStart[0] - origin[0]) / scale[0]) + endPt = ((roiEnd[1] - origin[1]) / scale[1], + (roiEnd[0] - origin[0]) / scale[0]) + + if numpy.array_equal(startPt, endPt): + return None + + bilinear = BilinearImage(currentData) + profile = bilinear.profile_line( + (startPt[0] - 0.5, startPt[1] - 0.5), + (endPt[0] - 0.5, endPt[1] - 0.5), + lineWidth, + method=method) + + # Compute the line size + lineSize = numpy.sqrt((roiEnd[1] - roiStart[1]) ** 2 + + (roiEnd[0] - roiStart[0]) ** 2) + coords = numpy.linspace(0, lineSize, len(profile), + endpoint=True, + dtype=numpy.float32) + + title = _lineProfileTitle(*roiStart, *roiEnd) + title = title + "; width = %d" % lineWidth + xLabel = "√({xlabel}²+{ylabel}²)" + yLabel = str(method).capitalize() + + # Use the axis names from the original plot + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + xLabel = _relabelAxes(plot, xLabel) + title = _relabelAxes(plot, title) + + data = core.CurveProfileData( + coords=coords, + profile=profile, + title=title, + xLabel=xLabel, + yLabel=yLabel, + ) + return data + + +class _ProfileCrossROI(roi_items.HandleBasedROI, core.ProfileRoiMixIn): + + """ROI to manage a cross of profiles + + It is managed using 2 sub ROIs for vertical and horizontal. + """ + + _kind = "Cross" + """Label for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + roi_items.HandleBasedROI.__init__(self, parent=parent) + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.sigRegionChanged.connect(self.__regionChanged) + self.sigAboutToBeRemoved.connect(self.__aboutToBeRemoved) + self.__position = 0, 0 + self.__vline = None + self.__hline = None + self.__handle = self.addHandle() + self.__handleLabel = self.addLabelHandle() + self.__handleLabel.setText(self.getName()) + self.__inhibitReentance = utils.LockReentrant() + self.computeProfile = None + self.sigItemChanged.connect(self.__updateLineProperty) + + # Make sure the marker is over the ROIs + self.__handle.setZValue(1) + # Create the vline and the hline + self._createSubRois() + + @docstring(roi_items.HandleBasedROI) + def contains(self, position): + roiPos = self.getPosition() + return position[0] == roiPos[0] or position[1] == roiPos[1] + + def setFirstShapePoints(self, points): + pos = points[0] + self.setPosition(pos) + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self.__position + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + self.__position = pos + with utils.blockSignals(self.__handle): + self.__handle.setPosition(*pos) + with utils.blockSignals(self.__handleLabel): + self.__handleLabel.setPosition(*pos) + self.sigRegionChanged.emit() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self.__handle: + self.setPosition(current) + + def __updateLineProperty(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + self.__handleLabel.setText(self.getName()) + elif event in [items.ItemChangedType.COLOR, + items.ItemChangedType.VISIBLE]: + lines = [] + if self.__vline: + lines.append(self.__vline) + if self.__hline: + lines.append(self.__hline) + self._updateItemProperty(event, self, lines) + + def _createLines(self, parent): + """Inherit this function to return 2 ROI objects for respectivly + the horizontal, and the vertical lines.""" + raise NotImplementedError() + + def _setProfileManager(self, profileManager): + core.ProfileRoiMixIn._setProfileManager(self, profileManager) + # Connecting the vline and the hline + roiManager = profileManager.getRoiManager() + roiManager.addRoi(self.__vline) + roiManager.addRoi(self.__hline) + + def _createSubRois(self): + hline, vline = self._createLines(parent=None) + for i, line in enumerate([vline, hline]): + line.setPosition(self.__position[i]) + line.setEditable(True) + line.setSelectable(True) + line.setFocusProxy(self) + line.setName("") + self.__vline = vline + self.__hline = hline + vline.sigAboutToBeRemoved.connect(self.__vlineRemoved) + vline.sigRegionChanged.connect(self.__vlineRegionChanged) + hline.sigAboutToBeRemoved.connect(self.__hlineRemoved) + hline.sigRegionChanged.connect(self.__hlineRegionChanged) + + def _getLines(self): + return self.__hline, self.__vline + + def __regionChanged(self): + if self.__inhibitReentance.locked(): + return + x, y = self.getPosition() + hline, vline = self._getLines() + if hline is None: + return + with self.__inhibitReentance: + hline.setPosition(y) + vline.setPosition(x) + + def __vlineRegionChanged(self): + if self.__inhibitReentance.locked(): + return + pos = self.getPosition() + vline = self.__vline + pos = vline.getPosition(), pos[1] + with self.__inhibitReentance: + self.setPosition(pos) + + def __hlineRegionChanged(self): + if self.__inhibitReentance.locked(): + return + pos = self.getPosition() + hline = self.__hline + pos = pos[0], hline.getPosition() + with self.__inhibitReentance: + self.setPosition(pos) + + def __aboutToBeRemoved(self): + vline = self.__vline + hline = self.__hline + # Avoid side remove signals + if hline is not None: + hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved) + hline.sigRegionChanged.disconnect(self.__hlineRegionChanged) + if vline is not None: + vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved) + vline.sigRegionChanged.disconnect(self.__vlineRegionChanged) + # Clean up the child + profileManager = self.getProfileManager() + roiManager = profileManager.getRoiManager() + if hline is not None: + roiManager.removeRoi(hline) + self.__hline = None + if vline is not None: + roiManager.removeRoi(vline) + self.__vline = None + + def __hlineRemoved(self): + self.__lineRemoved(isHline=True) + + def __vlineRemoved(self): + self.__lineRemoved(isHline=False) + + def __lineRemoved(self, isHline): + """If any of the lines is removed: disconnect this objects, and let the + other one persist""" + hline, vline = self._getLines() + + hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved) + vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved) + hline.sigRegionChanged.disconnect(self.__hlineRegionChanged) + vline.sigRegionChanged.disconnect(self.__vlineRegionChanged) + + self.__hline = None + self.__vline = None + profileManager = self.getProfileManager() + roiManager = profileManager.getRoiManager() + if isHline: + self.__releaseLine(vline) + else: + self.__releaseLine(hline) + roiManager.removeRoi(self) + + def __releaseLine(self, line): + """Release the line in order to make it independent""" + line.setFocusProxy(None) + line.setName(self.getName()) + line.setEditable(self.isEditable()) + line.setSelectable(self.isSelectable()) + + +class ProfileImageCrossROI(_ProfileCrossROI): + """ROI to manage a cross of profiles + + It is managed using 2 sub ROIs for vertical and horizontal. + """ + + ICON = 'shape-cross' + NAME = 'cross profile' + ITEM_KIND = items.ImageBase + + def _createLines(self, parent): + vline = ProfileImageVerticalLineROI(parent=parent) + hline = ProfileImageHorizontalLineROI(parent=parent) + return hline, vline + + def setProfileMethod(self, method): + """ + :param str method: method to compute the profile. Can be 'mean' or 'sum' + """ + hline, vline = self._getLines() + hline.setProfileMethod(method) + vline.setProfileMethod(method) + self.invalidateProperties() + + def getProfileMethod(self): + hline, _vline = self._getLines() + return hline.getProfileMethod() + + def setProfileLineWidth(self, width): + hline, vline = self._getLines() + hline.setProfileLineWidth(width) + vline.setProfileLineWidth(width) + self.invalidateProperties() + + def getProfileLineWidth(self): + hline, _vline = self._getLines() + return hline.getProfileLineWidth() + + +class _DefaultScatterProfileRoiMixIn(core.ProfileRoiMixIn): + """Provide common behavior for silx default scatter profile ROI. + """ + + ITEM_KIND = items.Scatter + + def __init__(self, parent=None): + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.__nPoints = 1024 + self.sigRegionChanged.connect(self.__regionChanged) + + def __regionChanged(self): + self.invalidateProfile() + + # Number of points + + def getNPoints(self): + """Returns the number of points of the profiles + + :rtype: int + """ + return self.__nPoints + + def setNPoints(self, npoints): + """Set the number of points of the profiles + + :param int npoints: + """ + npoints = int(npoints) + if npoints < 1: + raise ValueError("Unsupported number of points: %d" % npoints) + elif npoints != self.__nPoints: + self.__nPoints = npoints + self.invalidateProperties() + self.invalidateProfile() + + def _computeProfile(self, scatter, x0, y0, x1, y1): + """Compute corresponding profile + + :param float x0: Profile start point X coord + :param float y0: Profile start point Y coord + :param float x1: Profile end point X coord + :param float y1: Profile end point Y coord + :return: (points, values) profile data or None + """ + future = scatter._getInterpolator() + try: + interpolator = future.result() + except CancelledError: + return None + if interpolator is None: + return None # Cannot init an interpolator + + nPoints = self.getNPoints() + points = numpy.transpose(( + numpy.linspace(x0, x1, nPoints, endpoint=True), + numpy.linspace(y0, y1, nPoints, endpoint=True))) + + values = interpolator(points) + + if not numpy.any(numpy.isfinite(values)): + return None # Profile outside convex hull + + return points, values + + def computeProfile(self, item): + """Update profile according to current ROI""" + if not isinstance(item, items.Scatter): + raise TypeError("Unexpected class %s" % type(item)) + + # Get end points + if isinstance(self, roi_items.LineROI): + points = self.getEndPoints() + x0, y0 = points[0] + x1, y1 = points[1] + elif isinstance(self, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)): + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + + if isinstance(self, roi_items.HorizontalLineROI): + x0, x1 = plot.getXAxis().getLimits() + y0 = y1 = self.getPosition() + + elif isinstance(self, roi_items.VerticalLineROI): + x0 = x1 = self.getPosition() + y0, y1 = plot.getYAxis().getLimits() + else: + raise RuntimeError('Unsupported ROI for profile: {}'.format(self.__class__)) + + if x1 < x0 or (x1 == x0 and y1 < y0): + # Invert points + x0, y0, x1, y1 = x1, y1, x0, y0 + + profile = self._computeProfile(item, x0, y0, x1, y1) + if profile is None: + return None + + title = _lineProfileTitle(x0, y0, x1, y1) + points = profile[0] + values = profile[1] + + if (numpy.abs(points[-1, 0] - points[0, 0]) > + numpy.abs(points[-1, 1] - points[0, 1])): + xProfile = points[:, 0] + xLabel = '{xlabel}' + else: + xProfile = points[:, 1] + xLabel = '{ylabel}' + + # Use the axis names from the original + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + title = _relabelAxes(plot, title) + xLabel = _relabelAxes(plot, xLabel) + + data = core.CurveProfileData( + coords=xProfile, + profile=values, + title=title, + xLabel=xLabel, + yLabel='Profile', + ) + return data + + +class ProfileScatterHorizontalLineROI(roi_items.HorizontalLineROI, + _DefaultScatterProfileRoiMixIn): + """ROI for an horizontal profile at a location of a scatter""" + + ICON = 'shape-horizontal' + NAME = 'horizontal line profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterVerticalLineROI(roi_items.VerticalLineROI, + _DefaultScatterProfileRoiMixIn): + """ROI for an horizontal profile at a location of a scatter""" + + ICON = 'shape-vertical' + NAME = 'vertical line profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterLineROI(roi_items.LineROI, + _DefaultScatterProfileRoiMixIn): + """ROI for an horizontal profile at a location of a scatter""" + + ICON = 'shape-diagonal' + NAME = 'line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterCrossROI(_ProfileCrossROI): + """ROI to manage a cross of profiles for scatters. + """ + + ICON = 'shape-cross' + NAME = 'cross profile' + ITEM_KIND = items.Scatter + + def _createLines(self, parent): + vline = ProfileScatterVerticalLineROI(parent=parent) + hline = ProfileScatterHorizontalLineROI(parent=parent) + return hline, vline + + def getNPoints(self): + """Returns the number of points of the profiles + + :rtype: int + """ + hline, _vline = self._getLines() + return hline.getNPoints() + + def setNPoints(self, npoints): + """Set the number of points of the profiles + + :param int npoints: + """ + hline, vline = self._getLines() + hline.setNPoints(npoints) + vline.setNPoints(npoints) + self.invalidateProperties() + + +class _DefaultScatterProfileSliceRoiMixIn(core.ProfileRoiMixIn): + """Default ROI to allow to slice in the scatter data.""" + + ITEM_KIND = items.Scatter + + def __init__(self, parent=None): + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.__area = _SliceProfileArea(self) + self.addItem(self.__area) + self.sigRegionChanged.connect(self._regionChanged) + self.sigPlotItemChanged.connect(self._updateArea) + + def _regionChanged(self): + self.invalidateProfile() + self._updateArea() + + def _updateArea(self): + plotItem = self.getPlotItem() + if plotItem is None: + self.setLineStyle("-") + else: + self.setLineStyle("--") + + def _getSlice(self, item): + position = self.getPosition() + bounds = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_BOUNDS) + if isinstance(self, roi_items.HorizontalLineROI): + axis = 1 + elif isinstance(self, roi_items.VerticalLineROI): + axis = 0 + else: + assert False + if bounds is None or position < bounds[0][axis] or position > bounds[1][axis]: + # ROI outside of the scatter bound + return None + + major_order = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_MAJOR_ORDER) + assert major_order == 'row' + max_grid_yy, max_grid_xx = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_SHAPE) + + xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False) + if isinstance(self, roi_items.HorizontalLineROI): + axis = yy + max_grid_first = max_grid_yy + max_grid_second = max_grid_xx + major_axis = major_order == 'column' + elif isinstance(self, roi_items.VerticalLineROI): + axis = xx + max_grid_first = max_grid_xx + max_grid_second = max_grid_yy + major_axis = major_order == 'row' + else: + assert False + + def argnearest(array, value): + array = numpy.abs(array - value) + return numpy.argmin(array) + + if major_axis: + # slice in the middle of the scatter + start = max_grid_second // 2 * max_grid_first + vslice = axis[start:start + max_grid_second] + index = argnearest(vslice, position) + slicing = slice(index, None, max_grid_first) + else: + # slice in the middle of the scatter + vslice = axis[max_grid_second // 2::max_grid_second] + index = argnearest(vslice, position) + start = index * max_grid_second + slicing = slice(start, start + max_grid_second) + + return slicing + + def computeProfile(self, item): + if not isinstance(item, items.Scatter): + raise TypeError("Unsupported %s item" % type(item)) + + slicing = self._getSlice(item) + if slicing is None: + # ROI out of bounds + return None + + _xx, _yy, values, _xx_error, _yy_error = item.getData(copy=False) + profile = values[slicing] + + if isinstance(self, roi_items.HorizontalLineROI): + title = "Horizontal slice" + xLabel = "{xlabel} index" + elif isinstance(self, roi_items.VerticalLineROI): + title = "Vertical slice" + xLabel = "{ylabel} index" + else: + assert False + + # Use the axis names from the original plot + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + xLabel = _relabelAxes(plot, xLabel) + + data = core.CurveProfileData( + coords=numpy.arange(len(profile)), + profile=profile, + title=title, + xLabel=xLabel, + yLabel="Profile", + ) + return data + + +class ProfileScatterHorizontalSliceROI(roi_items.HorizontalLineROI, + _DefaultScatterProfileSliceRoiMixIn): + """ROI for an horizontal profile at a location of a scatter + using data slicing. + """ + + ICON = 'slice-horizontal' + NAME = 'horizontal data slice profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterVerticalSliceROI(roi_items.VerticalLineROI, + _DefaultScatterProfileSliceRoiMixIn): + """ROI for a vertical profile at a location of a scatter + using data slicing. + """ + + ICON = 'slice-vertical' + NAME = 'vertical data slice profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterCrossSliceROI(_ProfileCrossROI): + """ROI to manage a cross of slicing profiles on scatters. + """ + + ICON = 'slice-cross' + NAME = 'cross data slice profile' + ITEM_KIND = items.Scatter + + def _createLines(self, parent): + vline = ProfileScatterVerticalSliceROI(parent=parent) + hline = ProfileScatterHorizontalSliceROI(parent=parent) + return hline, vline + + +class _DefaultImageStackProfileRoiMixIn(_DefaultImageProfileRoiMixIn): + + ITEM_KIND = items.ImageStack + + def __init__(self, parent=None): + super(_DefaultImageStackProfileRoiMixIn, self).__init__(parent=parent) + self.__profileType = "1D" + """Kind of profile""" + + def getProfileType(self): + return self.__profileType + + def setProfileType(self, kind): + assert kind in ["1D", "2D"] + if self.__profileType == kind: + return + self.__profileType = kind + self.invalidateProperties() + self.invalidateProfile() + + def computeProfile(self, item): + if not isinstance(item, items.ImageStack): + raise TypeError("Unexpected class %s" % type(item)) + + kind = self.getProfileType() + if kind == "1D": + result = _DefaultImageProfileRoiMixIn.computeProfile(self, item) + # z = item.getStackPosition() + return result + + assert kind == "2D" + + def createProfile2(currentData): + coords, profile, _area, profileName, xLabel = core.createProfile( + roiInfo=self._getRoiInfo(), + currentData=currentData, + origin=origin, + scale=scale, + lineWidth=self.getProfileLineWidth(), + method=method) + return coords, profile, profileName, xLabel + + currentData = numpy.array(item.getStackData(copy=False)) + origin = item.getOrigin() + scale = item.getScale() + colormap = item.getColormap() + method = self.getProfileMethod() + + coords, profile, profileName, xLabel = createProfile2(currentData) + + data = core.ImageProfileData( + coords=coords, + profile=profile, + title=profileName, + xLabel=xLabel, + yLabel="Profile", + colormap=colormap, + ) + return data + + +class ProfileImageStackHorizontalLineROI(roi_items.HorizontalLineROI, + _DefaultImageStackProfileRoiMixIn): + """ROI for an horizontal profile at a location of a stack of images""" + + ICON = 'shape-horizontal' + NAME = 'horizontal line profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageStackVerticalLineROI(roi_items.VerticalLineROI, + _DefaultImageStackProfileRoiMixIn): + """ROI for an vertical profile at a location of a stack of images""" + + ICON = 'shape-vertical' + NAME = 'vertical line profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageStackLineROI(roi_items.LineROI, + _DefaultImageStackProfileRoiMixIn): + """ROI for an vertical profile at a location of a stack of images""" + + ICON = 'shape-diagonal' + NAME = 'line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageStackCrossROI(ProfileImageCrossROI): + """ROI for an vertical profile at a location of a stack of images""" + + ICON = 'shape-cross' + NAME = 'cross profile' + ITEM_KIND = items.ImageStack + + def _createLines(self, parent): + vline = ProfileImageStackVerticalLineROI(parent=parent) + hline = ProfileImageStackHorizontalLineROI(parent=parent) + return hline, vline + + def getProfileType(self): + hline, _vline = self._getLines() + return hline.getProfileType() + + def setProfileType(self, kind): + hline, vline = self._getLines() + hline.setProfileType(kind) + vline.setProfileType(kind) + self.invalidateProperties() diff --git a/src/silx/gui/plot/tools/profile/toolbar.py b/src/silx/gui/plot/tools/profile/toolbar.py new file mode 100644 index 0000000..4a9a195 --- /dev/null +++ b/src/silx/gui/plot/tools/profile/toolbar.py @@ -0,0 +1,172 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides tool bar helper. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import logging +import weakref + +from silx.gui import qt +from silx.gui.widgets.MultiModeAction import MultiModeAction +from . import manager +from .. import roi as roi_mdl +from silx.gui.plot import items + + +_logger = logging.getLogger(__name__) + + +class ProfileToolBar(qt.QToolBar): + """Tool bar to provide profile for a plot. + + It is an helper class. For a dedicated application it would be better to + use an own tool bar in order in order have more flexibility. + """ + def __init__(self, parent=None, plot=None): + super(ProfileToolBar, self).__init__(parent=parent) + self.__scheme = None + self.__manager = None + self.__plot = weakref.ref(plot) + self.__multiAction = None + + def getPlotWidget(self): + """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar. + + :rtype: Union[~silx.gui.plot.PlotWidget,None] + """ + if self.__plot is None: + return None + plot = self.__plot() + if self.__plot is None: + self.__plot = None + return plot + + def setScheme(self, scheme): + """Initialize the tool bar using a configuration scheme. + + It have to be done once and only once. + + :param str scheme: One of "scatter", "image", "imagestack" + """ + assert self.__scheme is None + self.__scheme = scheme + + plot = self.getPlotWidget() + self.__manager = manager.ProfileManager(self, plot) + + if scheme == "image": + self.__manager.setItemType(image=True) + self.__manager.setActiveItemTracking(True) + + multiAction = MultiModeAction(self) + self.addAction(multiAction) + for action in self.__manager.createImageActions(self): + multiAction.addAction(action) + self.__multiAction = multiAction + + cleanAction = self.__manager.createClearAction(self) + self.addAction(cleanAction) + editorAction = self.__manager.createEditorAction(self) + self.addAction(editorAction) + + plot.sigActiveImageChanged.connect(self._activeImageChanged) + self._activeImageChanged() + + elif scheme == "scatter": + self.__manager.setItemType(scatter=True) + self.__manager.setActiveItemTracking(True) + + multiAction = MultiModeAction(self) + self.addAction(multiAction) + for action in self.__manager.createScatterActions(self): + multiAction.addAction(action) + for action in self.__manager.createScatterSliceActions(self): + multiAction.addAction(action) + self.__multiAction = multiAction + + cleanAction = self.__manager.createClearAction(self) + self.addAction(cleanAction) + editorAction = self.__manager.createEditorAction(self) + self.addAction(editorAction) + + plot.sigActiveScatterChanged.connect(self._activeScatterChanged) + self._activeScatterChanged() + + elif scheme == "imagestack": + self.__manager.setItemType(image=True) + self.__manager.setActiveItemTracking(True) + + multiAction = MultiModeAction(self) + self.addAction(multiAction) + for action in self.__manager.createImageStackActions(self): + multiAction.addAction(action) + self.__multiAction = multiAction + + cleanAction = self.__manager.createClearAction(self) + self.addAction(cleanAction) + editorAction = self.__manager.createEditorAction(self) + self.addAction(editorAction) + + plot.sigActiveImageChanged.connect(self._activeImageChanged) + self._activeImageChanged() + + else: + raise ValueError("Toolbar scheme %s unsupported" % scheme) + + def _setRoiActionEnabled(self, itemKind, enabled): + for action in self.__multiAction.getMenu().actions(): + if not isinstance(action, roi_mdl.CreateRoiModeAction): + continue + roiClass = action.getRoiClass() + if issubclass(itemKind, roiClass.ITEM_KIND): + action.setEnabled(enabled) + + def _activeImageChanged(self, previous=None, legend=None): + """Handle active image change to toggle actions""" + if legend is None: + self._setRoiActionEnabled(items.ImageStack, False) + self._setRoiActionEnabled(items.ImageBase, False) + else: + plot = self.getPlotWidget() + image = plot.getActiveImage() + # Disable for empty image + enabled = image.getData(copy=False).size > 0 + self._setRoiActionEnabled(type(image), enabled) + + def _activeScatterChanged(self, previous=None, legend=None): + """Handle active scatter change to toggle actions""" + if legend is None: + self._setRoiActionEnabled(items.Scatter, False) + else: + plot = self.getPlotWidget() + scatter = plot.getActiveScatter() + # Disable for empty image + enabled = scatter.getValueData(copy=False).size > 0 + self._setRoiActionEnabled(type(scatter), enabled) diff --git a/src/silx/gui/plot/tools/roi.py b/src/silx/gui/plot/tools/roi.py new file mode 100644 index 0000000..e4be6a7 --- /dev/null +++ b/src/silx/gui/plot/tools/roi.py @@ -0,0 +1,1417 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides ROI interaction for :class:`~silx.gui.plot.PlotWidget`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import enum +import logging +import time +import weakref +import functools + +import numpy + +from ... import qt, icons +from ...utils import blockSignals +from ...utils import LockReentrant +from .. import PlotWidget +from ..items import roi as roi_items + +from ...colors import rgba + + +logger = logging.getLogger(__name__) + + +class CreateRoiModeAction(qt.QAction): + """ + This action is a plot mode which allows to create new ROIs using a ROI + manager. + + A ROI is created using a specific `roiClass`. `initRoi` and `finalizeRoi` + can be inherited to custom the ROI initialization. + + :param class roiClass: The ROI class which will be created by this action. + :param qt.QObject parent: The action parent + :param RegionOfInterestManager roiManager: The ROI manager + """ + + def __init__(self, parent, roiManager, roiClass): + assert roiManager is not None + assert roiClass is not None + qt.QAction.__init__(self, parent=parent) + self._roiManager = weakref.ref(roiManager) + self._roiClass = roiClass + self._singleShot = False + self._initAction() + self.triggered[bool].connect(self._actionTriggered) + + def _initAction(self): + """Default initialization of the action""" + roiClass = self._roiClass + + name = None + iconName = None + if hasattr(roiClass, "NAME"): + name = roiClass.NAME + if hasattr(roiClass, "ICON"): + iconName = roiClass.ICON + + if iconName is None: + iconName = "add-shape-unknown" + if name is None: + name = roiClass.__name__ + text = 'Add %s' % name + self.setIcon(icons.getQIcon(iconName)) + self.setText(text) + self.setCheckable(True) + self.setToolTip(text) + + def getRoiClass(self): + """Return the ROI class used by this action to create ROIs""" + return self._roiClass + + def getRoiManager(self): + return self._roiManager() + + def setSingleShot(self, singleShot): + """Set it to True to deactivate the action after the first creation + of a ROI. + + :param bool singleShot: New single short state + """ + self._singleShot = singleShot + + def getSingleShot(self): + """If True, after the first creation of a ROI with this mode, + the mode is deactivated. + + :rtype: bool + """ + return self._singleShot + + def _actionTriggered(self, checked): + """Handle mode actions being checked by the user + + :param bool checked: + :param str kind: Corresponding shape kind + """ + roiManager = self.getRoiManager() + if roiManager is None: + return + + if checked: + roiManager.start(self._roiClass, self) + self.__interactiveModeStarted(roiManager) + else: + source = roiManager.getInteractionSource() + if source is self: + roiManager.stop() + + def __interactiveModeStarted(self, roiManager): + roiManager.sigInteractiveRoiCreated.connect(self.initRoi) + roiManager.sigInteractiveRoiFinalized.connect(self.__finalizeRoi) + roiManager.sigInteractiveModeFinished.connect(self.__interactiveModeFinished) + + def __interactiveModeFinished(self): + roiManager = self.getRoiManager() + if roiManager is not None: + roiManager.sigInteractiveRoiCreated.disconnect(self.initRoi) + roiManager.sigInteractiveRoiFinalized.disconnect(self.__finalizeRoi) + roiManager.sigInteractiveModeFinished.disconnect(self.__interactiveModeFinished) + self.setChecked(False) + + def initRoi(self, roi): + """Inherit it to custom the new ROI at it's creation during the + interaction.""" + pass + + def __finalizeRoi(self, roi): + self.finalizeRoi(roi) + if self._singleShot: + roiManager = self.getRoiManager() + if roiManager is not None: + roiManager.stop() + + def finalizeRoi(self, roi): + """Inherit it to custom the new ROI after it's creation when the + interaction is finalized.""" + pass + + +class RoiModeSelector(qt.QWidget): + def __init__(self, parent=None): + super(RoiModeSelector, self).__init__(parent=parent) + self.__roi = None + self.__reentrant = LockReentrant() + + layout = qt.QHBoxLayout(self) + if isinstance(parent, qt.QMenu): + margins = layout.contentsMargins() + layout.setContentsMargins(margins.left(), 0, margins.right(), 0) + else: + layout.setContentsMargins(0, 0, 0, 0) + + self._label = qt.QLabel(self) + self._label.setText("Mode:") + self._label.setToolTip("Select a specific interaction to edit the ROI") + self._combo = qt.QComboBox(self) + self._combo.currentIndexChanged.connect(self._modeSelected) + layout.addWidget(self._label) + layout.addWidget(self._combo) + self._updateAvailableModes() + + def getRoi(self): + """Returns the edited ROI. + + :rtype: roi_items.RegionOfInterest + """ + return self.__roi + + def setRoi(self, roi): + """Returns the edited ROI. + + :rtype: roi_items.RegionOfInterest + """ + if self.__roi is roi: + return + if not isinstance(roi, roi_items.InteractionModeMixIn): + self.__roi = None + self._updateAvailableModes() + return + + if self.__roi is not None: + self.__roi.sigInteractionModeChanged.disconnect(self._modeChanged) + self.__roi = roi + if self.__roi is not None: + self.__roi.sigInteractionModeChanged.connect(self._modeChanged) + self._updateAvailableModes() + + def isEmpty(self): + return not self._label.isVisibleTo(self) + + def _updateAvailableModes(self): + roi = self.getRoi() + if isinstance(roi, roi_items.InteractionModeMixIn): + modes = roi.availableInteractionModes() + else: + modes = [] + if len(modes) <= 1: + self._label.setVisible(False) + self._combo.setVisible(False) + else: + self._label.setVisible(True) + self._combo.setVisible(True) + with blockSignals(self._combo): + self._combo.clear() + for im, m in enumerate(modes): + self._combo.addItem(m.label, m) + self._combo.setItemData(im, m.description, qt.Qt.ToolTipRole) + mode = roi.getInteractionMode() + self._modeChanged(mode) + index = modes.index(mode) + self._combo.setCurrentIndex(index) + + def _modeChanged(self, mode): + """Triggered when the ROI interaction mode was changed externally""" + if self.__reentrant.locked(): + # This event was initialised by the widget + return + roi = self.__roi + modes = roi.availableInteractionModes() + index = modes.index(mode) + with blockSignals(self._combo): + self._combo.setCurrentIndex(index) + + def _modeSelected(self): + """Triggered when the ROI interaction mode was selected in the widget""" + index = self._combo.currentIndex() + if index == -1: + return + roi = self.getRoi() + if roi is not None: + mode = self._combo.itemData(index, qt.Qt.UserRole) + with self.__reentrant: + roi.setInteractionMode(mode) + + +class RoiModeSelectorAction(qt.QWidgetAction): + """Display the selected mode of a ROI and allow to change it""" + + def __init__(self, parent=None): + super(RoiModeSelectorAction, self).__init__(parent) + self.__roiManager = None + + def createWidget(self, parent): + """Inherit the method to create a new widget""" + widget = RoiModeSelector(parent) + manager = self.__roiManager + if manager is not None: + roi = manager.getCurrentRoi() + widget.setRoi(roi) + self.setVisible(not widget.isEmpty()) + return widget + + def deleteWidget(self, widget): + """Inherit the method to delete a widget""" + widget.setRoi(None) + return qt.QWidgetAction.deleteWidget(self, widget) + + def setRoiManager(self, roiManager): + """ + Connect this action to a ROI manager. + + :param RegionOfInterestManager roiManager: A ROI manager + """ + if self.__roiManager is roiManager: + return + if self.__roiManager is not None: + self.__roiManager.sigCurrentRoiChanged.disconnect(self.__currentRoiChanged) + self.__roiManager = roiManager + if self.__roiManager is not None: + self.__roiManager.sigCurrentRoiChanged.connect(self.__currentRoiChanged) + self.__currentRoiChanged(roiManager.getCurrentRoi()) + + def __currentRoiChanged(self, roi): + """Handle changes of the selected ROI""" + self.setRoi(roi) + + def setRoi(self, roi): + """Set a profile ROI to edit. + + :param ProfileRoiMixIn roi: A profile ROI + """ + widget = None + for widget in self.createdWidgets(): + widget.setRoi(roi) + if widget is not None: + self.setVisible(not widget.isEmpty()) + + +class RegionOfInterestManager(qt.QObject): + """Class handling ROI interaction on a PlotWidget. + + It supports the multiple ROIs: points, rectangles, polygons, + lines, horizontal and vertical lines. + + See ``plotInteractiveImageROI.py`` sample code (:ref:`sample-code`). + + :param silx.gui.plot.PlotWidget parent: + The plot widget in which to control the ROIs. + """ + + sigRoiAdded = qt.Signal(roi_items.RegionOfInterest) + """Signal emitted when a new ROI has been added. + + It provides the newly add :class:`RegionOfInterest` object. + """ + + sigRoiAboutToBeRemoved = qt.Signal(roi_items.RegionOfInterest) + """Signal emitted just before a ROI is removed. + + It provides the :class:`RegionOfInterest` object that is about to be removed. + """ + + sigRoiChanged = qt.Signal() + """Signal emitted whenever the ROIs have changed.""" + + sigCurrentRoiChanged = qt.Signal(object) + """Signal emitted whenever a ROI is selected.""" + + sigInteractiveModeStarted = qt.Signal(object) + """Signal emitted when switching to ROI drawing interactive mode. + + It provides the class of the ROI which will be created by the interactive + mode. + """ + + sigInteractiveRoiCreated = qt.Signal(object) + """Signal emitted when a ROI is created during the interaction. + The interaction is still incomplete and can be aborted. + + It provides the ROI object which was just been created. + """ + + sigInteractiveRoiFinalized = qt.Signal(object) + """Signal emitted when a ROI creation is complet. + + It provides the ROI object which was just been created. + """ + + sigInteractiveModeFinished = qt.Signal() + """Signal emitted when leaving interactive ROI drawing mode. + """ + + ROI_CLASSES = ( + roi_items.PointROI, + roi_items.CrossROI, + roi_items.RectangleROI, + roi_items.CircleROI, + roi_items.EllipseROI, + roi_items.PolygonROI, + roi_items.LineROI, + roi_items.HorizontalLineROI, + roi_items.VerticalLineROI, + roi_items.ArcROI, + roi_items.HorizontalRangeROI, + ) + + def __init__(self, parent): + assert isinstance(parent, PlotWidget) + super(RegionOfInterestManager, self).__init__(parent) + self._rois = [] # List of ROIs + self._drawnROI = None # New ROI being currently drawn + + self._roiClass = None + self._source = None + self._color = rgba('red') + + self._label = "__RegionOfInterestManager__%d" % id(self) + + self._currentRoi = None + """Hold currently selected ROI""" + + self._eventLoop = None + + self._modeActions = {} + + parent.sigPlotSignal.connect(self._plotSignals) + + parent.sigInteractiveModeChanged.connect( + self._plotInteractiveModeChanged) + + parent.sigItemRemoved.connect(self._itemRemoved) + + parent._sigDefaultContextMenu.connect(self._feedContextMenu) + + @classmethod + def getSupportedRoiClasses(cls): + """Returns the default available ROI classes + + :rtype: List[class] + """ + return tuple(cls.ROI_CLASSES) + + # Associated QActions + + def getInteractionModeAction(self, roiClass): + """Returns the QAction corresponding to a kind of ROI + + The QAction allows to enable the corresponding drawing + interactive mode. + + :param class roiClass: The ROI class which will be created by this action. + :rtype: QAction + :raise ValueError: If kind is not supported + """ + if not issubclass(roiClass, roi_items.RegionOfInterest): + raise ValueError('Unsupported ROI class %s' % roiClass) + + action = self._modeActions.get(roiClass, None) + if action is None: # Lazy-loading + action = CreateRoiModeAction(self, self, roiClass) + self._modeActions[roiClass] = action + return action + + # PlotWidget eventFilter and listeners + + def _plotInteractiveModeChanged(self, source): + """Handle change of interactive mode in the plot""" + if source is not self: + self.__roiInteractiveModeEnded() + + def _getRoiFromItem(self, item): + """Returns the ROI which own this item, else None + if this manager do not have knowledge of this ROI.""" + for roi in self._rois: + if isinstance(roi, roi_items.RegionOfInterest): + for child in roi.getItems(): + if child is item: + return roi + return None + + def _itemRemoved(self, item): + """Called after an item was removed from the plot.""" + if not hasattr(item, "_roiGroup"): + # Early break to avoid to use _getRoiFromItem + # And to avoid reentrant signal when the ROI remove the item itself + return + roi = self._getRoiFromItem(item) + if roi is not None: + self.removeRoi(roi) + + # Handle ROI interaction + + def _handleInteraction(self, event): + """Handle mouse interaction for ROI addition""" + roiClass = self.getCurrentInteractionModeRoiClass() + if roiClass is None: + return # Should not happen + + kind = roiClass.getFirstInteractionShape() + if kind == 'point': + if event['event'] == 'mouseClicked' and event['button'] == 'left': + points = numpy.array([(event['x'], event['y'])], + dtype=numpy.float64) + # Not an interactive creation + roi = self._createInteractiveRoi(roiClass, points=points) + roi.creationFinalized() + self.sigInteractiveRoiFinalized.emit(roi) + else: # other shapes + if (event['event'] in ('drawingProgress', 'drawingFinished') and + event['parameters']['label'] == self._label): + points = numpy.array((event['xdata'], event['ydata']), + dtype=numpy.float64).T + + if self._drawnROI is None: # Create new ROI + # NOTE: Set something before createRoi, so isDrawing is True + self._drawnROI = object() + self._drawnROI = self._createInteractiveRoi(roiClass, points=points) + else: + self._drawnROI.setFirstShapePoints(points) + + if event['event'] == 'drawingFinished': + if kind == 'polygon' and len(points) > 1: + self._drawnROI.setFirstShapePoints(points[:-1]) + roi = self._drawnROI + self._drawnROI = None # Stop drawing + roi.creationFinalized() + self.sigInteractiveRoiFinalized.emit(roi) + + # RegionOfInterest selection + + def __getRoiFromMarker(self, marker): + """Returns a ROI from a marker, else None""" + # This should be speed up + for roi in self._rois: + if isinstance(roi, roi_items.HandleBasedROI): + for m in roi.getHandles(): + if m is marker: + return roi + else: + for m in roi.getItems(): + if m is marker: + return roi + return None + + def setCurrentRoi(self, roi): + """Set the currently selected ROI, and emit a signal. + + :param Union[RegionOfInterest,None] roi: The ROI to select + """ + if self._currentRoi is roi: + return + if roi is not None: + # Note: Fixed range to avoid infinite loops + for _ in range(10): + target = roi.getFocusProxy() + if target is None: + break + roi = target + else: + raise RuntimeError("Max selection proxy depth (10) reached.") + + if self._currentRoi is not None: + self._currentRoi.setHighlighted(False) + self._currentRoi = roi + if self._currentRoi is not None: + self._currentRoi.setHighlighted(True) + self.sigCurrentRoiChanged.emit(roi) + + def getCurrentRoi(self): + """Returns the currently selected ROI, else None. + + :rtype: Union[RegionOfInterest,None] + """ + return self._currentRoi + + def _plotSignals(self, event): + """Handle mouse interaction for ROI addition""" + clicked = False + roi = None + if event["event"] in ("markerClicked", "markerMoving"): + plot = self.parent() + legend = event["label"] + marker = plot._getMarker(legend=legend) + roi = self.__getRoiFromMarker(marker) + elif event["event"] == "mouseClicked" and event["button"] == "left": + # Marker click is only for dnd + # This also can click on a marker + clicked = True + plot = self.parent() + marker = plot._getMarkerAt(event["xpixel"], event["ypixel"]) + roi = self.__getRoiFromMarker(marker) + else: + return + + if roi not in self._rois: + # The ROI is not own by this manager + return + + if roi is not None: + currentRoi = self.getCurrentRoi() + if currentRoi is roi: + if clicked: + self.__updateMode(roi) + elif roi.isSelectable(): + self.setCurrentRoi(roi) + else: + self.setCurrentRoi(None) + + def __updateMode(self, roi): + if isinstance(roi, roi_items.InteractionModeMixIn): + available = roi.availableInteractionModes() + mode = roi.getInteractionMode() + imode = available.index(mode) + mode = available[(imode + 1) % len(available)] + roi.setInteractionMode(mode) + + def _feedContextMenu(self, menu): + """Called when the default plot context menu is about to be displayed""" + roi = self.getCurrentRoi() + if roi is not None: + if roi.isEditable(): + # Filter by data position + # FIXME: It would be better to use GUI coords for it + plot = self.parent() + pos = plot.getWidgetHandle().mapFromGlobal(qt.QCursor.pos()) + data = plot.pixelToData(pos.x(), pos.y()) + if roi.contains(data): + if isinstance(roi, roi_items.InteractionModeMixIn): + self._contextMenuForInteractionMode(menu, roi) + + removeAction = qt.QAction(menu) + removeAction.setText("Remove %s" % roi.getName()) + callback = functools.partial(self.removeRoi, roi) + removeAction.triggered.connect(callback) + menu.addAction(removeAction) + + def _contextMenuForInteractionMode(self, menu, roi): + availableModes = roi.availableInteractionModes() + currentMode = roi.getInteractionMode() + submenu = qt.QMenu(menu) + modeGroup = qt.QActionGroup(menu) + modeGroup.setExclusive(True) + for mode in availableModes: + action = qt.QAction(menu) + action.setText(mode.label) + action.setToolTip(mode.description) + action.setCheckable(True) + if mode is currentMode: + action.setChecked(True) + else: + callback = functools.partial(roi.setInteractionMode, mode) + action.triggered.connect(callback) + modeGroup.addAction(action) + submenu.addAction(action) + submenu.setTitle("%s interaction mode" % roi.getName()) + menu.addMenu(submenu) + + # RegionOfInterest API + + def getRois(self): + """Returns the list of ROIs. + + It returns an empty tuple if there is currently no ROI. + + :return: Tuple of arrays of objects describing the ROIs + :rtype: List[RegionOfInterest] + """ + return tuple(self._rois) + + def clear(self): + """Reset current ROIs + + :return: True if ROIs were reset. + :rtype: bool + """ + if self.getRois(): # Something to reset + for roi in self._rois: + roi.sigRegionChanged.disconnect( + self._regionOfInterestChanged) + roi.setParent(None) + self._rois = [] + self._roisUpdated() + return True + + else: + return False + + def _regionOfInterestChanged(self, event=None): + """Handle ROI object changed""" + self.sigRoiChanged.emit() + + def _createInteractiveRoi(self, roiClass, points, label=None, index=None): + """Create a new ROI with interactive creation. + + :param class roiClass: The class of the ROI to create + :param numpy.ndarray points: The first shape used to create the ROI + :param str label: The label to display along with the ROI. + :param int index: The position where to insert the ROI. + By default it is appended to the end of the list. + :return: The created ROI object + :rtype: roi_items.RegionOfInterest + :raise RuntimeError: When ROI cannot be added because the maximum + number of ROIs has been reached. + """ + roi = roiClass(parent=None) + if label is not None: + roi.setName(str(label)) + roi.creationStarted() + roi.setFirstShapePoints(points) + + self.addRoi(roi, index) + if roi.isSelectable(): + self.setCurrentRoi(roi) + self.sigInteractiveRoiCreated.emit(roi) + return roi + + def containsRoi(self, roi): + """Returns true if the ROI is part of this manager. + + :param roi_items.RegionOfInterest roi: The ROI to add + :rtype: bool + """ + return roi in self._rois + + def addRoi(self, roi, index=None, useManagerColor=True): + """Add the ROI to the list of ROIs. + + :param roi_items.RegionOfInterest roi: The ROI to add + :param int index: The position where to insert the ROI, + By default it is appended to the end of the list of ROIs + :param bool useManagerColor: + Whether to set the ROI color to the default one of the manager or not. + (Default: True). + :raise RuntimeError: When ROI cannot be added because the maximum + number of ROIs has been reached. + """ + plot = self.parent() + if plot is None: + raise RuntimeError( + 'Cannot add ROI: PlotWidget no more available') + + roi.setParent(self) + + if useManagerColor: + roi.setColor(self.getColor()) + + roi.sigRegionChanged.connect(self._regionOfInterestChanged) + roi.sigItemChanged.connect(self._regionOfInterestChanged) + + if index is None: + self._rois.append(roi) + else: + self._rois.insert(index, roi) + self.sigRoiAdded.emit(roi) + self._roisUpdated() + + def removeRoi(self, roi): + """Remove a ROI from the list of ROIs. + + :param roi_items.RegionOfInterest roi: The ROI to remove + :raise ValueError: When ROI does not belong to this object + """ + if not (isinstance(roi, roi_items.RegionOfInterest) and + roi.parent() is self and + roi in self._rois): + raise ValueError( + 'RegionOfInterest does not belong to this instance') + + roi.sigAboutToBeRemoved.emit() + self.sigRoiAboutToBeRemoved.emit(roi) + + if roi is self._currentRoi: + self.setCurrentRoi(None) + + mustRestart = False + if roi is self._drawnROI: + self._drawnROI = None + mustRestart = True + self._rois.remove(roi) + roi.sigRegionChanged.disconnect(self._regionOfInterestChanged) + roi.sigItemChanged.disconnect(self._regionOfInterestChanged) + roi.setParent(None) + self._roisUpdated() + + if mustRestart: + self._restart() + + def _roisUpdated(self): + """Handle update of the ROI list""" + self.sigRoiChanged.emit() + + # RegionOfInterest parameters + + def getColor(self): + """Return the default color of created ROIs + + :rtype: QColor + """ + return qt.QColor.fromRgbF(*self._color) + + def setColor(self, color): + """Set the default color to use when creating ROIs. + + Existing ROIs are not affected. + + :param color: The color to use for displaying ROIs as + either a color name, a QColor, a list of uint8 or float in [0, 1]. + """ + self._color = rgba(color) + + # Control ROI + + def getCurrentInteractionModeRoiClass(self): + """Returns the current ROI class used by the interactive drawing mode. + + Returns None if the ROI manager is not in an interactive mode. + + :rtype: Union[class,None] + """ + return self._roiClass + + def getInteractionSource(self): + """Returns the object which have requested the ROI creation. + + Returns None if the ROI manager is not in an interactive mode. + + :rtype: Union[object,None] + """ + return self._source + + def isStarted(self): + """Returns True if an interactive ROI drawing mode is active. + + :rtype: bool + """ + return self._roiClass is not None + + def isDrawing(self): + """Returns True if an interactive ROI is drawing. + + :rtype: bool + """ + return self._drawnROI is not None + + def start(self, roiClass, source=None): + """Start an interactive ROI drawing mode. + + :param class roiClass: The ROI class to create. It have to inherite from + `roi_items.RegionOfInterest`. + :param object source: SOurce of the ROI interaction. + :return: True if interactive ROI drawing was started, False otherwise + :rtype: bool + :raise ValueError: If roiClass is not supported + """ + self.stop() + + if not issubclass(roiClass, roi_items.RegionOfInterest): + raise ValueError('Unsupported ROI class %s' % roiClass) + + plot = self.parent() + if plot is None: + return False + + self._roiClass = roiClass + self._source = source + + self._restart() + + plot.sigPlotSignal.connect(self._handleInteraction) + + self.sigInteractiveModeStarted.emit(roiClass) + + return True + + def _restart(self): + """Restart the plot interaction without changing the + source or the ROI class. + """ + roiClass = self._roiClass + plot = self.parent() + firstInteractionShapeKind = roiClass.getFirstInteractionShape() + + if firstInteractionShapeKind == 'point': + plot.setInteractiveMode(mode='select', source=self) + else: + if roiClass.showFirstInteractionShape(): + color = rgba(self.getColor()) + else: + color = None + plot.setInteractiveMode(mode='select-draw', + source=self, + shape=firstInteractionShapeKind, + color=color, + label=self._label) + + def __roiInteractiveModeEnded(self): + """Handle end of ROI draw interactive mode""" + if self.isStarted(): + self._roiClass = None + self._source = None + + if self._drawnROI is not None: + # Cancel ROI create + roi = self._drawnROI + self._drawnROI = None + self.removeRoi(roi) + + plot = self.parent() + if plot is not None: + plot.sigPlotSignal.disconnect(self._handleInteraction) + + self.sigInteractiveModeFinished.emit() + + def stop(self): + """Stop interactive ROI drawing mode. + + :return: True if an interactive ROI drawing mode was actually stopped + :rtype: bool + """ + if not self.isStarted(): + return False + + plot = self.parent() + if plot is not None: + # This leads to call __roiInteractiveModeEnded through + # interactive mode changed signal + plot.resetInteractiveMode() + else: # Fallback + self.__roiInteractiveModeEnded() + + return True + + def exec(self, roiClass): + """Block until :meth:`quit` is called. + + :param class kind: The class of the ROI which have to be created. + See `silx.gui.plot.items.roi`. + :return: The list of ROIs + :rtype: tuple + """ + self.start(roiClass) + + plot = self.parent() + plot.show() + plot.raise_() + + self._eventLoop = qt.QEventLoop() + self._eventLoop.exec() + self._eventLoop = None + + self.stop() + + rois = self.getRois() + self.clear() + return rois + + def exec_(self, roiClass): # Qt5-like compatibility + return self.exec(roiClass) + + def quit(self): + """Stop a blocking :meth:`exec` and call :meth:`stop`""" + if self._eventLoop is not None: + self._eventLoop.quit() + self._eventLoop = None + self.stop() + + +class InteractiveRegionOfInterestManager(RegionOfInterestManager): + """RegionOfInterestManager with features for use from interpreter. + + It is meant to be used through the :meth:`exec`. + It provides some messages to display in a status bar and + different modes to end blocking calls to :meth:`exec`. + + :param parent: See QObject + """ + + sigMessageChanged = qt.Signal(str) + """Signal emitted when a new message should be displayed to the user + + It provides the message as a str. + """ + + def __init__(self, parent): + super(InteractiveRegionOfInterestManager, self).__init__(parent) + self._maxROI = None + self.__timeoutEndTime = None + self.__message = '' + self.__validationMode = self.ValidationMode.ENTER + self.__execClass = None + + self.sigRoiAdded.connect(self.__added) + self.sigRoiAboutToBeRemoved.connect(self.__aboutToBeRemoved) + self.sigInteractiveModeStarted.connect(self.__started) + self.sigInteractiveModeFinished.connect(self.__finished) + + # Max ROI + + def getMaxRois(self): + """Returns the maximum number of ROIs or None if no limit. + + :rtype: Union[int,None] + """ + return self._maxROI + + def setMaxRois(self, max_): + """Set the maximum number of ROIs. + + :param Union[int,None] max_: The max limit or None for no limit. + :raise ValueError: If there is more ROIs than max value + """ + if max_ is not None: + max_ = int(max_) + if max_ <= 0: + raise ValueError('Max limit must be strictly positive') + + if len(self.getRois()) > max_: + raise ValueError( + 'Cannot set max limit: Already too many ROIs') + + self._maxROI = max_ + + def isMaxRois(self): + """Returns True if the maximum number of ROIs is reached. + + :rtype: bool + """ + max_ = self.getMaxRois() + return max_ is not None and len(self.getRois()) >= max_ + + # Validation mode + + @enum.unique + class ValidationMode(enum.Enum): + """Mode of validation to leave blocking :meth:`exec`""" + + AUTO = 'auto' + """Automatically ends the interactive mode once + the user terminates the last ROI shape.""" + + ENTER = 'enter' + """Ends the interactive mode when the *Enter* key is pressed.""" + + AUTO_ENTER = 'auto_enter' + """Ends the interactive mode when reaching max ROIs or + when the *Enter* key is pressed. + """ + + NONE = 'none' + """Do not provide the user a way to end the interactive mode. + + The end of :meth:`exec` is done through :meth:`quit` or timeout. + """ + + def getValidationMode(self): + """Returns the interactive mode validation in use. + + :rtype: ValidationMode + """ + return self.__validationMode + + def setValidationMode(self, mode): + """Set the way to perform interactive mode validation. + + See :class:`ValidationMode` enumeration for the supported + validation modes. + + :param ValidationMode mode: The interactive mode validation to use. + """ + assert isinstance(mode, self.ValidationMode) + if mode != self.__validationMode: + self.__validationMode = mode + + if self.isExec(): + if (self.isMaxRois() and self.getValidationMode() in + (self.ValidationMode.AUTO, + self.ValidationMode.AUTO_ENTER)): + self.quit() + + self.__updateMessage() + + def eventFilter(self, obj, event): + if event.type() == qt.QEvent.Hide: + self.quit() + + if event.type() == qt.QEvent.KeyPress: + key = event.key() + if (key in (qt.Qt.Key_Return, qt.Qt.Key_Enter) and + self.getValidationMode() in ( + self.ValidationMode.ENTER, + self.ValidationMode.AUTO_ENTER)): + # Stop on return key pressed + self.quit() + return True # Stop further handling of this keys + + if (key in (qt.Qt.Key_Delete, qt.Qt.Key_Backspace) or ( + key == qt.Qt.Key_Z and + event.modifiers() & qt.Qt.ControlModifier)): + rois = self.getRois() + if rois: # Something to undo + self.removeRoi(rois[-1]) + # Stop further handling of keys if something was undone + return True + + return super(InteractiveRegionOfInterestManager, self).eventFilter(obj, event) + + # Message API + + def getMessage(self): + """Returns the current status message. + + This message is meant to be displayed in a status bar. + + :rtype: str + """ + if self.__timeoutEndTime is None: + return self.__message + else: + remaining = self.__timeoutEndTime - time.time() + return self.__message + (' - %d seconds remaining' % + max(1, int(remaining))) + + # Listen to ROI updates + + def __added(self, *args, **kwargs): + """Handle new ROI added""" + max_ = self.getMaxRois() + if max_ is not None: + # When reaching max number of ROIs, redo last one + while len(self.getRois()) > max_: + self.removeRoi(self.getRois()[-2]) + + self.__updateMessage() + if (self.isMaxRois() and + self.getValidationMode() in (self.ValidationMode.AUTO, + self.ValidationMode.AUTO_ENTER)): + self.quit() + + def __aboutToBeRemoved(self, *args, **kwargs): + """Handle removal of a ROI""" + # RegionOfInterest not removed yet + self.__updateMessage(nbrois=len(self.getRois()) - 1) + + def __started(self, roiKind): + """Handle interactive mode started""" + self.__updateMessage() + + def __finished(self): + """Handle interactive mode finished""" + self.__updateMessage() + + def __updateMessage(self, nbrois=None): + """Update message""" + if not self.isExec(): + message = 'Done' + + elif not self.isStarted(): + message = 'Use %s ROI edition mode' % self.__execClass + + else: + if nbrois is None: + nbrois = len(self.getRois()) + + name = self.__execClass._getShortName() + + max_ = self.getMaxRois() + if max_ is None: + message = 'Select %ss (%d selected)' % (name, nbrois) + + elif max_ <= 1: + message = 'Select a %s' % name + else: + message = 'Select %d/%d %ss' % (nbrois, max_, name) + + if (self.getValidationMode() == self.ValidationMode.ENTER and + self.isMaxRois()): + message += ' - Press Enter to confirm' + + if message != self.__message: + self.__message = message + # Use getMessage to add timeout message + self.sigMessageChanged.emit(self.getMessage()) + + # Handle blocking call + + def __timeoutUpdate(self): + """Handle update of timeout""" + if (self.__timeoutEndTime is not None and + (self.__timeoutEndTime - time.time()) > 0): + self.sigMessageChanged.emit(self.getMessage()) + else: # Stop interactive mode and message timer + timer = self.sender() + if timer is not None: + timer.stop() + self.__timeoutEndTime = None + self.quit() + + def isExec(self): + """Returns True if :meth:`exec` is currently running. + + :rtype: bool""" + return self.__execClass is not None + + def exec(self, roiClass, timeout=0): + """Block until ROI selection is done or timeout is elapsed. + + :meth:`quit` also ends this blocking call. + + :param class roiClass: The class of the ROI which have to be created. + See `silx.gui.plot.items.roi`. + :param int timeout: Maximum duration in seconds to block. + Default: No timeout + :return: The list of ROIs + :rtype: List[RegionOfInterest] + """ + plot = self.parent() + if plot is None: + return + + self.__execClass = roiClass + + plot.installEventFilter(self) + + if timeout > 0: + self.__timeoutEndTime = time.time() + timeout + timer = qt.QTimer(self) + timer.timeout.connect(self.__timeoutUpdate) + timer.start(1000) + + rois = super(InteractiveRegionOfInterestManager, self).exec(roiClass) + + timer.stop() + self.__timeoutEndTime = None + + else: + rois = super(InteractiveRegionOfInterestManager, self).exec(roiClass) + + plot.removeEventFilter(self) + + self.__execClass = None + self.__updateMessage() + + return rois + + def exec_(self, roiClass, timeout=0): # Qt5-like compatibility + return self.exec(roiClass, timeout) + + +class _DeleteRegionOfInterestToolButton(qt.QToolButton): + """Tool button deleting a ROI object + + :param parent: See QWidget + :param RegionOfInterest roi: The ROI to delete + """ + + def __init__(self, parent, roi): + super(_DeleteRegionOfInterestToolButton, self).__init__(parent) + self.setIcon(icons.getQIcon('remove')) + self.setToolTip("Remove this ROI") + self.__roiRef = roi if roi is None else weakref.ref(roi) + self.clicked.connect(self.__clicked) + + def __clicked(self, checked): + """Handle button clicked""" + roi = None if self.__roiRef is None else self.__roiRef() + if roi is not None: + manager = roi.parent() + if manager is not None: + manager.removeRoi(roi) + self.__roiRef = None + + +class RegionOfInterestTableWidget(qt.QTableWidget): + """Widget displaying the ROIs of a :class:`RegionOfInterestManager`""" + + def __init__(self, parent=None): + super(RegionOfInterestTableWidget, self).__init__(parent) + self._roiManagerRef = None + + headers = ['Label', 'Edit', 'Kind', 'Coordinates', ''] + self.setColumnCount(len(headers)) + self.setHorizontalHeaderLabels(headers) + + horizontalHeader = self.horizontalHeader() + horizontalHeader.setDefaultAlignment(qt.Qt.AlignLeft) + + horizontalHeader.setSectionResizeMode(0, qt.QHeaderView.Interactive) + horizontalHeader.setSectionResizeMode(1, qt.QHeaderView.ResizeToContents) + horizontalHeader.setSectionResizeMode(2, qt.QHeaderView.ResizeToContents) + horizontalHeader.setSectionResizeMode(3, qt.QHeaderView.Stretch) + horizontalHeader.setSectionResizeMode(4, qt.QHeaderView.ResizeToContents) + + verticalHeader = self.verticalHeader() + verticalHeader.setVisible(False) + + self.setSelectionMode(qt.QAbstractItemView.NoSelection) + self.setFocusPolicy(qt.Qt.NoFocus) + + self.itemChanged.connect(self.__itemChanged) + + def __itemChanged(self, item): + """Handle item updates""" + column = item.column() + index = item.data(qt.Qt.UserRole) + + if index is not None: + manager = self.getRegionOfInterestManager() + roi = manager.getRois()[index] + else: + return + + if column == 0: + # First collect information from item, then update ROI + # Otherwise, this causes issues issues + checked = item.checkState() == qt.Qt.Checked + text= item.text() + roi.setVisible(checked) + roi.setName(text) + elif column == 1: + roi.setEditable(item.checkState() == qt.Qt.Checked) + elif column in (2, 3, 4): + pass # TODO + else: + logger.error('Unhandled column %d', column) + + def setRegionOfInterestManager(self, manager): + """Set the :class:`RegionOfInterestManager` object to sync with + + :param RegionOfInterestManager manager: + """ + assert manager is None or isinstance(manager, RegionOfInterestManager) + + previousManager = self.getRegionOfInterestManager() + + if previousManager is not None: + previousManager.sigRoiChanged.disconnect(self._sync) + self.setRowCount(0) + + self._roiManagerRef = weakref.ref(manager) + + self._sync() + + if manager is not None: + manager.sigRoiChanged.connect(self._sync) + + def _getReadableRoiDescription(self, roi): + """Returns modelisation of a ROI as a readable sequence of values. + + :rtype: str + """ + text = str(roi) + try: + # Extract the params from syntax "CLASSNAME(PARAMS)" + elements = text.split("(", 1) + if len(elements) != 2: + return text + result = elements[1] + result = result.strip() + if not result.endswith(")"): + return text + result = result[0:-1] + # Capitalize each words + result = result.title() + return result + except Exception: + logger.debug("Backtrace", exc_info=True) + return text + + def _sync(self): + """Update widget content according to ROI manger""" + manager = self.getRegionOfInterestManager() + + if manager is None: + self.setRowCount(0) + return + + rois = manager.getRois() + + self.setRowCount(len(rois)) + for index, roi in enumerate(rois): + baseFlags = qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled + + # Label and visible + label = roi.getName() + item = qt.QTableWidgetItem(label) + item.setFlags(baseFlags | qt.Qt.ItemIsEditable | qt.Qt.ItemIsUserCheckable) + item.setData(qt.Qt.UserRole, index) + item.setCheckState( + qt.Qt.Checked if roi.isVisible() else qt.Qt.Unchecked) + self.setItem(index, 0, item) + + # Editable + item = qt.QTableWidgetItem() + item.setFlags(baseFlags | qt.Qt.ItemIsUserCheckable) + item.setData(qt.Qt.UserRole, index) + item.setCheckState( + qt.Qt.Checked if roi.isEditable() else qt.Qt.Unchecked) + self.setItem(index, 1, item) + item.setTextAlignment(qt.Qt.AlignCenter) + item.setText(None) + + # Kind + label = roi._getShortName() + if label is None: + # Default value if kind is not overrided + label = roi.__class__.__name__ + item = qt.QTableWidgetItem(label.capitalize()) + item.setFlags(baseFlags) + self.setItem(index, 2, item) + + item = qt.QTableWidgetItem() + item.setFlags(baseFlags) + + # Coordinates + text = self._getReadableRoiDescription(roi) + item.setText(text) + self.setItem(index, 3, item) + + # Delete + delBtn = _DeleteRegionOfInterestToolButton(None, roi) + widget = qt.QWidget(self) + layout = qt.QHBoxLayout() + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(0) + widget.setLayout(layout) + layout.addStretch(1) + layout.addWidget(delBtn) + layout.addStretch(1) + self.setCellWidget(index, 4, widget) + + def getRegionOfInterestManager(self): + """Returns the :class:`RegionOfInterestManager` this widget supervise. + + It returns None if not sync with an :class:`RegionOfInterestManager`. + + :rtype: RegionOfInterestManager + """ + return None if self._roiManagerRef is None else self._roiManagerRef() diff --git a/src/silx/gui/plot/tools/test/__init__.py b/src/silx/gui/plot/tools/test/__init__.py new file mode 100644 index 0000000..aa4a601 --- /dev/null +++ b/src/silx/gui/plot/tools/test/__init__.py @@ -0,0 +1,24 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ diff --git a/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py b/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py new file mode 100644 index 0000000..37af10e --- /dev/null +++ b/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py @@ -0,0 +1,113 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "02/08/2018" + + +import unittest + +from silx.gui import qt +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.plot import PlotWindow +from silx.gui.plot.tools import CurveLegendsWidget + + +class TestCurveLegendsWidget(TestCaseQt, ParametricTestCase): + """Tests for CurveLegendsWidget class""" + + def setUp(self): + super(TestCurveLegendsWidget, self).setUp() + self.plot = PlotWindow() + + self.legends = CurveLegendsWidget.CurveLegendsWidget() + self.legends.setPlotWidget(self.plot) + + dock = qt.QDockWidget() + dock.setWindowTitle('Curve Legends') + dock.setWidget(self.legends) + self.plot.addTabbedDockWidget(dock) + + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + def tearDown(self): + del self.legends + self.qapp.processEvents() + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + del self.plot + super(TestCurveLegendsWidget, self).tearDown() + + def _assertNbLegends(self, count): + """Check the number of legends in the CurveLegendsWidget""" + children = self.legends.findChildren(CurveLegendsWidget._LegendWidget) + self.assertEqual(len(children), count) + + def testAddRemoveCurves(self): + """Test CurveLegendsWidget while adding/removing curves""" + self.plot.addCurve((0, 1), (1, 2), legend='a') + self._assertNbLegends(1) + self.plot.addCurve((0, 1), (2, 3), legend='b') + self._assertNbLegends(2) + + # Detached/attach + self.legends.setPlotWidget(None) + self._assertNbLegends(0) + + self.legends.setPlotWidget(self.plot) + self._assertNbLegends(2) + + self.plot.clear() + self._assertNbLegends(0) + + def testUpdateCurves(self): + """Test CurveLegendsWidget while updating curves """ + self.plot.addCurve((0, 1), (1, 2), legend='a') + self._assertNbLegends(1) + self.plot.addCurve((0, 1), (2, 3), legend='b') + self._assertNbLegends(2) + + # Activate curve + self.plot.setActiveCurve('a') + self.qapp.processEvents() + self.plot.setActiveCurve('b') + self.qapp.processEvents() + + # Change curve style + curve = self.plot.getCurve('a') + curve.setLineWidth(2) + for linestyle in (':', '', '--', '-'): + with self.subTest(linestyle=linestyle): + curve.setLineStyle(linestyle) + self.qapp.processEvents() + self.qWait(1000) + + for symbol in ('o', 'd', '', 's'): + with self.subTest(symbol=symbol): + curve.setSymbol(symbol) + self.qapp.processEvents() + self.qWait(1000) diff --git a/src/silx/gui/plot/tools/test/testProfile.py b/src/silx/gui/plot/tools/test/testProfile.py new file mode 100644 index 0000000..829f49e --- /dev/null +++ b/src/silx/gui/plot/tools/test/testProfile.py @@ -0,0 +1,654 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import unittest +import contextlib +import numpy +import logging + +from silx.gui import qt +from silx.utils import deprecation +from silx.utils import testutils + +from silx.gui.utils.testutils import TestCaseQt +from silx.utils.testutils import ParametricTestCase +from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile +from silx.gui.plot.StackView import StackView +from silx.gui.plot.tools.profile import rois +from silx.gui.plot.tools.profile import editors +from silx.gui.plot.items import roi as roi_items +from silx.gui.plot.tools.profile import manager +from silx.gui import plot as silx_plot + +_logger = logging.getLogger(__name__) + + +class TestRois(TestCaseQt): + + def test_init(self): + """Check that the constructor is not called twice""" + roi = rois.ProfileImageVerticalLineROI() + if qt.BINDING == "PyQt5": + # the profile ROI + the shape + self.assertEqual(roi.receivers(roi.sigRegionChanged), 2) + + +class TestInteractions(TestCaseQt): + + @contextlib.contextmanager + def defaultPlot(self): + try: + widget = silx_plot.PlotWidget() + widget.show() + self.qWaitForWindowExposed(widget) + yield widget + finally: + widget.close() + widget = None + self.qWait() + + @contextlib.contextmanager + def imagePlot(self): + try: + widget = silx_plot.Plot2D() + image = numpy.arange(10 * 10).reshape(10, -1) + widget.addImage(image) + widget.show() + self.qWaitForWindowExposed(widget) + yield widget + finally: + widget.close() + widget = None + self.qWait() + + @contextlib.contextmanager + def scatterPlot(self): + try: + widget = silx_plot.ScatterView() + + nbX, nbY = 7, 5 + yy = numpy.atleast_2d(numpy.ones(nbY)).T + xx = numpy.atleast_2d(numpy.ones(nbX)) + positionX = numpy.linspace(10, 50, nbX) * yy + positionX = positionX.reshape(nbX * nbY) + positionY = numpy.atleast_2d(numpy.linspace(20, 60, nbY)).T * xx + positionY = positionY.reshape(nbX * nbY) + values = numpy.arange(nbX * nbY) + + widget.setData(positionX, positionY, values) + widget.resetZoom() + widget.show() + self.qWaitForWindowExposed(widget) + yield widget.getPlotWidget() + finally: + widget.close() + widget = None + self.qWait() + + @contextlib.contextmanager + def stackPlot(self): + try: + widget = silx_plot.StackView() + image = numpy.arange(10 * 10).reshape(10, -1) + cube = numpy.array([image, image, image]) + widget.setStack(cube) + widget.resetZoom() + widget.show() + self.qWaitForWindowExposed(widget) + yield widget.getPlotWidget() + finally: + widget.close() + widget = None + self.qWait() + + def waitPendingOperations(self, proflie): + for _ in range(10): + if not proflie.hasPendingOperations(): + return + self.qWait(100) + _logger.error("The profile manager still have pending operations") + + def genericRoiTest(self, plot, roiClass): + profileManager = manager.ProfileManager(plot, plot) + profileManager.setItemType(image=True, scatter=True) + + try: + action = profileManager.createProfileAction(roiClass, plot) + action.triggered[bool].emit(True) + widget = plot.getWidgetHandle() + + # Do the mouse interaction + pos1 = widget.width() * 0.4, widget.height() * 0.4 + self.mouseMove(widget, pos=pos1) + self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1) + + if issubclass(roiClass, roi_items.LineROI): + pos2 = widget.width() * 0.6, widget.height() * 0.6 + self.mouseMove(widget, pos=pos2) + self.mouseClick(widget, qt.Qt.LeftButton, pos=pos2) + + self.waitPendingOperations(profileManager) + + # Test that something was computed + if issubclass(roiClass, rois._ProfileCrossROI): + self.assertEqual(profileManager._computedProfiles, 2) + elif issubclass(roiClass, roi_items.LineROI): + self.assertGreaterEqual(profileManager._computedProfiles, 1) + else: + self.assertEqual(profileManager._computedProfiles, 1) + + # Test the created ROIs + profileRois = profileManager.getRoiManager().getRois() + if issubclass(roiClass, rois._ProfileCrossROI): + self.assertEqual(len(profileRois), 3) + else: + self.assertEqual(len(profileRois), 1) + # The first one should be the expected one + roi = profileRois[0] + + # Test that something was displayed + if issubclass(roiClass, rois._ProfileCrossROI): + profiles = roi._getLines() + window = profiles[0].getProfileWindow() + self.assertIsNotNone(window) + window = profiles[1].getProfileWindow() + self.assertIsNotNone(window) + else: + window = roi.getProfileWindow() + self.assertIsNotNone(window) + finally: + profileManager.clearProfile() + + def testImageActions(self): + roiClasses = [ + rois.ProfileImageHorizontalLineROI, + rois.ProfileImageVerticalLineROI, + rois.ProfileImageLineROI, + rois.ProfileImageCrossROI, + ] + with self.imagePlot() as plot: + for roiClass in roiClasses: + with self.subTest(roiClass=roiClass): + self.genericRoiTest(plot, roiClass) + + def testScatterActions(self): + roiClasses = [ + rois.ProfileScatterHorizontalLineROI, + rois.ProfileScatterVerticalLineROI, + rois.ProfileScatterLineROI, + rois.ProfileScatterCrossROI, + rois.ProfileScatterHorizontalSliceROI, + rois.ProfileScatterVerticalSliceROI, + rois.ProfileScatterCrossSliceROI, + ] + with self.scatterPlot() as plot: + for roiClass in roiClasses: + with self.subTest(roiClass=roiClass): + self.genericRoiTest(plot, roiClass) + + def testStackActions(self): + roiClasses = [ + rois.ProfileImageStackHorizontalLineROI, + rois.ProfileImageStackVerticalLineROI, + rois.ProfileImageStackLineROI, + rois.ProfileImageStackCrossROI, + ] + with self.stackPlot() as plot: + for roiClass in roiClasses: + with self.subTest(roiClass=roiClass): + self.genericRoiTest(plot, roiClass) + + def genericEditorTest(self, plot, roi, editor): + if isinstance(editor, editors._NoProfileRoiEditor): + pass + elif isinstance(editor, editors._DefaultImageStackProfileRoiEditor): + # GUI to ROI + editor._lineWidth.setValue(2) + self.assertEqual(roi.getProfileLineWidth(), 2) + editor._methodsButton.setMethod("sum") + self.assertEqual(roi.getProfileMethod(), "sum") + editor._profileDim.setDimension(1) + self.assertEqual(roi.getProfileType(), "1D") + # ROI to GUI + roi.setProfileLineWidth(3) + self.assertEqual(editor._lineWidth.value(), 3) + roi.setProfileMethod("mean") + self.assertEqual(editor._methodsButton.getMethod(), "mean") + roi.setProfileType("2D") + self.assertEqual(editor._profileDim.getDimension(), 2) + elif isinstance(editor, editors._DefaultImageProfileRoiEditor): + # GUI to ROI + editor._lineWidth.setValue(2) + self.assertEqual(roi.getProfileLineWidth(), 2) + editor._methodsButton.setMethod("sum") + self.assertEqual(roi.getProfileMethod(), "sum") + # ROI to GUI + roi.setProfileLineWidth(3) + self.assertEqual(editor._lineWidth.value(), 3) + roi.setProfileMethod("mean") + self.assertEqual(editor._methodsButton.getMethod(), "mean") + elif isinstance(editor, editors._DefaultScatterProfileRoiEditor): + # GUI to ROI + editor._nPoints.setValue(100) + self.assertEqual(roi.getNPoints(), 100) + # ROI to GUI + roi.setNPoints(200) + self.assertEqual(editor._nPoints.value(), 200) + else: + assert False + + def testEditors(self): + roiClasses = [ + (rois.ProfileImageHorizontalLineROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileImageVerticalLineROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileImageLineROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileImageCrossROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileScatterHorizontalLineROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterVerticalLineROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterLineROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterCrossROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterHorizontalSliceROI, editors._NoProfileRoiEditor), + (rois.ProfileScatterVerticalSliceROI, editors._NoProfileRoiEditor), + (rois.ProfileScatterCrossSliceROI, editors._NoProfileRoiEditor), + (rois.ProfileImageStackHorizontalLineROI, editors._DefaultImageStackProfileRoiEditor), + (rois.ProfileImageStackVerticalLineROI, editors._DefaultImageStackProfileRoiEditor), + (rois.ProfileImageStackLineROI, editors._DefaultImageStackProfileRoiEditor), + (rois.ProfileImageStackCrossROI, editors._DefaultImageStackProfileRoiEditor), + ] + with self.defaultPlot() as plot: + profileManager = manager.ProfileManager(plot, plot) + editorAction = profileManager.createEditorAction(parent=plot) + for roiClass, editorClass in roiClasses: + with self.subTest(roiClass=roiClass): + roi = roiClass() + roi._setProfileManager(profileManager) + try: + # Force widget creation + menu = qt.QMenu(plot) + menu.addAction(editorAction) + widgets = editorAction.createdWidgets() + self.assertGreater(len(widgets), 0) + + editorAction.setProfileRoi(roi) + editorWidget = editorAction._getEditor(widgets[0]) + self.assertIsInstance(editorWidget, editorClass) + self.genericEditorTest(plot, roi, editorWidget) + finally: + editorAction.setProfileRoi(None) + menu.deleteLater() + menu = None + self.qapp.processEvents() + + +class TestProfileToolBar(TestCaseQt, ParametricTestCase): + """Tests for ProfileToolBar widget.""" + + def setUp(self): + super(TestProfileToolBar, self).setUp() + self.plot = PlotWindow() + self.toolBar = Profile.ProfileToolBar(plot=self.plot) + self.plot.addToolBar(self.toolBar) + + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + self.mouseMove(self.plot) # Move to center + self.qapp.processEvents() + deprecation.FORCE = True + + def tearDown(self): + deprecation.FORCE = False + self.qapp.processEvents() + profileManager = self.toolBar.getProfileManager() + profileManager.clearProfile() + profileManager = None + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + del self.plot + del self.toolBar + + super(TestProfileToolBar, self).tearDown() + + def testAlignedProfile(self): + """Test horizontal and vertical profile, without and with image""" + # Use Plot backend widget to submit mouse events + widget = self.plot.getWidgetHandle() + for method in ('sum', 'mean'): + with self.subTest(method=method): + # 2 positions to use for mouse events + pos1 = widget.width() * 0.4, widget.height() * 0.4 + pos2 = widget.width() * 0.6, widget.height() * 0.6 + + for action in (self.toolBar.hLineAction, self.toolBar.vLineAction): + with self.subTest(mode=action.text()): + # Trigger tool button for mode + action.trigger() + # Without image + self.mouseMove(widget, pos=pos1) + self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1) + + # with image + self.plot.addImage( + numpy.arange(100 * 100).reshape(100, -1)) + self.mousePress(widget, qt.Qt.LeftButton, pos=pos1) + self.mouseMove(widget, pos=pos2) + self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2) + + self.mouseMove(widget) + self.mouseClick(widget, qt.Qt.LeftButton) + + manager = self.toolBar.getProfileManager() + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + @testutils.validate_logging(deprecation.depreclog.name, warning=4) + def testDiagonalProfile(self): + """Test diagonal profile, without and with image""" + # Use Plot backend widget to submit mouse events + widget = self.plot.getWidgetHandle() + + self.plot.addImage( + numpy.arange(100 * 100).reshape(100, -1)) + + for method in ('sum', 'mean'): + with self.subTest(method=method): + # 2 positions to use for mouse events + pos1 = widget.width() * 0.4, widget.height() * 0.4 + pos2 = widget.width() * 0.6, widget.height() * 0.6 + + # Trigger tool button for diagonal profile mode + self.toolBar.lineAction.trigger() + + # draw profile line + widget.setFocus(qt.Qt.OtherFocusReason) + self.mouseMove(widget, pos=pos1) + self.qWait(100) + self.mousePress(widget, qt.Qt.LeftButton, pos=pos1) + self.qWait(100) + self.mouseMove(widget, pos=pos2) + self.qWait(100) + self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2) + self.qWait(100) + + manager = self.toolBar.getProfileManager() + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + roi = manager.getCurrentRoi() + self.assertIsNotNone(roi) + roi.setProfileLineWidth(3) + roi.setProfileMethod(method) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + curveItem = self.toolBar.getProfilePlot().getAllCurves()[0] + if method == 'sum': + self.assertTrue(curveItem.getData()[1].max() > 10000) + elif method == 'mean': + self.assertTrue(curveItem.getData()[1].max() < 10000) + + # Remove the ROI so the profile window is also removed + roiManager = manager.getRoiManager() + roiManager.removeRoi(roi) + self.qWait(100) + + +class TestDeprecatedProfileToolBar(TestCaseQt): + """Tests old features of the ProfileToolBar widget.""" + + def setUp(self): + self.plot = None + super(TestDeprecatedProfileToolBar, self).setUp() + + def tearDown(self): + if self.plot is not None: + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + self.plot = None + self.qWait() + + super(TestDeprecatedProfileToolBar, self).tearDown() + + @testutils.validate_logging(deprecation.depreclog.name, warning=2) + def testCustomProfileWindow(self): + from silx.gui.plot import ProfileMainWindow + + self.plot = PlotWindow() + profileWindow = ProfileMainWindow.ProfileMainWindow(self.plot) + toolBar = Profile.ProfileToolBar(parent=self.plot, + plot=self.plot, + profileWindow=profileWindow) + + self.plot.show() + self.qWaitForWindowExposed(self.plot) + profileWindow.show() + self.qWaitForWindowExposed(profileWindow) + self.qapp.processEvents() + + self.plot.addImage(numpy.arange(10 * 10).reshape(10, -1)) + profile = rois.ProfileImageHorizontalLineROI() + profile.setPosition(5) + toolBar.getProfileManager().getRoiManager().addRoi(profile) + toolBar.getProfileManager().getRoiManager().setCurrentRoi(profile) + + for _ in range(20): + self.qWait(200) + if not toolBar.getProfileManager().hasPendingOperations(): + break + + # There is a displayed profile + self.assertIsNotNone(profileWindow.getProfile()) + self.assertIs(toolBar.getProfileMainWindow(), profileWindow) + + # There is nothing anymore but the window is still there + toolBar.getProfileManager().clearProfile() + self.qapp.processEvents() + self.assertIsNone(profileWindow.getProfile()) + + +class TestProfile3DToolBar(TestCaseQt): + """Tests for Profile3DToolBar widget. + """ + def setUp(self): + super(TestProfile3DToolBar, self).setUp() + self.plot = StackView() + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + self.plot.setStack(numpy.array([ + [[0, 1, 2], [3, 4, 5]], + [[6, 7, 8], [9, 10, 11]], + [[12, 13, 14], [15, 16, 17]] + ])) + deprecation.FORCE = True + + def tearDown(self): + deprecation.FORCE = False + profileManager = self.plot.getProfileToolbar().getProfileManager() + profileManager.clearProfile() + profileManager = None + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + self.plot = None + + super(TestProfile3DToolBar, self).tearDown() + + @testutils.validate_logging(deprecation.depreclog.name, warning=2) + def testMethodProfile2D(self): + """Test that the profile can have a different method if we want to + compute then in 1D or in 2D""" + + toolBar = self.plot.getProfileToolbar() + + toolBar.vLineAction.trigger() + plot2D = self.plot.getPlotWidget().getWidgetHandle() + pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5 + self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1) + + manager = toolBar.getProfileManager() + roi = manager.getCurrentRoi() + roi.setProfileMethod("mean") + roi.setProfileType("2D") + roi.setProfileLineWidth(3) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + # check 2D 'mean' profile + profilePlot = toolBar.getProfilePlot() + data = profilePlot.getAllImages()[0].getData() + expected = numpy.array([[1, 4], [7, 10], [13, 16]]) + numpy.testing.assert_almost_equal(data, expected) + + @testutils.validate_logging(deprecation.depreclog.name, warning=2) + def testMethodSumLine(self): + """Simple interaction test to make sure the sum is correctly computed + """ + toolBar = self.plot.getProfileToolbar() + + toolBar.lineAction.trigger() + plot2D = self.plot.getPlotWidget().getWidgetHandle() + pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2 + pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8 + + self.mouseMove(plot2D, pos=pos1) + self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1) + self.mouseMove(plot2D, pos=pos2) + self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2) + + manager = toolBar.getProfileManager() + roi = manager.getCurrentRoi() + roi.setProfileMethod("sum") + roi.setProfileType("2D") + roi.setProfileLineWidth(3) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + # check 2D 'sum' profile + profilePlot = toolBar.getProfilePlot() + data = profilePlot.getAllImages()[0].getData() + expected = numpy.array([[3, 12], [21, 30], [39, 48]]) + numpy.testing.assert_almost_equal(data, expected) + + +class TestGetProfilePlot(TestCaseQt): + + def setUp(self): + self.plot = None + super(TestGetProfilePlot, self).setUp() + + def tearDown(self): + if self.plot is not None: + manager = self.plot.getProfileToolbar().getProfileManager() + manager.clearProfile() + manager = None + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + self.plot = None + + super(TestGetProfilePlot, self).tearDown() + + def testProfile1D(self): + self.plot = Plot2D() + self.plot.show() + self.qWaitForWindowExposed(self.plot) + self.plot.addImage([[0, 1], [2, 3]]) + + toolBar = self.plot.getProfileToolbar() + + manager = toolBar.getProfileManager() + roiManager = manager.getRoiManager() + + roi = rois.ProfileImageHorizontalLineROI() + roi.setPosition(0.5) + roiManager.addRoi(roi) + roiManager.setCurrentRoi(roi) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + profileWindow = roi.getProfileWindow() + self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow) + self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D) + + def testProfile2D(self): + """Test that the profile plot associated to a stack view is either a + Plot1D or a plot 2D instance.""" + self.plot = StackView() + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + self.plot.setStack(numpy.array([[[0, 1], [2, 3]], + [[4, 5], [6, 7]]])) + + toolBar = self.plot.getProfileToolbar() + + manager = toolBar.getProfileManager() + roiManager = manager.getRoiManager() + + roi = rois.ProfileImageStackHorizontalLineROI() + roi.setPosition(0.5) + roi.setProfileType("2D") + roiManager.addRoi(roi) + roiManager.setCurrentRoi(roi) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + profileWindow = roi.getProfileWindow() + self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow) + self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot2D) + + roi.setProfileType("1D") + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + profileWindow = roi.getProfileWindow() + self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow) + self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D) diff --git a/src/silx/gui/plot/tools/test/testROI.py b/src/silx/gui/plot/tools/test/testROI.py new file mode 100644 index 0000000..21697d1 --- /dev/null +++ b/src/silx/gui/plot/tools/test/testROI.py @@ -0,0 +1,682 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import unittest +import numpy.testing + +from silx.gui import qt +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt, SignalListener +from silx.gui.plot import PlotWindow +import silx.gui.plot.items.roi as roi_items +from silx.gui.plot.tools import roi + + +class TestRoiItems(TestCaseQt): + + def testLine_geometry(self): + item = roi_items.LineROI() + startPoint = numpy.array([1, 2]) + endPoint = numpy.array([3, 4]) + item.setEndPoints(startPoint, endPoint) + numpy.testing.assert_allclose(item.getEndPoints()[0], startPoint) + numpy.testing.assert_allclose(item.getEndPoints()[1], endPoint) + + def testHLine_geometry(self): + item = roi_items.HorizontalLineROI() + item.setPosition(15) + self.assertEqual(item.getPosition(), 15) + + def testVLine_geometry(self): + item = roi_items.VerticalLineROI() + item.setPosition(15) + self.assertEqual(item.getPosition(), 15) + + def testPoint_geometry(self): + point = numpy.array([1, 2]) + item = roi_items.PointROI() + item.setPosition(point) + numpy.testing.assert_allclose(item.getPosition(), point) + + def testRectangle_originGeometry(self): + origin = numpy.array([0, 0]) + size = numpy.array([10, 20]) + center = numpy.array([5, 10]) + item = roi_items.RectangleROI() + item.setGeometry(origin=origin, size=size) + numpy.testing.assert_allclose(item.getOrigin(), origin) + numpy.testing.assert_allclose(item.getSize(), size) + numpy.testing.assert_allclose(item.getCenter(), center) + + def testRectangle_centerGeometry(self): + origin = numpy.array([0, 0]) + size = numpy.array([10, 20]) + center = numpy.array([5, 10]) + item = roi_items.RectangleROI() + item.setGeometry(center=center, size=size) + numpy.testing.assert_allclose(item.getOrigin(), origin) + numpy.testing.assert_allclose(item.getSize(), size) + numpy.testing.assert_allclose(item.getCenter(), center) + + def testRectangle_setCenterGeometry(self): + origin = numpy.array([0, 0]) + size = numpy.array([10, 20]) + item = roi_items.RectangleROI() + item.setGeometry(origin=origin, size=size) + newCenter = numpy.array([0, 0]) + item.setCenter(newCenter) + expectedOrigin = numpy.array([-5, -10]) + numpy.testing.assert_allclose(item.getOrigin(), expectedOrigin) + numpy.testing.assert_allclose(item.getCenter(), newCenter) + numpy.testing.assert_allclose(item.getSize(), size) + + def testRectangle_setOriginGeometry(self): + origin = numpy.array([0, 0]) + size = numpy.array([10, 20]) + item = roi_items.RectangleROI() + item.setGeometry(origin=origin, size=size) + newOrigin = numpy.array([10, 10]) + item.setOrigin(newOrigin) + expectedCenter = numpy.array([15, 20]) + numpy.testing.assert_allclose(item.getOrigin(), newOrigin) + numpy.testing.assert_allclose(item.getCenter(), expectedCenter) + numpy.testing.assert_allclose(item.getSize(), size) + + def testCircle_geometry(self): + center = numpy.array([0, 0]) + radius = 10. + item = roi_items.CircleROI() + item.setGeometry(center=center, radius=radius) + numpy.testing.assert_allclose(item.getCenter(), center) + numpy.testing.assert_allclose(item.getRadius(), radius) + + def testCircle_setCenter(self): + center = numpy.array([0, 0]) + radius = 10. + item = roi_items.CircleROI() + item.setGeometry(center=center, radius=radius) + newCenter = numpy.array([-10, 0]) + item.setCenter(newCenter) + numpy.testing.assert_allclose(item.getCenter(), newCenter) + numpy.testing.assert_allclose(item.getRadius(), radius) + + def testCircle_setRadius(self): + center = numpy.array([0, 0]) + radius = 10. + item = roi_items.CircleROI() + item.setGeometry(center=center, radius=radius) + newRadius = 5.1 + item.setRadius(newRadius) + numpy.testing.assert_allclose(item.getCenter(), center) + numpy.testing.assert_allclose(item.getRadius(), newRadius) + + def testCircle_contains(self): + center = numpy.array([2, -1]) + radius = 1. + item = roi_items.CircleROI() + item.setGeometry(center=center, radius=radius) + self.assertTrue(item.contains([1, -1])) + self.assertFalse(item.contains([0, 0])) + self.assertTrue(item.contains([2, 0])) + self.assertFalse(item.contains([3.01, -1])) + + def testEllipse_contains(self): + center = numpy.array([-2, 0]) + item = roi_items.EllipseROI() + item.setCenter(center) + item.setOrientation(numpy.pi / 4.0) + item.setMajorRadius(2) + item.setMinorRadius(1) + print(item.getMinorRadius(), item.getMajorRadius()) + self.assertFalse(item.contains([0, 0])) + self.assertTrue(item.contains([-1, 1])) + self.assertTrue(item.contains([-3, 0])) + self.assertTrue(item.contains([-2, 0])) + self.assertTrue(item.contains([-2, 1])) + self.assertFalse(item.contains([-4, 1])) + + def testRectangle_isIn(self): + origin = numpy.array([0, 0]) + size = numpy.array([10, 20]) + item = roi_items.RectangleROI() + item.setGeometry(origin=origin, size=size) + self.assertTrue(item.contains(position=(0, 0))) + self.assertTrue(item.contains(position=(2, 14))) + self.assertFalse(item.contains(position=(14, 12))) + + def testPolygon_emptyGeometry(self): + points = numpy.empty((0, 2)) + item = roi_items.PolygonROI() + item.setPoints(points) + numpy.testing.assert_allclose(item.getPoints(), points) + + def testPolygon_geometry(self): + points = numpy.array([[10, 10], [12, 10], [50, 1]]) + item = roi_items.PolygonROI() + item.setPoints(points) + numpy.testing.assert_allclose(item.getPoints(), points) + + def testPolygon_isIn(self): + points = numpy.array([[0, 0], [0, 10], [5, 10]]) + item = roi_items.PolygonROI() + item.setPoints(points) + self.assertTrue(item.contains((0, 0))) + self.assertFalse(item.contains((6, 2))) + self.assertFalse(item.contains((-2, 5))) + self.assertFalse(item.contains((2, -1))) + self.assertFalse(item.contains((8, 1))) + self.assertTrue(item.contains((1, 8))) + + def testArc_getToSetGeometry(self): + """Test that we can use getGeometry as input to setGeometry""" + item = roi_items.ArcROI() + item.setFirstShapePoints(numpy.array([[5, 10], [50, 100]])) + item.setGeometry(*item.getGeometry()) + + def testArc_degenerated_point(self): + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 0, 0, 0, 0 + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + + def testArc_degenerated_line(self): + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 0, 100, numpy.pi, numpy.pi + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + + def testArc_special_circle(self): + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 0, 100, numpy.pi, 3 * numpy.pi + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + numpy.testing.assert_allclose(item.getCenter(), center) + self.assertAlmostEqual(item.getInnerRadius(), innerRadius) + self.assertAlmostEqual(item.getOuterRadius(), outerRadius) + self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0) + self.assertTrue(item.isClosed()) + + def testArc_special_donut(self): + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi, 3 * numpy.pi + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + numpy.testing.assert_allclose(item.getCenter(), center) + self.assertAlmostEqual(item.getInnerRadius(), innerRadius) + self.assertAlmostEqual(item.getOuterRadius(), outerRadius) + self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0) + self.assertTrue(item.isClosed()) + + def testArc_clockwiseGeometry(self): + """Test that we can use getGeometry as input to setGeometry""" + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, numpy.pi + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + numpy.testing.assert_allclose(item.getCenter(), center) + self.assertAlmostEqual(item.getInnerRadius(), innerRadius) + self.assertAlmostEqual(item.getOuterRadius(), outerRadius) + self.assertAlmostEqual(item.getStartAngle(), startAngle) + self.assertAlmostEqual(item.getEndAngle(), endAngle) + self.assertAlmostEqual(item.isClosed(), False) + + def testArc_anticlockwiseGeometry(self): + """Test that we can use getGeometry as input to setGeometry""" + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, -numpy.pi * 0.5 + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + numpy.testing.assert_allclose(item.getCenter(), center) + self.assertAlmostEqual(item.getInnerRadius(), innerRadius) + self.assertAlmostEqual(item.getOuterRadius(), outerRadius) + self.assertAlmostEqual(item.getStartAngle(), startAngle) + self.assertAlmostEqual(item.getEndAngle(), endAngle) + self.assertAlmostEqual(item.isClosed(), False) + + def testHRange_geometry(self): + item = roi_items.HorizontalRangeROI() + vmin = 1 + vmax = 3 + item.setRange(vmin, vmax) + self.assertAlmostEqual(item.getMin(), vmin) + self.assertAlmostEqual(item.getMax(), vmax) + self.assertAlmostEqual(item.getCenter(), 2) + + +class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): + """Tests for RegionOfInterestManager class""" + + def setUp(self): + super(TestRegionOfInterestManager, self).setUp() + self.plot = PlotWindow() + + self.roiTableWidget = roi.RegionOfInterestTableWidget() + dock = qt.QDockWidget() + dock.setWidget(self.roiTableWidget) + self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + def tearDown(self): + del self.roiTableWidget + self.qapp.processEvents() + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + del self.plot + super(TestRegionOfInterestManager, self).tearDown() + + def test(self): + """Test ROI of different shapes""" + tests = ( # shape, points=[list of (x, y), list of (x, y)] + (roi_items.PointROI, numpy.array(([(10., 15.)], [(20., 25.)]))), + (roi_items.RectangleROI, + numpy.array((((1., 10.), (11., 20.)), + ((2., 3.), (12., 13.))))), + (roi_items.PolygonROI, + numpy.array((((0., 1.), (0., 10.), (10., 0.)), + ((5., 6.), (5., 16.), (15., 6.))))), + (roi_items.LineROI, + numpy.array((((10., 20.), (10., 30.)), + ((30., 40.), (30., 50.))))), + (roi_items.HorizontalLineROI, + numpy.array((((10., 20.), (10., 30.)), + ((30., 40.), (30., 50.))))), + (roi_items.VerticalLineROI, + numpy.array((((10., 20.), (10., 30.)), + ((30., 40.), (30., 50.))))), + (roi_items.HorizontalLineROI, + numpy.array((((10., 20.), (10., 30.)), + ((30., 40.), (30., 50.))))), + ) + + for roiClass, points in tests: + with self.subTest(roiClass=roiClass): + manager = roi.RegionOfInterestManager(self.plot) + self.roiTableWidget.setRegionOfInterestManager(manager) + manager.start(roiClass) + + self.assertEqual(manager.getRois(), ()) + + finishListener = SignalListener() + manager.sigInteractiveModeFinished.connect(finishListener) + + changedListener = SignalListener() + manager.sigRoiChanged.connect(changedListener) + + # Add a point + r = roiClass() + r.setFirstShapePoints(points[0]) + manager.addRoi(r) + self.qapp.processEvents() + self.assertTrue(len(manager.getRois()), 1) + self.assertEqual(changedListener.callCount(), 1) + + # Remove it + manager.removeRoi(manager.getRois()[0]) + self.assertEqual(manager.getRois(), ()) + self.assertEqual(changedListener.callCount(), 2) + + # Add two point + r = roiClass() + r.setFirstShapePoints(points[0]) + manager.addRoi(r) + self.qapp.processEvents() + r = roiClass() + r.setFirstShapePoints(points[1]) + manager.addRoi(r) + self.qapp.processEvents() + self.assertTrue(len(manager.getRois()), 2) + self.assertEqual(changedListener.callCount(), 4) + + # Reset it + result = manager.clear() + self.assertTrue(result) + self.assertEqual(manager.getRois(), ()) + self.assertEqual(changedListener.callCount(), 5) + + changedListener.clear() + + # Add two point + r = roiClass() + r.setFirstShapePoints(points[0]) + manager.addRoi(r) + self.qapp.processEvents() + r = roiClass() + r.setFirstShapePoints(points[1]) + manager.addRoi(r) + self.qapp.processEvents() + self.assertTrue(len(manager.getRois()), 2) + self.assertEqual(changedListener.callCount(), 2) + + # stop + result = manager.stop() + self.assertTrue(result) + self.assertTrue(len(manager.getRois()), 1) + self.qapp.processEvents() + self.assertEqual(finishListener.callCount(), 1) + + manager.clear() + + def testRoiDisplay(self): + rois = [] + + # Line + item = roi_items.LineROI() + startPoint = numpy.array([1, 2]) + endPoint = numpy.array([3, 4]) + item.setEndPoints(startPoint, endPoint) + rois.append(item) + # Horizontal line + item = roi_items.HorizontalLineROI() + item.setPosition(15) + rois.append(item) + # Vertical line + item = roi_items.VerticalLineROI() + item.setPosition(15) + rois.append(item) + # Point + item = roi_items.PointROI() + point = numpy.array([1, 2]) + item.setPosition(point) + rois.append(item) + # Rectangle + item = roi_items.RectangleROI() + origin = numpy.array([0, 0]) + size = numpy.array([10, 20]) + item.setGeometry(origin=origin, size=size) + rois.append(item) + # Polygon + item = roi_items.PolygonROI() + points = numpy.array([[10, 10], [12, 10], [50, 1]]) + item.setPoints(points) + rois.append(item) + # Degenerated polygon: No points + item = roi_items.PolygonROI() + points = numpy.empty((0, 2)) + item.setPoints(points) + rois.append(item) + # Degenerated polygon: A single point + item = roi_items.PolygonROI() + points = numpy.array([[5, 10]]) + item.setPoints(points) + rois.append(item) + # Degenerated arc: it's a point + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 0, 0, 0, 0 + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + rois.append(item) + # Degenerated arc: it's a line + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 0, 100, numpy.pi, numpy.pi + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + rois.append(item) + # Special arc: it's a donut + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi, 3 * numpy.pi + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + rois.append(item) + # Arc + item = roi_items.ArcROI() + center = numpy.array([10, 20]) + innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, numpy.pi + item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) + rois.append(item) + # Horizontal Range + item = roi_items.HorizontalRangeROI() + item.setRange(-1, 3) + rois.append(item) + + manager = roi.RegionOfInterestManager(self.plot) + self.roiTableWidget.setRegionOfInterestManager(manager) + for item in rois: + with self.subTest(roi=str(item)): + manager.addRoi(item) + self.qapp.processEvents() + item.setEditable(True) + self.qapp.processEvents() + item.setEditable(False) + self.qapp.processEvents() + manager.removeRoi(item) + self.qapp.processEvents() + + def testSelectionProxy(self): + item1 = roi_items.PointROI() + item1.setSelectable(True) + item2 = roi_items.PointROI() + item2.setSelectable(True) + item1.setFocusProxy(item2) + manager = roi.RegionOfInterestManager(self.plot) + manager.setCurrentRoi(item1) + self.assertIs(manager.getCurrentRoi(), item2) + + def testRemovedSelection(self): + item1 = roi_items.PointROI() + item1.setSelectable(True) + manager = roi.RegionOfInterestManager(self.plot) + manager.addRoi(item1) + manager.setCurrentRoi(item1) + manager.removeRoi(item1) + self.assertIs(manager.getCurrentRoi(), None) + + def testMaxROI(self): + """Test Max ROI""" + origin1 = numpy.array([1., 10.]) + size1 = numpy.array([10., 10.]) + origin2 = numpy.array([2., 3.]) + size2 = numpy.array([10., 10.]) + + manager = roi.InteractiveRegionOfInterestManager(self.plot) + self.roiTableWidget.setRegionOfInterestManager(manager) + self.assertEqual(manager.getRois(), ()) + + changedListener = SignalListener() + manager.sigRoiChanged.connect(changedListener) + + # Add two point + item = roi_items.RectangleROI() + item.setGeometry(origin=origin1, size=size1) + manager.addRoi(item) + item = roi_items.RectangleROI() + item.setGeometry(origin=origin2, size=size2) + manager.addRoi(item) + self.qapp.processEvents() + self.assertEqual(changedListener.callCount(), 2) + self.assertEqual(len(manager.getRois()), 2) + + # Try to set max ROI to 1 while there is 2 ROIs + with self.assertRaises(ValueError): + manager.setMaxRois(1) + + manager.clear() + self.assertEqual(len(manager.getRois()), 0) + self.assertEqual(changedListener.callCount(), 3) + + # Set max limit to 1 + manager.setMaxRois(1) + + # Add a point + item = roi_items.RectangleROI() + item.setGeometry(origin=origin1, size=size1) + manager.addRoi(item) + self.qapp.processEvents() + self.assertEqual(changedListener.callCount(), 4) + + # Add a 2nd point while max ROI is 1 + item = roi_items.RectangleROI() + item.setGeometry(origin=origin1, size=size1) + manager.addRoi(item) + self.qapp.processEvents() + self.assertEqual(changedListener.callCount(), 6) + self.assertEqual(len(manager.getRois()), 1) + + def testChangeInteractionMode(self): + """Test change of interaction mode""" + manager = roi.RegionOfInterestManager(self.plot) + self.roiTableWidget.setRegionOfInterestManager(manager) + manager.start(roi_items.PointROI) + + interactiveModeToolBar = self.plot.getInteractiveModeToolBar() + panAction = interactiveModeToolBar.getPanModeAction() + + for roiClass in manager.getSupportedRoiClasses(): + with self.subTest(roiClass=roiClass): + # Change to pan mode + panAction.trigger() + + # Change to interactive ROI mode + action = manager.getInteractionModeAction(roiClass) + action.trigger() + + self.assertEqual(roiClass, manager.getCurrentInteractionModeRoiClass()) + + manager.clear() + + def testLineInteraction(self): + """This test make sure that a ROI based on handles can be edited with + the mouse.""" + xlimit = self.plot.getXAxis().getLimits() + ylimit = self.plot.getYAxis().getLimits() + points = numpy.array([xlimit, ylimit]).T + center = numpy.mean(points, axis=0) + + # Create the line + manager = roi.RegionOfInterestManager(self.plot) + item = roi_items.LineROI() + item.setEndPoints(points[0], points[1]) + item.setEditable(True) + manager.addRoi(item) + self.qapp.processEvents() + + # Drag the center + widget = self.plot.getWidgetHandle() + mx, my = self.plot.dataToPixel(*center) + self.mouseMove(widget, pos=(mx, my)) + self.mousePress(widget, qt.Qt.LeftButton, pos=(mx, my)) + self.mouseMove(widget, pos=(mx, my+25)) + self.mouseMove(widget, pos=(mx, my+50)) + self.mouseRelease(widget, qt.Qt.LeftButton, pos=(mx, my+50)) + + result = numpy.array(item.getEndPoints()) + # x location is still the same + numpy.testing.assert_allclose(points[:, 0], result[:, 0], atol=0.5) + # size is still the same + numpy.testing.assert_allclose(points[1] - points[0], + result[1] - result[0], atol=0.5) + # But Y is not the same + self.assertNotEqual(points[0, 1], result[0, 1]) + self.assertNotEqual(points[1, 1], result[1, 1]) + item = None + manager.clear() + self.qapp.processEvents() + + def testPlotWhenCleared(self): + """PlotWidget.clear should clean up the available ROIs""" + manager = roi.RegionOfInterestManager(self.plot) + item = roi_items.LineROI() + item.setEndPoints((0, 0), (1, 1)) + item.setEditable(True) + manager.addRoi(item) + self.qWait() + try: + # Make sure the test setup is fine + self.assertNotEqual(len(manager.getRois()), 0) + self.assertNotEqual(len(self.plot.getItems()), 0) + + # Call clear and test the expected state + self.plot.clear() + self.assertEqual(len(manager.getRois()), 0) + self.assertEqual(len(self.plot.getItems()), 0) + finally: + # Clean up + manager.clear() + + def testPlotWhenRoiRemoved(self): + """Make sure there is no remaining items in the plot when a ROI is removed""" + manager = roi.RegionOfInterestManager(self.plot) + item = roi_items.LineROI() + item.setEndPoints((0, 0), (1, 1)) + item.setEditable(True) + manager.addRoi(item) + self.qWait() + try: + # Make sure the test setup is fine + self.assertNotEqual(len(manager.getRois()), 0) + self.assertNotEqual(len(self.plot.getItems()), 0) + + # Call clear and test the expected state + manager.removeRoi(item) + self.assertEqual(len(manager.getRois()), 0) + self.assertEqual(len(self.plot.getItems()), 0) + finally: + # Clean up + manager.clear() + + def testArcRoiSwitchMode(self): + """Make sure we can switch mode by clicking on the ROI""" + xlimit = self.plot.getXAxis().getLimits() + ylimit = self.plot.getYAxis().getLimits() + points = numpy.array([xlimit, ylimit]).T + center = numpy.mean(points, axis=0) + size = numpy.abs(points[1] - points[0]) + + # Create the line + manager = roi.RegionOfInterestManager(self.plot) + item = roi_items.ArcROI() + item.setGeometry(center, size[1] / 10, size[1] / 2, 0, 3) + item.setEditable(True) + item.setSelectable(True) + manager.addRoi(item) + self.qapp.processEvents() + + # Initial state + self.assertIs(item.getInteractionMode(), roi_items.ArcROI.ThreePointMode) + self.qWait(500) + + # Click on the center + widget = self.plot.getWidgetHandle() + mx, my = self.plot.dataToPixel(*center) + + # Select the ROI + self.mouseMove(widget, pos=(mx, my)) + self.mouseClick(widget, qt.Qt.LeftButton, pos=(mx, my)) + self.qWait(500) + self.assertIs(item.getInteractionMode(), roi_items.ArcROI.ThreePointMode) + + # Change the mode + self.mouseMove(widget, pos=(mx, my)) + self.mouseClick(widget, qt.Qt.LeftButton, pos=(mx, my)) + self.qWait(500) + self.assertIs(item.getInteractionMode(), roi_items.ArcROI.PolarMode) + + manager.clear() + self.qapp.processEvents() diff --git a/src/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/src/silx/gui/plot/tools/test/testScatterProfileToolBar.py new file mode 100644 index 0000000..582a276 --- /dev/null +++ b/src/silx/gui/plot/tools/test/testScatterProfileToolBar.py @@ -0,0 +1,184 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import unittest +import numpy + +from silx.gui import qt +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.plot import PlotWindow +from silx.gui.plot.tools.profile import manager +from silx.gui.plot.tools.profile import core +from silx.gui.plot.tools.profile import rois + + +class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): + """Tests for ScatterProfileToolBar class""" + + def setUp(self): + super(TestScatterProfileToolBar, self).setUp() + self.plot = PlotWindow() + + self.manager = manager.ProfileManager(plot=self.plot) + self.manager.setItemType(scatter=True) + self.manager.setActiveItemTracking(True) + + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + def tearDown(self): + del self.manager + self.qapp.processEvents() + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + del self.plot + super(TestScatterProfileToolBar, self).tearDown() + + def testHorizontalProfile(self): + """Test ScatterProfileToolBar horizontal profile""" + + roiManager = self.manager.getRoiManager() + + # Add a scatter plot + self.plot.addScatter( + x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) + self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) + self.qapp.processEvents() + + # Set a ROI profile + roi = rois.ProfileScatterHorizontalLineROI() + roi.setPosition(0.5) + roi.setNPoints(8) + roiManager.addRoi(roi) + + # Wait for async interpolator init + for _ in range(20): + self.qWait(200) + if not self.manager.hasPendingOperations(): + break + self.qapp.processEvents() + + window = roi.getProfileWindow() + self.assertIsNotNone(window) + data = window.getProfile() + self.assertIsInstance(data, core.CurveProfileData) + self.assertEqual(len(data.coords), 8) + + # Check that profile has same limits than Plot + xLimits = self.plot.getXAxis().getLimits() + self.assertEqual(data.coords[0], xLimits[0]) + self.assertEqual(data.coords[-1], xLimits[1]) + + # Clear the profile + self.manager.clearProfile() + self.qapp.processEvents() + self.assertIsNone(roi.getProfileWindow()) + + def testVerticalProfile(self): + """Test ScatterProfileToolBar vertical profile""" + + roiManager = self.manager.getRoiManager() + + # Add a scatter plot + self.plot.addScatter( + x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) + self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) + self.qapp.processEvents() + + # Set a ROI profile + roi = rois.ProfileScatterVerticalLineROI() + roi.setPosition(0.5) + roi.setNPoints(8) + roiManager.addRoi(roi) + + # Wait for async interpolator init + for _ in range(10): + self.qWait(200) + if not self.manager.hasPendingOperations(): + break + + window = roi.getProfileWindow() + self.assertIsNotNone(window) + data = window.getProfile() + self.assertIsInstance(data, core.CurveProfileData) + self.assertEqual(len(data.coords), 8) + + # Check that profile has same limits than Plot + yLimits = self.plot.getYAxis().getLimits() + self.assertEqual(data.coords[0], yLimits[0]) + self.assertEqual(data.coords[-1], yLimits[1]) + + # Check that profile limits are updated when changing limits + self.plot.getYAxis().setLimits(yLimits[0] + 1, yLimits[1] + 10) + + # Wait for async interpolator init + for _ in range(10): + self.qWait(200) + if not self.manager.hasPendingOperations(): + break + + yLimits = self.plot.getYAxis().getLimits() + data = window.getProfile() + self.assertEqual(data.coords[0], yLimits[0]) + self.assertEqual(data.coords[-1], yLimits[1]) + + # Clear the profile + self.manager.clearProfile() + self.qapp.processEvents() + self.assertIsNone(roi.getProfileWindow()) + + def testLineProfile(self): + """Test ScatterProfileToolBar line profile""" + + roiManager = self.manager.getRoiManager() + + # Add a scatter plot + self.plot.addScatter( + x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) + self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) + self.qapp.processEvents() + + # Set a ROI profile + roi = rois.ProfileScatterLineROI() + roi.setEndPoints(numpy.array([0., 0.]), numpy.array([1., 1.])) + roi.setNPoints(8) + roiManager.addRoi(roi) + + # Wait for async interpolator init + for _ in range(10): + self.qWait(200) + if not self.manager.hasPendingOperations(): + break + + window = roi.getProfileWindow() + self.assertIsNotNone(window) + data = window.getProfile() + self.assertIsInstance(data, core.CurveProfileData) + self.assertEqual(len(data.coords), 8) diff --git a/src/silx/gui/plot/tools/test/testTools.py b/src/silx/gui/plot/tools/test/testTools.py new file mode 100644 index 0000000..846f641 --- /dev/null +++ b/src/silx/gui/plot/tools/test/testTools.py @@ -0,0 +1,135 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Basic tests for silx.gui.plot.tools package""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "02/03/2018" + + +import functools +import unittest +import numpy + +from silx.utils.testutils import LoggingValidator +from silx.gui.utils.testutils import qWaitForWindowExposedAndActivate +from silx.gui import qt +from silx.gui.plot import PlotWindow +from silx.gui.plot import tools +from silx.gui.plot.test.utils import PlotWidgetTestCase + + +class TestPositionInfo(PlotWidgetTestCase): + """Tests for PositionInfo widget.""" + + def _createPlot(self): + return PlotWindow() + + def setUp(self): + super(TestPositionInfo, self).setUp() + self.mouseMove(self.plot, pos=(0, 0)) + self.qapp.processEvents() + self.qWait(100) + + def tearDown(self): + super(TestPositionInfo, self).tearDown() + + def _test(self, positionWidget, converterNames, **kwargs): + """General test of PositionInfo. + + - Add it to a toolbar and + - Move mouse around the center of the PlotWindow. + """ + toolBar = qt.QToolBar() + self.plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) + + toolBar.addWidget(positionWidget) + + converters = positionWidget.getConverters() + self.assertEqual(len(converters), len(converterNames)) + for index, name in enumerate(converterNames): + self.assertEqual(converters[index][0], name) + + self.qapp.processEvents() + with LoggingValidator(tools.__name__, **kwargs): + # Move mouse to center + center = self.plot.size() / 2 + self.mouseMove(self.plot, pos=(center.width(), center.height())) + # Move out + self.mouseMove(self.plot, pos=(1, 1)) + + def testDefaultConverters(self): + """Test PositionInfo with default converters""" + positionWidget = tools.PositionInfo(plot=self.plot) + self._test(positionWidget, ('X', 'Y')) + + def testCustomConverters(self): + """Test PositionInfo with custom converters""" + converters = [ + ('Coords', lambda x, y: (int(x), int(y))), + ('Radius', lambda x, y: numpy.sqrt(x * x + y * y)), + ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x))) + ] + positionWidget = tools.PositionInfo(plot=self.plot, + converters=converters) + self._test(positionWidget, ('Coords', 'Radius', 'Angle')) + + def testFailingConverters(self): + """Test PositionInfo with failing custom converters""" + def raiseException(x, y): + raise RuntimeError() + + positionWidget = tools.PositionInfo( + plot=self.plot, + converters=[('Exception', raiseException)]) + self._test(positionWidget, ['Exception'], error=2) + + def testUpdate(self): + """Test :meth:`PositionInfo.updateInfo`""" + calls = [] + + def update(calls, x, y): # Get number of calls + calls.append((x, y)) + return len(calls) + + positionWidget = tools.PositionInfo( + plot=self.plot, + converters=[('Call count', functools.partial(update, calls))]) + + positionWidget.updateInfo() + self.assertEqual(len(calls), 1) + + +class TestPlotToolsToolbars(PlotWidgetTestCase): + """Tests toolbars from silx.gui.plot.tools""" + + def test(self): + """"Add all toolbars""" + for tbClass in (tools.InteractiveModeToolBar, + tools.ImageToolBar, + tools.CurveToolBar, + tools.OutputToolBar): + tb = tbClass(parent=self.plot, plot=self.plot) + self.plot.addToolBar(tb) diff --git a/src/silx/gui/plot/tools/toolbars.py b/src/silx/gui/plot/tools/toolbars.py new file mode 100644 index 0000000..3df7d06 --- /dev/null +++ b/src/silx/gui/plot/tools/toolbars.py @@ -0,0 +1,362 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides toolbars that work with :class:`PlotWidget`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/03/2018" + + +from ... import qt +from .. import actions +from ..PlotWidget import PlotWidget +from .. import PlotToolButtons +from ....utils.deprecation import deprecated + + +class InteractiveModeToolBar(qt.QToolBar): + """Toolbar with interactive mode actions + + :param parent: See :class:`QWidget` + :param silx.gui.plot.PlotWidget plot: PlotWidget to control + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, plot=None, title='Plot Interaction'): + super(InteractiveModeToolBar, self).__init__(title, parent) + + assert isinstance(plot, PlotWidget) + + self._zoomModeAction = actions.mode.ZoomModeAction( + parent=self, plot=plot) + self.addAction(self._zoomModeAction) + + self._panModeAction = actions.mode.PanModeAction( + parent=self, plot=plot) + self.addAction(self._panModeAction) + + def getZoomModeAction(self): + """Returns the zoom mode QAction. + + :rtype: PlotAction + """ + return self._zoomModeAction + + def getPanModeAction(self): + """Returns the pan mode QAction + + :rtype: PlotAction + """ + return self._panModeAction + + +class OutputToolBar(qt.QToolBar): + """Toolbar providing icons to copy, save and print a PlotWidget + + :param parent: See :class:`QWidget` + :param silx.gui.plot.PlotWidget plot: PlotWidget to control + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, plot=None, title='Plot Output'): + super(OutputToolBar, self).__init__(title, parent) + + assert isinstance(plot, PlotWidget) + + self._copyAction = actions.io.CopyAction(parent=self, plot=plot) + self.addAction(self._copyAction) + + self._saveAction = actions.io.SaveAction(parent=self, plot=plot) + self.addAction(self._saveAction) + + self._printAction = actions.io.PrintAction(parent=self, plot=plot) + self.addAction(self._printAction) + + def getCopyAction(self): + """Returns the QAction performing copy to clipboard of the PlotWidget + + :rtype: PlotAction + """ + return self._copyAction + + def getSaveAction(self): + """Returns the QAction performing save to file of the PlotWidget + + :rtype: PlotAction + """ + return self._saveAction + + def getPrintAction(self): + """Returns the QAction performing printing of the PlotWidget + + :rtype: PlotAction + """ + return self._printAction + + +class ImageToolBar(qt.QToolBar): + """Toolbar providing PlotAction suited when displaying images + + :param parent: See :class:`QWidget` + :param silx.gui.plot.PlotWidget plot: PlotWidget to control + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, plot=None, title='Image'): + super(ImageToolBar, self).__init__(title, parent) + + assert isinstance(plot, PlotWidget) + + self._resetZoomAction = actions.control.ResetZoomAction( + parent=self, plot=plot) + self.addAction(self._resetZoomAction) + + self._colormapAction = actions.control.ColormapAction( + parent=self, plot=plot) + self.addAction(self._colormapAction) + + self._keepDataAspectRatioButton = PlotToolButtons.AspectToolButton( + parent=self, plot=plot) + self.addWidget(self._keepDataAspectRatioButton) + + self._yAxisInvertedButton = PlotToolButtons.YAxisOriginToolButton( + parent=self, plot=plot) + self.addWidget(self._yAxisInvertedButton) + + def getResetZoomAction(self): + """Returns the QAction to reset the zoom. + + :rtype: PlotAction + """ + return self._resetZoomAction + + def getColormapAction(self): + """Returns the QAction to control the colormap. + + :rtype: PlotAction + """ + return self._colormapAction + + def getKeepDataAspectRatioButton(self): + """Returns the QToolButton controlling data aspect ratio. + + :rtype: QToolButton + """ + return self._keepDataAspectRatioButton + + def getYAxisInvertedButton(self): + """Returns the QToolButton controlling Y axis orientation. + + :rtype: QToolButton + """ + return self._yAxisInvertedButton + + +class CurveToolBar(qt.QToolBar): + """Toolbar providing PlotAction suited when displaying curves + + :param parent: See :class:`QWidget` + :param silx.gui.plot.PlotWidget plot: PlotWidget to control + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, plot=None, title='Image'): + super(CurveToolBar, self).__init__(title, parent) + + assert isinstance(plot, PlotWidget) + + self._resetZoomAction = actions.control.ResetZoomAction( + parent=self, plot=plot) + self.addAction(self._resetZoomAction) + + self._xAxisAutoScaleAction = actions.control.XAxisAutoScaleAction( + parent=self, plot=plot) + self.addAction(self._xAxisAutoScaleAction) + + self._yAxisAutoScaleAction = actions.control.YAxisAutoScaleAction( + parent=self, plot=plot) + self.addAction(self._yAxisAutoScaleAction) + + self._xAxisLogarithmicAction = actions.control.XAxisLogarithmicAction( + parent=self, plot=plot) + self.addAction(self._xAxisLogarithmicAction) + + self._yAxisLogarithmicAction = actions.control.YAxisLogarithmicAction( + parent=self, plot=plot) + self.addAction(self._yAxisLogarithmicAction) + + self._gridAction = actions.control.GridAction( + parent=self, plot=plot) + self.addAction(self._gridAction) + + self._curveStyleAction = actions.control.CurveStyleAction( + parent=self, plot=plot) + self.addAction(self._curveStyleAction) + + def getResetZoomAction(self): + """Returns the QAction to reset the zoom. + + :rtype: PlotAction + """ + return self._resetZoomAction + + def getXAxisAutoScaleAction(self): + """Returns the QAction to toggle X axis autoscale. + + :rtype: PlotAction + """ + return self._xAxisAutoScaleAction + + def getYAxisAutoScaleAction(self): + """Returns the QAction to toggle Y axis autoscale. + + :rtype: PlotAction + """ + return self._yAxisAutoScaleAction + + def getXAxisLogarithmicAction(self): + """Returns the QAction to toggle X axis log/linear scale. + + :rtype: PlotAction + """ + return self._xAxisLogarithmicAction + + def getYAxisLogarithmicAction(self): + """Returns the QAction to toggle Y axis log/linear scale. + + :rtype: PlotAction + """ + return self._yAxisLogarithmicAction + + def getGridAction(self): + """Returns the action to toggle the plot grid. + + :rtype: PlotAction + """ + return self._gridAction + + def getCurveStyleAction(self): + """Returns the QAction to change the style of all curves. + + :rtype: PlotAction + """ + return self._curveStyleAction + + +class ScatterToolBar(qt.QToolBar): + """Toolbar providing PlotAction suited when displaying scatter plot + + :param parent: See :class:`QWidget` + :param silx.gui.plot.PlotWidget plot: PlotWidget to control + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, plot=None, title='Scatter Tools'): + super(ScatterToolBar, self).__init__(title, parent) + + assert isinstance(plot, PlotWidget) + + self._resetZoomAction = actions.control.ResetZoomAction( + parent=self, plot=plot) + self.addAction(self._resetZoomAction) + + self._xAxisLogarithmicAction = actions.control.XAxisLogarithmicAction( + parent=self, plot=plot) + self.addAction(self._xAxisLogarithmicAction) + + self._yAxisLogarithmicAction = actions.control.YAxisLogarithmicAction( + parent=self, plot=plot) + self.addAction(self._yAxisLogarithmicAction) + + self._keepDataAspectRatioButton = PlotToolButtons.AspectToolButton( + parent=self, plot=plot) + self.addWidget(self._keepDataAspectRatioButton) + + self._gridAction = actions.control.GridAction( + parent=self, plot=plot) + self.addAction(self._gridAction) + + self._colormapAction = actions.control.ColormapAction( + parent=self, plot=plot) + self.addAction(self._colormapAction) + + self._visualizationToolButton = \ + PlotToolButtons.ScatterVisualizationToolButton(parent=self, plot=plot) + self.addWidget(self._visualizationToolButton) + + def getResetZoomAction(self): + """Returns the QAction to reset the zoom. + + :rtype: PlotAction + """ + return self._resetZoomAction + + def getXAxisLogarithmicAction(self): + """Returns the QAction to toggle X axis log/linear scale. + + :rtype: PlotAction + """ + return self._xAxisLogarithmicAction + + def getYAxisLogarithmicAction(self): + """Returns the QAction to toggle Y axis log/linear scale. + + :rtype: PlotAction + """ + return self._yAxisLogarithmicAction + + def getGridAction(self): + """Returns the action to toggle the plot grid. + + :rtype: PlotAction + """ + return self._gridAction + + def getColormapAction(self): + """Returns the QAction to control the colormap. + + :rtype: PlotAction + """ + return self._colormapAction + + def getKeepDataAspectRatioButton(self): + """Returns the QToolButton controlling data aspect ratio. + + :rtype: QToolButton + """ + return self._keepDataAspectRatioButton + + def getScatterVisualizationToolButton(self): + """Returns the QToolButton controlling the visualization mode. + + :rtype: ScatterVisualizationToolButton + """ + return self._visualizationToolButton + + @deprecated(replacement='getScatterVisualizationToolButton', + since_version='0.11.0') + def getSymbolToolButton(self): + return self.getScatterVisualizationToolButton() |