summaryrefslogtreecommitdiff
path: root/src/silx/gui/colors.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/colors.py')
-rwxr-xr-xsrc/silx/gui/colors.py1114
1 files changed, 1114 insertions, 0 deletions
diff --git a/src/silx/gui/colors.py b/src/silx/gui/colors.py
new file mode 100755
index 0000000..b47fa85
--- /dev/null
+++ b/src/silx/gui/colors.py
@@ -0,0 +1,1114 @@
+# /*##########################################################################
+#
+# 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.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"""
+
+ 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: 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)
+ 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.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 = _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)