From 159ef14fb9e198bb0066ea14e6b980f065de63dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Picca=20Fr=C3=A9d=C3=A9ric-Emmanuel?= Date: Tue, 31 Jul 2018 16:22:25 +0200 Subject: New upstream version 0.8.0+dfsg --- silx/gui/colors.py | 732 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 732 insertions(+) create mode 100644 silx/gui/colors.py (limited to 'silx/gui/colors.py') diff --git a/silx/gui/colors.py b/silx/gui/colors.py new file mode 100644 index 0000000..028609b --- /dev/null +++ b/silx/gui/colors.py @@ -0,0 +1,732 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides API to manage colors. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent", "H.Payno"] +__license__ = "MIT" +__date__ = "14/06/2018" + +from silx.gui import qt +import copy as copy_mdl +import numpy +import logging +from silx.math.combo import min_max +from silx.math.colormap import cmap as _cmap +from silx.utils.exceptions import NotEditableError + +_logger = logging.getLogger(__file__) + + +_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' + + +# FIXME: It could be nice to expose a functional API instead of that attribute +COLORDICT = _COLORDICT + + +def rgba(color, colorDict=None): + """Convert color code '#RRGGBB' and '#RRGGBBAA' to (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 + """ + if colorDict is None: + colorDict = _COLORDICT + + if hasattr(color, 'getRgbF'): # QColor support + color = color.getRgbF() + + values = numpy.asarray(color).ravel() + + if values.dtype.kind in 'iuf': # integer or float + # Color is an array + assert len(values) in (3, 4) + + # Convert from integers in [0, 255] to float in [0, 1] + if values.dtype.kind in 'iu': + values = values / 255. + + # Clip to [0, 1] + values[values < 0.] = 0. + values[values > 1.] = 1. + + if len(values) == 3: + return values[0], values[1], values[2], 1. + else: + return tuple(values) + + # We assume color is a string + if not color.startswith('#'): + color = colorDict[color] + + assert len(color) in (7, 9) and color[0] == '#' + r = int(color[1:3], 16) / 255. + g = int(color[3:5], 16) / 255. + b = int(color[5:7], 16) / 255. + a = int(color[7:9], 16) / 255. if len(color) == 9 else 1. + return r, g, b, a + + +_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 cursorColorForColormap(colormapName): + """Get a color suitable for overlay over a colormap. + + :param str colormapName: The name of the colormap. + :return: Name of the color. + :rtype: str + """ + return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black') + + +DEFAULT_COLORMAPS = ( + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') +"""Tuple of supported colormap names.""" + +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""" + + +class Colormap(qt.QObject): + """Description of a colormap + + :param str name: Name of the colormap + :param tuple colors: optional, custom colormap. + Nx3 or Nx4 numpy array of RGB(A) colors, + either uint8 or float in [0, 1]. + If 'name' is None, then this array is used as the colormap. + :param str normalization: Normalization: 'linear' (default) or 'log' + :param float vmin: + Lower bound of the colormap or None for autoscale (default) + :param float vmax: + Upper bounds of the colormap or None for autoscale (default) + """ + + LINEAR = 'linear' + """constant for linear normalization""" + + LOGARITHM = 'log' + """constant for logarithmic normalization""" + + NORMALIZATIONS = (LINEAR, LOGARITHM) + """Tuple of managed normalizations""" + + sigChanged = qt.Signal() + """Signal emitted when the colormap has changed.""" + + def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): + qt.QObject.__init__(self) + 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." + m += ' Autoscale will be performed.' + m = m % (vmin, vmax) + _logger.warning(m) + vmin = None + vmax = None + + self._name = str(name) if name is not None else None + self._setColors(colors) + 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 + + :param numpy.ndarray colors: Array of float colors to convert + :return: colors as uint8 + :rtype: numpy.ndarray + """ + # 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 + else: + colors = numpy.array(colors, copy=False) + 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 + + 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`. + :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) + + 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 setName(self, name): + """Set the name of the colormap to use. + + :param str name: The name of the colormap. + At least the following names are supported: 'gray', + 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma'. + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + assert name in self.getSupportedColormaps() + self._name = str(name) + self._colors = None + self.sigChanged.emit() + + def getColormapLUT(self): + """Return the list of colors for the colormap or None if not set + + :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 + else: + return numpy.array(self._colors, copy=True) + + def setColormapLUT(self, colors): + """Set the colors of the colormap. + + :param numpy.ndarray colors: the colors of the LUT. + If float, it is converted from [0, 1] to uint8 range. + Otherwise it is casted to uint8. + + .. warning: this will set the value of name to None + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + self._setColors(colors) + if len(colors) is 0: + self._colors = None + + self._name = None + self.sigChanged.emit() + + def getNormalization(self): + """Return the normalization of the colormap ('log' or 'linear') + + :return: the normalization of the colormap + :rtype: str + """ + return self._normalization + + def setNormalization(self, norm): + """Set the norm ('log', 'linear') + + :param str norm: the norm to set + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + self._normalization = str(norm) + self.sigChanged.emit() + + def getVMin(self): + """Return the lower bound of the colormap + + :return: the lower bound of the colormap + :rtype: float or None + """ + return self._vmin + + def setVMin(self, vmin): + """Set the minimal value of the colormap + + :param float vmin: Lower bound of the colormap or None for autoscale + (default) + value) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmin is not None: + if self._vmax is not None and vmin > self._vmax: + err = "Can't set vmin because vmin >= vmax. " \ + "vmin = %s, vmax = %s" % (vmin, self._vmax) + raise ValueError(err) + + self._vmin = vmin + self.sigChanged.emit() + + def getVMax(self): + """Return the upper bounds of the colormap or None + + :return: the upper bounds of the colormap or None + :rtype: float or None + """ + return self._vmax + + def setVMax(self, vmax): + """Set the maximal value of the colormap + + :param float vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmax is not None: + if self._vmin is not None and vmax < self._vmin: + err = "Can't set vmax because vmax <= vmin. " \ + "vmin = %s, vmax = %s" % (self._vmin, vmax) + raise ValueError(err) + + self._vmax = vmax + self.sigChanged.emit() + + def isEditable(self): + """ Return if the colormap is editable or not + + :return: editable state of the colormap + :rtype: bool + """ + return self._editable + + def setEditable(self, editable): + """ + Set the editable state of the colormap + + :param bool editable: is the colormap editable + """ + assert type(editable) is bool + self._editable = editable + self.sigChanged.emit() + + def getColormapRange(self, data=None): + """Return (vmin, vmax) + + :return: the tuple vmin, vmax fitting vmin, vmax, normalization and + data if any given + :rtype: tuple + """ + vmin = self._vmin + vmax = self._vmax + assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters + + if self.getNormalization() == self.LOGARITHM: + # Handle negative bounds as autoscale + if vmin is not None and (vmin is not None and vmin <= 0.): + mess = 'negative vmin, moving to autoscale for lower bound' + _logger.warning(mess) + vmin = None + if vmax is not None and (vmax is not None and vmax <= 0.): + mess = 'negative vmax, moving to autoscale for upper bound' + _logger.warning(mess) + vmax = None + + if vmin is None or vmax is None: # Handle autoscale + # Get min/max from data + if data is not None: + data = numpy.array(data, copy=False) + if data.size == 0: # Fallback an array but no data + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + else: + if self.getNormalization() == self.LOGARITHM: + result = min_max(data, min_positive=True, finite=True) + min_ = result.min_positive # >0 or None + max_ = result.maximum # can be <= 0 + else: + min_, max_ = min_max(data, min_positive=False, finite=True) + + # Handle fallback + if min_ is None or not numpy.isfinite(min_): + min_ = self._getDefaultMin() + if max_ is None or not numpy.isfinite(max_): + max_ = self._getDefaultMax() + else: # Fallback if no data is provided + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + + 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 setVRange(self, vmin, vmax): + """Set the bounds of the colormap + + :param vmin: Lower bound of the colormap or None for autoscale + (default) + :param vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmin is not None and vmax is not None: + if vmin > vmax: + err = "Can't set vmin and vmax because vmin >= vmax " \ + "vmin = %s, vmax = %s" % (vmin, vmax) + raise ValueError(err) + + if self._vmin == vmin and self._vmax == vmax: + return + + self._vmin = vmin + self._vmax = vmax + self.sigChanged.emit() + + def __getitem__(self, item): + if item == 'autoscale': + return self.isAutoscale() + elif item == 'name': + return self.getName() + elif item == 'normalization': + return self.getNormalization() + elif item == 'vmin': + return self.getVMin() + elif item == 'vmax': + return self.getVMax() + elif item == 'colors': + return self.getColormapLUT() + else: + raise KeyError(item) + + def _toDict(self): + """Return the equivalent colormap as a dictionary + (old colormap representation) + + :return: the representation of the Colormap as a dictionary + :rtype: dict + """ + return { + 'name': self._name, + 'colors': copy_mdl.copy(self._colors), + 'vmin': self._vmin, + 'vmax': self._vmax, + 'autoscale': self.isAutoscale(), + 'normalization': self._normalization + } + + def _setFromDict(self, dic): + """Set values to the colormap from a dictionary + + :param dict dic: the colormap as a dictionary + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + name = dic['name'] if 'name' in dic else None + colors = dic['colors'] if 'colors' in dic else None + 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 recoginized (%s)' % normalization + raise ValueError(err) + + # If autoscale, then set boundaries to None + if dic.get('autoscale', False): + vmin, vmax = None, None + + self._name = name + self._colors = colors + self._vmin = vmin + self._vmax = vmax + self._autoscale = True if (vmin is None and vmax is None) else False + self._normalization = normalization + + self.sigChanged.emit() + + @staticmethod + def _fromDict(dic): + colormap = Colormap(name="") + colormap._setFromDict(dic) + return colormap + + def copy(self): + """Return a copy of the Colormap. + + :rtype: silx.gui.colors.Colormap + """ + return Colormap(name=self._name, + colors=copy_mdl.copy(self._colors), + vmin=self._vmin, + vmax=self._vmax, + normalization=self._normalization) + + def applyToData(self, data): + """Apply the colormap to the data + + :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) + + @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') + :rtype: tuple + """ + # FIXME: If possible remove dependency to the plot + from .plot.matplotlib import Colormap as MPLColormap + maps = MPLColormap.getSupportedColormaps() + return DEFAULT_COLORMAPS + maps + + def __str__(self): + return str(self._toDict()) + + def _getDefaultMin(self): + return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG + + def _getDefaultMax(self): + return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG + + def __eq__(self, other): + """Compare colormap values and not pointers""" + return (self.getName() == other.getName() and + self.getNormalization() == other.getNormalization() and + self.getVMin() == other.getVMin() and + self.getVMax() == other.getVMax() and + numpy.array_equal(self.getColormapLUT(), other.getColormapLUT()) + ) + + _SERIAL_VERSION = 1 + + def restoreState(self, byteArray): + """ + Read the colormap state from a QByteArray. + + :param qt.QByteArray byteArray: Stream containing the state + :return: True if the restoration sussseed + :rtype: bool + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly) + + className = stream.readQString() + if className != self.__class__.__name__: + _logger.warning("Classname mismatch. Found %s." % className) + return False + + version = stream.readUInt32() + if version != self._SERIAL_VERSION: + _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() + + # emit change event only once + old = self.blockSignals(True) + try: + self.setName(name) + self.setNormalization(normalization) + self.setVRange(vmin, vmax) + finally: + self.blockSignals(old) + self.sigChanged.emit() + return True + + def saveState(self): + """ + Save state of the colomap into a QDataStream. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + stream.writeQString(self.__class__.__name__) + stream.writeUInt32(self._SERIAL_VERSION) + stream.writeQString(self.getName()) + stream.writeBool(self.getVMin() is None) + if self.getVMin() is not None: + stream.writeQVariant(self.getVMin()) + stream.writeBool(self.getVMax() is None) + if self.getVMax() is not None: + stream.writeQVariant(self.getVMax()) + stream.writeQString(self.getNormalization()) + return data + + +_PREFERRED_COLORMAPS = None +""" +Tuple of preferred colormap names accessed with :meth:`preferredColormaps`. +""" + + +def preferredColormaps(): + """Returns the name of the preferred colormaps. + + This list is used by widgets allowing to change the colormap + like the :class:`ColormapDialog` as a subset of colormap choices. + + :rtype: tuple of str + """ + global _PREFERRED_COLORMAPS + if _PREFERRED_COLORMAPS is None: + _PREFERRED_COLORMAPS = DEFAULT_COLORMAPS + # Initialize preferred colormaps + setPreferredColormaps(('gray', 'reversed gray', + 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma', + 'hsv')) + return _PREFERRED_COLORMAPS + + +def setPreferredColormaps(colormaps): + """Set the list of preferred colormap names. + + Warning: If a colormap name is not available + it will be removed from the list. + + :param colormaps: Not empty list of colormap names + :type colormaps: iterable of str + :raise ValueError: if the list of available preferred colormaps is empty. + """ + supportedColormaps = Colormap.getSupportedColormaps() + colormaps = tuple( + 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 -- cgit v1.2.3