summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot/tools
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot/tools')
-rw-r--r--src/silx/gui/plot/tools/CurveLegendsWidget.py245
-rw-r--r--src/silx/gui/plot/tools/LimitsToolBar.py123
-rw-r--r--src/silx/gui/plot/tools/PlotToolButton.py92
-rw-r--r--src/silx/gui/plot/tools/PositionInfo.py357
-rw-r--r--src/silx/gui/plot/tools/RadarView.py359
-rw-r--r--src/silx/gui/plot/tools/RulerToolButton.py183
-rw-r--r--src/silx/gui/plot/tools/__init__.py49
-rw-r--r--src/silx/gui/plot/tools/compare/__init__.py29
-rw-r--r--src/silx/gui/plot/tools/compare/core.py198
-rw-r--r--src/silx/gui/plot/tools/compare/profile.py173
-rw-r--r--src/silx/gui/plot/tools/compare/statusbar.py218
-rw-r--r--src/silx/gui/plot/tools/compare/toolbar.py390
-rw-r--r--src/silx/gui/plot/tools/menus.py93
-rw-r--r--src/silx/gui/plot/tools/profile/ScatterProfileToolBar.py44
-rw-r--r--src/silx/gui/plot/tools/profile/__init__.py37
-rw-r--r--src/silx/gui/plot/tools/profile/core.py567
-rw-r--r--src/silx/gui/plot/tools/profile/editors.py309
-rw-r--r--src/silx/gui/plot/tools/profile/manager.py1109
-rw-r--r--src/silx/gui/plot/tools/profile/rois.py1176
-rw-r--r--src/silx/gui/plot/tools/profile/toolbar.py172
-rw-r--r--src/silx/gui/plot/tools/roi.py1515
-rw-r--r--src/silx/gui/plot/tools/test/__init__.py23
-rw-r--r--src/silx/gui/plot/tools/test/testCurveLegendsWidget.py117
-rw-r--r--src/silx/gui/plot/tools/test/testProfile.py604
-rw-r--r--src/silx/gui/plot/tools/test/testRoiCore.py535
-rw-r--r--src/silx/gui/plot/tools/test/testRoiItems.py313
-rw-r--r--src/silx/gui/plot/tools/test/testScatterProfileToolBar.py185
-rw-r--r--src/silx/gui/plot/tools/test/testTools.py135
-rw-r--r--src/silx/gui/plot/tools/toolbars.py357
29 files changed, 9707 insertions, 0 deletions
diff --git a/src/silx/gui/plot/tools/CurveLegendsWidget.py b/src/silx/gui/plot/tools/CurveLegendsWidget.py
new file mode 100644
index 0000000..0ebea0d
--- /dev/null
+++ b/src/silx/gui/plot/tools/CurveLegendsWidget.py
@@ -0,0 +1,245 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides a widget to display :class:`PlotWidget` curve legends.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "20/07/2018"
+
+
+import logging
+import weakref
+
+
+from ... import qt
+from ...widgets.FlowLayout import FlowLayout as _FlowLayout
+from ..LegendSelector import LegendIcon as _LegendIcon
+from .. import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _LegendWidget(qt.QWidget):
+ """Widget displaying curve style and its legend
+
+ :param QWidget parent: See :class:`QWidget`
+ :param ~silx.gui.plot.items.Curve curve: Associated curve
+ """
+
+ def __init__(self, parent, curve):
+ super(_LegendWidget, self).__init__(parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 10, 0)
+
+ curve.sigItemChanged.connect(self._curveChanged)
+
+ icon = _LegendIcon(curve=curve)
+ layout.addWidget(icon)
+
+ label = qt.QLabel(curve.getName())
+ label.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter)
+ layout.addWidget(label)
+
+ self._update()
+
+ def getCurve(self):
+ """Returns curve associated to this widget
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ icon = self.findChild(_LegendIcon)
+ return icon.getCurve()
+
+ def _update(self):
+ """Update widget according to current curve state."""
+ curve = self.getCurve()
+ if curve is None:
+ _logger.error("Curve no more exists")
+ self.setVisible(False)
+ return
+
+ self.setEnabled(curve.isVisible())
+
+ label = self.findChild(qt.QLabel)
+ if curve.isHighlighted():
+ label.setStyleSheet("border: 1px solid black")
+ else:
+ label.setStyleSheet("")
+
+ def _curveChanged(self, event):
+ """Handle update of curve item
+
+ :param event: Kind of change
+ """
+ if event in (
+ items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.HIGHLIGHTED,
+ items.ItemChangedType.HIGHLIGHTED_STYLE,
+ ):
+ self._update()
+
+
+class CurveLegendsWidget(qt.QWidget):
+ """Widget displaying curves legends in a plot
+
+ :param QWidget parent: See :class:`QWidget`
+ """
+
+ sigCurveClicked = qt.Signal(object)
+ """Signal emitted when the legend of a curve is clicked
+
+ It provides the corresponding curve.
+ """
+
+ def __init__(self, parent=None):
+ super(CurveLegendsWidget, self).__init__(parent)
+ self._clicked = None
+ self._legends = {}
+ self._plotRef = None
+
+ def layout(self):
+ layout = super(CurveLegendsWidget, self).layout()
+ if layout is None:
+ # Lazy layout initialization to allow overloading
+ layout = _FlowLayout()
+ layout.setHorizontalSpacing(0)
+ self.setLayout(layout)
+ return layout
+
+ def getPlotWidget(self):
+ """Returns the associated :class:`PlotWidget`
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ return None if self._plotRef is None else self._plotRef()
+
+ def setPlotWidget(self, plot):
+ """Set the associated :class:`PlotWidget`
+
+ :param ~silx.gui.plot.PlotWidget plot: Plot widget to attach
+ """
+ previousPlot = self.getPlotWidget()
+ if previousPlot is not None:
+ previousPlot.sigItemAdded.disconnect(self._itemAdded)
+ previousPlot.sigItemAboutToBeRemoved.disconnect(self._itemRemoved)
+ for legend in list(self._legends.keys()):
+ self._removeLegend(legend)
+
+ self._plotRef = None if plot is None else weakref.ref(plot)
+
+ if plot is not None:
+ plot.sigItemAdded.connect(self._itemAdded)
+ plot.sigItemAboutToBeRemoved.connect(self._itemRemoved)
+
+ for legend in plot.getAllCurves(just_legend=True):
+ self._addLegend(legend)
+
+ def curveAt(self, *args):
+ """Returns the curve object represented at the given position
+
+ Either takes a QPoint or x and y as input in widget coordinates.
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ if len(args) == 1:
+ point = args[0]
+ elif len(args) == 2:
+ point = qt.QPoint(*args)
+ else:
+ raise ValueError("Unsupported arguments")
+ assert isinstance(point, qt.QPoint)
+
+ widget = self.childAt(point)
+ while widget not in (self, None):
+ if isinstance(widget, _LegendWidget):
+ return widget.getCurve()
+ widget = widget.parent()
+ return None # No widget or not in _LegendWidget
+
+ def _itemAdded(self, item):
+ """Handle item added to the plot content"""
+ if isinstance(item, items.Curve):
+ self._addLegend(item.getName())
+
+ def _itemRemoved(self, item):
+ """Handle item removed from the plot content"""
+ if isinstance(item, items.Curve):
+ self._removeLegend(item.getName())
+
+ def _addLegend(self, legend):
+ """Add a curve to the legends
+
+ :param str legend: Curve's legend
+ """
+ if legend in self._legends:
+ return # Can happen when changing curve's y axis
+
+ plot = self.getPlotWidget()
+ if plot is None:
+ return None
+
+ curve = plot.getCurve(legend)
+ if curve is None:
+ _logger.error("Curve not found: %s" % legend)
+ return
+
+ widget = _LegendWidget(parent=self, curve=curve)
+ self.layout().addWidget(widget)
+ self._legends[legend] = widget
+
+ def _removeLegend(self, legend):
+ """Remove a curve from the legends if it exists
+
+ :param str legend: The curve's legend
+ """
+ widget = self._legends.pop(legend, None)
+ if widget is None:
+ _logger.warning("Unknown legend: %s" % legend)
+ else:
+ self.layout().removeWidget(widget)
+ widget.setParent(None)
+
+ def mousePressEvent(self, event):
+ if event.button() == qt.Qt.LeftButton:
+ self._clicked = event.pos()
+
+ _CLICK_THRESHOLD = 5
+ """Threshold for clicks"""
+
+ def mouseMoveEvent(self, event):
+ if self._clicked is not None:
+ dx = abs(self._clicked.x() - event.pos().x())
+ dy = abs(self._clicked.y() - event.pos().y())
+ if dx > self._CLICK_THRESHOLD or dy > self._CLICK_THRESHOLD:
+ self._clicked = None # Click is cancelled
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == qt.Qt.LeftButton and self._clicked is not None:
+ curve = self.curveAt(event.pos())
+ if curve is not None:
+ self.sigCurveClicked.emit(curve)
+
+ self._clicked = None
diff --git a/src/silx/gui/plot/tools/LimitsToolBar.py b/src/silx/gui/plot/tools/LimitsToolBar.py
new file mode 100644
index 0000000..5ed09f7
--- /dev/null
+++ b/src/silx/gui/plot/tools/LimitsToolBar.py
@@ -0,0 +1,123 @@
+# /*##########################################################################
+#
+# 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
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "16/10/2017"
+
+
+from ... import qt
+from ...widgets.FloatEdit import FloatEdit
+
+
+class LimitsToolBar(qt.QToolBar):
+ """QToolBar displaying and controlling the limits of a :class:`PlotWidget`.
+
+ To run the following sample code, a QApplication must be initialized.
+ First, create a PlotWindow:
+
+ >>> from silx.gui.plot import PlotWindow
+ >>> plot = PlotWindow() # Create a PlotWindow to add the toolbar to
+
+ Then, create the LimitsToolBar and add it to the PlotWindow.
+
+ >>> from silx.gui import qt
+ >>> from silx.gui.plot.tools import LimitsToolBar
+
+ >>> toolbar = LimitsToolBar(plot=plot) # Create the toolbar
+ >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolbar) # Add it to the plot
+ >>> plot.show() # To display the PlotWindow with the limits toolbar
+
+ :param parent: See :class:`QToolBar`.
+ :param plot: :class:`PlotWidget` instance on which to operate.
+ :param str title: See :class:`QToolBar`.
+ """
+
+ def __init__(self, parent=None, plot=None, title="Limits"):
+ super(LimitsToolBar, self).__init__(title, parent)
+ assert plot is not None
+ self._plot = plot
+ self._plot.sigPlotSignal.connect(self._plotWidgetSlot)
+
+ self._initWidgets()
+
+ @property
+ def plot(self):
+ """The :class:`PlotWidget` the toolbar is attached to."""
+ return self._plot
+
+ def _initWidgets(self):
+ """Create and init Toolbar widgets."""
+ xMin, xMax = self.plot.getXAxis().getLimits()
+ yMin, yMax = self.plot.getYAxis().getLimits()
+
+ self.addWidget(qt.QLabel("Limits: "))
+ self.addWidget(qt.QLabel(" X: "))
+ self._xMinFloatEdit = FloatEdit(self, xMin)
+ self._xMinFloatEdit.editingFinished[()].connect(self._xFloatEditChanged)
+ self.addWidget(self._xMinFloatEdit)
+
+ self._xMaxFloatEdit = FloatEdit(self, xMax)
+ self._xMaxFloatEdit.editingFinished[()].connect(self._xFloatEditChanged)
+ self.addWidget(self._xMaxFloatEdit)
+
+ self.addWidget(qt.QLabel(" Y: "))
+ self._yMinFloatEdit = FloatEdit(self, yMin)
+ self._yMinFloatEdit.editingFinished[()].connect(self._yFloatEditChanged)
+ self.addWidget(self._yMinFloatEdit)
+
+ self._yMaxFloatEdit = FloatEdit(self, yMax)
+ self._yMaxFloatEdit.editingFinished[()].connect(self._yFloatEditChanged)
+ self.addWidget(self._yMaxFloatEdit)
+
+ def _plotWidgetSlot(self, event):
+ """Listen to :class:`PlotWidget` events."""
+ if event["event"] not in ("limitsChanged",):
+ return
+
+ xMin, xMax = self.plot.getXAxis().getLimits()
+ yMin, yMax = self.plot.getYAxis().getLimits()
+
+ self._xMinFloatEdit.setValue(xMin)
+ self._xMaxFloatEdit.setValue(xMax)
+ self._yMinFloatEdit.setValue(yMin)
+ self._yMaxFloatEdit.setValue(yMax)
+
+ def _xFloatEditChanged(self):
+ """Handle X limits changed from the GUI."""
+ xMin, xMax = self._xMinFloatEdit.value(), self._xMaxFloatEdit.value()
+ if xMax < xMin:
+ xMin, xMax = xMax, xMin
+
+ self.plot.getXAxis().setLimits(xMin, xMax)
+
+ def _yFloatEditChanged(self):
+ """Handle Y limits changed from the GUI."""
+ yMin, yMax = self._yMinFloatEdit.value(), self._yMaxFloatEdit.value()
+ if yMax < yMin:
+ yMin, yMax = yMax, yMin
+
+ self.plot.getYAxis().setLimits(yMin, yMax)
diff --git a/src/silx/gui/plot/tools/PlotToolButton.py b/src/silx/gui/plot/tools/PlotToolButton.py
new file mode 100644
index 0000000..3a14f77
--- /dev/null
+++ b/src/silx/gui/plot/tools/PlotToolButton.py
@@ -0,0 +1,92 @@
+# /*##########################################################################
+#
+# Copyright (c) 2023 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 an abstract PlotToolButton that can be use to create
+plot tools for a toolbar.
+"""
+
+from __future__ import annotations
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "20/12/2023"
+
+
+import logging
+import weakref
+
+from silx.gui import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PlotToolButton(qt.QToolButton):
+ """A QToolButton connected to a :class:`~silx.gui.plot.PlotWidget`."""
+
+ def __init__(self, parent: qt.QWidget | None = None, plot=None):
+ super(PlotToolButton, self).__init__(parent)
+ self._plotRef = None
+ if plot is not None:
+ self.setPlot(plot)
+
+ def plot(self):
+ """
+ Returns the plot connected to the widget.
+ """
+ return None if self._plotRef is None else self._plotRef()
+
+ def setPlot(self, plot):
+ """
+ Set the plot connected to the widget
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ """
+ previousPlot = self.plot()
+
+ if previousPlot is plot:
+ return
+ if previousPlot is not None:
+ self._disconnectPlot(previousPlot)
+
+ if plot is None:
+ self._plotRef = None
+ else:
+ self._plotRef = weakref.ref(plot)
+ self._connectPlot(plot)
+
+ def _connectPlot(self, plot):
+ """
+ Called when the plot is connected to the widget
+
+ :param plot: :class:`.PlotWidget` instance
+ """
+ pass
+
+ def _disconnectPlot(self, plot):
+ """
+ Called when the plot is disconnected from the widget
+
+ :param plot: :class:`.PlotWidget` instance
+ """
+ pass
diff --git a/src/silx/gui/plot/tools/PositionInfo.py b/src/silx/gui/plot/tools/PositionInfo.py
new file mode 100644
index 0000000..e3b8425
--- /dev/null
+++ b/src/silx/gui/plot/tools/PositionInfo.py
@@ -0,0 +1,357 @@
+# /*##########################################################################
+#
+# Copyright (c) 2016-2023 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.
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "16/10/2017"
+
+
+import logging
+import numbers
+import traceback
+import weakref
+
+import numpy
+
+from ... import qt
+from .. import items
+from ...widgets.ElidedLabel import ElidedLabel
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _PositionInfoLabel(ElidedLabel):
+ """QLabel with a default size larger than what is displayed."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
+
+ def sizeHint(self):
+ hint = super().sizeHint()
+ width = self.fontMetrics().boundingRect("##############").width()
+ return qt.QSize(max(hint.width(), width), hint.height())
+
+
+# PositionInfo ################################################################
+
+
+class PositionInfo(qt.QWidget):
+ """QWidget displaying coords converted from data coords of the mouse.
+
+ Provide this widget with a list of couple:
+
+ - A name to display before the data
+ - A function that takes (x, y) as arguments and returns something that
+ gets converted to a string.
+ If the result is a float it is converted with '%.7g' format.
+
+ To run the following sample code, a QApplication must be initialized.
+ First, create a PlotWindow and add a QToolBar where to place the
+ PositionInfo widget.
+
+ >>> from silx.gui.plot import PlotWindow
+ >>> from silx.gui import qt
+
+ >>> plot = PlotWindow() # Create a PlotWindow to add the widget to
+ >>> toolBar = qt.QToolBar() # Create a toolbar to place the widget in
+ >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) # Add it to plot
+
+ Then, create the PositionInfo widget and add it to the toolbar.
+ The PositionInfo widget is created with a list of converters, here
+ to display polar coordinates of the mouse position.
+
+ >>> import numpy
+ >>> from silx.gui.plot.tools import PositionInfo
+
+ >>> position = PositionInfo(plot=plot, converters=[
+ ... ('Radius', lambda x, y: numpy.sqrt(x*x + y*y)),
+ ... ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x)))])
+ >>> toolBar.addWidget(position) # Add the widget to the toolbar
+ <...>
+ >>> plot.show() # To display the PlotWindow with the position widget
+
+ :param plot: The PlotWidget this widget is displaying data coords from.
+ :param converters:
+ List of 2-tuple: name to display and conversion function from (x, y)
+ in data coords to displayed value.
+ If None, the default, it displays X and Y.
+ :param parent: Parent widget
+ """
+
+ SNAP_THRESHOLD_DIST = 5
+
+ def __init__(self, parent=None, plot=None, converters=None):
+ assert plot is not None
+ self._plotRef = weakref.ref(plot)
+ self._snappingMode = self.SNAPPING_DISABLED
+
+ super(PositionInfo, self).__init__(parent)
+
+ if converters is None:
+ converters = (("X", lambda x, y: x), ("Y", lambda x, y: y))
+
+ self._fields = [] # To store (QLineEdit, name, function (x, y)->v)
+
+ # Create a new layout with new widgets
+ layout = qt.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ # layout.setSpacing(0)
+
+ # Create all QLabel and store them with the corresponding converter
+ for name, func in converters:
+ layout.addWidget(qt.QLabel("<b>" + name + ":</b>"))
+
+ contentWidget = _PositionInfoLabel(self)
+ contentWidget.setText("------")
+ layout.addWidget(contentWidget)
+ self._fields.append((contentWidget, name, func))
+
+ layout.addStretch(1)
+ self.setLayout(layout)
+
+ # Connect to Plot events
+ plot.sigPlotSignal.connect(self._plotEvent)
+
+ def getPlotWidget(self):
+ """Returns the PlotWidget this widget is attached to or None.
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ return self._plotRef()
+
+ 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 = "" # 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.getActiveScatter()
+ if activeScatter:
+ selectedItems.append(activeScatter)
+
+ else:
+ kinds = []
+ if snappingMode & self.SNAPPING_CURVE:
+ kinds.append(items.Curve)
+ kinds.append(items.Histogram)
+ if snappingMode & self.SNAPPING_SCATTER:
+ kinds.append(items.Scatter)
+ selectedItems = [
+ item
+ for item in plot.getItems()
+ if isinstance(item, tuple(kinds)) and item.isVisible()
+ ]
+
+ # Compute distance threshold
+ window = plot.window()
+ windowHandle = window.windowHandle()
+ if windowHandle is not None:
+ ratio = windowHandle.devicePixelRatio()
+ else:
+ ratio = qt.QGuiApplication.primaryScreen().devicePixelRatio()
+
+ # Baseline squared distance threshold
+ sqDistInPixels = (self.SNAP_THRESHOLD_DIST * ratio) ** 2
+
+ for item in selectedItems:
+ if snappingMode & self.SNAPPING_SYMBOLS_ONLY and (
+ not isinstance(item, items.SymbolMixIn) or not item.getSymbol()
+ ):
+ # Only handled if item symbols are visible
+ continue
+
+ if isinstance(item, items.Histogram):
+ result = item.pick(xPixel, yPixel)
+ if result is not None: # Histogram picked
+ index = result.getIndices()[0]
+ edges = item.getBinEdgesData(copy=False)
+
+ # Snap to bin center and value
+ xData = 0.5 * (edges[index] + edges[index + 1])
+ yData = item.getValueData(copy=False)[index]
+
+ # Update label style sheet
+ styleSheet = ""
+ break
+
+ else: # Curve, Scatter
+ result = item.pick(xPixel, yPixel)
+ if result is None:
+ continue
+ indices = result.getIndices(copy=False)
+ if indices is None:
+ continue
+
+ if isinstance(item, items.YAxisMixIn):
+ axis = item.getYAxis()
+ else:
+ axis = "left"
+
+ xArray = item.getXData(copy=False)[indices]
+ yArray = item.getYData(copy=False)[indices]
+ pixelPositions = plot.dataToPixel(xArray, yArray, axis=axis)
+ if pixelPositions is None:
+ continue
+ sqDistances = (pixelPositions[0] - xPixel) ** 2 + (
+ pixelPositions[1] - yPixel
+ ) ** 2
+ if not numpy.any(numpy.isfinite(sqDistances)):
+ continue
+ closestIndex = numpy.nanargmin(sqDistances)
+ closestSqDistInPixels = sqDistances[closestIndex]
+
+ if closestSqDistInPixels <= sqDistInPixels:
+ # Update label style sheet
+ styleSheet = ""
+
+ # if close enough, snap to data point coord
+ xData, yData = xArray[closestIndex], yArray[closestIndex]
+ sqDistInPixels = closestSqDistInPixels
+
+ 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
diff --git a/src/silx/gui/plot/tools/RadarView.py b/src/silx/gui/plot/tools/RadarView.py
new file mode 100644
index 0000000..8ddb98b
--- /dev/null
+++ b/src/silx/gui/plot/tools/RadarView.py
@@ -0,0 +1,359 @@
+# /*##########################################################################
+#
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""QWidget displaying an overview of a 2D plot.
+
+This shows the available range of the data, and the current location of the
+plot view.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "22/02/2021"
+
+import logging
+import weakref
+from ... import qt
+from ...utils import LockReentrant
+
+_logger = logging.getLogger(__name__)
+
+
+class _DraggableRectItem(qt.QGraphicsRectItem):
+ """RectItem which signals its change through visibleRectDragged."""
+
+ def __init__(self, *args, **kwargs):
+ super(_DraggableRectItem, self).__init__(*args, **kwargs)
+
+ self._previousCursor = None
+ self.setFlag(qt.QGraphicsItem.ItemIsMovable)
+ self.setFlag(qt.QGraphicsItem.ItemSendsGeometryChanges)
+ self.setAcceptHoverEvents(True)
+ self._ignoreChange = False
+ self._constraint = 0, 0, 0, 0
+
+ def setConstraintRect(self, left, top, width, height):
+ """Set the constraint rectangle for dragging.
+
+ The coordinates are in the _DraggableRectItem coordinate system.
+
+ This constraint only applies to modification through interaction
+ (i.e., this constraint is not applied to change through API).
+
+ If the _DraggableRectItem is smaller than the constraint rectangle,
+ the _DraggableRectItem remains within the constraint rectangle.
+ If the _DraggableRectItem is wider than the constraint rectangle,
+ the constraint rectangle remains within the _DraggableRectItem.
+ """
+ self._constraint = left, left + width, top, top + height
+
+ def setPos(self, *args, **kwargs):
+ """Overridden to ignore changes from API in itemChange."""
+ self._ignoreChange = True
+ super(_DraggableRectItem, self).setPos(*args, **kwargs)
+ self._ignoreChange = False
+
+ def moveBy(self, *args, **kwargs):
+ """Overridden to ignore changes from API in itemChange."""
+ self._ignoreChange = True
+ super(_DraggableRectItem, self).moveBy(*args, **kwargs)
+ self._ignoreChange = False
+
+ def itemChange(self, change, value):
+ """Callback called before applying changes to the item."""
+ if change == qt.QGraphicsItem.ItemPositionChange and not self._ignoreChange:
+ # Makes sure that the visible area is in the data
+ # or that data is in the visible area if area is too wide
+ x, y = value.x(), value.y()
+ xMin, xMax, yMin, yMax = self._constraint
+
+ if self.rect().width() <= (xMax - xMin):
+ if x < xMin:
+ value.setX(xMin)
+ elif x > xMax - self.rect().width():
+ value.setX(xMax - self.rect().width())
+ else:
+ if x > xMin:
+ value.setX(xMin)
+ elif x < xMax - self.rect().width():
+ value.setX(xMax - self.rect().width())
+
+ if self.rect().height() <= (yMax - yMin):
+ if y < yMin:
+ value.setY(yMin)
+ elif y > yMax - self.rect().height():
+ value.setY(yMax - self.rect().height())
+ else:
+ if y > yMin:
+ value.setY(yMin)
+ elif y < yMax - self.rect().height():
+ value.setY(yMax - self.rect().height())
+
+ if self.pos() != value:
+ # Notify change through signal
+ views = self.scene().views()
+ assert len(views) == 1
+ views[0].visibleRectDragged.emit(
+ value.x() + self.rect().left(),
+ value.y() + self.rect().top(),
+ self.rect().width(),
+ self.rect().height(),
+ )
+
+ return value
+
+ return super(_DraggableRectItem, self).itemChange(change, value)
+
+ def hoverEnterEvent(self, event):
+ """Called when the mouse enters the rectangle area"""
+ self._previousCursor = self.cursor()
+ self.setCursor(qt.Qt.OpenHandCursor)
+
+ def hoverLeaveEvent(self, event):
+ """Called when the mouse leaves the rectangle area"""
+ if self._previousCursor is not None:
+ self.setCursor(self._previousCursor)
+ self._previousCursor = None
+
+
+class RadarView(qt.QGraphicsView):
+ """Widget presenting a synthetic view of a 2D area and
+ the current visible area.
+
+ Coordinates are as in QGraphicsView:
+ x goes from left to right and y goes from top to bottom.
+ This widget preserves the aspect ratio of the areas.
+
+ The 2D area and the visible area can be set with :meth:`setDataRect`
+ and :meth:`setVisibleRect`.
+ When the visible area has been dragged by the user, its new position
+ is signaled by the *visibleRectDragged* signal.
+
+ It is possible to invert the direction of the axes by using the
+ :meth:`scale` method of QGraphicsView.
+ """
+
+ visibleRectDragged = qt.Signal(float, float, float, float)
+ """Signals that the visible rectangle has been dragged.
+
+ It provides: left, top, width, height in data coordinates.
+ """
+
+ _DATA_PEN = qt.QPen(qt.QColor("white"))
+ _DATA_BRUSH = qt.QBrush(qt.QColor("light gray"))
+ _ACTIVEDATA_PEN = qt.QPen(qt.QColor("black"))
+ _ACTIVEDATA_BRUSH = qt.QBrush(qt.QColor("transparent"))
+ _ACTIVEDATA_PEN.setWidth(2)
+ _ACTIVEDATA_PEN.setCosmetic(True)
+ _VISIBLE_PEN = qt.QPen(qt.QColor("blue"))
+ _VISIBLE_PEN.setWidth(2)
+ _VISIBLE_PEN.setCosmetic(True)
+ _VISIBLE_BRUSH = qt.QBrush(qt.QColor(0, 0, 0, 0))
+ _TOOLTIP = "Radar View:\nRed contour: Visible area\nGray area: The image"
+
+ _PIXMAP_SIZE = 256
+
+ def __init__(self, parent=None):
+ self.__plotRef = None
+ self._scene = qt.QGraphicsScene()
+ self._dataRect = self._scene.addRect(
+ 0, 0, 1, 1, self._DATA_PEN, self._DATA_BRUSH
+ )
+ self._imageRect = self._scene.addRect(
+ 0, 0, 1, 1, self._ACTIVEDATA_PEN, self._ACTIVEDATA_BRUSH
+ )
+ self._imageRect.setVisible(False)
+ self._scatterRect = self._scene.addRect(
+ 0, 0, 1, 1, self._ACTIVEDATA_PEN, self._ACTIVEDATA_BRUSH
+ )
+ self._scatterRect.setVisible(False)
+ self._curveRect = self._scene.addRect(
+ 0, 0, 1, 1, self._ACTIVEDATA_PEN, self._ACTIVEDATA_BRUSH
+ )
+ self._curveRect.setVisible(False)
+
+ self._visibleRect = _DraggableRectItem(0, 0, 1, 1)
+ self._visibleRect.setPen(self._VISIBLE_PEN)
+ self._visibleRect.setBrush(self._VISIBLE_BRUSH)
+ self._scene.addItem(self._visibleRect)
+
+ super(RadarView, self).__init__(self._scene, parent)
+ self.setHorizontalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff)
+ self.setFocusPolicy(qt.Qt.NoFocus)
+ self.setStyleSheet("border: 0px")
+ self.setToolTip(self._TOOLTIP)
+
+ self.__reentrant = LockReentrant()
+ self.visibleRectDragged.connect(self._viewRectDragged)
+
+ self.__timer = qt.QTimer(self)
+ self.__timer.timeout.connect(self._updateDataContent)
+
+ def sizeHint(self):
+ # """Overridden to avoid sizeHint to depend on content size."""
+ return self.minimumSizeHint()
+
+ def wheelEvent(self, event):
+ # """Overridden to disable vertical scrolling with wheel."""
+ event.ignore()
+
+ def resizeEvent(self, event):
+ # """Overridden to fit current content to new size."""
+ self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+ super(RadarView, self).resizeEvent(event)
+
+ def setDataRect(self, left, top, width, height):
+ """Set the bounds of the data rectangular area.
+
+ This sets the coordinate system.
+ """
+ self._dataRect.setRect(left, top, width, height)
+ self._visibleRect.setConstraintRect(left, top, width, height)
+ self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+
+ def setVisibleRect(self, left, top, width, height):
+ """Set the visible rectangular area.
+
+ The coordinates are relative to the data rect.
+ """
+ self.__visibleRect = left, top, width, height
+ self._visibleRect.setRect(0, 0, width, height)
+ self._visibleRect.setPos(left, top)
+ self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+
+ def __setVisibleRectFromPlot(self, plot):
+ """Update radar view visible area.
+
+ Takes care of y coordinate conversion.
+ """
+ xMin, xMax = plot.getXAxis().getLimits()
+ yMin, yMax = plot.getYAxis().getLimits()
+ self.setVisibleRect(xMin, yMin, xMax - xMin, yMax - yMin)
+
+ def getPlotWidget(self):
+ """Returns the connected plot
+
+ :rtype: Union[None,PlotWidget]
+ """
+ if self.__plotRef is None:
+ return None
+ plot = self.__plotRef()
+ if plot is None:
+ self.__plotRef = None
+ return plot
+
+ def setPlotWidget(self, plot):
+ """Set the PlotWidget this radar view connects to.
+
+ As result `setDataRect` and `setVisibleRect` will be called
+ automatically.
+
+ :param Union[None,PlotWidget] plot:
+ """
+ previousPlot = self.getPlotWidget()
+ if previousPlot is not None: # Disconnect previous plot
+ plot.getXAxis().sigLimitsChanged.disconnect(self._xLimitChanged)
+ plot.getYAxis().sigLimitsChanged.disconnect(self._yLimitChanged)
+ plot.getYAxis().sigInvertedChanged.disconnect(self._updateYAxisInverted)
+
+ # Reset plot and timer
+ # FIXME: It would be good to clean up the display here
+ self.__plotRef = None
+ self.__timer.stop()
+
+ if plot is not None: # Connect new plot
+ self.__plotRef = weakref.ref(plot)
+ plot.getXAxis().sigLimitsChanged.connect(self._xLimitChanged)
+ plot.getYAxis().sigLimitsChanged.connect(self._yLimitChanged)
+ plot.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted)
+ self.__setVisibleRectFromPlot(plot)
+ self._updateYAxisInverted()
+ self.__timer.start(500)
+
+ def _xLimitChanged(self, vmin, vmax):
+ plot = self.getPlotWidget()
+ self.__setVisibleRectFromPlot(plot)
+
+ def _yLimitChanged(self, vmin, vmax):
+ plot = self.getPlotWidget()
+ self.__setVisibleRectFromPlot(plot)
+
+ def _updateYAxisInverted(self, inverted=None):
+ """Sync radar view axis orientation."""
+ plot = self.getPlotWidget()
+ if inverted is None:
+ # Do not perform this when called from plot signal
+ inverted = plot.getYAxis().isInverted()
+ # Use scale to invert radarView
+ # RadarView default Y direction is from top to bottom
+ # As opposed to Plot. So invert RadarView when Plot is NOT inverted.
+ self.resetTransform()
+ if not inverted:
+ self.scale(1.0, -1.0)
+ self.update()
+
+ def _viewRectDragged(self, left, top, width, height):
+ """Slot for radar view visible rectangle changes."""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ if self.__reentrant.locked():
+ return
+
+ with self.__reentrant:
+ plot.setLimits(left, left + width, top, top + height)
+
+ def _updateDataContent(self):
+ """Update the content to the current data content"""
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+ ranges = plot.getDataRange()
+ xmin, xmax = ranges.x if ranges.x is not None else (0, 0)
+ ymin, ymax = ranges.y if ranges.y is not None else (0, 0)
+ self.setDataRect(xmin, ymin, xmax - xmin, ymax - ymin)
+
+ self.__updateItem(self._imageRect, plot.getActiveImage())
+ self.__updateItem(self._scatterRect, plot.getActiveScatter())
+ self.__updateItem(self._curveRect, plot.getActiveCurve())
+
+ def __updateItem(self, rect, item):
+ """Sync rect with item bounds
+
+ :param QGraphicsRectItem rect:
+ :param Item item:
+ """
+ if item is None:
+ rect.setVisible(False)
+ return
+ ranges = item._getBounds()
+ if ranges is None:
+ rect.setVisible(False)
+ return
+ xmin, xmax, ymin, ymax = ranges
+ width = xmax - xmin
+ height = ymax - ymin
+ rect.setRect(xmin, ymin, width, height)
+ rect.setVisible(True)
diff --git a/src/silx/gui/plot/tools/RulerToolButton.py b/src/silx/gui/plot/tools/RulerToolButton.py
new file mode 100644
index 0000000..55cc02f
--- /dev/null
+++ b/src/silx/gui/plot/tools/RulerToolButton.py
@@ -0,0 +1,183 @@
+# /*##########################################################################
+#
+# Copyright (c) 20023 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.
+#
+# ###########################################################################*/
+"""
+PlotToolButton to measure a distance in a plot
+"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "30/10/2023"
+
+
+import logging
+import numpy
+import weakref
+import typing
+
+from silx.gui import icons
+
+from .PlotToolButton import PlotToolButton
+
+from silx.gui.plot.tools.roi import RegionOfInterestManager
+from silx.gui.plot.items.roi import LineROI
+from silx.gui.plot import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _RulerROI(LineROI):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._formatFunction: typing.Optional[
+ typing.Callable[
+ [numpy.ndarray, numpy.ndarray], str
+ ]
+ ] = None
+ self.setColor("#001122") # Only there to trig updateStyle
+
+ def registerFormatFunction(
+ self,
+ fct: typing.Callable[
+ [numpy.ndarray, numpy.ndarray], str
+ ],
+ ):
+ """Register a function for the formatting of the label"""
+ self._formatFunction = fct
+
+ def _updatedStyle(self, event, style: items.CurveStyle):
+ style = items.CurveStyle(
+ color="red",
+ gapcolor="white",
+ linestyle=(0, (5, 5)),
+ linewidth=style.getLineWidth())
+ LineROI._updatedStyle(self, event, style)
+ self._handleLabel.setColor("black")
+ self._handleLabel.setBackgroundColor("#FFFFFF60")
+ self._handleLabel.setZValue(1000)
+
+ def setEndPoints(self, startPoint: numpy.ndarray, endPoint: numpy.ndarray):
+ super().setEndPoints(startPoint=startPoint, endPoint=endPoint)
+ if self._formatFunction is not None:
+ ruler_text = self._formatFunction(
+ startPoint=startPoint, endPoint=endPoint
+ )
+ self._updateText(ruler_text)
+
+
+class RulerToolButton(PlotToolButton):
+ """
+ Button to active measurement between two point of the plot
+
+ An instance of `RulerToolButton` can be added to a plot toolbar like:
+ .. code-block:: python
+
+ plot = Plot2D()
+
+ rulerButton = RulerToolButton(parent=plot, plot=plot)
+ plot.toolBar().addWidget(rulerButton)
+ """
+
+ def __init__(
+ self,
+ parent=None,
+ plot=None,
+ ):
+ super().__init__(parent=parent, plot=plot)
+ self.setCheckable(True)
+ self._roiManager = None
+ self.__lastRoiCreated = None
+ self.setIcon(icons.getQIcon("ruler"))
+ self.toggled.connect(self._callback)
+ self._connectPlot(plot)
+
+ def setPlot(self, plot):
+ return super().setPlot(plot)
+
+ @property
+ def _lastRoiCreated(self):
+ if self.__lastRoiCreated is None:
+ return None
+ return self.__lastRoiCreated()
+
+ def _callback(self, *args, **kwargs):
+ if not self._roiManager:
+ return
+ if self._lastRoiCreated is not None:
+ self._lastRoiCreated.setVisible(self.isChecked())
+ if self.isChecked():
+ self._roiManager.start(_RulerROI, self)
+ self.__interactiveModeStarted(self._roiManager)
+ else:
+ source = self._roiManager.getInteractionSource()
+ if source is self:
+ self._roiManager.stop()
+
+ def __interactiveModeStarted(self, roiManager):
+ roiManager.sigInteractiveModeFinished.connect(self.__interactiveModeFinished)
+
+ def __interactiveModeFinished(self):
+ roiManager = self._roiManager
+ if roiManager is not None:
+ roiManager.sigInteractiveModeFinished.disconnect(
+ self.__interactiveModeFinished
+ )
+ self.setChecked(False)
+
+ def _connectPlot(self, plot):
+ """
+ Called when the plot is connected to the widget
+
+ :param plot: :class:`.PlotWidget` instance
+ """
+ if plot is None:
+ return
+ self._roiManager = RegionOfInterestManager(plot)
+ self._roiManager.sigRoiAdded.connect(self._registerCurrentROI)
+
+ def _disconnectPlot(self, plot):
+ if plot and self._lastRoiCreated is not None:
+ self._roiManager.removeRoi(self._lastRoiCreated)
+ self.__lastRoiCreated = None
+ return super()._disconnectPlot(plot)
+
+ def _registerCurrentROI(self, currentRoi):
+ if self._lastRoiCreated is None:
+ self.__lastRoiCreated = weakref.ref(currentRoi)
+ self._lastRoiCreated.registerFormatFunction(self.buildDistanceText)
+ elif currentRoi is not self._lastRoiCreated and self._roiManager is not None:
+ self._roiManager.removeRoi(self._lastRoiCreated)
+ currentRoi.registerFormatFunction(self.buildDistanceText)
+ self.__lastRoiCreated = weakref.ref(currentRoi)
+
+ def buildDistanceText(self, startPoint: numpy.ndarray, endPoint: numpy.ndarray) -> str:
+ """
+ Define the text to be displayed by the ruler.
+
+ It can be redefine to modify precision or handle other parameters
+ (handling pixel size to display metric distance, display distance
+ on each distance - for non-square pixels...)
+ """
+ distance = numpy.linalg.norm(endPoint - startPoint)
+ return f"{distance: .1f}px"
diff --git a/src/silx/gui/plot/tools/__init__.py b/src/silx/gui/plot/tools/__init__.py
new file mode 100644
index 0000000..5b6b74c
--- /dev/null
+++ b/src/silx/gui/plot/tools/__init__.py
@@ -0,0 +1,49 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This package provides a set of widgets working with :class:`PlotWidget`.
+
+It provides some QToolBar and QWidget:
+
+- :class:`InteractiveModeToolBar`
+- :class:`OutputToolBar`
+- :class:`ImageToolBar`
+- :class:`CurveToolBar`
+- :class:`LimitsToolBar`
+- :class:`PositionInfo`
+
+It also provides a :mod:`~silx.gui.plot.tools.roi` module to handle
+interactive region of interest on a :class:`~silx.gui.plot.PlotWidget`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "01/03/2018"
+
+
+from .toolbars import InteractiveModeToolBar # noqa
+from .toolbars import OutputToolBar # noqa
+from .toolbars import ImageToolBar, CurveToolBar, ScatterToolBar # noqa
+
+from .LimitsToolBar import LimitsToolBar # noqa
+from .PositionInfo import PositionInfo # noqa
diff --git a/src/silx/gui/plot/tools/compare/__init__.py b/src/silx/gui/plot/tools/compare/__init__.py
new file mode 100644
index 0000000..7f23852
--- /dev/null
+++ b/src/silx/gui/plot/tools/compare/__init__.py
@@ -0,0 +1,29 @@
+# /*##########################################################################
+#
+# 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 related to the compare image plot.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "09/06/2023"
diff --git a/src/silx/gui/plot/tools/compare/core.py b/src/silx/gui/plot/tools/compare/core.py
new file mode 100644
index 0000000..90dbb79
--- /dev/null
+++ b/src/silx/gui/plot/tools/compare/core.py
@@ -0,0 +1,198 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides main objects shared by the compare image plot.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "09/06/2023"
+
+
+import numpy
+import enum
+import contextlib
+from typing import NamedTuple
+
+from silx.gui.plot.items.image import ImageBase
+from silx.gui.plot.items.core import ItemChangedType, ColormapMixIn
+
+from silx.opencl import ocl
+
+if ocl is not None:
+ try:
+ from silx.opencl import sift
+ except ImportError:
+ # sift module is not available (e.g., in official Debian packages)
+ sift = None
+else: # No OpenCL device or no pyopencl
+ sift = None
+
+
+@enum.unique
+class VisualizationMode(enum.Enum):
+ """Enum for each visualization mode available."""
+
+ ONLY_A = "a"
+ ONLY_B = "b"
+ VERTICAL_LINE = "vline"
+ HORIZONTAL_LINE = "hline"
+ COMPOSITE_RED_BLUE_GRAY = "rbgchannel"
+ COMPOSITE_RED_BLUE_GRAY_NEG = "rbgnegchannel"
+ COMPOSITE_A_MINUS_B = "aminusb"
+
+
+@enum.unique
+class AlignmentMode(enum.Enum):
+ """Enum for each alignment mode available."""
+
+ ORIGIN = "origin"
+ CENTER = "center"
+ STRETCH = "stretch"
+ AUTO = "auto"
+
+
+class AffineTransformation(NamedTuple):
+ """Description of a 2D affine transformation: translation, scale and
+ rotation.
+ """
+
+ tx: float
+ ty: float
+ sx: float
+ sy: float
+ rot: float
+
+
+class _CompareImageItem(ImageBase, ColormapMixIn):
+ """Description of a virtual item of images to compare, in order to share
+ the data through the silx components.
+ """
+
+ def __init__(self):
+ ImageBase.__init__(self)
+ ColormapMixIn.__init__(self)
+ self.__image1 = None
+ self.__image2 = None
+ self.__vizualisationMode = VisualizationMode.ONLY_A
+
+ def getImageData1(self):
+ return self.__image1
+
+ def getImageData2(self):
+ return self.__image2
+
+ def setImageData1(self, image1):
+ if self.__image1 is image1:
+ return
+ self.__image1 = image1
+ self._updated(ItemChangedType.DATA)
+
+ def setImageData2(self, image2):
+ if self.__image2 is image2:
+ return
+ self.__image2 = image2
+ self._updated(ItemChangedType.DATA)
+
+ def getVizualisationMode(self) -> VisualizationMode:
+ return self.__vizualisationMode
+
+ @contextlib.contextmanager
+ def _updateColormapRange(self, previousMode, mode):
+ """COMPOSITE_A_MINUS_B don't have the same data range than others.
+
+ If the colormap is using a fixed range, it is updated in order to set
+ a similar range with the new data.
+ """
+ normalize_colormap = (
+ previousMode == VisualizationMode.COMPOSITE_A_MINUS_B
+ or mode == VisualizationMode.COMPOSITE_A_MINUS_B
+ )
+ if normalize_colormap:
+ data = self._getConcatenatedData(copy=False)
+ if data is None or data.size == 0:
+ normalize_colormap = False
+ else:
+ std1 = numpy.nanstd(data)
+ mean1 = numpy.nanmean(data)
+ yield
+
+ def transfer(v, std1, mean1, std2, mean2):
+ """Transfer a value from a data range to another using statistics"""
+ if v is None:
+ return None
+ rv = (v - mean1) / std1
+ return rv * std2 + mean2
+
+ if normalize_colormap:
+ data = self._getConcatenatedData(copy=False)
+ if data is not None and data.size != 0:
+ std2 = numpy.nanstd(data)
+ mean2 = numpy.nanmean(data)
+ c = self.getColormap()
+ if c is not None:
+ vmin, vmax = c.getVRange()
+ vmin = transfer(vmin, std1, mean1, std2, mean2)
+ vmax = transfer(vmax, std1, mean1, std2, mean2)
+ c.setVRange(vmin, vmax)
+
+ def setVizualisationMode(self, mode: VisualizationMode):
+ if self.__vizualisationMode == mode:
+ return None
+ with self._updateColormapRange(self.__vizualisationMode, mode):
+ self.__vizualisationMode = mode
+ self._updated(ItemChangedType.DATA)
+
+ def _getConcatenatedData(self, copy=True):
+ if self.__image1 is None and self.__image2 is None:
+ return None
+ if self.__image1 is None:
+ return numpy.array(self.__image2, copy=copy)
+ if self.__image2 is None:
+ return numpy.array(self.__image1, copy=copy)
+
+ if self.__vizualisationMode == VisualizationMode.COMPOSITE_A_MINUS_B:
+ # In this case the histogram have to be special
+ if self.__image1.shape == self.__image2.shape:
+ return self.__image1.astype(numpy.float32) - self.__image2.astype(
+ numpy.float32
+ )
+ else:
+ d1 = self.__image1[numpy.isfinite(self.__image1)]
+ d2 = self.__image2[numpy.isfinite(self.__image2)]
+ return numpy.concatenate((d1, d2))
+
+ def _updated(self, event=None, checkVisibility=True):
+ # Synchronizes colormapped data if changed
+ if event in (ItemChangedType.DATA, ItemChangedType.MASK):
+ data = self._getConcatenatedData(copy=False)
+ return self._setColormappedData(data, copy=False)
+ super()._updated(event=event, checkVisibility=checkVisibility)
+
+ def getColormappedData(self, copy=True):
+ """
+ Reimplementation of the `ColormapMixIn.getColormappedData` method.
+
+ This is used to provide a consistent auto scale on the compared images.
+ """
+ return self._getConcatenatedData(copy=copy)
diff --git a/src/silx/gui/plot/tools/compare/profile.py b/src/silx/gui/plot/tools/compare/profile.py
new file mode 100644
index 0000000..afe0eba
--- /dev/null
+++ b/src/silx/gui/plot/tools/compare/profile.py
@@ -0,0 +1,173 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This provides profile ROIs.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "09/06/2023"
+
+
+import numpy
+
+from silx.gui.plot.tools.profile import rois
+from silx.gui.plot.tools.profile import core
+from .core import _CompareImageItem
+
+
+COLOR_A = "C0"
+COLOR_B = "C8"
+
+
+class ProfileImageLineROI(rois.ProfileImageLineROI):
+ """ROI for a compare image profile between 2 points.
+
+ The X profile of this ROI is the projection into one of the x/y axes,
+ using its scale and its orientation.
+ """
+
+ def computeProfile(self, item):
+ if not isinstance(item, _CompareImageItem):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ origin = item.getOrigin()
+ scale = item.getScale()
+ method = self.getProfileMethod()
+ lineWidth = self.getProfileLineWidth()
+ roiInfo = self._getRoiInfo()
+
+ def createProfile2(currentData):
+ coords, profile, _area, profileName, xLabel = core.createProfile(
+ roiInfo=roiInfo,
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=lineWidth,
+ method=method,
+ )
+ return coords, profile, profileName, xLabel
+
+ currentData1 = item.getImageData1()
+ currentData2 = item.getImageData2()
+
+ yLabel = "%s" % str(method).capitalize()
+ coords, profile1, title, xLabel = createProfile2(currentData1)
+ title = title + "; width = %d" % lineWidth
+ _coords, profile2, _title, _xLabel = createProfile2(currentData2)
+
+ profile1.shape = -1
+ profile2.shape = -1
+
+ title = title.format(xlabel="width", ylabel="height")
+ xLabel = xLabel.format(xlabel="width", ylabel="height")
+ yLabel = yLabel.format(xlabel="width", ylabel="height")
+
+ data = core.CurvesProfileData(
+ coords=coords,
+ profiles=[
+ core.CurveProfileDesc(profile1, color=COLOR_A, name="profileA"),
+ core.CurveProfileDesc(profile2, color=COLOR_B, name="profileB"),
+ ],
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ return data
+
+
+class ProfileImageDirectedLineROI(rois.ProfileImageDirectedLineROI):
+ """ROI for a compare image profile between 2 points.
+
+ The X profile of the line is displayed projected into the line itself,
+ using its scale and its orientation. It's the distance from the origin.
+ """
+
+ def computeProfile(self, item):
+ if not isinstance(item, _CompareImageItem):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ from silx.image.bilinear import BilinearImage
+
+ origin = item.getOrigin()
+ scale = item.getScale()
+ method = self.getProfileMethod()
+ lineWidth = self.getProfileLineWidth()
+
+ roiInfo = self._getRoiInfo()
+ roiStart, roiEnd, _lineProjectionMode = roiInfo
+
+ startPt = (
+ (roiStart[1] - origin[1]) / scale[1],
+ (roiStart[0] - origin[0]) / scale[0],
+ )
+ endPt = ((roiEnd[1] - origin[1]) / scale[1], (roiEnd[0] - origin[0]) / scale[0])
+
+ if numpy.array_equal(startPt, endPt):
+ return None
+
+ def computeProfile(data):
+ bilinear = BilinearImage(data)
+ profile = bilinear.profile_line(
+ (startPt[0] - 0.5, startPt[1] - 0.5),
+ (endPt[0] - 0.5, endPt[1] - 0.5),
+ lineWidth,
+ method=method,
+ )
+ return profile
+
+ currentData1 = item.getImageData1()
+ currentData2 = item.getImageData2()
+ profile1 = computeProfile(currentData1)
+ profile2 = computeProfile(currentData2)
+
+ # Compute the line size
+ lineSize = numpy.sqrt(
+ (roiEnd[1] - roiStart[1]) ** 2 + (roiEnd[0] - roiStart[0]) ** 2
+ )
+ coords = numpy.linspace(
+ 0, lineSize, len(profile1), endpoint=True, dtype=numpy.float32
+ )
+
+ title = rois._lineProfileTitle(*roiStart, *roiEnd)
+ title = title + "; width = %d" % lineWidth
+ xLabel = "√({xlabel}²+{ylabel}²)"
+ yLabel = str(method).capitalize()
+
+ # Use the axis names from the original plot
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ xLabel = rois._relabelAxes(plot, xLabel)
+ title = rois._relabelAxes(plot, title)
+
+ data = core.CurvesProfileData(
+ coords=coords,
+ profiles=[
+ core.CurveProfileDesc(profile1, color=COLOR_A, name="profileA"),
+ core.CurveProfileDesc(profile2, color=COLOR_B, name="profileB"),
+ ],
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ return data
diff --git a/src/silx/gui/plot/tools/compare/statusbar.py b/src/silx/gui/plot/tools/compare/statusbar.py
new file mode 100644
index 0000000..5e43a37
--- /dev/null
+++ b/src/silx/gui/plot/tools/compare/statusbar.py
@@ -0,0 +1,218 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides tool bar helper.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "09/06/2023"
+
+
+import logging
+import weakref
+import numpy
+
+from silx.gui import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class CompareImagesStatusBar(qt.QStatusBar):
+ """StatusBar containing specific information contained in a
+ :class:`CompareImages` widget
+
+ Use :meth:`setCompareWidget` to connect this toolbar to a specific
+ :class:`CompareImages` widget.
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ """
+
+ def __init__(self, parent=None):
+ qt.QStatusBar.__init__(self, parent)
+ self.setSizeGripEnabled(False)
+ self.layout().setSpacing(0)
+ self.__compareWidget = None
+ self._label1 = qt.QLabel(self)
+ self._label1.setFrameShape(qt.QFrame.WinPanel)
+ self._label1.setFrameShadow(qt.QFrame.Sunken)
+ self._label2 = qt.QLabel(self)
+ self._label2.setFrameShape(qt.QFrame.WinPanel)
+ self._label2.setFrameShadow(qt.QFrame.Sunken)
+ self._transform = qt.QLabel(self)
+ self._transform.setFrameShape(qt.QFrame.WinPanel)
+ self._transform.setFrameShadow(qt.QFrame.Sunken)
+ self.addWidget(self._label1)
+ self.addWidget(self._label2)
+ self.addWidget(self._transform)
+ self._pos = None
+ self._updateStatusBar()
+
+ def setCompareWidget(self, widget):
+ """
+ Connect this tool bar to a specific :class:`CompareImages` widget.
+
+ :param Union[None,CompareImages] widget: The widget to connect with.
+ """
+ compareWidget = self.getCompareWidget()
+ if compareWidget is not None:
+ compareWidget.getPlot().sigPlotSignal.disconnect(self.__plotSignalReceived)
+ compareWidget.sigConfigurationChanged.disconnect(self.__dataChanged)
+ compareWidget = widget
+ if compareWidget is None:
+ self.__compareWidget = None
+ else:
+ self.__compareWidget = weakref.ref(compareWidget)
+ if compareWidget is not None:
+ compareWidget.getPlot().sigPlotSignal.connect(self.__plotSignalReceived)
+ compareWidget.sigConfigurationChanged.connect(self.__dataChanged)
+
+ def getCompareWidget(self):
+ """Returns the connected widget.
+
+ :rtype: CompareImages
+ """
+ if self.__compareWidget is None:
+ return None
+ else:
+ return self.__compareWidget()
+
+ def __plotSignalReceived(self, event):
+ """Called when old style signals at emmited from the plot."""
+ if event["event"] == "mouseMoved":
+ x, y = event["x"], event["y"]
+ self.__mouseMoved(x, y)
+
+ def __mouseMoved(self, x, y):
+ """Called when mouse move over the plot."""
+ self._pos = x, y
+ self._updateStatusBar()
+
+ def __dataChanged(self):
+ """Called when internal data from the connected widget changes."""
+ self._updateStatusBar()
+
+ def _formatData(self, data):
+ """Format pixel of an image.
+
+ It supports intensity, RGB, and RGBA.
+
+ :param Union[int,float,numpy.ndarray,str]: Value of a pixel
+ :rtype: str
+ """
+ if data is None:
+ return "No data"
+ if isinstance(data, (int, numpy.integer)):
+ return "%d" % data
+ if isinstance(data, (float, numpy.floating)):
+ return "%f" % data
+ if isinstance(data, numpy.ndarray):
+ # RGBA value
+ if data.shape == (3,):
+ return "R:%d G:%d B:%d" % (data[0], data[1], data[2])
+ elif data.shape == (4,):
+ return "R:%d G:%d B:%d A:%d" % (data[0], data[1], data[2], data[3])
+ _logger.debug("Unsupported data format %s. Cast it to string.", type(data))
+ return str(data)
+
+ def _updateStatusBar(self):
+ """Update the content of the status bar"""
+ widget = self.getCompareWidget()
+ if widget is None:
+ self._label1.setText("ImageA: NA")
+ self._label2.setText("ImageB: NA")
+ self._transform.setVisible(False)
+ else:
+ transform = widget.getTransformation()
+ self._transform.setVisible(transform is not None)
+ if transform is not None:
+ has_notable_translation = not numpy.isclose(
+ transform.tx, 0.0, atol=0.01
+ ) or not numpy.isclose(transform.ty, 0.0, atol=0.01)
+ has_notable_scale = not numpy.isclose(
+ transform.sx, 1.0, atol=0.01
+ ) or not numpy.isclose(transform.sy, 1.0, atol=0.01)
+ has_notable_rotation = not numpy.isclose(transform.rot, 0.0, atol=0.01)
+
+ strings = []
+ if has_notable_translation:
+ strings.append("Translation")
+ if has_notable_scale:
+ strings.append("Scale")
+ if has_notable_rotation:
+ strings.append("Rotation")
+ if strings == []:
+ has_translation = not numpy.isclose(
+ transform.tx, 0.0
+ ) or not numpy.isclose(transform.ty, 0.0)
+ has_scale = not numpy.isclose(
+ transform.sx, 1.0
+ ) or not numpy.isclose(transform.sy, 1.0)
+ has_rotation = not numpy.isclose(transform.rot, 0.0)
+ if has_translation or has_scale or has_rotation:
+ text = "No big changes"
+ else:
+ text = "No changes"
+ else:
+ text = "+".join(strings)
+ self._transform.setText("Align: " + text)
+
+ strings = []
+ if not numpy.isclose(transform.ty, 0.0):
+ strings.append("Translation x: %0.3fpx" % transform.tx)
+ if not numpy.isclose(transform.ty, 0.0):
+ strings.append("Translation y: %0.3fpx" % transform.ty)
+ if not numpy.isclose(transform.sx, 1.0):
+ strings.append("Scale x: %0.3f" % transform.sx)
+ if not numpy.isclose(transform.sy, 1.0):
+ strings.append("Scale y: %0.3f" % transform.sy)
+ if not numpy.isclose(transform.rot, 0.0):
+ strings.append(
+ "Rotation: %0.3fdeg" % (transform.rot * 180 / numpy.pi)
+ )
+ if strings == []:
+ text = "No transformation"
+ else:
+ text = "\n".join(strings)
+ self._transform.setToolTip(text)
+
+ if self._pos is None:
+ self._label1.setText("ImageA: NA")
+ self._label2.setText("ImageB: NA")
+ else:
+ data1, data2 = widget.getRawPixelData(self._pos[0], self._pos[1])
+ if isinstance(data1, str):
+ self._label1.setToolTip(data1)
+ text1 = "NA"
+ else:
+ self._label1.setToolTip("")
+ text1 = self._formatData(data1)
+ if isinstance(data2, str):
+ self._label2.setToolTip(data2)
+ text2 = "NA"
+ else:
+ self._label2.setToolTip("")
+ text2 = self._formatData(data2)
+ self._label1.setText("ImageA: %s" % text1)
+ self._label2.setText("ImageB: %s" % text2)
diff --git a/src/silx/gui/plot/tools/compare/toolbar.py b/src/silx/gui/plot/tools/compare/toolbar.py
new file mode 100644
index 0000000..a7f56ec
--- /dev/null
+++ b/src/silx/gui/plot/tools/compare/toolbar.py
@@ -0,0 +1,390 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides tool bar helper.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import logging
+import weakref
+from typing import List, Optional
+
+from silx.gui import qt
+from silx.gui import icons
+from .core import AlignmentMode
+from .core import VisualizationMode
+from .core import sift
+
+
+_logger = logging.getLogger(__name__)
+
+
+class AlignmentModeToolButton(qt.QToolButton):
+ """ToolButton to select a AlignmentMode"""
+
+ sigSelected = qt.Signal(AlignmentMode)
+
+ def __init__(self, parent=None):
+ super(AlignmentModeToolButton, self).__init__(parent=parent)
+
+ menu = qt.QMenu(self)
+ self.setMenu(menu)
+
+ self.__group = qt.QActionGroup(self)
+ self.__group.setExclusive(True)
+ self.__group.triggered.connect(self.__selectionChanged)
+
+ icon = icons.getQIcon("compare-align-origin")
+ action = qt.QAction(icon, "Align images on their upper-left pixel", self)
+ action.setProperty("enum", AlignmentMode.ORIGIN)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__originAlignAction = action
+ menu.addAction(action)
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-align-center")
+ action = qt.QAction(icon, "Center images", self)
+ action.setProperty("enum", AlignmentMode.CENTER)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__centerAlignAction = action
+ menu.addAction(action)
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-align-stretch")
+ action = qt.QAction(icon, "Stretch the second image on the first one", self)
+ action.setProperty("enum", AlignmentMode.STRETCH)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__stretchAlignAction = action
+ menu.addAction(action)
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-align-auto")
+ action = qt.QAction(icon, "Auto-alignment of the second image", self)
+ action.setProperty("enum", AlignmentMode.AUTO)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__autoAlignAction = action
+ menu.addAction(action)
+ if sift is None:
+ action.setEnabled(False)
+ action.setToolTip("Sift module is not available")
+ self.__group.addAction(action)
+
+ def getActionFromMode(self, mode: AlignmentMode) -> Optional[qt.QAction]:
+ """Returns an action from it's mode"""
+ for action in self.__group.actions():
+ actionMode = action.property("enum")
+ if mode == actionMode:
+ return action
+ return None
+
+ def setVisibleModes(self, modes: List[AlignmentMode]):
+ """Make visible only a set of modes.
+
+ The order does not matter.
+ """
+ modes = set(modes)
+ for action in self.__group.actions():
+ mode = action.property("enum")
+ action.setVisible(mode in modes)
+
+ def __selectionChanged(self, selectedAction: qt.QAction):
+ """Called when user requesting changes of the alignment mode."""
+ self.__updateMenu()
+ mode = self.getSelected()
+ self.sigSelected.emit(mode)
+
+ def __updateMenu(self):
+ """Update the state of the action containing alignment menu."""
+ selectedAction = self.__group.checkedAction()
+ if selectedAction is not None:
+ self.setText(selectedAction.text())
+ self.setIcon(selectedAction.icon())
+ self.setToolTip(selectedAction.toolTip())
+ else:
+ self.setText("")
+ self.setIcon(qt.QIcon())
+ self.setToolTip("")
+
+ def getSelected(self) -> AlignmentMode:
+ action = self.__group.checkedAction()
+ if action is None:
+ return None
+ return action.property("enum")
+
+ def setSelected(self, mode: AlignmentMode):
+ action = self.getActionFromMode(mode)
+ old = self.__group.blockSignals(True)
+ if action is not None:
+ # Check this action
+ action.setChecked(True)
+ else:
+ action = self.__group.checkedAction()
+ if action is not None:
+ # Uncheck this action
+ action.setChecked(False)
+ self.__updateMenu()
+ self.__group.blockSignals(old)
+
+
+class VisualizationModeToolButton(qt.QToolButton):
+ """ToolButton to select a VisualisationMode"""
+
+ sigSelected = qt.Signal(VisualizationMode)
+
+ def __init__(self, parent=None):
+ super(VisualizationModeToolButton, self).__init__(parent=parent)
+
+ menu = qt.QMenu(self)
+ self.setMenu(menu)
+
+ self.__group = qt.QActionGroup(self)
+ self.__group.setExclusive(True)
+ self.__group.triggered.connect(self.__selectionChanged)
+
+ icon = icons.getQIcon("compare-mode-a")
+ action = qt.QAction(icon, "Display the first image only", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_A))
+ action.setProperty("enum", VisualizationMode.ONLY_A)
+ menu.addAction(action)
+ self.__aModeAction = action
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-b")
+ action = qt.QAction(icon, "Display the second image only", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_B))
+ action.setProperty("enum", VisualizationMode.ONLY_B)
+ menu.addAction(action)
+ self.__bModeAction = action
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-vline")
+ action = qt.QAction(icon, "Vertical compare mode", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_V))
+ action.setProperty("enum", VisualizationMode.VERTICAL_LINE)
+ menu.addAction(action)
+ self.__vlineModeAction = action
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-hline")
+ action = qt.QAction(icon, "Horizontal compare mode", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_H))
+ action.setProperty("enum", VisualizationMode.HORIZONTAL_LINE)
+ menu.addAction(action)
+ self.__hlineModeAction = action
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-rb-channel")
+ action = qt.QAction(icon, "Blue/red compare mode (additive mode)", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_C))
+ action.setProperty("enum", VisualizationMode.COMPOSITE_RED_BLUE_GRAY)
+ menu.addAction(action)
+ self.__brChannelModeAction = action
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-rbneg-channel")
+ action = qt.QAction(icon, "Yellow/cyan compare mode (subtractive mode)", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_Y))
+ action.setProperty("enum", VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG)
+ menu.addAction(action)
+ self.__ycChannelModeAction = action
+ self.__group.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-a-minus-b")
+ action = qt.QAction(icon, "Raw A minus B compare mode", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_W))
+ action.setProperty("enum", VisualizationMode.COMPOSITE_A_MINUS_B)
+ menu.addAction(action)
+ self.__ycChannelModeAction = action
+ self.__group.addAction(action)
+
+ def getActionFromMode(self, mode: VisualizationMode) -> Optional[qt.QAction]:
+ """Returns an action from it's mode"""
+ for action in self.__group.actions():
+ actionMode = action.property("enum")
+ if mode == actionMode:
+ return action
+ return None
+
+ def setVisibleModes(self, modes: List[VisualizationMode]):
+ """Make visible only a set of modes.
+
+ The order does not matter.
+ """
+ modes = set(modes)
+ for action in self.__group.actions():
+ mode = action.property("enum")
+ action.setVisible(mode in modes)
+
+ def __selectionChanged(self, selectedAction: qt.QAction):
+ """Called when user requesting changes of the visualization mode."""
+ self.__updateMenu()
+ mode = self.getSelected()
+ self.sigSelected.emit(mode)
+
+ def __updateMenu(self):
+ """Update the state of the action containing visualization menu."""
+ selectedAction = self.__group.checkedAction()
+ if selectedAction is not None:
+ self.setText(selectedAction.text())
+ self.setIcon(selectedAction.icon())
+ self.setToolTip(selectedAction.toolTip())
+ else:
+ self.setText("")
+ self.setIcon(qt.QIcon())
+ self.setToolTip("")
+
+ def getSelected(self) -> VisualizationMode:
+ action = self.__group.checkedAction()
+ if action is None:
+ return None
+ return action.property("enum")
+
+ def setSelected(self, mode: VisualizationMode):
+ action = self.getActionFromMode(mode)
+ old = self.__group.blockSignals(True)
+ if action is not None:
+ # Check this action
+ action.setChecked(True)
+ else:
+ action = self.__group.checkedAction()
+ if action is not None:
+ # Uncheck this action
+ action.setChecked(False)
+ self.__updateMenu()
+ self.__group.blockSignals(old)
+
+
+class CompareImagesToolBar(qt.QToolBar):
+ """ToolBar containing specific tools to custom the configuration of a
+ :class:`CompareImages` widget
+
+ Use :meth:`setCompareWidget` to connect this toolbar to a specific
+ :class:`CompareImages` widget.
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ """
+
+ def __init__(self, parent=None):
+ qt.QToolBar.__init__(self, parent)
+ self.setWindowTitle("Compare images")
+
+ self.__compareWidget = None
+
+ self.__visualizationToolButton = VisualizationModeToolButton(self)
+ self.__visualizationToolButton.setPopupMode(qt.QToolButton.InstantPopup)
+ self.__visualizationToolButton.sigSelected.connect(self.__visualizationChanged)
+ self.addWidget(self.__visualizationToolButton)
+
+ self.__alignmentToolButton = AlignmentModeToolButton(self)
+ self.__alignmentToolButton.setPopupMode(qt.QToolButton.InstantPopup)
+ self.__alignmentToolButton.sigSelected.connect(self.__alignmentChanged)
+ self.addWidget(self.__alignmentToolButton)
+
+ icon = icons.getQIcon("compare-keypoints")
+ action = qt.QAction(icon, "Display/hide alignment keypoints", self)
+ action.setCheckable(True)
+ action.triggered.connect(self.__keypointVisibilityChanged)
+ self.addAction(action)
+ self.__displayKeypoints = action
+
+ def __visualizationChanged(self, mode: VisualizationMode):
+ widget = self.getCompareWidget()
+ if widget is not None:
+ widget.setVisualizationMode(mode)
+
+ def __alignmentChanged(self, mode: AlignmentMode):
+ widget = self.getCompareWidget()
+ if widget is not None:
+ widget.setAlignmentMode(mode)
+
+ def setCompareWidget(self, widget):
+ """
+ Connect this tool bar to a specific :class:`CompareImages` widget.
+
+ :param Union[None,CompareImages] widget: The widget to connect with.
+ """
+ compareWidget = self.getCompareWidget()
+ if compareWidget is not None:
+ compareWidget.sigConfigurationChanged.disconnect(
+ self.__updateSelectedActions
+ )
+ compareWidget = widget
+ self.setEnabled(compareWidget is not None)
+ if compareWidget is None:
+ self.__compareWidget = None
+ else:
+ self.__compareWidget = weakref.ref(compareWidget)
+ if compareWidget is not None:
+ widget.sigConfigurationChanged.connect(self.__updateSelectedActions)
+ self.__updateSelectedActions()
+
+ def getCompareWidget(self):
+ """Returns the connected widget.
+
+ :rtype: CompareImages
+ """
+ if self.__compareWidget is None:
+ return None
+ else:
+ return self.__compareWidget()
+
+ def __updateSelectedActions(self):
+ """
+ Update the state of this tool bar according to the state of the
+ connected :class:`CompareImages` widget.
+ """
+ widget = self.getCompareWidget()
+ if widget is None:
+ return
+ self.__visualizationToolButton.setSelected(widget.getVisualizationMode())
+ self.__alignmentToolButton.setSelected(widget.getAlignmentMode())
+ self.__displayKeypoints.setChecked(widget.getKeypointsVisible())
+
+ def __keypointVisibilityChanged(self):
+ """Called when action managing keypoints visibility changes"""
+ widget = self.getCompareWidget()
+ if widget is not None:
+ keypointsVisible = self.__displayKeypoints.isChecked()
+ widget.setKeypointsVisible(keypointsVisible)
diff --git a/src/silx/gui/plot/tools/menus.py b/src/silx/gui/plot/tools/menus.py
new file mode 100644
index 0000000..c748b6e
--- /dev/null
+++ b/src/silx/gui/plot/tools/menus.py
@@ -0,0 +1,93 @@
+# /*##########################################################################
+#
+# Copyright (c) 2023 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 :class:`PlotWidget`-related QMenu.
+
+The following QMenu is available:
+
+- :class:`ZoomEnabledAxesMenu`
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "12/06/2023"
+
+
+import weakref
+from typing import Optional
+
+from silx.gui import qt
+
+from ..PlotWidget import PlotWidget
+
+
+class ZoomEnabledAxesMenu(qt.QMenu):
+ """Menu to toggle axes for zoom interaction"""
+
+ def __init__(self, plot: PlotWidget, parent: Optional[qt.QWidget] = None):
+ super().__init__(parent)
+ self.setTitle("Zoom axes")
+
+ assert isinstance(plot, PlotWidget)
+ self.__plotRef = weakref.ref(plot)
+
+ self.addSection("Enabled axes")
+ self.__xAxisAction = qt.QAction("X axis", parent=self)
+ self.__yAxisAction = qt.QAction("Y left axis", parent=self)
+ self.__y2AxisAction = qt.QAction("Y right axis", parent=self)
+
+ for action in (self.__xAxisAction, self.__yAxisAction, self.__y2AxisAction):
+ action.setCheckable(True)
+ action.setChecked(True)
+ action.triggered.connect(self._axesActionTriggered)
+ self.addAction(action)
+
+ # Listen to interaction configuration change
+ plot.interaction().sigChanged.connect(self._interactionChanged)
+ # Init the state
+ self._interactionChanged()
+
+ def getPlotWidget(self) -> Optional[PlotWidget]:
+ return self.__plotRef()
+
+ def _axesActionTriggered(self, checked=False):
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ plot.interaction().setZoomEnabledAxes(
+ self.__xAxisAction.isChecked(),
+ self.__yAxisAction.isChecked(),
+ self.__y2AxisAction.isChecked(),
+ )
+
+ def _interactionChanged(self):
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
+ enabledAxes = plot.interaction().getZoomEnabledAxes()
+ self.__xAxisAction.setChecked(enabledAxes.xaxis)
+ self.__yAxisAction.setChecked(enabledAxes.yaxis)
+ self.__y2AxisAction.setChecked(enabledAxes.y2axis)
diff --git a/src/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/src/silx/gui/plot/tools/profile/ScatterProfileToolBar.py
new file mode 100644
index 0000000..271adb8
--- /dev/null
+++ b/src/silx/gui/plot/tools/profile/ScatterProfileToolBar.py
@@ -0,0 +1,44 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2023 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module profile tools for scatter plots.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+from . import toolbar
+
+
+class ScatterProfileToolBar(toolbar.ProfileToolBar):
+ """QToolBar providing scatter plot profiling tools
+
+ :param parent: See :class:`QToolBar`.
+ :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate.
+ """
+
+ def __init__(self, parent=None, plot=None):
+ super(ScatterProfileToolBar, self).__init__(parent, plot)
+ self.setScheme("scatter")
diff --git a/src/silx/gui/plot/tools/profile/__init__.py b/src/silx/gui/plot/tools/profile/__init__.py
new file mode 100644
index 0000000..a72b5d2
--- /dev/null
+++ b/src/silx/gui/plot/tools/profile/__init__.py
@@ -0,0 +1,37 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides tools to get profiles on plot data.
+
+It provides:
+
+- :class:`ScatterProfileToolBar`: a QToolBar to handle profile on scatter data
+
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+
+from .ScatterProfileToolBar import ScatterProfileToolBar # noqa
diff --git a/src/silx/gui/plot/tools/profile/core.py b/src/silx/gui/plot/tools/profile/core.py
new file mode 100644
index 0000000..194f459
--- /dev/null
+++ b/src/silx/gui/plot/tools/profile/core.py
@@ -0,0 +1,567 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2023 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module define core objects for profile tools.
+"""
+
+from __future__ import annotations
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno", "V. Valls"]
+__license__ = "MIT"
+__date__ = "17/04/2020"
+
+import typing
+import numpy
+import weakref
+
+from silx.image.bilinear import BilinearImage
+from silx.gui import qt
+from silx.gui import colors
+import silx.gui.plot.items
+
+
+class CurveProfileData(typing.NamedTuple):
+ coords: numpy.ndarray
+ profile: numpy.ndarray
+ title: str
+ xLabel: str
+ yLabel: str
+
+
+class RgbaProfileData(typing.NamedTuple):
+ coords: numpy.ndarray
+ profile: numpy.ndarray
+ profile_r: numpy.ndarray
+ profile_g: numpy.ndarray
+ profile_b: numpy.ndarray
+ profile_a: numpy.ndarray
+ title: str
+ xLabel: str
+ yLabel: str
+
+
+class ImageProfileData(typing.NamedTuple):
+ coords: numpy.ndarray
+ profile: numpy.ndarray
+ title: str
+ xLabel: str
+ yLabel: str
+ colormap: colors.Colormap
+
+
+class CurveProfileDesc(typing.NamedTuple):
+ profile: numpy.ndarray
+ name: typing.Optional[str] = None
+ color: typing.Optional[str] = None
+
+
+class CurvesProfileData(typing.NamedTuple):
+ coords: numpy.ndarray
+ profiles: typing.List[CurveProfileDesc]
+ title: str
+ xLabel: str
+ yLabel: str
+
+
+class ProfileRoiMixIn:
+ """Base mix-in for ROI which can be used to select a profile.
+
+ This mix-in have to be applied to a :class:`~silx.gui.plot.items.roi.RegionOfInterest`
+ in order to be usable by a :class:`~silx.gui.plot.tools.profile.manager.ProfileManager`.
+ """
+
+ ITEM_KIND = None
+ """Define the plot item which can be used with this profile ROI"""
+
+ sigProfilePropertyChanged = qt.Signal()
+ """Emitted when a property of this profile have changed"""
+
+ sigPlotItemChanged = qt.Signal()
+ """Emitted when the plot item linked to this profile have changed"""
+
+ def __init__(self, parent=None):
+ self.__profileWindow = None
+ self.__profileManager = None
+ self.__plotItem = None
+ self.setName("Profile")
+ self.setEditable(True)
+ self.setSelectable(True)
+
+ def invalidateProfile(self):
+ """Must be called by the implementation when the profile have to be
+ recomputed."""
+ profileManager = self.getProfileManager()
+ if profileManager is not None:
+ profileManager.requestUpdateProfile(self)
+
+ def invalidateProperties(self):
+ """Must be called when a property of the profile have changed."""
+ self.sigProfilePropertyChanged.emit()
+
+ def _setPlotItem(self, plotItem):
+ """Specify the plot item to use with this profile
+
+ :param `~silx.gui.plot.items.Item` plotItem: A plot item
+ """
+ previousPlotItem = self.getPlotItem()
+ if previousPlotItem is plotItem:
+ return
+ self.__plotItem = weakref.ref(plotItem)
+ self.sigPlotItemChanged.emit()
+
+ def getPlotItem(self):
+ """Returns the plot item used by this profile
+
+ :rtype: `~silx.gui.plot.items.Item`
+ """
+ if self.__plotItem is None:
+ return None
+ plotItem = self.__plotItem()
+ if plotItem is None:
+ self.__plotItem = None
+ return plotItem
+
+ def _setProfileManager(self, profileManager):
+ self.__profileManager = profileManager
+
+ def getProfileManager(self):
+ """
+ Returns the profile manager connected to this ROI.
+
+ :rtype: ~silx.gui.plot.tools.profile.manager.ProfileManager
+ """
+ return self.__profileManager
+
+ def getProfileWindow(self):
+ """
+ Returns the windows associated to this ROI, else None.
+
+ :rtype: ProfileWindow
+ """
+ return self.__profileWindow
+
+ def setProfileWindow(self, profileWindow):
+ """
+ Associate a window to this ROI. Can be None.
+
+ :param ProfileWindow profileWindow: A main window
+ to display the profile.
+ """
+ if profileWindow is self.__profileWindow:
+ return
+ if self.__profileWindow is not None:
+ self.__profileWindow.sigClose.disconnect(self.__profileWindowAboutToClose)
+ self.__profileWindow.setRoiProfile(None)
+ self.__profileWindow = profileWindow
+ if self.__profileWindow is not None:
+ self.__profileWindow.sigClose.connect(self.__profileWindowAboutToClose)
+ self.__profileWindow.setRoiProfile(self)
+
+ def __profileWindowAboutToClose(self):
+ profileManager = self.getProfileManager()
+ roiManager = profileManager.getRoiManager()
+ try:
+ roiManager.removeRoi(self)
+ except ValueError:
+ pass
+
+ def computeProfile(
+ self, item: silx.gui.plot.items.Item
+ ) -> typing.Union[
+ CurveProfileData, ImageProfileData, RgbaProfileData, CurvesProfileData
+ ]:
+ """
+ Compute the profile which will be displayed.
+
+ This method is not called from the main Qt thread, but from a thread
+ pool.
+
+ :param item: A plot item
+ """
+ raise NotImplementedError()
+
+
+def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method):
+ """Get a profile along one axis on a stack of images
+
+ :param numpy.ndarray data: 3D volume (stack of 2D images)
+ The first dimension is the image index.
+ :param origin: Origin of image in plot (ox, oy)
+ :param scale: Scale of image in plot (sx, sy)
+ :param float position: Position of profile line in plot coords
+ on the axis orthogonal to the profile direction.
+ :param int roiWidth: Width of the profile in image pixels.
+ :param int axis: 0 for horizontal profile, 1 for vertical.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum' or
+ 'none'
+ :return: profile image + effective ROI area corners in plot coords
+ """
+ assert axis in (0, 1)
+ assert len(data.shape) == 3
+ assert method in ("mean", "sum", "none")
+
+ # Convert from plot to image coords
+ imgPos = int((position - origin[1 - axis]) / scale[1 - axis])
+
+ if axis == 1: # Vertical profile
+ # Transpose image to always do a horizontal profile
+ data = numpy.transpose(data, (0, 2, 1))
+
+ nimages, height, width = data.shape
+
+ roiWidth = min(height, roiWidth) # Clip roi width to image size
+
+ # Get [start, end[ coords of the roi in the data
+ start = int(int(imgPos) + 0.5 - roiWidth / 2.0)
+ start = min(max(0, start), height - roiWidth)
+ end = start + roiWidth
+
+ if method == "none":
+ profile = None
+ else:
+ if start < height and end > 0:
+ if method == "mean":
+ fct = numpy.mean
+ elif method == "sum":
+ fct = numpy.sum
+ else:
+ raise ValueError("method not managed")
+ profile = fct(data[:, max(0, start) : min(end, height), :], axis=1).astype(
+ numpy.float32
+ )
+ else:
+ profile = numpy.zeros((nimages, width), dtype=numpy.float32)
+
+ # Compute effective ROI in plot coords
+ profileBounds = (
+ numpy.array((0, width, width, 0), dtype=numpy.float32) * scale[axis]
+ + origin[axis]
+ )
+ roiBounds = (
+ numpy.array((start, start, end, end), dtype=numpy.float32) * scale[1 - axis]
+ + origin[1 - axis]
+ )
+
+ if axis == 0: # Horizontal profile
+ area = profileBounds, roiBounds
+ else: # vertical profile
+ area = roiBounds, profileBounds
+
+ return profile, area
+
+
+def _alignedPartialProfile(data, rowRange, colRange, axis, method):
+ """Mean of a rectangular region (ROI) of a stack of images
+ along a given axis.
+
+ Returned values and all parameters are in image coordinates.
+
+ :param numpy.ndarray data: 3D volume (stack of 2D images)
+ The first dimension is the image index.
+ :param rowRange: [min, max[ of ROI rows (upper bound excluded).
+ :type rowRange: 2-tuple of int (min, max) with min < max
+ :param colRange: [min, max[ of ROI columns (upper bound excluded).
+ :type colRange: 2-tuple of int (min, max) with min < max
+ :param int axis: The axis along which to take the profile of the ROI.
+ 0: Sum rows along columns.
+ 1: Sum columns along rows.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ :return: Profile image along the ROI as the mean of the intersection
+ of the ROI and the image.
+ """
+ assert axis in (0, 1)
+ assert len(data.shape) == 3
+ assert rowRange[0] < rowRange[1]
+ assert colRange[0] < colRange[1]
+ assert method in ("mean", "sum")
+
+ nimages, height, width = data.shape
+
+ # Range aligned with the integration direction
+ profileRange = colRange if axis == 0 else rowRange
+
+ profileLength = abs(profileRange[1] - profileRange[0])
+
+ # Subset of the image to use as intersection of ROI and image
+ rowStart = min(max(0, rowRange[0]), height)
+ rowEnd = min(max(0, rowRange[1]), height)
+ colStart = min(max(0, colRange[0]), width)
+ colEnd = min(max(0, colRange[1]), width)
+
+ if method == "mean":
+ _fct = numpy.mean
+ elif method == "sum":
+ _fct = numpy.sum
+ else:
+ raise ValueError("method not managed")
+
+ imgProfile = _fct(
+ data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1, dtype=numpy.float32
+ )
+
+ # Profile including out of bound area
+ profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
+
+ # Place imgProfile in full profile
+ offset = -min(0, profileRange[0])
+ profile[:, offset : offset + imgProfile.shape[1]] = imgProfile
+
+ return profile
+
+
+def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
+ """Create the profile line for the the given image.
+
+ :param roiInfo: information about the ROI: start point, end point and
+ type ("X", "Y", "D")
+ :param numpy.ndarray currentData: the 2D image or the 3D stack of images
+ on which we compute the profile.
+ :param origin: (ox, oy) the offset from origin
+ :type origin: 2-tuple of float
+ :param scale: (sx, sy) the scale to use
+ :type scale: 2-tuple of float
+ :param int lineWidth: width of the profile line
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ or 'none': to compute everything except the profile
+ :return: `coords, profile, area, profileName, xLabel`, where:
+ - coords is the X coordinate to use to display the profile
+ - profile is a 2D array of the profiles of the stack of images.
+ For a single image, the profile is a curve, so this parameter
+ has a shape *(1, len(curve))*
+ - area is a tuple of two 1D arrays with 4 values each. They represent
+ the effective ROI area corners in plot coords.
+ - profileName is a string describing the ROI, meant to be used as
+ title of the profile plot
+ - xLabel the label for X in the profile window
+
+ :rtype: tuple(ndarray,ndarray,(ndarray,ndarray),str)
+ """
+ if currentData is None or roiInfo is None or lineWidth is None:
+ raise ValueError("createProfile called with invalide arguments")
+
+ # force 3D data (stack of images)
+ if len(currentData.shape) == 2:
+ currentData3D = currentData.reshape((1,) + currentData.shape)
+ elif len(currentData.shape) == 3:
+ currentData3D = currentData
+
+ roiWidth = max(1, lineWidth)
+ roiStart, roiEnd, lineProjectionMode = roiInfo
+
+ if lineProjectionMode == "X": # Horizontal profile on the whole image
+ profile, area = _alignedFullProfile(
+ currentData3D, origin, scale, roiStart[1], roiWidth, axis=0, method=method
+ )
+
+ if method == "none":
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[0] + origin[0]
+
+ yMin, yMax = min(area[1]), max(area[1]) - 1
+ if roiWidth <= 1:
+ profileName = "{ylabel} = %g" % yMin
+ else:
+ profileName = "{ylabel} = [%g, %g]" % (yMin, yMax)
+ xLabel = "{xlabel}"
+
+ elif lineProjectionMode == "Y": # Vertical profile on the whole image
+ profile, area = _alignedFullProfile(
+ currentData3D, origin, scale, roiStart[0], roiWidth, axis=1, method=method
+ )
+
+ if method == "none":
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[1] + origin[1]
+
+ xMin, xMax = min(area[0]), max(area[0]) - 1
+ if roiWidth <= 1:
+ profileName = "{xlabel} = %g" % xMin
+ else:
+ profileName = "{xlabel} = [%g, %g]" % (xMin, xMax)
+ xLabel = "{ylabel}"
+
+ else: # Free line profile
+ # Convert start and end points in image coords as (row, col)
+ startPt = (
+ (roiStart[1] - origin[1]) / scale[1],
+ (roiStart[0] - origin[0]) / scale[0],
+ )
+ endPt = ((roiEnd[1] - origin[1]) / scale[1], (roiEnd[0] - origin[0]) / scale[0])
+
+ if int(startPt[0]) == int(endPt[0]) or int(startPt[1]) == int(endPt[1]):
+ # Profile is aligned with one of the axes
+
+ # Convert to int
+ startPt = int(startPt[0]), int(startPt[1])
+ endPt = int(endPt[0]), int(endPt[1])
+
+ # Ensure startPt <= endPt
+ if startPt[0] > endPt[0] or startPt[1] > endPt[1]:
+ startPt, endPt = endPt, startPt
+
+ if startPt[0] == endPt[0]: # Row aligned
+ rowRange = (
+ int(startPt[0] + 0.5 - 0.5 * roiWidth),
+ int(startPt[0] + 0.5 + 0.5 * roiWidth),
+ )
+ colRange = startPt[1], endPt[1] + 1
+ if method == "none":
+ profile = None
+ else:
+ profile = _alignedPartialProfile(
+ currentData3D, rowRange, colRange, axis=0, method=method
+ )
+
+ else: # Column aligned
+ rowRange = startPt[0], endPt[0] + 1
+ colRange = (
+ int(startPt[1] + 0.5 - 0.5 * roiWidth),
+ int(startPt[1] + 0.5 + 0.5 * roiWidth),
+ )
+ if method == "none":
+ profile = None
+ else:
+ profile = _alignedPartialProfile(
+ currentData3D, rowRange, colRange, axis=1, method=method
+ )
+ # Convert ranges to plot coords to draw ROI area
+ area = (
+ numpy.array(
+ (colRange[0], colRange[1], colRange[1], colRange[0]),
+ dtype=numpy.float32,
+ )
+ * scale[0]
+ + origin[0],
+ numpy.array(
+ (rowRange[0], rowRange[0], rowRange[1], rowRange[1]),
+ dtype=numpy.float32,
+ )
+ * scale[1]
+ + origin[1],
+ )
+
+ else: # General case: use bilinear interpolation
+ # Ensure startPt <= endPt
+ if startPt[1] > endPt[1] or (
+ startPt[1] == endPt[1] and startPt[0] > endPt[0]
+ ):
+ startPt, endPt = endPt, startPt
+
+ if method == "none":
+ profile = None
+ else:
+ profile = []
+ for slice_idx in range(currentData3D.shape[0]):
+ bilinear = BilinearImage(currentData3D[slice_idx, :, :])
+
+ profile.append(
+ bilinear.profile_line(
+ (startPt[0] - 0.5, startPt[1] - 0.5),
+ (endPt[0] - 0.5, endPt[1] - 0.5),
+ roiWidth,
+ method=method,
+ )
+ )
+ profile = numpy.array(profile)
+
+ # Extend ROI with half a pixel on each end, and
+ # Convert back to plot coords (x, y)
+ length = numpy.sqrt(
+ (endPt[0] - startPt[0]) ** 2 + (endPt[1] - startPt[1]) ** 2
+ )
+ dRow = (endPt[0] - startPt[0]) / length
+ dCol = (endPt[1] - startPt[1]) / length
+
+ # Extend ROI with half a pixel on each end
+ roiStartPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
+ roiEndPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol
+
+ # Rotate deltas by 90 degrees to apply line width
+ dRow, dCol = dCol, -dRow
+
+ area = (
+ numpy.array(
+ (
+ roiStartPt[1] - 0.5 * roiWidth * dCol,
+ roiStartPt[1] + 0.5 * roiWidth * dCol,
+ roiEndPt[1] + 0.5 * roiWidth * dCol,
+ roiEndPt[1] - 0.5 * roiWidth * dCol,
+ ),
+ dtype=numpy.float32,
+ )
+ * scale[0]
+ + origin[0],
+ numpy.array(
+ (
+ roiStartPt[0] - 0.5 * roiWidth * dRow,
+ roiStartPt[0] + 0.5 * roiWidth * dRow,
+ roiEndPt[0] + 0.5 * roiWidth * dRow,
+ roiEndPt[0] - 0.5 * roiWidth * dRow,
+ ),
+ dtype=numpy.float32,
+ )
+ * scale[1]
+ + origin[1],
+ )
+
+ # Convert start and end points back to plot coords
+ y0 = startPt[0] * scale[1] + origin[1]
+ x0 = startPt[1] * scale[0] + origin[0]
+ y1 = endPt[0] * scale[1] + origin[1]
+ x1 = endPt[1] * scale[0] + origin[0]
+
+ if startPt[1] == endPt[1]:
+ profileName = "{xlabel} = %g; {ylabel} = [%g, %g]" % (x0, y0, y1)
+ if method == "none":
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[1] + y0
+ xLabel = "{ylabel}"
+
+ elif startPt[0] == endPt[0]:
+ profileName = "{ylabel} = %g; {xlabel} = [%g, %g]" % (y0, x0, x1)
+ if method == "none":
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[0] + x0
+ xLabel = "{xlabel}"
+
+ else:
+ m = (y1 - y0) / (x1 - x0)
+ b = y0 - m * x0
+ profileName = "{ylabel} = %g * {xlabel} %+g" % (m, b)
+ if method == "none":
+ coords = None
+ else:
+ coords = numpy.linspace(
+ x0, x1, len(profile[0]), endpoint=True, dtype=numpy.float32
+ )
+ xLabel = "{xlabel}"
+
+ return coords, profile, area, profileName, xLabel
diff --git a/src/silx/gui/plot/tools/profile/editors.py b/src/silx/gui/plot/tools/profile/editors.py
new file mode 100644
index 0000000..d53f775
--- /dev/null
+++ b/src/silx/gui/plot/tools/profile/editors.py
@@ -0,0 +1,309 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides editors which are used to custom profile ROI properties.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+import logging
+
+from silx.gui import qt
+
+from silx.gui.utils import blockSignals
+from silx.gui.plot.PlotToolButtons import ProfileOptionToolButton
+from silx.gui.plot.PlotToolButtons import ProfileToolButton
+from . import rois
+from . import core
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _NoProfileRoiEditor(qt.QWidget):
+ sigDataCommited = qt.Signal()
+
+ def setEditorData(self, roi):
+ pass
+
+ def setRoiData(self, roi):
+ pass
+
+
+class _DefaultImageProfileRoiEditor(qt.QWidget):
+ sigDataCommited = qt.Signal()
+
+ def __init__(self, parent=None):
+ qt.QWidget.__init__(self, parent=parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self._initLayout(layout)
+
+ def _initLayout(self, layout):
+ self._lineWidth = qt.QSpinBox(self)
+ self._lineWidth.setRange(1, 1000)
+ self._lineWidth.setValue(1)
+ self._lineWidth.valueChanged[int].connect(self._widgetChanged)
+
+ self._methodsButton = ProfileOptionToolButton(parent=self, plot=None)
+ self._methodsButton.sigMethodChanged.connect(self._widgetChanged)
+
+ label = qt.QLabel("W:")
+ label.setToolTip("Line width in pixels")
+ layout.addWidget(label)
+ layout.addWidget(self._lineWidth)
+ layout.addWidget(self._methodsButton)
+
+ def _widgetChanged(self, value=None):
+ self.commitData()
+
+ def commitData(self):
+ self.sigDataCommited.emit()
+
+ def setEditorData(self, roi):
+ with blockSignals(self._lineWidth):
+ self._lineWidth.setValue(roi.getProfileLineWidth())
+ with blockSignals(self._methodsButton):
+ method = roi.getProfileMethod()
+ self._methodsButton.setMethod(method)
+
+ def setRoiData(self, roi):
+ lineWidth = self._lineWidth.value()
+ roi.setProfileLineWidth(lineWidth)
+ method = self._methodsButton.getMethod()
+ roi.setProfileMethod(method)
+
+
+class _DefaultImageStackProfileRoiEditor(_DefaultImageProfileRoiEditor):
+ def _initLayout(self, layout):
+ super(_DefaultImageStackProfileRoiEditor, self)._initLayout(layout)
+ self._profileDim = ProfileToolButton(parent=self, plot=None)
+ self._profileDim.sigDimensionChanged.connect(self._widgetChanged)
+ layout.addWidget(self._profileDim)
+
+ def setEditorData(self, roi):
+ super(_DefaultImageStackProfileRoiEditor, self).setEditorData(roi)
+ with blockSignals(self._profileDim):
+ kind = roi.getProfileType()
+ dim = {"1D": 1, "2D": 2}[kind]
+ self._profileDim.setDimension(dim)
+
+ def setRoiData(self, roi):
+ super(_DefaultImageStackProfileRoiEditor, self).setRoiData(roi)
+ dim = self._profileDim.getDimension()
+ kind = {1: "1D", 2: "2D"}[dim]
+ roi.setProfileType(kind)
+
+
+class _DefaultScatterProfileRoiEditor(qt.QWidget):
+ sigDataCommited = qt.Signal()
+
+ def __init__(self, parent=None):
+ qt.QWidget.__init__(self, parent=parent)
+
+ self._nPoints = qt.QSpinBox(self)
+ self._nPoints.setRange(1, 9999)
+ self._nPoints.setValue(1024)
+ self._nPoints.valueChanged[int].connect(self.__widgetChanged)
+
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ label = qt.QLabel("Samples:")
+ label.setToolTip("Number of sample points of the profile")
+ layout.addWidget(label)
+ layout.addWidget(self._nPoints)
+
+ def __widgetChanged(self, value=None):
+ self.commitData()
+
+ def commitData(self):
+ self.sigDataCommited.emit()
+
+ def setEditorData(self, roi):
+ with blockSignals(self._nPoints):
+ self._nPoints.setValue(roi.getNPoints())
+
+ def setRoiData(self, roi):
+ nPoints = self._nPoints.value()
+ roi.setNPoints(nPoints)
+
+
+class ProfileRoiEditorAction(qt.QWidgetAction):
+ """
+ Action displaying GUI to edit the selected ROI.
+
+ :param qt.QWidget parent: Parent widget
+ """
+
+ def __init__(self, parent=None):
+ super(ProfileRoiEditorAction, self).__init__(parent)
+ self.__roiManager = None
+ self.__roi = None
+ self.__inhibiteReentance = None
+
+ def createWidget(self, parent):
+ """Inherit the method to create a new editor"""
+ widget = qt.QWidget(parent)
+ layout = qt.QHBoxLayout(widget)
+ if isinstance(parent, qt.QMenu):
+ margins = layout.contentsMargins()
+ layout.setContentsMargins(margins.left(), 0, margins.right(), 0)
+ else:
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ editorClass = self.getEditorClass(self.__roi)
+ editor = editorClass(parent)
+ editor.setEditorData(self.__roi)
+ self.__setEditor(widget, editor)
+ return widget
+
+ def deleteWidget(self, widget):
+ """Inherit the method to delete an editor"""
+ self.__setEditor(widget, None)
+ return qt.QWidgetAction.deleteWidget(self, widget)
+
+ def _getEditor(self, widget):
+ """Returns the editor contained in the widget holder"""
+ layout = widget.layout()
+ if layout.count() == 0:
+ return None
+ return layout.itemAt(0).widget()
+
+ def setRoiManager(self, roiManager):
+ """
+ Connect this action to a ROI manager.
+
+ :param RegionOfInterestManager roiManager: A ROI manager
+ """
+ if self.__roiManager is roiManager:
+ return
+ if self.__roiManager is not None:
+ self.__roiManager.sigCurrentRoiChanged.disconnect(self.__currentRoiChanged)
+ self.__roiManager = roiManager
+ if self.__roiManager is not None:
+ self.__roiManager.sigCurrentRoiChanged.connect(self.__currentRoiChanged)
+ self.__currentRoiChanged(roiManager.getCurrentRoi())
+
+ def __currentRoiChanged(self, roi):
+ """Handle changes of the selected ROI"""
+ if roi is not None and not isinstance(roi, core.ProfileRoiMixIn):
+ return
+ self.setProfileRoi(roi)
+
+ def setProfileRoi(self, roi):
+ """Set a profile ROI to edit.
+
+ :param ProfileRoiMixIn roi: A profile ROI
+ """
+ if self.__roi is roi:
+ return
+ if self.__roi is not None:
+ self.__roi.sigProfilePropertyChanged.disconnect(self.__roiPropertyChanged)
+ self.__roi = roi
+ if self.__roi is not None:
+ self.__roi.sigProfilePropertyChanged.connect(self.__roiPropertyChanged)
+ self._updateWidgets()
+
+ def getRoiProfile(self):
+ """Returns the edited profile ROI.
+
+ :rtype: ProfileRoiMixIn
+ """
+ return self.__roi
+
+ def __roiPropertyChanged(self):
+ """Handle changes on the property defining the ROI."""
+ self._updateWidgetValues()
+
+ def __setEditor(self, widget, editor):
+ """Set the editor to display.
+
+ :param qt.QWidget editor: The editor to display
+ """
+ previousEditor = self._getEditor(widget)
+ if previousEditor is editor:
+ return
+ layout = widget.layout()
+ if previousEditor is not None:
+ try:
+ previousEditor.sigDataCommited.disconnect(self._editorDataCommited)
+ except (RuntimeError, TypeError):
+ pass
+ layout.removeWidget(previousEditor)
+ previousEditor.deleteLater()
+ if editor is not None:
+ editor.sigDataCommited.connect(self._editorDataCommited)
+ layout.addWidget(editor)
+
+ def getEditorClass(self, roi):
+ """Returns the editor class to use according to the ROI."""
+ if roi is None:
+ editorClass = _NoProfileRoiEditor
+ elif isinstance(
+ roi,
+ (rois._DefaultImageStackProfileRoiMixIn, rois.ProfileImageStackCrossROI),
+ ):
+ # Must be done before the default image ROI
+ # Cause ImageStack ROIs inherit from Image ROIs
+ editorClass = _DefaultImageStackProfileRoiEditor
+ elif isinstance(
+ roi, (rois._DefaultImageProfileRoiMixIn, rois.ProfileImageCrossROI)
+ ):
+ editorClass = _DefaultImageProfileRoiEditor
+ elif isinstance(
+ roi, (rois._DefaultScatterProfileRoiMixIn, rois.ProfileScatterCrossROI)
+ ):
+ editorClass = _DefaultScatterProfileRoiEditor
+ else:
+ # Unsupported
+ editorClass = _NoProfileRoiEditor
+ return editorClass
+
+ def _updateWidgets(self):
+ """Update the kind of editor to display, according to the selected
+ profile ROI."""
+ parent = self.parent()
+ editorClass = self.getEditorClass(self.__roi)
+ for widget in self.createdWidgets():
+ editor = editorClass(parent)
+ editor.setEditorData(self.__roi)
+ self.__setEditor(widget, editor)
+
+ def _updateWidgetValues(self):
+ """Update the content of the displayed editor, according to the
+ selected profile ROI."""
+ for widget in self.createdWidgets():
+ editor = self._getEditor(widget)
+ if self.__inhibiteReentance is editor:
+ continue
+ editor.setEditorData(self.__roi)
+
+ def _editorDataCommited(self):
+ """Handle changes from the editor."""
+ editor = self.sender()
+ if self.__roi is not None:
+ self.__inhibiteReentance = editor
+ editor.setRoiData(self.__roi)
+ self.__inhibiteReentance = None
diff --git a/src/silx/gui/plot/tools/profile/manager.py b/src/silx/gui/plot/tools/profile/manager.py
new file mode 100644
index 0000000..6f4ba35
--- /dev/null
+++ b/src/silx/gui/plot/tools/profile/manager.py
@@ -0,0 +1,1109 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2021 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides a manager to compute and display profiles.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+import logging
+import weakref
+
+from silx.gui import qt
+from silx.gui import colors
+from silx.gui import utils
+
+from silx.utils.weakref import WeakMethodProxy
+from silx.gui import icons
+from silx.gui.plot import PlotWidget
+from silx.gui.plot.tools.roi import RegionOfInterestManager
+from silx.gui.plot.tools.roi import CreateRoiModeAction
+from silx.gui.plot import items
+from silx.gui.qt import silxGlobalThreadPool
+from silx.gui.qt import inspect
+from . import rois
+from . import core
+from . import editors
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _RunnableComputeProfile(qt.QRunnable):
+ """Runner to process profiles
+
+ :param qt.QThreadPool threadPool: The thread which will be used to
+ execute this runner. It is used to update the used signals
+ :param ~silx.gui.plot.items.Item item: Item in which the profile is
+ computed
+ :param ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn roi: ROI
+ defining the profile shape and other characteristics
+ """
+
+ class _Signals(qt.QObject):
+ """Signal holder"""
+
+ resultReady = qt.Signal(object, object)
+ runnerFinished = qt.Signal(object)
+
+ def __init__(self, threadPool, item, roi):
+ """Constructor"""
+ super(_RunnableComputeProfile, self).__init__()
+ self._signals = self._Signals()
+ self._signals.moveToThread(threadPool.thread())
+ self._item = item
+ self._roi = roi
+ self._cancelled = False
+
+ def _lazyCancel(self):
+ """Cancel the runner if it is not yet started.
+
+ The threadpool will still execute the runner, but this will process
+ nothing.
+
+ This is only used with Qt<5.9 where QThreadPool.tryTake is not available.
+ """
+ self._cancelled = True
+
+ def autoDelete(self):
+ return False
+
+ def getRoi(self):
+ """Returns the ROI in which the runner will compute a profile.
+
+ :rtype: ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn
+ """
+ return self._roi
+
+ @property
+ def resultReady(self):
+ """Signal emitted when the result of the computation is available.
+
+ This signal provides 2 values: The ROI, and the computation result.
+ """
+ return self._signals.resultReady
+
+ @property
+ def runnerFinished(self):
+ """Signal emitted when runner have finished.
+
+ This signal provides a single value: the runner itself.
+ """
+ return self._signals.runnerFinished
+
+ def run(self):
+ """Process the profile computation."""
+ if not self._cancelled:
+ try:
+ profileData = self._roi.computeProfile(self._item)
+ except Exception:
+ _logger.error("Error while computing profile", exc_info=True)
+ else:
+ self.resultReady.emit(self._roi, profileData)
+ self.runnerFinished.emit(self)
+
+
+class ProfileWindow(qt.QMainWindow):
+ """
+ Display a computed profile.
+
+ The content can be described using :meth:`setRoiProfile` if the source of
+ the profile is a profile ROI, and :meth:`setProfile` for the data content.
+ """
+
+ sigClose = qt.Signal()
+ """Emitted by :meth:`closeEvent` (e.g. when the window is closed
+ through the window manager's close icon)."""
+
+ def __init__(self, parent=None, backend=None):
+ qt.QMainWindow.__init__(self, parent=parent, flags=qt.Qt.Dialog)
+
+ self.setWindowTitle("Profile window")
+ self._plot1D = None
+ self._plot2D = None
+ self._backend = backend
+ self._data = None
+
+ widget = qt.QWidget()
+ self._layout = qt.QStackedLayout(widget)
+ self._layout.setContentsMargins(0, 0, 0, 0)
+ self.setCentralWidget(widget)
+
+ def prepareWidget(self, roi):
+ """Called before the show to prepare the window to use with
+ a specific ROI."""
+ if isinstance(roi, rois._DefaultImageStackProfileRoiMixIn):
+ profileType = roi.getProfileType()
+ else:
+ profileType = "1D"
+ if profileType == "1D":
+ self.getPlot1D()
+ elif profileType == "2D":
+ self.getPlot2D()
+
+ def createPlot1D(self, parent, backend):
+ """Inherit this function to create your own plot to render 1D
+ profiles. The default value is a `Plot1D`.
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot.
+ See :class:`PlotWidget` for the list of supported backend.
+ :rtype: PlotWidget
+ """
+ # import here to avoid circular import
+ from ...PlotWindow import Plot1D
+
+ plot = Plot1D(parent=parent, backend=backend)
+ plot.setDataMargins(yMinMargin=0.1, yMaxMargin=0.1)
+ plot.setGraphYLabel("Profile")
+ plot.setGraphXLabel("")
+ positionInfo = plot.getPositionInfoWidget()
+ positionInfo.setSnappingMode(positionInfo.SNAPPING_CURVE)
+ return plot
+
+ def createPlot2D(self, parent, backend):
+ """Inherit this function to create your own plot to render 2D
+ profiles. The default value is a `Plot2D`.
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot.
+ See :class:`PlotWidget` for the list of supported backend.
+ :rtype: PlotWidget
+ """
+ # import here to avoid circular import
+ from ...PlotWindow import Plot2D
+
+ return Plot2D(parent=parent, backend=backend)
+
+ def getPlot1D(self, init=True):
+ """Return the current plot used to display curves and create it if it
+ does not yet exists and `init` is True. Else returns None."""
+ if not init:
+ return self._plot1D
+ if self._plot1D is None:
+ self._plot1D = self.createPlot1D(self, self._backend)
+ self._layout.addWidget(self._plot1D)
+ return self._plot1D
+
+ def _showPlot1D(self):
+ plot = self.getPlot1D()
+ self._layout.setCurrentWidget(plot)
+
+ def getPlot2D(self, init=True):
+ """Return the current plot used to display image and create it if it
+ does not yet exists and `init` is True. Else returns None."""
+ if not init:
+ return self._plot2D
+ if self._plot2D is None:
+ self._plot2D = self.createPlot2D(parent=self, backend=self._backend)
+ self._layout.addWidget(self._plot2D)
+ return self._plot2D
+
+ def _showPlot2D(self):
+ plot = self.getPlot2D()
+ self._layout.setCurrentWidget(plot)
+
+ def getCurrentPlotWidget(self):
+ return self._layout.currentWidget()
+
+ def closeEvent(self, qCloseEvent):
+ self.sigClose.emit()
+ qCloseEvent.accept()
+
+ def setRoiProfile(self, roi):
+ """Set the profile ROI which it the source of the following data
+ to display.
+
+ :param ProfileRoiMixIn roi: The profile ROI data source
+ """
+ if roi is None:
+ return
+ self.__color = colors.rgba(roi.getColor())
+
+ def _setImageProfile(self, data: core.ImageProfileData):
+ """
+ Setup the window to display a new profile data which is represented
+ by an image.
+
+ :param data: Computed data profile
+ """
+ plot = self.getPlot2D()
+
+ plot.clear()
+ plot.setGraphTitle(data.title)
+ plot.getXAxis().setLabel(data.xLabel)
+
+ coords = data.coords
+ colormap = data.colormap
+ profileScale = (coords[-1] - coords[0]) / data.profile.shape[1], 1
+ plot.addImage(
+ data.profile,
+ legend="profile",
+ colormap=colormap,
+ origin=(coords[0], 0),
+ scale=profileScale,
+ )
+ plot.getYAxis().setLabel("Frame index (depth)")
+
+ self._showPlot2D()
+
+ def _setCurveProfile(self, data: core.CurveProfileData):
+ """
+ Setup the window to display a new profile data which is represented
+ by a curve.
+
+ :param data: Computed data profile
+ """
+ plot = self.getPlot1D()
+
+ plot.clear()
+ plot.setGraphTitle(data.title)
+ plot.getXAxis().setLabel(data.xLabel)
+ plot.getYAxis().setLabel(data.yLabel)
+
+ plot.addCurve(data.coords, data.profile, legend="level", color=self.__color)
+
+ self._showPlot1D()
+
+ def _setRgbaProfile(self, data: core.RgbaProfileData):
+ """
+ Setup the window to display a new profile data which is represented
+ by a curve.
+
+ :param data: Computed data profile
+ """
+ plot = self.getPlot1D()
+
+ plot.clear()
+ plot.setGraphTitle(data.title)
+ plot.getXAxis().setLabel(data.xLabel)
+ plot.getYAxis().setLabel(data.yLabel)
+
+ self._showPlot1D()
+
+ plot.addCurve(data.coords, data.profile, legend="level", color="black")
+ plot.addCurve(data.coords, data.profile_r, legend="red", color="red")
+ plot.addCurve(data.coords, data.profile_g, legend="green", color="green")
+ plot.addCurve(data.coords, data.profile_b, legend="blue", color="blue")
+ if data.profile_a is not None:
+ plot.addCurve(data.coords, data.profile_a, legend="alpha", color="gray")
+
+ def _setCurvesProfile(self, data: core.CurvesProfileData):
+ """
+ Setup the window to display a new profile data which is represented
+ by multiple curves.
+
+ :param data: Computed data profile
+ """
+ plot = self.getPlot1D()
+
+ plot.clear()
+ plot.setGraphTitle(data.title)
+ plot.getXAxis().setLabel(data.xLabel)
+ plot.getYAxis().setLabel(data.yLabel)
+
+ self._showPlot1D()
+
+ for i, desc in enumerate(data.profiles):
+ name = desc.name if desc.name is not None else f"profile{i}"
+ plot.addCurve(data.coords, desc.profile, legend=name, color=desc.color)
+
+ def clear(self):
+ """Clear the window profile"""
+ plot = self.getPlot1D(init=False)
+ if plot is not None:
+ plot.clear()
+ plot = self.getPlot2D(init=False)
+ if plot is not None:
+ plot.clear()
+
+ def getProfile(self):
+ """Returns the profile data which is displayed"""
+ return self.__data
+
+ def setProfile(self, data):
+ """
+ Setup the window to display a new profile data.
+
+ This method dispatch the result to a specific method according to the
+ data type.
+
+ :param data: Computed data profile
+ """
+ self.__data = data
+ if data is None:
+ self.clear()
+ elif isinstance(data, core.ImageProfileData):
+ self._setImageProfile(data)
+ elif isinstance(data, core.RgbaProfileData):
+ self._setRgbaProfile(data)
+ elif isinstance(data, core.CurveProfileData):
+ self._setCurveProfile(data)
+ elif isinstance(data, core.CurvesProfileData):
+ self._setCurvesProfile(data)
+ else:
+ raise TypeError("Unsupported type %s" % type(data))
+
+
+class _ClearAction(qt.QAction):
+ """Action to clear the profile manager
+
+ The action is only enabled if something can be cleaned up.
+ """
+
+ def __init__(self, parent, profileManager):
+ super(_ClearAction, self).__init__(parent)
+ self.__profileManager = weakref.ref(profileManager)
+ icon = icons.getQIcon("profile-clear")
+ self.setIcon(icon)
+ self.setText("Clear profile")
+ self.setToolTip("Clear the profiles")
+ self.setCheckable(False)
+ self.setEnabled(False)
+ self.triggered.connect(profileManager.clearProfile)
+ plot = profileManager.getPlotWidget()
+ roiManager = profileManager.getRoiManager()
+ plot.sigInteractiveModeChanged.connect(self.__modeUpdated)
+ roiManager.sigRoiChanged.connect(self.__roiListUpdated)
+
+ def getProfileManager(self):
+ return self.__profileManager()
+
+ def __roiListUpdated(self):
+ self.__update()
+
+ def __modeUpdated(self, source):
+ self.__update()
+
+ def __update(self):
+ profileManager = self.getProfileManager()
+ if profileManager is None:
+ return
+ roiManager = profileManager.getRoiManager()
+ if roiManager is None:
+ return
+ enabled = roiManager.isStarted() or len(roiManager.getRois()) > 0
+ self.setEnabled(enabled)
+
+
+class _StoreLastParamBehavior(qt.QObject):
+ """This object allow to store and restore the properties of the ROI
+ profiles"""
+
+ def __init__(self, parent):
+ assert isinstance(parent, ProfileManager)
+ super(_StoreLastParamBehavior, self).__init__(parent=parent)
+ self.__properties = {}
+ self.__profileRoi = None
+ self.__filter = utils.LockReentrant()
+
+ def _roi(self):
+ """Return the spied ROI"""
+ if self.__profileRoi is None:
+ return None
+ roi = self.__profileRoi()
+ if roi is None:
+ self.__profileRoi = None
+ return roi
+
+ def setProfileRoi(self, roi):
+ """Set a profile ROI to spy.
+
+ :param ProfileRoiMixIn roi: A profile ROI
+ """
+ previousRoi = self._roi()
+ if previousRoi is roi:
+ return
+ if previousRoi is not None:
+ previousRoi.sigProfilePropertyChanged.disconnect(
+ self._profilePropertyChanged
+ )
+ self.__profileRoi = None if roi is None else weakref.ref(roi)
+ if roi is not None:
+ roi.sigProfilePropertyChanged.connect(self._profilePropertyChanged)
+
+ def _profilePropertyChanged(self):
+ """Handle changes on the properties defining the profile ROI."""
+ if self.__filter.locked():
+ return
+ roi = self.sender()
+ self.storeProperties(roi)
+
+ def storeProperties(self, roi):
+ if isinstance(
+ roi,
+ (rois._DefaultImageStackProfileRoiMixIn, rois.ProfileImageStackCrossROI),
+ ):
+ self.__properties["method"] = roi.getProfileMethod()
+ self.__properties["line-width"] = roi.getProfileLineWidth()
+ self.__properties["type"] = roi.getProfileType()
+ elif isinstance(
+ roi, (rois._DefaultImageProfileRoiMixIn, rois.ProfileImageCrossROI)
+ ):
+ self.__properties["method"] = roi.getProfileMethod()
+ self.__properties["line-width"] = roi.getProfileLineWidth()
+ elif isinstance(
+ roi, (rois._DefaultScatterProfileRoiMixIn, rois.ProfileScatterCrossROI)
+ ):
+ self.__properties["npoints"] = roi.getNPoints()
+
+ def restoreProperties(self, roi):
+ with self.__filter:
+ if isinstance(
+ roi,
+ (
+ rois._DefaultImageStackProfileRoiMixIn,
+ rois.ProfileImageStackCrossROI,
+ ),
+ ):
+ value = self.__properties.get("method", None)
+ if value is not None:
+ roi.setProfileMethod(value)
+ value = self.__properties.get("line-width", None)
+ if value is not None:
+ roi.setProfileLineWidth(value)
+ value = self.__properties.get("type", None)
+ if value is not None:
+ roi.setProfileType(value)
+ elif isinstance(
+ roi, (rois._DefaultImageProfileRoiMixIn, rois.ProfileImageCrossROI)
+ ):
+ value = self.__properties.get("method", None)
+ if value is not None:
+ roi.setProfileMethod(value)
+ value = self.__properties.get("line-width", None)
+ if value is not None:
+ roi.setProfileLineWidth(value)
+ elif isinstance(
+ roi, (rois._DefaultScatterProfileRoiMixIn, rois.ProfileScatterCrossROI)
+ ):
+ value = self.__properties.get("npoints", None)
+ if value is not None:
+ roi.setNPoints(value)
+
+
+class ProfileManager(qt.QObject):
+ """Base class for profile management tools
+
+ :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate.
+ :param plot: :class:`~silx.gui.plot.tools.roi.RegionOfInterestManager`
+ on which to operate.
+ """
+
+ def __init__(self, parent=None, plot=None, roiManager=None):
+ super(ProfileManager, self).__init__(parent)
+
+ assert isinstance(plot, PlotWidget)
+ self._plotRef = weakref.ref(plot, WeakMethodProxy(self.__plotDestroyed))
+
+ # Set-up interaction manager
+ if roiManager is None:
+ roiManager = RegionOfInterestManager(plot)
+
+ self._roiManagerRef = weakref.ref(roiManager)
+ self._rois = []
+ self._pendingRunners = []
+ """List of ROIs which have to be updated"""
+
+ self.__reentrantResults = {}
+ """Store reentrant result to avoid to skip some of them
+ cause the implementation uses a QEventLoop."""
+
+ self._profileWindowClass = ProfileWindow
+ """Class used to display the profile results"""
+
+ self._computedProfiles = 0
+ """Statistics for tests"""
+
+ self.__itemTypes = []
+ """Kind of items to use"""
+
+ self.__tracking = False
+ """Is the plot active items are tracked"""
+
+ self.__useColorFromCursor = True
+ """If true, force the ROI color with the colormap marker color"""
+
+ self._item = None
+ """The selected item"""
+
+ self.__singleProfileAtATime = True
+ """When it's true, only a single profile is displayed at a time."""
+
+ self._previousWindowGeometry = []
+
+ self._storeProperties = _StoreLastParamBehavior(self)
+ """If defined the profile properties of the last ROI are reused to the
+ new created ones"""
+
+ # Listen to plot limits changed
+ plot.getXAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile)
+ plot.getYAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile)
+
+ roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished)
+ roiManager.sigInteractiveRoiCreated.connect(self.__roiCreated)
+ roiManager.sigRoiAdded.connect(self.__roiAdded)
+ roiManager.sigRoiAboutToBeRemoved.connect(self.__roiRemoved)
+
+ def setSingleProfile(self, enable):
+ """
+ Enable or disable the single profile mode.
+
+ In single mode, the manager enforce a single ROI at the same
+ time. A new one will remove the previous one.
+
+ If this mode is not enabled, many ROIs can be created, and many
+ profile windows will be displayed.
+ """
+ self.__singleProfileAtATime = enable
+
+ def isSingleProfile(self):
+ """
+ Returns true if the manager is in a single profile mode.
+
+ :rtype: bool
+ """
+ return self.__singleProfileAtATime
+
+ def __interactionFinished(self):
+ """Handle end of interactive mode"""
+ pass
+
+ def __roiAdded(self, roi):
+ """Handle new ROI"""
+ # Filter out non profile ROIs
+ if not isinstance(roi, core.ProfileRoiMixIn):
+ return
+ self.__addProfile(roi)
+
+ def __roiRemoved(self, roi):
+ """Handle removed ROI"""
+ # Filter out non profile ROIs
+ if not isinstance(roi, core.ProfileRoiMixIn):
+ return
+ self.__removeProfile(roi)
+
+ def createProfileAction(self, profileRoiClass, parent=None):
+ """Create an action from a class of ProfileRoi
+
+ :param core.ProfileRoiMixIn profileRoiClass: A class of a profile ROI
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: qt.QAction
+ """
+ if not issubclass(profileRoiClass, core.ProfileRoiMixIn):
+ raise TypeError("Type %s not expected" % type(profileRoiClass))
+ roiManager = self.getRoiManager()
+ action = CreateRoiModeAction(parent, roiManager, profileRoiClass)
+ if hasattr(profileRoiClass, "ICON"):
+ action.setIcon(icons.getQIcon(profileRoiClass.ICON))
+ if hasattr(profileRoiClass, "NAME"):
+
+ def articulify(word):
+ """Add an an/a article in the front of the word"""
+ first = word[1] if word[0] == "h" else word[0]
+ if first in "aeiou":
+ return "an " + word
+ return "a " + word
+
+ action.setText("Define %s" % articulify(profileRoiClass.NAME))
+ action.setToolTip("Enables %s selection mode" % profileRoiClass.NAME)
+ action.setSingleShot(True)
+ return action
+
+ def createClearAction(self, parent):
+ """Create an action to clean up the plot from the profile ROIs.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: qt.QAction
+ """
+ action = _ClearAction(parent, self)
+ return action
+
+ def createImageActions(self, parent):
+ """Create actions designed for image items. This actions created
+ new ROIs.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileImageHorizontalLineROI,
+ rois.ProfileImageVerticalLineROI,
+ rois.ProfileImageLineROI,
+ rois.ProfileImageDirectedLineROI,
+ rois.ProfileImageCrossROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createScatterActions(self, parent):
+ """Create actions designed for scatter items. This actions created
+ new ROIs.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileScatterHorizontalLineROI,
+ rois.ProfileScatterVerticalLineROI,
+ rois.ProfileScatterLineROI,
+ rois.ProfileScatterCrossROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createScatterSliceActions(self, parent):
+ """Create actions designed for regular scatter items. This actions
+ created new ROIs.
+
+ This ROIs was designed to use the input data without interpolation,
+ like you could do with an image.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileScatterHorizontalSliceROI,
+ rois.ProfileScatterVerticalSliceROI,
+ rois.ProfileScatterCrossSliceROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createImageStackActions(self, parent):
+ """Create actions designed for stack image items. This actions
+ created new ROIs.
+
+ This ROIs was designed to create both profile on the displayed image
+ and profile on the full stack (2D result).
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileImageStackHorizontalLineROI,
+ rois.ProfileImageStackVerticalLineROI,
+ rois.ProfileImageStackLineROI,
+ rois.ProfileImageStackCrossROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createEditorAction(self, parent):
+ """Create an action containing GUI to edit the selected profile ROI.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: qt.QAction
+ """
+ action = editors.ProfileRoiEditorAction(parent)
+ action.setRoiManager(self.getRoiManager())
+ return action
+
+ def setItemType(self, image=False, scatter=False):
+ """Set the item type to use and select the active one.
+
+ :param bool image: Image item are allowed
+ :param bool scatter: Scatter item are allowed
+ """
+ self.__itemTypes = []
+ plot = self.getPlotWidget()
+ item = None
+ if image:
+ self.__itemTypes.append("image")
+ item = plot.getActiveImage()
+ if scatter:
+ self.__itemTypes.append("scatter")
+ if item is None:
+ item = plot.getActiveScatter()
+ self.setPlotItem(item)
+
+ def setProfileWindowClass(self, profileWindowClass):
+ """Set the class which will be instantiated to display profile result."""
+ self._profileWindowClass = profileWindowClass
+
+ def setActiveItemTracking(self, tracking):
+ """Enable/disable the tracking of the active item of the plot.
+
+ :param bool tracking: Tracking mode
+ """
+ if self.__tracking == tracking:
+ return
+ plot = self.getPlotWidget()
+ if self.__tracking:
+ plot.sigActiveImageChanged.disconnect(self._activeImageChanged)
+ plot.sigActiveScatterChanged.disconnect(self._activeScatterChanged)
+ self.__tracking = tracking
+ if self.__tracking:
+ plot.sigActiveImageChanged.connect(self.__activeImageChanged)
+ plot.sigActiveScatterChanged.connect(self.__activeScatterChanged)
+
+ def setDefaultColorFromCursorColor(self, enabled):
+ """Enabled/disable the use of the colormap cursor color to display the
+ ROIs.
+
+ If set, the manager will update the color of the profile ROIs using the
+ current colormap cursor color from the selected item.
+ """
+ self.__useColorFromCursor = enabled
+
+ def __activeImageChanged(self, previous, legend):
+ """Handle plot item selection"""
+ if "image" in self.__itemTypes:
+ plot = self.getPlotWidget()
+ item = plot.getImage(legend)
+ self.setPlotItem(item)
+
+ def __activeScatterChanged(self, previous, legend):
+ """Handle plot item selection"""
+ if "scatter" in self.__itemTypes:
+ plot = self.getPlotWidget()
+ item = plot.getScatter(legend)
+ self.setPlotItem(item)
+
+ def __roiCreated(self, roi):
+ """Handle ROI creation"""
+ # Filter out non profile ROIs
+ if isinstance(roi, core.ProfileRoiMixIn):
+ if self._storeProperties is not None:
+ # Initialize the properties with the previous ones
+ self._storeProperties.restoreProperties(roi)
+
+ def __addProfile(self, profileRoi):
+ """Add a new ROI to the manager."""
+ if profileRoi.getFocusProxy() is None:
+ if self._storeProperties is not None:
+ # Follow changes on properties
+ self._storeProperties.setProfileRoi(profileRoi)
+ if self.__singleProfileAtATime:
+ # FIXME: It would be good to reuse the windows to avoid blinking
+ self.clearProfile()
+
+ profileRoi._setProfileManager(self)
+ self._updateRoiColor(profileRoi)
+ self._rois.append(profileRoi)
+ self.requestUpdateProfile(profileRoi)
+
+ def __removeProfile(self, profileRoi):
+ """Remove a ROI from the manager."""
+ window = self._disconnectProfileWindow(profileRoi)
+ if window is not None:
+ geometry = window.geometry()
+ if not geometry.isEmpty():
+ self._previousWindowGeometry.append(geometry)
+ self.clearProfileWindow(window)
+ if profileRoi in self._rois:
+ self._rois.remove(profileRoi)
+
+ def _disconnectProfileWindow(self, profileRoi):
+ """Handle profile window close."""
+ window = profileRoi.getProfileWindow()
+ profileRoi.setProfileWindow(None)
+ return window
+
+ def clearProfile(self):
+ """Clear the associated ROI profile"""
+ roiManager = self.getRoiManager()
+ for roi in list(self._rois):
+ if roi.getFocusProxy() is not None:
+ # Skip sub ROIs, it will be removed by their parents
+ continue
+ roiManager.removeRoi(roi)
+
+ if not roiManager.isDrawing():
+ # Clean the selected mode
+ roiManager.stop()
+
+ def hasPendingOperations(self):
+ """Returns true if a thread is still computing or displaying a profile.
+
+ :rtype: bool
+ """
+ return len(self.__reentrantResults) > 0 or len(self._pendingRunners) > 0
+
+ def requestUpdateAllProfile(self):
+ """Request to update the profile of all the managed ROIs."""
+ for roi in self._rois:
+ self.requestUpdateProfile(roi)
+
+ def requestUpdateProfile(self, profileRoi):
+ """Request to update a specific profile ROI.
+
+ :param ~core.ProfileRoiMixIn profileRoi:
+ """
+ if profileRoi.computeProfile is None:
+ return
+ threadPool = silxGlobalThreadPool()
+
+ # Clean up deprecated runners
+ for runner in list(self._pendingRunners):
+ if not inspect.isValid(runner):
+ self._pendingRunners.remove(runner)
+ continue
+ if runner.getRoi() is profileRoi:
+ if hasattr(threadPool, "tryTake"):
+ if threadPool.tryTake(runner):
+ self._pendingRunners.remove(runner)
+ else: # Support Qt<5.9
+ runner._lazyCancel()
+
+ item = self.getPlotItem()
+ if item is None or not isinstance(item, profileRoi.ITEM_KIND):
+ # This item is not compatible with this profile
+ profileRoi._setPlotItem(None)
+ profileWindow = profileRoi.getProfileWindow()
+ if profileWindow is not None:
+ profileWindow.setProfile(None)
+ return
+
+ profileRoi._setPlotItem(item)
+ runner = _RunnableComputeProfile(threadPool, item, profileRoi)
+ runner.runnerFinished.connect(self.__cleanUpRunner)
+ runner.resultReady.connect(self.__displayResult)
+ self._pendingRunners.append(runner)
+ threadPool.start(runner)
+
+ def __cleanUpRunner(self, runner):
+ """Remove a thread pool runner from the list of hold tasks.
+
+ Called at the termination of the runner.
+ """
+ if runner in self._pendingRunners:
+ self._pendingRunners.remove(runner)
+
+ def __displayResult(self, roi, profileData):
+ """Display the result of a ROI.
+
+ :param ~core.ProfileRoiMixIn profileRoi: A managed ROI
+ :param ~core.CurveProfileData profileData: Computed data profile
+ """
+ if roi in self.__reentrantResults:
+ # Store the data to process it in the main loop
+ # And not a sub loop created by initProfileWindow
+ # This also remove the duplicated requested
+ self.__reentrantResults[roi] = profileData
+ return
+
+ self.__reentrantResults[roi] = profileData
+ self._computedProfiles = self._computedProfiles + 1
+ window = roi.getProfileWindow()
+ if window is None:
+ plot = self.getPlotWidget()
+ window = self.createProfileWindow(plot, roi)
+ # roi.profileWindow have to be set before initializing the window
+ # Cause the initialization is using QEventLoop
+ roi.setProfileWindow(window)
+ self.initProfileWindow(window, roi)
+ window.show()
+
+ lastData = self.__reentrantResults.pop(roi)
+ window.setProfile(lastData)
+
+ def __plotDestroyed(self, ref):
+ """Handle finalization of PlotWidget
+
+ :param ref: weakref to the plot
+ """
+ self._plotRef = None
+ self._roiManagerRef = None
+ self._pendingRunners = []
+
+ def setPlotItem(self, item):
+ """Set the plot item focused by the profile manager.
+
+ :param ~silx.gui.plot.items.Item item: A plot item
+ """
+ previous = self.getPlotItem()
+ if previous is item:
+ return
+ if item is None:
+ self._item = None
+ else:
+ item.sigItemChanged.connect(self.__itemChanged)
+ self._item = weakref.ref(item)
+ self._updateRoiColors()
+ self.requestUpdateAllProfile()
+
+ def getDefaultColor(self, item):
+ """Returns the default ROI color to use according to the given item.
+
+ :param ~silx.gui.plot.items.item.Item item: AN item
+ :rtype: qt.QColor
+ """
+ color = "pink"
+ if isinstance(item, items.ColormapMixIn):
+ colormap = item.getColormap()
+ name = colormap.getName()
+ if name is not None:
+ color = colors.cursorColorForColormap(name)
+ color = colors.asQColor(color)
+ return color
+
+ def _updateRoiColors(self):
+ """Update ROI color according to the item selection"""
+ if not self.__useColorFromCursor:
+ return
+ item = self.getPlotItem()
+ color = self.getDefaultColor(item)
+ for roi in self._rois:
+ roi.setColor(color)
+
+ def _updateRoiColor(self, roi):
+ """Update a specific ROI according to the current selected item.
+
+ :param RegionOfInterest roi: The ROI to update
+ """
+ if not self.__useColorFromCursor:
+ return
+ item = self.getPlotItem()
+ color = self.getDefaultColor(item)
+ roi.setColor(color)
+
+ def __itemChanged(self, changeType):
+ """Handle item changes."""
+ if changeType in (
+ items.ItemChangedType.DATA,
+ items.ItemChangedType.MASK,
+ items.ItemChangedType.POSITION,
+ items.ItemChangedType.SCALE,
+ ):
+ self.requestUpdateAllProfile()
+ elif changeType == (items.ItemChangedType.COLORMAP):
+ self._updateRoiColors()
+
+ def getPlotItem(self):
+ """Returns the item focused by the profile manager.
+
+ :rtype: ~silx.gui.plot.items.Item
+ """
+ if self._item is None:
+ return None
+ item = self._item()
+ if item is None:
+ self._item = None
+ return item
+
+ def getPlotWidget(self):
+ """The plot associated to the profile manager.
+
+ :rtype: ~silx.gui.plot.PlotWidget
+ """
+ if self._plotRef is None:
+ return None
+ plot = self._plotRef()
+ if plot is None:
+ self._plotRef = None
+ return plot
+
+ def getCurrentRoi(self):
+ """Returns the currently selected ROI, else None.
+
+ :rtype: core.ProfileRoiMixIn
+ """
+ roiManager = self.getRoiManager()
+ if roiManager is None:
+ return None
+ roi = roiManager.getCurrentRoi()
+ if not isinstance(roi, core.ProfileRoiMixIn):
+ return None
+ return roi
+
+ def getRoiManager(self):
+ """Returns the used ROI manager
+
+ :rtype: RegionOfInterestManager
+ """
+ return self._roiManagerRef()
+
+ def createProfileWindow(self, plot, roi):
+ """Create a new profile window.
+
+ :param ~core.ProfileRoiMixIn roi: The plot containing the raw data
+ :param ~core.ProfileRoiMixIn roi: A managed ROI
+ :rtype: ~ProfileWindow
+ """
+ return self._profileWindowClass(plot)
+
+ def initProfileWindow(self, profileWindow, roi):
+ """This function is called just after the profile window creation in
+ order to initialize the window location.
+
+ :param ~ProfileWindow profileWindow:
+ The profile window to initialize.
+ """
+ # Enforce the use of one of the widgets
+ # To have the correct window size
+ profileWindow.prepareWidget(roi)
+ profileWindow.adjustSize()
+
+ # Trick to avoid blinking while retrieving the right window size
+ # Display the window, hide it and wait for some event loops
+ profileWindow.show()
+ profileWindow.hide()
+ eventLoop = qt.QEventLoop(self)
+ for _ in range(10):
+ if not eventLoop.processEvents():
+ break
+
+ profileWindow.show()
+ if len(self._previousWindowGeometry) > 0:
+ geometry = self._previousWindowGeometry.pop()
+ profileWindow.setGeometry(geometry)
+ return
+
+ window = self.getPlotWidget().window()
+ winGeom = window.frameGeometry()
+ if qt.BINDING == "PyQt5":
+ qapp = qt.QApplication.instance()
+ desktop = qapp.desktop()
+ screenGeom = desktop.availableGeometry(window)
+ else: # Qt6 (and also Qt>=5.14)
+ screenGeom = window.screen().availableGeometry()
+ spaceOnLeftSide = winGeom.left()
+ spaceOnRightSide = screenGeom.width() - winGeom.right()
+
+ profileGeom = profileWindow.frameGeometry()
+ profileWidth = profileGeom.width()
+
+ # Align vertically to the center of the window
+ top = winGeom.top() + (winGeom.height() - profileGeom.height()) // 2
+
+ margin = 5
+ if profileWidth < spaceOnRightSide:
+ # Place profile on the right
+ left = winGeom.right() + margin
+ elif profileWidth < spaceOnLeftSide:
+ # Place profile on the left
+ left = max(0, winGeom.left() - profileWidth - margin)
+ else:
+ # Move it as much as possible where there is more space
+ if spaceOnLeftSide > spaceOnRightSide:
+ left = 0
+ else:
+ left = screenGeom.width() - profileGeom.width()
+ profileWindow.move(left, top)
+
+ def clearProfileWindow(self, profileWindow):
+ """Called when a profile window is not anymore needed.
+
+ By default the window will be closed. But it can be
+ inherited to change this behavior.
+ """
+ profileWindow.deleteLater()
diff --git a/src/silx/gui/plot/tools/profile/rois.py b/src/silx/gui/plot/tools/profile/rois.py
new file mode 100644
index 0000000..23f086a
--- /dev/null
+++ b/src/silx/gui/plot/tools/profile/rois.py
@@ -0,0 +1,1176 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2023 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module define ROIs for profile tools.
+
+.. inheritance-diagram::
+ silx.gui.plot.tools.profile.rois
+ :top-classes: silx.gui.plot.tools.profile.core.ProfileRoiMixIn, silx.gui.plot.items.roi.RegionOfInterest
+ :parts: 1
+ :private-bases:
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "01/12/2020"
+
+import numpy
+import weakref
+from concurrent.futures import CancelledError
+
+from silx.gui import colors
+
+from silx.gui.plot import items
+from silx.gui.plot.items import roi as roi_items
+from . import core
+from silx.gui import utils
+from .....utils.proxy import docstring
+
+
+def _relabelAxes(plot, text):
+ """Relabel {xlabel} and {ylabel} from this text using the corresponding
+ plot axis label. If the axis label is empty, label it with "X" and "Y".
+
+ :rtype: str
+ """
+ xLabel = plot.getXAxis().getLabel()
+ if not xLabel:
+ xLabel = "X"
+ yLabel = plot.getYAxis().getLabel()
+ if not yLabel:
+ yLabel = "Y"
+ return text.format(xlabel=xLabel, ylabel=yLabel)
+
+
+def _lineProfileTitle(x0, y0, x1, y1):
+ """Compute corresponding plot title
+
+ This can be overridden to change title behavior.
+
+ :param float x0: Profile start point X coord
+ :param float y0: Profile start point Y coord
+ :param float x1: Profile end point X coord
+ :param float y1: Profile end point Y coord
+ :return: Title to use
+ :rtype: str
+ """
+ if x0 == x1:
+ title = "{xlabel} = %g; {ylabel} = [%g, %g]" % (x0, y0, y1)
+ elif y0 == y1:
+ title = "{ylabel} = %g; {xlabel} = [%g, %g]" % (y0, x0, x1)
+ else:
+ m = (y1 - y0) / (x1 - x0)
+ b = y0 - m * x0
+ title = "{ylabel} = %g * {xlabel} %+g" % (m, b)
+
+ return title
+
+
+class _ImageProfileArea(items.Shape):
+ """This shape displays the location of pixels used to compute the
+ profile."""
+
+ def __init__(self, parentRoi):
+ items.Shape.__init__(self, "polygon")
+ color = colors.rgba(parentRoi.getColor())
+ self.setColor(color)
+ self.setFill(True)
+ self.setOverlay(True)
+ self.setPoints([[0, 0], [0, 0]]) # Else it segfault
+
+ self.__parentRoi = weakref.ref(parentRoi)
+ parentRoi.sigItemChanged.connect(self._updateAreaProperty)
+ parentRoi.sigRegionChanged.connect(self._updateArea)
+ parentRoi.sigProfilePropertyChanged.connect(self._updateArea)
+ parentRoi.sigPlotItemChanged.connect(self._updateArea)
+
+ def getParentRoi(self):
+ if self.__parentRoi is None:
+ return None
+ parentRoi = self.__parentRoi()
+ if parentRoi is None:
+ self.__parentRoi = None
+ return parentRoi
+
+ def _updateAreaProperty(self, event=None, checkVisibility=True):
+ parentRoi = self.sender()
+ if event == items.ItemChangedType.COLOR:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+ elif event == items.ItemChangedType.VISIBLE:
+ if self.getPlotItem() is not None:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+
+ def _updateArea(self):
+ roi = self.getParentRoi()
+ item = roi.getPlotItem()
+ if item is None:
+ self.setVisible(False)
+ return
+ polygon = self._computePolygon(item)
+ self.setVisible(True)
+ polygon = numpy.array(polygon).T
+ self.setLineStyle("--")
+ self.setPoints(polygon, copy=False)
+
+ def _computePolygon(self, item):
+ if not isinstance(item, items.ImageBase):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ currentData = item.getValueData(copy=False)
+
+ roi = self.getParentRoi()
+ origin = item.getOrigin()
+ scale = item.getScale()
+ _coords, _profile, area, _profileName, _xLabel = core.createProfile(
+ roiInfo=roi._getRoiInfo(),
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=roi.getProfileLineWidth(),
+ method="none",
+ )
+ return area
+
+
+class _SliceProfileArea(items.Shape):
+ """This shape displays the location a profile in a scatter.
+
+ Each point used to compute the slice are linked together.
+ """
+
+ def __init__(self, parentRoi):
+ items.Shape.__init__(self, "polygon")
+ color = colors.rgba(parentRoi.getColor())
+ self.setColor(color)
+ self.setFill(True)
+ self.setOverlay(True)
+ self.setPoints([[0, 0], [0, 0]]) # Else it segfault
+
+ self.__parentRoi = weakref.ref(parentRoi)
+ parentRoi.sigItemChanged.connect(self._updateAreaProperty)
+ parentRoi.sigRegionChanged.connect(self._updateArea)
+ parentRoi.sigProfilePropertyChanged.connect(self._updateArea)
+ parentRoi.sigPlotItemChanged.connect(self._updateArea)
+
+ def getParentRoi(self):
+ if self.__parentRoi is None:
+ return None
+ parentRoi = self.__parentRoi()
+ if parentRoi is None:
+ self.__parentRoi = None
+ return parentRoi
+
+ def _updateAreaProperty(self, event=None, checkVisibility=True):
+ parentRoi = self.sender()
+ if event == items.ItemChangedType.COLOR:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+ elif event == items.ItemChangedType.VISIBLE:
+ if self.getPlotItem() is not None:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+
+ def _updateArea(self):
+ roi = self.getParentRoi()
+ item = roi.getPlotItem()
+ if item is None:
+ self.setVisible(False)
+ return
+ polylines = self._computePolylines(roi, item)
+ if polylines is None:
+ self.setVisible(False)
+ return
+ self.setVisible(True)
+ self.setLineStyle("--")
+ self.setPoints(polylines, copy=False)
+
+ def _computePolylines(self, roi, item):
+ slicing = roi._getSlice(item)
+ if slicing is None:
+ return None
+ xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False)
+ xx, yy = xx[slicing], yy[slicing]
+ polylines = numpy.array((xx, yy)).T
+ if len(polylines) == 0:
+ return None
+ return polylines
+
+
+class _DefaultImageProfileRoiMixIn(core.ProfileRoiMixIn):
+ """Provide common behavior for silx default image profile ROI."""
+
+ ITEM_KIND = items.ImageBase
+
+ def __init__(self, parent=None):
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.__method = "mean"
+ self.__width = 1
+ self.sigRegionChanged.connect(self.__regionChanged)
+ self.sigPlotItemChanged.connect(self.__updateArea)
+ self.__area = _ImageProfileArea(self)
+ self.addItem(self.__area)
+
+ def __regionChanged(self):
+ self.invalidateProfile()
+ self.__updateArea()
+
+ def setProfileMethod(self, method):
+ """
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ """
+ if self.__method == method:
+ return
+ self.__method = method
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def getProfileMethod(self):
+ return self.__method
+
+ def setProfileLineWidth(self, width):
+ if self.__width == width:
+ return
+ self.__width = width
+ self.__updateArea()
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def getProfileLineWidth(self):
+ return self.__width
+
+ def __updateArea(self):
+ plotItem = self.getPlotItem()
+ if plotItem is None:
+ self.setLineStyle("-")
+ else:
+ self.setLineStyle("--")
+
+ def _getRoiInfo(self):
+ """Wrapper to allow to reuse the previous Profile code.
+
+ It would be good to remove it at one point.
+ """
+ if isinstance(self, roi_items.HorizontalLineROI):
+ lineProjectionMode = "X"
+ y = self.getPosition()
+ roiStart = (0, y)
+ roiEnd = (1, y)
+ elif isinstance(self, roi_items.VerticalLineROI):
+ lineProjectionMode = "Y"
+ x = self.getPosition()
+ roiStart = (x, 0)
+ roiEnd = (x, 1)
+ elif isinstance(self, roi_items.LineROI):
+ lineProjectionMode = "D"
+ roiStart, roiEnd = self.getEndPoints()
+ else:
+ assert False
+
+ return roiStart, roiEnd, lineProjectionMode
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.ImageBase):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ origin = item.getOrigin()
+ scale = item.getScale()
+ method = self.getProfileMethod()
+ lineWidth = self.getProfileLineWidth()
+ roiInfo = self._getRoiInfo()
+
+ def createProfile2(currentData):
+ coords, profile, _area, profileName, xLabel = core.createProfile(
+ roiInfo=roiInfo,
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=lineWidth,
+ method=method,
+ )
+ return coords, profile, profileName, xLabel
+
+ currentData = item.getValueData(copy=False)
+
+ yLabel = "%s" % str(method).capitalize()
+ coords, profile, title, xLabel = createProfile2(currentData)
+ title = title + "; width = %d" % lineWidth
+
+ # Use the axis names from the original plot
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ title = _relabelAxes(plot, title)
+ xLabel = _relabelAxes(plot, xLabel)
+
+ if isinstance(item, items.ImageRgba):
+ rgba = item.getData(copy=False)
+ _coords, r, _profileName, _xLabel = createProfile2(rgba[..., 0])
+ _coords, g, _profileName, _xLabel = createProfile2(rgba[..., 1])
+ _coords, b, _profileName, _xLabel = createProfile2(rgba[..., 2])
+ if rgba.shape[-1] == 4:
+ _coords, a, _profileName, _xLabel = createProfile2(rgba[..., 3])
+ else:
+ a = [None]
+ data = core.RgbaProfileData(
+ coords=coords,
+ profile=profile[0],
+ profile_r=r[0],
+ profile_g=g[0],
+ profile_b=b[0],
+ profile_a=a[0],
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ else:
+ data = core.CurveProfileData(
+ coords=coords,
+ profile=profile[0],
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ return data
+
+
+class ProfileImageHorizontalLineROI(
+ roi_items.HorizontalLineROI, _DefaultImageProfileRoiMixIn
+):
+ """ROI for an horizontal profile at a location of an image"""
+
+ ICON = "shape-horizontal"
+ NAME = "horizontal line profile"
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageVerticalLineROI(
+ roi_items.VerticalLineROI, _DefaultImageProfileRoiMixIn
+):
+ """ROI for a vertical profile at a location of an image"""
+
+ ICON = "shape-vertical"
+ NAME = "vertical line profile"
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageLineROI(roi_items.LineROI, _DefaultImageProfileRoiMixIn):
+ """ROI for an image profile between 2 points.
+
+ The X profile of this ROI is the projecting into one of the x/y axes,
+ using its scale and its orientation.
+ """
+
+ ICON = "shape-diagonal"
+ NAME = "line profile"
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageDirectedLineROI(roi_items.LineROI, _DefaultImageProfileRoiMixIn):
+ """ROI for an image profile between 2 points.
+
+ The X profile of the line is displayed projected into the line itself,
+ using its scale and its orientation. It's the distance from the origin.
+ """
+
+ ICON = "shape-diagonal-directed"
+ NAME = "directed line profile"
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+ self._handleStart.setSymbol("o")
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.ImageBase):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ from silx.image.bilinear import BilinearImage
+
+ origin = item.getOrigin()
+ scale = item.getScale()
+ method = self.getProfileMethod()
+ lineWidth = self.getProfileLineWidth()
+ currentData = item.getValueData(copy=False)
+
+ roiInfo = self._getRoiInfo()
+ roiStart, roiEnd, _lineProjectionMode = roiInfo
+
+ startPt = (
+ (roiStart[1] - origin[1]) / scale[1],
+ (roiStart[0] - origin[0]) / scale[0],
+ )
+ endPt = ((roiEnd[1] - origin[1]) / scale[1], (roiEnd[0] - origin[0]) / scale[0])
+
+ if numpy.array_equal(startPt, endPt):
+ return None
+
+ bilinear = BilinearImage(currentData)
+ profile = bilinear.profile_line(
+ (startPt[0] - 0.5, startPt[1] - 0.5),
+ (endPt[0] - 0.5, endPt[1] - 0.5),
+ lineWidth,
+ method=method,
+ )
+
+ # Compute the line size
+ lineSize = numpy.sqrt(
+ (roiEnd[1] - roiStart[1]) ** 2 + (roiEnd[0] - roiStart[0]) ** 2
+ )
+ coords = numpy.linspace(
+ 0, lineSize, len(profile), endpoint=True, dtype=numpy.float32
+ )
+
+ title = _lineProfileTitle(*roiStart, *roiEnd)
+ title = title + "; width = %d" % lineWidth
+ xLabel = "√({xlabel}²+{ylabel}²)"
+ yLabel = str(method).capitalize()
+
+ # Use the axis names from the original plot
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ xLabel = _relabelAxes(plot, xLabel)
+ title = _relabelAxes(plot, title)
+
+ data = core.CurveProfileData(
+ coords=coords,
+ profile=profile,
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ return data
+
+
+class _ProfileCrossROI(roi_items.HandleBasedROI, core.ProfileRoiMixIn):
+
+ """ROI to manage a cross of profiles
+
+ It is managed using 2 sub ROIs for vertical and horizontal.
+ """
+
+ _kind = "Cross"
+ """Label for this kind of ROI"""
+
+ _plotShape = "point"
+ """Plot shape which is used for the first interaction"""
+
+ def __init__(self, parent=None):
+ roi_items.HandleBasedROI.__init__(self, parent=parent)
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.sigRegionChanged.connect(self.__regionChanged)
+ self.sigAboutToBeRemoved.connect(self.__aboutToBeRemoved)
+ self.__position = 0, 0
+ self.__vline = None
+ self.__hline = None
+ self.__handle = self.addHandle()
+ self.__handleLabel = self.addLabelHandle()
+ self.__handleLabel.setText(self.getName())
+ self.__inhibitReentance = utils.LockReentrant()
+ self.computeProfile = None
+ self.sigItemChanged.connect(self.__updateLineProperty)
+
+ # Make sure the marker is over the ROIs
+ self.__handle.setZValue(1)
+ # Create the vline and the hline
+ self._createSubRois()
+
+ @docstring(roi_items.HandleBasedROI)
+ def contains(self, position):
+ roiPos = self.getPosition()
+ return position[0] == roiPos[0] or position[1] == roiPos[1]
+
+ def setFirstShapePoints(self, points):
+ pos = points[0]
+ self.setPosition(pos)
+
+ def getPosition(self):
+ """Returns the position of this ROI
+
+ :rtype: numpy.ndarray
+ """
+ return self.__position
+
+ def setPosition(self, pos):
+ """Set the position of this ROI
+
+ :param numpy.ndarray pos: 2d-coordinate of this point
+ """
+ self.__position = pos
+ with utils.blockSignals(self.__handle):
+ self.__handle.setPosition(*pos)
+ with utils.blockSignals(self.__handleLabel):
+ self.__handleLabel.setPosition(*pos)
+ self.sigRegionChanged.emit()
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle is self.__handle:
+ self.setPosition(current)
+
+ def __updateLineProperty(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.NAME:
+ self.__handleLabel.setText(self.getName())
+ elif event in [items.ItemChangedType.COLOR, items.ItemChangedType.VISIBLE]:
+ lines = []
+ if self.__vline:
+ lines.append(self.__vline)
+ if self.__hline:
+ lines.append(self.__hline)
+ self._updateItemProperty(event, self, lines)
+
+ def _createLines(self, parent):
+ """Inherit this function to return 2 ROI objects for respectivly
+ the horizontal, and the vertical lines."""
+ raise NotImplementedError()
+
+ def _setProfileManager(self, profileManager):
+ core.ProfileRoiMixIn._setProfileManager(self, profileManager)
+ # Connecting the vline and the hline
+ roiManager = profileManager.getRoiManager()
+ roiManager.addRoi(self.__vline)
+ roiManager.addRoi(self.__hline)
+
+ def _createSubRois(self):
+ hline, vline = self._createLines(parent=None)
+ for i, line in enumerate([vline, hline]):
+ line.setPosition(self.__position[i])
+ line.setEditable(True)
+ line.setSelectable(True)
+ line.setFocusProxy(self)
+ line.setName("")
+ self.__vline = vline
+ self.__hline = hline
+ vline.sigAboutToBeRemoved.connect(self.__vlineRemoved)
+ vline.sigRegionChanged.connect(self.__vlineRegionChanged)
+ hline.sigAboutToBeRemoved.connect(self.__hlineRemoved)
+ hline.sigRegionChanged.connect(self.__hlineRegionChanged)
+
+ def _getLines(self):
+ return self.__hline, self.__vline
+
+ def __regionChanged(self):
+ if self.__inhibitReentance.locked():
+ return
+ x, y = self.getPosition()
+ hline, vline = self._getLines()
+ if hline is None:
+ return
+ with self.__inhibitReentance:
+ hline.setPosition(y)
+ vline.setPosition(x)
+
+ def __vlineRegionChanged(self):
+ if self.__inhibitReentance.locked():
+ return
+ pos = self.getPosition()
+ vline = self.__vline
+ pos = vline.getPosition(), pos[1]
+ with self.__inhibitReentance:
+ self.setPosition(pos)
+
+ def __hlineRegionChanged(self):
+ if self.__inhibitReentance.locked():
+ return
+ pos = self.getPosition()
+ hline = self.__hline
+ pos = pos[0], hline.getPosition()
+ with self.__inhibitReentance:
+ self.setPosition(pos)
+
+ def __aboutToBeRemoved(self):
+ vline = self.__vline
+ hline = self.__hline
+ # Avoid side remove signals
+ if hline is not None:
+ hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved)
+ hline.sigRegionChanged.disconnect(self.__hlineRegionChanged)
+ if vline is not None:
+ vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved)
+ vline.sigRegionChanged.disconnect(self.__vlineRegionChanged)
+ # Clean up the child
+ profileManager = self.getProfileManager()
+ roiManager = profileManager.getRoiManager()
+ if hline is not None:
+ roiManager.removeRoi(hline)
+ self.__hline = None
+ if vline is not None:
+ roiManager.removeRoi(vline)
+ self.__vline = None
+
+ def __hlineRemoved(self):
+ self.__lineRemoved(isHline=True)
+
+ def __vlineRemoved(self):
+ self.__lineRemoved(isHline=False)
+
+ def __lineRemoved(self, isHline):
+ """If any of the lines is removed: disconnect this objects, and let the
+ other one persist"""
+ hline, vline = self._getLines()
+
+ hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved)
+ vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved)
+ hline.sigRegionChanged.disconnect(self.__hlineRegionChanged)
+ vline.sigRegionChanged.disconnect(self.__vlineRegionChanged)
+
+ self.__hline = None
+ self.__vline = None
+ profileManager = self.getProfileManager()
+ roiManager = profileManager.getRoiManager()
+ if isHline:
+ self.__releaseLine(vline)
+ else:
+ self.__releaseLine(hline)
+ roiManager.removeRoi(self)
+
+ def __releaseLine(self, line):
+ """Release the line in order to make it independent"""
+ line.setFocusProxy(None)
+ line.setName(self.getName())
+ line.setEditable(self.isEditable())
+ line.setSelectable(self.isSelectable())
+
+
+class ProfileImageCrossROI(_ProfileCrossROI):
+ """ROI to manage a cross of profiles
+
+ It is managed using 2 sub ROIs for vertical and horizontal.
+ """
+
+ ICON = "shape-cross"
+ NAME = "cross profile"
+ ITEM_KIND = items.ImageBase
+
+ def _createLines(self, parent):
+ vline = ProfileImageVerticalLineROI(parent=parent)
+ hline = ProfileImageHorizontalLineROI(parent=parent)
+ return hline, vline
+
+ def setProfileMethod(self, method):
+ """
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ """
+ hline, vline = self._getLines()
+ hline.setProfileMethod(method)
+ vline.setProfileMethod(method)
+ self.invalidateProperties()
+
+ def getProfileMethod(self):
+ hline, _vline = self._getLines()
+ return hline.getProfileMethod()
+
+ def setProfileLineWidth(self, width):
+ hline, vline = self._getLines()
+ hline.setProfileLineWidth(width)
+ vline.setProfileLineWidth(width)
+ self.invalidateProperties()
+
+ def getProfileLineWidth(self):
+ hline, _vline = self._getLines()
+ return hline.getProfileLineWidth()
+
+
+class _DefaultScatterProfileRoiMixIn(core.ProfileRoiMixIn):
+ """Provide common behavior for silx default scatter profile ROI."""
+
+ ITEM_KIND = items.Scatter
+
+ def __init__(self, parent=None):
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.__nPoints = 1024
+ self.sigRegionChanged.connect(self.__regionChanged)
+
+ def __regionChanged(self):
+ self.invalidateProfile()
+
+ # Number of points
+
+ def getNPoints(self):
+ """Returns the number of points of the profiles
+
+ :rtype: int
+ """
+ return self.__nPoints
+
+ def setNPoints(self, npoints):
+ """Set the number of points of the profiles
+
+ :param int npoints:
+ """
+ npoints = int(npoints)
+ if npoints < 1:
+ raise ValueError("Unsupported number of points: %d" % npoints)
+ elif npoints != self.__nPoints:
+ self.__nPoints = npoints
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def _computeProfile(self, scatter, x0, y0, x1, y1):
+ """Compute corresponding profile
+
+ :param float x0: Profile start point X coord
+ :param float y0: Profile start point Y coord
+ :param float x1: Profile end point X coord
+ :param float y1: Profile end point Y coord
+ :return: (points, values) profile data or None
+ """
+ future = scatter._getInterpolatorFuture()
+ try:
+ interpolator = future.result()
+ except CancelledError:
+ return None
+ if interpolator is None:
+ return None # Cannot init an interpolator
+
+ nPoints = self.getNPoints()
+ x = numpy.linspace(x0, x1, nPoints, endpoint=True)
+ y = numpy.linspace(y0, y1, nPoints, endpoint=True)
+
+ values = interpolator(x, y)
+ if not numpy.any(numpy.isfinite(values)):
+ return None # Profile outside convex hull
+
+ points = numpy.transpose((x, y))
+ return points, values
+
+ def computeProfile(self, item):
+ """Update profile according to current ROI"""
+ if not isinstance(item, items.Scatter):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ # Get end points
+ if isinstance(self, roi_items.LineROI):
+ points = self.getEndPoints()
+ x0, y0 = points[0]
+ x1, y1 = points[1]
+ elif isinstance(self, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)):
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+
+ if isinstance(self, roi_items.HorizontalLineROI):
+ x0, x1 = plot.getXAxis().getLimits()
+ y0 = y1 = self.getPosition()
+
+ elif isinstance(self, roi_items.VerticalLineROI):
+ x0 = x1 = self.getPosition()
+ y0, y1 = plot.getYAxis().getLimits()
+ else:
+ raise RuntimeError("Unsupported ROI for profile: {}".format(self.__class__))
+
+ if x1 < x0 or (x1 == x0 and y1 < y0):
+ # Invert points
+ x0, y0, x1, y1 = x1, y1, x0, y0
+
+ profile = self._computeProfile(item, x0, y0, x1, y1)
+ if profile is None:
+ return None
+
+ title = _lineProfileTitle(x0, y0, x1, y1)
+ points = profile[0]
+ values = profile[1]
+
+ if numpy.abs(points[-1, 0] - points[0, 0]) > numpy.abs(
+ points[-1, 1] - points[0, 1]
+ ):
+ xProfile = points[:, 0]
+ xLabel = "{xlabel}"
+ else:
+ xProfile = points[:, 1]
+ xLabel = "{ylabel}"
+
+ # Use the axis names from the original
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ title = _relabelAxes(plot, title)
+ xLabel = _relabelAxes(plot, xLabel)
+
+ data = core.CurveProfileData(
+ coords=xProfile,
+ profile=values,
+ title=title,
+ xLabel=xLabel,
+ yLabel="Profile",
+ )
+ return data
+
+
+class ProfileScatterHorizontalLineROI(
+ roi_items.HorizontalLineROI, _DefaultScatterProfileRoiMixIn
+):
+ """ROI for an horizontal profile at a location of a scatter"""
+
+ ICON = "shape-horizontal"
+ NAME = "horizontal line profile"
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterVerticalLineROI(
+ roi_items.VerticalLineROI, _DefaultScatterProfileRoiMixIn
+):
+ """ROI for an horizontal profile at a location of a scatter"""
+
+ ICON = "shape-vertical"
+ NAME = "vertical line profile"
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterLineROI(roi_items.LineROI, _DefaultScatterProfileRoiMixIn):
+ """ROI for an horizontal profile at a location of a scatter"""
+
+ ICON = "shape-diagonal"
+ NAME = "line profile"
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterCrossROI(_ProfileCrossROI):
+ """ROI to manage a cross of profiles for scatters."""
+
+ ICON = "shape-cross"
+ NAME = "cross profile"
+ ITEM_KIND = items.Scatter
+
+ def _createLines(self, parent):
+ vline = ProfileScatterVerticalLineROI(parent=parent)
+ hline = ProfileScatterHorizontalLineROI(parent=parent)
+ return hline, vline
+
+ def getNPoints(self):
+ """Returns the number of points of the profiles
+
+ :rtype: int
+ """
+ hline, _vline = self._getLines()
+ return hline.getNPoints()
+
+ def setNPoints(self, npoints):
+ """Set the number of points of the profiles
+
+ :param int npoints:
+ """
+ hline, vline = self._getLines()
+ hline.setNPoints(npoints)
+ vline.setNPoints(npoints)
+ self.invalidateProperties()
+
+
+class _DefaultScatterProfileSliceRoiMixIn(core.ProfileRoiMixIn):
+ """Default ROI to allow to slice in the scatter data."""
+
+ ITEM_KIND = items.Scatter
+
+ def __init__(self, parent=None):
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.__area = _SliceProfileArea(self)
+ self.addItem(self.__area)
+ self.sigRegionChanged.connect(self._regionChanged)
+ self.sigPlotItemChanged.connect(self._updateArea)
+
+ def _regionChanged(self):
+ self.invalidateProfile()
+ self._updateArea()
+
+ def _updateArea(self):
+ plotItem = self.getPlotItem()
+ if plotItem is None:
+ self.setLineStyle("-")
+ else:
+ self.setLineStyle("--")
+
+ def _getSlice(self, item):
+ position = self.getPosition()
+ bounds = item.getCurrentVisualizationParameter(
+ items.Scatter.VisualizationParameter.GRID_BOUNDS
+ )
+ if isinstance(self, roi_items.HorizontalLineROI):
+ axis = 1
+ elif isinstance(self, roi_items.VerticalLineROI):
+ axis = 0
+ else:
+ assert False
+ if bounds is None or position < bounds[0][axis] or position > bounds[1][axis]:
+ # ROI outside of the scatter bound
+ return None
+
+ major_order = item.getCurrentVisualizationParameter(
+ items.Scatter.VisualizationParameter.GRID_MAJOR_ORDER
+ )
+ assert major_order == "row"
+ max_grid_yy, max_grid_xx = item.getCurrentVisualizationParameter(
+ items.Scatter.VisualizationParameter.GRID_SHAPE
+ )
+
+ xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False)
+ if isinstance(self, roi_items.HorizontalLineROI):
+ axis = yy
+ max_grid_first = max_grid_yy
+ max_grid_second = max_grid_xx
+ major_axis = major_order == "column"
+ elif isinstance(self, roi_items.VerticalLineROI):
+ axis = xx
+ max_grid_first = max_grid_xx
+ max_grid_second = max_grid_yy
+ major_axis = major_order == "row"
+ else:
+ assert False
+
+ def argnearest(array, value):
+ array = numpy.abs(array - value)
+ return numpy.argmin(array)
+
+ if major_axis:
+ # slice in the middle of the scatter
+ actual_size_grid_second = len(axis) // max_grid_first
+ start = actual_size_grid_second // 2 * max_grid_first
+ vslice = axis[start : start + max_grid_first]
+ if len(vslice) == 0:
+ return None
+ index = argnearest(vslice, position)
+ slicing = slice(index, None, max_grid_first)
+ else:
+ # slice in the middle of the scatter
+ actual_size_grid_second = len(axis) // max_grid_first
+ vslice = axis[actual_size_grid_second // 2 :: max_grid_second]
+ if len(vslice) == 0:
+ return None
+ index = argnearest(vslice, position)
+ start = index * max_grid_second
+ slicing = slice(start, start + max_grid_second)
+
+ return slicing
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.Scatter):
+ raise TypeError("Unsupported %s item" % type(item))
+
+ slicing = self._getSlice(item)
+ if slicing is None:
+ # ROI out of bounds
+ return None
+
+ _xx, _yy, values, _xx_error, _yy_error = item.getData(copy=False)
+ profile = values[slicing]
+
+ if isinstance(self, roi_items.HorizontalLineROI):
+ title = "Horizontal slice"
+ xLabel = "{xlabel} index"
+ elif isinstance(self, roi_items.VerticalLineROI):
+ title = "Vertical slice"
+ xLabel = "{ylabel} index"
+ else:
+ assert False
+
+ # Use the axis names from the original plot
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ xLabel = _relabelAxes(plot, xLabel)
+
+ data = core.CurveProfileData(
+ coords=numpy.arange(len(profile)),
+ profile=profile,
+ title=title,
+ xLabel=xLabel,
+ yLabel="Profile",
+ )
+ return data
+
+
+class ProfileScatterHorizontalSliceROI(
+ roi_items.HorizontalLineROI, _DefaultScatterProfileSliceRoiMixIn
+):
+ """ROI for an horizontal profile at a location of a scatter
+ using data slicing.
+ """
+
+ ICON = "slice-horizontal"
+ NAME = "horizontal data slice profile"
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterVerticalSliceROI(
+ roi_items.VerticalLineROI, _DefaultScatterProfileSliceRoiMixIn
+):
+ """ROI for a vertical profile at a location of a scatter
+ using data slicing.
+ """
+
+ ICON = "slice-vertical"
+ NAME = "vertical data slice profile"
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterCrossSliceROI(_ProfileCrossROI):
+ """ROI to manage a cross of slicing profiles on scatters."""
+
+ ICON = "slice-cross"
+ NAME = "cross data slice profile"
+ ITEM_KIND = items.Scatter
+
+ def _createLines(self, parent):
+ vline = ProfileScatterVerticalSliceROI(parent=parent)
+ hline = ProfileScatterHorizontalSliceROI(parent=parent)
+ return hline, vline
+
+
+class _DefaultImageStackProfileRoiMixIn(_DefaultImageProfileRoiMixIn):
+ ITEM_KIND = items.ImageStack
+
+ def __init__(self, parent=None):
+ super(_DefaultImageStackProfileRoiMixIn, self).__init__(parent=parent)
+ self.__profileType = "1D"
+ """Kind of profile"""
+
+ def getProfileType(self):
+ return self.__profileType
+
+ def setProfileType(self, kind):
+ assert kind in ["1D", "2D"]
+ if self.__profileType == kind:
+ return
+ self.__profileType = kind
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.ImageStack):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ kind = self.getProfileType()
+ if kind == "1D":
+ result = _DefaultImageProfileRoiMixIn.computeProfile(self, item)
+ # z = item.getStackPosition()
+ return result
+
+ assert kind == "2D"
+
+ currentData = numpy.array(item.getStackData(copy=False))
+ origin = item.getOrigin()
+ scale = item.getScale()
+ colormap = item.getColormap()
+ method = self.getProfileMethod()
+ roiInfo = self._getRoiInfo()
+
+ def createProfile2(currentData):
+ coords, profile, _area, profileName, xLabel = core.createProfile(
+ roiInfo=roiInfo,
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=self.getProfileLineWidth(),
+ method=method,
+ )
+ return coords, profile, profileName, xLabel
+
+ coords, profile, profileName, xLabel = createProfile2(currentData)
+
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+
+ data = core.ImageProfileData(
+ coords=coords,
+ profile=profile,
+ title=_relabelAxes(plot, profileName),
+ xLabel=_relabelAxes(plot, xLabel),
+ yLabel="Profile",
+ colormap=colormap,
+ )
+ return data
+
+
+class ProfileImageStackHorizontalLineROI(
+ roi_items.HorizontalLineROI, _DefaultImageStackProfileRoiMixIn
+):
+ """ROI for an horizontal profile at a location of a stack of images"""
+
+ ICON = "shape-horizontal"
+ NAME = "horizontal line profile"
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageStackVerticalLineROI(
+ roi_items.VerticalLineROI, _DefaultImageStackProfileRoiMixIn
+):
+ """ROI for an vertical profile at a location of a stack of images"""
+
+ ICON = "shape-vertical"
+ NAME = "vertical line profile"
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageStackLineROI(roi_items.LineROI, _DefaultImageStackProfileRoiMixIn):
+ """ROI for an vertical profile at a location of a stack of images"""
+
+ ICON = "shape-diagonal"
+ NAME = "line profile"
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageStackCrossROI(ProfileImageCrossROI):
+ """ROI for an vertical profile at a location of a stack of images"""
+
+ ICON = "shape-cross"
+ NAME = "cross profile"
+ ITEM_KIND = items.ImageStack
+
+ def _createLines(self, parent):
+ vline = ProfileImageStackVerticalLineROI(parent=parent)
+ hline = ProfileImageStackHorizontalLineROI(parent=parent)
+ return hline, vline
+
+ def getProfileType(self):
+ hline, _vline = self._getLines()
+ return hline.getProfileType()
+
+ def setProfileType(self, kind):
+ hline, vline = self._getLines()
+ hline.setProfileType(kind)
+ vline.setProfileType(kind)
+ self.invalidateProperties()
diff --git a/src/silx/gui/plot/tools/profile/toolbar.py b/src/silx/gui/plot/tools/profile/toolbar.py
new file mode 100644
index 0000000..d073717
--- /dev/null
+++ b/src/silx/gui/plot/tools/profile/toolbar.py
@@ -0,0 +1,172 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides tool bar helper.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import logging
+import weakref
+
+from silx.gui import qt
+from silx.gui.widgets.MultiModeAction import MultiModeAction
+from . import manager
+from .. import roi as roi_mdl
+from silx.gui.plot import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ProfileToolBar(qt.QToolBar):
+ """Tool bar to provide profile for a plot.
+
+ It is an helper class. For a dedicated application it would be better to
+ use an own tool bar in order in order have more flexibility.
+ """
+
+ def __init__(self, parent=None, plot=None):
+ super(ProfileToolBar, self).__init__(parent=parent)
+ self.__scheme = None
+ self.__manager = None
+ self.__plot = weakref.ref(plot)
+ self.__multiAction = None
+
+ def getPlotWidget(self):
+ """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar.
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ if self.__plot is None:
+ return None
+ plot = self.__plot()
+ if self.__plot is None:
+ self.__plot = None
+ return plot
+
+ def setScheme(self, scheme):
+ """Initialize the tool bar using a configuration scheme.
+
+ It have to be done once and only once.
+
+ :param str scheme: One of "scatter", "image", "imagestack"
+ """
+ assert self.__scheme is None
+ self.__scheme = scheme
+
+ plot = self.getPlotWidget()
+ self.__manager = manager.ProfileManager(self, plot)
+
+ if scheme == "image":
+ self.__manager.setItemType(image=True)
+ self.__manager.setActiveItemTracking(True)
+
+ multiAction = MultiModeAction(self)
+ self.addAction(multiAction)
+ for action in self.__manager.createImageActions(self):
+ multiAction.addAction(action)
+ self.__multiAction = multiAction
+
+ cleanAction = self.__manager.createClearAction(self)
+ self.addAction(cleanAction)
+ editorAction = self.__manager.createEditorAction(self)
+ self.addAction(editorAction)
+
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ self._activeImageChanged()
+
+ elif scheme == "scatter":
+ self.__manager.setItemType(scatter=True)
+ self.__manager.setActiveItemTracking(True)
+
+ multiAction = MultiModeAction(self)
+ self.addAction(multiAction)
+ for action in self.__manager.createScatterActions(self):
+ multiAction.addAction(action)
+ for action in self.__manager.createScatterSliceActions(self):
+ multiAction.addAction(action)
+ self.__multiAction = multiAction
+
+ cleanAction = self.__manager.createClearAction(self)
+ self.addAction(cleanAction)
+ editorAction = self.__manager.createEditorAction(self)
+ self.addAction(editorAction)
+
+ plot.sigActiveScatterChanged.connect(self._activeScatterChanged)
+ self._activeScatterChanged()
+
+ elif scheme == "imagestack":
+ self.__manager.setItemType(image=True)
+ self.__manager.setActiveItemTracking(True)
+
+ multiAction = MultiModeAction(self)
+ self.addAction(multiAction)
+ for action in self.__manager.createImageStackActions(self):
+ multiAction.addAction(action)
+ self.__multiAction = multiAction
+
+ cleanAction = self.__manager.createClearAction(self)
+ self.addAction(cleanAction)
+ editorAction = self.__manager.createEditorAction(self)
+ self.addAction(editorAction)
+
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ self._activeImageChanged()
+
+ else:
+ raise ValueError("Toolbar scheme %s unsupported" % scheme)
+
+ def _setRoiActionEnabled(self, itemKind, enabled):
+ for action in self.__multiAction.getMenu().actions():
+ if not isinstance(action, roi_mdl.CreateRoiModeAction):
+ continue
+ roiClass = action.getRoiClass()
+ if issubclass(itemKind, roiClass.ITEM_KIND):
+ action.setEnabled(enabled)
+
+ def _activeImageChanged(self, previous=None, legend=None):
+ """Handle active image change to toggle actions"""
+ if legend is None:
+ self._setRoiActionEnabled(items.ImageStack, False)
+ self._setRoiActionEnabled(items.ImageBase, False)
+ else:
+ plot = self.getPlotWidget()
+ image = plot.getActiveImage()
+ # Disable for empty image
+ enabled = image.getData(copy=False).size > 0
+ self._setRoiActionEnabled(type(image), enabled)
+
+ def _activeScatterChanged(self, previous=None, legend=None):
+ """Handle active scatter change to toggle actions"""
+ if legend is None:
+ self._setRoiActionEnabled(items.Scatter, False)
+ else:
+ plot = self.getPlotWidget()
+ scatter = plot.getActiveScatter()
+ # Disable for empty image
+ enabled = scatter.getValueData(copy=False).size > 0
+ self._setRoiActionEnabled(type(scatter), enabled)
diff --git a/src/silx/gui/plot/tools/roi.py b/src/silx/gui/plot/tools/roi.py
new file mode 100644
index 0000000..21b9409
--- /dev/null
+++ b/src/silx/gui/plot/tools/roi.py
@@ -0,0 +1,1515 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2023 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides ROI interaction for :class:`~silx.gui.plot.PlotWidget`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import enum
+import logging
+import time
+import weakref
+import functools
+from typing import Optional
+
+import numpy
+
+from ... import qt, icons
+from ...utils import blockSignals
+from ...utils import LockReentrant
+from .. import PlotWidget
+from ..items import roi as roi_items
+from ..items import ItemChangedType
+from ..items.roi import RegionOfInterest
+
+from ...colors import rgba
+
+
+logger = logging.getLogger(__name__)
+
+
+class CreateRoiModeAction(qt.QAction):
+ """
+ This action is a plot mode which allows to create new ROIs using a ROI
+ manager.
+
+ A ROI is created using a specific `roiClass`. `initRoi` and `finalizeRoi`
+ can be inherited to custom the ROI initialization.
+
+ :param class roiClass: The ROI class which will be created by this action.
+ :param qt.QObject parent: The action parent
+ :param RegionOfInterestManager roiManager: The ROI manager
+ """
+
+ def __init__(self, parent, roiManager, roiClass):
+ assert roiManager is not None
+ assert roiClass is not None
+ qt.QAction.__init__(self, parent=parent)
+ self._roiManager = weakref.ref(roiManager)
+ self._roiClass = roiClass
+ self._singleShot = False
+ self._initAction()
+ self.triggered[bool].connect(self._actionTriggered)
+
+ def _initAction(self):
+ """Default initialization of the action"""
+ roiClass = self._roiClass
+
+ name = None
+ iconName = None
+ if hasattr(roiClass, "NAME"):
+ name = roiClass.NAME
+ if hasattr(roiClass, "ICON"):
+ iconName = roiClass.ICON
+
+ if iconName is None:
+ iconName = "add-shape-unknown"
+ if name is None:
+ name = roiClass.__name__
+ text = "Add %s" % name
+ self.setIcon(icons.getQIcon(iconName))
+ self.setText(text)
+ self.setCheckable(True)
+ self.setToolTip(text)
+
+ def getRoiClass(self):
+ """Return the ROI class used by this action to create ROIs"""
+ return self._roiClass
+
+ def getRoiManager(self):
+ return self._roiManager()
+
+ def setSingleShot(self, singleShot):
+ """Set it to True to deactivate the action after the first creation
+ of a ROI.
+
+ :param bool singleShot: New single short state
+ """
+ self._singleShot = singleShot
+
+ def getSingleShot(self):
+ """If True, after the first creation of a ROI with this mode,
+ the mode is deactivated.
+
+ :rtype: bool
+ """
+ return self._singleShot
+
+ def _actionTriggered(self, checked):
+ """Handle mode actions being checked by the user
+
+ :param bool checked:
+ :param str kind: Corresponding shape kind
+ """
+ roiManager = self.getRoiManager()
+ if roiManager is None:
+ return
+
+ if checked:
+ roiManager.start(self._roiClass, self)
+ self.__interactiveModeStarted(roiManager)
+ else:
+ source = roiManager.getInteractionSource()
+ if source is self:
+ roiManager.stop()
+
+ def __interactiveModeStarted(self, roiManager):
+ roiManager.sigInteractiveRoiCreated.connect(self.initRoi)
+ roiManager.sigInteractiveRoiFinalized.connect(self.__finalizeRoi)
+ roiManager.sigInteractiveModeFinished.connect(self.__interactiveModeFinished)
+
+ def __interactiveModeFinished(self):
+ roiManager = self.getRoiManager()
+ if roiManager is not None:
+ roiManager.sigInteractiveRoiCreated.disconnect(self.initRoi)
+ roiManager.sigInteractiveRoiFinalized.disconnect(self.__finalizeRoi)
+ roiManager.sigInteractiveModeFinished.disconnect(
+ self.__interactiveModeFinished
+ )
+ self.setChecked(False)
+
+ def initRoi(self, roi):
+ """Inherit it to custom the new ROI at it's creation during the
+ interaction."""
+ pass
+
+ def __finalizeRoi(self, roi):
+ self.finalizeRoi(roi)
+ if self._singleShot:
+ roiManager = self.getRoiManager()
+ if roiManager is not None:
+ roiManager.stop()
+
+ def finalizeRoi(self, roi):
+ """Inherit it to custom the new ROI after it's creation when the
+ interaction is finalized."""
+ pass
+
+
+class RoiModeSelector(qt.QWidget):
+ def __init__(self, parent=None):
+ super(RoiModeSelector, self).__init__(parent=parent)
+ self.__roi = None
+ self.__reentrant = LockReentrant()
+
+ layout = qt.QHBoxLayout(self)
+ if isinstance(parent, qt.QMenu):
+ margins = layout.contentsMargins()
+ layout.setContentsMargins(margins.left(), 0, margins.right(), 0)
+ else:
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._label = qt.QLabel(self)
+ self._label.setText("Mode:")
+ self._label.setToolTip("Select a specific interaction to edit the ROI")
+ self._combo = qt.QComboBox(self)
+ self._combo.currentIndexChanged.connect(self._modeSelected)
+ layout.addWidget(self._label)
+ layout.addWidget(self._combo)
+ self._updateAvailableModes()
+
+ def getRoi(self):
+ """Returns the edited ROI.
+
+ :rtype: roi_items.RegionOfInterest
+ """
+ return self.__roi
+
+ def setRoi(self, roi):
+ """Returns the edited ROI.
+
+ :rtype: roi_items.RegionOfInterest
+ """
+ if self.__roi is roi:
+ return
+ if not isinstance(roi, roi_items.InteractionModeMixIn):
+ self.__roi = None
+ self._updateAvailableModes()
+ return
+
+ if self.__roi is not None:
+ self.__roi.sigInteractionModeChanged.disconnect(self._modeChanged)
+ self.__roi = roi
+ if self.__roi is not None:
+ self.__roi.sigInteractionModeChanged.connect(self._modeChanged)
+ self._updateAvailableModes()
+
+ def isEmpty(self):
+ return not self._label.isVisibleTo(self)
+
+ def _updateAvailableModes(self):
+ roi = self.getRoi()
+ if isinstance(roi, roi_items.InteractionModeMixIn):
+ modes = roi.availableInteractionModes()
+ else:
+ modes = []
+ if len(modes) <= 1:
+ self._label.setVisible(False)
+ self._combo.setVisible(False)
+ else:
+ self._label.setVisible(True)
+ self._combo.setVisible(True)
+ with blockSignals(self._combo):
+ self._combo.clear()
+ for im, m in enumerate(modes):
+ self._combo.addItem(m.label, m)
+ self._combo.setItemData(im, m.description, qt.Qt.ToolTipRole)
+ mode = roi.getInteractionMode()
+ self._modeChanged(mode)
+ index = modes.index(mode)
+ self._combo.setCurrentIndex(index)
+
+ def _modeChanged(self, mode):
+ """Triggered when the ROI interaction mode was changed externally"""
+ if self.__reentrant.locked():
+ # This event was initialised by the widget
+ return
+ roi = self.__roi
+ modes = roi.availableInteractionModes()
+ index = modes.index(mode)
+ with blockSignals(self._combo):
+ self._combo.setCurrentIndex(index)
+
+ def _modeSelected(self):
+ """Triggered when the ROI interaction mode was selected in the widget"""
+ index = self._combo.currentIndex()
+ if index == -1:
+ return
+ roi = self.getRoi()
+ if roi is not None:
+ mode = self._combo.itemData(index, qt.Qt.UserRole)
+ with self.__reentrant:
+ roi.setInteractionMode(mode)
+
+
+class RoiModeSelectorAction(qt.QWidgetAction):
+ """Display the selected mode of a ROI and allow to change it"""
+
+ def __init__(self, parent=None):
+ super(RoiModeSelectorAction, self).__init__(parent)
+ self.__roiManager = None
+
+ def createWidget(self, parent):
+ """Inherit the method to create a new widget"""
+ widget = RoiModeSelector(parent)
+ manager = self.__roiManager
+ if manager is not None:
+ roi = manager.getCurrentRoi()
+ widget.setRoi(roi)
+ self.setVisible(not widget.isEmpty())
+ return widget
+
+ def deleteWidget(self, widget):
+ """Inherit the method to delete a widget"""
+ widget.setRoi(None)
+ return qt.QWidgetAction.deleteWidget(self, widget)
+
+ def setRoiManager(self, roiManager):
+ """
+ Connect this action to a ROI manager.
+
+ :param RegionOfInterestManager roiManager: A ROI manager
+ """
+ if self.__roiManager is roiManager:
+ return
+ if self.__roiManager is not None:
+ self.__roiManager.sigCurrentRoiChanged.disconnect(self.__currentRoiChanged)
+ self.__roiManager = roiManager
+ if self.__roiManager is not None:
+ self.__roiManager.sigCurrentRoiChanged.connect(self.__currentRoiChanged)
+ self.__currentRoiChanged(roiManager.getCurrentRoi())
+
+ def __currentRoiChanged(self, roi):
+ """Handle changes of the selected ROI"""
+ self.setRoi(roi)
+
+ def setRoi(self, roi):
+ """Set a profile ROI to edit.
+
+ :param ProfileRoiMixIn roi: A profile ROI
+ """
+ widget = None
+ for widget in self.createdWidgets():
+ widget.setRoi(roi)
+ if widget is not None:
+ self.setVisible(not widget.isEmpty())
+
+
+class RegionOfInterestManager(qt.QObject):
+ """Class handling ROI interaction on a PlotWidget.
+
+ It supports the multiple ROIs: points, rectangles, polygons,
+ lines, horizontal and vertical lines.
+
+ See ``plotInteractiveImageROI.py`` sample code (:ref:`sample-code`).
+
+ :param silx.gui.plot.PlotWidget parent:
+ The plot widget in which to control the ROIs.
+ """
+
+ sigRoiAdded = qt.Signal(roi_items.RegionOfInterest)
+ """Signal emitted when a new ROI has been added.
+
+ It provides the newly add :class:`RegionOfInterest` object.
+ """
+
+ sigRoiAboutToBeRemoved = qt.Signal(roi_items.RegionOfInterest)
+ """Signal emitted just before a ROI is removed.
+
+ It provides the :class:`RegionOfInterest` object that is about to be removed.
+ """
+
+ sigRoiChanged = qt.Signal()
+ """Signal emitted whenever the ROIs have changed."""
+
+ sigCurrentRoiChanged = qt.Signal(object)
+ """Signal emitted whenever a ROI is selected."""
+
+ sigInteractiveModeStarted = qt.Signal(object)
+ """Signal emitted when switching to ROI drawing interactive mode.
+
+ It provides the class of the ROI which will be created by the interactive
+ mode.
+ """
+
+ sigInteractiveRoiCreated = qt.Signal(object)
+ """Signal emitted when a ROI is created during the interaction.
+ The interaction is still incomplete and can be aborted.
+
+ It provides the ROI object which was just been created.
+ """
+
+ sigInteractiveRoiFinalized = qt.Signal(object)
+ """Signal emitted when a ROI creation is complet.
+
+ It provides the ROI object which was just been created.
+ """
+
+ sigInteractiveModeFinished = qt.Signal()
+ """Signal emitted when leaving interactive ROI drawing mode.
+ """
+
+ ROI_CLASSES = (
+ roi_items.PointROI,
+ roi_items.CrossROI,
+ roi_items.RectangleROI,
+ roi_items.CircleROI,
+ roi_items.EllipseROI,
+ roi_items.PolygonROI,
+ roi_items.LineROI,
+ roi_items.HorizontalLineROI,
+ roi_items.VerticalLineROI,
+ roi_items.ArcROI,
+ roi_items.HorizontalRangeROI,
+ roi_items.BandROI,
+ )
+
+ def __init__(self, parent):
+ assert isinstance(parent, PlotWidget)
+ super(RegionOfInterestManager, self).__init__(parent)
+ self._rois = [] # List of ROIs
+ self._drawnROI = None # New ROI being currently drawn
+
+ self._roiClass = None
+ self._source = None
+ self._lastHoveredMarkerLabel = None
+ self._color = rgba("red")
+
+ self._label = "__RegionOfInterestManager__%d" % id(self)
+
+ self._currentRoi = None
+ """Hold currently selected ROI"""
+
+ self._eventLoop = None
+
+ self._modeActions = {}
+
+ parent.sigPlotSignal.connect(self._plotSignals)
+
+ parent.sigInteractiveModeChanged.connect(self._plotInteractiveModeChanged)
+
+ parent.sigItemRemoved.connect(self._itemRemoved)
+
+ parent._sigDefaultContextMenu.connect(self._feedContextMenu)
+
+ @classmethod
+ def getSupportedRoiClasses(cls):
+ """Returns the default available ROI classes
+
+ :rtype: List[class]
+ """
+ return tuple(cls.ROI_CLASSES)
+
+ # Associated QActions
+
+ def getInteractionModeAction(self, roiClass):
+ """Returns the QAction corresponding to a kind of ROI
+
+ The QAction allows to enable the corresponding drawing
+ interactive mode.
+
+ :param class roiClass: The ROI class which will be created by this action.
+ :rtype: QAction
+ :raise ValueError: If kind is not supported
+ """
+ if not issubclass(roiClass, roi_items.RegionOfInterest):
+ raise ValueError("Unsupported ROI class %s" % roiClass)
+
+ action = self._modeActions.get(roiClass, None)
+ if action is None: # Lazy-loading
+ action = CreateRoiModeAction(self, self, roiClass)
+ self._modeActions[roiClass] = action
+ return action
+
+ # PlotWidget eventFilter and listeners
+
+ def _plotInteractiveModeChanged(self, source):
+ """Handle change of interactive mode in the plot"""
+ if source is not self:
+ self.__roiInteractiveModeEnded()
+
+ def _getRoiFromItem(self, item):
+ """Returns the ROI which own this item, else None
+ if this manager do not have knowledge of this ROI."""
+ for roi in self._rois:
+ if isinstance(roi, roi_items.RegionOfInterest):
+ for child in roi.getItems():
+ if child is item:
+ return roi
+ return None
+
+ def _itemRemoved(self, item):
+ """Called after an item was removed from the plot."""
+ if not hasattr(item, "_roiGroup"):
+ # Early break to avoid to use _getRoiFromItem
+ # And to avoid reentrant signal when the ROI remove the item itself
+ return
+ roi = self._getRoiFromItem(item)
+ if roi is not None:
+ self.removeRoi(roi)
+
+ # Handle ROI interaction
+
+ def _handleInteraction(self, event):
+ """Handle mouse interaction for ROI addition"""
+ roiClass = self.getCurrentInteractionModeRoiClass()
+ if roiClass is None:
+ return # Should not happen
+
+ kind = roiClass.getFirstInteractionShape()
+ if kind == "point":
+ if event["event"] == "mouseClicked" and event["button"] == "left":
+ points = numpy.array([(event["x"], event["y"])], dtype=numpy.float64)
+ # Not an interactive creation
+ roi = self._createInteractiveRoi(roiClass, points=points)
+ roi.creationFinalized()
+ self.sigInteractiveRoiFinalized.emit(roi)
+ else: # other shapes
+ if (
+ event["event"] in ("drawingProgress", "drawingFinished")
+ and event["parameters"]["label"] == self._label
+ ):
+ points = numpy.array(
+ (event["xdata"], event["ydata"]), dtype=numpy.float64
+ ).T
+
+ if self._drawnROI is None: # Create new ROI
+ # NOTE: Set something before createRoi, so isDrawing is True
+ self._drawnROI = object()
+ self._drawnROI = self._createInteractiveRoi(roiClass, points=points)
+ else:
+ self._drawnROI.setFirstShapePoints(points)
+
+ if event["event"] == "drawingFinished":
+ if kind == "polygon" and len(points) > 1:
+ self._drawnROI.setFirstShapePoints(points[:-1])
+ roi = self._drawnROI
+ self._drawnROI = None # Stop drawing
+ roi.creationFinalized()
+ self.sigInteractiveRoiFinalized.emit(roi)
+
+ # RegionOfInterest selection
+
+ def __getRoiFromMarker(self, marker):
+ """Returns a ROI from a marker, else None"""
+ # This should be speed up
+ for roi in self._rois:
+ if isinstance(roi, roi_items.HandleBasedROI):
+ for m in roi.getHandles():
+ if m is marker:
+ return roi
+ else:
+ for m in roi.getItems():
+ if m is marker:
+ return roi
+ return None
+
+ def setCurrentRoi(self, roi: Optional[RegionOfInterest]):
+ """Set the currently selected ROI, and emit a signal.
+
+ :param Union[RegionOfInterest,None] roi: The ROI to select
+ """
+ if self._currentRoi is roi:
+ return
+ if roi is not None:
+ # Note: Fixed range to avoid infinite loops
+ for _ in range(10):
+ target = roi.getFocusProxy()
+ if target is None:
+ break
+ roi = target
+ else:
+ raise RuntimeError("Max selection proxy depth (10) reached.")
+
+ if self._currentRoi is not None:
+ self._currentRoi.setHighlighted(False)
+ self._currentRoi = roi
+ if self._currentRoi is not None:
+ self._currentRoi.setHighlighted(True)
+ self.sigCurrentRoiChanged.emit(roi)
+
+ def getCurrentRoi(self) -> Optional[RegionOfInterest]:
+ """Returns the currently selected ROI, else None."""
+ return self._currentRoi
+
+ def _plotSignals(self, event):
+ """Handle mouse interaction for ROI addition"""
+ clicked = False
+ roi = None
+ if event["event"] in ("markerClicked", "markerMoving"):
+ plot = self.parent()
+ legend = event["label"]
+ marker = plot._getMarker(legend=legend)
+ roi = self.__getRoiFromMarker(marker)
+ elif event["event"] == "mouseClicked" and event["button"] == "left":
+ # Marker click is only for dnd
+ # This also can click on a marker
+ clicked = True
+ plot = self.parent()
+ marker = plot._getMarkerAt(event["xpixel"], event["ypixel"])
+ roi = self.__getRoiFromMarker(marker)
+ elif event["event"] == "hover":
+ self._lastHoveredMarkerLabel = event["label"]
+ else:
+ return
+
+ if roi not in self._rois:
+ # The ROI is not own by this manager
+ return
+
+ if roi is not None:
+ currentRoi = self.getCurrentRoi()
+ if currentRoi is roi:
+ if clicked:
+ self.__updateMode(roi)
+ elif roi.isSelectable():
+ self.setCurrentRoi(roi)
+ else:
+ self.setCurrentRoi(None)
+
+ def __updateMode(self, roi: RegionOfInterest):
+ if isinstance(roi, roi_items.InteractionModeMixIn):
+ available = roi.availableInteractionModes()
+ mode = roi.getInteractionMode()
+ imode = available.index(mode)
+ mode = available[(imode + 1) % len(available)]
+ roi.setInteractionMode(mode)
+
+ def _feedContextMenu(self, menu: qt.QMenu):
+ """Called when the default plot context menu is about to be displayed"""
+ roi = self.getCurrentRoi()
+ if roi is not None:
+ if roi.isEditable():
+ if self._isMouseHoverRoi(roi):
+ roiMenu = self._createMenuForRoi(menu, roi)
+ menu.addMenu(roiMenu)
+
+ def _isMouseHoverRoi(self, roi: RegionOfInterest) -> bool:
+ """Check that the mouse hovers this roi"""
+ plot = self.parent()
+
+ if self._lastHoveredMarkerLabel is not None:
+ marker = plot._getMarker(self._lastHoveredMarkerLabel)
+ if marker is not None:
+ r = self.__getRoiFromMarker(marker)
+ if roi is r:
+ return True
+
+ # Filter by data position
+ # FIXME: It would be better to use GUI coords for it
+ pos = plot.getWidgetHandle().mapFromGlobal(qt.QCursor.pos())
+ data = plot.pixelToData(pos.x(), pos.y())
+ return roi.contains(data)
+
+ def _createMenuForRoi(self, parent: qt.QWidget, roi: RegionOfInterest) -> qt.QMenu:
+ """Create a QMenu for the given RegionOfInterest"""
+ roiMenu = qt.QMenu(parent)
+ roiMenu.setTitle(roi.getName())
+
+ if isinstance(roi, roi_items.InteractionModeMixIn):
+ interactionMenu = roi.createMenuForInteractionMode(roiMenu)
+ roiMenu.addMenu(interactionMenu)
+
+ removeAction = qt.QAction(roiMenu)
+ removeAction.setText("Remove")
+ callback = functools.partial(self.removeRoi, roi)
+ removeAction.triggered.connect(callback)
+ roiMenu.addAction(removeAction)
+
+ roi.populateContextMenu(roiMenu)
+
+ return roiMenu
+
+ # RegionOfInterest API
+
+ def getRois(self):
+ """Returns the list of ROIs.
+
+ It returns an empty tuple if there is currently no ROI.
+
+ :return: Tuple of arrays of objects describing the ROIs
+ :rtype: List[RegionOfInterest]
+ """
+ return tuple(self._rois)
+
+ def clear(self):
+ """Reset current ROIs
+
+ :return: True if ROIs were reset.
+ :rtype: bool
+ """
+ if self.getRois(): # Something to reset
+ for roi in self._rois:
+ roi.sigRegionChanged.disconnect(self._regionOfInterestChanged)
+ roi.setParent(None)
+ self._rois = []
+ self._roisUpdated()
+ return True
+
+ else:
+ return False
+
+ def _regionOfInterestChanged(self, event=None):
+ """Handle ROI object changed"""
+ self.sigRoiChanged.emit()
+
+ def _createInteractiveRoi(self, roiClass, points, label=None, index=None):
+ """Create a new ROI with interactive creation.
+
+ :param class roiClass: The class of the ROI to create
+ :param numpy.ndarray points: The first shape used to create the ROI
+ :param str label: The label to display along with the ROI.
+ :param int index: The position where to insert the ROI.
+ By default it is appended to the end of the list.
+ :return: The created ROI object
+ :rtype: roi_items.RegionOfInterest
+ :raise RuntimeError: When ROI cannot be added because the maximum
+ number of ROIs has been reached.
+ """
+ roi = roiClass(parent=None)
+ if label is not None:
+ roi.setName(str(label))
+ roi.creationStarted()
+ roi.setFirstShapePoints(points)
+
+ self.addRoi(roi, index)
+ if roi.isSelectable():
+ self.setCurrentRoi(roi)
+ self.sigInteractiveRoiCreated.emit(roi)
+ return roi
+
+ def containsRoi(self, roi):
+ """Returns true if the ROI is part of this manager.
+
+ :param roi_items.RegionOfInterest roi: The ROI to add
+ :rtype: bool
+ """
+ return roi in self._rois
+
+ def addRoi(self, roi, index=None, useManagerColor=True):
+ """Add the ROI to the list of ROIs.
+
+ :param roi_items.RegionOfInterest roi: The ROI to add
+ :param int index: The position where to insert the ROI,
+ By default it is appended to the end of the list of ROIs
+ :param bool useManagerColor:
+ Whether to set the ROI color to the default one of the manager or not.
+ (Default: True).
+ :raise RuntimeError: When ROI cannot be added because the maximum
+ number of ROIs has been reached.
+ """
+ plot = self.parent()
+ if plot is None:
+ raise RuntimeError("Cannot add ROI: PlotWidget no more available")
+
+ roi.setParent(self)
+
+ if useManagerColor:
+ roi.setColor(self.getColor())
+
+ roi.sigRegionChanged.connect(self._regionOfInterestChanged)
+ roi.sigItemChanged.connect(self._regionOfInterestChanged)
+
+ if index is None:
+ self._rois.append(roi)
+ else:
+ self._rois.insert(index, roi)
+ self.sigRoiAdded.emit(roi)
+ self._roisUpdated()
+
+ def removeRoi(self, roi):
+ """Remove a ROI from the list of ROIs.
+
+ :param roi_items.RegionOfInterest roi: The ROI to remove
+ :raise ValueError: When ROI does not belong to this object
+ """
+ if not (
+ isinstance(roi, roi_items.RegionOfInterest)
+ and roi.parent() is self
+ and roi in self._rois
+ ):
+ raise ValueError("RegionOfInterest does not belong to this instance")
+
+ roi.sigAboutToBeRemoved.emit()
+ self.sigRoiAboutToBeRemoved.emit(roi)
+
+ if roi is self._currentRoi:
+ self.setCurrentRoi(None)
+
+ mustRestart = False
+ if roi is self._drawnROI:
+ self._drawnROI = None
+ mustRestart = True
+ self._rois.remove(roi)
+ roi.sigRegionChanged.disconnect(self._regionOfInterestChanged)
+ roi.sigItemChanged.disconnect(self._regionOfInterestChanged)
+ roi.setParent(None)
+ self._roisUpdated()
+
+ if mustRestart:
+ self._restart()
+
+ def _roisUpdated(self):
+ """Handle update of the ROI list"""
+ self.sigRoiChanged.emit()
+
+ # RegionOfInterest parameters
+
+ def getColor(self):
+ """Return the default color of created ROIs
+
+ :rtype: QColor
+ """
+ return qt.QColor.fromRgbF(*self._color)
+
+ def setColor(self, color):
+ """Set the default color to use when creating ROIs.
+
+ Existing ROIs are not affected.
+
+ :param color: The color to use for displaying ROIs as
+ either a color name, a QColor, a list of uint8 or float in [0, 1].
+ """
+ self._color = rgba(color)
+
+ # Control ROI
+
+ def getCurrentInteractionModeRoiClass(self):
+ """Returns the current ROI class used by the interactive drawing mode.
+
+ Returns None if the ROI manager is not in an interactive mode.
+
+ :rtype: Union[class,None]
+ """
+ return self._roiClass
+
+ def getInteractionSource(self):
+ """Returns the object which have requested the ROI creation.
+
+ Returns None if the ROI manager is not in an interactive mode.
+
+ :rtype: Union[object,None]
+ """
+ return self._source
+
+ def isStarted(self):
+ """Returns True if an interactive ROI drawing mode is active.
+
+ :rtype: bool
+ """
+ return self._roiClass is not None
+
+ def isDrawing(self):
+ """Returns True if an interactive ROI is drawing.
+
+ :rtype: bool
+ """
+ return self._drawnROI is not None
+
+ def start(self, roiClass, source=None):
+ """Start an interactive ROI drawing mode.
+
+ :param class roiClass: The ROI class to create. It have to inherite from
+ `roi_items.RegionOfInterest`.
+ :param object source: SOurce of the ROI interaction.
+ :return: True if interactive ROI drawing was started, False otherwise
+ :rtype: bool
+ :raise ValueError: If roiClass is not supported
+ """
+ self.stop()
+
+ if not issubclass(roiClass, roi_items.RegionOfInterest):
+ raise ValueError("Unsupported ROI class %s" % roiClass)
+
+ plot = self.parent()
+ if plot is None:
+ return False
+
+ self._roiClass = roiClass
+ self._source = source
+
+ self._restart()
+
+ plot.sigPlotSignal.connect(self._handleInteraction)
+
+ self.sigInteractiveModeStarted.emit(roiClass)
+
+ return True
+
+ def _restart(self):
+ """Restart the plot interaction without changing the
+ source or the ROI class.
+ """
+ roiClass = self._roiClass
+ plot = self.parent()
+ firstInteractionShapeKind = roiClass.getFirstInteractionShape()
+
+ if firstInteractionShapeKind == "point":
+ plot.setInteractiveMode(mode="select", source=self)
+ else:
+ if roiClass.showFirstInteractionShape():
+ color = rgba(self.getColor())
+ else:
+ color = None
+ plot.setInteractiveMode(
+ mode="draw",
+ source=self,
+ shape=firstInteractionShapeKind,
+ color=color,
+ label=self._label,
+ )
+
+ def __roiInteractiveModeEnded(self):
+ """Handle end of ROI draw interactive mode"""
+ if self.isStarted():
+ self._roiClass = None
+ self._source = None
+
+ if self._drawnROI is not None:
+ # Cancel ROI create
+ roi = self._drawnROI
+ self._drawnROI = None
+ self.removeRoi(roi)
+
+ plot = self.parent()
+ if plot is not None:
+ plot.sigPlotSignal.disconnect(self._handleInteraction)
+
+ self.sigInteractiveModeFinished.emit()
+
+ def stop(self):
+ """Stop interactive ROI drawing mode.
+
+ :return: True if an interactive ROI drawing mode was actually stopped
+ :rtype: bool
+ """
+ if not self.isStarted():
+ return False
+
+ plot = self.parent()
+ if plot is not None:
+ # This leads to call __roiInteractiveModeEnded through
+ # interactive mode changed signal
+ plot.resetInteractiveMode()
+ else: # Fallback
+ self.__roiInteractiveModeEnded()
+
+ return True
+
+ def exec(self, roiClass):
+ """Block until :meth:`quit` is called.
+
+ :param class kind: The class of the ROI which have to be created.
+ See `silx.gui.plot.items.roi`.
+ :return: The list of ROIs
+ :rtype: tuple
+ """
+ self.start(roiClass)
+
+ plot = self.parent()
+ plot.show()
+ plot.raise_()
+
+ self._eventLoop = qt.QEventLoop()
+ self._eventLoop.exec()
+ self._eventLoop = None
+
+ self.stop()
+
+ rois = self.getRois()
+ self.clear()
+ return rois
+
+ def exec_(self, roiClass): # Qt5-like compatibility
+ return self.exec(roiClass)
+
+ def quit(self):
+ """Stop a blocking :meth:`exec` and call :meth:`stop`"""
+ if self._eventLoop is not None:
+ self._eventLoop.quit()
+ self._eventLoop = None
+ self.stop()
+
+
+class InteractiveRegionOfInterestManager(RegionOfInterestManager):
+ """RegionOfInterestManager with features for use from interpreter.
+
+ It is meant to be used through the :meth:`exec`.
+ It provides some messages to display in a status bar and
+ different modes to end blocking calls to :meth:`exec`.
+
+ :param parent: See QObject
+ """
+
+ sigMessageChanged = qt.Signal(str)
+ """Signal emitted when a new message should be displayed to the user
+
+ It provides the message as a str.
+ """
+
+ def __init__(self, parent):
+ super(InteractiveRegionOfInterestManager, self).__init__(parent)
+ self._maxROI = None
+ self.__timeoutEndTime = None
+ self.__message = ""
+ self.__validationMode = self.ValidationMode.ENTER
+ self.__execClass = None
+
+ self.sigRoiAdded.connect(self.__added)
+ self.sigRoiAboutToBeRemoved.connect(self.__aboutToBeRemoved)
+ self.sigInteractiveModeStarted.connect(self.__started)
+ self.sigInteractiveModeFinished.connect(self.__finished)
+
+ # Max ROI
+
+ def getMaxRois(self):
+ """Returns the maximum number of ROIs or None if no limit.
+
+ :rtype: Union[int,None]
+ """
+ return self._maxROI
+
+ def setMaxRois(self, max_):
+ """Set the maximum number of ROIs.
+
+ :param Union[int,None] max_: The max limit or None for no limit.
+ :raise ValueError: If there is more ROIs than max value
+ """
+ if max_ is not None:
+ max_ = int(max_)
+ if max_ <= 0:
+ raise ValueError("Max limit must be strictly positive")
+
+ if len(self.getRois()) > max_:
+ raise ValueError("Cannot set max limit: Already too many ROIs")
+
+ self._maxROI = max_
+
+ def isMaxRois(self):
+ """Returns True if the maximum number of ROIs is reached.
+
+ :rtype: bool
+ """
+ max_ = self.getMaxRois()
+ return max_ is not None and len(self.getRois()) >= max_
+
+ # Validation mode
+
+ @enum.unique
+ class ValidationMode(enum.Enum):
+ """Mode of validation to leave blocking :meth:`exec`"""
+
+ AUTO = "auto"
+ """Automatically ends the interactive mode once
+ the user terminates the last ROI shape."""
+
+ ENTER = "enter"
+ """Ends the interactive mode when the *Enter* key is pressed."""
+
+ AUTO_ENTER = "auto_enter"
+ """Ends the interactive mode when reaching max ROIs or
+ when the *Enter* key is pressed.
+ """
+
+ NONE = "none"
+ """Do not provide the user a way to end the interactive mode.
+
+ The end of :meth:`exec` is done through :meth:`quit` or timeout.
+ """
+
+ def getValidationMode(self):
+ """Returns the interactive mode validation in use.
+
+ :rtype: ValidationMode
+ """
+ return self.__validationMode
+
+ def setValidationMode(self, mode):
+ """Set the way to perform interactive mode validation.
+
+ See :class:`ValidationMode` enumeration for the supported
+ validation modes.
+
+ :param ValidationMode mode: The interactive mode validation to use.
+ """
+ assert isinstance(mode, self.ValidationMode)
+ if mode != self.__validationMode:
+ self.__validationMode = mode
+
+ if self.isExec():
+ if self.isMaxRois() and self.getValidationMode() in (
+ self.ValidationMode.AUTO,
+ self.ValidationMode.AUTO_ENTER,
+ ):
+ self.quit()
+
+ self.__updateMessage()
+
+ def eventFilter(self, obj, event):
+ if event.type() == qt.QEvent.Hide:
+ self.quit()
+
+ if event.type() == qt.QEvent.KeyPress:
+ key = event.key()
+ if key in (
+ qt.Qt.Key_Return,
+ qt.Qt.Key_Enter,
+ ) and self.getValidationMode() in (
+ self.ValidationMode.ENTER,
+ self.ValidationMode.AUTO_ENTER,
+ ):
+ # Stop on return key pressed
+ self.quit()
+ return True # Stop further handling of this keys
+
+ if key in (qt.Qt.Key_Delete, qt.Qt.Key_Backspace) or (
+ key == qt.Qt.Key_Z and event.modifiers() & qt.Qt.ControlModifier
+ ):
+ rois = self.getRois()
+ if rois: # Something to undo
+ self.removeRoi(rois[-1])
+ # Stop further handling of keys if something was undone
+ return True
+
+ return super(InteractiveRegionOfInterestManager, self).eventFilter(obj, event)
+
+ # Message API
+
+ def getMessage(self):
+ """Returns the current status message.
+
+ This message is meant to be displayed in a status bar.
+
+ :rtype: str
+ """
+ if self.__timeoutEndTime is None:
+ return self.__message
+ else:
+ remaining = self.__timeoutEndTime - time.time()
+ return self.__message + (" - %d seconds remaining" % max(1, int(remaining)))
+
+ # Listen to ROI updates
+
+ def __added(self, *args, **kwargs):
+ """Handle new ROI added"""
+ max_ = self.getMaxRois()
+ if max_ is not None:
+ # When reaching max number of ROIs, redo last one
+ while len(self.getRois()) > max_:
+ self.removeRoi(self.getRois()[-2])
+
+ self.__updateMessage()
+ if self.isMaxRois() and self.getValidationMode() in (
+ self.ValidationMode.AUTO,
+ self.ValidationMode.AUTO_ENTER,
+ ):
+ self.quit()
+
+ def __aboutToBeRemoved(self, *args, **kwargs):
+ """Handle removal of a ROI"""
+ # RegionOfInterest not removed yet
+ self.__updateMessage(nbrois=len(self.getRois()) - 1)
+
+ def __started(self, roiKind):
+ """Handle interactive mode started"""
+ self.__updateMessage()
+
+ def __finished(self):
+ """Handle interactive mode finished"""
+ self.__updateMessage()
+
+ def __updateMessage(self, nbrois=None):
+ """Update message"""
+ if not self.isExec():
+ message = "Done"
+
+ elif not self.isStarted():
+ message = "Use %s ROI edition mode" % self.__execClass
+
+ else:
+ if nbrois is None:
+ nbrois = len(self.getRois())
+
+ name = self.__execClass._getShortName()
+
+ max_ = self.getMaxRois()
+ if max_ is None:
+ message = "Select %ss (%d selected)" % (name, nbrois)
+
+ elif max_ <= 1:
+ message = "Select a %s" % name
+ else:
+ message = "Select %d/%d %ss" % (nbrois, max_, name)
+
+ if (
+ self.getValidationMode() == self.ValidationMode.ENTER
+ and self.isMaxRois()
+ ):
+ message += " - Press Enter to confirm"
+
+ if message != self.__message:
+ self.__message = message
+ # Use getMessage to add timeout message
+ self.sigMessageChanged.emit(self.getMessage())
+
+ # Handle blocking call
+
+ def __timeoutUpdate(self):
+ """Handle update of timeout"""
+ if (
+ self.__timeoutEndTime is not None
+ and (self.__timeoutEndTime - time.time()) > 0
+ ):
+ self.sigMessageChanged.emit(self.getMessage())
+ else: # Stop interactive mode and message timer
+ timer = self.sender()
+ if timer is not None:
+ timer.stop()
+ self.__timeoutEndTime = None
+ self.quit()
+
+ def isExec(self):
+ """Returns True if :meth:`exec` is currently running.
+
+ :rtype: bool"""
+ return self.__execClass is not None
+
+ def exec(self, roiClass, timeout=0):
+ """Block until ROI selection is done or timeout is elapsed.
+
+ :meth:`quit` also ends this blocking call.
+
+ :param class roiClass: The class of the ROI which have to be created.
+ See `silx.gui.plot.items.roi`.
+ :param int timeout: Maximum duration in seconds to block.
+ Default: No timeout
+ :return: The list of ROIs
+ :rtype: List[RegionOfInterest]
+ """
+ plot = self.parent()
+ if plot is None:
+ return
+
+ self.__execClass = roiClass
+
+ plot.installEventFilter(self)
+
+ if timeout > 0:
+ self.__timeoutEndTime = time.time() + timeout
+ timer = qt.QTimer(self)
+ timer.timeout.connect(self.__timeoutUpdate)
+ timer.start(1000)
+
+ rois = super(InteractiveRegionOfInterestManager, self).exec(roiClass)
+
+ timer.stop()
+ self.__timeoutEndTime = None
+
+ else:
+ rois = super(InteractiveRegionOfInterestManager, self).exec(roiClass)
+
+ plot.removeEventFilter(self)
+
+ self.__execClass = None
+ self.__updateMessage()
+
+ return rois
+
+ def exec_(self, roiClass, timeout=0): # Qt5-like compatibility
+ return self.exec(roiClass, timeout)
+
+
+class _DeleteRegionOfInterestToolButton(qt.QToolButton):
+ """Tool button deleting a ROI object
+
+ :param parent: See QWidget
+ :param RegionOfInterest roi: The ROI to delete
+ """
+
+ def __init__(self, parent, roi):
+ super(_DeleteRegionOfInterestToolButton, self).__init__(parent)
+ self.setIcon(icons.getQIcon("remove"))
+ self.setToolTip("Remove this ROI")
+ self.__roiRef = roi if roi is None else weakref.ref(roi)
+ self.clicked.connect(self.__clicked)
+
+ def __clicked(self, checked):
+ """Handle button clicked"""
+ roi = None if self.__roiRef is None else self.__roiRef()
+ if roi is not None:
+ manager = roi.parent()
+ if manager is not None:
+ manager.removeRoi(roi)
+ self.__roiRef = None
+
+
+class RegionOfInterestTableWidget(qt.QTableWidget):
+ """Widget displaying the ROIs of a :class:`RegionOfInterestManager`"""
+
+ # Columns indices of the different displayed information
+ (
+ _LABEL_VISIBLE_COL,
+ _EDITABLE_COL,
+ _KIND_COL,
+ _COORDINATES_COL,
+ _DELETE_COL,
+ ) = range(5)
+
+ def __init__(self, parent=None):
+ super(RegionOfInterestTableWidget, self).__init__(parent)
+ self._roiManagerRef = None
+
+ headers = ["Label", "Edit", "Kind", "Coordinates", ""]
+ self.setColumnCount(len(headers))
+ self.setHorizontalHeaderLabels(headers)
+
+ horizontalHeader = self.horizontalHeader()
+ horizontalHeader.setDefaultAlignment(qt.Qt.AlignLeft)
+
+ horizontalHeader.setSectionResizeMode(0, qt.QHeaderView.Interactive)
+ horizontalHeader.setSectionResizeMode(1, qt.QHeaderView.ResizeToContents)
+ horizontalHeader.setSectionResizeMode(2, qt.QHeaderView.ResizeToContents)
+ horizontalHeader.setSectionResizeMode(3, qt.QHeaderView.Stretch)
+ horizontalHeader.setSectionResizeMode(4, qt.QHeaderView.ResizeToContents)
+
+ verticalHeader = self.verticalHeader()
+ verticalHeader.setVisible(False)
+
+ self.setSelectionMode(qt.QAbstractItemView.NoSelection)
+ self.setFocusPolicy(qt.Qt.NoFocus)
+
+ self.itemChanged.connect(self.__itemChanged)
+
+ def __itemChanged(self, item):
+ """Handle QTableWidget item updates"""
+ column = item.column()
+ roi = item.data(qt.Qt.UserRole)
+ if roi is None:
+ return
+
+ if column == 0:
+ # First collect information from item, then update ROI
+ # Otherwise, this causes issues
+ checked = item.checkState() == qt.Qt.Checked
+ text = item.text()
+ roi.setVisible(checked)
+ roi.setName(text)
+ elif column == 1:
+ roi.setEditable(item.checkState() == qt.Qt.Checked)
+ elif column in (2, 3, 4):
+ pass # TODO
+ else:
+ logger.error("Unhandled column %d", column)
+
+ def setRegionOfInterestManager(self, manager):
+ """Set the :class:`RegionOfInterestManager` object to sync with
+
+ :param RegionOfInterestManager manager:
+ """
+ assert manager is None or isinstance(manager, RegionOfInterestManager)
+
+ previousManager = self.getRegionOfInterestManager()
+
+ if previousManager is not None:
+ previousManager.sigRoiAdded.disconnect(self.__roiAdded)
+ previousManager.sigRoiAboutToBeRemoved.disconnect(
+ self.__roiAboutToBeRemoved
+ )
+ for roi in previousManager.getRois():
+ self.__disconnectRoi(roi)
+
+ self.setRowCount(0)
+
+ self._roiManagerRef = weakref.ref(manager)
+
+ self._sync()
+
+ if manager is not None:
+ for roi in manager.getRois():
+ self.__connectRoi(roi)
+ manager.sigRoiAdded.connect(self.__roiAdded)
+ manager.sigRoiAboutToBeRemoved.connect(self.__roiAboutToBeRemoved)
+
+ 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 __connectRoi(self, roi: RegionOfInterest):
+ """Start listening ROI signals"""
+ roi.sigItemChanged.connect(self.__roiItemChanged)
+ roi.sigRegionChanged.connect(self.__roiRegionChanged)
+
+ def __disconnectRoi(self, roi: RegionOfInterest):
+ """Stop listening ROI signals"""
+ roi.sigItemChanged.disconnect(self.__roiItemChanged)
+ roi.sigRegionChanged.disconnect(self.__roiRegionChanged)
+
+ def __getRoiRow(self, roi: RegionOfInterest) -> int:
+ """Returns row index of given region of interest
+
+ :raises ValueError: If region of interest is not in the list
+ """
+ manager = self.getRegionOfInterestManager()
+ if manager is None:
+ return
+ return manager.getRois().index(roi)
+
+ def __roiAdded(self, roi: RegionOfInterest):
+ """Handle new ROI added to the manager"""
+ self.__connectRoi(roi)
+ self._sync()
+
+ def __roiAboutToBeRemoved(self, roi: RegionOfInterest):
+ """Handle removing a ROI from the manager"""
+ self.__disconnectRoi(roi)
+ self.removeRow(self.__getRoiRow(roi))
+
+ def __roiItemChanged(self, event: ItemChangedType):
+ """Handle ROI sigItemChanged events"""
+ roi = self.sender()
+ if roi is None:
+ return
+
+ try:
+ row = self.__getRoiRow(roi)
+ except ValueError:
+ return
+
+ if event == ItemChangedType.VISIBLE:
+ item = self.item(row, self._LABEL_VISIBLE_COL)
+ item.setCheckState(qt.Qt.Checked if roi.isVisible() else qt.Qt.Unchecked)
+ return
+
+ if event == ItemChangedType.NAME:
+ item = self.item(row, self._LABEL_VISIBLE_COL)
+ item.setText(roi.getName())
+ return
+
+ if event == ItemChangedType.EDITABLE:
+ item = self.item(row, self._EDITABLE_COL)
+ item.setCheckState(qt.Qt.Checked if roi.isEditable() else qt.Qt.Unchecked)
+ return
+
+ def __roiRegionChanged(self):
+ """Handle change of ROI coordinates"""
+ roi = self.sender()
+ if roi is None:
+ return
+
+ item = self.item(self.__getRoiRow(roi), self._COORDINATES_COL)
+ if item is None:
+ return
+
+ text = self._getReadableRoiDescription(roi)
+ item.setText(text)
+
+ def _sync(self):
+ """Update widget content according to ROI manger"""
+ manager = self.getRegionOfInterestManager()
+
+ if manager is None:
+ self.setRowCount(0)
+ return
+
+ rois = manager.getRois()
+
+ self.setRowCount(len(rois))
+ for index, roi in enumerate(rois):
+ baseFlags = qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled
+
+ # Label and visible
+ item = qt.QTableWidgetItem()
+ item.setFlags(baseFlags | qt.Qt.ItemIsEditable | qt.Qt.ItemIsUserCheckable)
+ item.setData(qt.Qt.UserRole, roi)
+ item.setText(roi.getName())
+ item.setCheckState(qt.Qt.Checked if roi.isVisible() else qt.Qt.Unchecked)
+ self.setItem(index, self._LABEL_VISIBLE_COL, 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, self._EDITABLE_COL, item)
+ item.setTextAlignment(qt.Qt.AlignCenter)
+ item.setText(None)
+
+ # Kind
+ label = roi._getShortName()
+ if label is None:
+ # Default value if kind is not overrided
+ label = roi.__class__.__name__
+ item = qt.QTableWidgetItem(label.capitalize())
+ item.setFlags(baseFlags)
+ self.setItem(index, self._KIND_COL, item)
+
+ # Coordinates
+ item = qt.QTableWidgetItem()
+ item.setFlags(baseFlags)
+ text = self._getReadableRoiDescription(roi)
+ item.setText(text)
+ self.setItem(index, self._COORDINATES_COL, item)
+
+ # Delete
+ widget = qt.QWidget(self)
+ delBtn = _DeleteRegionOfInterestToolButton(widget, roi)
+ 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, self._DELETE_COL, widget)
+
+ def getRegionOfInterestManager(self):
+ """Returns the :class:`RegionOfInterestManager` this widget supervise.
+
+ It returns None if not sync with an :class:`RegionOfInterestManager`.
+
+ :rtype: RegionOfInterestManager
+ """
+ return None if self._roiManagerRef is None else self._roiManagerRef()
diff --git a/src/silx/gui/plot/tools/test/__init__.py b/src/silx/gui/plot/tools/test/__init__.py
new file mode 100644
index 0000000..2e682d7
--- /dev/null
+++ b/src/silx/gui/plot/tools/test/__init__.py
@@ -0,0 +1,23 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
diff --git a/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py b/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py
new file mode 100644
index 0000000..9f1a184
--- /dev/null
+++ b/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py
@@ -0,0 +1,117 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/08/2018"
+
+
+from silx.gui import qt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.plot import PlotWindow
+from silx.gui.plot.tools import CurveLegendsWidget
+
+
+class TestCurveLegendsWidget(TestCaseQt, ParametricTestCase):
+ """Tests for CurveLegendsWidget class"""
+
+ def setUp(self):
+ super(TestCurveLegendsWidget, self).setUp()
+ self.plot = PlotWindow()
+
+ self.legends = CurveLegendsWidget.CurveLegendsWidget()
+ self.legends.setPlotWidget(self.plot)
+
+ dock = qt.QDockWidget()
+ dock.setWindowTitle("Curve Legends")
+ dock.setWidget(self.legends)
+ self.plot.addTabbedDockWidget(dock)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ del self.legends
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestCurveLegendsWidget, self).tearDown()
+
+ def _assertNbLegends(self, count):
+ """Check the number of legends in the CurveLegendsWidget"""
+ children = self.legends.findChildren(CurveLegendsWidget._LegendWidget)
+ self.assertEqual(len(children), count)
+
+ def testAddRemoveCurves(self):
+ """Test CurveLegendsWidget while adding/removing curves"""
+ self.plot.addCurve((0, 1), (1, 2), legend="a")
+ self._assertNbLegends(1)
+ self.plot.addCurve((0, 1), (2, 3), legend="b")
+ self._assertNbLegends(2)
+
+ # Detached/attach
+ self.legends.setPlotWidget(None)
+ self._assertNbLegends(0)
+
+ self.legends.setPlotWidget(self.plot)
+ self._assertNbLegends(2)
+
+ self.plot.clear()
+ self._assertNbLegends(0)
+
+ def testUpdateCurves(self):
+ """Test CurveLegendsWidget while updating curves"""
+ self.plot.addCurve((0, 1), (1, 2), legend="a")
+ self._assertNbLegends(1)
+ self.plot.addCurve((0, 1), (2, 3), legend="b")
+ self._assertNbLegends(2)
+
+ # Activate curve
+ self.plot.setActiveCurve("a")
+ self.qapp.processEvents()
+ self.plot.setActiveCurve("b")
+ self.qapp.processEvents()
+
+ # Change curve style
+ curve = self.plot.getCurve("a")
+ curve.setLineWidth(2)
+ for linestyle in (
+ ":",
+ "",
+ "--",
+ "-",
+ (0.0, (5.0, 5.0)),
+ (5.0, (10.0, 2.0, 2.0, 5.0)),
+ ):
+ with self.subTest(linestyle=linestyle):
+ curve.setLineStyle(linestyle)
+ self.qapp.processEvents()
+ self.qWait(1000)
+
+ for symbol in ("o", "d", "", "s"):
+ with self.subTest(symbol=symbol):
+ curve.setSymbol(symbol)
+ self.qapp.processEvents()
+ self.qWait(1000)
diff --git a/src/silx/gui/plot/tools/test/testProfile.py b/src/silx/gui/plot/tools/test/testProfile.py
new file mode 100644
index 0000000..61b95a6
--- /dev/null
+++ b/src/silx/gui/plot/tools/test/testProfile.py
@@ -0,0 +1,604 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2023 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 contextlib
+import numpy
+import logging
+
+from silx.gui import qt
+
+from silx.gui.utils.testutils import TestCaseQt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile
+from silx.gui.plot.StackView import StackView
+from silx.gui.plot.tools.profile import rois
+from silx.gui.plot.tools.profile import editors
+from silx.gui.plot.items import roi as roi_items
+from silx.gui.plot.tools.profile import manager
+from silx.gui import plot as silx_plot
+
+_logger = logging.getLogger(__name__)
+
+
+class TestRois(TestCaseQt):
+ def test_init(self):
+ """Check that the constructor is not called twice"""
+ roi = rois.ProfileImageVerticalLineROI()
+ if qt.BINDING == "PyQt5":
+ # the profile ROI + the shape
+ self.assertEqual(roi.receivers(roi.sigRegionChanged), 2)
+
+
+class TestInteractions(TestCaseQt):
+ @contextlib.contextmanager
+ def defaultPlot(self):
+ try:
+ widget = silx_plot.PlotWidget()
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ @contextlib.contextmanager
+ def imagePlot(self):
+ try:
+ widget = silx_plot.Plot2D()
+ image = numpy.arange(10 * 10).reshape(10, -1)
+ widget.addImage(image)
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ @contextlib.contextmanager
+ def scatterPlot(self):
+ try:
+ widget = silx_plot.ScatterView()
+
+ nbX, nbY = 7, 5
+ yy = numpy.atleast_2d(numpy.ones(nbY)).T
+ xx = numpy.atleast_2d(numpy.ones(nbX))
+ positionX = numpy.linspace(10, 50, nbX) * yy
+ positionX = positionX.reshape(nbX * nbY)
+ positionY = numpy.atleast_2d(numpy.linspace(20, 60, nbY)).T * xx
+ positionY = positionY.reshape(nbX * nbY)
+ values = numpy.arange(nbX * nbY)
+
+ widget.setData(positionX, positionY, values)
+ widget.resetZoom()
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget.getPlotWidget()
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ @contextlib.contextmanager
+ def stackPlot(self):
+ try:
+ widget = silx_plot.StackView()
+ image = numpy.arange(10 * 10).reshape(10, -1)
+ cube = numpy.array([image, image, image])
+ widget.setStack(cube)
+ widget.resetZoom()
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget.getPlotWidget()
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ def waitPendingOperations(self, proflie):
+ for _ in range(10):
+ if not proflie.hasPendingOperations():
+ return
+ self.qWait(100)
+ _logger.error("The profile manager still have pending operations")
+
+ def genericRoiTest(self, plot, roiClass):
+ profileManager = manager.ProfileManager(plot, plot)
+ profileManager.setItemType(image=True, scatter=True)
+
+ try:
+ action = profileManager.createProfileAction(roiClass, plot)
+ action.triggered[bool].emit(True)
+ widget = plot.getWidgetHandle()
+
+ # Do the mouse interaction
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ self.mouseMove(widget, pos=pos1)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+
+ if issubclass(roiClass, roi_items.LineROI):
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+ self.mouseMove(widget, pos=pos2)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos2)
+
+ self.waitPendingOperations(profileManager)
+
+ # Test that something was computed
+ if issubclass(roiClass, rois._ProfileCrossROI):
+ self.assertEqual(profileManager._computedProfiles, 2)
+ elif issubclass(roiClass, roi_items.LineROI):
+ self.assertGreaterEqual(profileManager._computedProfiles, 1)
+ else:
+ self.assertEqual(profileManager._computedProfiles, 1)
+
+ # Test the created ROIs
+ profileRois = profileManager.getRoiManager().getRois()
+ if issubclass(roiClass, rois._ProfileCrossROI):
+ self.assertEqual(len(profileRois), 3)
+ else:
+ self.assertEqual(len(profileRois), 1)
+ # The first one should be the expected one
+ roi = profileRois[0]
+
+ # Test that something was displayed
+ if issubclass(roiClass, rois._ProfileCrossROI):
+ profiles = roi._getLines()
+ window = profiles[0].getProfileWindow()
+ self.assertIsNotNone(window)
+ window = profiles[1].getProfileWindow()
+ self.assertIsNotNone(window)
+ else:
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ finally:
+ profileManager.clearProfile()
+
+ def testImageActions(self):
+ roiClasses = [
+ rois.ProfileImageHorizontalLineROI,
+ rois.ProfileImageVerticalLineROI,
+ rois.ProfileImageLineROI,
+ rois.ProfileImageCrossROI,
+ ]
+ with self.imagePlot() as plot:
+ for roiClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ self.genericRoiTest(plot, roiClass)
+
+ def testScatterActions(self):
+ roiClasses = [
+ rois.ProfileScatterHorizontalLineROI,
+ rois.ProfileScatterVerticalLineROI,
+ rois.ProfileScatterLineROI,
+ rois.ProfileScatterCrossROI,
+ rois.ProfileScatterHorizontalSliceROI,
+ rois.ProfileScatterVerticalSliceROI,
+ rois.ProfileScatterCrossSliceROI,
+ ]
+ with self.scatterPlot() as plot:
+ for roiClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ self.genericRoiTest(plot, roiClass)
+
+ def testStackActions(self):
+ roiClasses = [
+ rois.ProfileImageStackHorizontalLineROI,
+ rois.ProfileImageStackVerticalLineROI,
+ rois.ProfileImageStackLineROI,
+ rois.ProfileImageStackCrossROI,
+ ]
+ with self.stackPlot() as plot:
+ for roiClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ self.genericRoiTest(plot, roiClass)
+
+ def genericEditorTest(self, plot, roi, editor):
+ if isinstance(editor, editors._NoProfileRoiEditor):
+ pass
+ elif isinstance(editor, editors._DefaultImageStackProfileRoiEditor):
+ # GUI to ROI
+ editor._lineWidth.setValue(2)
+ self.assertEqual(roi.getProfileLineWidth(), 2)
+ editor._methodsButton.setMethod("sum")
+ self.assertEqual(roi.getProfileMethod(), "sum")
+ editor._profileDim.setDimension(1)
+ self.assertEqual(roi.getProfileType(), "1D")
+ # ROI to GUI
+ roi.setProfileLineWidth(3)
+ self.assertEqual(editor._lineWidth.value(), 3)
+ roi.setProfileMethod("mean")
+ self.assertEqual(editor._methodsButton.getMethod(), "mean")
+ roi.setProfileType("2D")
+ self.assertEqual(editor._profileDim.getDimension(), 2)
+ elif isinstance(editor, editors._DefaultImageProfileRoiEditor):
+ # GUI to ROI
+ editor._lineWidth.setValue(2)
+ self.assertEqual(roi.getProfileLineWidth(), 2)
+ editor._methodsButton.setMethod("sum")
+ self.assertEqual(roi.getProfileMethod(), "sum")
+ # ROI to GUI
+ roi.setProfileLineWidth(3)
+ self.assertEqual(editor._lineWidth.value(), 3)
+ roi.setProfileMethod("mean")
+ self.assertEqual(editor._methodsButton.getMethod(), "mean")
+ elif isinstance(editor, editors._DefaultScatterProfileRoiEditor):
+ # GUI to ROI
+ editor._nPoints.setValue(100)
+ self.assertEqual(roi.getNPoints(), 100)
+ # ROI to GUI
+ roi.setNPoints(200)
+ self.assertEqual(editor._nPoints.value(), 200)
+ else:
+ assert False
+
+ def testEditors(self):
+ roiClasses = [
+ (rois.ProfileImageHorizontalLineROI, editors._DefaultImageProfileRoiEditor),
+ (rois.ProfileImageVerticalLineROI, editors._DefaultImageProfileRoiEditor),
+ (rois.ProfileImageLineROI, editors._DefaultImageProfileRoiEditor),
+ (rois.ProfileImageCrossROI, editors._DefaultImageProfileRoiEditor),
+ (
+ rois.ProfileScatterHorizontalLineROI,
+ editors._DefaultScatterProfileRoiEditor,
+ ),
+ (
+ rois.ProfileScatterVerticalLineROI,
+ editors._DefaultScatterProfileRoiEditor,
+ ),
+ (rois.ProfileScatterLineROI, editors._DefaultScatterProfileRoiEditor),
+ (rois.ProfileScatterCrossROI, editors._DefaultScatterProfileRoiEditor),
+ (rois.ProfileScatterHorizontalSliceROI, editors._NoProfileRoiEditor),
+ (rois.ProfileScatterVerticalSliceROI, editors._NoProfileRoiEditor),
+ (rois.ProfileScatterCrossSliceROI, editors._NoProfileRoiEditor),
+ (
+ rois.ProfileImageStackHorizontalLineROI,
+ editors._DefaultImageStackProfileRoiEditor,
+ ),
+ (
+ rois.ProfileImageStackVerticalLineROI,
+ editors._DefaultImageStackProfileRoiEditor,
+ ),
+ (rois.ProfileImageStackLineROI, editors._DefaultImageStackProfileRoiEditor),
+ (
+ rois.ProfileImageStackCrossROI,
+ editors._DefaultImageStackProfileRoiEditor,
+ ),
+ ]
+ with self.defaultPlot() as plot:
+ profileManager = manager.ProfileManager(plot, plot)
+ editorAction = profileManager.createEditorAction(parent=plot)
+ for roiClass, editorClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ roi = roiClass()
+ roi._setProfileManager(profileManager)
+ try:
+ # Force widget creation
+ menu = qt.QMenu(plot)
+ menu.addAction(editorAction)
+ widgets = editorAction.createdWidgets()
+ self.assertGreater(len(widgets), 0)
+
+ editorAction.setProfileRoi(roi)
+ editorWidget = editorAction._getEditor(widgets[0])
+ self.assertIsInstance(editorWidget, editorClass)
+ self.genericEditorTest(plot, roi, editorWidget)
+ finally:
+ editorAction.setProfileRoi(None)
+ menu.deleteLater()
+ menu = None
+ self.qapp.processEvents()
+
+
+class TestProfileToolBar(TestCaseQt, ParametricTestCase):
+ """Tests for ProfileToolBar widget."""
+
+ def setUp(self):
+ super(TestProfileToolBar, self).setUp()
+ self.plot = PlotWindow()
+ self.toolBar = Profile.ProfileToolBar(plot=self.plot)
+ self.plot.addToolBar(self.toolBar)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.mouseMove(self.plot) # Move to center
+ self.qapp.processEvents()
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ profileManager = self.toolBar.getProfileManager()
+ profileManager.clearProfile()
+ profileManager = None
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ del self.toolBar
+
+ super(TestProfileToolBar, self).tearDown()
+
+ def testAlignedProfile(self):
+ """Test horizontal and vertical profile, without and with image"""
+ # Use Plot backend widget to submit mouse events
+ widget = self.plot.getWidgetHandle()
+ for method in ("sum", "mean"):
+ with self.subTest(method=method):
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ for action in (self.toolBar.hLineAction, self.toolBar.vLineAction):
+ with self.subTest(mode=action.text()):
+ # Trigger tool button for mode
+ action.trigger()
+ # Without image
+ self.mouseMove(widget, pos=pos1)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+
+ # with image
+ self.plot.addImage(numpy.arange(100 * 100).reshape(100, -1))
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(widget, pos=pos2)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+
+ self.mouseMove(widget)
+ self.mouseClick(widget, qt.Qt.LeftButton)
+
+ manager = self.toolBar.getProfileManager()
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ def testDiagonalProfile(self):
+ """Test diagonal profile, without and with image"""
+ # Use Plot backend widget to submit mouse events
+ widget = self.plot.getWidgetHandle()
+
+ self.plot.addImage(numpy.arange(100 * 100).reshape(100, -1))
+
+ for method in ("sum", "mean"):
+ with self.subTest(method=method):
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ # Trigger tool button for diagonal profile mode
+ self.toolBar.lineAction.trigger()
+
+ # draw profile line
+ widget.setFocus(qt.Qt.OtherFocusReason)
+ self.mouseMove(widget, pos=pos1)
+ self.qWait(100)
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.qWait(100)
+ self.mouseMove(widget, pos=pos2)
+ self.qWait(100)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+ self.qWait(100)
+
+ manager = self.toolBar.getProfileManager()
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ roi = manager.getCurrentRoi()
+ self.assertIsNotNone(roi)
+ roi.setProfileLineWidth(3)
+ roi.setProfileMethod(method)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ curveItem = (
+ roi.getProfileWindow().getCurrentPlotWidget().getAllCurves()[0]
+ )
+ if method == "sum":
+ self.assertTrue(curveItem.getData()[1].max() > 10000)
+ elif method == "mean":
+ self.assertTrue(curveItem.getData()[1].max() < 10000)
+
+ # Remove the ROI so the profile window is also removed
+ roiManager = manager.getRoiManager()
+ roiManager.removeRoi(roi)
+ self.qWait(100)
+
+
+class TestProfile3DToolBar(TestCaseQt):
+ """Tests for Profile3DToolBar widget."""
+
+ def setUp(self):
+ super(TestProfile3DToolBar, self).setUp()
+ self.plot = StackView()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.plot.setStack(
+ numpy.array(
+ [
+ [[0, 1, 2], [3, 4, 5]],
+ [[6, 7, 8], [9, 10, 11]],
+ [[12, 13, 14], [15, 16, 17]],
+ ]
+ )
+ )
+
+ def tearDown(self):
+ profileManager = self.plot.getProfileToolbar().getProfileManager()
+ profileManager.clearProfile()
+ profileManager = None
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ self.plot = None
+
+ super(TestProfile3DToolBar, self).tearDown()
+
+ def testMethodProfile2D(self):
+ """Test that the profile can have a different method if we want to
+ compute then in 1D or in 2D"""
+
+ toolBar = self.plot.getProfileToolbar()
+
+ toolBar.vLineAction.trigger()
+ plot2D = self.plot.getPlotWidget().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5
+ self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1)
+
+ manager = toolBar.getProfileManager()
+ roi = manager.getCurrentRoi()
+ roi.setProfileMethod("mean")
+ roi.setProfileType("2D")
+ roi.setProfileLineWidth(3)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ # check 2D 'mean' profile
+ profilePlot = roi.getProfileWindow().getCurrentPlotWidget()
+ data = profilePlot.getAllImages()[0].getData()
+ expected = numpy.array([[1, 4], [7, 10], [13, 16]])
+ numpy.testing.assert_almost_equal(data, expected)
+
+ def testMethodSumLine(self):
+ """Simple interaction test to make sure the sum is correctly computed"""
+ toolBar = self.plot.getProfileToolbar()
+
+ toolBar.lineAction.trigger()
+ plot2D = self.plot.getPlotWidget().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2
+ pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8
+
+ self.mouseMove(plot2D, pos=pos1)
+ self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(plot2D, pos=pos2)
+ self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2)
+
+ manager = toolBar.getProfileManager()
+ roi = manager.getCurrentRoi()
+ roi.setProfileMethod("sum")
+ roi.setProfileType("2D")
+ roi.setProfileLineWidth(3)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ # check 2D 'sum' profile
+ profilePlot = roi.getProfileWindow().getCurrentPlotWidget()
+ data = profilePlot.getAllImages()[0].getData()
+ expected = numpy.array([[3, 12], [21, 30], [39, 48]])
+ numpy.testing.assert_almost_equal(data, expected)
+
+
+class TestGetProfilePlot(TestCaseQt):
+ def setUp(self):
+ self.plot = None
+ super(TestGetProfilePlot, self).setUp()
+
+ def tearDown(self):
+ if self.plot is not None:
+ manager = self.plot.getProfileToolbar().getProfileManager()
+ manager.clearProfile()
+ manager = None
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ self.plot = None
+
+ super(TestGetProfilePlot, self).tearDown()
+
+ def testProfile1D(self):
+ self.plot = Plot2D()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+ self.plot.addImage([[0, 1], [2, 3]])
+
+ toolBar = self.plot.getProfileToolbar()
+
+ manager = toolBar.getProfileManager()
+ roiManager = manager.getRoiManager()
+
+ roi = rois.ProfileImageHorizontalLineROI()
+ roi.setPosition(0.5)
+ roiManager.addRoi(roi)
+ roiManager.setCurrentRoi(roi)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ profileWindow = roi.getProfileWindow()
+ self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow)
+ self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D)
+
+ def testProfile2D(self):
+ """Test that the profile plot associated to a stack view is either a
+ Plot1D or a plot 2D instance."""
+ self.plot = StackView()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.plot.setStack(numpy.array([[[0, 1], [2, 3]], [[4, 5], [6, 7]]]))
+
+ toolBar = self.plot.getProfileToolbar()
+
+ manager = toolBar.getProfileManager()
+ roiManager = manager.getRoiManager()
+
+ roi = rois.ProfileImageStackHorizontalLineROI()
+ roi.setPosition(0.5)
+ roi.setProfileType("2D")
+ roiManager.addRoi(roi)
+ roiManager.setCurrentRoi(roi)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ profileWindow = roi.getProfileWindow()
+ self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow)
+ self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot2D)
+
+ roi.setProfileType("1D")
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ profileWindow = roi.getProfileWindow()
+ self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow)
+ self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D)
diff --git a/src/silx/gui/plot/tools/test/testRoiCore.py b/src/silx/gui/plot/tools/test/testRoiCore.py
new file mode 100644
index 0000000..e7f6d8a
--- /dev/null
+++ b/src/silx/gui/plot/tools/test/testRoiCore.py
@@ -0,0 +1,535 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import numpy.testing
+
+from silx.gui import qt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.utils.testutils import TestCaseQt, SignalListener
+from silx.gui.plot import PlotWindow
+import silx.gui.plot.items.roi as roi_items
+from silx.gui.plot.tools import roi
+
+
+class 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.0, 15.0)], [(20.0, 25.0)]))),
+ (
+ roi_items.RectangleROI,
+ numpy.array((((1.0, 10.0), (11.0, 20.0)), ((2.0, 3.0), (12.0, 13.0)))),
+ ),
+ (
+ roi_items.PolygonROI,
+ numpy.array(
+ (
+ ((0.0, 1.0), (0.0, 10.0), (10.0, 0.0)),
+ ((5.0, 6.0), (5.0, 16.0), (15.0, 6.0)),
+ )
+ ),
+ ),
+ (
+ roi_items.LineROI,
+ numpy.array(
+ (((10.0, 20.0), (10.0, 30.0)), ((30.0, 40.0), (30.0, 50.0)))
+ ),
+ ),
+ (
+ roi_items.HorizontalLineROI,
+ numpy.array(
+ (((10.0, 20.0), (10.0, 30.0)), ((30.0, 40.0), (30.0, 50.0)))
+ ),
+ ),
+ (
+ roi_items.VerticalLineROI,
+ numpy.array(
+ (((10.0, 20.0), (10.0, 30.0)), ((30.0, 40.0), (30.0, 50.0)))
+ ),
+ ),
+ (
+ roi_items.HorizontalLineROI,
+ numpy.array(
+ (((10.0, 20.0), (10.0, 30.0)), ((30.0, 40.0), (30.0, 50.0)))
+ ),
+ ),
+ )
+
+ for roiClass, points in tests:
+ with self.subTest(roiClass=roiClass):
+ manager = roi.RegionOfInterestManager(self.plot)
+ self.roiTableWidget.setRegionOfInterestManager(manager)
+ manager.start(roiClass)
+
+ self.assertEqual(manager.getRois(), ())
+
+ finishListener = SignalListener()
+ manager.sigInteractiveModeFinished.connect(finishListener)
+
+ changedListener = SignalListener()
+ manager.sigRoiChanged.connect(changedListener)
+
+ # Add a point
+ r = roiClass()
+ r.setFirstShapePoints(points[0])
+ manager.addRoi(r)
+ self.qapp.processEvents()
+ self.assertTrue(len(manager.getRois()), 1)
+ self.assertEqual(changedListener.callCount(), 1)
+
+ # Remove it
+ manager.removeRoi(manager.getRois()[0])
+ self.assertEqual(manager.getRois(), ())
+ self.assertEqual(changedListener.callCount(), 2)
+
+ # Add two point
+ r = roiClass()
+ r.setFirstShapePoints(points[0])
+ manager.addRoi(r)
+ self.qapp.processEvents()
+ r = roiClass()
+ r.setFirstShapePoints(points[1])
+ manager.addRoi(r)
+ self.qapp.processEvents()
+ self.assertTrue(len(manager.getRois()), 2)
+ self.assertEqual(changedListener.callCount(), 4)
+
+ # Reset it
+ result = manager.clear()
+ self.assertTrue(result)
+ self.assertEqual(manager.getRois(), ())
+ self.assertEqual(changedListener.callCount(), 5)
+
+ changedListener.clear()
+
+ # Add two point
+ r = roiClass()
+ r.setFirstShapePoints(points[0])
+ manager.addRoi(r)
+ self.qapp.processEvents()
+ r = roiClass()
+ r.setFirstShapePoints(points[1])
+ manager.addRoi(r)
+ self.qapp.processEvents()
+ self.assertTrue(len(manager.getRois()), 2)
+ self.assertEqual(changedListener.callCount(), 2)
+
+ # stop
+ result = manager.stop()
+ self.assertTrue(result)
+ self.assertTrue(len(manager.getRois()), 1)
+ self.qapp.processEvents()
+ self.assertEqual(finishListener.callCount(), 1)
+
+ manager.clear()
+
+ def testRoiDisplay(self):
+ rois = []
+
+ # Line
+ item = roi_items.LineROI()
+ startPoint = numpy.array([1, 2])
+ endPoint = numpy.array([3, 4])
+ item.setEndPoints(startPoint, endPoint)
+ rois.append(item)
+ # Horizontal line
+ item = roi_items.HorizontalLineROI()
+ item.setPosition(15)
+ rois.append(item)
+ # Vertical line
+ item = roi_items.VerticalLineROI()
+ item.setPosition(15)
+ rois.append(item)
+ # Point
+ item = roi_items.PointROI()
+ point = numpy.array([1, 2])
+ item.setPosition(point)
+ rois.append(item)
+ # Rectangle
+ item = roi_items.RectangleROI()
+ origin = numpy.array([0, 0])
+ size = numpy.array([10, 20])
+ item.setGeometry(origin=origin, size=size)
+ rois.append(item)
+ # Polygon
+ item = roi_items.PolygonROI()
+ points = numpy.array([[10, 10], [12, 10], [50, 1]])
+ item.setPoints(points)
+ rois.append(item)
+ # Degenerated polygon: No points
+ item = roi_items.PolygonROI()
+ points = numpy.empty((0, 2))
+ item.setPoints(points)
+ rois.append(item)
+ # Degenerated polygon: A single point
+ item = roi_items.PolygonROI()
+ points = numpy.array([[5, 10]])
+ item.setPoints(points)
+ rois.append(item)
+ # Degenerated arc: it's a point
+ item = roi_items.ArcROI()
+ center = numpy.array([10, 20])
+ innerRadius, outerRadius, startAngle, endAngle = 0, 0, 0, 0
+ item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle)
+ rois.append(item)
+ # Degenerated arc: it's a line
+ item = roi_items.ArcROI()
+ center = numpy.array([10, 20])
+ innerRadius, outerRadius, startAngle, endAngle = 0, 100, numpy.pi, numpy.pi
+ item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle)
+ rois.append(item)
+ # Special arc: it's a donut
+ item = roi_items.ArcROI()
+ center = numpy.array([10, 20])
+ innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi, 3 * numpy.pi
+ item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle)
+ rois.append(item)
+ # Arc
+ item = roi_items.ArcROI()
+ center = numpy.array([10, 20])
+ innerRadius, outerRadius, startAngle, endAngle = (
+ 1,
+ 100,
+ numpy.pi * 0.5,
+ numpy.pi,
+ )
+ item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle)
+ rois.append(item)
+ # Horizontal Range
+ item = roi_items.HorizontalRangeROI()
+ item.setRange(-1, 3)
+ rois.append(item)
+
+ manager = roi.RegionOfInterestManager(self.plot)
+ self.roiTableWidget.setRegionOfInterestManager(manager)
+ for item in rois:
+ with self.subTest(roi=str(item)):
+ manager.addRoi(item)
+ self.qapp.processEvents()
+ item.setEditable(True)
+ self.qapp.processEvents()
+ item.setEditable(False)
+ self.qapp.processEvents()
+ manager.removeRoi(item)
+ self.qapp.processEvents()
+
+ def testSelectionProxy(self):
+ item1 = roi_items.PointROI()
+ item1.setSelectable(True)
+ item2 = roi_items.PointROI()
+ item2.setSelectable(True)
+ item1.setFocusProxy(item2)
+ manager = roi.RegionOfInterestManager(self.plot)
+ manager.setCurrentRoi(item1)
+ self.assertIs(manager.getCurrentRoi(), item2)
+
+ def testRemovedSelection(self):
+ item1 = roi_items.PointROI()
+ item1.setSelectable(True)
+ manager = roi.RegionOfInterestManager(self.plot)
+ manager.addRoi(item1)
+ manager.setCurrentRoi(item1)
+ manager.removeRoi(item1)
+ self.assertIs(manager.getCurrentRoi(), None)
+
+ def testInitROIWithParent(self):
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.PointROI(manager)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+ manager.removeRoi(item)
+ self.qapp.processEvents()
+
+ def testMaxROI(self):
+ """Test Max ROI"""
+ origin1 = numpy.array([1.0, 10.0])
+ size1 = numpy.array([10.0, 10.0])
+ origin2 = numpy.array([2.0, 3.0])
+ size2 = numpy.array([10.0, 10.0])
+
+ manager = roi.InteractiveRegionOfInterestManager(self.plot)
+ self.roiTableWidget.setRegionOfInterestManager(manager)
+ self.assertEqual(manager.getRois(), ())
+
+ changedListener = SignalListener()
+ manager.sigRoiChanged.connect(changedListener)
+
+ # Add two point
+ item = roi_items.RectangleROI()
+ item.setGeometry(origin=origin1, size=size1)
+ manager.addRoi(item)
+ item = roi_items.RectangleROI()
+ item.setGeometry(origin=origin2, size=size2)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+ self.assertEqual(changedListener.callCount(), 2)
+ self.assertEqual(len(manager.getRois()), 2)
+
+ # Try to set max ROI to 1 while there is 2 ROIs
+ with self.assertRaises(ValueError):
+ manager.setMaxRois(1)
+
+ manager.clear()
+ self.assertEqual(len(manager.getRois()), 0)
+ self.assertEqual(changedListener.callCount(), 3)
+
+ # Set max limit to 1
+ manager.setMaxRois(1)
+
+ # Add a point
+ item = roi_items.RectangleROI()
+ item.setGeometry(origin=origin1, size=size1)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+ self.assertEqual(changedListener.callCount(), 4)
+
+ # Add a 2nd point while max ROI is 1
+ item = roi_items.RectangleROI()
+ item.setGeometry(origin=origin1, size=size1)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+ self.assertEqual(changedListener.callCount(), 6)
+ self.assertEqual(len(manager.getRois()), 1)
+
+ def testChangeInteractionMode(self):
+ """Test change of interaction mode"""
+ manager = roi.RegionOfInterestManager(self.plot)
+ self.roiTableWidget.setRegionOfInterestManager(manager)
+ manager.start(roi_items.PointROI)
+
+ interactiveModeToolBar = self.plot.getInteractiveModeToolBar()
+ panAction = interactiveModeToolBar.getPanModeAction()
+
+ for roiClass in manager.getSupportedRoiClasses():
+ with self.subTest(roiClass=roiClass):
+ # Change to pan mode
+ panAction.trigger()
+
+ # Change to interactive ROI mode
+ action = manager.getInteractionModeAction(roiClass)
+ action.trigger()
+
+ self.assertEqual(roiClass, manager.getCurrentInteractionModeRoiClass())
+
+ manager.clear()
+
+ def testLineInteraction(self):
+ """This test make sure that a ROI based on handles can be edited with
+ the mouse."""
+ xlimit = self.plot.getXAxis().getLimits()
+ ylimit = self.plot.getYAxis().getLimits()
+ points = numpy.array([xlimit, ylimit]).T
+ center = numpy.mean(points, axis=0)
+
+ # Create the line
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.LineROI()
+ item.setEndPoints(points[0], points[1])
+ item.setEditable(True)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+
+ # Drag the center
+ widget = self.plot.getWidgetHandle()
+ mx, my = self.plot.dataToPixel(*center)
+ self.mouseMove(widget, pos=(mx, my))
+ self.mousePress(widget, qt.Qt.LeftButton, pos=(mx, my))
+ self.mouseMove(widget, pos=(mx, my + 25))
+ self.mouseMove(widget, pos=(mx, my + 50))
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=(mx, my + 50))
+
+ result = numpy.array(item.getEndPoints())
+ # x location is still the same
+ numpy.testing.assert_allclose(points[:, 0], result[:, 0], atol=0.5)
+ # size is still the same
+ numpy.testing.assert_allclose(
+ points[1] - points[0], result[1] - result[0], atol=0.5
+ )
+ # But Y is not the same
+ self.assertNotEqual(points[0, 1], result[0, 1])
+ self.assertNotEqual(points[1, 1], result[1, 1])
+ item = None
+ manager.clear()
+ self.qapp.processEvents()
+
+ def testPlotWhenCleared(self):
+ """PlotWidget.clear should clean up the available ROIs"""
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.LineROI()
+ item.setEndPoints((0, 0), (1, 1))
+ item.setEditable(True)
+ manager.addRoi(item)
+ self.qWait()
+ try:
+ # Make sure the test setup is fine
+ self.assertNotEqual(len(manager.getRois()), 0)
+ self.assertNotEqual(len(self.plot.getItems()), 0)
+
+ # Call clear and test the expected state
+ self.plot.clear()
+ self.assertEqual(len(manager.getRois()), 0)
+ self.assertEqual(len(self.plot.getItems()), 0)
+ finally:
+ # Clean up
+ manager.clear()
+
+ def testPlotWhenRoiRemoved(self):
+ """Make sure there is no remaining items in the plot when a ROI is removed"""
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.LineROI()
+ item.setEndPoints((0, 0), (1, 1))
+ item.setEditable(True)
+ manager.addRoi(item)
+ self.qWait()
+ try:
+ # Make sure the test setup is fine
+ self.assertNotEqual(len(manager.getRois()), 0)
+ self.assertNotEqual(len(self.plot.getItems()), 0)
+
+ # Call clear and test the expected state
+ manager.removeRoi(item)
+ self.assertEqual(len(manager.getRois()), 0)
+ self.assertEqual(len(self.plot.getItems()), 0)
+ finally:
+ # Clean up
+ manager.clear()
+
+ def testArcRoiSwitchMode(self):
+ """Make sure we can switch mode by clicking on the ROI"""
+ xlimit = self.plot.getXAxis().getLimits()
+ ylimit = self.plot.getYAxis().getLimits()
+ points = numpy.array([xlimit, ylimit]).T
+ center = numpy.mean(points, axis=0)
+ size = numpy.abs(points[1] - points[0])
+
+ # Create the line
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.ArcROI()
+ item.setGeometry(center, size[1] / 10, size[1] / 2, 0, 3)
+ item.setEditable(True)
+ item.setSelectable(True)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+
+ # Initial state
+ self.assertIs(item.getInteractionMode(), roi_items.ArcROI.ThreePointMode)
+ self.qWait(500)
+
+ # Click on the center
+ widget = self.plot.getWidgetHandle()
+ mx, my = self.plot.dataToPixel(*center)
+
+ # Select the ROI
+ self.mouseMove(widget, pos=(mx, my))
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=(mx, my))
+ self.qWait(500)
+ self.assertIs(item.getInteractionMode(), roi_items.ArcROI.ThreePointMode)
+
+ # Change the mode
+ self.mouseMove(widget, pos=(mx, my))
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=(mx, my))
+ self.qWait(500)
+ self.assertIs(item.getInteractionMode(), roi_items.ArcROI.PolarMode)
+
+ manager.clear()
+ self.qapp.processEvents()
+
+ def testBandRoiSwitchMode(self):
+ """Make sure we can switch mode by clicking on the ROI"""
+ xlimit = self.plot.getXAxis().getLimits()
+ ylimit = self.plot.getYAxis().getLimits()
+ xcenter = 0.5 * (xlimit[0] + xlimit[1])
+ ycenter = 0.5 * (ylimit[0] + ylimit[1])
+
+ # Create the line
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.BandROI()
+ item.setGeometry(
+ (xlimit[0], ycenter),
+ (xlimit[1], ycenter),
+ 20,
+ )
+ item.setEditable(True)
+ item.setSelectable(True)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+
+ # Initial state
+ assert item.getInteractionMode() is roi_items.BandROI.BoundedMode
+ self.qWait(500)
+
+ # Click on the center
+ widget = self.plot.getWidgetHandle()
+ mx, my = self.plot.dataToPixel(xcenter, ycenter)
+
+ # Select the ROI
+ self.mouseMove(widget, pos=(mx, my))
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=(mx, my))
+ self.qWait(500)
+ assert item.getInteractionMode() is roi_items.BandROI.BoundedMode
+
+ # Change the mode
+ self.mouseMove(widget, pos=(mx, my))
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=(mx, my))
+ self.qWait(500)
+ assert item.getInteractionMode() is roi_items.BandROI.UnboundedMode
+
+ # Set available modes that exclude the current one
+ item.setAvailableInteractionModes([roi_items.BandROI.BoundedMode])
+ assert item.getInteractionMode() is roi_items.BandROI.BoundedMode
+
+ # Clicking does not change the mode since there is only one
+ self.mouseMove(widget, pos=(mx, my))
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=(mx, my))
+ self.qWait(500)
+ assert item.getInteractionMode() is roi_items.BandROI.BoundedMode
+
+ manager.clear()
+ self.qapp.processEvents()
diff --git a/src/silx/gui/plot/tools/test/testRoiItems.py b/src/silx/gui/plot/tools/test/testRoiItems.py
new file mode 100644
index 0000000..9bd9690
--- /dev/null
+++ b/src/silx/gui/plot/tools/test/testRoiItems.py
@@ -0,0 +1,313 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import pytest
+import numpy.testing
+
+import silx.gui.plot.items.roi as roi_items
+
+
+def testLine_geometry(qapp):
+ 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(qapp):
+ item = roi_items.HorizontalLineROI()
+ item.setPosition(15)
+ assert item.getPosition() == 15
+
+
+def testVLine_geometry(qapp):
+ item = roi_items.VerticalLineROI()
+ item.setPosition(15)
+ assert item.getPosition() == 15
+
+
+def testPoint_geometry(qapp):
+ point = numpy.array([1, 2])
+ item = roi_items.PointROI()
+ item.setPosition(point)
+ numpy.testing.assert_allclose(item.getPosition(), point)
+
+
+def testRectangle_originGeometry(qapp):
+ 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(qapp):
+ 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(qapp):
+ 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(qapp):
+ origin = numpy.array([0, 0])
+ size = numpy.array([10, 20])
+ item = roi_items.RectangleROI()
+ item.setGeometry(origin=origin, size=size)
+ newOrigin = numpy.array([10, 10])
+ item.setOrigin(newOrigin)
+ expectedCenter = numpy.array([15, 20])
+ numpy.testing.assert_allclose(item.getOrigin(), newOrigin)
+ numpy.testing.assert_allclose(item.getCenter(), expectedCenter)
+ numpy.testing.assert_allclose(item.getSize(), size)
+
+
+def testCircle_geometry(qapp):
+ center = numpy.array([0, 0])
+ radius = 10.0
+ item = roi_items.CircleROI()
+ item.setGeometry(center=center, radius=radius)
+ numpy.testing.assert_allclose(item.getCenter(), center)
+ numpy.testing.assert_allclose(item.getRadius(), radius)
+
+
+def testCircle_setCenter(qapp):
+ center = numpy.array([0, 0])
+ radius = 10.0
+ item = roi_items.CircleROI()
+ item.setGeometry(center=center, radius=radius)
+ newCenter = numpy.array([-10, 0])
+ item.setCenter(newCenter)
+ numpy.testing.assert_allclose(item.getCenter(), newCenter)
+ numpy.testing.assert_allclose(item.getRadius(), radius)
+
+
+def testCircle_setRadius(qapp):
+ center = numpy.array([0, 0])
+ radius = 10.0
+ item = roi_items.CircleROI()
+ item.setGeometry(center=center, radius=radius)
+ newRadius = 5.1
+ item.setRadius(newRadius)
+ numpy.testing.assert_allclose(item.getCenter(), center)
+ numpy.testing.assert_allclose(item.getRadius(), newRadius)
+
+
+def testCircle_contains(qapp):
+ center = numpy.array([2, -1])
+ radius = 1.0
+ item = roi_items.CircleROI()
+ item.setGeometry(center=center, radius=radius)
+ assert item.contains([1, -1])
+ assert not item.contains([0, 0])
+ assert item.contains([2, 0])
+ assert not item.contains([3.01, -1])
+
+
+def testEllipse_contains(qapp):
+ center = numpy.array([-2, 0])
+ item = roi_items.EllipseROI()
+ item.setCenter(center)
+ item.setOrientation(numpy.pi / 4.0)
+ item.setMajorRadius(2)
+ item.setMinorRadius(1)
+ print(item.getMinorRadius(), item.getMajorRadius())
+ assert not item.contains([0, 0])
+ assert item.contains([-1, 1])
+ assert item.contains([-3, 0])
+ assert item.contains([-2, 0])
+ assert item.contains([-2, 1])
+ assert not item.contains([-4, 1])
+
+
+def testRectangle_isIn(qapp):
+ origin = numpy.array([0, 0])
+ size = numpy.array([10, 20])
+ item = roi_items.RectangleROI()
+ item.setGeometry(origin=origin, size=size)
+ assert item.contains(position=(0, 0))
+ assert item.contains(position=(2, 14))
+ assert not item.contains(position=(14, 12))
+
+
+def testPolygon_emptyGeometry(qapp):
+ points = numpy.empty((0, 2))
+ item = roi_items.PolygonROI()
+ item.setPoints(points)
+ numpy.testing.assert_allclose(item.getPoints(), points)
+
+
+def testPolygon_geometry(qapp):
+ points = numpy.array([[10, 10], [12, 10], [50, 1]])
+ item = roi_items.PolygonROI()
+ item.setPoints(points)
+ numpy.testing.assert_allclose(item.getPoints(), points)
+
+
+def testPolygon_isIn(qapp):
+ points = numpy.array([[0, 0], [0, 10], [5, 10]])
+ item = roi_items.PolygonROI()
+ item.setPoints(points)
+ assert item.contains((0, 0))
+ assert not item.contains((6, 2))
+ assert not item.contains((-2, 5))
+ assert not item.contains((2, -1))
+ assert not item.contains((8, 1))
+ assert item.contains((1, 8))
+
+
+def testArc_getToSetGeometry(qapp):
+ """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(qapp):
+ 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(qapp):
+ 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(qapp):
+ 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)
+ assert item.getInnerRadius() == pytest.approx(innerRadius)
+ assert item.getOuterRadius() == pytest.approx(outerRadius)
+ assert item.getStartAngle() == pytest.approx(item.getEndAngle() - numpy.pi * 2.0)
+ assert item.isClosed()
+
+
+def testArc_special_donut(qapp):
+ 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)
+ assert item.getInnerRadius() == pytest.approx(innerRadius)
+ assert item.getOuterRadius() == pytest.approx(outerRadius)
+ assert item.getStartAngle() == pytest.approx(item.getEndAngle() - numpy.pi * 2.0)
+ assert item.isClosed()
+
+
+def testArc_clockwiseGeometry(qapp):
+ """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)
+ assert item.getInnerRadius() == pytest.approx(innerRadius)
+ assert item.getOuterRadius() == pytest.approx(outerRadius)
+ assert item.getStartAngle() == pytest.approx(startAngle)
+ assert item.getEndAngle() == pytest.approx(endAngle)
+ assert not item.isClosed()
+
+
+def testArc_anticlockwiseGeometry(qapp):
+ """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)
+ assert item.getInnerRadius() == pytest.approx(innerRadius)
+ assert item.getOuterRadius() == pytest.approx(outerRadius)
+ assert item.getStartAngle() == pytest.approx(startAngle)
+ assert item.getEndAngle() == pytest.approx(endAngle)
+ assert not item.isClosed()
+
+
+def testArc_position(qapp):
+ """Test validity of getPosition"""
+ 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)
+ assert item.getPosition(roi_items.ArcROI.Role.START) == pytest.approx((10.0, 70.5))
+ assert item.getPosition(roi_items.ArcROI.Role.STOP) == pytest.approx((-40.5, 20.0))
+ assert item.getPosition(roi_items.ArcROI.Role.MIDDLE) == pytest.approx(
+ (-25.71, 55.71), abs=0.1
+ )
+ assert item.getPosition(roi_items.ArcROI.Role.CENTER) == pytest.approx(
+ (10.0, 20), abs=0.1
+ )
+
+
+def testHRange_geometry(qapp):
+ item = roi_items.HorizontalRangeROI()
+ vmin = 1
+ vmax = 3
+ item.setRange(vmin, vmax)
+ assert item.getMin() == pytest.approx(vmin)
+ assert item.getMax() == pytest.approx(vmax)
+ assert item.getCenter() == pytest.approx(2)
+
+
+def testBand_getToSetGeometry(qapp):
+ """Test that we can use getGeometry as input to setGeometry"""
+ item = roi_items.BandROI()
+ item.setFirstShapePoints(numpy.array([[5, 10], [50, 100]]))
+ item.setGeometry(*item.getGeometry())
diff --git a/src/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/src/silx/gui/plot/tools/test/testScatterProfileToolBar.py
new file mode 100644
index 0000000..29c9ad0
--- /dev/null
+++ b/src/silx/gui/plot/tools/test/testScatterProfileToolBar.py
@@ -0,0 +1,185 @@
+# /*##########################################################################
+#
+# 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 numpy
+
+from silx.gui import qt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.plot import PlotWindow
+from silx.gui.plot.tools.profile import manager
+from silx.gui.plot.tools.profile import core
+from silx.gui.plot.tools.profile import rois
+
+
+class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase):
+ """Tests for ScatterProfileToolBar class"""
+
+ def setUp(self):
+ super(TestScatterProfileToolBar, self).setUp()
+ self.plot = PlotWindow()
+
+ self.manager = manager.ProfileManager(plot=self.plot)
+ self.manager.setItemType(scatter=True)
+ self.manager.setActiveItemTracking(True)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ del self.manager
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestScatterProfileToolBar, self).tearDown()
+
+ def testHorizontalProfile(self):
+ """Test ScatterProfileToolBar horizontal profile"""
+
+ roiManager = self.manager.getRoiManager()
+
+ # Add a scatter plot
+ self.plot.addScatter(
+ x=(0.0, 1.0, 1.0, 0.0), y=(0.0, 0.0, 1.0, 1.0), value=(0.0, 1.0, 2.0, 3.0)
+ )
+ self.plot.resetZoom(dataMargins=(0.1, 0.1, 0.1, 0.1))
+ self.qapp.processEvents()
+
+ # Set a ROI profile
+ roi = rois.ProfileScatterHorizontalLineROI()
+ roi.setPosition(0.5)
+ roi.setNPoints(8)
+ roiManager.addRoi(roi)
+
+ # Wait for async interpolator init
+ for _ in range(20):
+ self.qWait(200)
+ if not self.manager.hasPendingOperations():
+ break
+ self.qapp.processEvents()
+
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ data = window.getProfile()
+ self.assertIsInstance(data, core.CurveProfileData)
+ self.assertEqual(len(data.coords), 8)
+
+ # Check that profile has same limits than Plot
+ xLimits = self.plot.getXAxis().getLimits()
+ self.assertEqual(data.coords[0], xLimits[0])
+ self.assertEqual(data.coords[-1], xLimits[1])
+
+ # Clear the profile
+ self.manager.clearProfile()
+ self.qapp.processEvents()
+ self.assertIsNone(roi.getProfileWindow())
+
+ def testVerticalProfile(self):
+ """Test ScatterProfileToolBar vertical profile"""
+
+ roiManager = self.manager.getRoiManager()
+
+ # Add a scatter plot
+ self.plot.addScatter(
+ x=(0.0, 1.0, 1.0, 0.0), y=(0.0, 0.0, 1.0, 1.0), value=(0.0, 1.0, 2.0, 3.0)
+ )
+ self.plot.resetZoom(dataMargins=(0.1, 0.1, 0.1, 0.1))
+ self.qapp.processEvents()
+
+ # Set a ROI profile
+ roi = rois.ProfileScatterVerticalLineROI()
+ roi.setPosition(0.5)
+ roi.setNPoints(8)
+ roiManager.addRoi(roi)
+
+ # Wait for async interpolator init
+ for _ in range(10):
+ self.qWait(200)
+ if not self.manager.hasPendingOperations():
+ break
+
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ data = window.getProfile()
+ self.assertIsInstance(data, core.CurveProfileData)
+ self.assertEqual(len(data.coords), 8)
+
+ # Check that profile has same limits than Plot
+ yLimits = self.plot.getYAxis().getLimits()
+ self.assertEqual(data.coords[0], yLimits[0])
+ self.assertEqual(data.coords[-1], yLimits[1])
+
+ # Check that profile limits are updated when changing limits
+ self.plot.getYAxis().setLimits(yLimits[0] + 1, yLimits[1] + 10)
+
+ # Wait for async interpolator init
+ for _ in range(10):
+ self.qWait(200)
+ if not self.manager.hasPendingOperations():
+ break
+
+ yLimits = self.plot.getYAxis().getLimits()
+ data = window.getProfile()
+ self.assertEqual(data.coords[0], yLimits[0])
+ self.assertEqual(data.coords[-1], yLimits[1])
+
+ # Clear the profile
+ self.manager.clearProfile()
+ self.qapp.processEvents()
+ self.assertIsNone(roi.getProfileWindow())
+
+ def testLineProfile(self):
+ """Test ScatterProfileToolBar line profile"""
+
+ roiManager = self.manager.getRoiManager()
+
+ # Add a scatter plot
+ self.plot.addScatter(
+ x=(0.0, 1.0, 1.0, 0.0), y=(0.0, 0.0, 1.0, 1.0), value=(0.0, 1.0, 2.0, 3.0)
+ )
+ self.plot.resetZoom(dataMargins=(0.1, 0.1, 0.1, 0.1))
+ self.qapp.processEvents()
+
+ # Set a ROI profile
+ roi = rois.ProfileScatterLineROI()
+ roi.setEndPoints(numpy.array([0.0, 0.0]), numpy.array([1.0, 1.0]))
+ roi.setNPoints(8)
+ roiManager.addRoi(roi)
+
+ # Wait for async interpolator init
+ for _ in range(10):
+ self.qWait(200)
+ if not self.manager.hasPendingOperations():
+ break
+
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ data = window.getProfile()
+ self.assertIsInstance(data, core.CurveProfileData)
+ self.assertEqual(len(data.coords), 8)
diff --git a/src/silx/gui/plot/tools/test/testTools.py b/src/silx/gui/plot/tools/test/testTools.py
new file mode 100644
index 0000000..1212ead
--- /dev/null
+++ b/src/silx/gui/plot/tools/test/testTools.py
@@ -0,0 +1,135 @@
+# /*##########################################################################
+#
+# Copyright (c) 2016-2021 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Basic tests for silx.gui.plot.tools package"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/03/2018"
+
+
+import functools
+import numpy
+
+from silx.utils.testutils import LoggingValidator
+from silx.gui import qt
+from silx.gui.plot import PlotWindow
+from silx.gui.plot import tools
+from silx.gui.plot.test.utils import PlotWidgetTestCase
+
+
+class TestPositionInfo(PlotWidgetTestCase):
+ """Tests for PositionInfo widget."""
+
+ def _createPlot(self):
+ return PlotWindow()
+
+ def setUp(self):
+ super(TestPositionInfo, self).setUp()
+ self.mouseMove(self.plot, pos=(0, 0))
+ self.qapp.processEvents()
+ self.qWait(100)
+
+ def tearDown(self):
+ super(TestPositionInfo, self).tearDown()
+
+ def _test(self, positionWidget, converterNames, **kwargs):
+ """General test of PositionInfo.
+
+ - Add it to a toolbar and
+ - Move mouse around the center of the PlotWindow.
+ """
+ toolBar = qt.QToolBar()
+ self.plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar)
+
+ toolBar.addWidget(positionWidget)
+
+ converters = positionWidget.getConverters()
+ self.assertEqual(len(converters), len(converterNames))
+ for index, name in enumerate(converterNames):
+ self.assertEqual(converters[index][0], name)
+
+ self.qapp.processEvents()
+ with LoggingValidator(tools.__name__, **kwargs):
+ # Move mouse to center
+ center = self.plot.size() / 2
+ self.mouseMove(self.plot, pos=(center.width(), center.height()))
+ # Move out
+ self.mouseMove(self.plot, pos=(1, 1))
+
+ def testDefaultConverters(self):
+ """Test PositionInfo with default converters"""
+ positionWidget = tools.PositionInfo(plot=self.plot)
+ self._test(positionWidget, ("X", "Y"))
+
+ def testCustomConverters(self):
+ """Test PositionInfo with custom converters"""
+ converters = [
+ ("Coords", lambda x, y: (int(x), int(y))),
+ ("Radius", lambda x, y: numpy.sqrt(x * x + y * y)),
+ ("Angle", lambda x, y: numpy.degrees(numpy.arctan2(y, x))),
+ ]
+ positionWidget = tools.PositionInfo(plot=self.plot, converters=converters)
+ self._test(positionWidget, ("Coords", "Radius", "Angle"))
+
+ def testFailingConverters(self):
+ """Test PositionInfo with failing custom converters"""
+
+ def raiseException(x, y):
+ raise RuntimeError()
+
+ positionWidget = tools.PositionInfo(
+ plot=self.plot, converters=[("Exception", raiseException)]
+ )
+ self._test(positionWidget, ["Exception"], error=2)
+
+ def testUpdate(self):
+ """Test :meth:`PositionInfo.updateInfo`"""
+ calls = []
+
+ def update(calls, x, y): # Get number of calls
+ calls.append((x, y))
+ return len(calls)
+
+ positionWidget = tools.PositionInfo(
+ plot=self.plot,
+ converters=[("Call count", functools.partial(update, calls))],
+ )
+
+ positionWidget.updateInfo()
+ self.assertEqual(len(calls), 1)
+
+
+class TestPlotToolsToolbars(PlotWidgetTestCase):
+ """Tests toolbars from silx.gui.plot.tools"""
+
+ def test(self):
+ """ "Add all toolbars"""
+ for tbClass in (
+ tools.InteractiveModeToolBar,
+ tools.ImageToolBar,
+ tools.CurveToolBar,
+ tools.OutputToolBar,
+ ):
+ tb = tbClass(parent=self.plot, plot=self.plot)
+ self.plot.addToolBar(tb)
diff --git a/src/silx/gui/plot/tools/toolbars.py b/src/silx/gui/plot/tools/toolbars.py
new file mode 100644
index 0000000..7f38f1c
--- /dev/null
+++ b/src/silx/gui/plot/tools/toolbars.py
@@ -0,0 +1,357 @@
+# /*##########################################################################
+#
+# Copyright (c) 2018-2023 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._visualizationToolButton = PlotToolButtons.ScatterVisualizationToolButton(
+ parent=self, plot=plot
+ )
+ self.addWidget(self._visualizationToolButton)
+
+ def getResetZoomAction(self):
+ """Returns the QAction to reset the zoom.
+
+ :rtype: PlotAction
+ """
+ return self._resetZoomAction
+
+ def getXAxisLogarithmicAction(self):
+ """Returns the QAction to toggle X axis log/linear scale.
+
+ :rtype: PlotAction
+ """
+ return self._xAxisLogarithmicAction
+
+ def getYAxisLogarithmicAction(self):
+ """Returns the QAction to toggle Y axis log/linear scale.
+
+ :rtype: PlotAction
+ """
+ return self._yAxisLogarithmicAction
+
+ def getGridAction(self):
+ """Returns the action to toggle the plot grid.
+
+ :rtype: PlotAction
+ """
+ return self._gridAction
+
+ def getColormapAction(self):
+ """Returns the QAction to control the colormap.
+
+ :rtype: PlotAction
+ """
+ return self._colormapAction
+
+ def getKeepDataAspectRatioButton(self):
+ """Returns the QToolButton controlling data aspect ratio.
+
+ :rtype: QToolButton
+ """
+ return self._keepDataAspectRatioButton
+
+ def getScatterVisualizationToolButton(self):
+ """Returns the QToolButton controlling the visualization mode.
+
+ :rtype: ScatterVisualizationToolButton
+ """
+ return self._visualizationToolButton