diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
commit | f7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch) | |
tree | 9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx/gui/plot/ColormapDialog.py |
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx/gui/plot/ColormapDialog.py')
-rw-r--r-- | silx/gui/plot/ColormapDialog.py | 506 |
1 files changed, 506 insertions, 0 deletions
diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py new file mode 100644 index 0000000..ad1425c --- /dev/null +++ b/silx/gui/plot/ColormapDialog.py @@ -0,0 +1,506 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""A QDialog widget to set-up the colormap. + +It uses a description of colormaps as dict compatible with :class:`Plot`. + +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 + +>>> dialog = ColormapDialog() + +>>> 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.show() + +Get the colormap description (compatible with :class:`Plot`) from the dialog: + +>>> cmap = dialog.getColormap() +>>> cmap['name'] +'red' + +It is also possible to display an histogram of the image in the dialog. +This updates the data range with the range of the bins. + +>>> import numpy +>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1) +>>> hist, bin_edges = numpy.histogram(image, bins=10) +>>> dialog.setHistogram(hist, bin_edges) + +The updates of the colormap description are also available through the signal: +:attr:`ColormapDialog.sigColormapChanged`. +""" # noqa + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent"] +__license__ = "MIT" +__date__ = "29/03/2016" + + +import logging + +import numpy + +from .. import qt +from . import PlotWidget + + +_logger = logging.getLogger(__name__) + + +class _FloatEdit(qt.QLineEdit): + """Field to edit a float value. + + :param parent: See :class:`QLineEdit` + :param float value: The value to set the QLineEdit to. + """ + def __init__(self, parent=None, value=None): + qt.QLineEdit.__init__(self, parent) + self.setValidator(qt.QDoubleValidator()) + self.setAlignment(qt.Qt.AlignRight) + if value is not None: + self.setValue(value) + + def value(self): + """Return the QLineEdit current value as a float.""" + return float(self.text()) + + def setValue(self, value): + """Set the current value of the LineEdit + + :param float value: The value to set the QLineEdit to. + """ + self.setText('%g' % value) + + +class ColormapDialog(qt.QDialog): + """A QDialog widget to set the colormap. + + :param parent: See :class:`QDialog` + :param str title: The QDialog title + """ + + sigColormapChanged = qt.Signal(dict) + """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`. + """ + + def __init__(self, parent=None, title="Colormap Dialog"): + qt.QDialog.__init__(self, parent) + self.setWindowTitle(title) + + self._histogramData = None + self._dataRange = None + self._minMaxWasEdited = False + + self._colormapList = ( + 'gray', 'reversed gray', + 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma') + + # Make the GUI + vLayout = qt.QVBoxLayout(self) + + formWidget = qt.QWidget() + 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) + formLayout.addRow('Colormap:', self._comboBoxColormap) + + # Normalization row + self._normButtonLinear = qt.QRadioButton('Linear') + self._normButtonLinear.setChecked(True) + self._normButtonLog = qt.QRadioButton('Log') + + normButtonGroup = qt.QButtonGroup(self) + normButtonGroup.setExclusive(True) + normButtonGroup.addButton(self._normButtonLinear) + normButtonGroup.addButton(self._normButtonLog) + normButtonGroup.buttonClicked[int].connect(self._notify) + + normLayout = qt.QHBoxLayout() + normLayout.setContentsMargins(0, 0, 0, 0) + normLayout.setSpacing(10) + normLayout.addWidget(self._normButtonLinear) + normLayout.addWidget(self._normButtonLog) + + 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(value=1.) + self._minValue.setEnabled(False) + self._minValue.textEdited.connect(self._minMaxTextEdited) + self._minValue.editingFinished.connect(self._minEditingFinished) + formLayout.addRow('\tMin:', self._minValue) + + # Max row + self._maxValue = _FloatEdit(value=10.) + self._maxValue.setEnabled(False) + self._maxValue.textEdited.connect(self._minMaxTextEdited) + self._maxValue.editingFinished.connect(self._maxEditingFinished) + formLayout.addRow('\tMax:', self._maxValue) + + # Add plot for histogram + self._plotInit() + vLayout.addWidget(self._plot) + + # Close button + buttonsWidget = qt.QWidget() + vLayout.addWidget(buttonsWidget) + + buttonsLayout = qt.QHBoxLayout(buttonsWidget) + + okButton = qt.QPushButton('OK') + okButton.clicked.connect(self.accept) + buttonsLayout.addWidget(okButton) + + cancelButton = qt.QPushButton('Cancel') + cancelButton.clicked.connect(self.reject) + buttonsLayout.addWidget(cancelButton) + + # colormap window can not be resized + self.setFixedSize(vLayout.minimumSize()) + + # Set the colormap to default values + self.setColormap(name='gray', normalization='linear', + autoscale=True, vmin=1., vmax=10.) + + def _plotInit(self): + """Init the plot to display the range and the values""" + self._plot = PlotWidget() + self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125) + self._plot.setGraphXLabel("Data Values") + self._plot.setGraphYLabel("") + self._plot.setInteractiveMode('select', zoomOnWheel=False) + self._plot.setActiveCurveHandling(False) + self._plot.setMinimumSize(qt.QSize(250, 200)) + self._plot.sigPlotSignal.connect(self._plotSlot) + self._plot.hide() + + self._plotUpdate() + + 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()) + return + + if not self._plot.isVisibleTo(self): + self._plot.setVisible(True) + self.setFixedSize(self.layout().minimumSize()) + + dataMin, dataMax = dataRange + marge = (abs(dataMax) + abs(dataMin)) / 6.0 + minmd = dataMin - marge + maxpd = dataMax + marge + + start, end = self._minValue.value(), self._maxValue.value() + + if start <= end: + x = [minmd, start, end, maxpd] + y = [0, 0, 1, 1] + + 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) + + self._plot.addCurve(x, y, + legend="ConstrainedCurve", + color='black', + symbol='o', + linestyle='-', + resetzoom=False) + + draggable = not self._rangeAutoscaleButton.isChecked() + + if updateMarkers: + self._plot.addXMarker( + self._minValue.value(), + legend='Min', + text='Min', + draggable=draggable, + color='blue', + constraint=self._plotMinMarkerConstraint) + + self._plot.addXMarker( + self._maxValue.value(), + legend='Max', + text='Max', + draggable=draggable, + color='blue', + constraint=self._plotMaxMarkerConstraint) + + self._plot.resetZoom() + + def _plotMinMarkerConstraint(self, x, y): + """Constraint of the min marker""" + return min(x, self._maxValue.value()), y + + def _plotMaxMarkerConstraint(self, x, y): + """Constraint of the max marker""" + return max(x, self._minValue.value()), y + + def _plotSlot(self, event): + """Handle events from the plot""" + if event['event'] in ('markerMoving', 'markerMoved'): + value = float(str(event['xdata'])) + if event['label'] == 'Min': + self._minValue.setValue(value) + elif event['label'] == 'Max': + self._maxValue.setValue(value) + + # This will recreate the markers while interacting... + # It might break if marker interaction is changed + if event['event'] == 'markerMoved': + self._notify() + else: + self._plotUpdate(updateMarkers=False) + + def getHistogram(self): + """Returns the counts and bin edges of the displayed histogram. + + :return: (hist, bin_edges) + :rtype: 2-tuple of numpy arrays""" + if self._histogramData is None: + return None + else: + bins, counts = self._histogramData + return numpy.array(bins, copy=True), numpy.array(counts, copy=True) + + def setHistogram(self, hist=None, bin_edges=None): + """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 + + 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) + + # Update the data range + self.setDataRange(bin_edges[0], bin_edges[-1]) + + def getDataRange(self): + """Returns the data range used for the histogram area. + + :return: (dataMin, dataMax) or None if no data range is set + :rtype: 2-tuple of float + """ + return self._dataRange + + def setDataRange(self, min_=None, max_=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. + """ + if min_ is None or max_ is None: + self._dataRange = None + self._plotUpdate() + + 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() + + def getColormap(self): + """Return the colormap description as a dict. + + See :class:`Plot` for documentation on the colormap dict. + """ + isNormLinear = self._normButtonLinear.isChecked() + colormap = { + 'name': str(self._comboBoxColormap.currentText()).lower(), + 'normalization': 'linear' if isNormLinear else 'log', + 'autoscale': self._rangeAutoscaleButton.isChecked(), + 'vmin': self._minValue.value(), + 'vmax': self._maxValue.value()} + return colormap + + def setColormap(self, name=None, normalization=None, + autoscale=None, vmin=None, vmax=None, colors=None): + """Set the colormap description + + If some arguments are not provided, the current values are used. + + :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 + """ + 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 ('linear', 'log') + self._normButtonLinear.setChecked(normalization == 'linear') + self._normButtonLog.setChecked(normalization == 'log') + + 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""" + 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 _minMaxTextEdited(self, text): + """Handle _minValue and _maxValue textEdited signal""" + self._minMaxWasEdited = True + + def _minEditingFinished(self): + """Handle _minValue editingFinished signal + + Together with :meth:`_minMaxTextEdited`, this avoids to notify + colormap change when the min and max value where not edited. + """ + if self._minMaxWasEdited: + self._minMaxWasEdited = False + + # Fix start value + if self._minValue.value() > self._maxValue.value(): + self._minValue.setValue(self._maxValue.value()) + self._notify() + + def _maxEditingFinished(self): + """Handle _maxValue editingFinished signal + + Together with :meth:`_minMaxTextEdited`, this avoids to notify + colormap change when the min and max value where not edited. + """ + if self._minMaxWasEdited: + self._minMaxWasEdited = False + + # Fix end value + if self._minValue.value() > self._maxValue.value(): + self._maxValue.setValue(self._minValue.value()) + self._notify() + + def keyPressEvent(self, event): + """Override key handling. + + It disables leaving the dialog when editing a text field. + """ + if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or + self._maxValue.hasFocus()): + # Bypass QDialog keyPressEvent + # To avoid leaving the dialog when pressing enter on a text field + super(qt.QDialog, self).keyPressEvent(event) + else: + # Use QDialog keyPressEvent + super(ColormapDialog, self).keyPressEvent(event) |