2386 lines
86 KiB
Python
2386 lines
86 KiB
Python
|
"""
|
||
|
A module for converting numbers or color arguments to *RGB* or *RGBA*.
|
||
|
|
||
|
*RGB* and *RGBA* are sequences of, respectively, 3 or 4 floats in the
|
||
|
range 0-1.
|
||
|
|
||
|
This module includes functions and classes for color specification conversions,
|
||
|
and for mapping numbers to colors in a 1-D array of colors called a colormap.
|
||
|
|
||
|
Mapping data onto colors using a colormap typically involves two steps: a data
|
||
|
array is first mapped onto the range 0-1 using a subclass of `Normalize`,
|
||
|
then this number is mapped to a color using a subclass of `Colormap`. Two
|
||
|
subclasses of `Colormap` provided here: `LinearSegmentedColormap`, which uses
|
||
|
piecewise-linear interpolation to define colormaps, and `ListedColormap`, which
|
||
|
makes a colormap from a list of colors.
|
||
|
|
||
|
.. seealso::
|
||
|
|
||
|
:doc:`/tutorials/colors/colormap-manipulation` for examples of how to
|
||
|
make colormaps and
|
||
|
|
||
|
:doc:`/tutorials/colors/colormaps` for a list of built-in colormaps.
|
||
|
|
||
|
:doc:`/tutorials/colors/colormapnorms` for more details about data
|
||
|
normalization
|
||
|
|
||
|
More colormaps are available at palettable_.
|
||
|
|
||
|
The module also provides functions for checking whether an object can be
|
||
|
interpreted as a color (`is_color_like`), for converting such an object
|
||
|
to an RGBA tuple (`to_rgba`) or to an HTML-like hex string in the
|
||
|
"#rrggbb" format (`to_hex`), and a sequence of colors to an (n, 4)
|
||
|
RGBA array (`to_rgba_array`). Caching is used for efficiency.
|
||
|
|
||
|
Matplotlib recognizes the following formats to specify a color:
|
||
|
|
||
|
* an RGB or RGBA (red, green, blue, alpha) tuple of float values in closed
|
||
|
interval ``[0, 1]`` (e.g., ``(0.1, 0.2, 0.5)`` or ``(0.1, 0.2, 0.5, 0.3)``);
|
||
|
* a hex RGB or RGBA string (e.g., ``'#0f0f0f'`` or ``'#0f0f0f80'``;
|
||
|
case-insensitive);
|
||
|
* a shorthand hex RGB or RGBA string, equivalent to the hex RGB or RGBA
|
||
|
string obtained by duplicating each character, (e.g., ``'#abc'``, equivalent
|
||
|
to ``'#aabbcc'``, or ``'#abcd'``, equivalent to ``'#aabbccdd'``;
|
||
|
case-insensitive);
|
||
|
* a string representation of a float value in ``[0, 1]`` inclusive for gray
|
||
|
level (e.g., ``'0.5'``);
|
||
|
* one of the characters ``{'b', 'g', 'r', 'c', 'm', 'y', 'k', 'w'}``, which
|
||
|
are short-hand notations for shades of blue, green, red, cyan, magenta,
|
||
|
yellow, black, and white. Note that the colors ``'g', 'c', 'm', 'y'`` do not
|
||
|
coincide with the X11/CSS4 colors. Their particular shades were chosen for
|
||
|
better visibility of colored lines against typical backgrounds.
|
||
|
* a X11/CSS4 color name (case-insensitive);
|
||
|
* a name from the `xkcd color survey`_, prefixed with ``'xkcd:'`` (e.g.,
|
||
|
``'xkcd:sky blue'``; case insensitive);
|
||
|
* one of the Tableau Colors from the 'T10' categorical palette (the default
|
||
|
color cycle): ``{'tab:blue', 'tab:orange', 'tab:green', 'tab:red',
|
||
|
'tab:purple', 'tab:brown', 'tab:pink', 'tab:gray', 'tab:olive', 'tab:cyan'}``
|
||
|
(case-insensitive);
|
||
|
* a "CN" color spec, i.e. 'C' followed by a number, which is an index into the
|
||
|
default property cycle (:rc:`axes.prop_cycle`); the indexing is intended to
|
||
|
occur at rendering time, and defaults to black if the cycle does not include
|
||
|
color.
|
||
|
|
||
|
.. _palettable: https://jiffyclub.github.io/palettable/
|
||
|
.. _xkcd color survey: https://xkcd.com/color/rgb/
|
||
|
"""
|
||
|
|
||
|
import base64
|
||
|
from collections.abc import Sized, Sequence
|
||
|
import copy
|
||
|
import functools
|
||
|
import inspect
|
||
|
import io
|
||
|
import itertools
|
||
|
from numbers import Number
|
||
|
import re
|
||
|
from PIL import Image
|
||
|
from PIL.PngImagePlugin import PngInfo
|
||
|
|
||
|
import matplotlib as mpl
|
||
|
import numpy as np
|
||
|
from matplotlib import _api, cbook, scale
|
||
|
from ._color_data import BASE_COLORS, TABLEAU_COLORS, CSS4_COLORS, XKCD_COLORS
|
||
|
|
||
|
|
||
|
class _ColorMapping(dict):
|
||
|
def __init__(self, mapping):
|
||
|
super().__init__(mapping)
|
||
|
self.cache = {}
|
||
|
|
||
|
def __setitem__(self, key, value):
|
||
|
super().__setitem__(key, value)
|
||
|
self.cache.clear()
|
||
|
|
||
|
def __delitem__(self, key):
|
||
|
super().__delitem__(key)
|
||
|
self.cache.clear()
|
||
|
|
||
|
|
||
|
_colors_full_map = {}
|
||
|
# Set by reverse priority order.
|
||
|
_colors_full_map.update(XKCD_COLORS)
|
||
|
_colors_full_map.update({k.replace('grey', 'gray'): v
|
||
|
for k, v in XKCD_COLORS.items()
|
||
|
if 'grey' in k})
|
||
|
_colors_full_map.update(CSS4_COLORS)
|
||
|
_colors_full_map.update(TABLEAU_COLORS)
|
||
|
_colors_full_map.update({k.replace('gray', 'grey'): v
|
||
|
for k, v in TABLEAU_COLORS.items()
|
||
|
if 'gray' in k})
|
||
|
_colors_full_map.update(BASE_COLORS)
|
||
|
_colors_full_map = _ColorMapping(_colors_full_map)
|
||
|
|
||
|
_REPR_PNG_SIZE = (512, 64)
|
||
|
|
||
|
|
||
|
def get_named_colors_mapping():
|
||
|
"""Return the global mapping of names to named colors."""
|
||
|
return _colors_full_map
|
||
|
|
||
|
|
||
|
def _sanitize_extrema(ex):
|
||
|
if ex is None:
|
||
|
return ex
|
||
|
try:
|
||
|
ret = ex.item()
|
||
|
except AttributeError:
|
||
|
ret = float(ex)
|
||
|
return ret
|
||
|
|
||
|
|
||
|
def _is_nth_color(c):
|
||
|
"""Return whether *c* can be interpreted as an item in the color cycle."""
|
||
|
return isinstance(c, str) and re.match(r"\AC[0-9]+\Z", c)
|
||
|
|
||
|
|
||
|
def is_color_like(c):
|
||
|
"""Return whether *c* can be interpreted as an RGB(A) color."""
|
||
|
# Special-case nth color syntax because it cannot be parsed during setup.
|
||
|
if _is_nth_color(c):
|
||
|
return True
|
||
|
try:
|
||
|
to_rgba(c)
|
||
|
except ValueError:
|
||
|
return False
|
||
|
else:
|
||
|
return True
|
||
|
|
||
|
|
||
|
def _check_color_like(**kwargs):
|
||
|
"""
|
||
|
For each *key, value* pair in *kwargs*, check that *value* is color-like.
|
||
|
"""
|
||
|
for k, v in kwargs.items():
|
||
|
if not is_color_like(v):
|
||
|
raise ValueError(f"{v!r} is not a valid value for {k}")
|
||
|
|
||
|
|
||
|
def same_color(c1, c2):
|
||
|
"""
|
||
|
Return whether the colors *c1* and *c2* are the same.
|
||
|
|
||
|
*c1*, *c2* can be single colors or lists/arrays of colors.
|
||
|
"""
|
||
|
c1 = to_rgba_array(c1)
|
||
|
c2 = to_rgba_array(c2)
|
||
|
n1 = max(c1.shape[0], 1) # 'none' results in shape (0, 4), but is 1-elem
|
||
|
n2 = max(c2.shape[0], 1) # 'none' results in shape (0, 4), but is 1-elem
|
||
|
|
||
|
if n1 != n2:
|
||
|
raise ValueError('Different number of elements passed.')
|
||
|
# The following shape test is needed to correctly handle comparisons with
|
||
|
# 'none', which results in a shape (0, 4) array and thus cannot be tested
|
||
|
# via value comparison.
|
||
|
return c1.shape == c2.shape and (c1 == c2).all()
|
||
|
|
||
|
|
||
|
def to_rgba(c, alpha=None):
|
||
|
"""
|
||
|
Convert *c* to an RGBA color.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
c : Matplotlib color or ``np.ma.masked``
|
||
|
|
||
|
alpha : float, optional
|
||
|
If *alpha* is not ``None``, it forces the alpha value, except if *c* is
|
||
|
``"none"`` (case-insensitive), which always maps to ``(0, 0, 0, 0)``.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
tuple
|
||
|
Tuple of ``(r, g, b, a)`` scalars.
|
||
|
"""
|
||
|
# Special-case nth color syntax because it should not be cached.
|
||
|
if _is_nth_color(c):
|
||
|
from matplotlib import rcParams
|
||
|
prop_cycler = rcParams['axes.prop_cycle']
|
||
|
colors = prop_cycler.by_key().get('color', ['k'])
|
||
|
c = colors[int(c[1:]) % len(colors)]
|
||
|
try:
|
||
|
rgba = _colors_full_map.cache[c, alpha]
|
||
|
except (KeyError, TypeError): # Not in cache, or unhashable.
|
||
|
rgba = None
|
||
|
if rgba is None: # Suppress exception chaining of cache lookup failure.
|
||
|
rgba = _to_rgba_no_colorcycle(c, alpha)
|
||
|
try:
|
||
|
_colors_full_map.cache[c, alpha] = rgba
|
||
|
except TypeError:
|
||
|
pass
|
||
|
return rgba
|
||
|
|
||
|
|
||
|
def _to_rgba_no_colorcycle(c, alpha=None):
|
||
|
"""
|
||
|
Convert *c* to an RGBA color, with no support for color-cycle syntax.
|
||
|
|
||
|
If *alpha* is not ``None``, it forces the alpha value, except if *c* is
|
||
|
``"none"`` (case-insensitive), which always maps to ``(0, 0, 0, 0)``.
|
||
|
"""
|
||
|
orig_c = c
|
||
|
if c is np.ma.masked:
|
||
|
return (0., 0., 0., 0.)
|
||
|
if isinstance(c, str):
|
||
|
if c.lower() == "none":
|
||
|
return (0., 0., 0., 0.)
|
||
|
# Named color.
|
||
|
try:
|
||
|
# This may turn c into a non-string, so we check again below.
|
||
|
c = _colors_full_map[c]
|
||
|
except KeyError:
|
||
|
if len(orig_c) != 1:
|
||
|
try:
|
||
|
c = _colors_full_map[c.lower()]
|
||
|
except KeyError:
|
||
|
pass
|
||
|
if isinstance(c, str):
|
||
|
# hex color in #rrggbb format.
|
||
|
match = re.match(r"\A#[a-fA-F0-9]{6}\Z", c)
|
||
|
if match:
|
||
|
return (tuple(int(n, 16) / 255
|
||
|
for n in [c[1:3], c[3:5], c[5:7]])
|
||
|
+ (alpha if alpha is not None else 1.,))
|
||
|
# hex color in #rgb format, shorthand for #rrggbb.
|
||
|
match = re.match(r"\A#[a-fA-F0-9]{3}\Z", c)
|
||
|
if match:
|
||
|
return (tuple(int(n, 16) / 255
|
||
|
for n in [c[1]*2, c[2]*2, c[3]*2])
|
||
|
+ (alpha if alpha is not None else 1.,))
|
||
|
# hex color with alpha in #rrggbbaa format.
|
||
|
match = re.match(r"\A#[a-fA-F0-9]{8}\Z", c)
|
||
|
if match:
|
||
|
color = [int(n, 16) / 255
|
||
|
for n in [c[1:3], c[3:5], c[5:7], c[7:9]]]
|
||
|
if alpha is not None:
|
||
|
color[-1] = alpha
|
||
|
return tuple(color)
|
||
|
# hex color with alpha in #rgba format, shorthand for #rrggbbaa.
|
||
|
match = re.match(r"\A#[a-fA-F0-9]{4}\Z", c)
|
||
|
if match:
|
||
|
color = [int(n, 16) / 255
|
||
|
for n in [c[1]*2, c[2]*2, c[3]*2, c[4]*2]]
|
||
|
if alpha is not None:
|
||
|
color[-1] = alpha
|
||
|
return tuple(color)
|
||
|
# string gray.
|
||
|
try:
|
||
|
c = float(c)
|
||
|
except ValueError:
|
||
|
pass
|
||
|
else:
|
||
|
if not (0 <= c <= 1):
|
||
|
raise ValueError(
|
||
|
f"Invalid string grayscale value {orig_c!r}. "
|
||
|
f"Value must be within 0-1 range")
|
||
|
return c, c, c, alpha if alpha is not None else 1.
|
||
|
raise ValueError(f"Invalid RGBA argument: {orig_c!r}")
|
||
|
# turn 2-D array into 1-D array
|
||
|
if isinstance(c, np.ndarray):
|
||
|
if c.ndim == 2 and c.shape[0] == 1:
|
||
|
c = c.reshape(-1)
|
||
|
# tuple color.
|
||
|
if not np.iterable(c):
|
||
|
raise ValueError(f"Invalid RGBA argument: {orig_c!r}")
|
||
|
if len(c) not in [3, 4]:
|
||
|
raise ValueError("RGBA sequence should have length 3 or 4")
|
||
|
if not all(isinstance(x, Number) for x in c):
|
||
|
# Checks that don't work: `map(float, ...)`, `np.array(..., float)` and
|
||
|
# `np.array(...).astype(float)` would all convert "0.5" to 0.5.
|
||
|
raise ValueError(f"Invalid RGBA argument: {orig_c!r}")
|
||
|
# Return a tuple to prevent the cached value from being modified.
|
||
|
c = tuple(map(float, c))
|
||
|
if len(c) == 3 and alpha is None:
|
||
|
alpha = 1
|
||
|
if alpha is not None:
|
||
|
c = c[:3] + (alpha,)
|
||
|
if any(elem < 0 or elem > 1 for elem in c):
|
||
|
raise ValueError("RGBA values should be within 0-1 range")
|
||
|
return c
|
||
|
|
||
|
|
||
|
def to_rgba_array(c, alpha=None):
|
||
|
"""
|
||
|
Convert *c* to a (n, 4) array of RGBA colors.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
c : Matplotlib color or array of colors
|
||
|
If *c* is a masked array, an ndarray is returned with a (0, 0, 0, 0)
|
||
|
row for each masked value or row in *c*.
|
||
|
|
||
|
alpha : float or sequence of floats, optional
|
||
|
If *alpha* is not ``None``, it forces the alpha value, except if *c* is
|
||
|
``"none"`` (case-insensitive), which always maps to ``(0, 0, 0, 0)``.
|
||
|
If *alpha* is a sequence and *c* is a single color, *c* will be
|
||
|
repeated to match the length of *alpha*.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
array
|
||
|
(n, 4) array of RGBA colors.
|
||
|
|
||
|
"""
|
||
|
# Special-case inputs that are already arrays, for performance. (If the
|
||
|
# array has the wrong kind or shape, raise the error during one-at-a-time
|
||
|
# conversion.)
|
||
|
if np.iterable(alpha):
|
||
|
alpha = np.asarray(alpha).ravel()
|
||
|
if (isinstance(c, np.ndarray) and c.dtype.kind in "if"
|
||
|
and c.ndim == 2 and c.shape[1] in [3, 4]):
|
||
|
mask = c.mask.any(axis=1) if np.ma.is_masked(c) else None
|
||
|
c = np.ma.getdata(c)
|
||
|
if np.iterable(alpha):
|
||
|
if c.shape[0] == 1 and alpha.shape[0] > 1:
|
||
|
c = np.tile(c, (alpha.shape[0], 1))
|
||
|
elif c.shape[0] != alpha.shape[0]:
|
||
|
raise ValueError("The number of colors must match the number"
|
||
|
" of alpha values if there are more than one"
|
||
|
" of each.")
|
||
|
if c.shape[1] == 3:
|
||
|
result = np.column_stack([c, np.zeros(len(c))])
|
||
|
result[:, -1] = alpha if alpha is not None else 1.
|
||
|
elif c.shape[1] == 4:
|
||
|
result = c.copy()
|
||
|
if alpha is not None:
|
||
|
result[:, -1] = alpha
|
||
|
if mask is not None:
|
||
|
result[mask] = 0
|
||
|
if np.any((result < 0) | (result > 1)):
|
||
|
raise ValueError("RGBA values should be within 0-1 range")
|
||
|
return result
|
||
|
# Handle single values.
|
||
|
# Note that this occurs *after* handling inputs that are already arrays, as
|
||
|
# `to_rgba(c, alpha)` (below) is expensive for such inputs, due to the need
|
||
|
# to format the array in the ValueError message(!).
|
||
|
if cbook._str_lower_equal(c, "none"):
|
||
|
return np.zeros((0, 4), float)
|
||
|
try:
|
||
|
if np.iterable(alpha):
|
||
|
return np.array([to_rgba(c, a) for a in alpha], float)
|
||
|
else:
|
||
|
return np.array([to_rgba(c, alpha)], float)
|
||
|
except (ValueError, TypeError):
|
||
|
pass
|
||
|
|
||
|
if isinstance(c, str):
|
||
|
raise ValueError("Using a string of single character colors as "
|
||
|
"a color sequence is not supported. The colors can "
|
||
|
"be passed as an explicit list instead.")
|
||
|
|
||
|
if len(c) == 0:
|
||
|
return np.zeros((0, 4), float)
|
||
|
|
||
|
# Quick path if the whole sequence can be directly converted to a numpy
|
||
|
# array in one shot.
|
||
|
if isinstance(c, Sequence):
|
||
|
lens = {len(cc) if isinstance(cc, (list, tuple)) else -1 for cc in c}
|
||
|
if lens == {3}:
|
||
|
rgba = np.column_stack([c, np.ones(len(c))])
|
||
|
elif lens == {4}:
|
||
|
rgba = np.array(c)
|
||
|
else:
|
||
|
rgba = np.array([to_rgba(cc) for cc in c])
|
||
|
else:
|
||
|
rgba = np.array([to_rgba(cc) for cc in c])
|
||
|
|
||
|
if alpha is not None:
|
||
|
rgba[:, 3] = alpha
|
||
|
return rgba
|
||
|
|
||
|
|
||
|
def to_rgb(c):
|
||
|
"""Convert *c* to an RGB color, silently dropping the alpha channel."""
|
||
|
return to_rgba(c)[:3]
|
||
|
|
||
|
|
||
|
def to_hex(c, keep_alpha=False):
|
||
|
"""
|
||
|
Convert *c* to a hex color.
|
||
|
|
||
|
Uses the ``#rrggbb`` format if *keep_alpha* is False (the default),
|
||
|
``#rrggbbaa`` otherwise.
|
||
|
"""
|
||
|
c = to_rgba(c)
|
||
|
if not keep_alpha:
|
||
|
c = c[:3]
|
||
|
return "#" + "".join(format(int(round(val * 255)), "02x") for val in c)
|
||
|
|
||
|
|
||
|
### Backwards-compatible color-conversion API
|
||
|
|
||
|
|
||
|
cnames = CSS4_COLORS
|
||
|
hexColorPattern = re.compile(r"\A#[a-fA-F0-9]{6}\Z")
|
||
|
rgb2hex = to_hex
|
||
|
hex2color = to_rgb
|
||
|
|
||
|
|
||
|
class ColorConverter:
|
||
|
"""
|
||
|
A class only kept for backwards compatibility.
|
||
|
|
||
|
Its functionality is entirely provided by module-level functions.
|
||
|
"""
|
||
|
colors = _colors_full_map
|
||
|
cache = _colors_full_map.cache
|
||
|
to_rgb = staticmethod(to_rgb)
|
||
|
to_rgba = staticmethod(to_rgba)
|
||
|
to_rgba_array = staticmethod(to_rgba_array)
|
||
|
|
||
|
|
||
|
colorConverter = ColorConverter()
|
||
|
|
||
|
|
||
|
### End of backwards-compatible color-conversion API
|
||
|
|
||
|
|
||
|
def _create_lookup_table(N, data, gamma=1.0):
|
||
|
r"""
|
||
|
Create an *N* -element 1D lookup table.
|
||
|
|
||
|
This assumes a mapping :math:`f : [0, 1] \rightarrow [0, 1]`. The returned
|
||
|
data is an array of N values :math:`y = f(x)` where x is sampled from
|
||
|
[0, 1].
|
||
|
|
||
|
By default (*gamma* = 1) x is equidistantly sampled from [0, 1]. The
|
||
|
*gamma* correction factor :math:`\gamma` distorts this equidistant
|
||
|
sampling by :math:`x \rightarrow x^\gamma`.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
N : int
|
||
|
The number of elements of the created lookup table; at least 1.
|
||
|
|
||
|
data : (M, 3) array-like or callable
|
||
|
Defines the mapping :math:`f`.
|
||
|
|
||
|
If a (M, 3) array-like, the rows define values (x, y0, y1). The x
|
||
|
values must start with x=0, end with x=1, and all x values be in
|
||
|
increasing order.
|
||
|
|
||
|
A value between :math:`x_i` and :math:`x_{i+1}` is mapped to the range
|
||
|
:math:`y^1_{i-1} \ldots y^0_i` by linear interpolation.
|
||
|
|
||
|
For the simple case of a y-continuous mapping, y0 and y1 are identical.
|
||
|
|
||
|
The two values of y are to allow for discontinuous mapping functions.
|
||
|
E.g. a sawtooth with a period of 0.2 and an amplitude of 1 would be::
|
||
|
|
||
|
[(0, 1, 0), (0.2, 1, 0), (0.4, 1, 0), ..., [(1, 1, 0)]
|
||
|
|
||
|
In the special case of ``N == 1``, by convention the returned value
|
||
|
is y0 for x == 1.
|
||
|
|
||
|
If *data* is a callable, it must accept and return numpy arrays::
|
||
|
|
||
|
data(x : ndarray) -> ndarray
|
||
|
|
||
|
and map values between 0 - 1 to 0 - 1.
|
||
|
|
||
|
gamma : float
|
||
|
Gamma correction factor for input distribution x of the mapping.
|
||
|
|
||
|
See also https://en.wikipedia.org/wiki/Gamma_correction.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
array
|
||
|
The lookup table where ``lut[x * (N-1)]`` gives the closest value
|
||
|
for values of x between 0 and 1.
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
This function is internally used for `.LinearSegmentedColormap`.
|
||
|
"""
|
||
|
|
||
|
if callable(data):
|
||
|
xind = np.linspace(0, 1, N) ** gamma
|
||
|
lut = np.clip(np.array(data(xind), dtype=float), 0, 1)
|
||
|
return lut
|
||
|
|
||
|
try:
|
||
|
adata = np.array(data)
|
||
|
except Exception as err:
|
||
|
raise TypeError("data must be convertible to an array") from err
|
||
|
shape = adata.shape
|
||
|
if len(shape) != 2 or shape[1] != 3:
|
||
|
raise ValueError("data must be nx3 format")
|
||
|
|
||
|
x = adata[:, 0]
|
||
|
y0 = adata[:, 1]
|
||
|
y1 = adata[:, 2]
|
||
|
|
||
|
if x[0] != 0. or x[-1] != 1.0:
|
||
|
raise ValueError(
|
||
|
"data mapping points must start with x=0 and end with x=1")
|
||
|
if (np.diff(x) < 0).any():
|
||
|
raise ValueError("data mapping points must have x in increasing order")
|
||
|
# begin generation of lookup table
|
||
|
if N == 1:
|
||
|
# convention: use the y = f(x=1) value for a 1-element lookup table
|
||
|
lut = np.array(y0[-1])
|
||
|
else:
|
||
|
x = x * (N - 1)
|
||
|
xind = (N - 1) * np.linspace(0, 1, N) ** gamma
|
||
|
ind = np.searchsorted(x, xind)[1:-1]
|
||
|
|
||
|
distance = (xind[1:-1] - x[ind - 1]) / (x[ind] - x[ind - 1])
|
||
|
lut = np.concatenate([
|
||
|
[y1[0]],
|
||
|
distance * (y0[ind] - y1[ind - 1]) + y1[ind - 1],
|
||
|
[y0[-1]],
|
||
|
])
|
||
|
# ensure that the lut is confined to values between 0 and 1 by clipping it
|
||
|
return np.clip(lut, 0.0, 1.0)
|
||
|
|
||
|
|
||
|
def _warn_if_global_cmap_modified(cmap):
|
||
|
if getattr(cmap, '_global', False):
|
||
|
_api.warn_deprecated(
|
||
|
"3.3",
|
||
|
removal="3.6",
|
||
|
message="You are modifying the state of a globally registered "
|
||
|
"colormap. This has been deprecated since %(since)s and "
|
||
|
"%(removal)s, you will not be able to modify a "
|
||
|
"registered colormap in-place. To remove this warning, "
|
||
|
"you can make a copy of the colormap first. "
|
||
|
f'cmap = mpl.cm.get_cmap("{cmap.name}").copy()'
|
||
|
)
|
||
|
|
||
|
|
||
|
class Colormap:
|
||
|
"""
|
||
|
Baseclass for all scalar to RGBA mappings.
|
||
|
|
||
|
Typically, Colormap instances are used to convert data values (floats)
|
||
|
from the interval ``[0, 1]`` to the RGBA color that the respective
|
||
|
Colormap represents. For scaling of data into the ``[0, 1]`` interval see
|
||
|
`matplotlib.colors.Normalize`. Subclasses of `matplotlib.cm.ScalarMappable`
|
||
|
make heavy use of this ``data -> normalize -> map-to-color`` processing
|
||
|
chain.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, name, N=256):
|
||
|
"""
|
||
|
Parameters
|
||
|
----------
|
||
|
name : str
|
||
|
The name of the colormap.
|
||
|
N : int
|
||
|
The number of rgb quantization levels.
|
||
|
"""
|
||
|
self.name = name
|
||
|
self.N = int(N) # ensure that N is always int
|
||
|
self._rgba_bad = (0.0, 0.0, 0.0, 0.0) # If bad, don't paint anything.
|
||
|
self._rgba_under = None
|
||
|
self._rgba_over = None
|
||
|
self._i_under = self.N
|
||
|
self._i_over = self.N + 1
|
||
|
self._i_bad = self.N + 2
|
||
|
self._isinit = False
|
||
|
#: When this colormap exists on a scalar mappable and colorbar_extend
|
||
|
#: is not False, colorbar creation will pick up ``colorbar_extend`` as
|
||
|
#: the default value for the ``extend`` keyword in the
|
||
|
#: `matplotlib.colorbar.Colorbar` constructor.
|
||
|
self.colorbar_extend = False
|
||
|
|
||
|
def __call__(self, X, alpha=None, bytes=False):
|
||
|
"""
|
||
|
Parameters
|
||
|
----------
|
||
|
X : float or int, ndarray or scalar
|
||
|
The data value(s) to convert to RGBA.
|
||
|
For floats, X should be in the interval ``[0.0, 1.0]`` to
|
||
|
return the RGBA values ``X*100`` percent along the Colormap line.
|
||
|
For integers, X should be in the interval ``[0, Colormap.N)`` to
|
||
|
return RGBA values *indexed* from the Colormap with index ``X``.
|
||
|
alpha : float or array-like or None
|
||
|
Alpha must be a scalar between 0 and 1, a sequence of such
|
||
|
floats with shape matching X, or None.
|
||
|
bytes : bool
|
||
|
If False (default), the returned RGBA values will be floats in the
|
||
|
interval ``[0, 1]`` otherwise they will be uint8s in the interval
|
||
|
``[0, 255]``.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
Tuple of RGBA values if X is scalar, otherwise an array of
|
||
|
RGBA values with a shape of ``X.shape + (4, )``.
|
||
|
"""
|
||
|
if not self._isinit:
|
||
|
self._init()
|
||
|
|
||
|
mask_bad = X.mask if np.ma.is_masked(X) else np.isnan(X) # Mask nan's.
|
||
|
xa = np.array(X, copy=True)
|
||
|
if not xa.dtype.isnative:
|
||
|
xa = xa.byteswap().newbyteorder() # Native byteorder is faster.
|
||
|
if xa.dtype.kind == "f":
|
||
|
with np.errstate(invalid="ignore"):
|
||
|
xa *= self.N
|
||
|
# Negative values are out of range, but astype(int) would
|
||
|
# truncate them towards zero.
|
||
|
xa[xa < 0] = -1
|
||
|
# xa == 1 (== N after multiplication) is not out of range.
|
||
|
xa[xa == self.N] = self.N - 1
|
||
|
# Avoid converting large positive values to negative integers.
|
||
|
np.clip(xa, -1, self.N, out=xa)
|
||
|
xa = xa.astype(int)
|
||
|
# Set the over-range indices before the under-range;
|
||
|
# otherwise the under-range values get converted to over-range.
|
||
|
xa[xa > self.N - 1] = self._i_over
|
||
|
xa[xa < 0] = self._i_under
|
||
|
xa[mask_bad] = self._i_bad
|
||
|
|
||
|
if bytes:
|
||
|
lut = (self._lut * 255).astype(np.uint8)
|
||
|
else:
|
||
|
lut = self._lut.copy() # Don't let alpha modify original _lut.
|
||
|
|
||
|
rgba = np.empty(shape=xa.shape + (4,), dtype=lut.dtype)
|
||
|
lut.take(xa, axis=0, mode='clip', out=rgba)
|
||
|
|
||
|
if alpha is not None:
|
||
|
if np.iterable(alpha):
|
||
|
alpha = np.asarray(alpha)
|
||
|
if alpha.shape != xa.shape:
|
||
|
raise ValueError("alpha is array-like but its shape"
|
||
|
" %s doesn't match that of X %s" %
|
||
|
(alpha.shape, xa.shape))
|
||
|
alpha = np.clip(alpha, 0, 1)
|
||
|
if bytes:
|
||
|
alpha = (alpha * 255).astype(np.uint8)
|
||
|
rgba[..., -1] = alpha
|
||
|
|
||
|
# If the "bad" color is all zeros, then ignore alpha input.
|
||
|
if (lut[-1] == 0).all() and np.any(mask_bad):
|
||
|
if np.iterable(mask_bad) and mask_bad.shape == xa.shape:
|
||
|
rgba[mask_bad] = (0, 0, 0, 0)
|
||
|
else:
|
||
|
rgba[..., :] = (0, 0, 0, 0)
|
||
|
|
||
|
if not np.iterable(X):
|
||
|
rgba = tuple(rgba)
|
||
|
return rgba
|
||
|
|
||
|
def __copy__(self):
|
||
|
cls = self.__class__
|
||
|
cmapobject = cls.__new__(cls)
|
||
|
cmapobject.__dict__.update(self.__dict__)
|
||
|
if self._isinit:
|
||
|
cmapobject._lut = np.copy(self._lut)
|
||
|
cmapobject._global = False
|
||
|
return cmapobject
|
||
|
|
||
|
def get_bad(self):
|
||
|
"""Get the color for masked values."""
|
||
|
if not self._isinit:
|
||
|
self._init()
|
||
|
return np.array(self._lut[self._i_bad])
|
||
|
|
||
|
def set_bad(self, color='k', alpha=None):
|
||
|
"""Set the color for masked values."""
|
||
|
_warn_if_global_cmap_modified(self)
|
||
|
self._rgba_bad = to_rgba(color, alpha)
|
||
|
if self._isinit:
|
||
|
self._set_extremes()
|
||
|
|
||
|
def get_under(self):
|
||
|
"""Get the color for low out-of-range values."""
|
||
|
if not self._isinit:
|
||
|
self._init()
|
||
|
return np.array(self._lut[self._i_under])
|
||
|
|
||
|
def set_under(self, color='k', alpha=None):
|
||
|
"""Set the color for low out-of-range values."""
|
||
|
_warn_if_global_cmap_modified(self)
|
||
|
self._rgba_under = to_rgba(color, alpha)
|
||
|
if self._isinit:
|
||
|
self._set_extremes()
|
||
|
|
||
|
def get_over(self):
|
||
|
"""Get the color for high out-of-range values."""
|
||
|
if not self._isinit:
|
||
|
self._init()
|
||
|
return np.array(self._lut[self._i_over])
|
||
|
|
||
|
def set_over(self, color='k', alpha=None):
|
||
|
"""Set the color for high out-of-range values."""
|
||
|
_warn_if_global_cmap_modified(self)
|
||
|
self._rgba_over = to_rgba(color, alpha)
|
||
|
if self._isinit:
|
||
|
self._set_extremes()
|
||
|
|
||
|
def set_extremes(self, *, bad=None, under=None, over=None):
|
||
|
"""
|
||
|
Set the colors for masked (*bad*) values and, when ``norm.clip =
|
||
|
False``, low (*under*) and high (*over*) out-of-range values.
|
||
|
"""
|
||
|
if bad is not None:
|
||
|
self.set_bad(bad)
|
||
|
if under is not None:
|
||
|
self.set_under(under)
|
||
|
if over is not None:
|
||
|
self.set_over(over)
|
||
|
|
||
|
def with_extremes(self, *, bad=None, under=None, over=None):
|
||
|
"""
|
||
|
Return a copy of the colormap, for which the colors for masked (*bad*)
|
||
|
values and, when ``norm.clip = False``, low (*under*) and high (*over*)
|
||
|
out-of-range values, have been set accordingly.
|
||
|
"""
|
||
|
new_cm = copy.copy(self)
|
||
|
new_cm.set_extremes(bad=bad, under=under, over=over)
|
||
|
return new_cm
|
||
|
|
||
|
def _set_extremes(self):
|
||
|
if self._rgba_under:
|
||
|
self._lut[self._i_under] = self._rgba_under
|
||
|
else:
|
||
|
self._lut[self._i_under] = self._lut[0]
|
||
|
if self._rgba_over:
|
||
|
self._lut[self._i_over] = self._rgba_over
|
||
|
else:
|
||
|
self._lut[self._i_over] = self._lut[self.N - 1]
|
||
|
self._lut[self._i_bad] = self._rgba_bad
|
||
|
|
||
|
def _init(self):
|
||
|
"""Generate the lookup table, ``self._lut``."""
|
||
|
raise NotImplementedError("Abstract class only")
|
||
|
|
||
|
def is_gray(self):
|
||
|
"""Return whether the colormap is grayscale."""
|
||
|
if not self._isinit:
|
||
|
self._init()
|
||
|
return (np.all(self._lut[:, 0] == self._lut[:, 1]) and
|
||
|
np.all(self._lut[:, 0] == self._lut[:, 2]))
|
||
|
|
||
|
def _resample(self, lutsize):
|
||
|
"""Return a new colormap with *lutsize* entries."""
|
||
|
raise NotImplementedError()
|
||
|
|
||
|
def reversed(self, name=None):
|
||
|
"""
|
||
|
Return a reversed instance of the Colormap.
|
||
|
|
||
|
.. note:: This function is not implemented for base class.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
name : str, optional
|
||
|
The name for the reversed colormap. If it's None the
|
||
|
name will be the name of the parent colormap + "_r".
|
||
|
|
||
|
See Also
|
||
|
--------
|
||
|
LinearSegmentedColormap.reversed
|
||
|
ListedColormap.reversed
|
||
|
"""
|
||
|
raise NotImplementedError()
|
||
|
|
||
|
def _repr_png_(self):
|
||
|
"""Generate a PNG representation of the Colormap."""
|
||
|
X = np.tile(np.linspace(0, 1, _REPR_PNG_SIZE[0]),
|
||
|
(_REPR_PNG_SIZE[1], 1))
|
||
|
pixels = self(X, bytes=True)
|
||
|
png_bytes = io.BytesIO()
|
||
|
title = self.name + ' colormap'
|
||
|
author = f'Matplotlib v{mpl.__version__}, https://matplotlib.org'
|
||
|
pnginfo = PngInfo()
|
||
|
pnginfo.add_text('Title', title)
|
||
|
pnginfo.add_text('Description', title)
|
||
|
pnginfo.add_text('Author', author)
|
||
|
pnginfo.add_text('Software', author)
|
||
|
Image.fromarray(pixels).save(png_bytes, format='png', pnginfo=pnginfo)
|
||
|
return png_bytes.getvalue()
|
||
|
|
||
|
def _repr_html_(self):
|
||
|
"""Generate an HTML representation of the Colormap."""
|
||
|
png_bytes = self._repr_png_()
|
||
|
png_base64 = base64.b64encode(png_bytes).decode('ascii')
|
||
|
def color_block(color):
|
||
|
hex_color = to_hex(color, keep_alpha=True)
|
||
|
return (f'<div title="{hex_color}" '
|
||
|
'style="display: inline-block; '
|
||
|
'width: 1em; height: 1em; '
|
||
|
'margin: 0; '
|
||
|
'vertical-align: middle; '
|
||
|
'border: 1px solid #555; '
|
||
|
f'background-color: {hex_color};"></div>')
|
||
|
|
||
|
return ('<div style="vertical-align: middle;">'
|
||
|
f'<strong>{self.name}</strong> '
|
||
|
'</div>'
|
||
|
'<div class="cmap"><img '
|
||
|
f'alt="{self.name} colormap" '
|
||
|
f'title="{self.name}" '
|
||
|
'style="border: 1px solid #555;" '
|
||
|
f'src="data:image/png;base64,{png_base64}"></div>'
|
||
|
'<div style="vertical-align: middle; '
|
||
|
f'max-width: {_REPR_PNG_SIZE[0]+2}px; '
|
||
|
'display: flex; justify-content: space-between;">'
|
||
|
'<div style="float: left;">'
|
||
|
f'{color_block(self.get_under())} under'
|
||
|
'</div>'
|
||
|
'<div style="margin: 0 auto; display: inline-block;">'
|
||
|
f'bad {color_block(self.get_bad())}'
|
||
|
'</div>'
|
||
|
'<div style="float: right;">'
|
||
|
f'over {color_block(self.get_over())}'
|
||
|
'</div>')
|
||
|
|
||
|
def copy(self):
|
||
|
"""Return a copy of the colormap."""
|
||
|
return self.__copy__()
|
||
|
|
||
|
|
||
|
class LinearSegmentedColormap(Colormap):
|
||
|
"""
|
||
|
Colormap objects based on lookup tables using linear segments.
|
||
|
|
||
|
The lookup table is generated using linear interpolation for each
|
||
|
primary color, with the 0-1 domain divided into any number of
|
||
|
segments.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, name, segmentdata, N=256, gamma=1.0):
|
||
|
"""
|
||
|
Create colormap from linear mapping segments
|
||
|
|
||
|
segmentdata argument is a dictionary with a red, green and blue
|
||
|
entries. Each entry should be a list of *x*, *y0*, *y1* tuples,
|
||
|
forming rows in a table. Entries for alpha are optional.
|
||
|
|
||
|
Example: suppose you want red to increase from 0 to 1 over
|
||
|
the bottom half, green to do the same over the middle half,
|
||
|
and blue over the top half. Then you would use::
|
||
|
|
||
|
cdict = {'red': [(0.0, 0.0, 0.0),
|
||
|
(0.5, 1.0, 1.0),
|
||
|
(1.0, 1.0, 1.0)],
|
||
|
|
||
|
'green': [(0.0, 0.0, 0.0),
|
||
|
(0.25, 0.0, 0.0),
|
||
|
(0.75, 1.0, 1.0),
|
||
|
(1.0, 1.0, 1.0)],
|
||
|
|
||
|
'blue': [(0.0, 0.0, 0.0),
|
||
|
(0.5, 0.0, 0.0),
|
||
|
(1.0, 1.0, 1.0)]}
|
||
|
|
||
|
Each row in the table for a given color is a sequence of
|
||
|
*x*, *y0*, *y1* tuples. In each sequence, *x* must increase
|
||
|
monotonically from 0 to 1. For any input value *z* falling
|
||
|
between *x[i]* and *x[i+1]*, the output value of a given color
|
||
|
will be linearly interpolated between *y1[i]* and *y0[i+1]*::
|
||
|
|
||
|
row i: x y0 y1
|
||
|
/
|
||
|
/
|
||
|
row i+1: x y0 y1
|
||
|
|
||
|
Hence y0 in the first row and y1 in the last row are never used.
|
||
|
|
||
|
See Also
|
||
|
--------
|
||
|
LinearSegmentedColormap.from_list
|
||
|
Static method; factory function for generating a smoothly-varying
|
||
|
LinearSegmentedColormap.
|
||
|
"""
|
||
|
# True only if all colors in map are identical; needed for contouring.
|
||
|
self.monochrome = False
|
||
|
super().__init__(name, N)
|
||
|
self._segmentdata = segmentdata
|
||
|
self._gamma = gamma
|
||
|
|
||
|
def _init(self):
|
||
|
self._lut = np.ones((self.N + 3, 4), float)
|
||
|
self._lut[:-3, 0] = _create_lookup_table(
|
||
|
self.N, self._segmentdata['red'], self._gamma)
|
||
|
self._lut[:-3, 1] = _create_lookup_table(
|
||
|
self.N, self._segmentdata['green'], self._gamma)
|
||
|
self._lut[:-3, 2] = _create_lookup_table(
|
||
|
self.N, self._segmentdata['blue'], self._gamma)
|
||
|
if 'alpha' in self._segmentdata:
|
||
|
self._lut[:-3, 3] = _create_lookup_table(
|
||
|
self.N, self._segmentdata['alpha'], 1)
|
||
|
self._isinit = True
|
||
|
self._set_extremes()
|
||
|
|
||
|
def set_gamma(self, gamma):
|
||
|
"""Set a new gamma value and regenerate colormap."""
|
||
|
self._gamma = gamma
|
||
|
self._init()
|
||
|
|
||
|
@staticmethod
|
||
|
def from_list(name, colors, N=256, gamma=1.0):
|
||
|
"""
|
||
|
Create a `LinearSegmentedColormap` from a list of colors.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
name : str
|
||
|
The name of the colormap.
|
||
|
colors : array-like of colors or array-like of (value, color)
|
||
|
If only colors are given, they are equidistantly mapped from the
|
||
|
range :math:`[0, 1]`; i.e. 0 maps to ``colors[0]`` and 1 maps to
|
||
|
``colors[-1]``.
|
||
|
If (value, color) pairs are given, the mapping is from *value*
|
||
|
to *color*. This can be used to divide the range unevenly.
|
||
|
N : int
|
||
|
The number of rgb quantization levels.
|
||
|
gamma : float
|
||
|
"""
|
||
|
if not np.iterable(colors):
|
||
|
raise ValueError('colors must be iterable')
|
||
|
|
||
|
if (isinstance(colors[0], Sized) and len(colors[0]) == 2
|
||
|
and not isinstance(colors[0], str)):
|
||
|
# List of value, color pairs
|
||
|
vals, colors = zip(*colors)
|
||
|
else:
|
||
|
vals = np.linspace(0, 1, len(colors))
|
||
|
|
||
|
r, g, b, a = to_rgba_array(colors).T
|
||
|
cdict = {
|
||
|
"red": np.column_stack([vals, r, r]),
|
||
|
"green": np.column_stack([vals, g, g]),
|
||
|
"blue": np.column_stack([vals, b, b]),
|
||
|
"alpha": np.column_stack([vals, a, a]),
|
||
|
}
|
||
|
|
||
|
return LinearSegmentedColormap(name, cdict, N, gamma)
|
||
|
|
||
|
def _resample(self, lutsize):
|
||
|
"""Return a new colormap with *lutsize* entries."""
|
||
|
new_cmap = LinearSegmentedColormap(self.name, self._segmentdata,
|
||
|
lutsize)
|
||
|
new_cmap._rgba_over = self._rgba_over
|
||
|
new_cmap._rgba_under = self._rgba_under
|
||
|
new_cmap._rgba_bad = self._rgba_bad
|
||
|
return new_cmap
|
||
|
|
||
|
# Helper ensuring picklability of the reversed cmap.
|
||
|
@staticmethod
|
||
|
def _reverser(func, x):
|
||
|
return func(1 - x)
|
||
|
|
||
|
def reversed(self, name=None):
|
||
|
"""
|
||
|
Return a reversed instance of the Colormap.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
name : str, optional
|
||
|
The name for the reversed colormap. If it's None the
|
||
|
name will be the name of the parent colormap + "_r".
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
LinearSegmentedColormap
|
||
|
The reversed colormap.
|
||
|
"""
|
||
|
if name is None:
|
||
|
name = self.name + "_r"
|
||
|
|
||
|
# Using a partial object keeps the cmap picklable.
|
||
|
data_r = {key: (functools.partial(self._reverser, data)
|
||
|
if callable(data) else
|
||
|
[(1.0 - x, y1, y0) for x, y0, y1 in reversed(data)])
|
||
|
for key, data in self._segmentdata.items()}
|
||
|
|
||
|
new_cmap = LinearSegmentedColormap(name, data_r, self.N, self._gamma)
|
||
|
# Reverse the over/under values too
|
||
|
new_cmap._rgba_over = self._rgba_under
|
||
|
new_cmap._rgba_under = self._rgba_over
|
||
|
new_cmap._rgba_bad = self._rgba_bad
|
||
|
return new_cmap
|
||
|
|
||
|
|
||
|
class ListedColormap(Colormap):
|
||
|
"""
|
||
|
Colormap object generated from a list of colors.
|
||
|
|
||
|
This may be most useful when indexing directly into a colormap,
|
||
|
but it can also be used to generate special colormaps for ordinary
|
||
|
mapping.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
colors : list, array
|
||
|
List of Matplotlib color specifications, or an equivalent Nx3 or Nx4
|
||
|
floating point array (*N* rgb or rgba values).
|
||
|
name : str, optional
|
||
|
String to identify the colormap.
|
||
|
N : int, optional
|
||
|
Number of entries in the map. The default is *None*, in which case
|
||
|
there is one colormap entry for each element in the list of colors.
|
||
|
If ::
|
||
|
|
||
|
N < len(colors)
|
||
|
|
||
|
the list will be truncated at *N*. If ::
|
||
|
|
||
|
N > len(colors)
|
||
|
|
||
|
the list will be extended by repetition.
|
||
|
"""
|
||
|
def __init__(self, colors, name='from_list', N=None):
|
||
|
self.monochrome = False # Are all colors identical? (for contour.py)
|
||
|
if N is None:
|
||
|
self.colors = colors
|
||
|
N = len(colors)
|
||
|
else:
|
||
|
if isinstance(colors, str):
|
||
|
self.colors = [colors] * N
|
||
|
self.monochrome = True
|
||
|
elif np.iterable(colors):
|
||
|
if len(colors) == 1:
|
||
|
self.monochrome = True
|
||
|
self.colors = list(
|
||
|
itertools.islice(itertools.cycle(colors), N))
|
||
|
else:
|
||
|
try:
|
||
|
gray = float(colors)
|
||
|
except TypeError:
|
||
|
pass
|
||
|
else:
|
||
|
self.colors = [gray] * N
|
||
|
self.monochrome = True
|
||
|
super().__init__(name, N)
|
||
|
|
||
|
def _init(self):
|
||
|
self._lut = np.zeros((self.N + 3, 4), float)
|
||
|
self._lut[:-3] = to_rgba_array(self.colors)
|
||
|
self._isinit = True
|
||
|
self._set_extremes()
|
||
|
|
||
|
def _resample(self, lutsize):
|
||
|
"""Return a new colormap with *lutsize* entries."""
|
||
|
colors = self(np.linspace(0, 1, lutsize))
|
||
|
new_cmap = ListedColormap(colors, name=self.name)
|
||
|
# Keep the over/under values too
|
||
|
new_cmap._rgba_over = self._rgba_over
|
||
|
new_cmap._rgba_under = self._rgba_under
|
||
|
new_cmap._rgba_bad = self._rgba_bad
|
||
|
return new_cmap
|
||
|
|
||
|
def reversed(self, name=None):
|
||
|
"""
|
||
|
Return a reversed instance of the Colormap.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
name : str, optional
|
||
|
The name for the reversed colormap. If it's None the
|
||
|
name will be the name of the parent colormap + "_r".
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ListedColormap
|
||
|
A reversed instance of the colormap.
|
||
|
"""
|
||
|
if name is None:
|
||
|
name = self.name + "_r"
|
||
|
|
||
|
colors_r = list(reversed(self.colors))
|
||
|
new_cmap = ListedColormap(colors_r, name=name, N=self.N)
|
||
|
# Reverse the over/under values too
|
||
|
new_cmap._rgba_over = self._rgba_under
|
||
|
new_cmap._rgba_under = self._rgba_over
|
||
|
new_cmap._rgba_bad = self._rgba_bad
|
||
|
return new_cmap
|
||
|
|
||
|
|
||
|
class Normalize:
|
||
|
"""
|
||
|
A class which, when called, linearly normalizes data into the
|
||
|
``[0.0, 1.0]`` interval.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, vmin=None, vmax=None, clip=False):
|
||
|
"""
|
||
|
Parameters
|
||
|
----------
|
||
|
vmin, vmax : float or None
|
||
|
If *vmin* and/or *vmax* is not given, they are initialized from the
|
||
|
minimum and maximum value, respectively, of the first input
|
||
|
processed; i.e., ``__call__(A)`` calls ``autoscale_None(A)``.
|
||
|
|
||
|
clip : bool, default: False
|
||
|
If ``True`` values falling outside the range ``[vmin, vmax]``,
|
||
|
are mapped to 0 or 1, whichever is closer, and masked values are
|
||
|
set to 1. If ``False`` masked values remain masked.
|
||
|
|
||
|
Clipping silently defeats the purpose of setting the over, under,
|
||
|
and masked colors in a colormap, so it is likely to lead to
|
||
|
surprises; therefore the default is ``clip=False``.
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
Returns 0 if ``vmin == vmax``.
|
||
|
"""
|
||
|
self.vmin = _sanitize_extrema(vmin)
|
||
|
self.vmax = _sanitize_extrema(vmax)
|
||
|
self.clip = clip
|
||
|
self._scale = scale.LinearScale(axis=None)
|
||
|
|
||
|
@staticmethod
|
||
|
def process_value(value):
|
||
|
"""
|
||
|
Homogenize the input *value* for easy and efficient normalization.
|
||
|
|
||
|
*value* can be a scalar or sequence.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
result : masked array
|
||
|
Masked array with the same shape as *value*.
|
||
|
is_scalar : bool
|
||
|
Whether *value* is a scalar.
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
Float dtypes are preserved; integer types with two bytes or smaller are
|
||
|
converted to np.float32, and larger types are converted to np.float64.
|
||
|
Preserving float32 when possible, and using in-place operations,
|
||
|
greatly improves speed for large arrays.
|
||
|
"""
|
||
|
is_scalar = not np.iterable(value)
|
||
|
if is_scalar:
|
||
|
value = [value]
|
||
|
dtype = np.min_scalar_type(value)
|
||
|
if np.issubdtype(dtype, np.integer) or dtype.type is np.bool_:
|
||
|
# bool_/int8/int16 -> float32; int32/int64 -> float64
|
||
|
dtype = np.promote_types(dtype, np.float32)
|
||
|
# ensure data passed in as an ndarray subclass are interpreted as
|
||
|
# an ndarray. See issue #6622.
|
||
|
mask = np.ma.getmask(value)
|
||
|
data = np.asarray(value)
|
||
|
result = np.ma.array(data, mask=mask, dtype=dtype, copy=True)
|
||
|
return result, is_scalar
|
||
|
|
||
|
def __call__(self, value, clip=None):
|
||
|
"""
|
||
|
Normalize *value* data in the ``[vmin, vmax]`` interval into the
|
||
|
``[0.0, 1.0]`` interval and return it.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
value
|
||
|
Data to normalize.
|
||
|
clip : bool
|
||
|
If ``None``, defaults to ``self.clip`` (which defaults to
|
||
|
``False``).
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
If not already initialized, ``self.vmin`` and ``self.vmax`` are
|
||
|
initialized using ``self.autoscale_None(value)``.
|
||
|
"""
|
||
|
if clip is None:
|
||
|
clip = self.clip
|
||
|
|
||
|
result, is_scalar = self.process_value(value)
|
||
|
|
||
|
self.autoscale_None(result)
|
||
|
# Convert at least to float, without losing precision.
|
||
|
(vmin,), _ = self.process_value(self.vmin)
|
||
|
(vmax,), _ = self.process_value(self.vmax)
|
||
|
if vmin == vmax:
|
||
|
result.fill(0) # Or should it be all masked? Or 0.5?
|
||
|
elif vmin > vmax:
|
||
|
raise ValueError("minvalue must be less than or equal to maxvalue")
|
||
|
else:
|
||
|
if clip:
|
||
|
mask = np.ma.getmask(result)
|
||
|
result = np.ma.array(np.clip(result.filled(vmax), vmin, vmax),
|
||
|
mask=mask)
|
||
|
# ma division is very slow; we can take a shortcut
|
||
|
resdat = result.data
|
||
|
resdat -= vmin
|
||
|
resdat /= (vmax - vmin)
|
||
|
result = np.ma.array(resdat, mask=result.mask, copy=False)
|
||
|
if is_scalar:
|
||
|
result = result[0]
|
||
|
return result
|
||
|
|
||
|
def inverse(self, value):
|
||
|
if not self.scaled():
|
||
|
raise ValueError("Not invertible until both vmin and vmax are set")
|
||
|
(vmin,), _ = self.process_value(self.vmin)
|
||
|
(vmax,), _ = self.process_value(self.vmax)
|
||
|
|
||
|
if np.iterable(value):
|
||
|
val = np.ma.asarray(value)
|
||
|
return vmin + val * (vmax - vmin)
|
||
|
else:
|
||
|
return vmin + value * (vmax - vmin)
|
||
|
|
||
|
def autoscale(self, A):
|
||
|
"""Set *vmin*, *vmax* to min, max of *A*."""
|
||
|
A = np.asanyarray(A)
|
||
|
self.vmin = A.min()
|
||
|
self.vmax = A.max()
|
||
|
|
||
|
def autoscale_None(self, A):
|
||
|
"""If vmin or vmax are not set, use the min/max of *A* to set them."""
|
||
|
A = np.asanyarray(A)
|
||
|
if self.vmin is None and A.size:
|
||
|
self.vmin = A.min()
|
||
|
if self.vmax is None and A.size:
|
||
|
self.vmax = A.max()
|
||
|
|
||
|
def scaled(self):
|
||
|
"""Return whether vmin and vmax are set."""
|
||
|
return self.vmin is not None and self.vmax is not None
|
||
|
|
||
|
|
||
|
class TwoSlopeNorm(Normalize):
|
||
|
def __init__(self, vcenter, vmin=None, vmax=None):
|
||
|
"""
|
||
|
Normalize data with a set center.
|
||
|
|
||
|
Useful when mapping data with an unequal rates of change around a
|
||
|
conceptual center, e.g., data that range from -2 to 4, with 0 as
|
||
|
the midpoint.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
vcenter : float
|
||
|
The data value that defines ``0.5`` in the normalization.
|
||
|
vmin : float, optional
|
||
|
The data value that defines ``0.0`` in the normalization.
|
||
|
Defaults to the min value of the dataset.
|
||
|
vmax : float, optional
|
||
|
The data value that defines ``1.0`` in the normalization.
|
||
|
Defaults to the the max value of the dataset.
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
This maps data value -4000 to 0., 0 to 0.5, and +10000 to 1.0; data
|
||
|
between is linearly interpolated::
|
||
|
|
||
|
>>> import matplotlib.colors as mcolors
|
||
|
>>> offset = mcolors.TwoSlopeNorm(vmin=-4000.,
|
||
|
vcenter=0., vmax=10000)
|
||
|
>>> data = [-4000., -2000., 0., 2500., 5000., 7500., 10000.]
|
||
|
>>> offset(data)
|
||
|
array([0., 0.25, 0.5, 0.625, 0.75, 0.875, 1.0])
|
||
|
"""
|
||
|
|
||
|
self.vcenter = vcenter
|
||
|
self.vmin = vmin
|
||
|
self.vmax = vmax
|
||
|
if vcenter is not None and vmax is not None and vcenter >= vmax:
|
||
|
raise ValueError('vmin, vcenter, and vmax must be in '
|
||
|
'ascending order')
|
||
|
if vcenter is not None and vmin is not None and vcenter <= vmin:
|
||
|
raise ValueError('vmin, vcenter, and vmax must be in '
|
||
|
'ascending order')
|
||
|
|
||
|
def autoscale_None(self, A):
|
||
|
"""
|
||
|
Get vmin and vmax, and then clip at vcenter
|
||
|
"""
|
||
|
super().autoscale_None(A)
|
||
|
if self.vmin > self.vcenter:
|
||
|
self.vmin = self.vcenter
|
||
|
if self.vmax < self.vcenter:
|
||
|
self.vmax = self.vcenter
|
||
|
|
||
|
def __call__(self, value, clip=None):
|
||
|
"""
|
||
|
Map value to the interval [0, 1]. The clip argument is unused.
|
||
|
"""
|
||
|
result, is_scalar = self.process_value(value)
|
||
|
self.autoscale_None(result) # sets self.vmin, self.vmax if None
|
||
|
|
||
|
if not self.vmin <= self.vcenter <= self.vmax:
|
||
|
raise ValueError("vmin, vcenter, vmax must increase monotonically")
|
||
|
result = np.ma.masked_array(
|
||
|
np.interp(result, [self.vmin, self.vcenter, self.vmax],
|
||
|
[0, 0.5, 1.]), mask=np.ma.getmask(result))
|
||
|
if is_scalar:
|
||
|
result = np.atleast_1d(result)[0]
|
||
|
return result
|
||
|
|
||
|
|
||
|
class CenteredNorm(Normalize):
|
||
|
def __init__(self, vcenter=0, halfrange=None, clip=False):
|
||
|
"""
|
||
|
Normalize symmetrical data around a center (0 by default).
|
||
|
|
||
|
Unlike `TwoSlopeNorm`, `CenteredNorm` applies an equal rate of change
|
||
|
around the center.
|
||
|
|
||
|
Useful when mapping symmetrical data around a conceptual center
|
||
|
e.g., data that range from -2 to 4, with 0 as the midpoint, and
|
||
|
with equal rates of change around that midpoint.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
vcenter : float, default: 0
|
||
|
The data value that defines ``0.5`` in the normalization.
|
||
|
halfrange : float, optional
|
||
|
The range of data values that defines a range of ``0.5`` in the
|
||
|
normalization, so that *vcenter* - *halfrange* is ``0.0`` and
|
||
|
*vcenter* + *halfrange* is ``1.0`` in the normalization.
|
||
|
Defaults to the largest absolute difference to *vcenter* for
|
||
|
the values in the dataset.
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
This maps data values -2 to 0.25, 0 to 0.5, and 4 to 1.0
|
||
|
(assuming equal rates of change above and below 0.0):
|
||
|
|
||
|
>>> import matplotlib.colors as mcolors
|
||
|
>>> norm = mcolors.CenteredNorm(halfrange=4.0)
|
||
|
>>> data = [-2., 0., 4.]
|
||
|
>>> norm(data)
|
||
|
array([0.25, 0.5 , 1. ])
|
||
|
"""
|
||
|
self._vcenter = vcenter
|
||
|
self.vmin = None
|
||
|
self.vmax = None
|
||
|
# calling the halfrange setter to set vmin and vmax
|
||
|
self.halfrange = halfrange
|
||
|
self.clip = clip
|
||
|
|
||
|
def _set_vmin_vmax(self):
|
||
|
"""
|
||
|
Set *vmin* and *vmax* based on *vcenter* and *halfrange*.
|
||
|
"""
|
||
|
self.vmax = self._vcenter + self._halfrange
|
||
|
self.vmin = self._vcenter - self._halfrange
|
||
|
|
||
|
def autoscale(self, A):
|
||
|
"""
|
||
|
Set *halfrange* to ``max(abs(A-vcenter))``, then set *vmin* and *vmax*.
|
||
|
"""
|
||
|
A = np.asanyarray(A)
|
||
|
self._halfrange = max(self._vcenter-A.min(),
|
||
|
A.max()-self._vcenter)
|
||
|
self._set_vmin_vmax()
|
||
|
|
||
|
def autoscale_None(self, A):
|
||
|
"""Set *vmin* and *vmax*."""
|
||
|
A = np.asanyarray(A)
|
||
|
if self._halfrange is None and A.size:
|
||
|
self.autoscale(A)
|
||
|
|
||
|
@property
|
||
|
def vcenter(self):
|
||
|
return self._vcenter
|
||
|
|
||
|
@vcenter.setter
|
||
|
def vcenter(self, vcenter):
|
||
|
self._vcenter = vcenter
|
||
|
if self.vmax is not None:
|
||
|
# recompute halfrange assuming vmin and vmax represent
|
||
|
# min and max of data
|
||
|
self._halfrange = max(self._vcenter-self.vmin,
|
||
|
self.vmax-self._vcenter)
|
||
|
self._set_vmin_vmax()
|
||
|
|
||
|
@property
|
||
|
def halfrange(self):
|
||
|
return self._halfrange
|
||
|
|
||
|
@halfrange.setter
|
||
|
def halfrange(self, halfrange):
|
||
|
if halfrange is None:
|
||
|
self._halfrange = None
|
||
|
self.vmin = None
|
||
|
self.vmax = None
|
||
|
else:
|
||
|
self._halfrange = abs(halfrange)
|
||
|
|
||
|
def __call__(self, value, clip=None):
|
||
|
if self._halfrange is not None:
|
||
|
# enforce symmetry, reset vmin and vmax
|
||
|
self._set_vmin_vmax()
|
||
|
return super().__call__(value, clip=clip)
|
||
|
|
||
|
|
||
|
def _make_norm_from_scale(scale_cls, base_norm_cls=None, *, init=None):
|
||
|
"""
|
||
|
Decorator for building a `.Normalize` subclass from a `.Scale` subclass.
|
||
|
|
||
|
After ::
|
||
|
|
||
|
@_make_norm_from_scale(scale_cls)
|
||
|
class norm_cls(Normalize):
|
||
|
...
|
||
|
|
||
|
*norm_cls* is filled with methods so that normalization computations are
|
||
|
forwarded to *scale_cls* (i.e., *scale_cls* is the scale that would be used
|
||
|
for the colorbar of a mappable normalized with *norm_cls*).
|
||
|
|
||
|
If *init* is not passed, then the constructor signature of *norm_cls*
|
||
|
will be ``norm_cls(vmin=None, vmax=None, clip=False)``; these three
|
||
|
parameters will be forwarded to the base class (``Normalize.__init__``),
|
||
|
and a *scale_cls* object will be initialized with no arguments (other than
|
||
|
a dummy axis).
|
||
|
|
||
|
If the *scale_cls* constructor takes additional parameters, then *init*
|
||
|
should be passed to `_make_norm_from_scale`. It is a callable which is
|
||
|
*only* used for its signature. First, this signature will become the
|
||
|
signature of *norm_cls*. Second, the *norm_cls* constructor will bind the
|
||
|
parameters passed to it using this signature, extract the bound *vmin*,
|
||
|
*vmax*, and *clip* values, pass those to ``Normalize.__init__``, and
|
||
|
forward the remaining bound values (including any defaults defined by the
|
||
|
signature) to the *scale_cls* constructor.
|
||
|
"""
|
||
|
|
||
|
if base_norm_cls is None:
|
||
|
return functools.partial(_make_norm_from_scale, scale_cls, init=init)
|
||
|
|
||
|
if init is None:
|
||
|
def init(vmin=None, vmax=None, clip=False): pass
|
||
|
bound_init_signature = inspect.signature(init)
|
||
|
|
||
|
class Norm(base_norm_cls):
|
||
|
|
||
|
def __init__(self, *args, **kwargs):
|
||
|
ba = bound_init_signature.bind(*args, **kwargs)
|
||
|
ba.apply_defaults()
|
||
|
super().__init__(
|
||
|
**{k: ba.arguments.pop(k) for k in ["vmin", "vmax", "clip"]})
|
||
|
self._scale = scale_cls(axis=None, **ba.arguments)
|
||
|
self._trf = self._scale.get_transform()
|
||
|
|
||
|
def __call__(self, value, clip=None):
|
||
|
value, is_scalar = self.process_value(value)
|
||
|
self.autoscale_None(value)
|
||
|
if self.vmin > self.vmax:
|
||
|
raise ValueError("vmin must be less or equal to vmax")
|
||
|
if self.vmin == self.vmax:
|
||
|
return np.full_like(value, 0)
|
||
|
if clip is None:
|
||
|
clip = self.clip
|
||
|
if clip:
|
||
|
value = np.clip(value, self.vmin, self.vmax)
|
||
|
t_value = self._trf.transform(value).reshape(np.shape(value))
|
||
|
t_vmin, t_vmax = self._trf.transform([self.vmin, self.vmax])
|
||
|
if not np.isfinite([t_vmin, t_vmax]).all():
|
||
|
raise ValueError("Invalid vmin or vmax")
|
||
|
t_value -= t_vmin
|
||
|
t_value /= (t_vmax - t_vmin)
|
||
|
t_value = np.ma.masked_invalid(t_value, copy=False)
|
||
|
return t_value[0] if is_scalar else t_value
|
||
|
|
||
|
def inverse(self, value):
|
||
|
if not self.scaled():
|
||
|
raise ValueError("Not invertible until scaled")
|
||
|
if self.vmin > self.vmax:
|
||
|
raise ValueError("vmin must be less or equal to vmax")
|
||
|
t_vmin, t_vmax = self._trf.transform([self.vmin, self.vmax])
|
||
|
if not np.isfinite([t_vmin, t_vmax]).all():
|
||
|
raise ValueError("Invalid vmin or vmax")
|
||
|
value, is_scalar = self.process_value(value)
|
||
|
rescaled = value * (t_vmax - t_vmin)
|
||
|
rescaled += t_vmin
|
||
|
value = (self._trf
|
||
|
.inverted()
|
||
|
.transform(rescaled)
|
||
|
.reshape(np.shape(value)))
|
||
|
return value[0] if is_scalar else value
|
||
|
|
||
|
Norm.__name__ = base_norm_cls.__name__
|
||
|
Norm.__qualname__ = base_norm_cls.__qualname__
|
||
|
Norm.__module__ = base_norm_cls.__module__
|
||
|
Norm.__init__.__signature__ = bound_init_signature.replace(parameters=[
|
||
|
inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD),
|
||
|
*bound_init_signature.parameters.values()])
|
||
|
return Norm
|
||
|
|
||
|
|
||
|
@_make_norm_from_scale(
|
||
|
scale.FuncScale,
|
||
|
init=lambda functions, vmin=None, vmax=None, clip=False: None)
|
||
|
class FuncNorm(Normalize):
|
||
|
"""
|
||
|
Arbitrary normalization using functions for the forward and inverse.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
functions : (callable, callable)
|
||
|
two-tuple of the forward and inverse functions for the normalization.
|
||
|
The forward function must be monotonic.
|
||
|
|
||
|
Both functions must have the signature ::
|
||
|
|
||
|
def forward(values: array-like) -> array-like
|
||
|
|
||
|
vmin, vmax : float or None
|
||
|
If *vmin* and/or *vmax* is not given, they are initialized from the
|
||
|
minimum and maximum value, respectively, of the first input
|
||
|
processed; i.e., ``__call__(A)`` calls ``autoscale_None(A)``.
|
||
|
|
||
|
clip : bool, default: False
|
||
|
If ``True`` values falling outside the range ``[vmin, vmax]``,
|
||
|
are mapped to 0 or 1, whichever is closer, and masked values are
|
||
|
set to 1. If ``False`` masked values remain masked.
|
||
|
|
||
|
Clipping silently defeats the purpose of setting the over, under,
|
||
|
and masked colors in a colormap, so it is likely to lead to
|
||
|
surprises; therefore the default is ``clip=False``.
|
||
|
"""
|
||
|
|
||
|
|
||
|
@_make_norm_from_scale(functools.partial(scale.LogScale, nonpositive="mask"))
|
||
|
class LogNorm(Normalize):
|
||
|
"""Normalize a given value to the 0-1 range on a log scale."""
|
||
|
|
||
|
def autoscale(self, A):
|
||
|
# docstring inherited.
|
||
|
super().autoscale(np.ma.masked_less_equal(A, 0, copy=False))
|
||
|
|
||
|
def autoscale_None(self, A):
|
||
|
# docstring inherited.
|
||
|
super().autoscale_None(np.ma.masked_less_equal(A, 0, copy=False))
|
||
|
|
||
|
|
||
|
@_make_norm_from_scale(
|
||
|
scale.SymmetricalLogScale,
|
||
|
init=lambda linthresh, linscale=1., vmin=None, vmax=None, clip=False, *,
|
||
|
base=10: None)
|
||
|
class SymLogNorm(Normalize):
|
||
|
"""
|
||
|
The symmetrical logarithmic scale is logarithmic in both the
|
||
|
positive and negative directions from the origin.
|
||
|
|
||
|
Since the values close to zero tend toward infinity, there is a
|
||
|
need to have a range around zero that is linear. The parameter
|
||
|
*linthresh* allows the user to specify the size of this range
|
||
|
(-*linthresh*, *linthresh*).
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
linthresh : float
|
||
|
The range within which the plot is linear (to avoid having the plot
|
||
|
go to infinity around zero).
|
||
|
linscale : float, default: 1
|
||
|
This allows the linear range (-*linthresh* to *linthresh*) to be
|
||
|
stretched relative to the logarithmic range. Its value is the
|
||
|
number of decades to use for each half of the linear range. For
|
||
|
example, when *linscale* == 1.0 (the default), the space used for
|
||
|
the positive and negative halves of the linear range will be equal
|
||
|
to one decade in the logarithmic range.
|
||
|
base : float, default: 10
|
||
|
"""
|
||
|
|
||
|
@property
|
||
|
def linthresh(self):
|
||
|
return self._scale.linthresh
|
||
|
|
||
|
@linthresh.setter
|
||
|
def linthresh(self, value):
|
||
|
self._scale.linthresh = value
|
||
|
|
||
|
|
||
|
class PowerNorm(Normalize):
|
||
|
"""
|
||
|
Linearly map a given value to the 0-1 range and then apply
|
||
|
a power-law normalization over that range.
|
||
|
"""
|
||
|
def __init__(self, gamma, vmin=None, vmax=None, clip=False):
|
||
|
super().__init__(vmin, vmax, clip)
|
||
|
self.gamma = gamma
|
||
|
|
||
|
def __call__(self, value, clip=None):
|
||
|
if clip is None:
|
||
|
clip = self.clip
|
||
|
|
||
|
result, is_scalar = self.process_value(value)
|
||
|
|
||
|
self.autoscale_None(result)
|
||
|
gamma = self.gamma
|
||
|
vmin, vmax = self.vmin, self.vmax
|
||
|
if vmin > vmax:
|
||
|
raise ValueError("minvalue must be less than or equal to maxvalue")
|
||
|
elif vmin == vmax:
|
||
|
result.fill(0)
|
||
|
else:
|
||
|
if clip:
|
||
|
mask = np.ma.getmask(result)
|
||
|
result = np.ma.array(np.clip(result.filled(vmax), vmin, vmax),
|
||
|
mask=mask)
|
||
|
resdat = result.data
|
||
|
resdat -= vmin
|
||
|
resdat[resdat < 0] = 0
|
||
|
np.power(resdat, gamma, resdat)
|
||
|
resdat /= (vmax - vmin) ** gamma
|
||
|
|
||
|
result = np.ma.array(resdat, mask=result.mask, copy=False)
|
||
|
if is_scalar:
|
||
|
result = result[0]
|
||
|
return result
|
||
|
|
||
|
def inverse(self, value):
|
||
|
if not self.scaled():
|
||
|
raise ValueError("Not invertible until scaled")
|
||
|
gamma = self.gamma
|
||
|
vmin, vmax = self.vmin, self.vmax
|
||
|
|
||
|
if np.iterable(value):
|
||
|
val = np.ma.asarray(value)
|
||
|
return np.ma.power(val, 1. / gamma) * (vmax - vmin) + vmin
|
||
|
else:
|
||
|
return pow(value, 1. / gamma) * (vmax - vmin) + vmin
|
||
|
|
||
|
|
||
|
class BoundaryNorm(Normalize):
|
||
|
"""
|
||
|
Generate a colormap index based on discrete intervals.
|
||
|
|
||
|
Unlike `Normalize` or `LogNorm`, `BoundaryNorm` maps values to integers
|
||
|
instead of to the interval 0-1.
|
||
|
|
||
|
Mapping to the 0-1 interval could have been done via piece-wise linear
|
||
|
interpolation, but using integers seems simpler, and reduces the number of
|
||
|
conversions back and forth between integer and floating point.
|
||
|
"""
|
||
|
def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
|
||
|
"""
|
||
|
Parameters
|
||
|
----------
|
||
|
boundaries : array-like
|
||
|
Monotonically increasing sequence of at least 2 boundaries.
|
||
|
ncolors : int
|
||
|
Number of colors in the colormap to be used.
|
||
|
clip : bool, optional
|
||
|
If clip is ``True``, out of range values are mapped to 0 if they
|
||
|
are below ``boundaries[0]`` or mapped to ``ncolors - 1`` if they
|
||
|
are above ``boundaries[-1]``.
|
||
|
|
||
|
If clip is ``False``, out of range values are mapped to -1 if
|
||
|
they are below ``boundaries[0]`` or mapped to *ncolors* if they are
|
||
|
above ``boundaries[-1]``. These are then converted to valid indices
|
||
|
by `Colormap.__call__`.
|
||
|
extend : {'neither', 'both', 'min', 'max'}, default: 'neither'
|
||
|
Extend the number of bins to include one or both of the
|
||
|
regions beyond the boundaries. For example, if ``extend``
|
||
|
is 'min', then the color to which the region between the first
|
||
|
pair of boundaries is mapped will be distinct from the first
|
||
|
color in the colormap, and by default a
|
||
|
`~matplotlib.colorbar.Colorbar` will be drawn with
|
||
|
the triangle extension on the left or lower end.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
int16 scalar or array
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
*boundaries* defines the edges of bins, and data falling within a bin
|
||
|
is mapped to the color with the same index.
|
||
|
|
||
|
If the number of bins, including any extensions, is less than
|
||
|
*ncolors*, the color index is chosen by linear interpolation, mapping
|
||
|
the ``[0, nbins - 1]`` range onto the ``[0, ncolors - 1]`` range.
|
||
|
"""
|
||
|
if clip and extend != 'neither':
|
||
|
raise ValueError("'clip=True' is not compatible with 'extend'")
|
||
|
self.clip = clip
|
||
|
self.vmin = boundaries[0]
|
||
|
self.vmax = boundaries[-1]
|
||
|
self.boundaries = np.asarray(boundaries)
|
||
|
self.N = len(self.boundaries)
|
||
|
if self.N < 2:
|
||
|
raise ValueError("You must provide at least 2 boundaries "
|
||
|
f"(1 region) but you passed in {boundaries!r}")
|
||
|
self.Ncmap = ncolors
|
||
|
self.extend = extend
|
||
|
|
||
|
self._scale = None # don't use the default scale.
|
||
|
|
||
|
self._n_regions = self.N - 1 # number of colors needed
|
||
|
self._offset = 0
|
||
|
if extend in ('min', 'both'):
|
||
|
self._n_regions += 1
|
||
|
self._offset = 1
|
||
|
if extend in ('max', 'both'):
|
||
|
self._n_regions += 1
|
||
|
if self._n_regions > self.Ncmap:
|
||
|
raise ValueError(f"There are {self._n_regions} color bins "
|
||
|
"including extensions, but ncolors = "
|
||
|
f"{ncolors}; ncolors must equal or exceed the "
|
||
|
"number of bins")
|
||
|
|
||
|
def __call__(self, value, clip=None):
|
||
|
if clip is None:
|
||
|
clip = self.clip
|
||
|
|
||
|
xx, is_scalar = self.process_value(value)
|
||
|
mask = np.ma.getmaskarray(xx)
|
||
|
# Fill masked values a value above the upper boundary
|
||
|
xx = np.atleast_1d(xx.filled(self.vmax + 1))
|
||
|
if clip:
|
||
|
np.clip(xx, self.vmin, self.vmax, out=xx)
|
||
|
max_col = self.Ncmap - 1
|
||
|
else:
|
||
|
max_col = self.Ncmap
|
||
|
# this gives us the bins in the lookup table in the range
|
||
|
# [0, _n_regions - 1] (the offset is baked in in the init)
|
||
|
iret = np.digitize(xx, self.boundaries) - 1 + self._offset
|
||
|
# if we have more colors than regions, stretch the region
|
||
|
# index computed above to full range of the color bins. This
|
||
|
# will make use of the full range (but skip some of the colors
|
||
|
# in the middle) such that the first region is mapped to the
|
||
|
# first color and the last region is mapped to the last color.
|
||
|
if self.Ncmap > self._n_regions:
|
||
|
if self._n_regions == 1:
|
||
|
# special case the 1 region case, pick the middle color
|
||
|
iret[iret == 0] = (self.Ncmap - 1) // 2
|
||
|
else:
|
||
|
# otherwise linearly remap the values from the region index
|
||
|
# to the color index spaces
|
||
|
iret = (self.Ncmap - 1) / (self._n_regions - 1) * iret
|
||
|
# cast to 16bit integers in all cases
|
||
|
iret = iret.astype(np.int16)
|
||
|
iret[xx < self.vmin] = -1
|
||
|
iret[xx >= self.vmax] = max_col
|
||
|
ret = np.ma.array(iret, mask=mask)
|
||
|
if is_scalar:
|
||
|
ret = int(ret[0]) # assume python scalar
|
||
|
return ret
|
||
|
|
||
|
def inverse(self, value):
|
||
|
"""
|
||
|
Raises
|
||
|
------
|
||
|
ValueError
|
||
|
BoundaryNorm is not invertible, so calling this method will always
|
||
|
raise an error
|
||
|
"""
|
||
|
raise ValueError("BoundaryNorm is not invertible")
|
||
|
|
||
|
|
||
|
class NoNorm(Normalize):
|
||
|
"""
|
||
|
Dummy replacement for `Normalize`, for the case where we want to use
|
||
|
indices directly in a `~matplotlib.cm.ScalarMappable`.
|
||
|
"""
|
||
|
def __call__(self, value, clip=None):
|
||
|
return value
|
||
|
|
||
|
def inverse(self, value):
|
||
|
return value
|
||
|
|
||
|
|
||
|
def rgb_to_hsv(arr):
|
||
|
"""
|
||
|
Convert float rgb values (in the range [0, 1]), in a numpy array to hsv
|
||
|
values.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
arr : (..., 3) array-like
|
||
|
All values must be in the range [0, 1]
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
(..., 3) ndarray
|
||
|
Colors converted to hsv values in range [0, 1]
|
||
|
"""
|
||
|
arr = np.asarray(arr)
|
||
|
|
||
|
# check length of the last dimension, should be _some_ sort of rgb
|
||
|
if arr.shape[-1] != 3:
|
||
|
raise ValueError("Last dimension of input array must be 3; "
|
||
|
"shape {} was found.".format(arr.shape))
|
||
|
|
||
|
in_shape = arr.shape
|
||
|
arr = np.array(
|
||
|
arr, copy=False,
|
||
|
dtype=np.promote_types(arr.dtype, np.float32), # Don't work on ints.
|
||
|
ndmin=2, # In case input was 1D.
|
||
|
)
|
||
|
out = np.zeros_like(arr)
|
||
|
arr_max = arr.max(-1)
|
||
|
ipos = arr_max > 0
|
||
|
delta = arr.ptp(-1)
|
||
|
s = np.zeros_like(delta)
|
||
|
s[ipos] = delta[ipos] / arr_max[ipos]
|
||
|
ipos = delta > 0
|
||
|
# red is max
|
||
|
idx = (arr[..., 0] == arr_max) & ipos
|
||
|
out[idx, 0] = (arr[idx, 1] - arr[idx, 2]) / delta[idx]
|
||
|
# green is max
|
||
|
idx = (arr[..., 1] == arr_max) & ipos
|
||
|
out[idx, 0] = 2. + (arr[idx, 2] - arr[idx, 0]) / delta[idx]
|
||
|
# blue is max
|
||
|
idx = (arr[..., 2] == arr_max) & ipos
|
||
|
out[idx, 0] = 4. + (arr[idx, 0] - arr[idx, 1]) / delta[idx]
|
||
|
|
||
|
out[..., 0] = (out[..., 0] / 6.0) % 1.0
|
||
|
out[..., 1] = s
|
||
|
out[..., 2] = arr_max
|
||
|
|
||
|
return out.reshape(in_shape)
|
||
|
|
||
|
|
||
|
def hsv_to_rgb(hsv):
|
||
|
"""
|
||
|
Convert hsv values to rgb.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
hsv : (..., 3) array-like
|
||
|
All values assumed to be in range [0, 1]
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
(..., 3) ndarray
|
||
|
Colors converted to RGB values in range [0, 1]
|
||
|
"""
|
||
|
hsv = np.asarray(hsv)
|
||
|
|
||
|
# check length of the last dimension, should be _some_ sort of rgb
|
||
|
if hsv.shape[-1] != 3:
|
||
|
raise ValueError("Last dimension of input array must be 3; "
|
||
|
"shape {shp} was found.".format(shp=hsv.shape))
|
||
|
|
||
|
in_shape = hsv.shape
|
||
|
hsv = np.array(
|
||
|
hsv, copy=False,
|
||
|
dtype=np.promote_types(hsv.dtype, np.float32), # Don't work on ints.
|
||
|
ndmin=2, # In case input was 1D.
|
||
|
)
|
||
|
|
||
|
h = hsv[..., 0]
|
||
|
s = hsv[..., 1]
|
||
|
v = hsv[..., 2]
|
||
|
|
||
|
r = np.empty_like(h)
|
||
|
g = np.empty_like(h)
|
||
|
b = np.empty_like(h)
|
||
|
|
||
|
i = (h * 6.0).astype(int)
|
||
|
f = (h * 6.0) - i
|
||
|
p = v * (1.0 - s)
|
||
|
q = v * (1.0 - s * f)
|
||
|
t = v * (1.0 - s * (1.0 - f))
|
||
|
|
||
|
idx = i % 6 == 0
|
||
|
r[idx] = v[idx]
|
||
|
g[idx] = t[idx]
|
||
|
b[idx] = p[idx]
|
||
|
|
||
|
idx = i == 1
|
||
|
r[idx] = q[idx]
|
||
|
g[idx] = v[idx]
|
||
|
b[idx] = p[idx]
|
||
|
|
||
|
idx = i == 2
|
||
|
r[idx] = p[idx]
|
||
|
g[idx] = v[idx]
|
||
|
b[idx] = t[idx]
|
||
|
|
||
|
idx = i == 3
|
||
|
r[idx] = p[idx]
|
||
|
g[idx] = q[idx]
|
||
|
b[idx] = v[idx]
|
||
|
|
||
|
idx = i == 4
|
||
|
r[idx] = t[idx]
|
||
|
g[idx] = p[idx]
|
||
|
b[idx] = v[idx]
|
||
|
|
||
|
idx = i == 5
|
||
|
r[idx] = v[idx]
|
||
|
g[idx] = p[idx]
|
||
|
b[idx] = q[idx]
|
||
|
|
||
|
idx = s == 0
|
||
|
r[idx] = v[idx]
|
||
|
g[idx] = v[idx]
|
||
|
b[idx] = v[idx]
|
||
|
|
||
|
rgb = np.stack([r, g, b], axis=-1)
|
||
|
|
||
|
return rgb.reshape(in_shape)
|
||
|
|
||
|
|
||
|
def _vector_magnitude(arr):
|
||
|
# things that don't work here:
|
||
|
# * np.linalg.norm: drops mask from ma.array
|
||
|
# * np.sum: drops mask from ma.array unless entire vector is masked
|
||
|
sum_sq = 0
|
||
|
for i in range(arr.shape[-1]):
|
||
|
sum_sq += arr[..., i, np.newaxis] ** 2
|
||
|
return np.sqrt(sum_sq)
|
||
|
|
||
|
|
||
|
class LightSource:
|
||
|
"""
|
||
|
Create a light source coming from the specified azimuth and elevation.
|
||
|
Angles are in degrees, with the azimuth measured
|
||
|
clockwise from north and elevation up from the zero plane of the surface.
|
||
|
|
||
|
`shade` is used to produce "shaded" rgb values for a data array.
|
||
|
`shade_rgb` can be used to combine an rgb image with an elevation map.
|
||
|
`hillshade` produces an illumination map of a surface.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, azdeg=315, altdeg=45, hsv_min_val=0, hsv_max_val=1,
|
||
|
hsv_min_sat=1, hsv_max_sat=0):
|
||
|
"""
|
||
|
Specify the azimuth (measured clockwise from south) and altitude
|
||
|
(measured up from the plane of the surface) of the light source
|
||
|
in degrees.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
azdeg : float, default: 315 degrees (from the northwest)
|
||
|
The azimuth (0-360, degrees clockwise from North) of the light
|
||
|
source.
|
||
|
altdeg : float, default: 45 degrees
|
||
|
The altitude (0-90, degrees up from horizontal) of the light
|
||
|
source.
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
For backwards compatibility, the parameters *hsv_min_val*,
|
||
|
*hsv_max_val*, *hsv_min_sat*, and *hsv_max_sat* may be supplied at
|
||
|
initialization as well. However, these parameters will only be used if
|
||
|
"blend_mode='hsv'" is passed into `shade` or `shade_rgb`.
|
||
|
See the documentation for `blend_hsv` for more details.
|
||
|
"""
|
||
|
self.azdeg = azdeg
|
||
|
self.altdeg = altdeg
|
||
|
self.hsv_min_val = hsv_min_val
|
||
|
self.hsv_max_val = hsv_max_val
|
||
|
self.hsv_min_sat = hsv_min_sat
|
||
|
self.hsv_max_sat = hsv_max_sat
|
||
|
|
||
|
@property
|
||
|
def direction(self):
|
||
|
"""The unit vector direction towards the light source."""
|
||
|
# Azimuth is in degrees clockwise from North. Convert to radians
|
||
|
# counterclockwise from East (mathematical notation).
|
||
|
az = np.radians(90 - self.azdeg)
|
||
|
alt = np.radians(self.altdeg)
|
||
|
return np.array([
|
||
|
np.cos(az) * np.cos(alt),
|
||
|
np.sin(az) * np.cos(alt),
|
||
|
np.sin(alt)
|
||
|
])
|
||
|
|
||
|
def hillshade(self, elevation, vert_exag=1, dx=1, dy=1, fraction=1.):
|
||
|
"""
|
||
|
Calculate the illumination intensity for a surface using the defined
|
||
|
azimuth and elevation for the light source.
|
||
|
|
||
|
This computes the normal vectors for the surface, and then passes them
|
||
|
on to `shade_normals`
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
elevation : 2D array-like
|
||
|
The height values used to generate an illumination map
|
||
|
vert_exag : number, optional
|
||
|
The amount to exaggerate the elevation values by when calculating
|
||
|
illumination. This can be used either to correct for differences in
|
||
|
units between the x-y coordinate system and the elevation
|
||
|
coordinate system (e.g. decimal degrees vs. meters) or to
|
||
|
exaggerate or de-emphasize topographic effects.
|
||
|
dx : number, optional
|
||
|
The x-spacing (columns) of the input *elevation* grid.
|
||
|
dy : number, optional
|
||
|
The y-spacing (rows) of the input *elevation* grid.
|
||
|
fraction : number, optional
|
||
|
Increases or decreases the contrast of the hillshade. Values
|
||
|
greater than one will cause intermediate values to move closer to
|
||
|
full illumination or shadow (and clipping any values that move
|
||
|
beyond 0 or 1). Note that this is not visually or mathematically
|
||
|
the same as vertical exaggeration.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ndarray
|
||
|
A 2D array of illumination values between 0-1, where 0 is
|
||
|
completely in shadow and 1 is completely illuminated.
|
||
|
"""
|
||
|
|
||
|
# Because most image and raster GIS data has the first row in the array
|
||
|
# as the "top" of the image, dy is implicitly negative. This is
|
||
|
# consistent to what `imshow` assumes, as well.
|
||
|
dy = -dy
|
||
|
|
||
|
# compute the normal vectors from the partial derivatives
|
||
|
e_dy, e_dx = np.gradient(vert_exag * elevation, dy, dx)
|
||
|
|
||
|
# .view is to keep subclasses
|
||
|
normal = np.empty(elevation.shape + (3,)).view(type(elevation))
|
||
|
normal[..., 0] = -e_dx
|
||
|
normal[..., 1] = -e_dy
|
||
|
normal[..., 2] = 1
|
||
|
normal /= _vector_magnitude(normal)
|
||
|
|
||
|
return self.shade_normals(normal, fraction)
|
||
|
|
||
|
def shade_normals(self, normals, fraction=1.):
|
||
|
"""
|
||
|
Calculate the illumination intensity for the normal vectors of a
|
||
|
surface using the defined azimuth and elevation for the light source.
|
||
|
|
||
|
Imagine an artificial sun placed at infinity in some azimuth and
|
||
|
elevation position illuminating our surface. The parts of the surface
|
||
|
that slope toward the sun should brighten while those sides facing away
|
||
|
should become darker.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
fraction : number, optional
|
||
|
Increases or decreases the contrast of the hillshade. Values
|
||
|
greater than one will cause intermediate values to move closer to
|
||
|
full illumination or shadow (and clipping any values that move
|
||
|
beyond 0 or 1). Note that this is not visually or mathematically
|
||
|
the same as vertical exaggeration.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ndarray
|
||
|
A 2D array of illumination values between 0-1, where 0 is
|
||
|
completely in shadow and 1 is completely illuminated.
|
||
|
"""
|
||
|
|
||
|
intensity = normals.dot(self.direction)
|
||
|
|
||
|
# Apply contrast stretch
|
||
|
imin, imax = intensity.min(), intensity.max()
|
||
|
intensity *= fraction
|
||
|
|
||
|
# Rescale to 0-1, keeping range before contrast stretch
|
||
|
# If constant slope, keep relative scaling (i.e. flat should be 0.5,
|
||
|
# fully occluded 0, etc.)
|
||
|
if (imax - imin) > 1e-6:
|
||
|
# Strictly speaking, this is incorrect. Negative values should be
|
||
|
# clipped to 0 because they're fully occluded. However, rescaling
|
||
|
# in this manner is consistent with the previous implementation and
|
||
|
# visually appears better than a "hard" clip.
|
||
|
intensity -= imin
|
||
|
intensity /= (imax - imin)
|
||
|
intensity = np.clip(intensity, 0, 1)
|
||
|
|
||
|
return intensity
|
||
|
|
||
|
def shade(self, data, cmap, norm=None, blend_mode='overlay', vmin=None,
|
||
|
vmax=None, vert_exag=1, dx=1, dy=1, fraction=1, **kwargs):
|
||
|
"""
|
||
|
Combine colormapped data values with an illumination intensity map
|
||
|
(a.k.a. "hillshade") of the values.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
data : 2D array-like
|
||
|
The height values used to generate a shaded map.
|
||
|
cmap : `~matplotlib.colors.Colormap`
|
||
|
The colormap used to color the *data* array. Note that this must be
|
||
|
a `~matplotlib.colors.Colormap` instance. For example, rather than
|
||
|
passing in ``cmap='gist_earth'``, use
|
||
|
``cmap=plt.get_cmap('gist_earth')`` instead.
|
||
|
norm : `~matplotlib.colors.Normalize` instance, optional
|
||
|
The normalization used to scale values before colormapping. If
|
||
|
None, the input will be linearly scaled between its min and max.
|
||
|
blend_mode : {'hsv', 'overlay', 'soft'} or callable, optional
|
||
|
The type of blending used to combine the colormapped data
|
||
|
values with the illumination intensity. Default is
|
||
|
"overlay". Note that for most topographic surfaces,
|
||
|
"overlay" or "soft" appear more visually realistic. If a
|
||
|
user-defined function is supplied, it is expected to
|
||
|
combine an MxNx3 RGB array of floats (ranging 0 to 1) with
|
||
|
an MxNx1 hillshade array (also 0 to 1). (Call signature
|
||
|
``func(rgb, illum, **kwargs)``) Additional kwargs supplied
|
||
|
to this function will be passed on to the *blend_mode*
|
||
|
function.
|
||
|
vmin : float or None, optional
|
||
|
The minimum value used in colormapping *data*. If *None* the
|
||
|
minimum value in *data* is used. If *norm* is specified, then this
|
||
|
argument will be ignored.
|
||
|
vmax : float or None, optional
|
||
|
The maximum value used in colormapping *data*. If *None* the
|
||
|
maximum value in *data* is used. If *norm* is specified, then this
|
||
|
argument will be ignored.
|
||
|
vert_exag : number, optional
|
||
|
The amount to exaggerate the elevation values by when calculating
|
||
|
illumination. This can be used either to correct for differences in
|
||
|
units between the x-y coordinate system and the elevation
|
||
|
coordinate system (e.g. decimal degrees vs. meters) or to
|
||
|
exaggerate or de-emphasize topography.
|
||
|
dx : number, optional
|
||
|
The x-spacing (columns) of the input *elevation* grid.
|
||
|
dy : number, optional
|
||
|
The y-spacing (rows) of the input *elevation* grid.
|
||
|
fraction : number, optional
|
||
|
Increases or decreases the contrast of the hillshade. Values
|
||
|
greater than one will cause intermediate values to move closer to
|
||
|
full illumination or shadow (and clipping any values that move
|
||
|
beyond 0 or 1). Note that this is not visually or mathematically
|
||
|
the same as vertical exaggeration.
|
||
|
Additional kwargs are passed on to the *blend_mode* function.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ndarray
|
||
|
An MxNx4 array of floats ranging between 0-1.
|
||
|
"""
|
||
|
if vmin is None:
|
||
|
vmin = data.min()
|
||
|
if vmax is None:
|
||
|
vmax = data.max()
|
||
|
if norm is None:
|
||
|
norm = Normalize(vmin=vmin, vmax=vmax)
|
||
|
|
||
|
rgb0 = cmap(norm(data))
|
||
|
rgb1 = self.shade_rgb(rgb0, elevation=data, blend_mode=blend_mode,
|
||
|
vert_exag=vert_exag, dx=dx, dy=dy,
|
||
|
fraction=fraction, **kwargs)
|
||
|
# Don't overwrite the alpha channel, if present.
|
||
|
rgb0[..., :3] = rgb1[..., :3]
|
||
|
return rgb0
|
||
|
|
||
|
def shade_rgb(self, rgb, elevation, fraction=1., blend_mode='hsv',
|
||
|
vert_exag=1, dx=1, dy=1, **kwargs):
|
||
|
"""
|
||
|
Use this light source to adjust the colors of the *rgb* input array to
|
||
|
give the impression of a shaded relief map with the given *elevation*.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
rgb : array-like
|
||
|
An (M, N, 3) RGB array, assumed to be in the range of 0 to 1.
|
||
|
elevation : array-like
|
||
|
An (M, N) array of the height values used to generate a shaded map.
|
||
|
fraction : number
|
||
|
Increases or decreases the contrast of the hillshade. Values
|
||
|
greater than one will cause intermediate values to move closer to
|
||
|
full illumination or shadow (and clipping any values that move
|
||
|
beyond 0 or 1). Note that this is not visually or mathematically
|
||
|
the same as vertical exaggeration.
|
||
|
blend_mode : {'hsv', 'overlay', 'soft'} or callable, optional
|
||
|
The type of blending used to combine the colormapped data values
|
||
|
with the illumination intensity. For backwards compatibility, this
|
||
|
defaults to "hsv". Note that for most topographic surfaces,
|
||
|
"overlay" or "soft" appear more visually realistic. If a
|
||
|
user-defined function is supplied, it is expected to combine an
|
||
|
MxNx3 RGB array of floats (ranging 0 to 1) with an MxNx1 hillshade
|
||
|
array (also 0 to 1). (Call signature
|
||
|
``func(rgb, illum, **kwargs)``)
|
||
|
Additional kwargs supplied to this function will be passed on to
|
||
|
the *blend_mode* function.
|
||
|
vert_exag : number, optional
|
||
|
The amount to exaggerate the elevation values by when calculating
|
||
|
illumination. This can be used either to correct for differences in
|
||
|
units between the x-y coordinate system and the elevation
|
||
|
coordinate system (e.g. decimal degrees vs. meters) or to
|
||
|
exaggerate or de-emphasize topography.
|
||
|
dx : number, optional
|
||
|
The x-spacing (columns) of the input *elevation* grid.
|
||
|
dy : number, optional
|
||
|
The y-spacing (rows) of the input *elevation* grid.
|
||
|
Additional kwargs are passed on to the *blend_mode* function.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ndarray
|
||
|
An (m, n, 3) array of floats ranging between 0-1.
|
||
|
"""
|
||
|
# Calculate the "hillshade" intensity.
|
||
|
intensity = self.hillshade(elevation, vert_exag, dx, dy, fraction)
|
||
|
intensity = intensity[..., np.newaxis]
|
||
|
|
||
|
# Blend the hillshade and rgb data using the specified mode
|
||
|
lookup = {
|
||
|
'hsv': self.blend_hsv,
|
||
|
'soft': self.blend_soft_light,
|
||
|
'overlay': self.blend_overlay,
|
||
|
}
|
||
|
if blend_mode in lookup:
|
||
|
blend = lookup[blend_mode](rgb, intensity, **kwargs)
|
||
|
else:
|
||
|
try:
|
||
|
blend = blend_mode(rgb, intensity, **kwargs)
|
||
|
except TypeError as err:
|
||
|
raise ValueError('"blend_mode" must be callable or one of {}'
|
||
|
.format(lookup.keys)) from err
|
||
|
|
||
|
# Only apply result where hillshade intensity isn't masked
|
||
|
if np.ma.is_masked(intensity):
|
||
|
mask = intensity.mask[..., 0]
|
||
|
for i in range(3):
|
||
|
blend[..., i][mask] = rgb[..., i][mask]
|
||
|
|
||
|
return blend
|
||
|
|
||
|
def blend_hsv(self, rgb, intensity, hsv_max_sat=None, hsv_max_val=None,
|
||
|
hsv_min_val=None, hsv_min_sat=None):
|
||
|
"""
|
||
|
Take the input data array, convert to HSV values in the given colormap,
|
||
|
then adjust those color values to give the impression of a shaded
|
||
|
relief map with a specified light source. RGBA values are returned,
|
||
|
which can then be used to plot the shaded image with imshow.
|
||
|
|
||
|
The color of the resulting image will be darkened by moving the (s, v)
|
||
|
values (in hsv colorspace) toward (hsv_min_sat, hsv_min_val) in the
|
||
|
shaded regions, or lightened by sliding (s, v) toward (hsv_max_sat,
|
||
|
hsv_max_val) in regions that are illuminated. The default extremes are
|
||
|
chose so that completely shaded points are nearly black (s = 1, v = 0)
|
||
|
and completely illuminated points are nearly white (s = 0, v = 1).
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
rgb : ndarray
|
||
|
An MxNx3 RGB array of floats ranging from 0 to 1 (color image).
|
||
|
intensity : ndarray
|
||
|
An MxNx1 array of floats ranging from 0 to 1 (grayscale image).
|
||
|
hsv_max_sat : number, default: 1
|
||
|
The maximum saturation value that the *intensity* map can shift the
|
||
|
output image to.
|
||
|
hsv_min_sat : number, optional
|
||
|
The minimum saturation value that the *intensity* map can shift the
|
||
|
output image to. Defaults to 0.
|
||
|
hsv_max_val : number, optional
|
||
|
The maximum value ("v" in "hsv") that the *intensity* map can shift
|
||
|
the output image to. Defaults to 1.
|
||
|
hsv_min_val : number, optional
|
||
|
The minimum value ("v" in "hsv") that the *intensity* map can shift
|
||
|
the output image to. Defaults to 0.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ndarray
|
||
|
An MxNx3 RGB array representing the combined images.
|
||
|
"""
|
||
|
# Backward compatibility...
|
||
|
if hsv_max_sat is None:
|
||
|
hsv_max_sat = self.hsv_max_sat
|
||
|
if hsv_max_val is None:
|
||
|
hsv_max_val = self.hsv_max_val
|
||
|
if hsv_min_sat is None:
|
||
|
hsv_min_sat = self.hsv_min_sat
|
||
|
if hsv_min_val is None:
|
||
|
hsv_min_val = self.hsv_min_val
|
||
|
|
||
|
# Expects a 2D intensity array scaled between -1 to 1...
|
||
|
intensity = intensity[..., 0]
|
||
|
intensity = 2 * intensity - 1
|
||
|
|
||
|
# Convert to rgb, then rgb to hsv
|
||
|
hsv = rgb_to_hsv(rgb[:, :, 0:3])
|
||
|
hue, sat, val = np.moveaxis(hsv, -1, 0)
|
||
|
|
||
|
# Modify hsv values (in place) to simulate illumination.
|
||
|
# putmask(A, mask, B) <=> A[mask] = B[mask]
|
||
|
np.putmask(sat, (np.abs(sat) > 1.e-10) & (intensity > 0),
|
||
|
(1 - intensity) * sat + intensity * hsv_max_sat)
|
||
|
np.putmask(sat, (np.abs(sat) > 1.e-10) & (intensity < 0),
|
||
|
(1 + intensity) * sat - intensity * hsv_min_sat)
|
||
|
np.putmask(val, intensity > 0,
|
||
|
(1 - intensity) * val + intensity * hsv_max_val)
|
||
|
np.putmask(val, intensity < 0,
|
||
|
(1 + intensity) * val - intensity * hsv_min_val)
|
||
|
np.clip(hsv[:, :, 1:], 0, 1, out=hsv[:, :, 1:])
|
||
|
|
||
|
# Convert modified hsv back to rgb.
|
||
|
return hsv_to_rgb(hsv)
|
||
|
|
||
|
def blend_soft_light(self, rgb, intensity):
|
||
|
"""
|
||
|
Combine an rgb image with an intensity map using "soft light" blending,
|
||
|
using the "pegtop" formula.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
rgb : ndarray
|
||
|
An MxNx3 RGB array of floats ranging from 0 to 1 (color image).
|
||
|
intensity : ndarray
|
||
|
An MxNx1 array of floats ranging from 0 to 1 (grayscale image).
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ndarray
|
||
|
An MxNx3 RGB array representing the combined images.
|
||
|
"""
|
||
|
return 2 * intensity * rgb + (1 - 2 * intensity) * rgb**2
|
||
|
|
||
|
def blend_overlay(self, rgb, intensity):
|
||
|
"""
|
||
|
Combines an rgb image with an intensity map using "overlay" blending.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
rgb : ndarray
|
||
|
An MxNx3 RGB array of floats ranging from 0 to 1 (color image).
|
||
|
intensity : ndarray
|
||
|
An MxNx1 array of floats ranging from 0 to 1 (grayscale image).
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ndarray
|
||
|
An MxNx3 RGB array representing the combined images.
|
||
|
"""
|
||
|
low = 2 * intensity * rgb
|
||
|
high = 1 - 2 * (1 - intensity) * (1 - rgb)
|
||
|
return np.where(rgb <= 0.5, low, high)
|
||
|
|
||
|
|
||
|
def from_levels_and_colors(levels, colors, extend='neither'):
|
||
|
"""
|
||
|
A helper routine to generate a cmap and a norm instance which
|
||
|
behave similar to contourf's levels and colors arguments.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
levels : sequence of numbers
|
||
|
The quantization levels used to construct the `BoundaryNorm`.
|
||
|
Value ``v`` is quantized to level ``i`` if ``lev[i] <= v < lev[i+1]``.
|
||
|
colors : sequence of colors
|
||
|
The fill color to use for each level. If *extend* is "neither" there
|
||
|
must be ``n_level - 1`` colors. For an *extend* of "min" or "max" add
|
||
|
one extra color, and for an *extend* of "both" add two colors.
|
||
|
extend : {'neither', 'min', 'max', 'both'}, optional
|
||
|
The behaviour when a value falls out of range of the given levels.
|
||
|
See `~.Axes.contourf` for details.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
cmap : `~matplotlib.colors.Normalize`
|
||
|
norm : `~matplotlib.colors.Colormap`
|
||
|
"""
|
||
|
slice_map = {
|
||
|
'both': slice(1, -1),
|
||
|
'min': slice(1, None),
|
||
|
'max': slice(0, -1),
|
||
|
'neither': slice(0, None),
|
||
|
}
|
||
|
_api.check_in_list(slice_map, extend=extend)
|
||
|
color_slice = slice_map[extend]
|
||
|
|
||
|
n_data_colors = len(levels) - 1
|
||
|
n_expected = n_data_colors + color_slice.start - (color_slice.stop or 0)
|
||
|
if len(colors) != n_expected:
|
||
|
raise ValueError(
|
||
|
f'With extend == {extend!r} and {len(levels)} levels, '
|
||
|
f'expected {n_expected} colors, but got {len(colors)}')
|
||
|
|
||
|
cmap = ListedColormap(colors[color_slice], N=n_data_colors)
|
||
|
|
||
|
if extend in ['min', 'both']:
|
||
|
cmap.set_under(colors[0])
|
||
|
else:
|
||
|
cmap.set_under('none')
|
||
|
|
||
|
if extend in ['max', 'both']:
|
||
|
cmap.set_over(colors[-1])
|
||
|
else:
|
||
|
cmap.set_over('none')
|
||
|
|
||
|
cmap.colorbar_extend = extend
|
||
|
|
||
|
norm = BoundaryNorm(levels, ncolors=n_data_colors)
|
||
|
return cmap, norm
|