Source code for silx.gui.plot.ColormapDialog

# 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)


[docs]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)
[docs] 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)
[docs] 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])
[docs] 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
[docs] 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()
[docs] 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
[docs] 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()
[docs] 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)