diff options
Diffstat (limited to 'silx/gui/plot')
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) |