diff options
Diffstat (limited to 'silx/gui/plot/ColormapDialog.py')
-rw-r--r-- | silx/gui/plot/ColormapDialog.py | 897 |
1 files changed, 694 insertions, 203 deletions
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() |