diff options
author | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2020-07-21 14:45:14 +0200 |
---|---|---|
committer | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2020-07-21 14:45:14 +0200 |
commit | 328032e2317e3ac4859196bbf12bdb71795302fe (patch) | |
tree | 8cd13462beab109e3cb53410c42335b6d1e00ee6 /silx/gui/dialog/ColormapDialog.py | |
parent | 33ed2a64c92b0311ae35456c016eb284e426afc2 (diff) |
New upstream version 0.13.0+dfsg
Diffstat (limited to 'silx/gui/dialog/ColormapDialog.py')
-rw-r--r-- | silx/gui/dialog/ColormapDialog.py | 1661 |
1 files changed, 1135 insertions, 526 deletions
diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py index dddec4c..7e53585 100644 --- a/silx/gui/dialog/ColormapDialog.py +++ b/silx/gui/dialog/ColormapDialog.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2019 European Synchrotron Radiation Facility +# Copyright (c) 2004-2020 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 @@ -72,15 +72,20 @@ import logging import numpy from .. import qt -from ..colors import Colormap, preferredColormaps +from .. import utils +from ..colors import Colormap from ..plot import PlotWidget from ..plot.items.axis import Axis +from ..plot.items import BoundingRect from silx.gui.widgets.FloatEdit import FloatEdit import weakref from silx.math.combo import min_max +from silx.gui.plot import items from silx.gui import icons +from silx.gui.qt import inspect as qtinspect from silx.gui.widgets.ColormapNameComboBox import ColormapNameComboBox from silx.math.histogram import Histogramnd +from silx.utils import deprecation _logger = logging.getLogger(__name__) @@ -88,13 +93,39 @@ _logger = logging.getLogger(__name__) _colormapIconPreview = {} +class _DataRefHolder(items.Item, items.ColormapMixIn): + """Holder for a weakref of a numpy array. + + It provides features from `ColormapMixIn`. + """ + + def __init__(self, dataRef): + items.Item.__init__(self) + items.ColormapMixIn.__init__(self) + self.__dataRef = dataRef + self._updated(items.ItemChangedType.DATA) + + def getColormappedData(self, copy=True): + return self.__dataRef() + + class _BoundaryWidget(qt.QWidget): - """Widget to edit a boundary of the colormap (vmin, vmax)""" + """Widget to edit a boundary of the colormap (vmin or vmax)""" + + sigAutoScaleChanged = qt.Signal(object) + """Signal emitted when the autoscale was changed + + True is sent as an argument if autoscale is set to true. + """ + sigValueChanged = qt.Signal(object) - """Signal emitted when value is changed""" + """Signal emitted when value is changed + + The new value is sent as an argument. + """ def __init__(self, parent=None, value=0.0): - qt.QWidget.__init__(self, parent=None) + qt.QWidget.__init__(self, parent=parent) self.setLayout(qt.QHBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) self._numVal = FloatEdit(parent=self, value=value) @@ -102,23 +133,54 @@ class _BoundaryWidget(qt.QWidget): self._autoCB = qt.QCheckBox('auto', parent=self) self.layout().addWidget(self._autoCB) self._autoCB.setChecked(False) + self._autoCB.setVisible(False) self._autoCB.toggled.connect(self._autoToggled) - self.sigValueChanged = self._autoCB.toggled - self.textEdited = self._numVal.textEdited - self.editingFinished = self._numVal.editingFinished + self._numVal.textEdited.connect(self.__textEdited) + self._numVal.editingFinished.connect(self.__editingFinished) + self.setFocusProxy(self._numVal) + self._dataValue = None + self.__textWasEdited = False + """True if the text was edited, in order to send an event + at the end of the user interaction""" + + self.__realValue = None + """Store the real value set by setValue/setFiniteValue, to avoid + rounding of the widget""" + + def __textEdited(self): + self.__textWasEdited = True + + def __editingFinished(self): + if self.__textWasEdited: + value = self._numVal.value() + self.__realValue = value + self.sigValueChanged.emit(value) + self.__textWasEdited = False + def isAutoChecked(self): return self._autoCB.isChecked() def getValue(self): - return None if self._autoCB.isChecked() else self._numVal.value() + """Returns the stored range. If autoscale is + enabled, this returns None. + """ + if self._autoCB.isChecked(): + return None + if self.__realValue is not None: + return self.__realValue + return self._numVal.value() def getFiniteValue(self): if not self._autoCB.isChecked(): + if self.__realValue is not None: + return self.__realValue return self._numVal.value() elif self._dataValue is None: + if self.__realValue is not None: + return self.__realValue return self._numVal.value() else: return self._dataValue @@ -126,13 +188,14 @@ class _BoundaryWidget(qt.QWidget): def _autoToggled(self, enabled): self._numVal.setEnabled(not enabled) self._updateDisplayedText() + self.sigAutoScaleChanged.emit(enabled) def _updateDisplayedText(self): # if dataValue is finite + self.__textWasEdited = False if self._autoCB.isChecked() and self._dataValue is not None: - old = self._numVal.blockSignals(True) - self._numVal.setValue(self._dataValue) - self._numVal.blockSignals(old) + with utils.blockSignals(self._numVal): + self._numVal.setValue(self._dataValue) def setDataValue(self, dataValue): self._dataValue = dataValue @@ -142,108 +205,360 @@ class _BoundaryWidget(qt.QWidget): assert(value is not None) old = self._numVal.blockSignals(True) self._numVal.setValue(value) + self.__realValue = 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.__realValue = value self._updateDisplayedText() +class _AutoscaleModeComboBox(qt.QComboBox): + + DATA = { + Colormap.MINMAX: ("Min/max", "Use the data min/max"), + Colormap.STDDEV3: ("Mean ± 3 × stddev", "Use the data mean ± 3 × standard deviation"), + } + + def __init__(self, parent: qt.QWidget): + super(_AutoscaleModeComboBox, self).__init__(parent=parent) + self.currentIndexChanged.connect(self.__updateTooltip) + self._init() + + def _init(self): + for mode in Colormap.AUTOSCALE_MODES: + label, tooltip = self.DATA.get(mode, (mode, None)) + self.addItem(label, mode) + if tooltip is not None: + self.setItemData(self.count() - 1, tooltip, qt.Qt.ToolTipRole) + + def setCurrentIndex(self, index): + self.__updateTooltip(index) + super(_AutoscaleModeComboBox, self).setCurrentIndex(index) + + def __updateTooltip(self, index): + if index > -1: + tooltip = self.itemData(index, qt.Qt.ToolTipRole) + else: + tooltip = "" + self.setToolTip(tooltip) + + def currentMode(self): + index = self.currentIndex() + return self.itemData(index) + + def setCurrentMode(self, mode): + for index in range(self.count()): + if mode == self.itemData(index): + self.setCurrentIndex(index) + return + if mode is None: + # If None was not a value + self.setCurrentIndex(-1) + return + self.addItem(mode, mode) + self.setCurrentIndex(self.count() - 1) + + +class _AutoScaleButtons(qt.QWidget): + + autoRangeChanged = qt.Signal(object) + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent=parent) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.setFocusPolicy(qt.Qt.NoFocus) + + self._bothAuto = qt.QPushButton(self) + self._bothAuto.setText("Autoscale") + self._bothAuto.setToolTip("Enable/disable the autoscale for both min and max") + self._bothAuto.setCheckable(True) + self._bothAuto.toggled[bool].connect(self.__bothToggled) + self._bothAuto.setFocusPolicy(qt.Qt.TabFocus) + + self._minAuto = qt.QCheckBox(self) + self._minAuto.setText("") + self._minAuto.setToolTip("Enable/disable the autoscale for min") + self._minAuto.toggled[bool].connect(self.__minToggled) + self._minAuto.setFocusPolicy(qt.Qt.TabFocus) + + self._maxAuto = qt.QCheckBox(self) + self._maxAuto.setText("") + self._maxAuto.setToolTip("Enable/disable the autoscale for max") + self._maxAuto.toggled[bool].connect(self.__maxToggled) + self._maxAuto.setFocusPolicy(qt.Qt.TabFocus) + + layout.addStretch(1) + layout.addWidget(self._minAuto) + layout.addSpacing(20) + layout.addWidget(self._bothAuto) + layout.addSpacing(20) + layout.addWidget(self._maxAuto) + layout.addStretch(1) + + def __bothToggled(self, checked): + autoRange = checked, checked + self.setAutoRange(autoRange) + self.autoRangeChanged.emit(autoRange) + + def __minToggled(self, checked): + autoRange = self.getAutoRange() + self.setAutoRange(autoRange) + self.autoRangeChanged.emit(autoRange) + + def __maxToggled(self, checked): + autoRange = self.getAutoRange() + self.setAutoRange(autoRange) + self.autoRangeChanged.emit(autoRange) + + def setAutoRangeFromColormap(self, colormap): + vRange = colormap.getVRange() + autoRange = vRange[0] is None, vRange[1] is None + self.setAutoRange(autoRange) + + def setAutoRange(self, autoRange): + if autoRange[0] == autoRange[1]: + with utils.blockSignals(self._bothAuto): + self._bothAuto.setChecked(autoRange[0]) + else: + with utils.blockSignals(self._bothAuto): + self._bothAuto.setChecked(False) + with utils.blockSignals(self._minAuto): + self._minAuto.setChecked(autoRange[0]) + with utils.blockSignals(self._maxAuto): + self._maxAuto.setChecked(autoRange[1]) + + def getAutoRange(self): + return self._minAuto.isChecked(), self._maxAuto.isChecked() + + @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. +class _ColormapHistogram(qt.QWidget): + """Display the colormap and the data as a plot.""" - :param parent: See :class:`QDialog` - :param str title: The QDialog title + sigRangeMoving = qt.Signal(object, object) + """Emitted when a mouse interaction moves the location + of the colormap range in the plot. + + This signal contains 2 elements: + + - vmin: A float value if this range was moved, else None + - vmax: A float value if this range was moved, else None """ - visibleChanged = qt.Signal(bool) - """This event is sent when the dialog visibility change""" + sigRangeMoved = qt.Signal(object, object) + """Emitted when a mouse interaction stop. - def __init__(self, parent=None, title="Colormap Dialog"): - qt.QDialog.__init__(self, parent) - self.setWindowTitle(title) + This signal contains 2 elements: - self._colormap = None - self._data = None + - vmin: A float value if this range was moved, else None + - vmax: A float value if this range was moved, else None + """ + + def __init__(self, parent): + qt.QWidget.__init__(self, parent=parent) self._dataInPlotMode = _DataInPlotMode.RANGE + self._finiteRange = None, None + self._initPlot() - 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 = {} + """Histogram displayed in the plot""" + + self._dataRange = {} + """Histogram displayed in the plot""" + + self._invalidated = False + + def paintEvent(self, event): + if self._invalidated: + self._updateDataInPlot() + self._invalidated = False + self._updateMarkerPosition() + return super(_ColormapHistogram, self).paintEvent(event) + + def getFiniteRange(self): + """Returns the colormap range as displayed in the plot.""" + return self._finiteRange + + def setFiniteRange(self, vRange): + """Set the colormap range to use in the plot. + + Here there is no concept of auto. The values should + not be None, except if there is no range or marker + to display. """ + if vRange == self._finiteRange: + return + self._finiteRange = vRange + self.update() - self.__displayInvalidated = False - self._histogramData = None - self._minMaxWasEdited = False - self._initialRange = None + def getColormap(self): + return self.parent().getColormap() - self._dataRange = None - """If defined 3-tuple containing information from a data: - minimum, positive minimum, maximum""" + def _getNormalizedHistogram(self): + """Return an histogram already normalized according to the colormap + normalization. - self._colormapStoredState = None + Returns a tuple edges, counts + """ + norm = self._getNorm() + histogram = self._histogramData.get(norm, None) + if histogram is None: + histogram = self._computeNormalizedHistogram() + self._histogramData[norm] = histogram + return histogram + + def _computeNormalizedHistogram(self): + colormap = self.getColormap() + if colormap is None: + norm = Colormap.LINEAR + else: + norm = colormap.getNormalization() + + # Try to use the histogram defined in the dialog + histo = self.parent()._getHistogram() + if histo is not None: + counts, edges = histo + normalizer = Colormap(normalization=norm)._getNormalizer() + mask = normalizer.isValid(edges[:-1]) # Check lower bin edges only + firstValid = numpy.argmax(mask) # edges increases monotonically + if firstValid == 0: # Mask is all False or all True + return (counts, edges) if mask[0] else (None, None) + else: # Clip to valid values + return counts[firstValid:], edges[firstValid:] + + data = self.parent()._getArray() + if data is None: + return None, None + dataRange = self._getNormalizedDataRange() + if dataRange[0] is None or dataRange[1] is None: + return None, None + counts, edges = self.parent().computeHistogram(data, scale=norm, dataRange=dataRange) + return counts, edges - # Make the GUI - vLayout = qt.QVBoxLayout(self) + def _getNormalizedDataRange(self): + """Return a data range already normalized according to the colormap + normalization. - formWidget = qt.QWidget(parent=self) - vLayout.addWidget(formWidget) - formLayout = qt.QFormLayout(formWidget) - formLayout.setContentsMargins(10, 10, 10, 10) - formLayout.setSpacing(0) + Returns a tuple with min and max + """ + norm = self._getNorm() + dataRange = self._dataRange.get(norm, None) + if dataRange is None: + dataRange = self._computeNormalizedDataRange() + self._dataRange[norm] = dataRange + return dataRange - # Colormap row - self._comboBoxColormap = ColormapNameComboBox(parent=formWidget) - self._comboBoxColormap.currentIndexChanged[int].connect(self._updateLut) - formLayout.addRow('Colormap:', self._comboBoxColormap) + def _computeNormalizedDataRange(self): + colormap = self.getColormap() + if colormap is None: + norm = Colormap.LINEAR + else: + norm = colormap.getNormalization() - # Normalization row - self._normButtonLinear = qt.QRadioButton('Linear') - self._normButtonLinear.setChecked(True) - self._normButtonLog = qt.QRadioButton('Log') + # Try to use the one defined in the dialog + dataRange = self.parent()._getDataRange() + if dataRange is not None: + if norm in (Colormap.LINEAR, Colormap.GAMMA, Colormap.ARCSINH): + return dataRange[0], dataRange[2] + elif norm == Colormap.LOGARITHM: + return dataRange[1], dataRange[2] + elif norm == Colormap.SQRT: + return dataRange[1], dataRange[2] + else: + _logger.error("Undefined %s normalization", norm) + + # Try to use the histogram defined in the dialog + histo = self.parent()._getHistogram() + if histo is not None: + _histo, edges = histo + normalizer = Colormap(normalization=norm)._getNormalizer() + edges = edges[normalizer.isValid(edges)] + if edges.size == 0: + return None, None + else: + dataRange = min_max(edges, finite=True) + return dataRange.minimum, dataRange.maximum - normButtonGroup = qt.QButtonGroup(self) - normButtonGroup.setExclusive(True) - normButtonGroup.addButton(self._normButtonLinear) - normButtonGroup.addButton(self._normButtonLog) - normButtonGroup.buttonClicked[qt.QAbstractButton].connect(self._updateNormalization) + item = self.parent()._getItem() + if item is not None: + # Trick to reach data range using colormap cache + cm = Colormap() + cm.setVRange(None, None) + cm.setNormalization(norm) + dataRange = item._getColormapAutoscaleRange(cm) + return dataRange - normLayout = qt.QHBoxLayout() - normLayout.setContentsMargins(0, 0, 0, 0) - normLayout.setSpacing(10) - normLayout.addWidget(self._normButtonLinear) - normLayout.addWidget(self._normButtonLog) + # If there is no item, there is no data + return None, None - formLayout.addRow('Normalization:', normLayout) + def _getDisplayableRange(self): + """Returns the selected min/max range to apply to the data, + according to the used scale. - # Min row - 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) + One or both limits can be None in case it is not displayable in the + current axes scale. - # Max row - 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) + :returns: Tuple{float, float} + """ + scale = self._plot.getXAxis().getScale() + def isDisplayable(pos): + if pos is None: + return False + if scale == Axis.LOGARITHMIC: + return pos > 0.0 + return True + + posMin, posMax = self.getFiniteRange() + if not isDisplayable(posMin): + posMin = None + if not isDisplayable(posMax): + posMax = None + + return posMin, posMax + + def _initPlot(self): + """Init the plot to display the range and the values""" + self._plot = PlotWidget(self) + self._plot.setDataMargins(0.125, 0.125, 0.125, 0.125) + self._plot.getXAxis().setLabel("Data Values") + self._plot.getYAxis().setLabel("") + self._plot.setInteractiveMode('select', zoomOnWheel=False) + self._plot.setActiveCurveHandling(False) + self._plot.setMinimumSize(qt.QSize(250, 200)) + self._plot.sigPlotSignal.connect(self._plotEventReceived) + palette = self.palette() + color = palette.color(qt.QPalette.Normal, qt.QPalette.Window) + self._plot.setBackgroundColor(color) + self._plot.setDataBackgroundColor("white") + + lut = numpy.arange(256) + lut.shape = 1, -1 + self._plot.addImage(lut, legend='lut') + self._lutItem = self._plot._getItem("image", "lut") + self._lutItem.setVisible(False) + + self._plot.addScatter(x=[], y=[], value=[], legend='lut2') + self._lutItem2 = self._plot._getItem("scatter", "lut2") + self._lutItem2.setVisible(False) + self.__lutY = numpy.array([-0.05] * 256) + self.__lutV = numpy.arange(256) + + self._bound = BoundingRect() + self._plot.addItem(self._bound) + self._bound.setVisible(True) # Add plot for histogram self._plotToolbar = qt.QToolBar(self) @@ -256,14 +571,6 @@ class ColormapDialog(qt.QDialog): 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')) @@ -282,23 +589,339 @@ class ColormapDialog(qt.QDialog): group.addAction(action) group.triggered.connect(self._displayDataInPlotModeChanged) - self._plotBox = qt.QWidget(self) - self._plotInit() - 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) + self.setLayout(plotBoxLayout) + + def _plotEventReceived(self, event): + """Handle events from the plot""" + kind = event['event'] + + if kind == 'markerMoving': + value = event['xdata'] + if event['label'] == 'Min': + self._finiteRange = value, self._finiteRange[1] + self._last = value, None + self.sigRangeMoving.emit(*self._last) + elif event['label'] == 'Max': + self._finiteRange = self._finiteRange[0], value + self._last = None, value + self.sigRangeMoving.emit(*self._last) + self._updateLutItem(self._finiteRange) + elif kind == 'markerMoved': + self.sigRangeMoved.emit(*self._last) + self._plot.resetZoom() + else: + pass + + def _updateMarkerPosition(self): + colormap = self.getColormap() + posMin, posMax = self._getDisplayableRange() + + if colormap is None: + isDraggable = False + else: + isDraggable = colormap.isEditable() + + with utils.blockSignals(self): + if posMin is not None: + self._plot.addXMarker( + posMin, + legend='Min', + text='Min', + draggable=isDraggable, + color="blue", + constraint=self._plotMinMarkerConstraint) + if posMax is not None: + self._plot.addXMarker( + posMax, + legend='Max', + text='Max', + draggable=isDraggable, + color="blue", + constraint=self._plotMaxMarkerConstraint) + + self._updateLutItem((posMin, posMax)) + self._plot.resetZoom() + + def _updateLutItem(self, vRange): + colormap = self.getColormap() + if colormap is None: + return + + if vRange is None: + posMin, posMax = self._getDisplayableRange() + else: + posMin, posMax = vRange + if posMin is None or posMax is None: + self._lutItem.setVisible(False) + pos = posMax if posMin is None else posMin + if pos is not None: + self._bound.setBounds((pos, pos, -0.1, 0)) + else: + self._bound.setBounds((0, 0, -0.1, 0)) + else: + norm = colormap.getNormalization() + normColormap = colormap.copy() + normColormap.setVRange(0, 255) + normColormap.setNormalization(Colormap.LINEAR) + if norm == Colormap.LINEAR: + scale = (posMax - posMin) / 256 + self._lutItem.setColormap(normColormap) + self._lutItem.setOrigin((posMin, -0.09)) + self._lutItem.setScale((scale, 0.08)) + self._lutItem.setVisible(True) + self._lutItem2.setVisible(False) + elif norm == Colormap.LOGARITHM: + self._lutItem2.setVisible(False) + self._lutItem2.setColormap(normColormap) + xx = numpy.geomspace(posMin, posMax, 256) + self._lutItem2.setData(x=xx, + y=self.__lutY, + value=self.__lutV, + copy=False) + self._lutItem2.setSymbol("|") + self._lutItem2.setVisible(True) + self._lutItem.setVisible(False) + else: + # Fallback: Display with linear axis and applied normalization + self._lutItem2.setVisible(False) + normColormap.setNormalization(norm) + self._lutItem2.setColormap(normColormap) + xx = numpy.linspace(posMin, posMax, 256, endpoint=True) + self._lutItem2.setData( + x=xx, + y=self.__lutY, + value=self.__lutV, + copy=False) + self._lutItem2.setSymbol("|") + self._lutItem2.setVisible(True) + self._lutItem.setVisible(False) + + self._bound.setBounds((posMin, posMax, -0.1, 1)) + + def _plotMinMarkerConstraint(self, x, y): + """Constraint of the min marker""" + _vmin, vmax = self.getFiniteRange() + if vmax is None: + return x, y + return min(x, vmax), y + + def _plotMaxMarkerConstraint(self, x, y): + """Constraint of the max marker""" + vmin, _vmax = self.getFiniteRange() + if vmin is None: + return x, y + return max(x, vmin), y + + 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 invalidateData(self): + self._histogramData = {} + self._dataRange = {} + self._invalidated = True + self.update() + + def _updateDataInPlot(self): + mode = self._dataInPlotMode + + norm = self._getNorm() + if norm == Colormap.LINEAR: + scale = Axis.LINEAR + elif norm == Colormap.LOGARITHM: + scale = Axis.LOGARITHMIC + else: + scale = Axis.LINEAR + + axis = self._plot.getXAxis() + axis.setScale(scale) + + if mode == _DataInPlotMode.RANGE: + dataRange = self._getNormalizedDataRange() + xmin, xmax = dataRange + if xmax is None or xmin is None: + self._plot.remove(legend='Data', kind='histogram') + else: + histogram = numpy.array([1]) + bin_edges = numpy.array([xmin, xmax]) + self._plot.addHistogram(histogram, + bin_edges, + legend="Data", + color='gray', + align='center', + fill=True, + z=1) + + elif mode == _DataInPlotMode.HISTOGRAM: + histogram, bin_edges = self._getNormalizedHistogram() + if histogram is None or bin_edges is None: + self._plot.remove(legend='Data', kind='histogram') + else: + histogram = numpy.array(histogram, copy=True) + bin_edges = numpy.array(bin_edges, copy=True) + norm_histogram = histogram / max(histogram) + self._plot.addHistogram(norm_histogram, + bin_edges, + legend="Data", + color='gray', + align='center', + fill=True, + z=1) + else: + _logger.error("Mode unsupported") + + def sizeHint(self): + return self.layout().minimumSize() + + def updateLut(self): + self._updateLutItem(None) + + def _getNorm(self): + colormap = self.getColormap() + if colormap is None: + return Axis.LINEAR + else: + norm = colormap.getNormalization() + return norm + + def updateNormalization(self): + self._updateDataInPlot() + self.update() + + +class ColormapDialog(qt.QDialog): + """A QDialog widget to set the colormap. + + :param parent: See :class:`QDialog` + :param str title: The QDialog title + """ + + 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.__aboutToDelete = False + self._colormap = None + + self._data = None + """Weak ref to an external numpy array + """ + self._itemHolder = None + """Hard ref to a private item (used as holder to the data) + This allow to reuse the item cache + """ + self._item = None + """Weak ref to an external item""" + + self._colormapChange = utils.LockReentrant() + """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.__colormapInvalidated = False + self.__dataInvalidated = False + + self._histogramData = None + + self._dataRange = None + """If defined 3-tuple containing information from a data: + minimum, positive minimum, maximum""" + + self._colormapStoredState = None + + # Colormap row + self._comboBoxColormap = ColormapNameComboBox(parent=self) + self._comboBoxColormap.currentIndexChanged[int].connect(self._comboBoxColormapUpdated) + + # Normalization row + self._comboBoxNormalization = qt.QComboBox(parent=self) + normalizations = [ + ('Linear', Colormap.LINEAR), + ('Gamma correction', Colormap.GAMMA), + ('Arcsinh', Colormap.ARCSINH), + ('Logarithmic', Colormap.LOGARITHM), + ('Square root', Colormap.SQRT)] + for name, userData in normalizations: + try: + icon = icons.getQIcon("colormap-norm-%s" % userData) + except: + icon = qt.QIcon() + self._comboBoxNormalization.addItem(icon, name, userData) + self._comboBoxNormalization.currentIndexChanged[int].connect( + self._normalizationUpdated) + + self._gammaSpinBox = qt.QDoubleSpinBox(parent=self) + self._gammaSpinBox.setEnabled(False) + self._gammaSpinBox.setRange(0., 1000.) + self._gammaSpinBox.setDecimals(4) + if hasattr(qt.QDoubleSpinBox, "setStepType"): + # Introduced in Qt 5.12 + self._gammaSpinBox.setStepType(qt.QDoubleSpinBox.AdaptiveDecimalStepType) + else: + self._gammaSpinBox.setSingleStep(0.1) + self._gammaSpinBox.valueChanged.connect(self._gammaUpdated) + self._gammaSpinBox.setValue(2.) + + autoScaleCombo = _AutoscaleModeComboBox(self) + autoScaleCombo.currentIndexChanged.connect(self._autoscaleModeUpdated) + self._autoScaleCombo = autoScaleCombo + + # Min row + self._minValue = _BoundaryWidget(parent=self, value=1.0) + self._minValue.sigAutoScaleChanged.connect(self._minAutoscaleUpdated) + self._minValue.sigValueChanged.connect(self._minValueUpdated) + + # Max row + self._maxValue = _BoundaryWidget(parent=self, value=10.0) + self._maxValue.sigAutoScaleChanged.connect(self._maxAutoscaleUpdated) + self._maxValue.sigValueChanged.connect(self._maxValueUpdated) + + self._autoButtons = _AutoScaleButtons(self) + self._autoButtons.autoRangeChanged.connect(self._autoRangeButtonsUpdated) + + rangeLayout = qt.QGridLayout() + miniFont = qt.QFont(self.font()) + miniFont.setPixelSize(8) + labelMin = qt.QLabel("Min", self) + labelMin.setFont(miniFont) + labelMin.setAlignment(qt.Qt.AlignHCenter) + labelMax = qt.QLabel("Max", self) + labelMax.setAlignment(qt.Qt.AlignHCenter) + labelMax.setFont(miniFont) + rangeLayout.addWidget(labelMin, 0, 0) + rangeLayout.addWidget(labelMax, 0, 1) + rangeLayout.addWidget(self._minValue, 1, 0) + rangeLayout.addWidget(self._maxValue, 1, 1) + rangeLayout.addWidget(self._autoButtons, 2, 0, 1, -1, qt.Qt.AlignCenter) + + self._histoWidget = _ColormapHistogram(self) + self._histoWidget.sigRangeMoving.connect(self._histogramRangeMoving) + self._histoWidget.sigRangeMoved.connect(self._histogramRangeMoved) # 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) @@ -306,9 +929,14 @@ class ColormapDialog(qt.QDialog): 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) + button = self._buttonsNonModal.button(qt.QDialogButtonBox.Close) + button.clicked.connect(self.accept) + button.setDefault(True) + button = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset) + button.clicked.connect(self.resetColormap) + + self._buttonsModal.setFocus(qt.Qt.OtherFocusReason) + self._buttonsNonModal.setFocus(qt.Qt.OtherFocusReason) # Set the colormap to default values self.setColormap(Colormap(name='gray', normalization='linear', @@ -316,21 +944,62 @@ class ColormapDialog(qt.QDialog): self.setModal(self.isModal()) - vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + formLayout = qt.QFormLayout(self) + formLayout.setContentsMargins(10, 10, 10, 10) + formLayout.addRow('Colormap:', self._comboBoxColormap) + formLayout.addRow('Normalization:', self._comboBoxNormalization) + formLayout.addRow('Gamma:', self._gammaSpinBox) + formLayout.addRow(self._histoWidget) + formLayout.addRow(rangeLayout) + label = qt.QLabel('Mode:', self) + self._autoscaleModeLabel = label + label.setToolTip("Mode for autoscale. Algorithm used to find range in auto scale.") + formLayout.addItem(qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)) + formLayout.addRow(label, autoScaleCombo) + formLayout.addRow(self._buttonsModal) + formLayout.addRow(self._buttonsNonModal) + formLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + + self.setTabOrder(self._comboBoxColormap, self._comboBoxNormalization) + self.setTabOrder(self._comboBoxNormalization, self._gammaSpinBox) + self.setTabOrder(self._gammaSpinBox, self._minValue) + self.setTabOrder(self._minValue, self._maxValue) + self.setTabOrder(self._maxValue, self._autoButtons) + self.setTabOrder(self._autoButtons, self._autoScaleCombo) + self.setTabOrder(self._autoScaleCombo, self._buttonsModal) + self.setTabOrder(self._buttonsModal, self._buttonsNonModal) + self.setFixedSize(self.sizeHint()) self._applyColormap() - def _displayLater(self): - self.__displayInvalidated = True + def _invalidateColormap(self): + if self.isVisible(): + self._applyColormap() + else: + self.__colormapInvalidated = True + + def _invalidateData(self): + if self.isVisible(): + self._updateWidgetRange() + self._histoWidget.invalidateData() + else: + self.__dataInvalidated = True + + def _validate(self): + if self.__colormapInvalidated: + self._applyColormap() + if self.__dataInvalidated: + self._histoWidget.invalidateData() + if self.__dataInvalidated or self.__colormapInvalidated: + self._updateWidgetRange() + self.__dataInvalidated = False + self.__colormapInvalidated = False def showEvent(self, event): self.visibleChanged.emit(True) super(ColormapDialog, self).showEvent(event) if self.isVisible(): - if self.__displayInvalidated: - self._applyColormap() - self._updateDataInPlot() - self.__displayInvalidated = False + self._validate() def closeEvent(self, event): if not self.isModal(): @@ -351,179 +1020,32 @@ class ColormapDialog(qt.QDialog): self._buttonsModal.setVisible(modal) qt.QDialog.setModal(self, modal) + def event(self, event): + if event.type() == qt.QEvent.DeferredDelete: + self.__aboutToDelete = True + return super(ColormapDialog, self).event(event) + def exec_(self): wasModal = self.isModal() self.setModal(True) result = super(ColormapDialog, self).exec_() - self.setModal(wasModal) + if not self.__aboutToDelete: + self.setModal(wasModal) return result - 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.getXAxis().setLabel("Data Values") - self._plot.getYAxis().setLabel("") - self._plot.setInteractiveMode('select', zoomOnWheel=False) - self._plot.setActiveCurveHandling(False) - self._plot.setMinimumSize(qt.QSize(250, 200)) - self._plot.sigPlotSignal.connect(self._plotSlot) - - self._plotUpdate() - - def sizeHint(self): - return self.layout().minimumSize() - - def _computeView(self, dataMin, dataMax): - """Compute the location of the view according to the bound of the data - - :rtype: Tuple(float, float) - """ - marginRatio = 1.0 / 6.0 - scale = self._plot.getXAxis().getScale() - - if self._dataRange is not None: - if scale == Axis.LOGARITHMIC: - minRange = self._dataRange[1] - else: - minRange = self._dataRange[0] - maxRange = self._dataRange[2] - if minRange is not None: - dataMin = min(dataMin, minRange) - dataMax = max(dataMax, maxRange) - - if self._histogramData is not None: - info = min_max(self._histogramData[1]) - if scale == Axis.LOGARITHMIC: - minHisto = info.min_positive - else: - minHisto = info.minimum - maxHisto = info.maximum - if minHisto is not None: - dataMin = min(dataMin, minHisto) - dataMax = max(dataMax, maxHisto) - - if scale == Axis.LOGARITHMIC: - epsilon = numpy.finfo(numpy.float32).eps - if dataMin == 0: - dataMin = epsilon - if dataMax < dataMin: - dataMax = dataMin + epsilon - marge = marginRatio * abs(numpy.log10(dataMax) - numpy.log10(dataMin)) - viewMin = 10**(numpy.log10(dataMin) - marge) - viewMax = 10**(numpy.log10(dataMax) + marge) - else: # scale == Axis.LINEAR: - marge = marginRatio * abs(dataMax - dataMin) - if marge < 0.0001: - # Smaller that the QLineEdit precision - marge = 0.0001 - viewMin = dataMin - marge - viewMax = dataMax + marge - - return viewMin, viewMax - - def _plotUpdate(self, updateMarkers=True): - """Update the plot content - - :param bool updateMarkers: True to update markers, False otherwith + def _getFiniteColormapRange(self): + """Return a colormap range where auto ranges are fixed + according to the available data. """ colormap = self.getColormap() if colormap is None: - if self._plotBox.isVisibleTo(self): - self._plotBox.setVisible(False) - self.setFixedSize(self.sizeHint()) - return - - if not self._plotBox.isVisibleTo(self): - self._plotBox.setVisible(True) - self.setFixedSize(self.sizeHint()) - - minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue() - if minData > maxData: - # avoid a full collapse - minData, maxData = maxData, minData - - minView, maxView = self._computeView(minData, maxData) - - 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]) - - if minView > minData: - # Hide the min range - minData = minView - x = [minView, minData, maxData, maxView] - y = [0, 0, 1, 1] - - self._plot.addCurve(x, y, - legend="ConstrainedCurve", - color='black', - symbol='o', - linestyle='-', - z=2, - resetzoom=False) - - scale = self._plot.getXAxis().getScale() - - if updateMarkers: - posMin = self._minValue.getFiniteValue() - posMax = self._maxValue.getFiniteValue() - - def isDisplayable(pos): - if scale == Axis.LOGARITHMIC: - return pos > 0.0 - return True - - if isDisplayable(posMin): - minDraggable = (self._colormap().isEditable() and - not self._minValue.isAutoChecked()) - self._plot.addXMarker( - posMin, - legend='Min', - text='Min', - draggable=minDraggable, - color='blue', - constraint=self._plotMinMarkerConstraint) - if isDisplayable(posMax): - maxDraggable = (self._colormap().isEditable() and - not self._maxValue.isAutoChecked()) - self._plot.addXMarker( - posMax, - legend='Max', - text='Max', - draggable=maxDraggable, - color='blue', - constraint=self._plotMaxMarkerConstraint) + return 1, 10 - self._plot.resetZoom() - - def _plotMinMarkerConstraint(self, x, y): - """Constraint of the min marker""" - return min(x, self._maxValue.getFiniteValue()), y - - def _plotMaxMarkerConstraint(self, x, y): - """Constraint of the max marker""" - return max(x, self._minValue.getFiniteValue()), 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._initialRange = None - self._updateMinMax() - else: - self._plotUpdate(updateMarkers=False) + item = self._getItem() + if item is not None: + return colormap.getColormapRange(item) + # If there is not item, there is no data + return colormap.getColormapRange(None) @staticmethod def computeDataRange(data): @@ -552,53 +1074,117 @@ class ColormapDialog(qt.QDialog): return dataRange @staticmethod - def computeHistogram(data, scale=Axis.LINEAR): + def computeHistogram(data, scale=Axis.LINEAR, dataRange=None): """Compute the data histogram as used by :meth:`setHistogram`. :param data: The data to process + :param dataRange: Optional range to compute the histogram, which is a + tuple of min, max :rtype: Tuple(List(float),List(float) """ - _data = data - if _data.ndim == 3: # RGB(A) images + # For compatibility + if scale == Axis.LOGARITHMIC: + scale = Colormap.LOGARITHM + + if data is None: + return None, None + + if len(data) == 0: + return None, None + + 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) + data = (data[:, :, 0] * 0.299 + + data[:, :, 1] * 0.587 + + data[:, :, 2] * 0.114) + + # bad hack: get 256 continuous bins in the case we have a B&W + normalizeData = True + if numpy.issubdtype(data.dtype, numpy.ubyte): + normalizeData = False + elif numpy.issubdtype(data.dtype, numpy.integer): + if dataRange is not None: + xmin, xmax = dataRange + if xmin is not None and xmax is not None: + normalizeData = (xmax - xmin) > 255 + + if normalizeData: + if scale == Colormap.LOGARITHM: + with numpy.errstate(divide='ignore', invalid='ignore'): + data = numpy.log10(data) - if len(_data) == 0: - return None, None + if dataRange is not None: + xmin, xmax = dataRange + if xmin is None: + return None, None + if normalizeData: + if scale == Colormap.LOGARITHM: + xmin, xmax = numpy.log10(xmin), numpy.log10(xmax) + else: + xmin, xmax = min_max(data, min_positive=False, finite=True) - if scale == Axis.LOGARITHMIC: - _data = numpy.log10(_data) - xmin, xmax = min_max(_data, min_positive=False, finite=True) if xmin is None: return None, None - nbins = min(256, int(numpy.sqrt(_data.size))) + 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 numpy.issubdtype(data.dtype, numpy.integer): if nbins > xmax - xmin: - nbins = xmax - xmin + nbins = int(xmax - xmin) nbins = max(2, nbins) - _data = _data.ravel().astype(numpy.float32) + data = data.ravel().astype(numpy.float32) - histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range) + histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range) bins = histogram.edges[0] - if scale == Axis.LOGARITHMIC: - bins = 10**bins + if normalizeData: + if scale == Colormap.LOGARITHM: + bins = 10**bins return histogram.histo, bins + def _getItem(self): + if self._itemHolder is not None: + return self._itemHolder + if self._item is None: + return None + return self._item() + + def setItem(self, item): + """Store the plot item. + + According to the state of the dialog, the item will be used to display + the data range or the histogram of the data using :meth:`setDataRange` + and :meth:`setHistogram` + """ + # While event from items are not supported, we can't ignore dup items + # old = self._getItem() + # if old is item: + # return + self._data = None + self._itemHolder = None + try: + if item is None: + self._item = None + else: + if not isinstance(item, items.ColormapMixIn): + self._item = None + raise ValueError("Item %s is not supported" % item) + self._item = weakref.ref(item, self._itemAboutToFinalize) + finally: + self._dataRange = None + self._histogramData = None + self._invalidateData() + def _getData(self): if self._data is None: return None return self._data() def setData(self, data): - """Store the data as a weakref. + """Store the data 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` @@ -608,79 +1194,58 @@ class ColormapDialog(qt.QDialog): if oldData is data: return + self._item = None if data is None: self._data = None + self._itemHolder = None else: self._data = weakref.ref(data, self._dataAboutToFinalize) + self._itemHolder = _DataRefHolder(self._data) - if self.isVisible(): - self._updateDataInPlot() - else: - self._displayLater() - - def _setDataInPlotMode(self, mode): - if self._dataInPlotMode == mode: - return - self._dataInPlotMode = mode - self._updateDataInPlot() + self._dataRange = None + self._histogramData = None - def _displayDataInPlotModeChanged(self, action): - mode = action.data() - self._setDataInPlotMode(mode) + self._invalidateData() - def _updateDataInPlot(self): + def _getArray(self): data = self._getData() - if data is None: - self.setDataRange() - self.setHistogram() - return - - if data.size == 0: - # One or more dimensions are equal to 0 - self.setHistogram() - self.setDataRange() - 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, scale=self._plot.getXAxis().getScale()) - self.setHistogram(*result) - self.setDataRange() - - def _invalidateHistogram(self): - """Recompute the histogram if it is displayed""" - if self._dataInPlotMode == _DataInPlotMode.HISTOGRAM: - self._updateDataInPlot() + if data is not None: + return data + item = self._getItem() + if item is not None: + return item.getColormappedData(copy=False) + return None def _colormapAboutToFinalize(self, weakrefColormap): """Callback when the data weakref is about to be finalized.""" - if self._colormap is weakrefColormap: + if self._colormap is weakrefColormap and qtinspect.isValid(self): self.setColormap(None) def _dataAboutToFinalize(self, weakrefData): """Callback when the data weakref is about to be finalized.""" - if self._data is weakrefData: + if self._data is weakrefData and qtinspect.isValid(self): self.setData(None) + def _itemAboutToFinalize(self, weakref): + """Callback when the data weakref is about to be finalized.""" + if self._item is weakref and qtinspect.isValid(self): + self.setItem(None) + + @deprecation.deprecated(reason="It is private data", since_version="0.13") def getHistogram(self): - """Returns the counts and bin edges of the displayed histogram. + histo = self._getHistogram() + if histo is None: + return None + counts, bin_edges = histo + return numpy.array(counts, copy=True), numpy.array(bin_edges, copy=True) + + def _getHistogram(self): + """Returns the histogram defined by the dialog as metadata + to describe the data in order to speed up the dialog. :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) + return self._histogramData def setHistogram(self, hist=None, bin_edges=None): """Set the histogram to display. @@ -692,20 +1257,10 @@ class ColormapDialog(qt.QDialog): """ if hist is None or bin_edges is None: self._histogramData = None - 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 - norm_hist = hist / max(hist) - self._plot.addHistogram(norm_hist, - bin_edges, - legend="Histogram", - color='gray', - align='center', - fill=True, - z=1) - self._updateMinMaxData() + self._histogramData = numpy.array(hist), numpy.array(bin_edges) + + self._invalidateData() def getColormap(self): """Return the colormap description. @@ -726,11 +1281,18 @@ class ColormapDialog(qt.QDialog): colormap = self.getColormap() if colormap is not None and self._colormapStoredState is not None: if colormap != self._colormapStoredState: - self._ignoreColormapChange = True - colormap.setFromColormap(self._colormapStoredState) - self._ignoreColormapChange = False + with self._colormapChange: + colormap.setFromColormap(self._colormapStoredState) self._applyColormap() + def _getDataRange(self): + """Returns the data range defined by the dialog as metadata + to describe the data in order to speed up the dialog. + + :return: (minimum, positiveMin, maximum) + :rtype: 3-tuple of floats or None""" + return self._dataRange + def setDataRange(self, minimum=None, positiveMin=None, maximum=None): """Set the range of data to use for the range of the histogram area. @@ -738,62 +1300,37 @@ class ColormapDialog(qt.QDialog): :param float positiveMin: The positive minimum of the data :param float maximum: The maximum of the data """ - scale = self._plot.getXAxis().getScale() - if scale == Axis.LOGARITHMIC: - dataMin, dataMax = positiveMin, maximum - else: - dataMin, dataMax = minimum, maximum + self._dataRange = minimum, positiveMin, maximum + self._invalidateData() - if dataMin is None or dataMax is None: - self._dataRange = None - self._plot.remove(legend='Range', kind='histogram') - else: - hist = numpy.array([1]) - bin_edges = numpy.array([dataMin, dataMax]) - self._plot.addHistogram(hist, - bin_edges, - legend="Range", - color='gray', - align='center', - fill=True, - z=1) - 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.""" + def _setColormapRange(self, xmin, xmax): + """Set a new range to the held colormap and update the + widget.""" colormap = self.getColormap() + if colormap is not None: + with self._colormapChange: + colormap.setVRange(xmin, xmax) + self._updateWidgetRange() - 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]) + def _updateWidgetRange(self): + """Update the colormap range displayed into the widget.""" + xmin, xmax = self._getFiniteColormapRange() + colormap = self.getColormap() + if colormap is not None: + vRange = colormap.getVRange() + autoMin, autoMax = (r is None for r in vRange) 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() + autoMin, autoMax = False, False + + with utils.blockSignals(self._minValue): + self._minValue.setValue(xmin, autoMin) + with utils.blockSignals(self._maxValue): + self._maxValue.setValue(xmax, autoMax) + with utils.blockSignals(self._histoWidget): + self._histoWidget.setFiniteRange((xmin, xmax)) + with utils.blockSignals(self._autoButtons): + self._autoButtons.setAutoRange((autoMin, autoMax)) + self._autoscaleModeLabel.setEnabled(autoMin or autoMax) def accept(self): self.storeCurrentState() @@ -820,7 +1357,7 @@ class ColormapDialog(qt.QDialog): :param ~silx.gui.colors.Colormap colormap: the colormap to edit """ assert colormap is None or isinstance(colormap, Colormap) - if self._ignoreColormapChange is True: + if self._colormapChange.locked(): return oldColormap = self.getColormap() @@ -835,11 +1372,7 @@ class ColormapDialog(qt.QDialog): self._colormap = colormap self.storeCurrentState() - if self.isVisible(): - self._applyColormap() - else: - self._updateResetButton() - self._displayLater() + self._invalidateColormap() def _updateResetButton(self): resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset) @@ -852,156 +1385,232 @@ class ColormapDialog(qt.QDialog): def _applyColormap(self): self._updateResetButton() - if self._ignoreColormapChange is True: + if self._colormapChange.locked(): return colormap = self.getColormap() if colormap is None: self._comboBoxColormap.setEnabled(False) - self._normButtonLinear.setEnabled(False) - self._normButtonLog.setEnabled(False) + self._comboBoxNormalization.setEnabled(False) + self._gammaSpinBox.setEnabled(False) + self._autoScaleCombo.setEnabled(False) self._minValue.setEnabled(False) self._maxValue.setEnabled(False) + self._autoButtons.setEnabled(False) + self._autoscaleModeLabel.setEnabled(False) + self._histoWidget.setVisible(False) + self._histoWidget.setFiniteRange((None, None)) else: - self._ignoreColormapChange = True - self._comboBoxColormap.setCurrentLut(colormap) - self._comboBoxColormap.setEnabled(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(colormap.isEditable()) - self._normButtonLog.setEnabled(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(colormap.isEditable()) - self._maxValue.setEnabled(colormap.isEditable()) + with utils.blockSignals(self._comboBoxColormap): + self._comboBoxColormap.setCurrentLut(colormap) + self._comboBoxColormap.setEnabled(colormap.isEditable()) + with utils.blockSignals(self._comboBoxNormalization): + index = self._comboBoxNormalization.findData( + colormap.getNormalization()) + if index < 0: + _logger.error('Unsupported normalization: %s' % + colormap.getNormalization()) + else: + self._comboBoxNormalization.setCurrentIndex(index) + self._comboBoxNormalization.setEnabled(colormap.isEditable()) + with utils.blockSignals(self._gammaSpinBox): + self._gammaSpinBox.setValue( + colormap.getGammaNormalizationParameter()) + self._gammaSpinBox.setEnabled( + colormap.getNormalization() == 'gamma' and + colormap.isEditable()) + with utils.blockSignals(self._autoScaleCombo): + self._autoScaleCombo.setCurrentMode(colormap.getAutoscaleMode()) + self._autoScaleCombo.setEnabled(colormap.isEditable()) + with utils.blockSignals(self._autoButtons): + self._autoButtons.setEnabled(colormap.isEditable()) + self._autoButtons.setAutoRangeFromColormap(colormap) + + vmin, vmax = colormap.getVRange() + if vmin is None or vmax is None: + # Compute it only if needed + dataRange = self._getFiniteColormapRange() + else: + dataRange = vmin, vmax + + with utils.blockSignals(self._minValue): + self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None) + self._minValue.setEnabled(colormap.isEditable()) + with utils.blockSignals(self._maxValue): + self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None) + self._maxValue.setEnabled(colormap.isEditable()) + self._autoscaleModeLabel.setEnabled(vmin is None or vmax is None) + + with utils.blockSignals(self._histoWidget): + self._histoWidget.setVisible(True) + self._histoWidget.setFiniteRange(dataRange) + self._histoWidget.updateNormalization() + + def _comboBoxColormapUpdated(self): + """Callback executed when the combo box with the colormap LUT + is updated by user input. + """ + colormap = self.getColormap() + if colormap is not None: + with self._colormapChange: + name = self._comboBoxColormap.getCurrentName() + if name is not None: + colormap.setName(name) + else: + lut = self._comboBoxColormap.getCurrentColors() + colormap.setColormapLUT(lut) + self._histoWidget.updateLut() + + def _autoRangeButtonsUpdated(self, autoRange): + """Callback executed when the autoscale buttons widget + is updated by user input. + """ + dataRange = self._getFiniteColormapRange() - axis = self._plot.getXAxis() - scale = axis.LINEAR if colormap.getNormalization() == Colormap.LINEAR else axis.LOGARITHMIC - axis.setScale(scale) + # Final colormap range + vmin = (dataRange[0] if not autoRange[0] else None) + vmax = (dataRange[1] if not autoRange[1] else None) - self._ignoreColormapChange = False + with self._colormapChange: + colormap = self.getColormap() + colormap.setVRange(vmin, vmax) - self._plotUpdate() + with utils.blockSignals(self._minValue): + self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None) + with utils.blockSignals(self._maxValue): + self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None) - def _updateMinMax(self): - if self._ignoreColormapChange is True: - return + self._updateWidgetRange() - 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() + def _normalizationUpdated(self, index): + """Callback executed when the normalization widget + is updated by user input. + """ + colormap = self.getColormap() if colormap is not None: - colormap.setVRange(vmin, vmax) - self._ignoreColormapChange = False - self._plotUpdate() - self._updateResetButton() + normalization = self._comboBoxNormalization.itemData(index) + self._gammaSpinBox.setEnabled(normalization == 'gamma') - def _updateLut(self): - if self._ignoreColormapChange is True: - return + with self._colormapChange: + colormap.setNormalization(normalization) + self._histoWidget.updateNormalization() - colormap = self._colormap() - if colormap is not None: - self._ignoreColormapChange = True - name = self._comboBoxColormap.getCurrentName() - if name is not None: - colormap.setName(name) - else: - lut = self._comboBoxColormap.getCurrentColors() - colormap.setColormapLUT(lut) - self._ignoreColormapChange = False + self._updateWidgetRange() - def _updateNormalization(self, button): - if self._ignoreColormapChange is True: - return - if not button.isChecked(): - return + def _gammaUpdated(self, value): + """Callback used to update the gamma normalization parameter""" + colormap = self.getColormap() + if colormap is not None: + colormap.setGammaNormalizationParameter(value) - if button is self._normButtonLinear: - norm = Colormap.LINEAR - scale = Axis.LINEAR - elif button is self._normButtonLog: - norm = Colormap.LOGARITHM - scale = Axis.LOGARITHMIC - else: - assert(False) + def _autoscaleModeUpdated(self): + """Callback executed when the autoscale mode widget + is updated by user input. + """ + mode = self._autoScaleCombo.currentMode() colormap = self.getColormap() if colormap is not None: - self._ignoreColormapChange = True - colormap.setNormalization(norm) - axis = self._plot.getXAxis() - axis.setScale(scale) - self._ignoreColormapChange = False + with self._colormapChange: + colormap.setAutoscaleMode(mode) - self._invalidateHistogram() - self._updateMinMaxData() + self._updateWidgetRange() - def _minMaxTextEdited(self, text): - """Handle _minValue and _maxValue textEdited signal""" - self._minMaxWasEdited = True - - def _minEditingFinished(self): - """Handle _minValue editingFinished signal + def _minAutoscaleUpdated(self, autoEnabled): + """Callback executed when the min autoscale from + the lineedit is updated by user input""" + colormap = self.getColormap() + xmin, xmax = colormap.getVRange() + if autoEnabled: + xmin = None + else: + xmin, _xmax = self._getFiniteColormapRange() + self._setColormapRange(xmin, xmax) - Together with :meth:`_minMaxTextEdited`, this avoids to notify - colormap change when the min and max value where not edited. + def _maxAutoscaleUpdated(self, autoEnabled): + """Callback executed when the max autoscale from + the lineedit is updated by user input""" + colormap = self.getColormap() + xmin, xmax = colormap.getVRange() + if autoEnabled: + xmax = None + else: + _xmin, xmax = self._getFiniteColormapRange() + self._setColormapRange(xmin, xmax) + + def _minValueUpdated(self, value): + """Callback executed when the lineedit min value is + updated by user input""" + xmin = value + xmax = self._maxValue.getValue() + if xmax is not None and xmin > xmax: + # FIXME: This should be done in the widget itself + xmin = xmax + with utils.blockSignals(self._minValue): + self._minValue.setValue(xmin) + self._setColormapRange(xmin, xmax) + + def _maxValueUpdated(self, value): + """Callback executed when the lineedit max value is + updated by user input""" + xmin = self._minValue.getValue() + xmax = value + if xmin is not None and xmin > xmax: + # FIXME: This should be done in the widget itself + xmax = xmin + with utils.blockSignals(self._maxValue): + self._maxValue.setValue(xmax) + self._setColormapRange(xmin, xmax) + + def _histogramRangeMoving(self, vmin, vmax): + """Callback executed when for colormap range displayed in + the histogram widget is moving. + + :param vmin: Update of the minimum range, else None + :param vmax: Update of the maximum range, else None """ - if self._minMaxWasEdited: - self._minMaxWasEdited = False - - # Fix start value - 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 - - Together with :meth:`_minMaxTextEdited`, this avoids to notify - colormap change when the min and max value where not edited. + colormap = self.getColormap() + if vmin is not None: + if colormap.getVMin() is None: + with self._colormapChange: + colormap.setVMin(vmin) + self._minValue.setValue(vmin) + if vmax is not None: + if colormap.getVMax() is None: + with self._colormapChange: + colormap.setVMax(vmax) + self._maxValue.setValue(vmax) + + def _histogramRangeMoved(self, vmin, vmax): + """Callback executed when for colormap range displayed in + the histogram widget has finished to move """ - if self._minMaxWasEdited: - self._minMaxWasEdited = False - - # Fix end value - if (self._minValue.getValue() is not None and - self._minValue.getValue() > self._maxValue.getValue()): - self._maxValue.setValue(self._minValue.getValue()) - self._updateMinMax() + xmin = self._minValue.getValue() + xmax = self._maxValue.getValue() + self._setColormapRange(xmin, xmax) def keyPressEvent(self, event): """Override key handling. It disables leaving the dialog when editing a text field. + + But several press of Return key can be use to validate and close the + dialog. """ - if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or - self._maxValue.hasFocus()): + if event.key() in (qt.Qt.Key_Enter, qt.Qt.Key_Return): # Bypass QDialog keyPressEvent # To avoid leaving the dialog when pressing enter on a text field - super(qt.QDialog, self).keyPressEvent(event) + if self._minValue.hasFocus(): + nextFocus = self._maxValue + elif self._maxValue.hasFocus(): + if self.isModal(): + nextFocus = self._buttonsModal.button(qt.QDialogButtonBox.Apply) + else: + nextFocus = self._buttonsNonModal.button(qt.QDialogButtonBox.Close) + else: + nextFocus = None + if nextFocus is not None: + nextFocus.setFocus(qt.Qt.OtherFocusReason) else: - # Use QDialog keyPressEvent super(ColormapDialog, self).keyPressEvent(event) |