# /*##########################################################################
#
# 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"""
[docs]
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)
[docs]
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
[docs]
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)
[docs]
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")
[docs]
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
[docs]
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()
[docs]
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
[docs]
def getName(self) -> str | None:
"""Return the name of the colormap"""
return self._name
[docs]
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()
[docs]
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
[docs]
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()
[docs]
def getNaNColor(self) -> qt.QColor:
"""Returns the color to use for Not-A-Number floating point value."""
return qt.QColor(*self.__nanColor)
[docs]
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()
[docs]
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
[docs]
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()
[docs]
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()
[docs]
def getGammaNormalizationParameter(self) -> float:
"""Returns the gamma correction parameter value."""
return self.__gamma
[docs]
def getAutoscaleMode(self) -> str:
"""Return the autoscale mode of the colormap ('minmax' or 'stddev3')"""
return self._autoscaleMode
[docs]
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()
[docs]
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
[docs]
def getVMin(self) -> float | None:
"""Return the lower bound of the colormap
:return: the lower bound of the colormap
"""
return self._vmin
[docs]
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()
[docs]
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
[docs]
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()
[docs]
def isEditable(self) -> bool:
"""Return if the colormap is editable or not
:return: editable state of the colormap
"""
return self._editable
[docs]
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())
[docs]
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
[docs]
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()
[docs]
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
[docs]
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
[docs]
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
)
[docs]
@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
[docs]
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
[docs]
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",
)
[docs]
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)
[docs]
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
[docs]
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)