summaryrefslogtreecommitdiff
path: root/silx/gui/colors.py
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/colors.py')
-rwxr-xr-xsilx/gui/colors.py429
1 files changed, 358 insertions, 71 deletions
diff --git a/silx/gui/colors.py b/silx/gui/colors.py
index 365b569..4d750ba 100755
--- a/silx/gui/colors.py
+++ b/silx/gui/colors.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2020 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
@@ -35,9 +35,8 @@ 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.math import colormap as _colormap
from silx.utils.exceptions import NotEditableError
from silx.utils import deprecation
from silx.resources import resource_filename as _resource_filename
@@ -91,16 +90,16 @@ _LUT_DESCRIPTION = collections.namedtuple("_LUT_DESCRIPTION", ["source", "cursor
_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)),
('cividis', _LUT_DESCRIPTION('resource', 'pink', True)),
('magma', _LUT_DESCRIPTION('resource', 'green', True)),
('inferno', _LUT_DESCRIPTION('resource', 'green', True)),
('plasma', _LUT_DESCRIPTION('resource', 'green', True)),
+ ('temperature', _LUT_DESCRIPTION('builtin', 'pink', True)),
+ ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)),
('hsv', _LUT_DESCRIPTION('matplotlib', 'black', True)),
])
"""Description for internal porpose of all the default LUT provided by the library."""
@@ -110,10 +109,6 @@ 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):
@@ -331,6 +326,167 @@ def _getColormap(name):
return _COLORMAP_CACHE[name]
+# Normalizations
+
+class _NormalizationMixIn:
+ """Colormap normalization mix-in class"""
+
+ DEFAULT_RANGE = 0, 1
+ """Fallback for (vmin, vmax)"""
+
+ def isValid(self, value):
+ """Check if a value is in the valid range for this normalization.
+
+ Override in subclass.
+
+ :param Union[float,numpy.ndarray] value:
+ :rtype: Union[bool,numpy.ndarray]
+ """
+ if isinstance(value, collections.abc.Iterable):
+ return numpy.ones_like(value, dtype=numpy.bool_)
+ else:
+ return True
+
+ def autoscale(self, data, mode):
+ """Returns range for given data and autoscale mode.
+
+ :param Union[None,numpy.ndarray] data:
+ :param str mode: Autoscale mode, see :class:`Colormap`
+ :returns: Range as (min, max)
+ :rtype: Tuple[float,float]
+ """
+ data = None if data is None else numpy.array(data, copy=False)
+ if data is None or data.size == 0:
+ return self.DEFAULT_RANGE
+
+ if mode == Colormap.MINMAX:
+ vmin, vmax = self.autoscaleMinMax(data)
+ elif mode == Colormap.STDDEV3:
+ vmin, vmax = self.autoscaleMean3Std(data)
+ else:
+ raise ValueError('Unsupported mode: %s' % mode)
+
+ # Check returned range and handle fallbacks
+ if vmin is None or not numpy.isfinite(vmin):
+ vmin = self.DEFAULT_RANGE[0]
+ if vmax is None or not numpy.isfinite(vmax):
+ vmax = self.DEFAULT_RANGE[1]
+ if vmax < vmin:
+ vmax = vmin
+ return float(vmin), float(vmax)
+
+ def autoscaleMinMax(self, data):
+ """Autoscale using min/max
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ data = data[self.isValid(data)]
+ if data.size == 0:
+ return None, None
+ result = min_max(data, min_positive=False, finite=True)
+ return result.minimum, result.maximum
+
+ def autoscaleMean3Std(self, data):
+ """Autoscale using mean+/-3std
+
+ This implementation only works for normalization that do NOT
+ use the data range.
+ Override this method for normalization using the range.
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ # Use [0, 1] as data range for normalization not using range
+ normdata = self.apply(data, 0., 1.)
+ if normdata.dtype.kind == 'f': # Replaces inf by NaN
+ normdata[numpy.isfinite(normdata) == False] = numpy.nan
+ if normdata.size == 0: # Fallback
+ return None, None
+ mean, std = numpy.nanmean(normdata), numpy.nanstd(normdata)
+ return self.revert(mean - 3 * std, 0., 1.), self.revert(mean + 3 * std, 0., 1.)
+
+
+class _LinearNormalizationMixIn(_NormalizationMixIn):
+ """Colormap normalization mix-in class specific to autoscale taken from initial range"""
+
+ def autoscaleMean3Std(self, data):
+ """Autoscale using mean+/-3std
+
+ Do the autoscale on the data itself, not the normalized data.
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ if data.dtype.kind == 'f': # Replaces inf by NaN
+ data = numpy.array(data, copy=True) # Work on a copy
+ data[numpy.isfinite(data) == False] = numpy.nan
+ if data.size == 0: # Fallback
+ return None, None
+ mean, std = numpy.nanmean(data), numpy.nanstd(data)
+ return mean - 3 * std, mean + 3 * std
+
+
+class _LinearNormalization(_colormap.LinearNormalization, _LinearNormalizationMixIn):
+ """Linear normalization"""
+ def __init__(self):
+ _colormap.LinearNormalization.__init__(self)
+ _LinearNormalizationMixIn.__init__(self)
+
+
+class _LogarithmicNormalization(_colormap.LogarithmicNormalization, _NormalizationMixIn):
+ """Logarithm normalization"""
+
+ DEFAULT_RANGE = 1, 10
+
+ def __init__(self):
+ _colormap.LogarithmicNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+ def isValid(self, value):
+ return value > 0.
+
+ def autoscaleMinMax(self, data):
+ result = min_max(data, min_positive=True, finite=True)
+ return result.min_positive, result.maximum
+
+
+class _SqrtNormalization(_colormap.SqrtNormalization, _NormalizationMixIn):
+ """Square root normalization"""
+
+ DEFAULT_RANGE = 0, 1
+
+ def __init__(self):
+ _colormap.SqrtNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+ def isValid(self, value):
+ return value >= 0.
+
+
+class _GammaNormalization(_colormap.PowerNormalization, _LinearNormalizationMixIn):
+ """Gamma correction normalization:
+
+ Linear normalization to [0, 1] followed by power normalization.
+
+ :param gamma: Gamma correction factor
+ """
+ def __init__(self, gamma):
+ _colormap.PowerNormalization.__init__(self, gamma)
+ _LinearNormalizationMixIn.__init__(self)
+
+
+class _ArcsinhNormalization(_colormap.ArcsinhNormalization, _NormalizationMixIn):
+ """Inverse hyperbolic sine normalization"""
+
+ def __init__(self):
+ _colormap.ArcsinhNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+
class Colormap(qt.QObject):
"""Description of a colormap
@@ -342,10 +498,10 @@ class Colormap(qt.QObject):
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)
+ :param vmin: Lower bound of the colormap or None for autoscale (default)
+ :type vmin: Union[None, float]
+ :param vmax: Upper bounds of the colormap or None for autoscale (default)
+ :type vmax: Union[None, float]
"""
LINEAR = 'linear'
@@ -354,17 +510,46 @@ class Colormap(qt.QObject):
LOGARITHM = 'log'
"""constant for logarithmic normalization"""
- NORMALIZATIONS = (LINEAR, LOGARITHM)
+ 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: _LinearNormalization(),
+ LOGARITHM: _LogarithmicNormalization(),
+ SQRT: _SqrtNormalization(),
+ ARCSINH: _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)"""
+
+ AUTOSCALE_MODES = (MINMAX, STDDEV3)
+ """Tuple of managed auto scale algorithms"""
+
sigChanged = qt.Signal()
"""Signal emitted when the colormap has changed."""
- def __init__(self, name=None, colors=None, normalization=LINEAR, vmin=None, vmax=None):
+ def __init__(self, name=None, colors=None, normalization=LINEAR, vmin=None, vmax=None, autoscaleMode=MINMAX):
qt.QObject.__init__(self)
self._editable = True
+ self.__gamma = 2.0
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."
@@ -395,6 +580,7 @@ class Colormap(qt.QObject):
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
@@ -432,11 +618,12 @@ class Colormap(qt.QObject):
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=None, vmax=None)
+ colormap.setVRange(vmin=0, vmax=nbColors - 1)
colors = colormap.applyToData(
- numpy.arange(int(nbColors), dtype=numpy.int))
+ numpy.arange(nbColors, dtype=numpy.int))
return colors
def getName(self):
@@ -503,7 +690,9 @@ class Colormap(qt.QObject):
self.sigChanged.emit()
def getNormalization(self):
- """Return the normalization of the colormap ('log' or 'linear')
+ """Return the normalization of the colormap.
+
+ See :meth:`setNormalization` for returned values.
:return: the normalization of the colormap
:rtype: str
@@ -511,15 +700,58 @@ class Colormap(qt.QObject):
return self._normalization
def setNormalization(self, norm):
- """Set the norm ('log', 'linear')
+ """Set the colormap normalization.
+
+ Accepted normalizations: 'log', 'linear', 'sqrt'
:param str norm: the norm to set
"""
+ assert norm in self.NORMALIZATIONS
if self.isEditable() is False:
raise NotEditableError('Colormap is not editable')
self._normalization = str(norm)
self.sigChanged.emit()
+ def setGammaNormalizationParameter(self, gamma: float) -> None:
+ """Set the gamma correction parameter.
+
+ Only used for gamma correction normalization.
+
+ :param float gamma:
+ :raise ValueError: If gamma is not valid
+ """
+ if gamma < 0. or not numpy.isfinite(gamma):
+ raise ValueError("Gamma value not supported")
+ if gamma != self.__gamma:
+ self.__gamma = gamma
+ self.sigChanged.emit()
+
+ def getGammaNormalizationParameter(self) -> float:
+ """Returns the gamma correction parameter value.
+
+ :rtype: float
+ """
+ return self.__gamma
+
+ def getAutoscaleMode(self):
+ """Return the autoscale mode of the colormap ('minmax' or 'stddev3')
+
+ :rtype: str
+ """
+ return self._autoscaleMode
+
+ def setAutoscaleMode(self, mode):
+ """Set the autoscale mode: either 'minmax' or 'stddev3'
+
+ :param str 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()
+
def isAutoscale(self):
"""Return True if both min and max are in autoscale mode"""
return self._vmin is None and self._vmax is None
@@ -593,49 +825,57 @@ class Colormap(qt.QObject):
self._editable = editable
self.sigChanged.emit()
+ def _getNormalizer(self):
+ """Returns normalizer object"""
+ normalization = self.getNormalization()
+ if normalization == self.GAMMA:
+ return _GammaNormalization(self.getGammaNormalizationParameter())
+ else:
+ return self._BASIC_NORMALIZATIONS[normalization]
+
+ def _computeAutoscaleRange(self, data):
+ """Compute the data range which will be used in autoscale mode.
+
+ :param numpy.ndarray data: The data for which to compute the range
+ :return: (vmin, vmax) range
+ """
+ return self._getNormalizer().autoscale(
+ data, mode=self.getAutoscaleMode())
+
def getColormapRange(self, data=None):
- """Return (vmin, vmax)
+ """Return (vmin, vmax) the range of the colormap for the given data or item.
- :return: the tuple vmin, vmax fitting vmin, vmax, normalization and
- data if any given
+ :param Union[numpy.ndarray,~silx.gui.plot.items.ColormapMixIn] data:
+ The data or item to use for autoscale bounds.
+ :return: (vmin, vmax) corresponding to the colormap applied to data if provided.
: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
+ normalizer = self._getNormalizer()
+
+ # Handle invalid bounds as autoscale
+ if vmin is not None and not normalizer.isValid(vmin):
+ _logger.info(
+ 'Invalid vmin, switching to autoscale for lower bound')
+ vmin = None
+ if vmax is not None and not normalizer.isValid(vmax):
+ _logger.info(
+ 'Invalid vmax, switching to autoscale for upper bound')
+ 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()
+ from .plot.items.core import ColormapMixIn # avoid cyclic import
+ if isinstance(data, ColormapMixIn):
+ 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)
@@ -645,6 +885,15 @@ class Colormap(qt.QObject):
return vmin, vmax
+ def getVRange(self):
+ """Get the bounds of the colormap
+
+ :rtype: Tuple(Union[float,None],Union[float,None])
+ :returns: A tuple of 2 values for min and max. Or None instead of float
+ for autoscale
+ """
+ return self.getVMin(), self.getVMax()
+
def setVRange(self, vmin, vmax):
"""Set the bounds of the colormap
@@ -681,6 +930,8 @@ class Colormap(qt.QObject):
return self.getVMax()
elif item == 'colors':
return self.getColormapLUT()
+ elif item == 'autoscaleMode':
+ return self.getAutoscaleMode()
else:
raise KeyError(item)
@@ -697,8 +948,9 @@ class Colormap(qt.QObject):
'vmin': self._vmin,
'vmax': self._vmax,
'autoscale': self.isAutoscale(),
- 'normalization': self._normalization
- }
+ 'normalization': self.getNormalization(),
+ 'autoscaleMode': self.getAutoscaleMode(),
+ }
def _setFromDict(self, dic):
"""Set values to the colormap from a dictionary
@@ -728,7 +980,12 @@ class Colormap(qt.QObject):
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
+ 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
@@ -743,6 +1000,7 @@ class Colormap(qt.QObject):
self._vmax = vmax
self._autoscale = True if (vmin is None and vmax is None) else False
self._normalization = normalization
+ self._autoscaleMode = autoscaleMode
self.sigChanged.emit()
@@ -757,20 +1015,33 @@ class Colormap(qt.QObject):
:rtype: silx.gui.colors.Colormap
"""
- return Colormap(name=self._name,
+ colormap = Colormap(name=self._name,
colors=self.getColormapLUT(),
vmin=self._vmin,
vmax=self._vmax,
- normalization=self._normalization)
+ normalization=self.getNormalization(),
+ autoscaleMode=self.getAutoscaleMode())
+ colormap.setGammaNormalizationParameter(
+ self.getGammaNormalizationParameter())
+ return colormap
- def applyToData(self, data):
+ def applyToData(self, data, reference=None):
"""Apply the colormap to the data
- :param numpy.ndarray data: The data to convert.
+ :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn] data:
+ The data to convert or the item for which to apply the colormap.
+ :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn,None] reference:
+ The data or item to use as reference to compute autoscale
"""
- vmin, vmax = self.getColormapRange(data)
- normalization = self.getNormalization()
- return _cmap(data, self._colors, vmin, vmax, normalization)
+ if reference is None:
+ reference = data
+ vmin, vmax = self.getColormapRange(reference)
+
+ if hasattr(data, "getColormappedData"): # Use item's data
+ data = data.getColormappedData()
+
+ return _colormap.cmap(
+ data, self._colors, vmin, vmax, self._getNormalizer())
@staticmethod
def getSupportedColormaps():
@@ -796,26 +1067,26 @@ class Colormap(qt.QObject):
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"""
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.getNormalization() == other.getNormalization() 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 = 1
+ _SERIAL_VERSION = 2
def restoreState(self, byteArray):
"""
@@ -835,7 +1106,7 @@ class Colormap(qt.QObject):
return False
version = stream.readUInt32()
- if version != self._SERIAL_VERSION:
+ if version not in (1, self._SERIAL_VERSION):
_logger.warning("Serial version mismatch. Found %d." % version)
return False
@@ -850,14 +1121,27 @@ class Colormap(qt.QObject):
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()
# 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)
finally:
self.blockSignals(old)
self.sigChanged.emit()
@@ -882,6 +1166,9 @@ class Colormap(qt.QObject):
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())
return data