summaryrefslogtreecommitdiff
path: root/silx/gui/colors.py
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/colors.py')
-rw-r--r--silx/gui/colors.py454
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