summaryrefslogtreecommitdiff
path: root/silx/gui/plot
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot')
-rw-r--r--silx/gui/plot/ColorBar.py44
-rw-r--r--silx/gui/plot/Colormap.py243
-rw-r--r--silx/gui/plot/ColormapDialog.py897
-rw-r--r--silx/gui/plot/ComplexImageView.py314
-rw-r--r--silx/gui/plot/CurvesROIWidget.py684
-rw-r--r--silx/gui/plot/Interaction.py2
-rw-r--r--silx/gui/plot/PlotToolButtons.py15
-rw-r--r--silx/gui/plot/PlotTools.py10
-rw-r--r--silx/gui/plot/PlotWidget.py172
-rw-r--r--silx/gui/plot/PlotWindow.py63
-rw-r--r--silx/gui/plot/Profile.py12
-rw-r--r--silx/gui/plot/StackView.py24
-rw-r--r--silx/gui/plot/_utils/test/test_ticklayout.py18
-rw-r--r--silx/gui/plot/_utils/ticklayout.py20
-rw-r--r--silx/gui/plot/actions/PlotAction.py3
-rw-r--r--silx/gui/plot/actions/__init__.py12
-rw-r--r--silx/gui/plot/actions/control.py140
-rw-r--r--silx/gui/plot/actions/fit.py4
-rw-r--r--silx/gui/plot/actions/histogram.py4
-rw-r--r--silx/gui/plot/actions/io.py133
-rw-r--r--silx/gui/plot/actions/medfilt.py8
-rw-r--r--silx/gui/plot/backends/BackendBase.py11
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py154
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py177
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py2
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py2
-rw-r--r--silx/gui/plot/items/__init__.py7
-rw-r--r--silx/gui/plot/items/axis.py8
-rw-r--r--silx/gui/plot/items/complex.py356
-rw-r--r--silx/gui/plot/items/core.py95
-rw-r--r--silx/gui/plot/items/image.py7
-rw-r--r--silx/gui/plot/items/marker.py3
-rw-r--r--silx/gui/plot/matplotlib/Colormap.py58
-rw-r--r--silx/gui/plot/matplotlib/__init__.py5
-rw-r--r--silx/gui/plot/test/__init__.py6
-rw-r--r--silx/gui/plot/test/testColormap.py76
-rw-r--r--silx/gui/plot/test/testColormapDialog.py321
-rw-r--r--silx/gui/plot/test/testColors.py4
-rw-r--r--silx/gui/plot/test/testComplexImageView.py4
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py19
-rw-r--r--silx/gui/plot/test/testItem.py20
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py9
-rw-r--r--silx/gui/plot/test/testPlotTools.py4
-rw-r--r--silx/gui/plot/test/testPlotWidget.py22
-rw-r--r--silx/gui/plot/test/testPlotWidgetNoBackend.py4
-rw-r--r--silx/gui/plot/test/testProfile.py4
-rw-r--r--silx/gui/plot/test/testSaveAction.py97
-rw-r--r--silx/gui/plot/test/testScatterMaskToolsWidget.py9
-rw-r--r--silx/gui/plot/test/testUtilsAxis.py23
-rw-r--r--silx/gui/plot/test/utils.py127
-rw-r--r--silx/gui/plot/utils/axis.py78
51 files changed, 3006 insertions, 1528 deletions
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index 8f4bde2..2db7b79 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -27,7 +27,7 @@
__authors__ = ["H. Payno", "T. Vincent"]
__license__ = "MIT"
-__date__ = "11/04/2017"
+__date__ = "15/02/2018"
import logging
@@ -65,11 +65,12 @@ class ColorBarWidget(qt.QWidget):
:param plot: PlotWidget the colorbar is attached to (optional)
:param str legend: the label to set to the colorbar
"""
+ sigVisibleChanged = qt.Signal(bool)
+ """Emitted when the property `visible` have changed."""
def __init__(self, parent=None, plot=None, legend=None):
self._isConnected = False
self._plot = None
- self._viewAction = None
self._colormap = None
self._data = None
@@ -127,15 +128,18 @@ class ColorBarWidget(qt.QWidget):
self._plot.sigPlotSignal.connect(self._defaultColormapChanged)
self._isConnected = True
+ def setVisible(self, isVisible):
+ # isHidden looks to be always synchronized, while isVisible is not
+ wasHidden = self.isHidden()
+ qt.QWidget.setVisible(self, isVisible)
+ if wasHidden != self.isHidden():
+ self.sigVisibleChanged.emit(not self.isHidden())
+
def showEvent(self, event):
self._connectPlot()
- if self._viewAction is not None:
- self._viewAction.setChecked(True)
def hideEvent(self, event):
self._disconnectPlot()
- if self._viewAction is not None:
- self._viewAction.setChecked(False)
def getColormap(self):
"""
@@ -230,21 +234,6 @@ class ColorBarWidget(qt.QWidget):
and ticks"""
return self._colorScale
- def getToggleViewAction(self):
- """Returns a checkable action controlling this widget's visibility.
-
- :rtype: QAction
- """
- if self._viewAction is None:
- self._viewAction = qt.QAction(self)
- self._viewAction.setText('Colorbar')
- self._viewAction.setIcon(icons.getQIcon('colorbar'))
- self._viewAction.setToolTip('Show/Hide the colorbar')
- self._viewAction.setCheckable(True)
- self._viewAction.setChecked(self.isVisible())
- self._viewAction.toggled[bool].connect(self.setVisible)
- return self._viewAction
-
class _VerticalLegend(qt.QLabel):
"""Display vertically the given text
@@ -405,8 +394,8 @@ class ColorScaleBar(qt.QWidget):
:param val: if True, set the labels visible, otherwise set it not visible
"""
- self._maxLabel.show() if val is True else self._maxLabel.hide()
- self._minLabel.show() if val is True else self._minLabel.hide()
+ self._minLabel.setVisible(val)
+ self._maxLabel.setVisible(val)
def _updateMinMax(self):
"""Update the min and max label if we are in the case of the
@@ -533,12 +522,7 @@ class _ColorScale(qt.QWidget):
return
indices = numpy.linspace(0., 1., self._NB_CONTROL_POINTS)
- colormapDisp = Colormap.Colormap(name=colormap.getName(),
- normalization=Colormap.Colormap.LINEAR,
- vmin=None,
- vmax=None,
- colors=colormap.getColormapLUT())
- colors = colormapDisp.applyToData(indices)
+ colors = colormap.getNColors(nbColors=self._NB_CONTROL_POINTS)
self._gradient = qt.QLinearGradient(0, 1, 0, 0)
self._gradient.setCoordinateMode(qt.QGradient.StretchToDeviceMode)
self._gradient.setStops(
@@ -784,7 +768,7 @@ class _TickBar(qt.QWidget):
if self._norm == Colormap.Colormap.LINEAR:
return 1 - (val - self._vmin) / (self._vmax - self._vmin)
elif self._norm == Colormap.Colormap.LOGARITHM:
- return 1 - (numpy.log10(val) - numpy.log10(self._vmin))/(numpy.log10(self._vmax) - numpy.log(self._vmin))
+ return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log(self._vmin))
else:
raise ValueError('Norm is not recognized')
diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py
index abe8546..9adf0d4 100644
--- a/silx/gui/plot/Colormap.py
+++ b/silx/gui/plot/Colormap.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,7 +29,7 @@ from __future__ import absolute_import
__authors__ = ["T. Vincent", "H.Payno"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "08/01/2018"
from silx.gui import qt
import copy as copy_mdl
@@ -37,6 +37,7 @@ import numpy
from .matplotlib import Colormap as MPLColormap
import logging
from silx.math.combo import min_max
+from silx.utils.exceptions import NotEditableError
_logger = logging.getLogger(__file__)
@@ -62,7 +63,7 @@ class Colormap(qt.QObject):
Nx3 or Nx4 numpy array of RGB(A) colors,
either uint8 or float in [0, 1].
If 'name' is None, then this array is used as the colormap.
- :param str norm: Normalization: 'linear' (default) or 'log'
+ :param str normalization: Normalization: 'linear' (default) or 'log'
:param float vmin:
Lower bound of the colormap or None for autoscale (default)
:param float vmax:
@@ -79,6 +80,7 @@ class Colormap(qt.QObject):
"""Tuple of managed normalizations"""
sigChanged = qt.Signal()
+ """Signal emitted when the colormap has changed."""
def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None):
qt.QObject.__init__(self)
@@ -98,10 +100,11 @@ class Colormap(qt.QObject):
self._normalization = str(normalization)
self._vmin = float(vmin) if vmin is not None else None
self._vmax = float(vmax) if vmax is not None else None
+ self._editable = True
def isAutoscale(self):
"""Return True if both min and max are in autoscale mode"""
- return self._vmin is None or self._vmax is None
+ return self._vmin is None and self._vmax is None
def getName(self):
"""Return the name of the colormap
@@ -115,35 +118,69 @@ class Colormap(qt.QObject):
else:
self._colors = numpy.array(colors, copy=True)
+ def getNColors(self, nbColors=None):
+ """Returns N colors computed by sampling the colormap regularly.
+
+ :param nbColors:
+ The number of colors in the returned array or None for the default value.
+ The default value is 256 for colormap with a name (see :meth:`setName`) and
+ it is the size of the LUT for colormap defined with :meth:`setColormapLUT`.
+ :type nbColors: int or None
+ :return: 2D array of uint8 of shape (nbColors, 4)
+ :rtype: numpy.ndarray
+ """
+ # Handle default value for nbColors
+ if nbColors is None:
+ lut = self.getColormapLUT()
+ if lut is not None: # In this case uses LUT length
+ nbColors = len(lut)
+ else: # Default to 256
+ nbColors = 256
+
+ nbColors = int(nbColors)
+
+ colormap = self.copy()
+ colormap.setNormalization(Colormap.LINEAR)
+ colormap.setVRange(vmin=None, vmax=None)
+ colors = colormap.applyToData(
+ numpy.arange(nbColors, dtype=numpy.int))
+ return colors
+
def setName(self, name):
- """Set the name of the colormap and load the colors corresponding to
- the name
+ """Set the name of the colormap to use.
- :param str name: the name of the colormap (should be in ['gray',
+ :param str name: The name of the colormap.
+ At least the following names are supported: 'gray',
'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet',
- 'viridis', 'magma', 'inferno', 'plasma']
+ 'viridis', 'magma', 'inferno', 'plasma'.
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
assert name in self.getSupportedColormaps()
self._name = str(name)
self._colors = None
self.sigChanged.emit()
def getColormapLUT(self):
- """Return the list of colors for the colormap. None if not setted
-
- :return: the list of colors for the colormap. None if not setted
- :rtype: numpy.ndarray
+ """Return the list of colors for the colormap or None if not set
+
+ :return: the list of colors for the colormap or None if not set
+ :rtype: numpy.ndarray or None
"""
- return self._colors
+ if self._colors is None:
+ return None
+ else:
+ return numpy.array(self._colors, copy=True)
def setColormapLUT(self, colors):
- """
- Set the colors of the colormap.
+ """Set the colors of the colormap.
:param numpy.ndarray colors: the colors of the LUT
- .. warning: this will set the value of name to an empty string
+ .. warning: this will set the value of name to None
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
self._setColors(colors)
if len(colors) is 0:
self._colors = None
@@ -153,7 +190,7 @@ class Colormap(qt.QObject):
def getNormalization(self):
"""Return the normalization of the colormap ('log' or 'linear')
-
+
:return: the normalization of the colormap
:rtype: str
"""
@@ -164,12 +201,14 @@ class Colormap(qt.QObject):
:param str norm: the norm to set
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
self._normalization = str(norm)
self.sigChanged.emit()
def getVMin(self):
"""Return the lower bound of the colormap
-
+
:return: the lower bound of the colormap
:rtype: float or None
"""
@@ -182,10 +221,12 @@ class Colormap(qt.QObject):
(default)
value)
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
if vmin is not None:
- if self._vmax is not None and vmin >= self._vmax:
- err = "Can't set vmin because vmin >= vmax."
- err += "vmin = %s, vmax = %s" %(vmin, self._vmax)
+ if self._vmax is not None and vmin > self._vmax:
+ err = "Can't set vmin because vmin >= vmax. " \
+ "vmin = %s, vmax = %s" % (vmin, self._vmax)
raise ValueError(err)
self._vmin = vmin
@@ -193,7 +234,7 @@ class Colormap(qt.QObject):
def getVMax(self):
"""Return the upper bounds of the colormap or None
-
+
:return: the upper bounds of the colormap or None
:rtype: float or None
"""
@@ -205,15 +246,35 @@ class Colormap(qt.QObject):
:param float vmax: Upper bounds of the colormap or None for autoscale
(default)
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
if vmax is not None:
- if self._vmin is not None and vmax <= self._vmin:
- err = "Can't set vmax because vmax <= vmin."
- err += "vmin = %s, vmax = %s" %(self._vmin, vmax)
+ if self._vmin is not None and vmax < self._vmin:
+ err = "Can't set vmax because vmax <= vmin. " \
+ "vmin = %s, vmax = %s" % (self._vmin, vmax)
raise ValueError(err)
self._vmax = vmax
self.sigChanged.emit()
+ def isEditable(self):
+ """ Return if the colormap is editable or not
+
+ :return: editable state of the colormap
+ :rtype: bool
+ """
+ return self._editable
+
+ def setEditable(self, editable):
+ """
+ Set the editable state of the colormap
+
+ :param bool editable: is the colormap editable
+ """
+ assert type(editable) is bool
+ self._editable = editable
+ self.sigChanged.emit()
+
def getColormapRange(self, data=None):
"""Return (vmin, vmax)
@@ -267,20 +328,24 @@ class Colormap(qt.QObject):
return vmin, vmax
def setVRange(self, vmin, vmax):
- """
- Set bounds to the colormap
+ """Set the bounds of the colormap
:param vmin: Lower bound of the colormap or None for autoscale
(default)
:param vmax: Upper bounds of the colormap or None for autoscale
(default)
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
if vmin is not None and vmax is not None:
- if vmin >= vmax:
- err = "Can't set vmin and vmax because vmin >= vmax"
- err += "vmin = %s, vmax = %s" %(vmin, self._vmax)
+ if vmin > vmax:
+ err = "Can't set vmin and vmax because vmin >= vmax " \
+ "vmin = %s, vmax = %s" % (vmin, vmax)
raise ValueError(err)
+ if self._vmin == vmin and self._vmax == vmax:
+ return
+
self._vmin = vmin
self._vmax = vmax
self.sigChanged.emit()
@@ -322,6 +387,8 @@ class Colormap(qt.QObject):
:param dict dic: the colormap as a dictionary
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
name = dic['name'] if 'name' in dic else None
colors = dic['colors'] if 'colors' in dic else None
vmin = dic['vmin'] if 'vmin' in dic else None
@@ -361,9 +428,9 @@ class Colormap(qt.QObject):
return colormap
def copy(self):
- """
+ """Return a copy of the Colormap.
- :return: a copy of the Colormap object
+ :rtype: silx.gui.plot.Colormap.Colormap
"""
return Colormap(name=self._name,
colors=copy_mdl.copy(self._colors),
@@ -408,3 +475,115 @@ class Colormap(qt.QObject):
numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
)
+ _SERIAL_VERSION = 1
+
+ def restoreState(self, byteArray):
+ """
+ Read the colormap state from a QByteArray.
+
+ :param qt.QByteArray byteArray: Stream containing the state
+ :return: True if the restoration sussseed
+ :rtype: bool
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly)
+
+ className = stream.readQString()
+ if className != self.__class__.__name__:
+ _logger.warning("Classname mismatch. Found %s." % className)
+ return False
+
+ version = stream.readUInt32()
+ if version != self._SERIAL_VERSION:
+ _logger.warning("Serial version mismatch. Found %d." % version)
+ return False
+
+ name = stream.readQString()
+ isNull = stream.readBool()
+ if not isNull:
+ vmin = stream.readQVariant()
+ else:
+ vmin = None
+ isNull = stream.readBool()
+ if not isNull:
+ vmax = stream.readQVariant()
+ else:
+ vmax = None
+ normalization = stream.readQString()
+
+ # emit change event only once
+ old = self.blockSignals(True)
+ try:
+ self.setName(name)
+ self.setNormalization(normalization)
+ self.setVRange(vmin, vmax)
+ finally:
+ self.blockSignals(old)
+ self.sigChanged.emit()
+ return True
+
+ def saveState(self):
+ """
+ Save state of the colomap into a QDataStream.
+
+ :rtype: qt.QByteArray
+ """
+ data = qt.QByteArray()
+ stream = qt.QDataStream(data, qt.QIODevice.WriteOnly)
+
+ stream.writeQString(self.__class__.__name__)
+ stream.writeUInt32(self._SERIAL_VERSION)
+ stream.writeQString(self.getName())
+ stream.writeBool(self.getVMin() is None)
+ if self.getVMin() is not None:
+ stream.writeQVariant(self.getVMin())
+ stream.writeBool(self.getVMax() is None)
+ if self.getVMax() is not None:
+ stream.writeQVariant(self.getVMax())
+ stream.writeQString(self.getNormalization())
+ return data
+
+
+_PREFERRED_COLORMAPS = DEFAULT_COLORMAPS
+"""
+Tuple of preferred colormap names accessed with :meth:`preferredColormaps`.
+"""
+
+
+def preferredColormaps():
+ """Returns the name of the preferred colormaps.
+
+ This list is used by widgets allowing to change the colormap
+ like the :class:`ColormapDialog` as a subset of colormap choices.
+
+ :rtype: tuple of str
+ """
+ return _PREFERRED_COLORMAPS
+
+
+def setPreferredColormaps(colormaps):
+ """Set the list of preferred colormap names.
+
+ Warning: If a colormap name is not available
+ it will be removed from the list.
+
+ :param colormaps: Not empty list of colormap names
+ :type colormaps: iterable of str
+ :raise ValueError: if the list of available preferred colormaps is empty.
+ """
+ supportedColormaps = Colormap.getSupportedColormaps()
+ colormaps = tuple(
+ cmap for cmap in colormaps if cmap in supportedColormaps)
+ if len(colormaps) == 0:
+ raise ValueError("Cannot set preferred colormaps to an empty list")
+
+ global _PREFERRED_COLORMAPS
+ _PREFERRED_COLORMAPS = colormaps
+
+
+# Initialize preferred colormaps
+setPreferredColormaps(('gray', 'reversed gray',
+ 'temperature', 'red', 'green', 'blue', 'jet',
+ 'viridis', 'magma', 'inferno', 'plasma',
+ 'hsv'))
diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py
index 748dd72..4aefab6 100644
--- a/silx/gui/plot/ColormapDialog.py
+++ b/silx/gui/plot/ColormapDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -31,12 +31,14 @@ To run the following sample code, a QApplication must be initialized.
Create the colormap dialog and set the colormap description and data range:
>>> from silx.gui.plot.ColormapDialog import ColormapDialog
+>>> from silx.gui.plot.Colormap import Colormap
>>> dialog = ColormapDialog()
+>>> colormap = Colormap(name='red', normalization='log',
+... vmin=1., vmax=2.)
->>> dialog.setColormap(name='red', normalization='log',
-... autoscale=False, vmin=1., vmax=2.)
->>> dialog.setDataRange(1., 100.) # This scale the width of the plot area
+>>> dialog.setColormap(colormap)
+>>> colormap.setVRange(1., 100.) # This scale the width of the plot area
>>> dialog.show()
Get the colormap description (compatible with :class:`Plot`) from the dialog:
@@ -59,9 +61,9 @@ The updates of the colormap description are also available through the signal:
from __future__ import division
-__authors__ = ["V.A. Sole", "T. Vincent"]
+__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
__license__ = "MIT"
-__date__ = "02/10/2017"
+__date__ = "09/02/2018"
import logging
@@ -69,13 +71,162 @@ import logging
import numpy
from .. import qt
-from .Colormap import Colormap
+from .Colormap import Colormap, preferredColormaps
from . import PlotWidget
from silx.gui.widgets.FloatEdit import FloatEdit
+import weakref
+from silx.math.combo import min_max
+from silx.third_party import enum
+from silx.gui import icons
+from silx.math.histogram import Histogramnd
_logger = logging.getLogger(__name__)
+_colormapIconPreview = {}
+
+
+class _BoundaryWidget(qt.QWidget):
+ """Widget to edit a boundary of the colormap (vmin, vmax)"""
+ sigValueChanged = qt.Signal(object)
+ """Signal emitted when value is changed"""
+
+ def __init__(self, parent=None, value=0.0):
+ qt.QWidget.__init__(self, parent=None)
+ self.setLayout(qt.QHBoxLayout())
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self._numVal = FloatEdit(parent=self, value=value)
+ self.layout().addWidget(self._numVal)
+ self._autoCB = qt.QCheckBox('auto', parent=self)
+ self.layout().addWidget(self._autoCB)
+ self._autoCB.setChecked(False)
+
+ self._autoCB.toggled.connect(self._autoToggled)
+ self.sigValueChanged = self._autoCB.toggled
+ self.textEdited = self._numVal.textEdited
+ self.editingFinished = self._numVal.editingFinished
+ self._dataValue = None
+
+ def isAutoChecked(self):
+ return self._autoCB.isChecked()
+
+ def getValue(self):
+ return None if self._autoCB.isChecked() else self._numVal.value()
+
+ def getFiniteValue(self):
+ if not self._autoCB.isChecked():
+ return self._numVal.value()
+ elif self._dataValue is None:
+ return self._numVal.value()
+ else:
+ return self._dataValue
+
+ def _autoToggled(self, enabled):
+ self._numVal.setEnabled(not enabled)
+ self._updateDisplayedText()
+
+ def _updateDisplayedText(self):
+ # if dataValue is finite
+ if self._autoCB.isChecked() and self._dataValue is not None:
+ old = self._numVal.blockSignals(True)
+ self._numVal.setValue(self._dataValue)
+ self._numVal.blockSignals(old)
+
+ def setDataValue(self, dataValue):
+ self._dataValue = dataValue
+ self._updateDisplayedText()
+
+ def setFiniteValue(self, value):
+ assert(value is not None)
+ old = self._numVal.blockSignals(True)
+ self._numVal.setValue(value)
+ self._numVal.blockSignals(old)
+
+ def setValue(self, value, isAuto=False):
+ self._autoCB.setChecked(isAuto or value is None)
+ if value is not None:
+ self._numVal.setValue(value)
+ self._updateDisplayedText()
+
+
+class _ColormapNameCombox(qt.QComboBox):
+ def __init__(self, parent=None):
+ qt.QComboBox.__init__(self, parent)
+ self.__initItems()
+
+ ORIGINAL_NAME = qt.Qt.UserRole + 1
+
+ def __initItems(self):
+ for colormapName in preferredColormaps():
+ index = self.count()
+ self.addItem(str.title(colormapName))
+ self.setItemIcon(index, self.getIconPreview(colormapName))
+ self.setItemData(index, colormapName, role=self.ORIGINAL_NAME)
+
+ def getIconPreview(self, colormapName):
+ """Return an icon preview from a LUT name.
+
+ This icons are cached into a global structure.
+
+ :param str colormapName: str
+ :rtype: qt.QIcon
+ """
+ if colormapName not in _colormapIconPreview:
+ icon = self.createIconPreview(colormapName)
+ _colormapIconPreview[colormapName] = icon
+ return _colormapIconPreview[colormapName]
+
+ def createIconPreview(self, colormapName):
+ """Create and return an icon preview from a LUT name.
+
+ This icons are cached into a global structure.
+
+ :param str colormapName: Name of the LUT
+ :rtype: qt.QIcon
+ """
+ colormap = Colormap(colormapName)
+ size = 32
+ lut = colormap.getNColors(size)
+ 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.ORIGINAL_NAME)
+
+ def findColormap(self, name):
+ return self.findData(name, role=self.ORIGINAL_NAME)
+
+ def setCurrentName(self, name):
+ index = self.findColormap(name)
+ if index < 0:
+ index = self.count()
+ self.addItem(str.title(name))
+ self.setItemIcon(index, self.getIconPreview(name))
+ self.setItemData(index, name, role=self.ORIGINAL_NAME)
+ self.setCurrentIndex(index)
+
+
+@enum.unique
+class _DataInPlotMode(enum.Enum):
+ """Enum for each mode of display of the data in the plot."""
+ NONE = 'none'
+ RANGE = 'range'
+ HISTOGRAM = 'histogram'
+
+
class ColormapDialog(qt.QDialog):
"""A QDialog widget to set the colormap.
@@ -83,57 +234,62 @@ class ColormapDialog(qt.QDialog):
:param str title: The QDialog title
"""
- sigColormapChanged = qt.Signal(Colormap)
- """Signal triggered when the colormap is changed.
-
- It provides a dict describing the colormap to the slot.
- This dict can be used with :class:`Plot`.
- """
+ visibleChanged = qt.Signal(bool)
+ """This event is sent when the dialog visibility change"""
def __init__(self, parent=None, title="Colormap Dialog"):
qt.QDialog.__init__(self, parent)
self.setWindowTitle(title)
+ self._colormap = None
+ self._data = None
+ self._dataInPlotMode = _DataInPlotMode.RANGE
+
+ self._ignoreColormapChange = False
+ """Used as a semaphore to avoid editing the colormap object when we are
+ only attempt to display it.
+ Used instead of n connect and disconnect of the sigChanged. The
+ disconnection to sigChanged was also limiting when this colormapdialog
+ is used in the colormapaction and associated to the activeImageChanged.
+ (because the activeImageChanged is send when the colormap changed and
+ the self.setcolormap is a callback)
+ """
+
self._histogramData = None
- self._dataRange = None
self._minMaxWasEdited = False
+ self._initialRange = None
+
+ self._dataRange = None
+ """If defined 3-tuple containing information from a data:
+ minimum, positive minimum, maximum"""
- colormaps = [
- 'gray', 'reversed gray',
- 'temperature', 'red', 'green', 'blue', 'jet',
- 'viridis', 'magma', 'inferno', 'plasma']
- if 'hsv' in Colormap.getSupportedColormaps():
- colormaps.append('hsv')
- self._colormapList = tuple(colormaps)
+ self._colormapStoredState = None
# Make the GUI
vLayout = qt.QVBoxLayout(self)
- formWidget = qt.QWidget()
+ formWidget = qt.QWidget(parent=self)
vLayout.addWidget(formWidget)
formLayout = qt.QFormLayout(formWidget)
formLayout.setContentsMargins(10, 10, 10, 10)
formLayout.setSpacing(0)
# Colormap row
- self._comboBoxColormap = qt.QComboBox()
- for cmap in self._colormapList:
- # Capitalize first letters
- cmap = ' '.join(w[0].upper() + w[1:] for w in cmap.split())
- self._comboBoxColormap.addItem(cmap)
- self._comboBoxColormap.activated[int].connect(self._notify)
+ self._comboBoxColormap = _ColormapNameCombox(parent=formWidget)
+ self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName)
formLayout.addRow('Colormap:', self._comboBoxColormap)
# Normalization row
self._normButtonLinear = qt.QRadioButton('Linear')
self._normButtonLinear.setChecked(True)
self._normButtonLog = qt.QRadioButton('Log')
+ self._normButtonLog.toggled.connect(self._activeLogNorm)
normButtonGroup = qt.QButtonGroup(self)
normButtonGroup.setExclusive(True)
normButtonGroup.addButton(self._normButtonLinear)
normButtonGroup.addButton(self._normButtonLog)
- normButtonGroup.buttonClicked[int].connect(self._notify)
+ self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm)
normLayout = qt.QHBoxLayout()
normLayout.setContentsMargins(0, 0, 0, 0)
@@ -143,51 +299,124 @@ class ColormapDialog(qt.QDialog):
formLayout.addRow('Normalization:', normLayout)
- # Range row
- self._rangeAutoscaleButton = qt.QCheckBox('Autoscale')
- self._rangeAutoscaleButton.setChecked(True)
- self._rangeAutoscaleButton.toggled.connect(self._autoscaleToggled)
- self._rangeAutoscaleButton.clicked.connect(self._notify)
- formLayout.addRow('Range:', self._rangeAutoscaleButton)
-
# Min row
- self._minValue = FloatEdit(parent=self, value=1.)
- self._minValue.setEnabled(False)
+ self._minValue = _BoundaryWidget(parent=self, value=1.0)
self._minValue.textEdited.connect(self._minMaxTextEdited)
self._minValue.editingFinished.connect(self._minEditingFinished)
+ self._minValue.sigValueChanged.connect(self._updateMinMax)
formLayout.addRow('\tMin:', self._minValue)
# Max row
- self._maxValue = FloatEdit(parent=self, value=10.)
- self._maxValue.setEnabled(False)
+ self._maxValue = _BoundaryWidget(parent=self, value=10.0)
self._maxValue.textEdited.connect(self._minMaxTextEdited)
+ self._maxValue.sigValueChanged.connect(self._updateMinMax)
self._maxValue.editingFinished.connect(self._maxEditingFinished)
formLayout.addRow('\tMax:', self._maxValue)
# Add plot for histogram
+ self._plotToolbar = qt.QToolBar(self)
+ self._plotToolbar.setFloatable(False)
+ self._plotToolbar.setMovable(False)
+ self._plotToolbar.setIconSize(qt.QSize(8, 8))
+ self._plotToolbar.setStyleSheet("QToolBar { border: 0px }")
+ self._plotToolbar.setOrientation(qt.Qt.Vertical)
+
+ group = qt.QActionGroup(self._plotToolbar)
+ group.setExclusive(True)
+
+ action = qt.QAction("Nothing", self)
+ action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.")
+ action.setIcon(icons.getQIcon('colormap-none'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.NONE)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ action = qt.QAction("Data range", self)
+ action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.")
+ action.setIcon(icons.getQIcon('colormap-range'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.RANGE)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ action = qt.QAction("Histogram", self)
+ action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ")
+ action.setIcon(icons.getQIcon('colormap-histogram'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.HISTOGRAM)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ group.triggered.connect(self._displayDataInPlotModeChanged)
+
+ self._plotBox = qt.QWidget(self)
self._plotInit()
- vLayout.addWidget(self._plot)
- # Close button
- buttonsWidget = qt.QWidget()
- vLayout.addWidget(buttonsWidget)
+ plotBoxLayout = qt.QHBoxLayout()
+ plotBoxLayout.setContentsMargins(0, 0, 0, 0)
+ plotBoxLayout.setSpacing(2)
+ plotBoxLayout.addWidget(self._plotToolbar)
+ plotBoxLayout.addWidget(self._plot)
+ plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ self._plotBox.setLayout(plotBoxLayout)
+ vLayout.addWidget(self._plotBox)
+
+ # define modal buttons
+ types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
+ self._buttonsModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsModal.setStandardButtons(types)
+ self.layout().addWidget(self._buttonsModal)
+ self._buttonsModal.accepted.connect(self.accept)
+ self._buttonsModal.rejected.connect(self.reject)
+
+ # define non modal buttons
+ types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset
+ self._buttonsNonModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsNonModal.setStandardButtons(types)
+ self.layout().addWidget(self._buttonsNonModal)
+ self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept)
+ self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap)
+
+ # Set the colormap to default values
+ self.setColormap(Colormap(name='gray', normalization='linear',
+ vmin=None, vmax=None))
- buttonsLayout = qt.QHBoxLayout(buttonsWidget)
+ self.setModal(self.isModal())
- okButton = qt.QPushButton('OK')
- okButton.clicked.connect(self.accept)
- buttonsLayout.addWidget(okButton)
+ vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ self.setFixedSize(self.sizeHint())
+ self._applyColormap()
- cancelButton = qt.QPushButton('Cancel')
- cancelButton.clicked.connect(self.reject)
- buttonsLayout.addWidget(cancelButton)
+ def showEvent(self, event):
+ self.visibleChanged.emit(True)
+ super(ColormapDialog, self).showEvent(event)
- # colormap window can not be resized
- self.setFixedSize(vLayout.minimumSize())
+ def closeEvent(self, event):
+ if not self.isModal():
+ self.accept()
+ super(ColormapDialog, self).closeEvent(event)
- # Set the colormap to default values
- self.setColormap(name='gray', normalization='linear',
- autoscale=True, vmin=1., vmax=10.)
+ def hideEvent(self, event):
+ self.visibleChanged.emit(False)
+ super(ColormapDialog, self).hideEvent(event)
+
+ def close(self):
+ self.accept()
+ qt.QDialog.close(self)
+
+ def setModal(self, modal):
+ assert type(modal) is bool
+ self._buttonsNonModal.setVisible(not modal)
+ self._buttonsModal.setVisible(modal)
+ qt.QDialog.setModal(self, modal)
+
+ def exec_(self):
+ wasModal = self.isModal()
+ self.setModal(True)
+ result = super(ColormapDialog, self).exec_()
+ self.setModal(wasModal)
+ return result
def _plotInit(self):
"""Init the plot to display the range and the values"""
@@ -199,51 +428,63 @@ class ColormapDialog(qt.QDialog):
self._plot.setActiveCurveHandling(False)
self._plot.setMinimumSize(qt.QSize(250, 200))
self._plot.sigPlotSignal.connect(self._plotSlot)
- self._plot.hide()
self._plotUpdate()
+ def sizeHint(self):
+ return self.layout().minimumSize()
+
def _plotUpdate(self, updateMarkers=True):
"""Update the plot content
:param bool updateMarkers: True to update markers, False otherwith
"""
- dataRange = self.getDataRange()
-
- if dataRange is None:
- if self._plot.isVisibleTo(self):
- self._plot.setVisible(False)
- self.setFixedSize(self.layout().minimumSize())
+ colormap = self.getColormap()
+ if colormap is None:
+ if self._plotBox.isVisibleTo(self):
+ self._plotBox.setVisible(False)
+ self.setFixedSize(self.sizeHint())
return
- if not self._plot.isVisibleTo(self):
- self._plot.setVisible(True)
- self.setFixedSize(self.layout().minimumSize())
+ if not self._plotBox.isVisibleTo(self):
+ self._plotBox.setVisible(True)
+ self.setFixedSize(self.sizeHint())
- dataMin, dataMax = dataRange
- marge = (abs(dataMax) + abs(dataMin)) / 6.0
- minmd = dataMin - marge
- maxpd = dataMax + marge
+ minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue()
+ if minData > maxData:
+ # avoid a full collapse
+ minData, maxData = maxData, minData
+ minimum = minData
+ maximum = maxData
- start, end = self._minValue.value(), self._maxValue.value()
+ if self._dataRange is not None:
+ minRange = self._dataRange[0]
+ maxRange = self._dataRange[2]
+ minimum = min(minimum, minRange)
+ maximum = max(maximum, maxRange)
- if start <= end:
- x = [minmd, start, end, maxpd]
- y = [0, 0, 1, 1]
+ if self._histogramData is not None:
+ minHisto = self._histogramData[1][0]
+ maxHisto = self._histogramData[1][-1]
+ minimum = min(minimum, minHisto)
+ maximum = max(maximum, maxHisto)
- else:
- x = [minmd, end, start, maxpd]
- y = [1, 1, 0, 0]
-
- # Display the colormap on the side
- # colormap = {'name': self.getColormap()['name'],
- # 'normalization': self.getColormap()['normalization'],
- # 'autoscale': True, 'vmin': 1., 'vmax': 256.}
- # self._plot.addImage((1 + numpy.arange(256)).reshape(256, -1),
- # xScale=(minmd - marge, marge),
- # yScale=(1., 2./256.),
- # legend='colormap',
- # colormap=colormap)
+ marge = abs(maximum - minimum) / 6.0
+ if marge < 0.0001:
+ # Smaller that the QLineEdit precision
+ marge = 0.0001
+
+ minView, maxView = minimum - marge, maximum + marge
+
+ if updateMarkers:
+ # Save the state in we are not moving the markers
+ self._initialRange = minView, maxView
+ elif self._initialRange is not None:
+ minView = min(minView, self._initialRange[0])
+ maxView = max(maxView, self._initialRange[1])
+
+ x = [minView, minData, maxData, maxView]
+ y = [0, 0, 1, 1]
self._plot.addCurve(x, y,
legend="ConstrainedCurve",
@@ -252,22 +493,24 @@ class ColormapDialog(qt.QDialog):
linestyle='-',
resetzoom=False)
- draggable = not self._rangeAutoscaleButton.isChecked()
-
if updateMarkers:
+ minDraggable = (self._colormap().isEditable() and
+ not self._minValue.isAutoChecked())
self._plot.addXMarker(
- self._minValue.value(),
+ self._minValue.getFiniteValue(),
legend='Min',
text='Min',
- draggable=draggable,
+ draggable=minDraggable,
color='blue',
constraint=self._plotMinMarkerConstraint)
+ maxDraggable = (self._colormap().isEditable() and
+ not self._maxValue.isAutoChecked())
self._plot.addXMarker(
- self._maxValue.value(),
+ self._maxValue.getFiniteValue(),
legend='Max',
text='Max',
- draggable=draggable,
+ draggable=maxDraggable,
color='blue',
constraint=self._plotMaxMarkerConstraint)
@@ -275,11 +518,11 @@ class ColormapDialog(qt.QDialog):
def _plotMinMarkerConstraint(self, x, y):
"""Constraint of the min marker"""
- return min(x, self._maxValue.value()), y
+ return min(x, self._maxValue.getFiniteValue()), y
def _plotMaxMarkerConstraint(self, x, y):
"""Constraint of the max marker"""
- return max(x, self._minValue.value()), y
+ return max(x, self._minValue.getFiniteValue()), y
def _plotSlot(self, event):
"""Handle events from the plot"""
@@ -293,10 +536,139 @@ class ColormapDialog(qt.QDialog):
# This will recreate the markers while interacting...
# It might break if marker interaction is changed
if event['event'] == 'markerMoved':
- self._notify()
+ self._initialRange = None
+ self._updateMinMax()
else:
self._plotUpdate(updateMarkers=False)
+ @staticmethod
+ def computeDataRange(data):
+ """Compute the data range as used by :meth:`setDataRange`.
+
+ :param data: The data to process
+ :rtype: Tuple(float, float, float)
+ """
+ if data is None or len(data) == 0:
+ return None, None, None
+
+ dataRange = min_max(data, min_positive=True, finite=True)
+ if dataRange.minimum is None:
+ # Only non-finite data
+ dataRange = None
+
+ if dataRange is not None:
+ min_positive = dataRange.min_positive
+ if min_positive is None:
+ min_positive = float('nan')
+ dataRange = dataRange.minimum, min_positive, dataRange.maximum
+
+ if dataRange is None or len(dataRange) != 3:
+ qt.QMessageBox.warning(
+ None, "No Data",
+ "Image data does not contain any real value")
+ dataRange = 1., 1., 10.
+
+ return dataRange
+
+ @staticmethod
+ def computeHistogram(data):
+ """Compute the data histogram as used by :meth:`setHistogram`.
+
+ :param data: The data to process
+ :rtype: Tuple(List(float),List(float)
+ """
+ _data = data
+ if _data.ndim == 3: # RGB(A) images
+ _logger.info('Converting current image from RGB(A) to grayscale\
+ in order to compute the intensity distribution')
+ _data = (_data[:, :, 0] * 0.299 +
+ _data[:, :, 1] * 0.587 +
+ _data[:, :, 2] * 0.114)
+
+ if len(_data) == 0:
+ return None, None
+
+ xmin, xmax = min_max(_data, min_positive=False, finite=True)
+ nbins = min(256, int(numpy.sqrt(_data.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(_data.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = xmax - xmin
+
+ nbins = max(2, nbins)
+ _data = _data.ravel().astype(numpy.float32)
+
+ histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range)
+ return histogram.histo, histogram.edges[0]
+
+ def _getData(self):
+ if self._data is None:
+ return None
+ return self._data()
+
+ def setData(self, data):
+ """Store the data as a weakref.
+
+ According to the state of the dialog, the data will be used to display
+ the data range or the histogram of the data using :meth:`setDataRange`
+ and :meth:`setHistogram`
+ """
+ oldData = self._getData()
+ if oldData is data:
+ return
+
+ if data is None:
+ self.setDataRange()
+ self.setHistogram()
+ self._data = None
+ return
+
+ self._data = weakref.ref(data, self._dataAboutToFinalize)
+
+ self._updateDataInPlot()
+
+ def _setDataInPlotMode(self, mode):
+ if self._dataInPlotMode == mode:
+ return
+ self._dataInPlotMode = mode
+ self._updateDataInPlot()
+
+ def _displayDataInPlotModeChanged(self, action):
+ mode = action.data()
+ self._setDataInPlotMode(mode)
+
+ def _updateDataInPlot(self):
+ data = self._getData()
+ if data is None:
+ return
+
+ mode = self._dataInPlotMode
+
+ if mode == _DataInPlotMode.NONE:
+ self.setHistogram()
+ self.setDataRange()
+ elif mode == _DataInPlotMode.RANGE:
+ result = self.computeDataRange(data)
+ self.setHistogram()
+ self.setDataRange(*result)
+ elif mode == _DataInPlotMode.HISTOGRAM:
+ # The histogram should be done in a worker thread
+ result = self.computeHistogram(data)
+ self.setHistogram(*result)
+ self.setDataRange()
+
+ def _colormapAboutToFinalize(self, weakrefColormap):
+ """Callback when the data weakref is about to be finalized."""
+ if self._colormap is weakrefColormap:
+ self.setColormap(None)
+
+ def _dataAboutToFinalize(self, weakrefData):
+ """Callback when the data weakref is about to be finalized."""
+ if self._data is weakrefData:
+ self.setData(None)
+
def getHistogram(self):
"""Returns the counts and bin edges of the displayed histogram.
@@ -312,136 +684,243 @@ class ColormapDialog(qt.QDialog):
"""Set the histogram to display.
This update the data range with the bounds of the bins.
- See :meth:`setDataRange`.
:param hist: array-like of counts or None to hide histogram
:param bin_edges: array-like of bins edges or None to hide histogram
"""
if hist is None or bin_edges is None:
self._histogramData = None
- self._plot.remove(legend='Histogram', kind='curve')
- self.setDataRange() # Remove data range
-
+ self._plot.remove(legend='Histogram', kind='histogram')
else:
hist = numpy.array(hist, copy=True)
bin_edges = numpy.array(bin_edges, copy=True)
self._histogramData = hist, bin_edges
-
- # For now, draw the histogram as a curve
- # using bin centers and normalised counts
- bins_center = 0.5 * (bin_edges[:-1] + bin_edges[1:])
norm_hist = hist / max(hist)
- self._plot.addCurve(bins_center, norm_hist,
- legend="Histogram",
- color='gray',
- symbol='',
- linestyle='-',
- fill=True)
+ self._plot.addHistogram(norm_hist,
+ bin_edges,
+ legend="Histogram",
+ color='gray',
+ align='center',
+ fill=True)
+ self._updateMinMaxData()
- # Update the data range
- self.setDataRange(bin_edges[0], bin_edges[-1])
+ def getColormap(self):
+ """Return the colormap description as a :class:`.Colormap`.
- def getDataRange(self):
- """Returns the data range used for the histogram area.
+ """
+ if self._colormap is None:
+ return None
+ return self._colormap()
- :return: (dataMin, dataMax) or None if no data range is set
- :rtype: 2-tuple of float
+ def resetColormap(self):
"""
- return self._dataRange
+ Reset the colormap state before modification.
- def setDataRange(self, min_=None, max_=None):
+ ..note :: the colormap reference state is the state when set or the
+ state when validated
+ """
+ colormap = self.getColormap()
+ if colormap is not None and self._colormapStoredState is not None:
+ if self._colormap()._toDict() != self._colormapStoredState:
+ self._ignoreColormapChange = True
+ colormap._setFromDict(self._colormapStoredState)
+ self._ignoreColormapChange = False
+ self._applyColormap()
+
+ def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
"""Set the range of data to use for the range of the histogram area.
- :param float min_: The min of the data or None to disable range.
- :param float max_: The max of the data or None to disable range.
+ :param float minimum: The minimum of the data
+ :param float positiveMin: The positive minimum of the data
+ :param float maximum: The maximum of the data
"""
- if min_ is None or max_ is None:
+ if minimum is None or positiveMin is None or maximum is None:
self._dataRange = None
- self._plotUpdate()
-
+ self._plot.remove(legend='Range', kind='histogram')
else:
- min_, max_ = float(min_), float(max_)
- assert min_ <= max_
- self._dataRange = min_, max_
- if self._rangeAutoscaleButton.isChecked():
- self._minValue.setValue(min_)
- self._maxValue.setValue(max_)
- self._notify()
- else:
- self._plotUpdate()
+ hist = numpy.array([1])
+ bin_edges = numpy.array([minimum, maximum])
+ self._plot.addHistogram(hist,
+ bin_edges,
+ legend="Range",
+ color='gray',
+ align='center',
+ fill=True)
+ self._dataRange = minimum, positiveMin, maximum
+ self._updateMinMaxData()
+
+ def _updateMinMaxData(self):
+ """Update the min and max of the data according to the data range and
+ the histogram preset."""
+ colormap = self.getColormap()
+
+ minimum = float("+inf")
+ maximum = float("-inf")
+
+ if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM:
+ # find a range in the positive part of the data
+ if self._dataRange is not None:
+ minimum = min(minimum, self._dataRange[1])
+ maximum = max(maximum, self._dataRange[2])
+ if self._histogramData is not None:
+ positives = list(filter(lambda x: x > 0, self._histogramData[1]))
+ if len(positives) > 0:
+ minimum = min(minimum, positives[0])
+ maximum = max(maximum, positives[-1])
+ else:
+ if self._dataRange is not None:
+ minimum = min(minimum, self._dataRange[0])
+ maximum = max(maximum, self._dataRange[2])
+ if self._histogramData is not None:
+ minimum = min(minimum, self._histogramData[1][0])
+ maximum = max(maximum, self._histogramData[1][-1])
+
+ if not numpy.isfinite(minimum):
+ minimum = None
+ if not numpy.isfinite(maximum):
+ maximum = None
+
+ self._minValue.setDataValue(minimum)
+ self._maxValue.setDataValue(maximum)
+ self._plotUpdate()
- def getColormap(self):
- """Return the colormap description as a :class:`.Colormap`.
+ def accept(self):
+ self.storeCurrentState()
+ qt.QDialog.accept(self)
+ def storeCurrentState(self):
+ """
+ save the current value sof the colormap if the user want to undo is
+ modifications
"""
- isNormLinear = self._normButtonLinear.isChecked()
- if self._rangeAutoscaleButton.isChecked():
- vmin = None
- vmax = None
+ colormap = self.getColormap()
+ if colormap is not None:
+ self._colormapStoredState = colormap._toDict()
else:
- vmin = self._minValue.value()
- vmax = self._maxValue.value()
- norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM
- colormap = Colormap(
- name=str(self._comboBoxColormap.currentText()).lower(),
- normalization=norm,
- vmin=vmin,
- vmax=vmax)
- return colormap
-
- def setColormap(self, name=None, normalization=None,
- autoscale=None, vmin=None, vmax=None, colors=None):
- """Set the colormap description
+ self._colormapStoredState = None
- If some arguments are not provided, the current values are used.
+ def reject(self):
+ self.resetColormap()
+ qt.QDialog.reject(self)
- :param str name: The name of the colormap
- :param str normalization: 'linear' or 'log'
- :param bool autoscale: Toggle colormap range autoscale
- :param float vmin: The min value, ignored if autoscale is True
- :param float vmax: The max value, ignored if autoscale is True
+ def setColormap(self, colormap):
+ """Set the colormap description
+
+ :param :class:`Colormap` colormap: the colormap to edit
"""
- if name is not None:
- assert name in self._colormapList
- index = self._colormapList.index(name)
- self._comboBoxColormap.setCurrentIndex(index)
-
- if normalization is not None:
- assert normalization in Colormap.NORMALIZATIONS
- self._normButtonLinear.setChecked(normalization == Colormap.LINEAR)
- self._normButtonLog.setChecked(normalization == Colormap.LOGARITHM)
-
- if vmin is not None:
- self._minValue.setValue(vmin)
-
- if vmax is not None:
- self._maxValue.setValue(vmax)
-
- if autoscale is not None:
- self._rangeAutoscaleButton.setChecked(autoscale)
- if autoscale:
- dataRange = self.getDataRange()
- if dataRange is not None:
- self._minValue.setValue(dataRange[0])
- self._maxValue.setValue(dataRange[1])
-
- # Do it once for all the changes
- self._notify()
-
- def _notify(self, *args, **kwargs):
- """Emit the signal for colormap change"""
+ assert colormap is None or isinstance(colormap, Colormap)
+ if self._ignoreColormapChange is True:
+ return
+
+ oldColormap = self.getColormap()
+ if oldColormap is colormap:
+ return
+ if oldColormap is not None:
+ oldColormap.sigChanged.disconnect(self._applyColormap)
+
+ if colormap is not None:
+ colormap.sigChanged.connect(self._applyColormap)
+ colormap = weakref.ref(colormap, self._colormapAboutToFinalize)
+
+ self._colormap = colormap
+ self.storeCurrentState()
+ self._updateResetButton()
+ self._applyColormap()
+
+ def _updateResetButton(self):
+ resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ rStateEnabled = False
+ colormap = self.getColormap()
+ if colormap is not None and colormap.isEditable():
+ # can reset only in the case the colormap changed
+ rStateEnabled = colormap._toDict() != self._colormapStoredState
+ resetButton.setEnabled(rStateEnabled)
+
+ def _applyColormap(self):
+ self._updateResetButton()
+ if self._ignoreColormapChange is True:
+ return
+
+ colormap = self.getColormap()
+ if colormap is None:
+ self._comboBoxColormap.setEnabled(False)
+ self._normButtonLinear.setEnabled(False)
+ self._normButtonLog.setEnabled(False)
+ self._minValue.setEnabled(False)
+ self._maxValue.setEnabled(False)
+ else:
+ self._ignoreColormapChange = True
+
+ if colormap.getName() is not None:
+ name = colormap.getName()
+ self._comboBoxColormap.setCurrentName(name)
+ self._comboBoxColormap.setEnabled(self._colormap().isEditable())
+
+ assert colormap.getNormalization() in Colormap.NORMALIZATIONS
+ self._normButtonLinear.setChecked(
+ colormap.getNormalization() == Colormap.LINEAR)
+ self._normButtonLog.setChecked(
+ colormap.getNormalization() == Colormap.LOGARITHM)
+ vmin = colormap.getVMin()
+ vmax = colormap.getVMax()
+ dataRange = colormap.getColormapRange()
+ self._normButtonLinear.setEnabled(self._colormap().isEditable())
+ self._normButtonLog.setEnabled(self._colormap().isEditable())
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
+ self._minValue.setEnabled(self._colormap().isEditable())
+ self._maxValue.setEnabled(self._colormap().isEditable())
+ self._ignoreColormapChange = False
+
self._plotUpdate()
- self.sigColormapChanged.emit(self.getColormap())
-
- def _autoscaleToggled(self, checked):
- """Handle autoscale changes by enabling/disabling min/max fields"""
- self._minValue.setEnabled(not checked)
- self._maxValue.setEnabled(not checked)
- if checked:
- dataRange = self.getDataRange()
- if dataRange is not None:
- self._minValue.setValue(dataRange[0])
- self._maxValue.setValue(dataRange[1])
+
+ def _updateMinMax(self):
+ if self._ignoreColormapChange is True:
+ return
+
+ vmin = self._minValue.getFiniteValue()
+ vmax = self._maxValue.getFiniteValue()
+ if vmax is not None and vmin is not None and vmax < vmin:
+ # If only one autoscale is checked constraints are too strong
+ # We have to edit a user value anyway it is not requested
+ # TODO: It would be better IMO to disable the auto checkbox before
+ # this case occur (valls)
+ cmin = self._minValue.isAutoChecked()
+ cmax = self._maxValue.isAutoChecked()
+ if cmin is False:
+ self._minValue.setFiniteValue(vmax)
+ if cmax is False:
+ self._maxValue.setFiniteValue(vmin)
+
+ vmin = self._minValue.getValue()
+ vmax = self._maxValue.getValue()
+ self._ignoreColormapChange = True
+ colormap = self._colormap()
+ if colormap is not None:
+ colormap.setVRange(vmin, vmax)
+ self._ignoreColormapChange = False
+ self._plotUpdate()
+ self._updateResetButton()
+
+ def _updateName(self):
+ if self._ignoreColormapChange is True:
+ return
+
+ if self._colormap():
+ self._ignoreColormapChange = True
+ self._colormap().setName(
+ self._comboBoxColormap.getCurrentName())
+ self._ignoreColormapChange = False
+
+ def _updateLinearNorm(self, isNormLinear):
+ if self._ignoreColormapChange is True:
+ return
+
+ if self._colormap():
+ self._ignoreColormapChange = True
+ norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM
+ self._colormap().setNormalization(norm)
+ self._ignoreColormapChange = False
def _minMaxTextEdited(self, text):
"""Handle _minValue and _maxValue textEdited signal"""
@@ -457,9 +936,10 @@ class ColormapDialog(qt.QDialog):
self._minMaxWasEdited = False
# Fix start value
- if self._minValue.value() > self._maxValue.value():
- self._minValue.setValue(self._maxValue.value())
- self._notify()
+ if (self._maxValue.getValue() is not None and
+ self._minValue.getValue() > self._maxValue.getValue()):
+ self._minValue.setValue(self._maxValue.getValue())
+ self._updateMinMax()
def _maxEditingFinished(self):
"""Handle _maxValue editingFinished signal
@@ -471,9 +951,10 @@ class ColormapDialog(qt.QDialog):
self._minMaxWasEdited = False
# Fix end value
- if self._minValue.value() > self._maxValue.value():
- self._maxValue.setValue(self._minValue.value())
- self._notify()
+ if (self._minValue.getValue() is not None and
+ self._minValue.getValue() > self._maxValue.getValue()):
+ self._maxValue.setValue(self._minValue.getValue())
+ self._updateMinMax()
def keyPressEvent(self, event):
"""Override key handling.
@@ -488,3 +969,13 @@ class ColormapDialog(qt.QDialog):
else:
# Use QDialog keyPressEvent
super(ColormapDialog, self).keyPressEvent(event)
+
+ def _activeLogNorm(self, isLog):
+ if self._ignoreColormapChange is True:
+ return
+ if self._colormap():
+ self._ignoreColormapChange = True
+ norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR
+ self._colormap().setNormalization(norm)
+ self._ignoreColormapChange = False
+ self._updateMinMaxData()
diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py
index 1463293..ebff175 100644
--- a/silx/gui/plot/ComplexImageView.py
+++ b/silx/gui/plot/ComplexImageView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -32,144 +32,22 @@ from __future__ import absolute_import
__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"]
__license__ = "MIT"
-__date__ = "02/10/2017"
+__date__ = "19/01/2018"
import logging
+import collections
import numpy
from .. import qt, icons
from .PlotWindow import Plot2D
-from .Colormap import Colormap
from . import items
+from .items import ImageComplexData
from silx.gui.widgets.FloatEdit import FloatEdit
_logger = logging.getLogger(__name__)
-_PHASE_COLORMAP = Colormap(
- name='hsv',
- vmin=-numpy.pi,
- vmax=numpy.pi)
-"""Colormap to use for phase"""
-
-# Complex colormap functions
-
-def _phase2rgb(data):
- """Creates RGBA image with colour-coded phase.
-
- :param numpy.ndarray data: The data to convert
- :return: Array of RGBA colors
- :rtype: numpy.ndarray
- """
- if data.size == 0:
- return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
-
- phase = numpy.angle(data)
- return _PHASE_COLORMAP.applyToData(phase)
-
-
-def _complex2rgbalog(data, amin=0., dlogs=2, smax=None):
- """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha.
-
- :param numpy.ndarray data: the complex data array to convert to RGBA
- :param float amin: the minimum value for the alpha channel
- :param float dlogs: amplitude range displayed, in log10 units
- :param float smax:
- if specified, all values above max will be displayed with an alpha=1
- """
- if data.size == 0:
- return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
-
- rgba = _phase2rgb(data)
- sabs = numpy.absolute(data)
- if smax is not None:
- sabs[sabs > smax] = smax
- a = numpy.log10(sabs + 1e-20)
- a -= a.max() - dlogs # display dlogs orders of magnitude
- rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0))
- return rgba
-
-
-def _complex2rgbalin(data, gamma=1.0, smax=None):
- """Returns RGBA colors: colour-coded phase and linear amplitude in alpha.
-
- :param numpy.ndarray data:
- :param float gamma: Optional exponent gamma applied to the amplitude
- :param float smax:
- """
- if data.size == 0:
- return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
-
- rgba = _phase2rgb(data)
- a = numpy.absolute(data)
- if smax is not None:
- a[a > smax] = smax
- a /= a.max()
- rgba[..., 3] = 255 * a**gamma
- return rgba
-
-
-# Dedicated plot item
-
-class _ImageComplexData(items.ImageData):
- """Specific plot item to force colormap when using complex colormap.
-
- This is returning the specific colormap when displaying
- colored phase + amplitude.
- """
-
- def __init__(self):
- super(_ImageComplexData, self).__init__()
- self._readOnlyColormap = False
- self._mode = 'absolute'
- self._colormaps = { # Default colormaps for all modes
- 'absolute': Colormap(),
- 'phase': _PHASE_COLORMAP.copy(),
- 'real': Colormap(),
- 'imaginary': Colormap(),
- 'amplitude_phase': _PHASE_COLORMAP.copy(),
- 'log10_amplitude_phase': _PHASE_COLORMAP.copy(),
- }
-
- _READ_ONLY_MODES = 'amplitude_phase', 'log10_amplitude_phase'
- """Modes that requires a read-only colormap."""
-
- def setVisualizationMode(self, mode):
- """Set the visualization mode to use.
-
- :param str mode:
- """
- mode = str(mode)
- assert mode in self._colormaps
-
- if mode != self._mode:
- # Save current colormap
- self._colormaps[self._mode] = self.getColormap()
- self._mode = mode
-
- # Set colormap for new mode
- self.setColormap(self._colormaps[mode])
-
- def getVisualizationMode(self):
- """Returns the visualization mode in use."""
- return self._mode
-
- def _isReadOnlyColormap(self):
- """Returns True if colormap should not be modified."""
- return self.getVisualizationMode() in self._READ_ONLY_MODES
-
- def setColormap(self, colormap):
- if not self._isReadOnlyColormap():
- super(_ImageComplexData, self).setColormap(colormap)
-
- def getColormap(self):
- if self._isReadOnlyColormap():
- return _PHASE_COLORMAP.copy()
- else:
- return super(_ImageComplexData, self).getColormap()
-
-
# Widgets
class _AmplitudeRangeDialog(qt.QDialog):
@@ -291,13 +169,19 @@ class _ComplexDataToolButton(qt.QToolButton):
:param plot: The :class:`ComplexImageView` to control
"""
- _MODES = [
- ('absolute', 'math-amplitude', 'Amplitude'),
- ('phase', 'math-phase', 'Phase'),
- ('real', 'math-real', 'Real part'),
- ('imaginary', 'math-imaginary', 'Imaginary part'),
- ('amplitude_phase', 'math-phase-color', 'Amplitude and Phase'),
- ('log10_amplitude_phase', 'math-phase-color-log', 'Log10(Amp.) and Phase')]
+ _MODES = collections.OrderedDict([
+ (ImageComplexData.Mode.ABSOLUTE, ('math-amplitude', 'Amplitude')),
+ (ImageComplexData.Mode.SQUARE_AMPLITUDE,
+ ('math-square-amplitude', 'Square amplitude')),
+ (ImageComplexData.Mode.PHASE, ('math-phase', 'Phase')),
+ (ImageComplexData.Mode.REAL, ('math-real', 'Real part')),
+ (ImageComplexData.Mode.IMAGINARY,
+ ('math-imaginary', 'Imaginary part')),
+ (ImageComplexData.Mode.AMPLITUDE_PHASE,
+ ('math-phase-color', 'Amplitude and Phase')),
+ (ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE,
+ ('math-phase-color-log', 'Log10(Amp.) and Phase'))
+ ])
_RANGE_DIALOG_TEXT = 'Set Amplitude Range...'
@@ -311,8 +195,10 @@ class _ComplexDataToolButton(qt.QToolButton):
menu.triggered.connect(self._triggered)
self.setMenu(menu)
- for _, icon, text in self._MODES:
+ for mode, info in self._MODES.items():
+ icon, text = info
action = qt.QAction(icons.getQIcon(icon), text, self)
+ action.setData(mode)
action.setIconVisibleInMenu(True)
menu.addAction(action)
@@ -328,13 +214,10 @@ class _ComplexDataToolButton(qt.QToolButton):
def _modeChanged(self, mode):
"""Handle change of visualization modes"""
- for actionMode, icon, text in self._MODES:
- if actionMode == mode:
- self.setIcon(icons.getQIcon(icon))
- self.setToolTip('Display the ' + text.lower())
- break
-
- self._rangeDialogAction.setEnabled(mode == 'log10_amplitude_phase')
+ icon, text = self._MODES[mode]
+ self.setIcon(icons.getQIcon(icon))
+ self.setToolTip('Display the ' + text.lower())
+ self._rangeDialogAction.setEnabled(mode == ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE)
def _triggered(self, action):
"""Handle triggering of menu actions"""
@@ -360,9 +243,9 @@ class _ComplexDataToolButton(qt.QToolButton):
dialog.sigRangeChanged.disconnect(self._rangeChanged)
else: # update mode
- for mode, _, text in self._MODES:
- if actionText == text:
- self._plot2DComplex.setVisualizationMode(mode)
+ mode = action.data()
+ if isinstance(mode, ImageComplexData.Mode):
+ self._plot2DComplex.setVisualizationMode(mode)
def _rangeChanged(self, range_):
"""Handle updates of range in the dialog"""
@@ -375,10 +258,13 @@ class ComplexImageView(qt.QWidget):
:param parent: See :class:`QMainWindow`
"""
+ Mode = ImageComplexData.Mode
+ """Also expose the modes inside the class"""
+
sigDataChanged = qt.Signal()
"""Signal emitted when data has changed."""
- sigVisualizationModeChanged = qt.Signal(str)
+ sigVisualizationModeChanged = qt.Signal(object)
"""Signal emitted when the visualization mode has changed.
It provides the new visualization mode.
@@ -389,11 +275,6 @@ class ComplexImageView(qt.QWidget):
if parent is None:
self.setWindowTitle('ComplexImageView')
- self._mode = 'absolute'
- self._amplitudeRangeInfo = None, 2
- self._data = numpy.zeros((0, 0), dtype=numpy.complex)
- self._displayedData = numpy.zeros((0, 0), dtype=numpy.float)
-
self._plot2D = Plot2D(self)
layout = qt.QHBoxLayout(self)
@@ -403,10 +284,9 @@ class ComplexImageView(qt.QWidget):
self.setLayout(layout)
# Create and add image to the plot
- self._plotImage = _ImageComplexData()
+ self._plotImage = ImageComplexData()
self._plotImage._setLegend('__ComplexImageView__complex_image__')
- self._plotImage.setData(self._displayedData)
- self._plotImage.setVisualizationMode(self._mode)
+ self._plotImage.sigItemChanged.connect(self._itemChanged)
self._plot2D._add(self._plotImage)
self._plot2D.setActiveImage(self._plotImage.getLegend())
@@ -416,57 +296,18 @@ class ComplexImageView(qt.QWidget):
self._plot2D.insertToolBar(self._plot2D.getProfileToolbar(), toolBar)
+ def _itemChanged(self, event):
+ """Handle item changed signal"""
+ if event is items.ItemChangedType.DATA:
+ self.sigDataChanged.emit()
+ elif event is items.ItemChangedType.VISUALIZATION_MODE:
+ mode = self.getVisualizationMode()
+ self.sigVisualizationModeChanged.emit(mode)
+
def getPlot(self):
"""Return the PlotWidget displaying the data"""
return self._plot2D
- def _convertData(self, data, mode):
- """Convert complex data according to provided mode.
-
- :param numpy.ndarray data: The complex data to convert
- :param str mode: The visualization mode
- :return: The data corresponding to the mode
- :rtype: 2D numpy.ndarray of float or RGBA image
- """
- if mode == 'absolute':
- return numpy.absolute(data)
- elif mode == 'phase':
- return numpy.angle(data)
- elif mode == 'real':
- return numpy.real(data)
- elif mode == 'imaginary':
- return numpy.imag(data)
- elif mode == 'amplitude_phase':
- return _complex2rgbalin(data)
- elif mode == 'log10_amplitude_phase':
- max_, delta = self._getAmplitudeRangeInfo()
- return _complex2rgbalog(data, dlogs=delta, smax=max_)
- else:
- _logger.error(
- 'Unsupported conversion mode: %s, fallback to absolute',
- str(mode))
- return numpy.absolute(data)
-
- def _updatePlot(self):
- """Update the image in the plot"""
-
- mode = self.getVisualizationMode()
-
- self.getPlot().getColormapAction().setDisabled(
- mode in ('amplitude_phase', 'log10_amplitude_phase'))
-
- self._plotImage.setVisualizationMode(mode)
-
- image = self.getDisplayedData(copy=False)
- if mode in ('amplitude_phase', 'log10_amplitude_phase'):
- # Combined view
- absolute = numpy.absolute(self.getData(copy=False))
- self._plotImage.setData(
- absolute, alternative=image, copy=False)
- else:
- self._plotImage.setData(
- image, alternative=None, copy=False)
-
def setData(self, data=None, copy=True):
"""Set the complex data to display.
@@ -476,22 +317,13 @@ class ComplexImageView(qt.QWidget):
"""
if data is None:
data = numpy.zeros((0, 0), dtype=numpy.complex)
- else:
- data = numpy.array(data, copy=copy)
-
- assert data.ndim == 2
- if data.dtype.kind != 'c': # Convert to complex
- data = numpy.array(data, dtype=numpy.complex)
- shape_changed = (self._data.shape != data.shape)
- self._data = data
- self._displayedData = self._convertData(
- data, self.getVisualizationMode())
-
- self._updatePlot()
- if shape_changed:
- self.getPlot().resetZoom()
- self.sigDataChanged.emit()
+ previousData = self._plotImage.getComplexData(copy=False)
+
+ self._plotImage.setData(data, copy=copy)
+
+ if previousData.shape != data.shape:
+ self.getPlot().resetZoom()
def getData(self, copy=True):
"""Get the currently displayed complex data.
@@ -501,7 +333,7 @@ class ComplexImageView(qt.QWidget):
:return: The complex data array.
:rtype: numpy.ndarray of complex with 2 dimensions
"""
- return numpy.array(self._data, copy=copy)
+ return self._plotImage.getComplexData(copy=copy)
def getDisplayedData(self, copy=True):
"""Returns the displayed data depending on the visualization mode
@@ -512,7 +344,12 @@ class ComplexImageView(qt.QWidget):
False to return internal data (do not modify!)
:rtype: numpy.ndarray of float with 2 dims or RGBA image (uint8).
"""
- return numpy.array(self._displayedData, copy=copy)
+ mode = self.getVisualizationMode()
+ if mode in (self.Mode.AMPLITUDE_PHASE,
+ self.Mode.LOG10_AMPLITUDE_PHASE):
+ return self._plotImage.getRgbaImageData(copy=copy)
+ else:
+ return self._plotImage.getData(copy=copy)
@staticmethod
def getSupportedVisualizationModes():
@@ -530,12 +367,7 @@ class ComplexImageView(qt.QWidget):
:rtype: tuple of str
"""
- return ('absolute',
- 'phase',
- 'real',
- 'imaginary',
- 'amplitude_phase',
- 'log10_amplitude_phase')
+ return tuple(ImageComplexData.Mode)
def setVisualizationMode(self, mode):
"""Set the mode of visualization of the complex data.
@@ -545,20 +377,14 @@ class ComplexImageView(qt.QWidget):
:param str mode: The mode to use.
"""
- assert mode in self.getSupportedVisualizationModes()
- if mode != self._mode:
- self._mode = mode
- self._displayedData = self._convertData(
- self.getData(copy=False), mode)
- self._updatePlot()
- self.sigVisualizationModeChanged.emit(mode)
+ self._plotImage.setVisualizationMode(mode)
def getVisualizationMode(self):
"""Get the current visualization mode of the complex data.
- :rtype: str
+ :rtype: Mode
"""
- return self._mode
+ return self._plotImage.getVisualizationMode()
def _setAmplitudeRangeInfo(self, max_=None, delta=2):
"""Set the amplitude range to display for 'log10_amplitude_phase' mode.
@@ -567,39 +393,35 @@ class ComplexImageView(qt.QWidget):
If None it autoscales to data max.
:param float delta: Delta range in log10 to display
"""
- self._amplitudeRangeInfo = max_, float(delta)
- mode = self.getVisualizationMode()
- if mode == 'log10_amplitude_phase':
- self._displayedData = self._convertData(
- self.getData(copy=False), mode)
- self._updatePlot()
+ self._plotImage._setAmplitudeRangeInfo(max_, delta)
def _getAmplitudeRangeInfo(self):
"""Returns the amplitude range to use for 'log10_amplitude_phase' mode.
:return: (max, delta), if max is None, then it autoscales to data max
:rtype: 2-tuple"""
- return self._amplitudeRangeInfo
+ return self._plotImage._getAmplitudeRangeInfo()
# Image item proxy
- def setColormap(self, colormap):
+ def setColormap(self, colormap, mode=None):
"""Set the colormap to use for amplitude, phase, real or imaginary.
WARNING: This colormap is not used when displaying both
amplitude and phase.
- :param Colormap colormap: The colormap
+ :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap
+ :param Mode mode: If specified, set the colormap of this specific mode
"""
- self._plotImage.setColormap(colormap)
+ self._plotImage.setColormap(colormap, mode)
- def getColormap(self):
+ def getColormap(self, mode=None):
"""Returns the colormap used to display the data.
- :rtype: Colormap
+ :param Mode mode: If specified, set the colormap of this specific mode
+ :rtype: ~silx.gui.plot.Colormap.Colormap
"""
- # Returns internal colormap and bypass forcing colormap
- return items.ImageData.getColormap(self._plotImage)
+ return self._plotImage.getColormap(mode=mode)
def getOrigin(self):
"""Returns the offset from origin at which to display the image.
diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py
index 4b10cd6..ccb6866 100644
--- a/silx/gui/plot/CurvesROIWidget.py
+++ b/silx/gui/plot/CurvesROIWidget.py
@@ -46,7 +46,7 @@ ROI are defined by :
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "13/11/2017"
from collections import OrderedDict
@@ -57,6 +57,8 @@ import sys
import numpy
from silx.io import dictdump
+from silx.utils import deprecation
+
from .. import icons, qt
@@ -84,10 +86,14 @@ class CurvesROIWidget(qt.QWidget):
'rowheader'
"""
- def __init__(self, parent=None, name=None):
+ sigROISignal = qt.Signal(object)
+
+ def __init__(self, parent=None, name=None, plot=None):
super(CurvesROIWidget, self).__init__(parent)
if name is not None:
self.setWindowTitle(name)
+ assert plot is not None
+ self.plot = plot
layout = qt.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@@ -151,6 +157,19 @@ class CurvesROIWidget(qt.QWidget):
self.saveButton.clicked.connect(self._save)
self.roiTable.sigROITableSignal.connect(self._forward)
+ self.currentROI = None
+ self._middleROIMarkerFlag = False
+ self._isConnected = False # True if connected to plot signals
+ self._isInit = False
+
+ def showEvent(self, event):
+ self._visibilityChangedHandler(visible=True)
+ qt.QWidget.showEvent(self, event)
+
+ def hideEvent(self, event):
+ self._visibilityChangedHandler(visible=False)
+ qt.QWidget.hideEvent(self, event)
+
@property
def roiFileDir(self):
"""The directory from which to load/save ROI from/to files."""
@@ -214,6 +233,19 @@ class CurvesROIWidget(qt.QWidget):
return OrderedDict([(name, roidict[name]) for name in ordered_roilist])
+ def setMiddleROIMarkerFlag(self, flag=True):
+ """Activate or deactivate middle marker.
+
+ This allows shifting both min and max limits at once, by dragging
+ a marker located in the middle.
+
+ :param bool flag: True to activate middle ROI marker
+ """
+ if flag:
+ self._middleROIMarkerFlag = True
+ else:
+ self._middleROIMarkerFlag = False
+
def _add(self):
"""Add button clicked handler"""
ddict = {}
@@ -365,6 +397,322 @@ class CurvesROIWidget(qt.QWidget):
"""Set the header text of this widget"""
self.headerLabel.setText("<b>%s<\b>" % text)
+ def _roiSignal(self, ddict):
+ """Handle ROI widget signal"""
+ _logger.debug("CurvesROIWidget._roiSignal %s", str(ddict))
+ if ddict['event'] == "AddROI":
+ xmin, xmax = self.plot.getXAxis().getLimits()
+ fromdata = xmin + 0.25 * (xmax - xmin)
+ todata = xmin + 0.75 * (xmax - xmin)
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if self._middleROIMarkerFlag:
+ self.plot.remove('ROI middle', kind='marker')
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+ nrois = len(roiList)
+ if nrois == 0:
+ newroi = "ICR"
+ fromdata, dummy0, todata, dummy1 = self._getAllLimits()
+ draggable = False
+ color = 'black'
+ else:
+ for i in range(nrois):
+ i += 1
+ newroi = "newroi %d" % i
+ if newroi not in roiList:
+ break
+ color = 'blue'
+ draggable = True
+ self.plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ self.plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
+ if draggable and self._middleROIMarkerFlag:
+ pos = 0.5 * (fromdata + todata)
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=draggable)
+ roiList.append(newroi)
+ roiDict[newroi] = {}
+ if newroi == "ICR":
+ roiDict[newroi]['type'] = "Default"
+ else:
+ roiDict[newroi]['type'] = self.plot.getXAxis().getLabel()
+ roiDict[newroi]['from'] = fromdata
+ roiDict[newroi]['to'] = todata
+ self.roiTable.fillFromROIDict(roilist=roiList,
+ roidict=roiDict,
+ currentroi=newroi)
+ self.currentROI = newroi
+ self.calculateRois()
+ elif ddict['event'] in ['DelROI', "ResetROI"]:
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if self._middleROIMarkerFlag:
+ self.plot.remove('ROI middle', kind='marker')
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+ roiDictKeys = list(roiDict.keys())
+ if len(roiDictKeys):
+ currentroi = roiDictKeys[0]
+ else:
+ # create again the ICR
+ ddict = {"event": "AddROI"}
+ return self._roiSignal(ddict)
+
+ self.roiTable.fillFromROIDict(roilist=roiList,
+ roidict=roiDict,
+ currentroi=currentroi)
+ self.currentROI = currentroi
+
+ elif ddict['event'] == 'LoadROI':
+ self.calculateRois()
+
+ elif ddict['event'] == 'selectionChanged':
+ _logger.debug("Selection changed")
+ self.roilist, self.roidict = self.roiTable.getROIListAndDict()
+ fromdata = ddict['roi']['from']
+ todata = ddict['roi']['to']
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if ddict['key'] == 'ICR':
+ draggable = False
+ color = 'black'
+ else:
+ draggable = True
+ color = 'blue'
+ self.plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ self.plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
+ if draggable and self._middleROIMarkerFlag:
+ pos = 0.5 * (fromdata + todata)
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=True)
+ self.currentROI = ddict['key']
+ if ddict['colheader'] in ['From', 'To']:
+ dict0 = {}
+ dict0['event'] = "SetActiveCurveEvent"
+ dict0['legend'] = self.plot.getActiveCurve(just_legend=1)
+ self.plot.setActiveCurve(dict0['legend'])
+ elif ddict['colheader'] == 'Raw Counts':
+ pass
+ elif ddict['colheader'] == 'Net Counts':
+ pass
+ else:
+ self._emitCurrentROISignal()
+
+ else:
+ _logger.debug("Unknown or ignored event %s", ddict['event'])
+
+ def _getAllLimits(self):
+ """Retrieve the limits based on the curves."""
+ curves = self.plot.getAllCurves()
+ if not curves:
+ return 1.0, 1.0, 100., 100.
+
+ xmin, ymin = None, None
+ xmax, ymax = None, None
+
+ for curve in curves:
+ x = curve.getXData(copy=False)
+ y = curve.getYData(copy=False)
+ if xmin is None:
+ xmin = x.min()
+ else:
+ xmin = min(xmin, x.min())
+ if xmax is None:
+ xmax = x.max()
+ else:
+ xmax = max(xmax, x.max())
+ if ymin is None:
+ ymin = y.min()
+ else:
+ ymin = min(ymin, y.min())
+ if ymax is None:
+ ymax = y.max()
+ else:
+ ymax = max(ymax, y.max())
+
+ return xmin, ymin, xmax, ymax
+
+ @deprecation.deprecated(replacement="calculateRois",
+ reason="CamelCase convention")
+ def calculateROIs(self, *args, **kw):
+ self.calculateRois(*args, **kw)
+
+ def calculateRois(self, roiList=None, roiDict=None):
+ """Compute ROI information"""
+ if roiList is None or roiDict is None:
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+
+ activeCurve = self.plot.getActiveCurve(just_legend=False)
+ if activeCurve is None:
+ xproc = None
+ yproc = None
+ self.setHeader()
+ else:
+ x = activeCurve.getXData(copy=False)
+ y = activeCurve.getYData(copy=False)
+ legend = activeCurve.getLegend()
+ idx = numpy.argsort(x, kind='mergesort')
+ xproc = numpy.take(x, idx)
+ yproc = numpy.take(y, idx)
+ self.setHeader('ROIs of %s' % legend)
+
+ for key in roiList:
+ if key == 'ICR':
+ if xproc is not None:
+ roiDict[key]['from'] = xproc.min()
+ roiDict[key]['to'] = xproc.max()
+ else:
+ roiDict[key]['from'] = 0
+ roiDict[key]['to'] = -1
+ fromData = roiDict[key]['from']
+ toData = roiDict[key]['to']
+ if xproc is not None:
+ idx = numpy.nonzero((fromData <= xproc) &
+ (xproc <= toData))[0]
+ if len(idx):
+ xw = xproc[idx]
+ yw = yproc[idx]
+ rawCounts = yw.sum(dtype=numpy.float)
+ deltaX = xw[-1] - xw[0]
+ deltaY = yw[-1] - yw[0]
+ if deltaX > 0.0:
+ slope = (deltaY / deltaX)
+ background = yw[0] + slope * (xw - xw[0])
+ netCounts = (rawCounts -
+ background.sum(dtype=numpy.float))
+ else:
+ netCounts = 0.0
+ else:
+ rawCounts = 0.0
+ netCounts = 0.0
+ roiDict[key]['rawcounts'] = rawCounts
+ roiDict[key]['netcounts'] = netCounts
+ else:
+ roiDict[key].pop('rawcounts', None)
+ roiDict[key].pop('netcounts', None)
+
+ self.roiTable.fillFromROIDict(
+ roilist=roiList,
+ roidict=roiDict,
+ currentroi=self.currentROI if self.currentROI in roiList else None)
+
+ def _emitCurrentROISignal(self):
+ ddict = {}
+ ddict['event'] = "currentROISignal"
+ _roiList, roiDict = self.roiTable.getROIListAndDict()
+ if self.currentROI in roiDict:
+ ddict['ROI'] = roiDict[self.currentROI]
+ else:
+ self.currentROI = None
+ ddict['current'] = self.currentROI
+ self.sigROISignal.emit(ddict)
+
+ def _handleROIMarkerEvent(self, ddict):
+ """Handle plot signals related to marker events."""
+ if ddict['event'] == 'markerMoved':
+
+ label = ddict['label']
+ if label not in ['ROI min', 'ROI max', 'ROI middle']:
+ return
+
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+ if self.currentROI is None:
+ return
+ if self.currentROI not in roiDict:
+ return
+ x = ddict['x']
+
+ if label == 'ROI min':
+ roiDict[self.currentROI]['from'] = x
+ if self._middleROIMarkerFlag:
+ pos = 0.5 * (roiDict[self.currentROI]['to'] +
+ roiDict[self.currentROI]['from'])
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
+ elif label == 'ROI max':
+ roiDict[self.currentROI]['to'] = x
+ if self._middleROIMarkerFlag:
+ pos = 0.5 * (roiDict[self.currentROI]['to'] +
+ roiDict[self.currentROI]['from'])
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
+ elif label == 'ROI middle':
+ delta = x - 0.5 * (roiDict[self.currentROI]['from'] +
+ roiDict[self.currentROI]['to'])
+ roiDict[self.currentROI]['from'] += delta
+ roiDict[self.currentROI]['to'] += delta
+ self.plot.addXMarker(roiDict[self.currentROI]['from'],
+ legend='ROI min',
+ text='ROI min',
+ color='blue',
+ draggable=True)
+ self.plot.addXMarker(roiDict[self.currentROI]['to'],
+ legend='ROI max',
+ text='ROI max',
+ color='blue',
+ draggable=True)
+ else:
+ return
+ self.calculateRois(roiList, roiDict)
+ self._emitCurrentROISignal()
+
+ def _visibilityChangedHandler(self, visible):
+ """Handle widget's visibility updates.
+
+ It is connected to plot signals only when visible.
+ """
+ if visible:
+ if not self._isInit:
+ # Deferred ROI widget init finalization
+ self._isInit = True
+ self.sigROIWidgetSignal.connect(self._roiSignal)
+ # initialize with the ICR
+ self._roiSignal({'event': "AddROI"})
+
+ if not self._isConnected:
+ self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent)
+ self.plot.sigActiveCurveChanged.connect(
+ self._activeCurveChanged)
+ self._isConnected = True
+
+ self.calculateRois()
+ else:
+ if self._isConnected:
+ self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent)
+ self.plot.sigActiveCurveChanged.disconnect(
+ self._activeCurveChanged)
+ self._isConnected = False
+
+ def _activeCurveChanged(self, *args):
+ """Recompute ROIs when active curve changed."""
+ self.calculateRois()
+
class ROITable(qt.QTableWidget):
"""Table widget displaying ROI information.
@@ -622,6 +970,9 @@ class CurvesROIDockWidget(qt.QDockWidget):
:param name: See :class:`QDockWidget`
"""
sigROISignal = qt.Signal(object)
+ """Deprecated signal for backward compatibility with silx < 0.7.
+ Prefer connecting directly to :attr:`CurvesRoiWidget.sigRoiSignal`
+ """
def __init__(self, parent=None, plot=None, name=None):
super(CurvesROIDockWidget, self).__init__(name, parent)
@@ -629,25 +980,24 @@ class CurvesROIDockWidget(qt.QDockWidget):
assert plot is not None
self.plot = plot
- self.currentROI = None
- self._middleROIMarkerFlag = False
-
- self._isConnected = False # True if connected to plot signals
- self._isInit = False
-
- self.roiWidget = CurvesROIWidget(self, name)
+ self.roiWidget = CurvesROIWidget(self, name, plot=plot)
"""Main widget of type :class:`CurvesROIWidget`"""
# convenience methods to offer a simpler API allowing to ignore
# the details of the underlying implementation
- self.calculateROIs = self.calculateRois
+ # (ALL DEPRECATED)
+ self.calculateROIs = self.calculateRois = self.roiWidget.calculateRois
self.setRois = self.roiWidget.setRois
self.getRois = self.roiWidget.getRois
+ self.roiWidget.sigROISignal.connect(self._forwardSigROISignal)
+ self.currentROI = self.roiWidget.currentROI
self.layout().setContentsMargins(0, 0, 0, 0)
self.setWidget(self.roiWidget)
- self.visibilityChanged.connect(self._visibilityChangedHandler)
+ def _forwardSigROISignal(self, ddict):
+ # emit deprecated signal for backward compatibility (silx < 0.7)
+ self.sigROISignal.emit(ddict)
def toggleViewAction(self):
"""Returns a checkable action that shows or closes this widget.
@@ -658,320 +1008,10 @@ class CurvesROIDockWidget(qt.QDockWidget):
action.setIcon(icons.getQIcon('plot-roi'))
return action
- def _visibilityChangedHandler(self, visible):
- """Handle widget's visibilty updates.
-
- It is connected to plot signals only when visible.
- """
- if visible:
- if not self._isInit:
- # Deferred ROI widget init finalization
- self._isInit = True
- self.roiWidget.sigROIWidgetSignal.connect(self._roiSignal)
- # initialize with the ICR
- self._roiSignal({'event': "AddROI"})
-
- if not self._isConnected:
- self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent)
- self.plot.sigActiveCurveChanged.connect(
- self._activeCurveChanged)
- self._isConnected = True
-
- self.calculateROIs()
- else:
- if self._isConnected:
- self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent)
- self.plot.sigActiveCurveChanged.disconnect(
- self._activeCurveChanged)
- self._isConnected = False
-
- def _handleROIMarkerEvent(self, ddict):
- """Handle plot signals related to marker events."""
- if ddict['event'] == 'markerMoved':
-
- label = ddict['label']
- if label not in ['ROI min', 'ROI max', 'ROI middle']:
- return
-
- roiList, roiDict = self.roiWidget.getROIListAndDict()
- if self.currentROI is None:
- return
- if self.currentROI not in roiDict:
- return
- x = ddict['x']
-
- if label == 'ROI min':
- roiDict[self.currentROI]['from'] = x
- if self._middleROIMarkerFlag:
- pos = 0.5 * (roiDict[self.currentROI]['to'] +
- roiDict[self.currentROI]['from'])
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text='',
- color='yellow',
- draggable=True)
- elif label == 'ROI max':
- roiDict[self.currentROI]['to'] = x
- if self._middleROIMarkerFlag:
- pos = 0.5 * (roiDict[self.currentROI]['to'] +
- roiDict[self.currentROI]['from'])
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text='',
- color='yellow',
- draggable=True)
- elif label == 'ROI middle':
- delta = x - 0.5 * (roiDict[self.currentROI]['from'] +
- roiDict[self.currentROI]['to'])
- roiDict[self.currentROI]['from'] += delta
- roiDict[self.currentROI]['to'] += delta
- self.plot.addXMarker(roiDict[self.currentROI]['from'],
- legend='ROI min',
- text='ROI min',
- color='blue',
- draggable=True)
- self.plot.addXMarker(roiDict[self.currentROI]['to'],
- legend='ROI max',
- text='ROI max',
- color='blue',
- draggable=True)
- else:
- return
- self.calculateROIs(roiList, roiDict)
- self._emitCurrentROISignal()
-
- def _roiSignal(self, ddict):
- """Handle ROI widget signal"""
- _logger.debug("PlotWindow._roiSignal %s", str(ddict))
- if ddict['event'] == "AddROI":
- xmin, xmax = self.plot.getXAxis().getLimits()
- fromdata = xmin + 0.25 * (xmax - xmin)
- todata = xmin + 0.75 * (xmax - xmin)
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
- if self._middleROIMarkerFlag:
- self.remove('ROI middle', kind='marker')
- roiList, roiDict = self.roiWidget.getROIListAndDict()
- nrois = len(roiList)
- if nrois == 0:
- newroi = "ICR"
- fromdata, dummy0, todata, dummy1 = self._getAllLimits()
- draggable = False
- color = 'black'
- else:
- for i in range(nrois):
- i += 1
- newroi = "newroi %d" % i
- if newroi not in roiList:
- break
- color = 'blue'
- draggable = True
- self.plot.addXMarker(fromdata,
- legend='ROI min',
- text='ROI min',
- color=color,
- draggable=draggable)
- self.plot.addXMarker(todata,
- legend='ROI max',
- text='ROI max',
- color=color,
- draggable=draggable)
- if draggable and self._middleROIMarkerFlag:
- pos = 0.5 * (fromdata + todata)
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text="",
- color='yellow',
- draggable=draggable)
- roiList.append(newroi)
- roiDict[newroi] = {}
- if newroi == "ICR":
- roiDict[newroi]['type'] = "Default"
- else:
- roiDict[newroi]['type'] = self.plot.getXAxis().getLabel()
- roiDict[newroi]['from'] = fromdata
- roiDict[newroi]['to'] = todata
- self.roiWidget.fillFromROIDict(roilist=roiList,
- roidict=roiDict,
- currentroi=newroi)
- self.currentROI = newroi
- self.calculateROIs()
- elif ddict['event'] in ['DelROI', "ResetROI"]:
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
- if self._middleROIMarkerFlag:
- self.plot.remove('ROI middle', kind='marker')
- roiList, roiDict = self.roiWidget.getROIListAndDict()
- roiDictKeys = list(roiDict.keys())
- if len(roiDictKeys):
- currentroi = roiDictKeys[0]
- else:
- # create again the ICR
- ddict = {"event": "AddROI"}
- return self._roiSignal(ddict)
-
- self.roiWidget.fillFromROIDict(roilist=roiList,
- roidict=roiDict,
- currentroi=currentroi)
- self.currentROI = currentroi
-
- elif ddict['event'] == 'LoadROI':
- self.calculateROIs()
-
- elif ddict['event'] == 'selectionChanged':
- _logger.debug("Selection changed")
- self.roilist, self.roidict = self.roiWidget.getROIListAndDict()
- fromdata = ddict['roi']['from']
- todata = ddict['roi']['to']
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
- if ddict['key'] == 'ICR':
- draggable = False
- color = 'black'
- else:
- draggable = True
- color = 'blue'
- self.plot.addXMarker(fromdata,
- legend='ROI min',
- text='ROI min',
- color=color,
- draggable=draggable)
- self.plot.addXMarker(todata,
- legend='ROI max',
- text='ROI max',
- color=color,
- draggable=draggable)
- if draggable and self._middleROIMarkerFlag:
- pos = 0.5 * (fromdata + todata)
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text="",
- color='yellow',
- draggable=True)
- self.currentROI = ddict['key']
- if ddict['colheader'] in ['From', 'To']:
- dict0 = {}
- dict0['event'] = "SetActiveCurveEvent"
- dict0['legend'] = self.plot.getActiveCurve(just_legend=1)
- self.plot.setActiveCurve(dict0['legend'])
- elif ddict['colheader'] == 'Raw Counts':
- pass
- elif ddict['colheader'] == 'Net Counts':
- pass
- else:
- self._emitCurrentROISignal()
-
- else:
- _logger.debug("Unknown or ignored event %s", ddict['event'])
-
- def _activeCurveChanged(self, *args):
- """Recompute ROIs when active curve changed."""
- self.calculateROIs()
-
- def calculateRois(self, roiList=None, roiDict=None):
- """Compute ROI information"""
- if roiList is None or roiDict is None:
- roiList, roiDict = self.roiWidget.getROIListAndDict()
-
- activeCurve = self.plot.getActiveCurve(just_legend=False)
- if activeCurve is None:
- xproc = None
- yproc = None
- self.roiWidget.setHeader()
- else:
- x = activeCurve.getXData(copy=False)
- y = activeCurve.getYData(copy=False)
- legend = activeCurve.getLegend()
- idx = numpy.argsort(x, kind='mergesort')
- xproc = numpy.take(x, idx)
- yproc = numpy.take(y, idx)
- self.roiWidget.setHeader('ROIs of %s' % legend)
-
- for key in roiList:
- if key == 'ICR':
- if xproc is not None:
- roiDict[key]['from'] = xproc.min()
- roiDict[key]['to'] = xproc.max()
- else:
- roiDict[key]['from'] = 0
- roiDict[key]['to'] = -1
- fromData = roiDict[key]['from']
- toData = roiDict[key]['to']
- if xproc is not None:
- idx = numpy.nonzero((fromData <= xproc) &
- (xproc <= toData))[0]
- if len(idx):
- xw = xproc[idx]
- yw = yproc[idx]
- rawCounts = yw.sum(dtype=numpy.float)
- deltaX = xw[-1] - xw[0]
- deltaY = yw[-1] - yw[0]
- if deltaX > 0.0:
- slope = (deltaY / deltaX)
- background = yw[0] + slope * (xw - xw[0])
- netCounts = (rawCounts -
- background.sum(dtype=numpy.float))
- else:
- netCounts = 0.0
- else:
- rawCounts = 0.0
- netCounts = 0.0
- roiDict[key]['rawcounts'] = rawCounts
- roiDict[key]['netcounts'] = netCounts
- else:
- roiDict[key].pop('rawcounts', None)
- roiDict[key].pop('netcounts', None)
-
- self.roiWidget.fillFromROIDict(
- roilist=roiList,
- roidict=roiDict,
- currentroi=self.currentROI if self.currentROI in roiList else None)
-
- def _emitCurrentROISignal(self):
- ddict = {}
- ddict['event'] = "currentROISignal"
- _roiList, roiDict = self.roiWidget.getROIListAndDict()
- if self.currentROI in roiDict:
- ddict['ROI'] = roiDict[self.currentROI]
- else:
- self.currentROI = None
- ddict['current'] = self.currentROI
- self.sigROISignal.emit(ddict)
-
- def _getAllLimits(self):
- """Retrieve the limits based on the curves."""
- curves = self.plot.getAllCurves()
- if not curves:
- return 1.0, 1.0, 100., 100.
-
- xmin, ymin = None, None
- xmax, ymax = None, None
-
- for curve in curves:
- x = curve.getXData(copy=False)
- y = curve.getYData(copy=False)
- if xmin is None:
- xmin = x.min()
- else:
- xmin = min(xmin, x.min())
- if xmax is None:
- xmax = x.max()
- else:
- xmax = max(xmax, x.max())
- if ymin is None:
- ymin = y.min()
- else:
- ymin = min(ymin, y.min())
- if ymax is None:
- ymax = y.max()
- else:
- ymax = max(ymax, y.max())
-
- return xmin, ymin, xmax, ymax
-
def showEvent(self, event):
"""Make sure this widget is raised when it is shown
(when it is first created as a tab in PlotWindow or when it is shown
again after hiding).
"""
self.raise_()
+ qt.QDockWidget.showEvent(self, event)
diff --git a/silx/gui/plot/Interaction.py b/silx/gui/plot/Interaction.py
index f09b9bc..358af74 100644
--- a/silx/gui/plot/Interaction.py
+++ b/silx/gui/plot/Interaction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2016 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index 430489d..fc5fcf4 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -22,13 +22,14 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides a set of QToolButton to use with :class:`.PlotWidget`.
+"""This module provides a set of QToolButton to use with
+:class:`~silx.gui.plot.PlotWidget`.
The following QToolButton are available:
-- :class:`AspectToolButton`
-- :class:`YAxisOriginToolButton`
-- :class:`ProfileToolButton`
+- :class:`.AspectToolButton`
+- :class:`.YAxisOriginToolButton`
+- :class:`.ProfileToolButton`
"""
@@ -46,7 +47,7 @@ _logger = logging.getLogger(__name__)
class PlotToolButton(qt.QToolButton):
- """A QToolButton connected to a :class:`.PlotWidget`.
+ """A QToolButton connected to a :class:`~silx.gui.plot.PlotWidget`.
"""
def __init__(self, parent=None, plot=None):
@@ -93,6 +94,7 @@ class PlotToolButton(qt.QToolButton):
class AspectToolButton(PlotToolButton):
+ """Tool button to switch keep aspect ratio of a plot"""
STATE = None
"""Lazy loaded states used to feed AspectToolButton"""
@@ -159,6 +161,7 @@ class AspectToolButton(PlotToolButton):
class YAxisOriginToolButton(PlotToolButton):
+ """Tool button to switch the Y axis orientation of a plot."""
STATE = None
"""Lazy loaded states used to feed YAxisOriginToolButton"""
diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py
index ed62d48..7fadfd2 100644
--- a/silx/gui/plot/PlotTools.py
+++ b/silx/gui/plot/PlotTools.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -83,10 +83,10 @@ class PositionInfo(qt.QWidget):
>>> plot.show() # To display the PlotWindow with the position widget
:param plot: The PlotWidget this widget is displaying data coords from.
- :param converters: List of name to display and conversion function from
- (x, y) in data coords to displayed value.
- If None, the default, it displays X and Y.
- :type converters: Iterable of 2-tuple (str, function)
+ :param converters:
+ List of 2-tuple: name to display and conversion function from (x, y)
+ in data coords to displayed value.
+ If None, the default, it displays X and Y.
:param parent: Parent widget
"""
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index 5bf2b59..3641b8c 100644
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -23,151 +23,7 @@
# ###########################################################################*/
"""Qt widget providing plot API for 1D and 2D data.
-Widget with plot API for 1D and 2D data.
-
The :class:`PlotWidget` implements the plot API initially provided in PyMca.
-
-Plot Events
------------
-
-The :class:`PlotWidget` sends some event to the registered callback
-(See :meth:`PlotWidget.setCallback`).
-Those events are sent as a dictionary with a key 'event' describing the kind
-of event.
-
-Drawing events
-..............
-
-'drawingProgress' and 'drawingFinished' events are sent during drawing
-interaction (See :meth:`PlotWidget.setInteractiveMode`).
-
-- 'event': 'drawingProgress' or 'drawingFinished'
-- 'parameters': dict of parameters used by the drawing mode.
- It has the following keys: 'shape', 'label', 'color'.
- See :meth:`PlotWidget.setInteractiveMode`.
-- 'points': Points (x, y) in data coordinates of the drawn shape.
- For 'hline' and 'vline', it is the 2 points defining the line.
- For 'line' and 'rectangle', it is the coordinates of the start
- drawing point and the latest drawing point.
- For 'polygon', it is the coordinates of all points of the shape.
-- 'type': The type of drawing in 'line', 'hline', 'polygon', 'rectangle',
- 'vline'.
-- 'xdata' and 'ydata': X coords and Y coords of shape points in data
- coordinates (as in 'points').
-
-When the type is 'rectangle', the following additional keys are provided:
-
-- 'x' and 'y': The origin of the rectangle in data coordinates
-- 'widht' and 'height': The size of the rectangle in data coordinates
-
-
-Mouse events
-............
-
-'mouseMoved', 'mouseClicked' and 'mouseDoubleClicked' events are sent for
-mouse events.
-
-They provide the following keys:
-
-- 'event': 'mouseMoved', 'mouseClicked' or 'mouseDoubleClicked'
-- 'button': the mouse button that was pressed in 'left', 'middle', 'right'
-- 'x' and 'y': The mouse position in data coordinates
-- 'xpixel' and 'ypixel': The mouse position in pixels
-
-
-Marker events
-.............
-
-'hover', 'markerClicked', 'markerMoving' and 'markerMoved' events are
-sent during interaction with markers.
-
-'hover' is sent when the mouse cursor is over a marker.
-'markerClicker' is sent when the user click on a selectable marker.
-'markerMoving' and 'markerMoved' are sent when a draggable marker is moved.
-
-They provide the following keys:
-
-- 'event': 'hover', 'markerClicked', 'markerMoving' or 'markerMoved'
-- 'button': the mouse button that is pressed in 'left', 'middle', 'right'
-- 'draggable': True if the marker is draggable, False otherwise
-- 'label': The legend associated with the clicked image or curve
-- 'selectable': True if the marker is selectable, False otherwise
-- 'type': 'marker'
-- 'x' and 'y': The mouse position in data coordinates
-- 'xdata' and 'ydata': The marker position in data coordinates
-
-'markerClicked' and 'markerMoving' events have a 'xpixel' and a 'ypixel'
-additional keys, that provide the mouse position in pixels.
-
-
-Image and curve events
-......................
-
-'curveClicked' and 'imageClicked' events are sent when a selectable curve
-or image is clicked.
-
-Both share the following keys:
-
-- 'event': 'curveClicked' or 'imageClicked'
-- 'button': the mouse button that was pressed in 'left', 'middle', 'right'
-- 'label': The legend associated with the clicked image or curve
-- 'type': The type of item in 'curve', 'image'
-- 'x' and 'y': The clicked position in data coordinates
-- 'xpixel' and 'ypixel': The clicked position in pixels
-
-'curveClicked' events have a 'xdata' and a 'ydata' additional keys, that
-provide the coordinates of the picked points of the curve.
-There can be more than one point of the curve being picked, and if a line of
-the curve is picked, only the first point of the line is included in the list.
-
-'imageClicked' have a 'col' and a 'row' additional keys, that provide
-the column and row index in the image array that was clicked.
-
-
-Limits changed events
-.....................
-
-'limitsChanged' events are sent when the limits of the plot are changed.
-This can results from user interaction or API calls.
-
-It provides the following keys:
-
-- 'event': 'limitsChanged'
-- 'source': id of the widget that emitted this event.
-- 'xdata': Range of X in graph coordinates: (xMin, xMax).
-- 'ydata': Range of Y in graph coordinates: (yMin, yMax).
-- 'y2data': Range of right axis in graph coordinates (y2Min, y2Max) or None.
-
-Plot state change events
-........................
-
-The following events are emitted when the plot is modified.
-They provide the new state:
-
-- 'setGraphCursor' event with a 'state' key (bool)
-- 'setGraphGrid' event with a 'which' key (str), see :meth:`setGraphGrid`
-- 'setKeepDataAspectRatio' event with a 'state' key (bool)
-
-A 'contentChanged' event is triggered when the content of the plot is updated.
-It provides the following keys:
-
-- 'action': The change of the plot: 'add' or 'remove'
-- 'kind': The kind of primitive changed: 'curve', 'image', 'item' or 'marker'
-- 'legend': The legend of the primitive changed.
-
-'activeCurveChanged' and 'activeImageChanged' events with the following keys:
-
-- 'legend': Name (str) of the current active item or None if no active item.
-- 'previous': Name (str) of the previous active item or None if no item was
- active. It is the same as 'legend' if 'updated' == True
-- 'updated': (bool) True if active item name did not changed,
- but active item data or style was updated.
-
-'interactiveModeChanged' event with a 'source' key identifying the object
-setting the interactive mode.
-
-'defaultColormapChanged' event is triggered when the default colormap of
-the plot is updated.
"""
from __future__ import division
@@ -264,7 +120,8 @@ class PlotWidget(qt.QMainWindow):
"""Signal for all events of the plot.
The signal information is provided as a dict.
- See :class:`PlotWidget` for documentation of the content of the dict.
+ See the :ref:`plot signal documentation page <plot_signal>` for
+ information about the content of the dict
"""
sigSetKeepDataAspectRatio = qt.Signal(bool)
@@ -574,7 +431,7 @@ class PlotWidget(qt.QMainWindow):
item._setPlot(self)
if item.isVisible():
self._itemRequiresUpdate(item)
- if isinstance(item, (items.Curve, items.ImageBase)):
+ if isinstance(item, items.DATA_ITEMS):
self._invalidateDataRange() # TODO handle this automatically
self._notifyContentChanged(item)
@@ -964,7 +821,7 @@ class PlotWidget(qt.QMainWindow):
:param colormap: Description of the :class:`.Colormap` to use
(or None).
This is ignored if data is a RGB(A) image.
- :type colormap: Colormap or dict (old API )
+ :type colormap: Union[silx.gui.plot.Colormap.Colormap, dict]
:param pixmap: Pixmap representation of the data (if any)
:type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default)
:param str xlabel: X axis label to show when this curve is active,
@@ -1107,8 +964,8 @@ class PlotWidget(qt.QMainWindow):
:param numpy.ndarray y: The data corresponding to the y coordinates
:param numpy.ndarray value: The data value associated with each point
:param str legend: The legend to be associated to the scatter (or None)
- :param Colormap colormap: The :class:`.Colormap`. to be used for the
- scatter (or None)
+ :param silx.gui.plot.Colormap.Colormap colormap:
+ The :class:`.Colormap`. to be used for the scatter (or None)
:param info: User-defined information associated to the curve
:param str symbol: Symbol to be drawn at each (x, y) position::
@@ -2407,9 +2264,10 @@ class PlotWidget(qt.QMainWindow):
It only affects future calls to :meth:`addImage` without the colormap
parameter.
- :param Colormap colormap: The description of the default colormap, or
- None to set the :class:`.Colormap` to a linear
- autoscale gray colormap.
+ :param silx.gui.plot.Colormap.Colormap colormap:
+ The description of the default colormap, or
+ None to set the :class:`.Colormap` to a linear
+ autoscale gray colormap.
"""
if colormap is None:
colormap = Colormap(name='gray',
@@ -2533,6 +2391,7 @@ class PlotWidget(qt.QMainWindow):
if ddict['event'] in ["legendClicked", "curveClicked"]:
if ddict['button'] == "left":
self.setActiveCurve(ddict['label'])
+ qt.QToolTip.showText(self.cursor().pos(), ddict['label'])
def saveGraph(self, filename, fileFormat=None, dpi=None, **kw):
"""Save a snapshot of the plot.
@@ -2817,7 +2676,7 @@ class PlotWidget(qt.QMainWindow):
def test(mark):
return True
- markers = self._backend.pickItems(x, y)
+ markers = self._backend.pickItems(x, y, kinds=('marker',))
legends = [m['legend'] for m in markers if m['kind'] == 'marker']
for legend in reversed(legends):
@@ -2852,7 +2711,8 @@ class PlotWidget(qt.QMainWindow):
To use for interaction implementation.
- :param float x: X position in pixelsparam float y: Y position in pixels
+ :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.
"""
@@ -2860,7 +2720,7 @@ class PlotWidget(qt.QMainWindow):
def test(i):
return True
- allItems = self._backend.pickItems(x, y)
+ allItems = self._backend.pickItems(x, y, kinds=('curve', 'image'))
allItems = [item for item in allItems
if item['kind'] in ['curve', 'image']]
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index a23db04..5c7e661 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -29,7 +29,7 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`.
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "17/08/2017"
+__date__ = "15/02/2018"
import collections
import logging
@@ -41,6 +41,7 @@ from . import actions
from . import items
from .actions import medfilt as actions_medfilt
from .actions import fit as actions_fit
+from .actions import control as actions_control
from .actions import histogram as actions_histogram
from . import PlotToolButtons
from .PlotTools import PositionInfo
@@ -112,6 +113,10 @@ class PlotWindow(PlotWidget):
self._legendsDockWidget = None
self._curvesROIDockWidget = None
self._maskToolsDockWidget = None
+ self._consoleDockWidget = None
+
+ # Create color bar, hidden by default for backward compatibility
+ self._colorbar = ColorBarWidget(parent=self, plot=self)
# Init actions
self.group = qt.QActionGroup(self)
@@ -168,6 +173,12 @@ class PlotWindow(PlotWidget):
self.colormapAction.setVisible(colormap)
self.addAction(self.colormapAction)
+ self.colorbarAction = self.group.addAction(
+ actions_control.ColorBarAction(self, self))
+ self.colorbarAction.setVisible(False)
+ self.addAction(self.colorbarAction)
+ self._colorbar.setVisible(False)
+
self.keepDataAspectRatioButton = PlotToolButtons.AspectToolButton(
parent=self, plot=self)
self.keepDataAspectRatioButton.setVisible(aspectRatio)
@@ -219,10 +230,6 @@ class PlotWindow(PlotWidget):
self._panWithArrowKeysAction = None
self._crosshairAction = None
- # Create color bar, hidden by default for backward compatibility
- self._colorbar = ColorBarWidget(parent=self, plot=self)
- self._colorbar.setVisible(False)
-
# Make colorbar background white
self._colorbar.setAutoFillBackground(True)
palette = self._colorbar.palette()
@@ -301,11 +308,11 @@ class PlotWindow(PlotWidget):
"""
return bool(self.getMaskToolsDockWidget().setSelectionMask(mask))
- def _toggleConsoleVisibility(self, is_checked=False):
+ def _toggleConsoleVisibility(self, isChecked=False):
"""Create IPythonDockWidget if needed,
show it or hide it."""
# create widget if needed (first call)
- if not hasattr(self, '_consoleDockWidget'):
+ if self._consoleDockWidget is None:
available_vars = {"plt": self}
banner = "The variable 'plt' is available. Use the 'whos' "
banner += "and 'help(plt)' commands for more information.\n\n"
@@ -314,10 +321,11 @@ class PlotWindow(PlotWidget):
custom_banner=banner,
parent=self)
self.addTabbedDockWidget(self._consoleDockWidget)
- self._consoleDockWidget.visibilityChanged.connect(
+ # self._consoleDockWidget.setVisible(True)
+ self._consoleDockWidget.toggleViewAction().toggled.connect(
self.getConsoleAction().setChecked)
- self._consoleDockWidget.setVisible(is_checked)
+ self._consoleDockWidget.setVisible(isChecked)
def _createToolBar(self, title, parent):
"""Create a QToolBar from the QAction of the PlotWindow.
@@ -427,16 +435,22 @@ class PlotWindow(PlotWidget):
return self._legendsDockWidget
@property
- @deprecated(replacement="getCurvesRoiDockWidget()", since_version="0.4.0")
+ @deprecated(replacement="getCurvesRoiWidget()", since_version="0.4.0")
def curvesROIDockWidget(self):
return self.getCurvesRoiDockWidget()
def getCurvesRoiDockWidget(self):
- """DockWidget with curves' ROI panel (lazy-loaded).
+ # Undocumented for a "soft deprecation" in version 0.7.0
+ # (still used internally for lazy loading)
+ if self._curvesROIDockWidget is None:
+ self._curvesROIDockWidget = CurvesROIDockWidget(
+ plot=self, name='Regions Of Interest')
+ self._curvesROIDockWidget.hide()
+ self.addTabbedDockWidget(self._curvesROIDockWidget)
+ return self._curvesROIDockWidget
- The widget returned is a :class:`CurvesROIDockWidget`.
- Its central widget is a :class:`CurvesROIWidget`
- accessible as :attr:`CurvesROIDockWidget.roiWidget`.
+ def getCurvesRoiWidget(self):
+ """Return the :class:`CurvesROIWidget`.
:class:`silx.gui.plot.CurvesROIWidget.CurvesROIWidget` offers a getter
and a setter for the ROI data:
@@ -444,12 +458,7 @@ class PlotWindow(PlotWidget):
- :meth:`CurvesROIWidget.getRois`
- :meth:`CurvesROIWidget.setRois`
"""
- if self._curvesROIDockWidget is None:
- self._curvesROIDockWidget = CurvesROIDockWidget(
- plot=self, name='Regions Of Interest')
- self._curvesROIDockWidget.hide()
- self.addTabbedDockWidget(self._curvesROIDockWidget)
- return self._curvesROIDockWidget
+ return self.getCurvesRoiDockWidget().roiWidget
@property
@deprecated(replacement="getMaskToolsDockWidget()", since_version="0.4.0")
@@ -695,6 +704,16 @@ class PlotWindow(PlotWidget):
"""
return self._medianFilter2DAction
+ def getColorBarAction(self):
+ """Action toggling the colorbar show/hide action
+
+ .. warning:: to show/hide the plot colorbar call directly the ColorBar
+ widget using getColorBarWidget()
+
+ :rtype: actions.PlotAction
+ """
+ return self.colorbarAction
+
class Plot1D(PlotWindow):
"""PlotWindow with tools specific for curves.
@@ -756,6 +775,7 @@ class Plot2D(PlotWindow):
self.profile = ProfileToolBar(plot=self)
self.addToolBar(self.profile)
+ self.colorbarAction.setVisible(True)
self.getColorBarWidget().setVisible(True)
# Put colorbar action after colormap action
@@ -763,9 +783,6 @@ class Plot2D(PlotWindow):
for index, action in enumerate(actions):
if action is self.getColormapAction():
break
- self.toolBar().insertAction(
- actions[index + 1],
- self.getColorBarWidget().getToggleViewAction())
def _getImageValue(self, x, y):
"""Get status bar value of top most image at position (x, y)
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index 4a74fa7..f61412d 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -660,23 +660,23 @@ class ProfileToolBar(qt.QToolBar):
winGeom = self.window().frameGeometry()
qapp = qt.QApplication.instance()
screenGeom = qapp.desktop().availableGeometry(self)
-
spaceOnLeftSide = winGeom.left()
spaceOnRightSide = screenGeom.width() - winGeom.right()
profileWindowWidth = profileMainWindow.frameGeometry().width()
- if (profileWindowWidth < spaceOnRightSide or
- spaceOnRightSide > spaceOnLeftSide):
+ if (profileWindowWidth < spaceOnRightSide):
# Place profile on the right
profileMainWindow.move(winGeom.right(), winGeom.top())
- else:
- # Not enough place on the right, place profile on the left
+ elif(profileWindowWidth < spaceOnLeftSide):
+ # Place profile on the left
profileMainWindow.move(
- max(0, winGeom.left() - profileWindowWidth), winGeom.top())
+ max(0, winGeom.left() - profileWindowWidth), winGeom.top())
profileMainWindow.show()
+ profileMainWindow.raise_()
else:
self.getProfilePlot().show()
+ self.getProfilePlot().raise_()
def hideProfileWindow(self):
"""Hide profile window.
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index 938447b..1fb188c 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -69,7 +69,7 @@ Example::
__authors__ = ["P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "11/09/2017"
+__date__ = "15/02/2018"
import numpy
@@ -82,6 +82,7 @@ from .PlotTools import LimitsToolBar
from .Profile import Profile3DToolBar
from ..widgets.FrameBrowser import HorizontalSliderWithBrowser
+from silx.gui.plot.actions import control as actions_control
from silx.utils.array_like import DatasetView, ListOfImages
from silx.math import calibration
from silx.utils.deprecation import deprecated_warning
@@ -245,9 +246,8 @@ class StackView(qt.QMainWindow):
for index, action in enumerate(actions):
if action is self._plot.getColormapAction():
break
- self._plot.toolBar().insertAction(
- actions[index + 1],
- self._plot.getColorBarWidget().getToggleViewAction())
+ self._colorbarAction = actions_control.ColorBarAction(self._plot, self._plot)
+ self._plot.toolBar().insertAction(actions[index + 1], self._colorbarAction)
def _plotCallback(self, eventDict):
"""Callback for plot events.
@@ -652,7 +652,7 @@ class StackView(qt.QMainWindow):
when the volume is rotated (when different axes are selected as the
X and Y axes).
- :param list(str) labels: 3 labels corresponding to the 3 dimensions
+ :param List[str] labels: 3 labels corresponding to the 3 dimensions
of the data volumes.
"""
@@ -972,6 +972,16 @@ class StackView(qt.QMainWindow):
"""
return self._plot.getActiveImage(just_legend=just_legend)
+ def getColorBarAction(self):
+ """Returns the action managing the visibility of the colorbar.
+
+ .. warning:: to show/hide the plot colorbar call directly the ColorBar
+ widget using getColorBarWidget()
+
+ :rtype: QAction
+ """
+ return self._colorbarAction
+
def remove(self, legend=None,
kind=('curve', 'image', 'item', 'marker')):
"""See :meth:`Plot.Plot.remove`"""
@@ -1102,7 +1112,7 @@ class StackViewMainWindow(StackView):
menu.addSeparator()
menu.addAction(self._plot.resetZoomAction)
menu.addAction(self._plot.colormapAction)
- menu.addAction(self._plot.getColorBarWidget().getToggleViewAction())
+ menu.addAction(self.getColorBarAction())
menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self))
menu.addAction(actions.control.YAxisInvertedAction(self._plot, self))
diff --git a/silx/gui/plot/_utils/test/test_ticklayout.py b/silx/gui/plot/_utils/test/test_ticklayout.py
index 8c67620..927ffb6 100644
--- a/silx/gui/plot/_utils/test/test_ticklayout.py
+++ b/silx/gui/plot/_utils/test/test_ticklayout.py
@@ -27,12 +27,13 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "18/10/2016"
+__date__ = "17/01/2018"
import unittest
+import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot._utils import ticklayout
@@ -40,6 +41,19 @@ from silx.gui.plot._utils import ticklayout
class TestTickLayout(ParametricTestCase):
"""Test ticks layout algorithms"""
+ def testTicks(self):
+ """Test of :func:`ticks`"""
+ tests = { # (vmin, vmax): ref_ticks
+ (1., 1.): (1.,),
+ (0.5, 10.5): (2.0, 4.0, 6.0, 8.0, 10.0),
+ (0.001, 0.005): (0.001, 0.002, 0.003, 0.004, 0.005)
+ }
+
+ for (vmin, vmax), ref_ticks in tests.items():
+ with self.subTest(vmin=vmin, vmax=vmax):
+ ticks, labels = ticklayout.ticks(vmin, vmax)
+ self.assertTrue(numpy.allclose(ticks, ref_ticks))
+
def testNiceNumbers(self):
"""Minimalistic tests of :func:`niceNumbers`"""
tests = { # (vmin, vmax): ref_ticks
diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py
index 5f4b636..6e9f654 100644
--- a/silx/gui/plot/_utils/ticklayout.py
+++ b/silx/gui/plot/_utils/ticklayout.py
@@ -109,7 +109,7 @@ def ticks(vMin, vMax, nbTicks=5):
"""Returns tick positions and labels using nice numbers algorithm.
This enforces ticks to be within [vMin, vMax] range.
- It returns at least 2 ticks.
+ It returns at least 1 tick (when vMin == vMax).
:param float vMin: The min value on the axis
:param float vMax: The max value on the axis
@@ -117,13 +117,19 @@ def ticks(vMin, vMax, nbTicks=5):
:returns: tick positions and corresponding text labels
:rtype: 2-tuple: list of float, list of string
"""
- start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks)
- positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax]
+ assert vMin <= vMax
+ if vMin == vMax:
+ positions = [vMin]
+ nfrac = 0
+
+ else:
+ start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks)
+ positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax]
- # Makes sure there is at least 2 ticks
- if len(positions) < 2:
- positions = [vMin, vMax]
- nfrac = numberOfDigits(vMax - vMin)
+ # Makes sure there is at least 2 ticks
+ if len(positions) < 2:
+ positions = [vMin, vMax]
+ nfrac = numberOfDigits(vMax - vMin)
# Generate labels
format_ = '%g' if nfrac == 0 else '%.{}f'.format(nfrac)
diff --git a/silx/gui/plot/actions/PlotAction.py b/silx/gui/plot/actions/PlotAction.py
index 6eb9ba3..2983775 100644
--- a/silx/gui/plot/actions/PlotAction.py
+++ b/silx/gui/plot/actions/PlotAction.py
@@ -32,10 +32,9 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "20/04/2017"
+__date__ = "03/01/2018"
-from collections import OrderedDict
import weakref
from silx.gui import icons
from silx.gui import qt
diff --git a/silx/gui/plot/actions/__init__.py b/silx/gui/plot/actions/__init__.py
index 73829cd..930c728 100644
--- a/silx/gui/plot/actions/__init__.py
+++ b/silx/gui/plot/actions/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -22,10 +22,14 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This package provides a set of QActions to use with :class:`PlotWidget`
+"""This package provides a set of QAction to use with
+:class:`~silx.gui.plot.PlotWidget`
-It also contains the :class:'.PlotAction' (Base class for QAction that operates
-on a PlotWidget)
+Those actions are useful to add menu items or toolbar items
+that interact with a :class:`~silx.gui.plot.PlotWidget`.
+
+It provides a base class used to define new plot actions:
+:class:`~silx.gui.plot.actions.PlotAction`.
"""
__authors__ = ["H. Payno"]
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index 23e710e..ac6dc2f 100644
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -50,11 +50,10 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "15/02/2018"
from . import PlotAction
import logging
-import numpy
from silx.gui.plot import items
from silx.gui.plot.ColormapDialog import ColormapDialog
from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot
@@ -327,67 +326,112 @@ class ColormapAction(PlotAction):
plot, icon='colormap', text='Colormap',
tooltip="Change colormap",
triggered=self._actionTriggered,
- checkable=False, parent=parent)
+ checkable=True, parent=parent)
+ self.plot.sigActiveImageChanged.connect(self._updateColormap)
+
+ def setColorDialog(self, colorDialog):
+ """Set a specific color dialog instead of using the default dialog."""
+ assert(colorDialog is not None)
+ assert(self._dialog is None)
+ self._dialog = colorDialog
+ self._dialog.visibleChanged.connect(self._dialogVisibleChanged)
+ self.setChecked(self._dialog.isVisible())
+
+ @staticmethod
+ def _createDialog(parent):
+ """Create the dialog if not already existing
+
+ :parent QWidget parent: Parent of the new colormap
+ :rtype: ColormapDialog
+ """
+ dialog = ColormapDialog(parent=parent)
+ dialog.setModal(False)
+ return dialog
def _actionTriggered(self, checked=False):
"""Create a cmap dialog and update active image and default cmap."""
- # Create the dialog if not already existing
if self._dialog is None:
- self._dialog = ColormapDialog()
+ self._dialog = self._createDialog(self.plot)
+ self._dialog.visibleChanged.connect(self._dialogVisibleChanged)
+
+ # Run the dialog listening to colormap change
+ if checked is True:
+ self._dialog.show()
+ self._updateColormap()
+ else:
+ self._dialog.hide()
+
+ def _dialogVisibleChanged(self, isVisible):
+ self.setChecked(isVisible)
+ def _updateColormap(self):
+ if self._dialog is None:
+ return
image = self.plot.getActiveImage()
- if not isinstance(image, items.ColormapMixIn):
- # No active image or active image is RGBA,
- # set dialog from default info
- colormap = self.plot.getDefaultColormap()
- self._dialog.setHistogram() # Reset histogram and range if any
+ if isinstance(image, items.ImageComplexData):
+ # Specific init for complex images
+ colormap = image.getColormap()
- else:
+ mode = image.getVisualizationMode()
+ if mode in (items.ImageComplexData.Mode.AMPLITUDE_PHASE,
+ items.ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE):
+ data = image.getData(
+ copy=False, mode=items.ImageComplexData.Mode.PHASE)
+ else:
+ data = image.getData(copy=False)
+
+ # Set histogram and range if any
+ self._dialog.setData(data)
+
+ elif isinstance(image, items.ColormapMixIn):
# Set dialog from active image
colormap = image.getColormap()
-
data = image.getData(copy=False)
+ # Set histogram and range if any
+ self._dialog.setData(data)
- goodData = data[numpy.isfinite(data)]
- if goodData.size > 0:
- dataMin = goodData.min()
- dataMax = goodData.max()
- else:
- qt.QMessageBox.warning(
- None, "No Data",
- "Image data does not contain any real value")
- dataMin, dataMax = 1., 10.
-
- self._dialog.setHistogram() # Reset histogram if any
- self._dialog.setDataRange(dataMin, dataMax)
- # The histogram should be done in a worker thread
- # hist, bin_edges = numpy.histogram(goodData, bins=256)
- # self._dialog.setHistogram(hist, bin_edges)
-
- self._dialog.setColormap(name=colormap.getName(),
- normalization=colormap.getNormalization(),
- autoscale=colormap.isAutoscale(),
- vmin=colormap.getVMin(),
- vmax=colormap.getVMax(),
- colors=colormap.getColormapLUT())
+ else:
+ # No active image or active image is RGBA,
+ # set dialog from default info
+ colormap = self.plot.getDefaultColormap()
+ # Reset histogram and range if any
+ self._dialog.setData(None)
- # Run the dialog listening to colormap change
- self._dialog.sigColormapChanged.connect(self._colormapChanged)
- result = self._dialog.exec_()
- self._dialog.sigColormapChanged.disconnect(self._colormapChanged)
+ self._dialog.setColormap(colormap)
- if not result: # Restore the previous colormap
- self._colormapChanged(colormap)
- def _colormapChanged(self, colormap):
- # Update default colormap
- self.plot.setDefaultColormap(colormap)
+class ColorBarAction(PlotAction):
+ """QAction opening the ColorBarWidget of the specified plot.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ self._dialog = None # To store an instance of ColormapDialog
+ super(ColorBarAction, self).__init__(
+ plot, icon='colorbar', text='Colorbar',
+ tooltip="Show/Hide the colorbar",
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ colorBarWidget = self.plot.getColorBarWidget()
+ old = self.blockSignals(True)
+ self.setChecked(colorBarWidget.isVisibleTo(self.plot))
+ self.blockSignals(old)
+ colorBarWidget.sigVisibleChanged.connect(self._widgetVisibleChanged)
+
+ def _widgetVisibleChanged(self, isVisible):
+ """Callback when the colorbar `visible` property change."""
+ if self.isChecked() == isVisible:
+ return
+ self.setChecked(isVisible)
- # Update active image colormap
- activeImage = self.plot.getActiveImage()
- if isinstance(activeImage, items.ColormapMixIn):
- activeImage.setColormap(colormap)
+ def _actionTriggered(self, checked=False):
+ """Create a cmap dialog and update active image and default cmap."""
+ colorBarWidget = self.plot.getColorBarWidget()
+ if not colorBarWidget.isHidden() == checked:
+ return
+ self.plot.getColorBarWidget().setVisible(checked)
class KeepAspectRatioAction(PlotAction):
diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py
index d7256ab..5ca649c 100644
--- a/silx/gui/plot/actions/fit.py
+++ b/silx/gui/plot/actions/fit.py
@@ -36,7 +36,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "28/06/2017"
+__date__ = "03/01/2018"
from . import PlotAction
import logging
@@ -111,7 +111,7 @@ class FitAction(PlotAction):
if histo is None and curve is None:
# ambiguous case, we need to ask which plot item to fit
- isd = ItemsSelectionDialog(plot=self.plot)
+ isd = ItemsSelectionDialog(parent=self.plot, plot=self.plot)
isd.setWindowTitle("Select item to be fitted")
isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
isd.setAvailableKinds(["curve", "histogram"])
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index a4a91e9..40ef873 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -39,6 +39,7 @@ __license__ = "MIT"
from . import PlotAction
from silx.math.histogram import Histogramnd
+from silx.math.combo import min_max
import numpy
import logging
from silx.gui import qt
@@ -107,8 +108,7 @@ class PixelIntensitiesHistoAction(PlotAction):
image[:, :, 1] * 0.587 +
image[:, :, 2] * 0.114)
- xmin = numpy.nanmin(image)
- xmax = numpy.nanmax(image)
+ xmin, xmax = min_max(image, min_positive=False, finite=True)
nbins = min(1024, int(numpy.sqrt(image.size)))
data_range = xmin, xmax
diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py
index 50410e3..d6d5909 100644
--- a/silx/gui/plot/actions/io.py
+++ b/silx/gui/plot/actions/io.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -37,10 +37,11 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "02/02/2018"
from . import PlotAction
from silx.io.utils import save1D, savespec
+from silx.io.nxdata import save_NXdata
import logging
import sys
from collections import OrderedDict
@@ -59,6 +60,10 @@ else:
_logger = logging.getLogger(__name__)
+_NEXUS_HDF5_EXT = [".nx5", ".nxs", ".hdf", ".hdf5", ".cxi", ".h5"]
+_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT])
+
+
class SaveAction(PlotAction):
"""QAction for saving Plot content.
@@ -89,12 +94,15 @@ class SaveAction(PlotAction):
('Curve as OMNIC CSV (*.csv)',
{'fmt': '%.7E', 'delimiter': ',', 'header': False}),
('Curve as SpecFile (*.dat)',
- {'fmt': '%.7g', 'delimiter': '', 'header': False})
+ {'fmt': '%.10g', 'delimiter': '', 'header': False})
))
CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)'
- CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY]
+ CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR
+
+ CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY,
+ CURVE_FILTER_NXDATA]
ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", )
@@ -107,6 +115,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_RGB_TIFF = 'Image as TIFF (*.tif)'
+ IMAGE_FILTER_NXDATA = 'Image as NXdata (%s)' % _NEXUS_HDF5_EXT_STR
IMAGE_FILTERS = (IMAGE_FILTER_EDF,
IMAGE_FILTER_TIFF,
IMAGE_FILTER_NUMPY,
@@ -115,7 +124,11 @@ class SaveAction(PlotAction):
IMAGE_FILTER_CSV_SEMICOLON,
IMAGE_FILTER_CSV_TAB,
IMAGE_FILTER_RGB_PNG,
- IMAGE_FILTER_RGB_TIFF)
+ IMAGE_FILTER_RGB_TIFF,
+ IMAGE_FILTER_NXDATA)
+
+ SCATTER_FILTER_NXDATA = 'Scatter as NXdata (%s)' % _NEXUS_HDF5_EXT_STR
+ SCATTER_FILTERS = (SCATTER_FILTER_NXDATA, )
def __init__(self, plot, parent=None):
super(SaveAction, self).__init__(
@@ -183,7 +196,7 @@ class SaveAction(PlotAction):
csvdelim = filter_['delimiter']
autoheader = filter_['header']
else:
- # .npy
+ # .npy or nxdata
fmt, csvdelim, autoheader = ("", "", False)
# If curve has no associated label, get the default from the plot
@@ -194,6 +207,19 @@ class SaveAction(PlotAction):
if ylabel is None:
ylabel = self.plot.getYAxis().getLabel()
+ if nameFilter == self.CURVE_FILTER_NXDATA:
+ return save_NXdata(
+ filename,
+ signal=curve.getYData(copy=False),
+ axes=[curve.getXData(copy=False)],
+ signal_name="y",
+ axes_names=["x"],
+ signal_long_name=ylabel,
+ axes_long_names=[xlabel],
+ signal_errors=curve.getYErrorData(copy=False),
+ axes_errors=[curve.getXErrorData(copy=True)],
+ title=self.plot.getGraphTitle())
+
try:
save1D(filename,
curve.getXData(copy=False),
@@ -226,11 +252,13 @@ class SaveAction(PlotAction):
curve = curves[0]
scanno = 1
try:
+ xlabel = curve.getXLabel() or self.plot.getGraphXLabel()
+ ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis())
specfile = savespec(filename,
curve.getXData(copy=False),
curve.getYData(copy=False),
- curve.getXLabel(),
- curve.getYLabel(),
+ xlabel,
+ ylabel,
fmt="%.7g", scan_number=1, mode="w",
write_file_header=True,
close_file=False)
@@ -241,12 +269,14 @@ class SaveAction(PlotAction):
for curve in curves[1:]:
try:
scanno += 1
+ xlabel = curve.getXLabel() or self.plot.getGraphXLabel()
+ ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis())
specfile = savespec(specfile,
curve.getXData(copy=False),
curve.getYData(copy=False),
- curve.getXLabel(),
- curve.getYLabel(),
- fmt="%.7g", scan_number=scanno, mode="w",
+ xlabel,
+ ylabel,
+ fmt="%.7g", scan_number=scanno,
write_file_header=False,
close_file=False)
except IOError:
@@ -294,6 +324,24 @@ class SaveAction(PlotAction):
return False
return True
+ elif nameFilter == self.IMAGE_FILTER_NXDATA:
+ xorigin, yorigin = image.getOrigin()
+ xscale, yscale = image.getScale()
+ xaxis = xorigin + xscale * numpy.arange(data.shape[1])
+ yaxis = yorigin + yscale * numpy.arange(data.shape[0])
+ xlabel = image.getXLabel() or self.plot.getGraphXLabel()
+ ylabel = image.getYLabel() or self.plot.getGraphYLabel()
+ interpretation = "image" if len(data.shape) == 2 else "rgba-image"
+
+ return save_NXdata(filename,
+ signal=data,
+ axes=[yaxis, xaxis],
+ signal_name="image",
+ axes_names=["y", "x"],
+ axes_long_names=[ylabel, xlabel],
+ title=self.plot.getGraphTitle(),
+ interpretation=interpretation)
+
elif nameFilter in (self.IMAGE_FILTER_ASCII,
self.IMAGE_FILTER_CSV_COMMA,
self.IMAGE_FILTER_CSV_SEMICOLON,
@@ -343,6 +391,45 @@ class SaveAction(PlotAction):
return False
+ def _saveScatter(self, filename, nameFilter):
+ """Save an image from the plot.
+
+ :param str filename: The name of the file to write
+ :param str nameFilter: The selected name filter
+ :return: False if format is not supported or save failed,
+ True otherwise.
+ """
+ if nameFilter not in self.SCATTER_FILTERS:
+ return False
+
+ if nameFilter == self.SCATTER_FILTER_NXDATA:
+ scatter = self.plot.getScatter()
+ # TODO: we could get all scatters on this plot and concatenate their (x, y, values)
+ x = scatter.getXData(copy=False)
+ y = scatter.getYData(copy=False)
+ z = scatter.getValueData(copy=False)
+
+ xerror = scatter.getXErrorData(copy=False)
+ if isinstance(xerror, float):
+ xerror = xerror * numpy.ones(x.shape, dtype=numpy.float32)
+
+ yerror = scatter.getYErrorData(copy=False)
+ if isinstance(yerror, float):
+ yerror = yerror * numpy.ones(x.shape, dtype=numpy.float32)
+
+ xlabel = self.plot.getGraphXLabel()
+ ylabel = self.plot.getGraphYLabel()
+
+ return save_NXdata(
+ filename,
+ signal=z,
+ axes=[x, y],
+ signal_name="values",
+ axes_names=["x", "y"],
+ axes_long_names=[xlabel, ylabel],
+ axes_errors=[xerror, yerror],
+ title=self.plot.getGraphTitle())
+
def _actionTriggered(self, checked=False):
"""Handle save action."""
# Set-up filters
@@ -359,6 +446,11 @@ class SaveAction(PlotAction):
if len(self.plot.getAllCurves()) > 1:
filters.extend(self.ALL_CURVES_FILTERS)
+ # Add scatter filters if there is a scatter
+ # todo: CSV
+ if self.plot.getScatter() is not None:
+ filters.extend(self.SCATTER_FILTERS)
+
filters.extend(self.SNAPSHOT_FILTERS)
# Create and run File dialog
@@ -378,10 +470,19 @@ class SaveAction(PlotAction):
dialog.close()
# Forces the filename extension to match the chosen filter
- extension = nameFilter.split()[-1][2:-1]
- if (len(filename) <= len(extension) or
- filename[-len(extension):].lower() != extension.lower()):
- filename += extension
+ if "NXdata" in nameFilter:
+ has_allowed_ext = False
+ for ext in _NEXUS_HDF5_EXT:
+ if (len(filename) > len(ext) and
+ filename[-len(ext):].lower() == ext.lower()):
+ has_allowed_ext = True
+ if not has_allowed_ext:
+ filename += ".h5"
+ else:
+ default_extension = nameFilter.split()[-1][2:-1]
+ if (len(filename) <= len(default_extension) or
+ filename[-len(default_extension):].lower() != default_extension.lower()):
+ filename += default_extension
# Handle save
if nameFilter in self.SNAPSHOT_FILTERS:
@@ -392,6 +493,8 @@ class SaveAction(PlotAction):
return self._saveCurves(filename, nameFilter)
elif nameFilter in self.IMAGE_FILTERS:
return self._saveImage(filename, nameFilter)
+ elif nameFilter in self.SCATTER_FILTERS:
+ return self._saveScatter(filename, nameFilter)
else:
_logger.warning('Unsupported file filter: %s', nameFilter)
return False
diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py
index 3305d1b..4284a8b 100644
--- a/silx/gui/plot/actions/medfilt.py
+++ b/silx/gui/plot/actions/medfilt.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -39,7 +39,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "24/05/2017"
+__date__ = "03/01/2018"
from . import PlotAction
from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog
@@ -67,7 +67,7 @@ class MedianFilterAction(PlotAction):
self._originalImage = None
self._legend = None
self._filteredImage = None
- self._popup = MedianFilterDialog(parent=None)
+ self._popup = MedianFilterDialog(parent=plot)
self._popup.sigFilterOptChanged.connect(self._updateFilter)
self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
self._updateActiveImage()
@@ -101,7 +101,7 @@ class MedianFilterAction(PlotAction):
self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
def _computeFilteredImage(self, kernelWidth, conditional):
- raise NotImplemented('MedianFilterAction is a an abstract class')
+ raise NotImplementedError('MedianFilterAction is a an abstract class')
def getFilteredImage(self):
"""
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
index 12561b2..45bf785 100644
--- a/silx/gui/plot/backends/BackendBase.py
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -189,7 +189,7 @@ class BackendBase(object):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint, overlay):
+ symbol, constraint):
"""Add a point, vertical line or horizontal line marker to the plot.
:param float x: Horizontal position of the marker in graph coordinates.
@@ -221,9 +221,6 @@ class BackendBase(object):
:type constraint: None or a callable that takes the coordinates of
the current cursor position in the plot as input
and that returns the filtered coordinates.
- :param bool overlay: True if marker is an overlay (Default: False).
- This allows for rendering optimization if this
- marker is changed often.
:return: Handle used by the backend to univocally access the marker
"""
return legend
@@ -270,11 +267,13 @@ class BackendBase(object):
"""
pass
- def pickItems(self, x, y):
+ def pickItems(self, x, y, kinds):
"""Get a list of items at a pixel position.
:param float x: The x pixel coord where to pick.
:param float y: The y pixel coord where to pick.
+ :param List[str] kind: List of item kinds to pick.
+ Supported kinds: 'marker', 'curve', 'image'.
:return: All picked items from back to front.
One dict per item,
with 'kind' key in 'curve', 'marker', 'image';
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index b41f20e..f9a1fe5 100644
--- a/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -58,6 +58,59 @@ from . import BackendBase
from .._utils import FLOAT32_MINPOS
+class _MarkerContainer(Container):
+ """Marker artists container supporting draw/remove and text position update
+
+ :param artists:
+ Iterable with either one Line2D or a Line2D and a Text.
+ The use of an iterable if enforced by Container being
+ a subclass of tuple that defines a specific __new__.
+ :param x: X coordinate of the marker (None for horizontal lines)
+ :param y: Y coordinate of the marker (None for vertical lines)
+ """
+
+ def __init__(self, artists, x, y):
+ self.line = artists[0]
+ self.text = artists[1] if len(artists) > 1 else None
+ self.x = x
+ self.y = y
+
+ Container.__init__(self, artists)
+
+ def draw(self, *args, **kwargs):
+ """artist-like draw to broadcast draw to line and text"""
+ self.line.draw(*args, **kwargs)
+ if self.text is not None:
+ self.text.draw(*args, **kwargs)
+
+ def updateMarkerText(self, xmin, xmax, ymin, ymax):
+ """Update marker text position and visibility according to plot limits
+
+ :param xmin: X axis lower limit
+ :param xmax: X axis upper limit
+ :param ymin: Y axis lower limit
+ :param ymax: Y axis upprt limit
+ """
+ if self.text is not None:
+ visible = ((self.x is None or xmin <= self.x <= xmax) and
+ (self.y is None or ymin <= self.y <= ymax))
+ self.text.set_visible(visible)
+
+ if self.x is not None and self.y is None: # vertical line
+ delta = abs(ymax - ymin)
+ if ymin > ymax:
+ ymax = ymin
+ ymax -= 0.005 * delta
+ self.text.set_y(ymax)
+
+ if self.x is None and self.y is not None: # Horizontal line
+ delta = abs(xmax - xmin)
+ if xmin > xmax:
+ xmax = xmin
+ xmax -= 0.005 * delta
+ self.text.set_x(xmax)
+
+
class BackendMatplotlib(BackendBase.BackendBase):
"""Base class for Matplotlib backend without a FigureCanvas.
@@ -356,10 +409,13 @@ class BackendMatplotlib(BackendBase.BackendBase):
self.ax.add_patch(item)
elif shape in ('polygon', 'polylines'):
- xView = xView.reshape(1, -1)
- yView = yView.reshape(1, -1)
- item = Polygon(numpy.vstack((xView, yView)).T,
- closed=(shape == 'polygon'),
+ points = numpy.array((xView, yView)).T
+ if shape == 'polygon':
+ closed = True
+ else: # shape == 'polylines'
+ closed = numpy.all(numpy.equal(points[0], points[-1]))
+ item = Polygon(points,
+ closed=closed,
fill=False,
label=legend,
color=color)
@@ -381,9 +437,14 @@ class BackendMatplotlib(BackendBase.BackendBase):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint, overlay):
+ symbol, constraint):
legend = "__MARKER__" + legend
+ textArtist = None
+
+ xmin, xmax = self.getGraphXLimits()
+ ymin, ymax = self.getGraphYLimits(axis='left')
+
if x is not None and y is not None:
line = self.ax.plot(x, y, label=legend,
linestyle=" ",
@@ -392,49 +453,35 @@ class BackendMatplotlib(BackendBase.BackendBase):
markersize=10.)[-1]
if text is not None:
- xtmp, ytmp = self.ax.transData.transform_point((x, y))
- inv = self.ax.transData.inverted()
- xtmp, ytmp = inv.transform_point((xtmp, ytmp))
-
if symbol is None:
valign = 'baseline'
else:
valign = 'top'
text = " " + text
- line._infoText = self.ax.text(x, ytmp, text,
- color=color,
- horizontalalignment='left',
- verticalalignment=valign)
+ textArtist = self.ax.text(x, y, text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment=valign)
elif x is not None:
line = self.ax.axvline(x, label=legend, color=color)
if text is not None:
- text = " " + text
- ymin, ymax = self.getGraphYLimits(axis='left')
- delta = abs(ymax - ymin)
- if ymin > ymax:
- ymax = ymin
- ymax -= 0.005 * delta
- line._infoText = self.ax.text(x, ymax, text,
- color=color,
- horizontalalignment='left',
- verticalalignment='top')
+ # Y position will be updated in updateMarkerText call
+ textArtist = self.ax.text(x, 1., " " + text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment='top')
elif y is not None:
line = self.ax.axhline(y, label=legend, color=color)
if text is not None:
- text = " " + text
- xmin, xmax = self.getGraphXLimits()
- delta = abs(xmax - xmin)
- if xmin > xmax:
- xmax = xmin
- xmax -= 0.005 * delta
- line._infoText = self.ax.text(xmax, y, text,
- color=color,
- horizontalalignment='right',
- verticalalignment='top')
+ # X position will be updated in updateMarkerText call
+ textArtist = self.ax.text(1., y, " " + text,
+ color=color,
+ horizontalalignment='right',
+ verticalalignment='top')
else:
raise RuntimeError('A marker must at least have one coordinate')
@@ -442,19 +489,29 @@ class BackendMatplotlib(BackendBase.BackendBase):
if selectable or draggable:
line.set_picker(5)
- if overlay:
- line.set_animated(True)
- self._overlays.add(line)
+ # All markers are overlays
+ line.set_animated(True)
+ if textArtist is not None:
+ textArtist.set_animated(True)
+
+ artists = [line] if textArtist is None else [line, textArtist]
+ container = _MarkerContainer(artists, x, y)
+ container.updateMarkerText(xmin, xmax, ymin, ymax)
+ self._overlays.add(container)
- return line
+ return container
+
+ def _updateMarkers(self):
+ xmin, xmax = self.ax.get_xbound()
+ ymin, ymax = self.ax.get_ybound()
+ for item in self._overlays:
+ if isinstance(item, _MarkerContainer):
+ item.updateMarkerText(xmin, xmax, ymin, ymax)
# Remove methods
def remove(self, item):
# Warning: It also needs to remove extra stuff if added as for markers
- if hasattr(item, "_infoText"): # For markers text
- item._infoText.remove()
- item._infoText = None
self._overlays.discard(item)
try:
item.remove()
@@ -562,6 +619,8 @@ class BackendMatplotlib(BackendBase.BackendBase):
else:
self.ax.set_ylim(max(ymin, ymax), min(ymin, ymax))
+ self._updateMarkers()
+
def getGraphXLimits(self):
if self._dirtyLimits and self.isKeepDataAspectRatio():
self.replot() # makes sure we get the right limits
@@ -570,6 +629,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
def setGraphXLimits(self, xmin, xmax):
self._dirtyLimits = True
self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax))
+ self._updateMarkers()
def getGraphYLimits(self, axis):
assert axis in ('left', 'right')
@@ -607,6 +667,8 @@ class BackendMatplotlib(BackendBase.BackendBase):
else:
ax.set_ylim(ymax, ymin)
+ self._updateMarkers()
+
# Graph axes
def setXAxisLogarithmic(self, flag):
@@ -814,7 +876,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
self._picked.append({'kind': 'curve', 'legend': label,
'indices': event.ind})
- def pickItems(self, x, y):
+ def pickItems(self, x, y, kinds):
self._picked = []
# Weird way to do an explicit picking: Simulate a button press event
@@ -822,7 +884,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
cid = self.mpl_connect('pick_event', self._onPick)
self.fig.pick(mouseEvent)
self.mpl_disconnect(cid)
- picked = self._picked
+
+ picked = [p for p in self._picked if p['kind'] in kinds]
self._picked = None
return picked
@@ -882,6 +945,10 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
xLimits, yLimits, yRightLimits = self._limitsBeforeResize
self._limitsBeforeResize = None
+ if (xLimits != self.ax.get_xbound() or
+ yLimits != self.ax.get_ybound()):
+ self._updateMarkers()
+
if xLimits != self.ax.get_xbound():
self._plot.getXAxis()._emitLimitsChanged()
if yLimits != self.ax.get_ybound():
@@ -889,6 +956,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
if yRightLimits != self.ax2.get_ybound():
self._plot.getYAxis(axis='right')._emitLimitsChanged()
+
self._drawOverlays()
def replot(self):
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index c70b03a..3c18f4f 100644
--- a/silx/gui/plot/backends/BackendOpenGL.py
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -892,11 +892,13 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
for item in self._items.values():
shape2D = item.get('_shape2D')
if shape2D is None:
+ closed = item['shape'] != 'polylines'
shape2D = Shape2D(tuple(zip(item['x'], item['y'])),
fill=item['fill'],
fillColor=item['color'],
stroke=True,
- strokeColor=item['color'])
+ strokeColor=item['color'],
+ strokeClosed=closed)
item['_shape2D'] = shape2D
if ((isXLog and shape2D.xMin < FLOAT32_MINPOS) or
@@ -1032,17 +1034,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
data = numpy.array(data, dtype=numpy.float32, order='C')
colormapIsLog = colormap.getNormalization() == 'log'
-
cmapRange = colormap.getColormapRange(data=data)
-
- # Retrieve colormap LUT from name and color array
- colormapDisp = Colormap(name=colormap.getName(),
- normalization=Colormap.LINEAR,
- vmin=0,
- vmax=255,
- colors=colormap.getColormapLUT())
- colormapLut = colormapDisp.applyToData(
- numpy.arange(256, dtype=numpy.uint8))
+ colormapLut = colormap.getNColors(nbColors=256)
image = GLPlotColormap(data,
origin,
@@ -1087,7 +1080,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addItem(self, x, y, legend, shape, color, fill, overlay, z):
# TODO handle overlay
- if shape not in ('polygon', 'rectangle', 'line', 'vline', 'hline'):
+ if shape not in ('polygon', 'rectangle', 'line',
+ 'vline', 'hline', 'polylines'):
raise NotImplementedError("Unsupported shape {0}".format(shape))
x = numpy.array(x, copy=False)
@@ -1107,6 +1101,9 @@ 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),
@@ -1119,8 +1116,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint, overlay):
- # TODO handle overlay
+ symbol, constraint):
if symbol is None:
symbol = '+'
@@ -1227,90 +1223,93 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
self._plotFrame.size[1] - self._plotFrame.margins.bottom - 1)
return xPlot, yPlot
- def pickItems(self, x, y):
+ def pickItems(self, x, y, kinds):
picked = []
dataPos = self.pixelToData(x, y, axis='left', check=True)
if dataPos is not None:
# Pick markers
- for marker in reversed(list(self._markers.values())):
- pixelPos = self.dataToPixel(
- marker['x'], marker['y'], axis='left', check=False)
- if pixelPos is None: # negative coord on a log axis
- continue
-
- if marker['x'] is None: # Horizontal line
- pt1 = self.pixelToData(
- x, y - self._PICK_OFFSET, axis='left', check=False)
- pt2 = self.pixelToData(
- x, y + self._PICK_OFFSET, axis='left', check=False)
- isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <=
- max(pt1[1], pt2[1]))
-
- elif marker['y'] is None: # Vertical line
- pt1 = self.pixelToData(
- x - self._PICK_OFFSET, y, axis='left', check=False)
- pt2 = self.pixelToData(
- x + self._PICK_OFFSET, y, axis='left', check=False)
- isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <=
- max(pt1[0], pt2[0]))
-
- else:
- isPicked = (
- numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and
- numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET)
-
- if isPicked:
- picked.append(dict(kind='marker',
- legend=marker['legend']))
-
- # Pick image and curves
- for item in self._plotContent.zOrderedPrimitives(reverse=True):
- if isinstance(item, (GLPlotColormap, GLPlotRGBAImage)):
- pickedPos = item.pick(*dataPos)
- if pickedPos is not None:
- picked.append(dict(kind='image',
- legend=item.info['legend']))
-
- elif isinstance(item, GLPlotCurve2D):
- offset = self._PICK_OFFSET
- if item.marker is not None:
- offset = max(item.markerSize / 2., offset)
- if item.lineStyle is not None:
- offset = max(item.lineWidth / 2., offset)
-
- yAxis = item.info['yAxis']
-
- inAreaPos = self._mouseInPlotArea(x - offset, y - offset)
- dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
- axis=yAxis, check=True)
- if dataPos is None:
+ 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
- xPick0, yPick0 = dataPos
- inAreaPos = self._mouseInPlotArea(x + offset, y + offset)
- dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
- axis=yAxis, check=True)
- if dataPos is None:
- continue
- xPick1, yPick1 = dataPos
+ if 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]))
- if xPick0 < xPick1:
- xPickMin, xPickMax = xPick0, xPick1
else:
- xPickMin, xPickMax = xPick1, xPick0
+ isPicked = (
+ numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and
+ numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET)
- if yPick0 < yPick1:
- yPickMin, yPickMax = yPick0, yPick1
- else:
- yPickMin, yPickMax = yPick1, yPick0
-
- pickedIndices = item.pick(xPickMin, yPickMin,
- xPickMax, yPickMax)
- if pickedIndices:
- picked.append(dict(kind='curve',
- legend=item.info['legend'],
- indices=pickedIndices))
+ if isPicked:
+ picked.append(dict(kind='marker',
+ legend=marker['legend']))
+
+ # Pick image and curves
+ if 'image' in kinds or 'curve' in kinds:
+ for item in self._plotContent.zOrderedPrimitives(reverse=True):
+ if ('image' in kinds and
+ isinstance(item, (GLPlotColormap, GLPlotRGBAImage))):
+ pickedPos = item.pick(*dataPos)
+ if pickedPos is not None:
+ picked.append(dict(kind='image',
+ legend=item.info['legend']))
+
+ elif 'curve' in kinds and isinstance(item, GLPlotCurve2D):
+ offset = self._PICK_OFFSET
+ if item.marker is not None:
+ offset = max(item.markerSize / 2., offset)
+ if item.lineStyle is not None:
+ offset = max(item.lineWidth / 2., offset)
+
+ yAxis = item.info['yAxis']
+
+ inAreaPos = self._mouseInPlotArea(x - offset, y - offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick0, yPick0 = dataPos
+
+ inAreaPos = self._mouseInPlotArea(x + offset, y + offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick1, yPick1 = dataPos
+
+ if xPick0 < xPick1:
+ xPickMin, xPickMax = xPick0, xPick1
+ else:
+ xPickMin, xPickMax = xPick1, xPick0
+
+ if yPick0 < yPick1:
+ yPickMin, yPickMax = yPick0, yPick1
+ else:
+ yPickMin, yPickMax = yPick1, yPick0
+
+ pickedIndices = item.pick(xPickMin, yPickMin,
+ xPickMax, yPickMax)
+ if pickedIndices:
+ picked.append(dict(kind='curve',
+ legend=item.info['legend'],
+ indices=pickedIndices))
return picked
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
index 4433613..124a3da 100644
--- a/silx/gui/plot/backends/glutils/GLPlotCurve.py
+++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py
@@ -606,7 +606,7 @@ class _Points2D(object):
""",
ASTERISK: """
float alphaSymbol(vec2 coord, float size) {
- /* Combining +, x and cirle */
+ /* Combining +, x and circle */
vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5)));
vec2 pos = floor(size * coord) + 0.5;
vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py
index f028ee8..83c7ae0 100644
--- a/silx/gui/plot/backends/glutils/PlotImageFile.py
+++ b/silx/gui/plot/backends/glutils/PlotImageFile.py
@@ -93,7 +93,7 @@ def saveImageToFile(data, fileNameOrObj, fileFormat):
assert fileFormat in ('png', 'ppm', 'svg', 'tiff')
if not hasattr(fileNameOrObj, 'write'):
- if sys.version < "3.0":
+ if sys.version_info < (3, ):
fileObj = open(fileNameOrObj, "wb")
else:
if fileFormat in ('png', 'ppm', 'tiff'):
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py
index bf39c87..e7957ac 100644
--- a/silx/gui/plot/items/__init__.py
+++ b/silx/gui/plot/items/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -35,6 +35,7 @@ __date__ = "22/06/2017"
from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa
SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa
AlphaMixIn, LineMixIn, ItemChangedType) # noqa
+from .complex import ImageComplexData # noqa
from .curve import Curve # noqa
from .histogram import Histogram # noqa
from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa
@@ -42,3 +43,7 @@ from .shape import Shape # noqa
from .scatter import Scatter # noqa
from .marker import Marker, XMarker, YMarker # noqa
from .axis import Axis, XAxis, YAxis, YRightAxis
+
+DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter
+"""Classes of items representing data and to consider to compute data bounds.
+"""
diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py
index ff36512..d7e6eff 100644
--- a/silx/gui/plot/items/axis.py
+++ b/silx/gui/plot/items/axis.py
@@ -27,7 +27,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "30/08/2017"
+__date__ = "06/12/2017"
import logging
from ... import qt
@@ -66,7 +66,7 @@ class Axis(qt.QObject):
"""Signal emitted when axis autoscale has changed"""
sigLimitsChanged = qt.Signal(float, float)
- """Signal emitted when axis autoscale has changed"""
+ """Signal emitted when axis limits have changed"""
def __init__(self, plot):
"""Constructor
@@ -262,7 +262,7 @@ class Axis(qt.QObject):
def setLimitsConstraints(self, minPos=None, maxPos=None):
"""
- Set a constaints on the position of the axes.
+ Set a constraint on the position of the axes.
:param float minPos: Minimum allowed axis value.
:param float maxPos: Maximum allowed axis value.
@@ -283,7 +283,7 @@ class Axis(qt.QObject):
def setRangeConstraints(self, minRange=None, maxRange=None):
"""
- Set a constaints on the position of the axes.
+ Set a constraint on the position of the axes.
:param float minRange: Minimum allowed left-to-right span across the
view
diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py
new file mode 100644
index 0000000..ba57e85
--- /dev/null
+++ b/silx/gui/plot/items/complex.py
@@ -0,0 +1,356 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :class:`ImageComplexData` of the :class:`Plot`.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "19/01/2018"
+
+
+import logging
+import numpy
+
+from silx.third_party import enum
+
+from ..Colormap import Colormap
+from .core import ColormapMixIn, ItemChangedType
+from .image import ImageBase
+
+
+_logger = logging.getLogger(__name__)
+
+
+# Complex colormap functions
+
+def _phase2rgb(colormap, data):
+ """Creates RGBA image with colour-coded phase.
+
+ :param Colormap colormap: The colormap to use
+ :param numpy.ndarray data: The data to convert
+ :return: Array of RGBA colors
+ :rtype: numpy.ndarray
+ """
+ if data.size == 0:
+ return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+
+ phase = numpy.angle(data)
+ return colormap.applyToData(phase)
+
+
+def _complex2rgbalog(phaseColormap, data, amin=0., dlogs=2, smax=None):
+ """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha.
+
+ :param Colormap phaseColormap: Colormap to use for the phase
+ :param numpy.ndarray data: the complex data array to convert to RGBA
+ :param float amin: the minimum value for the alpha channel
+ :param float dlogs: amplitude range displayed, in log10 units
+ :param float smax:
+ if specified, all values above max will be displayed with an alpha=1
+ """
+ if data.size == 0:
+ return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+
+ rgba = _phase2rgb(phaseColormap, data)
+ sabs = numpy.absolute(data)
+ if smax is not None:
+ sabs[sabs > smax] = smax
+ a = numpy.log10(sabs + 1e-20)
+ a -= a.max() - dlogs # display dlogs orders of magnitude
+ rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0))
+ return rgba
+
+
+def _complex2rgbalin(phaseColormap, data, gamma=1.0, smax=None):
+ """Returns RGBA colors: colour-coded phase and linear amplitude in alpha.
+
+ :param Colormap phaseColormap: Colormap to use for the phase
+ :param numpy.ndarray data:
+ :param float gamma: Optional exponent gamma applied to the amplitude
+ :param float smax:
+ """
+ if data.size == 0:
+ return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+
+ rgba = _phase2rgb(phaseColormap, data)
+ a = numpy.absolute(data)
+ if smax is not None:
+ a[a > smax] = smax
+ a /= a.max()
+ rgba[..., 3] = 255 * a**gamma
+ return rgba
+
+
+class ImageComplexData(ImageBase, ColormapMixIn):
+ """Specific plot item to force colormap when using complex colormap.
+
+ This is returning the specific colormap when displaying
+ colored phase + amplitude.
+ """
+
+ class Mode(enum.Enum):
+ """Identify available display mode for complex"""
+ ABSOLUTE = 'absolute'
+ PHASE = 'phase'
+ REAL = 'real'
+ IMAGINARY = 'imaginary'
+ AMPLITUDE_PHASE = 'amplitude_phase'
+ LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase'
+ SQUARE_AMPLITUDE = 'square_amplitude'
+
+ def __init__(self):
+ ImageBase.__init__(self)
+ ColormapMixIn.__init__(self)
+ self._data = numpy.zeros((0, 0), dtype=numpy.complex64)
+ self._dataByModesCache = {}
+ self._mode = self.Mode.ABSOLUTE
+ self._amplitudeRangeInfo = None, 2
+
+ # Use default from ColormapMixIn
+ colormap = super(ImageComplexData, self).getColormap()
+
+ phaseColormap = Colormap(
+ name='hsv',
+ vmin=-numpy.pi,
+ vmax=numpy.pi)
+ phaseColormap.setEditable(False)
+
+ self._colormaps = { # Default colormaps for all modes
+ self.Mode.ABSOLUTE: colormap,
+ self.Mode.PHASE: phaseColormap,
+ self.Mode.REAL: colormap,
+ self.Mode.IMAGINARY: colormap,
+ self.Mode.AMPLITUDE_PHASE: phaseColormap,
+ self.Mode.LOG10_AMPLITUDE_PHASE: phaseColormap,
+ self.Mode.SQUARE_AMPLITUDE: colormap,
+ }
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ plot = self.getPlot()
+ assert plot is not None
+ if not self._isPlotLinear(plot):
+ # Do not render with non linear scales
+ return None
+
+ mode = self.getVisualizationMode()
+ if mode in (self.Mode.AMPLITUDE_PHASE,
+ self.Mode.LOG10_AMPLITUDE_PHASE):
+ # For those modes, compute RGBA image here
+ colormap = None
+ data = self.getRgbaImageData(copy=False)
+ else:
+ colormap = self.getColormap()
+ data = self.getData(copy=False)
+
+ if data.size == 0:
+ return None # No data to display
+
+ return backend.addImage(data,
+ legend=self.getLegend(),
+ origin=self.getOrigin(),
+ scale=self.getScale(),
+ z=self.getZValue(),
+ selectable=self.isSelectable(),
+ draggable=self.isDraggable(),
+ colormap=colormap,
+ alpha=self.getAlpha())
+
+
+ def setVisualizationMode(self, mode):
+ """Set the visualization mode to use.
+
+ :param Mode mode:
+ """
+ assert isinstance(mode, self.Mode)
+ assert mode in self._colormaps
+
+ if mode != self._mode:
+ self._mode = mode
+
+ self._updated(ItemChangedType.VISUALIZATION_MODE)
+
+ # Send data updated as value returned by getData has changed
+ self._updated(ItemChangedType.DATA)
+
+ # Update ColormapMixIn colormap
+ colormap = self._colormaps[self._mode]
+ if colormap is not super(ImageComplexData, self).getColormap():
+ super(ImageComplexData, self).setColormap(colormap)
+
+ def getVisualizationMode(self):
+ """Returns the visualization mode in use.
+
+ :rtype: Mode
+ """
+ return self._mode
+
+ def _setAmplitudeRangeInfo(self, max_=None, delta=2):
+ """Set the amplitude range to display for 'log10_amplitude_phase' mode.
+
+ :param max_: Max of the amplitude range.
+ If None it autoscales to data max.
+ :param float delta: Delta range in log10 to display
+ """
+ self._amplitudeRangeInfo = max_, float(delta)
+ self._updated(ItemChangedType.VISUALIZATION_MODE)
+
+ def _getAmplitudeRangeInfo(self):
+ """Returns the amplitude range to use for 'log10_amplitude_phase' mode.
+
+ :return: (max, delta), if max is None, then it autoscales to data max
+ :rtype: 2-tuple"""
+ return self._amplitudeRangeInfo
+
+ def setColormap(self, colormap, mode=None):
+ """Set the colormap for this specific mode.
+
+ :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap
+ :param Mode mode:
+ If specified, set the colormap of this specific mode.
+ Default: current mode.
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ self._colormaps[mode] = colormap
+ if mode is self.getVisualizationMode():
+ super(ImageComplexData, self).setColormap(colormap)
+ else:
+ self._updated(ItemChangedType.COLORMAP)
+
+ def getColormap(self, mode=None):
+ """Get the colormap for the (current) mode.
+
+ :param Mode mode:
+ If specified, get the colormap of this specific mode.
+ Default: current mode.
+ :rtype: ~silx.gui.plot.Colormap.Colormap
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ return self._colormaps[mode]
+
+ def setData(self, data, copy=True):
+ """"Set the image complex data
+
+ :param numpy.ndarray data: 2D array of complex with 2 dimensions (h, w)
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ """
+ data = numpy.array(data, copy=copy)
+ assert data.ndim == 2
+ if not numpy.issubdtype(data.dtype, numpy.complexfloating):
+ _logger.warning(
+ 'Image is not complex, converting it to complex to plot it.')
+ data = numpy.array(data, dtype=numpy.complex64)
+
+ self._data = data
+ self._dataByModesCache = {}
+
+ # TODO hackish data range implementation
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ self._updated(ItemChangedType.DATA)
+
+ def getComplexData(self, copy=True):
+ """Returns the image complex data
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray of complex
+ """
+ return numpy.array(self._data, copy=copy)
+
+ def getData(self, copy=True, mode=None):
+ """Returns the image data corresponding to (current) mode.
+
+ The returned data is always floats, to get the complex data, use
+ :meth:`getComplexData`.
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :param Mode mode:
+ If specified, get data corresponding to the mode.
+ Default: Current mode.
+ :rtype: numpy.ndarray of float
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ if mode not in self._dataByModesCache:
+ # Compute data for mode and store it in cache
+ complexData = self.getComplexData(copy=False)
+ if mode is self.Mode.PHASE:
+ data = numpy.angle(complexData)
+ elif mode is self.Mode.REAL:
+ data = numpy.real(complexData)
+ elif mode is self.Mode.IMAGINARY:
+ data = numpy.imag(complexData)
+ elif mode in (self.Mode.ABSOLUTE,
+ self.Mode.LOG10_AMPLITUDE_PHASE,
+ self.Mode.AMPLITUDE_PHASE):
+ data = numpy.absolute(complexData)
+ elif mode is self.Mode.SQUARE_AMPLITUDE:
+ data = numpy.absolute(complexData) ** 2
+ else:
+ _logger.error(
+ 'Unsupported conversion mode: %s, fallback to absolute',
+ str(mode))
+ data = numpy.absolute(complexData)
+
+ self._dataByModesCache[mode] = data
+
+ return numpy.array(self._dataByModesCache[mode], copy=copy)
+
+ def getRgbaImageData(self, copy=True, mode=None):
+ """Get the displayed RGB(A) image for (current) mode
+
+ :param bool copy: Ignored for this class
+ :param Mode mode:
+ If specified, get data corresponding to the mode.
+ Default: Current mode.
+ :rtype: numpy.ndarray of uint8 of shape (height, width, 4)
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ colormap = self.getColormap(mode=mode)
+ if mode is self.Mode.AMPLITUDE_PHASE:
+ data = self.getComplexData(copy=False)
+ return _complex2rgbalin(colormap, data)
+ elif mode is self.Mode.LOG10_AMPLITUDE_PHASE:
+ data = self.getComplexData(copy=False)
+ max_, delta = self._getAmplitudeRangeInfo()
+ return _complex2rgbalog(colormap, data, dlogs=delta, smax=max_)
+ else:
+ data = self.getData(copy=False, mode=mode)
+ return colormap.applyToData(data)
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 34ac700..bcb6dd1 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -115,6 +115,9 @@ class ItemChangedType(enum.Enum):
OVERLAY = 'overlayChanged'
"""Item's overlay state changed flag."""
+ VISUALIZATION_MODE = 'visualizationModeChanged'
+ """Item's visualization mode changed flag."""
+
class Item(qt.QObject):
"""Description of an item of the plot"""
@@ -136,7 +139,7 @@ class Item(qt.QObject):
"""
def __init__(self):
- super(Item, self).__init__()
+ qt.QObject.__init__(self)
self._dirty = True
self._plotRef = None
self._visible = True
@@ -312,7 +315,24 @@ class Item(qt.QObject):
# Mix-in classes ##############################################################
-class LabelsMixIn(object):
+class ItemMixInBase(qt.QObject):
+ """Base class for Item mix-in"""
+
+ def _updated(self, event=None, checkVisibility=True):
+ """This is implemented in :class:`Item`.
+
+ Mark the item as dirty (i.e., needing update).
+ This also triggers Plot.replot.
+
+ :param event: The event to send to :attr:`sigItemChanged` signal.
+ :param bool checkVisibility: True to only mark as dirty if visible,
+ False to always mark as dirty.
+ """
+ raise RuntimeError(
+ "Issue with Mix-In class inheritance order")
+
+
+class LabelsMixIn(ItemMixInBase):
"""Mix-in class for items with x and y labels
Setters are private, otherwise it needs to check the plot
@@ -352,7 +372,7 @@ class LabelsMixIn(object):
self._ylabel = str(label)
-class DraggableMixIn(object):
+class DraggableMixIn(ItemMixInBase):
"""Mix-in class for draggable items"""
def __init__(self):
@@ -375,7 +395,7 @@ class DraggableMixIn(object):
self._draggable = bool(draggable)
-class ColormapMixIn(object):
+class ColormapMixIn(ItemMixInBase):
"""Mix-in class for items with colormap"""
def __init__(self):
@@ -389,7 +409,7 @@ class ColormapMixIn(object):
def setColormap(self, colormap):
"""Set the colormap of this image
- :param Colormap colormap: colormap description
+ :param silx.gui.plot.Colormap.Colormap colormap: colormap description
"""
if isinstance(colormap, dict):
colormap = Colormap._fromDict(colormap)
@@ -406,7 +426,7 @@ class ColormapMixIn(object):
self._updated(ItemChangedType.COLORMAP)
-class SymbolMixIn(object):
+class SymbolMixIn(ItemMixInBase):
"""Mix-in class for items with symbol type"""
_DEFAULT_SYMBOL = ''
@@ -415,10 +435,49 @@ class SymbolMixIn(object):
_DEFAULT_SYMBOL_SIZE = 6.0
"""Default marker size of the item"""
+ _SUPPORTED_SYMBOLS = collections.OrderedDict((
+ ('o', 'Circle'),
+ ('d', 'Diamond'),
+ ('s', 'Square'),
+ ('+', 'Plus'),
+ ('x', 'Cross'),
+ ('.', 'Point'),
+ (',', 'Pixel'),
+ ('', 'None')))
+ """Dict of supported symbols"""
+
def __init__(self):
self._symbol = self._DEFAULT_SYMBOL
self._symbol_size = self._DEFAULT_SYMBOL_SIZE
+ @classmethod
+ def getSupportedSymbols(cls):
+ """Returns the list of supported symbol names.
+
+ :rtype: tuple of str
+ """
+ return tuple(cls._SUPPORTED_SYMBOLS.keys())
+
+ @classmethod
+ def getSupportedSymbolNames(cls):
+ """Returns the list of supported symbol human-readable names.
+
+ :rtype: tuple of str
+ """
+ return tuple(cls._SUPPORTED_SYMBOLS.values())
+
+ def getSymbolName(self, symbol=None):
+ """Returns human-readable name for a symbol.
+
+ :param str symbol: The symbol from which to get the name.
+ Default: current symbol.
+ :rtype: str
+ :raise KeyError: if symbol is not in :meth:`getSupportedSymbols`.
+ """
+ if symbol is None:
+ symbol = self.getSymbol()
+ return self._SUPPORTED_SYMBOLS[symbol]
+
def getSymbol(self):
"""Return the point marker type.
@@ -441,11 +500,19 @@ class SymbolMixIn(object):
See :meth:`getSymbol`.
- :param str symbol: Marker type
+ :param str symbol: Marker type or marker name
"""
- assert symbol in ('o', '.', ',', '+', 'x', 'd', 's', '', None)
if symbol is None:
symbol = self._DEFAULT_SYMBOL
+
+ elif symbol not in self.getSupportedSymbols():
+ for symbolCode, name in self._SUPPORTED_SYMBOLS.items():
+ if name.lower() == symbol.lower():
+ symbol = symbolCode
+ break
+ else:
+ raise ValueError('Unsupported symbol %s' % str(symbol))
+
if symbol != self._symbol:
self._symbol = symbol
self._updated(ItemChangedType.SYMBOL)
@@ -471,7 +538,7 @@ class SymbolMixIn(object):
self._updated(ItemChangedType.SYMBOL_SIZE)
-class LineMixIn(object):
+class LineMixIn(ItemMixInBase):
"""Mix-in class for item with line"""
_DEFAULT_LINEWIDTH = 1.
@@ -531,7 +598,7 @@ class LineMixIn(object):
self._updated(ItemChangedType.LINE_STYLE)
-class ColorMixIn(object):
+class ColorMixIn(ItemMixInBase):
"""Mix-in class for item with color"""
_DEFAULT_COLOR = (0., 0., 0., 1.)
@@ -570,7 +637,7 @@ class ColorMixIn(object):
self._updated(ItemChangedType.COLOR)
-class YAxisMixIn(object):
+class YAxisMixIn(ItemMixInBase):
"""Mix-in class for item with yaxis"""
_DEFAULT_YAXIS = 'left'
@@ -600,7 +667,7 @@ class YAxisMixIn(object):
self._updated(ItemChangedType.YAXIS)
-class FillMixIn(object):
+class FillMixIn(ItemMixInBase):
"""Mix-in class for item with fill"""
def __init__(self):
@@ -624,7 +691,7 @@ class FillMixIn(object):
self._updated(ItemChangedType.FILL)
-class AlphaMixIn(object):
+class AlphaMixIn(ItemMixInBase):
"""Mix-in class for item with opacity"""
def __init__(self):
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
index acf7bf6..99a916a 100644
--- a/silx/gui/plot/items/image.py
+++ b/silx/gui/plot/items/image.py
@@ -28,7 +28,7 @@ of the :class:`Plot`.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "20/10/2017"
from collections import Sequence
@@ -38,7 +38,6 @@ import numpy
from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn,
AlphaMixIn, ItemChangedType)
-from ..Colors import applyColormapToData
_logger = logging.getLogger(__name__)
@@ -62,7 +61,7 @@ def _convertImageToRgba32(image, copy=True):
assert image.shape[-1] in (3, 4)
# Convert type to uint8
- if image.dtype.name != 'uin8':
+ if image.dtype.name != 'uint8':
if image.dtype.kind == 'f': # Float in [0, 1]
image = (numpy.clip(image, 0., 1.) * 255).astype(numpy.uint8)
elif image.dtype.kind == 'b': # boolean
@@ -334,7 +333,7 @@ class ImageData(ImageBase, ColormapMixIn):
_logger.warning(
'Converting boolean image to int8 to plot it.')
data = numpy.array(data, copy=False, dtype=numpy.int8)
- elif numpy.issubdtype(data.dtype, numpy.complex):
+ elif numpy.iscomplexobj(data):
_logger.warning(
'Converting complex image to absolute value to plot it.')
data = numpy.absolute(data)
diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py
index 5f930b7..8f79033 100644
--- a/silx/gui/plot/items/marker.py
+++ b/silx/gui/plot/items/marker.py
@@ -69,8 +69,7 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
selectable=self.isSelectable(),
draggable=self.isDraggable(),
symbol=symbol,
- constraint=self.getConstraint(),
- overlay=self.isOverlay())
+ constraint=self.getConstraint())
def isOverlay(self):
"""Return true if marker is drawn as an overlay.
diff --git a/silx/gui/plot/matplotlib/Colormap.py b/silx/gui/plot/matplotlib/Colormap.py
index a86d76e..d035605 100644
--- a/silx/gui/plot/matplotlib/Colormap.py
+++ b/silx/gui/plot/matplotlib/Colormap.py
@@ -168,70 +168,16 @@ def getScalarMappable(colormap, data=None):
colors = colors.astype(numpy.float32) / 255.
cmap = matplotlib.colors.ListedColormap(colors)
+ vmin, vmax = colormap.getColormapRange(data)
if colormap.getNormalization().startswith('log'):
- vmin, vmax = None, None
- if not colormap.isAutoscale():
- if colormap.getVMin() > 0.:
- vmin = colormap.getVMin()
- if colormap.getVMax() > 0.:
- vmax = colormap.getVMax()
-
- if vmin is None or vmax is None:
- _logger.warning('Log colormap with negative bounds, ' +
- 'changing bounds to positive ones.')
- elif vmin > vmax:
- _logger.warning('Colormap bounds are inverted.')
- vmin, vmax = vmax, vmin
-
- # Set unset/negative bounds to positive bounds
- if vmin is None or vmax is None:
- # Convert to numpy array
- data = numpy.array(data if data is not None else [], copy=False)
-
- if data.size > 0:
- finiteData = data[numpy.isfinite(data)]
- posData = finiteData[finiteData > 0]
- if vmax is None:
- # 1. as an ultimate fallback
- vmax = posData.max() if posData.size > 0 else 1.
- if vmin is None:
- vmin = posData.min() if posData.size > 0 else vmax
- if vmin > vmax:
- vmin = vmax
- else:
- vmin, vmax = 1., 1.
-
norm = matplotlib.colors.LogNorm(vmin, vmax)
-
else: # Linear normalization
- if colormap.isAutoscale():
- # Convert to numpy array
- data = numpy.array(data if data is not None else [], copy=False)
-
- if data.size == 0:
- vmin, vmax = 1., 1.
- else:
- finiteData = data[numpy.isfinite(data)]
- if finiteData.size > 0:
- vmin = finiteData.min()
- vmax = finiteData.max()
- else:
- vmin, vmax = 1., 1.
-
- else:
- vmin = colormap.getVMin()
- vmax = colormap.getVMax()
- if vmin > vmax:
- _logger.warning('Colormap bounds are inverted.')
- vmin, vmax = vmax, vmin
-
norm = matplotlib.colors.Normalize(vmin, vmax)
return matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap)
-def applyColormapToData(data,
- colormap):
+def applyColormapToData(data, colormap):
"""Apply a colormap to the data and returns the RGBA image
This supports data of any dimensions (not only of dimension 2).
diff --git a/silx/gui/plot/matplotlib/__init__.py b/silx/gui/plot/matplotlib/__init__.py
index be9cb9a..384d049 100644
--- a/silx/gui/plot/matplotlib/__init__.py
+++ b/silx/gui/plot/matplotlib/__init__.py
@@ -59,6 +59,11 @@ elif qt.BINDING == 'PyQt4':
matplotlib.rcParams['backend'] = 'Qt4Agg'
import matplotlib.backends.backend_qt4agg as backend
+elif qt.BINDING == 'PySide2':
+ matplotlib.rcParams['backend'] = 'Qt5Agg'
+ matplotlib.rcParams['backend.qt5'] = 'PySide2'
+ import matplotlib.backends.backend_qt5agg as backend
+
elif qt.BINDING == 'PyQt5':
matplotlib.rcParams['backend'] = 'Qt5Agg'
import matplotlib.backends.backend_qt5agg as backend
diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py
index 07338b6..154a70a 100644
--- a/silx/gui/plot/test/__init__.py
+++ b/silx/gui/plot/test/__init__.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "04/08/2017"
+__date__ = "28/11/2017"
import unittest
@@ -52,6 +52,7 @@ from . import testUtilsAxis
from . import testLimitConstraints
from . import testComplexImageView
from . import testImageView
+from . import testSaveAction
def suite():
@@ -79,5 +80,6 @@ def suite():
testUtilsAxis.suite(),
testLimitConstraints.suite(),
testComplexImageView.suite(),
- testImageView.suite()])
+ testImageView.suite(),
+ testSaveAction.suite()])
return test_suite
diff --git a/silx/gui/plot/test/testColormap.py b/silx/gui/plot/test/testColormap.py
index aa285d3..4888a7c 100644
--- a/silx/gui/plot/test/testColormap.py
+++ b/silx/gui/plot/test/testColormap.py
@@ -29,12 +29,14 @@ from __future__ import absolute_import
__authors__ = ["H.Payno"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "17/01/2018"
import unittest
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot.Colormap import Colormap
+from silx.gui.plot.Colormap import preferredColormaps, setPreferredColormaps
+from silx.utils.exceptions import NotEditableError
class TestDictAPI(unittest.TestCase):
@@ -134,14 +136,11 @@ class TestDictAPI(unittest.TestCase):
'autoscale': False
}
with self.assertRaises(ValueError):
- colormapObject = Colormap._fromDict(clm_dict)
+ Colormap._fromDict(clm_dict)
class TestObjectAPI(ParametricTestCase):
"""Test the new Object API of the colormap"""
- def setUp(self):
- signalHasBeenEmitting = False
-
def testVMinVMax(self):
"""Test getter and setter associated to vmin and vmax values"""
vmin = 1.0
@@ -277,10 +276,73 @@ class TestObjectAPI(ParametricTestCase):
self.assertEqual(image.shape[-1], 4)
self.assertEqual(image.shape[:-1], data.shape)
+ def testGetNColors(self):
+ """Test getNColors method"""
+ # specific LUT
+ colormap = Colormap(name=None,
+ colors=((0, 0, 0), (1, 1, 1)),
+ vmin=1000,
+ vmax=2000)
+ colors = colormap.getNColors()
+ self.assertTrue(numpy.all(numpy.equal(
+ colors,
+ ((0, 0, 0, 255), (255, 255, 255, 255)))))
+
+ def testEditableMode(self):
+ """Make sure the colormap will raise NotEditableError when try to
+ change a colormap not editable"""
+ colormap = Colormap()
+ colormap.setEditable(False)
+ with self.assertRaises(NotEditableError):
+ colormap.setVRange(0., 1.)
+ with self.assertRaises(NotEditableError):
+ colormap.setVMin(1.)
+ with self.assertRaises(NotEditableError):
+ colormap.setVMax(1.)
+ with self.assertRaises(NotEditableError):
+ colormap.setNormalization(Colormap.LOGARITHM)
+ with self.assertRaises(NotEditableError):
+ colormap.setName('magma')
+ with self.assertRaises(NotEditableError):
+ colormap.setColormapLUT(numpy.array([0, 1]))
+ with self.assertRaises(NotEditableError):
+ colormap._setFromDict(colormap._toDict())
+ state = colormap.saveState()
+ with self.assertRaises(NotEditableError):
+ colormap.restoreState(state)
+
+
+class TestPreferredColormaps(unittest.TestCase):
+ """Test get|setPreferredColormaps functions"""
+
+ def setUp(self):
+ # Save preferred colormaps
+ self._colormaps = preferredColormaps()
+
+ def tearDown(self):
+ # Restore saved preferred colormaps
+ setPreferredColormaps(self._colormaps)
+
+ def test(self):
+ colormaps = 'viridis', 'magma'
+
+ setPreferredColormaps(colormaps)
+ self.assertEqual(preferredColormaps(), colormaps)
+
+ with self.assertRaises(ValueError):
+ setPreferredColormaps(())
+
+ with self.assertRaises(ValueError):
+ setPreferredColormaps(('This is not a colormap',))
+
+ colormaps = 'red', 'green'
+ setPreferredColormaps(('This is not a colormap',) + colormaps)
+ self.assertEqual(preferredColormaps(), colormaps)
+
def suite():
test_suite = unittest.TestSuite()
- for ui in (TestDictAPI, TestObjectAPI):
+ for ui in (TestDictAPI, TestObjectAPI, TestPreferredColormaps):
test_suite.addTest(
unittest.defaultTestLoader.loadTestsFromTestCase(ui))
diff --git a/silx/gui/plot/test/testColormapDialog.py b/silx/gui/plot/test/testColormapDialog.py
index d016548..8087369 100644
--- a/silx/gui/plot/test/testColormapDialog.py
+++ b/silx/gui/plot/test/testColormapDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "17/01/2018"
import doctest
@@ -35,6 +35,12 @@ import unittest
from silx.gui.test.utils import qWaitForWindowExposedAndActivate
from silx.gui import qt
from silx.gui.plot import ColormapDialog
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.plot.Colormap import Colormap, preferredColormaps
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.plot.PlotWindow import PlotWindow
+
+import numpy.random
# Makes sure a QApplication exists
@@ -58,9 +64,320 @@ cmapDocTestSuite = doctest.DocTestSuite(ColormapDialog, tearDown=_tearDownQt)
"""Test suite of tests from the module's docstrings."""
+class TestColormapDialog(TestCaseQt, ParametricTestCase):
+ """Test the ColormapDialog."""
+ def setUp(self):
+ TestCaseQt.setUp(self)
+ ParametricTestCase.setUp(self)
+ self.colormap = Colormap(name='gray', vmin=10.0, vmax=20.0,
+ normalization='linear')
+
+ self.colormapDiag = ColormapDialog.ColormapDialog()
+ self.colormapDiag.setAttribute(qt.Qt.WA_DeleteOnClose)
+
+ def tearDown(self):
+ del self.colormapDiag
+ ParametricTestCase.tearDown(self)
+ TestCaseQt.tearDown(self)
+
+ def testGUIEdition(self):
+ """Make sure the colormap is correctly edited and also that the
+ modification are correctly updated if an other colormapdialog is
+ editing the same colormap"""
+ colormapDiag2 = ColormapDialog.ColormapDialog()
+ colormapDiag2.setColormap(self.colormap)
+ self.colormapDiag.setColormap(self.colormap)
+
+ self.colormapDiag._comboBoxColormap.setCurrentName('red')
+ self.colormapDiag._normButtonLog.setChecked(True)
+ self.assertTrue(self.colormap.getName() == 'red')
+ self.assertTrue(self.colormapDiag.getColormap().getName() == 'red')
+ self.assertTrue(self.colormap.getNormalization() == 'log')
+ self.assertTrue(self.colormap.getVMin() == 10)
+ self.assertTrue(self.colormap.getVMax() == 20)
+ # checked second colormap dialog
+ self.assertTrue(colormapDiag2._comboBoxColormap.getCurrentName() == 'red')
+ self.assertTrue(colormapDiag2._normButtonLog.isChecked())
+ self.assertTrue(int(colormapDiag2._minValue.getValue()) == 10)
+ self.assertTrue(int(colormapDiag2._maxValue.getValue()) == 20)
+ colormapDiag2.close()
+
+ def testGUIModalOk(self):
+ """Make sure the colormap is modified if gone through accept"""
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(True)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.colormapDiag._maxValue.setValue(None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Ok),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.assertTrue(self.colormap.getVMax() is None)
+ self.assertTrue(self.colormap.isAutoscale() is True)
+
+ def testGUIModalCancel(self):
+ """Make sure the colormap is not modified if gone through reject"""
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(True)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Cancel),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is not None)
+
+ def testGUIModalClose(self):
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(False)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Close),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is None)
+
+ def testGUIModalReset(self):
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(False)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag.close()
+
+ def testGUIClose(self):
+ """Make sure the colormap is modify if go through reject"""
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.colormapDiag.close()
+ self.assertTrue(self.colormap.getVMin() is None)
+
+ def testSetColormapIsCorrect(self):
+ """Make sure the interface fir the colormap when set a new colormap"""
+ self.colormap.setName('red')
+ for norm in (Colormap.NORMALIZATIONS):
+ for autoscale in (True, False):
+ if autoscale is True:
+ self.colormap.setVRange(None, None)
+ else:
+ self.colormap.setVRange(11, 101)
+ self.colormap.setNormalization(norm)
+ with self.subTest(colormap=self.colormap):
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(
+ self.colormapDiag._normButtonLinear.isChecked() == (norm is Colormap.LINEAR))
+ self.assertTrue(
+ self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
+ self.assertTrue(
+ self.colormapDiag._minValue.isAutoChecked() == autoscale)
+ self.assertTrue(
+ self.colormapDiag._maxValue.isAutoChecked() == autoscale)
+ if autoscale is False:
+ self.assertTrue(self.colormapDiag._minValue.getValue() == 11)
+ self.assertTrue(self.colormapDiag._maxValue.getValue() == 101)
+ self.assertTrue(self.colormapDiag._minValue.isEnabled())
+ self.assertTrue(self.colormapDiag._maxValue.isEnabled())
+ else:
+ self.assertFalse(self.colormapDiag._minValue._numVal.isEnabled())
+ self.assertFalse(self.colormapDiag._maxValue._numVal.isEnabled())
+
+ def testColormapDel(self):
+ """Check behavior if the colormap has been deleted outside. For now
+ we make sure the colormap is still running and nothing more"""
+ self.colormapDiag.setColormap(self.colormap)
+ self.colormapDiag.show()
+ del self.colormap
+ self.assertTrue(self.colormapDiag.getColormap() is None)
+ self.colormapDiag._comboBoxColormap.setCurrentName('blue')
+
+ def testColormapEditedOutside(self):
+ """Make sure the GUI is still up to date if the colormap is modified
+ outside"""
+ self.colormapDiag.setColormap(self.colormap)
+ self.colormapDiag.show()
+
+ self.colormap.setName('red')
+ self.assertTrue(
+ self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
+ self.colormap.setNormalization(Colormap.LOGARITHM)
+ self.assertFalse(self.colormapDiag._normButtonLinear.isChecked())
+ self.colormap.setVRange(11, 201)
+ self.assertTrue(self.colormapDiag._minValue.getValue() == 11)
+ self.assertTrue(self.colormapDiag._maxValue.getValue() == 201)
+ self.assertTrue(self.colormapDiag._minValue._numVal.isEnabled())
+ self.assertTrue(self.colormapDiag._maxValue._numVal.isEnabled())
+ self.assertFalse(self.colormapDiag._minValue.isAutoChecked())
+ self.assertFalse(self.colormapDiag._maxValue.isAutoChecked())
+ self.colormap.setVRange(None, None)
+ self.assertFalse(self.colormapDiag._minValue._numVal.isEnabled())
+ self.assertFalse(self.colormapDiag._maxValue._numVal.isEnabled())
+ self.assertTrue(self.colormapDiag._minValue.isAutoChecked())
+ self.assertTrue(self.colormapDiag._maxValue.isAutoChecked())
+
+ def testSetColormapScenario(self):
+ """Test of a simple scenario of a colormap dialog editing several
+ colormap"""
+ colormap1 = Colormap(name='gray', vmin=10.0, vmax=20.0,
+ normalization='linear')
+ colormap2 = Colormap(name='red', vmin=10.0, vmax=20.0,
+ normalization='log')
+ colormap3 = Colormap(name='blue', vmin=None, vmax=None,
+ normalization='linear')
+ self.colormapDiag.setColormap(self.colormap)
+ self.colormapDiag.setColormap(colormap1)
+ del colormap1
+ self.colormapDiag.setColormap(colormap2)
+ del colormap2
+ self.colormapDiag.setColormap(colormap3)
+ del colormap3
+
+ def testNotPreferredColormap(self):
+ """Test that the colormapEditor is able to edit a colormap which is not
+ part of the 'prefered colormap'
+ """
+ def getFirstNotPreferredColormap():
+ cms = Colormap.getSupportedColormaps()
+ preferred = preferredColormaps()
+ for cm in cms:
+ if cm not in preferred:
+ return cm
+ return None
+
+ colormapName = getFirstNotPreferredColormap()
+ assert colormapName is not None
+ colormap = Colormap(name=colormapName)
+ self.colormapDiag.setColormap(colormap)
+ self.colormapDiag.show()
+ cb = self.colormapDiag._comboBoxColormap
+ self.assertTrue(cb.getCurrentName() == colormapName)
+ cb.setCurrentIndex(0)
+ index = cb.findColormap(colormapName)
+ assert index is not 0 # if 0 then the rest of the test has no sense
+ cb.setCurrentIndex(index)
+ self.assertTrue(cb.getCurrentName() == colormapName)
+
+ def testColormapEditableMode(self):
+ """Test that the colormapDialog is correctly updated when changing the
+ colormap editable status"""
+ colormap = Colormap(normalization='linear', vmin=1.0, vmax=10.0)
+ self.colormapDiag.setColormap(colormap)
+ for editable in (True, False):
+ with self.subTest(editable=editable):
+ colormap.setEditable(editable)
+ self.assertTrue(
+ self.colormapDiag._comboBoxColormap.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._minValue.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._maxValue.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._normButtonLinear.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._normButtonLog.isEnabled() is editable)
+
+ # Make sure the reset button is also set to enable when edition mode is
+ # False
+ self.colormapDiag.setModal(False)
+ colormap.setEditable(True)
+ self.colormapDiag._normButtonLog.setChecked(True)
+ resetButton = self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ self.assertTrue(resetButton.isEnabled())
+ colormap.setEditable(False)
+ self.assertFalse(resetButton.isEnabled())
+
+
+class TestColormapAction(TestCaseQt):
+ def setUp(self):
+ TestCaseQt.setUp(self)
+ self.plot = PlotWindow()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+
+ self.colormap1 = Colormap(name='blue', vmin=0.0, vmax=1.0,
+ normalization='linear')
+ self.colormap2 = Colormap(name='red', vmin=10.0, vmax=100.0,
+ normalization='log')
+ self.defaultColormap = self.plot.getDefaultColormap()
+
+ self.plot.getColormapAction()._actionTriggered(checked=True)
+ self.colormapDialog = self.plot.getColormapAction()._dialog
+ self.colormapDialog.setAttribute(qt.Qt.WA_DeleteOnClose)
+
+ def tearDown(self):
+ self.colormapDialog.close()
+ self.plot.close()
+ del self.colormapDialog
+ del self.plot
+ TestCaseQt.tearDown(self)
+
+ def testActiveColormap(self):
+ self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
+
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1',
+ replace=False, origin=(0, 0),
+ colormap=self.colormap1)
+ self.plot.setActiveImage('img1')
+ self.assertTrue(self.colormapDialog.getColormap() is self.colormap1)
+
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img2',
+ replace=False, origin=(0, 0),
+ colormap=self.colormap2)
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img3',
+ replace=False, origin=(0, 0))
+
+ self.plot.setActiveImage('img3')
+ self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
+ self.plot.getActiveImage().setColormap(self.colormap2)
+ self.assertTrue(self.colormapDialog.getColormap() is self.colormap2)
+
+ self.plot.remove('img2')
+ self.plot.remove('img3')
+ self.plot.remove('img1')
+ self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
+
+ def testShowHideColormapDialog(self):
+ self.plot.getColormapAction()._actionTriggered(checked=False)
+ self.assertFalse(self.plot.getColormapAction().isChecked())
+ self.plot.getColormapAction()._actionTriggered(checked=True)
+ self.assertTrue(self.plot.getColormapAction().isChecked())
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1',
+ replace=False, origin=(0, 0),
+ colormap=self.colormap1)
+ self.colormap1.setName('red')
+ self.plot.getColormapAction()._actionTriggered()
+ self.colormap1.setName('blue')
+ self.colormapDialog.close()
+ self.assertFalse(self.plot.getColormapAction().isChecked())
+
+
def suite():
test_suite = unittest.TestSuite()
test_suite.addTest(cmapDocTestSuite)
+ for testClass in (TestColormapDialog, TestColormapAction):
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ testClass))
return test_suite
diff --git a/silx/gui/plot/test/testColors.py b/silx/gui/plot/test/testColors.py
index 18f0902..4d617eb 100644
--- a/silx/gui/plot/test/testColors.py
+++ b/silx/gui/plot/test/testColors.py
@@ -26,13 +26,13 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "17/01/2018"
import numpy
import unittest
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot import Colors
from silx.gui.plot.Colormap import Colormap
diff --git a/silx/gui/plot/test/testComplexImageView.py b/silx/gui/plot/test/testComplexImageView.py
index f8ec370..1933a95 100644
--- a/silx/gui/plot/test/testComplexImageView.py
+++ b/silx/gui/plot/test/testComplexImageView.py
@@ -26,14 +26,14 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "14/09/2017"
+__date__ = "17/01/2018"
import unittest
import logging
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot import ComplexImageView
from .utils import PlotWidgetTestCase
diff --git a/silx/gui/plot/test/testCurvesROIWidget.py b/silx/gui/plot/test/testCurvesROIWidget.py
index 716960a..0fd2456 100644
--- a/silx/gui/plot/test/testCurvesROIWidget.py
+++ b/silx/gui/plot/test/testCurvesROIWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -24,9 +24,9 @@
# ###########################################################################*/
"""Basic tests for CurvesROIWidget"""
-__authors__ = ["T. Vincent"]
+__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "15/05/2017"
+__date__ = "16/11/2017"
import logging
@@ -105,6 +105,19 @@ class TestCurvesROIWidget(TestCaseQt):
del self.tmpFile
+ def testMiddleMarker(self):
+ """Test with middle marker enabled"""
+ self.widget.roiWidget.setMiddleROIMarkerFlag(True)
+
+ # Add a ROI
+ self.mouseClick(self.widget.roiWidget.addButton, qt.Qt.LeftButton)
+
+ xleftMarker = self.plot._getMarker(legend='ROI min').getXPosition()
+ xMiddleMarker = self.plot._getMarker(legend='ROI middle').getXPosition()
+ xRightMarker = self.plot._getMarker(legend='ROI max').getXPosition()
+ self.assertAlmostEqual(xMiddleMarker,
+ xleftMarker + (xRightMarker - xleftMarker) / 2.)
+
def testCalculation(self):
x = numpy.arange(100.)
y = numpy.arange(100.)
diff --git a/silx/gui/plot/test/testItem.py b/silx/gui/plot/test/testItem.py
index 8c15bb7..1ba09c6 100644
--- a/silx/gui/plot/test/testItem.py
+++ b/silx/gui/plot/test/testItem.py
@@ -58,7 +58,7 @@ class TestSigItemChangedSignal(PlotWidgetTestCase):
curve.setData(numpy.arange(100), numpy.arange(100))
# SymbolMixIn
- curve.setSymbol('o')
+ curve.setSymbol('Circle')
curve.setSymbol('d')
curve.setSymbolSize(20)
@@ -220,10 +220,28 @@ class TestSigItemChangedSignal(PlotWidgetTestCase):
(ItemChangedType.DATA,)])
+class TestSymbol(PlotWidgetTestCase):
+ """Test item's symbol """
+
+ def test(self):
+ """Test sigItemChanged for curve"""
+ self.plot.addCurve(numpy.arange(10), numpy.arange(10), legend='test')
+ curve = self.plot.getCurve('test')
+
+ # SymbolMixIn
+ curve.setSymbol('o')
+ name = curve.getSymbolName()
+ self.assertEqual('Circle', name)
+
+ name = curve.getSymbolName('d')
+ self.assertEqual('Diamond', name)
+
+
def suite():
test_suite = unittest.TestSuite()
loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
test_suite.addTest(loadTests(TestSigItemChangedSignal))
+ test_suite.addTest(loadTests(TestSymbol))
return test_suite
diff --git a/silx/gui/plot/test/testMaskToolsWidget.py b/silx/gui/plot/test/testMaskToolsWidget.py
index 191bbe0..40c1db3 100644
--- a/silx/gui/plot/test/testMaskToolsWidget.py
+++ b/silx/gui/plot/test/testMaskToolsWidget.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "17/01/2018"
import logging
@@ -36,7 +36,8 @@ import unittest
import numpy
from silx.gui import qt
-from silx.test.utils import temp_dir, ParametricTestCase
+from silx.test.utils import temp_dir
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, MaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -84,8 +85,10 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
pos0 = xCenter, yCenter
pos1 = xCenter + offset, yCenter + offset
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
@@ -194,7 +197,7 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.assertIsNot(toolButton, None)
self.mouseClick(toolButton, qt.Qt.LeftButton)
- self.maskWidget.pencilSpinBox.setValue(10)
+ self.maskWidget.pencilSpinBox.setValue(30)
self.qapp.processEvents()
# mask
diff --git a/silx/gui/plot/test/testPlotTools.py b/silx/gui/plot/test/testPlotTools.py
index a08a18a..3d5849f 100644
--- a/silx/gui/plot/test/testPlotTools.py
+++ b/silx/gui/plot/test/testPlotTools.py
@@ -26,13 +26,13 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "17/01/2018"
import numpy
import unittest
-from silx.test.utils import ParametricTestCase, TestLogging
+from silx.utils.testutils import ParametricTestCase, TestLogging
from silx.gui.test.utils import (
qWaitForWindowExposedAndActivate, TestCaseQt, getQToolButtonFromAction)
from silx.gui import qt
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
index ccee428..72617e5 100644
--- a/silx/gui/plot/test/testPlotWidget.py
+++ b/silx/gui/plot/test/testPlotWidget.py
@@ -26,17 +26,17 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "26/01/2018"
import unittest
import logging
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import SignalListener
from silx.gui.test.utils import TestCaseQt
-from silx.test import utils
+from silx.utils import testutils
from silx.utils import deprecation
from silx.gui import qt
@@ -77,9 +77,9 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
self.assertEqual(self.plot.getYAxis().getLabel(), ylabel)
def _checkLimits(self,
- expectedXLim=None,
- expectedYLim=None,
- expectedRatio=None):
+ expectedXLim=None,
+ expectedYLim=None,
+ expectedRatio=None):
"""Assert that limits are as expected"""
xlim = self.plot.getXAxis().getLimits()
ylim = self.plot.getYAxis().getLimits()
@@ -132,13 +132,11 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
# Resize with aspect ratio
self.plot.setKeepDataAspectRatio(True)
- listener.clear() # Clean-up received signal
self.qapp.processEvents()
- self.assertEqual(listener.callCount(), 0) # No event when redrawing
+ listener.clear() # Clean-up received signal
self.plot.resize(200, 200)
self.qapp.processEvents()
-
self.assertNotEqual(listener.callCount(), 0)
@@ -723,7 +721,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
if getter is not None:
self.assertEqual(getter(), expected)
- @utils.test_logging(deprecation.depreclog.name, warning=2)
+ @testutils.test_logging(deprecation.depreclog.name, warning=2)
def testOldPlotAxis_Logarithmic(self):
"""Test silx API prior to silx 0.6"""
x = self.plot.getXAxis()
@@ -762,7 +760,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.assertEqual(self.plot.isYAxisLogarithmic(), False)
self.assertEqual(listener.arguments(callIndex=-1), ("y", False))
- @utils.test_logging(deprecation.depreclog.name, warning=2)
+ @testutils.test_logging(deprecation.depreclog.name, warning=2)
def testOldPlotAxis_AutoScale(self):
"""Test silx API prior to silx 0.6"""
x = self.plot.getXAxis()
@@ -801,7 +799,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.assertEqual(self.plot.isYAxisAutoScale(), True)
self.assertEqual(listener.arguments(callIndex=-1), ("y", True))
- @utils.test_logging(deprecation.depreclog.name, warning=1)
+ @testutils.test_logging(deprecation.depreclog.name, warning=1)
def testOldPlotAxis_Inverted(self):
"""Test silx API prior to silx 0.6"""
x = self.plot.getXAxis()
diff --git a/silx/gui/plot/test/testPlotWidgetNoBackend.py b/silx/gui/plot/test/testPlotWidgetNoBackend.py
index 3094a20..0d0ddc4 100644
--- a/silx/gui/plot/test/testPlotWidgetNoBackend.py
+++ b/silx/gui/plot/test/testPlotWidgetNoBackend.py
@@ -26,12 +26,12 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "17/01/2018"
import unittest
from functools import reduce
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
import numpy
diff --git a/silx/gui/plot/test/testProfile.py b/silx/gui/plot/test/testProfile.py
index 43d3329..28d9669 100644
--- a/silx/gui/plot/test/testProfile.py
+++ b/silx/gui/plot/test/testProfile.py
@@ -26,12 +26,12 @@
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "23/02/2017"
+__date__ = "17/01/2018"
import numpy
import unittest
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import (
TestCaseQt, getQToolButtonFromAction)
from silx.gui import qt
diff --git a/silx/gui/plot/test/testSaveAction.py b/silx/gui/plot/test/testSaveAction.py
new file mode 100644
index 0000000..4dfe373
--- /dev/null
+++ b/silx/gui/plot/test/testSaveAction.py
@@ -0,0 +1,97 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Test the plot's save action (consistency of output)"""
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "28/11/2017"
+
+
+import unittest
+import tempfile
+import os
+
+from silx.gui.plot import PlotWidget
+from silx.gui.plot.actions.io import SaveAction
+
+
+class TestSaveAction(unittest.TestCase):
+
+ def setUp(self):
+ self.plot = PlotWidget(backend='none')
+ self.saveAction = SaveAction(plot=self.plot)
+
+ self.tempdir = tempfile.mkdtemp()
+ self.out_fname = os.path.join(self.tempdir, "out.dat")
+
+ def tearDown(self):
+ os.unlink(self.out_fname)
+ os.rmdir(self.tempdir)
+
+ def testSaveMultipleCurvesAsSpec(self):
+ """Test that labels are properly used."""
+ self.plot.setGraphXLabel("graph x label")
+ self.plot.setGraphYLabel("graph y label")
+
+ self.plot.addCurve([0, 1], [1, 2], "curve with labels",
+ xlabel="curve0 X", ylabel="curve0 Y")
+ self.plot.addCurve([-1, 3], [-6, 2], "curve with X label",
+ xlabel="curve1 X")
+ self.plot.addCurve([-2, 0], [8, 12], "curve with Y label",
+ ylabel="curve2 Y")
+ self.plot.addCurve([3, 1], [7, 6], "curve with no labels")
+
+ self.saveAction._saveCurves(self.out_fname,
+ SaveAction.ALL_CURVES_FILTERS[0]) # "All curves as SpecFile (*.dat)"
+
+ with open(self.out_fname, "rb") as f:
+ file_content = f.read()
+ if hasattr(file_content, "decode"):
+ file_content = file_content.decode()
+
+ # case with all curve labels specified
+ self.assertIn("#S 1 curve0 Y", file_content)
+ self.assertIn("#L curve0 X curve0 Y", file_content)
+
+ # graph X&Y labels are used when no curve label is specified
+ self.assertIn("#S 2 graph y label", file_content)
+ self.assertIn("#L curve1 X graph y label", file_content)
+
+ self.assertIn("#S 3 curve2 Y", file_content)
+ self.assertIn("#L graph x label curve2 Y", file_content)
+
+ self.assertIn("#S 4 graph y label", file_content)
+ self.assertIn("#L graph x label graph y label", file_content)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestSaveAction))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testScatterMaskToolsWidget.py b/silx/gui/plot/test/testScatterMaskToolsWidget.py
index 178274a..0342c8f 100644
--- a/silx/gui/plot/test/testScatterMaskToolsWidget.py
+++ b/silx/gui/plot/test/testScatterMaskToolsWidget.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "17/01/2018"
import logging
@@ -36,7 +36,8 @@ import unittest
import numpy
from silx.gui import qt
-from silx.test.utils import temp_dir, ParametricTestCase
+from silx.test.utils import temp_dir
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, ScatterMaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -86,8 +87,10 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
pos0 = xCenter, yCenter
pos1 = xCenter + offset, yCenter + offset
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
@@ -197,7 +200,7 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.assertIsNot(toolButton, None)
self.mouseClick(toolButton, qt.Qt.LeftButton)
- self.maskWidget.pencilSpinBox.setValue(10)
+ self.maskWidget.pencilSpinBox.setValue(30)
self.qapp.processEvents()
# mask
diff --git a/silx/gui/plot/test/testUtilsAxis.py b/silx/gui/plot/test/testUtilsAxis.py
index 6702b00..3f19dcd 100644
--- a/silx/gui/plot/test/testUtilsAxis.py
+++ b/silx/gui/plot/test/testUtilsAxis.py
@@ -26,18 +26,20 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "04/08/2017"
+__date__ = "14/02/2018"
import unittest
from silx.gui.plot import PlotWidget
+from silx.gui.test.utils import TestCaseQt
from silx.gui.plot.utils.axis import SyncAxes
-class TestAxisSync(unittest.TestCase):
+class TestAxisSync(TestCaseQt):
"""Tests AxisSync class"""
def setUp(self):
+ TestCaseQt.setUp(self)
self.plot1 = PlotWidget()
self.plot2 = PlotWidget()
self.plot3 = PlotWidget()
@@ -46,6 +48,7 @@ class TestAxisSync(unittest.TestCase):
self.plot1 = None
self.plot2 = None
self.plot3 = None
+ TestCaseQt.tearDown(self)
def testMoveFirstAxis(self):
"""Test synchronization after construction"""
@@ -85,6 +88,22 @@ class TestAxisSync(unittest.TestCase):
self.assertNotEqual(self.plot2.getXAxis().getLimits(), (10, 500))
self.assertNotEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+ def testAxisDestruction(self):
+ """Test synchronization when an axis disappear"""
+ _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+
+ # Destroy the plot is possible
+ import weakref
+ plot = weakref.ref(self.plot2)
+ self.plot2 = None
+ result = self.qWaitForDestroy(plot)
+ if not result:
+ # We can't test
+ self.skipTest("Object not destroyed")
+
+ self.plot1.getXAxis().setLimits(10, 500)
+ self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+
def testStop(self):
"""Test synchronization after calling stop"""
sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
diff --git a/silx/gui/plot/test/utils.py b/silx/gui/plot/test/utils.py
index ef547c6..ec9bc7c 100644
--- a/silx/gui/plot/test/utils.py
+++ b/silx/gui/plot/test/utils.py
@@ -26,17 +26,15 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "26/01/2018"
import logging
-import contextlib
from silx.gui.test.utils import TestCaseQt
from silx.gui import qt
from silx.gui.plot import PlotWidget
-from silx.gui.plot.backends.BackendMatplotlib import BackendMatplotlibQt
logger = logging.getLogger(__name__)
@@ -48,9 +46,10 @@ class PlotWidgetTestCase(TestCaseQt):
plot attribute is the PlotWidget created for the test.
"""
+ __screenshot_already_taken = False
+
def __init__(self, methodName='runTest'):
TestCaseQt.__init__(self, methodName=methodName)
- self.__mousePos = None
def _createPlot(self):
return PlotWidget()
@@ -79,116 +78,16 @@ class PlotWidgetTestCase(TestCaseQt):
logger.error("Plot is still alive")
def tearDown(self):
+ if not self._currentTestSucceeded():
+ # MPL is the only widget which uses the real system mouse.
+ # In case of a the windows is outside of the screen, minimzed,
+ # overlapped by a system popup, the MPL widget will not receive the
+ # mouse event.
+ # Taking a screenshot help debuging this cases in the continuous
+ # integration environement.
+ if not PlotWidgetTestCase.__screenshot_already_taken:
+ PlotWidgetTestCase.__screenshot_already_taken = True
+ self.logScreenShot()
self.qapp.processEvents()
self._waitForPlotClosed()
super(PlotWidgetTestCase, self).tearDown()
-
- def _logMplEvents(self, event):
- self.__mplEvents.append(event)
-
- @contextlib.contextmanager
- def _waitForMplEvent(self, plot, mplEventType):
- """Check if an event was received by the MPL backend.
-
- :param PlotWidget plot: A plot widget or a MPL plot backend
- :param str mplEventType: MPL event type
- :raises RuntimeError: When the event did not happen
- """
- self.__mplEvents = []
- if isinstance(plot, BackendMatplotlibQt):
- backend = plot
- else:
- backend = plot._backend
-
- callbackId = backend.mpl_connect(mplEventType, self._logMplEvents)
- received = False
- yield
- for _ in range(100):
- if len(self.__mplEvents) > 0:
- received = True
- break
- self.qWait(10)
- backend.mpl_disconnect(callbackId)
- del self.__mplEvents
- if not received:
- self.logScreenShot()
- raise RuntimeError("MPL event %s expected but nothing received" % mplEventType)
-
- def _haveMplEvent(self, widget, pos):
- """Check if the widget at this position is a matplotlib widget."""
- if isinstance(pos, qt.QPoint):
- pass
- else:
- pos = qt.QPoint(pos[0], pos[1])
- pos = widget.mapTo(widget.window(), pos)
- target = widget.window().childAt(pos)
-
- # Check if the target is a MPL container
- backend = target
- if hasattr(target, "_backend"):
- backend = target._backend
- haveEvent = isinstance(backend, BackendMatplotlibQt)
- return haveEvent
-
- def _patchPos(self, widget, pos):
- """Return a real position relative to the widget.
-
- If pos is None, the returned value is the center of the widget,
- as the default behaviour of functions like QTest.mouseMove.
- Else the position is returned as it is.
- """
- if pos is None:
- pos = widget.size() / 2
- pos = pos.width(), pos.height()
- return pos
-
- def _checkMouseMove(self, widget, pos):
- """Returns true if the position differe from the current position of
- the cursor"""
- pos = qt.QPoint(pos[0], pos[1])
- pos = widget.mapTo(widget.window(), pos)
- willMove = pos != self.__mousePos
- self.__mousePos = pos
- return willMove
-
- def mouseMove(self, widget, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- willMove = self._checkMouseMove(widget, pos)
- hadMplEvents = self._haveMplEvent(widget, self.__mousePos)
- willHaveMplEvents = self._haveMplEvent(widget, pos)
- if (not hadMplEvents and not willHaveMplEvents) or not willMove:
- return TestCaseQt.mouseMove(self, widget, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "motion_notify_event"):
- TestCaseQt.mouseMove(self, widget, pos=pos, delay=delay)
-
- def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- self._checkMouseMove(widget, pos)
- if not self._haveMplEvent(widget, pos):
- return TestCaseQt.mouseClick(self, widget, button, modifier=modifier, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "button_release_event"):
- TestCaseQt.mouseClick(self, widget, button, modifier=modifier, pos=pos, delay=delay)
-
- def mousePress(self, widget, button, modifier=None, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- self._checkMouseMove(widget, pos)
- if not self._haveMplEvent(widget, pos):
- return TestCaseQt.mousePress(self, widget, button, modifier=modifier, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "button_press_event"):
- TestCaseQt.mousePress(self, widget, button, modifier=modifier, pos=pos, delay=delay)
-
- def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- self._checkMouseMove(widget, pos)
- if not self._haveMplEvent(widget, pos):
- return TestCaseQt.mouseRelease(self, widget, button, modifier=modifier, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "button_release_event"):
- TestCaseQt.mouseRelease(self, widget, button, modifier=modifier, pos=pos, delay=delay)
diff --git a/silx/gui/plot/utils/axis.py b/silx/gui/plot/utils/axis.py
index f7ec711..80e1dc4 100644
--- a/silx/gui/plot/utils/axis.py
+++ b/silx/gui/plot/utils/axis.py
@@ -27,12 +27,13 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "04/08/2017"
+__date__ = "23/02/2018"
import functools
import logging
from contextlib import contextmanager
-from silx.utils import weakref
+import weakref
+import silx.utils.weakref as silxWeakref
_logger = logging.getLogger(__name__)
@@ -63,14 +64,20 @@ class SyncAxes(object):
:param bool syncDirection: Synchronize axes direction
"""
object.__init__(self)
- self.__axes = []
self.__locked = False
+ self.__axes = []
self.__syncLimits = syncLimits
self.__syncScale = syncScale
self.__syncDirection = syncDirection
- self.__callbacks = []
+ self.__callbacks = None
+
+ qtCallback = silxWeakref.WeakMethodProxy(self.__deleteAxisQt)
+ for axis in axes:
+ ref = weakref.ref(axis)
+ self.__axes.append(ref)
+ callback = functools.partial(qtCallback, ref)
+ axis.destroyed.connect(callback)
- self.__axes.extend(axes)
self.start()
def start(self):
@@ -80,54 +87,71 @@ class SyncAxes(object):
After that, any changes to any axes will be used to synchronize other
axes.
"""
- if len(self.__callbacks) != 0:
+ if self.__callbacks is not None:
raise RuntimeError("Axes already synchronized")
+ self.__callbacks = {}
# register callback for further sync
- for axis in self.__axes:
+ for refAxis in self.__axes:
+ axis = refAxis()
+ callbacks = []
if self.__syncLimits:
# the weakref is needed to be able ignore self references
- callback = weakref.WeakMethodProxy(self.__axisLimitsChanged)
- callback = functools.partial(callback, axis)
+ callback = silxWeakref.WeakMethodProxy(self.__axisLimitsChanged)
+ callback = functools.partial(callback, refAxis)
sig = axis.sigLimitsChanged
sig.connect(callback)
- self.__callbacks.append((sig, callback))
+ callbacks.append(("sigLimitsChanged", callback))
if self.__syncScale:
# the weakref is needed to be able ignore self references
- callback = weakref.WeakMethodProxy(self.__axisScaleChanged)
- callback = functools.partial(callback, axis)
+ callback = silxWeakref.WeakMethodProxy(self.__axisScaleChanged)
+ callback = functools.partial(callback, refAxis)
sig = axis.sigScaleChanged
sig.connect(callback)
- self.__callbacks.append((sig, callback))
+ callbacks.append(("sigScaleChanged", callback))
if self.__syncDirection:
# the weakref is needed to be able ignore self references
- callback = weakref.WeakMethodProxy(self.__axisInvertedChanged)
- callback = functools.partial(callback, axis)
+ callback = silxWeakref.WeakMethodProxy(self.__axisInvertedChanged)
+ callback = functools.partial(callback, refAxis)
sig = axis.sigInvertedChanged
sig.connect(callback)
- self.__callbacks.append((sig, callback))
+ callbacks.append(("sigInvertedChanged", callback))
+
+ self.__callbacks[refAxis] = callbacks
# sync the current state
- mainAxis = self.__axes[0]
+ refMainAxis = self.__axes[0]
+ mainAxis = refMainAxis()
if self.__syncLimits:
- self.__axisLimitsChanged(mainAxis, *mainAxis.getLimits())
+ self.__axisLimitsChanged(refMainAxis, *mainAxis.getLimits())
if self.__syncScale:
- self.__axisScaleChanged(mainAxis, mainAxis.getScale())
+ self.__axisScaleChanged(refMainAxis, mainAxis.getScale())
if self.__syncDirection:
- self.__axisInvertedChanged(mainAxis, mainAxis.isInverted())
+ self.__axisInvertedChanged(refMainAxis, mainAxis.isInverted())
+
+ def __deleteAxis(self, ref):
+ _logger.debug("Delete axes ref %s", ref)
+ self.__axes.remove(ref)
+ del self.__callbacks[ref]
+
+ def __deleteAxisQt(self, ref, qobject):
+ self.__deleteAxis(ref)
def stop(self):
"""Stop the synchronization of the axes"""
- if len(self.__callbacks) == 0:
+ if self.__callbacks is None:
raise RuntimeError("Axes not synchronized")
- for sig, callback in self.__callbacks:
- sig.disconnect(callback)
- self.__callbacks = []
+ for ref, callbacks in self.__callbacks.items():
+ axes = ref()
+ for sigName, callback in callbacks:
+ sig = getattr(axes, sigName)
+ sig.disconnect(callback)
+ self.__callbacks = None
def __del__(self):
"""Destructor"""
# clean up references
- if len(self.__callbacks) != 0:
+ if self.__callbacks is not None:
self.stop()
@contextmanager
@@ -138,6 +162,7 @@ class SyncAxes(object):
def __otherAxes(self, changedAxis):
for axis in self.__axes:
+ axis = axis()
if axis is changedAxis:
continue
yield axis
@@ -145,6 +170,7 @@ class SyncAxes(object):
def __axisLimitsChanged(self, changedAxis, vmin, vmax):
if self.__locked:
return
+ changedAxis = changedAxis()
with self.__inhibitSignals():
for axis in self.__otherAxes(changedAxis):
axis.setLimits(vmin, vmax)
@@ -152,6 +178,7 @@ class SyncAxes(object):
def __axisScaleChanged(self, changedAxis, scale):
if self.__locked:
return
+ changedAxis = changedAxis()
with self.__inhibitSignals():
for axis in self.__otherAxes(changedAxis):
axis.setScale(scale)
@@ -159,6 +186,7 @@ class SyncAxes(object):
def __axisInvertedChanged(self, changedAxis, isInverted):
if self.__locked:
return
+ changedAxis = changedAxis()
with self.__inhibitSignals():
for axis in self.__otherAxes(changedAxis):
axis.setInverted(isInverted)