diff options
Diffstat (limited to 'silx/gui/plot')
117 files changed, 0 insertions, 47208 deletions
diff --git a/silx/gui/plot/AlphaSlider.py b/silx/gui/plot/AlphaSlider.py deleted file mode 100644 index ab2e5aa..0000000 --- a/silx/gui/plot/AlphaSlider.py +++ /dev/null @@ -1,300 +0,0 @@ -# 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 deleted file mode 100644 index fd4d34e..0000000 --- a/silx/gui/plot/ColorBar.py +++ /dev/null @@ -1,881 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Module containing several widgets associated to a colormap. -""" - -__authors__ = ["H. Payno", "T. Vincent"] -__license__ = "MIT" -__date__ = "24/04/2018" - - -import logging -import weakref -import numpy - -from ._utils import ticklayout -from .. import qt -from silx.gui 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 colorbar - """ - sigVisibleChanged = qt.Signal(bool) - """Emitted when the property `visible` have changed.""" - - def __init__(self, parent=None, plot=None, legend=None): - self._isConnected = False - self._plotRef = None - self._colormap = None - self._data = None - - super(ColorBarWidget, self).__init__(parent) - - 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) - - def getPlot(self): - """Returns the :class:`Plot` associated to this widget or None""" - return None if self._plotRef is None else self._plotRef() - - 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. - """ - self._disconnectPlot() - self._plotRef = None if plot is None else weakref.ref(plot) - self._connectPlot() - - def _disconnectPlot(self): - """Disconnect from Plot signals""" - plot = self.getPlot() - if plot is not None and self._isConnected: - self._isConnected = False - plot.sigActiveImageChanged.disconnect( - self._activeImageChanged) - plot.sigActiveScatterChanged.disconnect( - self._activeScatterChanged) - plot.sigPlotSignal.disconnect(self._defaultColormapChanged) - - def _connectPlot(self): - """Connect to Plot signals""" - plot = self.getPlot() - if plot is not None and not self._isConnected: - activeImageLegend = plot.getActiveImage(just_legend=True) - activeScatterLegend = plot._getActiveItem( - kind='scatter', just_legend=True) - if activeImageLegend is None and activeScatterLegend is None: - # Show plot default colormap - self._syncWithDefaultColormap() - elif activeImageLegend is not None: # Show active image colormap - self._activeImageChanged(None, activeImageLegend) - elif activeScatterLegend is not None: # Show active scatter colormap - self._activeScatterChanged(None, activeScatterLegend) - - plot.sigActiveImageChanged.connect(self._activeImageChanged) - plot.sigActiveScatterChanged.connect(self._activeScatterChanged) - plot.sigPlotSignal.connect(self._defaultColormapChanged) - self._isConnected = True - - def setVisible(self, isVisible): - # isHidden looks to be always synchronized, while isVisible is not - wasHidden = self.isHidden() - qt.QWidget.setVisible(self, isVisible) - if wasHidden != self.isHidden(): - self.sigVisibleChanged.emit(not self.isHidden()) - - def showEvent(self, event): - self._connectPlot() - - def hideEvent(self, event): - self._disconnectPlot() - - def getColormap(self): - """Returns the colormap displayed in the colorbar. - - :rtype: ~silx.gui.colors.Colormap - """ - return self.getColorScaleBar().getColormap() - - def setColormap(self, colormap, data=None): - """Set the colormap to be displayed. - - :param ~silx.gui.colors.Colormap colormap: - The colormap to apply on the ColorBarWidget - :param numpy.ndarray data: the data to display, needed if the colormap - require an autoscale - """ - self._data = data - self.getColorScaleBar().setColormap(colormap=colormap, - data=data) - if self._colormap is not None: - self._colormap.sigChanged.disconnect(self._colormapHasChanged) - self._colormap = colormap - if self._colormap is not None: - self._colormap.sigChanged.connect(self._colormapHasChanged) - - def _colormapHasChanged(self): - """handler of the Colormap.sigChanged signal - """ - assert self._colormap is not None - self.setColormap(colormap=self._colormap, - data=self._data) - - 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.text() - - def _activeScatterChanged(self, previous, legend): - """Handle plot active scatter changed""" - plot = self.getPlot() - - # Do not handle active scatter while there is an image - if plot.getActiveImage() is not None: - return - - if legend is None: # No active scatter, display no colormap - self.setColormap(colormap=None) - return - - # Sync with active scatter - activeScatter = plot._getActiveItem(kind='scatter') - - self.setColormap(colormap=activeScatter.getColormap(), - data=activeScatter.getValueData(copy=False)) - - def _activeImageChanged(self, previous, legend): - """Handle plot active image changed""" - plot = self.getPlot() - - if legend is None: # No active image, try with active scatter - activeScatterLegend = plot._getActiveItem( - kind='scatter', just_legend=True) - # No more active image, use active scatter if any - self._activeScatterChanged(None, activeScatterLegend) - else: - # Sync with active image - image = plot.getActiveImage().getData(copy=False) - - # RGB(A) image, display default colormap - if image.ndim != 2: - self.setColormap(colormap=None) - 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 - self.setColormap(colormap=plot.getActiveImage().getColormap(), - data=image) - - def _defaultColormapChanged(self, event): - """Handle plot default colormap changed""" - if (event['event'] == 'defaultColormapChanged' and - self.getPlot().getActiveImage() is None): - # No active image, take default colormap update into account - self._syncWithDefaultColormap() - - def _syncWithDefaultColormap(self, data=None): - """Update colorbar according to plot default colormap""" - self.setColormap(self.getPlot().getDefaultColormap(), data) - - 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 = Colormap(name='gray', - ... norm='log', - ... vmin=1, - ... vmax=100000, - ... ) - >>> 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""" - - def __init__(self, parent=None, colormap=None, data=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, - data=data, - parent=self, - margin=ColorScaleBar._TEXT_MARGIN) - if colormap: - vmin, vmax = colormap.getColormapRange(data) - else: - vmin, vmax = colors.DEFAULT_MIN_LIN, colors.DEFAULT_MAX_LIN - - norm = colormap.getNormalization() if colormap else colors.Colormap.LINEAR - self.tickbar = _TickBar(vmin=vmin, - vmax=vmax, - norm=norm, - parent=self, - displayValues=displayTicksValues, - margin=ColorScaleBar._TEXT_MARGIN) - - self.layout().addWidget(self.tickbar, 1, 0, 1, 1, qt.Qt.AlignRight) - self.layout().addWidget(self.colorScale, 1, 1, qt.Qt.AlignLeft) - - self.layout().setContentsMargins(0, 0, 0, 0) - self.layout().setSpacing(0) - - # max label - self._maxLabel = qt.QLabel(str(1.0), parent=self) - self._maxLabel.setToolTip(str(0.0)) - self.layout().addWidget(self._maxLabel, 0, 0, 1, 2, qt.Qt.AlignRight) - - # min label - self._minLabel = qt.QLabel(str(0.0), parent=self) - self._minLabel.setToolTip(str(0.0)) - self.layout().addWidget(self._minLabel, 2, 0, 1, 2, qt.Qt.AlignRight) - - self.layout().setSizeConstraint(qt.QLayout.SetMinAndMaxSize) - self.layout().setColumnStretch(0, 1) - self.layout().setRowStretch(1, 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 getColormap(self): - """ - - :returns: the colormap. - :rtype: :class:`.Colormap` - """ - return self.colorScale.getColormap() - - def setColormap(self, colormap, data=None): - """Set the new colormap to be displayed - - :param Colormap colormap: the colormap to set - :param numpy.ndarray data: the data to display, needed if the colormap - require an autoscale - """ - self.colorScale.setColormap(colormap, data) - - if colormap is not None: - vmin, vmax = colormap.getColormapRange(data) - norm = colormap.getNormalization() - else: - vmin, vmax = None, None - norm = None - - self.tickbar.update(vmin=vmin, - vmax=vmax, - norm=norm) - self._setMinMaxLabels(vmin, 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._minLabel.setVisible(val) - self._maxLabel.setVisible(val) - - def _updateMinMax(self): - """Update the min and max label if we are in the case of the - configuration 'minMaxValueOnly'""" - if self.minVal is None: - text, tooltip = '', '' - else: - if self.minVal == 0 or 0 <= numpy.log10(abs(self.minVal)) < 7: - text = '%.7g' % self.minVal - else: - text = '%.2e' % self.minVal - tooltip = repr(self.minVal) - - self._minLabel.setText(text) - self._minLabel.setToolTip(tooltip) - - if self.maxVal is None: - text, tooltip = '', '' - else: - if self.maxVal == 0 or 0 <= numpy.log10(abs(self.maxVal)) < 7: - text = '%.7g' % self.maxVal - else: - text = '%.2e' % self.maxVal - tooltip = repr(self.maxVal) - - self._maxLabel.setText(text) - self._maxLabel.setToolTip(tooltip) - - 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 = Colormap(name='viridis', - ... norm='log', - ... vmin=1, - ... vmax=100000, - ... ) - >>> 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, data=None): - qt.QWidget.__init__(self, parent) - self._colormap = None - self.margin = margin - self.setColormap(colormap, data) - - self.setLayout(qt.QVBoxLayout()) - self.setSizePolicy(qt.QSizePolicy.Fixed, 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) - - self.setMinimumHeight(self._NB_CONTROL_POINTS // 2 + 2 * self.margin) - self.setFixedWidth(25) - - def setColormap(self, colormap, data=None): - """Set the new colormap to be displayed - - :param dict colormap: the colormap to set - :param data: Optional data for which to compute colormap range. - """ - self._colormap = colormap - self.setEnabled(colormap is not None) - - if colormap is None: - self.vmin, self.vmax = None, None - else: - assert colormap.getNormalization() in colors.Colormap.NORMALIZATIONS - self.vmin, self.vmax = self._colormap.getColormapRange(data=data) - self._updateColorGradient() - self.update() - - def getColormap(self): - """Returns the colormap - - :rtype: :class:`.Colormap` - """ - return None if self._colormap is None else self._colormap - - def _updateColorGradient(self): - """Compute the color gradient""" - colormap = self.getColormap() - if colormap is None: - return - - indices = numpy.linspace(0., 1., self._NB_CONTROL_POINTS) - colors = colormap.getNColors(nbColors=self._NB_CONTROL_POINTS) - self._gradient = qt.QLinearGradient(0, 1, 0, 0) - self._gradient.setCoordinateMode(qt.QGradient.StretchToDeviceMode) - self._gradient.setStops( - [(i, qt.QColor(*color)) for i, color in zip(indices, colors)] - ) - - def paintEvent(self, event): - """""" - painter = qt.QPainter(self) - if self.getColormap() is not None: - painter.setBrush(self._gradient) - penColor = self.palette().color(qt.QPalette.Active, - qt.QPalette.Foreground) - else: - penColor = self.palette().color(qt.QPalette.Disabled, - qt.QPalette.Foreground) - painter.setPen(penColor) - - painter.drawRect(qt.QRect( - 0, - self.margin, - self.width() - 1., - self.height() - 2. * self.margin - 1.)) - - def mouseMoveEvent(self, event): - tooltip = str(self.getValueFromRelativePosition( - self._getRelativePosition(event.y()))) - qt.QToolTip.showText(event.globalPos(), tooltip, self) - 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. - (yPixel - self.margin) / 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']] - """ - colormap = self.getColormap() - if colormap is None: - return - - value = max(0.0, value) - value = min(value, 1.0) - - vmin = self.vmin - vmax = self.vmax - if colormap.getNormalization() == colors.Colormap.LINEAR: - return vmin + (vmax - vmin) * value - elif colormap.getNormalization() == colors.Colormap.LOGARITHM: - 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" % 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 - self.update() - - -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.margin = margin - self._nticks = None - self.ticks = () - self.subTicks = () - self._forcedDisplayType = None - self.ticksDensity = _TickBar.DEFAULT_TICK_DENSITY - - self._vmin = vmin - self._vmax = vmax - self._norm = norm - self.displayValues = displayValues - self.setTicksNumber(nticks) - - self.setMargin(margin) - self.setContentsMargins(0, 0, 0, 0) - - self._resetWidth() - - def setTicksValuesVisible(self, val): - self.displayValues = val - self._resetWidth() - - def _resetWidth(self): - width = self._WIDTH_DISP_VAL if self.displayValues else self._WIDTH_NO_DISP_VAL - self.setFixedWidth(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.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._vmin == self._vmax: - # No range: no ticks - self.ticks = () - self.subTicks = () - elif self._norm == colors.Colormap.LOGARITHM: - self._computeTicksLog(nticks) - elif self._norm == colors.Colormap.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 - for val in self.ticks: - self._paintTick(val, painter, majorTick=True) - - # paint subticks - for val in self.subTicks: - self._paintTick(val, painter, majorTick=False) - - def _getRelativePosition(self, val): - """Return the relative position of val according to min and max value - """ - if self._norm == colors.Colormap.LINEAR: - return 1 - (val - self._vmin) / (self._vmax - self._vmin) - elif self._norm == colors.Colormap.LOGARITHM: - 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 - 1 - 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 - """ - 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/Colormap.py b/silx/gui/plot/Colormap.py deleted file mode 100644 index e797d89..0000000 --- a/silx/gui/plot/Colormap.py +++ /dev/null @@ -1,44 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Deprecated module providing the Colormap object -""" - -from __future__ import absolute_import - -__authors__ = ["T. Vincent", "H.Payno"] -__license__ = "MIT" -__date__ = "24/04/2018" - -import silx.utils.deprecation - -silx.utils.deprecation.deprecated_warning("Module", - name="silx.gui.plot.Colormap", - reason="moved", - replacement="silx.gui.colors.Colormap", - since_version="0.8.0", - only_once=True, - skip_backtrace_count=1) - -from ..colors import * # noqa diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py deleted file mode 100644 index 7c66cb8..0000000 --- a/silx/gui/plot/ColormapDialog.py +++ /dev/null @@ -1,43 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Deprecated module providing ColormapDialog.""" - -from __future__ import absolute_import - -__authors__ = ["T. Vincent", "H.Payno"] -__license__ = "MIT" -__date__ = "24/04/2018" - -import silx.utils.deprecation - -silx.utils.deprecation.deprecated_warning("Module", - name="silx.gui.plot.ColormapDialog", - reason="moved", - replacement="silx.gui.dialog.ColormapDialog", - since_version="0.8.0", - only_once=True, - skip_backtrace_count=1) - -from ..dialog.ColormapDialog import * # noqa diff --git a/silx/gui/plot/Colors.py b/silx/gui/plot/Colors.py deleted file mode 100644 index 277e104..0000000 --- a/silx/gui/plot/Colors.py +++ /dev/null @@ -1,90 +0,0 @@ -# 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.""" - -from __future__ import absolute_import - -__authors__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "14/06/2018" - -import silx.utils.deprecation - -silx.utils.deprecation.deprecated_warning("Module", - name="silx.gui.plot.Colors", - reason="moved", - replacement="silx.gui.colors", - since_version="0.8.0", - only_once=True, - skip_backtrace_count=1) - -from ..colors import * # noqa - - -@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.applyColormap') -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 - """ - colormap = Colormap(name=name, - normalization=normalization, - vmin=vmin, - vmax=vmax, - colors=colors) - return colormap.applyToData(data) - - -@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.getSupportedColormaps') -def getSupportedColormaps(): - """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 Colormap.getSupportedColormaps() diff --git a/silx/gui/plot/CompareImages.py b/silx/gui/plot/CompareImages.py deleted file mode 100644 index 88b257d..0000000 --- a/silx/gui/plot/CompareImages.py +++ /dev/null @@ -1,1190 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""A widget dedicated to compare 2 images. -""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "23/07/2018" - - -import logging -import numpy -import weakref -import collections -import math - -import silx.image.bilinear -from silx.gui import qt -from silx.gui import plot -from silx.gui import icons -from silx.gui.colors import Colormap -from silx.gui.plot import tools -from silx.third_party import enum - -_logger = logging.getLogger(__name__) - -from silx.opencl import ocl -if ocl is not None: - from silx.opencl import sift -else: # No OpenCL device or no pyopencl - sift = None - - -@enum.unique -class VisualizationMode(enum.Enum): - """Enum for each visualization mode available.""" - ONLY_A = 'a' - ONLY_B = 'b' - VERTICAL_LINE = 'vline' - HORIZONTAL_LINE = 'hline' - COMPOSITE_RED_BLUE_GRAY = "rbgchannel" - COMPOSITE_RED_BLUE_GRAY_NEG = "rbgnegchannel" - - -@enum.unique -class AlignmentMode(enum.Enum): - """Enum for each alignment mode available.""" - ORIGIN = 'origin' - CENTER = 'center' - STRETCH = 'stretch' - AUTO = 'auto' - - -AffineTransformation = collections.namedtuple("AffineTransformation", - ["tx", "ty", "sx", "sy", "rot"]) -"""Contains a 2D affine transformation: translation, scale and rotation""" - - -class CompareImagesToolBar(qt.QToolBar): - """ToolBar containing specific tools to custom the configuration of a - :class:`CompareImages` widget - - Use :meth:`setCompareWidget` to connect this toolbar to a specific - :class:`CompareImages` widget. - - :param Union[qt.QWidget,None] parent: Parent of this widget. - """ - def __init__(self, parent=None): - qt.QToolBar.__init__(self, parent) - - self.__compareWidget = None - - menu = qt.QMenu(self) - self.__visualizationAction = qt.QAction(self) - self.__visualizationAction.setMenu(menu) - self.__visualizationAction.setCheckable(False) - self.addAction(self.__visualizationAction) - self.__visualizationGroup = qt.QActionGroup(self) - self.__visualizationGroup.setExclusive(True) - self.__visualizationGroup.triggered.connect(self.__visualizationModeChanged) - - icon = icons.getQIcon("compare-mode-a") - action = qt.QAction(icon, "Display the first image only", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_A)) - action.setProperty("mode", VisualizationMode.ONLY_A) - menu.addAction(action) - self.__aModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-b") - action = qt.QAction(icon, "Display the second image only", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_B)) - action.setProperty("mode", VisualizationMode.ONLY_B) - menu.addAction(action) - self.__bModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-vline") - action = qt.QAction(icon, "Vertical compare mode", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_V)) - action.setProperty("mode", VisualizationMode.VERTICAL_LINE) - menu.addAction(action) - self.__vlineModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-hline") - action = qt.QAction(icon, "Horizontal compare mode", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_H)) - action.setProperty("mode", VisualizationMode.HORIZONTAL_LINE) - menu.addAction(action) - self.__hlineModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-rb-channel") - action = qt.QAction(icon, "Blue/red compare mode (additive mode)", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_C)) - action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY) - menu.addAction(action) - self.__brChannelModeAction = action - self.__visualizationGroup.addAction(action) - - icon = icons.getQIcon("compare-mode-rbneg-channel") - action = qt.QAction(icon, "Yellow/cyan compare mode (subtractive mode)", self) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_W)) - action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG) - menu.addAction(action) - self.__ycChannelModeAction = action - self.__visualizationGroup.addAction(action) - - menu = qt.QMenu(self) - self.__alignmentAction = qt.QAction(self) - self.__alignmentAction.setMenu(menu) - self.__alignmentAction.setIconVisibleInMenu(True) - self.addAction(self.__alignmentAction) - self.__alignmentGroup = qt.QActionGroup(self) - self.__alignmentGroup.setExclusive(True) - self.__alignmentGroup.triggered.connect(self.__alignmentModeChanged) - - icon = icons.getQIcon("compare-align-origin") - action = qt.QAction(icon, "Align images on their upper-left pixel", self) - action.setProperty("mode", AlignmentMode.ORIGIN) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__originAlignAction = action - menu.addAction(action) - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-align-center") - action = qt.QAction(icon, "Center images", self) - action.setProperty("mode", AlignmentMode.CENTER) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__centerAlignAction = action - menu.addAction(action) - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-align-stretch") - action = qt.QAction(icon, "Stretch the second image on the first one", self) - action.setProperty("mode", AlignmentMode.STRETCH) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__stretchAlignAction = action - menu.addAction(action) - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-align-auto") - action = qt.QAction(icon, "Auto-alignment of the second image", self) - action.setProperty("mode", AlignmentMode.AUTO) - action.setIconVisibleInMenu(True) - action.setCheckable(True) - self.__autoAlignAction = action - menu.addAction(action) - if sift is None: - action.setEnabled(False) - action.setToolTip("Sift module is not available") - self.__alignmentGroup.addAction(action) - - icon = icons.getQIcon("compare-keypoints") - action = qt.QAction(icon, "Display/hide alignment keypoints", self) - action.setCheckable(True) - action.triggered.connect(self.__keypointVisibilityChanged) - self.addAction(action) - self.__displayKeypoints = action - - def setCompareWidget(self, widget): - """ - Connect this tool bar to a specific :class:`CompareImages` widget. - - :param Union[None,CompareImages] widget: The widget to connect with. - """ - compareWidget = self.getCompareWidget() - if compareWidget is not None: - compareWidget.sigConfigurationChanged.disconnect(self.__updateSelectedActions) - compareWidget = widget - if compareWidget is None: - self.__compareWidget = None - else: - self.__compareWidget = weakref.ref(compareWidget) - if compareWidget is not None: - widget.sigConfigurationChanged.connect(self.__updateSelectedActions) - self.__updateSelectedActions() - - def getCompareWidget(self): - """Returns the connected widget. - - :rtype: CompareImages - """ - if self.__compareWidget is None: - return None - else: - return self.__compareWidget() - - def __updateSelectedActions(self): - """ - Update the state of this tool bar according to the state of the - connected :class:`CompareImages` widget. - """ - widget = self.getCompareWidget() - if widget is None: - return - - mode = widget.getVisualizationMode() - action = None - for a in self.__visualizationGroup.actions(): - actionMode = a.property("mode") - if mode == actionMode: - action = a - break - old = self.__visualizationGroup.blockSignals(True) - if action is not None: - # Check this action - action.setChecked(True) - else: - action = self.__visualizationGroup.checkedAction() - if action is not None: - # Uncheck this action - action.setChecked(False) - self.__updateVisualizationMenu() - self.__visualizationGroup.blockSignals(old) - - mode = widget.getAlignmentMode() - action = None - for a in self.__alignmentGroup.actions(): - actionMode = a.property("mode") - if mode == actionMode: - action = a - break - old = self.__alignmentGroup.blockSignals(True) - if action is not None: - # Check this action - action.setChecked(True) - else: - action = self.__alignmentGroup.checkedAction() - if action is not None: - # Uncheck this action - action.setChecked(False) - self.__updateAlignmentMenu() - self.__alignmentGroup.blockSignals(old) - - def __visualizationModeChanged(self, selectedAction): - """Called when user requesting changes of the visualization mode. - """ - self.__updateVisualizationMenu() - widget = self.getCompareWidget() - if widget is not None: - mode = selectedAction.property("mode") - widget.setVisualizationMode(mode) - - def __updateVisualizationMenu(self): - """Update the state of the action containing visualization menu. - """ - selectedAction = self.__visualizationGroup.checkedAction() - if selectedAction is not None: - self.__visualizationAction.setText(selectedAction.text()) - self.__visualizationAction.setIcon(selectedAction.icon()) - self.__visualizationAction.setToolTip(selectedAction.toolTip()) - else: - self.__visualizationAction.setText("") - self.__visualizationAction.setIcon(qt.QIcon()) - self.__visualizationAction.setToolTip("") - - def __alignmentModeChanged(self, selectedAction): - """Called when user requesting changes of the alignment mode. - """ - self.__updateAlignmentMenu() - widget = self.getCompareWidget() - if widget is not None: - mode = selectedAction.property("mode") - widget.setAlignmentMode(mode) - - def __updateAlignmentMenu(self): - """Update the state of the action containing alignment menu. - """ - selectedAction = self.__alignmentGroup.checkedAction() - if selectedAction is not None: - self.__alignmentAction.setText(selectedAction.text()) - self.__alignmentAction.setIcon(selectedAction.icon()) - self.__alignmentAction.setToolTip(selectedAction.toolTip()) - else: - self.__alignmentAction.setText("") - self.__alignmentAction.setIcon(qt.QIcon()) - self.__alignmentAction.setToolTip("") - - def __keypointVisibilityChanged(self): - """Called when action managing keypoints visibility changes""" - widget = self.getCompareWidget() - if widget is not None: - keypointsVisible = self.__displayKeypoints.isChecked() - widget.setKeypointsVisible(keypointsVisible) - - -class CompareImagesStatusBar(qt.QStatusBar): - """StatusBar containing specific information contained in a - :class:`CompareImages` widget - - Use :meth:`setCompareWidget` to connect this toolbar to a specific - :class:`CompareImages` widget. - - :param Union[qt.QWidget,None] parent: Parent of this widget. - """ - def __init__(self, parent=None): - qt.QStatusBar.__init__(self, parent) - self.setSizeGripEnabled(False) - self.layout().setSpacing(0) - self.__compareWidget = None - self._label1 = qt.QLabel(self) - self._label1.setFrameShape(qt.QFrame.WinPanel) - self._label1.setFrameShadow(qt.QFrame.Sunken) - self._label2 = qt.QLabel(self) - self._label2.setFrameShape(qt.QFrame.WinPanel) - self._label2.setFrameShadow(qt.QFrame.Sunken) - self._transform = qt.QLabel(self) - self._transform.setFrameShape(qt.QFrame.WinPanel) - self._transform.setFrameShadow(qt.QFrame.Sunken) - self.addWidget(self._label1) - self.addWidget(self._label2) - self.addWidget(self._transform) - self._pos = None - self._updateStatusBar() - - def setCompareWidget(self, widget): - """ - Connect this tool bar to a specific :class:`CompareImages` widget. - - :param Union[None,CompareImages] widget: The widget to connect with. - """ - compareWidget = self.getCompareWidget() - if compareWidget is not None: - compareWidget.getPlot().sigPlotSignal.disconnect(self.__plotSignalReceived) - compareWidget.sigConfigurationChanged.disconnect(self.__dataChanged) - compareWidget = widget - if compareWidget is None: - self.__compareWidget = None - else: - self.__compareWidget = weakref.ref(compareWidget) - if compareWidget is not None: - compareWidget.getPlot().sigPlotSignal.connect(self.__plotSignalReceived) - compareWidget.sigConfigurationChanged.connect(self.__dataChanged) - - def getCompareWidget(self): - """Returns the connected widget. - - :rtype: CompareImages - """ - if self.__compareWidget is None: - return None - else: - return self.__compareWidget() - - def __plotSignalReceived(self, event): - """Called when old style signals at emmited from the plot.""" - if event["event"] == "mouseMoved": - x, y = event["x"], event["y"] - self.__mouseMoved(x, y) - - def __mouseMoved(self, x, y): - """Called when mouse move over the plot.""" - self._pos = x, y - self._updateStatusBar() - - def __dataChanged(self): - """Called when internal data from the connected widget changes.""" - self._updateStatusBar() - - def _formatData(self, data): - """Format pixel of an image. - - It supports intensity, RGB, and RGBA. - - :param Union[int,float,numpy.ndarray,str]: Value of a pixel - :rtype: str - """ - if data is None: - return "No data" - if isinstance(data, (int, numpy.integer)): - return "%d" % data - if isinstance(data, (float, numpy.floating)): - return "%f" % data - if isinstance(data, numpy.ndarray): - # RGBA value - if data.shape == (3,): - return "R:%d G:%d B:%d" % (data[0], data[1], data[2]) - elif data.shape == (4,): - return "R:%d G:%d B:%d A:%d" % (data[0], data[1], data[2], data[3]) - _logger.debug("Unsupported data format %s. Cast it to string.", type(data)) - return str(data) - - def _updateStatusBar(self): - """Update the content of the status bar""" - widget = self.getCompareWidget() - if widget is None: - self._label1.setText("Image1: NA") - self._label2.setText("Image2: NA") - self._transform.setVisible(False) - else: - transform = widget.getTransformation() - self._transform.setVisible(transform is not None) - if transform is not None: - has_notable_translation = not numpy.isclose(transform.tx, 0.0, atol=0.01) \ - or not numpy.isclose(transform.ty, 0.0, atol=0.01) - has_notable_scale = not numpy.isclose(transform.sx, 1.0, atol=0.01) \ - or not numpy.isclose(transform.sy, 1.0, atol=0.01) - has_notable_rotation = not numpy.isclose(transform.rot, 0.0, atol=0.01) - - strings = [] - if has_notable_translation: - strings.append("Translation") - if has_notable_scale: - strings.append("Scale") - if has_notable_rotation: - strings.append("Rotation") - if strings == []: - has_translation = not numpy.isclose(transform.tx, 0.0) \ - or not numpy.isclose(transform.ty, 0.0) - has_scale = not numpy.isclose(transform.sx, 1.0) \ - or not numpy.isclose(transform.sy, 1.0) - has_rotation = not numpy.isclose(transform.rot, 0.0) - if has_translation or has_scale or has_rotation: - text = "No big changes" - else: - text = "No changes" - else: - text = "+".join(strings) - self._transform.setText("Align: " + text) - - strings = [] - if not numpy.isclose(transform.ty, 0.0): - strings.append("Translation x: %0.3fpx" % transform.tx) - if not numpy.isclose(transform.ty, 0.0): - strings.append("Translation y: %0.3fpx" % transform.ty) - if not numpy.isclose(transform.sx, 1.0): - strings.append("Scale x: %0.3f" % transform.sx) - if not numpy.isclose(transform.sy, 1.0): - strings.append("Scale y: %0.3f" % transform.sy) - if not numpy.isclose(transform.rot, 0.0): - strings.append("Rotation: %0.3fdeg" % (transform.rot * 180 / numpy.pi)) - if strings == []: - text = "No transformation" - else: - text = "\n".join(strings) - self._transform.setToolTip(text) - - if self._pos is None: - self._label1.setText("Image1: NA") - self._label2.setText("Image2: NA") - else: - data1, data2 = widget.getRawPixelData(self._pos[0], self._pos[1]) - if isinstance(data1, str): - self._label1.setToolTip(data1) - text1 = "NA" - else: - self._label1.setToolTip("") - text1 = self._formatData(data1) - if isinstance(data2, str): - self._label2.setToolTip(data2) - text2 = "NA" - else: - self._label2.setToolTip("") - text2 = self._formatData(data2) - self._label1.setText("Image1: %s" % text1) - self._label2.setText("Image2: %s" % text2) - - -class CompareImages(qt.QMainWindow): - """Widget providing tools to compare 2 images. - - .. image:: img/CompareImages.png - - :param Union[qt.QWidget,None] parent: Parent of this widget. - :param backend: The backend to use, in: - 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none' - or a :class:`BackendBase.BackendBase` class - :type backend: str or :class:`BackendBase.BackendBase` - """ - - VisualizationMode = VisualizationMode - """Available visualization modes""" - - AlignmentMode = AlignmentMode - """Available alignment modes""" - - sigConfigurationChanged = qt.Signal() - """Emitted when the configuration of the widget (visualization mode, - alignement mode...) have changed.""" - - def __init__(self, parent=None, backend=None): - qt.QMainWindow.__init__(self, parent) - - if parent is None: - self.setWindowTitle('Compare images') - else: - self.setWindowFlags(qt.Qt.Widget) - - self.__transformation = None - self.__raw1 = None - self.__raw2 = None - self.__data1 = None - self.__data2 = None - self.__previousSeparatorPosition = None - - self.__plot = plot.PlotWidget(parent=self, backend=backend) - self.__plot.getXAxis().setLabel('Columns') - self.__plot.getYAxis().setLabel('Rows') - if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': - self.__plot.getYAxis().setInverted(True) - - self.__plot.setKeepDataAspectRatio(True) - self.__plot.sigPlotSignal.connect(self.__plotSlot) - self.__plot.setAxesDisplayed(False) - - self.setCentralWidget(self.__plot) - - legend = VisualizationMode.VERTICAL_LINE.name - self.__plot.addXMarker( - 0, - legend=legend, - text='', - draggable=True, - color='blue', - constraint=self.__separatorConstraint) - self.__vline = self.__plot._getMarker(legend) - - legend = VisualizationMode.HORIZONTAL_LINE.name - self.__plot.addYMarker( - 0, - legend=legend, - text='', - draggable=True, - color='blue', - constraint=self.__separatorConstraint) - self.__hline = self.__plot._getMarker(legend) - - # default values - self.__visualizationMode = "" - self.__alignmentMode = "" - self.__keypointsVisible = True - - self.setAlignmentMode(AlignmentMode.ORIGIN) - self.setVisualizationMode(VisualizationMode.VERTICAL_LINE) - self.setKeypointsVisible(False) - - # Toolbars - - self._createToolBars(self.__plot) - if self._interactiveModeToolBar is not None: - self.addToolBar(self._interactiveModeToolBar) - if self._imageToolBar is not None: - self.addToolBar(self._imageToolBar) - if self._compareToolBar is not None: - self.addToolBar(self._compareToolBar) - - # Statusbar - - self._createStatusBar(self.__plot) - if self._statusBar is not None: - self.setStatusBar(self._statusBar) - - def _createStatusBar(self, plot): - self._statusBar = CompareImagesStatusBar(self) - self._statusBar.setCompareWidget(self) - - def _createToolBars(self, plot): - """Create tool bars displayed by the widget""" - toolBar = tools.InteractiveModeToolBar(parent=self, plot=plot) - self._interactiveModeToolBar = toolBar - toolBar = tools.ImageToolBar(parent=self, plot=plot) - self._imageToolBar = toolBar - toolBar = CompareImagesToolBar(self) - toolBar.setCompareWidget(self) - self._compareToolBar = toolBar - - def getPlot(self): - """Returns the plot which is used to display the images. - - :rtype: silx.gui.plot.PlotWidget - """ - return self.__plot - - def getRawPixelData(self, x, y): - """Return the raw pixel of each image data from axes positions. - - If the coordinate is outside of the image it returns None element in - the tuple. - - The pixel is reach from the raw data image without filter or - transformation. But the coordinate x and y are in the reference of the - current displayed mode. - - :param float x: X-coordinate of the pixel in the current displayed plot - :param float y: Y-coordinate of the pixel in the current displayed plot - :return: A tuple of for each images containing pixel information. It - could be a scalar value or an array in case of RGB/RGBA informations. - It also could be a string containing information is some cases. - :rtype: Tuple(Union[int,float,numpy.ndarray,str],Union[int,float,numpy.ndarray,str]) - """ - data2 = None - alignmentMode = self.__alignmentMode - raw1, raw2 = self.__raw1, self.__raw2 - if alignmentMode == AlignmentMode.ORIGIN: - x1 = x - y1 = y - x2 = x - y2 = y - elif alignmentMode == AlignmentMode.CENTER: - yy = max(raw1.shape[0], raw2.shape[0]) - xx = max(raw1.shape[1], raw2.shape[1]) - x1 = x - (xx - raw1.shape[1]) * 0.5 - x2 = x - (xx - raw2.shape[1]) * 0.5 - y1 = y - (yy - raw1.shape[0]) * 0.5 - y2 = y - (yy - raw2.shape[0]) * 0.5 - elif alignmentMode == AlignmentMode.STRETCH: - x1 = x - y1 = y - x2 = x * raw2.shape[1] / raw1.shape[1] - y2 = x * raw2.shape[1] / raw1.shape[1] - elif alignmentMode == AlignmentMode.AUTO: - x1 = x - y1 = y - # Not implemented - data2 = "Not implemented with sift" - else: - assert(False) - - x1, y1 = int(x1), int(y1) - if raw1 is None or y1 < 0 or y1 >= raw1.shape[0] or x1 < 0 or x1 >= raw1.shape[1]: - data1 = None - else: - data1 = raw1[y1, x1] - - if data2 is None: - x2, y2 = int(x2), int(y2) - if raw2 is None or y2 < 0 or y2 >= raw2.shape[0] or x2 < 0 or x2 >= raw2.shape[1]: - data2 = None - else: - data2 = raw2[y2, x2] - - return data1, data2 - - def setVisualizationMode(self, mode): - """Set the visualization mode. - - :param str mode: New visualization to display the image comparison - """ - if self.__visualizationMode == mode: - return - self.__visualizationMode = mode - mode = self.getVisualizationMode() - self.__vline.setVisible(mode == VisualizationMode.VERTICAL_LINE) - self.__hline.setVisible(mode == VisualizationMode.HORIZONTAL_LINE) - self.__updateData() - self.sigConfigurationChanged.emit() - - def getVisualizationMode(self): - """Returns the current interaction mode.""" - return self.__visualizationMode - - def setAlignmentMode(self, mode): - """Set the alignment mode. - - :param str mode: New alignement to apply to images - """ - if self.__alignmentMode == mode: - return - self.__alignmentMode = mode - self.__updateData() - self.sigConfigurationChanged.emit() - - def getAlignmentMode(self): - """Returns the current selected alignemnt mode.""" - return self.__alignmentMode - - def setKeypointsVisible(self, isVisible): - """Set keypoints visibility. - - :param bool isVisible: If True, keypoints are displayed (if some) - """ - if self.__keypointsVisible == isVisible: - return - self.__keypointsVisible = isVisible - self.__updateKeyPoints() - self.sigConfigurationChanged.emit() - - def __setDefaultAlignmentMode(self): - """Reset the alignemnt mode to the default value""" - self.setAlignmentMode(AlignmentMode.ORIGIN) - - def __plotSlot(self, event): - """Handle events from the plot""" - if event['event'] in ('markerMoving', 'markerMoved'): - mode = self.getVisualizationMode() - legend = mode.name - if event['label'] == legend: - if mode == VisualizationMode.VERTICAL_LINE: - value = int(float(str(event['xdata']))) - elif mode == VisualizationMode.HORIZONTAL_LINE: - value = int(float(str(event['ydata']))) - else: - assert(False) - if self.__previousSeparatorPosition != value: - self.__separatorMoved(value) - self.__previousSeparatorPosition = value - - def __separatorConstraint(self, x, y): - """Manage contains on the separators to clamp them inside the images.""" - if self.__data1 is None: - return 0, 0 - x = int(x) - if x < 0: - x = 0 - elif x > self.__data1.shape[1]: - x = self.__data1.shape[1] - y = int(y) - if y < 0: - y = 0 - elif y > self.__data1.shape[0]: - y = self.__data1.shape[0] - return x, y - - def __updateSeparators(self): - """Redraw images according to the current state of the separators. - """ - mode = self.getVisualizationMode() - if mode == VisualizationMode.VERTICAL_LINE: - pos = self.__vline.getXPosition() - self.__separatorMoved(pos) - self.__previousSeparatorPosition = pos - elif mode == VisualizationMode.HORIZONTAL_LINE: - pos = self.__hline.getYPosition() - self.__separatorMoved(pos) - self.__previousSeparatorPosition = pos - else: - self.__image1.setOrigin((0, 0)) - self.__image2.setOrigin((0, 0)) - - def __separatorMoved(self, pos): - """Called when vertical or horizontal separators have moved. - - Update the displayed images. - """ - if self.__data1 is None: - return - - mode = self.getVisualizationMode() - if mode == VisualizationMode.VERTICAL_LINE: - pos = int(pos) - if pos <= 0: - pos = 0 - elif pos >= self.__data1.shape[1]: - pos = self.__data1.shape[1] - data1 = self.__data1[:, 0:pos] - data2 = self.__data2[:, pos:] - self.__image1.setData(data1, copy=False) - self.__image2.setData(data2, copy=False) - self.__image2.setOrigin((pos, 0)) - elif mode == VisualizationMode.HORIZONTAL_LINE: - pos = int(pos) - if pos <= 0: - pos = 0 - elif pos >= self.__data1.shape[0]: - pos = self.__data1.shape[0] - data1 = self.__data1[0:pos, :] - data2 = self.__data2[pos:, :] - self.__image1.setData(data1, copy=False) - self.__image2.setData(data2, copy=False) - self.__image2.setOrigin((0, pos)) - else: - assert(False) - - def setData(self, image1, image2): - """Set images to compare. - - Images can contains floating-point or integer values, or RGB and RGBA - values, but should have comparable intensities. - - RGB and RGBA images are provided as an array as `[width,height,channels]` - of usigned integer 8-bits or floating-points between 0.0 to 1.0. - - :param numpy.ndarray image1: The first image - :param numpy.ndarray image2: The second image - """ - self.__raw1 = image1 - self.__raw2 = image2 - self.__updateData() - self.__plot.resetZoom() - - def setImage1(self, image1): - """Set image1 to be compared. - - Images can contains floating-point or integer values, or RGB and RGBA - values, but should have comparable intensities. - - RGB and RGBA images are provided as an array as `[width,height,channels]` - of usigned integer 8-bits or floating-points between 0.0 to 1.0. - - :param numpy.ndarray image1: The first image - """ - self.__raw1 = image1 - self.__updateData() - self.__plot.resetZoom() - - def setImage2(self, image2): - """Set image2 to be compared. - - Images can contains floating-point or integer values, or RGB and RGBA - values, but should have comparable intensities. - - RGB and RGBA images are provided as an array as `[width,height,channels]` - of usigned integer 8-bits or floating-points between 0.0 to 1.0. - - :param numpy.ndarray image2: The second image - """ - self.__raw2 = image2 - self.__updateData() - self.__plot.resetZoom() - - def __updateKeyPoints(self): - """Update the displayed keypoints using cached keypoints. - """ - if self.__keypointsVisible: - data = self.__matching_keypoints - else: - data = [], [], [] - self.__plot.addScatter(x=data[0], - y=data[1], - z=1, - value=data[2], - legend="keypoints", - colormap=Colormap("spring")) - - def __updateData(self): - """Compute aligned image when the alignement mode changes. - - This function cache input images which are used when - vertical/horizontal separators moves. - """ - raw1, raw2 = self.__raw1, self.__raw2 - if raw1 is None or raw2 is None: - return - - alignmentMode = self.getAlignmentMode() - self.__transformation = None - - if alignmentMode == AlignmentMode.ORIGIN: - yy = max(raw1.shape[0], raw2.shape[0]) - xx = max(raw1.shape[1], raw2.shape[1]) - size = yy, xx - data1 = self.__createMarginImage(raw1, size, transparent=True) - data2 = self.__createMarginImage(raw2, size, transparent=True) - self.__matching_keypoints = [0.0], [0.0], [1.0] - elif alignmentMode == AlignmentMode.CENTER: - yy = max(raw1.shape[0], raw2.shape[0]) - xx = max(raw1.shape[1], raw2.shape[1]) - size = yy, xx - data1 = self.__createMarginImage(raw1, size, transparent=True, center=True) - data2 = self.__createMarginImage(raw2, size, transparent=True, center=True) - self.__matching_keypoints = ([data1.shape[1] // 2], - [data1.shape[0] // 2], - [1.0]) - elif alignmentMode == AlignmentMode.STRETCH: - data1 = raw1 - data2 = self.__rescaleImage(raw2, data1.shape) - self.__matching_keypoints = ([0, data1.shape[1], data1.shape[1], 0], - [0, 0, data1.shape[0], data1.shape[0]], - [1.0, 1.0, 1.0, 1.0]) - elif alignmentMode == AlignmentMode.AUTO: - # TODO: sift implementation do not support RGBA images - yy = max(raw1.shape[0], raw2.shape[0]) - xx = max(raw1.shape[1], raw2.shape[1]) - size = yy, xx - data1 = self.__createMarginImage(raw1, size) - data2 = self.__createMarginImage(raw2, size) - self.__matching_keypoints = [0.0], [0.0], [1.0] - try: - data1, data2 = self.__createSiftData(data1, data2) - if data2 is None: - raise ValueError("Unexpected None value") - except Exception as e: - # TODO: Display it on the GUI - _logger.error(e) - self.__setDefaultAlignmentMode() - return - else: - assert(False) - - mode = self.getVisualizationMode() - if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG: - data1 = self.__composeImage(data1, data2, mode) - data2 = numpy.empty((0, 0)) - elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY: - data1 = self.__composeImage(data1, data2, mode) - data2 = numpy.empty((0, 0)) - elif mode == VisualizationMode.ONLY_A: - data2 = numpy.empty((0, 0)) - elif mode == VisualizationMode.ONLY_B: - data1 = numpy.empty((0, 0)) - - self.__data1, self.__data2 = data1, data2 - self.__plot.addImage(data1, z=0, legend="image1", resetzoom=False) - self.__plot.addImage(data2, z=0, legend="image2", resetzoom=False) - self.__image1 = self.__plot.getImage("image1") - self.__image2 = self.__plot.getImage("image2") - self.__updateKeyPoints() - - # Set the separator into the middle - if self.__previousSeparatorPosition is None: - value = self.__data1.shape[1] // 2 - self.__vline.setPosition(value, 0) - value = self.__data1.shape[0] // 2 - self.__hline.setPosition(0, value) - self.__updateSeparators() - - # Avoid to change the colormap range when the separator is moving - # TODO: The colormap histogram will still be wrong - mode1 = self.__getImageMode(data1) - mode2 = self.__getImageMode(data2) - if mode1 == "intensity" and mode1 == mode2: - if self.__data1.size == 0: - vmin = self.__data2.min() - vmax = self.__data2.max() - elif self.__data2.size == 0: - vmin = self.__data1.min() - vmax = self.__data1.max() - else: - vmin = min(self.__data1.min(), self.__data2.min()) - vmax = max(self.__data1.max(), self.__data2.max()) - colormap = Colormap(vmin=vmin, vmax=vmax) - self.__image1.setColormap(colormap) - self.__image2.setColormap(colormap) - - def __getImageMode(self, image): - """Returns a value identifying the way the image is stored in the - array. - - :param numpy.ndarray image: Image to check - :rtype: str - """ - if len(image.shape) == 2: - return "intensity" - elif len(image.shape) == 3: - if image.shape[2] == 3: - return "rgb" - elif image.shape[2] == 4: - return "rgba" - raise TypeError("'image' argument is not an image.") - - def __rescaleImage(self, image, shape): - """Rescale an image to the requested shape. - - :rtype: numpy.ndarray - """ - mode = self.__getImageMode(image) - if mode == "intensity": - data = self.__rescaleArray(image, shape) - elif mode == "rgb": - data = numpy.empty((shape[0], shape[1], 3), dtype=image.dtype) - for c in range(3): - data[:, :, c] = self.__rescaleArray(image[:, :, c], shape) - elif mode == "rgba": - data = numpy.empty((shape[0], shape[1], 4), dtype=image.dtype) - for c in range(4): - data[:, :, c] = self.__rescaleArray(image[:, :, c], shape) - return data - - def __composeImage(self, data1, data2, mode): - """Returns an RBG image containing composition of data1 and data2 in 2 - different channels - - :param numpy.ndarray data1: First image - :param numpy.ndarray data1: Second image - :param VisualizationMode mode: Composition mode. - :rtype: numpy.ndarray - """ - assert(data1.shape[0:2] == data2.shape[0:2]) - mode1 = self.__getImageMode(data1) - if mode1 in ["rgb", "rgba"]: - intensity1 = self.__luminosityImage(data1) - vmin1, vmax1 = 0.0, 1.0 - else: - intensity1 = data1 - vmin1, vmax1 = data1.min(), data1.max() - - mode2 = self.__getImageMode(data2) - if mode2 in ["rgb", "rgba"]: - intensity2 = self.__luminosityImage(data2) - vmin2, vmax2 = 0.0, 1.0 - else: - intensity2 = data2 - vmin2, vmax2 = data2.min(), data2.max() - - vmin, vmax = min(vmin1, vmin2) * 1.0, max(vmax1, vmax2) * 1.0 - shape = data1.shape - result = numpy.empty((shape[0], shape[1], 3), dtype=numpy.uint8) - a = (intensity1 - vmin) * (1.0 / (vmax - vmin)) * 255.0 - b = (intensity2 - vmin) * (1.0 / (vmax - vmin)) * 255.0 - if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY: - result[:, :, 0] = a - result[:, :, 1] = (a + b) / 2 - result[:, :, 2] = b - elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG: - result[:, :, 0] = 255 - b - result[:, :, 1] = 255 - (a + b) / 2 - result[:, :, 2] = 255 - a - return result - - def __luminosityImage(self, image): - """Returns the luminosity channel from an RBG(A) image. - The alpha channel is ignored. - - :rtype: numpy.ndarray - """ - mode = self.__getImageMode(image) - assert(mode in ["rgb", "rgba"]) - is_uint8 = image.dtype.type == numpy.uint8 - # luminosity - image = 0.21 * image[..., 0] + 0.72 * image[..., 1] + 0.07 * image[..., 2] - if is_uint8: - image = image / 255.0 - return image - - def __rescaleArray(self, image, shape): - """Rescale a 2D array to the requested shape. - - :rtype: numpy.ndarray - """ - y, x = numpy.ogrid[:shape[0], :shape[1]] - y, x = y * 1.0 * (image.shape[0] - 1) / (shape[0] - 1), x * 1.0 * (image.shape[1] - 1) / (shape[1] - 1) - b = silx.image.bilinear.BilinearImage(image) - # TODO: could be optimized using strides - x2d = numpy.zeros_like(y) + x - y2d = numpy.zeros_like(x) + y - result = b.map_coordinates((y2d, x2d)) - return result - - def __createMarginImage(self, image, size, transparent=False, center=False): - """Returns a new image with margin to respect the requested size. - - :rtype: numpy.ndarray - """ - assert(image.shape[0] <= size[0]) - assert(image.shape[1] <= size[1]) - if image.shape == size: - return image - mode = self.__getImageMode(image) - - if center: - pos0 = size[0] // 2 - image.shape[0] // 2 - pos1 = size[1] // 2 - image.shape[1] // 2 - else: - pos0, pos1 = 0, 0 - - if mode == "intensity": - data = numpy.zeros(size, dtype=image.dtype) - data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1]] = image - # TODO: It is maybe possible to put NaN on the margin - else: - if transparent: - data = numpy.zeros((size[0], size[1], 4), dtype=numpy.uint8) - else: - data = numpy.zeros((size[0], size[1], 3), dtype=numpy.uint8) - depth = min(data.shape[2], image.shape[2]) - data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 0:depth] = image[:, :, 0:depth] - if transparent and depth == 3: - data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 3] = 255 - return data - - def __toAffineTransformation(self, sift_result): - """Returns an affine transformation from the sift result. - - :param dict sift_result: Result of sift when using `all_result=True` - :rtype: AffineTransformation - """ - offset = sift_result["offset"] - matrix = sift_result["matrix"] - - tx = offset[0] - ty = offset[1] - a = matrix[0, 0] - b = matrix[0, 1] - c = matrix[1, 0] - d = matrix[1, 1] - rot = math.atan2(-b, a) - sx = (-1.0 if a < 0 else 1.0) * math.sqrt(a**2 + b**2) - sy = (-1.0 if d < 0 else 1.0) * math.sqrt(c**2 + d**2) - return AffineTransformation(tx, ty, sx, sy, rot) - - def getTransformation(self): - """Retuns the affine transformation applied to the second image to align - it to the first image. - - This result is only valid for sift alignment. - - :rtype: Union[None,AffineTransformation] - """ - return self.__transformation - - def __createSiftData(self, image, second_image): - """Generate key points and aligned images from 2 images. - - If no keypoints matches, unaligned data are anyway returns. - - :rtype: Tuple(numpy.ndarray,numpy.ndarray) - """ - devicetype = "GPU" - - # Compute base image - sift_ocl = sift.SiftPlan(template=image, devicetype=devicetype) - keypoints = sift_ocl(image) - - # Check image compatibility - second_keypoints = sift_ocl(second_image) - mp = sift.MatchPlan() - match = mp(keypoints, second_keypoints) - _logger.info("Number of Keypoints within image 1: %i" % keypoints.size) - _logger.info(" within image 2: %i" % second_keypoints.size) - - self.__matching_keypoints = (match[:].x[:, 0], - match[:].y[:, 0], - match[:].scale[:, 0]) - matching_keypoints = match.shape[0] - _logger.info("Matching keypoints: %i" % matching_keypoints) - if matching_keypoints == 0: - return image, second_image - - # TODO: Problem here is we have to compute 2 time sift - # The first time to extract matching keypoints, second time - # to extract the aligned image. - - # Normalize the second image - sa = sift.LinearAlign(image, devicetype=devicetype) - data1 = image - # TODO: Create a sift issue: if data1 is RGB and data2 intensity - # it returns None, while extracting manually keypoints (above) works - result = sa.align(second_image, return_all=True) - data2 = result["result"] - self.__transformation = self.__toAffineTransformation(result) - return data1, data2 diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py deleted file mode 100644 index bbcb0a5..0000000 --- a/silx/gui/plot/ComplexImageView.py +++ /dev/null @@ -1,492 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides a widget to view 2D complex data. - -The :class:`ComplexImageView` widget is dedicated to visualize a single 2D dataset -of complex data. -""" - -from __future__ import absolute_import - -__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] -__license__ = "MIT" -__date__ = "24/04/2018" - - -import logging -import collections -import numpy - -from .. import qt, icons -from .PlotWindow import Plot2D -from . import items -from .items import ImageComplexData -from silx.gui.widgets.FloatEdit import FloatEdit - -_logger = logging.getLogger(__name__) - - -# Widgets - -class _AmplitudeRangeDialog(qt.QDialog): - """QDialog asking for the amplitude range to display.""" - - sigRangeChanged = qt.Signal(tuple) - """Signal emitted when the range has changed. - - It provides the new range as a 2-tuple: (max, delta) - """ - - def __init__(self, - parent=None, - amplitudeRange=None, - displayedRange=(None, 2)): - super(_AmplitudeRangeDialog, self).__init__(parent) - self.setWindowTitle('Set Displayed Amplitude Range') - - if amplitudeRange is not None: - amplitudeRange = min(amplitudeRange), max(amplitudeRange) - self._amplitudeRange = amplitudeRange - self._defaultDisplayedRange = displayedRange - - layout = qt.QFormLayout() - self.setLayout(layout) - - if self._amplitudeRange is not None: - min_, max_ = self._amplitudeRange - layout.addRow( - qt.QLabel('Data Amplitude Range: [%g, %g]' % (min_, max_))) - - self._maxLineEdit = FloatEdit(parent=self) - self._maxLineEdit.validator().setBottom(0.) - self._maxLineEdit.setAlignment(qt.Qt.AlignRight) - - self._maxLineEdit.editingFinished.connect(self._rangeUpdated) - layout.addRow('Displayed Max.:', self._maxLineEdit) - - self._autoscale = qt.QCheckBox('autoscale') - self._autoscale.toggled.connect(self._autoscaleCheckBoxToggled) - layout.addRow('', self._autoscale) - - self._deltaLineEdit = FloatEdit(parent=self) - self._deltaLineEdit.validator().setBottom(1.) - self._deltaLineEdit.setAlignment(qt.Qt.AlignRight) - self._deltaLineEdit.editingFinished.connect(self._rangeUpdated) - layout.addRow('Displayed delta (log10 unit):', self._deltaLineEdit) - - buttons = qt.QDialogButtonBox(self) - buttons.addButton(qt.QDialogButtonBox.Ok) - buttons.addButton(qt.QDialogButtonBox.Cancel) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addRow(buttons) - - # Set dialog from default values - self._resetDialogToDefault() - - self.rejected.connect(self._handleRejected) - - def _resetDialogToDefault(self): - """Set Widgets of the dialog from range information - """ - max_, delta = self._defaultDisplayedRange - - if max_ is not None: # Not in autoscale - displayedMax = max_ - elif self._amplitudeRange is not None: # Autoscale with data - displayedMax = self._amplitudeRange[1] - else: # Autoscale without data - displayedMax = '' - if displayedMax == "": - self._maxLineEdit.setText("") - else: - self._maxLineEdit.setValue(displayedMax) - self._maxLineEdit.setEnabled(max_ is not None) - - self._deltaLineEdit.setValue(delta) - - self._autoscale.setChecked(self._defaultDisplayedRange[0] is None) - - def getRangeInfo(self): - """Returns the current range as a 2-tuple (max, delta (in log10))""" - if self._autoscale.isChecked(): - max_ = None - else: - maxStr = self._maxLineEdit.text() - max_ = self._maxLineEdit.value() if maxStr else None - return max_, self._deltaLineEdit.value() if self._deltaLineEdit.text() else 2 - - def _handleRejected(self): - """Reset range info to default when rejected""" - self._resetDialogToDefault() - self._rangeUpdated() - - def _rangeUpdated(self): - """Handle QLineEdit editing finised""" - self.sigRangeChanged.emit(self.getRangeInfo()) - - def _autoscaleCheckBoxToggled(self, checked): - """Handle autoscale checkbox state changes""" - if checked: # Use default values - if self._amplitudeRange is None: - max_ = '' - else: - max_ = self._amplitudeRange[1] - if max_ == "": - self._maxLineEdit.setText("") - else: - self._maxLineEdit.setValue(max_) - self._maxLineEdit.setEnabled(not checked) - self._rangeUpdated() - - -class _ComplexDataToolButton(qt.QToolButton): - """QToolButton providing choices of complex data visualization modes - - :param parent: See :class:`QToolButton` - :param plot: The :class:`ComplexImageView` to control - """ - - _MODES = collections.OrderedDict([ - (ImageComplexData.Mode.ABSOLUTE, ('math-amplitude', 'Amplitude')), - (ImageComplexData.Mode.SQUARE_AMPLITUDE, - ('math-square-amplitude', 'Square amplitude')), - (ImageComplexData.Mode.PHASE, ('math-phase', 'Phase')), - (ImageComplexData.Mode.REAL, ('math-real', 'Real part')), - (ImageComplexData.Mode.IMAGINARY, - ('math-imaginary', 'Imaginary part')), - (ImageComplexData.Mode.AMPLITUDE_PHASE, - ('math-phase-color', 'Amplitude and Phase')), - (ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE, - ('math-phase-color-log', 'Log10(Amp.) and Phase')) - ]) - - _RANGE_DIALOG_TEXT = 'Set Amplitude Range...' - - def __init__(self, parent=None, plot=None): - super(_ComplexDataToolButton, self).__init__(parent=parent) - - assert plot is not None - self._plot2DComplex = plot - - menu = qt.QMenu(self) - menu.triggered.connect(self._triggered) - self.setMenu(menu) - - for mode, info in self._MODES.items(): - icon, text = info - action = qt.QAction(icons.getQIcon(icon), text, self) - action.setData(mode) - action.setIconVisibleInMenu(True) - menu.addAction(action) - - self._rangeDialogAction = qt.QAction(self) - self._rangeDialogAction.setText(self._RANGE_DIALOG_TEXT) - menu.addAction(self._rangeDialogAction) - - self.setPopupMode(qt.QToolButton.InstantPopup) - - self._modeChanged(self._plot2DComplex.getVisualizationMode()) - self._plot2DComplex.sigVisualizationModeChanged.connect( - self._modeChanged) - - def _modeChanged(self, mode): - """Handle change of visualization modes""" - icon, text = self._MODES[mode] - self.setIcon(icons.getQIcon(icon)) - self.setToolTip('Display the ' + text.lower()) - self._rangeDialogAction.setEnabled(mode == ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE) - - def _triggered(self, action): - """Handle triggering of menu actions""" - actionText = action.text() - - if actionText == self._RANGE_DIALOG_TEXT: # Show dialog - # Get amplitude range - data = self._plot2DComplex.getData(copy=False) - - if data.size > 0: - absolute = numpy.absolute(data) - dataRange = (numpy.nanmin(absolute), numpy.nanmax(absolute)) - else: - dataRange = None - - # Show dialog - dialog = _AmplitudeRangeDialog( - parent=self, - amplitudeRange=dataRange, - displayedRange=self._plot2DComplex._getAmplitudeRangeInfo()) - dialog.sigRangeChanged.connect(self._rangeChanged) - dialog.exec_() - dialog.sigRangeChanged.disconnect(self._rangeChanged) - - else: # update mode - mode = action.data() - if isinstance(mode, ImageComplexData.Mode): - self._plot2DComplex.setVisualizationMode(mode) - - def _rangeChanged(self, range_): - """Handle updates of range in the dialog""" - self._plot2DComplex._setAmplitudeRangeInfo(*range_) - - -class ComplexImageView(qt.QWidget): - """Display an image of complex data and allow to choose the visualization. - - :param parent: See :class:`QMainWindow` - """ - - Mode = ImageComplexData.Mode - """Also expose the modes inside the class""" - - sigDataChanged = qt.Signal() - """Signal emitted when data has changed.""" - - sigVisualizationModeChanged = qt.Signal(object) - """Signal emitted when the visualization mode has changed. - - It provides the new visualization mode. - """ - - def __init__(self, parent=None): - super(ComplexImageView, self).__init__(parent) - if parent is None: - self.setWindowTitle('ComplexImageView') - - self._plot2D = Plot2D(self) - - layout = qt.QHBoxLayout(self) - layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._plot2D) - self.setLayout(layout) - - # Create and add image to the plot - self._plotImage = ImageComplexData() - self._plotImage._setLegend('__ComplexImageView__complex_image__') - self._plotImage.sigItemChanged.connect(self._itemChanged) - self._plot2D._add(self._plotImage) - self._plot2D.setActiveImage(self._plotImage.getLegend()) - - toolBar = qt.QToolBar('Complex', self) - toolBar.addWidget( - _ComplexDataToolButton(parent=self, plot=self)) - - self._plot2D.insertToolBar(self._plot2D.getProfileToolbar(), toolBar) - - def _itemChanged(self, event): - """Handle item changed signal""" - if event is items.ItemChangedType.DATA: - self.sigDataChanged.emit() - elif event is items.ItemChangedType.VISUALIZATION_MODE: - mode = self.getVisualizationMode() - self.sigVisualizationModeChanged.emit(mode) - - def getPlot(self): - """Return the PlotWidget displaying the data""" - return self._plot2D - - def setData(self, data=None, copy=True): - """Set the complex data to display. - - :param numpy.ndarray data: 2D complex data - :param bool copy: True (default) to copy the data, - False to use provided data (do not modify!). - """ - if data is None: - data = numpy.zeros((0, 0), dtype=numpy.complex) - - previousData = self._plotImage.getComplexData(copy=False) - - self._plotImage.setData(data, copy=copy) - - if previousData.shape != data.shape: - self.getPlot().resetZoom() - - def getData(self, copy=True): - """Get the currently displayed complex data. - - :param bool copy: True (default) to return a copy of the data, - False to return internal data (do not modify!). - :return: The complex data array. - :rtype: numpy.ndarray of complex with 2 dimensions - """ - return self._plotImage.getComplexData(copy=copy) - - def getDisplayedData(self, copy=True): - """Returns the displayed data depending on the visualization mode - - WARNING: The returned data can be a uint8 RGBA image - - :param bool copy: True (default) to return a copy of the data, - False to return internal data (do not modify!) - :rtype: numpy.ndarray of float with 2 dims or RGBA image (uint8). - """ - mode = self.getVisualizationMode() - if mode in (self.Mode.AMPLITUDE_PHASE, - self.Mode.LOG10_AMPLITUDE_PHASE): - return self._plotImage.getRgbaImageData(copy=copy) - else: - return self._plotImage.getData(copy=copy) - - @staticmethod - def getSupportedVisualizationModes(): - """Returns the supported visualization modes. - - Supported visualization modes are: - - - amplitude: The absolute value provided by numpy.absolute - - phase: The phase (or argument) provided by numpy.angle - - real: Real part - - imaginary: Imaginary part - - amplitude_phase: Color-coded phase with amplitude as alpha. - - log10_amplitude_phase: - Color-coded phase with log10(amplitude) as alpha. - - :rtype: tuple of str - """ - return tuple(ImageComplexData.Mode) - - def setVisualizationMode(self, mode): - """Set the mode of visualization of the complex data. - - See :meth:`getSupportedVisualizationModes` for the list of - supported modes. - - :param str mode: The mode to use. - """ - self._plotImage.setVisualizationMode(mode) - - def getVisualizationMode(self): - """Get the current visualization mode of the complex data. - - :rtype: Mode - """ - return self._plotImage.getVisualizationMode() - - def _setAmplitudeRangeInfo(self, max_=None, delta=2): - """Set the amplitude range to display for 'log10_amplitude_phase' mode. - - :param max_: Max of the amplitude range. - If None it autoscales to data max. - :param float delta: Delta range in log10 to display - """ - self._plotImage._setAmplitudeRangeInfo(max_, delta) - - def _getAmplitudeRangeInfo(self): - """Returns the amplitude range to use for 'log10_amplitude_phase' mode. - - :return: (max, delta), if max is None, then it autoscales to data max - :rtype: 2-tuple""" - return self._plotImage._getAmplitudeRangeInfo() - - # Image item proxy - - def setColormap(self, colormap, mode=None): - """Set the colormap to use for amplitude, phase, real or imaginary. - - WARNING: This colormap is not used when displaying both - amplitude and phase. - - :param ~silx.gui.colors.Colormap colormap: The colormap - :param Mode mode: If specified, set the colormap of this specific mode - """ - self._plotImage.setColormap(colormap, mode) - - def getColormap(self, mode=None): - """Returns the colormap used to display the data. - - :param Mode mode: If specified, set the colormap of this specific mode - :rtype: ~silx.gui.colors.Colormap - """ - return self._plotImage.getColormap(mode=mode) - - def getOrigin(self): - """Returns the offset from origin at which to display the image. - - :rtype: 2-tuple of float - """ - return self._plotImage.getOrigin() - - 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 - """ - self._plotImage.setOrigin(origin) - - def getScale(self): - """Returns the scale of the image in data coordinates. - - :rtype: 2-tuple of float - """ - return self._plotImage.getScale() - - 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 - """ - self._plotImage.setScale(scale) - - # PlotWidget API proxy - - def getXAxis(self): - """Returns the X axis - - :rtype: :class:`.items.Axis` - """ - return self.getPlot().getXAxis() - - def getYAxis(self): - """Returns an Y axis - - :rtype: :class:`.items.Axis` - """ - return self.getPlot().getYAxis(axis='left') - - def getGraphTitle(self): - """Return the plot main title as a str.""" - return self.getPlot().getGraphTitle() - - def setGraphTitle(self, title=""): - """Set the plot main title. - - :param str title: Main title of the plot (default: '') - """ - self.getPlot().setGraphTitle(title) - - def setKeepDataAspectRatio(self, flag): - """Set whether the plot keeps data aspect ratio or not. - - :param bool flag: True to respect data aspect ratio - """ - self.getPlot().setKeepDataAspectRatio(flag) - - def isKeepDataAspectRatio(self): - """Returns whether the plot is keeping data aspect ratio or not.""" - return self.getPlot().isKeepDataAspectRatio() diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py deleted file mode 100644 index 81e684e..0000000 --- a/silx/gui/plot/CurvesROIWidget.py +++ /dev/null @@ -1,1044 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""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: Sum of the curve's values in the defined Region Of Intereset. - - .. image:: img/rawCounts.png - -- Net counts: Raw counts minus background - - .. image:: img/netCounts.png -""" - -__authors__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "13/11/2017" - -from collections import OrderedDict - -import logging -import os -import sys -import weakref - -import numpy - -from silx.io import dictdump -from silx.utils import deprecation - -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' - """ - - sigROISignal = qt.Signal(object) - - def __init__(self, parent=None, name=None, plot=None): - super(CurvesROIWidget, self).__init__(parent) - if name is not None: - self.setWindowTitle(name) - assert plot is not None - self._plotRef = weakref.ref(plot) - - 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) - - self.currentROI = None - self._middleROIMarkerFlag = False - self._isConnected = False # True if connected to plot signals - self._isInit = False - - def getPlotWidget(self): - """Returns the associated PlotWidget or None - - :rtype: Union[~silx.gui.plot.PlotWidget,None] - """ - return None if self._plotRef is None else self._plotRef() - - def showEvent(self, event): - self._visibilityChangedHandler(visible=True) - qt.QWidget.showEvent(self, event) - - def hideEvent(self, event): - self._visibilityChangedHandler(visible=False) - qt.QWidget.hideEvent(self, event) - - @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 setMiddleROIMarkerFlag(self, flag=True): - """Activate or deactivate middle marker. - - This allows shifting both min and max limits at once, by dragging - a marker located in the middle. - - :param bool flag: True to activate middle ROI marker - """ - if flag: - self._middleROIMarkerFlag = True - else: - self._middleROIMarkerFlag = False - - 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) - - def _roiSignal(self, ddict): - """Handle ROI widget signal""" - _logger.debug("CurvesROIWidget._roiSignal %s", str(ddict)) - plot = self.getPlotWidget() - if plot is None: - return - - if ddict['event'] == "AddROI": - xmin, xmax = plot.getXAxis().getLimits() - fromdata = xmin + 0.25 * (xmax - xmin) - todata = xmin + 0.75 * (xmax - xmin) - plot.remove('ROI min', kind='marker') - plot.remove('ROI max', kind='marker') - if self._middleROIMarkerFlag: - plot.remove('ROI middle', kind='marker') - roiList, roiDict = self.roiTable.getROIListAndDict() - nrois = len(roiList) - if nrois == 0: - newroi = "ICR" - fromdata, dummy0, todata, dummy1 = self._getAllLimits() - draggable = False - color = 'black' - else: - # find the next index free for newroi. - for i in range(nrois): - i += 1 - newroi = "newroi %d" % i - if newroi not in roiList: - break - color = 'blue' - draggable = True - plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) - if draggable and self._middleROIMarkerFlag: - pos = 0.5 * (fromdata + todata) - 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'] = plot.getXAxis().getLabel() - roiDict[newroi]['from'] = fromdata - roiDict[newroi]['to'] = todata - self.roiTable.fillFromROIDict(roilist=roiList, - roidict=roiDict, - currentroi=newroi) - self.currentROI = newroi - self.calculateRois() - elif ddict['event'] in ['DelROI', "ResetROI"]: - plot.remove('ROI min', kind='marker') - plot.remove('ROI max', kind='marker') - if self._middleROIMarkerFlag: - plot.remove('ROI middle', kind='marker') - roiList, roiDict = self.roiTable.getROIListAndDict() - roiDictKeys = list(roiDict.keys()) - if len(roiDictKeys): - currentroi = roiDictKeys[0] - else: - # create again the ICR - ddict = {"event": "AddROI"} - return self._roiSignal(ddict) - - self.roiTable.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.roiTable.getROIListAndDict() - fromdata = ddict['roi']['from'] - todata = ddict['roi']['to'] - plot.remove('ROI min', kind='marker') - plot.remove('ROI max', kind='marker') - if ddict['key'] == 'ICR': - draggable = False - color = 'black' - else: - draggable = True - color = 'blue' - plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) - if draggable and self._middleROIMarkerFlag: - pos = 0.5 * (fromdata + todata) - 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'] = plot.getActiveCurve(just_legend=1) - 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 _getAllLimits(self): - """Retrieve the limits based on the curves.""" - plot = self.getPlotWidget() - curves = () if plot is None else 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 - - @deprecation.deprecated(replacement="calculateRois", - reason="CamelCase convention") - def calculateROIs(self, *args, **kw): - self.calculateRois(*args, **kw) - - def calculateRois(self, roiList=None, roiDict=None): - """Compute ROI information""" - if roiList is None or roiDict is None: - roiList, roiDict = self.roiTable.getROIListAndDict() - - plot = self.getPlotWidget() - if plot is None: - activeCurve = None - else: - activeCurve = plot.getActiveCurve(just_legend=False) - - if activeCurve is None: - xproc = None - yproc = None - self.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.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.roiTable.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.roiTable.getROIListAndDict() - if self.currentROI in roiDict: - ddict['ROI'] = roiDict[self.currentROI] - else: - self.currentROI = None - ddict['current'] = self.currentROI - self.sigROISignal.emit(ddict) - - 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.roiTable.getROIListAndDict() - if self.currentROI is None: - return - if self.currentROI not in roiDict: - return - - plot = self.getPlotWidget() - if plot is None: - 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']) - 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']) - 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 - plot.addXMarker(roiDict[self.currentROI]['from'], - legend='ROI min', - text='ROI min', - color='blue', - draggable=True) - 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 _visibilityChangedHandler(self, visible): - """Handle widget's visibility updates. - - It is connected to plot signals only when visible. - """ - plot = self.getPlotWidget() - - if visible: - if not self._isInit: - # Deferred ROI widget init finalization - self._finalizeInit() - - if not self._isConnected and plot is not None: - plot.sigPlotSignal.connect(self._handleROIMarkerEvent) - plot.sigActiveCurveChanged.connect( - self._activeCurveChanged) - self._isConnected = True - - self.calculateRois() - else: - if self._isConnected: - if plot is not None: - plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent) - plot.sigActiveCurveChanged.disconnect( - self._activeCurveChanged) - self._isConnected = False - - def _activeCurveChanged(self, *args): - """Recompute ROIs when active curve changed.""" - self.calculateRois() - - def _finalizeInit(self): - self._isInit = True - self.sigROIWidgetSignal.connect(self._roiSignal) - # initialize with the ICR if no ROi existing yet - if len(self.getRois()) is 0: - self._roiSignal({'event': "AddROI"}) - - -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) - """Deprecated signal for backward compatibility with silx < 0.7. - Prefer connecting directly to :attr:`CurvesRoiWidget.sigRoiSignal` - """ - - def __init__(self, parent=None, plot=None, name=None): - super(CurvesROIDockWidget, self).__init__(name, parent) - - self.roiWidget = CurvesROIWidget(self, name, plot=plot) - """Main widget of type :class:`CurvesROIWidget`""" - - # convenience methods to offer a simpler API allowing to ignore - # the details of the underlying implementation - # (ALLÂ DEPRECATED) - self.calculateROIs = self.calculateRois = self.roiWidget.calculateRois - self.setRois = self.roiWidget.setRois - self.getRois = self.roiWidget.getRois - self.roiWidget.sigROISignal.connect(self._forwardSigROISignal) - self.currentROI = self.roiWidget.currentROI - - self.layout().setContentsMargins(0, 0, 0, 0) - self.setWidget(self.roiWidget) - - def _forwardSigROISignal(self, ddict): - # emit deprecated signal for backward compatibility (silx < 0.7) - self.sigROISignal.emit(ddict) - - 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 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_() - qt.QDockWidget.showEvent(self, event) diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py deleted file mode 100644 index eba9bc6..0000000 --- a/silx/gui/plot/ImageView.py +++ /dev/null @@ -1,871 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""QWidget displaying 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. - -For an example of use, see `imageview.py` in :ref:`sample-code`. -""" - -from __future__ import division - - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "26/04/2018" - - -import logging -import numpy - -import silx -from .. import qt - -from . import items, PlotWindow, PlotWidget, actions -from ..colors import Colormap -from ..colors import cursorColorForColormap -from .tools 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. - - The :class:`ImageView` inherits from :class:`.PlotWindow` (which provides - the toolbars) and also exposes :class:`.PlotWidget` API for further - plot control (plot title, axes labels, aspect ratio, ...). - - :param parent: The parent of this widget or None. - :param backend: The backend to use for the plot (default: matplotlib). - See :class:`.PlotWidget` 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') - - if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': - self.getYAxis().setInverted(True) - - 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.""" - self._histoHPlot = PlotWidget(backend=backend, parent=self) - self._histoHPlot.getWidgetHandle().setMinimumHeight( - self.HISTOGRAMS_HEIGHT) - self._histoHPlot.getWidgetHandle().setMaximumHeight( - self.HISTOGRAMS_HEIGHT) - self._histoHPlot.setInteractiveMode('zoom') - self._histoHPlot.sigPlotSignal.connect(self._histoHPlotCB) - - self.setPanWithArrowKeys(True) - - self.setInteractiveMode('zoom') # Color set in setColormap - self.sigPlotSignal.connect(self._imagePlotCB) - self.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted) - self.sigActiveImageChanged.connect(self._activeImageChangedSlot) - - self._histoVPlot = PlotWidget(backend=backend, parent=self) - self._histoVPlot.getWidgetHandle().setMinimumWidth( - self.HISTOGRAMS_HEIGHT) - self._histoVPlot.getWidgetHandle().setMaximumWidth( - self.HISTOGRAMS_HEIGHT) - self._histoVPlot.setInteractiveMode('zoom') - self._histoVPlot.sigPlotSignal.connect(self._histoVPlotCB) - - self._radarView = RadarView(parent=self) - self._radarView.visibleRectDragged.connect(self._radarViewCB) - - layout = qt.QGridLayout() - layout.addWidget(self.getWidgetHandle(), 0, 0) - layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1) - layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0) - layout.addWidget(self._radarView, 1, 1, 1, 2) - layout.addWidget(self.getColorBarWidget(), 0, 2) - - layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE) - layout.setColumnStretch(0, 1) - layout.setColumnMinimumWidth(1, self.HISTOGRAMS_HEIGHT) - layout.setColumnStretch(1, 0) - - layout.setRowMinimumHeight(0, self.IMAGE_MIN_SIZE) - layout.setRowStretch(0, 1) - layout.setRowMinimumHeight(1, self.HISTOGRAMS_HEIGHT) - layout.setRowStretch(1, 0) - - layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) - - centralWidget = qt.QWidget(self) - centralWidget.setLayout(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.getXAxis().getLimits() - yMin, yMax = self.getYAxis().getLimits() - - # 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.getYAxis().setLimits(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.getXAxis().setLimits(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.getXAxis().getLimits() - yMin, yMax = self.getYAxis().getLimits() - 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': - self._updateHistogramsLimits() - - def _updateHistogramsLimits(self): - # Do not handle histograms limitsChanged while - # updating their limits from here. - self._updatingLimits = True - - # Refresh histograms - self._updateHistograms() - - xMin, xMax = self.getXAxis().getLimits() - yMin, yMax = self.getYAxis().getLimits() - - # Set horizontal histo limits - self._histoHPlot.getXAxis().setLimits(xMin, xMax) - - # Set vertical histo limits - self._histoVPlot.getYAxis().setLimits(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.getXAxis().getLimits()): - xMin, xMax = eventDict['xdata'] - self.getXAxis().setLimits(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.getYAxis().getLimits()): - yMin, yMax = eventDict['ydata'] - self.getYAxis().setLimits(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.getYAxis().isInverted() - - self._histoVPlot.getYAxis().setInverted(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.centralWidget().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 - """ - cmap = self.getDefaultColormap() - - if isinstance(colormap, Colormap): - # Replace colormap - cmap = colormap - - self.setDefaultColormap(cmap) - - # Update active image colormap - activeImage = self.getActiveImage() - if isinstance(activeImage, items.ColormapMixIn): - activeImage.setColormap(cmap) - - elif 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 - cmap._setFromDict(colormap) - - else: - if colormap is not None: - cmap.setName(colormap) - if normalization is not None: - cmap.setNormalization(normalization) - if autoscale: - cmap.setVRange(None, None) - else: - if vmin is not None: - cmap.setVMin(vmin) - if vmax is not None: - cmap.setVMax(vmax) - if colors is not None: - cmap.setColormapLUT(colors) - - cursorColor = cursorColorForColormap(cmap.getName()) - self.setInteractiveMode('zoom', color=cursorColor) - - 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(), - resetzoom=False) - self.setActiveImage(self._imageLegend) - self._updateHistograms() - - self._radarView.setDataRect(origin[0], - origin[1], - width * scale[0], - height * scale[1]) - - if reset: - self.resetZoom() - else: - self._updateHistogramsLimits() - - -# 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.getXAxis().setLabel('X') - self.getYAxis().setLabel('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.getOutputToolBar().getSaveAction()) - menu.addAction(self.getOutputToolBar().getPrintAction()) - menu.addSeparator() - action = menu.addAction('Quit') - action.triggered[bool].connect(qt.QApplication.instance().quit) - - menu = self.menuBar().addMenu('Edit') - menu.addAction(self.getOutputToolBar().getCopyAction()) - menu.addSeparator() - menu.addAction(self.getResetZoomAction()) - menu.addAction(self.getColormapAction()) - menu.addAction(actions.control.KeepAspectRatioAction(self, self)) - menu.addAction(actions.control.YAxisInvertedAction(self, self)) - - menu = self.menuBar().addMenu('Profile') - 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 deleted file mode 100644 index 358af74..0000000 --- a/silx/gui/plot/Interaction.py +++ /dev/null @@ -1,300 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides 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/ItemsSelectionDialog.py b/silx/gui/plot/ItemsSelectionDialog.py deleted file mode 100644 index acb287a..0000000 --- a/silx/gui/plot/ItemsSelectionDialog.py +++ /dev/null @@ -1,282 +0,0 @@ -# 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 a dialog widget to select plot items. - -.. autoclass:: ItemsSelectionDialog - -""" - -__authors__ = ["P. Knobel"] -__license__ = "MIT" -__date__ = "28/06/2017" - -import logging - -from silx.gui import qt -from silx.gui.plot.PlotWidget import PlotWidget - -_logger = logging.getLogger(__name__) - - -class KindsSelector(qt.QListWidget): - """List widget allowing to select plot item kinds - ("curve", "scatter", "image"...) - """ - sigSelectedKindsChanged = qt.Signal(list) - - def __init__(self, parent=None, kinds=None): - """ - - :param parent: Parent QWidget or None - :param tuple(str) kinds: Sequence of kinds. If None, the default - behavior is to provide a checkbox for all possible item kinds. - """ - qt.QListWidget.__init__(self, parent) - - self.plot_item_kinds = [] - - self.setAvailableKinds(kinds if kinds is not None else PlotWidget.ITEM_KINDS) - - self.setSelectionMode(qt.QAbstractItemView.ExtendedSelection) - self.selectAll() - - self.itemSelectionChanged.connect(self.emitSigKindsSelectionChanged) - - def emitSigKindsSelectionChanged(self): - self.sigSelectedKindsChanged.emit(self.selectedKinds) - - @property - def selectedKinds(self): - """Tuple of all selected kinds (as strings).""" - # check for updates when self.itemSelectionChanged - return [item.text() for item in self.selectedItems()] - - def setAvailableKinds(self, kinds): - """Set a list of kinds to be displayed. - - :param list[str] kinds: Sequence of kinds - """ - self.plot_item_kinds = kinds - - self.clear() - for kind in self.plot_item_kinds: - item = qt.QListWidgetItem(self) - item.setText(kind) - self.addItem(item) - - def selectAll(self): - """Select all available kinds.""" - if self.selectionMode() in [qt.QAbstractItemView.SingleSelection, - qt.QAbstractItemView.NoSelection]: - raise RuntimeError("selectAll requires a multiple selection mode") - for i in range(self.count()): - self.item(i).setSelected(True) - - -class PlotItemsSelector(qt.QTableWidget): - """Table widget displaying the legend and kind of all - plot items corresponding to a list of specified kinds. - - Selected plot items are provided as property :attr:`selectedPlotItems`. - You can be warned of selection changes by listening to signal - :attr:`itemSelectionChanged`. - """ - def __init__(self, parent=None, plot=None): - if plot is None or not isinstance(plot, PlotWidget): - raise AttributeError("parameter plot is required") - self.plot = plot - """:class:`PlotWidget` instance""" - - self.plot_item_kinds = None - """List of plot item kinds (strings)""" - - qt.QTableWidget.__init__(self, parent) - - self.setColumnCount(2) - - self.setSelectionBehavior(qt.QTableWidget.SelectRows) - - def _clear(self): - self.clear() - self.setHorizontalHeaderLabels(["legend", "type"]) - - def setAllKindsFilter(self): - """Display all kinds of plot items.""" - self.setKindsFilter(PlotWidget.ITEM_KINDS) - - def setKindsFilter(self, kinds): - """Set list of all kinds of plot items to be displayed. - - :param list[str] kinds: Sequence of kinds - """ - if not set(kinds) <= set(PlotWidget.ITEM_KINDS): - raise KeyError("Illegal plot item kinds: %s" % - set(kinds) - set(PlotWidget.ITEM_KINDS)) - self.plot_item_kinds = kinds - - self.updatePlotItems() - - def updatePlotItems(self): - self._clear() - - nrows = len(self.plot._getItems(kind=self.plot_item_kinds, - just_legend=True)) - self.setRowCount(nrows) - - # respect order of kinds as set in method setKindsFilter - i = 0 - for kind in self.plot_item_kinds: - for plot_item in self.plot._getItems(kind=kind): - legend_twitem = qt.QTableWidgetItem(plot_item.getLegend()) - self.setItem(i, 0, legend_twitem) - - kind_twitem = qt.QTableWidgetItem(kind) - self.setItem(i, 1, kind_twitem) - i += 1 - - @property - def selectedPlotItems(self): - """List of all selected items""" - selection_model = self.selectionModel() - selected_rows_idx = selection_model.selectedRows() - selected_rows = [idx.row() for idx in selected_rows_idx] - - items = [] - for row in selected_rows: - legend = self.item(row, 0).text() - kind = self.item(row, 1).text() - items.append(self.plot._getItem(kind, legend)) - - return items - - -class ItemsSelectionDialog(qt.QDialog): - """This widget is a modal dialog allowing to select one or more plot - items, in a table displaying their legend and kind. - - Public methods: - - - :meth:`getSelectedItems` - - :meth:`setAvailableKinds` - - :meth:`setItemsSelectionMode` - - This widget inherits QDialog and therefore implements the usual - dialog methods, e.g. :meth:`exec_`. - - A trivial usage example would be:: - - isd = ItemsSelectionDialog(plot=my_plot_widget) - isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection) - result = isd.exec_() - if result: - for item in isd.getSelectedItems(): - print(item.getLegend(), type(item)) - else: - print("Selection cancelled") - """ - def __init__(self, parent=None, plot=None): - if plot is None or not isinstance(plot, PlotWidget): - raise AttributeError("parameter plot is required") - qt.QDialog.__init__(self, parent) - - self.setWindowTitle("Plot items selector") - - kind_selector_label = qt.QLabel("Filter item kinds:", self) - item_selector_label = qt.QLabel("Select items:", self) - - self.kind_selector = KindsSelector(self) - self.kind_selector.setToolTip( - "select one or more item kinds to show them in the item list") - - self.item_selector = PlotItemsSelector(self, plot) - self.item_selector.setToolTip("select items") - - self.item_selector.setKindsFilter(self.kind_selector.selectedKinds) - self.kind_selector.sigSelectedKindsChanged.connect( - self.item_selector.setKindsFilter - ) - - okb = qt.QPushButton("OK", self) - okb.clicked.connect(self.accept) - - cancelb = qt.QPushButton("Cancel", self) - cancelb.clicked.connect(self.reject) - - layout = qt.QGridLayout(self) - layout.addWidget(kind_selector_label, 0, 0) - layout.addWidget(item_selector_label, 0, 1) - layout.addWidget(self.kind_selector, 1, 0) - layout.addWidget(self.item_selector, 1, 1) - layout.addWidget(okb, 2, 0) - layout.addWidget(cancelb, 2, 1) - - self.setLayout(layout) - - def getSelectedItems(self): - """Return a list of selected plot items - - :return: List of selected plot items - :rtype: list[silx.gui.plot.items.Item]""" - return self.item_selector.selectedPlotItems - - def setAvailableKinds(self, kinds): - """Set a list of kinds to be displayed. - - :param list[str] kinds: Sequence of kinds - """ - self.kind_selector.setAvailableKinds(kinds) - - def selectAllKinds(self): - self.kind_selector.selectAll() - - def setItemsSelectionMode(self, mode): - """Set selection mode for plot item (single item selection, - multiple...). - - :param mode: One of :class:`QTableWidget` selection modes - """ - if mode == self.item_selector.SingleSelection: - self.item_selector.setToolTip( - "Select one item by clicking on it.") - elif mode == self.item_selector.MultiSelection: - self.item_selector.setToolTip( - "Select one or more items by clicking with the left mouse" - " button.\nYou can unselect items by clicking them again.\n" - "Multiple items can be toggled by dragging the mouse over them.") - elif mode == self.item_selector.ExtendedSelection: - self.item_selector.setToolTip( - "Select one or more items. You can select multiple items " - "by keeping the Ctrl key pushed when clicking.\nYou can " - "select a range of items by clicking on the first and " - "last while keeping the Shift key pushed.") - elif mode == self.item_selector.ContiguousSelection: - self.item_selector.setToolTip( - "Select one item by clicking on it. If you press the Shift" - " key while clicking on a second item,\nall items between " - "the two will be selected.") - elif mode == self.item_selector.NoSelection: - raise ValueError("The NoSelection mode is not allowed " - "in this context.") - self.item_selector.setSelectionMode(mode) diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py deleted file mode 100644 index b9d0fd3..0000000 --- a/silx/gui/plot/LegendSelector.py +++ /dev/null @@ -1,1193 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""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__ = "16/10/2017" - - -import logging -import weakref - -import numpy - -from .. import qt, colors -from . import items - - -_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. - - :param QWidget parent: See :class:`QWidget` - :param Union[~silx.gui.plot.items.Curve,None] curve: - Curve with which to synchronize - """ - - def __init__(self, parent=None, curve=None): - super(LegendIcon, self).__init__(parent) - self._curveRef = None - - # 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) - - self.setCurve(curve) - - def sizeHint(self): - return qt.QSize(50, 15) - - # Synchronize with a curve - - def getCurve(self): - """Returns curve associated to this widget - - :rtype: Union[~silx.gui.plot.items.Curve,None] - """ - return None if self._curveRef is None else self._curveRef() - - def setCurve(self, curve): - """Set the curve with which to synchronize this widget. - - :param curve: Union[~silx.gui.plot.items.Curve,None] - """ - assert curve is None or isinstance(curve, items.Curve) - - previousCurve = self.getCurve() - if curve == previousCurve: - return - - if previousCurve is not None: - previousCurve.sigItemChanged.disconnect(self._curveChanged) - - self._curveRef = None if curve is None else weakref.ref(curve) - - if curve is not None: - curve.sigItemChanged.connect(self._curveChanged) - - self._update() - - def _update(self): - """Update widget according to current curve state. - """ - curve = self.getCurve() - if curve is None: - _logger.error('Curve no more exists') - self.setEnabled(False) - return - - style = curve.getCurrentStyle() - - self.setEnabled(curve.isVisible()) - self.setSymbol(style.getSymbol()) - self.setLineWidth(style.getLineWidth()) - self.setLineStyle(style.getLineStyle()) - - color = style.getColor() - if numpy.array(color, copy=False).ndim != 1: - # array of colors, use transparent black - color = 0., 0., 0., 0. - color = colors.rgba(color) # Make sure it is float in [0, 1] - alpha = curve.getAlpha() - color = qt.QColor.fromRgbF( - color[0], color[1], color[2], color[3] * alpha) - self.setLineColor(color) - self.setSymbolColor(color) - self.update() # TODO this should not be needed - - def _curveChanged(self, event): - """Handle update of curve item - - :param event: Kind of change - """ - if event in (items.ItemChangedType.VISIBLE, - items.ItemChangedType.SYMBOL, - items.ItemChangedType.SYMBOL_SIZE, - items.ItemChangedType.LINE_WIDTH, - items.ItemChangedType.LINE_STYLE, - items.ItemChangedType.COLOR, - items.ItemChangedType.ALPHA, - items.ItemChangedType.HIGHLIGHTED, - items.ItemChangedType.HIGHLIGHTED_STYLE): - self._update() - - # 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) - - # Override color when disabled - if self.isEnabled(): - overrideColor = None - else: - overrideColor = palette.color(qt.QPalette.Disabled, - qt.QPalette.WindowText) - - # 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) - lineBrush = qt.QBrush( - self.lineColor if overrideColor is None else overrideColor) - linePen = qt.QPen( - lineBrush, - (self.lineWidth / self.height()), - self.lineStyle, - qt.Qt.FlatCap - ) - llist.append((linePath, linePen, lineBrush)) - 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 if overrideColor is None else overrideColor, - 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) - self._palette = qt.QPalette() - - 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] - isActive = item[1].get("active", False) - 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 isActive: - brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.Highlight) - elif 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 - if isActive: - brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.HighlightedText) - else: - brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.WindowText) - 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.setPen(textBrush.color()) - 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(parent=self) - 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 changed - :param str newLegend: The new legend of the curve - """ - is_active = self.plot.getActiveCurve(just_legend=True) == oldLegend - 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) - if is_active: - self.plot.setActiveCurve(newLegend) - - 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 - isActive = legend == self.plot.getActiveCurve(just_legend=True) - style = curve.getCurrentStyle() - color = style.getColor() - if numpy.array(color, copy=False).ndim != 1: - # array of colors, use transparent black - color = 0., 0., 0., 0. - - curveInfo = { - 'color': qt.QColor.fromRgbF(*color), - 'linewidth': style.getLineWidth(), - 'linestyle': style.getLineStyle(), - 'symbol': style.getSymbol(), - 'selected': not self.plot.isCurveHidden(legend), - 'active': isActive} - 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/LimitsHistory.py b/silx/gui/plot/LimitsHistory.py deleted file mode 100644 index a323548..0000000 --- a/silx/gui/plot/LimitsHistory.py +++ /dev/null @@ -1,83 +0,0 @@ -# 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 handling of :class:`PlotWidget` limits history. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "19/07/2017" - - -from .. import qt - - -class LimitsHistory(qt.QObject): - """Class handling history of limits of a :class:`PlotWidget`. - - :param PlotWidget parent: The plot widget this object is bound to. - """ - - def __init__(self, parent): - self._history = [] - super(LimitsHistory, self).__init__(parent) - self.setParent(parent) - - def setParent(self, parent): - """See :meth:`QObject.setParent`. - - :param PlotWidget parent: The PlotWidget this object is bound to. - """ - self.clear() # Clear history when changing parent - super(LimitsHistory, self).setParent(parent) - - def push(self): - """Append current limits to the history.""" - plot = self.parent() - xmin, xmax = plot.getXAxis().getLimits() - ymin, ymax = plot.getYAxis(axis='left').getLimits() - y2min, y2max = plot.getYAxis(axis='right').getLimits() - self._history.append((xmin, xmax, ymin, ymax, y2min, y2max)) - - def pop(self): - """Restore previously limits stored in the history. - - :return: True if limits were restored, False if history was empty. - :rtype: bool - """ - plot = self.parent() - if self._history: - limits = self._history.pop(-1) - plot.setLimits(*limits) - return True - else: - plot.resetZoom() - return False - - def clear(self): - """Clear stored limits states.""" - self._history = [] - - def __len__(self): - return len(self._history) diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py deleted file mode 100644 index 990e479..0000000 --- a/silx/gui/plot/MaskToolsWidget.py +++ /dev/null @@ -1,774 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""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__ = "29/08/2018" - - -import os -import sys -import numpy -import logging -import collections -import h5py - -from silx.image import shapes -from silx.io.utils import NEXUS_HDF5_EXT, is_dataset -from silx.gui.dialog.DatasetDialog import DatasetDialog - -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__) - - -_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT]) - - -def _selectDataset(filename, mode=DatasetDialog.SaveMode): - """Open a dialog to prompt the user to select a dataset in - a hdf5 file. - - :param str filename: name of an existing HDF5 file - :param mode: DatasetDialog.SaveMode or DatasetDialog.LoadMode - :rtype: str - :return: Name of selected dataset - """ - dialog = DatasetDialog() - dialog.addFile(filename) - dialog.setWindowTitle("Select a 2D dataset") - dialog.setMode(mode) - if not dialog.exec_(): - return None - return dialog.getSelectedDataUrl().data_path() - - -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) - self.reset(shape=(0, 0)) # Init the mask with a 2D shape - - 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', 'h5' - 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) in NEXUS_HDF5_EXT: - self._saveToHdf5(filename, self.getMask(copy=False)) - - 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) - - @staticmethod - def _saveToHdf5(filename, mask): - """Save a mask array to a HDF5 file. - - :param str filename: name of an existing HDF5 file - :param numpy.ndarray mask: Mask array. - :returns: True if operation succeeded, False otherwise. - """ - if not os.path.exists(filename): - # create new file - with h5py.File(filename, "w") as _h5f: - pass - dataPath = _selectDataset(filename) - if dataPath is None: - return False - with h5py.File(filename, "a") as h5f: - existing_ds = h5f.get(dataPath) - if existing_ds is not None: - reply = qt.QMessageBox.question( - None, - "Confirm overwrite", - "Do you want to overwrite an existing dataset?", - qt.QMessageBox.Yes | qt.QMessageBox.No) - if reply != qt.QMessageBox.Yes: - return False - del h5f[dataPath] - try: - h5f.create_dataset(dataPath, data=mask) - except Exception: - return False - return True - - # 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): - super(MaskToolsWidget, self).__init__(parent, plot, - mask=ImageMask()) - 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 - - def setSelectionMask(self, mask, copy=True): - """Set the mask to a new array. - - :param numpy.ndarray mask: - The array to use for the mask or None to reset 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. - """ - if mask is None: - self.resetSelectionMask() - return self._data.shape[:2] - - 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 mask has not changed, do nothing - if numpy.array_equal(mask, self.getSelectionMask()): - return mask.shape - - # ensure all mask attributes are synchronized with the active image - # and connect listener - activeImage = self.plot.getActiveImage() - if activeImage is not None and activeImage.getLegend() != self._maskName: - self._activeImageChanged() - self.plot.sigActiveImageChanged.connect(self._activeImageChanged) - - 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 mask is not None: - # get the mask from the plot - maskItem = self.plot.getImage(self._maskName) - mustBeAdded = maskItem is None - if mustBeAdded: - maskItem = items.MaskImageData() - maskItem._setLegend(self._maskName) - # update the items - maskItem.setData(mask, copy=False) - maskItem.setColormap(self._colormap) - maskItem.setOrigin(self._origin) - maskItem.setScale(self._scale) - maskItem.setZValue(self._z) - - if mustBeAdded: - self.plot._add(maskItem) - - 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): - try: - self.plot.sigActiveImageChanged.disconnect( - self._activeImageChanged) - except (RuntimeError, TypeError): - pass - if self.isMaskInteractionActivated(): - # Disable drawing tool - self.browseAction.trigger() - - if self.getSelectionMask(copy=False) is not None: - 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._data = numpy.zeros((0, 0), dtype=numpy.uint8) - self._mask.setDataItem(None) - self._mask.reset() - - if self.plot.getImage(self._maskName): - self.plot.remove(self._maskName, kind='image') - - 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._mask.getMask(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 or - activeImage.getData(copy=False).size == 0): - # No active image or active image is the mask or image has no data... - 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._mask.getMask(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 in ["tif", "tiff"]: - try: - image = TiffIO(filename, mode="r") - mask = image.getImage(0) - except Exception as e: - _logger.error("Can't load filename %s", filename) - _logger.debug("Backtrace", exc_info=True) - raise e - 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 - elif ("." + extension) in NEXUS_HDF5_EXT: - mask = self._loadFromHdf5(filename) - if mask is None: - raise IOError("Could not load mask from HDF5 dataset") - 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) - - extensions = collections.OrderedDict() - extensions["EDF files"] = "*.edf" - extensions["TIFF files"] = "*.tif *.tiff" - extensions["NumPy binary files"] = "*.npy" - extensions["HDF5 files"] = _HDF5_EXT_STR - # Fit2D mask is displayed anyway fabio is here or not - # to show to the user that the option exists - extensions["Fit2D mask files"] = "*.msk" - - filters = [] - filters.append("All supported files (%s)" % " ".join(extensions.values())) - for name, extension in extensions.items(): - filters.append("%s (%s)" % (name, extension)) - filters.append("All files (*)") - - 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_() - - @staticmethod - def _loadFromHdf5(filename): - """Load a mask array from a HDF5 file. - - :param str filename: name of an existing HDF5 file - :returns: AÂ mask as a numpy array, or None if the interactive dialog - was cancelled - """ - dataPath = _selectDataset(filename, mode=DatasetDialog.LoadMode) - if dataPath is None: - return None - - with h5py.File(filename, "r") as h5f: - dataset = h5f.get(dataPath) - if not is_dataset(dataset): - raise IOError("%s is not a dataset" % dataPath) - mask = dataset[()] - return mask - - def _saveMask(self): - """Open Save mask dialog""" - dialog = qt.QFileDialog(self) - dialog.setWindowTitle("Save Mask") - dialog.setOption(dialog.DontUseNativeDialog) - dialog.setModal(1) - hdf5Filter = 'HDF5 (%s)' % _HDF5_EXT_STR - filters = [ - 'EDF (*.edf)', - 'TIFF (*.tif)', - 'NumPy binary file (*.npy)', - hdf5Filter, - # 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) - - def onFilterSelection(filt_): - # disable overwrite confirmation for HDF5, - # because we append the data to existing files - if filt_ == hdf5Filter: - dialog.setOption(dialog.DontConfirmOverwrite) - else: - dialog.setOption(dialog.DontConfirmOverwrite, False) - - dialog.filterSelected.connect(onFilterSelection) - if not dialog.exec_(): - dialog.close() - return - - nameFilter = dialog.selectedNameFilter() - filename = dialog.selectedFiles()[0] - dialog.close() - - if "HDF5" in nameFilter: - has_allowed_ext = False - for ext in NEXUS_HDF5_EXT: - if (len(filename) > len(ext) and - filename[-len(ext):].lower() == ext.lower()): - has_allowed_ext = True - extension = ext - if not has_allowed_ext: - extension = ".h5" - filename += ".h5" - else: - # convert filter name to extension name with the . - extension = nameFilter.split()[-1][2:-1] - if not filename.lower().endswith(extension): - filename += extension - - if os.path.exists(filename) and "HDF5" not in nameFilter: - 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: - raise - 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._getPencilWidth() - - 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'): - widget = MaskToolsWidget(plot=plot) - super(MaskToolsDockWidget, self).__init__(parent, name, widget) diff --git a/silx/gui/plot/PlotActions.py b/silx/gui/plot/PlotActions.py deleted file mode 100644 index dd16221..0000000 --- a/silx/gui/plot/PlotActions.py +++ /dev/null @@ -1,67 +0,0 @@ -# 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. -# -# ###########################################################################*/ -"""Depracted module linking old PlotAction with the actions.xxx""" - - -__author__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "01/06/2017" - -from silx.utils.deprecation import deprecated_warning - -deprecated_warning(type_='module', - name=__file__, - reason='PlotActions refactoring', - replacement='plot.actions', - since_version='0.6') - -from .actions import PlotAction - -from .actions.io import CopyAction -from .actions.io import PrintAction -from .actions.io import SaveAction - -from .actions.control import ColormapAction -from .actions.control import CrosshairAction -from .actions.control import CurveStyleAction -from .actions.control import GridAction -from .actions.control import KeepAspectRatioAction -from .actions.control import PanWithArrowKeysAction -from .actions.control import ResetZoomAction -from .actions.control import XAxisAutoScaleAction -from .actions.control import XAxisLogarithmicAction -from .actions.control import YAxisAutoScaleAction -from .actions.control import YAxisLogarithmicAction -from .actions.control import YAxisInvertedAction -from .actions.control import ZoomInAction -from .actions.control import ZoomOutAction - -from .actions.medfilt import MedianFilter1DAction -from .actions.medfilt import MedianFilter2DAction -from .actions.medfilt import MedianFilterAction - -from .actions.histogram import PixelIntensitiesHistoAction - -from .actions.fit import FitAction diff --git a/silx/gui/plot/PlotEvents.py b/silx/gui/plot/PlotEvents.py deleted file mode 100644 index 83f253c..0000000 --- a/silx/gui/plot/PlotEvents.py +++ /dev/null @@ -1,166 +0,0 @@ -# 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 deleted file mode 100644 index 356bda6..0000000 --- a/silx/gui/plot/PlotInteraction.py +++ /dev/null @@ -1,1603 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Implementation of the interaction for the :class:`Plot`.""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "24/04/2018" - - -import math -import numpy -import time -import weakref - -from .. import colors -from .. import qt -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` - """ - - _DOUBLE_CLICK_TIMEOUT = 0.4 - - 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 click(self, x, y, btn): - """Handle clicks by sending events - - :param int x: Mouse X position in pixels - :param int y: Mouse Y position in pixels - :param btn: Clicked mouse button - """ - 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) - - elif btn == RIGHT_BTN: - # 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) - - 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') - - self._lastClick = 0., None - - -# 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.getXAxis().getLimits() - yMin, yMax = self.plot.getYAxis().getLimits() - y2Min, y2Max = self.plot.getYAxis(axis='right').getLimits() - - if self.plot.getXAxis()._isLogarithmic(): - 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.getYAxis()._isLogarithmic(): - 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. - """ - - def __init__(self, plot, color): - self.color = color - - super(Zoom, self).__init__(plot) - self.plot.getLimitsHistory().clear() - - 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 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 - self.plot.getLimitsHistory().push() - - 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.getDragThreshold() - 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) - - threshold = self.machine.getDragThreshold() - - # Only allow to close polygon after first point - if len(self.points) > 2 and dx <= threshold and dy <= threshold: - 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 >= threshold or dy >= threshold: - self.points.append(dataPos) - else: - self.points[-1] = dataPos - - return True - 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) - threshold = self.machine.getDragThreshold() - - if dx <= threshold and dy <= threshold: - 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() - - def getDragThreshold(self): - """Return dragging ratio with device to pixel ratio applied. - - :rtype: float - """ - ratio = 1. - if qt.BINDING in ('PyQt5', 'PySide2'): - ratio = self.plot.window().windowHandle().devicePixelRatio() - return self.DRAG_THRESHOLD_DIST * ratio - - - -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] - indices = picked[2] - - dataPos = self.plot.pixelToData(x, y) - assert dataPos is not None - - xData = curve.getXData(copy=False) - yData = curve.getYData(copy=False) - - eventDict = prepareCurveSignal('left', - curve.getLegend(), - 'curve', - xData[indices], - yData[indices], - 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() - - -class ItemsInteractionForCombo(ItemsInteraction): - """Interaction with items to combine through :class:`FocusManager`. - """ - - class Idle(ItemsInteraction.Idle): - def onPress(self, x, y, btn): - if btn == LEFT_BTN: - def test(item): - return (item.isSelectable() or - (isinstance(item, items.DraggableMixIn) and - item.isDraggable())) - - picked = self.machine.plot._pickMarker(x, y, test) - if picked is not None: - itemInteraction = True - - else: - picked = self.machine.plot._pickImageOrCurve(x, y, test) - itemInteraction = picked is not None - - if itemInteraction: # Request focus and handle interaction - self.goto('clickOrDrag', x, y) - return True - else: # Do not request focus - return False - - elif btn == RIGHT_BTN: - self.goto('rightClick', x, y) - return True - - def __init__(self, plot): - _PlotInteraction.__init__(self, plot) - - states = { - 'idle': ItemsInteractionForCombo.Idle, - 'rightClick': ClickOrDrag.RightClick, - 'clickOrDrag': ClickOrDrag.ClickOrDrag, - 'drag': ClickOrDrag.Drag - } - StateMachine.__init__(self, states, 'idle') - - -# 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) - - -class PanAndSelect(ItemsInteraction): - """Combine Pan and ItemInteraction state machine. - - :param plot: The Plot to which this interaction is attached - """ - - def __init__(self, plot): - super(PanAndSelect, self).__init__(plot) - self._pan = Pan(plot) - self._doPan = False - - 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._pan.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._doPan = not super(PanAndSelect, self).beginDrag(x, y) - if self._doPan: - self._pan.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._doPan: - return self._pan.drag(x, y) - else: - return super(PanAndSelect, 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._doPan: - return self._pan.endDrag(startPos, endPos) - else: - return super(PanAndSelect, 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, FocusManager): - drawHandler = self._eventHandler.eventHandlers[1] - if not isinstance(drawHandler, Select): - raise RuntimeError('Unknown interactive mode') - - result = drawHandler.parameters.copy() - result['mode'] = 'draw' - return result - - elif isinstance(self._eventHandler, Select): - result = self._eventHandler.parameters.copy() - result['mode'] = 'draw' - return result - - elif isinstance(self._eventHandler, PanAndSelect): - 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', 'select-draw', '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', 'select-draw', 'zoom') - - plot = self._plot() - assert plot is not None - - if color not in (None, 'video inverted'): - color = colors.rgba(color) - - if mode in ('draw', 'select-draw'): - assert shape in self._DRAW_MODES - eventHandlerClass = self._DRAW_MODES[shape] - parameters = { - 'shape': shape, - 'label': label, - 'color': color, - 'width': width, - } - eventHandler = eventHandlerClass(plot, parameters) - - self._eventHandler.cancel() - - if mode == 'draw': - self._eventHandler = eventHandler - - else: # mode == 'select-draw' - self._eventHandler = FocusManager( - (ItemsInteractionForCombo(plot), eventHandler)) - - elif mode == 'pan': - # Ignores color, shape and label - self._eventHandler.cancel() - self._eventHandler = PanAndSelect(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 deleted file mode 100644 index f6291b5..0000000 --- a/silx/gui/plot/PlotToolButtons.py +++ /dev/null @@ -1,419 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides a set of QToolButton to use with -:class:`~silx.gui.plot.PlotWidget`. - -The following QToolButton are available: - -- :class:`.AspectToolButton` -- :class:`.YAxisOriginToolButton` -- :class:`.ProfileToolButton` -- :class:`.SymbolToolButton` - -""" - -__authors__ = ["V. Valls", "H. Payno"] -__license__ = "MIT" -__date__ = "27/06/2017" - - -import functools -import logging -import weakref - -from .. import icons -from .. import qt - -from .items import SymbolMixIn - - -_logger = logging.getLogger(__name__) - - -class PlotToolButton(qt.QToolButton): - """A QToolButton connected to a :class:`~silx.gui.plot.PlotWidget`. - """ - - def __init__(self, parent=None, plot=None): - super(PlotToolButton, self).__init__(parent) - self._plotRef = None - if plot is not None: - self.setPlot(plot) - - def plot(self): - """ - Returns the plot connected to the widget. - """ - return None if self._plotRef is None else self._plotRef() - - def setPlot(self, plot): - """ - Set the plot connected to the widget - - :param plot: :class:`.PlotWidget` instance on which to operate. - """ - previousPlot = self.plot() - - if previousPlot is plot: - return - if previousPlot is not None: - self._disconnectPlot(previousPlot) - - if plot is None: - self._plotRef = None - else: - self._plotRef = weakref.ref(plot) - self._connectPlot(plot) - - def _connectPlot(self, plot): - """ - Called when the plot is connected to the widget - - :param plot: :class:`.PlotWidget` instance - """ - pass - - def _disconnectPlot(self, plot): - """ - Called when the plot is disconnected from the widget - - :param plot: :class:`.PlotWidget` instance - """ - pass - - -class AspectToolButton(PlotToolButton): - """Tool button to switch keep aspect ratio of a plot""" - - 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): - """Tool button to switch the Y axis orientation of a plot.""" - - 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): - yAxis = plot.getYAxis() - yAxis.sigInvertedChanged.connect(self._yAxisInvertedChanged) - self._yAxisInvertedChanged(yAxis.isInverted()) - - def _disconnectPlot(self, plot): - plot.getYAxis().sigInvertedChanged.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.getYAxis().setInverted(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.getYAxis().setInverted(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 ProfileOptionToolButton(PlotToolButton): - """Button to define option on the profile""" - sigMethodChanged = qt.Signal(str) - - def __init__(self, parent=None, plot=None): - PlotToolButton.__init__(self, parent=parent, plot=plot) - - self.STATE = {} - # is down - self.STATE['sum', "icon"] = icons.getQIcon('math-sigma') - self.STATE['sum', "state"] = "compute profile sum" - self.STATE['sum', "action"] = "compute profile sum" - # keep ration - self.STATE['mean', "icon"] = icons.getQIcon('math-mean') - self.STATE['mean', "state"] = "compute profile mean" - self.STATE['mean', "action"] = "compute profile mean" - - sumAction = self._createAction('sum') - sumAction.triggered.connect(self.setSum) - sumAction.setIconVisibleInMenu(True) - - meanAction = self._createAction('mean') - meanAction.triggered.connect(self.setMean) - meanAction.setIconVisibleInMenu(True) - - menu = qt.QMenu(self) - menu.addAction(sumAction) - menu.addAction(meanAction) - self.setMenu(menu) - self.setPopupMode(qt.QToolButton.InstantPopup) - self.setMean() - - def _createAction(self, method): - icon = self.STATE[method, "icon"] - text = self.STATE[method, "action"] - return qt.QAction(icon, text, self) - - def setSum(self): - """Configure the plot to use y-axis upward""" - self._method = 'sum' - self.sigMethodChanged.emit(self._method) - self._update() - - def _update(self): - icon = self.STATE[self._method, "icon"] - toolTip = self.STATE[self._method, "state"] - self.setIcon(icon) - self.setToolTip(toolTip) - - def setMean(self): - """Configure the plot to use y-axis downward""" - self._method = 'mean' - self.sigMethodChanged.emit(self._method) - self._update() - - -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) - - -class SymbolToolButton(PlotToolButton): - """A tool button with a drop-down menu to control symbol size and marker. - - :param parent: See QWidget - :param plot: The `~silx.gui.plot.PlotWidget` to control - """ - - def __init__(self, parent=None, plot=None): - super(SymbolToolButton, self).__init__(parent=parent, plot=plot) - - self.setToolTip('Set symbol size and marker') - self.setIcon(icons.getQIcon('plot-symbols')) - - menu = qt.QMenu(self) - - # Size slider - - slider = qt.QSlider(qt.Qt.Horizontal) - slider.setRange(1, 20) - slider.setValue(SymbolMixIn._DEFAULT_SYMBOL_SIZE) - slider.setTracking(False) - slider.valueChanged.connect(self._sizeChanged) - widgetAction = qt.QWidgetAction(menu) - widgetAction.setDefaultWidget(slider) - menu.addAction(widgetAction) - - menu.addSeparator() - - # Marker actions - - for marker, name in zip(SymbolMixIn.getSupportedSymbols(), - SymbolMixIn.getSupportedSymbolNames()): - action = qt.QAction(name, menu) - action.setCheckable(False) - action.triggered.connect( - functools.partial(self._markerChanged, marker)) - menu.addAction(action) - - self.setMenu(menu) - self.setPopupMode(qt.QToolButton.InstantPopup) - - def _sizeChanged(self, value): - """Manage slider value changed - - :param int value: Marker size - """ - plot = self.plot() - if plot is None: - return - - for item in plot._getItems(withhidden=True): - if isinstance(item, SymbolMixIn): - item.setSymbolSize(value) - - def _markerChanged(self, marker): - """Manage change of marker. - - :param str marker: Letter describing the marker - """ - plot = self.plot() - if plot is None: - return - - for item in plot._getItems(withhidden=True): - if isinstance(item, SymbolMixIn): - item.setSymbol(marker) diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py deleted file mode 100644 index 5929473..0000000 --- a/silx/gui/plot/PlotTools.py +++ /dev/null @@ -1,43 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Set of widgets to associate with a :class:'PlotWidget'. -""" - -from __future__ import absolute_import - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "01/03/2018" - - -from ...utils.deprecation import deprecated_warning - -deprecated_warning(type_='module', - name=__file__, - reason='Plot tools refactoring', - replacement='silx.gui.plot.tools', - since_version='0.8') - -from .tools import PositionInfo, LimitsToolBar # noqa diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py deleted file mode 100644 index e023a21..0000000 --- a/silx/gui/plot/PlotWidget.py +++ /dev/null @@ -1,3228 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# ###########################################################################*/ -"""Qt widget providing plot API for 1D and 2D data. - -The :class:`PlotWidget` implements the plot API initially provided in PyMca. -""" - -from __future__ import division - - -__authors__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "12/10/2018" - - -from collections import OrderedDict, namedtuple -from contextlib import contextmanager -import datetime as dt -import itertools -import logging - -import numpy - -import silx -from silx.utils.weakref import WeakMethodProxy -from silx.utils import deprecation -from silx.utils.property import classproperty -from silx.utils.deprecation import deprecated -# Import matplotlib backend here to init matplotlib our way -from .backends.BackendMatplotlib import BackendMatplotlibQt - -from ..colors import Colormap -from .. import colors -from . import PlotInteraction -from . import PlotEvents -from .LimitsHistory import LimitsHistory -from . import _utils - -from . import items -from .items.curve import CurveStyle -from .items.axis import TickMode # noqa - -from .. import qt -from ._utils.panzoom import ViewConstraints -from ...gui.plot._utils.dtime_ticklayout import timestamp - -_logger = logging.getLogger(__name__) - - -_COLORDICT = colors.COLORDICT -_COLORLIST = silx.config.DEFAULT_PLOT_CURVE_COLORS - -""" -Object returned when requesting the data range. -""" -_PlotDataRange = namedtuple('PlotDataRange', - ['x', 'y', 'yright']) - - -class PlotWidget(qt.QMainWindow): - """Qt Widget providing a 1D/2D plot. - - This widget is a QMainWindow. - 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 of this widget or None (default). - :param backend: The backend to use, in: - 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none' - or a :class:`BackendBase.BackendBase` class - :type backend: str or :class:`BackendBase.BackendBase` - """ - - # TODO: Can be removed for silx 0.10 - @classproperty - @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2) - def DEFAULT_BACKEND(self): - """Class attribute setting the default backend for all instances.""" - return silx.config.DEFAULT_PLOT_BACKEND - - colorList = _COLORLIST - colorDict = _COLORDICT - - sigPlotSignal = qt.Signal(object) - """Signal for all events of the plot. - - The signal information is provided as a dict. - See the :ref:`plot signal documentation page <plot_signal>` for - information about the content of the dict - """ - - 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""" - - _sigAxesVisibilityChanged = qt.Signal(bool) - """Signal emitted when the axes visibility changed""" - - sigContentChanged = qt.Signal(str, str, str) - """Signal emitted when the content of the plot is changed. - - It provides the following information: - - - 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 the following information: - - - 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 the following information: - - - 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 the 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`. - """ - - sigItemAdded = qt.Signal(items.Item) - """Signal emitted when an item was just added to the plot - - It provides the added item. - """ - - sigItemAboutToBeRemoved = qt.Signal(items.Item) - """Signal emitted right before an item is removed from the plot. - - It provides the item that will be removed. - """ - - sigVisibilityChanged = qt.Signal(bool) - """Signal emitted when the widget becomes visible (or invisible). - This happens when the widget is hidden or shown. - - It provides the visible state. - """ - - def __init__(self, parent=None, backend=None, - legends=False, callback=None, **kw): - self._autoreplot = False - self._dirty = False - self._cursorInPlot = False - self.__muteActiveItemChanged = False - - 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 - self._viewConstrains = None - - super(PlotWidget, self).__init__(parent) - if parent is not None: - # behave as a widget - self.setWindowFlags(qt.Qt.Widget) - else: - self.setWindowTitle('PlotWidget') - - if backend is None: - backend = silx.config.DEFAULT_PLOT_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)) - - self.setCallback() # set _callback - - # Items handling - self._content = OrderedDict() - self._contentToUpdate = [] # Used as an OrderedSet - - self._dataRange = None - - # line types - self._styleList = ['-', '--', '-.', ':'] - self._colorIndex = 0 - self._styleIndex = 0 - - self._activeCurveSelectionMode = "atmostone" - self._activeCurveStyle = CurveStyle(color='#000000') - self._activeLegend = {'curve': None, 'image': None, - 'scatter': None} - - # default properties - self._cursorConfiguration = None - - self._xAxis = items.XAxis(self) - self._yAxis = items.YAxis(self) - self._yRightAxis = items.YRightAxis(self, self._yAxis) - - self._grid = None - self._graphTitle = '' - - self.setGraphTitle() - self.setGraphXLabel() - self.setGraphYLabel() - self.setGraphYLabel('', axis='right') - - self.setDefaultColormap() # Init default colormap - - self.setDefaultPlotPoints(False) - self.setDefaultPlotLines(True) - - self._limitsHistory = LimitsHistory(self) - - 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 - - widget = self.getWidgetHandle() - if widget is not None: - self.setCentralWidget(widget) - else: - _logger.info("PlotWidget backend does not support widget") - - self.setFocusPolicy(qt.Qt.StrongFocus) - self.setFocus(qt.Qt.OtherFocusReason) - - # Set default limits - self.setGraphXLimits(0., 100.) - self.setGraphYLimits(0., 100., axis='right') - self.setGraphYLimits(0., 100., axis='left') - - # TODO: Can be removed for silx 0.10 - @staticmethod - @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2) - def setDefaultBackend(backend): - """Set system wide default plot backend. - - .. versionadded:: 0.6 - - :param backend: The backend to use, in: - 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none' - or a :class:`BackendBase.BackendBase` class - """ - silx.config.DEFAULT_PLOT_BACKEND = backend - - 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 and self.isVisible(): - self._backend.postRedisplay() - - def showEvent(self, event): - if self._autoreplot and self._dirty: - self._backend.postRedisplay() - super(PlotWidget, self).showEvent(event) - self.sigVisibilityChanged.emit(True) - - def hideEvent(self, event): - super(PlotWidget, self).hideEvent(event) - self.sigVisibilityChanged.emit(False) - - def _invalidateDataRange(self): - """ - Notifies this PlotWidget 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 PlotWidget. - """ - 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 PlotWidget'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.DATA_ITEMS): - self._invalidateDataRange() # TODO handle this automatically - - self._notifyContentChanged(item) - self.sigItemAdded.emit(item) - - def _notifyContentChanged(self, item): - legend, kind = self._itemKey(item) - self.notify('contentChanged', action='add', kind=kind, legend=legend) - - 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') - - self.sigItemAboutToBeRemoved.emit(item) - - legend, kind = key - - if kind in self._ACTIVE_ITEM_KINDS: - if self._getActiveItem(kind) == item: - # Reset active item - self._setActiveItem(kind, None) - - # Remove item from plot - self._content.pop(key) - if item in self._contentToUpdate: - self._contentToUpdate.remove(item) - if item.isVisible(): - self._setDirtyPlot(overlayOnly=item.isOverlay()) - if item.getBounds() is not None: - self._invalidateDataRange() - item._removeBackendRenderer(self._backend) - item._setPlot(None) - - if (kind == 'curve' and not self.getAllCurves(just_legend=True, - withhidden=True)): - self._resetColorAndStyle() - - self.notify('contentChanged', action='remove', - kind=kind, legend=legend) - - 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 - # Pu item at the end of the list - if item in self._contentToUpdate: - self._contentToUpdate.remove(item) - self._contentToUpdate.append(item) - self._setDirtyPlot(overlayOnly=item.isOverlay()) - - @contextmanager - def _muteActiveItemChangedSignal(self): - self.__muteActiveItemChanged = True - yield - self.__muteActiveItemChanged = False - - # 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. - If x contains datetime objects the XAxis tickMode is set to - TickMode.TIME_SERIES. - :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 - - if replace: - self._resetColorAndStyle() - - # Create/Update curve object - curve = self.getCurve(legend) - mustBeAdded = curve is None - 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) - - # Do not emit sigActiveCurveChanged, - # it will be sent once with _setActiveItem - with self._muteActiveItemChangedSignal(): - # 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) - - # Convert x to timestamps so that the internal representation - # remains floating points. The user is expected to set the axis' - # tickMode to TickMode.TIME_SERIES and, if necessary, set the axis - # to the correct time zone. - if len(x) > 0 and isinstance(x[0], dt.datetime): - x = [timestamp(d) for d in x] - - 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) - - if mustBeAdded: - self._add(curve) - else: - self._notifyContentChanged(curve) - - if wasActive: - self.setActiveCurve(curve.getLegend()) - elif self.getActiveCurveSelectionMode() == "legacy": - if self.getActiveCurve(just_legend=True) is None: - if len(self.getAllCurves(just_legend=True, - withhidden=False)) == 1: - if curve.isVisible(): - 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) - mustBeAdded = histo is None - 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]) - - # 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) - - if mustBeAdded: - self._add(histo) - else: - self._notifyContentChanged(histo) - - 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=False, 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 - Note: boolean values are converted to int8. - :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 to delete already existing images (Default: False). - :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 colormap: Colormap object to use (or None). - This is ignored if data is a RGB(A) image. - :type colormap: Union[~silx.gui.colors.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 - - mustBeAdded = image is 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) - - # Do not emit sigActiveImageChanged, - # it will be sent once with _setActiveItem - with self._muteActiveItemChangedSignal(): - # 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): - if isinstance(colormap, dict): - image.setColormap(Colormap._fromDict(colormap)) - else: - assert isinstance(colormap, Colormap) - 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 mustBeAdded: - self._add(image) - else: - self._notifyContentChanged(image) - - if len(self.getAllImages()) == 1 or wasActive: - self.setActiveImage(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 ~silx.gui.colors.Colormap colormap: - Colormap object to be used for the scatter (or None) - :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) - mustBeAdded = scatter is None - 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()) - - # Do not emit sigActiveScatterChanged, - # it will be sent once with _setActiveItem - with self._muteActiveItemChangedSignal(): - # 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: - if isinstance(colormap, dict): - scatter.setColormap(Colormap._fromDict(colormap)) - else: - assert isinstance(colormap, Colormap) - 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) - - if mustBeAdded: - self._add(scatter) - else: - self._notifyContentChanged(scatter) - - 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) - - 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._xAxis.getLimits() - x = 0.5 * (xmax + xmin) - - if y is None: - ymin, ymax = self._yAxis.getLimits() - 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 - - mustBeAdded = marker is None - if marker is None: - # No previous marker, create one - marker = markerClass() - marker._setLegend(legend) - - 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) - - if mustBeAdded: - self._add(marker) - else: - self._notifyContentChanged(marker) - - 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' - """List of supported kind of items in the plot.""" - - _ACTIVE_ITEM_KINDS = 'curve', 'scatter', 'image' - """List of item's kind which have a active item.""" - - 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. - See :attr:`ITEM_KINDS`. - 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: - self._remove(item) - - 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._xAxis.getLimits() - - xMin, xMax = _utils.applyPan(xMin, xMax, xFactor, - self._xAxis.getScale() == self._xAxis.LOGARITHMIC) - self._xAxis.setLimits(xMin, xMax) - - else: # direction in ('up', 'down') - sign = -1. if self._yAxis.isInverted() else 1. - yFactor = sign * (factor if direction == 'up' else -factor) - yMin, yMax = self._yAxis.getLimits() - yIsLog = self._yAxis.getScale() == self._yAxis.LOGARITHMIC - - yMin, yMax = _utils.applyPan(yMin, yMax, yFactor, yIsLog) - self._yAxis.setLimits(yMin, yMax) - - y2Min, y2Max = self._yRightAxis.getLimits() - - y2Min, y2Max = _utils.applyPan(y2Min, y2Max, yFactor, yIsLog) - self._yRightAxis.setLimits(y2Min, y2Max) - - # Active Curve/Image - - def isActiveCurveHandling(self): - """Returns True if active curve selection is enabled. - - :rtype: bool - """ - return self.getActiveCurveSelectionMode() != 'none' - - def setActiveCurveHandling(self, flag=True): - """Enable/Disable active curve selection. - - :param bool flag: True to enable 'atmostone' active curve selection, - False to disable active curve selection. - """ - self.setActiveCurveSelectionMode('atmostone' if flag else 'none') - - def getActiveCurveStyle(self): - """Returns the current style applied to active curve - - :rtype: CurveStyle - """ - return self._activeCurveStyle - - def setActiveCurveStyle(self, - color=None, - linewidth=None, - linestyle=None, - symbol=None, - symbolsize=None): - """Set the style of active curve - - :param color: Color - :param Union[str,None] linestyle: Style of the line - :param Union[float,None] linewidth: Width of the line - :param Union[str,None] symbol: Symbol of the markers - :param Union[float,None] symbolsize: Size of the symbols - """ - self._activeCurveStyle = CurveStyle(color=color, - linewidth=linewidth, - linestyle=linestyle, - symbol=symbol, - symbolsize=symbolsize) - curve = self.getActiveCurve() - if curve is not None: - curve.setHighlightedStyle(self.getActiveCurveStyle()) - - @deprecated(replacement="getActiveCurveStyle", since_version="0.9") - def getActiveCurveColor(self): - """Get the color used to display the currently active curve. - - See :meth:`setActiveCurveColor`. - """ - return self._activeCurveStyle.getColor() - - @deprecated(replacement="setActiveCurveStyle", since_version="0.9") - 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.setActiveCurveStyle(color=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 - if legend is None and self.getActiveCurveSelectionMode() == "legacy": - _logger.info( - 'setActiveCurve(None) ignored due to active curve selection mode') - return - - return self._setActiveItem(kind='curve', legend=legend) - - def setActiveCurveSelectionMode(self, mode): - """Sets the current selection mode. - - :param str mode: The active curve selection mode to use. - It can be: 'legacy', 'atmostone' or 'none'. - """ - assert mode in ('legacy', 'atmostone', 'none') - - if mode != self._activeCurveSelectionMode: - self._activeCurveSelectionMode = mode - if mode == 'none': # reset active curve - self._setActiveItem(kind='curve', legend=None) - - elif mode == 'legacy' and self.getActiveCurve() is None: - # Select an active curve - curves = self.getAllCurves(just_legend=False, - withhidden=False) - if len(curves) == 1: - if curves[0].isVisible(): - self.setActiveCurve(curves[0].getLegend()) - - def getActiveCurveSelectionMode(self): - """Returns the current selection mode. - - It can be "atmostone", "legacy" or "none". - - :rtype: str - """ - return self._activeCurveSelectionMode - - 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 self._ACTIVE_ITEM_KINDS - - 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 self._ACTIVE_ITEM_KINDS - - xLabel = None - yLabel = None - yRightLabel = None - - oldActiveItem = self._getActiveItem(kind=kind) - - if oldActiveItem is not None: # Stop listening previous active image - oldActiveItem.sigItemChanged.disconnect(self._activeItemChanged) - - # 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.setHighlightedStyle(self.getActiveCurveStyle()) - 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() - - # Start listening new active item - item.sigItemChanged.connect(self._activeItemChanged) - - # Store current labels and update plot - self._xAxis._setCurrentLabel(xLabel) - self._yAxis._setCurrentLabel(yLabel) - self._yRightAxis._setCurrentLabel(yRightLabel) - - 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 - - def _activeItemChanged(self, type_): - """Listen for active item changed signal and broadcast signal - - :param item.ItemChangedType type_: The type of item change - """ - if not self.__muteActiveItemChanged: - item = self.sender() - if item is not None: - legend, kind = self._itemKey(item) - self.notify( - 'active' + kind[0].upper() + kind[1:] + 'Changed', - updated=False, - previous=legend, - legend=legend) - - # Getters - - def getItems(self): - """Returns the list of items in the plot - - :rtype: List[silx.gui.plot.items.Item] - """ - return tuple(self._content.values()) - - 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=ITEM_KINDS, just_legend=False, withhidden=False): - """Retrieve all items of a kind in the plot - - :param kind: The kind of elements to retrieve from the plot. - See :attr:`ITEM_KINDS`. - By default, it removes all kind of elements. - :type kind: str or tuple of str to specify multiple kinds. - :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 - """ - 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 - - output = [] - for (legend, type_), item in self._content.items(): - if type_ in 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 to retrieve, - see :attr:`ITEM_KINDS`. - :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 self._ACTIVE_ITEM_KINDS: - 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, emitSignal=True): - """Send an event when plot area limits are changed.""" - xRange = self._xAxis.getLimits() - yRange = self._yAxis.getLimits() - y2Range = self._yRightAxis.getLimits() - if emitSignal: - axes = self.getXAxis(), self.getYAxis(), self.getYAxis(axis="right") - ranges = xRange, yRange, y2Range - for axis, limits in zip(axes, ranges): - axis.sigLimitsChanged.emit(*limits) - event = PlotEvents.prepareLimitsChangedSignal( - id(self.getWidgetHandle()), xRange, yRange, y2Range) - self.notify(**event) - - def getLimitsHistory(self): - """Returns the object handling the history of limits of the plot""" - return self._limitsHistory - - 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') - self._xAxis.setLimits(xmin, xmax) - - 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') - yAxis = self._yAxis if axis == 'left' else self._yRightAxis - return yAxis.getLimits() - - 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') - yAxis = self._yAxis if axis == 'left' else self._yRightAxis - return yAxis.setLimits(ymin, ymax) - - 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 - axis = self.getXAxis() - xmin, xmax = axis._checkLimits(xmin, xmax) - axis = self.getYAxis() - ymin, ymax = axis._checkLimits(ymin, ymax) - - if y2min is None or y2max is None: - # if one limit is None, both are ignored - y2min, y2max = None, None - else: - axis = self.getYAxis(axis="right") - y2min, y2max = axis._checkLimits(y2min, y2max) - - if self._viewConstrains: - view = self._viewConstrains.normalize(xmin, xmax, ymin, ymax) - xmin, xmax, ymin, ymax = view - - self._backend.setLimits(xmin, xmax, ymin, ymax, y2min, y2max) - self._setDirtyPlot() - self._notifyLimitsChanged() - - def _getViewConstraints(self): - """Return the plot object managing constaints on the plot view. - - :rtype: ViewConstraints - """ - if self._viewConstrains is None: - self._viewConstrains = ViewConstraints() - return self._viewConstrains - - # 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._xAxis.getLabel() - - 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._xAxis.setLabel(label) - - 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') - yAxis = self._yAxis if axis == 'left' else self._yRightAxis - return yAxis.getLabel() - - 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') - yAxis = self._yAxis if axis == 'left' else self._yRightAxis - return yAxis.setLabel(label) - - # Axes - - def getXAxis(self): - """Returns the X axis - - .. versionadded:: 0.6 - - :rtype: :class:`.items.Axis` - """ - return self._xAxis - - def getYAxis(self, axis="left"): - """Returns an Y axis - - .. versionadded:: 0.6 - - :param str axis: The Y axis to return - ('left' or 'right'). - :rtype: :class:`.items.Axis` - """ - assert(axis in ["left", "right"]) - return self._yAxis if axis == "left" else self._yRightAxis - - def setAxesDisplayed(self, displayed): - """Display or not the axes. - - :param bool displayed: If `True` axes are displayed. If `False` axes - are not anymore visible and the margin used for them is removed. - """ - self._backend.setAxesDisplayed(displayed) - self._setDirtyPlot() - self._sigAxesVisibilityChanged.emit(displayed) - - def _isAxesDisplayed(self): - return self._backend.isAxesDisplayed() - - @property - @deprecated(since_version='0.6') - def sigSetYAxisInverted(self): - """Signal emitted when Y axis orientation has changed""" - return self._yAxis.sigInvertedChanged - - @property - @deprecated(since_version='0.6') - def sigSetXAxisLogarithmic(self): - """Signal emitted when X axis scale has changed""" - return self._xAxis._sigLogarithmicChanged - - @property - @deprecated(since_version='0.6') - def sigSetYAxisLogarithmic(self): - """Signal emitted when Y axis scale has changed""" - return self._yAxis._sigLogarithmicChanged - - @property - @deprecated(since_version='0.6') - def sigSetXAxisAutoScale(self): - """Signal emitted when X axis autoscale has changed""" - return self._xAxis.sigAutoScaleChanged - - @property - @deprecated(since_version='0.6') - def sigSetYAxisAutoScale(self): - """Signal emitted when Y axis autoscale has changed""" - return self._yAxis.sigAutoScaleChanged - - 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._yAxis.setInverted(flag) - - def isYAxisInverted(self): - """Return True if Y axis goes from top to bottom, False otherwise.""" - return self._yAxis.isInverted() - - def isXAxisLogarithmic(self): - """Return True if X axis scale is logarithmic, False if linear.""" - return self._xAxis._isLogarithmic() - - 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. - """ - self._xAxis._setLogarithmic(flag) - - def isYAxisLogarithmic(self): - """Return True if Y axis scale is logarithmic, False if linear.""" - return self._yAxis._isLogarithmic() - - 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. - """ - self._yAxis._setLogarithmic(flag) - - def isXAxisAutoScale(self): - """Return True if X axis is automatically adjusting its limits.""" - return self._xAxis.isAutoScale() - - 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._xAxis.setAutoScale(flag) - - def isYAxisAutoScale(self): - """Return True if Y axes are automatically adjusting its limits.""" - return self._yAxis.isAutoScale() - - 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._yAxis.setAutoScale(flag) - - 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._forceResetZoom() - 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`. - - :rtype: ~silx.gui.colors.Colormap - """ - return self._defaultColormap - - 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 ~silx.gui.colors.Colormap colormap: - The description of the default colormap, or - None to set the colormap to a linear - autoscale gray colormap. - """ - if colormap is None: - colormap = Colormap(name=silx.config.DEFAULT_COLORMAP_NAME, - normalization='linear', - vmin=None, - vmax=None) - if isinstance(colormap, dict): - self._defaultColormap = Colormap._fromDict(colormap) - else: - assert isinstance(colormap, Colormap) - self._defaultColormap = colormap - self.notify('defaultColormapChanged') - - @staticmethod - def getSupportedColormaps(): - """Get the supported colormap names as a tuple of str. - - The list contains at least: - ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue', - 'magma', 'inferno', 'plasma', 'viridis') - """ - return Colormap.getSupportedColormaps() - - def _resetColorAndStyle(self): - self._colorIndex = 0 - self._styleIndex = 0 - - 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 colors.rgba(color) == self.getActiveCurveStyle().getColor(): - 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 and send signals. - - 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.sigPlotSignal.emit(eventDict) - - if 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']) - - 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 - # allow register listener by event type - if callbackFunction is None: - callbackFunction = WeakMethodProxy(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']) - qt.QToolTip.showText(self.cursor().pos(), ddict['label']) - elif ddict['event'] == 'mouseClicked' and ddict['button'] == 'left': - self.setActiveCurve(None) - - def saveGraph(self, filename, fileFormat=None, dpi=None, **kw): - """Save a snapshot of the plot. - - Supported file formats depends on the backend in use. - The following file formats are always supported: "png", "svg". - The matplotlib backend supports more formats: - "pdf", "ps", "eps", "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 = [] - self._backend.replot() - self._dirty = False # reset dirty flag - - def _forceResetZoom(self, dataMargins=None): - """Reset the plot limits to the bounds of the data and redraw the plot. - - This method forces a reset zoom and does not check axis autoscale. - - Extra margins can be added around the data inside the plot area - (see :meth:`setDataMargins`). - 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 - - # 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._xAxis._isLogarithmic(), - self._yAxis._isLogarithmic(), - 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:] - if plotWidth > 0 and plotHeight > 0: - plotRatio = plotHeight / plotWidth - 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) - - 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:`getXAxis`, :meth:`getYAxis` and :meth:`Axis.setAutoScale`). - It keeps current limits on axes that are not in autoscale mode. - - Extra margins can be added around the data inside the plot area - (see :meth:`setDataMargins`). - 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). - """ - xLimits = self._xAxis.getLimits() - yLimits = self._yAxis.getLimits() - y2Limits = self._yRightAxis.getLimits() - - xAuto = self._xAxis.isAutoScale() - yAuto = self._yAxis.isAutoScale() - - # With log axes, autoscale if limits are <= 0 - # This avoids issues with toggling log scale with matplotlib 2.1.0 - if self._xAxis.getScale() == self._xAxis.LOGARITHMIC and xLimits[0] <= 0: - xAuto = True - if self._yAxis.getScale() == self._yAxis.LOGARITHMIC and (yLimits[0] <= 0 or y2Limits[0] <= 0): - yAuto = True - - if not xAuto and not yAuto: - _logger.debug("Nothing to autoscale") - else: # Some axes to autoscale - self._forceResetZoom(dataMargins=dataMargins) - - # Restore limits for axis not in autoscale - 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') - - if (xLimits != self._xAxis.getLimits() or - yLimits != self._yAxis.getLimits() or - y2Limits != self._yRightAxis.getLimits()): - 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._xAxis.getLimits() - yAxis = self.getYAxis(axis=axis) - ymin, ymax = yAxis.getLimits() - - 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, kinds=('marker',)) - 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 pixels - :param 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, kinds=('curve', 'image')) - 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['indices'] - - 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 - - def _pick(self, x, y): - """Pick items in the plot at given position. - - :param float x: X position in pixels - :param float y: Y position in pixels - :return: Iterable of (plot item, indices) at picked position. - Items are ordered from back to front. - """ - items = [] - - # Convert backend result to plot items - for itemInfo in self._backend.pickItems( - x, y, kinds=('marker', 'curve', 'image')): - kind, legend = itemInfo['kind'], itemInfo['legend'] - - if kind in ('marker', 'image'): - item = self._getItem(kind=kind, legend=legend) - indices = None # TODO compute indices for images - - else: # backend kind == 'curve' - for kind in ('curve', 'histogram', 'scatter'): - item = self._getItem(kind=kind, legend=legend) - if item is not None: - indices = itemInfo['indices'] - break - else: - _logger.error( - 'Cannot find corresponding picked item') - continue - items.append((item, indices)) - - return tuple(items) - - # 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', 'select-draw', '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) - - # 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) - - # 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:`Axis.setInverted` instead.""" - _logger.warning('invertYAxis deprecated, ' - 'use getYAxis().setInverted instead.') - return self.getYAxis().setInverted(*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/PlotWindow.py b/silx/gui/plot/PlotWindow.py deleted file mode 100644 index 23ea399..0000000 --- a/silx/gui/plot/PlotWindow.py +++ /dev/null @@ -1,948 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""A :class:`.PlotWidget` with additional toolbars. - -The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`. -""" - -__authors__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "12/10/2018" - -import collections -import logging -import weakref - -import silx -from silx.utils.weakref import WeakMethodProxy -from silx.utils.deprecation import deprecated - -from . import PlotWidget -from . import actions -from . import items -from .actions import medfilt as actions_medfilt -from .actions import fit as actions_fit -from .actions import control as actions_control -from .actions import histogram as actions_histogram -from . import PlotToolButtons -from . import tools -from .Profile import ProfileToolBar -from .LegendSelector import LegendsDockWidget -from .CurvesROIWidget import CurvesROIDockWidget -from .MaskToolsWidget import MaskToolsDockWidget -from .StatsWidget import BasicStatsWidget -from .ColorBar import ColorBarWidget -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:`.PlotWidget` 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.tools.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 - self._consoleDockWidget = None - self._statsWidget = None - - # Create color bar, hidden by default for backward compatibility - self._colorbar = ColorBarWidget(parent=self, plot=self) - - # Init actions - self.group = qt.QActionGroup(self) - self.group.setExclusive(False) - - self.resetZoomAction = self.group.addAction( - actions.control.ResetZoomAction(self)) - self.resetZoomAction.setVisible(resetzoom) - self.addAction(self.resetZoomAction) - - self.zoomInAction = actions.control.ZoomInAction(self) - self.addAction(self.zoomInAction) - - self.zoomOutAction = actions.control.ZoomOutAction(self) - self.addAction(self.zoomOutAction) - - self.xAxisAutoScaleAction = self.group.addAction( - actions.control.XAxisAutoScaleAction(self)) - self.xAxisAutoScaleAction.setVisible(autoScale) - self.addAction(self.xAxisAutoScaleAction) - - self.yAxisAutoScaleAction = self.group.addAction( - actions.control.YAxisAutoScaleAction(self)) - self.yAxisAutoScaleAction.setVisible(autoScale) - self.addAction(self.yAxisAutoScaleAction) - - self.xAxisLogarithmicAction = self.group.addAction( - actions.control.XAxisLogarithmicAction(self)) - self.xAxisLogarithmicAction.setVisible(logScale) - self.addAction(self.xAxisLogarithmicAction) - - self.yAxisLogarithmicAction = self.group.addAction( - actions.control.YAxisLogarithmicAction(self)) - self.yAxisLogarithmicAction.setVisible(logScale) - self.addAction(self.yAxisLogarithmicAction) - - self.gridAction = self.group.addAction( - actions.control.GridAction(self, gridMode='both')) - self.gridAction.setVisible(grid) - self.addAction(self.gridAction) - - self.curveStyleAction = self.group.addAction( - actions.control.CurveStyleAction(self)) - self.curveStyleAction.setVisible(curveStyle) - self.addAction(self.curveStyleAction) - - self.colormapAction = self.group.addAction( - actions.control.ColormapAction(self)) - self.colormapAction.setVisible(colormap) - self.addAction(self.colormapAction) - - self.colorbarAction = self.group.addAction( - actions_control.ColorBarAction(self, self)) - self.colorbarAction.setVisible(False) - self.addAction(self.colorbarAction) - self._colorbar.setVisible(False) - - 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( - actions_histogram.PixelIntensitiesHistoAction(self)) - self._intensityHistoAction.setVisible(False) - - self._medianFilter2DAction = self.group.addAction( - actions_medfilt.MedianFilter2DAction(self)) - self._medianFilter2DAction.setVisible(False) - - self._medianFilter1DAction = self.group.addAction( - actions_medfilt.MedianFilter1DAction(self)) - self._medianFilter1DAction.setVisible(False) - - self.fitAction = self.group.addAction(actions_fit.FitAction(self)) - self.fitAction.setVisible(fit) - self.addAction(self.fitAction) - - # lazy loaded actions needed by the controlButton menu - self._consoleAction = None - self._statsAction = None - self._panWithArrowKeysAction = None - self._crosshairAction = None - - # Make colorbar background white - self._colorbar.setAutoFillBackground(True) - palette = self._colorbar.palette() - palette.setColor(qt.QPalette.Background, qt.Qt.white) - palette.setColor(qt.QPalette.Window, qt.Qt.white) - self._colorbar.setPalette(palette) - - gridLayout = qt.QGridLayout() - gridLayout.setSpacing(0) - gridLayout.setContentsMargins(0, 0, 0, 0) - gridLayout.addWidget(self.getWidgetHandle(), 0, 0) - gridLayout.addWidget(self._colorbar, 0, 1) - gridLayout.setRowStretch(0, 1) - gridLayout.setColumnStretch(0, 1) - centralWidget = qt.QWidget(self) - centralWidget.setLayout(gridLayout) - self.setCentralWidget(centralWidget) - - self._positionWidget = 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 = tools.PositionInfo( - plot=self, converters=converters) - # Set a snapping mode that is consistent with legacy one - self._positionWidget.setSnappingMode( - tools.PositionInfo.SNAPPING_CROSSHAIR | - tools.PositionInfo.SNAPPING_ACTIVE_ONLY | - tools.PositionInfo.SNAPPING_SYMBOLS_ONLY | - tools.PositionInfo.SNAPPING_CURVE | - tools.PositionInfo.SNAPPING_SCATTER) - - hbox.addWidget(self._positionWidget) - - hbox.addStretch(1) - bottomBar = qt.QWidget(centralWidget) - bottomBar.setLayout(hbox) - - gridLayout.addWidget(bottomBar, 1, 0, 1, -1) - - # Creating the toolbar also create actions for toolbuttons - self._interactiveModeToolBar = tools.InteractiveModeToolBar( - parent=self, plot=self) - self.addToolBar(self._interactiveModeToolBar) - - self._toolbar = self._createToolBar(title='Plot', parent=None) - self.addToolBar(self._toolbar) - - self._outputToolBar = tools.OutputToolBar(parent=self, plot=self) - self._outputToolBar.getCopyAction().setVisible(copy) - self._outputToolBar.getSaveAction().setVisible(save) - self._outputToolBar.getPrintAction().setVisible(print_) - self.addToolBar(self._outputToolBar) - - # Activate shortcuts in PlotWindow widget: - for toolbar in (self._interactiveModeToolBar, self._outputToolBar): - for action in toolbar.actions(): - self.addAction(action) - - def getInteractiveModeToolBar(self): - """Returns QToolBar controlling interactive mode. - - :rtype: QToolBar - """ - return self._interactiveModeToolBar - - def getOutputToolBar(self): - """Returns QToolBar containing save, copy and print actions - - :rtype: QToolBar - """ - return self._outputToolBar - - @property - @deprecated(replacement="getPositionInfoWidget()", since_version="0.8.0") - def positionWidget(self): - return self.getPositionInfoWidget() - - def getPositionInfoWidget(self): - """Returns the widget displaying current cursor position information - - :rtype: ~silx.gui.plot.tools.PositionInfo - """ - return self._positionWidget - - 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, isChecked=False): - """Create IPythonDockWidget if needed, - show it or hide it.""" - # create widget if needed (first call) - if self._consoleDockWidget is None: - available_vars = {"plt": weakref.proxy(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.setVisible(True) - self._consoleDockWidget.toggleViewAction().toggled.connect( - self.getConsoleAction().setChecked) - - self._consoleDockWidget.setVisible(isChecked) - - def _toggleStatsVisibility(self, isChecked=False): - self.getStatsWidget().parent().setVisible(isChecked) - - 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.getStatsAction()) - 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 > (1.25 * height): - 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) - - def getColorBarWidget(self): - """Returns the embedded :class:`ColorBarWidget` widget. - - :rtype: ColorBarWidget - """ - return self._colorbar - - # 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="getCurvesRoiWidget()", since_version="0.4.0") - def curvesROIDockWidget(self): - return self.getCurvesRoiDockWidget() - - def getCurvesRoiDockWidget(self): - # Undocumented for a "soft deprecation" in version 0.7.0 - # (still used internally for lazy loading) - if self._curvesROIDockWidget is None: - self._curvesROIDockWidget = CurvesROIDockWidget( - plot=self, name='Regions Of Interest') - self._curvesROIDockWidget.hide() - self.addTabbedDockWidget(self._curvesROIDockWidget) - return self._curvesROIDockWidget - - def getCurvesRoiWidget(self): - """Return the :class:`CurvesROIWidget`. - - :class:`silx.gui.plot.CurvesROIWidget.CurvesROIWidget` offers a getter - and a setter for the ROI data: - - - :meth:`CurvesROIWidget.getRois` - - :meth:`CurvesROIWidget.setRois` - """ - return self.getCurvesRoiDockWidget().roiWidget - - @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 - - def getStatsWidget(self): - """Returns a BasicStatsWidget connected to this plot - - :rtype: BasicStatsWidget - """ - if self._statsWidget is None: - dockWidget = qt.QDockWidget(parent=self) - dockWidget.setWindowTitle("Curves stats") - dockWidget.layout().setContentsMargins(0, 0, 0, 0) - self._statsWidget = BasicStatsWidget(parent=self, plot=self) - self._statsWidget.sigVisibilityChanged.connect(self.getStatsAction().setChecked) - dockWidget.setWidget(self._statsWidget) - dockWidget.hide() - self.addTabbedDockWidget(dockWidget) - return self._statsWidget - - # getters for actions - @property - @deprecated(replacement="getInteractiveModeToolBar().getZoomModeAction()", - since_version="0.8.0") - def zoomModeAction(self): - return self.getInteractiveModeToolBar().getZoomModeAction() - - @property - @deprecated(replacement="getInteractiveModeToolBar().getPanModeAction()", - since_version="0.8.0") - def panModeAction(self): - return self.getInteractiveModeToolBar().getPanModeAction() - - @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: actions.PlotAction - """ - if self._crosshairAction is None: - self._crosshairAction = actions.control.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: actions.PlotAction - """ - if self._panWithArrowKeysAction is None: - self._panWithArrowKeysAction = actions.control.PanWithArrowKeysAction(self) - return self._panWithArrowKeysAction - - @property - @deprecated(replacement="getRoiAction()", since_version="0.4.0") - def roiAction(self): - return self.getRoiAction() - - def getStatsAction(self): - if self._statsAction is None: - self._statsAction = qt.QAction('Curves stats', self) - self._statsAction.setCheckable(True) - self._statsAction.setChecked(self.getStatsWidget().parent().isVisible()) - self._statsAction.toggled.connect(self._toggleStatsVisibility) - return self._statsAction - - def getRoiAction(self): - """QAction toggling curve ROI dock widget - - :rtype: QAction - """ - return self.getCurvesRoiDockWidget().toggleViewAction() - - def getResetZoomAction(self): - """Action resetting the zoom - - :rtype: actions.PlotAction - """ - return self.resetZoomAction - - def getZoomInAction(self): - """Action to zoom in - - :rtype: actions.PlotAction - """ - return self.zoomInAction - - def getZoomOutAction(self): - """Action to zoom out - - :rtype: actions.PlotAction - """ - return self.zoomOutAction - - def getXAxisAutoScaleAction(self): - """Action to toggle the X axis autoscale on zoom reset - - :rtype: actions.PlotAction - """ - return self.xAxisAutoScaleAction - - def getYAxisAutoScaleAction(self): - """Action to toggle the Y axis autoscale on zoom reset - - :rtype: actions.PlotAction - """ - return self.yAxisAutoScaleAction - - def getXAxisLogarithmicAction(self): - """Action to toggle logarithmic X axis - - :rtype: actions.PlotAction - """ - return self.xAxisLogarithmicAction - - def getYAxisLogarithmicAction(self): - """Action to toggle logarithmic Y axis - - :rtype: actions.PlotAction - """ - return self.yAxisLogarithmicAction - - def getGridAction(self): - """Action to toggle the grid visibility in the plot - - :rtype: actions.PlotAction - """ - return self.gridAction - - def getCurveStyleAction(self): - """Action to change curve line and markers styles - - :rtype: actions.PlotAction - """ - return self.curveStyleAction - - def getColormapAction(self): - """Action open a colormap dialog to change active image - and default colormap. - - :rtype: actions.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: actions.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: actions.PlotAction - """ - return self.yAxisInvertedAction - - def getIntensityHistogramAction(self): - """Action toggling the histogram intensity Plot widget - - :rtype: actions.PlotAction - """ - return self._intensityHistoAction - - def getCopyAction(self): - """Action to copy plot snapshot to clipboard - - :rtype: actions.PlotAction - """ - return self.getOutputToolBar().getCopyAction() - - def getSaveAction(self): - """Action to save plot - - :rtype: actions.PlotAction - """ - return self.getOutputToolBar().getSaveAction() - - def getPrintAction(self): - """Action to print plot - - :rtype: actions.PlotAction - """ - return self.getOutputToolBar().getPrintAction() - - def getFitAction(self): - """Action to fit selected curve - - :rtype: actions.PlotAction - """ - return self.fitAction - - def getMedianFilter1DAction(self): - """Action toggling the 1D median filter - - :rtype: actions.PlotAction - """ - return self._medianFilter1DAction - - def getMedianFilter2DAction(self): - """Action toggling the 2D median filter - - :rtype: actions.PlotAction - """ - return self._medianFilter2DAction - - def getColorBarAction(self): - """Action toggling the colorbar show/hide action - - .. warning:: to show/hide the plot colorbar call directly the ColorBar - widget using getColorBarWidget() - - :rtype: actions.PlotAction - """ - return self.colorbarAction - - -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:`.PlotWidget` 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.getXAxis().setLabel('X') - self.getYAxis().setLabel('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:`.PlotWidget` 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', WeakMethodProxy(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.getXAxis().setLabel('Columns') - self.getYAxis().setLabel('Rows') - - if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': - self.getYAxis().setInverted(True) - - self.profile = ProfileToolBar(plot=self) - self.addToolBar(self.profile) - - self.colorbarAction.setVisible(True) - self.getColorBarWidget().setVisible(True) - - # Put colorbar action after colormap action - actions = self.toolBar().actions() - for action in actions: - if action is self.getColormapAction(): - break - - self.sigActiveImageChanged.connect(self.__activeImageChanged) - - def __activeImageChanged(self, previous, legend): - """Handle change of active image - - :param Union[str,None] previous: Legend of previous active image - :param Union[str,None] legend: Legend of current active image - """ - if previous is not None: - item = self.getImage(previous) - if item is not None: - item.sigItemChanged.disconnect(self.__imageChanged) - - if legend is not None: - item = self.getImage(legend) - item.sigItemChanged.connect(self.__imageChanged) - - positionInfo = self.getPositionInfoWidget() - if positionInfo is not None: - positionInfo.updateInfo() - - def __imageChanged(self, event): - """Handle update of active image item - - :param event: Type of changed event - """ - if event == items.ItemChangedType.DATA: - positionInfo = self.getPositionInfoWidget() - if positionInfo is not None: - positionInfo.updateInfo() - - def _getImageValue(self, x, y): - """Get status bar 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') - mask = 0 - maskZ = -float('inf') - - for image in self.getAllImages(): - data = image.getData(copy=False) - isMask = isinstance(image, items.MaskImageData) - if isMask: - zIndex = maskZ - else: - zIndex = valueZ - if image.getZValue() >= zIndex: - # 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]): - v, z = data[row, col], image.getZValue() - if not isMask: - value = v - valueZ = z - else: - mask = v - maskZ = z - if maskZ > valueZ and mask > 0: - return value, "Masked" - 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/PrintPreviewToolButton.py b/silx/gui/plot/PrintPreviewToolButton.py deleted file mode 100644 index b48505d..0000000 --- a/silx/gui/plot/PrintPreviewToolButton.py +++ /dev/null @@ -1,351 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -This modules provides tool buttons to send the content of a plot to a -print preview page. -The plot content can then be moved on the page and resized prior to printing. - -Classes -------- - -- :class:`PrintPreviewToolButton` -- :class:`SingletonPrintPreviewToolButton` - -Examples --------- - -Simple example -++++++++++++++ - -.. code-block:: python - - from silx.gui import qt - from silx.gui.plot import PlotWidget - from silx.gui.plot.PrintPreviewToolButton import PrintPreviewToolButton - import numpy - - app = qt.QApplication([]) - - pw = PlotWidget() - toolbar = qt.QToolBar(pw) - toolbutton = PrintPreviewToolButton(parent=toolbar, plot=pw) - pw.addToolBar(toolbar) - toolbar.addWidget(toolbutton) - pw.show() - - x = numpy.arange(1000) - y = x / numpy.sin(x) - pw.addCurve(x, y) - - app.exec_() - -Singleton example -+++++++++++++++++ - -This example illustrates how to print the content of several different -plots on the same page. The plots all instantiate a -:class:`SingletonPrintPreviewToolButton`, which relies on a singleton widget -(:class:`silx.gui.widgets.PrintPreview.SingletonPrintPreviewDialog`). - -.. image:: img/printPreviewMultiPlot.png - -.. code-block:: python - - from silx.gui import qt - from silx.gui.plot import PlotWidget - from silx.gui.plot.PrintPreviewToolButton import SingletonPrintPreviewToolButton - import numpy - - app = qt.QApplication([]) - - plot_widgets = [] - - for i in range(3): - pw = PlotWidget() - toolbar = qt.QToolBar(pw) - toolbutton = SingletonPrintPreviewToolButton(parent=toolbar, - plot=pw) - pw.addToolBar(toolbar) - toolbar.addWidget(toolbutton) - pw.show() - plot_widgets.append(pw) - - x = numpy.arange(1000) - - plot_widgets[0].addCurve(x, numpy.sin(x * 2 * numpy.pi / 1000)) - plot_widgets[1].addCurve(x, numpy.cos(x * 2 * numpy.pi / 1000)) - plot_widgets[2].addCurve(x, numpy.tan(x * 2 * numpy.pi / 1000)) - - app.exec_() - -""" -from __future__ import absolute_import - -import logging -from io import StringIO - -from .. import qt -from .. import icons -from . import PlotWidget -from ..widgets.PrintPreview import PrintPreviewDialog, SingletonPrintPreviewDialog -from ..widgets.PrintGeometryDialog import PrintGeometryDialog - -__authors__ = ["P. Knobel"] -__license__ = "MIT" -__date__ = "18/07/2017" - -_logger = logging.getLogger(__name__) -# _logger.setLevel(logging.DEBUG) - - -class PrintPreviewToolButton(qt.QToolButton): - """QToolButton to open a :class:`PrintPreviewDialog` (if not already open) - and add the current plot to its page to be printed. - - :param parent: See :class:`QAction` - :param plot: :class:`.PlotWidget` instance on which to operate - """ - def __init__(self, parent=None, plot=None): - super(PrintPreviewToolButton, self).__init__(parent) - - if not isinstance(plot, PlotWidget): - raise TypeError("plot parameter must be a PlotWidget") - self.plot = plot - - self.setIcon(icons.getQIcon('document-print')) - - printGeomAction = qt.QAction("Print geometry", self) - printGeomAction.setToolTip("Define a print geometry prior to sending " - "the plot to the print preview dialog") - printGeomAction.setIcon(icons.getQIcon('shape-rectangle')) # fixme: icon not displayed in menu - printGeomAction.triggered.connect(self._setPrintConfiguration) - - printPreviewAction = qt.QAction("Print preview", self) - printPreviewAction.setToolTip("Send plot to the print preview dialog") - printPreviewAction.setIcon(icons.getQIcon('document-print')) # fixme: icon not displayed - printPreviewAction.triggered.connect(self._plotToPrintPreview) - - menu = qt.QMenu(self) - menu.addAction(printGeomAction) - menu.addAction(printPreviewAction) - self.setMenu(menu) - self.setPopupMode(qt.QToolButton.InstantPopup) - - self._printPreviewDialog = None - self._printConfigurationDialog = None - - self._printGeometry = {"xOffset": 0.1, - "yOffset": 0.1, - "width": 0.9, - "height": 0.9, - "units": "page", - "keepAspectRatio": True} - - @property - def printPreviewDialog(self): - """Lazy loaded :class:`PrintPreviewDialog`""" - # if changes are made here, don't forget making them in - # SingletonPrintPreviewToolButton.printPreviewDialog as well - if self._printPreviewDialog is None: - self._printPreviewDialog = PrintPreviewDialog(self.parent()) - return self._printPreviewDialog - - def _plotToPrintPreview(self): - """Grab the plot widget and send it to the print preview dialog. - Make sure the print preview dialog is shown and raised.""" - if not self.printPreviewDialog.ensurePrinterIsSet(): - return - - if qt.HAS_SVG: - svgRenderer, viewBox = self._getSvgRendererAndViewbox() - self.printPreviewDialog.addSvgItem(svgRenderer, - viewBox=viewBox) - else: - _logger.warning("Missing QtSvg library, using a raster image") - if qt.BINDING in ["PyQt4", "PySide"]: - pixmap = qt.QPixmap.grabWidget(self.plot.centralWidget()) - else: - # PyQt5 and hopefully PyQt6+ - pixmap = self.plot.centralWidget().grab() - self.printPreviewDialog.addPixmap(pixmap) - self.printPreviewDialog.show() - self.printPreviewDialog.raise_() - - def _getSvgRendererAndViewbox(self): - """Return a SVG renderer displaying the plot and its viewbox - (interactively specified by the user the first time this is called). - - The size of the renderer is adjusted to the printer configuration - and to the geometry configuration (width, height, ratio) specified - by the user.""" - imgData = StringIO() - assert self.plot.saveGraph(imgData, fileFormat="svg"), \ - "Unable to save graph" - imgData.flush() - imgData.seek(0) - svgData = imgData.read() - - svgRenderer = qt.QSvgRenderer() - - viewbox = self._getViewBox() - - svgRenderer.setViewBox(viewbox) - - xml_stream = qt.QXmlStreamReader(svgData.encode(errors="replace")) - - # This is for PyMca compatibility, to share a print preview with PyMca plots - svgRenderer._viewBox = viewbox - svgRenderer._svgRawData = svgData.encode(errors="replace") - svgRenderer._svgRendererData = xml_stream - - if not svgRenderer.load(xml_stream): - raise RuntimeError("Cannot interpret svg data") - - return svgRenderer, viewbox - - def _getViewBox(self): - """ - """ - printer = self.printPreviewDialog.printer - dpix = printer.logicalDpiX() - dpiy = printer.logicalDpiY() - availableWidth = printer.width() - availableHeight = printer.height() - - config = self._printGeometry - width = config['width'] - height = config['height'] - xOffset = config['xOffset'] - yOffset = config['yOffset'] - units = config['units'] - keepAspectRatio = config['keepAspectRatio'] - aspectRatio = self._getPlotAspectRatio() - - # convert the offsets to dots - if units.lower() in ['inch', 'inches']: - xOffset = xOffset * dpix - yOffset = yOffset * dpiy - if width is not None: - width = width * dpix - if height is not None: - height = height * dpiy - elif units.lower() in ['cm', 'centimeters']: - xOffset = (xOffset / 2.54) * dpix - yOffset = (yOffset / 2.54) * dpiy - if width is not None: - width = (width / 2.54) * dpix - if height is not None: - height = (height / 2.54) * dpiy - else: - # page units - xOffset = availableWidth * xOffset - yOffset = availableHeight * yOffset - if width is not None: - width = availableWidth * width - if height is not None: - height = availableHeight * height - - availableWidth -= xOffset - availableHeight -= yOffset - - if width is not None: - if (availableWidth + 0.1) < width: - txt = "Available width %f is less than requested width %f" % \ - (availableWidth, width) - raise ValueError(txt) - if height is not None: - if (availableHeight + 0.1) < height: - txt = "Available height %f is less than requested height %f" % \ - (availableHeight, height) - raise ValueError(txt) - - if keepAspectRatio: - bodyWidth = width or availableWidth - bodyHeight = bodyWidth * aspectRatio - - if bodyHeight > availableHeight: - bodyHeight = availableHeight - bodyWidth = bodyHeight / aspectRatio - - else: - bodyWidth = width or availableWidth - bodyHeight = height or availableHeight - - return qt.QRectF(xOffset, - yOffset, - bodyWidth, - bodyHeight) - - def _setPrintConfiguration(self): - """Open a dialog to prompt the user to adjust print - geometry parameters.""" - self.printPreviewDialog.ensurePrinterIsSet() - if self._printConfigurationDialog is None: - self._printConfigurationDialog = PrintGeometryDialog(self.parent()) - - self._printConfigurationDialog.setPrintGeometry(self._printGeometry) - if self._printConfigurationDialog.exec_(): - self._printGeometry = self._printConfigurationDialog.getPrintGeometry() - - def _getPlotAspectRatio(self): - widget = self.plot.centralWidget() - graphWidth = float(widget.width()) - graphHeight = float(widget.height()) - return graphHeight / graphWidth - - -class SingletonPrintPreviewToolButton(PrintPreviewToolButton): - """This class is similar to its parent class :class:`PrintPreviewToolButton` - but it uses a singleton print preview widget. - - This allows for several plots to send their content to the - same print page, and for users to arrange them.""" - def __init__(self, parent=None, plot=None): - PrintPreviewToolButton.__init__(self, parent, plot) - - @property - def printPreviewDialog(self): - if self._printPreviewDialog is None: - self._printPreviewDialog = SingletonPrintPreviewDialog(self.parent()) - return self._printPreviewDialog - - -if __name__ == '__main__': - import numpy - app = qt.QApplication([]) - - pw = PlotWidget() - toolbar = qt.QToolBar(pw) - toolbutton = PrintPreviewToolButton(parent=toolbar, - plot=pw) - pw.addToolBar(toolbar) - toolbar.addWidget(toolbutton) - pw.show() - - x = numpy.arange(1000) - y = x / numpy.sin(x) - pw.addCurve(x, y) - - app.exec_() diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py deleted file mode 100644 index 182cf60..0000000 --- a/silx/gui/plot/Profile.py +++ /dev/null @@ -1,810 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""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/07/2018" - - -import weakref - -import numpy - -from silx.image.bilinear import BilinearImage - -from .. import icons -from .. import qt -from . import items -from ..colors import cursorColorForColormap -from . import actions -from .PlotToolButtons import ProfileToolButton, ProfileOptionToolButton -from .ProfileMainWindow import ProfileMainWindow - -from silx.utils.deprecation import deprecated - - -def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method): - """Get a profile along one axis on a stack of images - - :param numpy.ndarray data: 3D volume (stack of 2D images) - The first dimension is the image index. - :param origin: Origin of image in plot (ox, oy) - :param scale: Scale of image in plot (sx, sy) - :param float position: Position of profile line in plot coords - on the axis orthogonal to the profile direction. - :param int roiWidth: Width of the profile in image pixels. - :param int axis: 0 for horizontal profile, 1 for vertical. - :param str method: method to compute the profile. Can be 'mean' or 'sum' - :return: profile image + effective ROI area corners in plot coords - """ - assert axis in (0, 1) - assert len(data.shape) == 3 - assert method in ('mean', 'sum') - - # 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: - if method == 'mean': - _fct = numpy.mean - elif method == 'sum': - _fct = numpy.sum - else: - raise ValueError('method not managed') - profile = _fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32) - else: - profile = numpy.zeros((nimages, width), dtype=numpy.float32) - - # Compute effective ROI in plot coords - profileBounds = numpy.array( - (0, width, width, 0), - dtype=numpy.float32) * scale[axis] + origin[axis] - roiBounds = numpy.array( - (start, start, end, end), - dtype=numpy.float32) * scale[1 - axis] + origin[1 - axis] - - if axis == 0: # Horizontal profile - area = profileBounds, roiBounds - else: # vertical profile - area = roiBounds, profileBounds - - return profile, area - - -def _alignedPartialProfile(data, rowRange, colRange, axis, method): - """Mean of a rectangular region (ROI) of a stack of images - along a given axis. - - Returned values and all parameters are in image coordinates. - - :param numpy.ndarray data: 3D volume (stack of 2D images) - The first dimension is the image index. - :param rowRange: [min, max[ of ROI rows (upper bound excluded). - :type rowRange: 2-tuple of int (min, max) with min < max - :param colRange: [min, max[ of ROI columns (upper bound excluded). - :type colRange: 2-tuple of int (min, max) with min < max - :param int axis: The axis along which to take the profile of the ROI. - 0: Sum rows along columns. - 1: Sum columns along rows. - :param str method: method to compute the profile. Can be 'mean' or 'sum' - :return: Profile image along the ROI as the mean of the intersection - of the ROI and the image. - """ - assert axis in (0, 1) - assert len(data.shape) == 3 - assert rowRange[0] < rowRange[1] - assert colRange[0] < colRange[1] - assert method in ('mean', 'sum') - - nimages, height, width = data.shape - - # Range aligned with the integration direction - profileRange = colRange if axis == 0 else rowRange - - profileLength = abs(profileRange[1] - profileRange[0]) - - # Subset of the image to use as intersection of ROI and image - rowStart = min(max(0, rowRange[0]), height) - rowEnd = min(max(0, rowRange[1]), height) - colStart = min(max(0, colRange[0]), width) - colEnd = min(max(0, colRange[1]), width) - - if method == 'mean': - _fct = numpy.mean - elif method == 'sum': - _fct = numpy.sum - else: - raise ValueError('method not managed') - - imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1, - dtype=numpy.float32) - - # Profile including out of bound area - profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32) - - # Place imgProfile in full profile - offset = - min(0, profileRange[0]) - profile[:, offset:offset + imgProfile.shape[1]] = imgProfile - - return profile - - -def createProfile(roiInfo, currentData, origin, scale, lineWidth, method): - """Create the profile line for the the given image. - - :param roiInfo: information about the ROI: start point, end point and - type ("X", "Y", "D") - :param numpy.ndarray currentData: the 2D image or the 3D stack of images - on which we compute the profile. - :param origin: (ox, oy) the offset from origin - :type origin: 2-tuple of float - :param scale: (sx, sy) the scale to use - :type scale: 2-tuple of float - :param int lineWidth: width of the profile line - :param str method: method to compute the profile. Can be 'mean' or 'sum' - :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, - method=method) - - 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, - method=method) - - 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, - method=method) - - else: # Column aligned - rowRange = startPt[0], endPt[0] + 1 - colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth), - int(startPt[1] + 0.5 + 0.5 * roiWidth)) - profile = _alignedPartialProfile(currentData3D, - rowRange, colRange, - axis=1, - method=method) - - # Convert ranges to plot coords to draw ROI area - area = ( - numpy.array( - (colRange[0], colRange[1], colRange[1], colRange[0]), - dtype=numpy.float32) * scale[0] + origin[0], - numpy.array( - (rowRange[0], rowRange[0], rowRange[1], rowRange[1]), - dtype=numpy.float32) * scale[1] + origin[1]) - - else: # General case: use bilinear interpolation - - # Ensure startPt <= endPt - if (startPt[1] > endPt[1] or ( - startPt[1] == endPt[1] and startPt[0] > endPt[0])): - startPt, endPt = endPt, startPt - - profile = [] - for slice_idx in range(currentData3D.shape[0]): - bilinear = BilinearImage(currentData3D[slice_idx, :, :]) - - profile.append(bilinear.profile_line( - (startPt[0] - 0.5, startPt[1] - 0.5), - (endPt[0] - 0.5, endPt[1] - 0.5), - roiWidth, - method=method)) - profile = numpy.array(profile) - - # Extend ROI with half a pixel on each end, and - # Convert back to plot coords (x, y) - length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 + - (endPt[1] - startPt[1]) ** 2) - dRow = (endPt[0] - startPt[0]) / length - dCol = (endPt[1] - startPt[1]) / length - - # Extend ROI with half a pixel on each end - 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' - - DEFAULT_PROF_METHOD = 'mean' - - def __init__(self, parent=None, plot=None, profileWindow=None, - title='Profile Selection'): - super(ProfileToolBar, self).__init__(title, parent) - assert plot is not None - self._plotRef = weakref.ref(plot) - - self._overlayColor = None - self._defaultOverlayColor = 'red' # update when active image change - self._method = self.DEFAULT_PROF_METHOD - - 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 = actions.mode.ZoomModeAction(self.plot, parent=self) - self._browseAction.setVisible(False) - - 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) - - # 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(1, 1000) - self.lineWidthSpinBox.setValue(1) - self.lineWidthSpinBox.valueChanged[int].connect( - self._lineWidthSpinBoxValueChangedSlot) - self.addWidget(self.lineWidthSpinBox) - - self.methodsButton = ProfileOptionToolButton(parent=self, plot=self) - self.addWidget(self.methodsButton) - # TODO: add connection with the signal - self.methodsButton.sigMethodChanged.connect(self.setProfileMethod) - - 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 - def plot(self): - """The :class:`.PlotWidget` associated to the toolbar.""" - return self._plotRef() - - @property - @deprecated(since_version="0.6.0") - def browseAction(self): - return self._browseAction - - @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""" - if legend is None: - self.setEnabled(False) - else: - activeImage = self.plot.getActiveImage() - - # Disable for empty image - self.setEnabled(activeImage.getData(copy=False).size > 0) - - # Update default profile color - 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.clearProfile() - - # Uncheck all drawing profile modes - self.hLineAction.setChecked(False) - self.vLineAction.setChecked(False) - self.lineAction.setChecked(False) - - if self.getProfileMainWindow() is not None: - self.getProfileMainWindow().hide() - - 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 _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().getXAxis().setLabel('X') - self.getProfilePlot().getYAxis().setLabel('Y') - - self._createProfile(currentData=image.getData(copy=False), - origin=image.getOrigin(), - scale=image.getScale(), - colormap=None, # Not used for 2D data - z=image.getZValue(), - method=self.getProfileMethod()) - - def _createProfile(self, currentData, origin, scale, colormap, z, method): - """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(), - method=method) - - 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) - # Scale horizontal and vertical profile coordinates - if self._roiInfo[2] == 'X': - coords = coords * scale[0] + origin[0] - elif self._roiInfo[2] == 'Y': - coords = coords * scale[1] + origin[1] - - 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): - # Place profile on the right - profileMainWindow.move(winGeom.right(), winGeom.top()) - elif(profileWindowWidth < spaceOnLeftSide): - # Place profile on the left - profileMainWindow.move( - max(0, winGeom.left() - profileWindowWidth), winGeom.top()) - - profileMainWindow.show() - profileMainWindow.raise_() - else: - self.getProfilePlot().show() - self.getProfilePlot().raise_() - - 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() - - def setProfileMethod(self, method): - assert method in ('sum', 'mean') - self._method = method - self.updateProfile() - - def getProfileMethod(self): - return self._method - - -class Profile3DToolBar(ProfileToolBar): - def __init__(self, parent=None, stackview=None, - title='Profile Selection'): - """QToolBar providing profile tools for an image or a stack of images. - - :param parent: the parent QWidget - :param stackview: :class:`StackView` 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=stackview.getPlot(), - title=title) - self.stackView = stackview - """:class:`StackView` instance""" - - 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) - self._method3D = 'sum' - - 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.stackView.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().getXAxis().setLabel('X') - self.getProfilePlot().getYAxis().setLabel('Y') - self._createProfile(currentData=stackData[0], - origin=stackData[1]['origin'], - scale=stackData[1]['scale'], - colormap=stackData[1]['colormap'], - z=stackData[1]['z'], - method=self.getProfileMethod()) - else: - raise ValueError( - "Profile type must be 1D or 2D, not %s" % self._profileType) - - def setProfileMethod(self, method): - assert method in ('sum', 'mean') - self._method3D = method - self.updateProfile() - - def getProfileMethod(self): - return self._method3D diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py deleted file mode 100644 index caa076c..0000000 --- a/silx/gui/plot/ProfileMainWindow.py +++ /dev/null @@ -1,115 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module 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).""" - - sigProfileMethodChanged = qt.Signal(str) - """Emitted when the method to compute the profile changed (for now can be - sum or mean)""" - - 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") - self.setProfileMethod('sum') - - 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._plot1D.setGraphYLabel('Profile') - self._plot1D.setGraphXLabel('') - 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() - - def setProfileMethod(self, method): - """ - - :param str method: method to manage the 'width' in the profile - (computing mean or sum). - """ - assert method in ('sum', 'mean') - self._method = method - self.sigProfileMethodChanged.emit(self._method) diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py deleted file mode 100644 index de645be..0000000 --- a/silx/gui/plot/ScatterMaskToolsWidget.py +++ /dev/null @@ -1,565 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""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__ = "24/04/2018" - - -import math -import logging -import os -import numpy -import sys - -from .. import qt -from ...math.combo import min_max -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): - super(ScatterMaskToolsWidget, self).__init__(parent, plot, - mask=ScatterMask()) - self._z = 2 # Mask layer in plot - self._data_scatter = None - """plot Scatter item for data""" - - self._data_extent = None - """Maximum extent of the data i.e., max(xMax-xMin, yMax-yMin)""" - - self._mask_scatter = None - """plot Scatter item for representing the mask""" - - def setSelectionMask(self, mask, copy=True): - """Set the mask to a new array. - - :param numpy.ndarray mask: - The array to use for the mask or None to reset 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. - """ - if self._data_scatter is None: - # this can happen if the mask tools widget has never been shown - self._data_scatter = self.plot._getActiveItem(kind="scatter") - if self._data_scatter is None: - return None - self._adjustColorAndBrushSize(self._data_scatter) - - if mask is None: - self.resetSelectionMask() - return self._data_scatter.getXData(copy=False).shape - - 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 mask is not None: - 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() + 2.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 self.getSelectionMask(copy=False) is not None: - self.plot.sigActiveScatterChanged.connect( - self._activeScatterChangedAfterCare) - - def _adjustColorAndBrushSize(self, activeScatter): - 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 - - # Adjust brush size to data range - xData = self._data_scatter.getXData(copy=False) - yData = self._data_scatter.getYData(copy=False) - # Adjust brush size to data range - if xData.size > 0 and yData.size > 0: - xMin, xMax = min_max(xData) - yMin, yMax = min_max(yData) - self._data_extent = max(xMax - xMin, yMax - yMin) - else: - self._data_extent = None - - 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) - self._data_extent = None - self._data_scatter = None - - else: - self._adjustColorAndBrushSize(activeScatter) - - if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(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) - self._data_extent = None - self._data_scatter = None - - 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._data_extent = None - self._mask.reset() - self._mask.commit() - - else: # There is an active scatter - self.setEnabled(True) - self._adjustColorAndBrushSize(activeScatter) - - self._mask.setDataItem(self._data_scatter) - if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(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 _getPencilWidth(self): - """Returns the width of the pencil to use in data coordinates` - - :rtype: float - """ - width = super(ScatterMaskToolsWidget, self)._getPencilWidth() - if self._data_extent is not None: - width *= 0.01 * self._data_extent - return width - - 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[:, (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._getPencilWidth() - - 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'): - widget = ScatterMaskToolsWidget(plot=plot) - super(ScatterMaskToolsDockWidget, self).__init__(parent, name, widget) diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py deleted file mode 100644 index ae79cf9..0000000 --- a/silx/gui/plot/ScatterView.py +++ /dev/null @@ -1,355 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""A widget dedicated to display scatter plots - -It is based on a :class:`~silx.gui.plot.PlotWidget` with additional tools -for scatter plots. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "14/06/2018" - - -import logging -import weakref - -import numpy - -from . import items -from . import PlotWidget -from . import tools -from .tools.profile import ScatterProfileToolBar -from .ColorBar import ColorBarWidget -from .ScatterMaskToolsWidget import ScatterMaskToolsWidget - -from ..widgets.BoxLayoutDockWidget import BoxLayoutDockWidget -from .. import qt, icons - - -_logger = logging.getLogger(__name__) - - -class ScatterView(qt.QMainWindow): - """Main window with a PlotWidget and tools specific for scatter plots. - - :param parent: The parent of this widget - :param backend: The backend to use for the plot (default: matplotlib). - See :class:`~silx.gui.plot.PlotWidget` for the list of supported backend. - :type backend: Union[str,~silx.gui.plot.backends.BackendBase.BackendBase] - """ - - _SCATTER_LEGEND = ' ' - """Legend used for the scatter item""" - - def __init__(self, parent=None, backend=None): - super(ScatterView, self).__init__(parent=parent) - if parent is not None: - # behave as a widget - self.setWindowFlags(qt.Qt.Widget) - else: - self.setWindowTitle('ScatterView') - - # Create plot widget - plot = PlotWidget(parent=self, backend=backend) - self._plot = weakref.ref(plot) - - # Add an empty scatter - plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) - - # Create colorbar widget with white background - self._colorbar = ColorBarWidget(parent=self, plot=plot) - self._colorbar.setAutoFillBackground(True) - palette = self._colorbar.palette() - palette.setColor(qt.QPalette.Background, qt.Qt.white) - palette.setColor(qt.QPalette.Window, qt.Qt.white) - self._colorbar.setPalette(palette) - - # Create PositionInfo widget - self.__lastPickingPos = None - self.__pickingCache = None - self._positionInfo = tools.PositionInfo( - plot=plot, - converters=(('X', lambda x, y: x), - ('Y', lambda x, y: y), - ('Data', lambda x, y: self._getScatterValue(x, y)), - ('Index', lambda x, y: self._getScatterIndex(x, y)))) - - # Combine plot, position info and colorbar into central widget - gridLayout = qt.QGridLayout() - gridLayout.setSpacing(0) - gridLayout.setContentsMargins(0, 0, 0, 0) - gridLayout.addWidget(plot, 0, 0) - gridLayout.addWidget(self._colorbar, 0, 1) - gridLayout.addWidget(self._positionInfo, 1, 0, 1, -1) - gridLayout.setRowStretch(0, 1) - gridLayout.setColumnStretch(0, 1) - centralWidget = qt.QWidget(self) - centralWidget.setLayout(gridLayout) - self.setCentralWidget(centralWidget) - - # Create mask tool dock widget - self._maskToolsWidget = ScatterMaskToolsWidget(parent=self, plot=plot) - self._maskDock = BoxLayoutDockWidget() - self._maskDock.setWindowTitle('Scatter Mask') - self._maskDock.setWidget(self._maskToolsWidget) - self._maskDock.setVisible(False) - self.addDockWidget(qt.Qt.BottomDockWidgetArea, self._maskDock) - - self._maskAction = self._maskDock.toggleViewAction() - self._maskAction.setIcon(icons.getQIcon('image-mask')) - self._maskAction.setToolTip("Display/hide mask tools") - - # Create toolbars - self._interactiveModeToolBar = tools.InteractiveModeToolBar( - parent=self, plot=plot) - - self._scatterToolBar = tools.ScatterToolBar( - parent=self, plot=plot) - self._scatterToolBar.addAction(self._maskAction) - - self._profileToolBar = ScatterProfileToolBar(parent=self, plot=plot) - - self._outputToolBar = tools.OutputToolBar(parent=self, plot=plot) - - # Activate shortcuts in PlotWindow widget: - for toolbar in (self._interactiveModeToolBar, - self._scatterToolBar, - self._profileToolBar, - self._outputToolBar): - self.addToolBar(toolbar) - for action in toolbar.actions(): - self.addAction(action) - - def _pickScatterData(self, x, y): - """Get data and index and value of top most scatter plot at position (x, y) - - :param float x: X position in plot coordinates - :param float y: Y position in plot coordinates - :return: The data index and value at that point or None - """ - pickingPos = x, y - if self.__lastPickingPos != pickingPos: - self.__pickingCache = None - self.__lastPickingPos = pickingPos - - plot = self.getPlotWidget() - if plot is not None: - pixelPos = plot.dataToPixel(x, y) - if pixelPos is not None: - # Start from top-most item - for item, indices in reversed(plot._pick(*pixelPos)): - if isinstance(item, items.Scatter): - # Get last index - # with matplotlib it should be the top-most point - dataIndex = indices[-1] - self.__pickingCache = ( - dataIndex, - item.getValueData(copy=False)[dataIndex]) - break - - return self.__pickingCache - - def _getScatterValue(self, x, y): - """Get data value of top most scatter plot at position (x, y) - - :param float x: X position in plot coordinates - :param float y: Y position in plot coordinates - :return: The data value at that point or '-' - """ - picking = self._pickScatterData(x, y) - return '-' if picking is None else picking[1] - - def _getScatterIndex(self, x, y): - """Get data index of top most scatter plot at position (x, y) - - :param float x: X position in plot coordinates - :param float y: Y position in plot coordinates - :return: The data index at that point or '-' - """ - picking = self._pickScatterData(x, y) - return '-' if picking is None else picking[0] - - _PICK_OFFSET = 3 # Offset in pixel used for picking - - def _mouseInPlotArea(self, x, y): - """Clip mouse coordinates to plot area coordinates - - :param float x: X position in pixels - :param float y: Y position in pixels - :return: (x, y) in data coordinates - """ - plot = self.getPlotWidget() - left, top, width, height = plot.getPlotBoundsInPixels() - xPlot = numpy.clip(x, left, left + width - 1) - yPlot = numpy.clip(y, top, top + height - 1) - return xPlot, yPlot - - def getPlotWidget(self): - """Returns the :class:`~silx.gui.plot.PlotWidget` this window is based on. - - :rtype: ~silx.gui.plot.PlotWidget - """ - return self._plot() - - def getPositionInfoWidget(self): - """Returns the widget display mouse coordinates information. - - :rtype: ~silx.gui.plot.tools.PositionInfo - """ - return self._positionInfo - - def getMaskToolsWidget(self): - """Returns the widget controlling mask drawing - - :rtype: ~silx.gui.plot.ScatterMaskToolsWidget - """ - return self._maskToolsWidget - - def getInteractiveModeToolBar(self): - """Returns QToolBar controlling interactive mode. - - :rtype: ~silx.gui.plot.tools.InteractiveModeToolBar - """ - return self._interactiveModeToolBar - - def getScatterToolBar(self): - """Returns QToolBar providing scatter plot tools. - - :rtype: ~silx.gui.plot.tools.ScatterToolBar - """ - return self._scatterToolBar - - def getScatterProfileToolBar(self): - """Returns QToolBar providing scatter profile tools. - - :rtype: ~silx.gui.plot.tools.profile.ScatterProfileToolBar - """ - return self._profileToolBar - - def getOutputToolBar(self): - """Returns QToolBar containing save, copy and print actions - - :rtype: ~silx.gui.plot.tools.OutputToolBar - """ - return self._outputToolBar - - def setColormap(self, colormap=None): - """Set the colormap for the displayed scatter and the - default plot colormap. - - :param ~silx.gui.colors.Colormap colormap: - The description of the colormap. - """ - self.getScatterItem().setColormap(colormap) - # Resilient to call to PlotWidget API (e.g., clear) - self.getPlotWidget().setDefaultColormap(colormap) - - def getColormap(self): - """Return the colormap object in use. - - :return: Colormap currently in use - :rtype: ~silx.gui.colors.Colormap - """ - return self.getScatterItem().getColormap() - - # Control displayed scatter plot - - def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True): - """Set the data of the scatter plot. - - To reset the scatter plot, set x, y and value to None. - - :param Union[numpy.ndarray,None] x: X coordinates. - :param Union[numpy.ndarray,None] y: Y coordinates. - :param Union[numpy.ndarray,None] value: - The data corresponding to the value of the data points. - :param xerror: Values with the uncertainties on the x values. - 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. - :type xerror: A float, or a numpy.ndarray of float32. - - :param yerror: Values with the uncertainties on the y values - :type yerror: A float, or a numpy.ndarray of float32. See xerror. - :param alpha: Values with the transparency (between 0 and 1) - :type alpha: A float, or a numpy.ndarray of float32 - :param bool copy: True make a copy of the data (default), - False to use provided arrays. - """ - x = () if x is None else x - y = () if y is None else y - value = () if value is None else value - - self.getScatterItem().setData( - x=x, y=y, value=value, xerror=xerror, yerror=yerror, alpha=alpha, copy=copy) - - def getData(self, *args, **kwargs): - return self.getScatterItem().getData(*args, **kwargs) - - getData.__doc__ = items.Scatter.getData.__doc__ - - def getScatterItem(self): - """Returns the plot item displaying the scatter data. - - This allows to set the style of the displayed scatter. - - :rtype: ~silx.gui.plot.items.Scatter - """ - plot = self.getPlotWidget() - scatter = plot._getItem(kind='scatter', legend=self._SCATTER_LEGEND) - if scatter is None: # Resilient to call to PlotWidget API (e.g., clear) - plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) - scatter = plot._getItem( - kind='scatter', legend=self._SCATTER_LEGEND) - return scatter - - # Convenient proxies - - def getXAxis(self, *args, **kwargs): - return self.getPlotWidget().getXAxis(*args, **kwargs) - - getXAxis.__doc__ = PlotWidget.getXAxis.__doc__ - - def getYAxis(self, *args, **kwargs): - return self.getPlotWidget().getYAxis(*args, **kwargs) - - getYAxis.__doc__ = PlotWidget.getYAxis.__doc__ - - def setGraphTitle(self, *args, **kwargs): - return self.getPlotWidget().setGraphTitle(*args, **kwargs) - - setGraphTitle.__doc__ = PlotWidget.setGraphTitle.__doc__ - - def getGraphTitle(self, *args, **kwargs): - return self.getPlotWidget().getGraphTitle(*args, **kwargs) - - getGraphTitle.__doc__ = PlotWidget.getGraphTitle.__doc__ - - def resetZoom(self, *args, **kwargs): - return self.getPlotWidget().resetZoom(*args, **kwargs) - - resetZoom.__doc__ = PlotWidget.resetZoom.__doc__ diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py deleted file mode 100644 index 72b6cd4..0000000 --- a/silx/gui/plot/StackView.py +++ /dev/null @@ -1,1240 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""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__ = "10/10/2018" - -import numpy -import logging - -import silx -from silx.gui import qt -from .. import icons -from . import items, PlotWindow, actions -from ..colors import Colormap -from ..colors import cursorColorForColormap -from .tools import LimitsToolBar -from .Profile import Profile3DToolBar -from ..widgets.FrameBrowser import HorizontalSliderWithBrowser - -from silx.gui.plot.actions import control as actions_control -from silx.utils.array_like import DatasetView, ListOfImages -from silx.math import calibration -from silx.utils.deprecation import deprecated_warning - -try: - import h5py -except ImportError: - def is_dataset(obj): - return False - h5py = None -else: - from silx.io.utils import is_dataset - -_logger = logging.getLogger(__name__) - - -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:`.PlotWidget` 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. - """ - - sigFrameChanged = qt.Signal(int) - """Signal emitter when the frame number has changed. - - This signal provides the current frame number. - """ - - 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""" - - self._titleCallback = self._defaultTitleCallback - """Function returning the plot title based on the frame index. - It can be set to a custom function using :meth:`setTitleCallback`""" - - self.calibrations3D = (calibration.NoCalibration(), - calibration.NoCalibration(), - calibration.NoCalibration()) - - 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._plot.getIntensityHistogramAction().setVisible(True) - self.sigInteractiveModeChanged = self._plot.sigInteractiveModeChanged - self.sigActiveImageChanged = self._plot.sigActiveImageChanged - self.sigPlotSignal = self._plot.sigPlotSignal - - if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': - self._plot.getYAxis().setInverted(True) - - self._addColorBarAction() - - self._plot.profile = Profile3DToolBar(parent=self._plot, - stackview=self) - self._plot.addToolBar(self._plot.profile) - self._plot.getXAxis().setLabel('Columns') - self._plot.getYAxis().setLabel('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.setRange(0, 0) - 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 _addColorBarAction(self): - self._plot.getColorBarWidget().setVisible(True) - actions = self._plot.toolBar().actions() - for index, action in enumerate(actions): - if action is self._plot.getColormapAction(): - break - self._colorbarAction = actions_control.ColorBarAction(self._plot, self._plot) - self._plot.toolBar().insertAction(actions[index + 1], self._colorbarAction) - - 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._plot.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 getPerspective(self): - """Returns the index of the dimension the stack is browsed with - - Possible values are: 0, 1, or 2. - - :rtype: int - """ - return self._perspective - - def setPerspective(self, perspective): - """Set the index of the dimension the stack is browsed with: - - - slice plane Dim1-Dim2: perspective 0 - - slice plane Dim0-Dim2: perspective 1 - - slice plane Dim0-Dim1: perspective 2 - - :param int perspective: Orthogonal dimension number (0, 1, or 2) - """ - 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 = int(perspective) - self.__createTransposedView() - self.__updateFrameNumber(self._browser.value()) - self._plot.resetZoom() - self.__updatePlotLabels() - self._updateTitle() - 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) - self.__planeSelection.sigPlaneSelectionChanged.disconnect(self.setPerspective) - self.__planeSelection.setPerspective(self._perspective) - self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective) - - 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 is_dataset(self._stack) 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 __updateFrameNumber(self, index): - """Update the current image. - - :param index: index of the frame to be displayed - """ - if self.__transposed_view is None: - # no data set - return - self._plot.addImage(self.__transposed_view[index, :, :], - origin=self._getImageOrigin(), - scale=self._getImageScale(), - legend=self.__imageLegend, - resetzoom=False) - self._updateTitle() - self.sigFrameChanged.emit(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 i, calib in enumerate(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") - elif not calib.is_affine(): - _logger.warning( - "Calibration for dimension %d is not linear, " - "it will be ignored for scaling the graph axes.", - i) - self.calibrations3D.append(calib) - - def getCalibrations(self, order='array'): - """Returns currently used calibrations for each axis - - Returned calibrations might differ from the ones that were set as - non-linear calibrations used for image axes are temporarily ignored. - - :param str order: - 'array' to sort calibrations as data array (dim0, dim1, dim2), - 'axes' to sort calibrations as currently selected x, y and z axes. - :return: Calibrations ordered depending on order - :rtype: List[~silx.math.calibration.AbstractCalibration] - """ - assert order in ('array', 'axes') - calibs = [] - - # filter out non-linear calibration for graph axes - for index, calib in enumerate(self.calibrations3D): - if index != self._perspective and not calib.is_affine(): - calib = calibration.NoCalibration() - calibs.append(calib) - - if order == 'axes': # Move 'z' axis to the end - xy_dims = [d for d in (0, 1, 2) if d != self._perspective] - calibs = [calibs[max(xy_dims)], - calibs[min(xy_dims)], - calibs[self._perspective]] - - return tuple(calibs) - - def _getImageScale(self): - """ - :return: 2-tuple (XScale, YScale) for current image view - """ - xcalib, ycalib, _zcalib = self.getCalibrations(order='axes') - return xcalib.get_slope(), ycalib.get_slope() - - def _getImageOrigin(self): - """ - :return: 2-tuple (XOrigin, YOrigin) for current image view - """ - xcalib, ycalib, _zcalib = self.getCalibrations(order='axes') - 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.getCalibrations(order='axes') - return zcalib(index) - - def _updateTitle(self): - frame_idx = self._browser.value() - self._plot.setGraphTitle(self._titleCallback(frame_idx)) - - def _defaultTitleCallback(self, index): - return "Image z=%g" % self._getImageZ(index) - - # public API, stack specific methods - def setStack(self, stack, perspective=None, 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. - Use ``None`` to keep the current perspective (default). - :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 not is_dataset(stack): - 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() - - perspective_changed = False - if perspective not in [None, self._perspective]: - perspective_changed = True - 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(), - replace=True, - resetzoom=False) - self._plot.setActiveImage(self.__imageLegend) - self.__updatePlotLabels() - self._updateTitle() - - if reset: - self._plot.resetZoom() - - # enable and init browser - self._browser.setEnabled(True) - - if not perspective_changed: # avoid double signal (see self.setPerspective) - 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._plot.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._plot.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 setFrameNumber(self, number): - """Set the frame selection to a specific value - - :param int number: Number of the frame - """ - self._browser.setValue(number) - - def getFrameNumber(self): - """Set the frame selection to a specific value - - :return: Index of currently displayed frame - :rtype: int - """ - return self._browser.value() - - 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) - - def setTitleCallback(self, callback): - """Set a user defined function to generate the plot title based on the - image/frame index. - - The callback function must accept an integer as a its first positional - parameter and must not require any other mandatory parameter. - It must return a string. - - To switch back the default behavior, you can pass ``None``:: - - mystackview.setTitleCallback(None) - - To have no title, pass a function that returns an empty string:: - - mystackview.setTitleCallback(lambda idx: "") - - :param callback: Callback function generating the stack title based - on the frame number. - """ - - if callback is None: - self._titleCallback = self._defaultTitleCallback - elif callable(callback): - self._titleCallback = callback - else: - raise TypeError("Provided callback is not callable") - self._updateTitle() - - 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) - # reset browser range - self._browser.setRange(0, 0) - self._plot.clear() - - 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 getLabels(self): - """Return dimension labels displayed on the plot axes - - :return: List of three strings corresponding to the 3 dimensions - of the stack: (name_dim0, name_dim1, name_dim2) - """ - return self.__dimensionsLabels - - 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 a :class`.Colormap` object. - :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 False. This option is not compatible - with h5py datasets. - :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 - """ - # if is a colormap object or a dictionary - if isinstance(colormap, Colormap) or isinstance(colormap, dict): - # Support colormap parameter as a dict - errmsg = "If colormap is provided as a Colormap object, 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 - - if isinstance(colormap, dict): - reason = 'colormap parameter should now be an object' - replacement = 'Colormap()' - since_version = '0.6' - deprecated_warning(type_='function', - name='setColormap', - reason=reason, - replacement=replacement, - since_version=since_version) - _colormap = Colormap._fromDict(colormap) - else: - _colormap = colormap - else: - norm = normalization if normalization is not None else 'linear' - name = colormap if colormap is not None else 'gray' - _colormap = Colormap(name=name, - normalization=norm, - vmin=vmin, - vmax=vmax, - colors=colors) - - # Patch: since we don't apply this colormap to a single 2D data but - # a 2D stack we have to deal manually with vmin, vmax - if autoscale is None: - # set default - autoscale = False - elif autoscale and is_dataset(self._stack): - # 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): - _vmin, _vmax = _colormap.getColormapRange(data=self._stack) - _colormap.setVRange(vmin=_vmin, vmax=_vmax) - else: - if vmin is None and self._stack is not None: - _colormap.setVMin(self._stack.min()) - else: - _colormap.setVMin(vmin) - if vmax is None and self._stack is not None: - _colormap.setVMax(self._stack.max()) - else: - _colormap.setVMax(vmax) - - cursorColor = cursorColorForColormap(_colormap.getName()) - self._plot.setInteractiveMode('zoom', color=cursorColor) - - self._plot.setDefaultColormap(_colormap) - - # Update active image colormap - activeImage = self._plot.getActiveImage() - if isinstance(activeImage, items.ColormapMixIn): - activeImage.setColormap(self.getColormap()) - - def getPlot(self): - """Return the :class:`PlotWidget`. - - This gives access to advanced plot configuration options. - Be warned that modifying the plot can cause issues, and some changes - you make to the plot could be overwritten by the :class:`StackView` - widget's internal methods and callbacks. - - :return: instance of :class:`PlotWidget` used in widget - """ - return self._plot - - 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() - - 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) - - # proxies to PlotWidget or PlotWindow methods - def getProfileToolbar(self): - """Profile tools attached to this plot - - See :class:`silx.gui.plot.Profile.Profile3DToolBar` - """ - return self._plot.profile - - 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 getGraphXLabel(self): - """Return the current horizontal axis label as a str. - """ - return self._plot.getXAxis().getLabel() - - 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.getXAxis().setLabel(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.getYAxis().getLabel(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.getYAxis(axis=axis).setLabel(label) - - def resetZoom(self): - """Reset the plot limits to the bounds of the data and redraw the plot. - - This method is a simple proxy to the legacy :class:`PlotWidget` method - of the same name. Using the object oriented approach is now - preferred:: - - stackview.getPlot().resetZoom() - """ - self._plot.resetZoom() - - def setYAxisInverted(self, flag=True): - """Set the Y axis orientation. - - This method is a simple proxy to the legacy :class:`PlotWidget` method - of the same name. Using the object oriented approach is now - preferred:: - - stackview.getPlot().setYAxisInverted(flag) - - :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. - - This method is a simple proxy to the legacy :class:`PlotWidget` method - of the same name. Using the object oriented approach is now - preferred:: - - stackview.getPlot().isYAxisInverted()""" - return self._plot.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') - - This method is a simple proxy to the legacy :class:`PlotWidget` method - of the same name. Using the object oriented approach is now - preferred:: - - stackview.getPlot().getSupportedColormaps() - """ - return self._plot.getSupportedColormaps() - - def isKeepDataAspectRatio(self): - """Returns whether the plot is keeping data aspect ratio or not. - - This method is a simple proxy to the legacy :class:`PlotWidget` method - of the same name. Using the object oriented approach is now - preferred:: - - stackview.getPlot().isKeepDataAspectRatio()""" - return self._plot.isKeepDataAspectRatio() - - def setKeepDataAspectRatio(self, flag=True): - """Set whether the plot keeps data aspect ratio or not. - - This method is a simple proxy to the legacy :class:`PlotWidget` method - of the same name. Using the object oriented approach is now - preferred:: - - stackview.getPlot().setKeepDataAspectRatio(flag) - - :param bool flag: True to respect data aspect ratio - """ - self._plot.setKeepDataAspectRatio(flag) - - # kind of private methods, but needed by Profile - def getActiveImage(self, just_legend=False): - """Returns the currently active image object. - - It returns None in case of not having an active image. - - This method is a simple proxy to the legacy :class:`PlotWidget` method - of the same name. Using the object oriented approach is now - preferred:: - - stackview.getPlot().getActiveImage() - - :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 getColorBarAction(self): - """Returns the action managing the visibility of the colorbar. - - .. warning:: to show/hide the plot colorbar call directly the ColorBar - widget using getColorBarWidget() - - :rtype: QAction - """ - return self._colorbarAction - - 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) - - -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.getOutputToolBar().getSaveAction()) - menu.addAction(self._plot.getOutputToolBar().getPrintAction()) - menu.addSeparator() - action = menu.addAction('Quit') - action.triggered[bool].connect(qt.QApplication.instance().quit) - - menu = self.menuBar().addMenu('Edit') - menu.addAction(self._plot.getOutputToolBar().getCopyAction()) - menu.addSeparator() - menu.addAction(self._plot.getResetZoomAction()) - menu.addAction(self._plot.getColormapAction()) - menu.addAction(self.getColorBarAction()) - - menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self)) - menu.addAction(actions.control.YAxisInvertedAction(self._plot, self)) - - menu = self.menuBar().addMenu('Profile') - 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/StatsWidget.py b/silx/gui/plot/StatsWidget.py deleted file mode 100644 index bb66613..0000000 --- a/silx/gui/plot/StatsWidget.py +++ /dev/null @@ -1,582 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -Module containing widgets displaying stats from items of a plot. -""" - -__authors__ = ["H. Payno"] -__license__ = "MIT" -__date__ = "24/07/2018" - - -import functools -import logging -import numpy -from collections import OrderedDict - -import silx.utils.weakref -from silx.gui import qt -from silx.gui import icons -from silx.gui.plot.items.curve import Curve as CurveItem -from silx.gui.plot.items.histogram import Histogram as HistogramItem -from silx.gui.plot.items.image import ImageBase as ImageItem -from silx.gui.plot.items.scatter import Scatter as ScatterItem -from silx.gui.plot import stats as statsmdl -from silx.gui.widgets.TableWidget import TableWidget -from silx.gui.plot.stats.statshandler import StatsHandler, StatFormatter - -logger = logging.getLogger(__name__) - - -class StatsWidget(qt.QWidget): - """ - Widget displaying a set of :class:`Stat` to be displayed on a - :class:`StatsTable` and to be apply on items contained in the :class:`Plot` - Also contains options to: - - * compute statistics on all the data or on visible data only - * show statistics of all items or only the active one - - :param parent: Qt parent - :param plot: the plot containing items on which we want statistics. - """ - - sigVisibilityChanged = qt.Signal(bool) - - NUMBER_FORMAT = '{0:.3f}' - - class OptionsWidget(qt.QToolBar): - - def __init__(self, parent=None): - qt.QToolBar.__init__(self, parent) - self.setIconSize(qt.QSize(16, 16)) - - action = qt.QAction(self) - action.setIcon(icons.getQIcon("stats-active-items")) - action.setText("Active items only") - action.setToolTip("Display stats for active items only.") - action.setCheckable(True) - action.setChecked(True) - self.__displayActiveItems = action - - action = qt.QAction(self) - action.setIcon(icons.getQIcon("stats-whole-items")) - action.setText("All items") - action.setToolTip("Display stats for all available items.") - action.setCheckable(True) - self.__displayWholeItems = action - - action = qt.QAction(self) - action.setIcon(icons.getQIcon("stats-visible-data")) - action.setText("Use the visible data range") - action.setToolTip("Use the visible data range.<br/>" - "If activated the data is filtered to only use" - "visible data of the plot." - "The filtering is a data sub-sampling." - "No interpolation is made to fit data to" - "boundaries.") - action.setCheckable(True) - self.__useVisibleData = action - - action = qt.QAction(self) - action.setIcon(icons.getQIcon("stats-whole-data")) - action.setText("Use the full data range") - action.setToolTip("Use the full data range.") - action.setCheckable(True) - action.setChecked(True) - self.__useWholeData = action - - self.addAction(self.__displayWholeItems) - self.addAction(self.__displayActiveItems) - self.addSeparator() - self.addAction(self.__useVisibleData) - self.addAction(self.__useWholeData) - - self.itemSelection = qt.QActionGroup(self) - self.itemSelection.setExclusive(True) - self.itemSelection.addAction(self.__displayActiveItems) - self.itemSelection.addAction(self.__displayWholeItems) - - self.dataRangeSelection = qt.QActionGroup(self) - self.dataRangeSelection.setExclusive(True) - self.dataRangeSelection.addAction(self.__useWholeData) - self.dataRangeSelection.addAction(self.__useVisibleData) - - def isActiveItemMode(self): - return self.itemSelection.checkedAction() is self.__displayActiveItems - - def isVisibleDataRangeMode(self): - return self.dataRangeSelection.checkedAction() is self.__useVisibleData - - def __init__(self, parent=None, plot=None, stats=None): - qt.QWidget.__init__(self, parent) - self.setLayout(qt.QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - self._options = self.OptionsWidget(parent=self) - self.layout().addWidget(self._options) - self._statsTable = StatsTable(parent=self, plot=plot) - self.setStats = self._statsTable.setStats - self.setStats(stats) - - self.layout().addWidget(self._statsTable) - self.setPlot = self._statsTable.setPlot - - self._options.itemSelection.triggered.connect( - self._optSelectionChanged) - self._options.dataRangeSelection.triggered.connect( - self._optDataRangeChanged) - self._optSelectionChanged() - self._optDataRangeChanged() - - self.setDisplayOnlyActiveItem = self._statsTable.setDisplayOnlyActiveItem - self.setStatsOnVisibleData = self._statsTable.setStatsOnVisibleData - - def showEvent(self, event): - self.sigVisibilityChanged.emit(True) - qt.QWidget.showEvent(self, event) - - def hideEvent(self, event): - self.sigVisibilityChanged.emit(False) - qt.QWidget.hideEvent(self, event) - - def _optSelectionChanged(self, action=None): - self._statsTable.setDisplayOnlyActiveItem(self._options.isActiveItemMode()) - - def _optDataRangeChanged(self, action=None): - self._statsTable.setStatsOnVisibleData(self._options.isVisibleDataRangeMode()) - - -class BasicStatsWidget(StatsWidget): - """ - Widget defining a simple set of :class:`Stat` to be displayed on a - :class:`StatsWidget`. - - :param parent: Qt parent - :param plot: the plot containing items on which we want statistics. - """ - - STATS = StatsHandler(( - (statsmdl.StatMin(), StatFormatter()), - statsmdl.StatCoordMin(), - (statsmdl.StatMax(), StatFormatter()), - statsmdl.StatCoordMax(), - (('std', numpy.std), StatFormatter()), - (('mean', numpy.mean), StatFormatter()), - statsmdl.StatCOM() - )) - - def __init__(self, parent=None, plot=None): - StatsWidget.__init__(self, parent=parent, plot=plot, stats=self.STATS) - - -class StatsTable(TableWidget): - """ - TableWidget displaying for each curves contained by the Plot some - information: - - * legend - * minimal value - * maximal value - * standard deviation (std) - - :param parent: The widget's parent. - :param plot: :class:`.PlotWidget` instance on which to operate - """ - - COMPATIBLE_KINDS = { - 'curve': CurveItem, - 'image': ImageItem, - 'scatter': ScatterItem, - 'histogram': HistogramItem - } - - COMPATIBLE_ITEMS = tuple(COMPATIBLE_KINDS.values()) - - def __init__(self, parent=None, plot=None): - TableWidget.__init__(self, parent) - """Next freeID for the curve""" - self.plot = None - self._displayOnlyActItem = False - self._statsOnVisibleData = False - self._lgdAndKindToItems = {} - """Associate to a tuple(legend, kind) the items legend""" - self.callbackImage = None - self.callbackScatter = None - self.callbackCurve = None - """Associate the curve legend to his first item""" - self._statsHandler = None - self._legendsSet = [] - """list of legends actually displayed""" - self._resetColumns() - - self.setColumnCount(len(self._columns)) - self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) - self.setPlot(plot) - self.setSortingEnabled(True) - - def _resetColumns(self): - self._columns_index = OrderedDict([('legend', 0), ('kind', 1)]) - self._columns = self._columns_index.keys() - self.setColumnCount(len(self._columns)) - - def setStats(self, statsHandler): - """ - - :param statsHandler: Set the statistics to be displayed and how to - format them using - :rtype: :class:`StatsHandler` - """ - _statsHandler = statsHandler - if statsHandler is None: - _statsHandler = StatsHandler(statFormatters=()) - if isinstance(_statsHandler, (list, tuple)): - _statsHandler = StatsHandler(_statsHandler) - assert isinstance(_statsHandler, StatsHandler) - self._resetColumns() - self.clear() - - for statName, stat in list(_statsHandler.stats.items()): - assert isinstance(stat, statsmdl.StatBase) - self._columns_index[statName] = len(self._columns_index) - self._statsHandler = _statsHandler - self._columns = self._columns_index.keys() - self.setColumnCount(len(self._columns)) - - self._updateItemObserve() - self._updateAllStats() - - def getStatsHandler(self): - return self._statsHandler - - def _updateAllStats(self): - for (legend, kind) in self._lgdAndKindToItems: - self._updateStats(legend, kind) - - @staticmethod - def _getKind(myItem): - if isinstance(myItem, CurveItem): - return 'curve' - elif isinstance(myItem, ImageItem): - return 'image' - elif isinstance(myItem, ScatterItem): - return 'scatter' - elif isinstance(myItem, HistogramItem): - return 'histogram' - else: - return None - - def setPlot(self, plot): - """ - Define the plot to interact with - - :param plot: the plot containing the items on which statistics are - applied - :rtype: :class:`.PlotWidget` - """ - if self.plot: - self._dealWithPlotConnection(create=False) - self.plot = plot - self.clear() - if self.plot: - self._dealWithPlotConnection(create=True) - self._updateItemObserve() - - def _updateItemObserve(self): - if self.plot: - self.clear() - if self._displayOnlyActItem is True: - activeCurve = self.plot.getActiveCurve(just_legend=False) - activeScatter = self.plot._getActiveItem(kind='scatter', - just_legend=False) - activeImage = self.plot.getActiveImage(just_legend=False) - if activeCurve: - self._addItem(activeCurve) - if activeImage: - self._addItem(activeImage) - if activeScatter: - self._addItem(activeScatter) - else: - [self._addItem(curve) for curve in self.plot.getAllCurves()] - [self._addItem(image) for image in self.plot.getAllImages()] - scatters = self.plot._getItems(kind='scatter', - just_legend=False, - withhidden=True) - [self._addItem(scatter) for scatter in scatters] - histograms = self.plot._getItems(kind='histogram', - just_legend=False, - withhidden=True) - [self._addItem(histogram) for histogram in histograms] - - def _dealWithPlotConnection(self, create=True): - """ - Manage connection to plot signals - - Note: connection on Item are managed by the _removeItem function - """ - if self.plot is None: - return - if self._displayOnlyActItem: - if create is True: - if self.callbackImage is None: - self.callbackImage = functools.partial(self._activeItemChanged, 'image') - self.callbackScatter = functools.partial(self._activeItemChanged, 'scatter') - self.callbackCurve = functools.partial(self._activeItemChanged, 'curve') - self.plot.sigActiveImageChanged.connect(self.callbackImage) - self.plot.sigActiveScatterChanged.connect(self.callbackScatter) - self.plot.sigActiveCurveChanged.connect(self.callbackCurve) - else: - if self.callbackImage is not None: - self.plot.sigActiveImageChanged.disconnect(self.callbackImage) - self.plot.sigActiveScatterChanged.disconnect(self.callbackScatter) - self.plot.sigActiveCurveChanged.disconnect(self.callbackCurve) - self.callbackImage = None - self.callbackScatter = None - self.callbackCurve = None - else: - if create is True: - self.plot.sigContentChanged.connect(self._plotContentChanged) - else: - self.plot.sigContentChanged.disconnect(self._plotContentChanged) - if create is True: - self.plot.sigPlotSignal.connect(self._zoomPlotChanged) - else: - self.plot.sigPlotSignal.disconnect(self._zoomPlotChanged) - - def clear(self): - """ - Clear all existing items - """ - lgdsAndKinds = list(self._lgdAndKindToItems.keys()) - for lgdAndKind in lgdsAndKinds: - self._removeItem(legend=lgdAndKind[0], kind=lgdAndKind[1]) - self._lgdAndKindToItems = {} - qt.QTableWidget.clear(self) - self.setRowCount(0) - - # It have to called befor3e accessing to the header items - self.setHorizontalHeaderLabels(list(self._columns)) - - if self._statsHandler is not None: - for columnId, name in enumerate(self._columns): - item = self.horizontalHeaderItem(columnId) - if name in self._statsHandler.stats: - stat = self._statsHandler.stats[name] - text = stat.name[0].upper() + stat.name[1:] - if stat.description is not None: - tooltip = stat.description - else: - tooltip = "" - else: - text = name[0].upper() + name[1:] - tooltip = "" - item.setToolTip(tooltip) - item.setText(text) - - if hasattr(self.horizontalHeader(), 'setSectionResizeMode'): # Qt5 - self.horizontalHeader().setSectionResizeMode(qt.QHeaderView.ResizeToContents) - else: # Qt4 - self.horizontalHeader().setResizeMode(qt.QHeaderView.ResizeToContents) - self.setColumnHidden(self._columns_index['kind'], True) - - def _addItem(self, item): - assert isinstance(item, self.COMPATIBLE_ITEMS) - if (item.getLegend(), self._getKind(item)) in self._lgdAndKindToItems: - self._updateStats(item.getLegend(), self._getKind(item)) - return - - self.setRowCount(self.rowCount() + 1) - indexTable = self.rowCount() - 1 - kind = self._getKind(item) - - self._lgdAndKindToItems[(item.getLegend(), kind)] = {} - - # the get item will manage the item creation of not existing - _createItem = self._getItem - for itemName in self._columns: - _createItem(name=itemName, legend=item.getLegend(), kind=kind, - indexTable=indexTable) - - self._updateStats(legend=item.getLegend(), kind=kind) - - callback = functools.partial( - silx.utils.weakref.WeakMethodProxy(self._updateStats), - item.getLegend(), kind) - item.sigItemChanged.connect(callback) - self.setColumnHidden(self._columns_index['kind'], - item.getLegend() not in self._legendsSet) - self._legendsSet.append(item.getLegend()) - - def _getItem(self, name, legend, kind, indexTable): - if (legend, kind) not in self._lgdAndKindToItems: - self._lgdAndKindToItems[(legend, kind)] = {} - if not (name in self._lgdAndKindToItems[(legend, kind)] and - self._lgdAndKindToItems[(legend, kind)]): - if name in ('legend', 'kind'): - _item = qt.QTableWidgetItem(type=qt.QTableWidgetItem.Type) - if name == 'legend': - _item.setText(legend) - else: - assert name == 'kind' - _item.setText(kind) - else: - if self._statsHandler.formatters[name]: - _item = self._statsHandler.formatters[name].tabWidgetItemClass() - else: - _item = qt.QTableWidgetItem() - tooltip = self._statsHandler.stats[name].getToolTip(kind=kind) - if tooltip is not None: - _item.setToolTip(tooltip) - - _item.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable) - self.setItem(indexTable, self._columns_index[name], _item) - self._lgdAndKindToItems[(legend, kind)][name] = _item - - return self._lgdAndKindToItems[(legend, kind)][name] - - def _removeItem(self, legend, kind): - if (legend, kind) not in self._lgdAndKindToItems or not self.plot: - return - - self.firstItem = self._lgdAndKindToItems[(legend, kind)]['legend'] - del self._lgdAndKindToItems[(legend, kind)] - self.removeRow(self.firstItem.row()) - self._legendsSet.remove(legend) - self.setColumnHidden(self._columns_index['kind'], - legend not in self._legendsSet) - - def _updateCurrentStats(self): - for lgdAndKind in self._lgdAndKindToItems: - self._updateStats(lgdAndKind[0], lgdAndKind[1]) - - def _updateStats(self, legend, kind, event=None): - if self._statsHandler is None: - return - - assert kind in ('curve', 'image', 'scatter', 'histogram') - if kind == 'curve': - item = self.plot.getCurve(legend) - elif kind == 'image': - item = self.plot.getImage(legend) - elif kind == 'scatter': - item = self.plot.getScatter(legend) - elif kind == 'histogram': - item = self.plot.getHistogram(legend) - else: - raise ValueError('kind not managed') - - if not item or (item.getLegend(), kind) not in self._lgdAndKindToItems: - return - - assert isinstance(item, self.COMPATIBLE_ITEMS) - - statsValDict = self._statsHandler.calculate(item, self.plot, - self._statsOnVisibleData) - - lgdItem = self._lgdAndKindToItems[(item.getLegend(), kind)]['legend'] - assert lgdItem - rowStat = lgdItem.row() - - for statName, statVal in list(statsValDict.items()): - assert statName in self._lgdAndKindToItems[(item.getLegend(), kind)] - tableItem = self._getItem(name=statName, legend=item.getLegend(), - kind=kind, indexTable=rowStat) - tableItem.setText(str(statVal)) - - def currentChanged(self, current, previous): - if current.row() >= 0: - legendItem = self.item(current.row(), self._columns_index['legend']) - assert legendItem - kindItem = self.item(current.row(), self._columns_index['kind']) - kind = kindItem.text() - if kind == 'curve': - self.plot.setActiveCurve(legendItem.text()) - elif kind == 'image': - self.plot.setActiveImage(legendItem.text()) - elif kind == 'scatter': - self.plot._setActiveItem('scatter', legendItem.text()) - elif kind == 'histogram': - # active histogram not managed by the plot actually - pass - else: - raise ValueError('kind not managed') - qt.QTableWidget.currentChanged(self, current, previous) - - def setDisplayOnlyActiveItem(self, displayOnlyActItem): - """ - - :param bool displayOnlyActItem: True if we want to only show active - item - """ - if self._displayOnlyActItem == displayOnlyActItem: - return - self._displayOnlyActItem = displayOnlyActItem - self._dealWithPlotConnection(create=False) - self._updateItemObserve() - self._dealWithPlotConnection(create=True) - - def setStatsOnVisibleData(self, b): - """ - .. warning:: When visible data is activated we will process to a simple - filtering of visible data by the user. The filtering is a - simple data sub-sampling. No interpolation is made to fit - data to boundaries. - - :param bool b: True if we want to apply statistics only on visible data - """ - if self._statsOnVisibleData != b: - self._statsOnVisibleData = b - self._updateCurrentStats() - - def _activeItemChanged(self, kind, previous, current): - """Callback used when plotting only the active item""" - assert kind in ('curve', 'image', 'scatter', 'histogram') - self._updateItemObserve() - - def _plotContentChanged(self, action, kind, legend): - """Callback used when plotting all the plot items""" - if kind not in ('curve', 'image', 'scatter', 'histogram'): - return - if kind == 'curve': - item = self.plot.getCurve(legend) - elif kind == 'image': - item = self.plot.getImage(legend) - elif kind == 'scatter': - item = self.plot.getScatter(legend) - elif kind == 'histogram': - item = self.plot.getHistogram(legend) - else: - raise ValueError('kind not managed') - - if action == 'add': - if item is None: - raise ValueError('Item from legend "%s" do not exists' % legend) - self._addItem(item) - elif action == 'remove': - self._removeItem(legend, kind) - - def _zoomPlotChanged(self, event): - if self._statsOnVisibleData is True: - if 'event' in event and event['event'] == 'limitsChanged': - self._updateCurrentStats() diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py deleted file mode 100644 index e087354..0000000 --- a/silx/gui/plot/_BaseMaskToolsWidget.py +++ /dev/null @@ -1,1167 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module 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__ = "29/08/2018" - -import os -import weakref - -import numpy - -from silx.gui import qt, icons -from silx.gui.widgets.FloatEdit import FloatEdit -from silx.gui.colors import Colormap -from silx.gui.colors import rgba -from .actions.mode import PanModeAction - - -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, mask=None): - """ - - :param parent: Parent QWidget - :param plot: Plot widget on which to operate - :param mask: Instance of subclass of :class:`BaseMask` - (e.g. :class:`ImageMask`) - """ - super(BaseMaskToolsWidget, self).__init__(parent) - # 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) - - # as parent have to be the first argument of the widget to fit - # QtDesigner need but here plot can't be None by default. - assert plot is not None - self._plotRef = weakref.ref(plot) - self._maskName = '__MASK_TOOLS_%d' % id(self) # Legend of the mask - - self._colormap = Colormap(name="", - normalization='linear', - vmin=0, - vmax=self._maxLevelNumber, - colors=None) - self._defaultOverlayColor = rgba('gray') # Color of the mask - self._setMaskColors(1, 0.5) - - if not isinstance(mask, BaseMask): - raise TypeError("mask is not an instance of BaseMask") - self._mask = mask - - 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' - - self._maskFileDir = qt.QDir.home().absolutePath() - self.plot.sigInteractiveModeChanged.connect( - self._interactiveModeChanged) - - self._initWidgets() - - 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 mask (as an array of uint8) with dimension of - the 'active' plot item. - If there is no active image or scatter, it returns None. - :rtype: Union[numpy.ndarray,None] - """ - mask = self._mask.getMask(copy=copy) - return None if mask.size == 0 else mask - - def setSelectionMask(self, mask): - """Set the mask: Must be implemented in subclass""" - raise NotImplementedError() - - def resetSelectionMask(self): - """Reset the mask: Must be implemented in subclass""" - raise NotImplementedError() - - 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.""" - plot = self._plotRef() - if plot is None: - raise RuntimeError( - 'Mask widget attached to a PlotWidget that no longer exists') - return 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 isMaskInteractionActivated(self): - """Returns true if any mask interaction is activated""" - return self.drawActionGroup.checkedAction() is not None - - def _initDrawGroupBox(self): - """Init drawing tools widgets""" - layout = qt.QVBoxLayout() - - self.browseAction = PanModeAction(self.plot, self.plot) - self.addAction(self.browseAction) - - # Draw tools - 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 new polygon corners<br>' - 'Left-click on first corner to close the polygon') - 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.pencilAction) - - self.drawActionGroup = qt.QActionGroup(self) - self.drawActionGroup.setExclusive(True) - self.drawActionGroup.addAction(self.rectAction) - self.drawActionGroup.addAction(self.polygonAction) - self.drawActionGroup.addAction(self.pencilAction) - - actions = (self.browseAction, self.rectAction, - self.polygonAction, self.pencilAction) - drawButtons = [] - for action in actions: - btn = qt.QToolButton() - btn.setDefaultAction(action) - drawButtons.append(btn) - container = self._hboxWidget(*drawButtons) - 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) - - self.maskStateWidget.setHidden(True) - - # 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 = FloatEdit(self, value=0) - self.minLineEdit.setEnabled(False) - form.addRow('Min:', self.minLineEdit) - - self.maxLineEdit = FloatEdit(self, value=0) - 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 - self.drawActionGroup.checkedAction()): - # Disable drawing tool by setting interaction to zoom - self.browseAction.trigger() - - 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.setColormapLUT(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: - self.pencilAction.setChecked(False) - self.rectAction.setChecked(False) - self.polygonAction.setChecked(False) - self._releaseDrawingMode() - self._updateDrawingModeWidgets() - - 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 _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 _getPencilWidth(self): - """Returns the width of the pencil to use in data coordinates` - - :rtype: float - """ - return self.pencilSpinBox.value() - - def _activePencilMode(self): - """Handle pencil action mode triggering""" - self._releaseDrawingMode() - self._drawingMode = 'pencil' - self.plot.sigPlotSignal.connect(self._plotDrawEvent) - color = self.getCurrentMaskColor() - width = self._getPencilWidth() - self.plot.setInteractiveMode( - 'draw', shape='pencil', source=self, color=color, width=width) - self._updateDrawingModeWidgets() - - def _updateDrawingModeWidgets(self): - self.maskStateWidget.setVisible(self._drawingMode is not None) - 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(), - self.minLineEdit.value()) - self._mask.commit() - - elif self.betweenThresholdAction.isChecked(): - if self.minLineEdit.text() and self.maxLineEdit.text(): - min_ = self.minLineEdit.value() - max_ = self.maxLineEdit.value() - self._mask.updateBetweenThresholds(self.levelSpinBox.value(), - min_, max_) - self._mask.commit() - - elif self.aboveThresholdAction.isChecked(): - if self.maxLineEdit.text(): - max_ = float(self.maxLineEdit.value()) - 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', widget=None): - super(BaseMaskToolsDockWidget, self).__init__(parent) - self.setWindowTitle(name) - - if not isinstance(widget, BaseMaskToolsWidget): - raise TypeError("BaseMaskToolsDockWidget requires a MaskToolsWidget") - self.setWidget(widget) - self.widget().sigMaskChanged.connect(self._emitSigMaskChanged) - - 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 resetSelectionMask(self): - """Reset the mask to an array of zeros with the shape of the - current data.""" - self.widget().resetSelectionMask() - - 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 deleted file mode 100644 index 3a141b3..0000000 --- a/silx/gui/plot/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This 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:`.ScatterView`: A widget with tools for scatter plot. -- :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:`.PlotWidget`). - -.. 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__ = "03/05/2017" - - -from .PlotWidget import PlotWidget # noqa -from .PlotWindow import PlotWindow, Plot1D, Plot2D # noqa -from .items.axis import TickMode -from .ImageView import ImageView # noqa -from .StackView import StackView # noqa -from .ScatterView import ScatterView # noqa - -__all__ = ['ImageView', 'PlotWidget', 'PlotWindow', 'Plot1D', 'Plot2D', - 'StackView', 'ScatterView', 'TickMode'] diff --git a/silx/gui/plot/_utils/__init__.py b/silx/gui/plot/_utils/__init__.py deleted file mode 100644 index 3c2dfa4..0000000 --- a/silx/gui/plot/_utils/__init__.py +++ /dev/null @@ -1,93 +0,0 @@ -# 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 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/dtime_ticklayout.py b/silx/gui/plot/_utils/dtime_ticklayout.py deleted file mode 100644 index 95fc235..0000000 --- a/silx/gui/plot/_utils/dtime_ticklayout.py +++ /dev/null @@ -1,438 +0,0 @@ -# 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 date-time labels layout on graph axes.""" - -from __future__ import absolute_import, division, unicode_literals - -__authors__ = ["P. Kenter"] -__license__ = "MIT" -__date__ = "04/04/2018" - - -import datetime as dt -import logging -import math -import time - -import dateutil.tz - -from dateutil.relativedelta import relativedelta - -from silx.third_party import enum -from .ticklayout import niceNumGeneric - -_logger = logging.getLogger(__name__) - - -MICROSECONDS_PER_SECOND = 1000000 -SECONDS_PER_MINUTE = 60 -SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE -SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR -SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY -SECONDS_PER_MONTH_AVERAGE = SECONDS_PER_YEAR / 12 # Seconds per average month - - -# No dt.timezone in Python 2.7 so we use dateutil.tz.tzutc -_EPOCH = dt.datetime(1970, 1, 1, tzinfo=dateutil.tz.tzutc()) - -def timestamp(dtObj): - """ Returns POSIX timestamp of a datetime objects. - - If the dtObj object has a timestamp() method (python 3.3), this is - used. Otherwise (e.g. python 2.7) it is calculated here. - - The POSIX timestamp is a floating point value of the number of seconds - since the start of an epoch (typically 1970-01-01). For details see: - https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp - - :param datetime.datetime dtObj: date-time representation. - :return: POSIX timestamp - :rtype: float - """ - if hasattr(dtObj, "timestamp"): - return dtObj.timestamp() - else: - # Back ported from Python 3.5 - if dtObj.tzinfo is None: - return time.mktime((dtObj.year, dtObj.month, dtObj.day, - dtObj.hour, dtObj.minute, dtObj.second, - -1, -1, -1)) + dtObj.microsecond / 1e6 - else: - return (dtObj - _EPOCH).total_seconds() - - -@enum.unique -class DtUnit(enum.Enum): - YEARS = 0 - MONTHS = 1 - DAYS = 2 - HOURS = 3 - MINUTES = 4 - SECONDS = 5 - MICRO_SECONDS = 6 # a fraction of a second - - -def getDateElement(dateTime, unit): - """ Picks the date element with the unit from the dateTime - - E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6 - - :param datetime dateTime: date/time to pick from - :param DtUnit unit: The unit describing the date element. - """ - if unit == DtUnit.YEARS: - return dateTime.year - elif unit == DtUnit.MONTHS: - return dateTime.month - elif unit == DtUnit.DAYS: - return dateTime.day - elif unit == DtUnit.HOURS: - return dateTime.hour - elif unit == DtUnit.MINUTES: - return dateTime.minute - elif unit == DtUnit.SECONDS: - return dateTime.second - elif unit == DtUnit.MICRO_SECONDS: - return dateTime.microsecond - else: - raise ValueError("Unexpected DtUnit: {}".format(unit)) - - -def setDateElement(dateTime, value, unit): - """ Returns a copy of dateTime with the tickStep unit set to value - - :param datetime.datetime: date time object - :param int value: value to set - :param DtUnit unit: unit - :return: datetime.datetime - """ - intValue = int(value) - _logger.debug("setDateElement({}, {} (int={}), {})" - .format(dateTime, value, intValue, unit)) - - year = dateTime.year - month = dateTime.month - day = dateTime.day - hour = dateTime.hour - minute = dateTime.minute - second = dateTime.second - microsecond = dateTime.microsecond - - if unit == DtUnit.YEARS: - year = intValue - elif unit == DtUnit.MONTHS: - month = intValue - elif unit == DtUnit.DAYS: - day = intValue - elif unit == DtUnit.HOURS: - hour = intValue - elif unit == DtUnit.MINUTES: - minute = intValue - elif unit == DtUnit.SECONDS: - second = intValue - elif unit == DtUnit.MICRO_SECONDS: - microsecond = intValue - else: - raise ValueError("Unexpected DtUnit: {}".format(unit)) - - _logger.debug("creating date time {}" - .format((year, month, day, hour, minute, second, microsecond))) - - return dt.datetime(year, month, day, hour, minute, second, microsecond, - tzinfo=dateTime.tzinfo) - - - -def roundToElement(dateTime, unit): - """ Returns a copy of dateTime with the - - :param datetime.datetime: date time object - :param DtUnit unit: unit - :return: datetime.datetime - """ - year = dateTime.year - month = dateTime.month - day = dateTime.day - hour = dateTime.hour - minute = dateTime.minute - second = dateTime.second - microsecond = dateTime.microsecond - - if unit.value < DtUnit.YEARS.value: - pass # Never round years - if unit.value < DtUnit.MONTHS.value: - month = 1 - if unit.value < DtUnit.DAYS.value: - day = 1 - if unit.value < DtUnit.HOURS.value: - hour = 0 - if unit.value < DtUnit.MINUTES.value: - minute = 0 - if unit.value < DtUnit.SECONDS.value: - second = 0 - if unit.value < DtUnit.MICRO_SECONDS.value: - microsecond = 0 - - result = dt.datetime(year, month, day, hour, minute, second, microsecond, - tzinfo=dateTime.tzinfo) - - return result - - -def addValueToDate(dateTime, value, unit): - """ Adds a value with unit to a dateTime. - - Uses dateutil.relativedelta.relativedelta from the standard library to do - the actual math. This function doesn't allow for fractional month or years, - so month and year are truncated to integers before adding. - - :param datetime dateTime: date time - :param float value: value to be added - :param DtUnit unit: of the value - :return: - """ - #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit)) - - if unit == DtUnit.YEARS: - intValue = int(value) # floats not implemented in relativeDelta(years) - return dateTime + relativedelta(years=intValue) - elif unit == DtUnit.MONTHS: - intValue = int(value) # floats not implemented in relativeDelta(mohths) - return dateTime + relativedelta(months=intValue) - elif unit == DtUnit.DAYS: - return dateTime + relativedelta(days=value) - elif unit == DtUnit.HOURS: - return dateTime + relativedelta(hours=value) - elif unit == DtUnit.MINUTES: - return dateTime + relativedelta(minutes=value) - elif unit == DtUnit.SECONDS: - return dateTime + relativedelta(seconds=value) - elif unit == DtUnit.MICRO_SECONDS: - return dateTime + relativedelta(microseconds=value) - else: - raise ValueError("Unexpected DtUnit: {}".format(unit)) - - -def bestUnit(durationInSeconds): - """ Gets the best tick spacing given a duration in seconds. - - :param durationInSeconds: time span duration in seconds - :return: DtUnit enumeration. - """ - - # Based on; https://stackoverflow.com/a/2144398/ - # If the duration is longer than two years the tick spacing will be in - # years. Else, if the duration is longer than two months, the spacing will - # be in months, Etcetera. - # - # This factor differs per unit. As a baseline it is 2, but for instance, - # for Months this needs to be higher (3>), This because it is impossible to - # have partial months so the tick spacing is always at least 1 month. A - # duration of two months would result in two ticks, which is too few. - # months would then results - - if durationInSeconds > SECONDS_PER_YEAR * 3: - return (durationInSeconds / SECONDS_PER_YEAR, DtUnit.YEARS) - elif durationInSeconds > SECONDS_PER_MONTH_AVERAGE * 3: - return (durationInSeconds / SECONDS_PER_MONTH_AVERAGE, DtUnit.MONTHS) - elif durationInSeconds > SECONDS_PER_DAY * 2: - return (durationInSeconds / SECONDS_PER_DAY, DtUnit.DAYS) - elif durationInSeconds > SECONDS_PER_HOUR * 2: - return (durationInSeconds / SECONDS_PER_HOUR, DtUnit.HOURS) - elif durationInSeconds > SECONDS_PER_MINUTE * 2: - return (durationInSeconds / SECONDS_PER_MINUTE, DtUnit.MINUTES) - elif durationInSeconds > 1 * 2: - return (durationInSeconds, DtUnit.SECONDS) - else: - return (durationInSeconds * MICROSECONDS_PER_SECOND, - DtUnit.MICRO_SECONDS) - - -NICE_DATE_VALUES = { - DtUnit.YEARS: [1, 2, 5, 10], - DtUnit.MONTHS: [1, 2, 3, 4, 6, 12], - DtUnit.DAYS: [1, 2, 3, 7, 14, 28], - DtUnit.HOURS: [1, 2, 3, 4, 6, 12], - DtUnit.MINUTES: [1, 2, 3, 5, 10, 15, 30], - DtUnit.SECONDS: [1, 2, 3, 5, 10, 15, 30], - DtUnit.MICRO_SECONDS : [1.0, 2.0, 5.0, 10.0], # floats for microsec -} - - -def bestFormatString(spacing, unit): - """ Finds the best format string given the spacing and DtUnit. - - If the spacing is a fractional number < 1 the format string will take this - into account - - :param spacing: spacing between ticks - :param DtUnit unit: - :return: Format string for use in strftime - :rtype: str - """ - isSmall = spacing < 1 - - if unit == DtUnit.YEARS: - return "%Y-m" if isSmall else "%Y" - elif unit == DtUnit.MONTHS: - return "%Y-%m-%d" if isSmall else "%Y-%m" - elif unit == DtUnit.DAYS: - return "%H:%M" if isSmall else "%Y-%m-%d" - elif unit == DtUnit.HOURS: - return "%H:%M" if isSmall else "%H:%M" - elif unit == DtUnit.MINUTES: - return "%H:%M:%S" if isSmall else "%H:%M" - elif unit == DtUnit.SECONDS: - return "%S.%f" if isSmall else "%H:%M:%S" - elif unit == DtUnit.MICRO_SECONDS: - return "%S.%f" - else: - raise ValueError("Unexpected DtUnit: {}".format(unit)) - - -def niceDateTimeElement(value, unit, isRound=False): - """ Uses the Nice Numbers algorithm to determine a nice value. - - The fractions are optimized for the unit of the date element. - """ - - niceValues = NICE_DATE_VALUES[unit] - elemValue = niceNumGeneric(value, niceValues, isRound=isRound) - - if unit == DtUnit.YEARS or unit == DtUnit.MONTHS: - elemValue = max(1, int(elemValue)) - - return elemValue - - -def findStartDate(dMin, dMax, nTicks): - """ Rounds a date down to the nearest nice number of ticks - """ - assert dMax > dMin, \ - "dMin ({}) should come before dMax ({})".format(dMin, dMax) - - delta = dMax - dMin - lengthSec = delta.total_seconds() - _logger.debug("findStartDate: {}, {} (duration = {} sec, {} days)" - .format(dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY)) - - length, unit = bestUnit(delta.total_seconds()) - niceLength = niceDateTimeElement(length, unit) - - _logger.debug("Length: {:8.3f} {} (nice = {})" - .format(length, unit.name, niceLength)) - - niceSpacing = niceDateTimeElement(niceLength / nTicks, unit, isRound=True) - - _logger.debug("Spacing: {:8.3f} {} (nice = {})" - .format(niceLength / nTicks, unit.name, niceSpacing)) - - dVal = getDateElement(dMin, unit) - - if unit == DtUnit.MONTHS: # TODO: better rounding? - niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 - elif unit == DtUnit.DAYS: - niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 - else: - niceVal = math.floor(dVal / niceSpacing) * niceSpacing - - _logger.debug("StartValue: dVal = {}, niceVal: {} ({})" - .format(dVal, niceVal, unit.name)) - - startDate = roundToElement(dMin, unit) - startDate = setDateElement(startDate, niceVal, unit) - - return startDate, niceSpacing, unit - - -def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): - """ Generates a range of dates - - :param datetime dMin: start date - :param datetime dMax: end date - :param int step: the step size - :param DtUnit unit: the unit of the step size - :param bool includeFirstBeyond: if True the first date later than dMax will - be included in the range. If False (the default), the last generated - datetime will always be smaller than dMax. - :return: - """ - if (unit == DtUnit.YEARS or unit == DtUnit.MONTHS or - unit == DtUnit.MICRO_SECONDS): - - # Month and years will be converted to integers - assert int(step) > 0, "Integer value or tickstep is 0" - else: - assert step > 0, "tickstep is 0" - - dateTime = dMin - while dateTime < dMax: - yield dateTime - dateTime = addValueToDate(dateTime, step, unit) - - if includeFirstBeyond: - yield dateTime - - - -def calcTicks(dMin, dMax, nTicks): - """Returns tick positions. - - :param datetime.datetime dMin: The min value on the axis - :param datetime.datetime dMax: The max value on the axis - :param int nTicks: The target number of ticks. The actual number of found - ticks may differ. - :returns: (list of datetimes, DtUnit) tuple - """ - _logger.debug("Calc calcTicks({}, {}, nTicks={})" - .format(dMin, dMax, nTicks)) - - startDate, niceSpacing, unit = findStartDate(dMin, dMax, nTicks) - - result = [] - for d in dateRange(startDate, dMax, niceSpacing, unit, - includeFirstBeyond=True): - result.append(d) - - assert result[0] <= dMin, \ - "First nice date ({}) should be <= dMin {}".format(result[0], dMin) - - assert result[-1] >= dMax, \ - "Last nice date ({}) should be >= dMax {}".format(result[-1], dMax) - - return result, niceSpacing, unit - - -def calcTicksAdaptive(dMin, dMax, axisLength, tickDensity): - """ Calls calcTicks with a variable number of ticks, depending on axisLength - """ - # At least 2 ticks - nticks = max(2, int(round(tickDensity * axisLength))) - return calcTicks(dMin, dMax, nticks) - - - - - diff --git a/silx/gui/plot/_utils/panzoom.py b/silx/gui/plot/_utils/panzoom.py deleted file mode 100644 index 3946a04..0000000 --- a/silx/gui/plot/_utils/panzoom.py +++ /dev/null @@ -1,292 +0,0 @@ -# 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", "V. Valls"] -__license__ = "MIT" -__date__ = "08/08/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.getXAxis().getLimits() - yMin, yMax = plot.getYAxis().getLimits() - - 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.getXAxis()._isLogarithmic()) - - yMin, yMax = scale1DRange(yMin, yMax, dataCenterPos[1], scaleF, - plot.getYAxis()._isLogarithmic()) - - dataPos = plot.pixelToData(cx, cy, axis="right") - assert dataPos is not None - y2Center = dataPos[1] - y2Min, y2Max = plot.getYAxis(axis="right").getLimits() - y2Min, y2Max = scale1DRange(y2Min, y2Max, y2Center, scaleF, - plot.getYAxis()._isLogarithmic()) - - 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_ - - -class _Unset(object): - """To be able to have distinction between None and unset""" - pass - - -class ViewConstraints(object): - """ - Store constraints applied on the view box and compute the resulting view box. - """ - - def __init__(self): - self._min = [None, None] - self._max = [None, None] - self._minRange = [None, None] - self._maxRange = [None, None] - - def update(self, xMin=_Unset, xMax=_Unset, - yMin=_Unset, yMax=_Unset, - minXRange=_Unset, maxXRange=_Unset, - minYRange=_Unset, maxYRange=_Unset): - """ - Update the constraints managed by the object - - The constraints are the same as the ones provided by PyQtGraph. - - :param float xMin: Minimum allowed x-axis value. - (default do not change the stat, None remove the constraint) - :param float xMax: Maximum allowed x-axis value. - (default do not change the stat, None remove the constraint) - :param float yMin: Minimum allowed y-axis value. - (default do not change the stat, None remove the constraint) - :param float yMax: Maximum allowed y-axis value. - (default do not change the stat, None remove the constraint) - :param float minXRange: Minimum allowed left-to-right span across the - view (default do not change the stat, None remove the constraint) - :param float maxXRange: Maximum allowed left-to-right span across the - view (default do not change the stat, None remove the constraint) - :param float minYRange: Minimum allowed top-to-bottom span across the - view (default do not change the stat, None remove the constraint) - :param float maxYRange: Maximum allowed top-to-bottom span across the - view (default do not change the stat, None remove the constraint) - :return: True if the constraints was changed - """ - updated = False - - minRange = [minXRange, minYRange] - maxRange = [maxXRange, maxYRange] - minPos = [xMin, yMin] - maxPos = [xMax, yMax] - - for axis in range(2): - - value = minPos[axis] - if value is not _Unset and value != self._min[axis]: - self._min[axis] = value - updated = True - - value = maxPos[axis] - if value is not _Unset and value != self._max[axis]: - self._max[axis] = value - updated = True - - value = minRange[axis] - if value is not _Unset and value != self._minRange[axis]: - self._minRange[axis] = value - updated = True - - value = maxRange[axis] - if value is not _Unset and value != self._maxRange[axis]: - self._maxRange[axis] = value - updated = True - - # Sanity checks - - for axis in range(2): - if self._maxRange[axis] is not None and self._min[axis] is not None and self._max[axis] is not None: - # max range cannot be larger than bounds - diff = self._max[axis] - self._min[axis] - self._maxRange[axis] = min(self._maxRange[axis], diff) - updated = True - - return updated - - def normalize(self, xMin, xMax, yMin, yMax, allow_scaling=True): - """Normalize a view range defined by x and y corners using predefined - containts. - - :param float xMin: Min position of the x-axis - :param float xMax: Max position of the x-axis - :param float yMin: Min position of the y-axis - :param float yMax: Max position of the y-axis - :param bool allow_scaling: Allow or not to apply scaling for the - normalization. Used according to the interaction mode. - :return: A normalized tuple of (xMin, xMax, yMin, yMax) - """ - viewRange = [[xMin, xMax], [yMin, yMax]] - - for axis in range(2): - # clamp xRange and yRange - if allow_scaling: - diff = viewRange[axis][1] - viewRange[axis][0] - delta = None - if self._maxRange[axis] is not None and diff > self._maxRange[axis]: - delta = self._maxRange[axis] - diff - elif self._minRange[axis] is not None and diff < self._minRange[axis]: - delta = self._minRange[axis] - diff - if delta is not None: - viewRange[axis][0] -= delta * 0.5 - viewRange[axis][1] += delta * 0.5 - - # clamp min and max positions - outMin = self._min[axis] is not None and viewRange[axis][0] < self._min[axis] - outMax = self._max[axis] is not None and viewRange[axis][1] > self._max[axis] - - if outMin and outMax: - if allow_scaling: - # we can clamp both sides - viewRange[axis][0] = self._min[axis] - viewRange[axis][1] = self._max[axis] - else: - # center the result - delta = viewRange[axis][1] - viewRange[axis][0] - mid = self._min[axis] + self._max[axis] - self._min[axis] - viewRange[axis][0] = mid - delta - viewRange[axis][1] = mid + delta - elif outMin: - delta = self._min[axis] - viewRange[axis][0] - viewRange[axis][0] += delta - viewRange[axis][1] += delta - elif outMax: - delta = self._max[axis] - viewRange[axis][1] - viewRange[axis][0] += delta - viewRange[axis][1] += delta - - return viewRange[0][0], viewRange[0][1], viewRange[1][0], viewRange[1][1] diff --git a/silx/gui/plot/_utils/setup.py b/silx/gui/plot/_utils/setup.py deleted file mode 100644 index 0271745..0000000 --- a/silx/gui/plot/_utils/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 624dbcb..0000000 --- a/silx/gui/plot/_utils/test/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ - -from __future__ import absolute_import, division, unicode_literals - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "18/10/2016" - - -import unittest - -from .test_dtime_ticklayout import suite as test_dtime_ticklayout_suite -from .test_ticklayout import suite as test_ticklayout_suite - - -def suite(): - testsuite = unittest.TestSuite() - testsuite.addTest(test_dtime_ticklayout_suite()) - testsuite.addTest(test_ticklayout_suite()) - return testsuite diff --git a/silx/gui/plot/_utils/test/test_dtime_ticklayout.py b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py deleted file mode 100644 index 2b87148..0000000 --- a/silx/gui/plot/_utils/test/test_dtime_ticklayout.py +++ /dev/null @@ -1,93 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ - -from __future__ import absolute_import, division, unicode_literals - -__authors__ = ["P. Kenter"] -__license__ = "MIT" -__date__ = "06/04/2018" - - -import datetime as dt -import unittest - - -from silx.gui.plot._utils.dtime_ticklayout import ( - calcTicks, DtUnit, SECONDS_PER_YEAR) - - -class DtTestTickLayout(unittest.TestCase): - """Test ticks layout algorithms""" - - def testSmallMonthlySpacing(self): - """ Tests a range that did result in a spacing of less than 1 month. - It is impossible to add fractional month so the unit must be in days - """ - from dateutil import parser - d1 = parser.parse("2017-01-03 13:15:06.000044") - d2 = parser.parse("2017-03-08 09:16:16.307584") - _ticks, _units, spacing = calcTicks(d1, d2, nTicks=4) - - self.assertEqual(spacing, DtUnit.DAYS) - - - def testNoCrash(self): - """ Creates many combinations of and number-of-ticks and end-dates; - tests that it doesn't give an exception and returns a reasonable number - of ticks. - """ - d1 = dt.datetime(2017, 1, 3, 13, 15, 6, 44) - - value = 100e-6 # Start at 100 micro sec range. - - while value <= 200 * SECONDS_PER_YEAR: - - d2 = d1 + dt.timedelta(microseconds=value*1e6) # end date range - - for numTicks in range(2, 12): - ticks, _, _ = calcTicks(d1, d2, numTicks) - - margin = 2.5 - self.assertTrue( - numTicks/margin <= len(ticks) <= numTicks*margin, - "Condition {} <= {} <= {} failed for # ticks={} and d2={}:" - .format(numTicks/margin, len(ticks), numTicks * margin, - numTicks, d2)) - - value = value * 1.5 # let date period grow exponentially - - - - - -def suite(): - testsuite = unittest.TestSuite() - testsuite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(DtTestTickLayout)) - return testsuite - - -if __name__ == '__main__': - unittest.main() diff --git a/silx/gui/plot/_utils/test/test_ticklayout.py b/silx/gui/plot/_utils/test/test_ticklayout.py deleted file mode 100644 index 927ffb6..0000000 --- a/silx/gui/plot/_utils/test/test_ticklayout.py +++ /dev/null @@ -1,92 +0,0 @@ -# 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__ = "17/01/2018" - - -import unittest -import numpy - -from silx.utils.testutils import ParametricTestCase - -from silx.gui.plot._utils import ticklayout - - -class TestTickLayout(ParametricTestCase): - """Test ticks layout algorithms""" - - def testTicks(self): - """Test of :func:`ticks`""" - tests = { # (vmin, vmax): ref_ticks - (1., 1.): (1.,), - (0.5, 10.5): (2.0, 4.0, 6.0, 8.0, 10.0), - (0.001, 0.005): (0.001, 0.002, 0.003, 0.004, 0.005) - } - - for (vmin, vmax), ref_ticks in tests.items(): - with self.subTest(vmin=vmin, vmax=vmax): - ticks, labels = ticklayout.ticks(vmin, vmax) - self.assertTrue(numpy.allclose(ticks, ref_ticks)) - - 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 deleted file mode 100644 index c9fd3e6..0000000 --- a/silx/gui/plot/_utils/ticklayout.py +++ /dev/null @@ -1,267 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module 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 ################################################################ - -# This is the original niceNum implementation. For the date time ticks a more -# generic implementation was needed. -# -# 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.: # In niceNumGeneric this is (2+5)/2 = 3.5 -# nicefrac = 2. -# elif frac < 7.: -# nicefrac = 5. # In niceNumGeneric this is (5+10)/2 = 7.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 niceNumGeneric(value, niceFractions=None, isRound=False): - """ A more generic implementation of the _niceNum function - - Allows the user to specify the fractions instead of using a hardcoded - list of [1, 2, 5, 10.0]. - """ - if value == 0: - return value - - if niceFractions is None: # Use default values - niceFractions = 1., 2., 5., 10. - roundFractions = (1.5, 3., 7., 10.) if isRound else niceFractions - - else: - roundFractions = list(niceFractions) - if isRound: - # Take the average with the next element. The last remains the same. - for i in range(len(roundFractions) - 1): - roundFractions[i] = (niceFractions[i] + niceFractions[i+1]) / 2 - - highest = niceFractions[-1] - value = float(value) - - expvalue = math.floor(math.log(value, highest)) - frac = value / pow(highest, expvalue) - - for niceFrac, roundFrac in zip(niceFractions, roundFractions): - if frac <= roundFrac: - return niceFrac * pow(highest, expvalue) - - # should not come here - assert False, "should not come here" - - -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 = niceNumGeneric(vMax - vMin, isRound=False) - spacing = niceNumGeneric(vrange / nTicks, isRound=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 1 tick (when vMin == vMax). - - :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 - """ - assert vMin <= vMax - if vMin == vMax: - positions = [vMin] - nfrac = 0 - - else: - 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/actions/PlotAction.py b/silx/gui/plot/actions/PlotAction.py deleted file mode 100644 index 2983775..0000000 --- a/silx/gui/plot/actions/PlotAction.py +++ /dev/null @@ -1,78 +0,0 @@ -# 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. -# -# ###########################################################################*/ -""" -The class :class:`.PlotAction` help the creation of a qt.QAction associated -with a :class:`.PlotWidget`. -""" - -from __future__ import division - - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__license__ = "MIT" -__date__ = "03/01/2018" - - -import weakref -from silx.gui import icons -from silx.gui import qt - - -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() diff --git a/silx/gui/plot/actions/PlotToolAction.py b/silx/gui/plot/actions/PlotToolAction.py deleted file mode 100644 index 77e8be2..0000000 --- a/silx/gui/plot/actions/PlotToolAction.py +++ /dev/null @@ -1,150 +0,0 @@ -# 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. -# -# ###########################################################################*/ -""" -The class :class:`.PlotToolAction` help the creation of a qt.QAction associating -a tool window with a :class:`.PlotWidget`. -""" - -from __future__ import division - - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__license__ = "MIT" -__date__ = "10/10/2018" - - -import weakref - -from .PlotAction import PlotAction -from silx.gui import qt - - -class PlotToolAction(PlotAction): - """Base class for QAction that maintain a tool window operating on a - PlotWidget.""" - - def __init__(self, plot, icon, text, tooltip=None, - triggered=None, checkable=False, parent=None): - PlotAction.__init__(self, - plot=plot, - icon=icon, - text=text, - tooltip=tooltip, - triggered=self._triggered, - parent=parent, - checkable=True) - self._previousGeometry = None - self._toolWindow = None - - def _triggered(self, checked): - """Update the plot of the histogram visibility status - - :param bool checked: status of the action button - """ - self._setToolWindowVisible(checked) - - def _setToolWindowVisible(self, visible): - """Set the tool window visible or hidden.""" - tool = self._getToolWindow() - if tool.isVisible() == visible: - # Nothing to do - return - - if visible: - self._connectPlot(tool) - tool.show() - if self._previousGeometry is not None: - # Restore the geometry - tool.setGeometry(self._previousGeometry) - else: - self._disconnectPlot(tool) - # Save the geometry - self._previousGeometry = tool.geometry() - tool.hide() - - def _connectPlot(self, window): - """Called if the tool is visible and have to be updated according to - event of the plot. - - :param qt.QWidget window: The tool window - """ - pass - - def _disconnectPlot(self, window): - """Called if the tool is not visible and dont have anymore to be updated - according to event of the plot. - - :param qt.QWidget window: The tool window - """ - pass - - def _isWindowInUse(self): - """Returns true if the tool window is currently in use.""" - if not self.isChecked(): - return False - return self._toolWindow is not None - - def _ownerVisibilityChanged(self, isVisible): - """Called when the visibility of the parent of the tool window changes - - :param bool isVisible: True if the parent became visible - """ - if self._isWindowInUse(): - self._setToolWindowVisible(isVisible) - - 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._toolWindow is not None: - window = self._toolWindow() - self._previousGeometry = window.geometry() - window.hide() - self.setChecked(False) - - return PlotAction.eventFilter(self, qobject, event) - - def _getToolWindow(self): - """Returns the window containg tohe tool. - - It uses lazy loading to create this tool.. - """ - if self._toolWindow is None: - window = self._createToolWindow() - if self._previousGeometry is not None: - window.setGeometry(self._previousGeometry) - window.installEventFilter(self) - plot = self.plot - plot.sigVisibilityChanged.connect(self._ownerVisibilityChanged) - self._toolWindow = weakref.ref(window) - return self._toolWindow() - - def _createToolWindow(self): - """Create the tool window managing the plot.""" - raise NotImplementedError() diff --git a/silx/gui/plot/actions/__init__.py b/silx/gui/plot/actions/__init__.py deleted file mode 100644 index 930c728..0000000 --- a/silx/gui/plot/actions/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This package provides a set of QAction to use with -:class:`~silx.gui.plot.PlotWidget` - -Those actions are useful to add menu items or toolbar items -that interact with a :class:`~silx.gui.plot.PlotWidget`. - -It provides a base class used to define new plot actions: -:class:`~silx.gui.plot.actions.PlotAction`. -""" - -__authors__ = ["H. Payno"] -__license__ = "MIT" -__date__ = "16/08/2017" - -from .PlotAction import PlotAction -from . import control -from . import mode -from . import io diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py deleted file mode 100644 index 10df130..0000000 --- a/silx/gui/plot/actions/control.py +++ /dev/null @@ -1,604 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -:mod:`silx.gui.plot.actions.control` provides a set of QAction relative to control -of a :class:`.PlotWidget`. - -The following QAction are available: - -- :class:`ColormapAction` -- :class:`CrosshairAction` -- :class:`CurveStyleAction` -- :class:`GridAction` -- :class:`KeepAspectRatioAction` -- :class:`PanWithArrowKeysAction` -- :class:`ResetZoomAction` -- :class:`XAxisLogarithmicAction` -- :class:`XAxisAutoScaleAction` -- :class:`YAxisInvertedAction` -- :class:`YAxisLogarithmicAction` -- :class:`YAxisAutoScaleAction` -- :class:`ZoomBackAction` -- :class:`ZoomInAction` -- :class:`ZoomOutAction` -- :class:'ShowAxisAction' -""" - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__license__ = "MIT" -__date__ = "24/04/2018" - -from . import PlotAction -import logging -from silx.gui.plot import items -from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot -from silx.gui import qt -from silx.gui import icons - -_logger = logging.getLogger(__name__) - - -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.getXAxis().sigAutoScaleChanged.connect(self._autoscaleChanged) - plot.getYAxis().sigAutoScaleChanged.connect(self._autoscaleChanged) - - def _autoscaleChanged(self, enabled): - xAxis = self.plot.getXAxis() - yAxis = self.plot.getYAxis() - self.setEnabled(xAxis.isAutoScale() or yAxis.isAutoScale()) - - if xAxis.isAutoScale() and yAxis.isAutoScale(): - tooltip = 'Auto-scale the graph' - elif xAxis.isAutoScale(): # And not Y axis - tooltip = 'Auto-scale the x-axis of the graph only' - elif yAxis.isAutoScale(): # 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 ZoomBackAction(PlotAction): - """QAction performing a zoom-back in :class:`.PlotWidget` limits history. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(ZoomBackAction, self).__init__( - plot, icon='zoom-back', text='Zoom Back', - tooltip='Zoom back the plot', - triggered=self._actionTriggered, - checkable=False, parent=parent) - self.setShortcutContext(qt.Qt.WidgetShortcut) - - def _actionTriggered(self, checked=False): - self.plot.getLimitsHistory().pop() - - -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.getXAxis().isAutoScale()) - plot.getXAxis().sigAutoScaleChanged.connect(self.setChecked) - - def _actionTriggered(self, checked=False): - self.plot.getXAxis().setAutoScale(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.getYAxis().isAutoScale()) - plot.getYAxis().sigAutoScaleChanged.connect(self.setChecked) - - def _actionTriggered(self, checked=False): - self.plot.getYAxis().setAutoScale(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.axis = plot.getXAxis() - self.setChecked(self.axis.getScale() == self.axis.LOGARITHMIC) - self.axis.sigScaleChanged.connect(self._setCheckedIfLogScale) - - def _setCheckedIfLogScale(self, scale): - self.setChecked(scale == self.axis.LOGARITHMIC) - - def _actionTriggered(self, checked=False): - scale = self.axis.LOGARITHMIC if checked else self.axis.LINEAR - self.axis.setScale(scale) - - -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.axis = plot.getYAxis() - self.setChecked(self.axis.getScale() == self.axis.LOGARITHMIC) - self.axis.sigScaleChanged.connect(self._setCheckedIfLogScale) - - def _setCheckedIfLogScale(self, scale): - self.setChecked(scale == self.axis.LOGARITHMIC) - - def _actionTriggered(self, checked=False): - scale = self.axis.LOGARITHMIC if checked else self.axis.LINEAR - self.axis.setScale(scale) - - -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=True, parent=parent) - self.plot.sigActiveImageChanged.connect(self._updateColormap) - self.plot.sigActiveScatterChanged.connect(self._updateColormap) - - def setColorDialog(self, colorDialog): - """Set a specific color dialog instead of using the default dialog.""" - assert(colorDialog is not None) - assert(self._dialog is None) - self._dialog = colorDialog - self._dialog.visibleChanged.connect(self._dialogVisibleChanged) - self.setChecked(self._dialog.isVisible()) - - @staticmethod - def _createDialog(parent): - """Create the dialog if not already existing - - :parent QWidget parent: Parent of the new colormap - :rtype: ColormapDialog - """ - from silx.gui.dialog.ColormapDialog import ColormapDialog - dialog = ColormapDialog(parent=parent) - dialog.setModal(False) - return dialog - - def _actionTriggered(self, checked=False): - """Create a cmap dialog and update active image and default cmap.""" - if self._dialog is None: - self._dialog = self._createDialog(self.plot) - self._dialog.visibleChanged.connect(self._dialogVisibleChanged) - - # Run the dialog listening to colormap change - if checked is True: - self._dialog.show() - self._updateColormap() - else: - self._dialog.hide() - - def _dialogVisibleChanged(self, isVisible): - self.setChecked(isVisible) - - def _updateColormap(self): - if self._dialog is None: - return - image = self.plot.getActiveImage() - - if isinstance(image, items.ImageComplexData): - # Specific init for complex images - colormap = image.getColormap() - - mode = image.getVisualizationMode() - if mode in (items.ImageComplexData.Mode.AMPLITUDE_PHASE, - items.ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE): - data = image.getData( - copy=False, mode=items.ImageComplexData.Mode.PHASE) - else: - data = image.getData(copy=False) - - # Set histogram and range if any - self._dialog.setData(data) - - elif isinstance(image, items.ColormapMixIn): - # Set dialog from active image - colormap = image.getColormap() - data = image.getData(copy=False) - # Set histogram and range if any - self._dialog.setData(data) - - else: - # No active image or active image is RGBA, - # Check for active scatter plot - scatter = self.plot._getActiveItem(kind='scatter') - if scatter is not None: - colormap = scatter.getColormap() - data = scatter.getValueData(copy=False) - self._dialog.setData(data) - - else: - # No active data image nor scatter, - # set dialog from default info - colormap = self.plot.getDefaultColormap() - # Reset histogram and range if any - self._dialog.setData(None) - - self._dialog.setColormap(colormap) - - -class ColorBarAction(PlotAction): - """QAction opening the ColorBarWidget of the specified plot. - - :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 ColorBar - super(ColorBarAction, self).__init__( - plot, icon='colorbar', text='Colorbar', - tooltip="Show/Hide the colorbar", - triggered=self._actionTriggered, - checkable=True, parent=parent) - colorBarWidget = self.plot.getColorBarWidget() - old = self.blockSignals(True) - self.setChecked(colorBarWidget.isVisibleTo(self.plot)) - self.blockSignals(old) - colorBarWidget.sigVisibleChanged.connect(self._widgetVisibleChanged) - - def _widgetVisibleChanged(self, isVisible): - """Callback when the colorbar `visible` property change.""" - if self.isChecked() == isVisible: - return - self.setChecked(isVisible) - - def _actionTriggered(self, checked=False): - """Create a cmap dialog and update active image and default cmap.""" - colorBarWidget = self.plot.getColorBarWidget() - if not colorBarWidget.isHidden() == checked: - return - self.plot.getColorBarWidget().setVisible(checked) - - -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.getYAxis().isInverted()] - super(YAxisInvertedAction, self).__init__( - plot, - icon=icon, - text='Invert Y Axis', - tooltip=tooltip, - triggered=self._actionTriggered, - checkable=False, - parent=parent) - plot.getYAxis().sigInvertedChanged.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 - yAxis = self.plot.getYAxis() - yAxis.setInverted(not yAxis.isInverted()) - - -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) - - -class ShowAxisAction(PlotAction): - """QAction controlling axis visibility on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - tooltip = 'Show plot axis when checked, otherwise hide them' - PlotAction.__init__(self, - plot, - icon='axis', - text='show axis', - tooltip=tooltip, - triggered=self._actionTriggered, - checkable=True, - parent=parent) - self.setChecked(self.plot._backend.isAxesDisplayed()) - plot._sigAxesVisibilityChanged.connect(self.setChecked) - - def _actionTriggered(self, checked=False): - self.plot.setAxesDisplayed(checked) - diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py deleted file mode 100644 index cb70733..0000000 --- a/silx/gui/plot/actions/fit.py +++ /dev/null @@ -1,186 +0,0 @@ -# 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. -# -# ###########################################################################*/ -""" -:mod:`silx.gui.plot.actions.fit` module provides actions relative to fit. - -The following QAction are available: - -- :class:`.FitAction` - -.. autoclass:`.FitAction` -""" - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__license__ = "MIT" -__date__ = "10/10/2018" - -from .PlotToolAction import PlotToolAction -import logging -from silx.gui import qt -from silx.gui.plot.ItemsSelectionDialog import ItemsSelectionDialog -from silx.gui.plot.items import Curve, Histogram - -_logger = logging.getLogger(__name__) - - -def _getUniqueCurve(plt): - """Get a single curve from the plot. - Get the active curve if any, else if a single curve is plotted - get it, else return None. - - :param plt: :class:`.PlotWidget` instance on which to operate - - :return: return value of plt.getActiveCurve(), or plt.getAllCurves()[0], - or None - """ - curve = plt.getActiveCurve() - if curve is not None: - return curve - - curves = plt.getAllCurves() - if len(curves) == 0: - return None - - if len(curves) == 1 and len(plt._getItems(kind='histogram')) == 0: - return curves[0] - - return None - - -def _getUniqueHistogram(plt): - """Return the histogram if there is a single histogram and no curve in - the plot. In all other cases, return None. - - :param plt: :class:`.PlotWidget` instance on which to operate - :return: histogram or None - """ - histograms = plt._getItems(kind='histogram') - if len(histograms) != 1: - return None - if plt.getAllCurves(just_legend=True): - return None - return histograms[0] - - -class FitAction(PlotToolAction): - """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', - parent=parent) - self.fit_widget = None - - def _createToolWindow(self): - window = qt.QMainWindow(parent=self.plot) - # import done here rather than at module level to avoid circular import - # FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget - from ...fit.FitWidget import FitWidget - fit_widget = FitWidget(parent=window) - window.setCentralWidget(fit_widget) - fit_widget.guibuttons.DismissButton.clicked.connect(window.close) - fit_widget.sigFitWidgetSignal.connect(self.handle_signal) - self.fit_widget = fit_widget - return window - - def _connectPlot(self, window): - # Wait for the next iteration, else the plot is not yet initialized - # No curve available - qt.QTimer.singleShot(10, lambda: self._initFit(window)) - - def _initFit(self, window): - plot = self.plot - self.xlabel = plot.getXAxis().getLabel() - self.ylabel = plot.getYAxis().getLabel() - self.xmin, self.xmax = plot.getXAxis().getLimits() - - histo = _getUniqueHistogram(self.plot) - curve = _getUniqueCurve(self.plot) - - if histo is None and curve is None: - # ambiguous case, we need to ask which plot item to fit - isd = ItemsSelectionDialog(parent=plot, plot=self.plot) - isd.setWindowTitle("Select item to be fitted") - isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection) - isd.setAvailableKinds(["curve", "histogram"]) - isd.selectAllKinds() - - result = isd.exec_() - if result and len(isd.getSelectedItems()) == 1: - item = isd.getSelectedItems()[0] - else: - return - elif histo is not None: - # presence of a unique histo and no curve - item = histo - elif curve is not None: - # presence of a unique or active curve - item = curve - - self.legend = item.getLegend() - - if isinstance(item, Histogram): - bin_edges = item.getBinEdgesData(copy=False) - # take the middle coordinate between adjacent bin edges - self.x = (bin_edges[1:] + bin_edges[:-1]) / 2 - self.y = item.getValueData(copy=False) - # else take the active curve, or else the unique curve - elif isinstance(item, Curve): - self.x = item.getXData(copy=False) - self.y = item.getYData(copy=False) - - self.fit_widget.setData(self.x, self.y, - xmin=self.xmin, xmax=self.xmax) - 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) diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py deleted file mode 100644 index 9181f53..0000000 --- a/silx/gui/plot/actions/histogram.py +++ /dev/null @@ -1,146 +0,0 @@ -# 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. -# -# ###########################################################################*/ -""" -:mod:`silx.gui.plot.actions.histogram` provides actions relative to histograms -for :class:`.PlotWidget`. - -The following QAction are available: - -- :class:`PixelIntensitiesHistoAction` -""" - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__date__ = "10/10/2018" -__license__ = "MIT" - -from .PlotToolAction import PlotToolAction -from silx.math.histogram import Histogramnd -from silx.math.combo import min_max -import numpy -import logging -from silx.gui import qt - -_logger = logging.getLogger(__name__) - - -class PixelIntensitiesHistoAction(PlotToolAction): - """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): - PlotToolAction.__init__(self, - plot, - icon='pixel-intensities', - text='pixels intensity', - tooltip='Compute image intensity distribution', - parent=parent) - self._connectedToActiveImage = False - self._histo = None - - def _connectPlot(self, window): - if not self._connectedToActiveImage: - self.plot.sigActiveImageChanged.connect( - self._activeImageChanged) - self._connectedToActiveImage = True - self.computeIntensityDistribution() - PlotToolAction._connectPlot(self, window) - - def _disconnectPlot(self, window): - if self._connectedToActiveImage: - self.plot.sigActiveImageChanged.disconnect( - self._activeImageChanged) - self._connectedToActiveImage = False - PlotToolAction._disconnectPlot(self, window) - - def _activeImageChanged(self, previous, legend): - """Handle active image change: toggle enabled toolbar, update curve""" - if self._isWindowInUse(): - 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, xmax = min_max(image, min_positive=False, finite=True) - 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='#66aad7') - plot.resetZoom() - - def getHistogramPlotWidget(self): - """Create the plot histogram if needed, otherwise create it - - :return: the PlotWidget showing the histogram of the pixel intensities - """ - return self._getToolWindow() - - def _createToolWindow(self): - from silx.gui.plot.PlotWindow import Plot1D - window = Plot1D(parent=self.plot) - window.setWindowFlags(qt.Qt.Window) - window.setWindowTitle('Image Intensity Histogram') - window.getXAxis().setLabel("Value") - window.getYAxis().setLabel("Count") - return window - - def getHistogram(self): - """Return the last computed histogram - - :return: the histogram displayed in the HistogramPlotWiget - """ - return self._histo diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py deleted file mode 100644 index 97de527..0000000 --- a/silx/gui/plot/actions/io.py +++ /dev/null @@ -1,743 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -:mod:`silx.gui.plot.actions.io` provides a set of QAction relative of inputs -and outputs for a :class:`.PlotWidget`. - -The following QAction are available: - -- :class:`CopyAction` -- :class:`PrintAction` -- :class:`SaveAction` -""" - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__license__ = "MIT" -__date__ = "12/07/2018" - -from . import PlotAction -from silx.io.utils import save1D, savespec, NEXUS_HDF5_EXT -from silx.io.nxdata import save_NXdata -import logging -import sys -import os.path -from collections import OrderedDict -import traceback -import numpy -from silx.utils.deprecation import deprecated -from silx.gui import qt, printer -from silx.gui.dialog.GroupDialog import GroupDialog -from silx.third_party.EdfFile import EdfFile -from silx.third_party.TiffIO import TiffIO -from ...utils.image import convertArrayToQImage -if sys.version_info[0] == 3: - from io import BytesIO -else: - import cStringIO as _StringIO - BytesIO = _StringIO.StringIO - -_logger = logging.getLogger(__name__) - -_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT]) - - -def selectOutputGroup(h5filename): - """Open a dialog to prompt the user to select a group in - which to output data. - - :param str h5filename: name of an existing HDF5 file - :rtype: str - :return: Name of output group, or None if the dialog was cancelled - """ - dialog = GroupDialog() - dialog.addFile(h5filename) - dialog.setWindowTitle("Select an output group") - if not dialog.exec_(): - return None - return dialog.getSelectedDataUrl().data_path() - - -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`. - """ - - SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)' - SNAPSHOT_FILTER_PNG = 'Plot Snapshot as PNG (*.png)' - - DEFAULT_ALL_FILTERS = (SNAPSHOT_FILTER_PNG, 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': '%.10g', 'delimiter': '', 'header': False}) - )) - - CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)' - - CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR - - DEFAULT_CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [ - CURVE_FILTER_NPY, CURVE_FILTER_NXDATA] - - DEFAULT_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_NXDATA = 'Image as NXdata (%s)' % _NEXUS_HDF5_EXT_STR - DEFAULT_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_NXDATA) - - SCATTER_FILTER_NXDATA = 'Scatter as NXdata (%s)' % _NEXUS_HDF5_EXT_STR - DEFAULT_SCATTER_FILTERS = (SCATTER_FILTER_NXDATA,) - - # filters for which we don't want an "overwrite existing file" warning - DEFAULT_APPEND_FILTERS = (CURVE_FILTER_NXDATA, IMAGE_FILTER_NXDATA, - SCATTER_FILTER_NXDATA) - - def __init__(self, plot, parent=None): - self._filters = { - 'all': OrderedDict(), - 'curve': OrderedDict(), - 'curves': OrderedDict(), - 'image': OrderedDict(), - 'scatter': OrderedDict()} - - # Initialize filters - for nameFilter in self.DEFAULT_ALL_FILTERS: - self.setFileFilter( - dataKind='all', nameFilter=nameFilter, func=self._saveSnapshot) - - for nameFilter in self.DEFAULT_CURVE_FILTERS: - self.setFileFilter( - dataKind='curve', nameFilter=nameFilter, func=self._saveCurve) - - for nameFilter in self.DEFAULT_ALL_CURVES_FILTERS: - self.setFileFilter( - dataKind='curves', nameFilter=nameFilter, func=self._saveCurves) - - for nameFilter in self.DEFAULT_IMAGE_FILTERS: - self.setFileFilter( - dataKind='image', nameFilter=nameFilter, func=self._saveImage) - - for nameFilter in self.DEFAULT_SCATTER_FILTERS: - self.setFileFilter( - dataKind='scatter', nameFilter=nameFilter, func=self._saveScatter) - - 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, plot, 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_PNG: - fileFormat = 'png' - elif nameFilter == self.SNAPSHOT_FILTER_SVG: - fileFormat = 'svg' - else: # Format not supported - _logger.error( - 'Saving plot snapshot failed: format not supported') - return False - - plot.saveGraph(filename, fileFormat=fileFormat) - return True - - def _getAxesLabels(self, item): - # If curve has no associated label, get the default from the plot - xlabel = item.getXLabel() or self.plot.getXAxis().getLabel() - ylabel = item.getYLabel() or self.plot.getYAxis().getLabel() - return xlabel, ylabel - - def _selectWriteableOutputGroup(self, filename): - if os.path.exists(filename) and os.path.isfile(filename) \ - and os.access(filename, os.W_OK): - entryPath = selectOutputGroup(filename) - if entryPath is None: - _logger.info("Save operation cancelled") - return None - return entryPath - elif not os.path.exists(filename): - # create new entry in new file - return "/entry" - else: - self._errorMessage('Save failed (file access issue)\n') - return None - - def _saveCurveAsNXdata(self, curve, filename): - entryPath = self._selectWriteableOutputGroup(filename) - if entryPath is None: - return False - - xlabel, ylabel = self._getAxesLabels(curve) - - return save_NXdata( - filename, - nxentry_name=entryPath, - signal=curve.getYData(copy=False), - axes=[curve.getXData(copy=False)], - signal_name="y", - axes_names=["x"], - signal_long_name=ylabel, - axes_long_names=[xlabel], - signal_errors=curve.getYErrorData(copy=False), - axes_errors=[curve.getXErrorData(copy=True)], - title=self.plot.getGraphTitle()) - - def _saveCurve(self, plot, 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.DEFAULT_CURVE_FILTERS: - return False - - # Check if a curve is to be saved - curve = 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 = 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 or nxdata - fmt, csvdelim, autoheader = ("", "", False) - - xlabel, ylabel = self._getAxesLabels(curve) - - if nameFilter == self.CURVE_FILTER_NXDATA: - return self._saveCurveAsNXdata(curve, filename) - - 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, plot, 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.DEFAULT_ALL_CURVES_FILTERS: - return False - - curves = plot.getAllCurves() - if not curves: - self._errorMessage("No curves to be saved") - return False - - curve = curves[0] - scanno = 1 - try: - xlabel = curve.getXLabel() or plot.getGraphXLabel() - ylabel = curve.getYLabel() or plot.getGraphYLabel(curve.getYAxis()) - specfile = savespec(filename, - curve.getXData(copy=False), - curve.getYData(copy=False), - xlabel, - ylabel, - 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 - xlabel = curve.getXLabel() or plot.getGraphXLabel() - ylabel = curve.getYLabel() or plot.getGraphYLabel(curve.getYAxis()) - specfile = savespec(specfile, - curve.getXData(copy=False), - curve.getYData(copy=False), - xlabel, - ylabel, - fmt="%.7g", scan_number=scanno, - write_file_header=False, - close_file=False) - except IOError: - self._errorMessage('Save failed\n') - return False - specfile.close() - - return True - - def _saveImage(self, plot, 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.DEFAULT_IMAGE_FILTERS: - return False - - image = plot.getActiveImage() - if image is None: - qt.QMessageBox.warning( - 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 == self.IMAGE_FILTER_NXDATA: - entryPath = self._selectWriteableOutputGroup(filename) - if entryPath is None: - return False - xorigin, yorigin = image.getOrigin() - xscale, yscale = image.getScale() - xaxis = xorigin + xscale * numpy.arange(data.shape[1]) - yaxis = yorigin + yscale * numpy.arange(data.shape[0]) - xlabel, ylabel = self._getAxesLabels(image) - interpretation = "image" if len(data.shape) == 2 else "rgba-image" - - return save_NXdata(filename, - nxentry_name=entryPath, - signal=data, - axes=[yaxis, xaxis], - signal_name="image", - axes_names=["y", "x"], - axes_long_names=[ylabel, xlabel], - title=plot.getGraphTitle(), - interpretation=interpretation) - - 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 == self.IMAGE_FILTER_RGB_PNG: - # Get displayed image - rgbaImage = image.getRgbaImageData(copy=False) - # Convert RGB QImage - qimage = convertArrayToQImage(rgbaImage[:, :, :3]) - - if qimage.save(filename, 'PNG'): - 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 _saveScatter(self, plot, 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.DEFAULT_SCATTER_FILTERS: - return False - - if nameFilter == self.SCATTER_FILTER_NXDATA: - entryPath = self._selectWriteableOutputGroup(filename) - if entryPath is None: - return False - scatter = plot.getScatter() - - x = scatter.getXData(copy=False) - y = scatter.getYData(copy=False) - z = scatter.getValueData(copy=False) - - xerror = scatter.getXErrorData(copy=False) - if isinstance(xerror, float): - xerror = xerror * numpy.ones(x.shape, dtype=numpy.float32) - - yerror = scatter.getYErrorData(copy=False) - if isinstance(yerror, float): - yerror = yerror * numpy.ones(x.shape, dtype=numpy.float32) - - xlabel = plot.getGraphXLabel() - ylabel = plot.getGraphYLabel() - - return save_NXdata( - filename, - nxentry_name=entryPath, - signal=z, - axes=[x, y], - signal_name="values", - axes_names=["x", "y"], - axes_long_names=[xlabel, ylabel], - axes_errors=[xerror, yerror], - title=plot.getGraphTitle()) - - def setFileFilter(self, dataKind, nameFilter, func): - """Set a name filter to add/replace a file format support - - :param str dataKind: - The kind of data for which the provided filter is valid. - One of: 'all', 'curve', 'curves', 'image', 'scatter' - :param str nameFilter: The name filter in the QFileDialog. - See :meth:`QFileDialog.setNameFilters`. - :param callable func: The function to call to perform saving. - Expected signature is: - bool func(PlotWidget plot, str filename, str nameFilter) - """ - assert dataKind in ('all', 'curve', 'curves', 'image', 'scatter') - - self._filters[dataKind][nameFilter] = func - - def getFileFilters(self, dataKind): - """Returns the nameFilter and associated function for a kind of data. - - :param str dataKind: - The kind of data for which the provided filter is valid. - On of: 'all', 'curve', 'curves', 'image', 'scatter' - :return: {nameFilter: function} associations. - :rtype: collections.OrderedDict - """ - assert dataKind in ('all', 'curve', 'curves', 'image', 'scatter') - - return self._filters[dataKind].copy() - - def _actionTriggered(self, checked=False): - """Handle save action.""" - # Set-up filters - filters = OrderedDict() - - # Add image filters if there is an active image - if self.plot.getActiveImage() is not None: - filters.update(self._filters['image'].items()) - - # Add curve filters if there is a curve to save - if (self.plot.getActiveCurve() is not None or - len(self.plot.getAllCurves()) == 1): - filters.update(self._filters['curve'].items()) - if len(self.plot.getAllCurves()) >= 1: - filters.update(self._filters['curves'].items()) - - # Add scatter filters if there is a scatter - # todo: CSV - if self.plot.getScatter() is not None: - filters.update(self._filters['scatter'].items()) - - filters.update(self._filters['all'].items()) - - # Create and run File dialog - dialog = qt.QFileDialog(self.plot) - dialog.setOption(dialog.DontUseNativeDialog) - dialog.setWindowTitle("Output File Selection") - dialog.setModal(1) - dialog.setNameFilters(list(filters.keys())) - - dialog.setFileMode(dialog.AnyFile) - dialog.setAcceptMode(dialog.AcceptSave) - - def onFilterSelection(filt_): - # disable overwrite confirmation for NXdata types, - # because we append the data to existing files - if filt_ in self.DEFAULT_APPEND_FILTERS: - dialog.setOption(dialog.DontConfirmOverwrite) - else: - dialog.setOption(dialog.DontConfirmOverwrite, False) - - dialog.filterSelected.connect(onFilterSelection) - - if not dialog.exec_(): - return False - - nameFilter = dialog.selectedNameFilter() - filename = dialog.selectedFiles()[0] - dialog.close() - - if '(' in nameFilter and ')' == nameFilter.strip()[-1]: - # Check for correct file extension - # Extract file extensions as .something - extensions = [ext[ext.find('.'):] for ext in - nameFilter[nameFilter.find('(')+1:-1].split()] - for ext in extensions: - if (len(filename) > len(ext) and - filename[-len(ext):].lower() == ext.lower()): - break - else: # filename has no extension supported in nameFilter, add one - if len(extensions) >= 1: - filename += extensions[0] - - # Handle save - func = filters.get(nameFilter, None) - if func is not None: - return func(self.plot, filename, nameFilter) - else: - _logger.error('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`. - """ - - 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) - - def getPrinter(self): - """The QPrinter instance used by the PrintAction. - - :rtype: QPrinter - """ - return printer.getDefaultPrinter() - - @property - @deprecated(replacement="getPrinter()", since_version="0.8.0") - def printer(self): - return self.getPrinter() - - 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.getPrinter(), 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.getPrinter()): - return False - - pageRect = self.getPrinter().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.getPrinter(), 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.getPrinter().pageRect().width() / pixmap.width() - yScale = self.getPrinter().pageRect().height() / pixmap.height() - scale = min(xScale, yScale) - - # Draw pixmap with painter - painter = qt.QPainter() - if not painter.begin(self.getPrinter()): - 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) diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py deleted file mode 100644 index 276f970..0000000 --- a/silx/gui/plot/actions/medfilt.py +++ /dev/null @@ -1,147 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -:mod:`silx.gui.plot.actions.medfilt` provides a set of QAction to apply filter -on data contained in a :class:`.PlotWidget`. - -The following QAction are available: - -- :class:`MedianFilterAction` -- :class:`MedianFilter1DAction` -- :class:`MedianFilter2DAction` - -""" - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__license__ = "MIT" - -__date__ = "10/10/2018" - -from .PlotToolAction import PlotToolAction -from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog -from silx.math.medianfilter import medfilt2d -import logging - -_logger = logging.getLogger(__name__) - - -class MedianFilterAction(PlotToolAction): - """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): - PlotToolAction.__init__(self, - plot, - icon='median-filter', - text='median filter', - tooltip='Apply a median filter on the image', - parent=parent) - self._originalImage = None - self._legend = None - self._filteredImage = None - - def _createToolWindow(self): - popup = MedianFilterDialog(parent=self.plot) - popup.sigFilterOptChanged.connect(self._updateFilter) - return popup - - def _connectPlot(self, window): - PlotToolAction._connectPlot(self, window) - self.plot.sigActiveImageChanged.connect(self._updateActiveImage) - self._updateActiveImage() - - def _disconnectPlot(self, window): - PlotToolAction._disconnectPlot(self, window) - self.plot.sigActiveImageChanged.disconnect(self._updateActiveImage) - - 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 NotImplementedError('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/actions/mode.py b/silx/gui/plot/actions/mode.py deleted file mode 100644 index ee05256..0000000 --- a/silx/gui/plot/actions/mode.py +++ /dev/null @@ -1,104 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -:mod:`silx.gui.plot.actions.mode` provides a set of QAction relative to mouse -mode of a :class:`.PlotWidget`. - -The following QAction are available: - -- :class:`ZoomModeAction` -- :class:`PanModeAction` -""" - -from __future__ import division - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "16/08/2017" - -from . import PlotAction -import logging - -_logger = logging.getLogger(__name__) - - -class ZoomModeAction(PlotAction): - """QAction controlling the zoom mode of a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(ZoomModeAction, self).__init__( - plot, icon='zoom', text='Zoom mode', - tooltip='Zoom in or out', - triggered=self._actionTriggered, - checkable=True, parent=parent) - # Listen to mode change - self.plot.sigInteractiveModeChanged.connect(self._modeChanged) - # Init the state - self._modeChanged(None) - - def _modeChanged(self, source): - modeDict = self.plot.getInteractiveMode() - old = self.blockSignals(True) - self.setChecked(modeDict["mode"] == "zoom") - self.blockSignals(old) - - def _actionTriggered(self, checked=False): - plot = self.plot - if plot is not None: - plot.setInteractiveMode('zoom', source=self) - - -class PanModeAction(PlotAction): - """QAction controlling the pan mode of a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(PanModeAction, self).__init__( - plot, icon='pan', text='Pan mode', - tooltip='Pan the view', - triggered=self._actionTriggered, - checkable=True, parent=parent) - # Listen to mode change - self.plot.sigInteractiveModeChanged.connect(self._modeChanged) - # Init the state - self._modeChanged(None) - - def _modeChanged(self, source): - modeDict = self.plot.getInteractiveMode() - old = self.blockSignals(True) - self.setChecked(modeDict["mode"] == "pan") - self.blockSignals(old) - - def _actionTriggered(self, checked=False): - plot = self.plot - if plot is not None: - plot.setInteractiveMode('pan', source=self) diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py deleted file mode 100644 index 7fb8be0..0000000 --- a/silx/gui/plot/backends/BackendBase.py +++ /dev/null @@ -1,548 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -"""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__ = "24/04/2018" - -import weakref -from ... import qt - - -# 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 - self._xAxisTimeZone = None - self._axesDisplayed = True - # Store a weakref to get access to the plot state. - self._setPlot(plot) - - self.__zoomBackAction = None - - @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) - - # Default Qt context menu - - def contextMenuEvent(self, event): - """Override QWidget.contextMenuEvent to implement the context menu""" - if self.__zoomBackAction is None: - from ..actions.control import ZoomBackAction # Avoid cyclic import - self.__zoomBackAction = ZoomBackAction(plot=self._plot, - parent=self._plot) - menu = qt.QMenu(self) - menu.addAction(self.__zoomBackAction) - menu.exec_(event.globalPos()) - - # 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 ~silx.gui.colors.Colormap colormap: Colormap object to use. - Ignored if data is RGB(A). - :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, linestyle, linewidth, constraint): - """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 str linestyle: Style of the line. - Only relevant for line markers where X or Y is None. - Value in: - - - ' ' no line - - '-' solid line - - '--' dashed line - - '-.' dash-dot line - - ':' dotted line - :param float linewidth: Width of the line. - Only relevant for line markers where X or Y is None. - :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: 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, kinds): - """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. - :param List[str] kind: List of item kinds to pick. - Supported kinds: 'marker', 'curve', 'image'. - :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) - - At least "png", "svg" are supported. - - :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 getXAxisTimeZone(self): - """Returns tzinfo that is used if the X-Axis plots date-times. - - None means the datetimes are interpreted as local time. - - :rtype: datetime.tzinfo of None. - """ - return self._xAxisTimeZone - - def setXAxisTimeZone(self, tz): - """Sets tzinfo that is used if the X-Axis plots date-times. - - Use None to let the datetimes be interpreted as local time. - - :rtype: datetime.tzinfo of None. - """ - self._xAxisTimeZone = tz - - def isXAxisTimeSeries(self): - """Return True if the X-axis scale shows datetime objects. - - :rtype: bool - """ - raise NotImplementedError() - - def setXAxisTimeSeries(self, isTimeSeries): - """Set whether the X-axis is a time series - - :param bool flag: True to switch to time series, False for regular axis. - """ - raise NotImplementedError() - - 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() - - def setAxesDisplayed(self, displayed): - """Display or not the axes. - - :param bool displayed: If `True` axes are displayed. If `False` axes - are not anymore visible and the margin used for them is removed. - """ - self._axesDisplayed = displayed - - def isAxesDisplayed(self): - """private because in some case it is possible that one of the two axes - are displayed and not the other. - This only check status set to axes from the public API - """ - return self._axesDisplayed diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py deleted file mode 100644 index 3b1d6dd..0000000 --- a/silx/gui/plot/backends/BackendMatplotlib.py +++ /dev/null @@ -1,1139 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Matplotlib Plot backend.""" - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent, H. Payno"] -__license__ = "MIT" -__date__ = "01/08/2018" - - -import logging -import datetime as dt -import numpy - -from pkg_resources import parse_version as _parse_version - - -_logger = logging.getLogger(__name__) - - -from ... import qt - -# First of all init matplotlib and set its backend -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 matplotlib.ticker import Formatter, ScalarFormatter, Locator - - -from ....third_party.modest_image import ModestImage -from . import BackendBase -from .._utils import FLOAT32_MINPOS -from .._utils.dtime_ticklayout import calcTicks, bestFormatString, timestamp - - - -class NiceDateLocator(Locator): - """ - Matplotlib Locator that uses Nice Numbers algorithm (adapted to dates) - to find the tick locations. This results in the same number behaviour - as when using the silx Open GL backend. - - Expects the data to be posix timestampes (i.e. seconds since 1970) - """ - def __init__(self, numTicks=5, tz=None): - """ - :param numTicks: target number of ticks - :param datetime.tzinfo tz: optional time zone. None is local time. - """ - super(NiceDateLocator, self).__init__() - self.numTicks = numTicks - - self._spacing = None - self._unit = None - self.tz = tz - - @property - def spacing(self): - """ The current spacing. Will be updated when new tick value are made""" - return self._spacing - - @property - def unit(self): - """ The current DtUnit. Will be updated when new tick value are made""" - return self._unit - - def __call__(self): - """Return the locations of the ticks""" - vmin, vmax = self.axis.get_view_interval() - return self.tick_values(vmin, vmax) - - def tick_values(self, vmin, vmax): - """ Calculates tick values - """ - if vmax < vmin: - vmin, vmax = vmax, vmin - - # vmin and vmax should be timestamps (i.e. seconds since 1 Jan 1970) - dtMin = dt.datetime.fromtimestamp(vmin, tz=self.tz) - dtMax = dt.datetime.fromtimestamp(vmax, tz=self.tz) - dtTicks, self._spacing, self._unit = \ - calcTicks(dtMin, dtMax, self.numTicks) - - # Convert datetime back to time stamps. - ticks = [timestamp(dtTick) for dtTick in dtTicks] - return ticks - - - -class NiceAutoDateFormatter(Formatter): - """ - Matplotlib FuncFormatter that is linked to a NiceDateLocator and gives the - best possible formats given the locators current spacing an date unit. - """ - - def __init__(self, locator, tz=None): - """ - :param niceDateLocator: a NiceDateLocator object - :param datetime.tzinfo tz: optional time zone. None is local time. - """ - super(NiceAutoDateFormatter, self).__init__() - self.locator = locator - self.tz = tz - - @property - def formatString(self): - if self.locator.spacing is None or self.locator.unit is None: - # Locator has no spacing or units yet. Return elaborate fmtString - return "Y-%m-%d %H:%M:%S" - else: - return bestFormatString(self.locator.spacing, self.locator.unit) - - - def __call__(self, x, pos=None): - """Return the format for tick val *x* at position *pos* - Expects x to be a POSIX timestamp (seconds since 1 Jan 1970) - """ - dateTime = dt.datetime.fromtimestamp(x, tz=self.tz) - tickStr = dateTime.strftime(self.formatString) - return tickStr - - - - -class _MarkerContainer(Container): - """Marker artists container supporting draw/remove and text position update - - :param artists: - Iterable with either one Line2D or a Line2D and a Text. - The use of an iterable if enforced by Container being - a subclass of tuple that defines a specific __new__. - :param x: X coordinate of the marker (None for horizontal lines) - :param y: Y coordinate of the marker (None for vertical lines) - """ - - def __init__(self, artists, x, y): - self.line = artists[0] - self.text = artists[1] if len(artists) > 1 else None - self.x = x - self.y = y - - Container.__init__(self, artists) - - def draw(self, *args, **kwargs): - """artist-like draw to broadcast draw to line and text""" - self.line.draw(*args, **kwargs) - if self.text is not None: - self.text.draw(*args, **kwargs) - - def updateMarkerText(self, xmin, xmax, ymin, ymax): - """Update marker text position and visibility according to plot limits - - :param xmin: X axis lower limit - :param xmax: X axis upper limit - :param ymin: Y axis lower limit - :param ymax: Y axis upprt limit - """ - if self.text is not None: - visible = ((self.x is None or xmin <= self.x <= xmax) and - (self.y is None or ymin <= self.y <= ymax)) - self.text.set_visible(visible) - - if self.x is not None and self.y is None: # vertical line - delta = abs(ymax - ymin) - if ymin > ymax: - ymax = ymin - ymax -= 0.005 * delta - self.text.set_y(ymax) - - if self.x is None and self.y is not None: # Horizontal line - delta = abs(xmax - xmin) - if xmin > xmax: - xmax = xmin - xmax -= 0.005 * delta - self.text.set_x(xmax) - - -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._axesDisplayed = True - self._matplotlibVersion = _parse_version(matplotlib.__version__) - - 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") - - # disable the use of offsets - try: - self.ax.get_yaxis().get_major_formatter().set_useOffset(False) - self.ax.get_xaxis().get_major_formatter().set_useOffset(False) - self.ax2.get_yaxis().get_major_formatter().set_useOffset(False) - self.ax2.get_xaxis().get_major_formatter().set_useOffset(False) - except: - _logger.warning('Cannot disabled axes offsets in %s ' \ - % matplotlib.__version__) - - # 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 self._matplotlibVersion < _parse_version('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._enableAxis('right', False) - self._isXAxisTimeSeries = 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 (isinstance(yerror, numpy.ndarray) 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**2) - 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 self._matplotlibVersion < _parse_version('1.2.0'): - if (len(data.shape) == 2 and colormap.getName() is None and - colormap.getColormapLUT() is not None): - colors = colormap.getColormapLUT() - 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.getNormalization() == 'linear' and - not colormap.isAutoscale() and - colormap.getVMin() == 0 and - colormap.getVMax() == 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 - - # All image are shown as RGBA image - image = imageClass(self.ax, - label="__IMAGE__" + legend, - interpolation='nearest', - picker=picker, - zorder=z, - 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] - - if self._matplotlibVersion < _parse_version('2.1'): - # matplotlib 1.4.2 do not support float128 - dtype = data.dtype - if dtype.kind == "f" and dtype.itemsize >= 16: - _logger.warning("Your matplotlib version do not support " - "float128. Data converted to float64.") - data = data.astype(numpy.float64) - - if data.ndim == 2: # Data image, convert to RGBA image - data = colormap.applyToData(data) - - 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'): - points = numpy.array((xView, yView)).T - if shape == 'polygon': - closed = True - else: # shape == 'polylines' - closed = numpy.all(numpy.equal(points[0], points[-1])) - item = Polygon(points, - closed=closed, - 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, linestyle, linewidth, constraint): - legend = "__MARKER__" + legend - - textArtist = None - - xmin, xmax = self.getGraphXLimits() - ymin, ymax = self.getGraphYLimits(axis='left') - - 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: - if symbol is None: - valign = 'baseline' - else: - valign = 'top' - text = " " + text - - textArtist = self.ax.text(x, y, text, - color=color, - horizontalalignment='left', - verticalalignment=valign) - - elif x is not None: - line = self.ax.axvline(x, - label=legend, - color=color, - linewidth=linewidth, - linestyle=linestyle) - if text is not None: - # Y position will be updated in updateMarkerText call - textArtist = self.ax.text(x, 1., " " + text, - color=color, - horizontalalignment='left', - verticalalignment='top') - - elif y is not None: - line = self.ax.axhline(y, - label=legend, - color=color, - linewidth=linewidth, - linestyle=linestyle) - - if text is not None: - # X position will be updated in updateMarkerText call - textArtist = self.ax.text(1., 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) - - # All markers are overlays - line.set_animated(True) - if textArtist is not None: - textArtist.set_animated(True) - - artists = [line] if textArtist is None else [line, textArtist] - container = _MarkerContainer(artists, x, y) - container.updateMarkerText(xmin, xmax, ymin, ymax) - self._overlays.add(container) - - return container - - def _updateMarkers(self): - xmin, xmax = self.ax.get_xbound() - ymin, ymax = self.ax.get_ybound() - for item in self._overlays: - if isinstance(item, _MarkerContainer): - item.updateMarkerText(xmin, xmax, ymin, ymax) - - # Remove methods - - def remove(self, item): - # Warning: It also needs to remove extra stuff if added as for markers - self._overlays.discard(item) - try: - item.remove() - except ValueError: - pass # Already removed e.g., in set[X|Y]AxisLogarithmic - - # 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)) - - self._updateMarkers() - - 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)) - self._updateMarkers() - - 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) - - self._updateMarkers() - - # Graph axes - - def setXAxisTimeZone(self, tz): - super(BackendMatplotlib, self).setXAxisTimeZone(tz) - - # Make new formatter and locator with the time zone. - self.setXAxisTimeSeries(self.isXAxisTimeSeries()) - - def isXAxisTimeSeries(self): - return self._isXAxisTimeSeries - - def setXAxisTimeSeries(self, isTimeSeries): - self._isXAxisTimeSeries = isTimeSeries - if self._isXAxisTimeSeries: - # We can't use a matplotlib.dates.DateFormatter because it expects - # the data to be in datetimes. Silx works internally with - # timestamps (floats). - locator = NiceDateLocator(tz=self.getXAxisTimeZone()) - self.ax.xaxis.set_major_locator(locator) - self.ax.xaxis.set_major_formatter( - NiceAutoDateFormatter(locator, tz=self.getXAxisTimeZone())) - else: - try: - scalarFormatter = ScalarFormatter(useOffset=False) - except: - _logger.warning('Cannot disabled axes offsets in %s ' % - matplotlib.__version__) - scalarFormatter = ScalarFormatter() - self.ax.xaxis.set_major_formatter(scalarFormatter) - - def setXAxisLogarithmic(self, flag): - # Workaround for matplotlib 2.1.0 when one tries to set an axis - # to log scale with both limits <= 0 - # In this case a draw with positive limits is needed first - if flag and self._matplotlibVersion >= _parse_version('2.1.0'): - xlim = self.ax.get_xlim() - if xlim[0] <= 0 and xlim[1] <= 0: - self.ax.set_xlim(1, 10) - self.draw() - - self.ax2.set_xscale('log' if flag else 'linear') - self.ax.set_xscale('log' if flag else 'linear') - - def setYAxisLogarithmic(self, flag): - # Workaround for matplotlib 2.0 issue with negative bounds - # before switching to log scale - if flag and self._matplotlibVersion >= _parse_version('2.0.0'): - redraw = False - for axis, dataRangeIndex in ((self.ax, 1), (self.ax2, 2)): - ylim = axis.get_ylim() - if ylim[0] <= 0 or ylim[1] <= 0: - dataRange = self._plot.getDataRange()[dataRangeIndex] - if dataRange is None: - dataRange = 1, 100 # Fallback - axis.set_ylim(*dataRange) - redraw = True - if redraw: - self.draw() - - 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 _mplQtYAxisCoordConversion(self, y): - """Qt origin (top) to/from matplotlib origin (bottom) conversion. - - :rtype: float - """ - height = self.fig.get_window_extent().height - return height - y - - 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 - - # Convert from matplotlib origin (bottom) to Qt origin (top) - yPixel = self._mplQtYAxisCoordConversion(yPixel) - - return xPixel, yPixel - - def pixelToData(self, x, y, axis, check): - ax = self.ax2 if axis == "right" else self.ax - - # Convert from Qt origin (top) to matplotlib origin (bottom) - y = self._mplQtYAxisCoordConversion(y) - - 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() - # Warning this is not returning int... - return (bbox.xmin, - self._mplQtYAxisCoordConversion(bbox.ymax), - bbox.width, - bbox.height) - - def setAxesDisplayed(self, displayed): - """Display or not the axes. - - :param bool displayed: If `True` axes are displayed. If `False` axes - are not anymore visible and the margin used for them is removed. - """ - BackendBase.BackendBase.setAxesDisplayed(self, displayed) - if displayed: - # show axes and viewbox rect - self.ax.set_axis_on() - self.ax2.set_axis_on() - # set the default margins - self.ax.set_position([.15, .15, .75, .75]) - self.ax2.set_position([.15, .15, .75, .75]) - else: - # hide axes and viewbox rect - self.ax.set_axis_off() - self.ax2.set_axis_off() - # remove external margins - self.ax.set_position([0, 0, 1, 1]) - self.ax2.set_position([0, 0, 1, 1]) - self._plot._setDirtyPlot() - - -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): - BackendMatplotlib.__init__(self, plot, parent) - FigureCanvasQTAgg.__init__(self, self.fig) - self.setParent(parent) - - self._limitsBeforeResize = None - - 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 contextMenuEvent(self, event): - """Override QWidget.contextMenuEvent to implement the context menu""" - # Makes sure it is overridden (issue with PySide) - BackendBase.BackendBase.contextMenuEvent(self, event) - - 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, self._mplQtYAxisCoordConversion(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, self._mplQtYAxisCoordConversion(event.y)) - - def _onMouseRelease(self, event): - self._plot.onMouseRelease( - event.x, self._mplQtYAxisCoordConversion(event.y), - self._MPL_TO_PLOT_BUTTONS[event.button]) - - def _onMouseWheel(self, event): - self._plot.onMouseWheel( - event.x, self._mplQtYAxisCoordConversion(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 not isinstance(event.artist, (PathCollection, Line2D)): - _logger.info('Unsupported artist, ignored') - return - - self._picked.append({'kind': 'curve', 'legend': label, - 'indices': event.ind}) - - def pickItems(self, x, y, kinds): - self._picked = [] - - # Weird way to do an explicit picking: Simulate a button press event - mouseEvent = MouseEvent('button_press_event', - self, x, self._mplQtYAxisCoordConversion(y)) - cid = self.mpl_connect('pick_event', self._onPick) - self.fig.pick(mouseEvent) - self.mpl_disconnect(cid) - - picked = [p for p in self._picked if p['kind'] in kinds] - self._picked = None - - return picked - - # replot control - - def resizeEvent(self, event): - # Store current limits - self._limitsBeforeResize = ( - self.ax.get_xbound(), self.ax.get_ybound(), self.ax2.get_ybound()) - - FigureCanvasQTAgg.resizeEvent(self, event) - if self.isKeepDataAspectRatio() or self._overlays or self._graphCursor: - # This is needed with matplotlib 1.5.x and 2.0.x - self._plot._setDirtyPlot() - - def _drawOverlays(self): - """Draw overlays if any.""" - if self._overlays or self._graphCursor: - # There is some overlays or crosshair - - # 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) - - def draw(self): - """Overload draw - - It performs a full redraw (including overlays) of the plot. - It also resets background and emit limits changed signal. - - This is directly called by matplotlib for widget resize. - """ - # Starting with mpl 2.1.0, toggling autoscale raises a ValueError - # in some situations. See #1081, #1136, #1163, - if self._matplotlibVersion >= _parse_version("2.0.0"): - try: - FigureCanvasQTAgg.draw(self) - except ValueError as err: - _logger.debug( - "ValueError caught while calling FigureCanvasQTAgg.draw: " - "'%s'", err) - else: - FigureCanvasQTAgg.draw(self) - - if self._overlays or self._graphCursor: - # Save background - self._background = self.copy_from_bbox(self.fig.bbox) - else: - self._background = None # Reset background - - # Check if limits changed due to a resize of the widget - if self._limitsBeforeResize is not None: - xLimits, yLimits, yRightLimits = self._limitsBeforeResize - self._limitsBeforeResize = None - - if (xLimits != self.ax.get_xbound() or - yLimits != self.ax.get_ybound()): - self._updateMarkers() - - if xLimits != self.ax.get_xbound(): - self._plot.getXAxis()._emitLimitsChanged() - if yLimits != self.ax.get_ybound(): - self._plot.getYAxis(axis='left')._emitLimitsChanged() - if yRightLimits != self.ax2.get_ybound(): - self._plot.getYAxis(axis='right')._emitLimitsChanged() - - self._drawOverlays() - - def replot(self): - BackendMatplotlib.replot(self) - - dirtyFlag = self._plot._getDirtyPlot() - - if dirtyFlag == 'overlay': - # Only redraw overlays using fast rendering path - if self._background is None: - self._background = self.copy_from_bbox(self.fig.bbox) - self.restore_region(self._background) - self._drawOverlays() - self.blit(self.fig.bbox) - - elif dirtyFlag: # Need full redraw - self.draw() - - # Workaround issue of rendering overlays with some matplotlib versions - if (_parse_version('1.5') <= self._matplotlibVersion < _parse_version('2.1') and - not hasattr(self, '_firstReplot')): - self._firstReplot = False - if self._overlays or self._graphCursor: - qt.QTimer.singleShot(0, self.draw) # Request async draw - - # cursor - - _QT_CURSORS = { - 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): - if cursor is None: - FigureCanvasQTAgg.unsetCursor(self) - else: - 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 deleted file mode 100644 index 9e2cb73..0000000 --- a/silx/gui/plot/backends/BackendOpenGL.py +++ /dev/null @@ -1,1725 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -"""OpenGL Plot backend.""" - -from __future__ import division - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "01/08/2018" - -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); - gl_FragColor.a = 1.0; - } - """ - - -# BackendOpenGL ############################################################### - -_current_context = None - - -def _getContext(): - assert _current_context is not None - return _current_context - - -class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): - """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, f=qt.Qt.WindowFlags()): - glu.OpenGLWidget.__init__(self, parent, - alphaBufferSize=8, - depthBufferSize=0, - stencilBufferSize=0, - version=(2, 1), - f=f) - 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._crosshairCursor = None - self._mousePosInPixels = None - - self._markers = OrderedDict() - self._items = OrderedDict() - self._plotContent = PlotDataContent() # For images and curves - 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) - - self.setAutoFillBackground(False) - self.setMouseTracking(True) - - # QWidget - - _MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'} - - def contextMenuEvent(self, event): - """Override QWidget.contextMenuEvent to implement the context menu""" - # Makes sure it is overridden (issue with PySide) - BackendBase.BackendBase.contextMenuEvent(self, event) - - def sizeHint(self): - return qt.QSize(8 * 80, 6 * 80) # Mimic MatplotlibBackend - - def mousePressEvent(self, event): - xPixel = event.x() * self.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - btn = self._MOUSE_BTNS[event.button()] - self._plot.onMousePress(xPixel, yPixel, btn) - event.accept() - - def mouseMoveEvent(self, event): - xPixel = event.x() * self.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - - # 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._mousePosInPixels): - # 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.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - - btn = self._MOUSE_BTNS[event.button()] - self._plot.onMouseRelease(xPixel, yPixel, btn) - event.accept() - - def wheelEvent(self, event): - xPixel = event.x() * self.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - - 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() - - # OpenGLWidget API - - def initializeGL(self): - gl.testGL() - - gl.glClearColor(1., 1., 1., 1.) - gl.glClearStencil(0) - - gl.glEnable(gl.GL_BLEND) - # gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA, - gl.GL_ONE_MINUS_SRC_ALPHA, - gl.GL_ONE, - gl.GL_ONE) - - # 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().astype(numpy.float32)) - - 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) - - # 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:] - - # Render in plot area - gl.glScissor(self._plotFrame.margins.left, - self._plotFrame.margins.bottom, - plotWidth, plotHeight) - gl.glEnable(gl.GL_SCISSOR_TEST) - - gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) - - # Prepare vertical and horizontal markers rendering - self._progBase.use() - gl.glUniformMatrix4fv( - self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, - self.matScreenProj.astype(numpy.float32)) - gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) - 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 ((self._plotFrame.xAxis.isLog and - xCoord is not None and - xCoord <= 0) or - (self._plotFrame.yAxis.isLog and - yCoord is not None and - yCoord <= 0)): - # 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) - - width = self._plotFrame.size[0] - vertices = numpy.array(((0, pixelPos[1]), - (width, pixelPos[1])), - 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) - - height = self._plotFrame.size[1] - vertices = numpy.array(((pixelPos[0], 0), - (pixelPos[0], height)), - 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((pixelPos[0],), dtype=numpy.float64), - numpy.array((pixelPos[1],), dtype=numpy.float64), - marker=marker['symbol'], - markerColor=marker['color'], - markerSize=11) - markerCurve.render(self.matScreenProj, False, False) - 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 crosshair cursor - if 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'], False, False) - 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 crosshair cursor in 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.astype(numpy.float32)) - - 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 - gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) - - self._progBase.use() - gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, - self.matScreenProj.astype(numpy.float32)) - gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) - gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) - - for item in self._items.values(): - if ((isXLog and numpy.min(item['x']) < FLOAT32_MINPOS) or - (isYLog and numpy.min(item['y']) < FLOAT32_MINPOS)): - # Ignore items <= 0. on log axes - continue - - closed = item['shape'] != 'polylines' - points = [self.dataToPixel(x, y, axis='left', check=False) - for (x, y) in zip(item['x'], item['y'])] - shape2D = Shape2D(points, - fill=item['fill'], - fillColor=item['color'], - stroke=True, - strokeColor=item['color'], - strokeClosed=closed) - - 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 = ( - int(self.getDevicePixelRatio() * width), - int(self.getDevicePixelRatio() * height)) - - self.matScreenProj = mat4Ortho(0, self._plotFrame.size[0], - self._plotFrame.size[1], 0, - 1, -1) - - # Store current ranges - previousXRange = self.getGraphXLimits() - previousYRange = self.getGraphYLimits(axis='left') - previousYRightRange = self.getGraphYLimits(axis='right') - - (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \ - self._plotFrame.dataRanges - self.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) - - # If plot range has changed, then emit signal - if previousXRange != self.getGraphXLimits(): - self._plot.getXAxis()._emitLimitsChanged() - if previousYRange != self.getGraphYLimits(axis='left'): - self._plot.getYAxis(axis='left')._emitLimitsChanged() - if previousYRightRange != self.getGraphYLimits(axis='right'): - self._plot.getYAxis(axis='right')._emitLimitsChanged() - - # Add methods - - @staticmethod - def _castArrayTo(v): - """Returns best floating type to cast the array to. - - :param numpy.ndarray v: Array to cast - :rtype: numpy.dtype - :raise ValueError: If dtype is not supported - """ - if numpy.issubdtype(v.dtype, numpy.floating): - return numpy.float32 if v.itemsize <= 4 else numpy.float64 - elif numpy.issubdtype(v.dtype, numpy.integer): - return numpy.float32 if v.itemsize <= 2 else numpy.float64 - else: - raise ValueError('Unsupported data type') - - 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') - - # Convert input data - x = numpy.array(x, copy=False) - y = numpy.array(y, copy=False) - - # Check if float32 is enough - if (self._castArrayTo(x) is numpy.float32 and - self._castArrayTo(y) is numpy.float32): - dtype = numpy.float32 - else: - dtype = numpy.float64 - - x = numpy.array(x, dtype=dtype, copy=False, order='C') - y = numpy.array(y, dtype=dtype, copy=False, order='C') - - # Convert errors to float32 - 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') - - # Handle axes log scale: convert data - - if self._plotFrame.xAxis.isLog: - logX = numpy.log10(x) - - if xerror is not None: - # Transform xerror so that - # log10(x) +/- xerror' = log10(x +/- xerror) - if hasattr(xerror, 'shape') and len(xerror.shape) == 2: - xErrorMinus, xErrorPlus = xerror[0], xerror[1] - else: - xErrorMinus, xErrorPlus = xerror, xerror - xErrorMinus = logX - numpy.log10(x - xErrorMinus) - xErrorPlus = numpy.log10(x + xErrorPlus) - logX - xerror = numpy.array((xErrorMinus, xErrorPlus), - dtype=numpy.float32) - - x = logX - - isYLog = (yaxis == 'left' and self._plotFrame.yAxis.isLog) or ( - yaxis == 'right' and self._plotFrame.y2Axis.isLog) - - if isYLog: - logY = numpy.log10(y) - - if yerror is not None: - # Transform yerror so that - # log10(y) +/- yerror' = log10(y +/- yerror) - if hasattr(yerror, 'shape') and len(yerror.shape) == 2: - yErrorMinus, yErrorPlus = yerror[0], yerror[1] - else: - yErrorMinus, yErrorPlus = yerror, yerror - yErrorMinus = logY - numpy.log10(y - yErrorMinus) - yErrorPlus = numpy.log10(y + yErrorPlus) - logY - yerror = numpy.array((yErrorMinus, yErrorPlus), - dtype=numpy.float32) - - y = logY - - # TODO check if need more filtering of error (e.g., clip to positive) - - # 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, - isYLog=isYLog) - 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.getNormalization() == 'log' - cmapRange = colormap.getColormapRange(data=data) - colormapLut = colormap.getNColors(nbColors=256) - - 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) - - if numpy.issubdtype(data.dtype, numpy.floating): - data = numpy.array(data, dtype=numpy.float32, copy=False) - elif numpy.issubdtype(data.dtype, numpy.integer): - data = numpy.array(data, dtype=numpy.uint8, copy=False) - else: - raise ValueError('Unsupported data type') - - 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', 'polylines'): - 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') - - # Ignore fill for polylines to mimic matplotlib - fill = fill if shape != 'polylines' else False - - 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, linestyle, linewidth, constraint): - - if symbol is None: - symbol = '+' - - if linestyle != '-' or linewidth != 1: - _logger.warning( - 'OpenGL backend does not support marker line style and width.') - - 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) - - 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 = { - 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): - if cursor is None: - super(BackendOpenGL, self).unsetCursor() - else: - 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, kinds): - picked = [] - - dataPos = self.pixelToData(x, y, axis='left', check=True) - if dataPos is not None: - # Pick markers - if 'marker' in kinds: - 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 - if 'image' in kinds or 'curve' in kinds: - for item in self._plotContent.zOrderedPrimitives(reverse=True): - if ('image' in kinds and - isinstance(item, (GLPlotColormap, GLPlotRGBAImage))): - pickedPos = item.pick(*dataPos) - if pickedPos is not None: - picked.append(dict(kind='image', - legend=item.info['legend'])) - - elif 'curve' in kinds and 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 - - # Apply log scale if axis is log - if self._plotFrame.xAxis.isLog: - xPickMin = numpy.log10(xPickMin) - xPickMax = numpy.log10(xPickMax) - - if (yAxis == 'left' and self._plotFrame.yAxis.isLog) or ( - yAxis == 'right' and self._plotFrame.y2Axis.isLog): - yPickMin = numpy.log10(yPickMin) - yPickMax = numpy.log10(yPickMax) - - pickedIndices = item.pick(xPickMin, yPickMin, - xPickMax, yPickMax) - if pickedIndices: - picked.append(dict(kind='curve', - legend=item.info['legend'], - indices=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) - - if not self.isValid(): - _logger.error('OpenGL 2.1 not available, cannot save OpenGL image') - width, height = self._plotFrame.size - data = numpy.zeros((height, width, 3), dtype=numpy.uint8) - else: - self.makeCurrent() - - data = numpy.empty( - (self._plotFrame.size[1], self._plotFrame.size[0], 3), - dtype=numpy.uint8, order='C') - - context = self.context() - framebufferTexture = self._plotFBOs.get(context) - if framebufferTexture is None: - # Fallback, supports direct rendering mode: _paintDirectGL - # might have issues as it can read on-screen framebuffer - fboName = self.defaultFramebufferObject() - width, height = self._plotFrame.size - else: - fboName = framebufferTexture.name - height, width = framebufferTexture.shape - - previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING) - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fboName) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - gl.glReadPixels(0, 0, width, height, - gl.GL_RGB, gl.GL_UNSIGNED_BYTE, data) - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, previousFramebuffer) - - # 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 getXAxisTimeZone(self): - return self._plotFrame.xAxis.timeZone - - def setXAxisTimeZone(self, tz): - self._plotFrame.xAxis.timeZone = tz - - def isXAxisTimeSeries(self): - return self._plotFrame.xAxis.isTimeSeries - - def setXAxisTimeSeries(self, isTimeSeries): - self._plotFrame.xAxis.isTimeSeries = isTimeSeries - - 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 - - def setAxesDisplayed(self, displayed): - BackendBase.BackendBase.setAxesDisplayed(self, displayed) - self._plotFrame.displayed = displayed diff --git a/silx/gui/plot/backends/__init__.py b/silx/gui/plot/backends/__init__.py deleted file mode 100644 index 966d9df..0000000 --- a/silx/gui/plot/backends/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py deleted file mode 100644 index 12b6bbe..0000000 --- a/silx/gui/plot/backends/glutils/GLPlotCurve.py +++ /dev/null @@ -1,1151 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -""" -This module provides classes to render 2D lines and scatter plots -""" - -from __future__ import division - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "03/04/2017" - - -import math -import logging -import warnings - -import numpy - -from silx.math.combo import min_max - -from ...._glutils import gl -from ...._glutils import Program, vertexBuffer -from .GLSupport import buildFillMaskIndices, mat4Identity, mat4Translate - - -_logger = logging.getLogger(__name__) - - -_MPL_NONES = None, 'None', '', ' ' -"""Possible values for None""" - - -def _notNaNSlices(array, length=1): - """Returns slices of none NaN values in the array. - - :param numpy.ndarray array: 1D array from which to get slices - :param int length: Slices shorter than length gets discarded - :return: Array of (start, end) slice indices - :rtype: numpy.ndarray - """ - isnan = numpy.isnan(numpy.array(array, copy=False).reshape(-1)) - notnan = numpy.logical_not(isnan) - start = numpy.where(numpy.logical_and(isnan[:-1], notnan[1:]))[0] + 1 - if notnan[0]: - start = numpy.append(0, start) - end = numpy.where(numpy.logical_and(notnan[:-1], isnan[1:]))[0] + 1 - if notnan[-1]: - end = numpy.append(end, len(array)) - slices = numpy.transpose((start, end)) - if length > 1: - # discard slices with less than length values - slices = slices[numpy.diff(slices, axis=1).ravel() >= length] - return slices - - -# fill ######################################################################## - -class _Fill2D(object): - """Object rendering curve filling as polygons - - :param numpy.ndarray xData: X coordinates of points - :param numpy.ndarray yData: Y coordinates of points - :param float baseline: Y value of the 'bottom' of the fill. - 0 for linear Y scale, -38 for log Y scale - :param List[float] color: RGBA color as 4 float in [0, 1] - :param List[float] offset: Translation of coordinates (ox, oy) - """ - - _PROGRAM = Program( - vertexShader=""" - #version 120 - - uniform mat4 matrix; - attribute float xPos; - attribute float yPos; - - void main(void) { - gl_Position = matrix * vec4(xPos, yPos, 0.0, 1.0); - } - """, - fragmentShader=""" - #version 120 - - uniform vec4 color; - - void main(void) { - gl_FragColor = color; - } - """, - attrib0='xPos') - - def __init__(self, xData=None, yData=None, - baseline=0, - color=(0., 0., 0., 1.), - offset=(0., 0.)): - self.xData = xData - self.yData = yData - self._xFillVboData = None - self._yFillVboData = None - self.color = color - self.offset = offset - - # Offset baseline - self.baseline = baseline - self.offset[1] - - def prepare(self): - """Rendering preparation: build indices and bounding box vertices""" - if (self._xFillVboData is None and - self.xData is not None and self.yData is not None): - - # Get slices of not NaN values longer than 1 element - isnan = numpy.logical_or(numpy.isnan(self.xData), - numpy.isnan(self.yData)) - notnan = numpy.logical_not(isnan) - start = numpy.where(numpy.logical_and(isnan[:-1], notnan[1:]))[0] + 1 - if notnan[0]: - start = numpy.append(0, start) - end = numpy.where(numpy.logical_and(notnan[:-1], isnan[1:]))[0] + 1 - if notnan[-1]: - end = numpy.append(end, len(isnan)) - slices = numpy.transpose((start, end)) - # discard slices with less than length values - slices = slices[numpy.diff(slices, axis=1).reshape(-1) >= 2] - - # Number of points: slice + 2 * leading and trailing points - # Twice leading and trailing points to produce degenerated triangles - nbPoints = numpy.sum(numpy.diff(slices, axis=1)) + 4 * len(slices) - points = numpy.empty((nbPoints, 2), dtype=numpy.float32) - - offset = 0 - for start, end in slices: - # Duplicate first point for connecting degenerated triangle - points[offset:offset+2] = self.xData[start], self.baseline - - # 2nd point of the polygon is last point - points[offset+2] = self.xData[end-1], self.baseline - - # Add all points from the data - indices = start + buildFillMaskIndices(end - start) - - points[offset+3:offset+3+len(indices), 0] = self.xData[indices] - points[offset+3:offset+3+len(indices), 1] = self.yData[indices] - - # Duplicate last point for connecting degenerated triangle - points[offset+3+len(indices)] = points[offset+3+len(indices)-1] - - offset += len(indices) + 4 - - self._xFillVboData, self._yFillVboData = vertexBuffer(points.T) - - def render(self, matrix): - """Perform rendering - - :param numpy.ndarray matrix: 4x4 transform matrix to use - """ - self.prepare() - - if self._xFillVboData is None: - return # Nothing to display - - self._PROGRAM.use() - - gl.glUniformMatrix4fv( - self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, - numpy.dot(matrix, - mat4Translate(*self.offset)).astype(numpy.float32)) - - gl.glUniform4f(self._PROGRAM.uniforms['color'], *self.color) - - xPosAttrib = self._PROGRAM.attributes['xPos'] - yPosAttrib = self._PROGRAM.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.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, self._xFillVboData.size) - - 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) - - # Draw directly in NDC - gl.glUniformMatrix4fv(self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, - mat4Identity().astype(numpy.float32)) - - # NDC vertices - gl.glVertexAttribPointer( - xPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, - numpy.array((-1., -1., 1., 1.), dtype=numpy.float32)) - gl.glVertexAttribPointer( - yPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, - numpy.array((-1., 1., -1., 1.), dtype=numpy.float32)) - - gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) - - gl.glDisable(gl.GL_STENCIL_TEST) - - def discard(self): - """Release VBOs""" - if self._xFillVboData is not None: - self._xFillVboData.vbo.discard() - - self._xFillVboData = None - self._yFillVboData = None - - -# line ######################################################################## - -SOLID, DASHED, DASHDOT, DOTTED = '-', '--', '-.', ':' - - -class _Lines2D(object): - """Object rendering curve as a polyline - - :param xVboData: X coordinates VBO - :param yVboData: Y coordinates VBO - :param colorVboData: VBO of colors - :param distVboData: VBO of distance along the polyline - :param str style: Line style in: '-', '--', '-.', ':' - :param List[float] color: RGBA color as 4 float in [0, 1] - :param float width: Line width - :param float dashPeriod: Period of dashes - :param drawMode: OpenGL drawing mode - :param List[float] offset: Translation of coordinates (ox, oy) - """ - - STYLES = SOLID, DASHED, DASHDOT, DOTTED - """Supported line styles""" - - _SOLID_PROGRAM = Program( - vertexShader=""" - #version 120 - - uniform mat4 matrix; - attribute float xPos; - attribute float yPos; - attribute vec4 color; - - varying vec4 vColor; - - void main(void) { - gl_Position = matrix * vec4(xPos, yPos, 0., 1.) ; - vColor = color; - } - """, - fragmentShader=""" - #version 120 - - varying vec4 vColor; - - void main(void) { - gl_FragColor = vColor; - } - """, - attrib0='xPos') - - # 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 - _DASH_PROGRAM = Program( - vertexShader=""" - #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; - - void main(void) { - gl_Position = matrix * vec4(xPos, yPos, 0., 1.); - //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; - } - """, - fragmentShader=""" - #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; - } - """, - attrib0='xPos') - - def __init__(self, xVboData=None, yVboData=None, - colorVboData=None, distVboData=None, - style=SOLID, color=(0., 0., 0., 1.), - width=1, dashPeriod=20, drawMode=None, - offset=(0., 0.)): - self.xVboData = xVboData - self.yVboData = yVboData - self.distVboData = distVboData - self.colorVboData = colorVboData - self.useColorVboData = colorVboData is not None - - self.color = color - self.width = width - self._style = None - self.style = style - self.dashPeriod = dashPeriod - self.offset = offset - - self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP - - @property - def style(self): - """Line style (Union[str,None])""" - return self._style - - @style.setter - def style(self, style): - if style in _MPL_NONES: - self._style = None - else: - assert style in self.STYLES - self._style = style - - @classmethod - def init(cls): - """OpenGL context initialization""" - gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) - - def render(self, matrix): - """Perform rendering - - :param numpy.ndarray matrix: 4x4 transform matrix to use - """ - style = self.style - if style is None: - return - - elif style == SOLID: - program = self._SOLID_PROGRAM - program.use() - - else: # DASHED, DASHDOT, DOTTED - program = self._DASH_PROGRAM - program.use() - - x, y, viewWidth, viewHeight = gl.glGetFloatv(gl.GL_VIEWPORT) - gl.glUniform2f(program.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(program.uniforms['dash'], *dash) - - distAttrib = program.attributes['distance'] - gl.glEnableVertexAttribArray(distAttrib) - self.distVboData.setVertexAttrib(distAttrib) - - gl.glEnable(gl.GL_LINE_SMOOTH) - - matrix = numpy.dot(matrix, - mat4Translate(*self.offset)).astype(numpy.float32) - gl.glUniformMatrix4fv(program.uniforms['matrix'], - 1, gl.GL_TRUE, matrix) - - colorAttrib = program.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 = program.attributes['xPos'] - gl.glEnableVertexAttribArray(xPosAttrib) - self.xVboData.setVertexAttrib(xPosAttrib) - - yPosAttrib = program.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): - """Returns distances between each points - - :param numpy.ndarray xData: X coordinate of points - :param numpy.ndarray yData: Y coordinate of points - :rtype: numpy.ndarray - """ - 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): - """Object rendering curve markers - - :param xVboData: X coordinates VBO - :param yVboData: Y coordinates VBO - :param colorVboData: VBO of colors - :param str marker: Kind of symbol to use, see :attr:`MARKERS`. - :param List[float] color: RGBA color as 4 float in [0, 1] - :param float size: Marker size - :param List[float] offset: Translation of coordinates (ox, oy) - """ - - MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK, - H_LINE, V_LINE) - """List of supported markers""" - - _VERTEX_SHADER = """ - #version 120 - - uniform mat4 matrix; - uniform int transform; - uniform float size; - attribute float xPos; - attribute float yPos; - attribute vec4 color; - - varying vec4 vColor; - - void main(void) { - gl_Position = matrix * vec4(xPos, yPos, 0., 1.); - vColor = color; - gl_PointSize = size; - } - """ - - _FRAGMENT_SHADER_SYMBOLS = { - 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 circle */ - 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_SHADER_TEMPLATE = """ - #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, - offset=(0., 0.)): - self.color = color - self._marker = None - self.marker = marker - self.size = size - self.offset = offset - - self.xVboData = xVboData - self.yVboData = yVboData - self.colorVboData = colorVboData - self.useColorVboData = colorVboData is not None - - @property - def marker(self): - """Symbol used to display markers (str)""" - return self._marker - - @marker.setter - def marker(self, marker): - if marker in _MPL_NONES: - self._marker = None - else: - assert marker in self.MARKERS - self._marker = marker - - @classmethod - def _getProgram(cls, marker): - """On-demand shader program creation.""" - if marker == PIXEL: - marker = SQUARE - elif marker == POINT: - marker = CIRCLE - - if marker not in cls._PROGRAMS: - cls._PROGRAMS[marker] = Program( - vertexShader=cls._VERTEX_SHADER, - fragmentShader=(cls._FRAGMENT_SHADER_TEMPLATE % - cls._FRAGMENT_SHADER_SYMBOLS[marker]), - attrib0='xPos') - - return cls._PROGRAMS[marker] - - @classmethod - def init(cls): - """OpenGL context initialization""" - 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 render(self, matrix): - """Perform rendering - - :param numpy.ndarray matrix: 4x4 transform matrix to use - """ - if self.marker is None: - return - - program = self._getProgram(self.marker) - program.use() - - matrix = numpy.dot(matrix, - mat4Translate(*self.offset)).astype(numpy.float32) - gl.glUniformMatrix4fv(program.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(program.uniforms['size'], size) - # gl.glPointSize(self.size) - - cAttrib = program.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 = program.attributes['xPos'] - gl.glEnableVertexAttribArray(xAttrib) - self.xVboData.setVertexAttrib(xAttrib) - - yAttrib = program.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. - - It uses 2 vertices per error bars and uses :class:`_Lines2D` to - render error bars and :class:`_Points2D` to render the ends. - - :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 List[float] color: RGBA color as 4 float in [0, 1] - :param List[float] offset: Translation of coordinates (ox, oy) - """ - - def __init__(self, xData, yData, xError, yError, - xMin, yMin, - color=(0., 0., 0., 1.), - offset=(0., 0.)): - self._attribs = None - self._xMin, self._yMin = xMin, yMin - self.offset = offset - - if xError is not None or yError is not None: - 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, offset=offset) - self._xErrPoints = _Points2D( - None, None, color=color, marker=V_LINE, offset=offset) - self._yErrPoints = _Points2D( - None, None, color=color, marker=H_LINE, offset=offset) - - def _buildVertices(self): - """Generates error bars vertices""" - nbLinesPerDataPts = (0 if self._xError is None else 2) + \ - (0 if self._yError is None else 2) - - nbDataPts = len(self._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 self._xError is not None: # errors on the X axis - if len(self._xError.shape) == 2: - xErrorMinus, xErrorPlus = self._xError[0], self._xError[1] - else: - # numpy arrays of len 1 or len(xData) - xErrorMinus, xErrorPlus = self._xError, self._xError - - # Interleave vertices for xError - endXError = 4 * nbDataPts - xCoords[0:endXError-3:4] = self._xData + xErrorPlus - xCoords[1:endXError-2:4] = self._xData - xCoords[2:endXError-1:4] = self._xData - xCoords[3:endXError:4] = self._xData - xErrorMinus - - yCoords[0:endXError-3:4] = self._yData - yCoords[1:endXError-2:4] = self._yData - yCoords[2:endXError-1:4] = self._yData - yCoords[3:endXError:4] = self._yData - - else: - endXError = 0 - - if self._yError is not None: # errors on the Y axis - if len(self._yError.shape) == 2: - yErrorMinus, yErrorPlus = self._yError[0], self._yError[1] - else: - # numpy arrays of len 1 or len(yData) - yErrorMinus, yErrorPlus = self._yError, self._yError - - # Interleave vertices for yError - xCoords[endXError::4] = self._xData - xCoords[endXError+1::4] = self._xData - xCoords[endXError+2::4] = self._xData - xCoords[endXError+3::4] = self._xData - - yCoords[endXError::4] = self._yData + yErrorPlus - yCoords[endXError+1::4] = self._yData - yCoords[endXError+2::4] = self._yData - yCoords[endXError+3::4] = self._yData - yErrorMinus - - return xCoords, yCoords - - def prepare(self): - """Rendering preparation: build indices and bounding box vertices""" - if self._xData is None: - return - - if self._attribs is None: - xCoords, yCoords = self._buildVertices() - - xAttrib, yAttrib = vertexBuffer((xCoords, yCoords)) - self._attribs = xAttrib, yAttrib - - self._lines.xVboData = xAttrib - self._lines.yVboData = 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): - """Perform rendering - - :param numpy.ndarray matrix: 4x4 transform matrix to use - """ - self.prepare() - - if self._attribs is not None: - self._lines.render(matrix) - self._xErrPoints.render(matrix) - self._yErrPoints.render(matrix) - - def discard(self): - """Release VBOs""" - 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=SOLID, - lineColor=(0., 0., 0., 1.), - lineWidth=1, - lineDashPeriod=20, - marker=SQUARE, - markerColor=(0., 0., 0., 1.), - markerSize=7, - fillColor=None, - isYLog=False): - - self.colorData = colorData - - # Compute x bounds - if xError is None: - self.xMin, self.xMax = min_max(xData, min_positive=False) - else: - # Takes the error into account - if hasattr(xError, 'shape') and len(xError.shape) == 2: - xErrorMinus, xErrorPlus = xError[0], xError[1] - else: - xErrorMinus, xErrorPlus = xError, xError - self.xMin = numpy.nanmin(xData - xErrorMinus) - self.xMax = numpy.nanmax(xData + xErrorPlus) - - # Compute y bounds - if yError is None: - self.yMin, self.yMax = min_max(yData, min_positive=False) - else: - # Takes the error into account - if hasattr(yError, 'shape') and len(yError.shape) == 2: - yErrorMinus, yErrorPlus = yError[0], yError[1] - else: - yErrorMinus, yErrorPlus = yError, yError - self.yMin = numpy.nanmin(yData - yErrorMinus) - self.yMax = numpy.nanmax(yData + yErrorPlus) - - # Handle data offset - if xData.itemsize > 4 or yData.itemsize > 4: # Use normalization - # offset data, do not offset error as it is relative - self.offset = self.xMin, self.yMin - self.xData = (xData - self.offset[0]).astype(numpy.float32) - self.yData = (yData - self.offset[1]).astype(numpy.float32) - - else: # float32 - self.offset = 0., 0. - self.xData = xData - self.yData = yData - - if fillColor is not None: - # Use different baseline depending of Y log scale - self.fill = _Fill2D(self.xData, self.yData, - baseline=-38 if isYLog else 0, - color=fillColor, - offset=self.offset) - else: - self.fill = None - - self._errorBars = _ErrorBars(self.xData, self.yData, - xError, yError, - self.xMin, self.yMin, - offset=self.offset) - - self.lines = _Lines2D() - self.lines.style = lineStyle - self.lines.color = lineColor - self.lines.width = lineWidth - self.lines.dashPeriod = lineDashPeriod - self.lines.offset = self.offset - - self.points = _Points2D() - self.points.marker = marker - self.points.color = markerColor - self.points.size = markerSize - self.points.offset = self.offset - - 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): - """OpenGL context initialization""" - _Lines2D.init() - _Points2D.init() - - def prepare(self): - """Rendering preparation: build indices and bounding box vertices""" - if self.xVboData is None: - xAttrib, yAttrib, cAttrib, dAttrib = None, None, None, None - if self.lineStyle in (DASHED, DASHDOT, DOTTED): - dists = _distancesFromArrays(self.xData, self.yData) - if self.colorData is None: - xAttrib, yAttrib, dAttrib = vertexBuffer( - (self.xData, self.yData, dists)) - else: - xAttrib, yAttrib, cAttrib, dAttrib = vertexBuffer( - (self.xData, self.yData, self.colorData, dists)) - elif self.colorData is None: - xAttrib, yAttrib = vertexBuffer((self.xData, self.yData)) - else: - xAttrib, yAttrib, cAttrib = vertexBuffer( - (self.xData, self.yData, self.colorData)) - - self.xVboData = xAttrib - self.yVboData = yAttrib - self.distVboData = dAttrib - - if cAttrib is not None and self.colorData.dtype.kind == 'u': - cAttrib.normalization = True # Normalize uint to [0, 1] - self.colorVboData = cAttrib - self.useColorVboData = cAttrib is not None - - def render(self, matrix, isXLog, isYLog): - """Perform rendering - - :param numpy.ndarray matrix: 4x4 transform matrix to use - :param bool isXLog: - :param bool isYLog: - """ - self.prepare() - if self.fill is not None: - self.fill.render(matrix) - self._errorBars.render(matrix) - self.lines.render(matrix) - self.points.render(matrix) - - def discard(self): - """Release VBOs""" - 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() - if self.fill is not None: - self.fill.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: - return None - - # offset picking bounds - xPickMin = xPickMin - self.offset[0] - xPickMax = xPickMax - self.offset[0] - yPickMin = yPickMin - self.offset[1] - yPickMax = yPickMax - self.offset[1] - - if self.lineStyle is not None: - # Using Cohen-Sutherland algorithm for line clipping - with warnings.catch_warnings(): # Ignore NaN comparison warnings - warnings.simplefilter('ignore', category=RuntimeWarning) - codes = ((self.yData > yPickMax) << 3) | \ - ((self.yData < yPickMin) << 2) | \ - ((self.xData > xPickMax) << 1) | \ - (self.xData < xPickMin) - - notNaN = numpy.logical_not(numpy.logical_or( - numpy.isnan(self.xData), numpy.isnan(self.yData))) - - # Add all points that are inside the picking area - indices = numpy.nonzero( - numpy.logical_and(codes == 0, notNaN))[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: - with warnings.catch_warnings(): # Ignore NaN comparison warnings - warnings.simplefilter('ignore', category=RuntimeWarning) - 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 deleted file mode 100644 index 4ad1547..0000000 --- a/silx/gui/plot/backends/glutils/GLPlotFrame.py +++ /dev/null @@ -1,1116 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -""" -This 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 datetime as dt -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 -from ..._utils.dtime_ticklayout import calcTicksAdaptive, bestFormatString -from ..._utils.dtime_ticklayout import timestamp - -_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._isDateTime = False - self._timeZone = None - 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 timeZone(self): - """Returnss datetime.tzinfo that is used if this axis plots date times.""" - return self._timeZone - - @timeZone.setter - def timeZone(self, tz): - """Sets dateetime.tzinfo that is used if this axis plots date times.""" - self._timeZone = tz - self._dirtyTicks() - - @property - def isTimeSeries(self): - """Whether the axis is showing floats as datetime objects""" - return self._isDateTime - - @isTimeSeries.setter - def isTimeSeries(self, isTimeSeries): - isTimeSeries = bool(isTimeSeries) - if isTimeSeries != self._isDateTime: - self._isDateTime = isTimeSeries - 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: - - if self.isTimeSeries: - _logger.warning("Time series not implemented for log-scale") - - 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 - tickDensity = 1.3 / 92 - - if not self.isTimeSeries: - tickMin, tickMax, step, nbFrac = niceNumbersAdaptative( - dataMin, dataMax, nbPixels, tickDensity) - - 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) - else: - # Time series - dtMin = dt.datetime.fromtimestamp(dataMin, tz=self.timeZone) - dtMax = dt.datetime.fromtimestamp(dataMax, tz=self.timeZone) - - tickDateTimes, spacing, unit = calcTicksAdaptive( - dtMin, dtMax, nbPixels, tickDensity) - - for tickDateTime in tickDateTimes: - if dtMin <= tickDateTime <= dtMax: - - dataPos = timestamp(tickDateTime) - xPixel = x0 + (dataPos - dataMin) * xScale - yPixel = y0 + (dataPos - dataMin) * yScale - - fmtStr = bestFormatString(spacing, unit) - text = tickDateTime.strftime(fmtStr) - - 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')) - - # Margins used when plot frame is not displayed - _NoDisplayMargins = _Margins(0, 0, 0, 0) - - 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 = '' - self._displayed = True - - @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 displayed(self): - """Whether axes and their labels are displayed or not (bool)""" - return self._displayed - - @displayed.setter - def displayed(self, displayed): - displayed = bool(displayed) - if displayed != self._displayed: - self._displayed = displayed - self._dirty() - - @property - def margins(self): - """Margins in pixels around the plot.""" - if not self.displayed: - return self._NoDisplayMargins - else: - 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 not self.displayed: - 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.astype(numpy.float32)) - 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.astype(numpy.float32)) - 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 = numpy.dot(mat, numpy.array(( - (xx, yx, 0., 0.), - (xy, yy, 0., 0.), - (0., 0., 1., 0.), - (0., 0., 0., 1.)), dtype=numpy.float64)) - - 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 = numpy.dot(mat, numpy.matrix(( - (xx, yx, 0., 0.), - (xy, yy, 0., 0.), - (0., 0., 1., 0.), - (0., 0., 0., 1.)), dtype=numpy.float64)) - - 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 deleted file mode 100644 index 6f3c487..0000000 --- a/silx/gui/plot/backends/glutils/GLPlotImage.py +++ /dev/null @@ -1,674 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -""" -This module provides a 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 = (1., 10.) # Colormap 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.cmapIsLog: - assert self._cmapRange[0] > 0. and self._cmapRange[1] > 0. - return self._cmapRange - - @cmapRange.setter - def cmapRange(self, cmapRange): - assert len(cmapRange) == 2 - assert cmapRange[0] <= cmapRange[1] - self._cmapRange = float(cmapRange[0]), float(cmapRange[1]) - - @property - def alpha(self): - return self._alpha - - def updateData(self, data): - assert data.dtype in self._INTERNAL_FORMATS - oldData = self.data - self.data = data - - 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 = numpy.dot(numpy.dot(matrix, - mat4Translate(*self.origin)), - mat4Scale(*self.scale)) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, - mat.astype(numpy.float32)) - - 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.astype(numpy.float32)) - mat = numpy.dot(mat4Translate(ox, oy), mat4Scale(*self.scale)) - gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, - mat.astype(numpy.float32)) - - 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 = numpy.dot(numpy.dot(matrix, mat4Translate(*self.origin)), - mat4Scale(*self.scale)) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, - mat.astype(numpy.float32)) - - 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.astype(numpy.float32)) - mat = numpy.dot(mat4Translate(ox, oy), mat4Scale(*self.scale)) - gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, - mat.astype(numpy.float32)) - - 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 deleted file mode 100644 index 18c5eb7..0000000 --- a/silx/gui/plot/backends/glutils/GLSupport.py +++ /dev/null @@ -1,201 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -""" -This module provides 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, dtype=None): - """Returns triangle strip indices for rendering a filled polygon mask - - :param int nIndices: Number of points - :param Union[numpy.dtype,None] dtype: - If specified the dtype of the returned indices array - :return: 1D array of indices constructing a triangle strip - :rtype: numpy.ndarray - """ - if dtype is None: - 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.array(( - (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.float64) - - -def mat4Translate(x=0., y=0., z=0.): - """Translation matrix (row-major)""" - return numpy.array(( - (1., 0., 0., x), - (0., 1., 0., y), - (0., 0., 1., z), - (0., 0., 0., 1.)), dtype=numpy.float64) - - -def mat4Scale(sx=1., sy=1., sz=1.): - """Scale matrix (row-major)""" - return numpy.array(( - (sx, 0., 0., 0.), - (0., sy, 0., 0.), - (0., 0., sz, 0.), - (0., 0., 0., 1.)), dtype=numpy.float64) - - -def mat4Identity(): - """Identity matrix""" - return numpy.array(( - (1., 0., 0., 0.), - (0., 1., 0., 0.), - (0., 0., 1., 0.), - (0., 0., 0., 1.)), dtype=numpy.float64) diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py deleted file mode 100644 index 3d262bc..0000000 --- a/silx/gui/plot/backends/glutils/GLText.py +++ /dev/null @@ -1,270 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -""" -This module provides 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" - - -from collections import OrderedDict -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? - - -class _Cache(object): - """LRU (Least Recent Used) cache. - - :param int maxsize: Maximum number of (key, value) pairs in the cache - :param callable callback: - Called when a (key, value) pair is removed from the cache. - It must take 2 arguments: key and value. - """ - - def __init__(self, maxsize=128, callback=None): - self._maxsize = int(maxsize) - self._callback = callback - self._cache = OrderedDict() - - def __contains__(self, item): - return item in self._cache - - def __getitem__(self, key): - if key in self._cache: - # Remove/add key from ordered dict to store last access info - value = self._cache.pop(key) - self._cache[key] = value - return value - else: - raise KeyError - - def __setitem__(self, key, value): - """Add a key, value pair to the cache. - - :param key: The key to set - :param value: The corresponding value - """ - if key not in self._cache and len(self._cache) >= self._maxsize: - removedKey, removedValue = self._cache.popitem(last=False) - if self._callback is not None: - self._callback(removedKey, removedValue) - self._cache[key] = value - - -# 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') - - # Discard texture objects when removed from the cache - _textures = _Cache(callback=lambda key, value: value[0].discard()) - """Cache already created textures""" - - _sizes = _Cache() - """Cache already computed sizes""" - - 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) - - def _getTexture(self, text): - key = getGLContext(), text - - if key not in self._textures: - image, offset = font.rasterText(text, - font.getDefaultFontFamily()) - if text not in self._sizes: - self._sizes[text] = image.shape[1], image.shape[0] - - self._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 self._textures[key] - - @property - def text(self): - return self._text - - @property - def size(self): - if self.text not in self._sizes: - image, offset = font.rasterText(self.text, - font.getDefaultFontFamily()) - self._sizes[self.text] = image.shape[1], image.shape[0] - return self._sizes[self.text] - - 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) - - mat = numpy.dot(matrix, mat4Translate(int(self.x), int(self.y))) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, - mat.astype(numpy.float32)) - - 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 deleted file mode 100644 index 25dd9f1..0000000 --- a/silx/gui/plot/backends/glutils/GLTexture.py +++ /dev/null @@ -1,239 +0,0 @@ -# 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 deleted file mode 100644 index 83c7ae0..0000000 --- a/silx/gui/plot/backends/glutils/PlotImageFile.py +++ /dev/null @@ -1,153 +0,0 @@ -# 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_info < (3, ): - fileObj = open(fileNameOrObj, "wb") - else: - if fileFormat in ('png', 'ppm', 'tiff'): - # Open in binary mode - 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(b'P6\n') - fileObj.write(b'%d %d\n' % (width, height)) - fileObj.write(b'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': 'OpenGL Plot Snapshot'}) - - if fileObj != fileNameOrObj: - fileObj.close() diff --git a/silx/gui/plot/backends/glutils/__init__.py b/silx/gui/plot/backends/glutils/__init__.py deleted file mode 100644 index 771de39..0000000 --- a/silx/gui/plot/backends/glutils/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -# 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 deleted file mode 100644 index e7957ac..0000000 --- a/silx/gui/plot/items/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This package provides classes that describes :class:`.PlotWidget` content. - -Instances of those classes are returned by :class:`.PlotWidget` methods that give -access to its content such as :meth:`.PlotWidget.getCurve`, :meth:`.PlotWidget.getImage`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "22/06/2017" - -from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa - SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa - AlphaMixIn, LineMixIn, ItemChangedType) # noqa -from .complex import ImageComplexData # noqa -from .curve import Curve # noqa -from .histogram import Histogram # noqa -from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa -from .shape import Shape # noqa -from .scatter import Scatter # noqa -from .marker import Marker, XMarker, YMarker # noqa -from .axis import Axis, XAxis, YAxis, YRightAxis - -DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter -"""Classes of items representing data and to consider to compute data bounds. -""" diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py deleted file mode 100644 index 3d9fe14..0000000 --- a/silx/gui/plot/items/axis.py +++ /dev/null @@ -1,567 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides the class for axes of the :class:`PlotWidget`. -""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "06/12/2017" - -import datetime as dt -import logging - -import dateutil.tz - -from ... import qt - -from silx.third_party import enum - -_logger = logging.getLogger(__name__) - - -class TickMode(enum.Enum): - """Determines if ticks are regular number or datetimes.""" - DEFAULT = 0 # Ticks are regular numbers - TIME_SERIES = 1 # Ticks are datetime objects - - -class Axis(qt.QObject): - """This class describes and controls a plot axis. - - Note: This is an abstract class. - """ - # States are half-stored on the backend of the plot, and half-stored on this - # object. - # TODO It would be good to store all the states of an axis in this object. - # i.e. vmin and vmax - - LINEAR = "linear" - """Constant defining a linear scale""" - - LOGARITHMIC = "log" - """Constant defining a logarithmic scale""" - - _SCALES = set([LINEAR, LOGARITHMIC]) - - sigInvertedChanged = qt.Signal(bool) - """Signal emitted when axis orientation has changed""" - - sigScaleChanged = qt.Signal(str) - """Signal emitted when axis scale has changed""" - - _sigLogarithmicChanged = qt.Signal(bool) - """Signal emitted when axis scale has changed to or from logarithmic""" - - sigAutoScaleChanged = qt.Signal(bool) - """Signal emitted when axis autoscale has changed""" - - sigLimitsChanged = qt.Signal(float, float) - """Signal emitted when axis limits have changed""" - - def __init__(self, plot): - """Constructor - - :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this - axis - """ - qt.QObject.__init__(self, parent=plot) - self._scale = self.LINEAR - self._isAutoScale = True - # Store default labels provided to setGraph[X|Y]Label - self._defaultLabel = '' - # Store currently displayed labels - # Current label can differ from input one with active curve handling - self._currentLabel = '' - - def _getPlot(self): - """Returns the PlotWidget this Axis belongs to. - - :rtype: PlotWidget - """ - plot = self.parent() - if plot is None: - raise RuntimeError("Axis no longer attached to a PlotWidget") - return plot - - def _getBackend(self): - """Returns the backend - - :rtype: BackendBase - """ - return self._getPlot()._backend - - def getLimits(self): - """Get the limits of this axis. - - :return: Minimum and maximum values of this axis as tuple - """ - return self._internalGetLimits() - - def setLimits(self, vmin, vmax): - """Set this axis limits. - - :param float vmin: minimum axis value - :param float vmax: maximum axis value - """ - vmin, vmax = self._checkLimits(vmin, vmax) - if self.getLimits() == (vmin, vmax): - return - - self._internalSetLimits(vmin, vmax) - self._getPlot()._setDirtyPlot() - - self._emitLimitsChanged() - - def _emitLimitsChanged(self): - """Emit axis sigLimitsChanged and PlotWidget limitsChanged event""" - vmin, vmax = self.getLimits() - self.sigLimitsChanged.emit(vmin, vmax) - self._getPlot()._notifyLimitsChanged(emitSignal=False) - - def _checkLimits(self, vmin, vmax): - """Makes sure axis range is not empty - - :param float vmin: Min axis value - :param float vmax: Max axis value - :return: (min, max) making sure min < max - :rtype: 2-tuple of float - """ - if vmax < vmin: - _logger.debug('%s axis: max < min, inverting limits.', self._defaultLabel) - vmin, vmax = vmax, vmin - elif vmax == vmin: - _logger.debug('%s axis: max == min, expanding limits.', self._defaultLabel) - if vmin == 0.: - vmin, vmax = -0.1, 0.1 - elif vmin < 0: - vmin, vmax = vmin * 1.1, vmin * 0.9 - else: # xmin > 0 - vmin, vmax = vmin * 0.9, vmin * 1.1 - - return vmin, vmax - - def isInverted(self): - """Return True if the axis is inverted (top to bottom for the y-axis), - False otherwise. It is always False for the X axis. - - :rtype: bool - """ - return False - - def setInverted(self, isInverted): - """Set the axis orientation. - - This is only available for the Y axis. - - :param bool flag: True for Y axis going from top to bottom, - False for Y axis going from bottom to top - """ - if isInverted == self.isInverted(): - return - raise NotImplementedError() - - def getLabel(self): - """Return the current displayed label of this axis. - - :param str axis: The Y axis for which to get the label (left or right) - :rtype: str - """ - return self._currentLabel - - def setLabel(self, label): - """Set the label displayed on the plot for this axis. - - The provided label can be temporarily replaced by the label of the - active curve if any. - - :param str label: The axis label - """ - self._defaultLabel = label - self._setCurrentLabel(label) - self._getPlot()._setDirtyPlot() - - def _setCurrentLabel(self, label): - """Define the label currently displayed. - - If the label is None or empty the default label is used. - - :param str label: Currently displayed label - """ - if label is None or label == '': - label = self._defaultLabel - if label is None: - label = '' - self._currentLabel = label - self._internalSetCurrentLabel(label) - - def getScale(self): - """Return the name of the scale used by this axis. - - :rtype: str - """ - return self._scale - - def setScale(self, scale): - """Set the scale to be used by this axis. - - :param str scale: Name of the scale ("log", or "linear") - """ - assert(scale in self._SCALES) - if self._scale == scale: - return - - # For the backward compatibility signal - emitLog = self._scale == self.LOGARITHMIC or scale == self.LOGARITHMIC - - self._scale = scale - - # TODO hackish way of forcing update of curves and images - plot = self._getPlot() - for item in plot._getItems(withhidden=True): - item._updated() - plot._invalidateDataRange() - - if scale == self.LOGARITHMIC: - self._internalSetLogarithmic(True) - elif scale == self.LINEAR: - self._internalSetLogarithmic(False) - else: - raise ValueError("Scale %s unsupported" % scale) - - plot._forceResetZoom() - - self.sigScaleChanged.emit(self._scale) - if emitLog: - self._sigLogarithmicChanged.emit(self._scale == self.LOGARITHMIC) - - def _isLogarithmic(self): - """Return True if this axis scale is logarithmic, False if linear. - - :rtype: bool - """ - return self._scale == self.LOGARITHMIC - - def _setLogarithmic(self, flag): - """Set the scale of this axes (either linear or logarithmic). - - :param bool flag: True to use a logarithmic scale, False for linear. - """ - flag = bool(flag) - self.setScale(self.LOGARITHMIC if flag else self.LINEAR) - - def getTimeZone(self): - """Sets tzinfo that is used if this axis plots date times. - - None means the datetimes are interpreted as local time. - - :rtype: datetime.tzinfo of None. - """ - raise NotImplementedError() - - def setTimeZone(self, tz): - """Sets tzinfo that is used if this axis' tickMode is TIME_SERIES - - The tz must be a descendant of the datetime.tzinfo class, "UTC" or None. - Use None to let the datetimes be interpreted as local time. - Use the string "UTC" to let the date datetimes be in UTC time. - - :param tz: datetime.tzinfo, "UTC" or None. - """ - raise NotImplementedError() - - def getTickMode(self): - """Determines if axis ticks are number or datetimes. - - :rtype: TickMode enum. - """ - raise NotImplementedError() - - def setTickMode(self, tickMode): - """Determines if axis ticks are number or datetimes. - - :param TickMode tickMode: tick mode enum. - """ - raise NotImplementedError() - - def isAutoScale(self): - """Return True if axis is automatically adjusting its limits. - - :rtype: bool - """ - return self._isAutoScale - - def setAutoScale(self, flag=True): - """Set the axis limits adjusting behavior of :meth:`resetZoom`. - - :param bool flag: True to resize limits automatically, - False to disable it. - """ - self._isAutoScale = bool(flag) - self.sigAutoScaleChanged.emit(self._isAutoScale) - - def _setLimitsConstraints(self, minPos=None, maxPos=None): - raise NotImplementedError() - - def setLimitsConstraints(self, minPos=None, maxPos=None): - """ - Set a constraint on the position of the axes. - - :param float minPos: Minimum allowed axis value. - :param float maxPos: Maximum allowed axis value. - :return: True if the constaints was updated - :rtype: bool - """ - updated = self._setLimitsConstraints(minPos, maxPos) - if updated: - plot = self._getPlot() - xMin, xMax = plot.getXAxis().getLimits() - yMin, yMax = plot.getYAxis().getLimits() - y2Min, y2Max = plot.getYAxis('right').getLimits() - plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) - return updated - - def _setRangeConstraints(self, minRange=None, maxRange=None): - raise NotImplementedError() - - def setRangeConstraints(self, minRange=None, maxRange=None): - """ - Set a constraint on the position of the axes. - - :param float minRange: Minimum allowed left-to-right span across the - view - :param float maxRange: Maximum allowed left-to-right span across the - view - :return: True if the constaints was updated - :rtype: bool - """ - updated = self._setRangeConstraints(minRange, maxRange) - if updated: - plot = self._getPlot() - xMin, xMax = plot.getXAxis().getLimits() - yMin, yMax = plot.getYAxis().getLimits() - y2Min, y2Max = plot.getYAxis('right').getLimits() - plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) - return updated - - -class XAxis(Axis): - """Axis class defining primitives for the X axis""" - - # TODO With some changes on the backend, it will be able to remove all this - # specialised implementations (prefixel by '_internal') - - def getTimeZone(self): - return self._getBackend().getXAxisTimeZone() - - def setTimeZone(self, tz): - if isinstance(tz, str) and tz.upper() == "UTC": - tz = dateutil.tz.tzutc() - elif not(tz is None or isinstance(tz, dt.tzinfo)): - raise TypeError("tz must be a dt.tzinfo object, None or 'UTC'.") - - self._getBackend().setXAxisTimeZone(tz) - self._getPlot()._setDirtyPlot() - - def getTickMode(self): - if self._getBackend().isXAxisTimeSeries(): - return TickMode.TIME_SERIES - else: - return TickMode.DEFAULT - - def setTickMode(self, tickMode): - if tickMode == TickMode.DEFAULT: - self._getBackend().setXAxisTimeSeries(False) - elif tickMode == TickMode.TIME_SERIES: - self._getBackend().setXAxisTimeSeries(True) - else: - raise ValueError("Unexpected TickMode: {}".format(tickMode)) - - def _internalSetCurrentLabel(self, label): - self._getBackend().setGraphXLabel(label) - - def _internalGetLimits(self): - return self._getBackend().getGraphXLimits() - - def _internalSetLimits(self, xmin, xmax): - self._getBackend().setGraphXLimits(xmin, xmax) - - def _internalSetLogarithmic(self, flag): - self._getBackend().setXAxisLogarithmic(flag) - - def _setLimitsConstraints(self, minPos=None, maxPos=None): - constrains = self._getPlot()._getViewConstraints() - updated = constrains.update(xMin=minPos, xMax=maxPos) - return updated - - def _setRangeConstraints(self, minRange=None, maxRange=None): - constrains = self._getPlot()._getViewConstraints() - updated = constrains.update(minXRange=minRange, maxXRange=maxRange) - return updated - - -class YAxis(Axis): - """Axis class defining primitives for the Y axis""" - - # TODO With some changes on the backend, it will be able to remove all this - # specialised implementations (prefixel by '_internal') - - def _internalSetCurrentLabel(self, label): - self._getBackend().setGraphYLabel(label, axis='left') - - def _internalGetLimits(self): - return self._getBackend().getGraphYLimits(axis='left') - - def _internalSetLimits(self, ymin, ymax): - self._getBackend().setGraphYLimits(ymin, ymax, axis='left') - - def _internalSetLogarithmic(self, flag): - self._getBackend().setYAxisLogarithmic(flag) - - def setInverted(self, flag=True): - """Set the axis orientation. - - This is only available for the Y axis. - - :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._getBackend().setYAxisInverted(flag) - self._getPlot()._setDirtyPlot() - self.sigInvertedChanged.emit(flag) - - def isInverted(self): - """Return True if the axis is inverted (top to bottom for the y-axis), - False otherwise. It is always False for the X axis. - - :rtype: bool - """ - return self._getBackend().isYAxisInverted() - - def _setLimitsConstraints(self, minPos=None, maxPos=None): - constrains = self._getPlot()._getViewConstraints() - updated = constrains.update(yMin=minPos, yMax=maxPos) - return updated - - def _setRangeConstraints(self, minRange=None, maxRange=None): - constrains = self._getPlot()._getViewConstraints() - updated = constrains.update(minYRange=minRange, maxYRange=maxRange) - return updated - - -class YRightAxis(Axis): - """Proxy axis for the secondary Y axes. It manages it own label and limit - but share the some state like scale and direction with the main axis.""" - - # TODO With some changes on the backend, it will be able to remove all this - # specialised implementations (prefixel by '_internal') - - def __init__(self, plot, mainAxis): - """Constructor - - :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this - axis - :param Axis mainAxis: Axis which sharing state with this axis - """ - Axis.__init__(self, plot) - self.__mainAxis = mainAxis - - @property - def sigInvertedChanged(self): - """Signal emitted when axis orientation has changed""" - return self.__mainAxis.sigInvertedChanged - - @property - def sigScaleChanged(self): - """Signal emitted when axis scale has changed""" - return self.__mainAxis.sigScaleChanged - - @property - def _sigLogarithmicChanged(self): - """Signal emitted when axis scale has changed to or from logarithmic""" - return self.__mainAxis._sigLogarithmicChanged - - @property - def sigAutoScaleChanged(self): - """Signal emitted when axis autoscale has changed""" - return self.__mainAxis.sigAutoScaleChanged - - def _internalSetCurrentLabel(self, label): - self._getBackend().setGraphYLabel(label, axis='right') - - def _internalGetLimits(self): - return self._getBackend().getGraphYLimits(axis='right') - - def _internalSetLimits(self, ymin, ymax): - self._getBackend().setGraphYLimits(ymin, ymax, axis='right') - - def setInverted(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 - """ - return self.__mainAxis.setInverted(flag) - - def isInverted(self): - """Return True if Y axis goes from top to bottom, False otherwise.""" - return self.__mainAxis.isInverted() - - def getScale(self): - """Return the name of the scale used by this axis. - - :rtype: str - """ - return self.__mainAxis.getScale() - - def setScale(self, scale): - """Set the scale to be used by this axis. - - :param str scale: Name of the scale ("log", or "linear") - """ - self.__mainAxis.setScale(scale) - - def _isLogarithmic(self): - """Return True if Y axis scale is logarithmic, False if linear.""" - return self.__mainAxis._isLogarithmic() - - def _setLogarithmic(self, flag): - """Set the Y axes scale (either linear or logarithmic). - - :param bool flag: True to use a logarithmic scale, False for linear. - """ - return self.__mainAxis._setLogarithmic(flag) - - def isAutoScale(self): - """Return True if Y axes are automatically adjusting its limits.""" - return self.__mainAxis.isAutoScale() - - def setAutoScale(self, flag=True): - """Set the Y axis limits adjusting behavior of :meth:`PlotWidget.resetZoom`. - - :param bool flag: True to resize limits automatically, - False to disable it. - """ - return self.__mainAxis.setAutoScale(flag) diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py deleted file mode 100644 index 535b0a9..0000000 --- a/silx/gui/plot/items/complex.py +++ /dev/null @@ -1,356 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides the :class:`ImageComplexData` of the :class:`Plot`. -""" - -from __future__ import absolute_import - -__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] -__license__ = "MIT" -__date__ = "14/06/2018" - - -import logging -import numpy - -from silx.third_party import enum - -from ...colors import Colormap -from .core import ColormapMixIn, ItemChangedType -from .image import ImageBase - - -_logger = logging.getLogger(__name__) - - -# Complex colormap functions - -def _phase2rgb(colormap, data): - """Creates RGBA image with colour-coded phase. - - :param Colormap colormap: The colormap to use - :param numpy.ndarray data: The data to convert - :return: Array of RGBA colors - :rtype: numpy.ndarray - """ - if data.size == 0: - return numpy.zeros((0, 0, 4), dtype=numpy.uint8) - - phase = numpy.angle(data) - return colormap.applyToData(phase) - - -def _complex2rgbalog(phaseColormap, data, amin=0., dlogs=2, smax=None): - """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha. - - :param Colormap phaseColormap: Colormap to use for the phase - :param numpy.ndarray data: the complex data array to convert to RGBA - :param float amin: the minimum value for the alpha channel - :param float dlogs: amplitude range displayed, in log10 units - :param float smax: - if specified, all values above max will be displayed with an alpha=1 - """ - if data.size == 0: - return numpy.zeros((0, 0, 4), dtype=numpy.uint8) - - rgba = _phase2rgb(phaseColormap, data) - sabs = numpy.absolute(data) - if smax is not None: - sabs[sabs > smax] = smax - a = numpy.log10(sabs + 1e-20) - a -= a.max() - dlogs # display dlogs orders of magnitude - rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0)) - return rgba - - -def _complex2rgbalin(phaseColormap, data, gamma=1.0, smax=None): - """Returns RGBA colors: colour-coded phase and linear amplitude in alpha. - - :param Colormap phaseColormap: Colormap to use for the phase - :param numpy.ndarray data: - :param float gamma: Optional exponent gamma applied to the amplitude - :param float smax: - """ - if data.size == 0: - return numpy.zeros((0, 0, 4), dtype=numpy.uint8) - - rgba = _phase2rgb(phaseColormap, data) - a = numpy.absolute(data) - if smax is not None: - a[a > smax] = smax - a /= a.max() - rgba[..., 3] = 255 * a**gamma - return rgba - - -class ImageComplexData(ImageBase, ColormapMixIn): - """Specific plot item to force colormap when using complex colormap. - - This is returning the specific colormap when displaying - colored phase + amplitude. - """ - - class Mode(enum.Enum): - """Identify available display mode for complex""" - ABSOLUTE = 'absolute' - PHASE = 'phase' - REAL = 'real' - IMAGINARY = 'imaginary' - AMPLITUDE_PHASE = 'amplitude_phase' - LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase' - SQUARE_AMPLITUDE = 'square_amplitude' - - def __init__(self): - ImageBase.__init__(self) - ColormapMixIn.__init__(self) - self._data = numpy.zeros((0, 0), dtype=numpy.complex64) - self._dataByModesCache = {} - self._mode = self.Mode.ABSOLUTE - self._amplitudeRangeInfo = None, 2 - - # Use default from ColormapMixIn - colormap = super(ImageComplexData, self).getColormap() - - phaseColormap = Colormap( - name='hsv', - vmin=-numpy.pi, - vmax=numpy.pi) - phaseColormap.setEditable(False) - - self._colormaps = { # Default colormaps for all modes - self.Mode.ABSOLUTE: colormap, - self.Mode.PHASE: phaseColormap, - self.Mode.REAL: colormap, - self.Mode.IMAGINARY: colormap, - self.Mode.AMPLITUDE_PHASE: phaseColormap, - self.Mode.LOG10_AMPLITUDE_PHASE: phaseColormap, - self.Mode.SQUARE_AMPLITUDE: colormap, - } - - def _addBackendRenderer(self, backend): - """Update backend renderer""" - plot = self.getPlot() - assert plot is not None - if not self._isPlotLinear(plot): - # Do not render with non linear scales - return None - - mode = self.getVisualizationMode() - if mode in (self.Mode.AMPLITUDE_PHASE, - self.Mode.LOG10_AMPLITUDE_PHASE): - # For those modes, compute RGBA image here - colormap = None - data = self.getRgbaImageData(copy=False) - else: - colormap = self.getColormap() - 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=colormap, - alpha=self.getAlpha()) - - - def setVisualizationMode(self, mode): - """Set the visualization mode to use. - - :param Mode mode: - """ - assert isinstance(mode, self.Mode) - assert mode in self._colormaps - - if mode != self._mode: - self._mode = mode - - self._updated(ItemChangedType.VISUALIZATION_MODE) - - # Send data updated as value returned by getData has changed - self._updated(ItemChangedType.DATA) - - # Update ColormapMixIn colormap - colormap = self._colormaps[self._mode] - if colormap is not super(ImageComplexData, self).getColormap(): - super(ImageComplexData, self).setColormap(colormap) - - def getVisualizationMode(self): - """Returns the visualization mode in use. - - :rtype: Mode - """ - return self._mode - - def _setAmplitudeRangeInfo(self, max_=None, delta=2): - """Set the amplitude range to display for 'log10_amplitude_phase' mode. - - :param max_: Max of the amplitude range. - If None it autoscales to data max. - :param float delta: Delta range in log10 to display - """ - self._amplitudeRangeInfo = max_, float(delta) - self._updated(ItemChangedType.VISUALIZATION_MODE) - - def _getAmplitudeRangeInfo(self): - """Returns the amplitude range to use for 'log10_amplitude_phase' mode. - - :return: (max, delta), if max is None, then it autoscales to data max - :rtype: 2-tuple""" - return self._amplitudeRangeInfo - - def setColormap(self, colormap, mode=None): - """Set the colormap for this specific mode. - - :param ~silx.gui.colors.Colormap colormap: The colormap - :param Mode mode: - If specified, set the colormap of this specific mode. - Default: current mode. - """ - if mode is None: - mode = self.getVisualizationMode() - - self._colormaps[mode] = colormap - if mode is self.getVisualizationMode(): - super(ImageComplexData, self).setColormap(colormap) - else: - self._updated(ItemChangedType.COLORMAP) - - def getColormap(self, mode=None): - """Get the colormap for the (current) mode. - - :param Mode mode: - If specified, get the colormap of this specific mode. - Default: current mode. - :rtype: ~silx.gui.colors.Colormap - """ - if mode is None: - mode = self.getVisualizationMode() - - return self._colormaps[mode] - - def setData(self, data, copy=True): - """"Set the image complex data - - :param numpy.ndarray data: 2D array of complex with 2 dimensions (h, w) - :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 - if not numpy.issubdtype(data.dtype, numpy.complexfloating): - _logger.warning( - 'Image is not complex, converting it to complex to plot it.') - data = numpy.array(data, dtype=numpy.complex64) - - self._data = data - self._dataByModesCache = {} - - # TODO hackish data range implementation - if self.isVisible(): - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - self._updated(ItemChangedType.DATA) - - def getComplexData(self, copy=True): - """Returns the image complex data - - :param bool copy: True (Default) to get a copy, - False to use internal representation (do not modify!) - :rtype: numpy.ndarray of complex - """ - return numpy.array(self._data, copy=copy) - - def getData(self, copy=True, mode=None): - """Returns the image data corresponding to (current) mode. - - The returned data is always floats, to get the complex data, use - :meth:`getComplexData`. - - :param bool copy: True (Default) to get a copy, - False to use internal representation (do not modify!) - :param Mode mode: - If specified, get data corresponding to the mode. - Default: Current mode. - :rtype: numpy.ndarray of float - """ - if mode is None: - mode = self.getVisualizationMode() - - if mode not in self._dataByModesCache: - # Compute data for mode and store it in cache - complexData = self.getComplexData(copy=False) - if mode is self.Mode.PHASE: - data = numpy.angle(complexData) - elif mode is self.Mode.REAL: - data = numpy.real(complexData) - elif mode is self.Mode.IMAGINARY: - data = numpy.imag(complexData) - elif mode in (self.Mode.ABSOLUTE, - self.Mode.LOG10_AMPLITUDE_PHASE, - self.Mode.AMPLITUDE_PHASE): - data = numpy.absolute(complexData) - elif mode is self.Mode.SQUARE_AMPLITUDE: - data = numpy.absolute(complexData) ** 2 - else: - _logger.error( - 'Unsupported conversion mode: %s, fallback to absolute', - str(mode)) - data = numpy.absolute(complexData) - - self._dataByModesCache[mode] = data - - return numpy.array(self._dataByModesCache[mode], copy=copy) - - def getRgbaImageData(self, copy=True, mode=None): - """Get the displayed RGB(A) image for (current) mode - - :param bool copy: Ignored for this class - :param Mode mode: - If specified, get data corresponding to the mode. - Default: Current mode. - :rtype: numpy.ndarray of uint8 of shape (height, width, 4) - """ - if mode is None: - mode = self.getVisualizationMode() - - colormap = self.getColormap(mode=mode) - if mode is self.Mode.AMPLITUDE_PHASE: - data = self.getComplexData(copy=False) - return _complex2rgbalin(colormap, data) - elif mode is self.Mode.LOG10_AMPLITUDE_PHASE: - data = self.getComplexData(copy=False) - max_, delta = self._getAmplitudeRangeInfo() - return _complex2rgbalog(colormap, data, dlogs=delta, smax=max_) - else: - data = self.getData(copy=False, mode=mode) - return colormap.applyToData(data) diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py deleted file mode 100644 index e000751..0000000 --- a/silx/gui/plot/items/core.py +++ /dev/null @@ -1,1036 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides the base class for items of the :class:`Plot`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "14/06/2018" - -import collections -from copy import deepcopy -import logging -import warnings -import weakref -import numpy -from silx.third_party import six, enum - -from ... import qt -from ... import colors -from ...colors import Colormap - - -_logger = logging.getLogger(__name__) - - -@enum.unique -class ItemChangedType(enum.Enum): - """Type of modification provided by :attr:`Item.sigItemChanged` signal.""" - # Private setters and setInfo are not emitting sigItemChanged signal. - # Signals to consider: - # COLORMAP_SET emitted when setColormap is called but not forward colormap object signal - # CURRENT_COLOR_CHANGED emitted current color changed because highlight changed, - # highlighted color changed or color changed depending on hightlight state. - - VISIBLE = 'visibleChanged' - """Item's visibility changed flag.""" - - ZVALUE = 'zValueChanged' - """Item's Z value changed flag.""" - - COLORMAP = 'colormapChanged' # Emitted when set + forward events from the colormap object - """Item's colormap changed flag. - - This is emitted both when setting a new colormap and - when the current colormap object is updated. - """ - - SYMBOL = 'symbolChanged' - """Item's symbol changed flag.""" - - SYMBOL_SIZE = 'symbolSizeChanged' - """Item's symbol size changed flag.""" - - LINE_WIDTH = 'lineWidthChanged' - """Item's line width changed flag.""" - - LINE_STYLE = 'lineStyleChanged' - """Item's line style changed flag.""" - - COLOR = 'colorChanged' - """Item's color changed flag.""" - - YAXIS = 'yAxisChanged' - """Item's Y axis binding changed flag.""" - - FILL = 'fillChanged' - """Item's fill changed flag.""" - - ALPHA = 'alphaChanged' - """Item's transparency alpha changed flag.""" - - DATA = 'dataChanged' - """Item's data changed flag""" - - HIGHLIGHTED = 'highlightedChanged' - """Item's highlight state changed flag.""" - - HIGHLIGHTED_COLOR = 'highlightedColorChanged' - """Deprecated, use HIGHLIGHTED_STYLE instead.""" - - HIGHLIGHTED_STYLE = 'highlightedStyleChanged' - """Item's highlighted style changed flag.""" - - SCALE = 'scaleChanged' - """Item's scale changed flag.""" - - TEXT = 'textChanged' - """Item's text changed flag.""" - - POSITION = 'positionChanged' - """Item's position changed flag. - - This is emitted when a marker position changed and - when an image origin changed. - """ - - OVERLAY = 'overlayChanged' - """Item's overlay state changed flag.""" - - VISUALIZATION_MODE = 'visualizationModeChanged' - """Item's visualization mode changed flag.""" - - -class Item(qt.QObject): - """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""" - - sigItemChanged = qt.Signal(object) - """Signal emitted when the item has changed. - - It provides a flag describing which property of the item has changed. - See :class:`ItemChangedType` for flags description. - """ - - def __init__(self): - qt.QObject.__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(ItemChangedType.VISIBLE, - 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(ItemChangedType.ZVALUE) - - 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, event=None, checkVisibility=True): - """Mark the item as dirty (i.e., needing update). - - This also triggers Plot.replot. - - :param event: The event to send to :attr:`sigItemChanged` signal. - :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) - if event is not None: - self.sigItemChanged.emit(event) - - 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 ItemMixInBase(qt.QObject): - """Base class for Item mix-in""" - - def _updated(self, event=None, checkVisibility=True): - """This is implemented in :class:`Item`. - - Mark the item as dirty (i.e., needing update). - This also triggers Plot.replot. - - :param event: The event to send to :attr:`sigItemChanged` signal. - :param bool checkVisibility: True to only mark as dirty if visible, - False to always mark as dirty. - """ - raise RuntimeError( - "Issue with Mix-In class inheritance order") - - -class LabelsMixIn(ItemMixInBase): - """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(ItemMixInBase): - """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(ItemMixInBase): - """Mix-in class for items with colormap""" - - def __init__(self): - self._colormap = Colormap() - self._colormap.sigChanged.connect(self._colormapChanged) - - def getColormap(self): - """Return the used colormap""" - return self._colormap - - def setColormap(self, colormap): - """Set the colormap of this image - - :param silx.gui.colors.Colormap colormap: colormap description - """ - if isinstance(colormap, dict): - colormap = Colormap._fromDict(colormap) - - if self._colormap is not None: - self._colormap.sigChanged.disconnect(self._colormapChanged) - self._colormap = colormap - if self._colormap is not None: - self._colormap.sigChanged.connect(self._colormapChanged) - self._colormapChanged() - - def _colormapChanged(self): - """Handle updates of the colormap""" - self._updated(ItemChangedType.COLORMAP) - - -class SymbolMixIn(ItemMixInBase): - """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""" - - _SUPPORTED_SYMBOLS = collections.OrderedDict(( - ('o', 'Circle'), - ('d', 'Diamond'), - ('s', 'Square'), - ('+', 'Plus'), - ('x', 'Cross'), - ('.', 'Point'), - (',', 'Pixel'), - ('', 'None'))) - """Dict of supported symbols""" - - def __init__(self): - self._symbol = self._DEFAULT_SYMBOL - self._symbol_size = self._DEFAULT_SYMBOL_SIZE - - @classmethod - def getSupportedSymbols(cls): - """Returns the list of supported symbol names. - - :rtype: tuple of str - """ - return tuple(cls._SUPPORTED_SYMBOLS.keys()) - - @classmethod - def getSupportedSymbolNames(cls): - """Returns the list of supported symbol human-readable names. - - :rtype: tuple of str - """ - return tuple(cls._SUPPORTED_SYMBOLS.values()) - - def getSymbolName(self, symbol=None): - """Returns human-readable name for a symbol. - - :param str symbol: The symbol from which to get the name. - Default: current symbol. - :rtype: str - :raise KeyError: if symbol is not in :meth:`getSupportedSymbols`. - """ - if symbol is None: - symbol = self.getSymbol() - return self._SUPPORTED_SYMBOLS[symbol] - - 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 or marker name - """ - if symbol is None: - symbol = self._DEFAULT_SYMBOL - - elif symbol not in self.getSupportedSymbols(): - for symbolCode, name in self._SUPPORTED_SYMBOLS.items(): - if name.lower() == symbol.lower(): - symbol = symbolCode - break - else: - raise ValueError('Unsupported symbol %s' % str(symbol)) - - if symbol != self._symbol: - self._symbol = symbol - self._updated(ItemChangedType.SYMBOL) - - 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(ItemChangedType.SYMBOL_SIZE) - - -class LineMixIn(ItemMixInBase): - """Mix-in class for item with line""" - - _DEFAULT_LINEWIDTH = 1. - """Default line width""" - - _DEFAULT_LINESTYLE = '-' - """Default line style""" - - _SUPPORTED_LINESTYLE = '', ' ', '-', '--', '-.', ':', None - """Supported line styles""" - - def __init__(self): - self._linewidth = self._DEFAULT_LINEWIDTH - self._linestyle = self._DEFAULT_LINESTYLE - - @classmethod - def getSupportedLineStyles(cls): - """Returns list of supported line styles. - - :rtype: List[str,None] - """ - return cls._SUPPORTED_LINESTYLE - - def getLineWidth(self): - """Return the curve line width in pixels - - :rtype: float - """ - 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(ItemChangedType.LINE_WIDTH) - - 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 self.getSupportedLineStyles() - if style is None: - style = self._DEFAULT_LINESTYLE - if style != self._linestyle: - self._linestyle = style - self._updated(ItemChangedType.LINE_STYLE) - - -class ColorMixIn(ItemMixInBase): - """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] or array of colors - """ - 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(ItemChangedType.COLOR) - - -class YAxisMixIn(ItemMixInBase): - """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(ItemChangedType.YAXIS) - - -class FillMixIn(ItemMixInBase): - """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(ItemChangedType.FILL) - - -class AlphaMixIn(ItemMixInBase): - """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(ItemChangedType.ALPHA) - - -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 - valueMinusError = value - numpy.atleast_2d(error)[0] - errorClipped = numpy.isnan(valueMinusError) - mask = numpy.logical_not(errorClipped) - errorClipped[mask] = valueMinusError[mask] <= 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: - xclipped, yclipped = False, False - - if xPositive: - x = self.getXData(copy=False) - with warnings.catch_warnings(): # Ignore NaN warnings - warnings.simplefilter('ignore', category=RuntimeWarning) - xclipped = x <= 0 - - if yPositive: - y = self.getYData(copy=False) - with warnings.catch_warnings(): # Ignore NaN warnings - warnings.simplefilter('ignore', category=RuntimeWarning) - yclipped = y <= 0 - - 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.getXAxis()._isLogarithmic() - yPositive = plot.getYAxis()._isLogarithmic() - 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.getXAxis()._isLogarithmic() - yPositive = plot.getYAxis()._isLogarithmic() - 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, float or None - """ - if isinstance(self._xerror, numpy.ndarray): - return numpy.array(self._xerror, copy=copy) - else: - return self._xerror # float or None - - 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, float or None - """ - if isinstance(self._yerror, numpy.ndarray): - return numpy.array(self._yerror, copy=copy) - else: - return self._yerror # float or None - - 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: - if isinstance(xerror, collections.Iterable): - xerror = numpy.array(xerror, copy=copy) - else: - xerror = float(xerror) - if yerror is not None: - if isinstance(yerror, collections.Iterable): - yerror = numpy.array(yerror, copy=copy) - else: - yerror = float(yerror) - # 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 - - # TODO hackish data range implementation - if self.isVisible(): - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - self._updated(ItemChangedType.DATA) diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py deleted file mode 100644 index 80d9dea..0000000 --- a/silx/gui/plot/items/curve.py +++ /dev/null @@ -1,362 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides the :class:`Curve` item of the :class:`Plot`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "24/04/2018" - - -import logging -import numpy - -from silx.third_party import six -from ....utils.deprecation import deprecated -from ... import colors -from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn, - FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType) - - -_logger = logging.getLogger(__name__) - - -class CurveStyle(object): - """Object storing the style of a curve. - - Set a value to None to use the default - - :param color: Color - :param Union[str,None] linestyle: Style of the line - :param Union[float,None] linewidth: Width of the line - :param Union[str,None] symbol: Symbol for markers - :param Union[float,None] symbolsize: Size of the markers - """ - - def __init__(self, color=None, linestyle=None, linewidth=None, - symbol=None, symbolsize=None): - if color is None: - self._color = None - else: - if isinstance(color, six.string_types): - color = colors.rgba(color) - else: # array-like expected - color = numpy.array(color, copy=False) - if color.ndim == 1: # Array is 1D, this is a single color - color = colors.rgba(color) - self._color = color - - if linestyle is not None: - assert linestyle in LineMixIn.getSupportedLineStyles() - self._linestyle = linestyle - - self._linewidth = None if linewidth is None else float(linewidth) - - if symbol is not None: - assert symbol in SymbolMixIn.getSupportedSymbols() - self._symbol = symbol - - self._symbolsize = None if symbolsize is None else float(symbolsize) - - def getColor(self, copy=True): - """Returns the color or None if not set. - - :param bool copy: True to get a copy (default), - False to get internal representation (do not modify!) - - :rtype: Union[List[float],None] - """ - if isinstance(self._color, numpy.ndarray): - return numpy.array(self._color, copy=copy) - else: - return self._color - - def getLineStyle(self): - """Return the type of the line or None if not set. - - Type of line:: - - - ' ' no line - - '-' solid line - - '--' dashed line - - '-.' dash-dot line - - ':' dotted line - - :rtype: Union[str,None] - """ - return self._linestyle - - def getLineWidth(self): - """Return the curve line width in pixels or None if not set. - - :rtype: Union[float,None] - """ - return self._linewidth - - def getSymbol(self): - """Return the point marker type. - - Marker type:: - - - 'o' circle - - '.' point - - ',' pixel - - '+' cross - - 'x' x-cross - - 'd' diamond - - 's' square - - :rtype: Union[str,None] - """ - return self._symbol - - def getSymbolSize(self): - """Return the point marker size in points. - - :rtype: Union[float,None] - """ - return self._symbolsize - - def __eq__(self, other): - if isinstance(other, CurveStyle): - return (numpy.array_equal(self.getColor(), other.getColor()) and - self.getLineStyle() == other.getLineStyle() and - self.getLineWidth() == other.getLineWidth() and - self.getSymbol() == other.getSymbol() and - self.getSymbolSize() == other.getSymbolSize()) - else: - return False - - -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_STYLE = CurveStyle(color='black') - """Default highlight style 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._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE - self._highlighted = False - - self.sigItemChanged.connect(self.__itemChanged) - - def __itemChanged(self, event): - if event == ItemChangedType.YAXIS: - # TODO hackish data range implementation - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - 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 or not numpy.any(numpy.isfinite(xFiltered)): - return None # No data to display, do not add renderer to backend - - style = self.getCurrentStyle() - - return backend.addCurve(xFiltered, yFiltered, self.getLegend(), - color=style.getColor(), - symbol=style.getSymbol(), - linestyle=style.getLineStyle(), - linewidth=style.getLineWidth(), - yaxis=self.getYAxis(), - xerror=xerror, - yerror=yerror, - z=self.getZValue(), - selectable=self.isSelectable(), - fill=self.isFill(), - alpha=self.getAlpha(), - symbolsize=style.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 - """ - visible = bool(visible) - # TODO hackish data range implementation - if self.isVisible() != visible: - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - super(Curve, self).setVisible(visible) - - 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(ItemChangedType.HIGHLIGHTED) - - def getHighlightedStyle(self): - """Returns the highlighted style in use - - :rtype: CurveStyle - """ - return self._highlightStyle - - def setHighlightedStyle(self, style): - """Set the style to use for highlighting - - :param CurveStyle style: New style to use - """ - previous = self.getHighlightedStyle() - if style != previous: - assert isinstance(style, CurveStyle) - self._highlightStyle = style - self._updated(ItemChangedType.HIGHLIGHTED_STYLE) - - # Backward compatibility event - if previous.getColor() != style.getColor(): - self._updated(ItemChangedType.HIGHLIGHTED_COLOR) - - @deprecated(replacement='Curve.getHighlightedStyle().getColor()', - since_version='0.9.0') - def getHighlightedColor(self): - """Returns the RGBA highlight color of the item - - :rtype: 4-tuple of float in [0, 1] - """ - return self.getHighlightedStyle().getColor() - - @deprecated(replacement='Curve.setHighlightedStyle()', - since_version='0.9.0') - 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 - """ - self.setHighlightedStyle(CurveStyle(color)) - - def getCurrentStyle(self): - """Returns the current curve style. - - Curve style depends on curve highlighting - - :rtype: CurveStyle - """ - if self.isHighlighted(): - style = self.getHighlightedStyle() - color = style.getColor() - linestyle = style.getLineStyle() - linewidth = style.getLineWidth() - symbol = style.getSymbol() - symbolsize = style.getSymbolSize() - - return CurveStyle( - color=self.getColor() if color is None else color, - linestyle=self.getLineStyle() if linestyle is None else linestyle, - linewidth=self.getLineWidth() if linewidth is None else linewidth, - symbol=self.getSymbol() if symbol is None else symbol, - symbolsize=self.getSymbolSize() if symbolsize is None else symbolsize) - - else: - return CurveStyle(color=self.getColor(), - linestyle=self.getLineStyle(), - linewidth=self.getLineWidth(), - symbol=self.getSymbol(), - symbolsize=self.getSymbolSize()) - - @deprecated(replacement='Curve.getCurrentStyle()', - since_version='0.9.0') - 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 float in [0, 1] - """ - return self.getCurrentStyle().getColor() diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py deleted file mode 100644 index 389e8a6..0000000 --- a/silx/gui/plot/items/histogram.py +++ /dev/null @@ -1,332 +0,0 @@ -# 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__ = "28/08/2018" - -import logging - -import numpy - -from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn, - LineMixIn, YAxisMixIn, ItemChangedType) - -_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.getXAxis()._isLogarithmic() - yPositive = plot.getYAxis()._isLogarithmic() - 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.getXAxis()._isLogarithmic() - yPositive = plot.getYAxis()._isLogarithmic() - 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 = (edges <= 0) - edges = numpy.array(edges, copy=True, dtype=numpy.float) - edges[clipped_edges] = numpy.nan - clipped_values = numpy.logical_or(clipped_edges[:-1], - clipped_edges[1:]) - else: - clipped_values = numpy.zeros_like(values, dtype=numpy.bool) - - if yPositive: - # Replace values <= 0 by NaN, do not modify edges - clipped_values = numpy.logical_or(clipped_values, values <= 0) - - values[clipped_values] = 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 - """ - visible = bool(visible) - # TODO hackish data range implementation - if self.isVisible() != visible: - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - super(Histogram, self).setVisible(visible) - - 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 - self._alignement = align - - if self.isVisible(): - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - self._updated(ItemChangedType.DATA) - - def getAlignment(self): - """ - - :return: histogram alignement. Value in ('center', 'left', 'right'). - """ - return self._alignement - - def _revertComputeEdges(self, 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': - return edges[1:] - if histogramType is 'center': - edges = (edges[1:] + edges[:-1]) / 2.0 - if histogramType is 'right': - width = 1 - if len(x) > 1: - width = x[-1] + x[-2] - edges = edges[:-1] - return edges diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py deleted file mode 100644 index 99a916a..0000000 --- a/silx/gui/plot/items/image.py +++ /dev/null @@ -1,421 +0,0 @@ -# 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__ = "20/10/2017" - - -from collections import Sequence -import logging - -import numpy - -from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, - AlphaMixIn, ItemChangedType) - - -_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 != 'uint8': - 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 - """ - visible = bool(visible) - # TODO hackish data range implementation - if self.isVisible() != visible: - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - super(ImageBase, self).setVisible(visible) - - def _isPlotLinear(self, plot): - """Return True if plot only uses linear scale for both of x and y - axes.""" - linear = plot.getXAxis().LINEAR - if plot.getXAxis().getScale() != linear: - return False - if plot.getYAxis().getScale() != linear: - return False - return True - - 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 not self._isPlotLinear(plot): - 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 - - # TODO hackish data range implementation - if self.isVisible(): - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - self._updated(ItemChangedType.POSITION) - - 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 - - # TODO hackish data range implementation - if self.isVisible(): - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - self._updated(ItemChangedType.SCALE) - - -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 not self._isPlotLinear(plot): - # Do not render with non linear scales - return None - - 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 = colormap.applyToData(self.getData(copy=False)) - 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 - if data.dtype.kind == 'b': - _logger.warning( - 'Converting boolean image to int8 to plot it.') - data = numpy.array(data, copy=False, dtype=numpy.int8) - elif numpy.iscomplexobj(data): - _logger.warning( - 'Converting complex image to absolute value to plot it.') - data = numpy.absolute(data) - 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 - - # TODO hackish data range implementation - if self.isVisible(): - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - self._updated(ItemChangedType.DATA) - - -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 not self._isPlotLinear(plot): - # Do not render with non linear scales - return None - - 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 - - # TODO hackish data range implementation - if self.isVisible(): - plot = self.getPlot() - if plot is not None: - plot._invalidateDataRange() - - self._updated(ItemChangedType.DATA) - - -class MaskImageData(ImageData): - """Description of an image used as a mask. - - This class is used to flag mask items. This information is used to improve - internal silx widgets. - """ - pass diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py deleted file mode 100644 index 09767a5..0000000 --- a/silx/gui/plot/items/marker.py +++ /dev/null @@ -1,261 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides markers item of the :class:`Plot`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "06/03/2017" - - -import logging - -from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, - ItemChangedType) - - -_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 _addRendererCall(self, backend, - symbol=None, linestyle='-', linewidth=1): - """Perform the update of the backend renderer""" - 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, - linestyle=linestyle, - linewidth=linewidth, - constraint=self.getConstraint()) - - def _addBackendRenderer(self, backend): - """Update backend renderer""" - raise NotImplementedError() - - 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(ItemChangedType.TEXT) - - 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(ItemChangedType.POSITION) - - 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 _addBackendRenderer(self, backend): - return self._addRendererCall(backend, symbol=self.getSymbol()) - - 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 _LineMarker(_BaseMarker, LineMixIn): - """Base class for line markers""" - - def __init__(self): - _BaseMarker.__init__(self) - LineMixIn.__init__(self) - - def _addBackendRenderer(self, backend): - return self._addRendererCall(backend, - linestyle=self.getLineStyle(), - linewidth=self.getLineWidth()) - - -class XMarker(_LineMarker): - """Description of a marker""" - - def __init__(self): - _LineMarker.__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(ItemChangedType.POSITION) - - -class YMarker(_LineMarker): - """Description of a marker""" - - def __init__(self): - _LineMarker.__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(ItemChangedType.POSITION) diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py deleted file mode 100644 index f55ef91..0000000 --- a/silx/gui/plot/items/roi.py +++ /dev/null @@ -1,1416 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides ROI item for the :class:`~silx.gui.plot.PlotWidget`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import functools -import itertools -import logging -import collections -import numpy - -from ....utils.weakref import WeakList -from ... import qt -from .. import items -from ...colors import rgba - - -logger = logging.getLogger(__name__) - - -class RegionOfInterest(qt.QObject): - """Object describing a region of interest in a plot. - - :param QObject parent: - The RegionOfInterestManager that created this object - """ - - _kind = None - """Label for this kind of ROI. - - Should be setted by inherited classes to custom the ROI manager widget. - """ - - sigRegionChanged = qt.Signal() - """Signal emitted everytime the shape or position of the ROI changes""" - - def __init__(self, parent=None): - # Avoid circular dependancy - from ..tools import roi as roi_tools - assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) - super(RegionOfInterest, self).__init__(parent) - self._color = rgba('red') - self._items = WeakList() - self._editAnchors = WeakList() - self._points = None - self._label = '' - self._labelItem = None - self._editable = False - - def __del__(self): - # Clean-up plot items - self._removePlotItems() - - def setParent(self, parent): - """Set the parent of the RegionOfInterest - - :param Union[None,RegionOfInterestManager] parent: - """ - # Avoid circular dependancy - from ..tools import roi as roi_tools - if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)): - raise ValueError('Unsupported parent') - - self._removePlotItems() - super(RegionOfInterest, self).setParent(parent) - self._createPlotItems() - - @classmethod - def _getKind(cls): - """Return an human readable kind of ROI - - :rtype: str - """ - return cls._kind - - def getColor(self): - """Returns the color of this ROI - - :rtype: QColor - """ - return qt.QColor.fromRgbF(*self._color) - - def _getAnchorColor(self, color): - """Returns the anchor color from the base ROI color - - :param Union[numpy.array,Tuple,List]: color - :rtype: Union[numpy.array,Tuple,List] - """ - return color[:3] + (0.5,) - - def setColor(self, color): - """Set the color used for this ROI. - - :param color: The color to use for ROI shape as - either a color name, a QColor, a list of uint8 or float in [0, 1]. - """ - color = rgba(color) - if color != self._color: - self._color = color - - # Update color of shape items in the plot - rgbaColor = rgba(color) - for item in list(self._items): - if isinstance(item, items.ColorMixIn): - item.setColor(rgbaColor) - item = self._getLabelItem() - if isinstance(item, items.ColorMixIn): - item.setColor(rgbaColor) - - rgbaColor = self._getAnchorColor(rgbaColor) - for item in list(self._editAnchors): - if isinstance(item, items.ColorMixIn): - item.setColor(rgbaColor) - - def getLabel(self): - """Returns the label displayed for this ROI. - - :rtype: str - """ - return self._label - - def setLabel(self, label): - """Set the label displayed with this ROI. - - :param str label: The text label to display - """ - label = str(label) - if label != self._label: - self._label = label - self._updateLabelItem(label) - - def isEditable(self): - """Returns whether the ROI is editable by the user or not. - - :rtype: bool - """ - return self._editable - - def setEditable(self, editable): - """Set whether the ROI can be changed interactively. - - :param bool editable: True to allow edition by the user, - False to disable. - """ - editable = bool(editable) - if self._editable != editable: - self._editable = editable - # Recreate plot items - # This can be avoided once marker.setDraggable is public - self._createPlotItems() - - def _getControlPoints(self): - """Returns the current ROI control points. - - It returns an empty tuple if there is currently no ROI. - - :return: Array of (x, y) position in plot coordinates - :rtype: numpy.ndarray - """ - return None if self._points is None else numpy.array(self._points) - - @classmethod - def showFirstInteractionShape(cls): - """Returns True if the shape created by the first interaction and - managed by the plot have to be visible. - - :rtype: bool - """ - return True - - @classmethod - def getFirstInteractionShape(cls): - """Returns the shape kind which will be used by the very first - interaction with the plot. - - This interactions are hardcoded inside the plot - - :rtype: str - """ - return cls._plotShape - - def setFirstShapePoints(self, points): - """"Initialize the ROI using the points from the first interaction. - - This interaction is constains by the plot API and only supports few - shapes. - """ - points = self._createControlPointsFromFirstShape(points) - self._setControlPoints(points) - - def _createControlPointsFromFirstShape(self, points): - """Returns the list of control points from the very first shape - provided. - - This shape is provided by the plot interaction and constained by the - class of the ROI itself. - """ - return points - - def _setControlPoints(self, points): - """Set this ROI control points. - - :param points: Iterable of (x, y) control points - """ - points = numpy.array(points) - - nbPointsChanged = (self._points is None or - points.shape != self._points.shape) - - if nbPointsChanged or not numpy.all(numpy.equal(points, self._points)): - self._points = points - - self._updateShape() - if self._items and not nbPointsChanged: # Update plot items - item = self._getLabelItem() - if item is not None: - markerPos = self._getLabelPosition() - item.setPosition(*markerPos) - - if self._editAnchors: # Update anchors - for anchor, point in zip(self._editAnchors, points): - old = anchor.blockSignals(True) - anchor.setPosition(*point) - anchor.blockSignals(old) - - else: # No items or new point added - # re-create plot items - self._createPlotItems() - - self.sigRegionChanged.emit() - - def _updateShape(self): - """Called when shape must be updated. - - Must be reimplemented if a shape item have to be updated. - """ - return - - def _getLabelPosition(self): - """Compute position of the label - - :return: (x, y) position of the marker - """ - return None - - def _createPlotItems(self): - """Create items displaying the ROI in the plot. - - It first removes any existing plot items. - """ - roiManager = self.parent() - if roiManager is None: - return - plot = roiManager.parent() - - self._removePlotItems() - - legendPrefix = "__RegionOfInterest-%d__" % id(self) - itemIndex = 0 - - controlPoints = self._getControlPoints() - - if self._labelItem is None: - self._labelItem = self._createLabelItem() - if self._labelItem is not None: - self._labelItem._setLegend(legendPrefix + "label") - plot._add(self._labelItem) - - self._items = WeakList() - plotItems = self._createShapeItems(controlPoints) - for item in plotItems: - item._setLegend(legendPrefix + str(itemIndex)) - plot._add(item) - self._items.append(item) - itemIndex += 1 - - self._editAnchors = WeakList() - if self.isEditable(): - plotItems = self._createAnchorItems(controlPoints) - color = rgba(self.getColor()) - color = self._getAnchorColor(color) - for index, item in enumerate(plotItems): - item._setLegend(legendPrefix + str(itemIndex)) - item.setColor(color) - plot._add(item) - item.sigItemChanged.connect(functools.partial( - self._controlPointAnchorChanged, index)) - self._editAnchors.append(item) - itemIndex += 1 - - def _updateLabelItem(self, label): - """Update the marker displaying the label. - - Inherite this method to custom the way the ROI display the label. - - :param str label: The new label to use - """ - item = self._getLabelItem() - if item is not None: - item.setText(label) - - def _createLabelItem(self): - """Returns a created marker which will be used to dipslay the label of - this ROI. - - Inherite this method to return nothing if no new items have to be - created, or your own marker. - - :rtype: Union[None,Marker] - """ - # Add label marker - markerPos = self._getLabelPosition() - marker = items.Marker() - marker.setPosition(*markerPos) - marker.setText(self.getLabel()) - marker.setColor(rgba(self.getColor())) - marker.setSymbol('') - marker._setDraggable(False) - return marker - - def _getLabelItem(self): - """Returns the marker displaying the label of this ROI. - - Inherite this method to choose your own item. In case this item is also - a control point. - """ - return self._labelItem - - def _createShapeItems(self, points): - """Create shape items from the current control points. - - :rtype: List[PlotItem] - """ - return [] - - def _createAnchorItems(self, points): - """Create anchor items from the current control points. - - :rtype: List[Marker] - """ - return [] - - def _controlPointAnchorChanged(self, index, event): - """Handle update of position of an edition anchor - - :param int index: Index of the anchor - :param ItemChangedType event: Event type - """ - if event == items.ItemChangedType.POSITION: - anchor = self._editAnchors[index] - previous = self._points[index].copy() - current = anchor.getPosition() - self._controlPointAnchorPositionChanged(index, current, previous) - - def _controlPointAnchorPositionChanged(self, index, current, previous): - """Called when an anchor is manually edited. - - This function have to be inherited to change the behaviours of the - control points. This function have to call :meth:`_getControlPoints` to - reach the previous state of the control points. Updated the positions - of the changed control points. Then call :meth:`_setControlPoints` to - update the anchors and send signals. - """ - points = self._getControlPoints() - points[index] = current - self._setControlPoints(points) - - def _removePlotItems(self): - """Remove items from their plot.""" - for item in itertools.chain(list(self._items), - list(self._editAnchors)): - - plot = item.getPlot() - if plot is not None: - plot._remove(item) - self._items = WeakList() - self._editAnchors = WeakList() - - if self._labelItem is not None: - item = self._labelItem - plot = item.getPlot() - if plot is not None: - plot._remove(item) - self._labelItem = None - - def __str__(self): - """Returns parameters of the ROI as a string.""" - points = self._getControlPoints() - params = '; '.join('(%f; %f)' % (pt[0], pt[1]) for pt in points) - return "%s(%s)" % (self.__class__.__name__, params) - - -class PointROI(RegionOfInterest): - """A ROI identifying a point in a 2D plot.""" - - _kind = "Point" - """Label for this kind of ROI""" - - _plotShape = "point" - """Plot shape which is used for the first interaction""" - - def getPosition(self): - """Returns the position of this ROI - - :rtype: numpy.ndarray - """ - return self._points[0].copy() - - def setPosition(self, pos): - """Set the position of this ROI - - :param numpy.ndarray pos: 2d-coordinate of this point - """ - controlPoints = numpy.array([pos]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None - - def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: - item = self._items[0] - item.setText(label) - - def _createShapeItems(self, points): - if self.isEditable(): - return [] - marker = items.Marker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) - marker.setColor(rgba(self.getColor())) - marker._setDraggable(False) - return [marker] - - def _createAnchorItems(self, points): - marker = items.Marker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) - marker._setDraggable(self.isEditable()) - return [marker] - - def __str__(self): - points = self._getControlPoints() - params = '%f %f' % (points[0, 0], points[0, 1]) - return "%s(%s)" % (self.__class__.__name__, params) - - -class LineROI(RegionOfInterest): - """A ROI identifying a line in a 2D plot. - - This ROI provides 1 anchor for each boundary of the line, plus an center - in the center to translate the full ROI. - """ - - _kind = "Line" - """Label for this kind of ROI""" - - _plotShape = "line" - """Plot shape which is used for the first interaction""" - - def _createControlPointsFromFirstShape(self, points): - center = numpy.mean(points, axis=0) - controlPoints = numpy.array([points[0], points[1], center]) - return controlPoints - - def setEndPoints(self, startPoint, endPoint): - """Set this line location using the endding points - - :param numpy.ndarray startPoint: Staring bounding point of the line - :param numpy.ndarray endPoint: Endding bounding point of the line - """ - assert(startPoint.shape == (2,) and endPoint.shape == (2,)) - shapePoints = numpy.array([startPoint, endPoint]) - controlPoints = self._createControlPointsFromFirstShape(shapePoints) - self._setControlPoints(controlPoints) - - def getEndPoints(self): - """Returns bounding points of this ROI. - - :rtype: Tuple(numpy.ndarray,numpy.ndarray) - """ - startPoint = self._points[0].copy() - endPoint = self._points[1].copy() - return (startPoint, endPoint) - - def _getLabelPosition(self): - points = self._getControlPoints() - return points[-1] - - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - points = self._getShapeFromControlPoints(points) - shape.setPoints(points) - - def _getShapeFromControlPoints(self, points): - # Remove the center from the control points - return points[0:2] - - def _createShapeItems(self, points): - shapePoints = self._getShapeFromControlPoints(points) - item = items.Shape("polylines") - item.setPoints(shapePoints) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - return [item] - - def _createAnchorItems(self, points): - anchors = [] - for point in points[0:-1]: - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol('s') - anchor._setDraggable(True) - anchors.append(anchor) - - # Add an anchor to the center of the rectangle - center = numpy.mean(points, axis=0) - anchor = items.Marker() - anchor.setPosition(*center) - anchor.setText('') - anchor.setSymbol('+') - anchor._setDraggable(True) - anchors.append(anchor) - - return anchors - - def _controlPointAnchorPositionChanged(self, index, current, previous): - if index == len(self._editAnchors) - 1: - # It is the center anchor - points = self._getControlPoints() - center = numpy.mean(points[0:-1], axis=0) - offset = current - previous - points[-1] = current - points[0:-1] = points[0:-1] + offset - self._setControlPoints(points) - else: - # Update the center - points = self._getControlPoints() - points[index] = current - center = numpy.mean(points[0:-1], axis=0) - points[-1] = center - self._setControlPoints(points) - - def __str__(self): - points = self._getControlPoints() - params = points[0][0], points[0][1], points[1][0], points[1][1] - params = 'start: %f %f; end: %f %f' % params - return "%s(%s)" % (self.__class__.__name__, params) - - -class HorizontalLineROI(RegionOfInterest): - """A ROI identifying an horizontal line in a 2D plot.""" - - _kind = "HLine" - """Label for this kind of ROI""" - - _plotShape = "hline" - """Plot shape which is used for the first interaction""" - - def _createControlPointsFromFirstShape(self, points): - points = numpy.array([(float('nan'), points[0, 1])], - dtype=numpy.float64) - return points - - def getPosition(self): - """Returns the position of this line if the horizontal axis - - :rtype: float - """ - return self._points[0, 1] - - def setPosition(self, pos): - """Set the position of this ROI - - :param float pos: Horizontal position of this line - """ - controlPoints = numpy.array([[float('nan'), pos]]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None - - def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: - item = self._items[0] - item.setText(label) - - def _updateShape(self): - if not self.isEditable(): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) - - def _createShapeItems(self, points): - if self.isEditable(): - return [] - marker = items.YMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) - marker.setColor(rgba(self.getColor())) - marker._setDraggable(False) - return [marker] - - def _createAnchorItems(self, points): - marker = items.YMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) - marker._setDraggable(self.isEditable()) - return [marker] - - def __str__(self): - points = self._getControlPoints() - params = 'y: %f' % points[0, 1] - return "%s(%s)" % (self.__class__.__name__, params) - - -class VerticalLineROI(RegionOfInterest): - """A ROI identifying a vertical line in a 2D plot.""" - - _kind = "VLine" - """Label for this kind of ROI""" - - _plotShape = "vline" - """Plot shape which is used for the first interaction""" - - def _createControlPointsFromFirstShape(self, points): - points = numpy.array([(points[0, 0], float('nan'))], - dtype=numpy.float64) - return points - - def getPosition(self): - """Returns the position of this line if the horizontal axis - - :rtype: float - """ - return self._points[0, 0] - - def setPosition(self, pos): - """Set the position of this ROI - - :param float pos: Horizontal position of this line - """ - controlPoints = numpy.array([[pos, float('nan')]]) - self._setControlPoints(controlPoints) - - def _createLabelItem(self): - return None - - def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: - item = self._items[0] - item.setText(label) - - def _updateShape(self): - if not self.isEditable(): - if len(self._items) > 0: - controlPoints = self._getControlPoints() - item = self._items[0] - item.setPosition(*controlPoints[0]) - - def _createShapeItems(self, points): - if self.isEditable(): - return [] - marker = items.XMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) - marker.setColor(rgba(self.getColor())) - marker._setDraggable(False) - return [marker] - - def _createAnchorItems(self, points): - marker = items.XMarker() - marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) - marker._setDraggable(self.isEditable()) - return [marker] - - def __str__(self): - points = self._getControlPoints() - params = 'x: %f' % points[0, 0] - return "%s(%s)" % (self.__class__.__name__, params) - - -class RectangleROI(RegionOfInterest): - """A ROI identifying a rectangle in a 2D plot. - - This ROI provides 1 anchor for each corner, plus an anchor in the - center to translate the full ROI. - """ - - _kind = "Rectangle" - """Label for this kind of ROI""" - - _plotShape = "rectangle" - """Plot shape which is used for the first interaction""" - - def _createControlPointsFromFirstShape(self, points): - point0 = points[0] - point1 = points[1] - - # 4 corners - controlPoints = numpy.array([ - point0[0], point0[1], - point0[0], point1[1], - point1[0], point1[1], - point1[0], point0[1], - ]) - # Central - center = numpy.mean(points, axis=0) - controlPoints = numpy.append(controlPoints, center) - controlPoints.shape = -1, 2 - return controlPoints - - def getCenter(self): - """Returns the central point of this rectangle - - :rtype: numpy.ndarray([float,float]) - """ - return numpy.mean(self._points, axis=0) - - def getOrigin(self): - """Returns the corner point with the smaller coordinates - - :rtype: numpy.ndarray([float,float]) - """ - return numpy.min(self._points, axis=0) - - def getSize(self): - """Returns the size of this rectangle - - :rtype: numpy.ndarray([float,float]) - """ - minPoint = numpy.min(self._points, axis=0) - maxPoint = numpy.max(self._points, axis=0) - return maxPoint - minPoint - - def setOrigin(self, position): - """Set the origin position of this ROI - - :param numpy.ndarray position: Location of the smaller corner of the ROI - """ - size = self.getSize() - self.setGeometry(origin=position, size=size) - - def setSize(self, size): - """Set the size of this ROI - - :param numpy.ndarray size: Size of the center of the ROI - """ - origin = self.getOrigin() - self.setGeometry(origin=origin, size=size) - - def setCenter(self, position): - """Set the size of this ROI - - :param numpy.ndarray position: Location of the center of the ROI - """ - size = self.getSize() - self.setGeometry(center=position, size=size) - - def setGeometry(self, origin=None, size=None, center=None): - """Set the geometry of the ROI - """ - if origin is not None: - origin = numpy.array(origin) - size = numpy.array(size) - points = numpy.array([origin, origin + size]) - controlPoints = self._createControlPointsFromFirstShape(points) - elif center is not None: - center = numpy.array(center) - size = numpy.array(size) - points = numpy.array([center - size * 0.5, center + size * 0.5]) - controlPoints = self._createControlPointsFromFirstShape(points) - else: - raise ValueError("Origin or cengter expected") - self._setControlPoints(controlPoints) - - def _getLabelPosition(self): - points = self._getControlPoints() - return points.min(axis=0) - - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - points = self._getShapeFromControlPoints(points) - shape.setPoints(points) - - def _getShapeFromControlPoints(self, points): - minPoint = points.min(axis=0) - maxPoint = points.max(axis=0) - return numpy.array([minPoint, maxPoint]) - - def _createShapeItems(self, points): - shapePoints = self._getShapeFromControlPoints(points) - item = items.Shape("rectangle") - item.setPoints(shapePoints) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - return [item] - - def _createAnchorItems(self, points): - # Remove the center control point - points = points[0:-1] - - anchors = [] - for point in points: - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol('s') - anchor._setDraggable(True) - anchors.append(anchor) - - # Add an anchor to the center of the rectangle - center = numpy.mean(points, axis=0) - anchor = items.Marker() - anchor.setPosition(*center) - anchor.setText('') - anchor.setSymbol('+') - anchor._setDraggable(True) - anchors.append(anchor) - - return anchors - - def _controlPointAnchorPositionChanged(self, index, current, previous): - if index == len(self._editAnchors) - 1: - # It is the center anchor - points = self._getControlPoints() - center = numpy.mean(points[0:-1], axis=0) - offset = current - previous - points[-1] = current - points[0:-1] = points[0:-1] + offset - self._setControlPoints(points) - else: - # Fix other corners - constrains = [(1, 3), (0, 2), (3, 1), (2, 0)] - constrains = constrains[index] - points = self._getControlPoints() - points[index] = current - points[constrains[0]][0] = current[0] - points[constrains[1]][1] = current[1] - # Update the center - center = numpy.mean(points[0:-1], axis=0) - points[-1] = center - self._setControlPoints(points) - - def __str__(self): - origin = self.getOrigin() - w, h = self.getSize() - params = origin[0], origin[1], w, h - params = 'origin: %f %f; width: %f; height: %f' % params - return "%s(%s)" % (self.__class__.__name__, params) - - -class PolygonROI(RegionOfInterest): - """A ROI identifying a closed polygon in a 2D plot. - - This ROI provides 1 anchor for each point of the polygon. - """ - - _kind = "Polygon" - """Label for this kind of ROI""" - - _plotShape = "polygon" - """Plot shape which is used for the first interaction""" - - def getPoints(self): - """Returns the list of the points of this polygon. - - :rtype: numpy.ndarray - """ - return self._points.copy() - - def setPoints(self, points): - """Set the position of this ROI - - :param numpy.ndarray pos: 2d-coordinate of this point - """ - assert(len(points.shape) == 2 and points.shape[1] == 2) - if len(points) > 0: - controlPoints = numpy.array(points) - else: - controlPoints = numpy.empty((0, 2)) - self._setControlPoints(controlPoints) - - def _getLabelPosition(self): - points = self._getControlPoints() - if len(points) == 0: - # FIXME: we should return none, this polygon have no location - return numpy.array([0, 0]) - return points[numpy.argmin(points[:, 1])] - - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - shape.setPoints(points) - - def _createShapeItems(self, points): - if len(points) == 0: - return [] - else: - item = items.Shape("polygon") - item.setPoints(points) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - return [item] - - def _createAnchorItems(self, points): - anchors = [] - for point in points: - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol('s') - anchor._setDraggable(True) - anchors.append(anchor) - return anchors - - def __str__(self): - points = self._getControlPoints() - params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points) - return "%s(%s)" % (self.__class__.__name__, params) - - -class ArcROI(RegionOfInterest): - """A ROI identifying an arc of a circle with a width. - - This ROI provides 3 anchors to control the curvature, 1 anchor to control - the weigth, and 1 anchor to translate the shape. - """ - - _kind = "Arc" - """Label for this kind of ROI""" - - _plotShape = "line" - """Plot shape which is used for the first interaction""" - - _ArcGeometry = collections.namedtuple('ArcGeometry', ['center', - 'startPoint', 'endPoint', - 'radius', 'weight', - 'startAngle', 'endAngle']) - - def __init__(self, parent=None): - RegionOfInterest.__init__(self, parent=parent) - self._geometry = None - - def _getInternalGeometry(self): - """Returns the object storing the internal geometry of this ROI. - - This geometry is derived from the control points and cached for - efficiency. Calling :meth:`_setControlPoints` invalidate the cache. - """ - if self._geometry is None: - controlPoints = self._getControlPoints() - self._geometry = self._createGeometryFromControlPoint(controlPoints) - return self._geometry - - @classmethod - def showFirstInteractionShape(cls): - return False - - def _getLabelPosition(self): - points = self._getControlPoints() - return points.min(axis=0) - - def _updateShape(self): - if len(self._items) == 0: - return - shape = self._items[0] - points = self._getControlPoints() - points = self._getShapeFromControlPoints(points) - shape.setPoints(points) - - def _controlPointAnchorPositionChanged(self, index, current, previous): - controlPoints = self._getControlPoints() - currentWeigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 - - if index in [0, 2]: - # Moving start or end will maintain the same curvature - # Then we have to custom the curvature control point - startPoint = controlPoints[0] - endPoint = controlPoints[2] - center = (startPoint + endPoint) * 0.5 - normal = (endPoint - startPoint) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - # Compute the coeficient which have to be constrained - if distance != 0: - normal /= distance - midVector = controlPoints[1] - center - constainedCoef = numpy.dot(midVector, normal) / distance - else: - constainedCoef = 1.0 - - # Compute the location of the curvature point - controlPoints[index] = current - startPoint = controlPoints[0] - endPoint = controlPoints[2] - center = (startPoint + endPoint) * 0.5 - normal = (endPoint - startPoint) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - if distance != 0: - # BTW we dont need to divide by the distance here - # Cause we compute normal * distance after all - normal /= distance - midPoint = center + normal * constainedCoef * distance - controlPoints[1] = midPoint - - # The weight have to be fixed - self._updateWeightControlPoint(controlPoints, currentWeigth) - self._setControlPoints(controlPoints) - - elif index == 1: - # The weight have to be fixed - controlPoints[index] = current - self._updateWeightControlPoint(controlPoints, currentWeigth) - self._setControlPoints(controlPoints) - else: - super(ArcROI, self)._controlPointAnchorPositionChanged(index, current, previous) - - def _updateWeightControlPoint(self, controlPoints, weigth): - startPoint = controlPoints[0] - midPoint = controlPoints[1] - endPoint = controlPoints[2] - normal = (endPoint - startPoint) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - if distance != 0: - normal /= distance - controlPoints[3] = midPoint + normal * weigth * 0.5 - - def _createGeometryFromControlPoint(self, controlPoints): - """Returns the geometry of the object""" - weigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2 - if numpy.allclose(controlPoints[0], controlPoints[2]): - # Special arc: It's a closed circle - center = (controlPoints[0] + controlPoints[1]) * 0.5 - radius = numpy.linalg.norm(controlPoints[0] - center) - v = controlPoints[0] - center - startAngle = numpy.angle(complex(v[0], v[1])) - endAngle = startAngle + numpy.pi * 2.0 - return self._ArcGeometry(center, controlPoints[0], controlPoints[2], - radius, weigth, startAngle, endAngle) - - elif numpy.linalg.norm( - numpy.cross(controlPoints[1] - controlPoints[0], - controlPoints[2] - controlPoints[0])) < 1e-5: - # Degenerated arc, it's a rectangle - return self._ArcGeometry(None, controlPoints[0], controlPoints[2], - None, weigth, None, None) - else: - center, radius = self._circleEquation(*controlPoints[:3]) - v = controlPoints[0] - center - startAngle = numpy.angle(complex(v[0], v[1])) - v = controlPoints[1] - center - midAngle = numpy.angle(complex(v[0], v[1])) - v = controlPoints[2] - center - endAngle = numpy.angle(complex(v[0], v[1])) - # Is it clockwise or anticlockwise - if (midAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi) <= numpy.pi: - if endAngle < startAngle: - endAngle += 2 * numpy.pi - else: - if endAngle > startAngle: - endAngle -= 2 * numpy.pi - - return self._ArcGeometry(center, controlPoints[0], controlPoints[2], - radius, weigth, startAngle, endAngle) - - def _isCircle(self, geometry): - """Returns True if the geometry is a closed circle""" - delta = numpy.abs(geometry.endAngle - geometry.startAngle) - return numpy.isclose(delta, numpy.pi * 2) - - def _getShapeFromControlPoints(self, controlPoints): - geometry = self._createGeometryFromControlPoint(controlPoints) - if geometry.center is None: - # It is not an arc - # but we can display it as an the intermediat shape - normal = (geometry.endPoint - geometry.startPoint) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - if distance != 0: - normal /= distance - points = numpy.array([ - geometry.startPoint + normal * geometry.weight * 0.5, - geometry.endPoint + normal * geometry.weight * 0.5, - geometry.endPoint - normal * geometry.weight * 0.5, - geometry.startPoint - normal * geometry.weight * 0.5]) - else: - innerRadius = geometry.radius - geometry.weight * 0.5 - outerRadius = geometry.radius + geometry.weight * 0.5 - - if numpy.isnan(geometry.startAngle): - # Degenerated, it's a point - # At least 2 points are expected - return numpy.array([geometry.startPoint, geometry.startPoint]) - - delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 - if geometry.startAngle == geometry.endAngle: - # Degenerated, it's a line (single radius) - angle = geometry.startAngle - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points = [] - points.append(geometry.center + direction * innerRadius) - points.append(geometry.center + direction * outerRadius) - return numpy.array(points) - - angles = numpy.arange(geometry.startAngle, geometry.endAngle, delta) - if angles[-1] != geometry.endAngle: - angles = numpy.append(angles, geometry.endAngle) - - isCircle = self._isCircle(geometry) - - if isCircle: - if innerRadius <= 0: - # It's a circle - points = [] - numpy.append(angles, angles[-1]) - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.append(geometry.center + direction * outerRadius) - else: - # It's a donut - points = [] - # NOTE: NaN value allow to create 2 separated circle shapes - # using a single plot item. It's a kind of cheat - points.append(numpy.array([float("nan"), float("nan")])) - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.insert(0, geometry.center + direction * innerRadius) - points.append(geometry.center + direction * outerRadius) - points.append(numpy.array([float("nan"), float("nan")])) - else: - if innerRadius <= 0: - # It's a part of camembert - points = [] - points.append(geometry.center) - points.append(geometry.startPoint) - delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1 - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.append(geometry.center + direction * outerRadius) - points.append(geometry.endPoint) - points.append(geometry.center) - else: - # It's a part of donut - points = [] - points.append(geometry.startPoint) - for angle in angles: - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - points.insert(0, geometry.center + direction * innerRadius) - points.append(geometry.center + direction * outerRadius) - points.insert(0, geometry.endPoint) - points.append(geometry.endPoint) - points = numpy.array(points) - - return points - - def _setControlPoints(self, points): - # Invalidate the geometry - self._geometry = None - RegionOfInterest._setControlPoints(self, points) - - def getGeometry(self): - """Returns a tuple containing the geometry of this ROI - - It is a symetric fonction of :meth:`setGeometry`. - - If `startAngle` is smaller than `endAngle` the rotation is clockwise, - else the rotation is anticlockwise. - - :rtype: Tuple[numpy.ndarray,float,float,float,float] - :raise ValueError: In case the ROI can't be representaed as section of - a circle - """ - geometry = self._getInternalGeometry() - if geometry.center is None: - raise ValueError("This ROI can't be represented as a section of circle") - return geometry.center, self.getInnerRadius(), self.getOuterRadius(), geometry.startAngle, geometry.endAngle - - def isClosed(self): - """Returns true if the arc is a closed shape, like a circle or a donut. - - :rtype: bool - """ - geometry = self._getInternalGeometry() - return self._isCircle(geometry) - - def getCenter(self): - """Returns the center of the circle used to draw arcs of this ROI. - - This center is usually outside the the shape itself. - - :rtype: numpy.ndarray - """ - geometry = self._getInternalGeometry() - return geometry.center - - def getStartAngle(self): - """Returns the angle of the start of the section of this ROI (in radian). - - If `startAngle` is smaller than `endAngle` the rotation is clockwise, - else the rotation is anticlockwise. - - :rtype: float - """ - geometry = self._getInternalGeometry() - return geometry.startAngle - - def getEndAngle(self): - """Returns the angle of the end of the section of this ROI (in radian). - - If `startAngle` is smaller than `endAngle` the rotation is clockwise, - else the rotation is anticlockwise. - - :rtype: float - """ - geometry = self._getInternalGeometry() - return geometry.endAngle - - def getInnerRadius(self): - """Returns the radius of the smaller arc used to draw this ROI. - - :rtype: float - """ - geometry = self._getInternalGeometry() - radius = geometry.radius - geometry.weight * 0.5 - if radius < 0: - radius = 0 - return radius - - def getOuterRadius(self): - """Returns the radius of the bigger arc used to draw this ROI. - - :rtype: float - """ - geometry = self._getInternalGeometry() - radius = geometry.radius + geometry.weight * 0.5 - return radius - - def setGeometry(self, center, innerRadius, outerRadius, startAngle, endAngle): - """ - Set the geometry of this arc. - - :param numpy.ndarray center: Center of the circle. - :param float innerRadius: Radius of the smaller arc of the section. - :param float outerRadius: Weight of the bigger arc of the section. - It have to be bigger than `innerRadius` - :param float startAngle: Location of the start of the section (in radian) - :param float endAngle: Location of the end of the section (in radian). - If `startAngle` is smaller than `endAngle` the rotation is clockwise, - else the rotation is anticlockwise. - """ - assert(innerRadius <= outerRadius) - assert(numpy.abs(startAngle - endAngle) <= 2 * numpy.pi) - center = numpy.array(center) - radius = (innerRadius + outerRadius) * 0.5 - weight = outerRadius - innerRadius - geometry = self._ArcGeometry(center, None, None, radius, weight, startAngle, endAngle) - controlPoints = self._createControlPointsFromGeometry(geometry) - self._setControlPoints(controlPoints) - - def _createControlPointsFromGeometry(self, geometry): - if geometry.startPoint or geometry.endPoint: - # Duplication with the angles - raise NotImplementedError("This general case is not implemented") - - angle = geometry.startAngle - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - startPoint = geometry.center + direction * geometry.radius - - angle = geometry.endAngle - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - endPoint = geometry.center + direction * geometry.radius - - angle = (geometry.startAngle + geometry.endAngle) * 0.5 - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - curvaturePoint = geometry.center + direction * geometry.radius - weightPoint = curvaturePoint + direction * geometry.weight * 0.5 - - return numpy.array([startPoint, curvaturePoint, endPoint, weightPoint]) - - def _createControlPointsFromFirstShape(self, points): - # The first shape is a line - point0 = points[0] - point1 = points[1] - - # Compute a non colineate point for the curvature - center = (point1 + point0) * 0.5 - normal = point1 - center - normal = numpy.array((normal[1], -normal[0])) - defaultCurvature = numpy.pi / 5.0 - defaultWeight = 0.20 # percentage - curvaturePoint = center - normal * defaultCurvature - weightPoint = center - normal * defaultCurvature * (1.0 + defaultWeight) - - # 3 corners - controlPoints = numpy.array([ - point0, - curvaturePoint, - point1, - weightPoint - ]) - return controlPoints - - def _createShapeItems(self, points): - shapePoints = self._getShapeFromControlPoints(points) - item = items.Shape("polygon") - item.setPoints(shapePoints) - item.setColor(rgba(self.getColor())) - item.setFill(False) - item.setOverlay(True) - return [item] - - def _createAnchorItems(self, points): - anchors = [] - symbols = ['o', 'o', 'o', 's'] - - for index, point in enumerate(points): - if index in [1, 3]: - constraint = self._arcCurvatureMarkerConstraint - else: - constraint = None - anchor = items.Marker() - anchor.setPosition(*point) - anchor.setText('') - anchor.setSymbol(symbols[index]) - anchor._setDraggable(True) - if constraint is not None: - anchor._setConstraint(constraint) - anchors.append(anchor) - - return anchors - - def _arcCurvatureMarkerConstraint(self, x, y): - """Curvature marker remains on "mediatrice" """ - start = self._points[0] - end = self._points[2] - midPoint = (start + end) / 2. - normal = (end - start) - normal = numpy.array((normal[1], -normal[0])) - distance = numpy.linalg.norm(normal) - if distance != 0: - normal /= distance - v = numpy.dot(normal, (numpy.array((x, y)) - midPoint)) - x, y = midPoint + v * normal - return x, y - - @staticmethod - def _circleEquation(pt1, pt2, pt3): - """Circle equation from 3 (x, y) points - - :return: Position of the center of the circle and the radius - :rtype: Tuple[Tuple[float,float],float] - """ - x, y, z = complex(*pt1), complex(*pt2), complex(*pt3) - w = z - x - w /= y - x - c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x - return ((-c.real, -c.imag), abs(c + x)) - - def __str__(self): - try: - center, innerRadius, outerRadius, startAngle, endAngle = self.getGeometry() - params = center[0], center[1], innerRadius, outerRadius, startAngle, endAngle - params = 'center: %f %f; radius: %f %f; angles: %f %f' % params - except ValueError: - params = "invalid" - return "%s(%s)" % (self.__class__.__name__, params) diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py deleted file mode 100644 index acc74b4..0000000 --- a/silx/gui/plot/items/scatter.py +++ /dev/null @@ -1,193 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides the :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 - - -_logger = logging.getLogger(__name__) - - -class Scatter(Points, ColormapMixIn): - """Description of a scatter""" - - _DEFAULT_SELECTABLE = True - """Default selectable state for scatter plots""" - - _DEFAULT_SYMBOL = 'o' - """Default symbol of the scatter plots""" - - def __init__(self): - Points.__init__(self) - ColormapMixIn.__init__(self) - self._value = () - self.__alpha = None - - 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 = cmap.applyToData(self._value) - - if self.__alpha is not None: - rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8) - - 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 getAlphaData(self, copy=True): - """Returns the alpha (transparency) 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.__alpha, 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, alpha=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 alpha: Values with the transparency (between 0 and 1) - :type alpha: A float, or a numpy.ndarray of float32 - :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 - - if alpha is not None: - # Make sure alpha is an array of float in [0, 1] - alpha = numpy.array(alpha, copy=copy) - assert alpha.ndim == 1 - assert len(x) == len(alpha) - if alpha.dtype.kind != 'f': - alpha = alpha.astype(numpy.float32) - if numpy.any(numpy.logical_or(alpha < 0., alpha > 1.)): - alpha = numpy.clip(alpha, 0., 1.) - self.__alpha = alpha - - # 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 deleted file mode 100644 index 65b26a1..0000000 --- a/silx/gui/plot/items/shape.py +++ /dev/null @@ -1,121 +0,0 @@ -# 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__ = "17/05/2017" - - -import logging - -import numpy - -from .core import (Item, ColorMixIn, FillMixIn, ItemChangedType) - - -_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', 'polylines' - """ - - def __init__(self, type_): - Item.__init__(self) - ColorMixIn.__init__(self) - FillMixIn.__init__(self) - self._overlay = False - assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polylines') - 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(ItemChangedType.OVERLAY) - - def getType(self): - """Returns the type of shape to draw. - - One of: 'hline', 'polygon', 'rectangle', 'vline', 'polylines' - - :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(ItemChangedType.DATA) diff --git a/silx/gui/plot/matplotlib/Colormap.py b/silx/gui/plot/matplotlib/Colormap.py deleted file mode 100644 index 772a473..0000000 --- a/silx/gui/plot/matplotlib/Colormap.py +++ /dev/null @@ -1,232 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# Copyright (C) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ############################################################################*/ -"""Matplotlib's new colormaps""" - -import numpy -import logging -from matplotlib.colors import ListedColormap -import matplotlib.colors -import matplotlib.cm -import silx.resources -from silx.utils.deprecation import deprecated - - -_logger = logging.getLogger(__name__) - -_AVAILABLE_AS_RESOURCE = ('magma', 'inferno', 'plasma', 'viridis') -"""List available colormap name as resources""" - -_AVAILABLE_AS_BUILTINS = ('gray', 'reversed gray', - 'temperature', 'red', 'green', 'blue') -"""List of colormaps available through built-in declarations""" - -_CMAPS = {} -"""Cache colormaps""" - - -@property -def magma(): - return getColormap('magma') - - -@property -def inferno(): - return getColormap('inferno') - - -@property -def plasma(): - return getColormap('plasma') - - -@property -def viridis(): - return getColormap('viridis') - - -def getColormap(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 name in _AVAILABLE_AS_RESOURCE: - filename = silx.resources.resource_filename("gui/colormaps/%s.npy" % name) - data = numpy.load(filename) - lut = ListedColormap(data, name=name) - _CMAPS[name] = lut - return lut - else: - # matplotlib built-in - return matplotlib.cm.get_cmap(name) - - -def getScalarMappable(colormap, data=None): - """Returns matplotlib ScalarMappable corresponding to colormap - - :param :class:`.Colormap` 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.getName() is not None: - cmap = getColormap(colormap.getName()) - - else: # No name, use custom colors - if colormap.getColormapLUT() is None: - raise ValueError( - 'addImage: colormap no name nor list of colors.') - colors = colormap.getColormapLUT() - 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) - - vmin, vmax = colormap.getColormapRange(data) - if colormap.getNormalization().startswith('log'): - norm = matplotlib.colors.LogNorm(vmin, vmax) - else: # Linear normalization - norm = matplotlib.colors.Normalize(vmin, vmax) - - return matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap) - - -@deprecated(replacement='silx.colors.Colormap.applyToData', - since_version='0.8.0') -def applyColormapToData(data, colormap): - """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 :class:`.Colormap`: The colormap to apply - """ - # 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 (colormap.getName() is None and - colormap.getColormapLUT() is not None): - colors = colormap.getColormapLUT() - 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.getNormalization() == 'linear' and - not colormap.isAutoscale() and - colormap.getVMin() == 0 and - colormap.getVMax() == 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__) - - scalarMappable = getScalarMappable(colormap, data) - rgbaImage = scalarMappable.to_rgba(data, bytes=True) - - return rgbaImage - - -def getSupportedColormaps(): - """Get the supported colormap names as a tuple of str. - """ - colormaps = set(matplotlib.cm.datad.keys()) - colormaps.update(_AVAILABLE_AS_BUILTINS) - colormaps.update(_AVAILABLE_AS_RESOURCE) - return tuple(sorted(colormaps)) diff --git a/silx/gui/plot/matplotlib/__init__.py b/silx/gui/plot/matplotlib/__init__.py deleted file mode 100644 index a4dc235..0000000 --- a/silx/gui/plot/matplotlib/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -# 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 - -"""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__ = "02/05/2018" - - -import sys -import logging - - -_logger = logging.getLogger(__name__) - -_matplotlib_already_loaded = 'matplotlib' in sys.modules -"""If true, matplotlib was already loaded""" - -import matplotlib -from ... import qt - - -def _configure(backend, backend_qt4=None, backend_qt5=None, check=False): - """Configure matplotlib using a specific backend. - - It initialize `matplotlib.rcParams` using the requested backend, or check - if it is already configured as requested. - - :param bool check: If true, the function only check that matplotlib - is already initialized as request. If not a warning is emitted. - If `check` is false, matplotlib is initialized. - """ - if check: - valid = matplotlib.rcParams['backend'] == backend - if backend_qt4 is not None: - valid = valid and matplotlib.rcParams['backend.qt4'] == backend_qt4 - if backend_qt5 is not None: - valid = valid and matplotlib.rcParams['backend.qt5'] == backend_qt5 - - if not valid: - _logger.warning('matplotlib already loaded, setting its backend may not work') - else: - matplotlib.rcParams['backend'] = backend - if backend_qt4 is not None: - matplotlib.rcParams['backend.qt4'] = backend_qt4 - if backend_qt5 is not None: - matplotlib.rcParams['backend.qt5'] = backend_qt5 - - -if qt.BINDING == 'PySide': - _configure('Qt4Agg', backend_qt4='PySide', check=_matplotlib_already_loaded) - import matplotlib.backends.backend_qt4agg as backend - -elif qt.BINDING == 'PyQt4': - _configure('Qt4Agg', check=_matplotlib_already_loaded) - import matplotlib.backends.backend_qt4agg as backend - -elif qt.BINDING == 'PySide2': - _configure('Qt5Agg', backend_qt5="PySide2", check=_matplotlib_already_loaded) - import matplotlib.backends.backend_qt5agg as backend - -elif qt.BINDING == 'PyQt5': - _configure('Qt5Agg', check=_matplotlib_already_loaded) - import matplotlib.backends.backend_qt5agg as backend - -else: - backend = None - -if backend is not None: - FigureCanvasQTAgg = backend.FigureCanvasQTAgg # noqa diff --git a/silx/gui/plot/setup.py b/silx/gui/plot/setup.py deleted file mode 100644 index e0b2c91..0000000 --- a/silx/gui/plot/setup.py +++ /dev/null @@ -1,54 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "29/06/2017" - - -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('utils') - config.add_subpackage('matplotlib') - config.add_subpackage('stats') - config.add_subpackage('backends') - config.add_subpackage('backends.glutils') - config.add_subpackage('items') - config.add_subpackage('test') - config.add_subpackage('tools') - config.add_subpackage('tools.profile') - config.add_subpackage('tools.test') - config.add_subpackage('actions') - - return config - - -if __name__ == "__main__": - from numpy.distutils.core import setup - - setup(configuration=configuration) diff --git a/silx/gui/plot/stats/__init__.py b/silx/gui/plot/stats/__init__.py deleted file mode 100644 index 04a5327..0000000 --- a/silx/gui/plot/stats/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# 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. -# -# ###########################################################################*/ -""" -""" - -__authors__ = ["H. Payno"] -__license__ = "MIT" -__date__ = "07/03/2018" - - -from .stats import * diff --git a/silx/gui/plot/stats/stats.py b/silx/gui/plot/stats/stats.py deleted file mode 100644 index a753989..0000000 --- a/silx/gui/plot/stats/stats.py +++ /dev/null @@ -1,491 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides the :class:`Scatter` item of the :class:`Plot`. -""" - -__authors__ = ["H. Payno"] -__license__ = "MIT" -__date__ = "06/06/2018" - - -import numpy -from silx.gui.plot.items.curve import Curve as CurveItem -from silx.gui.plot.items.image import ImageBase as ImageItem -from silx.gui.plot.items.scatter import Scatter as ScatterItem -from silx.gui.plot.items.histogram import Histogram as HistogramItem -from silx.math.combo import min_max -from collections import OrderedDict -import logging - -logger = logging.getLogger(__name__) - - -class Stats(OrderedDict): - """Class to define a set of statistic relative to a dataset - (image, curve...). - - The goal of this class is to avoid multiple recalculation of some - basic operations such as filtering data area where the statistics has to - be apply. - Min and max are also stored because they can be used several time. - - :param List statslist: List of the :class:`Stat` object to be computed. - """ - def __init__(self, statslist=None): - OrderedDict.__init__(self) - _statslist = statslist if not None else [] - if statslist is not None: - for stat in _statslist: - self.add(stat) - - def calculate(self, item, plot, onlimits): - """ - Call all :class:`Stat` object registred and return the result of the - computation. - - :param item: the item for which we want statistics - :param plot: plot containing the item - :param bool onlimits: True if we want to apply statistic only on - visible data. - :return dict: dictionary with :class:`Stat` name as ket and result - of the calculation as value - """ - res = {} - if isinstance(item, CurveItem): - context = _CurveContext(item, plot, onlimits) - elif isinstance(item, ImageItem): - context = _ImageContext(item, plot, onlimits) - elif isinstance(item, ScatterItem): - context = _ScatterContext(item, plot, onlimits) - elif isinstance(item, HistogramItem): - context = _HistogramContext(item, plot, onlimits) - else: - raise ValueError('Item type not managed') - for statName, stat in list(self.items()): - if context.kind not in stat.compatibleKinds: - logger.debug('kind %s not managed by statistic %s' - % (context.kind, stat.name)) - res[statName] = None - else: - res[statName] = stat.calculate(context) - return res - - def __setitem__(self, key, value): - assert isinstance(value, StatBase) - OrderedDict.__setitem__(self, key, value) - - def add(self, stat): - self.__setitem__(key=stat.name, value=stat) - - -class _StatsContext(object): - """ - The context is designed to be a simple buffer and avoid repetition of - calculations that can appear during stats evaluation. - - .. warning:: this class gives access to the data to be used for computation - . It deal with filtering data visible by the user on plot. - The filtering is a simple data sub-sampling. No interpolation - is made to fit data to boundaries. - - :param item: the item for which we want to compute the context - :param str kind: the kind of the item - :param plot: the plot containing the item - :param bool onlimits: True if we want to apply statistic only on - visible data. - """ - def __init__(self, item, kind, plot, onlimits): - assert item - assert plot - assert type(onlimits) is bool - self.kind = kind - self.min = None - self.max = None - self.data = None - self.values = None - self.createContext(item, plot, onlimits) - - def createContext(self, item, plot, onlimits): - raise NotImplementedError("Base class") - - -class _CurveContext(_StatsContext): - """ - StatsContext for :class:`Curve` - - :param item: the item for which we want to compute the context - :param plot: the plot containing the item - :param bool onlimits: True if we want to apply statistic only on - visible data. - """ - def __init__(self, item, plot, onlimits): - _StatsContext.__init__(self, kind='curve', item=item, - plot=plot, onlimits=onlimits) - - def createContext(self, item, plot, onlimits): - xData, yData = item.getData(copy=True)[0:2] - - if onlimits: - minX, maxX = plot.getXAxis().getLimits() - yData = yData[(minX <= xData) & (xData <= maxX)] - xData = xData[(minX <= xData) & (xData <= maxX)] - - self.xData = xData - self.yData = yData - if len(yData) > 0: - self.min, self.max = min_max(yData) - else: - self.min, self.max = None, None - self.data = (xData, yData) - self.values = yData - - -class _HistogramContext(_StatsContext): - """ - StatsContext for :class:`Curve` - - :param item: the item for which we want to compute the context - :param plot: the plot containing the item - :param bool onlimits: True if we want to apply statistic only on - visible data. - """ - def __init__(self, item, plot, onlimits): - _StatsContext.__init__(self, kind='histogram', item=item, - plot=plot, onlimits=onlimits) - - def createContext(self, item, plot, onlimits): - xData, edges = item.getData(copy=True)[0:2] - yData = item._revertComputeEdges(x=edges, histogramType=item.getAlignment()) - if onlimits: - minX, maxX = plot.getXAxis().getLimits() - yData = yData[(minX <= xData) & (xData <= maxX)] - xData = xData[(minX <= xData) & (xData <= maxX)] - - self.xData = xData - self.yData = yData - if len(yData) > 0: - self.min, self.max = min_max(yData) - else: - self.min, self.max = None, None - self.data = (xData, yData) - self.values = yData - - -class _ScatterContext(_StatsContext): - """ - StatsContext for :class:`Scatter` - - :param item: the item for which we want to compute the context - :param plot: the plot containing the item - :param bool onlimits: True if we want to apply statistic only on - visible data. - """ - def __init__(self, item, plot, onlimits): - _StatsContext.__init__(self, kind='scatter', item=item, plot=plot, - onlimits=onlimits) - - def createContext(self, item, plot, onlimits): - xData, yData, valueData, xerror, yerror = item.getData(copy=True) - assert plot - if onlimits: - minX, maxX = plot.getXAxis().getLimits() - minY, maxY = plot.getYAxis().getLimits() - # filter on X axis - valueData = valueData[(minX <= xData) & (xData <= maxX)] - yData = yData[(minX <= xData) & (xData <= maxX)] - xData = xData[(minX <= xData) & (xData <= maxX)] - # filter on Y axis - valueData = valueData[(minY <= yData) & (yData <= maxY)] - xData = xData[(minY <= yData) & (yData <= maxY)] - yData = yData[(minY <= yData) & (yData <= maxY)] - if len(valueData) > 0: - self.min, self.max = min_max(valueData) - else: - self.min, self.max = None, None - self.data = (xData, yData, valueData) - self.values = valueData - - -class _ImageContext(_StatsContext): - """ - StatsContext for :class:`ImageBase` - - :param item: the item for which we want to compute the context - :param plot: the plot containing the item - :param bool onlimits: True if we want to apply statistic only on - visible data. - """ - def __init__(self, item, plot, onlimits): - _StatsContext.__init__(self, kind='image', item=item, - plot=plot, onlimits=onlimits) - - def createContext(self, item, plot, onlimits): - self.origin = item.getOrigin() - self.scale = item.getScale() - self.data = item.getData() - - if onlimits: - minX, maxX = plot.getXAxis().getLimits() - minY, maxY = plot.getYAxis().getLimits() - - XMinBound = int((minX - self.origin[0]) / self.scale[0]) - YMinBound = int((minY - self.origin[1]) / self.scale[1]) - XMaxBound = int((maxX - self.origin[0]) / self.scale[0]) - YMaxBound = int((maxY - self.origin[1]) / self.scale[1]) - - XMinBound = max(XMinBound, 0) - YMinBound = max(YMinBound, 0) - - if XMaxBound <= XMinBound or YMaxBound <= YMinBound: - return self.noDataSelected() - data = item.getData() - self.data = data[YMinBound:YMaxBound + 1, XMinBound:XMaxBound + 1] - else: - self.data = item.getData() - - if self.data.size > 0: - self.min, self.max = min_max(self.data) - else: - self.min, self.max = None, None - self.values = self.data - - -BASIC_COMPATIBLE_KINDS = { - 'curve': CurveItem, - 'image': ImageItem, - 'scatter': ScatterItem, - 'histogram': HistogramItem, -} - - -class StatBase(object): - """ - Base class for defining a statistic. - - :param str name: the name of the statistic. Must be unique. - :param compatibleKinds: the kind of items (curve, scatter...) for which - the statistic apply. - :rtype: List or tuple - """ - def __init__(self, name, compatibleKinds=BASIC_COMPATIBLE_KINDS, description=None): - self.name = name - self.compatibleKinds = compatibleKinds - self.description = description - - def calculate(self, context): - """ - compute the statistic for the given :class:`StatsContext` - - :param context: - :return dict: key is stat name, statistic computed is the dict value - """ - raise NotImplementedError('Base class') - - def getToolTip(self, kind): - """ - If necessary add a tooltip for a stat kind - - :param str kinf: the kind of item the statistic is compute for. - :return: tooltip or None if no tooltip - """ - return None - - -class Stat(StatBase): - """ - Create a StatBase class based on a function pointer. - - :param str name: name of the statistic. Used as id - :param fct: function which should have as unique mandatory parameter the - data. Should be able to adapt to all `kinds` defined as - compatible - :param tuple kinds: the compatible item kinds of the function (curve, - image...) - """ - def __init__(self, name, fct, kinds=BASIC_COMPATIBLE_KINDS): - StatBase.__init__(self, name, kinds) - self._fct = fct - - def calculate(self, context): - if context.kind in self.compatibleKinds: - return self._fct(context.values) - else: - raise ValueError('Kind %s not managed by %s' - '' % (context.kind, self.name)) - - -class StatMin(StatBase): - """ - Compute the minimal value on data - """ - def __init__(self): - StatBase.__init__(self, name='min') - - def calculate(self, context): - return context.min - - -class StatMax(StatBase): - """ - Compute the maximal value on data - """ - def __init__(self): - StatBase.__init__(self, name='max') - - def calculate(self, context): - return context.max - - -class StatDelta(StatBase): - """ - Compute the delta between minimal and maximal on data - """ - def __init__(self): - StatBase.__init__(self, name='delta') - - def calculate(self, context): - return context.max - context.min - - -class StatCoordMin(StatBase): - """ - Compute the first coordinates of the data minimal value - """ - def __init__(self): - StatBase.__init__(self, name='coords min') - - def calculate(self, context): - if context.kind in ('curve', 'histogram'): - return context.xData[numpy.argmin(context.yData)] - elif context.kind == 'scatter': - xData, yData, valueData = context.data - return (xData[numpy.argmin(valueData)], - yData[numpy.argmin(valueData)]) - elif context.kind == 'image': - scaleX, scaleY = context.scale - originX, originY = context.origin - index1D = numpy.argmin(context.data) - ySize = (context.data.shape[1]) - x = index1D % context.data.shape[1] - y = (index1D - x) / ySize - x = x * scaleX + originX - y = y * scaleY + originY - return (x, y) - else: - raise ValueError('kind not managed') - - def getToolTip(self, kind): - if kind in ('scatter', 'image'): - return '(x, y)' - else: - return None - -class StatCoordMax(StatBase): - """ - Compute the first coordinates of the data minimal value - """ - def __init__(self): - StatBase.__init__(self, name='coords max') - - def calculate(self, context): - if context.kind in ('curve', 'histogram'): - return context.xData[numpy.argmax(context.yData)] - elif context.kind == 'scatter': - xData, yData, valueData = context.data - return (xData[numpy.argmax(valueData)], - yData[numpy.argmax(valueData)]) - elif context.kind == 'image': - scaleX, scaleY = context.scale - originX, originY = context.origin - index1D = numpy.argmax(context.data) - ySize = (context.data.shape[1]) - x = index1D % context.data.shape[1] - y = (index1D - x) / ySize - x = x * scaleX + originX - y = y * scaleY + originY - return (x, y) - else: - raise ValueError('kind not managed') - - def getToolTip(self, kind): - if kind in ('scatter', 'image'): - return '(x, y)' - else: - return None - -class StatCOM(StatBase): - """ - Compute data center of mass - """ - def __init__(self): - StatBase.__init__(self, name='COM', description='Center of mass') - - def calculate(self, context): - if context.kind in ('curve', 'histogram'): - xData, yData = context.data - deno = numpy.sum(yData).astype(numpy.float32) - if deno == 0.: - return numpy.nan - else: - return numpy.sum(xData * yData).astype(numpy.float32) / deno - elif context.kind == 'scatter': - xData, yData, values = context.data - deno = numpy.sum(values).astype(numpy.float32) - if deno == 0.: - return numpy.nan, numpy.nan - else: - xcom = numpy.sum(xData * values).astype(numpy.float32) / deno - ycom = numpy.sum(yData * values).astype(numpy.float32) / deno - return (xcom, ycom) - elif context.kind == 'image': - yData = numpy.sum(context.data, axis=1) - xData = numpy.sum(context.data, axis=0) - dataXRange = range(context.data.shape[1]) - dataYRange = range(context.data.shape[0]) - xScale, yScale = context.scale - xOrigin, yOrigin = context.origin - - denoY = numpy.sum(yData) - if denoY == 0.: - ycom = numpy.nan - else: - ycom = numpy.sum(yData * dataYRange) / denoY - ycom = ycom * yScale + yOrigin - - denoX = numpy.sum(xData) - if denoX == 0.: - xcom = numpy.nan - else: - xcom = numpy.sum(xData * dataXRange) / denoX - xcom = xcom * xScale + xOrigin - return (xcom, ycom) - else: - raise ValueError('kind not managed') - - def getToolTip(self, kind): - if kind in ('scatter', 'image'): - return '(x, y)' - else: - return None diff --git a/silx/gui/plot/stats/statshandler.py b/silx/gui/plot/stats/statshandler.py deleted file mode 100644 index 0a62b31..0000000 --- a/silx/gui/plot/stats/statshandler.py +++ /dev/null @@ -1,190 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -""" - -__authors__ = ["H. Payno"] -__license__ = "MIT" -__date__ = "05/06/2018" - - -import logging - -from silx.gui import qt -from silx.gui.plot import stats as statsmdl - -logger = logging.getLogger(__name__) - - -class _FloatItem(qt.QTableWidgetItem): - """Simple QTableWidgetItem allowing ordering on floats""" - - def __init__(self, type=qt.QTableWidgetItem.Type): - qt.QTableWidgetItem.__init__(self, type=type) - - def __lt__(self, other): - return float(self.text()) < float(other.text()) - - -class StatFormatter(object): - """ - Class used to apply format on :class:`Stat` - - :param formatter: the formatter. Defined as str.format() - :param qItemClass: the class inheriting from :class:`QTableWidgetItem` - which will be used to display the result of the - statistic computation. - """ - DEFAULT_FORMATTER = '{0:.3f}' - - def __init__(self, formatter=DEFAULT_FORMATTER, qItemClass=_FloatItem): - self.formatter = formatter - self.tabWidgetItemClass = qItemClass - - def format(self, val): - if self.formatter is None or val is None: - return str(val) - else: - return self.formatter.format(val) - - -class StatsHandler(object): - """ - Give - create: - - * Stats object which will manage the statistic computation - * Associate formatter and :class:`Stat` - - :param statFormatters: Stat and optional formatter. - If elements are given as a tuple, elements - should be (:class:`Stat`, formatter). - Otherwise should be :class:`Stat` elements. - :rtype: List or tuple - """ - - def __init__(self, statFormatters): - self.stats = statsmdl.Stats() - self.formatters = {} - for elmt in statFormatters: - helper = _StatHelper(elmt) - self.add(stat=helper.stat, formatter=helper.statFormatter) - - def add(self, stat, formatter=None): - assert isinstance(stat, statsmdl.StatBase) - self.stats.add(stat) - _formatter = formatter - if type(_formatter) is str: - _formatter = StatFormatter(formatter=_formatter) - self.formatters[stat.name] = _formatter - - def format(self, name, val): - """ - Apply the format for the `name` statistic and the given value - :param name: the name of the associated statistic - :param val: value before formatting - :return: formatted value - """ - if name not in self.formatters: - logger.warning("statistic %s haven't been registred" % name) - return val - else: - if self.formatters[name] is None: - return str(val) - else: - if isinstance(val, (tuple, list)): - res = [] - [res.append(self.formatters[name].format(_val)) for _val in val] - return ', '.join(res) - else: - return self.formatters[name].format(val) - - def calculate(self, item, plot, onlimits): - """ - compute all statistic registred and return the list of formatted - statistics result. - - :param item: item for which we want to compute statistics - :param plot: plot containing the item - :param onlimits: True if we want to compute statistics on visible data - only - :return: list of formatted statistics (as str) - :rtype: dict - """ - res = self.stats.calculate(item, plot, onlimits) - for resName, resValue in list(res.items()): - res[resName] = self.format(resName, res[resName]) - return res - - -class _StatHelper(object): - """ - Helper class to generated the requested StatBase instance and the - associated StatFormatter - """ - def __init__(self, arg): - self.statFormatter = None - self.stat = None - - if isinstance(arg, statsmdl.StatBase): - self.stat = arg - else: - assert len(arg) > 0 - if isinstance(arg[0], statsmdl.StatBase): - self.dealWithStatAndFormatter(arg) - else: - _arg = arg - if isinstance(arg[0], tuple): - _arg = arg[0] - if len(arg) > 1: - self.statFormatter = arg[1] - self.createStatInstanceAndFormatter(_arg) - - def dealWithStatAndFormatter(self, arg): - assert isinstance(arg[0], statsmdl.StatBase) - self.stat = arg[0] - if len(arg) > 2: - raise ValueError('To many argument with %s. At most one ' - 'argument can be associated with the ' - 'BaseStat (the `StatFormatter`') - if len(arg) is 2: - assert isinstance(arg[1], (StatFormatter, type(None), str)) - self.statFormatter = arg[1] - - def createStatInstanceAndFormatter(self, arg): - if type(arg[0]) is not str: - raise ValueError('first element of the tuple should be a string' - ' or a StatBase instance') - if len(arg) is 1: - raise ValueError('A function should be associated with the' - 'stat name') - if len(arg) > 3: - raise ValueError('Two much argument given for defining statistic.' - 'Take at most three arguments (name, function, ' - 'kinds)') - if len(arg) is 2: - self.stat = statsmdl.Stat(name=arg[0], fct=arg[1]) - else: - self.stat = statsmdl.Stat(name=arg[0], fct=arg[1], kinds=arg[2]) diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py deleted file mode 100644 index 89c10c6..0000000 --- a/silx/gui/plot/test/__init__.py +++ /dev/null @@ -1,90 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "23/07/2018" - - -import unittest - -from .._utils import test -from . import testColorBar -from . import testCurvesROIWidget -from . import testStats -from . import testAlphaSlider -from . import testInteraction -from . import testLegendSelector -from . import testMaskToolsWidget -from . import testScatterMaskToolsWidget -from . import testPlotInteraction -from . import testPlotWidgetNoBackend -from . import testPlotWidget -from . import testPlotWindow -from . import testProfile -from . import testStackView -from . import testItem -from . import testUtilsAxis -from . import testLimitConstraints -from . import testComplexImageView -from . import testImageView -from . import testSaveAction -from . import testScatterView -from . import testPixelIntensityHistoAction -from . import testCompareImages - - -def suite(): - # Lazy-loading to avoid cyclic reference - from ..tools import test as testTools - - test_suite = unittest.TestSuite() - test_suite.addTests( - [test.suite(), - testTools.suite(), - testColorBar.suite(), - testCurvesROIWidget.suite(), - testStats.suite(), - testAlphaSlider.suite(), - testInteraction.suite(), - testLegendSelector.suite(), - testMaskToolsWidget.suite(), - testScatterMaskToolsWidget.suite(), - testPlotInteraction.suite(), - testPlotWidgetNoBackend.suite(), - testPlotWidget.suite(), - testPlotWindow.suite(), - testProfile.suite(), - testStackView.suite(), - testItem.suite(), - testUtilsAxis.suite(), - testLimitConstraints.suite(), - testComplexImageView.suite(), - testImageView.suite(), - testSaveAction.suite(), - testScatterView.suite(), - testPixelIntensityHistoAction.suite(), - testCompareImages.suite() - ]) - return test_suite diff --git a/silx/gui/plot/test/testAlphaSlider.py b/silx/gui/plot/test/testAlphaSlider.py deleted file mode 100644 index 63de441..0000000 --- a/silx/gui/plot/test/testAlphaSlider.py +++ /dev/null @@ -1,221 +0,0 @@ -# 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.utils.testutils 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 deleted file mode 100644 index 9a02e04..0000000 --- a/silx/gui/plot/test/testColorBar.py +++ /dev/null @@ -1,351 +0,0 @@ -# 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__ = "24/04/2018" - -import unittest -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot.ColorBar import _ColorScale -from silx.gui.plot.ColorBar import ColorBarWidget -from silx.gui.colors import Colormap -from silx.gui.plot import Plot2D -from silx.gui import qt -import numpy - - -class TestColorScale(TestCaseQt): - """Test that interaction with the colorScale is correct""" - def setUp(self): - super(TestColorScale, self).setUp() - self.colorScaleWidget = _ColorScale(colormap=None, parent=None) - self.colorScaleWidget.show() - self.qWaitForWindowExposed(self.colorScaleWidget) - - def tearDown(self): - self.qapp.processEvents() - self.colorScaleWidget.setAttribute(qt.Qt.WA_DeleteOnClose) - self.colorScaleWidget.close() - del self.colorScaleWidget - super(TestColorScale, self).tearDown() - - def testNoColormap(self): - """Test _ColorScale without a colormap""" - colormap = self.colorScaleWidget.getColormap() - self.assertIsNone(colormap) - - def testRelativePositionLinear(self): - self.colorMapLin1 = Colormap(name='gray', - normalization=Colormap.LINEAR, - 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 = Colormap(name='viridis', - normalization=Colormap.LINEAR, - 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 = Colormap(name='temperature', - normalization=Colormap.LOGARITHM, - 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) - - -class TestNoAutoscale(TestCaseQt): - """Test that ticks and color displayed are correct in the case of a colormap - with no autoscale - """ - - def setUp(self): - super(TestNoAutoscale, self).setUp() - self.plot = Plot2D() - self.colorBar = self.plot.getColorBarWidget() - self.colorBar.setVisible(True) # Makes sure the colormap is visible - self.tickBar = self.colorBar.getColorScaleBar().getTickBar() - self.colorScale = self.colorBar.getColorScaleBar().getColorScale() - - self.plot.show() - self.qWaitForWindowExposed(self.plot) - - def tearDown(self): - self.qapp.processEvents() - self.tickBar = None - self.colorScale = None - del self.colorBar - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - del self.plot - super(TestNoAutoscale, self).tearDown() - - def testLogNormNoAutoscale(self): - colormapLog = Colormap(name='gray', - normalization=Colormap.LOGARITHM, - 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 = Colormap(name='gray', - normalization=Colormap.LINEAR, - 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 ColorBarWidget""" - - def setUp(self): - super(TestColorBarWidget, self).setUp() - self.plot = Plot2D() - self.colorBar = self.plot.getColorBarWidget() - self.colorBar.setVisible(True) # Makes sure the colormap is visible - - self.plot.show() - self.qWaitForWindowExposed(self.plot) - - def tearDown(self): - self.qapp.processEvents() - del self.colorBar - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - 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 = Colormap(name='gray', - normalization=Colormap.LOGARITHM, - vmin=None, - vmax=None) - - 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 with log and negative values: should set vmin - # to 1 and vmax to 10 - self.assertTrue(self.colorBar.getColorScaleBar().minVal == 2) - self.assertTrue(self.colorBar.getColorScaleBar().maxVal == 30) - - # if data is positive - data[data < 1] = data.max() - self.plot.addImage(data=data, - colormap=colormapLog, - legend='toto', - replace=True) - self.plot.setActiveImage('toto') - - self.assertTrue(self.colorBar.getColorScaleBar().minVal == data.min()) - self.assertTrue(self.colorBar.getColorScaleBar().maxVal == data.max()) - - def testPlotAssocation(self): - """Make sure the ColorBarWidget is properly connected with the plot""" - colormap = Colormap(name='gray', - normalization=Colormap.LINEAR, - vmin=None, - vmax=None) - - # make sure that default settings are the same (but a copy of the - self.colorBar.setPlot(self.plot) - self.assertTrue( - self.colorBar.getColormap() is 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() is self.plot.getDefaultColormap()) - self.assertTrue( - self.colorBar.getColormap() is colormap) - - # test that colorbar is updated when default plot colormap changes - self.plot.clear() - plotColormap = Colormap(name='gray', - normalization=Colormap.LOGARITHM, - vmin=None, - vmax=None) - self.plot.setDefaultColormap(plotColormap) - self.assertTrue(self.colorBar.getColormap() is plotColormap) - - def testColormapWithoutRange(self): - """Test with a colormap with vmin==vmax""" - colormap = Colormap(name='gray', - normalization=Colormap.LINEAR, - vmin=1.0, - vmax=1.0) - self.colorBar.setColormap(colormap) - - -class TestColorBarUpdate(TestCaseQt): - """Test that the ColorBar is correctly updated when the signal 'sigChanged' - of the colormap is emitted - """ - - def setUp(self): - super(TestColorBarUpdate, self).setUp() - self.plot = Plot2D() - self.colorBar = self.plot.getColorBarWidget() - self.colorBar.setVisible(True) # Makes sure the colormap is visible - self.colorBar.setPlot(self.plot) - - self.plot.show() - self.qWaitForWindowExposed(self.plot) - self.data = numpy.random.rand(9).reshape(3, 3) - - def tearDown(self): - self.qapp.processEvents() - del self.colorBar - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - del self.plot - super(TestColorBarUpdate, self).tearDown() - - def testUpdateColorMap(self): - colormap = Colormap(name='gray', - normalization='linear', - vmin=0, - vmax=1) - - # check inital state - self.plot.addImage(data=self.data, colormap=colormap, legend='toto') - self.plot.setActiveImage('toto') - - self.assertTrue(self.colorBar.getColorScaleBar().minVal == 0) - self.assertTrue(self.colorBar.getColorScaleBar().maxVal == 1) - self.assertTrue( - self.colorBar.getColorScaleBar().getTickBar()._vmin == 0) - self.assertTrue( - self.colorBar.getColorScaleBar().getTickBar()._vmax == 1) - self.assertTrue( - self.colorBar.getColorScaleBar().getTickBar()._norm == "linear") - - # update colormap - colormap.setVMin(0.5) - self.assertTrue(self.colorBar.getColorScaleBar().minVal == 0.5) - self.assertTrue( - self.colorBar.getColorScaleBar().getTickBar()._vmin == 0.5) - - colormap.setVMax(0.8) - self.assertTrue(self.colorBar.getColorScaleBar().maxVal == 0.8) - self.assertTrue( - self.colorBar.getColorScaleBar().getTickBar()._vmax == 0.8) - - colormap.setNormalization('log') - self.assertTrue( - self.colorBar.getColorScaleBar().getTickBar()._norm == 'log') - - # TODO : should also check that if the colormap is changing then values (especially in log scale) - # should be coherent if in autoscale - - -def suite(): - test_suite = unittest.TestSuite() - for ui in (TestColorScale, TestNoAutoscale, TestColorBarWidget, - TestColorBarUpdate): - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(ui)) - - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/test/testCompareImages.py b/silx/gui/plot/test/testCompareImages.py deleted file mode 100644 index ed6942a..0000000 --- a/silx/gui/plot/test/testCompareImages.py +++ /dev/null @@ -1,117 +0,0 @@ -# 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. -# -# ###########################################################################*/ -"""Tests for CompareImages widget""" - -__authors__ = ["H. Payno"] -__license__ = "MIT" -__date__ = "23/07/2018" - -import unittest -import numpy -import weakref - -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot.CompareImages import CompareImages - - -class TestCompareImages(TestCaseQt): - """Test that CompareImages widget is working in some cases""" - - def setUp(self): - super(TestCompareImages, self).setUp() - self.widget = CompareImages() - - def tearDown(self): - ref = weakref.ref(self.widget) - self.widget = None - self.qWaitForDestroy(ref) - super(TestCompareImages, self).tearDown() - - def testIntensityImage(self): - image1 = numpy.random.rand(10, 10) - image2 = numpy.random.rand(10, 10) - self.widget.setData(image1, image2) - - def testRgbImage(self): - image1 = numpy.random.randint(0, 255, size=(10, 10, 3)) - image2 = numpy.random.randint(0, 255, size=(10, 10, 3)) - self.widget.setData(image1, image2) - - def testRgbaImage(self): - image1 = numpy.random.randint(0, 255, size=(10, 10, 4)) - image2 = numpy.random.randint(0, 255, size=(10, 10, 4)) - self.widget.setData(image1, image2) - - def testVizualisations(self): - image1 = numpy.random.rand(10, 10) - image2 = numpy.random.rand(10, 10) - self.widget.setData(image1, image2) - for mode in CompareImages.VisualizationMode: - self.widget.setVisualizationMode(mode) - - def testAlignemnt(self): - image1 = numpy.random.rand(10, 10) - image2 = numpy.random.rand(5, 5) - self.widget.setData(image1, image2) - for mode in CompareImages.AlignmentMode: - self.widget.setAlignmentMode(mode) - - def testGetPixel(self): - image1 = numpy.random.rand(11, 11) - image2 = numpy.random.rand(5, 5) - image1[5, 5] = 111.111 - image2[2, 2] = 222.222 - self.widget.setData(image1, image2) - expectedValue = {} - expectedValue[CompareImages.AlignmentMode.CENTER] = 222.222 - expectedValue[CompareImages.AlignmentMode.STRETCH] = 222.222 - expectedValue[CompareImages.AlignmentMode.ORIGIN] = None - for mode in expectedValue.keys(): - self.widget.setAlignmentMode(mode) - data = self.widget.getRawPixelData(11 / 2.0, 11 / 2.0) - data1, data2 = data - self.assertEqual(data1, 111.111) - self.assertEqual(data2, expectedValue[mode]) - - def testImageEmpty(self): - self.widget.setData(image1=None, image2=None) - self.assertTrue(self.widget.getRawPixelData(11 / 2.0, 11 / 2.0) == (None, None)) - - def testSetImageSeparately(self): - self.widget.setImage1(numpy.random.rand(10, 10)) - self.widget.setImage2(numpy.random.rand(10, 10)) - for mode in CompareImages.VisualizationMode: - self.widget.setVisualizationMode(mode) - - -def suite(): - test_suite = unittest.TestSuite() - loadTests = unittest.defaultTestLoader.loadTestsFromTestCase - test_suite.addTest(loadTests(TestCompareImages)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/test/testComplexImageView.py b/silx/gui/plot/test/testComplexImageView.py deleted file mode 100644 index 1933a95..0000000 --- a/silx/gui/plot/test/testComplexImageView.py +++ /dev/null @@ -1,95 +0,0 @@ -# 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. -# -# ###########################################################################*/ -"""Test suite for :class:`ComplexImageView`""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "17/01/2018" - - -import unittest -import logging -import numpy - -from silx.utils.testutils import ParametricTestCase -from silx.gui.plot import ComplexImageView - -from .utils import PlotWidgetTestCase - - -logger = logging.getLogger(__name__) - - -class TestComplexImageView(PlotWidgetTestCase, ParametricTestCase): - """Test suite of ComplexImageView widget""" - - def _createPlot(self): - return ComplexImageView.ComplexImageView() - - def testPlot2DComplex(self): - """Test API of ComplexImageView widget""" - data = numpy.array(((0, 1j), (1, 1 + 1j)), dtype=numpy.complex) - self.plot.setData(data) - self.plot.setKeepDataAspectRatio(True) - self.plot.getPlot().resetZoom() - self.qWait(100) - - # Test colormap API - colormap = self.plot.getColormap().copy() - colormap.setName('magma') - self.plot.setColormap(colormap) - self.qWait(100) - - # Test all modes - modes = self.plot.getSupportedVisualizationModes() - for mode in modes: - with self.subTest(mode=mode): - self.plot.setVisualizationMode(mode) - self.qWait(100) - - # Test origin and scale API - self.plot.setScale((2, 1)) - self.qWait(100) - self.plot.setOrigin((1, 1)) - self.qWait(100) - - # Test no data - self.plot.setData(numpy.zeros((0, 0), dtype=numpy.complex)) - self.qWait(100) - - # Test float data - self.plot.setData(numpy.arange(100, dtype=numpy.float).reshape(10, 10)) - self.qWait(100) - - -def suite(): - test_suite = unittest.TestSuite() - test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase( - TestComplexImageView)) - 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 deleted file mode 100644 index 0704779..0000000 --- a/silx/gui/plot/test/testCurvesROIWidget.py +++ /dev/null @@ -1,183 +0,0 @@ -# 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 CurvesROIWidget""" - -__authors__ = ["T. Vincent", "P. Knobel", "H. Payno"] -__license__ = "MIT" -__date__ = "16/11/2017" - - -import logging -import os.path -import unittest -from collections import OrderedDict -import numpy -from silx.gui import qt -from silx.test.utils import temp_dir -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot import PlotWindow, CurvesROIWidget - - -_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 testMiddleMarker(self): - """Test with middle marker enabled""" - self.widget.roiWidget.setMiddleROIMarkerFlag(True) - - # Add a ROI - self.mouseClick(self.widget.roiWidget.addButton, qt.Qt.LeftButton) - - xleftMarker = self.plot._getMarker(legend='ROI min').getXPosition() - xMiddleMarker = self.plot._getMarker(legend='ROI middle').getXPosition() - xRightMarker = self.plot._getMarker(legend='ROI max').getXPosition() - self.assertAlmostEqual(xMiddleMarker, - xleftMarker + (xRightMarker - xleftMarker) / 2.) - - 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 testDeferedInit(self): - x = numpy.arange(100.) - y = numpy.arange(100.) - self.plot.addCurve(x=x, y=y, legend="name", replace="True") - roisDefs = OrderedDict([ - ["range1", - OrderedDict([["from", 20], ["to", 200], ["type", "energy"]])], - ["range2", - OrderedDict([["from", 300], ["to", 500], ["type", "energy"]])] - ]) - - roiWidget = self.plot.getCurvesRoiDockWidget().roiWidget - self.assertFalse(roiWidget._isInit) - self.plot.getCurvesRoiDockWidget().setRois(roisDefs) - self.assertTrue(len(roiWidget.getRois()) is len(roisDefs)) - self.plot.getCurvesRoiDockWidget().setVisible(True) - self.assertTrue(len(roiWidget.getRois()) is len(roisDefs)) - - -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/testImageView.py b/silx/gui/plot/test/testImageView.py deleted file mode 100644 index 3c8d84c..0000000 --- a/silx/gui/plot/test/testImageView.py +++ /dev/null @@ -1,136 +0,0 @@ -# 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. -# -# ###########################################################################*/ -"""Basic tests for PlotWindow""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "24/04/2018" - - -import unittest -import numpy - -from silx.gui import qt -from silx.gui.utils.testutils import TestCaseQt - -from silx.gui.plot import ImageView -from silx.gui.colors import Colormap - - -class TestImageView(TestCaseQt): - """Tests of ImageView widget.""" - - def setUp(self): - super(TestImageView, self).setUp() - self.plot = ImageView() - 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 - self.qapp.processEvents() - super(TestImageView, self).tearDown() - - def testSetImage(self): - """Test setImage""" - image = numpy.arange(100).reshape(10, 10) - - self.plot.setImage(image, reset=True) - self.qWait(100) - self.assertEqual(self.plot.getXAxis().getLimits(), (0, 10)) - self.assertEqual(self.plot.getYAxis().getLimits(), (0, 10)) - - # With reset=False - self.plot.setImage(image[::2, ::2], reset=False) - self.qWait(100) - self.assertEqual(self.plot.getXAxis().getLimits(), (0, 10)) - self.assertEqual(self.plot.getYAxis().getLimits(), (0, 10)) - - self.plot.setImage(image, origin=(10, 20), scale=(2, 4), reset=False) - self.qWait(100) - self.assertEqual(self.plot.getXAxis().getLimits(), (0, 10)) - self.assertEqual(self.plot.getYAxis().getLimits(), (0, 10)) - - # With reset=True - self.plot.setImage(image, origin=(1, 2), scale=(1, 0.5), reset=True) - self.qWait(100) - self.assertEqual(self.plot.getXAxis().getLimits(), (1, 11)) - self.assertEqual(self.plot.getYAxis().getLimits(), (2, 7)) - - self.plot.setImage(image[::2, ::2], reset=True) - self.qWait(100) - self.assertEqual(self.plot.getXAxis().getLimits(), (0, 5)) - self.assertEqual(self.plot.getYAxis().getLimits(), (0, 5)) - - def testColormap(self): - """Test get|setColormap""" - image = numpy.arange(100).reshape(10, 10) - self.plot.setImage(image) - - # Colormap as dict - self.plot.setColormap({'name': 'viridis', - 'normalization': 'log', - 'autoscale': False, - 'vmin': 0, - 'vmax': 1}) - colormap = self.plot.getColormap() - self.assertEqual(colormap.getName(), 'viridis') - self.assertEqual(colormap.getNormalization(), 'log') - self.assertEqual(colormap.getVMin(), 0) - self.assertEqual(colormap.getVMax(), 1) - - # Colormap as keyword arguments - self.plot.setColormap(colormap='magma', - normalization='linear', - autoscale=True, - vmin=1, - vmax=2) - self.assertEqual(colormap.getName(), 'magma') - self.assertEqual(colormap.getNormalization(), 'linear') - self.assertEqual(colormap.getVMin(), None) - self.assertEqual(colormap.getVMax(), None) - - # Update colormap with keyword argument - self.plot.setColormap(normalization='log') - self.assertEqual(colormap.getNormalization(), 'log') - - # Colormap as Colormap object - cmap = Colormap() - self.plot.setColormap(cmap) - self.assertIs(self.plot.getColormap(), cmap) - - -def suite(): - test_suite = unittest.TestSuite() - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(TestImageView)) - 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 deleted file mode 100644 index 074a7cd..0000000 --- a/silx/gui/plot/test/testInteraction.py +++ /dev/null @@ -1,89 +0,0 @@ -# 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/testItem.py b/silx/gui/plot/test/testItem.py deleted file mode 100644 index 993cce7..0000000 --- a/silx/gui/plot/test/testItem.py +++ /dev/null @@ -1,249 +0,0 @@ -# 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 PlotWidget items.""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "01/09/2017" - - -import unittest - -import numpy - -from silx.gui.utils.testutils import SignalListener -from silx.gui.plot.items import ItemChangedType -from .utils import PlotWidgetTestCase - - -class TestSigItemChangedSignal(PlotWidgetTestCase): - """Test item's sigItemChanged signal""" - - def testCurveChanged(self): - """Test sigItemChanged for curve""" - self.plot.addCurve(numpy.arange(10), numpy.arange(10), legend='test') - curve = self.plot.getCurve('test') - - listener = SignalListener() - curve.sigItemChanged.connect(listener) - - # Test for signal in Item class - curve.setVisible(False) - curve.setVisible(True) - curve.setZValue(100) - - # Test for signals in Points class - curve.setData(numpy.arange(100), numpy.arange(100)) - - # SymbolMixIn - curve.setSymbol('Circle') - curve.setSymbol('d') - curve.setSymbolSize(20) - - # AlphaMixIn - curve.setAlpha(0.5) - - # Test for signals in Curve class - # ColorMixIn - curve.setColor('yellow') - # YAxisMixIn - curve.setYAxis('right') - # FillMixIn - curve.setFill(True) - # LineMixIn - curve.setLineStyle(':') - curve.setLineStyle(':') # Not sending event - curve.setLineWidth(2) - - self.assertEqual(listener.arguments(argumentIndex=0), - [ItemChangedType.VISIBLE, - ItemChangedType.VISIBLE, - ItemChangedType.ZVALUE, - ItemChangedType.DATA, - ItemChangedType.SYMBOL, - ItemChangedType.SYMBOL, - ItemChangedType.SYMBOL_SIZE, - ItemChangedType.ALPHA, - ItemChangedType.COLOR, - ItemChangedType.YAXIS, - ItemChangedType.FILL, - ItemChangedType.LINE_STYLE, - ItemChangedType.LINE_WIDTH]) - - def testHistogramChanged(self): - """Test sigItemChanged for Histogram""" - self.plot.addHistogram( - numpy.arange(10), edges=numpy.arange(11), legend='test') - histogram = self.plot.getHistogram('test') - listener = SignalListener() - histogram.sigItemChanged.connect(listener) - - # Test signals in Histogram class - histogram.setData(numpy.zeros(10), numpy.arange(11)) - - self.assertEqual(listener.arguments(argumentIndex=0), - [ItemChangedType.DATA]) - - def testImageDataChanged(self): - """Test sigItemChanged for ImageData""" - self.plot.addImage(numpy.arange(100).reshape(10, 10), legend='test') - image = self.plot.getImage('test') - - listener = SignalListener() - image.sigItemChanged.connect(listener) - - # ColormapMixIn - colormap = self.plot.getDefaultColormap().copy() - image.setColormap(colormap) - image.getColormap().setName('viridis') - - # Test of signals in ImageBase class - image.setOrigin(10) - image.setScale(2) - - # Test of signals in ImageData class - image.setData(numpy.ones((10, 10))) - - self.assertEqual(listener.arguments(argumentIndex=0), - [ItemChangedType.COLORMAP, - ItemChangedType.COLORMAP, - ItemChangedType.POSITION, - ItemChangedType.SCALE, - ItemChangedType.DATA]) - - def testImageRgbaChanged(self): - """Test sigItemChanged for ImageRgba""" - self.plot.addImage(numpy.ones((10, 10, 3)), legend='rgb') - image = self.plot.getImage('rgb') - - listener = SignalListener() - image.sigItemChanged.connect(listener) - - # Test of signals in ImageRgba class - image.setData(numpy.zeros((10, 10, 3))) - - self.assertEqual(listener.arguments(argumentIndex=0), - [ItemChangedType.DATA]) - - def testMarkerChanged(self): - """Test sigItemChanged for markers""" - self.plot.addMarker(10, 20, legend='test') - marker = self.plot._getMarker('test') - - listener = SignalListener() - marker.sigItemChanged.connect(listener) - - # Test signals in _BaseMarker - marker.setPosition(10, 10) - marker.setPosition(10, 10) # Not sending event - marker.setText('toto') - self.assertEqual(listener.arguments(argumentIndex=0), - [ItemChangedType.POSITION, - ItemChangedType.TEXT]) - - # XMarker - self.plot.addXMarker(10, legend='x') - marker = self.plot._getMarker('x') - - listener = SignalListener() - marker.sigItemChanged.connect(listener) - marker.setPosition(20, 20) - self.assertEqual(listener.arguments(argumentIndex=0), - [ItemChangedType.POSITION]) - - # YMarker - self.plot.addYMarker(10, legend='x') - marker = self.plot._getMarker('x') - - listener = SignalListener() - marker.sigItemChanged.connect(listener) - marker.setPosition(20, 20) - self.assertEqual(listener.arguments(argumentIndex=0), - [ItemChangedType.POSITION]) - - def testScatterChanged(self): - """Test sigItemChanged for scatter""" - data = numpy.arange(10) - self.plot.addScatter(data, data, data, legend='test') - scatter = self.plot.getScatter('test') - - listener = SignalListener() - scatter.sigItemChanged.connect(listener) - - # ColormapMixIn - scatter.getColormap().setName('viridis') - data2 = data + 10 - - # Test of signals in Scatter class - scatter.setData(data2, data2, data2) - - self.assertEqual(listener.arguments(), - [(ItemChangedType.COLORMAP,), - (ItemChangedType.DATA,)]) - - def testShapeChanged(self): - """Test sigItemChanged for shape""" - data = numpy.array((1., 10.)) - self.plot.addItem(data, data, legend='test', shape='rectangle') - shape = self.plot._getItem(kind='item', legend='test') - - listener = SignalListener() - shape.sigItemChanged.connect(listener) - - shape.setOverlay(True) - shape.setPoints(((2., 2.), (3., 3.))) - - self.assertEqual(listener.arguments(), - [(ItemChangedType.OVERLAY,), - (ItemChangedType.DATA,)]) - - -class TestSymbol(PlotWidgetTestCase): - """Test item's symbol """ - - def test(self): - """Test sigItemChanged for curve""" - self.plot.addCurve(numpy.arange(10), numpy.arange(10), legend='test') - curve = self.plot.getCurve('test') - - # SymbolMixIn - curve.setSymbol('o') - name = curve.getSymbolName() - self.assertEqual('Circle', name) - - name = curve.getSymbolName('d') - self.assertEqual('Diamond', name) - - -def suite(): - test_suite = unittest.TestSuite() - loadTests = unittest.defaultTestLoader.loadTestsFromTestCase - test_suite.addTest(loadTests(TestSigItemChangedSignal)) - test_suite.addTest(loadTests(TestSymbol)) - 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 deleted file mode 100644 index de5ffde..0000000 --- a/silx/gui/plot/test/testLegendSelector.py +++ /dev/null @@ -1,142 +0,0 @@ -# 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__ = "15/05/2017" - - -import logging -import unittest - -from silx.gui import qt -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot import LegendSelector - - -_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/testLimitConstraints.py b/silx/gui/plot/test/testLimitConstraints.py deleted file mode 100644 index 5e7e0b1..0000000 --- a/silx/gui/plot/test/testLimitConstraints.py +++ /dev/null @@ -1,125 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Test setLimitConstaints on the PlotWidget""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "30/08/2017" - - -import unittest -from silx.gui.plot import PlotWidget - - -class TestLimitConstaints(unittest.TestCase): - """Tests setLimitConstaints class""" - - def setUp(self): - self.plot = PlotWidget() - - def tearDown(self): - self.plot = None - - def testApi(self): - """Test availability of the API""" - self.plot.getXAxis().setLimitsConstraints(minPos=1, maxPos=10) - self.plot.getXAxis().setRangeConstraints(minRange=1, maxRange=1) - self.plot.getYAxis().setLimitsConstraints(minPos=1, maxPos=10) - self.plot.getYAxis().setRangeConstraints(minRange=1, maxRange=1) - - def testXMinMax(self): - """Test limit constains on x-axis""" - self.plot.getXAxis().setLimitsConstraints(minPos=0, maxPos=100) - self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101) - self.assertEqual(self.plot.getXAxis().getLimits(), (0, 100)) - self.assertEqual(self.plot.getYAxis().getLimits(), (-1, 101)) - - def testYMinMax(self): - """Test limit constains on y-axis""" - self.plot.getYAxis().setLimitsConstraints(minPos=0, maxPos=100) - self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101) - self.assertEqual(self.plot.getXAxis().getLimits(), (-1, 101)) - self.assertEqual(self.plot.getYAxis().getLimits(), (0, 100)) - - def testMinXRange(self): - """Test min range constains on x-axis""" - self.plot.getXAxis().setRangeConstraints(minRange=100) - self.plot.setLimits(xmin=1, xmax=99, ymin=1, ymax=99) - limits = self.plot.getXAxis().getLimits() - self.assertEqual(limits[1] - limits[0], 100) - limits = self.plot.getYAxis().getLimits() - self.assertNotEqual(limits[1] - limits[0], 100) - - def testMaxXRange(self): - """Test max range constains on x-axis""" - self.plot.getXAxis().setRangeConstraints(maxRange=100) - self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101) - limits = self.plot.getXAxis().getLimits() - self.assertEqual(limits[1] - limits[0], 100) - limits = self.plot.getYAxis().getLimits() - self.assertNotEqual(limits[1] - limits[0], 100) - - def testMinYRange(self): - """Test min range constains on y-axis""" - self.plot.getYAxis().setRangeConstraints(minRange=100) - self.plot.setLimits(xmin=1, xmax=99, ymin=1, ymax=99) - limits = self.plot.getXAxis().getLimits() - self.assertNotEqual(limits[1] - limits[0], 100) - limits = self.plot.getYAxis().getLimits() - self.assertEqual(limits[1] - limits[0], 100) - - def testMaxYRange(self): - """Test max range constains on y-axis""" - self.plot.getYAxis().setRangeConstraints(maxRange=100) - self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101) - limits = self.plot.getXAxis().getLimits() - self.assertNotEqual(limits[1] - limits[0], 100) - limits = self.plot.getYAxis().getLimits() - self.assertEqual(limits[1] - limits[0], 100) - - def testChangeOfConstraints(self): - """Test changing of the constraints""" - self.plot.getXAxis().setRangeConstraints(minRange=10, maxRange=10) - # There is no more constraints on the range - self.plot.getXAxis().setRangeConstraints(minRange=None, maxRange=None) - self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101) - self.assertEqual(self.plot.getXAxis().getLimits(), (-1, 101)) - - def testSettingConstraints(self): - """Test setting a constaint (setLimits first then the constaint)""" - self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101) - self.plot.getXAxis().setLimitsConstraints(minPos=0, maxPos=100) - self.assertEqual(self.plot.getXAxis().getLimits(), (0, 100)) - - -def suite(): - test_suite = unittest.TestSuite() - loadTests = unittest.defaultTestLoader.loadTestsFromTestCase - test_suite.addTest(loadTests(TestLimitConstaints)) - 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 deleted file mode 100644 index 6912ea3..0000000 --- a/silx/gui/plot/test/testMaskToolsWidget.py +++ /dev/null @@ -1,294 +0,0 @@ -# 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__ = "17/01/2018" - - -import logging -import os.path -import unittest - -import numpy - -from silx.gui import qt -from silx.test.utils import temp_dir -from silx.utils.testutils import ParametricTestCase -from silx.gui.utils.testutils import getQToolButtonFromAction -from silx.gui.plot import PlotWindow, MaskToolsWidget -from .utils import PlotWidgetTestCase - -try: - import fabio -except ImportError: - fabio = None - - -_logger = logging.getLogger(__name__) - - -class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase): - """Basic test for MaskToolsWidget""" - - def _createPlot(self): - return PlotWindow() - - def setUp(self): - super(TestMaskToolsWidget, self).setUp() - self.widget = MaskToolsWidget.MaskToolsDockWidget(plot=self.plot, name='TEST') - self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, self.widget) - self.maskWidget = self.widget.widget() - - def tearDown(self): - del self.maskWidget - del self.widget - 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.getWidgetHandle() - 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=(0, 0)) - self.mouseMove(plot, pos=pos0) - self.mouseClick(plot, qt.Qt.LeftButton, pos=pos0) - self.mouseMove(plot, pos=(0, 0)) - self.mouseMove(plot, pos=pos1) - self.mouseClick(plot, qt.Qt.LeftButton, pos=pos1) - - def _drawPolygon(self): - """Draw a star polygon in the plot""" - plot = self.plot.getWidgetHandle() - 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), - (x, y + offset)] # Close polygon - - self.mouseMove(plot, pos=(0, 0)) - for pos in star: - self.mouseMove(plot, pos=pos) - self.qapp.processEvents() - self.mouseClick(plot, qt.Qt.LeftButton, pos=pos) - self.qapp.processEvents() - - def _drawPencil(self): - """Draw a star polygon in the plot""" - plot = self.plot.getWidgetHandle() - 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=(0, 0)) - self.mouseMove(plot, pos=star[0]) - self.mousePress(plot, qt.Qt.LeftButton, pos=star[0]) - for pos in star[1:]: - 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(30) - 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/testPixelIntensityHistoAction.py b/silx/gui/plot/test/testPixelIntensityHistoAction.py deleted file mode 100644 index 20d1ea2..0000000 --- a/silx/gui/plot/test/testPixelIntensityHistoAction.py +++ /dev/null @@ -1,104 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Basic tests for PixelIntensitiesHistoAction""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "02/03/2018" - - -import numpy -import unittest - -from silx.utils.testutils import ParametricTestCase -from silx.gui.utils.testutils import TestCaseQt, getQToolButtonFromAction -from silx.gui import qt -from silx.gui.plot import Plot2D - - -class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase): - """Tests for PixelIntensitiesHistoAction 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( - unittest.defaultTestLoader.loadTestsFromTestCase( - TestPixelIntensitiesHisto)) - 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 deleted file mode 100644 index 335b1e4..0000000 --- a/silx/gui/plot/test/testPlotInteraction.py +++ /dev/null @@ -1,168 +0,0 @@ -# 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. -# -# ###########################################################################*/ -"""Tests of plot interaction, through a PlotWidget""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "01/09/2017" - - -import unittest -from silx.gui import qt -from .utils import PlotWidgetTestCase - - -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(PlotWidgetTestCase): - """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 (closed) - """ - plot = self.plot.getWidgetHandle() - - dump = _SignalDump() - self.plot.sigPlotSignal.connect(dump) - - for pos in polygon: - self.mouseMove(plot, pos=pos) - self.mouseClick(plot, qt.Qt.LeftButton, 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.getWidgetHandle() - 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), - (xCenter, yCenter + offset)] # Close polygon - - # 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), - (xCenter - offset, yCenter - offset)] # Close polygon - - # 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)] # Close polygon - - # 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)] # Close polygon - - # 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/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py deleted file mode 100644 index 857b9bc..0000000 --- a/silx/gui/plot/test/testPlotWidget.py +++ /dev/null @@ -1,1539 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Basic tests for PlotWidget""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "21/09/2018" - - -import unittest -import logging -import numpy - -from silx.utils.testutils import ParametricTestCase, parameterize -from silx.gui.utils.testutils import SignalListener -from silx.gui.utils.testutils import TestCaseQt -from silx.utils import testutils -from silx.utils import deprecation - -from silx.test.utils import test_options - -from silx.gui import qt -from silx.gui.plot import PlotWidget -from silx.gui.plot.items.curve import CurveStyle -from silx.gui.colors import Colormap - -from .utils import PlotWidgetTestCase - - -SIZE = 1024 -"""Size of the test image""" - -DATA_2D = numpy.arange(SIZE ** 2).reshape(SIZE, SIZE) -"""Image data set""" - - -logger = logging.getLogger(__name__) - - -class TestPlotWidget(PlotWidgetTestCase, 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.getXAxis().setLabel(xlabel) - self.plot.getYAxis().setLabel(ylabel) - self.qapp.processEvents() - - self.assertEqual(self.plot.getGraphTitle(), title) - self.assertEqual(self.plot.getXAxis().getLabel(), xlabel) - self.assertEqual(self.plot.getYAxis().getLabel(), ylabel) - - def _checkLimits(self, - expectedXLim=None, - expectedYLim=None, - expectedRatio=None): - """Assert that limits are as expected""" - xlim = self.plot.getXAxis().getLimits() - ylim = self.plot.getYAxis().getLimits() - 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)) - - def testChangeLimitsWithAspectRatio(self): - self.plot.setKeepDataAspectRatio() - self.qapp.processEvents() - xlim = self.plot.getXAxis().getLimits() - ylim = self.plot.getYAxis().getLimits() - defaultRatio = abs(xlim[1] - xlim[0]) / abs(ylim[1] - ylim[0]) - - self.plot.getXAxis().setLimits(1., 10.) - self._checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio) - self.qapp.processEvents() - self._checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio) - - self.plot.getYAxis().setLimits(1., 10.) - self._checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio) - self.qapp.processEvents() - self._checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio) - - def testResizeWidget(self): - """Test resizing the widget and receiving limitsChanged events""" - self.plot.resize(200, 200) - self.qapp.processEvents() - self.qWait(100) - - xlim = self.plot.getXAxis().getLimits() - ylim = self.plot.getYAxis().getLimits() - - listener = SignalListener() - self.plot.getXAxis().sigLimitsChanged.connect(listener.partial('x')) - self.plot.getYAxis().sigLimitsChanged.connect(listener.partial('y')) - - # Resize without aspect ratio - self.plot.resize(200, 300) - self.qapp.processEvents() - self.qWait(100) - self._checkLimits(expectedXLim=xlim, expectedYLim=ylim) - self.assertEqual(listener.callCount(), 0) - - # Resize with aspect ratio - self.plot.setKeepDataAspectRatio(True) - self.qapp.processEvents() - self.qWait(1000) - listener.clear() # Clean-up received signal - - self.plot.resize(200, 200) - self.qapp.processEvents() - self.qWait(100) - self.assertNotEqual(listener.callCount(), 0) - - def testAddRemoveItemSignals(self): - """Test sigItemAdded and sigItemAboutToBeRemoved""" - listener = SignalListener() - self.plot.sigItemAdded.connect(listener.partial('add')) - self.plot.sigItemAboutToBeRemoved.connect(listener.partial('remove')) - - self.plot.addCurve((1, 2, 3), (3, 2, 1), legend='curve') - self.assertEqual(listener.callCount(), 1) - - curve = self.plot.getCurve('curve') - self.plot.remove('curve') - self.assertEqual(listener.callCount(), 2) - self.assertEqual(listener.arguments(callIndex=0), ('add', curve)) - self.assertEqual(listener.arguments(callIndex=1), ('remove', curve)) - - def testGetItems(self): - """Test getItems method""" - curve_x = 1, 2 - self.plot.addCurve(curve_x, (3, 4)) - image = (0, 1), (2, 3) - self.plot.addImage(image) - scatter_x = 10, 11 - self.plot.addScatter(scatter_x, (12, 13), (0, 1)) - marker_pos = 5, 5 - self.plot.addMarker(*marker_pos) - marker_x = 6 - self.plot.addXMarker(marker_x) - self.plot.addItem((0, 5), (2, 10), shape='rectangle') - - items = self.plot.getItems() - self.assertEqual(len(items), 6) - self.assertTrue(numpy.all(numpy.equal(items[0].getXData(), curve_x))) - self.assertTrue(numpy.all(numpy.equal(items[1].getData(), image))) - self.assertTrue(numpy.all(numpy.equal(items[2].getXData(), scatter_x))) - self.assertTrue(numpy.all(numpy.equal(items[3].getPosition(), marker_pos))) - self.assertTrue(numpy.all(numpy.equal(items[4].getPosition()[0], marker_x))) - self.assertEqual(items[5].getType(), 'rectangle') - -class TestPlotImage(PlotWidgetTestCase, ParametricTestCase): - """Basic tests for addImage""" - - def setUp(self): - super(TestPlotImage, self).setUp() - - self.plot.getYAxis().setLabel('Rows') - self.plot.getXAxis().setLabel('Columns') - - def testPlotColormapTemperature(self): - self.plot.setGraphTitle('Temp. Linear') - - colormap = Colormap(name='temperature', - normalization='linear', - vmin=None, - vmax=None) - self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap) - - def testPlotColormapGray(self): - self.plot.setKeepDataAspectRatio(False) - self.plot.setGraphTitle('Gray Linear') - - colormap = Colormap(name='gray', - normalization='linear', - vmin=None, - vmax=None) - self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap) - - def testPlotColormapTemperatureLog(self): - self.plot.setGraphTitle('Temp. Log') - - colormap = Colormap(name='temperature', - normalization=Colormap.LOGARITHM, - vmin=None, - vmax=None) - 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), - 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), - resetzoom=False) - - self.plot.resetZoom() - - def testPlotColormapCustom(self): - self.plot.setKeepDataAspectRatio(False) - self.plot.setGraphTitle('Custom colormap') - - colormap = Colormap(name=None, - normalization=Colormap.LINEAR, - vmin=None, - vmax=None, - colors=((0., 0., 0.), (1., 0., 0.), - (0., 1., 0.), (0., 0., 1.))) - self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap, - resetzoom=False) - - colormap = Colormap(name=None, - normalization=Colormap.LINEAR, - vmin=None, - vmax=None, - 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), - 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.getXAxis().getLimits() - ymin, ymax = self.plot.getYAxis().getLimits() - 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.getXAxis().getLimits() - ymin, ymax = self.plot.getYAxis().getLimits() - self.assertTrue(round(xmin, 7) <= min(xbounds)) - self.assertTrue(round(xmax, 7) >= max(xbounds)) - self.assertTrue(round(ymin, 7) <= min(ybounds)) - self.assertTrue(round(ymax, 7) >= max(ybounds)) - - self.plot.setKeepDataAspectRatio(False) # Reset aspect ratio - self.plot.clear() - self.plot.resetZoom() - - def testPlotColormapDictAPI(self): - """Test that the addImage API using a colormap dictionary is still - working""" - self.plot.setGraphTitle('Temp. Log') - - colormap = { - 'name': 'temperature', - 'normalization': 'log', - 'vmin': None, - 'vmax': None - } - self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap) - - def testPlotComplexImage(self): - """Test that a complex image is displayed as its absolute value.""" - data = numpy.linspace(1, 1j, 100).reshape(10, 10) - self.plot.addImage(data, legend='complex') - - image = self.plot.getActiveImage() - retrievedData = image.getData(copy=False) - self.assertTrue( - numpy.all(numpy.equal(retrievedData, numpy.absolute(data)))) - - def testPlotBooleanImage(self): - """Test that a boolean image is displayed and converted to int8.""" - data = numpy.zeros((10, 10), dtype=numpy.bool) - data[::2, ::2] = True - self.plot.addImage(data, legend='boolean') - - image = self.plot.getActiveImage() - retrievedData = image.getData(copy=False) - self.assertTrue(numpy.all(numpy.equal(retrievedData, data))) - self.assertIs(retrievedData.dtype.type, numpy.int8) - - -class TestPlotCurve(PlotWidgetTestCase): - """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.getYAxis().setLabel('Rows') - self.plot.getXAxis().setLabel('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() - - # Test updating color array - - # From array to array - newColors = numpy.ones((len(self.xData), 3), dtype=numpy.float32) - self.plot.addCurve(self.xData, self.yData, - legend="curve 2", - replace=False, resetzoom=False, - color=newColors, symbol='o') - - # Array to single color - self.plot.addCurve(self.xData, self.yData, - legend="curve 2", - replace=False, resetzoom=False, - color='green', symbol='o') - - # single color to array - self.plot.addCurve(self.xData, self.yData, - legend="curve 2", - replace=False, resetzoom=False, - color=color, symbol='o') - -class TestPlotMarker(PlotWidgetTestCase): - """Basic tests for add*Marker""" - - def setUp(self): - super(TestPlotMarker, self).setUp() - self.plot.getYAxis().setLabel('Rows') - self.plot.getXAxis().setLabel('Columns') - - self.plot.getXAxis().setAutoScale(False) - self.plot.getYAxis().setAutoScale(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.getYAxis().setInverted(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(PlotWidgetTestCase): - """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.getYAxis().setLabel('Rows') - self.plot.getXAxis().setLabel('Columns') - self.plot.getXAxis().setAutoScale(False) - self.plot.getYAxis().setAutoScale(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(PlotWidgetTestCase): - """Basic tests for active curve and image handling""" - xData = numpy.arange(1000) - yData = -500 + 100 * numpy.sin(xData) - xData2 = xData + 1000 - yData2 = xData - 1000 + 200 * numpy.random.random(1000) - - def tearDown(self): - self.plot.setActiveCurveHandling(False) - super(TestPlotActiveCurveImage, self).tearDown() - - def testActiveCurveAndLabels(self): - # Active curve handling off, no label change - self.plot.setActiveCurveHandling(False) - self.plot.getXAxis().setLabel('XLabel') - self.plot.getYAxis().setLabel('YLabel') - self.plot.addCurve((1, 2), (1, 2)) - self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel') - self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel') - - self.plot.addCurve((1, 2), (2, 3), xlabel='x1', ylabel='y1') - self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel') - self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel') - - self.plot.clear() - self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel') - self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel') - - # Active curve handling on, label changes - self.plot.setActiveCurveHandling(True) - self.plot.getXAxis().setLabel('XLabel') - self.plot.getYAxis().setLabel('YLabel') - - # labels changed as active curve - self.plot.addCurve((1, 2), (1, 2), legend='1', - xlabel='x1', ylabel='y1') - self.plot.setActiveCurve('1') - self.assertEqual(self.plot.getXAxis().getLabel(), 'x1') - self.assertEqual(self.plot.getYAxis().getLabel(), 'y1') - - # labels not changed as not active curve - self.plot.addCurve((1, 2), (2, 3), legend='2') - self.assertEqual(self.plot.getXAxis().getLabel(), 'x1') - self.assertEqual(self.plot.getYAxis().getLabel(), 'y1') - - # labels changed - self.plot.setActiveCurve('2') - self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel') - self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel') - - self.plot.setActiveCurve('1') - self.assertEqual(self.plot.getXAxis().getLabel(), 'x1') - self.assertEqual(self.plot.getYAxis().getLabel(), 'y1') - - self.plot.clear() - self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel') - self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel') - - def testPlotActiveCurveSelectionMode(self): - self.plot.clear() - self.plot.setActiveCurveHandling(True) - legend = "curve 1" - self.plot.addCurve(self.xData, self.yData, - legend=legend, - color="green") - - # active curve should be None - self.assertEqual(self.plot.getActiveCurve(just_legend=True), None) - - # active curve should be None when None is set as active curve - self.plot.setActiveCurve(legend) - current = self.plot.getActiveCurve(just_legend=True) - self.assertEqual(current, legend) - self.plot.setActiveCurve(None) - current = self.plot.getActiveCurve(just_legend=True) - self.assertEqual(current, None) - - # testing it automatically toggles if there is only one - self.plot.setActiveCurveSelectionMode("legacy") - current = self.plot.getActiveCurve(just_legend=True) - self.assertEqual(current, legend) - - # active curve should not change when None set as active curve - self.assertEqual(self.plot.getActiveCurveSelectionMode(), "legacy") - self.plot.setActiveCurve(None) - current = self.plot.getActiveCurve(just_legend=True) - self.assertEqual(current, legend) - - # situation where no curve is active - self.plot.clear() - self.plot.setActiveCurveHandling(True) - self.assertEqual(self.plot.getActiveCurveSelectionMode(), "atmostone") - self.plot.addCurve(self.xData, self.yData, - legend=legend, - color="green") - self.assertEqual(self.plot.getActiveCurve(just_legend=True), None) - self.plot.addCurve(self.xData2, self.yData2, - legend="curve 2", - color="red") - self.assertEqual(self.plot.getActiveCurve(just_legend=True), None) - self.plot.setActiveCurveSelectionMode("legacy") - self.assertEqual(self.plot.getActiveCurve(just_legend=True), None) - - # the first curve added should be active - self.plot.clear() - self.plot.addCurve(self.xData, self.yData, - legend=legend, - color="green") - self.assertEqual(self.plot.getActiveCurve(just_legend=True), legend) - self.plot.addCurve(self.xData2, self.yData2, - legend="curve 2", - color="red") - self.assertEqual(self.plot.getActiveCurve(just_legend=True), legend) - - def testActiveCurveStyle(self): - """Test change of active curve style""" - self.plot.setActiveCurveHandling(True) - self.plot.setActiveCurveStyle(color='black') - style = self.plot.getActiveCurveStyle() - self.assertEqual(style.getColor(), (0., 0., 0., 1.)) - self.assertIsNone(style.getLineStyle()) - self.assertIsNone(style.getLineWidth()) - self.assertIsNone(style.getSymbol()) - self.assertIsNone(style.getSymbolSize()) - - self.plot.addCurve(x=self.xData, y=self.yData, legend="curve1") - curve = self.plot.getCurve("curve1") - curve.setColor('blue') - curve.setLineStyle('-') - curve.setLineWidth(1) - curve.setSymbol('o') - curve.setSymbolSize(5) - - # Check default current style - defaultStyle = curve.getCurrentStyle() - self.assertEqual(defaultStyle, CurveStyle(color='blue', - linestyle='-', - linewidth=1, - symbol='o', - symbolsize=5)) - - # Activate curve with highlight color=black - self.plot.setActiveCurve("curve1") - style = curve.getCurrentStyle() - self.assertEqual(style.getColor(), (0., 0., 0., 1.)) - self.assertEqual(style.getLineStyle(), '-') - self.assertEqual(style.getLineWidth(), 1) - self.assertEqual(style.getSymbol(), 'o') - self.assertEqual(style.getSymbolSize(), 5) - - # Change highlight to linewidth=2 - self.plot.setActiveCurveStyle(linewidth=2) - style = curve.getCurrentStyle() - self.assertEqual(style.getColor(), (0., 0., 1., 1.)) - self.assertEqual(style.getLineStyle(), '-') - self.assertEqual(style.getLineWidth(), 2) - self.assertEqual(style.getSymbol(), 'o') - self.assertEqual(style.getSymbolSize(), 5) - - self.plot.setActiveCurve(None) - self.assertEqual(curve.getCurrentStyle(), defaultStyle) - - def testActiveImageAndLabels(self): - # Active image handling always on, no API for toggling it - self.plot.getXAxis().setLabel('XLabel') - self.plot.getYAxis().setLabel('YLabel') - - # labels changed as active curve - self.plot.addImage(numpy.arange(100).reshape(10, 10), - legend='1', xlabel='x1', ylabel='y1') - self.assertEqual(self.plot.getXAxis().getLabel(), 'x1') - self.assertEqual(self.plot.getYAxis().getLabel(), 'y1') - - # labels not changed as not active curve - self.plot.addImage(numpy.arange(100).reshape(10, 10), - legend='2') - self.assertEqual(self.plot.getXAxis().getLabel(), 'x1') - self.assertEqual(self.plot.getYAxis().getLabel(), 'y1') - - # labels changed - self.plot.setActiveImage('2') - self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel') - self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel') - - self.plot.setActiveImage('1') - self.assertEqual(self.plot.getXAxis().getLabel(), 'x1') - self.assertEqual(self.plot.getYAxis().getLabel(), 'y1') - - self.plot.clear() - self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel') - self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel') - - -############################################################################## -# Log -############################################################################## - -class TestPlotEmptyLog(PlotWidgetTestCase): - """Basic tests for log plot""" - def testEmptyPlotTitleLabelsLog(self): - self.plot.setGraphTitle('Empty Log Log') - self.plot.getXAxis().setLabel('X') - self.plot.getYAxis().setLabel('Y') - self.plot.getXAxis()._setLogarithmic(True) - self.plot.getYAxis()._setLogarithmic(True) - self.plot.resetZoom() - - -class TestPlotAxes(TestCaseQt, ParametricTestCase): - - # Test data - xData = numpy.arange(1, 10) - yData = xData ** 2 - - def __init__(self, methodName='runTest', backend=None): - unittest.TestCase.__init__(self, methodName) - self.__backend = backend - - def setUp(self): - super(TestPlotAxes, self).setUp() - self.plot = PlotWidget(backend=self.__backend) - # It is not needed to display the plot - # It saves a lot of time - # 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(TestPlotAxes, self).tearDown() - - def testDefaultAxes(self): - axis = self.plot.getXAxis() - self.assertEqual(axis.getScale(), axis.LINEAR) - axis = self.plot.getYAxis() - self.assertEqual(axis.getScale(), axis.LINEAR) - axis = self.plot.getYAxis(axis="right") - self.assertEqual(axis.getScale(), axis.LINEAR) - - def testOldPlotAxis_getterSetter(self): - """Test silx API prior to silx 0.6""" - x = self.plot.getXAxis() - y = self.plot.getYAxis() - p = self.plot - - tests = [ - # setters - (p.setGraphXLimits, (10, 20), x.getLimits, (10, 20)), - (p.setGraphYLimits, (10, 20), y.getLimits, (10, 20)), - (p.setGraphXLabel, "foox", x.getLabel, "foox"), - (p.setGraphYLabel, "fooy", y.getLabel, "fooy"), - (p.setYAxisInverted, True, y.isInverted, True), - (p.setXAxisLogarithmic, True, x.getScale, x.LOGARITHMIC), - (p.setYAxisLogarithmic, True, y.getScale, y.LOGARITHMIC), - (p.setXAxisAutoScale, False, x.isAutoScale, False), - (p.setYAxisAutoScale, False, y.isAutoScale, False), - # getters - (x.setLimits, (11, 20), p.getGraphXLimits, (11, 20)), - (y.setLimits, (11, 20), p.getGraphYLimits, (11, 20)), - (x.setLabel, "fooxx", p.getGraphXLabel, "fooxx"), - (y.setLabel, "fooyy", p.getGraphYLabel, "fooyy"), - (y.setInverted, False, p.isYAxisInverted, False), - (x.setScale, x.LINEAR, p.isXAxisLogarithmic, False), - (y.setScale, y.LINEAR, p.isYAxisLogarithmic, False), - (x.setAutoScale, True, p.isXAxisAutoScale, True), - (y.setAutoScale, True, p.isYAxisAutoScale, True), - ] - for testCase in tests: - setter, value, getter, expected = testCase - with self.subTest(): - if setter is not None: - if not isinstance(value, tuple): - value = (value, ) - setter(*value) - if getter is not None: - self.assertEqual(getter(), expected) - - @testutils.test_logging(deprecation.depreclog.name) - def testOldPlotAxis_Logarithmic(self): - """Test silx API prior to silx 0.6""" - x = self.plot.getXAxis() - y = self.plot.getYAxis() - yright = self.plot.getYAxis(axis="right") - - listener = SignalListener() - self.plot.sigSetXAxisLogarithmic.connect(listener.partial("x")) - self.plot.sigSetYAxisLogarithmic.connect(listener.partial("y")) - - self.assertEqual(x.getScale(), x.LINEAR) - self.assertEqual(y.getScale(), x.LINEAR) - self.assertEqual(yright.getScale(), x.LINEAR) - - self.plot.setXAxisLogarithmic(True) - self.assertEqual(x.getScale(), x.LOGARITHMIC) - self.assertEqual(y.getScale(), x.LINEAR) - self.assertEqual(yright.getScale(), x.LINEAR) - self.assertEqual(self.plot.isXAxisLogarithmic(), True) - self.assertEqual(self.plot.isYAxisLogarithmic(), False) - self.assertEqual(listener.arguments(callIndex=-1), ("x", True)) - - self.plot.setYAxisLogarithmic(True) - self.assertEqual(x.getScale(), x.LOGARITHMIC) - self.assertEqual(y.getScale(), x.LOGARITHMIC) - self.assertEqual(yright.getScale(), x.LOGARITHMIC) - self.assertEqual(self.plot.isXAxisLogarithmic(), True) - self.assertEqual(self.plot.isYAxisLogarithmic(), True) - self.assertEqual(listener.arguments(callIndex=-1), ("y", True)) - - yright.setScale(yright.LINEAR) - self.assertEqual(x.getScale(), x.LOGARITHMIC) - self.assertEqual(y.getScale(), x.LINEAR) - self.assertEqual(yright.getScale(), x.LINEAR) - self.assertEqual(self.plot.isXAxisLogarithmic(), True) - self.assertEqual(self.plot.isYAxisLogarithmic(), False) - self.assertEqual(listener.arguments(callIndex=-1), ("y", False)) - - @testutils.test_logging(deprecation.depreclog.name) - def testOldPlotAxis_AutoScale(self): - """Test silx API prior to silx 0.6""" - x = self.plot.getXAxis() - y = self.plot.getYAxis() - yright = self.plot.getYAxis(axis="right") - - listener = SignalListener() - self.plot.sigSetXAxisAutoScale.connect(listener.partial("x")) - self.plot.sigSetYAxisAutoScale.connect(listener.partial("y")) - - self.assertEqual(x.isAutoScale(), True) - self.assertEqual(y.isAutoScale(), True) - self.assertEqual(yright.isAutoScale(), True) - - self.plot.setXAxisAutoScale(False) - self.assertEqual(x.isAutoScale(), False) - self.assertEqual(y.isAutoScale(), True) - self.assertEqual(yright.isAutoScale(), True) - self.assertEqual(self.plot.isXAxisAutoScale(), False) - self.assertEqual(self.plot.isYAxisAutoScale(), True) - self.assertEqual(listener.arguments(callIndex=-1), ("x", False)) - - self.plot.setYAxisAutoScale(False) - self.assertEqual(x.isAutoScale(), False) - self.assertEqual(y.isAutoScale(), False) - self.assertEqual(yright.isAutoScale(), False) - self.assertEqual(self.plot.isXAxisAutoScale(), False) - self.assertEqual(self.plot.isYAxisAutoScale(), False) - self.assertEqual(listener.arguments(callIndex=-1), ("y", False)) - - yright.setAutoScale(True) - self.assertEqual(x.isAutoScale(), False) - self.assertEqual(y.isAutoScale(), True) - self.assertEqual(yright.isAutoScale(), True) - self.assertEqual(self.plot.isXAxisAutoScale(), False) - self.assertEqual(self.plot.isYAxisAutoScale(), True) - self.assertEqual(listener.arguments(callIndex=-1), ("y", True)) - - @testutils.test_logging(deprecation.depreclog.name) - def testOldPlotAxis_Inverted(self): - """Test silx API prior to silx 0.6""" - x = self.plot.getXAxis() - y = self.plot.getYAxis() - yright = self.plot.getYAxis(axis="right") - - listener = SignalListener() - self.plot.sigSetYAxisInverted.connect(listener.partial("y")) - - self.assertEqual(x.isInverted(), False) - self.assertEqual(y.isInverted(), False) - self.assertEqual(yright.isInverted(), False) - - self.plot.setYAxisInverted(True) - self.assertEqual(x.isInverted(), False) - self.assertEqual(y.isInverted(), True) - self.assertEqual(yright.isInverted(), True) - self.assertEqual(self.plot.isYAxisInverted(), True) - self.assertEqual(listener.arguments(callIndex=-1), ("y", True)) - - yright.setInverted(False) - self.assertEqual(x.isInverted(), False) - self.assertEqual(y.isInverted(), False) - self.assertEqual(yright.isInverted(), False) - self.assertEqual(self.plot.isYAxisInverted(), False) - self.assertEqual(listener.arguments(callIndex=-1), ("y", False)) - - def testLogXWithData(self): - 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') - axis = self.plot.getXAxis() - axis.setScale(axis.LOGARITHMIC) - - self.assertEqual(axis.getScale(), axis.LOGARITHMIC) - - def testLogYWithData(self): - 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') - axis = self.plot.getYAxis() - axis.setScale(axis.LOGARITHMIC) - - self.assertEqual(axis.getScale(), axis.LOGARITHMIC) - axis = self.plot.getYAxis(axis="right") - self.assertEqual(axis.getScale(), axis.LOGARITHMIC) - - def testLogYRightWithData(self): - 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') - axis = self.plot.getYAxis(axis="right") - axis.setScale(axis.LOGARITHMIC) - - self.assertEqual(axis.getScale(), axis.LOGARITHMIC) - axis = self.plot.getYAxis() - self.assertEqual(axis.getScale(), axis.LOGARITHMIC) - - def testLimitsChanged_setLimits(self): - self.plot.addCurve(self.xData, self.yData, - legend="curve", - replace=False, resetzoom=False, - color='green', linestyle="-", symbol='o') - listener = SignalListener() - self.plot.getXAxis().sigLimitsChanged.connect(listener.partial(axis="x")) - self.plot.getYAxis().sigLimitsChanged.connect(listener.partial(axis="y")) - self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2")) - self.plot.setLimits(0, 1, 0, 1, 0, 1) - # at least one event per axis - self.assertEqual(len(set(listener.karguments(argumentName="axis"))), 3) - - def testLimitsChanged_resetZoom(self): - self.plot.addCurve(self.xData, self.yData, - legend="curve", - replace=False, resetzoom=False, - color='green', linestyle="-", symbol='o') - listener = SignalListener() - self.plot.getXAxis().sigLimitsChanged.connect(listener.partial(axis="x")) - self.plot.getYAxis().sigLimitsChanged.connect(listener.partial(axis="y")) - self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2")) - self.plot.resetZoom() - # at least one event per axis - self.assertEqual(len(set(listener.karguments(argumentName="axis"))), 3) - - def testLimitsChanged_setXLimit(self): - self.plot.addCurve(self.xData, self.yData, - legend="curve", - replace=False, resetzoom=False, - color='green', linestyle="-", symbol='o') - listener = SignalListener() - axis = self.plot.getXAxis() - axis.sigLimitsChanged.connect(listener) - axis.setLimits(20, 30) - # at least one event per axis - self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0)) - self.assertEqual(axis.getLimits(), (20.0, 30.0)) - - def testLimitsChanged_setYLimit(self): - self.plot.addCurve(self.xData, self.yData, - legend="curve", - replace=False, resetzoom=False, - color='green', linestyle="-", symbol='o') - listener = SignalListener() - axis = self.plot.getYAxis() - axis.sigLimitsChanged.connect(listener) - axis.setLimits(20, 30) - # at least one event per axis - self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0)) - self.assertEqual(axis.getLimits(), (20.0, 30.0)) - - def testLimitsChanged_setYRightLimit(self): - self.plot.addCurve(self.xData, self.yData, - legend="curve", - replace=False, resetzoom=False, - color='green', linestyle="-", symbol='o') - listener = SignalListener() - axis = self.plot.getYAxis(axis="right") - axis.sigLimitsChanged.connect(listener) - axis.setLimits(20, 30) - # at least one event per axis - self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0)) - self.assertEqual(axis.getLimits(), (20.0, 30.0)) - - def testScaleProxy(self): - listener = SignalListener() - y = self.plot.getYAxis() - yright = self.plot.getYAxis(axis="right") - y.sigScaleChanged.connect(listener.partial("left")) - yright.sigScaleChanged.connect(listener.partial("right")) - yright.setScale(yright.LOGARITHMIC) - - self.assertEqual(y.getScale(), y.LOGARITHMIC) - events = listener.arguments() - self.assertEqual(len(events), 2) - self.assertIn(("left", y.LOGARITHMIC), events) - self.assertIn(("right", y.LOGARITHMIC), events) - - def testAutoScaleProxy(self): - listener = SignalListener() - y = self.plot.getYAxis() - yright = self.plot.getYAxis(axis="right") - y.sigAutoScaleChanged.connect(listener.partial("left")) - yright.sigAutoScaleChanged.connect(listener.partial("right")) - yright.setAutoScale(False) - - self.assertEqual(y.isAutoScale(), False) - events = listener.arguments() - self.assertEqual(len(events), 2) - self.assertIn(("left", False), events) - self.assertIn(("right", False), events) - - def testInvertedProxy(self): - listener = SignalListener() - y = self.plot.getYAxis() - yright = self.plot.getYAxis(axis="right") - y.sigInvertedChanged.connect(listener.partial("left")) - yright.sigInvertedChanged.connect(listener.partial("right")) - yright.setInverted(True) - - self.assertEqual(y.isInverted(), True) - events = listener.arguments() - self.assertEqual(len(events), 2) - self.assertIn(("left", True), events) - self.assertIn(("right", True), events) - - def testAxesDisplayedFalse(self): - """Test coverage on setAxesDisplayed(False)""" - self.plot.setAxesDisplayed(False) - - def testAxesDisplayedTrue(self): - """Test coverage on setAxesDisplayed(True)""" - self.plot.setAxesDisplayed(True) - - -class TestPlotCurveLog(PlotWidgetTestCase, ParametricTestCase): - """Basic tests for addCurve with log scale axes""" - - # Test data - xData = numpy.arange(1000) + 1 - yData = xData ** 2 - - def _setLabels(self): - self.plot.getXAxis().setLabel('X') - self.plot.getYAxis().setLabel('X * X') - - def testPlotCurveLogX(self): - self._setLabels() - self.plot.getXAxis()._setLogarithmic(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.getYAxis()._setLogarithmic(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.getXAxis()._setLogarithmic(True) - self.plot.getYAxis()._setLogarithmic(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.getXAxis()._setLogarithmic(True) - self.plot.getYAxis()._setLogarithmic(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.getXAxis().getLimits() - self.assertEqual(xLim, (min(xData), max(xData))) - yLim = self.plot.getYAxis().getLimits() - self.assertEqual(yLim, (min(yData), max(yData))) - - # x axis log - self.plot.getXAxis()._setLogarithmic(True) - self.qapp.processEvents() - - xLim = self.plot.getXAxis().getLimits() - yLim = self.plot.getYAxis().getLimits() - 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.getYAxis()._setLogarithmic(True) - self.qapp.processEvents() - - xLim = self.plot.getXAxis().getLimits() - yLim = self.plot.getYAxis().getLimits() - 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.getXAxis()._setLogarithmic(False) - self.qapp.processEvents() - - xLim = self.plot.getXAxis().getLimits() - yLim = self.plot.getYAxis().getLimits() - 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.getYAxis()._setLogarithmic(False) - self.qapp.processEvents() - - xLim = self.plot.getXAxis().getLimits() - self.assertEqual(xLim, (min(xData), max(xData))) - yLim = self.plot.getYAxis().getLimits() - self.assertEqual(yLim, (min(yData), max(yData))) - - self.plot.clear() - self.plot.resetZoom() - self.qapp.processEvents() - - -class TestPlotImageLog(PlotWidgetTestCase): - """Basic tests for addImage with log scale axes.""" - - def setUp(self): - super(TestPlotImageLog, self).setUp() - - self.plot.getXAxis().setLabel('Columns') - self.plot.getYAxis().setLabel('Rows') - - def testPlotColormapGrayLogX(self): - self.plot.getXAxis()._setLogarithmic(True) - self.plot.setGraphTitle('CMap X: Log Y: Linear') - - colormap = Colormap(name='gray', - normalization='linear', - vmin=None, - vmax=None) - self.plot.addImage(DATA_2D, legend="image 1", - origin=(1., 1.), scale=(1., 1.), - resetzoom=False, colormap=colormap) - self.plot.resetZoom() - - def testPlotColormapGrayLogY(self): - self.plot.getYAxis()._setLogarithmic(True) - self.plot.setGraphTitle('CMap X: Linear Y: Log') - - colormap = Colormap(name='gray', - normalization='linear', - vmin=None, - vmax=None) - self.plot.addImage(DATA_2D, legend="image 1", - origin=(1., 1.), scale=(1., 1.), - resetzoom=False, colormap=colormap) - self.plot.resetZoom() - - def testPlotColormapGrayLogXY(self): - self.plot.getXAxis()._setLogarithmic(True) - self.plot.getYAxis()._setLogarithmic(True) - self.plot.setGraphTitle('CMap X: Log Y: Log') - - colormap = Colormap(name='gray', - normalization='linear', - vmin=None, - vmax=None) - self.plot.addImage(DATA_2D, legend="image 1", - origin=(1., 1.), scale=(1., 1.), - resetzoom=False, colormap=colormap) - self.plot.resetZoom() - - def testPlotRgbRgbaLogXY(self): - self.plot.getXAxis()._setLogarithmic(True) - self.plot.getYAxis()._setLogarithmic(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), - 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.), - resetzoom=False) - self.plot.resetZoom() - - -class TestPlotMarkerLog(PlotWidgetTestCase): - """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.getYAxis().setLabel('Rows') - self.plot.getXAxis().setLabel('Columns') - self.plot.getXAxis().setAutoScale(False) - self.plot.getYAxis().setAutoScale(False) - self.plot.setKeepDataAspectRatio(False) - self.plot.setLimits(1., 100., 1., 1000.) - self.plot.getXAxis()._setLogarithmic(True) - self.plot.getYAxis()._setLogarithmic(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(PlotWidgetTestCase): - """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.getYAxis().setLabel('Rows') - self.plot.getXAxis().setLabel('Columns') - self.plot.getXAxis().setAutoScale(False) - self.plot.getYAxis().setAutoScale(False) - self.plot.setKeepDataAspectRatio(False) - self.plot.setLimits(1., 100., 1., 100.) - self.plot.getXAxis()._setLogarithmic(True) - self.plot.getYAxis()._setLogarithmic(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(): - testClasses = (TestPlotWidget, TestPlotImage, TestPlotCurve, - TestPlotMarker, TestPlotItem, TestPlotAxes, - TestPlotActiveCurveImage, - TestPlotEmptyLog, TestPlotCurveLog, TestPlotImageLog, - TestPlotMarkerLog, TestPlotItemLog) - - test_suite = unittest.TestSuite() - - # Tests with matplotlib - for testClass in testClasses: - test_suite.addTest(parameterize(testClass, backend=None)) - - if test_options.WITH_GL_TEST: - # Tests with OpenGL backend - for testClass in testClasses: - test_suite.addTest(parameterize(testClass, backend='gl')) - - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/test/testPlotWidgetNoBackend.py b/silx/gui/plot/test/testPlotWidgetNoBackend.py deleted file mode 100644 index cd7cbb3..0000000 --- a/silx/gui/plot/test/testPlotWidgetNoBackend.py +++ /dev/null @@ -1,633 +0,0 @@ -# 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 PlotWidget with 'none' backend""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "17/01/2018" - - -import unittest -from functools import reduce -from silx.utils.testutils import ParametricTestCase - -import numpy - -from silx.gui.plot.PlotWidget import PlotWidget -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 = PlotWidget(backend='none') - - title, xlabel, ylabel = 'the title', 'x label', 'y label' - plot.setGraphTitle(title) - plot.getXAxis().setLabel(xlabel) - plot.getYAxis().setLabel(ylabel) - - self.assertEqual(plot.getGraphTitle(), title) - self.assertEqual(plot.getXAxis().getLabel(), xlabel) - self.assertEqual(plot.getYAxis().getLabel(), ylabel) - - def testAddNoRemove(self): - """add objects to the Plot""" - - plot = PlotWidget(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 = PlotWidget(backend='none') - - for logX, logY in ((False, False), - (True, False), - (True, True), - (False, True), - (False, False)): - with self.subTest(logX=logX, logY=logY): - plot.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(logY) - dataRange = plot.getDataRange() - self.assertIsNone(dataRange.x) - self.assertIsNone(dataRange.y) - self.assertIsNone(dataRange.yright) - - def testDataRangeLeft(self): - """left axis range""" - - plot = PlotWidget(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.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(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 = PlotWidget(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.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(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 = PlotWidget(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.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(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 = PlotWidget(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.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(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 = PlotWidget(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.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(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 = PlotWidget(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.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(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 = PlotWidget(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.getXAxis()._setLogarithmic(logX) - plot.getYAxis()._setLogarithmic(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 = PlotWidget(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): - """PlotWidget.getCurve and Plot.getActiveCurve tests""" - - plot = PlotWidget(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 PlotWidget.getCurve and Plot.getActiveCurve tests""" - - plot = PlotWidget(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): - """PlotWidget.getImage and PlotWidget.getActiveImage tests""" - - plot = PlotWidget(backend='none') - - # No image - image = plot.getImage() - self.assertIsNone(image) - - plot.addImage(((0, 1), (2, 3)), legend='image 0') - plot.addImage(((0, 1), (2, 3)), legend='image 1') - - # 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') - 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): - """PlotWidget.getImage and PlotWidget.getActiveImage old API tests""" - - plot = PlotWidget(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!"]) - - # 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): - """PlotWidget.getAllImages test""" - - plot = PlotWidget(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') - plot.addImage(data, origin=(10, 10), legend='2') - 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 = PlotWidget(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): - """PlotWidget.getAllImages test""" - - plot = PlotWidget(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/testPlotWindow.py b/silx/gui/plot/test/testPlotWindow.py deleted file mode 100644 index 6d3eb8f..0000000 --- a/silx/gui/plot/test/testPlotWindow.py +++ /dev/null @@ -1,138 +0,0 @@ -# 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__ = "27/06/2017" - - -import doctest -import unittest - -from silx.gui.utils.testutils 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.getXAxis().isAutoScale), - (self.plot.yAxisAutoScaleAction, self.plot.getYAxis().isAutoScale), - (self.plot.xAxisLogarithmicAction, self.plot.getXAxis()._isLogarithmic), - (self.plot.yAxisLogarithmicAction, self.plot.getYAxis()._isLogarithmic), - (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.getYAxis().isInverted()) - self.plot.yAxisInvertedButton.setYAxisDownward() - self.assertTrue(self.plot.getYAxis().isInverted()) - - -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 deleted file mode 100644 index 847f404..0000000 --- a/silx/gui/plot/test/testProfile.py +++ /dev/null @@ -1,291 +0,0 @@ -# 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__ = "17/01/2018" - -import numpy -import unittest - -from silx.utils.testutils import ParametricTestCase -from silx.gui.utils.testutils 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() - for method in ('sum', 'mean'): - with self.subTest(method=method): - # 2 positions to use for mouse events - pos1 = widget.width() * 0.4, widget.height() * 0.4 - pos2 = widget.width() * 0.6, widget.height() * 0.6 - - for action in (self.toolBar.hLineAction, self.toolBar.vLineAction): - with self.subTest(mode=action.text()): - # Trigger tool button for mode - 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() - - for method in ('sum', 'mean'): - with self.subTest(method=method): - self.toolBar.setProfileMethod(method) - - # 2 positions to use for mouse events - pos1 = widget.width() * 0.4, widget.height() * 0.4 - pos2 = widget.width() * 0.6, widget.height() * 0.6 - - for image in (False, True): - with self.subTest(image=image): - if image: - self.plot.addImage( - numpy.arange(100 * 100).reshape(100, -1)) - - # 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) - self.toolBar.lineWidthSpinBox.setValue(3) - - # draw profile line - 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) - - if image is True: - profileCurve = self.toolBar.getProfilePlot().getAllCurves()[0] - if method == 'sum': - self.assertTrue(profileCurve.getData()[1].max() > 10000) - elif method == 'mean': - self.assertTrue(profileCurve.getData()[1].max() < 10000) - self.plot.clear() - - -class TestProfile3DToolBar(TestCaseQt): - """Tests for Profile3DToolBar widget. - """ - def setUp(self): - super(TestProfile3DToolBar, self).setUp() - self.plot = StackView() - self.plot.show() - self.qWaitForWindowExposed(self.plot) - - self.plot.setStack(numpy.array([ - [[0, 1, 2], [3, 4, 5]], - [[6, 7, 8], [9, 10, 11]], - [[12, 13, 14], [15, 16, 17]] - ])) - - def tearDown(self): - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - self.plot = None - - super(TestProfile3DToolBar, self).tearDown() - - def testMethodProfile1DAnd2D(self): - """Test that the profile can have a different method if we want to - compute then in 1D or in 2D""" - - _3DProfileToolbar = self.plot.getProfileToolbar() - _2DProfilePlot = _3DProfileToolbar.getProfilePlot() - self.plot.getProfileToolbar().setProfileMethod('mean') - self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3) - self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'mean') - - # check 2D 'mean' profile - _3DProfileToolbar.profile3dAction.computeProfileIn2D() - toolButton = getQToolButtonFromAction(_3DProfileToolbar.vLineAction) - self.assertIsNot(toolButton, None) - self.mouseMove(toolButton) - self.mouseClick(toolButton, qt.Qt.LeftButton) - plot2D = self.plot.getPlot().getWidgetHandle() - pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5 - self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1) - self.assertTrue(numpy.array_equal( - _2DProfilePlot.getActiveImage().getData(), - numpy.array([[1, 4], [7, 10], [13, 16]]) - )) - - # check 1D 'sum' profile - _2DProfileToolbar = _2DProfilePlot.getProfileToolbar() - _2DProfileToolbar.setProfileMethod('sum') - self.assertTrue(_2DProfileToolbar.getProfileMethod() == 'sum') - _1DProfilePlot = _2DProfileToolbar.getProfilePlot() - - _2DProfileToolbar.lineWidthSpinBox.setValue(3) - toolButton = getQToolButtonFromAction(_2DProfileToolbar.vLineAction) - self.assertIsNot(toolButton, None) - self.mouseMove(toolButton) - self.mouseClick(toolButton, qt.Qt.LeftButton) - plot1D = _2DProfilePlot.getWidgetHandle() - pos1 = plot1D.width() * 0.5, plot1D.height() * 0.5 - self.mouseClick(plot1D, qt.Qt.LeftButton, pos=pos1) - self.assertTrue(numpy.array_equal( - _1DProfilePlot.getAllCurves()[0].getData()[1], - numpy.array([5, 17, 29]) - )) - - def testMethodSumLine(self): - """Simple interaction test to make sure the sum is correctly computed - """ - _3DProfileToolbar = self.plot.getProfileToolbar() - _2DProfilePlot = _3DProfileToolbar.getProfilePlot() - self.plot.getProfileToolbar().setProfileMethod('sum') - self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3) - self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'sum') - - # check 2D 'mean' profile - _3DProfileToolbar.profile3dAction.computeProfileIn2D() - toolButton = getQToolButtonFromAction(_3DProfileToolbar.lineAction) - self.assertIsNot(toolButton, None) - self.mouseMove(toolButton) - self.mouseClick(toolButton, qt.Qt.LeftButton) - plot2D = self.plot.getPlot().getWidgetHandle() - pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2 - pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8 - - self.mouseMove(plot2D, pos=pos1) - self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1) - self.mouseMove(plot2D, pos=pos2) - self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2) - self.assertTrue(numpy.array_equal( - _2DProfilePlot.getActiveImage().getData(), - numpy.array([[3, 12], [21, 30], [39, 48]]) - )) - - -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) - - 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() - for testClass in (TestProfileToolBar, TestGetProfilePlot, - TestProfile3DToolBar): - test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase( - testClass)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/test/testSaveAction.py b/silx/gui/plot/test/testSaveAction.py deleted file mode 100644 index 85669bf..0000000 --- a/silx/gui/plot/test/testSaveAction.py +++ /dev/null @@ -1,125 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Test the plot's save action (consistency of output)""" - -__authors__ = ["P. Knobel"] -__license__ = "MIT" -__date__ = "28/11/2017" - - -import unittest -import tempfile -import os - -from silx.gui.plot.test.utils import PlotWidgetTestCase - -from silx.gui.plot import PlotWidget -from silx.gui.plot.actions.io import SaveAction - - -class TestSaveActionSaveCurvesAsSpec(unittest.TestCase): - - def setUp(self): - self.plot = PlotWidget(backend='none') - self.saveAction = SaveAction(plot=self.plot) - - self.tempdir = tempfile.mkdtemp() - self.out_fname = os.path.join(self.tempdir, "out.dat") - - def tearDown(self): - os.unlink(self.out_fname) - os.rmdir(self.tempdir) - - def testSaveMultipleCurvesAsSpec(self): - """Test that labels are properly used.""" - self.plot.setGraphXLabel("graph x label") - self.plot.setGraphYLabel("graph y label") - - self.plot.addCurve([0, 1], [1, 2], "curve with labels", - xlabel="curve0 X", ylabel="curve0 Y") - self.plot.addCurve([-1, 3], [-6, 2], "curve with X label", - xlabel="curve1 X") - self.plot.addCurve([-2, 0], [8, 12], "curve with Y label", - ylabel="curve2 Y") - self.plot.addCurve([3, 1], [7, 6], "curve with no labels") - - self.saveAction._saveCurves(self.plot, - self.out_fname, - SaveAction.DEFAULT_ALL_CURVES_FILTERS[0]) # "All curves as SpecFile (*.dat)" - - with open(self.out_fname, "rb") as f: - file_content = f.read() - if hasattr(file_content, "decode"): - file_content = file_content.decode() - - # case with all curve labels specified - self.assertIn("#S 1 curve0 Y", file_content) - self.assertIn("#L curve0 X curve0 Y", file_content) - - # graph X&Y labels are used when no curve label is specified - self.assertIn("#S 2 graph y label", file_content) - self.assertIn("#L curve1 X graph y label", file_content) - - self.assertIn("#S 3 curve2 Y", file_content) - self.assertIn("#L graph x label curve2 Y", file_content) - - self.assertIn("#S 4 graph y label", file_content) - self.assertIn("#L graph x label graph y label", file_content) - - -class TestSaveActionExtension(PlotWidgetTestCase): - """Test SaveAction file filter API""" - - def _dummySaveFunction(self, plot, filename, nameFilter): - pass - - def testFileFilterAPI(self): - """Test addition/update of a file filter""" - saveAction = SaveAction(plot=self.plot, parent=self.plot) - - # Add a new file filter - nameFilter = 'Dummy file (*.dummy)' - saveAction.setFileFilter('all', nameFilter, self._dummySaveFunction) - self.assertTrue(nameFilter in saveAction.getFileFilters('all')) - self.assertEqual(saveAction.getFileFilters('all')[nameFilter], - self._dummySaveFunction) - - # Update an existing file filter - nameFilter = SaveAction.IMAGE_FILTER_EDF - saveAction.setFileFilter('image', nameFilter, self._dummySaveFunction) - self.assertEqual(saveAction.getFileFilters('image')[nameFilter], - self._dummySaveFunction) - - -def suite(): - test_suite = unittest.TestSuite() - for cls in (TestSaveActionSaveCurvesAsSpec, TestSaveActionExtension): - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(cls)) - 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 deleted file mode 100644 index a446911..0000000 --- a/silx/gui/plot/test/testScatterMaskToolsWidget.py +++ /dev/null @@ -1,313 +0,0 @@ -# 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__ = "17/01/2018" - - -import logging -import os.path -import unittest - -import numpy - -from silx.gui import qt -from silx.test.utils import temp_dir -from silx.utils.testutils import ParametricTestCase -from silx.gui.utils.testutils import getQToolButtonFromAction -from silx.gui.plot import PlotWindow, ScatterMaskToolsWidget -from .utils import PlotWidgetTestCase - -try: - import fabio -except ImportError: - fabio = None - - -_logger = logging.getLogger(__name__) - - -class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase): - """Basic test for MaskToolsWidget""" - - def _createPlot(self): - return PlotWindow() - - def setUp(self): - super(TestScatterMaskToolsWidget, self).setUp() - self.widget = ScatterMaskToolsWidget.ScatterMaskToolsDockWidget( - plot=self.plot, name='TEST') - self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, self.widget) - - self.maskWidget = self.widget.widget() - - def tearDown(self): - del self.maskWidget - del self.widget - 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.getWidgetHandle() - 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=(0, 0)) - self.mouseMove(plot, pos=pos0) - self.mouseClick(plot, qt.Qt.LeftButton, pos=pos0) - self.mouseMove(plot, pos=(0, 0)) - self.mouseMove(plot, pos=pos1) - self.mouseClick(plot, qt.Qt.LeftButton, pos=pos1) - - def _drawPolygon(self): - """Draw a star polygon in the plot""" - plot = self.plot.getWidgetHandle() - 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), - (x, y + offset)] # Close polygon - - self.mouseMove(plot, pos=[0, 0]) - for pos in star: - self.mouseMove(plot, pos=pos) - self.qapp.processEvents() - self.mouseClick(plot, qt.Qt.LeftButton, pos=pos) - self.qapp.processEvents() - - def _drawPencil(self): - """Draw a star polygon in the plot""" - plot = self.plot.getWidgetHandle() - 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=[0, 0]) - self.mouseMove(plot, pos=star[0]) - self.mousePress(plot, qt.Qt.LeftButton, pos=star[0]) - for pos in star[1:]: - 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(30) - 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/testScatterView.py b/silx/gui/plot/test/testScatterView.py deleted file mode 100644 index 583e3ed..0000000 --- a/silx/gui/plot/test/testScatterView.py +++ /dev/null @@ -1,134 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Basic tests for ScatterView""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "06/03/2018" - - -import unittest - -import numpy - -from silx.gui.plot.items import Axis, Scatter -from silx.gui.plot import ScatterView -from silx.gui.plot.test.utils import PlotWidgetTestCase - - -class TestScatterView(PlotWidgetTestCase): - """Test of ScatterView widget""" - - def _createPlot(self): - return ScatterView() - - def test(self): - """Simple tests""" - x = numpy.arange(100) - y = numpy.arange(100) - value = numpy.arange(100) - self.plot.setData(x, y, value) - self.qapp.processEvents() - - data = self.plot.getData() - self.assertEqual(len(data), 5) - self.assertTrue(numpy.all(numpy.equal(x, data[0]))) - self.assertTrue(numpy.all(numpy.equal(y, data[1]))) - self.assertTrue(numpy.all(numpy.equal(value, data[2]))) - self.assertIsNone(data[3]) # xerror - self.assertIsNone(data[4]) # yerror - - # Test access to scatter item - self.assertIsInstance(self.plot.getScatterItem(), Scatter) - - # Test toolbar actions - - action = self.plot.getScatterToolBar().getXAxisLogarithmicAction() - action.trigger() - self.qapp.processEvents() - - maskAction = self.plot.getScatterToolBar().actions()[-1] - maskAction.trigger() - self.qapp.processEvents() - - # Test proxy API - - self.plot.resetZoom() - self.qapp.processEvents() - - scale = self.plot.getXAxis().getScale() - self.assertEqual(scale, Axis.LOGARITHMIC) - - scale = self.plot.getYAxis().getScale() - self.assertEqual(scale, Axis.LINEAR) - - title = 'Test ScatterView' - self.plot.setGraphTitle(title) - self.assertEqual(self.plot.getGraphTitle(), title) - - self.qapp.processEvents() - - # Reset scatter data - - self.plot.setData(None, None, None) - self.qapp.processEvents() - - data = self.plot.getData() - self.assertEqual(len(data), 5) - self.assertEqual(len(data[0]), 0) # x - self.assertEqual(len(data[1]), 0) # y - self.assertEqual(len(data[2]), 0) # value - self.assertIsNone(data[3]) # xerror - self.assertIsNone(data[4]) # yerror - - def testAlpha(self): - """Test alpha transparency in setData""" - _pts = 100 - _levels = 100 - _fwhm = 50 - x = numpy.random.rand(_pts)*_levels - y = numpy.random.rand(_pts)*_levels - value = numpy.random.rand(_pts)*_levels - x0 = x[int(_pts/2)] - y0 = x[int(_pts/2)] - #2D Gaussian kernel - alpha = numpy.exp(-4*numpy.log(2) * ((x-x0)**2 + (y-y0)**2) / _fwhm**2) - - self.plot.setData(x, y, value, alpha=alpha) - self.qapp.processEvents() - - alphaData = self.plot.getScatterItem().getAlphaData() - self.assertTrue(numpy.all(numpy.equal(alpha, alphaData))) - - -def suite(): - test_suite = unittest.TestSuite() - loadTests = unittest.defaultTestLoader.loadTestsFromTestCase - test_suite.addTest(loadTests(TestScatterView)) - 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 deleted file mode 100644 index a5f649c..0000000 --- a/silx/gui/plot/test/testStackView.py +++ /dev/null @@ -1,252 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Basic tests for StackView""" - -__authors__ = ["P. Knobel"] -__license__ = "MIT" -__date__ = "20/03/2017" - - -import unittest -import numpy - -from silx.gui.utils.testutils import TestCaseQt, SignalListener - -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, 1, - "Perspective not preserved when calling setStack " - "without specifying the perspective parameter.") - - 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 testDefaultTitle(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") - - def testCustomTitle(self): - """Test setting the plot title with a user defined callback""" - self.stackview.setStack(numpy.arange(24).reshape((4, 3, 2)), - calibrations=[(0, 1), (-10, 10), (3.14, 3.14)]) - - def title_callback(frame_idx): - return "Cubed index title %d" % (frame_idx**3) - - self.stackview.setTitleCallback(title_callback) - self.assertEqual(self.stackview._plot.getGraphTitle(), - "Cubed index title 0") - self.stackview.setFrameNumber(2) - self.assertEqual(self.stackview._plot.getGraphTitle(), - "Cubed index title 8") - - # perspective should not matter, only frame index - self.stackview._StackView__planeSelection.setPerspective(1) - self.stackview.setFrameNumber(0) - self.assertEqual(self.stackview._plot.getGraphTitle(), - "Cubed index title 0") - self.stackview.setFrameNumber(2) - self.assertEqual(self.stackview._plot.getGraphTitle(), - "Cubed index title 8") - - with self.assertRaises(TypeError): - # setTitleCallback should not accept non-callable objects like strings - self.stackview.setTitleCallback( - "Là , vous faites sirop de vingt-et-un et vous dites : " - "beau sirop, mi-sirop, siroté, gagne-sirop, sirop-grelot," - " passe-montagne, sirop au bon goût.") - - def testStackFrameNumber(self): - self.stackview.setStack(self.mystack) - self.assertEqual(self.stackview.getFrameNumber(), 0) - - listener = SignalListener() - self.stackview.sigFrameChanged.connect(listener) - - self.stackview.setFrameNumber(1) - self.assertEqual(self.stackview.getFrameNumber(), 1) - self.assertEqual(listener.arguments(), [(1,)]) - - -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') diff --git a/silx/gui/plot/test/testStats.py b/silx/gui/plot/test/testStats.py deleted file mode 100644 index faedcff..0000000 --- a/silx/gui/plot/test/testStats.py +++ /dev/null @@ -1,562 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Basic tests for CurvesROIWidget""" - -__authors__ = ["H. Payno"] -__license__ = "MIT" -__date__ = "07/03/2018" - - -from silx.gui import qt -from silx.gui.plot.stats import stats -from silx.gui.plot import StatsWidget -from silx.gui.plot.stats import statshandler -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot import Plot1D, Plot2D -import unittest -import logging -import numpy - -_logger = logging.getLogger(__name__) - - -class TestStats(TestCaseQt): - """ - Test :class:`BaseClass` class and inheriting classes - """ - def setUp(self): - TestCaseQt.setUp(self) - self.createCurveContext() - self.createImageContext() - self.createScatterContext() - - def tearDown(self): - self.plot1d.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot1d.close() - self.plot2d.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot2d.close() - self.scatterPlot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.scatterPlot.close() - - def createCurveContext(self): - self.plot1d = Plot1D() - x = range(20) - y = range(20) - self.plot1d.addCurve(x, y, legend='curve0') - - self.curveContext = stats._CurveContext( - item=self.plot1d.getCurve('curve0'), - plot=self.plot1d, - onlimits=False) - - def createScatterContext(self): - self.scatterPlot = Plot2D() - lgd = 'scatter plot' - self.xScatterData = numpy.array([0, 1, 2, 20, 50, 60, 36]) - self.yScatterData = numpy.array([2, 3, 4, 26, 69, 6, 18]) - self.valuesScatterData = numpy.array([5, 6, 7, 10, 90, 20, 5]) - self.scatterPlot.addScatter(self.xScatterData, self.yScatterData, - self.valuesScatterData, legend=lgd) - self.scatterContext = stats._ScatterContext( - item=self.scatterPlot.getScatter(lgd), - plot=self.scatterPlot, - onlimits=False - ) - - def createImageContext(self): - self.plot2d = Plot2D() - self._imgLgd = 'test image' - self.imageData = numpy.arange(32*128).reshape(32, 128) - self.plot2d.addImage(data=self.imageData, - legend=self._imgLgd, replace=False) - self.imageContext = stats._ImageContext( - item=self.plot2d.getImage(self._imgLgd), - plot=self.plot2d, - onlimits=False - ) - - def getBasicStats(self): - return { - 'min': stats.StatMin(), - 'minCoords': stats.StatCoordMin(), - 'max': stats.StatMax(), - 'maxCoords': stats.StatCoordMax(), - 'std': stats.Stat(name='std', fct=numpy.std), - 'mean': stats.Stat(name='mean', fct=numpy.mean), - 'com': stats.StatCOM() - } - - def testBasicStatsCurve(self): - """Test result for simple stats on a curve""" - _stats = self.getBasicStats() - xData = yData = numpy.array(range(20)) - self.assertTrue(_stats['min'].calculate(self.curveContext) == 0) - self.assertTrue(_stats['max'].calculate(self.curveContext) == 19) - self.assertTrue(_stats['minCoords'].calculate(self.curveContext) == [0]) - self.assertTrue(_stats['maxCoords'].calculate(self.curveContext) == [19]) - self.assertTrue(_stats['std'].calculate(self.curveContext) == numpy.std(yData)) - self.assertTrue(_stats['mean'].calculate(self.curveContext) == numpy.mean(yData)) - com = numpy.sum(xData * yData) / numpy.sum(yData) - self.assertTrue(_stats['com'].calculate(self.curveContext) == com) - - def testBasicStatsImage(self): - """Test result for simple stats on an image""" - _stats = self.getBasicStats() - self.assertTrue(_stats['min'].calculate(self.imageContext) == 0) - self.assertTrue(_stats['max'].calculate(self.imageContext) == 128 * 32 - 1) - self.assertTrue(_stats['minCoords'].calculate(self.imageContext) == (0, 0)) - self.assertTrue(_stats['maxCoords'].calculate(self.imageContext) == (127, 31)) - self.assertTrue(_stats['std'].calculate(self.imageContext) == numpy.std(self.imageData)) - self.assertTrue(_stats['mean'].calculate(self.imageContext) == numpy.mean(self.imageData)) - - yData = numpy.sum(self.imageData, axis=1) - xData = numpy.sum(self.imageData, axis=0) - dataXRange = range(self.imageData.shape[1]) - dataYRange = range(self.imageData.shape[0]) - - ycom = numpy.sum(yData*dataYRange) / numpy.sum(yData) - xcom = numpy.sum(xData*dataXRange) / numpy.sum(xData) - - self.assertTrue(_stats['com'].calculate(self.imageContext) == (xcom, ycom)) - - def testStatsImageAdv(self): - """Test that scale and origin are taking into account for images""" - - image2Data = numpy.arange(32 * 128).reshape(32, 128) - self.plot2d.addImage(data=image2Data, legend=self._imgLgd, - replace=True, origin=(100, 10), scale=(2, 0.5)) - image2Context = stats._ImageContext( - item=self.plot2d.getImage(self._imgLgd), - plot=self.plot2d, - onlimits=False - ) - _stats = self.getBasicStats() - self.assertTrue(_stats['min'].calculate(image2Context) == 0) - self.assertTrue( - _stats['max'].calculate(image2Context) == 128 * 32 - 1) - self.assertTrue( - _stats['minCoords'].calculate(image2Context) == (100, 10)) - self.assertTrue( - _stats['maxCoords'].calculate(image2Context) == (127*2. + 100, - 31 * 0.5 + 10) - ) - self.assertTrue( - _stats['std'].calculate(image2Context) == numpy.std( - self.imageData)) - self.assertTrue( - _stats['mean'].calculate(image2Context) == numpy.mean( - self.imageData)) - - yData = numpy.sum(self.imageData, axis=1) - xData = numpy.sum(self.imageData, axis=0) - dataXRange = range(self.imageData.shape[1]) - dataYRange = range(self.imageData.shape[0]) - - ycom = numpy.sum(yData * dataYRange) / numpy.sum(yData) - ycom = (ycom * 0.5) + 10 - xcom = numpy.sum(xData * dataXRange) / numpy.sum(xData) - xcom = (xcom * 2.) + 100 - self.assertTrue( - _stats['com'].calculate(image2Context) == (xcom, ycom)) - - def testBasicStatsScatter(self): - """Test result for simple stats on a scatter""" - _stats = self.getBasicStats() - self.assertTrue(_stats['min'].calculate(self.scatterContext) == 5) - self.assertTrue(_stats['max'].calculate(self.scatterContext) == 90) - self.assertTrue(_stats['minCoords'].calculate(self.scatterContext) == (0, 2)) - self.assertTrue(_stats['maxCoords'].calculate(self.scatterContext) == (50, 69)) - self.assertTrue(_stats['std'].calculate(self.scatterContext) == numpy.std(self.valuesScatterData)) - self.assertTrue(_stats['mean'].calculate(self.scatterContext) == numpy.mean(self.valuesScatterData)) - - comx = numpy.sum(self.xScatterData * self.valuesScatterData).astype(numpy.float32) / numpy.sum( - self.valuesScatterData).astype(numpy.float32) - comy = numpy.sum(self.yScatterData * self.valuesScatterData).astype(numpy.float32) / numpy.sum( - self.valuesScatterData).astype(numpy.float32) - self.assertTrue(numpy.all( - numpy.equal(_stats['com'].calculate(self.scatterContext), - (comx, comy))) - ) - - def testKindNotManagedByStat(self): - """Make sure an exception is raised if we try to execute calculate - of the base class""" - b = stats.StatBase(name='toto', compatibleKinds='curve') - with self.assertRaises(NotImplementedError): - b.calculate(self.imageContext) - - def testKindNotManagedByContext(self): - """ - Make sure an error is raised if we try to calculate a statistic with - a context not managed - """ - myStat = stats.Stat(name='toto', fct=numpy.std, kinds=('curve')) - myStat.calculate(self.curveContext) - with self.assertRaises(ValueError): - myStat.calculate(self.scatterContext) - with self.assertRaises(ValueError): - myStat.calculate(self.imageContext) - - def testOnLimits(self): - stat = stats.StatMin() - - self.plot1d.getXAxis().setLimitsConstraints(minPos=2, maxPos=5) - curveContextOnLimits = stats._CurveContext( - item=self.plot1d.getCurve('curve0'), - plot=self.plot1d, - onlimits=True) - self.assertTrue(stat.calculate(curveContextOnLimits) == 2) - - self.plot2d.getXAxis().setLimitsConstraints(minPos=32) - imageContextOnLimits = stats._ImageContext( - item=self.plot2d.getImage('test image'), - plot=self.plot2d, - onlimits=True) - self.assertTrue(stat.calculate(imageContextOnLimits) == 32) - - self.scatterPlot.getXAxis().setLimitsConstraints(minPos=40) - scatterContextOnLimits = stats._ScatterContext( - item=self.scatterPlot.getScatter('scatter plot'), - plot=self.scatterPlot, - onlimits=True) - self.assertTrue(stat.calculate(scatterContextOnLimits) == 20) - - -class TestStatsFormatter(TestCaseQt): - """Simple test to check usage of the :class:`StatsFormatter`""" - def setUp(self): - self.plot1d = Plot1D() - x = range(20) - y = range(20) - self.plot1d.addCurve(x, y, legend='curve0') - - self.curveContext = stats._CurveContext( - item=self.plot1d.getCurve('curve0'), - plot=self.plot1d, - onlimits=False) - - self.stat = stats.StatMin() - - def tearDown(self): - self.plot1d.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot1d.close() - - def testEmptyFormatter(self): - """Make sure a formatter with no formatter definition will return a - simple cast to str""" - emptyFormatter = statshandler.StatFormatter() - self.assertTrue( - emptyFormatter.format(self.stat.calculate(self.curveContext)) == '0.000') - - def testSettedFormatter(self): - """Make sure a formatter with no formatter definition will return a - simple cast to str""" - formatter= statshandler.StatFormatter(formatter='{0:.3f}') - self.assertTrue( - formatter.format(self.stat.calculate(self.curveContext)) == '0.000') - - -class TestStatsHandler(unittest.TestCase): - """Make sure the StatHandler is correctly making the link between - :class:`StatBase` and :class:`StatFormatter` and checking the API is valid - """ - def setUp(self): - self.plot1d = Plot1D() - x = range(20) - y = range(20) - self.plot1d.addCurve(x, y, legend='curve0') - self.curveItem = self.plot1d.getCurve('curve0') - - self.stat = stats.StatMin() - - def tearDown(self): - self.plot1d.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot1d.close() - - def testConstructor(self): - """Make sure the constructor can deal will all possible arguments: - - * tuple of :class:`StatBase` derivated classes - * tuple of tuples (:class:`StatBase`, :class:`StatFormatter`) - * tuple of tuples (str, pointer to function, kind) - """ - handler0 = statshandler.StatsHandler( - (stats.StatMin(), stats.StatMax()) - ) - - res = handler0.calculate(item=self.curveItem, plot=self.plot1d, - onlimits=False) - self.assertTrue('min' in res) - self.assertTrue(res['min'] == '0') - self.assertTrue('max' in res) - self.assertTrue(res['max'] == '19') - - handler1 = statshandler.StatsHandler( - ( - (stats.StatMin(), statshandler.StatFormatter(formatter=None)), - (stats.StatMax(), statshandler.StatFormatter()) - ) - ) - - res = handler1.calculate(item=self.curveItem, plot=self.plot1d, - onlimits=False) - self.assertTrue('min' in res) - self.assertTrue(res['min'] == '0') - self.assertTrue('max' in res) - self.assertTrue(res['max'] == '19.000') - - handler2 = statshandler.StatsHandler( - ( - (stats.StatMin(), None), - (stats.StatMax(), statshandler.StatFormatter()) - )) - - res = handler2.calculate(item=self.curveItem, plot=self.plot1d, - onlimits=False) - self.assertTrue('min' in res) - self.assertTrue(res['min'] == '0') - self.assertTrue('max' in res) - self.assertTrue(res['max'] == '19.000') - - handler3 = statshandler.StatsHandler(( - (('amin', numpy.argmin), statshandler.StatFormatter()), - ('amax', numpy.argmax) - )) - - res = handler3.calculate(item=self.curveItem, plot=self.plot1d, - onlimits=False) - self.assertTrue('amin' in res) - self.assertTrue(res['amin'] == '0.000') - self.assertTrue('amax' in res) - self.assertTrue(res['amax'] == '19') - - with self.assertRaises(ValueError): - statshandler.StatsHandler(('name')) - - -class TestStatsWidgetWithCurves(TestCaseQt): - """Basic test for StatsWidget with curves""" - def setUp(self): - TestCaseQt.setUp(self) - self.plot = Plot1D() - self.plot.show() - x = range(20) - y = range(20) - self.plot.addCurve(x, y, legend='curve0') - y = range(12, 32) - self.plot.addCurve(x, y, legend='curve1') - y = range(-2, 18) - self.plot.addCurve(x, y, legend='curve2') - self.widget = StatsWidget.StatsTable(plot=self.plot) - - mystats = statshandler.StatsHandler(( - stats.StatMin(), - (stats.StatCoordMin(), statshandler.StatFormatter(None, qt.QTableWidgetItem)), - stats.StatMax(), - (stats.StatCoordMax(), statshandler.StatFormatter(None, qt.QTableWidgetItem)), - stats.StatDelta(), - ('std', numpy.std), - ('mean', numpy.mean), - stats.StatCOM() - )) - - self.widget.setStats(mystats) - - def tearDown(self): - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) - self.widget.close() - self.widget = None - self.plot = None - TestCaseQt.tearDown(self) - - def testInit(self): - """Make sure all the curves are registred on initialization""" - self.assertTrue(self.widget.rowCount() is 3) - - def testRemoveCurve(self): - """Make sure the Curves stats take into account the curve removal from - plot""" - self.plot.removeCurve('curve2') - self.assertTrue(self.widget.rowCount() is 2) - for iRow in range(2): - self.assertTrue(self.widget.item(iRow, 0).text() in ('curve0', 'curve1')) - - self.plot.removeCurve('curve0') - self.assertTrue(self.widget.rowCount() is 1) - self.plot.removeCurve('curve1') - self.assertTrue(self.widget.rowCount() is 0) - - def testAddCurve(self): - """Make sure the Curves stats take into account the add curve action""" - self.plot.addCurve(legend='curve3', x=range(10), y=range(10)) - self.assertTrue(self.widget.rowCount() is 4) - - def testUpdateCurveFrmAddCurve(self): - """Make sure the stats of the cuve will be removed after updating a - curve""" - self.plot.addCurve(legend='curve0', x=range(10), y=range(10)) - self.assertTrue(self.widget.rowCount() is 3) - itemMax = self.widget._getItem(name='max', legend='curve0', - kind='curve', indexTable=None) - self.assertTrue(itemMax.text() == '9') - - def testUpdateCurveFrmCurveObj(self): - self.plot.getCurve('curve0').setData(x=range(4), y=range(4)) - self.assertTrue(self.widget.rowCount() is 3) - itemMax = self.widget._getItem(name='max', legend='curve0', - kind='curve', indexTable=None) - self.assertTrue(itemMax.text() == '3') - - def testSetAnotherPlot(self): - plot2 = Plot1D() - plot2.addCurve(x=range(26), y=range(26), legend='new curve') - self.widget.setPlot(plot2) - self.assertTrue(self.widget.rowCount() is 1) - self.qapp.processEvents() - plot2.setAttribute(qt.Qt.WA_DeleteOnClose) - plot2.close() - plot2 = None - - -class TestStatsWidgetWithImages(TestCaseQt): - """Basic test for StatsWidget with images""" - def setUp(self): - TestCaseQt.setUp(self) - self.plot = Plot2D() - - self.plot.addImage(data=numpy.arange(128*128).reshape(128, 128), - legend='test image', replace=False) - - self.widget = StatsWidget.StatsTable(plot=self.plot) - - mystats = statshandler.StatsHandler(( - (stats.StatMin(), statshandler.StatFormatter()), - (stats.StatCoordMin(), statshandler.StatFormatter(None, qt.QTableWidgetItem)), - (stats.StatMax(), statshandler.StatFormatter()), - (stats.StatCoordMax(), statshandler.StatFormatter(None, qt.QTableWidgetItem)), - (stats.StatDelta(), statshandler.StatFormatter()), - ('std', numpy.std), - ('mean', numpy.mean), - (stats.StatCOM(), statshandler.StatFormatter(None)) - )) - - self.widget.setStats(mystats) - - def tearDown(self): - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) - self.widget.close() - self.widget = None - self.plot = None - TestCaseQt.tearDown(self) - - def test(self): - columnsIndex = self.widget._columns_index - itemLegend = self.widget._lgdAndKindToItems[('test image', 'image')]['legend'] - itemMin = self.widget.item(itemLegend.row(), columnsIndex['min']) - itemMax = self.widget.item(itemLegend.row(), columnsIndex['max']) - itemDelta = self.widget.item(itemLegend.row(), columnsIndex['delta']) - itemCoordsMin = self.widget.item(itemLegend.row(), - columnsIndex['coords min']) - itemCoordsMax = self.widget.item(itemLegend.row(), - columnsIndex['coords max']) - max = (128 * 128) - 1 - self.assertTrue(itemMin.text() == '0.000') - self.assertTrue(itemMax.text() == '{0:.3f}'.format(max)) - self.assertTrue(itemDelta.text() == '{0:.3f}'.format(max)) - self.assertTrue(itemCoordsMin.text() == '0.0, 0.0') - self.assertTrue(itemCoordsMax.text() == '127.0, 127.0') - - -class TestStatsWidgetWithScatters(TestCaseQt): - def setUp(self): - TestCaseQt.setUp(self) - self.scatterPlot = Plot2D() - self.scatterPlot.addScatter([0, 1, 2, 20, 50, 60], - [2, 3, 4, 26, 69, 6], - [5, 6, 7, 10, 90, 20], - legend='scatter plot') - self.widget = StatsWidget.StatsTable(plot=self.scatterPlot) - - mystats = statshandler.StatsHandler(( - stats.StatMin(), - (stats.StatCoordMin(), statshandler.StatFormatter(None, qt.QTableWidgetItem)), - stats.StatMax(), - (stats.StatCoordMax(), statshandler.StatFormatter(None, qt.QTableWidgetItem)), - stats.StatDelta(), - ('std', numpy.std), - ('mean', numpy.mean), - stats.StatCOM() - )) - - self.widget.setStats(mystats) - - def tearDown(self): - self.scatterPlot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.scatterPlot.close() - self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) - self.widget.close() - self.widget = None - self.scatterPlot = None - TestCaseQt.tearDown(self) - - def testStats(self): - columnsIndex = self.widget._columns_index - itemLegend = self.widget._lgdAndKindToItems[('scatter plot', 'scatter')]['legend'] - itemMin = self.widget.item(itemLegend.row(), columnsIndex['min']) - itemMax = self.widget.item(itemLegend.row(), columnsIndex['max']) - itemDelta = self.widget.item(itemLegend.row(), columnsIndex['delta']) - itemCoordsMin = self.widget.item(itemLegend.row(), - columnsIndex['coords min']) - itemCoordsMax = self.widget.item(itemLegend.row(), - columnsIndex['coords max']) - self.assertTrue(itemMin.text() == '5') - self.assertTrue(itemMax.text() == '90') - self.assertTrue(itemDelta.text() == '85') - self.assertTrue(itemCoordsMin.text() == '0, 2') - self.assertTrue(itemCoordsMax.text() == '50, 69') - - -class TestEmptyStatsWidget(TestCaseQt): - def test(self): - widget = StatsWidget.StatsWidget() - widget.show() - - -def suite(): - test_suite = unittest.TestSuite() - for TestClass in (TestStats, TestStatsHandler, TestStatsWidgetWithScatters, - TestStatsWidgetWithImages, TestStatsWidgetWithCurves, - TestStatsFormatter, TestEmptyStatsWidget): - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(TestClass)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/test/testUtilsAxis.py b/silx/gui/plot/test/testUtilsAxis.py deleted file mode 100644 index 016fafe..0000000 --- a/silx/gui/plot/test/testUtilsAxis.py +++ /dev/null @@ -1,167 +0,0 @@ -# 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__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "14/02/2018" - - -import unittest -from silx.gui.plot import PlotWidget -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot.utils.axis import SyncAxes - - -class TestAxisSync(TestCaseQt): - """Tests AxisSync class""" - - def setUp(self): - TestCaseQt.setUp(self) - self.plot1 = PlotWidget() - self.plot2 = PlotWidget() - self.plot3 = PlotWidget() - - def tearDown(self): - self.plot1 = None - self.plot2 = None - self.plot3 = None - TestCaseQt.tearDown(self) - - def testMoveFirstAxis(self): - """Test synchronization after construction""" - _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - - self.plot1.getXAxis().setLimits(10, 500) - self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500)) - - def testMoveSecondAxis(self): - """Test synchronization after construction""" - _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - - self.plot2.getXAxis().setLimits(10, 500) - self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500)) - - def testMoveTwoAxes(self): - """Test synchronization after construction""" - _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - - self.plot1.getXAxis().setLimits(1, 50) - self.plot2.getXAxis().setLimits(10, 500) - self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500)) - - def testDestruction(self): - """Test synchronization when sync object is destroyed""" - sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - del sync - - self.plot1.getXAxis().setLimits(10, 500) - self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500)) - self.assertNotEqual(self.plot2.getXAxis().getLimits(), (10, 500)) - self.assertNotEqual(self.plot3.getXAxis().getLimits(), (10, 500)) - - def testAxisDestruction(self): - """Test synchronization when an axis disappear""" - _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - - # Destroy the plot is possible - import weakref - plot = weakref.ref(self.plot2) - self.plot2 = None - result = self.qWaitForDestroy(plot) - if not result: - # We can't test - self.skipTest("Object not destroyed") - - self.plot1.getXAxis().setLimits(10, 500) - self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500)) - - def testStop(self): - """Test synchronization after calling stop""" - sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - sync.stop() - - self.plot1.getXAxis().setLimits(10, 500) - self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500)) - self.assertNotEqual(self.plot2.getXAxis().getLimits(), (10, 500)) - self.assertNotEqual(self.plot3.getXAxis().getLimits(), (10, 500)) - - def testStopMovingStart(self): - """Test synchronization after calling stop, moving an axis, then start again""" - sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - sync.stop() - self.plot1.getXAxis().setLimits(10, 500) - self.plot2.getXAxis().setLimits(1, 50) - self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500)) - sync.start() - - # The first axis is the reference - self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500)) - self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500)) - - def testDoubleStop(self): - """Test double stop""" - sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - sync.stop() - self.assertRaises(RuntimeError, sync.stop) - - def testDoubleStart(self): - """Test double stop""" - sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - self.assertRaises(RuntimeError, sync.start) - - def testScale(self): - """Test scale change""" - _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()]) - self.plot1.getXAxis().setScale(self.plot1.getXAxis().LOGARITHMIC) - self.assertEqual(self.plot1.getXAxis().getScale(), self.plot1.getXAxis().LOGARITHMIC) - self.assertEqual(self.plot2.getXAxis().getScale(), self.plot1.getXAxis().LOGARITHMIC) - self.assertEqual(self.plot3.getXAxis().getScale(), self.plot1.getXAxis().LOGARITHMIC) - - def testDirection(self): - """Test direction change""" - _sync = SyncAxes([self.plot1.getYAxis(), self.plot2.getYAxis(), self.plot3.getYAxis()]) - self.plot1.getYAxis().setInverted(True) - self.assertEqual(self.plot1.getYAxis().isInverted(), True) - self.assertEqual(self.plot2.getYAxis().isInverted(), True) - self.assertEqual(self.plot3.getYAxis().isInverted(), True) - - -def suite(): - test_suite = unittest.TestSuite() - loadTests = unittest.defaultTestLoader.loadTestsFromTestCase - test_suite.addTest(loadTests(TestAxisSync)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/test/utils.py b/silx/gui/plot/test/utils.py deleted file mode 100644 index ed1917a..0000000 --- a/silx/gui/plot/test/utils.py +++ /dev/null @@ -1,94 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Basic tests for PlotWidget""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "26/01/2018" - - -import logging - -from silx.gui.utils.testutils import TestCaseQt - -from silx.gui import qt -from silx.gui.plot import PlotWidget - - -logger = logging.getLogger(__name__) - - -class PlotWidgetTestCase(TestCaseQt): - """Base class for tests of PlotWidget, not a TestCase in itself. - - plot attribute is the PlotWidget created for the test. - """ - - __screenshot_already_taken = False - - def __init__(self, methodName='runTest', backend=None): - TestCaseQt.__init__(self, methodName=methodName) - self.__backend = backend - - def _createPlot(self): - return PlotWidget(backend=self.__backend) - - def setUp(self): - super(PlotWidgetTestCase, self).setUp() - self.plot = self._createPlot() - self.plot.show() - self.plotAlive = True - self.qWaitForWindowExposed(self.plot) - TestCaseQt.mouseClick(self, self.plot, button=qt.Qt.LeftButton, pos=(0, 0)) - - def __onPlotDestroyed(self): - self.plotAlive = False - - def _waitForPlotClosed(self): - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.destroyed.connect(self.__onPlotDestroyed) - self.plot.close() - del self.plot - for _ in range(100): - if not self.plotAlive: - break - self.qWait(10) - else: - logger.error("Plot is still alive") - - def tearDown(self): - if not self._currentTestSucceeded(): - # MPL is the only widget which uses the real system mouse. - # In case of a the windows is outside of the screen, minimzed, - # overlapped by a system popup, the MPL widget will not receive the - # mouse event. - # Taking a screenshot help debuging this cases in the continuous - # integration environement. - if not PlotWidgetTestCase.__screenshot_already_taken: - PlotWidgetTestCase.__screenshot_already_taken = True - self.logScreenShot() - self.qapp.processEvents() - self._waitForPlotClosed() - super(PlotWidgetTestCase, self).tearDown() diff --git a/silx/gui/plot/tools/CurveLegendsWidget.py b/silx/gui/plot/tools/CurveLegendsWidget.py deleted file mode 100644 index 7b63b29..0000000 --- a/silx/gui/plot/tools/CurveLegendsWidget.py +++ /dev/null @@ -1,247 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides a widget to display :class:`PlotWidget` curve legends. -""" - -from __future__ import division - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "20/07/2018" - - -import logging -import weakref - - -from ... import qt -from ...widgets.FlowLayout import FlowLayout as _FlowLayout -from ..LegendSelector import LegendIcon as _LegendIcon -from .. import items - - -_logger = logging.getLogger(__name__) - - -class _LegendWidget(qt.QWidget): - """Widget displaying curve style and its legend - - :param QWidget parent: See :class:`QWidget` - :param ~silx.gui.plot.items.Curve curve: Associated curve - """ - - def __init__(self, parent, curve): - super(_LegendWidget, self).__init__(parent) - layout = qt.QHBoxLayout(self) - layout.setContentsMargins(10, 0, 10, 0) - - curve.sigItemChanged.connect(self._curveChanged) - - icon = _LegendIcon(curve=curve) - layout.addWidget(icon) - - label = qt.QLabel(curve.getLegend()) - label.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter) - layout.addWidget(label) - - self._update() - - def getCurve(self): - """Returns curve associated to this widget - - :rtype: Union[~silx.gui.plot.items.Curve,None] - """ - icon = self.findChild(_LegendIcon) - return icon.getCurve() - - def _update(self): - """Update widget according to current curve state. - """ - curve = self.getCurve() - if curve is None: - _logger.error('Curve no more exists') - self.setVisible(False) - return - - self.setEnabled(curve.isVisible()) - - label = self.findChild(qt.QLabel) - if curve.isHighlighted(): - label.setStyleSheet("border: 1px solid black") - else: - label.setStyleSheet("") - - def _curveChanged(self, event): - """Handle update of curve item - - :param event: Kind of change - """ - if event in (items.ItemChangedType.VISIBLE, - items.ItemChangedType.HIGHLIGHTED, - items.ItemChangedType.HIGHLIGHTED_STYLE): - self._update() - - -class CurveLegendsWidget(qt.QWidget): - """Widget displaying curves legends in a plot - - :param QWidget parent: See :class:`QWidget` - """ - - sigCurveClicked = qt.Signal(object) - """Signal emitted when the legend of a curve is clicked - - It provides the corresponding curve. - """ - - def __init__(self, parent=None): - super(CurveLegendsWidget, self).__init__(parent) - self._clicked = None - self._legends = {} - self._plotRef = None - - def layout(self): - layout = super(CurveLegendsWidget, self).layout() - if layout is None: - # Lazy layout initialization to allow overloading - layout = _FlowLayout() - layout.setHorizontalSpacing(0) - self.setLayout(layout) - return layout - - def getPlotWidget(self): - """Returns the associated :class:`PlotWidget` - - :rtype: Union[~silx.gui.plot.PlotWidget,None] - """ - return None if self._plotRef is None else self._plotRef() - - def setPlotWidget(self, plot): - """Set the associated :class:`PlotWidget` - - :param ~silx.gui.plot.PlotWidget plot: Plot widget to attach - """ - previousPlot = self.getPlotWidget() - if previousPlot is not None: - previousPlot.sigItemAdded.disconnect( self._itemAdded) - previousPlot.sigItemAboutToBeRemoved.disconnect(self._itemRemoved) - for legend in list(self._legends.keys()): - self._removeLegend(legend) - - self._plotRef = None if plot is None else weakref.ref(plot) - - if plot is not None: - plot.sigItemAdded.connect(self._itemAdded) - plot.sigItemAboutToBeRemoved.connect(self._itemRemoved) - - for legend in plot.getAllCurves(just_legend=True): - self._addLegend(legend) - - def curveAt(self, *args): - """Returns the curve object represented at the given position - - Either takes a QPoint or x and y as input in widget coordinates. - - :rtype: Union[~silx.gui.plot.items.Curve,None] - """ - if len(args) == 1: - point = args[0] - elif len(args) == 2: - point = qt.QPoint(*args) - else: - raise ValueError('Unsupported arguments') - assert isinstance(point, qt.QPoint) - - widget = self.childAt(point) - while widget not in (self, None): - if isinstance(widget, _LegendWidget): - return widget.getCurve() - widget = widget.parent() - return None # No widget or not in _LegendWidget - - def _itemAdded(self, item): - """Handle item added to the plot content""" - if isinstance(item, items.Curve): - self._addLegend(item.getLegend()) - - def _itemRemoved(self, item): - """Handle item removed from the plot content""" - if isinstance(item, items.Curve): - self._removeLegend(item.getLegend()) - - def _addLegend(self, legend): - """Add a curve to the legends - - :param str legend: Curve's legend - """ - if legend in self._legends: - return # Can happen when changing curve's y axis - - plot = self.getPlotWidget() - if plot is None: - return None - - curve = plot.getCurve(legend) - if curve is None: - _logger.error('Curve not found: %s' % legend) - return - - widget = _LegendWidget(parent=self, curve=curve) - self.layout().addWidget(widget) - self._legends[legend] = widget - - def _removeLegend(self, legend): - """Remove a curve from the legends if it exists - - :param str legend: The curve's legend - """ - widget = self._legends.pop(legend, None) - if widget is None: - _logger.warning('Unknown legend: %s' % legend) - else: - self.layout().removeWidget(widget) - widget.setParent(None) - - def mousePressEvent(self, event): - if event.button() == qt.Qt.LeftButton: - self._clicked = event.pos() - - _CLICK_THRESHOLD = 5 - """Threshold for clicks""" - - def mouseMoveEvent(self, event): - if self._clicked is not None: - dx = abs(self._clicked.x() - event.pos().x()) - dy = abs(self._clicked.y() - event.pos().y()) - if dx > self._CLICK_THRESHOLD or dy > self._CLICK_THRESHOLD: - self._clicked = None # Click is cancelled - - def mouseReleaseEvent(self, event): - if event.button() == qt.Qt.LeftButton and self._clicked is not None: - curve = self.curveAt(event.pos()) - if curve is not None: - self.sigCurveClicked.emit(curve) - - self._clicked = None diff --git a/silx/gui/plot/tools/LimitsToolBar.py b/silx/gui/plot/tools/LimitsToolBar.py deleted file mode 100644 index fc192a6..0000000 --- a/silx/gui/plot/tools/LimitsToolBar.py +++ /dev/null @@ -1,131 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""A toolbar to display and edit limits of a PlotWidget -""" - - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "16/10/2017" - - -from ... import qt -from ...widgets.FloatEdit import FloatEdit - - -class LimitsToolBar(qt.QToolBar): - """QToolBar displaying and controlling the limits of a :class:`PlotWidget`. - - To run the following sample code, a QApplication must be initialized. - First, create a PlotWindow: - - >>> from silx.gui.plot import PlotWindow - >>> plot = PlotWindow() # Create a PlotWindow to add the toolbar to - - Then, create the LimitsToolBar and add it to the PlotWindow. - - >>> from silx.gui import qt - >>> from silx.gui.plot.tools import LimitsToolBar - - >>> toolbar = LimitsToolBar(plot=plot) # Create the toolbar - >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolbar) # Add it to the plot - >>> plot.show() # To display the PlotWindow with the limits toolbar - - :param parent: See :class:`QToolBar`. - :param plot: :class:`PlotWidget` instance on which to operate. - :param str title: See :class:`QToolBar`. - """ - - def __init__(self, parent=None, plot=None, title='Limits'): - super(LimitsToolBar, self).__init__(title, parent) - assert plot is not None - self._plot = plot - self._plot.sigPlotSignal.connect(self._plotWidgetSlot) - - self._initWidgets() - - @property - def plot(self): - """The :class:`PlotWidget` the toolbar is attached to.""" - return self._plot - - def _initWidgets(self): - """Create and init Toolbar widgets.""" - xMin, xMax = self.plot.getXAxis().getLimits() - yMin, yMax = self.plot.getYAxis().getLimits() - - self.addWidget(qt.QLabel('Limits: ')) - self.addWidget(qt.QLabel(' X: ')) - self._xMinFloatEdit = FloatEdit(self, xMin) - self._xMinFloatEdit.editingFinished[()].connect( - self._xFloatEditChanged) - self.addWidget(self._xMinFloatEdit) - - self._xMaxFloatEdit = FloatEdit(self, xMax) - self._xMaxFloatEdit.editingFinished[()].connect( - self._xFloatEditChanged) - self.addWidget(self._xMaxFloatEdit) - - self.addWidget(qt.QLabel(' Y: ')) - self._yMinFloatEdit = FloatEdit(self, yMin) - self._yMinFloatEdit.editingFinished[()].connect( - self._yFloatEditChanged) - self.addWidget(self._yMinFloatEdit) - - self._yMaxFloatEdit = FloatEdit(self, yMax) - self._yMaxFloatEdit.editingFinished[()].connect( - self._yFloatEditChanged) - self.addWidget(self._yMaxFloatEdit) - - def _plotWidgetSlot(self, event): - """Listen to :class:`PlotWidget` events.""" - if event['event'] not in ('limitsChanged',): - return - - xMin, xMax = self.plot.getXAxis().getLimits() - yMin, yMax = self.plot.getYAxis().getLimits() - - self._xMinFloatEdit.setValue(xMin) - self._xMaxFloatEdit.setValue(xMax) - self._yMinFloatEdit.setValue(yMin) - self._yMaxFloatEdit.setValue(yMax) - - def _xFloatEditChanged(self): - """Handle X limits changed from the GUI.""" - xMin, xMax = self._xMinFloatEdit.value(), self._xMaxFloatEdit.value() - if xMax < xMin: - xMin, xMax = xMax, xMin - - self.plot.getXAxis().setLimits(xMin, xMax) - - def _yFloatEditChanged(self): - """Handle Y limits changed from the GUI.""" - yMin, yMax = self._yMinFloatEdit.value(), self._yMaxFloatEdit.value() - if yMax < yMin: - yMin, yMax = yMax, yMin - - self.plot.getYAxis().setLimits(yMin, yMax) diff --git a/silx/gui/plot/tools/PositionInfo.py b/silx/gui/plot/tools/PositionInfo.py deleted file mode 100644 index 83b61bd..0000000 --- a/silx/gui/plot/tools/PositionInfo.py +++ /dev/null @@ -1,347 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides a widget displaying mouse coordinates in a PlotWidget. - -It can be configured to provide more information. -""" - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "16/10/2017" - - -import logging -import numbers -import traceback -import weakref - -import numpy - -from ....utils.deprecation import deprecated -from ... import qt -from .. import items - - -_logger = logging.getLogger(__name__) - - -# PositionInfo ################################################################ - -class PositionInfo(qt.QWidget): - """QWidget displaying coords converted from data coords of the mouse. - - Provide this widget with a list of couple: - - - A name to display before the data - - A function that takes (x, y) as arguments and returns something that - gets converted to a string. - If the result is a float it is converted with '%.7g' format. - - To run the following sample code, a QApplication must be initialized. - First, create a PlotWindow and add a QToolBar where to place the - PositionInfo widget. - - >>> from silx.gui.plot import PlotWindow - >>> from silx.gui import qt - - >>> plot = PlotWindow() # Create a PlotWindow to add the widget to - >>> toolBar = qt.QToolBar() # Create a toolbar to place the widget in - >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) # Add it to plot - - Then, create the PositionInfo widget and add it to the toolbar. - The PositionInfo widget is created with a list of converters, here - to display polar coordinates of the mouse position. - - >>> import numpy - >>> from silx.gui.plot.tools import PositionInfo - - >>> position = PositionInfo(plot=plot, converters=[ - ... ('Radius', lambda x, y: numpy.sqrt(x*x + y*y)), - ... ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x)))]) - >>> toolBar.addWidget(position) # Add the widget to the toolbar - <...> - >>> plot.show() # To display the PlotWindow with the position widget - - :param plot: The PlotWidget this widget is displaying data coords from. - :param converters: - List of 2-tuple: name to display and conversion function from (x, y) - in data coords to displayed value. - If None, the default, it displays X and Y. - :param parent: Parent widget - """ - - SNAP_THRESHOLD_DIST = 5 - - def __init__(self, parent=None, plot=None, converters=None): - assert plot is not None - self._plotRef = weakref.ref(plot) - self._snappingMode = self.SNAPPING_DISABLED - - super(PositionInfo, self).__init__(parent) - - if converters is None: - converters = (('X', lambda x, y: x), ('Y', lambda x, y: y)) - - self._fields = [] # To store (QLineEdit, name, function (x, y)->v) - - # Create a new layout with new widgets - layout = qt.QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - # layout.setSpacing(0) - - # Create all QLabel and store them with the corresponding converter - for name, func in converters: - layout.addWidget(qt.QLabel('<b>' + name + ':</b>')) - - contentWidget = qt.QLabel() - contentWidget.setText('------') - contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) - contentWidget.setFixedWidth( - contentWidget.fontMetrics().width('##############')) - layout.addWidget(contentWidget) - self._fields.append((contentWidget, name, func)) - - layout.addStretch(1) - self.setLayout(layout) - - # Connect to Plot events - plot.sigPlotSignal.connect(self._plotEvent) - - def getPlotWidget(self): - """Returns the PlotWidget this widget is attached to or None. - - :rtype: Union[~silx.gui.plot.PlotWidget,None] - """ - return self._plotRef() - - @property - @deprecated(replacement='getPlotWidget', since_version='0.8.0') - def plot(self): - return self.getPlotWidget() - - def getConverters(self): - """Return the list of converters as 2-tuple (name, function).""" - return [(name, func) for _label, name, func in self._fields] - - def _plotEvent(self, event): - """Handle events from the Plot. - - :param dict event: Plot event - """ - if event['event'] == 'mouseMoved': - x, y = event['x'], event['y'] - xPixel, yPixel = event['xpixel'], event['ypixel'] - self._updateStatusBar(x, y, xPixel, yPixel) - - def updateInfo(self): - """Update displayed information""" - plot = self.getPlotWidget() - if plot is None: - _logger.error("Trying to update PositionInfo " - "while PlotWidget no longer exists") - return - - widget = plot.getWidgetHandle() - position = widget.mapFromGlobal(qt.QCursor.pos()) - xPixel, yPixel = position.x(), position.y() - dataPos = plot.pixelToData(xPixel, yPixel, check=True) - if dataPos is not None: # Inside plot area - x, y = dataPos - self._updateStatusBar(x, y, xPixel, yPixel) - - def _updateStatusBar(self, x, y, xPixel, yPixel): - """Update information from the status bar using the definitions. - - :param float x: Position-x in data - :param float y: Position-y in data - :param float xPixel: Position-x in pixels - :param float yPixel: Position-y in pixels - """ - plot = self.getPlotWidget() - if plot is None: - return - - styleSheet = "color: rgb(0, 0, 0);" # Default style - xData, yData = x, y - - snappingMode = self.getSnappingMode() - - # Snapping when crosshair either not requested or active - if (snappingMode & (self.SNAPPING_CURVE | self.SNAPPING_SCATTER) and - (not (snappingMode & self.SNAPPING_CROSSHAIR) or - plot.getGraphCursor())): - styleSheet = "color: rgb(255, 0, 0);" # Style far from item - - if snappingMode & self.SNAPPING_ACTIVE_ONLY: - selectedItems = [] - - if snappingMode & self.SNAPPING_CURVE: - activeCurve = plot.getActiveCurve() - if activeCurve: - selectedItems.append(activeCurve) - - if snappingMode & self.SNAPPING_SCATTER: - activeScatter = plot._getActiveItem(kind='scatter') - if activeScatter: - selectedItems.append(activeScatter) - - else: - kinds = [] - if snappingMode & self.SNAPPING_CURVE: - kinds.append('curve') - if snappingMode & self.SNAPPING_SCATTER: - kinds.append('scatter') - selectedItems = plot._getItems(kind=kinds) - - # Compute distance threshold - if qt.BINDING in ('PyQt5', 'PySide2'): - window = plot.window() - windowHandle = window.windowHandle() - if windowHandle is not None: - ratio = windowHandle.devicePixelRatio() - else: - ratio = qt.QGuiApplication.primaryScreen().devicePixelRatio() - else: - ratio = 1. - - # Baseline squared distance threshold - distInPixels = (self.SNAP_THRESHOLD_DIST * ratio)**2 - - for item in selectedItems: - if (snappingMode & self.SNAPPING_SYMBOLS_ONLY and - not item.getSymbol()): - # Only handled if item symbols are visible - continue - - xArray = item.getXData(copy=False) - yArray = item.getYData(copy=False) - closestIndex = numpy.argmin( - pow(xArray - x, 2) + pow(yArray - y, 2)) - - xClosest = xArray[closestIndex] - yClosest = yArray[closestIndex] - - if isinstance(item, items.YAxisMixIn): - axis = item.getYAxis() - else: - axis = 'left' - - closestInPixels = plot.dataToPixel( - xClosest, yClosest, axis=axis) - if closestInPixels is not None: - curveDistInPixels = ( - (closestInPixels[0] - xPixel)**2 + - (closestInPixels[1] - yPixel)**2) - - if curveDistInPixels <= distInPixels: - # Update label style sheet - styleSheet = "color: rgb(0, 0, 0);" - - # if close enough, snap to data point coord - xData, yData = xClosest, yClosest - distInPixels = curveDistInPixels - - for label, name, func in self._fields: - label.setStyleSheet(styleSheet) - - try: - value = func(xData, yData) - text = self.valueToString(value) - label.setText(text) - except: - label.setText('Error') - _logger.error( - "Error while converting coordinates (%f, %f)" - "with converter '%s'" % (xPixel, yPixel, name)) - _logger.error(traceback.format_exc()) - - def valueToString(self, value): - if isinstance(value, (tuple, list)): - value = [self.valueToString(v) for v in value] - return ", ".join(value) - elif isinstance(value, numbers.Real): - # Use this for floats and int - return '%.7g' % value - else: - # Fallback for other types - return str(value) - - # Snapping mode - - SNAPPING_DISABLED = 0 - """No snapping occurs""" - - SNAPPING_CROSSHAIR = 1 << 0 - """Snapping only enabled when crosshair cursor is enabled""" - - SNAPPING_ACTIVE_ONLY = 1 << 1 - """Snapping only enabled for active item""" - - SNAPPING_SYMBOLS_ONLY = 1 << 2 - """Snapping only when symbols are visible""" - - SNAPPING_CURVE = 1 << 3 - """Snapping on curves""" - - SNAPPING_SCATTER = 1 << 4 - """Snapping on scatter""" - - def setSnappingMode(self, mode): - """Set the snapping mode. - - The mode is a mask. - - :param int mode: The mode to use - """ - if mode != self._snappingMode: - self._snappingMode = mode - self.updateInfo() - - def getSnappingMode(self): - """Returns the snapping mode as a mask - - :rtype: int - """ - return self._snappingMode - - _SNAPPING_LEGACY = (SNAPPING_CROSSHAIR | - SNAPPING_ACTIVE_ONLY | - SNAPPING_SYMBOLS_ONLY | - SNAPPING_CURVE | - SNAPPING_SCATTER) - """Legacy snapping mode""" - - @property - @deprecated(replacement="getSnappingMode", since_version="0.8") - def autoSnapToActiveCurve(self): - return self.getSnappingMode() == self._SNAPPING_LEGACY - - @autoSnapToActiveCurve.setter - @deprecated(replacement="setSnappingMode", since_version="0.8") - def autoSnapToActiveCurve(self, flag): - self.setSnappingMode( - self._SNAPPING_LEGACY if flag else self.SNAPPING_DISABLED) diff --git a/silx/gui/plot/tools/__init__.py b/silx/gui/plot/tools/__init__.py deleted file mode 100644 index 09f468c..0000000 --- a/silx/gui/plot/tools/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This package provides a set of widgets working with :class:`PlotWidget`. - -It provides some QToolBar and QWidget: - -- :class:`InteractiveModeToolBar` -- :class:`OutputToolBar` -- :class:`ImageToolBar` -- :class:`CurveToolBar` -- :class:`LimitsToolBar` -- :class:`PositionInfo` - -It also provides a :mod:`~silx.gui.plot.tools.roi` module to handle -interactive region of interest on a :class:`~silx.gui.plot.PlotWidget`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "01/03/2018" - - -from .toolbars import InteractiveModeToolBar # noqa -from .toolbars import OutputToolBar # noqa -from .toolbars import ImageToolBar, CurveToolBar, ScatterToolBar # noqa - -from .LimitsToolBar import LimitsToolBar # noqa -from .PositionInfo import PositionInfo # noqa diff --git a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py deleted file mode 100644 index fd21515..0000000 --- a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py +++ /dev/null @@ -1,431 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module profile tools for scatter plots. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import logging -import threading -import time - -import numpy - -try: - from scipy.interpolate import LinearNDInterpolator -except ImportError: - LinearNDInterpolator = None - - # Fallback using local Delaunay and matplotlib interpolator - from silx.third_party.scipy_spatial import Delaunay - import matplotlib.tri - -from ._BaseProfileToolBar import _BaseProfileToolBar -from .... import qt -from ... import items - - -_logger = logging.getLogger(__name__) - - -# TODO support log scale - - -class _InterpolatorInitThread(qt.QThread): - """Thread building a scatter interpolator - - This works in greedy mode in that the signal is only emitted - when no other request is pending - """ - - sigInterpolatorReady = qt.Signal(object) - """Signal emitted whenever an interpolator is ready - - It provides a 3-tuple (points, values, interpolator) - """ - - _RUNNING_THREADS_TO_DELETE = [] - """Store reference of no more used threads but still running""" - - def __init__(self): - super(_InterpolatorInitThread, self).__init__() - self._lock = threading.RLock() - self._pendingData = None - self._firstFallbackRun = True - - def discard(self, obj=None): - """Wait for pending thread to complete and delete then - - Connect this to the destroyed signal of widget using this thread - """ - if self.isRunning(): - self.cancel() - self._RUNNING_THREADS_TO_DELETE.append(self) # Keep a reference - self.finished.connect(self.__finished) - - def __finished(self): - """Handle finished signal of threads to delete""" - try: - self._RUNNING_THREADS_TO_DELETE.remove(self) - except ValueError: - _logger.warning('Finished thread no longer in reference list') - - def request(self, points, values): - """Request new initialisation of interpolator - - :param numpy.ndarray points: Point coordinates (N, D) - :param numpy.ndarray values: Values the N points (1D array) - """ - with self._lock: - # Possibly replace already pending data - self._pendingData = points, values - - if not self.isRunning(): - self.start() - - def cancel(self): - """Cancel any running/pending requests""" - with self._lock: - self._pendingData = 'cancelled' - - def run(self): - """Run the init of the scatter interpolator""" - if LinearNDInterpolator is None: - self.run_matplotlib() - else: - self.run_scipy() - - def run_matplotlib(self): - """Run the init of the scatter interpolator""" - if self._firstFallbackRun: - self._firstFallbackRun = False - _logger.warning( - "scipy.spatial.LinearNDInterpolator not available: " - "Scatter plot interpolator initialisation can freeze the GUI.") - - while True: - with self._lock: - data = self._pendingData - self._pendingData = None - - if data in (None, 'cancelled'): - return - - points, values = data - - startTime = time.time() - try: - delaunay = Delaunay(points) - except: - _logger.warning( - "Cannot triangulate scatter data") - else: - with self._lock: - data = self._pendingData - - if data is not None: # Break point - _logger.info('Interpolator discarded after %f s', - time.time() - startTime) - else: - - x, y = points.T - triangulation = matplotlib.tri.Triangulation( - x, y, triangles=delaunay.simplices) - - interpolator = matplotlib.tri.LinearTriInterpolator( - triangulation, values) - - with self._lock: - data = self._pendingData - - if data is not None: - _logger.info('Interpolator discarded after %f s', - time.time() - startTime) - else: - # No other processing requested: emit the signal - _logger.info("Interpolator initialised in %f s", - time.time() - startTime) - - # Wrap interpolator to have same API as scipy's one - def wrapper(points): - return interpolator(*points.T) - - self.sigInterpolatorReady.emit( - (points, values, wrapper)) - - def run_scipy(self): - """Run the init of the scatter interpolator""" - while True: - with self._lock: - data = self._pendingData - self._pendingData = None - - if data in (None, 'cancelled'): - return - - points, values = data - - startTime = time.time() - try: - interpolator = LinearNDInterpolator(points, values) - except: - _logger.warning( - "Cannot initialise scatter profile interpolator") - else: - with self._lock: - data = self._pendingData - - if data is not None: # Break point - _logger.info('Interpolator discarded after %f s', - time.time() - startTime) - else: - # First call takes a while, do it here - interpolator([(0., 0.)]) - - with self._lock: - data = self._pendingData - - if data is not None: - _logger.info('Interpolator discarded after %f s', - time.time() - startTime) - else: - # No other processing requested: emit the signal - _logger.info("Interpolator initialised in %f s", - time.time() - startTime) - self.sigInterpolatorReady.emit( - (points, values, interpolator)) - - -class ScatterProfileToolBar(_BaseProfileToolBar): - """QToolBar providing scatter plot profiling tools - - :param parent: See :class:`QToolBar`. - :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate. - :param str title: See :class:`QToolBar`. - """ - - def __init__(self, parent=None, plot=None, title='Scatter Profile'): - super(ScatterProfileToolBar, self).__init__(parent, plot, title) - - self.__nPoints = 1024 - self.__interpolator = None - self.__interpolatorCache = None # points, values, interpolator - - self.__initThread = _InterpolatorInitThread() - self.destroyed.connect(self.__initThread.discard) - self.__initThread.sigInterpolatorReady.connect( - self.__interpolatorReady) - - roiManager = self._getRoiManager() - if roiManager is None: - _logger.error( - "Error during scatter profile toolbar initialisation") - else: - roiManager.sigInteractiveModeStarted.connect( - self.__interactionStarted) - roiManager.sigInteractiveModeFinished.connect( - self.__interactionFinished) - if roiManager.isStarted(): - self.__interactionStarted(roiManager.getCurrentInteractionModeRoiClass()) - - def __interactionStarted(self, roiClass): - """Handle start of ROI interaction""" - plot = self.getPlotWidget() - if plot is None: - return - - plot.sigActiveScatterChanged.connect(self.__activeScatterChanged) - - scatter = plot._getActiveItem(kind='scatter') - legend = None if scatter is None else scatter.getLegend() - self.__activeScatterChanged(None, legend) - - def __interactionFinished(self): - """Handle end of ROI interaction""" - plot = self.getPlotWidget() - if plot is None: - return - - plot.sigActiveScatterChanged.disconnect(self.__activeScatterChanged) - - scatter = plot._getActiveItem(kind='scatter') - legend = None if scatter is None else scatter.getLegend() - self.__activeScatterChanged(legend, None) - - def __activeScatterChanged(self, previous, legend): - """Handle change of active scatter - - :param Union[str,None] previous: - :param Union[str,None] legend: - """ - self.__initThread.cancel() - - # Reset interpolator - self.__interpolator = None - - plot = self.getPlotWidget() - if plot is None: - _logger.error("Associated PlotWidget no longer exists") - - else: - if previous is not None: # Disconnect signal - scatter = plot.getScatter(previous) - if scatter is not None: - scatter.sigItemChanged.disconnect( - self.__scatterItemChanged) - - if legend is not None: - scatter = plot.getScatter(legend) - if scatter is None: - _logger.error("Cannot retrieve active scatter") - - else: - scatter.sigItemChanged.connect(self.__scatterItemChanged) - points = numpy.transpose(numpy.array(( - scatter.getXData(copy=False), - scatter.getYData(copy=False)))) - values = scatter.getValueData(copy=False) - - self.__updateInterpolator(points, values) - - # Refresh profile - self.updateProfile() - - def __scatterItemChanged(self, event): - """Handle update of active scatter plot item - - :param ItemChangedType event: - """ - if event == items.ItemChangedType.DATA: - self.__interpolator = None - scatter = self.sender() - if scatter is None: - _logger.error("Cannot retrieve updated scatter item") - - else: - points = numpy.transpose(numpy.array(( - scatter.getXData(copy=False), - scatter.getYData(copy=False)))) - values = scatter.getValueData(copy=False) - - self.__updateInterpolator(points, values) - - # Handle interpolator init thread - - def __updateInterpolator(self, points, values): - """Update used interpolator with new data""" - if (self.__interpolatorCache is not None and - len(points) == len(self.__interpolatorCache[0]) and - numpy.all(numpy.equal(self.__interpolatorCache[0], points)) and - numpy.all(numpy.equal(self.__interpolatorCache[1], values))): - # Reuse previous interpolator - _logger.info( - 'Scatter changed: Reuse previous interpolator') - self.__interpolator = self.__interpolatorCache[2] - - else: - # Interpolator needs update: Start background processing - _logger.info( - 'Scatter changed: Rebuild interpolator') - self.__interpolator = None - self.__interpolatorCache = None - self.__initThread.request(points, values) - - def __interpolatorReady(self, data): - """Handle end of init interpolator thread""" - points, values, interpolator = data - self.__interpolator = interpolator - self.__interpolatorCache = None if interpolator is None else data - self.updateProfile() - - def hasPendingOperations(self): - return self.__initThread.isRunning() - - # Number of points - - def getNPoints(self): - """Returns the number of points of the profiles - - :rtype: int - """ - return self.__nPoints - - def setNPoints(self, npoints): - """Set the number of points of the profiles - - :param int npoints: - """ - npoints = int(npoints) - if npoints < 1: - raise ValueError("Unsupported number of points: %d" % npoints) - else: - self.__nPoints = npoints - - # Overridden methods - - def computeProfileTitle(self, x0, y0, x1, y1): - """Compute corresponding plot title - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: Title to use - :rtype: str - """ - if self.hasPendingOperations(): - return 'Pre-processing data...' - - else: - return super(ScatterProfileToolBar, self).computeProfileTitle( - x0, y0, x1, y1) - - def computeProfile(self, x0, y0, x1, y1): - """Compute corresponding profile - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: (points, values) profile data or None - """ - if self.__interpolator is None: - return None - - nPoints = self.getNPoints() - - points = numpy.transpose(( - numpy.linspace(x0, x1, nPoints, endpoint=True), - numpy.linspace(y0, y1, nPoints, endpoint=True))) - - values = self.__interpolator(points) - - if not numpy.any(numpy.isfinite(values)): - return None # Profile outside convex hull - - return points, values diff --git a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py deleted file mode 100644 index 6d9d6d4..0000000 --- a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py +++ /dev/null @@ -1,430 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides the base class for profile toolbars.""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import logging -import weakref - -import numpy - -from silx.utils.weakref import WeakMethodProxy -from silx.gui import qt, icons, colors -from silx.gui.plot import PlotWidget, items -from silx.gui.plot.ProfileMainWindow import ProfileMainWindow -from silx.gui.plot.tools.roi import RegionOfInterestManager -from silx.gui.plot.items import roi as roi_items - - -_logger = logging.getLogger(__name__) - - -class _BaseProfileToolBar(qt.QToolBar): - """Base class for QToolBar plot profiling tools - - :param parent: See :class:`QToolBar`. - :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate. - :param str title: See :class:`QToolBar`. - """ - - sigProfileChanged = qt.Signal() - """Signal emitted when the profile has changed""" - - def __init__(self, parent=None, plot=None, title=''): - super(_BaseProfileToolBar, self).__init__(title, parent) - - self.__profile = None - self.__profileTitle = '' - - assert isinstance(plot, PlotWidget) - self._plotRef = weakref.ref( - plot, WeakMethodProxy(self.__plotDestroyed)) - - self._profileWindow = None - - # Set-up interaction manager - roiManager = RegionOfInterestManager(plot) - self._roiManagerRef = weakref.ref(roiManager) - - roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished) - roiManager.sigRoiChanged.connect(self.updateProfile) - roiManager.sigRoiAdded.connect(self.__roiAdded) - - # Add interactive mode actions - for kind, icon, tooltip in ( - (roi_items.HorizontalLineROI, 'shape-horizontal', - 'Enables horizontal line profile selection mode'), - (roi_items.VerticalLineROI, 'shape-vertical', - 'Enables vertical line profile selection mode'), - (roi_items.LineROI, 'shape-diagonal', - 'Enables line profile selection mode')): - action = roiManager.getInteractionModeAction(kind) - action.setIcon(icons.getQIcon(icon)) - action.setToolTip(tooltip) - self.addAction(action) - - # Add clear action - action = qt.QAction(icons.getQIcon('profile-clear'), - 'Clear Profile', self) - action.setToolTip('Clear the profile') - action.setCheckable(False) - action.triggered.connect(self.clearProfile) - self.addAction(action) - - # Initialize color - self._color = None - self.setColor('red') - - # Listen to plot limits changed - plot.getXAxis().sigLimitsChanged.connect(self.updateProfile) - plot.getYAxis().sigLimitsChanged.connect(self.updateProfile) - - # Listen to plot scale - plot.getXAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged) - plot.getYAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged) - - self.setDefaultProfileWindowEnabled(True) - - def getProfilePoints(self, copy=True): - """Returns the profile sampling points as (x, y) or None - - :param bool copy: True to get a copy, - False to get internal arrays (do not modify) - :rtype: Union[numpy.ndarray,None] - """ - if self.__profile is None: - return None - else: - return numpy.array(self.__profile[0], copy=copy) - - def getProfileValues(self, copy=True): - """Returns the values of the profile or None - - :param bool copy: True to get a copy, - False to get internal arrays (do not modify) - :rtype: Union[numpy.ndarray,None] - """ - if self.__profile is None: - return None - else: - return numpy.array(self.__profile[1], copy=copy) - - def getProfileTitle(self): - """Returns the profile title - - :rtype: str - """ - return self.__profileTitle - - # Handle plot reference - - def __plotDestroyed(self, ref): - """Handle finalization of PlotWidget - - :param ref: weakref to the plot - """ - self._plotRef = None - self.setEnabled(False) # Profile is pointless - for action in self.actions(): # TODO useful? - self.removeAction(action) - - def getPlotWidget(self): - """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar. - - :rtype: Union[~silx.gui.plot.PlotWidget,None] - """ - return None if self._plotRef is None else self._plotRef() - - def _getRoiManager(self): - """Returns the used ROI manager - - :rtype: RegionOfInterestManager - """ - return self._roiManagerRef() - - # Profile Plot - - def isDefaultProfileWindowEnabled(self): - """Returns True if the default floating profile window is used - - :rtype: bool - """ - return self.getDefaultProfileWindow() is not None - - def setDefaultProfileWindowEnabled(self, enabled): - """Set whether to use or not the default floating profile window. - - :param bool enabled: True to use, False to disable - """ - if self.isDefaultProfileWindowEnabled() != enabled: - if enabled: - self._profileWindow = ProfileMainWindow(self) - self._profileWindow.sigClose.connect(self.clearProfile) - self.sigProfileChanged.connect(self.__updateDefaultProfilePlot) - - else: - self.sigProfileChanged.disconnect(self.__updateDefaultProfilePlot) - self._profileWindow.sigClose.disconnect(self.clearProfile) - self._profileWindow.close() - self._profileWindow = None - - def getDefaultProfileWindow(self): - """Returns the default floating profile window if in use else None. - - See :meth:`isDefaultProfileWindowEnabled` - - :rtype: Union[ProfileMainWindow,None] - """ - return self._profileWindow - - def __updateDefaultProfilePlot(self): - """Update the plot of the default profile window""" - profileWindow = self.getDefaultProfileWindow() - if profileWindow is None: - return - - profilePlot = profileWindow.getPlot() - if profilePlot is None: - return - - profilePlot.clear() - profilePlot.setGraphTitle(self.getProfileTitle()) - - points = self.getProfilePoints(copy=False) - values = self.getProfileValues(copy=False) - - if points is not None and values is not None: - if (numpy.abs(points[-1, 0] - points[0, 0]) > - numpy.abs(points[-1, 1] - points[0, 1])): - xProfile = points[:, 0] - profilePlot.getXAxis().setLabel('X') - else: - xProfile = points[:, 1] - profilePlot.getXAxis().setLabel('Y') - - profilePlot.addCurve( - xProfile, values, legend='Profile', color=self._color) - - self._showDefaultProfileWindow() - - def _showDefaultProfileWindow(self): - """If profile window was created by this toolbar, - try to avoid overlapping with the toolbar's parent window. - """ - profileWindow = self.getDefaultProfileWindow() - roiManager = self._getRoiManager() - if profileWindow is None or roiManager is None: - return - - if roiManager.isStarted() and not profileWindow.isVisible(): - profileWindow.show() - profileWindow.raise_() - - window = self.window() - winGeom = window.frameGeometry() - qapp = qt.QApplication.instance() - desktop = qapp.desktop() - screenGeom = desktop.availableGeometry(self) - spaceOnLeftSide = winGeom.left() - spaceOnRightSide = screenGeom.width() - winGeom.right() - - frameGeometry = profileWindow.frameGeometry() - profileWindowWidth = frameGeometry.width() - if profileWindowWidth < spaceOnRightSide: - # Place profile on the right - profileWindow.move(winGeom.right(), winGeom.top()) - elif profileWindowWidth < spaceOnLeftSide: - # Place profile on the left - profileWindow.move( - max(0, winGeom.left() - profileWindowWidth), winGeom.top()) - - # Handle plot in log scale - - def __plotAxisScaleChanged(self, scale): - """Handle change of axis scale in the plot widget""" - plot = self.getPlotWidget() - if plot is None: - return - - xScale = plot.getXAxis().getScale() - yScale = plot.getYAxis().getScale() - - if xScale == items.Axis.LINEAR and yScale == items.Axis.LINEAR: - self.setEnabled(True) - - else: - roiManager = self._getRoiManager() - if roiManager is not None: - roiManager.stop() # Stop interactive mode - - self.clearProfile() - self.setEnabled(False) - - # Profile color - - def getColor(self): - """Returns the color used for the profile and ROI - - :rtype: QColor - """ - return qt.QColor.fromRgbF(*self._color) - - def setColor(self, color): - """Set the color to use for ROI and profile. - - :param color: - Either a color name, a QColor, a list of uint8 or float in [0, 1]. - """ - self._color = colors.rgba(color) - roiManager = self._getRoiManager() - if roiManager is not None: - roiManager.setColor(self._color) - for roi in roiManager.getRois(): - roi.setColor(self._color) - self.updateProfile() - - # Handle ROI manager - - def __interactionFinished(self): - """Handle end of interactive mode""" - self.clearProfile() - - profileWindow = self.getDefaultProfileWindow() - if profileWindow is not None: - profileWindow.hide() - - def __roiAdded(self, roi): - """Handle new ROI""" - roi.setLabel('Profile') - roi.setEditable(True) - - # Remove any other ROI - roiManager = self._getRoiManager() - if roiManager is not None: - for regionOfInterest in list(roiManager.getRois()): - if regionOfInterest is not roi: - roiManager.removeRoi(regionOfInterest) - - def computeProfile(self, x0, y0, x1, y1): - """Compute corresponding profile - - Override in subclass to compute profile - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: (points, values) profile data or None - """ - return None - - def computeProfileTitle(self, x0, y0, x1, y1): - """Compute corresponding plot title - - This can be overridden to change title behavior. - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: Title to use - :rtype: str - """ - if x0 == x1: - title = 'X = %g; Y = [%g, %g]' % (x0, y0, y1) - elif y0 == y1: - title = 'Y = %g; X = [%g, %g]' % (y0, x0, x1) - else: - m = (y1 - y0) / (x1 - x0) - b = y0 - m * x0 - title = 'Y = %g * X %+g' % (m, b) - - return title - - def updateProfile(self): - """Update profile according to current ROI""" - roiManager = self._getRoiManager() - if roiManager is None: - roi = None - else: - rois = roiManager.getRois() - roi = None if len(rois) == 0 else rois[0] - - if roi is None: - self._setProfile(profile=None, title='') - return - - # Get end points - if isinstance(roi, roi_items.LineROI): - points = roi.getEndPoints() - x0, y0 = points[0] - x1, y1 = points[1] - elif isinstance(roi, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)): - plot = self.getPlotWidget() - if plot is None: - self._setProfile(profile=None, title='') - return - - elif isinstance(roi, roi_items.HorizontalLineROI): - x0, x1 = plot.getXAxis().getLimits() - y0 = y1 = roi.getPosition() - - elif isinstance(roi, roi_items.VerticalLineROI): - x0 = x1 = roi.getPosition() - y0, y1 = plot.getYAxis().getLimits() - - else: - raise RuntimeError('Unsupported ROI for profile: {}'.format(roi.__class__)) - - if x1 < x0 or (x1 == x0 and y1 < y0): - # Invert points - x0, y0, x1, y1 = x1, y1, x0, y0 - - profile = self.computeProfile(x0, y0, x1, y1) - title = self.computeProfileTitle(x0, y0, x1, y1) - self._setProfile(profile=profile, title=title) - - def _setProfile(self, profile=None, title=''): - """Set profile data and emit signal. - - :param profile: points and profile values - :param str title: - """ - self.__profile = profile - self.__profileTitle = title - - self.sigProfileChanged.emit() - - def clearProfile(self): - """Clear the current line ROI and associated profile""" - roiManager = self._getRoiManager() - if roiManager is not None: - roiManager.clear() - - self._setProfile(profile=None, title='') diff --git a/silx/gui/plot/tools/profile/__init__.py b/silx/gui/plot/tools/profile/__init__.py deleted file mode 100644 index d91191e..0000000 --- a/silx/gui/plot/tools/profile/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides tools to get profiles on plot data. - -It provides: - -- :class:`ScatterProfileToolBar`: a QToolBar to handle profile on scatter data - -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "07/06/2018" - - -from .ScatterProfileToolBar import ScatterProfileToolBar # noqa diff --git a/silx/gui/plot/tools/roi.py b/silx/gui/plot/tools/roi.py deleted file mode 100644 index d58c041..0000000 --- a/silx/gui/plot/tools/roi.py +++ /dev/null @@ -1,934 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides ROI interaction for :class:`~silx.gui.plot.PlotWidget`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import collections -import functools -import logging -import time -import weakref - -import numpy - -from ....third_party import enum -from ....utils.weakref import WeakMethodProxy -from ... import qt, icons -from .. import PlotWidget -from ..items import roi as roi_items - -from ...colors import rgba - - -logger = logging.getLogger(__name__) - - -class RegionOfInterestManager(qt.QObject): - """Class handling ROI interaction on a PlotWidget. - - It supports the multiple ROIs: points, rectangles, polygons, - lines, horizontal and vertical lines. - - See ``plotInteractiveImageROI.py`` sample code (:ref:`sample-code`). - - :param silx.gui.plot.PlotWidget parent: - The plot widget in which to control the ROIs. - """ - - sigRoiAdded = qt.Signal(roi_items.RegionOfInterest) - """Signal emitted when a new ROI has been added. - - It provides the newly add :class:`RegionOfInterest` object. - """ - - sigRoiAboutToBeRemoved = qt.Signal(roi_items.RegionOfInterest) - """Signal emitted just before a ROI is removed. - - It provides the :class:`RegionOfInterest` object that is about to be removed. - """ - - sigRoiChanged = qt.Signal() - """Signal emitted whenever the ROIs have changed.""" - - sigInteractiveModeStarted = qt.Signal(object) - """Signal emitted when switching to ROI drawing interactive mode. - - It provides the class of the ROI which will be created by the interactive - mode. - """ - - sigInteractiveModeFinished = qt.Signal() - """Signal emitted when leaving and interactive ROI drawing. - - It provides the list of ROIs. - """ - - _MODE_ACTIONS_PARAMS = collections.OrderedDict() - # Interactive mode: (icon name, text) - _MODE_ACTIONS_PARAMS[roi_items.PointROI] = 'add-shape-point', 'Add point markers' - _MODE_ACTIONS_PARAMS[roi_items.RectangleROI] = 'add-shape-rectangle', 'Add rectangle ROI' - _MODE_ACTIONS_PARAMS[roi_items.PolygonROI] = 'add-shape-polygon', 'Add polygon ROI' - _MODE_ACTIONS_PARAMS[roi_items.LineROI] = 'add-shape-diagonal', 'Add line ROI' - _MODE_ACTIONS_PARAMS[roi_items.HorizontalLineROI] = 'add-shape-horizontal', 'Add horizontal line ROI' - _MODE_ACTIONS_PARAMS[roi_items.VerticalLineROI] = 'add-shape-vertical', 'Add vertical line ROI' - _MODE_ACTIONS_PARAMS[roi_items.ArcROI] = 'add-shape-arc', 'Add arc ROI' - - def __init__(self, parent): - assert isinstance(parent, PlotWidget) - super(RegionOfInterestManager, self).__init__(parent) - self._rois = [] # List of ROIs - self._drawnROI = None # New ROI being currently drawn - - self._roiClass = None - self._color = rgba('red') - - self._label = "__RegionOfInterestManager__%d" % id(self) - - self._eventLoop = None - - self._modeActions = {} - - parent.sigInteractiveModeChanged.connect( - self._plotInteractiveModeChanged) - - @classmethod - def getSupportedRoiClasses(cls): - """Returns the default available ROI classes - - :rtype: List[class] - """ - return tuple(cls._MODE_ACTIONS_PARAMS.keys()) - - # Associated QActions - - def getInteractionModeAction(self, roiClass): - """Returns the QAction corresponding to a kind of ROI - - The QAction allows to enable the corresponding drawing - interactive mode. - - :param str roiClass: The ROI class which will be crated by this action. - :rtype: QAction - :raise ValueError: If kind is not supported - """ - if not issubclass(roiClass, roi_items.RegionOfInterest): - raise ValueError('Unsupported ROI class %s' % roiClass) - - action = self._modeActions.get(roiClass, None) - if action is None: # Lazy-loading - if roiClass in self._MODE_ACTIONS_PARAMS: - iconName, text = self._MODE_ACTIONS_PARAMS[roiClass] - else: - iconName = "add-shape-unknown" - name = roiClass._getKind() - if name is None: - name = roiClass.__name__ - text = 'Add %s' % name - action = qt.QAction(self) - action.setIcon(icons.getQIcon(iconName)) - action.setText(text) - action.setCheckable(True) - action.setChecked(self.getCurrentInteractionModeRoiClass() is roiClass) - action.setToolTip(text) - - action.triggered[bool].connect(functools.partial( - WeakMethodProxy(self._modeActionTriggered), roiClass=roiClass)) - self._modeActions[roiClass] = action - return action - - def _modeActionTriggered(self, checked, roiClass): - """Handle mode actions being checked by the user - - :param bool checked: - :param str kind: Corresponding shape kind - """ - if checked: - self.start(roiClass) - else: # Keep action checked - action = self.sender() - action.setChecked(True) - - def _updateModeActions(self): - """Check/Uncheck action corresponding to current mode""" - for roiClass, action in self._modeActions.items(): - action.setChecked(roiClass == self.getCurrentInteractionModeRoiClass()) - - # PlotWidget eventFilter and listeners - - def _plotInteractiveModeChanged(self, source): - """Handle change of interactive mode in the plot""" - if source is not self: - self.__roiInteractiveModeEnded() - - else: # Check the corresponding action - self._updateModeActions() - - # Handle ROI interaction - - def _handleInteraction(self, event): - """Handle mouse interaction for ROI addition""" - roiClass = self.getCurrentInteractionModeRoiClass() - if roiClass is None: - return # Should not happen - - kind = roiClass.getFirstInteractionShape() - if kind == 'point': - if event['event'] == 'mouseClicked' and event['button'] == 'left': - points = numpy.array([(event['x'], event['y'])], - dtype=numpy.float64) - self.createRoi(roiClass, points=points) - - else: # other shapes - if (event['event'] in ('drawingProgress', 'drawingFinished') and - event['parameters']['label'] == self._label): - points = numpy.array((event['xdata'], event['ydata']), - dtype=numpy.float64).T - - if self._drawnROI is None: # Create new ROI - self._drawnROI = self.createRoi(roiClass, points=points) - else: - self._drawnROI.setFirstShapePoints(points) - - if event['event'] == 'drawingFinished': - if kind == 'polygon' and len(points) > 1: - self._drawnROI.setFirstShapePoints(points[:-1]) - self._drawnROI = None # Stop drawing - - # RegionOfInterest API - - def getRois(self): - """Returns the list of ROIs. - - It returns an empty tuple if there is currently no ROI. - - :return: Tuple of arrays of objects describing the ROIs - :rtype: List[RegionOfInterest] - """ - return tuple(self._rois) - - def clear(self): - """Reset current ROIs - - :return: True if ROIs were reset. - :rtype: bool - """ - if self.getRois(): # Something to reset - for roi in self._rois: - roi.sigRegionChanged.disconnect( - self._regionOfInterestChanged) - roi.setParent(None) - self._rois = [] - self._roisUpdated() - return True - - else: - return False - - def _regionOfInterestChanged(self): - """Handle ROI object changed""" - self.sigRoiChanged.emit() - - def createRoi(self, roiClass, points, label='', index=None): - """Create a new ROI and add it to list of ROIs. - - :param class roiClass: The class of the ROI to create - :param numpy.ndarray points: The first shape used to create the ROI - :param str label: The label to display along with the ROI. - :param int index: The position where to insert the ROI. - By default it is appended to the end of the list. - :return: The created ROI object - :rtype: roi_items.RegionOfInterest - :raise RuntimeError: When ROI cannot be added because the maximum - number of ROIs has been reached. - """ - roi = roiClass(parent=None) - roi.setLabel(str(label)) - roi.setFirstShapePoints(points) - - self.addRoi(roi, index) - return roi - - def addRoi(self, roi, index=None, useManagerColor=True): - """Add the ROI to the list of ROIs. - - :param roi_items.RegionOfInterest roi: The ROI to add - :param int index: The position where to insert the ROI, - By default it is appended to the end of the list of ROIs - :raise RuntimeError: When ROI cannot be added because the maximum - number of ROIs has been reached. - """ - plot = self.parent() - if plot is None: - raise RuntimeError( - 'Cannot add ROI: PlotWidget no more available') - - roi.setParent(self) - - if useManagerColor: - roi.setColor(self.getColor()) - - roi.sigRegionChanged.connect(self._regionOfInterestChanged) - - if index is None: - self._rois.append(roi) - else: - self._rois.insert(index, roi) - self.sigRoiAdded.emit(roi) - self._roisUpdated() - - def removeRoi(self, roi): - """Remove a ROI from the list of ROIs. - - :param roi_items.RegionOfInterest roi: The ROI to remove - :raise ValueError: When ROI does not belong to this object - """ - if not (isinstance(roi, roi_items.RegionOfInterest) and - roi.parent() is self and - roi in self._rois): - raise ValueError( - 'RegionOfInterest does not belong to this instance') - - self.sigRoiAboutToBeRemoved.emit(roi) - - self._rois.remove(roi) - roi.sigRegionChanged.disconnect(self._regionOfInterestChanged) - roi.setParent(None) - self._roisUpdated() - - def _roisUpdated(self): - """Handle update of the ROI list""" - self.sigRoiChanged.emit() - - # RegionOfInterest parameters - - def getColor(self): - """Return the default color of created ROIs - - :rtype: QColor - """ - return qt.QColor.fromRgbF(*self._color) - - def setColor(self, color): - """Set the default color to use when creating ROIs. - - Existing ROIs are not affected. - - :param color: The color to use for displaying ROIs as - either a color name, a QColor, a list of uint8 or float in [0, 1]. - """ - self._color = rgba(color) - - # Control ROI - - def getCurrentInteractionModeRoiClass(self): - """Returns the current ROI class used by the interactive drawing mode. - - Returns None if the ROI manager is not in an interactive mode. - - :rtype: Union[class,None] - """ - return self._roiClass - - def isStarted(self): - """Returns True if an interactive ROI drawing mode is active. - - :rtype: bool - """ - return self._roiClass is not None - - def start(self, roiClass): - """Start an interactive ROI drawing mode. - - :param class roiClass: The ROI class to create. It have to inherite from - `roi_items.RegionOfInterest`. - :return: True if interactive ROI drawing was started, False otherwise - :rtype: bool - :raise ValueError: If roiClass is not supported - """ - self.stop() - - if not issubclass(roiClass, roi_items.RegionOfInterest): - raise ValueError('Unsupported ROI class %s' % roiClass) - - plot = self.parent() - if plot is None: - return False - - self._roiClass = roiClass - firstInteractionShapeKind = roiClass.getFirstInteractionShape() - - if firstInteractionShapeKind == 'point': - plot.setInteractiveMode(mode='select', source=self) - else: - if roiClass.showFirstInteractionShape(): - color = rgba(self.getColor()) - else: - color = None - plot.setInteractiveMode(mode='select-draw', - source=self, - shape=firstInteractionShapeKind, - color=color, - label=self._label) - - plot.sigPlotSignal.connect(self._handleInteraction) - - self.sigInteractiveModeStarted.emit(roiClass) - - return True - - def __roiInteractiveModeEnded(self): - """Handle end of ROI draw interactive mode""" - if self.isStarted(): - self._roiClass = None - - if self._drawnROI is not None: - # Cancel ROI create - self.removeRoi(self._drawnROI) - self._drawnROI = None - - plot = self.parent() - if plot is not None: - plot.sigPlotSignal.disconnect(self._handleInteraction) - - self._updateModeActions() - - self.sigInteractiveModeFinished.emit() - - def stop(self): - """Stop interactive ROI drawing mode. - - :return: True if an interactive ROI drawing mode was actually stopped - :rtype: bool - """ - if not self.isStarted(): - return False - - plot = self.parent() - if plot is not None: - # This leads to call __roiInteractiveModeEnded through - # interactive mode changed signal - plot.setInteractiveMode(mode='zoom', source=None) - else: # Fallback - self.__roiInteractiveModeEnded() - - return True - - def exec_(self, roiClass): - """Block until :meth:`quit` is called. - - :param class kind: The class of the ROI which have to be created. - See `silx.gui.plot.items.roi`. - :return: The list of ROIs - :rtype: tuple - """ - self.start(roiClass) - - plot = self.parent() - plot.show() - plot.raise_() - - self._eventLoop = qt.QEventLoop() - self._eventLoop.exec_() - self._eventLoop = None - - self.stop() - - rois = self.getRois() - self.clear() - return rois - - def quit(self): - """Stop a blocking :meth:`exec_` and call :meth:`stop`""" - if self._eventLoop is not None: - self._eventLoop.quit() - self._eventLoop = None - self.stop() - - -class InteractiveRegionOfInterestManager(RegionOfInterestManager): - """RegionOfInterestManager with features for use from interpreter. - - It is meant to be used through the :meth:`exec_`. - It provides some messages to display in a status bar and - different modes to end blocking calls to :meth:`exec_`. - - :param parent: See QObject - """ - - sigMessageChanged = qt.Signal(str) - """Signal emitted when a new message should be displayed to the user - - It provides the message as a str. - """ - - def __init__(self, parent): - super(InteractiveRegionOfInterestManager, self).__init__(parent) - self._maxROI = None - self.__timeoutEndTime = None - self.__message = '' - self.__validationMode = self.ValidationMode.ENTER - self.__execClass = None - - self.sigRoiAdded.connect(self.__added) - self.sigRoiAboutToBeRemoved.connect(self.__aboutToBeRemoved) - self.sigInteractiveModeStarted.connect(self.__started) - self.sigInteractiveModeFinished.connect(self.__finished) - - # Max ROI - - def getMaxRois(self): - """Returns the maximum number of ROIs or None if no limit. - - :rtype: Union[int,None] - """ - return self._maxROI - - def setMaxRois(self, max_): - """Set the maximum number of ROIs. - - :param Union[int,None] max_: The max limit or None for no limit. - :raise ValueError: If there is more ROIs than max value - """ - if max_ is not None: - max_ = int(max_) - if max_ <= 0: - raise ValueError('Max limit must be strictly positive') - - if len(self.getRois()) > max_: - raise ValueError( - 'Cannot set max limit: Already too many ROIs') - - self._maxROI = max_ - - def isMaxRois(self): - """Returns True if the maximum number of ROIs is reached. - - :rtype: bool - """ - max_ = self.getMaxRois() - return max_ is not None and len(self.getRois()) >= max_ - - # Validation mode - - @enum.unique - class ValidationMode(enum.Enum): - """Mode of validation to leave blocking :meth:`exec_`""" - - AUTO = 'auto' - """Automatically ends the interactive mode once - the user terminates the last ROI shape.""" - - ENTER = 'enter' - """Ends the interactive mode when the *Enter* key is pressed.""" - - AUTO_ENTER = 'auto_enter' - """Ends the interactive mode when reaching max ROIs or - when the *Enter* key is pressed. - """ - - NONE = 'none' - """Do not provide the user a way to end the interactive mode. - - The end of :meth:`exec_` is done through :meth:`quit` or timeout. - """ - - def getValidationMode(self): - """Returns the interactive mode validation in use. - - :rtype: ValidationMode - """ - return self.__validationMode - - def setValidationMode(self, mode): - """Set the way to perform interactive mode validation. - - See :class:`ValidationMode` enumeration for the supported - validation modes. - - :param ValidationMode mode: The interactive mode validation to use. - """ - assert isinstance(mode, self.ValidationMode) - if mode != self.__validationMode: - self.__validationMode = mode - - if self.isExec(): - if (self.isMaxRois() and self.getValidationMode() in - (self.ValidationMode.AUTO, - self.ValidationMode.AUTO_ENTER)): - self.quit() - - self.__updateMessage() - - def eventFilter(self, obj, event): - if event.type() == qt.QEvent.Hide: - self.quit() - - if event.type() == qt.QEvent.KeyPress: - key = event.key() - if (key in (qt.Qt.Key_Return, qt.Qt.Key_Enter) and - self.getValidationMode() in ( - self.ValidationMode.ENTER, - self.ValidationMode.AUTO_ENTER)): - # Stop on return key pressed - self.quit() - return True # Stop further handling of this keys - - if (key in (qt.Qt.Key_Delete, qt.Qt.Key_Backspace) or ( - key == qt.Qt.Key_Z and - event.modifiers() & qt.Qt.ControlModifier)): - rois = self.getRois() - if rois: # Something to undo - self.removeRoi(rois[-1]) - # Stop further handling of keys if something was undone - return True - - return super(InteractiveRegionOfInterestManager, self).eventFilter(obj, event) - - # Message API - - def getMessage(self): - """Returns the current status message. - - This message is meant to be displayed in a status bar. - - :rtype: str - """ - if self.__timeoutEndTime is None: - return self.__message - else: - remaining = self.__timeoutEndTime - time.time() - return self.__message + (' - %d seconds remaining' % - max(1, int(remaining))) - - # Listen to ROI updates - - def __added(self, *args, **kwargs): - """Handle new ROI added""" - max_ = self.getMaxRois() - if max_ is not None: - # When reaching max number of ROIs, redo last one - while len(self.getRois()) > max_: - self.removeRoi(self.getRois()[-2]) - - self.__updateMessage() - if (self.isMaxRois() and - self.getValidationMode() in (self.ValidationMode.AUTO, - self.ValidationMode.AUTO_ENTER)): - self.quit() - - def __aboutToBeRemoved(self, *args, **kwargs): - """Handle removal of a ROI""" - # RegionOfInterest not removed yet - self.__updateMessage(nbrois=len(self.getRois()) - 1) - - def __started(self, roiKind): - """Handle interactive mode started""" - self.__updateMessage() - - def __finished(self): - """Handle interactive mode finished""" - self.__updateMessage() - - def __updateMessage(self, nbrois=None): - """Update message""" - if not self.isExec(): - message = 'Done' - - elif not self.isStarted(): - message = 'Use %s ROI edition mode' % self.__execClass - - else: - if nbrois is None: - nbrois = len(self.getRois()) - - kind = self.__execClass._getKind() - max_ = self.getMaxRois() - - if max_ is None: - message = 'Select %ss (%d selected)' % (kind, nbrois) - - elif max_ <= 1: - message = 'Select a %s' % kind - else: - message = 'Select %d/%d %ss' % (nbrois, max_, kind) - - if (self.getValidationMode() == self.ValidationMode.ENTER and - self.isMaxRois()): - message += ' - Press Enter to confirm' - - if message != self.__message: - self.__message = message - # Use getMessage to add timeout message - self.sigMessageChanged.emit(self.getMessage()) - - # Handle blocking call - - def __timeoutUpdate(self): - """Handle update of timeout""" - if (self.__timeoutEndTime is not None and - (self.__timeoutEndTime - time.time()) > 0): - self.sigMessageChanged.emit(self.getMessage()) - else: # Stop interactive mode and message timer - timer = self.sender() - if timer is not None: - timer.stop() - self.__timeoutEndTime = None - self.quit() - - def isExec(self): - """Returns True if :meth:`exec_` is currently running. - - :rtype: bool""" - return self.__execClass is not None - - def exec_(self, roiClass, timeout=0): - """Block until ROI selection is done or timeout is elapsed. - - :meth:`quit` also ends this blocking call. - - :param class roiClass: The class of the ROI which have to be created. - See `silx.gui.plot.items.roi`. - :param int timeout: Maximum duration in seconds to block. - Default: No timeout - :return: The list of ROIs - :rtype: List[RegionOfInterest] - """ - plot = self.parent() - if plot is None: - return - - self.__execClass = roiClass - - plot.installEventFilter(self) - - if timeout > 0: - self.__timeoutEndTime = time.time() + timeout - timer = qt.QTimer(self) - timer.timeout.connect(self.__timeoutUpdate) - timer.start(1000) - - rois = super(InteractiveRegionOfInterestManager, self).exec_(roiClass) - - timer.stop() - self.__timeoutEndTime = None - - else: - rois = super(InteractiveRegionOfInterestManager, self).exec_(roiClass) - - plot.removeEventFilter(self) - - self.__execClass = None - self.__updateMessage() - - return rois - - -class _DeleteRegionOfInterestToolButton(qt.QToolButton): - """Tool button deleting a ROI object - - :param parent: See QWidget - :param RegionOfInterest roi: The ROI to delete - """ - - def __init__(self, parent, roi): - super(_DeleteRegionOfInterestToolButton, self).__init__(parent) - self.setIcon(icons.getQIcon('remove')) - self.setToolTip("Remove this ROI") - self.__roiRef = roi if roi is None else weakref.ref(roi) - self.clicked.connect(self.__clicked) - - def __clicked(self, checked): - """Handle button clicked""" - roi = None if self.__roiRef is None else self.__roiRef() - if roi is not None: - manager = roi.parent() - if manager is not None: - manager.removeRoi(roi) - self.__roiRef = None - - -class RegionOfInterestTableWidget(qt.QTableWidget): - """Widget displaying the ROIs of a :class:`RegionOfInterestManager`""" - - def __init__(self, parent=None): - super(RegionOfInterestTableWidget, self).__init__(parent) - self._roiManagerRef = None - - self.setColumnCount(5) - self.setHorizontalHeaderLabels( - ['Label', 'Edit', 'Kind', 'Coordinates', '']) - - horizontalHeader = self.horizontalHeader() - horizontalHeader.setDefaultAlignment(qt.Qt.AlignLeft) - if hasattr(horizontalHeader, 'setResizeMode'): # Qt 4 - setSectionResizeMode = horizontalHeader.setResizeMode - else: # Qt5 - setSectionResizeMode = horizontalHeader.setSectionResizeMode - - setSectionResizeMode(0, qt.QHeaderView.Interactive) - setSectionResizeMode(1, qt.QHeaderView.ResizeToContents) - setSectionResizeMode(2, qt.QHeaderView.ResizeToContents) - setSectionResizeMode(3, qt.QHeaderView.Stretch) - setSectionResizeMode(4, qt.QHeaderView.ResizeToContents) - - verticalHeader = self.verticalHeader() - verticalHeader.setVisible(False) - - self.setSelectionMode(qt.QAbstractItemView.NoSelection) - self.setFocusPolicy(qt.Qt.NoFocus) - - self.itemChanged.connect(self.__itemChanged) - - @staticmethod - def __itemChanged(item): - """Handle item updates""" - column = item.column() - roi = item.data(qt.Qt.UserRole) - if column == 0: - roi.setLabel(item.text()) - elif column == 1: - roi.setEditable( - item.checkState() == qt.Qt.Checked) - elif column in (2, 3, 4): - pass # TODO - else: - logger.error('Unhandled column %d', column) - - def setRegionOfInterestManager(self, manager): - """Set the :class:`RegionOfInterestManager` object to sync with - - :param RegionOfInterestManager manager: - """ - assert manager is None or isinstance(manager, RegionOfInterestManager) - - previousManager = self.getRegionOfInterestManager() - - if previousManager is not None: - previousManager.sigRoiChanged.disconnect(self._sync) - self.setRowCount(0) - - self._roiManagerRef = weakref.ref(manager) - - self._sync() - - if manager is not None: - manager.sigRoiChanged.connect(self._sync) - - def _getReadableRoiDescription(self, roi): - """Returns modelisation of a ROI as a readable sequence of values. - - :rtype: str - """ - text = str(roi) - try: - # Extract the params from syntax "CLASSNAME(PARAMS)" - elements = text.split("(", 1) - if len(elements) != 2: - return text - result = elements[1] - result = result.strip() - if not result.endswith(")"): - return text - result = result[0:-1] - # Capitalize each words - result = result.title() - return result - except Exception: - logger.debug("Backtrace", exc_info=True) - return text - - def _sync(self): - """Update widget content according to ROI manger""" - manager = self.getRegionOfInterestManager() - - if manager is None: - self.setRowCount(0) - return - - rois = manager.getRois() - - self.setRowCount(len(rois)) - for index, roi in enumerate(rois): - baseFlags = qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled - - # Label - label = roi.getLabel() - item = qt.QTableWidgetItem(label) - item.setFlags(baseFlags | qt.Qt.ItemIsEditable) - item.setData(qt.Qt.UserRole, roi) - self.setItem(index, 0, item) - - # Editable - item = qt.QTableWidgetItem() - item.setFlags(baseFlags | qt.Qt.ItemIsUserCheckable) - item.setData(qt.Qt.UserRole, roi) - item.setCheckState( - qt.Qt.Checked if roi.isEditable() else qt.Qt.Unchecked) - self.setItem(index, 1, item) - item.setTextAlignment(qt.Qt.AlignCenter) - item.setText(None) - - # Kind - label = roi._getKind() - if label is None: - # Default value if kind is not overrided - label = roi.__class__.__name__ - item = qt.QTableWidgetItem(label.capitalize()) - item.setFlags(baseFlags) - self.setItem(index, 2, item) - - item = qt.QTableWidgetItem() - item.setFlags(baseFlags) - - # Coordinates - text = self._getReadableRoiDescription(roi) - item.setText(text) - self.setItem(index, 3, item) - - # Delete - delBtn = _DeleteRegionOfInterestToolButton(None, roi) - widget = qt.QWidget(self) - layout = qt.QHBoxLayout() - layout.setContentsMargins(2, 2, 2, 2) - layout.setSpacing(0) - widget.setLayout(layout) - layout.addStretch(1) - layout.addWidget(delBtn) - layout.addStretch(1) - self.setCellWidget(index, 4, widget) - - def getRegionOfInterestManager(self): - """Returns the :class:`RegionOfInterestManager` this widget supervise. - - It returns None if not sync with an :class:`RegionOfInterestManager`. - - :rtype: RegionOfInterestManager - """ - return None if self._roiManagerRef is None else self._roiManagerRef() diff --git a/silx/gui/plot/tools/test/__init__.py b/silx/gui/plot/tools/test/__init__.py deleted file mode 100644 index 9cede27..0000000 --- a/silx/gui/plot/tools/test/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "26/03/2018" - - -import unittest - -from . import testROI -from . import testTools -from . import testScatterProfileToolBar -from . import testCurveLegendsWidget - - -def suite(): - test_suite = unittest.TestSuite() - test_suite.addTests( - [testROI.suite(), - testTools.suite(), - testScatterProfileToolBar.suite(), - testCurveLegendsWidget.suite(), - ]) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/tools/test/testCurveLegendsWidget.py b/silx/gui/plot/tools/test/testCurveLegendsWidget.py deleted file mode 100644 index 4824dd7..0000000 --- a/silx/gui/plot/tools/test/testCurveLegendsWidget.py +++ /dev/null @@ -1,125 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "02/08/2018" - - -import unittest - -from silx.gui import qt -from silx.utils.testutils import ParametricTestCase -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot import PlotWindow -from silx.gui.plot.tools import CurveLegendsWidget - - -class TestCurveLegendsWidget(TestCaseQt, ParametricTestCase): - """Tests for CurveLegendsWidget class""" - - def setUp(self): - super(TestCurveLegendsWidget, self).setUp() - self.plot = PlotWindow() - - self.legends = CurveLegendsWidget.CurveLegendsWidget() - self.legends.setPlotWidget(self.plot) - - dock = qt.QDockWidget() - dock.setWindowTitle('Curve Legends') - dock.setWidget(self.legends) - self.plot.addTabbedDockWidget(dock) - - self.plot.show() - self.qWaitForWindowExposed(self.plot) - - def tearDown(self): - del self.legends - self.qapp.processEvents() - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - del self.plot - super(TestCurveLegendsWidget, self).tearDown() - - def _assertNbLegends(self, count): - """Check the number of legends in the CurveLegendsWidget""" - children = self.legends.findChildren(CurveLegendsWidget._LegendWidget) - self.assertEqual(len(children), count) - - def testAddRemoveCurves(self): - """Test CurveLegendsWidget while adding/removing curves""" - self.plot.addCurve((0, 1), (1, 2), legend='a') - self._assertNbLegends(1) - self.plot.addCurve((0, 1), (2, 3), legend='b') - self._assertNbLegends(2) - - # Detached/attach - self.legends.setPlotWidget(None) - self._assertNbLegends(0) - - self.legends.setPlotWidget(self.plot) - self._assertNbLegends(2) - - self.plot.clear() - self._assertNbLegends(0) - - def testUpdateCurves(self): - """Test CurveLegendsWidget while updating curves """ - self.plot.addCurve((0, 1), (1, 2), legend='a') - self._assertNbLegends(1) - self.plot.addCurve((0, 1), (2, 3), legend='b') - self._assertNbLegends(2) - - # Activate curve - self.plot.setActiveCurve('a') - self.qapp.processEvents() - self.plot.setActiveCurve('b') - self.qapp.processEvents() - - # Change curve style - curve = self.plot.getCurve('a') - curve.setLineWidth(2) - for linestyle in (':', '', '--', '-'): - with self.subTest(linestyle=linestyle): - curve.setLineStyle(linestyle) - self.qapp.processEvents() - self.qWait(1000) - - for symbol in ('o', 'd', '', 's'): - with self.subTest(symbol=symbol): - curve.setSymbol(symbol) - self.qapp.processEvents() - self.qWait(1000) - - -def suite(): - test_suite = unittest.TestSuite() - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase( - TestCurveLegendsWidget)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/tools/test/testROI.py b/silx/gui/plot/tools/test/testROI.py deleted file mode 100644 index 8aec1d9..0000000 --- a/silx/gui/plot/tools/test/testROI.py +++ /dev/null @@ -1,456 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import unittest -import numpy.testing - -from silx.gui import qt -from silx.utils.testutils import ParametricTestCase -from silx.gui.utils.testutils import TestCaseQt, SignalListener -from silx.gui.plot import PlotWindow -import silx.gui.plot.items.roi as roi_items -from silx.gui.plot.tools import roi - - -class TestRoiItems(TestCaseQt): - - def testLine_geometry(self): - item = roi_items.LineROI() - startPoint = numpy.array([1, 2]) - endPoint = numpy.array([3, 4]) - item.setEndPoints(startPoint, endPoint) - numpy.testing.assert_allclose(item.getEndPoints()[0], startPoint) - numpy.testing.assert_allclose(item.getEndPoints()[1], endPoint) - - def testHLine_geometry(self): - item = roi_items.HorizontalLineROI() - item.setPosition(15) - self.assertEqual(item.getPosition(), 15) - - def testVLine_geometry(self): - item = roi_items.VerticalLineROI() - item.setPosition(15) - self.assertEqual(item.getPosition(), 15) - - def testPoint_geometry(self): - point = numpy.array([1, 2]) - item = roi_items.VerticalLineROI() - item.setPosition(point) - numpy.testing.assert_allclose(item.getPosition(), point) - - def testRectangle_originGeometry(self): - origin = numpy.array([0, 0]) - size = numpy.array([10, 20]) - center = numpy.array([5, 10]) - item = roi_items.RectangleROI() - item.setGeometry(origin=origin, size=size) - numpy.testing.assert_allclose(item.getOrigin(), origin) - numpy.testing.assert_allclose(item.getSize(), size) - numpy.testing.assert_allclose(item.getCenter(), center) - - def testRectangle_centerGeometry(self): - origin = numpy.array([0, 0]) - size = numpy.array([10, 20]) - center = numpy.array([5, 10]) - item = roi_items.RectangleROI() - item.setGeometry(center=center, size=size) - numpy.testing.assert_allclose(item.getOrigin(), origin) - numpy.testing.assert_allclose(item.getSize(), size) - numpy.testing.assert_allclose(item.getCenter(), center) - - def testRectangle_setCenterGeometry(self): - origin = numpy.array([0, 0]) - size = numpy.array([10, 20]) - item = roi_items.RectangleROI() - item.setGeometry(origin=origin, size=size) - newCenter = numpy.array([0, 0]) - item.setCenter(newCenter) - expectedOrigin = numpy.array([-5, -10]) - numpy.testing.assert_allclose(item.getOrigin(), expectedOrigin) - numpy.testing.assert_allclose(item.getCenter(), newCenter) - numpy.testing.assert_allclose(item.getSize(), size) - - def testRectangle_setOriginGeometry(self): - origin = numpy.array([0, 0]) - size = numpy.array([10, 20]) - item = roi_items.RectangleROI() - item.setGeometry(origin=origin, size=size) - newOrigin = numpy.array([10, 10]) - item.setOrigin(newOrigin) - expectedCenter = numpy.array([15, 20]) - numpy.testing.assert_allclose(item.getOrigin(), newOrigin) - numpy.testing.assert_allclose(item.getCenter(), expectedCenter) - numpy.testing.assert_allclose(item.getSize(), size) - - def testPolygon_emptyGeometry(self): - points = numpy.empty((0, 2)) - item = roi_items.PolygonROI() - item.setPoints(points) - numpy.testing.assert_allclose(item.getPoints(), points) - - def testPolygon_geometry(self): - points = numpy.array([[10, 10], [12, 10], [50, 1]]) - item = roi_items.PolygonROI() - item.setPoints(points) - numpy.testing.assert_allclose(item.getPoints(), points) - - def testArc_getToSetGeometry(self): - """Test that we can use getGeometry as input to setGeometry""" - item = roi_items.ArcROI() - item.setFirstShapePoints(numpy.array([[5, 10], [50, 100]])) - item.setGeometry(*item.getGeometry()) - - def testArc_degenerated_point(self): - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 0, 0, 0, 0 - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - - def testArc_degenerated_line(self): - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 0, 100, numpy.pi, numpy.pi - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - - def testArc_special_circle(self): - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 0, 100, numpy.pi, 3 * numpy.pi - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - numpy.testing.assert_allclose(item.getCenter(), center) - self.assertAlmostEqual(item.getInnerRadius(), innerRadius) - self.assertAlmostEqual(item.getOuterRadius(), outerRadius) - self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0) - self.assertAlmostEqual(item.isClosed(), True) - - def testArc_special_donut(self): - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi, 3 * numpy.pi - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - numpy.testing.assert_allclose(item.getCenter(), center) - self.assertAlmostEqual(item.getInnerRadius(), innerRadius) - self.assertAlmostEqual(item.getOuterRadius(), outerRadius) - self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0) - self.assertAlmostEqual(item.isClosed(), True) - - def testArc_clockwiseGeometry(self): - """Test that we can use getGeometry as input to setGeometry""" - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, numpy.pi - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - numpy.testing.assert_allclose(item.getCenter(), center) - self.assertAlmostEqual(item.getInnerRadius(), innerRadius) - self.assertAlmostEqual(item.getOuterRadius(), outerRadius) - self.assertAlmostEqual(item.getStartAngle(), startAngle) - self.assertAlmostEqual(item.getEndAngle(), endAngle) - self.assertAlmostEqual(item.isClosed(), False) - - def testArc_anticlockwiseGeometry(self): - """Test that we can use getGeometry as input to setGeometry""" - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, -numpy.pi * 0.5 - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - numpy.testing.assert_allclose(item.getCenter(), center) - self.assertAlmostEqual(item.getInnerRadius(), innerRadius) - self.assertAlmostEqual(item.getOuterRadius(), outerRadius) - self.assertAlmostEqual(item.getStartAngle(), startAngle) - self.assertAlmostEqual(item.getEndAngle(), endAngle) - self.assertAlmostEqual(item.isClosed(), False) - - -class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): - """Tests for RegionOfInterestManager class""" - - def setUp(self): - super(TestRegionOfInterestManager, self).setUp() - self.plot = PlotWindow() - - self.roiTableWidget = roi.RegionOfInterestTableWidget() - dock = qt.QDockWidget() - dock.setWidget(self.roiTableWidget) - self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) - - self.plot.show() - self.qWaitForWindowExposed(self.plot) - - def tearDown(self): - del self.roiTableWidget - self.qapp.processEvents() - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - del self.plot - super(TestRegionOfInterestManager, self).tearDown() - - def test(self): - """Test ROI of different shapes""" - tests = ( # shape, points=[list of (x, y), list of (x, y)] - (roi_items.PointROI, numpy.array(([(10., 15.)], [(20., 25.)]))), - (roi_items.RectangleROI, - numpy.array((((1., 10.), (11., 20.)), - ((2., 3.), (12., 13.))))), - (roi_items.PolygonROI, - numpy.array((((0., 1.), (0., 10.), (10., 0.)), - ((5., 6.), (5., 16.), (15., 6.))))), - (roi_items.LineROI, - numpy.array((((10., 20.), (10., 30.)), - ((30., 40.), (30., 50.))))), - (roi_items.HorizontalLineROI, - numpy.array((((10., 20.), (10., 30.)), - ((30., 40.), (30., 50.))))), - (roi_items.VerticalLineROI, - numpy.array((((10., 20.), (10., 30.)), - ((30., 40.), (30., 50.))))), - ) - - for roiClass, points in tests: - with self.subTest(roiClass=roiClass): - manager = roi.RegionOfInterestManager(self.plot) - self.roiTableWidget.setRegionOfInterestManager(manager) - manager.start(roiClass) - - self.assertEqual(manager.getRois(), ()) - - finishListener = SignalListener() - manager.sigInteractiveModeFinished.connect(finishListener) - - changedListener = SignalListener() - manager.sigRoiChanged.connect(changedListener) - - # Add a point - manager.createRoi(roiClass, points[0]) - self.qapp.processEvents() - self.assertTrue(len(manager.getRois()), 1) - self.assertEqual(changedListener.callCount(), 1) - - # Remove it - manager.removeRoi(manager.getRois()[0]) - self.assertEqual(manager.getRois(), ()) - self.assertEqual(changedListener.callCount(), 2) - - # Add two point - manager.createRoi(roiClass, points[0]) - self.qapp.processEvents() - manager.createRoi(roiClass, points[1]) - self.qapp.processEvents() - self.assertTrue(len(manager.getRois()), 2) - self.assertEqual(changedListener.callCount(), 4) - - # Reset it - result = manager.clear() - self.assertTrue(result) - self.assertEqual(manager.getRois(), ()) - self.assertEqual(changedListener.callCount(), 5) - - changedListener.clear() - - # Add two point - manager.createRoi(roiClass, points[0]) - self.qapp.processEvents() - manager.createRoi(roiClass, points[1]) - self.qapp.processEvents() - self.assertTrue(len(manager.getRois()), 2) - self.assertEqual(changedListener.callCount(), 2) - - # stop - result = manager.stop() - self.assertTrue(result) - self.assertTrue(len(manager.getRois()), 1) - self.qapp.processEvents() - self.assertEqual(finishListener.callCount(), 1) - - manager.clear() - - def testRoiDisplay(self): - rois = [] - - # Line - item = roi_items.LineROI() - startPoint = numpy.array([1, 2]) - endPoint = numpy.array([3, 4]) - item.setEndPoints(startPoint, endPoint) - rois.append(item) - # Horizontal line - item = roi_items.HorizontalLineROI() - item.setPosition(15) - rois.append(item) - # Vertical line - item = roi_items.VerticalLineROI() - item.setPosition(15) - rois.append(item) - # Point - item = roi_items.PointROI() - point = numpy.array([1, 2]) - item.setPosition(point) - rois.append(item) - # Rectangle - item = roi_items.RectangleROI() - origin = numpy.array([0, 0]) - size = numpy.array([10, 20]) - item.setGeometry(origin=origin, size=size) - rois.append(item) - # Polygon - item = roi_items.PolygonROI() - points = numpy.array([[10, 10], [12, 10], [50, 1]]) - item.setPoints(points) - rois.append(item) - # Degenerated polygon: No points - item = roi_items.PolygonROI() - points = numpy.empty((0, 2)) - item.setPoints(points) - rois.append(item) - # Degenerated polygon: A single point - item = roi_items.PolygonROI() - points = numpy.array([[5, 10]]) - item.setPoints(points) - rois.append(item) - # Degenerated arc: it's a point - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 0, 0, 0, 0 - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - rois.append(item) - # Degenerated arc: it's a line - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 0, 100, numpy.pi, numpy.pi - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - rois.append(item) - # Special arc: it's a donut - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi, 3 * numpy.pi - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - rois.append(item) - # Arc - item = roi_items.ArcROI() - center = numpy.array([10, 20]) - innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, numpy.pi - item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) - rois.append(item) - - manager = roi.RegionOfInterestManager(self.plot) - self.roiTableWidget.setRegionOfInterestManager(manager) - for item in rois: - with self.subTest(roi=str(item)): - manager.addRoi(item) - self.qapp.processEvents() - item.setEditable(True) - self.qapp.processEvents() - item.setEditable(False) - self.qapp.processEvents() - manager.removeRoi(item) - self.qapp.processEvents() - - def testMaxROI(self): - """Test Max ROI""" - origin1 = numpy.array([1., 10.]) - size1 = numpy.array([10., 10.]) - origin2 = numpy.array([2., 3.]) - size2 = numpy.array([10., 10.]) - - manager = roi.InteractiveRegionOfInterestManager(self.plot) - self.roiTableWidget.setRegionOfInterestManager(manager) - self.assertEqual(manager.getRois(), ()) - - changedListener = SignalListener() - manager.sigRoiChanged.connect(changedListener) - - # Add two point - item = roi_items.RectangleROI() - item.setGeometry(origin=origin1, size=size1) - manager.addRoi(item) - item = roi_items.RectangleROI() - item.setGeometry(origin=origin2, size=size2) - manager.addRoi(item) - self.qapp.processEvents() - self.assertEqual(changedListener.callCount(), 2) - self.assertEqual(len(manager.getRois()), 2) - - # Try to set max ROI to 1 while there is 2 ROIs - with self.assertRaises(ValueError): - manager.setMaxRois(1) - - manager.clear() - self.assertEqual(len(manager.getRois()), 0) - self.assertEqual(changedListener.callCount(), 3) - - # Set max limit to 1 - manager.setMaxRois(1) - - # Add a point - item = roi_items.RectangleROI() - item.setGeometry(origin=origin1, size=size1) - manager.addRoi(item) - self.qapp.processEvents() - self.assertEqual(changedListener.callCount(), 4) - - # Add a 2nd point while max ROI is 1 - item = roi_items.RectangleROI() - item.setGeometry(origin=origin1, size=size1) - manager.addRoi(item) - self.qapp.processEvents() - self.assertEqual(changedListener.callCount(), 6) - self.assertEqual(len(manager.getRois()), 1) - - def testChangeInteractionMode(self): - """Test change of interaction mode""" - manager = roi.RegionOfInterestManager(self.plot) - self.roiTableWidget.setRegionOfInterestManager(manager) - manager.start(roi_items.PointROI) - - interactiveModeToolBar = self.plot.getInteractiveModeToolBar() - panAction = interactiveModeToolBar.getPanModeAction() - - for roiClass in manager.getSupportedRoiClasses(): - with self.subTest(roiClass=roiClass): - # Change to pan mode - panAction.trigger() - - # Change to interactive ROI mode - action = manager.getInteractionModeAction(roiClass) - action.trigger() - - self.assertEqual(roiClass, manager.getCurrentInteractionModeRoiClass()) - - manager.clear() - - -def suite(): - test_suite = unittest.TestSuite() - loadTests = unittest.defaultTestLoader.loadTestsFromTestCase - test_suite.addTest(loadTests(TestRoiItems)) - test_suite.addTest(loadTests(TestRegionOfInterestManager)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/silx/gui/plot/tools/test/testScatterProfileToolBar.py deleted file mode 100644 index b99cac7..0000000 --- a/silx/gui/plot/tools/test/testScatterProfileToolBar.py +++ /dev/null @@ -1,216 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import unittest -import numpy - -from silx.gui import qt -from silx.utils.testutils import ParametricTestCase -from silx.gui.utils.testutils import TestCaseQt -from silx.gui.plot import PlotWindow -from silx.gui.plot.tools import profile -import silx.gui.plot.items.roi as roi_items - - -class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): - """Tests for ScatterProfileToolBar class""" - - def setUp(self): - super(TestScatterProfileToolBar, self).setUp() - self.plot = PlotWindow() - - self.profile = profile.ScatterProfileToolBar(plot=self.plot) - - self.plot.addToolBar(self.profile) - - self.plot.show() - self.qWaitForWindowExposed(self.plot) - - def tearDown(self): - del self.profile - self.qapp.processEvents() - self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) - self.plot.close() - del self.plot - super(TestScatterProfileToolBar, self).tearDown() - - def testNoProfile(self): - """Test ScatterProfileToolBar without profile""" - self.assertEqual(self.profile.getPlotWidget(), self.plot) - - # Add a scatter plot - self.plot.addScatter( - x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) - self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) - self.qapp.processEvents() - - # Check that there is no profile - self.assertIsNone(self.profile.getProfileValues()) - self.assertIsNone(self.profile.getProfilePoints()) - - def testHorizontalProfile(self): - """Test ScatterProfileToolBar horizontal profile""" - nPoints = 8 - self.profile.setNPoints(nPoints) - self.assertEqual(self.profile.getNPoints(), nPoints) - - # Add a scatter plot - self.plot.addScatter( - x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) - self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) - self.qapp.processEvents() - - # Activate Horizontal profile - hlineAction = self.profile.actions()[0] - hlineAction.trigger() - self.qapp.processEvents() - - # Set a ROI profile - roi = roi_items.HorizontalLineROI() - roi.setPosition(0.5) - self.profile._getRoiManager().addRoi(roi) - - # Wait for async interpolator init - for _ in range(10): - self.qWait(200) - if not self.profile.hasPendingOperations(): - break - - self.assertIsNotNone(self.profile.getProfileValues()) - points = self.profile.getProfilePoints() - self.assertEqual(len(points), nPoints) - - # Check that profile has same limits than Plot - xLimits = self.plot.getXAxis().getLimits() - self.assertEqual(points[0, 0], xLimits[0]) - self.assertEqual(points[-1, 0], xLimits[1]) - - # Clear the profile - clearAction = self.profile.actions()[-1] - clearAction.trigger() - self.qapp.processEvents() - - self.assertIsNone(self.profile.getProfileValues()) - self.assertIsNone(self.profile.getProfilePoints()) - self.assertEqual(self.profile.getProfileTitle(), '') - - def testVerticalProfile(self): - """Test ScatterProfileToolBar vertical profile""" - nPoints = 8 - self.profile.setNPoints(nPoints) - self.assertEqual(self.profile.getNPoints(), nPoints) - - # Add a scatter plot - self.plot.addScatter( - x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) - self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) - self.qapp.processEvents() - - # Activate vertical profile - vlineAction = self.profile.actions()[1] - vlineAction.trigger() - self.qapp.processEvents() - - # Set a ROI profile - roi = roi_items.VerticalLineROI() - roi.setPosition(0.5) - self.profile._getRoiManager().addRoi(roi) - - # Wait for async interpolator init - for _ in range(10): - self.qWait(200) - if not self.profile.hasPendingOperations(): - break - - self.assertIsNotNone(self.profile.getProfileValues()) - points = self.profile.getProfilePoints() - self.assertEqual(len(points), nPoints) - - # Check that profile has same limits than Plot - yLimits = self.plot.getYAxis().getLimits() - self.assertEqual(points[0, 1], yLimits[0]) - self.assertEqual(points[-1, 1], yLimits[1]) - - # Check that profile limits are updated when changing limits - self.plot.getYAxis().setLimits(yLimits[0] + 1, yLimits[1] + 10) - self.qapp.processEvents() - yLimits = self.plot.getYAxis().getLimits() - points = self.profile.getProfilePoints() - self.assertEqual(points[0, 1], yLimits[0]) - self.assertEqual(points[-1, 1], yLimits[1]) - - # Clear the plot - self.plot.clear() - self.qapp.processEvents() - self.assertIsNone(self.profile.getProfileValues()) - self.assertIsNone(self.profile.getProfilePoints()) - - def testLineProfile(self): - """Test ScatterProfileToolBar line profile""" - nPoints = 8 - self.profile.setNPoints(nPoints) - self.assertEqual(self.profile.getNPoints(), nPoints) - - # Activate line profile - lineAction = self.profile.actions()[2] - lineAction.trigger() - self.qapp.processEvents() - - # Add a scatter plot - self.plot.addScatter( - x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) - self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) - self.qapp.processEvents() - - # Set a ROI profile - roi = roi_items.LineROI() - roi.setEndPoints(numpy.array([0., 0.]), numpy.array([1., 1.])) - self.profile._getRoiManager().addRoi(roi) - - # Wait for async interpolator init - for _ in range(10): - self.qWait(200) - if not self.profile.hasPendingOperations(): - break - - self.assertIsNotNone(self.profile.getProfileValues()) - points = self.profile.getProfilePoints() - self.assertEqual(len(points), nPoints) - - -def suite(): - test_suite = unittest.TestSuite() - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase( - TestScatterProfileToolBar)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/tools/test/testTools.py b/silx/gui/plot/tools/test/testTools.py deleted file mode 100644 index f4adda0..0000000 --- a/silx/gui/plot/tools/test/testTools.py +++ /dev/null @@ -1,175 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Basic tests for silx.gui.plot.tools package""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "02/03/2018" - - -import functools -import unittest -import numpy - -from silx.utils.testutils import TestLogging -from silx.gui.utils.testutils import qWaitForWindowExposedAndActivate -from silx.gui import qt -from silx.gui.plot import PlotWindow -from silx.gui.plot import tools -from silx.gui.plot.test.utils import PlotWidgetTestCase - - -# Makes sure a QApplication exists -_qapp = qt.QApplication.instance() or qt.QApplication([]) - - -def _tearDownDocTest(docTest): - """Tear down to use for test from docstring. - - Checks that plot widget is displayed - """ - plot = docTest.globs['plot'] - qWaitForWindowExposedAndActivate(plot) - plot.setAttribute(qt.Qt.WA_DeleteOnClose) - plot.close() - del plot - -# Disable doctest because of -# "NameError: name 'numpy' is not defined" -# -# import doctest -# positionInfoTestSuite = doctest.DocTestSuite( -# PlotTools, tearDown=_tearDownDocTest, -# optionflags=doctest.ELLIPSIS) -# """Test suite of tests from PlotTools docstrings. -# -# Test PositionInfo and ProfileToolBar docstrings. -# """ - - -class TestPositionInfo(PlotWidgetTestCase): - """Tests for PositionInfo widget.""" - - def _createPlot(self): - return PlotWindow() - - def setUp(self): - super(TestPositionInfo, self).setUp() - self.mouseMove(self.plot, pos=(0, 0)) - self.qapp.processEvents() - self.qWait(100) - - def tearDown(self): - super(TestPositionInfo, self).tearDown() - - def _test(self, positionWidget, converterNames, **kwargs): - """General test of PositionInfo. - - - Add it to a toolbar and - - Move mouse around the center of the PlotWindow. - """ - toolBar = qt.QToolBar() - self.plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) - - toolBar.addWidget(positionWidget) - - converters = positionWidget.getConverters() - self.assertEqual(len(converters), len(converterNames)) - for index, name in enumerate(converterNames): - self.assertEqual(converters[index][0], name) - - with TestLogging(tools.__name__, **kwargs): - # Move mouse to center - center = self.plot.size() / 2 - self.mouseMove(self.plot, pos=(center.width(), center.height())) - # Move out - self.mouseMove(self.plot, pos=(1, 1)) - - def testDefaultConverters(self): - """Test PositionInfo with default converters""" - positionWidget = tools.PositionInfo(plot=self.plot) - self._test(positionWidget, ('X', 'Y')) - - def testCustomConverters(self): - """Test PositionInfo with custom converters""" - converters = [ - ('Coords', lambda x, y: (int(x), int(y))), - ('Radius', lambda x, y: numpy.sqrt(x * x + y * y)), - ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x))) - ] - positionWidget = tools.PositionInfo(plot=self.plot, - converters=converters) - self._test(positionWidget, ('Coords', 'Radius', 'Angle')) - - def testFailingConverters(self): - """Test PositionInfo with failing custom converters""" - def raiseException(x, y): - raise RuntimeError() - - positionWidget = tools.PositionInfo( - plot=self.plot, - converters=[('Exception', raiseException)]) - self._test(positionWidget, ['Exception'], error=2) - - def testUpdate(self): - """Test :meth:`PositionInfo.updateInfo`""" - calls = [] - - def update(calls, x, y): # Get number of calls - calls.append((x, y)) - return len(calls) - - positionWidget = tools.PositionInfo( - plot=self.plot, - converters=[('Call count', functools.partial(update, calls))]) - - positionWidget.updateInfo() - self.assertEqual(len(calls), 1) - - -class TestPlotToolsToolbars(PlotWidgetTestCase): - """Tests toolbars from silx.gui.plot.tools""" - - def test(self): - """"Add all toolbars""" - for tbClass in (tools.InteractiveModeToolBar, - tools.ImageToolBar, - tools.CurveToolBar, - tools.OutputToolBar): - tb = tbClass(parent=self.plot, plot=self.plot) - self.plot.addToolBar(tb) - - -def suite(): - test_suite = unittest.TestSuite() - # test_suite.addTest(positionInfoTestSuite) - for testClass in (TestPositionInfo, TestPlotToolsToolbars): - test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase( - testClass)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/tools/toolbars.py b/silx/gui/plot/tools/toolbars.py deleted file mode 100644 index 28fb7f9..0000000 --- a/silx/gui/plot/tools/toolbars.py +++ /dev/null @@ -1,356 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides toolbars that work with :class:`PlotWidget`. -""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "01/03/2018" - - -from ... import qt -from .. import actions -from ..PlotWidget import PlotWidget -from .. import PlotToolButtons - - -class InteractiveModeToolBar(qt.QToolBar): - """Toolbar with interactive mode actions - - :param parent: See :class:`QWidget` - :param silx.gui.plot.PlotWidget plot: PlotWidget to control - :param str title: Title of the toolbar. - """ - - def __init__(self, parent=None, plot=None, title='Plot Interaction'): - super(InteractiveModeToolBar, self).__init__(title, parent) - - assert isinstance(plot, PlotWidget) - - self._zoomModeAction = actions.mode.ZoomModeAction( - parent=self, plot=plot) - self.addAction(self._zoomModeAction) - - self._panModeAction = actions.mode.PanModeAction( - parent=self, plot=plot) - self.addAction(self._panModeAction) - - def getZoomModeAction(self): - """Returns the zoom mode QAction. - - :rtype: PlotAction - """ - return self._zoomModeAction - - def getPanModeAction(self): - """Returns the pan mode QAction - - :rtype: PlotAction - """ - return self._panModeAction - - -class OutputToolBar(qt.QToolBar): - """Toolbar providing icons to copy, save and print a PlotWidget - - :param parent: See :class:`QWidget` - :param silx.gui.plot.PlotWidget plot: PlotWidget to control - :param str title: Title of the toolbar. - """ - - def __init__(self, parent=None, plot=None, title='Plot Output'): - super(OutputToolBar, self).__init__(title, parent) - - assert isinstance(plot, PlotWidget) - - self._copyAction = actions.io.CopyAction(parent=self, plot=plot) - self.addAction(self._copyAction) - - self._saveAction = actions.io.SaveAction(parent=self, plot=plot) - self.addAction(self._saveAction) - - self._printAction = actions.io.PrintAction(parent=self, plot=plot) - self.addAction(self._printAction) - - def getCopyAction(self): - """Returns the QAction performing copy to clipboard of the PlotWidget - - :rtype: PlotAction - """ - return self._copyAction - - def getSaveAction(self): - """Returns the QAction performing save to file of the PlotWidget - - :rtype: PlotAction - """ - return self._saveAction - - def getPrintAction(self): - """Returns the QAction performing printing of the PlotWidget - - :rtype: PlotAction - """ - return self._printAction - - -class ImageToolBar(qt.QToolBar): - """Toolbar providing PlotAction suited when displaying images - - :param parent: See :class:`QWidget` - :param silx.gui.plot.PlotWidget plot: PlotWidget to control - :param str title: Title of the toolbar. - """ - - def __init__(self, parent=None, plot=None, title='Image'): - super(ImageToolBar, self).__init__(title, parent) - - assert isinstance(plot, PlotWidget) - - self._resetZoomAction = actions.control.ResetZoomAction( - parent=self, plot=plot) - self.addAction(self._resetZoomAction) - - self._colormapAction = actions.control.ColormapAction( - parent=self, plot=plot) - self.addAction(self._colormapAction) - - self._keepDataAspectRatioButton = PlotToolButtons.AspectToolButton( - parent=self, plot=plot) - self.addWidget(self._keepDataAspectRatioButton) - - self._yAxisInvertedButton = PlotToolButtons.YAxisOriginToolButton( - parent=self, plot=plot) - self.addWidget(self._yAxisInvertedButton) - - def getResetZoomAction(self): - """Returns the QAction to reset the zoom. - - :rtype: PlotAction - """ - return self._resetZoomAction - - def getColormapAction(self): - """Returns the QAction to control the colormap. - - :rtype: PlotAction - """ - return self._colormapAction - - def getKeepDataAspectRatioButton(self): - """Returns the QToolButton controlling data aspect ratio. - - :rtype: QToolButton - """ - return self._keepDataAspectRatioButton - - def getYAxisInvertedButton(self): - """Returns the QToolButton controlling Y axis orientation. - - :rtype: QToolButton - """ - return self._yAxisInvertedButton - - -class CurveToolBar(qt.QToolBar): - """Toolbar providing PlotAction suited when displaying curves - - :param parent: See :class:`QWidget` - :param silx.gui.plot.PlotWidget plot: PlotWidget to control - :param str title: Title of the toolbar. - """ - - def __init__(self, parent=None, plot=None, title='Image'): - super(CurveToolBar, self).__init__(title, parent) - - assert isinstance(plot, PlotWidget) - - self._resetZoomAction = actions.control.ResetZoomAction( - parent=self, plot=plot) - self.addAction(self._resetZoomAction) - - self._xAxisAutoScaleAction = actions.control.XAxisAutoScaleAction( - parent=self, plot=plot) - self.addAction(self._xAxisAutoScaleAction) - - self._yAxisAutoScaleAction = actions.control.YAxisAutoScaleAction( - parent=self, plot=plot) - self.addAction(self._yAxisAutoScaleAction) - - self._xAxisLogarithmicAction = actions.control.XAxisLogarithmicAction( - parent=self, plot=plot) - self.addAction(self._xAxisLogarithmicAction) - - self._yAxisLogarithmicAction = actions.control.YAxisLogarithmicAction( - parent=self, plot=plot) - self.addAction(self._yAxisLogarithmicAction) - - self._gridAction = actions.control.GridAction( - parent=self, plot=plot) - self.addAction(self._gridAction) - - self._curveStyleAction = actions.control.CurveStyleAction( - parent=self, plot=plot) - self.addAction(self._curveStyleAction) - - def getResetZoomAction(self): - """Returns the QAction to reset the zoom. - - :rtype: PlotAction - """ - return self._resetZoomAction - - def getXAxisAutoScaleAction(self): - """Returns the QAction to toggle X axis autoscale. - - :rtype: PlotAction - """ - return self._xAxisAutoScaleAction - - def getYAxisAutoScaleAction(self): - """Returns the QAction to toggle Y axis autoscale. - - :rtype: PlotAction - """ - return self._yAxisAutoScaleAction - - def getXAxisLogarithmicAction(self): - """Returns the QAction to toggle X axis log/linear scale. - - :rtype: PlotAction - """ - return self._xAxisLogarithmicAction - - def getYAxisLogarithmicAction(self): - """Returns the QAction to toggle Y axis log/linear scale. - - :rtype: PlotAction - """ - return self._yAxisLogarithmicAction - - def getGridAction(self): - """Returns the action to toggle the plot grid. - - :rtype: PlotAction - """ - return self._gridAction - - def getCurveStyleAction(self): - """Returns the QAction to change the style of all curves. - - :rtype: PlotAction - """ - return self._curveStyleAction - - -class ScatterToolBar(qt.QToolBar): - """Toolbar providing PlotAction suited when displaying scatter plot - - :param parent: See :class:`QWidget` - :param silx.gui.plot.PlotWidget plot: PlotWidget to control - :param str title: Title of the toolbar. - """ - - def __init__(self, parent=None, plot=None, title='Scatter Tools'): - super(ScatterToolBar, self).__init__(title, parent) - - assert isinstance(plot, PlotWidget) - - self._resetZoomAction = actions.control.ResetZoomAction( - parent=self, plot=plot) - self.addAction(self._resetZoomAction) - - self._xAxisLogarithmicAction = actions.control.XAxisLogarithmicAction( - parent=self, plot=plot) - self.addAction(self._xAxisLogarithmicAction) - - self._yAxisLogarithmicAction = actions.control.YAxisLogarithmicAction( - parent=self, plot=plot) - self.addAction(self._yAxisLogarithmicAction) - - self._keepDataAspectRatioButton = PlotToolButtons.AspectToolButton( - parent=self, plot=plot) - self.addWidget(self._keepDataAspectRatioButton) - - self._gridAction = actions.control.GridAction( - parent=self, plot=plot) - self.addAction(self._gridAction) - - self._colormapAction = actions.control.ColormapAction( - parent=self, plot=plot) - self.addAction(self._colormapAction) - - self._symbolToolButton = PlotToolButtons.SymbolToolButton( - parent=self, plot=plot) - self.addWidget(self._symbolToolButton) - - def getResetZoomAction(self): - """Returns the QAction to reset the zoom. - - :rtype: PlotAction - """ - return self._resetZoomAction - - def getXAxisLogarithmicAction(self): - """Returns the QAction to toggle X axis log/linear scale. - - :rtype: PlotAction - """ - return self._xAxisLogarithmicAction - - def getYAxisLogarithmicAction(self): - """Returns the QAction to toggle Y axis log/linear scale. - - :rtype: PlotAction - """ - return self._yAxisLogarithmicAction - - def getGridAction(self): - """Returns the action to toggle the plot grid. - - :rtype: PlotAction - """ - return self._gridAction - - def getColormapAction(self): - """Returns the QAction to control the colormap. - - :rtype: PlotAction - """ - return self._colormapAction - - def getSymbolToolButton(self): - """Returns the QToolButton controlling symbol size and marker. - - :rtype: SymbolToolButton - """ - return self._symbolToolButton - - def getKeepDataAspectRatioButton(self): - """Returns the QToolButton controlling data aspect ratio. - - :rtype: QToolButton - """ - return self._keepDataAspectRatioButton diff --git a/silx/gui/plot/utils/__init__.py b/silx/gui/plot/utils/__init__.py deleted file mode 100644 index 3187f6b..0000000 --- a/silx/gui/plot/utils/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# 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. -# -# ###########################################################################*/ -"""Utils module for plot. -""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "29/06/2017" diff --git a/silx/gui/plot/utils/axis.py b/silx/gui/plot/utils/axis.py deleted file mode 100644 index bd19996..0000000 --- a/silx/gui/plot/utils/axis.py +++ /dev/null @@ -1,199 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module contains utils class for axes management. -""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "23/02/2018" - -import functools -import logging -from contextlib import contextmanager -import weakref -import silx.utils.weakref as silxWeakref - -try: - from ...qt.inspect import isValid as _isQObjectValid -except ImportError: # PySide(1) fallback - def _isQObjectValid(obj): - return True - - -_logger = logging.getLogger(__name__) - - -class SyncAxes(object): - """Synchronize a set of plot axes together. - - It is created with the expected axes and starts to synchronize them. - - It can be customized to synchronize limits, scale, and direction of axes - together. By default everything is synchronized. - - The API :meth:`start` and :meth:`stop` can be used to enable/disable the - synchronization while this object is still alive. - - If this object is destroyed the synchronization stop. - - .. versionadded:: 0.6 - """ - - def __init__(self, axes, syncLimits=True, syncScale=True, syncDirection=True): - """ - Constructor - - :param list(Axis) axes: A list of axes to synchronize together - :param bool syncLimits: Synchronize axes limits - :param bool syncScale: Synchronize axes scale - :param bool syncDirection: Synchronize axes direction - """ - object.__init__(self) - self.__locked = False - self.__axisRefs = [] - self.__syncLimits = syncLimits - self.__syncScale = syncScale - self.__syncDirection = syncDirection - self.__callbacks = None - - for axis in axes: - self.__axisRefs.append(weakref.ref(axis)) - - self.start() - - def start(self): - """Start synchronizing axes together. - - The first axis is used as the reference for the first synchronization. - After that, any changes to any axes will be used to synchronize other - axes. - """ - if self.__callbacks is not None: - raise RuntimeError("Axes already synchronized") - self.__callbacks = {} - - axes = self.__getAxes() - if len(axes) == 0: - raise RuntimeError('No axis to synchronize') - - # register callback for further sync - for axis in axes: - refAxis = weakref.ref(axis) - callbacks = [] - if self.__syncLimits: - # the weakref is needed to be able ignore self references - callback = silxWeakref.WeakMethodProxy(self.__axisLimitsChanged) - callback = functools.partial(callback, refAxis) - sig = axis.sigLimitsChanged - sig.connect(callback) - callbacks.append(("sigLimitsChanged", callback)) - if self.__syncScale: - # the weakref is needed to be able ignore self references - callback = silxWeakref.WeakMethodProxy(self.__axisScaleChanged) - callback = functools.partial(callback, refAxis) - sig = axis.sigScaleChanged - sig.connect(callback) - callbacks.append(("sigScaleChanged", callback)) - if self.__syncDirection: - # the weakref is needed to be able ignore self references - callback = silxWeakref.WeakMethodProxy(self.__axisInvertedChanged) - callback = functools.partial(callback, refAxis) - sig = axis.sigInvertedChanged - sig.connect(callback) - callbacks.append(("sigInvertedChanged", callback)) - - self.__callbacks[refAxis] = callbacks - - # sync the current state - mainAxis = axes[0] - refMainAxis = weakref.ref(mainAxis) - if self.__syncLimits: - self.__axisLimitsChanged(refMainAxis, *mainAxis.getLimits()) - if self.__syncScale: - self.__axisScaleChanged(refMainAxis, mainAxis.getScale()) - if self.__syncDirection: - self.__axisInvertedChanged(refMainAxis, mainAxis.isInverted()) - - def stop(self): - """Stop the synchronization of the axes""" - if self.__callbacks is None: - raise RuntimeError("Axes not synchronized") - for ref, callbacks in self.__callbacks.items(): - axis = ref() - if axis is not None and _isQObjectValid(axis): - for sigName, callback in callbacks: - sig = getattr(axis, sigName) - sig.disconnect(callback) - self.__callbacks = None - - def __del__(self): - """Destructor""" - # clean up references - if self.__callbacks is not None: - self.stop() - - def __getAxes(self): - """Returns list of existing axes. - - :rtype: List[Axis] - """ - axes = [ref() for ref in self.__axisRefs] - return [axis for axis in axes if axis is not None] - - @contextmanager - def __inhibitSignals(self): - self.__locked = True - yield - self.__locked = False - - def __otherAxes(self, changedAxis): - for axis in self.__getAxes(): - if axis is changedAxis: - continue - yield axis - - def __axisLimitsChanged(self, changedAxis, vmin, vmax): - if self.__locked: - return - changedAxis = changedAxis() - with self.__inhibitSignals(): - for axis in self.__otherAxes(changedAxis): - axis.setLimits(vmin, vmax) - - def __axisScaleChanged(self, changedAxis, scale): - if self.__locked: - return - changedAxis = changedAxis() - with self.__inhibitSignals(): - for axis in self.__otherAxes(changedAxis): - axis.setScale(scale) - - def __axisInvertedChanged(self, changedAxis, isInverted): - if self.__locked: - return - changedAxis = changedAxis() - with self.__inhibitSignals(): - for axis in self.__otherAxes(changedAxis): - axis.setInverted(isInverted) |