# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2015-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# 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 absolute_import

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

import numpy
import logging
import collections
import warnings

from silx.gui import qt
from silx.gui.utils import blockSignals
from silx.math.combo import min_max
from silx.math import colormap as _colormap
from silx.utils.exceptions import NotEditableError
from silx.utils import deprecation
from silx.resources import resource_filename as _resource_filename


_logger = logging.getLogger(__file__)

try:
    import silx.gui.utils.matplotlib  # noqa  Initalize matplotlib
    from matplotlib import cm as _matplotlib_cm
    from matplotlib.pyplot import colormaps as _matplotlib_colormaps
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


_LUT_DESCRIPTION = collections.namedtuple("_LUT_DESCRIPTION", ["source", "cursor_color", "preferred"])
"""Description of a LUT for internal purpose."""


_AVAILABLE_LUTS = collections.OrderedDict([
    ('gray', _LUT_DESCRIPTION('builtin', 'pink', True)),
    ('reversed gray', _LUT_DESCRIPTION('builtin', 'pink', True)),
    ('red', _LUT_DESCRIPTION('builtin', 'green', True)),
    ('green', _LUT_DESCRIPTION('builtin', 'pink', True)),
    ('blue', _LUT_DESCRIPTION('builtin', 'yellow', True)),
    ('viridis', _LUT_DESCRIPTION('resource', 'pink', True)),
    ('cividis', _LUT_DESCRIPTION('resource', 'pink', True)),
    ('magma', _LUT_DESCRIPTION('resource', 'green', True)),
    ('inferno', _LUT_DESCRIPTION('resource', 'green', True)),
    ('plasma', _LUT_DESCRIPTION('resource', 'green', True)),
    ('temperature', _LUT_DESCRIPTION('builtin', 'pink', True)),
    ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)),
    ('hsv', _LUT_DESCRIPTION('matplotlib', 'black', True)),
])
"""Description for internal porpose of all the default LUT provided by the library."""


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


def rgba(color, colorDict=None):
    """Convert color code '#RRGGBB' and '#RRGGBBAA' to a tuple (R, G, B, A)
    of floats.

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

    :param str color: The color to convert
    :param dict colorDict: A dictionary of color name conversion to color code
    :returns: RGBA colors as floats in [0., 1.]
    :rtype: tuple
    """
    if colorDict is None:
        colorDict = _COLORDICT

    if hasattr(color, 'getRgbF'):  # QColor support
        color = color.getRgbF()

    values = numpy.asarray(color).ravel()

    if values.dtype.kind in 'iuf':  # integer or float
        # Color is an array
        assert len(values) in (3, 4)

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

        # Clip to [0, 1]
        values[values < 0.] = 0.
        values[values > 1.] = 1.

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

    # We assume color is a string
    if not color.startswith('#'):
        color = colorDict[color]

    assert len(color) in (7, 9) and color[0] == '#'
    r = int(color[1:3], 16) / 255.
    g = int(color[3:5], 16) / 255.
    b = int(color[5:7], 16) / 255.
    a = int(color[7:9], 16) / 255. if len(color) == 9 else 1.
    return r, g, b, a


