summaryrefslogtreecommitdiff
path: root/silx/gui/plot
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2017-08-18 14:48:52 +0200
committerPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2017-08-18 14:48:52 +0200
commitf7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch)
tree9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx/gui/plot
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx/gui/plot')
-rw-r--r--silx/gui/plot/AlphaSlider.py300
-rw-r--r--silx/gui/plot/ColorBar.py790
-rw-r--r--silx/gui/plot/ColormapDialog.py506
-rw-r--r--silx/gui/plot/Colors.py359
-rw-r--r--silx/gui/plot/CurvesROIWidget.py975
-rw-r--r--silx/gui/plot/ImageView.py860
-rw-r--r--silx/gui/plot/Interaction.py300
-rw-r--r--silx/gui/plot/LegendSelector.py1087
-rw-r--r--silx/gui/plot/MPLColormap.py1062
-rw-r--r--silx/gui/plot/MaskToolsWidget.py615
-rw-r--r--silx/gui/plot/Plot.py2925
-rw-r--r--silx/gui/plot/PlotActions.py1386
-rw-r--r--silx/gui/plot/PlotEvents.py166
-rw-r--r--silx/gui/plot/PlotInteraction.py1493
-rw-r--r--silx/gui/plot/PlotToolButtons.py280
-rw-r--r--silx/gui/plot/PlotTools.py313
-rw-r--r--silx/gui/plot/PlotWidget.py267
-rw-r--r--silx/gui/plot/PlotWindow.py766
-rw-r--r--silx/gui/plot/Profile.py741
-rw-r--r--silx/gui/plot/ProfileMainWindow.py99
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py529
-rw-r--r--silx/gui/plot/StackView.py1033
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py1138
-rw-r--r--silx/gui/plot/__init__.py71
-rw-r--r--silx/gui/plot/_utils/__init__.py104
-rw-r--r--silx/gui/plot/_utils/panzoom.py156
-rw-r--r--silx/gui/plot/_utils/setup.py42
-rw-r--r--silx/gui/plot/_utils/test/__init__.py41
-rw-r--r--silx/gui/plot/_utils/test/test_ticklayout.py78
-rw-r--r--silx/gui/plot/_utils/ticklayout.py224
-rw-r--r--silx/gui/plot/backends/BackendBase.py474
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py821
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py1631
-rw-r--r--silx/gui/plot/backends/ModestImage.py174
-rw-r--r--silx/gui/plot/backends/__init__.py29
-rw-r--r--silx/gui/plot/backends/_matplotlib.py64
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py1317
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotFrame.py1039
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py707
-rw-r--r--silx/gui/plot/backends/glutils/GLSupport.py192
-rw-r--r--silx/gui/plot/backends/glutils/GLText.py222
-rw-r--r--silx/gui/plot/backends/glutils/GLTexture.py239
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py149
-rw-r--r--silx/gui/plot/backends/glutils/__init__.py44
-rw-r--r--silx/gui/plot/items/__init__.py43
-rw-r--r--silx/gui/plot/items/core.py839
-rw-r--r--silx/gui/plot/items/curve.py192
-rw-r--r--silx/gui/plot/items/histogram.py288
-rw-r--r--silx/gui/plot/items/image.py385
-rw-r--r--silx/gui/plot/items/marker.py241
-rw-r--r--silx/gui/plot/items/scatter.py169
-rw-r--r--silx/gui/plot/items/shape.py121
-rw-r--r--silx/gui/plot/setup.py47
-rw-r--r--silx/gui/plot/test/__init__.py71
-rw-r--r--silx/gui/plot/test/testAlphaSlider.py221
-rw-r--r--silx/gui/plot/test/testColorBar.py240
-rw-r--r--silx/gui/plot/test/testColormapDialog.py68
-rw-r--r--silx/gui/plot/test/testColors.py94
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py153
-rw-r--r--silx/gui/plot/test/testInteraction.py89
-rw-r--r--silx/gui/plot/test/testLegendSelector.py143
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py295
-rw-r--r--silx/gui/plot/test/testPlot.py633
-rw-r--r--silx/gui/plot/test/testPlotInteraction.py167
-rw-r--r--silx/gui/plot/test/testPlotTools.py203
-rw-r--r--silx/gui/plot/test/testPlotWidget.py967
-rw-r--r--silx/gui/plot/test/testPlotWindow.py138
-rw-r--r--silx/gui/plot/test/testProfile.py183
-rw-r--r--silx/gui/plot/test/testScatterMaskToolsWidget.py313
-rw-r--r--silx/gui/plot/test/testStackView.py209
70 files changed, 32320 insertions, 0 deletions
diff --git a/silx/gui/plot/AlphaSlider.py b/silx/gui/plot/AlphaSlider.py
new file mode 100644
index 0000000..ab2e5aa
--- /dev/null
+++ b/silx/gui/plot/AlphaSlider.py
@@ -0,0 +1,300 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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 defines slider widgets interacting with the transparency
+of an image on a :class:`PlotWidget`
+
+Classes:
+--------
+
+- :class:`BaseAlphaSlider` (abstract class)
+- :class:`NamedImageAlphaSlider`
+- :class:`ActiveImageAlphaSlider`
+
+Example:
+--------
+
+This widget can, for instance, be added to a plot toolbar.
+
+.. code-block:: python
+
+ import numpy
+ from silx.gui import qt
+ from silx.gui.plot import PlotWidget
+ from silx.gui.plot.ImageAlphaSlider import NamedImageAlphaSlider
+
+ app = qt.QApplication([])
+ pw = PlotWidget()
+
+ img0 = numpy.arange(200*150).reshape((200, 150))
+ pw.addImage(img0, legend="my background", z=0, origin=(50, 50))
+
+ x, y = numpy.meshgrid(numpy.linspace(-10, 10, 200),
+ numpy.linspace(-10, 5, 150),
+ indexing="ij")
+ img1 = numpy.asarray(numpy.sin(x * y) / (x * y),
+ dtype='float32')
+
+ pw.addImage(img1, legend="my data", z=1,
+ replace=False)
+
+ alpha_slider = NamedImageAlphaSlider(parent=pw,
+ plot=pw,
+ legend="my data")
+ alpha_slider.setOrientation(qt.Qt.Horizontal)
+
+ toolbar = qt.QToolBar("plot", pw)
+ toolbar.addWidget(alpha_slider)
+ pw.addToolBar(toolbar)
+
+ pw.show()
+ app.exec_()
+
+"""
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "24/03/2017"
+
+import logging
+
+from silx.gui import qt
+
+_logger = logging.getLogger(__name__)
+
+
+class BaseAlphaSlider(qt.QSlider):
+ """Slider widget to be used in a plot toolbar to control the
+ transparency of a plot primitive (image, scatter or curve).
+
+ Internally, the slider stores its state as an integer between
+ 0 and 255. This is the value emitted by the :attr:`valueChanged`
+ signal.
+
+ The method :meth:`getAlpha` returns the corresponding opacity/alpha
+ as a float between 0. and 1. (with a step of :math:`\frac{1}{255}`).
+
+ You must subclass this class and implement :meth:`getItem`.
+ """
+ sigAlphaChanged = qt.Signal(float)
+ """Emits the alpha value when the slider's value changes,
+ as a float between 0. and 1."""
+
+ def __init__(self, parent=None, plot=None):
+ """
+
+ :param parent: Parent QWidget
+ :param plot: Parent plot widget
+ """
+ assert plot is not None
+ super(BaseAlphaSlider, self).__init__(parent)
+
+ self.plot = plot
+
+ self.setRange(0, 255)
+
+ # if already connected to an item, use its alpha as initial value
+ if self.getItem() is None:
+ self.setValue(255)
+ self.setEnabled(False)
+ else:
+ alpha = self.getItem().getAlpha()
+ self.setValue(round(255*alpha))
+
+ self.valueChanged.connect(self._valueChanged)
+
+ def getItem(self):
+ """You must implement this class to define which item
+ to work on. It must return an item that inherits
+ :class:`silx.gui.plot.items.core.AlphaMixIn`.
+
+ :return: Item on which to operate, or None
+ :rtype: :class:`silx.plot.items.Item`
+ """
+ raise NotImplementedError(
+ "BaseAlphaSlider must be subclassed to " +
+ "implement getItem()")
+
+ def getAlpha(self):
+ """Get the opacity, as a float between 0. and 1.
+
+ :return: Alpha value in [0., 1.]
+ :rtype: float
+ """
+ return self.value() / 255.
+
+ def _valueChanged(self, value):
+ self._updateItem()
+ self.sigAlphaChanged.emit(value / 255.)
+
+ def _updateItem(self):
+ """Update the item's alpha channel.
+ """
+ item = self.getItem()
+ if item is not None:
+ item.setAlpha(self.getAlpha())
+
+
+class ActiveImageAlphaSlider(BaseAlphaSlider):
+ """Slider widget to be used in a plot toolbar to control the
+ transparency of the **active image**.
+
+ :param parent: Parent QWidget
+ :param plot: Plot on which to operate
+
+ See documentation of :class:`BaseAlphaSlider`
+ """
+ def __init__(self, parent=None, plot=None):
+ """
+
+ :param parent: Parent QWidget
+ :param plot: Plot widget on which to operate
+ """
+ super(ActiveImageAlphaSlider, self).__init__(parent, plot)
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+
+ def getItem(self):
+ return self.plot.getActiveImage()
+
+ def _activeImageChanged(self, previous, new):
+ """Activate or deactivate slider depending on presence of a new
+ active image.
+ Apply transparency value to new active image.
+
+ :param previous: Legend of previous active image, or None
+ :param new: Legend of new active image, or None
+ """
+ if new is not None and not self.isEnabled():
+ self.setEnabled(True)
+ elif new is None and self.isEnabled():
+ self.setEnabled(False)
+
+ self._updateItem()
+
+
+class NamedItemAlphaSlider(BaseAlphaSlider):
+ """Slider widget to be used in a plot toolbar to control the
+ transparency of an item (defined by its kind and legend).
+
+ :param parent: Parent QWidget
+ :param plot: Plot on which to operate
+ :param str kind: Kind of item whose transparency is to be
+ controlled: "scatter", "image" or "curve".
+ :param str legend: Legend of item whose transparency is to be
+ controlled.
+ """
+ def __init__(self, parent=None, plot=None,
+ kind=None, legend=None):
+ self._item_legend = legend
+ self._item_kind = kind
+
+ super(NamedItemAlphaSlider, self).__init__(parent, plot)
+
+ self._updateState()
+ plot.sigContentChanged.connect(self._onContentChanged)
+
+ def _onContentChanged(self, action, kind, legend):
+ if legend == self._item_legend and kind == self._item_kind:
+ if action == "add":
+ self.setEnabled(True)
+ elif action == "remove":
+ self.setEnabled(False)
+
+ def _updateState(self):
+ """Enable or disable widget based on item's availability."""
+ if self.getItem() is not None:
+ self.setEnabled(True)
+ else:
+ self.setEnabled(False)
+
+ def getItem(self):
+ """Return plot item currently associated to this widget (can be
+ a curve, an image, a scatter...)
+
+ :rtype: subclass of :class:`silx.gui.plot.items.Item`"""
+ if self._item_legend is None or self._item_kind is None:
+ return None
+ return self.plot._getItem(kind=self._item_kind,
+ legend=self._item_legend)
+
+ def setLegend(self, legend):
+ """Associate a different item (of the same kind) to the slider.
+
+ :param legend: New legend of item whose transparency is to be
+ controlled.
+ """
+ self._item_legend = legend
+ self._updateState()
+
+ def getLegend(self):
+ """Return legend of the item currently controlled by this slider.
+
+ :return: Image legend associated to the slider
+ """
+ return self._item_kind
+
+ def setItemKind(self, legend):
+ """Associate a different item (of the same kind) to the slider.
+
+ :param legend: New legend of item whose transparency is to be
+ controlled.
+ """
+ self._item_legend = legend
+ self._updateState()
+
+ def getItemKind(self):
+ """Return kind of the item currently controlled by this slider.
+
+ :return: Item kind ("image", "scatter"...)
+ :rtype: str on None
+ """
+ return self._item_kind
+
+
+class NamedImageAlphaSlider(NamedItemAlphaSlider):
+ """Slider widget to be used in a plot toolbar to control the
+ transparency of an image (defined by its legend).
+
+ :param parent: Parent QWidget
+ :param plot: Plot on which to operate
+ :param str legend: Legend of image whose transparency is to be
+ controlled.
+ """
+ def __init__(self, parent=None, plot=None, legend=None):
+ NamedItemAlphaSlider.__init__(self, parent, plot,
+ kind="image", legend=legend)
+
+
+class NamedScatterAlphaSlider(NamedItemAlphaSlider):
+ """Slider widget to be used in a plot toolbar to control the
+ transparency of a scatter (defined by its legend).
+
+ :param parent: Parent QWidget
+ :param plot: Plot on which to operate
+ :param str legend: Legend of scatter whose transparency is to be
+ controlled.
+ """
+ def __init__(self, parent=None, plot=None, legend=None):
+ NamedItemAlphaSlider.__init__(self, parent, plot,
+ kind="scatter", legend=legend)
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
new file mode 100644
index 0000000..93e3c36
--- /dev/null
+++ b/silx/gui/plot/ColorBar.py
@@ -0,0 +1,790 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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.
+#
+# ###########################################################################*/
+"""Module containing several widgets associated to a colormap.
+"""
+
+__authors__ = ["H. Payno", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/04/2017"
+
+
+import logging
+import numpy
+from ._utils import ticklayout
+from ._utils import clipColormapLogRange
+
+
+from .. import qt
+from silx.gui.plot import Colors
+
+_logger = logging.getLogger(__name__)
+
+
+class ColorBarWidget(qt.QWidget):
+ """Colorbar widget displaying a colormap
+
+ It uses a description of colormap as dict compatible with :class:`Plot`.
+
+ .. image:: img/linearColorbar.png
+ :width: 80px
+ :align: center
+
+ To run the following sample code, a QApplication must be initialized.
+
+ >>> from silx.gui.plot import Plot2D
+ >>> from silx.gui.plot.ColorBar import ColorBarWidget
+
+ >>> plot = Plot2D() # Create a plot widget
+ >>> plot.show()
+
+ >>> colorbar = ColorBarWidget(plot=plot, legend='Colormap') # Associate the colorbar with it
+ >>> colorbar.show()
+
+ Initializer parameters:
+
+ :param parent: See :class:`QWidget`
+ :param plot: PlotWidget the colorbar is attached to (optional)
+ :param str legend: the label to set to the colormap
+ """
+
+ def __init__(self, parent=None, plot=None, legend=None):
+ super(ColorBarWidget, self).__init__(parent)
+ self._plot = None
+
+ self.__buildGUI()
+ self.setLegend(legend)
+ self.setPlot(plot)
+
+ def __buildGUI(self):
+ self.setLayout(qt.QHBoxLayout())
+
+ # create color scale widget
+ self._colorScale = ColorScaleBar(parent=self,
+ colormap=None)
+ self.layout().addWidget(self._colorScale)
+
+ # legend (is the right group)
+ self.legend = _VerticalLegend('', self)
+ self.layout().addWidget(self.legend)
+
+ self.layout().setSizeConstraint(qt.QLayout.SetMinAndMaxSize)
+ self.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Expanding)
+ self.layout().setContentsMargins(0, 0, 0, 0)
+
+ def getPlot(self):
+ """Returns the :class:`Plot` associated to this widget or None"""
+ return self._plot
+
+ def setPlot(self, plot):
+ """Associate a plot to the ColorBar
+
+ :param plot: the plot to associate with the colorbar. If None will remove
+ any connection with a previous plot.
+ """
+ # removing previous plot if any
+ if self._plot is not None:
+ self._plot.sigActiveImageChanged.disconnect(self._activeImageChanged)
+
+ # setting the new plot
+ self._plot = plot
+ if self._plot is not None:
+ self._plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ self._activeImageChanged(self._plot.getActiveImage(just_legend=True))
+
+ def getColormap(self):
+ """Return the colormap displayed in the colorbar as a dict.
+
+ It returns None if no colormap is set.
+ See :class:`silx.gui.plot.Plot` documentation for the description of the colormap
+ dict description.
+ """
+ return self._colormap.copy()
+
+ def setColormap(self, colormap):
+ """Set the colormap to be displayed.
+
+ :param dict colormap: The colormap to apply on the ColorBarWidget
+ """
+ self._colormap = colormap
+ if self._colormap is None:
+ return
+
+ if self._colormap['normalization'] not in ('log', 'linear'):
+ raise ValueError('Wrong normalization %s' % self._colormap['normalization'])
+
+ if self._colormap['normalization'] is 'log':
+ if self._colormap['vmin'] < 1. or self._colormap['vmax'] < 1.:
+ _logger.warning('Log colormap with bound <= 1: changing bounds.')
+ clipColormapLogRange(colormap)
+
+ self.getColorScaleBar().setColormap(self._colormap)
+
+ def setLegend(self, legend):
+ """Set the legend displayed along the colorbar
+
+ :param str legend: The label
+ """
+ if legend is None or legend == "":
+ self.legend.hide()
+ self.legend.setText("")
+ else:
+ assert(type(legend) is str)
+ self.legend.show()
+ self.legend.setText(legend)
+
+ def getLegend(self):
+ """
+ Returns the legend displayed along the colorbar
+
+ :return: return the legend displayed along the colorbar
+ :rtype: str
+ """
+ return self.legend.getText()
+
+ def _activeImageChanged(self, legend):
+ """Handle plot active curve changed"""
+ if legend is None: # No active image, display default colormap
+ self._syncWithDefaultColormap()
+ return
+
+ # Sync with active image
+ image = self._plot.getActiveImage().getData(copy=False)
+
+ # RGB(A) image, display default colormap
+ if image.ndim != 2:
+ self._syncWithDefaultColormap()
+ return
+
+ # data image, sync with image colormap
+ # do we need the copy here : used in the case we are changing
+ # vmin and vmax but should have already be done by the plot
+ cmap = self._plot.getActiveImage().getColormap().copy()
+ if cmap['autoscale']:
+ if cmap['normalization'] == 'log':
+ data = image[
+ numpy.logical_and(image > 0, numpy.isfinite(image))]
+ else:
+ data = image[numpy.isfinite(image)]
+ cmap['vmin'], cmap['vmax'] = data.min(), data.max()
+
+ self.setColormap(cmap)
+
+ def _defaultColormapChanged(self):
+ """Handle plot default colormap changed"""
+ if self._plot.getActiveImage() is None:
+ # No active image, take default colormap update into account
+ self._syncWithDefaultColormap()
+
+ def _syncWithDefaultColormap(self):
+ """Update colorbar according to plot default colormap"""
+ self.setColormap(self._plot.getDefaultColormap())
+
+ def getColorScaleBar(self):
+ """
+
+ :return: return the :class:`ColorScaleBar` used to display ColorScale
+ and ticks"""
+ return self._colorScale
+
+
+class _VerticalLegend(qt.QLabel):
+ """Display vertically the given text
+ """
+ def __init__(self, text, parent=None):
+ """
+
+ :param text: the legend
+ :param parent: the Qt parent if any
+ """
+ qt.QLabel.__init__(self, text, parent)
+ self.setLayout(qt.QVBoxLayout())
+ self.layout().setContentsMargins(0, 0, 0, 0)
+
+ def paintEvent(self, event):
+ painter = qt.QPainter(self)
+ painter.setFont(self.font())
+
+ painter.translate(0, self.rect().height())
+ painter.rotate(270)
+ newRect = qt.QRect(0, 0, self.rect().height(), self.rect().width())
+
+ painter.drawText(newRect, qt.Qt.AlignHCenter, self.text())
+
+ fm = qt.QFontMetrics(self.font())
+ preferedHeight = fm.width(self.text())
+ preferedWidth = fm.height()
+ self.setFixedWidth(preferedWidth)
+ self.setMinimumHeight(preferedHeight)
+
+
+class ColorScaleBar(qt.QWidget):
+ """This class is making the composition of a :class:`_ColorScale` and a
+ :class:`_TickBar`.
+
+ It is the simplest widget displaying ticks and colormap gradient.
+
+ .. image:: img/colorScaleBar.png
+ :width: 150px
+ :align: center
+
+ To run the following sample code, a QApplication must be initialized.
+
+ >>> colormap={'name':'gray',
+ ... 'normalization':'log',
+ ... 'vmin':1,
+ ... 'vmax':100000,
+ ... 'autoscale':False
+ ... }
+ >>> colorscale = ColorScaleBar(parent=None,
+ ... colormap=colormap )
+ >>> colorscale.show()
+
+ Initializer parameters :
+
+ :param colormap: the colormap to be displayed
+ :param parent: the Qt parent if any
+ :param displayTicksValues: display the ticks value or only the '-'
+ """
+
+ _TEXT_MARGIN = 5
+ """The tick bar need a margin to display all labels at the correct place.
+ So the ColorScale should have the same margin in order for both to fit"""
+
+ _MIN_LIM_SCI_FORM = -1000
+ """Used for the min and max label to know when we should display it under
+ the scientific form"""
+
+ _MAX_LIM_SCI_FORM = 1000
+ """Used for the min and max label to know when we should display it under
+ the scientific form"""
+
+ def __init__(self, parent=None, colormap=None, displayTicksValues=True):
+ super(ColorScaleBar, self).__init__(parent)
+
+ self.minVal = None
+ """Value set to the _minLabel"""
+ self.maxVal = None
+ """Value set to the _maxLabel"""
+
+ self.setLayout(qt.QGridLayout())
+
+ # create the left side group (ColorScale)
+ self.colorScale = _ColorScale(colormap=colormap,
+ parent=self,
+ margin=ColorScaleBar._TEXT_MARGIN)
+
+ self.tickbar = _TickBar(vmin=colormap['vmin'] if colormap else 0.0,
+ vmax=colormap['vmax'] if colormap else 1.0,
+ norm=colormap['normalization'] if colormap else 'linear',
+ parent=self,
+ displayValues=displayTicksValues,
+ margin=ColorScaleBar._TEXT_MARGIN)
+
+ self.layout().addWidget(self.tickbar, 1, 0)
+ self.layout().addWidget(self.colorScale, 1, 1)
+
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self.layout().setSpacing(0)
+
+ # max label
+ self._maxLabel = qt.QLabel(str(1.0), parent=self)
+ self._maxLabel.setAlignment(qt.Qt.AlignHCenter)
+ self._maxLabel.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum)
+ self.layout().addWidget(self._maxLabel, 0, 1)
+
+ # min label
+ self._minLabel = qt.QLabel(str(0.0), parent=self)
+ self._minLabel.setAlignment(qt.Qt.AlignHCenter)
+ self._minLabel.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum)
+ self.layout().addWidget(self._minLabel, 2, 1)
+
+ def getTickBar(self):
+ """
+
+ :return: the instanciation of the :class:`_TickBar`
+ """
+ return self.tickbar
+
+ def getColorScale(self):
+ """
+
+ :return: the instanciation of the :class:`_ColorScale`
+ """
+ return self.colorScale
+
+ def setColormap(self, colormap):
+ """Set the new colormap to be displayed
+
+ :param dict colormap: the colormap to set
+ """
+ if colormap is not None:
+ self.colorScale.setColormap(colormap)
+
+ self.tickbar.update(vmin=colormap['vmin'],
+ vmax=colormap['vmax'],
+ norm=colormap['normalization'])
+
+ self._setMinMaxLabels(colormap['vmin'], colormap['vmax'])
+
+ def setMinMaxVisible(self, val=True):
+ """Change visibility of the min label and the max label
+
+ :param val: if True, set the labels visible, otherwise set it not visible
+ """
+ self._maxLabel.show() if val is True else self._maxLabel.hide()
+ self._minLabel.show() if val is True else self._minLabel.hide()
+
+ def _updateMinMax(self):
+ """Update the min and max label if we are in the case of the
+ configuration 'minMaxValueOnly'"""
+ if self._minLabel is not None and self._maxLabel is not None:
+ if self.minVal is not None:
+ if ColorScaleBar._MIN_LIM_SCI_FORM <= self.minVal <= ColorScaleBar._MAX_LIM_SCI_FORM:
+ self._minLabel.setText(str(self.minVal))
+ else:
+ self._minLabel.setText("{0:.0e}".format(self.minVal))
+ if self.maxVal is not None:
+ if ColorScaleBar._MIN_LIM_SCI_FORM <= self.maxVal <= ColorScaleBar._MAX_LIM_SCI_FORM:
+ self._maxLabel.setText(str(self.maxVal))
+ else:
+ self._maxLabel.setText("{0:.0e}".format(self.maxVal))
+
+ def _setMinMaxLabels(self, minVal, maxVal):
+ """Change the value of the min and max labels to be displayed.
+
+ :param minVal: the minimal value of the TickBar (not str)
+ :param maxVal: the maximal value of the TickBar (not str)
+ """
+ # bad hack to try to display has much information as possible
+ self.minVal = minVal
+ self.maxVal = maxVal
+ self._updateMinMax()
+
+ def resizeEvent(self, event):
+ qt.QWidget.resizeEvent(self, event)
+ self._updateMinMax()
+
+
+class _ColorScale(qt.QWidget):
+ """Widget displaying the colormap colorScale.
+
+ Show matching value between the gradient color (from the colormap) at mouse
+ position and value.
+
+ .. image:: img/colorScale.png
+ :width: 20px
+ :align: center
+
+
+ To run the following sample code, a QApplication must be initialized.
+
+ >>> colormap={'name':'viridis',
+ ... 'normalization':'log',
+ ... 'vmin':1,
+ ... 'vmax':100000,
+ ... 'autoscale':False
+ ... }
+ >>> colorscale = ColorScale(parent=None,
+ ... colormap=colormap)
+ >>> colorscale.show()
+
+ Initializer parameters :
+
+ :param colormap: the colormap to be displayed
+ :param parent: the Qt parent if any
+ :param int margin: the top and left margin to apply.
+
+ .. warning:: Value drawing will be
+ done at the center of ticks. So if no margin is done your values
+ drawing might not be fully done for extrems values.
+ """
+
+ _NB_CONTROL_POINTS = 256
+
+ def __init__(self, colormap, parent=None, margin=5):
+ qt.QWidget.__init__(self, parent)
+ self.colormap = None
+ self.setColormap(colormap)
+
+ self.setLayout(qt.QVBoxLayout())
+ self.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding)
+ # needed to get the mouse event without waiting for button click
+ self.setMouseTracking(True)
+ self.setMargin(margin)
+ self.setContentsMargins(0, 0, 0, 0)
+
+ def setColormap(self, colormap):
+ """Set the new colormap to be displayed
+
+ :param dict colormap: the colormap to set
+ """
+ if colormap is None:
+ return
+
+ if colormap['normalization'] not in ('log', 'linear'):
+ raise ValueError("Unrecognized normalization, should be 'linear' or 'log'")
+
+ if colormap['normalization'] is 'log':
+ if not (colormap['vmin'] > 0 and colormap['vmax'] > 0):
+ raise ValueError('vmin and vmax should be positives')
+ self.colormap = colormap
+ self._computeColorPoints()
+
+ def _computeColorPoints(self):
+ """Compute the color points for the gradient
+ """
+ if self.colormap is None:
+ return
+
+ vmin = self.colormap['vmin']
+ vmax = self.colormap['vmax']
+ steps = (vmax - vmin)/float(_ColorScale._NB_CONTROL_POINTS)
+ self.ctrPoints = numpy.arange(vmin, vmax, steps)
+ self.colorsCtrPts = Colors.applyColormapToData(self.ctrPoints,
+ name=self.colormap['name'],
+ normalization='linear',
+ autoscale=self.colormap['autoscale'],
+ vmin=vmin,
+ vmax=vmax)
+
+ def paintEvent(self, event):
+ """"""
+ qt.QWidget.paintEvent(self, event)
+ if self.colormap is None:
+ return
+
+ vmin = self.colormap['vmin']
+ vmax = self.colormap['vmax']
+
+ painter = qt.QPainter(self)
+ gradient = qt.QLinearGradient(0, 0, 0, self.rect().height() - 2*self.margin)
+ for iPt, pt in enumerate(self.ctrPoints):
+ colormapPosition = 1 - (pt-vmin) / (vmax-vmin)
+ assert(colormapPosition >= 0.0)
+ assert(colormapPosition <= 1.0)
+ gradient.setColorAt(colormapPosition, qt.QColor(*(self.colorsCtrPts[iPt])))
+
+ painter.setBrush(gradient)
+ painter.drawRect(
+ qt.QRect(0, self.margin, self.width(), self.height() - 2.*self.margin))
+
+ def mouseMoveEvent(self, event):
+ """"""
+ self.setToolTip(str(self.getValueFromRelativePosition(self._getRelativePosition(event.y()))))
+ super(_ColorScale, self).mouseMoveEvent(event)
+
+ def _getRelativePosition(self, yPixel):
+ """yPixel : pixel position into _ColorScale widget reference
+ """
+ # widgets are bottom-top referencial but we display in top-bottom referential
+ return 1 - float(yPixel)/float(self.height() - 2*self.margin)
+
+ def getValueFromRelativePosition(self, value):
+ """Return the value in the colorMap from a relative position in the
+ ColorScaleBar (y)
+
+ :param value: float value in [0, 1]
+ :return: the value in [colormap['vmin'], colormap['vmax']]
+ """
+ value = max(0.0, value)
+ value = min(value, 1.0)
+ vmin = self.colormap['vmin']
+ vmax = self.colormap['vmax']
+ if self.colormap['normalization'] is 'linear':
+ return vmin + (vmax - vmin) * value
+ elif self.colormap['normalization'] is 'log':
+ rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin)
+ return numpy.power(10., rpos)
+ else:
+ err = "normalization type (%s) is not managed by the _ColorScale Widget" % self.colormap['normalization']
+ raise ValueError(err)
+
+ def setMargin(self, margin):
+ """Define the margin to fit with a TickBar object.
+ This is needed since we can only paint on the viewport of the widget.
+ Didn't work with a simple setContentsMargins
+
+ :param int margin: the margin to apply on the top and bottom.
+ """
+ self.margin = margin
+
+
+class _TickBar(qt.QWidget):
+ """Bar grouping the ticks displayed
+
+ To run the following sample code, a QApplication must be initialized.
+
+ >>> bar = TickBar(1, 1000, norm='log', parent=None, displayValues=True)
+ >>> bar.show()
+
+ .. image:: img/tickbar.png
+ :width: 40px
+ :align: center
+
+ :param int vmin: smaller value of the range of values
+ :param int vmax: higher value of the range of values
+ :param str norm: normalization type to be displayed. Valid values are
+ 'linear' and 'log'
+ :param parent: the Qt parent if any
+ :param bool displayValues: if True display the values close to the tick,
+ Otherwise only signal it by '-'
+ :param int nticks: the number of tick we want to display. Should be an
+ unsigned int ot None. If None, let the Tick bar find the optimal
+ number of ticks from the tick density.
+ :param int margin: margin to set on the top and bottom
+ """
+ _WIDTH_DISP_VAL = 45
+ """widget width when displayed with ticks labels"""
+ _WIDTH_NO_DISP_VAL = 10
+ """widget width when displayed without ticks labels"""
+ _FONT_SIZE = 10
+ """font size for ticks labels"""
+ _LINE_WIDTH = 10
+ """width of the line to mark a tick"""
+
+ DEFAULT_TICK_DENSITY = 0.015
+
+ def __init__(self, vmin, vmax, norm, parent=None, displayValues=True,
+ nticks=None, margin=5):
+ super(_TickBar, self).__init__(parent)
+ self._forcedDisplayType = None
+ self.ticksDensity = _TickBar.DEFAULT_TICK_DENSITY
+
+ self._vmin = vmin
+ self._vmax = vmax
+ # TODO : should be grouped into a global function, called by all
+ # logScale displayer to make sure we have the same behavior everywhere
+ if self._vmin < 1. or self._vmax < 1.:
+ _logger.warning(
+ 'Log colormap with bound <= 1: changing bounds.')
+ self._vmin, self._vmax = 1., 10.
+
+ self._norm = norm
+ self.displayValues = displayValues
+ self.setTicksNumber(nticks)
+ self.setMargin(margin)
+
+ self.setLayout(qt.QVBoxLayout())
+ self.setMargin(margin)
+ self.setContentsMargins(0, 0, 0, 0)
+
+ self._resetWidth()
+
+ def setTicksValuesVisible(self, val):
+ self.displayValues = val
+ self._resetWidth()
+
+ def _resetWidth(self):
+ self.width = _TickBar._WIDTH_DISP_VAL if self.displayValues else _TickBar._WIDTH_NO_DISP_VAL
+ self.setFixedWidth(self.width)
+
+ def update(self, vmin, vmax, norm):
+ self._vmin = vmin
+ self._vmax = vmax
+ self._norm = norm
+ self.computeTicks()
+ qt.QWidget.update(self)
+
+ def setMargin(self, margin):
+ """Define the margin to fit with a _ColorScale object.
+ This is needed since we can only paint on the viewport of the widget
+
+ :param int margin: the margin to apply on the top and bottom.
+ """
+ self.margin = margin
+
+ def setTicksNumber(self, nticks):
+ """Set the number of ticks to display.
+
+ :param nticks: the number of tick to be display. Should be an
+ unsigned int ot None. If None, let the :class:`_TickBar` find the
+ optimal number of ticks from the tick density.
+ """
+ self._nticks = nticks
+ self.ticks = None
+ self.computeTicks()
+ qt.QWidget.update(self)
+
+ def setTicksDensity(self, density):
+ """If you let :class:`_TickBar` deal with the number of ticks
+ (nticks=None) then you can specify a ticks density to be displayed.
+ """
+ if density < 0.0:
+ raise ValueError('Density should be a positive value')
+ self.ticksDensity = density
+
+ def computeTicks(self):
+ """This function compute ticks values labels. It is called at each
+ update and each resize event.
+ Deal only with linear and log scale.
+ """
+ nticks = self._nticks
+ if nticks is None:
+ nticks = self._getOptimalNbTicks()
+
+ if self._norm == 'log':
+ self._computeTicksLog(nticks)
+ elif self._norm == 'linear':
+ self._computeTicksLin(nticks)
+ else:
+ err = 'TickBar - Wrong normalization %s' % self._norm
+ raise ValueError(err)
+ # update the form
+ font = qt.QFont()
+ font.setPixelSize(_TickBar._FONT_SIZE)
+
+ self.form = self._getFormat(font)
+
+ def _computeTicksLog(self, nticks):
+ logMin = numpy.log10(self._vmin)
+ logMax = numpy.log10(self._vmax)
+ lowBound, highBound, spacing, self._nfrac = ticklayout.niceNumbersForLog10(logMin,
+ logMax,
+ nticks)
+ self.ticks = numpy.power(10., numpy.arange(lowBound, highBound, spacing))
+ if spacing == 1:
+ self.subTicks = ticklayout.computeLogSubTicks(ticks=self.ticks,
+ lowBound=numpy.power(10., lowBound),
+ highBound=numpy.power(10., highBound))
+ else:
+ self.subTicks = []
+
+ def resizeEvent(self, event):
+ qt.QWidget.resizeEvent(self, event)
+ self.computeTicks()
+
+ def _computeTicksLin(self, nticks):
+ _min, _max, _spacing, self._nfrac = ticklayout.niceNumbers(self._vmin,
+ self._vmax,
+ nticks)
+
+ self.ticks = numpy.arange(_min, _max, _spacing)
+ self.subTicks = []
+
+ def _getOptimalNbTicks(self):
+ return max(2, int(round(self.ticksDensity * self.rect().height())))
+
+ def paintEvent(self, event):
+ painter = qt.QPainter(self)
+ font = painter.font()
+ font.setPixelSize(_TickBar._FONT_SIZE)
+ painter.setFont(font)
+
+ # paint ticks
+ if self.ticks is not None:
+ for val in self.ticks:
+ self._paintTick(val, painter, majorTick=True)
+
+ # paint subticks
+ for val in self.subTicks:
+ self._paintTick(val, painter, majorTick=False)
+
+ qt.QWidget.paintEvent(self, event)
+
+ def _getRelativePosition(self, val):
+ """Return the relative position of val according to min and max value
+ """
+ if self._norm == 'linear':
+ return 1 - (val - self._vmin) / (self._vmax - self._vmin)
+ elif self._norm == 'log':
+ return 1 - (numpy.log10(val) - numpy.log10(self._vmin))/(numpy.log10(self._vmax) - numpy.log(self._vmin))
+ else:
+ raise ValueError('Norm is not recognized')
+
+ def _paintTick(self, val, painter, majorTick=True):
+ """
+
+ :param bool majorTick: if False will never draw text and will set a line
+ with a smaller width
+ """
+ fm = qt.QFontMetrics(painter.font())
+ viewportHeight = self.rect().height() - self.margin * 2
+ relativePos = self._getRelativePosition(val)
+ height = viewportHeight * relativePos
+ height += self.margin
+ lineWidth = _TickBar._LINE_WIDTH
+ if majorTick is False:
+ lineWidth /= 2
+
+ painter.drawLine(qt.QLine(self.width - lineWidth,
+ height,
+ self.width,
+ height))
+
+ if self.displayValues and majorTick is True:
+ painter.drawText(qt.QPoint(0.0, height + (fm.height() / 2)),
+ self.form.format(val))
+
+ def setDisplayType(self, disType):
+ """Set the type of display we want to set for ticks labels
+
+ :param str disType: The type of display we want to set. disType values
+ can be :
+
+ - 'std' for standard, meaning only a formatting on the number of
+ digits is done
+ - 'e' for scientific display
+ - None to let the _TickBar guess the best display for this kind of data.
+ """
+ if disType not in (None, 'std', 'e'):
+ raise ValueError("display type not recognized, value should be in (None, 'std', 'e'")
+ self._forcedDisplayType = disType
+
+ def _getStandardFormat(self):
+ return "{0:.%sf}" % self._nfrac
+
+ def _getFormat(self, font):
+ if self._forcedDisplayType is None:
+ return self._guessType(font)
+ elif self._forcedDisplayType is 'std':
+ return self._getStandardFormat()
+ elif self._forcedDisplayType is 'e':
+ return self._getScientificForm()
+ else:
+ err = 'Forced type for display %s is not recognized' % self._forcedDisplayType
+ raise ValueError(err)
+
+ def _getScientificForm(self):
+ return "{0:.0e}"
+
+ def _guessType(self, font):
+ """Try fo find the better format to display the tick's labels
+
+ :param QFont font: the font we want want to use durint the painting
+ """
+ assert(type(self._vmin) == type(self._vmax))
+ form = self._getStandardFormat()
+
+ fm = qt.QFontMetrics(font)
+ width = 0
+ for tick in self.ticks:
+ width = max(fm.width(form.format(tick)), width)
+
+ # if the length of the string are too long we are mooving to scientific
+ # display
+ if width > _TickBar._WIDTH_DISP_VAL - _TickBar._LINE_WIDTH:
+ return self._getScientificForm()
+ else:
+ return form
diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py
new file mode 100644
index 0000000..ad1425c
--- /dev/null
+++ b/silx/gui/plot/ColormapDialog.py
@@ -0,0 +1,506 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2016 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 QDialog widget to set-up the colormap.
+
+It uses a description of colormaps as dict compatible with :class:`Plot`.
+
+To run the following sample code, a QApplication must be initialized.
+
+Create the colormap dialog and set the colormap description and data range:
+
+>>> from silx.gui.plot.ColormapDialog import ColormapDialog
+
+>>> dialog = ColormapDialog()
+
+>>> dialog.setColormap(name='red', normalization='log',
+... autoscale=False, vmin=1., vmax=2.)
+>>> dialog.setDataRange(1., 100.) # This scale the width of the plot area
+>>> dialog.show()
+
+Get the colormap description (compatible with :class:`Plot`) from the dialog:
+
+>>> cmap = dialog.getColormap()
+>>> cmap['name']
+'red'
+
+It is also possible to display an histogram of the image in the dialog.
+This updates the data range with the range of the bins.
+
+>>> import numpy
+>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1)
+>>> hist, bin_edges = numpy.histogram(image, bins=10)
+>>> dialog.setHistogram(hist, bin_edges)
+
+The updates of the colormap description are also available through the signal:
+:attr:`ColormapDialog.sigColormapChanged`.
+""" # noqa
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "29/03/2016"
+
+
+import logging
+
+import numpy
+
+from .. import qt
+from . import PlotWidget
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _FloatEdit(qt.QLineEdit):
+ """Field to edit a float value.
+
+ :param parent: See :class:`QLineEdit`
+ :param float value: The value to set the QLineEdit to.
+ """
+ def __init__(self, parent=None, value=None):
+ qt.QLineEdit.__init__(self, parent)
+ self.setValidator(qt.QDoubleValidator())
+ self.setAlignment(qt.Qt.AlignRight)
+ if value is not None:
+ self.setValue(value)
+
+ def value(self):
+ """Return the QLineEdit current value as a float."""
+ return float(self.text())
+
+ def setValue(self, value):
+ """Set the current value of the LineEdit
+
+ :param float value: The value to set the QLineEdit to.
+ """
+ self.setText('%g' % value)
+
+
+class ColormapDialog(qt.QDialog):
+ """A QDialog widget to set the colormap.
+
+ :param parent: See :class:`QDialog`
+ :param str title: The QDialog title
+ """
+
+ sigColormapChanged = qt.Signal(dict)
+ """Signal triggered when the colormap is changed.
+
+ It provides a dict describing the colormap to the slot.
+ This dict can be used with :class:`Plot`.
+ """
+
+ def __init__(self, parent=None, title="Colormap Dialog"):
+ qt.QDialog.__init__(self, parent)
+ self.setWindowTitle(title)
+
+ self._histogramData = None
+ self._dataRange = None
+ self._minMaxWasEdited = False
+
+ self._colormapList = (
+ 'gray', 'reversed gray',
+ 'temperature', 'red', 'green', 'blue', 'jet',
+ 'viridis', 'magma', 'inferno', 'plasma')
+
+ # Make the GUI
+ vLayout = qt.QVBoxLayout(self)
+
+ formWidget = qt.QWidget()
+ vLayout.addWidget(formWidget)
+ formLayout = qt.QFormLayout(formWidget)
+ formLayout.setContentsMargins(10, 10, 10, 10)
+ formLayout.setSpacing(0)
+
+ # Colormap row
+ self._comboBoxColormap = qt.QComboBox()
+ for cmap in self._colormapList:
+ # Capitalize first letters
+ cmap = ' '.join(w[0].upper() + w[1:] for w in cmap.split())
+ self._comboBoxColormap.addItem(cmap)
+ self._comboBoxColormap.activated[int].connect(self._notify)
+ formLayout.addRow('Colormap:', self._comboBoxColormap)
+
+ # Normalization row
+ self._normButtonLinear = qt.QRadioButton('Linear')
+ self._normButtonLinear.setChecked(True)
+ self._normButtonLog = qt.QRadioButton('Log')
+
+ normButtonGroup = qt.QButtonGroup(self)
+ normButtonGroup.setExclusive(True)
+ normButtonGroup.addButton(self._normButtonLinear)
+ normButtonGroup.addButton(self._normButtonLog)
+ normButtonGroup.buttonClicked[int].connect(self._notify)
+
+ normLayout = qt.QHBoxLayout()
+ normLayout.setContentsMargins(0, 0, 0, 0)
+ normLayout.setSpacing(10)
+ normLayout.addWidget(self._normButtonLinear)
+ normLayout.addWidget(self._normButtonLog)
+
+ formLayout.addRow('Normalization:', normLayout)
+
+ # Range row
+ self._rangeAutoscaleButton = qt.QCheckBox('Autoscale')
+ self._rangeAutoscaleButton.setChecked(True)
+ self._rangeAutoscaleButton.toggled.connect(self._autoscaleToggled)
+ self._rangeAutoscaleButton.clicked.connect(self._notify)
+ formLayout.addRow('Range:', self._rangeAutoscaleButton)
+
+ # Min row
+ self._minValue = _FloatEdit(value=1.)
+ self._minValue.setEnabled(False)
+ self._minValue.textEdited.connect(self._minMaxTextEdited)
+ self._minValue.editingFinished.connect(self._minEditingFinished)
+ formLayout.addRow('\tMin:', self._minValue)
+
+ # Max row
+ self._maxValue = _FloatEdit(value=10.)
+ self._maxValue.setEnabled(False)
+ self._maxValue.textEdited.connect(self._minMaxTextEdited)
+ self._maxValue.editingFinished.connect(self._maxEditingFinished)
+ formLayout.addRow('\tMax:', self._maxValue)
+
+ # Add plot for histogram
+ self._plotInit()
+ vLayout.addWidget(self._plot)
+
+ # Close button
+ buttonsWidget = qt.QWidget()
+ vLayout.addWidget(buttonsWidget)
+
+ buttonsLayout = qt.QHBoxLayout(buttonsWidget)
+
+ okButton = qt.QPushButton('OK')
+ okButton.clicked.connect(self.accept)
+ buttonsLayout.addWidget(okButton)
+
+ cancelButton = qt.QPushButton('Cancel')
+ cancelButton.clicked.connect(self.reject)
+ buttonsLayout.addWidget(cancelButton)
+
+ # colormap window can not be resized
+ self.setFixedSize(vLayout.minimumSize())
+
+ # Set the colormap to default values
+ self.setColormap(name='gray', normalization='linear',
+ autoscale=True, vmin=1., vmax=10.)
+
+ def _plotInit(self):
+ """Init the plot to display the range and the values"""
+ self._plot = PlotWidget()
+ self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125)
+ self._plot.setGraphXLabel("Data Values")
+ self._plot.setGraphYLabel("")
+ self._plot.setInteractiveMode('select', zoomOnWheel=False)
+ self._plot.setActiveCurveHandling(False)
+ self._plot.setMinimumSize(qt.QSize(250, 200))
+ self._plot.sigPlotSignal.connect(self._plotSlot)
+ self._plot.hide()
+
+ self._plotUpdate()
+
+ def _plotUpdate(self, updateMarkers=True):
+ """Update the plot content
+
+ :param bool updateMarkers: True to update markers, False otherwith
+ """
+ dataRange = self.getDataRange()
+
+ if dataRange is None:
+ if self._plot.isVisibleTo(self):
+ self._plot.setVisible(False)
+ self.setFixedSize(self.layout().minimumSize())
+ return
+
+ if not self._plot.isVisibleTo(self):
+ self._plot.setVisible(True)
+ self.setFixedSize(self.layout().minimumSize())
+
+ dataMin, dataMax = dataRange
+ marge = (abs(dataMax) + abs(dataMin)) / 6.0
+ minmd = dataMin - marge
+ maxpd = dataMax + marge
+
+ start, end = self._minValue.value(), self._maxValue.value()
+
+ if start <= end:
+ x = [minmd, start, end, maxpd]
+ y = [0, 0, 1, 1]
+
+ else:
+ x = [minmd, end, start, maxpd]
+ y = [1, 1, 0, 0]
+
+ # Display the colormap on the side
+ # colormap = {'name': self.getColormap()['name'],
+ # 'normalization': self.getColormap()['normalization'],
+ # 'autoscale': True, 'vmin': 1., 'vmax': 256.}
+ # self._plot.addImage((1 + numpy.arange(256)).reshape(256, -1),
+ # xScale=(minmd - marge, marge),
+ # yScale=(1., 2./256.),
+ # legend='colormap',
+ # colormap=colormap)
+
+ self._plot.addCurve(x, y,
+ legend="ConstrainedCurve",
+ color='black',
+ symbol='o',
+ linestyle='-',
+ resetzoom=False)
+
+ draggable = not self._rangeAutoscaleButton.isChecked()
+
+ if updateMarkers:
+ self._plot.addXMarker(
+ self._minValue.value(),
+ legend='Min',
+ text='Min',
+ draggable=draggable,
+ color='blue',
+ constraint=self._plotMinMarkerConstraint)
+
+ self._plot.addXMarker(
+ self._maxValue.value(),
+ legend='Max',
+ text='Max',
+ draggable=draggable,
+ color='blue',
+ constraint=self._plotMaxMarkerConstraint)
+
+ self._plot.resetZoom()
+
+ def _plotMinMarkerConstraint(self, x, y):
+ """Constraint of the min marker"""
+ return min(x, self._maxValue.value()), y
+
+ def _plotMaxMarkerConstraint(self, x, y):
+ """Constraint of the max marker"""
+ return max(x, self._minValue.value()), y
+
+ def _plotSlot(self, event):
+ """Handle events from the plot"""
+ if event['event'] in ('markerMoving', 'markerMoved'):
+ value = float(str(event['xdata']))
+ if event['label'] == 'Min':
+ self._minValue.setValue(value)
+ elif event['label'] == 'Max':
+ self._maxValue.setValue(value)
+
+ # This will recreate the markers while interacting...
+ # It might break if marker interaction is changed
+ if event['event'] == 'markerMoved':
+ self._notify()
+ else:
+ self._plotUpdate(updateMarkers=False)
+
+ def getHistogram(self):
+ """Returns the counts and bin edges of the displayed histogram.
+
+ :return: (hist, bin_edges)
+ :rtype: 2-tuple of numpy arrays"""
+ if self._histogramData is None:
+ return None
+ else:
+ bins, counts = self._histogramData
+ return numpy.array(bins, copy=True), numpy.array(counts, copy=True)
+
+ def setHistogram(self, hist=None, bin_edges=None):
+ """Set the histogram to display.
+
+ This update the data range with the bounds of the bins.
+ See :meth:`setDataRange`.
+
+ :param hist: array-like of counts or None to hide histogram
+ :param bin_edges: array-like of bins edges or None to hide histogram
+ """
+ if hist is None or bin_edges is None:
+ self._histogramData = None
+ self._plot.remove(legend='Histogram', kind='curve')
+ self.setDataRange() # Remove data range
+
+ else:
+ hist = numpy.array(hist, copy=True)
+ bin_edges = numpy.array(bin_edges, copy=True)
+ self._histogramData = hist, bin_edges
+
+ # For now, draw the histogram as a curve
+ # using bin centers and normalised counts
+ bins_center = 0.5 * (bin_edges[:-1] + bin_edges[1:])
+ norm_hist = hist / max(hist)
+ self._plot.addCurve(bins_center, norm_hist,
+ legend="Histogram",
+ color='gray',
+ symbol='',
+ linestyle='-',
+ fill=True)
+
+ # Update the data range
+ self.setDataRange(bin_edges[0], bin_edges[-1])
+
+ def getDataRange(self):
+ """Returns the data range used for the histogram area.
+
+ :return: (dataMin, dataMax) or None if no data range is set
+ :rtype: 2-tuple of float
+ """
+ return self._dataRange
+
+ def setDataRange(self, min_=None, max_=None):
+ """Set the range of data to use for the range of the histogram area.
+
+ :param float min_: The min of the data or None to disable range.
+ :param float max_: The max of the data or None to disable range.
+ """
+ if min_ is None or max_ is None:
+ self._dataRange = None
+ self._plotUpdate()
+
+ else:
+ min_, max_ = float(min_), float(max_)
+ assert min_ <= max_
+ self._dataRange = min_, max_
+ if self._rangeAutoscaleButton.isChecked():
+ self._minValue.setValue(min_)
+ self._maxValue.setValue(max_)
+ self._notify()
+ else:
+ self._plotUpdate()
+
+ def getColormap(self):
+ """Return the colormap description as a dict.
+
+ See :class:`Plot` for documentation on the colormap dict.
+ """
+ isNormLinear = self._normButtonLinear.isChecked()
+ colormap = {
+ 'name': str(self._comboBoxColormap.currentText()).lower(),
+ 'normalization': 'linear' if isNormLinear else 'log',
+ 'autoscale': self._rangeAutoscaleButton.isChecked(),
+ 'vmin': self._minValue.value(),
+ 'vmax': self._maxValue.value()}
+ return colormap
+
+ def setColormap(self, name=None, normalization=None,
+ autoscale=None, vmin=None, vmax=None, colors=None):
+ """Set the colormap description
+
+ If some arguments are not provided, the current values are used.
+
+ :param str name: The name of the colormap
+ :param str normalization: 'linear' or 'log'
+ :param bool autoscale: Toggle colormap range autoscale
+ :param float vmin: The min value, ignored if autoscale is True
+ :param float vmax: The max value, ignored if autoscale is True
+ """
+ if name is not None:
+ assert name in self._colormapList
+ index = self._colormapList.index(name)
+ self._comboBoxColormap.setCurrentIndex(index)
+
+ if normalization is not None:
+ assert normalization in ('linear', 'log')
+ self._normButtonLinear.setChecked(normalization == 'linear')
+ self._normButtonLog.setChecked(normalization == 'log')
+
+ if vmin is not None:
+ self._minValue.setValue(vmin)
+
+ if vmax is not None:
+ self._maxValue.setValue(vmax)
+
+ if autoscale is not None:
+ self._rangeAutoscaleButton.setChecked(autoscale)
+ if autoscale:
+ dataRange = self.getDataRange()
+ if dataRange is not None:
+ self._minValue.setValue(dataRange[0])
+ self._maxValue.setValue(dataRange[1])
+
+ # Do it once for all the changes
+ self._notify()
+
+ def _notify(self, *args, **kwargs):
+ """Emit the signal for colormap change"""
+ self._plotUpdate()
+ self.sigColormapChanged.emit(self.getColormap())
+
+ def _autoscaleToggled(self, checked):
+ """Handle autoscale changes by enabling/disabling min/max fields"""
+ self._minValue.setEnabled(not checked)
+ self._maxValue.setEnabled(not checked)
+ if checked:
+ dataRange = self.getDataRange()
+ if dataRange is not None:
+ self._minValue.setValue(dataRange[0])
+ self._maxValue.setValue(dataRange[1])
+
+ def _minMaxTextEdited(self, text):
+ """Handle _minValue and _maxValue textEdited signal"""
+ self._minMaxWasEdited = True
+
+ def _minEditingFinished(self):
+ """Handle _minValue editingFinished signal
+
+ Together with :meth:`_minMaxTextEdited`, this avoids to notify
+ colormap change when the min and max value where not edited.
+ """
+ if self._minMaxWasEdited:
+ self._minMaxWasEdited = False
+
+ # Fix start value
+ if self._minValue.value() > self._maxValue.value():
+ self._minValue.setValue(self._maxValue.value())
+ self._notify()
+
+ def _maxEditingFinished(self):
+ """Handle _maxValue editingFinished signal
+
+ Together with :meth:`_minMaxTextEdited`, this avoids to notify
+ colormap change when the min and max value where not edited.
+ """
+ if self._minMaxWasEdited:
+ self._minMaxWasEdited = False
+
+ # Fix end value
+ if self._minValue.value() > self._maxValue.value():
+ self._maxValue.setValue(self._minValue.value())
+ self._notify()
+
+ def keyPressEvent(self, event):
+ """Override key handling.
+
+ It disables leaving the dialog when editing a text field.
+ """
+ if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or
+ self._maxValue.hasFocus()):
+ # Bypass QDialog keyPressEvent
+ # To avoid leaving the dialog when pressing enter on a text field
+ super(qt.QDialog, self).keyPressEvent(event)
+ else:
+ # Use QDialog keyPressEvent
+ super(ColormapDialog, self).keyPressEvent(event)
diff --git a/silx/gui/plot/Colors.py b/silx/gui/plot/Colors.py
new file mode 100644
index 0000000..7a3cd97
--- /dev/null
+++ b/silx/gui/plot/Colors.py
@@ -0,0 +1,359 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Color conversion function, color dictionary and colormap tools."""
+
+__authors__ = ["V.A. Sole", "T. VINCENT"]
+__license__ = "MIT"
+__date__ = "16/01/2017"
+
+
+import logging
+
+import numpy
+
+import matplotlib
+import matplotlib.colors
+import matplotlib.cm
+
+from . import MPLColormap
+
+
+_logger = logging.getLogger(__name__)
+
+
+COLORDICT = {}
+"""Dictionary of common colors."""
+
+COLORDICT['b'] = COLORDICT['blue'] = '#0000ff'
+COLORDICT['r'] = COLORDICT['red'] = '#ff0000'
+COLORDICT['g'] = COLORDICT['green'] = '#00ff00'
+COLORDICT['k'] = COLORDICT['black'] = '#000000'
+COLORDICT['w'] = COLORDICT['white'] = '#ffffff'
+COLORDICT['pink'] = '#ff66ff'
+COLORDICT['brown'] = '#a52a2a'
+COLORDICT['orange'] = '#ff9900'
+COLORDICT['violet'] = '#6600ff'
+COLORDICT['gray'] = COLORDICT['grey'] = '#a0a0a4'
+# COLORDICT['darkGray'] = COLORDICT['darkGrey'] = '#808080'
+# COLORDICT['lightGray'] = COLORDICT['lightGrey'] = '#c0c0c0'
+COLORDICT['y'] = COLORDICT['yellow'] = '#ffff00'
+COLORDICT['m'] = COLORDICT['magenta'] = '#ff00ff'
+COLORDICT['c'] = COLORDICT['cyan'] = '#00ffff'
+COLORDICT['darkBlue'] = '#000080'
+COLORDICT['darkRed'] = '#800000'
+COLORDICT['darkGreen'] = '#008000'
+COLORDICT['darkBrown'] = '#660000'
+COLORDICT['darkCyan'] = '#008080'
+COLORDICT['darkYellow'] = '#808000'
+COLORDICT['darkMagenta'] = '#800080'
+
+
+def rgba(color, colorDict=None):
+ """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A)
+
+ It also convert RGB(A) values from uint8 to float in [0, 1] and
+ accept a QColor as color argument.
+
+ :param str color: The color to convert
+ :param dict colorDict: A dictionary of color name conversion to color code
+ :returns: RGBA colors as floats in [0., 1.]
+ :rtype: tuple
+ """
+ if colorDict is None:
+ colorDict = COLORDICT
+
+ if hasattr(color, 'getRgbF'): # QColor support
+ color = color.getRgbF()
+
+ values = numpy.asarray(color).ravel()
+
+ if values.dtype.kind in 'iuf': # integer or float
+ # Color is an array
+ assert len(values) in (3, 4)
+
+ # Convert from integers in [0, 255] to float in [0, 1]
+ if values.dtype.kind in 'iu':
+ values = values / 255.
+
+ # Clip to [0, 1]
+ values[values < 0.] = 0.
+ values[values > 1.] = 1.
+
+ if len(values) == 3:
+ return values[0], values[1], values[2], 1.
+ else:
+ return tuple(values)
+
+ # We assume color is a string
+ if not color.startswith('#'):
+ color = colorDict[color]
+
+ assert len(color) in (7, 9) and color[0] == '#'
+ r = int(color[1:3], 16) / 255.
+ g = int(color[3:5], 16) / 255.
+ b = int(color[5:7], 16) / 255.
+ a = int(color[7:9], 16) / 255. if len(color) == 9 else 1.
+ return r, g, b, a
+
+
+_COLORMAP_CURSOR_COLORS = {
+ 'gray': 'pink',
+ 'reversed gray': 'pink',
+ 'temperature': 'pink',
+ 'red': 'green',
+ 'green': 'pink',
+ 'blue': 'yellow',
+ 'jet': 'pink',
+ 'viridis': 'pink',
+ 'magma': 'green',
+ 'inferno': 'green',
+ 'plasma': 'green',
+}
+
+
+def cursorColorForColormap(colormapName):
+ """Get a color suitable for overlay over a colormap.
+
+ :param str colormapName: The name of the colormap.
+ :return: Name of the color.
+ :rtype: str
+ """
+ return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black')
+
+
+_CMAPS = {} # Store additional colormaps
+
+
+def getMPLColormap(name):
+ """Returns matplotlib colormap corresponding to given name
+
+ :param str name: The name of the colormap
+ :return: The corresponding colormap
+ :rtype: matplolib.colors.Colormap
+ """
+ if not _CMAPS: # Lazy initialization of own colormaps
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (1.0, 1.0, 1.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0))}
+ _CMAPS['red'] = matplotlib.colors.LinearSegmentedColormap(
+ 'red', cdict, 256)
+
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (1.0, 1.0, 1.0)),
+ 'blue': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0))}
+ _CMAPS['green'] = matplotlib.colors.LinearSegmentedColormap(
+ 'green', cdict, 256)
+
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 0.0, 0.0),
+ (1.0, 1.0, 1.0))}
+ _CMAPS['blue'] = matplotlib.colors.LinearSegmentedColormap(
+ 'blue', cdict, 256)
+
+ # Temperature as defined in spslut
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (0.5, 0.0, 0.0),
+ (0.75, 1.0, 1.0),
+ (1.0, 1.0, 1.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (0.25, 1.0, 1.0),
+ (0.75, 1.0, 1.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 1.0, 1.0),
+ (0.25, 1.0, 1.0),
+ (0.5, 0.0, 0.0),
+ (1.0, 0.0, 0.0))}
+ # but limited to 256 colors for a faster display (of the colorbar)
+ _CMAPS['temperature'] = \
+ matplotlib.colors.LinearSegmentedColormap(
+ 'temperature', cdict, 256)
+
+ # reversed gray
+ cdict = {'red': ((0.0, 1.0, 1.0),
+ (1.0, 0.0, 0.0)),
+ 'green': ((0.0, 1.0, 1.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 1.0, 1.0),
+ (1.0, 0.0, 0.0))}
+
+ _CMAPS['reversed gray'] = \
+ matplotlib.colors.LinearSegmentedColormap(
+ 'yerg', cdict, 256)
+
+ if name in _CMAPS:
+ return _CMAPS[name]
+ elif hasattr(MPLColormap, name): # viridis and sister colormaps
+ return getattr(MPLColormap, name)
+ else:
+ # matplotlib built-in
+ return matplotlib.cm.get_cmap(name)
+
+
+def getMPLScalarMappable(colormap, data=None):
+ """Returns matplotlib ScalarMappable corresponding to colormap
+
+ :param dict colormap: The colormap to convert
+ :param numpy.ndarray data:
+ The data on which the colormap is applied.
+ If provided, it is used to compute autoscale.
+ :return: matplotlib object corresponding to colormap
+ :rtype: matplotlib.cm.ScalarMappable
+ """
+ assert colormap is not None
+
+ if colormap['name'] is not None:
+ cmap = getMPLColormap(colormap['name'])
+
+ else: # No name, use custom colors
+ if 'colors' not in colormap:
+ raise ValueError(
+ 'addImage: colormap no name nor list of colors.')
+ colors = numpy.array(colormap['colors'], copy=True)
+ assert len(colors.shape) == 2
+ assert colors.shape[-1] in (3, 4)
+ if colors.dtype == numpy.uint8:
+ # Convert to float in [0., 1.]
+ colors = colors.astype(numpy.float32) / 255.
+ cmap = matplotlib.colors.ListedColormap(colors)
+
+ if colormap['normalization'].startswith('log'):
+ vmin, vmax = None, None
+ if not colormap['autoscale']:
+ if colormap['vmin'] > 0.:
+ vmin = colormap['vmin']
+ if colormap['vmax'] > 0.:
+ vmax = colormap['vmax']
+
+ if vmin is None or vmax is None:
+ _logger.warning('Log colormap with negative bounds, ' +
+ 'changing bounds to positive ones.')
+ elif vmin > vmax:
+ _logger.warning('Colormap bounds are inverted.')
+ vmin, vmax = vmax, vmin
+
+ # Set unset/negative bounds to positive bounds
+ if (vmin is None or vmax is None) and data is not None:
+ finiteData = data[numpy.isfinite(data)]
+ posData = finiteData[finiteData > 0]
+ if vmax is None:
+ # 1. as an ultimate fallback
+ vmax = posData.max() if posData.size > 0 else 1.
+ if vmin is None:
+ vmin = posData.min() if posData.size > 0 else vmax
+ if vmin > vmax:
+ vmin = vmax
+
+ norm = matplotlib.colors.LogNorm(vmin, vmax)
+
+ else: # Linear normalization
+ if colormap['autoscale']:
+ if data is None:
+ vmin, vmax = None, None
+ else:
+ finiteData = data[numpy.isfinite(data)]
+ vmin = finiteData.min()
+ vmax = finiteData.max()
+ else:
+ vmin = colormap['vmin']
+ vmax = colormap['vmax']
+ if vmin > vmax:
+ _logger.warning('Colormap bounds are inverted.')
+ vmin, vmax = vmax, vmin
+
+ norm = matplotlib.colors.Normalize(vmin, vmax)
+
+ return matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap)
+
+
+def applyColormapToData(data,
+ name='gray',
+ normalization='linear',
+ autoscale=True,
+ vmin=0.,
+ vmax=1.,
+ colors=None):
+ """Apply a colormap to the data and returns the RGBA image
+
+ This supports data of any dimensions (not only of dimension 2).
+ The returned array will have one more dimension (with 4 entries)
+ than the input data to store the RGBA channels
+ corresponding to each bin in the array.
+
+ :param numpy.ndarray data: The data to convert.
+ :param str name: Name of the colormap (default: 'gray').
+ :param str normalization: Colormap mapping: 'linear' or 'log'.
+ :param bool autoscale: Whether to use data min/max (True, default)
+ or [vmin, vmax] range (False).
+ :param float vmin: The minimum value of the range to use if
+ 'autoscale' is False.
+ :param float vmax: The maximum value of the range to use if
+ 'autoscale' is False.
+ :param numpy.ndarray colors: Only used if name is None.
+ Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays
+ :return: The computed RGBA image
+ :rtype: numpy.ndarray of uint8
+ """
+ # Debian 7 specific support
+ # No transparent colormap with matplotlib < 1.2.0
+ # Add support for transparent colormap for uint8 data with
+ # colormap with 256 colors, linear norm, [0, 255] range
+ if matplotlib.__version__ < '1.2.0':
+ if name is None and colors is not None:
+ colors = numpy.array(colors, copy=False)
+ if (colors.shape[-1] == 4 and
+ not numpy.all(numpy.equal(colors[3], 255))):
+ # This is a transparent colormap
+ if (colors.shape == (256, 4) and
+ normalization == 'linear' and
+ not autoscale and
+ vmin == 0 and vmax == 255 and
+ data.dtype == numpy.uint8):
+ # Supported case, convert data to RGBA
+ return colors[data.reshape(-1)].reshape(
+ data.shape + (4,))
+ else:
+ _logger.warning(
+ 'matplotlib %s does not support transparent '
+ 'colormap.', matplotlib.__version__)
+
+ colormap = dict(name=name,
+ normalization=normalization,
+ autoscale=autoscale,
+ vmin=vmin,
+ vmax=vmax,
+ colors=colors)
+ scalarMappable = getMPLScalarMappable(colormap, data)
+ rgbaImage = scalarMappable.to_rgba(data, bytes=True)
+
+ return rgbaImage
diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py
new file mode 100644
index 0000000..13c3de0
--- /dev/null
+++ b/silx/gui/plot/CurvesROIWidget.py
@@ -0,0 +1,975 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Widget to handle regions of interest (ROI) on curves displayed in a PlotWindow.
+
+This widget is meant to work with :class:`PlotWindow`.
+
+ROI are defined by :
+
+- A name (`ROI` column)
+- A type. The type is the label of the x axis.
+ This can be used to apply or not some ROI to a curve and do some post processing.
+- The x coordinate of the left limit (`from` column)
+- The x coordinate of the right limit (`to` column)
+- Raw counts: integral of the curve between the
+ min ROI point and the max ROI point to the y = 0 line
+
+ .. image:: img/rawCounts.png
+
+- Net counts: the integral of the curve between the
+ min ROI point and the max ROI point to [ROI min point, ROI max point] segment
+
+ .. image:: img/netCounts.png
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/04/2017"
+
+from collections import OrderedDict
+
+import logging
+import os
+import sys
+
+import numpy
+
+from silx.io import dictdump
+from .. import icons, qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class CurvesROIWidget(qt.QWidget):
+ """Widget displaying a table of ROI information.
+
+ :param parent: See :class:`QWidget`
+ :param str name: The title of this widget
+ """
+
+ sigROIWidgetSignal = qt.Signal(object)
+ """Signal of ROIs modifications.
+
+ Modification information if given as a dict with an 'event' key
+ providing the type of events.
+
+ Type of events:
+
+ - AddROI, DelROI, LoadROI and ResetROI with keys: 'roilist', 'roidict'
+
+ - selectionChanged with keys: 'row', 'col' 'roi', 'key', 'colheader',
+ 'rowheader'
+ """
+
+ def __init__(self, parent=None, name=None):
+ super(CurvesROIWidget, self).__init__(parent)
+ if name is not None:
+ self.setWindowTitle(name)
+ layout = qt.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ ##############
+ self.headerLabel = qt.QLabel(self)
+ self.headerLabel.setAlignment(qt.Qt.AlignHCenter)
+ self.setHeader()
+ layout.addWidget(self.headerLabel)
+ ##############
+ self.roiTable = ROITable(self)
+ rheight = self.roiTable.horizontalHeader().sizeHint().height()
+ self.roiTable.setMinimumHeight(4 * rheight)
+ self.fillFromROIDict = self.roiTable.fillFromROIDict
+ self.getROIListAndDict = self.roiTable.getROIListAndDict
+ layout.addWidget(self.roiTable)
+ self._roiFileDir = qt.QDir.home().absolutePath()
+ #################
+
+ hbox = qt.QWidget(self)
+ hboxlayout = qt.QHBoxLayout(hbox)
+ hboxlayout.setContentsMargins(0, 0, 0, 0)
+ hboxlayout.setSpacing(0)
+
+ hboxlayout.addStretch(0)
+
+ self.addButton = qt.QPushButton(hbox)
+ self.addButton.setText("Add ROI")
+ self.addButton.setToolTip('Create a new ROI')
+ self.delButton = qt.QPushButton(hbox)
+ self.delButton.setText("Delete ROI")
+ self.addButton.setToolTip('Remove the selected ROI')
+ self.resetButton = qt.QPushButton(hbox)
+ self.resetButton.setText("Reset")
+ self.addButton.setToolTip('Clear all created ROIs. We only let the default ROI')
+
+ hboxlayout.addWidget(self.addButton)
+ hboxlayout.addWidget(self.delButton)
+ hboxlayout.addWidget(self.resetButton)
+
+ hboxlayout.addStretch(0)
+
+ self.loadButton = qt.QPushButton(hbox)
+ self.loadButton.setText("Load")
+ self.loadButton.setToolTip('Load ROIs from a .ini file')
+ self.saveButton = qt.QPushButton(hbox)
+ self.saveButton.setText("Save")
+ self.loadButton.setToolTip('Save ROIs to a .ini file')
+ hboxlayout.addWidget(self.loadButton)
+ hboxlayout.addWidget(self.saveButton)
+ layout.setStretchFactor(self.headerLabel, 0)
+ layout.setStretchFactor(self.roiTable, 1)
+ layout.setStretchFactor(hbox, 0)
+
+ layout.addWidget(hbox)
+
+ self.addButton.clicked.connect(self._add)
+ self.delButton.clicked.connect(self._del)
+ self.resetButton.clicked.connect(self._reset)
+
+ self.loadButton.clicked.connect(self._load)
+ self.saveButton.clicked.connect(self._save)
+ self.roiTable.sigROITableSignal.connect(self._forward)
+
+ @property
+ def roiFileDir(self):
+ """The directory from which to load/save ROI from/to files."""
+ if not os.path.isdir(self._roiFileDir):
+ self._roiFileDir = qt.QDir.home().absolutePath()
+ return self._roiFileDir
+
+ @roiFileDir.setter
+ def roiFileDir(self, roiFileDir):
+ self._roiFileDir = str(roiFileDir)
+
+ def setRois(self, roidict, order=None):
+ """Set the ROIs by providing a dictionary of ROI information.
+
+ The dictionary keys are the ROI names.
+ Each value is a sub-dictionary of ROI info with the following fields:
+
+ - ``"from"``: x coordinate of the left limit, as a float
+ - ``"to"``: x coordinate of the right limit, as a float
+ - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
+
+
+ :param roidict: Dictionary of ROIs
+ :param str order: Field used for ordering the ROIs.
+ One of "from", "to", "type".
+ None (default) for no ordering, or same order as specified
+ in parameter ``roidict`` if provided as an OrderedDict.
+ """
+ if order is None or order.lower() == "none":
+ roilist = list(roidict.keys())
+ else:
+ assert order in ["from", "to", "type"]
+ roilist = sorted(roidict.keys(),
+ key=lambda roi_name: roidict[roi_name].get(order))
+
+ return self.roiTable.fillFromROIDict(roilist, roidict)
+
+ def getRois(self, order=None):
+ """Return the currently defined ROIs, as an ordered dict.
+
+ The dictionary keys are the ROI names.
+ Each value is a sub-dictionary of ROI info with the following fields:
+
+ - ``"from"``: x coordinate of the left limit, as a float
+ - ``"to"``: x coordinate of the right limit, as a float
+ - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
+ :param order: Field used for ordering the ROIs.
+ One of "from", "to", "type", "netcounts", "rawcounts".
+ None (default) to get the same order as displayed in the widget.
+ :return: Ordered dictionary of ROI information
+ """
+ roilist, roidict = self.roiTable.getROIListAndDict()
+ if order is None or order.lower() == "none":
+ ordered_roilist = roilist
+ else:
+ assert order in ["from", "to", "type", "netcounts", "rawcounts"]
+ ordered_roilist = sorted(roidict.keys(),
+ key=lambda roi_name: roidict[roi_name].get(order))
+
+ return OrderedDict([(name, roidict[name]) for name in ordered_roilist])
+
+ def _add(self):
+ """Add button clicked handler"""
+ ddict = {}
+ ddict['event'] = "AddROI"
+ roilist, roidict = self.roiTable.getROIListAndDict()
+ ddict['roilist'] = roilist
+ ddict['roidict'] = roidict
+ self.sigROIWidgetSignal.emit(ddict)
+
+ def _del(self):
+ """Delete button clicked handler"""
+ row = self.roiTable.currentRow()
+ if row >= 0:
+ index = self.roiTable.labels.index('Type')
+ text = str(self.roiTable.item(row, index).text())
+ if text.upper() != 'DEFAULT':
+ index = self.roiTable.labels.index('ROI')
+ key = str(self.roiTable.item(row, index).text())
+ else:
+ # This is to prevent deleting ICR ROI, that is
+ # usually initialized as "Default" type.
+ return
+ roilist, roidict = self.roiTable.getROIListAndDict()
+ row = roilist.index(key)
+ del roilist[row]
+ del roidict[key]
+ if len(roilist) > 0:
+ currentroi = roilist[0]
+ else:
+ currentroi = None
+
+ self.roiTable.fillFromROIDict(roilist=roilist,
+ roidict=roidict,
+ currentroi=currentroi)
+ ddict = {}
+ ddict['event'] = "DelROI"
+ ddict['roilist'] = roilist
+ ddict['roidict'] = roidict
+ self.sigROIWidgetSignal.emit(ddict)
+
+ def _forward(self, ddict):
+ """Broadcast events from ROITable signal"""
+ self.sigROIWidgetSignal.emit(ddict)
+
+ def _reset(self):
+ """Reset button clicked handler"""
+ ddict = {}
+ ddict['event'] = "ResetROI"
+ roilist0, roidict0 = self.roiTable.getROIListAndDict()
+ index = 0
+ for key in roilist0:
+ if roidict0[key]['type'].upper() == 'DEFAULT':
+ index = roilist0.index(key)
+ break
+ roilist = []
+ roidict = {}
+ if len(roilist0):
+ roilist.append(roilist0[index])
+ roidict[roilist[0]] = {}
+ roidict[roilist[0]].update(roidict0[roilist[0]])
+ self.roiTable.fillFromROIDict(roilist=roilist, roidict=roidict)
+ ddict['roilist'] = roilist
+ ddict['roidict'] = roidict
+ self.sigROIWidgetSignal.emit(ddict)
+
+ def _load(self):
+ """Load button clicked handler"""
+ dialog = qt.QFileDialog(self)
+ dialog.setNameFilters(
+ ['INI File *.ini', 'JSON File *.json', 'All *.*'])
+ dialog.setFileMode(qt.QFileDialog.ExistingFile)
+ dialog.setDirectory(self.roiFileDir)
+ if not dialog.exec_():
+ dialog.close()
+ return
+
+ # pyflakes bug http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=666494
+ outputFile = dialog.selectedFiles()[0]
+ dialog.close()
+
+ self.roiFileDir = os.path.dirname(outputFile)
+ self.load(outputFile)
+
+ def load(self, filename):
+ """Load ROI widget information from a file storing a dict of ROI.
+
+ :param str filename: The file from which to load ROI
+ """
+ rois = dictdump.load(filename)
+ currentROI = None
+ if self.roiTable.rowCount():
+ item = self.roiTable.item(self.roiTable.currentRow(), 0)
+ if item is not None:
+ currentROI = str(item.text())
+
+ # Remove rawcounts and netcounts from ROIs
+ for roi in rois['ROI']['roidict'].values():
+ roi.pop('rawcounts', None)
+ roi.pop('netcounts', None)
+
+ self.roiTable.fillFromROIDict(roilist=rois['ROI']['roilist'],
+ roidict=rois['ROI']['roidict'],
+ currentroi=currentROI)
+
+ roilist, roidict = self.roiTable.getROIListAndDict()
+ event = {'event': 'LoadROI', 'roilist': roilist, 'roidict': roidict}
+ self.sigROIWidgetSignal.emit(event)
+
+ def _save(self):
+ """Save button clicked handler"""
+ dialog = qt.QFileDialog(self)
+ dialog.setNameFilters(['INI File *.ini', 'JSON File *.json'])
+ dialog.setFileMode(qt.QFileDialog.AnyFile)
+ dialog.setAcceptMode(qt.QFileDialog.AcceptSave)
+ dialog.setDirectory(self.roiFileDir)
+ if not dialog.exec_():
+ dialog.close()
+ return
+
+ outputFile = dialog.selectedFiles()[0]
+ extension = '.' + dialog.selectedNameFilter().split('.')[-1]
+ dialog.close()
+
+ if not outputFile.endswith(extension):
+ outputFile += extension
+
+ if os.path.exists(outputFile):
+ try:
+ os.remove(outputFile)
+ except IOError:
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setText("Input Output Error: %s" % (sys.exc_info()[1]))
+ msg.exec_()
+ return
+ self.roiFileDir = os.path.dirname(outputFile)
+ self.save(outputFile)
+
+ def save(self, filename):
+ """Save current ROIs of the widget as a dict of ROI to a file.
+
+ :param str filename: The file to which to save the ROIs
+ """
+ roilist, roidict = self.roiTable.getROIListAndDict()
+ datadict = {'ROI': {'roilist': roilist, 'roidict': roidict}}
+ dictdump.dump(datadict, filename)
+
+ def setHeader(self, text='ROIs'):
+ """Set the header text of this widget"""
+ self.headerLabel.setText("<b>%s<\b>" % text)
+
+
+class ROITable(qt.QTableWidget):
+ """Table widget displaying ROI information.
+
+ See :class:`QTableWidget` for constructor arguments.
+ """
+
+ sigROITableSignal = qt.Signal(object)
+ """Signal of ROI table modifications.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(ROITable, self).__init__(*args, **kwargs)
+ self.setRowCount(1)
+ self.labels = 'ROI', 'Type', 'From', 'To', 'Raw Counts', 'Net Counts'
+ self.setColumnCount(len(self.labels))
+ self.setSortingEnabled(False)
+
+ for index, label in enumerate(self.labels):
+ item = self.horizontalHeaderItem(index)
+ if item is None:
+ item = qt.QTableWidgetItem(label,
+ qt.QTableWidgetItem.Type)
+ item.setText(label)
+ self.setHorizontalHeaderItem(index, item)
+
+ self.roidict = {}
+ self.roilist = []
+
+ self.building = False
+ self.fillFromROIDict(roilist=self.roilist, roidict=self.roidict)
+
+ self.cellClicked[(int, int)].connect(self._cellClickedSlot)
+ self.cellChanged[(int, int)].connect(self._cellChangedSlot)
+ verticalHeader = self.verticalHeader()
+ verticalHeader.sectionClicked[int].connect(self._rowChangedSlot)
+
+ self.__setTooltip()
+
+ def __setTooltip(self):
+ assert(self.labels[0] == 'ROI')
+ self.horizontalHeaderItem(0).setToolTip('Region of interest identifier')
+ assert(self.labels[1] == 'Type')
+ self.horizontalHeaderItem(1).setToolTip('Type of the ROI')
+ assert(self.labels[2] == 'From')
+ self.horizontalHeaderItem(2).setToolTip('X-value of the min point')
+ assert(self.labels[3] == 'To')
+ self.horizontalHeaderItem(3).setToolTip('X-value of the max point')
+ assert(self.labels[4] == 'Raw Counts')
+ self.horizontalHeaderItem(4).setToolTip('Estimation of the integral \
+ between y=0 and the selected curve')
+ assert(self.labels[5] == 'Net Counts')
+ self.horizontalHeaderItem(5).setToolTip('Estimation of the integral \
+ between the segment [maxPt, minPt] and the selected curve')
+
+ def fillFromROIDict(self, roilist=(), roidict=None, currentroi=None):
+ """Set the ROIs by providing a list of ROI names and a dictionary
+ of ROI information for each ROI.
+
+ The ROI names must match an existing dictionary key.
+ The name list is used to provide an order for the ROIs.
+
+ The dictionary's values are sub-dictionaries containing 3
+ mandatory fields:
+
+ - ``"from"``: x coordinate of the left limit, as a float
+ - ``"to"``: x coordinate of the right limit, as a float
+ - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
+
+ :param roilist: List of ROI names (keys of roidict)
+ :type roilist: List
+ :param dict roidict: Dict of ROI information
+ :param currentroi: Name of the selected ROI or None (no selection)
+ """
+ if roidict is None:
+ roidict = {}
+
+ self.building = True
+ line0 = 0
+ self.roilist = []
+ self.roidict = {}
+ for key in roilist:
+ if key in roidict.keys():
+ roi = roidict[key]
+ self.roilist.append(key)
+ self.roidict[key] = {}
+ self.roidict[key].update(roi)
+ line0 = line0 + 1
+ nlines = self.rowCount()
+ if (line0 > nlines):
+ self.setRowCount(line0)
+ line = line0 - 1
+ self.roidict[key]['line'] = line
+ ROI = key
+ roitype = "%s" % roi['type']
+ fromdata = "%6g" % (roi['from'])
+ todata = "%6g" % (roi['to'])
+ if 'rawcounts' in roi:
+ rawcounts = "%6g" % (roi['rawcounts'])
+ else:
+ rawcounts = " ?????? "
+ if 'netcounts' in roi:
+ netcounts = "%6g" % (roi['netcounts'])
+ else:
+ netcounts = " ?????? "
+ fields = [ROI, roitype, fromdata, todata, rawcounts, netcounts]
+ col = 0
+ for field in fields:
+ key2 = self.item(line, col)
+ if key2 is None:
+ key2 = qt.QTableWidgetItem(field,
+ qt.QTableWidgetItem.Type)
+ self.setItem(line, col, key2)
+ else:
+ key2.setText(field)
+ if (ROI.upper() == 'ICR') or (ROI.upper() == 'DEFAULT'):
+ key2.setFlags(qt.Qt.ItemIsSelectable |
+ qt.Qt.ItemIsEnabled)
+ else:
+ if col in [0, 2, 3]:
+ key2.setFlags(qt.Qt.ItemIsSelectable |
+ qt.Qt.ItemIsEnabled |
+ qt.Qt.ItemIsEditable)
+ else:
+ key2.setFlags(qt.Qt.ItemIsSelectable |
+ qt.Qt.ItemIsEnabled)
+ col = col + 1
+ self.setRowCount(line0)
+ i = 0
+ for _label in self.labels:
+ self.resizeColumnToContents(i)
+ i = i + 1
+ self.sortByColumn(2, qt.Qt.AscendingOrder)
+ for i in range(len(self.roilist)):
+ key = str(self.item(i, 0).text())
+ self.roilist[i] = key
+ self.roidict[key]['line'] = i
+ if len(self.roilist) == 1:
+ self.selectRow(0)
+ else:
+ if currentroi in self.roidict.keys():
+ self.selectRow(self.roidict[currentroi]['line'])
+ _logger.debug("Qt4 ensureCellVisible to be implemented")
+ self.building = False
+
+ def getROIListAndDict(self):
+ """Return the currently defined ROIs, as a 2-tuple
+ ``(roiList, roiDict)``
+
+ ``roiList`` is a list of ROI names.
+ ``roiDict`` is a dictionary of ROI info.
+
+ The ROI names must match an existing dictionary key.
+ The name list is used to provide an order for the ROIs.
+
+ The dictionary's values are sub-dictionaries containing 3
+ fields:
+
+ - ``"from"``: x coordinate of the left limit, as a float
+ - ``"to"``: x coordinate of the right limit, as a float
+ - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
+
+
+ :return: ordered dict as a tuple of (list of ROI names, dict of info)
+ """
+ return self.roilist, self.roidict
+
+ def _cellClickedSlot(self, *var, **kw):
+ # selection changed event, get the current selection
+ row = self.currentRow()
+ col = self.currentColumn()
+ if row >= 0 and row < len(self.roilist):
+ item = self.item(row, 0)
+ text = '' if item is None else str(item.text())
+ self.roilist[row] = text
+ self._emitSelectionChangedSignal(row, col)
+
+ def _rowChangedSlot(self, row):
+ self._emitSelectionChangedSignal(row, 0)
+
+ def _cellChangedSlot(self, row, col):
+ _logger.debug("_cellChangedSlot(%d, %d)", row, col)
+ if self.building:
+ return
+ if col == 0:
+ self.nameSlot(row, col)
+ else:
+ self._valueChanged(row, col)
+
+ def _valueChanged(self, row, col):
+ if col not in [2, 3]:
+ return
+ item = self.item(row, col)
+ if item is None:
+ return
+ text = str(item.text())
+ try:
+ value = float(text)
+ except:
+ return
+ if row >= len(self.roilist):
+ _logger.debug("deleting???")
+ return
+ item = self.item(row, 0)
+ if item is None:
+ text = ""
+ else:
+ text = str(item.text())
+ if not len(text):
+ return
+ if col == 2:
+ self.roidict[text]['from'] = value
+ elif col == 3:
+ self.roidict[text]['to'] = value
+ self._emitSelectionChangedSignal(row, col)
+
+ def nameSlot(self, row, col):
+ if col != 0:
+ return
+ if row >= len(self.roilist):
+ _logger.debug("deleting???")
+ return
+ item = self.item(row, col)
+ if item is None:
+ text = ""
+ else:
+ text = str(item.text())
+ if len(text) and (text not in self.roilist):
+ old = self.roilist[row]
+ self.roilist[row] = text
+ self.roidict[text] = {}
+ self.roidict[text].update(self.roidict[old])
+ del self.roidict[old]
+ self._emitSelectionChangedSignal(row, col)
+
+ def _emitSelectionChangedSignal(self, row, col):
+ ddict = {}
+ ddict['event'] = "selectionChanged"
+ ddict['row'] = row
+ ddict['col'] = col
+ ddict['roi'] = self.roidict[self.roilist[row]]
+ ddict['key'] = self.roilist[row]
+ ddict['colheader'] = self.labels[col]
+ ddict['rowheader'] = "%d" % row
+ self.sigROITableSignal.emit(ddict)
+
+
+class CurvesROIDockWidget(qt.QDockWidget):
+ """QDockWidget with a :class:`CurvesROIWidget` connected to a PlotWindow.
+
+ It makes the link between the :class:`CurvesROIWidget` and the PlotWindow.
+
+ :param parent: See :class:`QDockWidget`
+ :param plot: :class:`.PlotWindow` instance on which to operate
+ :param name: See :class:`QDockWidget`
+ """
+ sigROISignal = qt.Signal(object)
+
+ def __init__(self, parent=None, plot=None, name=None):
+ super(CurvesROIDockWidget, self).__init__(name, parent)
+
+ assert plot is not None
+ self.plot = plot
+
+ self.currentROI = None
+ self._middleROIMarkerFlag = False
+
+ self._isConnected = False # True if connected to plot signals
+ self._isInit = False
+
+ self.roiWidget = CurvesROIWidget(self, name)
+ """Main widget of type :class:`CurvesROIWidget`"""
+
+ # convenience methods to offer a simpler API allowing to ignore
+ # the details of the underlying implementation
+ self.calculateROIs = self.calculateRois
+ self.setRois = self.roiWidget.setRois
+ self.getRois = self.roiWidget.getRois
+
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self.setWidget(self.roiWidget)
+
+ self.visibilityChanged.connect(self._visibilityChangedHandler)
+
+ def toggleViewAction(self):
+ """Returns a checkable action that shows or closes this widget.
+
+ See :class:`QMainWindow`.
+ """
+ action = super(CurvesROIDockWidget, self).toggleViewAction()
+ action.setIcon(icons.getQIcon('plot-roi'))
+ return action
+
+ def _visibilityChangedHandler(self, visible):
+ """Handle widget's visibilty updates.
+
+ It is connected to plot signals only when visible.
+ """
+ if visible:
+ if not self._isInit:
+ # Deferred ROI widget init finalization
+ self._isInit = True
+ self.roiWidget.sigROIWidgetSignal.connect(self._roiSignal)
+ # initialize with the ICR
+ self._roiSignal({'event': "AddROI"})
+
+ if not self._isConnected:
+ self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent)
+ self.plot.sigActiveCurveChanged.connect(
+ self._activeCurveChanged)
+ self._isConnected = True
+
+ self.calculateROIs()
+ else:
+ if self._isConnected:
+ self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent)
+ self.plot.sigActiveCurveChanged.disconnect(
+ self._activeCurveChanged)
+ self._isConnected = False
+
+ def _handleROIMarkerEvent(self, ddict):
+ """Handle plot signals related to marker events."""
+ if ddict['event'] == 'markerMoved':
+
+ label = ddict['label']
+ if label not in ['ROI min', 'ROI max', 'ROI middle']:
+ return
+
+ roiList, roiDict = self.roiWidget.getROIListAndDict()
+ if self.currentROI is None:
+ return
+ if self.currentROI not in roiDict:
+ return
+ x = ddict['x']
+
+ if label == 'ROI min':
+ roiDict[self.currentROI]['from'] = x
+ if self._middleROIMarkerFlag:
+ pos = 0.5 * (roiDict[self.currentROI]['to'] +
+ roiDict[self.currentROI]['from'])
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
+ elif label == 'ROI max':
+ roiDict[self.currentROI]['to'] = x
+ if self._middleROIMarkerFlag:
+ pos = 0.5 * (roiDict[self.currentROI]['to'] +
+ roiDict[self.currentROI]['from'])
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
+ elif label == 'ROI middle':
+ delta = x - 0.5 * (roiDict[self.currentROI]['from'] +
+ roiDict[self.currentROI]['to'])
+ roiDict[self.currentROI]['from'] += delta
+ roiDict[self.currentROI]['to'] += delta
+ self.plot.addXMarker(roiDict[self.currentROI]['from'],
+ legend='ROI min',
+ text='ROI min',
+ color='blue',
+ draggable=True)
+ self.plot.addXMarker(roiDict[self.currentROI]['to'],
+ legend='ROI max',
+ text='ROI max',
+ color='blue',
+ draggable=True)
+ else:
+ return
+ self.calculateROIs(roiList, roiDict)
+ self._emitCurrentROISignal()
+
+ def _roiSignal(self, ddict):
+ """Handle ROI widget signal"""
+ _logger.debug("PlotWindow._roiSignal %s", str(ddict))
+ if ddict['event'] == "AddROI":
+ xmin, xmax = self.plot.getGraphXLimits()
+ fromdata = xmin + 0.25 * (xmax - xmin)
+ todata = xmin + 0.75 * (xmax - xmin)
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if self._middleROIMarkerFlag:
+ self.remove('ROI middle', kind='marker')
+ roiList, roiDict = self.roiWidget.getROIListAndDict()
+ nrois = len(roiList)
+ if nrois == 0:
+ newroi = "ICR"
+ fromdata, dummy0, todata, dummy1 = self._getAllLimits()
+ draggable = False
+ color = 'black'
+ else:
+ for i in range(nrois):
+ i += 1
+ newroi = "newroi %d" % i
+ if newroi not in roiList:
+ break
+ color = 'blue'
+ draggable = True
+ self.plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ self.plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
+ if draggable and self._middleROIMarkerFlag:
+ pos = 0.5 * (fromdata + todata)
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=draggable)
+ roiList.append(newroi)
+ roiDict[newroi] = {}
+ if newroi == "ICR":
+ roiDict[newroi]['type'] = "Default"
+ else:
+ roiDict[newroi]['type'] = self.plot.getGraphXLabel()
+ roiDict[newroi]['from'] = fromdata
+ roiDict[newroi]['to'] = todata
+ self.roiWidget.fillFromROIDict(roilist=roiList,
+ roidict=roiDict,
+ currentroi=newroi)
+ self.currentROI = newroi
+ self.calculateROIs()
+ elif ddict['event'] in ['DelROI', "ResetROI"]:
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if self._middleROIMarkerFlag:
+ self.plot.remove('ROI middle', kind='marker')
+ roiList, roiDict = self.roiWidget.getROIListAndDict()
+ roiDictKeys = list(roiDict.keys())
+ if len(roiDictKeys):
+ currentroi = roiDictKeys[0]
+ else:
+ # create again the ICR
+ ddict = {"event": "AddROI"}
+ return self._roiSignal(ddict)
+
+ self.roiWidget.fillFromROIDict(roilist=roiList,
+ roidict=roiDict,
+ currentroi=currentroi)
+ self.currentROI = currentroi
+
+ elif ddict['event'] == 'LoadROI':
+ self.calculateROIs()
+
+ elif ddict['event'] == 'selectionChanged':
+ _logger.debug("Selection changed")
+ self.roilist, self.roidict = self.roiWidget.getROIListAndDict()
+ fromdata = ddict['roi']['from']
+ todata = ddict['roi']['to']
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if ddict['key'] == 'ICR':
+ draggable = False
+ color = 'black'
+ else:
+ draggable = True
+ color = 'blue'
+ self.plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ self.plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
+ if draggable and self._middleROIMarkerFlag:
+ pos = 0.5 * (fromdata + todata)
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=True)
+ self.currentROI = ddict['key']
+ if ddict['colheader'] in ['From', 'To']:
+ dict0 = {}
+ dict0['event'] = "SetActiveCurveEvent"
+ dict0['legend'] = self.plot.getActiveCurve(just_legend=1)
+ self.plot.setActiveCurve(dict0['legend'])
+ elif ddict['colheader'] == 'Raw Counts':
+ pass
+ elif ddict['colheader'] == 'Net Counts':
+ pass
+ else:
+ self._emitCurrentROISignal()
+
+ else:
+ _logger.debug("Unknown or ignored event %s", ddict['event'])
+
+ def _activeCurveChanged(self, *args):
+ """Recompute ROIs when active curve changed."""
+ self.calculateROIs()
+
+ def calculateRois(self, roiList=None, roiDict=None):
+ """Compute ROI information"""
+ if roiList is None or roiDict is None:
+ roiList, roiDict = self.roiWidget.getROIListAndDict()
+
+ activeCurve = self.plot.getActiveCurve(just_legend=False)
+ if activeCurve is None:
+ xproc = None
+ yproc = None
+ self.roiWidget.setHeader()
+ else:
+ x = activeCurve.getXData(copy=False)
+ y = activeCurve.getYData(copy=False)
+ legend = activeCurve.getLegend()
+ idx = numpy.argsort(x, kind='mergesort')
+ xproc = numpy.take(x, idx)
+ yproc = numpy.take(y, idx)
+ self.roiWidget.setHeader('ROIs of %s' % legend)
+
+ for key in roiList:
+ if key == 'ICR':
+ if xproc is not None:
+ roiDict[key]['from'] = xproc.min()
+ roiDict[key]['to'] = xproc.max()
+ else:
+ roiDict[key]['from'] = 0
+ roiDict[key]['to'] = -1
+ fromData = roiDict[key]['from']
+ toData = roiDict[key]['to']
+ if xproc is not None:
+ idx = numpy.nonzero((fromData <= xproc) &
+ (xproc <= toData))[0]
+ if len(idx):
+ xw = xproc[idx]
+ yw = yproc[idx]
+ rawCounts = yw.sum(dtype=numpy.float)
+ deltaX = xw[-1] - xw[0]
+ deltaY = yw[-1] - yw[0]
+ if deltaX > 0.0:
+ slope = (deltaY / deltaX)
+ background = yw[0] + slope * (xw - xw[0])
+ netCounts = (rawCounts -
+ background.sum(dtype=numpy.float))
+ else:
+ netCounts = 0.0
+ else:
+ rawCounts = 0.0
+ netCounts = 0.0
+ roiDict[key]['rawcounts'] = rawCounts
+ roiDict[key]['netcounts'] = netCounts
+ else:
+ roiDict[key].pop('rawcounts', None)
+ roiDict[key].pop('netcounts', None)
+
+ self.roiWidget.fillFromROIDict(
+ roilist=roiList,
+ roidict=roiDict,
+ currentroi=self.currentROI if self.currentROI in roiList else None)
+
+ def _emitCurrentROISignal(self):
+ ddict = {}
+ ddict['event'] = "currentROISignal"
+ _roiList, roiDict = self.roiWidget.getROIListAndDict()
+ if self.currentROI in roiDict:
+ ddict['ROI'] = roiDict[self.currentROI]
+ else:
+ self.currentROI = None
+ ddict['current'] = self.currentROI
+ self.sigROISignal.emit(ddict)
+
+ def _getAllLimits(self):
+ """Retrieve the limits based on the curves."""
+ curves = self.plot.getAllCurves()
+ if not curves:
+ return 1.0, 1.0, 100., 100.
+
+ xmin, ymin = None, None
+ xmax, ymax = None, None
+
+ for curve in curves:
+ x = curve.getXData(copy=False)
+ y = curve.getYData(copy=False)
+ if xmin is None:
+ xmin = x.min()
+ else:
+ xmin = min(xmin, x.min())
+ if xmax is None:
+ xmax = x.max()
+ else:
+ xmax = max(xmax, x.max())
+ if ymin is None:
+ ymin = y.min()
+ else:
+ ymin = min(ymin, y.min())
+ if ymax is None:
+ ymax = y.max()
+ else:
+ ymax = max(ymax, y.max())
+
+ return xmin, ymin, xmax, ymax
+
+ def showEvent(self, event):
+ """Make sure this widget is raised when it is shown
+ (when it is first created as a tab in PlotWindow or when it is shown
+ again after hiding).
+ """
+ self.raise_()
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
new file mode 100644
index 0000000..780215e
--- /dev/null
+++ b/silx/gui/plot/ImageView.py
@@ -0,0 +1,860 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-2017 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 a 2D image with histograms on its sides.
+
+The :class:`ImageView` implements this widget, and
+:class:`ImageViewMainWindow` provides a main window with additional toolbar
+and status bar.
+
+Basic usage of :class:`ImageView` is through the following methods:
+
+- :meth:`ImageView.getColormap`, :meth:`ImageView.setColormap` to update the
+ default colormap to use and update the currently displayed image.
+- :meth:`ImageView.setImage` to update the displayed image.
+
+The :class:`ImageView` uses :class:`PlotWindow` and also
+exposes :class:`silx.gui.plot.Plot` API for further control
+(plot title, axes labels, adding other images, ...).
+
+For an example of use, see the implementation of :class:`ImageViewMainWindow`,
+and `example/imageview.py`.
+"""
+
+from __future__ import division
+
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "13/10/2016"
+
+
+import logging
+import numpy
+
+from .. import qt
+
+from . import items, PlotWindow, PlotWidget, PlotActions
+from .Colors import cursorColorForColormap
+from .PlotTools import LimitsToolBar
+from .Profile import ProfileToolBar
+
+
+_logger = logging.getLogger(__name__)
+
+
+# RadarView ###################################################################
+
+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'))
+ _VISIBLE_PEN = qt.QPen(qt.QColor('red'))
+ _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
+
+ class _DraggableRectItem(qt.QGraphicsRectItem):
+ """RectItem which signals its change through visibleRectDragged."""
+ def __init__(self, *args, **kwargs):
+ super(RadarView._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(RadarView._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(RadarView._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(RadarView._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
+
+ def __init__(self, parent=None):
+ self._scene = qt.QGraphicsScene()
+ self._dataRect = self._scene.addRect(0, 0, 1, 1,
+ self._DATA_PEN,
+ self._DATA_BRUSH)
+ self._visibleRect = self._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)
+
+ 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.setRect(0, 0, width, height)
+ self._visibleRect.setPos(left, top)
+ self.fitInView(self._scene.itemsBoundingRect(), qt.Qt.KeepAspectRatio)
+
+
+# ImageView ###################################################################
+
+class ImageView(PlotWindow):
+ """Display a single image with horizontal and vertical histograms.
+
+ Use :meth:`setImage` to control the displayed image.
+ This class also provides the :class:`silx.gui.plot.Plot` API.
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`.Plot` for the list of supported backend.
+ :type backend: str or :class:`BackendBase.BackendBase`
+ """
+
+ HISTOGRAMS_COLOR = 'blue'
+ """Color to use for the side histograms."""
+
+ HISTOGRAMS_HEIGHT = 200
+ """Height in pixels of the side histograms."""
+
+ IMAGE_MIN_SIZE = 200
+ """Minimum size in pixels of the image area."""
+
+ # Qt signals
+ valueChanged = qt.Signal(float, float, float)
+ """Signals that the data value under the cursor has changed.
+
+ It provides: row, column, data value.
+
+ When the cursor is over an histogram, either row or column is Nan
+ and the provided data value is the histogram value
+ (i.e., the sum along the corresponding row/column).
+ Row and columns are either Nan or integer values.
+ """
+
+ def __init__(self, parent=None, backend=None):
+ self._imageLegend = '__ImageView__image' + str(id(self))
+ self._cache = None # Store currently visible data information
+ self._updatingLimits = False
+
+ super(ImageView, self).__init__(parent=parent, backend=backend,
+ resetzoom=True, autoScale=False,
+ logScale=False, grid=False,
+ curveStyle=False, colormap=True,
+ aspectRatio=True, yInverted=True,
+ copy=True, save=True, print_=True,
+ control=False, position=False,
+ roi=False, mask=True)
+ if parent is None:
+ self.setWindowTitle('ImageView')
+
+ self._initWidgets(backend)
+
+ self.profile = ProfileToolBar(plot=self)
+ """"Profile tools attached to this plot.
+
+ See :class:`silx.gui.plot.PlotTools.ProfileToolBar`
+ """
+
+ self.addToolBar(self.profile)
+
+ # Sync PlotBackend and ImageView
+ self._updateYAxisInverted()
+
+ def _initWidgets(self, backend):
+ """Set-up layout and plots."""
+ # Monkey-patch for histogram size
+ # alternative: create a layout that does not use widget size hints
+ def sizeHint():
+ return qt.QSize(self.HISTOGRAMS_HEIGHT, self.HISTOGRAMS_HEIGHT)
+
+ self._histoHPlot = PlotWidget(backend=backend)
+ self._histoHPlot.setInteractiveMode('zoom')
+ self._histoHPlot.setCallback(self._histoHPlotCB)
+ self._histoHPlot.getWidgetHandle().sizeHint = sizeHint
+ self._histoHPlot.getWidgetHandle().minimumSizeHint = sizeHint
+
+ self.setPanWithArrowKeys(True)
+
+ self.setInteractiveMode('zoom') # Color set in setColormap
+ self.sigPlotSignal.connect(self._imagePlotCB)
+ self.sigSetYAxisInverted.connect(self._updateYAxisInverted)
+ self.sigActiveImageChanged.connect(self._activeImageChangedSlot)
+
+ self._histoVPlot = PlotWidget(backend=backend)
+ self._histoVPlot.setInteractiveMode('zoom')
+ self._histoVPlot.setCallback(self._histoVPlotCB)
+ self._histoVPlot.getWidgetHandle().sizeHint = sizeHint
+ self._histoVPlot.getWidgetHandle().minimumSizeHint = sizeHint
+
+ self._radarView = RadarView()
+ self._radarView.visibleRectDragged.connect(self._radarViewCB)
+
+ self._layout = qt.QGridLayout()
+ self._layout.addWidget(self.getWidgetHandle(), 0, 0)
+ self._layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1)
+ self._layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0)
+ self._layout.addWidget(self._radarView, 1, 1)
+
+ self._layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE)
+ self._layout.setColumnStretch(0, 1)
+ self._layout.setColumnMinimumWidth(1, self.HISTOGRAMS_HEIGHT)
+ self._layout.setColumnStretch(1, 0)
+
+ self._layout.setRowMinimumHeight(0, self.IMAGE_MIN_SIZE)
+ self._layout.setRowStretch(0, 1)
+ self._layout.setRowMinimumHeight(1, self.HISTOGRAMS_HEIGHT)
+ self._layout.setRowStretch(1, 0)
+
+ self._layout.setSpacing(0)
+ self._layout.setContentsMargins(0, 0, 0, 0)
+
+ centralWidget = qt.QWidget()
+ centralWidget.setLayout(self._layout)
+ self.setCentralWidget(centralWidget)
+
+ def _dirtyCache(self):
+ self._cache = None
+
+ def _updateHistograms(self):
+ """Update histograms content using current active image."""
+ activeImage = self.getActiveImage()
+ if activeImage is not None:
+ wasUpdatingLimits = self._updatingLimits
+ self._updatingLimits = True
+
+ data = activeImage.getData(copy=False)
+ origin = activeImage.getOrigin()
+ scale = activeImage.getScale()
+ height, width = data.shape
+
+ xMin, xMax = self.getGraphXLimits()
+ yMin, yMax = self.getGraphYLimits()
+
+ # Convert plot area limits to image coordinates
+ # and work in image coordinates (i.e., in pixels)
+ xMin = int((xMin - origin[0]) / scale[0])
+ xMax = int((xMax - origin[0]) / scale[0])
+ yMin = int((yMin - origin[1]) / scale[1])
+ yMax = int((yMax - origin[1]) / scale[1])
+
+ if (xMin < width and xMax >= 0 and
+ yMin < height and yMax >= 0):
+ # The image is at least partly in the plot area
+ # Get the visible bounds in image coords (i.e., in pixels)
+ subsetXMin = 0 if xMin < 0 else xMin
+ subsetXMax = (width if xMax >= width else xMax) + 1
+ subsetYMin = 0 if yMin < 0 else yMin
+ subsetYMax = (height if yMax >= height else yMax) + 1
+
+ if (self._cache is None or
+ subsetXMin != self._cache['dataXMin'] or
+ subsetXMax != self._cache['dataXMax'] or
+ subsetYMin != self._cache['dataYMin'] or
+ subsetYMax != self._cache['dataYMax']):
+ # The visible area of data has changed, update histograms
+
+ # Rebuild histograms for visible area
+ visibleData = data[subsetYMin:subsetYMax,
+ subsetXMin:subsetXMax]
+ histoHVisibleData = numpy.sum(visibleData, axis=0)
+ histoVVisibleData = numpy.sum(visibleData, axis=1)
+
+ self._cache = {
+ 'dataXMin': subsetXMin,
+ 'dataXMax': subsetXMax,
+ 'dataYMin': subsetYMin,
+ 'dataYMax': subsetYMax,
+
+ 'histoH': histoHVisibleData,
+ 'histoHMin': numpy.min(histoHVisibleData),
+ 'histoHMax': numpy.max(histoHVisibleData),
+
+ 'histoV': histoVVisibleData,
+ 'histoVMin': numpy.min(histoVVisibleData),
+ 'histoVMax': numpy.max(histoVVisibleData)
+ }
+
+ # Convert to histogram curve and update plots
+ # Taking into account origin and scale
+ coords = numpy.arange(2 * histoHVisibleData.size)
+ xCoords = (coords + 1) // 2 + subsetXMin
+ xCoords = origin[0] + scale[0] * xCoords
+ xData = numpy.take(histoHVisibleData, coords // 2)
+ self._histoHPlot.addCurve(xCoords, xData,
+ xlabel='', ylabel='',
+ replace=False,
+ color=self.HISTOGRAMS_COLOR,
+ linestyle='-',
+ selectable=False)
+ vMin = self._cache['histoHMin']
+ vMax = self._cache['histoHMax']
+ vOffset = 0.1 * (vMax - vMin)
+ if vOffset == 0.:
+ vOffset = 1.
+ self._histoHPlot.setGraphYLimits(vMin - vOffset,
+ vMax + vOffset)
+
+ coords = numpy.arange(2 * histoVVisibleData.size)
+ yCoords = (coords + 1) // 2 + subsetYMin
+ yCoords = origin[1] + scale[1] * yCoords
+ yData = numpy.take(histoVVisibleData, coords // 2)
+ self._histoVPlot.addCurve(yData, yCoords,
+ xlabel='', ylabel='',
+ replace=False,
+ color=self.HISTOGRAMS_COLOR,
+ linestyle='-',
+ selectable=False)
+ vMin = self._cache['histoVMin']
+ vMax = self._cache['histoVMax']
+ vOffset = 0.1 * (vMax - vMin)
+ if vOffset == 0.:
+ vOffset = 1.
+ self._histoVPlot.setGraphXLimits(vMin - vOffset,
+ vMax + vOffset)
+ else:
+ self._dirtyCache()
+ self._histoHPlot.remove(kind='curve')
+ self._histoVPlot.remove(kind='curve')
+
+ self._updatingLimits = wasUpdatingLimits
+
+ def _updateRadarView(self):
+ """Update radar view visible area.
+
+ Takes care of y coordinate conversion.
+ """
+ xMin, xMax = self.getGraphXLimits()
+ yMin, yMax = self.getGraphYLimits()
+ self._radarView.setVisibleRect(xMin, yMin, xMax - xMin, yMax - yMin)
+
+ # Plots event listeners
+
+ def _imagePlotCB(self, eventDict):
+ """Callback for imageView plot events."""
+ if eventDict['event'] == 'mouseMoved':
+ activeImage = self.getActiveImage()
+ if activeImage is not None:
+ data = activeImage.getData(copy=False)
+ height, width = data.shape
+
+ # Get corresponding coordinate in image
+ origin = activeImage.getOrigin()
+ scale = activeImage.getScale()
+ if (eventDict['x'] >= origin[0] and
+ eventDict['y'] >= origin[1]):
+ x = int((eventDict['x'] - origin[0]) / scale[0])
+ y = int((eventDict['y'] - origin[1]) / scale[1])
+
+ if x >= 0 and x < width and y >= 0 and y < height:
+ self.valueChanged.emit(float(x), float(y),
+ data[y][x])
+
+ elif eventDict['event'] == 'limitsChanged':
+ # Do not handle histograms limitsChanged while
+ # updating their limits from here.
+ self._updatingLimits = True
+
+ # Refresh histograms
+ self._updateHistograms()
+
+ # could use eventDict['xdata'], eventDict['ydata'] instead
+ xMin, xMax = self.getGraphXLimits()
+ yMin, yMax = self.getGraphYLimits()
+
+ # Set horizontal histo limits
+ self._histoHPlot.setGraphXLimits(xMin, xMax)
+
+ # Set vertical histo limits
+ self._histoVPlot.setGraphYLimits(yMin, yMax)
+
+ self._updateRadarView()
+
+ self._updatingLimits = False
+
+ def _histoHPlotCB(self, eventDict):
+ """Callback for horizontal histogram plot events."""
+ if eventDict['event'] == 'mouseMoved':
+ if self._cache is not None:
+ activeImage = self.getActiveImage()
+ if activeImage is not None:
+ xOrigin = activeImage.getOrigin()[0]
+ xScale = activeImage.getScale()[0]
+
+ minValue = xOrigin + xScale * self._cache['dataXMin']
+
+ if eventDict['x'] >= minValue:
+ data = self._cache['histoH']
+ column = int((eventDict['x'] - minValue) / xScale)
+ if column >= 0 and column < data.shape[0]:
+ self.valueChanged.emit(
+ float('nan'),
+ float(column + self._cache['dataXMin']),
+ data[column])
+
+ elif eventDict['event'] == 'limitsChanged':
+ if (not self._updatingLimits and
+ eventDict['xdata'] != self.getGraphXLimits()):
+ xMin, xMax = eventDict['xdata']
+ self.setGraphXLimits(xMin, xMax)
+
+ def _histoVPlotCB(self, eventDict):
+ """Callback for vertical histogram plot events."""
+ if eventDict['event'] == 'mouseMoved':
+ if self._cache is not None:
+ activeImage = self.getActiveImage()
+ if activeImage is not None:
+ yOrigin = activeImage.getOrigin()[1]
+ yScale = activeImage.getScale()[1]
+
+ minValue = yOrigin + yScale * self._cache['dataYMin']
+
+ if eventDict['y'] >= minValue:
+ data = self._cache['histoV']
+ row = int((eventDict['y'] - minValue) / yScale)
+ if row >= 0 and row < data.shape[0]:
+ self.valueChanged.emit(
+ float(row + self._cache['dataYMin']),
+ float('nan'),
+ data[row])
+
+ elif eventDict['event'] == 'limitsChanged':
+ if (not self._updatingLimits and
+ eventDict['ydata'] != self.getGraphYLimits()):
+ yMin, yMax = eventDict['ydata']
+ self.setGraphYLimits(yMin, yMax)
+
+ def _radarViewCB(self, left, top, width, height):
+ """Slot for radar view visible rectangle changes."""
+ if not self._updatingLimits:
+ # Takes care of Y axis conversion
+ self.setLimits(left, left + width, top, top + height)
+
+ def _updateYAxisInverted(self, inverted=None):
+ """Sync image, vertical histogram and radar view axis orientation."""
+ if inverted is None:
+ # Do not perform this when called from plot signal
+ inverted = self.isYAxisInverted()
+
+ self._histoVPlot.setYAxisInverted(inverted)
+
+ # 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._radarView.resetTransform()
+ if not inverted:
+ self._radarView.scale(1., -1.)
+ self._updateRadarView()
+
+ self._radarView.update()
+
+ def _activeImageChangedSlot(self, previous, legend):
+ """Handle Plot active image change.
+
+ Resets side histograms cache
+ """
+ self._dirtyCache()
+ self._updateHistograms()
+
+ def getHistogram(self, axis):
+ """Return the histogram and corresponding row or column extent.
+
+ The returned value when an histogram is available is a dict with keys:
+
+ - 'data': numpy array of the histogram values.
+ - 'extent': (start, end) row or column index.
+ end index is not included in the histogram.
+
+ :param str axis: 'x' for horizontal, 'y' for vertical
+ :return: The histogram and its extent as a dict or None.
+ :rtype: dict
+ """
+ assert axis in ('x', 'y')
+ if self._cache is None:
+ return None
+ else:
+ if axis == 'x':
+ return dict(
+ data=numpy.array(self._cache['histoH'], copy=True),
+ extent=(self._cache['dataXMin'], self._cache['dataXMax']))
+ else:
+ return dict(
+ data=numpy.array(self._cache['histoV'], copy=True),
+ extent=(self._cache['dataYMin'], self._cache['dataYMax']))
+
+ def radarView(self):
+ """Get the lower right radarView widget."""
+ return self._radarView
+
+ def setRadarView(self, radarView):
+ """Change the lower right radarView widget.
+
+ :param RadarView radarView: Widget subclassing RadarView to replace
+ the lower right corner widget.
+ """
+ self._radarView.visibleRectDragged.disconnect(self._radarViewCB)
+ self._radarView = radarView
+ self._radarView.visibleRectDragged.connect(self._radarViewCB)
+ self._layout.addWidget(self._radarView, 1, 1)
+
+ self._updateYAxisInverted()
+
+ # High-level API
+
+ def getColormap(self):
+ """Get the default colormap description.
+
+ :return: A description of the current colormap.
+ See :meth:`setColormap` for details.
+ :rtype: dict
+ """
+ return self.getDefaultColormap()
+
+ def setColormap(self, colormap=None, normalization=None,
+ autoscale=None, vmin=None, vmax=None, colors=None):
+ """Set the default colormap and update active image.
+
+ Parameters that are not provided are taken from the current colormap.
+
+ The colormap parameter can also be a dict with the following keys:
+
+ - *name*: string. The colormap to use:
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
+ - *normalization*: string. The mapping to use for the colormap:
+ either 'linear' or 'log'.
+ - *autoscale*: bool. Whether to use autoscale (True)
+ or range provided by keys 'vmin' and 'vmax' (False).
+ - *vmin*: float. The minimum value of the range to use if 'autoscale'
+ is False.
+ - *vmax*: float. The maximum value of the range to use if 'autoscale'
+ is False.
+ - *colors*: optional. Nx3 or Nx4 array of float in [0, 1] or uint8.
+ List of RGB or RGBA colors to use (only if name is None)
+
+ :param colormap: Name of the colormap in
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
+ Or the description of the colormap as a dict.
+ :type colormap: dict or str.
+ :param str normalization: Colormap mapping: 'linear' or 'log'.
+ :param bool autoscale: Whether to use autoscale (True)
+ or [vmin, vmax] range (False).
+ :param float vmin: The minimum value of the range to use if
+ 'autoscale' is False.
+ :param float vmax: The maximum value of the range to use if
+ 'autoscale' is False.
+ :param numpy.ndarray colors: Only used if name is None.
+ Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays
+ """
+ cmapDict = self.getDefaultColormap()
+
+ if isinstance(colormap, dict):
+ # Support colormap parameter as a dict
+ assert normalization is None
+ assert autoscale is None
+ assert vmin is None
+ assert vmax is None
+ assert colors is None
+ for key, value in colormap.items():
+ cmapDict[key] = value
+
+ else:
+ if colormap is not None:
+ cmapDict['name'] = colormap
+ if normalization is not None:
+ cmapDict['normalization'] = normalization
+ if autoscale is not None:
+ cmapDict['autoscale'] = autoscale
+ if vmin is not None:
+ cmapDict['vmin'] = vmin
+ if vmax is not None:
+ cmapDict['vmax'] = vmax
+ if colors is not None:
+ cmapDict['colors'] = colors
+
+ cursorColor = cursorColorForColormap(cmapDict['name'])
+ self.setInteractiveMode('zoom', color=cursorColor)
+
+ self.setDefaultColormap(cmapDict)
+
+ # Update active image colormap
+ activeImage = self.getActiveImage()
+ if isinstance(activeImage, items.ColormapMixIn):
+ activeImage.setColormap(self.getColormap())
+
+ def setImage(self, image, origin=(0, 0), scale=(1., 1.),
+ copy=True, reset=True):
+ """Set the image to display.
+
+ :param image: A 2D array representing the image or None to empty plot.
+ :type image: numpy.ndarray-like with 2 dimensions or None.
+ :param origin: The (x, y) position of the origin of the image.
+ Default: (0, 0).
+ The origin is the lower left corner of the image when
+ the Y axis is not inverted.
+ :type origin: Tuple of 2 floats: (origin x, origin y).
+ :param scale: The scale factor to apply to the image on X and Y axes.
+ Default: (1, 1).
+ It is the size of a pixel in the coordinates of the axes.
+ Scales must be positive numbers.
+ :type scale: Tuple of 2 floats: (scale x, scale y).
+ :param bool copy: Whether to copy image data (default) or not.
+ :param bool reset: Whether to reset zoom and ROI (default) or not.
+ """
+ self._dirtyCache()
+
+ assert len(origin) == 2
+ assert len(scale) == 2
+ assert scale[0] > 0
+ assert scale[1] > 0
+
+ if image is None:
+ self.remove(self._imageLegend, kind='image')
+ return
+
+ data = numpy.array(image, order='C', copy=copy)
+ assert data.size != 0
+ assert len(data.shape) == 2
+ height, width = data.shape
+
+ self.addImage(data,
+ legend=self._imageLegend,
+ origin=origin, scale=scale,
+ colormap=self.getColormap(),
+ replace=False)
+ self.setActiveImage(self._imageLegend)
+ self._updateHistograms()
+
+ self._radarView.setDataRect(origin[0],
+ origin[1],
+ width * scale[0],
+ height * scale[1])
+
+ if reset:
+ self.resetZoom()
+
+
+# ImageViewMainWindow #########################################################
+
+class ImageViewMainWindow(ImageView):
+ """:class:`ImageView` with additional toolbars
+
+ Adds extra toolbar and a status bar to :class:`ImageView`.
+ """
+ def __init__(self, parent=None, backend=None):
+ self._dataInfo = None
+ super(ImageViewMainWindow, self).__init__(parent, backend)
+ self.setWindowFlags(qt.Qt.Window)
+
+ self.setGraphXLabel('X')
+ self.setGraphYLabel('Y')
+ self.setGraphTitle('Image')
+
+ # Add toolbars and status bar
+ self.addToolBar(qt.Qt.BottomToolBarArea, LimitsToolBar(plot=self))
+
+ self.statusBar()
+
+ menu = self.menuBar().addMenu('File')
+ menu.addAction(self.saveAction)
+ menu.addAction(self.printAction)
+ menu.addSeparator()
+ action = menu.addAction('Quit')
+ action.triggered[bool].connect(qt.QApplication.instance().quit)
+
+ menu = self.menuBar().addMenu('Edit')
+ menu.addAction(self.copyAction)
+ menu.addSeparator()
+ menu.addAction(self.resetZoomAction)
+ menu.addAction(self.colormapAction)
+ menu.addAction(PlotActions.KeepAspectRatioAction(self, self))
+ menu.addAction(PlotActions.YAxisInvertedAction(self, self))
+
+ menu = self.menuBar().addMenu('Profile')
+ menu.addAction(self.profile.browseAction)
+ menu.addAction(self.profile.hLineAction)
+ menu.addAction(self.profile.vLineAction)
+ menu.addAction(self.profile.lineAction)
+ menu.addAction(self.profile.clearAction)
+
+ # Connect to ImageView's signal
+ self.valueChanged.connect(self._statusBarSlot)
+
+ def _statusBarSlot(self, row, column, value):
+ """Update status bar with coordinates/value from plots."""
+ if numpy.isnan(row):
+ msg = 'Column: %d, Sum: %g' % (int(column), value)
+ elif numpy.isnan(column):
+ msg = 'Row: %d, Sum: %g' % (int(row), value)
+ else:
+ msg = 'Position: (%d, %d), Value: %g' % (int(row), int(column),
+ value)
+ if self._dataInfo is not None:
+ msg = self._dataInfo + ', ' + msg
+
+ self.statusBar().showMessage(msg)
+
+ def setImage(self, image, *args, **kwargs):
+ """Set the displayed image.
+
+ See :meth:`ImageView.setImage` for details.
+ """
+ if hasattr(image, 'dtype') and hasattr(image, 'shape'):
+ assert len(image.shape) == 2
+ height, width = image.shape
+ self._dataInfo = 'Data: %dx%d (%s)' % (width, height,
+ str(image.dtype))
+ self.statusBar().showMessage(self._dataInfo)
+ else:
+ self._dataInfo = None
+
+ # Set the new image in ImageView widget
+ super(ImageViewMainWindow, self).setImage(image, *args, **kwargs)
+ self.setStatusBar(None)
diff --git a/silx/gui/plot/Interaction.py b/silx/gui/plot/Interaction.py
new file mode 100644
index 0000000..f09b9bc
--- /dev/null
+++ b/silx/gui/plot/Interaction.py
@@ -0,0 +1,300 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2016 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 implementation of state machines for interaction.
+
+Sample code of a state machine with two states ('idle' and 'active')
+with transitions on left button press/release:
+
+.. code-block:: python
+
+ from silx.gui.plot.Interaction import *
+
+ class SampleStateMachine(StateMachine):
+
+ class Idle(State):
+ def onPress(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('active')
+
+ class Active(State):
+ def enterState(self):
+ print('Enabled') # Handle enter active state here
+
+ def leaveState(self):
+ print('Disabled') # Handle leave active state here
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('idle')
+
+ def __init__(self):
+ # State machine has 2 states
+ states = {
+ 'idle': SampleStateMachine.Idle,
+ 'active': SampleStateMachine.Active
+ }
+ super(TwoStates, self).__init__(states, 'idle')
+ # idle is the initial state
+
+ stateMachine = SampleStateMachine()
+
+ # Triggers a transition to the Active state:
+ stateMachine.handleEvent('press', 0, 0, LEFT_BTN)
+
+ # Triggers a transition to the Idle state:
+ stateMachine.handleEvent('release', 0, 0, LEFT_BTN)
+
+See :class:`ClickOrDrag` for another example of a state machine.
+
+See `Renaud Blanch, Michel Beaudouin-Lafon.
+Programming Rich Interactions using the Hierarchical State Machine Toolkit.
+In Proceedings of AVI 2006. p 51-58.
+<http://iihm.imag.fr/en/publication/BB06a/>`_
+for a discussion of using (hierarchical) state machines for interaction.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/02/2016"
+
+
+import weakref
+
+
+# state machine ###############################################################
+
+class State(object):
+ """Base class for the states of a state machine.
+
+ This class is meant to be subclassed.
+ """
+
+ def __init__(self, machine):
+ """State instances should be created by the :class:`StateMachine`.
+
+ They are not intended to be used outside this context.
+
+ :param machine: The state machine instance this state belongs to.
+ :type machine: StateMachine
+ """
+ self._machineRef = weakref.ref(machine) # Prevent cyclic reference
+
+ @property
+ def machine(self):
+ """The state machine this state belongs to.
+
+ Useful to access data or methods that are shared across states.
+ """
+ machine = self._machineRef()
+ if machine is not None:
+ return machine
+ else:
+ raise RuntimeError("Associated StateMachine is not valid")
+
+ def goto(self, state, *args, **kwargs):
+ """Performs a transition to a new state.
+
+ Extra arguments are passed to the :meth:`enterState` method of the
+ new state.
+
+ :param str state: The name of the state to go to.
+ """
+ self.machine._goto(state, *args, **kwargs)
+
+ def enterState(self, *args, **kwargs):
+ """Called when the state machine enters this state.
+
+ Arguments are those provided to the :meth:`goto` method that
+ triggered the transition to this state.
+ """
+ pass
+
+ def leaveState(self):
+ """Called when the state machine leaves this state
+ (i.e., when :meth:`goto` is called).
+ """
+ pass
+
+
+class StateMachine(object):
+ """State machine controller.
+
+ This is the entry point of a state machine.
+ It is in charge of dispatching received event and handling the
+ current active state.
+ """
+
+ def __init__(self, states, initState, *args, **kwargs):
+ """Create a state machine controller with an initial state.
+
+ Extra arguments are passed to the :meth:`enterState` method
+ of the initState.
+
+ :param states: All states of the state machine
+ :type states: dict of: {str name: State subclass}
+ :param str initState: Key of the initial state in states
+ """
+ self.states = states
+
+ self.state = self.states[initState](self)
+ self.state.enterState(*args, **kwargs)
+
+ def _goto(self, state, *args, **kwargs):
+ self.state.leaveState()
+ self.state = self.states[state](self)
+ self.state.enterState(*args, **kwargs)
+
+ def handleEvent(self, eventName, *args, **kwargs):
+ """Process an event with the state machine.
+
+ This method looks up for an event handler in the current state
+ and then in the :class:`StateMachine` instance.
+ Handler are looked up as 'onEventName' method.
+ If a handler is found, it is called with the provided extra
+ arguments, and this method returns the return value of the
+ handler.
+ If no handler is found, this method returns None.
+
+ :param str eventName: Name of the event to handle
+ :returns: The return value of the handler or None
+ """
+ handlerName = 'on' + eventName[0].upper() + eventName[1:]
+ try:
+ handler = getattr(self.state, handlerName)
+ except AttributeError:
+ try:
+ handler = getattr(self, handlerName)
+ except AttributeError:
+ handler = None
+ if handler is not None:
+ return handler(*args, **kwargs)
+
+
+# clickOrDrag #################################################################
+
+LEFT_BTN = 'left'
+"""Left mouse button."""
+
+RIGHT_BTN = 'right'
+"""Right mouse button."""
+
+MIDDLE_BTN = 'middle'
+"""Middle mouse button."""
+
+
+class ClickOrDrag(StateMachine):
+ """State machine for left and right click and left drag interaction.
+
+ It is intended to be used through subclassing by overriding
+ :meth:`click`, :meth:`beginDrag`, :meth:`drag` and :meth:`endDrag`.
+ """
+
+ DRAG_THRESHOLD_SQUARE_DIST = 5 ** 2
+
+ class Idle(State):
+ def onPress(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('clickOrDrag', x, y)
+ return True
+ elif btn == RIGHT_BTN:
+ self.goto('rightClick', x, y)
+ return True
+
+ class RightClick(State):
+ def onMove(self, x, y):
+ self.goto('idle')
+
+ def onRelease(self, x, y, btn):
+ if btn == RIGHT_BTN:
+ self.machine.click(x, y, btn)
+ self.goto('idle')
+
+ class ClickOrDrag(State):
+ def enterState(self, x, y):
+ self.initPos = x, y
+
+ def onMove(self, x, y):
+ dx2 = (x - self.initPos[0]) ** 2
+ dy2 = (y - self.initPos[1]) ** 2
+ if (dx2 + dy2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST:
+ self.goto('drag', self.initPos, (x, y))
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.machine.click(x, y, btn)
+ self.goto('idle')
+
+ class Drag(State):
+ def enterState(self, initPos, curPos):
+ self.initPos = initPos
+ self.machine.beginDrag(*initPos)
+ self.machine.drag(*curPos)
+
+ def onMove(self, x, y):
+ self.machine.drag(x, y)
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.machine.endDrag(self.initPos, (x, y))
+ self.goto('idle')
+
+ def __init__(self):
+ states = {
+ 'idle': ClickOrDrag.Idle,
+ 'rightClick': ClickOrDrag.RightClick,
+ 'clickOrDrag': ClickOrDrag.ClickOrDrag,
+ 'drag': ClickOrDrag.Drag
+ }
+ super(ClickOrDrag, self).__init__(states, 'idle')
+
+ def click(self, x, y, btn):
+ """Called upon a left or right button click.
+
+ To override in a subclass.
+ """
+ pass
+
+ def beginDrag(self, x, y):
+ """Called at the beginning of a drag gesture with left button
+ pressed.
+
+ To override in a subclass.
+ """
+ pass
+
+ def drag(self, x, y):
+ """Called on mouse moved during a drag gesture.
+
+ To override in a subclass.
+ """
+ pass
+
+ def endDrag(self, startPoint, endPoint):
+ """Called at the end of a drag gesture when the left button is
+ released.
+
+ To override in a subclass.
+ """
+ pass
diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py
new file mode 100644
index 0000000..3af9050
--- /dev/null
+++ b/silx/gui/plot/LegendSelector.py
@@ -0,0 +1,1087 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Widget displaying curves legends and allowing to operate on curves.
+
+This widget is meant to work with :class:`PlotWindow`.
+"""
+
+__authors__ = ["V.A. Sole", "T. Rueter", "T. Vincent"]
+__license__ = "MIT"
+__data__ = "28/04/2016"
+
+
+import logging
+import weakref
+
+from .. import qt
+
+
+_logger = logging.getLogger(__name__)
+
+# Build all symbols
+# Courtesy of the pyqtgraph project
+Symbols = dict([(name, qt.QPainterPath())
+ for name in ['o', 's', 't', 'd', '+', 'x', '.', ',']])
+Symbols['o'].addEllipse(qt.QRectF(.1, .1, .8, .8))
+Symbols['.'].addEllipse(qt.QRectF(.3, .3, .4, .4))
+Symbols[','].addEllipse(qt.QRectF(.4, .4, .2, .2))
+Symbols['s'].addRect(qt.QRectF(.1, .1, .8, .8))
+
+coords = {
+ 't': [(0.5, 0.), (.1, .8), (.9, .8)],
+ 'd': [(0.1, 0.5), (0.5, 0.), (0.9, 0.5), (0.5, 1.)],
+ '+': [(0.0, 0.40), (0.40, 0.40), (0.40, 0.), (0.60, 0.),
+ (0.60, 0.40), (1., 0.40), (1., 0.60), (0.60, 0.60),
+ (0.60, 1.), (0.40, 1.), (0.40, 0.60), (0., 0.60)],
+ 'x': [(0.0, 0.40), (0.40, 0.40), (0.40, 0.), (0.60, 0.),
+ (0.60, 0.40), (1., 0.40), (1., 0.60), (0.60, 0.60),
+ (0.60, 1.), (0.40, 1.), (0.40, 0.60), (0., 0.60)]
+}
+for s, c in coords.items():
+ Symbols[s].moveTo(*c[0])
+ for x, y in c[1:]:
+ Symbols[s].lineTo(x, y)
+ Symbols[s].closeSubpath()
+tr = qt.QTransform()
+tr.rotate(45)
+Symbols['x'].translate(qt.QPointF(-0.5, -0.5))
+Symbols['x'] = tr.map(Symbols['x'])
+Symbols['x'].translate(qt.QPointF(0.5, 0.5))
+
+NoSymbols = (None, 'None', 'none', '', ' ')
+"""List of values resulting in no symbol being displayed for a curve"""
+
+
+LineStyles = {
+ None: qt.Qt.NoPen,
+ 'None': qt.Qt.NoPen,
+ 'none': qt.Qt.NoPen,
+ '': qt.Qt.NoPen,
+ ' ': qt.Qt.NoPen,
+ '-': qt.Qt.SolidLine,
+ '--': qt.Qt.DashLine,
+ ':': qt.Qt.DotLine,
+ '-.': qt.Qt.DashDotLine
+}
+"""Conversion from matplotlib-like linestyle to Qt"""
+
+NoLineStyle = (None, 'None', 'none', '', ' ')
+"""List of style values resulting in no line being displayed for a curve"""
+
+
+class LegendIcon(qt.QWidget):
+ """Object displaying a curve linestyle and symbol."""
+
+ def __init__(self, parent=None):
+ super(LegendIcon, self).__init__(parent)
+
+ # Visibilities
+ self.showLine = True
+ self.showSymbol = True
+
+ # Line attributes
+ self.lineStyle = qt.Qt.NoPen
+ self.lineWidth = 1.
+ self.lineColor = qt.Qt.green
+
+ self.symbol = ''
+ # Symbol attributes
+ self.symbolStyle = qt.Qt.SolidPattern
+ self.symbolColor = qt.Qt.green
+ self.symbolOutlineBrush = qt.QBrush(qt.Qt.white)
+
+ # Control widget size: sizeHint "is the only acceptable
+ # alternative, so the widget can never grow or shrink"
+ # (c.f. Qt Doc, enum QSizePolicy::Policy)
+ self.setSizePolicy(qt.QSizePolicy.Fixed,
+ qt.QSizePolicy.Fixed)
+
+ def sizeHint(self):
+ return qt.QSize(50, 15)
+
+ # Modify Symbol
+ def setSymbol(self, symbol):
+ symbol = str(symbol)
+ if symbol not in NoSymbols:
+ if symbol not in Symbols:
+ raise ValueError("Unknown symbol: <%s>" % symbol)
+ self.symbol = symbol
+ # self.update() after set...?
+ # Does not seem necessary
+
+ def setSymbolColor(self, color):
+ """
+ :param color: determines the symbol color
+ :type style: qt.QColor
+ """
+ self.symbolColor = qt.QColor(color)
+
+ # Modify Line
+
+ def setLineColor(self, color):
+ self.lineColor = qt.QColor(color)
+
+ def setLineWidth(self, width):
+ self.lineWidth = float(width)
+
+ def setLineStyle(self, style):
+ """Set the linestyle.
+
+ Possible line styles:
+
+ - '', ' ', 'None': No line
+ - '-': solid
+ - '--': dashed
+ - ':': dotted
+ - '-.': dash and dot
+
+ :param str style: The linestyle to use
+ """
+ if style not in LineStyles:
+ raise ValueError('Unknown style: %s', style)
+ self.lineStyle = LineStyles[style]
+
+ # Paint
+
+ def paintEvent(self, event):
+ """
+ :param event: event
+ :type event: QPaintEvent
+ """
+ painter = qt.QPainter(self)
+ self.paint(painter, event.rect(), self.palette())
+
+ def paint(self, painter, rect, palette):
+ painter.save()
+ painter.setRenderHint(qt.QPainter.Antialiasing)
+ # Scale painter to the icon height
+ # current -> width = 2.5, height = 1.0
+ scale = float(self.height())
+ ratio = float(self.width()) / scale
+ painter.scale(scale,
+ scale)
+ symbolOffset = qt.QPointF(.5 * (ratio - 1.), 0.)
+ # Determine and scale offset
+ offset = qt.QPointF(float(rect.left()) / scale, float(rect.top()) / scale)
+ # Draw BG rectangle (for debugging)
+ # bottomRight = qt.QPointF(
+ # float(rect.right())/scale,
+ # float(rect.bottom())/scale)
+ # painter.fillRect(qt.QRectF(offset, bottomRight),
+ # qt.QBrush(qt.Qt.green))
+ llist = []
+ if self.showLine:
+ linePath = qt.QPainterPath()
+ linePath.moveTo(0., 0.5)
+ linePath.lineTo(ratio, 0.5)
+ # linePath.lineTo(2.5, 0.5)
+ linePen = qt.QPen(
+ qt.QBrush(self.lineColor),
+ (self.lineWidth / self.height()),
+ self.lineStyle,
+ qt.Qt.FlatCap
+ )
+ llist.append((linePath,
+ linePen,
+ qt.QBrush(self.lineColor)))
+ if (self.showSymbol and len(self.symbol) and
+ self.symbol not in NoSymbols):
+ # PITFALL ahead: Let this be a warning to others
+ # symbolPath = Symbols[self.symbol]
+ # Copy before translate! Dict is a mutable type
+ symbolPath = qt.QPainterPath(Symbols[self.symbol])
+ symbolPath.translate(symbolOffset)
+ symbolBrush = qt.QBrush(
+ self.symbolColor,
+ self.symbolStyle
+ )
+ symbolPen = qt.QPen(
+ self.symbolOutlineBrush, # Brush
+ 1. / self.height(), # Width
+ qt.Qt.SolidLine # Style
+ )
+ llist.append((symbolPath,
+ symbolPen,
+ symbolBrush))
+ # Draw
+ for path, pen, brush in llist:
+ path.translate(offset)
+ painter.setPen(pen)
+ painter.setBrush(brush)
+ painter.drawPath(path)
+ painter.restore()
+
+
+class LegendModel(qt.QAbstractListModel):
+ """Data model of curve legends.
+
+ It holds the information of the curve:
+
+ - color
+ - line width
+ - line style
+ - visibility of the lines
+ - symbol
+ - visibility of the symbols
+ """
+ iconColorRole = qt.Qt.UserRole + 0
+ iconLineWidthRole = qt.Qt.UserRole + 1
+ iconLineStyleRole = qt.Qt.UserRole + 2
+ showLineRole = qt.Qt.UserRole + 3
+ iconSymbolRole = qt.Qt.UserRole + 4
+ showSymbolRole = qt.Qt.UserRole + 5
+
+ def __init__(self, legendList=None, parent=None):
+ super(LegendModel, self).__init__(parent)
+ if legendList is None:
+ legendList = []
+ self.legendList = []
+ self.insertLegendList(0, legendList)
+
+ def __getitem__(self, idx):
+ if idx >= len(self.legendList):
+ raise IndexError('list index out of range')
+ return self.legendList[idx]
+
+ def rowCount(self, modelIndex=None):
+ return len(self.legendList)
+
+ def flags(self, index):
+ return (qt.Qt.ItemIsEditable |
+ qt.Qt.ItemIsEnabled |
+ qt.Qt.ItemIsSelectable)
+
+ def data(self, modelIndex, role):
+ if modelIndex.isValid:
+ idx = modelIndex.row()
+ else:
+ return None
+ if idx >= len(self.legendList):
+ raise IndexError('list index out of range')
+
+ item = self.legendList[idx]
+ if role == qt.Qt.DisplayRole:
+ # Data to be rendered in the form of text
+ legend = str(item[0])
+ return legend
+ elif role == qt.Qt.SizeHintRole:
+ # size = qt.QSize(200,50)
+ _logger.warning('LegendModel -- size hint role not implemented')
+ return qt.QSize()
+ elif role == qt.Qt.TextAlignmentRole:
+ alignment = qt.Qt.AlignVCenter | qt.Qt.AlignLeft
+ return alignment
+ elif role == qt.Qt.BackgroundRole:
+ # Background color, must be QBrush
+ if idx % 2:
+ brush = qt.QBrush(qt.QColor(240, 240, 240))
+ else:
+ brush = qt.QBrush(qt.Qt.white)
+ return brush
+ elif role == qt.Qt.ForegroundRole:
+ # ForegroundRole color, must be QBrush
+ brush = qt.QBrush(qt.Qt.blue)
+ return brush
+ elif role == qt.Qt.CheckStateRole:
+ return bool(item[2]) # item[2] == True
+ elif role == qt.Qt.ToolTipRole or role == qt.Qt.StatusTipRole:
+ return ''
+ elif role == self.iconColorRole:
+ return item[1]['color']
+ elif role == self.iconLineWidthRole:
+ return item[1]['linewidth']
+ elif role == self.iconLineStyleRole:
+ return item[1]['linestyle']
+ elif role == self.iconSymbolRole:
+ return item[1]['symbol']
+ elif role == self.showLineRole:
+ return item[3]
+ elif role == self.showSymbolRole:
+ return item[4]
+ else:
+ _logger.info('Unkown role requested: %s', str(role))
+ return None
+
+ def setData(self, modelIndex, value, role):
+ if modelIndex.isValid:
+ idx = modelIndex.row()
+ else:
+ return None
+ if idx >= len(self.legendList):
+ # raise IndexError('list index out of range')
+ _logger.warning(
+ 'setData -- List index out of range, idx: %d', idx)
+ return None
+
+ item = self.legendList[idx]
+ try:
+ if role == qt.Qt.DisplayRole:
+ # Set legend
+ item[0] = str(value)
+ elif role == self.iconColorRole:
+ item[1]['color'] = qt.QColor(value)
+ elif role == self.iconLineWidthRole:
+ item[1]['linewidth'] = int(value)
+ elif role == self.iconLineStyleRole:
+ item[1]['linestyle'] = str(value)
+ elif role == self.iconSymbolRole:
+ item[1]['symbol'] = str(value)
+ elif role == qt.Qt.CheckStateRole:
+ item[2] = value
+ elif role == self.showLineRole:
+ item[3] = value
+ elif role == self.showSymbolRole:
+ item[4] = value
+ except ValueError:
+ _logger.warning('Conversion failed:\n\tvalue: %s\n\trole: %s',
+ str(value), str(role))
+ # Can that be right? Read docs again..
+ self.dataChanged.emit(modelIndex, modelIndex)
+ return True
+
+ def insertLegendList(self, row, llist):
+ """
+ :param int row: Determines after which row the items are inserted
+ :param llist: Carries the new legend information
+ :type llist: List
+ """
+ modelIndex = self.createIndex(row, 0)
+ count = len(llist)
+ super(LegendModel, self).beginInsertRows(modelIndex,
+ row,
+ row + count)
+ head = self.legendList[0:row]
+ tail = self.legendList[row:]
+ new = []
+ for (legend, icon) in llist:
+ linestyle = icon.get('linestyle', None)
+ if linestyle in NoLineStyle:
+ # Curve had no line, give it one and hide it
+ # So when toggle line, it will display a solid line
+ showLine = False
+ icon['linestyle'] = '-'
+ else:
+ showLine = True
+
+ symbol = icon.get('symbol', None)
+ if symbol in NoSymbols:
+ # Curve had no symbol, give it one and hide it
+ # So when toggle symbol, it will display 'o'
+ showSymbol = False
+ icon['symbol'] = 'o'
+ else:
+ showSymbol = True
+
+ selected = icon.get('selected', True)
+ item = [legend,
+ icon,
+ selected,
+ showLine,
+ showSymbol]
+ new.append(item)
+ self.legendList = head + new + tail
+ super(LegendModel, self).endInsertRows()
+ return True
+
+ def insertRows(self, row, count, modelIndex=qt.QModelIndex()):
+ raise NotImplementedError('Use LegendModel.insertLegendList instead')
+
+ def removeRow(self, row):
+ return self.removeRows(row, 1)
+
+ def removeRows(self, row, count, modelIndex=qt.QModelIndex()):
+ length = len(self.legendList)
+ if length == 0:
+ # Nothing to do..
+ return True
+ if row < 0 or row >= length:
+ raise IndexError('Index out of range -- ' +
+ 'idx: %d, len: %d' % (row, length))
+ if count == 0:
+ return False
+ super(LegendModel, self).beginRemoveRows(modelIndex,
+ row,
+ row + count)
+ del(self.legendList[row:row + count])
+ super(LegendModel, self).endRemoveRows()
+ return True
+
+ def setEditor(self, event, editor):
+ """
+ :param str event: String that identifies the editor
+ :param editor: Widget used to change data in the underlying model
+ :type editor: QWidget
+ """
+ if event not in self.eventList:
+ raise ValueError('setEditor -- Event must be in %s' %
+ str(self.eventList))
+ self.editorDict[event] = editor
+
+
+class LegendListItemWidget(qt.QItemDelegate):
+ """Object displaying a single item (i.e., a row) in the list."""
+
+ # Notice: LegendListItem does NOT inherit
+ # from QObject, it cannot emit signals!
+
+ def __init__(self, parent=None, itemType=0):
+ super(LegendListItemWidget, self).__init__(parent)
+
+ # Dictionary to render checkboxes
+ self.cbDict = {}
+ self.labelDict = {}
+ self.iconDict = {}
+
+ # Keep checkbox and legend to get sizeHint
+ self.checkbox = qt.QCheckBox()
+ self.legend = qt.QLabel()
+ self.icon = LegendIcon()
+
+ # Context Menu and Editors
+ self.contextMenu = None
+
+ def paint(self, painter, option, modelIndex):
+ """
+ Here be docs..
+
+ :param QPainter painter:
+ :param QStyleOptionViewItem option:
+ :param QModelIndex modelIndex:
+ """
+ painter.save()
+ rect = option.rect
+
+ # Calculate the icon rectangle
+ iconSize = self.icon.sizeHint()
+ # Calculate icon position
+ x = rect.left() + 2
+ y = rect.top() + int(.5 * (rect.height() - iconSize.height()))
+ iconRect = qt.QRect(qt.QPoint(x, y), iconSize)
+
+ # Calculate label rectangle
+ legendSize = qt.QSize(rect.width() - iconSize.width() - 30,
+ rect.height())
+ # Calculate label position
+ x = rect.left() + iconRect.width()
+ y = rect.top()
+ labelRect = qt.QRect(qt.QPoint(x, y), legendSize)
+ labelRect.translate(qt.QPoint(10, 0))
+
+ # Calculate the checkbox rectangle
+ x = rect.right() - 30
+ y = rect.top()
+ chBoxRect = qt.QRect(qt.QPoint(x, y), rect.bottomRight())
+
+ # Remember the rectangles
+ idx = modelIndex.row()
+ self.cbDict[idx] = chBoxRect
+ self.iconDict[idx] = iconRect
+ self.labelDict[idx] = labelRect
+
+ # Draw background first!
+ if option.state & qt.QStyle.State_MouseOver:
+ backgroundBrush = option.palette.highlight()
+ else:
+ backgroundBrush = modelIndex.data(qt.Qt.BackgroundRole)
+ painter.fillRect(rect, backgroundBrush)
+
+ # Draw label
+ legendText = modelIndex.data(qt.Qt.DisplayRole)
+ textBrush = modelIndex.data(qt.Qt.ForegroundRole)
+ textAlign = modelIndex.data(qt.Qt.TextAlignmentRole)
+ painter.setBrush(textBrush)
+ painter.setFont(self.legend.font())
+ painter.drawText(labelRect, textAlign, legendText)
+
+ # Draw icon
+ iconColor = modelIndex.data(LegendModel.iconColorRole)
+ iconLineWidth = modelIndex.data(LegendModel.iconLineWidthRole)
+ iconLineStyle = modelIndex.data(LegendModel.iconLineStyleRole)
+ iconSymbol = modelIndex.data(LegendModel.iconSymbolRole)
+ icon = LegendIcon()
+ icon.resize(iconRect.size())
+ icon.move(iconRect.topRight())
+ icon.showSymbol = modelIndex.data(LegendModel.showSymbolRole)
+ icon.showLine = modelIndex.data(LegendModel.showLineRole)
+ icon.setSymbolColor(iconColor)
+ icon.setLineColor(iconColor)
+ icon.setLineWidth(iconLineWidth)
+ icon.setLineStyle(iconLineStyle)
+ icon.setSymbol(iconSymbol)
+ icon.symbolOutlineBrush = backgroundBrush
+ icon.paint(painter, iconRect, option.palette)
+
+ # Draw the checkbox
+ if modelIndex.data(qt.Qt.CheckStateRole):
+ checkState = qt.Qt.Checked
+ else:
+ checkState = qt.Qt.Unchecked
+
+ self.drawCheck(
+ painter, qt.QStyleOptionViewItem(), chBoxRect, checkState)
+
+ painter.restore()
+
+ def editorEvent(self, event, model, option, modelIndex):
+ # From the docs:
+ # Mouse events are sent to editorEvent()
+ # even if they don't start editing of the item.
+ if event.button() == qt.Qt.RightButton and self.contextMenu:
+ self.contextMenu.exec_(event.globalPos(), modelIndex)
+ return True
+ elif event.button() == qt.Qt.LeftButton:
+ # Check if checkbox was clicked
+ idx = modelIndex.row()
+ cbRect = self.cbDict[idx]
+ if cbRect.contains(event.pos()):
+ # Toggle checkbox
+ model.setData(modelIndex,
+ not modelIndex.data(qt.Qt.CheckStateRole),
+ qt.Qt.CheckStateRole)
+ event.ignore()
+ return True
+ else:
+ return super(LegendListItemWidget, self).editorEvent(
+ event, model, option, modelIndex)
+
+ def createEditor(self, parent, option, idx):
+ _logger.info('### Editor request ###')
+
+ def sizeHint(self, option, idx):
+ # return qt.QSize(68,24)
+ iconSize = self.icon.sizeHint()
+ legendSize = self.legend.sizeHint()
+ checkboxSize = self.checkbox.sizeHint()
+ height = max([iconSize.height(),
+ legendSize.height(),
+ checkboxSize.height()]) + 4
+ width = iconSize.width() + legendSize.width() + checkboxSize.width()
+ return qt.QSize(width, height)
+
+
+class LegendListView(qt.QListView):
+ """Widget displaying a list of curve legends, line style and symbol."""
+
+ sigLegendSignal = qt.Signal(object)
+ """Signal emitting a dict when an action is triggered by the user."""
+
+ __mouseClickedEvent = 'mouseClicked'
+ __checkBoxClickedEvent = 'checkBoxClicked'
+ __legendClickedEvent = 'legendClicked'
+
+ def __init__(self, parent=None, model=None, contextMenu=None):
+ super(LegendListView, self).__init__(parent)
+ self.__lastButton = None
+ self.__lastClickPos = None
+ self.__lastModelIdx = None
+ # Set default delegate
+ self.setItemDelegate(LegendListItemWidget())
+ # Set default editors
+ # self.setSizePolicy(qt.QSizePolicy.MinimumExpanding,
+ # qt.QSizePolicy.MinimumExpanding)
+ # Set edit triggers by hand using self.edit(QModelIndex)
+ # in mousePressEvent (better to control than signals)
+ self.setEditTriggers(qt.QAbstractItemView.NoEditTriggers)
+
+ # Control layout
+ # self.setBatchSize(2)
+ # self.setLayoutMode(qt.QListView.Batched)
+ # self.setFlow(qt.QListView.LeftToRight)
+
+ # Control selection
+ self.setSelectionMode(qt.QAbstractItemView.NoSelection)
+
+ if model is None:
+ model = LegendModel()
+ self.setModel(model)
+ self.setContextMenu(contextMenu)
+
+ def setLegendList(self, legendList, row=None):
+ self.clear()
+ if row is None:
+ row = 0
+ model = self.model()
+ model.insertLegendList(row, legendList)
+ _logger.debug('LegendListView.setLegendList(legendList) finished')
+
+ def clear(self):
+ model = self.model()
+ model.removeRows(0, model.rowCount())
+ _logger.debug('LegendListView.clear() finished')
+
+ def setContextMenu(self, contextMenu=None):
+ delegate = self.itemDelegate()
+ if isinstance(delegate, LegendListItemWidget) and self.model():
+ if contextMenu is None:
+ delegate.contextMenu = LegendListContextMenu(self.model())
+ delegate.contextMenu.sigContextMenu.connect(
+ self._contextMenuSlot)
+ else:
+ delegate.contextMenu = contextMenu
+
+ def __getitem__(self, idx):
+ model = self.model()
+ try:
+ item = model[idx]
+ except ValueError:
+ item = None
+ return item
+
+ def _contextMenuSlot(self, ddict):
+ self.sigLegendSignal.emit(ddict)
+
+ def mousePressEvent(self, event):
+ self.__lastButton = event.button()
+ self.__lastPosition = event.pos()
+ super(LegendListView, self).mousePressEvent(event)
+ # call _handleMouseClick after editing was handled
+ # If right click (context menu) is aborted, no
+ # signal is emitted..
+ self._handleMouseClick(self.indexAt(self.__lastPosition))
+
+ def mouseDoubleClickEvent(self, event):
+ self.__lastButton = event.button()
+ self.__lastPosition = event.pos()
+ super(LegendListView, self).mouseDoubleClickEvent(event)
+ # call _handleMouseClick after editing was handled
+ # If right click (context menu) is aborted, no
+ # signal is emitted..
+ self._handleMouseClick(self.indexAt(self.__lastPosition))
+
+ def mouseMoveEvent(self, event):
+ # LegendListView.mouseMoveEvent is overwritten
+ # to suppress unwanted behavior in the delegate.
+ pass
+
+ def mouseReleaseEvent(self, event):
+ # LegendListView.mouseReleaseEvent is overwritten
+ # to subpress unwanted behavior in the delegate.
+ pass
+
+ def _handleMouseClick(self, modelIndex):
+ """
+ Distinguish between mouse click on Legend
+ and mouse click on CheckBox by setting the
+ currentCheckState attribute in LegendListItem.
+
+ Emits signal sigLegendSignal(ddict)
+
+ :param QModelIndex modelIndex: index of the clicked item
+ """
+ _logger.debug('self._handleMouseClick called')
+ if self.__lastButton not in [qt.Qt.LeftButton,
+ qt.Qt.RightButton]:
+ return
+ if not modelIndex.isValid():
+ _logger.debug('_handleMouseClick -- Invalid QModelIndex')
+ return
+ # model = self.model()
+ idx = modelIndex.row()
+
+ delegate = self.itemDelegate()
+ cbClicked = False
+ if isinstance(delegate, LegendListItemWidget):
+ for cbRect in delegate.cbDict.values():
+ if cbRect.contains(self.__lastPosition):
+ cbClicked = True
+ break
+
+ # TODO: Check for doubleclicks on legend/icon and spawn editors
+
+ ddict = {
+ 'legend': str(modelIndex.data(qt.Qt.DisplayRole)),
+ 'icon': {
+ 'linewidth': str(modelIndex.data(
+ LegendModel.iconLineWidthRole)),
+ 'linestyle': str(modelIndex.data(
+ LegendModel.iconLineStyleRole)),
+ 'symbol': str(modelIndex.data(LegendModel.iconSymbolRole))
+ },
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data())
+ }
+ if self.__lastButton == qt.Qt.RightButton:
+ _logger.debug('Right clicked')
+ ddict['button'] = "right"
+ ddict['event'] = self.__mouseClickedEvent
+ elif cbClicked:
+ _logger.debug('CheckBox clicked')
+ ddict['button'] = "left"
+ ddict['event'] = self.__checkBoxClickedEvent
+ else:
+ _logger.debug('Legend clicked')
+ ddict['button'] = "left"
+ ddict['event'] = self.__legendClickedEvent
+ _logger.debug(' idx: %d\n ddict: %s', idx, str(ddict))
+ self.sigLegendSignal.emit(ddict)
+
+
+class LegendListContextMenu(qt.QMenu):
+ """Contextual menu associated to items in a :class:`LegendListView`."""
+
+ sigContextMenu = qt.Signal(object)
+ """Signal emitting a dict upon contextual menu actions."""
+
+ def __init__(self, model):
+ super(LegendListContextMenu, self).__init__(parent=None)
+ self.model = model
+
+ self.addAction('Set Active', self.setActiveAction)
+ self.addAction('Map to left', self.mapToLeftAction)
+ self.addAction('Map to right', self.mapToRightAction)
+
+ self._pointsAction = self.addAction(
+ 'Points', self.togglePointsAction)
+ self._pointsAction.setCheckable(True)
+
+ self._linesAction = self.addAction('Lines', self.toggleLinesAction)
+ self._linesAction.setCheckable(True)
+
+ self.addAction('Remove curve', self.removeItemAction)
+ self.addAction('Rename curve', self.renameItemAction)
+
+ def exec_(self, pos, idx):
+ self.__currentIdx = idx
+
+ # Set checkable action state
+ modelIndex = self.currentIdx()
+ self._pointsAction.setChecked(
+ modelIndex.data(LegendModel.showSymbolRole))
+ self._linesAction.setChecked(
+ modelIndex.data(LegendModel.showLineRole))
+
+ super(LegendListContextMenu, self).popup(pos)
+
+ def currentIdx(self):
+ return self.__currentIdx
+
+ def mapToLeftAction(self):
+ _logger.debug('LegendListContextMenu.mapToLeftAction called')
+ modelIndex = self.currentIdx()
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ ddict = {
+ 'legend': legend,
+ 'label': legend,
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data()),
+ 'event': "mapToLeft"
+ }
+ self.sigContextMenu.emit(ddict)
+
+ def mapToRightAction(self):
+ _logger.debug('LegendListContextMenu.mapToRightAction called')
+ modelIndex = self.currentIdx()
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ ddict = {
+ 'legend': legend,
+ 'label': legend,
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data()),
+ 'event': "mapToRight"
+ }
+ self.sigContextMenu.emit(ddict)
+
+ def removeItemAction(self):
+ _logger.debug('LegendListContextMenu.removeCurveAction called')
+ modelIndex = self.currentIdx()
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ ddict = {
+ 'legend': legend,
+ 'label': legend,
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data()),
+ 'event': "removeCurve"
+ }
+ self.model.removeRow(modelIndex.row())
+ self.sigContextMenu.emit(ddict)
+
+ def renameItemAction(self):
+ _logger.debug('LegendListContextMenu.renameCurveAction called')
+ modelIndex = self.currentIdx()
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ ddict = {
+ 'legend': legend,
+ 'label': legend,
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data()),
+ 'event': "renameCurve"
+ }
+ self.sigContextMenu.emit(ddict)
+
+ def toggleLinesAction(self):
+ modelIndex = self.currentIdx()
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ ddict = {
+ 'legend': legend,
+ 'label': legend,
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data()),
+ }
+ linestyle = modelIndex.data(LegendModel.iconLineStyleRole)
+ visible = not modelIndex.data(LegendModel.showLineRole)
+ _logger.debug('toggleLinesAction -- lines visible: %s', str(visible))
+ ddict['event'] = "toggleLine"
+ ddict['line'] = visible
+ ddict['linestyle'] = linestyle if visible else ''
+ self.model.setData(modelIndex, visible, LegendModel.showLineRole)
+ self.sigContextMenu.emit(ddict)
+
+ def togglePointsAction(self):
+ modelIndex = self.currentIdx()
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ ddict = {
+ 'legend': legend,
+ 'label': legend,
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data()),
+ }
+ flag = modelIndex.data(LegendModel.showSymbolRole)
+ symbol = modelIndex.data(LegendModel.iconSymbolRole)
+ visible = not flag or symbol in NoSymbols
+ _logger.debug(
+ 'togglePointsAction -- Symbols visible: %s', str(visible))
+
+ ddict['event'] = "togglePoints"
+ ddict['points'] = visible
+ ddict['symbol'] = symbol if visible else ''
+ self.model.setData(modelIndex, visible, LegendModel.showSymbolRole)
+ self.sigContextMenu.emit(ddict)
+
+ def setActiveAction(self):
+ modelIndex = self.currentIdx()
+ legend = str(modelIndex.data(qt.Qt.DisplayRole))
+ _logger.debug('setActiveAction -- active curve: %s', legend)
+ ddict = {
+ 'legend': legend,
+ 'label': legend,
+ 'selected': modelIndex.data(qt.Qt.CheckStateRole),
+ 'type': str(modelIndex.data()),
+ 'event': "setActiveCurve",
+ }
+ self.sigContextMenu.emit(ddict)
+
+
+class RenameCurveDialog(qt.QDialog):
+ """Dialog box to input the name of a curve."""
+
+ def __init__(self, parent=None, current="", curves=()):
+ super(RenameCurveDialog, self).__init__(parent)
+ self.setWindowTitle("Rename Curve %s" % current)
+ self.curves = curves
+ layout = qt.QVBoxLayout(self)
+ self.lineEdit = qt.QLineEdit(self)
+ self.lineEdit.setText(current)
+ self.hbox = qt.QWidget(self)
+ self.hboxLayout = qt.QHBoxLayout(self.hbox)
+ self.hboxLayout.addStretch(1)
+ self.okButton = qt.QPushButton(self.hbox)
+ self.okButton.setText('OK')
+ self.hboxLayout.addWidget(self.okButton)
+ self.cancelButton = qt.QPushButton(self.hbox)
+ self.cancelButton.setText('Cancel')
+ self.hboxLayout.addWidget(self.cancelButton)
+ self.hboxLayout.addStretch(1)
+ layout.addWidget(self.lineEdit)
+ layout.addWidget(self.hbox)
+ self.okButton.clicked.connect(self.preAccept)
+ self.cancelButton.clicked.connect(self.reject)
+
+ def preAccept(self):
+ text = str(self.lineEdit.text())
+ addedText = ""
+ if len(text):
+ if text not in self.curves:
+ self.accept()
+ return
+ else:
+ addedText = "Curve already exists."
+ text = "Invalid Curve Name"
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setWindowTitle(text)
+ text += "\n%s" % addedText
+ msg.setText(text)
+ msg.exec_()
+
+ def getText(self):
+ return str(self.lineEdit.text())
+
+
+class LegendsDockWidget(qt.QDockWidget):
+ """QDockWidget with a :class:`LegendSelector` connected to a PlotWindow.
+
+ It makes the link between the LegendListView widget and the PlotWindow.
+
+ :param parent: See :class:`QDockWidget`
+ :param plot: :class:`.PlotWindow` instance on which to operate
+ """
+
+ def __init__(self, parent=None, plot=None):
+ assert plot is not None
+ self._plotRef = weakref.ref(plot)
+ self._isConnected = False # True if widget connected to plot signals
+
+ super(LegendsDockWidget, self).__init__("Legends", parent)
+
+ self._legendWidget = LegendListView()
+
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self.setWidget(self._legendWidget)
+
+ self.visibilityChanged.connect(
+ self._visibilityChangedHandler)
+
+ self._legendWidget.sigLegendSignal.connect(self._legendSignalHandler)
+
+ @property
+ def plot(self):
+ """The :class:`.PlotWindow` this widget is attached to."""
+ return self._plotRef()
+
+ def renameCurve(self, oldLegend, newLegend):
+ """Change the name of a curve using remove and addCurve
+
+ :param str oldLegend: The legend of the curve to be change
+ :param str newLegend: The new legend of the curve
+ """
+ curve = self.plot.getCurve(oldLegend)
+ self.plot.remove(oldLegend, kind='curve')
+ self.plot.addCurve(curve.getXData(copy=False),
+ curve.getYData(copy=False),
+ legend=newLegend,
+ info=curve.getInfo(),
+ color=curve.getColor(),
+ symbol=curve.getSymbol(),
+ linewidth=curve.getLineWidth(),
+ linestyle=curve.getLineStyle(),
+ xlabel=curve.getXLabel(),
+ ylabel=curve.getYLabel(),
+ xerror=curve.getXErrorData(copy=False),
+ yerror=curve.getYErrorData(copy=False),
+ z=curve.getZValue(),
+ selectable=curve.isSelectable(),
+ fill=curve.isFill(),
+ resetzoom=False)
+
+ def _legendSignalHandler(self, ddict):
+ """Handles events from the LegendListView signal"""
+ _logger.debug("Legend signal ddict = %s", str(ddict))
+
+ if ddict['event'] == "legendClicked":
+ if ddict['button'] == "left":
+ self.plot.setActiveCurve(ddict['legend'])
+
+ elif ddict['event'] == "removeCurve":
+ self.plot.removeCurve(ddict['legend'])
+
+ elif ddict['event'] == "renameCurve":
+ curveList = self.plot.getAllCurves(just_legend=True)
+ oldLegend = ddict['legend']
+ dialog = RenameCurveDialog(self.plot, oldLegend, curveList)
+ ret = dialog.exec_()
+ if ret:
+ newLegend = dialog.getText()
+ self.renameCurve(oldLegend, newLegend)
+
+ elif ddict['event'] == "setActiveCurve":
+ self.plot.setActiveCurve(ddict['legend'])
+
+ elif ddict['event'] == "checkBoxClicked":
+ self.plot.hideCurve(ddict['legend'], not ddict['selected'])
+
+ elif ddict['event'] in ["mapToRight", "mapToLeft"]:
+ legend = ddict['legend']
+ curve = self.plot.getCurve(legend)
+ yaxis = 'right' if ddict['event'] == 'mapToRight' else 'left'
+ self.plot.addCurve(x=curve.getXData(copy=False),
+ y=curve.getYData(copy=False),
+ legend=curve.getLegend(),
+ info=curve.getInfo(),
+ yaxis=yaxis)
+
+ elif ddict['event'] == "togglePoints":
+ legend = ddict['legend']
+ curve = self.plot.getCurve(legend)
+ symbol = ddict['symbol'] if ddict['points'] else ''
+ self.plot.addCurve(x=curve.getXData(copy=False),
+ y=curve.getYData(copy=False),
+ legend=curve.getLegend(),
+ info=curve.getInfo(),
+ symbol=symbol)
+
+ elif ddict['event'] == "toggleLine":
+ legend = ddict['legend']
+ curve = self.plot.getCurve(legend)
+ linestyle = ddict['linestyle'] if ddict['line'] else ''
+ self.plot.addCurve(x=curve.getXData(copy=False),
+ y=curve.getYData(copy=False),
+ legend=curve.getLegend(),
+ info=curve.getInfo(),
+ linestyle=linestyle)
+
+ else:
+ _logger.debug("unhandled event %s", str(ddict['event']))
+
+ def updateLegends(self, *args):
+ """Sync the LegendSelector widget displayed info with the plot.
+ """
+ legendList = []
+ for curve in self.plot.getAllCurves(withhidden=True):
+ legend = curve.getLegend()
+ # Use active color if curve is active
+ if legend == self.plot.getActiveCurve(just_legend=True):
+ color = qt.QColor(self.plot.getActiveCurveColor())
+ else:
+ color = qt.QColor.fromRgbF(*curve.getColor())
+
+ curveInfo = {
+ 'color': color,
+ 'linewidth': curve.getLineWidth(),
+ 'linestyle': curve.getLineStyle(),
+ 'symbol': curve.getSymbol(),
+ 'selected': not self.plot.isCurveHidden(legend)}
+ legendList.append((legend, curveInfo))
+
+ self._legendWidget.setLegendList(legendList)
+
+ def _visibilityChangedHandler(self, visible):
+ if visible:
+ self.updateLegends()
+ if not self._isConnected:
+ self.plot.sigContentChanged.connect(self.updateLegends)
+ self.plot.sigActiveCurveChanged.connect(self.updateLegends)
+ self._isConnected = True
+ else:
+ if self._isConnected:
+ self.plot.sigContentChanged.disconnect(self.updateLegends)
+ self.plot.sigActiveCurveChanged.disconnect(self.updateLegends)
+ self._isConnected = False
+
+ def showEvent(self, event):
+ """Make sure this widget is raised when it is shown
+ (when it is first created as a tab in PlotWindow or when it is shown
+ again after hiding).
+ """
+ self.raise_()
diff --git a/silx/gui/plot/MPLColormap.py b/silx/gui/plot/MPLColormap.py
new file mode 100644
index 0000000..49b11d7
--- /dev/null
+++ b/silx/gui/plot/MPLColormap.py
@@ -0,0 +1,1062 @@
+# New matplotlib colormaps by Nathaniel J. Smith, Stefan van der Walt,
+# and (in the case of viridis) Eric Firing.
+#
+# This file and the colormaps in it are released under the CC0 license /
+# public domain dedication. We would appreciate credit if you use or
+# redistribute these colormaps, but do not impose any legal restrictions.
+#
+# To the extent possible under law, the persons who associated CC0 with
+# mpl-colormaps have waived all copyright and related or neighboring rights
+# to mpl-colormaps.
+#
+# You should have received a copy of the CC0 legalcode along with this
+# work. If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
+"""Matplotlib's new colormaps"""
+
+
+from matplotlib.colors import ListedColormap
+
+
+__all__ = ['magma', 'inferno', 'plasma', 'viridis']
+
+_magma_data = [[0.001462, 0.000466, 0.013866],
+ [0.002258, 0.001295, 0.018331],
+ [0.003279, 0.002305, 0.023708],
+ [0.004512, 0.003490, 0.029965],
+ [0.005950, 0.004843, 0.037130],
+ [0.007588, 0.006356, 0.044973],
+ [0.009426, 0.008022, 0.052844],
+ [0.011465, 0.009828, 0.060750],
+ [0.013708, 0.011771, 0.068667],
+ [0.016156, 0.013840, 0.076603],
+ [0.018815, 0.016026, 0.084584],
+ [0.021692, 0.018320, 0.092610],
+ [0.024792, 0.020715, 0.100676],
+ [0.028123, 0.023201, 0.108787],
+ [0.031696, 0.025765, 0.116965],
+ [0.035520, 0.028397, 0.125209],
+ [0.039608, 0.031090, 0.133515],
+ [0.043830, 0.033830, 0.141886],
+ [0.048062, 0.036607, 0.150327],
+ [0.052320, 0.039407, 0.158841],
+ [0.056615, 0.042160, 0.167446],
+ [0.060949, 0.044794, 0.176129],
+ [0.065330, 0.047318, 0.184892],
+ [0.069764, 0.049726, 0.193735],
+ [0.074257, 0.052017, 0.202660],
+ [0.078815, 0.054184, 0.211667],
+ [0.083446, 0.056225, 0.220755],
+ [0.088155, 0.058133, 0.229922],
+ [0.092949, 0.059904, 0.239164],
+ [0.097833, 0.061531, 0.248477],
+ [0.102815, 0.063010, 0.257854],
+ [0.107899, 0.064335, 0.267289],
+ [0.113094, 0.065492, 0.276784],
+ [0.118405, 0.066479, 0.286321],
+ [0.123833, 0.067295, 0.295879],
+ [0.129380, 0.067935, 0.305443],
+ [0.135053, 0.068391, 0.315000],
+ [0.140858, 0.068654, 0.324538],
+ [0.146785, 0.068738, 0.334011],
+ [0.152839, 0.068637, 0.343404],
+ [0.159018, 0.068354, 0.352688],
+ [0.165308, 0.067911, 0.361816],
+ [0.171713, 0.067305, 0.370771],
+ [0.178212, 0.066576, 0.379497],
+ [0.184801, 0.065732, 0.387973],
+ [0.191460, 0.064818, 0.396152],
+ [0.198177, 0.063862, 0.404009],
+ [0.204935, 0.062907, 0.411514],
+ [0.211718, 0.061992, 0.418647],
+ [0.218512, 0.061158, 0.425392],
+ [0.225302, 0.060445, 0.431742],
+ [0.232077, 0.059889, 0.437695],
+ [0.238826, 0.059517, 0.443256],
+ [0.245543, 0.059352, 0.448436],
+ [0.252220, 0.059415, 0.453248],
+ [0.258857, 0.059706, 0.457710],
+ [0.265447, 0.060237, 0.461840],
+ [0.271994, 0.060994, 0.465660],
+ [0.278493, 0.061978, 0.469190],
+ [0.284951, 0.063168, 0.472451],
+ [0.291366, 0.064553, 0.475462],
+ [0.297740, 0.066117, 0.478243],
+ [0.304081, 0.067835, 0.480812],
+ [0.310382, 0.069702, 0.483186],
+ [0.316654, 0.071690, 0.485380],
+ [0.322899, 0.073782, 0.487408],
+ [0.329114, 0.075972, 0.489287],
+ [0.335308, 0.078236, 0.491024],
+ [0.341482, 0.080564, 0.492631],
+ [0.347636, 0.082946, 0.494121],
+ [0.353773, 0.085373, 0.495501],
+ [0.359898, 0.087831, 0.496778],
+ [0.366012, 0.090314, 0.497960],
+ [0.372116, 0.092816, 0.499053],
+ [0.378211, 0.095332, 0.500067],
+ [0.384299, 0.097855, 0.501002],
+ [0.390384, 0.100379, 0.501864],
+ [0.396467, 0.102902, 0.502658],
+ [0.402548, 0.105420, 0.503386],
+ [0.408629, 0.107930, 0.504052],
+ [0.414709, 0.110431, 0.504662],
+ [0.420791, 0.112920, 0.505215],
+ [0.426877, 0.115395, 0.505714],
+ [0.432967, 0.117855, 0.506160],
+ [0.439062, 0.120298, 0.506555],
+ [0.445163, 0.122724, 0.506901],
+ [0.451271, 0.125132, 0.507198],
+ [0.457386, 0.127522, 0.507448],
+ [0.463508, 0.129893, 0.507652],
+ [0.469640, 0.132245, 0.507809],
+ [0.475780, 0.134577, 0.507921],
+ [0.481929, 0.136891, 0.507989],
+ [0.488088, 0.139186, 0.508011],
+ [0.494258, 0.141462, 0.507988],
+ [0.500438, 0.143719, 0.507920],
+ [0.506629, 0.145958, 0.507806],
+ [0.512831, 0.148179, 0.507648],
+ [0.519045, 0.150383, 0.507443],
+ [0.525270, 0.152569, 0.507192],
+ [0.531507, 0.154739, 0.506895],
+ [0.537755, 0.156894, 0.506551],
+ [0.544015, 0.159033, 0.506159],
+ [0.550287, 0.161158, 0.505719],
+ [0.556571, 0.163269, 0.505230],
+ [0.562866, 0.165368, 0.504692],
+ [0.569172, 0.167454, 0.504105],
+ [0.575490, 0.169530, 0.503466],
+ [0.581819, 0.171596, 0.502777],
+ [0.588158, 0.173652, 0.502035],
+ [0.594508, 0.175701, 0.501241],
+ [0.600868, 0.177743, 0.500394],
+ [0.607238, 0.179779, 0.499492],
+ [0.613617, 0.181811, 0.498536],
+ [0.620005, 0.183840, 0.497524],
+ [0.626401, 0.185867, 0.496456],
+ [0.632805, 0.187893, 0.495332],
+ [0.639216, 0.189921, 0.494150],
+ [0.645633, 0.191952, 0.492910],
+ [0.652056, 0.193986, 0.491611],
+ [0.658483, 0.196027, 0.490253],
+ [0.664915, 0.198075, 0.488836],
+ [0.671349, 0.200133, 0.487358],
+ [0.677786, 0.202203, 0.485819],
+ [0.684224, 0.204286, 0.484219],
+ [0.690661, 0.206384, 0.482558],
+ [0.697098, 0.208501, 0.480835],
+ [0.703532, 0.210638, 0.479049],
+ [0.709962, 0.212797, 0.477201],
+ [0.716387, 0.214982, 0.475290],
+ [0.722805, 0.217194, 0.473316],
+ [0.729216, 0.219437, 0.471279],
+ [0.735616, 0.221713, 0.469180],
+ [0.742004, 0.224025, 0.467018],
+ [0.748378, 0.226377, 0.464794],
+ [0.754737, 0.228772, 0.462509],
+ [0.761077, 0.231214, 0.460162],
+ [0.767398, 0.233705, 0.457755],
+ [0.773695, 0.236249, 0.455289],
+ [0.779968, 0.238851, 0.452765],
+ [0.786212, 0.241514, 0.450184],
+ [0.792427, 0.244242, 0.447543],
+ [0.798608, 0.247040, 0.444848],
+ [0.804752, 0.249911, 0.442102],
+ [0.810855, 0.252861, 0.439305],
+ [0.816914, 0.255895, 0.436461],
+ [0.822926, 0.259016, 0.433573],
+ [0.828886, 0.262229, 0.430644],
+ [0.834791, 0.265540, 0.427671],
+ [0.840636, 0.268953, 0.424666],
+ [0.846416, 0.272473, 0.421631],
+ [0.852126, 0.276106, 0.418573],
+ [0.857763, 0.279857, 0.415496],
+ [0.863320, 0.283729, 0.412403],
+ [0.868793, 0.287728, 0.409303],
+ [0.874176, 0.291859, 0.406205],
+ [0.879464, 0.296125, 0.403118],
+ [0.884651, 0.300530, 0.400047],
+ [0.889731, 0.305079, 0.397002],
+ [0.894700, 0.309773, 0.393995],
+ [0.899552, 0.314616, 0.391037],
+ [0.904281, 0.319610, 0.388137],
+ [0.908884, 0.324755, 0.385308],
+ [0.913354, 0.330052, 0.382563],
+ [0.917689, 0.335500, 0.379915],
+ [0.921884, 0.341098, 0.377376],
+ [0.925937, 0.346844, 0.374959],
+ [0.929845, 0.352734, 0.372677],
+ [0.933606, 0.358764, 0.370541],
+ [0.937221, 0.364929, 0.368567],
+ [0.940687, 0.371224, 0.366762],
+ [0.944006, 0.377643, 0.365136],
+ [0.947180, 0.384178, 0.363701],
+ [0.950210, 0.390820, 0.362468],
+ [0.953099, 0.397563, 0.361438],
+ [0.955849, 0.404400, 0.360619],
+ [0.958464, 0.411324, 0.360014],
+ [0.960949, 0.418323, 0.359630],
+ [0.963310, 0.425390, 0.359469],
+ [0.965549, 0.432519, 0.359529],
+ [0.967671, 0.439703, 0.359810],
+ [0.969680, 0.446936, 0.360311],
+ [0.971582, 0.454210, 0.361030],
+ [0.973381, 0.461520, 0.361965],
+ [0.975082, 0.468861, 0.363111],
+ [0.976690, 0.476226, 0.364466],
+ [0.978210, 0.483612, 0.366025],
+ [0.979645, 0.491014, 0.367783],
+ [0.981000, 0.498428, 0.369734],
+ [0.982279, 0.505851, 0.371874],
+ [0.983485, 0.513280, 0.374198],
+ [0.984622, 0.520713, 0.376698],
+ [0.985693, 0.528148, 0.379371],
+ [0.986700, 0.535582, 0.382210],
+ [0.987646, 0.543015, 0.385210],
+ [0.988533, 0.550446, 0.388365],
+ [0.989363, 0.557873, 0.391671],
+ [0.990138, 0.565296, 0.395122],
+ [0.990871, 0.572706, 0.398714],
+ [0.991558, 0.580107, 0.402441],
+ [0.992196, 0.587502, 0.406299],
+ [0.992785, 0.594891, 0.410283],
+ [0.993326, 0.602275, 0.414390],
+ [0.993834, 0.609644, 0.418613],
+ [0.994309, 0.616999, 0.422950],
+ [0.994738, 0.624350, 0.427397],
+ [0.995122, 0.631696, 0.431951],
+ [0.995480, 0.639027, 0.436607],
+ [0.995810, 0.646344, 0.441361],
+ [0.996096, 0.653659, 0.446213],
+ [0.996341, 0.660969, 0.451160],
+ [0.996580, 0.668256, 0.456192],
+ [0.996775, 0.675541, 0.461314],
+ [0.996925, 0.682828, 0.466526],
+ [0.997077, 0.690088, 0.471811],
+ [0.997186, 0.697349, 0.477182],
+ [0.997254, 0.704611, 0.482635],
+ [0.997325, 0.711848, 0.488154],
+ [0.997351, 0.719089, 0.493755],
+ [0.997351, 0.726324, 0.499428],
+ [0.997341, 0.733545, 0.505167],
+ [0.997285, 0.740772, 0.510983],
+ [0.997228, 0.747981, 0.516859],
+ [0.997138, 0.755190, 0.522806],
+ [0.997019, 0.762398, 0.528821],
+ [0.996898, 0.769591, 0.534892],
+ [0.996727, 0.776795, 0.541039],
+ [0.996571, 0.783977, 0.547233],
+ [0.996369, 0.791167, 0.553499],
+ [0.996162, 0.798348, 0.559820],
+ [0.995932, 0.805527, 0.566202],
+ [0.995680, 0.812706, 0.572645],
+ [0.995424, 0.819875, 0.579140],
+ [0.995131, 0.827052, 0.585701],
+ [0.994851, 0.834213, 0.592307],
+ [0.994524, 0.841387, 0.598983],
+ [0.994222, 0.848540, 0.605696],
+ [0.993866, 0.855711, 0.612482],
+ [0.993545, 0.862859, 0.619299],
+ [0.993170, 0.870024, 0.626189],
+ [0.992831, 0.877168, 0.633109],
+ [0.992440, 0.884330, 0.640099],
+ [0.992089, 0.891470, 0.647116],
+ [0.991688, 0.898627, 0.654202],
+ [0.991332, 0.905763, 0.661309],
+ [0.990930, 0.912915, 0.668481],
+ [0.990570, 0.920049, 0.675675],
+ [0.990175, 0.927196, 0.682926],
+ [0.989815, 0.934329, 0.690198],
+ [0.989434, 0.941470, 0.697519],
+ [0.989077, 0.948604, 0.704863],
+ [0.988717, 0.955742, 0.712242],
+ [0.988367, 0.962878, 0.719649],
+ [0.988033, 0.970012, 0.727077],
+ [0.987691, 0.977154, 0.734536],
+ [0.987387, 0.984288, 0.742002],
+ [0.987053, 0.991438, 0.749504]]
+
+_inferno_data = [[0.001462, 0.000466, 0.013866],
+ [0.002267, 0.001270, 0.018570],
+ [0.003299, 0.002249, 0.024239],
+ [0.004547, 0.003392, 0.030909],
+ [0.006006, 0.004692, 0.038558],
+ [0.007676, 0.006136, 0.046836],
+ [0.009561, 0.007713, 0.055143],
+ [0.011663, 0.009417, 0.063460],
+ [0.013995, 0.011225, 0.071862],
+ [0.016561, 0.013136, 0.080282],
+ [0.019373, 0.015133, 0.088767],
+ [0.022447, 0.017199, 0.097327],
+ [0.025793, 0.019331, 0.105930],
+ [0.029432, 0.021503, 0.114621],
+ [0.033385, 0.023702, 0.123397],
+ [0.037668, 0.025921, 0.132232],
+ [0.042253, 0.028139, 0.141141],
+ [0.046915, 0.030324, 0.150164],
+ [0.051644, 0.032474, 0.159254],
+ [0.056449, 0.034569, 0.168414],
+ [0.061340, 0.036590, 0.177642],
+ [0.066331, 0.038504, 0.186962],
+ [0.071429, 0.040294, 0.196354],
+ [0.076637, 0.041905, 0.205799],
+ [0.081962, 0.043328, 0.215289],
+ [0.087411, 0.044556, 0.224813],
+ [0.092990, 0.045583, 0.234358],
+ [0.098702, 0.046402, 0.243904],
+ [0.104551, 0.047008, 0.253430],
+ [0.110536, 0.047399, 0.262912],
+ [0.116656, 0.047574, 0.272321],
+ [0.122908, 0.047536, 0.281624],
+ [0.129285, 0.047293, 0.290788],
+ [0.135778, 0.046856, 0.299776],
+ [0.142378, 0.046242, 0.308553],
+ [0.149073, 0.045468, 0.317085],
+ [0.155850, 0.044559, 0.325338],
+ [0.162689, 0.043554, 0.333277],
+ [0.169575, 0.042489, 0.340874],
+ [0.176493, 0.041402, 0.348111],
+ [0.183429, 0.040329, 0.354971],
+ [0.190367, 0.039309, 0.361447],
+ [0.197297, 0.038400, 0.367535],
+ [0.204209, 0.037632, 0.373238],
+ [0.211095, 0.037030, 0.378563],
+ [0.217949, 0.036615, 0.383522],
+ [0.224763, 0.036405, 0.388129],
+ [0.231538, 0.036405, 0.392400],
+ [0.238273, 0.036621, 0.396353],
+ [0.244967, 0.037055, 0.400007],
+ [0.251620, 0.037705, 0.403378],
+ [0.258234, 0.038571, 0.406485],
+ [0.264810, 0.039647, 0.409345],
+ [0.271347, 0.040922, 0.411976],
+ [0.277850, 0.042353, 0.414392],
+ [0.284321, 0.043933, 0.416608],
+ [0.290763, 0.045644, 0.418637],
+ [0.297178, 0.047470, 0.420491],
+ [0.303568, 0.049396, 0.422182],
+ [0.309935, 0.051407, 0.423721],
+ [0.316282, 0.053490, 0.425116],
+ [0.322610, 0.055634, 0.426377],
+ [0.328921, 0.057827, 0.427511],
+ [0.335217, 0.060060, 0.428524],
+ [0.341500, 0.062325, 0.429425],
+ [0.347771, 0.064616, 0.430217],
+ [0.354032, 0.066925, 0.430906],
+ [0.360284, 0.069247, 0.431497],
+ [0.366529, 0.071579, 0.431994],
+ [0.372768, 0.073915, 0.432400],
+ [0.379001, 0.076253, 0.432719],
+ [0.385228, 0.078591, 0.432955],
+ [0.391453, 0.080927, 0.433109],
+ [0.397674, 0.083257, 0.433183],
+ [0.403894, 0.085580, 0.433179],
+ [0.410113, 0.087896, 0.433098],
+ [0.416331, 0.090203, 0.432943],
+ [0.422549, 0.092501, 0.432714],
+ [0.428768, 0.094790, 0.432412],
+ [0.434987, 0.097069, 0.432039],
+ [0.441207, 0.099338, 0.431594],
+ [0.447428, 0.101597, 0.431080],
+ [0.453651, 0.103848, 0.430498],
+ [0.459875, 0.106089, 0.429846],
+ [0.466100, 0.108322, 0.429125],
+ [0.472328, 0.110547, 0.428334],
+ [0.478558, 0.112764, 0.427475],
+ [0.484789, 0.114974, 0.426548],
+ [0.491022, 0.117179, 0.425552],
+ [0.497257, 0.119379, 0.424488],
+ [0.503493, 0.121575, 0.423356],
+ [0.509730, 0.123769, 0.422156],
+ [0.515967, 0.125960, 0.420887],
+ [0.522206, 0.128150, 0.419549],
+ [0.528444, 0.130341, 0.418142],
+ [0.534683, 0.132534, 0.416667],
+ [0.540920, 0.134729, 0.415123],
+ [0.547157, 0.136929, 0.413511],
+ [0.553392, 0.139134, 0.411829],
+ [0.559624, 0.141346, 0.410078],
+ [0.565854, 0.143567, 0.408258],
+ [0.572081, 0.145797, 0.406369],
+ [0.578304, 0.148039, 0.404411],
+ [0.584521, 0.150294, 0.402385],
+ [0.590734, 0.152563, 0.400290],
+ [0.596940, 0.154848, 0.398125],
+ [0.603139, 0.157151, 0.395891],
+ [0.609330, 0.159474, 0.393589],
+ [0.615513, 0.161817, 0.391219],
+ [0.621685, 0.164184, 0.388781],
+ [0.627847, 0.166575, 0.386276],
+ [0.633998, 0.168992, 0.383704],
+ [0.640135, 0.171438, 0.381065],
+ [0.646260, 0.173914, 0.378359],
+ [0.652369, 0.176421, 0.375586],
+ [0.658463, 0.178962, 0.372748],
+ [0.664540, 0.181539, 0.369846],
+ [0.670599, 0.184153, 0.366879],
+ [0.676638, 0.186807, 0.363849],
+ [0.682656, 0.189501, 0.360757],
+ [0.688653, 0.192239, 0.357603],
+ [0.694627, 0.195021, 0.354388],
+ [0.700576, 0.197851, 0.351113],
+ [0.706500, 0.200728, 0.347777],
+ [0.712396, 0.203656, 0.344383],
+ [0.718264, 0.206636, 0.340931],
+ [0.724103, 0.209670, 0.337424],
+ [0.729909, 0.212759, 0.333861],
+ [0.735683, 0.215906, 0.330245],
+ [0.741423, 0.219112, 0.326576],
+ [0.747127, 0.222378, 0.322856],
+ [0.752794, 0.225706, 0.319085],
+ [0.758422, 0.229097, 0.315266],
+ [0.764010, 0.232554, 0.311399],
+ [0.769556, 0.236077, 0.307485],
+ [0.775059, 0.239667, 0.303526],
+ [0.780517, 0.243327, 0.299523],
+ [0.785929, 0.247056, 0.295477],
+ [0.791293, 0.250856, 0.291390],
+ [0.796607, 0.254728, 0.287264],
+ [0.801871, 0.258674, 0.283099],
+ [0.807082, 0.262692, 0.278898],
+ [0.812239, 0.266786, 0.274661],
+ [0.817341, 0.270954, 0.270390],
+ [0.822386, 0.275197, 0.266085],
+ [0.827372, 0.279517, 0.261750],
+ [0.832299, 0.283913, 0.257383],
+ [0.837165, 0.288385, 0.252988],
+ [0.841969, 0.292933, 0.248564],
+ [0.846709, 0.297559, 0.244113],
+ [0.851384, 0.302260, 0.239636],
+ [0.855992, 0.307038, 0.235133],
+ [0.860533, 0.311892, 0.230606],
+ [0.865006, 0.316822, 0.226055],
+ [0.869409, 0.321827, 0.221482],
+ [0.873741, 0.326906, 0.216886],
+ [0.878001, 0.332060, 0.212268],
+ [0.882188, 0.337287, 0.207628],
+ [0.886302, 0.342586, 0.202968],
+ [0.890341, 0.347957, 0.198286],
+ [0.894305, 0.353399, 0.193584],
+ [0.898192, 0.358911, 0.188860],
+ [0.902003, 0.364492, 0.184116],
+ [0.905735, 0.370140, 0.179350],
+ [0.909390, 0.375856, 0.174563],
+ [0.912966, 0.381636, 0.169755],
+ [0.916462, 0.387481, 0.164924],
+ [0.919879, 0.393389, 0.160070],
+ [0.923215, 0.399359, 0.155193],
+ [0.926470, 0.405389, 0.150292],
+ [0.929644, 0.411479, 0.145367],
+ [0.932737, 0.417627, 0.140417],
+ [0.935747, 0.423831, 0.135440],
+ [0.938675, 0.430091, 0.130438],
+ [0.941521, 0.436405, 0.125409],
+ [0.944285, 0.442772, 0.120354],
+ [0.946965, 0.449191, 0.115272],
+ [0.949562, 0.455660, 0.110164],
+ [0.952075, 0.462178, 0.105031],
+ [0.954506, 0.468744, 0.099874],
+ [0.956852, 0.475356, 0.094695],
+ [0.959114, 0.482014, 0.089499],
+ [0.961293, 0.488716, 0.084289],
+ [0.963387, 0.495462, 0.079073],
+ [0.965397, 0.502249, 0.073859],
+ [0.967322, 0.509078, 0.068659],
+ [0.969163, 0.515946, 0.063488],
+ [0.970919, 0.522853, 0.058367],
+ [0.972590, 0.529798, 0.053324],
+ [0.974176, 0.536780, 0.048392],
+ [0.975677, 0.543798, 0.043618],
+ [0.977092, 0.550850, 0.039050],
+ [0.978422, 0.557937, 0.034931],
+ [0.979666, 0.565057, 0.031409],
+ [0.980824, 0.572209, 0.028508],
+ [0.981895, 0.579392, 0.026250],
+ [0.982881, 0.586606, 0.024661],
+ [0.983779, 0.593849, 0.023770],
+ [0.984591, 0.601122, 0.023606],
+ [0.985315, 0.608422, 0.024202],
+ [0.985952, 0.615750, 0.025592],
+ [0.986502, 0.623105, 0.027814],
+ [0.986964, 0.630485, 0.030908],
+ [0.987337, 0.637890, 0.034916],
+ [0.987622, 0.645320, 0.039886],
+ [0.987819, 0.652773, 0.045581],
+ [0.987926, 0.660250, 0.051750],
+ [0.987945, 0.667748, 0.058329],
+ [0.987874, 0.675267, 0.065257],
+ [0.987714, 0.682807, 0.072489],
+ [0.987464, 0.690366, 0.079990],
+ [0.987124, 0.697944, 0.087731],
+ [0.986694, 0.705540, 0.095694],
+ [0.986175, 0.713153, 0.103863],
+ [0.985566, 0.720782, 0.112229],
+ [0.984865, 0.728427, 0.120785],
+ [0.984075, 0.736087, 0.129527],
+ [0.983196, 0.743758, 0.138453],
+ [0.982228, 0.751442, 0.147565],
+ [0.981173, 0.759135, 0.156863],
+ [0.980032, 0.766837, 0.166353],
+ [0.978806, 0.774545, 0.176037],
+ [0.977497, 0.782258, 0.185923],
+ [0.976108, 0.789974, 0.196018],
+ [0.974638, 0.797692, 0.206332],
+ [0.973088, 0.805409, 0.216877],
+ [0.971468, 0.813122, 0.227658],
+ [0.969783, 0.820825, 0.238686],
+ [0.968041, 0.828515, 0.249972],
+ [0.966243, 0.836191, 0.261534],
+ [0.964394, 0.843848, 0.273391],
+ [0.962517, 0.851476, 0.285546],
+ [0.960626, 0.859069, 0.298010],
+ [0.958720, 0.866624, 0.310820],
+ [0.956834, 0.874129, 0.323974],
+ [0.954997, 0.881569, 0.337475],
+ [0.953215, 0.888942, 0.351369],
+ [0.951546, 0.896226, 0.365627],
+ [0.950018, 0.903409, 0.380271],
+ [0.948683, 0.910473, 0.395289],
+ [0.947594, 0.917399, 0.410665],
+ [0.946809, 0.924168, 0.426373],
+ [0.946392, 0.930761, 0.442367],
+ [0.946403, 0.937159, 0.458592],
+ [0.946903, 0.943348, 0.474970],
+ [0.947937, 0.949318, 0.491426],
+ [0.949545, 0.955063, 0.507860],
+ [0.951740, 0.960587, 0.524203],
+ [0.954529, 0.965896, 0.540361],
+ [0.957896, 0.971003, 0.556275],
+ [0.961812, 0.975924, 0.571925],
+ [0.966249, 0.980678, 0.587206],
+ [0.971162, 0.985282, 0.602154],
+ [0.976511, 0.989753, 0.616760],
+ [0.982257, 0.994109, 0.631017],
+ [0.988362, 0.998364, 0.644924]]
+
+_plasma_data = [[0.050383, 0.029803, 0.527975],
+ [0.063536, 0.028426, 0.533124],
+ [0.075353, 0.027206, 0.538007],
+ [0.086222, 0.026125, 0.542658],
+ [0.096379, 0.025165, 0.547103],
+ [0.105980, 0.024309, 0.551368],
+ [0.115124, 0.023556, 0.555468],
+ [0.123903, 0.022878, 0.559423],
+ [0.132381, 0.022258, 0.563250],
+ [0.140603, 0.021687, 0.566959],
+ [0.148607, 0.021154, 0.570562],
+ [0.156421, 0.020651, 0.574065],
+ [0.164070, 0.020171, 0.577478],
+ [0.171574, 0.019706, 0.580806],
+ [0.178950, 0.019252, 0.584054],
+ [0.186213, 0.018803, 0.587228],
+ [0.193374, 0.018354, 0.590330],
+ [0.200445, 0.017902, 0.593364],
+ [0.207435, 0.017442, 0.596333],
+ [0.214350, 0.016973, 0.599239],
+ [0.221197, 0.016497, 0.602083],
+ [0.227983, 0.016007, 0.604867],
+ [0.234715, 0.015502, 0.607592],
+ [0.241396, 0.014979, 0.610259],
+ [0.248032, 0.014439, 0.612868],
+ [0.254627, 0.013882, 0.615419],
+ [0.261183, 0.013308, 0.617911],
+ [0.267703, 0.012716, 0.620346],
+ [0.274191, 0.012109, 0.622722],
+ [0.280648, 0.011488, 0.625038],
+ [0.287076, 0.010855, 0.627295],
+ [0.293478, 0.010213, 0.629490],
+ [0.299855, 0.009561, 0.631624],
+ [0.306210, 0.008902, 0.633694],
+ [0.312543, 0.008239, 0.635700],
+ [0.318856, 0.007576, 0.637640],
+ [0.325150, 0.006915, 0.639512],
+ [0.331426, 0.006261, 0.641316],
+ [0.337683, 0.005618, 0.643049],
+ [0.343925, 0.004991, 0.644710],
+ [0.350150, 0.004382, 0.646298],
+ [0.356359, 0.003798, 0.647810],
+ [0.362553, 0.003243, 0.649245],
+ [0.368733, 0.002724, 0.650601],
+ [0.374897, 0.002245, 0.651876],
+ [0.381047, 0.001814, 0.653068],
+ [0.387183, 0.001434, 0.654177],
+ [0.393304, 0.001114, 0.655199],
+ [0.399411, 0.000859, 0.656133],
+ [0.405503, 0.000678, 0.656977],
+ [0.411580, 0.000577, 0.657730],
+ [0.417642, 0.000564, 0.658390],
+ [0.423689, 0.000646, 0.658956],
+ [0.429719, 0.000831, 0.659425],
+ [0.435734, 0.001127, 0.659797],
+ [0.441732, 0.001540, 0.660069],
+ [0.447714, 0.002080, 0.660240],
+ [0.453677, 0.002755, 0.660310],
+ [0.459623, 0.003574, 0.660277],
+ [0.465550, 0.004545, 0.660139],
+ [0.471457, 0.005678, 0.659897],
+ [0.477344, 0.006980, 0.659549],
+ [0.483210, 0.008460, 0.659095],
+ [0.489055, 0.010127, 0.658534],
+ [0.494877, 0.011990, 0.657865],
+ [0.500678, 0.014055, 0.657088],
+ [0.506454, 0.016333, 0.656202],
+ [0.512206, 0.018833, 0.655209],
+ [0.517933, 0.021563, 0.654109],
+ [0.523633, 0.024532, 0.652901],
+ [0.529306, 0.027747, 0.651586],
+ [0.534952, 0.031217, 0.650165],
+ [0.540570, 0.034950, 0.648640],
+ [0.546157, 0.038954, 0.647010],
+ [0.551715, 0.043136, 0.645277],
+ [0.557243, 0.047331, 0.643443],
+ [0.562738, 0.051545, 0.641509],
+ [0.568201, 0.055778, 0.639477],
+ [0.573632, 0.060028, 0.637349],
+ [0.579029, 0.064296, 0.635126],
+ [0.584391, 0.068579, 0.632812],
+ [0.589719, 0.072878, 0.630408],
+ [0.595011, 0.077190, 0.627917],
+ [0.600266, 0.081516, 0.625342],
+ [0.605485, 0.085854, 0.622686],
+ [0.610667, 0.090204, 0.619951],
+ [0.615812, 0.094564, 0.617140],
+ [0.620919, 0.098934, 0.614257],
+ [0.625987, 0.103312, 0.611305],
+ [0.631017, 0.107699, 0.608287],
+ [0.636008, 0.112092, 0.605205],
+ [0.640959, 0.116492, 0.602065],
+ [0.645872, 0.120898, 0.598867],
+ [0.650746, 0.125309, 0.595617],
+ [0.655580, 0.129725, 0.592317],
+ [0.660374, 0.134144, 0.588971],
+ [0.665129, 0.138566, 0.585582],
+ [0.669845, 0.142992, 0.582154],
+ [0.674522, 0.147419, 0.578688],
+ [0.679160, 0.151848, 0.575189],
+ [0.683758, 0.156278, 0.571660],
+ [0.688318, 0.160709, 0.568103],
+ [0.692840, 0.165141, 0.564522],
+ [0.697324, 0.169573, 0.560919],
+ [0.701769, 0.174005, 0.557296],
+ [0.706178, 0.178437, 0.553657],
+ [0.710549, 0.182868, 0.550004],
+ [0.714883, 0.187299, 0.546338],
+ [0.719181, 0.191729, 0.542663],
+ [0.723444, 0.196158, 0.538981],
+ [0.727670, 0.200586, 0.535293],
+ [0.731862, 0.205013, 0.531601],
+ [0.736019, 0.209439, 0.527908],
+ [0.740143, 0.213864, 0.524216],
+ [0.744232, 0.218288, 0.520524],
+ [0.748289, 0.222711, 0.516834],
+ [0.752312, 0.227133, 0.513149],
+ [0.756304, 0.231555, 0.509468],
+ [0.760264, 0.235976, 0.505794],
+ [0.764193, 0.240396, 0.502126],
+ [0.768090, 0.244817, 0.498465],
+ [0.771958, 0.249237, 0.494813],
+ [0.775796, 0.253658, 0.491171],
+ [0.779604, 0.258078, 0.487539],
+ [0.783383, 0.262500, 0.483918],
+ [0.787133, 0.266922, 0.480307],
+ [0.790855, 0.271345, 0.476706],
+ [0.794549, 0.275770, 0.473117],
+ [0.798216, 0.280197, 0.469538],
+ [0.801855, 0.284626, 0.465971],
+ [0.805467, 0.289057, 0.462415],
+ [0.809052, 0.293491, 0.458870],
+ [0.812612, 0.297928, 0.455338],
+ [0.816144, 0.302368, 0.451816],
+ [0.819651, 0.306812, 0.448306],
+ [0.823132, 0.311261, 0.444806],
+ [0.826588, 0.315714, 0.441316],
+ [0.830018, 0.320172, 0.437836],
+ [0.833422, 0.324635, 0.434366],
+ [0.836801, 0.329105, 0.430905],
+ [0.840155, 0.333580, 0.427455],
+ [0.843484, 0.338062, 0.424013],
+ [0.846788, 0.342551, 0.420579],
+ [0.850066, 0.347048, 0.417153],
+ [0.853319, 0.351553, 0.413734],
+ [0.856547, 0.356066, 0.410322],
+ [0.859750, 0.360588, 0.406917],
+ [0.862927, 0.365119, 0.403519],
+ [0.866078, 0.369660, 0.400126],
+ [0.869203, 0.374212, 0.396738],
+ [0.872303, 0.378774, 0.393355],
+ [0.875376, 0.383347, 0.389976],
+ [0.878423, 0.387932, 0.386600],
+ [0.881443, 0.392529, 0.383229],
+ [0.884436, 0.397139, 0.379860],
+ [0.887402, 0.401762, 0.376494],
+ [0.890340, 0.406398, 0.373130],
+ [0.893250, 0.411048, 0.369768],
+ [0.896131, 0.415712, 0.366407],
+ [0.898984, 0.420392, 0.363047],
+ [0.901807, 0.425087, 0.359688],
+ [0.904601, 0.429797, 0.356329],
+ [0.907365, 0.434524, 0.352970],
+ [0.910098, 0.439268, 0.349610],
+ [0.912800, 0.444029, 0.346251],
+ [0.915471, 0.448807, 0.342890],
+ [0.918109, 0.453603, 0.339529],
+ [0.920714, 0.458417, 0.336166],
+ [0.923287, 0.463251, 0.332801],
+ [0.925825, 0.468103, 0.329435],
+ [0.928329, 0.472975, 0.326067],
+ [0.930798, 0.477867, 0.322697],
+ [0.933232, 0.482780, 0.319325],
+ [0.935630, 0.487712, 0.315952],
+ [0.937990, 0.492667, 0.312575],
+ [0.940313, 0.497642, 0.309197],
+ [0.942598, 0.502639, 0.305816],
+ [0.944844, 0.507658, 0.302433],
+ [0.947051, 0.512699, 0.299049],
+ [0.949217, 0.517763, 0.295662],
+ [0.951344, 0.522850, 0.292275],
+ [0.953428, 0.527960, 0.288883],
+ [0.955470, 0.533093, 0.285490],
+ [0.957469, 0.538250, 0.282096],
+ [0.959424, 0.543431, 0.278701],
+ [0.961336, 0.548636, 0.275305],
+ [0.963203, 0.553865, 0.271909],
+ [0.965024, 0.559118, 0.268513],
+ [0.966798, 0.564396, 0.265118],
+ [0.968526, 0.569700, 0.261721],
+ [0.970205, 0.575028, 0.258325],
+ [0.971835, 0.580382, 0.254931],
+ [0.973416, 0.585761, 0.251540],
+ [0.974947, 0.591165, 0.248151],
+ [0.976428, 0.596595, 0.244767],
+ [0.977856, 0.602051, 0.241387],
+ [0.979233, 0.607532, 0.238013],
+ [0.980556, 0.613039, 0.234646],
+ [0.981826, 0.618572, 0.231287],
+ [0.983041, 0.624131, 0.227937],
+ [0.984199, 0.629718, 0.224595],
+ [0.985301, 0.635330, 0.221265],
+ [0.986345, 0.640969, 0.217948],
+ [0.987332, 0.646633, 0.214648],
+ [0.988260, 0.652325, 0.211364],
+ [0.989128, 0.658043, 0.208100],
+ [0.989935, 0.663787, 0.204859],
+ [0.990681, 0.669558, 0.201642],
+ [0.991365, 0.675355, 0.198453],
+ [0.991985, 0.681179, 0.195295],
+ [0.992541, 0.687030, 0.192170],
+ [0.993032, 0.692907, 0.189084],
+ [0.993456, 0.698810, 0.186041],
+ [0.993814, 0.704741, 0.183043],
+ [0.994103, 0.710698, 0.180097],
+ [0.994324, 0.716681, 0.177208],
+ [0.994474, 0.722691, 0.174381],
+ [0.994553, 0.728728, 0.171622],
+ [0.994561, 0.734791, 0.168938],
+ [0.994495, 0.740880, 0.166335],
+ [0.994355, 0.746995, 0.163821],
+ [0.994141, 0.753137, 0.161404],
+ [0.993851, 0.759304, 0.159092],
+ [0.993482, 0.765499, 0.156891],
+ [0.993033, 0.771720, 0.154808],
+ [0.992505, 0.777967, 0.152855],
+ [0.991897, 0.784239, 0.151042],
+ [0.991209, 0.790537, 0.149377],
+ [0.990439, 0.796859, 0.147870],
+ [0.989587, 0.803205, 0.146529],
+ [0.988648, 0.809579, 0.145357],
+ [0.987621, 0.815978, 0.144363],
+ [0.986509, 0.822401, 0.143557],
+ [0.985314, 0.828846, 0.142945],
+ [0.984031, 0.835315, 0.142528],
+ [0.982653, 0.841812, 0.142303],
+ [0.981190, 0.848329, 0.142279],
+ [0.979644, 0.854866, 0.142453],
+ [0.977995, 0.861432, 0.142808],
+ [0.976265, 0.868016, 0.143351],
+ [0.974443, 0.874622, 0.144061],
+ [0.972530, 0.881250, 0.144923],
+ [0.970533, 0.887896, 0.145919],
+ [0.968443, 0.894564, 0.147014],
+ [0.966271, 0.901249, 0.148180],
+ [0.964021, 0.907950, 0.149370],
+ [0.961681, 0.914672, 0.150520],
+ [0.959276, 0.921407, 0.151566],
+ [0.956808, 0.928152, 0.152409],
+ [0.954287, 0.934908, 0.152921],
+ [0.951726, 0.941671, 0.152925],
+ [0.949151, 0.948435, 0.152178],
+ [0.946602, 0.955190, 0.150328],
+ [0.944152, 0.961916, 0.146861],
+ [0.941896, 0.968590, 0.140956],
+ [0.940015, 0.975158, 0.131326]]
+
+_viridis_data = [[0.267004, 0.004874, 0.329415],
+ [0.268510, 0.009605, 0.335427],
+ [0.269944, 0.014625, 0.341379],
+ [0.271305, 0.019942, 0.347269],
+ [0.272594, 0.025563, 0.353093],
+ [0.273809, 0.031497, 0.358853],
+ [0.274952, 0.037752, 0.364543],
+ [0.276022, 0.044167, 0.370164],
+ [0.277018, 0.050344, 0.375715],
+ [0.277941, 0.056324, 0.381191],
+ [0.278791, 0.062145, 0.386592],
+ [0.279566, 0.067836, 0.391917],
+ [0.280267, 0.073417, 0.397163],
+ [0.280894, 0.078907, 0.402329],
+ [0.281446, 0.084320, 0.407414],
+ [0.281924, 0.089666, 0.412415],
+ [0.282327, 0.094955, 0.417331],
+ [0.282656, 0.100196, 0.422160],
+ [0.282910, 0.105393, 0.426902],
+ [0.283091, 0.110553, 0.431554],
+ [0.283197, 0.115680, 0.436115],
+ [0.283229, 0.120777, 0.440584],
+ [0.283187, 0.125848, 0.444960],
+ [0.283072, 0.130895, 0.449241],
+ [0.282884, 0.135920, 0.453427],
+ [0.282623, 0.140926, 0.457517],
+ [0.282290, 0.145912, 0.461510],
+ [0.281887, 0.150881, 0.465405],
+ [0.281412, 0.155834, 0.469201],
+ [0.280868, 0.160771, 0.472899],
+ [0.280255, 0.165693, 0.476498],
+ [0.279574, 0.170599, 0.479997],
+ [0.278826, 0.175490, 0.483397],
+ [0.278012, 0.180367, 0.486697],
+ [0.277134, 0.185228, 0.489898],
+ [0.276194, 0.190074, 0.493001],
+ [0.275191, 0.194905, 0.496005],
+ [0.274128, 0.199721, 0.498911],
+ [0.273006, 0.204520, 0.501721],
+ [0.271828, 0.209303, 0.504434],
+ [0.270595, 0.214069, 0.507052],
+ [0.269308, 0.218818, 0.509577],
+ [0.267968, 0.223549, 0.512008],
+ [0.266580, 0.228262, 0.514349],
+ [0.265145, 0.232956, 0.516599],
+ [0.263663, 0.237631, 0.518762],
+ [0.262138, 0.242286, 0.520837],
+ [0.260571, 0.246922, 0.522828],
+ [0.258965, 0.251537, 0.524736],
+ [0.257322, 0.256130, 0.526563],
+ [0.255645, 0.260703, 0.528312],
+ [0.253935, 0.265254, 0.529983],
+ [0.252194, 0.269783, 0.531579],
+ [0.250425, 0.274290, 0.533103],
+ [0.248629, 0.278775, 0.534556],
+ [0.246811, 0.283237, 0.535941],
+ [0.244972, 0.287675, 0.537260],
+ [0.243113, 0.292092, 0.538516],
+ [0.241237, 0.296485, 0.539709],
+ [0.239346, 0.300855, 0.540844],
+ [0.237441, 0.305202, 0.541921],
+ [0.235526, 0.309527, 0.542944],
+ [0.233603, 0.313828, 0.543914],
+ [0.231674, 0.318106, 0.544834],
+ [0.229739, 0.322361, 0.545706],
+ [0.227802, 0.326594, 0.546532],
+ [0.225863, 0.330805, 0.547314],
+ [0.223925, 0.334994, 0.548053],
+ [0.221989, 0.339161, 0.548752],
+ [0.220057, 0.343307, 0.549413],
+ [0.218130, 0.347432, 0.550038],
+ [0.216210, 0.351535, 0.550627],
+ [0.214298, 0.355619, 0.551184],
+ [0.212395, 0.359683, 0.551710],
+ [0.210503, 0.363727, 0.552206],
+ [0.208623, 0.367752, 0.552675],
+ [0.206756, 0.371758, 0.553117],
+ [0.204903, 0.375746, 0.553533],
+ [0.203063, 0.379716, 0.553925],
+ [0.201239, 0.383670, 0.554294],
+ [0.199430, 0.387607, 0.554642],
+ [0.197636, 0.391528, 0.554969],
+ [0.195860, 0.395433, 0.555276],
+ [0.194100, 0.399323, 0.555565],
+ [0.192357, 0.403199, 0.555836],
+ [0.190631, 0.407061, 0.556089],
+ [0.188923, 0.410910, 0.556326],
+ [0.187231, 0.414746, 0.556547],
+ [0.185556, 0.418570, 0.556753],
+ [0.183898, 0.422383, 0.556944],
+ [0.182256, 0.426184, 0.557120],
+ [0.180629, 0.429975, 0.557282],
+ [0.179019, 0.433756, 0.557430],
+ [0.177423, 0.437527, 0.557565],
+ [0.175841, 0.441290, 0.557685],
+ [0.174274, 0.445044, 0.557792],
+ [0.172719, 0.448791, 0.557885],
+ [0.171176, 0.452530, 0.557965],
+ [0.169646, 0.456262, 0.558030],
+ [0.168126, 0.459988, 0.558082],
+ [0.166617, 0.463708, 0.558119],
+ [0.165117, 0.467423, 0.558141],
+ [0.163625, 0.471133, 0.558148],
+ [0.162142, 0.474838, 0.558140],
+ [0.160665, 0.478540, 0.558115],
+ [0.159194, 0.482237, 0.558073],
+ [0.157729, 0.485932, 0.558013],
+ [0.156270, 0.489624, 0.557936],
+ [0.154815, 0.493313, 0.557840],
+ [0.153364, 0.497000, 0.557724],
+ [0.151918, 0.500685, 0.557587],
+ [0.150476, 0.504369, 0.557430],
+ [0.149039, 0.508051, 0.557250],
+ [0.147607, 0.511733, 0.557049],
+ [0.146180, 0.515413, 0.556823],
+ [0.144759, 0.519093, 0.556572],
+ [0.143343, 0.522773, 0.556295],
+ [0.141935, 0.526453, 0.555991],
+ [0.140536, 0.530132, 0.555659],
+ [0.139147, 0.533812, 0.555298],
+ [0.137770, 0.537492, 0.554906],
+ [0.136408, 0.541173, 0.554483],
+ [0.135066, 0.544853, 0.554029],
+ [0.133743, 0.548535, 0.553541],
+ [0.132444, 0.552216, 0.553018],
+ [0.131172, 0.555899, 0.552459],
+ [0.129933, 0.559582, 0.551864],
+ [0.128729, 0.563265, 0.551229],
+ [0.127568, 0.566949, 0.550556],
+ [0.126453, 0.570633, 0.549841],
+ [0.125394, 0.574318, 0.549086],
+ [0.124395, 0.578002, 0.548287],
+ [0.123463, 0.581687, 0.547445],
+ [0.122606, 0.585371, 0.546557],
+ [0.121831, 0.589055, 0.545623],
+ [0.121148, 0.592739, 0.544641],
+ [0.120565, 0.596422, 0.543611],
+ [0.120092, 0.600104, 0.542530],
+ [0.119738, 0.603785, 0.541400],
+ [0.119512, 0.607464, 0.540218],
+ [0.119423, 0.611141, 0.538982],
+ [0.119483, 0.614817, 0.537692],
+ [0.119699, 0.618490, 0.536347],
+ [0.120081, 0.622161, 0.534946],
+ [0.120638, 0.625828, 0.533488],
+ [0.121380, 0.629492, 0.531973],
+ [0.122312, 0.633153, 0.530398],
+ [0.123444, 0.636809, 0.528763],
+ [0.124780, 0.640461, 0.527068],
+ [0.126326, 0.644107, 0.525311],
+ [0.128087, 0.647749, 0.523491],
+ [0.130067, 0.651384, 0.521608],
+ [0.132268, 0.655014, 0.519661],
+ [0.134692, 0.658636, 0.517649],
+ [0.137339, 0.662252, 0.515571],
+ [0.140210, 0.665859, 0.513427],
+ [0.143303, 0.669459, 0.511215],
+ [0.146616, 0.673050, 0.508936],
+ [0.150148, 0.676631, 0.506589],
+ [0.153894, 0.680203, 0.504172],
+ [0.157851, 0.683765, 0.501686],
+ [0.162016, 0.687316, 0.499129],
+ [0.166383, 0.690856, 0.496502],
+ [0.170948, 0.694384, 0.493803],
+ [0.175707, 0.697900, 0.491033],
+ [0.180653, 0.701402, 0.488189],
+ [0.185783, 0.704891, 0.485273],
+ [0.191090, 0.708366, 0.482284],
+ [0.196571, 0.711827, 0.479221],
+ [0.202219, 0.715272, 0.476084],
+ [0.208030, 0.718701, 0.472873],
+ [0.214000, 0.722114, 0.469588],
+ [0.220124, 0.725509, 0.466226],
+ [0.226397, 0.728888, 0.462789],
+ [0.232815, 0.732247, 0.459277],
+ [0.239374, 0.735588, 0.455688],
+ [0.246070, 0.738910, 0.452024],
+ [0.252899, 0.742211, 0.448284],
+ [0.259857, 0.745492, 0.444467],
+ [0.266941, 0.748751, 0.440573],
+ [0.274149, 0.751988, 0.436601],
+ [0.281477, 0.755203, 0.432552],
+ [0.288921, 0.758394, 0.428426],
+ [0.296479, 0.761561, 0.424223],
+ [0.304148, 0.764704, 0.419943],
+ [0.311925, 0.767822, 0.415586],
+ [0.319809, 0.770914, 0.411152],
+ [0.327796, 0.773980, 0.406640],
+ [0.335885, 0.777018, 0.402049],
+ [0.344074, 0.780029, 0.397381],
+ [0.352360, 0.783011, 0.392636],
+ [0.360741, 0.785964, 0.387814],
+ [0.369214, 0.788888, 0.382914],
+ [0.377779, 0.791781, 0.377939],
+ [0.386433, 0.794644, 0.372886],
+ [0.395174, 0.797475, 0.367757],
+ [0.404001, 0.800275, 0.362552],
+ [0.412913, 0.803041, 0.357269],
+ [0.421908, 0.805774, 0.351910],
+ [0.430983, 0.808473, 0.346476],
+ [0.440137, 0.811138, 0.340967],
+ [0.449368, 0.813768, 0.335384],
+ [0.458674, 0.816363, 0.329727],
+ [0.468053, 0.818921, 0.323998],
+ [0.477504, 0.821444, 0.318195],
+ [0.487026, 0.823929, 0.312321],
+ [0.496615, 0.826376, 0.306377],
+ [0.506271, 0.828786, 0.300362],
+ [0.515992, 0.831158, 0.294279],
+ [0.525776, 0.833491, 0.288127],
+ [0.535621, 0.835785, 0.281908],
+ [0.545524, 0.838039, 0.275626],
+ [0.555484, 0.840254, 0.269281],
+ [0.565498, 0.842430, 0.262877],
+ [0.575563, 0.844566, 0.256415],
+ [0.585678, 0.846661, 0.249897],
+ [0.595839, 0.848717, 0.243329],
+ [0.606045, 0.850733, 0.236712],
+ [0.616293, 0.852709, 0.230052],
+ [0.626579, 0.854645, 0.223353],
+ [0.636902, 0.856542, 0.216620],
+ [0.647257, 0.858400, 0.209861],
+ [0.657642, 0.860219, 0.203082],
+ [0.668054, 0.861999, 0.196293],
+ [0.678489, 0.863742, 0.189503],
+ [0.688944, 0.865448, 0.182725],
+ [0.699415, 0.867117, 0.175971],
+ [0.709898, 0.868751, 0.169257],
+ [0.720391, 0.870350, 0.162603],
+ [0.730889, 0.871916, 0.156029],
+ [0.741388, 0.873449, 0.149561],
+ [0.751884, 0.874951, 0.143228],
+ [0.762373, 0.876424, 0.137064],
+ [0.772852, 0.877868, 0.131109],
+ [0.783315, 0.879285, 0.125405],
+ [0.793760, 0.880678, 0.120005],
+ [0.804182, 0.882046, 0.114965],
+ [0.814576, 0.883393, 0.110347],
+ [0.824940, 0.884720, 0.106217],
+ [0.835270, 0.886029, 0.102646],
+ [0.845561, 0.887322, 0.099702],
+ [0.855810, 0.888601, 0.097452],
+ [0.866013, 0.889868, 0.095953],
+ [0.876168, 0.891125, 0.095250],
+ [0.886271, 0.892374, 0.095374],
+ [0.896320, 0.893616, 0.096335],
+ [0.906311, 0.894855, 0.098125],
+ [0.916242, 0.896091, 0.100717],
+ [0.926106, 0.897330, 0.104071],
+ [0.935904, 0.898570, 0.108131],
+ [0.945636, 0.899815, 0.112838],
+ [0.955300, 0.901065, 0.118128],
+ [0.964894, 0.902323, 0.123941],
+ [0.974417, 0.903590, 0.130215],
+ [0.983868, 0.904867, 0.136897],
+ [0.993248, 0.906157, 0.143936]]
+
+
+cmaps = {}
+for (name, data) in (('magma', _magma_data),
+ ('inferno', _inferno_data),
+ ('plasma', _plasma_data),
+ ('viridis', _viridis_data)):
+
+ cmaps[name] = ListedColormap(data, name=name)
+
+magma = cmaps['magma']
+inferno = cmaps['inferno']
+plasma = cmaps['plasma']
+viridis = cmaps['viridis']
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
new file mode 100644
index 0000000..6407d44
--- /dev/null
+++ b/silx/gui/plot/MaskToolsWidget.py
@@ -0,0 +1,615 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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.
+#
+# ###########################################################################*/
+"""Widget providing a set of tools to draw masks on a PlotWidget.
+
+This widget is meant to work with :class:`silx.gui.plot.PlotWidget`.
+
+- :class:`ImageMask`: Handle mask bitmap update and history
+- :class:`MaskToolsWidget`: GUI for :class:`Mask`
+- :class:`MaskToolsDockWidget`: DockWidget to integrate in :class:`PlotWindow`
+"""
+from __future__ import division
+
+
+__authors__ = ["T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "20/04/2017"
+
+
+import os
+import sys
+import numpy
+import logging
+
+from silx.image import shapes
+
+from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
+from . import items
+from .Colors import cursorColorForColormap, rgba
+from .. import qt
+
+from silx.third_party.EdfFile import EdfFile
+from silx.third_party.TiffIO import TiffIO
+
+try:
+ import fabio
+except ImportError:
+ fabio = None
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ImageMask(BaseMask):
+ """A 2D mask field with update operations.
+
+ Coords follows (row, column) convention and are in mask array coords.
+
+ This is meant for internal use by :class:`MaskToolsWidget`.
+ """
+ def __init__(self, image=None):
+ """
+
+ :param image: :class:`silx.gui.plot.items.ImageBase` instance
+ """
+ BaseMask.__init__(self, image)
+
+ def getDataValues(self):
+ """Return image data as a 2D or 3D array (if it is a RGBA image).
+
+ :rtype: 2D or 3D numpy.ndarray
+ """
+ return self._dataItem.getData(copy=False)
+
+ def save(self, filename, kind):
+ """Save current mask in a file
+
+ :param str filename: The file where to save to mask
+ :param str kind: The kind of file to save in 'edf', 'tif', 'npy',
+ or 'msk' (if FabIO is installed)
+ :raise Exception: Raised if the file writing fail
+ """
+ if kind == 'edf':
+ edfFile = EdfFile(filename, access="w+")
+ edfFile.WriteImage({}, self.getMask(copy=False), Append=0)
+
+ elif kind == 'tif':
+ tiffFile = TiffIO(filename, mode='w')
+ tiffFile.writeImage(self.getMask(copy=False), software='silx')
+
+ elif kind == 'npy':
+ try:
+ numpy.save(filename, self.getMask(copy=False))
+ except IOError:
+ raise RuntimeError("Mask file can't be written")
+
+ elif kind == 'msk':
+ if fabio is None:
+ raise ImportError("Fit2d mask files can't be written: Fabio module is not available")
+ try:
+ data = self.getMask(copy=False)
+ image = fabio.fabioimage.FabioImage(data=data)
+ image = image.convert(fabio.fit2dmaskimage.Fit2dMaskImage)
+ image.save(filename)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ raise RuntimeError("Mask file can't be written")
+
+ else:
+ raise ValueError("Format '%s' is not supported" % kind)
+
+ # Drawing operations
+ def updateRectangle(self, level, row, col, height, width, mask=True):
+ """Mask/Unmask a rectangle of the given mask level.
+
+ :param int level: Mask level to update.
+ :param int row: Starting row of the rectangle
+ :param int col: Starting column of the rectangle
+ :param int height:
+ :param int width:
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ assert 0 < level < 256
+ selection = self._mask[max(0, row):row + height + 1,
+ max(0, col):col + width + 1]
+ if mask:
+ selection[:, :] = level
+ else:
+ selection[selection == level] = 0
+ self._notify()
+
+ def updatePolygon(self, level, vertices, mask=True):
+ """Mask/Unmask a polygon of the given mask level.
+
+ :param int level: Mask level to update.
+ :param vertices: Nx2 array of polygon corners as (row, col)
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ fill = shapes.polygon_fill_mask(vertices, self._mask.shape)
+ if mask:
+ self._mask[fill != 0] = level
+ else:
+ self._mask[numpy.logical_and(fill != 0,
+ self._mask == level)] = 0
+ self._notify()
+
+ def updatePoints(self, level, rows, cols, mask=True):
+ """Mask/Unmask points with given coordinates.
+
+ :param int level: Mask level to update.
+ :param rows: Rows of selected points
+ :type rows: 1D numpy.ndarray
+ :param cols: Columns of selected points
+ :type cols: 1D numpy.ndarray
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ valid = numpy.logical_and(
+ numpy.logical_and(rows >= 0, cols >= 0),
+ numpy.logical_and(rows < self._mask.shape[0],
+ cols < self._mask.shape[1]))
+ rows, cols = rows[valid], cols[valid]
+
+ if mask:
+ self._mask[rows, cols] = level
+ else:
+ inMask = self._mask[rows, cols] == level
+ self._mask[rows[inMask], cols[inMask]] = 0
+ self._notify()
+
+ def updateDisk(self, level, crow, ccol, radius, mask=True):
+ """Mask/Unmask a disk of the given mask level.
+
+ :param int level: Mask level to update.
+ :param int crow: Disk center row.
+ :param int ccol: Disk center column.
+ :param float radius: Radius of the disk in mask array unit
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ rows, cols = shapes.circle_fill(crow, ccol, radius)
+ self.updatePoints(level, rows, cols, mask)
+
+ def updateLine(self, level, row0, col0, row1, col1, width, mask=True):
+ """Mask/Unmask a line of the given mask level.
+
+ :param int level: Mask level to update.
+ :param int row0: Row of the starting point.
+ :param int col0: Column of the starting point.
+ :param int row1: Row of the end point.
+ :param int col1: Column of the end point.
+ :param int width: Width of the line in mask array unit.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ rows, cols = shapes.draw_line(row0, col0, row1, col1, width)
+ self.updatePoints(level, rows, cols, mask)
+
+
+class MaskToolsWidget(BaseMaskToolsWidget):
+ """Widget with tools for drawing mask on an image in a PlotWidget."""
+
+ _maxLevelNumber = 255
+
+ def __init__(self, parent=None, plot=None):
+ self._origin = (0., 0.) # Mask origin in plot
+ self._scale = (1., 1.) # Mask scale in plot
+ self._z = 1 # Mask layer in plot
+ self._data = numpy.zeros((0, 0), dtype=numpy.uint8) # Store image
+
+ self._mask = ImageMask()
+
+ super(MaskToolsWidget, self).__init__(parent, plot)
+
+ self._initWidgets()
+
+ def setSelectionMask(self, mask, copy=True):
+ """Set the mask to a new array.
+
+ :param numpy.ndarray mask: The array to use for the mask.
+ :type mask: numpy.ndarray of uint8 of dimension 2, C-contiguous.
+ Array of other types are converted.
+ :param bool copy: True (the default) to copy the array,
+ False to use it as is if possible.
+ :return: None if failed, shape of mask as 2-tuple if successful.
+ The mask can be cropped or padded to fit active image,
+ the returned shape is that of the active image.
+ """
+ mask = numpy.array(mask, copy=False, dtype=numpy.uint8)
+ if len(mask.shape) != 2:
+ _logger.error('Not an image, shape: %d', len(mask.shape))
+ return None
+
+ if self._data.shape[0:2] == (0, 0) or mask.shape == self._data.shape[0:2]:
+ self._mask.setMask(mask, copy=copy)
+ self._mask.commit()
+ return mask.shape
+ else:
+ _logger.warning('Mask has not the same size as current image.'
+ ' Mask will be cropped or padded to fit image'
+ ' dimensions. %s != %s',
+ str(mask.shape), str(self._data.shape))
+ resizedMask = numpy.zeros(self._data.shape[0:2],
+ dtype=numpy.uint8)
+ height = min(self._data.shape[0], mask.shape[0])
+ width = min(self._data.shape[1], mask.shape[1])
+ resizedMask[:height, :width] = mask[:height, :width]
+ self._mask.setMask(resizedMask, copy=False)
+ self._mask.commit()
+ return resizedMask.shape
+
+ # Handle mask refresh on the plot
+ def _updatePlotMask(self):
+ """Update mask image in plot"""
+ mask = self.getSelectionMask(copy=False)
+ if len(mask):
+ self.plot.addImage(mask, legend=self._maskName,
+ colormap=self._colormap,
+ origin=self._origin,
+ scale=self._scale,
+ z=self._z,
+ replace=False, resetzoom=False)
+ elif self.plot.getImage(self._maskName):
+ self.plot.remove(self._maskName, kind='image')
+
+ def showEvent(self, event):
+ try:
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChangedAfterCare)
+ except (RuntimeError, TypeError):
+ pass
+ self._activeImageChanged() # Init mask + enable/disable widget
+ self.plot.sigActiveImageChanged.connect(self._activeImageChanged)
+
+ def hideEvent(self, event):
+ self.plot.sigActiveImageChanged.disconnect(self._activeImageChanged)
+ if not self.browseAction.isChecked():
+ self.browseAction.trigger() # Disable drawing tool
+
+ if len(self.getSelectionMask(copy=False)):
+ self.plot.sigActiveImageChanged.connect(
+ self._activeImageChangedAfterCare)
+
+ def _setOverlayColorForImage(self, image):
+ """Set the color of overlay adapted to image
+
+ :param image: :class:`.items.ImageBase` object to set color for.
+ """
+ if isinstance(image, items.ColormapMixIn):
+ colormap = image.getColormap()
+ self._defaultOverlayColor = rgba(
+ cursorColorForColormap(colormap['name']))
+ else:
+ self._defaultOverlayColor = rgba('black')
+
+ def _activeImageChangedAfterCare(self, *args):
+ """Check synchro of active image and mask when mask widget is hidden.
+
+ If active image has no more the same size as the mask, the mask is
+ removed, otherwise it is adjusted to origin, scale and z.
+ """
+ activeImage = self.plot.getActiveImage()
+ if activeImage is None or activeImage.getLegend() == self._maskName:
+ # No active image or active image is the mask...
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChangedAfterCare)
+ else:
+ self._setOverlayColorForImage(activeImage)
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+
+ self._origin = activeImage.getOrigin()
+ self._scale = activeImage.getScale()
+ self._z = activeImage.getZValue() + 1
+ self._data = activeImage.getData(copy=False)
+ if self._data.shape[:2] != self.getSelectionMask(copy=False).shape:
+ # Image has not the same size, remove mask and stop listening
+ if self.plot.getImage(self._maskName):
+ self.plot.remove(self._maskName, kind='image')
+
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChangedAfterCare)
+ else:
+ # Refresh in case origin, scale, z changed
+ self._mask.setDataItem(activeImage)
+ self._updatePlotMask()
+
+ def _activeImageChanged(self, *args):
+ """Update widget and mask according to active image changes"""
+ activeImage = self.plot.getActiveImage()
+ if activeImage is None or activeImage.getLegend() == self._maskName:
+ # No active image or active image is the mask...
+ self.setEnabled(False)
+
+ self._data = numpy.zeros((0, 0), dtype=numpy.uint8)
+ self._mask.reset()
+ self._mask.commit()
+
+ else: # There is an active image
+ self.setEnabled(True)
+
+ self._setOverlayColorForImage(activeImage)
+
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+
+ self._origin = activeImage.getOrigin()
+ self._scale = activeImage.getScale()
+ self._z = activeImage.getZValue() + 1
+ self._data = activeImage.getData(copy=False)
+ self._mask.setDataItem(activeImage)
+ if self._data.shape[:2] != self.getSelectionMask(copy=False).shape:
+ self._mask.reset(self._data.shape[:2])
+ self._mask.commit()
+ else:
+ # Refresh in case origin, scale, z changed
+ self._updatePlotMask()
+
+ # Threshold tools only available for data with colormap
+ self.thresholdGroup.setEnabled(self._data.ndim == 2)
+
+ self._updateInteractiveMode()
+
+ # Handle whole mask operations
+ def load(self, filename):
+ """Load a mask from an image file.
+
+ :param str filename: File name from which to load the mask
+ :raise Exception: An exception in case of failure
+ :raise RuntimeWarning: In case the mask was applied but with some
+ import changes to notice
+ """
+ _, extension = os.path.splitext(filename)
+ extension = extension.lower()[1:]
+
+ if extension == "npy":
+ try:
+ mask = numpy.load(filename)
+ except IOError:
+ _logger.error("Can't load filename '%s'", filename)
+ _logger.debug("Backtrace", exc_info=True)
+ raise RuntimeError('File "%s" is not a numpy file.', filename)
+ elif extension == "edf":
+ try:
+ mask = EdfFile(filename, access='r').GetData(0)
+ except Exception as e:
+ _logger.error("Can't load filename %s", filename)
+ _logger.debug("Backtrace", exc_info=True)
+ raise e
+ elif extension == "msk":
+ if fabio is None:
+ raise ImportError("Fit2d mask files can't be read: Fabio module is not available")
+ try:
+ mask = fabio.open(filename).data
+ except Exception as e:
+ _logger.error("Can't load fit2d mask file")
+ _logger.debug("Backtrace", exc_info=True)
+ raise e
+ else:
+ msg = "Extension '%s' is not supported."
+ raise RuntimeError(msg % extension)
+
+ effectiveMaskShape = self.setSelectionMask(mask, copy=False)
+ if effectiveMaskShape is None:
+ return
+ if mask.shape != effectiveMaskShape:
+ msg = 'Mask was resized from %s to %s'
+ msg = msg % (str(mask.shape), str(effectiveMaskShape))
+ raise RuntimeWarning(msg)
+
+ def _loadMask(self):
+ """Open load mask dialog"""
+ dialog = qt.QFileDialog(self)
+ dialog.setWindowTitle("Load Mask")
+ dialog.setModal(1)
+ filters = [
+ 'EDF (*.edf)',
+ 'TIFF (*.tif)',
+ 'NumPy binary file (*.npy)',
+ # Fit2D mask is displayed anyway fabio is here or not
+ # to show to the user that the option exists
+ 'Fit2D mask (*.msk)',
+ ]
+ dialog.setNameFilters(filters)
+ dialog.setFileMode(qt.QFileDialog.ExistingFile)
+ dialog.setDirectory(self.maskFileDir)
+ if not dialog.exec_():
+ dialog.close()
+ return
+
+ filename = dialog.selectedFiles()[0]
+ dialog.close()
+
+ self.maskFileDir = os.path.dirname(filename)
+ try:
+ self.load(filename)
+ except RuntimeWarning as e:
+ message = e.args[0]
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Warning)
+ msg.setText("Mask loaded but an operation was applied.\n" + message)
+ msg.exec_()
+ except Exception as e:
+ message = e.args[0]
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setText("Cannot load mask from file. " + message)
+ msg.exec_()
+
+ def _saveMask(self):
+ """Open Save mask dialog"""
+ dialog = qt.QFileDialog(self)
+ dialog.setWindowTitle("Save Mask")
+ dialog.setModal(1)
+ filters = [
+ 'EDF (*.edf)',
+ 'TIFF (*.tif)',
+ 'NumPy binary file (*.npy)',
+ # Fit2D mask is displayed anyway fabio is here or not
+ # to show to the user that the option exists
+ 'Fit2D mask (*.msk)',
+ ]
+ dialog.setNameFilters(filters)
+ dialog.setFileMode(qt.QFileDialog.AnyFile)
+ dialog.setAcceptMode(qt.QFileDialog.AcceptSave)
+ dialog.setDirectory(self.maskFileDir)
+ if not dialog.exec_():
+ dialog.close()
+ return
+
+ # convert filter name to extension name with the .
+ extension = dialog.selectedNameFilter().split()[-1][2:-1]
+ filename = dialog.selectedFiles()[0]
+ dialog.close()
+
+ if not filename.lower().endswith(extension):
+ filename += extension
+
+ if os.path.exists(filename):
+ try:
+ os.remove(filename)
+ except IOError:
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setText("Cannot save.\n"
+ "Input Output Error: %s" % (sys.exc_info()[1]))
+ msg.exec_()
+ return
+
+ self.maskFileDir = os.path.dirname(filename)
+ try:
+ self.save(filename, extension[1:])
+ except Exception as e:
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setText("Cannot save file %s\n%s" % (filename, e.args[0]))
+ msg.exec_()
+
+ def resetSelectionMask(self):
+ """Reset the mask"""
+ self._mask.reset(shape=self._data.shape[:2])
+ self._mask.commit()
+
+ def _plotDrawEvent(self, event):
+ """Handle draw events from the plot"""
+ if (self._drawingMode is None or
+ event['event'] not in ('drawingProgress', 'drawingFinished')):
+ return
+
+ if not len(self._data):
+ return
+
+ level = self.levelSpinBox.value()
+
+ if (self._drawingMode == 'rectangle' and
+ event['event'] == 'drawingFinished'):
+ # Convert from plot to array coords
+ doMask = self._isMasking()
+ ox, oy = self._origin
+ sx, sy = self._scale
+
+ height = int(abs(event['height'] / sy))
+ width = int(abs(event['width'] / sx))
+
+ row = int((event['y'] - oy) / sy)
+ if sy < 0:
+ row -= height
+
+ col = int((event['x'] - ox) / sx)
+ if sx < 0:
+ col -= width
+
+ self._mask.updateRectangle(
+ level,
+ row=row,
+ col=col,
+ height=height,
+ width=width,
+ mask=doMask)
+ self._mask.commit()
+
+ elif (self._drawingMode == 'polygon' and
+ event['event'] == 'drawingFinished'):
+ doMask = self._isMasking()
+ # Convert from plot to array coords
+ vertices = (event['points'] - self._origin) / self._scale
+ vertices = vertices.astype(numpy.int)[:, (1, 0)] # (row, col)
+ self._mask.updatePolygon(level, vertices, doMask)
+ self._mask.commit()
+
+ elif self._drawingMode == 'pencil':
+ doMask = self._isMasking()
+ # convert from plot to array coords
+ col, row = (event['points'][-1] - self._origin) / self._scale
+ col, row = int(col), int(row)
+ brushSize = self.pencilSpinBox.value()
+
+ if self._lastPencilPos != (row, col):
+ if self._lastPencilPos is not None:
+ # Draw the line
+ self._mask.updateLine(
+ level,
+ self._lastPencilPos[0], self._lastPencilPos[1],
+ row, col,
+ brushSize,
+ doMask)
+
+ # Draw the very first, or last point
+ self._mask.updateDisk(level, row, col, brushSize / 2., doMask)
+
+ if event['event'] == 'drawingFinished':
+ self._mask.commit()
+ self._lastPencilPos = None
+ else:
+ self._lastPencilPos = row, col
+
+ def _loadRangeFromColormapTriggered(self):
+ """Set range from active image colormap range"""
+ activeImage = self.plot.getActiveImage()
+ if (isinstance(activeImage, items.ColormapMixIn) and
+ activeImage.getLegend() != self._maskName):
+ # Update thresholds according to colormap
+ colormap = activeImage.getColormap()
+ if colormap['autoscale']:
+ min_ = numpy.nanmin(activeImage.getData(copy=False))
+ max_ = numpy.nanmax(activeImage.getData(copy=False))
+ else:
+ min_, max_ = colormap['vmin'], colormap['vmax']
+ self.minLineEdit.setText(str(min_))
+ self.maxLineEdit.setText(str(max_))
+
+
+class MaskToolsDockWidget(BaseMaskToolsDockWidget):
+ """:class:`MaskToolsWidget` embedded in a QDockWidget.
+
+ For integration in a :class:`PlotWindow`.
+
+ :param parent: See :class:`QDockWidget`
+ :param plot: The PlotWidget this widget is operating on
+ :paran str name: The title of this widget
+ """
+ def __init__(self, parent=None, plot=None, name='Mask'):
+ super(MaskToolsDockWidget, self).__init__(parent, name)
+ self.setWidget(MaskToolsWidget(plot=plot))
+ self.widget().sigMaskChanged.connect(self._emitSigMaskChanged)
diff --git a/silx/gui/plot/Plot.py b/silx/gui/plot/Plot.py
new file mode 100644
index 0000000..fe0a7b8
--- /dev/null
+++ b/silx/gui/plot/Plot.py
@@ -0,0 +1,2925 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+# ###########################################################################*/
+"""Plot API for 1D and 2D data.
+
+The :class:`Plot` implements the plot API initially provided in PyMca.
+
+
+Colormap
+--------
+
+The :class:`Plot` uses a dictionary to describe a colormap.
+This dictionary has the following keys:
+
+- 'name': str, name of the colormap. Available colormap are returned by
+ :meth:`Plot.getSupportedColormaps`.
+ At least 'gray', 'reversed gray', 'temperature',
+ 'red', 'green', 'blue' are supported.
+- 'normalization': Either 'linear' or 'log'
+- 'autoscale': bool, True to get bounds from the min and max of the
+ data, False to use [vmin, vmax]
+- 'vmin': float, min value, ignored if autoscale is True
+- 'vmax': float, max value, ignored if autoscale is True
+- 'colors': optional, custom colormap.
+ Nx3 or Nx4 numpy array of RGB(A) colors,
+ either uint8 or float in [0, 1].
+ If 'name' is None, then this array is used as the colormap.
+
+
+Plot Events
+-----------
+
+The Plot sends some event to the registered callback
+(See :meth:`Plot.setCallback`).
+Those events are sent as a dictionary with a key 'event' describing the kind
+of event.
+
+Drawing events
+..............
+
+'drawingProgress' and 'drawingFinished' events are sent during drawing
+interaction (See :meth:`Plot.setInteractiveMode`).
+
+- 'event': 'drawingProgress' or 'drawingFinished'
+- 'parameters': dict of parameters used by the drawing mode.
+ It has the following keys: 'shape', 'label', 'color'.
+ See :meth:`Plot.setInteractiveMode`.
+- 'points': Points (x, y) in data coordinates of the drawn shape.
+ For 'hline' and 'vline', it is the 2 points defining the line.
+ For 'line' and 'rectangle', it is the coordinates of the start
+ drawing point and the latest drawing point.
+ For 'polygon', it is the coordinates of all points of the shape.
+- 'type': The type of drawing in 'line', 'hline', 'polygon', 'rectangle',
+ 'vline'.
+- 'xdata' and 'ydata': X coords and Y coords of shape points in data
+ coordinates (as in 'points').
+
+When the type is 'rectangle', the following additional keys are provided:
+
+- 'x' and 'y': The origin of the rectangle in data coordinates
+- 'widht' and 'height': The size of the rectangle in data coordinates
+
+
+Mouse events
+............
+
+'mouseMoved', 'mouseClicked' and 'mouseDoubleClicked' events are sent for
+mouse events.
+
+They provide the following keys:
+
+- 'event': 'mouseMoved', 'mouseClicked' or 'mouseDoubleClicked'
+- 'button': the mouse button that was pressed in 'left', 'middle', 'right'
+- 'x' and 'y': The mouse position in data coordinates
+- 'xpixel' and 'ypixel': The mouse position in pixels
+
+
+Marker events
+.............
+
+'hover', 'markerClicked', 'markerMoving' and 'markerMoved' events are
+sent during interaction with markers.
+
+'hover' is sent when the mouse cursor is over a marker.
+'markerClicker' is sent when the user click on a selectable marker.
+'markerMoving' and 'markerMoved' are sent when a draggable marker is moved.
+
+They provide the following keys:
+
+- 'event': 'hover', 'markerClicked', 'markerMoving' or 'markerMoved'
+- 'button': the mouse button that is pressed in 'left', 'middle', 'right'
+- 'draggable': True if the marker is draggable, False otherwise
+- 'label': The legend associated with the clicked image or curve
+- 'selectable': True if the marker is selectable, False otherwise
+- 'type': 'marker'
+- 'x' and 'y': The mouse position in data coordinates
+- 'xdata' and 'ydata': The marker position in data coordinates
+
+'markerClicked' and 'markerMoving' events have a 'xpixel' and a 'ypixel'
+additional keys, that provide the mouse position in pixels.
+
+
+Image and curve events
+......................
+
+'curveClicked' and 'imageClicked' events are sent when a selectable curve
+or image is clicked.
+
+Both share the following keys:
+
+- 'event': 'curveClicked' or 'imageClicked'
+- 'button': the mouse button that was pressed in 'left', 'middle', 'right'
+- 'label': The legend associated with the clicked image or curve
+- 'type': The type of item in 'curve', 'image'
+- 'x' and 'y': The clicked position in data coordinates
+- 'xpixel' and 'ypixel': The clicked position in pixels
+
+'curveClicked' events have a 'xdata' and a 'ydata' additional keys, that
+provide the coordinates of the picked points of the curve.
+There can be more than one point of the curve being picked, and if a line of
+the curve is picked, only the first point of the line is included in the list.
+
+'imageClicked' have a 'col' and a 'row' additional keys, that provide
+the column and row index in the image array that was clicked.
+
+
+Limits changed events
+.....................
+
+'limitsChanged' events are sent when the limits of the plot are changed.
+This can results from user interaction or API calls.
+
+It provides the following keys:
+
+- 'event': 'limitsChanged'
+- 'source': id of the widget that emitted this event.
+- 'xdata': Range of X in graph coordinates: (xMin, xMax).
+- 'ydata': Range of Y in graph coordinates: (yMin, yMax).
+- 'y2data': Range of right axis in graph coordinates (y2Min, y2Max) or None.
+
+Plot state change events
+........................
+
+The following events are emitted when the plot is modified.
+They provide the new state:
+
+- 'setGraphCursor' event with a 'state' key (bool)
+- 'setGraphGrid' event with a 'which' key (str), see :meth:`setGraphGrid`
+- 'setKeepDataAspectRatio' event with a 'state' key (bool)
+- 'setXAxisAutoScale' event with a 'state' key (bool)
+- 'setXAxisLogarithmic' event with a 'state' key (bool)
+- 'setYAxisAutoScale' event with a 'state' key (bool)
+- 'setYAxisInverted' event with a 'state' key (bool)
+- 'setYAxisLogarithmic' event with a 'state' key (bool)
+
+A 'contentChanged' event is triggered when the content of the plot is updated.
+It provides the following keys:
+
+- 'action': The change of the plot: 'add' or 'remove'
+- 'kind': The kind of primitive changed: 'curve', 'image', 'item' or 'marker'
+- 'legend': The legend of the primitive changed.
+
+'activeCurveChanged' and 'activeImageChanged' events with the following keys:
+
+- 'legend': Name (str) of the current active item or None if no active item.
+- 'previous': Name (str) of the previous active item or None if no item was
+ active. It is the same as 'legend' if 'updated' == True
+- 'updated': (bool) True if active item name did not changed,
+ but active item data or style was updated.
+
+'interactiveModeChanged' event with a 'source' key identifying the object
+setting the interactive mode.
+"""
+
+from __future__ import division
+
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "16/02/2017"
+
+
+from collections import OrderedDict, namedtuple
+import itertools
+import logging
+
+import numpy
+
+# Import matplotlib backend here to init matplotlib our way
+from .backends.BackendMatplotlib import BackendMatplotlibQt
+
+try:
+ from matplotlib import cm as matplotlib_cm
+except ImportError:
+ matplotlib_cm = None
+
+from . import Colors
+from . import PlotInteraction
+from . import PlotEvents
+from . import _utils
+
+from . import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+_COLORDICT = Colors.COLORDICT
+_COLORLIST = [_COLORDICT['black'],
+ _COLORDICT['blue'],
+ _COLORDICT['red'],
+ _COLORDICT['green'],
+ _COLORDICT['pink'],
+ _COLORDICT['yellow'],
+ _COLORDICT['brown'],
+ _COLORDICT['cyan'],
+ _COLORDICT['magenta'],
+ _COLORDICT['orange'],
+ _COLORDICT['violet'],
+ # _COLORDICT['bluegreen'],
+ _COLORDICT['grey'],
+ _COLORDICT['darkBlue'],
+ _COLORDICT['darkRed'],
+ _COLORDICT['darkGreen'],
+ _COLORDICT['darkCyan'],
+ _COLORDICT['darkMagenta'],
+ _COLORDICT['darkYellow'],
+ _COLORDICT['darkBrown']]
+
+
+"""
+Object returned when requesting the data range.
+"""
+_PlotDataRange = namedtuple('PlotDataRange',
+ ['x', 'y', 'yright'])
+
+
+class Plot(object):
+ """This class implements the plot API initially provided in PyMca.
+
+ Supported backends:
+
+ - 'matplotlib' and 'mpl': Matplotlib with Qt.
+ - 'opengl' and 'gl': OpenGL backend (requires PyOpenGL and OpenGL >= 2.1)
+ - 'none': No backend, to run headless for testing purpose.
+
+ :param parent: The parent widget of the plot (Default: None)
+ :param backend: The backend to use. A str in:
+ 'matplotlib', 'mpl', 'opengl', 'gl', 'none'
+ or a :class:`BackendBase.BackendBase` class
+ """
+
+ DEFAULT_BACKEND = 'matplotlib'
+ """Class attribute setting the default backend for all instances."""
+
+ colorList = _COLORLIST
+ colorDict = _COLORDICT
+
+ def __init__(self, parent=None, backend=None):
+ self._autoreplot = False
+ self._dirty = False
+ self._cursorInPlot = False
+
+ if backend is None:
+ backend = self.DEFAULT_BACKEND
+
+ if hasattr(backend, "__call__"):
+ self._backend = backend(self, parent)
+
+ elif hasattr(backend, "lower"):
+ lowerCaseString = backend.lower()
+ if lowerCaseString in ("matplotlib", "mpl"):
+ backendClass = BackendMatplotlibQt
+ elif lowerCaseString in ('gl', 'opengl'):
+ from .backends.BackendOpenGL import BackendOpenGL
+ backendClass = BackendOpenGL
+ elif lowerCaseString == 'none':
+ from .backends.BackendBase import BackendBase as backendClass
+ else:
+ raise ValueError("Backend not supported %s" % backend)
+ self._backend = backendClass(self, parent)
+
+ else:
+ raise ValueError("Backend not supported %s" % str(backend))
+
+ super(Plot, self).__init__()
+
+ self.setCallback() # set _callback
+
+ # Items handling
+ self._content = OrderedDict()
+ self._contentToUpdate = set()
+
+ self._dataRange = None
+
+ # line types
+ self._styleList = ['-', '--', '-.', ':']
+ self._colorIndex = 0
+ self._styleIndex = 0
+
+ self._activeCurveHandling = True
+ self._activeCurveColor = "#000000"
+ self._activeLegend = {'curve': None, 'image': None,
+ 'scatter': None}
+
+ # default properties
+ self._cursorConfiguration = None
+
+ self._logY = False
+ self._logX = False
+ self._xAutoScale = True
+ self._yAutoScale = True
+ self._grid = None
+
+ # Store default labels provided to setGraph[X|Y]Label
+ self._defaultLabels = {'x': '', 'y': '', 'yright': ''}
+ # Store currently displayed labels
+ # Current label can differ from input one with active curve handling
+ self._currentLabels = {'x': '', 'y': '', 'yright': ''}
+
+ self._graphTitle = ''
+
+ self.setGraphTitle()
+ self.setGraphXLabel()
+ self.setGraphYLabel()
+ self.setGraphYLabel('', axis='right')
+
+ self.setDefaultColormap() # Init default colormap
+
+ self.setDefaultPlotPoints(False)
+ self.setDefaultPlotLines(True)
+
+ self._eventHandler = PlotInteraction.PlotInteraction(self)
+ self._eventHandler.setInteractiveMode('zoom', color=(0., 0., 0., 1.))
+
+ self._pressedButtons = [] # Currently pressed mouse buttons
+
+ self._defaultDataMargins = (0., 0., 0., 0.)
+
+ # Only activate autoreplot at the end
+ # This avoids errors when loaded in Qt designer
+ self._dirty = False
+ self._autoreplot = True
+
+ def _getDirtyPlot(self):
+ """Return the plot dirty flag.
+
+ If False, the plot has not changed since last replot.
+ If True, the full plot need to be redrawn.
+ If 'overlay', only the overlay has changed since last replot.
+
+ It can be accessed by backend to check the dirty state.
+
+ :return: False, True, 'overlay'
+ """
+ return self._dirty
+
+ def _setDirtyPlot(self, overlayOnly=False):
+ """Mark the plot as needing redraw
+
+ :param bool overlayOnly: True to redraw only the overlay,
+ False to redraw everything
+ """
+ wasDirty = self._dirty
+
+ if not self._dirty and overlayOnly:
+ self._dirty = 'overlay'
+ else:
+ self._dirty = True
+
+ if self._autoreplot and not wasDirty:
+ self._backend.postRedisplay()
+
+ def _invalidateDataRange(self):
+ """
+ Notifies this Plot instance that the range has changed and will have
+ to be recomputed.
+ """
+ self._dataRange = None
+
+ def _updateDataRange(self):
+ """
+ Recomputes the range of the data displayed on this Plot.
+ """
+ xMin = yMinLeft = yMinRight = float('nan')
+ xMax = yMaxLeft = yMaxRight = float('nan')
+
+ for item in self._content.values():
+ if item.isVisible():
+ bounds = item.getBounds()
+ if bounds is not None:
+ xMin = numpy.nanmin([xMin, bounds[0]])
+ xMax = numpy.nanmax([xMax, bounds[1]])
+ # Take care of right axis
+ if (isinstance(item, items.YAxisMixIn) and
+ item.getYAxis() == 'right'):
+ yMinRight = numpy.nanmin([yMinRight, bounds[2]])
+ yMaxRight = numpy.nanmax([yMaxRight, bounds[3]])
+ else:
+ yMinLeft = numpy.nanmin([yMinLeft, bounds[2]])
+ yMaxLeft = numpy.nanmax([yMaxLeft, bounds[3]])
+
+ def lGetRange(x, y):
+ return None if numpy.isnan(x) and numpy.isnan(y) else (x, y)
+ xRange = lGetRange(xMin, xMax)
+ yLeftRange = lGetRange(yMinLeft, yMaxLeft)
+ yRightRange = lGetRange(yMinRight, yMaxRight)
+
+ self._dataRange = _PlotDataRange(x=xRange,
+ y=yLeftRange,
+ yright=yRightRange)
+
+ def getDataRange(self):
+ """
+ Returns this Plot's data range.
+
+ :return: a namedtuple with the following members :
+ x, y (left y axis), yright. Each member is a tuple (min, max)
+ or None if no data is associated with the axis.
+ :rtype: namedtuple
+ """
+ if self._dataRange is None:
+ self._updateDataRange()
+ return self._dataRange
+
+ # Content management
+
+ @staticmethod
+ def _itemKey(item):
+ """Build the key of given :class:`Item` in the plot
+
+ :param Item item: The item to make the key from
+ :return: (legend, kind)
+ :rtype: (str, str)
+ """
+ if isinstance(item, items.Curve):
+ kind = 'curve'
+ elif isinstance(item, items.ImageBase):
+ kind = 'image'
+ elif isinstance(item, items.Scatter):
+ kind = 'scatter'
+ elif isinstance(item, (items.Marker,
+ items.XMarker, items.YMarker)):
+ kind = 'marker'
+ elif isinstance(item, items.Shape):
+ kind = 'item'
+ elif isinstance(item, items.Histogram):
+ kind = 'histogram'
+ else:
+ raise ValueError('Unsupported item type %s' % type(item))
+
+ return item.getLegend(), kind
+
+ def _add(self, item):
+ """Add the given :class:`Item` to the plot.
+
+ :param Item item: The item to append to the plot content
+ """
+ key = self._itemKey(item)
+ if key in self._content:
+ raise RuntimeError('Item already in the plot')
+
+ # Add item to plot
+ self._content[key] = item
+ item._setPlot(self)
+ if item.isVisible():
+ self._itemRequiresUpdate(item)
+ if isinstance(item, (items.Curve, items.ImageBase)):
+ self._invalidateDataRange() # TODO handle this automatically
+
+ def _remove(self, item):
+ """Remove the given :class:`Item` from the plot.
+
+ :param Item item: The item to remove from the plot content
+ """
+ key = self._itemKey(item)
+ if key not in self._content:
+ raise RuntimeError('Item not in the plot')
+
+ # Remove item from plot
+ self._content.pop(key)
+ self._contentToUpdate.discard(item)
+ if item.isVisible():
+ self._setDirtyPlot(overlayOnly=item.isOverlay())
+ if item.getBounds() is not None:
+ self._invalidateDataRange()
+ item._removeBackendRenderer(self._backend)
+ item._setPlot(None)
+
+ def _itemRequiresUpdate(self, item):
+ """Called by items in the plot for asynchronous update
+
+ :param Item item: The item that required update
+ """
+ assert item.getPlot() == self
+ self._contentToUpdate.add(item)
+ self._setDirtyPlot(overlayOnly=item.isOverlay())
+
+ # Add
+
+ # add * input arguments management:
+ # If an arg is set, then use it.
+ # Else:
+ # If a curve with the same legend exists, then use its arg value
+ # Else, use a default value.
+ # Store used value.
+ # This value is used when curve is updated either internally or by user.
+
+ def addCurve(self, x, y, legend=None, info=None,
+ replace=False, replot=None,
+ color=None, symbol=None,
+ linewidth=None, linestyle=None,
+ xlabel=None, ylabel=None, yaxis=None,
+ xerror=None, yerror=None, z=None, selectable=None,
+ fill=None, resetzoom=True,
+ histogram=None, copy=True, **kw):
+ """Add a 1D curve given by x an y to the graph.
+
+ Curves are uniquely identified by their legend.
+ To add multiple curves, call :meth:`addCurve` multiple times with
+ different legend argument.
+ To replace an existing curve, call :meth:`addCurve` with the
+ existing curve legend.
+ If you want to display the curve values as an histogram see the
+ histogram parameter or :meth:`addHistogram`.
+
+ When curve parameters are not provided, if a curve with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray x: The data corresponding to the x coordinates.
+ If you attempt to plot an histogram you can set edges values in x.
+ In this case len(x) = len(y) + 1
+ :param numpy.ndarray y: The data corresponding to the y coordinates
+ :param str legend: The legend to be associated to the curve (or None)
+ :param info: User-defined information associated to the curve
+ :param bool replace: True (the default) to delete already existing
+ curves
+ :param color: color(s) to be used
+ :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ :param str symbol: Symbol to be drawn at each (x, y) position::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+ - None (the default) to use default symbol
+
+ :param float linewidth: The width of the curve in pixels (Default: 1).
+ :param str linestyle: Type of line::
+
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+ - None (the default) to use default line style
+
+ :param str xlabel: Label to show on the X axis when the curve is active
+ or None to keep default axis label.
+ :param str ylabel: Label to show on the Y axis when the curve is active
+ or None to keep default axis label.
+ :param str yaxis: The Y axis this curve is attached to.
+ Either 'left' (the default) or 'right'
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for positive errors,
+ row 1 for negative errors.
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param int z: Layer on which to draw the curve (default: 1)
+ This allows to control the overlay.
+ :param bool selectable: Indicate if the curve can be selected.
+ (Default: True)
+ :param bool fill: True to fill the curve, False otherwise (default).
+ :param bool resetzoom: True (the default) to reset the zoom.
+ :param str histogram: if not None then the curve will be draw as an
+ histogram. The step for each values of the curve can be set to the
+ left, center or right of the original x curve values.
+ If histogram is not None and len(x) == len(y)+1 then x is directly
+ take as edges of the histogram.
+ Type of histogram::
+
+ - None (default)
+ - 'left'
+ - 'right'
+ - 'center'
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this curve
+ """
+ # Deprecation warnings
+ if replot is not None:
+ _logger.warning(
+ 'addCurve deprecated replot argument, use resetzoom instead')
+ resetzoom = replot and resetzoom
+
+ if kw:
+ _logger.warning('addCurve: deprecated extra arguments')
+
+ # This is an histogram, use addHistogram
+ if histogram is not None:
+ histoLegend = self.addHistogram(histogram=y,
+ edges=x,
+ legend=legend,
+ color=color,
+ fill=fill,
+ align=histogram,
+ copy=copy)
+ histo = self.getHistogram(histoLegend)
+
+ histo.setInfo(info)
+ if linewidth is not None:
+ histo.setLineWidth(linewidth)
+ if linestyle is not None:
+ histo.setLineStyle(linestyle)
+ if xlabel is not None:
+ _logger.warning(
+ 'addCurve: Histogram does not support xlabel argument')
+ if ylabel is not None:
+ _logger.warning(
+ 'addCurve: Histogram does not support ylabel argument')
+ if yaxis is not None:
+ histo.setYAxis(yaxis)
+ if z is not None:
+ histo.setZValue(z)
+ if selectable is not None:
+ _logger.warning(
+ 'addCurve: Histogram does not support selectable argument')
+
+ return
+
+ legend = 'Unnamed curve 1.1' if legend is None else str(legend)
+
+ # Check if curve was previously active
+ wasActive = self.getActiveCurve(just_legend=True) == legend
+
+ # Create/Update curve object
+ curve = self.getCurve(legend)
+ if curve is None:
+ # No previous curve, create a default one and add it to the plot
+ curve = items.Curve() if histogram is None else items.Histogram()
+ curve._setLegend(legend)
+ # Set default color, linestyle and symbol
+ default_color, default_linestyle = self._getColorAndStyle()
+ curve.setColor(default_color)
+ curve.setLineStyle(default_linestyle)
+ curve.setSymbol(self._defaultPlotPoints)
+ self._add(curve)
+
+ # Override previous/default values with provided ones
+ curve.setInfo(info)
+ if color is not None:
+ curve.setColor(color)
+ if symbol is not None:
+ curve.setSymbol(symbol)
+ if linewidth is not None:
+ curve.setLineWidth(linewidth)
+ if linestyle is not None:
+ curve.setLineStyle(linestyle)
+ if xlabel is not None:
+ curve._setXLabel(xlabel)
+ if ylabel is not None:
+ curve._setYLabel(ylabel)
+ if yaxis is not None:
+ curve.setYAxis(yaxis)
+ if z is not None:
+ curve.setZValue(z)
+ if selectable is not None:
+ curve._setSelectable(selectable)
+ if fill is not None:
+ curve.setFill(fill)
+
+ # Set curve data
+ # If errors not provided, reuse previous ones
+ # TODO: Issue if size of data change but not that of errors
+ if xerror is None:
+ xerror = curve.getXErrorData(copy=False)
+ if yerror is None:
+ yerror = curve.getYErrorData(copy=False)
+
+ curve.setData(x, y, xerror, yerror, copy=copy)
+
+ if replace: # Then remove all other curves
+ for c in self.getAllCurves(withhidden=True):
+ if c is not curve:
+ self._remove(c)
+
+ self.notify(
+ 'contentChanged', action='add', kind='curve', legend=legend)
+
+ if wasActive:
+ self.setActiveCurve(curve.getLegend())
+
+ if resetzoom:
+ # We ask for a zoom reset in order to handle the plot scaling
+ # if the user does not want that, autoscale of the different
+ # axes has to be set to off.
+ self.resetZoom()
+
+ return legend
+
+ def addHistogram(self,
+ histogram,
+ edges,
+ legend=None,
+ color=None,
+ fill=None,
+ align='center',
+ resetzoom=True,
+ copy=True):
+ """Add an histogram to the graph.
+
+ This is NOT computing the histogram, this method takes as parameter
+ already computed histogram values.
+
+ Histogram are uniquely identified by their legend.
+ To add multiple histograms, call :meth:`addHistogram` multiple times
+ with different legend argument.
+
+ When histogram parameters are not provided, if an histogram with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray histogram: The values of the histogram.
+ :param numpy.ndarray edges:
+ The bin edges of the histogram.
+ If histogram and edges have the same length, the bin edges
+ are computed according to the align parameter.
+ :param str legend:
+ The legend to be associated to the histogram (or None)
+ :param color: color to be used
+ :type color: str ("#RRGGBB") or RGB unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ :param bool fill: True to fill the curve, False otherwise (default).
+ :param str align:
+ In case histogram values and edges have the same length N,
+ the N+1 bin edges are computed according to the alignment in:
+ 'center' (default), 'left', 'right'.
+ :param bool resetzoom: True (the default) to reset the zoom.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this histogram
+ """
+ legend = 'Unnamed histogram' if legend is None else str(legend)
+
+ # Create/Update histogram object
+ histo = self.getHistogram(legend)
+ if histo is None:
+ # No previous histogram, create a default one and
+ # add it to the plot
+ histo = items.Histogram()
+ histo._setLegend(legend)
+ histo.setColor(self._getColorAndStyle()[0])
+ self._add(histo)
+
+ # Override previous/default values with provided ones
+ if color is not None:
+ histo.setColor(color)
+ if fill is not None:
+ histo.setFill(fill)
+
+ # Set histogram data
+ histo.setData(histogram, edges, align=align, copy=copy)
+
+ self.notify(
+ 'contentChanged', action='add', kind='histogram', legend=legend)
+
+ if resetzoom:
+ # We ask for a zoom reset in order to handle the plot scaling
+ # if the user does not want that, autoscale of the different
+ # axes has to be set to off.
+ self.resetZoom()
+
+ return legend
+
+ def addImage(self, data, legend=None, info=None,
+ replace=True, replot=None,
+ xScale=None, yScale=None, z=None,
+ selectable=None, draggable=None,
+ colormap=None, pixmap=None,
+ xlabel=None, ylabel=None,
+ origin=None, scale=None,
+ resetzoom=True, copy=True, **kw):
+ """Add a 2D dataset or an image to the plot.
+
+ It displays either an array of data using a colormap or a RGB(A) image.
+
+ Images are uniquely identified by their legend.
+ To add multiple images, call :meth:`addImage` multiple times with
+ different legend argument.
+ To replace/update an existing image, call :meth:`addImage` with the
+ existing image legend.
+
+ When image parameters are not provided, if an image with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray data: (nrows, ncolumns) data or
+ (nrows, ncolumns, RGBA) ubyte array
+ :param str legend: The legend to be associated to the image (or None)
+ :param info: User-defined information associated to the image
+ :param bool replace: True (default) to delete already existing images
+ :param int z: Layer on which to draw the image (default: 0)
+ This allows to control the overlay.
+ :param bool selectable: Indicate if the image can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the image can be moved.
+ (default: False)
+ :param dict colormap: Description of the colormap to use (or None)
+ This is ignored if data is a RGB(A) image.
+ See :mod:`Plot` for the documentation
+ of the colormap dict.
+ :param pixmap: Pixmap representation of the data (if any)
+ :type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default)
+ :param str xlabel: X axis label to show when this curve is active,
+ or None to keep default axis label.
+ :param str ylabel: Y axis label to show when this curve is active,
+ or None to keep default axis label.
+ :param origin: (origin X, origin Y) of the data.
+ It is possible to pass a single float if both
+ coordinates are equal.
+ Default: (0., 0.)
+ :type origin: float or 2-tuple of float
+ :param scale: (scale X, scale Y) of the data.
+ It is possible to pass a single float if both
+ coordinates are equal.
+ Default: (1., 1.)
+ :type scale: float or 2-tuple of float
+ :param bool resetzoom: True (the default) to reset the zoom.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this image
+ """
+ # Deprecation warnings
+ if xScale is not None or yScale is not None:
+ _logger.warning(
+ 'addImage deprecated xScale and yScale arguments,'
+ 'use origin, scale arguments instead.')
+ if origin is None and scale is None:
+ origin = xScale[0], yScale[0]
+ scale = xScale[1], yScale[1]
+ else:
+ _logger.warning(
+ 'addCurve: xScale, yScale and origin, scale arguments'
+ ' are conflicting. xScale and yScale are ignored.'
+ ' Use only origin, scale arguments.')
+
+ if replot is not None:
+ _logger.warning(
+ 'addImage deprecated replot argument, use resetzoom instead')
+ resetzoom = replot and resetzoom
+
+ if kw:
+ _logger.warning('addImage: deprecated extra arguments')
+
+ legend = "Unnamed Image 1.1" if legend is None else str(legend)
+
+ # Check if image was previously active
+ wasActive = self.getActiveImage(just_legend=True) == legend
+
+ data = numpy.array(data, copy=False)
+ assert data.ndim in (2, 3)
+
+ image = self.getImage(legend)
+ if image is not None and image.getData(copy=False).ndim != data.ndim:
+ # Update a data image with RGBA image or the other way around:
+ # Remove previous image
+ # In this case, we don't retrieve defaults from the previous image
+ self._remove(image)
+ image = None
+
+ if image is None:
+ # No previous image, create a default one and add it to the plot
+ if data.ndim == 2:
+ image = items.ImageData()
+ image.setColormap(self.getDefaultColormap())
+ else:
+ image = items.ImageRgba()
+ image._setLegend(legend)
+ self._add(image)
+
+ # Override previous/default values with provided ones
+ image.setInfo(info)
+ if origin is not None:
+ image.setOrigin(origin)
+ if scale is not None:
+ image.setScale(scale)
+ if z is not None:
+ image.setZValue(z)
+ if selectable is not None:
+ image._setSelectable(selectable)
+ if draggable is not None:
+ image._setDraggable(draggable)
+ if colormap is not None and isinstance(image, items.ColormapMixIn):
+ image.setColormap(colormap)
+ if xlabel is not None:
+ image._setXLabel(xlabel)
+ if ylabel is not None:
+ image._setYLabel(ylabel)
+
+ if data.ndim == 2:
+ image.setData(data, alternative=pixmap, copy=copy)
+ else: # RGB(A) image
+ if pixmap is not None:
+ _logger.warning(
+ 'addImage: pixmap argument ignored when data is RGB(A)')
+ image.setData(data, copy=copy)
+
+ if replace:
+ for img in self.getAllImages():
+ if img is not image:
+ self._remove(img)
+
+ if len(self.getAllImages()) == 1 or wasActive:
+ self.setActiveImage(legend)
+
+ self.notify(
+ 'contentChanged', action='add', kind='image', legend=legend)
+
+ if resetzoom:
+ # We ask for a zoom reset in order to handle the plot scaling
+ # if the user does not want that, autoscale of the different
+ # axes has to be set to off.
+ self.resetZoom()
+
+ return legend
+
+ def addScatter(self, x, y, value, legend=None, colormap=None,
+ info=None, symbol=None, xerror=None, yerror=None,
+ z=None, copy=True):
+ """Add a (x, y, value) scatter to the graph.
+
+ Scatters are uniquely identified by their legend.
+ To add multiple scatters, call :meth:`addScatter` multiple times with
+ different legend argument.
+ To replace/update an existing scatter, call :meth:`addScatter` with the
+ existing scatter legend.
+
+ When scatter parameters are not provided, if a scatter with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray x: The data corresponding to the x coordinates.
+ :param numpy.ndarray y: The data corresponding to the y coordinates
+ :param numpy.ndarray value: The data value associated with each point
+ :param str legend: The legend to be associated to the scatter (or None)
+ :param dict colormap: The colormap to be used for the scatter (or None)
+ See :mod:`Plot` for the documentation
+ of the colormap dict.
+ :param info: User-defined information associated to the curve
+ :param str symbol: Symbol to be drawn at each (x, y) position::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+ - None (the default) to use default symbol
+
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for positive errors,
+ row 1 for negative errors.
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param int z: Layer on which to draw the scatter (default: 1)
+ This allows to control the overlay.
+
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this scatter
+ """
+ legend = 'Unnamed scatter 1.1' if legend is None else str(legend)
+
+ # Check if scatter was previously active
+ wasActive = self._getActiveItem(kind='scatter',
+ just_legend=True) == legend
+
+ # Create/Update curve object
+ scatter = self._getItem(kind='scatter', legend=legend)
+ if scatter is None:
+ # No previous scatter, create a default one and add it to the plot
+ scatter = items.Scatter()
+ scatter._setLegend(legend)
+ scatter.setColormap(self.getDefaultColormap())
+ self._add(scatter)
+
+ # Override previous/default values with provided ones
+ scatter.setInfo(info)
+ if symbol is not None:
+ scatter.setSymbol(symbol)
+ if z is not None:
+ scatter.setZValue(z)
+ if colormap is not None:
+ scatter.setColormap(colormap)
+
+ # Set scatter data
+ # If errors not provided, reuse previous ones
+ if xerror is None:
+ xerror = scatter.getXErrorData(copy=False)
+ if xerror is not None and len(xerror) != len(x):
+ xerror = None
+ if yerror is None:
+ yerror = scatter.getYErrorData(copy=False)
+ if yerror is not None and len(yerror) != len(y):
+ yerror = None
+
+ scatter.setData(x, y, value, xerror, yerror, copy=copy)
+
+ self.notify(
+ 'contentChanged', action='add', kind='scatter', legend=legend)
+
+ if len(self._getItems(kind="scatter")) == 1 or wasActive:
+ self._setActiveItem('scatter', scatter.getLegend())
+
+ return legend
+
+ def addItem(self, xdata, ydata, legend=None, info=None,
+ replace=False,
+ shape="polygon", color='black', fill=True,
+ overlay=False, z=None, **kw):
+ """Add an item (i.e. a shape) to the plot.
+
+ Items are uniquely identified by their legend.
+ To add multiple items, call :meth:`addItem` multiple times with
+ different legend argument.
+ To replace/update an existing item, call :meth:`addItem` with the
+ existing item legend.
+
+ :param numpy.ndarray xdata: The X coords of the points of the shape
+ :param numpy.ndarray ydata: The Y coords of the points of the shape
+ :param str legend: The legend to be associated to the item
+ :param info: User-defined information associated to the item
+ :param bool replace: True (default) to delete already existing images
+ :param str shape: Type of item to be drawn in
+ hline, polygon (the default), rectangle, vline,
+ polylines
+ :param str color: Color of the item, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool fill: True (the default) to fill the shape
+ :param bool overlay: True if item is an overlay (Default: False).
+ This allows for rendering optimization if this
+ item is changed often.
+ :param int z: Layer on which to draw the item (default: 2)
+ :returns: The key string identify this item
+ """
+ # expected to receive the same parameters as the signal
+
+ if kw:
+ _logger.warning('addItem deprecated parameters: %s', str(kw))
+
+ legend = "Unnamed Item 1.1" if legend is None else str(legend)
+
+ z = int(z) if z is not None else 2
+
+ if replace:
+ self.remove(kind='item')
+ else:
+ self.remove(legend, kind='item')
+
+ item = items.Shape(shape)
+ item._setLegend(legend)
+ item.setInfo(info)
+ item.setColor(color)
+ item.setFill(fill)
+ item.setOverlay(overlay)
+ item.setZValue(z)
+ item.setPoints(numpy.array((xdata, ydata)).T)
+
+ self._add(item)
+
+ self.notify('contentChanged', action='add', kind='item', legend=legend)
+
+ return legend
+
+ def addXMarker(self, x, legend=None,
+ text=None,
+ color=None,
+ selectable=False,
+ draggable=False,
+ constraint=None,
+ **kw):
+ """Add a vertical line marker to the plot.
+
+ Markers are uniquely identified by their legend.
+ As opposed to curves, images and items, two calls to
+ :meth:`addXMarker` without legend argument adds two markers with
+ different identifying legends.
+
+ :param float x: Position of the marker on the X axis in data
+ coordinates
+ :param str legend: Legend associated to the marker to identify it
+ :param str text: Text to display on the marker.
+ :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool selectable: Indicate if the marker can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the marker can be moved.
+ (default: False)
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :type constraint: None or a callable that takes the coordinates of
+ the current cursor position in the plot as input
+ and that returns the filtered coordinates.
+ :return: The key string identify this marker
+ """
+ if kw:
+ _logger.warning(
+ 'addXMarker deprecated extra parameters: %s', str(kw))
+
+ return self._addMarker(x=x, y=None, legend=legend,
+ text=text, color=color,
+ selectable=selectable, draggable=draggable,
+ symbol=None, constraint=constraint)
+
+ def addYMarker(self, y,
+ legend=None,
+ text=None,
+ color=None,
+ selectable=False,
+ draggable=False,
+ constraint=None,
+ **kw):
+ """Add a horizontal line marker to the plot.
+
+ Markers are uniquely identified by their legend.
+ As opposed to curves, images and items, two calls to
+ :meth:`addYMarker` without legend argument adds two markers with
+ different identifying legends.
+
+ :param float y: Position of the marker on the Y axis in data
+ coordinates
+ :param str legend: Legend associated to the marker to identify it
+ :param str text: Text to display next to the marker.
+ :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool selectable: Indicate if the marker can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the marker can be moved.
+ (default: False)
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :type constraint: None or a callable that takes the coordinates of
+ the current cursor position in the plot as input
+ and that returns the filtered coordinates.
+ :return: The key string identify this marker
+ """
+ if kw:
+ _logger.warning(
+ 'addYMarker deprecated extra parameters: %s', str(kw))
+
+ return self._addMarker(x=None, y=y, legend=legend,
+ text=text, color=color,
+ selectable=selectable, draggable=draggable,
+ symbol=None, constraint=constraint)
+
+ def addMarker(self, x, y, legend=None,
+ text=None,
+ color=None,
+ selectable=False,
+ draggable=False,
+ symbol='+',
+ constraint=None,
+ **kw):
+ """Add a point marker to the plot.
+
+ Markers are uniquely identified by their legend.
+ As opposed to curves, images and items, two calls to
+ :meth:`addMarker` without legend argument adds two markers with
+ different identifying legends.
+
+ :param float x: Position of the marker on the X axis in data
+ coordinates
+ :param float y: Position of the marker on the Y axis in data
+ coordinates
+ :param str legend: Legend associated to the marker to identify it
+ :param str text: Text to display next to the marker
+ :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool selectable: Indicate if the marker can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the marker can be moved.
+ (default: False)
+ :param str symbol: Symbol representing the marker in::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross (the default)
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :type constraint: None or a callable that takes the coordinates of
+ the current cursor position in the plot as input
+ and that returns the filtered coordinates.
+ :return: The key string identify this marker
+ """
+ if kw:
+ _logger.warning(
+ 'addMarker deprecated extra parameters: %s', str(kw))
+
+ if x is None:
+ xmin, xmax = self.getGraphXLimits()
+ x = 0.5 * (xmax + xmin)
+
+ if y is None:
+ ymin, ymax = self.getGraphYLimits()
+ y = 0.5 * (ymax + ymin)
+
+ return self._addMarker(x=x, y=y, legend=legend,
+ text=text, color=color,
+ selectable=selectable, draggable=draggable,
+ symbol=symbol, constraint=constraint)
+
+ def _addMarker(self, x, y, legend,
+ text, color,
+ selectable, draggable,
+ symbol, constraint):
+ """Common method for adding point, vline and hline marker.
+
+ See :meth:`addMarker` for argument documentation.
+ """
+ assert (x, y) != (None, None)
+
+ if legend is None: # Find an unused legend
+ markerLegends = self._getAllMarkers(just_legend=True)
+ for index in itertools.count():
+ legend = "Unnamed Marker %d" % index
+ if legend not in markerLegends:
+ break # Keep this legend
+ legend = str(legend)
+
+ if x is None:
+ markerClass = items.YMarker
+ elif y is None:
+ markerClass = items.XMarker
+ else:
+ markerClass = items.Marker
+
+ # Create/Update marker object
+ marker = self._getMarker(legend)
+ if marker is not None and not isinstance(marker, markerClass):
+ _logger.warning('Adding marker with same legend'
+ ' but different type replaces it')
+ self._remove(marker)
+ marker = None
+
+ if marker is None:
+ # No previous marker, create one
+ marker = markerClass()
+ marker._setLegend(legend)
+ self._add(marker)
+
+ if text is not None:
+ marker.setText(text)
+ if color is not None:
+ marker.setColor(color)
+ if selectable is not None:
+ marker._setSelectable(selectable)
+ if draggable is not None:
+ marker._setDraggable(draggable)
+ if symbol is not None:
+ marker.setSymbol(symbol)
+
+ # TODO to improve, but this ensure constraint is applied
+ marker.setPosition(x, y)
+ if constraint is not None:
+ marker._setConstraint(constraint)
+ marker.setPosition(x, y)
+
+ self.notify(
+ 'contentChanged', action='add', kind='marker', legend=legend)
+
+ return legend
+
+ # Hide
+
+ def isCurveHidden(self, legend):
+ """Returns True if the curve associated to legend is hidden, else False
+
+ :param str legend: The legend key identifying the curve
+ :return: True if the associated curve is hidden, False otherwise
+ """
+ curve = self._getItem('curve', legend)
+ return curve is not None and not curve.isVisible()
+
+ def hideCurve(self, legend, flag=True, replot=None):
+ """Show/Hide the curve associated to legend.
+
+ Even when hidden, the curve is kept in the list of curves.
+
+ :param str legend: The legend associated to the curve to be hidden
+ :param bool flag: True (default) to hide the curve, False to show it
+ """
+ if replot is not None:
+ _logger.warning('hideCurve deprecated replot parameter')
+
+ curve = self._getItem('curve', legend)
+ if curve is None:
+ _logger.warning('Curve not in plot: %s', legend)
+ return
+
+ isVisible = not flag
+ if isVisible != curve.isVisible():
+ curve.setVisible(isVisible)
+
+ # Remove
+
+ ITEM_KINDS = 'curve', 'image', 'scatter', 'item', 'marker', 'histogram'
+
+ def remove(self, legend=None, kind=ITEM_KINDS):
+ """Remove one or all element(s) of the given legend and kind.
+
+ Examples:
+
+ - ``remove()`` clears the plot
+ - ``remove(kind='curve')`` removes all curves from the plot
+ - ``remove('myCurve', kind='curve')`` removes the curve with
+ legend 'myCurve' from the plot.
+ - ``remove('myImage, kind='image')`` removes the image with
+ legend 'myImage' from the plot.
+ - ``remove('myImage')`` removes elements (for instance curve, image,
+ item and marker) with legend 'myImage'.
+
+ :param str legend: The legend associated to the element to remove,
+ or None to remove
+ :param kind: The kind of elements to remove from the plot.
+ In: 'all', 'curve', 'image', 'item', 'marker'.
+ By default, it removes all kind of elements.
+ :type kind: str or tuple of str to specify multiple kinds.
+ """
+ if kind is 'all': # Replace all by tuple of all kinds
+ kind = self.ITEM_KINDS
+
+ if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
+ kind = (kind,)
+
+ for aKind in kind:
+ assert aKind in self.ITEM_KINDS
+
+ if legend is None: # This is a clear
+ # Clear each given kind
+ for aKind in kind:
+ for legend in self._getItems(
+ kind=aKind, just_legend=True, withhidden=True):
+ self.remove(legend=legend, kind=aKind)
+
+ else: # This is removing a single element
+ # Remove each given kind
+ for aKind in kind:
+ item = self._getItem(aKind, legend)
+ if item is not None:
+ if aKind in ('curve', 'image'):
+ if self._getActiveItem(aKind) == item:
+ # Reset active item
+ self._setActiveItem(aKind, None)
+
+ self._remove(item)
+
+ if (aKind == 'curve' and
+ not self.getAllCurves(just_legend=True,
+ withhidden=True)):
+ self._colorIndex = 0
+ self._styleIndex = 0
+
+ self.notify('contentChanged', action='remove',
+ kind=aKind, legend=legend)
+
+ def removeCurve(self, legend):
+ """Remove the curve associated to legend from the graph.
+
+ :param str legend: The legend associated to the curve to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='curve')
+
+ def removeImage(self, legend):
+ """Remove the image associated to legend from the graph.
+
+ :param str legend: The legend associated to the image to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='image')
+
+ def removeItem(self, legend):
+ """Remove the item associated to legend from the graph.
+
+ :param str legend: The legend associated to the item to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='item')
+
+ def removeMarker(self, legend):
+ """Remove the marker associated to legend from the graph.
+
+ :param str legend: The legend associated to the marker to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='marker')
+
+ # Clear
+
+ def clear(self):
+ """Remove everything from the plot."""
+ self.remove()
+
+ def clearCurves(self):
+ """Remove all the curves from the plot."""
+ self.remove(kind='curve')
+
+ def clearImages(self):
+ """Remove all the images from the plot."""
+ self.remove(kind='image')
+
+ def clearItems(self):
+ """Remove all the items from the plot. """
+ self.remove(kind='item')
+
+ def clearMarkers(self):
+ """Remove all the markers from the plot."""
+ self.remove(kind='marker')
+
+ # Interaction
+
+ def getGraphCursor(self):
+ """Returns the state of the crosshair cursor.
+
+ See :meth:`setGraphCursor`.
+
+ :return: None if the crosshair cursor is not active,
+ else a tuple (color, linewidth, linestyle).
+ """
+ return self._cursorConfiguration
+
+ def setGraphCursor(self, flag=False, color='black',
+ linewidth=1, linestyle='-'):
+ """Toggle the display of a crosshair cursor and set its attributes.
+
+ :param bool flag: Toggle the display of a crosshair cursor.
+ The crosshair cursor is hidden by default.
+ :param color: The color to use for the crosshair.
+ :type color: A string (either a predefined color name in Colors.py
+ or "#RRGGBB")) or a 4 columns unsigned byte array
+ (Default: black).
+ :param int linewidth: The width of the lines of the crosshair
+ (Default: 1).
+ :param str linestyle: Type of line::
+
+ - ' ' no line
+ - '-' solid line (the default)
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+ """
+ if flag:
+ self._cursorConfiguration = color, linewidth, linestyle
+ else:
+ self._cursorConfiguration = None
+
+ self._backend.setGraphCursor(flag=flag, color=color,
+ linewidth=linewidth, linestyle=linestyle)
+ self._setDirtyPlot()
+ self.notify('setGraphCursor',
+ state=self._cursorConfiguration is not None)
+
+ def pan(self, direction, factor=0.1):
+ """Pan the graph in the given direction by the given factor.
+
+ Warning: Pan of right Y axis not implemented!
+
+ :param str direction: One of 'up', 'down', 'left', 'right'.
+ :param float factor: Proportion of the range used to pan the graph.
+ Must be strictly positive.
+ """
+ assert direction in ('up', 'down', 'left', 'right')
+ assert factor > 0.
+
+ if direction in ('left', 'right'):
+ xFactor = factor if direction == 'right' else - factor
+ xMin, xMax = self.getGraphXLimits()
+
+ xMin, xMax = _utils.applyPan(xMin, xMax, xFactor,
+ self.isXAxisLogarithmic())
+ self.setGraphXLimits(xMin, xMax)
+
+ else: # direction in ('up', 'down')
+ sign = -1. if self.isYAxisInverted() else 1.
+ yFactor = sign * (factor if direction == 'up' else -factor)
+ yMin, yMax = self.getGraphYLimits()
+ yIsLog = self.isYAxisLogarithmic()
+
+ yMin, yMax = _utils.applyPan(yMin, yMax, yFactor, yIsLog)
+ self.setGraphYLimits(yMin, yMax, axis='left')
+
+ y2Min, y2Max = self.getGraphYLimits(axis='right')
+
+ y2Min, y2Max = _utils.applyPan(y2Min, y2Max, yFactor, yIsLog)
+ self.setGraphYLimits(y2Min, y2Max, axis='right')
+
+ # Active Curve/Image
+
+ def isActiveCurveHandling(self):
+ """Returns True if active curve selection is enabled."""
+ return self._activeCurveHandling
+
+ def setActiveCurveHandling(self, flag=True):
+ """Enable/Disable active curve selection.
+
+ :param bool flag: True (the default) to enable active curve selection.
+ """
+ if not flag:
+ self.setActiveCurve(None) # Reset active curve
+
+ self._activeCurveHandling = bool(flag)
+
+ def getActiveCurveColor(self):
+ """Get the color used to display the currently active curve.
+
+ See :meth:`setActiveCurveColor`.
+ """
+ return self._activeCurveColor
+
+ def setActiveCurveColor(self, color="#000000"):
+ """Set the color to use to display the currently active curve.
+
+ :param str color: Color of the active curve,
+ e.g., 'blue', 'b', '#FF0000' (Default: 'black')
+ """
+ if color is None:
+ color = "black"
+ if color in self.colorDict:
+ color = self.colorDict[color]
+ self._activeCurveColor = color
+
+ def getActiveCurve(self, just_legend=False):
+ """Return the currently active curve.
+
+ It returns None in case of not having an active curve.
+
+ :param bool just_legend: True to get the legend of the curve,
+ False (the default) to get the curve data
+ and info.
+ :return: Active curve's legend or corresponding
+ :class:`.items.Curve`
+ :rtype: str or :class:`.items.Curve` or None
+ """
+ if not self.isActiveCurveHandling():
+ return None
+
+ return self._getActiveItem(kind='curve', just_legend=just_legend)
+
+ def setActiveCurve(self, legend, replot=None):
+ """Make the curve associated to legend the active curve.
+
+ :param legend: The legend associated to the curve
+ or None to have no active curve.
+ :type legend: str or None
+ """
+ if replot is not None:
+ _logger.warning('setActiveCurve deprecated replot parameter')
+
+ if not self.isActiveCurveHandling():
+ return
+
+ return self._setActiveItem(kind='curve', legend=legend)
+
+ def getActiveImage(self, just_legend=False):
+ """Returns the currently active image.
+
+ It returns None in case of not having an active image.
+
+ :param bool just_legend: True to get the legend of the image,
+ False (the default) to get the image data
+ and info.
+ :return: Active image's legend or corresponding image object
+ :rtype: str, :class:`.items.ImageData`, :class:`.items.ImageRgba`
+ or None
+ """
+ return self._getActiveItem(kind='image', just_legend=just_legend)
+
+ def setActiveImage(self, legend, replot=None):
+ """Make the image associated to legend the active image.
+
+ :param str legend: The legend associated to the image
+ or None to have no active image.
+ """
+ if replot is not None:
+ _logger.warning('setActiveImage deprecated replot parameter')
+
+ return self._setActiveItem(kind='image', legend=legend)
+
+ def _getActiveItem(self, kind, just_legend=False):
+ """Return the currently active item of that kind if any
+
+ :param str kind: Type of item: 'curve', 'scatter' or 'image'
+ :param bool just_legend: True to get the legend,
+ False (default) to get the item
+ :return: legend or item or None if no active item
+ """
+ assert kind in ('curve', 'scatter', 'image')
+
+ if self._activeLegend[kind] is None:
+ return None
+
+ if (self._activeLegend[kind], kind) not in self._content:
+ self._activeLegend[kind] = None
+ return None
+
+ if just_legend:
+ return self._activeLegend[kind]
+ else:
+ return self._getItem(kind, self._activeLegend[kind])
+
+ def _setActiveItem(self, kind, legend):
+ """Make the curve associated to legend the active curve.
+
+ :param str kind: Type of item: 'curve' or 'image'
+ :param legend: The legend associated to the curve
+ or None to have no active curve.
+ :type legend: str or None
+ """
+ assert kind in ('curve', 'image', 'scatter')
+
+ xLabel = self._defaultLabels['x']
+ yLabel = self._defaultLabels['y']
+ yRightLabel = self._defaultLabels['yright']
+
+ oldActiveItem = self._getActiveItem(kind=kind)
+
+ # Curve specific: Reset highlight of previous active curve
+ if kind == 'curve' and oldActiveItem is not None:
+ oldActiveItem.setHighlighted(False)
+
+ if legend is None:
+ self._activeLegend[kind] = None
+ else:
+ legend = str(legend)
+ item = self._getItem(kind, legend)
+ if item is None:
+ _logger.warning("This %s does not exist: %s", kind, legend)
+ self._activeLegend[kind] = None
+ else:
+ self._activeLegend[kind] = legend
+
+ # Curve specific: handle highlight
+ if kind == 'curve':
+ item.setHighlightedColor(self.getActiveCurveColor())
+ item.setHighlighted(True)
+
+ if isinstance(item, items.LabelsMixIn):
+ if item.getXLabel() is not None:
+ xLabel = item.getXLabel()
+ if item.getYLabel() is not None:
+ if (isinstance(item, items.YAxisMixIn) and
+ item.getYAxis() == 'right'):
+ yRightLabel = item.getYLabel()
+ else:
+ yLabel = item.getYLabel()
+
+ # Store current labels and update plot
+ self._currentLabels['x'] = xLabel
+ self._currentLabels['y'] = yLabel
+ self._currentLabels['yright'] = yRightLabel
+
+ self._backend.setGraphXLabel(xLabel)
+ self._backend.setGraphYLabel(yLabel, axis='left')
+ self._backend.setGraphYLabel(yRightLabel, axis='right')
+
+ self._setDirtyPlot()
+
+ activeLegend = self._activeLegend[kind]
+ if oldActiveItem is not None or activeLegend is not None:
+ if oldActiveItem is None:
+ oldActiveLegend = None
+ else:
+ oldActiveLegend = oldActiveItem.getLegend()
+ self.notify(
+ 'active' + kind[0].upper() + kind[1:] + 'Changed',
+ updated=oldActiveLegend != activeLegend,
+ previous=oldActiveLegend,
+ legend=activeLegend)
+
+ return activeLegend
+
+ # Getters
+
+ def getAllCurves(self, just_legend=False, withhidden=False):
+ """Returns all curves legend or info and data.
+
+ It returns an empty list in case of not having any curve.
+
+ If just_legend is False, it returns a list of :class:`items.Curve`
+ objects describing the curves.
+ If just_legend is True, it returns a list of curves' legend.
+
+ :param bool just_legend: True to get the legend of the curves,
+ False (the default) to get the curves' data
+ and info.
+ :param bool withhidden: False (default) to skip hidden curves.
+ :return: list of curves' legend or :class:`.items.Curve`
+ :rtype: list of str or list of :class:`.items.Curve`
+ """
+ return self._getItems(kind='curve',
+ just_legend=just_legend,
+ withhidden=withhidden)
+
+ def getCurve(self, legend=None):
+ """Get the object describing a specific curve.
+
+ It returns None in case no matching curve is found.
+
+ :param str legend:
+ The legend identifying the curve.
+ If not provided or None (the default), the active curve is returned
+ or if there is no active curve, the latest updated curve that is
+ not hidden is returned if there are curves in the plot.
+ :return: None or :class:`.items.Curve` object
+ """
+ return self._getItem(kind='curve', legend=legend)
+
+ def getAllImages(self, just_legend=False):
+ """Returns all images legend or objects.
+
+ It returns an empty list in case of not having any image.
+
+ If just_legend is False, it returns a list of :class:`items.ImageBase`
+ objects describing the images.
+ If just_legend is True, it returns a list of legends.
+
+ :param bool just_legend: True to get the legend of the images,
+ False (the default) to get the images'
+ object.
+ :return: list of images' legend or :class:`.items.ImageBase`
+ :rtype: list of str or list of :class:`.items.ImageBase`
+ """
+ return self._getItems(kind='image',
+ just_legend=just_legend,
+ withhidden=True)
+
+ def getImage(self, legend=None):
+ """Get the object describing a specific image.
+
+ It returns None in case no matching image is found.
+
+ :param str legend:
+ The legend identifying the image.
+ If not provided or None (the default), the active image is returned
+ or if there is no active image, the latest updated image
+ is returned if there are images in the plot.
+ :return: None or :class:`.items.ImageBase` object
+ """
+ return self._getItem(kind='image', legend=legend)
+
+ def getScatter(self, legend=None):
+ """Get the object describing a specific scatter.
+
+ It returns None in case no matching scatter is found.
+
+ :param str legend:
+ The legend identifying the scatter.
+ If not provided or None (the default), the active scatter is
+ returned or if there is no active scatter, the latest updated
+ scatter is returned if there are scatters in the plot.
+ :return: None or :class:`.items.Scatter` object
+ """
+ return self._getItem(kind='scatter', legend=legend)
+
+ def getHistogram(self, legend=None):
+ """Get the object describing a specific histogram.
+
+ It returns None in case no matching histogram is found.
+
+ :param str legend:
+ The legend identifying the histogram.
+ If not provided or None (the default), the latest updated scatter
+ is returned if there are histograms in the plot.
+ :return: None or :class:`.items.Histogram` object
+ """
+ return self._getItem(kind='histogram', legend=legend)
+
+ def _getItems(self, kind, just_legend=False, withhidden=False):
+ """Retrieve all items of a kind in the plot
+
+ :param str kind: Type of item: 'curve' or 'image'
+ :param bool just_legend: True to get the legend of the curves,
+ False (the default) to get the curves' data
+ and info.
+ :param bool withhidden: False (default) to skip hidden curves.
+ :return: list of legends or item objects
+ """
+ assert kind in self.ITEM_KINDS
+ output = []
+ for (legend, type_), item in self._content.items():
+ if type_ == kind and (withhidden or item.isVisible()):
+ output.append(legend if just_legend else item)
+ return output
+
+ def _getItem(self, kind, legend=None):
+ """Get an item from the plot: either an image or a curve.
+
+ Returns None if no match found
+
+ :param str kind: Type of item: 'curve' or 'image'
+ :param str legend: Legend of the item or
+ None to get active or last item
+ :return: Object describing the item or None
+ """
+ assert kind in self.ITEM_KINDS
+
+ if legend is not None:
+ return self._content.get((legend, kind), None)
+ else:
+ if kind in ('curve', 'image', 'scatter'):
+ item = self._getActiveItem(kind=kind)
+ if item is not None: # Return active item if available
+ return item
+ # Return last visible item if any
+ allItems = self._getItems(
+ kind=kind, just_legend=False, withhidden=False)
+ return allItems[-1] if allItems else None
+
+ # Limits
+
+ def _notifyLimitsChanged(self):
+ """Send an event when plot area limits are changed."""
+ xRange = self.getGraphXLimits()
+ yRange = self.getGraphYLimits(axis='left')
+ y2Range = self.getGraphYLimits(axis='right')
+ event = PlotEvents.prepareLimitsChangedSignal(
+ id(self.getWidgetHandle()), xRange, yRange, y2Range)
+ self.notify(**event)
+
+ def _checkLimits(self, min_, max_, axis):
+ """Makes sure axis range is not empty
+
+ :param float min_: Min axis value
+ :param float max_: Max axis value
+ :param str axis: 'x', 'y' or 'y2' the axis to deal with
+ :return: (min, max) making sure min < max
+ :rtype: 2-tuple of float
+ """
+ if max_ < min_:
+ _logger.info('%s axis: max < min, inverting limits.', axis)
+ min_, max_ = max_, min_
+ elif max_ == min_:
+ _logger.info('%s axis: max == min, expanding limits.', axis)
+ if min_ == 0.:
+ min_, max_ = -0.1, 0.1
+ elif min_ < 0:
+ min_, max_ = min_ * 1.1, min_ * 0.9
+ else: # xmin > 0
+ min_, max_ = min_ * 0.9, min_ * 1.1
+
+ return min_, max_
+
+ def getGraphXLimits(self):
+ """Get the graph X (bottom) limits.
+
+ :return: Minimum and maximum values of the X axis
+ """
+ return self._backend.getGraphXLimits()
+
+ def setGraphXLimits(self, xmin, xmax, replot=None):
+ """Set the graph X (bottom) limits.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ """
+ if replot is not None:
+ _logger.warning('setGraphXLimits deprecated replot parameter')
+
+ xmin, xmax = self._checkLimits(xmin, xmax, axis='x')
+
+ self._backend.setGraphXLimits(xmin, xmax)
+ self._setDirtyPlot()
+
+ self._notifyLimitsChanged()
+
+ def getGraphYLimits(self, axis='left'):
+ """Get the graph Y limits.
+
+ :param str axis: The axis for which to get the limits:
+ Either 'left' or 'right'
+ :return: Minimum and maximum values of the X axis
+ """
+ assert axis in ('left', 'right')
+ return self._backend.getGraphYLimits(axis)
+
+ def setGraphYLimits(self, ymin, ymax, axis='left', replot=None):
+ """Set the graph Y limits.
+
+ :param float ymin: minimum bottom axis value
+ :param float ymax: maximum bottom axis value
+ :param str axis: The axis for which to get the limits:
+ Either 'left' or 'right'
+ """
+ if replot is not None:
+ _logger.warning('setGraphYLimits deprecated replot parameter')
+
+ assert axis in ('left', 'right')
+
+ ymin, ymax = self._checkLimits(ymin, ymax,
+ axis='y' if axis == 'left' else 'y2')
+
+ self._backend.setGraphYLimits(ymin, ymax, axis)
+ self._setDirtyPlot()
+
+ self._notifyLimitsChanged()
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ """Set the limits of the X and Y axes at once.
+
+ If y2min or y2max is None, the right Y axis limits are not updated.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ :param float ymin: minimum left axis value
+ :param float ymax: maximum left axis value
+ :param float y2min: minimum right axis value or None (the default)
+ :param float y2max: maximum right axis value or None (the default)
+ """
+ # Deal with incorrect values
+ xmin, xmax = self._checkLimits(xmin, xmax, axis='x')
+ ymin, ymax = self._checkLimits(ymin, ymax, axis='y')
+
+ if y2min is None or y2max is None:
+ # if one limit is None, both are ignored
+ y2min, y2max = None, None
+ else:
+ y2min, y2max = self._checkLimits(y2min, y2max, axis='y2')
+
+ self._backend.setLimits(xmin, xmax, ymin, ymax, y2min, y2max)
+ self._setDirtyPlot()
+ self._notifyLimitsChanged()
+
+ # Title and labels
+
+ def getGraphTitle(self):
+ """Return the plot main title as a str."""
+ return self._graphTitle
+
+ def setGraphTitle(self, title=""):
+ """Set the plot main title.
+
+ :param str title: Main title of the plot (default: '')
+ """
+ self._graphTitle = str(title)
+ self._backend.setGraphTitle(title)
+ self._setDirtyPlot()
+
+ def getGraphXLabel(self):
+ """Return the current X axis label as a str."""
+ return self._currentLabels['x']
+
+ def setGraphXLabel(self, label="X"):
+ """Set the plot X axis label.
+
+ The provided label can be temporarily replaced by the X label of the
+ active curve if any.
+
+ :param str label: The X axis label (default: 'X')
+ """
+ self._defaultLabels['x'] = label
+ self._currentLabels['x'] = label
+ self._backend.setGraphXLabel(label)
+ self._setDirtyPlot()
+
+ def getGraphYLabel(self, axis='left'):
+ """Return the current Y axis label as a str.
+
+ :param str axis: The Y axis for which to get the label (left or right)
+ """
+ assert axis in ('left', 'right')
+
+ return self._currentLabels['y' if axis == 'left' else 'yright']
+
+ def setGraphYLabel(self, label="Y", axis='left'):
+ """Set the plot Y axis label.
+
+ The provided label can be temporarily replaced by the Y label of the
+ active curve if any.
+
+ :param str label: The Y axis label (default: 'Y')
+ :param str axis: The Y axis for which to set the label (left or right)
+ """
+ assert axis in ('left', 'right')
+
+ if axis == 'left':
+ self._defaultLabels['y'] = label
+ self._currentLabels['y'] = label
+ else:
+ self._defaultLabels['yright'] = label
+ self._currentLabels['yright'] = label
+
+ self._backend.setGraphYLabel(label, axis=axis)
+ self._setDirtyPlot()
+
+ # Axes
+
+ def setYAxisInverted(self, flag=True):
+ """Set the Y axis orientation.
+
+ :param bool flag: True for Y axis going from top to bottom,
+ False for Y axis going from bottom to top
+ """
+ flag = bool(flag)
+ self._backend.setYAxisInverted(flag)
+ self._setDirtyPlot()
+ self.notify('setYAxisInverted', state=flag)
+
+ def isYAxisInverted(self):
+ """Return True if Y axis goes from top to bottom, False otherwise."""
+ return self._backend.isYAxisInverted()
+
+ def isXAxisLogarithmic(self):
+ """Return True if X axis scale is logarithmic, False if linear."""
+ return self._logX
+
+ def setXAxisLogarithmic(self, flag):
+ """Set the bottom X axis scale (either linear or logarithmic).
+
+ :param bool flag: True to use a logarithmic scale, False for linear.
+ """
+ if bool(flag) == self._logX:
+ return
+ self._logX = bool(flag)
+
+ self._backend.setXAxisLogarithmic(self._logX)
+
+ # TODO hackish way of forcing update of curves and images
+ for curve in self.getAllCurves():
+ curve._updated()
+ for image in self.getAllImages():
+ image._updated()
+ self._invalidateDataRange()
+
+ self.resetZoom()
+ self.notify('setXAxisLogarithmic', state=self._logX)
+
+ def isYAxisLogarithmic(self):
+ """Return True if Y axis scale is logarithmic, False if linear."""
+ return self._logY
+
+ def setYAxisLogarithmic(self, flag):
+ """Set the Y axes scale (either linear or logarithmic).
+
+ :param bool flag: True to use a logarithmic scale, False for linear.
+ """
+ if bool(flag) == self._logY:
+ return
+ self._logY = bool(flag)
+
+ self._backend.setYAxisLogarithmic(self._logY)
+
+ # TODO hackish way of forcing update of curves and images
+ for curve in self.getAllCurves():
+ curve._updated()
+ for image in self.getAllImages():
+ image._updated()
+ self._invalidateDataRange()
+
+ self.resetZoom()
+ self.notify('setYAxisLogarithmic', state=self._logY)
+
+ def isXAxisAutoScale(self):
+ """Return True if X axis is automatically adjusting its limits."""
+ return self._xAutoScale
+
+ def setXAxisAutoScale(self, flag=True):
+ """Set the X axis limits adjusting behavior of :meth:`resetZoom`.
+
+ :param bool flag: True to resize limits automatically,
+ False to disable it.
+ """
+ self._xAutoScale = bool(flag)
+ self.notify('setXAxisAutoScale', state=self._xAutoScale)
+
+ def isYAxisAutoScale(self):
+ """Return True if Y axes are automatically adjusting its limits."""
+ return self._yAutoScale
+
+ def setYAxisAutoScale(self, flag=True):
+ """Set the Y axis limits adjusting behavior of :meth:`resetZoom`.
+
+ :param bool flag: True to resize limits automatically,
+ False to disable it.
+ """
+ self._yAutoScale = bool(flag)
+ self.notify('setYAxisAutoScale', state=self._yAutoScale)
+
+ def isKeepDataAspectRatio(self):
+ """Returns whether the plot is keeping data aspect ratio or not."""
+ return self._backend.isKeepDataAspectRatio()
+
+ def setKeepDataAspectRatio(self, flag=True):
+ """Set whether the plot keeps data aspect ratio or not.
+
+ :param bool flag: True to respect data aspect ratio
+ """
+ flag = bool(flag)
+ self._backend.setKeepDataAspectRatio(flag=flag)
+ self._setDirtyPlot()
+ self.resetZoom()
+ self.notify('setKeepDataAspectRatio', state=flag)
+
+ def getGraphGrid(self):
+ """Return the current grid mode, either None, 'major' or 'both'.
+
+ See :meth:`setGraphGrid`.
+ """
+ return self._grid
+
+ def setGraphGrid(self, which=True):
+ """Set the type of grid to display.
+
+ :param which: None or False to disable the grid,
+ 'major' or True for grid on major ticks (the default),
+ 'both' for grid on both major and minor ticks.
+ :type which: str of bool
+ """
+ assert which in (None, True, False, 'both', 'major')
+ if not which:
+ which = None
+ elif which is True:
+ which = 'major'
+ self._grid = which
+ self._backend.setGraphGrid(which)
+ self._setDirtyPlot()
+ self.notify('setGraphGrid', which=str(which))
+
+ # Defaults
+
+ def isDefaultPlotPoints(self):
+ """Return True if default Curve symbol is 'o', False for no symbol."""
+ return self._defaultPlotPoints == 'o'
+
+ def setDefaultPlotPoints(self, flag):
+ """Set the default symbol of all curves.
+
+ When called, this reset the symbol of all existing curves.
+
+ :param bool flag: True to use 'o' as the default curve symbol,
+ False to use no symbol.
+ """
+ self._defaultPlotPoints = 'o' if flag else ''
+
+ # Reset symbol of all curves
+ curves = self.getAllCurves(just_legend=False, withhidden=True)
+
+ if curves:
+ for curve in curves:
+ curve.setSymbol(self._defaultPlotPoints)
+
+ def isDefaultPlotLines(self):
+ """Return True for line as default line style, False for no line."""
+ return self._plotLines
+
+ def setDefaultPlotLines(self, flag):
+ """Toggle the use of lines as the default curve line style.
+
+ :param bool flag: True to use a line as the default line style,
+ False to use no line as the default line style.
+ """
+ self._plotLines = bool(flag)
+
+ linestyle = '-' if self._plotLines else ' '
+
+ # Reset linestyle of all curves
+ curves = self.getAllCurves(withhidden=True)
+
+ if curves:
+ for curve in curves:
+ curve.setLineStyle(linestyle)
+
+ def getDefaultColormap(self):
+ """Return the default colormap used by :meth:`addImage` as a dict.
+
+ See :mod:`Plot` for the documentation of the colormap dict.
+ """
+ return self._defaultColormap.copy()
+
+ def setDefaultColormap(self, colormap=None):
+ """Set the default colormap used by :meth:`addImage`.
+
+ Setting the default colormap do not change any currently displayed
+ image.
+ It only affects future calls to :meth:`addImage` without the colormap
+ parameter.
+
+ :param dict colormap: The description of the default colormap, or
+ None to set the colormap to a linear autoscale
+ gray colormap.
+ See :mod:`Plot` for the documentation
+ of the colormap dict.
+ """
+ if colormap is None:
+ colormap = {'name': 'gray', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ self._defaultColormap = colormap.copy()
+
+ def getSupportedColormaps(self):
+ """Get the supported colormap names as a tuple of str.
+
+ The list should at least contain and start by:
+ ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+ """
+ default = ('gray', 'reversed gray',
+ 'temperature',
+ 'red', 'green', 'blue')
+ if matplotlib_cm is None:
+ return default
+ else:
+ maps = [m for m in matplotlib_cm.datad]
+ maps.sort()
+ return default + tuple(maps)
+
+ def _getColorAndStyle(self):
+ color = self.colorList[self._colorIndex]
+ style = self._styleList[self._styleIndex]
+
+ # Loop over color and then styles
+ self._colorIndex += 1
+ if self._colorIndex >= len(self.colorList):
+ self._colorIndex = 0
+ self._styleIndex = (self._styleIndex + 1) % len(self._styleList)
+
+ # If color is the one of active curve, take the next one
+ if color == self.getActiveCurveColor():
+ color, style = self._getColorAndStyle()
+
+ if not self._plotLines:
+ style = ' '
+
+ return color, style
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ """Return the widget the plot is displayed in.
+
+ This widget is owned by the backend.
+ """
+ return self._backend.getWidgetHandle()
+
+ def notify(self, event, **kwargs):
+ """Send an event to the listeners.
+
+ Event are passed to the registered callback as a dict with an 'event'
+ key for backward compatibility with PyMca.
+
+ :param str event: The type of event
+ :param kwargs: The information of the event.
+ """
+ eventDict = kwargs.copy()
+ eventDict['event'] = event
+ self._callback(eventDict)
+
+ def setCallback(self, callbackFunction=None):
+ """Attach a listener to the backend.
+
+ Limitation: Only one listener at a time.
+
+ :param callbackFunction: function accepting a dictionary as input
+ to handle the graph events
+ If None (default), use a default listener.
+ """
+ # TODO allow multiple listeners, keep a weakref on it
+ # allow register listener by event type
+ if callbackFunction is None:
+ callbackFunction = self.graphCallback
+ self._callback = callbackFunction
+
+ def graphCallback(self, ddict=None):
+ """This callback is going to receive all the events from the plot.
+
+ Those events will consist on a dictionary and among the dictionary
+ keys the key 'event' is mandatory to describe the type of event.
+ This default implementation only handles setting the active curve.
+ """
+
+ if ddict is None:
+ ddict = {}
+ _logger.debug("Received dict keys = %s", str(ddict.keys()))
+ _logger.debug(str(ddict))
+ if ddict['event'] in ["legendClicked", "curveClicked"]:
+ if ddict['button'] == "left":
+ self.setActiveCurve(ddict['label'])
+
+ def saveGraph(self, filename, fileFormat=None, dpi=None, **kw):
+ """Save a snapshot of the plot.
+
+ Supported file formats: "png", "svg", "pdf", "ps", "eps",
+ "tif", "tiff", "jpeg", "jpg".
+
+ :param filename: Destination
+ :type filename: str, StringIO or BytesIO
+ :param str fileFormat: String specifying the format
+ :return: False if cannot save the plot, True otherwise
+ """
+ if kw:
+ _logger.warning('Extra parameters ignored: %s', str(kw))
+
+ if fileFormat is None:
+ if not hasattr(filename, 'lower'):
+ _logger.warning(
+ 'saveGraph cancelled, cannot define file format.')
+ return False
+ else:
+ fileFormat = (filename.split(".")[-1]).lower()
+
+ supportedFormats = ("png", "svg", "pdf", "ps", "eps",
+ "tif", "tiff", "jpeg", "jpg")
+
+ if fileFormat not in supportedFormats:
+ _logger.warning('Unsupported format %s', fileFormat)
+ return False
+ else:
+ self._backend.saveGraph(filename,
+ fileFormat=fileFormat,
+ dpi=dpi)
+ return True
+
+ def getDataMargins(self):
+ """Get the default data margin ratios, see :meth:`setDataMargins`.
+
+ :return: The margin ratios for each side (xMin, xMax, yMin, yMax).
+ :rtype: A 4-tuple of floats.
+ """
+ return self._defaultDataMargins
+
+ def setDataMargins(self, xMinMargin=0., xMaxMargin=0.,
+ yMinMargin=0., yMaxMargin=0.):
+ """Set the default data margins to use in :meth:`resetZoom`.
+
+ Set the default ratios of margins (as floats) to add around the data
+ inside the plot area for each side.
+ """
+ self._defaultDataMargins = (xMinMargin, xMaxMargin,
+ yMinMargin, yMaxMargin)
+
+ def getAutoReplot(self):
+ """Return True if replot is automatically handled, False otherwise.
+
+ See :meth`setAutoReplot`.
+ """
+ return self._autoreplot
+
+ def setAutoReplot(self, autoreplot=True):
+ """Set automatic replot mode.
+
+ When enabled, the plot is redrawn automatically when changed.
+ When disabled, the plot is not redrawn when its content change.
+ Instead, it :meth:`replot` must be called.
+
+ :param bool autoreplot: True to enable it (default),
+ False to disable it.
+ """
+ self._autoreplot = bool(autoreplot)
+
+ # If the plot is dirty before enabling autoreplot,
+ # then _backend.postRedisplay will never be called from _setDirtyPlot
+ if self._autoreplot and self._getDirtyPlot():
+ self._backend.postRedisplay()
+
+ def replot(self):
+ """Redraw the plot immediately."""
+ for item in self._contentToUpdate:
+ item._update(self._backend)
+ self._contentToUpdate.clear()
+ self._backend.replot()
+ self._dirty = False # reset dirty flag
+
+ def resetZoom(self, dataMargins=None):
+ """Reset the plot limits to the bounds of the data and redraw the plot.
+
+ It automatically scale limits of axes that are in autoscale mode
+ (See :meth:`setXAxisAutoScale`, :meth:`setYAxisAutoScale`).
+ It keeps current limits on axes that are not in autoscale mode.
+
+ Extra margins can be added around the data inside the plot area.
+ Margins are given as one ratio of the data range per limit of the
+ data (xMin, xMax, yMin and yMax limits).
+ For log scale, extra margins are applied in log10 of the data.
+
+ :param dataMargins: Ratios of margins to add around the data inside
+ the plot area for each side (Default: no margins).
+ :type dataMargins: A 4-tuple of float as (xMin, xMax, yMin, yMax).
+ """
+ if dataMargins is None:
+ dataMargins = self._defaultDataMargins
+
+ xLimits = self.getGraphXLimits()
+ yLimits = self.getGraphYLimits(axis='left')
+ y2Limits = self.getGraphYLimits(axis='right')
+
+ xAuto = self.isXAxisAutoScale()
+ yAuto = self.isYAxisAutoScale()
+
+ if not xAuto and not yAuto:
+ _logger.debug("Nothing to autoscale")
+ else: # Some axes to autoscale
+
+ # Get data range
+ ranges = self.getDataRange()
+ xmin, xmax = (1., 100.) if ranges.x is None else ranges.x
+ ymin, ymax = (1., 100.) if ranges.y is None else ranges.y
+ if ranges.yright is None:
+ ymin2, ymax2 = None, None
+ else:
+ ymin2, ymax2 = ranges.yright
+
+ # Add margins around data inside the plot area
+ newLimits = list(_utils.addMarginsToLimits(
+ dataMargins,
+ self.isXAxisLogarithmic(),
+ self.isYAxisLogarithmic(),
+ xmin, xmax, ymin, ymax, ymin2, ymax2))
+
+ if self.isKeepDataAspectRatio():
+ # Use limits with margins to keep ratio
+ xmin, xmax, ymin, ymax = newLimits[:4]
+
+ # Compute bbox wth figure aspect ratio
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+ plotRatio = plotHeight / plotWidth
+
+ if plotRatio > 0.:
+ dataRatio = (ymax - ymin) / (xmax - xmin)
+ if dataRatio < plotRatio:
+ # Increase y range
+ ycenter = 0.5 * (ymax + ymin)
+ yrange = (xmax - xmin) * plotRatio
+ newLimits[2] = ycenter - 0.5 * yrange
+ newLimits[3] = ycenter + 0.5 * yrange
+
+ elif dataRatio > plotRatio:
+ # Increase x range
+ xcenter = 0.5 * (xmax + xmin)
+ xrange_ = (ymax - ymin) / plotRatio
+ newLimits[0] = xcenter - 0.5 * xrange_
+ newLimits[1] = xcenter + 0.5 * xrange_
+
+ self.setLimits(*newLimits)
+
+ if not xAuto and yAuto:
+ self.setGraphXLimits(*xLimits)
+ elif xAuto and not yAuto:
+ if y2Limits is not None:
+ self.setGraphYLimits(
+ y2Limits[0], y2Limits[1], axis='right')
+ if yLimits is not None:
+ self.setGraphYLimits(yLimits[0], yLimits[1], axis='left')
+
+ self._setDirtyPlot()
+
+ if (xLimits != self.getGraphXLimits() or
+ yLimits != self.getGraphYLimits(axis='left') or
+ y2Limits != self.getGraphYLimits(axis='right')):
+ self._notifyLimitsChanged()
+
+ # Coord conversion
+
+ def dataToPixel(self, x=None, y=None, axis="left", check=True):
+ """Convert a position in data coordinates to a position in pixels.
+
+ :param float x: The X coordinate in data space. If None (default)
+ the middle position of the displayed data is used.
+ :param float y: The Y coordinate in data space. If None (default)
+ the middle position of the displayed data is used.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :param bool check: True to return None if outside displayed area,
+ False to convert to pixels anyway
+ :returns: The corresponding position in pixels or
+ None if the data position is not in the displayed area and
+ check is True.
+ :rtype: A tuple of 2 floats: (xPixel, yPixel) or None.
+ """
+ assert axis in ("left", "right")
+
+ xmin, xmax = self.getGraphXLimits()
+ ymin, ymax = self.getGraphYLimits(axis=axis)
+
+ if x is None:
+ x = 0.5 * (xmax + xmin)
+ if y is None:
+ y = 0.5 * (ymax + ymin)
+
+ if check:
+ if x > xmax or x < xmin:
+ return None
+
+ if y > ymax or y < ymin:
+ return None
+
+ return self._backend.dataToPixel(x, y, axis=axis)
+
+ def pixelToData(self, x, y, axis="left", check=False):
+ """Convert a position in pixels to a position in data coordinates.
+
+ :param float x: The X coordinate in pixels. If None (default)
+ the center of the widget is used.
+ :param float y: The Y coordinate in pixels. If None (default)
+ the center of the widget is used.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :param bool check: Toggle checking if pixel is in plot area.
+ If False, this method never returns None.
+ :returns: The corresponding position in data space or
+ None if the pixel position is not in the plot area.
+ :rtype: A tuple of 2 floats: (xData, yData) or None.
+ """
+ assert axis in ("left", "right")
+ return self._backend.pixelToData(x, y, axis=axis, check=check)
+
+ def getPlotBoundsInPixels(self):
+ """Plot area bounds in widget coordinates in pixels.
+
+ :return: bounds as a 4-tuple of int: (left, top, width, height)
+ """
+ return self._backend.getPlotBoundsInPixels()
+
+ # Interaction support
+
+ def setGraphCursorShape(self, cursor=None):
+ """Set the cursor shape.
+
+ :param str cursor: Name of the cursor shape
+ """
+ self._backend.setGraphCursorShape(cursor)
+
+ def _pickMarker(self, x, y, test=None):
+ """Pick a marker at the given position.
+
+ To use for interaction implementation.
+
+ :param float x: X position in pixels.
+ :param float y: Y position in pixels.
+ :param test: A callable to call for each picked marker to filter
+ picked markers. If None (default), do not filter markers.
+ """
+ if test is None:
+ def test(mark):
+ return True
+
+ markers = self._backend.pickItems(x, y)
+ legends = [m['legend'] for m in markers if m['kind'] == 'marker']
+
+ for legend in reversed(legends):
+ marker = self._getMarker(legend)
+ if marker is not None and test(marker):
+ return marker
+ return None
+
+ def _getAllMarkers(self, just_legend=False):
+ """Returns all markers' legend or objects
+
+ :param bool just_legend: True to get the legend of the markers,
+ False (the default) to get marker objects.
+ :return: list of legend of list of marker objects
+ :rtype: list of str or list of marker objects
+ """
+ return self._getItems(
+ kind='marker', just_legend=just_legend, withhidden=True)
+
+ def _getMarker(self, legend=None):
+ """Get the object describing a specific marker.
+
+ It returns None in case no matching marker is found
+
+ :param str legend: The legend of the marker to retrieve
+ :rtype: None of marker object
+ """
+ return self._getItem(kind='marker', legend=legend)
+
+ def _pickImageOrCurve(self, x, y, test=None):
+ """Pick an image or a curve at the given position.
+
+ To use for interaction implementation.
+
+ :param float x: X position in pixelsparam float y: Y position in pixels
+ :param test: A callable to call for each picked item to filter
+ picked items. If None (default), do not filter items.
+ """
+ if test is None:
+ def test(i):
+ return True
+
+ allItems = self._backend.pickItems(x, y)
+ allItems = [item for item in allItems
+ if item['kind'] in ['curve', 'image']]
+
+ for item in reversed(allItems):
+ kind, legend = item['kind'], item['legend']
+ if kind == 'curve':
+ curve = self.getCurve(legend)
+ if curve is not None and test(curve):
+ return kind, curve, item['xdata'], item['ydata']
+
+ elif kind == 'image':
+ image = self.getImage(legend)
+ if image is not None and test(image):
+ return kind, image, None
+
+ else:
+ _logger.warning('Unsupported kind: %s', kind)
+
+ return None
+
+ # User event handling #
+
+ def _isPositionInPlotArea(self, x, y):
+ """Project position in pixel to the closest point in the plot area
+
+ :param float x: X coordinate in widget coordinate (in pixel)
+ :param float y: Y coordinate in widget coordinate (in pixel)
+ :return: (x, y) in widget coord (in pixel) in the plot area
+ """
+ left, top, width, height = self.getPlotBoundsInPixels()
+ xPlot = numpy.clip(x, left, left + width)
+ yPlot = numpy.clip(y, top, top + height)
+ return xPlot, yPlot
+
+ def onMousePress(self, xPixel, yPixel, btn):
+ """Handle mouse press event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ :param str btn: Mouse button in 'left', 'middle', 'right'
+ """
+ if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel):
+ self._pressedButtons.append(btn)
+ self._eventHandler.handleEvent('press', xPixel, yPixel, btn)
+
+ def onMouseMove(self, xPixel, yPixel):
+ """Handle mouse move event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ """
+ inXPixel, inYPixel = self._isPositionInPlotArea(xPixel, yPixel)
+ isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel
+
+ if self._cursorInPlot != isCursorInPlot:
+ self._cursorInPlot = isCursorInPlot
+ self._eventHandler.handleEvent(
+ 'enter' if self._cursorInPlot else 'leave')
+
+ if isCursorInPlot:
+ # Signal mouse move event
+ dataPos = self.pixelToData(inXPixel, inYPixel)
+ assert dataPos is not None
+
+ btn = self._pressedButtons[-1] if self._pressedButtons else None
+ event = PlotEvents.prepareMouseSignal(
+ 'mouseMoved', btn, dataPos[0], dataPos[1], xPixel, yPixel)
+ self.notify(**event)
+
+ # Either button was pressed in the plot or cursor is in the plot
+ if isCursorInPlot or self._pressedButtons:
+ self._eventHandler.handleEvent('move', inXPixel, inYPixel)
+
+ def onMouseRelease(self, xPixel, yPixel, btn):
+ """Handle mouse release event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ :param str btn: Mouse button in 'left', 'middle', 'right'
+ """
+ try:
+ self._pressedButtons.remove(btn)
+ except ValueError:
+ pass
+ else:
+ xPixel, yPixel = self._isPositionInPlotArea(xPixel, yPixel)
+ self._eventHandler.handleEvent('release', xPixel, yPixel, btn)
+
+ def onMouseWheel(self, xPixel, yPixel, angleInDegrees):
+ """Handle mouse wheel event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ :param float angleInDegrees: Angle corresponding to wheel motion.
+ Positive for movement away from the user,
+ negative for movement toward the user.
+ """
+ if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel):
+ self._eventHandler.handleEvent(
+ 'wheel', xPixel, yPixel, angleInDegrees)
+
+ def onMouseLeaveWidget(self):
+ """Handle mouse leave widget event."""
+ if self._cursorInPlot:
+ self._cursorInPlot = False
+ self._eventHandler.handleEvent('leave')
+
+ # Interaction modes #
+
+ def getInteractiveMode(self):
+ """Returns the current interactive mode as a dict.
+
+ The returned dict contains at least the key 'mode'.
+ Mode can be: 'draw', 'pan', 'select', 'zoom'.
+ It can also contains extra keys (e.g., 'color') specific to a mode
+ as provided to :meth:`setInteractiveMode`.
+ """
+ return self._eventHandler.getInteractiveMode()
+
+ def setInteractiveMode(self, mode, color='black',
+ shape='polygon', label=None,
+ zoomOnWheel=True, source=None, width=None):
+ """Switch the interactive mode.
+
+ :param str mode: The name of the interactive mode.
+ In 'draw', 'pan', 'select', 'zoom'.
+ :param color: Only for 'draw' and 'zoom' modes.
+ Color to use for drawing selection area. Default black.
+ :type color: Color description: The name as a str or
+ a tuple of 4 floats.
+ :param str shape: Only for 'draw' mode. The kind of shape to draw.
+ In 'polygon', 'rectangle', 'line', 'vline', 'hline',
+ 'freeline'.
+ Default is 'polygon'.
+ :param str label: Only for 'draw' mode, sent in drawing events.
+ :param bool zoomOnWheel: Toggle zoom on wheel support
+ :param source: A user-defined object (typically the caller object)
+ that will be send in the interactiveModeChanged event,
+ to identify which object required a mode change.
+ Default: None
+ :param float width: Width of the pencil. Only for draw pencil mode.
+ """
+ self._eventHandler.setInteractiveMode(mode, color, shape, label, width)
+ self._eventHandler.zoomOnWheel = zoomOnWheel
+
+ self.notify(
+ 'interactiveModeChanged', source=source)
+
+ # Deprecated #
+
+ def isDrawModeEnabled(self):
+ """Deprecated, use :meth:`getInteractiveMode` instead.
+
+ Return True if the current interactive state is drawing."""
+ _logger.warning(
+ 'isDrawModeEnabled deprecated, use getInteractiveMode instead')
+ return self.getInteractiveMode()['mode'] == 'draw'
+
+ def setDrawModeEnabled(self, flag=True, shape='polygon', label=None,
+ color=None, **kwargs):
+ """Deprecated, use :meth:`setInteractiveMode` instead.
+
+ Set the drawing mode if flag is True and its parameters.
+
+ If flag is False, only item selection is enabled.
+
+ Warning: Zoom and drawing are not compatible and cannot be enabled
+ simultaneously.
+
+ :param bool flag: True to enable drawing and disable zoom and select.
+ :param str shape: Type of item to be drawn in:
+ hline, vline, rectangle, polygon (default)
+ :param str label: Associated text for identifying draw signals
+ :param color: The color to use to draw the selection area
+ :type color: string ("#RRGGBB") or 4 column unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ """
+ _logger.warning(
+ 'setDrawModeEnabled deprecated, use setInteractiveMode instead')
+
+ if kwargs:
+ _logger.warning('setDrawModeEnabled ignores additional parameters')
+
+ if color is None:
+ color = 'black'
+
+ if flag:
+ self.setInteractiveMode('draw', shape=shape,
+ label=label, color=color)
+ elif self.getInteractiveMode()['mode'] == 'draw':
+ self.setInteractiveMode('select')
+
+ def getDrawMode(self):
+ """Deprecated, use :meth:`getInteractiveMode` instead.
+
+ Return the draw mode parameters as a dict of None.
+
+ It returns None if the interactive mode is not a drawing mode,
+ otherwise, it returns a dict containing the drawing mode parameters
+ as provided to :meth:`setDrawModeEnabled`.
+ """
+ _logger.warning(
+ 'getDrawMode deprecated, use getInteractiveMode instead')
+ mode = self.getInteractiveMode()
+ return mode if mode['mode'] == 'draw' else None
+
+ def isZoomModeEnabled(self):
+ """Deprecated, use :meth:`getInteractiveMode` instead.
+
+ Return True if the current interactive state is zooming."""
+ _logger.warning(
+ 'isZoomModeEnabled deprecated, use getInteractiveMode instead')
+ return self.getInteractiveMode()['mode'] == 'zoom'
+
+ def setZoomModeEnabled(self, flag=True, color=None):
+ """Deprecated, use :meth:`setInteractiveMode` instead.
+
+ Set the zoom mode if flag is True, else item selection is enabled.
+
+ Warning: Zoom and drawing are not compatible and cannot be enabled
+ simultaneously
+
+ :param bool flag: If True, enable zoom and select mode.
+ :param color: The color to use to draw the selection area.
+ (Default: 'black')
+ :param color: The color to use to draw the selection area
+ :type color: string ("#RRGGBB") or 4 column unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ """
+ _logger.warning(
+ 'setZoomModeEnabled deprecated, use setInteractiveMode instead')
+ if color is None:
+ color = 'black'
+
+ if flag:
+ self.setInteractiveMode('zoom', color=color)
+ elif self.getInteractiveMode()['mode'] == 'zoom':
+ self.setInteractiveMode('select')
+
+ def insertMarker(self, *args, **kwargs):
+ """Deprecated, use :meth:`addMarker` instead."""
+ _logger.warning(
+ 'insertMarker deprecated, use addMarker instead.')
+ return self.addMarker(*args, **kwargs)
+
+ def insertXMarker(self, *args, **kwargs):
+ """Deprecated, use :meth:`addXMarker` instead."""
+ _logger.warning(
+ 'insertXMarker deprecated, use addXMarker instead.')
+ return self.addXMarker(*args, **kwargs)
+
+ def insertYMarker(self, *args, **kwargs):
+ """Deprecated, use :meth:`addYMarker` instead."""
+ _logger.warning(
+ 'insertYMarker deprecated, use addYMarker instead.')
+ return self.addYMarker(*args, **kwargs)
+
+ def isActiveCurveHandlingEnabled(self):
+ """Deprecated, use :meth:`isActiveCurveHandling` instead."""
+ _logger.warning(
+ 'isActiveCurveHandlingEnabled deprecated, '
+ 'use isActiveCurveHandling instead.')
+ return self.isActiveCurveHandling()
+
+ def enableActiveCurveHandling(self, *args, **kwargs):
+ """Deprecated, use :meth:`setActiveCurveHandling` instead."""
+ _logger.warning(
+ 'enableActiveCurveHandling deprecated, '
+ 'use setActiveCurveHandling instead.')
+ return self.setActiveCurveHandling(*args, **kwargs)
+
+ def invertYAxis(self, *args, **kwargs):
+ """Deprecated, use :meth:`setYAxisInverted` instead."""
+ _logger.warning('invertYAxis deprecated, '
+ 'use setYAxisInverted instead.')
+ return self.setYAxisInverted(*args, **kwargs)
+
+ def showGrid(self, flag=True):
+ """Deprecated, use :meth:`setGraphGrid` instead."""
+ _logger.warning("showGrid deprecated, use setGraphGrid instead")
+ if flag in (0, False):
+ flag = None
+ elif flag in (1, True):
+ flag = 'major'
+ else:
+ flag = 'both'
+ return self.setGraphGrid(flag)
+
+ def keepDataAspectRatio(self, *args, **kwargs):
+ """Deprecated, use :meth:`setKeepDataAspectRatio`."""
+ _logger.warning('keepDataAspectRatio deprecated,'
+ 'use setKeepDataAspectRatio instead')
+ return self.setKeepDataAspectRatio(*args, **kwargs)
diff --git a/silx/gui/plot/PlotActions.py b/silx/gui/plot/PlotActions.py
new file mode 100644
index 0000000..aad27d2
--- /dev/null
+++ b/silx/gui/plot/PlotActions.py
@@ -0,0 +1,1386 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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 set of QAction to use with :class:`.PlotWidget`.
+
+The following QAction are available:
+
+- :class:`ColormapAction`
+- :class:`CopyAction`
+- :class:`CrosshairAction`
+- :class:`CurveStyleAction`
+- :class:`FitAction`
+- :class:`GridAction`
+- :class:`KeepAspectRatioAction`
+- :class:`PanWithArrowKeysAction`
+- :class:`PrintAction`
+- :class:`ResetZoomAction`
+- :class:`SaveAction`
+- :class:`XAxisLogarithmicAction`
+- :class:`XAxisAutoScaleAction`
+- :class:`YAxisInvertedAction`
+- :class:`YAxisLogarithmicAction`
+- :class:`YAxisAutoScaleAction`
+- :class:`ZoomInAction`
+- :class:`ZoomOutAction`
+"""
+
+from __future__ import division
+
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "20/04/2017"
+
+
+from collections import OrderedDict
+import logging
+import sys
+import traceback
+import weakref
+
+if sys.version_info[0] == 3:
+ from io import BytesIO
+else:
+ import cStringIO as _StringIO
+ BytesIO = _StringIO.StringIO
+
+import numpy
+
+from .. import icons
+from .. import qt
+from .._utils import convertArrayToQImage
+from . import Colors, items
+from .ColormapDialog import ColormapDialog
+from ._utils import applyZoomToPlot as _applyZoomToPlot
+from silx.third_party.EdfFile import EdfFile
+from silx.third_party.TiffIO import TiffIO
+from silx.math.histogram import Histogramnd
+from silx.math.medianfilter import medfilt2d
+from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog
+
+from silx.io.utils import save1D, savespec
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PlotAction(qt.QAction):
+ """Base class for QAction that operates on a PlotWidget.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ :param icon: QIcon or str name of icon to use
+ :param str text: The name of this action to be used for menu label
+ :param str tooltip: The text of the tooltip
+ :param triggered: The callback to connect to the action's triggered
+ signal or None for no callback.
+ :param bool checkable: True for checkable action, False otherwise (default)
+ :param parent: See :class:`QAction`.
+ """
+
+ def __init__(self, plot, icon, text, tooltip=None,
+ triggered=None, checkable=False, parent=None):
+ assert plot is not None
+ self._plotRef = weakref.ref(plot)
+
+ if not isinstance(icon, qt.QIcon):
+ # Try with icon as a string and load corresponding icon
+ icon = icons.getQIcon(icon)
+
+ super(PlotAction, self).__init__(icon, text, parent)
+
+ if tooltip is not None:
+ self.setToolTip(tooltip)
+
+ self.setCheckable(checkable)
+
+ if triggered is not None:
+ self.triggered[bool].connect(triggered)
+
+ @property
+ def plot(self):
+ """The :class:`.PlotWidget` this action group is controlling."""
+ return self._plotRef()
+
+
+class ResetZoomAction(PlotAction):
+ """QAction controlling reset zoom on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ResetZoomAction, self).__init__(
+ plot, icon='zoom-original', text='Reset Zoom',
+ tooltip='Auto-scale the graph',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self._autoscaleChanged(True)
+ plot.sigSetXAxisAutoScale.connect(self._autoscaleChanged)
+ plot.sigSetYAxisAutoScale.connect(self._autoscaleChanged)
+
+ def _autoscaleChanged(self, enabled):
+ self.setEnabled(
+ self.plot.isXAxisAutoScale() or self.plot.isYAxisAutoScale())
+
+ if self.plot.isXAxisAutoScale() and self.plot.isYAxisAutoScale():
+ tooltip = 'Auto-scale the graph'
+ elif self.plot.isXAxisAutoScale(): # And not Y axis
+ tooltip = 'Auto-scale the x-axis of the graph only'
+ elif self.plot.isYAxisAutoScale(): # And not X axis
+ tooltip = 'Auto-scale the y-axis of the graph only'
+ else: # no axis in autoscale
+ tooltip = 'Auto-scale the graph'
+ self.setToolTip(tooltip)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.resetZoom()
+
+
+class ZoomInAction(PlotAction):
+ """QAction performing a zoom-in on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ZoomInAction, self).__init__(
+ plot, icon='zoom-in', text='Zoom In',
+ tooltip='Zoom in the plot',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.ZoomIn)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def _actionTriggered(self, checked=False):
+ _applyZoomToPlot(self.plot, 1.1)
+
+
+class ZoomOutAction(PlotAction):
+ """QAction performing a zoom-out on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ZoomOutAction, self).__init__(
+ plot, icon='zoom-out', text='Zoom Out',
+ tooltip='Zoom out the plot',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.ZoomOut)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def _actionTriggered(self, checked=False):
+ _applyZoomToPlot(self.plot, 1. / 1.1)
+
+
+class XAxisAutoScaleAction(PlotAction):
+ """QAction controlling X axis autoscale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(XAxisAutoScaleAction, self).__init__(
+ plot, icon='plot-xauto', text='X Autoscale',
+ tooltip='Enable x-axis auto-scale when checked.\n'
+ 'If unchecked, x-axis does not change when reseting zoom.',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.isXAxisAutoScale())
+ plot.sigSetXAxisAutoScale.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setXAxisAutoScale(checked)
+ if checked:
+ self.plot.resetZoom()
+
+
+class YAxisAutoScaleAction(PlotAction):
+ """QAction controlling Y axis autoscale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(YAxisAutoScaleAction, self).__init__(
+ plot, icon='plot-yauto', text='Y Autoscale',
+ tooltip='Enable y-axis auto-scale when checked.\n'
+ 'If unchecked, y-axis does not change when reseting zoom.',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.isXAxisAutoScale())
+ plot.sigSetYAxisAutoScale.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setYAxisAutoScale(checked)
+ if checked:
+ self.plot.resetZoom()
+
+
+class XAxisLogarithmicAction(PlotAction):
+ """QAction controlling X axis log scale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(XAxisLogarithmicAction, self).__init__(
+ plot, icon='plot-xlog', text='X Log. scale',
+ tooltip='Logarithmic x-axis when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.isXAxisLogarithmic())
+ plot.sigSetXAxisLogarithmic.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setXAxisLogarithmic(checked)
+
+
+class YAxisLogarithmicAction(PlotAction):
+ """QAction controlling Y axis log scale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(YAxisLogarithmicAction, self).__init__(
+ plot, icon='plot-ylog', text='Y Log. scale',
+ tooltip='Logarithmic y-axis when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.isYAxisLogarithmic())
+ plot.sigSetYAxisLogarithmic.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setYAxisLogarithmic(checked)
+
+
+class GridAction(PlotAction):
+ """QAction controlling grid mode on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param str gridMode: The grid mode to use in 'both', 'major'.
+ See :meth:`.PlotWidget.setGraphGrid`
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, gridMode='both', parent=None):
+ assert gridMode in ('both', 'major')
+ self._gridMode = gridMode
+
+ super(GridAction, self).__init__(
+ plot, icon='plot-grid', text='Grid',
+ tooltip='Toggle grid (on/off)',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.getGraphGrid() is not None)
+ plot.sigSetGraphGrid.connect(self._gridChanged)
+
+ def _gridChanged(self, which):
+ """Slot listening for PlotWidget grid mode change."""
+ self.setChecked(which != 'None')
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setGraphGrid(self._gridMode if checked else None)
+
+
+class CurveStyleAction(PlotAction):
+ """QAction controlling curve style on a :class:`.PlotWidget`.
+
+ It changes the default line and markers style which updates all
+ curves on the plot.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(CurveStyleAction, self).__init__(
+ plot, icon='plot-toggle-points', text='Curve style',
+ tooltip='Change curve line and markers style',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+
+ def _actionTriggered(self, checked=False):
+ currentState = (self.plot.isDefaultPlotLines(),
+ self.plot.isDefaultPlotPoints())
+
+ # line only, line and symbol, symbol only
+ states = (True, False), (True, True), (False, True)
+ newState = states[(states.index(currentState) + 1) % 3]
+
+ self.plot.setDefaultPlotLines(newState[0])
+ self.plot.setDefaultPlotPoints(newState[1])
+
+
+class ColormapAction(PlotAction):
+ """QAction opening a ColormapDialog to update the colormap.
+
+ Both the active image colormap and the default colormap are updated.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ self._dialog = None # To store an instance of ColormapDialog
+ super(ColormapAction, self).__init__(
+ plot, icon='colormap', text='Colormap',
+ tooltip="Change colormap",
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+
+ def _actionTriggered(self, checked=False):
+ """Create a cmap dialog and update active image and default cmap."""
+ # Create the dialog if not already existing
+ if self._dialog is None:
+ self._dialog = ColormapDialog()
+
+ image = self.plot.getActiveImage()
+ if not isinstance(image, items.ColormapMixIn):
+ # No active image or active image is RGBA,
+ # set dialog from default info
+ colormap = self.plot.getDefaultColormap()
+
+ self._dialog.setHistogram() # Reset histogram and range if any
+
+ else:
+ # Set dialog from active image
+ colormap = image.getColormap()
+
+ data = image.getData(copy=False)
+
+ goodData = data[numpy.isfinite(data)]
+ if goodData.size > 0:
+ dataMin = goodData.min()
+ dataMax = goodData.max()
+ else:
+ qt.QMessageBox.warning(
+ self, "No Data",
+ "Image data does not contain any real value")
+ dataMin, dataMax = 1., 10.
+
+ self._dialog.setHistogram() # Reset histogram if any
+ self._dialog.setDataRange(dataMin, dataMax)
+ # The histogram should be done in a worker thread
+ # hist, bin_edges = numpy.histogram(goodData, bins=256)
+ # self._dialog.setHistogram(hist, bin_edges)
+
+ self._dialog.setColormap(**colormap)
+
+ # Run the dialog listening to colormap change
+ self._dialog.sigColormapChanged.connect(self._colormapChanged)
+ result = self._dialog.exec_()
+ self._dialog.sigColormapChanged.disconnect(self._colormapChanged)
+
+ if not result: # Restore the previous colormap
+ self._colormapChanged(colormap)
+
+ def _colormapChanged(self, colormap):
+ # Update default colormap
+ self.plot.setDefaultColormap(colormap)
+
+ # Update active image colormap
+ activeImage = self.plot.getActiveImage()
+ if isinstance(activeImage, items.ColormapMixIn):
+ activeImage.setColormap(colormap)
+
+
+class KeepAspectRatioAction(PlotAction):
+ """QAction controlling aspect ratio on a :class:`.PlotWidget`.
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ # Uses two images for checked/unchecked states
+ self._states = {
+ False: (icons.getQIcon('shape-circle-solid'),
+ "Keep data aspect ratio"),
+ True: (icons.getQIcon('shape-ellipse-solid'),
+ "Do no keep data aspect ratio")
+ }
+
+ icon, tooltip = self._states[plot.isKeepDataAspectRatio()]
+ super(KeepAspectRatioAction, self).__init__(
+ plot,
+ icon=icon,
+ text='Toggle keep aspect ratio',
+ tooltip=tooltip,
+ triggered=self._actionTriggered,
+ checkable=False,
+ parent=parent)
+ plot.sigSetKeepDataAspectRatio.connect(
+ self._keepDataAspectRatioChanged)
+
+ def _keepDataAspectRatioChanged(self, aspectRatio):
+ """Handle Plot set keep aspect ratio signal"""
+ icon, tooltip = self._states[aspectRatio]
+ self.setIcon(icon)
+ self.setToolTip(tooltip)
+
+ def _actionTriggered(self, checked=False):
+ # This will trigger _keepDataAspectRatioChanged
+ self.plot.setKeepDataAspectRatio(not self.plot.isKeepDataAspectRatio())
+
+
+class YAxisInvertedAction(PlotAction):
+ """QAction controlling Y orientation on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ # Uses two images for checked/unchecked states
+ self._states = {
+ False: (icons.getQIcon('plot-ydown'),
+ "Orient Y axis downward"),
+ True: (icons.getQIcon('plot-yup'),
+ "Orient Y axis upward"),
+ }
+
+ icon, tooltip = self._states[plot.isYAxisInverted()]
+ super(YAxisInvertedAction, self).__init__(
+ plot,
+ icon=icon,
+ text='Invert Y Axis',
+ tooltip=tooltip,
+ triggered=self._actionTriggered,
+ checkable=False,
+ parent=parent)
+ plot.sigSetYAxisInverted.connect(self._yAxisInvertedChanged)
+
+ def _yAxisInvertedChanged(self, inverted):
+ """Handle Plot set y axis inverted signal"""
+ icon, tooltip = self._states[inverted]
+ self.setIcon(icon)
+ self.setToolTip(tooltip)
+
+ def _actionTriggered(self, checked=False):
+ # This will trigger _yAxisInvertedChanged
+ self.plot.setYAxisInverted(not self.plot.isYAxisInverted())
+
+
+class SaveAction(PlotAction):
+ """QAction for saving Plot content.
+
+ It opens a Save as... dialog.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ :param parent: See :class:`QAction`.
+ """
+ # TODO find a way to make the filter list selectable and extensible
+
+ SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)'
+
+ SNAPSHOT_FILTERS = ('Plot Snapshot as PNG (*.png)',
+ 'Plot Snapshot as JPEG (*.jpg)',
+ SNAPSHOT_FILTER_SVG)
+
+ # Dict of curve filters with CSV-like format
+ # Using ordered dict to guarantee filters order
+ # Note: '%.18e' is numpy.savetxt default format
+ CURVE_FILTERS_TXT = OrderedDict((
+ ('Curve as Raw ASCII (*.txt)',
+ {'fmt': '%.18e', 'delimiter': ' ', 'header': False}),
+ ('Curve as ";"-separated CSV (*.csv)',
+ {'fmt': '%.18e', 'delimiter': ';', 'header': True}),
+ ('Curve as ","-separated CSV (*.csv)',
+ {'fmt': '%.18e', 'delimiter': ',', 'header': True}),
+ ('Curve as tab-separated CSV (*.csv)',
+ {'fmt': '%.18e', 'delimiter': '\t', 'header': True}),
+ ('Curve as OMNIC CSV (*.csv)',
+ {'fmt': '%.7E', 'delimiter': ',', 'header': False}),
+ ('Curve as SpecFile (*.dat)',
+ {'fmt': '%.7g', 'delimiter': '', 'header': False})
+ ))
+
+ CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)'
+
+ CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY]
+
+ ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", )
+
+ IMAGE_FILTER_EDF = 'Image data as EDF (*.edf)'
+ IMAGE_FILTER_TIFF = 'Image data as TIFF (*.tif)'
+ IMAGE_FILTER_NUMPY = 'Image data as NumPy binary file (*.npy)'
+ IMAGE_FILTER_ASCII = 'Image data as ASCII (*.dat)'
+ IMAGE_FILTER_CSV_COMMA = 'Image data as ,-separated CSV (*.csv)'
+ IMAGE_FILTER_CSV_SEMICOLON = 'Image data as ;-separated CSV (*.csv)'
+ IMAGE_FILTER_CSV_TAB = 'Image data as tab-separated CSV (*.csv)'
+ IMAGE_FILTER_RGB_PNG = 'Image as PNG (*.png)'
+ IMAGE_FILTER_RGB_TIFF = 'Image as TIFF (*.tif)'
+ IMAGE_FILTERS = (IMAGE_FILTER_EDF,
+ IMAGE_FILTER_TIFF,
+ IMAGE_FILTER_NUMPY,
+ IMAGE_FILTER_ASCII,
+ IMAGE_FILTER_CSV_COMMA,
+ IMAGE_FILTER_CSV_SEMICOLON,
+ IMAGE_FILTER_CSV_TAB,
+ IMAGE_FILTER_RGB_PNG,
+ IMAGE_FILTER_RGB_TIFF)
+
+ def __init__(self, plot, parent=None):
+ super(SaveAction, self).__init__(
+ plot, icon='document-save', text='Save as...',
+ tooltip='Save curve/image/plot snapshot dialog',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.Save)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def _errorMessage(self, informativeText=''):
+ """Display an error message."""
+ # TODO issue with QMessageBox size fixed and too small
+ msg = qt.QMessageBox(self.plot)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setInformativeText(informativeText + ' ' + str(sys.exc_info()[1]))
+ msg.setDetailedText(traceback.format_exc())
+ msg.exec_()
+
+ def _saveSnapshot(self, filename, nameFilter):
+ """Save a snapshot of the :class:`PlotWindow` widget.
+
+ :param str filename: The name of the file to write
+ :param str nameFilter: The selected name filter
+ :return: False if format is not supported or save failed,
+ True otherwise.
+ """
+ if nameFilter == self.SNAPSHOT_FILTER_SVG:
+ self.plot.saveGraph(filename, fileFormat='svg')
+
+ else:
+ if hasattr(qt.QPixmap, "grabWidget"):
+ # Qt 4
+ pixmap = qt.QPixmap.grabWidget(self.plot.getWidgetHandle())
+ else:
+ # Qt 5
+ pixmap = self.plot.getWidgetHandle().grab()
+ if not pixmap.save(filename):
+ self._errorMessage()
+ return False
+ return True
+
+ def _saveCurve(self, filename, nameFilter):
+ """Save a curve from the plot.
+
+ :param str filename: The name of the file to write
+ :param str nameFilter: The selected name filter
+ :return: False if format is not supported or save failed,
+ True otherwise.
+ """
+ if nameFilter not in self.CURVE_FILTERS:
+ return False
+
+ # Check if a curve is to be saved
+ curve = self.plot.getActiveCurve()
+ # before calling _saveCurve, if there is no selected curve, we
+ # make sure there is only one curve on the graph
+ if curve is None:
+ curves = self.plot.getAllCurves()
+ if not curves:
+ self._errorMessage("No curve to be saved")
+ return False
+ curve = curves[0]
+
+ if nameFilter in self.CURVE_FILTERS_TXT:
+ filter_ = self.CURVE_FILTERS_TXT[nameFilter]
+ fmt = filter_['fmt']
+ csvdelim = filter_['delimiter']
+ autoheader = filter_['header']
+ else:
+ # .npy
+ fmt, csvdelim, autoheader = ("", "", False)
+
+ # If curve has no associated label, get the default from the plot
+ xlabel = curve.getXLabel()
+ if xlabel is None:
+ xlabel = self.plot.getGraphXLabel()
+ ylabel = curve.getYLabel()
+ if ylabel is None:
+ ylabel = self.plot.getGraphYLabel()
+
+ try:
+ save1D(filename,
+ curve.getXData(copy=False),
+ curve.getYData(copy=False),
+ xlabel, [ylabel],
+ fmt=fmt, csvdelim=csvdelim,
+ autoheader=autoheader)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+
+ return True
+
+ def _saveCurves(self, filename, nameFilter):
+ """Save all curves from the plot.
+
+ :param str filename: The name of the file to write
+ :param str nameFilter: The selected name filter
+ :return: False if format is not supported or save failed,
+ True otherwise.
+ """
+ if nameFilter not in self.ALL_CURVES_FILTERS:
+ return False
+
+ curves = self.plot.getAllCurves()
+ if not curves:
+ self._errorMessage("No curves to be saved")
+ return False
+
+ curve = curves[0]
+ scanno = 1
+ try:
+ specfile = savespec(filename,
+ curve.getXData(copy=False),
+ curve.getYData(copy=False),
+ curve.getXLabel(),
+ curve.getYLabel(),
+ fmt="%.7g", scan_number=1, mode="w",
+ write_file_header=True,
+ close_file=False)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+
+ for curve in curves[1:]:
+ try:
+ scanno += 1
+ specfile = savespec(specfile,
+ curve.getXData(copy=False),
+ curve.getYData(copy=False),
+ curve.getXLabel(),
+ curve.getYLabel(),
+ fmt="%.7g", scan_number=scanno, mode="w",
+ write_file_header=False,
+ close_file=False)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+ specfile.close()
+
+ return True
+
+ def _saveImage(self, filename, nameFilter):
+ """Save an image from the plot.
+
+ :param str filename: The name of the file to write
+ :param str nameFilter: The selected name filter
+ :return: False if format is not supported or save failed,
+ True otherwise.
+ """
+ if nameFilter not in self.IMAGE_FILTERS:
+ return False
+
+ image = self.plot.getActiveImage()
+ if image is None:
+ qt.QMessageBox.warning(
+ self.plot, "No Data", "No image to be saved")
+ return False
+
+ data = image.getData(copy=False)
+
+ # TODO Use silx.io for writing files
+ if nameFilter == self.IMAGE_FILTER_EDF:
+ edfFile = EdfFile(filename, access="w+")
+ edfFile.WriteImage({}, data, Append=0)
+ return True
+
+ elif nameFilter == self.IMAGE_FILTER_TIFF:
+ tiffFile = TiffIO(filename, mode='w')
+ tiffFile.writeImage(data, software='silx')
+ return True
+
+ elif nameFilter == self.IMAGE_FILTER_NUMPY:
+ try:
+ numpy.save(filename, data)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+ return True
+
+ elif nameFilter in (self.IMAGE_FILTER_ASCII,
+ self.IMAGE_FILTER_CSV_COMMA,
+ self.IMAGE_FILTER_CSV_SEMICOLON,
+ self.IMAGE_FILTER_CSV_TAB):
+ csvdelim, filetype = {
+ self.IMAGE_FILTER_ASCII: (' ', 'txt'),
+ self.IMAGE_FILTER_CSV_COMMA: (',', 'csv'),
+ self.IMAGE_FILTER_CSV_SEMICOLON: (';', 'csv'),
+ self.IMAGE_FILTER_CSV_TAB: ('\t', 'csv'),
+ }[nameFilter]
+
+ height, width = data.shape
+ rows, cols = numpy.mgrid[0:height, 0:width]
+ try:
+ save1D(filename, rows.ravel(), (cols.ravel(), data.ravel()),
+ filetype=filetype,
+ xlabel='row',
+ ylabels=['column', 'value'],
+ csvdelim=csvdelim,
+ autoheader=True)
+
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+ return True
+
+ elif nameFilter in (self.IMAGE_FILTER_RGB_PNG,
+ self.IMAGE_FILTER_RGB_TIFF):
+ # Get displayed image
+ rgbaImage = image.getRbgaImageData(copy=False)
+ # Convert RGB QImage
+ qimage = convertArrayToQImage(rgbaImage[:, :, :3])
+
+ if nameFilter == self.IMAGE_FILTER_RGB_PNG:
+ fileFormat = 'PNG'
+ else:
+ fileFormat = 'TIFF'
+
+ if qimage.save(filename, fileFormat):
+ return True
+ else:
+ _logger.error('Failed to save image as %s', filename)
+ qt.QMessageBox.critical(
+ self.parent(),
+ 'Save image as',
+ 'Failed to save image')
+
+ return False
+
+ def _actionTriggered(self, checked=False):
+ """Handle save action."""
+ # Set-up filters
+ filters = []
+
+ # Add image filters if there is an active image
+ if self.plot.getActiveImage() is not None:
+ filters.extend(self.IMAGE_FILTERS)
+
+ # Add curve filters if there is a curve to save
+ if (self.plot.getActiveCurve() is not None or
+ len(self.plot.getAllCurves()) == 1):
+ filters.extend(self.CURVE_FILTERS)
+ if len(self.plot.getAllCurves()) > 1:
+ filters.extend(self.ALL_CURVES_FILTERS)
+
+ filters.extend(self.SNAPSHOT_FILTERS)
+
+ # Create and run File dialog
+ dialog = qt.QFileDialog(self.plot)
+ dialog.setWindowTitle("Output File Selection")
+ dialog.setModal(1)
+ dialog.setNameFilters(filters)
+
+ dialog.setFileMode(dialog.AnyFile)
+ dialog.setAcceptMode(dialog.AcceptSave)
+
+ if not dialog.exec_():
+ return False
+
+ nameFilter = dialog.selectedNameFilter()
+ filename = dialog.selectedFiles()[0]
+ dialog.close()
+
+ # Forces the filename extension to match the chosen filter
+ extension = nameFilter.split()[-1][2:-1]
+ if (len(filename) <= len(extension) or
+ filename[-len(extension):].lower() != extension.lower()):
+ filename += extension
+
+ # Handle save
+ if nameFilter in self.SNAPSHOT_FILTERS:
+ return self._saveSnapshot(filename, nameFilter)
+ elif nameFilter in self.CURVE_FILTERS:
+ return self._saveCurve(filename, nameFilter)
+ elif nameFilter in self.ALL_CURVES_FILTERS:
+ return self._saveCurves(filename, nameFilter)
+ elif nameFilter in self.IMAGE_FILTERS:
+ return self._saveImage(filename, nameFilter)
+ else:
+ _logger.warning('Unsupported file filter: %s', nameFilter)
+ return False
+
+
+def _plotAsPNG(plot):
+ """Save a :class:`Plot` as PNG and return the payload.
+
+ :param plot: The :class:`Plot` to save
+ """
+ pngFile = BytesIO()
+ plot.saveGraph(pngFile, fileFormat='png')
+ pngFile.flush()
+ pngFile.seek(0)
+ data = pngFile.read()
+ pngFile.close()
+ return data
+
+
+class PrintAction(PlotAction):
+ """QAction for printing the plot.
+
+ It opens a Print dialog.
+
+ Current implementation print a bitmap of the plot area and not vector
+ graphics, so printing quality is not great.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ :param parent: See :class:`QAction`.
+ """
+
+ # Share QPrinter instance to propose latest used as default
+ _printer = None
+
+ def __init__(self, plot, parent=None):
+ super(PrintAction, self).__init__(
+ plot, icon='document-print', text='Print...',
+ tooltip='Open print dialog',
+ triggered=self.printPlot,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.Print)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ @property
+ def printer(self):
+ """The QPrinter instance used by the actions.
+
+ This is shared accross all instances of PrintAct
+ """
+ if self._printer is None:
+ PrintAction._printer = qt.QPrinter()
+ return self._printer
+
+ def printPlotAsWidget(self):
+ """Open the print dialog and print the plot.
+
+ Use :meth:`QWidget.render` to print the plot
+
+ :return: True if successful
+ """
+ dialog = qt.QPrintDialog(self.printer, self.plot)
+ dialog.setWindowTitle('Print Plot')
+ if not dialog.exec_():
+ return False
+
+ # Print a snapshot of the plot widget at the top of the page
+ widget = self.plot.centralWidget()
+
+ painter = qt.QPainter()
+ if not painter.begin(self.printer):
+ return False
+
+ pageRect = self.printer.pageRect()
+ xScale = pageRect.width() / widget.width()
+ yScale = pageRect.height() / widget.height()
+ scale = min(xScale, yScale)
+
+ painter.translate(pageRect.width() / 2., 0.)
+ painter.scale(scale, scale)
+ painter.translate(-widget.width() / 2., 0.)
+ widget.render(painter)
+ painter.end()
+
+ return True
+
+ def printPlot(self):
+ """Open the print dialog and print the plot.
+
+ Use :meth:`Plot.saveGraph` to print the plot.
+
+ :return: True if successful
+ """
+ # Init printer and start printer dialog
+ dialog = qt.QPrintDialog(self.printer, self.plot)
+ dialog.setWindowTitle('Print Plot')
+ if not dialog.exec_():
+ return False
+
+ # Save Plot as PNG and make a pixmap from it with default dpi
+ pngData = _plotAsPNG(self.plot)
+
+ pixmap = qt.QPixmap()
+ pixmap.loadFromData(pngData, 'png')
+
+ xScale = self.printer.pageRect().width() / pixmap.width()
+ yScale = self.printer.pageRect().height() / pixmap.height()
+ scale = min(xScale, yScale)
+
+ # Draw pixmap with painter
+ painter = qt.QPainter()
+ if not painter.begin(self.printer):
+ return False
+
+ painter.drawPixmap(0, 0,
+ pixmap.width() * scale,
+ pixmap.height() * scale,
+ pixmap)
+ painter.end()
+
+ return True
+
+
+class CopyAction(PlotAction):
+ """QAction to copy :class:`.PlotWidget` content to clipboard.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(CopyAction, self).__init__(
+ plot, icon='edit-copy', text='Copy plot',
+ tooltip='Copy a snapshot of the plot into the clipboard',
+ triggered=self.copyPlot,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.Copy)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def copyPlot(self):
+ """Copy plot content to the clipboard as a bitmap."""
+ # Save Plot as PNG and make a QImage from it with default dpi
+ pngData = _plotAsPNG(self.plot)
+ image = qt.QImage.fromData(pngData, 'png')
+ qt.QApplication.clipboard().setImage(image)
+
+
+class CrosshairAction(PlotAction):
+ """QAction toggling crosshair cursor on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param str color: Color to use to draw the crosshair
+ :param int linewidth: Width of the crosshair cursor
+ :param str linestyle: Style of line. See :meth:`.Plot.setGraphCursor`
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, color='black', linewidth=1, linestyle='-',
+ parent=None):
+ self.color = color
+ """Color used to draw the crosshair (str)."""
+
+ self.linewidth = linewidth
+ """Width of the crosshair cursor (int)."""
+
+ self.linestyle = linestyle
+ """Style of line of the cursor (str)."""
+
+ super(CrosshairAction, self).__init__(
+ plot, icon='crosshair', text='Crosshair Cursor',
+ tooltip='Enable crosshair cursor when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.getGraphCursor() is not None)
+ plot.sigSetGraphCursor.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setGraphCursor(checked,
+ color=self.color,
+ linestyle=self.linestyle,
+ linewidth=self.linewidth)
+
+
+class PanWithArrowKeysAction(PlotAction):
+ """QAction toggling pan with arrow keys on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+
+ super(PanWithArrowKeysAction, self).__init__(
+ plot, icon='arrow-keys', text='Pan with arrow keys',
+ tooltip='Enable pan with arrow keys when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.isPanWithArrowKeys())
+ plot.sigSetPanWithArrowKeys.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setPanWithArrowKeys(checked)
+
+
+def _warningMessage(informativeText='', detailedText='', parent=None):
+ """Display a popup warning message."""
+ msg = qt.QMessageBox(parent)
+ msg.setIcon(qt.QMessageBox.Warning)
+ msg.setInformativeText(informativeText)
+ msg.setDetailedText(detailedText)
+ msg.exec_()
+
+
+def _getOneCurve(plt, mode="unique"):
+ """Get a single curve from the plot.
+ By default, get the active curve if any, else if a single curve is plotted
+ get it, else return None and display a warning popup.
+
+ This behavior can be adjusted by modifying the *mode* parameter: always
+ return the active curve if any, but adjust the behavior in case no curve
+ is active.
+
+ :param plt: :class:`.PlotWidget` instance on which to operate
+ :param mode: Parameter defining the behavior when no curve is active.
+ Possible modes:
+ - "none": return None (enforce curve activation)
+ - "unique": return the unique curve or None if multiple curves
+ - "first": return first curve
+ - "last": return last curve (most recently added one)
+ :return: return value of plt.getActiveCurve(), or plt.getAllCurves()[0],
+ or plt.getAllCurves()[-1], or None
+ """
+ curve = plt.getActiveCurve()
+ if curve is not None:
+ return curve
+
+ if mode is None or mode.lower() == "none":
+ _warningMessage("You must activate a curve!",
+ parent=plt)
+ return None
+
+ curves = plt.getAllCurves()
+ if len(curves) == 0:
+ _warningMessage("No curve on this plot.",
+ parent=plt)
+ return None
+
+ if len(curves) == 1:
+ return curves[0]
+
+ if len(curves) > 1:
+ if mode == "unique":
+ _warningMessage("Multiple curves are plotted. " +
+ "Please activate the one you want to use.",
+ parent=plt)
+ return None
+ if mode.lower() == "first":
+ return curves[0]
+ if mode.lower() == "last":
+ return curves[-1]
+
+ raise ValueError("Illegal value for parameter 'mode'." +
+ " Allowed values: 'none', 'unique', 'first', 'last'.")
+
+
+class FitAction(PlotAction):
+ """QAction to open a :class:`FitWidget` and set its data to the
+ active curve if any, or to the first curve.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ super(FitAction, self).__init__(
+ plot, icon='math-fit', text='Fit curve',
+ tooltip='Open a fit dialog',
+ triggered=self._getFitWindow,
+ checkable=False, parent=parent)
+ self.fit_window = None
+
+ def _getFitWindow(self):
+ curve = _getOneCurve(self.plot)
+ if curve is None:
+ return
+ self.xlabel = self.plot.getGraphXLabel()
+ self.ylabel = self.plot.getGraphYLabel()
+ self.x = curve.getXData(copy=False)
+ self.y = curve.getYData(copy=False)
+ self.legend = curve.getLegend()
+ self.xmin, self.xmax = self.plot.getGraphXLimits()
+
+ # open a window with a FitWidget
+ if self.fit_window is None:
+ self.fit_window = qt.QMainWindow()
+ # import done here rather than at module level to avoid circular import
+ # FitWidget -> BackgroundWidget -> PlotWindow -> PlotActions -> FitWidget
+ from ..fit.FitWidget import FitWidget
+ self.fit_widget = FitWidget(parent=self.fit_window)
+ self.fit_window.setCentralWidget(
+ self.fit_widget)
+ self.fit_widget.guibuttons.DismissButton.clicked.connect(
+ self.fit_window.close)
+ self.fit_widget.sigFitWidgetSignal.connect(
+ self.handle_signal)
+ self.fit_window.show()
+ else:
+ if self.fit_window.isHidden():
+ self.fit_window.show()
+ self.fit_widget.show()
+ self.fit_window.raise_()
+
+ self.fit_widget.setData(self.x, self.y,
+ xmin=self.xmin, xmax=self.xmax)
+ self.fit_window.setWindowTitle(
+ "Fitting " + self.legend +
+ " on x range %f-%f" % (self.xmin, self.xmax))
+
+ def handle_signal(self, ddict):
+ x_fit = self.x[self.xmin <= self.x]
+ x_fit = x_fit[x_fit <= self.xmax]
+ fit_legend = "Fit <%s>" % self.legend
+ fit_curve = self.plot.getCurve(fit_legend)
+
+ if ddict["event"] == "FitFinished":
+ y_fit = self.fit_widget.fitmanager.gendata()
+ if fit_curve is None:
+ self.plot.addCurve(x_fit, y_fit,
+ fit_legend,
+ xlabel=self.xlabel, ylabel=self.ylabel,
+ resetzoom=False)
+ else:
+ fit_curve.setData(x_fit, y_fit)
+ fit_curve.setVisible(True)
+
+ if ddict["event"] in ["FitStarted", "FitFailed"]:
+ if fit_curve is not None:
+ fit_curve.setVisible(False)
+
+
+class PixelIntensitiesHistoAction(PlotAction):
+ """QAction to plot the pixels intensities diagram
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ PlotAction.__init__(self,
+ plot,
+ icon='pixel-intensities',
+ text='pixels intensity',
+ tooltip='Compute image intensity distribution',
+ triggered=self._triggered,
+ parent=parent,
+ checkable=True)
+ self._plotHistogram = None
+ self._connectedToActiveImage = False
+ self._histo = None
+
+ def _triggered(self, checked):
+ """Update the plot of the histogram visibility status
+
+ :param bool checked: status of the action button
+ """
+ if checked:
+ if not self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.connect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = True
+ self.computeIntensityDistribution()
+
+ self.getHistogramPlotWidget().show()
+
+ else:
+ if self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = False
+
+ self.getHistogramPlotWidget().hide()
+
+ def _activeImageChanged(self, previous, legend):
+ """Handle active image change: toggle enabled toolbar, update curve"""
+ if self.isChecked():
+ self.computeIntensityDistribution()
+
+ def computeIntensityDistribution(self):
+ """Get the active image and compute the image intensity distribution
+ """
+ activeImage = self.plot.getActiveImage()
+
+ if activeImage is not None:
+ image = activeImage.getData(copy=False)
+ if image.ndim == 3: # RGB(A) images
+ _logger.info('Converting current image from RGB(A) to grayscale\
+ in order to compute the intensity distribution')
+ image = (image[:, :, 0] * 0.299 +
+ image[:, :, 1] * 0.587 +
+ image[:, :, 2] * 0.114)
+
+ xmin = numpy.nanmin(image)
+ xmax = numpy.nanmax(image)
+ nbins = min(1024, int(numpy.sqrt(image.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(image.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = xmax - xmin
+
+ nbins = max(2, nbins)
+
+ data = image.ravel().astype(numpy.float32)
+ histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
+ assert len(histogram.edges) == 1
+ self._histo = histogram.histo
+ edges = histogram.edges[0]
+ plot = self.getHistogramPlotWidget()
+ plot.addHistogram(histogram=self._histo,
+ edges=edges,
+ legend='pixel intensity',
+ fill=True,
+ color='red')
+ plot.resetZoom()
+
+ def eventFilter(self, qobject, event):
+ """Observe when the close event is emitted then
+ simply uncheck the action button
+
+ :param qobject: the object observe
+ :param event: the event received by qobject
+ """
+ if event.type() == qt.QEvent.Close:
+ if self._plotHistogram is not None:
+ self._plotHistogram.hide()
+ self.setChecked(False)
+
+ return PlotAction.eventFilter(self, qobject, event)
+
+ def getHistogramPlotWidget(self):
+ """Create the plot histogram if needed, otherwise create it
+
+ :return: the PlotWidget showing the histogram of the pixel intensities
+ """
+ from silx.gui.plot.PlotWindow import Plot1D
+ if self._plotHistogram is None:
+ self._plotHistogram = Plot1D(parent=self.plot)
+ self._plotHistogram.setWindowFlags(qt.Qt.Window)
+ self._plotHistogram.setWindowTitle('Image Intensity Histogram')
+ self._plotHistogram.installEventFilter(self)
+ self._plotHistogram.setGraphXLabel("Value")
+ self._plotHistogram.setGraphYLabel("Count")
+
+ return self._plotHistogram
+
+ def getHistogram(self):
+ """Return the last computed histogram
+
+ :return: the histogram displayed in the HistogramPlotWiget
+ """
+ return self._histo
+
+
+class MedianFilterAction(PlotAction):
+ """QAction to plot the pixels intensities diagram
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ PlotAction.__init__(self,
+ plot,
+ icon='median-filter',
+ text='median filter',
+ tooltip='Apply a median filter on the image',
+ triggered=self._triggered,
+ parent=parent)
+ self._originalImage = None
+ self._legend = None
+ self._filteredImage = None
+ self._popup = MedianFilterDialog(parent=None)
+ self._popup.sigFilterOptChanged.connect(self._updateFilter)
+ self.plot.sigActiveImageChanged.connect( self._updateActiveImage)
+ self._updateActiveImage()
+
+ def _triggered(self, checked):
+ """Update the plot of the histogram visibility status
+
+ :param bool checked: status of the action button
+ """
+ self._popup.show()
+
+ def _updateActiveImage(self):
+ """Set _activeImageLegend and _originalImage from the active image"""
+ self._activeImageLegend = self.plot.getActiveImage(just_legend=True)
+ if self._activeImageLegend is None:
+ self._originalImage = None
+ self._legend = None
+ else:
+ self._originalImage = self.plot.getImage(self._activeImageLegend).getData(copy=False)
+ self._legend = self.plot.getImage(self._activeImageLegend).getLegend()
+
+ def _updateFilter(self, kernelWidth, conditional=False):
+ if self._originalImage is None:
+ return
+
+ self.plot.sigActiveImageChanged.disconnect(self._updateActiveImage)
+ filteredImage = self._computeFilteredImage(kernelWidth, conditional)
+ self.plot.addImage(data=filteredImage,
+ legend=self._legend,
+ replace=True)
+ self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
+
+ def _computeFilteredImage(self, kernelWidth, conditional):
+ raise NotImplemented('MedianFilterAction is a an abstract class')
+
+ def getFilteredImage(self):
+ """
+ :return: the image with the median filter apply on"""
+ return self._filteredImage
+
+
+class MedianFilter1DAction(MedianFilterAction):
+ """Define the MedianFilterAction for 1D
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ MedianFilterAction.__init__(self,
+ plot,
+ parent=parent)
+
+ def _computeFilteredImage(self, kernelWidth, conditional):
+ assert(self.plot is not None)
+ return medfilt2d(self._originalImage,
+ (kernelWidth, 1),
+ conditional)
+
+
+class MedianFilter2DAction(MedianFilterAction):
+ """Define the MedianFilterAction for 2D
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ MedianFilterAction.__init__(self,
+ plot,
+ parent=parent)
+
+ def _computeFilteredImage(self, kernelWidth, conditional):
+ assert(self.plot is not None)
+ return medfilt2d(self._originalImage,
+ (kernelWidth, kernelWidth),
+ conditional)
diff --git a/silx/gui/plot/PlotEvents.py b/silx/gui/plot/PlotEvents.py
new file mode 100644
index 0000000..83f253c
--- /dev/null
+++ b/silx/gui/plot/PlotEvents.py
@@ -0,0 +1,166 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2016 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.
+#
+# ###########################################################################*/
+"""Functions to prepare events to be sent to Plot callback."""
+
+__author__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/02/2016"
+
+
+import numpy as np
+
+
+def prepareDrawingSignal(event, type_, points, parameters=None):
+ """See Plot documentation for content of events"""
+ assert event in ('drawingProgress', 'drawingFinished')
+
+ if parameters is None:
+ parameters = {}
+
+ eventDict = {}
+ eventDict['event'] = event
+ eventDict['type'] = type_
+ points = np.array(points, dtype=np.float32)
+ points.shape = -1, 2
+ eventDict['points'] = points
+ eventDict['xdata'] = points[:, 0]
+ eventDict['ydata'] = points[:, 1]
+ if type_ in ('rectangle',):
+ eventDict['x'] = eventDict['xdata'].min()
+ eventDict['y'] = eventDict['ydata'].min()
+ eventDict['width'] = eventDict['xdata'].max() - eventDict['x']
+ eventDict['height'] = eventDict['ydata'].max() - eventDict['y']
+ eventDict['parameters'] = parameters.copy()
+ return eventDict
+
+
+def prepareMouseSignal(eventType, button, xData, yData, xPixel, yPixel):
+ """See Plot documentation for content of events"""
+ assert eventType in ('mouseMoved', 'mouseClicked', 'mouseDoubleClicked')
+ assert button in (None, 'left', 'middle', 'right')
+
+ return {'event': eventType,
+ 'x': xData,
+ 'y': yData,
+ 'xpixel': xPixel,
+ 'ypixel': yPixel,
+ 'button': button}
+
+
+def prepareHoverSignal(label, type_, posData, posPixel, draggable, selectable):
+ """See Plot documentation for content of events"""
+ return {'event': 'hover',
+ 'label': label,
+ 'type': type_,
+ 'x': posData[0],
+ 'y': posData[1],
+ 'xpixel': posPixel[0],
+ 'ypixel': posPixel[1],
+ 'draggable': draggable,
+ 'selectable': selectable}
+
+
+def prepareMarkerSignal(eventType, button, label, type_,
+ draggable, selectable,
+ posDataMarker,
+ posPixelCursor=None, posDataCursor=None):
+ """See Plot documentation for content of events"""
+ if eventType == 'markerClicked':
+ assert posPixelCursor is not None
+ assert posDataCursor is None
+
+ posDataCursor = list(posDataMarker)
+ if hasattr(posDataCursor[0], "__len__"):
+ posDataCursor[0] = posDataCursor[0][-1]
+ if hasattr(posDataCursor[1], "__len__"):
+ posDataCursor[1] = posDataCursor[1][-1]
+
+ elif eventType == 'markerMoving':
+ assert posPixelCursor is not None
+ assert posDataCursor is not None
+
+ elif eventType == 'markerMoved':
+ assert posPixelCursor is None
+ assert posDataCursor is None
+
+ posDataCursor = posDataMarker
+ else:
+ raise NotImplementedError("Unknown event type {0}".format(eventType))
+
+ eventDict = {'event': eventType,
+ 'button': button,
+ 'label': label,
+ 'type': type_,
+ 'x': posDataCursor[0],
+ 'y': posDataCursor[1],
+ 'xdata': posDataMarker[0],
+ 'ydata': posDataMarker[1],
+ 'draggable': draggable,
+ 'selectable': selectable}
+
+ if eventType in ('markerMoving', 'markerClicked'):
+ eventDict['xpixel'] = posPixelCursor[0]
+ eventDict['ypixel'] = posPixelCursor[1]
+
+ return eventDict
+
+
+def prepareImageSignal(button, label, type_, col, row,
+ x, y, xPixel, yPixel):
+ """See Plot documentation for content of events"""
+ return {'event': 'imageClicked',
+ 'button': button,
+ 'label': label,
+ 'type': type_,
+ 'col': col,
+ 'row': row,
+ 'x': x,
+ 'y': y,
+ 'xpixel': xPixel,
+ 'ypixel': yPixel}
+
+
+def prepareCurveSignal(button, label, type_, xData, yData,
+ x, y, xPixel, yPixel):
+ """See Plot documentation for content of events"""
+ return {'event': 'curveClicked',
+ 'button': button,
+ 'label': label,
+ 'type': type_,
+ 'xdata': xData,
+ 'ydata': yData,
+ 'x': x,
+ 'y': y,
+ 'xpixel': xPixel,
+ 'ypixel': yPixel}
+
+
+def prepareLimitsChangedSignal(sourceObj, xRange, yRange, y2Range):
+ """See Plot documentation for content of events"""
+ return {'event': 'limitsChanged',
+ 'source': id(sourceObj),
+ 'xdata': xRange,
+ 'ydata': yRange,
+ 'y2data': y2Range}
diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py
new file mode 100644
index 0000000..fbc9c1f
--- /dev/null
+++ b/silx/gui/plot/PlotInteraction.py
@@ -0,0 +1,1493 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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.
+#
+# ###########################################################################*/
+"""Implementation of the interaction for the :class:`Plot`."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "24/01/2017"
+
+
+import math
+import numpy
+import time
+import weakref
+
+from . import Colors
+from . import items
+from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN,
+ State, StateMachine)
+from .PlotEvents import (prepareCurveSignal, prepareDrawingSignal,
+ prepareHoverSignal, prepareImageSignal,
+ prepareMarkerSignal, prepareMouseSignal)
+
+from .backends.BackendBase import (CURSOR_POINTING, CURSOR_SIZE_HOR,
+ CURSOR_SIZE_VER, CURSOR_SIZE_ALL)
+
+from ._utils import (FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX,
+ applyZoomToPlot)
+
+
+# Base class ##################################################################
+
+class _PlotInteraction(object):
+ """Base class for interaction handler.
+
+ It provides a weakref to the plot and methods to set/reset overlay.
+ """
+ def __init__(self, plot):
+ """Init.
+
+ :param plot: The plot to apply modifications to.
+ """
+ self._needReplot = False
+ self._selectionAreas = set()
+ self._plot = weakref.ref(plot) # Avoid cyclic-ref
+
+ @property
+ def plot(self):
+ plot = self._plot()
+ assert plot is not None
+ return plot
+
+ def setSelectionArea(self, points, fill, color, name='', shape='polygon'):
+ """Set a polygon selection area overlaid on the plot.
+ Multiple simultaneous areas are supported through the name parameter.
+
+ :param points: The 2D coordinates of the points of the polygon
+ :type points: An iterable of (x, y) coordinates
+ :param str fill: The fill mode: 'hatch', 'solid' or 'none'
+ :param color: RGBA color to use or None to disable display
+ :type color: list or tuple of 4 float in the range [0, 1]
+ :param name: The key associated with this selection area
+ :param str shape: Shape of the area in 'polygon', 'polylines'
+ """
+ assert shape in ('polygon', 'polylines')
+
+ if color is None:
+ return
+
+ points = numpy.asarray(points)
+
+ # TODO Not very nice, but as is for now
+ legend = '__SELECTION_AREA__' + name
+
+ fill = fill != 'none' # TODO not very nice either
+
+ self.plot.addItem(points[:, 0], points[:, 1], legend=legend,
+ replace=False,
+ shape=shape, color=color, fill=fill,
+ overlay=True)
+ self._selectionAreas.add(legend)
+
+ def resetSelectionArea(self):
+ """Remove all selection areas set by setSelectionArea."""
+ for legend in self._selectionAreas:
+ self.plot.remove(legend, kind='item')
+ self._selectionAreas = set()
+
+
+# Zoom/Pan ####################################################################
+
+class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
+ """:class:`ClickOrDrag` state machine with zooming on mouse wheel.
+
+ Base class for :class:`Pan` and :class:`Zoom`
+ """
+ class ZoomIdle(ClickOrDrag.Idle):
+ def onWheel(self, x, y, angle):
+ scaleF = 1.1 if angle > 0 else 1. / 1.1
+ applyZoomToPlot(self.machine.plot, scaleF, (x, y))
+
+ def __init__(self, plot):
+ """Init.
+
+ :param plot: The plot to apply modifications to.
+ """
+ _PlotInteraction.__init__(self, plot)
+
+ states = {
+ 'idle': _ZoomOnWheel.ZoomIdle,
+ 'rightClick': ClickOrDrag.RightClick,
+ 'clickOrDrag': ClickOrDrag.ClickOrDrag,
+ 'drag': ClickOrDrag.Drag
+ }
+ StateMachine.__init__(self, states, 'idle')
+
+
+# Pan #########################################################################
+
+class Pan(_ZoomOnWheel):
+ """Pan plot content and zoom on wheel state machine."""
+
+ def _pixelToData(self, x, y):
+ xData, yData = self.plot.pixelToData(x, y)
+ _, y2Data = self.plot.pixelToData(x, y, axis='right')
+ return xData, yData, y2Data
+
+ def beginDrag(self, x, y):
+ self._previousDataPos = self._pixelToData(x, y)
+
+ def drag(self, x, y):
+ xData, yData, y2Data = self._pixelToData(x, y)
+ lastX, lastY, lastY2 = self._previousDataPos
+
+ xMin, xMax = self.plot.getGraphXLimits()
+ yMin, yMax = self.plot.getGraphYLimits(axis='left')
+ y2Min, y2Max = self.plot.getGraphYLimits(axis='right')
+
+ if self.plot.isXAxisLogarithmic():
+ try:
+ dx = math.log10(xData) - math.log10(lastX)
+ newXMin = pow(10., (math.log10(xMin) - dx))
+ newXMax = pow(10., (math.log10(xMax) - dx))
+ except (ValueError, OverflowError):
+ newXMin, newXMax = xMin, xMax
+
+ # Makes sure both values stays in positive float32 range
+ if newXMin < FLOAT32_MINPOS or newXMax > FLOAT32_SAFE_MAX:
+ newXMin, newXMax = xMin, xMax
+ else:
+ dx = xData - lastX
+ newXMin, newXMax = xMin - dx, xMax - dx
+
+ # Makes sure both values stays in float32 range
+ if newXMin < FLOAT32_SAFE_MIN or newXMax > FLOAT32_SAFE_MAX:
+ newXMin, newXMax = xMin, xMax
+
+ if self.plot.isYAxisLogarithmic():
+ try:
+ dy = math.log10(yData) - math.log10(lastY)
+ newYMin = pow(10., math.log10(yMin) - dy)
+ newYMax = pow(10., math.log10(yMax) - dy)
+
+ dy2 = math.log10(y2Data) - math.log10(lastY2)
+ newY2Min = pow(10., math.log10(y2Min) - dy2)
+ newY2Max = pow(10., math.log10(y2Max) - dy2)
+ except (ValueError, OverflowError):
+ newYMin, newYMax = yMin, yMax
+ newY2Min, newY2Max = y2Min, y2Max
+
+ # Makes sure y and y2 stays in positive float32 range
+ if (newYMin < FLOAT32_MINPOS or newYMax > FLOAT32_SAFE_MAX or
+ newY2Min < FLOAT32_MINPOS or newY2Max > FLOAT32_SAFE_MAX):
+ newYMin, newYMax = yMin, yMax
+ newY2Min, newY2Max = y2Min, y2Max
+ else:
+ dy = yData - lastY
+ dy2 = y2Data - lastY2
+ newYMin, newYMax = yMin - dy, yMax - dy
+ newY2Min, newY2Max = y2Min - dy2, y2Max - dy2
+
+ # Makes sure y and y2 stays in float32 range
+ if (newYMin < FLOAT32_SAFE_MIN or
+ newYMax > FLOAT32_SAFE_MAX or
+ newY2Min < FLOAT32_SAFE_MIN or
+ newY2Max > FLOAT32_SAFE_MAX):
+ newYMin, newYMax = yMin, yMax
+ newY2Min, newY2Max = y2Min, y2Max
+
+ self.plot.setLimits(newXMin, newXMax,
+ newYMin, newYMax,
+ newY2Min, newY2Max)
+
+ self._previousDataPos = self._pixelToData(x, y)
+
+ def endDrag(self, startPos, endPos):
+ del self._previousDataPos
+
+ def cancel(self):
+ pass
+
+
+# Zoom ########################################################################
+
+class Zoom(_ZoomOnWheel):
+ """Zoom-in/out state machine.
+
+ Zoom-in on selected area, zoom-out on right click,
+ and zoom on mouse wheel.
+ """
+ _DOUBLE_CLICK_TIMEOUT = 0.4
+
+ def __init__(self, plot, color):
+ self.color = color
+ self.zoomStack = []
+ self._lastClick = 0., None
+
+ super(Zoom, self).__init__(plot)
+
+ def _areaWithAspectRatio(self, x0, y0, x1, y1):
+ _plotLeft, _plotTop, plotW, plotH = self.plot.getPlotBoundsInPixels()
+
+ areaX0, areaY0, areaX1, areaY1 = x0, y0, x1, y1
+
+ if plotH != 0.:
+ plotRatio = plotW / float(plotH)
+ width, height = math.fabs(x1 - x0), math.fabs(y1 - y0)
+
+ if height != 0. and width != 0.:
+ if width / height > plotRatio:
+ areaHeight = width / plotRatio
+ areaX0, areaX1 = x0, x1
+ center = 0.5 * (y0 + y1)
+ areaY0 = center - numpy.sign(y1 - y0) * 0.5 * areaHeight
+ areaY1 = center + numpy.sign(y1 - y0) * 0.5 * areaHeight
+ else:
+ areaWidth = height * plotRatio
+ areaY0, areaY1 = y0, y1
+ center = 0.5 * (x0 + x1)
+ areaX0 = center - numpy.sign(x1 - x0) * 0.5 * areaWidth
+ areaX1 = center + numpy.sign(x1 - x0) * 0.5 * areaWidth
+
+ return areaX0, areaY0, areaX1, areaY1
+
+ def click(self, x, y, btn):
+ if btn == LEFT_BTN:
+ lastClickTime, lastClickPos = self._lastClick
+
+ # Signal mouse double clicked event first
+ if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT:
+ # Use position of first click
+ eventDict = prepareMouseSignal('mouseDoubleClicked', 'left',
+ *lastClickPos)
+ self.plot.notify(**eventDict)
+
+ self._lastClick = 0., None
+ else:
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ eventDict = prepareMouseSignal('mouseClicked', 'left',
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**eventDict)
+
+ self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y)
+
+ # Zoom-in centered on mouse cursor
+ # xMin, xMax = self.plot.getGraphXLimits()
+ # yMin, yMax = self.plot.getGraphYLimits()
+ # y2Min, y2Max = self.plot.getGraphYLimits(axis="right")
+ # self.zoomStack.append((xMin, xMax, yMin, yMax, y2Min, y2Max))
+ # self._zoom(x, y, 2)
+ elif btn == RIGHT_BTN:
+ try:
+ xMin, xMax, yMin, yMax, y2Min, y2Max = self.zoomStack.pop()
+ except IndexError:
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ eventDict = prepareMouseSignal('mouseClicked', 'right',
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**eventDict)
+ else:
+ self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+
+ def beginDrag(self, x, y):
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ self.x0, self.y0 = x, y
+
+ def drag(self, x1, y1):
+ if self.color is None:
+ return # Do not draw zoom area
+
+ dataPos = self.plot.pixelToData(x1, y1)
+ assert dataPos is not None
+
+ if self.plot.isKeepDataAspectRatio():
+ area = self._areaWithAspectRatio(self.x0, self.y0, x1, y1)
+ areaX0, areaY0, areaX1, areaY1 = area
+ areaPoints = ((areaX0, areaY0),
+ (areaX1, areaY0),
+ (areaX1, areaY1),
+ (areaX0, areaY1))
+ areaPoints = numpy.array([self.plot.pixelToData(
+ x, y, check=False) for (x, y) in areaPoints])
+
+ if self.color != 'video inverted':
+ areaColor = list(self.color)
+ areaColor[3] *= 0.25
+ else:
+ areaColor = [1., 1., 1., 1.]
+
+ self.setSelectionArea(areaPoints,
+ fill='none',
+ color=areaColor,
+ name="zoomedArea")
+
+ corners = ((self.x0, self.y0),
+ (self.x0, y1),
+ (x1, y1),
+ (x1, self.y0))
+ corners = numpy.array([self.plot.pixelToData(x, y, check=False)
+ for (x, y) in corners])
+
+ self.setSelectionArea(corners, fill='none', color=self.color)
+
+ def endDrag(self, startPos, endPos):
+ x0, y0 = startPos
+ x1, y1 = endPos
+
+ if x0 != x1 or y0 != y1: # Avoid empty zoom area
+ # Store current zoom state in stack
+ xMin, xMax = self.plot.getGraphXLimits()
+ yMin, yMax = self.plot.getGraphYLimits()
+ y2Min, y2Max = self.plot.getGraphYLimits(axis="right")
+ self.zoomStack.append((xMin, xMax, yMin, yMax, y2Min, y2Max))
+
+ if self.plot.isKeepDataAspectRatio():
+ x0, y0, x1, y1 = self._areaWithAspectRatio(x0, y0, x1, y1)
+
+ # Convert to data space and set limits
+ x0, y0 = self.plot.pixelToData(x0, y0, check=False)
+
+ dataPos = self.plot.pixelToData(
+ startPos[0], startPos[1], axis="right", check=False)
+ y2_0 = dataPos[1]
+
+ x1, y1 = self.plot.pixelToData(x1, y1, check=False)
+
+ dataPos = self.plot.pixelToData(
+ endPos[0], endPos[1], axis="right", check=False)
+ y2_1 = dataPos[1]
+
+ xMin, xMax = min(x0, x1), max(x0, x1)
+ yMin, yMax = min(y0, y1), max(y0, y1)
+ y2Min, y2Max = min(y2_0, y2_1), max(y2_0, y2_1)
+
+ self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+
+ self.resetSelectionArea()
+
+ def cancel(self):
+ if isinstance(self.state, self.states['drag']):
+ self.resetSelectionArea()
+
+
+# Select ######################################################################
+
+class Select(StateMachine, _PlotInteraction):
+ """Base class for drawing selection areas."""
+
+ def __init__(self, plot, parameters, states, state):
+ """Init a state machine.
+
+ :param plot: The plot to apply changes to.
+ :param dict parameters: A dict of parameters such as color.
+ :param dict states: The states of the state machine.
+ :param str state: The name of the initial state.
+ """
+ _PlotInteraction.__init__(self, plot)
+ self.parameters = parameters
+ StateMachine.__init__(self, states, state)
+
+ def onWheel(self, x, y, angle):
+ scaleF = 1.1 if angle > 0 else 1. / 1.1
+ applyZoomToPlot(self.plot, scaleF, (x, y))
+
+ @property
+ def color(self):
+ return self.parameters.get('color', None)
+
+
+class SelectPolygon(Select):
+ """Drawing selection polygon area state machine."""
+
+ DRAG_THRESHOLD_DIST = 4
+
+ class Idle(State):
+ def onPress(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('select', x, y)
+ return True
+
+ class Select(State):
+ def enterState(self, x, y):
+ dataPos = self.machine.plot.pixelToData(x, y)
+ assert dataPos is not None
+ self._firstPos = dataPos
+ self.points = [dataPos, dataPos]
+
+ self.updateFirstPoint()
+
+ def updateFirstPoint(self):
+ """Update drawing first point, using self._firstPos"""
+ x, y = self.machine.plot.dataToPixel(*self._firstPos, check=False)
+
+ offset = self.machine.DRAG_THRESHOLD_DIST
+ points = [(x - offset, y - offset),
+ (x - offset, y + offset),
+ (x + offset, y + offset),
+ (x + offset, y - offset)]
+ points = [self.machine.plot.pixelToData(xpix, ypix, check=False)
+ for xpix, ypix in points]
+ self.machine.setSelectionArea(points, fill=None,
+ color=self.machine.color,
+ name='first_point')
+
+ def updateSelectionArea(self):
+ """Update drawing selection area using self.points"""
+ self.machine.setSelectionArea(self.points,
+ fill='hatch',
+ color=self.machine.color)
+ eventDict = prepareDrawingSignal('drawingProgress',
+ 'polygon',
+ self.points,
+ self.machine.parameters)
+ self.machine.plot.notify(**eventDict)
+
+ def onWheel(self, x, y, angle):
+ self.machine.onWheel(x, y, angle)
+ self.updateFirstPoint()
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ # checking if the position is close to the first point
+ # if yes : closing the "loop"
+ firstPos = self.machine.plot.dataToPixel(*self._firstPos,
+ check=False)
+ dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y)
+
+ # Only allow to close polygon after first point
+ if (len(self.points) > 2 and
+ dx < self.machine.DRAG_THRESHOLD_DIST and
+ dy < self.machine.DRAG_THRESHOLD_DIST):
+ self.machine.resetSelectionArea()
+
+ self.points[-1] = self.points[0]
+
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'polygon',
+ self.points,
+ self.machine.parameters)
+ self.machine.plot.notify(**eventDict)
+ self.goto('idle')
+ return False
+
+ # Update polygon last point not too close to previous one
+ dataPos = self.machine.plot.pixelToData(x, y)
+ assert dataPos is not None
+ self.updateSelectionArea()
+
+ # checking that the new points isnt the same (within range)
+ # of the previous one
+ # This has to be done because sometimes the mouse release event
+ # is caught right after entering the Select state (i.e : press
+ # in Idle state, but with a slightly different position that
+ # the mouse press. So we had the two first vertices that were
+ # almost identical.
+ previousPos = self.machine.plot.dataToPixel(*self.points[-2],
+ check=False)
+ dx, dy = abs(previousPos[0] - x), abs(previousPos[1] - y)
+ if(dx >= self.machine.DRAG_THRESHOLD_DIST or
+ dy >= self.machine.DRAG_THRESHOLD_DIST):
+ self.points.append(dataPos)
+ else:
+ self.points[-1] = dataPos
+
+ return True
+
+ elif btn == RIGHT_BTN:
+ self.machine.resetSelectionArea()
+
+ firstPos = self.machine.plot.dataToPixel(*self._firstPos,
+ check=False)
+ dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y)
+
+ if (dx < self.machine.DRAG_THRESHOLD_DIST and
+ dy < self.machine.DRAG_THRESHOLD_DIST):
+ self.points[-1] = self.points[0]
+ else:
+ dataPos = self.machine.plot.pixelToData(x, y)
+ assert dataPos is not None
+ self.points[-1] = dataPos
+ if self.points[-2] == self.points[-1]:
+ self.points.pop()
+ self.points.append(self.points[0])
+
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'polygon',
+ self.points,
+ self.machine.parameters)
+ self.machine.plot.notify(**eventDict)
+ self.goto('idle')
+ return False
+
+ return False
+
+ def onMove(self, x, y):
+ firstPos = self.machine.plot.dataToPixel(*self._firstPos,
+ check=False)
+ dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y)
+ if (dx < self.machine.DRAG_THRESHOLD_DIST and
+ dy < self.machine.DRAG_THRESHOLD_DIST):
+ x, y = firstPos # Snap to first point
+
+ dataPos = self.machine.plot.pixelToData(x, y)
+ assert dataPos is not None
+ self.points[-1] = dataPos
+ self.updateSelectionArea()
+
+ def __init__(self, plot, parameters):
+ states = {
+ 'idle': SelectPolygon.Idle,
+ 'select': SelectPolygon.Select
+ }
+ super(SelectPolygon, self).__init__(plot, parameters,
+ states, 'idle')
+
+ def cancel(self):
+ if isinstance(self.state, self.states['select']):
+ self.resetSelectionArea()
+
+
+class Select2Points(Select):
+ """Base class for drawing selection based on 2 input points."""
+ class Idle(State):
+ def onPress(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('start', x, y)
+ return True
+
+ class Start(State):
+ def enterState(self, x, y):
+ self.machine.beginSelect(x, y)
+
+ def onMove(self, x, y):
+ self.goto('select', x, y)
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('select', x, y)
+ return True
+
+ class Select(State):
+ def enterState(self, x, y):
+ self.onMove(x, y)
+
+ def onMove(self, x, y):
+ self.machine.select(x, y)
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.machine.endSelect(x, y)
+ self.goto('idle')
+
+ def __init__(self, plot, parameters):
+ states = {
+ 'idle': Select2Points.Idle,
+ 'start': Select2Points.Start,
+ 'select': Select2Points.Select
+ }
+ super(Select2Points, self).__init__(plot, parameters,
+ states, 'idle')
+
+ def beginSelect(self, x, y):
+ pass
+
+ def select(self, x, y):
+ pass
+
+ def endSelect(self, x, y):
+ pass
+
+ def cancelSelect(self):
+ pass
+
+ def cancel(self):
+ if isinstance(self.state, self.states['select']):
+ self.cancelSelect()
+
+
+class SelectRectangle(Select2Points):
+ """Drawing rectangle selection area state machine."""
+ def beginSelect(self, x, y):
+ self.startPt = self.plot.pixelToData(x, y)
+ assert self.startPt is not None
+
+ def select(self, x, y):
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+
+ self.setSelectionArea((self.startPt,
+ (self.startPt[0], dataPos[1]),
+ dataPos,
+ (dataPos[0], self.startPt[1])),
+ fill='hatch',
+ color=self.color)
+
+ eventDict = prepareDrawingSignal('drawingProgress',
+ 'rectangle',
+ (self.startPt, dataPos),
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def endSelect(self, x, y):
+ self.resetSelectionArea()
+
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'rectangle',
+ (self.startPt, dataPos),
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def cancelSelect(self):
+ self.resetSelectionArea()
+
+
+class SelectLine(Select2Points):
+ """Drawing line selection area state machine."""
+ def beginSelect(self, x, y):
+ self.startPt = self.plot.pixelToData(x, y)
+ assert self.startPt is not None
+
+ def select(self, x, y):
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+
+ self.setSelectionArea((self.startPt, dataPos),
+ fill='hatch',
+ color=self.color)
+
+ eventDict = prepareDrawingSignal('drawingProgress',
+ 'line',
+ (self.startPt, dataPos),
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def endSelect(self, x, y):
+ self.resetSelectionArea()
+
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'line',
+ (self.startPt, dataPos),
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def cancelSelect(self):
+ self.resetSelectionArea()
+
+
+class Select1Point(Select):
+ """Base class for drawing selection area based on one input point."""
+ class Idle(State):
+ def onPress(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('select', x, y)
+ return True
+
+ class Select(State):
+ def enterState(self, x, y):
+ self.onMove(x, y)
+
+ def onMove(self, x, y):
+ self.machine.select(x, y)
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.machine.endSelect(x, y)
+ self.goto('idle')
+
+ def onWheel(self, x, y, angle):
+ self.machine.onWheel(x, y, angle) # Call select default wheel
+ self.machine.select(x, y)
+
+ def __init__(self, plot, parameters):
+ states = {
+ 'idle': Select1Point.Idle,
+ 'select': Select1Point.Select
+ }
+ super(Select1Point, self).__init__(plot, parameters, states, 'idle')
+
+ def select(self, x, y):
+ pass
+
+ def endSelect(self, x, y):
+ pass
+
+ def cancelSelect(self):
+ pass
+
+ def cancel(self):
+ if isinstance(self.state, self.states['select']):
+ self.cancelSelect()
+
+
+class SelectHLine(Select1Point):
+ """Drawing a horizontal line selection area state machine."""
+ def _hLine(self, y):
+ """Return points in data coords of the segment visible in the plot.
+
+ Supports non-orthogonal axes.
+ """
+ left, _top, width, _height = self.plot.getPlotBoundsInPixels()
+
+ dataPos1 = self.plot.pixelToData(left, y, check=False)
+ dataPos2 = self.plot.pixelToData(left + width, y, check=False)
+ return dataPos1, dataPos2
+
+ def select(self, x, y):
+ points = self._hLine(y)
+ self.setSelectionArea(points, fill='hatch', color=self.color)
+
+ eventDict = prepareDrawingSignal('drawingProgress',
+ 'hline',
+ points,
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def endSelect(self, x, y):
+ self.resetSelectionArea()
+
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'hline',
+ self._hLine(y),
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def cancelSelect(self):
+ self.resetSelectionArea()
+
+
+class SelectVLine(Select1Point):
+ """Drawing a vertical line selection area state machine."""
+ def _vLine(self, x):
+ """Return points in data coords of the segment visible in the plot.
+
+ Supports non-orthogonal axes.
+ """
+ _left, top, _width, height = self.plot.getPlotBoundsInPixels()
+
+ dataPos1 = self.plot.pixelToData(x, top, check=False)
+ dataPos2 = self.plot.pixelToData(x, top + height, check=False)
+ return dataPos1, dataPos2
+
+ def select(self, x, y):
+ points = self._vLine(x)
+ self.setSelectionArea(points, fill='hatch', color=self.color)
+
+ eventDict = prepareDrawingSignal('drawingProgress',
+ 'vline',
+ points,
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def endSelect(self, x, y):
+ self.resetSelectionArea()
+
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'vline',
+ self._vLine(x),
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def cancelSelect(self):
+ self.resetSelectionArea()
+
+
+class DrawFreeHand(Select):
+ """Interaction for drawing pencil. It display the preview of the pencil
+ before pressing the mouse.
+ """
+
+ class Idle(State):
+ def onPress(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self.goto('select', x, y)
+ return True
+
+ def onMove(self, x, y):
+ self.machine.updatePencilShape(x, y)
+
+ def onLeave(self):
+ self.machine.cancel()
+
+ class Select(State):
+ def enterState(self, x, y):
+ self.__isOut = False
+ self.machine.setFirstPoint(x, y)
+
+ def onMove(self, x, y):
+ self.machine.updatePencilShape(x, y)
+ self.machine.select(x, y)
+
+ def onRelease(self, x, y, btn):
+ if btn == LEFT_BTN:
+ if self.__isOut:
+ self.machine.resetSelectionArea()
+ self.machine.endSelect(x, y)
+ self.goto('idle')
+
+ def onEnter(self):
+ self.__isOut = False
+
+ def onLeave(self):
+ self.__isOut = True
+
+ def __init__(self, plot, parameters):
+ # Circle used for pencil preview
+ angle = numpy.arange(13.) * numpy.pi * 2.0 / 13.
+ size = parameters.get('width', 1.) * 0.5
+ self._circle = size * numpy.array((numpy.cos(angle),
+ numpy.sin(angle))).T
+
+ states = {
+ 'idle': DrawFreeHand.Idle,
+ 'select': DrawFreeHand.Select
+ }
+ super(DrawFreeHand, self).__init__(plot, parameters, states, 'idle')
+
+ @property
+ def width(self):
+ return self.parameters.get('width', None)
+
+ def setFirstPoint(self, x, y):
+ self._points = []
+ self.select(x, y)
+
+ def updatePencilShape(self, x, y):
+ center = self.plot.pixelToData(x, y, check=False)
+ assert center is not None
+
+ polygon = center + self._circle
+
+ self.setSelectionArea(polygon, fill='none', color=self.color)
+
+ def select(self, x, y):
+ pos = self.plot.pixelToData(x, y, check=False)
+ if len(self._points) > 0:
+ if self._points[-1] == pos:
+ # Skip same points
+ return
+ self._points.append(pos)
+ eventDict = prepareDrawingSignal('drawingProgress',
+ 'polylines',
+ self._points,
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ def endSelect(self, x, y):
+ pos = self.plot.pixelToData(x, y, check=False)
+ if len(self._points) > 0:
+ if self._points[-1] != pos:
+ # Append if different
+ self._points.append(pos)
+
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'polylines',
+ self._points,
+ self.parameters)
+ self.plot.notify(**eventDict)
+ self._points = None
+
+ def cancelSelect(self):
+ self.resetSelectionArea()
+
+ def cancel(self):
+ self.resetSelectionArea()
+
+
+class SelectFreeLine(ClickOrDrag, _PlotInteraction):
+ """Base class for drawing free lines with tools such as pencil."""
+
+ def __init__(self, plot, parameters):
+ """Init a state machine.
+
+ :param plot: The plot to apply changes to.
+ :param dict parameters: A dict of parameters such as color.
+ """
+ # self.DRAG_THRESHOLD_SQUARE_DIST = 1 # Disable first move threshold
+ self._points = []
+ ClickOrDrag.__init__(self)
+ _PlotInteraction.__init__(self, plot)
+ self.parameters = parameters
+
+ def onWheel(self, x, y, angle):
+ scaleF = 1.1 if angle > 0 else 1. / 1.1
+ applyZoomToPlot(self.plot, scaleF, (x, y))
+
+ @property
+ def color(self):
+ return self.parameters.get('color', None)
+
+ def click(self, x, y, btn):
+ if btn == LEFT_BTN:
+ self._processEvent(x, y, isLast=True)
+
+ def beginDrag(self, x, y):
+ self._processEvent(x, y, isLast=False)
+
+ def drag(self, x, y):
+ self._processEvent(x, y, isLast=False)
+
+ def endDrag(self, startPos, endPos):
+ x, y = endPos
+ self._processEvent(x, y, isLast=True)
+
+ def cancel(self):
+ self.resetSelectionArea()
+ self._points = []
+
+ def _processEvent(self, x, y, isLast):
+ dataPos = self.plot.pixelToData(x, y, check=False)
+ isNewPoint = not self._points or dataPos != self._points[-1]
+
+ if isNewPoint:
+ self._points.append(dataPos)
+
+ if isNewPoint or isLast:
+ eventDict = prepareDrawingSignal(
+ 'drawingFinished' if isLast else 'drawingProgress',
+ 'polylines',
+ self._points,
+ self.parameters)
+ self.plot.notify(**eventDict)
+
+ if not isLast:
+ self.setSelectionArea(self._points, fill='none', color=self.color,
+ shape='polylines')
+ else:
+ self.cancel()
+
+
+# ItemInteraction #############################################################
+
+class ItemsInteraction(ClickOrDrag, _PlotInteraction):
+ """Interaction with items (markers, curves and images).
+
+ This class provides selection and dragging of plot primitives
+ that support those interaction.
+ It is also meant to be combined with the zoom interaction.
+ """
+
+ class Idle(ClickOrDrag.Idle):
+ def __init__(self, *args, **kw):
+ super(ItemsInteraction.Idle, self).__init__(*args, **kw)
+ self._hoverMarker = None
+
+ def onWheel(self, x, y, angle):
+ scaleF = 1.1 if angle > 0 else 1. / 1.1
+ applyZoomToPlot(self.machine.plot, scaleF, (x, y))
+
+ def onMove(self, x, y):
+ marker = self.machine.plot._pickMarker(x, y)
+ if marker is not None:
+ dataPos = self.machine.plot.pixelToData(x, y)
+ assert dataPos is not None
+ eventDict = prepareHoverSignal(
+ marker.getLegend(), 'marker',
+ dataPos, (x, y),
+ marker.isDraggable(),
+ marker.isSelectable())
+ self.machine.plot.notify(**eventDict)
+
+ if marker != self._hoverMarker:
+ self._hoverMarker = marker
+
+ if marker is None:
+ self.machine.plot.setGraphCursorShape()
+
+ elif marker.isDraggable():
+ if isinstance(marker, items.YMarker):
+ self.machine.plot.setGraphCursorShape(CURSOR_SIZE_VER)
+ elif isinstance(marker, items.XMarker):
+ self.machine.plot.setGraphCursorShape(CURSOR_SIZE_HOR)
+ else:
+ self.machine.plot.setGraphCursorShape(CURSOR_SIZE_ALL)
+
+ elif marker.isSelectable():
+ self.machine.plot.setGraphCursorShape(CURSOR_POINTING)
+
+ return True
+
+ def __init__(self, plot):
+ _PlotInteraction.__init__(self, plot)
+
+ states = {
+ 'idle': ItemsInteraction.Idle,
+ 'rightClick': ClickOrDrag.RightClick,
+ 'clickOrDrag': ClickOrDrag.ClickOrDrag,
+ 'drag': ClickOrDrag.Drag
+ }
+ StateMachine.__init__(self, states, 'idle')
+
+ def click(self, x, y, btn):
+ """Handle mouse click
+
+ :param x: X position of the mouse in pixels
+ :param y: Y position of the mouse in pixels
+ :param btn: Pressed button id
+ :return: True if click is catched by an item, False otherwise
+ """
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ eventDict = prepareMouseSignal('mouseClicked', btn,
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**eventDict)
+
+ eventDict = self._handleClick(x, y, btn)
+ if eventDict is not None:
+ self.plot.notify(**eventDict)
+
+ def _handleClick(self, x, y, btn):
+ """Perform picking and prepare event if click is handled here
+
+ :param x: X position of the mouse in pixels
+ :param y: Y position of the mouse in pixels
+ :param btn: Pressed button id
+ :return: event description to send of None if not handling event.
+ :rtype: dict or None
+ """
+
+ if btn == LEFT_BTN:
+ marker = self.plot._pickMarker(
+ x, y, lambda m: m.isSelectable())
+ if marker is not None:
+ xData, yData = marker.getPosition()
+ if xData is None:
+ xData = [0, 1]
+ if yData is None:
+ yData = [0, 1]
+
+ eventDict = prepareMarkerSignal('markerClicked',
+ 'left',
+ marker.getLegend(),
+ 'marker',
+ marker.isDraggable(),
+ marker.isSelectable(),
+ (xData, yData),
+ (x, y), None)
+ return eventDict
+
+ else:
+ picked = self.plot._pickImageOrCurve(
+ x, y, lambda item: item.isSelectable())
+
+ if picked is None:
+ pass
+
+ elif picked[0] == 'curve':
+ curve = picked[1]
+
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+
+ eventDict = prepareCurveSignal('left',
+ curve.getLegend(),
+ 'curve',
+ picked[2], picked[3],
+ dataPos[0], dataPos[1],
+ x, y)
+ return eventDict
+
+ elif picked[0] == 'image':
+ image = picked[1]
+
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+
+ # Get corresponding coordinate in image
+ origin = image.getOrigin()
+ scale = image.getScale()
+ column = int((dataPos[0] - origin[0]) / float(scale[0]))
+ row = int((dataPos[1] - origin[1]) / float(scale[1]))
+
+ eventDict = prepareImageSignal('left',
+ image.getLegend(),
+ 'image',
+ column, row,
+ dataPos[0], dataPos[1],
+ x, y)
+ return eventDict
+
+ return None
+
+ def _signalMarkerMovingEvent(self, eventType, marker, x, y):
+ assert marker is not None
+
+ xData, yData = marker.getPosition()
+ if xData is None:
+ xData = [0, 1]
+ if yData is None:
+ yData = [0, 1]
+
+ posDataCursor = self.plot.pixelToData(x, y)
+ assert posDataCursor is not None
+
+ eventDict = prepareMarkerSignal(eventType,
+ 'left',
+ marker.getLegend(),
+ 'marker',
+ marker.isDraggable(),
+ marker.isSelectable(),
+ (xData, yData),
+ (x, y),
+ posDataCursor)
+ self.plot.notify(**eventDict)
+
+ def beginDrag(self, x, y):
+ """Handle begining of drag interaction
+
+ :param x: X position of the mouse in pixels
+ :param y: Y position of the mouse in pixels
+ :return: True if drag is catched by an item, False otherwise
+ """
+ self._lastPos = self.plot.pixelToData(x, y)
+ assert self._lastPos is not None
+
+ self.imageLegend = None
+ self.markerLegend = None
+ marker = self.plot._pickMarker(
+ x, y, lambda m: m.isDraggable())
+
+ if marker is not None:
+ self.markerLegend = marker.getLegend()
+ self._signalMarkerMovingEvent('markerMoving', marker, x, y)
+ else:
+ picked = self.plot._pickImageOrCurve(
+ x,
+ y,
+ lambda item:
+ hasattr(item, 'isDraggable') and item.isDraggable())
+ if picked is None:
+ self.imageLegend = None
+ self.plot.setGraphCursorShape()
+ return False
+ else:
+ assert picked[0] == 'image' # For now only drag images
+ self.imageLegend = picked[1].getLegend()
+ return True
+
+ def drag(self, x, y):
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ xData, yData = dataPos
+
+ if self.markerLegend is not None:
+ marker = self.plot._getMarker(self.markerLegend)
+ if marker is not None:
+ marker.setPosition(xData, yData)
+
+ self._signalMarkerMovingEvent(
+ 'markerMoving', marker, x, y)
+
+ if self.imageLegend is not None:
+ image = self.plot.getImage(self.imageLegend)
+ origin = image.getOrigin()
+ xImage = origin[0] + xData - self._lastPos[0]
+ yImage = origin[1] + yData - self._lastPos[1]
+ image.setOrigin((xImage, yImage))
+
+ self._lastPos = xData, yData
+
+ def endDrag(self, startPos, endPos):
+ if self.markerLegend is not None:
+ marker = self.plot._getMarker(self.markerLegend)
+ posData = list(marker.getPosition())
+ if posData[0] is None:
+ posData[0] = [0, 1]
+ if posData[1] is None:
+ posData[1] = [0, 1]
+
+ eventDict = prepareMarkerSignal(
+ 'markerMoved',
+ 'left',
+ marker.getLegend(),
+ 'marker',
+ marker.isDraggable(),
+ marker.isSelectable(),
+ posData)
+ self.plot.notify(**eventDict)
+
+ self.plot.setGraphCursorShape()
+
+ del self.markerLegend
+ del self.imageLegend
+ del self._lastPos
+
+ def cancel(self):
+ self.plot.setGraphCursorShape()
+
+
+# FocusManager ################################################################
+
+class FocusManager(StateMachine):
+ """Manages focus across multiple event handlers
+
+ On press an event handler can acquire focus.
+ By default it looses focus when all buttons are released.
+ """
+ class Idle(State):
+ def onPress(self, x, y, btn):
+ for eventHandler in self.machine.eventHandlers:
+ requestFocus = eventHandler.handleEvent('press', x, y, btn)
+ if requestFocus:
+ self.goto('focus', eventHandler, btn)
+ break
+
+ def _processEvent(self, *args):
+ for eventHandler in self.machine.eventHandlers:
+ consumeEvent = eventHandler.handleEvent(*args)
+ if consumeEvent:
+ break
+
+ def onMove(self, x, y):
+ self._processEvent('move', x, y)
+
+ def onRelease(self, x, y, btn):
+ self._processEvent('release', x, y, btn)
+
+ def onWheel(self, x, y, angle):
+ self._processEvent('wheel', x, y, angle)
+
+ class Focus(State):
+ def enterState(self, eventHandler, btn):
+ self.eventHandler = eventHandler
+ self.focusBtns = {btn}
+
+ def onPress(self, x, y, btn):
+ self.focusBtns.add(btn)
+ self.eventHandler.handleEvent('press', x, y, btn)
+
+ def onMove(self, x, y):
+ self.eventHandler.handleEvent('move', x, y)
+
+ def onRelease(self, x, y, btn):
+ self.focusBtns.discard(btn)
+ requestFocus = self.eventHandler.handleEvent('release', x, y, btn)
+ if len(self.focusBtns) == 0 and not requestFocus:
+ self.goto('idle')
+
+ def onWheel(self, x, y, angleInDegrees):
+ self.eventHandler.handleEvent('wheel', x, y, angleInDegrees)
+
+ def __init__(self, eventHandlers=()):
+ self.eventHandlers = list(eventHandlers)
+
+ states = {
+ 'idle': FocusManager.Idle,
+ 'focus': FocusManager.Focus
+ }
+ super(FocusManager, self).__init__(states, 'idle')
+
+ def cancel(self):
+ for handler in self.eventHandlers:
+ handler.cancel()
+
+
+class ZoomAndSelect(ItemsInteraction):
+ """Combine Zoom and ItemInteraction state machine.
+
+ :param plot: The Plot to which this interaction is attached
+ :param color: The color to use for the zoom area bounding box
+ """
+
+ def __init__(self, plot, color):
+ super(ZoomAndSelect, self).__init__(plot)
+ self._zoom = Zoom(plot, color)
+ self._doZoom = False
+
+ @property
+ def color(self):
+ """Color of the zoom area"""
+ return self._zoom.color
+
+ def click(self, x, y, btn):
+ """Handle mouse click
+
+ :param x: X position of the mouse in pixels
+ :param y: Y position of the mouse in pixels
+ :param btn: Pressed button id
+ :return: True if click is catched by an item, False otherwise
+ """
+ eventDict = self._handleClick(x, y, btn)
+
+ if eventDict is not None:
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ clickedEventDict = prepareMouseSignal('mouseClicked', btn,
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**clickedEventDict)
+
+ self.plot.notify(**eventDict)
+
+ else:
+ self._zoom.click(x, y, btn)
+
+ def beginDrag(self, x, y):
+ """Handle start drag and switching between zoom and item drag.
+
+ :param x: X position in pixels
+ :param y: Y position in pixels
+ """
+ self._doZoom = not super(ZoomAndSelect, self).beginDrag(x, y)
+ if self._doZoom:
+ self._zoom.beginDrag(x, y)
+
+ def drag(self, x, y):
+ """Handle drag, eventually forwarding to zoom.
+
+ :param x: X position in pixels
+ :param y: Y position in pixels
+ """
+ if self._doZoom:
+ return self._zoom.drag(x, y)
+ else:
+ return super(ZoomAndSelect, self).drag(x, y)
+
+ def endDrag(self, startPos, endPos):
+ """Handle end of drag, eventually forwarding to zoom.
+
+ :param startPos: (x, y) position at the beginning of the drag
+ :param endPos: (x, y) position at the end of the drag
+ """
+ if self._doZoom:
+ return self._zoom.endDrag(startPos, endPos)
+ else:
+ return super(ZoomAndSelect, self).endDrag(startPos, endPos)
+
+
+# Interaction mode control ####################################################
+
+class PlotInteraction(object):
+ """Proxy to currently use state machine for interaction.
+
+ This allows to switch interactive mode.
+
+ :param plot: The :class:`Plot` to apply interaction to
+ """
+
+ _DRAW_MODES = {
+ 'polygon': SelectPolygon,
+ 'rectangle': SelectRectangle,
+ 'line': SelectLine,
+ 'vline': SelectVLine,
+ 'hline': SelectHLine,
+ 'polylines': SelectFreeLine,
+ 'pencil': DrawFreeHand,
+ }
+
+ def __init__(self, plot):
+ self._plot = weakref.ref(plot) # Avoid cyclic-ref
+
+ self.zoomOnWheel = True
+ """True to enable zoom on wheel, False otherwise."""
+
+ # Default event handler
+ self._eventHandler = ItemsInteraction(plot)
+
+ def getInteractiveMode(self):
+ """Returns the current interactive mode as a dict.
+
+ The returned dict contains at least the key 'mode'.
+ Mode can be: 'draw', 'pan', 'select', 'zoom'.
+ It can also contains extra keys (e.g., 'color') specific to a mode
+ as provided to :meth:`setInteractiveMode`.
+ """
+ if isinstance(self._eventHandler, ZoomAndSelect):
+ return {'mode': 'zoom', 'color': self._eventHandler.color}
+
+ elif isinstance(self._eventHandler, Select):
+ result = self._eventHandler.parameters.copy()
+ result['mode'] = 'draw'
+ return result
+
+ elif isinstance(self._eventHandler, Pan):
+ return {'mode': 'pan'}
+
+ else:
+ return {'mode': 'select'}
+
+ def setInteractiveMode(self, mode, color='black',
+ shape='polygon', label=None, width=None):
+ """Switch the interactive mode.
+
+ :param str mode: The name of the interactive mode.
+ In 'draw', 'pan', 'select', 'zoom'.
+ :param color: Only for 'draw' and 'zoom' modes.
+ Color to use for drawing selection area. Default black.
+ If None, selection area is not drawn.
+ :type color: Color description: The name as a str or
+ a tuple of 4 floats or None.
+ :param str shape: Only for 'draw' mode. The kind of shape to draw.
+ In 'polygon', 'rectangle', 'line', 'vline', 'hline',
+ 'polylines'.
+ Default is 'polygon'.
+ :param str label: Only for 'draw' mode.
+ :param float width: Width of the pencil. Only for draw pencil mode.
+ """
+ assert mode in ('draw', 'pan', 'select', 'zoom')
+
+ plot = self._plot()
+ assert plot is not None
+
+ if color not in (None, 'video inverted'):
+ color = Colors.rgba(color)
+
+ if mode == 'draw':
+ assert shape in self._DRAW_MODES
+ eventHandlerClass = self._DRAW_MODES[shape]
+ parameters = {
+ 'shape': shape,
+ 'label': label,
+ 'color': color,
+ 'width': width,
+ }
+
+ self._eventHandler.cancel()
+ self._eventHandler = eventHandlerClass(plot, parameters)
+
+ elif mode == 'pan':
+ # Ignores color, shape and label
+ self._eventHandler.cancel()
+ self._eventHandler = Pan(plot)
+
+ elif mode == 'zoom':
+ # Ignores shape and label
+ self._eventHandler.cancel()
+ self._eventHandler = ZoomAndSelect(plot, color)
+
+ else: # Default mode: interaction with plot objects
+ # Ignores color, shape and label
+ self._eventHandler.cancel()
+ self._eventHandler = ItemsInteraction(plot)
+
+ def handleEvent(self, event, *args, **kwargs):
+ """Forward event to current interactive mode state machine."""
+ if not self.zoomOnWheel and event == 'wheel':
+ return # Discard wheel events
+ self._eventHandler.handleEvent(event, *args, **kwargs)
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
new file mode 100644
index 0000000..8042391
--- /dev/null
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -0,0 +1,280 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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 set of QToolButton to use with :class:`.PlotWidget`.
+
+The following QToolButton are available:
+
+- :class:`AspectToolButton`
+- :class:`YAxisOriginToolButton`
+- :class:`ProfileToolButton`
+
+"""
+
+__authors__ = ["V. Valls", "H. Payno"]
+__license__ = "MIT"
+__date__ = "26/01/2017"
+
+
+import logging
+from .. import icons
+from .. import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PlotToolButton(qt.QToolButton):
+ """A QToolButton connected to a :class:`.PlotWidget`.
+ """
+
+ def __init__(self, parent=None, plot=None):
+ super(PlotToolButton, self).__init__(parent)
+ self._plot = None
+ if plot is not None:
+ self.setPlot(plot)
+
+ def plot(self):
+ """
+ Returns the plot connected to the widget.
+ """
+ return self._plot
+
+ def setPlot(self, plot):
+ """
+ Set the plot connected to the widget
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ """
+ if self._plot is plot:
+ return
+ if self._plot is not None:
+ self._disconnectPlot(self._plot)
+ self._plot = plot
+ if self._plot is not None:
+ self._connectPlot(self._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
+
+
+class AspectToolButton(PlotToolButton):
+
+ STATE = None
+ """Lazy loaded states used to feed AspectToolButton"""
+
+ def __init__(self, parent=None, plot=None):
+ if self.STATE is None:
+ self.STATE = {}
+ # dont keep ratio
+ self.STATE[False, "icon"] = icons.getQIcon('shape-ellipse-solid')
+ self.STATE[False, "state"] = "Aspect ratio is not kept"
+ self.STATE[False, "action"] = "Do no keep data aspect ratio"
+ # keep ratio
+ self.STATE[True, "icon"] = icons.getQIcon('shape-circle-solid')
+ self.STATE[True, "state"] = "Aspect ratio is kept"
+ self.STATE[True, "action"] = "Keep data aspect ratio"
+
+ super(AspectToolButton, self).__init__(parent=parent, plot=plot)
+
+ keepAction = self._createAction(True)
+ keepAction.triggered.connect(self.keepDataAspectRatio)
+ keepAction.setIconVisibleInMenu(True)
+
+ dontKeepAction = self._createAction(False)
+ dontKeepAction.triggered.connect(self.dontKeepDataAspectRatio)
+ dontKeepAction.setIconVisibleInMenu(True)
+
+ menu = qt.QMenu(self)
+ menu.addAction(keepAction)
+ menu.addAction(dontKeepAction)
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+
+ def _createAction(self, keepAspectRatio):
+ icon = self.STATE[keepAspectRatio, "icon"]
+ text = self.STATE[keepAspectRatio, "action"]
+ return qt.QAction(icon, text, self)
+
+ def _connectPlot(self, plot):
+ plot.sigSetKeepDataAspectRatio.connect(self._keepDataAspectRatioChanged)
+ self._keepDataAspectRatioChanged(plot.isKeepDataAspectRatio())
+
+ def _disconnectPlot(self, plot):
+ plot.sigSetKeepDataAspectRatio.disconnect(self._keepDataAspectRatioChanged)
+
+ def keepDataAspectRatio(self):
+ """Configure the plot to keep the aspect ratio"""
+ plot = self.plot()
+ if plot is not None:
+ # This will trigger _keepDataAspectRatioChanged
+ plot.setKeepDataAspectRatio(True)
+
+ def dontKeepDataAspectRatio(self):
+ """Configure the plot to not keep the aspect ratio"""
+ plot = self.plot()
+ if plot is not None:
+ # This will trigger _keepDataAspectRatioChanged
+ plot.setKeepDataAspectRatio(False)
+
+ def _keepDataAspectRatioChanged(self, aspectRatio):
+ """Handle Plot set keep aspect ratio signal"""
+ icon, toolTip = self.STATE[aspectRatio, "icon"], self.STATE[aspectRatio, "state"]
+ self.setIcon(icon)
+ self.setToolTip(toolTip)
+
+
+class YAxisOriginToolButton(PlotToolButton):
+
+ STATE = None
+ """Lazy loaded states used to feed YAxisOriginToolButton"""
+
+ def __init__(self, parent=None, plot=None):
+ if self.STATE is None:
+ self.STATE = {}
+ # is down
+ self.STATE[False, "icon"] = icons.getQIcon('plot-ydown')
+ self.STATE[False, "state"] = "Y-axis is oriented downward"
+ self.STATE[False, "action"] = "Orient Y-axis downward"
+ # keep ration
+ self.STATE[True, "icon"] = icons.getQIcon('plot-yup')
+ self.STATE[True, "state"] = "Y-axis is oriented upward"
+ self.STATE[True, "action"] = "Orient Y-axis upward"
+
+ super(YAxisOriginToolButton, self).__init__(parent=parent, plot=plot)
+
+ upwardAction = self._createAction(True)
+ upwardAction.triggered.connect(self.setYAxisUpward)
+ upwardAction.setIconVisibleInMenu(True)
+
+ downwardAction = self._createAction(False)
+ downwardAction.triggered.connect(self.setYAxisDownward)
+ downwardAction.setIconVisibleInMenu(True)
+
+ menu = qt.QMenu(self)
+ menu.addAction(upwardAction)
+ menu.addAction(downwardAction)
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+
+ def _createAction(self, isUpward):
+ icon = self.STATE[isUpward, "icon"]
+ text = self.STATE[isUpward, "action"]
+ return qt.QAction(icon, text, self)
+
+ def _connectPlot(self, plot):
+ plot.sigSetYAxisInverted.connect(self._yAxisInvertedChanged)
+ self._yAxisInvertedChanged(plot.isYAxisInverted())
+
+ def _disconnectPlot(self, plot):
+ plot.sigSetYAxisInverted.disconnect(self._yAxisInvertedChanged)
+
+ def setYAxisUpward(self):
+ """Configure the plot to use y-axis upward"""
+ plot = self.plot()
+ if plot is not None:
+ # This will trigger _yAxisInvertedChanged
+ plot.setYAxisInverted(False)
+
+ def setYAxisDownward(self):
+ """Configure the plot to use y-axis downward"""
+ plot = self.plot()
+ if plot is not None:
+ # This will trigger _yAxisInvertedChanged
+ plot.setYAxisInverted(True)
+
+ def _yAxisInvertedChanged(self, inverted):
+ """Handle Plot set y axis inverted signal"""
+ isUpward = not inverted
+ icon, toolTip = self.STATE[isUpward, "icon"], self.STATE[isUpward, "state"]
+ self.setIcon(icon)
+ self.setToolTip(toolTip)
+
+
+class ProfileToolButton(PlotToolButton):
+ """Button used in Profile3DToolbar to switch between 2D profile
+ and 1D profile."""
+ STATE = None
+ """Lazy loaded states used to feed ProfileToolButton"""
+
+ sigDimensionChanged = qt.Signal(int)
+
+ def __init__(self, parent=None, plot=None):
+ if self.STATE is None:
+ self.STATE = {
+ (1, "icon"): icons.getQIcon('profile1D'),
+ (1, "state"): "1D profile is computed on visible image",
+ (1, "action"): "1D profile on visible image",
+ (2, "icon"): icons.getQIcon('profile2D'),
+ (2, "state"): "2D profile is computed, one 1D profile for each image in the stack",
+ (2, "action"): "2D profile on image stack"}
+ # Compute 1D profile
+ # Compute 2D profile
+
+ super(ProfileToolButton, self).__init__(parent=parent, plot=plot)
+
+ profile1DAction = self._createAction(1)
+ profile1DAction.triggered.connect(self.computeProfileIn1D)
+ profile1DAction.setIconVisibleInMenu(True)
+
+ profile2DAction = self._createAction(2)
+ profile2DAction.triggered.connect(self.computeProfileIn2D)
+ profile2DAction.setIconVisibleInMenu(True)
+
+ menu = qt.QMenu(self)
+ menu.addAction(profile1DAction)
+ menu.addAction(profile2DAction)
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+ menu.setTitle('Select profile dimension')
+
+ def _createAction(self, profileDimension):
+ icon = self.STATE[profileDimension, "icon"]
+ text = self.STATE[profileDimension, "action"]
+ return qt.QAction(icon, text, self)
+
+ def _profileDimensionChanged(self, profileDimension):
+ """Update icon in toolbar, emit number of dimensions for profile"""
+ self.setIcon(self.STATE[profileDimension, "icon"])
+ self.setToolTip(self.STATE[profileDimension, "state"])
+ self.sigDimensionChanged.emit(profileDimension)
+
+ def computeProfileIn1D(self):
+ self._profileDimensionChanged(1)
+
+ def computeProfileIn2D(self):
+ self._profileDimensionChanged(2)
diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py
new file mode 100644
index 0000000..7158d0e
--- /dev/null
+++ b/silx/gui/plot/PlotTools.py
@@ -0,0 +1,313 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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.
+#
+# ###########################################################################*/
+"""Set of widgets to associate with a :class:'PlotWidget'.
+"""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/03/2017"
+
+
+import logging
+import numbers
+import traceback
+import weakref
+
+import numpy
+
+from .. import qt
+
+_logger = logging.getLogger(__name__)
+_logger.setLevel(logging.DEBUG)
+
+
+# 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.PlotTools 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 name to display and conversion function from
+ (x, y) in data coords to displayed value.
+ If None, the default, it displays X and Y.
+ :type converters: Iterable of 2-tuple (str, function)
+ :param parent: Parent widget
+ """
+
+ def __init__(self, parent=None, plot=None, converters=None):
+ assert plot is not None
+ self._plotRef = weakref.ref(plot)
+
+ super(PositionInfo, self).__init__(parent)
+
+ if converters is None:
+ converters = (('X', lambda x, y: x), ('Y', lambda x, y: y))
+
+ self.autoSnapToActiveCurve = False
+ """Toggle snapping use position to active curve.
+
+ - True to snap used coordinates to the active curve if the active curve
+ is displayed with symbols and mouse is close enough.
+ If the mouse is not close to a point of the curve, values are
+ displayed in red.
+ - False (the default) to always use mouse coordinates.
+
+ """
+
+ self._fields = [] # To store (QLineEdit, name, function (x, y)->v)
+
+ # Create a new layout with new widgets
+ layout = qt.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ # layout.setSpacing(0)
+
+ # Create all QLabel and store them with the corresponding converter
+ for name, func in converters:
+ layout.addWidget(qt.QLabel('<b>' + name + ':</b>'))
+
+ contentWidget = qt.QLabel()
+ contentWidget.setText('------')
+ contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
+ contentWidget.setFixedWidth(
+ contentWidget.fontMetrics().width('##############'))
+ layout.addWidget(contentWidget)
+ self._fields.append((contentWidget, name, func))
+
+ layout.addStretch(1)
+ self.setLayout(layout)
+
+ # Connect to Plot events
+ plot.sigPlotSignal.connect(self._plotEvent)
+
+ @property
+ def plot(self):
+ """The :class:`.PlotWindow` this widget is attached to."""
+ 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'] # Position in data
+ styleSheet = "color: rgb(0, 0, 0);" # Default style
+
+ if self.autoSnapToActiveCurve and self.plot.getGraphCursor():
+ # Check if near active curve with symbols.
+
+ styleSheet = "color: rgb(255, 0, 0);" # Style far from curve
+
+ activeCurve = self.plot.getActiveCurve()
+ if activeCurve:
+ xData = activeCurve.getXData(copy=False)
+ yData = activeCurve.getYData(copy=False)
+ if activeCurve.getSymbol(): # Only handled if symbols on curve
+ closestIndex = numpy.argmin(
+ pow(xData - x, 2) + pow(yData - y, 2))
+
+ xClosest = xData[closestIndex]
+ yClosest = yData[closestIndex]
+
+ closestInPixels = self.plot.dataToPixel(
+ xClosest, yClosest, axis=activeCurve.getYAxis())
+ if closestInPixels is not None:
+ xPixel, yPixel = event['xpixel'], event['ypixel']
+
+ if (abs(closestInPixels[0] - xPixel) < 5 and
+ abs(closestInPixels[1] - yPixel) < 5):
+ # Update label style sheet
+ styleSheet = "color: rgb(0, 0, 0);"
+
+ # if close enough, wrap to data point coords
+ x, y = xClosest, yClosest
+
+ for label, name, func in self._fields:
+ label.setStyleSheet(styleSheet)
+
+ try:
+ value = func(x, y)
+ except:
+ label.setText('Error')
+ _logger.error(
+ "Error while converting coordinates (%f, %f)"
+ "with converter '%s'" % (x, y, name))
+ _logger.error(traceback.format_exc())
+ else:
+ if isinstance(value, numbers.Real):
+ value = '%.7g' % value # Use this for floats and int
+ else:
+ value = str(value) # Fallback for other types
+ label.setText(value)
+
+
+# LimitsToolBar ##############################################################
+
+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.PlotTools 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`.
+ """
+
+ class _FloatEdit(qt.QLineEdit):
+ """Field to edit a float value."""
+ def __init__(self, value=None, *args, **kwargs):
+ qt.QLineEdit.__init__(self, *args, **kwargs)
+ self.setValidator(qt.QDoubleValidator())
+ self.setFixedWidth(100)
+ self.setAlignment(qt.Qt.AlignLeft)
+ if value is not None:
+ self.setValue(value)
+
+ def value(self):
+ return float(self.text())
+
+ def setValue(self, value):
+ self.setText('%g' % value)
+
+ 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.getGraphXLimits()
+ yMin, yMax = self.plot.getGraphYLimits()
+
+ self.addWidget(qt.QLabel('Limits: '))
+ self.addWidget(qt.QLabel(' X: '))
+ self._xMinFloatEdit = self._FloatEdit(xMin)
+ self._xMinFloatEdit.editingFinished[()].connect(
+ self._xFloatEditChanged)
+ self.addWidget(self._xMinFloatEdit)
+
+ self._xMaxFloatEdit = self._FloatEdit(xMax)
+ self._xMaxFloatEdit.editingFinished[()].connect(
+ self._xFloatEditChanged)
+ self.addWidget(self._xMaxFloatEdit)
+
+ self.addWidget(qt.QLabel(' Y: '))
+ self._yMinFloatEdit = self._FloatEdit(yMin)
+ self._yMinFloatEdit.editingFinished[()].connect(
+ self._yFloatEditChanged)
+ self.addWidget(self._yMinFloatEdit)
+
+ self._yMaxFloatEdit = self._FloatEdit(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.getGraphXLimits()
+ yMin, yMax = self.plot.getGraphYLimits()
+
+ 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.setGraphXLimits(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.setGraphYLimits(yMin, yMax)
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
new file mode 100644
index 0000000..5666d56
--- /dev/null
+++ b/silx/gui/plot/PlotWidget.py
@@ -0,0 +1,267 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Qt widget providing Plot API for 1D and 2D data.
+
+This provides the plot API of :class:`silx.gui.plot.Plot.Plot` as a
+Qt widget.
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "22/02/2016"
+
+
+import logging
+
+from . import Plot
+
+from .. import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PlotWidget(qt.QMainWindow, Plot.Plot):
+ """Qt Widget providing a 1D/2D plot.
+
+ This widget is a QMainWindow.
+ It provides Qt signals for the Plot and add supports for panning
+ with arrow keys.
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`.Plot` for the list of supported backend.
+ :type backend: str or :class:`BackendBase.BackendBase`
+ """
+
+ sigPlotSignal = qt.Signal(object)
+ """Signal for all events of the plot.
+
+ The signal information is provided as a dict.
+ See :class:`.Plot` for documentation of the content of the dict.
+ """
+
+ sigSetYAxisInverted = qt.Signal(bool)
+ """Signal emitted when Y axis orientation has changed"""
+
+ sigSetXAxisLogarithmic = qt.Signal(bool)
+ """Signal emitted when X axis scale has changed"""
+
+ sigSetYAxisLogarithmic = qt.Signal(bool)
+ """Signal emitted when Y axis scale has changed"""
+
+ sigSetXAxisAutoScale = qt.Signal(bool)
+ """Signal emitted when X axis autoscale has changed"""
+
+ sigSetYAxisAutoScale = qt.Signal(bool)
+ """Signal emitted when Y axis autoscale has changed"""
+
+ sigSetKeepDataAspectRatio = qt.Signal(bool)
+ """Signal emitted when plot keep aspect ratio has changed"""
+
+ sigSetGraphGrid = qt.Signal(str)
+ """Signal emitted when plot grid has changed"""
+
+ sigSetGraphCursor = qt.Signal(bool)
+ """Signal emitted when plot crosshair cursor has changed"""
+
+ sigSetPanWithArrowKeys = qt.Signal(bool)
+ """Signal emitted when pan with arrow keys has changed"""
+
+ sigContentChanged = qt.Signal(str, str, str)
+ """Signal emitted when the content of the plot is changed.
+
+ It provides 3 informations:
+
+ - action: The change of the plot: 'add' or 'remove'
+ - kind: The kind of primitive changed:
+ 'curve', 'image', 'scatter', 'histogram', 'item' or 'marker'
+ - legend: The legend of the primitive changed.
+ """
+
+ sigActiveCurveChanged = qt.Signal(object, object)
+ """Signal emitted when the active curve has changed.
+
+ It provides 2 informations:
+
+ - previous: The legend of the previous active curve or None
+ - legend: The legend of the new active curve or None if no curve is active
+ """
+
+ sigActiveImageChanged = qt.Signal(object, object)
+ """Signal emitted when the active image has changed.
+
+ It provides 2 informations:
+
+ - previous: The legend of the previous active image or None
+ - legend: The legend of the new active image or None if no image is active
+ """
+
+ sigActiveScatterChanged = qt.Signal(object, object)
+ """Signal emitted when the active Scatter has changed.
+
+ It provides following information:
+
+ - previous: The legend of the previous active scatter or None
+ - legend: The legend of the new active image or None if no image is active
+ """
+
+ sigInteractiveModeChanged = qt.Signal(object)
+ """Signal emitted when the interactive mode has changed
+
+ It provides the source as passed to :meth:`setInteractiveMode`.
+ """
+
+ def __init__(self, parent=None, backend=None,
+ legends=False, callback=None, **kw):
+
+ if kw:
+ _logger.warning(
+ 'deprecated: __init__ extra arguments: %s', str(kw))
+ if legends:
+ _logger.warning('deprecated: __init__ legend argument')
+ if callback:
+ _logger.warning('deprecated: __init__ callback argument')
+
+ self._panWithArrowKeys = True
+
+ qt.QMainWindow.__init__(self, parent)
+ if parent is not None:
+ # behave as a widget
+ self.setWindowFlags(qt.Qt.Widget)
+ else:
+ self.setWindowTitle('PlotWidget')
+
+ Plot.Plot.__init__(self, parent, backend=backend)
+
+ widget = self.getWidgetHandle()
+ if widget is not None:
+ self.setCentralWidget(widget)
+ else:
+ _logger.warning("Plot backend does not support widget")
+
+ self.setFocusPolicy(qt.Qt.StrongFocus)
+ self.setFocus(qt.Qt.OtherFocusReason)
+
+ def notify(self, event, **kwargs):
+ """Override :meth:`Plot.notify` to send Qt signals."""
+ eventDict = kwargs.copy()
+ eventDict['event'] = event
+ self.sigPlotSignal.emit(eventDict)
+
+ if event == 'setYAxisInverted':
+ self.sigSetYAxisInverted.emit(kwargs['state'])
+ elif event == 'setXAxisLogarithmic':
+ self.sigSetXAxisLogarithmic.emit(kwargs['state'])
+ elif event == 'setYAxisLogarithmic':
+ self.sigSetYAxisLogarithmic.emit(kwargs['state'])
+ elif event == 'setXAxisAutoScale':
+ self.sigSetXAxisAutoScale.emit(kwargs['state'])
+ elif event == 'setYAxisAutoScale':
+ self.sigSetYAxisAutoScale.emit(kwargs['state'])
+ elif event == 'setKeepDataAspectRatio':
+ self.sigSetKeepDataAspectRatio.emit(kwargs['state'])
+ elif event == 'setGraphGrid':
+ self.sigSetGraphGrid.emit(kwargs['which'])
+ elif event == 'setGraphCursor':
+ self.sigSetGraphCursor.emit(kwargs['state'])
+ elif event == 'contentChanged':
+ self.sigContentChanged.emit(
+ kwargs['action'], kwargs['kind'], kwargs['legend'])
+ elif event == 'activeCurveChanged':
+ self.sigActiveCurveChanged.emit(
+ kwargs['previous'], kwargs['legend'])
+ elif event == 'activeImageChanged':
+ self.sigActiveImageChanged.emit(
+ kwargs['previous'], kwargs['legend'])
+ elif event == 'activeScatterChanged':
+ self.sigActiveScatterChanged.emit(
+ kwargs['previous'], kwargs['legend'])
+ elif event == 'interactiveModeChanged':
+ self.sigInteractiveModeChanged.emit(kwargs['source'])
+ Plot.Plot.notify(self, event, **kwargs)
+
+ # Panning with arrow keys
+
+ def isPanWithArrowKeys(self):
+ """Returns whether or not panning the graph with arrow keys is enable.
+
+ See :meth:`setPanWithArrowKeys`.
+ """
+ return self._panWithArrowKeys
+
+ def setPanWithArrowKeys(self, pan=False):
+ """Enable/Disable panning the graph with arrow keys.
+
+ This grabs the keyboard.
+
+ :param bool pan: True to enable panning, False to disable.
+ """
+ pan = bool(pan)
+ panHasChanged = self._panWithArrowKeys != pan
+
+ self._panWithArrowKeys = pan
+ if not self._panWithArrowKeys:
+ self.setFocusPolicy(qt.Qt.NoFocus)
+ else:
+ self.setFocusPolicy(qt.Qt.StrongFocus)
+ self.setFocus(qt.Qt.OtherFocusReason)
+
+ if panHasChanged:
+ self.sigSetPanWithArrowKeys.emit(pan)
+
+ # Dict to convert Qt arrow key code to direction str.
+ _ARROWS_TO_PAN_DIRECTION = {
+ qt.Qt.Key_Left: 'left',
+ qt.Qt.Key_Right: 'right',
+ qt.Qt.Key_Up: 'up',
+ qt.Qt.Key_Down: 'down'
+ }
+
+ def keyPressEvent(self, event):
+ """Key event handler handling panning on arrow keys.
+
+ Overrides base class implementation.
+ """
+ key = event.key()
+ if self._panWithArrowKeys and key in self._ARROWS_TO_PAN_DIRECTION:
+ self.pan(self._ARROWS_TO_PAN_DIRECTION[key], factor=0.1)
+
+ # Send a mouse move event to the plot widget to take into account
+ # that even if mouse didn't move on the screen, it moved relative
+ # to the plotted data.
+ qapp = qt.QApplication.instance()
+ event = qt.QMouseEvent(
+ qt.QEvent.MouseMove,
+ self.getWidgetHandle().mapFromGlobal(qt.QCursor.pos()),
+ qt.Qt.NoButton,
+ qapp.mouseButtons(),
+ qapp.keyboardModifiers())
+ qapp.sendEvent(self.getWidgetHandle(), event)
+
+ else:
+ # Only call base class implementation when key is not handled.
+ # See QWidget.keyPressEvent for details.
+ super(PlotWidget, self).keyPressEvent(event)
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
new file mode 100644
index 0000000..ae25cfd
--- /dev/null
+++ b/silx/gui/plot/PlotWindow.py
@@ -0,0 +1,766 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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 :class:`.PlotWidget` with additional toolbars.
+
+The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`.
+It provides the plot API fully defined in :class:`.Plot`.
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "27/04/2017"
+
+import collections
+import logging
+
+from silx.utils.decorators import deprecated
+
+from . import PlotWidget
+from . import PlotActions
+from . import PlotToolButtons
+from .PlotTools import PositionInfo
+from .Profile import ProfileToolBar
+from .LegendSelector import LegendsDockWidget
+from .CurvesROIWidget import CurvesROIDockWidget
+from .MaskToolsWidget import MaskToolsDockWidget
+try:
+ from ..console import IPythonDockWidget
+except ImportError:
+ IPythonDockWidget = None
+
+from .. import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PlotWindow(PlotWidget):
+ """Qt Widget providing a 1D/2D plot area and additional tools.
+
+ This widgets inherits from :class:`.PlotWidget` and provides its plot API.
+
+ Initialiser parameters:
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`.Plot` for the list of supported backend.
+ :type backend: str or :class:`BackendBase.BackendBase`
+ :param bool resetzoom: Toggle visibility of reset zoom action.
+ :param bool autoScale: Toggle visibility of axes autoscale actions.
+ :param bool logScale: Toggle visibility of axes log scale actions.
+ :param bool grid: Toggle visibility of grid mode action.
+ :param bool curveStyle: Toggle visibility of curve style action.
+ :param bool colormap: Toggle visibility of colormap action.
+ :param bool aspectRatio: Toggle visibility of aspect ratio button.
+ :param bool yInverted: Toggle visibility of Y axis direction button.
+ :param bool copy: Toggle visibility of copy action.
+ :param bool save: Toggle visibility of save action.
+ :param bool print_: Toggle visibility of print action.
+ :param bool control: True to display an Options button with a sub-menu
+ to show legends, toggle crosshair and pan with arrows.
+ (Default: False)
+ :param position: True to display widget with (x, y) mouse position
+ (Default: False).
+ It also supports a list of (name, funct(x, y)->value)
+ to customize the displayed values.
+ See :class:`silx.gui.plot.PlotTools.PositionInfo`.
+ :param bool roi: Toggle visibilty of ROI action.
+ :param bool mask: Toggle visibilty of mask action.
+ :param bool fit: Toggle visibilty of fit action.
+ """
+
+ def __init__(self, parent=None, backend=None,
+ resetzoom=True, autoScale=True, logScale=True, grid=True,
+ curveStyle=True, colormap=True,
+ aspectRatio=True, yInverted=True,
+ copy=True, save=True, print_=True,
+ control=False, position=False,
+ roi=True, mask=True, fit=False):
+ super(PlotWindow, self).__init__(parent=parent, backend=backend)
+ if parent is None:
+ self.setWindowTitle('PlotWindow')
+
+ self._dockWidgets = []
+
+ # lazy loaded dock widgets
+ self._legendsDockWidget = None
+ self._curvesROIDockWidget = None
+ self._maskToolsDockWidget = None
+
+ # Init actions
+ self.group = qt.QActionGroup(self)
+ self.group.setExclusive(False)
+
+ self.resetZoomAction = self.group.addAction(PlotActions.ResetZoomAction(self))
+ self.resetZoomAction.setVisible(resetzoom)
+ self.addAction(self.resetZoomAction)
+
+ self.zoomInAction = PlotActions.ZoomInAction(self)
+ self.addAction(self.zoomInAction)
+
+ self.zoomOutAction = PlotActions.ZoomOutAction(self)
+ self.addAction(self.zoomOutAction)
+
+ self.xAxisAutoScaleAction = self.group.addAction(
+ PlotActions.XAxisAutoScaleAction(self))
+ self.xAxisAutoScaleAction.setVisible(autoScale)
+ self.addAction(self.xAxisAutoScaleAction)
+
+ self.yAxisAutoScaleAction = self.group.addAction(
+ PlotActions.YAxisAutoScaleAction(self))
+ self.yAxisAutoScaleAction.setVisible(autoScale)
+ self.addAction(self.yAxisAutoScaleAction)
+
+ self.xAxisLogarithmicAction = self.group.addAction(
+ PlotActions.XAxisLogarithmicAction(self))
+ self.xAxisLogarithmicAction.setVisible(logScale)
+ self.addAction(self.xAxisLogarithmicAction)
+
+ self.yAxisLogarithmicAction = self.group.addAction(
+ PlotActions.YAxisLogarithmicAction(self))
+ self.yAxisLogarithmicAction.setVisible(logScale)
+ self.addAction(self.yAxisLogarithmicAction)
+
+ self.gridAction = self.group.addAction(
+ PlotActions.GridAction(self, gridMode='both'))
+ self.gridAction.setVisible(grid)
+ self.addAction(self.gridAction)
+
+ self.curveStyleAction = self.group.addAction(PlotActions.CurveStyleAction(self))
+ self.curveStyleAction.setVisible(curveStyle)
+ self.addAction(self.curveStyleAction)
+
+ self.colormapAction = self.group.addAction(PlotActions.ColormapAction(self))
+ self.colormapAction.setVisible(colormap)
+ self.addAction(self.colormapAction)
+
+ self.keepDataAspectRatioButton = PlotToolButtons.AspectToolButton(
+ parent=self, plot=self)
+ self.keepDataAspectRatioButton.setVisible(aspectRatio)
+
+ self.yAxisInvertedButton = PlotToolButtons.YAxisOriginToolButton(
+ parent=self, plot=self)
+ self.yAxisInvertedButton.setVisible(yInverted)
+
+ self.group.addAction(self.getRoiAction())
+ self.getRoiAction().setVisible(roi)
+
+ self.group.addAction(self.getMaskAction())
+ self.getMaskAction().setVisible(mask)
+
+ self._intensityHistoAction = self.group.addAction(
+ PlotActions.PixelIntensitiesHistoAction(self))
+ self._intensityHistoAction.setVisible(False)
+
+ self._medianFilter2DAction = self.group.addAction(
+ PlotActions.MedianFilter2DAction(self))
+ self._medianFilter2DAction.setVisible(False)
+
+ self._medianFilter1DAction = self.group.addAction(
+ PlotActions.MedianFilter1DAction(self))
+ self._medianFilter1DAction.setVisible(False)
+
+ self._separator = qt.QAction('separator', self)
+ self._separator.setSeparator(True)
+ self.group.addAction(self._separator)
+
+ self.copyAction = self.group.addAction(PlotActions.CopyAction(self))
+ self.copyAction.setVisible(copy)
+ self.addAction(self.copyAction)
+
+ self.saveAction = self.group.addAction(PlotActions.SaveAction(self))
+ self.saveAction.setVisible(save)
+ self.addAction(self.saveAction)
+
+ self.printAction = self.group.addAction(PlotActions.PrintAction(self))
+ self.printAction.setVisible(print_)
+ self.addAction(self.printAction)
+
+ self.fitAction = self.group.addAction(PlotActions.FitAction(self))
+ self.fitAction.setVisible(fit)
+ self.addAction(self.fitAction)
+
+ # lazy loaded actions needed by the controlButton menu
+ self._consoleAction = None
+ self._panWithArrowKeysAction = None
+ self._crosshairAction = None
+
+ if control or position:
+ hbox = qt.QHBoxLayout()
+ hbox.setContentsMargins(0, 0, 0, 0)
+
+ if control:
+ self.controlButton = qt.QToolButton()
+ self.controlButton.setText("Options")
+ self.controlButton.setToolButtonStyle(qt.Qt.ToolButtonTextBesideIcon)
+ self.controlButton.setAutoRaise(True)
+ self.controlButton.setPopupMode(qt.QToolButton.InstantPopup)
+ menu = qt.QMenu(self)
+ menu.aboutToShow.connect(self._customControlButtonMenu)
+ self.controlButton.setMenu(menu)
+
+ hbox.addWidget(self.controlButton)
+
+ if position: # Add PositionInfo widget to the bottom of the plot
+ if isinstance(position, collections.Iterable):
+ # Use position as a set of converters
+ converters = position
+ else:
+ converters = None
+ self.positionWidget = PositionInfo(
+ plot=self, converters=converters)
+ self.positionWidget.autoSnapToActiveCurve = True
+
+ hbox.addWidget(self.positionWidget)
+
+ hbox.addStretch(1)
+ bottomBar = qt.QWidget()
+ bottomBar.setLayout(hbox)
+
+ layout = qt.QVBoxLayout()
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.getWidgetHandle())
+ layout.addWidget(bottomBar)
+ layout.setStretch(0, 1)
+
+ centralWidget = qt.QWidget()
+ centralWidget.setLayout(layout)
+ self.setCentralWidget(centralWidget)
+
+ # Creating the toolbar also create actions for toolbuttons
+ self._toolbar = self._createToolBar(title='Plot', parent=None)
+ self.addToolBar(self._toolbar)
+
+ def getSelectionMask(self):
+ """Return the current mask handled by :attr:`maskToolsDockWidget`.
+
+ :return: The array of the mask with dimension of the 'active' image.
+ If there is no active image, an empty array is returned.
+ :rtype: 2D numpy.ndarray of uint8
+ """
+ return self.getMaskToolsDockWidget().getSelectionMask()
+
+ def setSelectionMask(self, mask):
+ """Set the mask handled by :attr:`maskToolsDockWidget`.
+
+ If the provided mask has not the same dimension as the 'active'
+ image, it will by cropped or padded.
+
+ :param mask: The array to use for the mask.
+ :type mask: numpy.ndarray of uint8 of dimension 2, C-contiguous.
+ Array of other types are converted.
+ :return: True if success, False if failed
+ """
+ return bool(self.getMaskToolsDockWidget().setSelectionMask(mask))
+
+ def _toggleConsoleVisibility(self, is_checked=False):
+ """Create IPythonDockWidget if needed,
+ show it or hide it."""
+ # create widget if needed (first call)
+ if not hasattr(self, '_consoleDockWidget'):
+ available_vars = {"plt": self}
+ banner = "The variable 'plt' is available. Use the 'whos' "
+ banner += "and 'help(plt)' commands for more information.\n\n"
+ self._consoleDockWidget = IPythonDockWidget(
+ available_vars=available_vars,
+ custom_banner=banner,
+ parent=self)
+ self.addTabbedDockWidget(self._consoleDockWidget)
+ self._consoleDockWidget.visibilityChanged.connect(
+ self.getConsoleAction().setChecked)
+
+ self._consoleDockWidget.setVisible(is_checked)
+
+ def _createToolBar(self, title, parent):
+ """Create a QToolBar from the QAction of the PlotWindow.
+
+ :param str title: The title of the QMenu
+ :param qt.QWidget parent: See :class:`QToolBar`
+ """
+ toolbar = qt.QToolBar(title, parent)
+
+ # Order widgets with actions
+ objects = self.group.actions()
+
+ # Add push buttons to list
+ index = objects.index(self.colormapAction)
+ objects.insert(index + 1, self.keepDataAspectRatioButton)
+ objects.insert(index + 2, self.yAxisInvertedButton)
+
+ for obj in objects:
+ if isinstance(obj, qt.QAction):
+ toolbar.addAction(obj)
+ else:
+ # Add action for toolbutton in order to allow changing
+ # visibility (see doc QToolBar.addWidget doc)
+ if obj is self.keepDataAspectRatioButton:
+ self.keepDataAspectRatioAction = toolbar.addWidget(obj)
+ elif obj is self.yAxisInvertedButton:
+ self.yAxisInvertedAction = toolbar.addWidget(obj)
+ else:
+ raise RuntimeError()
+ return toolbar
+
+ def toolBar(self):
+ """Return a QToolBar from the QAction of the PlotWindow.
+ """
+ return self._toolbar
+
+ def menu(self, title='Plot', parent=None):
+ """Return a QMenu from the QAction of the PlotWindow.
+
+ :param str title: The title of the QMenu
+ :param parent: See :class:`QMenu`
+ """
+ menu = qt.QMenu(title, parent)
+ for action in self.group.actions():
+ menu.addAction(action)
+ return menu
+
+ def _customControlButtonMenu(self):
+ """Display Options button sub-menu."""
+ controlMenu = self.controlButton.menu()
+ controlMenu.clear()
+ controlMenu.addAction(self.getLegendsDockWidget().toggleViewAction())
+ controlMenu.addAction(self.getRoiAction())
+ controlMenu.addAction(self.getMaskAction())
+ controlMenu.addAction(self.getConsoleAction())
+
+ controlMenu.addSeparator()
+ controlMenu.addAction(self.getCrosshairAction())
+ controlMenu.addAction(self.getPanWithArrowKeysAction())
+
+ def addTabbedDockWidget(self, dock_widget):
+ """Add a dock widget as a new tab if there are already dock widgets
+ in the plot. When the first tab is added, the area is chosen
+ depending on the plot geometry:
+ it the window is much wider than it is high, the right dock area
+ is used, else the bottom dock area is used.
+
+ :param dock_widget: Instance of :class:`QDockWidget` to be added.
+ """
+ if dock_widget not in self._dockWidgets:
+ self._dockWidgets.append(dock_widget)
+ if len(self._dockWidgets) == 1:
+ # The first created dock widget must be added to a Widget area
+ width = self.centralWidget().width()
+ height = self.centralWidget().height()
+ if width > (2.0 * height) and width > 1000:
+ area = qt.Qt.RightDockWidgetArea
+ else:
+ area = qt.Qt.BottomDockWidgetArea
+ self.addDockWidget(area, dock_widget)
+ else:
+ # Other dock widgets are added as tabs to the same widget area
+ self.tabifyDockWidget(self._dockWidgets[0],
+ dock_widget)
+
+ # getters for dock widgets
+ @property
+ @deprecated(replacement="getLegendsDockWidget()", since_version="0.4.0")
+ def legendsDockWidget(self):
+ return self.getLegendsDockWidget()
+
+ def getLegendsDockWidget(self):
+ """DockWidget with Legend panel"""
+ if self._legendsDockWidget is None:
+ self._legendsDockWidget = LegendsDockWidget(plot=self)
+ self._legendsDockWidget.hide()
+ self.addTabbedDockWidget(self._legendsDockWidget)
+ return self._legendsDockWidget
+
+ @property
+ @deprecated(replacement="getCurvesRoiDockWidget()", since_version="0.4.0")
+ def curvesROIDockWidget(self):
+ return self.getCurvesRoiDockWidget()
+
+ def getCurvesRoiDockWidget(self):
+ """DockWidget with curves' ROI panel (lazy-loaded).
+
+ The widget returned is a :class:`CurvesROIDockWidget`.
+ Its central widget is a :class:`CurvesROIWidget`
+ accessible as :attr:`CurvesROIDockWidget.roiWidget`.
+
+ :class:`silx.gui.plot.CurvesROIWidget.CurvesROIWidget` offers a getter
+ and a setter for the ROI data:
+
+ - :meth:`CurvesROIWidget.getRois`
+ - :meth:`CurvesROIWidget.setRois`
+ """
+ if self._curvesROIDockWidget is None:
+ self._curvesROIDockWidget = CurvesROIDockWidget(
+ plot=self, name='Regions Of Interest')
+ self._curvesROIDockWidget.hide()
+ self.addTabbedDockWidget(self._curvesROIDockWidget)
+ return self._curvesROIDockWidget
+
+ @property
+ @deprecated(replacement="getMaskToolsDockWidget()", since_version="0.4.0")
+ def maskToolsDockWidget(self):
+ return self.getMaskToolsDockWidget()
+
+ def getMaskToolsDockWidget(self):
+ """DockWidget with image mask panel (lazy-loaded)."""
+ if self._maskToolsDockWidget is None:
+ self._maskToolsDockWidget = MaskToolsDockWidget(
+ plot=self, name='Mask')
+ self._maskToolsDockWidget.hide()
+ self.addTabbedDockWidget(self._maskToolsDockWidget)
+ return self._maskToolsDockWidget
+
+ # getters for actions
+ @property
+ @deprecated(replacement="getConsoleAction()", since_version="0.4.0")
+ def consoleAction(self):
+ return self.getConsoleAction()
+
+ def getConsoleAction(self):
+ """QAction handling the IPython console activation.
+
+ By default, it is connected to a method that initializes the
+ console widget the first time the user clicks the "Console" menu
+ button. The following clicks, after initialization is done,
+ will toggle the visibility of the console widget.
+
+ :rtype: QAction
+ """
+ if self._consoleAction is None:
+ self._consoleAction = qt.QAction('Console', self)
+ self._consoleAction.setCheckable(True)
+ if IPythonDockWidget is not None:
+ self._consoleAction.toggled.connect(self._toggleConsoleVisibility)
+ else:
+ self._consoleAction.setEnabled(False)
+ return self._consoleAction
+
+ @property
+ @deprecated(replacement="getCrosshairAction()", since_version="0.4.0")
+ def crosshairAction(self):
+ return self.getCrosshairAction()
+
+ def getCrosshairAction(self):
+ """Action toggling crosshair cursor mode.
+
+ :rtype: PlotActions.PlotAction
+ """
+ if self._crosshairAction is None:
+ self._crosshairAction = PlotActions.CrosshairAction(self, color='red')
+ return self._crosshairAction
+
+ @property
+ @deprecated(replacement="getMaskAction()", since_version="0.4.0")
+ def maskAction(self):
+ return self.getMaskAction()
+
+ def getMaskAction(self):
+ """QAction toggling image mask dock widget
+
+ :rtype: QAction
+ """
+ return self.getMaskToolsDockWidget().toggleViewAction()
+
+ @property
+ @deprecated(replacement="getPanWithArrowKeysAction()",
+ since_version="0.4.0")
+ def panWithArrowKeysAction(self):
+ return self.getPanWithArrowKeysAction()
+
+ def getPanWithArrowKeysAction(self):
+ """Action toggling pan with arrow keys.
+
+ :rtype: PlotActions.PlotAction
+ """
+ if self._panWithArrowKeysAction is None:
+ self._panWithArrowKeysAction = PlotActions.PanWithArrowKeysAction(self)
+ return self._panWithArrowKeysAction
+
+ @property
+ @deprecated(replacement="getRoiAction()", since_version="0.4.0")
+ def roiAction(self):
+ return self.getRoiAction()
+
+ def getRoiAction(self):
+ """QAction toggling curve ROI dock widget
+
+ :rtype: QAction
+ """
+ return self.getCurvesRoiDockWidget().toggleViewAction()
+
+ def getResetZoomAction(self):
+ """Action resetting the zoom
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.resetZoomAction
+
+ def getZoomInAction(self):
+ """Action to zoom in
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.zoomInAction
+
+ def getZoomOutAction(self):
+ """Action to zoom out
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.zoomOutAction
+
+ def getXAxisAutoScaleAction(self):
+ """Action to toggle the X axis autoscale on zoom reset
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.xAxisAutoScaleAction
+
+ def getYAxisAutoScaleAction(self):
+ """Action to toggle the Y axis autoscale on zoom reset
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.yAxisAutoScaleAction
+
+ def getXAxisLogarithmicAction(self):
+ """Action to toggle logarithmic X axis
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.xAxisLogarithmicAction
+
+ def getYAxisLogarithmicAction(self):
+ """Action to toggle logarithmic Y axis
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.yAxisLogarithmicAction
+
+ def getGridAction(self):
+ """Action to toggle the grid visibility in the plot
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.gridAction
+
+ def getCurveStyleAction(self):
+ """Action to change curve line and markers styles
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.curveStyleAction
+
+ def getColormapAction(self):
+ """Action open a colormap dialog to change active image
+ and default colormap.
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.colormapAction
+
+ def getKeepDataAspectRatioButton(self):
+ """Button to toggle aspect ratio preservation
+
+ :rtype: PlotToolButtons.AspectToolButton
+ """
+ return self.keepDataAspectRatioButton
+
+ def getKeepDataAspectRatioAction(self):
+ """Action associated to keepDataAspectRatioButton.
+ Use this to change the visibility of keepDataAspectRatioButton in the
+ toolbar (See :meth:`QToolBar.addWidget` documentation).
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.keepDataAspectRatioButton
+
+ def getYAxisInvertedButton(self):
+ """Button to switch the Y axis orientation
+
+ :rtype: PlotToolButtons.YAxisOriginToolButton
+ """
+ return self.yAxisInvertedButton
+
+ def getYAxisInvertedAction(self):
+ """Action associated to yAxisInvertedButton.
+ Use this to change the visibility yAxisInvertedButton in the toolbar.
+ (See :meth:`QToolBar.addWidget` documentation).
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.yAxisInvertedAction
+
+ def getIntensityHistogramAction(self):
+ """Action toggling the histogram intensity Plot widget
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self._intensityHistoAction
+
+ def getCopyAction(self):
+ """Action to copy plot snapshot to clipboard
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.copyAction
+
+ def getSaveAction(self):
+ """Action to save plot
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.saveAction
+
+ def getPrintAction(self):
+ """Action to print plot
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.printAction
+
+ def getFitAction(self):
+ """Action to fit selected curve
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self.fitAction
+
+ def getMedianFilter1DAction(self):
+ """Action toggling the 1D median filter
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self._medianFilter1DAction
+
+ def getMedianFilter2DAction(self):
+ """Action toggling the 2D median filter
+
+ :rtype: PlotActions.PlotAction
+ """
+ return self._medianFilter2DAction
+
+
+class Plot1D(PlotWindow):
+ """PlotWindow with tools specific for curves.
+
+ This widgets provides the plot API of :class:`.PlotWidget`.
+
+ :param parent: The parent of this widget
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`.Plot` for the list of supported backend.
+ :type backend: str or :class:`BackendBase.BackendBase`
+ """
+
+ def __init__(self, parent=None, backend=None):
+ super(Plot1D, self).__init__(parent=parent, backend=backend,
+ resetzoom=True, autoScale=True,
+ logScale=True, grid=True,
+ curveStyle=True, colormap=False,
+ aspectRatio=False, yInverted=False,
+ copy=True, save=True, print_=True,
+ control=True, position=True,
+ roi=True, mask=False, fit=True)
+ if parent is None:
+ self.setWindowTitle('Plot1D')
+ self.setGraphXLabel('X')
+ self.setGraphYLabel('Y')
+
+
+class Plot2D(PlotWindow):
+ """PlotWindow with a toolbar specific for images.
+
+ This widgets provides the plot API of :~:`.PlotWidget`.
+
+ :param parent: The parent of this widget
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`.Plot` for the list of supported backend.
+ :type backend: str or :class:`BackendBase.BackendBase`
+ """
+
+ def __init__(self, parent=None, backend=None):
+ # List of information to display at the bottom of the plot
+ posInfo = [
+ ('X', lambda x, y: x),
+ ('Y', lambda x, y: y),
+ ('Data', self._getImageValue)]
+
+ super(Plot2D, self).__init__(parent=parent, backend=backend,
+ resetzoom=True, autoScale=False,
+ logScale=False, grid=False,
+ curveStyle=False, colormap=True,
+ aspectRatio=True, yInverted=True,
+ copy=True, save=True, print_=True,
+ control=False, position=posInfo,
+ roi=False, mask=True)
+ if parent is None:
+ self.setWindowTitle('Plot2D')
+ self.setGraphXLabel('Columns')
+ self.setGraphYLabel('Rows')
+
+ self.profile = ProfileToolBar(plot=self)
+
+ self.addToolBar(self.profile)
+
+ def _getImageValue(self, x, y):
+ """Get value of top most image at position (x, y)
+
+ :param float x: X position in plot coordinates
+ :param float y: Y position in plot coordinates
+ :return: The value at that point or '-'
+ """
+ value = '-'
+ valueZ = - float('inf')
+
+ for image in self.getAllImages():
+ data = image.getData(copy=False)
+ if image.getZValue() >= valueZ: # This image is over the previous one
+ ox, oy = image.getOrigin()
+ sx, sy = image.getScale()
+ row, col = (y - oy) / sy, (x - ox) / sx
+ if row >= 0 and col >= 0:
+ # Test positive before cast otherwise issue with int(-0.5) = 0
+ row, col = int(row), int(col)
+ if (row < data.shape[0] and col < data.shape[1]):
+ value = data[row, col]
+ valueZ = image.getZValue()
+ return value
+
+ def getProfileToolbar(self):
+ """Profile tools attached to this plot
+
+ See :class:`silx.gui.plot.Profile.ProfileToolBar`
+ """
+ return self.profile
+
+ @deprecated(replacement="getProfilePlot", since_version="0.5.0")
+ def getProfileWindow(self):
+ return self.getProfilePlot()
+
+ def getProfilePlot(self):
+ """Return plot window used to display profile curve.
+
+ :return: :class:`Plot1D`
+ """
+ return self.profile.getProfilePlot()
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
new file mode 100644
index 0000000..a11b3f0
--- /dev/null
+++ b/silx/gui/plot/Profile.py
@@ -0,0 +1,741 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Utility functions, toolbars and actions to create profile on images
+and stacks of images"""
+
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"]
+__license__ = "MIT"
+__date__ = "24/04/2017"
+
+
+import numpy
+
+from silx.image.bilinear import BilinearImage
+
+from .. import icons
+from .. import qt
+from . import items
+from .Colors import cursorColorForColormap
+from .PlotActions import PlotAction
+from .PlotToolButtons import ProfileToolButton
+from .ProfileMainWindow import ProfileMainWindow
+
+from silx.utils.decorators import deprecated
+
+
+def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
+ """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.
+ :return: profile image + effective ROI area corners in plot coords
+ """
+ assert axis in (0, 1)
+ assert len(data.shape) == 3
+
+ # Convert from plot to image coords
+ imgPos = int((position - origin[1 - axis]) / scale[1 - axis])
+
+ if axis == 1: # Vertical profile
+ # Transpose image to always do a horizontal profile
+ data = numpy.transpose(data, (0, 2, 1))
+
+ nimages, height, width = data.shape
+
+ roiWidth = min(height, roiWidth) # Clip roi width to image size
+
+ # Get [start, end[ coords of the roi in the data
+ start = int(int(imgPos) + 0.5 - roiWidth / 2.)
+ start = min(max(0, start), height - roiWidth)
+ end = start + roiWidth
+
+ if start < height and end > 0:
+ profile = data[:, max(0, start):min(end, height), :].mean(
+ axis=1, dtype=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):
+ """Mean of a rectangular region (ROI) of a stack of images
+ along a given axis.
+
+ Returned values and all parameters are in image coordinates.
+
+ :param numpy.ndarray data: 3D volume (stack of 2D images)
+ The first dimension is the image index.
+ :param rowRange: [min, max[ of ROI rows (upper bound excluded).
+ :type rowRange: 2-tuple of int (min, max) with min < max
+ :param colRange: [min, max[ of ROI columns (upper bound excluded).
+ :type colRange: 2-tuple of int (min, max) with min < max
+ :param int axis: The axis along which to take the profile of the ROI.
+ 0: Sum rows along columns.
+ 1: Sum columns along rows.
+ :return: Profile image along the ROI as the mean of the intersection
+ of the ROI and the image.
+ """
+ assert axis in (0, 1)
+ assert len(data.shape) == 3
+ assert rowRange[0] < rowRange[1]
+ assert colRange[0] < colRange[1]
+
+ nimages, height, width = data.shape
+
+ # Range aligned with the integration direction
+ profileRange = colRange if axis == 0 else rowRange
+
+ profileLength = abs(profileRange[1] - profileRange[0])
+
+ # Subset of the image to use as intersection of ROI and image
+ rowStart = min(max(0, rowRange[0]), height)
+ rowEnd = min(max(0, rowRange[1]), height)
+ colStart = min(max(0, colRange[0]), width)
+ colEnd = min(max(0, colRange[1]), width)
+
+ imgProfile = numpy.mean(data[:, rowStart:rowEnd, colStart:colEnd],
+ axis=axis + 1, dtype=numpy.float32)
+
+ # Profile including out of bound area
+ profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
+
+ # Place imgProfile in full profile
+ offset = - min(0, profileRange[0])
+ profile[:, offset:offset + imgProfile.shape[1]] = imgProfile
+
+ return profile
+
+
+def createProfile(roiInfo, currentData, origin, scale, lineWidth):
+ """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
+ :return: `profile, area, profileName, xLabel`, where:
+ - profile is a 2D array of the profiles of the stack of images.
+ For a single image, the profile is a curve, so this parameter
+ has a shape *(1, len(curve))*
+ - area is a tuple of two 1D arrays with 4 values each. They represent
+ the effective ROI area corners in plot coords.
+ - profileName is a string describing the ROI, meant to be used as
+ title of the profile plot
+ - xLabel is a string describing the meaning of the X axis on the
+ profile plot ("rows", "columns", "distance")
+
+ :rtype: tuple(ndarray, (ndarray, ndarray), str, 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)
+
+ yMin, yMax = min(area[1]), max(area[1]) - 1
+ if roiWidth <= 1:
+ profileName = 'Y = %g' % yMin
+ else:
+ profileName = 'Y = [%g, %g]' % (yMin, yMax)
+ xLabel = 'Columns'
+
+ elif lineProjectionMode == 'Y': # Vertical profile on the whole image
+ profile, area = _alignedFullProfile(currentData3D,
+ origin, scale,
+ roiStart[0], roiWidth,
+ axis=1)
+
+ xMin, xMax = min(area[0]), max(area[0]) - 1
+ if roiWidth <= 1:
+ profileName = 'X = %g' % xMin
+ else:
+ profileName = 'X = [%g, %g]' % (xMin, xMax)
+ xLabel = 'Rows'
+
+ 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
+ profile = _alignedPartialProfile(currentData3D,
+ rowRange, colRange,
+ axis=0)
+
+ else: # Column aligned
+ rowRange = startPt[0], endPt[0] + 1
+ colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth),
+ int(startPt[1] + 0.5 + 0.5 * roiWidth))
+ profile = _alignedPartialProfile(currentData3D,
+ rowRange, colRange,
+ axis=1)
+
+ # Convert ranges to plot coords to draw ROI area
+ area = (
+ numpy.array(
+ (colRange[0], colRange[1], colRange[1], colRange[0]),
+ dtype=numpy.float32) * scale[0] + origin[0],
+ numpy.array(
+ (rowRange[0], rowRange[0], rowRange[1], rowRange[1]),
+ dtype=numpy.float32) * scale[1] + origin[1])
+
+ else: # General case: use bilinear interpolation
+
+ # Ensure startPt <= endPt
+ if (startPt[1] > endPt[1] or (
+ startPt[1] == endPt[1] and startPt[0] > endPt[0])):
+ startPt, endPt = endPt, startPt
+
+ profile = []
+ for slice_idx in range(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))
+ profile = numpy.array(profile)
+
+ # Extend ROI with half a pixel on each end, and
+ # Convert back to plot coords (x, y)
+ length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 +
+ (endPt[1] - startPt[1]) ** 2)
+ dRow = (endPt[0] - startPt[0]) / length
+ dCol = (endPt[1] - startPt[1]) / length
+
+ # Extend ROI with half a pixel on each end
+ startPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
+ endPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol
+
+ # Rotate deltas by 90 degrees to apply line width
+ dRow, dCol = dCol, -dRow
+
+ area = (
+ numpy.array((startPt[1] - 0.5 * roiWidth * dCol,
+ startPt[1] + 0.5 * roiWidth * dCol,
+ endPt[1] + 0.5 * roiWidth * dCol,
+ endPt[1] - 0.5 * roiWidth * dCol),
+ dtype=numpy.float32) * scale[0] + origin[0],
+ numpy.array((startPt[0] - 0.5 * roiWidth * dRow,
+ startPt[0] + 0.5 * roiWidth * dRow,
+ endPt[0] + 0.5 * roiWidth * dRow,
+ endPt[0] - 0.5 * roiWidth * dRow),
+ dtype=numpy.float32) * scale[1] + origin[1])
+
+ y0, x0 = startPt
+ y1, x1 = endPt
+ if x1 == x0 or y1 == y0:
+ profileName = 'From (%g, %g) to (%g, %g)' % (x0, y0, x1, y1)
+ else:
+ m = (y1 - y0) / (x1 - x0)
+ b = y0 - m * x0
+ profileName = 'y = %g * x %+g ; width=%d' % (m, b, roiWidth)
+ xLabel = 'Distance'
+
+ return profile, area, profileName, xLabel
+
+
+# ProfileToolBar ##############################################################
+
+class ProfileToolBar(qt.QToolBar):
+ """QToolBar providing profile tools operating on a :class:`PlotWindow`.
+
+ Attributes:
+
+ - plot: Associated :class:`PlotWindow` on which the profile line is drawn.
+ - actionGroup: :class:`QActionGroup` of available actions.
+
+ To run the following sample code, a QApplication must be initialized.
+ First, create a PlotWindow and add a :class:`ProfileToolBar`.
+
+ >>> from silx.gui.plot import PlotWindow
+ >>> from silx.gui.plot.Profile import ProfileToolBar
+
+ >>> plot = PlotWindow() # Create a PlotWindow
+ >>> toolBar = ProfileToolBar(plot=plot) # Create a profile toolbar
+ >>> plot.addToolBar(toolBar) # Add it to plot
+ >>> plot.show() # To display the PlotWindow with the profile toolbar
+
+ :param plot: :class:`PlotWindow` instance on which to operate.
+ :param profileWindow: Plot widget instance where to
+ display the profile curve or None to create one.
+ :param str title: See :class:`QToolBar`.
+ :param parent: See :class:`QToolBar`.
+ """
+ # TODO Make it a QActionGroup instead of a QToolBar
+
+ _POLYGON_LEGEND = '__ProfileToolBar_ROI_Polygon'
+
+ def __init__(self, parent=None, plot=None, profileWindow=None,
+ title='Profile Selection'):
+ super(ProfileToolBar, self).__init__(title, parent)
+ assert plot is not None
+ self.plot = plot
+
+ self._overlayColor = None
+ self._defaultOverlayColor = 'red' # update when active image change
+
+ self._roiInfo = None # Store start and end points and type of ROI
+
+ self._profileWindow = profileWindow
+ """User provided plot widget in which the profile curve is plotted.
+ None if no custom profile plot was provided."""
+
+ self._profileMainWindow = None
+ """Main window providing 2 profile plot widgets for 1D or 2D profiles.
+ The window provides two public methods
+ - :meth:`setProfileDimensions`
+ - :meth:`getPlot`: return handle on the actual plot widget
+ currently being used
+ None if the user specified a custom profile plot window.
+ """
+
+ if self._profileWindow is None:
+ self._profileMainWindow = ProfileMainWindow(self)
+
+ # Actions
+ self.browseAction = qt.QAction(
+ icons.getQIcon('normal'),
+ 'Browsing Mode', None)
+ self.browseAction.setToolTip(
+ 'Enables zooming interaction mode')
+ self.browseAction.setCheckable(True)
+ self.browseAction.triggered[bool].connect(self._browseActionTriggered)
+
+ self.hLineAction = qt.QAction(
+ icons.getQIcon('shape-horizontal'),
+ 'Horizontal Profile Mode', None)
+ self.hLineAction.setToolTip(
+ 'Enables horizontal profile selection mode')
+ self.hLineAction.setCheckable(True)
+ self.hLineAction.toggled[bool].connect(self._hLineActionToggled)
+
+ self.vLineAction = qt.QAction(
+ icons.getQIcon('shape-vertical'),
+ 'Vertical Profile Mode', None)
+ self.vLineAction.setToolTip(
+ 'Enables vertical profile selection mode')
+ self.vLineAction.setCheckable(True)
+ self.vLineAction.toggled[bool].connect(self._vLineActionToggled)
+
+ self.lineAction = qt.QAction(
+ icons.getQIcon('shape-diagonal'),
+ 'Free Line Profile Mode', None)
+ self.lineAction.setToolTip(
+ 'Enables line profile selection mode')
+ self.lineAction.setCheckable(True)
+ self.lineAction.toggled[bool].connect(self._lineActionToggled)
+
+ self.clearAction = qt.QAction(
+ icons.getQIcon('profile-clear'),
+ 'Clear Profile', None)
+ self.clearAction.setToolTip(
+ 'Clear the profile Region of interest')
+ self.clearAction.setCheckable(False)
+ self.clearAction.triggered.connect(self.clearProfile)
+
+ # ActionGroup
+ self.actionGroup = qt.QActionGroup(self)
+ self.actionGroup.addAction(self.browseAction)
+ self.actionGroup.addAction(self.hLineAction)
+ self.actionGroup.addAction(self.vLineAction)
+ self.actionGroup.addAction(self.lineAction)
+
+ self.browseAction.setChecked(True)
+
+ # Add actions to ToolBar
+ self.addAction(self.browseAction)
+ self.addAction(self.hLineAction)
+ self.addAction(self.vLineAction)
+ self.addAction(self.lineAction)
+ self.addAction(self.clearAction)
+
+ # Add width spin box to toolbar
+ self.addWidget(qt.QLabel('W:'))
+ self.lineWidthSpinBox = qt.QSpinBox(self)
+ self.lineWidthSpinBox.setRange(0, 1000)
+ self.lineWidthSpinBox.setValue(1)
+ self.lineWidthSpinBox.valueChanged[int].connect(
+ self._lineWidthSpinBoxValueChangedSlot)
+ self.addWidget(self.lineWidthSpinBox)
+
+ self.plot.sigInteractiveModeChanged.connect(
+ self._interactiveModeChanged)
+
+ # Enable toolbar only if there is an active image
+ self.setEnabled(self.plot.getActiveImage(just_legend=True) is not None)
+ self.plot.sigActiveImageChanged.connect(
+ self._activeImageChanged)
+
+ # listen to the profile window signals to clear profile polygon on close
+ if self.getProfileMainWindow() is not None:
+ self.getProfileMainWindow().sigClose.connect(self.clearProfile)
+
+ @property
+ @deprecated(replacement="getProfilePlot", since_version="0.5.0")
+ def profileWindow(self):
+ return self.getProfilePlot()
+
+ def getProfilePlot(self):
+ """Return plot widget in which the profile curve or the
+ profile image is plotted.
+ """
+ if self.getProfileMainWindow() is not None:
+ return self.getProfileMainWindow().getPlot()
+
+ # in case the user provided a custom plot for profiles
+ return self._profileWindow
+
+ def getProfileMainWindow(self):
+ """Return window containing the profile curve widget.
+ This can return *None* if a custom profile plot window was
+ specified in the constructor.
+ """
+ return self._profileMainWindow
+
+ def _activeImageChanged(self, previous, legend):
+ """Handle active image change: toggle enabled toolbar, update curve"""
+ self.setEnabled(legend is not None)
+ if legend is not None:
+ # Update default profile color
+ activeImage = self.plot.getActiveImage()
+ if isinstance(activeImage, items.ColormapMixIn):
+ self._defaultOverlayColor = cursorColorForColormap(
+ activeImage.getColormap()['name'])
+ else:
+ self._defaultOverlayColor = 'black'
+
+ self.updateProfile()
+
+ def _lineWidthSpinBoxValueChangedSlot(self, value):
+ """Listen to ROI width widget to refresh ROI and profile"""
+ self.updateProfile()
+
+ def _interactiveModeChanged(self, source):
+ """Handle plot interactive mode changed:
+
+ If changed from elsewhere, disable drawing tool
+ """
+ if source is not self:
+ self.browseAction.setChecked(True)
+
+ def _hLineActionToggled(self, checked):
+ """Handle horizontal line profile action toggle"""
+ if checked:
+ self.plot.setInteractiveMode('draw', shape='hline',
+ color=None, source=self)
+ self.plot.sigPlotSignal.connect(self._plotWindowSlot)
+ else:
+ self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
+
+ def _vLineActionToggled(self, checked):
+ """Handle vertical line profile action toggle"""
+ if checked:
+ self.plot.setInteractiveMode('draw', shape='vline',
+ color=None, source=self)
+ self.plot.sigPlotSignal.connect(self._plotWindowSlot)
+ else:
+ self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
+
+ def _lineActionToggled(self, checked):
+ """Handle line profile action toggle"""
+ if checked:
+ self.plot.setInteractiveMode('draw', shape='line',
+ color=None, source=self)
+ self.plot.sigPlotSignal.connect(self._plotWindowSlot)
+ else:
+ self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
+
+ def _browseActionTriggered(self, checked):
+ """Handle browse action mode triggered by user."""
+ if checked:
+ self.clearProfile()
+ self.plot.setInteractiveMode('zoom', source=self)
+ if self.getProfileMainWindow() is not None:
+ self.getProfileMainWindow().hide()
+
+ def _plotWindowSlot(self, event):
+ """Listen to Plot to handle drawing events to refresh ROI and profile.
+ """
+ if event['event'] not in ('drawingProgress', 'drawingFinished'):
+ return
+
+ checkedAction = self.actionGroup.checkedAction()
+ if checkedAction == self.hLineAction:
+ lineProjectionMode = 'X'
+ elif checkedAction == self.vLineAction:
+ lineProjectionMode = 'Y'
+ elif checkedAction == self.lineAction:
+ lineProjectionMode = 'D'
+ else:
+ return
+
+ roiStart, roiEnd = event['points'][0], event['points'][1]
+
+ self._roiInfo = roiStart, roiEnd, lineProjectionMode
+ self.updateProfile()
+
+ @property
+ def overlayColor(self):
+ """The color to use for the ROI.
+
+ If set to None (the default), the overlay color is adapted to the
+ active image colormap and changes if the active image colormap changes.
+ """
+ return self._overlayColor or self._defaultOverlayColor
+
+ @overlayColor.setter
+ def overlayColor(self, color):
+ self._overlayColor = color
+ self.updateProfile()
+
+ def clearProfile(self):
+ """Remove profile curve and profile area."""
+ self._roiInfo = None
+ self.updateProfile()
+
+ def updateProfile(self):
+ """Update the displayed profile and profile ROI.
+
+ This uses the current active image of the plot and the current ROI.
+ """
+ image = self.plot.getActiveImage()
+ if image is None:
+ return
+
+ # Clean previous profile area, and previous curve
+ self.plot.remove(self._POLYGON_LEGEND, kind='item')
+ self.getProfilePlot().clear()
+ self.getProfilePlot().setGraphTitle('')
+ self.getProfilePlot().setGraphXLabel('X')
+ self.getProfilePlot().setGraphYLabel('Y')
+
+ self._createProfile(currentData=image.getData(copy=False),
+ origin=image.getOrigin(),
+ scale=image.getScale(),
+ colormap=None, # Not used for 2D data
+ z=image.getZValue())
+
+ def _createProfile(self, currentData, origin, scale, colormap, z):
+ """Create the profile line for the the given image.
+
+ :param numpy.ndarray currentData: the image or the 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 dict colormap: The colormap to use
+ :param int z: The z layer of the image
+ """
+ if self._roiInfo is None:
+ return
+
+ profile, area, profileName, xLabel = createProfile(
+ roiInfo=self._roiInfo,
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=self.lineWidthSpinBox.value())
+
+ self.getProfilePlot().setGraphTitle(profileName)
+
+ dataIs3D = len(currentData.shape) > 2
+ if dataIs3D:
+ self.getProfilePlot().addImage(profile,
+ legend=profileName,
+ xlabel=xLabel,
+ ylabel="Frame index (depth)",
+ colormap=colormap)
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ self.getProfilePlot().addCurve(coords,
+ profile[0],
+ legend=profileName,
+ xlabel=xLabel,
+ color=self.overlayColor)
+
+ self.plot.addItem(area[0], area[1],
+ legend=self._POLYGON_LEGEND,
+ color=self.overlayColor,
+ shape='polygon', fill=True,
+ replace=False, z=z + 1)
+
+ self._showProfileMainWindow()
+
+ def _showProfileMainWindow(self):
+ """If profile window was created by this toolbar,
+ try to avoid overlapping with the toolbar's parent window.
+ """
+ profileMainWindow = self.getProfileMainWindow()
+ if profileMainWindow is not None:
+ winGeom = self.window().frameGeometry()
+ qapp = qt.QApplication.instance()
+ screenGeom = qapp.desktop().availableGeometry(self)
+
+ spaceOnLeftSide = winGeom.left()
+ spaceOnRightSide = screenGeom.width() - winGeom.right()
+
+ profileWindowWidth = profileMainWindow.frameGeometry().width()
+ if (profileWindowWidth < spaceOnRightSide or
+ spaceOnRightSide > spaceOnLeftSide):
+ # Place profile on the right
+ profileMainWindow.move(winGeom.right(), winGeom.top())
+ else:
+ # Not enough place on the right, place profile on the left
+ profileMainWindow.move(
+ max(0, winGeom.left() - profileWindowWidth), winGeom.top())
+
+ profileMainWindow.show()
+ else:
+ self.getProfilePlot().show()
+
+ def hideProfileWindow(self):
+ """Hide profile window.
+ """
+ # this method is currently only used by StackView when the perspective
+ # is changed
+ if self.getProfileMainWindow() is not None:
+ self.getProfileMainWindow().hide()
+
+
+class Profile3DToolBar(ProfileToolBar):
+ def __init__(self, parent=None, plot=None, title='Profile Selection'):
+ """QToolBar providing profile tools for an image or a stack of images.
+
+ :param parent: the parent QWidget
+ :param plot: :class:`PlotWindow` instance on which to operate.
+ :param str title: See :class:`QToolBar`.
+ :param parent: See :class:`QToolBar`.
+ """
+ # TODO: add param profileWindow (specify the plot used for profiles)
+ super(Profile3DToolBar, self).__init__(parent=parent, plot=plot,
+ title=title)
+
+ self.profile3dAction = ProfileToolButton(
+ parent=self, plot=self.plot)
+ self.profile3dAction.computeProfileIn2D()
+ self.profile3dAction.setVisible(True)
+ self.addWidget(self.profile3dAction)
+ self.profile3dAction.sigDimensionChanged.connect(self._setProfileType)
+
+ # create the 3D toolbar
+ self._profileType = None
+ self._setProfileType(2)
+
+ def _setProfileType(self, dimensions):
+ """Set the profile type: "1D" for a curve (profile on a single image)
+ or "2D" for an image (profile on a stack of images).
+
+ :param int dimensions: 1 for a "1D" profile or 2 for a "2D" profile
+ """
+ # fixme this assumes that we created _profileMainWindow
+ self._profileType = "1D" if dimensions == 1 else "2D"
+ self.getProfileMainWindow().setProfileType(self._profileType)
+ self.updateProfile()
+
+ def updateProfile(self):
+ """Method overloaded from :class:`ProfileToolBar`,
+ to pass the stack of images instead of just the active image.
+
+ In 1D profile mode, use the regular parent method.
+ """
+ if self._profileType == "1D":
+ super(Profile3DToolBar, self).updateProfile()
+ elif self._profileType == "2D":
+ stackData = self.plot.getCurrentView(copy=False,
+ returnNumpyArray=True)
+ if stackData is None:
+ return
+ self.plot.remove(self._POLYGON_LEGEND, kind='item')
+ self.getProfilePlot().clear()
+ self.getProfilePlot().setGraphTitle('')
+ self.getProfilePlot().setGraphXLabel('X')
+ self.getProfilePlot().setGraphYLabel('Y')
+
+ self._createProfile(currentData=stackData[0],
+ origin=stackData[1]['origin'],
+ scale=stackData[1]['scale'],
+ colormap=stackData[1]['colormap'],
+ z=stackData[1]['z'])
+ else:
+ raise ValueError(
+ "Profile type must be 1D or 2D, not %s" % self._profileType)
diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py
new file mode 100644
index 0000000..835de2c
--- /dev/null
+++ b/silx/gui/plot/ProfileMainWindow.py
@@ -0,0 +1,99 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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 contains a QMainWindow class used to display profile plots.
+"""
+from silx.gui import qt
+
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "21/02/2017"
+
+
+class ProfileMainWindow(qt.QMainWindow):
+ """QMainWindow providing 2 plot widgets specialized in
+ 1D and 2D plotting, with different toolbars.
+ Only one of the plots is visible at any given time.
+ """
+ sigProfileDimensionsChanged = qt.Signal(int)
+ """This signal is emitted when :meth:`setProfileDimensions` is called.
+ It carries the number of dimensions for the profile data (1 or 2).
+ It can be used to be notified that the profile plot widget has changed.
+ """
+
+ 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):
+ qt.QMainWindow.__init__(self, parent=parent)
+
+ self.setWindowTitle('Profile window')
+ # plots are created on demand, in self.setProfileDimensions()
+ self._plot1D = None
+ self._plot2D = None
+ # by default, profile is assumed to be a 1D curve
+ self._profileType = None
+ self.setProfileType("1D")
+
+ def setProfileType(self, profileType):
+ """Set which profile plot widget (1D or 2D) is to be used
+
+ :param str profileType: Type of profile data,
+ "1D" for a curve or "2D" for an image
+ """
+ # import here to avoid circular import
+ from .PlotWindow import Plot1D, Plot2D # noqa
+ self._profileType = profileType
+
+ if self._profileType == "1D":
+ if self._plot2D is not None:
+ self._plot2D.setParent(None) # necessary to avoid widget destruction
+ if self._plot1D is None:
+ self._plot1D = Plot1D()
+ self.setCentralWidget(self._plot1D)
+ elif self._profileType == "2D":
+ if self._plot1D is not None:
+ self._plot1D.setParent(None) # necessary to avoid widget destruction
+ if self._plot2D is None:
+ self._plot2D = Plot2D()
+ self.setCentralWidget(self._plot2D)
+ else:
+ raise ValueError("Profile type must be '1D' or '2D'")
+
+ self.sigProfileDimensionsChanged.emit(profileType)
+
+ def getPlot(self):
+ """Return the profile plot widget which is currently in use.
+ This can be the 2D profile plot or the 1D profile plot.
+ """
+ if self._profileType == "2D":
+ return self._plot2D
+ else:
+ return self._plot1D
+
+ def closeEvent(self, qCloseEvent):
+ self.sigClose.emit()
+ qCloseEvent.accept()
diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py
new file mode 100644
index 0000000..793719d
--- /dev/null
+++ b/silx/gui/plot/ScatterMaskToolsWidget.py
@@ -0,0 +1,529 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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.
+#
+# ###########################################################################*/
+"""Widget providing a set of tools to draw masks on a PlotWidget.
+
+This widget is meant to work with a modified :class:`silx.gui.plot.PlotWidget`
+
+- :class:`ScatterMask`: Handle scatter mask update and history
+- :class:`ScatterMaskToolsWidget`: GUI for :class:`ScatterMask`
+- :class:`ScatterMaskToolsDockWidget`: DockWidget to integrate in :class:`PlotWindow`
+"""
+
+from __future__ import division
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "07/04/2017"
+
+
+import math
+import logging
+import os
+import numpy
+import sys
+
+from .. import qt
+from ...image import shapes
+
+from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
+from .Colors import cursorColorForColormap, rgba
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ScatterMask(BaseMask):
+ """A 1D mask for scatter data.
+ """
+ def __init__(self, scatter=None):
+ """
+
+ :param scatter: :class:`silx.gui.plot.items.Scatter` instance
+ """
+ BaseMask.__init__(self, scatter)
+
+ def _getXY(self):
+ x = self._dataItem.getXData(copy=False)
+ y = self._dataItem.getYData(copy=False)
+ return x, y
+
+ def getDataValues(self):
+ """Return scatter data values as a 1D array.
+
+ :rtype: 1D numpy.ndarray
+ """
+ return self._dataItem.getValueData(copy=False)
+
+ def save(self, filename, kind):
+ if kind == 'npy':
+ try:
+ numpy.save(filename, self.getMask(copy=False))
+ except IOError:
+ raise RuntimeError("Mask file can't be written")
+ elif kind in ["csv", "txt"]:
+ try:
+ numpy.savetxt(filename, self.getMask(copy=False))
+ except IOError:
+ raise RuntimeError("Mask file can't be written")
+
+ def updatePoints(self, level, indices, mask=True):
+ """Mask/Unmask points with given indices.
+
+ :param int level: Mask level to update.
+ :param indices: Sequence or 1D array of indices of points to be
+ updated
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ if mask:
+ self._mask[indices] = level
+ else:
+ # unmask only where mask level is the specified value
+ indices_stencil = numpy.zeros_like(self._mask, dtype=numpy.bool)
+ indices_stencil[indices] = True
+ self._mask[numpy.logical_and(self._mask == level, indices_stencil)] = 0
+ self._notify()
+
+ # update shapes
+ def updatePolygon(self, level, vertices, mask=True):
+ """Mask/Unmask a polygon of the given mask level.
+
+ :param int level: Mask level to update.
+ :param vertices: Nx2 array of polygon corners as (y, x) or (row, col)
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ polygon = shapes.Polygon(vertices)
+ x, y = self._getXY()
+
+ # TODO: this could be optimized if necessary
+ indices_in_polygon = [idx for idx in range(len(x)) if
+ polygon.is_inside(y[idx], x[idx])]
+
+ self.updatePoints(level, indices_in_polygon, mask)
+
+ def updateRectangle(self, level, y, x, height, width, mask=True):
+ """Mask/Unmask data inside a rectangle
+
+ :param int level: Mask level to update.
+ :param float y: Y coordinate of bottom left corner of the rectangle
+ :param float x: X coordinate of bottom left corner of the rectangle
+ :param float height:
+ :param float width:
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ vertices = [(y, x),
+ (y + height, x),
+ (y + height, x + width),
+ (y, x + width)]
+ self.updatePolygon(level, vertices, mask)
+
+ def updateDisk(self, level, cy, cx, radius, mask=True):
+ """Mask/Unmask a disk of the given mask level.
+
+ :param int level: Mask level to update.
+ :param float cy: Disk center (y).
+ :param float cx: Disk center (x).
+ :param float radius: Radius of the disk in mask array unit
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ x, y = self._getXY()
+ stencil = (y - cy)**2 + (x - cx)**2 < radius**2
+ self.updateStencil(level, stencil, mask)
+
+ def updateLine(self, level, y0, x0, y1, x1, width, mask=True):
+ """Mask/Unmask points inside a rectangle defined by a line (two
+ end points) and a width.
+
+ :param int level: Mask level to update.
+ :param float y0: Row of the starting point.
+ :param float x0: Column of the starting point.
+ :param float row1: Row of the end point.
+ :param float col1: Column of the end point.
+ :param float width: Width of the line.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ # theta is the angle between the horizontal and the line
+ theta = math.atan((y1 - y0) / (x1 - x0)) if x1 - x0 else 0
+ w_over_2_sin_theta = width / 2. * math.sin(theta)
+ w_over_2_cos_theta = width / 2. * math.cos(theta)
+
+ vertices = [(y0 - w_over_2_cos_theta, x0 + w_over_2_sin_theta),
+ (y0 + w_over_2_cos_theta, x0 - w_over_2_sin_theta),
+ (y1 + w_over_2_cos_theta, x1 - w_over_2_sin_theta),
+ (y1 - w_over_2_cos_theta, x1 + w_over_2_sin_theta)]
+
+ self.updatePolygon(level, vertices, mask)
+
+
+class ScatterMaskToolsWidget(BaseMaskToolsWidget):
+ """Widget with tools for masking data points on a scatter in a
+ :class:`PlotWidget`."""
+
+ def __init__(self, parent=None, plot=None):
+ self._z = 2 # Mask layer in plot
+ self._data_scatter = None
+ """plot Scatter item for data"""
+ self._mask_scatter = None
+ """plot Scatter item for representing the mask"""
+
+ self._mask = ScatterMask()
+
+ super(ScatterMaskToolsWidget, self).__init__(parent, plot)
+
+ self._initWidgets()
+
+ def setSelectionMask(self, mask, copy=True):
+ """Set the mask to a new array.
+
+ :param numpy.ndarray mask: The array to use for the mask.
+ :type mask: numpy.ndarray of uint8, C-contiguous.
+ Array of other types are converted.
+ :param bool copy: True (the default) to copy the array,
+ False to use it as is if possible.
+ :return: None if failed, shape of mask as 1-tuple if successful.
+ The mask can be cropped or padded to fit active scatter,
+ the returned shape is that of the scatter data.
+ """
+ mask = numpy.array(mask, copy=False, dtype=numpy.uint8)
+
+ if self._data_scatter.getXData(copy=False).shape == (0,) \
+ or mask.shape == self._data_scatter.getXData(copy=False).shape:
+ self._mask.setMask(mask, copy=copy)
+ self._mask.commit()
+ return mask.shape
+ else:
+ raise ValueError("Mask does not have the same shape as the data")
+
+ # Handle mask refresh on the plot
+
+ def _updatePlotMask(self):
+ """Update mask image in plot"""
+ mask = self.getSelectionMask(copy=False)
+ if len(mask):
+ self.plot.addScatter(self._data_scatter.getXData(),
+ self._data_scatter.getYData(),
+ mask,
+ legend=self._maskName,
+ colormap=self._colormap,
+ z=self._z)
+ self._mask_scatter = self.plot._getItem(kind="scatter",
+ legend=self._maskName)
+ self._mask_scatter.setSymbolSize(
+ self._data_scatter.getSymbolSize() * 4.0
+ )
+ elif self.plot._getItem(kind="scatter",
+ legend=self._maskName) is not None:
+ self.plot.remove(self._maskName, kind='scatter')
+
+ # track widget visibility and plot active image changes
+
+ def showEvent(self, event):
+ try:
+ self.plot.sigActiveScatterChanged.disconnect(
+ self._activeScatterChangedAfterCare)
+ except (RuntimeError, TypeError):
+ pass
+ self._activeScatterChanged(None, None) # Init mask + enable/disable widget
+ self.plot.sigActiveScatterChanged.connect(self._activeScatterChanged)
+
+ def hideEvent(self, event):
+ self.plot.sigActiveScatterChanged.disconnect(self._activeScatterChanged)
+ if not self.browseAction.isChecked():
+ self.browseAction.trigger() # Disable drawing tool
+
+ if len(self.getSelectionMask(copy=False)):
+ self.plot.sigActiveScatterChanged.connect(
+ self._activeScatterChangedAfterCare)
+
+ def _activeScatterChangedAfterCare(self, previous, next):
+ """Check synchro of active scatter and mask when mask widget is hidden.
+
+ If active image has no more the same size as the mask, the mask is
+ removed, otherwise it is adjusted to z.
+ """
+ # check that content changed was the active scatter
+ activeScatter = self.plot._getActiveItem(kind="scatter")
+
+ if activeScatter is None or activeScatter.getLegend() == self._maskName:
+ # No active scatter or active scatter is the mask...
+ self.plot.sigActiveScatterChanged.disconnect(
+ self._activeScatterChangedAfterCare)
+ else:
+ colormap = activeScatter.getColormap()
+ self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+
+ self._z = activeScatter.getZValue() + 1
+ self._data_scatter = activeScatter
+ if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape:
+ # scatter has not the same size, remove mask and stop listening
+ if self.plot._getItem(kind="scatter", legend=self._maskName):
+ self.plot.remove(self._maskName, kind='scatter')
+
+ self.plot.sigActiveScatterChanged.disconnect(
+ self._activeScatterChangedAfterCare)
+ else:
+ # Refresh in case z changed
+ self._mask.setDataItem(self._data_scatter)
+ self._updatePlotMask()
+
+ def _activeScatterChanged(self, previous, next):
+ """Update widget and mask according to active scatter changes"""
+ activeScatter = self.plot._getActiveItem(kind="scatter")
+
+ if activeScatter is None or activeScatter.getLegend() == self._maskName:
+ # No active scatter or active scatter is the mask...
+ self.setEnabled(False)
+
+ self._data_scatter = None
+ self._mask.reset()
+ self._mask.commit()
+
+ else: # There is an active scatter
+ self.setEnabled(True)
+
+ colormap = activeScatter.getColormap()
+ self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+
+ self._z = activeScatter.getZValue() + 1
+ self._data_scatter = activeScatter
+ self._mask.setDataItem(self._data_scatter)
+ if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape:
+ self._mask.reset(self._data_scatter.getXData(copy=False).shape)
+ self._mask.commit()
+ else:
+ # Refresh in case z changed
+ self._updatePlotMask()
+
+ self._updateInteractiveMode()
+
+ # Handle whole mask operations
+
+ def load(self, filename):
+ """Load a mask from an image file.
+
+ :param str filename: File name from which to load the mask
+ :raise Exception: An exception in case of failure
+ :raise RuntimeWarning: In case the mask was applied but with some
+ import changes to notice
+ """
+ _, extension = os.path.splitext(filename)
+ extension = extension.lower()[1:]
+ if extension == "npy":
+ try:
+ mask = numpy.load(filename)
+ except IOError:
+ _logger.error("Can't load filename '%s'", filename)
+ _logger.debug("Backtrace", exc_info=True)
+ raise RuntimeError('File "%s" is not a numpy file.',
+ filename)
+ elif extension in ["txt", "csv"]:
+ try:
+ mask = numpy.loadtxt(filename)
+ except IOError:
+ _logger.error("Can't load filename '%s'", filename)
+ _logger.debug("Backtrace", exc_info=True)
+ raise RuntimeError('File "%s" is not a numpy txt file.',
+ filename)
+ else:
+ msg = "Extension '%s' is not supported."
+ raise RuntimeError(msg % extension)
+
+ self.setSelectionMask(mask, copy=False)
+
+ def _loadMask(self):
+ """Open load mask dialog"""
+ dialog = qt.QFileDialog(self)
+ dialog.setWindowTitle("Load Mask")
+ dialog.setModal(1)
+ filters = [
+ 'NumPy binary file (*.npy)',
+ 'CSV text file (*.csv)',
+ ]
+ dialog.setNameFilters(filters)
+ dialog.setFileMode(qt.QFileDialog.ExistingFile)
+ dialog.setDirectory(self.maskFileDir)
+ if not dialog.exec_():
+ dialog.close()
+ return
+
+ filename = dialog.selectedFiles()[0]
+ dialog.close()
+
+ self.maskFileDir = os.path.dirname(filename)
+ try:
+ self.load(filename)
+ # except RuntimeWarning as e:
+ # message = e.args[0]
+ # msg = qt.QMessageBox(self)
+ # msg.setIcon(qt.QMessageBox.Warning)
+ # msg.setText("Mask loaded but an operation was applied.\n" + message)
+ # msg.exec_()
+ except Exception as e:
+ message = e.args[0]
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setText("Cannot load mask from file. " + message)
+ msg.exec_()
+
+ def _saveMask(self):
+ """Open Save mask dialog"""
+ dialog = qt.QFileDialog(self)
+ dialog.setWindowTitle("Save Mask")
+ dialog.setModal(1)
+ filters = [
+ 'NumPy binary file (*.npy)',
+ 'CSV text file (*.csv)',
+ ]
+ dialog.setNameFilters(filters)
+ dialog.setFileMode(qt.QFileDialog.AnyFile)
+ dialog.setAcceptMode(qt.QFileDialog.AcceptSave)
+ dialog.setDirectory(self.maskFileDir)
+ if not dialog.exec_():
+ dialog.close()
+ return
+
+ # convert filter name to extension name with the .
+ extension = dialog.selectedNameFilter().split()[-1][2:-1]
+ filename = dialog.selectedFiles()[0]
+ dialog.close()
+
+ if not filename.lower().endswith(extension):
+ filename += extension
+
+ if os.path.exists(filename):
+ try:
+ os.remove(filename)
+ except IOError:
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setText("Cannot save.\n"
+ "Input Output Error: %s" % (sys.exc_info()[1]))
+ msg.exec_()
+ return
+
+ self.maskFileDir = os.path.dirname(filename)
+ try:
+ self.save(filename, extension[1:])
+ except Exception as e:
+ msg = qt.QMessageBox(self)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setText("Cannot save file %s\n%s" % (filename, e.args[0]))
+ msg.exec_()
+
+ def resetSelectionMask(self):
+ """Reset the mask"""
+ self._mask.reset(
+ shape=self._data_scatter.getXData(copy=False).shape)
+ self._mask.commit()
+
+ def _plotDrawEvent(self, event):
+ """Handle draw events from the plot"""
+ if (self._drawingMode is None or
+ event['event'] not in ('drawingProgress', 'drawingFinished')):
+ return
+
+ if not len(self._data_scatter.getXData(copy=False)):
+ return
+
+ level = self.levelSpinBox.value()
+
+ if (self._drawingMode == 'rectangle' and
+ event['event'] == 'drawingFinished'):
+ doMask = self._isMasking()
+
+ self._mask.updateRectangle(
+ level,
+ y=event['y'],
+ x=event['x'],
+ height=abs(event['height']),
+ width=abs(event['width']),
+ mask=doMask)
+ self._mask.commit()
+
+ elif (self._drawingMode == 'polygon' and
+ event['event'] == 'drawingFinished'):
+ doMask = self._isMasking()
+ vertices = event['points']
+ vertices = vertices.astype(numpy.int)[:, (1, 0)] # (y, x)
+ self._mask.updatePolygon(level, vertices, doMask)
+ self._mask.commit()
+
+ elif self._drawingMode == 'pencil':
+ doMask = self._isMasking()
+ # convert from plot to array coords
+ x, y = event['points'][-1]
+ brushSize = self.pencilSpinBox.value()
+
+ if self._lastPencilPos != (y, x):
+ if self._lastPencilPos is not None:
+ # Draw the line
+ self._mask.updateLine(
+ level,
+ self._lastPencilPos[0], self._lastPencilPos[1],
+ y, x,
+ brushSize,
+ doMask)
+
+ # Draw the very first, or last point
+ self._mask.updateDisk(level, y, x, brushSize / 2., doMask)
+
+ if event['event'] == 'drawingFinished':
+ self._mask.commit()
+ self._lastPencilPos = None
+ else:
+ self._lastPencilPos = y, x
+
+ def _loadRangeFromColormapTriggered(self):
+ """Set range from active scatter colormap range"""
+ if self._data_scatter is not None:
+ # Update thresholds according to colormap
+ colormap = self._data_scatter.getColormap()
+ if colormap['autoscale']:
+ min_ = numpy.nanmin(self._data_scatter.getValueData(copy=False))
+ max_ = numpy.nanmax(self._data_scatter.getValueData(copy=False))
+ else:
+ min_, max_ = colormap['vmin'], colormap['vmax']
+ self.minLineEdit.setText(str(min_))
+ self.maxLineEdit.setText(str(max_))
+
+
+class ScatterMaskToolsDockWidget(BaseMaskToolsDockWidget):
+ """:class:`ScatterMaskToolsWidget` embedded in a QDockWidget.
+
+ For integration in a :class:`PlotWindow`.
+
+ :param parent: See :class:`QDockWidget`
+ :param plot: The PlotWidget this widget is operating on
+ :paran str name: The title of this widget
+ """
+ def __init__(self, parent=None, plot=None, name='Mask'):
+ super(ScatterMaskToolsDockWidget, self).__init__(parent, name)
+ self.setWidget(ScatterMaskToolsWidget(plot=plot))
+ self.widget().sigMaskChanged.connect(self._emitSigMaskChanged)
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
new file mode 100644
index 0000000..9bb0cf0
--- /dev/null
+++ b/silx/gui/plot/StackView.py
@@ -0,0 +1,1033 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 a 3D volume as a stack of 2D images.
+
+The :class:`StackView` class implements this widget.
+
+Basic usage of :class:`StackView` is through the following methods:
+
+- :meth:`StackView.getColormap`, :meth:`StackView.setColormap` to update the
+ default colormap to use and update the currently displayed image.
+- :meth:`StackView.setStack` to update the displayed image.
+
+The :class:`StackView` uses :class:`PlotWindow` and also
+exposes a subset of the :class:`silx.gui.plot.Plot` API for further control
+(plot title, axes labels, ...).
+
+The :class:`StackViewMainWindow` class implements a widget that adds a status
+bar displaying the 3D index and the value under the mouse cursor.
+
+Example::
+
+ import numpy
+ import sys
+ from silx.gui import qt
+ from silx.gui.plot.StackView import StackViewMainWindow
+
+
+ app = qt.QApplication(sys.argv[1:])
+
+ # synthetic data, stack of 100 images of size 200x300
+ mystack = numpy.fromfunction(
+ lambda i, j, k: numpy.sin(i/15.) + numpy.cos(j/4.) + 2 * numpy.sin(k/6.),
+ (100, 200, 300)
+ )
+
+
+ sv = StackViewMainWindow()
+ sv.setColormap("jet", autoscale=True)
+ sv.setStack(mystack)
+ sv.setLabels(["1st dim (0-99)", "2nd dim (0-199)",
+ "3rd dim (0-299)"])
+ sv.show()
+
+ app.exec_()
+
+"""
+
+__authors__ = ["P. Knobel", "H. Payno"]
+__license__ = "MIT"
+__date__ = "20/01/2017"
+
+import numpy
+
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
+from silx.gui import qt
+from .. import icons
+from . import items, PlotWindow, PlotActions
+from .Colors import cursorColorForColormap
+from .PlotTools import LimitsToolBar
+from .Profile import Profile3DToolBar
+from ..widgets.FrameBrowser import HorizontalSliderWithBrowser
+
+from silx.utils.array_like import DatasetView, ListOfImages
+from silx.math import calibration
+
+
+class StackView(qt.QMainWindow):
+ """Stack view widget, to display and browse through stack of
+ images.
+
+ The profile tool can be switched to "3D" mode, to compute the profile
+ on each image of the stack (not only the active image currently displayed)
+ and display the result as a slice.
+
+ :param QWidget parent: the Qt parent, or None
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`.Plot` for the list of supported backend.
+ :type backend: str or :class:`BackendBase.BackendBase`
+ :param bool resetzoom: Toggle visibility of reset zoom action.
+ :param bool autoScale: Toggle visibility of axes autoscale actions.
+ :param bool logScale: Toggle visibility of axes log scale actions.
+ :param bool grid: Toggle visibility of grid mode action.
+ :param bool colormap: Toggle visibility of colormap action.
+ :param bool aspectRatio: Toggle visibility of aspect ratio button.
+ :param bool yInverted: Toggle visibility of Y axis direction button.
+ :param bool copy: Toggle visibility of copy action.
+ :param bool save: Toggle visibility of save action.
+ :param bool print_: Toggle visibility of print action.
+ :param bool control: True to display an Options button with a sub-menu
+ to show legends, toggle crosshair and pan with arrows.
+ (Default: False)
+ :param position: True to display widget with (x, y) mouse position
+ (Default: False).
+ It also supports a list of (name, funct(x, y)->value)
+ to customize the displayed values.
+ See :class:`silx.gui.plot.PlotTools.PositionInfo`.
+ :param bool mask: Toggle visibilty of mask action.
+ """
+ # Qt signals
+ valueChanged = qt.Signal(object, object, object)
+ """Signals that the data value under the cursor has changed.
+
+ It provides: row, column, data value.
+ """
+
+ sigPlaneSelectionChanged = qt.Signal(int)
+ """Signal emitted when there is a change is perspective/displayed axes.
+
+ It provides the perspective as an integer, with the following meaning:
+
+ - 0: axis Y is the 2nd dimension, axis X is the 3rd dimension
+ - 1: axis Y is the 1st dimension, axis X is the 3rd dimension
+ - 2: axis Y is the 1st dimension, axis X is the 2nd dimension
+ """
+
+ sigStackChanged = qt.Signal(int)
+ """Signal emitted when the stack is changed.
+ This happens when a new volume is loaded, or when the current volume
+ is transposed (change in perspective).
+
+ The signal provides the size (number of pixels) of the stack.
+ This will be 0 if the stack is cleared, else it will be a positive
+ integer.
+ """
+
+ def __init__(self, parent=None, resetzoom=True, backend=None,
+ autoScale=False, logScale=False, grid=False,
+ colormap=True, aspectRatio=True, yinverted=True,
+ copy=True, save=True, print_=True, control=False,
+ position=None, mask=True):
+ qt.QMainWindow.__init__(self, parent)
+ if parent is not None:
+ # behave as a widget
+ self.setWindowFlags(qt.Qt.Widget)
+ else:
+ self.setWindowTitle('StackView')
+
+ self._stack = None
+ """Loaded stack, as a 3D array, a 3D dataset or a list of 2D arrays."""
+ self.__transposed_view = None
+ """View on :attr:`_stack` with the axes sorted, to have
+ the orthogonal dimension first"""
+ self._perspective = 0
+ """Orthogonal dimension (depth) in :attr:`_stack`"""
+
+ self.__imageLegend = '__StackView__image' + str(id(self))
+ self.__autoscaleCmap = False
+ """Flag to disable/enable colormap auto-scaling
+ based on the min/max values of the entire 3D volume"""
+ self.__dimensionsLabels = ["Dimension 0", "Dimension 1",
+ "Dimension 2"]
+ """These labels are displayed on the X and Y axes.
+ :meth:`setLabels` updates this attribute."""
+
+ self._first_stack_dimension = 0
+ """Used for dimension labels and combobox"""
+
+ central_widget = qt.QWidget(self)
+
+ self._plot = PlotWindow(parent=central_widget, backend=backend,
+ resetzoom=resetzoom, autoScale=autoScale,
+ logScale=logScale, grid=grid,
+ curveStyle=False, colormap=colormap,
+ aspectRatio=aspectRatio, yInverted=yinverted,
+ copy=copy, save=save, print_=print_,
+ control=control, position=position,
+ roi=False, mask=mask)
+ self.sigInteractiveModeChanged = self._plot.sigInteractiveModeChanged
+ self.sigActiveImageChanged = self._plot.sigActiveImageChanged
+ self.sigPlotSignal = self._plot.sigPlotSignal
+
+ self._plot.profile = Profile3DToolBar(parent=self._plot,
+ plot=self)
+ self._plot.addToolBar(self._plot.profile)
+ self._plot.setGraphXLabel('Columns')
+ self._plot.setGraphYLabel('Rows')
+ self._plot.sigPlotSignal.connect(self._plotCallback)
+
+ self.__planeSelection = PlanesWidget(self._plot)
+ self.__planeSelection.sigPlaneSelectionChanged.connect(self.__setPerspective)
+
+ self._browser_label = qt.QLabel("Image index (Dim0):")
+
+ self._browser = HorizontalSliderWithBrowser(central_widget)
+ self._browser.valueChanged[int].connect(self.__updateFrameNumber)
+ self._browser.setEnabled(False)
+
+ layout = qt.QGridLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._plot, 0, 0, 1, 3)
+ layout.addWidget(self.__planeSelection, 1, 0)
+ layout.addWidget(self._browser_label, 1, 1)
+ layout.addWidget(self._browser, 1, 2)
+
+ central_widget.setLayout(layout)
+ self.setCentralWidget(central_widget)
+
+ # clear profile lines when the perspective changes (plane browsed changed)
+ self.__planeSelection.sigPlaneSelectionChanged.connect(
+ self._plot.profile.getProfilePlot().clear)
+ self.__planeSelection.sigPlaneSelectionChanged.connect(
+ self._plot.profile.clearProfile)
+
+ def setOptionVisible(self, isVisible):
+ """
+ Set the visibility of the browsing options.
+
+ :param bool isVisible: True to have the options visible, else False
+ """
+ self._browser.setVisible(isVisible)
+ self.__planeSelection.setVisible(isVisible)
+
+ def _plotCallback(self, eventDict):
+ """Callback for plot events.
+
+ Emit :attr:`valueChanged` signal, with (x, y, value) tuple of the
+ cursor location in the plot."""
+ if eventDict['event'] == 'mouseMoved':
+ activeImage = self.getActiveImage()
+ if activeImage is not None:
+ data = activeImage.getData()
+ height, width = data.shape
+
+ # Get corresponding coordinate in image
+ origin = activeImage.getOrigin()
+ scale = activeImage.getScale()
+ x = int((eventDict['x'] - origin[0]) / scale[0])
+ y = int((eventDict['y'] - origin[1]) / scale[1])
+
+ if 0 <= x < width and 0 <= y < height:
+ self.valueChanged.emit(float(x), float(y),
+ data[y][x])
+ else:
+ self.valueChanged.emit(float(x), float(y),
+ None)
+
+ def __setPerspective(self, perspective):
+ """Function called when the browsed/orthogonal dimension changes.
+ Updates :attr:`_perspective`, transposes data, updates the plot,
+ emits :attr:`sigPlaneSelectionChanged` and :attr:`sigStackChanged`.
+
+ :param int perspective: the new browsed dimension
+ """
+ if perspective == self._perspective:
+ return
+ else:
+ if perspective > 2 or perspective < 0:
+ raise ValueError(
+ "Perspective must be 0, 1 or 2, not %s" % perspective)
+
+ self._perspective = perspective
+ self.__createTransposedView()
+ self.__updateFrameNumber(self._browser.value())
+ self._plot.resetZoom()
+ self.__updatePlotLabels()
+ self._browser_label.setText("Image index (Dim%d):" %
+ (self._first_stack_dimension + perspective))
+
+ self.sigPlaneSelectionChanged.emit(perspective)
+ self.sigStackChanged.emit(self._stack.size if
+ self._stack is not None else 0)
+
+ def __updatePlotLabels(self):
+ """Update plot axes labels depending on perspective"""
+ y, x = (1, 2) if self._perspective == 0 else \
+ (0, 2) if self._perspective == 1 else (0, 1)
+ self.setGraphXLabel(self.__dimensionsLabels[x])
+ self.setGraphYLabel(self.__dimensionsLabels[y])
+
+ def __createTransposedView(self):
+ """Create the new view on the stack depending on the perspective
+ (set orthogonal axis browsed on the viewer as first dimension)
+ """
+ assert self._stack is not None
+ assert 0 <= self._perspective < 3
+
+ # ensure we have the stack encapsulated in an array like object
+ # having a transpose() method
+ if isinstance(self._stack, numpy.ndarray):
+ self.__transposed_view = self._stack
+
+ elif h5py is not None and isinstance(self._stack, h5py.Dataset) or \
+ isinstance(self._stack, DatasetView):
+ self.__transposed_view = DatasetView(self._stack)
+
+ elif isinstance(self._stack, ListOfImages):
+ self.__transposed_view = ListOfImages(self._stack)
+
+ # transpose the array like object if necessary
+ if self._perspective == 1:
+ self.__transposed_view = self.__transposed_view.transpose((1, 0, 2))
+ elif self._perspective == 2:
+ self.__transposed_view = self.__transposed_view.transpose((2, 0, 1))
+
+ self._browser.setRange(0, self.__transposed_view.shape[0] - 1)
+ self._browser.setValue(0)
+
+ def setFrameNumber(self, number):
+ """Set the frame selection to a specific value\
+
+ :param int number: Number of the frame
+ """
+ self._browser.setValue(number)
+
+ def __updateFrameNumber(self, index):
+ """Update the current image.
+
+ :param index: index of the frame to be displayed
+ """
+ assert self.__transposed_view is not None
+ self._plot.addImage(self.__transposed_view[index, :, :],
+ origin=self._getImageOrigin(),
+ scale=self._getImageScale(),
+ legend=self.__imageLegend,
+ resetzoom=False, replace=False)
+ self._plot.setGraphTitle("Image z=%g" % self._getImageZ(index))
+
+ def _set3DScaleAndOrigin(self, calibrations):
+ """Set scale and origin for all 3 axes, to be used when plotting
+ an image.
+
+ See setStack for parameter documentation
+ """
+ if calibrations is None:
+ self.calibrations3D = (calibration.NoCalibration(),
+ calibration.NoCalibration(),
+ calibration.NoCalibration())
+ else:
+ self.calibrations3D = []
+ for calib in calibrations:
+ if hasattr(calib, "__len__") and len(calib) == 2:
+ calib = calibration.LinearCalibration(calib[0], calib[1])
+ elif calib is None:
+ calib = calibration.NoCalibration()
+ elif not isinstance(calib, calibration.AbstractCalibration):
+ raise TypeError("calibration must be a 2-tuple, None or" +
+ " an instance of an AbstractCalibration " +
+ "subclass")
+ self.calibrations3D.append(calib)
+
+ def _getXYZCalibs(self):
+ xy_dims = [0, 1, 2]
+ xy_dims.remove(self._perspective)
+
+ xcalib = self.calibrations3D[max(xy_dims)]
+ ycalib = self.calibrations3D[min(xy_dims)]
+ zcalib = self.calibrations3D[self._perspective]
+
+ return xcalib, ycalib, zcalib
+
+ def _getImageScale(self):
+ """
+ :return: 2-tuple (XScale, YScale) for current image view
+ """
+ xcalib, ycalib, _zcalib = self._getXYZCalibs()
+ return xcalib.get_slope(), ycalib.get_slope()
+
+ def _getImageOrigin(self):
+ """
+ :return: 2-tuple (XOrigin, YOrigin) for current image view
+ """
+ xcalib, ycalib, _zcalib = self._getXYZCalibs()
+ return xcalib(0), ycalib(0)
+
+ def _getImageZ(self, index):
+ """
+ :param idx: 0-based image index in the stack
+ :return: calibrated Z value corresponding to the image idx
+ """
+ _xcalib, _ycalib, zcalib = self._getXYZCalibs()
+ return zcalib(index)
+
+ # public API
+ def setStack(self, stack, perspective=0, reset=True, calibrations=None):
+ """Set the 3D stack.
+
+ The perspective parameter is used to define which dimension of the 3D
+ array is to be used as frame index. The lowest remaining dimension
+ number is the row index of the displayed image (Y axis), and the highest
+ remaining dimension is the column index (X axis).
+
+ :param stack: 3D stack, or `None` to clear plot.
+ :type stack: 3D numpy.ndarray, or 3D h5py.Dataset, or list/tuple of 2D
+ numpy arrays, or None.
+ :param int perspective: Dimension for the frame index: 0, 1 or 2.
+ By default, the dimension for the image index is the first
+ dimension of the 3D stack (``perspective=0``).
+ :param bool reset: Whether to reset zoom or not.
+ :param calibrations: Sequence of 3 calibration objects for each axis.
+ These objects can be a subclass of :class:`AbstractCalibration`,
+ or 2-tuples *(a, b)* where *a* is the y-intercept and *b* is the
+ slope of a linear calibration (:math:`x \mapsto a + b x`)
+ """
+ if stack is None:
+ self.clear()
+ self.sigStackChanged.emit(0)
+ return
+
+ self._set3DScaleAndOrigin(calibrations)
+
+ # stack as list of 2D arrays: must be converted into an array_like
+ if not isinstance(stack, numpy.ndarray):
+ if h5py is None or not isinstance(stack, h5py.Dataset):
+ try:
+ assert hasattr(stack, "__len__")
+ for img in stack:
+ assert hasattr(img, "shape")
+ assert len(img.shape) == 2
+ except AssertionError:
+ raise ValueError(
+ "Stack must be a 3D array/dataset or a list of " +
+ "2D arrays.")
+ stack = ListOfImages(stack)
+
+ assert len(stack.shape) == 3, "data must be 3D"
+
+ self._stack = stack
+ self.__createTransposedView()
+
+ if perspective != self._perspective:
+ self.__setPerspective(perspective)
+
+ # This call to setColormap redefines the meaning of autoscale
+ # for 3D volume: take global min/max rather than frame min/max
+ if self.__autoscaleCmap:
+ self.setColormap(autoscale=True)
+
+ # init plot
+ self._plot.addImage(self.__transposed_view[0, :, :],
+ legend=self.__imageLegend,
+ colormap=self.getColormap(),
+ origin=self._getImageOrigin(),
+ scale=self._getImageScale(),
+ resetzoom=False)
+ self._plot.setActiveImage(self.__imageLegend)
+ self._plot.setGraphTitle("Image z=%g" % self._getImageZ(0))
+ self.__updatePlotLabels()
+
+ if reset:
+ self._plot.resetZoom()
+
+ # enable and init browser
+ self._browser.setEnabled(True)
+
+ if perspective != self._perspective:
+ self.__planeSelection.setPerspective(perspective)
+ # this causes self.__setPerspective to be called, which emits
+ # sigStackChanged and sigPlaneSelectionChanged
+
+ else:
+ self.sigStackChanged.emit(stack.size)
+
+ def getStack(self, copy=True, returnNumpyArray=False):
+ """Get the original stack, as a 3D array or dataset.
+
+ The output has the form: [data, params]
+ where params is a dictionary containing display parameters.
+
+ :param bool copy: If True (default), then the object is copied
+ and returned as a numpy array.
+ Else, a reference to original data is returned, if possible.
+ If the original data is not a numpy array and parameter
+ returnNumpyArray is True, a copy will be made anyway.
+ :param bool returnNumpyArray: If True, the returned object is
+ guaranteed to be a numpy array.
+ :return: 3D stack and parameters.
+ :rtype: (numpy.ndarray, dict)
+ """
+ image = self.getActiveImage()
+ if image is None:
+ return None
+
+ if isinstance(image, items.ColormapMixIn):
+ colormap = image.getColormap()
+ else:
+ colormap = None
+
+ params = {
+ 'info': image.getInfo(),
+ 'origin': image.getOrigin(),
+ 'scale': image.getScale(),
+ 'z': image.getZValue(),
+ 'selectable': image.isSelectable(),
+ 'draggable': image.isDraggable(),
+ 'colormap': colormap,
+ 'xlabel': image.getXLabel(),
+ 'ylabel': image.getYLabel(),
+ }
+ if returnNumpyArray or copy:
+ return numpy.array(self._stack, copy=copy), params
+
+ # if a list of 2D arrays was cast into a ListOfImages,
+ # return the original list
+ if isinstance(self._stack, ListOfImages):
+ return self._stack.images, params
+
+ return self._stack, params
+
+ def getCurrentView(self, copy=True, returnNumpyArray=False):
+ """Get the stack, as it is currently displayed.
+
+ The first index of the returned stack is always the frame
+ index. If the perspective has been changed in the widget since the
+ data was first loaded, this will be reflected in the order of the
+ dimensions of the returned object.
+
+ The output has the form: [data, params]
+ where params is a dictionary containing display parameters.
+
+ :param bool copy: If True (default), then the object is copied
+ and returned as a numpy array.
+ Else, a reference to original data is returned, if possible.
+ If the original data is not a numpy array and parameter
+ `returnNumpyArray` is `True`, a copy will be made anyway.
+ :param bool returnNumpyArray: If `True`, the returned object is
+ guaranteed to be a numpy array.
+ :return: 3D stack and parameters.
+ :rtype: (numpy.ndarray, dict)
+ """
+ image = self.getActiveImage()
+ if image is None:
+ return None
+
+ if isinstance(image, items.ColormapMixIn):
+ colormap = image.getColormap()
+ else:
+ colormap = None
+
+ params = {
+ 'info': image.getInfo(),
+ 'origin': image.getOrigin(),
+ 'scale': image.getScale(),
+ 'z': image.getZValue(),
+ 'selectable': image.isSelectable(),
+ 'draggable': image.isDraggable(),
+ 'colormap': colormap,
+ 'xlabel': image.getXLabel(),
+ 'ylabel': image.getYLabel(),
+ }
+ if returnNumpyArray or copy:
+ return numpy.array(self.__transposed_view, copy=copy), params
+ return self.__transposed_view, params
+
+ def getActiveImage(self, just_legend=False):
+ """Returns the currently active image object.
+
+ It returns None in case of not having an active image.
+
+ :param bool just_legend: True to get the legend of the image,
+ False (the default) to get the image data and info.
+ Note: :class:`StackView` uses the same legend for all frames.
+ :return: legend or image object
+ :rtype: str or list or None
+ """
+ return self._plot.getActiveImage(just_legend=just_legend)
+
+ def clear(self):
+ """Clear the widget:
+
+ - clear the plot
+ - clear the loaded data volume
+ """
+ self._stack = None
+ self.__transposed_view = None
+ self._perspective = 0
+ self._browser.setEnabled(False)
+ self._plot.clear()
+
+ def resetZoom(self):
+ """Reset the plot limits to the bounds of the data and redraw the plot.
+ """
+ self._plot.resetZoom()
+
+ def getGraphTitle(self):
+ """Return the plot main title as a str."""
+ return self._plot.getGraphTitle()
+
+ def setGraphTitle(self, title=""):
+ """Set the plot main title.
+
+ :param str title: Main title of the plot (default: '')
+ """
+ return self._plot.setGraphTitle(title)
+
+ def setLabels(self, labels=None):
+ """Set the labels to be displayed on the plot axes.
+
+ You must provide a sequence of 3 strings, corresponding to the 3
+ dimensions of the original data volume.
+ The proper label will automatically be selected for each plot axis
+ when the volume is rotated (when different axes are selected as the
+ X and Y axes).
+
+ :param list(str) labels: 3 labels corresponding to the 3 dimensions
+ of the data volumes.
+ """
+
+ default_labels = ["Dimension %d" % self._first_stack_dimension,
+ "Dimension %d" % (self._first_stack_dimension + 1),
+ "Dimension %d" % (self._first_stack_dimension + 2)]
+ if labels is None:
+ new_labels = default_labels
+ else:
+ # filter-out None
+ new_labels = []
+ for i, label in enumerate(labels):
+ new_labels.append(label or default_labels[i])
+
+ self.__dimensionsLabels = new_labels
+ self.__updatePlotLabels()
+
+ def getGraphXLabel(self):
+ """Return the current horizontal axis label as a str."""
+ return self._plot.getGraphXLabel()
+
+ def setGraphXLabel(self, label=None):
+ """Set the plot horizontal axis label.
+
+ :param str label: The horizontal axis label
+ """
+ if label is None:
+ label = self.__dimensionsLabels[1 if self._perspective == 2 else 2]
+ self._plot.setGraphXLabel(label)
+
+ def getGraphYLabel(self, axis='left'):
+ """Return the current vertical axis label as a str.
+
+ :param str axis: The Y axis for which to get the label (left or right)
+ """
+ return self._plot.getGraphYLabel(axis)
+
+ def setGraphYLabel(self, label=None, axis='left'):
+ """Set the vertical axis label on the plot.
+
+ :param str label: The Y axis label
+ :param str axis: The Y axis for which to set the label (left or right)
+ """
+ if label is None:
+ label = self.__dimensionsLabels[1 if self._perspective == 0 else 0]
+ self._plot.setGraphYLabel(label, axis)
+
+ def setYAxisInverted(self, flag=True):
+ """Set the Y axis orientation.
+
+ :param bool flag: True for Y axis going from top to bottom,
+ False for Y axis going from bottom to top
+ """
+ self._plot.setYAxisInverted(flag)
+
+ def isYAxisInverted(self):
+ """Return True if Y axis goes from top to bottom, False otherwise."""
+ return self._backend.isYAxisInverted()
+
+ def getSupportedColormaps(self):
+ """Get the supported colormap names as a tuple of str.
+
+ The list should at least contain and start by:
+ ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+ """
+ return self._plot.getSupportedColormaps()
+
+ def getColormap(self):
+ """Get the current colormap description.
+
+ :return: A description of the current colormap.
+ See :meth:`setColormap` for details.
+ :rtype: dict
+ """
+ # "default" colormap used by addImage when image is added without
+ # specifying a special colormap
+ return self._plot.getDefaultColormap()
+
+ def setColormap(self, colormap=None, normalization=None,
+ autoscale=None, vmin=None, vmax=None, colors=None):
+ """Set the colormap and update active image.
+
+ Parameters that are not provided are taken from the current colormap.
+
+ The colormap parameter can also be a dict with the following keys:
+
+ - *name*: string. The colormap to use:
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
+ - *normalization*: string. The mapping to use for the colormap:
+ either 'linear' or 'log'.
+ - *autoscale*: bool. Whether to use autoscale (True) or range
+ provided by keys
+ 'vmin' and 'vmax' (False).
+ - *vmin*: float. The minimum value of the range to use if 'autoscale'
+ is False.
+ - *vmax*: float. The maximum value of the range to use if 'autoscale'
+ is False.
+ - *colors*: optional. Nx3 or Nx4 array of float in [0, 1] or uint8.
+ List of RGB or RGBA colors to use (only if name is None)
+
+ :param colormap: Name of the colormap in
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
+ Or the description of the colormap as a dict.
+ :type colormap: dict or str.
+ :param str normalization: Colormap mapping: 'linear' or 'log'.
+ :param bool autoscale: Whether to use autoscale or [vmin, vmax] range.
+ Default value of autoscale is True if data is a numpy array,
+ False if data is a h5py dataset.
+ :param float vmin: The minimum value of the range to use if
+ 'autoscale' is False.
+ :param float vmax: The maximum value of the range to use if
+ 'autoscale' is False.
+ :param numpy.ndarray colors: Only used if name is None.
+ Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays
+ """
+ cmapDict = self.getColormap()
+
+ if isinstance(colormap, dict):
+ # Support colormap parameter as a dict
+ errmsg = "If colormap is provided as a dict, all other parameters"
+ errmsg += " must not be specified when calling setColormap"
+ assert normalization is None, errmsg
+ assert autoscale is None, errmsg
+ assert vmin is None, errmsg
+ assert vmax is None, errmsg
+ assert colors is None, errmsg
+ cmapDict.update(colormap)
+
+ else:
+ if colormap is not None:
+ cmapDict['name'] = colormap
+ if normalization is not None:
+ cmapDict['normalization'] = normalization
+ if colors is not None:
+ cmapDict['colors'] = colors
+
+ # Default meaning of autoscale is to reset min and max
+ # each time a new image is added to the plot.
+ # We want to use min and max of global volume,
+ # and not change them when browsing slides
+ cmapDict['autoscale'] = False
+
+ if autoscale is None:
+ # set default
+ autoscale = False
+ # TODO: assess cost of computing min/max for large 3D array
+ # if isinstance(self._stack, numpy.ndarray):
+ # autoscale = True
+ # else: # h5py.Dataset
+ # autoscale = False
+ elif autoscale and isinstance(self._stack, h5py.Dataset):
+ # h5py dataset has no min()/max() methods
+ raise RuntimeError(
+ "Cannot auto-scale colormap for a h5py dataset")
+ else:
+ autoscale = autoscale
+ self.__autoscaleCmap = autoscale
+ if autoscale and (self._stack is not None):
+ cmapDict['vmin'] = self._stack.min()
+ cmapDict['vmax'] = self._stack.max()
+ else:
+ if vmin is not None:
+ cmapDict['vmin'] = vmin
+ if vmax is not None:
+ cmapDict['vmax'] = vmax
+
+ cursorColor = cursorColorForColormap(cmapDict['name'])
+ self._plot.setInteractiveMode('zoom', color=cursorColor)
+
+ self._plot.setDefaultColormap(cmapDict)
+
+ # Update active image colormap
+ activeImage = self._plot.getActiveImage()
+ if isinstance(activeImage, items.ColormapMixIn):
+ activeImage.setColormap(self.getColormap())
+
+ def isKeepDataAspectRatio(self):
+ """Returns whether the plot is keeping data aspect ratio or not."""
+ return self._plot.isKeepDataAspectRatio()
+
+ def setKeepDataAspectRatio(self, flag=True):
+ """Set whether the plot keeps data aspect ratio or not.
+
+ :param bool flag: True to respect data aspect ratio
+ """
+ self._plot.setKeepDataAspectRatio(flag)
+
+ def getProfileToolbar(self):
+ """Profile tools attached to this plot
+
+ See :class:`silx.gui.plot.Profile.Profile3DToolBar`
+ """
+ return self._plot.profile
+
+ def getProfileWindow1D(self):
+ """Plot window used to display 1D profile curve.
+
+ :return: :class:`Plot1D`
+ """
+ return self._plot.profile.getProfileWindow1D()
+
+ def getProfileWindow2D(self):
+ """Plot window used to display 2D profile image.
+
+ :return: :class:`Plot2D`
+ """
+ return self._plot.profile.getProfileWindow2D()
+
+ # kind of private methods, but needed by Profile
+ def remove(self, legend=None,
+ kind=('curve', 'image', 'item', 'marker')):
+ """See :meth:`Plot.Plot.remove`"""
+ self._plot.remove(legend, kind)
+
+ def setInteractiveMode(self, *args, **kwargs):
+ """
+ See :meth:`Plot.Plot.setInteractiveMode`
+ """
+ self._plot.setInteractiveMode(*args, **kwargs)
+
+ def addItem(self, *args, **kwargs):
+ """
+ See :meth:`Plot.Plot.addItem`
+ """
+ self._plot.addItem(*args, **kwargs)
+
+ def setFirstStackDimension(self, first_stack_dimension):
+ """When viewing the last 3 dimensions of an n-D array (n>3), you can
+ use this method to change the text in the combobox.
+
+ For instance, for a 7-D array, first stack dim is 4, so the default
+ "Dim1-Dim2" text should be replaced with "Dim5-Dim6" (dimensions
+ numbers are 0-based).
+
+ :param int first_stack_dim: First stack dimension (n-3) when viewing the
+ last 3 dimensions of an n-D array.
+ """
+ old_state = self.__planeSelection.blockSignals(True)
+ self.__planeSelection.setFirstStackDimension(first_stack_dimension)
+ self.__planeSelection.blockSignals(old_state)
+ self._first_stack_dimension = first_stack_dimension
+ self._browser_label.setText("Image index (Dim%d):" % first_stack_dimension)
+
+
+class PlanesWidget(qt.QWidget):
+ """Widget for the plane/perspective selection
+
+ :param parent: the parent QWidget
+ """
+ sigPlaneSelectionChanged = qt.Signal(int)
+
+ def __init__(self, parent):
+ super(PlanesWidget, self).__init__(parent)
+
+ self.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum)
+ layout0 = qt.QHBoxLayout()
+ self.setLayout(layout0)
+ layout0.setContentsMargins(0, 0, 0, 0)
+
+ layout0.addWidget(qt.QLabel("Axes selection:"))
+
+ # By default, the first dimension (dim0) is the frame index/depth/z,
+ # the second dimension is the image row number/y axis
+ # and the third dimension is the image column index/x axis
+
+ # 1
+ # | 0
+ # |/__2
+ self.qcbAxisSelection = qt.QComboBox(self)
+ self._setCBChoices(first_stack_dimension=0)
+ self.qcbAxisSelection.currentIndexChanged[int].connect(
+ self.__planeSelectionChanged)
+
+ layout0.addWidget(self.qcbAxisSelection)
+
+ def __planeSelectionChanged(self, idx):
+ """Callback function when the combobox selection changes
+
+ idx is the dimension number orthogonal to the slice plane,
+ following the convention:
+
+ - slice plane Dim1-Dim2: perspective 0
+ - slice plane Dim0-Dim2: perspective 1
+ - slice plane Dim0-Dim1: perspective 2
+ """
+ self.sigPlaneSelectionChanged.emit(idx)
+
+ def _setCBChoices(self, first_stack_dimension):
+ self.qcbAxisSelection.clear()
+
+ dim1dim2 = 'Dim%d-Dim%d' % (first_stack_dimension + 1,
+ first_stack_dimension + 2)
+ dim0dim2 = 'Dim%d-Dim%d' % (first_stack_dimension,
+ first_stack_dimension + 2)
+ dim0dim1 = 'Dim%d-Dim%d' % (first_stack_dimension,
+ first_stack_dimension + 1)
+
+ self.qcbAxisSelection.addItem(icons.getQIcon("cube-front"), dim1dim2)
+ self.qcbAxisSelection.addItem(icons.getQIcon("cube-bottom"), dim0dim2)
+ self.qcbAxisSelection.addItem(icons.getQIcon("cube-left"), dim0dim1)
+
+ def setFirstStackDimension(self, first_stack_dim):
+ """When viewing the last 3 dimensions of an n-D array (n>3), you can
+ use this method to change the text in the combobox.
+
+ For instance, for a 7-D array, first stack dim is 4, so the default
+ "Dim1-Dim2" text should be replaced with "Dim5-Dim6" (dimensions
+ numbers are 0-based).
+
+ :param int first_stack_dim: First stack dimension (n-3) when viewing the
+ last 3 dimensions of an n-D array.
+ """
+ self._setCBChoices(first_stack_dim)
+
+ def setPerspective(self, perspective):
+ """Update the combobox selection.
+
+ - slice plane Dim1-Dim2: perspective 0
+ - slice plane Dim0-Dim2: perspective 1
+ - slice plane Dim0-Dim1: perspective 2
+
+ :param perspective: Orthogonal dimension number (0, 1, or 2)
+ """
+ self.qcbAxisSelection.setCurrentIndex(perspective)
+
+
+class StackViewMainWindow(StackView):
+ """This class is a :class:`StackView` with a menu, an additional toolbar
+ to set the plot limits, and a status bar to display the value and 3D
+ index of the data samples hovered by the mouse cursor.
+
+ :param QWidget parent: Parent widget, or None
+ """
+ def __init__(self, parent=None):
+ self._dataInfo = None
+ super(StackViewMainWindow, self).__init__(parent)
+ self.setWindowFlags(qt.Qt.Window)
+
+ # Add toolbars and status bar
+ self.addToolBar(qt.Qt.BottomToolBarArea,
+ LimitsToolBar(plot=self._plot))
+
+ self.statusBar()
+
+ menu = self.menuBar().addMenu('File')
+ menu.addAction(self._plot.saveAction)
+ menu.addAction(self._plot.printAction)
+ menu.addSeparator()
+ action = menu.addAction('Quit')
+ action.triggered[bool].connect(qt.QApplication.instance().quit)
+
+ menu = self.menuBar().addMenu('Edit')
+ menu.addAction(self._plot.copyAction)
+ menu.addSeparator()
+ menu.addAction(self._plot.resetZoomAction)
+ menu.addAction(self._plot.colormapAction)
+ menu.addAction(PlotActions.KeepAspectRatioAction(self._plot, self))
+ menu.addAction(PlotActions.YAxisInvertedAction(self._plot, self))
+
+ menu = self.menuBar().addMenu('Profile')
+ menu.addAction(self._plot.profile.browseAction)
+ menu.addAction(self._plot.profile.hLineAction)
+ menu.addAction(self._plot.profile.vLineAction)
+ menu.addAction(self._plot.profile.lineAction)
+ menu.addSeparator()
+ menu.addAction(self._plot.profile.clearAction)
+ self._plot.profile.profile3dAction.computeProfileIn2D()
+ menu.addMenu(self._plot.profile.profile3dAction.menu())
+
+ # Connect to StackView's signal
+ self.valueChanged.connect(self._statusBarSlot)
+
+ def _statusBarSlot(self, x, y, value):
+ """Update status bar with coordinates/value from plots."""
+ # todo (after implementing calibration):
+ # - use floats for (x, y, z)
+ # - display both indices (dim0, dim1, dim2) and (x, y, z)
+ msg = "Cursor out of range"
+ if x is not None and y is not None:
+ img_idx = self._browser.value()
+
+ if self._perspective == 0:
+ dim0, dim1, dim2 = img_idx, int(y), int(x)
+ elif self._perspective == 1:
+ dim0, dim1, dim2 = int(y), img_idx, int(x)
+ elif self._perspective == 2:
+ dim0, dim1, dim2 = int(y), int(x), img_idx
+
+ msg = 'Position: (%d, %d, %d)' % (dim0, dim1, dim2)
+ if value is not None:
+ msg += ', Value: %g' % value
+ if self._dataInfo is not None:
+ msg = self._dataInfo + ', ' + msg
+
+ self.statusBar().showMessage(msg)
+
+ def setStack(self, stack, *args, **kwargs):
+ """Set the displayed stack.
+
+ See :meth:`StackView.setStack` for details.
+ """
+ if hasattr(stack, 'dtype') and hasattr(stack, 'shape'):
+ assert len(stack.shape) == 3
+ nframes, height, width = stack.shape
+ self._dataInfo = 'Data: %dx%dx%d (%s)' % (nframes, height, width,
+ str(stack.dtype))
+ self.statusBar().showMessage(self._dataInfo)
+ else:
+ self._dataInfo = None
+
+ # Set the new stack in StackView widget
+ super(StackViewMainWindow, self).setStack(stack, *args, **kwargs)
+ self.setStatusBar(None)
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
new file mode 100644
index 0000000..91bbe1c
--- /dev/null
+++ b/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -0,0 +1,1138 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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 is a collection of base classes used in modules
+:mod:`.MaskToolsWidget` (images) and :mod:`.ScatterMaskToolsWidget`
+"""
+from __future__ import division
+
+
+__authors__ = ["T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "20/04/2017"
+
+import os
+
+import numpy
+
+from silx.gui import qt, icons
+from silx.gui.plot.Colors import rgba
+
+
+class BaseMask(qt.QObject):
+ """Base class for :class:`ImageMask` and :class:`ScatterMask`
+
+ A mask field with update operations.
+
+ A mask is an array of the same shape as some underlying data. The mask
+ array stores integer values in the range 0-255, to allow for 254 levels
+ of mask (value 0 is reserved for unmasked data).
+
+ The mask is updated using spatial selection methods: data located inside
+ a selected area is masked with a specified mask level.
+
+ """
+
+ sigChanged = qt.Signal()
+ """Signal emitted when the mask has changed"""
+
+ sigUndoable = qt.Signal(bool)
+ """Signal emitted when undo becomes possible/impossible"""
+
+ sigRedoable = qt.Signal(bool)
+ """Signal emitted when redo becomes possible/impossible"""
+
+ def __init__(self, dataItem=None):
+ self.historyDepth = 10
+ """Maximum number of operation stored in history list for undo"""
+ # Init lists for undo/redo
+ self._history = []
+ self._redo = []
+
+ # Store the mask
+ self._mask = numpy.array((), dtype=numpy.uint8)
+
+ # Store the plot item to be masked
+ self._dataItem = None
+ if dataItem is not None:
+ self.setDataItem(dataItem)
+ self.reset(self.getDataValues().shape)
+
+ super(BaseMask, self).__init__()
+
+ def setDataItem(self, item):
+ """Set a data item
+
+ :param item: A plot item, subclass of :class:`silx.gui.plot.items.Item`
+ :return:
+ """
+ self._dataItem = item
+
+ def getDataValues(self):
+ """Return data values, as a numpy array with the same shape
+ as the mask.
+
+ This method must be implemented in a subclass, as the way of
+ accessing data depends on the data item passed to :meth:`setDataItem`
+
+ :return: Data values associated with the data item.
+ :rtype: numpy.ndarray
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def _notify(self):
+ """Notify of mask change."""
+ self.sigChanged.emit()
+
+ def getMask(self, copy=True):
+ """Get the current mask as a numpy array.
+
+ :param bool copy: True (default) to get a copy of the mask.
+ If False, the returned array MUST not be modified.
+ :return: The array of the mask with dimension of the data to be masked.
+ :rtype: numpy.ndarray of uint8
+ """
+ return numpy.array(self._mask, copy=copy)
+
+ def setMask(self, mask, copy=True):
+ """Set the mask to a new array.
+
+ :param numpy.ndarray mask: The array to use for the mask.
+ :type mask: numpy.ndarray of uint8, C-contiguous.
+ Array of other types are converted.
+ :param bool copy: True (the default) to copy the array,
+ False to use it as is if possible.
+ """
+ self._mask = numpy.array(mask, copy=copy, order='C', dtype=numpy.uint8)
+ self._notify()
+
+ # History control
+ def resetHistory(self):
+ """Reset history"""
+ self._history = [numpy.array(self._mask, copy=True)]
+ self._redo = []
+ self.sigUndoable.emit(False)
+ self.sigRedoable.emit(False)
+
+ def commit(self):
+ """Append the current mask to history if changed"""
+ if (not self._history or self._redo or
+ not numpy.all(numpy.equal(self._mask, self._history[-1]))):
+ if self._redo:
+ self._redo = [] # Reset redo as a new action as been performed
+ self.sigRedoable[bool].emit(False)
+
+ while len(self._history) >= self.historyDepth:
+ self._history.pop(0)
+ self._history.append(numpy.array(self._mask, copy=True))
+
+ if len(self._history) == 2:
+ self.sigUndoable.emit(True)
+
+ def undo(self):
+ """Restore previous mask if any"""
+ if len(self._history) > 1:
+ self._redo.append(self._history.pop())
+ self._mask = numpy.array(self._history[-1], copy=True)
+ self._notify() # Do not store this change in history
+
+ if len(self._redo) == 1: # First redo
+ self.sigRedoable.emit(True)
+ if len(self._history) == 1: # Last value in history
+ self.sigUndoable.emit(False)
+
+ def redo(self):
+ """Restore previously undone modification if any"""
+ if self._redo:
+ self._mask = self._redo.pop()
+ self._history.append(numpy.array(self._mask, copy=True))
+ self._notify()
+
+ if not self._redo: # No more redo
+ self.sigRedoable.emit(False)
+ if len(self._history) == 2: # Something to undo
+ self.sigUndoable.emit(True)
+
+ # Whole mask operations
+
+ def clear(self, level):
+ """Set all values of the given mask level to 0.
+
+ :param int level: Value of the mask to set to 0.
+ """
+ assert 0 < level < 256
+ self._mask[self._mask == level] = 0
+ self._notify()
+
+ def invert(self, level):
+ """Invert mask of the given mask level.
+
+ 0 values become level and level values become 0.
+
+ :param int level: The level to invert.
+ """
+ assert 0 < level < 256
+ masked = self._mask == level
+ self._mask[self._mask == 0] = level
+ self._mask[masked] = 0
+ self._notify()
+
+ def reset(self, shape=None):
+ """Reset the mask to zero and change its shape.
+
+ :param shape: Shape of the new mask with the correct dimensionality
+ with regards to the data dimensionality,
+ or None to have an empty mask
+ :type shape: tuple of int
+ """
+ if shape is None:
+ # assume dimensionality never changes
+ shape = (0, ) * len(self._mask.shape) # empty array
+ shapeChanged = (shape != self._mask.shape)
+ self._mask = numpy.zeros(shape, dtype=numpy.uint8)
+ if shapeChanged:
+ self.resetHistory()
+
+ self._notify()
+
+ # To be implemented
+ def save(self, filename, kind):
+ """Save current mask in a file
+
+ :param str filename: The file where to save to mask
+ :param str kind: The kind of file to save (e.g 'npy')
+ :raise Exception: Raised if the file writing fail
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ # update thresholds
+ def updateStencil(self, level, stencil, mask=True):
+ """Mask/Unmask points from boolean mask: all elements that are True
+ in the boolean mask are set to ``level`` (if ``mask=True``) or 0
+ (if ``mask=False``)
+
+ :param int level: Mask level to update.
+ :param stencil: Boolean mask.
+ :type stencil: numpy.array of same dimension as the mask
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ if mask:
+ self._mask[stencil] = level
+ else:
+ self._mask[numpy.logical_and(self._mask == level, stencil)] = 0
+ self._notify()
+
+ def updateBelowThreshold(self, level, threshold, mask=True):
+ """Mask/unmask all points whose values are below a threshold.
+
+ :param int level:
+ :param float threshold: Threshold
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ self.updateStencil(level,
+ self.getDataValues() < threshold,
+ mask)
+
+ def updateBetweenThresholds(self, level, min_, max_, mask=True):
+ """Mask/unmask all points whose values are in a range.
+
+ :param int level:
+ :param float min_: Lower threshold
+ :param float max_: Upper threshold
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ stencil = numpy.logical_and(min_ <= self.getDataValues(),
+ self.getDataValues() <= max_)
+ self.updateStencil(level, stencil, mask)
+
+ def updateAboveThreshold(self, level, threshold, mask=True):
+ """Mask/unmask all points whose values are above a threshold.
+
+ :param int level: Mask level to update.
+ :param float threshold: Threshold.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ self.updateStencil(level,
+ self.getDataValues() > threshold,
+ mask)
+
+ def updateNotFinite(self, level, mask=True):
+ """Mask/unmask all points whose values are not finite.
+
+ :param int level: Mask level to update.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ self.updateStencil(level,
+ numpy.logical_not(numpy.isfinite(self.getDataValues())),
+ mask)
+
+ # Drawing operations:
+ def updateRectangle(self, level, row, col, height, width, mask=True):
+ """Mask/Unmask data inside a rectangle, with the given mask level.
+
+ :param int level: Mask level to update, in range 1-255.
+ :param row: Starting row/y of the rectangle
+ :param col: Starting column/x of the rectangle
+ :param height:
+ :param width:
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updatePolygon(self, level, vertices, mask=True):
+ """Mask/Unmask data inside a polygon, with the given mask level.
+
+ :param int level: Mask level to update.
+ :param vertices: Nx2 array of polygon corners as (row, col) / (y, x)
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updatePoints(self, level, rows, cols, mask=True):
+ """Mask/Unmask points with given coordinates.
+
+ :param int level: Mask level to update.
+ :param rows: Rows/ordinates (y) of selected points
+ :type rows: 1D numpy.ndarray
+ :param cols: Columns/abscissa (x) of selected points
+ :type cols: 1D numpy.ndarray
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updateDisk(self, level, crow, ccol, radius, mask=True):
+ """Mask/Unmask data located inside a disk of the given mask level.
+
+ :param int level: Mask level to update.
+ :param crow: Disk center row/ordinate (y).
+ :param ccol: Disk center column/abscissa.
+ :param float radius: Radius of the disk in mask array unit
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updateLine(self, level, row0, col0, row1, col1, width, mask=True):
+ """Mask/Unmask a line of the given mask level.
+
+ :param int level: Mask level to update.
+ :param row0: Row/y of the starting point.
+ :param col0: Column/x of the starting point.
+ :param row1: Row/y of the end point.
+ :param col1: Column/x of the end point.
+ :param width: Width of the line in mask array unit.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+
+class BaseMaskToolsWidget(qt.QWidget):
+ """Base class for :class:`MaskToolsWidget` (image mask) and
+ :class:`scatterMaskToolsWidget`"""
+
+ sigMaskChanged = qt.Signal()
+ _maxLevelNumber = 255
+
+ def __init__(self, parent=None, plot=None):
+ # register if the user as force a color for the corresponding mask level
+ self._defaultColors = numpy.ones((self._maxLevelNumber + 1), dtype=numpy.bool)
+ # overlays colors set by the user
+ self._overlayColors = numpy.zeros((self._maxLevelNumber + 1, 3), dtype=numpy.float32)
+
+ self._plot = plot
+ self._maskName = '__MASK_TOOLS_%d' % id(self) # Legend of the mask
+
+ self._colormap = {
+ 'name': None,
+ 'normalization': 'linear',
+ 'autoscale': False,
+ 'vmin': 0, 'vmax': self._maxLevelNumber,
+ 'colors': None}
+ self._defaultOverlayColor = rgba('gray') # Color of the mask
+ self._setMaskColors(1, 0.5)
+
+ self._mask.sigChanged.connect(self._updatePlotMask)
+ self._mask.sigChanged.connect(self._emitSigMaskChanged)
+
+ self._drawingMode = None # Store current drawing mode
+ self._lastPencilPos = None
+ self._multipleMasks = 'exclusive'
+
+ super(BaseMaskToolsWidget, self).__init__(parent)
+
+ self._maskFileDir = qt.QDir.home().absolutePath()
+ self.plot.sigInteractiveModeChanged.connect(
+ self._interactiveModeChanged)
+
+ def _emitSigMaskChanged(self):
+ """Notify mask changes"""
+ self.sigMaskChanged.emit()
+
+ def getSelectionMask(self, copy=True):
+ """Get the current mask as a numpy array.
+
+ :param bool copy: True (default) to get a copy of the mask.
+ If False, the returned array MUST not be modified.
+ :return: The array of the mask with dimension of the 'active' plot item.
+ If there is no active image or scatter, an empty array is
+ returned.
+ :rtype: numpy.ndarray of uint8
+ """
+ return self._mask.getMask(copy=copy)
+
+ def multipleMasks(self):
+ """Return the current mode of multiple masks support.
+
+ See :meth:`setMultipleMasks`
+ """
+ return self._multipleMasks
+
+ def setMultipleMasks(self, mode):
+ """Set the mode of multiple masks support.
+
+ Available modes:
+
+ - 'single': Edit a single level of mask
+ - 'exclusive': Supports to 256 levels of non overlapping masks
+
+ :param str mode: The mode to use
+ """
+ assert mode in ('exclusive', 'single')
+ if mode != self._multipleMasks:
+ self._multipleMasks = mode
+ self.levelWidget.setVisible(self._multipleMasks != 'single')
+ self.clearAllBtn.setVisible(self._multipleMasks != 'single')
+
+ @property
+ def maskFileDir(self):
+ """The directory from which to load/save mask from/to files."""
+ if not os.path.isdir(self._maskFileDir):
+ self._maskFileDir = qt.QDir.home().absolutePath()
+ return self._maskFileDir
+
+ @maskFileDir.setter
+ def maskFileDir(self, maskFileDir):
+ self._maskFileDir = str(maskFileDir)
+
+ @property
+ def plot(self):
+ """The :class:`.PlotWindow` this widget is attached to."""
+ return self._plot
+
+ def setDirection(self, direction=qt.QBoxLayout.LeftToRight):
+ """Set the direction of the layout of the widget
+
+ :param direction: QBoxLayout direction
+ """
+ self.layout().setDirection(direction)
+
+ def _initWidgets(self):
+ """Create widgets"""
+ layout = qt.QBoxLayout(qt.QBoxLayout.LeftToRight)
+ layout.addWidget(self._initMaskGroupBox())
+ layout.addWidget(self._initDrawGroupBox())
+ layout.addWidget(self._initThresholdGroupBox())
+ layout.addStretch(1)
+ self.setLayout(layout)
+
+ @staticmethod
+ def _hboxWidget(*widgets, **kwargs):
+ """Place widgets in widget with horizontal layout
+
+ :param widgets: Widgets to position horizontally
+ :param bool stretch: True for trailing stretch (default),
+ False for no trailing stretch
+ :return: A QWidget with a QHBoxLayout
+ """
+ stretch = kwargs.get('stretch', True)
+
+ layout = qt.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ for widget in widgets:
+ layout.addWidget(widget)
+ if stretch:
+ layout.addStretch(1)
+ widget = qt.QWidget()
+ widget.setLayout(layout)
+ return widget
+
+ def _initTransparencyWidget(self):
+ """ Init the mask transparency widget """
+ transparencyWidget = qt.QWidget(self)
+ grid = qt.QGridLayout()
+ grid.setContentsMargins(0, 0, 0, 0)
+ self.transparencySlider = qt.QSlider(qt.Qt.Horizontal, parent=transparencyWidget)
+ self.transparencySlider.setRange(3, 10)
+ self.transparencySlider.setValue(8)
+ self.transparencySlider.setToolTip(
+ 'Set the transparency of the mask display')
+ self.transparencySlider.valueChanged.connect(self._updateColors)
+ grid.addWidget(qt.QLabel('Display:', parent=transparencyWidget), 0, 0)
+ grid.addWidget(self.transparencySlider, 0, 1, 1, 3)
+ grid.addWidget(qt.QLabel('<small><b>Transparent</b></small>', parent=transparencyWidget), 1, 1)
+ grid.addWidget(qt.QLabel('<small><b>Opaque</b></small>', parent=transparencyWidget), 1, 3)
+ transparencyWidget.setLayout(grid)
+ return transparencyWidget
+
+ def _initMaskGroupBox(self):
+ """Init general mask operation widgets"""
+
+ # Mask level
+ self.levelSpinBox = qt.QSpinBox()
+ self.levelSpinBox.setRange(1, self._maxLevelNumber)
+ self.levelSpinBox.setToolTip(
+ 'Choose which mask level is edited.\n'
+ 'A mask can have up to 255 non-overlapping levels.')
+ self.levelSpinBox.valueChanged[int].connect(self._updateColors)
+ self.levelWidget = self._hboxWidget(qt.QLabel('Mask level:'),
+ self.levelSpinBox)
+ # Transparency
+ self.transparencyWidget = self._initTransparencyWidget()
+
+ # Buttons group
+ invertBtn = qt.QPushButton('Invert')
+ invertBtn.setShortcut(qt.Qt.CTRL + qt.Qt.Key_I)
+ invertBtn.setToolTip('Invert current mask <b>%s</b>' %
+ invertBtn.shortcut().toString())
+ invertBtn.clicked.connect(self._handleInvertMask)
+
+ clearBtn = qt.QPushButton('Clear')
+ clearBtn.setShortcut(qt.QKeySequence.Delete)
+ clearBtn.setToolTip('Clear current mask level <b>%s</b>' %
+ clearBtn.shortcut().toString())
+ clearBtn.clicked.connect(self._handleClearMask)
+
+ invertClearWidget = self._hboxWidget(
+ invertBtn, clearBtn, stretch=False)
+
+ undoBtn = qt.QPushButton('Undo')
+ undoBtn.setShortcut(qt.QKeySequence.Undo)
+ undoBtn.setToolTip('Undo last mask change <b>%s</b>' %
+ undoBtn.shortcut().toString())
+ self._mask.sigUndoable.connect(undoBtn.setEnabled)
+ undoBtn.clicked.connect(self._mask.undo)
+
+ redoBtn = qt.QPushButton('Redo')
+ redoBtn.setShortcut(qt.QKeySequence.Redo)
+ redoBtn.setToolTip('Redo last undone mask change <b>%s</b>' %
+ redoBtn.shortcut().toString())
+ self._mask.sigRedoable.connect(redoBtn.setEnabled)
+ redoBtn.clicked.connect(self._mask.redo)
+
+ undoRedoWidget = self._hboxWidget(undoBtn, redoBtn, stretch=False)
+
+ self.clearAllBtn = qt.QPushButton('Clear all')
+ self.clearAllBtn.setToolTip('Clear all mask levels')
+ self.clearAllBtn.clicked.connect(self.resetSelectionMask)
+
+ loadBtn = qt.QPushButton('Load...')
+ loadBtn.clicked.connect(self._loadMask)
+
+ saveBtn = qt.QPushButton('Save...')
+ saveBtn.clicked.connect(self._saveMask)
+
+ self.loadSaveWidget = self._hboxWidget(loadBtn, saveBtn, stretch=False)
+
+ layout = qt.QVBoxLayout()
+ layout.addWidget(self.levelWidget)
+ layout.addWidget(self.transparencyWidget)
+ layout.addWidget(invertClearWidget)
+ layout.addWidget(undoRedoWidget)
+ layout.addWidget(self.clearAllBtn)
+ layout.addWidget(self.loadSaveWidget)
+ layout.addStretch(1)
+
+ maskGroup = qt.QGroupBox('Mask')
+ maskGroup.setLayout(layout)
+ return maskGroup
+
+ def _initDrawGroupBox(self):
+ """Init drawing tools widgets"""
+ layout = qt.QVBoxLayout()
+
+ # Draw tools
+ self.browseAction = qt.QAction(
+ icons.getQIcon('normal'), 'Browse', None)
+ self.browseAction.setShortcut(qt.QKeySequence(qt.Qt.Key_B))
+ self.browseAction.setToolTip(
+ 'Disables drawing tools, enables zooming interaction mode'
+ ' <b>B</b>')
+ self.browseAction.setCheckable(True)
+ self.browseAction.triggered.connect(self._activeBrowseMode)
+ self.addAction(self.browseAction)
+
+ self.rectAction = qt.QAction(
+ icons.getQIcon('shape-rectangle'), 'Rectangle selection', None)
+ self.rectAction.setToolTip(
+ 'Rectangle selection tool: (Un)Mask a rectangular region <b>R</b>')
+ self.rectAction.setShortcut(qt.QKeySequence(qt.Qt.Key_R))
+ self.rectAction.setCheckable(True)
+ self.rectAction.triggered.connect(self._activeRectMode)
+ self.addAction(self.rectAction)
+
+ self.polygonAction = qt.QAction(
+ icons.getQIcon('shape-polygon'), 'Polygon selection', None)
+ self.polygonAction.setShortcut(qt.QKeySequence(qt.Qt.Key_S))
+ self.polygonAction.setToolTip(
+ 'Polygon selection tool: (Un)Mask a polygonal region <b>S</b><br>'
+ 'Left-click to place polygon corners<br>'
+ 'Right-click to place the last corner')
+ self.polygonAction.setCheckable(True)
+ self.polygonAction.triggered.connect(self._activePolygonMode)
+ self.addAction(self.polygonAction)
+
+ self.pencilAction = qt.QAction(
+ icons.getQIcon('draw-pencil'), 'Pencil tool', None)
+ self.pencilAction.setShortcut(qt.QKeySequence(qt.Qt.Key_P))
+ self.pencilAction.setToolTip(
+ 'Pencil tool: (Un)Mask using a pencil <b>P</b>')
+ self.pencilAction.setCheckable(True)
+ self.pencilAction.triggered.connect(self._activePencilMode)
+ self.addAction(self.polygonAction)
+
+ self.drawActionGroup = qt.QActionGroup(self)
+ self.drawActionGroup.setExclusive(True)
+ self.drawActionGroup.addAction(self.browseAction)
+ self.drawActionGroup.addAction(self.rectAction)
+ self.drawActionGroup.addAction(self.polygonAction)
+ self.drawActionGroup.addAction(self.pencilAction)
+
+ self.browseAction.setChecked(True)
+
+ self.drawButtons = {}
+ for action in self.drawActionGroup.actions():
+ btn = qt.QToolButton()
+ btn.setDefaultAction(action)
+ self.drawButtons[action.text()] = btn
+ container = self._hboxWidget(*self.drawButtons.values())
+ layout.addWidget(container)
+
+ # Mask/Unmask radio buttons
+ maskRadioBtn = qt.QRadioButton('Mask')
+ maskRadioBtn.setToolTip(
+ 'Drawing masks with current level. Press <b>Ctrl</b> to unmask')
+ maskRadioBtn.setChecked(True)
+
+ unmaskRadioBtn = qt.QRadioButton('Unmask')
+ unmaskRadioBtn.setToolTip(
+ 'Drawing unmasks with current level. Press <b>Ctrl</b> to mask')
+
+ self.maskStateGroup = qt.QButtonGroup()
+ self.maskStateGroup.addButton(maskRadioBtn, 1)
+ self.maskStateGroup.addButton(unmaskRadioBtn, 0)
+
+ self.maskStateWidget = self._hboxWidget(maskRadioBtn, unmaskRadioBtn)
+ layout.addWidget(self.maskStateWidget)
+
+ # Connect mask state widget visibility with browse action
+ self.maskStateWidget.setHidden(self.browseAction.isChecked())
+ self.browseAction.toggled[bool].connect(
+ self.maskStateWidget.setHidden)
+
+ # Pencil settings
+ self.pencilSetting = self._createPencilSettings(None)
+ self.pencilSetting.setVisible(False)
+ layout.addWidget(self.pencilSetting)
+
+ layout.addStretch(1)
+
+ drawGroup = qt.QGroupBox('Draw tools')
+ drawGroup.setLayout(layout)
+ return drawGroup
+
+ def _createPencilSettings(self, parent=None):
+ pencilSetting = qt.QWidget(parent)
+
+ self.pencilSpinBox = qt.QSpinBox(parent=pencilSetting)
+ self.pencilSpinBox.setRange(1, 1024)
+ pencilToolTip = """Set pencil drawing tool size in pixels of the image
+ on which to make the mask."""
+ self.pencilSpinBox.setToolTip(pencilToolTip)
+
+ self.pencilSlider = qt.QSlider(qt.Qt.Horizontal, parent=pencilSetting)
+ self.pencilSlider.setRange(1, 50)
+ self.pencilSlider.setToolTip(pencilToolTip)
+
+ pencilLabel = qt.QLabel('Pencil size:', parent=pencilSetting)
+
+ layout = qt.QGridLayout()
+ layout.addWidget(pencilLabel, 0, 0)
+ layout.addWidget(self.pencilSpinBox, 0, 1)
+ layout.addWidget(self.pencilSlider, 1, 1)
+ pencilSetting.setLayout(layout)
+
+ self.pencilSpinBox.valueChanged.connect(self._pencilWidthChanged)
+ self.pencilSlider.valueChanged.connect(self._pencilWidthChanged)
+
+ return pencilSetting
+
+ def _initThresholdGroupBox(self):
+ """Init thresholding widgets"""
+ layout = qt.QVBoxLayout()
+
+ # Thresholing
+
+ self.belowThresholdAction = qt.QAction(
+ icons.getQIcon('plot-roi-below'), 'Mask below threshold', None)
+ self.belowThresholdAction.setToolTip(
+ 'Mask image where values are below given threshold')
+ self.belowThresholdAction.setCheckable(True)
+ self.belowThresholdAction.triggered[bool].connect(
+ self._belowThresholdActionTriggered)
+
+ self.betweenThresholdAction = qt.QAction(
+ icons.getQIcon('plot-roi-between'), 'Mask within range', None)
+ self.betweenThresholdAction.setToolTip(
+ 'Mask image where values are within given range')
+ self.betweenThresholdAction.setCheckable(True)
+ self.betweenThresholdAction.triggered[bool].connect(
+ self._betweenThresholdActionTriggered)
+
+ self.aboveThresholdAction = qt.QAction(
+ icons.getQIcon('plot-roi-above'), 'Mask above threshold', None)
+ self.aboveThresholdAction.setToolTip(
+ 'Mask image where values are above given threshold')
+ self.aboveThresholdAction.setCheckable(True)
+ self.aboveThresholdAction.triggered[bool].connect(
+ self._aboveThresholdActionTriggered)
+
+ self.thresholdActionGroup = qt.QActionGroup(self)
+ self.thresholdActionGroup.setExclusive(False)
+ self.thresholdActionGroup.addAction(self.belowThresholdAction)
+ self.thresholdActionGroup.addAction(self.betweenThresholdAction)
+ self.thresholdActionGroup.addAction(self.aboveThresholdAction)
+ self.thresholdActionGroup.triggered.connect(
+ self._thresholdActionGroupTriggered)
+
+ self.loadColormapRangeAction = qt.QAction(
+ icons.getQIcon('view-refresh'), 'Set min-max from colormap', None)
+ self.loadColormapRangeAction.setToolTip(
+ 'Set min and max values from current colormap range')
+ self.loadColormapRangeAction.setCheckable(False)
+ self.loadColormapRangeAction.triggered.connect(
+ self._loadRangeFromColormapTriggered)
+
+ widgets = []
+ for action in self.thresholdActionGroup.actions():
+ btn = qt.QToolButton()
+ btn.setDefaultAction(action)
+ widgets.append(btn)
+
+ spacer = qt.QWidget()
+ spacer.setSizePolicy(qt.QSizePolicy.Expanding,
+ qt.QSizePolicy.Preferred)
+ widgets.append(spacer)
+
+ loadColormapRangeBtn = qt.QToolButton()
+ loadColormapRangeBtn.setDefaultAction(self.loadColormapRangeAction)
+ widgets.append(loadColormapRangeBtn)
+
+ container = self._hboxWidget(*widgets, stretch=False)
+ layout.addWidget(container)
+
+ form = qt.QFormLayout()
+
+ self.minLineEdit = qt.QLineEdit()
+ self.minLineEdit.setText('0')
+ self.minLineEdit.setValidator(qt.QDoubleValidator())
+ self.minLineEdit.setEnabled(False)
+ form.addRow('Min:', self.minLineEdit)
+
+ self.maxLineEdit = qt.QLineEdit()
+ self.maxLineEdit.setText('0')
+ self.maxLineEdit.setValidator(qt.QDoubleValidator())
+ self.maxLineEdit.setEnabled(False)
+ form.addRow('Max:', self.maxLineEdit)
+
+ self.applyMaskBtn = qt.QPushButton('Apply mask')
+ self.applyMaskBtn.clicked.connect(self._maskBtnClicked)
+ self.applyMaskBtn.setEnabled(False)
+ form.addRow(self.applyMaskBtn)
+
+ self.maskNanBtn = qt.QPushButton('Mask not finite values')
+ self.maskNanBtn.setToolTip('Mask Not a Number and infinite values')
+ self.maskNanBtn.clicked.connect(self._maskNotFiniteBtnClicked)
+ form.addRow(self.maskNanBtn)
+
+ thresholdWidget = qt.QWidget()
+ thresholdWidget.setLayout(form)
+ layout.addWidget(thresholdWidget)
+
+ layout.addStretch(1)
+
+ self.thresholdGroup = qt.QGroupBox('Threshold')
+ self.thresholdGroup.setLayout(layout)
+ return self.thresholdGroup
+
+ # track widget visibility and plot active image changes
+
+ def changeEvent(self, event):
+ """Reset drawing action when disabling widget"""
+ if (event.type() == qt.QEvent.EnabledChange and
+ not self.isEnabled() and
+ not self.browseAction.isChecked()):
+ self.browseAction.trigger() # Disable drawing tool
+
+ def save(self, filename, kind):
+ """Save current mask in a file
+
+ :param str filename: The file where to save to mask
+ :param str kind: The kind of file to save in 'edf', 'tif', 'npy'
+ :raise Exception: Raised if the process fails
+ """
+ self._mask.save(filename, kind)
+
+ def getCurrentMaskColor(self):
+ """Returns the color of the current selected level.
+
+ :rtype: A tuple or a python array
+ """
+ currentLevel = self.levelSpinBox.value()
+ if self._defaultColors[currentLevel]:
+ return self._defaultOverlayColor
+ else:
+ return self._overlayColors[currentLevel].tolist()
+
+ def _setMaskColors(self, level, alpha):
+ """Set-up the mask colormap to highlight current mask level.
+
+ :param int level: The mask level to highlight
+ :param float alpha: Alpha level of mask in [0., 1.]
+ """
+ assert 0 < level <= self._maxLevelNumber
+
+ colors = numpy.empty((self._maxLevelNumber + 1, 4), dtype=numpy.float32)
+
+ # Set color
+ colors[:, :3] = self._defaultOverlayColor[:3]
+
+ # check if some colors has been directly set by the user
+ mask = numpy.equal(self._defaultColors, False)
+ colors[mask, :3] = self._overlayColors[mask, :3]
+
+ # Set alpha
+ colors[:, -1] = alpha / 2.
+
+ # Set highlighted level color
+ colors[level, 3] = alpha
+
+ # Set no mask level
+ colors[0] = (0., 0., 0., 0.)
+
+ self._colormap['colors'] = colors
+
+ def resetMaskColors(self, level=None):
+ """Reset the mask color at the given level to be defaultColors
+
+ :param level:
+ The index of the mask for which we want to reset the color.
+ If none we will reset color for all masks.
+ """
+ if level is None:
+ self._defaultColors[level] = True
+ else:
+ self._defaultColors[:] = True
+
+ self._updateColors()
+
+ def setMaskColors(self, rgb, level=None):
+ """Set the masks color
+
+ :param rgb: The rgb color
+ :param level:
+ The index of the mask for which we want to change the color.
+ If none set this color for all the masks
+ """
+ if level is None:
+ self._overlayColors[:] = rgb
+ self._defaultColors[:] = False
+ else:
+ self._overlayColors[level] = rgb
+ self._defaultColors[level] = False
+
+ self._updateColors()
+
+ def getMaskColors(self):
+ """masks colors getter"""
+ return self._overlayColors
+
+ def _updateColors(self, *args):
+ """Rebuild mask colormap when selected level or transparency change"""
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+ self._updatePlotMask()
+ self._updateInteractiveMode()
+
+ def _pencilWidthChanged(self, width):
+
+ old = self.pencilSpinBox.blockSignals(True)
+ try:
+ self.pencilSpinBox.setValue(width)
+ finally:
+ self.pencilSpinBox.blockSignals(old)
+
+ old = self.pencilSlider.blockSignals(True)
+ try:
+ self.pencilSlider.setValue(width)
+ finally:
+ self.pencilSlider.blockSignals(old)
+ self._updateInteractiveMode()
+
+ def _updateInteractiveMode(self):
+ """Update the current mode to the same if some cached data have to be
+ updated. It is the case for the color for example.
+ """
+ if self._drawingMode == 'rectangle':
+ self._activeRectMode()
+ elif self._drawingMode == 'polygon':
+ self._activePolygonMode()
+ elif self._drawingMode == 'pencil':
+ self._activePencilMode()
+
+ def _handleClearMask(self):
+ """Handle clear button clicked: reset current level mask"""
+ self._mask.clear(self.levelSpinBox.value())
+ self._mask.commit()
+
+ def _handleInvertMask(self):
+ """Invert the current mask level selection."""
+ self._mask.invert(self.levelSpinBox.value())
+ self._mask.commit()
+
+ # Handle drawing tools UI events
+
+ def _interactiveModeChanged(self, source):
+ """Handle plot interactive mode changed:
+
+ If changed from elsewhere, disable drawing tool
+ """
+ if source is not self:
+ # Do not trigger browseAction to avoid to call
+ # self.plot.setInteractiveMode
+ self.browseAction.setChecked(True)
+ self._releaseDrawingMode()
+
+ def _releaseDrawingMode(self):
+ """Release the drawing mode if is was used"""
+ if self._drawingMode is None:
+ return
+ self.plot.sigPlotSignal.disconnect(self._plotDrawEvent)
+ self._drawingMode = None
+
+ def _activeBrowseMode(self):
+ """Handle browse action mode triggered by user.
+
+ Set plot interactive mode only when
+ the user is triggering the browse action.
+ """
+ self._releaseDrawingMode()
+ self.plot.setInteractiveMode('zoom', source=self)
+ self._updateDrawingModeWidgets()
+
+ def _activeRectMode(self):
+ """Handle rect action mode triggering"""
+ self._releaseDrawingMode()
+ self._drawingMode = 'rectangle'
+ self.plot.sigPlotSignal.connect(self._plotDrawEvent)
+ color = self.getCurrentMaskColor()
+ self.plot.setInteractiveMode(
+ 'draw', shape='rectangle', source=self, color=color)
+ self._updateDrawingModeWidgets()
+
+ def _activePolygonMode(self):
+ """Handle polygon action mode triggering"""
+ self._releaseDrawingMode()
+ self._drawingMode = 'polygon'
+ self.plot.sigPlotSignal.connect(self._plotDrawEvent)
+ color = self.getCurrentMaskColor()
+ self.plot.setInteractiveMode('draw', shape='polygon', source=self, color=color)
+ self._updateDrawingModeWidgets()
+
+ def _activePencilMode(self):
+ """Handle pencil action mode triggering"""
+ self._releaseDrawingMode()
+ self._drawingMode = 'pencil'
+ self.plot.sigPlotSignal.connect(self._plotDrawEvent)
+ color = self.getCurrentMaskColor()
+ width = self.pencilSpinBox.value()
+ self.plot.setInteractiveMode(
+ 'draw', shape='pencil', source=self, color=color, width=width)
+ self._updateDrawingModeWidgets()
+
+ def _updateDrawingModeWidgets(self):
+ self.pencilSetting.setVisible(self._drawingMode == 'pencil')
+
+ # Handle plot drawing events
+
+ def _isMasking(self):
+ """Returns true if the tool is used for masking, else it is used for
+ unmasking.
+
+ :rtype: bool"""
+ # First draw event, use current modifiers for all draw sequence
+ doMask = (self.maskStateGroup.checkedId() == 1)
+ if qt.QApplication.keyboardModifiers() & qt.Qt.ControlModifier:
+ doMask = not doMask
+ return doMask
+
+ # Handle threshold UI events
+ def _belowThresholdActionTriggered(self, triggered):
+ if triggered:
+ self.minLineEdit.setEnabled(True)
+ self.maxLineEdit.setEnabled(False)
+ self.applyMaskBtn.setEnabled(True)
+
+ def _betweenThresholdActionTriggered(self, triggered):
+ if triggered:
+ self.minLineEdit.setEnabled(True)
+ self.maxLineEdit.setEnabled(True)
+ self.applyMaskBtn.setEnabled(True)
+
+ def _aboveThresholdActionTriggered(self, triggered):
+ if triggered:
+ self.minLineEdit.setEnabled(False)
+ self.maxLineEdit.setEnabled(True)
+ self.applyMaskBtn.setEnabled(True)
+
+ def _thresholdActionGroupTriggered(self, triggeredAction):
+ """Threshold action group listener."""
+ if triggeredAction.isChecked():
+ # Uncheck other actions
+ for action in self.thresholdActionGroup.actions():
+ if action is not triggeredAction and action.isChecked():
+ action.setChecked(False)
+ else:
+ # Disable min/max edit
+ self.minLineEdit.setEnabled(False)
+ self.maxLineEdit.setEnabled(False)
+ self.applyMaskBtn.setEnabled(False)
+
+ def _maskBtnClicked(self):
+ if self.belowThresholdAction.isChecked():
+ if self.minLineEdit.text():
+ self._mask.updateBelowThreshold(self.levelSpinBox.value(),
+ float(self.minLineEdit.text()))
+ self._mask.commit()
+
+ elif self.betweenThresholdAction.isChecked():
+ if self.minLineEdit.text() and self.maxLineEdit.text():
+ min_ = float(self.minLineEdit.text())
+ max_ = float(self.maxLineEdit.text())
+ self._mask.updateBetweenThresholds(self.levelSpinBox.value(),
+ min_, max_)
+ self._mask.commit()
+
+ elif self.aboveThresholdAction.isChecked():
+ if self.maxLineEdit.text():
+ max_ = float(self.maxLineEdit.text())
+ self._mask.updateAboveThreshold(self.levelSpinBox.value(),
+ max_)
+ self._mask.commit()
+
+ def _maskNotFiniteBtnClicked(self):
+ """Handle not finite mask button clicked: mask NaNs and inf"""
+ self._mask.updateNotFinite(
+ self.levelSpinBox.value())
+ self._mask.commit()
+
+
+class BaseMaskToolsDockWidget(qt.QDockWidget):
+ """Base class for :class:`MaskToolsWidget` and
+ :class:`ScatterMaskToolsWidget`
+
+ For integration in a :class:`PlotWindow`.
+
+ :param parent: See :class:`QDockWidget`
+ :paran str name: The title of this widget
+ """
+
+ sigMaskChanged = qt.Signal()
+
+ def __init__(self, parent=None, name='Mask'):
+ super(BaseMaskToolsDockWidget, self).__init__(parent)
+ self.setWindowTitle(name)
+
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self.dockLocationChanged.connect(self._dockLocationChanged)
+ self.topLevelChanged.connect(self._topLevelChanged)
+
+ def _emitSigMaskChanged(self):
+ """Notify mask changes"""
+ # must be connected to self.widget().sigMaskChanged in child class
+ self.sigMaskChanged.emit()
+
+ def getSelectionMask(self, copy=True):
+ """Get the current mask as a 2D array.
+
+ :param bool copy: True (default) to get a copy of the mask.
+ If False, the returned array MUST not be modified.
+ :return: The array of the mask with dimension of the 'active' image.
+ If there is no active image, an empty array is returned.
+ :rtype: 2D numpy.ndarray of uint8
+ """
+ return self.widget().getSelectionMask(copy=copy)
+
+ def setSelectionMask(self, mask, copy=True):
+ """Set the mask to a new array.
+
+ :param numpy.ndarray mask: The array to use for the mask.
+ :type mask: numpy.ndarray of uint8 of dimension 2, C-contiguous.
+ Array of other types are converted.
+ :param bool copy: True (the default) to copy the array,
+ False to use it as is if possible.
+ :return: None if failed, shape of mask as 2-tuple if successful.
+ The mask can be cropped or padded to fit active image,
+ the returned shape is that of the active image.
+ """
+ return self.widget().setSelectionMask(mask, copy=copy)
+
+ def toggleViewAction(self):
+ """Returns a checkable action that shows or closes this widget.
+
+ See :class:`QMainWindow`.
+ """
+ action = super(BaseMaskToolsDockWidget, self).toggleViewAction()
+ action.setIcon(icons.getQIcon('image-mask'))
+ action.setToolTip("Display/hide mask tools")
+ return action
+
+ def _dockLocationChanged(self, area):
+ if area in (qt.Qt.LeftDockWidgetArea, qt.Qt.RightDockWidgetArea):
+ direction = qt.QBoxLayout.TopToBottom
+ else:
+ direction = qt.QBoxLayout.LeftToRight
+ self.widget().setDirection(direction)
+
+ def _topLevelChanged(self, topLevel):
+ if topLevel:
+ self.widget().setDirection(qt.QBoxLayout.LeftToRight)
+ self.resize(self.widget().minimumSize())
+ self.adjustSize()
+
+ def showEvent(self, event):
+ """Make sure this widget is raised when it is shown
+ (when it is first created as a tab in PlotWindow or when it is shown
+ again after hiding).
+ """
+ self.raise_()
diff --git a/silx/gui/plot/__init__.py b/silx/gui/plot/__init__.py
new file mode 100644
index 0000000..06a24a7
--- /dev/null
+++ b/silx/gui/plot/__init__.py
@@ -0,0 +1,71 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 Qt widgets for plotting curves and images.
+
+The plotting API is inherited from the `PyMca <http://pymca.sourceforge.net/>`_
+plot API and is mostly compatible with it.
+
+Those widgets supports interaction (e.g., zoom, pan, selections).
+
+List of Qt widgets:
+
+.. currentmodule:: silx.gui.plot
+
+- :mod:`.PlotWidget`: A widget displaying a single plot.
+- :mod:`.PlotWindow`: A :mod:`.PlotWidget` with a configurable set of tools.
+- :class:`.Plot1D`: A widget with tools for curves.
+- :class:`.Plot2D`: A widget with tools for images.
+- :class:`.ImageView`: A widget with tools for images and a side histogram.
+- :class:`.StackView`: A widget with tools for a stack of images.
+
+By default, those widget are using matplotlib_.
+They can optionally use a faster OpenGL-based rendering (beta feature),
+which is enabled by setting the ``backend`` argument to ``'gl'``
+when creating the widgets (See :class:`.Plot`).
+
+.. note::
+
+ This package depends on matplotlib_.
+ The OpenGL backend further depends on
+ `PyOpenGL <http://pyopengl.sourceforge.net/>`_ and OpenGL >= 2.1.
+
+.. _matplotlib: http://matplotlib.org/
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "22/02/2016"
+
+
+# First of all init matplotlib and set its backend
+from .backends import _matplotlib # noqa
+
+from .PlotWidget import PlotWidget # noqa
+from .PlotWindow import PlotWindow, Plot1D, Plot2D # noqa
+from .ImageView import ImageView # noqa
+from .StackView import StackView # noqa
+
+__all__ = ['ImageView', 'PlotWidget', 'PlotWindow', 'Plot1D', 'Plot2D',
+ 'StackView']
diff --git a/silx/gui/plot/_utils/__init__.py b/silx/gui/plot/_utils/__init__.py
new file mode 100644
index 0000000..355bc02
--- /dev/null
+++ b/silx/gui/plot/_utils/__init__.py
@@ -0,0 +1,104 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Miscellaneous utility functions for the Plot"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "21/03/2017"
+
+
+import numpy
+
+from .panzoom import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX
+from .panzoom import applyZoomToPlot, applyPan
+
+
+def clipColormapLogRange(colormap):
+ """Clip colormap vmin and vmax to 1, 10 if normalization is 'log' and vmin
+ or vmax <1
+
+ :param dict colormap: the colormap for which we want to clip vmin and vmax
+ """
+ if colormap['normalization'] is 'log':
+ if colormap['vmin'] < 1. or colormap['vmax'] < 1.:
+ colormap['vmin'], colormap['vmax'] = 1., 10.
+
+
+def addMarginsToLimits(margins, isXLog, isYLog,
+ xMin, xMax, yMin, yMax, y2Min=None, y2Max=None):
+ """Returns updated limits by extending them with margins.
+
+ :param margins: The ratio of the margins to add or None for no margins.
+ :type margins: A 4-tuple of floats as
+ (xMinMargin, xMaxMargin, yMinMargin, yMaxMargin)
+
+ :return: The updated limits
+ :rtype: tuple of 4 or 6 floats: Either (xMin, xMax, yMin, yMax) or
+ (xMin, xMax, yMin, yMax, y2Min, y2Max) if y2Min and y2Max
+ are provided.
+ """
+ if margins is not None:
+ xMinMargin, xMaxMargin, yMinMargin, yMaxMargin = margins
+
+ if not isXLog:
+ xRange = xMax - xMin
+ xMin -= xMinMargin * xRange
+ xMax += xMaxMargin * xRange
+
+ elif xMin > 0. and xMax > 0.: # Log scale
+ # Do not apply margins if limits < 0
+ xMinLog, xMaxLog = numpy.log10(xMin), numpy.log10(xMax)
+ xRangeLog = xMaxLog - xMinLog
+ xMin = pow(10., xMinLog - xMinMargin * xRangeLog)
+ xMax = pow(10., xMaxLog + xMaxMargin * xRangeLog)
+
+ if not isYLog:
+ yRange = yMax - yMin
+ yMin -= yMinMargin * yRange
+ yMax += yMaxMargin * yRange
+ elif yMin > 0. and yMax > 0.: # Log scale
+ # Do not apply margins if limits < 0
+ yMinLog, yMaxLog = numpy.log10(yMin), numpy.log10(yMax)
+ yRangeLog = yMaxLog - yMinLog
+ yMin = pow(10., yMinLog - yMinMargin * yRangeLog)
+ yMax = pow(10., yMaxLog + yMaxMargin * yRangeLog)
+
+ if y2Min is not None and y2Max is not None:
+ if not isYLog:
+ yRange = y2Max - y2Min
+ y2Min -= yMinMargin * yRange
+ y2Max += yMaxMargin * yRange
+ elif y2Min > 0. and y2Max > 0.: # Log scale
+ # Do not apply margins if limits < 0
+ yMinLog, yMaxLog = numpy.log10(y2Min), numpy.log10(y2Max)
+ yRangeLog = yMaxLog - yMinLog
+ y2Min = pow(10., yMinLog - yMinMargin * yRangeLog)
+ y2Max = pow(10., yMaxLog + yMaxMargin * yRangeLog)
+
+ if y2Min is None or y2Max is None:
+ return xMin, xMax, yMin, yMax
+ else:
+ return xMin, xMax, yMin, yMax, y2Min, y2Max
+
diff --git a/silx/gui/plot/_utils/panzoom.py b/silx/gui/plot/_utils/panzoom.py
new file mode 100644
index 0000000..bec31df
--- /dev/null
+++ b/silx/gui/plot/_utils/panzoom.py
@@ -0,0 +1,156 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Functions to apply pan and zoom on a Plot"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "21/03/2017"
+
+
+import math
+
+import numpy
+
+
+# Float 32 info ###############################################################
+# Using min/max value below limits of float32
+# so operation with such value (e.g., max - min) do not overflow
+
+FLOAT32_SAFE_MIN = -1e37
+FLOAT32_MINPOS = numpy.finfo(numpy.float32).tiny
+FLOAT32_SAFE_MAX = 1e37
+# TODO double support
+
+
+def scale1DRange(min_, max_, center, scale, isLog):
+ """Scale a 1D range given a scale factor and an center point.
+
+ Keeps the values in a smaller range than float32.
+
+ :param float min_: The current min value of the range.
+ :param float max_: The current max value of the range.
+ :param float center: The center of the zoom (i.e., invariant point).
+ :param float scale: The scale to use for zoom
+ :param bool isLog: Whether using log scale or not.
+ :return: The zoomed range.
+ :rtype: tuple of 2 floats: (min, max)
+ """
+ if isLog:
+ # Min and center can be < 0 when
+ # autoscale is off and switch to log scale
+ # max_ < 0 should not happen
+ min_ = numpy.log10(min_) if min_ > 0. else FLOAT32_MINPOS
+ center = numpy.log10(center) if center > 0. else FLOAT32_MINPOS
+ max_ = numpy.log10(max_) if max_ > 0. else FLOAT32_MINPOS
+
+ if min_ == max_:
+ return min_, max_
+
+ offset = (center - min_) / (max_ - min_)
+ range_ = (max_ - min_) / scale
+ newMin = center - offset * range_
+ newMax = center + (1. - offset) * range_
+
+ if isLog:
+ # No overflow as exponent is log10 of a float32
+ newMin = pow(10., newMin)
+ newMax = pow(10., newMax)
+ newMin = numpy.clip(newMin, FLOAT32_MINPOS, FLOAT32_SAFE_MAX)
+ newMax = numpy.clip(newMax, FLOAT32_MINPOS, FLOAT32_SAFE_MAX)
+ else:
+ newMin = numpy.clip(newMin, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX)
+ newMax = numpy.clip(newMax, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX)
+ return newMin, newMax
+
+
+def applyZoomToPlot(plot, scaleF, center=None):
+ """Zoom in/out plot given a scale and a center point.
+
+ :param plot: The plot on which to apply zoom.
+ :param float scaleF: Scale factor of zoom.
+ :param center: (x, y) coords in pixel coordinates of the zoom center.
+ :type center: 2-tuple of float
+ """
+ xMin, xMax = plot.getGraphXLimits()
+ yMin, yMax = plot.getGraphYLimits()
+
+ if center is None:
+ left, top, width, height = plot.getPlotBoundsInPixels()
+ cx, cy = left + width // 2, top + height // 2
+ else:
+ cx, cy = center
+
+ dataCenterPos = plot.pixelToData(cx, cy)
+ assert dataCenterPos is not None
+
+ xMin, xMax = scale1DRange(xMin, xMax, dataCenterPos[0], scaleF,
+ plot.isXAxisLogarithmic())
+
+ yMin, yMax = scale1DRange(yMin, yMax, dataCenterPos[1], scaleF,
+ plot.isYAxisLogarithmic())
+
+ dataPos = plot.pixelToData(cx, cy, axis="right")
+ assert dataPos is not None
+ y2Center = dataPos[1]
+ y2Min, y2Max = plot.getGraphYLimits(axis="right")
+ y2Min, y2Max = scale1DRange(y2Min, y2Max, y2Center, scaleF,
+ plot.isYAxisLogarithmic())
+
+ plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+
+
+def applyPan(min_, max_, panFactor, isLog10):
+ """Returns a new range with applied panning.
+
+ Moves the range according to panFactor.
+ If isLog10 is True, converts to log10 before moving.
+
+ :param float min_: Min value of the data range to pan.
+ :param float max_: Max value of the data range to pan.
+ Must be >= min.
+ :param float panFactor: Signed proportion of the range to use for pan.
+ :param bool isLog10: True if log10 scale, False if linear scale.
+ :return: New min and max value with pan applied.
+ :rtype: 2-tuple of float.
+ """
+ if isLog10 and min_ > 0.:
+ # Negative range and log scale can happen with matplotlib
+ logMin, logMax = math.log10(min_), math.log10(max_)
+ logOffset = panFactor * (logMax - logMin)
+ newMin = pow(10., logMin + logOffset)
+ newMax = pow(10., logMax + logOffset)
+
+ # Takes care of out-of-range values
+ if newMin > 0. and newMax < float('inf'):
+ min_, max_ = newMin, newMax
+
+ else:
+ offset = panFactor * (max_ - min_)
+ newMin, newMax = min_ + offset, max_ + offset
+
+ # Takes care of out-of-range values
+ if newMin > - float('inf') and newMax < float('inf'):
+ min_, max_ = newMin, newMax
+ return min_, max_
diff --git a/silx/gui/plot/_utils/setup.py b/silx/gui/plot/_utils/setup.py
new file mode 100644
index 0000000..0271745
--- /dev/null
+++ b/silx/gui/plot/_utils/setup.py
@@ -0,0 +1,42 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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__ = "21/03/2017"
+
+
+from numpy.distutils.misc_util import Configuration
+
+
+def configuration(parent_package='', top_path=None):
+ config = Configuration('_utils', parent_package, top_path)
+ config.add_subpackage('test')
+ return config
+
+
+if __name__ == "__main__":
+ from numpy.distutils.core import setup
+
+ setup(configuration=configuration)
diff --git a/silx/gui/plot/_utils/test/__init__.py b/silx/gui/plot/_utils/test/__init__.py
new file mode 100644
index 0000000..4a443ac
--- /dev/null
+++ b/silx/gui/plot/_utils/test/__init__.py
@@ -0,0 +1,41 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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.
+#
+# ###########################################################################*/
+
+from __future__ import absolute_import, division, unicode_literals
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/10/2016"
+
+
+import unittest
+
+from .test_ticklayout import suite as test_ticklayout_suite
+
+
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(test_ticklayout_suite())
+ return testsuite
diff --git a/silx/gui/plot/_utils/test/test_ticklayout.py b/silx/gui/plot/_utils/test/test_ticklayout.py
new file mode 100644
index 0000000..8c67620
--- /dev/null
+++ b/silx/gui/plot/_utils/test/test_ticklayout.py
@@ -0,0 +1,78 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-2017 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.
+#
+# ###########################################################################*/
+
+from __future__ import absolute_import, division, unicode_literals
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/10/2016"
+
+
+import unittest
+
+from silx.test.utils import ParametricTestCase
+
+from silx.gui.plot._utils import ticklayout
+
+
+class TestTickLayout(ParametricTestCase):
+ """Test ticks layout algorithms"""
+
+ def testNiceNumbers(self):
+ """Minimalistic tests of :func:`niceNumbers`"""
+ tests = { # (vmin, vmax): ref_ticks
+ (0.5, 10.5): (0.0, 12.0, 2.0, 0),
+ (10000., 10000.5): (10000.0, 10000.5, 0.1, 1),
+ (0.001, 0.005): (0.001, 0.005, 0.001, 3)
+ }
+
+ for (vmin, vmax), ref_ticks in tests.items():
+ with self.subTest(vmin=vmin, vmax=vmax):
+ ticks = ticklayout.niceNumbers(vmin, vmax)
+ self.assertEqual(ticks, ref_ticks)
+
+ def testNiceNumbersLog(self):
+ """Minimalistic tests of :func:`niceNumbersForLog10`"""
+ tests = { # (log10(min), log10(max): ref_ticks
+ (0., 3.): (0, 3, 1, 0),
+ (-3., 3): (-3, 3, 1, 0),
+ (-32., 0.): (-36, 0, 6, 0)
+ }
+
+ for (vmin, vmax), ref_ticks in tests.items():
+ with self.subTest(vmin=vmin, vmax=vmax):
+ ticks = ticklayout.niceNumbersForLog10(vmin, vmax)
+ self.assertEqual(ticks, ref_ticks)
+
+
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestTickLayout))
+ return testsuite
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py
new file mode 100644
index 0000000..5f4b636
--- /dev/null
+++ b/silx/gui/plot/_utils/ticklayout.py
@@ -0,0 +1,224 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 implements labels layout on graph axes."""
+
+from __future__ import absolute_import, division, unicode_literals
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/10/2016"
+
+
+import math
+
+
+# utils #######################################################################
+
+def numberOfDigits(tickSpacing):
+ """Returns the number of digits to display for text label.
+
+ :param float tickSpacing: Step between ticks in data space.
+ :return: Number of digits to show for labels.
+ :rtype: int
+ """
+ nfrac = int(-math.floor(math.log10(tickSpacing)))
+ if nfrac < 0:
+ nfrac = 0
+ return nfrac
+
+
+# Nice Numbers ################################################################
+
+def _niceNum(value, isRound=False):
+ expvalue = math.floor(math.log10(value))
+ frac = value/pow(10., expvalue)
+ if isRound:
+ if frac < 1.5:
+ nicefrac = 1.
+ elif frac < 3.:
+ nicefrac = 2.
+ elif frac < 7.:
+ nicefrac = 5.
+ else:
+ nicefrac = 10.
+ else:
+ if frac <= 1.:
+ nicefrac = 1.
+ elif frac <= 2.:
+ nicefrac = 2.
+ elif frac <= 5.:
+ nicefrac = 5.
+ else:
+ nicefrac = 10.
+ return nicefrac * pow(10., expvalue)
+
+
+def niceNumbers(vMin, vMax, nTicks=5):
+ """Returns tick positions.
+
+ This function implements graph labels layout using nice numbers
+ by Paul Heckbert from "Graphics Gems", Academic Press, 1990.
+ See `C code <http://tog.acm.org/resources/GraphicsGems/gems/Label.c>`_.
+
+ :param float vMin: The min value on the axis
+ :param float vMax: The max value on the axis
+ :param int nTicks: The number of ticks to position
+ :returns: min, max, increment value of tick positions and
+ number of fractional digit to show
+ :rtype: tuple
+ """
+ vrange = _niceNum(vMax - vMin, False)
+ spacing = _niceNum(vrange / nTicks, True)
+ graphmin = math.floor(vMin / spacing) * spacing
+ graphmax = math.ceil(vMax / spacing) * spacing
+ nfrac = numberOfDigits(spacing)
+ return graphmin, graphmax, spacing, nfrac
+
+
+def _frange(start, stop, step):
+ """range for float (including stop)."""
+ assert step >= 0.
+ while start <= stop:
+ yield start
+ start += step
+
+
+def ticks(vMin, vMax, nbTicks=5):
+ """Returns tick positions and labels using nice numbers algorithm.
+
+ This enforces ticks to be within [vMin, vMax] range.
+ It returns at least 2 ticks.
+
+ :param float vMin: The min value on the axis
+ :param float vMax: The max value on the axis
+ :param int nbTicks: The number of ticks to position
+ :returns: tick positions and corresponding text labels
+ :rtype: 2-tuple: list of float, list of string
+ """
+ start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks)
+ positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax]
+
+ # Makes sure there is at least 2 ticks
+ if len(positions) < 2:
+ positions = [vMin, vMax]
+ nfrac = numberOfDigits(vMax - vMin)
+
+ # Generate labels
+ format_ = '%g' if nfrac == 0 else '%.{}f'.format(nfrac)
+ labels = [format_ % tick for tick in positions]
+ return positions, labels
+
+
+def niceNumbersAdaptative(vMin, vMax, axisLength, tickDensity):
+ """Returns tick positions using :func:`niceNumbers` and a
+ density of ticks.
+
+ axisLength and tickDensity are based on the same unit (e.g., pixel).
+
+ :param float vMin: The min value on the axis
+ :param float vMax: The max value on the axis
+ :param float axisLength: The length of the axis.
+ :param float tickDensity: The density of ticks along the axis.
+ :returns: min, max, increment value of tick positions and
+ number of fractional digit to show
+ :rtype: tuple
+ """
+ # At least 2 ticks
+ nticks = max(2, int(round(tickDensity * axisLength)))
+ tickmin, tickmax, step, nfrac = niceNumbers(vMin, vMax, nticks)
+
+ return tickmin, tickmax, step, nfrac
+
+
+# Nice Numbers for log scale ##################################################
+
+def niceNumbersForLog10(minLog, maxLog, nTicks=5):
+ """Return tick positions for logarithmic scale
+
+ :param float minLog: log10 of the min value on the axis
+ :param float maxLog: log10 of the max value on the axis
+ :param int nTicks: The number of ticks to position
+ :returns: log10 of min, max, increment value of tick positions and
+ number of fractional digit to show
+ :rtype: tuple of int
+ """
+ graphminlog = math.floor(minLog)
+ graphmaxlog = math.ceil(maxLog)
+ rangelog = graphmaxlog - graphminlog
+
+ if rangelog <= nTicks:
+ spacing = 1.
+ else:
+ spacing = math.floor(rangelog / nTicks)
+
+ graphminlog = math.floor(graphminlog / spacing) * spacing
+ graphmaxlog = math.ceil(graphmaxlog / spacing) * spacing
+
+ nfrac = numberOfDigits(spacing)
+
+ return int(graphminlog), int(graphmaxlog), int(spacing), nfrac
+
+
+def niceNumbersAdaptativeForLog10(vMin, vMax, axisLength, tickDensity):
+ """Returns tick positions using :func:`niceNumbers` and a
+ density of ticks.
+
+ axisLength and tickDensity are based on the same unit (e.g., pixel).
+
+ :param float vMin: The min value on the axis
+ :param float vMax: The max value on the axis
+ :param float axisLength: The length of the axis.
+ :param float tickDensity: The density of ticks along the axis.
+ :returns: log10 of min, max, increment value of tick positions and
+ number of fractional digit to show
+ :rtype: tuple
+ """
+ # At least 2 ticks
+ nticks = max(2, int(round(tickDensity * axisLength)))
+ tickmin, tickmax, step, nfrac = niceNumbersForLog10(vMin, vMax, nticks)
+
+ return tickmin, tickmax, step, nfrac
+
+
+def computeLogSubTicks(ticks, lowBound, highBound):
+ """Return the sub ticks for the log scale for all given ticks if subtick
+ is in [lowBound, highBound]
+
+ :param ticks: log10 of the ticks
+ :param lowBound: the lower boundary of ticks
+ :param highBound: the higher boundary of ticks
+ :return: all the sub ticks contained in ticks (log10)
+ """
+ if len(ticks) < 1:
+ return []
+
+ res = []
+ for logPos in ticks:
+ dataOrigPos = logPos
+ for index in range(2, 10):
+ dataPos = dataOrigPos * index
+ if lowBound <= dataPos <= highBound:
+ res.append(dataPos)
+ return res
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
new file mode 100644
index 0000000..74f96af
--- /dev/null
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -0,0 +1,474 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ############################################################################*/
+"""Base class for Plot backends.
+
+It documents the Plot backend API.
+
+This API is a simplified version of PyMca PlotBackend API.
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/02/2016"
+
+
+import weakref
+
+
+# Names for setCursor
+CURSOR_DEFAULT = 'default'
+CURSOR_POINTING = 'pointing'
+CURSOR_SIZE_HOR = 'size horizontal'
+CURSOR_SIZE_VER = 'size vertical'
+CURSOR_SIZE_ALL = 'size all'
+
+
+class BackendBase(object):
+ """Class defining the API a backend of the Plot should provide."""
+
+ def __init__(self, plot, parent=None):
+ """Init.
+
+ :param Plot plot: The Plot this backend is attached to
+ :param parent: The parent widget of the plot widget.
+ """
+ self.__xLimits = 1., 100.
+ self.__yLimits = {'left': (1., 100.), 'right': (1., 100.)}
+ self.__yAxisInverted = False
+ self.__keepDataAspectRatio = False
+ # Store a weakref to get access to the plot state.
+ self._setPlot(plot)
+
+ @property
+ def _plot(self):
+ """The plot this backend is attached to."""
+ if self._plotRef is None:
+ raise RuntimeError('This backend is not attached to a Plot')
+
+ plot = self._plotRef()
+ if plot is None:
+ raise RuntimeError('This backend is no more attached to a Plot')
+ return plot
+
+ def _setPlot(self, plot):
+ """Allow to set plot after init.
+
+ Use with caution, basically **immediately** after init.
+ """
+ self._plotRef = weakref.ref(plot)
+
+ # Add methods
+
+ def addCurve(self, x, y, legend,
+ color, symbol, linewidth, linestyle,
+ yaxis,
+ xerror, yerror, z, selectable,
+ fill, alpha, symbolsize):
+ """Add a 1D curve given by x an y to the graph.
+
+ :param numpy.ndarray x: The data corresponding to the x axis
+ :param numpy.ndarray y: The data corresponding to the y axis
+ :param str legend: The legend to be associated to the curve
+ :param color: color(s) to be used
+ :type color: string ("#RRGGBB") or (npoints, 4) unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ :param str symbol: Symbol to be drawn at each (x, y) position::
+
+ - ' ' or '' no symbol
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :param float linewidth: The width of the curve in pixels
+ :param str linestyle: Type of line::
+
+ - ' ' or '' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+
+ :param str yaxis: The Y axis this curve belongs to in: 'left', 'right'
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: numpy.ndarray or None
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: numpy.ndarray or None
+ :param int z: Layer on which to draw the cuve
+ :param bool selectable: indicate if the curve can be selected
+ :param bool fill: True to fill the curve, False otherwise
+ :param float alpha: Curve opacity, as a float in [0., 1.]
+ :param float symbolsize: Size of the symbol (if any) drawn
+ at each (x, y) position.
+ :returns: The handle used by the backend to univocally access the curve
+ """
+ return legend
+
+ def addImage(self, data, legend,
+ origin, scale, z,
+ selectable, draggable,
+ colormap, alpha):
+ """Add an image to the plot.
+
+ :param numpy.ndarray data: (nrows, ncolumns) data or
+ (nrows, ncolumns, RGBA) ubyte array
+ :param str legend: The legend to be associated to the image
+ :param origin: (origin X, origin Y) of the data.
+ Default: (0., 0.)
+ :type origin: 2-tuple of float
+ :param scale: (scale X, scale Y) of the data.
+ Default: (1., 1.)
+ :type scale: 2-tuple of float
+ :param int z: Layer on which to draw the image
+ :param bool selectable: indicate if the image can be selected
+ :param bool draggable: indicate if the image can be moved
+ :param colormap: Dictionary describing the colormap to use.
+ Ignored if data is RGB(A).
+ :type colormap: dict or None
+ :param float alpha: Opacity of the image, as a float in range [0, 1].
+ :returns: The handle used by the backend to univocally access the image
+ """
+ return legend
+
+ def addItem(self, x, y, legend, shape, color, fill, overlay, z):
+ """Add an item (i.e. a shape) to the plot.
+
+ :param numpy.ndarray x: The X coords of the points of the shape
+ :param numpy.ndarray y: The Y coords of the points of the shape
+ :param str legend: The legend to be associated to the item
+ :param str shape: Type of item to be drawn in
+ hline, polygon, rectangle, vline, polylines
+ :param str color: Color of the item
+ :param bool fill: True to fill the shape
+ :param bool overlay: True if item is an overlay, False otherwise
+ :param int z: Layer on which to draw the item
+ :returns: The handle used by the backend to univocally access the item
+ """
+ return legend
+
+ def addMarker(self, x, y, legend, text, color,
+ selectable, draggable,
+ symbol, constraint, overlay):
+ """Add a point, vertical line or horizontal line marker to the plot.
+
+ :param float x: Horizontal position of the marker in graph coordinates.
+ If None, the marker is a horizontal line.
+ :param float y: Vertical position of the marker in graph coordinates.
+ If None, the marker is a vertical line.
+ :param str legend: Legend associated to the marker
+ :param str text: Text associated to the marker (or None for no text)
+ :param str color: Color to be used for instance 'blue', 'b', '#FF0000'
+ :param bool selectable: indicate if the marker can be selected
+ :param bool draggable: indicate if the marker can be moved
+ :param str symbol: Symbol representing the marker.
+ Only relevant for point markers where X and Y are not None.
+ Value in:
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :type constraint: None or a callable that takes the coordinates of
+ the current cursor position in the plot as input
+ and that returns the filtered coordinates.
+ :param bool overlay: True if marker is an overlay (Default: False).
+ This allows for rendering optimization if this
+ marker is changed often.
+ :return: Handle used by the backend to univocally access the marker
+ """
+ return legend
+
+ # Remove methods
+
+ def remove(self, item):
+ """Remove an existing item from the plot.
+
+ :param item: A backend specific item handle returned by a add* method
+ """
+ pass
+
+ # Interaction methods
+
+ def setGraphCursorShape(self, cursor):
+ """Set the cursor shape.
+
+ To override in interactive backends.
+
+ :param str cursor: Name of the cursor shape or None
+ """
+ pass
+
+ def setGraphCursor(self, flag, color, linewidth, linestyle):
+ """Toggle the display of a crosshair cursor and set its attributes.
+
+ To override in interactive backends.
+
+ :param bool flag: Toggle the display of a crosshair cursor.
+ :param color: The color to use for the crosshair.
+ :type color: A string (either a predefined color name in Colors.py
+ or "#RRGGBB")) or a 4 columns unsigned byte array.
+ :param int linewidth: The width of the lines of the crosshair.
+ :param linestyle: Type of line::
+
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+
+ :type linestyle: None or one of the predefined styles.
+ """
+ pass
+
+ def pickItems(self, x, y):
+ """Get a list of items at a pixel position.
+
+ :param float x: The x pixel coord where to pick.
+ :param float y: The y pixel coord where to pick.
+ :return: All picked items from back to front.
+ One dict per item,
+ with 'kind' key in 'curve', 'marker', 'image';
+ 'legend' key, the item legend.
+ and for curves, 'xdata' and 'ydata' keys storing picked
+ position on the curve.
+ :rtype: list of dict
+ """
+ return []
+
+ # Update curve
+
+ def setCurveColor(self, curve, color):
+ """Set the color of a curve.
+
+ :param curve: The curve handle
+ :param str color: The color to use.
+ """
+ pass
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ """Return the widget this backend is drawing to."""
+ return None
+
+ def postRedisplay(self):
+ """Trigger a :meth:`Plot.replot`.
+
+ Default implementation triggers a synchronous replot if plot is dirty.
+ This method should be overridden by the embedding widget in order to
+ provide an asynchronous call to replot in order to optimize the number
+ replot operations.
+ """
+ # This method can be deferred and it might happen that plot has been
+ # destroyed in between, especially with unittests
+
+ plot = self._plotRef()
+ if plot is not None and plot._getDirtyPlot():
+ plot.replot()
+
+ def replot(self):
+ """Redraw the plot."""
+ pass
+
+ def saveGraph(self, fileName, fileFormat, dpi):
+ """Save the graph to a file (or a StringIO)
+
+ :param fileName: Destination
+ :type fileName: String or StringIO or BytesIO
+ :param str fileFormat: String specifying the format
+ :param int dpi: The resolution to use or None.
+ """
+ pass
+
+ # Graph labels
+
+ def setGraphTitle(self, title):
+ """Set the main title of the plot.
+
+ :param str title: Title associated to the plot
+ """
+ pass
+
+ def setGraphXLabel(self, label):
+ """Set the X axis label.
+
+ :param str label: label associated to the plot bottom X axis
+ """
+ pass
+
+ def setGraphYLabel(self, label, axis):
+ """Set the left Y axis label.
+
+ :param str label: label associated to the plot left Y axis
+ :param str axis: The axis for which to get the limits: left or right
+ """
+ pass
+
+ # Graph limits
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ """Set the limits of the X and Y axes at once.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ :param float ymin: minimum left axis value
+ :param float ymax: maximum left axis value
+ :param float y2min: minimum right axis value
+ :param float y2max: maximum right axis value
+ """
+ self.__xLimits = xmin, xmax
+ self.__yLimits['left'] = ymin, ymax
+ if y2min is not None and y2max is not None:
+ self.__yLimits['right'] = y2min, y2max
+
+ def getGraphXLimits(self):
+ """Get the graph X (bottom) limits.
+
+ :return: Minimum and maximum values of the X axis
+ """
+ return self.__xLimits
+
+ def setGraphXLimits(self, xmin, xmax):
+ """Set the limits of X axis.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ """
+ self.__xLimits = xmin, xmax
+
+ def getGraphYLimits(self, axis):
+ """Get the graph Y (left) limits.
+
+ :param str axis: The axis for which to get the limits: left or right
+ :return: Minimum and maximum values of the Y axis
+ """
+ return self.__yLimits[axis]
+
+ def setGraphYLimits(self, ymin, ymax, axis):
+ """Set the limits of the Y axis.
+
+ :param float ymin: minimum left axis value
+ :param float ymax: maximum left axis value
+ :param str axis: The axis for which to get the limits: left or right
+ """
+ self.__yLimits[axis] = ymin, ymax
+
+ # Graph axes
+
+ def setXAxisLogarithmic(self, flag):
+ """Set the X axis scale between linear and log.
+
+ :param bool flag: If True, the bottom axis will use a log scale
+ """
+ pass
+
+ def setYAxisLogarithmic(self, flag):
+ """Set the Y axis scale between linear and log.
+
+ :param bool flag: If True, the left axis will use a log scale
+ """
+ pass
+
+ def setYAxisInverted(self, flag):
+ """Invert the Y axis.
+
+ :param bool flag: If True, put the vertical axis origin on the top
+ """
+ self.__yAxisInverted = bool(flag)
+
+ def isYAxisInverted(self):
+ """Return True if left Y axis is inverted, False otherwise."""
+ return self.__yAxisInverted
+
+ def isKeepDataAspectRatio(self):
+ """Returns whether the plot is keeping data aspect ratio or not."""
+ return self.__keepDataAspectRatio
+
+ def setKeepDataAspectRatio(self, flag):
+ """Set whether to keep data aspect ratio or not.
+
+ :param flag: True to respect data aspect ratio
+ :type flag: Boolean, default True
+ """
+ self.__keepDataAspectRatio = bool(flag)
+
+ def setGraphGrid(self, which):
+ """Set grid.
+
+ :param which: None to disable grid, 'major' for major grid,
+ 'both' for major and minor grid
+ """
+ pass
+
+ # Data <-> Pixel coordinates conversion
+
+ def dataToPixel(self, x, y, axis):
+ """Convert a position in data space to a position in pixels
+ in the widget.
+
+ :param float x: The X coordinate in data space.
+ :param float y: The Y coordinate in data space.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :returns: The corresponding position in pixels or
+ None if the data position is not in the displayed area.
+ :rtype: A tuple of 2 floats: (xPixel, yPixel) or None.
+ """
+ raise NotImplementedError()
+
+ def pixelToData(self, x, y, axis, check):
+ """Convert a position in pixels in the widget to a position in
+ the data space.
+
+ :param float x: The X coordinate in pixels.
+ :param float y: The Y coordinate in pixels.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :param bool check: True to check if the coordinates are in the
+ plot area.
+ :returns: The corresponding position in data space or
+ None if the pixel position is not in the plot area.
+ :rtype: A tuple of 2 floats: (xData, yData) or None.
+ """
+ raise NotImplementedError()
+
+ def getPlotBoundsInPixels(self):
+ """Plot area bounds in widget coordinates in pixels.
+
+ :return: bounds as a 4-tuple of int: (left, top, width, height)
+ """
+ raise NotImplementedError()
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
new file mode 100644
index 0000000..f9e60d5
--- /dev/null
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -0,0 +1,821 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ###########################################################################*/
+"""Matplotlib Plot backend."""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent, H. Payno"]
+__license__ = "MIT"
+__date__ = "18/01/2017"
+
+
+import logging
+
+import numpy
+
+
+_logger = logging.getLogger(__name__)
+
+
+from ... import qt
+
+from ._matplotlib import FigureCanvasQTAgg
+import matplotlib
+from matplotlib.container import Container
+from matplotlib.figure import Figure
+from matplotlib.patches import Rectangle, Polygon
+from matplotlib.image import AxesImage
+from matplotlib.backend_bases import MouseEvent
+from matplotlib.lines import Line2D
+from matplotlib.collections import PathCollection, LineCollection
+
+from .ModestImage import ModestImage
+from . import BackendBase
+from .. import Colors
+from .._utils import FLOAT32_MINPOS
+
+
+class BackendMatplotlib(BackendBase.BackendBase):
+ """Base class for Matplotlib backend without a FigureCanvas.
+
+ For interactive on screen plot, see :class:`BackendMatplotlibQt`.
+
+ See :class:`BackendBase.BackendBase` for public API documentation.
+ """
+
+ def __init__(self, plot, parent=None):
+ super(BackendMatplotlib, self).__init__(plot, parent)
+
+ # matplotlib is handling keep aspect ratio at draw time
+ # When keep aspect ratio is on, and one changes the limits and
+ # ask them *before* next draw has been performed he will get the
+ # limits without applying keep aspect ratio.
+ # This attribute is used to ensure consistent values returned
+ # when getting the limits at the expense of a replot
+ self._dirtyLimits = True
+
+ self.fig = Figure()
+ self.fig.set_facecolor("w")
+
+ self.ax = self.fig.add_axes([.15, .15, .75, .75], label="left")
+ self.ax2 = self.ax.twinx()
+ self.ax2.set_label("right")
+
+ # critical for picking!!!!
+ self.ax2.set_zorder(0)
+ self.ax2.set_autoscaley_on(True)
+ self.ax.set_zorder(1)
+ # this works but the figure color is left
+ if matplotlib.__version__[0] < '2':
+ self.ax.set_axis_bgcolor('none')
+ else:
+ self.ax.set_facecolor('none')
+ self.fig.sca(self.ax)
+
+ self._overlays = set()
+ self._background = None
+
+ self._colormaps = {}
+
+ self._graphCursor = tuple()
+ self.matplotlibVersion = matplotlib.__version__
+
+ self.setGraphXLimits(0., 100.)
+ self.setGraphYLimits(0., 100., axis='right')
+ self.setGraphYLimits(0., 100., axis='left')
+
+ self._enableAxis('right', False)
+
+ # Add methods
+
+ def addCurve(self, x, y, legend,
+ color, symbol, linewidth, linestyle,
+ yaxis,
+ xerror, yerror, z, selectable,
+ fill, alpha, symbolsize):
+ for parameter in (x, y, legend, color, symbol, linewidth, linestyle,
+ yaxis, z, selectable, fill, alpha, symbolsize):
+ assert parameter is not None
+ assert yaxis in ('left', 'right')
+
+ if (len(color) == 4 and
+ type(color[3]) in [type(1), numpy.uint8, numpy.int8]):
+ color = numpy.array(color, dtype=numpy.float) / 255.
+
+ if yaxis == "right":
+ axes = self.ax2
+ self._enableAxis("right", True)
+ else:
+ axes = self.ax
+
+ picker = 3 if selectable else None
+
+ artists = [] # All the artists composing the curve
+
+ # First add errorbars if any so they are behind the curve
+ if xerror is not None or yerror is not None:
+ if hasattr(color, 'dtype') and len(color) == len(x):
+ errorbarColor = 'k'
+ else:
+ errorbarColor = color
+
+ # On Debian 7 at least, Nx1 array yerr does not seems supported
+ if (yerror is not None and yerror.ndim == 2 and
+ yerror.shape[1] == 1 and len(x) != 1):
+ yerror = numpy.ravel(yerror)
+
+ errorbars = axes.errorbar(x, y, label=legend,
+ xerr=xerror, yerr=yerror,
+ linestyle=' ', color=errorbarColor)
+ artists += list(errorbars.get_children())
+
+ if hasattr(color, 'dtype') and len(color) == len(x):
+ # scatter plot
+ if color.dtype not in [numpy.float32, numpy.float]:
+ actualColor = color / 255.
+ else:
+ actualColor = color
+
+ if linestyle not in ["", " ", None]:
+ # scatter plot with an actual line ...
+ # we need to assign a color ...
+ curveList = axes.plot(x, y, label=legend,
+ linestyle=linestyle,
+ color=actualColor[0],
+ linewidth=linewidth,
+ picker=picker,
+ marker=None)
+ artists += list(curveList)
+
+ scatter = axes.scatter(x, y,
+ label=legend,
+ color=actualColor,
+ marker=symbol,
+ picker=picker,
+ s=symbolsize)
+ artists.append(scatter)
+
+ if fill:
+ artists.append(axes.fill_between(
+ x, FLOAT32_MINPOS, y, facecolor=actualColor[0], linestyle=''))
+
+ else: # Curve
+ curveList = axes.plot(x, y,
+ label=legend,
+ linestyle=linestyle,
+ color=color,
+ linewidth=linewidth,
+ marker=symbol,
+ picker=picker,
+ markersize=symbolsize)
+ artists += list(curveList)
+
+ if fill:
+ artists.append(
+ axes.fill_between(x, FLOAT32_MINPOS, y, facecolor=color))
+
+ for artist in artists:
+ artist.set_zorder(z)
+ if alpha < 1:
+ artist.set_alpha(alpha)
+
+ return Container(artists)
+
+ def addImage(self, data, legend,
+ origin, scale, z,
+ selectable, draggable,
+ colormap, alpha):
+ # Non-uniform image
+ # http://wiki.scipy.org/Cookbook/Histograms
+ # Non-linear axes
+ # http://stackoverflow.com/questions/11488800/non-linear-axes-for-imshow-in-matplotlib
+ for parameter in (data, legend, origin, scale, z,
+ selectable, draggable):
+ assert parameter is not None
+
+ origin = float(origin[0]), float(origin[1])
+ scale = float(scale[0]), float(scale[1])
+ height, width = data.shape[0:2]
+
+ picker = (selectable or draggable)
+
+ # Debian 7 specific support
+ # No transparent colormap with matplotlib < 1.2.0
+ # Add support for transparent colormap for uint8 data with
+ # colormap with 256 colors, linear norm, [0, 255] range
+ if matplotlib.__version__ < '1.2.0':
+ if (len(data.shape) == 2 and colormap['name'] is None and
+ 'colors' in colormap):
+ colors = numpy.array(colormap['colors'], copy=False)
+ if (colors.shape[-1] == 4 and
+ not numpy.all(numpy.equal(colors[3], 255))):
+ # This is a transparent colormap
+ if (colors.shape == (256, 4) and
+ colormap['normalization'] == 'linear' and
+ not colormap['autoscale'] and
+ colormap['vmin'] == 0 and
+ colormap['vmax'] == 255 and
+ data.dtype == numpy.uint8):
+ # Supported case, convert data to RGBA
+ data = colors[data.reshape(-1)].reshape(
+ data.shape + (4,))
+ else:
+ _logger.warning(
+ 'matplotlib %s does not support transparent '
+ 'colormap.', matplotlib.__version__)
+
+ if ((height * width) > 5.0e5 and
+ origin == (0., 0.) and scale == (1., 1.)):
+ imageClass = ModestImage
+ else:
+ imageClass = AxesImage
+
+ # the normalization can be a source of time waste
+ # Two possibilities, we receive data or a ready to show image
+ if len(data.shape) == 3: # RGBA image
+ image = imageClass(self.ax,
+ label="__IMAGE__" + legend,
+ interpolation='nearest',
+ picker=picker,
+ zorder=z,
+ origin='lower')
+
+ else:
+ # Convert colormap argument to matplotlib colormap
+ scalarMappable = Colors.getMPLScalarMappable(colormap, data)
+
+ # try as data
+ image = imageClass(self.ax,
+ label="__IMAGE__" + legend,
+ interpolation='nearest',
+ cmap=scalarMappable.cmap,
+ picker=picker,
+ zorder=z,
+ norm=scalarMappable.norm,
+ origin='lower')
+ if alpha < 1:
+ image.set_alpha(alpha)
+
+ # Set image extent
+ xmin = origin[0]
+ xmax = xmin + scale[0] * width
+ if scale[0] < 0.:
+ xmin, xmax = xmax, xmin
+
+ ymin = origin[1]
+ ymax = ymin + scale[1] * height
+ if scale[1] < 0.:
+ ymin, ymax = ymax, ymin
+
+ image.set_extent((xmin, xmax, ymin, ymax))
+
+ # Set image data
+ if scale[0] < 0. or scale[1] < 0.:
+ # For negative scale, step by -1
+ xstep = 1 if scale[0] >= 0. else -1
+ ystep = 1 if scale[1] >= 0. else -1
+ data = data[::ystep, ::xstep]
+
+ image.set_data(data)
+
+ self.ax.add_artist(image)
+
+ return image
+
+ def addItem(self, x, y, legend, shape, color, fill, overlay, z):
+ xView = numpy.array(x, copy=False)
+ yView = numpy.array(y, copy=False)
+
+ if shape == "line":
+ item = self.ax.plot(x, y, label=legend, color=color,
+ linestyle='-', marker=None)[0]
+
+ elif shape == "hline":
+ if hasattr(y, "__len__"):
+ y = y[-1]
+ item = self.ax.axhline(y, label=legend, color=color)
+
+ elif shape == "vline":
+ if hasattr(x, "__len__"):
+ x = x[-1]
+ item = self.ax.axvline(x, label=legend, color=color)
+
+ elif shape == 'rectangle':
+ xMin = numpy.nanmin(xView)
+ xMax = numpy.nanmax(xView)
+ yMin = numpy.nanmin(yView)
+ yMax = numpy.nanmax(yView)
+ w = xMax - xMin
+ h = yMax - yMin
+ item = Rectangle(xy=(xMin, yMin),
+ width=w,
+ height=h,
+ fill=False,
+ color=color)
+ if fill:
+ item.set_hatch('.')
+
+ self.ax.add_patch(item)
+
+ elif shape in ('polygon', 'polylines'):
+ xView = xView.reshape(1, -1)
+ yView = yView.reshape(1, -1)
+ item = Polygon(numpy.vstack((xView, yView)).T,
+ closed=(shape == 'polygon'),
+ fill=False,
+ label=legend,
+ color=color)
+ if fill and shape == 'polygon':
+ item.set_hatch('/')
+
+ self.ax.add_patch(item)
+
+ else:
+ raise NotImplementedError("Unsupported item shape %s" % shape)
+
+ item.set_zorder(z)
+
+ if overlay:
+ item.set_animated(True)
+ self._overlays.add(item)
+
+ return item
+
+ def addMarker(self, x, y, legend, text, color,
+ selectable, draggable,
+ symbol, constraint, overlay):
+ legend = "__MARKER__" + legend
+
+ if x is not None and y is not None:
+ line = self.ax.plot(x, y, label=legend,
+ linestyle=" ",
+ color=color,
+ marker=symbol,
+ markersize=10.)[-1]
+
+ if text is not None:
+ xtmp, ytmp = self.ax.transData.transform_point((x, y))
+ inv = self.ax.transData.inverted()
+ xtmp, ytmp = inv.transform_point((xtmp, ytmp))
+
+ if symbol is None:
+ valign = 'baseline'
+ else:
+ valign = 'top'
+ text = " " + text
+
+ line._infoText = self.ax.text(x, ytmp, text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment=valign)
+
+ elif x is not None:
+ line = self.ax.axvline(x, label=legend, color=color)
+ if text is not None:
+ text = " " + text
+ ymin, ymax = self.getGraphYLimits(axis='left')
+ delta = abs(ymax - ymin)
+ if ymin > ymax:
+ ymax = ymin
+ ymax -= 0.005 * delta
+ line._infoText = self.ax.text(x, ymax, text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment='top')
+
+ elif y is not None:
+ line = self.ax.axhline(y, label=legend, color=color)
+
+ if text is not None:
+ text = " " + text
+ xmin, xmax = self.getGraphXLimits()
+ delta = abs(xmax - xmin)
+ if xmin > xmax:
+ xmax = xmin
+ xmax -= 0.005 * delta
+ line._infoText = self.ax.text(xmax, y, text,
+ color=color,
+ horizontalalignment='right',
+ verticalalignment='top')
+
+ else:
+ raise RuntimeError('A marker must at least have one coordinate')
+
+ if selectable or draggable:
+ line.set_picker(5)
+
+ if overlay:
+ line.set_animated(True)
+ self._overlays.add(line)
+
+ return line
+
+ # Remove methods
+
+ def remove(self, item):
+ # Warning: It also needs to remove extra stuff if added as for markers
+ if hasattr(item, "_infoText"): # For markers text
+ item._infoText.remove()
+ item._infoText = None
+ self._overlays.discard(item)
+ item.remove()
+
+ # Interaction methods
+
+ def setGraphCursor(self, flag, color, linewidth, linestyle):
+ if flag:
+ lineh = self.ax.axhline(
+ self.ax.get_ybound()[0], visible=False, color=color,
+ linewidth=linewidth, linestyle=linestyle)
+ lineh.set_animated(True)
+
+ linev = self.ax.axvline(
+ self.ax.get_xbound()[0], visible=False, color=color,
+ linewidth=linewidth, linestyle=linestyle)
+ linev.set_animated(True)
+
+ self._graphCursor = lineh, linev
+ else:
+ if self._graphCursor is not None:
+ lineh, linev = self._graphCursor
+ lineh.remove()
+ linev.remove()
+ self._graphCursor = tuple()
+
+ # Active curve
+
+ def setCurveColor(self, curve, color):
+ # Store Line2D and PathCollection
+ for artist in curve.get_children():
+ if isinstance(artist, (Line2D, LineCollection)):
+ artist.set_color(color)
+ elif isinstance(artist, PathCollection):
+ artist.set_facecolors(color)
+ artist.set_edgecolors(color)
+ else:
+ _logger.warning(
+ 'setActiveCurve ignoring artist %s', str(artist))
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ return self.fig.canvas
+
+ def _enableAxis(self, axis, flag=True):
+ """Show/hide Y axis
+
+ :param str axis: Axis name: 'left' or 'right'
+ :param bool flag: Default, True
+ """
+ assert axis in ('right', 'left')
+ axes = self.ax2 if axis == 'right' else self.ax
+ axes.get_yaxis().set_visible(flag)
+
+ def replot(self):
+ """Do not perform rendering.
+
+ Override in subclass to actually draw something.
+ """
+ # TODO images, markers? scatter plot? move in remove?
+ # Right Y axis only support curve for now
+ # Hide right Y axis if no line is present
+ self._dirtyLimits = False
+ if not self.ax2.lines:
+ self._enableAxis('right', False)
+
+ def saveGraph(self, fileName, fileFormat, dpi):
+ # fileName can be also a StringIO or file instance
+ if dpi is not None:
+ self.fig.savefig(fileName, format=fileFormat, dpi=dpi)
+ else:
+ self.fig.savefig(fileName, format=fileFormat)
+ self._plot._setDirtyPlot()
+
+ # Graph labels
+
+ def setGraphTitle(self, title):
+ self.ax.set_title(title)
+
+ def setGraphXLabel(self, label):
+ self.ax.set_xlabel(label)
+
+ def setGraphYLabel(self, label, axis):
+ axes = self.ax if axis == 'left' else self.ax2
+ axes.set_ylabel(label)
+
+ # Graph limits
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ # Let matplotlib taking care of keep aspect ratio if any
+ self._dirtyLimits = True
+ self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax))
+
+ if y2min is not None and y2max is not None:
+ if not self.isYAxisInverted():
+ self.ax2.set_ylim(min(y2min, y2max), max(y2min, y2max))
+ else:
+ self.ax2.set_ylim(max(y2min, y2max), min(y2min, y2max))
+
+ if not self.isYAxisInverted():
+ self.ax.set_ylim(min(ymin, ymax), max(ymin, ymax))
+ else:
+ self.ax.set_ylim(max(ymin, ymax), min(ymin, ymax))
+
+ def getGraphXLimits(self):
+ if self._dirtyLimits and self.isKeepDataAspectRatio():
+ self.replot() # makes sure we get the right limits
+ return self.ax.get_xbound()
+
+ def setGraphXLimits(self, xmin, xmax):
+ self._dirtyLimits = True
+ self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax))
+
+ def getGraphYLimits(self, axis):
+ assert axis in ('left', 'right')
+ ax = self.ax2 if axis == 'right' else self.ax
+
+ if not ax.get_visible():
+ return None
+
+ if self._dirtyLimits and self.isKeepDataAspectRatio():
+ self.replot() # makes sure we get the right limits
+
+ return ax.get_ybound()
+
+ def setGraphYLimits(self, ymin, ymax, axis):
+ ax = self.ax2 if axis == 'right' else self.ax
+ if ymax < ymin:
+ ymin, ymax = ymax, ymin
+ self._dirtyLimits = True
+
+ if self.isKeepDataAspectRatio():
+ # matplotlib keeps limits of shared axis when keeping aspect ratio
+ # So x limits are kept when changing y limits....
+ # Change x limits first by taking into account aspect ratio
+ # and then change y limits.. so matplotlib does not need
+ # to make change (to y) to keep aspect ratio
+ xmin, xmax = ax.get_xbound()
+ curYMin, curYMax = ax.get_ybound()
+
+ newXRange = (xmax - xmin) * (ymax - ymin) / (curYMax - curYMin)
+ xcenter = 0.5 * (xmin + xmax)
+ ax.set_xlim(xcenter - 0.5 * newXRange, xcenter + 0.5 * newXRange)
+
+ if not self.isYAxisInverted():
+ ax.set_ylim(ymin, ymax)
+ else:
+ ax.set_ylim(ymax, ymin)
+
+ # Graph axes
+
+ def setXAxisLogarithmic(self, flag):
+ self.ax2.set_xscale('log' if flag else 'linear')
+ self.ax.set_xscale('log' if flag else 'linear')
+
+ def setYAxisLogarithmic(self, flag):
+ self.ax2.set_yscale('log' if flag else 'linear')
+ self.ax.set_yscale('log' if flag else 'linear')
+
+ def setYAxisInverted(self, flag):
+ if self.ax.yaxis_inverted() != bool(flag):
+ self.ax.invert_yaxis()
+
+ def isYAxisInverted(self):
+ return self.ax.yaxis_inverted()
+
+ def isKeepDataAspectRatio(self):
+ return self.ax.get_aspect() in (1.0, 'equal')
+
+ def setKeepDataAspectRatio(self, flag):
+ self.ax.set_aspect(1.0 if flag else 'auto')
+ self.ax2.set_aspect(1.0 if flag else 'auto')
+
+ def setGraphGrid(self, which):
+ self.ax.grid(False, which='both') # Disable all grid first
+ if which is not None:
+ self.ax.grid(True, which=which)
+
+ # Data <-> Pixel coordinates conversion
+
+ def dataToPixel(self, x, y, axis):
+ ax = self.ax2 if axis == "right" else self.ax
+
+ pixels = ax.transData.transform_point((x, y))
+ xPixel, yPixel = pixels.T
+ return xPixel, yPixel
+
+ def pixelToData(self, x, y, axis, check):
+ ax = self.ax2 if axis == "right" else self.ax
+
+ inv = ax.transData.inverted()
+ x, y = inv.transform_point((x, y))
+
+ if check:
+ xmin, xmax = self.getGraphXLimits()
+ ymin, ymax = self.getGraphYLimits(axis=axis)
+
+ if x > xmax or x < xmin or y > ymax or y < ymin:
+ return None # (x, y) is out of plot area
+
+ return x, y
+
+ def getPlotBoundsInPixels(self):
+ bbox = self.ax.get_window_extent().transformed(
+ self.fig.dpi_scale_trans.inverted())
+ dpi = self.fig.dpi
+ # Warning this is not returning int...
+ return (bbox.bounds[0] * dpi, bbox.bounds[1] * dpi,
+ bbox.bounds[2] * dpi, bbox.bounds[3] * dpi)
+
+
+class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
+ """QWidget matplotlib backend using a QtAgg canvas.
+
+ It adds fast overlay drawing and mouse event management.
+ """
+
+ _sigPostRedisplay = qt.Signal()
+ """Signal handling automatic asynchronous replot"""
+
+ def __init__(self, plot, parent=None):
+ self._insideResizeEventMethod = False
+
+ BackendMatplotlib.__init__(self, plot, parent)
+ FigureCanvasQTAgg.__init__(self, self.fig)
+ self.setParent(parent)
+
+ FigureCanvasQTAgg.setSizePolicy(
+ self, qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding)
+ FigureCanvasQTAgg.updateGeometry(self)
+
+ # Make postRedisplay asynchronous using Qt signal
+ self._sigPostRedisplay.connect(
+ super(BackendMatplotlibQt, self).postRedisplay,
+ qt.Qt.QueuedConnection)
+
+ self._picked = None
+
+ self.mpl_connect('button_press_event', self._onMousePress)
+ self.mpl_connect('button_release_event', self._onMouseRelease)
+ self.mpl_connect('motion_notify_event', self._onMouseMove)
+ self.mpl_connect('scroll_event', self._onMouseWheel)
+
+ def postRedisplay(self):
+ self._sigPostRedisplay.emit()
+
+ # Mouse event forwarding
+
+ _MPL_TO_PLOT_BUTTONS = {1: 'left', 2: 'middle', 3: 'right'}
+
+ def _onMousePress(self, event):
+ self._plot.onMousePress(
+ event.x, event.y, self._MPL_TO_PLOT_BUTTONS[event.button])
+
+ def _onMouseMove(self, event):
+ if self._graphCursor:
+ lineh, linev = self._graphCursor
+ if event.inaxes != self.ax and lineh.get_visible():
+ lineh.set_visible(False)
+ linev.set_visible(False)
+ self._plot._setDirtyPlot(overlayOnly=True)
+ else:
+ linev.set_visible(True)
+ linev.set_xdata((event.xdata, event.xdata))
+ lineh.set_visible(True)
+ lineh.set_ydata((event.ydata, event.ydata))
+ self._plot._setDirtyPlot(overlayOnly=True)
+ # onMouseMove must trigger replot if dirty flag is raised
+
+ self._plot.onMouseMove(event.x, event.y)
+
+ def _onMouseRelease(self, event):
+ self._plot.onMouseRelease(
+ event.x, event.y, self._MPL_TO_PLOT_BUTTONS[event.button])
+
+ def _onMouseWheel(self, event):
+ self._plot.onMouseWheel(event.x, event.y, event.step)
+
+ def leaveEvent(self, event):
+ """QWidget event handler"""
+ self._plot.onMouseLeaveWidget()
+
+ # picking
+
+ def _onPick(self, event):
+ # TODO not very nice and fragile, find a better way?
+ # Make a selection according to kind
+ if self._picked is None:
+ _logger.error('Internal picking error')
+ return
+
+ label = event.artist.get_label()
+ if label.startswith('__MARKER__'):
+ self._picked.append({'kind': 'marker', 'legend': label[10:]})
+
+ elif label.startswith('__IMAGE__'):
+ self._picked.append({'kind': 'image', 'legend': label[9:]})
+
+ else: # it's a curve, item have no picker for now
+ if isinstance(event.artist, PathCollection):
+ data = event.artist.get_offsets()[event.ind, :]
+ xdata, ydata = data[:, 0], data[:, 1]
+ elif isinstance(event.artist, Line2D):
+ xdata = event.artist.get_xdata()[event.ind]
+ ydata = event.artist.get_ydata()[event.ind]
+ else:
+ _logger.info('Unsupported artist, ignored')
+ return
+
+ self._picked.append({'kind': 'curve', 'legend': label,
+ 'xdata': xdata, 'ydata': ydata})
+
+ def pickItems(self, x, y):
+ self._picked = []
+
+ # Weird way to do an explicit picking: Simulate a button press event
+ mouseEvent = MouseEvent('button_press_event', self, x, y)
+ cid = self.mpl_connect('pick_event', self._onPick)
+ self.fig.pick(mouseEvent)
+ self.mpl_disconnect(cid)
+ picked = self._picked
+ self._picked = None
+
+ return picked
+
+ # replot control
+
+ def resizeEvent(self, event):
+ self._insideResizeEventMethod = True
+ # Need to dirty the whole plot on resize.
+ self._plot._setDirtyPlot()
+ FigureCanvasQTAgg.resizeEvent(self, event)
+ self._insideResizeEventMethod = False
+
+ def draw(self):
+ """Override canvas draw method to support faster draw of overlays."""
+ if self._plot._getDirtyPlot(): # Need a full redraw
+ FigureCanvasQTAgg.draw(self)
+ self._background = None # Any saved background is dirty
+
+ if (self._overlays or self._graphCursor or
+ self._plot._getDirtyPlot() == 'overlay'):
+ # There are overlays or crosshair, or they is just no more overlays
+
+ # Specific case: called from resizeEvent:
+ # avoid store/restore background, just draw the overlay
+ if not self._insideResizeEventMethod:
+ if self._background is None: # First store the background
+ self._background = self.copy_from_bbox(self.fig.bbox)
+
+ self.restore_region(self._background)
+
+ # This assume that items are only on left/bottom Axes
+ for item in self._overlays:
+ self.ax.draw_artist(item)
+
+ for item in self._graphCursor:
+ self.ax.draw_artist(item)
+
+ self.blit(self.fig.bbox)
+
+ def replot(self):
+ BackendMatplotlib.replot(self)
+ self.draw()
+
+ # cursor
+
+ _QT_CURSORS = {
+ None: qt.Qt.ArrowCursor,
+ BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor,
+ BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor,
+ BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor,
+ BackendBase.CURSOR_SIZE_VER: qt.Qt.SizeVerCursor,
+ BackendBase.CURSOR_SIZE_ALL: qt.Qt.SizeAllCursor,
+ }
+
+ def setGraphCursorShape(self, cursor):
+ cursor = self._QT_CURSORS[cursor]
+
+ FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor))
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
new file mode 100644
index 0000000..bc10eca
--- /dev/null
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -0,0 +1,1631 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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.
+#
+# ############################################################################*/
+"""OpenGL Plot backend."""
+
+from __future__ import division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "21/03/2017"
+
+from collections import OrderedDict, namedtuple
+from ctypes import c_void_p
+import logging
+
+import numpy
+
+from .._utils import FLOAT32_MINPOS
+from . import BackendBase
+from .. import Colors
+from ... import qt
+
+from ..._glutils import gl
+from ... import _glutils as glu
+from .glutils import (
+ GLPlotCurve2D, GLPlotColormap, GLPlotRGBAImage, GLPlotFrame2D,
+ mat4Ortho, mat4Identity,
+ LEFT, RIGHT, BOTTOM, TOP,
+ Text2D, Shape2D)
+from .glutils.PlotImageFile import saveImageToFile
+
+_logger = logging.getLogger(__name__)
+
+
+# TODO idea: BackendQtMixIn class to share code between mpl and gl
+# TODO check if OpenGL is available
+# TODO make an off-screen mesa backend
+
+# Bounds ######################################################################
+
+class Range(namedtuple('Range', ('min_', 'max_'))):
+ """Describes a 1D range"""
+
+ @property
+ def range_(self):
+ return self.max_ - self.min_
+
+ @property
+ def center(self):
+ return 0.5 * (self.min_ + self.max_)
+
+
+class Bounds(object):
+ """Describes plot bounds with 2 y axis"""
+
+ def __init__(self, xMin, xMax, yMin, yMax, y2Min, y2Max):
+ self._xAxis = Range(xMin, xMax)
+ self._yAxis = Range(yMin, yMax)
+ self._y2Axis = Range(y2Min, y2Max)
+
+ def __repr__(self):
+ return "x: %s, y: %s, y2: %s" % (repr(self._xAxis),
+ repr(self._yAxis),
+ repr(self._y2Axis))
+
+ @property
+ def xAxis(self):
+ return self._xAxis
+
+ @property
+ def yAxis(self):
+ return self._yAxis
+
+ @property
+ def y2Axis(self):
+ return self._y2Axis
+
+
+# Content #####################################################################
+
+class PlotDataContent(object):
+ """Manage plot data content: images and curves.
+
+ This class is only meant to work with _OpenGLPlotCanvas.
+ """
+
+ _PRIMITIVE_TYPES = 'curve', 'image'
+
+ def __init__(self):
+ self._primitives = OrderedDict() # For images and curves
+
+ def add(self, primitive):
+ """Add a curve or image to the content dictionary.
+
+ This function generates the key in the dict from the primitive.
+
+ :param primitive: The primitive to add.
+ :type primitive: Instance of GLPlotCurve2D, GLPlotColormap,
+ GLPlotRGBAImage.
+ """
+ if isinstance(primitive, GLPlotCurve2D):
+ primitiveType = 'curve'
+ elif isinstance(primitive, (GLPlotColormap, GLPlotRGBAImage)):
+ primitiveType = 'image'
+ else:
+ raise RuntimeError('Unsupported object type: %s', primitive)
+
+ key = primitiveType, primitive.info['legend']
+ self._primitives[key] = primitive
+
+ def get(self, primitiveType, legend):
+ """Get the corresponding primitive of given type with given legend.
+
+ :param str primitiveType: Type of primitive ('curve' or 'image').
+ :param str legend: The legend of the primitive to retrieve.
+ :return: The corresponding curve or None if no such curve.
+ """
+ assert primitiveType in self._PRIMITIVE_TYPES
+ return self._primitives.get((primitiveType, legend))
+
+ def pop(self, primitiveType, key):
+ """Pop the corresponding curve or return None if no such curve.
+
+ :param str primitiveType:
+ :param str key:
+ :return:
+ """
+ assert primitiveType in self._PRIMITIVE_TYPES
+ return self._primitives.pop((primitiveType, key), None)
+
+ def zOrderedPrimitives(self, reverse=False):
+ """List of primitives sorted according to their z order.
+
+ It is a stable sort (as sorted):
+ Original order is preserved when key is the same.
+
+ :param bool reverse: Ascending (True, default) or descending (False).
+ """
+ return sorted(self._primitives.values(),
+ key=lambda primitive: primitive.info['zOrder'],
+ reverse=reverse)
+
+ def primitives(self):
+ """Iterator over all primitives."""
+ return self._primitives.values()
+
+ def primitiveKeys(self, primitiveType):
+ """Iterator over primitives of a specific type."""
+ assert primitiveType in self._PRIMITIVE_TYPES
+ for type_, key in self._primitives.keys():
+ if type_ == primitiveType:
+ yield key
+
+ def getBounds(self, xPositive=False, yPositive=False):
+ """Bounds of the data.
+
+ Can return strictly positive bounds (for log scale).
+ In this case, curves are clipped to their smaller positive value
+ and images with negative min are ignored.
+
+ :param bool xPositive: True to get strictly positive range.
+ :param bool yPositive: True to get strictly positive range.
+ :return: The range of data for x, y and y2, or default (1., 100.)
+ if no range found for one dimension.
+ :rtype: Bounds
+ """
+ xMin, yMin, y2Min = float('inf'), float('inf'), float('inf')
+ xMax = 0. if xPositive else -float('inf')
+ if yPositive:
+ yMax, y2Max = 0., 0.
+ else:
+ yMax, y2Max = -float('inf'), -float('inf')
+
+ for item in self._primitives.values():
+ # To support curve <= 0. and log and bypass images:
+ # If positive only, uses x|yMinPos if available
+ # and bypass other data with negative min bounds
+ if xPositive:
+ itemXMin = getattr(item, 'xMinPos', item.xMin)
+ if itemXMin is None or itemXMin < FLOAT32_MINPOS:
+ continue
+ else:
+ itemXMin = item.xMin
+
+ if yPositive:
+ itemYMin = getattr(item, 'yMinPos', item.yMin)
+ if itemYMin is None or itemYMin < FLOAT32_MINPOS:
+ continue
+ else:
+ itemYMin = item.yMin
+
+ if itemXMin < xMin:
+ xMin = itemXMin
+ if item.xMax > xMax:
+ xMax = item.xMax
+
+ if item.info.get('yAxis') == 'right':
+ if itemYMin < y2Min:
+ y2Min = itemYMin
+ if item.yMax > y2Max:
+ y2Max = item.yMax
+ else:
+ if itemYMin < yMin:
+ yMin = itemYMin
+ if item.yMax > yMax:
+ yMax = item.yMax
+
+ # One of the limit has not been updated, return default range
+ if xMin >= xMax:
+ xMin, xMax = 1., 100.
+ if yMin >= yMax:
+ yMin, yMax = 1., 100.
+ if y2Min >= y2Max:
+ y2Min, y2Max = 1., 100.
+
+ return Bounds(xMin, xMax, yMin, yMax, y2Min, y2Max)
+
+
+# shaders #####################################################################
+
+_baseVertShd = """
+ attribute vec2 position;
+ uniform mat4 matrix;
+ uniform bvec2 isLog;
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ vec2 posTransformed = position;
+ if (isLog.x) {
+ posTransformed.x = oneOverLog10 * log(position.x);
+ }
+ if (isLog.y) {
+ posTransformed.y = oneOverLog10 * log(position.y);
+ }
+ gl_Position = matrix * vec4(posTransformed, 0.0, 1.0);
+ }
+ """
+
+_baseFragShd = """
+ uniform vec4 color;
+ uniform int hatchStep;
+ uniform float tickLen;
+
+ void main(void) {
+ if (tickLen != 0.) {
+ if (mod((gl_FragCoord.x + gl_FragCoord.y) / tickLen, 2.) < 1.) {
+ gl_FragColor = color;
+ } else {
+ discard;
+ }
+ } else if (hatchStep == 0 ||
+ mod(gl_FragCoord.x - gl_FragCoord.y, float(hatchStep)) == 0.) {
+ gl_FragColor = color;
+ } else {
+ discard;
+ }
+ }
+ """
+
+_texVertShd = """
+ attribute vec2 position;
+ attribute vec2 texCoords;
+ uniform mat4 matrix;
+
+ varying vec2 coords;
+
+ void main(void) {
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ coords = texCoords;
+ }
+ """
+
+_texFragShd = """
+ uniform sampler2D tex;
+
+ varying vec2 coords;
+
+ void main(void) {
+ gl_FragColor = texture2D(tex, coords);
+ }
+ """
+
+
+# BackendOpenGL ###############################################################
+
+_current_context = None
+
+
+def _getContext():
+ assert _current_context is not None
+ return _current_context
+
+
+class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
+ """OpenGL-based Plot backend.
+
+ WARNINGS:
+ Unless stated otherwise, this API is NOT thread-safe and MUST be
+ called from the main thread.
+ When numpy arrays are passed as arguments to the API (through
+ :func:`addCurve` and :func:`addImage`), they are copied only if
+ required.
+ So, the caller should not modify these arrays afterwards.
+ """
+
+ _sigPostRedisplay = qt.Signal()
+ """Signal handling automatic asynchronous replot"""
+
+ def __init__(self, plot, parent=None):
+ qt.QGLWidget.__init__(self, parent)
+ BackendBase.BackendBase.__init__(self, plot, parent)
+
+ self.matScreenProj = mat4Identity()
+
+ self._progBase = glu.Program(
+ _baseVertShd, _baseFragShd, attrib0='position')
+ self._progTex = glu.Program(
+ _texVertShd, _texFragShd, attrib0='position')
+ self._plotFBOs = {}
+
+ self._keepDataAspectRatio = False
+
+ self._devicePixelRatio = 1.0
+
+ self._crosshairCursor = None
+ self._mousePosInPixels = None
+
+ self._markers = OrderedDict()
+ self._items = OrderedDict()
+ self._plotContent = PlotDataContent() # For images and curves
+ self._selectionAreas = OrderedDict()
+ self._glGarbageCollector = []
+
+ self._plotFrame = GLPlotFrame2D(
+ margins={'left': 100, 'right': 50, 'top': 50, 'bottom': 50})
+
+ # Make postRedisplay asynchronous using Qt signal
+ self._sigPostRedisplay.connect(
+ super(BackendOpenGL, self).postRedisplay,
+ qt.Qt.QueuedConnection)
+
+ # TODO is this needed? move it Plot?
+ self.setGraphXLimits(0., 100.)
+ self.setGraphYLimits(0., 100., axis='right')
+ self.setGraphYLimits(0., 100., axis='left')
+
+ self.setAutoFillBackground(False)
+ self.setMouseTracking(True)
+
+ # QWidget
+
+ _MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
+
+ def sizeHint(self):
+ return qt.QSize(8 * 80, 6 * 80) # Mimic MatplotlibBackend
+
+ def mousePressEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+ btn = self._MOUSE_BTNS[event.button()]
+ self._plot.onMousePress(xPixel, yPixel, btn)
+ event.accept()
+
+ def mouseMoveEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+
+ # Handle crosshair
+ inXPixel, inYPixel = self._mouseInPlotArea(xPixel, yPixel)
+ isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel
+
+ previousMousePosInPixels = self._mousePosInPixels
+ self._mousePosInPixels = (xPixel, yPixel) if isCursorInPlot else None
+ if (self._crosshairCursor is not None and
+ previousMousePosInPixels != self._crosshairCursor):
+ # Avoid replot when cursor remains outside plot area
+ self._plot._setDirtyPlot(overlayOnly=True)
+
+ self._plot.onMouseMove(xPixel, yPixel)
+ event.accept()
+
+ def mouseReleaseEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+
+ btn = self._MOUSE_BTNS[event.button()]
+ self._plot.onMouseRelease(xPixel, yPixel, btn)
+ event.accept()
+
+ def wheelEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+
+ if hasattr(event, 'angleDelta'): # Qt 5
+ delta = event.angleDelta().y()
+ else: # Qt 4 support
+ delta = event.delta()
+ angleInDegrees = delta / 8.
+ self._plot.onMouseWheel(xPixel, yPixel, angleInDegrees)
+ event.accept()
+
+ def leaveEvent(self, _):
+ self._plot.onMouseLeaveWidget()
+
+ # QGLWidget API
+
+ @staticmethod
+ def _setBlendFuncGL():
+ # glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
+ gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA,
+ gl.GL_ONE_MINUS_SRC_ALPHA,
+ gl.GL_ONE,
+ gl.GL_ONE)
+
+ def initializeGL(self):
+ gl.testGL()
+
+ gl.glClearColor(1., 1., 1., 1.)
+ gl.glClearStencil(0)
+
+ gl.glEnable(gl.GL_BLEND)
+ self._setBlendFuncGL()
+
+ # For lines
+ gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
+
+ # For points
+ gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
+ gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
+ # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
+
+ def _paintDirectGL(self):
+ self._renderPlotAreaGL()
+ self._plotFrame.render()
+ self._renderMarkersGL()
+ self._renderOverlayGL()
+
+ def _paintFBOGL(self):
+ context = glu.getGLContext()
+ plotFBOTex = self._plotFBOs.get(context)
+ if (self._plot._getDirtyPlot() or self._plotFrame.isDirty or
+ plotFBOTex is None):
+ self._plotVertices = numpy.array(((-1., -1., 0., 0.),
+ (1., -1., 1., 0.),
+ (-1., 1., 0., 1.),
+ (1., 1., 1., 1.)),
+ dtype=numpy.float32)
+ if plotFBOTex is None or \
+ plotFBOTex.shape[1] != self._plotFrame.size[0] or \
+ plotFBOTex.shape[0] != self._plotFrame.size[1]:
+ if plotFBOTex is not None:
+ plotFBOTex.discard()
+ plotFBOTex = glu.FramebufferTexture(
+ gl.GL_RGBA,
+ shape=(self._plotFrame.size[1],
+ self._plotFrame.size[0]),
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=(gl.GL_CLAMP_TO_EDGE,
+ gl.GL_CLAMP_TO_EDGE))
+ self._plotFBOs[context] = plotFBOTex
+
+ with plotFBOTex:
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT)
+ self._renderPlotAreaGL()
+ self._plotFrame.render()
+
+ # Render plot in screen coords
+ gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1])
+
+ self._progTex.use()
+ texUnit = 0
+
+ gl.glUniform1i(self._progTex.uniforms['tex'], texUnit)
+ gl.glUniformMatrix4fv(self._progTex.uniforms['matrix'], 1, gl.GL_TRUE,
+ mat4Identity())
+
+ stride = self._plotVertices.shape[-1] * self._plotVertices.itemsize
+ gl.glEnableVertexAttribArray(self._progTex.attributes['position'])
+ gl.glVertexAttribPointer(self._progTex.attributes['position'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, self._plotVertices)
+
+ texCoordsPtr = c_void_p(self._plotVertices.ctypes.data +
+ 2 * self._plotVertices.itemsize) # Better way?
+ gl.glEnableVertexAttribArray(self._progTex.attributes['texCoords'])
+ gl.glVertexAttribPointer(self._progTex.attributes['texCoords'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, texCoordsPtr)
+
+ with plotFBOTex.texture:
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._plotVertices))
+
+ self._renderMarkersGL()
+ self._renderOverlayGL()
+
+ def paintGL(self):
+ global _current_context
+ _current_context = self.context()
+
+ glu.setGLContextGetter(_getContext)
+
+ if hasattr(self, 'windowHandle'): # Qt 5
+ devicePixelRatio = self.windowHandle().devicePixelRatio()
+ if devicePixelRatio != self._devicePixelRatio:
+ self._devicePixelRatio = devicePixelRatio
+ self.resizeGL(int(self.width() * devicePixelRatio),
+ int(self.height() * devicePixelRatio))
+
+ # Release OpenGL resources
+ for item in self._glGarbageCollector:
+ item.discard()
+ self._glGarbageCollector = []
+
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT)
+
+ # Check if window is large enough
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+ if plotWidth <= 2 or plotHeight <= 2:
+ return
+
+ # self._paintDirectGL()
+ self._paintFBOGL()
+
+ glu.setGLContextGetter()
+ _current_context = None
+
+ def _nonOrthoAxesLineMarkerPrimitives(self, marker, pixelOffset):
+ """Generates the vertices and label for a line marker.
+
+ :param dict marker: Description of a line marker
+ :param int pixelOffset: Offset of text from borders in pixels
+ :return: Line vertices and Text label or None
+ :rtype: 2-tuple (2x2 numpy.array of float, Text2D)
+ """
+ label, vertices = None, None
+
+ xCoord, yCoord = marker['x'], marker['y']
+ assert xCoord is None or yCoord is None # Specific to line markers
+
+ # Get plot corners in data coords
+ plotLeft, plotTop, plotWidth, plotHeight = self.getPlotBoundsInPixels()
+
+ corners = [(plotLeft, plotTop),
+ (plotLeft, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop)]
+ corners = numpy.array([self.pixelToData(x, y, axis='left', check=False)
+ for (x, y) in corners])
+
+ borders = {
+ 'right': (corners[3], corners[2]),
+ 'top': (corners[0], corners[3]),
+ 'bottom': (corners[2], corners[1]),
+ 'left': (corners[1], corners[0])
+ }
+
+ textLayouts = { # align, valign, offsets
+ 'right': (RIGHT, BOTTOM, (-1., -1.)),
+ 'top': (LEFT, TOP, (1., 1.)),
+ 'bottom': (LEFT, BOTTOM, (1., -1.)),
+ 'left': (LEFT, BOTTOM, (1., -1.))
+ }
+
+ if xCoord is None: # Horizontal line in data space
+ if marker['text'] is not None:
+ # Find intersection of hline with borders in data
+ # Order is important as it stops at first intersection
+ for border_name in ('right', 'top', 'bottom', 'left'):
+ (x0, y0), (x1, y1) = borders[border_name]
+
+ if min(y0, y1) <= yCoord < max(y0, y1):
+ xIntersect = (yCoord - y0) * (x1 - x0) / (y1 - y0) + x0
+
+ # Add text label
+ pixelPos = self.dataToPixel(
+ xIntersect, yCoord, axis='left', check=False)
+
+ align, valign, offsets = textLayouts[border_name]
+
+ x = pixelPos[0] + offsets[0] * pixelOffset
+ y = pixelPos[1] + offsets[1] * pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=align, valign=valign)
+ break # Stop at first intersection
+
+ xMin, xMax = corners[:, 0].min(), corners[:, 0].max()
+ vertices = numpy.array(
+ ((xMin, yCoord), (xMax, yCoord)), dtype=numpy.float32)
+
+ else: # yCoord is None: vertical line in data space
+ if marker['text'] is not None:
+ # Find intersection of hline with borders in data
+ # Order is important as it stops at first intersection
+ for border_name in ('top', 'bottom', 'right', 'left'):
+ (x0, y0), (x1, y1) = borders[border_name]
+ if min(x0, x1) <= xCoord < max(x0, x1):
+ yIntersect = (xCoord - x0) * (y1 - y0) / (x1 - x0) + y0
+
+ # Add text label
+ pixelPos = self.dataToPixel(
+ xCoord, yIntersect, axis='left', check=False)
+
+ align, valign, offsets = textLayouts[border_name]
+
+ x = pixelPos[0] + offsets[0] * pixelOffset
+ y = pixelPos[1] + offsets[1] * pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=align, valign=valign)
+ break # Stop at first intersection
+
+ yMin, yMax = corners[:, 1].min(), corners[:, 1].max()
+ vertices = numpy.array(
+ ((xCoord, yMin), (xCoord, yMax)), dtype=numpy.float32)
+
+ return vertices, label
+
+ def _renderMarkersGL(self):
+ if len(self._markers) == 0:
+ return
+
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+
+ isXLog = self._plotFrame.xAxis.isLog
+ isYLog = self._plotFrame.yAxis.isLog
+
+ # Render in plot area
+ gl.glScissor(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+ gl.glEnable(gl.GL_SCISSOR_TEST)
+
+ gl.glViewport(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+
+ # Prepare vertical and horizontal markers rendering
+ self._progBase.use()
+ gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE,
+ self._plotFrame.transformedDataProjMat)
+ gl.glUniform2i(self._progBase.uniforms['isLog'], isXLog, isYLog)
+ gl.glUniform1i(self._progBase.uniforms['hatchStep'], 0)
+ gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.)
+ posAttrib = self._progBase.attributes['position']
+
+ labels = []
+ pixelOffset = 3
+
+ for marker in self._markers.values():
+ xCoord, yCoord = marker['x'], marker['y']
+
+ if ((isXLog and xCoord is not None and
+ xCoord < FLOAT32_MINPOS) or
+ (isYLog and yCoord is not None and
+ yCoord < FLOAT32_MINPOS)):
+ # Do not render markers with negative coords on log axis
+ continue
+
+ if xCoord is None or yCoord is None:
+ if not self.isDefaultBaseVectors(): # Non-orthogonal axes
+ vertices, label = self._nonOrthoAxesLineMarkerPrimitives(
+ marker, pixelOffset)
+ if label is not None:
+ labels.append(label)
+
+ else: # Orthogonal axes
+ pixelPos = self.dataToPixel(
+ xCoord, yCoord, axis='left', check=False)
+
+ if xCoord is None: # Horizontal line in data space
+ if marker['text'] is not None:
+ x = self._plotFrame.size[0] - \
+ self._plotFrame.margins.right - pixelOffset
+ y = pixelPos[1] - pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=RIGHT, valign=BOTTOM)
+ labels.append(label)
+
+ xMin, xMax = self._plotFrame.dataRanges.x
+ vertices = numpy.array(((xMin, yCoord),
+ (xMax, yCoord)),
+ dtype=numpy.float32)
+
+ else: # yCoord is None: vertical line in data space
+ if marker['text'] is not None:
+ x = pixelPos[0] + pixelOffset
+ y = self._plotFrame.margins.top + pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=LEFT, valign=TOP)
+ labels.append(label)
+
+ yMin, yMax = self._plotFrame.dataRanges.y
+ vertices = numpy.array(((xCoord, yMin),
+ (xCoord, yMax)),
+ dtype=numpy.float32)
+
+ self._progBase.use()
+
+ gl.glUniform4f(self._progBase.uniforms['color'],
+ *marker['color'])
+
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, vertices)
+ gl.glLineWidth(1)
+ gl.glDrawArrays(gl.GL_LINES, 0, len(vertices))
+
+ else:
+ pixelPos = self.dataToPixel(
+ xCoord, yCoord, axis='left', check=True)
+ if pixelPos is None:
+ # Do not render markers outside visible plot area
+ continue
+
+ if marker['text'] is not None:
+ x = pixelPos[0] + pixelOffset
+ y = pixelPos[1] + pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=LEFT, valign=TOP)
+ labels.append(label)
+
+ # For now simple implementation: using a curve for each marker
+ # Should pack all markers to a single set of points
+ markerCurve = GLPlotCurve2D(
+ numpy.array((xCoord,), dtype=numpy.float32),
+ numpy.array((yCoord,), dtype=numpy.float32),
+ marker=marker['symbol'],
+ markerColor=marker['color'],
+ markerSize=11)
+ markerCurve.render(self._plotFrame.transformedDataProjMat,
+ isXLog, isYLog)
+ markerCurve.discard()
+
+ gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1])
+
+ # Render marker labels
+ for label in labels:
+ label.render(self.matScreenProj)
+
+ gl.glDisable(gl.GL_SCISSOR_TEST)
+
+ def _renderOverlayGL(self):
+ # Render selection area and crosshair cursor
+ if self._selectionAreas or self._crosshairCursor is not None:
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+
+ # Scissor to plot area
+ gl.glScissor(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+ gl.glEnable(gl.GL_SCISSOR_TEST)
+
+ self._progBase.use()
+ gl.glUniform2i(self._progBase.uniforms['isLog'],
+ self._plotFrame.xAxis.isLog,
+ self._plotFrame.yAxis.isLog)
+ gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.)
+ posAttrib = self._progBase.attributes['position']
+ matrixUnif = self._progBase.uniforms['matrix']
+ colorUnif = self._progBase.uniforms['color']
+ hatchStepUnif = self._progBase.uniforms['hatchStep']
+
+ # Render selection area in plot area
+ if self._selectionAreas:
+ gl.glViewport(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+
+ gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE,
+ self._plotFrame.transformedDataProjMat)
+
+ for shape in self._selectionAreas.values():
+ if shape.isVideoInverted:
+ gl.glBlendFunc(gl.GL_ONE_MINUS_DST_COLOR, gl.GL_ZERO)
+
+ shape.render(posAttrib, colorUnif, hatchStepUnif)
+
+ if shape.isVideoInverted:
+ self._setBlendFuncGL()
+
+ # Render crosshair cursor is screen frame but with scissor
+ if (self._crosshairCursor is not None and
+ self._mousePosInPixels is not None):
+ gl.glViewport(
+ 0, 0, self._plotFrame.size[0], self._plotFrame.size[1])
+
+ gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE,
+ self.matScreenProj)
+
+ color, lineWidth = self._crosshairCursor
+ gl.glUniform4f(colorUnif, *color)
+ gl.glUniform1i(hatchStepUnif, 0)
+
+ xPixel, yPixel = self._mousePosInPixels
+ xPixel, yPixel = xPixel + 0.5, yPixel + 0.5
+ vertices = numpy.array(((0., yPixel),
+ (self._plotFrame.size[0], yPixel),
+ (xPixel, 0.),
+ (xPixel, self._plotFrame.size[1])),
+ dtype=numpy.float32)
+
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, vertices)
+ gl.glLineWidth(lineWidth)
+ gl.glDrawArrays(gl.GL_LINES, 0, len(vertices))
+
+ gl.glDisable(gl.GL_SCISSOR_TEST)
+
+ def _renderPlotAreaGL(self):
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+
+ self._plotFrame.renderGrid()
+
+ gl.glScissor(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+ gl.glEnable(gl.GL_SCISSOR_TEST)
+
+ # Matrix
+ trBounds = self._plotFrame.transformedDataRanges
+ if trBounds.x[0] == trBounds.x[1] or \
+ trBounds.y[0] == trBounds.y[1]:
+ return
+
+ isXLog = self._plotFrame.xAxis.isLog
+ isYLog = self._plotFrame.yAxis.isLog
+
+ gl.glViewport(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+
+ # Render images and curves
+ # sorted is stable: original order is preserved when key is the same
+ for item in self._plotContent.zOrderedPrimitives():
+ if item.info.get('yAxis') == 'right':
+ item.render(self._plotFrame.transformedDataY2ProjMat,
+ isXLog, isYLog)
+ else:
+ item.render(self._plotFrame.transformedDataProjMat,
+ isXLog, isYLog)
+
+ # Render Items
+ self._progBase.use()
+ gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE,
+ self._plotFrame.transformedDataProjMat)
+ gl.glUniform2i(self._progBase.uniforms['isLog'],
+ self._plotFrame.xAxis.isLog,
+ self._plotFrame.yAxis.isLog)
+ gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.)
+
+ for item in self._items.values():
+ shape2D = item.get('_shape2D')
+ if shape2D is None:
+ shape2D = Shape2D(tuple(zip(item['x'], item['y'])),
+ fill=item['fill'],
+ fillColor=item['color'],
+ stroke=True,
+ strokeColor=item['color'])
+ item['_shape2D'] = shape2D
+
+ if ((isXLog and shape2D.xMin < FLOAT32_MINPOS) or
+ (isYLog and shape2D.yMin < FLOAT32_MINPOS)):
+ # Ignore items <= 0. on log axes
+ continue
+
+ posAttrib = self._progBase.attributes['position']
+ colorUnif = self._progBase.uniforms['color']
+ hatchStepUnif = self._progBase.uniforms['hatchStep']
+ shape2D.render(posAttrib, colorUnif, hatchStepUnif)
+
+ gl.glDisable(gl.GL_SCISSOR_TEST)
+
+ def resizeGL(self, width, height):
+ if width == 0 or height == 0: # Do not resize
+ return
+ self._plotFrame.size = width, height
+
+ self.matScreenProj = mat4Ortho(0, self._plotFrame.size[0],
+ self._plotFrame.size[1], 0,
+ 1, -1)
+
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \
+ self._plotFrame.dataRanges
+ self.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+
+ # Add methods
+
+ def addCurve(self, x, y, legend,
+ color, symbol, linewidth, linestyle,
+ yaxis,
+ xerror, yerror, z, selectable,
+ fill, alpha, symbolsize):
+ for parameter in (x, y, legend, color, symbol, linewidth, linestyle,
+ yaxis, z, selectable, fill, symbolsize):
+ assert parameter is not None
+ assert yaxis in ('left', 'right')
+
+ x = numpy.array(x, dtype=numpy.float32, copy=False, order='C')
+ y = numpy.array(y, dtype=numpy.float32, copy=False, order='C')
+ if xerror is not None:
+ xerror = numpy.array(
+ xerror, dtype=numpy.float32, copy=False, order='C')
+ if yerror is not None:
+ yerror = numpy.array(
+ yerror, dtype=numpy.float32, copy=False, order='C')
+
+ # TODO check and improve this
+ if (len(color) == 4 and
+ type(color[3]) in [type(1), numpy.uint8, numpy.int8]):
+ color = numpy.array(color, dtype=numpy.float32) / 255.
+
+ if isinstance(color, numpy.ndarray) and color.ndim == 2:
+ colorArray = color
+ color = None
+ else:
+ colorArray = None
+ color = Colors.rgba(color)
+
+ if alpha < 1.: # Apply image transparency
+ if colorArray is not None and colorArray.shape[1] == 4:
+ # multiply alpha channel
+ colorArray[:, 3] = colorArray[:, 3] * alpha
+ if color is not None:
+ color = color[0], color[1], color[2], color[3] * alpha
+
+ behaviors = set()
+ if selectable:
+ behaviors.add('selectable')
+
+ curve = GLPlotCurve2D(x, y, colorArray,
+ xError=xerror,
+ yError=yerror,
+ lineStyle=linestyle,
+ lineColor=color,
+ lineWidth=linewidth,
+ marker=symbol,
+ markerColor=color,
+ markerSize=symbolsize,
+ fillColor=color if fill else None)
+ curve.info = {
+ 'legend': legend,
+ 'zOrder': z,
+ 'behaviors': behaviors,
+ 'yAxis': 'left' if yaxis is None else yaxis,
+ }
+
+ if yaxis == "right":
+ self._plotFrame.isY2Axis = True
+
+ self._plotContent.add(curve)
+
+ return legend, 'curve'
+
+ def addImage(self, data, legend,
+ origin, scale, z,
+ selectable, draggable,
+ colormap, alpha):
+ for parameter in (data, legend, origin, scale, z,
+ selectable, draggable):
+ assert parameter is not None
+
+ behaviors = set()
+ if selectable:
+ behaviors.add('selectable')
+ if draggable:
+ behaviors.add('draggable')
+
+ if data.ndim == 2:
+ # Ensure array is contiguous and eventually convert its type
+ if data.dtype in (numpy.float32, numpy.uint8, numpy.uint16):
+ data = numpy.array(data, copy=False, order='C')
+ else:
+ _logger.info(
+ 'addImage: Convert %s data to float32', str(data.dtype))
+ data = numpy.array(data, dtype=numpy.float32, order='C')
+
+ colormapIsLog = colormap['normalization'].startswith('log')
+
+ if colormap['autoscale']:
+ cmapRange = None
+ else:
+ cmapRange = colormap['vmin'], colormap['vmax']
+ assert cmapRange[0] <= cmapRange[1]
+
+ # Retrieve colormap LUT from name and color array
+ colormapLut = Colors.applyColormapToData(
+ numpy.arange(256, dtype=numpy.uint8),
+ name=colormap['name'],
+ normalization='linear',
+ autoscale=False,
+ vmin=0,
+ vmax=255,
+ colors=colormap.get('colors'))
+
+ image = GLPlotColormap(data,
+ origin,
+ scale,
+ colormapLut,
+ colormapIsLog,
+ cmapRange,
+ alpha)
+ image.info = {
+ 'legend': legend,
+ 'zOrder': z,
+ 'behaviors': behaviors
+ }
+ self._plotContent.add(image)
+
+ elif len(data.shape) == 3:
+ # For RGB, RGBA data
+ assert data.shape[2] in (3, 4)
+ assert data.dtype in (numpy.float32, numpy.uint8)
+
+ image = GLPlotRGBAImage(data, origin, scale, alpha)
+
+ image.info = {
+ 'legend': legend,
+ 'zOrder': z,
+ 'behaviors': behaviors
+ }
+
+ if self._plotFrame.xAxis.isLog and image.xMin <= 0.:
+ raise RuntimeError(
+ 'Cannot add image with X <= 0 with X axis log scale')
+ if self._plotFrame.yAxis.isLog and image.yMin <= 0.:
+ raise RuntimeError(
+ 'Cannot add image with Y <= 0 with Y axis log scale')
+
+ self._plotContent.add(image)
+
+ else:
+ raise RuntimeError("Unsupported data shape {0}".format(data.shape))
+
+ return legend, 'image'
+
+ def addItem(self, x, y, legend, shape, color, fill, overlay, z):
+ # TODO handle overlay
+ if shape not in ('polygon', 'rectangle', 'line', 'vline', 'hline'):
+ raise NotImplementedError("Unsupported shape {0}".format(shape))
+
+ x = numpy.array(x, copy=False)
+ y = numpy.array(y, copy=False)
+
+ if shape == 'rectangle':
+ xMin, xMax = x
+ x = numpy.array((xMin, xMin, xMax, xMax))
+ yMin, yMax = y
+ y = numpy.array((yMin, yMax, yMax, yMin))
+
+ # TODO is this needed?
+ if self._plotFrame.xAxis.isLog and x.min() <= 0.:
+ raise RuntimeError(
+ 'Cannot add item with X <= 0 with X axis log scale')
+ if self._plotFrame.yAxis.isLog and y.min() <= 0.:
+ raise RuntimeError(
+ 'Cannot add item with Y <= 0 with Y axis log scale')
+
+ self._items[legend] = {
+ 'shape': shape,
+ 'color': Colors.rgba(color),
+ 'fill': 'hatch' if fill else None,
+ 'x': x,
+ 'y': y
+ }
+
+ return legend, 'item'
+
+ def addMarker(self, x, y, legend, text, color,
+ selectable, draggable,
+ symbol, constraint, overlay):
+ # TODO handle overlay
+
+ if symbol is None:
+ symbol = '+'
+
+ behaviors = set()
+ if selectable:
+ behaviors.add('selectable')
+ if draggable:
+ behaviors.add('draggable')
+
+ # Apply constraint to provided position
+ isConstraint = (draggable and constraint is not None and
+ x is not None and y is not None)
+ if isConstraint:
+ x, y = constraint(x, y)
+
+ if x is not None and self._plotFrame.xAxis.isLog and x <= 0.:
+ raise RuntimeError(
+ 'Cannot add marker with X <= 0 with X axis log scale')
+ if y is not None and self._plotFrame.yAxis.isLog and y <= 0.:
+ raise RuntimeError(
+ 'Cannot add marker with Y <= 0 with Y axis log scale')
+
+ self._markers[legend] = {
+ 'x': x,
+ 'y': y,
+ 'legend': legend,
+ 'text': text,
+ 'color': Colors.rgba(color),
+ 'behaviors': behaviors,
+ 'constraint': constraint if isConstraint else None,
+ 'symbol': symbol,
+ }
+
+ return legend, 'marker'
+
+ # Remove methods
+
+ def remove(self, item):
+ legend, kind = item
+
+ if kind == 'curve':
+ curve = self._plotContent.pop('curve', legend)
+ if curve is not None:
+ # Check if some curves remains on the right Y axis
+ y2AxisItems = (item for item in self._plotContent.primitives()
+ if item.info.get('yAxis', 'left') == 'right')
+ self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None
+
+ self._glGarbageCollector.append(curve)
+
+ elif kind == 'image':
+ image = self._plotContent.pop('image', legend)
+ if image is not None:
+ self._glGarbageCollector.append(image)
+
+ elif kind == 'marker':
+ self._markers.pop(legend, False)
+
+ elif kind == 'item':
+ self._items.pop(legend, False)
+
+ else:
+ _logger.error('Unsupported kind: %s', str(kind))
+
+ # Interaction methods
+
+ _QT_CURSORS = {
+ None: qt.Qt.ArrowCursor,
+ BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor,
+ BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor,
+ BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor,
+ BackendBase.CURSOR_SIZE_VER: qt.Qt.SizeVerCursor,
+ BackendBase.CURSOR_SIZE_ALL: qt.Qt.SizeAllCursor,
+ }
+
+ def setGraphCursorShape(self, cursor):
+ cursor = self._QT_CURSORS[cursor]
+
+ super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
+
+ def setGraphCursor(self, flag, color, linewidth, linestyle):
+ if linestyle is not '-':
+ _logger.warning(
+ "BackendOpenGL.setGraphCursor linestyle parameter ignored")
+
+ if flag:
+ color = Colors.rgba(color)
+ crosshairCursor = color, linewidth
+ else:
+ crosshairCursor = None
+
+ if crosshairCursor != self._crosshairCursor:
+ self._crosshairCursor = crosshairCursor
+
+ _PICK_OFFSET = 3 # Offset in pixel used for picking
+
+ def _mouseInPlotArea(self, x, y):
+ xPlot = numpy.clip(
+ x, self._plotFrame.margins.left,
+ self._plotFrame.size[0] - self._plotFrame.margins.right - 1)
+ yPlot = numpy.clip(
+ y, self._plotFrame.margins.top,
+ self._plotFrame.size[1] - self._plotFrame.margins.bottom - 1)
+ return xPlot, yPlot
+
+ def pickItems(self, x, y):
+ picked = []
+
+ dataPos = self.pixelToData(x, y, axis='left', check=True)
+ if dataPos is not None:
+ # Pick markers
+ for marker in reversed(list(self._markers.values())):
+ pixelPos = self.dataToPixel(
+ marker['x'], marker['y'], axis='left', check=False)
+ if pixelPos is None: # negative coord on a log axis
+ continue
+
+ if marker['x'] is None: # Horizontal line
+ pt1 = self.pixelToData(
+ x, y - self._PICK_OFFSET, axis='left', check=False)
+ pt2 = self.pixelToData(
+ x, y + self._PICK_OFFSET, axis='left', check=False)
+ isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <=
+ max(pt1[1], pt2[1]))
+
+ elif marker['y'] is None: # Vertical line
+ pt1 = self.pixelToData(
+ x - self._PICK_OFFSET, y, axis='left', check=False)
+ pt2 = self.pixelToData(
+ x + self._PICK_OFFSET, y, axis='left', check=False)
+ isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <=
+ max(pt1[0], pt2[0]))
+
+ else:
+ isPicked = (
+ numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and
+ numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET)
+
+ if isPicked:
+ picked.append(dict(kind='marker',
+ legend=marker['legend']))
+
+ # Pick image and curves
+ for item in self._plotContent.zOrderedPrimitives(reverse=True):
+ if isinstance(item, (GLPlotColormap, GLPlotRGBAImage)):
+ pickedPos = item.pick(*dataPos)
+ if pickedPos is not None:
+ picked.append(dict(kind='image',
+ legend=item.info['legend']))
+
+ elif isinstance(item, GLPlotCurve2D):
+ offset = self._PICK_OFFSET
+ if item.marker is not None:
+ offset = max(item.markerSize / 2., offset)
+ if item.lineStyle is not None:
+ offset = max(item.lineWidth / 2., offset)
+
+ yAxis = item.info['yAxis']
+
+ inAreaPos = self._mouseInPlotArea(x - offset, y - offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick0, yPick0 = dataPos
+
+ inAreaPos = self._mouseInPlotArea(x + offset, y + offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick1, yPick1 = dataPos
+
+ if xPick0 < xPick1:
+ xPickMin, xPickMax = xPick0, xPick1
+ else:
+ xPickMin, xPickMax = xPick1, xPick0
+
+ if yPick0 < yPick1:
+ yPickMin, yPickMax = yPick0, yPick1
+ else:
+ yPickMin, yPickMax = yPick1, yPick0
+
+ pickedIndices = item.pick(xPickMin, yPickMin,
+ xPickMax, yPickMax)
+ if pickedIndices:
+ picked.append(dict(kind='curve',
+ legend=item.info['legend'],
+ xdata=item.xData[pickedIndices],
+ ydata=item.yData[pickedIndices]))
+
+ return picked
+
+ # Update curve
+
+ def setCurveColor(self, curve, color):
+ pass # TODO
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ return self
+
+ def postRedisplay(self):
+ self._sigPostRedisplay.emit()
+
+ def replot(self):
+ self.update() # async redraw
+ # self.repaint() # immediate redraw
+
+ def saveGraph(self, fileName, fileFormat, dpi):
+ if dpi is not None:
+ _logger.warning("saveGraph ignores dpi parameter")
+
+ if fileFormat not in ['png', 'ppm', 'svg', 'tiff']:
+ raise NotImplementedError('Unsupported format: %s' % fileFormat)
+
+ self.makeCurrent()
+
+ data = numpy.empty(
+ (self._plotFrame.size[1], self._plotFrame.size[0], 3),
+ dtype=numpy.uint8, order='C')
+
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
+ gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1)
+ gl.glReadPixels(0, 0, self._plotFrame.size[0], self._plotFrame.size[1],
+ gl.GL_RGB, gl.GL_UNSIGNED_BYTE, data)
+
+ # glReadPixels gives bottom to top,
+ # while images are stored as top to bottom
+ data = numpy.flipud(data)
+
+ # fileName is either a file-like object or a str
+ saveImageToFile(data, fileName, fileFormat)
+
+ # Graph labels
+
+ def setGraphTitle(self, title):
+ self._plotFrame.title = title
+
+ def setGraphXLabel(self, label):
+ self._plotFrame.xAxis.title = label
+
+ def setGraphYLabel(self, label, axis):
+ if axis == 'left':
+ self._plotFrame.yAxis.title = label
+ else: # right axis
+ if label:
+ _logger.warning('Right axis label not implemented')
+
+ # Non orthogonal axes
+
+ def setBaseVectors(self, x=(1., 0.), y=(0., 1.)):
+ """Set base vectors.
+
+ Useful for non-orthogonal axes.
+ If an axis is in log scale, skew is applied to log transformed values.
+
+ Base vector does not work well with log axes, to investi
+ """
+ if x != (1., 0.) and y != (0., 1.):
+ if self._plotFrame.xAxis.isLog:
+ _logger.warning("setBaseVectors disables X axis logarithmic.")
+ self.setXAxisLogarithmic(False)
+ if self._plotFrame.yAxis.isLog:
+ _logger.warning("setBaseVectors disables Y axis logarithmic.")
+ self.setYAxisLogarithmic(False)
+
+ if self.isKeepDataAspectRatio():
+ _logger.warning("setBaseVectors disables keepDataAspectRatio.")
+ self.keepDataAspectRatio(False)
+
+ self._plotFrame.baseVectors = x, y
+
+ def getBaseVectors(self):
+ return self._plotFrame.baseVectors
+
+ def isDefaultBaseVectors(self):
+ return self._plotFrame.baseVectors == \
+ self._plotFrame.DEFAULT_BASE_VECTORS
+
+ # Graph limits
+
+ def _setDataRanges(self, xlim=None, ylim=None, y2lim=None):
+ """Set the visible range of data in the plot frame.
+
+ This clips the ranges to possible values (takes care of float32
+ range + positive range for log).
+ This also takes care of non-orthogonal axes.
+
+ This should be moved to PlotFrame.
+ """
+ # Update axes range with a clipped range if too wide
+ self._plotFrame.setDataRanges(xlim, ylim, y2lim)
+
+ if not self.isDefaultBaseVectors():
+ # Update axes range with axes bounds in data coords
+ plotLeft, plotTop, plotWidth, plotHeight = \
+ self.getPlotBoundsInPixels()
+
+ self._plotFrame.xAxis.dataRange = sorted([
+ self.pixelToData(x, y, axis='left', check=False)[0]
+ for (x, y) in ((plotLeft, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop + plotHeight))])
+
+ self._plotFrame.yAxis.dataRange = sorted([
+ self.pixelToData(x, y, axis='left', check=False)[1]
+ for (x, y) in ((plotLeft, plotTop + plotHeight),
+ (plotLeft, plotTop))])
+
+ self._plotFrame.y2Axis.dataRange = sorted([
+ self.pixelToData(x, y, axis='right', check=False)[1]
+ for (x, y) in ((plotLeft + plotWidth, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop))])
+
+ def _ensureAspectRatio(self, keepDim=None):
+ """Update plot bounds in order to keep aspect ratio.
+
+ Warning: keepDim on right Y axis is not implemented !
+
+ :param str keepDim: The dimension to maintain: 'x', 'y' or None.
+ If None (the default), the dimension with the largest range.
+ """
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+ if plotWidth <= 2 or plotHeight <= 2:
+ return
+
+ if keepDim is None:
+ dataBounds = self._plotContent.getBounds(
+ self._plotFrame.xAxis.isLog, self._plotFrame.yAxis.isLog)
+ if dataBounds.yAxis.range_ != 0.:
+ dataRatio = dataBounds.xAxis.range_
+ dataRatio /= float(dataBounds.yAxis.range_)
+
+ plotRatio = plotWidth / float(plotHeight) # Test != 0 before
+
+ keepDim = 'x' if dataRatio > plotRatio else 'y'
+ else: # Limit case
+ keepDim = 'x'
+
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \
+ self._plotFrame.dataRanges
+ if keepDim == 'y':
+ dataW = (yMax - yMin) * plotWidth / float(plotHeight)
+ xCenter = 0.5 * (xMin + xMax)
+ xMin = xCenter - 0.5 * dataW
+ xMax = xCenter + 0.5 * dataW
+ elif keepDim == 'x':
+ dataH = (xMax - xMin) * plotHeight / float(plotWidth)
+ yCenter = 0.5 * (yMin + yMax)
+ yMin = yCenter - 0.5 * dataH
+ yMax = yCenter + 0.5 * dataH
+ y2Center = 0.5 * (y2Min + y2Max)
+ y2Min = y2Center - 0.5 * dataH
+ y2Max = y2Center + 0.5 * dataH
+ else:
+ raise RuntimeError('Unsupported dimension to keep: %s' % keepDim)
+
+ # Update plot frame bounds
+ self._setDataRanges(xlim=(xMin, xMax),
+ ylim=(yMin, yMax),
+ y2lim=(y2Min, y2Max))
+
+ def _setPlotBounds(self, xRange=None, yRange=None, y2Range=None,
+ keepDim=None):
+ # Update axes range with a clipped range if too wide
+ self._setDataRanges(xlim=xRange,
+ ylim=yRange,
+ y2lim=y2Range)
+
+ # Keep data aspect ratio
+ if self.isKeepDataAspectRatio():
+ self._ensureAspectRatio(keepDim)
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ assert xmin < xmax
+ assert ymin < ymax
+
+ if y2min is None or y2max is None:
+ y2Range = None
+ else:
+ assert y2min < y2max
+ y2Range = y2min, y2max
+ self._setPlotBounds((xmin, xmax), (ymin, ymax), y2Range)
+
+ def getGraphXLimits(self):
+ return self._plotFrame.dataRanges.x
+
+ def setGraphXLimits(self, xmin, xmax):
+ assert xmin < xmax
+ self._setPlotBounds(xRange=(xmin, xmax), keepDim='x')
+
+ def getGraphYLimits(self, axis):
+ assert axis in ("left", "right")
+ if axis == "left":
+ return self._plotFrame.dataRanges.y
+ else:
+ return self._plotFrame.dataRanges.y2
+
+ def setGraphYLimits(self, ymin, ymax, axis):
+ assert ymin < ymax
+ assert axis in ("left", "right")
+
+ if axis == "left":
+ self._setPlotBounds(yRange=(ymin, ymax), keepDim='y')
+ else:
+ self._setPlotBounds(y2Range=(ymin, ymax), keepDim='y')
+
+ # Graph axes
+
+ def setXAxisLogarithmic(self, flag):
+ if flag != self._plotFrame.xAxis.isLog:
+ if flag and self._keepDataAspectRatio:
+ _logger.warning(
+ "KeepDataAspectRatio is ignored with log axes")
+
+ if flag and not self.isDefaultBaseVectors():
+ _logger.warning(
+ "setXAxisLogarithmic ignored because baseVectors are set")
+ return
+
+ self._plotFrame.xAxis.isLog = flag
+
+ def setYAxisLogarithmic(self, flag):
+ if (flag != self._plotFrame.yAxis.isLog or
+ flag != self._plotFrame.y2Axis.isLog):
+ if flag and self._keepDataAspectRatio:
+ _logger.warning(
+ "KeepDataAspectRatio is ignored with log axes")
+
+ if flag and not self.isDefaultBaseVectors():
+ _logger.warning(
+ "setYAxisLogarithmic ignored because baseVectors are set")
+ return
+
+ self._plotFrame.yAxis.isLog = flag
+ self._plotFrame.y2Axis.isLog = flag
+
+ def setYAxisInverted(self, flag):
+ if flag != self._plotFrame.isYAxisInverted:
+ self._plotFrame.isYAxisInverted = flag
+
+ def isYAxisInverted(self):
+ return self._plotFrame.isYAxisInverted
+
+ def isKeepDataAspectRatio(self):
+ if self._plotFrame.xAxis.isLog or self._plotFrame.yAxis.isLog:
+ return False
+ else:
+ return self._keepDataAspectRatio
+
+ def setKeepDataAspectRatio(self, flag):
+ if flag and (self._plotFrame.xAxis.isLog or
+ self._plotFrame.yAxis.isLog):
+ _logger.warning("KeepDataAspectRatio is ignored with log axes")
+ if flag and not self.isDefaultBaseVectors():
+ _logger.warning(
+ "keepDataAspectRatio ignored because baseVectors are set")
+
+ self._keepDataAspectRatio = flag
+
+ def setGraphGrid(self, which):
+ assert which in (None, 'major', 'both')
+ self._plotFrame.grid = which is not None # TODO True grid support
+
+ # Data <-> Pixel coordinates conversion
+
+ def dataToPixel(self, x, y, axis, check=False):
+ assert axis in ('left', 'right')
+
+ if x is None or y is None:
+ dataBounds = self._plotContent.getBounds(
+ self._plotFrame.xAxis.isLog, self._plotFrame.yAxis.isLog)
+
+ if x is None:
+ x = dataBounds.xAxis.center
+
+ if y is None:
+ if axis == 'left':
+ y = dataBounds.yAxis.center
+ else:
+ y = dataBounds.y2Axis.center
+
+ result = self._plotFrame.dataToPixel(x, y, axis)
+
+ if check and result is not None:
+ xPixel, yPixel = result
+ width, height = self._plotFrame.size
+ if (xPixel < self._plotFrame.margins.left or
+ xPixel > (width - self._plotFrame.margins.right) or
+ yPixel < self._plotFrame.margins.top or
+ yPixel > height - self._plotFrame.margins.bottom):
+ return None # (x, y) is out of plot area
+
+ return result
+
+ def pixelToData(self, x, y, axis, check):
+ assert axis in ("left", "right")
+
+ if x is None:
+ x = self._plotFrame.size[0] / 2.
+ if y is None:
+ y = self._plotFrame.size[1] / 2.
+
+ if check and (x < self._plotFrame.margins.left or
+ x > (self._plotFrame.size[0] -
+ self._plotFrame.margins.right) or
+ y < self._plotFrame.margins.top or
+ y > (self._plotFrame.size[1] -
+ self._plotFrame.margins.bottom)):
+ return None # (x, y) is out of plot area
+
+ return self._plotFrame.pixelToData(x, y, axis)
+
+ def getPlotBoundsInPixels(self):
+ return self._plotFrame.plotOrigin + self._plotFrame.plotSize
diff --git a/silx/gui/plot/backends/ModestImage.py b/silx/gui/plot/backends/ModestImage.py
new file mode 100644
index 0000000..93fba5a
--- /dev/null
+++ b/silx/gui/plot/backends/ModestImage.py
@@ -0,0 +1,174 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 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.
+#
+# ############################################################################*/
+"""Matplotlib computationally modest image class."""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "16/02/2016"
+
+
+import numpy
+
+from matplotlib import cbook
+from matplotlib.image import AxesImage
+
+
+class ModestImage(AxesImage):
+ """Computationally modest image class.
+
+Customization of https://github.com/ChrisBeaumont/ModestImage to allow
+extent support.
+
+ModestImage is an extension of the Matplotlib AxesImage class
+better suited for the interactive display of larger images. Before
+drawing, ModestImage resamples the data array based on the screen
+resolution and view window. This has very little affect on the
+appearance of the image, but can substantially cut down on
+computation since calculations of unresolved or clipped pixels
+are skipped.
+
+The interface of ModestImage is the same as AxesImage. However, it
+does not currently support setting the 'extent' property. There
+may also be weird coordinate warping operations for images that
+I'm not aware of. Don't expect those to work either.
+"""
+ def __init__(self, *args, **kwargs):
+ self._full_res = None
+ self._sx, self._sy = None, None
+ self._bounds = (None, None, None, None)
+ self._origExtent = None
+ super(ModestImage, self).__init__(*args, **kwargs)
+ if 'extent' in kwargs and kwargs['extent'] is not None:
+ self.set_extent(kwargs['extent'])
+
+ def set_extent(self, extent):
+ super(ModestImage, self).set_extent(extent)
+ if self._origExtent is None:
+ self._origExtent = self.get_extent()
+
+ def get_image_extent(self):
+ """Returns the extent of the whole image.
+
+ get_extent returns the extent of the drawn area and not of the full
+ image.
+
+ :return: Bounds of the image (x0, x1, y0, y1).
+ :rtype: Tuple of 4 floats.
+ """
+ if self._origExtent is not None:
+ return self._origExtent
+ else:
+ return self.get_extent()
+
+ def set_data(self, A):
+ """
+ Set the image array
+
+ ACCEPTS: numpy/PIL Image A
+ """
+
+ self._full_res = A
+ self._A = A
+
+ if (self._A.dtype != numpy.uint8 and
+ not numpy.can_cast(self._A.dtype, numpy.float)):
+ raise TypeError("Image data can not convert to float")
+
+ if (self._A.ndim not in (2, 3) or
+ (self._A.ndim == 3 and self._A.shape[-1] not in (3, 4))):
+ raise TypeError("Invalid dimensions for image data")
+
+ self._imcache = None
+ self._rgbacache = None
+ self._oldxslice = None
+ self._oldyslice = None
+ self._sx, self._sy = None, None
+
+ def get_array(self):
+ """Override to return the full-resolution array"""
+ return self._full_res
+
+ def _scale_to_res(self):
+ """ Change self._A and _extent to render an image whose
+resolution is matched to the eventual rendering."""
+ # extent has to be set BEFORE set_data
+ if self._origExtent is None:
+ if self.origin == "upper":
+ self._origExtent = (0, self._full_res.shape[1],
+ self._full_res.shape[0], 0)
+ else:
+ self._origExtent = (0, self._full_res.shape[1],
+ 0, self._full_res.shape[0])
+
+ if self.origin == "upper":
+ origXMin, origXMax, origYMax, origYMin = self._origExtent[0:4]
+ else:
+ origXMin, origXMax, origYMin, origYMax = self._origExtent[0:4]
+ ax = self.axes
+ ext = ax.transAxes.transform([1, 1]) - ax.transAxes.transform([0, 0])
+ xlim, ylim = ax.get_xlim(), ax.get_ylim()
+ xlim = max(xlim[0], origXMin), min(xlim[1], origXMax)
+ if ylim[0] > ylim[1]:
+ ylim = max(ylim[1], origYMin), min(ylim[0], origYMax)
+ else:
+ ylim = max(ylim[0], origYMin), min(ylim[1], origYMax)
+ # print("THOSE LIMITS ARE TO BE COMPARED WITH THE EXTENT")
+ # print("IN ORDER TO KNOW WHAT IT IS LIMITING THE DISPLAY")
+ # print("IF THE AXES OR THE EXTENT")
+ dx, dy = xlim[1] - xlim[0], ylim[1] - ylim[0]
+
+ y0 = max(0, ylim[0] - 5)
+ y1 = min(self._full_res.shape[0], ylim[1] + 5)
+ x0 = max(0, xlim[0] - 5)
+ x1 = min(self._full_res.shape[1], xlim[1] + 5)
+ y0, y1, x0, x1 = [int(a) for a in [y0, y1, x0, x1]]
+
+ sy = int(max(1, min((y1 - y0) / 5., numpy.ceil(dy / ext[1]))))
+ sx = int(max(1, min((x1 - x0) / 5., numpy.ceil(dx / ext[0]))))
+
+ # have we already calculated what we need?
+ if (self._sx is not None) and (self._sy is not None):
+ if (sx >= self._sx and sy >= self._sy and
+ x0 >= self._bounds[0] and x1 <= self._bounds[1] and
+ y0 >= self._bounds[2] and y1 <= self._bounds[3]):
+ return
+
+ self._A = self._full_res[y0:y1:sy, x0:x1:sx]
+ self._A = cbook.safe_masked_invalid(self._A)
+ x1 = x0 + self._A.shape[1] * sx
+ y1 = y0 + self._A.shape[0] * sy
+
+ if self.origin == "upper":
+ self.set_extent([x0, x1, y1, y0])
+ else:
+ self.set_extent([x0, x1, y0, y1])
+ self._sx = sx
+ self._sy = sy
+ self._bounds = (x0, x1, y0, y1)
+ self.changed()
+
+ def draw(self, renderer, *args, **kwargs):
+ self._scale_to_res()
+ super(ModestImage, self).draw(renderer, *args, **kwargs)
diff --git a/silx/gui/plot/backends/__init__.py b/silx/gui/plot/backends/__init__.py
new file mode 100644
index 0000000..966d9df
--- /dev/null
+++ b/silx/gui/plot/backends/__init__.py
@@ -0,0 +1,29 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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 implements the backend of the Plot."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "21/03/2017"
diff --git a/silx/gui/plot/backends/_matplotlib.py b/silx/gui/plot/backends/_matplotlib.py
new file mode 100644
index 0000000..26732a0
--- /dev/null
+++ b/silx/gui/plot/backends/_matplotlib.py
@@ -0,0 +1,64 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 inits matplotlib and setups the backend to use.
+
+It MUST be imported prior to any other import of matplotlib.
+
+It provides the matplotlib :class:`FigureCanvasQTAgg` class corresponding
+to the used backend.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/10/2016"
+
+
+import sys
+import logging
+
+
+_logger = logging.getLogger(__name__)
+
+if 'matplotlib' in sys.modules:
+ _logger.warning(
+ 'matplotlib already loaded, setting its backend may not work')
+
+
+from ... import qt
+
+import matplotlib
+
+if qt.BINDING == 'PySide':
+ matplotlib.rcParams['backend'] = 'Qt4Agg'
+ matplotlib.rcParams['backend.qt4'] = 'PySide'
+ from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg # noqa
+
+elif qt.BINDING == 'PyQt4':
+ matplotlib.rcParams['backend'] = 'Qt4Agg'
+ from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg # noqa
+
+elif qt.BINDING == 'PyQt5':
+ matplotlib.rcParams['backend'] = 'Qt5Agg'
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg # noqa
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
new file mode 100644
index 0000000..4f08054
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py
@@ -0,0 +1,1317 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 classes to render 2D lines and scatter plots
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import math
+import logging
+
+import numpy
+
+from silx.math.combo import min_max
+
+from ...._glutils import gl
+from ...._glutils import numpyToGLType, Program, vertexBuffer
+from ..._utils import FLOAT32_MINPOS
+from .GLSupport import buildFillMaskIndices
+
+
+_logger = logging.getLogger(__name__)
+
+
+_MPL_NONES = None, 'None', '', ' '
+
+
+# fill ########################################################################
+
+class _Fill2D(object):
+ _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3
+
+ _SHADERS = {
+ 'vertexTransforms': {
+ _LINEAR: """
+ vec4 transformXY(float x, float y) {
+ return vec4(x, y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_X: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x), y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(x, oneOverLog10 * log(y), 0.0, 1.0);
+ }
+ """,
+ _LOG10_X_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x),
+ oneOverLog10 * log(y),
+ 0.0, 1.0);
+ }
+ """
+ },
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ attribute float xPos;
+ attribute float yPos;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform vec4 color;
+
+ void main(void) {
+ gl_FragColor = color;
+ }
+ """
+ }
+
+ _programs = {
+ _LINEAR: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LINEAR],
+ _SHADERS['fragment'], attrib0='xPos'),
+ _LOG10_X: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X],
+ _SHADERS['fragment'], attrib0='xPos'),
+ _LOG10_Y: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_Y],
+ _SHADERS['fragment'], attrib0='xPos'),
+ _LOG10_X_Y: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X_Y],
+ _SHADERS['fragment'], attrib0='xPos'),
+ }
+
+ def __init__(self, xFillVboData=None, yFillVboData=None,
+ xMin=None, yMin=None, xMax=None, yMax=None,
+ color=(0., 0., 0., 1.)):
+ self.xFillVboData = xFillVboData
+ self.yFillVboData = yFillVboData
+ self.xMin, self.yMin = xMin, yMin
+ self.xMax, self.yMax = xMax, yMax
+ self.color = color
+
+ self._bboxVertices = None
+ self._indices = None
+ self._indicesType = None
+
+ def prepare(self):
+ if self._indices is None:
+ self._indices = buildFillMaskIndices(self.xFillVboData.size)
+ self._indicesType = numpyToGLType(self._indices.dtype)
+
+ if self._bboxVertices is None:
+ yMin, yMax = min(self.yMin, 1e-32), max(self.yMax, 1e-32)
+ self._bboxVertices = numpy.array(((self.xMin, self.xMin,
+ self.xMax, self.xMax),
+ (yMin, yMax, yMin, yMax)),
+ dtype=numpy.float32)
+
+ def render(self, matrix, isXLog, isYLog):
+ self.prepare()
+
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._programs[transform]
+ prog.use()
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+
+ gl.glUniform4f(prog.uniforms['color'], *self.color)
+
+ xPosAttrib = prog.attributes['xPos']
+ yPosAttrib = prog.attributes['yPos']
+
+ gl.glEnableVertexAttribArray(xPosAttrib)
+ self.xFillVboData.setVertexAttrib(xPosAttrib)
+
+ gl.glEnableVertexAttribArray(yPosAttrib)
+ self.yFillVboData.setVertexAttrib(yPosAttrib)
+
+ # Prepare fill mask
+ gl.glEnable(gl.GL_STENCIL_TEST)
+ gl.glStencilMask(1)
+ gl.glStencilFunc(gl.GL_ALWAYS, 1, 1)
+ gl.glStencilOp(gl.GL_INVERT, gl.GL_INVERT, gl.GL_INVERT)
+ gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE)
+ gl.glDepthMask(gl.GL_FALSE)
+
+ gl.glDrawElements(gl.GL_TRIANGLE_STRIP, self._indices.size,
+ self._indicesType, self._indices)
+
+ gl.glStencilFunc(gl.GL_EQUAL, 1, 1)
+ # Reset stencil while drawing
+ gl.glStencilOp(gl.GL_ZERO, gl.GL_ZERO, gl.GL_ZERO)
+ gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE)
+ gl.glDepthMask(gl.GL_TRUE)
+
+ gl.glVertexAttribPointer(xPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0,
+ self._bboxVertices[0])
+ gl.glVertexAttribPointer(yPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0,
+ self._bboxVertices[1])
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, self._bboxVertices[0].size)
+
+ gl.glDisable(gl.GL_STENCIL_TEST)
+
+
+# line ########################################################################
+
+SOLID, DASHED, DASHDOT, DOTTED = '-', '--', '-.', ':'
+
+
+class _Lines2D(object):
+ STYLES = SOLID, DASHED, DASHDOT, DOTTED
+ """Supported line styles"""
+
+ _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3
+
+ _SHADERS = {
+ 'vertexTransforms': {
+ _LINEAR: """
+ vec4 transformXY(float x, float y) {
+ return vec4(x, y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_X: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x), y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(x, oneOverLog10 * log(y), 0.0, 1.0);
+ }
+ """,
+ _LOG10_X_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x),
+ oneOverLog10 * log(y),
+ 0.0, 1.0);
+ }
+ """
+ },
+ 'solid': {
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ attribute float xPos;
+ attribute float yPos;
+ attribute vec4 color;
+
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ vColor = color;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ varying vec4 vColor;
+
+ void main(void) {
+ gl_FragColor = vColor;
+ }
+ """
+ },
+
+
+ # Limitation: Dash using an estimate of distance in screen coord
+ # to avoid computing distance when viewport is resized
+ # results in inequal dashes when viewport aspect ratio is far from 1
+ 'dashed': {
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ uniform vec2 halfViewportSize;
+ attribute float xPos;
+ attribute float yPos;
+ attribute vec4 color;
+ attribute float distance;
+
+ varying float vDist;
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ //Estimate distance in pixels
+ vec2 probe = vec2(matrix * vec4(1., 1., 0., 0.)) *
+ halfViewportSize;
+ float pixelPerDataEstimate = length(probe)/sqrt(2.);
+ vDist = distance * pixelPerDataEstimate;
+ vColor = color;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ /* Dashes: [0, x], [y, z]
+ Dash period: w */
+ uniform vec4 dash;
+
+ varying float vDist;
+ varying vec4 vColor;
+
+ void main(void) {
+ float dist = mod(vDist, dash.w);
+ if ((dist > dash.x && dist < dash.y) || dist > dash.z) {
+ discard;
+ }
+ gl_FragColor = vColor;
+ }
+ """
+ }
+ }
+
+ _programs = {}
+
+ def __init__(self, xVboData=None, yVboData=None,
+ colorVboData=None, distVboData=None,
+ style=SOLID, color=(0., 0., 0., 1.),
+ width=1, dashPeriod=20, drawMode=None):
+ self.xVboData = xVboData
+ self.yVboData = yVboData
+ self.distVboData = distVboData
+ self.colorVboData = colorVboData
+ self.useColorVboData = colorVboData is not None
+
+ self.color = color
+ self._width = 1
+ self.width = width
+ self._style = None
+ self.style = style
+ self.dashPeriod = dashPeriod
+
+ self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP
+
+ @property
+ def style(self):
+ return self._style
+
+ @style.setter
+ def style(self, style):
+ if style in _MPL_NONES:
+ self._style = None
+ self.render = self._renderNone
+ else:
+ assert style in self.STYLES
+ self._style = style
+ if style == SOLID:
+ self.render = self._renderSolid
+ else: # DASHED, DASHDOT, DOTTED
+ self.render = self._renderDash
+
+ @property
+ def width(self):
+ return self._width
+
+ @width.setter
+ def width(self, width):
+ # try:
+ # widthRange = self._widthRange
+ # except AttributeError:
+ # widthRange = gl.glGetFloatv(gl.GL_ALIASED_LINE_WIDTH_RANGE)
+ # # Shared among contexts, this should be enough..
+ # _Lines2D._widthRange = widthRange
+ # assert width >= widthRange[0] and width <= widthRange[1]
+ self._width = width
+
+ @classmethod
+ def _getProgram(cls, transform, style):
+ try:
+ prgm = cls._programs[(transform, style)]
+ except KeyError:
+ sources = cls._SHADERS[style]
+ vertexShdr = sources['vertex'] % \
+ cls._SHADERS['vertexTransforms'][transform]
+ prgm = Program(vertexShdr, sources['fragment'], attrib0='xPos')
+ cls._programs[(transform, style)] = prgm
+ return prgm
+
+ @classmethod
+ def init(cls):
+ gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
+
+ def _renderNone(self, matrix, isXLog, isYLog):
+ pass
+
+ render = _renderNone # Overridden in style setter
+
+ def _renderSolid(self, matrix, isXLog, isYLog):
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._getProgram(transform, 'solid')
+ prog.use()
+
+ gl.glEnable(gl.GL_LINE_SMOOTH)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+
+ colorAttrib = prog.attributes['color']
+ if self.useColorVboData and self.colorVboData is not None:
+ gl.glEnableVertexAttribArray(colorAttrib)
+ self.colorVboData.setVertexAttrib(colorAttrib)
+ else:
+ gl.glDisableVertexAttribArray(colorAttrib)
+ gl.glVertexAttrib4f(colorAttrib, *self.color)
+
+ xPosAttrib = prog.attributes['xPos']
+ gl.glEnableVertexAttribArray(xPosAttrib)
+ self.xVboData.setVertexAttrib(xPosAttrib)
+
+ yPosAttrib = prog.attributes['yPos']
+ gl.glEnableVertexAttribArray(yPosAttrib)
+ self.yVboData.setVertexAttrib(yPosAttrib)
+
+ gl.glLineWidth(self.width)
+ gl.glDrawArrays(self._drawMode, 0, self.xVboData.size)
+
+ gl.glDisable(gl.GL_LINE_SMOOTH)
+
+ def _renderDash(self, matrix, isXLog, isYLog):
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._getProgram(transform, 'dashed')
+ prog.use()
+
+ gl.glEnable(gl.GL_LINE_SMOOTH)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ x, y, viewWidth, viewHeight = gl.glGetFloatv(gl.GL_VIEWPORT)
+ gl.glUniform2f(prog.uniforms['halfViewportSize'],
+ 0.5 * viewWidth, 0.5 * viewHeight)
+
+ if self.style == DOTTED:
+ dash = (0.1 * self.dashPeriod,
+ 0.6 * self.dashPeriod,
+ 0.7 * self.dashPeriod,
+ self.dashPeriod)
+ elif self.style == DASHDOT:
+ dash = (0.3 * self.dashPeriod,
+ 0.5 * self.dashPeriod,
+ 0.6 * self.dashPeriod,
+ self.dashPeriod)
+ else:
+ dash = (0.5 * self.dashPeriod,
+ self.dashPeriod,
+ self.dashPeriod,
+ self.dashPeriod)
+
+ gl.glUniform4f(prog.uniforms['dash'], *dash)
+
+ colorAttrib = prog.attributes['color']
+ if self.useColorVboData and self.colorVboData is not None:
+ gl.glEnableVertexAttribArray(colorAttrib)
+ self.colorVboData.setVertexAttrib(colorAttrib)
+ else:
+ gl.glDisableVertexAttribArray(colorAttrib)
+ gl.glVertexAttrib4f(colorAttrib, *self.color)
+
+ distAttrib = prog.attributes['distance']
+ gl.glEnableVertexAttribArray(distAttrib)
+ self.distVboData.setVertexAttrib(distAttrib)
+
+ xPosAttrib = prog.attributes['xPos']
+ gl.glEnableVertexAttribArray(xPosAttrib)
+ self.xVboData.setVertexAttrib(xPosAttrib)
+
+ yPosAttrib = prog.attributes['yPos']
+ gl.glEnableVertexAttribArray(yPosAttrib)
+ self.yVboData.setVertexAttrib(yPosAttrib)
+
+ gl.glLineWidth(self.width)
+ gl.glDrawArrays(self._drawMode, 0, self.xVboData.size)
+
+ gl.glDisable(gl.GL_LINE_SMOOTH)
+
+
+def _distancesFromArrays(xData, yData):
+ deltas = numpy.dstack((
+ numpy.ediff1d(xData, to_begin=numpy.float32(0.)),
+ numpy.ediff1d(yData, to_begin=numpy.float32(0.))))[0]
+ return numpy.cumsum(numpy.sqrt(numpy.sum(deltas ** 2, axis=1)))
+
+
+# points ######################################################################
+
+DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK = \
+ 'd', 'o', 's', '+', 'x', '.', ',', '*'
+
+H_LINE, V_LINE = '_', '|'
+
+
+class _Points2D(object):
+ MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK,
+ H_LINE, V_LINE)
+
+ _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3
+
+ _SHADERS = {
+ 'vertexTransforms': {
+ _LINEAR: """
+ vec4 transformXY(float x, float y) {
+ return vec4(x, y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_X: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x), y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(x, oneOverLog10 * log(y), 0.0, 1.0);
+ }
+ """,
+ _LOG10_X_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x),
+ oneOverLog10 * log(y),
+ 0.0, 1.0);
+ }
+ """
+ },
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ uniform int transform;
+ uniform float size;
+ attribute float xPos;
+ attribute float yPos;
+ attribute vec4 color;
+
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ vColor = color;
+ gl_PointSize = size;
+ }
+ """,
+
+ 'fragmentSymbols': {
+ DIAMOND: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 centerCoord = abs(coord - vec2(0.5, 0.5));
+ float f = centerCoord.x + centerCoord.y;
+ return clamp(size * (0.5 - f), 0.0, 1.0);
+ }
+ """,
+ CIRCLE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float radius = 0.5;
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (radius - r), 0.0, 1.0);
+ }
+ """,
+ SQUARE: """
+ float alphaSymbol(vec2 coord, float size) {
+ return 1.0;
+ }
+ """,
+ PLUS: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 d = abs(size * (coord - vec2(0.5, 0.5)));
+ if (min(d.x, d.y) < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ X_MARKER: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_x.x, d_x.y) <= 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ ASTERISK: """
+ float alphaSymbol(vec2 coord, float size) {
+ /* Combining +, x and cirle */
+ vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5)));
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_plus.x, d_plus.y) < 0.5) {
+ return 1.0;
+ } else if (min(d_x.x, d_x.y) <= 0.5) {
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (0.5 - r), 0.0, 1.0);
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ H_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dy = abs(size * (coord.y - 0.5));
+ if (dy < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ V_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dx = abs(size * (coord.x - 0.5));
+ if (dx < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """
+ },
+
+ 'fragment': """
+ #version 120
+
+ uniform float size;
+
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ float alpha = alphaSymbol(gl_PointCoord, size);
+ if (alpha <= 0.0) {
+ discard;
+ } else {
+ gl_FragColor = vec4(vColor.rgb, alpha * clamp(vColor.a, 0.0, 1.0));
+ }
+ }
+ """
+ }
+
+ _programs = {}
+
+ def __init__(self, xVboData=None, yVboData=None, colorVboData=None,
+ marker=SQUARE, color=(0., 0., 0., 1.), size=7):
+ self.color = color
+ self._marker = None
+ self.marker = marker
+ self._size = 1
+ self.size = size
+
+ self.xVboData = xVboData
+ self.yVboData = yVboData
+ self.colorVboData = colorVboData
+ self.useColorVboData = colorVboData is not None
+
+ @property
+ def marker(self):
+ return self._marker
+
+ @marker.setter
+ def marker(self, marker):
+ if marker in _MPL_NONES:
+ self._marker = None
+ self.render = self._renderNone
+ else:
+ assert marker in self.MARKERS
+ self._marker = marker
+ self.render = self._renderMarkers
+
+ @property
+ def size(self):
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ # try:
+ # sizeRange = self._sizeRange
+ # except AttributeError:
+ # sizeRange = gl.glGetFloatv(gl.GL_POINT_SIZE_RANGE)
+ # # Shared among contexts, this should be enough..
+ # _Points2D._sizeRange = sizeRange
+ # assert size >= sizeRange[0] and size <= sizeRange[1]
+ self._size = size
+
+ @classmethod
+ def _getProgram(cls, transform, marker):
+ """On-demand shader program creation."""
+ if marker == PIXEL:
+ marker = SQUARE
+ elif marker == POINT:
+ marker = CIRCLE
+ try:
+ prgm = cls._programs[(transform, marker)]
+ except KeyError:
+ vertShdr = cls._SHADERS['vertex'] % \
+ cls._SHADERS['vertexTransforms'][transform]
+ fragShdr = cls._SHADERS['fragment'] % \
+ cls._SHADERS['fragmentSymbols'][marker]
+ prgm = Program(vertShdr, fragShdr, attrib0='xPos')
+
+ cls._programs[(transform, marker)] = prgm
+ return prgm
+
+ @classmethod
+ def init(cls):
+ version = gl.glGetString(gl.GL_VERSION)
+ majorVersion = int(version[0])
+ assert majorVersion >= 2
+ gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
+ gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
+ if majorVersion >= 3: # OpenGL 3
+ gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
+
+ def _renderNone(self, matrix, isXLog, isYLog):
+ pass
+
+ render = _renderNone
+
+ def _renderMarkers(self, matrix, isXLog, isYLog):
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._getProgram(transform, self.marker)
+ prog.use()
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ if self.marker == PIXEL:
+ size = 1
+ elif self.marker == POINT:
+ size = math.ceil(0.5 * self.size) + 1 # Mimic Matplotlib point
+ else:
+ size = self.size
+ gl.glUniform1f(prog.uniforms['size'], size)
+ # gl.glPointSize(self.size)
+
+ cAttrib = prog.attributes['color']
+ if self.useColorVboData and self.colorVboData is not None:
+ gl.glEnableVertexAttribArray(cAttrib)
+ self.colorVboData.setVertexAttrib(cAttrib)
+ else:
+ gl.glDisableVertexAttribArray(cAttrib)
+ gl.glVertexAttrib4f(cAttrib, *self.color)
+
+ xAttrib = prog.attributes['xPos']
+ gl.glEnableVertexAttribArray(xAttrib)
+ self.xVboData.setVertexAttrib(xAttrib)
+
+ yAttrib = prog.attributes['yPos']
+ gl.glEnableVertexAttribArray(yAttrib)
+ self.yVboData.setVertexAttrib(yAttrib)
+
+ gl.glDrawArrays(gl.GL_POINTS, 0, self.xVboData.size)
+
+ gl.glUseProgram(0)
+
+
+# error bars ##################################################################
+
+class _ErrorBars(object):
+ """Display errors bars.
+
+ This is using its own VBO as opposed to fill/points/lines.
+ There is no picking on error bars.
+ As is, there is no way to update data and errors, but it handles
+ log scales by removing data <= 0 and clipping error bars to positive
+ range.
+
+ It uses 2 vertices per error bars and uses :class:`_Lines2D` to
+ render error bars and :class:`_Points2D` to render the ends.
+ """
+
+ def __init__(self, xData, yData, xError, yError,
+ xMin, yMin,
+ color=(0., 0., 0., 1.)):
+ """Initialization.
+
+ :param numpy.ndarray xData: X coordinates of the data.
+ :param numpy.ndarray yData: Y coordinates of the data.
+ :param xError: The absolute error on the X axis.
+ :type xError: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for negative errors,
+ row 1 for positive errors.
+ :param yError: The absolute error on the Y axis.
+ :type yError: A float, or a numpy.ndarray of float32. See xError.
+ :param float xMin: The min X value already computed by GLPlotCurve2D.
+ :param float yMin: The min Y value already computed by GLPlotCurve2D.
+ :param color: The color to use for both lines and ending points.
+ :type color: tuple of 4 floats
+ """
+ self._attribs = None
+ self._isXLog, self._isYLog = False, False
+ self._xMin, self._yMin = xMin, yMin
+
+ if xError is not None or yError is not None:
+ assert len(xData) == len(yData)
+ self._xData = numpy.array(
+ xData, order='C', dtype=numpy.float32, copy=False)
+ self._yData = numpy.array(
+ yData, order='C', dtype=numpy.float32, copy=False)
+
+ # This also works if xError, yError is a float/int
+ self._xError = numpy.array(
+ xError, order='C', dtype=numpy.float32, copy=False)
+ self._yError = numpy.array(
+ yError, order='C', dtype=numpy.float32, copy=False)
+ else:
+ self._xData, self._yData = None, None
+ self._xError, self._yError = None, None
+
+ self._lines = _Lines2D(None, None, color=color, drawMode=gl.GL_LINES)
+ self._xErrPoints = _Points2D(None, None, color=color, marker=V_LINE)
+ self._yErrPoints = _Points2D(None, None, color=color, marker=H_LINE)
+
+ def _positiveValueFilter(self, onlyXPos, onlyYPos):
+ """Filter data (x, y) and errors (xError, yError) to remove
+ negative and null data values on required axis (onlyXPos, onlyYPos).
+
+ Returned arrays might be NOT contiguous.
+
+ :return: Filtered xData, yData, xError and yError arrays.
+ """
+ if ((not onlyXPos or self._xMin > 0.) and
+ (not onlyYPos or self._yMin > 0.)):
+ # No need to filter, all values are > 0 on log axes
+ return self._xData, self._yData, self._xError, self._yError
+
+ _logger.warning(
+ 'Removing values <= 0 of curve with error bars on a log axis.')
+
+ x, y = self._xData, self._yData
+ xError, yError = self._xError, self._yError
+
+ # First remove negative data
+ if onlyXPos and onlyYPos:
+ mask = (x > 0.) & (y > 0.)
+ elif onlyXPos:
+ mask = x > 0.
+ else: # onlyYPos
+ mask = y > 0.
+ x, y = x[mask], y[mask]
+
+ # Remove corresponding values from error arrays
+ if xError is not None and xError.size != 1:
+ if len(xError.shape) == 1:
+ xError = xError[mask]
+ else: # 2 rows
+ xError = xError[:, mask]
+ if yError is not None and yError.size != 1:
+ if len(yError.shape) == 1:
+ yError = yError[mask]
+ else: # 2 rows
+ yError = yError[:, mask]
+
+ return x, y, xError, yError
+
+ def _buildVertices(self, isXLog, isYLog):
+ """Generates error bars vertices according to log scales."""
+ xData, yData, xError, yError = self._positiveValueFilter(
+ isXLog, isYLog)
+
+ nbLinesPerDataPts = 1 if xError is not None else 0
+ nbLinesPerDataPts += 1 if yError is not None else 0
+
+ nbDataPts = len(xData)
+
+ # interleave coord+error, coord-error.
+ # xError vertices first if any, then yError vertices if any.
+ xCoords = numpy.empty(nbDataPts * nbLinesPerDataPts * 2,
+ dtype=numpy.float32)
+ yCoords = numpy.empty(nbDataPts * nbLinesPerDataPts * 2,
+ dtype=numpy.float32)
+
+ if xError is not None: # errors on the X axis
+ if len(xError.shape) == 2:
+ xErrorMinus, xErrorPlus = xError[0], xError[1]
+ else:
+ # numpy arrays of len 1 or len(xData)
+ xErrorMinus, xErrorPlus = xError, xError
+
+ # Interleave vertices for xError
+ endXError = 2 * nbDataPts
+ xCoords[0:endXError-1:2] = xData + xErrorPlus
+
+ minValues = xData - xErrorMinus
+ if isXLog:
+ # Clip min bounds to positive value
+ minValues[minValues <= 0] = FLOAT32_MINPOS
+ xCoords[1:endXError:2] = minValues
+
+ yCoords[0:endXError-1:2] = yData
+ yCoords[1:endXError:2] = yData
+ else:
+ endXError = 0
+
+ if yError is not None: # errors on the Y axis
+ if len(yError.shape) == 2:
+ yErrorMinus, yErrorPlus = yError[0], yError[1]
+ else:
+ # numpy arrays of len 1 or len(yData)
+ yErrorMinus, yErrorPlus = yError, yError
+
+ # Interleave vertices for yError
+ xCoords[endXError::2] = xData
+ xCoords[endXError+1::2] = xData
+ yCoords[endXError::2] = yData + yErrorPlus
+ minValues = yData - yErrorMinus
+ if isYLog:
+ # Clip min bounds to positive value
+ minValues[minValues <= 0] = FLOAT32_MINPOS
+ yCoords[endXError+1::2] = minValues
+
+ return xCoords, yCoords
+
+ def prepare(self, isXLog, isYLog):
+ if self._xData is None:
+ return
+
+ if self._isXLog != isXLog or self._isYLog != isYLog:
+ # Log state has changed
+ self._isXLog, self._isYLog = isXLog, isYLog
+
+ self.discard() # discard existing VBOs
+
+ if self._attribs is None:
+ xCoords, yCoords = self._buildVertices(isXLog, isYLog)
+
+ xAttrib, yAttrib = vertexBuffer((xCoords, yCoords))
+ self._attribs = xAttrib, yAttrib
+
+ self._lines.xVboData, self._lines.yVboData = xAttrib, yAttrib
+
+ # Set xError points using the same VBO as lines
+ self._xErrPoints.xVboData = xAttrib.copy()
+ self._xErrPoints.xVboData.size //= 2
+ self._xErrPoints.yVboData = yAttrib.copy()
+ self._xErrPoints.yVboData.size //= 2
+
+ # Set yError points using the same VBO as lines
+ self._yErrPoints.xVboData = xAttrib.copy()
+ self._yErrPoints.xVboData.size //= 2
+ self._yErrPoints.xVboData.offset += (xAttrib.itemsize *
+ xAttrib.size // 2)
+ self._yErrPoints.yVboData = yAttrib.copy()
+ self._yErrPoints.yVboData.size //= 2
+ self._yErrPoints.yVboData.offset += (yAttrib.itemsize *
+ yAttrib.size // 2)
+
+ def render(self, matrix, isXLog, isYLog):
+ if self._attribs is not None:
+ self._lines.render(matrix, isXLog, isYLog)
+ self._xErrPoints.render(matrix, isXLog, isYLog)
+ self._yErrPoints.render(matrix, isXLog, isYLog)
+
+ def discard(self):
+ if self._attribs is not None:
+ self._lines.xVboData, self._lines.yVboData = None, None
+ self._xErrPoints.xVboData, self._xErrPoints.yVboData = None, None
+ self._yErrPoints.xVboData, self._yErrPoints.yVboData = None, None
+ self._attribs[0].vbo.discard()
+ self._attribs = None
+
+
+# curves ######################################################################
+
+def _proxyProperty(*componentsAttributes):
+ """Create a property to access an attribute of attribute(s).
+ Useful for composition.
+ Supports multiple components this way:
+ getter returns the first found, setter sets all
+ """
+ def getter(self):
+ for compName, attrName in componentsAttributes:
+ try:
+ component = getattr(self, compName)
+ except AttributeError:
+ pass
+ else:
+ return getattr(component, attrName)
+
+ def setter(self, value):
+ for compName, attrName in componentsAttributes:
+ component = getattr(self, compName)
+ setattr(component, attrName, value)
+ return property(getter, setter)
+
+
+class GLPlotCurve2D(object):
+ def __init__(self, xData, yData, colorData=None,
+ xError=None, yError=None,
+ lineStyle=None, lineColor=None,
+ lineWidth=None, lineDashPeriod=None,
+ marker=None, markerColor=None, markerSize=None,
+ fillColor=None):
+ self._isXLog = False
+ self._isYLog = False
+ self.xData, self.yData, self.colorData = xData, yData, colorData
+
+ if fillColor is not None:
+ self.fill = _Fill2D(color=fillColor)
+ else:
+ self.fill = None
+
+ # Compute x bounds
+ if xError is None:
+ result = min_max(xData, min_positive=True)
+ self.xMin = result.minimum
+ self.xMinPos = result.min_positive
+ self.xMax = result.maximum
+ else:
+ # Takes the error into account
+ if hasattr(xError, 'shape') and len(xError.shape) == 2:
+ xErrorPlus, xErrorMinus = xError[0], xError[1]
+ else:
+ xErrorPlus, xErrorMinus = xError, xError
+ result = min_max(xData - xErrorMinus, min_positive=True)
+ self.xMin = result.minimum
+ self.xMinPos = result.min_positive
+ self.xMax = (xData + xErrorPlus).max()
+
+ # Compute y bounds
+ if yError is None:
+ result = min_max(yData, min_positive=True)
+ self.yMin = result.minimum
+ self.yMinPos = result.min_positive
+ self.yMax = result.maximum
+ else:
+ # Takes the error into account
+ if hasattr(yError, 'shape') and len(yError.shape) == 2:
+ yErrorPlus, yErrorMinus = yError[0], yError[1]
+ else:
+ yErrorPlus, yErrorMinus = yError, yError
+ result = min_max(yData - yErrorMinus, min_positive=True)
+ self.yMin = result.minimum
+ self.yMinPos = result.min_positive
+ self.yMax = (yData + yErrorPlus).max()
+
+ self._errorBars = _ErrorBars(xData, yData, xError, yError,
+ self.xMin, self.yMin)
+
+ kwargs = {'style': lineStyle}
+ if lineColor is not None:
+ kwargs['color'] = lineColor
+ if lineWidth is not None:
+ kwargs['width'] = lineWidth
+ if lineDashPeriod is not None:
+ kwargs['dashPeriod'] = lineDashPeriod
+ self.lines = _Lines2D(**kwargs)
+
+ kwargs = {'marker': marker}
+ if markerColor is not None:
+ kwargs['color'] = markerColor
+ if markerSize is not None:
+ kwargs['size'] = markerSize
+ self.points = _Points2D(**kwargs)
+
+ xVboData = _proxyProperty(('lines', 'xVboData'), ('points', 'xVboData'))
+
+ yVboData = _proxyProperty(('lines', 'yVboData'), ('points', 'yVboData'))
+
+ colorVboData = _proxyProperty(('lines', 'colorVboData'),
+ ('points', 'colorVboData'))
+
+ useColorVboData = _proxyProperty(('lines', 'useColorVboData'),
+ ('points', 'useColorVboData'))
+
+ distVboData = _proxyProperty(('lines', 'distVboData'))
+
+ lineStyle = _proxyProperty(('lines', 'style'))
+
+ lineColor = _proxyProperty(('lines', 'color'))
+
+ lineWidth = _proxyProperty(('lines', 'width'))
+
+ lineDashPeriod = _proxyProperty(('lines', 'dashPeriod'))
+
+ marker = _proxyProperty(('points', 'marker'))
+
+ markerColor = _proxyProperty(('points', 'color'))
+
+ markerSize = _proxyProperty(('points', 'size'))
+
+ @classmethod
+ def init(cls):
+ _Lines2D.init()
+ _Points2D.init()
+
+ @staticmethod
+ def _logFilterData(x, y, color=None, xLog=False, yLog=False):
+ # Copied from Plot.py
+ if xLog and yLog:
+ idx = numpy.nonzero((x > 0) & (y > 0))[0]
+ x = numpy.take(x, idx)
+ y = numpy.take(y, idx)
+ elif yLog:
+ idx = numpy.nonzero(y > 0)[0]
+ x = numpy.take(x, idx)
+ y = numpy.take(y, idx)
+ elif xLog:
+ idx = numpy.nonzero(x > 0)[0]
+ x = numpy.take(x, idx)
+ y = numpy.take(y, idx)
+ else:
+ idx = None
+
+ if idx is not None and isinstance(color, numpy.ndarray):
+ colors = numpy.zeros((x.size, 4), color.dtype)
+ colors[:, 0] = color[idx, 0]
+ colors[:, 1] = color[idx, 1]
+ colors[:, 2] = color[idx, 2]
+ colors[:, 3] = color[idx, 3]
+ else:
+ colors = color
+ return x, y, colors
+
+ def prepare(self, isXLog, isYLog):
+ # init only supports updating isXLog, isYLog
+ xData, yData, colorData = self.xData, self.yData, self.colorData
+
+ if self._isXLog != isXLog or self._isYLog != isYLog:
+ # Log state has changed
+ self._isXLog, self._isYLog = isXLog, isYLog
+
+ # Check if data <= 0. with log scale
+ if (isXLog and self.xMin <= 0.) or (isYLog and self.yMin <= 0.):
+ # Filtering data is needed
+ xData, yData, colorData = self._logFilterData(
+ self.xData, self.yData, self.colorData,
+ self._isXLog, self._isYLog)
+
+ self.discard() # discard existing VBOs
+
+ if self.xVboData is None:
+ xAttrib, yAttrib, cAttrib, dAttrib = None, None, None, None
+ if self.lineStyle in (DASHED, DASHDOT, DOTTED):
+ dists = _distancesFromArrays(xData, yData)
+ if self.colorData is None:
+ xAttrib, yAttrib, dAttrib = vertexBuffer(
+ (xData, yData, dists),
+ prefix=(1, 1, 0), suffix=(1, 1, 0))
+ else:
+ xAttrib, yAttrib, cAttrib, dAttrib = vertexBuffer(
+ (xData, yData, colorData, dists),
+ prefix=(1, 1, 0, 0), suffix=(1, 1, 0, 0))
+ elif self.colorData is None:
+ xAttrib, yAttrib = vertexBuffer(
+ (xData, yData), prefix=(1, 1), suffix=(1, 1))
+ else:
+ xAttrib, yAttrib, cAttrib = vertexBuffer(
+ (xData, yData, colorData), prefix=(1, 1, 0))
+
+ # Shrink VBO
+ self.xVboData = xAttrib.copy()
+ self.xVboData.size -= 2
+ self.xVboData.offset += xAttrib.itemsize
+
+ self.yVboData = yAttrib.copy()
+ self.yVboData.size -= 2
+ self.yVboData.offset += yAttrib.itemsize
+
+ if cAttrib is not None and colorData.dtype.kind == 'u':
+ cAttrib.normalisation = True # Normalise uint to [0, 1]
+ self.colorVboData = cAttrib
+ self.useColorVboData = cAttrib is not None
+ self.distVboData = dAttrib
+
+ if self.fill is not None:
+ xData = xData.reshape(xData.size, 1)
+ zero = numpy.array((1e-32,), dtype=self.yData.dtype)
+
+ # Add one point before data: (x0, 0.)
+ xAttrib.vbo.update(xData[0], xAttrib.offset,
+ xData[0].itemsize)
+ yAttrib.vbo.update(zero, yAttrib.offset, zero.itemsize)
+
+ # Add one point after data: (xN, 0.)
+ xAttrib.vbo.update(xData[-1],
+ xAttrib.offset +
+ (xAttrib.size - 1) * xAttrib.itemsize,
+ xData[-1].itemsize)
+ yAttrib.vbo.update(zero,
+ yAttrib.offset +
+ (yAttrib.size - 1) * yAttrib.itemsize,
+ zero.itemsize)
+
+ self.fill.xFillVboData = xAttrib
+ self.fill.yFillVboData = yAttrib
+ self.fill.xMin, self.fill.yMin = self.xMin, self.yMin
+ self.fill.xMax, self.fill.yMax = self.xMax, self.yMax
+
+ self._errorBars.prepare(isXLog, isYLog)
+
+ def render(self, matrix, isXLog, isYLog):
+ self.prepare(isXLog, isYLog)
+ if self.fill is not None:
+ self.fill.render(matrix, isXLog, isYLog)
+ self._errorBars.render(matrix, isXLog, isYLog)
+ self.lines.render(matrix, isXLog, isYLog)
+ self.points.render(matrix, isXLog, isYLog)
+
+ def discard(self):
+ if self.xVboData is not None:
+ self.xVboData.vbo.discard()
+
+ self.xVboData = None
+ self.yVboData = None
+ self.colorVboData = None
+ self.distVboData = None
+
+ self._errorBars.discard()
+
+ def pick(self, xPickMin, yPickMin, xPickMax, yPickMax):
+ """Perform picking on the curve according to its rendering.
+
+ The picking area is [xPickMin, xPickMax], [yPickMin, yPickMax].
+
+ In case a segment between 2 points with indices i, i+1 is picked,
+ only its lower index end point (i.e., i) is added to the result.
+ In case an end point with index i is picked it is added to the result,
+ and the segment [i-1, i] is not tested for picking.
+
+ :return: The indices of the picked data
+ :rtype: list of int
+ """
+ if (self.marker is None and self.lineStyle is None) or \
+ self.xMin > xPickMax or xPickMin > self.xMax or \
+ self.yMin > yPickMax or yPickMin > self.yMax:
+ # Note: With log scale the bounding box is too large if
+ # some data <= 0.
+ return None
+
+ elif self.lineStyle is not None:
+ # Using Cohen-Sutherland algorithm for line clipping
+ codes = ((self.yData > yPickMax) << 3) | \
+ ((self.yData < yPickMin) << 2) | \
+ ((self.xData > xPickMax) << 1) | \
+ (self.xData < xPickMin)
+
+ # Add all points that are inside the picking area
+ indices = numpy.nonzero(codes == 0)[0].tolist()
+
+ # Segment that might cross the area with no end point inside it
+ segToTestIdx = numpy.nonzero((codes[:-1] != 0) &
+ (codes[1:] != 0) &
+ ((codes[:-1] & codes[1:]) == 0))[0]
+
+ TOP, BOTTOM, RIGHT, LEFT = (1 << 3), (1 << 2), (1 << 1), (1 << 0)
+
+ for index in segToTestIdx:
+ if index not in indices:
+ x0, y0 = self.xData[index], self.yData[index]
+ x1, y1 = self.xData[index + 1], self.yData[index + 1]
+ code1 = codes[index + 1]
+
+ # check for crossing with horizontal bounds
+ # y0 == y1 is a never event:
+ # => pt0 and pt1 in same vertical area are not in segToTest
+ if code1 & TOP:
+ x = x0 + (x1 - x0) * (yPickMax - y0) / (y1 - y0)
+ elif code1 & BOTTOM:
+ x = x0 + (x1 - x0) * (yPickMin - y0) / (y1 - y0)
+ else:
+ x = None # No horizontal bounds intersection test
+
+ if x is not None and xPickMin <= x <= xPickMax:
+ # Intersection
+ indices.append(index)
+
+ else:
+ # check for crossing with vertical bounds
+ # x0 == x1 is a never event (see remark for y)
+ if code1 & RIGHT:
+ y = y0 + (y1 - y0) * (xPickMax - x0) / (x1 - x0)
+ elif code1 & LEFT:
+ y = y0 + (y1 - y0) * (xPickMin - x0) / (x1 - x0)
+ else:
+ y = None # No vertical bounds intersection test
+
+ if y is not None and yPickMin <= y <= yPickMax:
+ # Intersection
+ indices.append(index)
+
+ indices.sort()
+
+ else:
+ indices = numpy.nonzero((self.xData >= xPickMin) &
+ (self.xData <= xPickMax) &
+ (self.yData >= yPickMin) &
+ (self.yData <= yPickMax))[0].tolist()
+
+ return indices
diff --git a/silx/gui/plot/backends/glutils/GLPlotFrame.py b/silx/gui/plot/backends/glutils/GLPlotFrame.py
new file mode 100644
index 0000000..367419c
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLPlotFrame.py
@@ -0,0 +1,1039 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 modules provides the rendering of plot titles, axes and grid.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+# TODO
+# keep aspect ratio managed here?
+# smarter dirty flag handling?
+
+import math
+import weakref
+import logging
+from collections import namedtuple
+
+import numpy
+
+from ...._glutils import gl, Program
+from ..._utils import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX
+from .GLSupport import mat4Ortho
+from .GLText import Text2D, CENTER, BOTTOM, TOP, LEFT, RIGHT, ROTATE_270
+from ..._utils.ticklayout import niceNumbersAdaptative, niceNumbersForLog10
+
+
+_logger = logging.getLogger(__name__)
+
+
+# PlotAxis ####################################################################
+
+class PlotAxis(object):
+ """Represents a 1D axis of the plot.
+ This class is intended to be used with :class:`GLPlotFrame`.
+ """
+
+ def __init__(self, plot,
+ tickLength=(0., 0.),
+ labelAlign=CENTER, labelVAlign=CENTER,
+ titleAlign=CENTER, titleVAlign=CENTER,
+ titleRotate=0, titleOffset=(0., 0.)):
+ self._ticks = None
+
+ self._plot = weakref.ref(plot)
+
+ self._isLog = False
+ self._dataRange = 1., 100.
+ self._displayCoords = (0., 0.), (1., 0.)
+ self._title = ''
+
+ self._tickLength = tickLength
+ self._labelAlign = labelAlign
+ self._labelVAlign = labelVAlign
+ self._titleAlign = titleAlign
+ self._titleVAlign = titleVAlign
+ self._titleRotate = titleRotate
+ self._titleOffset = titleOffset
+
+ @property
+ def dataRange(self):
+ """The range of the data represented on the axis as a tuple
+ of 2 floats: (min, max)."""
+ return self._dataRange
+
+ @dataRange.setter
+ def dataRange(self, dataRange):
+ assert len(dataRange) == 2
+ assert dataRange[0] <= dataRange[1]
+ dataRange = float(dataRange[0]), float(dataRange[1])
+
+ if dataRange != self._dataRange:
+ self._dataRange = dataRange
+ self._dirtyTicks()
+
+ @property
+ def isLog(self):
+ """Whether the axis is using a log10 scale or not as a bool."""
+ return self._isLog
+
+ @isLog.setter
+ def isLog(self, isLog):
+ isLog = bool(isLog)
+ if isLog != self._isLog:
+ self._isLog = isLog
+ self._dirtyTicks()
+
+ @property
+ def displayCoords(self):
+ """The coordinates of the start and end points of the axis
+ in display space (i.e., in pixels) as a tuple of 2 tuples of
+ 2 floats: ((x0, y0), (x1, y1)).
+ """
+ return self._displayCoords
+
+ @displayCoords.setter
+ def displayCoords(self, displayCoords):
+ assert len(displayCoords) == 2
+ assert len(displayCoords[0]) == 2
+ assert len(displayCoords[1]) == 2
+ displayCoords = tuple(displayCoords[0]), tuple(displayCoords[1])
+ if displayCoords != self._displayCoords:
+ self._displayCoords = displayCoords
+ self._dirtyTicks()
+
+ @property
+ def title(self):
+ """The text label associated with this axis as a str in latin-1."""
+ return self._title
+
+ @title.setter
+ def title(self, title):
+ if title != self._title:
+ self._title = title
+
+ plot = self._plot()
+ if plot is not None:
+ plot._dirty()
+
+ @property
+ def ticks(self):
+ """Ticks as tuples: ((x, y) in display, dataPos, textLabel)."""
+ if self._ticks is None:
+ self._ticks = tuple(self._ticksGenerator())
+ return self._ticks
+
+ def getVerticesAndLabels(self):
+ """Create the list of vertices for axis and associated text labels.
+
+ :returns: A tuple: List of 2D line vertices, List of Text2D labels.
+ """
+ vertices = list(self.displayCoords) # Add start and end points
+ labels = []
+ tickLabelsSize = [0., 0.]
+
+ xTickLength, yTickLength = self._tickLength
+ for (xPixel, yPixel), dataPos, text in self.ticks:
+ if text is None:
+ tickScale = 0.5
+ else:
+ tickScale = 1.
+
+ label = Text2D(text=text,
+ x=xPixel - xTickLength,
+ y=yPixel - yTickLength,
+ align=self._labelAlign,
+ valign=self._labelVAlign)
+
+ width, height = label.size
+ if width > tickLabelsSize[0]:
+ tickLabelsSize[0] = width
+ if height > tickLabelsSize[1]:
+ tickLabelsSize[1] = height
+
+ labels.append(label)
+
+ vertices.append((xPixel, yPixel))
+ vertices.append((xPixel + tickScale * xTickLength,
+ yPixel + tickScale * yTickLength))
+
+ (x0, y0), (x1, y1) = self.displayCoords
+ xAxisCenter = 0.5 * (x0 + x1)
+ yAxisCenter = 0.5 * (y0 + y1)
+
+ xOffset, yOffset = self._titleOffset
+
+ # Adaptative title positioning:
+ # tickNorm = math.sqrt(xTickLength ** 2 + yTickLength ** 2)
+ # xOffset = -tickLabelsSize[0] * xTickLength / tickNorm
+ # xOffset -= 3 * xTickLength
+ # yOffset = -tickLabelsSize[1] * yTickLength / tickNorm
+ # yOffset -= 3 * yTickLength
+
+ axisTitle = Text2D(text=self.title,
+ x=xAxisCenter + xOffset,
+ y=yAxisCenter + yOffset,
+ align=self._titleAlign,
+ valign=self._titleVAlign,
+ rotate=self._titleRotate)
+ labels.append(axisTitle)
+
+ return vertices, labels
+
+ def _dirtyTicks(self):
+ """Mark ticks as dirty and notify listener (i.e., background)."""
+ self._ticks = None
+ plot = self._plot()
+ if plot is not None:
+ plot._dirty()
+
+ @staticmethod
+ def _frange(start, stop, step):
+ """range for float (including stop)."""
+ while start <= stop:
+ yield start
+ start += step
+
+ def _ticksGenerator(self):
+ """Generator of ticks as tuples:
+ ((x, y) in display, dataPos, textLabel).
+ """
+ dataMin, dataMax = self.dataRange
+ if self.isLog and dataMin <= 0.:
+ _logger.warning(
+ 'Getting ticks while isLog=True and dataRange[0]<=0.')
+ dataMin = 1.
+ if dataMax < dataMin:
+ dataMax = 1.
+
+ if dataMin != dataMax: # data range is not null
+ (x0, y0), (x1, y1) = self.displayCoords
+
+ if self.isLog:
+ logMin, logMax = math.log10(dataMin), math.log10(dataMax)
+ tickMin, tickMax, step, _ = niceNumbersForLog10(logMin, logMax)
+
+ xScale = (x1 - x0) / (logMax - logMin)
+ yScale = (y1 - y0) / (logMax - logMin)
+
+ for logPos in self._frange(tickMin, tickMax, step):
+ if logMin <= logPos <= logMax:
+ dataPos = 10 ** logPos
+ xPixel = x0 + (logPos - logMin) * xScale
+ yPixel = y0 + (logPos - logMin) * yScale
+ text = '1e%+03d' % logPos
+ yield ((xPixel, yPixel), dataPos, text)
+
+ if step == 1:
+ ticks = list(self._frange(tickMin, tickMax, step))[:-1]
+ for logPos in ticks:
+ dataOrigPos = 10 ** logPos
+ for index in range(2, 10):
+ dataPos = dataOrigPos * index
+ if dataMin <= dataPos <= dataMax:
+ logSubPos = math.log10(dataPos)
+ xPixel = x0 + (logSubPos - logMin) * xScale
+ yPixel = y0 + (logSubPos - logMin) * yScale
+ yield ((xPixel, yPixel), dataPos, None)
+
+ else:
+ xScale = (x1 - x0) / (dataMax - dataMin)
+ yScale = (y1 - y0) / (dataMax - dataMin)
+
+ nbPixels = math.sqrt(pow(x1 - x0, 2) + pow(y1 - y0, 2))
+
+ # Density of 1.3 label per 92 pixels
+ # i.e., 1.3 label per inch on a 92 dpi screen
+ tickMin, tickMax, step, nbFrac = niceNumbersAdaptative(
+ dataMin, dataMax, nbPixels, 1.3 / 92)
+
+ for dataPos in self._frange(tickMin, tickMax, step):
+ if dataMin <= dataPos <= dataMax:
+ xPixel = x0 + (dataPos - dataMin) * xScale
+ yPixel = y0 + (dataPos - dataMin) * yScale
+
+ if nbFrac == 0:
+ text = '%g' % dataPos
+ else:
+ text = ('%.' + str(nbFrac) + 'f') % dataPos
+ yield ((xPixel, yPixel), dataPos, text)
+
+
+# GLPlotFrame #################################################################
+
+class GLPlotFrame(object):
+ """Base class for rendering a 2D frame surrounded by axes."""
+
+ _TICK_LENGTH_IN_PIXELS = 5
+ _LINE_WIDTH = 1
+
+ _SHADERS = {
+ 'vertex': """
+ attribute vec2 position;
+ uniform mat4 matrix;
+
+ void main(void) {
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ }
+ """,
+ 'fragment': """
+ uniform vec4 color;
+ uniform float tickFactor; /* = 1./tickLength or 0. for solid line */
+
+ void main(void) {
+ if (mod(tickFactor * (gl_FragCoord.x + gl_FragCoord.y), 2.) < 1.) {
+ gl_FragColor = color;
+ } else {
+ discard;
+ }
+ }
+ """
+ }
+
+ _Margins = namedtuple('Margins', ('left', 'right', 'top', 'bottom'))
+
+ def __init__(self, margins):
+ """
+ :param margins: The margins around plot area for axis and labels.
+ :type margins: dict with 'left', 'right', 'top', 'bottom' keys and
+ values as ints.
+ """
+ self._renderResources = None
+
+ self._margins = self._Margins(**margins)
+
+ self.axes = [] # List of PlotAxis to be updated by subclasses
+
+ self._grid = False
+ self._size = 0., 0.
+ self._title = ''
+
+ @property
+ def isDirty(self):
+ """True if it need to refresh graphic rendering, False otherwise."""
+ return self._renderResources is None
+
+ GRID_NONE = 0
+ GRID_MAIN_TICKS = 1
+ GRID_SUB_TICKS = 2
+ GRID_ALL_TICKS = (GRID_MAIN_TICKS + GRID_SUB_TICKS)
+
+ @property
+ def margins(self):
+ """Margins in pixels around the plot."""
+ return self._margins
+
+ @property
+ def grid(self):
+ """Grid display mode:
+ - 0: No grid.
+ - 1: Grid on main ticks.
+ - 2: Grid on sub-ticks for log scale axes.
+ - 3: Grid on main and sub ticks."""
+ return self._grid
+
+ @grid.setter
+ def grid(self, grid):
+ assert grid in (self.GRID_NONE, self.GRID_MAIN_TICKS,
+ self.GRID_SUB_TICKS, self.GRID_ALL_TICKS)
+ if grid != self._grid:
+ self._grid = grid
+ self._dirty()
+
+ @property
+ def size(self):
+ """Size in pixels of the plot area including margins."""
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ assert len(size) == 2
+ size = tuple(size)
+ if size != self._size:
+ self._size = size
+ self._dirty()
+
+ @property
+ def plotOrigin(self):
+ """Plot area origin (left, top) in widget coordinates in pixels."""
+ return self.margins.left, self.margins.top
+
+ @property
+ def plotSize(self):
+ """Plot area size (width, height) in pixels."""
+ w, h = self.size
+ w -= self.margins.left + self.margins.right
+ h -= self.margins.top + self.margins.bottom
+ return w, h
+
+ @property
+ def title(self):
+ """Main title as a str in latin-1."""
+ return self._title
+
+ @title.setter
+ def title(self, title):
+ if title != self._title:
+ self._title = title
+ self._dirty()
+
+ # In-place update
+ # if self._renderResources is not None:
+ # self._renderResources[-1][-1].text = title
+
+ def _dirty(self):
+ # When Text2D require discard we need to handle it
+ self._renderResources = None
+
+ def _buildGridVertices(self):
+ if self._grid == self.GRID_NONE:
+ return []
+
+ elif self._grid == self.GRID_MAIN_TICKS:
+ def test(text):
+ return text is not None
+ elif self._grid == self.GRID_SUB_TICKS:
+ def test(text):
+ return text is None
+ elif self._grid == self.GRID_ALL_TICKS:
+ def test(_):
+ return True
+ else:
+ logging.warning('Wrong grid mode: %d' % self._grid)
+ return []
+
+ return self._buildGridVerticesWithTest(test)
+
+ def _buildGridVerticesWithTest(self, test):
+ """Override in subclass to generate grid vertices"""
+ return []
+
+ def _buildVerticesAndLabels(self):
+ # To fill with copy of axes lists
+ vertices = []
+ labels = []
+
+ for axis in self.axes:
+ axisVertices, axisLabels = axis.getVerticesAndLabels()
+ vertices += axisVertices
+ labels += axisLabels
+
+ vertices = numpy.array(vertices, dtype=numpy.float32)
+
+ # Add main title
+ xTitle = (self.size[0] + self.margins.left -
+ self.margins.right) // 2
+ yTitle = self.margins.top - self._TICK_LENGTH_IN_PIXELS
+ labels.append(Text2D(text=self.title,
+ x=xTitle,
+ y=yTitle,
+ align=CENTER,
+ valign=BOTTOM))
+
+ # grid
+ gridVertices = numpy.array(self._buildGridVertices(),
+ dtype=numpy.float32)
+
+ self._renderResources = (vertices, gridVertices, labels)
+
+ _program = Program(
+ _SHADERS['vertex'], _SHADERS['fragment'], attrib0='position')
+
+ def render(self):
+ if self._renderResources is None:
+ self._buildVerticesAndLabels()
+ vertices, gridVertices, labels = self._renderResources
+
+ width, height = self.size
+ matProj = mat4Ortho(0, width, height, 0, 1, -1)
+
+ gl.glViewport(0, 0, width, height)
+
+ prog = self._program
+ prog.use()
+
+ gl.glLineWidth(self._LINE_WIDTH)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matProj)
+ gl.glUniform4f(prog.uniforms['color'], 0., 0., 0., 1.)
+ gl.glUniform1f(prog.uniforms['tickFactor'], 0.)
+
+ gl.glEnableVertexAttribArray(prog.attributes['position'])
+ gl.glVertexAttribPointer(prog.attributes['position'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, vertices)
+
+ gl.glDrawArrays(gl.GL_LINES, 0, len(vertices))
+
+ for label in labels:
+ label.render(matProj)
+
+ def renderGrid(self):
+ if self._grid == self.GRID_NONE:
+ return
+
+ if self._renderResources is None:
+ self._buildVerticesAndLabels()
+ vertices, gridVertices, labels = self._renderResources
+
+ width, height = self.size
+ matProj = mat4Ortho(0, width, height, 0, 1, -1)
+
+ gl.glViewport(0, 0, width, height)
+
+ prog = self._program
+ prog.use()
+
+ gl.glLineWidth(self._LINE_WIDTH)
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matProj)
+ gl.glUniform4f(prog.uniforms['color'], 0.7, 0.7, 0.7, 1.)
+ gl.glUniform1f(prog.uniforms['tickFactor'], 0.) # 1/2.) # 1/tickLen
+
+ gl.glEnableVertexAttribArray(prog.attributes['position'])
+ gl.glVertexAttribPointer(prog.attributes['position'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, gridVertices)
+
+ gl.glDrawArrays(gl.GL_LINES, 0, len(gridVertices))
+
+
+# GLPlotFrame2D ###############################################################
+
+class GLPlotFrame2D(GLPlotFrame):
+ def __init__(self, margins):
+ """
+ :param margins: The margins around plot area for axis and labels.
+ :type margins: dict with 'left', 'right', 'top', 'bottom' keys and
+ values as ints.
+ """
+ super(GLPlotFrame2D, self).__init__(margins)
+ self.axes.append(PlotAxis(self,
+ tickLength=(0., -5.),
+ labelAlign=CENTER, labelVAlign=TOP,
+ titleAlign=CENTER, titleVAlign=TOP,
+ titleRotate=0,
+ titleOffset=(0, self.margins.bottom // 2)))
+
+ self._x2AxisCoords = ()
+
+ self.axes.append(PlotAxis(self,
+ tickLength=(5., 0.),
+ labelAlign=RIGHT, labelVAlign=CENTER,
+ titleAlign=CENTER, titleVAlign=BOTTOM,
+ titleRotate=ROTATE_270,
+ titleOffset=(-3 * self.margins.left // 4,
+ 0)))
+
+ self._y2Axis = PlotAxis(self,
+ tickLength=(-5., 0.),
+ labelAlign=LEFT, labelVAlign=CENTER,
+ titleAlign=CENTER, titleVAlign=TOP,
+ titleRotate=ROTATE_270,
+ titleOffset=(3 * self.margins.right // 4,
+ 0))
+
+ self._isYAxisInverted = False
+
+ self._dataRanges = {
+ 'x': (1., 100.), 'y': (1., 100.), 'y2': (1., 100.)}
+
+ self._baseVectors = (1., 0.), (0., 1.)
+
+ self._transformedDataRanges = None
+ self._transformedDataProjMat = None
+ self._transformedDataY2ProjMat = None
+
+ def _dirty(self):
+ super(GLPlotFrame2D, self)._dirty()
+ self._transformedDataRanges = None
+ self._transformedDataProjMat = None
+ self._transformedDataY2ProjMat = None
+
+ @property
+ def isDirty(self):
+ """True if it need to refresh graphic rendering, False otherwise."""
+ return (super(GLPlotFrame2D, self).isDirty or
+ self._transformedDataRanges is None or
+ self._transformedDataProjMat is None or
+ self._transformedDataY2ProjMat is None)
+
+ @property
+ def xAxis(self):
+ return self.axes[0]
+
+ @property
+ def yAxis(self):
+ return self.axes[1]
+
+ @property
+ def y2Axis(self):
+ return self._y2Axis
+
+ @property
+ def isY2Axis(self):
+ """Whether to display the left Y axis or not."""
+ return len(self.axes) == 3
+
+ @isY2Axis.setter
+ def isY2Axis(self, isY2Axis):
+ if isY2Axis != self.isY2Axis:
+ if isY2Axis:
+ self.axes.append(self._y2Axis)
+ else:
+ self.axes = self.axes[:2]
+
+ self._dirty()
+
+ @property
+ def isYAxisInverted(self):
+ """Whether Y axes are inverted or not as a bool."""
+ return self._isYAxisInverted
+
+ @isYAxisInverted.setter
+ def isYAxisInverted(self, value):
+ value = bool(value)
+ if value != self._isYAxisInverted:
+ self._isYAxisInverted = value
+ self._dirty()
+
+ DEFAULT_BASE_VECTORS = (1., 0.), (0., 1.)
+ """Values of baseVectors for orthogonal axes."""
+
+ @property
+ def baseVectors(self):
+ """Coordinates of the X and Y axes in the orthogonal plot coords.
+
+ Raises ValueError if corresponding matrix is singular.
+
+ 2 tuples of 2 floats: (xx, xy), (yx, yy)
+ """
+ return self._baseVectors
+
+ @baseVectors.setter
+ def baseVectors(self, baseVectors):
+ self._dirty()
+
+ (xx, xy), (yx, yy) = baseVectors
+ vectors = (float(xx), float(xy)), (float(yx), float(yy))
+
+ det = (vectors[0][0] * vectors[1][1] - vectors[1][0] * vectors[0][1])
+ if det == 0.:
+ raise ValueError("Singular matrix for base vectors: " +
+ str(vectors))
+
+ if vectors != self._baseVectors:
+ self._baseVectors = vectors
+ self._dirty()
+
+ @property
+ def dataRanges(self):
+ """Ranges of data visible in the plot on x, y and y2 axes.
+
+ This is different to the axes range when axes are not orthogonal.
+
+ Type: ((xMin, xMax), (yMin, yMax), (y2Min, y2Max))
+ """
+ return self._DataRanges(self._dataRanges['x'],
+ self._dataRanges['y'],
+ self._dataRanges['y2'])
+
+ @staticmethod
+ def _clipToSafeRange(min_, max_, isLog):
+ # Clip range if needed
+ minLimit = FLOAT32_MINPOS if isLog else FLOAT32_SAFE_MIN
+ min_ = numpy.clip(min_, minLimit, FLOAT32_SAFE_MAX)
+ max_ = numpy.clip(max_, minLimit, FLOAT32_SAFE_MAX)
+ assert min_ < max_
+ return min_, max_
+
+ def setDataRanges(self, x=None, y=None, y2=None):
+ """Set data range over each axes.
+
+ The provided ranges are clipped to possible values
+ (i.e., 32 float range + positive range for log scale).
+
+ :param x: (min, max) data range over X axis
+ :param y: (min, max) data range over Y axis
+ :param y2: (min, max) data range over Y2 axis
+ """
+ if x is not None:
+ self._dataRanges['x'] = \
+ self._clipToSafeRange(x[0], x[1], self.xAxis.isLog)
+
+ if y is not None:
+ self._dataRanges['y'] = \
+ self._clipToSafeRange(y[0], y[1], self.yAxis.isLog)
+
+ if y2 is not None:
+ self._dataRanges['y2'] = \
+ self._clipToSafeRange(y2[0], y2[1], self.y2Axis.isLog)
+
+ self.xAxis.dataRange = self._dataRanges['x']
+ self.yAxis.dataRange = self._dataRanges['y']
+ self.y2Axis.dataRange = self._dataRanges['y2']
+
+ _DataRanges = namedtuple('dataRanges', ('x', 'y', 'y2'))
+
+ @property
+ def transformedDataRanges(self):
+ """Bounds of the displayed area in transformed data coordinates
+ (i.e., log scale applied if any as well as skew)
+
+ 3-tuple of 2-tuple (min, max) for each axis: x, y, y2.
+ """
+ if self._transformedDataRanges is None:
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = self.dataRanges
+
+ if self.xAxis.isLog:
+ try:
+ xMin = math.log10(xMin)
+ except ValueError:
+ _logger.info('xMin: warning log10(%f)', xMin)
+ xMin = 0.
+ try:
+ xMax = math.log10(xMax)
+ except ValueError:
+ _logger.info('xMax: warning log10(%f)', xMax)
+ xMax = 0.
+
+ if self.yAxis.isLog:
+ try:
+ yMin = math.log10(yMin)
+ except ValueError:
+ _logger.info('yMin: warning log10(%f)', yMin)
+ yMin = 0.
+ try:
+ yMax = math.log10(yMax)
+ except ValueError:
+ _logger.info('yMax: warning log10(%f)', yMax)
+ yMax = 0.
+
+ try:
+ y2Min = math.log10(y2Min)
+ except ValueError:
+ _logger.info('yMin: warning log10(%f)', y2Min)
+ y2Min = 0.
+ try:
+ y2Max = math.log10(y2Max)
+ except ValueError:
+ _logger.info('yMax: warning log10(%f)', y2Max)
+ y2Max = 0.
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ skew_mat = numpy.array(((xx, yx), (xy, yy)))
+
+ corners = [(xMin, yMin), (xMin, yMax),
+ (xMax, yMin), (xMax, yMax),
+ (xMin, y2Min), (xMin, y2Max),
+ (xMax, y2Min), (xMax, y2Max)]
+
+ corners = numpy.array(
+ [numpy.dot(skew_mat, corner) for corner in corners],
+ dtype=numpy.float32)
+ xMin, xMax = corners[:, 0].min(), corners[:, 0].max()
+ yMin, yMax = corners[0:4, 1].min(), corners[0:4, 1].max()
+ y2Min, y2Max = corners[4:, 1].min(), corners[4:, 1].max()
+
+ self._transformedDataRanges = self._DataRanges(
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max))
+
+ return self._transformedDataRanges
+
+ @property
+ def transformedDataProjMat(self):
+ """Orthographic projection matrix for rendering transformed data
+
+ :type: numpy.matrix
+ """
+ if self._transformedDataProjMat is None:
+ xMin, xMax = self.transformedDataRanges.x
+ yMin, yMax = self.transformedDataRanges.y
+
+ if self.isYAxisInverted:
+ mat = mat4Ortho(xMin, xMax, yMax, yMin, 1, -1)
+ else:
+ mat = mat4Ortho(xMin, xMax, yMin, yMax, 1, -1)
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ mat = mat * numpy.matrix((
+ (xx, yx, 0., 0.),
+ (xy, yy, 0., 0.),
+ (0., 0., 1., 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+ self._transformedDataProjMat = mat
+
+ return self._transformedDataProjMat
+
+ @property
+ def transformedDataY2ProjMat(self):
+ """Orthographic projection matrix for rendering transformed data
+ for the 2nd Y axis
+
+ :type: numpy.matrix
+ """
+ if self._transformedDataY2ProjMat is None:
+ xMin, xMax = self.transformedDataRanges.x
+ y2Min, y2Max = self.transformedDataRanges.y2
+
+ if self.isYAxisInverted:
+ mat = mat4Ortho(xMin, xMax, y2Max, y2Min, 1, -1)
+ else:
+ mat = mat4Ortho(xMin, xMax, y2Min, y2Max, 1, -1)
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ mat = mat * numpy.matrix((
+ (xx, yx, 0., 0.),
+ (xy, yy, 0., 0.),
+ (0., 0., 1., 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+ self._transformedDataY2ProjMat = mat
+
+ return self._transformedDataY2ProjMat
+
+ def dataToPixel(self, x, y, axis='left'):
+ """Convert data coordinate to widget pixel coordinate.
+ """
+ assert axis in ('left', 'right')
+
+ trBounds = self.transformedDataRanges
+
+ if self.xAxis.isLog:
+ if x < FLOAT32_MINPOS:
+ return None
+ xDataTr = math.log10(x)
+ else:
+ xDataTr = x
+
+ if self.yAxis.isLog:
+ if y < FLOAT32_MINPOS:
+ return None
+ yDataTr = math.log10(y)
+ else:
+ yDataTr = y
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ skew_mat = numpy.array(((xx, yx), (xy, yy)))
+
+ coords = numpy.dot(skew_mat, numpy.array((xDataTr, yDataTr)))
+ xDataTr, yDataTr = coords
+
+ plotWidth, plotHeight = self.plotSize
+
+ xPixel = int(self.margins.left +
+ plotWidth * (xDataTr - trBounds.x[0]) /
+ (trBounds.x[1] - trBounds.x[0]))
+
+ usedAxis = trBounds.y if axis == "left" else trBounds.y2
+ yOffset = (plotHeight * (yDataTr - usedAxis[0]) /
+ (usedAxis[1] - usedAxis[0]))
+
+ if self.isYAxisInverted:
+ yPixel = int(self.margins.top + yOffset)
+ else:
+ yPixel = int(self.size[1] - self.margins.bottom - yOffset)
+
+ return xPixel, yPixel
+
+ def pixelToData(self, x, y, axis="left"):
+ """Convert pixel position to data coordinates.
+
+ :param float x: X coord
+ :param float y: Y coord
+ :param str axis: Y axis to use in ('left', 'right')
+ :return: (x, y) position in data coords
+ """
+ assert axis in ("left", "right")
+
+ plotWidth, plotHeight = self.plotSize
+
+ trBounds = self.transformedDataRanges
+
+ xData = (x - self.margins.left + 0.5) / float(plotWidth)
+ xData = trBounds.x[0] + xData * (trBounds.x[1] - trBounds.x[0])
+
+ usedAxis = trBounds.y if axis == "left" else trBounds.y2
+ if self.isYAxisInverted:
+ yData = (y - self.margins.top + 0.5) / float(plotHeight)
+ yData = usedAxis[0] + yData * (usedAxis[1] - usedAxis[0])
+ else:
+ yData = self.size[1] - self.margins.bottom - y - 0.5
+ yData /= float(plotHeight)
+ yData = usedAxis[0] + yData * (usedAxis[1] - usedAxis[0])
+
+ # non-orthogonal axis
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ skew_mat = numpy.array(((xx, yx), (xy, yy)))
+ skew_mat = numpy.linalg.inv(skew_mat)
+
+ coords = numpy.dot(skew_mat, numpy.array((xData, yData)))
+ xData, yData = coords
+
+ if self.xAxis.isLog:
+ xData = pow(10, xData)
+ if self.yAxis.isLog:
+ yData = pow(10, yData)
+
+ return xData, yData
+
+ def _buildGridVerticesWithTest(self, test):
+ vertices = []
+
+ if self.baseVectors == self.DEFAULT_BASE_VECTORS:
+ for axis in self.axes:
+ for (xPixel, yPixel), data, text in axis.ticks:
+ if test(text):
+ vertices.append((xPixel, yPixel))
+ if axis == self.xAxis:
+ vertices.append((xPixel, self.margins.top))
+ elif axis == self.yAxis:
+ vertices.append((self.size[0] - self.margins.right,
+ yPixel))
+ else: # axis == self.y2Axis
+ vertices.append((self.margins.left, yPixel))
+
+ else:
+ # Get plot corners in data coords
+ plotLeft, plotTop = self.plotOrigin
+ plotWidth, plotHeight = self.plotSize
+
+ corners = [(plotLeft, plotTop),
+ (plotLeft, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop)]
+
+ for axis in self.axes:
+ if axis == self.xAxis:
+ cornersInData = numpy.array([
+ self.pixelToData(x, y) for (x, y) in corners])
+ borders = ((cornersInData[0], cornersInData[3]), # top
+ (cornersInData[1], cornersInData[0]), # left
+ (cornersInData[3], cornersInData[2])) # right
+
+ for (xPixel, yPixel), data, text in axis.ticks:
+ if test(text):
+ for (x0, y0), (x1, y1) in borders:
+ if min(x0, x1) <= data < max(x0, x1):
+ yIntersect = (data - x0) * \
+ (y1 - y0) / (x1 - x0) + y0
+
+ pixelPos = self.dataToPixel(
+ data, yIntersect)
+ if pixelPos is not None:
+ vertices.append((xPixel, yPixel))
+ vertices.append(pixelPos)
+ break # Stop at first intersection
+
+ else: # y or y2 axes
+ if axis == self.yAxis:
+ axis_name = 'left'
+ cornersInData = numpy.array([
+ self.pixelToData(x, y) for (x, y) in corners])
+ borders = (
+ (cornersInData[3], cornersInData[2]), # right
+ (cornersInData[0], cornersInData[3]), # top
+ (cornersInData[2], cornersInData[1])) # bottom
+
+ else: # axis == self.y2Axis
+ axis_name = 'right'
+ corners = numpy.array([self.pixelToData(
+ x, y, axis='right') for (x, y) in corners])
+ borders = (
+ (cornersInData[1], cornersInData[0]), # left
+ (cornersInData[0], cornersInData[3]), # top
+ (cornersInData[2], cornersInData[1])) # bottom
+
+ for (xPixel, yPixel), data, text in axis.ticks:
+ if test(text):
+ for (x0, y0), (x1, y1) in borders:
+ if min(y0, y1) <= data < max(y0, y1):
+ xIntersect = (data - y0) * \
+ (x1 - x0) / (y1 - y0) + x0
+
+ pixelPos = self.dataToPixel(
+ xIntersect, data, axis=axis_name)
+ if pixelPos is not None:
+ vertices.append((xPixel, yPixel))
+ vertices.append(pixelPos)
+ break # Stop at first intersection
+
+ return vertices
+
+ def _buildVerticesAndLabels(self):
+ width, height = self.size
+
+ xCoords = (self.margins.left - 0.5,
+ width - self.margins.right + 0.5)
+ yCoords = (height - self.margins.bottom + 0.5,
+ self.margins.top - 0.5)
+
+ self.axes[0].displayCoords = ((xCoords[0], yCoords[0]),
+ (xCoords[1], yCoords[0]))
+
+ self._x2AxisCoords = ((xCoords[0], yCoords[1]),
+ (xCoords[1], yCoords[1]))
+
+ if self.isYAxisInverted:
+ # Y axes are inverted, axes coordinates are inverted
+ yCoords = yCoords[1], yCoords[0]
+
+ self.axes[1].displayCoords = ((xCoords[0], yCoords[0]),
+ (xCoords[0], yCoords[1]))
+
+ self._y2Axis.displayCoords = ((xCoords[1], yCoords[0]),
+ (xCoords[1], yCoords[1]))
+
+ super(GLPlotFrame2D, self)._buildVerticesAndLabels()
+
+ vertices, gridVertices, labels = self._renderResources
+
+ # Adds vertices for borders without axis
+ extraVertices = []
+ extraVertices += self._x2AxisCoords
+ if not self.isY2Axis:
+ extraVertices += self._y2Axis.displayCoords
+
+ extraVertices = numpy.array(
+ extraVertices, copy=False, dtype=numpy.float32)
+ vertices = numpy.append(vertices, extraVertices, axis=0)
+
+ self._renderResources = (vertices, gridVertices, labels)
diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py
new file mode 100644
index 0000000..8fff82b
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLPlotImage.py
@@ -0,0 +1,707 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 class to render 2D array as a colormap or RGB(A) image
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import math
+import numpy
+
+from silx.math.combo import min_max
+
+from ...._glutils import gl, Program, Texture
+from ..._utils import FLOAT32_MINPOS
+from .GLSupport import mat4Translate, mat4Scale
+from .GLTexture import Image
+
+
+class _GLPlotData2D(object):
+ def __init__(self, data, origin, scale):
+ self.data = data
+ assert len(origin) == 2
+ self.origin = tuple(origin)
+ assert len(scale) == 2
+ self.scale = tuple(scale)
+
+ def pick(self, x, y):
+ if self.xMin <= x <= self.xMax and self.yMin <= y <= self.yMax:
+ ox, oy = self.origin
+ sx, sy = self.scale
+ col = int((x - ox) / sx)
+ row = int((y - oy) / sy)
+ return col, row
+ else:
+ return None
+
+ @property
+ def xMin(self):
+ ox, sx = self.origin[0], self.scale[0]
+ return ox if sx >= 0. else ox + sx * self.data.shape[1]
+
+ @property
+ def yMin(self):
+ oy, sy = self.origin[1], self.scale[1]
+ return oy if sy >= 0. else oy + sy * self.data.shape[0]
+
+ @property
+ def xMax(self):
+ ox, sx = self.origin[0], self.scale[0]
+ return ox + sx * self.data.shape[1] if sx >= 0. else ox
+
+ @property
+ def yMax(self):
+ oy, sy = self.origin[1], self.scale[1]
+ return oy + sy * self.data.shape[0] if sy >= 0. else oy
+
+ def discard(self):
+ pass
+
+ def prepare(self):
+ pass
+
+ def render(self, matrix, isXLog, isYLog):
+ pass
+
+
+class GLPlotColormap(_GLPlotData2D):
+
+ _SHADERS = {
+ 'linear': {
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ attribute vec2 texCoords;
+ attribute vec2 position;
+
+ varying vec2 coords;
+
+ void main(void) {
+ coords = texCoords;
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ }
+ """,
+ 'fragTransform': """
+ vec2 textureCoords(void) {
+ return coords;
+ }
+ """},
+
+ 'log': {
+ 'vertex': """
+ #version 120
+
+ attribute vec2 position;
+ uniform mat4 matrix;
+ uniform mat4 matOffset;
+ uniform bvec2 isLog;
+
+ varying vec2 coords;
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ vec4 dataPos = matOffset * vec4(position, 0.0, 1.0);
+ if (isLog.x) {
+ dataPos.x = oneOverLog10 * log(dataPos.x);
+ }
+ if (isLog.y) {
+ dataPos.y = oneOverLog10 * log(dataPos.y);
+ }
+ coords = dataPos.xy;
+ gl_Position = matrix * dataPos;
+ }
+ """,
+ 'fragTransform': """
+ uniform bvec2 isLog;
+ uniform struct {
+ vec2 oneOverRange;
+ vec2 originOverRange;
+ } bounds;
+
+ vec2 textureCoords(void) {
+ vec2 pos = coords;
+ if (isLog.x) {
+ pos.x = pow(10., coords.x);
+ }
+ if (isLog.y) {
+ pos.y = pow(10., coords.y);
+ }
+ return pos * bounds.oneOverRange - bounds.originOverRange;
+ // TODO texture coords in range different from [0, 1]
+ }
+ """},
+
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D data;
+ uniform struct {
+ sampler2D texture;
+ bool isLog;
+ float min;
+ float oneOverRange;
+ } cmap;
+ uniform float alpha;
+
+ varying vec2 coords;
+
+ %s
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ float value = texture2D(data, textureCoords()).r;
+ if (cmap.isLog) {
+ if (value > 0.) {
+ value = clamp(cmap.oneOverRange *
+ (oneOverLog10 * log(value) - cmap.min),
+ 0., 1.);
+ } else {
+ value = 0.;
+ }
+ } else { /*Linear mapping*/
+ value = clamp(cmap.oneOverRange * (value - cmap.min), 0., 1.);
+ }
+
+ gl_FragColor = texture2D(cmap.texture, vec2(value, 0.5));
+ gl_FragColor.a *= alpha;
+ }
+ """
+ }
+
+ _DATA_TEX_UNIT = 0
+ _CMAP_TEX_UNIT = 1
+
+ _INTERNAL_FORMATS = {
+ numpy.dtype(numpy.float32): gl.GL_R32F,
+ # Use normalized integer for unsigned int formats
+ numpy.dtype(numpy.uint16): gl.GL_R16,
+ numpy.dtype(numpy.uint8): gl.GL_R8,
+ }
+
+ _linearProgram = Program(_SHADERS['linear']['vertex'],
+ _SHADERS['fragment'] %
+ _SHADERS['linear']['fragTransform'],
+ attrib0='position')
+
+ _logProgram = Program(_SHADERS['log']['vertex'],
+ _SHADERS['fragment'] %
+ _SHADERS['log']['fragTransform'],
+ attrib0='position')
+
+ def __init__(self, data, origin, scale,
+ colormap, cmapIsLog=False, cmapRange=None,
+ alpha=1.0):
+ """Create a 2D colormap
+
+ :param data: The 2D scalar data array to display
+ :type data: numpy.ndarray with 2 dimensions (dtype=numpy.float32)
+ :param origin: (x, y) coordinates of the origin of the data array
+ :type origin: 2-tuple of floats.
+ :param scale: (sx, sy) scale factors of the data array.
+ This is the size of a data pixel in plot data space.
+ :type scale: 2-tuple of floats.
+ :param str colormap: Name of the colormap to use
+ TODO: Accept a 1D scalar array as the colormap
+ :param bool cmapIsLog: If True, uses log10 of the data value
+ :param cmapRange: The range of colormap or None for autoscale colormap
+ For logarithmic colormap, the range is in the untransformed data
+ TODO: check consistency with matplotlib
+ :type cmapRange: (float, float) or None
+ :param float alpha: Opacity from 0 (transparent) to 1 (opaque)
+ """
+ assert data.dtype in self._INTERNAL_FORMATS
+
+ super(GLPlotColormap, self).__init__(data, origin, scale)
+ self.colormap = numpy.array(colormap, copy=False)
+ self.cmapIsLog = cmapIsLog
+ self._cmapRange = None # User-provided range info
+ self._cmapRangeCache = None # Store extra data for range
+ self.cmapRange = cmapRange # Update _cmapRange
+ self._alpha = numpy.clip(alpha, 0., 1.)
+
+ self._cmap_texture = None
+ self._texture = None
+ self._textureIsDirty = False
+
+ def discard(self):
+ if self._cmap_texture is not None:
+ self._cmap_texture.discard()
+ self._cmap_texture = None
+
+ if self._texture is not None:
+ self._texture.discard()
+ self._texture = None
+ self._textureIsDirty = False
+
+ @property
+ def cmapRange(self):
+ if self._cmapRange is None: # Auto-scale mode
+ if self._cmapRangeCache is None:
+ # Build data , positive ranges
+ result = min_max(self.data, min_positive=True)
+ min_ = result.minimum
+ minPos = result.min_positive
+ max_ = result.maximum
+ maxPos = max_ if max_ > 0. else 1.
+ if minPos is None:
+ minPos = maxPos
+ self._cmapRangeCache = {'range': (min_, max_),
+ 'pos': (minPos, maxPos)}
+
+ return self._cmapRangeCache['pos' if self.cmapIsLog else 'range']
+
+ else:
+ if not self.cmapIsLog:
+ return self._cmapRange # Return range as is
+ else:
+ if self._cmapRangeCache is None:
+ # Build a strictly positive range from cmapRange
+ min_, max_ = self._cmapRange
+ if min_ > 0. and max_ > 0.:
+ minPos, maxPos = min_, max_
+ else:
+ result = min_max(self.data, min_positive=True)
+ minPos = result.min_positive
+ dataMax = result.maximum
+ if max_ > 0.:
+ maxPos = max_
+ elif dataMax > 0.:
+ maxPos = dataMax
+ else:
+ maxPos = 1. # Arbitrary fallback
+ if minPos is None:
+ minPos = maxPos
+ self._cmapRangeCache = minPos, maxPos
+ return self._cmapRangeCache # Strictly positive range
+
+ @cmapRange.setter
+ def cmapRange(self, cmapRange):
+ self._cmapRangeCache = None
+ if cmapRange is None:
+ self._cmapRange = None
+ else:
+ assert len(cmapRange) == 2
+ assert cmapRange[0] <= cmapRange[1]
+ self._cmapRange = tuple(cmapRange)
+
+ @property
+ def alpha(self):
+ return self._alpha
+
+ def updateData(self, data):
+ assert data.dtype in self._INTERNAL_FORMATS
+ oldData = self.data
+ self.data = data
+
+ self._cmapRangeCache = None
+
+ if self._texture is not None:
+ if (self.data.shape != oldData.shape or
+ self.data.dtype != oldData.dtype):
+ self.discard()
+ else:
+ self._textureIsDirty = True
+
+ def prepare(self):
+ if self._cmap_texture is None:
+ # TODO share cmap texture accross Images
+ # put all cmaps in one texture
+ colormap = numpy.empty((16, 256, self.colormap.shape[1]),
+ dtype=self.colormap.dtype)
+ colormap[:] = self.colormap
+ format_ = gl.GL_RGBA if colormap.shape[-1] == 4 else gl.GL_RGB
+ self._cmap_texture = Texture(internalFormat=format_,
+ data=colormap,
+ format_=format_,
+ texUnit=self._CMAP_TEX_UNIT,
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=(gl.GL_CLAMP_TO_EDGE,
+ gl.GL_CLAMP_TO_EDGE))
+
+ if self._texture is None:
+ internalFormat = self._INTERNAL_FORMATS[self.data.dtype]
+
+ self._texture = Image(internalFormat,
+ self.data,
+ format_=gl.GL_RED,
+ texUnit=self._DATA_TEX_UNIT)
+ elif self._textureIsDirty:
+ self._textureIsDirty = True
+ self._texture.updateAll(format_=gl.GL_RED, data=self.data)
+
+ def _setCMap(self, prog):
+ dataMin, dataMax = self.cmapRange # If log, it is stricly positive
+
+ if self.data.dtype in (numpy.uint16, numpy.uint8):
+ # Using unsigned int as normalized integer in OpenGL
+ # So normalize range
+ maxInt = float(numpy.iinfo(self.data.dtype).max)
+ dataMin, dataMax = dataMin / maxInt, dataMax / maxInt
+
+ if self.cmapIsLog:
+ dataMin = math.log10(dataMin)
+ dataMax = math.log10(dataMax)
+
+ gl.glUniform1i(prog.uniforms['cmap.texture'],
+ self._cmap_texture.texUnit)
+ gl.glUniform1i(prog.uniforms['cmap.isLog'], self.cmapIsLog)
+ gl.glUniform1f(prog.uniforms['cmap.min'], dataMin)
+ if dataMax > dataMin:
+ oneOverRange = 1. / (dataMax - dataMin)
+ else:
+ oneOverRange = 0. # Fall-back
+ gl.glUniform1f(prog.uniforms['cmap.oneOverRange'], oneOverRange)
+
+ self._cmap_texture.bind()
+
+ def _renderLinear(self, matrix):
+ self.prepare()
+
+ prog = self._linearProgram
+ prog.use()
+
+ gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT)
+
+ mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ self._setCMap(prog)
+
+ self._texture.render(prog.attributes['position'],
+ prog.attributes['texCoords'],
+ self._DATA_TEX_UNIT)
+
+ def _renderLog10(self, matrix, isXLog, isYLog):
+ xMin, yMin = self.xMin, self.yMin
+ if ((isXLog and xMin < FLOAT32_MINPOS) or
+ (isYLog and yMin < FLOAT32_MINPOS)):
+ # Do not render images that are partly or totally <= 0
+ return
+
+ self.prepare()
+
+ prog = self._logProgram
+ prog.use()
+
+ ox, oy = self.origin
+
+ gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ mat = mat4Translate(ox, oy) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog)
+
+ ex = ox + self.scale[0] * self.data.shape[1]
+ ey = oy + self.scale[1] * self.data.shape[0]
+
+ xOneOverRange = 1. / (ex - ox)
+ yOneOverRange = 1. / (ey - oy)
+ gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ ox * xOneOverRange, oy * yOneOverRange)
+ gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ xOneOverRange, yOneOverRange)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ self._setCMap(prog)
+
+ try:
+ tiles = self._texture.tiles
+ except AttributeError:
+ raise RuntimeError("No texture, discard has already been called")
+ if len(tiles) > 1:
+ raise NotImplementedError(
+ "Image over multiple textures not supported with log scale")
+
+ texture, vertices, info = tiles[0]
+
+ texture.bind(self._DATA_TEX_UNIT)
+
+ posAttrib = prog.attributes['position']
+ stride = vertices.shape[-1] * vertices.itemsize
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, vertices)
+
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices))
+
+ def render(self, matrix, isXLog, isYLog):
+ if any((isXLog, isYLog)):
+ self._renderLog10(matrix, isXLog, isYLog)
+ else:
+ self._renderLinear(matrix)
+
+ # Unbind colormap texture
+ gl.glActiveTexture(gl.GL_TEXTURE0 + self._cmap_texture.texUnit)
+ gl.glBindTexture(self._cmap_texture.target, 0)
+
+
+# image #######################################################################
+
+class GLPlotRGBAImage(_GLPlotData2D):
+
+ _SHADERS = {
+ 'linear': {
+ 'vertex': """
+ #version 120
+
+ attribute vec2 position;
+ attribute vec2 texCoords;
+ uniform mat4 matrix;
+
+ varying vec2 coords;
+
+ void main(void) {
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ coords = texCoords;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D tex;
+ uniform float alpha;
+
+ varying vec2 coords;
+
+ void main(void) {
+ gl_FragColor = texture2D(tex, coords);
+ gl_FragColor.a *= alpha;
+ }
+ """},
+
+ 'log': {
+ 'vertex': """
+ #version 120
+
+ attribute vec2 position;
+ uniform mat4 matrix;
+ uniform mat4 matOffset;
+ uniform bvec2 isLog;
+
+ varying vec2 coords;
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ vec4 dataPos = matOffset * vec4(position, 0.0, 1.0);
+ if (isLog.x) {
+ dataPos.x = oneOverLog10 * log(dataPos.x);
+ }
+ if (isLog.y) {
+ dataPos.y = oneOverLog10 * log(dataPos.y);
+ }
+ coords = dataPos.xy;
+ gl_Position = matrix * dataPos;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D tex;
+ uniform bvec2 isLog;
+ uniform struct {
+ vec2 oneOverRange;
+ vec2 originOverRange;
+ } bounds;
+ uniform float alpha;
+
+ varying vec2 coords;
+
+ vec2 textureCoords(void) {
+ vec2 pos = coords;
+ if (isLog.x) {
+ pos.x = pow(10., coords.x);
+ }
+ if (isLog.y) {
+ pos.y = pow(10., coords.y);
+ }
+ return pos * bounds.oneOverRange - bounds.originOverRange;
+ // TODO texture coords in range different from [0, 1]
+ }
+
+ void main(void) {
+ gl_FragColor = texture2D(tex, textureCoords());
+ gl_FragColor.a *= alpha;
+ }
+ """}
+ }
+
+ _DATA_TEX_UNIT = 0
+
+ _SUPPORTED_DTYPES = (numpy.dtype(numpy.float32),
+ numpy.dtype(numpy.uint8))
+
+ _linearProgram = Program(_SHADERS['linear']['vertex'],
+ _SHADERS['linear']['fragment'],
+ attrib0='position')
+
+ _logProgram = Program(_SHADERS['log']['vertex'],
+ _SHADERS['log']['fragment'],
+ attrib0='position')
+
+ def __init__(self, data, origin, scale, alpha):
+ """Create a 2D RGB(A) image from data
+
+ :param data: The 2D image data array to display
+ :type data: numpy.ndarray with 3 dimensions
+ (dtype=numpy.uint8 or numpy.float32)
+ :param origin: (x, y) coordinates of the origin of the data array
+ :type origin: 2-tuple of floats.
+ :param scale: (sx, sy) scale factors of the data array.
+ This is the size of a data pixel in plot data space.
+ :type scale: 2-tuple of floats.
+ :param float alpha: Opacity from 0 (transparent) to 1 (opaque)
+ """
+ assert data.dtype in self._SUPPORTED_DTYPES
+ super(GLPlotRGBAImage, self).__init__(data, origin, scale)
+ self._texture = None
+ self._textureIsDirty = False
+ self._alpha = numpy.clip(alpha, 0., 1.)
+
+ @property
+ def alpha(self):
+ return self._alpha
+
+ def discard(self):
+ if self._texture is not None:
+ self._texture.discard()
+ self._texture = None
+ self._textureIsDirty = False
+
+ def updateData(self, data):
+ assert data.dtype in self._SUPPORTED_DTYPES
+ oldData = self.data
+ self.data = data
+
+ if self._texture is not None:
+ if self.data.shape != oldData.shape:
+ self.discard()
+ else:
+ self._textureIsDirty = True
+
+ def prepare(self):
+ if self._texture is None:
+ format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB
+
+ self._texture = Image(format_,
+ self.data,
+ format_=format_,
+ texUnit=self._DATA_TEX_UNIT)
+ elif self._textureIsDirty:
+ self._textureIsDirty = False
+
+ # We should check that internal format is the same
+ format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB
+ self._texture.updateAll(format_=format_, data=self.data)
+
+ def _renderLinear(self, matrix):
+ self.prepare()
+
+ prog = self._linearProgram
+ prog.use()
+
+ gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT)
+
+ mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ self._texture.render(prog.attributes['position'],
+ prog.attributes['texCoords'],
+ self._DATA_TEX_UNIT)
+
+ def _renderLog(self, matrix, isXLog, isYLog):
+ self.prepare()
+
+ prog = self._logProgram
+ prog.use()
+
+ ox, oy = self.origin
+
+ gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ mat = mat4Translate(ox, oy) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ ex = ox + self.scale[0] * self.data.shape[1]
+ ey = oy + self.scale[1] * self.data.shape[0]
+
+ xOneOverRange = 1. / (ex - ox)
+ yOneOverRange = 1. / (ey - oy)
+ gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ ox * xOneOverRange, oy * yOneOverRange)
+ gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ xOneOverRange, yOneOverRange)
+
+ try:
+ tiles = self._texture.tiles
+ except AttributeError:
+ raise RuntimeError("No texture, discard has already been called")
+ if len(tiles) > 1:
+ raise NotImplementedError(
+ "Image over multiple textures not supported with log scale")
+
+ texture, vertices, info = tiles[0]
+
+ texture.bind(self._DATA_TEX_UNIT)
+
+ posAttrib = prog.attributes['position']
+ stride = vertices.shape[-1] * vertices.itemsize
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, vertices)
+
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices))
+
+ def render(self, matrix, isXLog, isYLog):
+ if any((isXLog, isYLog)):
+ self._renderLog(matrix, isXLog, isYLog)
+ else:
+ self._renderLinear(matrix)
diff --git a/silx/gui/plot/backends/glutils/GLSupport.py b/silx/gui/plot/backends/glutils/GLSupport.py
new file mode 100644
index 0000000..3f473be
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLSupport.py
@@ -0,0 +1,192 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 convenient classes and functions for OpenGL rendering.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import numpy
+
+from ...._glutils import gl
+
+
+def buildFillMaskIndices(nIndices):
+ if nIndices <= numpy.iinfo(numpy.uint16).max + 1:
+ dtype = numpy.uint16
+ else:
+ dtype = numpy.uint32
+
+ lastIndex = nIndices - 1
+ splitIndex = lastIndex // 2 + 1
+ indices = numpy.empty(nIndices, dtype=dtype)
+ indices[::2] = numpy.arange(0, splitIndex, step=1, dtype=dtype)
+ indices[1::2] = numpy.arange(lastIndex, splitIndex - 1, step=-1,
+ dtype=dtype)
+ return indices
+
+
+class Shape2D(object):
+ _NO_HATCH = 0
+ _HATCH_STEP = 20
+
+ def __init__(self, points, fill='solid', stroke=True,
+ fillColor=(0., 0., 0., 1.), strokeColor=(0., 0., 0., 1.),
+ strokeClosed=True):
+ self.vertices = numpy.array(points, dtype=numpy.float32, copy=False)
+ self.strokeClosed = strokeClosed
+
+ self._indices = buildFillMaskIndices(len(self.vertices))
+
+ tVertex = numpy.transpose(self.vertices)
+ xMin, xMax = min(tVertex[0]), max(tVertex[0])
+ yMin, yMax = min(tVertex[1]), max(tVertex[1])
+ self.bboxVertices = numpy.array(((xMin, yMin), (xMin, yMax),
+ (xMax, yMin), (xMax, yMax)),
+ dtype=numpy.float32)
+ self._xMin, self._xMax = xMin, xMax
+ self._yMin, self._yMax = yMin, yMax
+
+ self.fill = fill
+ self.fillColor = fillColor
+ self.stroke = stroke
+ self.strokeColor = strokeColor
+
+ @property
+ def xMin(self):
+ return self._xMin
+
+ @property
+ def xMax(self):
+ return self._xMax
+
+ @property
+ def yMin(self):
+ return self._yMin
+
+ @property
+ def yMax(self):
+ return self._yMax
+
+ def prepareFillMask(self, posAttrib):
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, self.vertices)
+
+ gl.glEnable(gl.GL_STENCIL_TEST)
+ gl.glStencilMask(1)
+ gl.glStencilFunc(gl.GL_ALWAYS, 1, 1)
+ gl.glStencilOp(gl.GL_INVERT, gl.GL_INVERT, gl.GL_INVERT)
+ gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE)
+ gl.glDepthMask(gl.GL_FALSE)
+
+ gl.glDrawElements(gl.GL_TRIANGLE_STRIP, len(self._indices),
+ gl.GL_UNSIGNED_SHORT, self._indices)
+
+ gl.glStencilFunc(gl.GL_EQUAL, 1, 1)
+ # Reset stencil while drawing
+ gl.glStencilOp(gl.GL_ZERO, gl.GL_ZERO, gl.GL_ZERO)
+ gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE)
+ gl.glDepthMask(gl.GL_TRUE)
+
+ def renderFill(self, posAttrib):
+ self.prepareFillMask(posAttrib)
+
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, self.bboxVertices)
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self.bboxVertices))
+
+ gl.glDisable(gl.GL_STENCIL_TEST)
+
+ def renderStroke(self, posAttrib):
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, self.vertices)
+ gl.glLineWidth(1)
+ drawMode = gl.GL_LINE_LOOP if self.strokeClosed else gl.GL_LINE_STRIP
+ gl.glDrawArrays(drawMode, 0, len(self.vertices))
+
+ def render(self, posAttrib, colorUnif, hatchStepUnif):
+ assert self.fill in ['hatch', 'solid', None]
+ if self.fill is not None:
+ gl.glUniform4f(colorUnif, *self.fillColor)
+ step = self._HATCH_STEP if self.fill == 'hatch' else self._NO_HATCH
+ gl.glUniform1i(hatchStepUnif, step)
+ self.renderFill(posAttrib)
+
+ if self.stroke:
+ gl.glUniform4f(colorUnif, *self.strokeColor)
+ gl.glUniform1i(hatchStepUnif, self._NO_HATCH)
+ self.renderStroke(posAttrib)
+
+
+# matrix ######################################################################
+
+def mat4Ortho(left, right, bottom, top, near, far):
+ """Orthographic projection matrix (row-major)"""
+ return numpy.matrix((
+ (2./(right - left), 0., 0., -(right+left)/float(right-left)),
+ (0., 2./(top - bottom), 0., -(top+bottom)/float(top-bottom)),
+ (0., 0., -2./(far-near), -(far+near)/float(far-near)),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Translate(x=0., y=0., z=0.):
+ """Translation matrix (row-major)"""
+ return numpy.matrix((
+ (1., 0., 0., x),
+ (0., 1., 0., y),
+ (0., 0., 1., z),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Scale(sx=1., sy=1., sz=1.):
+ """Scale matrix (row-major)"""
+ return numpy.matrix((
+ (sx, 0., 0., 0.),
+ (0., sy, 0., 0.),
+ (0., 0., sz, 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Identity():
+ """Identity matrix"""
+ return numpy.matrix((
+ (1., 0., 0., 0.),
+ (0., 1., 0., 0.),
+ (0., 0., 1., 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py
new file mode 100644
index 0000000..495882c
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLText.py
@@ -0,0 +1,222 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 minimalistic text support for OpenGL.
+It provides Latin-1 (ISO8859-1) characters for one monospace font at one size.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import numpy
+
+from ...._glutils import font, gl, getGLContext, Program, Texture
+from .GLSupport import mat4Translate
+
+
+# TODO: Font should be configurable by the main program: using mpl.rcParams?
+
+
+# Text2D ######################################################################
+
+LEFT, CENTER, RIGHT = 'left', 'center', 'right'
+TOP, BASELINE, BOTTOM = 'top', 'baseline', 'bottom'
+ROTATE_90, ROTATE_180, ROTATE_270 = 90, 180, 270
+
+
+class Text2D(object):
+
+ _SHADERS = {
+ 'vertex': """
+ #version 120
+
+ attribute vec2 position;
+ attribute vec2 texCoords;
+ uniform mat4 matrix;
+
+ varying vec2 vCoords;
+
+ void main(void) {
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ vCoords = texCoords;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D texText;
+ uniform vec4 color;
+ uniform vec4 bgColor;
+
+ varying vec2 vCoords;
+
+ void main(void) {
+ gl_FragColor = mix(bgColor, color, texture2D(texText, vCoords).r);
+ }
+ """
+ }
+
+ _TEX_COORDS = numpy.array(((0., 0.), (1., 0.), (0., 1.), (1., 1.)),
+ dtype=numpy.float32).ravel()
+
+ _program = Program(_SHADERS['vertex'],
+ _SHADERS['fragment'],
+ attrib0='position')
+
+ _textures = {}
+
+ _rasterTextCache = {}
+ """Internal cache storing already rasterized text"""
+ # TODO limit cache size and discard least recent used
+
+ def __init__(self, text, x=0, y=0,
+ color=(0., 0., 0., 1.),
+ bgColor=None,
+ align=LEFT, valign=BASELINE,
+ rotate=0):
+ self._vertices = None
+ self._text = text
+ self.x = x
+ self.y = y
+ self.color = color
+ self.bgColor = bgColor
+
+ if align not in (LEFT, CENTER, RIGHT):
+ raise ValueError(
+ "Horizontal alignment not supported: {0}".format(align))
+ self._align = align
+
+ if valign not in (TOP, CENTER, BASELINE, BOTTOM):
+ raise ValueError(
+ "Vertical alignment not supported: {0}".format(valign))
+ self._valign = valign
+
+ self._rotate = numpy.radians(rotate)
+
+ @classmethod
+ def _getTexture(cls, text):
+ key = getGLContext(), text
+ if key not in cls._textures:
+ image, offset = font.rasterText(text,
+ font.getDefaultFontFamily())
+ cls._textures[key] = (Texture(gl.GL_RED,
+ data=image,
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=(gl.GL_CLAMP_TO_EDGE,
+ gl.GL_CLAMP_TO_EDGE)),
+ offset)
+
+ return cls._textures[key]
+
+ @property
+ def text(self):
+ return self._text
+
+ @property
+ def size(self): # TODO very poor implementation
+ image, offset = font.rasterText(self.text,
+ font.getDefaultFontFamily())
+ return image.shape[1], image.shape[0]
+
+ def getVertices(self, offset, shape):
+ height, width = shape
+
+ if self._align == LEFT:
+ xOrig = 0
+ elif self._align == RIGHT:
+ xOrig = - width
+ else: # CENTER
+ xOrig = - width // 2
+
+ if self._valign == BASELINE:
+ yOrig = - offset
+ elif self._valign == TOP:
+ yOrig = 0
+ elif self._valign == BOTTOM:
+ yOrig = - height
+ else: # CENTER
+ yOrig = - height // 2
+
+ vertices = numpy.array((
+ (xOrig, yOrig),
+ (xOrig + width, yOrig),
+ (xOrig, yOrig + height),
+ (xOrig + width, yOrig + height)), dtype=numpy.float32)
+
+ cos, sin = numpy.cos(self._rotate), numpy.sin(self._rotate)
+ vertices = numpy.ascontiguousarray(numpy.transpose(numpy.array((
+ cos * vertices[:, 0] - sin * vertices[:, 1],
+ sin * vertices[:, 0] + cos * vertices[:, 1]),
+ dtype=numpy.float32)))
+
+ return vertices
+
+ def render(self, matrix):
+ if not self.text:
+ return
+
+ prog = self._program
+ prog.use()
+
+ texUnit = 0
+ texture, offset = self._getTexture(self.text)
+
+ gl.glUniform1i(prog.uniforms['texText'], texUnit)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE,
+ matrix * mat4Translate(int(self.x), int(self.y)))
+
+ gl.glUniform4f(prog.uniforms['color'], *self.color)
+ if self.bgColor is not None:
+ bgColor = self.bgColor
+ else:
+ bgColor = self.color[0], self.color[1], self.color[2], 0.
+ gl.glUniform4f(prog.uniforms['bgColor'], *bgColor)
+
+ vertices = self.getVertices(offset, texture.shape)
+
+ posAttrib = prog.attributes['position']
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0,
+ vertices)
+
+ texAttrib = prog.attributes['texCoords']
+ gl.glEnableVertexAttribArray(texAttrib)
+ gl.glVertexAttribPointer(texAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0,
+ self._TEX_COORDS)
+
+ with texture:
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4)
diff --git a/silx/gui/plot/backends/glutils/GLTexture.py b/silx/gui/plot/backends/glutils/GLTexture.py
new file mode 100644
index 0000000..25dd9f1
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLTexture.py
@@ -0,0 +1,239 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 classes wrapping OpenGL texture."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+from ctypes import c_void_p
+import logging
+
+import numpy
+
+from ...._glutils import gl, Texture, numpyToGLType
+
+
+_logger = logging.getLogger(__name__)
+
+
+def _checkTexture2D(internalFormat, shape,
+ format_=None, type_=gl.GL_FLOAT, border=0):
+ """Check if texture size with provided parameters is supported
+
+ :rtype: bool
+ """
+ height, width = shape
+ gl.glTexImage2D(gl.GL_PROXY_TEXTURE_2D, 0, internalFormat,
+ width, height, border,
+ format_ or internalFormat,
+ type_, c_void_p(0))
+ width = gl.glGetTexLevelParameteriv(
+ gl.GL_PROXY_TEXTURE_2D, 0, gl.GL_TEXTURE_WIDTH)
+ return bool(width)
+
+
+MIN_TEXTURE_SIZE = 64
+
+
+def _getMaxSquareTexture2DSize(internalFormat=gl.GL_RGBA,
+ format_=None,
+ type_=gl.GL_FLOAT,
+ border=0):
+ """Returns a supported size for a corresponding square texture
+
+ :returns: GL_MAX_TEXTURE_SIZE or a smaller supported size (not optimal)
+ :rtype: int
+ """
+ # Is this useful?
+ maxTexSize = gl.glGetIntegerv(gl.GL_MAX_TEXTURE_SIZE)
+ while maxTexSize > MIN_TEXTURE_SIZE and \
+ not _checkTexture2D(internalFormat, (maxTexSize, maxTexSize),
+ format_, type_, border):
+ maxTexSize //= 2
+ return max(MIN_TEXTURE_SIZE, maxTexSize)
+
+
+class Image(object):
+ """Image of any size eventually using multiple textures or larger texture
+ """
+
+ _WRAP = (gl.GL_CLAMP_TO_EDGE, gl.GL_CLAMP_TO_EDGE)
+ _MIN_FILTER = gl.GL_NEAREST
+ _MAG_FILTER = gl.GL_NEAREST
+
+ def __init__(self, internalFormat, data, format_=None, texUnit=0):
+ self.internalFormat = internalFormat
+ self.height, self.width = data.shape[0:2]
+ type_ = numpyToGLType(data.dtype)
+
+ if _checkTexture2D(internalFormat, data.shape[0:2], format_, type_):
+ texture = Texture(internalFormat,
+ data,
+ format_,
+ texUnit=texUnit,
+ minFilter=self._MIN_FILTER,
+ magFilter=self._MAG_FILTER,
+ wrap=self._WRAP)
+ vertices = numpy.array((
+ (0., 0., 0., 0.),
+ (self.width, 0., 1., 0.),
+ (0., self.height, 0., 1.),
+ (self.width, self.height, 1., 1.)), dtype=numpy.float32)
+ self.tiles = ((texture, vertices,
+ {'xOrigData': 0, 'yOrigData': 0,
+ 'wData': self.width, 'hData': self.height}),)
+
+ else:
+ # Handle dimension too large: make tiles
+ maxTexSize = _getMaxSquareTexture2DSize(internalFormat,
+ format_, type_)
+
+ nCols = (self.width+maxTexSize-1) // maxTexSize
+ colWidths = [self.width // nCols] * nCols
+ colWidths[-1] += self.width % nCols
+
+ nRows = (self.height+maxTexSize-1) // maxTexSize
+ rowHeights = [self.height//nRows] * nRows
+ rowHeights[-1] += self.height % nRows
+
+ tiles = []
+ yOrig = 0
+ for hData in rowHeights:
+ xOrig = 0
+ for wData in colWidths:
+ if (hData < MIN_TEXTURE_SIZE or wData < MIN_TEXTURE_SIZE) \
+ and not _checkTexture2D(internalFormat,
+ (hData, wData),
+ format_,
+ type_):
+ # Ensure texture size is at least MIN_TEXTURE_SIZE
+ tH = max(hData, MIN_TEXTURE_SIZE)
+ tW = max(wData, MIN_TEXTURE_SIZE)
+
+ uMax, vMax = float(wData)/tW, float(hData)/tH
+
+ # TODO issue with type_ and alignment
+ texture = Texture(internalFormat,
+ data=None,
+ format_=format_,
+ shape=(tH, tW),
+ texUnit=texUnit,
+ minFilter=self._MIN_FILTER,
+ magFilter=self._MAG_FILTER,
+ wrap=self._WRAP)
+ # TODO handle unpack
+ texture.update(format_,
+ data[yOrig:yOrig+hData,
+ xOrig:xOrig+wData])
+ # texture.update(format_, type_, data,
+ # width=wData, height=hData,
+ # unpackRowLength=width,
+ # unpackSkipPixels=xOrig,
+ # unpackSkipRows=yOrig)
+ else:
+ uMax, vMax = 1, 1
+ # TODO issue with type_ and unpacking tiles
+ # TODO idea to handle unpack: use array strides
+ # As it is now, it will make a copy
+ texture = Texture(internalFormat,
+ data[yOrig:yOrig+hData,
+ xOrig:xOrig+wData],
+ format_,
+ shape=(hData, wData),
+ texUnit=texUnit,
+ minFilter=self._MIN_FILTER,
+ magFilter=self._MAG_FILTER,
+ wrap=self._WRAP)
+ # TODO
+ # unpackRowLength=width,
+ # unpackSkipPixels=xOrig,
+ # unpackSkipRows=yOrig)
+ vertices = numpy.array((
+ (xOrig, yOrig, 0., 0.),
+ (xOrig + wData, yOrig, uMax, 0.),
+ (xOrig, yOrig + hData, 0., vMax),
+ (xOrig + wData, yOrig + hData, uMax, vMax)),
+ dtype=numpy.float32)
+ tiles.append((texture, vertices,
+ {'xOrigData': xOrig, 'yOrigData': yOrig,
+ 'wData': wData, 'hData': hData}))
+ xOrig += wData
+ yOrig += hData
+ self.tiles = tuple(tiles)
+
+ def discard(self):
+ for texture, vertices, _ in self.tiles:
+ texture.discard()
+ del self.tiles
+
+ def updateAll(self, format_, data, texUnit=0):
+ if not hasattr(self, 'tiles'):
+ raise RuntimeError("No texture, discard has already been called")
+
+ assert data.shape[:2] == (self.height, self.width)
+ if len(self.tiles) == 1:
+ self.tiles[0][0].update(format_, data, texUnit=texUnit)
+ else:
+ for texture, _, info in self.tiles:
+ yOrig, xOrig = info['yOrigData'], info['xOrigData']
+ height, width = info['hData'], info['wData']
+ texture.update(format_,
+ data[yOrig:yOrig+height, xOrig:xOrig+width],
+ texUnit=texUnit)
+ # TODO check
+ # width=info['wData'], height=info['hData'],
+ # texUnit=texUnit, unpackAlign=unpackAlign,
+ # unpackRowLength=self.width,
+ # unpackSkipPixels=info['xOrigData'],
+ # unpackSkipRows=info['yOrigData'])
+
+ def render(self, posAttrib, texAttrib, texUnit=0):
+ try:
+ tiles = self.tiles
+ except AttributeError:
+ raise RuntimeError("No texture, discard has already been called")
+
+ for texture, vertices, _ in tiles:
+ texture.bind(texUnit)
+
+ stride = vertices.shape[-1] * vertices.itemsize
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, vertices)
+
+ texCoordsPtr = c_void_p(vertices.ctypes.data +
+ 2 * vertices.itemsize)
+ gl.glEnableVertexAttribArray(texAttrib)
+ gl.glVertexAttribPointer(texAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, texCoordsPtr)
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices))
diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py
new file mode 100644
index 0000000..e4ebe24
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/PlotImageFile.py
@@ -0,0 +1,149 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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.
+#
+# ############################################################################*/
+"""Function to save an image to a file."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import base64
+import struct
+import sys
+import zlib
+
+
+# Image writer ################################################################
+
+def convertRGBDataToPNG(data):
+ """Convert a RGB bitmap to PNG.
+
+ It only supports RGB bitmap with one byte per channel stored as a 3D array.
+ See `Definitive Guide <http://www.libpng.org/pub/png/book/>`_ and
+ `Specification <http://www.libpng.org/pub/png/spec/1.2/>`_ for details.
+
+ :param data: A 3D array (h, w, rgb) storing an RGB image
+ :type data: numpy.ndarray of unsigned bytes
+ :returns: The PNG encoded data
+ :rtype: bytes
+ """
+ height, width = data.shape[0], data.shape[1]
+ depth = 8 # 8 bit per channel
+ colorType = 2 # 'truecolor' = RGB
+ interlace = 0 # No
+
+ IHDRdata = struct.pack(">ccccIIBBBBB", b'I', b'H', b'D', b'R',
+ width, height, depth, colorType,
+ 0, 0, interlace)
+
+ # Add filter 'None' before each scanline
+ preparedData = b'\x00' + b'\x00'.join(line.tostring() for line in data)
+ compressedData = zlib.compress(preparedData, 8)
+
+ IDATdata = struct.pack("cccc", b'I', b'D', b'A', b'T')
+ IDATdata += compressedData
+
+ return b''.join([
+ b'\x89PNG\r\n\x1a\n', # PNG signature
+ # IHDR chunk: Image Header
+ struct.pack(">I", 13), # length
+ IHDRdata,
+ struct.pack(">I", zlib.crc32(IHDRdata) & 0xffffffff), # CRC
+ # IDAT chunk: Payload
+ struct.pack(">I", len(compressedData)),
+ IDATdata,
+ struct.pack(">I", zlib.crc32(IDATdata) & 0xffffffff), # CRC
+ b'\x00\x00\x00\x00IEND\xaeB`\x82' # IEND chunk: footer
+ ])
+
+
+def saveImageToFile(data, fileNameOrObj, fileFormat):
+ """Save a RGB image to a file.
+
+ :param data: A 3D array (h, w, 3) storing an RGB image.
+ :type data: numpy.ndarray with of unsigned bytes.
+ :param fileNameOrObj: Filename or object to use to write the image.
+ :type fileNameOrObj: A str or a 'file-like' object with a 'write' method.
+ :param str fileFormat: The type of the file in: 'png', 'ppm', 'svg', 'tiff'.
+ """
+ assert len(data.shape) == 3
+ assert data.shape[2] == 3
+ assert fileFormat in ('png', 'ppm', 'svg', 'tiff')
+
+ if not hasattr(fileNameOrObj, 'write'):
+ if sys.version < "3.0":
+ fileObj = open(fileNameOrObj, "wb")
+ else:
+ fileObj = open(fileNameOrObj, "w", newline='')
+ else: # Use as a file-like object
+ fileObj = fileNameOrObj
+
+ if fileFormat == 'svg':
+ height, width = data.shape[:2]
+ base64Data = base64.b64encode(convertRGBDataToPNG(data))
+
+ fileObj.write(
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n')
+ fileObj.write('<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"\n')
+ fileObj.write(
+ ' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n')
+ fileObj.write('<svg xmlns:xlink="http://www.w3.org/1999/xlink"\n')
+ fileObj.write(' xmlns="http://www.w3.org/2000/svg"\n')
+ fileObj.write(' version="1.1"\n')
+ fileObj.write(' width="%d"\n' % width)
+ fileObj.write(' height="%d">\n' % height)
+ fileObj.write(' <image xlink:href="data:image/png;base64,')
+ fileObj.write(base64Data.decode('ascii'))
+ fileObj.write('"\n')
+ fileObj.write(' x="0"\n')
+ fileObj.write(' y="0"\n')
+ fileObj.write(' width="%d"\n' % width)
+ fileObj.write(' height="%d"\n' % height)
+ fileObj.write(' id="image" />\n')
+ fileObj.write('</svg>')
+
+ elif fileFormat == 'ppm':
+ height, width = data.shape[:2]
+
+ fileObj.write('P6\n')
+ fileObj.write('%d %d\n' % (width, height))
+ fileObj.write('255\n')
+ fileObj.write(data.tostring())
+
+ elif fileFormat == 'png':
+ fileObj.write(convertRGBDataToPNG(data))
+
+ elif fileFormat == 'tiff':
+ if fileObj == fileNameOrObj:
+ raise NotImplementedError(
+ 'Save TIFF to a file-like object not implemented')
+
+ from silx.third_party.TiffIO import TiffIO
+
+ tif = TiffIO(fileNameOrObj, mode='wb+')
+ tif.writeImage(data, info={'Title': 'PyMCA GL Snapshot'})
+
+ if fileObj != fileNameOrObj:
+ fileObj.close()
diff --git a/silx/gui/plot/backends/glutils/__init__.py b/silx/gui/plot/backends/glutils/__init__.py
new file mode 100644
index 0000000..771de39
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/__init__.py
@@ -0,0 +1,44 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 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 convenient classes for the OpenGL rendering backend.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import logging
+
+
+_logger = logging.getLogger(__name__)
+
+
+from .GLPlotCurve import * # noqa
+from .GLPlotFrame import * # noqa
+from .GLPlotImage import * # noqa
+from .GLSupport import * # noqa
+from .GLText import * # noqa
+from .GLTexture import * # noqa
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py
new file mode 100644
index 0000000..b16fe40
--- /dev/null
+++ b/silx/gui/plot/items/__init__.py
@@ -0,0 +1,43 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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 classes that describes :class:`.Plot` content.
+
+Instances of those classes are returned by :class:`.Plot` methods that give
+access to its content such as :meth:`.Plot.getCurve`, :meth:`.Plot.getImage`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/03/2017"
+
+from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa
+ SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa
+ AlphaMixIn, LineMixIn) # noqa
+from .curve import Curve # noqa
+from .histogram import Histogram # noqa
+from .image import ImageBase, ImageData, ImageRgba # noqa
+from .shape import Shape # noqa
+from .scatter import Scatter # noqa
+from .marker import Marker, XMarker, YMarker # noqa
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
new file mode 100644
index 0000000..72bfd9a
--- /dev/null
+++ b/silx/gui/plot/items/core.py
@@ -0,0 +1,839 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the base class for items of the :class:`Plot`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/04/2017"
+
+from copy import deepcopy
+import logging
+import weakref
+import numpy
+from silx.third_party import six
+
+from .. import Colors
+
+
+
+_logger = logging.getLogger(__name__)
+
+
+class Item(object):
+ """Description of an item of the plot"""
+
+ _DEFAULT_Z_LAYER = 0
+ """Default layer for overlay rendering"""
+
+ _DEFAULT_LEGEND = ''
+ """Default legend of items"""
+
+ _DEFAULT_SELECTABLE = False
+ """Default selectable state of items"""
+
+ def __init__(self):
+ self._dirty = True
+ self._plotRef = None
+ self._visible = True
+ self._legend = self._DEFAULT_LEGEND
+ self._selectable = self._DEFAULT_SELECTABLE
+ self._z = self._DEFAULT_Z_LAYER
+ self._info = None
+ self._xlabel = None
+ self._ylabel = None
+
+ self._backendRenderer = None
+
+ def getPlot(self):
+ """Returns Plot this item belongs to.
+
+ :rtype: Plot or None
+ """
+ return None if self._plotRef is None else self._plotRef()
+
+ def _setPlot(self, plot):
+ """Set the plot this item belongs to.
+
+ WARNING: This should only be called from the Plot.
+
+ :param Plot plot: The Plot instance.
+ """
+ if plot is not None and self._plotRef is not None:
+ raise RuntimeError('Trying to add a node at two places.')
+ self._plotRef = None if plot is None else weakref.ref(plot)
+ self._updated()
+
+ def getBounds(self): # TODO return a Bounds object rather than a tuple
+ """Returns the bounding box of this item in data coordinates
+
+ :returns: (xmin, xmax, ymin, ymax) or None
+ :rtype: 4-tuple of float or None
+ """
+ return self._getBounds()
+
+ def _getBounds(self):
+ """:meth:`getBounds` implementation to override by sub-class"""
+ return None
+
+ def isVisible(self):
+ """True if item is visible, False otherwise
+
+ :rtype: bool
+ """
+ return self._visible
+
+ def setVisible(self, visible):
+ """Set visibility of item.
+
+ :param bool visible: True to display it, False otherwise
+ """
+ visible = bool(visible)
+ if visible != self._visible:
+ self._visible = visible
+ # When visibility has changed, always mark as dirty
+ self._updated(checkVisibility=False)
+
+ def isOverlay(self):
+ """Return true if item is drawn as an overlay.
+
+ :rtype: bool
+ """
+ return False
+
+ def getLegend(self):
+ """Returns the legend of this item (str)"""
+ return self._legend
+
+ def _setLegend(self, legend):
+ """Set the legend.
+
+ This is private as it is used by the plot as an identifier
+
+ :param str legend: Item legend
+ """
+ legend = str(legend) if legend is not None else self._DEFAULT_LEGEND
+ self._legend = legend
+
+ def isSelectable(self):
+ """Returns true if item is selectable (bool)"""
+ return self._selectable
+
+ def _setSelectable(self, selectable): # TODO support update
+ """Set whether item is selectable or not.
+
+ This is private for now as change is not handled.
+
+ :param bool selectable: True to make item selectable
+ """
+ self._selectable = bool(selectable)
+
+ def getZValue(self):
+ """Returns the layer on which to draw this item (int)"""
+ return self._z
+
+ def setZValue(self, z):
+ z = int(z) if z is not None else self._DEFAULT_Z_LAYER
+ if z != self._z:
+ self._z = z
+ self._updated()
+
+ def getInfo(self, copy=True):
+ """Returns the info associated to this item
+
+ :param bool copy: True to get a deepcopy, False otherwise.
+ """
+ return deepcopy(self._info) if copy else self._info
+
+ def setInfo(self, info, copy=True):
+ if copy:
+ info = deepcopy(info)
+ self._info = info
+
+ def _updated(self, checkVisibility=True):
+ """Mark the item as dirty (i.e., needing update).
+
+ This also triggers Plot.replot.
+
+ :param bool checkVisibility: True to only mark as dirty if visible,
+ False to always mark as dirty.
+ """
+ if not checkVisibility or self.isVisible():
+ if not self._dirty:
+ self._dirty = True
+ # TODO: send event instead of explicit call
+ plot = self.getPlot()
+ if plot is not None:
+ plot._itemRequiresUpdate(self)
+
+ def _update(self, backend):
+ """Called by Plot to update the backend for this item.
+
+ This is meant to be called asynchronously from _updated.
+ This optimizes the number of call to _update.
+
+ :param backend: The backend to update
+ """
+ if self._dirty:
+ # Remove previous renderer from backend if any
+ self._removeBackendRenderer(backend)
+
+ # If not visible, do not add renderer to backend
+ if self.isVisible():
+ self._backendRenderer = self._addBackendRenderer(backend)
+
+ self._dirty = False
+
+ def _addBackendRenderer(self, backend):
+ """Override in subclass to add specific backend renderer.
+
+ :param BackendBase backend: The backend to update
+ :return: The renderer handle to store or None if no renderer in backend
+ """
+ return None
+
+ def _removeBackendRenderer(self, backend):
+ """Override in subclass to remove specific backend renderer.
+
+ :param BackendBase backend: The backend to update
+ """
+ if self._backendRenderer is not None:
+ backend.remove(self._backendRenderer)
+ self._backendRenderer = None
+
+
+# Mix-in classes ##############################################################
+
+class LabelsMixIn(object):
+ """Mix-in class for items with x and y labels
+
+ Setters are private, otherwise it needs to check the plot
+ current active curve and access the internal current labels.
+ """
+
+ def __init__(self):
+ self._xlabel = None
+ self._ylabel = None
+
+ def getXLabel(self):
+ """Return the X axis label associated to this curve
+
+ :rtype: str or None
+ """
+ return self._xlabel
+
+ def _setXLabel(self, label):
+ """Set the X axis label associated with this curve
+
+ :param str label: The X axis label
+ """
+ self._xlabel = str(label)
+
+ def getYLabel(self):
+ """Return the Y axis label associated to this curve
+
+ :rtype: str or None
+ """
+ return self._ylabel
+
+ def _setYLabel(self, label):
+ """Set the Y axis label associated with this curve
+
+ :param str label: The Y axis label
+ """
+ self._ylabel = str(label)
+
+
+class DraggableMixIn(object):
+ """Mix-in class for draggable items"""
+
+ def __init__(self):
+ self._draggable = False
+
+ def isDraggable(self):
+ """Returns true if image is draggable
+
+ :rtype: bool
+ """
+ return self._draggable
+
+ def _setDraggable(self, draggable): # TODO support update
+ """Set if image is draggable or not.
+
+ This is private for not as it does not support update.
+
+ :param bool draggable:
+ """
+ self._draggable = bool(draggable)
+
+
+class ColormapMixIn(object):
+ """Mix-in class for items with colormap"""
+
+ _DEFAULT_COLORMAP = {'name': 'gray', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ """Default colormap of the item"""
+
+ def __init__(self):
+ self._colormap = self._DEFAULT_COLORMAP
+
+ def getColormap(self):
+ """Return the used colormap"""
+ return self._colormap.copy()
+
+ def setColormap(self, colormap):
+ """Set the colormap of this image
+
+ :param dict colormap: colormap description
+ """
+ self._colormap = colormap.copy()
+ # TODO colormap comparison + colormap object and events on modification
+ self._updated()
+
+
+class SymbolMixIn(object):
+ """Mix-in class for items with symbol type"""
+
+ _DEFAULT_SYMBOL = ''
+ """Default marker of the item"""
+
+ _DEFAULT_SYMBOL_SIZE = 6.0
+ """Default marker size of the item"""
+
+ def __init__(self):
+ self._symbol = self._DEFAULT_SYMBOL
+ self._symbol_size = self._DEFAULT_SYMBOL_SIZE
+
+ def getSymbol(self):
+ """Return the point marker type.
+
+ Marker type::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :rtype: str
+ """
+ return self._symbol
+
+ def setSymbol(self, symbol):
+ """Set the marker type
+
+ See :meth:`getSymbol`.
+
+ :param str symbol: Marker type
+ """
+ assert symbol in ('o', '.', ',', '+', 'x', 'd', 's', '', None)
+ if symbol is None:
+ symbol = self._DEFAULT_SYMBOL
+ if symbol != self._symbol:
+ self._symbol = symbol
+ self._updated()
+
+ def getSymbolSize(self):
+ """Return the point marker size in points.
+
+ :rtype: float
+ """
+ return self._symbol_size
+
+ def setSymbolSize(self, size):
+ """Set the point marker size in points.
+
+ See :meth:`getSymbolSize`.
+
+ :param str symbol: Marker type
+ """
+ if size is None:
+ size = self._DEFAULT_SYMBOL_SIZE
+ if size != self._symbol_size:
+ self._symbol_size = size
+ self._updated()
+
+
+class LineMixIn(object):
+ """Mix-in class for item with line"""
+
+ _DEFAULT_LINEWIDTH = 1.
+ """Default line width"""
+
+ _DEFAULT_LINESTYLE = '-'
+ """Default line style"""
+
+ def __init__(self):
+ self._linewidth = self._DEFAULT_LINEWIDTH
+ self._linestyle = self._DEFAULT_LINESTYLE
+
+ def getLineWidth(self):
+ """Return the curve line width in pixels (int)"""
+ return self._linewidth
+
+ def setLineWidth(self, width):
+ """Set the width in pixel of the curve line
+
+ See :meth:`getLineWidth`.
+
+ :param float width: Width in pixels
+ """
+ width = float(width)
+ if width != self._linewidth:
+ self._linewidth = width
+ self._updated()
+
+ def getLineStyle(self):
+ """Return the type of the line
+
+ Type of line::
+
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+
+ :rtype: str
+ """
+ return self._linestyle
+
+ def setLineStyle(self, style):
+ """Set the style of the curve line.
+
+ See :meth:`getLineStyle`.
+
+ :param str style: Line style
+ """
+ style = str(style)
+ assert style in ('', ' ', '-', '--', '-.', ':', None)
+ if style is None:
+ style = self._DEFAULT_LINESTYLE
+ if style != self._linestyle:
+ self._linestyle = style
+ self._updated()
+
+
+class ColorMixIn(object):
+ """Mix-in class for item with color"""
+
+ _DEFAULT_COLOR = (0., 0., 0., 1.)
+ """Default color of the item"""
+
+ def __init__(self):
+ self._color = self._DEFAULT_COLOR
+
+ def getColor(self):
+ """Returns the RGBA color of the item
+
+ :rtype: 4-tuple of float in [0, 1]
+ """
+ return self._color
+
+ def setColor(self, color, copy=True):
+ """Set item color
+
+ :param color: color(s) to be used
+ :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ """
+ if isinstance(color, six.string_types):
+ color = Colors.rgba(color)
+ else:
+ color = numpy.array(color, copy=copy)
+ # TODO more checks + improve color array support
+ if color.ndim == 1: # Single RGBA color
+ color = Colors.rgba(color)
+ else: # Array of colors
+ assert color.ndim == 2
+
+ self._color = color
+ self._updated()
+
+
+class YAxisMixIn(object):
+ """Mix-in class for item with yaxis"""
+
+ _DEFAULT_YAXIS = 'left'
+ """Default Y axis the item belongs to"""
+
+ def __init__(self):
+ self._yaxis = self._DEFAULT_YAXIS
+
+ def getYAxis(self):
+ """Returns the Y axis this curve belongs to.
+
+ Either 'left' or 'right'.
+
+ :rtype: str
+ """
+ return self._yaxis
+
+ def setYAxis(self, yaxis):
+ """Set the Y axis this curve belongs to.
+
+ :param str yaxis: 'left' or 'right'
+ """
+ yaxis = str(yaxis)
+ assert yaxis in ('left', 'right')
+ if yaxis != self._yaxis:
+ self._yaxis = yaxis
+ self._updated()
+
+
+class FillMixIn(object):
+ """Mix-in class for item with fill"""
+
+ def __init__(self):
+ self._fill = False
+
+ def isFill(self):
+ """Returns whether the item is filled or not.
+
+ :rtype: bool
+ """
+ return self._fill
+
+ def setFill(self, fill):
+ """Set whether to fill the item or not.
+
+ :param bool fill:
+ """
+ fill = bool(fill)
+ if fill != self._fill:
+ self._fill = fill
+ self._updated()
+
+
+class AlphaMixIn(object):
+ """Mix-in class for item with opacity"""
+
+ def __init__(self):
+ self._alpha = 1.
+
+ def getAlpha(self):
+ """Returns the opacity of the item
+
+ :rtype: float in [0, 1.]
+ """
+ return self._alpha
+
+ def setAlpha(self, alpha):
+ """Set the opacity of the item
+
+ .. note::
+
+ If the colormap already has some transparency, this alpha
+ adds additional transparency. The alpha channel of the colormap
+ is multiplied by this value.
+
+ :param alpha: Opacity of the item, between 0 (full transparency)
+ and 1. (full opacity)
+ :type alpha: float
+ """
+ alpha = float(alpha)
+ alpha = max(0., min(alpha, 1.)) # Clip alpha to [0., 1.] range
+ if alpha != self._alpha:
+ self._alpha = alpha
+ self._updated()
+
+
+class Points(Item, SymbolMixIn, AlphaMixIn):
+ """Base class for :class:`Curve` and :class:`Scatter`"""
+ # note: _logFilterData must be overloaded if you overload
+ # getData to change its signature
+
+ _DEFAULT_Z_LAYER = 1
+ """Default overlay layer for points,
+ on top of images."""
+
+ def __init__(self):
+ Item.__init__(self)
+ SymbolMixIn.__init__(self)
+ AlphaMixIn.__init__(self)
+ self._x = ()
+ self._y = ()
+ self._xerror = None
+ self._yerror = None
+
+ # Store filtered data for x > 0 and/or y > 0
+ self._filteredCache = {}
+ self._clippedCache = {}
+
+ # Store bounds depending on axes filtering >0:
+ # key is (isXPositiveFilter, isYPositiveFilter)
+ self._boundsCache = {}
+
+ @staticmethod
+ def _logFilterError(value, error):
+ """Filter/convert error values if they go <= 0.
+
+ Replace error leading to negative values by nan
+
+ :param numpy.ndarray value: 1D array of values
+ :param numpy.ndarray error:
+ Array of errors: scalar, N, Nx1 or 2xN or None.
+ :return: Filtered error so error bars are never negative
+ """
+ if error is not None:
+ # Convert Nx1 to N
+ if error.ndim == 2 and error.shape[1] == 1 and len(value) != 1:
+ error = numpy.ravel(error)
+
+ # Supports error being scalar, N or 2xN array
+ errorClipped = (value - numpy.atleast_2d(error)[0]) <= 0
+
+ if numpy.any(errorClipped): # Need filtering
+
+ # expand errorbars to 2xN
+ if error.size == 1: # Scalar
+ error = numpy.full(
+ (2, len(value)), error, dtype=numpy.float)
+
+ elif error.ndim == 1: # N array
+ newError = numpy.empty((2, len(value)),
+ dtype=numpy.float)
+ newError[0, :] = error
+ newError[1, :] = error
+ error = newError
+
+ elif error.size == 2 * len(value): # 2xN array
+ error = numpy.array(
+ error, copy=True, dtype=numpy.float)
+
+ else:
+ _logger.error("Unhandled error array")
+ return error
+
+ error[0, errorClipped] = numpy.nan
+
+ return error
+
+ def _getClippingBoolArray(self, xPositive, yPositive):
+ """Compute a boolean array to filter out points with negative
+ coordinates on log axes.
+
+ :param bool xPositive: True to filter arrays according to X coords.
+ :param bool yPositive: True to filter arrays according to Y coords.
+ :rtype: boolean numpy.ndarray
+ """
+ assert xPositive or yPositive
+ if (xPositive, yPositive) not in self._clippedCache:
+ x = self.getXData(copy=False)
+ y = self.getYData(copy=False)
+ xclipped = (x <= 0) if xPositive else False
+ yclipped = (y <= 0) if yPositive else False
+ self._clippedCache[(xPositive, yPositive)] = \
+ numpy.logical_or(xclipped, yclipped)
+ return self._clippedCache[(xPositive, yPositive)]
+
+ def _logFilterData(self, xPositive, yPositive):
+ """Filter out values with x or y <= 0 on log axes
+
+ :param bool xPositive: True to filter arrays according to X coords.
+ :param bool yPositive: True to filter arrays according to Y coords.
+ :return: The filter arrays or unchanged object if filtering not needed
+ :rtype: (x, y, xerror, yerror)
+ """
+ x = self.getXData(copy=False)
+ y = self.getYData(copy=False)
+ xerror = self.getXErrorData(copy=False)
+ yerror = self.getYErrorData(copy=False)
+
+ if xPositive or yPositive:
+ clipped = self._getClippingBoolArray(xPositive, yPositive)
+
+ if numpy.any(clipped):
+ # copy to keep original array and convert to float
+ x = numpy.array(x, copy=True, dtype=numpy.float)
+ x[clipped] = numpy.nan
+ y = numpy.array(y, copy=True, dtype=numpy.float)
+ y[clipped] = numpy.nan
+
+ if xPositive and xerror is not None:
+ xerror = self._logFilterError(x, xerror)
+
+ if yPositive and yerror is not None:
+ yerror = self._logFilterError(y, yerror)
+
+ return x, y, xerror, yerror
+
+ def _getBounds(self):
+ if self.getXData(copy=False).size == 0: # Empty data
+ return None
+
+ plot = self.getPlot()
+ if plot is not None:
+ xPositive = plot.isXAxisLogarithmic()
+ yPositive = plot.isYAxisLogarithmic()
+ else:
+ xPositive = False
+ yPositive = False
+
+ # TODO bounds do not take error bars into account
+ if (xPositive, yPositive) not in self._boundsCache:
+ # use the getData class method because instance method can be
+ # overloaded to return additional arrays
+ data = Points.getData(self, copy=False,
+ displayed=True)
+ if len(data) == 5:
+ # hack to avoid duplicating caching mechanism in Scatter
+ # (happens when cached data is used, caching done using
+ # Scatter._logFilterData)
+ x, y, xerror, yerror = data[0], data[1], data[3], data[4]
+ else:
+ x, y, xerror, yerror = data
+
+ self._boundsCache[(xPositive, yPositive)] = (
+ numpy.nanmin(x),
+ numpy.nanmax(x),
+ numpy.nanmin(y),
+ numpy.nanmax(y)
+ )
+ return self._boundsCache[(xPositive, yPositive)]
+
+ def _getCachedData(self):
+ """Return cached filtered data if applicable,
+ i.e. if any axis is in log scale.
+ Return None if caching is not applicable."""
+ plot = self.getPlot()
+ if plot is not None:
+ xPositive = plot.isXAxisLogarithmic()
+ yPositive = plot.isYAxisLogarithmic()
+ if xPositive or yPositive:
+ # At least one axis has log scale, filter data
+ if (xPositive, yPositive) not in self._filteredCache:
+ self._filteredCache[(xPositive, yPositive)] = \
+ self._logFilterData(xPositive, yPositive)
+ return self._filteredCache[(xPositive, yPositive)]
+ return None
+
+ def getData(self, copy=True, displayed=False):
+ """Returns the x, y values of the curve points and xerror, yerror
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :param bool displayed: True to only get curve points that are displayed
+ in the plot. Default: False
+ Note: If plot has log scale, negative points
+ are not displayed.
+ :returns: (x, y, xerror, yerror)
+ :rtype: 4-tuple of numpy.ndarray
+ """
+ if displayed: # filter data according to plot state
+ cached_data = self._getCachedData()
+ if cached_data is not None:
+ return cached_data
+
+ return (self.getXData(copy),
+ self.getYData(copy),
+ self.getXErrorData(copy),
+ self.getYErrorData(copy))
+
+ def getXData(self, copy=True):
+ """Returns the x coordinates of the data points
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._x, copy=copy)
+
+ def getYData(self, copy=True):
+ """Returns the y coordinates of the data points
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._y, copy=copy)
+
+ def getXErrorData(self, copy=True):
+ """Returns the x error of the points
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray or None
+ """
+ if self._xerror is None:
+ return None
+ else:
+ return numpy.array(self._xerror, copy=copy)
+
+ def getYErrorData(self, copy=True):
+ """Returns the y error of the points
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray or None
+ """
+ if self._yerror is None:
+ return None
+ else:
+ return numpy.array(self._yerror, copy=copy)
+
+ def setData(self, x, y, xerror=None, yerror=None, copy=True):
+ """Set the data of the curve.
+
+ :param numpy.ndarray x: The data corresponding to the x coordinates.
+ :param numpy.ndarray y: The data corresponding to the y coordinates.
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for positive errors,
+ row 1 for negative errors.
+ :param yerror: Values with the uncertainties on the y values.
+ :type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ """
+ x = numpy.array(x, copy=copy)
+ y = numpy.array(y, copy=copy)
+ assert len(x) == len(y)
+ assert x.ndim == y.ndim == 1
+
+ if xerror is not None:
+ xerror = numpy.array(xerror, copy=copy)
+ if yerror is not None:
+ yerror = numpy.array(yerror, copy=copy)
+ # TODO checks on xerror, yerror
+ self._x, self._y = x, y
+ self._xerror, self._yerror = xerror, yerror
+
+ self._boundsCache = {} # Reset cached bounds
+ self._filteredCache = {} # Reset cached filtered data
+ self._clippedCache = {} # Reset cached clipped bool array
+
+ self._updated()
+ # TODO hackish data range implementation
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py
new file mode 100644
index 0000000..d25ae00
--- /dev/null
+++ b/silx/gui/plot/items/curve.py
@@ -0,0 +1,192 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :class:`Curve` item of the :class:`Plot`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/03/2017"
+
+
+import logging
+
+import numpy
+
+from .. import Colors
+from .core import (Points, LabelsMixIn, SymbolMixIn,
+ ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn)
+
+
+_logger = logging.getLogger(__name__)
+
+
+class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
+ """Description of a curve"""
+
+ _DEFAULT_Z_LAYER = 1
+ """Default overlay layer for curves"""
+
+ _DEFAULT_SELECTABLE = True
+ """Default selectable state for curves"""
+
+ _DEFAULT_LINEWIDTH = 1.
+ """Default line width of the curve"""
+
+ _DEFAULT_LINESTYLE = '-'
+ """Default line style of the curve"""
+
+ _DEFAULT_HIGHLIGHT_COLOR = (0, 0, 0, 255)
+ """Default highlight color of the item"""
+
+ def __init__(self):
+ Points.__init__(self)
+ ColorMixIn.__init__(self)
+ YAxisMixIn.__init__(self)
+ FillMixIn.__init__(self)
+ LabelsMixIn.__init__(self)
+ LineMixIn.__init__(self)
+
+ self._highlightColor = self._DEFAULT_HIGHLIGHT_COLOR
+ self._highlighted = False
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ # Filter-out values <= 0
+ xFiltered, yFiltered, xerror, yerror = self.getData(
+ copy=False, displayed=True)
+
+ if len(xFiltered) == 0:
+ return None # No data to display, do not add renderer to backend
+
+ return backend.addCurve(xFiltered, yFiltered, self.getLegend(),
+ color=self.getCurrentColor(),
+ symbol=self.getSymbol(),
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth(),
+ yaxis=self.getYAxis(),
+ xerror=xerror,
+ yerror=yerror,
+ z=self.getZValue(),
+ selectable=self.isSelectable(),
+ fill=self.isFill(),
+ alpha=self.getAlpha(),
+ symbolsize=self.getSymbolSize())
+
+ def __getitem__(self, item):
+ """Compatibility with PyMca and silx <= 0.4.0"""
+ if isinstance(item, slice):
+ return [self[index] for index in range(*item.indices(5))]
+ elif item == 0:
+ return self.getXData(copy=False)
+ elif item == 1:
+ return self.getYData(copy=False)
+ elif item == 2:
+ return self.getLegend()
+ elif item == 3:
+ info = self.getInfo(copy=False)
+ return {} if info is None else info
+ elif item == 4:
+ params = {
+ 'info': self.getInfo(),
+ 'color': self.getColor(),
+ 'symbol': self.getSymbol(),
+ 'linewidth': self.getLineWidth(),
+ 'linestyle': self.getLineStyle(),
+ 'xlabel': self.getXLabel(),
+ 'ylabel': self.getYLabel(),
+ 'yaxis': self.getYAxis(),
+ 'xerror': self.getXErrorData(copy=False),
+ 'yerror': self.getYErrorData(copy=False),
+ 'z': self.getZValue(),
+ 'selectable': self.isSelectable(),
+ 'fill': self.isFill()
+ }
+ return params
+ else:
+ raise IndexError("Index out of range: %s", str(item))
+
+ def setVisible(self, visible):
+ """Set visibility of item.
+
+ :param bool visible: True to display it, False otherwise
+ """
+ visibleChanged = self.isVisible() != bool(visible)
+ super(Curve, self).setVisible(visible)
+
+ # TODO hackish data range implementation
+ if visibleChanged:
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ def isHighlighted(self):
+ """Returns True if curve is highlighted.
+
+ :rtype: bool
+ """
+ return self._highlighted
+
+ def setHighlighted(self, highlighted):
+ """Set the highlight state of the curve
+
+ :param bool highlighted:
+ """
+ highlighted = bool(highlighted)
+ if highlighted != self._highlighted:
+ self._highlighted = highlighted
+ # TODO inefficient: better to use backend's setCurveColor
+ self._updated()
+
+ def getHighlightedColor(self):
+ """Returns the RGBA highlight color of the item
+
+ :rtype: 4-tuple of int in [0, 255]
+ """
+ return self._highlightColor
+
+ def setHighlightedColor(self, color):
+ """Set the color to use when highlighted
+
+ :param color: color(s) to be used for highlight
+ :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ """
+ color = Colors.rgba(color)
+ if color != self._highlightColor:
+ self._highlightColor = color
+ self._updated()
+
+ def getCurrentColor(self):
+ """Returns the current color of the curve.
+
+ This color is either the color of the curve or the highlighted color,
+ depending on the highlight state.
+
+ :rtype: 4-tuple of int in [0, 255]
+ """
+ if self.isHighlighted():
+ return self.getHighlightedColor()
+ else:
+ return self.getColor()
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
new file mode 100644
index 0000000..c3821bc
--- /dev/null
+++ b/silx/gui/plot/items/histogram.py
@@ -0,0 +1,288 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :class:`Histogram` item of the :class:`Plot`.
+"""
+
+__authors__ = ["H. Payno", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/05/2017"
+
+
+import logging
+
+import numpy
+
+from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn,
+ LineMixIn, YAxisMixIn)
+
+
+_logger = logging.getLogger(__name__)
+
+
+def _computeEdges(x, histogramType):
+ """Compute the edges from a set of xs and a rule to generate the edges
+
+ :param x: the x value of the curve to transform into an histogram
+ :param histogramType: the type of histogram we wan't to generate.
+ This define the way to center the histogram values compared to the
+ curve value. Possible values can be::
+
+ - 'left'
+ - 'right'
+ - 'center'
+
+ :return: the edges for the given x and the histogramType
+ """
+ # for now we consider that the spaces between xs are constant
+ edges = x.copy()
+ if histogramType is 'left':
+ width = 1
+ if len(x) > 1:
+ width = x[1] - x[0]
+ edges = numpy.append(x[0] - width, edges)
+ if histogramType is 'center':
+ edges = _computeEdges(edges, 'right')
+ widths = (edges[1:] - edges[0:-1]) / 2.0
+ widths = numpy.append(widths, widths[-1])
+ edges = edges - widths
+ if histogramType is 'right':
+ width = 1
+ if len(x) > 1:
+ width = x[-1] - x[-2]
+ edges = numpy.append(edges, x[-1] + width)
+
+ return edges
+
+
+def _getHistogramCurve(histogram, edges):
+ """Returns the x and y value of a curve corresponding to the histogram
+
+ :param numpy.ndarray histogram: The values of the histogram
+ :param numpy.ndarray edges: The bin edges of the histogram
+ :return: a tuple(x, y) which contains the value of the curve to use
+ to display the histogram
+ """
+ assert len(histogram) + 1 == len(edges)
+ x = numpy.empty(len(histogram) * 2, dtype=edges.dtype)
+ y = numpy.empty(len(histogram) * 2, dtype=histogram.dtype)
+ # Make a curve with stairs
+ x[:-1:2] = edges[:-1]
+ x[1::2] = edges[1:]
+ y[:-1:2] = histogram
+ y[1::2] = histogram
+
+ return x, y
+
+
+# TODO: Yerror, test log scale
+class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
+ LineMixIn, YAxisMixIn):
+ """Description of an histogram"""
+
+ _DEFAULT_Z_LAYER = 1
+ """Default overlay layer for histograms"""
+
+ _DEFAULT_SELECTABLE = False
+ """Default selectable state for histograms"""
+
+ _DEFAULT_LINEWIDTH = 1.
+ """Default line width of the histogram"""
+
+ _DEFAULT_LINESTYLE = '-'
+ """Default line style of the histogram"""
+
+ def __init__(self):
+ Item.__init__(self)
+ AlphaMixIn.__init__(self)
+ ColorMixIn.__init__(self)
+ FillMixIn.__init__(self)
+ LineMixIn.__init__(self)
+ YAxisMixIn.__init__(self)
+
+ self._histogram = ()
+ self._edges = ()
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ values, edges = self.getData(copy=False)
+
+ if values.size == 0:
+ return None # No data to display, do not add renderer
+
+ if values.size == 0:
+ return None # No data to display, do not add renderer to backend
+
+ x, y = _getHistogramCurve(values, edges)
+
+ # Filter-out values <= 0
+ plot = self.getPlot()
+ if plot is not None:
+ xPositive = plot.isXAxisLogarithmic()
+ yPositive = plot.isYAxisLogarithmic()
+ else:
+ xPositive = False
+ yPositive = False
+
+ if xPositive or yPositive:
+ clipped = numpy.logical_or(
+ (x <= 0) if xPositive else False,
+ (y <= 0) if yPositive else False)
+ # Make a copy and replace negative points by NaN
+ x = numpy.array(x, dtype=numpy.float)
+ y = numpy.array(y, dtype=numpy.float)
+ x[clipped] = numpy.nan
+ y[clipped] = numpy.nan
+
+ return backend.addCurve(x, y, self.getLegend(),
+ color=self.getColor(),
+ symbol='',
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth(),
+ yaxis=self.getYAxis(),
+ xerror=None,
+ yerror=None,
+ z=self.getZValue(),
+ selectable=self.isSelectable(),
+ fill=self.isFill(),
+ alpha=self.getAlpha(),
+ symbolsize=1)
+
+ def _getBounds(self):
+ values, edges = self.getData(copy=False)
+
+ plot = self.getPlot()
+ if plot is not None:
+ xPositive = plot.isXAxisLogarithmic()
+ yPositive = plot.isYAxisLogarithmic()
+ else:
+ xPositive = False
+ yPositive = False
+
+ if xPositive or yPositive:
+ values = numpy.array(values, copy=True, dtype=numpy.float)
+
+ if xPositive:
+ # Replace edges <= 0 by NaN and corresponding values by NaN
+ clipped = (edges <= 0)
+ edges = numpy.array(edges, copy=True, dtype=numpy.float)
+ edges[clipped] = numpy.nan
+ values[numpy.logical_or(clipped[:-1], clipped[1:])] = numpy.nan
+
+ if yPositive:
+ # Replace values <= 0 by NaN, do not modify edges
+ values[values <= 0] = numpy.nan
+
+ if xPositive or yPositive:
+ return (numpy.nanmin(edges),
+ numpy.nanmax(edges),
+ numpy.nanmin(values),
+ numpy.nanmax(values))
+
+ else: # No log scale, include 0 in bounds
+ return (numpy.nanmin(edges),
+ numpy.nanmax(edges),
+ min(0, numpy.nanmin(values)),
+ max(0, numpy.nanmax(values)))
+
+ def setVisible(self, visible):
+ """Set visibility of item.
+
+ :param bool visible: True to display it, False otherwise
+ """
+ visibleChanged = self.isVisible() != bool(visible)
+ super(Histogram, self).setVisible(visible)
+
+ # TODO hackish data range implementation
+ if visibleChanged:
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ def getValueData(self, copy=True):
+ """The values of the histogram
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :returns: The bin edges of the histogram
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._histogram, copy=copy)
+
+ def getBinEdgesData(self, copy=True):
+ """The bin edges of the histogram (number of histogram values + 1)
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :returns: The bin edges of the histogram
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._edges, copy=copy)
+
+ def getData(self, copy=True):
+ """Return the histogram values and the bin edges
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :returns: (N histogram value, N+1 bin edges)
+ :rtype: 2-tuple of numpy.nadarray
+ """
+ return (self.getValueData(copy), self.getBinEdgesData(copy))
+
+ def setData(self, histogram, edges, align='center', copy=True):
+ """Set the histogram values and bin edges.
+
+ :param numpy.ndarray histogram: The values of the histogram.
+ :param numpy.ndarray edges:
+ The bin edges of the histogram.
+ If histogram and edges have the same length, the bin edges
+ are computed according to the align parameter.
+ :param str align:
+ In case histogram values and edges have the same length N,
+ the N+1 bin edges are computed according to the alignment in:
+ 'center' (default), 'left', 'right'.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ """
+ histogram = numpy.array(histogram, copy=copy)
+ edges = numpy.array(edges, copy=copy)
+
+ assert histogram.ndim == 1
+ assert edges.ndim == 1
+ assert edges.size in (histogram.size, histogram.size + 1)
+ assert align in ('center', 'left', 'right')
+
+ if histogram.size == 0: # No data
+ self._histogram = ()
+ self._edges = ()
+ else:
+ if edges.size == histogram.size: # Compute true bin edges
+ edges = _computeEdges(edges, align)
+
+ # Check that bin edges are monotonic
+ edgesDiff = numpy.diff(edges)
+ assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0)
+
+ self._histogram = histogram
+ self._edges = edges
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
new file mode 100644
index 0000000..7e1dd8b
--- /dev/null
+++ b/silx/gui/plot/items/image.py
@@ -0,0 +1,385 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :class:`ImageData` and :class:`ImageRgba` items
+of the :class:`Plot`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/03/2017"
+
+
+from collections import Sequence
+import logging
+
+import numpy
+
+from .core import Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn
+from ..Colors import applyColormapToData
+
+
+_logger = logging.getLogger(__name__)
+
+
+def _convertImageToRgba32(image, copy=True):
+ """Convert an RGB or RGBA image to RGBA32.
+
+ It converts from floats in [0, 1], bool, integer and uint in [0, 255]
+
+ If the input image is already an RGBA32 image,
+ the returned image shares the same data.
+
+ :param image: Image to convert to
+ :type image: numpy.ndarray with 3 dimensions: height, width, color channels
+ :param bool copy: True (Default) to get a copy, False, avoid copy if possible
+ :return: The image converted to RGBA32 with dimension: (height, width, 4)
+ :rtype: numpy.ndarray of uint8
+ """
+ assert image.ndim == 3
+ assert image.shape[-1] in (3, 4)
+
+ # Convert type to uint8
+ if image.dtype.name != 'uin8':
+ if image.dtype.kind == 'f': # Float in [0, 1]
+ image = (numpy.clip(image, 0., 1.) * 255).astype(numpy.uint8)
+ elif image.dtype.kind == 'b': # boolean
+ image = image.astype(numpy.uint8) * 255
+ elif image.dtype.kind in ('i', 'u'): # int, uint
+ image = numpy.clip(image, 0, 255).astype(numpy.uint8)
+ else:
+ raise ValueError('Unsupported image dtype: %s', image.dtype.name)
+ copy = False # A copy as already been done, avoid next one
+
+ # Convert RGB to RGBA
+ if image.shape[-1] == 3:
+ new_image = numpy.empty((image.shape[0], image.shape[1], 4),
+ dtype=numpy.uint8)
+ new_image[:, :, :3] = image
+ new_image[:, :, 3] = 255
+ return new_image # This is a copy anyway
+ else:
+ return numpy.array(image, copy=copy)
+
+
+class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
+ """Description of an image"""
+
+ def __init__(self):
+ Item.__init__(self)
+ LabelsMixIn.__init__(self)
+ DraggableMixIn.__init__(self)
+ AlphaMixIn.__init__(self)
+ self._data = numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+
+ self._origin = (0., 0.)
+ self._scale = (1., 1.)
+
+ def __getitem__(self, item):
+ """Compatibility with PyMca and silx <= 0.4.0"""
+ if isinstance(item, slice):
+ return [self[index] for index in range(*item.indices(5))]
+ elif item == 0:
+ return self.getData(copy=False)
+ elif item == 1:
+ return self.getLegend()
+ elif item == 2:
+ info = self.getInfo(copy=False)
+ return {} if info is None else info
+ elif item == 3:
+ return None
+ elif item == 4:
+ params = {
+ 'info': self.getInfo(),
+ 'origin': self.getOrigin(),
+ 'scale': self.getScale(),
+ 'z': self.getZValue(),
+ 'selectable': self.isSelectable(),
+ 'draggable': self.isDraggable(),
+ 'colormap': None,
+ 'xlabel': self.getXLabel(),
+ 'ylabel': self.getYLabel(),
+ }
+ return params
+ else:
+ raise IndexError("Index out of range: %s" % str(item))
+
+ def setVisible(self, visible):
+ """Set visibility of item.
+
+ :param bool visible: True to display it, False otherwise
+ """
+ visibleChanged = self.isVisible() != bool(visible)
+ super(ImageBase, self).setVisible(visible)
+
+ # TODO hackish data range implementation
+ if visibleChanged:
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ def _getBounds(self):
+ if self.getData(copy=False).size == 0: # Empty data
+ return None
+
+ height, width = self.getData(copy=False).shape[:2]
+ origin = self.getOrigin()
+ scale = self.getScale()
+ # Taking care of scale might be < 0
+ xmin, xmax = origin[0], origin[0] + width * scale[0]
+ if xmin > xmax:
+ xmin, xmax = xmax, xmin
+ # Taking care of scale might be < 0
+ ymin, ymax = origin[1], origin[1] + height * scale[1]
+ if ymin > ymax:
+ ymin, ymax = ymax, ymin
+
+ plot = self.getPlot()
+ if (plot is not None and
+ plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic()):
+ return None
+ else:
+ return xmin, xmax, ymin, ymax
+
+ def getData(self, copy=True):
+ """Returns the image data
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._data, copy=copy)
+
+ def getRgbaImageData(self, copy=True):
+ """Get the displayed RGB(A) image
+
+ :returns: numpy.ndarray of uint8 of shape (height, width, 4)
+ """
+ raise NotImplementedError('This MUST be implemented in sub-class')
+
+ def getOrigin(self):
+ """Returns the offset from origin at which to display the image.
+
+ :rtype: 2-tuple of float
+ """
+ return self._origin
+
+ def setOrigin(self, origin):
+ """Set the offset from origin at which to display the image.
+
+ :param origin: (ox, oy) Offset from origin
+ :type origin: float or 2-tuple of float
+ """
+ if isinstance(origin, Sequence):
+ origin = float(origin[0]), float(origin[1])
+ else: # single value origin
+ origin = float(origin), float(origin)
+ if origin != self._origin:
+ self._origin = origin
+ self._updated()
+
+ # TODO hackish data range implementation
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ def getScale(self):
+ """Returns the scale of the image in data coordinates.
+
+ :rtype: 2-tuple of float
+ """
+ return self._scale
+
+ def setScale(self, scale):
+ """Set the scale of the image
+
+ :param scale: (sx, sy) Scale of the image
+ :type scale: float or 2-tuple of float
+ """
+ if isinstance(scale, Sequence):
+ scale = float(scale[0]), float(scale[1])
+ else: # single value scale
+ scale = float(scale), float(scale)
+ if scale != self._scale:
+ self._scale = scale
+ self._updated()
+
+
+class ImageData(ImageBase, ColormapMixIn):
+ """Description of a data image with a colormap"""
+
+ def __init__(self):
+ ImageBase.__init__(self)
+ ColormapMixIn.__init__(self)
+ self._data = numpy.zeros((0, 0), dtype=numpy.float32)
+ self._alternativeImage = None
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ plot = self.getPlot()
+ assert plot is not None
+ if plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic():
+ return None # Do not render with log scales
+
+ if self.getAlternativeImageData(copy=False) is not None:
+ dataToUse = self.getAlternativeImageData(copy=False)
+ else:
+ dataToUse = self.getData(copy=False)
+
+ if dataToUse.size == 0:
+ return None # No data to display
+
+ return backend.addImage(dataToUse,
+ legend=self.getLegend(),
+ origin=self.getOrigin(),
+ scale=self.getScale(),
+ z=self.getZValue(),
+ selectable=self.isSelectable(),
+ draggable=self.isDraggable(),
+ colormap=self.getColormap(),
+ alpha=self.getAlpha())
+
+ def __getitem__(self, item):
+ """Compatibility with PyMca and silx <= 0.4.0"""
+ if item == 3:
+ return self.getAlternativeImageData(copy=False)
+
+ params = ImageBase.__getitem__(self, item)
+ if item == 4:
+ params['colormap'] = self.getColormap()
+
+ return params
+
+ def getRgbaImageData(self, copy=True):
+ """Get the displayed RGB(A) image
+
+ :returns: numpy.ndarray of uint8 of shape (height, width, 4)
+ """
+ if self._alternativeImage is not None:
+ return _convertImageToRgba32(
+ self.getAlternativeImageData(copy=False), copy=copy)
+ else:
+ # Apply colormap, in this case an new array is always returned
+ colormap = self.getColormap()
+ image = applyColormapToData(self.getData(copy=False),
+ **colormap)
+ return image
+
+ def getAlternativeImageData(self, copy=True):
+ """Get the optional RGBA image that is displayed instead of the data
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :returns: None or numpy.ndarray
+ :rtype: numpy.ndarray or None
+ """
+ if self._alternativeImage is None:
+ return None
+ else:
+ return numpy.array(self._alternativeImage, copy=copy)
+
+ def setData(self, data, alternative=None, copy=True):
+ """"Set the image data and optionally an alternative RGB(A) representation
+
+ :param numpy.ndarray data: Data array with 2 dimensions (h, w)
+ :param alternative: RGB(A) image to display instead of data,
+ shape: (h, w, 3 or 4)
+ :type alternative: None or numpy.ndarray
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ """
+ data = numpy.array(data, copy=copy)
+ assert data.ndim == 2
+ self._data = data
+
+ if alternative is not None:
+ alternative = numpy.array(alternative, copy=copy)
+ assert alternative.ndim == 3
+ assert alternative.shape[2] in (3, 4)
+ assert alternative.shape[:2] == data.shape[:2]
+ self._alternativeImage = alternative
+ self._updated()
+
+ # TODO hackish data range implementation
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+
+class ImageRgba(ImageBase):
+ """Description of an RGB(A) image"""
+
+ def __init__(self):
+ ImageBase.__init__(self)
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ plot = self.getPlot()
+ assert plot is not None
+ if plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic():
+ return None # Do not render with log scales
+
+ data = self.getData(copy=False)
+
+ if data.size == 0:
+ return None # No data to display
+
+ return backend.addImage(data,
+ legend=self.getLegend(),
+ origin=self.getOrigin(),
+ scale=self.getScale(),
+ z=self.getZValue(),
+ selectable=self.isSelectable(),
+ draggable=self.isDraggable(),
+ colormap=None,
+ alpha=self.getAlpha())
+
+ def getRgbaImageData(self, copy=True):
+ """Get the displayed RGB(A) image
+
+ :returns: numpy.ndarray of uint8 of shape (height, width, 4)
+ """
+ return _convertImageToRgba32(self.getData(copy=False), copy=copy)
+
+ def setData(self, data, copy=True):
+ """Set the image data
+
+ :param data: RGB(A) image data to set
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ """
+ data = numpy.array(data, copy=copy)
+ assert data.ndim == 3
+ assert data.shape[-1] in (3, 4)
+ self._data = data
+
+ self._updated()
+
+ # TODO hackish data range implementation
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py
new file mode 100644
index 0000000..c05558b
--- /dev/null
+++ b/silx/gui/plot/items/marker.py
@@ -0,0 +1,241 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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 markers item of the :class:`Plot`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/03/2017"
+
+
+import logging
+
+from .core import Item, DraggableMixIn, ColorMixIn, SymbolMixIn
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
+ """Base class for markers"""
+
+ _DEFAULT_COLOR = (0., 0., 0., 1.)
+ """Default color of the markers"""
+
+ def __init__(self):
+ Item.__init__(self)
+ DraggableMixIn.__init__(self)
+ ColorMixIn.__init__(self)
+
+ self._text = ''
+ self._x = None
+ self._y = None
+ self._constraint = self._defaultConstraint
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ # TODO not very nice way to do it, but simple
+ symbol = self.getSymbol() if isinstance(self, Marker) else None
+
+ return backend.addMarker(
+ x=self.getXPosition(),
+ y=self.getYPosition(),
+ legend=self.getLegend(),
+ text=self.getText(),
+ color=self.getColor(),
+ selectable=self.isSelectable(),
+ draggable=self.isDraggable(),
+ symbol=symbol,
+ constraint=self.getConstraint(),
+ overlay=self.isOverlay())
+
+ def isOverlay(self):
+ """Return true if marker is drawn as an overlay.
+
+ A marker is an overlay if it is draggable.
+
+ :rtype: bool
+ """
+ return self.isDraggable()
+
+ def getText(self):
+ """Returns marker text.
+
+ :rtype: str
+ """
+ return self._text
+
+ def setText(self, text):
+ """Set the text of the marker.
+
+ :param str text: The text to use
+ """
+ text = str(text)
+ if text != self._text:
+ self._text = text
+ self._updated()
+
+ def getXPosition(self):
+ """Returns the X position of the marker line in data coordinates
+
+ :rtype: float or None
+ """
+ return self._x
+
+ def getYPosition(self):
+ """Returns the Y position of the marker line in data coordinates
+
+ :rtype: float or None
+ """
+ return self._y
+
+ def getPosition(self):
+ """Returns the (x, y) position of the marker in data coordinates
+
+ :rtype: 2-tuple of float or None
+ """
+ return self._x, self._y
+
+ def setPosition(self, x, y):
+ """Set marker position in data coordinates
+
+ Constraint are applied if any.
+
+ :param float x: X coordinates in data frame
+ :param float y: Y coordinates in data frame
+ """
+ x, y = self.getConstraint()(x, y)
+ x, y = float(x), float(y)
+ if x != self._x or y != self._y:
+ self._x, self._y = x, y
+ self._updated()
+
+ def getConstraint(self):
+ """Returns the dragging constraint of this item"""
+ return self._constraint
+
+ def _setConstraint(self, constraint): # TODO support update
+ """Set the constraint.
+
+ This is private for now as update is not handled.
+
+ :param callable constraint:
+ :param constraint: A function filtering item displacement by
+ dragging operations or None for no filter.
+ This function is called each time the item is
+ moved.
+ This is only used if isDraggable returns True.
+ :type constraint: None or a callable that takes the coordinates of
+ the current cursor position in the plot as input
+ and that returns the filtered coordinates.
+ """
+ if constraint is None:
+ constraint = self._defaultConstraint
+ assert callable(constraint)
+ self._constraint = constraint
+
+ @staticmethod
+ def _defaultConstraint(*args):
+ """Default constraint not doing anything"""
+ return args
+
+
+class Marker(_BaseMarker, SymbolMixIn):
+ """Description of a marker"""
+
+ _DEFAULT_SYMBOL = '+'
+ """Default symbol of the marker"""
+
+ def __init__(self):
+ _BaseMarker.__init__(self)
+ SymbolMixIn.__init__(self)
+
+ self._x = 0.
+ self._y = 0.
+
+ def _setConstraint(self, constraint):
+ """Set the constraint function of the marker drag.
+
+ It also supports 'horizontal' and 'vertical' str as constraint.
+
+ :param constraint: The constraint of the dragging of this marker
+ :type: constraint: callable or str
+ """
+ if constraint == 'horizontal':
+ constraint = self._horizontalConstraint
+ elif constraint == 'vertical':
+ constraint = self._verticalConstraint
+
+ super(Marker, self)._setConstraint(constraint)
+
+ def _horizontalConstraint(self, _, y):
+ return self.getXPosition(), y
+
+ def _verticalConstraint(self, x, _):
+ return x, self.getYPosition()
+
+
+class XMarker(_BaseMarker):
+ """Description of a marker"""
+
+ def __init__(self):
+ _BaseMarker.__init__(self)
+ self._x = 0.
+
+ def setPosition(self, x, y):
+ """Set marker line position in data coordinates
+
+ Constraint are applied if any.
+
+ :param float x: X coordinates in data frame
+ :param float y: Y coordinates in data frame
+ """
+ x, _ = self.getConstraint()(x, y)
+ x = float(x)
+ if x != self._x:
+ self._x = x
+ self._updated()
+
+
+class YMarker(_BaseMarker):
+ """Description of a marker"""
+
+ def __init__(self):
+ _BaseMarker.__init__(self)
+ self._y = 0.
+
+ def setPosition(self, x, y):
+ """Set marker line position in data coordinates
+
+ Constraint are applied if any.
+
+ :param float x: X coordinates in data frame
+ :param float y: Y coordinates in data frame
+ """
+ _, y = self.getConstraint()(x, y)
+ y = float(y)
+ if y != self._y:
+ self._y = y
+ self._updated()
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
new file mode 100644
index 0000000..3897dc1
--- /dev/null
+++ b/silx/gui/plot/items/scatter.py
@@ -0,0 +1,169 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :class:`Scatter` item of the :class:`Plot`.
+"""
+
+__authors__ = ["T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "29/03/2017"
+
+
+import logging
+
+import numpy
+
+from .core import Points, ColormapMixIn
+from silx.gui.plot.Colors import applyColormapToData # TODO: cherry-pick commit or wait for PR merge
+
+_logger = logging.getLogger(__name__)
+
+
+class Scatter(Points, ColormapMixIn):
+ """Description of a scatter"""
+ _DEFAULT_SYMBOL = 'o'
+ """Default symbol of the scatter plots"""
+
+ def __init__(self):
+ Points.__init__(self)
+ ColormapMixIn.__init__(self)
+ self._value = ()
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ # Filter-out values <= 0
+ xFiltered, yFiltered, valueFiltered, xerror, yerror = self.getData(
+ copy=False, displayed=True)
+
+ if len(xFiltered) == 0:
+ return None # No data to display, do not add renderer to backend
+
+ cmap = self.getColormap()
+ rgbacolors = applyColormapToData(self._value,
+ cmap["name"],
+ cmap["normalization"],
+ cmap["autoscale"],
+ cmap["vmin"],
+ cmap["vmax"],
+ cmap.get("colors"))
+
+ return backend.addCurve(xFiltered, yFiltered, self.getLegend(),
+ color=rgbacolors,
+ symbol=self.getSymbol(),
+ linewidth=0,
+ linestyle="",
+ yaxis='left',
+ xerror=xerror,
+ yerror=yerror,
+ z=self.getZValue(),
+ selectable=self.isSelectable(),
+ fill=False,
+ alpha=self.getAlpha(),
+ symbolsize=self.getSymbolSize())
+
+ def _logFilterData(self, xPositive, yPositive):
+ """Filter out values with x or y <= 0 on log axes
+
+ :param bool xPositive: True to filter arrays according to X coords.
+ :param bool yPositive: True to filter arrays according to Y coords.
+ :return: The filtered arrays or unchanged object if not filtering needed
+ :rtype: (x, y, value, xerror, yerror)
+ """
+ # overloaded from Points to filter also value.
+ value = self.getValueData(copy=False)
+
+ if xPositive or yPositive:
+ clipped = self._getClippingBoolArray(xPositive, yPositive)
+
+ if numpy.any(clipped):
+ # copy to keep original array and convert to float
+ value = numpy.array(value, copy=True, dtype=numpy.float)
+ value[clipped] = numpy.nan
+
+ x, y, xerror, yerror = Points._logFilterData(self, xPositive, yPositive)
+
+ return x, y, value, xerror, yerror
+
+ def getValueData(self, copy=True):
+ """Returns the value assigned to the scatter data points.
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._value, copy=copy)
+
+ def getData(self, copy=True, displayed=False):
+ """Returns the x, y coordinates and the value of the data points
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :param bool displayed: True to only get curve points that are displayed
+ in the plot. Default: False.
+ Note: If plot has log scale, negative points
+ are not displayed.
+ :returns: (x, y, value, xerror, yerror)
+ :rtype: 5-tuple of numpy.ndarray
+ """
+ if displayed:
+ data = self._getCachedData()
+ if data is not None:
+ assert len(data) == 5
+ return data
+
+ return (self.getXData(copy),
+ self.getYData(copy),
+ self.getValueData(copy),
+ self.getXErrorData(copy),
+ self.getYErrorData(copy))
+
+ # reimplemented from Points to handle `value`
+ def setData(self, x, y, value, xerror=None, yerror=None, copy=True):
+ """Set the data of the scatter.
+
+ :param numpy.ndarray x: The data corresponding to the x coordinates.
+ :param numpy.ndarray y: The data corresponding to the y coordinates.
+ :param numpy.ndarray value: The data corresponding to the value of
+ the data points.
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for positive errors,
+ row 1 for negative errors.
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ """
+ value = numpy.array(value, copy=copy)
+ assert value.ndim == 1
+ assert len(x) == len(value)
+
+ self._value = value
+
+ # set x, y, xerror, yerror
+
+ # call self._updated + plot._invalidateDataRange()
+ Points.setData(self, x, y, xerror, yerror, copy)
diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py
new file mode 100644
index 0000000..b663989
--- /dev/null
+++ b/silx/gui/plot/items/shape.py
@@ -0,0 +1,121 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :class:`Shape` item of the :class:`Plot`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/03/2017"
+
+
+import logging
+
+import numpy
+
+from .core import Item, ColorMixIn, FillMixIn
+
+
+_logger = logging.getLogger(__name__)
+
+
+# TODO probably make one class for each kind of shape
+# TODO check fill:polygon/polyline + fill = duplicated
+class Shape(Item, ColorMixIn, FillMixIn):
+ """Description of a shape item
+
+ :param str type_: The type of shape in:
+ 'hline', 'polygon', 'rectangle', 'vline', 'polyline'
+ """
+
+ def __init__(self, type_):
+ Item.__init__(self)
+ ColorMixIn.__init__(self)
+ FillMixIn.__init__(self)
+ self._overlay = False
+ assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polyline')
+ self._type = type_
+ self._points = ()
+
+ self._handle = None
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ points = self.getPoints(copy=False)
+ x, y = points.T[0], points.T[1]
+ return backend.addItem(x,
+ y,
+ legend=self.getLegend(),
+ shape=self.getType(),
+ color=self.getColor(),
+ fill=self.isFill(),
+ overlay=self.isOverlay(),
+ z=self.getZValue())
+
+ def isOverlay(self):
+ """Return true if shape is drawn as an overlay
+
+ :rtype: bool
+ """
+ return self._overlay
+
+ def setOverlay(self, overlay):
+ """Set the overlay state of the shape
+
+ :param bool overlay: True to make it an overlay
+ """
+ overlay = bool(overlay)
+ if overlay != self._overlay:
+ self._overlay = overlay
+ self._updated()
+
+ def getType(self):
+ """Returns the type of shape to draw.
+
+ One of: 'hline', 'polygon', 'rectangle', 'vline', 'polyline'
+
+ :rtype: str
+ """
+ return self._type
+
+ def getPoints(self, copy=True):
+ """Get the control points of the shape.
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :return: Array of point coordinates
+ :rtype: numpy.ndarray with 2 dimensions
+ """
+ return numpy.array(self._points, copy=copy)
+
+ def setPoints(self, points, copy=True):
+ """Set the point coordinates
+
+ :param numpy.ndarray points: Array of point coordinates
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :return:
+ """
+ self._points = numpy.array(points, copy=copy)
+ self._updated()
diff --git a/silx/gui/plot/setup.py b/silx/gui/plot/setup.py
new file mode 100644
index 0000000..6408113
--- /dev/null
+++ b/silx/gui/plot/setup.py
@@ -0,0 +1,47 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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__ = "16/02/2016"
+
+
+from numpy.distutils.misc_util import Configuration
+
+
+def configuration(parent_package='', top_path=None):
+ config = Configuration('plot', parent_package, top_path)
+ config.add_subpackage('_utils')
+ config.add_subpackage('backends')
+ config.add_subpackage('backends.glutils')
+ config.add_subpackage('items')
+ config.add_subpackage('test')
+
+ return config
+
+
+if __name__ == "__main__":
+ from numpy.distutils.core import setup
+
+ setup(configuration=configuration)
diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py
new file mode 100644
index 0000000..b4378c7
--- /dev/null
+++ b/silx/gui/plot/test/__init__.py
@@ -0,0 +1,71 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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__ = "18/02/2016"
+
+
+import unittest
+
+from .._utils.test import suite as testUtilsSuite
+from .testColorBar import suite as testColorBarSuite
+from .testColormapDialog import suite as testColormapDialogSuite
+from .testColors import suite as testColorsSuite
+from .testCurvesROIWidget import suite as testCurvesROIWidgetSuite
+from .testAlphaSlider import suite as testAlphaSliderSuite
+from .testInteraction import suite as testInteractionSuite
+from .testLegendSelector import suite as testLegendSelectorSuite
+from .testMaskToolsWidget import suite as testMaskToolsWidgetSuite
+from .testScatterMaskToolsWidget import suite as testScatterMaskToolsWidgetSuite
+from .testPlotInteraction import suite as testPlotInteractionSuite
+from .testPlotTools import suite as testPlotToolsSuite
+from .testPlotWidget import suite as testPlotWidgetSuite
+from .testPlotWindow import suite as testPlotWindowSuite
+from .testPlot import suite as testPlotSuite
+from .testProfile import suite as testProfileSuite
+from .testStackView import suite as testStackViewSuite
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTests(
+ [testUtilsSuite(),
+ testColorBarSuite(),
+ testColorsSuite(),
+ testColormapDialogSuite(),
+ testCurvesROIWidgetSuite(),
+ testAlphaSliderSuite(),
+ testInteractionSuite(),
+ testLegendSelectorSuite(),
+ testMaskToolsWidgetSuite(),
+ testScatterMaskToolsWidgetSuite(),
+ testPlotInteractionSuite(),
+ testPlotSuite(),
+ testPlotToolsSuite(),
+ testPlotWidgetSuite(),
+ testPlotWindowSuite(),
+ testProfileSuite(),
+ testStackViewSuite()])
+ return test_suite
diff --git a/silx/gui/plot/test/testAlphaSlider.py b/silx/gui/plot/test/testAlphaSlider.py
new file mode 100644
index 0000000..304a562
--- /dev/null
+++ b/silx/gui/plot/test/testAlphaSlider.py
@@ -0,0 +1,221 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 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.
+#
+# ###########################################################################*/
+"""Tests for ImageAlphaSlider"""
+
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "28/03/2017"
+
+import numpy
+import unittest
+
+from silx.gui import qt
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.plot import PlotWidget
+from silx.gui.plot import AlphaSlider
+
+# Makes sure a QApplication exists
+_qapp = qt.QApplication.instance() or qt.QApplication([])
+
+
+class TestActiveImageAlphaSlider(TestCaseQt):
+ def setUp(self):
+ super(TestActiveImageAlphaSlider, self).setUp()
+ self.plot = PlotWidget()
+ self.aslider = AlphaSlider.ActiveImageAlphaSlider(plot=self.plot)
+ self.aslider.setOrientation(qt.Qt.Horizontal)
+
+ toolbar = qt.QToolBar("plot", self.plot)
+ toolbar.addWidget(self.aslider)
+ self.plot.addToolBar(toolbar)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.mouseMove(self.plot) # Move to center
+ self.qapp.processEvents()
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ del self.aslider
+
+ super(TestActiveImageAlphaSlider, self).tearDown()
+
+ def testWidgetEnabled(self):
+ # no active image initially, slider must be deactivate
+ self.assertFalse(self.aslider.isEnabled())
+
+ self.plot.addImage(numpy.array([[0, 1, 2], [3, 4, 5]]))
+ # now we have an active image
+ self.assertTrue(self.aslider.isEnabled())
+
+ self.plot.setActiveImage(None)
+ self.assertFalse(self.aslider.isEnabled())
+
+ def testGetImage(self):
+ self.plot.addImage(numpy.array([[0, 1, 2], [3, 4, 5]]))
+ self.assertEqual(self.plot.getActiveImage(),
+ self.aslider.getItem())
+
+ self.plot.addImage(numpy.array([[0, 1, 3], [2, 4, 6]]), legend="2")
+ self.plot.setActiveImage("2")
+ self.assertEqual(self.plot.getImage("2"),
+ self.aslider.getItem())
+
+ def testGetAlpha(self):
+ self.plot.addImage(numpy.array([[0, 1, 2], [3, 4, 5]]), legend="1")
+ self.aslider.setValue(137)
+ self.assertAlmostEqual(self.aslider.getAlpha(),
+ 137. / 255)
+
+
+class TestNamedImageAlphaSlider(TestCaseQt):
+ def setUp(self):
+ super(TestNamedImageAlphaSlider, self).setUp()
+ self.plot = PlotWidget()
+ self.aslider = AlphaSlider.NamedImageAlphaSlider(plot=self.plot)
+ self.aslider.setOrientation(qt.Qt.Horizontal)
+
+ toolbar = qt.QToolBar("plot", self.plot)
+ toolbar.addWidget(self.aslider)
+ self.plot.addToolBar(toolbar)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.mouseMove(self.plot) # Move to center
+ self.qapp.processEvents()
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ del self.aslider
+
+ super(TestNamedImageAlphaSlider, self).tearDown()
+
+ def testWidgetEnabled(self):
+ # no image set initially, slider must be deactivate
+ self.assertFalse(self.aslider.isEnabled())
+
+ self.plot.addImage(numpy.array([[0, 1, 2], [3, 4, 5]]), legend="1")
+ self.aslider.setLegend("1")
+ # now we have an image set
+ self.assertTrue(self.aslider.isEnabled())
+
+ def testGetImage(self):
+ self.plot.addImage(numpy.array([[0, 1, 2], [3, 4, 5]]), legend="1")
+ self.plot.addImage(numpy.array([[0, 1, 3], [2, 4, 6]]), legend="2")
+ self.aslider.setLegend("1")
+ self.assertEqual(self.plot.getImage("1"),
+ self.aslider.getItem())
+
+ self.aslider.setLegend("2")
+ self.assertEqual(self.plot.getImage("2"),
+ self.aslider.getItem())
+
+ def testGetAlpha(self):
+ self.plot.addImage(numpy.array([[0, 1, 2], [3, 4, 5]]), legend="1")
+ self.aslider.setLegend("1")
+ self.aslider.setValue(128)
+ self.assertAlmostEqual(self.aslider.getAlpha(),
+ 128. / 255)
+
+
+class TestNamedScatterAlphaSlider(TestCaseQt):
+ def setUp(self):
+ super(TestNamedScatterAlphaSlider, self).setUp()
+ self.plot = PlotWidget()
+ self.aslider = AlphaSlider.NamedScatterAlphaSlider(plot=self.plot)
+ self.aslider.setOrientation(qt.Qt.Horizontal)
+
+ toolbar = qt.QToolBar("plot", self.plot)
+ toolbar.addWidget(self.aslider)
+ self.plot.addToolBar(toolbar)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.mouseMove(self.plot) # Move to center
+ self.qapp.processEvents()
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ del self.aslider
+
+ super(TestNamedScatterAlphaSlider, self).tearDown()
+
+ def testWidgetEnabled(self):
+ # no Scatter set initially, slider must be deactivate
+ self.assertFalse(self.aslider.isEnabled())
+
+ self.plot.addScatter([0, 1, 2], [2, 3, 4], [5, 6, 7],
+ legend="1")
+ self.aslider.setLegend("1")
+ # now we have an image set
+ self.assertTrue(self.aslider.isEnabled())
+
+ def testGetScatter(self):
+ self.plot.addScatter([0, 1, 2], [2, 3, 4], [5, 6, 7],
+ legend="1")
+ self.plot.addScatter([0, 10, 20], [20, 30, 40], [50, 60, 70],
+ legend="2")
+ self.aslider.setLegend("1")
+ self.assertEqual(self.plot.getScatter("1"),
+ self.aslider.getItem())
+
+ self.aslider.setLegend("2")
+ self.assertEqual(self.plot.getScatter("2"),
+ self.aslider.getItem())
+
+ def testGetAlpha(self):
+ self.plot.addScatter([0, 10, 20], [20, 30, 40], [50, 60, 70],
+ legend="1")
+ self.aslider.setLegend("1")
+ self.aslider.setValue(128)
+ self.assertAlmostEqual(self.aslider.getAlpha(),
+ 128. / 255)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ # test_suite.addTest(positionInfoTestSuite)
+ for testClass in (TestActiveImageAlphaSlider, TestNamedImageAlphaSlider,
+ TestNamedScatterAlphaSlider):
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ testClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testColorBar.py b/silx/gui/plot/test/testColorBar.py
new file mode 100644
index 0000000..797ff03
--- /dev/null
+++ b/silx/gui/plot/test/testColorBar.py
@@ -0,0 +1,240 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 ColorBar featues and sub widgets of Colorbar module"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "11/04/2017"
+
+import unittest
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.plot.ColorBar import _ColorScale
+from silx.gui.plot.ColorBar import ColorBarWidget
+from silx.gui.plot import Plot2D
+import numpy
+
+
+class TestColorScale(unittest.TestCase):
+ """Test that interaction with the colorScale is correct"""
+ def setUp(self):
+ self.colorScaleWidget = _ColorScale(colormap=None, parent=None)
+
+ def tearDown(self):
+ self.colorScaleWidget.deleteLater()
+ self.colorScaleWidget = None
+
+ def testRelativePositionLinear(self):
+ self.colorMapLin1 = { 'name': 'gray', 'normalization': 'linear',
+ 'autoscale': False, 'vmin': 0.0, 'vmax': 1.0 }
+ self.colorScaleWidget.setColormap(self.colorMapLin1)
+
+ self.assertTrue(
+ self.colorScaleWidget.getValueFromRelativePosition(0.25) == 0.25)
+ self.assertTrue(
+ self.colorScaleWidget.getValueFromRelativePosition(0.5) == 0.5)
+ self.assertTrue(
+ self.colorScaleWidget.getValueFromRelativePosition(1.0) == 1.0)
+
+ self.colorMapLin2 = { 'name': 'viridis', 'normalization': 'linear',
+ 'autoscale': False, 'vmin': -10, 'vmax': 0 }
+ self.colorScaleWidget.setColormap(self.colorMapLin2)
+
+ self.assertTrue(
+ self.colorScaleWidget.getValueFromRelativePosition(0.25) == -7.5)
+ self.assertTrue(
+ self.colorScaleWidget.getValueFromRelativePosition(0.5) == -5.0)
+ self.assertTrue(
+ self.colorScaleWidget.getValueFromRelativePosition(1.0) == 0.0)
+
+ def testRelativePositionLog(self):
+ self.colorMapLog1 = { 'name': 'temperature', 'normalization': 'log',
+ 'autoscale': False, 'vmin': 1.0, 'vmax': 100.0 }
+
+ self.colorScaleWidget.setColormap(self.colorMapLog1)
+
+ val = self.colorScaleWidget.getValueFromRelativePosition(1.0)
+ self.assertTrue(val == 100.0)
+
+ val = self.colorScaleWidget.getValueFromRelativePosition(0.5)
+ self.assertTrue(val == 10.0)
+
+ val = self.colorScaleWidget.getValueFromRelativePosition(0.0)
+ self.assertTrue(val == 1.0)
+
+ def testNegativeLogMin(self):
+ colormap = { 'name': 'gray', 'normalization': 'log',
+ 'autoscale': False, 'vmin': -1.0, 'vmax': 1.0 }
+
+ with self.assertRaises(ValueError):
+ self.colorScaleWidget.setColormap(colormap)
+
+ def testNegativeLogMax(self):
+ colormap = { 'name': 'gray', 'normalization': 'log',
+ 'autoscale': False, 'vmin': 1.0, 'vmax': -1.0 }
+
+ with self.assertRaises(ValueError):
+ self.colorScaleWidget.setColormap(colormap)
+
+class TestNoAutoscale(unittest.TestCase):
+ """Test that ticks and color displayed are correct in the case of a colormap
+ with no autoscale
+ """
+
+ def setUp(self):
+ self.plot = Plot2D()
+ self.colorBar = ColorBarWidget(parent=None, plot=self.plot)
+ self.tickBar = self.colorBar.getColorScaleBar().getTickBar()
+ self.colorScale = self.colorBar.getColorScaleBar().getColorScale()
+
+ def tearDown(self):
+ self.tickBar = None
+ self.colorScale = None
+ del self.colorBar
+ self.plot.close()
+ del self.plot
+
+ def testLogNormNoAutoscale(self):
+ colormapLog = { 'name': 'gray', 'normalization': 'log',
+ 'autoscale': False, 'vmin': 1.0, 'vmax': 100.0 }
+
+ data = numpy.linspace(10, 1e10, 9).reshape(3, 3)
+ self.plot.addImage(data=data, colormap=colormapLog, legend='toto')
+ self.plot.setActiveImage('toto')
+
+ # test Ticks
+ self.tickBar.setTicksNumber(10)
+ self.tickBar.computeTicks()
+
+ ticksTh = numpy.linspace(1.0, 100.0, 10)
+ ticksTh = 10**ticksTh
+ numpy.array_equal(self.tickBar.ticks, ticksTh)
+
+ # test ColorScale
+ val = self.colorScale.getValueFromRelativePosition(1.0)
+ self.assertTrue(val == 100.0)
+
+ val = self.colorScale.getValueFromRelativePosition(0.0)
+ self.assertTrue(val == 1.0)
+
+ def testLinearNormNoAutoscale(self):
+ colormapLog = { 'name': 'gray', 'normalization': 'linear',
+ 'autoscale': False, 'vmin': -4, 'vmax': 5 }
+
+ data = numpy.linspace(1, 9, 9).reshape(3, 3)
+ self.plot.addImage(data=data, colormap=colormapLog, legend='toto')
+ self.plot.setActiveImage('toto')
+
+ # test Ticks
+ self.tickBar.setTicksNumber(10)
+ self.tickBar.computeTicks()
+
+ numpy.array_equal(self.tickBar.ticks, numpy.linspace(-4, 5, 10))
+
+ # test ColorScale
+ val = self.colorScale.getValueFromRelativePosition(1.0)
+ self.assertTrue(val == 5.0)
+
+ val = self.colorScale.getValueFromRelativePosition(0.0)
+ self.assertTrue(val == -4.0)
+
+class TestColorbarWidget(TestCaseQt):
+ """Test interaction with the ColorScaleBar"""
+
+ def setUp(self):
+ super(TestColorbarWidget, self).setUp()
+ self.plot = Plot2D()
+ self.colorBar = ColorBarWidget(parent=None, plot=self.plot)
+
+ def tearDown(self):
+ del self.colorBar
+ self.plot.close()
+ del self.plot
+
+ super(TestColorbarWidget, self).tearDown()
+
+ def testEmptyColorBar(self):
+ colorBar = ColorBarWidget(parent=None)
+ colorBar.show()
+ self.qWaitForWindowExposed(colorBar)
+
+ def testNegativeColormaps(self):
+ """test the behavior of the ColorBarWidget in the case of negative
+ values
+
+ Note : colorbar is modified by the Plot directly not ColorBarWidget
+ """
+ colormapLog = { 'name': 'gray', 'normalization': 'log',
+ 'autoscale': True, 'vmin': -1.0, 'vmax': 1.0 }
+
+ colormapLog2 = { 'name': 'gray', 'normalization': 'log',
+ 'autoscale': False, 'vmin': -1.0, 'vmax': 1.0 }
+
+ data = numpy.array([-5, -4, 0, 2, 3, 5, 10, 20, 30])
+ data = data.reshape(3, 3)
+ self.plot.addImage(data=data, colormap=colormapLog, legend='toto')
+ self.plot.setActiveImage('toto')
+
+ # default behavior when autoscale : set to minmal positive value
+ data[data<1] = data.max()
+ self.assertTrue(self.colorBar._colormap['vmin'] == data.min())
+ self.assertTrue(self.colorBar._colormap['vmax'] == data.max())
+
+ data = numpy.linspace(-9, -2, 100).reshape(10, 10)
+
+ self.plot.addImage(data=data, colormap=colormapLog2, legend='toto')
+ self.plot.setActiveImage('toto')
+ # if negative values, changing bounds for default : 1, 10
+ self.assertTrue(self.colorBar._colormap['vmin'] == 1)
+ self.assertTrue(self.colorBar._colormap['vmax'] == 10)
+
+ def testPlotAssocation(self):
+ """Make sure the ColorBarWidget is proparly connected with the plot"""
+ colormap = { 'name': 'gray', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': -1.0, 'vmax': 1.0 }
+
+ # make sure that default settings are the same
+ self.assertTrue(
+ self.colorBar.getColormap() == self.plot.getDefaultColormap())
+
+ data = numpy.linspace(0, 10, 100).reshape(10, 10)
+ self.plot.addImage(data=data, colormap=colormap, legend='toto')
+ self.plot.setActiveImage('toto')
+
+ # make sure the modification of the colormap has been done
+ self.assertFalse(
+ self.colorBar.getColormap() == self.plot.getDefaultColormap())
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for ui in (TestColorScale, TestNoAutoscale, TestColorbarWidget):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(ui))
+
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite') \ No newline at end of file
diff --git a/silx/gui/plot/test/testColormapDialog.py b/silx/gui/plot/test/testColormapDialog.py
new file mode 100644
index 0000000..d016548
--- /dev/null
+++ b/silx/gui/plot/test/testColormapDialog.py
@@ -0,0 +1,68 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 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 ColormapDialog"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import doctest
+import unittest
+
+from silx.gui.test.utils import qWaitForWindowExposedAndActivate
+from silx.gui import qt
+from silx.gui.plot import ColormapDialog
+
+
+# Makes sure a QApplication exists
+_qapp = qt.QApplication.instance() or qt.QApplication([])
+
+
+def _tearDownQt(docTest):
+ """Tear down to use for test from docstring.
+
+ Checks that dialog widget is displayed
+ """
+ dialogWidget = docTest.globs['dialog']
+ qWaitForWindowExposedAndActivate(dialogWidget)
+ dialogWidget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ dialogWidget.close()
+ del dialogWidget
+ _qapp.processEvents()
+
+
+cmapDocTestSuite = doctest.DocTestSuite(ColormapDialog, tearDown=_tearDownQt)
+"""Test suite of tests from the module's docstrings."""
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(cmapDocTestSuite)
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testColors.py b/silx/gui/plot/test/testColors.py
new file mode 100644
index 0000000..94c22f3
--- /dev/null
+++ b/silx/gui/plot/test/testColors.py
@@ -0,0 +1,94 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 Colors"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import numpy
+
+import unittest
+from silx.test.utils import ParametricTestCase
+
+from silx.gui.plot import Colors
+
+
+class TestRGBA(ParametricTestCase):
+ """Basic tests of rgba function"""
+
+ def testRGBA(self):
+ """"Test rgba function with accepted values"""
+ tests = { # name: (colors, expected values)
+ 'blue': ('blue', (0., 0., 1., 1.)),
+ '#010203': ('#010203', (1. / 255., 2. / 255., 3. / 255., 1.)),
+ '#01020304': ('#01020304', (1. / 255., 2. / 255., 3. / 255., 4. / 255.)),
+ '3 x uint8': (numpy.array((1, 255, 0), dtype=numpy.uint8),
+ (1 / 255., 1., 0., 1.)),
+ '4 x uint8': (numpy.array((1, 255, 0, 1), dtype=numpy.uint8),
+ (1 / 255., 1., 0., 1 / 255.)),
+ '3 x float overflow': ((3., 0.5, 1.), (1., 0.5, 1., 1.)),
+ }
+
+ for name, test in tests.items():
+ color, expected = test
+ with self.subTest(msg=name):
+ result = Colors.rgba(color)
+ self.assertEqual(result, expected)
+
+
+class TestApplyColormapToData(ParametricTestCase):
+ """Tests of applyColormapToData function"""
+
+ def testApplyColormapToData(self):
+ """Simple test of applyColormapToData function"""
+ colormap = dict(name='gray', normalization='linear',
+ autoscale=False, vmin=0, vmax=255)
+
+ size = 10
+ expected = numpy.empty((size, 4), dtype='uint8')
+ expected[:, 0] = numpy.arange(size, dtype='uint8')
+ expected[:, 1] = expected[:, 0]
+ expected[:, 2] = expected[:, 0]
+ expected[:, 3] = 255
+
+ for dtype in ('uint8', 'int32', 'float32', 'float64'):
+ with self.subTest(dtype=dtype):
+ array = numpy.arange(size, dtype=dtype)
+ result = Colors.applyColormapToData(array, **colormap)
+ self.assertTrue(numpy.all(numpy.equal(result, expected)))
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for testClass in (TestRGBA, TestApplyColormapToData):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(testClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testCurvesROIWidget.py b/silx/gui/plot/test/testCurvesROIWidget.py
new file mode 100644
index 0000000..3c6f2ba
--- /dev/null
+++ b/silx/gui/plot/test/testCurvesROIWidget.py
@@ -0,0 +1,153 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 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 CurvesROIWidget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import logging
+import os.path
+import unittest
+
+import numpy
+
+from silx.gui import qt
+from silx.test.utils import temp_dir
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.plot import PlotWindow, CurvesROIWidget
+
+
+logging.basicConfig()
+_logger = logging.getLogger(__name__)
+
+
+class TestCurvesROIWidget(TestCaseQt):
+ """Basic test for CurvesROIWidget"""
+
+ def setUp(self):
+ super(TestCurvesROIWidget, self).setUp()
+ self.plot = PlotWindow()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.widget = CurvesROIWidget.CurvesROIDockWidget(plot=self.plot, name='TEST')
+ self.widget.show()
+ self.qWaitForWindowExposed(self.widget)
+
+ def tearDown(self):
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+
+ self.widget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.widget.close()
+ del self.widget
+
+ super(TestCurvesROIWidget, self).tearDown()
+
+ def testEmptyPlot(self):
+ """Empty plot, display ROI widget"""
+ pass
+
+ def testWithCurves(self):
+ """Plot with curves: test all ROI widget buttons"""
+ for offset in range(2):
+ self.plot.addCurve(numpy.arange(1000),
+ offset + numpy.random.random(1000),
+ legend=str(offset))
+
+ # Add two ROI
+ self.mouseClick(self.widget.roiWidget.addButton, qt.Qt.LeftButton)
+ self.mouseClick(self.widget.roiWidget.addButton, qt.Qt.LeftButton)
+
+ # Change active curve
+ self.plot.setActiveCurve(str(1))
+
+ # Delete a ROI
+ self.mouseClick(self.widget.roiWidget.delButton, qt.Qt.LeftButton)
+
+ with temp_dir() as tmpDir:
+ self.tmpFile = os.path.join(tmpDir, 'test.ini')
+
+ # Save ROIs
+ self.widget.roiWidget.save(self.tmpFile)
+ self.assertTrue(os.path.isfile(self.tmpFile))
+
+ # Reset ROIs
+ self.mouseClick(self.widget.roiWidget.resetButton,
+ qt.Qt.LeftButton)
+
+ # Load ROIs
+ self.widget.roiWidget.load(self.tmpFile)
+
+ del self.tmpFile
+
+ def testCalculation(self):
+ x = numpy.arange(100.)
+ y = numpy.arange(100.)
+
+ # Add two curves
+ self.plot.addCurve(x, y, legend="positive")
+ self.plot.addCurve(-x, y, legend="negative")
+
+ # Make sure there is an active curve and it is the positive one
+ self.plot.setActiveCurve("positive")
+
+ # Add two ROIs
+ ddict = {}
+ ddict["positive"] = {"from": 10, "to": 20, "type":"X"}
+ ddict["negative"] = {"from": -20, "to": -10, "type":"X"}
+ self.widget.roiWidget.setRois(ddict)
+
+ # And calculate the expected output
+ self.widget.calculateROIs()
+
+ output = self.widget.roiWidget.getRois()
+ self.assertEqual(output["positive"]["rawcounts"],
+ y[ddict["positive"]["from"]:ddict["positive"]["to"]+1].sum(),
+ "Calculation failed on positive X coordinates")
+
+ # Set the curve with negative X coordinates as active
+ self.plot.setActiveCurve("negative")
+
+ # the ROIs should have been automatically updated
+ output = self.widget.roiWidget.getRois()
+ selection = numpy.nonzero((-x >= output["negative"]["from"]) & \
+ (-x <= output["negative"]["to"]))[0]
+ self.assertEqual(output["negative"]["rawcounts"],
+ y[selection].sum(), "Calculation failed on negative X coordinates")
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for TestClass in (TestCurvesROIWidget,):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testInteraction.py b/silx/gui/plot/test/testInteraction.py
new file mode 100644
index 0000000..074a7cd
--- /dev/null
+++ b/silx/gui/plot/test/testInteraction.py
@@ -0,0 +1,89 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 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.
+#
+# ###########################################################################*/
+"""Tests from interaction state machines"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/02/2016"
+
+
+import unittest
+
+from silx.gui.plot import Interaction
+
+
+class TestInteraction(unittest.TestCase):
+ def testClickOrDrag(self):
+ """Minimalistic test for click or drag state machine."""
+ events = []
+
+ class TestClickOrDrag(Interaction.ClickOrDrag):
+ def click(self, x, y, btn):
+ events.append(('click', x, y, btn))
+
+ def beginDrag(self, x, y):
+ events.append(('beginDrag', x, y))
+
+ def drag(self, x, y):
+ events.append(('drag', x, y))
+
+ def endDrag(self, x, y):
+ events.append(('endDrag', x, y))
+
+ clickOrDrag = TestClickOrDrag()
+
+ # click
+ clickOrDrag.handleEvent('press', 10, 10, Interaction.LEFT_BTN)
+ self.assertEqual(len(events), 0)
+
+ clickOrDrag.handleEvent('release', 10, 10, Interaction.LEFT_BTN)
+ self.assertEqual(len(events), 1)
+ self.assertEqual(events[0], ('click', 10, 10, Interaction.LEFT_BTN))
+
+ # drag
+ events = []
+ clickOrDrag.handleEvent('press', 10, 10, Interaction.LEFT_BTN)
+ self.assertEqual(len(events), 0)
+ clickOrDrag.handleEvent('move', 15, 10)
+ self.assertEqual(len(events), 2) # Received beginDrag and drag
+ self.assertEqual(events[0], ('beginDrag', 10, 10))
+ self.assertEqual(events[1], ('drag', 15, 10))
+ clickOrDrag.handleEvent('move', 20, 10)
+ self.assertEqual(len(events), 3)
+ self.assertEqual(events[-1], ('drag', 20, 10))
+ clickOrDrag.handleEvent('release', 20, 10, Interaction.LEFT_BTN)
+ self.assertEqual(len(events), 4)
+ self.assertEqual(events[-1], ('endDrag', (10, 10), (20, 10)))
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestInteraction))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testLegendSelector.py b/silx/gui/plot/test/testLegendSelector.py
new file mode 100644
index 0000000..371197f
--- /dev/null
+++ b/silx/gui/plot/test/testLegendSelector.py
@@ -0,0 +1,143 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2016 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 PlotWidget"""
+
+__authors__ = ["T. Rueter", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import logging
+import unittest
+
+from silx.gui import qt
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.plot import LegendSelector
+
+
+logging.basicConfig()
+_logger = logging.getLogger(__name__)
+
+
+class TestLegendSelector(TestCaseQt):
+ """Basic test for LegendSelector"""
+
+ def testLegendSelector(self):
+ """Test copied from __main__ of LegendSelector in PyMca"""
+ class Notifier(qt.QObject):
+ def __init__(self):
+ qt.QObject.__init__(self)
+ self.chk = True
+
+ def signalReceived(self, **kw):
+ obj = self.sender()
+ _logger.info('NOTIFIER -- signal received\n\tsender: %s',
+ str(obj))
+
+ notifier = Notifier()
+
+ legends = ['Legend0',
+ 'Legend1',
+ 'Long Legend 2',
+ 'Foo Legend 3',
+ 'Even Longer Legend 4',
+ 'Short Leg 5',
+ 'Dot symbol 6',
+ 'Comma symbol 7']
+ colors = [qt.Qt.darkRed, qt.Qt.green, qt.Qt.yellow, qt.Qt.darkCyan,
+ qt.Qt.blue, qt.Qt.darkBlue, qt.Qt.red, qt.Qt.darkYellow]
+ symbols = ['o', 't', '+', 'x', 's', 'd', '.', ',']
+
+ win = LegendSelector.LegendListView()
+ # win = LegendListContextMenu()
+ # win = qt.QWidget()
+ # layout = qt.QVBoxLayout()
+ # layout.setContentsMargins(0,0,0,0)
+ llist = []
+
+ for _idx, (l, c, s) in enumerate(zip(legends, colors, symbols)):
+ ddict = {
+ 'color': qt.QColor(c),
+ 'linewidth': 4,
+ 'symbol': s,
+ }
+ legend = l
+ llist.append((legend, ddict))
+ # item = qt.QListWidgetItem(win)
+ # legendWidget = LegendListItemWidget(l)
+ # legendWidget.icon.setSymbol(s)
+ # legendWidget.icon.setColor(qt.QColor(c))
+ # layout.addWidget(legendWidget)
+ # win.setItemWidget(item, legendWidget)
+
+ # win = LegendListItemWidget('Some Legend 1')
+ # print(llist)
+ model = LegendSelector.LegendModel(legendList=llist)
+ win.setModel(model)
+ win.setSelectionModel(qt.QItemSelectionModel(model))
+ win.setContextMenu()
+ # print('Edit triggers: %d'%win.editTriggers())
+
+ # win = LegendListWidget(None, legends)
+ # win[0].updateItem(ddict)
+ # win.setLayout(layout)
+ win.sigLegendSignal.connect(notifier.signalReceived)
+ win.show()
+
+ win.clear()
+ win.setLegendList(llist)
+
+ self.qWaitForWindowExposed(win)
+
+
+class TestRenameCurveDialog(TestCaseQt):
+ """Basic test for RenameCurveDialog"""
+
+ def testDialog(self):
+ """Create dialog, change name and press OK"""
+ self.dialog = LegendSelector.RenameCurveDialog(
+ None, 'curve1', ['curve1', 'curve2', 'curve3'])
+ self.dialog.open()
+ self.qWaitForWindowExposed(self.dialog)
+ self.keyClicks(self.dialog.lineEdit, 'changed')
+ self.mouseClick(self.dialog.okButton, qt.Qt.LeftButton)
+ self.qapp.processEvents()
+ ret = self.dialog.result()
+ self.assertEqual(ret, qt.QDialog.Accepted)
+ newName = self.dialog.getText()
+ self.assertEqual(newName, 'curve1changed')
+ del self.dialog
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for TestClass in (TestLegendSelector, TestRenameCurveDialog):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testMaskToolsWidget.py b/silx/gui/plot/test/testMaskToolsWidget.py
new file mode 100644
index 0000000..0c11928
--- /dev/null
+++ b/silx/gui/plot/test/testMaskToolsWidget.py
@@ -0,0 +1,295 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 MaskToolsWidget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "24/01/2017"
+
+
+import logging
+import os.path
+import unittest
+
+import numpy
+
+from silx.gui import qt
+from silx.test.utils import temp_dir, ParametricTestCase
+from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.plot import PlotWindow, MaskToolsWidget
+
+try:
+ import fabio
+except ImportError:
+ fabio = None
+
+
+logging.basicConfig()
+_logger = logging.getLogger(__name__)
+
+
+class TestMaskToolsWidget(TestCaseQt, ParametricTestCase):
+ """Basic test for MaskToolsWidget"""
+
+ def setUp(self):
+ super(TestMaskToolsWidget, self).setUp()
+ self.plot = PlotWindow()
+
+ self.widget = MaskToolsWidget.MaskToolsDockWidget(plot=self.plot, name='TEST')
+ self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, self.widget)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.maskWidget = self.widget.widget()
+
+ def tearDown(self):
+ del self.maskWidget
+ del self.widget
+
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+
+ super(TestMaskToolsWidget, self).tearDown()
+
+ def testEmptyPlot(self):
+ """Empty plot, display MaskToolsDockWidget, toggle multiple masks"""
+ self.maskWidget.setMultipleMasks('single')
+ self.qapp.processEvents()
+
+ self.maskWidget.setMultipleMasks('exclusive')
+ self.qapp.processEvents()
+
+ def _drag(self):
+ """Drag from plot center to offset position"""
+ plot = self.plot.centralWidget()
+ xCenter, yCenter = plot.width() // 2, plot.height() // 2
+ offset = min(plot.width(), plot.height()) // 10
+
+ pos0 = xCenter, yCenter
+ pos1 = xCenter + offset, yCenter + offset
+
+ self.mouseMove(plot, pos=pos0)
+ self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseMove(plot, pos=pos1)
+ self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
+
+ def _drawPolygon(self):
+ """Draw a star polygon in the plot"""
+ plot = self.plot.centralWidget()
+ x, y = plot.width() // 2, plot.height() // 2
+ offset = min(plot.width(), plot.height()) // 10
+
+ star = [(x, y + offset),
+ (x - offset, y - offset),
+ (x + offset, y),
+ (x - offset, y),
+ (x + offset, y - offset)]
+
+ for pos in star:
+ self.mouseMove(plot, pos=pos)
+ btn = qt.Qt.LeftButton if pos != star[-1] else qt.Qt.RightButton
+ self.mouseClick(plot, btn, pos=pos)
+
+ def _drawPencil(self):
+ """Draw a star polygon in the plot"""
+ plot = self.plot.centralWidget()
+ x, y = plot.width() // 2, plot.height() // 2
+ offset = min(plot.width(), plot.height()) // 10
+
+ star = [(x, y + offset),
+ (x - offset, y - offset),
+ (x + offset, y),
+ (x - offset, y),
+ (x + offset, y - offset)]
+
+ self.mouseMove(plot, pos=star[0])
+ self.mousePress(plot, qt.Qt.LeftButton, pos=star[0])
+ for pos in star:
+ self.mouseMove(plot, pos=pos)
+ self.mouseRelease(
+ plot, qt.Qt.LeftButton, pos=star[-1])
+
+ def testWithAnImage(self):
+ """Plot with an image: test MaskToolsWidget interactions"""
+
+ # Add and remove a image (this should enable/disable GUI + change mask)
+ self.plot.addImage(numpy.random.random(1024**2).reshape(1024, 1024),
+ legend='test')
+ self.qapp.processEvents()
+
+ self.plot.remove('test', kind='image')
+ self.qapp.processEvents()
+
+ tests = [((0, 0), (1, 1)),
+ ((1000, 1000), (1, 1)),
+ ((0, 0), (-1, -1)),
+ ((1000, 1000), (-1, -1))]
+
+ for origin, scale in tests:
+ with self.subTest(origin=origin, scale=scale):
+ self.plot.addImage(numpy.arange(1024**2).reshape(1024, 1024),
+ legend='test',
+ origin=origin,
+ scale=scale)
+ self.qapp.processEvents()
+
+ # Test draw rectangle #
+ toolButton = getQToolButtonFromAction(self.maskWidget.rectAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drag()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drag()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # Test draw polygon #
+ toolButton = getQToolButtonFromAction(self.maskWidget.polygonAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drawPolygon()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drawPolygon()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # Test draw pencil #
+ toolButton = getQToolButtonFromAction(self.maskWidget.pencilAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ self.maskWidget.pencilSpinBox.setValue(10)
+ self.qapp.processEvents()
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drawPencil()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drawPencil()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # Test no draw tool #
+ toolButton = getQToolButtonFromAction(self.maskWidget.browseAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ self.plot.clear()
+
+ def __loadSave(self, file_format):
+ """Plot with an image: test MaskToolsWidget operations"""
+ self.plot.addImage(numpy.arange(1024**2).reshape(1024, 1024),
+ legend='test')
+ self.qapp.processEvents()
+
+ # Draw a polygon mask
+ toolButton = getQToolButtonFromAction(self.maskWidget.polygonAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self._drawPolygon()
+
+ ref_mask = self.maskWidget.getSelectionMask()
+ self.assertFalse(numpy.all(numpy.equal(ref_mask, 0)))
+
+ with temp_dir() as tmp:
+ mask_filename = os.path.join(tmp, 'mask.' + file_format)
+ self.maskWidget.save(mask_filename, file_format)
+
+ self.maskWidget.resetSelectionMask()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ self.maskWidget.load(mask_filename)
+ self.assertTrue(numpy.all(numpy.equal(
+ self.maskWidget.getSelectionMask(), ref_mask)))
+
+ def testLoadSaveNpy(self):
+ self.__loadSave("npy")
+
+ def testLoadSaveFit2D(self):
+ if fabio is None:
+ self.skipTest("Fabio is missing")
+ self.__loadSave("msk")
+
+ def testSigMaskChangedEmitted(self):
+ self.plot.addImage(numpy.arange(512**2).reshape(512, 512),
+ legend='test')
+ self.plot.resetZoom()
+ self.qapp.processEvents()
+
+ l = []
+
+ def slot():
+ l.append(1)
+
+ self.maskWidget.sigMaskChanged.connect(slot)
+
+ # rectangle mask
+ toolButton = getQToolButtonFromAction(self.maskWidget.rectAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drag()
+
+ self.assertGreater(len(l), 0)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for TestClass in (TestMaskToolsWidget,):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testPlot.py b/silx/gui/plot/test/testPlot.py
new file mode 100644
index 0000000..25e7511
--- /dev/null
+++ b/silx/gui/plot/test/testPlot.py
@@ -0,0 +1,633 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 Plot"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import unittest
+from functools import reduce
+from silx.test.utils import ParametricTestCase
+
+import numpy
+
+from silx.gui.plot.Plot import Plot
+from silx.gui.plot.items.histogram import _getHistogramCurve, _computeEdges
+
+
+class TestPlot(unittest.TestCase):
+ """Basic tests of Plot without backend"""
+
+ def testPlotTitleLabels(self):
+ """Create a Plot and set the labels"""
+
+ plot = Plot(backend='none')
+
+ title, xlabel, ylabel = 'the title', 'x label', 'y label'
+ plot.setGraphTitle(title)
+ plot.setGraphXLabel(xlabel)
+ plot.setGraphYLabel(ylabel)
+
+ self.assertEqual(plot.getGraphTitle(), title)
+ self.assertEqual(plot.getGraphXLabel(), xlabel)
+ self.assertEqual(plot.getGraphYLabel(), ylabel)
+
+ def testAddNoRemove(self):
+ """add objects to the Plot"""
+
+ plot = Plot(backend='none')
+ plot.addCurve(x=(1, 2, 3), y=(3, 2, 1))
+ plot.addImage(numpy.arange(100.).reshape(10, -1))
+ plot.addItem(
+ numpy.array((1., 10.)), numpy.array((10., 10.)), shape="rectangle")
+ plot.addXMarker(10.)
+
+
+class TestPlotRanges(ParametricTestCase):
+ """Basic tests of Plot data ranges without backend"""
+
+ _getValidValues = {True: lambda ar: ar > 0,
+ False: lambda ar: numpy.ones(shape=ar.shape,
+ dtype=bool)}
+
+ @staticmethod
+ def _getRanges(arrays, are_logs):
+ gen = (TestPlotRanges._getValidValues[is_log](ar)
+ for (ar, is_log) in zip(arrays, are_logs))
+ indices = numpy.where(reduce(numpy.logical_and, gen))[0]
+ if len(indices) > 0:
+ ranges = [(ar[indices[0]], ar[indices[-1]]) for ar in arrays]
+ else:
+ ranges = [None] * len(arrays)
+
+ return ranges
+
+ @staticmethod
+ def _getRangesMinmax(ranges):
+ # TODO : error if None in ranges.
+ rangeMin = numpy.min([rng[0] for rng in ranges])
+ rangeMax = numpy.max([rng[1] for rng in ranges])
+ return rangeMin, rangeMax
+
+ def testDataRangeNoPlot(self):
+ """empty plot data range"""
+
+ plot = Plot(backend='none')
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ self.assertIsNone(dataRange.x)
+ self.assertIsNone(dataRange.y)
+ self.assertIsNone(dataRange.yright)
+
+ def testDataRangeLeft(self):
+ """left axis range"""
+
+ plot = Plot(backend='none')
+
+ xData = numpy.arange(10) - 4.9 # range : -4.9 , 4.1
+ yData = numpy.arange(10) - 6.9 # range : -6.9 , 2.1
+
+ plot.addCurve(x=xData,
+ y=yData,
+ legend='plot_0',
+ yaxis='left')
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ xRange, yRange = self._getRanges([xData, yData],
+ [logX, logY])
+ self.assertSequenceEqual(dataRange.x, xRange)
+ self.assertSequenceEqual(dataRange.y, yRange)
+ self.assertIsNone(dataRange.yright)
+
+ def testDataRangeRight(self):
+ """right axis range"""
+
+ plot = Plot(backend='none')
+ xData = numpy.arange(10) - 4.9 # range : -4.9 , 4.1
+ yData = numpy.arange(10) - 6.9 # range : -6.9 , 2.1
+ plot.addCurve(x=xData,
+ y=yData,
+ legend='plot_0',
+ yaxis='right')
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ xRange, yRange = self._getRanges([xData, yData],
+ [logX, logY])
+ self.assertSequenceEqual(dataRange.x, xRange)
+ self.assertIsNone(dataRange.y)
+ self.assertSequenceEqual(dataRange.yright, yRange)
+
+ def testDataRangeImage(self):
+ """image data range"""
+
+ origin = (-10, 25)
+ scale = (3., 8.)
+ image = numpy.arange(100.).reshape(20, 5)
+
+ plot = Plot(backend='none')
+ plot.addImage(image,
+ origin=origin, scale=scale)
+
+ xRange = numpy.array([0., image.shape[1] * scale[0]]) + origin[0]
+ yRange = numpy.array([0., image.shape[0] * scale[1]]) + origin[1]
+
+ ranges = {(False, False): (xRange, yRange),
+ (True, False): (None, None),
+ (True, True): (None, None),
+ (False, True): (None, None)}
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ xRange, yRange = ranges[logX, logY]
+ self.assertTrue(numpy.array_equal(dataRange.x, xRange),
+ msg='{0} != {1}'.format(dataRange.x, xRange))
+ self.assertTrue(numpy.array_equal(dataRange.y, yRange),
+ msg='{0} != {1}'.format(dataRange.y, yRange))
+ self.assertIsNone(dataRange.yright)
+
+ def testDataRangeLeftRight(self):
+ """right+left axis range"""
+
+ plot = Plot(backend='none')
+
+ xData_l = numpy.arange(10) - 0.9 # range : -0.9 , 8.1
+ yData_l = numpy.arange(10) - 1.9 # range : -1.9 , 7.1
+ plot.addCurve(x=xData_l,
+ y=yData_l,
+ legend='plot_l',
+ yaxis='left')
+
+ xData_r = numpy.arange(10) - 4.9 # range : -4.9 , 4.1
+ yData_r = numpy.arange(10) - 6.9 # range : -6.9 , 2.1
+ plot.addCurve(x=xData_r,
+ y=yData_r,
+ legend='plot_r',
+ yaxis='right')
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ xRangeL, yRangeL = self._getRanges([xData_l, yData_l],
+ [logX, logY])
+ xRangeR, yRangeR = self._getRanges([xData_r, yData_r],
+ [logX, logY])
+ xRangeLR = self._getRangesMinmax([xRangeL, xRangeR])
+ self.assertSequenceEqual(dataRange.x, xRangeLR)
+ self.assertSequenceEqual(dataRange.y, yRangeL)
+ self.assertSequenceEqual(dataRange.yright, yRangeR)
+
+ def testDataRangeCurveImage(self):
+ """right+left+image axis range"""
+
+ # overlapping ranges :
+ # image sets x min and y max
+ # plot_left sets y min
+ # plot_right sets x max (and yright)
+ plot = Plot(backend='none')
+
+ origin = (-10, 5)
+ scale = (3., 8.)
+ image = numpy.arange(100.).reshape(20, 5)
+
+ plot.addImage(image,
+ origin=origin, scale=scale, legend='image')
+
+ xData_l = numpy.arange(10) - 0.9 # range : -0.9 , 8.1
+ yData_l = numpy.arange(10) - 1.9 # range : -1.9 , 7.1
+ plot.addCurve(x=xData_l,
+ y=yData_l,
+ legend='plot_l',
+ yaxis='left')
+
+ xData_r = numpy.arange(10) + 4.1 # range : 4.1 , 13.1
+ yData_r = numpy.arange(10) - 0.9 # range : -0.9 , 8.1
+ plot.addCurve(x=xData_r,
+ y=yData_r,
+ legend='plot_r',
+ yaxis='right')
+
+ imgXRange = numpy.array([0., image.shape[1] * scale[0]]) + origin[0]
+ imgYRange = numpy.array([0., image.shape[0] * scale[1]]) + origin[1]
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ xRangeL, yRangeL = self._getRanges([xData_l, yData_l],
+ [logX, logY])
+ xRangeR, yRangeR = self._getRanges([xData_r, yData_r],
+ [logX, logY])
+ if logX or logY:
+ xRangeLR = self._getRangesMinmax([xRangeL, xRangeR])
+ else:
+ xRangeLR = self._getRangesMinmax([xRangeL,
+ xRangeR,
+ imgXRange])
+ yRangeL = self._getRangesMinmax([yRangeL, imgYRange])
+ self.assertSequenceEqual(dataRange.x, xRangeLR)
+ self.assertSequenceEqual(dataRange.y, yRangeL)
+ self.assertSequenceEqual(dataRange.yright, yRangeR)
+
+ def testDataRangeImageNegativeScaleX(self):
+ """image data range, negative scale"""
+
+ origin = (-10, 25)
+ scale = (-3., 8.)
+ image = numpy.arange(100.).reshape(20, 5)
+
+ plot = Plot(backend='none')
+ plot.addImage(image,
+ origin=origin, scale=scale)
+
+ xRange = numpy.array([0., image.shape[1] * scale[0]]) + origin[0]
+ xRange.sort() # negative scale!
+ yRange = numpy.array([0., image.shape[0] * scale[1]]) + origin[1]
+
+ ranges = {(False, False): (xRange, yRange),
+ (True, False): (None, None),
+ (True, True): (None, None),
+ (False, True): (None, None)}
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ xRange, yRange = ranges[logX, logY]
+ self.assertTrue(numpy.array_equal(dataRange.x, xRange),
+ msg='{0} != {1}'.format(dataRange.x, xRange))
+ self.assertTrue(numpy.array_equal(dataRange.y, yRange),
+ msg='{0} != {1}'.format(dataRange.y, yRange))
+ self.assertIsNone(dataRange.yright)
+
+ def testDataRangeImageNegativeScaleY(self):
+ """image data range, negative scale"""
+
+ origin = (-10, 25)
+ scale = (3., -8.)
+ image = numpy.arange(100.).reshape(20, 5)
+
+ plot = Plot(backend='none')
+ plot.addImage(image,
+ origin=origin, scale=scale)
+
+ xRange = numpy.array([0., image.shape[1] * scale[0]]) + origin[0]
+ yRange = numpy.array([0., image.shape[0] * scale[1]]) + origin[1]
+ yRange.sort() # negative scale!
+
+ ranges = {(False, False): (xRange, yRange),
+ (True, False): (None, None),
+ (True, True): (None, None),
+ (False, True): (None, None)}
+
+ for logX, logY in ((False, False),
+ (True, False),
+ (True, True),
+ (False, True),
+ (False, False)):
+ with self.subTest(logX=logX, logY=logY):
+ plot.setXAxisLogarithmic(logX)
+ plot.setYAxisLogarithmic(logY)
+ dataRange = plot.getDataRange()
+ xRange, yRange = ranges[logX, logY]
+ self.assertTrue(numpy.array_equal(dataRange.x, xRange),
+ msg='{0} != {1}'.format(dataRange.x, xRange))
+ self.assertTrue(numpy.array_equal(dataRange.y, yRange),
+ msg='{0} != {1}'.format(dataRange.y, yRange))
+ self.assertIsNone(dataRange.yright)
+
+ def testDataRangeHiddenCurve(self):
+ """curves with a hidden curve"""
+ plot = Plot(backend='none')
+ plot.addCurve((0, 1), (0, 1), legend='shown')
+ plot.addCurve((0, 1, 2), (5, 5, 5), legend='hidden')
+ range1 = plot.getDataRange()
+ self.assertEqual(range1.x, (0, 2))
+ self.assertEqual(range1.y, (0, 5))
+ plot.hideCurve('hidden')
+ range2 = plot.getDataRange()
+ self.assertEqual(range2.x, (0, 1))
+ self.assertEqual(range2.y, (0, 1))
+
+
+class TestPlotGetCurveImage(unittest.TestCase):
+ """Test of plot getCurve and getImage methods"""
+
+ def testGetCurve(self):
+ """Plot.getCurve and Plot.getActiveCurve tests"""
+
+ plot = Plot(backend='none')
+
+ # No curve
+ curve = plot.getCurve()
+ self.assertIsNone(curve) # No curve
+
+ plot.setActiveCurveHandling(True)
+ plot.addCurve(x=(0, 1), y=(0, 1), legend='curve 0')
+ plot.addCurve(x=(0, 1), y=(0, 1), legend='curve 1')
+ plot.addCurve(x=(0, 1), y=(0, 1), legend='curve 2')
+ plot.setActiveCurve('curve 0')
+
+ # Active curve
+ active = plot.getActiveCurve()
+ self.assertEqual(active.getLegend(), 'curve 0')
+ curve = plot.getCurve()
+ self.assertEqual(curve.getLegend(), 'curve 0')
+
+ # No active curve and curves
+ plot.setActiveCurveHandling(False)
+ active = plot.getActiveCurve()
+ self.assertIsNone(active) # No active curve
+ curve = plot.getCurve()
+ self.assertEqual(curve.getLegend(), 'curve 2') # Last added curve
+
+ # Last curve hidden
+ plot.hideCurve('curve 2', True)
+ curve = plot.getCurve()
+ self.assertEqual(curve.getLegend(), 'curve 1') # Last added curve
+
+ # All curves hidden
+ plot.hideCurve('curve 1', True)
+ plot.hideCurve('curve 0', True)
+ curve = plot.getCurve()
+ self.assertIsNone(curve)
+
+ def testGetCurveOldApi(self):
+ """old API Plot.getCurve and Plot.getActiveCurve tests"""
+
+ plot = Plot(backend='none')
+
+ # No curve
+ curve = plot.getCurve()
+ self.assertIsNone(curve) # No curve
+
+ plot.setActiveCurveHandling(True)
+ x = numpy.arange(10.).astype(numpy.float32)
+ y = x * x;
+ plot.addCurve(x=x, y=y, legend='curve 0', info=["whatever"])
+ plot.addCurve(x=x, y=2*x, legend='curve 1', info="anything")
+ plot.setActiveCurve('curve 0')
+
+ # Active curve (4 elements)
+ xOut, yOut, legend, info = plot.getActiveCurve()[:4]
+ self.assertEqual(legend, 'curve 0')
+ self.assertTrue(numpy.allclose(xOut, x), 'curve 0 wrong x data')
+ self.assertTrue(numpy.allclose(yOut, y), 'curve 0 wrong y data')
+
+ # Active curve (5 elements)
+ xOut, yOut, legend, info, params = plot.getCurve("curve 1")
+ self.assertEqual(legend, 'curve 1')
+ self.assertEqual(info, 'anything')
+ self.assertTrue(numpy.allclose(xOut, x), 'curve 1 wrong x data')
+ self.assertTrue(numpy.allclose(yOut, 2*x), 'curve 1 wrong y data')
+
+ def testGetImage(self):
+ """Plot.getImage and Plot.getActiveImage tests"""
+
+ plot = Plot(backend='none')
+
+ # No image
+ image = plot.getImage()
+ self.assertIsNone(image)
+
+ plot.addImage(((0, 1), (2, 3)), legend='image 0', replace=False)
+ plot.addImage(((0, 1), (2, 3)), legend='image 1', replace=False)
+
+ # Active image
+ active = plot.getActiveImage()
+ self.assertEqual(active.getLegend(), 'image 0')
+ image = plot.getImage()
+ self.assertEqual(image.getLegend(), 'image 0')
+
+ # No active image
+ plot.addImage(((0, 1), (2, 3)), legend='image 2', replace=False)
+ plot.setActiveImage(None)
+ active = plot.getActiveImage()
+ self.assertIsNone(active)
+ image = plot.getImage()
+ self.assertEqual(image.getLegend(), 'image 2')
+
+ # Active image
+ plot.setActiveImage('image 1')
+ active = plot.getActiveImage()
+ self.assertEqual(active.getLegend(), 'image 1')
+ image = plot.getImage()
+ self.assertEqual(image.getLegend(), 'image 1')
+
+ def testGetImageOldApi(self):
+ """Plot.getImage and Plot.getActiveImage old API tests"""
+
+ plot = Plot(backend='none')
+
+ # No image
+ image = plot.getImage()
+ self.assertIsNone(image)
+
+ image = numpy.arange(10).astype(numpy.float32)
+ image.shape = 5, 2
+
+ plot.addImage(image, legend='image 0', info=["Hi!"], replace=False)
+
+ # Active image
+ data, legend, info, something, params = plot.getActiveImage()
+ self.assertEqual(legend, 'image 0')
+ self.assertEqual(info, ["Hi!"])
+ self.assertTrue(numpy.allclose(data, image), "image 0 data not correct")
+
+ def testGetAllImages(self):
+ """Plot.getAllImages test"""
+
+ plot = Plot(backend='none')
+
+ # No image
+ images = plot.getAllImages()
+ self.assertEqual(len(images), 0)
+
+ # 2 images
+ data = numpy.arange(100).reshape(10, 10)
+ plot.addImage(data, legend='1', replace=False)
+ plot.addImage(data, origin=(10, 10), legend='2', replace=False)
+ images = plot.getAllImages(just_legend=True)
+ self.assertEqual(list(images), ['1', '2'])
+ images = plot.getAllImages(just_legend=False)
+ self.assertEqual(len(images), 2)
+ self.assertEqual(images[0].getLegend(), '1')
+ self.assertEqual(images[1].getLegend(), '2')
+
+
+class TestPlotAddScatter(unittest.TestCase):
+ """Test of plot addScatter"""
+
+ def testAddGetScatter(self):
+
+ plot = Plot(backend='none')
+
+ # No curve
+ scatter = plot._getItem(kind="scatter")
+ self.assertIsNone(scatter) # No curve
+
+ plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 0')
+ plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 1')
+ plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 2')
+ plot._setActiveItem('scatter', 'scatter 0')
+
+ # Active scatter
+ active = plot._getActiveItem(kind='scatter')
+ self.assertEqual(active.getLegend(), 'scatter 0')
+
+ # check default values
+ self.assertAlmostEqual(active.getSymbolSize(), active._DEFAULT_SYMBOL_SIZE)
+ self.assertEqual(active.getSymbol(), "o")
+ self.assertAlmostEqual(active.getAlpha(), 1.0)
+
+ # modify parameters
+ active.setSymbolSize(20.5)
+ active.setSymbol("d")
+ active.setAlpha(0.777)
+
+ s0 = plot.getScatter("scatter 0")
+
+ self.assertAlmostEqual(s0.getSymbolSize(), 20.5)
+ self.assertEqual(s0.getSymbol(), "d")
+ self.assertAlmostEqual(s0.getAlpha(), 0.777)
+
+ scatter1 = plot._getItem(kind='scatter', legend='scatter 1')
+ self.assertEqual(scatter1.getLegend(), 'scatter 1')
+
+ def testGetAllScatters(self):
+ """Plot.getAllImages test"""
+
+ plot = Plot(backend='none')
+
+ scatters = plot._getItems(kind='scatter')
+ self.assertEqual(len(scatters), 0)
+
+ plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 0')
+ plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 1')
+ plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 2')
+
+ scatters = plot._getItems(kind='scatter')
+ self.assertEqual(len(scatters), 3)
+ self.assertEqual(scatters[0].getLegend(), 'scatter 0')
+ self.assertEqual(scatters[2].getLegend(), 'scatter 2')
+
+ scatters = plot._getItems(kind='scatter', just_legend=True)
+ self.assertEqual(len(scatters), 3)
+ self.assertEqual(list(scatters), ['scatter 0', 'scatter 1', 'scatter 2'])
+
+
+class TestPlotHistogram(unittest.TestCase):
+ """Basic tests for histogram."""
+
+ def testEdges(self):
+ x = numpy.array([0, 1, 2])
+ edgesRight = numpy.array([0, 1, 2, 3])
+ edgesLeft = numpy.array([-1, 0, 1, 2])
+ edgesCenter = numpy.array([-0.5, 0.5, 1.5, 2.5])
+
+ # testing x values for right
+ edges = _computeEdges(x, 'right')
+ numpy.testing.assert_array_equal(edges, edgesRight)
+
+ edges = _computeEdges(x, 'center')
+ numpy.testing.assert_array_equal(edges, edgesCenter)
+
+ edges = _computeEdges(x, 'left')
+ numpy.testing.assert_array_equal(edges, edgesLeft)
+
+ def testHistogramCurve(self):
+ y = numpy.array([3, 2, 5])
+ edges = numpy.array([0, 1, 2, 3])
+
+ xHisto, yHisto = _getHistogramCurve(y, edges)
+ numpy.testing.assert_array_equal(
+ yHisto, numpy.array([3, 3, 2, 2, 5, 5]))
+
+ y = numpy.array([-3, 2, 5, 0])
+ edges = numpy.array([-2, -1, 0, 1, 2])
+ xHisto, yHisto = _getHistogramCurve(y, edges)
+ numpy.testing.assert_array_equal(
+ yHisto, numpy.array([-3, -3, 2, 2, 5, 5, 0, 0]))
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for TestClass in (TestPlot, TestPlotRanges, TestPlotGetCurveImage,
+ TestPlotHistogram, TestPlotAddScatter):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testPlotInteraction.py b/silx/gui/plot/test/testPlotInteraction.py
new file mode 100644
index 0000000..25f57a9
--- /dev/null
+++ b/silx/gui/plot/test/testPlotInteraction.py
@@ -0,0 +1,167 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 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.
+#
+# ###########################################################################*/
+"""Tests of plot interaction, through a PlotWidget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "13/10/2016"
+
+
+import unittest
+from silx.gui import qt
+from silx.gui.plot.test.testPlotWidget import _PlotWidgetTest
+
+
+class _SignalDump(object):
+ """Callable object that store passed arguments in a list"""
+
+ def __init__(self):
+ self._received = []
+
+ def __call__(self, *args):
+ self._received.append(args)
+
+ @property
+ def received(self):
+ """Return a shallow copy of the list of received arguments"""
+ return list(self._received)
+
+
+class TestSelectPolygon(_PlotWidgetTest):
+ """Test polygon selection interaction"""
+
+ def _interactionModeChanged(self, source):
+ """Check that source received in event is the correct one"""
+ self.assertEqual(source, self)
+
+ def _draw(self, polygon):
+ """Draw a polygon in the plot
+
+ :param polygon: List of points (x, y) of the polygon (not closed)
+ """
+ plot = self.plot.centralWidget()
+
+ dump = _SignalDump()
+ self.plot.sigPlotSignal.connect(dump)
+
+ for pos in polygon:
+ self.mouseMove(plot, pos=pos)
+ btn = qt.Qt.LeftButton if pos != polygon[-1] else qt.Qt.RightButton
+ self.mouseClick(plot, btn, pos=pos)
+
+ self.plot.sigPlotSignal.disconnect(dump)
+ return [args[0] for args in dump.received]
+
+ def test(self):
+ """Test draw polygons + events"""
+ self.plot.sigInteractiveModeChanged.connect(
+ self._interactionModeChanged)
+
+ self.plot.setInteractiveMode(
+ 'draw', shape='polygon', label='test', source=self)
+ interaction = self.plot.getInteractiveMode()
+
+ self.assertEqual(interaction['mode'], 'draw')
+ self.assertEqual(interaction['shape'], 'polygon')
+
+ self.plot.sigInteractiveModeChanged.disconnect(
+ self._interactionModeChanged)
+
+ plot = self.plot.centralWidget()
+ xCenter, yCenter = plot.width() // 2, plot.height() // 2
+ offset = min(plot.width(), plot.height()) // 10
+
+ # Star polygon
+ star = [(xCenter, yCenter + offset),
+ (xCenter - offset, yCenter - offset),
+ (xCenter + offset, yCenter),
+ (xCenter - offset, yCenter),
+ (xCenter + offset, yCenter - offset)]
+
+ # Draw while dumping signals
+ events = self._draw(star)
+
+ # Test last event
+ drawEvents = [event for event in events
+ if event['event'].startswith('drawing')]
+ self.assertEqual(drawEvents[-1]['event'], 'drawingFinished')
+ self.assertEqual(len(drawEvents[-1]['points']), 6)
+
+ # Large square
+ largeSquare = [(xCenter - offset, yCenter - offset),
+ (xCenter + offset, yCenter - offset),
+ (xCenter + offset, yCenter + offset),
+ (xCenter - offset, yCenter + offset)]
+
+ # Draw while dumping signals
+ events = self._draw(largeSquare)
+
+ # Test last event
+ drawEvents = [event for event in events
+ if event['event'].startswith('drawing')]
+ self.assertEqual(drawEvents[-1]['event'], 'drawingFinished')
+ self.assertEqual(len(drawEvents[-1]['points']), 5)
+
+ # Rectangle too thin along X: Some points are ignored
+ thinRectX = [(xCenter, yCenter - offset),
+ (xCenter, yCenter + offset),
+ (xCenter + 1, yCenter + offset),
+ (xCenter + 1, yCenter - offset)]
+
+ # Draw while dumping signals
+ events = self._draw(thinRectX)
+
+ # Test last event
+ drawEvents = [event for event in events
+ if event['event'].startswith('drawing')]
+ self.assertEqual(drawEvents[-1]['event'], 'drawingFinished')
+ self.assertEqual(len(drawEvents[-1]['points']), 3)
+
+ # Rectangle too thin along Y: Some points are ignored
+ thinRectY = [(xCenter - offset, yCenter),
+ (xCenter + offset, yCenter),
+ (xCenter + offset, yCenter + 1),
+ (xCenter - offset, yCenter + 1)]
+
+ # Draw while dumping signals
+ events = self._draw(thinRectY)
+
+ # Test last event
+ drawEvents = [event for event in events
+ if event['event'].startswith('drawing')]
+ self.assertEqual(drawEvents[-1]['event'], 'drawingFinished')
+ self.assertEqual(len(drawEvents[-1]['points']), 3)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for TestClass in (TestSelectPolygon,):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testPlotTools.py b/silx/gui/plot/test/testPlotTools.py
new file mode 100644
index 0000000..1d5e148
--- /dev/null
+++ b/silx/gui/plot/test/testPlotTools.py
@@ -0,0 +1,203 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 PlotTools"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import numpy
+import unittest
+
+from silx.test.utils import ParametricTestCase, TestLogging
+from silx.gui.test.utils import (
+ qWaitForWindowExposedAndActivate, TestCaseQt, getQToolButtonFromAction)
+from silx.gui import qt
+from silx.gui.plot import Plot2D, PlotWindow, PlotTools
+
+
+# Makes sure a QApplication exists
+_qapp = qt.QApplication.instance() or qt.QApplication([])
+
+
+def _tearDownDocTest(docTest):
+ """Tear down to use for test from docstring.
+
+ Checks that plot widget is displayed
+ """
+ plot = docTest.globs['plot']
+ qWaitForWindowExposedAndActivate(plot)
+ plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ plot.close()
+ del plot
+
+# Disable doctest because of
+# "NameError: name 'numpy' is not defined"
+#
+# import doctest
+# positionInfoTestSuite = doctest.DocTestSuite(
+# PlotTools, tearDown=_tearDownDocTest,
+# optionflags=doctest.ELLIPSIS)
+# """Test suite of tests from PlotTools docstrings.
+#
+# Test PositionInfo and ProfileToolBar docstrings.
+# """
+
+
+class TestPositionInfo(TestCaseQt):
+ """Tests for PositionInfo widget."""
+
+ def setUp(self):
+ super(TestPositionInfo, self).setUp()
+ self.plot = PlotWindow()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+ self.mouseMove(self.plot, pos=(1, 1))
+ self.qapp.processEvents()
+ self.qWait(100)
+
+ def tearDown(self):
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+
+ super(TestPositionInfo, self).tearDown()
+
+ def _test(self, positionWidget, converterNames, **kwargs):
+ """General test of PositionInfo.
+
+ - Add it to a toolbar and
+ - Move mouse around the center of the PlotWindow.
+ """
+ toolBar = qt.QToolBar()
+ self.plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar)
+
+ toolBar.addWidget(positionWidget)
+
+ converters = positionWidget.getConverters()
+ self.assertEqual(len(converters), len(converterNames))
+ for index, name in enumerate(converterNames):
+ self.assertEqual(converters[index][0], name)
+
+ with TestLogging(PlotTools.__name__, **kwargs):
+ # Move mouse to center
+ self.mouseMove(self.plot)
+ self.mouseMove(self.plot, pos=(1, 1))
+ self.qapp.processEvents()
+ self.qWait(100)
+
+ def testDefaultConverters(self):
+ """Test PositionInfo with default converters"""
+ positionWidget = PlotTools.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 = PlotTools.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 = PlotTools.PositionInfo(
+ plot=self.plot,
+ converters=[('Exception', raiseException)])
+ self._test(positionWidget, ['Exception'], error=2)
+
+
+class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase):
+ """Tests for ProfileToolBar widget."""
+
+ def setUp(self):
+ super(TestPixelIntensitiesHisto, self).setUp()
+ self.image = numpy.random.rand(100, 100)
+ self.plotImage = Plot2D()
+ self.plotImage.getIntensityHistogramAction().setVisible(True)
+
+ def tearDown(self):
+ del self.plotImage
+ super(TestPixelIntensitiesHisto, self).tearDown()
+
+ def testShowAndHide(self):
+ """Simple test that the plot is showing and hiding when activating the
+ action"""
+ self.plotImage.addImage(self.image, origin=(0, 0), legend='sino')
+ self.plotImage.show()
+
+ histoAction = self.plotImage.getIntensityHistogramAction()
+
+ # test the pixel intensity diagram is showing
+ button = getQToolButtonFromAction(histoAction)
+ self.assertIsNot(button, None)
+ self.mouseMove(button)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qapp.processEvents()
+ self.assertTrue(histoAction.getHistogramPlotWidget().isVisible())
+
+ # test the pixel intensity diagram is hiding
+ self.qapp.setActiveWindow(self.plotImage)
+ self.qapp.processEvents()
+ self.mouseMove(button)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qapp.processEvents()
+ self.assertFalse(histoAction.getHistogramPlotWidget().isVisible())
+
+ def testImageFormatInput(self):
+ """Test multiple type as image input"""
+ typesToTest = [numpy.uint8, numpy.int8, numpy.int16, numpy.int32,
+ numpy.float32, numpy.float64]
+ self.plotImage.addImage(self.image, origin=(0, 0), legend='sino')
+ self.plotImage.show()
+ button = getQToolButtonFromAction(
+ self.plotImage.getIntensityHistogramAction())
+ self.mouseMove(button)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qapp.processEvents()
+ for typeToTest in typesToTest:
+ with self.subTest(typeToTest=typeToTest):
+ self.plotImage.addImage(self.image.astype(typeToTest),
+ origin=(0, 0), legend='sino')
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ # test_suite.addTest(positionInfoTestSuite)
+ for testClass in (TestPositionInfo, TestPixelIntensitiesHisto):
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ testClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
new file mode 100644
index 0000000..2de18a8
--- /dev/null
+++ b/silx/gui/plot/test/testPlotWidget.py
@@ -0,0 +1,967 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 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 PlotWidget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import unittest
+
+import numpy
+
+from silx.test.utils import ParametricTestCase
+from silx.gui.test.utils import TestCaseQt
+
+from silx.gui import qt
+from silx.gui.plot import PlotWidget
+
+
+SIZE = 1024
+"""Size of the test image"""
+
+DATA_2D = numpy.arange(SIZE ** 2).reshape(SIZE, SIZE)
+"""Image data set"""
+
+
+class _PlotWidgetTest(TestCaseQt):
+ """Base class for tests of PlotWidget, not a TestCase in itself.
+
+ plot attribute is the PlotWidget created for the test.
+ """
+
+ def setUp(self):
+ super(_PlotWidgetTest, self).setUp()
+ self.plot = PlotWidget()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(_PlotWidgetTest, self).tearDown()
+
+
+class TestPlotWidget(_PlotWidgetTest, ParametricTestCase):
+ """Basic tests for PlotWidget"""
+
+ def testShow(self):
+ """Most basic test"""
+ pass
+
+ def testSetTitleLabels(self):
+ """Set title and axes labels"""
+
+ title, xlabel, ylabel = 'the title', 'x label', 'y label'
+ self.plot.setGraphTitle(title)
+ self.plot.setGraphXLabel(xlabel)
+ self.plot.setGraphYLabel(ylabel)
+ self.qapp.processEvents()
+
+ self.assertEqual(self.plot.getGraphTitle(), title)
+ self.assertEqual(self.plot.getGraphXLabel(), xlabel)
+ self.assertEqual(self.plot.getGraphYLabel(), ylabel)
+
+ def testChangeLimitsWithAspectRatio(self):
+ def checkLimits(expectedXLim=None, expectedYLim=None,
+ expectedRatio=None):
+ xlim = self.plot.getGraphXLimits()
+ ylim = self.plot.getGraphYLimits()
+ ratio = abs(xlim[1] - xlim[0]) / abs(ylim[1] - ylim[0])
+
+ if expectedXLim is not None:
+ self.assertEqual(expectedXLim, xlim)
+
+ if expectedYLim is not None:
+ self.assertEqual(expectedYLim, ylim)
+
+ if expectedRatio is not None:
+ self.assertTrue(
+ numpy.allclose(expectedRatio, ratio, atol=0.01))
+
+ self.plot.setKeepDataAspectRatio()
+ self.qapp.processEvents()
+ xlim = self.plot.getGraphXLimits()
+ ylim = self.plot.getGraphYLimits()
+ defaultRatio = abs(xlim[1] - xlim[0]) / abs(ylim[1] - ylim[0])
+
+ self.plot.setGraphXLimits(1., 10.)
+ checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio)
+ self.qapp.processEvents()
+ checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio)
+
+ self.plot.setGraphYLimits(1., 10.)
+ checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio)
+ self.qapp.processEvents()
+ checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio)
+
+
+class TestPlotImage(_PlotWidgetTest, ParametricTestCase):
+ """Basic tests for addImage"""
+
+ def setUp(self):
+ super(TestPlotImage, self).setUp()
+
+ self.plot.setGraphYLabel('Rows')
+ self.plot.setGraphXLabel('Columns')
+
+ def testPlotColormapTemperature(self):
+ self.plot.setGraphTitle('Temp. Linear')
+
+ colormap = {'name': 'temperature', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap)
+
+ def testPlotColormapGray(self):
+ self.plot.setKeepDataAspectRatio(False)
+ self.plot.setGraphTitle('Gray Linear')
+
+ colormap = {'name': 'gray', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap)
+
+ def testPlotColormapTemperatureLog(self):
+ self.plot.setGraphTitle('Temp. Log')
+
+ colormap = {'name': 'temperature', 'normalization': 'log',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap)
+
+ def testPlotRgbRgba(self):
+ self.plot.setKeepDataAspectRatio(False)
+ self.plot.setGraphTitle('RGB + RGBA')
+
+ rgb = numpy.array(
+ (((0, 0, 0), (128, 0, 0), (255, 0, 0)),
+ ((0, 128, 0), (0, 128, 128), (0, 128, 256))),
+ dtype=numpy.uint8)
+
+ self.plot.addImage(rgb, legend="rgb",
+ origin=(0, 0), scale=(10, 10),
+ replace=False, resetzoom=False)
+
+ rgba = numpy.array(
+ (((0, 0, 0, .5), (.5, 0, 0, 1), (1, 0, 0, .5)),
+ ((0, .5, 0, 1), (0, .5, .5, 1), (0, 1, 1, .5))),
+ dtype=numpy.float32)
+
+ self.plot.addImage(rgba, legend="rgba",
+ origin=(5, 5), scale=(10, 10),
+ replace=False, resetzoom=False)
+
+ self.plot.resetZoom()
+
+ def testPlotColormapCustom(self):
+ self.plot.setKeepDataAspectRatio(False)
+ self.plot.setGraphTitle('Custom colormap')
+
+ colormap = {'name': None, 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0,
+ 'colors': ((0., 0., 0.), (1., 0., 0.),
+ (0., 1., 0.), (0., 0., 1.))}
+ self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap,
+ replace=False, resetzoom=False)
+
+ colormap = {'name': None, 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0,
+ 'colors': numpy.array(
+ ((0, 0, 0, 0), (0, 0, 0, 128),
+ (128, 128, 128, 128), (255, 255, 255, 255)),
+ dtype=numpy.uint8)}
+ self.plot.addImage(DATA_2D, legend="image 2", colormap=colormap,
+ origin=(DATA_2D.shape[0], 0),
+ replace=False, resetzoom=False)
+ self.plot.resetZoom()
+
+ def testImageOriginScale(self):
+ """Test of image with different origin and scale"""
+ self.plot.setGraphTitle('origin and scale')
+
+ tests = [ # (origin, scale)
+ ((10, 20), (1, 1)),
+ ((10, 20), (-1, -1)),
+ ((-10, 20), (2, 1)),
+ ((10, -20), (-1, -2)),
+ (100, 2),
+ (-100, (1, 1)),
+ ((10, 20), 2),
+ ]
+
+ for origin, scale in tests:
+ with self.subTest(origin=origin, scale=scale):
+ self.plot.addImage(DATA_2D, origin=origin, scale=scale)
+
+ try:
+ ox, oy = origin
+ except TypeError:
+ ox, oy = origin, origin
+ try:
+ sx, sy = scale
+ except TypeError:
+ sx, sy = scale, scale
+ xbounds = ox, ox + DATA_2D.shape[1] * sx
+ ybounds = oy, oy + DATA_2D.shape[0] * sy
+
+ # Check limits without aspect ratio
+ xmin, xmax = self.plot.getGraphXLimits()
+ ymin, ymax = self.plot.getGraphYLimits()
+ self.assertEqual(xmin, min(xbounds))
+ self.assertEqual(xmax, max(xbounds))
+ self.assertEqual(ymin, min(ybounds))
+ self.assertEqual(ymax, max(ybounds))
+
+ # Check limits with aspect ratio
+ self.plot.setKeepDataAspectRatio(True)
+ xmin, xmax = self.plot.getGraphXLimits()
+ ymin, ymax = self.plot.getGraphYLimits()
+ self.assertTrue(xmin <= min(xbounds))
+ self.assertTrue(xmax >= max(xbounds))
+ self.assertTrue(ymin <= min(ybounds))
+ self.assertTrue(ymax >= max(ybounds))
+
+ self.plot.setKeepDataAspectRatio(False) # Reset aspect ratio
+ self.plot.clear()
+ self.plot.resetZoom()
+
+
+class TestPlotCurve(_PlotWidgetTest):
+ """Basic tests for addCurve."""
+
+ # Test data sets
+ xData = numpy.arange(1000)
+ yData = -500 + 100 * numpy.sin(xData)
+ xData2 = xData + 1000
+ yData2 = xData - 1000 + 200 * numpy.random.random(1000)
+
+ def setUp(self):
+ super(TestPlotCurve, self).setUp()
+ self.plot.setGraphTitle('Curve')
+ self.plot.setGraphYLabel('Rows')
+ self.plot.setGraphXLabel('Columns')
+
+ self.plot.setActiveCurveHandling(False)
+
+ def testPlotCurveColorFloat(self):
+ color = numpy.array(numpy.random.random(3 * 1000),
+ dtype=numpy.float32).reshape(1000, 3)
+
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve 1",
+ replace=False, resetzoom=False,
+ color=color,
+ linestyle="", symbol="s")
+ self.plot.addCurve(self.xData2, self.yData2,
+ legend="curve 2",
+ replace=False, resetzoom=False,
+ color='green', linestyle="-", symbol='o')
+ self.plot.resetZoom()
+
+ def testPlotCurveColorByte(self):
+ color = numpy.array(255 * numpy.random.random(3 * 1000),
+ dtype=numpy.uint8).reshape(1000, 3)
+
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve 1",
+ replace=False, resetzoom=False,
+ color=color,
+ linestyle="", symbol="s")
+ self.plot.addCurve(self.xData2, self.yData2,
+ legend="curve 2",
+ replace=False, resetzoom=False,
+ color='green', linestyle="-", symbol='o')
+ self.plot.resetZoom()
+
+ def testPlotCurveColors(self):
+ color = numpy.array(numpy.random.random(3 * 1000),
+ dtype=numpy.float32).reshape(1000, 3)
+
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve 2",
+ replace=False, resetzoom=False,
+ color=color, linestyle="-", symbol='o')
+ self.plot.resetZoom()
+
+
+class TestPlotMarker(_PlotWidgetTest):
+ """Basic tests for add*Marker"""
+
+ def setUp(self):
+ super(TestPlotMarker, self).setUp()
+ self.plot.setGraphYLabel('Rows')
+ self.plot.setGraphXLabel('Columns')
+
+ self.plot.setXAxisAutoScale(False)
+ self.plot.setYAxisAutoScale(False)
+ self.plot.setKeepDataAspectRatio(False)
+ self.plot.setLimits(0., 100., -100., 100.)
+
+ def testPlotMarkerX(self):
+ self.plot.setGraphTitle('Markers X')
+
+ markers = [
+ (10., 'blue', False, False),
+ (20., 'red', False, False),
+ (40., 'green', True, False),
+ (60., 'gray', True, True),
+ (80., 'black', False, True),
+ ]
+
+ for x, color, select, drag in markers:
+ name = str(x)
+ if select:
+ name += " sel."
+ if drag:
+ name += " drag"
+ self.plot.addXMarker(x, name, name, color, select, drag)
+ self.plot.resetZoom()
+
+ def testPlotMarkerY(self):
+ self.plot.setGraphTitle('Markers Y')
+
+ markers = [
+ (-50., 'blue', False, False),
+ (-30., 'red', False, False),
+ (0., 'green', True, False),
+ (10., 'gray', True, True),
+ (80., 'black', False, True),
+ ]
+
+ for y, color, select, drag in markers:
+ name = str(y)
+ if select:
+ name += " sel."
+ if drag:
+ name += " drag"
+ self.plot.addYMarker(y, name, name, color, select, drag)
+ self.plot.resetZoom()
+
+ def testPlotMarkerPt(self):
+ self.plot.setGraphTitle('Markers Pt')
+
+ markers = [
+ (10., -50., 'blue', False, False),
+ (40., -30., 'red', False, False),
+ (50., 0., 'green', True, False),
+ (50., 20., 'gray', True, True),
+ (70., 50., 'black', False, True),
+ ]
+ for x, y, color, select, drag in markers:
+ name = "{0},{1}".format(x, y)
+ if select:
+ name += " sel."
+ if drag:
+ name += " drag"
+ self.plot.addMarker(x, y, name, name, color, select, drag)
+
+ self.plot.resetZoom()
+
+ def testPlotMarkerWithoutLegend(self):
+ self.plot.setGraphTitle('Markers without legend')
+ self.plot.setYAxisInverted(True)
+
+ # Markers without legend
+ self.plot.addMarker(10, 10)
+ self.plot.addMarker(10, 20)
+ self.plot.addMarker(40, 50, text='test', symbol=None)
+ self.plot.addMarker(40, 50, text='test', symbol='+')
+ self.plot.addXMarker(25)
+ self.plot.addXMarker(35)
+ self.plot.addXMarker(45, text='test')
+ self.plot.addYMarker(55)
+ self.plot.addYMarker(65)
+ self.plot.addYMarker(75, text='test')
+
+ self.plot.resetZoom()
+
+
+# TestPlotItem ################################################################
+
+class TestPlotItem(_PlotWidgetTest):
+ """Basic tests for addItem."""
+
+ # Polygon coordinates and color
+ polygons = [ # legend, x coords, y coords, color
+ ('triangle', numpy.array((10, 30, 50)),
+ numpy.array((55, 70, 55)), 'red'),
+ ('square', numpy.array((10, 10, 50, 50)),
+ numpy.array((10, 50, 50, 10)), 'green'),
+ ('star', numpy.array((60, 70, 80, 60, 80)),
+ numpy.array((25, 50, 25, 40, 40)), 'blue'),
+ ]
+
+ # Rectangle coordinantes and color
+ rectangles = [ # legend, x coords, y coords, color
+ ('square 1', numpy.array((1., 10.)),
+ numpy.array((1., 10.)), 'red'),
+ ('square 2', numpy.array((10., 20.)),
+ numpy.array((10., 20.)), 'green'),
+ ('square 3', numpy.array((20., 30.)),
+ numpy.array((20., 30.)), 'blue'),
+ ('rect 1', numpy.array((1., 30.)),
+ numpy.array((35., 40.)), 'black'),
+ ('line h', numpy.array((1., 30.)),
+ numpy.array((45., 45.)), 'darkRed'),
+ ]
+
+ def setUp(self):
+ super(TestPlotItem, self).setUp()
+
+ self.plot.setGraphYLabel('Rows')
+ self.plot.setGraphXLabel('Columns')
+ self.plot.setXAxisAutoScale(False)
+ self.plot.setYAxisAutoScale(False)
+ self.plot.setKeepDataAspectRatio(False)
+ self.plot.setLimits(0., 100., -100., 100.)
+
+ def testPlotItemPolygonFill(self):
+ self.plot.setGraphTitle('Item Fill')
+
+ for legend, xList, yList, color in self.polygons:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=True, color=color)
+ self.plot.resetZoom()
+
+ def testPlotItemPolygonNoFill(self):
+ self.plot.setGraphTitle('Item No Fill')
+
+ for legend, xList, yList, color in self.polygons:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=False, color=color)
+ self.plot.resetZoom()
+
+ def testPlotItemRectangleFill(self):
+ self.plot.setGraphTitle('Rectangle Fill')
+
+ for legend, xList, yList, color in self.rectangles:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=True, color=color)
+ self.plot.resetZoom()
+
+ def testPlotItemRectangleNoFill(self):
+ self.plot.setGraphTitle('Rectangle No Fill')
+
+ for legend, xList, yList, color in self.rectangles:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=False, color=color)
+ self.plot.resetZoom()
+
+
+class TestPlotActiveCurveImage(_PlotWidgetTest):
+ """Basic tests for active image handling"""
+
+ def testActiveCurveAndLabels(self):
+ # Active curve handling off, no label change
+ self.plot.setActiveCurveHandling(False)
+ self.plot.setGraphXLabel('XLabel')
+ self.plot.setGraphYLabel('YLabel')
+ self.plot.addCurve((1, 2), (1, 2))
+ self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
+ self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+
+ self.plot.addCurve((1, 2), (2, 3), xlabel='x1', ylabel='y1')
+ self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
+ self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+
+ self.plot.clear()
+ self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
+ self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+
+ # Active curve handling on, label changes
+ self.plot.setActiveCurveHandling(True)
+ self.plot.setGraphXLabel('XLabel')
+ self.plot.setGraphYLabel('YLabel')
+
+ # labels changed as active curve
+ self.plot.addCurve((1, 2), (1, 2), legend='1',
+ xlabel='x1', ylabel='y1')
+ self.assertEqual(self.plot.getGraphXLabel(), 'x1')
+ self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+
+ # labels not changed as not active curve
+ self.plot.addCurve((1, 2), (2, 3), legend='2')
+ self.assertEqual(self.plot.getGraphXLabel(), 'x1')
+ self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+
+ # labels changed
+ self.plot.setActiveCurve('2')
+ self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
+ self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+
+ self.plot.setActiveCurve('1')
+ self.assertEqual(self.plot.getGraphXLabel(), 'x1')
+ self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+
+ self.plot.clear()
+ self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
+ self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+
+ def testActiveImageAndLabels(self):
+ # Active image handling always on, no API for toggling it
+ self.plot.setGraphXLabel('XLabel')
+ self.plot.setGraphYLabel('YLabel')
+
+ # labels changed as active curve
+ self.plot.addImage(numpy.arange(100).reshape(10, 10), replace=False,
+ legend='1', xlabel='x1', ylabel='y1')
+ self.assertEqual(self.plot.getGraphXLabel(), 'x1')
+ self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+
+ # labels not changed as not active curve
+ self.plot.addImage(numpy.arange(100).reshape(10, 10), replace=False,
+ legend='2')
+ self.assertEqual(self.plot.getGraphXLabel(), 'x1')
+ self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+
+ # labels changed
+ self.plot.setActiveImage('2')
+ self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
+ self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+
+ self.plot.setActiveImage('1')
+ self.assertEqual(self.plot.getGraphXLabel(), 'x1')
+ self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+
+ self.plot.clear()
+ self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
+ self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+
+
+##############################################################################
+# Log
+##############################################################################
+
+class TestPlotEmptyLog(_PlotWidgetTest):
+ """Basic tests for log plot"""
+ def testEmptyPlotTitleLabelsLog(self):
+ self.plot.setGraphTitle('Empty Log Log')
+ self.plot.setGraphXLabel('X')
+ self.plot.setGraphYLabel('Y')
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setYAxisLogarithmic(True)
+ self.plot.resetZoom()
+
+
+class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
+ """Basic tests for addCurve with log scale axes"""
+
+ # Test data
+ xData = numpy.arange(1000) + 1
+ yData = xData ** 2
+
+ def _setLabels(self):
+ self.plot.setGraphXLabel('X')
+ self.plot.setGraphYLabel('X * X')
+
+ def testPlotCurveLogX(self):
+ self._setLabels()
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setGraphTitle('Curve X: Log Y: Linear')
+
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=True,
+ color='green', linestyle="-", symbol='o')
+
+ def testPlotCurveLogY(self):
+ self._setLabels()
+ self.plot.setYAxisLogarithmic(True)
+
+ self.plot.setGraphTitle('Curve X: Linear Y: Log')
+
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=True,
+ color='green', linestyle="-", symbol='o')
+
+ def testPlotCurveLogXY(self):
+ self._setLabels()
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setYAxisLogarithmic(True)
+
+ self.plot.setGraphTitle('Curve X: Log Y: Log')
+
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=True,
+ color='green', linestyle="-", symbol='o')
+
+ def testPlotCurveErrorLogXY(self):
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setYAxisLogarithmic(True)
+
+ # Every second error leads to negative number
+ errors = numpy.ones_like(self.xData)
+ errors[::2] = self.xData[::2] + 1
+
+ tests = [ # name, xerror, yerror
+ ('xerror=3', 3, None),
+ ('xerror=N array', errors, None),
+ ('xerror=Nx1 array', errors.reshape(len(errors), 1), None),
+ ('xerror=2xN array', numpy.array((errors, errors)), None),
+ ('yerror=6', None, 6),
+ ('yerror=N array', None, errors ** 2),
+ ('yerror=Nx1 array', None, (errors ** 2).reshape(len(errors), 1)),
+ ('yerror=2xN array', None, numpy.array((errors, errors)) ** 2),
+ ]
+
+ for name, xError, yError in tests:
+ with self.subTest(name):
+ self.plot.setGraphTitle(name)
+ self.plot.addCurve(self.xData, self.yData,
+ legend=name,
+ xerror=xError, yerror=yError,
+ replace=False, resetzoom=True,
+ color='green', linestyle="-", symbol='o')
+
+ self.qapp.processEvents()
+
+ self.plot.clear()
+ self.plot.resetZoom()
+ self.qapp.processEvents()
+
+ def testPlotCurveToggleLog(self):
+ """Add a curve with negative data and toggle log axis"""
+ arange = numpy.arange(1000) + 1
+ tests = [ # name, xData, yData
+ ('x>0, some negative y', arange, arange - 500),
+ ('x>0, y<0', arange, -arange),
+ ('some negative x, y>0', arange - 500, arange),
+ ('x<0, y>0', -arange, arange),
+ ('some negative x and y', arange - 500, arange - 500),
+ ('x<0, y<0', -arange, -arange),
+ ]
+
+ for name, xData, yData in tests:
+ with self.subTest(name):
+ self.plot.addCurve(xData, yData, resetzoom=True)
+ self.qapp.processEvents()
+
+ # no log axis
+ xLim = self.plot.getGraphXLimits()
+ self.assertEqual(xLim, (min(xData), max(xData)))
+ yLim = self.plot.getGraphYLimits()
+ self.assertEqual(yLim, (min(yData), max(yData)))
+
+ # x axis log
+ self.plot.setXAxisLogarithmic(True)
+ self.qapp.processEvents()
+
+ xLim = self.plot.getGraphXLimits()
+ yLim = self.plot.getGraphYLimits()
+ positives = xData > 0
+ if numpy.any(positives):
+ self.assertTrue(numpy.allclose(
+ xLim, (min(xData[positives]), max(xData[positives]))))
+ self.assertEqual(
+ yLim, (min(yData[positives]), max(yData[positives])))
+ else: # No positive x in the curve
+ self.assertEqual(xLim, (1., 100.))
+ self.assertEqual(yLim, (1., 100.))
+
+ # x axis and y axis log
+ self.plot.setYAxisLogarithmic(True)
+ self.qapp.processEvents()
+
+ xLim = self.plot.getGraphXLimits()
+ yLim = self.plot.getGraphYLimits()
+ positives = numpy.logical_and(xData > 0, yData > 0)
+ if numpy.any(positives):
+ self.assertTrue(numpy.allclose(
+ xLim, (min(xData[positives]), max(xData[positives]))))
+ self.assertTrue(numpy.allclose(
+ yLim, (min(yData[positives]), max(yData[positives]))))
+ else: # No positive x and y in the curve
+ self.assertEqual(xLim, (1., 100.))
+ self.assertEqual(yLim, (1., 100.))
+
+ # y axis log
+ self.plot.setXAxisLogarithmic(False)
+ self.qapp.processEvents()
+
+ xLim = self.plot.getGraphXLimits()
+ yLim = self.plot.getGraphYLimits()
+ positives = yData > 0
+ if numpy.any(positives):
+ self.assertEqual(
+ xLim, (min(xData[positives]), max(xData[positives])))
+ self.assertTrue(numpy.allclose(
+ yLim, (min(yData[positives]), max(yData[positives]))))
+ else: # No positive y in the curve
+ self.assertEqual(xLim, (1., 100.))
+ self.assertEqual(yLim, (1., 100.))
+
+ # no log axis
+ self.plot.setYAxisLogarithmic(False)
+ self.qapp.processEvents()
+
+ xLim = self.plot.getGraphXLimits()
+ self.assertEqual(xLim, (min(xData), max(xData)))
+ yLim = self.plot.getGraphYLimits()
+ self.assertEqual(yLim, (min(yData), max(yData)))
+
+ self.plot.clear()
+ self.plot.resetZoom()
+ self.qapp.processEvents()
+
+
+class TestPlotImageLog(_PlotWidgetTest):
+ """Basic tests for addImage with log scale axes."""
+
+ def setUp(self):
+ super(TestPlotImageLog, self).setUp()
+
+ self.plot.setGraphXLabel('Columns')
+ self.plot.setGraphYLabel('Rows')
+
+ def testPlotColormapGrayLogX(self):
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setGraphTitle('CMap X: Log Y: Linear')
+
+ colormap = {'name': 'gray', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ self.plot.addImage(DATA_2D, legend="image 1",
+ origin=(1., 1.), scale=(1., 1.),
+ replace=False, resetzoom=False, colormap=colormap)
+ self.plot.resetZoom()
+
+ def testPlotColormapGrayLogY(self):
+ self.plot.setYAxisLogarithmic(True)
+ self.plot.setGraphTitle('CMap X: Linear Y: Log')
+
+ colormap = {'name': 'gray', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ self.plot.addImage(DATA_2D, legend="image 1",
+ origin=(1., 1.), scale=(1., 1.),
+ replace=False, resetzoom=False, colormap=colormap)
+ self.plot.resetZoom()
+
+ def testPlotColormapGrayLogXY(self):
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setYAxisLogarithmic(True)
+ self.plot.setGraphTitle('CMap X: Log Y: Log')
+
+ colormap = {'name': 'gray', 'normalization': 'linear',
+ 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ self.plot.addImage(DATA_2D, legend="image 1",
+ origin=(1., 1.), scale=(1., 1.),
+ replace=False, resetzoom=False, colormap=colormap)
+ self.plot.resetZoom()
+
+ def testPlotRgbRgbaLogXY(self):
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setYAxisLogarithmic(True)
+ self.plot.setGraphTitle('RGB + RGBA X: Log Y: Log')
+
+ rgb = numpy.array(
+ (((0, 0, 0), (128, 0, 0), (255, 0, 0)),
+ ((0, 128, 0), (0, 128, 128), (0, 128, 256))),
+ dtype=numpy.uint8)
+
+ self.plot.addImage(rgb, legend="rgb",
+ origin=(1, 1), scale=(10, 10),
+ replace=False, resetzoom=False)
+
+ rgba = numpy.array(
+ (((0, 0, 0, .5), (.5, 0, 0, 1), (1, 0, 0, .5)),
+ ((0, .5, 0, 1), (0, .5, .5, 1), (0, 1, 1, .5))),
+ dtype=numpy.float32)
+
+ self.plot.addImage(rgba, legend="rgba",
+ origin=(5., 5.), scale=(10., 10.),
+ replace=False, resetzoom=False)
+ self.plot.resetZoom()
+
+
+class TestPlotMarkerLog(_PlotWidgetTest):
+ """Basic tests for markers on log scales"""
+
+ # Test marker parameters
+ markers = [ # x, y, color, selectable, draggable
+ (10., 10., 'blue', False, False),
+ (20., 20., 'red', False, False),
+ (40., 100., 'green', True, False),
+ (40., 500., 'gray', True, True),
+ (60., 800., 'black', False, True),
+ ]
+
+ def setUp(self):
+ super(TestPlotMarkerLog, self).setUp()
+
+ self.plot.setGraphYLabel('Rows')
+ self.plot.setGraphXLabel('Columns')
+ self.plot.setXAxisAutoScale(False)
+ self.plot.setYAxisAutoScale(False)
+ self.plot.setKeepDataAspectRatio(False)
+ self.plot.setLimits(1., 100., 1., 1000.)
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setYAxisLogarithmic(True)
+
+ def testPlotMarkerXLog(self):
+ self.plot.setGraphTitle('Markers X, Log axes')
+
+ for x, _, color, select, drag in self.markers:
+ name = str(x)
+ if select:
+ name += " sel."
+ if drag:
+ name += " drag"
+ self.plot.addXMarker(x, name, name, color, select, drag)
+ self.plot.resetZoom()
+
+ def testPlotMarkerYLog(self):
+ self.plot.setGraphTitle('Markers Y, Log axes')
+
+ for _, y, color, select, drag in self.markers:
+ name = str(y)
+ if select:
+ name += " sel."
+ if drag:
+ name += " drag"
+ self.plot.addYMarker(y, name, name, color, select, drag)
+ self.plot.resetZoom()
+
+ def testPlotMarkerPtLog(self):
+ self.plot.setGraphTitle('Markers Pt, Log axes')
+
+ for x, y, color, select, drag in self.markers:
+ name = "{0},{1}".format(x, y)
+ if select:
+ name += " sel."
+ if drag:
+ name += " drag"
+ self.plot.addMarker(x, y, name, name, color, select, drag)
+ self.plot.resetZoom()
+
+
+class TestPlotItemLog(_PlotWidgetTest):
+ """Basic tests for items with log scale axes"""
+
+ # Polygon coordinates and color
+ polygons = [ # legend, x coords, y coords, color
+ ('triangle', numpy.array((10, 30, 50)),
+ numpy.array((55, 70, 55)), 'red'),
+ ('square', numpy.array((10, 10, 50, 50)),
+ numpy.array((10, 50, 50, 10)), 'green'),
+ ('star', numpy.array((60, 70, 80, 60, 80)),
+ numpy.array((25, 50, 25, 40, 40)), 'blue'),
+ ]
+
+ # Rectangle coordinantes and color
+ rectangles = [ # legend, x coords, y coords, color
+ ('square 1', numpy.array((1., 10.)),
+ numpy.array((1., 10.)), 'red'),
+ ('square 2', numpy.array((10., 20.)),
+ numpy.array((10., 20.)), 'green'),
+ ('square 3', numpy.array((20., 30.)),
+ numpy.array((20., 30.)), 'blue'),
+ ('rect 1', numpy.array((1., 30.)),
+ numpy.array((35., 40.)), 'black'),
+ ('line h', numpy.array((1., 30.)),
+ numpy.array((45., 45.)), 'darkRed'),
+ ]
+
+ def setUp(self):
+ super(TestPlotItemLog, self).setUp()
+
+ self.plot.setGraphYLabel('Rows')
+ self.plot.setGraphXLabel('Columns')
+ self.plot.setXAxisAutoScale(False)
+ self.plot.setYAxisAutoScale(False)
+ self.plot.setKeepDataAspectRatio(False)
+ self.plot.setLimits(1., 100., 1., 100.)
+ self.plot.setXAxisLogarithmic(True)
+ self.plot.setYAxisLogarithmic(True)
+
+ def testPlotItemPolygonLogFill(self):
+ self.plot.setGraphTitle('Item Fill Log')
+
+ for legend, xList, yList, color in self.polygons:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=True, color=color)
+ self.plot.resetZoom()
+
+ def testPlotItemPolygonLogNoFill(self):
+ self.plot.setGraphTitle('Item No Fill Log')
+
+ for legend, xList, yList, color in self.polygons:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=False, color=color)
+ self.plot.resetZoom()
+
+ def testPlotItemRectangleLogFill(self):
+ self.plot.setGraphTitle('Rectangle Fill Log')
+
+ for legend, xList, yList, color in self.rectangles:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=True, color=color)
+ self.plot.resetZoom()
+
+ def testPlotItemRectangleLogNoFill(self):
+ self.plot.setGraphTitle('Rectangle No Fill Log')
+
+ for legend, xList, yList, color in self.rectangles:
+ self.plot.addItem(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=False, color=color)
+ self.plot.resetZoom()
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotWidget))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotImage))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotCurve))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotMarker))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotItem))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotEmptyLog))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotCurveLog))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotImageLog))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotMarkerLog))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotItemLog))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testPlotWindow.py b/silx/gui/plot/test/testPlotWindow.py
new file mode 100644
index 0000000..5afd53a
--- /dev/null
+++ b/silx/gui/plot/test/testPlotWindow.py
@@ -0,0 +1,138 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 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 PlotWindow"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+
+import doctest
+import unittest
+
+from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+
+from silx.gui import qt
+from silx.gui.plot import PlotWindow
+
+
+# Test of the docstrings #
+
+# Makes sure a QApplication exists
+_qapp = qt.QApplication.instance() or qt.QApplication([])
+
+
+def _tearDownQt(docTest):
+ """Tear down to use for test from docstring.
+
+ Checks that plt widget is displayed
+ """
+ _qapp.processEvents()
+ for obj in docTest.globs.values():
+ if isinstance(obj, PlotWindow):
+ # Commented out as it takes too long
+ # qWaitForWindowExposedAndActivate(obj)
+ obj.setAttribute(qt.Qt.WA_DeleteOnClose)
+ obj.close()
+ del obj
+
+
+plotWindowDocTestSuite = doctest.DocTestSuite('silx.gui.plot.PlotWindow',
+ tearDown=_tearDownQt)
+"""Test suite of tests from the module's docstrings."""
+
+
+class TestPlotWindow(TestCaseQt):
+ """Base class for tests of PlotWindow."""
+
+ def setUp(self):
+ super(TestPlotWindow, self).setUp()
+ self.plot = PlotWindow()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestPlotWindow, self).tearDown()
+
+ def testActions(self):
+ """Test the actions QToolButtons"""
+ self.plot.setLimits(1, 100, 1, 100)
+
+ checkList = [ # QAction, Plot state getter
+ (self.plot.xAxisAutoScaleAction, self.plot.isXAxisAutoScale),
+ (self.plot.yAxisAutoScaleAction, self.plot.isYAxisAutoScale),
+ (self.plot.xAxisLogarithmicAction, self.plot.isXAxisLogarithmic),
+ (self.plot.yAxisLogarithmicAction, self.plot.isYAxisLogarithmic),
+ (self.plot.gridAction, self.plot.getGraphGrid),
+ ]
+
+ for action, getter in checkList:
+ self.mouseMove(self.plot)
+ initialState = getter()
+ toolButton = getQToolButtonFromAction(action)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self.assertNotEqual(getter(), initialState,
+ msg='"%s" state not changed' % action.text())
+
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self.assertEqual(getter(), initialState,
+ msg='"%s" state not changed' % action.text())
+
+ # Trigger a zoom reset
+ self.mouseMove(self.plot)
+ resetZoomAction = self.plot.resetZoomAction
+ toolButton = getQToolButtonFromAction(resetZoomAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ def testToolAspectRatio(self):
+ self.plot.toolBar()
+ self.plot.keepDataAspectRatioButton.keepDataAspectRatio()
+ self.assertTrue(self.plot.isKeepDataAspectRatio())
+ self.plot.keepDataAspectRatioButton.dontKeepDataAspectRatio()
+ self.assertFalse(self.plot.isKeepDataAspectRatio())
+
+ def testToolYAxisOrigin(self):
+ self.plot.toolBar()
+ self.plot.yAxisInvertedButton.setYAxisUpward()
+ self.assertFalse(self.plot.isYAxisInverted())
+ self.plot.yAxisInvertedButton.setYAxisDownward()
+ self.assertTrue(self.plot.isYAxisInverted())
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(plotWindowDocTestSuite)
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotWindow))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testProfile.py b/silx/gui/plot/test/testProfile.py
new file mode 100644
index 0000000..43d3329
--- /dev/null
+++ b/silx/gui/plot/test/testProfile.py
@@ -0,0 +1,183 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 Profile"""
+
+__authors__ = ["T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "23/02/2017"
+
+import numpy
+import unittest
+
+from silx.test.utils import ParametricTestCase
+from silx.gui.test.utils import (
+ TestCaseQt, getQToolButtonFromAction)
+from silx.gui import qt
+from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile
+from silx.gui.plot.StackView import StackView
+
+
+# Makes sure a QApplication exists
+_qapp = qt.QApplication.instance() or qt.QApplication([])
+
+
+class TestProfileToolBar(TestCaseQt, ParametricTestCase):
+ """Tests for ProfileToolBar widget."""
+
+ def setUp(self):
+ super(TestProfileToolBar, self).setUp()
+ profileWindow = PlotWindow()
+ self.plot = PlotWindow()
+ self.toolBar = Profile.ProfileToolBar(
+ plot=self.plot, profileWindow=profileWindow)
+ self.plot.addToolBar(self.toolBar)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+ profileWindow.show()
+ self.qWaitForWindowExposed(profileWindow)
+
+ self.mouseMove(self.plot) # Move to center
+ self.qapp.processEvents()
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ 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()
+
+ # 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
+ toolButton = getQToolButtonFromAction(action)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # 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)
+
+ def testDiagonalProfile(self):
+ """Test diagonal profile, without and with image"""
+ # Use Plot backend widget to submit mouse events
+ widget = self.plot.getWidgetHandle()
+
+ # 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
+ toolButton = getQToolButtonFromAction(self.toolBar.lineAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ for image in (False, True):
+ with self.subTest(image=image):
+ if image:
+ self.plot.addImage(numpy.arange(100 * 100).reshape(100, -1))
+
+ self.mouseMove(widget, pos=pos1)
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(widget, pos=pos2)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+
+ self.plot.clear()
+
+
+class TestGetProfilePlot(TestCaseQt):
+
+ def testProfile1D(self):
+ plot = Plot2D()
+ plot.show()
+ self.qWaitForWindowExposed(plot)
+ plot.addImage([[0, 1], [2, 3]])
+ self.assertIsInstance(plot.getProfileToolbar().getProfileMainWindow(),
+ qt.QMainWindow)
+ self.assertIsInstance(plot.getProfilePlot(),
+ Plot1D)
+ plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ plot.close()
+ del plot
+
+ def testProfile2D(self):
+ """Test that the profile plot associated to a stack view is either a
+ Plot1D or a plot 2D instance."""
+ plot = StackView()
+ plot.show()
+ self.qWaitForWindowExposed(plot)
+
+ plot.setStack(numpy.array([[[0, 1], [2, 3]],
+ [[4, 5], [6, 7]]]))
+
+ self.assertIsInstance(plot.getProfileToolbar().getProfileMainWindow(),
+ qt.QMainWindow)
+
+ # plot.getProfileToolbar().profile3dAction.computeProfileIn2D() # default
+
+ self.assertIsInstance(plot.getProfileToolbar().getProfilePlot(),
+ Plot2D)
+ plot.getProfileToolbar().profile3dAction.computeProfileIn1D()
+ self.assertIsInstance(plot.getProfileToolbar().getProfilePlot(),
+ Plot1D)
+
+ plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ plot.close()
+ del plot
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ # test_suite.addTest(positionInfoTestSuite)
+ for testClass in (TestProfileToolBar, TestGetProfilePlot):
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ testClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testScatterMaskToolsWidget.py b/silx/gui/plot/test/testScatterMaskToolsWidget.py
new file mode 100644
index 0000000..8b5f2ad
--- /dev/null
+++ b/silx/gui/plot/test/testScatterMaskToolsWidget.py
@@ -0,0 +1,313 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 MaskToolsWidget"""
+
+__authors__ = ["T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "10/07/2017"
+
+
+import logging
+import os.path
+import unittest
+
+import numpy
+
+from silx.gui import qt
+from silx.test.utils import temp_dir, ParametricTestCase
+from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.plot import PlotWindow, ScatterMaskToolsWidget
+
+try:
+ import fabio
+except ImportError:
+ fabio = None
+
+
+logging.basicConfig()
+_logger = logging.getLogger(__name__)
+
+
+class TestScatterMaskToolsWidget(TestCaseQt, ParametricTestCase):
+ """Basic test for MaskToolsWidget"""
+
+ def setUp(self):
+ super(TestScatterMaskToolsWidget, self).setUp()
+ self.plot = PlotWindow()
+
+ self.widget = ScatterMaskToolsWidget.ScatterMaskToolsDockWidget(
+ plot=self.plot, name='TEST')
+ self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, self.widget)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.maskWidget = self.widget.widget()
+
+ def tearDown(self):
+ del self.maskWidget
+ del self.widget
+
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+
+ super(TestScatterMaskToolsWidget, self).tearDown()
+
+ def testEmptyPlot(self):
+ """Empty plot, display MaskToolsDockWidget, toggle multiple masks"""
+ self.maskWidget.setMultipleMasks('single')
+ self.qapp.processEvents()
+
+ self.maskWidget.setMultipleMasks('exclusive')
+ self.qapp.processEvents()
+
+ def _drag(self):
+ """Drag from plot center to offset position"""
+ plot = self.plot.centralWidget()
+ xCenter, yCenter = plot.width() // 2, plot.height() // 2
+ offset = min(plot.width(), plot.height()) // 10
+
+ pos0 = xCenter, yCenter
+ pos1 = xCenter + offset, yCenter + offset
+
+ self.mouseMove(plot, pos=pos0)
+ self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseMove(plot, pos=pos1)
+ self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
+
+ def _drawPolygon(self):
+ """Draw a star polygon in the plot"""
+ plot = self.plot.centralWidget()
+ x, y = plot.width() // 2, plot.height() // 2
+ offset = min(plot.width(), plot.height()) // 10
+
+ star = [(x, y + offset),
+ (x - offset, y - offset),
+ (x + offset, y),
+ (x - offset, y),
+ (x + offset, y - offset)]
+
+ for pos in star:
+ self.mouseMove(plot, pos=pos)
+ btn = qt.Qt.LeftButton if pos != star[-1] else qt.Qt.RightButton
+ self.mouseClick(plot, btn, pos=pos)
+
+ def _drawPencil(self):
+ """Draw a star polygon in the plot"""
+ plot = self.plot.centralWidget()
+ x, y = plot.width() // 2, plot.height() // 2
+ offset = min(plot.width(), plot.height()) // 10
+
+ star = [(x, y + offset),
+ (x - offset, y - offset),
+ (x + offset, y),
+ (x - offset, y),
+ (x + offset, y - offset)]
+
+ self.mouseMove(plot, pos=star[0])
+ self.mousePress(plot, qt.Qt.LeftButton, pos=star[0])
+ for pos in star:
+ self.mouseMove(plot, pos=pos)
+ self.mouseRelease(
+ plot, qt.Qt.LeftButton, pos=star[-1])
+
+ def testWithAScatter(self):
+ """Plot with a Scatter: test MaskToolsWidget interactions"""
+
+ # Add and remove a scatter (this should enable/disable GUI + change mask)
+ self.plot.addScatter(
+ x=numpy.arange(256),
+ y=numpy.arange(256),
+ value=numpy.random.random(256),
+ legend='test')
+ self.plot._setActiveItem(kind="scatter", legend="test")
+ self.qapp.processEvents()
+
+ self.plot.remove('test', kind='scatter')
+ self.qapp.processEvents()
+
+ self.plot.addScatter(
+ x=numpy.arange(1000),
+ y=1000 * (numpy.arange(1000) % 20),
+ value=numpy.random.random(1000),
+ legend='test')
+ self.plot._setActiveItem(kind="scatter", legend="test")
+ self.plot.resetZoom()
+ self.qapp.processEvents()
+
+ # Test draw rectangle #
+ toolButton = getQToolButtonFromAction(self.maskWidget.rectAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drag()
+
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drag()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # Test draw polygon #
+ toolButton = getQToolButtonFromAction(self.maskWidget.polygonAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drawPolygon()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drawPolygon()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # Test draw pencil #
+ toolButton = getQToolButtonFromAction(self.maskWidget.pencilAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ self.maskWidget.pencilSpinBox.setValue(10)
+ self.qapp.processEvents()
+
+ # mask
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drawPencil()
+ self.assertFalse(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # unmask same region
+ self.maskWidget.maskStateGroup.button(0).click()
+ self.qapp.processEvents()
+ self._drawPencil()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ # Test no draw tool #
+ toolButton = getQToolButtonFromAction(self.maskWidget.browseAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ self.plot.clear()
+
+ def __loadSave(self, file_format):
+ self.plot.addScatter(
+ x=numpy.arange(256),
+ y=25 * (numpy.arange(256) % 10),
+ value=numpy.random.random(256),
+ legend='test')
+ self.plot._setActiveItem(kind="scatter", legend="test")
+ self.plot.resetZoom()
+ self.qapp.processEvents()
+
+ # Draw a polygon mask
+ toolButton = getQToolButtonFromAction(self.maskWidget.polygonAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self._drawPolygon()
+
+ ref_mask = self.maskWidget.getSelectionMask()
+ self.assertFalse(numpy.all(numpy.equal(ref_mask, 0)))
+
+ with temp_dir() as tmp:
+ mask_filename = os.path.join(tmp, 'mask.' + file_format)
+ self.maskWidget.save(mask_filename, file_format)
+
+ self.maskWidget.resetSelectionMask()
+ self.assertTrue(
+ numpy.all(numpy.equal(self.maskWidget.getSelectionMask(), 0)))
+
+ self.maskWidget.load(mask_filename)
+ self.assertTrue(numpy.all(numpy.equal(
+ self.maskWidget.getSelectionMask(), ref_mask)))
+
+ def testLoadSaveNpy(self):
+ self.__loadSave("npy")
+
+ def testLoadSaveCsv(self):
+ self.__loadSave("csv")
+
+ def testSigMaskChangedEmitted(self):
+ self.qapp.processEvents()
+ self.plot.addScatter(
+ x=numpy.arange(1000),
+ y=1000 * (numpy.arange(1000) % 20),
+ value=numpy.ones((1000,)),
+ legend='test')
+ self.plot._setActiveItem(kind="scatter", legend="test")
+ self.plot.resetZoom()
+ self.qapp.processEvents()
+
+ self.plot.remove('test', kind='scatter')
+ self.qapp.processEvents()
+
+ self.plot.addScatter(
+ x=numpy.arange(1000),
+ y=1000 * (numpy.arange(1000) % 20),
+ value=numpy.random.random(1000),
+ legend='test')
+
+ l = []
+
+ def slot():
+ l.append(1)
+
+ self.maskWidget.sigMaskChanged.connect(slot)
+
+ # rectangle mask
+ toolButton = getQToolButtonFromAction(self.maskWidget.rectAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self.maskWidget.maskStateGroup.button(1).click()
+ self.qapp.processEvents()
+ self._drag()
+
+ self.assertGreater(len(l), 0)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for TestClass in (TestScatterMaskToolsWidget,):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testStackView.py b/silx/gui/plot/test/testStackView.py
new file mode 100644
index 0000000..69584cd
--- /dev/null
+++ b/silx/gui/plot/test/testStackView.py
@@ -0,0 +1,209 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 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 StackView"""
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "20/03/2017"
+
+
+import unittest
+import numpy
+
+from silx.gui.test.utils import TestCaseQt
+
+from silx.gui import qt
+from silx.gui.plot import StackView
+from silx.gui.plot.StackView import StackViewMainWindow
+
+from silx.utils.array_like import ListOfImages
+
+
+# Makes sure a QApplication exists
+_qapp = qt.QApplication.instance() or qt.QApplication([])
+
+
+class TestStackView(TestCaseQt):
+ """Base class for tests of StackView."""
+
+ def setUp(self):
+ super(TestStackView, self).setUp()
+ self.stackview = StackView()
+ self.stackview.show()
+ self.qWaitForWindowExposed(self.stackview)
+ self.mystack = numpy.fromfunction(
+ lambda i, j, k: numpy.sin(i/15.) + numpy.cos(j/4.) + 2 * numpy.sin(k/6.),
+ (10, 20, 30)
+ )
+
+ def tearDown(self):
+ self.stackview.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.stackview.close()
+ del self.stackview
+ super(TestStackView, self).tearDown()
+
+ def testSetStack(self):
+ self.stackview.setStack(self.mystack)
+ self.stackview.setColormap("viridis", autoscale=True)
+ my_trans_stack, params = self.stackview.getStack()
+ self.assertEqual(my_trans_stack.shape, self.mystack.shape)
+ self.assertTrue(numpy.array_equal(self.mystack,
+ my_trans_stack))
+ self.assertEqual(params["colormap"]["name"],
+ "viridis")
+
+ def testSetStackPerspective(self):
+ self.stackview.setStack(self.mystack, perspective=1)
+ # my_orig_stack, params = self.stackview.getStack()
+ my_trans_stack, params = self.stackview.getCurrentView()
+
+ # get stack returns the transposed data, depending on the perspective
+ self.assertEqual(my_trans_stack.shape,
+ (self.mystack.shape[1], self.mystack.shape[0], self.mystack.shape[2]))
+ self.assertTrue(numpy.array_equal(numpy.transpose(self.mystack, axes=(1, 0, 2)),
+ my_trans_stack))
+
+ def testSetStackListOfImages(self):
+ loi = [self.mystack[i] for i in range(self.mystack.shape[0])]
+
+ self.stackview.setStack(loi)
+ my_orig_stack, params = self.stackview.getStack(returnNumpyArray=True)
+ my_trans_stack, params = self.stackview.getStack(returnNumpyArray=True)
+ self.assertEqual(my_trans_stack.shape, self.mystack.shape)
+ self.assertTrue(numpy.array_equal(self.mystack,
+ my_trans_stack))
+ self.assertTrue(numpy.array_equal(self.mystack,
+ my_orig_stack))
+ self.assertIsInstance(my_trans_stack, numpy.ndarray)
+
+ self.stackview.setStack(loi, perspective=2)
+ my_orig_stack, params = self.stackview.getStack(copy=False)
+ my_trans_stack, params = self.stackview.getCurrentView(copy=False)
+ # getStack(copy=False) must return the object set in setStack
+ self.assertIs(my_orig_stack, loi)
+ # getCurrentView(copy=False) returns a ListOfImages whose .images
+ # attr is the original data
+ self.assertEqual(my_trans_stack.shape,
+ (self.mystack.shape[2], self.mystack.shape[0], self.mystack.shape[1]))
+ self.assertTrue(numpy.array_equal(numpy.array(my_trans_stack),
+ numpy.transpose(self.mystack, axes=(2, 0, 1))))
+ self.assertIsInstance(my_trans_stack,
+ ListOfImages) # returnNumpyArray=False by default in getStack
+ self.assertIs(my_trans_stack.images, loi)
+
+ def testPerspective(self):
+ self.stackview.setStack(numpy.arange(24).reshape((2, 3, 4)))
+ self.assertEqual(self.stackview._perspective, 0,
+ "Default perspective is not 0 (dim1-dim2).")
+
+ self.stackview._StackView__planeSelection.setPerspective(1)
+ self.assertEqual(self.stackview._perspective, 1,
+ "Plane selection combobox not updating perspective")
+
+ self.stackview.setStack(numpy.arange(6).reshape((1, 2, 3)))
+ self.assertEqual(self.stackview._perspective, 0,
+ "Default perspective not restored in setStack.")
+
+ self.stackview.setStack(numpy.arange(24).reshape((2, 3, 4)), perspective=2)
+ self.assertEqual(self.stackview._perspective, 2,
+ "Perspective not set in setStack(..., perspective=2).")
+
+ def testTitle(self):
+ """Test that the plot title contains the proper Z information"""
+ self.stackview.setStack(numpy.arange(24).reshape((4, 3, 2)),
+ calibrations=[(0, 1), (-10, 10), (3.14, 3.14)])
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Image z=0")
+ self.stackview.setFrameNumber(2)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Image z=2")
+
+ self.stackview._StackView__planeSelection.setPerspective(1)
+ self.stackview.setFrameNumber(0)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Image z=-10")
+ self.stackview.setFrameNumber(2)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Image z=10")
+
+ self.stackview._StackView__planeSelection.setPerspective(2)
+ self.stackview.setFrameNumber(0)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Image z=3.14")
+ self.stackview.setFrameNumber(1)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Image z=6.28")
+
+
+class TestStackViewMainWindow(TestCaseQt):
+ """Base class for tests of StackView."""
+
+ def setUp(self):
+ super(TestStackViewMainWindow, self).setUp()
+ self.stackview = StackViewMainWindow()
+ self.stackview.show()
+ self.qWaitForWindowExposed(self.stackview)
+ self.mystack = numpy.fromfunction(
+ lambda i, j, k: numpy.sin(i/15.) + numpy.cos(j/4.) + 2 * numpy.sin(k/6.),
+ (10, 20, 30)
+ )
+
+ def tearDown(self):
+ self.stackview.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.stackview.close()
+ del self.stackview
+ super(TestStackViewMainWindow, self).tearDown()
+
+ def testSetStack(self):
+ self.stackview.setStack(self.mystack)
+ self.stackview.setColormap("viridis", autoscale=True)
+ my_trans_stack, params = self.stackview.getStack()
+ self.assertEqual(my_trans_stack.shape, self.mystack.shape)
+ self.assertTrue(numpy.array_equal(self.mystack,
+ my_trans_stack))
+ self.assertEqual(params["colormap"]["name"],
+ "viridis")
+
+ def testSetStackPerspective(self):
+ self.stackview.setStack(self.mystack, perspective=1)
+ my_trans_stack, params = self.stackview.getCurrentView()
+ # get stack returns the transposed data, depending on the perspective
+ self.assertEqual(my_trans_stack.shape,
+ (self.mystack.shape[1], self.mystack.shape[0], self.mystack.shape[2]))
+ self.assertTrue(numpy.array_equal(numpy.transpose(self.mystack, axes=(1, 0, 2)),
+ my_trans_stack))
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestStackView))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestStackViewMainWindow))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')