diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2019-12-23 13:45:09 +0100 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2019-12-23 13:45:09 +0100 |
commit | 5d647cf9a6159afd2933da594b9c79ad93d3cd9b (patch) | |
tree | 2571025a602f68fc8933b01104dc712d41f84034 /silx/gui | |
parent | 654a6ac93513c3cc1ef97cacd782ff674c6f4559 (diff) |
New upstream version 0.12.0~b0+dfsg
Diffstat (limited to 'silx/gui')
64 files changed, 4138 insertions, 1870 deletions
diff --git a/silx/gui/_glutils/OpenGLWidget.py b/silx/gui/_glutils/OpenGLWidget.py index 7f600a0..c5ece9c 100644 --- a/silx/gui/_glutils/OpenGLWidget.py +++ b/silx/gui/_glutils/OpenGLWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -30,7 +30,7 @@ across Qt<=5.3 QtOpenGL.QGLWidget and QOpenGLWidget. __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "26/07/2017" +__date__ = "22/11/2019" import logging @@ -192,7 +192,12 @@ else: # Check OpenGL version if self.getOpenGLVersion() >= self.getRequestedOpenGLVersion(): - version = gl.glGetString(gl.GL_VERSION) + try: + gl.glGetError() # clear any previous error (if any) + version = gl.glGetString(gl.GL_VERSION) + except: + version = None + if version: self.__isValid = True else: diff --git a/silx/gui/colors.py b/silx/gui/colors.py index aa2958a..365b569 100644..100755 --- a/silx/gui/colors.py +++ b/silx/gui/colors.py @@ -97,6 +97,7 @@ _AVAILABLE_LUTS = collections.OrderedDict([ ('blue', _LUT_DESCRIPTION('builtin', 'yellow', True)), ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)), ('viridis', _LUT_DESCRIPTION('resource', 'pink', True)), + ('cividis', _LUT_DESCRIPTION('resource', 'pink', True)), ('magma', _LUT_DESCRIPTION('resource', 'green', True)), ('inferno', _LUT_DESCRIPTION('resource', 'green', True)), ('plasma', _LUT_DESCRIPTION('resource', 'green', True)), @@ -116,10 +117,11 @@ DEFAULT_MAX_LOG = 10 def rgba(color, colorDict=None): - """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A) + """Convert color code '#RRGGBB' and '#RRGGBBAA' to a tuple (R, G, B, A) + of floats. - It also convert RGB(A) values from uint8 to float in [0, 1] and - accept a QColor as color argument. + It also supports RGB(A) from uint8 in [0, 255], float in [0, 1], and + QColor as color argument. :param str color: The color to convert :param dict colorDict: A dictionary of color name conversion to color code @@ -167,8 +169,8 @@ def greyed(color, colorDict=None): """Convert color code '#RRGGBB' and '#RRGGBBAA' to a grey color (R, G, B, A). - It also convert RGB(A) values from uint8 to float in [0, 1] and - accept a QColor as color argument. + It also supports RGB(A) from uint8 in [0, 255], float in [0, 1], and + QColor as color argument. :param str color: The color to convert :param dict colorDict: A dictionary of color name conversion to color code @@ -180,6 +182,19 @@ def greyed(color, colorDict=None): return g, g, g, a +def asQColor(color): + """Convert color code '#RRGGBB' and '#RRGGBBAA' to a `qt.QColor`. + + It also supports RGB(A) from uint8 in [0, 255], float in [0, 1], and + QColor as color argument. + + :param str color: The color to convert + :rtype: qt.QColor + """ + color = rgba(color) + return qt.QColor.fromRgbF(*color) + + def cursorColorForColormap(colormapName): """Get a color suitable for overlay over a colormap. @@ -386,7 +401,7 @@ class Colormap(qt.QObject): def setFromColormap(self, other): """Set this colormap using information from the `other` colormap. - :param Colormap other: Colormap to use as reference. + :param ~silx.gui.colors.Colormap other: Colormap to use as reference. """ if not self.isEditable(): raise NotEditableError('Colormap is not editable') diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py index b33a931..67db5f9 100644 --- a/silx/gui/data/DataViewer.py +++ b/silx/gui/data/DataViewer.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# Copyright (c) 2016-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -31,8 +31,10 @@ from silx.gui.data import DataViews from silx.gui.data.DataViews import _normalizeData import logging from silx.gui import qt +from silx.gui.utils import blockSignals from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector + __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "12/02/2019" @@ -197,25 +199,38 @@ class DataViewer(qt.QFrame): """ Update the numpy-selector according to the needed axis names """ - previous = self.__numpySelection.blockSignals(True) - self.__numpySelection.clear() - info = self._getInfo() - axisNames = self.__currentView.axesNames(self.__data, info) - if info.isArray and info.size != 0 and self.__data is not None and axisNames is not None: - self.__useAxisSelection = True - self.__numpySelection.setAxisNames(axisNames) - self.__numpySelection.setCustomAxis(self.__currentView.customAxisNames()) - data = self.normalizeData(self.__data) - self.__numpySelection.setData(data) - if hasattr(data, "shape"): - isVisible = not (len(axisNames) == 1 and len(data.shape) == 1) + with blockSignals(self.__numpySelection): + previousPermutation = self.__numpySelection.permutation() + previousSelection = self.__numpySelection.selection() + + self.__numpySelection.clear() + + info = self._getInfo() + axisNames = self.__currentView.axesNames(self.__data, info) + if (info.isArray and info.size != 0 and + self.__data is not None and axisNames is not None): + self.__useAxisSelection = True + self.__numpySelection.setAxisNames(axisNames) + self.__numpySelection.setCustomAxis( + self.__currentView.customAxisNames()) + data = self.normalizeData(self.__data) + self.__numpySelection.setData(data) + + # Try to restore previous permutation and selection + try: + self.__numpySelection.setSelection( + previousSelection, previousPermutation) + except ValueError as e: + _logger.error("Not restoring selection because: %s", e) + + if hasattr(data, "shape"): + isVisible = not (len(axisNames) == 1 and len(data.shape) == 1) + else: + isVisible = True + self.__axisSelection.setVisible(isVisible) else: - isVisible = True - self.__axisSelection.setVisible(isVisible) - else: - self.__useAxisSelection = False - self.__axisSelection.setVisible(False) - self.__numpySelection.blockSignals(previous) + self.__useAxisSelection = False + self.__axisSelection.setVisible(False) def __updateDataInView(self): """ diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py index 664090d..eb635c4 100644 --- a/silx/gui/data/DataViews.py +++ b/silx/gui/data/DataViews.py @@ -1482,7 +1482,8 @@ class _NXdataXYVScatterView(DataView): data = self.normalizeData(data) if info.hasNXdata and not info.isInvalidNXdata: if nxdata.get_default(data, validate=False).is_x_y_value_scatter: - return 100 + # It have to be a little more than a NX curve priority + return 110 return DataView.UNSUPPORTED diff --git a/silx/gui/data/NumpyAxesSelector.py b/silx/gui/data/NumpyAxesSelector.py index 4530aa9..e6da0d4 100644 --- a/silx/gui/data/NumpyAxesSelector.py +++ b/silx/gui/data/NumpyAxesSelector.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# Copyright (c) 2016-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -31,13 +31,18 @@ __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "29/01/2018" +import logging import numpy import functools from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser from silx.gui import qt +from silx.gui.utils import blockSignals import silx.utils.weakref +_logger = logging.getLogger(__name__) + + class _Axis(qt.QWidget): """Widget displaying an axis. @@ -110,6 +115,8 @@ class _Axis(qt.QWidget): if axisName == "" and self.__axes.count() == 0: self.__axes.setCurrentIndex(-1) self.__updateSliderVisibility() + return + for index in range(self.__axes.count()): name = self.__axes.itemData(index) if name == axisName: @@ -121,7 +128,7 @@ class _Axis(qt.QWidget): def axisName(self): """Returns the selected axis name. - If no names are selected, an empty string is retruned. + If no name is selected, an empty string is returned. :rtype: str """ @@ -136,11 +143,11 @@ class _Axis(qt.QWidget): :param List[str] axesNames: List of available names """ self.__axes.clear() - previous = self.__axes.blockSignals(True) - self.__axes.addItem(" ", "") - for axis in axesNames: - self.__axes.addItem(axis, axis) - self.__axes.blockSignals(previous) + with blockSignals(self.__axes): + self.__axes.addItem(" ", "") + for axis in axesNames: + self.__axes.addItem(axis, axis) + self.__updateSliderVisibility() def setCustomAxis(self, axesNames): @@ -168,12 +175,19 @@ class _Axis(qt.QWidget): self.__slider.setVisible(isVisible) def value(self): - """Returns the current selected position in the axis. + """Returns the currently selected position in the axis. :rtype: int """ return self.__slider.value() + def setValue(self, value): + """Set the currently selected position in the axis. + + :param int value: + """ + self.__slider.setValue(value) + def __sliderValueChanged(self, value): """Called when the selected position in the axis change. @@ -183,18 +197,14 @@ class _Axis(qt.QWidget): def setNamedAxisSelectorVisibility(self, visible): """Hide or show the named axis combobox. - If both the selector and the slider are hidden, - hide the entire widget. + + If both the selector and the slider are hidden, hide the entire widget. :param visible: boolean """ self.__axes.setVisible(visible) name = self.axisName() - - if not visible and name != "": - self.setVisible(False) - else: - self.setVisible(True) + self.setVisible(visible or name == "") class NumpyAxesSelector(qt.QWidget): @@ -236,7 +246,6 @@ class NumpyAxesSelector(qt.QWidget): self.__data = None self.__selectedData = None - self.__selection = tuple() self.__axis = [] self.__axisNames = [] self.__customAxisNames = set([]) @@ -268,13 +277,12 @@ class NumpyAxesSelector(qt.QWidget): if delta < 0: delta = 0 for index, axis in enumerate(self.__axis): - previous = axis.blockSignals(True) - axis.setAxisNames(self.__axisNames) - if index >= delta and index - delta < len(self.__axisNames): - axis.setAxisName(self.__axisNames[index - delta]) - else: - axis.setAxisName("") - axis.blockSignals(previous) + with blockSignals(axis): + axis.setAxisNames(self.__axisNames) + if index >= delta and index - delta < len(self.__axisNames): + axis.setAxisName(self.__axisNames[index - delta]) + else: + axis.setAxisName("") self.__updateSelectedData() def setCustomAxis(self, axesNames): @@ -372,9 +380,8 @@ class NumpyAxesSelector(qt.QWidget): # If there is no other solution we set the name at the same place axisChanged = False availableWidget = axis - previous = availableWidget.blockSignals(True) - availableWidget.setAxisName(missingName) - availableWidget.blockSignals(previous) + with blockSignals(availableWidget): + availableWidget.setAxisName(missingName) else: # there is a duplicated name somewhere # we swap it with the missing name or with nothing @@ -387,9 +394,8 @@ class NumpyAxesSelector(qt.QWidget): break if missingName is None: missingName = "" - previous = dupWidget.blockSignals(True) - dupWidget.setAxisName(missingName) - dupWidget.blockSignals(previous) + with blockSignals(dupWidget): + dupWidget.setAxisName(missingName) if self.__data is None: return @@ -402,70 +408,164 @@ class NumpyAxesSelector(qt.QWidget): It fires a `selectionChanged` event. """ - if self.__data is None: + permutation = self.permutation() + + if self.__data is None or permutation is None: + # No data or not all the expected axes are there if self.__selectedData is not None: self.__selectedData = None - self.__selection = tuple() self.selectionChanged.emit() return - selection = [] - axisNames = [] - for slider in self.__axis: - name = slider.axisName() - if name == "": - selection.append(slider.value()) - else: - selection.append(slice(None)) - axisNames.append(name) - self.__selection = tuple(selection) # get a view with few fixed dimensions # with a h5py dataset, it create a copy # TODO we can reuse the same memory in case of a copy - view = self.__data[self.__selection] - - if set(self.__axisNames) - set(axisNames) != set([]): - # Not all the expected axis are there - if self.__selectedData is not None: - self.__selectedData = None - self.__selection = tuple() - self.selectionChanged.emit() - return - - # order axis as expected - source = [] - destination = [] - order = [] - for index, name in enumerate(self.__axisNames): - destination.append(index) - source.append(axisNames.index(name)) - for _, s in sorted(zip(destination, source)): - order.append(s) - view = numpy.transpose(view, order) - - self.__selectedData = view + self.__selectedData = numpy.transpose(self.__data[self.selection()], permutation) self.selectionChanged.emit() def data(self): """Returns the input data. - :rtype: numpy.ndarray + :rtype: Union[numpy.ndarray,None] """ - return self.__data + if self.__data is None: + return None + else: + return numpy.array(self.__data, copy=False) def selectedData(self): """Returns the output data. - :rtype: numpy.ndarray + This is equivalent to:: + + numpy.transpose(self.data()[self.selection()], self.permutation()) + + :rtype: Union[numpy.ndarray,None] """ - return self.__selectedData + if self.__selectedData is None: + return None + else: + return numpy.array(self.__selectedData, copy=False) + + def permutation(self): + """Returns the axes permutation to convert data subset to selected data. + + If permutation cannot be computer, it returns None. + + :rtype: Union[List[int],None] + """ + if self.__data is None: + return None + else: + indices = [] + for name in self.__axisNames: + index = 0 + for axis in self.__axis: + if axis.axisName() == name: + indices.append(index) + break + if axis.axisName() != "": + index += 1 + else: + _logger.warning("No axis corresponding to: %s", name) + return None + return tuple(indices) def selection(self): """Returns the selection tuple used to slice the data. :rtype: tuple """ - return self.__selection + if self.__data is None: + return tuple() + else: + return tuple([axis.value() if axis.axisName() == "" else slice(None) + for axis in self.__axis]) + + def setSelection(self, selection, permutation=None): + """Set the selection along each dimension. + + tuple returned by :meth:`selection` can be provided as input, + provided that it is for the same the number of axes and + the same number of dimensions of the data. + + :param List[Union[int,slice,None]] selection: + The selection tuple with as one element for each dimension of the data. + If an element is None, then the whole dimension is selected. + :param Union[List[int],None] permutation: + The data axes indices to transpose. + If not given, no permutation is applied + :raise ValueError: + When the selection does not match current data shape and number of axes. + """ + data_shape = self.__data.shape if self.__data is not None else () + + # Check selection + if len(selection) != len(data_shape): + raise ValueError( + "Selection length (%d) and data ndim (%d) mismatch" % + (len(selection), len(data_shape))) + + # Check selection type + selectedDataNDim = 0 + for element, size in zip(selection, data_shape): + if isinstance(element, int): + if not 0 <= element < size: + raise ValueError( + "Selected index (%d) outside data dimension range [0-%d]" % + (element, size)) + elif element is None or element == slice(None): + selectedDataNDim += 1 + else: + raise ValueError("Unsupported element in selection: %s" % element) + + ndim = len(self.__axisNames) + if selectedDataNDim != ndim: + raise ValueError( + "Selection dimensions (%d) and number of axes (%d) mismatch" % + (selectedDataNDim, ndim)) + + # check permutation + if permutation is None: + permutation = tuple(range(ndim)) + + if set(permutation) != set(range(ndim)): + raise ValueError( + "Error in provided permutation: " + "Wrong size, elements out of range or duplicates") + + inversePermutation = numpy.argsort(permutation) + + axisNameChanged = False + customValueChanged = [] + with blockSignals(*self.__axis): + index = 0 + for element, axis in zip(selection, self.__axis): + if isinstance(element, int): + name = "" + else: + name = self.__axisNames[inversePermutation[index]] + index += 1 + + if axis.axisName() != name: + axis.setAxisName(name) + axisNameChanged = True + + for element, axis in zip(selection, self.__axis): + value = element if isinstance(element, int) else 0 + if axis.value() != value: + axis.setValue(value) + + name = axis.axisName() + if name in self.__customAxisNames: + customValueChanged.append((name, value)) + + # Send signals that where disabled + if axisNameChanged: + self.selectedAxisChanged.emit() + for name, value in customValueChanged: + self.customAxisChanged.emit(name, value) + self.__updateSelectedData() def setNamedAxesSelectorVisibility(self, visible): """Show or hide the combo-boxes allowing to map the plot axes diff --git a/silx/gui/data/test/test_numpyaxesselector.py b/silx/gui/data/test/test_numpyaxesselector.py index df11c1a..d37cff7 100644 --- a/silx/gui/data/test/test_numpyaxesselector.py +++ b/silx/gui/data/test/test_numpyaxesselector.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -76,7 +76,7 @@ class TestNumpyAxesSelector(TestCaseQt): widget.setAxisNames(["x", "y", "z", "boum"]) widget.setData(data[0]) result = widget.selectedData() - self.assertEqual(result, None) + self.assertIsNone(result) widget.setData(data) result = widget.selectedData() self.assertTrue(numpy.array_equal(result, expectedResult)) diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py index 9c956f8..ed15947 100644 --- a/silx/gui/dialog/ColormapDialog.py +++ b/silx/gui/dialog/ColormapDialog.py @@ -79,6 +79,7 @@ from silx.gui.widgets.FloatEdit import FloatEdit import weakref from silx.math.combo import min_max from silx.gui import icons +from silx.gui.widgets.ColormapNameComboBox import ColormapNameComboBox from silx.math.histogram import Histogramnd _logger = logging.getLogger(__name__) @@ -150,128 +151,6 @@ class _BoundaryWidget(qt.QWidget): self._updateDisplayedText() -class _ColormapNameCombox(qt.QComboBox): - def __init__(self, parent=None): - qt.QComboBox.__init__(self, parent) - self.__initItems() - - LUT_NAME = qt.Qt.UserRole + 1 - LUT_COLORS = qt.Qt.UserRole + 2 - - def __initItems(self): - for colormapName in preferredColormaps(): - index = self.count() - self.addItem(str.title(colormapName)) - self.setItemIcon(index, self.getIconPreview(name=colormapName)) - self.setItemData(index, colormapName, role=self.LUT_NAME) - - def getIconPreview(self, name=None, colors=None): - """Return an icon preview from a LUT name. - - This icons are cached into a global structure. - - :param str name: Name of the LUT - :param numpy.ndarray colors: Colors identify the LUT - :rtype: qt.QIcon - """ - if name is not None: - iconKey = name - else: - iconKey = tuple(colors) - icon = _colormapIconPreview.get(iconKey, None) - if icon is None: - icon = self.createIconPreview(name, colors) - _colormapIconPreview[iconKey] = icon - return icon - - def createIconPreview(self, name=None, colors=None): - """Create and return an icon preview from a LUT name. - - This icons are cached into a global structure. - - :param str name: Name of the LUT - :param numpy.ndarray colors: Colors identify the LUT - :rtype: qt.QIcon - """ - colormap = Colormap(name) - size = 32 - if name is not None: - lut = colormap.getNColors(size) - else: - lut = colors - if len(lut) > size: - # Down sample - step = int(len(lut) / size) - lut = lut[::step] - elif len(lut) < size: - # Over sample - indexes = numpy.arange(size) / float(size) * (len(lut) - 1) - indexes = indexes.astype("int") - lut = lut[indexes] - if lut is None or len(lut) == 0: - return qt.QIcon() - - pixmap = qt.QPixmap(size, size) - painter = qt.QPainter(pixmap) - for i in range(size): - rgb = lut[i] - r, g, b = rgb[0], rgb[1], rgb[2] - painter.setPen(qt.QColor(r, g, b)) - painter.drawPoint(qt.QPoint(i, 0)) - - painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1) - painter.end() - - return qt.QIcon(pixmap) - - def getCurrentName(self): - return self.itemData(self.currentIndex(), self.LUT_NAME) - - def getCurrentColors(self): - return self.itemData(self.currentIndex(), self.LUT_COLORS) - - def findLutName(self, name): - return self.findData(name, role=self.LUT_NAME) - - def findLutColors(self, lut): - for index in range(self.count()): - if self.itemData(index, role=self.LUT_NAME) is not None: - continue - colors = self.itemData(index, role=self.LUT_COLORS) - if colors is None: - continue - if numpy.array_equal(colors, lut): - return index - return -1 - - def setCurrentLut(self, colormap): - name = colormap.getName() - if name is not None: - self._setCurrentName(name) - else: - lut = colormap.getColormapLUT() - self._setCurrentLut(lut) - - def _setCurrentLut(self, lut): - index = self.findLutColors(lut) - if index == -1: - index = self.count() - self.addItem("Custom") - self.setItemIcon(index, self.getIconPreview(colors=lut)) - self.setItemData(index, None, role=self.LUT_NAME) - self.setItemData(index, lut, role=self.LUT_COLORS) - self.setCurrentIndex(index) - - def _setCurrentName(self, name): - index = self.findLutName(name) - if index < 0: - index = self.count() - self.addItem(str.title(name)) - self.setItemIcon(index, self.getIconPreview(name=name)) - self.setItemData(index, name, role=self.LUT_NAME) - self.setCurrentIndex(index) - - @enum.unique class _DataInPlotMode(enum.Enum): """Enum for each mode of display of the data in the plot.""" @@ -329,7 +208,7 @@ class ColormapDialog(qt.QDialog): formLayout.setSpacing(0) # Colormap row - self._comboBoxColormap = _ColormapNameCombox(parent=formWidget) + self._comboBoxColormap = ColormapNameComboBox(parent=formWidget) self._comboBoxColormap.currentIndexChanged[int].connect(self._updateLut) formLayout.addRow('Colormap:', self._comboBoxColormap) diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py index 6ea870f..11a08b6 100644..100755 --- a/silx/gui/hdf5/Hdf5Item.py +++ b/silx/gui/hdf5/Hdf5Item.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# Copyright (c) 2016-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -30,6 +30,7 @@ __date__ = "17/01/2019" import logging import collections +import enum from .. import qt from .. import icons @@ -44,6 +45,17 @@ _hdf5Formatter = Hdf5Formatter(textFormatter=_formatter) # FIXME: The formatter should be an attribute of the Hdf5Model +class DescriptionType(enum.Enum): + """List of available kind of description. + """ + ERROR = "error" + DESCRIPTION = "description" + TITLE = "title" + PROGRAM = "program" + NAME = "name" + VALUE = "value" + + class Hdf5Item(Hdf5Node): """Subclass of :class:`qt.QStandardItem` to represent an HDF5-like item (dataset, file, group or link) as an element of a HDF5-like @@ -62,6 +74,7 @@ class Hdf5Item(Hdf5Node): self.__error = None self.__text = text self.__linkClass = linkClass + self.__description = None self.__nx_class = None Hdf5Node.__init__(self, parent, populateAll=populateAll) @@ -367,7 +380,20 @@ class Hdf5Item(Hdf5Node): text = "" else: text = self._getFormatter().textFormatter().toString(obj) - self.__nx_class = text.strip('"') + text = text.strip('"') + # Check NX_class formatting + lower = text.lower() + if lower.startswith('nx'): + formatedNX_class = 'NX' + lower[2:] + if lower == 'nxcansas': + formatedNX_class = 'NXcanSAS' # That's the only class with capital letters... + if text != formatedNX_class: + _logger.error("NX_class: %s is malformed (should be %s)", + text, + formatedNX_class) + text = formatedNX_class + + self.__nx_class = text return self.__nx_class def dataName(self, role): @@ -430,31 +456,131 @@ class Hdf5Item(Hdf5Node): return self._getFormatter().humanReadableValue(self.obj) return None + _NEXUS_CLASS_TO_VALUE_CHILDREN = { + 'NXaperture': ( + (DescriptionType.DESCRIPTION, 'description'), + ), + 'NXbeam_stop': ( + (DescriptionType.DESCRIPTION, 'description'), + ), + 'NXdetector': ( + (DescriptionType.NAME, 'local_name'), + (DescriptionType.DESCRIPTION, 'description') + ), + 'NXentry': ( + (DescriptionType.TITLE, 'title'), + ), + 'NXenvironment': ( + (DescriptionType.NAME, 'short_name'), + (DescriptionType.NAME, 'name'), + (DescriptionType.DESCRIPTION, 'description') + ), + 'NXinstrument': ( + (DescriptionType.NAME, 'name'), + ), + 'NXlog': ( + (DescriptionType.DESCRIPTION, 'description'), + ), + 'NXmirror': ( + (DescriptionType.DESCRIPTION, 'description'), + ), + 'NXpositioner': ( + (DescriptionType.NAME, 'name'), + ), + 'NXprocess': ( + (DescriptionType.PROGRAM, 'program'), + ), + 'NXsample': ( + (DescriptionType.TITLE, 'short_title'), + (DescriptionType.NAME, 'name'), + (DescriptionType.DESCRIPTION, 'description') + ), + 'NXsample_component': ( + (DescriptionType.NAME, 'name'), + (DescriptionType.DESCRIPTION, 'description') + ), + 'NXsensor': ( + (DescriptionType.NAME, 'short_name'), + (DescriptionType.NAME, 'name') + ), + 'NXsource': ( + (DescriptionType.NAME, 'name'), + ), # or its 'short_name' attribute... This is not supported + 'NXsubentry': ( + (DescriptionType.DESCRIPTION, 'definition'), + (DescriptionType.PROGRAM, 'program_name'), + (DescriptionType.TITLE, 'title'), + ), + } + """Mapping from NeXus class to child names containing data to use as value""" + + def __computeDataDescription(self): + """Compute the data description of this item + + :rtype: Tuple[kind, str] + """ + if self.__isBroken or self.__error is not None: + self.obj # lazy loading of the object + return DescriptionType.ERROR, self.__error + + if self.h5Class == silx.io.utils.H5Type.DATASET: + return DescriptionType.VALUE, self._getFormatter().humanReadableValue(self.obj) + + elif self.isGroupObj() and self.nexusClassName: + # For NeXus groups, try to find a title or name + # By default, look for a title (most application definitions should have one) + defaultSequence = ((DescriptionType.TITLE, 'title'),) + sequence = self._NEXUS_CLASS_TO_VALUE_CHILDREN.get(self.nexusClassName, defaultSequence) + for kind, child_name in sequence: + for index in range(self.childCount()): + child = self.child(index) + if (isinstance(child, Hdf5Item) and + child.h5Class == silx.io.utils.H5Type.DATASET and + child.basename == child_name): + return kind, self._getFormatter().humanReadableValue(child.obj) + + description = self.obj.attrs.get("desc", None) + if description is not None: + return DescriptionType.DESCRIPTION, description + else: + return None, None + + def __getDataDescription(self): + """Returns a cached version of the data description + + As the data description have to reach inside the HDF5 tree, the result + is cached. A better implementation could be to use a MRU cache, to avoid + to allocate too much data. + + :rtype: Tuple[kind, str] + """ + if self.__description is None: + self.__description = self.__computeDataDescription() + return self.__description + def dataDescription(self, role): """Data for the description column""" if role == qt.Qt.DecorationRole: + kind, _label = self.__getDataDescription() + if kind is not None: + icon = icons.getQIcon("description-%s" % kind.value) + return icon return None if role == qt.Qt.TextAlignmentRole: return qt.Qt.AlignTop | qt.Qt.AlignLeft if role == qt.Qt.DisplayRole: - if self.__isBroken: - self.obj # lazy loading of the object - return self.__error - if "desc" in self.obj.attrs: - text = self.obj.attrs["desc"] - else: - return "" - return text + _kind, label = self.__getDataDescription() + return label if role == qt.Qt.ToolTipRole: if self.__error is not None: self.obj # lazy loading of the object self.__initH5Object() return self.__error - if "desc" in self.obj.attrs: - text = self.obj.attrs["desc"] + kind, label = self.__getDataDescription() + if label is not None: + return "<b>%s</b><br/>%s" % (kind.value.capitalize(), label) else: return "" - return "Description: %s" % text return None def dataNode(self, role): diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py index 0ab4dc4..4bb43ff 100644..100755 --- a/silx/gui/hdf5/test/test_hdf5.py +++ b/silx/gui/hdf5/test/test_hdf5.py @@ -313,7 +313,7 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "") self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "") self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "") - self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], None) self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "File") def testGroupData(self): @@ -345,7 +345,7 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], value.dtype.name) self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "3") self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "[1 2 3]") - self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "[1 2 3]") self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Dataset") def testDropLastAsFirst(self): diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py index 9798123..fd4fdf8 100644 --- a/silx/gui/plot/ColorBar.py +++ b/silx/gui/plot/ColorBar.py @@ -874,7 +874,7 @@ class _TickBar(qt.QWidget): fm = qt.QFontMetrics(font) width = 0 for tick in self.ticks: - width = max(fm.width(form.format(tick)), width) + width = max(fm.boundingRect(form.format(tick)).width(), width) # if the length of the string are too long we are moving to scientific # display diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py index 050b344..4508c60 100644 --- a/silx/gui/plot/CurvesROIWidget.py +++ b/silx/gui/plot/CurvesROIWidget.py @@ -42,11 +42,13 @@ import numpy from silx.io import dictdump from silx.utils import deprecation from silx.utils.weakref import WeakMethodProxy +from silx.utils.proxy import docstring from .. import icons, qt -from silx.gui.plot.items.curve import Curve from silx.math.combo import min_max import weakref from silx.gui.widgets.TableWidget import TableWidget +from . import items +from .items.roi import _RegionOfInterestBase _logger = logging.getLogger(__name__) @@ -66,12 +68,15 @@ class CurvesROIWidget(qt.QWidget): 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' + + 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) @@ -1045,12 +1050,12 @@ class ROITable(TableWidget): _indexNextROI = 0 -class ROI(qt.QObject): +class ROI(_RegionOfInterestBase): """The Region Of Interest is defined by: - A name - 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. + not some ROI to a curve and do some post processing. - The x coordinate of the left limit (fromdata) - The x coordinate of the right limit (todata) @@ -1064,17 +1069,22 @@ class ROI(qt.QObject): """Signal emitted when the ROI is edited""" def __init__(self, name, fromdata=None, todata=None, type_=None): - qt.QObject.__init__(self) - assert type(name) is str + _RegionOfInterestBase.__init__(self, name=name) global _indexNextROI self._id = _indexNextROI _indexNextROI += 1 - self._name = name self._fromdata = fromdata self._todata = todata self._type = type_ or 'Default' + self.sigItemChanged.connect(self.__itemChanged) + + def __itemChanged(self, event): + """Handle name change""" + if event == items.ItemChangedType.NAME: + self.sigChanged.emit() + def getID(self): """ @@ -1098,23 +1108,6 @@ class ROI(qt.QObject): """ return self._type - def setName(self, name): - """ - Set the name of the :class:`ROI` - - :param str name: - """ - if self._name != name: - self._name = name - self.sigChanged.emit() - - def getName(self): - """ - - :return str: name of the :class:`ROI` - """ - return self._name - def setFrom(self, frm): """ @@ -1161,7 +1154,7 @@ class ROI(qt.QObject): """ ddict = { 'type': self._type, - 'name': self._name, + 'name': self.getName(), 'from': self._fromdata, 'to': self._todata, } @@ -1191,7 +1184,7 @@ class ROI(qt.QObject): :return: True if the ROI is the `ICR` """ - return self._name == 'ICR' + return self.getName() == 'ICR' def computeRawAndNetCounts(self, curve): """Compute the Raw and net counts in the ROI for the given curve. @@ -1208,7 +1201,7 @@ class ROI(qt.QObject): :param CurveItem curve: :return tuple: rawCount, netCount """ - assert isinstance(curve, Curve) or curve is None + assert isinstance(curve, items.Curve) or curve is None if curve is None: return None, None @@ -1251,7 +1244,7 @@ class ROI(qt.QObject): :param CurveItem curve: :return tuple: rawArea, netArea """ - assert isinstance(curve, Curve) or curve is None + assert isinstance(curve, items.Curve) or curve is None if curve is None: return None, None diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py index b9d0fd3..a9d89db 100644..100755 --- a/silx/gui/plot/LegendSelector.py +++ b/silx/gui/plot/LegendSelector.py @@ -38,63 +38,14 @@ import weakref import numpy from .. import qt, colors +from ..widgets.LegendIconWidget import LegendIconWidget 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): + +class LegendIcon(LegendIconWidget): """Object displaying a curve linestyle and symbol. :param QWidget parent: See :class:`QWidget` @@ -105,35 +56,8 @@ class LegendIcon(qt.QWidget): 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 @@ -206,125 +130,6 @@ class LegendIcon(qt.QWidget): 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. @@ -476,7 +281,7 @@ class LegendModel(qt.QAbstractListModel): new = [] for (legend, icon) in llist: linestyle = icon.get('linestyle', None) - if linestyle in NoLineStyle: + if LegendIconWidget.isEmptyLineStyle(linestyle): # Curve had no line, give it one and hide it # So when toggle line, it will display a solid line showLine = False @@ -485,7 +290,7 @@ class LegendModel(qt.QAbstractListModel): showLine = True symbol = icon.get('symbol', None) - if symbol in NoSymbols: + if LegendIconWidget.isEmptySymbol(symbol): # Curve had no symbol, give it one and hide it # So when toggle symbol, it will display 'o' showSymbol = False @@ -959,7 +764,7 @@ class LegendListContextMenu(qt.QMenu): } flag = modelIndex.data(LegendModel.showSymbolRole) symbol = modelIndex.data(LegendModel.iconSymbolRole) - visible = not flag or symbol in NoSymbols + visible = not flag or LegendIconWidget.isEmptySymbol(symbol) _logger.debug( 'togglePointsAction -- Symbols visible: %s', str(visible)) diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py index 27abd10..abfcf79 100644 --- a/silx/gui/plot/PlotInteraction.py +++ b/silx/gui/plot/PlotInteraction.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility +# Copyright (c) 2014-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -1079,7 +1079,10 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): applyZoomToPlot(self.machine.plot, scaleF, (x, y)) def onMove(self, x, y): - marker = self.machine.plot._pickMarker(x, y) + result = self.machine.plot._pickTopMost( + x, y, lambda item: isinstance(item, items.MarkerBase)) + marker = result.getItem() if result is not None else None + if marker is not None: dataPos = self.machine.plot.pixelToData(x, y) assert dataPos is not None @@ -1151,10 +1154,14 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): """ if btn == LEFT_BTN: - marker = self.plot._pickMarker( - x, y, lambda m: m.isSelectable()) - if marker is not None: - xData, yData = marker.getPosition() + result = self.plot._pickTopMost(x, y, lambda i: i.isSelectable()) + if result is None: + return None + + item = result.getItem() + + if isinstance(item, items.MarkerBase): + xData, yData = item.getPosition() if xData is None: xData = [0, 1] if yData is None: @@ -1162,58 +1169,44 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): eventDict = prepareMarkerSignal('markerClicked', 'left', - marker.getLegend(), + item.getLegend(), 'marker', - marker.isDraggable(), - marker.isSelectable(), + item.isDraggable(), + item.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 + elif isinstance(item, items.Curve): + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + + xData = item.getXData(copy=False) + yData = item.getYData(copy=False) + + indices = result.getIndices(copy=False) + eventDict = prepareCurveSignal('left', + item.getLegend(), + 'curve', + xData[indices], + yData[indices], + dataPos[0], dataPos[1], + x, y) + return eventDict + + elif isinstance(item, items.ImageBase): + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + + indices = result.getIndices(copy=False) + row, column = indices[0][0], indices[1][0] + eventDict = prepareImageSignal('left', + item.getLegend(), + 'image', + column, row, + dataPos[0], dataPos[1], + x, y) + return eventDict return None @@ -1240,6 +1233,15 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): posDataCursor) self.plot.notify(**eventDict) + @staticmethod + def __isDraggableItem(item): + return isinstance(item, items.DraggableMixIn) and item.isDraggable() + + def __terminateDrag(self): + """Finalize a drag operation by reseting to initial state""" + self.plot.setGraphCursorShape() + self.draggedItemRef = None + def beginDrag(self, x, y): """Handle begining of drag interaction @@ -1250,78 +1252,56 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): 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()) + result = self.plot._pickTopMost(x, y, self.__isDraggableItem) + item = result.getItem() if result is not None else None + + self.draggedItemRef = None if item is None else weakref.ref(item) + + if item is None: + self.__terminateDrag() + return False + + if isinstance(item, items.MarkerBase): + self._signalMarkerMovingEvent('markerMoving', item, x, y) - 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) + item = None if self.draggedItemRef is None else self.draggedItemRef() + if item is not None: + item.drag(self._lastPos, dataPos) - self._signalMarkerMovingEvent( - 'markerMoving', marker, x, y) + if isinstance(item, items.MarkerBase): + self._signalMarkerMovingEvent('markerMoving', item, 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 + self._lastPos = dataPos def endDrag(self, startPos, endPos): - if self.markerLegend is not None: - marker = self.plot._getMarker(self.markerLegend) - posData = list(marker.getPosition()) + item = None if self.draggedItemRef is None else self.draggedItemRef() + if item is not None and isinstance(item, items.MarkerBase): + posData = list(item.getPosition()) if posData[0] is None: - posData[0] = [0, 1] + posData[0] = 1. if posData[1] is None: - posData[1] = [0, 1] + posData[1] = 1. eventDict = prepareMarkerSignal( 'markerMoved', 'left', - marker.getLegend(), + item.getLegend(), 'marker', - marker.isDraggable(), - marker.isSelectable(), + item.isDraggable(), + item.isSelectable(), posData) self.plot.notify(**eventDict) - self.plot.setGraphCursorShape() - - del self.markerLegend - del self.imageLegend - del self._lastPos + self.__terminateDrag() def cancel(self): - self.plot.setGraphCursorShape() + self.__terminateDrag() class ItemsInteractionForCombo(ItemsInteraction): @@ -1329,22 +1309,16 @@ class ItemsInteractionForCombo(ItemsInteraction): """ class Idle(ItemsInteraction.Idle): + @staticmethod + def __isItemSelectableOrDraggable(item): + return (item.isSelectable() or ( + isinstance(item, items.DraggableMixIn) and item.isDraggable())) + 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 + result = self.machine.plot._pickTopMost( + x, y, self.__isItemSelectableOrDraggable) + if result is not None: # Request focus and handle interaction self.goto('clickOrDrag', x, y) return True else: # Do not request focus @@ -1658,7 +1632,7 @@ class PlotInteraction(object): plot = self._plot() assert plot is not None - if color not in (None, 'video inverted'): + if isinstance(color, numpy.ndarray) or color not in (None, 'video inverted'): color = colors.rgba(color) if mode in ('draw', 'select-draw'): diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py index 9b9b4d2..49e444a 100644..100755 --- a/silx/gui/plot/PlotWidget.py +++ b/silx/gui/plot/PlotWidget.py @@ -39,10 +39,6 @@ _logger = logging.getLogger(__name__) from collections import OrderedDict, namedtuple -try: - from collections import abc -except ImportError: # Python2 support - import collections as abc from contextlib import contextmanager import datetime as dt import itertools @@ -60,6 +56,7 @@ try: except ImportError: _logger.debug("matplotlib not available") +import six from ..colors import Colormap from .. import colors from . import PlotInteraction @@ -311,7 +308,7 @@ class PlotWidget(qt.QMainWindow): if callable(backend): return backend - elif isinstance(backend, str): + elif isinstance(backend, six.string_types): backend = backend.lower() if backend in ('matplotlib', 'mpl'): try: @@ -337,7 +334,7 @@ class PlotWidget(qt.QMainWindow): return backendClass - elif isinstance(backend, abc.Iterable): + elif isinstance(backend, (tuple, list)): for b in backend: try: return self.__getBackendClass(b) @@ -606,7 +603,7 @@ class PlotWidget(qt.QMainWindow): elif isinstance(item, (items.Marker, items.XMarker, items.YMarker)): kind = 'marker' - elif isinstance(item, items.Shape): + elif isinstance(item, (items.Shape, items.BoundingRect)): kind = 'item' elif isinstance(item, items.Histogram): kind = 'histogram' @@ -710,7 +707,8 @@ class PlotWidget(qt.QMainWindow): xlabel=None, ylabel=None, yaxis=None, xerror=None, yerror=None, z=None, selectable=None, fill=None, resetzoom=True, - histogram=None, copy=True): + histogram=None, copy=True, + baseline=None): """Add a 1D curve given by x an y to the graph. Curves are uniquely identified by their legend. @@ -791,6 +789,8 @@ class PlotWidget(qt.QMainWindow): - 'center' :param bool copy: True make a copy of the data (default), False to use provided arrays. + :param baseline: curve baseline + :type: Union[None,float,numpy.ndarray] :returns: The key string identify this curve """ # This is an histogram, use addHistogram @@ -845,6 +845,7 @@ class PlotWidget(qt.QMainWindow): curve.setColor(default_color) curve.setLineStyle(default_linestyle) curve.setSymbol(self._defaultPlotPoints) + curve._setBaseline(baseline=baseline) # Do not emit sigActiveCurveChanged, # it will be sent once with _setActiveItem @@ -887,7 +888,7 @@ class PlotWidget(qt.QMainWindow): 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) + curve.setData(x, y, xerror, yerror, baseline=baseline, copy=copy) if replace: # Then remove all other curves for c in self.getAllCurves(withhidden=True): @@ -924,7 +925,9 @@ class PlotWidget(qt.QMainWindow): fill=None, align='center', resetzoom=True, - copy=True): + copy=True, + z=None, + baseline=None): """Add an histogram to the graph. This is NOT computing the histogram, this method takes as parameter @@ -955,6 +958,9 @@ class PlotWidget(qt.QMainWindow): :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. + :param int z: Layer on which to draw the histogram + :param baseline: histogram baseline + :type: Union[None,float,numpy.ndarray] :returns: The key string identify this histogram """ legend = 'Unnamed histogram' if legend is None else str(legend) @@ -974,9 +980,12 @@ class PlotWidget(qt.QMainWindow): histo.setColor(color) if fill is not None: histo.setFill(fill) + if z is not None: + histo.setZValue(z=z) # Set histogram data - histo.setData(histogram, edges, align=align, copy=copy) + histo.setData(histogram=histogram, edges=edges, baseline=baseline, + align=align, copy=copy) if mustBeAdded: self._add(histo) @@ -1307,7 +1316,8 @@ class PlotWidget(qt.QMainWindow): color=None, selectable=False, draggable=False, - constraint=None): + constraint=None, + yaxis='left'): """Add a vertical line marker to the plot. Markers are uniquely identified by their legend. @@ -1333,12 +1343,14 @@ class PlotWidget(qt.QMainWindow): :type constraint: None or a callable that takes the coordinates of the current cursor position in the plot as input and that returns the filtered coordinates. + :param str yaxis: The Y axis this marker belongs to in: 'left', 'right' :return: The key string identify this marker """ return self._addMarker(x=x, y=None, legend=legend, text=text, color=color, selectable=selectable, draggable=draggable, - symbol=None, constraint=constraint) + symbol=None, constraint=constraint, + yaxis=yaxis) def addYMarker(self, y, legend=None, @@ -1346,7 +1358,8 @@ class PlotWidget(qt.QMainWindow): color=None, selectable=False, draggable=False, - constraint=None): + constraint=None, + yaxis='left'): """Add a horizontal line marker to the plot. Markers are uniquely identified by their legend. @@ -1372,12 +1385,14 @@ class PlotWidget(qt.QMainWindow): :type constraint: None or a callable that takes the coordinates of the current cursor position in the plot as input and that returns the filtered coordinates. + :param str yaxis: The Y axis this marker belongs to in: 'left', 'right' :return: The key string identify this marker """ return self._addMarker(x=None, y=y, legend=legend, text=text, color=color, selectable=selectable, draggable=draggable, - symbol=None, constraint=constraint) + symbol=None, constraint=constraint, + yaxis=yaxis) def addMarker(self, x, y, legend=None, text=None, @@ -1385,7 +1400,8 @@ class PlotWidget(qt.QMainWindow): selectable=False, draggable=False, symbol='+', - constraint=None): + constraint=None, + yaxis='left'): """Add a point marker to the plot. Markers are uniquely identified by their legend. @@ -1423,6 +1439,7 @@ class PlotWidget(qt.QMainWindow): :type constraint: None or a callable that takes the coordinates of the current cursor position in the plot as input and that returns the filtered coordinates. + :param str yaxis: The Y axis this marker belongs to in: 'left', 'right' :return: The key string identify this marker """ if x is None: @@ -1436,12 +1453,14 @@ class PlotWidget(qt.QMainWindow): return self._addMarker(x=x, y=y, legend=legend, text=text, color=color, selectable=selectable, draggable=draggable, - symbol=symbol, constraint=constraint) + symbol=symbol, constraint=constraint, + yaxis=yaxis) def _addMarker(self, x, y, legend, text, color, selectable, draggable, - symbol, constraint): + symbol, constraint, + yaxis=None): """Common method for adding point, vline and hline marker. See :meth:`addMarker` for argument documentation. @@ -1487,6 +1506,7 @@ class PlotWidget(qt.QMainWindow): marker._setDraggable(draggable) if symbol is not None: marker.setSymbol(symbol) + marker.setYAxis(yaxis) # TODO to improve, but this ensure constraint is applied marker.setPosition(x, y) @@ -2692,6 +2712,7 @@ class PlotWidget(qt.QMainWindow): """Redraw the plot immediately.""" for item in self._contentToUpdate: item._update(self._backend) + self._contentToUpdate = [] self._backend.replot() self._dirty = False # reset dirty flag @@ -2862,7 +2883,18 @@ class PlotWidget(qt.QMainWindow): :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) + + if x is None: + x = self.width() // 2 + if y is None: + y = self.height() // 2 + + if check: + left, top, width, height = self.getPlotBoundsInPixels() + if not (left <= x <= left + width and top <= y <= top + height): + return None + + return self._backend.pixelToData(x, y, axis) def getPlotBoundsInPixels(self): """Plot area bounds in widget coordinates in pixels. @@ -2880,29 +2912,6 @@ class PlotWidget(qt.QMainWindow): """ 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 @@ -2924,73 +2933,41 @@ class PlotWidget(qt.QMainWindow): """ return self._getItem(kind='marker', legend=legend) - def _pickImageOrCurve(self, x, y, test=None): - """Pick an image or a curve at the given position. + def pickItems(self, x, y, condition=None): + """Generator of picked items in the plot at given position. - To use for interaction implementation. + Items are returned from front to back. :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. + :param callable condition: + Callable taking an item as input and returning False for items + to skip during picking. If None (default) no item is skipped. + :return: Iterable of :class:`PickingResult` objects at picked position. + Items are ordered from front to back. """ - 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 + for item in reversed(self._backend.getItemsFromBackToFront(condition=condition)): + result = item.pick(x, y) + if result is not None: + yield result - else: - _logger.warning('Unsupported kind: %s', kind) - - return None + def _pickTopMost(self, x, y, condition=None): + """Returns top-most picked item in the plot at given position. - def _pick(self, x, y): - """Pick items in the plot at given position. + Items are checked from front to back. :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) + :param callable condition: + Callable taking an item as input and returning False for items + to skip during picking. If None (default) no item is skipped. + :return: :class:`PickingResult` object at picked position. + If no item is picked, it returns None + :rtype: Union[None,PickingResult] + """ + for result in self.pickItems(x, y, condition): + return result + return None # User event handling # @@ -3123,7 +3100,7 @@ class PlotWidget(qt.QMainWindow): # Panning with arrow keys def isPanWithArrowKeys(self): - """Returns whether or not panning the graph with arrow keys is enable. + """Returns whether or not panning the graph with arrow keys is enabled. See :meth:`setPanWithArrowKeys`. """ diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py index a39430e..0196050 100644 --- a/silx/gui/plot/PlotWindow.py +++ b/silx/gui/plot/PlotWindow.py @@ -122,7 +122,7 @@ class PlotWindow(PlotWidget): self._curvesROIDockWidget = None self._maskToolsDockWidget = None self._consoleDockWidget = None - self._statsWidget = None + self._statsDockWidget = None # Create color bar, hidden by default for backward compatibility self._colorbar = ColorBarWidget(parent=self, plot=self) @@ -485,6 +485,22 @@ class PlotWindow(PlotWidget): self.tabifyDockWidget(self._dockWidgets[0], dock_widget) + def __handleFirstDockWidgetShow(self, visible): + """Handle QDockWidget.visibilityChanged + + It calls :meth:`addTabbedDockWidget` for the `sender` widget. + This allows to call `addTabbedDockWidget` lazily. + + It disconnect itself from the signal once done. + + :param bool visible: + """ + if visible: + dockWidget = self.sender() + dockWidget.visibilityChanged.disconnect( + self.__handleFirstDockWidgetShow) + self.addTabbedDockWidget(dockWidget) + def getColorBarWidget(self): """Returns the embedded :class:`ColorBarWidget` widget. @@ -499,7 +515,8 @@ class PlotWindow(PlotWidget): if self._legendsDockWidget is None: self._legendsDockWidget = LegendsDockWidget(plot=self) self._legendsDockWidget.hide() - self.addTabbedDockWidget(self._legendsDockWidget) + self._legendsDockWidget.visibilityChanged.connect( + self.__handleFirstDockWidgetShow) return self._legendsDockWidget def getCurvesRoiDockWidget(self): @@ -509,7 +526,8 @@ class PlotWindow(PlotWidget): self._curvesROIDockWidget = CurvesROIDockWidget( plot=self, name='Regions Of Interest') self._curvesROIDockWidget.hide() - self.addTabbedDockWidget(self._curvesROIDockWidget) + self._curvesROIDockWidget.visibilityChanged.connect( + self.__handleFirstDockWidgetShow) return self._curvesROIDockWidget def getCurvesRoiWidget(self): @@ -529,7 +547,8 @@ class PlotWindow(PlotWidget): self._maskToolsDockWidget = MaskToolsDockWidget( plot=self, name='Mask') self._maskToolsDockWidget.hide() - self.addTabbedDockWidget(self._maskToolsDockWidget) + self._maskToolsDockWidget.visibilityChanged.connect( + self.__handleFirstDockWidgetShow) return self._maskToolsDockWidget def getStatsWidget(self): @@ -537,16 +556,18 @@ class PlotWindow(PlotWidget): :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 + if self._statsDockWidget is None: + self._statsDockWidget = qt.QDockWidget() + self._statsDockWidget.setWindowTitle("Curves stats") + self._statsDockWidget.layout().setContentsMargins(0, 0, 0, 0) + statsWidget = BasicStatsWidget(parent=self, plot=self) + self._statsDockWidget.setWidget(statsWidget) + statsWidget.sigVisibilityChanged.connect( + self.getStatsAction().setChecked) + self._statsDockWidget.hide() + self._statsDockWidget.visibilityChanged.connect( + self.__handleFirstDockWidgetShow) + return self._statsDockWidget.widget() # getters for actions @property diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py index 0c6797f..9a15763 100644 --- a/silx/gui/plot/ScatterMaskToolsWidget.py +++ b/silx/gui/plot/ScatterMaskToolsWidget.py @@ -276,7 +276,12 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self.plot.sigActiveScatterChanged.connect(self._activeScatterChanged) def hideEvent(self, event): - self.plot.sigActiveScatterChanged.disconnect(self._activeScatterChanged) + try: + # if the method is not connected this raises a TypeError and there is no way + # to know the connected slots + self.plot.sigActiveScatterChanged.disconnect(self._activeScatterChanged) + except (RuntimeError, TypeError): + _logger.info(sys.exc_info()[1]) if not self.browseAction.isChecked(): self.browseAction.trigger() # Disable drawing tool diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py index 1d015d4..bdbf3ab 100644 --- a/silx/gui/plot/ScatterView.py +++ b/silx/gui/plot/ScatterView.py @@ -79,7 +79,7 @@ class ScatterView(qt.QMainWindow): self._plot = weakref.ref(plot) # Add an empty scatter - plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) + self.__createEmptyScatter() # Create colorbar widget with white background self._colorbar = ColorBarWidget(parent=self, plot=plot) @@ -145,6 +145,21 @@ class ScatterView(qt.QMainWindow): for action in toolbar.actions(): self.addAction(action) + + def __createEmptyScatter(self): + """Create an empty scatter item that is used to display the data + + :rtype: ~silx.gui.plot.items.Scatter + """ + plot = self.getPlotWidget() + plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) + scatter = plot._getItem( + kind='scatter', legend=self._SCATTER_LEGEND) + # Profile is not selectable, + # so it does not interfere with profile interaction + scatter._setSelectable(False) + return scatter + def _pickScatterData(self, x, y): """Get data and index and value of top most scatter plot at position (x, y) @@ -162,17 +177,19 @@ class ScatterView(qt.QMainWindow): 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.getXData(copy=False)[dataIndex], - item.getYData(copy=False)[dataIndex], - item.getValueData(copy=False)[dataIndex]) - break + result = plot._pickTopMost( + pixelPos[0], pixelPos[1], + lambda item: isinstance(item, items.Scatter)) + if result is not None: + # Get last index + # with matplotlib it should be the top-most point + dataIndex = result.getIndices(copy=False)[-1] + item = result.getItem() + self.__pickingCache = ( + dataIndex, + item.getXData(copy=False)[dataIndex], + item.getYData(copy=False)[dataIndex], + item.getValueData(copy=False)[dataIndex]) return self.__pickingCache @@ -343,9 +360,7 @@ class ScatterView(qt.QMainWindow): 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) + scatter = self.__createEmptyScatter() return scatter # Convenient proxies diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py index 2a3d7e8..7e4c389 100644 --- a/silx/gui/plot/StackView.py +++ b/silx/gui/plot/StackView.py @@ -85,6 +85,8 @@ from .Profile import Profile3DToolBar from ..widgets.FrameBrowser import HorizontalSliderWithBrowser from silx.gui.plot.actions import control as actions_control +from silx.gui.plot.actions import io as silx_io +from silx.io.nxdata import save_NXdata from silx.utils.array_like import DatasetView, ListOfImages from silx.math import calibration from silx.utils.deprecation import deprecated_warning @@ -160,6 +162,9 @@ class StackView(qt.QMainWindow): This signal provides the current frame number. """ + IMAGE_STACK_FILTER_NXDATA = 'Stack of images as NXdata (%s)' % silx_io._NEXUS_HDF5_EXT_STR + + def __init__(self, parent=None, resetzoom=True, backend=None, autoScale=False, logScale=False, grid=False, colormap=True, aspectRatio=True, yinverted=True, @@ -226,6 +231,7 @@ class StackView(qt.QMainWindow): self._plot.getXAxis().setLabel('Columns') self._plot.getYAxis().setLabel('Rows') self._plot.sigPlotSignal.connect(self._plotCallback) + self._plot.getSaveAction().setFileFilter('image', self.IMAGE_STACK_FILTER_NXDATA, func=self._saveImageStack, appendToFile=True) self.__planeSelection = PlanesWidget(self._plot) self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective) @@ -253,6 +259,25 @@ class StackView(qt.QMainWindow): self.__planeSelection.sigPlaneSelectionChanged.connect( self._plot.profile.clearProfile) + def _saveImageStack(self, plot, filename, nameFilter): + """Save all images from the stack into a volume. + + :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. + :raises: ValueError if nameFilter is invalid + """ + if not nameFilter == self.IMAGE_STACK_FILTER_NXDATA: + raise ValueError('Wrong callback') + entryPath = silx_io.SaveAction._selectWriteableOutputGroup(filename, parent=self) + if entryPath is None: + return False + return save_NXdata(filename, + nxentry_name=entryPath, + signal=self.getStack(copy=False, returnNumpyArray=True)[0], + signal_name="image_stack") + def _addColorBarAction(self): self._plot.getColorBarWidget().setVisible(True) actions = self._plot.toolBar().actions() @@ -517,7 +542,12 @@ class StackView(qt.QMainWindow): # 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) + # note: there is no real autoscale in the stack widget, it is more + # like a hack computing stack min and max + colormap = self.getColormap() + _vmin, _vmax = colormap.getColormapRange(data=self._stack) + colormap.setVRange(_vmin, _vmax) + self.setColormap(colormap=colormap) # init plot self._plot.addImage(self.__transposed_view[0, :, :], diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py index 5e2dc58..80bc05d 100644 --- a/silx/gui/plot/StatsWidget.py +++ b/silx/gui/plot/StatsWidget.py @@ -1323,6 +1323,7 @@ class _BaseLineStatsWidget(_StatsWidgetBase, qt.QWidget): def setStats(self, statsHandler): """Set which stats to display and the associated formatting. + :param StatsHandler statsHandler: Set the statistics to be displayed and how to format them using """ @@ -1541,12 +1542,12 @@ class BasicGridStatsWidget(qt.QWidget): only visible ones. :param int statsPerLine: number of statistic to be displayed per line - .. snapshotqt:: img/_BasicGridStatsWidget.png + .. snapshotqt:: img/BasicGridStatsWidget.png :width: 600px :align: center from silx.gui.plot import Plot1D - from silx.gui.plot.StatsWidget import _BasicGridStatsWidget + from silx.gui.plot.StatsWidget import BasicGridStatsWidget plot = Plot1D() x = range(100) @@ -1554,7 +1555,7 @@ class BasicGridStatsWidget(qt.QWidget): plot.addCurve(x, y, legend='curve_0') plot.setActiveCurve('curve_0') - widget = _BasicGridStatsWidget(plot=plot, kind='curve') + widget = BasicGridStatsWidget(plot=plot, kind='curve') widget.show() """ diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py index ec4a3de..e2fa6b1 100644..100755 --- a/silx/gui/plot/actions/control.py +++ b/silx/gui/plot/actions/control.py @@ -360,8 +360,8 @@ class ColormapAction(PlotAction): # Run the dialog listening to colormap change if checked is True: - self._dialog.show() self._updateColormap() + self._dialog.show() else: self._dialog.hide() diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py index cb70733..6fc5c75 100644 --- a/silx/gui/plot/actions/fit.py +++ b/silx/gui/plot/actions/fit.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -98,18 +98,15 @@ class FitAction(PlotToolAction): 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 + + window = FitWidget(parent=self.plot) + window.setWindowFlags(qt.Qt.Window) + window.sigFitWidgetSignal.connect(self.handle_signal) return window def _connectPlot(self, window): @@ -158,8 +155,8 @@ class FitAction(PlotToolAction): 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.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)) @@ -171,7 +168,10 @@ class FitAction(PlotToolAction): fit_curve = self.plot.getCurve(fit_legend) if ddict["event"] == "FitFinished": - y_fit = self.fit_widget.fitmanager.gendata() + fit_widget = self._getToolWindow() + if fit_widget is None: + return + y_fit = fit_widget.fitmanager.gendata() if fit_curve is None: self.plot.addCurve(x_fit, y_fit, fit_legend, diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py index 9181f53..3bb3e6a 100644 --- a/silx/gui/plot/actions/histogram.py +++ b/silx/gui/plot/actions/histogram.py @@ -134,6 +134,7 @@ class PixelIntensitiesHistoAction(PlotToolAction): window = Plot1D(parent=self.plot) window.setWindowFlags(qt.Qt.Window) window.setWindowTitle('Image Intensity Histogram') + window.setDataMargins(0.1, 0.1, 0.1, 0.1) window.getXAxis().setLabel("Value") window.getYAxis().setLabel("Count") return window diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py index 09e4a99..43b3b3a 100644 --- a/silx/gui/plot/actions/io.py +++ b/silx/gui/plot/actions/io.py @@ -131,6 +131,7 @@ class SaveAction(PlotAction): 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, @@ -156,6 +157,8 @@ class SaveAction(PlotAction): 'image': OrderedDict(), 'scatter': OrderedDict()} + self._appendFilters = list(self.DEFAULT_APPEND_FILTERS) + # Initialize filters for nameFilter in self.DEFAULT_ALL_FILTERS: self.setFileFilter( @@ -185,10 +188,11 @@ class SaveAction(PlotAction): self.setShortcut(qt.QKeySequence.Save) self.setShortcutContext(qt.Qt.WidgetShortcut) - def _errorMessage(self, informativeText=''): + @staticmethod + def _errorMessage(informativeText='', parent=None): """Display an error message.""" # TODO issue with QMessageBox size fixed and too small - msg = qt.QMessageBox(self.plot) + msg = qt.QMessageBox(parent) msg.setIcon(qt.QMessageBox.Critical) msg.setInformativeText(informativeText + ' ' + str(sys.exc_info()[1])) msg.setDetailedText(traceback.format_exc()) @@ -220,7 +224,8 @@ class SaveAction(PlotAction): ylabel = item.getYLabel() or self.plot.getYAxis().getLabel() return xlabel, ylabel - def _selectWriteableOutputGroup(self, filename): + @staticmethod + def _selectWriteableOutputGroup(filename, parent): if os.path.exists(filename) and os.path.isfile(filename) \ and os.access(filename, os.W_OK): entryPath = selectOutputGroup(filename) @@ -232,11 +237,11 @@ class SaveAction(PlotAction): # create new entry in new file return "/entry" else: - self._errorMessage('Save failed (file access issue)\n') + SaveAction._errorMessage('Save failed (file access issue)\n', parent=parent) return None def _saveCurveAsNXdata(self, curve, filename): - entryPath = self._selectWriteableOutputGroup(filename) + entryPath = self._selectWriteableOutputGroup(filename, parent=self.plot) if entryPath is None: return False @@ -273,7 +278,7 @@ class SaveAction(PlotAction): if curve is None: curves = plot.getAllCurves() if not curves: - self._errorMessage("No curve to be saved") + self._errorMessage("No curve to be saved", parent=self.plot) return False curve = curves[0] @@ -299,7 +304,7 @@ class SaveAction(PlotAction): fmt=fmt, csvdelim=csvdelim, autoheader=autoheader) except IOError: - self._errorMessage('Save failed\n') + self._errorMessage('Save failed\n', parent=self.plot) return False return True @@ -317,7 +322,7 @@ class SaveAction(PlotAction): curves = plot.getAllCurves() if not curves: - self._errorMessage("No curves to be saved") + self._errorMessage("No curves to be saved", parent=self.plot) return False curve = curves[0] @@ -334,7 +339,7 @@ class SaveAction(PlotAction): write_file_header=True, close_file=False) except IOError: - self._errorMessage('Save failed\n') + self._errorMessage('Save failed\n', parent=self.plot) return False for curve in curves[1:]: @@ -351,7 +356,7 @@ class SaveAction(PlotAction): write_file_header=False, close_file=False) except IOError: - self._errorMessage('Save failed\n') + self._errorMessage('Save failed\n', parent=self.plot) return False specfile.close() @@ -391,12 +396,12 @@ class SaveAction(PlotAction): try: numpy.save(filename, data) except IOError: - self._errorMessage('Save failed\n') + self._errorMessage('Save failed\n', parent=self.plot) return False return True elif nameFilter == self.IMAGE_FILTER_NXDATA: - entryPath = self._selectWriteableOutputGroup(filename) + entryPath = self._selectWriteableOutputGroup(filename, parent=self.plot) if entryPath is None: return False xorigin, yorigin = image.getOrigin() @@ -438,7 +443,7 @@ class SaveAction(PlotAction): autoheader=True) except IOError: - self._errorMessage('Save failed\n') + self._errorMessage('Save failed\n', parent=self.plot) return False return True @@ -471,7 +476,7 @@ class SaveAction(PlotAction): return False if nameFilter == self.SCATTER_FILTER_NXDATA: - entryPath = self._selectWriteableOutputGroup(filename) + entryPath = self._selectWriteableOutputGroup(filename, parent=self.plot) if entryPath is None: return False scatter = plot.getScatter() @@ -502,7 +507,7 @@ class SaveAction(PlotAction): axes_errors=[xerror, yerror], title=plot.getGraphTitle()) - def setFileFilter(self, dataKind, nameFilter, func, index=None): + def setFileFilter(self, dataKind, nameFilter, func, index=None, appendToFile=False): """Set a name filter to add/replace a file format support :param str dataKind: @@ -513,10 +518,15 @@ class SaveAction(PlotAction): :param callable func: The function to call to perform saving. Expected signature is: bool func(PlotWidget plot, str filename, str nameFilter) + :param bool appendToFile: True to append the data into the selected + file. :param integer index: Index of the filter in the final list (or None) """ assert dataKind in ('all', 'curve', 'curves', 'image', 'scatter') + if appendToFile: + self._appendFilters.append(nameFilter) + # first append or replace the new filter to prevent colissions self._filters[dataKind][nameFilter] = func if index is None: @@ -601,7 +611,7 @@ class SaveAction(PlotAction): def onFilterSelection(filt_): # disable overwrite confirmation for NXdata types, # because we append the data to existing files - if filt_ in self.DEFAULT_APPEND_FILTERS: + if filt_ in self._appendFilters: dialog.setOption(dialog.DontConfirmOverwrite) else: dialog.setOption(dialog.DontConfirmOverwrite, False) diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py index af37543..75d999b 100644..100755 --- a/silx/gui/plot/backends/BackendBase.py +++ b/silx/gui/plot/backends/BackendBase.py @@ -97,16 +97,15 @@ class BackendBase(object): # Add methods - def addCurve(self, x, y, legend, + def addCurve(self, x, y, color, symbol, linewidth, linestyle, yaxis, - xerror, yerror, z, selectable, - fill, alpha, symbolsize): + xerror, yerror, z, + fill, alpha, symbolsize, baseline): """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 @@ -136,24 +135,21 @@ class BackendBase(object): :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 + return object() - def addImage(self, data, legend, + def addImage(self, data, 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 @@ -161,39 +157,34 @@ class BackendBase(object): 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 + return object() - def addTriangles(self, x, y, triangles, legend, - color, z, selectable, alpha): + def addTriangles(self, x, y, triangles, + color, z, alpha): """Add a set of triangles. :param numpy.ndarray x: The data corresponding to the x axis :param numpy.ndarray y: The data corresponding to the y axis :param numpy.ndarray triangles: The indices to make triangles as a (Ntriangle, 3) array - :param str legend: The legend to be associated to the curve :param numpy.ndarray color: color(s) as (npoints, 4) array :param int z: Layer on which to draw the cuve - :param bool selectable: indicate if the curve can be selected :param float alpha: Opacity as a float in [0., 1.] :returns: The triangles' unique identifier used by the backend """ - return legend + return object() - def addItem(self, x, y, legend, shape, color, fill, overlay, z, + def addItem(self, x, y, shape, color, fill, overlay, z, linestyle, linewidth, linebgcolor): """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 @@ -215,22 +206,18 @@ class BackendBase(object): '#FF0000'. It is used to draw dotted line using a second color. :returns: The handle used by the backend to univocally access the item """ - return legend + return object() - def addMarker(self, x, y, legend, text, color, - selectable, draggable, - symbol, linestyle, linewidth, constraint): + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): """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: @@ -257,13 +244,13 @@ class BackendBase(object): dragging operations or None for no filter. This function is called each time a marker is moved. - This parameter is only used if draggable is True. :type constraint: None or a callable that takes the coordinates of the current cursor position in the plot as input and that returns the filtered coordinates. + :param str yaxis: The Y axis this marker belongs to in: 'left', 'right' :return: Handle used by the backend to univocally access the marker """ - return legend + return object() # Remove methods @@ -307,22 +294,40 @@ class BackendBase(object): """ pass - def pickItems(self, x, y, kinds): - """Get a list of items at a pixel position. + def getItemsFromBackToFront(self, condition=None): + """Returns the list of plot items order as rendered by the backend. + + This is the order used for rendering. + By default, it takes into account overlays, z value and order of addition of items, + but backends can override it. + + :param callable condition: + Callable taking an item as input and returning False for items to skip. + If None (default), no item is skipped. + :rtype: List[~silx.gui.plot.items.Item] + """ + # Sort items: Overlays first, then others + # and in each category ordered by z and then by order of addition + # as content keeps this order. + content = self._plot.getItems() + if condition is not None: + content = [item for item in content if condition(item)] + + return sorted( + content, + key=lambda i: ((1 if i.isOverlay() else 0), i.getZValue())) + + def pickItem(self, x, y, item): + """Return picked indices if any, or None. :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 [] + :param item: A backend item created with add* methods. + :return: None if item was not picked, else returns + picked indices information. + :rtype: Union[None,List] + """ + return None # Update curve diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py index 7739329..075f6aa 100644..100755 --- a/silx/gui/plot/backends/BackendMatplotlib.py +++ b/silx/gui/plot/backends/BackendMatplotlib.py @@ -56,12 +56,13 @@ from matplotlib.collections import PathCollection, LineCollection from matplotlib.ticker import Formatter, ScalarFormatter, Locator from matplotlib.tri import Triangulation from matplotlib.collections import TriMesh +from matplotlib import path as mpath from . import BackendBase +from .. import items from .._utils import FLOAT32_MINPOS from .._utils.dtime_ticklayout import calcTicks, bestFormatString, timestamp - _PATCH_LINESTYLE = { "-": 'solid', "--": 'dashed', @@ -72,11 +73,54 @@ _PATCH_LINESTYLE = { } """Patches do not uses the same matplotlib syntax""" +_MARKER_PATHS = {} +"""Store cached extra marker paths""" + +_SPECIAL_MARKERS = { + 'tickleft': 0, + 'tickright': 1, + 'tickup': 2, + 'tickdown': 3, + 'caretleft': 4, + 'caretright': 5, + 'caretup': 6, + 'caretdown': 7, +} + def normalize_linestyle(linestyle): """Normalize known old-style linestyle, else return the provided value.""" return _PATCH_LINESTYLE.get(linestyle, linestyle) +def get_path_from_symbol(symbol): + """Get the path representation of a symbol, else None if + it is not provided. + + :param str symbol: Symbol description used by silx + :rtype: Union[None,matplotlib.path.Path] + """ + if symbol == u'\u2665': + path = _MARKER_PATHS.get(symbol, None) + if path is not None: + return path + vertices = numpy.array([ + [0,-99], + [31,-73], [47,-55], [55,-46], + [63,-37], [94,-2], [94,33], + [94,69], [71,89], [47,89], + [24,89], [8,74], [0,58], + [-8,74], [-24,89], [-47,89], + [-71,89], [-94,69], [-94,33], + [-94,-2], [-63,-37], [-55,-46], + [-47,-55], [-31,-73], [0,-99], + [0,-99]]) + codes = [mpath.Path.CURVE4] * len(vertices) + codes[0] = mpath.Path.MOVETO + codes[-1] = mpath.Path.CLOSEPOLY + path = mpath.Path(vertices, codes) + _MARKER_PATHS[symbol] = path + return path + return None class NiceDateLocator(Locator): """ @@ -162,7 +206,53 @@ class NiceAutoDateFormatter(Formatter): return tickStr -class _MarkerContainer(Container): +class _PickableContainer(Container): + """Artists container with a :meth:`contains` method""" + + def __init__(self, *args, **kwargs): + Container.__init__(self, *args, **kwargs) + self.__zorder = None + + @property + def axes(self): + """Mimin Artist.axes""" + for child in self.get_children(): + if hasattr(child, 'axes'): + return child.axes + return None + + def draw(self, *args, **kwargs): + """artist-like draw to broadcast draw to children""" + for child in self.get_children(): + child.draw(*args, **kwargs) + + def get_zorder(self): + """Mimic Artist.get_zorder""" + return self.__zorder + + def set_zorder(self, z): + """Mimic Artist.set_zorder to broadcast to children""" + if z != self.__zorder: + self.__zorder = z + for child in self.get_children(): + child.set_zorder(z) + + def contains(self, mouseevent): + """Mimic Artist.contains, and call it on all children. + + :param mouseevent: + :return: Picking status and associated information as a dict + :rtype: (bool,dict) + """ + # Goes through children from front to back and return first picked one. + for child in reversed(self.get_children()): + picked, info = child.contains(mouseevent) + if picked: + return picked, info + return False, {} + + +class _MarkerContainer(_PickableContainer): """Marker artists container supporting draw/remove and text position update :param artists: @@ -173,13 +263,14 @@ class _MarkerContainer(Container): :param y: Y coordinate of the marker (None for vertical lines) """ - def __init__(self, artists, x, y): + def __init__(self, artists, x, y, yAxis): self.line = artists[0] self.text = artists[1] if len(artists) > 1 else None self.x = x self.y = y + self.yAxis = yAxis - Container.__init__(self, artists) + _PickableContainer.__init__(self, artists) def draw(self, *args, **kwargs): """artist-like draw to broadcast draw to line and text""" @@ -214,6 +305,15 @@ class _MarkerContainer(Container): xmax -= 0.005 * delta self.text.set_x(xmax) + def contains(self, mouseevent): + """Mimic Artist.contains, and call it on the line Artist. + + :param mouseevent: + :return: Picking status and associated information as a dict + :rtype: (bool,dict) + """ + return self.line.contains(mouseevent) + class _DoubleColoredLinePatch(matplotlib.patches.Patch): """Matplotlib patch to display any patch using double color.""" @@ -294,7 +394,11 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax2 = self.ax.twinx() self.ax2.set_label("right") # Make sure background of Axes is displayed - self.ax2.patch.set_visible(True) + self.ax2.patch.set_visible(False) + self.ax.patch.set_visible(True) + + # Set axis zorder=0.5 so grid is displayed at 0.5 + self.ax.set_axisbelow(True) # disable the use of offsets try: @@ -306,10 +410,8 @@ class BackendMatplotlib(BackendBase.BackendBase): _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') @@ -317,7 +419,6 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax.set_facecolor('none') self.fig.sca(self.ax) - self._overlays = set() self._background = None self._colormaps = {} @@ -327,15 +428,67 @@ class BackendMatplotlib(BackendBase.BackendBase): self._enableAxis('right', False) self._isXAxisTimeSeries = False + def getItemsFromBackToFront(self, condition=None): + """Order as BackendBase + take into account matplotlib Axes structure""" + def axesOrder(item): + if item.isOverlay(): + return 2 + elif isinstance(item, items.YAxisMixIn) and item.getYAxis() == 'right': + return 1 + else: + return 0 + + return sorted( + BackendBase.BackendBase.getItemsFromBackToFront( + self, condition=condition), + key=axesOrder) + + def _overlayItems(self): + """Generator of backend renderer for overlay items""" + for item in self._plot.getItems(): + if (item.isOverlay() and + item.isVisible() and + item._backendRenderer is not None): + yield item._backendRenderer + + def _hasOverlays(self): + """Returns whether there is an overlay layer or not. + + The overlay layers contains overlay items and the crosshair. + + :rtype: bool + """ + if self._graphCursor: + return True # There is the crosshair + + for item in self._overlayItems(): + return True # There is at least one overlay item + return False + # Add methods - def addCurve(self, x, y, legend, + def _getMarkerFromSymbol(self, symbol): + """Returns a marker that can be displayed by matplotlib. + + :param str symbol: A symbol description used by silx + :rtype: Union[str,int,matplotlib.path.Path] + """ + path = get_path_from_symbol(symbol) + if path is not None: + return path + num = _SPECIAL_MARKERS.get(symbol, None) + if num is not None: + return num + # This symbol must be supported by matplotlib + return symbol + + def addCurve(self, x, y, 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): + xerror, yerror, z, + fill, alpha, symbolsize, baseline): + for parameter in (x, y, color, symbol, linewidth, linestyle, + yaxis, z, fill, alpha, symbolsize): assert parameter is not None assert yaxis in ('left', 'right') @@ -349,7 +502,7 @@ class BackendMatplotlib(BackendBase.BackendBase): else: axes = self.ax - picker = 3 if selectable else None + picker = 3 artists = [] # All the artists composing the curve @@ -368,7 +521,7 @@ class BackendMatplotlib(BackendBase.BackendBase): yerror.shape[1] == 1): yerror = numpy.ravel(yerror) - errorbars = axes.errorbar(x, y, label=legend, + errorbars = axes.errorbar(x, y, xerr=xerror, yerr=yerror, linestyle=' ', color=errorbarColor) artists += list(errorbars.get_children()) @@ -383,7 +536,7 @@ class BackendMatplotlib(BackendBase.BackendBase): if linestyle not in ["", " ", None]: # scatter plot with an actual line ... # we need to assign a color ... - curveList = axes.plot(x, y, label=legend, + curveList = axes.plot(x, y, linestyle=linestyle, color=actualColor[0], linewidth=linewidth, @@ -391,21 +544,24 @@ class BackendMatplotlib(BackendBase.BackendBase): marker=None) artists += list(curveList) + marker = self._getMarkerFromSymbol(symbol) scatter = axes.scatter(x, y, - label=legend, color=actualColor, - marker=symbol, + marker=marker, picker=picker, s=symbolsize**2) artists.append(scatter) if fill: + if baseline is None: + _baseline = FLOAT32_MINPOS + else: + _baseline = baseline artists.append(axes.fill_between( - x, FLOAT32_MINPOS, y, facecolor=actualColor[0], linestyle='')) + x, _baseline, y, facecolor=actualColor[0], linestyle='')) else: # Curve curveList = axes.plot(x, y, - label=legend, linestyle=linestyle, color=color, linewidth=linewidth, @@ -415,40 +571,35 @@ class BackendMatplotlib(BackendBase.BackendBase): artists += list(curveList) if fill: + if baseline is None: + _baseline = FLOAT32_MINPOS + else: + _baseline = baseline artists.append( - axes.fill_between(x, FLOAT32_MINPOS, y, facecolor=color)) + axes.fill_between(x, _baseline, y, facecolor=color)) for artist in artists: - artist.set_zorder(z) if alpha < 1: artist.set_alpha(alpha) - return Container(artists) + return _PickableContainer(artists) - def addImage(self, data, legend, - origin, scale, z, - selectable, draggable, - colormap, alpha): + def addImage(self, data, origin, scale, z, 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): + for parameter in (data, origin, scale, z): 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) - # All image are shown as RGBA image image = Image(self.ax, - label="__IMAGE__" + legend, interpolation='nearest', - picker=picker, - zorder=z, + picker=True, origin='lower') if alpha < 1: @@ -481,15 +632,10 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax.add_artist(image) return image - def addTriangles(self, x, y, triangles, legend, - color, z, selectable, alpha): - for parameter in (x, y, triangles, legend, color, - z, selectable, alpha): + def addTriangles(self, x, y, triangles, color, z, alpha): + for parameter in (x, y, triangles, color, z, alpha): assert parameter is not None - # 0 enables picking on filled triangle - picker = 0 if selectable else None - color = numpy.array(color, copy=False) assert color.ndim == 2 and len(color) == len(x) @@ -498,16 +644,14 @@ class BackendMatplotlib(BackendBase.BackendBase): collection = TriMesh( Triangulation(x, y, triangles), - label=legend, alpha=alpha, - picker=picker, - zorder=z) + picker=0) # 0 enables picking on filled triangle collection.set_color(color) self.ax.add_collection(collection) return collection - def addItem(self, x, y, legend, shape, color, fill, overlay, z, + def addItem(self, x, y, shape, color, fill, overlay, z, linestyle, linewidth, linebgcolor): if (linebgcolor is not None and shape not in ('rectangle', 'polygon', 'polylines')): @@ -520,20 +664,20 @@ class BackendMatplotlib(BackendBase.BackendBase): linestyle = normalize_linestyle(linestyle) if shape == "line": - item = self.ax.plot(x, y, label=legend, color=color, + item = self.ax.plot(x, y, color=color, linestyle=linestyle, linewidth=linewidth, marker=None)[0] elif shape == "hline": if hasattr(y, "__len__"): y = y[-1] - item = self.ax.axhline(y, label=legend, color=color, + item = self.ax.axhline(y, color=color, linestyle=linestyle, linewidth=linewidth) elif shape == "vline": if hasattr(x, "__len__"): x = x[-1] - item = self.ax.axvline(x, label=legend, color=color, + item = self.ax.axvline(x, color=color, linestyle=linestyle, linewidth=linewidth) elif shape == 'rectangle': @@ -568,7 +712,6 @@ class BackendMatplotlib(BackendBase.BackendBase): item = Polygon(points, closed=closed, fill=False, - label=legend, color=color, linestyle=linestyle, linewidth=linewidth) @@ -584,30 +727,32 @@ class BackendMatplotlib(BackendBase.BackendBase): 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 - + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): textArtist = None xmin, xmax = self.getGraphXLimits() - ymin, ymax = self.getGraphYLimits(axis='left') + ymin, ymax = self.getGraphYLimits(axis=yaxis) + + if yaxis == 'left': + ax = self.ax + elif yaxis == 'right': + ax = self.ax2 + else: + assert(False) + marker = self._getMarkerFromSymbol(symbol) 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] + line = ax.plot(x, y, + linestyle=" ", + color=color, + marker=marker, + markersize=10.)[-1] if text is not None: if symbol is None: @@ -616,43 +761,40 @@ class BackendMatplotlib(BackendBase.BackendBase): valign = 'top' text = " " + text - textArtist = self.ax.text(x, y, text, - color=color, - horizontalalignment='left', - verticalalignment=valign) + textArtist = 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) + line = ax.axvline(x, + 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') + textArtist = 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) + line = ax.axhline(y, + 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') + textArtist = 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) + line.set_picker(5) # All markers are overlays line.set_animated(True) @@ -660,24 +802,25 @@ class BackendMatplotlib(BackendBase.BackendBase): textArtist.set_animated(True) artists = [line] if textArtist is None else [line, textArtist] - container = _MarkerContainer(artists, x, y) + container = _MarkerContainer(artists, x, y, yaxis) 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: + ymin1, ymax1 = self.ax.get_ybound() + ymin2, ymax2 = self.ax2.get_ybound() + for item in self._overlayItems(): if isinstance(item, _MarkerContainer): - item.updateMarkerText(xmin, xmax, ymin, ymax) + if item.yAxis == 'left': + item.updateMarkerText(xmin, xmax, ymin1, ymax1) + else: + item.updateMarkerText(xmin, xmax, ymin2, ymax2) # 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: @@ -699,7 +842,7 @@ class BackendMatplotlib(BackendBase.BackendBase): self._graphCursor = lineh, linev else: - if self._graphCursor is not None: + if self._graphCursor: lineh, linev = self._graphCursor lineh.remove() linev.remove() @@ -746,7 +889,37 @@ class BackendMatplotlib(BackendBase.BackendBase): if not self.ax2.lines: self._enableAxis('right', False) + def _drawOverlays(self): + """Draw overlays if any.""" + def condition(item): + return (item.isVisible() and + item._backendRenderer is not None and + item.isOverlay()) + + for item in self.getItemsFromBackToFront(condition=condition): + if (isinstance(item, items.YAxisMixIn) and + item.getYAxis() == 'right'): + axes = self.ax2 + else: + axes = self.ax + axes.draw_artist(item._backendRenderer) + + for item in self._graphCursor: + self.ax.draw_artist(item) + + def updateZOrder(self): + """Reorder all items with z order from 0 to 1""" + items = self.getItemsFromBackToFront( + lambda item: item.isVisible() and item._backendRenderer is not None) + count = len(items) + for index, item in enumerate(items): + zorder = 1. + index / count + if zorder != item._backendRenderer.get_zorder(): + item._backendRenderer.set_zorder(zorder) + def saveGraph(self, fileName, fileFormat, dpi): + self.updateZOrder() + # fileName can be also a StringIO or file instance if dpi is not None: self.fig.savefig(fileName, format=fileFormat, dpi=dpi) @@ -788,7 +961,9 @@ class BackendMatplotlib(BackendBase.BackendBase): def getGraphXLimits(self): if self._dirtyLimits and self.isKeepDataAspectRatio(): - self.replot() # makes sure we get the right limits + self.ax.apply_aspect() + self.ax2.apply_aspect() + self._dirtyLimits = False return self.ax.get_xbound() def setGraphXLimits(self, xmin, xmax): @@ -804,7 +979,9 @@ class BackendMatplotlib(BackendBase.BackendBase): return None if self._dirtyLimits and self.isKeepDataAspectRatio(): - self.replot() # makes sure we get the right limits + self.ax.apply_aspect() + self.ax2.apply_aspect() + self._dirtyLimits = False return ax.get_ybound() @@ -936,7 +1113,7 @@ class BackendMatplotlib(BackendBase.BackendBase): return xPixel, yPixel - def pixelToData(self, x, y, axis, check): + def pixelToData(self, x, y, axis): ax = self.ax2 if axis == "right" else self.ax # Convert from Qt origin (top) to matplotlib origin (bottom) @@ -944,14 +1121,6 @@ class BackendMatplotlib(BackendBase.BackendBase): 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): @@ -996,12 +1165,12 @@ class BackendMatplotlib(BackendBase.BackendBase): else: dataBackgroundColor = backgroundColor - if self.ax2.axison: + if self.ax.axison: self.fig.patch.set_facecolor(backgroundColor) if self._matplotlibVersion < _parse_version('2'): - self.ax2.set_axis_bgcolor(dataBackgroundColor) + self.ax.set_axis_bgcolor(dataBackgroundColor) else: - self.ax2.set_facecolor(dataBackgroundColor) + self.ax.set_facecolor(dataBackgroundColor) else: self.fig.patch.set_facecolor(dataBackgroundColor) @@ -1033,6 +1202,12 @@ class BackendMatplotlib(BackendBase.BackendBase): line.set_color(gridColor) # axes.grid().set_markeredgecolor(gridColor) + def setBackgroundColors(self, backgroundColor, dataBackgroundColor): + self._synchronizeBackgroundColors() + + def setForegroundColors(self, foregroundColor, gridColor): + self._synchronizeForegroundColors() + class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): """QWidget matplotlib backend using a QtAgg canvas. @@ -1079,9 +1254,11 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): _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]) + button = self._MPL_TO_PLOT_BUTTONS.get(event.button, None) + if button is not None: + self._plot.onMousePress( + event.x, self._mplQtYAxisCoordConversion(event.y), + button) def _onMouseMove(self, event): if self._graphCursor: @@ -1102,9 +1279,11 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): 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]) + button = self._MPL_TO_PLOT_BUTTONS.get(event.button, None) + if button is not None: + self._plot.onMouseRelease( + event.x, self._mplQtYAxisCoordConversion(event.y), + button) def _onMouseWheel(self, event): self._plot.onMouseWheel( @@ -1116,58 +1295,31 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): # 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:]}) + def pickItem(self, x, y, item): + mouseEvent = MouseEvent( + 'button_press_event', self, x, self._mplQtYAxisCoordConversion(y)) + mouseEvent.inaxes = item.axes + picked, info = item.contains(mouseEvent) - elif label.startswith('__IMAGE__'): - self._picked.append({'kind': 'image', 'legend': label[9:]}) + if not picked: + return None - elif isinstance(event.artist, TriMesh): + elif isinstance(item, TriMesh): # Convert selected triangle to data point indices - triangulation = event.artist._triangulation - indices = triangulation.get_masked_triangles()[event.ind[0]] + triangulation = item._triangulation + indices = triangulation.get_masked_triangles()[info['ind'][0]] # Sort picked triangle points by distance to mouse # from furthest to closest to put closest point last # This is to be somewhat consistent with last scatter point # being the top one. - dists = ((triangulation.x[indices] - event.mouseevent.xdata) ** 2 + - (triangulation.y[indices] - event.mouseevent.ydata) ** 2) - indices = indices[numpy.flip(numpy.argsort(dists))] - - self._picked.append({'kind': 'curve', 'legend': label, - 'indices': indices}) + xdata, ydata = self.pixelToData(x, y, axis='left') + dists = ((triangulation.x[indices] - xdata) ** 2 + + (triangulation.y[indices] - ydata) ** 2) + return indices[numpy.flip(numpy.argsort(dists), axis=0)] - 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 + else: # Returns indices if any + return info.get('ind', ()) # replot control @@ -1177,22 +1329,10 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): 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: + if self.isKeepDataAspectRatio() or self._hasOverlays(): # 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 @@ -1201,6 +1341,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): This is directly called by matplotlib for widget resize. """ + self.updateZOrder() + # 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"): @@ -1213,7 +1355,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): else: FigureCanvasQTAgg.draw(self) - if self._overlays or self._graphCursor: + if self._hasOverlays(): # Save background self._background = self.copy_from_bbox(self.fig.bbox) else: @@ -1257,7 +1399,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): 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: + if self._hasOverlays(): qt.QTimer.singleShot(0, self.draw) # Request async draw # cursor @@ -1276,9 +1418,3 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): else: cursor = self._QT_CURSORS[cursor] FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor)) - - def setBackgroundColors(self, backgroundColor, dataBackgroundColor): - self._synchronizeBackgroundColors() - - def setForegroundColors(self, foregroundColor, gridColor): - self._synchronizeForegroundColors() diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py index 0420aa9..27f3894 100644..100755 --- a/silx/gui/plot/backends/BackendOpenGL.py +++ b/silx/gui/plot/backends/BackendOpenGL.py @@ -30,13 +30,13 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "21/12/2018" -from collections import OrderedDict, namedtuple import logging import warnings import weakref import numpy +from .. import items from .._utils import FLOAT32_MINPOS from . import BackendBase from ... import colors @@ -59,186 +59,66 @@ _logger = logging.getLogger(__name__) # 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', 'triangles' - - 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' - elif isinstance(primitive, GLPlotTriangles): - primitiveType = 'triangles' - else: - raise RuntimeError('Unsupported object type: %s', primitive) - - key = primitiveType, primitive.info['legend'] - self._primitives[key] = primitive +class _ShapeItem(dict): + def __init__(self, x, y, shape, color, fill, overlay, z, + linestyle, linewidth, linebgcolor): + super(_ShapeItem, self).__init__() - def get(self, primitiveType, legend): - """Get the corresponding primitive of given type with given legend. + if shape not in ('polygon', 'rectangle', 'line', + 'vline', 'hline', 'polylines'): + raise NotImplementedError("Unsupported shape {0}".format(shape)) - :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)) + x = numpy.array(x, copy=False) + y = numpy.array(y, copy=False) - def pop(self, primitiveType, key): - """Pop the corresponding curve or return None if no such curve. + if shape == 'rectangle': + xMin, xMax = x + x = numpy.array((xMin, xMin, xMax, xMax)) + yMin, yMax = y + y = numpy.array((yMin, yMax, yMax, yMin)) - :param str primitiveType: - :param str key: - :return: - """ - assert primitiveType in self._PRIMITIVE_TYPES - return self._primitives.pop((primitiveType, key), None) + # Ignore fill for polylines to mimic matplotlib + fill = fill if shape != 'polylines' else False - def zOrderedPrimitives(self, reverse=False): - """List of primitives sorted according to their z order. + self.update({ + 'shape': shape, + 'color': colors.rgba(color), + 'fill': 'hatch' if fill else None, + 'x': x, + 'y': y, + 'linestyle': linestyle, + 'linewidth': linewidth, + 'linebgcolor': linebgcolor, + }) - 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 +class _MarkerItem(dict): + def __init__(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + super(_MarkerItem, self).__init__() - 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 + if symbol is None: + symbol = '+' - # 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. + # Apply constraint to provided position + isConstraint = (constraint is not None and + x is not None and y is not None) + if isConstraint: + x, y = constraint(x, y) - return Bounds(xMin, xMax, yMin, yMax, y2Min, y2Max) + self.update({ + 'x': x, + 'y': y, + 'text': text, + 'color': colors.rgba(color), + 'constraint': constraint if isConstraint else None, + 'symbol': symbol, + 'linestyle': linestyle, + 'linewidth': linewidth, + 'yaxis': yaxis, + }) # shaders ##################################################################### @@ -350,9 +230,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): self._crosshairCursor = None self._mousePosInPixels = None - self._markers = OrderedDict() - self._items = OrderedDict() - self._plotContent = PlotDataContent() # For images and curves self._glGarbageCollector = [] self._plotFrame = GLPlotFrame2D( @@ -457,7 +334,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): def _paintDirectGL(self): self._renderPlotAreaGL() self._plotFrame.render() - self._renderMarkersGL() self._renderOverlayGL() def _paintFBOGL(self): @@ -522,7 +398,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): with plotFBOTex.texture: gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._plotVertices[0])) - self._renderMarkersGL() self._renderOverlayGL() def paintGL(self): @@ -543,120 +418,203 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # self._paintDirectGL() self._paintFBOGL() - def _renderMarkersGL(self): - if len(self._markers) == 0: - return + def _renderItems(self, overlay=False): + """Render items according to :class:`PlotWidget` order - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + Note: Scissor test should already be set. - # 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]) + :param bool overlay: + False (the default) to render item that are not overlays. + True to render items that are overlays. + """ + # Values that are often used + plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + isXLog = self._plotFrame.xAxis.isLog + isYLog = self._plotFrame.yAxis.isLog + # Used by marker rendering 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 + for plotItem in self.getItemsFromBackToFront( + condition=lambda i: i.isVisible() and i.isOverlay() == overlay): + if plotItem._backendRenderer is None: continue - if xCoord is None or yCoord is None: - 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) + item = plotItem._backendRenderer + + if isinstance(item, (GLPlotCurve2D, + GLPlotColormap, + GLPlotRGBAImage, + GLPlotTriangles)): # Render data items + gl.glViewport(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + if isinstance(item, GLPlotCurve2D) and item.info.get('yAxis') == 'right': + item.render(self._plotFrame.transformedDataY2ProjMat, + isXLog, isYLog) + else: + item.render(self._plotFrame.transformedDataProjMat, + isXLog, isYLog) + + elif isinstance(item, _ShapeItem): # Render shape items + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + 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 + + if item['shape'] == 'hline': width = self._plotFrame.size[0] - lines = GLLines2D((0, width), (pixelPos[1], pixelPos[1]), - style=marker['linestyle'], - color=marker['color'], - width=marker['linewidth']) + _, yPixel = self._plot.dataToPixel( + None, item['y'], axis='left', check=False) + points = numpy.array(((0., yPixel), (width, yPixel)), + dtype=numpy.float32) + + elif item['shape'] == 'vline': + xPixel, _ = self._plot.dataToPixel( + item['x'], None, axis='left', check=False) + height = self._plotFrame.size[1] + points = numpy.array(((xPixel, 0), (xPixel, height)), + dtype=numpy.float32) + + else: + points = numpy.array([ + self._plot.dataToPixel(x, y, axis='left', check=False) + for (x, y) in zip(item['x'], item['y'])]) + + # Draw the fill + if (item['fill'] is not None and + item['shape'] not in ('hline', 'vline')): + 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.) + + shape2D = FilledShape2D( + points, style=item['fill'], color=item['color']) + shape2D.render( + posAttrib=self._progBase.attributes['position'], + colorUnif=self._progBase.uniforms['color'], + hatchStepUnif=self._progBase.uniforms['hatchStep']) + + # Draw the stroke + if item['linestyle'] not in ('', ' ', None): + if item['shape'] != 'polylines': + # close the polyline + points = numpy.append(points, + numpy.atleast_2d(points[0]), axis=0) + + lines = GLLines2D(points[:, 0], points[:, 1], + style=item['linestyle'], + color=item['color'], + dash2ndColor=item['linebgcolor'], + width=item['linewidth']) lines.render(self.matScreenProj) - else: # yCoord is None: vertical line in data space - if marker['text'] is not None: + elif isinstance(item, _MarkerItem): + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + xCoord, yCoord, yAxis = item['x'], item['y'], item['yaxis'] + + if ((isXLog and xCoord is not None and xCoord <= 0) or + (isYLog 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: + pixelPos = self._plot.dataToPixel( + xCoord, yCoord, axis=yAxis, check=False) + + if xCoord is None: # Horizontal line in data space + if item['text'] is not None: + x = self._plotFrame.size[0] - \ + self._plotFrame.margins.right - pixelOffset + y = pixelPos[1] - pixelOffset + label = Text2D(item['text'], x, y, + color=item['color'], + bgColor=(1., 1., 1., 0.5), + align=RIGHT, valign=BOTTOM) + labels.append(label) + + width = self._plotFrame.size[0] + lines = GLLines2D((0, width), (pixelPos[1], pixelPos[1]), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + lines.render(self.matScreenProj) + + else: # yCoord is None: vertical line in data space + if item['text'] is not None: + x = pixelPos[0] + pixelOffset + y = self._plotFrame.margins.top + pixelOffset + label = Text2D(item['text'], x, y, + color=item['color'], + bgColor=(1., 1., 1., 0.5), + align=LEFT, valign=TOP) + labels.append(label) + + height = self._plotFrame.size[1] + lines = GLLines2D((pixelPos[0], pixelPos[0]), (0, height), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + lines.render(self.matScreenProj) + + else: + pixelPos = self._plot.dataToPixel( + xCoord, yCoord, axis=yAxis, check=True) + if pixelPos is None: + # Do not render markers outside visible plot area + continue + + if item['text'] is not None: x = pixelPos[0] + pixelOffset - y = self._plotFrame.margins.top + pixelOffset - label = Text2D(marker['text'], x, y, - color=marker['color'], + y = pixelPos[1] + pixelOffset + label = Text2D(item['text'], x, y, + color=item['color'], bgColor=(1., 1., 1., 0.5), align=LEFT, valign=TOP) labels.append(label) - height = self._plotFrame.size[1] - lines = GLLines2D((pixelPos[0], pixelPos[0]), (0, height), - style=marker['linestyle'], - color=marker['color'], - width=marker['linewidth']) - lines.render(self.matScreenProj) + # 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=item['symbol'], + markerColor=item['color'], + markerSize=11) + markerCurve.render(self.matScreenProj, False, False) + markerCurve.discard() 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]) + _logger.error('Unsupported item: %s', str(item)) + continue # Render marker labels + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) 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:] + """Render overlay layer: overlay items and crosshair.""" + 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) - # Scissor to plot area - gl.glScissor(self._plotFrame.margins.left, - self._plotFrame.margins.bottom, - plotWidth, plotHeight) - gl.glEnable(gl.GL_SCISSOR_TEST) + self._renderItems(overlay=True) + # Render crosshair cursor + if self._crosshairCursor is not None and self._mousePosInPixels is not None: self._progBase.use() gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) @@ -665,39 +623,39 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): 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) + 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): + """Render base layer of plot area. + + It renders the background, grid and items except overlays + """ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] gl.glScissor(self._plotFrame.margins.left, @@ -713,85 +671,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # 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]) - - 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 - - if item['shape'] == 'hline': - width = self._plotFrame.size[0] - _, yPixel = self.dataToPixel( - None, item['y'], axis='left', check=False) - points = numpy.array(((0., yPixel), (width, yPixel)), - dtype=numpy.float32) - - elif item['shape'] == 'vline': - xPixel, _ = self.dataToPixel( - item['x'], None, axis='left', check=False) - height = self._plotFrame.size[1] - points = numpy.array(((xPixel, 0), (xPixel, height)), - dtype=numpy.float32) - - else: - points = numpy.array([ - self.dataToPixel(x, y, axis='left', check=False) - for (x, y) in zip(item['x'], item['y'])]) - - # Draw the fill - if (item['fill'] is not None and - item['shape'] not in ('hline', 'vline')): - 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.) - - shape2D = FilledShape2D( - points, style=item['fill'], color=item['color']) - shape2D.render( - posAttrib=self._progBase.attributes['position'], - colorUnif=self._progBase.uniforms['color'], - hatchStepUnif=self._progBase.uniforms['hatchStep']) - - # Draw the stroke - if item['linestyle'] not in ('', ' ', None): - if item['shape'] != 'polylines': - # close the polyline - points = numpy.append(points, - numpy.atleast_2d(points[0]), axis=0) - - lines = GLLines2D(points[:, 0], points[:, 1], - style=item['linestyle'], - color=item['color'], - dash2ndColor=item['linebgcolor'], - width=item['linewidth']) - lines.render(self.matScreenProj) + if trBounds.x[0] != trBounds.x[1] and trBounds.y[0] != trBounds.y[1]: + # Do rendering of items + self._renderItems(overlay=False) gl.glDisable(gl.GL_SCISSOR_TEST) @@ -841,13 +723,13 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): else: raise ValueError('Unsupported data type') - def addCurve(self, x, y, legend, + def addCurve(self, x, y, 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): + xerror, yerror, z, + fill, alpha, symbolsize, baseline): + for parameter in (x, y, color, symbol, linewidth, linestyle, + yaxis, z, fill, symbolsize): assert parameter is not None assert yaxis in ('left', 'right') @@ -939,10 +821,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): if color is not None: color = color[0], color[1], color[2], color[3] * alpha - behaviors = set() - if selectable: - behaviors.add('selectable') - + fillColor = None + if fill is True: + fillColor = color curve = GLPlotCurve2D(x, y, colorArray, xError=xerror, yError=yerror, @@ -952,36 +833,24 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): marker=symbol, markerColor=color, markerSize=symbolsize, - fillColor=color if fill else None, + fillColor=fillColor, + baseline=baseline, 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 curve - return legend, 'curve' - - def addImage(self, data, legend, + def addImage(self, data, origin, scale, z, - selectable, draggable, colormap, alpha): - for parameter in (data, legend, origin, scale, z, - selectable, draggable): + for parameter in (data, origin, scale, z): 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): @@ -1002,12 +871,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): colormapIsLog, cmapRange, alpha) - image.info = { - 'legend': legend, - 'zOrder': z, - 'behaviors': behaviors - } - self._plotContent.add(image) elif len(data.shape) == 3: # For RGB, RGBA data @@ -1022,29 +885,21 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): 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' + # TODO is this needed? + 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') - def addTriangles(self, x, y, triangles, legend, - color, z, selectable, alpha): + return image + def addTriangles(self, x, y, triangles, + color, z, alpha): # Handle axes log scale: convert data if self._plotFrame.xAxis.isLog: x = numpy.log10(x) @@ -1052,31 +907,14 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): y = numpy.log10(y) triangles = GLPlotTriangles(x, y, color, triangles, alpha) - triangles.info = { - 'legend': legend, - 'zOrder': z, - 'behaviors': set(['selectable']) if selectable else set(), - } - self._plotContent.add(triangles) - return legend, 'triangles' + return triangles - def addItem(self, x, y, legend, shape, color, fill, overlay, z, + def addItem(self, x, y, shape, color, fill, overlay, z, linestyle, linewidth, linebgcolor): - # 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( @@ -1085,84 +923,35 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): 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, - 'linestyle': linestyle, - 'linewidth': linewidth, - 'linebgcolor': linebgcolor, - } - - return legend, 'item' - - def addMarker(self, x, y, legend, text, color, - selectable, draggable, - symbol, linestyle, linewidth, constraint): - - if symbol is None: - symbol = '+' - - behaviors = set() - if selectable: - behaviors.add('selectable') - if draggable: - behaviors.add('draggable') - - # Apply constraint to provided position - isConstraint = (draggable and constraint is not None and - x is not None and y is not None) - if isConstraint: - x, y = constraint(x, y) - - 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, - 'linestyle': linestyle, - 'linewidth': linewidth, - } + return _ShapeItem(x, y, shape, color, fill, overlay, z, + linestyle, linewidth, linebgcolor) - return legend, 'marker' + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + return _MarkerItem(x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis) # Remove methods def remove(self, item): - legend, kind = item - - if kind == 'curve': - curve = self._plotContent.pop('curve', legend) - if curve is not None: + if isinstance(item, (GLPlotCurve2D, + GLPlotColormap, + GLPlotRGBAImage, + GLPlotTriangles)): + if isinstance(item, GLPlotCurve2D): # 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') + y2AxisItems = (item for item in self._plot.getItems() + if isinstance(item, items.YAxisMixIn) and + item.getYAxis() == 'right') self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None - self._glGarbageCollector.append(curve) - - elif kind in ('image', 'triangles'): - item = self._plotContent.pop(kind, legend) - if item is not None: - self._glGarbageCollector.append(item) + self._glGarbageCollector.append(item) - elif kind == 'marker': - self._markers.pop(legend, False) - - elif kind == 'item': - self._items.pop(legend, False) + elif isinstance(item, (_MarkerItem, _ShapeItem)): + pass # No-op else: - _logger.error('Unsupported kind: %s', str(kind)) + _logger.error('Unsupported item: %s', str(item)) # Interaction methods @@ -1212,8 +1001,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): :param GLPlotCurve2D item: :param float x: X position of the mouse in widget coordinates :param float y: Y position of the mouse in widget coordinates - :return: List of indices of picked points - :rtype: List[int] + :return: List of indices of picked points or None if not picked + :rtype: Union[List[int],None] """ offset = self._PICK_OFFSET if item.marker is not None: @@ -1224,17 +1013,17 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): yAxis = item.info['yAxis'] inAreaPos = self._mouseInPlotArea(x - offset, y - offset) - dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], - axis=yAxis, check=True) + dataPos = self._plot.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) if dataPos is None: - return [] + return None xPick0, yPick0 = dataPos inAreaPos = self._mouseInPlotArea(x + offset, y + offset) - dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], - axis=yAxis, check=True) + dataPos = self._plot.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) if dataPos is None: - return [] + return None xPick1, yPick1 = dataPos if xPick0 < xPick1: @@ -1260,69 +1049,58 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): return item.pick(xPickMin, yPickMin, xPickMax, yPickMax) - 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 + def pickItem(self, x, y, item): + dataPos = self._plot.pixelToData(x, y, axis='left', check=True) + if dataPos is None: + return None # Outside plot area + + if item is None: + _logger.error("No item provided for picking") + return None + + # Pick markers + if isinstance(item, _MarkerItem): + yaxis = item['yaxis'] + pixelPos = self._plot.dataToPixel( + item['x'], item['y'], axis=yaxis, check=False) + if pixelPos is None: + return None # negative coord on a log axis + + if item['x'] is None: # Horizontal line + pt1 = self._plot.pixelToData( + x, y - self._PICK_OFFSET, axis=yaxis, check=False) + pt2 = self._plot.pixelToData( + x, y + self._PICK_OFFSET, axis=yaxis, check=False) + isPicked = (min(pt1[1], pt2[1]) <= item['y'] <= + max(pt1[1], pt2[1])) + + elif item['y'] is None: # Vertical line + pt1 = self._plot.pixelToData( + x - self._PICK_OFFSET, y, axis=yaxis, check=False) + pt2 = self._plot.pixelToData( + x + self._PICK_OFFSET, y, axis=yaxis, check=False) + isPicked = (min(pt1[0], pt2[0]) <= item['x'] <= + max(pt1[0], pt2[0])) - 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: - if isinstance(item, GLPlotCurve2D): - pickedIndices = self.__pickCurves(item, x, y) - if pickedIndices: - picked.append(dict(kind='curve', - legend=item.info['legend'], - indices=pickedIndices)) - - elif isinstance(item, GLPlotTriangles): - pickedIndices = item.pick(*dataPos) - if pickedIndices: - picked.append(dict(kind='curve', - legend=item.info['legend'], - indices=pickedIndices)) - return picked + else: + isPicked = ( + numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and + numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET) + + return (0,) if isPicked else None + + # Pick image, curve, triangles + elif isinstance(item, (GLPlotCurve2D, + GLPlotColormap, + GLPlotRGBAImage, + GLPlotTriangles)): + if isinstance(item, (GLPlotColormap, GLPlotRGBAImage, GLPlotTriangles)): + return item.pick(*dataPos) # Might be None + + elif isinstance(item, GLPlotCurve2D): + return self.__pickCurves(item, x, y) + else: + return None # Update curve @@ -1426,12 +1204,11 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): 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_) - + ranges = self._plot.getDataRange() + if (ranges.y is not None and + ranges.x is not None and + (ranges.y[1] - ranges.y[0]) != 0.): + dataRatio = (ranges.x[1] - ranges.x[0]) / float(ranges.y[1] - ranges.y[0]) plotRatio = plotWidth / float(plotHeight) # Test != 0 before keepDim = 'x' if dataRatio > plotRatio else 'y' @@ -1564,51 +1341,10 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # 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 + def dataToPixel(self, x, y, axis): + return self._plotFrame.dataToPixel(x, y, axis) + def pixelToData(self, x, y, axis): return self._plotFrame.pixelToData(x, y, axis) def getPlotBoundsInPixels(self): diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py index 5f8d652..3a0ebac 100644 --- a/silx/gui/plot/backends/glutils/GLPlotCurve.py +++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -132,8 +132,7 @@ class _Fill2D(object): 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)) + 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]: @@ -147,22 +146,25 @@ class _Fill2D(object): # 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) + nbPoints = numpy.sum(numpy.diff(slices, axis=1)) * 2 + 4 * len(slices) points = numpy.empty((nbPoints, 2), dtype=numpy.float32) offset = 0 + # invert baseline for filling + new_y_data = numpy.append(self.yData, self.baseline) for start, end in slices: # Duplicate first point for connecting degenerated triangle - points[offset:offset+2] = self.xData[start], self.baseline + points[offset:offset+2] = self.xData[start], new_y_data[start] # 2nd point of the polygon is last point - points[offset+2] = self.xData[end-1], self.baseline + points[offset+2] = self.xData[start], self.baseline[start] - # Add all points from the data - indices = start + buildFillMaskIndices(end - start) + indices = numpy.append(numpy.arange(start, end), + numpy.arange(len(self.xData) + end-1, len(self.xData) + start-1, -1)) + indices = indices[buildFillMaskIndices(len(indices))] - points[offset+3:offset+3+len(indices), 0] = self.xData[indices] - points[offset+3:offset+3+len(indices), 1] = self.yData[indices] + points[offset+3:offset+3+len(indices), 0] = self.xData[indices % len(self.xData)] + points[offset+3:offset+3+len(indices), 1] = new_y_data[indices] # Duplicate last point for connecting degenerated triangle points[offset+3+len(indices)] = points[offset+3+len(indices)-1] @@ -526,7 +528,16 @@ def distancesFromArrays(xData, yData): DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK = \ 'd', 'o', 's', '+', 'x', '.', ',', '*' -H_LINE, V_LINE = '_', '|' +H_LINE, V_LINE, HEART = '_', '|', u'\u2665' + +TICK_LEFT = "tickleft" +TICK_RIGHT = "tickright" +TICK_UP = "tickup" +TICK_DOWN = "tickdown" +CARET_LEFT = "caretleft" +CARET_RIGHT = "caretright" +CARET_UP = "caretup" +CARET_DOWN = "caretdown" class _Points2D(object): @@ -542,7 +553,8 @@ class _Points2D(object): """ MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK, - H_LINE, V_LINE) + H_LINE, V_LINE, HEART, TICK_LEFT, TICK_RIGHT, TICK_UP, TICK_DOWN, + CARET_LEFT, CARET_RIGHT, CARET_UP, CARET_DOWN) """List of supported markers""" _VERTEX_SHADER = """ @@ -640,7 +652,110 @@ class _Points2D(object): return 0.0; } } - """ + """, + HEART: """ + float alphaSymbol(vec2 coord, float size) { + coord = (coord - 0.5) * 2.; + coord *= 0.75; + coord.y += 0.25; + float a = atan(coord.x,-coord.y)/3.141593; + float r = length(coord); + float h = abs(a); + float d = (13.0*h - 22.0*h*h + 10.0*h*h*h)/(6.0-5.0*h); + float res = clamp(r-d, 0., 1.); + // antialiasing + res = smoothstep(0.1, 0.001, res); + return res; + } + """, + TICK_LEFT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dy = abs(coord.y); + if (dy < 0.5 && coord.x < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_RIGHT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dy = abs(coord.y); + if (dy < 0.5 && coord.x > -0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_UP: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (dx < 0.5 && coord.y < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_DOWN: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (dx < 0.5 && coord.y > -0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + CARET_LEFT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.x) - abs(coord.y); + if (d >= -0.1 && coord.x > 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_RIGHT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.x) - abs(coord.y); + if (d >= -0.1 && coord.x < 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_UP: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.y) - abs(coord.x); + if (d >= -0.1 && coord.y > 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_DOWN: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.y) - abs(coord.x); + if (d >= -0.1 && coord.y < 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, } _FRAGMENT_SHADER_TEMPLATE = """ @@ -964,6 +1079,7 @@ class GLPlotCurve2D(object): markerColor=(0., 0., 0., 1.), markerSize=7, fillColor=None, + baseline=None, isYLog=False): self.colorData = colorData @@ -1003,11 +1119,28 @@ class GLPlotCurve2D(object): self.offset = 0., 0. self.xData = xData self.yData = yData - if fillColor is not None: + def deduce_baseline(baseline): + if baseline is None: + _baseline = 0 + else: + _baseline = baseline + if not isinstance(_baseline, numpy.ndarray): + _baseline = numpy.repeat(_baseline, + len(self.xData)) + if isYLog is True: + with warnings.catch_warnings(): # Ignore NaN comparison warnings + warnings.simplefilter('ignore', + category=RuntimeWarning) + log_val = numpy.log10(_baseline) + _baseline = numpy.where(_baseline>0.0, log_val, -38) + return _baseline + + _baseline = deduce_baseline(baseline) + # Use different baseline depending of Y log scale self.fill = _Fill2D(self.xData, self.yData, - baseline=-38 if isYLog else 0, + baseline=_baseline, color=fillColor, offset=self.offset) else: @@ -1129,7 +1262,7 @@ class GLPlotCurve2D(object): and the segment [i-1, i] is not tested for picking. :return: The indices of the picked data - :rtype: list of int + :rtype: Union[List[int],None] """ if (self.marker is None and self.lineStyle is None) or \ self.xMin > xPickMax or xPickMin > self.xMax or \ @@ -1209,4 +1342,4 @@ class GLPlotCurve2D(object): (self.yData >= yPickMin) & (self.yData <= yPickMax))[0].tolist() - return indices + return tuple(indices) if len(indices) > 0 else None diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py index 6f3c487..5d79023 100644 --- a/silx/gui/plot/backends/glutils/GLPlotImage.py +++ b/silx/gui/plot/backends/glutils/GLPlotImage.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility +# Copyright (c) 2014-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -56,7 +56,7 @@ class _GLPlotData2D(object): sx, sy = self.scale col = int((x - ox) / sx) row = int((y - oy) / sy) - return col, row + return ((row, col),) else: return None diff --git a/silx/gui/plot/backends/glutils/GLPlotTriangles.py b/silx/gui/plot/backends/glutils/GLPlotTriangles.py index c756749..7aeb5ab 100644 --- a/silx/gui/plot/backends/glutils/GLPlotTriangles.py +++ b/silx/gui/plot/backends/glutils/GLPlotTriangles.py @@ -114,11 +114,12 @@ class GLPlotTriangles(object): :param float x: X coordinates in plot data frame :param float y: Y coordinates in plot data frame :return: List of picked data point indices - :rtype: numpy.ndarray + :rtype: Union[List[int],None] """ if (x < self.xMin or x > self.xMax or y < self.yMin or y > self.yMax): - return () + return None + xPts, yPts = self.__x_y_color[:2] if self.__picking_triangles is None: self.__picking_triangles = numpy.zeros( @@ -135,9 +136,9 @@ class GLPlotTriangles(object): # Sorted from furthest to closest point dists = (xPts[indices] - x) ** 2 + (yPts[indices] - y) ** 2 - indices = indices[numpy.flip(numpy.argsort(dists))] + indices = indices[numpy.flip(numpy.argsort(dists), axis=0)] - return tuple(indices) + return tuple(indices) if len(indices) > 0 else None def discard(self): """Release resources on the GPU""" diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py index f3a36db..7eff1d0 100644 --- a/silx/gui/plot/items/__init__.py +++ b/silx/gui/plot/items/__init__.py @@ -40,11 +40,11 @@ from .complex import ImageComplexData # noqa from .curve import Curve, CurveStyle # noqa from .histogram import Histogram # noqa from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa -from .shape import Shape # noqa +from .shape import Shape, BoundingRect # noqa from .scatter import Scatter # noqa from .marker import MarkerBase, Marker, XMarker, YMarker # noqa from .axis import Axis, XAxis, YAxis, YRightAxis -DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter +DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter, BoundingRect """Classes of items representing data and to consider to compute data bounds. """ diff --git a/silx/gui/plot/items/_pick.py b/silx/gui/plot/items/_pick.py new file mode 100644 index 0000000..14078fd --- /dev/null +++ b/silx/gui/plot/items/_pick.py @@ -0,0 +1,70 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides classes supporting item picking.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/06/2019" + +import numpy + + +class PickingResult(object): + """Class to access picking information in a :class:`PlotWidget`""" + + def __init__(self, item, indices=None): + """Init + + :param item: The picked item + :param numpy.ndarray indices: Array-like of indices of picked data. + Either 1D or 2D with dim0: data dimension and dim1: indices. + No copy is made. + """ + self._item = item + + if indices is None or len(indices) == 0: + self._indices = None + else: + self._indices = numpy.array(indices, copy=False, dtype=numpy.int) + + def getItem(self): + """Returns the item this results corresponds to.""" + return self._item + + def getIndices(self, copy=True): + """Returns indices of picked data. + + If data is 1D, it returns a numpy.ndarray, otherwise + it returns a tuple with as many numpy.ndarray as there are + dimensions in the data. + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray,List[numpy.ndarray]] + """ + if self._indices is None: + return None + indices = numpy.array(self._indices, copy=copy) + return indices if indices.ndim == 1 else tuple(indices) diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py index 3869a05..988022a 100644 --- a/silx/gui/plot/items/complex.py +++ b/silx/gui/plot/items/complex.py @@ -113,6 +113,16 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): colored phase + amplitude. """ + _SUPPORTED_COMPLEX_MODES = ( + ComplexMixIn.ComplexMode.ABSOLUTE, + ComplexMixIn.ComplexMode.PHASE, + ComplexMixIn.ComplexMode.REAL, + ComplexMixIn.ComplexMode.IMAGINARY, + ComplexMixIn.ComplexMode.AMPLITUDE_PHASE, + ComplexMixIn.ComplexMode.LOG10_AMPLITUDE_PHASE, + ComplexMixIn.ComplexMode.SQUARE_AMPLITUDE) + """Overrides supported ComplexMode""" + def __init__(self): ImageBase.__init__(self) ColormapMixIn.__init__(self) @@ -161,12 +171,9 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): 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()) diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index e7342b0..6d6575b 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -47,6 +47,7 @@ from ....utils.enum import Enum as _Enum from ... import qt from ... import colors from ...colors import Colormap +from ._pick import PickingResult from silx import config @@ -136,6 +137,12 @@ class ItemChangedType(enum.Enum): COMPLEX_MODE = 'complexModeChanged' """Item's complex data visualization mode changed flag.""" + NAME = 'nameChanged' + """Item's name changed flag.""" + + EDITABLE = 'editableChanged' + """Item's editable state changed flags.""" + class Item(qt.QObject): """Description of an item of the plot""" @@ -330,6 +337,26 @@ class Item(qt.QObject): backend.remove(self._backendRenderer) self._backendRenderer = None + def pick(self, x, y): + """Run picking test on this item + + :param float x: The x pixel coord where to pick. + :param float y: The y pixel coord where to pick. + :return: None if not picked, else the picked position information + :rtype: Union[None,PickingResult] + """ + if not self.isVisible() or self._backendRenderer is None: + return None + plot = self.getPlot() + if plot is None: + return None + + indices = plot._backend.pickItem(x, y, self._backendRenderer) + if indices is None: + return None + else: + return PickingResult(self, indices if len(indices) != 0 else None) + # Mix-in classes ############################################################## @@ -471,6 +498,17 @@ class SymbolMixIn(ItemMixInBase): ('x', 'Cross'), ('.', 'Point'), (',', 'Pixel'), + ('|', 'Vertical line'), + ('_', 'Horizontal line'), + ('tickleft', 'Tick left'), + ('tickright', 'Tick right'), + ('tickup', 'Tick up'), + ('tickdown', 'Tick down'), + ('caretleft', 'Caret left'), + ('caretright', 'Caret right'), + ('caretup', 'Caret up'), + ('caretdown', 'Caret down'), + (u'\u2665', 'Heart'), ('', 'None'))) """Dict of supported symbols""" @@ -781,6 +819,7 @@ class ComplexMixIn(ItemMixInBase): class ComplexMode(_Enum): """Identify available display mode for complex""" + NONE = 'none' ABSOLUTE = 'amplitude' PHASE = 'phase' REAL = 'real' @@ -884,8 +923,54 @@ class ScatterVisualizationMixIn(ItemMixInBase): This is based on Delaunay triangulation """ + REGULAR_GRID = 'regular_grid' + """Display scatter plot as an image. + + It expects the points to be the intersection of a regular grid, + and the order of points following that of an image. + First line, then second one, and always in the same direction + (either all lines from left to right or all from right to left). + """ + + IRREGULAR_GRID = 'irregular_grid' + """Display scatter plot as contiguous quadrilaterals. + + It expects the points to be the intersection of an irregular grid, + and the order of points following that of an image. + First line, then second one, and always in the same direction + (either all lines from left to right or all from right to left). + """ + + @enum.unique + class VisualizationParameter(_Enum): + """Different parameter names for scatter plot visualizations""" + + GRID_MAJOR_ORDER = 'grid_major_order' + """The major order of points in the regular grid. + + Either 'row' (row-major, fast X) or 'column' (column-major, fast Y). + """ + + GRID_BOUNDS = 'grid_bounds' + """The expected range in data coordinates of the regular grid. + + A 2-tuple of 2-tuple: (begin (x, y), end (x, y)). + This provides the data coordinates of the first point and the expected + last on. + As for `GRID_SHAPE`, this can be wider than the current data. + """ + + GRID_SHAPE = 'grid_shape' + """The expected size of the regular grid (height, width). + + The given shape can be wider than the number of points, + in which case the grid is not fully filled. + """ + def __init__(self): self.__visualization = self.Visualization.POINTS + self.__parameters = dict( # Init parameters to None + (parameter, None) for parameter in self.VisualizationParameter) @classmethod def supportedVisualizations(cls): @@ -929,6 +1014,54 @@ class ScatterVisualizationMixIn(ItemMixInBase): """ return self.__visualization + def setVisualizationParameter(self, parameter, value=None): + """Set the given visualization parameter. + + :param Union[str,VisualizationParameter] parameter: + The name of the parameter to set + :param value: The value to use for this parameter + Set to None to automatically set the parameter + :raises ValueError: If parameter is not supported + :return: True if parameter was set, False if is was already set + :rtype: bool + """ + parameter = self.VisualizationParameter.from_value(parameter) + + if self.__parameters[parameter] != value: + self.__parameters[parameter] = value + self._updated(ItemChangedType.VISUALIZATION_MODE) + return True + return False + + def getVisualizationParameter(self, parameter): + """Returns the value of the given visualization parameter. + + This method returns the parameter as set by + :meth:`setVisualizationParameter`. + + :param parameter: The name of the parameter to retrieve + :returns: The value previously set or None if automatically set + :raises ValueError: If parameter is not supported + """ + if parameter not in self.VisualizationParameter: + raise ValueError("parameter not supported: %s", parameter) + + return self.__parameters[parameter] + + def getCurrentVisualizationParameter(self, parameter): + """Returns the current value of the given visualization parameter. + + If the parameter was set by :meth:`setVisualizationParameter` to + a value that is not None, this value is returned; + else the current value that is automatically computed is returned. + + :param parameter: The name of the parameter to retrieve + :returns: The current value (either set or automatically computed) + :raises ValueError: If parameter is not supported + """ + # Override in subclass to provide automatically computed parameters + return self.getVisualizationParameter(parameter) + class PointsBase(Item, SymbolMixIn, AlphaMixIn): """Base class for :class:`Curve` and :class:`Scatter`""" @@ -1224,3 +1357,32 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn): if plot is not None: plot._invalidateDataRange() self._updated(ItemChangedType.DATA) + + +class BaselineMixIn(object): + """Base class for Baseline mix-in""" + def __init__(self, baseline=None): + self._baseline = baseline + + def _setBaseline(self, baseline): + """ + Set baseline value + + :param baseline: baseline value(s) + :type: Union[None,float,numpy.ndarray] + """ + if (isinstance(baseline, abc.Iterable)): + baseline = numpy.array(baseline) + self._baseline = baseline + + def getBaseline(self, copy=True): + """ + + :param bool copy: + :return: histogram baseline + :rtype: Union[None,float,numpy.ndarray] + """ + if isinstance(self._baseline, numpy.ndarray): + return numpy.array(self._baseline, copy=True) + else: + return self._baseline diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py index 439af33..5853ef5 100644 --- a/silx/gui/plot/items/curve.py +++ b/silx/gui/plot/items/curve.py @@ -38,7 +38,8 @@ import six from ....utils.deprecation import deprecated from ... import colors from .core import (PointsBase, LabelsMixIn, ColorMixIn, YAxisMixIn, - FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType) + FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType, + BaselineMixIn) _logger = logging.getLogger(__name__) @@ -151,7 +152,8 @@ class CurveStyle(object): return False -class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): +class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, + LineMixIn, BaselineMixIn): """Description of a curve""" _DEFAULT_Z_LAYER = 1 @@ -169,6 +171,8 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI _DEFAULT_HIGHLIGHT_STYLE = CurveStyle(color='black') """Default highlight style of the item""" + _DEFAULT_BASELINE = None + def __init__(self): PointsBase.__init__(self) ColorMixIn.__init__(self) @@ -176,9 +180,11 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI FillMixIn.__init__(self) LabelsMixIn.__init__(self) LineMixIn.__init__(self) + BaselineMixIn.__init__(self) self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE self._highlighted = False + self._setBaseline(Curve._DEFAULT_BASELINE) self.sigItemChanged.connect(self.__itemChanged) @@ -200,7 +206,7 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI style = self.getCurrentStyle() - return backend.addCurve(xFiltered, yFiltered, self.getLegend(), + return backend.addCurve(xFiltered, yFiltered, color=style.getColor(), symbol=style.getSymbol(), linestyle=style.getLineStyle(), @@ -209,10 +215,10 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI xerror=xerror, yerror=yerror, z=self.getZValue(), - selectable=self.isSelectable(), fill=self.isFill(), alpha=self.getAlpha(), - symbolsize=style.getSymbolSize()) + symbolsize=style.getSymbolSize(), + baseline=self.getBaseline(copy=False)) def __getitem__(self, item): """Compatibility with PyMca and silx <= 0.4.0""" @@ -241,7 +247,7 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI 'yerror': self.getYErrorData(copy=False), 'z': self.getZValue(), 'selectable': self.isSelectable(), - 'fill': self.isFill() + 'fill': self.isFill(), } return params else: @@ -361,3 +367,25 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixI :rtype: 4-tuple of float in [0, 1] """ return self.getCurrentStyle().getColor() + + def setData(self, x, y, xerror=None, yerror=None, baseline=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 baseline: curve baseline + :type baseline: Union[None,float,numpy.ndarray] + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + PointsBase.setData(self, x=x, y=y, xerror=xerror, yerror=yerror, + copy=copy) + self._setBaseline(baseline=baseline) diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py index a1d6586..993c0f0 100644 --- a/silx/gui/plot/items/histogram.py +++ b/silx/gui/plot/items/histogram.py @@ -8,7 +8,7 @@ # 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: +# furnished to do so, subject to the following conditions::t # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. @@ -32,8 +32,13 @@ __date__ = "28/08/2018" import logging import numpy +from collections import OrderedDict, namedtuple +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc -from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn, +from .core import (Item, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn, LineMixIn, YAxisMixIn, ItemChangedType) _logger = logging.getLogger(__name__) @@ -96,7 +101,7 @@ def _getHistogramCurve(histogram, edges): # TODO: Yerror, test log scale class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, - LineMixIn, YAxisMixIn): + LineMixIn, YAxisMixIn, BaselineMixIn): """Description of an histogram""" _DEFAULT_Z_LAYER = 1 @@ -111,9 +116,12 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, _DEFAULT_LINESTYLE = '-' """Default line style of the histogram""" + _DEFAULT_BASELINE = None + def __init__(self): Item.__init__(self) AlphaMixIn.__init__(self) + BaselineMixIn.__init__(self) ColorMixIn.__init__(self) FillMixIn.__init__(self) LineMixIn.__init__(self) @@ -121,10 +129,11 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, self._histogram = () self._edges = () + self._setBaseline(Histogram._DEFAULT_BASELINE) def _addBackendRenderer(self, backend): """Update backend renderer""" - values, edges = self.getData(copy=False) + values, edges, baseline = self.getData(copy=False) if values.size == 0: return None # No data to display, do not add renderer @@ -153,7 +162,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, x[clipped] = numpy.nan y[clipped] = numpy.nan - return backend.addCurve(x, y, self.getLegend(), + return backend.addCurve(x, y, color=self.getColor(), symbol='', linestyle=self.getLineStyle(), @@ -162,13 +171,13 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, xerror=None, yerror=None, z=self.getZValue(), - selectable=self.isSelectable(), fill=self.isFill(), alpha=self.getAlpha(), + baseline=baseline, symbolsize=1) def _getBounds(self): - values, edges = self.getData(copy=False) + values, edges, baseline = self.getData(copy=False) plot = self.getPlot() if plot is not None: @@ -243,16 +252,19 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, return numpy.array(self._edges, copy=copy) def getData(self, copy=True): - """Return the histogram values and the bin edges + """Return the histogram values, bin edges and baseline :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) + return (self.getValueData(copy), + self.getBinEdgesData(copy), + self.getBaseline(copy)) - def setData(self, histogram, edges, align='center', copy=True): + def setData(self, histogram, edges, align='center', baseline=None, + copy=True): """Set the histogram values and bin edges. :param numpy.ndarray histogram: The values of the histogram. @@ -264,6 +276,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, 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 baseline: histogram baseline + :type baseline: Union[None,float,numpy.ndarray] :param bool copy: True make a copy of the data (default), False to use provided arrays. """ @@ -285,10 +299,18 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, # Check that bin edges are monotonic edgesDiff = numpy.diff(edges) assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0) - + # manage baseline + if (isinstance(baseline, abc.Iterable)): + baseline = numpy.array(baseline) + if baseline.size == histogram.size: + new_baseline = numpy.empty(baseline.shape[0] * 2) + for i_value, value in enumerate(baseline): + new_baseline[i_value*2:i_value*2+2] = value + baseline = new_baseline self._histogram = histogram self._edges = edges self._alignement = align + self._setBaseline(baseline) if self.isVisible(): plot = self.getPlot() diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py index d74f4d3..44cb70f 100644 --- a/silx/gui/plot/items/image.py +++ b/silx/gui/plot/items/image.py @@ -42,6 +42,7 @@ import numpy from ....utils.proxy import docstring from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn, ItemChangedType) +from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -142,6 +143,25 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): plot._invalidateDataRange() super(ImageBase, self).setVisible(visible) + @docstring(Item) + def pick(self, x, y): + if super(ImageBase, self).pick(x, y) is not None: + plot = self.getPlot() + if plot is None: + return None + + dataPos = plot.pixelToData(x, y) + if dataPos is None: + return None + + origin = self.getOrigin() + scale = self.getScale() + column = int((dataPos[0] - origin[0]) / float(scale[0])) + row = int((dataPos[1] - origin[1]) / float(scale[1])) + return PickingResult(self, ([row], [column])) + + return None + def _isPlotLinear(self, plot): """Return True if plot only uses linear scale for both of x and y axes.""" @@ -282,12 +302,9 @@ class ImageData(ImageBase, ColormapMixIn): 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()) @@ -415,12 +432,9 @@ class ImageRgba(ImageBase): 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()) diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py index 80ca0b6..f5a1689 100644..100755 --- a/silx/gui/plot/items/marker.py +++ b/silx/gui/plot/items/marker.py @@ -34,13 +34,13 @@ import logging from ....utils.proxy import docstring from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, - ItemChangedType) + ItemChangedType, YAxisMixIn) _logger = logging.getLogger(__name__) -class MarkerBase(Item, DraggableMixIn, ColorMixIn): +class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): """Base class for markers""" _DEFAULT_COLOR = (0., 0., 0., 1.) @@ -50,6 +50,7 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn): Item.__init__(self) DraggableMixIn.__init__(self) ColorMixIn.__init__(self) + YAxisMixIn.__init__(self) self._text = '' self._x = None @@ -62,15 +63,13 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn): 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()) + constraint=self.getConstraint(), + yaxis=self.getYAxis()) def _addBackendRenderer(self, backend): """Update backend renderer""" @@ -81,13 +80,11 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn): self.setPosition(to[0], to[1]) def isOverlay(self): - """Return true if marker is drawn as an overlay. - - A marker is an overlay if it is draggable. + """Returns True: A marker is always rendered as an overlay. :rtype: bool """ - return self.isDraggable() + return True def getText(self): """Returns marker text. diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py index 65831be..dcad943 100644 --- a/silx/gui/plot/items/roi.py +++ b/silx/gui/plot/items/roi.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -40,12 +40,51 @@ from ....utils.weakref import WeakList from ... import qt from .. import items from ...colors import rgba +import silx.utils.deprecation +from silx.utils.proxy import docstring logger = logging.getLogger(__name__) -class RegionOfInterest(qt.QObject): +class _RegionOfInterestBase(qt.QObject): + """Base class of 1D and 2D region of interest + + :param QObject parent: See QObject + :param str name: The name of the ROI + """ + + sigItemChanged = qt.Signal(object) + """Signal emitted when item has changed. + + It provides a flag describing which property of the item has changed. + See :class:`ItemChangedType` for flags description. + """ + + def __init__(self, parent=None, name=''): + qt.QObject.__init__(self) + self.__name = str(name) + + def getName(self): + """Returns the name of the ROI + + :return: name of the region of interest + :rtype: str + """ + return self.__name + + def setName(self, name): + """Set the name of the ROI + + :param str name: name of the region of interest + """ + name = str(name) + if self.__name != name: + self.__name = name + self.sigItemChanged.emit(items.ItemChangedType.NAME) + + +class RegionOfInterest(_RegionOfInterestBase): """Object describing a region of interest in a plot. :param QObject parent: @@ -55,7 +94,7 @@ class RegionOfInterest(qt.QObject): _kind = None """Label for this kind of ROI. - Should be setted by inherited classes to custom the ROI manager widget. + Should be set by inherited classes to custom the ROI manager widget. """ sigRegionChanged = qt.Signal() @@ -65,15 +104,20 @@ class RegionOfInterest(qt.QObject): # Avoid circular dependancy from ..tools import roi as roi_tools assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) - qt.QObject.__init__(self, parent) + _RegionOfInterestBase.__init__(self, parent, '') self._color = rgba('red') self._items = WeakList() self._editAnchors = WeakList() self._points = None - self._label = '' self._labelItem = None self._editable = False self._visible = True + self.sigItemChanged.connect(self.__itemChanged) + + def __itemChanged(self, event): + """Handle name change""" + if event == items.ItemChangedType.NAME: + self._updateLabelItem(self.getName()) def __del__(self): # Clean-up plot items @@ -140,22 +184,27 @@ class RegionOfInterest(qt.QObject): if isinstance(item, items.ColorMixIn): item.setColor(rgbaColor) + self.sigItemChanged.emit(items.ItemChangedType.COLOR) + + @silx.utils.deprecation.deprecated(reason='API modification', + replacement='getName()', + since_version=0.12) def getLabel(self): """Returns the label displayed for this ROI. :rtype: str """ - return self._label + return self.getName() + @silx.utils.deprecation.deprecated(reason='API modification', + replacement='setName(name)', + since_version=0.12) 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) + self.setName(name=label) def isEditable(self): """Returns whether the ROI is editable by the user or not. @@ -176,6 +225,7 @@ class RegionOfInterest(qt.QObject): # Recreate plot items # This can be avoided once marker.setDraggable is public self._createPlotItems() + self.sigItemChanged.emit(items.ItemChangedType.EDITABLE) def isVisible(self): """Returns whether the ROI is visible in the plot. @@ -197,13 +247,13 @@ class RegionOfInterest(qt.QObject): hide it. """ visible = bool(visible) - if self._visible == visible: - return - self._visible = visible - if self._labelItem is not None: - self._labelItem.setVisible(visible) - for item in self._items + self._editAnchors: - item.setVisible(visible) + if self._visible != visible: + self._visible = visible + if self._labelItem is not None: + self._labelItem.setVisible(visible) + for item in self._items + self._editAnchors: + item.setVisible(visible) + self.sigItemChanged.emit(items.ItemChangedType.VISIBLE) def _getControlPoints(self): """Returns the current ROI control points. @@ -371,7 +421,7 @@ class RegionOfInterest(qt.QObject): markerPos = self._getLabelPosition() marker = items.Marker() marker.setPosition(*markerPos) - marker.setText(self.getLabel()) + marker.setText(self.getName()) marker.setColor(rgba(self.getColor())) marker.setSymbol('') marker._setDraggable(False) @@ -465,6 +515,12 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): _plotShape = "point" """Plot shape which is used for the first interaction""" + _DEFAULT_SYMBOL = '+' + """Default symbol of the PointROI + + It overwrite the `SymbolMixIn` class attribte. + """ + def __init__(self, parent=None): items.SymbolMixIn.__init__(self) RegionOfInterest.__init__(self, parent=parent) @@ -488,31 +544,31 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): return None def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: + self._items[0].setText(label) + + def _updateShape(self): + if len(self._items) > 0: + controlPoints = self._getControlPoints() item = self._items[0] - item.setText(label) + item.setPosition(*controlPoints[0]) + + def __positionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + if isinstance(marker, items.Marker): + self.setPosition(marker.getPosition()) 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.setText(self.getName()) marker.setSymbol(self.getSymbol()) marker.setSymbolSize(self.getSymbolSize()) - 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.setColor(rgba(self.getColor())) marker._setDraggable(self.isEditable()) - marker.setSymbol(self.getSymbol()) - marker.setSymbolSize(self.getSymbolSize()) + if self.isEditable(): + marker.sigItemChanged.connect(self.__positionChanged) return [marker] def __str__(self): @@ -672,38 +728,31 @@ class HorizontalLineROI(RegionOfInterest, items.LineMixIn): return None def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: - item = self._items[0] - item.setText(label) + self._items[0].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]) + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def __positionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + if isinstance(marker, items.YMarker): + self.setPosition(marker.getYPosition()) def _createShapeItems(self, points): - if self.isEditable(): - return [] marker = items.YMarker() marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) + marker.setText(self.getName()) marker.setColor(rgba(self.getColor())) - marker._setDraggable(False) marker.setLineWidth(self.getLineWidth()) marker.setLineStyle(self.getLineStyle()) - 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()) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) + if self.isEditable(): + marker.sigItemChanged.connect(self.__positionChanged) return [marker] def __str__(self): @@ -749,38 +798,31 @@ class VerticalLineROI(RegionOfInterest, items.LineMixIn): return None def _updateLabelItem(self, label): - if self.isEditable(): - item = self._editAnchors[0] - else: - item = self._items[0] - item.setText(label) + self._items[0].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]) + if len(self._items) > 0: + controlPoints = self._getControlPoints() + item = self._items[0] + item.setPosition(*controlPoints[0]) + + def __positionChanged(self, event): + """Handle position changed events of the marker""" + if event is items.ItemChangedType.POSITION: + marker = self.sender() + if isinstance(marker, items.XMarker): + self.setPosition(marker.getXPosition()) def _createShapeItems(self, points): - if self.isEditable(): - return [] marker = items.XMarker() marker.setPosition(points[0][0], points[0][1]) - marker.setText(self.getLabel()) + marker.setText(self.getName()) marker.setColor(rgba(self.getColor())) - marker._setDraggable(False) marker.setLineWidth(self.getLineWidth()) marker.setLineStyle(self.getLineStyle()) - 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()) - marker.setLineWidth(self.getLineWidth()) - marker.setLineStyle(self.getLineStyle()) + if self.isEditable(): + marker.sigItemChanged.connect(self.__positionChanged) return [marker] def __str__(self): diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py index b2f087b..50cc694 100644 --- a/silx/gui/plot/items/scatter.py +++ b/silx/gui/plot/items/scatter.py @@ -25,11 +25,15 @@ """This module provides the :class:`Scatter` item of the :class:`Plot`. """ +from __future__ import division + + __authors__ = ["T. Vincent", "P. Knobel"] __license__ = "MIT" __date__ = "29/03/2017" +from collections import namedtuple import logging import threading import numpy @@ -37,10 +41,13 @@ import numpy from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, CancelledError +from ....utils.proxy import docstring +from ....math.combo import min_max from ....utils.weakref import WeakList from .._utils.delaunay import delaunay from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn from .axis import Axis +from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -79,6 +86,184 @@ class _GreedyThreadPoolExecutor(ThreadPoolExecutor): return future +# Functions to guess grid shape from coordinates + +def _get_z_line_length(array): + """Return length of line if array is a Z-like 2D regular grid. + + :param numpy.ndarray array: The 1D array of coordinates to check + :return: 0 if no line length could be found, + else the number of element per line. + :rtype: int + """ + sign = numpy.sign(numpy.diff(array)) + if len(sign) == 0 or sign[0] == 0: # We don't handle that + return 0 + # Check this way to account for 0 sign (i.e., diff == 0) + beginnings = numpy.where(sign == - sign[0])[0] + 1 + if len(beginnings) == 0: + return 0 + length = beginnings[0] + if numpy.all(numpy.equal(numpy.diff(beginnings), length)): + return length + return 0 + + +def _guess_z_grid_shape(x, y): + """Guess the shape of a grid from (x, y) coordinates. + + The grid might contain more elements than x and y, + as the last line might be partly filled. + + :param numpy.ndarray x: + :paran numpy.ndarray y: + :returns: (order, (height, width)) of the regular grid, + or None if could not guess one. + 'order' is 'row' if X (i.e., column) is the fast dimension, else 'column'. + :rtype: Union[List(str,int),None] + """ + width = _get_z_line_length(x) + if width != 0: + return 'row', (int(numpy.ceil(len(x) / width)), width) + else: + height = _get_z_line_length(y) + if height != 0: + return 'column', (height, int(numpy.ceil(len(y) / height))) + return None + + +def is_monotonic(array): + """Returns whether array is monotonic (increasing or decreasing). + + :param numpy.ndarray array: 1D array-like container. + :returns: 1 if array is monotonically increasing, + -1 if array is monotonically decreasing, + 0 if array is not monotonic + :rtype: int + """ + diff = numpy.diff(numpy.ravel(array)) + if numpy.all(diff >= 0): + return 1 + elif numpy.all(diff <= 0): + return -1 + else: + return 0 + + +def _guess_grid(x, y): + """Guess a regular grid from the points. + + Result convention is (x, y) + + :param numpy.ndarray x: X coordinates of the points + :param numpy.ndarray y: Y coordinates of the points + :returns: (order, (height, width) + order is 'row' or 'column' + :rtype: Union[List[str,List[int]],None] + """ + x, y = numpy.ravel(x), numpy.ravel(y) + + guess = _guess_z_grid_shape(x, y) + if guess is not None: + return guess + + else: + # Cannot guess a regular grid + # Let's assume it's a single line + order = 'row' # or 'column' doesn't matter for a single line + y_monotonic = is_monotonic(y) + if is_monotonic(x) or y_monotonic: # we can guess a line + x_min, x_max = min_max(x) + y_min, y_max = min_max(y) + + if not y_monotonic or x_max - x_min >= y_max - y_min: + # x only is monotonic or both are and X varies more + # line along X + shape = 1, len(x) + else: + # y only is monotonic or both are and Y varies more + # line along Y + shape = len(y), 1 + + else: # Cannot guess a line from the points + return None + + return order, shape + + +def _quadrilateral_grid_coords(points): + """Compute an irregular grid of quadrilaterals from a set of points + + The input points are expected to lie on a grid. + + :param numpy.ndarray points: + 3D data set of 2D input coordinates (height, width, 2) + height and width must be at least 2. + :return: 3D dataset of 2D coordinates of the grid (height+1, width+1, 2) + """ + assert points.ndim == 3 + assert points.shape[0] >= 2 + assert points.shape[1] >= 2 + assert points.shape[2] == 2 + + dim0, dim1 = points.shape[:2] + grid_points = numpy.zeros((dim0 + 1, dim1 + 1, 2), dtype=numpy.float64) + + # Compute inner points as mean of 4 neighbours + neighbour_view = numpy.lib.stride_tricks.as_strided( + points, + shape=(dim0 - 1, dim1 - 1, 2, 2, points.shape[2]), + strides=points.strides[:2] + points.strides[:2] + points.strides[-1:], writeable=False) + inner_points = numpy.mean(neighbour_view, axis=(2, 3)) + grid_points[1:-1, 1:-1] = inner_points + + # Compute 'vertical' sides + # Alternative: grid_points[1:-1, [0, -1]] = points[:-1, [0, -1]] + points[1:, [0, -1]] - inner_points[:, [0, -1]] + grid_points[1:-1, [0, -1], 0] = points[:-1, [0, -1], 0] + points[1:, [0, -1], 0] - inner_points[:, [0, -1], 0] + grid_points[1:-1, [0, -1], 1] = inner_points[:, [0, -1], 1] + + # Compute 'horizontal' sides + grid_points[[0, -1], 1:-1, 0] = inner_points[[0, -1], :, 0] + grid_points[[0, -1], 1:-1, 1] = points[[0, -1], :-1, 1] + points[[0, -1], 1:, 1] - inner_points[[0, -1], :, 1] + + # Compute corners + d0, d1 = [0, 0, -1, -1], [0, -1, -1, 0] + grid_points[d0, d1] = 2 * points[d0, d1] - inner_points[d0, d1] + return grid_points + + +def _quadrilateral_grid_as_triangles(points): + """Returns the points and indices to make a grid of quadirlaterals + + :param numpy.ndarray points: + 3D array of points (height, width, 2) + :return: triangle corners (4 * N, 2), triangle indices (2 * N, 3) + With N = height * width, the number of input points + """ + nbpoints = numpy.prod(points.shape[:2]) + + grid = _quadrilateral_grid_coords(points) + coords = numpy.empty((4 * nbpoints, 2), dtype=grid.dtype) + coords[::4] = grid[:-1, :-1].reshape(-1, 2) + coords[1::4] = grid[1:, :-1].reshape(-1, 2) + coords[2::4] = grid[:-1, 1:].reshape(-1, 2) + coords[3::4] = grid[1:, 1:].reshape(-1, 2) + + indices = numpy.empty((2 * nbpoints, 3), dtype=numpy.uint32) + indices[::2, 0] = numpy.arange(0, 4 * nbpoints, 4) + indices[::2, 1] = numpy.arange(1, 4 * nbpoints, 4) + indices[::2, 2] = numpy.arange(2, 4 * nbpoints, 4) + indices[1::2, 0] = indices[::2, 1] + indices[1::2, 1] = indices[::2, 2] + indices[1::2, 2] = numpy.arange(3, 4 * nbpoints, 4) + + return coords, indices + + +_RegularGridInfo = namedtuple( + '_RegularGridInfo', ['bounds', 'origin', 'scale', 'shape', 'order']) + + class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): """Description of a scatter""" @@ -87,7 +272,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): _SUPPORTED_SCATTER_VISUALIZATION = ( ScatterVisualizationMixIn.Visualization.POINTS, - ScatterVisualizationMixIn.Visualization.SOLID) + ScatterVisualizationMixIn.Visualization.SOLID, + ScatterVisualizationMixIn.Visualization.REGULAR_GRID, + ScatterVisualizationMixIn.Visualization.IRREGULAR_GRID, + ) """Overrides supported Visualizations""" def __init__(self): @@ -104,7 +292,86 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): # Cache triangles: x, y, indices self.__cacheTriangles = None, None, None + + # Cache regular grid info + self.__cacheRegularGridInfo = None + + @docstring(ScatterVisualizationMixIn) + def setVisualizationParameter(self, parameter, value): + changed = super(Scatter, self).setVisualizationParameter(parameter, value) + if changed and parameter in (self.VisualizationParameter.GRID_BOUNDS, + self.VisualizationParameter.GRID_MAJOR_ORDER, + self.VisualizationParameter.GRID_SHAPE): + self.__cacheRegularGridInfo = None + return changed + + @docstring(ScatterVisualizationMixIn) + def getCurrentVisualizationParameter(self, parameter): + value = self.getVisualizationParameter(parameter) + if value is not None: + return value # Value has been set, return it + + elif parameter is self.VisualizationParameter.GRID_BOUNDS: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.bounds + elif parameter is self.VisualizationParameter.GRID_MAJOR_ORDER: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.order + + elif parameter is self.VisualizationParameter.GRID_SHAPE: + grid = self.__getRegularGridInfo() + return None if grid is None else grid.shape + + else: + raise NotImplementedError() + + def __getRegularGridInfo(self): + """Get grid info""" + if self.__cacheRegularGridInfo is None: + shape = self.getVisualizationParameter( + self.VisualizationParameter.GRID_SHAPE) + order = self.getVisualizationParameter( + self.VisualizationParameter.GRID_MAJOR_ORDER) + if shape is None or order is None: + guess = _guess_grid(self.getXData(copy=False), + self.getYData(copy=False)) + if guess is None: + _logger.warning( + 'Cannot guess a grid: Cannot display as regular grid image') + return None + if shape is None: + shape = guess[1] + if order is None: + order = guess[0] + + bounds = self.getVisualizationParameter( + self.VisualizationParameter.GRID_BOUNDS) + if bounds is None: + x, y = self.getXData(copy=False), self.getYData(copy=False) + min_, max_ = min_max(x) + xRange = (min_, max_) if (x[0] - min_) < (max_ - x[0]) else (max_, min_) + min_, max_ = min_max(y) + yRange = (min_, max_) if (y[0] - min_) < (max_ - y[0]) else (max_, min_) + bounds = (xRange[0], yRange[0]), (xRange[1], yRange[1]) + + begin, end = bounds + scale = ((end[0] - begin[0]) / max(1, shape[1] - 1), + (end[1] - begin[1]) / max(1, shape[0] - 1)) + if scale[0] == 0 and scale[1] == 0: + scale = 1., 1. + elif scale[0] == 0: + scale = scale[1], scale[1] + elif scale[1] == 0: + scale = scale[0], scale[0] + + origin = begin[0] - 0.5 * scale[0], begin[1] - 0.5 * scale[1] + + self.__cacheRegularGridInfo = _RegularGridInfo( + bounds=bounds, origin=origin, scale=scale, shape=shape, order=order) + + return self.__cacheRegularGridInfo + def _addBackendRenderer(self, backend): """Update backend renderer""" # Filter-out values <= 0 @@ -129,8 +396,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): # Apply mask to colors rgbacolors = rgbacolors[mask] - if self.getVisualization() is self.Visualization.POINTS: - return backend.addCurve(xFiltered, yFiltered, self.getLegend(), + visualization = self.getVisualization() + + if visualization is self.Visualization.POINTS: + return backend.addCurve(xFiltered, yFiltered, color=rgbacolors, symbol=self.getSymbol(), linewidth=0, @@ -139,32 +408,153 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): xerror=xerror, yerror=yerror, z=self.getZValue(), - selectable=self.isSelectable(), fill=False, alpha=self.getAlpha(), - symbolsize=self.getSymbolSize()) + symbolsize=self.getSymbolSize(), + baseline=None) - else: # 'solid' + else: plot = self.getPlot() if (plot is None or plot.getXAxis().getScale() != Axis.LINEAR or plot.getYAxis().getScale() != Axis.LINEAR): - # Solid visualization is not available with log scaled axes + # Those visualizations are not available with log scaled axes return None - triangulation = self._getDelaunay().result() - if triangulation is None: - return None - else: - triangles = triangulation.simplices.astype(numpy.int32) - return backend.addTriangles(xFiltered, - yFiltered, - triangles, - legend=self.getLegend(), - color=rgbacolors, + if visualization is self.Visualization.SOLID: + triangulation = self._getDelaunay().result() + if triangulation is None: + _logger.warning( + 'Cannot get a triangulation: Cannot display as solid surface') + return None + else: + triangles = triangulation.simplices.astype(numpy.int32) + return backend.addTriangles(xFiltered, + yFiltered, + triangles, + color=rgbacolors, + z=self.getZValue(), + alpha=self.getAlpha()) + + elif visualization is self.Visualization.REGULAR_GRID: + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + dim0, dim1 = gridInfo.shape + if gridInfo.order == 'column': # transposition needed + dim0, dim1 = dim1, dim0 + + if len(rgbacolors) == dim0 * dim1: + image = rgbacolors.reshape(dim0, dim1, -1) + else: + # The points do not fill the whole image + image = numpy.empty((dim0 * dim1, 4), dtype=rgbacolors.dtype) + image[:len(rgbacolors)] = rgbacolors + image[len(rgbacolors):] = 0, 0, 0, 0 # Transparent pixels + image.shape = dim0, dim1, -1 + + if gridInfo.order == 'column': + image = numpy.transpose(image, axes=(1, 0, 2)) + + return backend.addImage( + data=image, + origin=gridInfo.origin, + scale=gridInfo.scale, + z=self.getZValue(), + colormap=None, + alpha=self.getAlpha()) + + elif visualization is self.Visualization.IRREGULAR_GRID: + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + shape = gridInfo.shape + if shape is None: # No shape, no display + return None + + # clip shape to fully filled lines + if len(xFiltered) != numpy.prod(shape): + if gridInfo.order == 'row': + shape = len(xFiltered) // shape[1], shape[1] + else: # column-major order + shape = shape[0], len(xFiltered) // shape[0] + if shape[0] < 2 or shape[1] < 2: # Not enough points + return None + + nbpoints = numpy.prod(shape) + if gridInfo.order == 'row': + points = numpy.transpose((xFiltered[:nbpoints], yFiltered[:nbpoints])) + points = points.reshape(shape[0], shape[1], 2) + + else: # column-major order + points = numpy.transpose((yFiltered[:nbpoints], xFiltered[:nbpoints])) + points = points.reshape(shape[1], shape[0], 2) + + coords, indices = _quadrilateral_grid_as_triangles(points) + + if gridInfo.order == 'row': + x, y = coords[:, 0], coords[:, 1] + else: # column-major order + y, x = coords[:, 0], coords[:, 1] + + gridcolors = numpy.empty( + (4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype) + for first in range(4): + gridcolors[first::4] = rgbacolors[:nbpoints] + + return backend.addTriangles(x, + y, + indices, + color=gridcolors, z=self.getZValue(), - selectable=self.isSelectable(), alpha=self.getAlpha()) + else: + _logger.error("Unhandled visualization %s", visualization) + return None + + @docstring(PointsBase) + def pick(self, x, y): + result = super(Scatter, self).pick(x, y) + + if result is not None: + visualization = self.getVisualization() + + if visualization is self.Visualization.IRREGULAR_GRID: + # Specific handling of picking for the irregular grid mode + index = result.getIndices(copy=False)[0] // 4 + result = PickingResult(self, (index,)) + + elif visualization is self.Visualization.REGULAR_GRID: + # Specific handling of picking for the regular grid mode + plot = self.getPlot() + if plot is None: + return None + + dataPos = plot.pixelToData(x, y) + if dataPos is None: + return None + + gridInfo = self.__getRegularGridInfo() + if gridInfo is None: + return None + + origin = gridInfo.origin + scale = gridInfo.scale + column = int((dataPos[0] - origin[0]) / scale[0]) + row = int((dataPos[1] - origin[1]) / scale[1]) + + if gridInfo.order == 'row': + index = row * gridInfo.shape[1] + column + else: + index = row + column * gridInfo.shape[0] + if index >= len(self.getXData(copy=False)): # OK as long as not log scale + return None # Image can be larger than scatter + + result = PickingResult(self, (index,)) + + return result def __getExecutor(self): """Returns async greedy executor @@ -358,6 +748,9 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): self.__interpolatorFuture.cancel() self.__interpolatorFuture = None + # Data changed, this needs update + self.__cacheRegularGridInfo = None + self._value = value if alpha is not None: diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py index 9fc1306..e6dc529 100644 --- a/silx/gui/plot/items/shape.py +++ b/silx/gui/plot/items/shape.py @@ -36,7 +36,7 @@ import numpy import six from ... import colors -from .core import Item, ColorMixIn, FillMixIn, ItemChangedType, LineMixIn +from .core import Item, ColorMixIn, FillMixIn, ItemChangedType, LineMixIn, YAxisMixIn _logger = logging.getLogger(__name__) @@ -70,7 +70,6 @@ class Shape(Item, ColorMixIn, FillMixIn, LineMixIn): 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(), @@ -154,3 +153,64 @@ class Shape(Item, ColorMixIn, FillMixIn, LineMixIn): self._lineBgColor = color self._updated(ItemChangedType.LINE_BG_COLOR) + + +class BoundingRect(Item, YAxisMixIn): + """An invisible shape which enforce the plot view to display the defined + space on autoscale. + + This item do not display anything. But if the visible property is true, + this bounding box is used by the plot, if not, the bounding box is + ignored. That's the default behaviour for plot items. + + It can be applied on the "left" or "right" axes. Not both at the same time. + """ + + def __init__(self): + Item.__init__(self) + YAxisMixIn.__init__(self) + self.__bounds = None + + def _updated(self, event=None, checkVisibility=True): + if event in (ItemChangedType.YAXIS, + ItemChangedType.VISIBLE, + ItemChangedType.DATA): + # TODO hackish data range implementation + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + super(BoundingRect, self)._updated(event, checkVisibility) + + def setBounds(self, rect): + """Set the bounding box of this item in data coordinates + + :param Union[None,List[float]] rect: (xmin, xmax, ymin, ymax) or None + """ + if rect is not None: + rect = float(rect[0]), float(rect[1]), float(rect[2]), float(rect[3]) + assert rect[0] <= rect[1] + assert rect[2] <= rect[3] + + if rect != self.__bounds: + self.__bounds = rect + self._updated(ItemChangedType.DATA) + + def _getBounds(self): + plot = self.getPlot() + if plot is not None: + xPositive = plot.getXAxis()._isLogarithmic() + yPositive = plot.getYAxis()._isLogarithmic() + if xPositive or yPositive: + bounds = list(self.__bounds) + if xPositive and bounds[1] <= 0: + return None + if xPositive and bounds[0] <= 0: + bounds[0] = bounds[1] + if yPositive and bounds[3] <= 0: + return None + if yPositive and bounds[2] <= 0: + bounds[2] = bounds[3] + return tuple(bounds) + + return self.__bounds diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py index 7449c12..9724ec6 100644..100755 --- a/silx/gui/plot/test/testPlotWidget.py +++ b/silx/gui/plot/test/testPlotWidget.py @@ -32,6 +32,7 @@ __date__ = "03/01/2019" import unittest import logging import numpy +import sys from silx.utils.testutils import ParametricTestCase, parameterize from silx.gui.utils.testutils import SignalListener @@ -42,6 +43,7 @@ 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.plot.items.shape import BoundingRect from silx.gui.colors import Colormap from .utils import PlotWidgetTestCase @@ -57,6 +59,19 @@ DATA_2D = numpy.arange(SIZE ** 2).reshape(SIZE, SIZE) logger = logging.getLogger(__name__) +class TestSpecialBackend(PlotWidgetTestCase, ParametricTestCase): + + def __init__(self, methodName='runTest', backend=None): + TestCaseQt.__init__(self, methodName=methodName) + self.__backend = backend + + def _createPlot(self): + return PlotWidget(backend=self.__backend) + + def testPlot(self): + self.assertIsNotNone(self.plot) + + class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase): """Basic tests for PlotWidget""" @@ -475,6 +490,56 @@ class TestPlotCurve(PlotWidgetTestCase): replace=False, resetzoom=False, color=color, symbol='o') + def testPlotBaselineNumpyArray(self): + """simple test of the API with baseline as a numpy array""" + x = numpy.arange(0, 10, step=0.1) + my_sin = numpy.sin(x) + y = numpy.arange(-4, 6, step=0.1) + my_sin + baseline = y - 1.0 + + self.plot.addCurve(x=x, y=y, color='grey', legend='curve1', fill=True, + baseline=baseline) + + def testPlotBaselineScalar(self): + """simple test of the API with baseline as an int""" + x = numpy.arange(0, 10, step=0.1) + my_sin = numpy.sin(x) + y = numpy.arange(-4, 6, step=0.1) + my_sin + + self.plot.addCurve(x=x, y=y, color='grey', legend='curve1', fill=True, + baseline=0) + + def testPlotBaselineList(self): + """simple test of the API with baseline as an int""" + x = numpy.arange(0, 10, step=0.1) + my_sin = numpy.sin(x) + y = numpy.arange(-4, 6, step=0.1) + my_sin + + self.plot.addCurve(x=x, y=y, color='grey', legend='curve1', fill=True, + baseline=list(range(0, 100, 1))) + + +class TestPlotHistogram(PlotWidgetTestCase): + """Basic tests for add Histogram""" + def setUp(self): + super(TestPlotHistogram, self).setUp() + self.edges = numpy.arange(0, 10, step=1) + self.histogram = numpy.random.random(len(self.edges)) + + def testPlot(self): + self.plot.addHistogram(histogram=self.histogram, + edges=self.edges, + legend='histogram1') + + def testPlotBaseline(self): + self.plot.addHistogram(histogram=self.histogram, + edges=self.edges, + legend='histogram1', + color='blue', + baseline=-2, + z=2, + fill=True) + class TestPlotScatter(PlotWidgetTestCase, ParametricTestCase): """Basic tests for addScatter""" @@ -487,7 +552,7 @@ class TestPlotScatter(PlotWidgetTestCase, ParametricTestCase): self.plot.resetZoom() def testScatterVisualization(self): - self.plot.addScatter((0, 1, 2, 3), (2, 0, 2, 1), (0, 1, 2, 3)) + self.plot.addScatter((0, 1, 0, 1), (0, 0, 2, 2), (0, 1, 2, 3)) self.plot.resetZoom() self.qapp.processEvents() @@ -495,12 +560,76 @@ class TestPlotScatter(PlotWidgetTestCase, ParametricTestCase): for visualization in ('solid', 'points', + 'regular_grid', scatter.Visualization.SOLID, - scatter.Visualization.POINTS): + scatter.Visualization.POINTS, + scatter.Visualization.REGULAR_GRID): with self.subTest(visualization=visualization): scatter.setVisualization(visualization) self.qapp.processEvents() + def testGridVisualization(self): + """Test regular and irregular grid mode with different points""" + points = { # name: (x, y, order) + 'single point': ((1.,), (1.,), 'row'), + 'horizontal line': ((0, 1, 2), (0, 0, 0), 'row'), + 'horizontal line backward': ((2, 1, 0), (0, 0, 0), 'row'), + 'vertical line': ((0, 0, 0), (0, 1, 2), 'row'), + 'vertical line backward': ((0, 0, 0), (2, 1, 0), 'row'), + 'grid fast x, +x +y': ((0, 1, 2, 0, 1, 2), (0, 0, 0, 1, 1, 1), 'row'), + 'grid fast x, +x -y': ((0, 1, 2, 0, 1, 2), (1, 1, 1, 0, 0, 0), 'row'), + 'grid fast x, -x -y': ((2, 1, 0, 2, 1, 0), (1, 1, 1, 0, 0, 0), 'row'), + 'grid fast x, -x +y': ((2, 1, 0, 2, 1, 0), (0, 0, 0, 1, 1, 1), 'row'), + 'grid fast y, +x +y': ((0, 0, 0, 1, 1, 1), (0, 1, 2, 0, 1, 2), 'column'), + 'grid fast y, +x -y': ((0, 0, 0, 1, 1, 1), (2, 1, 0, 2, 1, 0), 'column'), + 'grid fast y, -x -y': ((1, 1, 1, 0, 0, 0), (2, 1, 0, 2, 1, 0), 'column'), + 'grid fast y, -x +y': ((1, 1, 1, 0, 0, 0), (0, 1, 2, 0, 1, 2), 'column'), + } + + self.plot.addScatter((), (), ()) + scatter = self.plot.getItems()[0] + + self.qapp.processEvents() + + for visualization in (scatter.Visualization.REGULAR_GRID, + scatter.Visualization.IRREGULAR_GRID): + scatter.setVisualization(visualization) + self.assertIs(scatter.getVisualization(), visualization) + + for name, (x, y, ref_order) in points.items(): + with self.subTest(name=name, visualization=visualization.name): + scatter.setData(x, y, numpy.arange(len(x))) + self.plot.setGraphTitle(name) + self.plot.resetZoom() + self.qapp.processEvents() + + order = scatter.getCurrentVisualizationParameter( + scatter.VisualizationParameter.GRID_MAJOR_ORDER) + self.assertEqual(ref_order, order) + + ref_bounds = (x[0], y[0]), (x[-1], y[-1]) + bounds = scatter.getCurrentVisualizationParameter( + scatter.VisualizationParameter.GRID_BOUNDS) + self.assertEqual(ref_bounds, bounds) + + shape = scatter.getCurrentVisualizationParameter( + scatter.VisualizationParameter.GRID_SHAPE) + + self.plot.getXAxis().setLimits(numpy.min(x) - 1, numpy.max(x) + 1) + self.plot.getYAxis().setLimits(numpy.min(y) - 1, numpy.max(y) + 1) + self.qapp.processEvents() + + for index, position in enumerate(zip(x, y)): + xpixel, ypixel = self.plot.dataToPixel(*position) + result = scatter.pick(xpixel, ypixel) + if (visualization is scatter.Visualization.IRREGULAR_GRID and + (shape[0] < 2 or shape[1] < 2)): + self.assertIsNone(result) + else: + self.assertIsNotNone(result) + self.assertIs(result.getItem(), scatter) + self.assertEqual(result.getIndices()[0], (index,)) + class TestPlotMarker(PlotWidgetTestCase): """Basic tests for add*Marker""" @@ -593,6 +722,39 @@ class TestPlotMarker(PlotWidgetTestCase): self.plot.resetZoom() + def testPlotMarkerYAxis(self): + # Check only the API + + legend = self.plot.addMarker(10, 10) + item = self.plot._getMarker(legend) + self.assertEqual(item.getYAxis(), "left") + + legend = self.plot.addMarker(10, 10, yaxis="right") + item = self.plot._getMarker(legend) + self.assertEqual(item.getYAxis(), "right") + + legend = self.plot.addMarker(10, 10, yaxis="left") + item = self.plot._getMarker(legend) + self.assertEqual(item.getYAxis(), "left") + + legend = self.plot.addXMarker(10, yaxis="right") + item = self.plot._getMarker(legend) + self.assertEqual(item.getYAxis(), "right") + + legend = self.plot.addXMarker(10, yaxis="left") + item = self.plot._getMarker(legend) + self.assertEqual(item.getYAxis(), "left") + + legend = self.plot.addYMarker(10, yaxis="right") + item = self.plot._getMarker(legend) + self.assertEqual(item.getYAxis(), "right") + + legend = self.plot.addYMarker(10, yaxis="left") + item = self.plot._getMarker(legend) + self.assertEqual(item.getYAxis(), "left") + + self.plot.resetZoom() + # TestPlotItem ################################################################ @@ -1185,6 +1347,53 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase): """Test coverage on setAxesDisplayed(True)""" self.plot.setAxesDisplayed(True) + def testBoundingRectItem(self): + item = BoundingRect() + item.setBounds((-1000, 1000, -2000, 2000)) + self.plot._add(item) + self.plot.resetZoom() + limits = numpy.array(self.plot.getXAxis().getLimits()) + numpy.testing.assert_almost_equal(limits, numpy.array([-1000, 1000])) + limits = numpy.array(self.plot.getYAxis().getLimits()) + numpy.testing.assert_almost_equal(limits, numpy.array([-2000, 2000])) + + def testBoundingRectRightItem(self): + item = BoundingRect() + item.setYAxis("right") + item.setBounds((-1000, 1000, -2000, 2000)) + self.plot._add(item) + self.plot.resetZoom() + limits = numpy.array(self.plot.getXAxis().getLimits()) + numpy.testing.assert_almost_equal(limits, numpy.array([-1000, 1000])) + limits = numpy.array(self.plot.getYAxis("right").getLimits()) + numpy.testing.assert_almost_equal(limits, numpy.array([-2000, 2000])) + + def testBoundingRectArguments(self): + item = BoundingRect() + with self.assertRaises(Exception): + item.setBounds((1000, -1000, -2000, 2000)) + with self.assertRaises(Exception): + item.setBounds((-1000, 1000, 2000, -2000)) + + def testBoundingRectWithLog(self): + item = BoundingRect() + self.plot._add(item) + + item.setBounds((-1000, 1000, -2000, 2000)) + self.plot.getXAxis()._setLogarithmic(True) + self.plot.getYAxis()._setLogarithmic(False) + self.assertEqual(item.getBounds(), (1000, 1000, -2000, 2000)) + + item.setBounds((-1000, 1000, -2000, 2000)) + self.plot.getXAxis()._setLogarithmic(False) + self.plot.getYAxis()._setLogarithmic(True) + self.assertEqual(item.getBounds(), (-1000, 1000, 2000, 2000)) + + item.setBounds((-1000, 0, -2000, 2000)) + self.plot.getXAxis()._setLogarithmic(True) + self.plot.getYAxis()._setLogarithmic(False) + self.assertIsNone(item.getBounds()) + class TestPlotCurveLog(PlotWidgetTestCase, ParametricTestCase): """Basic tests for addCurve with log scale axes""" @@ -1564,6 +1773,7 @@ def suite(): testClasses = (TestPlotWidget, TestPlotImage, TestPlotCurve, + TestPlotHistogram, TestPlotScatter, TestPlotMarker, TestPlotItem, @@ -1581,6 +1791,10 @@ def suite(): for testClass in testClasses: test_suite.addTest(parameterize(testClass, backend=None)) + test_suite.addTest(parameterize(TestSpecialBackend, backend=u"mpl")) + if sys.version_info[0] == 2: + test_suite.addTest(parameterize(TestSpecialBackend, backend=b"mpl")) + if test_options.WITH_GL_TEST: # Tests with OpenGL backend for testClass in testClasses: diff --git a/silx/gui/plot/test/testStats.py b/silx/gui/plot/test/testStats.py index 4bc2144..185e79c 100644 --- a/silx/gui/plot/test/testStats.py +++ b/silx/gui/plot/test/testStats.py @@ -273,11 +273,12 @@ class TestStatsFormatter(TestCaseQt): formatter.format(self.stat.calculate(self.curveContext)), '0.000') -class TestStatsHandler(unittest.TestCase): +class TestStatsHandler(TestCaseQt): """Make sure the StatHandler is correctly making the link between :class:`StatBase` and :class:`StatFormatter` and checking the API is valid """ def setUp(self): + TestCaseQt.setUp(self) self.plot1d = Plot1D() x = range(20) y = range(20) @@ -289,6 +290,8 @@ class TestStatsHandler(unittest.TestCase): def tearDown(self): self.plot1d.setAttribute(qt.Qt.WA_DeleteOnClose) self.plot1d.close() + self.plot1d = None + TestCaseQt.tearDown(self) def testConstructor(self): """Make sure the constructor can deal will all possible arguments: diff --git a/silx/gui/plot/tools/PositionInfo.py b/silx/gui/plot/tools/PositionInfo.py index 83b61bd..fef11dd 100644 --- a/silx/gui/plot/tools/PositionInfo.py +++ b/silx/gui/plot/tools/PositionInfo.py @@ -121,7 +121,7 @@ class PositionInfo(qt.QWidget): contentWidget.setText('------') contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) contentWidget.setFixedWidth( - contentWidget.fontMetrics().width('##############')) + contentWidget.fontMetrics().boundingRect('##############').width()) layout.addWidget(contentWidget) self._fields.append((contentWidget, name, func)) diff --git a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py index 6d9d6d4..ced81da 100644 --- a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py +++ b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -321,7 +321,7 @@ class _BaseProfileToolBar(qt.QToolBar): def __roiAdded(self, roi): """Handle new ROI""" - roi.setLabel('Profile') + roi.setName('Profile') roi.setEditable(True) # Remove any other ROI diff --git a/silx/gui/plot/tools/roi.py b/silx/gui/plot/tools/roi.py index eb933a0..3535097 100644 --- a/silx/gui/plot/tools/roi.py +++ b/silx/gui/plot/tools/roi.py @@ -253,7 +253,7 @@ class RegionOfInterestManager(qt.QObject): else: return False - def _regionOfInterestChanged(self): + def _regionOfInterestChanged(self, event=None): """Handle ROI object changed""" self.sigRoiChanged.emit() @@ -271,7 +271,7 @@ class RegionOfInterestManager(qt.QObject): number of ROIs has been reached. """ roi = roiClass(parent=None) - roi.setLabel(str(label)) + roi.setName(str(label)) roi.setFirstShapePoints(points) self.addRoi(roi, index) @@ -283,6 +283,9 @@ class RegionOfInterestManager(qt.QObject): :param roi_items.RegionOfInterest roi: The ROI to add :param int index: The position where to insert the ROI, By default it is appended to the end of the list of ROIs + :param bool useManagerColor: + Whether to set the ROI color to the default one of the manager or not. + (Default: True). :raise RuntimeError: When ROI cannot be added because the maximum number of ROIs has been reached. """ @@ -297,6 +300,7 @@ class RegionOfInterestManager(qt.QObject): roi.setColor(self.getColor()) roi.sigRegionChanged.connect(self._regionOfInterestChanged) + roi.sigItemChanged.connect(self._regionOfInterestChanged) if index is None: self._rois.append(roi) @@ -321,6 +325,7 @@ class RegionOfInterestManager(qt.QObject): self._rois.remove(roi) roi.sigRegionChanged.disconnect(self._regionOfInterestChanged) + roi.sigItemChanged.disconnect(self._regionOfInterestChanged) roi.setParent(None) self._roisUpdated() @@ -820,11 +825,14 @@ class RegionOfInterestTableWidget(qt.QTableWidget): return if column == 0: - roi.setVisible(item.checkState() == qt.Qt.Checked) - roi.setLabel(item.text()) + # First collect information from item, then update ROI + # Otherwise, this causes issues issues + checked = item.checkState() == qt.Qt.Checked + text= item.text() + roi.setVisible(checked) + roi.setName(text) elif column == 1: - roi.setEditable( - item.checkState() == qt.Qt.Checked) + roi.setEditable(item.checkState() == qt.Qt.Checked) elif column in (2, 3, 4): pass # TODO else: @@ -888,7 +896,7 @@ class RegionOfInterestTableWidget(qt.QTableWidget): baseFlags = qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled # Label and visible - label = roi.getLabel() + label = roi.getName() item = qt.QTableWidgetItem(label) item.setFlags(baseFlags | qt.Qt.ItemIsEditable | qt.Qt.ItemIsUserCheckable) item.setData(qt.Qt.UserRole, index) diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py index 9fe3e51..7f3921a 100644 --- a/silx/gui/plot3d/_model/items.py +++ b/silx/gui/plot3d/_model/items.py @@ -45,7 +45,7 @@ from ...utils.image import convertArrayToQImage from ...colors import preferredColormaps from ... import qt, icons from .. import items -from ..items.volume import Isosurface, CutPlane +from ..items.volume import Isosurface, CutPlane, ComplexIsosurface from ..Plot3DWidget import Plot3DWidget @@ -867,6 +867,17 @@ class ColormapRow(_ColormapBaseProxyRow): self._sigColormapChanged.connect(self._updateColormapImage) + def getColormapImage(self): + """Returns image representing the colormap or None + + :rtype: Union[QImage,None] + """ + if self._colormapImage is None and self._colormap is not None: + image = numpy.zeros((16, 130, 3), dtype=numpy.uint8) + image[1:-1, 1:-1] = self._colormap.getNColors(image.shape[1] - 2)[:, :3] + self._colormapImage = convertArrayToQImage(image) + return self._colormapImage + def _get(self): """Getter for ProxyRow subclass""" return None @@ -908,13 +919,9 @@ class ColormapRow(_ColormapBaseProxyRow): def data(self, column, role): if column == 1 and role == qt.Qt.DecorationRole: - if self._colormapImage is None: - image = numpy.zeros((16, 130, 3), dtype=numpy.uint8) - image[1:-1, 1:-1] = self._colormap.getNColors(image.shape[1] - 2)[:, :3] - self._colormapImage = convertArrayToQImage(image) - return self._colormapImage - - return super(ColormapRow, self).data(column, role) + return self.getColormapImage() + else: + return super(ColormapRow, self).data(column, role) class SymbolRow(ItemProxyRow): @@ -1055,12 +1062,12 @@ class ComplexModeRow(ItemProxyRow): :param Item3D item: Scene item with symbol property """ - def __init__(self, item): + def __init__(self, item, name='Mode'): names = [m.value.replace('_', ' ').title() for m in item.supportedComplexModes()] super(ComplexModeRow, self).__init__( item=item, - name='Mode', + name=name, fget=item.getComplexMode, fset=item.setComplexMode, events=items.ItemChangedType.COMPLEX_MODE, @@ -1283,6 +1290,71 @@ class IsosurfaceRow(Item3DRow): return super(IsosurfaceRow, self).setData(column, value, role) +class ComplexIsosurfaceRow(IsosurfaceRow): + """Represents an :class:`ComplexIsosurface` item. + + :param ComplexIsosurface item: + """ + + _EVENTS = (items.ItemChangedType.VISIBLE, + items.ItemChangedType.COLOR, + items.ItemChangedType.COMPLEX_MODE) + """Events for which to update the first column in the tree""" + + def __init__(self, item): + super(ComplexIsosurfaceRow, self).__init__(item) + + self.addRow(ComplexModeRow(item, "Color Complex Mode"), index=1) + for row in self.children(): + if isinstance(row, ColorProxyRow): + self._colorRow = row + break + else: + raise RuntimeError("Cannot retrieve Color tree row") + self._colormapRow = ColormapRow(item) + + self.__updateRowsForItem(item) + item.sigItemChanged.connect(self.__itemChanged) + + def __itemChanged(self, event): + """Update enabled/disabled rows""" + if event == items.ItemChangedType.COMPLEX_MODE: + item = self.sender() + self.__updateRowsForItem(item) + + def __updateRowsForItem(self, item): + """Update rows for item + + :param item: + """ + if not isinstance(item, ComplexIsosurface): + return + + if item.getComplexMode() == items.ComplexMixIn.ComplexMode.NONE: + removed = self._colormapRow + added = self._colorRow + else: + removed = self._colorRow + added = self._colormapRow + + # Remove unwanted rows + if removed in self.children(): + self.removeRow(removed) + + # Add required rows + if added not in self.children(): + self.addRow(added, index=2) + + def data(self, column, role): + if column == 0 and role == qt.Qt.DecorationRole: + item = self.item() + if (item is not None and + item.getComplexMode() != items.ComplexMixIn.ComplexMode.NONE): + return self._colormapRow.getColormapImage() + + return super(ComplexIsosurfaceRow, self).data(column, role) + + class AddIsosurfaceRow(BaseRow): """Class for Isosurface create button @@ -1358,7 +1430,7 @@ class VolumeIsoSurfacesRow(StaticRow): volume.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved) if isinstance(volume, items.ComplexMixIn): - self.addRow(ComplexModeRow(volume)) + self.addRow(ComplexModeRow(volume, "Complex Mode")) for item in volume.getIsosurfaces(): self.addRow(nodeFromItem(item)) @@ -1581,6 +1653,8 @@ def nodeFromItem(item): # Item with specific model row class if isinstance(item, (items.GroupItem, items.GroupWithAxesItem)): return GroupItemRow(item) + elif isinstance(item, ComplexIsosurface): + return ComplexIsosurfaceRow(item) elif isinstance(item, Isosurface): return IsosurfaceRow(item) diff --git a/silx/gui/plot3d/items/_pick.py b/silx/gui/plot3d/items/_pick.py index b35ef0d..8494723 100644 --- a/silx/gui/plot3d/items/_pick.py +++ b/silx/gui/plot3d/items/_pick.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -34,6 +34,7 @@ __date__ = "24/09/2018" import logging import numpy +from ...plot.items._pick import PickingResult as _PickingResult from ..scene import Viewport, Base @@ -177,9 +178,8 @@ class PickContext(object): return rayObject -class PickingResult(object): - """Class to access picking information in a 3D scene. - """ +class PickingResult(_PickingResult): + """Class to access picking information in a 3D scene.""" def __init__(self, item, positions, indices=None, fetchdata=None): """Init @@ -194,7 +194,8 @@ class PickingResult(object): to provide an alternative function to access item data. Default is to use `item.getData`. """ - self._item = item + super(PickingResult, self).__init__(item, indices) + self._objectPositions = numpy.array( positions, copy=False, dtype=numpy.float) @@ -205,36 +206,8 @@ class PickingResult(object): self._scenePositions = None self._ndcPositions = None - if indices is None: - self._indices = None - else: - self._indices = numpy.array(indices, copy=False, dtype=numpy.int) - self._fetchdata = fetchdata - def getItem(self): - """Returns the item this results corresponds to. - - :rtype: ~silx.gui.plot3d.items.Item3D - """ - return self._item - - def getIndices(self, copy=True): - """Returns indices of picked data. - - If data is 1D, it returns a numpy.ndarray, otherwise - it returns a tuple with as many numpy.ndarray as there are - dimensions in the data. - - :param bool copy: True (default) to get a copy, - False to return internal arrays - :rtype: Union[None,numpy.ndarray,List[numpy.ndarray]] - """ - if self._indices is None: - return None - indices = numpy.array(self._indices, copy=copy) - return indices if indices.ndim == 1 else tuple(indices) - def getData(self, copy=True): """Returns picked data values diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py index e8ffee1..5fce629 100644 --- a/silx/gui/plot3d/items/scatter.py +++ b/silx/gui/plot3d/items/scatter.py @@ -234,6 +234,9 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn, } """Dict {visualization mode: property names used in this mode}""" + _SUPPORTED_SCATTER_VISUALIZATION = tuple(_VISUALIZATION_PROPERTIES.keys()) + """Overrides supported Visualizations""" + def __init__(self, parent=None): DataItem3D.__init__(self, parent=parent) ColormapMixIn.__init__(self) diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py index ae91e82..e0a2a1f 100644 --- a/silx/gui/plot3d/items/volume.py +++ b/silx/gui/plot3d/items/volume.py @@ -37,13 +37,14 @@ import numpy from silx.math.combo import min_max from silx.math.marchingcubes import MarchingCubes +from silx.math.interpolate import interp3d from ....utils.proxy import docstring from ... import _glutils as glu from ... import qt from ...colors import rgba -from ..scene import cutplane, primitives, transform, utils +from ..scene import cutplane, function, primitives, transform, utils from .core import BaseNodeItem, Item3D, ItemChangedType, Item3DChangedType from .mixins import ColormapMixIn, ComplexMixIn, InterpolationMixIn, PlaneMixIn @@ -301,6 +302,15 @@ class Isosurface(Item3D): """Return the color of this iso-surface (QColor)""" return qt.QColor.fromRgbF(*self._color) + def _updateColor(self, color): + """Handle update of color + + :param List[float] color: RGBA channels in [0, 1] + """ + primitive = self._getScenePrimitive() + if len(primitive.children) != 0: + primitive.children[0].setAttribute('color', color) + def setColor(self, color): """Set the color of the iso-surface @@ -310,15 +320,15 @@ class Isosurface(Item3D): color = rgba(color) if color != self._color: self._color = color - primitive = self._getScenePrimitive() - if len(primitive.children) != 0: - primitive.children[0].setAttribute('color', self._color) + self._updateColor(self._color) self._updated(ItemChangedType.COLOR) - def _updateScenePrimitive(self): - """Update underlying mesh""" - self._getScenePrimitive().children = [] + def _computeIsosurface(self): + """Compute isosurface for current state. + :return: (vertices, normals, indices) arrays + :rtype: List[Union[None,numpy.ndarray]] + """ data = self.getData(copy=False) if data is None: @@ -349,24 +359,31 @@ class Isosurface(Item3D): self._level = level self._updated(Item3DChangedType.ISO_LEVEL) - if not numpy.isfinite(self._level): - return + if numpy.isfinite(self._level): + st = time.time() + vertices, normals, indices = MarchingCubes( + data, + isolevel=self._level) + _logger.info('Computed iso-surface in %f s.', time.time() - st) - st = time.time() - vertices, normals, indices = MarchingCubes( - data, - isolevel=self._level) - _logger.info('Computed iso-surface in %f s.', time.time() - st) + if len(vertices) != 0: + return vertices, normals, indices - if len(vertices) == 0: - return - else: - mesh = primitives.Mesh3D(vertices, - colors=self._color, - normals=normals, - mode='triangles', - indices=indices) - self._getScenePrimitive().children = [mesh] + return None, None, None + + def _updateScenePrimitive(self): + """Update underlying mesh""" + self._getScenePrimitive().children = [] + + vertices, normals, indices = self._computeIsosurface() + if vertices is not None: + mesh = primitives.Mesh3D(vertices, + colors=self._color, + normals=normals, + mode='triangles', + indices=indices, + copy=False) + self._getScenePrimitive().children = [mesh] def _pickFull(self, context): """Perform picking in this item at given widget position. @@ -677,17 +694,39 @@ class ComplexCutPlane(CutPlane, ComplexMixIn): super(ComplexCutPlane, self)._updated(event) -class ComplexIsosurface(Isosurface): +class ComplexIsosurface(Isosurface, ComplexMixIn, ColormapMixIn): """Class representing an iso-surface in a :class:`ComplexField3D` item. :param parent: The DataItem3D this iso-surface belongs to """ + _SUPPORTED_COMPLEX_MODES = \ + (ComplexMixIn.ComplexMode.NONE,) + ComplexMixIn._SUPPORTED_COMPLEX_MODES + """Overrides supported ComplexMode""" + def __init__(self, parent): - super(ComplexIsosurface, self).__init__(parent) + ComplexMixIn.__init__(self) + ColormapMixIn.__init__(self, function.Colormap()) + Isosurface.__init__(self, parent=parent) + self.setComplexMode(self.ComplexMode.NONE) + + def _updateColor(self, color): + """Handle update of color + + :param List[float] color: RGBA channels in [0, 1] + """ + primitive = self._getScenePrimitive() + if (len(primitive.children) != 0 and + isinstance(primitive.children[0], primitives.ColormapMesh3D)): + primitive.children[0].alpha = self._color[3] + else: + super(ComplexIsosurface, self)._updateColor(color) def _syncDataWithParent(self): """Synchronize this instance data with that of its parent""" + if self.getComplexMode() != self.ComplexMode.NONE: + self._setRangeFromData(self.getColormappedData(copy=False)) + parent = self.parent() if parent is None: self._data = None @@ -702,6 +741,67 @@ class ComplexIsosurface(Isosurface): self._syncDataWithParent() super(ComplexIsosurface, self)._parentChanged(event) + def getColormappedData(self, copy=True): + """Return 3D dataset used to apply the colormap on the isosurface. + + This depends on :meth:`getComplexMode`. + + :param bool copy: + True (default) to get a copy, + False to get the internal data (DO NOT modify!) + :return: The data set (or None if not set) + :rtype: Union[numpy.ndarray,None] + """ + if self.getComplexMode() == self.ComplexMode.NONE: + return None + else: + parent = self.parent() + if parent is None: + return None + else: + return parent.getData(mode=self.getComplexMode(), copy=copy) + + def _updated(self, event=None): + """Handle update of the isosurface (and take care of mode change) + + :param ItemChangedType event: The kind of update + """ + if (event == ItemChangedType.COMPLEX_MODE and + self.getComplexMode() != self.ComplexMode.NONE): + self._setRangeFromData(self.getColormappedData(copy=False)) + + if event in (ItemChangedType.COMPLEX_MODE, + ItemChangedType.COLORMAP, + Item3DChangedType.INTERPOLATION): + self._updateScenePrimitive() + super(ComplexIsosurface, self)._updated(event) + + def _updateScenePrimitive(self): + """Update underlying mesh""" + if self.getComplexMode() == self.ComplexMode.NONE: + super(ComplexIsosurface, self)._updateScenePrimitive() + + else: # Specific display for colormapped isosurface + self._getScenePrimitive().children = [] + + values = self.getColormappedData(copy=False) + if values is not None: + vertices, normals, indices = self._computeIsosurface() + if vertices is not None: + values = interp3d(values, vertices, method='linear_omp') + # TODO reuse isosurface when only color changes... + + mesh = primitives.ColormapMesh3D( + vertices, + value=values.reshape(-1, 1), + colormap=self._getSceneColormap(), + normal=normals, + mode='triangles', + indices=indices, + copy=False) + mesh.alpha = self._color[3] + self._getScenePrimitive().children = [mesh] + class ComplexField3D(ScalarField3D, ComplexMixIn): """3D complex field on a regular grid. @@ -720,6 +820,7 @@ class ComplexField3D(ScalarField3D, ComplexMixIn): @docstring(ComplexMixIn) def setComplexMode(self, mode): + mode = ComplexMixIn.ComplexMode.from_value(mode) if mode != self.getComplexMode(): self.clearIsosurfaces() # Reset isosurfaces ComplexMixIn.setComplexMode(self, mode) diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py index 08724ba..7db61e8 100644 --- a/silx/gui/plot3d/scene/primitives.py +++ b/silx/gui/plot3d/scene/primitives.py @@ -1874,6 +1874,8 @@ class ColormapMesh3D(Geometry): } """, string.Template(""" + uniform float alpha; + varying vec4 vCameraPosition; varying vec3 vPosition; varying vec3 vNormal; @@ -1889,6 +1891,7 @@ class ColormapMesh3D(Geometry): vec4 color = $colormapCall(vValue); gl_FragColor = $lightingCall(color, vPosition, vNormal); + gl_FragColor.a *= alpha; $scenePostCall(vCameraPosition); } @@ -1908,6 +1911,7 @@ class ColormapMesh3D(Geometry): value=value, copy=copy) + self._alpha = 1.0 self._lineWidth = 1.0 self._lineSmooth = True self._culling = None @@ -1922,6 +1926,10 @@ class ColormapMesh3D(Geometry): converter=bool, doc="Smooth line rendering enabled (bool, default: True)") + alpha = event.notifyProperty( + '_alpha', converter=float, + doc="Transparency of the mesh, float in [0, 1]") + @property def culling(self): """Face culling (str) @@ -1978,6 +1986,7 @@ class ColormapMesh3D(Geometry): program.setUniformMatrix('transformMat', ctx.objectToCamera.matrix, safe=True) + gl.glUniform1f(program.uniforms['alpha'], self._alpha) if self.drawMode in self._LINE_MODES: gl.glLineWidth(self.lineWidth) diff --git a/silx/gui/plot3d/tools/PositionInfoWidget.py b/silx/gui/plot3d/tools/PositionInfoWidget.py index fc86a7f..52a6163 100644 --- a/silx/gui/plot3d/tools/PositionInfoWidget.py +++ b/silx/gui/plot3d/tools/PositionInfoWidget.py @@ -189,7 +189,7 @@ class PositionInfoWidget(qt.QWidget): return # No picked item item = picking.getItem() - self._itemLabel.setText(item.getLabel()) + self._itemLabel.setText(item.getName()) positions = picking.getPositions('scene', copy=False) x, y, z = positions[0] self._xLabel.setText("%g" % x) diff --git a/silx/gui/qt/_utils.py b/silx/gui/qt/_utils.py index 912f08c..f5915ae 100644 --- a/silx/gui/qt/_utils.py +++ b/silx/gui/qt/_utils.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -29,21 +29,22 @@ __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "30/11/2016" -import sys -from . import _qt as qt + +import sys as _sys +from . import _qt def supportedImageFormats(): """Return a set of string of file format extensions supported by the Qt runtime.""" - if sys.version_info[0] < 3 or qt.BINDING == 'PySide': + if _sys.version_info[0] < 3 or _qt.BINDING == 'PySide': convert = str - elif qt.BINDING == 'PySide2': + elif _qt.BINDING == 'PySide2': def convert(data): return str(data.data(), 'ascii') else: convert = lambda data: str(data, 'ascii') - formats = qt.QImageReader.supportedImageFormats() + formats = _qt.QImageReader.supportedImageFormats() return set([convert(data) for data in formats]) @@ -59,7 +60,7 @@ def silxGlobalThreadPool(): """ global __globalThreadPoolInstance if __globalThreadPoolInstance is None: - tp = qt.QThreadPool() + tp = _qt.QThreadPool() # This pointless command fixes a segfault with PyQt 5.9.1 on Windows tp.setMaxThreadCount(tp.maxThreadCount()) __globalThreadPoolInstance = tp diff --git a/silx/gui/test/test_colors.py b/silx/gui/test/test_colors.py index 6e4fc73..12387a3 100644..100755 --- a/silx/gui/test/test_colors.py +++ b/silx/gui/test/test_colors.py @@ -39,28 +39,39 @@ from silx.gui.colors import Colormap from silx.utils.exceptions import NotEditableError -class TestRGBA(ParametricTestCase): +class TestColor(ParametricTestCase): """Basic tests of rgba function""" + TEST_COLORS = { # name: (colors, expected values) + 'blue': ('blue', (0., 0., 1., 1.)), + '#010203': ('#010203', (1. / 255., 2. / 255., 3. / 255., 1.)), + '#01020304': ('#01020304', (1. / 255., 2. / 255., 3. / 255., 4. / 255.)), + '3 x uint8': (numpy.array((1, 255, 0), dtype=numpy.uint8), + (1 / 255., 1., 0., 1.)), + '4 x uint8': (numpy.array((1, 255, 0, 1), dtype=numpy.uint8), + (1 / 255., 1., 0., 1 / 255.)), + '3 x float overflow': ((3., 0.5, 1.), (1., 0.5, 1., 1.)), + } + def testRGBA(self): """"Test rgba function with accepted values""" - tests = { # name: (colors, expected values) - 'blue': ('blue', (0., 0., 1., 1.)), - '#010203': ('#010203', (1. / 255., 2. / 255., 3. / 255., 1.)), - '#01020304': ('#01020304', (1. / 255., 2. / 255., 3. / 255., 4. / 255.)), - '3 x uint8': (numpy.array((1, 255, 0), dtype=numpy.uint8), - (1 / 255., 1., 0., 1.)), - '4 x uint8': (numpy.array((1, 255, 0, 1), dtype=numpy.uint8), - (1 / 255., 1., 0., 1 / 255.)), - '3 x float overflow': ((3., 0.5, 1.), (1., 0.5, 1., 1.)), - } - - for name, test in tests.items(): + for name, test in self.TEST_COLORS.items(): color, expected = test with self.subTest(msg=name): result = colors.rgba(color) self.assertEqual(result, expected) + def testQColor(self): + """"Test getQColor function with accepted values""" + for name, test in self.TEST_COLORS.items(): + color, expected = test + with self.subTest(msg=name): + result = colors.asQColor(color) + self.assertAlmostEqual(result.redF(), expected[0], places=4) + self.assertAlmostEqual(result.greenF(), expected[1], places=4) + self.assertAlmostEqual(result.blueF(), expected[2], places=4) + self.assertAlmostEqual(result.alphaF(), expected[3], places=4) + class TestApplyColormapToData(ParametricTestCase): """Tests of applyColormapToData function""" @@ -477,7 +488,7 @@ def suite(): test_suite = unittest.TestSuite() loadTests = unittest.defaultTestLoader.loadTestsFromTestCase test_suite.addTest(loadTests(TestApplyColormapToData)) - test_suite.addTest(loadTests(TestRGBA)) + test_suite.addTest(loadTests(TestColor)) test_suite.addTest(loadTests(TestDictAPI)) test_suite.addTest(loadTests(TestObjectAPI)) test_suite.addTest(loadTests(TestPreferredColormaps)) diff --git a/silx/gui/utils/__init__.py b/silx/gui/utils/__init__.py index 51c4fac..a4e442f 100644..100755 --- a/silx/gui/utils/__init__.py +++ b/silx/gui/utils/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -27,3 +27,33 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "09/03/2018" + + +import contextlib as _contextlib + + +@_contextlib.contextmanager +def blockSignals(*objs): + """Context manager blocking signals of QObjects. + + It restores previous state when leaving. + + :param qt.QObject objs: QObjects for which to block signals + """ + blocked = [(obj, obj.blockSignals(True)) for obj in objs] + try: + yield + finally: + for obj, previous in blocked: + obj.blockSignals(previous) + + +def getQEventName(eventType): + """ + Returns the name of a QEvent. + + :param Union[int,qt.QEvent] eventType: A QEvent or a QEvent type. + :returns: str + """ + from . import qtutils + return qtutils.getQEventName(eventType) diff --git a/silx/gui/utils/qtutils.py b/silx/gui/utils/qtutils.py new file mode 100755 index 0000000..eb823a8 --- /dev/null +++ b/silx/gui/utils/qtutils.py @@ -0,0 +1,170 @@ +from silx.gui import qt
+
+
+QT_EVENT_NAMES = {
+ 0: "None",
+ 114: "ActionAdded",
+ 113: "ActionChanged",
+ 115: "ActionRemoved",
+ 99: "ActivationChange",
+ 121: "ApplicationActivate",
+ # ApplicationActivate: "ApplicationActivated",
+ 122: "ApplicationDeactivate",
+ 36: "ApplicationFontChange",
+ 37: "ApplicationLayoutDirectionChange",
+ 38: "ApplicationPaletteChange",
+ 214: "ApplicationStateChange",
+ 35: "ApplicationWindowIconChange",
+ 68: "ChildAdded",
+ 69: "ChildPolished",
+ 71: "ChildRemoved",
+ 40: "Clipboard",
+ 19: "Close",
+ 200: "CloseSoftwareInputPanel",
+ 178: "ContentsRectChange",
+ 82: "ContextMenu",
+ 183: "CursorChange",
+ 52: "DeferredDelete",
+ 60: "DragEnter",
+ 62: "DragLeave",
+ 61: "DragMove",
+ 63: "Drop",
+ 170: "DynamicPropertyChange",
+ 98: "EnabledChange",
+ 10: "Enter",
+ 150: "EnterEditFocus",
+ 124: "EnterWhatsThisMode",
+ 206: "Expose",
+ 116: "FileOpen",
+ 8: "FocusIn",
+ 9: "FocusOut",
+ 23: "FocusAboutToChange",
+ 97: "FontChange",
+ 198: "Gesture",
+ 202: "GestureOverride",
+ 188: "GrabKeyboard",
+ 186: "GrabMouse",
+ 159: "GraphicsSceneContextMenu",
+ 164: "GraphicsSceneDragEnter",
+ 166: "GraphicsSceneDragLeave",
+ 165: "GraphicsSceneDragMove",
+ 167: "GraphicsSceneDrop",
+ 163: "GraphicsSceneHelp",
+ 160: "GraphicsSceneHoverEnter",
+ 162: "GraphicsSceneHoverLeave",
+ 161: "GraphicsSceneHoverMove",
+ 158: "GraphicsSceneMouseDoubleClick",
+ 155: "GraphicsSceneMouseMove",
+ 156: "GraphicsSceneMousePress",
+ 157: "GraphicsSceneMouseRelease",
+ 182: "GraphicsSceneMove",
+ 181: "GraphicsSceneResize",
+ 168: "GraphicsSceneWheel",
+ 18: "Hide",
+ 27: "HideToParent",
+ 127: "HoverEnter",
+ 128: "HoverLeave",
+ 129: "HoverMove",
+ 96: "IconDrag",
+ 101: "IconTextChange",
+ 83: "InputMethod",
+ 207: "InputMethodQuery",
+ 169: "KeyboardLayoutChange",
+ 6: "KeyPress",
+ 7: "KeyRelease",
+ 89: "LanguageChange",
+ 90: "LayoutDirectionChange",
+ 76: "LayoutRequest",
+ 11: "Leave",
+ 151: "LeaveEditFocus",
+ 125: "LeaveWhatsThisMode",
+ 88: "LocaleChange",
+ 176: "NonClientAreaMouseButtonDblClick",
+ 174: "NonClientAreaMouseButtonPress",
+ 175: "NonClientAreaMouseButtonRelease",
+ 173: "NonClientAreaMouseMove",
+ 177: "MacSizeChange",
+ 43: "MetaCall",
+ 102: "ModifiedChange",
+ 4: "MouseButtonDblClick",
+ 2: "MouseButtonPress",
+ 3: "MouseButtonRelease",
+ 5: "MouseMove",
+ 109: "MouseTrackingChange",
+ 13: "Move",
+ 197: "NativeGesture",
+ 208: "OrientationChange",
+ 12: "Paint",
+ 39: "PaletteChange",
+ 131: "ParentAboutToChange",
+ 21: "ParentChange",
+ 212: "PlatformPanel",
+ 217: "PlatformSurface",
+ 75: "Polish",
+ 74: "PolishRequest",
+ 123: "QueryWhatsThis",
+ 106: "ReadOnlyChange",
+ 199: "RequestSoftwareInputPanel",
+ 14: "Resize",
+ 204: "ScrollPrepare",
+ 205: "Scroll",
+ 117: "Shortcut",
+ 51: "ShortcutOverride",
+ 17: "Show",
+ 26: "ShowToParent",
+ 50: "SockAct",
+ 192: "StateMachineSignal",
+ 193: "StateMachineWrapped",
+ 112: "StatusTip",
+ 100: "StyleChange",
+ 87: "TabletMove",
+ 92: "TabletPress",
+ 93: "TabletRelease",
+ 171: "TabletEnterProximity",
+ 172: "TabletLeaveProximity",
+ 219: "TabletTrackingChange",
+ 22: "ThreadChange",
+ 1: "Timer",
+ 120: "ToolBarChange",
+ 110: "ToolTip",
+ 184: "ToolTipChange",
+ 194: "TouchBegin",
+ 209: "TouchCancel",
+ 196: "TouchEnd",
+ 195: "TouchUpdate",
+ 189: "UngrabKeyboard",
+ 187: "UngrabMouse",
+ 78: "UpdateLater",
+ 77: "UpdateRequest",
+ 111: "WhatsThis",
+ 118: "WhatsThisClicked",
+ 31: "Wheel",
+ 132: "WinEventAct",
+ 24: "WindowActivate",
+ 103: "WindowBlocked",
+ 25: "WindowDeactivate",
+ 34: "WindowIconChange",
+ 105: "WindowStateChange",
+ 33: "WindowTitleChange",
+ 104: "WindowUnblocked",
+ 203: "WinIdChange",
+ 126: "ZOrderChange",
+ 65535: "MaxUser",
+}
+
+
+def getQEventName(eventType):
+ """
+ Returns the name of a QEvent.
+
+ :param Union[int,qt.QEvent] eventType: A QEvent or a QEvent type.
+ :returns: str
+ """
+ if isinstance(eventType, qt.QEvent):
+ eventType = eventType.type()
+ if 1000 <= eventType <= 65535:
+ return "User_%d" % eventType
+ name = QT_EVENT_NAMES.get(eventType, None)
+ if name is not None:
+ return name
+ return "Unknown_%d" % eventType
diff --git a/silx/gui/utils/test/__init__.py b/silx/gui/utils/test/__init__.py index 9e50170..d500c05 100644..100755 --- a/silx/gui/utils/test/__init__.py +++ b/silx/gui/utils/test/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -34,15 +34,21 @@ import unittest from . import test_async from . import test_image +from . import test_qtutils +from . import test_testutils +from . import test def suite(): """Test suite for module silx.image.test""" test_suite = unittest.TestSuite() + test_suite.addTest(test.suite()) test_suite.addTest(test_async.suite()) test_suite.addTest(test_image.suite()) + test_suite.addTest(test_qtutils.suite()) + test_suite.addTest(test_testutils.suite()) return test_suite -if __name__ == '__main__': - unittest.main(defaultTest='suite') +if __name__ == "__main__": + unittest.main(defaultTest="suite") diff --git a/silx/gui/utils/test/test.py b/silx/gui/utils/test/test.py new file mode 100644 index 0000000..8bba852 --- /dev/null +++ b/silx/gui/utils/test/test.py @@ -0,0 +1,76 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of functions available in silx.gui.utils module.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/08/2019" + + +import unittest +from silx.gui import qt +from silx.gui.utils.testutils import TestCaseQt, SignalListener + +from silx.gui.utils import blockSignals + + +class TestBlockSignals(TestCaseQt): + """Test blockSignals context manager""" + + def _test(self, *objs): + """Test for provided objects""" + listener = SignalListener() + for obj in objs: + obj.objectNameChanged.connect(listener) + obj.setObjectName("received") + + with blockSignals(*objs): + for obj in objs: + obj.setObjectName("silent") + + self.assertEqual(listener.arguments(), [("received",)] * len(objs)) + + @unittest.skipUnless(qt.BINDING in ('PyQt5', 'PySide2'), 'Qt5 only test') + def testManyObjects(self): + """Test blockSignals with 2 QObjects""" + self._test(qt.QObject(), qt.QObject()) + + @unittest.skipUnless(qt.BINDING in ('PyQt5', 'PySide2'), 'Qt5 only test') + def testOneObject(self): + """Test blockSignals context manager with a single QObject""" + self._test(qt.QObject()) + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase( + TestBlockSignals)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/utils/test/test_qtutils.py b/silx/gui/utils/test/test_qtutils.py new file mode 100755 index 0000000..043a0a6 --- /dev/null +++ b/silx/gui/utils/test/test_qtutils.py @@ -0,0 +1,75 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of functions available in silx.gui.utils module.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/08/2019" + + +import unittest +from silx.gui import qt +from silx.gui import utils +from silx.gui.utils.testutils import TestCaseQt + + +class TestQEventName(TestCaseQt): + """Test QEvent names""" + + def testNoneType(self): + result = utils.getQEventName(0) + self.assertEqual(result, "None") + + def testNoneEvent(self): + event = qt.QEvent(qt.QEvent.Type(0)) + result = utils.getQEventName(event) + self.assertEqual(result, "None") + + def testUserType(self): + result = utils.getQEventName(1050) + self.assertIn("User", result) + self.assertIn("1050", result) + + def testQtUndefinedType(self): + result = utils.getQEventName(900) + self.assertIn("Unknown", result) + self.assertIn("900", result) + + def testUndefinedType(self): + result = utils.getQEventName(70000) + self.assertIn("Unknown", result) + self.assertIn("70000", result) + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(TestQEventName)) + return test_suite + + +if __name__ == "__main__": + unittest.main(defaultTest="suite") diff --git a/silx/gui/utils/test/test_testutils.py b/silx/gui/utils/test/test_testutils.py new file mode 100644 index 0000000..8a58e6e --- /dev/null +++ b/silx/gui/utils/test/test_testutils.py @@ -0,0 +1,55 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of testutils module.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "16/01/2017" + +import unittest +import sys + +from silx.gui import qt +from ..testutils import TestCaseQt + + +class TestOutcome(unittest.TestCase): + """Tests conversion of QImage to/from numpy array.""" + + @unittest.skipIf(sys.version_info.major <= 2, 'Python3 only') + def testNoneOutcome(self): + test = TestCaseQt() + test._currentTestSucceeded() + + +def suite(): + test_suite = unittest.TestSuite() + loader = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loader(TestOutcome)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/utils/testutils.py b/silx/gui/utils/testutils.py index d7f2f41..14dcc3f 100644 --- a/silx/gui/utils/testutils.py +++ b/silx/gui/utils/testutils.py @@ -155,7 +155,8 @@ class TestCaseQt(unittest.TestCase): if hasattr(self, '_outcome'): # For Python >= 3.4 result = self.defaultTestResult() # these 2 methods have no side effects - self._feedErrorsToResult(result, self._outcome.errors) + if hasattr(self._outcome, 'errors'): + self._feedErrorsToResult(result, self._outcome.errors) else: # For Python < 3.4 result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups) diff --git a/silx/gui/widgets/ColormapNameComboBox.py b/silx/gui/widgets/ColormapNameComboBox.py new file mode 100644 index 0000000..fa8faf1 --- /dev/null +++ b/silx/gui/widgets/ColormapNameComboBox.py @@ -0,0 +1,166 @@ +# 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 QComboBox to display prefered colormaps +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"] +__license__ = "MIT" +__date__ = "27/11/2018" + + +import logging +import numpy + +from .. import qt +from .. import colors as colors_mdl + +_logger = logging.getLogger(__name__) + + +_colormapIconPreview = {} + + +class ColormapNameComboBox(qt.QComboBox): + def __init__(self, parent=None): + qt.QComboBox.__init__(self, parent) + self.__initItems() + + LUT_NAME = qt.Qt.UserRole + 1 + LUT_COLORS = qt.Qt.UserRole + 2 + + def __initItems(self): + for colormapName in colors_mdl.preferredColormaps(): + index = self.count() + self.addItem(str.title(colormapName)) + self.setItemIcon(index, self.getIconPreview(name=colormapName)) + self.setItemData(index, colormapName, role=self.LUT_NAME) + + def getIconPreview(self, name=None, colors=None): + """Return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str name: Name of the LUT + :param numpy.ndarray colors: Colors identify the LUT + :rtype: qt.QIcon + """ + if name is not None: + iconKey = name + else: + iconKey = tuple(colors) + icon = _colormapIconPreview.get(iconKey, None) + if icon is None: + icon = self.createIconPreview(name, colors) + _colormapIconPreview[iconKey] = icon + return icon + + def createIconPreview(self, name=None, colors=None): + """Create and return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str name: Name of the LUT + :param numpy.ndarray colors: Colors identify the LUT + :rtype: qt.QIcon + """ + colormap = colors_mdl.Colormap(name) + size = 32 + if name is not None: + lut = colormap.getNColors(size) + else: + lut = colors + if len(lut) > size: + # Down sample + step = int(len(lut) / size) + lut = lut[::step] + elif len(lut) < size: + # Over sample + indexes = numpy.arange(size) / float(size) * (len(lut) - 1) + indexes = indexes.astype("int") + lut = lut[indexes] + if lut is None or len(lut) == 0: + return qt.QIcon() + + pixmap = qt.QPixmap(size, size) + painter = qt.QPainter(pixmap) + for i in range(size): + rgb = lut[i] + r, g, b = rgb[0], rgb[1], rgb[2] + painter.setPen(qt.QColor(r, g, b)) + painter.drawPoint(qt.QPoint(i, 0)) + + painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1) + painter.end() + + return qt.QIcon(pixmap) + + def getCurrentName(self): + return self.itemData(self.currentIndex(), self.LUT_NAME) + + def getCurrentColors(self): + return self.itemData(self.currentIndex(), self.LUT_COLORS) + + def findLutName(self, name): + return self.findData(name, role=self.LUT_NAME) + + def findLutColors(self, lut): + for index in range(self.count()): + if self.itemData(index, role=self.LUT_NAME) is not None: + continue + colors = self.itemData(index, role=self.LUT_COLORS) + if colors is None: + continue + if numpy.array_equal(colors, lut): + return index + return -1 + + def setCurrentLut(self, colormap): + name = colormap.getName() + if name is not None: + self._setCurrentName(name) + else: + lut = colormap.getColormapLUT() + self._setCurrentLut(lut) + + def _setCurrentLut(self, lut): + index = self.findLutColors(lut) + if index == -1: + index = self.count() + self.addItem("Custom") + self.setItemIcon(index, self.getIconPreview(colors=lut)) + self.setItemData(index, None, role=self.LUT_NAME) + self.setItemData(index, lut, role=self.LUT_COLORS) + self.setCurrentIndex(index) + + def _setCurrentName(self, name): + index = self.findLutName(name) + if index < 0: + index = self.count() + self.addItem(str.title(name)) + self.setItemIcon(index, self.getIconPreview(name=name)) + self.setItemData(index, name, role=self.LUT_NAME) + self.setCurrentIndex(index) diff --git a/silx/gui/widgets/FrameBrowser.py b/silx/gui/widgets/FrameBrowser.py index b4f88fc..671991f 100644 --- a/silx/gui/widgets/FrameBrowser.py +++ b/silx/gui/widgets/FrameBrowser.py @@ -95,7 +95,7 @@ class FrameBrowser(qt.QWidget): else: first, last = 0, n - self._lineEdit.setFixedWidth(self._lineEdit.fontMetrics().width('%05d' % last)) + self._lineEdit.setFixedWidth(self._lineEdit.fontMetrics().boundingRect('%05d' % last).width()) validator = qt.QIntValidator(first, last, self._lineEdit) self._lineEdit.setValidator(validator) self._lineEdit.setText("%d" % first) diff --git a/silx/gui/widgets/LegendIconWidget.py b/silx/gui/widgets/LegendIconWidget.py new file mode 100755 index 0000000..1a403cb --- /dev/null +++ b/silx/gui/widgets/LegendIconWidget.py @@ -0,0 +1,513 @@ +# 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 a symbol (marker symbol, line style and color) to identify +an item displayed by a plot. +""" + +__authors__ = ["V.A. Sole", "T. Rueter", "T. Vincent"] +__license__ = "MIT" +__data__ = "11/11/2019" + + +import logging + +import numpy + +from .. import qt, colors + + +_logger = logging.getLogger(__name__) + + +# Build all symbols +# Courtesy of the pyqtgraph project + +_Symbols = None +""""Cache supported symbols as Qt paths""" + + +_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""" + + +_colormapImage = {} +"""Store cached pixmap""" +# FIXME: Could be better to use a LRU dictionary + +_COLORMAP_PIXMAP_SIZE = 32 +"""Size of the cached pixmaps for the colormaps""" + + +def _initSymbols(): + """Init the cached symbol structure if not yet done.""" + global _Symbols + if _Symbols is not None: + return + + 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)) + + _Symbols = symbols + + +class LegendIconWidget(qt.QWidget): + """Object displaying linestyle and symbol of plots. + + :param QWidget parent: See :class:`QWidget` + """ + + def __init__(self, parent=None): + super(LegendIconWidget, self).__init__(parent) + _initSymbols() + + # Visibilities + self.showLine = True + self.showSymbol = True + self.showColormap = 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) + self.symbolColormap = None + """Name or array of colors""" + + self.colormap = None + """Name or array of colors""" + + # Control widget size: sizeHint "is the only acceptable + # alternative, so the widget can never grow or shrink" + # (c.f. Qt Doc, enum QSizePolicy::Policy) + self.setSizePolicy(qt.QSizePolicy.Fixed, + qt.QSizePolicy.Fixed) + + def sizeHint(self): + return qt.QSize(50, 15) + + def setSymbol(self, symbol): + """Set the 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() + + def setSymbolColor(self, color): + """ + :param color: determines the symbol color + :type style: qt.QColor + """ + self.symbolColor = qt.QColor(color) + self.update() + + # Modify Line + + def setLineColor(self, color): + self.lineColor = qt.QColor(color) + self.update() + + def setLineWidth(self, width): + self.lineWidth = float(width) + self.update() + + 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] + self.update() + + def _toLut(self, colormap): + """Returns an internal LUT object used by this widget to manage + a colormap LUT. + + If the argument is a `Colormap` object, only the current state will be + displayed. The object itself will not be stored, and further changes + of this `Colormap` will not update this widget. + + :param Union[str,numpy.ndarray,Colormap] colormap: The colormap to + display + :rtype: Union[None,str,numpy.ndarray] + """ + if isinstance(colormap, colors.Colormap): + # Helper to allow to support Colormap objects + c = colormap.getName() + if c is None: + c = colormap.getNColors() + colormap = c + + return colormap + + def setColormap(self, colormap): + """Set the colormap to display + + If the argument is a `Colormap` object, only the current state will be + displayed. The object itself will not be stored, and further changes + of this `Colormap` will not update this widget. + + :param Union[str,numpy.ndarray,Colormap] colormap: The colormap to + display + """ + colormap = self._toLut(colormap) + + if colormap is None: + if self.colormap is None: + return + self.colormap = None + self.update() + return + + if numpy.array_equal(self.colormap, colormap): + # This also works with strings + return + + self.colormap = colormap + self.update() + + def getColormap(self): + """Returns the used colormap. + + If the argument was set with a `Colormap` object, this function will + returns the LUT, represented by a string name or by an array or colors. + + :returns: Union[None,str,numpy.ndarray,Colormap] + """ + return self.colormap + + def setSymbolColormap(self, colormap): + """Set the colormap to display a symbol + + If the argument is a `Colormap` object, only the current state will be + displayed. The object itself will not be stored, and further changes + of this `Colormap` will not update this widget. + + :param Union[str,numpy.ndarray,Colormap] colormap: The colormap to + display + """ + colormap = self._toLut(colormap) + + if colormap is None: + if self.colormap is None: + return + self.symbolColormap = None + self.update() + return + + if numpy.array_equal(self.symbolColormap, colormap): + # This also works with strings + return + + self.symbolColormap = colormap + self.update() + + def getSymbolColormap(self): + """Returns the used symbol colormap. + + If the argument was set with a `Colormap` object, this function will + returns the LUT, represented by a string name or by an array or colors. + + :returns: Union[None,str,numpy.ndarray,Colormap] + """ + return self.colormap + + # 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 + 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)) + + if self.showColormap: + if self.colormap is not None: + if self.isEnabled(): + image = self.getColormapImage(self.colormap) + else: + image = self.getGrayedColormapImage(self.colormap) + pixmapRect = qt.QRect(0, 0, _COLORMAP_PIXMAP_SIZE, 1) + widthMargin = 0 + halfHeight = 4 + dest = qt.QRect( + rect.left() + widthMargin, + rect.center().y() - halfHeight + 1, + rect.width() - widthMargin * 2, + halfHeight * 2, + ) + painter.drawImage(dest, image, pixmapRect) + + painter.scale(scale, scale) + + 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)) + + isValidSymbol = (len(self.symbol) and + self.symbol not in _NoSymbols) + if self.showSymbol and isValidSymbol: + if self.symbolColormap is None: + # 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)) + else: + nbSymbols = int(ratio + 2) + for i in range(nbSymbols): + if self.isEnabled(): + image = self.getColormapImage(self.symbolColormap) + else: + image = self.getGrayedColormapImage(self.symbolColormap) + pos = int((_COLORMAP_PIXMAP_SIZE / nbSymbols) * i) + pos = numpy.clip(pos, 0, _COLORMAP_PIXMAP_SIZE-1) + color = image.pixelColor(pos, 0) + delta = qt.QPointF(ratio * ((i - (nbSymbols-1)/2) / nbSymbols), 0) + + symbolPath = qt.QPainterPath(_Symbols[self.symbol]) + symbolPath.translate(symbolOffset + delta) + symbolBrush = qt.QBrush(color, 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() + + # Helpers + + @staticmethod + def isEmptySymbol(symbol): + """Returns True if this symbol description will result in an empty + symbol.""" + return symbol in _NoSymbols + + @staticmethod + def isEmptyLineStyle(lineStyle): + """Returns True if this line style description will result in an empty + line.""" + return lineStyle in _NoLineStyle + + @staticmethod + def _getColormapKey(colormap): + """ + Returns the key used to store the image in the data storage + """ + if isinstance(colormap, numpy.ndarray): + key = tuple(colormap) + else: + key = colormap + return key + + @staticmethod + def getGrayedColormapImage(colormap): + """Return a grayed version image preview from a LUT name. + + This images are cached into a global structure. + + :param Union[str,numpy.ndarray] colormap: Description of the LUT + :rtype: qt.QImage + """ + key = LegendIconWidget._getColormapKey(colormap) + grayKey = (key, "gray") + image = _colormapImage.get(grayKey, None) + if image is None: + image = LegendIconWidget.getColormapImage(colormap) + image = image.convertToFormat(qt.QImage.Format_Grayscale8) + _colormapImage[grayKey] = image + return image + + @staticmethod + def getColormapImage(colormap): + """Return an image preview from a LUT name. + + This images are cached into a global structure. + + :param Union[str,numpy.ndarray] colormap: Description of the LUT + :rtype: qt.QImage + """ + key = LegendIconWidget._getColormapKey(colormap) + image = _colormapImage.get(key, None) + if image is None: + image = LegendIconWidget.createColormapImage(colormap) + _colormapImage[key] = image + return image + + @staticmethod + def createColormapImage(colormap): + """Create and return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param Union[str,numpy.ndarray] colormap: Description of the LUT + :rtype: qt.QImage + """ + size = _COLORMAP_PIXMAP_SIZE + if isinstance(colormap, numpy.ndarray): + lut = colormap + if len(lut) > size: + # Down sample + step = int(len(lut) / size) + lut = lut[::step] + elif len(lut) < size: + # Over sample + indexes = numpy.arange(size) / float(size) * (len(lut) - 1) + indexes = indexes.astype("int") + lut = lut[indexes] + else: + colormap = colors.Colormap(colormap) + lut = colormap.getNColors(size) + + if lut is None or len(lut) == 0: + return qt.QIcon() + + pixmap = qt.QPixmap(size, 1) + painter = qt.QPainter(pixmap) + for i in range(size): + rgb = lut[i] + r, g, b = rgb[0], rgb[1], rgb[2] + painter.setPen(qt.QColor(r, g, b)) + painter.drawPoint(qt.QPoint(i, 0)) + painter.end() + return pixmap.toImage() diff --git a/silx/gui/widgets/RangeSlider.py b/silx/gui/widgets/RangeSlider.py index 0cf195c..c352147 100644 --- a/silx/gui/widgets/RangeSlider.py +++ b/silx/gui/widgets/RangeSlider.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# Copyright (c) 2015-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -466,7 +466,7 @@ class RangeSlider(qt.QWidget): :param Union[numpy.ndarray,None] profile: 1D array of values to display - :param Union[Colormap,str] colormap: + :param Union[~silx.gui.colors.Colormap,str] colormap: The colormap name or object to convert profile values to colors """ if profile is None: |