def greyed(color, colorDict=None):
    """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 str color: The color to convert
    :param dict colorDict: A dictionary of color name conversion to color code
    :returns: RGBA colors as floats in [0., 1.]
    :rtype: tuple
    """
    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):
    """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 str color: The color to convert
    :rtype: qt.QColor
    """
    color = rgba(color)
    return qt.QColor.fromRgbF(*color)


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

    :param str colormapName: The name of the colormap.
    :return: Name of the color.
    :rtype: str
    """
    description = _AVAILABLE_LUTS.get(colormapName, None)
    if description is not None:
        color = description.cursor_color
        if color is not None:
            return color
    return 'black'


# Colormap loader

_COLORMAP_CACHE = {}
"""Cache already used colormaps as name: color LUT"""


def _arrayToRgba8888(colors):
    """Convert colors from a numpy array using float (0..1) int or uint
    (0..255) to uint8 RGBA.

    :param numpy.ndarray colors: Array of float int or uint  colors to convert
    :return: colors as uint8
    :rtype: numpy.ndarray
    """
    assert len(colors.shape) == 2
    assert colors.shape[1] in (3, 4)

    if colors.dtype == numpy.uint8:
        pass
    elif colors.dtype.kind == 'f':
        # Each bin is [N, N+1[ except the last one: [255, 256]
        colors = numpy.clip(colors.astype(numpy.float64) * 256, 0., 255.)
        colors = colors.astype(numpy.uint8)
    elif colors.dtype.kind in 'iu':
        colors = numpy.clip(colors, 0, 255)
        colors = colors.astype(numpy.uint8)

    if colors.shape[1] == 3:
        tmp = numpy.empty((len(colors), 4), dtype=numpy.uint8)
        tmp[:, 0:3] = colors
        tmp[:, 3] = 255
        colors = tmp

    return colors


def _createColormapLut(name):
    """Returns the color LUT corresponding to a colormap name

    :param str name: Name of the colormap to load
    :returns: Corresponding table of colors
    :rtype: numpy.ndarray
    :raise ValueError: If no colormap corresponds to name
    """
    description = _AVAILABLE_LUTS.get(name)
    use_mpl = False
    if description is not None:
        if description.source == "builtin":
            # Build colormap LUT
            lut = numpy.zeros((256, 4), dtype=numpy.uint8)
            lut[:, 3] = 255

            if name == 'gray':
                lut[:, :3] = numpy.arange(256, dtype=numpy.uint8).reshape(-1, 1)
            elif name == 'reversed gray':
                lut[:, :3] = numpy.arange(255, -1, -1, dtype=numpy.uint8).reshape(-1, 1)
            elif name == 'red':
                lut[:, 0] = numpy.arange(256, dtype=numpy.uint8)
            elif name == 'green':
                lut[:, 1] = numpy.arange(256, dtype=numpy.uint8)
            elif name == 'blue':
                lut[:, 2] = numpy.arange(256, dtype=numpy.uint8)
            elif name == 'temperature':
                # Red
                lut[128:192, 0] = numpy.arange(2, 255, 4, dtype=numpy.uint8)
                lut[192:, 0] = 255
                # Green
                lut[:64, 1] = numpy.arange(0, 255, 4, dtype=numpy.uint8)
                lut[64:192, 1] = 255
                lut[192:, 1] = numpy.arange(252, -1, -4, dtype=numpy.uint8)
                # Blue
                lut[:64, 2] = 255
                lut[64:128, 2] = numpy.arange(254, 0, -4, dtype=numpy.uint8)
            else:
                raise RuntimeError("Built-in colormap not implemented")
            return lut

        elif description.source == "resource":
            # Load colormap LUT
            colors = numpy.load(_resource_filename("gui/colormaps/%s.npy" % name))
            # Convert to uint8 and add alpha channel
            lut = _arrayToRgba8888(colors)
            return lut

        elif description.source == "matplotlib":
            use_mpl = True

        else:
            raise RuntimeError("Internal LUT source '%s' unsupported" % description.source)

    # Here it expect a matplotlib LUTs

    if use_mpl:
        # matplotlib is mandatory
        if _matplotlib_cm is None:
            raise ValueError("The colormap '%s' expect matplotlib, but matplotlib is not installed" % name)

    if _matplotlib_cm is not None:  # Try to load with matplotlib
        colormap = _matplotlib_cm.get_cmap(name)
        lut = colormap(numpy.linspace(0, 1, colormap.N, endpoint=True))
        lut = _arrayToRgba8888(lut)
        return lut

    raise ValueError("Unknown colormap '%s'" % name)


def _getColormap(name):
    """Returns the color LUT corresponding to a colormap name

    :param str name: Name of the colormap to load
    :returns: Corresponding table of colors
    :rtype: numpy.ndarray
    :raise ValueError: If no colormap corresponds to name
    """
    name = str(name)
    if name not in _COLORMAP_CACHE:
        lut = _createColormapLut(name)
        _COLORMAP_CACHE[name] = lut
    return _COLORMAP_CACHE[name]


# Normalizations

class _NormalizationMixIn:
    """Colormap normalization mix-in class"""

    DEFAULT_RANGE = 0, 1
    """Fallback for (vmin, vmax)"""

    def isValid(self, value):
        """Check if a value is in the valid range for this normalization.

        Override in subclass.

        :param Union[float,numpy.ndarray] value:
        :rtype: Union[bool,numpy.ndarray]
        """
        if isinstance(value, collections.abc.Iterable):
            return numpy.ones_like(value, dtype=numpy.bool_)
        else:
            return True

    def autoscale(self, data, mode):
        """Returns range for given data and autoscale mode.

        :param Union[None,numpy.ndarray] data:
        :param str mode: Autoscale mode, see :class:`Colormap`
        :returns: Range as (min, max)
        :rtype: Tuple[float,float]
        """
        data = None if data is None else numpy.array(data, copy=False)
        if data is None or data.size == 0:
            return self.DEFAULT_RANGE

        if mode == Colormap.MINMAX:
            vmin, vmax = self.autoscaleMinMax(data)
        elif mode == Colormap.STDDEV3:
            dmin, dmax = self.autoscaleMinMax(data)
            stdmin, stdmax = self.autoscaleMean3Std(data)
            if dmin is None:
                vmin = stdmin
            elif stdmin is None:
                vmin = dmin
            else:
                vmin = max(dmin, stdmin)

            if dmax is None:
                vmax = stdmax
            elif stdmax is None:
                vmax = dmax
            else:
                vmax = min(dmax, stdmax)

        else:
            raise ValueError('Unsupported mode: %s' % mode)

        # Check returned range and handle fallbacks
        if vmin is None or not numpy.isfinite(vmin):
            vmin = self.DEFAULT_RANGE[0]
        if vmax is None or not numpy.isfinite(vmax):
            vmax = self.DEFAULT_RANGE[1]
        if vmax < vmin:
            vmax = vmin
        return float(vmin), float(vmax)

    def autoscaleMinMax(self, data):
        """Autoscale using min/max

        :param numpy.ndarray data:
        :returns: (vmin, vmax)
        :rtype: Tuple[float,float]
        """
        data = data[self.isValid(data)]
        if data.size == 0:
            return None, None
        result = min_max(data, min_positive=False, finite=True)
        return result.minimum, result.maximum

    def autoscaleMean3Std(self, data):
        """Autoscale using mean+/-3std

        This implementation only works for normalization that do NOT
        use the data range.
        Override this method for normalization using the range.

        :param numpy.ndarray data:
        :returns: (vmin, vmax)
        :rtype: Tuple[float,float]
        """
        # Use [0, 1] as data range for normalization not using range
        normdata = self.apply(data, 0., 1.)
        if normdata.dtype.kind == 'f':  # Replaces inf by NaN
            normdata[numpy.isfinite(normdata) == False] = numpy.nan
        if normdata.size == 0:  # Fallback
            return None, None

        with warnings.catch_warnings():
            warnings.simplefilter('ignore', category=RuntimeWarning)
            # Ignore nanmean "Mean of empty slice" warning and
            # nanstd "Degrees of freedom <= 0 for slice" warning
            mean, std = numpy.nanmean(normdata), numpy.nanstd(normdata)

        return self.revert(mean - 3 * std, 0., 1.), self.revert(mean + 3 * std, 0., 1.)


class _LinearNormalizationMixIn(_NormalizationMixIn):
    """Colormap normalization mix-in class specific to autoscale taken from initial range"""

    def autoscaleMean3Std(self, data):
        """Autoscale using mean+/-3std

        Do the autoscale on the data itself, not the normalized data.

        :param numpy.ndarray data:
        :returns: (vmin, vmax)
        :rtype: Tuple[float,float]
        """
        if data.dtype.kind == 'f':  # Replaces inf by NaN
            data = numpy.array(data, copy=True)  # Work on a copy
            data[numpy.isfinite(data) == False] = numpy.nan
        if data.size == 0:  # Fallback
            return None, None
        with warnings.catch_warnings():
            warnings.simplefilter('ignore', category=RuntimeWarning)
            # Ignore nanmean "Mean of empty slice" warning and
            # nanstd "Degrees of freedom <= 0 for slice" warning
            mean, std = numpy.nanmean(data), numpy.nanstd(data)
        return mean - 3 * std, mean + 3 * std


class _LinearNormalization(_colormap.LinearNormalization, _LinearNormalizationMixIn):
    """Linear normalization"""
    def __init__(self):
        _colormap.LinearNormalization.__init__(self)
        _LinearNormalizationMixIn.__init__(self)


class _LogarithmicNormalization(_colormap.LogarithmicNormalization, _NormalizationMixIn):
    """Logarithm normalization"""

    DEFAULT_RANGE = 1, 10

    def __init__(self):
        _colormap.LogarithmicNormalization.__init__(self)
        _NormalizationMixIn.__init__(self)

    def isValid(self, value):
        return value > 0.

    def autoscaleMinMax(self, data):
        result = min_max(data, min_positive=True, finite=True)
        return result.min_positive, result.maximum


class _SqrtNormalization(_colormap.SqrtNormalization, _NormalizationMixIn):
    """Square root normalization"""

    DEFAULT_RANGE = 0, 1

    def __init__(self):
        _colormap.SqrtNormalization.__init__(self)
        _NormalizationMixIn.__init__(self)

    def isValid(self, value):
        return value >= 0.


class _GammaNormalization(_colormap.PowerNormalization, _LinearNormalizationMixIn):
    """Gamma correction normalization:

    Linear normalization to [0, 1] followed by power normalization.

    :param gamma: Gamma correction factor
    """
    def __init__(self, gamma):
        _colormap.PowerNormalization.__init__(self, gamma)
        _LinearNormalizationMixIn.__init__(self)


class _ArcsinhNormalization(_colormap.ArcsinhNormalization, _NormalizationMixIn):
    """Inverse hyperbolic sine normalization"""

    def __init__(self):
        _colormap.ArcsinhNormalization.__init__(self)
        _NormalizationMixIn.__init__(self)


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

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

    :param str name: Name of the colormap
    :param tuple 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 str normalization: Normalization: 'linear' (default) or 'log'
    :param vmin: Lower bound of the colormap or None for autoscale (default)
    :type vmin: Union[None, float]
    :param vmax: Upper bounds of the colormap or None for autoscale (default)
    :type vmax: Union[None, float]
    """

    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: _LinearNormalization(),
        LOGARITHM: _LogarithmicNormalization(),
        SQRT: _SqrtNormalization(),
        ARCSINH: _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"""

    AUTOSCALE_MODES = (MINMAX, STDDEV3)
    """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=None, colors=None, normalization=LINEAR, vmin=None, vmax=None, autoscaleMode=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:
            deprecation.deprecated_warning("Argument",
                                           name="silx.gui.plot.Colors",
                                           reason="name and colors can't be used at the same time",
                                           since_version="0.10.0",
                                           skip_backtrace_count=1)

            colors = None

        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

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

        :param ~silx.gui.colors.Colormap 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=None):
        """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.
        :type nbColors: int or None
        :return: 2D array of uint8 of shape (nbColors, 4)
        :rtype: numpy.ndarray
        """
        # 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):
        """Return the name of the colormap
        :rtype: str
        """
        return self._name

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

        :param str 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=True):
        """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 bool copy: If true a copy of the numpy array is provided
        :return: the list of colors for the colormap or None if not set
        :rtype: numpy.ndarray or None
        """
        if self._name is None:
            return numpy.array(self._colors, copy=copy)
        else:
            return None

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

        :param numpy.ndarray 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.array(colors, copy=False)
        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 = _arrayToRgba8888(colors)
        self._name = None
        self.sigChanged.emit()

    def getNaNColor(self):
        """Returns the color to use for Not-A-Number floating point value.

        :rtype: QColor
        """
        return qt.QColor(*self.__nanColor)

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

        :param color: RGB(A) color to use for NaN values
        :type color: QColor, str, tuple of uint8 or float in [0., 1.]
        """
        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):
        """Return the normalization of the colormap.

        See :meth:`setNormalization` for returned values.

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

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

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

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

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

        Only used for gamma correction normalization.

        :param float gamma:
        :raise ValueError: If gamma is not valid
        """
        if gamma < 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.

        :rtype: float
        """
        return self.__gamma

    def getAutoscaleMode(self):
        """Return the autoscale mode of the colormap ('minmax' or 'stddev3')

        :rtype: str
        """
        return self._autoscaleMode

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

        :param str 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):
        """Return True if both min and max are in autoscale mode"""
        return self._vmin is None and self._vmax is None

    def getVMin(self):
        """Return the lower bound of the colormap

         :return: the lower bound of the colormap
         :rtype: float or None
         """
        return self._vmin

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

        :param float vmin: Lower bound of the colormap or None for autoscale
            (default)
            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)

        self._vmin = vmin
        self.sigChanged.emit()

    def getVMax(self):
        """Return the upper bounds of the colormap or None

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

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

        :param float vmax: Upper bounds of the colormap or None for autoscale
            (default)
        """
        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)

        self._vmax = vmax
        self.sigChanged.emit()

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

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

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

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

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

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

        :param numpy.ndarray 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=None):
        """Return (vmin, vmax) the range of the colormap for the given data or item.

        :param Union[numpy.ndarray,~silx.gui.plot.items.ColormapMixIn] data:
            The data or item to use for autoscale bounds.
        :return: (vmin, vmax) corresponding to the colormap applied to data if provided.
        :rtype: tuple
        """
        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.isValid(vmin):
            _logger.info(
                'Invalid vmin, switching to autoscale for lower bound')
            vmin = None
        if vmax is not None and not normalizer.isValid(vmax):
            _logger.info(
                'Invalid vmax, switching to autoscale for upper bound')
            vmax = None

        if vmin is None or vmax is None:  # Handle autoscale
            from .plot.items.core import ColormapMixIn  # avoid cyclic import
            if isinstance(data, ColormapMixIn):
                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):
        """Get the bounds of the colormap

        :rtype: Tuple(Union[float,None],Union[float,None])
        :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, vmax):
        """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 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

        self._vmin = vmin
        self._vmax = vmax
        self.sigChanged.emit()

    def __getitem__(self, item):
        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):
        """Return the equivalent colormap as a dictionary
        (old colormap representation)

        :return: the representation of the Colormap as a dictionary
        :rtype: dict
        """
        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):
        """Set values to the colormap from a dictionary

        :param dict 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.sigChanged.emit()

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

    def copy(self):
        """Return a copy of the Colormap.

        :rtype: silx.gui.colors.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, reference=None):
        """Apply the colormap to the data

        :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn] data:
            The data to convert or the item for which to apply the colormap.
        :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn,None] reference:
            The data or item to use as reference to compute autoscale
        """
        if reference is None:
            reference = data
        vmin, vmax = self.getColormapRange(reference)

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

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

    @staticmethod
    def getSupportedColormaps():
        """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')

        :rtype: tuple
        """
        colormaps = set()
        if _matplotlib_colormaps is not None:
            colormaps.update(_matplotlib_colormaps())
        colormaps.update(_AVAILABLE_LUTS.keys())

        colormaps = tuple(cmap for cmap in sorted(colormaps)
                          if cmap not in _AVAILABLE_LUTS.keys())

        return tuple(_AVAILABLE_LUTS.keys()) + colormaps

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

    def __eq__(self, other):
        """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):
        """
        Read the colormap state from a QByteArray.

        :param qt.QByteArray byteArray: Stream containing the state
        :return: True if the restoration sussseed
        :rtype: bool
        """
        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):
        """
        Save state of the colomap into a QDataStream.

        :rtype: qt.QByteArray
        """
        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`.
"""


def preferredColormaps():
    """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.

    :rtype: tuple of str
    """
    global _PREFERRED_COLORMAPS
    if _PREFERRED_COLORMAPS is None:
        # Initialize preferred colormaps
        default_preferred = []
        for name, info in _AVAILABLE_LUTS.items():
            if (info.preferred and
                    (info.source != 'matplotlib' or _matplotlib_cm is not None)):
                default_preferred.append(name)
        setPreferredColormaps(default_preferred)
    return tuple(_PREFERRED_COLORMAPS)


def setPreferredColormaps(colormaps):
    """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
    :type colormaps: iterable of str
    :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, colors, cursor_color='black', preferred=True):
    """Register a custom LUT to be used with `Colormap` objects.

    It can override existing LUT names.

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

    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

    # Register the cache as the LUT was already loaded
    _COLORMAP_CACHE[name] = colors
