# /*##########################################################################
#
# 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"""
[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)
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.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()
[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)