summaryrefslogtreecommitdiff
path: root/silx/gui/plot/tools
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/tools')
-rw-r--r--silx/gui/plot/tools/LimitsToolBar.py131
-rw-r--r--silx/gui/plot/tools/PositionInfo.py347
-rw-r--r--silx/gui/plot/tools/__init__.py50
-rw-r--r--silx/gui/plot/tools/profile/ImageProfileToolBar.py271
-rw-r--r--silx/gui/plot/tools/profile/ScatterProfileToolBar.py431
-rw-r--r--silx/gui/plot/tools/profile/_BaseProfileToolBar.py430
-rw-r--r--silx/gui/plot/tools/profile/__init__.py38
-rw-r--r--silx/gui/plot/tools/roi.py934
-rw-r--r--silx/gui/plot/tools/test/__init__.py48
-rw-r--r--silx/gui/plot/tools/test/testROI.py456
-rw-r--r--silx/gui/plot/tools/test/testScatterProfileToolBar.py216
-rw-r--r--silx/gui/plot/tools/test/testTools.py175
-rw-r--r--silx/gui/plot/tools/toolbars.py356
13 files changed, 3883 insertions, 0 deletions
diff --git a/silx/gui/plot/tools/LimitsToolBar.py b/silx/gui/plot/tools/LimitsToolBar.py
new file mode 100644
index 0000000..fc192a6
--- /dev/null
+++ b/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/silx/gui/plot/tools/PositionInfo.py b/silx/gui/plot/tools/PositionInfo.py
new file mode 100644
index 0000000..83b61bd
--- /dev/null
+++ b/silx/gui/plot/tools/PositionInfo.py
@@ -0,0 +1,347 @@
+# 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.
+#
+# ###########################################################################*/
+"""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
+
+
+_logger = logging.getLogger(__name__)
+
+
+# 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 = qt.QLabel()
+ contentWidget.setText('------')
+ contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
+ contentWidget.setFixedWidth(
+ contentWidget.fontMetrics().width('##############'))
+ 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('curve')
+ if snappingMode & self.SNAPPING_SCATTER:
+ kinds.append('scatter')
+ selectedItems = plot._getItems(kind=kinds)
+
+ # Compute distance threshold
+ if qt.BINDING in ('PyQt5', 'PySide2'):
+ window = plot.window()
+ windowHandle = window.windowHandle()
+ if windowHandle is not None:
+ ratio = windowHandle.devicePixelRatio()
+ else:
+ ratio = qt.QGuiApplication.primaryScreen().devicePixelRatio()
+ else:
+ ratio = 1.
+
+ # Baseline squared distance threshold
+ distInPixels = (self.SNAP_THRESHOLD_DIST * ratio)**2
+
+ for item in selectedItems:
+ if (snappingMode & self.SNAPPING_SYMBOLS_ONLY and
+ not item.getSymbol()):
+ # Only handled if item symbols are visible
+ continue
+
+ 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/silx/gui/plot/tools/__init__.py b/silx/gui/plot/tools/__init__.py
new file mode 100644
index 0000000..09f468c
--- /dev/null
+++ b/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/silx/gui/plot/tools/profile/ImageProfileToolBar.py b/silx/gui/plot/tools/profile/ImageProfileToolBar.py
new file mode 100644
index 0000000..207a2e2
--- /dev/null
+++ b/silx/gui/plot/tools/profile/ImageProfileToolBar.py
@@ -0,0 +1,271 @@
+# TODO quick & dirty proof of concept
+
+import numpy
+
+from silx.gui.plot.tools.profile.ScatterProfileToolBar import _BaseProfileToolBar
+from .. import items
+from ...colors import cursorColorForColormap
+from ....image.bilinear import BilinearImage
+
+
+def _alignedPartialProfile(data, rowRange, colRange, axis):
+ """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.
+ :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]
+
+ 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)
+
+ imgProfile = numpy.mean(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(points, data, origin, scale, lineWidth):
+ """Create the profile line for the the given image.
+
+ :param points: Coords of profile end points: (x0, y0, x1, y1)
+ :param numpy.ndarray data: 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
+ :return: `profile, area`, where:
+ - 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.
+
+ :rtype: tuple(ndarray, (ndarray, ndarray), str, str)
+ """
+ if data is None or points is None or lineWidth is None:
+ raise ValueError("createProfile called with invalid arguments")
+
+ # force 3D data (stack of images)
+ if len(data.shape) == 2:
+ data3D = data.reshape((1,) + data.shape)
+ elif len(data.shape) == 3:
+ data3D = data
+
+ roiWidth = max(1, lineWidth)
+ x0, y0, x1, y1 = points
+
+ # Convert start and end points in image coords as (row, col)
+ startPt = ((y0 - origin[1]) / scale[1],
+ (x0 - origin[0]) / scale[0])
+ endPt = ((y1 - origin[1]) / scale[1],
+ (x1 - 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
+ profile = _alignedPartialProfile(data3D,
+ rowRange, colRange,
+ axis=0)
+
+ 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))
+ profile = _alignedPartialProfile(data3D,
+ rowRange, colRange,
+ axis=1)
+
+ # 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
+
+ profile = []
+ for slice_idx in range(data3D.shape[0]):
+ bilinear = BilinearImage(data3D[slice_idx, :, :])
+
+ profile.append(bilinear.profile_line(
+ (startPt[0] - 0.5, startPt[1] - 0.5),
+ (endPt[0] - 0.5, endPt[1] - 0.5),
+ roiWidth))
+ 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
+ startPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
+ endPt = 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((startPt[1] - 0.5 * roiWidth * dCol,
+ startPt[1] + 0.5 * roiWidth * dCol,
+ endPt[1] + 0.5 * roiWidth * dCol,
+ endPt[1] - 0.5 * roiWidth * dCol),
+ dtype=numpy.float32) * scale[0] + origin[0],
+ numpy.array((startPt[0] - 0.5 * roiWidth * dRow,
+ startPt[0] + 0.5 * roiWidth * dRow,
+ endPt[0] + 0.5 * roiWidth * dRow,
+ endPt[0] - 0.5 * roiWidth * dRow),
+ dtype=numpy.float32) * scale[1] + origin[1])
+
+ xProfile = numpy.arange(len(profile[0]), dtype=numpy.float64)
+
+ return (xProfile, profile[0]), area
+
+
+class ImageProfileToolBar(_BaseProfileToolBar):
+
+ def __init__(self, parent=None, plot=None, title='Image Profile'):
+ super(ImageProfileToolBar, self).__init__(parent, plot, title)
+ plot.sigActiveImageChanged.connect(self.__activeImageChanged)
+
+ roiManager = self._getRoiManager()
+ if roiManager is None:
+ _logger.error(
+ "Error during scatter profile toolbar initialisation")
+ else:
+ roiManager.sigInteractiveModeStarted.connect(
+ self.__interactionStarted)
+ roiManager.sigInteractiveModeFinished.connect(
+ self.__interactionFinished)
+ if roiManager.isStarted():
+ self.__interactionStarted(roiManager.getRegionOfInterestKind())
+
+ def __interactionStarted(self, kind):
+ """Handle start of ROI interaction"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ plot.sigActiveImageChanged.connect(self.__activeImageChanged)
+
+ image = plot.getActiveImage()
+ legend = None if image is None else image.getLegend()
+ self.__activeImageChanged(None, legend)
+
+ def __interactionFinished(self, rois):
+ """Handle end of ROI interaction"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ plot.sigActiveImageChanged.disconnect(self.__activeImageChanged)
+
+ image = plot.getActiveImage()
+ legend = None if image is None else image.getLegend()
+ self.__activeImageChanged(legend, None)
+
+ def __activeImageChanged(self, previous, legend):
+ """Handle active image change: toggle enabled toolbar, update curve"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ activeImage = plot.getActiveImage()
+ if activeImage is None:
+ self.setEnabled(False)
+ else:
+ # Disable for empty image
+ self.setEnabled(activeImage.getData(copy=False).size > 0)
+
+ # Update default profile color
+ if isinstance(activeImage, items.ColormapMixIn):
+ self.setColor(cursorColorForColormap(
+ activeImage.getColormap()['name'])) # TODO change thsi
+ else:
+ self.setColor('black')
+
+ self.updateProfile()
+
+ def computeProfile(self, 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: (x, y) profile data or None
+ """
+ plot = self.getPlotWidget()
+ if plot is None:
+ return None
+
+ image = plot.getActiveImage()
+ if image is None:
+ return None
+
+ profile, area = createProfile(
+ points=(x0, y0, x1, y1),
+ data=image.getData(copy=False),
+ origin=image.getOrigin(),
+ scale=image.getScale(),
+ lineWidth=1) # TODO
+
+ return profile \ No newline at end of file
diff --git a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py
new file mode 100644
index 0000000..fd21515
--- /dev/null
+++ b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py
@@ -0,0 +1,431 @@
+# 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 profile tools for scatter plots.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import logging
+import threading
+import time
+
+import numpy
+
+try:
+ from scipy.interpolate import LinearNDInterpolator
+except ImportError:
+ LinearNDInterpolator = None
+
+ # Fallback using local Delaunay and matplotlib interpolator
+ from silx.third_party.scipy_spatial import Delaunay
+ import matplotlib.tri
+
+from ._BaseProfileToolBar import _BaseProfileToolBar
+from .... import qt
+from ... import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+# TODO support log scale
+
+
+class _InterpolatorInitThread(qt.QThread):
+ """Thread building a scatter interpolator
+
+ This works in greedy mode in that the signal is only emitted
+ when no other request is pending
+ """
+
+ sigInterpolatorReady = qt.Signal(object)
+ """Signal emitted whenever an interpolator is ready
+
+ It provides a 3-tuple (points, values, interpolator)
+ """
+
+ _RUNNING_THREADS_TO_DELETE = []
+ """Store reference of no more used threads but still running"""
+
+ def __init__(self):
+ super(_InterpolatorInitThread, self).__init__()
+ self._lock = threading.RLock()
+ self._pendingData = None
+ self._firstFallbackRun = True
+
+ def discard(self, obj=None):
+ """Wait for pending thread to complete and delete then
+
+ Connect this to the destroyed signal of widget using this thread
+ """
+ if self.isRunning():
+ self.cancel()
+ self._RUNNING_THREADS_TO_DELETE.append(self) # Keep a reference
+ self.finished.connect(self.__finished)
+
+ def __finished(self):
+ """Handle finished signal of threads to delete"""
+ try:
+ self._RUNNING_THREADS_TO_DELETE.remove(self)
+ except ValueError:
+ _logger.warning('Finished thread no longer in reference list')
+
+ def request(self, points, values):
+ """Request new initialisation of interpolator
+
+ :param numpy.ndarray points: Point coordinates (N, D)
+ :param numpy.ndarray values: Values the N points (1D array)
+ """
+ with self._lock:
+ # Possibly replace already pending data
+ self._pendingData = points, values
+
+ if not self.isRunning():
+ self.start()
+
+ def cancel(self):
+ """Cancel any running/pending requests"""
+ with self._lock:
+ self._pendingData = 'cancelled'
+
+ def run(self):
+ """Run the init of the scatter interpolator"""
+ if LinearNDInterpolator is None:
+ self.run_matplotlib()
+ else:
+ self.run_scipy()
+
+ def run_matplotlib(self):
+ """Run the init of the scatter interpolator"""
+ if self._firstFallbackRun:
+ self._firstFallbackRun = False
+ _logger.warning(
+ "scipy.spatial.LinearNDInterpolator not available: "
+ "Scatter plot interpolator initialisation can freeze the GUI.")
+
+ while True:
+ with self._lock:
+ data = self._pendingData
+ self._pendingData = None
+
+ if data in (None, 'cancelled'):
+ return
+
+ points, values = data
+
+ startTime = time.time()
+ try:
+ delaunay = Delaunay(points)
+ except:
+ _logger.warning(
+ "Cannot triangulate scatter data")
+ else:
+ with self._lock:
+ data = self._pendingData
+
+ if data is not None: # Break point
+ _logger.info('Interpolator discarded after %f s',
+ time.time() - startTime)
+ else:
+
+ x, y = points.T
+ triangulation = matplotlib.tri.Triangulation(
+ x, y, triangles=delaunay.simplices)
+
+ interpolator = matplotlib.tri.LinearTriInterpolator(
+ triangulation, values)
+
+ with self._lock:
+ data = self._pendingData
+
+ if data is not None:
+ _logger.info('Interpolator discarded after %f s',
+ time.time() - startTime)
+ else:
+ # No other processing requested: emit the signal
+ _logger.info("Interpolator initialised in %f s",
+ time.time() - startTime)
+
+ # Wrap interpolator to have same API as scipy's one
+ def wrapper(points):
+ return interpolator(*points.T)
+
+ self.sigInterpolatorReady.emit(
+ (points, values, wrapper))
+
+ def run_scipy(self):
+ """Run the init of the scatter interpolator"""
+ while True:
+ with self._lock:
+ data = self._pendingData
+ self._pendingData = None
+
+ if data in (None, 'cancelled'):
+ return
+
+ points, values = data
+
+ startTime = time.time()
+ try:
+ interpolator = LinearNDInterpolator(points, values)
+ except:
+ _logger.warning(
+ "Cannot initialise scatter profile interpolator")
+ else:
+ with self._lock:
+ data = self._pendingData
+
+ if data is not None: # Break point
+ _logger.info('Interpolator discarded after %f s',
+ time.time() - startTime)
+ else:
+ # First call takes a while, do it here
+ interpolator([(0., 0.)])
+
+ with self._lock:
+ data = self._pendingData
+
+ if data is not None:
+ _logger.info('Interpolator discarded after %f s',
+ time.time() - startTime)
+ else:
+ # No other processing requested: emit the signal
+ _logger.info("Interpolator initialised in %f s",
+ time.time() - startTime)
+ self.sigInterpolatorReady.emit(
+ (points, values, interpolator))
+
+
+class ScatterProfileToolBar(_BaseProfileToolBar):
+ """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='Scatter Profile'):
+ super(ScatterProfileToolBar, self).__init__(parent, plot, title)
+
+ self.__nPoints = 1024
+ self.__interpolator = None
+ self.__interpolatorCache = None # points, values, interpolator
+
+ self.__initThread = _InterpolatorInitThread()
+ self.destroyed.connect(self.__initThread.discard)
+ self.__initThread.sigInterpolatorReady.connect(
+ self.__interpolatorReady)
+
+ roiManager = self._getRoiManager()
+ if roiManager is None:
+ _logger.error(
+ "Error during scatter profile toolbar initialisation")
+ else:
+ roiManager.sigInteractiveModeStarted.connect(
+ self.__interactionStarted)
+ roiManager.sigInteractiveModeFinished.connect(
+ self.__interactionFinished)
+ if roiManager.isStarted():
+ self.__interactionStarted(roiManager.getCurrentInteractionModeRoiClass())
+
+ def __interactionStarted(self, roiClass):
+ """Handle start of ROI interaction"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ plot.sigActiveScatterChanged.connect(self.__activeScatterChanged)
+
+ scatter = plot._getActiveItem(kind='scatter')
+ legend = None if scatter is None else scatter.getLegend()
+ self.__activeScatterChanged(None, legend)
+
+ def __interactionFinished(self):
+ """Handle end of ROI interaction"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ plot.sigActiveScatterChanged.disconnect(self.__activeScatterChanged)
+
+ scatter = plot._getActiveItem(kind='scatter')
+ legend = None if scatter is None else scatter.getLegend()
+ self.__activeScatterChanged(legend, None)
+
+ def __activeScatterChanged(self, previous, legend):
+ """Handle change of active scatter
+
+ :param Union[str,None] previous:
+ :param Union[str,None] legend:
+ """
+ self.__initThread.cancel()
+
+ # Reset interpolator
+ self.__interpolator = None
+
+ plot = self.getPlotWidget()
+ if plot is None:
+ _logger.error("Associated PlotWidget no longer exists")
+
+ else:
+ if previous is not None: # Disconnect signal
+ scatter = plot.getScatter(previous)
+ if scatter is not None:
+ scatter.sigItemChanged.disconnect(
+ self.__scatterItemChanged)
+
+ if legend is not None:
+ scatter = plot.getScatter(legend)
+ if scatter is None:
+ _logger.error("Cannot retrieve active scatter")
+
+ else:
+ scatter.sigItemChanged.connect(self.__scatterItemChanged)
+ points = numpy.transpose(numpy.array((
+ scatter.getXData(copy=False),
+ scatter.getYData(copy=False))))
+ values = scatter.getValueData(copy=False)
+
+ self.__updateInterpolator(points, values)
+
+ # Refresh profile
+ self.updateProfile()
+
+ def __scatterItemChanged(self, event):
+ """Handle update of active scatter plot item
+
+ :param ItemChangedType event:
+ """
+ if event == items.ItemChangedType.DATA:
+ self.__interpolator = None
+ scatter = self.sender()
+ if scatter is None:
+ _logger.error("Cannot retrieve updated scatter item")
+
+ else:
+ points = numpy.transpose(numpy.array((
+ scatter.getXData(copy=False),
+ scatter.getYData(copy=False))))
+ values = scatter.getValueData(copy=False)
+
+ self.__updateInterpolator(points, values)
+
+ # Handle interpolator init thread
+
+ def __updateInterpolator(self, points, values):
+ """Update used interpolator with new data"""
+ if (self.__interpolatorCache is not None and
+ len(points) == len(self.__interpolatorCache[0]) and
+ numpy.all(numpy.equal(self.__interpolatorCache[0], points)) and
+ numpy.all(numpy.equal(self.__interpolatorCache[1], values))):
+ # Reuse previous interpolator
+ _logger.info(
+ 'Scatter changed: Reuse previous interpolator')
+ self.__interpolator = self.__interpolatorCache[2]
+
+ else:
+ # Interpolator needs update: Start background processing
+ _logger.info(
+ 'Scatter changed: Rebuild interpolator')
+ self.__interpolator = None
+ self.__interpolatorCache = None
+ self.__initThread.request(points, values)
+
+ def __interpolatorReady(self, data):
+ """Handle end of init interpolator thread"""
+ points, values, interpolator = data
+ self.__interpolator = interpolator
+ self.__interpolatorCache = None if interpolator is None else data
+ self.updateProfile()
+
+ def hasPendingOperations(self):
+ return self.__initThread.isRunning()
+
+ # 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)
+ else:
+ self.__nPoints = npoints
+
+ # Overridden methods
+
+ def computeProfileTitle(self, x0, y0, x1, y1):
+ """Compute corresponding plot title
+
+ :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 self.hasPendingOperations():
+ return 'Pre-processing data...'
+
+ else:
+ return super(ScatterProfileToolBar, self).computeProfileTitle(
+ x0, y0, x1, y1)
+
+ def computeProfile(self, 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
+ """
+ if self.__interpolator is None:
+ return None
+
+ nPoints = self.getNPoints()
+
+ points = numpy.transpose((
+ numpy.linspace(x0, x1, nPoints, endpoint=True),
+ numpy.linspace(y0, y1, nPoints, endpoint=True)))
+
+ values = self.__interpolator(points)
+
+ if not numpy.any(numpy.isfinite(values)):
+ return None # Profile outside convex hull
+
+ return points, values
diff --git a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py
new file mode 100644
index 0000000..6d9d6d4
--- /dev/null
+++ b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py
@@ -0,0 +1,430 @@
+# 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 the base class for profile toolbars."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import logging
+import weakref
+
+import numpy
+
+from silx.utils.weakref import WeakMethodProxy
+from silx.gui import qt, icons, colors
+from silx.gui.plot import PlotWidget, items
+from silx.gui.plot.ProfileMainWindow import ProfileMainWindow
+from silx.gui.plot.tools.roi import RegionOfInterestManager
+from silx.gui.plot.items import roi as roi_items
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _BaseProfileToolBar(qt.QToolBar):
+ """Base class for QToolBar 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`.
+ """
+
+ sigProfileChanged = qt.Signal()
+ """Signal emitted when the profile has changed"""
+
+ def __init__(self, parent=None, plot=None, title=''):
+ super(_BaseProfileToolBar, self).__init__(title, parent)
+
+ self.__profile = None
+ self.__profileTitle = ''
+
+ assert isinstance(plot, PlotWidget)
+ self._plotRef = weakref.ref(
+ plot, WeakMethodProxy(self.__plotDestroyed))
+
+ self._profileWindow = None
+
+ # Set-up interaction manager
+ roiManager = RegionOfInterestManager(plot)
+ self._roiManagerRef = weakref.ref(roiManager)
+
+ roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished)
+ roiManager.sigRoiChanged.connect(self.updateProfile)
+ roiManager.sigRoiAdded.connect(self.__roiAdded)
+
+ # Add interactive mode actions
+ for kind, icon, tooltip in (
+ (roi_items.HorizontalLineROI, 'shape-horizontal',
+ 'Enables horizontal line profile selection mode'),
+ (roi_items.VerticalLineROI, 'shape-vertical',
+ 'Enables vertical line profile selection mode'),
+ (roi_items.LineROI, 'shape-diagonal',
+ 'Enables line profile selection mode')):
+ action = roiManager.getInteractionModeAction(kind)
+ action.setIcon(icons.getQIcon(icon))
+ action.setToolTip(tooltip)
+ self.addAction(action)
+
+ # Add clear action
+ action = qt.QAction(icons.getQIcon('profile-clear'),
+ 'Clear Profile', self)
+ action.setToolTip('Clear the profile')
+ action.setCheckable(False)
+ action.triggered.connect(self.clearProfile)
+ self.addAction(action)
+
+ # Initialize color
+ self._color = None
+ self.setColor('red')
+
+ # Listen to plot limits changed
+ plot.getXAxis().sigLimitsChanged.connect(self.updateProfile)
+ plot.getYAxis().sigLimitsChanged.connect(self.updateProfile)
+
+ # Listen to plot scale
+ plot.getXAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged)
+ plot.getYAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged)
+
+ self.setDefaultProfileWindowEnabled(True)
+
+ def getProfilePoints(self, copy=True):
+ """Returns the profile sampling points as (x, y) or None
+
+ :param bool copy: True to get a copy,
+ False to get internal arrays (do not modify)
+ :rtype: Union[numpy.ndarray,None]
+ """
+ if self.__profile is None:
+ return None
+ else:
+ return numpy.array(self.__profile[0], copy=copy)
+
+ def getProfileValues(self, copy=True):
+ """Returns the values of the profile or None
+
+ :param bool copy: True to get a copy,
+ False to get internal arrays (do not modify)
+ :rtype: Union[numpy.ndarray,None]
+ """
+ if self.__profile is None:
+ return None
+ else:
+ return numpy.array(self.__profile[1], copy=copy)
+
+ def getProfileTitle(self):
+ """Returns the profile title
+
+ :rtype: str
+ """
+ return self.__profileTitle
+
+ # Handle plot reference
+
+ def __plotDestroyed(self, ref):
+ """Handle finalization of PlotWidget
+
+ :param ref: weakref to the plot
+ """
+ self._plotRef = None
+ self.setEnabled(False) # Profile is pointless
+ for action in self.actions(): # TODO useful?
+ self.removeAction(action)
+
+ def getPlotWidget(self):
+ """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar.
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ return None if self._plotRef is None else self._plotRef()
+
+ def _getRoiManager(self):
+ """Returns the used ROI manager
+
+ :rtype: RegionOfInterestManager
+ """
+ return self._roiManagerRef()
+
+ # Profile Plot
+
+ def isDefaultProfileWindowEnabled(self):
+ """Returns True if the default floating profile window is used
+
+ :rtype: bool
+ """
+ return self.getDefaultProfileWindow() is not None
+
+ def setDefaultProfileWindowEnabled(self, enabled):
+ """Set whether to use or not the default floating profile window.
+
+ :param bool enabled: True to use, False to disable
+ """
+ if self.isDefaultProfileWindowEnabled() != enabled:
+ if enabled:
+ self._profileWindow = ProfileMainWindow(self)
+ self._profileWindow.sigClose.connect(self.clearProfile)
+ self.sigProfileChanged.connect(self.__updateDefaultProfilePlot)
+
+ else:
+ self.sigProfileChanged.disconnect(self.__updateDefaultProfilePlot)
+ self._profileWindow.sigClose.disconnect(self.clearProfile)
+ self._profileWindow.close()
+ self._profileWindow = None
+
+ def getDefaultProfileWindow(self):
+ """Returns the default floating profile window if in use else None.
+
+ See :meth:`isDefaultProfileWindowEnabled`
+
+ :rtype: Union[ProfileMainWindow,None]
+ """
+ return self._profileWindow
+
+ def __updateDefaultProfilePlot(self):
+ """Update the plot of the default profile window"""
+ profileWindow = self.getDefaultProfileWindow()
+ if profileWindow is None:
+ return
+
+ profilePlot = profileWindow.getPlot()
+ if profilePlot is None:
+ return
+
+ profilePlot.clear()
+ profilePlot.setGraphTitle(self.getProfileTitle())
+
+ points = self.getProfilePoints(copy=False)
+ values = self.getProfileValues(copy=False)
+
+ if points is not None and values is not None:
+ if (numpy.abs(points[-1, 0] - points[0, 0]) >
+ numpy.abs(points[-1, 1] - points[0, 1])):
+ xProfile = points[:, 0]
+ profilePlot.getXAxis().setLabel('X')
+ else:
+ xProfile = points[:, 1]
+ profilePlot.getXAxis().setLabel('Y')
+
+ profilePlot.addCurve(
+ xProfile, values, legend='Profile', color=self._color)
+
+ self._showDefaultProfileWindow()
+
+ def _showDefaultProfileWindow(self):
+ """If profile window was created by this toolbar,
+ try to avoid overlapping with the toolbar's parent window.
+ """
+ profileWindow = self.getDefaultProfileWindow()
+ roiManager = self._getRoiManager()
+ if profileWindow is None or roiManager is None:
+ return
+
+ if roiManager.isStarted() and not profileWindow.isVisible():
+ profileWindow.show()
+ profileWindow.raise_()
+
+ window = self.window()
+ winGeom = window.frameGeometry()
+ qapp = qt.QApplication.instance()
+ desktop = qapp.desktop()
+ screenGeom = desktop.availableGeometry(self)
+ spaceOnLeftSide = winGeom.left()
+ spaceOnRightSide = screenGeom.width() - winGeom.right()
+
+ frameGeometry = profileWindow.frameGeometry()
+ profileWindowWidth = frameGeometry.width()
+ if profileWindowWidth < spaceOnRightSide:
+ # Place profile on the right
+ profileWindow.move(winGeom.right(), winGeom.top())
+ elif profileWindowWidth < spaceOnLeftSide:
+ # Place profile on the left
+ profileWindow.move(
+ max(0, winGeom.left() - profileWindowWidth), winGeom.top())
+
+ # Handle plot in log scale
+
+ def __plotAxisScaleChanged(self, scale):
+ """Handle change of axis scale in the plot widget"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ xScale = plot.getXAxis().getScale()
+ yScale = plot.getYAxis().getScale()
+
+ if xScale == items.Axis.LINEAR and yScale == items.Axis.LINEAR:
+ self.setEnabled(True)
+
+ else:
+ roiManager = self._getRoiManager()
+ if roiManager is not None:
+ roiManager.stop() # Stop interactive mode
+
+ self.clearProfile()
+ self.setEnabled(False)
+
+ # Profile color
+
+ def getColor(self):
+ """Returns the color used for the profile and ROI
+
+ :rtype: QColor
+ """
+ return qt.QColor.fromRgbF(*self._color)
+
+ def setColor(self, color):
+ """Set the color to use for ROI and profile.
+
+ :param color:
+ Either a color name, a QColor, a list of uint8 or float in [0, 1].
+ """
+ self._color = colors.rgba(color)
+ roiManager = self._getRoiManager()
+ if roiManager is not None:
+ roiManager.setColor(self._color)
+ for roi in roiManager.getRois():
+ roi.setColor(self._color)
+ self.updateProfile()
+
+ # Handle ROI manager
+
+ def __interactionFinished(self):
+ """Handle end of interactive mode"""
+ self.clearProfile()
+
+ profileWindow = self.getDefaultProfileWindow()
+ if profileWindow is not None:
+ profileWindow.hide()
+
+ def __roiAdded(self, roi):
+ """Handle new ROI"""
+ roi.setLabel('Profile')
+ roi.setEditable(True)
+
+ # Remove any other ROI
+ roiManager = self._getRoiManager()
+ if roiManager is not None:
+ for regionOfInterest in list(roiManager.getRois()):
+ if regionOfInterest is not roi:
+ roiManager.removeRoi(regionOfInterest)
+
+ def computeProfile(self, x0, y0, x1, y1):
+ """Compute corresponding profile
+
+ Override in subclass to compute 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
+ """
+ return None
+
+ def computeProfileTitle(self, 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 = 'X = %g; Y = [%g, %g]' % (x0, y0, y1)
+ elif y0 == y1:
+ title = 'Y = %g; X = [%g, %g]' % (y0, x0, x1)
+ else:
+ m = (y1 - y0) / (x1 - x0)
+ b = y0 - m * x0
+ title = 'Y = %g * X %+g' % (m, b)
+
+ return title
+
+ def updateProfile(self):
+ """Update profile according to current ROI"""
+ roiManager = self._getRoiManager()
+ if roiManager is None:
+ roi = None
+ else:
+ rois = roiManager.getRois()
+ roi = None if len(rois) == 0 else rois[0]
+
+ if roi is None:
+ self._setProfile(profile=None, title='')
+ return
+
+ # Get end points
+ if isinstance(roi, roi_items.LineROI):
+ points = roi.getEndPoints()
+ x0, y0 = points[0]
+ x1, y1 = points[1]
+ elif isinstance(roi, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)):
+ plot = self.getPlotWidget()
+ if plot is None:
+ self._setProfile(profile=None, title='')
+ return
+
+ elif isinstance(roi, roi_items.HorizontalLineROI):
+ x0, x1 = plot.getXAxis().getLimits()
+ y0 = y1 = roi.getPosition()
+
+ elif isinstance(roi, roi_items.VerticalLineROI):
+ x0 = x1 = roi.getPosition()
+ y0, y1 = plot.getYAxis().getLimits()
+
+ else:
+ raise RuntimeError('Unsupported ROI for profile: {}'.format(roi.__class__))
+
+ if x1 < x0 or (x1 == x0 and y1 < y0):
+ # Invert points
+ x0, y0, x1, y1 = x1, y1, x0, y0
+
+ profile = self.computeProfile(x0, y0, x1, y1)
+ title = self.computeProfileTitle(x0, y0, x1, y1)
+ self._setProfile(profile=profile, title=title)
+
+ def _setProfile(self, profile=None, title=''):
+ """Set profile data and emit signal.
+
+ :param profile: points and profile values
+ :param str title:
+ """
+ self.__profile = profile
+ self.__profileTitle = title
+
+ self.sigProfileChanged.emit()
+
+ def clearProfile(self):
+ """Clear the current line ROI and associated profile"""
+ roiManager = self._getRoiManager()
+ if roiManager is not None:
+ roiManager.clear()
+
+ self._setProfile(profile=None, title='')
diff --git a/silx/gui/plot/tools/profile/__init__.py b/silx/gui/plot/tools/profile/__init__.py
new file mode 100644
index 0000000..d91191e
--- /dev/null
+++ b/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/silx/gui/plot/tools/roi.py b/silx/gui/plot/tools/roi.py
new file mode 100644
index 0000000..d58c041
--- /dev/null
+++ b/silx/gui/plot/tools/roi.py
@@ -0,0 +1,934 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides ROI interaction for :class:`~silx.gui.plot.PlotWidget`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import collections
+import functools
+import logging
+import time
+import weakref
+
+import numpy
+
+from ....third_party import enum
+from ....utils.weakref import WeakMethodProxy
+from ... import qt, icons
+from .. import PlotWidget
+from ..items import roi as roi_items
+
+from ...colors import rgba
+
+
+logger = logging.getLogger(__name__)
+
+
+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."""
+
+ 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.
+ """
+
+ sigInteractiveModeFinished = qt.Signal()
+ """Signal emitted when leaving and interactive ROI drawing.
+
+ It provides the list of ROIs.
+ """
+
+ _MODE_ACTIONS_PARAMS = collections.OrderedDict()
+ # Interactive mode: (icon name, text)
+ _MODE_ACTIONS_PARAMS[roi_items.PointROI] = 'add-shape-point', 'Add point markers'
+ _MODE_ACTIONS_PARAMS[roi_items.RectangleROI] = 'add-shape-rectangle', 'Add rectangle ROI'
+ _MODE_ACTIONS_PARAMS[roi_items.PolygonROI] = 'add-shape-polygon', 'Add polygon ROI'
+ _MODE_ACTIONS_PARAMS[roi_items.LineROI] = 'add-shape-diagonal', 'Add line ROI'
+ _MODE_ACTIONS_PARAMS[roi_items.HorizontalLineROI] = 'add-shape-horizontal', 'Add horizontal line ROI'
+ _MODE_ACTIONS_PARAMS[roi_items.VerticalLineROI] = 'add-shape-vertical', 'Add vertical line ROI'
+ _MODE_ACTIONS_PARAMS[roi_items.ArcROI] = 'add-shape-arc', 'Add arc ROI'
+
+ 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._color = rgba('red')
+
+ self._label = "__RegionOfInterestManager__%d" % id(self)
+
+ self._eventLoop = None
+
+ self._modeActions = {}
+
+ parent.sigInteractiveModeChanged.connect(
+ self._plotInteractiveModeChanged)
+
+ @classmethod
+ def getSupportedRoiClasses(cls):
+ """Returns the default available ROI classes
+
+ :rtype: List[class]
+ """
+ return tuple(cls._MODE_ACTIONS_PARAMS.keys())
+
+ # 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 str roiClass: The ROI class which will be crated 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
+ if roiClass in self._MODE_ACTIONS_PARAMS:
+ iconName, text = self._MODE_ACTIONS_PARAMS[roiClass]
+ else:
+ iconName = "add-shape-unknown"
+ name = roiClass._getKind()
+ if name is None:
+ name = roiClass.__name__
+ text = 'Add %s' % name
+ action = qt.QAction(self)
+ action.setIcon(icons.getQIcon(iconName))
+ action.setText(text)
+ action.setCheckable(True)
+ action.setChecked(self.getCurrentInteractionModeRoiClass() is roiClass)
+ action.setToolTip(text)
+
+ action.triggered[bool].connect(functools.partial(
+ WeakMethodProxy(self._modeActionTriggered), roiClass=roiClass))
+ self._modeActions[roiClass] = action
+ return action
+
+ def _modeActionTriggered(self, checked, roiClass):
+ """Handle mode actions being checked by the user
+
+ :param bool checked:
+ :param str kind: Corresponding shape kind
+ """
+ if checked:
+ self.start(roiClass)
+ else: # Keep action checked
+ action = self.sender()
+ action.setChecked(True)
+
+ def _updateModeActions(self):
+ """Check/Uncheck action corresponding to current mode"""
+ for roiClass, action in self._modeActions.items():
+ action.setChecked(roiClass == self.getCurrentInteractionModeRoiClass())
+
+ # PlotWidget eventFilter and listeners
+
+ def _plotInteractiveModeChanged(self, source):
+ """Handle change of interactive mode in the plot"""
+ if source is not self:
+ self.__roiInteractiveModeEnded()
+
+ else: # Check the corresponding action
+ self._updateModeActions()
+
+ # 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)
+ self.createRoi(roiClass, points=points)
+
+ 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
+ self._drawnROI = self.createRoi(roiClass, points=points)
+ else:
+ self._drawnROI.setFirstShapePoints(points)
+
+ if event['event'] == 'drawingFinished':
+ if kind == 'polygon' and len(points) > 1:
+ self._drawnROI.setFirstShapePoints(points[:-1])
+ self._drawnROI = None # Stop drawing
+
+ # 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):
+ """Handle ROI object changed"""
+ self.sigRoiChanged.emit()
+
+ def createRoi(self, roiClass, points, label='', index=None):
+ """Create a new ROI and add it to list of ROIs.
+
+ :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)
+ roi.setLabel(str(label))
+ roi.setFirstShapePoints(points)
+
+ self.addRoi(roi, index)
+ return roi
+
+ 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
+ :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)
+
+ 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')
+
+ self.sigRoiAboutToBeRemoved.emit(roi)
+
+ self._rois.remove(roi)
+ roi.sigRegionChanged.disconnect(self._regionOfInterestChanged)
+ roi.setParent(None)
+ self._roisUpdated()
+
+ 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 isStarted(self):
+ """Returns True if an interactive ROI drawing mode is active.
+
+ :rtype: bool
+ """
+ return self._roiClass is not None
+
+ def start(self, roiClass):
+ """Start an interactive ROI drawing mode.
+
+ :param class roiClass: The ROI class to create. It have to inherite from
+ `roi_items.RegionOfInterest`.
+ :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
+ 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)
+
+ plot.sigPlotSignal.connect(self._handleInteraction)
+
+ self.sigInteractiveModeStarted.emit(roiClass)
+
+ return True
+
+ def __roiInteractiveModeEnded(self):
+ """Handle end of ROI draw interactive mode"""
+ if self.isStarted():
+ self._roiClass = None
+
+ if self._drawnROI is not None:
+ # Cancel ROI create
+ self.removeRoi(self._drawnROI)
+ self._drawnROI = None
+
+ plot = self.parent()
+ if plot is not None:
+ plot.sigPlotSignal.disconnect(self._handleInteraction)
+
+ self._updateModeActions()
+
+ 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.setInteractiveMode(mode='zoom', source=None)
+ 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 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())
+
+ kind = self.__execClass._getKind()
+ max_ = self.getMaxRois()
+
+ if max_ is None:
+ message = 'Select %ss (%d selected)' % (kind, nbrois)
+
+ elif max_ <= 1:
+ message = 'Select a %s' % kind
+ else:
+ message = 'Select %d/%d %ss' % (nbrois, max_, kind)
+
+ 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
+
+
+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
+
+ self.setColumnCount(5)
+ self.setHorizontalHeaderLabels(
+ ['Label', 'Edit', 'Kind', 'Coordinates', ''])
+
+ horizontalHeader = self.horizontalHeader()
+ horizontalHeader.setDefaultAlignment(qt.Qt.AlignLeft)
+ if hasattr(horizontalHeader, 'setResizeMode'): # Qt 4
+ setSectionResizeMode = horizontalHeader.setResizeMode
+ else: # Qt5
+ setSectionResizeMode = horizontalHeader.setSectionResizeMode
+
+ setSectionResizeMode(0, qt.QHeaderView.Interactive)
+ setSectionResizeMode(1, qt.QHeaderView.ResizeToContents)
+ setSectionResizeMode(2, qt.QHeaderView.ResizeToContents)
+ setSectionResizeMode(3, qt.QHeaderView.Stretch)
+ 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)
+
+ @staticmethod
+ def __itemChanged(item):
+ """Handle item updates"""
+ column = item.column()
+ roi = item.data(qt.Qt.UserRole)
+ if column == 0:
+ roi.setLabel(item.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
+ label = roi.getLabel()
+ item = qt.QTableWidgetItem(label)
+ item.setFlags(baseFlags | qt.Qt.ItemIsEditable)
+ item.setData(qt.Qt.UserRole, roi)
+ self.setItem(index, 0, item)
+
+ # Editable
+ item = qt.QTableWidgetItem()
+ item.setFlags(baseFlags | qt.Qt.ItemIsUserCheckable)
+ item.setData(qt.Qt.UserRole, roi)
+ 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._getKind()
+ 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/silx/gui/plot/tools/test/__init__.py b/silx/gui/plot/tools/test/__init__.py
new file mode 100644
index 0000000..79301ab
--- /dev/null
+++ b/silx/gui/plot/tools/test/__init__.py
@@ -0,0 +1,48 @@
+# 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__ = "26/03/2018"
+
+
+import unittest
+
+from . import testROI
+from . import testTools
+from . import testScatterProfileToolBar
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTests(
+ [testROI.suite(),
+ testTools.suite(),
+ testScatterProfileToolBar.suite(),
+ ])
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/tools/test/testROI.py b/silx/gui/plot/tools/test/testROI.py
new file mode 100644
index 0000000..5032036
--- /dev/null
+++ b/silx/gui/plot/tools/test/testROI.py
@@ -0,0 +1,456 @@
+# 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.testing
+
+from silx.gui import qt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.test.utils 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.VerticalLineROI()
+ 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 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 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.assertAlmostEqual(item.isClosed(), True)
+
+ 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.assertAlmostEqual(item.isClosed(), True)
+
+ 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)
+
+
+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.))))),
+ )
+
+ 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
+ manager.createRoi(roiClass, points[0])
+ 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
+ manager.createRoi(roiClass, points[0])
+ self.qapp.processEvents()
+ manager.createRoi(roiClass, points[1])
+ 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
+ manager.createRoi(roiClass, points[0])
+ self.qapp.processEvents()
+ manager.createRoi(roiClass, points[1])
+ 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)
+
+ 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 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 suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestRoiItems))
+ test_suite.addTest(loadTests(TestRegionOfInterestManager))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
new file mode 100644
index 0000000..16972f9
--- /dev/null
+++ b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
@@ -0,0 +1,216 @@
+# 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.test.utils import TestCaseQt
+from silx.gui.plot import PlotWindow
+from silx.gui.plot.tools import profile
+import silx.gui.plot.items.roi as roi_items
+
+
+class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase):
+ """Tests for ScatterProfileToolBar class"""
+
+ def setUp(self):
+ super(TestScatterProfileToolBar, self).setUp()
+ self.plot = PlotWindow()
+
+ self.profile = profile.ScatterProfileToolBar(plot=self.plot)
+
+ self.plot.addToolBar(self.profile)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ del self.profile
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestScatterProfileToolBar, self).tearDown()
+
+ def testNoProfile(self):
+ """Test ScatterProfileToolBar without profile"""
+ self.assertEqual(self.profile.getPlotWidget(), self.plot)
+
+ # 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()
+
+ # Check that there is no profile
+ self.assertIsNone(self.profile.getProfileValues())
+ self.assertIsNone(self.profile.getProfilePoints())
+
+ def testHorizontalProfile(self):
+ """Test ScatterProfileToolBar horizontal profile"""
+ nPoints = 8
+ self.profile.setNPoints(nPoints)
+ self.assertEqual(self.profile.getNPoints(), nPoints)
+
+ # 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()
+
+ # Activate Horizontal profile
+ hlineAction = self.profile.actions()[0]
+ hlineAction.trigger()
+ self.qapp.processEvents()
+
+ # Set a ROI profile
+ roi = roi_items.HorizontalLineROI()
+ roi.setPosition(0.5)
+ self.profile._getRoiManager().addRoi(roi)
+
+ # Wait for async interpolator init
+ for _ in range(10):
+ self.qWait(200)
+ if not self.profile.hasPendingOperations():
+ break
+
+ self.assertIsNotNone(self.profile.getProfileValues())
+ points = self.profile.getProfilePoints()
+ self.assertEqual(len(points), nPoints)
+
+ # Check that profile has same limits than Plot
+ xLimits = self.plot.getXAxis().getLimits()
+ self.assertEqual(points[0, 0], xLimits[0])
+ self.assertEqual(points[-1, 0], xLimits[1])
+
+ # Clear the profile
+ clearAction = self.profile.actions()[-1]
+ clearAction.trigger()
+ self.qapp.processEvents()
+
+ self.assertIsNone(self.profile.getProfileValues())
+ self.assertIsNone(self.profile.getProfilePoints())
+ self.assertEqual(self.profile.getProfileTitle(), '')
+
+ def testVerticalProfile(self):
+ """Test ScatterProfileToolBar vertical profile"""
+ nPoints = 8
+ self.profile.setNPoints(nPoints)
+ self.assertEqual(self.profile.getNPoints(), nPoints)
+
+ # 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()
+
+ # Activate vertical profile
+ vlineAction = self.profile.actions()[1]
+ vlineAction.trigger()
+ self.qapp.processEvents()
+
+ # Set a ROI profile
+ roi = roi_items.VerticalLineROI()
+ roi.setPosition(0.5)
+ self.profile._getRoiManager().addRoi(roi)
+
+ # Wait for async interpolator init
+ for _ in range(10):
+ self.qWait(200)
+ if not self.profile.hasPendingOperations():
+ break
+
+ self.assertIsNotNone(self.profile.getProfileValues())
+ points = self.profile.getProfilePoints()
+ self.assertEqual(len(points), nPoints)
+
+ # Check that profile has same limits than Plot
+ yLimits = self.plot.getYAxis().getLimits()
+ self.assertEqual(points[0, 1], yLimits[0])
+ self.assertEqual(points[-1, 1], yLimits[1])
+
+ # Check that profile limits are updated when changing limits
+ self.plot.getYAxis().setLimits(yLimits[0] + 1, yLimits[1] + 10)
+ self.qapp.processEvents()
+ yLimits = self.plot.getYAxis().getLimits()
+ points = self.profile.getProfilePoints()
+ self.assertEqual(points[0, 1], yLimits[0])
+ self.assertEqual(points[-1, 1], yLimits[1])
+
+ # Clear the plot
+ self.plot.clear()
+ self.qapp.processEvents()
+ self.assertIsNone(self.profile.getProfileValues())
+ self.assertIsNone(self.profile.getProfilePoints())
+
+ def testLineProfile(self):
+ """Test ScatterProfileToolBar line profile"""
+ nPoints = 8
+ self.profile.setNPoints(nPoints)
+ self.assertEqual(self.profile.getNPoints(), nPoints)
+
+ # Activate line profile
+ lineAction = self.profile.actions()[2]
+ lineAction.trigger()
+ self.qapp.processEvents()
+
+ # 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 = roi_items.LineROI()
+ roi.setEndPoints(numpy.array([0., 0.]), numpy.array([1., 1.]))
+ self.profile._getRoiManager().addRoi(roi)
+
+ # Wait for async interpolator init
+ for _ in range(10):
+ self.qWait(200)
+ if not self.profile.hasPendingOperations():
+ break
+
+ self.assertIsNotNone(self.profile.getProfileValues())
+ points = self.profile.getProfilePoints()
+ self.assertEqual(len(points), nPoints)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestScatterProfileToolBar))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/tools/test/testTools.py b/silx/gui/plot/tools/test/testTools.py
new file mode 100644
index 0000000..810b933
--- /dev/null
+++ b/silx/gui/plot/tools/test/testTools.py
@@ -0,0 +1,175 @@
+# 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.
+#
+# ###########################################################################*/
+"""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 TestLogging
+from silx.gui.test.utils 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
+
+
+# Makes sure a QApplication exists
+_qapp = qt.QApplication.instance() or qt.QApplication([])
+
+
+def _tearDownDocTest(docTest):
+ """Tear down to use for test from docstring.
+
+ Checks that plot widget is displayed
+ """
+ plot = docTest.globs['plot']
+ qWaitForWindowExposedAndActivate(plot)
+ plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ plot.close()
+ del plot
+
+# Disable doctest because of
+# "NameError: name 'numpy' is not defined"
+#
+# import doctest
+# positionInfoTestSuite = doctest.DocTestSuite(
+# PlotTools, tearDown=_tearDownDocTest,
+# optionflags=doctest.ELLIPSIS)
+# """Test suite of tests from PlotTools docstrings.
+#
+# Test PositionInfo and ProfileToolBar docstrings.
+# """
+
+
+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)
+
+ with TestLogging(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)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ # test_suite.addTest(positionInfoTestSuite)
+ for testClass in (TestPositionInfo, TestPlotToolsToolbars):
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ testClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/tools/toolbars.py b/silx/gui/plot/tools/toolbars.py
new file mode 100644
index 0000000..28fb7f9
--- /dev/null
+++ b/silx/gui/plot/tools/toolbars.py
@@ -0,0 +1,356 @@
+# 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 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
+
+
+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._symbolToolButton = PlotToolButtons.SymbolToolButton(
+ parent=self, plot=plot)
+ self.addWidget(self._symbolToolButton)
+
+ 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 getSymbolToolButton(self):
+ """Returns the QToolButton controlling symbol size and marker.
+
+ :rtype: SymbolToolButton
+ """
+ return self._symbolToolButton
+
+ def getKeepDataAspectRatioButton(self):
+ """Returns the QToolButton controlling data aspect ratio.
+
+ :rtype: QToolButton
+ """
+ return self._keepDataAspectRatioButton