summaryrefslogtreecommitdiff
path: root/silx/gui
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui')
-rw-r--r--silx/gui/_glutils/OpenGLWidget.py11
-rwxr-xr-x[-rw-r--r--]silx/gui/colors.py27
-rw-r--r--silx/gui/data/DataViewer.py53
-rw-r--r--silx/gui/data/DataViews.py3
-rw-r--r--silx/gui/data/NumpyAxesSelector.py236
-rw-r--r--silx/gui/data/test/test_numpyaxesselector.py4
-rw-r--r--silx/gui/dialog/ColormapDialog.py125
-rwxr-xr-x[-rw-r--r--]silx/gui/hdf5/Hdf5Item.py152
-rwxr-xr-x[-rw-r--r--]silx/gui/hdf5/test/test_hdf5.py4
-rw-r--r--silx/gui/plot/ColorBar.py2
-rw-r--r--silx/gui/plot/CurvesROIWidget.py59
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/LegendSelector.py207
-rw-r--r--silx/gui/plot/PlotInteraction.py208
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/PlotWidget.py177
-rw-r--r--silx/gui/plot/PlotWindow.py49
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py7
-rw-r--r--silx/gui/plot/ScatterView.py45
-rw-r--r--silx/gui/plot/StackView.py32
-rw-r--r--silx/gui/plot/StatsWidget.py7
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/actions/control.py2
-rw-r--r--silx/gui/plot/actions/fit.py22
-rw-r--r--silx/gui/plot/actions/histogram.py1
-rw-r--r--silx/gui/plot/actions/io.py42
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/backends/BackendBase.py87
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/backends/BackendMatplotlib.py496
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/backends/BackendOpenGL.py990
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py165
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py4
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotTriangles.py9
-rw-r--r--silx/gui/plot/items/__init__.py4
-rw-r--r--silx/gui/plot/items/_pick.py70
-rw-r--r--silx/gui/plot/items/complex.py13
-rw-r--r--silx/gui/plot/items/core.py162
-rw-r--r--silx/gui/plot/items/curve.py40
-rw-r--r--silx/gui/plot/items/histogram.py44
-rw-r--r--silx/gui/plot/items/image.py26
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/items/marker.py17
-rw-r--r--silx/gui/plot/items/roi.py200
-rw-r--r--silx/gui/plot/items/scatter.py429
-rw-r--r--silx/gui/plot/items/shape.py64
-rwxr-xr-x[-rw-r--r--]silx/gui/plot/test/testPlotWidget.py218
-rw-r--r--silx/gui/plot/test/testStats.py5
-rw-r--r--silx/gui/plot/tools/PositionInfo.py2
-rw-r--r--silx/gui/plot/tools/profile/_BaseProfileToolBar.py4
-rw-r--r--silx/gui/plot/tools/roi.py22
-rw-r--r--silx/gui/plot3d/_model/items.py96
-rw-r--r--silx/gui/plot3d/items/_pick.py39
-rw-r--r--silx/gui/plot3d/items/scatter.py3
-rw-r--r--silx/gui/plot3d/items/volume.py151
-rw-r--r--silx/gui/plot3d/scene/primitives.py9
-rw-r--r--silx/gui/plot3d/tools/PositionInfoWidget.py2
-rw-r--r--silx/gui/qt/_utils.py15
-rwxr-xr-x[-rw-r--r--]silx/gui/test/test_colors.py39
-rwxr-xr-x[-rw-r--r--]silx/gui/utils/__init__.py32
-rwxr-xr-xsilx/gui/utils/qtutils.py170
-rwxr-xr-x[-rw-r--r--]silx/gui/utils/test/__init__.py12
-rw-r--r--silx/gui/utils/test/test.py76
-rwxr-xr-xsilx/gui/utils/test/test_qtutils.py75
-rw-r--r--silx/gui/utils/test/test_testutils.py55
-rw-r--r--silx/gui/utils/testutils.py3
-rw-r--r--silx/gui/widgets/ColormapNameComboBox.py166
-rw-r--r--silx/gui/widgets/FrameBrowser.py2
-rwxr-xr-xsilx/gui/widgets/LegendIconWidget.py513
-rw-r--r--silx/gui/widgets/RangeSlider.py4
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: