diff options
Diffstat (limited to 'silx/gui/plot/tools')
-rw-r--r-- | silx/gui/plot/tools/LimitsToolBar.py | 131 | ||||
-rw-r--r-- | silx/gui/plot/tools/PositionInfo.py | 347 | ||||
-rw-r--r-- | silx/gui/plot/tools/__init__.py | 50 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/ImageProfileToolBar.py | 271 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/ScatterProfileToolBar.py | 431 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/_BaseProfileToolBar.py | 430 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/__init__.py | 38 | ||||
-rw-r--r-- | silx/gui/plot/tools/roi.py | 934 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/__init__.py | 48 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testROI.py | 456 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testScatterProfileToolBar.py | 216 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testTools.py | 175 | ||||
-rw-r--r-- | silx/gui/plot/tools/toolbars.py | 356 |
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 |