diff options
Diffstat (limited to 'silx/gui/colors.py')
-rw-r--r-- | silx/gui/colors.py | 454 |
1 files changed, 329 insertions, 125 deletions
diff --git a/silx/gui/colors.py b/silx/gui/colors.py index a51bcdc..f1f34c9 100644 --- a/silx/gui/colors.py +++ b/silx/gui/colors.py @@ -29,18 +29,28 @@ from __future__ import absolute_import __authors__ = ["T. Vincent", "H.Payno"] __license__ = "MIT" -__date__ = "05/10/2018" +__date__ = "29/01/2019" -from silx.gui import qt -import copy as copy_mdl import numpy import logging +import collections +from silx.gui import qt +from silx import config from silx.math.combo import min_max from silx.math.colormap import cmap as _cmap from silx.utils.exceptions import NotEditableError +from silx.utils import deprecation +from silx.resources import resource_filename as _resource_filename + _logger = logging.getLogger(__file__) +try: + from matplotlib import cm as _matplotlib_cm +except ImportError: + _logger.info("matplotlib not available, only embedded colormaps available") + _matplotlib_cm = None + _COLORDICT = {} """Dictionary of common colors.""" @@ -67,12 +77,44 @@ _COLORDICT['darkBrown'] = '#660000' _COLORDICT['darkCyan'] = '#008080' _COLORDICT['darkYellow'] = '#808000' _COLORDICT['darkMagenta'] = '#800080' +_COLORDICT['transparent'] = '#00000000' # FIXME: It could be nice to expose a functional API instead of that attribute COLORDICT = _COLORDICT +_LUT_DESCRIPTION = collections.namedtuple("_LUT_DESCRIPTION", ["source", "cursor_color", "preferred"]) +"""Description of a LUT for internal purpose.""" + + +_AVAILABLE_LUTS = collections.OrderedDict([ + ('gray', _LUT_DESCRIPTION('builtin', 'pink', True)), + ('reversed gray', _LUT_DESCRIPTION('builtin', 'pink', True)), + ('temperature', _LUT_DESCRIPTION('builtin', 'pink', True)), + ('red', _LUT_DESCRIPTION('builtin', 'green', True)), + ('green', _LUT_DESCRIPTION('builtin', 'pink', True)), + ('blue', _LUT_DESCRIPTION('builtin', 'yellow', True)), + ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)), + ('viridis', _LUT_DESCRIPTION('resource', 'pink', True)), + ('magma', _LUT_DESCRIPTION('resource', 'green', True)), + ('inferno', _LUT_DESCRIPTION('resource', 'green', True)), + ('plasma', _LUT_DESCRIPTION('resource', 'green', True)), + ('hsv', _LUT_DESCRIPTION('matplotlib', 'black', True)), +]) +"""Description for internal porpose of all the default LUT provided by the library.""" + + +DEFAULT_MIN_LIN = 0 +"""Default min value if in linear normalization""" +DEFAULT_MAX_LIN = 1 +"""Default max value if in linear normalization""" +DEFAULT_MIN_LOG = 1 +"""Default min value if in log normalization""" +DEFAULT_MAX_LOG = 10 +"""Default max value if in log normalization""" + + def rgba(color, colorDict=None): """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A) @@ -121,19 +163,21 @@ def rgba(color, colorDict=None): return r, g, b, a -_COLORMAP_CURSOR_COLORS = { - 'gray': 'pink', - 'reversed gray': 'pink', - 'temperature': 'pink', - 'red': 'green', - 'green': 'pink', - 'blue': 'yellow', - 'jet': 'pink', - 'viridis': 'pink', - 'magma': 'green', - 'inferno': 'green', - 'plasma': 'green', -} +def greyed(color, colorDict=None): + """Convert color code '#RRGGBB' and '#RRGGBBAA' to a grey color + (R, G, B, A). + + It also convert RGB(A) values from uint8 to float in [0, 1] and + accept a QColor as color argument. + + :param str color: The color to convert + :param dict colorDict: A dictionary of color name conversion to color code + :returns: RGBA colors as floats in [0., 1.] + :rtype: tuple + """ + r, g, b, a = rgba(color=color, colorDict=colorDict) + g = 0.21 * r + 0.72 * g + 0.07 * b + return g, g, g, a def cursorColorForColormap(colormapName): @@ -143,26 +187,140 @@ def cursorColorForColormap(colormapName): :return: Name of the color. :rtype: str """ - return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black') + description = _AVAILABLE_LUTS.get(colormapName, None) + if description is not None: + color = description.cursor_color + if color is not None: + return color + return 'black' -DEFAULT_COLORMAPS = ( - 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') -"""Tuple of supported colormap names.""" +# Colormap loader -DEFAULT_MIN_LIN = 0 -"""Default min value if in linear normalization""" -DEFAULT_MAX_LIN = 1 -"""Default max value if in linear normalization""" -DEFAULT_MIN_LOG = 1 -"""Default min value if in log normalization""" -DEFAULT_MAX_LOG = 10 -"""Default max value if in log normalization""" +_COLORMAP_CACHE = {} +"""Cache already used colormaps as name: color LUT""" + + +def _arrayToRgba8888(colors): + """Convert colors from a numpy array using float (0..1) int or uint + (0..255) to uint8 RGBA. + + :param numpy.ndarray colors: Array of float int or uint colors to convert + :return: colors as uint8 + :rtype: numpy.ndarray + """ + assert len(colors.shape) == 2 + assert colors.shape[1] in (3, 4) + + if colors.dtype == numpy.uint8: + pass + elif colors.dtype.kind == 'f': + # Each bin is [N, N+1[ except the last one: [255, 256] + colors = numpy.clip(colors.astype(numpy.float64) * 256, 0., 255.) + colors = colors.astype(numpy.uint8) + elif colors.dtype.kind in 'iu': + colors = numpy.clip(colors, 0, 255) + colors = colors.astype(numpy.uint8) + + if colors.shape[1] == 3: + tmp = numpy.empty((len(colors), 4), dtype=numpy.uint8) + tmp[:, 0:3] = colors + tmp[:, 3] = 255 + colors = tmp + + return colors + + +def _createColormapLut(name): + """Returns the color LUT corresponding to a colormap name + + :param str name: Name of the colormap to load + :returns: Corresponding table of colors + :rtype: numpy.ndarray + :raise ValueError: If no colormap corresponds to name + """ + description = _AVAILABLE_LUTS.get(name) + use_mpl = False + if description is not None: + if description.source == "builtin": + # Build colormap LUT + lut = numpy.zeros((256, 4), dtype=numpy.uint8) + lut[:, 3] = 255 + + if name == 'gray': + lut[:, :3] = numpy.arange(256, dtype=numpy.uint8).reshape(-1, 1) + elif name == 'reversed gray': + lut[:, :3] = numpy.arange(255, -1, -1, dtype=numpy.uint8).reshape(-1, 1) + elif name == 'red': + lut[:, 0] = numpy.arange(256, dtype=numpy.uint8) + elif name == 'green': + lut[:, 1] = numpy.arange(256, dtype=numpy.uint8) + elif name == 'blue': + lut[:, 2] = numpy.arange(256, dtype=numpy.uint8) + elif name == 'temperature': + # Red + lut[128:192, 0] = numpy.arange(2, 255, 4, dtype=numpy.uint8) + lut[192:, 0] = 255 + # Green + lut[:64, 1] = numpy.arange(0, 255, 4, dtype=numpy.uint8) + lut[64:192, 1] = 255 + lut[192:, 1] = numpy.arange(252, -1, -4, dtype=numpy.uint8) + # Blue + lut[:64, 2] = 255 + lut[64:128, 2] = numpy.arange(254, 0, -4, dtype=numpy.uint8) + else: + raise RuntimeError("Built-in colormap not implemented") + return lut + + elif description.source == "resource": + # Load colormap LUT + colors = numpy.load(_resource_filename("gui/colormaps/%s.npy" % name)) + # Convert to uint8 and add alpha channel + lut = _arrayToRgba8888(colors) + return lut + + elif description.source == "matplotlib": + use_mpl = True + + else: + raise RuntimeError("Internal LUT source '%s' unsupported" % description.source) + + # Here it expect a matplotlib LUTs + + if use_mpl: + # matplotlib is mandatory + if _matplotlib_cm is None: + raise ValueError("The colormap '%s' expect matplotlib, but matplotlib is not installed" % name) + + if _matplotlib_cm is not None: # Try to load with matplotlib + colormap = _matplotlib_cm.get_cmap(name) + lut = colormap(numpy.linspace(0, 1, colormap.N, endpoint=True)) + lut = _arrayToRgba8888(lut) + return lut + + raise ValueError("Unknown colormap '%s'" % name) + + +def _getColormap(name): + """Returns the color LUT corresponding to a colormap name + + :param str name: Name of the colormap to load + :returns: Corresponding table of colors + :rtype: numpy.ndarray + :raise ValueError: If no colormap corresponds to name + """ + name = str(name) + if name not in _COLORMAP_CACHE: + lut = _createColormapLut(name) + _COLORMAP_CACHE[name] = lut + return _COLORMAP_CACHE[name] class Colormap(qt.QObject): """Description of a colormap + If no `name` nor `colors` are provided, a default gray LUT is used. + :param str name: Name of the colormap :param tuple colors: optional, custom colormap. Nx3 or Nx4 numpy array of RGB(A) colors, @@ -187,10 +345,11 @@ class Colormap(qt.QObject): sigChanged = qt.Signal() """Signal emitted when the colormap has changed.""" - def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): + def __init__(self, name=None, colors=None, normalization=LINEAR, vmin=None, vmax=None): qt.QObject.__init__(self) + self._editable = True + assert normalization in Colormap.NORMALIZATIONS - assert not (name is None and colors is None) 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." @@ -200,78 +359,76 @@ class Colormap(qt.QObject): vmin = None vmax = None - self._name = str(name) if name is not None else None - self._setColors(colors) + self._name = None + self._colors = None + + if colors is not None and name is not None: + deprecation.deprecated_warning("Argument", + name="silx.gui.plot.Colors", + reason="name and colors can't be used at the same time", + since_version="0.10.0", + skip_backtrace_count=1) + + colors = None + + if name is not None: + self.setName(name) # And resets colormap LUT + elif colors is not None: + self.setColormapLUT(colors) + else: + # Default colormap is grey + self.setName("gray") + self._normalization = str(normalization) self._vmin = float(vmin) if vmin is not None else None self._vmax = float(vmax) if vmax is not None else None - self._editable = True - - def isAutoscale(self): - """Return True if both min and max are in autoscale mode""" - return self._vmin is None and self._vmax is None - def getName(self): - """Return the name of the colormap - :rtype: str - """ - return self._name - - @staticmethod - def _convertColorsFromFloatToUint8(colors): - """Convert colors from float in [0, 1] to uint8 + def setFromColormap(self, other): + """Set this colormap using information from the `other` colormap. - :param numpy.ndarray colors: Array of float colors to convert - :return: colors as uint8 - :rtype: numpy.ndarray + :param Colormap other: Colormap to use as reference. """ - # Each bin is [N, N+1[ except the last one: [255, 256] - return numpy.clip( - colors.astype(numpy.float64) * 256, 0., 255.).astype(numpy.uint8) - - def _setColors(self, colors): - if colors is None: - self._colors = None + if not self.isEditable(): + raise NotEditableError('Colormap is not editable') + if self == other: + return + old = self.blockSignals(True) + name = other.getName() + if name is not None: + self.setName(name) else: - colors = numpy.array(colors, copy=False) - if colors.shape == (): - raise TypeError("An array is expected for 'colors' argument. '%s' was found." % type(colors)) - colors.shape = -1, colors.shape[-1] - if colors.dtype.kind == 'f': - colors = self._convertColorsFromFloatToUint8(colors) - - # Makes sure it is RGBA8888 - self._colors = numpy.zeros((len(colors), 4), dtype=numpy.uint8) - self._colors[:, 3] = 255 # Alpha channel - self._colors[:, :colors.shape[1]] = colors # Copy colors + self.setColormapLUT(other.getColormapLUT()) + self.setNormalization(other.getNormalization()) + self.setVRange(other.getVMin(), other.getVMax()) + self.blockSignals(old) + self.sigChanged.emit() def getNColors(self, nbColors=None): """Returns N colors computed by sampling the colormap regularly. :param nbColors: The number of colors in the returned array or None for the default value. - The default value is 256 for colormap with a name (see :meth:`setName`) and - it is the size of the LUT for colormap defined with :meth:`setColormapLUT`. + The default value is the size of the colormap LUT. :type nbColors: int or None :return: 2D array of uint8 of shape (nbColors, 4) :rtype: numpy.ndarray """ # Handle default value for nbColors if nbColors is None: - lut = self.getColormapLUT() - if lut is not None: # In this case uses LUT length - nbColors = len(lut) - else: # Default to 256 - nbColors = 256 - - nbColors = int(nbColors) + return numpy.array(self._colors, copy=True) + else: + colormap = self.copy() + colormap.setNormalization(Colormap.LINEAR) + colormap.setVRange(vmin=None, vmax=None) + colors = colormap.applyToData( + numpy.arange(int(nbColors), dtype=numpy.int)) + return colors - colormap = self.copy() - colormap.setNormalization(Colormap.LINEAR) - colormap.setVRange(vmin=None, vmax=None) - colors = colormap.applyToData( - numpy.arange(nbColors, dtype=numpy.int)) - return colors + def getName(self): + """Return the name of the colormap + :rtype: str + """ + return self._name def setName(self, name): """Set the name of the colormap to use. @@ -281,23 +438,31 @@ class Colormap(qt.QObject): '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') - assert name in self.getSupportedColormaps() - self._name = str(name) - self._colors = None + 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): - """Return the list of colors for the colormap or None if not set + def getColormapLUT(self, copy=True): + """Return the list of colors for the colormap or None if not set. + This returns None if the colormap was set with :meth:`setName`. + Use :meth:`getNColors` to get the colormap LUT for any colormap. + + :param bool copy: If true a copy of the numpy array is provided :return: the list of colors for the colormap or None if not set :rtype: numpy.ndarray or None """ - if self._colors is None: - return None + if self._name is None: + return numpy.array(self._colors, copy=copy) else: - return numpy.array(self._colors, copy=True) + return None def setColormapLUT(self, colors): """Set the colors of the colormap. @@ -310,10 +475,15 @@ class Colormap(qt.QObject): """ if self.isEditable() is False: raise NotEditableError('Colormap is not editable') - self._setColors(colors) - if len(colors) is 0: - self._colors = None - + assert colors is not None + + colors = numpy.array(colors, copy=False) + if colors.shape == (): + raise TypeError("An array is expected for 'colors' argument. '%s' was found." % type(colors)) + assert len(colors) != 0 + assert colors.ndim >= 2 + colors.shape = -1, colors.shape[-1] + self._colors = _arrayToRgba8888(colors) self._name = None self.sigChanged.emit() @@ -335,6 +505,10 @@ class Colormap(qt.QObject): self._normalization = str(norm) self.sigChanged.emit() + def isAutoscale(self): + """Return True if both min and max are in autoscale mode""" + return self._vmin is None and self._vmax is None + def getVMin(self): """Return the lower bound of the colormap @@ -504,7 +678,7 @@ class Colormap(qt.QObject): """ return { 'name': self._name, - 'colors': copy_mdl.copy(self._colors), + 'colors': self.getColormapLUT(), 'vmin': self._vmin, 'vmax': self._vmax, 'autoscale': self.isAutoscale(), @@ -546,8 +720,10 @@ class Colormap(qt.QObject): if dic.get('autoscale', False): vmin, vmax = None, None - self._name = name - self._colors = colors + 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 @@ -557,7 +733,7 @@ class Colormap(qt.QObject): @staticmethod def _fromDict(dic): - colormap = Colormap(name="") + colormap = Colormap() colormap._setFromDict(dic) return colormap @@ -567,7 +743,7 @@ class Colormap(qt.QObject): :rtype: silx.gui.colors.Colormap """ return Colormap(name=self._name, - colors=copy_mdl.copy(self._colors), + colors=self.getColormapLUT(), vmin=self._vmin, vmax=self._vmax, normalization=self._normalization) @@ -577,34 +753,30 @@ class Colormap(qt.QObject): :param numpy.ndarray data: The data to convert. """ - name = self.getName() - if name is not None: # Get colormap definition from matplotlib - # FIXME: If possible remove dependency to the plot - from .plot.matplotlib import Colormap as MPLColormap - mplColormap = MPLColormap.getColormap(name) - colors = mplColormap(numpy.linspace(0, 1, 256, endpoint=True)) - colors = self._convertColorsFromFloatToUint8(colors) - - else: # Use user defined LUT - colors = self.getColormapLUT() - vmin, vmax = self.getColormapRange(data) normalization = self.getNormalization() - - return _cmap(data, colors, vmin, vmax, normalization) + return _cmap(data, self._colors, vmin, vmax, normalization) @staticmethod def getSupportedColormaps(): """Get the supported colormap names as a tuple of str. The list should at least contain and start by: - ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') + + ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue', + 'viridis', 'magma', 'inferno', 'plasma') + :rtype: tuple """ - # FIXME: If possible remove dependency to the plot - from .plot.matplotlib import Colormap as MPLColormap - maps = MPLColormap.getSupportedColormaps() - return DEFAULT_COLORMAPS + maps + colormaps = set() + if _matplotlib_cm is not None: + colormaps.update(_matplotlib_cm.cmap_d.keys()) + colormaps.update(_AVAILABLE_LUTS.keys()) + + colormaps = tuple(cmap for cmap in sorted(colormaps) + if cmap not in _AVAILABLE_LUTS.keys()) + + return tuple(_AVAILABLE_LUTS.keys()) + colormaps def __str__(self): return str(self._toDict()) @@ -617,6 +789,10 @@ class Colormap(qt.QObject): def __eq__(self, other): """Compare colormap values and not pointers""" + if other is None: + return False + if not isinstance(other, Colormap): + return False return (self.getName() == other.getName() and self.getNormalization() == other.getNormalization() and self.getVMin() == other.getVMin() and @@ -710,13 +886,10 @@ def preferredColormaps(): """ global _PREFERRED_COLORMAPS if _PREFERRED_COLORMAPS is None: - _PREFERRED_COLORMAPS = DEFAULT_COLORMAPS # Initialize preferred colormaps - setPreferredColormaps(('gray', 'reversed gray', - 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma', - 'hsv')) - return _PREFERRED_COLORMAPS + default_preferred = [k for k in _AVAILABLE_LUTS.keys() if _AVAILABLE_LUTS[k].preferred] + setPreferredColormaps(default_preferred) + return tuple(_PREFERRED_COLORMAPS) def setPreferredColormaps(colormaps): @@ -730,10 +903,41 @@ def setPreferredColormaps(colormaps): :raise ValueError: if the list of available preferred colormaps is empty. """ supportedColormaps = Colormap.getSupportedColormaps() - colormaps = tuple( - cmap for cmap in colormaps if cmap in supportedColormaps) + colormaps = [cmap for cmap in colormaps if cmap in supportedColormaps] if len(colormaps) == 0: raise ValueError("Cannot set preferred colormaps to an empty list") global _PREFERRED_COLORMAPS _PREFERRED_COLORMAPS = colormaps + + +def registerLUT(name, colors, cursor_color='black', preferred=True): + """Register a custom LUT to be used with `Colormap` objects. + + It can override existing LUT names. + + :param str name: Name of the LUT as defined to configure colormaps + :param numpy.ndarray colors: The custom LUT to register. + Nx3 or Nx4 numpy array of RGB(A) colors, + either uint8 or float in [0, 1]. + :param bool preferred: If true, this LUT will be displayed as part of the + preferred colormaps in dialogs. + :param str cursor_color: Color used to display overlay over images using + colormap with this LUT. + """ + description = _LUT_DESCRIPTION('user', cursor_color, preferred=preferred) + colors = _arrayToRgba8888(colors) + _AVAILABLE_LUTS[name] = description + + if preferred: + # Invalidate the preferred cache + global _PREFERRED_COLORMAPS + if _PREFERRED_COLORMAPS is not None: + if name not in _PREFERRED_COLORMAPS: + _PREFERRED_COLORMAPS.append(name) + else: + # The cache is not yet loaded, it's fine + pass + + # Register the cache as the LUT was already loaded + _COLORMAP_CACHE[name] = colors |