Source code for silx.gui.dialog.ColormapDialog

# /*##########################################################################
#
# Copyright (c) 2004-2023 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""A QDialog widget to set-up the colormap.

It uses a description of colormaps as dict compatible with :class:`Plot`.

To run the following sample code, a QApplication must be initialized.

Create the colormap dialog and set the colormap description and data range:

>>> from silx.gui.dialog.ColormapDialog import ColormapDialog
>>> from silx.gui.colors import Colormap

>>> dialog = ColormapDialog()
>>> colormap = Colormap(name='red', normalization='log',
...                     vmin=1., vmax=2.)

>>> 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:

>>> cmap = dialog.getColormap()
>>> cmap.getName()
'red'

It is also possible to display an histogram of the image in the dialog.
This updates the data range with the range of the bins.

>>> import numpy
>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1)
>>> hist, bin_edges = numpy.histogram(image, bins=10)
>>> dialog.setHistogram(hist, bin_edges)

The updates of the colormap description are also available through the signal:
:attr:`ColormapDialog.sigColormapChanged`.
"""  # noqa

from __future__ import annotations

__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
__license__ = "MIT"
__date__ = "08/12/2020"

import enum
import logging

import numpy

from .. import qt
from .. import utils
from ..colors import Colormap, cursorColorForColormap
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.gui.widgets.FormGridLayout import FormGridLayout
from silx.math.histogram import Histogramnd
from silx.gui.plot.items.roi import RectangleROI
from silx.gui.plot.tools.roi import RegionOfInterestManager
from silx.utils.enum import Enum as _Enum

_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 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

    The new value is sent as an argument.
    """

    def __init__(self, parent=None, value=0.0):
        qt.QWidget.__init__(self, parent=parent)
        self.setLayout(qt.QHBoxLayout())
        self.layout().setContentsMargins(0, 0, 0, 0)
        self._numVal = FloatEdit(parent=self, value=value)

        self._iconAuto = icons.getQIcon("scale-auto")
        self._iconFixed = icons.getQIcon("scale-fixed")

        self._autoToggleAction = qt.QAction(self)
        self._autoToggleAction.setText("Auto scale")
        self._autoToggleAction.setToolTip("Toggle auto scale")
        self._autoToggleAction.setCheckable(True)
        self._autoToggleAction.setIcon(self._iconFixed)
        self._autoToggleAction.setChecked(False)
        self._autoToggleAction.toggled.connect(self._autoToggled)

        self._numVal.addAction(self._autoToggleAction, qt.QLineEdit.LeadingPosition)

        self.layout().addWidget(self._numVal)
        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._numVal.textEdited.connect(self.__textEdited)
        self._numVal.editingFinished.connect(self.__editingFinished)
        self.setFocusProxy(self._numVal)

        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, 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
            with utils.blockSignals(self._numVal):
                # Fix the formatting
                self._numVal.setValue(self.__realValue)
            self.sigValueChanged.emit(value)
            self.__textWasEdited = False

    def isAutoChecked(self):
        return self._autoCB.isChecked()

    def getValue(self):
        """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 _autoToggled(self, enabled):
        self._updateAutoScaleState(enabled)
        self._updateDisplayedText()
        self.sigAutoScaleChanged.emit(enabled)

    def _updateDisplayedText(self):
        self.__textWasEdited = False
        if self._autoCB.isChecked() and self.__realValue is not None:
            with utils.blockSignals(self._numVal):
                self._numVal.setValue(self.__realValue)

    def setValue(self, value, isAuto=False):
        """Set the value of the boundary.

        :param float value: A finite value for the boundary
        :param bool isAuto: If true, the finite value was automatically computed
            from the data, else it is a fixed custom value.
        """
        assert value is not None
        self._autoCB.setChecked(isAuto)
        with utils.blockSignals(self._numVal):
            if isAuto or self.__realValue != value:
                if not self.__textWasEdited:
                    self._numVal.setValue(value)
            self.__realValue = value
            self._updateAutoScaleState(isAuto)

    def _updateAutoScaleState(self, isAutoScale):
        self._numVal.setReadOnly(isAutoScale)
        palette = qt.QPalette()
        if isAutoScale:
            color = palette.color(qt.QPalette.Disabled, qt.QPalette.Base)
            icon = self._iconAuto
        else:
            color = palette.color(qt.QPalette.Active, qt.QPalette.Base)
            icon = self._iconFixed
        palette.setColor(qt.QPalette.Base, color)
        self._numVal.setPalette(palette)
        self._autoToggleAction.setIcon(icon)


class _AutoscaleModeComboBox(qt.QComboBox):
    DATA = {
        Colormap.MINMAX: ("Min/max", "Use the data min/max"),
        Colormap.STDDEV3: ("Mean±3std", "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 _AutoScaleButton(qt.QPushButton):
    autoRangeChanged = qt.Signal(object)

    def __init__(self, parent=None):
        qt.QPushButton.__init__(self, parent=parent)
        self.setText("Autoscale")
        self.setToolTip("Enable/disable the autoscale for both min and max")
        self.setCheckable(True)
        self.toggled[bool].connect(self.__toggled)
        self.setFocusPolicy(qt.Qt.TabFocus)

    def __toggled(self, checked):
        autoRange = checked, checked
        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):
        with utils.blockSignals(self):
            self.setChecked(autoRange[0] if autoRange[0] == autoRange[1] else False)


@enum.unique
class DisplayMode(_Enum):
    """Enum for each mode of display of the data in the plot."""

    RANGE = "range"
    HISTOGRAM = "histogram"


class _ColormapHistogram(qt.QWidget):
    """Display the colormap and the data as a plot."""

    sigRangeMoving = qt.Signal(object, 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
    - gammaPos: A float value if this range was moved, else None
    """

    sigRangeMoved = qt.Signal(object, object, object)
    """Emitted when a mouse interaction stop.

    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
    - gammaPos: A float value if this range was moved, else None
    """

    def __init__(self, parent):
        qt.QWidget.__init__(self, parent=parent)
        self._displayMode = DisplayMode.RANGE
        self._finiteRange = None, None
        self._initPlot()

        self._histogramData = {}
        """Histogram displayed in the plot"""

        self._dragging = False, False, False
        """True, if the min or the max handle is dragging"""

        self._dataRange = {}
        """Histogram displayed in the plot"""

        self._invalidated = False

    def paintEvent(self, event):
        if self._invalidated:
            self._updateDisplayMode()
            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.
        """
        # Do not reset the limit for handle about to be dragged
        if self._dragging[0]:
            vRange = self._finiteRange[0], vRange[1]
        if self._dragging[1]:
            vRange = vRange[0], self._finiteRange[1]

        if vRange == self._finiteRange:
            return

        self._finiteRange = vRange
        self.update()

    def getColormap(self):
        return self.parent().getColormap()

    def _getNormalizedHistogram(self):
        """Return an histogram already normalized according to the colormap
        normalization.

        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.is_valid(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

    def _getNormalizedDataRange(self):
        """Return a data range already normalized according to the colormap
        normalization.

        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

    def _computeNormalizedDataRange(self):
        colormap = self.getColormap()
        if colormap is None:
            norm = Colormap.LINEAR
        else:
            norm = colormap.getNormalization()

        # 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.is_valid(edges)]
            if edges.size == 0:
                return None, None
            else:
                dataRange = min_max(edges, finite=True)
                return dataRange.minimum, dataRange.maximum

        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

        # If there is no item, there is no data
        return None, None

    def _getDisplayableRange(self):
        """Returns the selected min/max range to apply to the data,
        according to the used scale.

        One or both limits can be None in case it is not displayable in the
        current axes scale.

        :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.setAxesDisplayed(False)
        self._plot.setDataMargins(0.125, 0.125, 0.01, 0.01)
        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.Active, 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)
        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)
        # data range mode
        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(DisplayMode.RANGE)
        action.setChecked(action.data() == self._displayMode)
        self._plotToolbar.addAction(action)
        group.addAction(action)
        self._dataRangeAction = action
        # histogram mode
        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(DisplayMode.HISTOGRAM)
        action.setChecked(action.data() == self._displayMode)
        self._plotToolbar.addAction(action)
        group.addAction(action)
        self._dataHistogramAction = action
        group.setExclusive(True)
        group.triggered.connect(self._displayModeChanged)

        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.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._dragging = True, False, False
                self._finiteRange = value, self._finiteRange[1]
                self._last = value, None, None
                self._updateGammaPosition()
                self.sigRangeMoving.emit(*self._last)
            elif event["label"] == "Max":
                self._dragging = False, True, False
                self._finiteRange = self._finiteRange[0], value
                self._last = None, value, None
                self._updateGammaPosition()
                self.sigRangeMoving.emit(*self._last)
            elif event["label"] == "Gamma":
                self._dragging = False, False, True
                self._last = None, None, value
                self.sigRangeMoving.emit(*self._last)
            self._updateLutItem(self._finiteRange)
        elif kind == "markerMoved":
            self.sigRangeMoved.emit(*self._last)
            self._plot.resetZoom()
            self._dragging = False, False, False
        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 and not self._dragging[0]:
                self._plot.addXMarker(
                    posMin,
                    legend="Min",
                    text="Min",
                    draggable=isDraggable,
                    color="blue",
                    constraint=self._plotMinMarkerConstraint,
                )
            self._updateGammaPosition()
            if posMax is not None and not self._dragging[1]:
                self._plot.addXMarker(
                    posMax,
                    legend="Max",
                    text="\n\nMax",
                    draggable=isDraggable,
                    color="blue",
                    constraint=self._plotMaxMarkerConstraint,
                )

        self._updateLutItem((posMin, posMax))
        self._plot.resetZoom()

    def _updateGammaPosition(self):
        colormap = self.getColormap()
        posMin, posMax = self._getDisplayableRange()

        if colormap is None:
            gamma = None
        else:
            if colormap.getNormalization() == Colormap.GAMMA:
                gamma = colormap.getGammaNormalizationParameter()
            else:
                gamma = None

        if gamma is not None:
            if not self._dragging[2]:
                posRange = posMax - posMin
                if posRange > 0:
                    gammaPos = posMin + posRange * 0.5 ** (1 / gamma)
                else:
                    gammaPos = posMin
                marker = self._plot._getMarker(
                    self._plot.addXMarker(
                        gammaPos,
                        legend="Gamma",
                        text="\nGamma",
                        draggable=True,
                        color="blue",
                        constraint=self._plotGammaMarkerConstraint,
                    )
                )
                marker.setZValue(2)
        else:
            try:
                self._plot.removeMarker("Gamma")
            except Exception:
                pass

    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.setEditable(True)
            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 _plotGammaMarkerConstraint(self, x, y):
        """Constraint of the gamma marker"""
        vmin, vmax = self.getFiniteRange()
        if vmin is not None:
            x = max(x, vmin)
        if vmax is not None:
            x = min(x, vmax)
        return x, y

    def setDisplayMode(self, mode: str | DisplayMode):
        mode = DisplayMode.from_value(mode)
        if mode is DisplayMode.HISTOGRAM:
            action = self._dataHistogramAction
        elif mode is DisplayMode.RANGE:
            action = self._dataRangeAction
        else:
            raise ValueError("Mode not supported")
        action.setChecked(True)
        self._displayModeChanged(action)

    def _setDisplayMode(self, mode):
        if self._displayMode == mode:
            return
        self._displayMode = mode
        self._updateDisplayMode()

    def getDsiplayMode(self) -> DisplayMode:
        return self._displayMode

    def _displayModeChanged(self, action):
        mode = action.data()
        self._setDisplayMode(mode)

    def invalidateData(self):
        self._histogramData = {}
        self._dataRange = {}
        self._invalidated = True
        self.update()

    def _updateDisplayMode(self):
        mode = self._displayMode

        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 == DisplayMode.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 == DisplayMode.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)
                with numpy.errstate(invalid="ignore"):
                    norm_histogram = histogram / numpy.nanmax(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._updateDisplayMode()
        self.update()


[docs] 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._colormapped = None """Weak ref to reduce data update""" 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.01, 100.0) 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.0) 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) self._minValue.setMinimumWidth(140) # Max row self._maxValue = _BoundaryWidget(parent=self, value=10.0) self._maxValue.sigAutoScaleChanged.connect(self._maxAutoscaleUpdated) self._maxValue.sigValueChanged.connect(self._maxValueUpdated) self._maxValue.setMinimumWidth(140) self._autoButtons = _AutoScaleButton(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, 1) rangeLayout.addWidget(labelMax, 0, 3) rangeLayout.addWidget(self._minValue, 1, 1) rangeLayout.addWidget(self._maxValue, 1, 3) rangeLayout.setColumnStretch(0, 1) rangeLayout.setColumnStretch(1, 2) rangeLayout.setColumnStretch(2, 1) rangeLayout.setColumnStretch(3, 2) rangeLayout.setColumnStretch(4, 1) self._histoWidget = _ColormapHistogram(self) self._histoWidget.sigRangeMoving.connect(self._histogramRangeMoving) self._histoWidget.sigRangeMoved.connect(self._histogramRangeMoved) self._histoWidget.setSizePolicy( qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding ) # Scale to buttons self._visibleAreaButton = qt.QPushButton(self) self._visibleAreaButton.setEnabled(False) self._visibleAreaButton.setText("Visible Area") self._visibleAreaButton.clicked.connect( self._handleScaleToVisibleAreaClicked, type=qt.Qt.QueuedConnection ) # Place-holder for selected area ROI manager self._roiForColormapManager = None self._selectedAreaButton = qt.QPushButton(self) self._selectedAreaButton.setCheckable(True) self._selectedAreaButton.setEnabled(False) self._selectedAreaButton.setText("Selection") self._selectedAreaButton.setIcon(icons.getQIcon("add-shape-rectangle")) self._selectedAreaButton.setCheckable(True) self._selectedAreaButton.toggled.connect( self._handleScaleToSelectionToggled, type=qt.Qt.QueuedConnection ) # define modal buttons types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel self._buttonsModal = qt.QDialogButtonBox(parent=self) self._buttonsModal.setStandardButtons(types) 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) 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", vmin=None, vmax=None) ) self.setModal(self.isModal()) layout = qt.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self._visibleAreaButton) layout.addWidget(self._selectedAreaButton) layout.addStretch() self._scaleToAreaGroup = qt.QWidget(self) self._scaleToAreaGroup.setLayout(layout) self._scaleToAreaGroup.setVisible(False) layoutScale = qt.QHBoxLayout() layoutScale.setContentsMargins(0, 0, 0, 0) layoutScale.addWidget(self._autoButtons) layoutScale.addWidget(self._autoScaleCombo) layoutScale.addStretch() formLayout = FormGridLayout(self) formLayout.setContentsMargins(10, 10, 10, 10) formLayout.addRow("Colormap:", self._comboBoxColormap) formLayout.addRow("Normalization:", self._comboBoxNormalization) formLayout.addRow("Gamma:", self._gammaSpinBox) formLayout.addItem( qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) ) formLayout.addRow(self._histoWidget) formLayout.setRowStretch(formLayout.rowCount() - 1, 1) formLayout.addRow(rangeLayout) formLayout.addItem( qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) ) formLayout.addRow("Scale:", layoutScale) formLayout.addRow("Fixed scale on:", self._scaleToAreaGroup) formLayout.addRow(self._buttonsModal) formLayout.addRow(self._buttonsNonModal) formLayout.setSizeConstraint(qt.QLayout.SetMinAndMaxSize) 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._visibleAreaButton) self.setTabOrder(self._visibleAreaButton, self._selectedAreaButton) self.setTabOrder(self._selectedAreaButton, self._buttonsModal) self.setTabOrder(self._buttonsModal, self._buttonsNonModal) self._applyColormap() def getHistogramWidget(self): return self._histoWidget 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
[docs] def showEvent(self, event): self.visibleChanged.emit(True) super(ColormapDialog, self).showEvent(event) if self.isVisible(): self._validate()
[docs] def closeEvent(self, event): if not self.isModal(): self.accept() super(ColormapDialog, self).closeEvent(event)
[docs] def hideEvent(self, event): if self._selectedAreaButton.isChecked(): self._selectedAreaButton.setChecked(False) self.visibleChanged.emit(False) super(ColormapDialog, self).hideEvent(event)
[docs] def close(self): if self._selectedAreaButton.isChecked(): self._selectedAreaButton.setChecked(False) self.accept() qt.QDialog.close(self)
[docs] def setModal(self, modal): assert type(modal) is bool self._buttonsNonModal.setVisible(not modal) self._buttonsModal.setVisible(modal) qt.QDialog.setModal(self, modal)
[docs] def event(self, event): if event.type() == qt.QEvent.DeferredDelete: self.__aboutToDelete = True return super(ColormapDialog, self).event(event)
[docs] def exec(self): wasModal = self.isModal() self.setModal(True) result = super(ColormapDialog, self).exec() if not self.__aboutToDelete: self.setModal(wasModal) return result
[docs] def exec_(self): # Qt5 compatibility wrapper return self.exec()
def _getFiniteColormapRange(self): """Return a colormap range where auto ranges are fixed according to the available data. """ colormap = self.getColormap() if colormap is None: return 1, 10 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)
[docs] @staticmethod def computeDataRange(data): """Compute the data range as used by :meth:`setDataRange`. :param data: The data to process :rtype: List[Union[None,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: dataRange = dataRange.minimum, dataRange.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.0, 1.0, 10.0 return dataRange
[docs] @staticmethod 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) """ # 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 # 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 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 xmin is None: return None, None 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 = int(xmax - xmin) nbins = max(2, nbins) data = data.ravel().astype(numpy.float32) histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range) bins = histogram.edges[0] 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()
[docs] 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` """ old = self._getItem() if old is item: # While event from items are not supported, we can't ignore dup items if item is not None: array = item.getColormappedData(copy=False) else: array = None colormapped = self._colormapped if colormapped is not None: oldArray = colormapped() else: oldArray = None if oldArray is array: return self.__resetItem() 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) item.sigItemChanged.connect(self.__itemChanged) self._item = weakref.ref(item, self._itemAboutToFinalize) finally: self._syncScaleToButtonsEnabled() self._dataRange = None self._histogramData = None self._invalidateData()
def __resetItem(self): """Reset item and data used by the dialog""" self._data = None self._itemHolder = None if self._item is not None: item = self._item() self._item = None if item is not None: item.sigItemChanged.disconnect(self.__itemChanged) def __itemChanged(self, event): if event == items.ItemChangedType.DATA: self._invalidateData() def _getData(self): if self._data is None: return None return self._data()
[docs] def setData(self, data): """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` and :meth:`setHistogram` """ oldData = self._getData() if oldData is data: return self.__resetItem() self._syncScaleToButtonsEnabled() if data is not None: self._data = weakref.ref(data, self._dataAboutToFinalize) self._itemHolder = _DataRefHolder(self._data) self._dataRange = None self._histogramData = None self._invalidateData()
def _getArray(self): data = self._getData() if data is not None: return data item = self._getItem() if item is not None: colormapped = item.getColormappedData(copy=False) if colormapped is not None: self._colormapped = weakref.ref(colormapped) else: self._colormapped = None return colormapped return None def _colormapAboutToFinalize(self, weakrefColormap): """Callback when the data weakref is about to be finalized.""" 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 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) 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""" return self._histogramData
[docs] def setHistogram(self, hist=None, bin_edges=None): """Set the histogram to display. This update the data range with the bounds of the bins. :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 else: self._histogramData = numpy.array(hist), numpy.array(bin_edges) self._invalidateData()
[docs] def getColormap(self): """Return the colormap description. :rtype: ~silx.gui.colors.Colormap """ if self._colormap is None: return None return self._colormap()
[docs] def resetColormap(self): """ Reset the colormap state before modification. ..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 colormap != self._colormapStoredState: 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
[docs] 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 minimum: The minimum of the data :param float positiveMin: The positive minimum of the data :param float maximum: The maximum of the data """ self._dataRange = minimum, positiveMin, maximum self._invalidateData()
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()
[docs] def setColormapRangeFromDataBounds(self, bounds): """Set the range of the colormap from current item and rect. If there is no ColormapMixIn item attached to the ColormapDialog, nothing is done. :param Union[List[float],None] bounds: (xmin, xmax, ymin, ymax) Rectangular region in data space """ if bounds is None: return # no-op colormap = self.getColormap() if colormap is None: return # no-op item = self._getItem() if not isinstance(item, items.ColormapMixIn): return # no-op data = item.getColormappedData(copy=False) xmin, xmax, ymin, ymax = bounds if isinstance(item, items.ImageBase): if data.ndim != 2: return # no-op ox, oy = item.getOrigin() sx, sy = item.getScale() ystart = max(0, int((ymin - oy) / sy)) ystop = max(0, int(numpy.ceil((ymax - oy) / sy))) xstart = max(0, int((xmin - ox) / sx)) xstop = max(0, int(numpy.ceil((xmax - ox) / sx))) subset = data[ystart:ystop, xstart:xstop] elif isinstance(item, items.Scatter): x = item.getXData(copy=False) y = item.getYData(copy=False) subset = data[ numpy.logical_and( numpy.logical_and(xmin <= x, x <= xmax), numpy.logical_and(ymin <= y, y <= ymax), ) ] if subset.size == 0: return # no-op vmin, vmax = colormap._computeAutoscaleRange(subset) self._setColormapRange(vmin, vmax)
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: 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))
[docs] def accept(self): self.storeCurrentState() qt.QDialog.accept(self)
[docs] def storeCurrentState(self): """ save the current value sof the colormap if the user want to undo is modifications """ colormap = self.getColormap() if colormap is not None: self._colormapStoredState = colormap.copy() else: self._colormapStoredState = None
[docs] def reject(self): self.resetColormap() qt.QDialog.reject(self)
[docs] def setColormap(self, colormap): """Set the colormap description :param ~silx.gui.colors.Colormap colormap: the colormap to edit """ assert colormap is None or isinstance(colormap, Colormap) if self._colormapChange.locked(): 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._invalidateColormap()
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 != self._colormapStoredState resetButton.setEnabled(rStateEnabled) def _applyColormap(self): self._updateResetButton() if self._colormapChange.locked(): return self._syncScaleToButtonsEnabled() colormap = self.getColormap() if colormap is None: self._comboBoxColormap.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._histoWidget.setVisible(False) self._histoWidget.setFiniteRange((None, None)) else: assert colormap.getNormalization() in Colormap.NORMALIZATIONS 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() == Colormap.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()) 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() # Final colormap range vmin = dataRange[0] if not autoRange[0] else None vmax = dataRange[1] if not autoRange[1] else None with self._colormapChange: colormap = self.getColormap() colormap.setVRange(vmin, vmax) 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) self._updateWidgetRange() def _normalizationUpdated(self, index): """Callback executed when the normalization widget is updated by user input. """ colormap = self.getColormap() if colormap is not None: normalization = self._comboBoxNormalization.itemData(index) self._gammaSpinBox.setEnabled(normalization == "gamma") with self._colormapChange: colormap.setNormalization(normalization) self._histoWidget.updateNormalization() self._updateWidgetRange() def _gammaUpdated(self, value): """Callback used to update the gamma normalization parameter""" colormap = self.getColormap() if colormap is not None: colormap.setGammaNormalizationParameter(value) 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: with self._colormapChange: colormap.setAutoscaleMode(mode) self._updateWidgetRange() 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) 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, gammaPos): """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 :param gammaPos: Update of the gamma location, else None """ colormap = self.getColormap() if vmin is not None: with self._colormapChange: colormap.setVMin(vmin) self._minValue.setValue(vmin) if vmax is not None: with self._colormapChange: colormap.setVMax(vmax) self._maxValue.setValue(vmax) if gammaPos is not None: vmin, vmax = self._histoWidget.getFiniteRange() if vmax < vmin: gamma = 1 elif gammaPos >= vmax: gamma = self._gammaSpinBox.maximum() elif gammaPos <= vmin: gamma = self._gammaSpinBox.minimum() else: gamma = numpy.clip( numpy.log(0.5) / numpy.log((gammaPos - vmin) / (vmax - vmin)), self._gammaSpinBox.minimum(), self._gammaSpinBox.maximum(), ) with self._colormapChange: colormap.setGammaNormalizationParameter(gamma) with utils.blockSignals(self._gammaSpinBox): self._gammaSpinBox.setValue(gamma) def _histogramRangeMoved(self, vmin, vmax, gammaPos): """Callback executed when for colormap range displayed in the histogram widget has finished to move """ if vmin is None and vmax is None: return xmin = self._minValue.getValue() xmax = self._maxValue.getValue() if vmin is None: vmin = xmin if vmax is None: vmax = xmax self._setColormapRange(vmin, vmax) def _syncScaleToButtonsEnabled(self): """Set the state of scale to buttons according to current item and colormap""" colormap = self.getColormap() enabled = ( self._item is not None and colormap is not None and colormap.isEditable() ) self._scaleToAreaGroup.setVisible(enabled) self._visibleAreaButton.setEnabled(enabled) if not enabled: self._selectedAreaButton.setChecked(False) self._selectedAreaButton.setEnabled(enabled) def _handleScaleToVisibleAreaClicked(self): """Set colormap range from current item's visible area""" item = self._getItem() if item is None: return # no-op bounds = item.getVisibleBounds() if bounds is None: return # no-op self.setColormapRangeFromDataBounds(bounds) def _handleScaleToSelectionToggled(self, checked=False): """Handle toggle of scale to selected are button""" # Reset any previous ROI manager if self._roiForColormapManager is not None: self._roiForColormapManager.clear() self._roiForColormapManager.stop() self._roiForColormapManager = None if not checked: # Reset button status self._selectedAreaButton.setText("Selection") return item = self._getItem() if item is None: self._selectedAreaButton.setChecked(False) return # no-op plotWidget = item.getPlot() if plotWidget is None: self._selectedAreaButton.setChecked(False) return # no-op self._selectedAreaButton.setText("Draw Area...") self._roiForColormapManager = RegionOfInterestManager(parent=plotWidget) cmap = self.getColormap() self._roiForColormapManager.setColor( "black" if cmap is None else cursorColorForColormap(cmap.getName()) ) self._roiForColormapManager.sigInteractiveModeFinished.connect( self.__roiInteractiveModeFinished ) self._roiForColormapManager.sigInteractiveRoiFinalized.connect( self.__roiFinalized ) self._roiForColormapManager.start(RectangleROI) def __roiInteractiveModeFinished(self): self._selectedAreaButton.setChecked(False) def __roiFinalized(self, roi): if roi is not None: ox, oy = roi.getOrigin() width, height = roi.getSize() self.setColormapRangeFromDataBounds((ox, ox + width, oy, oy + height)) # clear ROI self._roiForColormapManager.removeRoi(roi)
[docs] 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() in (qt.Qt.Key_Enter, qt.Qt.Key_Return): # Bypass QDialog keyPressEvent # To avoid leaving the dialog when pressing enter on a text field 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: super(ColormapDialog, self).keyPressEvent(event)