Skip to main content
`Ctrl`+`K`

# Source code for silx.gui.colors

    # /*##########################################################################
    #
    # Copyright (c) 2015-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.
    #
    # ###########################################################################*/
    """This module provides API to manage colors.
    """

    from __future__ import annotations

    __authors__ = ["T. Vincent", "H.Payno"]
    __license__ = "MIT"
    __date__ = "29/01/2019"

    import numpy
    import logging
    import numbers
    import re
    from collections.abc import Iterable
    from typing import Any, Sequence, Tuple, Union

    import silx
    from silx._utils import NP_OPTIONAL_COPY
    from silx.gui import qt
    from silx.gui.utils import blockSignals
    from silx.math import colormap as _colormap
    from silx.utils.exceptions import NotEditableError

    _logger = logging.getLogger(__name__)

    try:
        import silx.gui.utils.matplotlib  # noqa  Initalize matplotlib

        try:
            from matplotlib import colormaps as _matplotlib_colormaps
        except ImportError:  # For matplotlib < 3.5
            from matplotlib import cm as _matplotlib_cm
            from matplotlib.pyplot import colormaps as _matplotlib_colormaps
        else:
            _matplotlib_cm = None
    except ImportError:
        _logger.info("matplotlib not available, only embedded colormaps available")
        _matplotlib_cm = None
        _matplotlib_colormaps = None

    _COLORDICT = {}
    """Dictionary of common colors."""

    _COLORDICT["b"] = _COLORDICT["blue"] = "#0000ff"
    _COLORDICT["r"] = _COLORDICT["red"] = "#ff0000"
    _COLORDICT["g"] = _COLORDICT["green"] = "#00ff00"
    _COLORDICT["k"] = _COLORDICT["black"] = "#000000"
    _COLORDICT["w"] = _COLORDICT["white"] = "#ffffff"
    _COLORDICT["pink"] = "#ff66ff"
    _COLORDICT["brown"] = "#a52a2a"
    _COLORDICT["orange"] = "#ff9900"
    _COLORDICT["violet"] = "#6600ff"
    _COLORDICT["gray"] = _COLORDICT["grey"] = "#a0a0a4"
    # _COLORDICT['darkGray'] = _COLORDICT['darkGrey'] = '#808080'
    # _COLORDICT['lightGray'] = _COLORDICT['lightGrey'] = '#c0c0c0'
    _COLORDICT["y"] = _COLORDICT["yellow"] = "#ffff00"
    _COLORDICT["m"] = _COLORDICT["magenta"] = "#ff00ff"
    _COLORDICT["c"] = _COLORDICT["cyan"] = "#00ffff"
    _COLORDICT["darkBlue"] = "#000080"
    _COLORDICT["darkRed"] = "#800000"
    _COLORDICT["darkGreen"] = "#008000"
    _COLORDICT["darkBrown"] = "#660000"
    _COLORDICT["darkCyan"] = "#008080"
    _COLORDICT["darkYellow"] = "#808000"
    _COLORDICT["darkMagenta"] = "#800080"
    _COLORDICT["transparent"] = "#00000000"

    # FIXME: It could be nice to expose a functional API instead of that attribute
    COLORDICT = _COLORDICT

    DEFAULT_MIN_LIN = 0
    """Default min value if in linear normalization"""
    DEFAULT_MAX_LIN = 1
    """Default max value if in linear normalization"""

    _INDEXED_COLOR_PATTERN = re.compile(r"C(?P<index>[0-9]+)")

    ColorType = Union[str, Sequence[numbers.Real], qt.QColor]
    """Type of :func:`rgba`'s color argument"""

    RGBAColorType = Tuple[float, float, float, float]
    """Type of :func:`rgba` return value"""

    def rgba(
        color: ColorType,
        colorDict: dict[str, str] | None = None,
        colors: Sequence[str] | None = None,
    ) -> RGBAColorType:
        """Convert different kind of color definition to a tuple (R, G, B, A) of floats.

        It supports:
        - color names: e.g., 'green'
        - color codes: '#RRGGBB' and '#RRGGBBAA'
        - indexed color names: e.g., 'C0'
        - RGB(A) sequence of uint8 in [0, 255] or float in [0, 1]
        - QColor

        :param color: The color to convert
        :param colorDict: A dictionary of color name conversion to color code
        :param colors: Sequence of colors to use for `
        :returns: RGBA colors as floats in [0., 1.]
        :raises ValueError: if the input is not a valid color
        """
        if isinstance(color, str):
            # From name
            colorFromDict = (_COLORDICT if colorDict is None else colorDict).get(color)
            if colorFromDict is not None:
                return rgba(colorFromDict, colorDict, colors)

            # From indexed color name: color{index}
            match = _INDEXED_COLOR_PATTERN.fullmatch(color)
            if match is not None:
                if colors is None:
                    colors = silx.config.DEFAULT_PLOT_CURVE_COLORS
                index = int(match["index"]) % len(colors)
                return rgba(colors[index], colorDict, colors)

            # From #code
            if len(color) in (7, 9) and color[0] == "#":
                r = int(color[1:3], 16) / 255.0
                g = int(color[3:5], 16) / 255.0
                b = int(color[5:7], 16) / 255.0
                a = int(color[7:9], 16) / 255.0 if len(color) == 9 else 1.0
                return r, g, b, a

            raise ValueError(f"The string '{color}' is not a valid color")

        # From QColor
        if isinstance(color, qt.QColor):
            return rgba(color.getRgb(), colorDict, colors)

        # From array
        values = numpy.asarray(color).ravel()

        if values.dtype.kind not in "iuf":
            raise ValueError(
                f"The array color must be integer/unsigned or float. Found '{values.dtype.kind}'"
            )
        if len(values) not in (3, 4):
            raise ValueError(
                f"The array color must have 3 or 4 compound. Found '{len(values)}'"
            )

        # Convert from integers in [0, 255] to float in [0, 1]
        if values.dtype.kind in "iu":
            values = values / 255.0

        values = numpy.clip(values, 0.0, 1.0)

        if len(values) == 3:
            return values[0], values[1], values[2], 1.0
        return tuple(values)

    def greyed(
        color: ColorType,
        colorDict: dict[str, str] | None = None,
    ) -> RGBAColorType:
        """Convert color code '#RRGGBB' and '#RRGGBBAA' to a grey color
        (R, G, B, A).

        It also supports RGB(A) from uint8 in [0, 255], float in [0, 1], and
        QColor as color argument.

        :param color: The color to convert
        :param colorDict: A dictionary of color name conversion to color code
        :returns: RGBA colors as floats in [0., 1.]
        """
        r, g, b, a = rgba(color=color, colorDict=colorDict)
        g = 0.21 * r + 0.72 * g + 0.07 * b
        return g, g, g, a

    def asQColor(color: ColorType) -> qt.QColor:
        """Convert color code '#RRGGBB' and '#RRGGBBAA' to a `qt.QColor`.

        It also supports RGB(A) from uint8 in [0, 255], float in [0, 1], and
        QColor as color argument.

        :param color: The color to convert
        """
        color = rgba(color)
        return qt.QColor.fromRgbF(*color)

    def cursorColorForColormap(colormapName: str) -> str:
        """Get a color suitable for overlay over a colormap.

        :param colormapName: The name of the colormap.
        :return: Name of the color.
        """
        return _colormap.get_colormap_cursor_color(colormapName)

    # Colormap loader

    def _registerColormapFromMatplotlib(
        name: str,
        cursor_color: str = "black",
        preferred: bool = False,
    ):
        if _matplotlib_cm is not None:
            colormap = _matplotlib_cm.get_cmap(name)
        else:  # matplotlib >= 3.5
            colormap = _matplotlib_colormaps[name]
        lut = colormap(numpy.linspace(0, 1, colormap.N, endpoint=True))
        colors = _colormap.array_to_rgba8888(lut)
        registerLUT(name, colors, cursor_color, preferred)

    def _getColormap(name: str) -> numpy.ndarray:
        """Returns the color LUT corresponding to a colormap name
        :param name: Name of the colormap to load
        :returns: Corresponding table of colors
        :raise ValueError: If no colormap corresponds to name
        """
        name = str(name)
        try:
            return _colormap.get_colormap_lut(name)
        except ValueError:
            # Colormap is not available, try to load it from matplotlib
            _registerColormapFromMatplotlib(name, "black", False)
        return _colormap.get_colormap_lut(name)

    class _Colormappable:
        """Class for objects that can be colormapped by a :class:`Colormap`

        Used by silx.gui.plot.items.core.ColormapMixIn
        """

        def _getColormapAutoscaleRange(
            self,
            colormap: Colormap | None,
        ) -> tuple[float | None, float | None]:
            """Returns the autoscale range for given colormap.

            :param colormap:
               The colormap for which to compute the autoscale range.
               If None, the default, the colormap of the item is used
            :return: (vmin, vmax) range
            """
            raise NotImplementedError("This method must be implemented in subclass")

        def getColormappedData(copy: bool = False) -> numpy.ndarray | None:
            """Returns the data used to compute the displayed colors

            :param copy: True to get a copy, False to get internal data (do not modify!).
            """
            raise NotImplementedError("This method must be implemented in subclass")

    class Colormap(qt.QObject):
        """Description of a colormap

        If no `name` nor `colors` are provided, a default gray LUT is used.

        :param name: Name of the colormap
        :param colors: optional, custom colormap.
                Nx3 or Nx4 numpy array of RGB(A) colors,
                either uint8 or float in [0, 1].
                If 'name' is None, then this array is used as the colormap.
        :param normalization: Normalization: 'linear' (default) or 'log'
        :param vmin: Lower bound of the colormap or None for autoscale (default)
        :param vmax: Upper bounds of the colormap or None for autoscale (default)
        """

        LINEAR = "linear"
        """constant for linear normalization"""

        LOGARITHM = "log"
        """constant for logarithmic normalization"""

        SQRT = "sqrt"
        """constant for square root normalization"""

        GAMMA = "gamma"
        """Constant for gamma correction normalization"""

        ARCSINH = "arcsinh"
        """constant for inverse hyperbolic sine normalization"""

        _BASIC_NORMALIZATIONS = {
            LINEAR: _colormap.LinearNormalization(),
            LOGARITHM: _colormap.LogarithmicNormalization(),
            SQRT: _colormap.SqrtNormalization(),
            ARCSINH: _colormap.ArcsinhNormalization(),
        }
        """Normalizations without parameters"""

        NORMALIZATIONS = LINEAR, LOGARITHM, SQRT, GAMMA, ARCSINH
        """Tuple of managed normalizations"""

        MINMAX = "minmax"
        """constant for autoscale using min/max data range"""

        STDDEV3 = "stddev3"
        """constant for autoscale using mean +/- 3*std(data)
        with a clamp on min/max of the data"""

        PERCENTILE_1_99 = "percentile_1_99"
        """constant for autoscale using 1st and 99th percentile of data"""

        AUTOSCALE_MODES = (MINMAX, STDDEV3, PERCENTILE_1_99)
        """Tuple of managed auto scale algorithms"""

        sigChanged = qt.Signal()
        """Signal emitted when the colormap has changed."""

        _DEFAULT_NAN_COLOR = 255, 255, 255, 0

        def __init__(
            self,
            name: str | None = None,
            colors: numpy.ndarray | None = None,
            normalization: str = LINEAR,
            vmin: float | None = None,
            vmax: float | None = None,
            autoscaleMode: str = MINMAX,
        ):
            qt.QObject.__init__(self)
            self._editable = True
            self.__gamma = 2.0
            # Default NaN color: fully transparent white
            self.__nanColor = numpy.array(self._DEFAULT_NAN_COLOR, dtype=numpy.uint8)

            assert normalization in Colormap.NORMALIZATIONS
            assert autoscaleMode in Colormap.AUTOSCALE_MODES

            if normalization is Colormap.LOGARITHM:
                if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0):
                    m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale."
                    m += " Autoscale will be performed."
                    m = m % (vmin, vmax)
                    _logger.warning(m)
                    vmin = None
                    vmax = None

            self._name = None
            self._colors = None

            if colors is not None and name is not None:
                raise ValueError("name and colors arguments can't be set at the same time")

            if name is not None:
                self.setName(name)  # And resets colormap LUT
            elif colors is not None:
                self.setColormapLUT(colors)
            else:
                # Default colormap is grey
                self.setName("gray")

            self._normalization = str(normalization)
            self._autoscaleMode = str(autoscaleMode)
            self._vmin = float(vmin) if vmin is not None else None
            self._vmax = float(vmax) if vmax is not None else None
            self.__warnBadVmin = True
            self.__warnBadVmax = True

        def setFromColormap(self, other: Colormap):
            """Set this colormap using information from the `other` colormap.

            :param other: Colormap to use as reference.
            """
            if not self.isEditable():
                raise NotEditableError("Colormap is not editable")
            if self == other:
                return
            with blockSignals(self):
                name = other.getName()
                if name is not None:
                    self.setName(name)
                else:
                    self.setColormapLUT(other.getColormapLUT())
                self.setNaNColor(other.getNaNColor())
                self.setNormalization(other.getNormalization())
                self.setGammaNormalizationParameter(other.getGammaNormalizationParameter())
                self.setAutoscaleMode(other.getAutoscaleMode())
                self.setVRange(*other.getVRange())
                self.setEditable(other.isEditable())
            self.sigChanged.emit()

        def getNColors(self, nbColors: int | None = None) -> numpy.ndarray:
            """Returns N colors computed by sampling the colormap regularly.

            :param nbColors:
                The number of colors in the returned array or None for the default value.
                The default value is the size of the colormap LUT.
            :return: 2D array of uint8 of shape (nbColors, 4)
            """
            # Handle default value for nbColors
            if nbColors is None:
                return numpy.array(self._colors, copy=True)
            else:
                nbColors = int(nbColors)
                colormap = self.copy()
                colormap.setNormalization(Colormap.LINEAR)
                colormap.setVRange(vmin=0, vmax=nbColors - 1)
                colors = colormap.applyToData(numpy.arange(nbColors, dtype=numpy.int32))
                return colors

        def getName(self) -> str | None:
            """Return the name of the colormap"""
            return self._name

        def setName(self, name: str):
            """Set the name of the colormap to use.

            :param name: The name of the colormap.
                At least the following names are supported: 'gray',
                'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet',
                'viridis', 'magma', 'inferno', 'plasma'.
            """
            name = str(name)
            if self._name == name:
                return
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            if name not in self.getSupportedColormaps():
                raise ValueError("Colormap name '%s' is not supported" % name)
            self._name = name
            self._colors = _getColormap(self._name)
            self.sigChanged.emit()

        def getColormapLUT(self, copy: bool = True) -> numpy.ndarray | None:
            """Return the list of colors for the colormap or None if not set.

            This returns None if the colormap was set with :meth:`setName`.
            Use :meth:`getNColors` to get the colormap LUT for any colormap.

            :param copy: If true a copy of the numpy array is provided
            :return: the list of colors for the colormap or None if not set
            """
            if self._name is None:
                return numpy.array(self._colors, copy=copy or NP_OPTIONAL_COPY)
            return None

        def setColormapLUT(self, colors: numpy.ndarray):
            """Set the colors of the colormap.

            :param colors: the colors of the LUT.
               If float, it is converted from [0, 1] to uint8 range.
               Otherwise it is casted to uint8.

            .. warning: this will set the value of name to None
            """
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            assert colors is not None

            colors = numpy.asarray(colors)
            if colors.shape == ():
                raise TypeError(
                    "An array is expected for 'colors' argument. '%s' was found."
                    % type(colors)
                )
            assert len(colors) != 0
            assert colors.ndim >= 2
            colors.shape = -1, colors.shape[-1]
            self._colors = _colormap.array_to_rgba8888(colors)
            self._name = None
            self.sigChanged.emit()

        def getNaNColor(self) -> qt.QColor:
            """Returns the color to use for Not-A-Number floating point value."""
            return qt.QColor(*self.__nanColor)

        def setNaNColor(self, color: ColorType):
            """Set the color to use for Not-A-Number floating point value.

            :param color: RGB(A) color to use for NaN values
            """
            color = (numpy.array(rgba(color)) * 255).astype(numpy.uint8)
            if not numpy.array_equal(self.__nanColor, color):
                self.__nanColor = color
                self.sigChanged.emit()

        def getNormalization(self) -> str:
            """Return the normalization of the colormap.

            See :meth:`setNormalization` for returned values.

            :return: the normalization of the colormap
            """
            return self._normalization

        def setNormalization(self, norm: str):
            """Set the colormap normalization.

            Accepted normalizations: 'log', 'linear', 'sqrt'

            :param norm: the norm to set
            """
            assert norm in self.NORMALIZATIONS
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            norm = str(norm)
            if norm != self._normalization:
                self._normalization = norm
                self.__warnBadVmin = True
                self.__warnBadVmax = True
                self.sigChanged.emit()

        def setGammaNormalizationParameter(self, gamma: float):
            """Set the gamma correction parameter.

            Only used for gamma correction normalization.

            :raise ValueError: If gamma is not valid
            """
            if gamma < 0.0 or not numpy.isfinite(gamma):
                raise ValueError("Gamma value not supported")
            if gamma != self.__gamma:
                self.__gamma = gamma
                self.sigChanged.emit()

        def getGammaNormalizationParameter(self) -> float:
            """Returns the gamma correction parameter value."""
            return self.__gamma

        def getAutoscaleMode(self) -> str:
            """Return the autoscale mode of the colormap ('minmax' or 'stddev3')"""
            return self._autoscaleMode

        def setAutoscaleMode(self, mode: str):
            """Set the autoscale mode: either 'minmax' or 'stddev3'

            :param mode: the mode to set
            """
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            assert mode in self.AUTOSCALE_MODES
            if mode != self._autoscaleMode:
                self._autoscaleMode = mode
                self.sigChanged.emit()

        def isAutoscale(self) -> bool:
            """Return True if both min and max are in autoscale mode"""
            return self._vmin is None and self._vmax is None

        def getVMin(self) -> float | None:
            """Return the lower bound of the colormap

            :return: the lower bound of the colormap
            """
            return self._vmin

        def setVMin(self, vmin: float | None):
            """Set the minimal value of the colormap

            :param vmin: Lower bound of the colormap or None for autoscale (initial value)
            """
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            if vmin is not None:
                if self._vmax is not None and vmin > self._vmax:
                    err = "Can't set vmin because vmin >= vmax. " "vmin = %s, vmax = %s" % (
                        vmin,
                        self._vmax,
                    )
                    raise ValueError(err)

            if vmin != self._vmin:
                self._vmin = vmin
                self.__warnBadVmin = True
                self.sigChanged.emit()

        def getVMax(self) -> float | None:
            """Return the upper bounds of the colormap or None

            :return: the upper bounds of the colormap or None
            """
            return self._vmax

        def setVMax(self, vmax: float | None):
            """Set the maximal value of the colormap

            :param vmax: Upper bounds of the colormap or None for autoscale (initial value)
            """
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            if vmax is not None:
                if self._vmin is not None and vmax < self._vmin:
                    err = "Can't set vmax because vmax <= vmin. " "vmin = %s, vmax = %s" % (
                        self._vmin,
                        vmax,
                    )
                    raise ValueError(err)

            if vmax != self._vmax:
                self._vmax = vmax
                self.__warnBadVmax = True
                self.sigChanged.emit()

        def isEditable(self) -> bool:
            """Return if the colormap is editable or not

            :return: editable state of the colormap
            """
            return self._editable

        def setEditable(self, editable: bool):
            """
            Set the editable state of the colormap

            :param editable: is the colormap editable
            """
            assert type(editable) is bool
            self._editable = editable
            self.sigChanged.emit()

        def _getNormalizer(self):  # TODO
            """Returns normalizer object"""
            normalization = self.getNormalization()
            if normalization == self.GAMMA:
                return _colormap.GammaNormalization(self.getGammaNormalizationParameter())
            else:
                return self._BASIC_NORMALIZATIONS[normalization]

        def _computeAutoscaleRange(self, data: numpy.ndarray):
            """Compute the data range which will be used in autoscale mode.

            :param data: The data for which to compute the range
            :return: (vmin, vmax) range
            """
            return self._getNormalizer().autoscale(data, mode=self.getAutoscaleMode())

        def getColormapRange(
            self,
            data: numpy.ndarray | _Colormappable | None = None,
        ) -> tuple[float, float]:
            """Return (vmin, vmax) the range of the colormap for the given data or item.

            :param data: The data or item to use for autoscale bounds.
            :return: (vmin, vmax) corresponding to the colormap applied to data if provided.
            """
            vmin = self._vmin
            vmax = self._vmax
            assert (
                vmin is None or vmax is None or vmin <= vmax
            )  # TODO handle this in setters

            normalizer = self._getNormalizer()

            # Handle invalid bounds as autoscale
            if vmin is not None and not normalizer.is_valid(vmin):
                if self.__warnBadVmin:
                    self.__warnBadVmin = False
                    _logger.info("Invalid vmin, switching to autoscale for lower bound")
                vmin = None
            if vmax is not None and not normalizer.is_valid(vmax):
                if self.__warnBadVmax:
                    self.__warnBadVmax = False
                    _logger.info("Invalid vmax, switching to autoscale for upper bound")
                vmax = None

            if vmin is None or vmax is None:  # Handle autoscale
                if isinstance(data, _Colormappable):
                    min_, max_ = data._getColormapAutoscaleRange(self)
                    # Make sure min_, max_ are not None
                    min_ = normalizer.DEFAULT_RANGE[0] if min_ is None else min_
                    max_ = normalizer.DEFAULT_RANGE[1] if max_ is None else max_
                else:
                    min_, max_ = normalizer.autoscale(data, mode=self.getAutoscaleMode())

                if vmin is None:  # Set vmin respecting provided vmax
                    vmin = min_ if vmax is None else min(min_, vmax)

                if vmax is None:
                    vmax = max(max_, vmin)  # Handle max_ <= 0 for log scale

            return vmin, vmax

        def getVRange(self) -> tuple[float | None, float | None]:
            """Get the bounds of the colormap

            :returns: A tuple of 2 values for min and max. Or None instead of float
                for autoscale
            """
            return self.getVMin(), self.getVMax()

        def setVRange(self, vmin: float | None, vmax: float | None):
            """Set the bounds of the colormap

            :param vmin: Lower bound of the colormap or None for autoscale
                (default)
            :param vmax: Upper bounds of the colormap or None for autoscale
                (default)
            """
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")

            if (vmin is not None and not numpy.isfinite(vmin)) or (
                vmax is not None and not numpy.isfinite(vmax)
            ):
                err = (
                    "Can't set vmin and vmax because vmin or vmax are not finite "
                    "vmin = %s, vmax = %s" % (vmin, vmax)
                )
                raise ValueError(err)

            if vmin is not None and vmax is not None:
                if vmin > vmax:
                    err = (
                        "Can't set vmin and vmax because vmin >= vmax "
                        "vmin = %s, vmax = %s" % (vmin, vmax)
                    )
                    raise ValueError(err)

            if self._vmin == vmin and self._vmax == vmax:
                return

            if vmin != self._vmin:
                self.__warnBadVmin = True
            self._vmin = vmin
            if vmax != self._vmax:
                self.__warnBadVmax = True
            self._vmax = vmax
            self.sigChanged.emit()

        def __getitem__(self, item: str):
            if item == "autoscale":
                return self.isAutoscale()
            elif item == "name":
                return self.getName()
            elif item == "normalization":
                return self.getNormalization()
            elif item == "vmin":
                return self.getVMin()
            elif item == "vmax":
                return self.getVMax()
            elif item == "colors":
                return self.getColormapLUT()
            elif item == "autoscaleMode":
                return self.getAutoscaleMode()
            else:
                raise KeyError(item)

        def _toDict(self) -> dict:
            """Return the equivalent colormap as a dictionary
            (old colormap representation)

            :return: the representation of the Colormap as a dictionary
            """
            return {
                "name": self._name,
                "colors": self.getColormapLUT(),
                "vmin": self._vmin,
                "vmax": self._vmax,
                "autoscale": self.isAutoscale(),
                "normalization": self.getNormalization(),
                "autoscaleMode": self.getAutoscaleMode(),
            }

        def _setFromDict(self, dic: dict):
            """Set values to the colormap from a dictionary

            :param dic: the colormap as a dictionary
            """
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            name = dic["name"] if "name" in dic else None
            colors = dic["colors"] if "colors" in dic else None
            if name is not None and colors is not None:
                if isinstance(colors, int):
                    # Filter out argument which was supported but never used
                    _logger.info("Unused 'colors' from colormap dictionary filterer.")
                    colors = None
            vmin = dic["vmin"] if "vmin" in dic else None
            vmax = dic["vmax"] if "vmax" in dic else None
            if "normalization" in dic:
                normalization = dic["normalization"]
            else:
                warn = "Normalization not given in the dictionary, "
                warn += "set by default to " + Colormap.LINEAR
                _logger.warning(warn)
                normalization = Colormap.LINEAR

            if name is None and colors is None:
                err = "The colormap should have a name defined or a tuple of colors"
                raise ValueError(err)
            if normalization not in Colormap.NORMALIZATIONS:
                err = "Given normalization is not recognized (%s)" % normalization
                raise ValueError(err)

            autoscaleMode = dic.get("autoscaleMode", Colormap.MINMAX)
            if autoscaleMode not in Colormap.AUTOSCALE_MODES:
                err = "Given autoscale mode is not recognized (%s)" % autoscaleMode
                raise ValueError(err)

            # If autoscale, then set boundaries to None
            if dic.get("autoscale", False):
                vmin, vmax = None, None

            if name is not None:
                self.setName(name)
            else:
                self.setColormapLUT(colors)
            self._vmin = vmin
            self._vmax = vmax
            self._autoscale = True if (vmin is None and vmax is None) else False
            self._normalization = normalization
            self._autoscaleMode = autoscaleMode

            self.__warnBadVmin = True
            self.__warnBadVmax = True
            self.sigChanged.emit()

        @staticmethod
        def _fromDict(dic: dict):
            colormap = Colormap()
            colormap._setFromDict(dic)
            return colormap

        def copy(self) -> Colormap:
            """Return a copy of the Colormap."""
            colormap = Colormap(
                name=self._name,
                colors=self.getColormapLUT(),
                vmin=self._vmin,
                vmax=self._vmax,
                normalization=self.getNormalization(),
                autoscaleMode=self.getAutoscaleMode(),
            )
            colormap.setNaNColor(self.getNaNColor())
            colormap.setGammaNormalizationParameter(self.getGammaNormalizationParameter())
            colormap.setEditable(self.isEditable())
            return colormap

        def applyToData(
            self,
            data: numpy.ndarray | _Colormappable,
            reference: numpy.ndarray | _Colormappable | None = None,
        ) -> numpy.ndarray:
            """Apply the colormap to the data

            :param data:
                The data to convert or the item for which to apply the colormap.
            :param reference:
                The data or item to use as reference to compute autoscale
            """
            if reference is None:
                reference = data
            vmin, vmax = self.getColormapRange(reference)

            if isinstance(data, _Colormappable):  # Use item's data
                data = data.getColormappedData(copy=False)

            return _colormap.cmap(
                data, self._colors, vmin, vmax, self._getNormalizer(), self.__nanColor
            )

        @staticmethod
        def getSupportedColormaps() -> tuple[str, ...]:
            """Get the supported colormap names as a tuple of str.

            The list should at least contain and start by:

             ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue',
             'viridis', 'magma', 'inferno', 'plasma')
            """
            registered_colormaps = _colormap.get_registered_colormaps()
            colormaps = set(registered_colormaps)
            if _matplotlib_colormaps is not None:
                colormaps.update(_matplotlib_colormaps())

            # Put registered_colormaps first
            colormaps = tuple(
                cmap for cmap in sorted(colormaps) if cmap not in registered_colormaps
            )
            return registered_colormaps + colormaps

        def __str__(self) -> str:
            return str(self._toDict())

        def __eq__(self, other: Any):
            """Compare colormap values and not pointers"""
            if other is None:
                return False
            if not isinstance(other, Colormap):
                return False
            if self.getNormalization() != other.getNormalization():
                return False
            if self.getNormalization() == self.GAMMA:
                delta = (
                    self.getGammaNormalizationParameter()
                    - other.getGammaNormalizationParameter()
                )
                if abs(delta) > 0.001:
                    return False
            return (
                self.getName() == other.getName()
                and self.getAutoscaleMode() == other.getAutoscaleMode()
                and self.getVMin() == other.getVMin()
                and self.getVMax() == other.getVMax()
                and numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
            )

        _SERIAL_VERSION = 3

        def restoreState(self, byteArray: qt.QByteArray) -> bool:
            """
            Read the colormap state from a QByteArray.

            :param byteArray: Stream containing the state
            :return: True if the restoration sussseed
            """
            if self.isEditable() is False:
                raise NotEditableError("Colormap is not editable")
            stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly)

            className = stream.readQString()
            if className != self.__class__.__name__:
                _logger.warning("Classname mismatch. Found %s." % className)
                return False

            version = stream.readUInt32()
            if version not in numpy.arange(1, self._SERIAL_VERSION + 1):
                _logger.warning("Serial version mismatch. Found %d." % version)
                return False

            name = stream.readQString()
            isNull = stream.readBool()
            if not isNull:
                vmin = stream.readQVariant()
            else:
                vmin = None
            isNull = stream.readBool()
            if not isNull:
                vmax = stream.readQVariant()
            else:
                vmax = None

            normalization = stream.readQString()
            if normalization == Colormap.GAMMA:
                gamma = stream.readFloat()
            else:
                gamma = None

            if version == 1:
                autoscaleMode = Colormap.MINMAX
            else:
                autoscaleMode = stream.readQString()

            if version <= 2:
                nanColor = self._DEFAULT_NAN_COLOR
            else:
                nanColor = (
                    stream.readInt32(),
                    stream.readInt32(),
                    stream.readInt32(),
                    stream.readInt32(),
                )

            # emit change event only once
            old = self.blockSignals(True)
            try:
                self.setName(name)
                self.setNormalization(normalization)
                self.setAutoscaleMode(autoscaleMode)
                self.setVRange(vmin, vmax)
                if gamma is not None:
                    self.setGammaNormalizationParameter(gamma)
                self.setNaNColor(nanColor)
            finally:
                self.blockSignals(old)
            self.sigChanged.emit()
            return True

        def saveState(self) -> qt.QByteArray:
            """Save state of the colomap into a QDataStream."""
            data = qt.QByteArray()
            stream = qt.QDataStream(data, qt.QIODevice.WriteOnly)

            stream.writeQString(self.__class__.__name__)
            stream.writeUInt32(self._SERIAL_VERSION)
            stream.writeQString(self.getName())
            stream.writeBool(self.getVMin() is None)
            if self.getVMin() is not None:
                stream.writeQVariant(self.getVMin())
            stream.writeBool(self.getVMax() is None)
            if self.getVMax() is not None:
                stream.writeQVariant(self.getVMax())
            stream.writeQString(self.getNormalization())
            if self.getNormalization() == Colormap.GAMMA:
                stream.writeFloat(self.getGammaNormalizationParameter())
            stream.writeQString(self.getAutoscaleMode())
            nanColor = self.getNaNColor()
            stream.writeInt32(nanColor.red())
            stream.writeInt32(nanColor.green())
            stream.writeInt32(nanColor.blue())
            stream.writeInt32(nanColor.alpha())

            return data

    _PREFERRED_COLORMAPS = None
    """
    Tuple of preferred colormap names accessed with :meth:`preferredColormaps`.
    """

    _DEFAULT_PREFERRED_COLORMAPS = (
        "gray",
        "reversed gray",
        "red",
        "green",
        "blue",
        "viridis",
        "cividis",
        "magma",
        "inferno",
        "plasma",
        "temperature",
        "jet",
        "hsv",
    )

    def preferredColormaps() -> tuple[str, ...]:
        """Returns the name of the preferred colormaps.

        This list is used by widgets allowing to change the colormap
        like the :class:`ColormapDialog` as a subset of colormap choices.
        """
        global _PREFERRED_COLORMAPS
        if _PREFERRED_COLORMAPS is None:
            # Initialize preferred colormaps
            setPreferredColormaps(_DEFAULT_PREFERRED_COLORMAPS)
        return tuple(_PREFERRED_COLORMAPS)

    def setPreferredColormaps(colormaps: Iterable[str]):
        """Set the list of preferred colormap names.

        Warning: If a colormap name is not available
        it will be removed from the list.

        :param colormaps: Not empty list of colormap names
        :raise ValueError: if the list of available preferred colormaps is empty.
        """
        supportedColormaps = Colormap.getSupportedColormaps()
        colormaps = [cmap for cmap in colormaps if cmap in supportedColormaps]
        if len(colormaps) == 0:
            raise ValueError("Cannot set preferred colormaps to an empty list")

        global _PREFERRED_COLORMAPS
        _PREFERRED_COLORMAPS = colormaps

    def registerLUT(
        name: str,
        colors: numpy.ndarray,
        cursor_color: str = "black",
        preferred: bool = True,
    ):
        """Register a custom LUT to be used with `Colormap` objects.

        It can override existing LUT names.

        :param name: Name of the LUT as defined to configure colormaps
        :param colors: The custom LUT to register.
                Nx3 or Nx4 numpy array of RGB(A) colors,
                either uint8 or float in [0, 1].
        :param preferred: If true, this LUT will be displayed as part of the
            preferred colormaps in dialogs.
        :param cursor_color: Color used to display overlay over images using
            colormap with this LUT.
        """
        _colormap.register_colormap(name, colors, cursor_color)

        if preferred:
            # Invalidate the preferred cache
            global _PREFERRED_COLORMAPS
            if _PREFERRED_COLORMAPS is not None:
                if name not in _PREFERRED_COLORMAPS:
                    _PREFERRED_COLORMAPS.append(name)
            else:
                # The cache is not yet loaded, it's fine
                pass

    # Load some colormaps from matplotlib by default
    if _matplotlib_cm is not None:
        _registerColormapFromMatplotlib("jet", cursor_color="pink", preferred=True)
        _registerColormapFromMatplotlib("hsv", cursor_color="black", preferred=True)