#!/usr/bin/env python3
"""
New colormap classes and colormap normalization classes.
"""
import os
import re
import json
from xml.etree import ElementTree
from numbers import Number, Integral
import numpy as np
import numpy.ma as ma
import matplotlib.cm as mcm
import matplotlib.colors as mcolors
from matplotlib import rcParams
from .internals import ic # noqa: F401
from .internals import docstring, warnings, _not_none
from .utils import to_rgb, to_rgba, to_xyz, to_xyza
if hasattr(mcm, '_cmap_registry'):
_cmap_database_attr = '_cmap_registry'
else:
_cmap_database_attr = 'cmap_d'
_cmap_database = getattr(mcm, _cmap_database_attr)
__all__ = [
'make_mapping_array',
'ListedColormap',
'LinearSegmentedColormap',
'PerceptuallyUniformColormap',
'DiscreteNorm',
'DivergingNorm',
'LinearSegmentedNorm',
'ColorDatabase',
'ColormapDatabase',
'BinNorm', 'MidpointNorm', 'ColorDict', 'CmapDict', # deprecated
]
HEX_PATTERN = r'#(?:[0-9a-fA-F]{3,4}){2}' # 6-8 digit hex
CMAPS_DIVERGING = tuple(
(key1.lower(), key2.lower()) for key1, key2 in (
('PiYG', 'GYPi'),
('PRGn', 'GnRP'),
('BrBG', 'GBBr'),
('PuOr', 'OrPu'),
('RdGy', 'GyRd'),
('RdBu', 'BuRd'),
('RdYlBu', 'BuYlRd'),
('RdYlGn', 'GnYlRd'),
('BR', 'RB'),
('CoolWarm', 'WarmCool'),
('ColdHot', 'HotCold'),
('NegPos', 'PosNeg'),
('DryWet', 'WetDry')
)
)
docstring.snippets['cmap.init'] = """
name : str
The colormap name.
segmentdata : dict-like
Mapping containing the keys ``'hue'``, ``'saturation'``, and
``'luminance'``. The key values can be callable functions that
return channel values given a colormap index, or 3-column
arrays indicating the coordinates and channel transitions.
See `~matplotlib.colors.LinearSegmentedColormap` for a more
detailed explanation.
N : int, optional
Number of points in the colormap lookup table.
Default is :rc:`image.lut`.
alpha : float, optional
The opacity for the entire colormap. Overrides the input
segment data.
cyclic : bool, optional
Whether the colormap is cyclic. If ``True``, this changes how the
leftmost and rightmost color levels are selected, and `extend` can
only be ``'neither'`` (a warning will be issued otherwise).
"""
docstring.snippets['cmap.gamma'] = """
gamma : float, optional
Sets `gamma1` and `gamma2` to this identical value.
gamma1 : float, optional
If >1, makes low saturation colors more prominent. If <1,
makes high saturation colors more prominent. Similar to the
`HCLWizard <http://hclwizard.org:64230/hclwizard/>`_ option.
See `make_mapping_array` for details.
gamma2 : float, optional
If >1, makes high luminance colors more prominent. If <1,
makes low luminance colors more prominent. Similar to the
`HCLWizard <http://hclwizard.org:64230/hclwizard/>`_ option.
See `make_mapping_array` for details.
"""
docstring.snippets['cmap.from_file'] = """
Parameters
----------
path : str
The file path.
warn_on_failure : bool, optional
If ``True``, issue a warning when loading fails rather than
raising an error.
""" # noqa
def _get_channel(color, channel, space='hcl'):
"""
Get the hue, saturation, or luminance channel value from the input color.
The color name `color` can optionally be a string with the format
``'color+x'`` or ``'color-x'``, where `x` specifies the offset from the
channel value.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgba`.
channel : {'hue', 'chroma', 'saturation', 'luminance'}
The HCL channel to be retrieved.
space : {'hcl', 'hpl', 'hsl', 'hsv', 'rgb'}, optional
The colorspace for the corresponding channel value.
Returns
-------
value : float
The channel value.
"""
# Interpret channel
if callable(color) or isinstance(color, Number):
return color
if channel == 'hue':
channel = 0
elif channel in ('chroma', 'saturation'):
channel = 1
elif channel == 'luminance':
channel = 2
else:
raise ValueError(f'Unknown channel {channel!r}.')
# Interpret string or RGB tuple
offset = 0
if isinstance(color, str):
match = re.search('([-+][0-9.]+)$', color)
if match:
offset = float(match.group(0))
color = color[:match.start()]
return offset + to_xyz(color, space)[channel]
def _clip_colors(colors, clip=True, gray=0.2, warn=False):
"""
Clip impossible colors rendered in an HSL-to-RGB colorspace conversion.
Used by `PerceptuallyUniformColormap`. If `mask` is ``True``, impossible
colors are masked out.
Parameters
----------
colors : list of length-3 tuples
The RGB colors.
clip : bool, optional
If `clip` is ``True`` (the default), RGB channel values >1 are clipped
to 1. Otherwise, the color is masked out as gray.
gray : float, optional
The identical RGB channel values (gray color) to be used if `mask`
is ``True``.
warn : bool, optional
Whether to issue warning when colors are clipped.
"""
colors = np.array(colors)
over = colors > 1
under = colors < 0
if clip:
colors[under] = 0
colors[over] = 1
else:
colors[under | over] = gray
if warn:
msg = 'Clipped' if clip else 'Invalid'
for i, name in enumerate('rgb'):
if under[:, i].any():
warnings._warn_proplot(f'{msg} {name!r} channel ( < 0).')
if over[:, i].any():
warnings._warn_proplot(f'{msg} {name!r} channel ( > 1).')
return colors
def _make_segmentdata_array(values, coords=None, ratios=None):
"""
Return a segmentdata array or callable given the input colors
and coordinates.
Parameters
----------
values : list of float
The channel values.
coords : list of float, optional
The segment coordinates.
ratios : list of float, optional
The relative length of each segment transition.
"""
# Allow callables
if callable(values):
return values
values = np.atleast_1d(values)
if len(values) == 1:
value = values[0]
return [(0, value, value), (1, value, value)]
# Get coordinates
if not np.iterable(values):
raise TypeError('Colors must be iterable, got {values!r}.')
if coords is not None:
coords = np.atleast_1d(coords)
if ratios is not None:
warnings._warn_proplot(
f'Segment coordinates were provided, ignoring '
f'ratios={ratios!r}.'
)
if len(coords) != len(values) or coords[0] != 0 or coords[-1] != 1:
raise ValueError(
f'Coordinates must range from 0 to 1, got {coords!r}.'
)
elif ratios is not None:
coords = np.atleast_1d(ratios)
if len(coords) != len(values) - 1:
raise ValueError(
f'Need {len(values)-1} ratios for {len(values)} colors, '
f'but got {len(ratios)} ratios.'
)
coords = np.concatenate(([0], np.cumsum(coords)))
coords = coords / np.max(coords) # normalize to 0-1
else:
coords = np.linspace(0, 1, len(values))
# Build segmentdata array
array = []
for c, value in zip(coords, values):
array.append((c, value, value))
return array
[docs]def make_mapping_array(N, data, gamma=1.0, inverse=False):
r"""
Similar to `~matplotlib.colors.makeMappingArray` but permits
*circular* hue gradations along 0-360, disables clipping of
out-of-bounds channel values, and uses fancier "gamma" scaling.
Parameters
----------
N : int
Number of points in the colormap lookup table.
data : 2D array-like
List of :math:`(x, y_0, y_1)` tuples specifying the channel jump (from
:math:`y_0` to :math:`y_1`) and the :math:`x` coordinate of that
transition (ranges between 0 and 1).
See `~matplotlib.colors.LinearSegmentedColormap` for details.
gamma : float or list of float, optional
To obtain channel values between coordinates :math:`x_i` and
:math:`x_{i+1}` in rows :math:`i` and :math:`i+1` of `data`,
we use the formula:
.. math::
y = y_{1,i} + w_i^{\gamma_i}*(y_{0,i+1} - y_{1,i})
where :math:`\gamma_i` corresponds to `gamma` and the weight
:math:`w_i` ranges from 0 to 1 between rows ``i`` and ``i+1``.
If `gamma` is float, it applies to every transition. Otherwise,
its length must equal ``data.shape[0]-1``.
This is like the `gamma` used with matplotlib's
`~matplotlib.colors.makeMappingArray`, except it controls the
weighting for transitions *between* each segment data coordinate rather
than the coordinates themselves. This makes more sense for
`PerceptuallyUniformColormap`\ s because they usually consist of just
one linear transition for *sequential* colormaps and two linear
transitions for *diverging* colormaps -- and in the latter case, it
is often desirable to modify both "halves" of the colormap in the
same way.
inverse : bool, optional
If ``True``, :math:`w_i^{\gamma_i}` is replaced with
:math:`1 - (1 - w_i)^{\gamma_i}` -- that is, when `gamma` is greater
than 1, this weights colors toward *higher* channel values instead
of lower channel values.
This is implemented in case we want to apply *equal* "gamma scaling"
to different HSL channels in different directions. Usually, this
is done to weight low data values with higher luminance *and* lower
saturation, thereby emphasizing "extreme" data values with stronger
colors.
"""
# Allow for *callable* instead of linearly interpolating between segments
gammas = np.atleast_1d(gamma)
if (gammas < 0.01).any() or (gammas > 10).any():
raise ValueError('Gamma can only be in range [0.01,10].')
if callable(data):
if len(gammas) > 1:
raise ValueError(
'Only one gamma allowed for functional segmentdata.')
x = np.linspace(0, 1, N)**gamma
lut = np.array(data(x), dtype=float)
return lut
# Get array
data = np.array(data)
shape = data.shape
if len(shape) != 2 or shape[1] != 3:
raise ValueError('Data must be nx3 format.')
if len(gammas) != 1 and len(gammas) != shape[0] - 1:
raise ValueError(
f'Need {shape[0]-1} gammas for {shape[0]}-level mapping array, '
f'but got {len(gamma)}.'
)
if len(gammas) == 1:
gammas = np.repeat(gammas, shape[:1])
# Get indices
x = data[:, 0]
y0 = data[:, 1]
y1 = data[:, 2]
if x[0] != 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.'
)
x = x * (N - 1)
# Get distances from the segmentdata entry to the *left* for each requested
# level, excluding ends at (0,1), which must exactly match segmentdata ends
xq = (N - 1) * np.linspace(0, 1, N)
# where xq[i] must be inserted so it is larger than x[ind[i]-1] but
# smaller than x[ind[i]]
ind = np.searchsorted(x, xq)[1:-1]
distance = (xq[1:-1] - x[ind - 1]) / (x[ind] - x[ind - 1])
# Scale distances in each segment by input gamma
# The ui are starting-points, the ci are counts from that point
# over which segment applies (i.e. where to apply the gamma), the relevant
# 'segment' is to the *left* of index returned by searchsorted
_, uind, cind = np.unique(ind, return_index=True, return_counts=True)
for ui, ci in zip(uind, cind): # length should be N-1
# the relevant segment is to *left* of this number
gamma = gammas[ind[ui] - 1]
if gamma == 1:
continue
ireverse = False
if ci > 1: # i.e. more than 1 color in this 'segment'
# by default want to weight toward a *lower* channel value
ireverse = ((y0[ind[ui]] - y1[ind[ui] - 1]) < 0)
if inverse:
ireverse = not ireverse
if ireverse:
distance[ui:ui + ci] = 1 - (1 - distance[ui:ui + ci])**gamma
else:
distance[ui:ui + ci] **= gamma
# Perform successive linear interpolations all rolled up into one equation
lut = np.zeros((N,), float)
lut[1:-1] = distance * (y0[ind] - y1[ind - 1]) + y1[ind - 1]
lut[0] = y1[0]
lut[-1] = y0[-1]
return lut
class _Colormap(object):
"""
Mixin class used to add some helper methods.
"""
def _get_data(self, ext, alpha=True):
"""
Return a string containing the colormap colors for saving.
Parameters
----------
ext : {'hex', 'txt', 'rgb'}
The filename extension.
alpha : bool, optional
Whether to include an opacity column.
"""
# Get lookup table colors and filter out bad ones
if not self._isinit:
self._init()
colors = self._lut[:-3, :]
# Get data string
if ext == 'hex':
data = ', '.join(mcolors.to_hex(color) for color in colors)
elif ext in ('txt', 'rgb'):
rgb = mcolors.to_rgba if alpha else mcolors.to_rgb
data = [rgb(color) for color in colors]
data = '\n'.join(
' '.join(f'{num:0.6f}' for num in line) for line in data
)
else:
raise ValueError(
f'Invalid extension {ext!r}. Options are: '
"'hex', 'txt', 'rgb', 'rgba'."
)
return data
def _parse_path(self, path, dirname='.', ext=''):
"""
Parse the user input path.
Parameters
----------
dirname : str, optional
The default directory.
ext : str, optional
The default extension.
"""
path = os.path.expanduser(path or '')
dirname = os.path.expanduser(dirname or '')
if not path or os.path.isdir(path):
path = os.path.join(path or dirname, self.name) # default name
dirname, basename = os.path.split(path) # default to current directory
path = os.path.join(dirname or '.', basename)
if not os.path.splitext(path)[-1]:
path = path + '.' + ext # default file extension
return path
@classmethod
def _from_file(cls, filename, warn_on_failure=False):
"""
Read generalized colormap and color cycle files.
"""
filename = os.path.expanduser(filename)
name, ext = os.path.splitext(os.path.basename(filename))
listed = issubclass(cls, mcolors.ListedColormap)
reversed = name[-2:] == '_r'
# Warn if loading failed during `register_cmaps` or `register_cycles`
# but raise error if user tries to load a file.
def _warn_or_raise(msg, error=RuntimeError):
if warn_on_failure:
warnings._warn_proplot(msg)
else:
raise error(msg)
if not os.path.exists(filename):
return _warn_or_raise(f'File {filename!r} not found.', FileNotFoundError)
# Directly read segmentdata json file
# NOTE: This is special case! Immediately return name and cmap
ext = ext[1:]
if ext == 'json':
if listed:
raise TypeError(
f'Cannot load listed colormaps from json files ({filename!r}).'
)
try:
with open(filename, 'r') as fh:
data = json.load(fh)
except json.JSONDecodeError:
return _warn_or_raise(
f'Failed to load {filename!r}.', json.JSONDecodeError
)
kw = {}
for key in ('cyclic', 'gamma', 'gamma1', 'gamma2', 'space'):
if key in data:
kw[key] = data.pop(key, None)
if 'red' in data:
cmap = LinearSegmentedColormap(name, data)
else:
cmap = PerceptuallyUniformColormap(name, data, **kw)
if reversed:
cmap = cmap.reversed(name[:-2])
return cmap
# Read .rgb and .rgba files
if ext in ('txt', 'rgb'):
# Load
# NOTE: This appears to be biggest import time bottleneck! Increases
# time from 0.05s to 0.2s, with numpy loadtxt or with this regex thing.
delim = re.compile(r'[,\s]+')
data = [
delim.split(line.strip())
for line in open(filename)
if line.strip() and line.strip()[0] != '#'
]
try:
data = [[float(num) for num in line] for line in data]
except ValueError:
return _warn_or_raise(
f'Failed to load {filename!r}. Expected a table of comma '
'or space-separated values.'
)
# Build x-coordinates and standardize shape
data = np.array(data)
if data.shape[1] not in (3, 4):
return _warn_or_raise(
f'Failed to load {filename!r}. Got {data.shape[1]} columns, '
f'but expected 3 or 4.'
)
if ext[0] != 'x': # i.e. no x-coordinates specified explicitly
x = np.linspace(0, 1, data.shape[0])
else:
x, data = data[:, 0], data[:, 1:]
# Load XML files created with scivizcolor
# Adapted from script found here:
# https://sciviscolor.org/matlab-matplotlib-pv44/
elif ext == 'xml':
try:
doc = ElementTree.parse(filename)
except ElementTree.ParseError:
return _warn_or_raise(
f'Failed to load {filename!r}. Parsing error.',
ElementTree.ParseError
)
x, data = [], []
for s in doc.getroot().findall('.//Point'):
# Verify keys
if any(key not in s.attrib for key in 'xrgb'):
return _warn_or_raise(
f'Failed to load {filename!r}. Missing an x, r, g, or b '
'specification inside one or more <Point> tags.'
)
# Get data
color = []
for key in 'rgbao': # o for opacity
if key not in s.attrib:
continue
color.append(float(s.attrib[key]))
x.append(float(s.attrib['x']))
data.append(color)
# Convert to array
if not all(
len(data[0]) == len(color) and len(color) in (3, 4)
for color in data
):
return _warn_or_raise(
f'Failed to load {filename!r}. Unexpected number of channels '
'or mixed channels across <Point> tags.'
)
# Read hex strings
elif ext == 'hex':
# Read arbitrary format
string = open(filename).read() # into single string
data = re.findall(HEX_PATTERN, string)
if len(data) < 2:
return _warn_or_raise(
f'Failed to load {filename!r}. Hex strings not found.'
)
# Convert to array
x = np.linspace(0, 1, len(data))
data = [to_rgb(color) for color in data]
# Invalid extension
else:
return _warn_or_raise(
f'Colormap or cycle file {filename!r} has unknown extension.'
)
# Standardize and reverse if necessary to cmap
# TODO: Document the fact that filenames ending in _r return a reversed
# version of the colormap stored in that file.
x, data = np.array(x), np.array(data)
x = (x - x.min()) / (x.max() - x.min()) # ensure they span 0-1
if np.any(data > 2): # from 0-255 to 0-1
data = data / 255
if reversed:
name = name[:-2]
data = data[::-1, :]
x = 1 - x[::-1]
if listed:
return ListedColormap(data, name)
else:
data = [(x, color) for x, color in zip(x, data)]
return LinearSegmentedColormap.from_list(name, data)
[docs]class LinearSegmentedColormap(mcolors.LinearSegmentedColormap, _Colormap):
r"""
New base class for all `~matplotlib.colors.LinearSegmentedColormap`\ s.
"""
def __str__(self):
return type(self).__name__ + f'(name={self.name!r})'
def __repr__(self):
string = f" 'name': {self.name!r},\n"
if hasattr(self, '_space'):
string += f" 'space': {self._space!r},\n"
if hasattr(self, '_cyclic'):
string += f" 'cyclic': {self._cyclic!r},\n"
for key, data in self._segmentdata.items():
if callable(data):
string += f' {key!r}: <function>,\n'
else:
string += (f' {key!r}: [{data[0][2]:.3f}, '
f'..., {data[-1][1]:.3f}],\n')
return type(self).__name__ + '({\n' + string + '})'
@docstring.add_snippets
def __init__(
self, name, segmentdata, N=None, gamma=1,
cyclic=False, alpha=None,
):
"""
Parameters
----------
%(cmap.init)s
gamma : float, optional
Gamma scaling used for the *x* coordinates.
"""
N = _not_none(N, rcParams['image.lut'])
super().__init__(name, segmentdata, N=N, gamma=gamma)
self._cyclic = cyclic
if alpha is not None:
self.set_alpha(alpha)
[docs] def append(self, *args, ratios=None, name=None, N=None, **kwargs):
"""
Return the concatenation of this colormap with the
input colormaps.
Parameters
----------
*args
Instances of `LinearSegmentedColormap`.
ratios : list of float, optional
Relative extent of each component colormap in the merged colormap.
Length must equal ``len(args) + 1``.
For example, ``cmap1.append(cmap2, ratios=(2, 1))`` generates
a colormap with the left two-thrids containing colors from
``cmap1`` and the right one-third containing colors from ``cmap2``.
name : str, optional
The colormap name. Default is
``'_'.join(cmap.name for cmap in args)``.
N : int, optional
The number of points in the colormap lookup table.
Default is :rc:`image.lut` times ``len(args)``.
Other parameters
----------------
**kwargs
Passed to `LinearSegmentedColormap.copy`
or `PerceptuallyUniformColormap.copy`.
Returns
-------
`LinearSegmentedColormap`
The colormap.
"""
# Parse input args
if not args:
return self
if not all(
isinstance(cmap, mcolors.LinearSegmentedColormap) for cmap in args
):
raise TypeError(
f'Input arguments {args!r} must be instances of '
'LinearSegmentedColormap.'
)
# PerceptuallyUniformColormap --> LinearSegmentedColormap conversions
cmaps = [self, *args]
spaces = {getattr(cmap, '_space', None) for cmap in cmaps}
to_linear_segmented = len(spaces) > 1 # mixed colorspaces *or* mixed types
if to_linear_segmented:
for i, cmap in enumerate(cmaps):
if isinstance(cmap, PerceptuallyUniformColormap):
cmaps[i] = cmap.to_linear_segmented()
# Combine the segmentdata, and use the y1/y2 slots at merge points so
# we never interpolate between end colors of different colormaps
segmentdata = {}
if name is None:
name = '_'.join(cmap.name for cmap in cmaps)
if not np.iterable(ratios):
ratios = [1] * len(cmaps)
ratios = np.asarray(ratios) / np.sum(ratios)
x0 = np.append(0, np.cumsum(ratios)) # coordinates for edges
xw = x0[1:] - x0[:-1] # widths between edges
for key in cmaps[0]._segmentdata.keys(): # not self._segmentdata
# Callable segments
# WARNING: If just reference a global 'funcs' list from inside the
# 'data' function it can get overwritten in this loop. Must
# embed 'funcs' into the definition using a keyword argument.
callable_ = [callable(cmap._segmentdata[key]) for cmap in cmaps]
if all(callable_): # expand range from x-to-w to 0-1
funcs = [cmap._segmentdata[key] for cmap in cmaps]
def xyy(ix, funcs=funcs):
ix = np.atleast_1d(ix)
kx = np.empty(ix.shape)
for j, jx in enumerate(ix.flat):
idx = max(np.searchsorted(x0, jx) - 1, 0)
kx.flat[j] = funcs[idx]((jx - x0[idx]) / xw[idx])
return kx
# Concatenate segment arrays and make the transition at the
# seam instant so we *never interpolate* between end colors
# of different maps.
elif not any(callable_):
datas = []
for x, w, cmap in zip(x0[:-1], xw, cmaps):
xyy = np.array(cmap._segmentdata[key])
xyy[:, 0] = x + w * xyy[:, 0]
datas.append(xyy)
for i in range(len(datas) - 1):
datas[i][-1, 2] = datas[i + 1][0, 2]
datas[i + 1] = datas[i + 1][1:, :]
xyy = np.concatenate(datas, axis=0)
xyy[:, 0] = xyy[:, 0] / xyy[:, 0].max(axis=0) # fix fp errors
else:
raise TypeError(
'Mixed callable and non-callable colormap values.'
)
segmentdata[key] = xyy
# Handle gamma values
if key == 'saturation':
ikey = 'gamma1'
elif key == 'luminance':
ikey = 'gamma2'
else:
continue
if ikey in kwargs:
continue
gamma = []
for cmap in cmaps:
igamma = getattr(cmap, '_' + ikey)
if not np.iterable(igamma):
if all(callable_):
igamma = [igamma]
else:
igamma = (len(cmap._segmentdata[key]) - 1) * [igamma]
gamma.extend(igamma)
if all(callable_):
if any(igamma != gamma[0] for igamma in gamma[1:]):
warnings._warn_proplot(
'Cannot use multiple segment gammas when '
'concatenating callable segments. Using the first '
f'gamma of {gamma[0]}.'
)
gamma = gamma[0]
kwargs[ikey] = gamma
# Return copy or merge mixed types
if to_linear_segmented and isinstance(self, PerceptuallyUniformColormap):
return LinearSegmentedColormap(name, segmentdata, N, **kwargs)
else:
return self.copy(name, segmentdata, N, **kwargs)
[docs] def cut(self, cut=None, name=None, left=None, right=None, **kwargs):
"""
Return a version of the colormap with the center "cut out".
This is great for making the transition from "negative" to "positive"
in a diverging colormap more distinct.
Parameters
----------
cut : float, optional
The proportion to cut from the center of the colormap.
For example, ``center=0.1`` cuts the central 10%.
name : str, optional
The name of the new colormap. Default is
``self.name + '_cut'``.
left, right : float, optional
The colormap indices for the "leftmost" and "rightmost" colors.
Defaults are ``0`` and ``1``. See
`~LinearSegmentedColormap.truncate` for details.
right : float, optional
The colormap index for the new "rightmost" color. Must fall between
Other parameters
----------------
**kwargs
Passed to `LinearSegmentedColormap.copy`
or `PerceptuallyUniformColormap.copy`.
Returns
-------
`LinearSegmentedColormap`
The colormap.
"""
# Parse input args
left = max(_not_none(left, 0), 0)
right = min(_not_none(right, 1), 1)
offset = _not_none(cut, 0) / 2
if offset == 0:
return self.truncate(left, right)
# Decompose cut into two truncations followed by concatenation
if 0.5 - offset < left or 0.5 + offset > right:
raise ValueError(
f'Invalid cut={cut} for left={left} and right={right}.'
)
if name is None:
name = self.name + '_cut'
cmap_left = self.truncate(left, 0.5 - offset)
cmap_right = self.truncate(0.5 + offset, right)
return cmap_left.append(cmap_right, name=name, **kwargs)
[docs] def reversed(self, name=None, **kwargs):
"""
Return a reversed copy of the colormap, as in
`~matplotlib.colors.LinearSegmentedColormap`.
Parameters
----------
name : str, optional
The new colormap name. Default is ``self.name + '_r'``.
Other parameters
----------------
**kwargs
Passed to `LinearSegmentedColormap.copy`
or `PerceptuallyUniformColormap.copy`.
"""
if name is None:
name = self.name + '_r'
def factory(dat):
def func_r(x):
return dat(1.0 - x)
return func_r
segmentdata = {
key:
factory(data) if callable(data) else
[(1.0 - x, y1, y0) for x, y0, y1 in reversed(data)]
for key, data in self._segmentdata.items()
}
for key in ('gamma1', 'gamma2'):
if key in kwargs:
continue
gamma = getattr(self, '_' + key, None)
if gamma is not None and np.iterable(gamma):
kwargs[key] = gamma[::-1]
return self.copy(name, segmentdata, **kwargs)
[docs] def save(self, path=None, alpha=True):
"""
Save the colormap data to a file.
Parameters
----------
path : str, optional
The output filename. If not provided, the colormap
is saved under ``~/.proplot/cmaps/name.json`` where ``name``
is the colormap name. Valid extensions are described in
the below table.
===================== ==========================================
Extension Description
===================== ==========================================
``.json`` (default) JSON database of the channel segment data.
``.hex`` Comma-delimited list of HEX strings.
``.rgb``, ``.txt`` 3-4 column table of channel values.
===================== ==========================================
alpha : bool, optional
Whether to include an opacity column for ``.rgb``
and ``.txt`` files.
"""
dirname = os.path.join('~', '.proplot', 'cmaps')
filename = self._parse_path(path, dirname, 'json')
# Save channel segment data in json file
_, ext = os.path.splitext(filename)
if ext[1:] == 'json':
# Sanitize segmentdata values
# Convert np.float to builtin float, np.array to list of lists,
# and callable to list of lists. We tried encoding func.__code__
# with base64 and marshal instead, but when cmap.concatenate()
# embeds functions as keyword arguments, this seems to make it
# *impossible* to load back up the function with FunctionType
# (error message: arg 5 (closure) must be tuple). Instead use
# this brute force workaround.
data = {}
for key, value in self._segmentdata.items():
if callable(value):
x = np.linspace(0, 1, 256) # just save the transitions
y = np.array([value(_) for _ in x]).squeeze()
value = np.vstack((x, y, y)).T
data[key] = np.asarray(value).astype(float).tolist()
# Add critical attributes to the dictionary
keys = ()
if isinstance(self, PerceptuallyUniformColormap):
keys = ('cyclic', 'gamma1', 'gamma2', 'space')
elif isinstance(self, LinearSegmentedColormap):
keys = ('cyclic', 'gamma')
for key in keys:
data[key] = getattr(self, '_' + key)
with open(filename, 'w') as fh:
json.dump(data, fh, indent=4)
# Save lookup table colors
else:
data = self._get_data(ext[1:], alpha=alpha)
with open(filename, 'w') as fh:
fh.write(data)
print(f'Saved colormap to {filename!r}.')
[docs] def set_alpha(self, alpha, coords=None, ratios=None):
"""
Set the opacity for the entire colormap or set up an opacity gradation.
Parameters
----------
alpha : float or list of float
If float, this is the opacity for the entire colormap. If list of
float, the colormap traverses these opacity values.
coords : list of float, optional
Colormap coordinates for the opacity values. The first and last
coordinates must be ``0`` and ``1``. If `alpha` is not scalar, the
default coordinates are ``np.linspace(0, 1, len(alpha))``.
ratios : list of float, optional
Relative extent of each opacity transition segment. Length should
equal ``len(alpha) + 1``. For example
``cmap.set_alpha((1, 1, 0), ratios=(2, 1))`` creates a transtion from
100 percent to 0 percent opacity in the right *third* of the colormap.
"""
alpha = _make_segmentdata_array(alpha, coords=coords, ratios=ratios)
self._segmentdata['alpha'] = alpha
self._isinit = False
[docs] def set_cyclic(self, b):
"""
Set whether this colormap is "cyclic". See `LinearSegmentedColormap`
for details.
"""
self._cyclic = bool(b)
self._isinit = False
[docs] def shifted(self, shift=180, name=None, **kwargs):
"""
Return a cyclicaly shifted version of the colormap. If the colormap
cyclic property is set to ``False`` a warning will be raised.
Parameters
----------
shift : float, optional
The number of degrees to shift, out of 360 degrees.
The default is ``180``.
name : str, optional
The name of the new colormap. Default is
``self.name + '_s'``.
Other parameters
----------------
**kwargs
Passed to `LinearSegmentedColormap.copy`
or `PerceptuallyUniformColormap.copy`.
"""
shift = ((shift or 0) / 360) % 1
if shift == 0:
return self
if name is None:
name = self.name + '_s'
if not self._cyclic:
warnings._warn_proplot(
f'Shifting non-cyclic colormap {self.name!r}. '
f'Use cmap.set_cyclic(True) or Colormap(..., cyclic=True) to '
'suppress this warning.'
)
self._cyclic = True
# Decompose shift into two truncations followed by concatenation
cmap_left = self.truncate(shift, 1)
cmap_right = self.truncate(0, shift)
return cmap_left.append(
cmap_right, ratios=(1 - shift, shift), name=name, **kwargs
)
[docs] def truncate(self, left=None, right=None, name=None, **kwargs):
"""
Return a truncated version of the colormap.
Parameters
----------
left : float, optional
The colormap index for the new "leftmost" color. Must fall between
``0`` and ``1``. For example,
``left=0.1`` cuts the leftmost 10%% of the colors.
right : float, optional
The colormap index for the new "rightmost" color. Must fall between
``0`` and ``1``. For example,
``right=0.9`` cuts the leftmost 10%% of the colors.
name : str, optional
The name of the new colormap. Default is
``self.name + '_truncate'``.
Other parameters
----------------
**kwargs
Passed to `LinearSegmentedColormap.copy`
or `PerceptuallyUniformColormap.copy`.
"""
# Bail out
left = max(_not_none(left, 0), 0)
right = min(_not_none(right, 1), 1)
if left == 0 and right == 1:
return self
if name is None:
name = self.name + '_truncate'
# Resample the segmentdata arrays
segmentdata = {}
for key, xyy in self._segmentdata.items():
# Callable array
# WARNING: If just reference a global 'xyy' callable from inside
# the lambda function it gets overwritten in the loop! Must embed
# the old callable in the new one as a default keyword arg.
if callable(xyy):
def xyy(x, func=xyy):
return func(left + x * (right - left))
# Slice
# l is the first point where x > 0 or x > left, should be >= 1
# r is the last point where r < 1 or r < right
else:
xyy = np.asarray(xyy)
x = xyy[:, 0]
l = np.searchsorted(x, left) # first x value > left # noqa
r = np.searchsorted(x, right) - 1 # last x value < right
xc = xyy[l:r + 1, :].copy()
xl = xyy[l - 1, 1:] + (left - x[l - 1]) * (
(xyy[l, 1:] - xyy[l - 1, 1:]) / (x[l] - x[l - 1])
)
xr = xyy[r, 1:] + (right - x[r]) * (
(xyy[r + 1, 1:] - xyy[r, 1:]) / (x[r + 1] - x[r])
)
xyy = np.vstack(((left, *xl), xc, (right, *xr)))
xyy[:, 0] = (xyy[:, 0] - left) / (right - left)
segmentdata[key] = xyy
# Retain the corresponding gamma *segments*
if key == 'saturation':
ikey = 'gamma1'
elif key == 'luminance':
ikey = 'gamma2'
else:
continue
if ikey in kwargs:
continue
gamma = getattr(self, '_' + ikey)
if np.iterable(gamma):
if callable(xyy):
if any(igamma != gamma[0] for igamma in gamma[1:]):
warnings._warn_proplot(
'Cannot use multiple segment gammas when '
'truncating colormap. Using the first gamma '
f'of {gamma[0]}.'
)
gamma = gamma[0]
else:
gamma = gamma[l - 1:r + 1]
kwargs[ikey] = gamma
return self.copy(name, segmentdata, **kwargs)
[docs] def copy(
self, name=None, segmentdata=None, N=None, *,
alpha=None, gamma=None, cyclic=None
):
"""
Return a new colormap with relevant properties copied from this one
if they were not provided as keyword arguments.
Parameters
----------
name : str
The colormap name. Default is ``self.name + '_copy'``.
segmentdata, N, alpha, gamma, cyclic : optional
See `LinearSegmentedColormap`. If not provided,
these are copied from the current colormap.
"""
if name is None:
name = self.name + '_copy'
if segmentdata is None:
segmentdata = self._segmentdata
if gamma is None:
gamma = self._gamma
if cyclic is None:
cyclic = self._cyclic
if N is None:
N = self.N
cmap = LinearSegmentedColormap(
name, segmentdata, N,
alpha=alpha, gamma=gamma, cyclic=cyclic
)
cmap._rgba_bad = self._rgba_bad
cmap._rgba_under = self._rgba_under
cmap._rgba_over = self._rgba_over
return cmap
[docs] def to_listed(self, samples=10, **kwargs):
"""
Convert the `LinearSegmentedColormap` to a `ListedColormap` by drawing
samples from the colormap.
Parameters
----------
samples : int or list of float, optional
If integer, draw samples at the colormap coordinates
``np.linspace(0, 1, samples)``. If list of float, draw samples
at the specified points.
Other parameters
----------------
**kwargs
Passed to `ListedColormap`.
"""
if isinstance(samples, Integral):
samples = np.linspace(0, 1, samples)
elif not np.iterable(samples):
raise TypeError('Samples must be integer or iterable.')
samples = np.asarray(samples)
colors = self(samples)
kwargs.setdefault('name', self.name)
return ListedColormap(colors, **kwargs)
[docs] @docstring.add_snippets
@classmethod
def from_file(cls, path, warn_on_failure=False):
"""
Load colormap from a file.
%(cmap.ext_table)s
%(cmap.from_file)s
"""
return cls._from_file(path, warn_on_failure=warn_on_failure)
[docs] @classmethod
def from_list(cls, name, colors, ratios=None, **kwargs):
"""
Make a `LinearSegmentedColormap` from a list of colors.
Parameters
----------
name : str
The colormap name.
colors : list of color-spec or (float, color-spec) tuples, optional
If list of RGB[A] tuples or color strings, the colormap transitions
evenly from ``colors[0]`` at the left-hand side to
``colors[-1]`` at the right-hand side.
If list of (float, color-spec) tuples, the float values are the
coordinate of each transition and must range from 0 to 1. This
can be used to divide the colormap range unevenly.
ratios : list of float, optional
Relative extents of each color transition. Must have length
``len(colors) - 1``. Larger numbers indicate a slower
transition, smaller numbers indicate a faster transition.
Other parameters
----------------
**kwargs
Passed to `LinearSegmentedColormap`.
Returns
-------
`LinearSegmentedColormap`
The colormap.
"""
# Get coordinates
coords = None
if not np.iterable(colors):
raise TypeError('Colors must be iterable.')
if (
np.iterable(colors[0])
and len(colors[0]) == 2
and not isinstance(colors[0], str)
):
coords, colors = zip(*colors)
colors = [to_rgba(color) for color in colors]
# Build segmentdata
keys = ('red', 'green', 'blue', 'alpha')
cdict = {}
for key, values in zip(keys, zip(*colors)):
cdict[key] = _make_segmentdata_array(values, coords, ratios)
return cls(name, cdict, **kwargs)
# Rename methods
concatenate = warnings._rename_obj('concatenate', append)
punched = warnings._rename_obj('punched', cut)
truncated = warnings._rename_obj('truncated', truncate)
updated = warnings._rename_obj('updated', copy)
[docs]class ListedColormap(mcolors.ListedColormap, _Colormap):
r"""
New base class for all `~matplotlib.colors.ListedColormap`\ s.
"""
def __str__(self):
return f'ListedColormap(name={self.name!r})'
def __repr__(self):
return (
'ListedColormap({\n'
f" 'name': {self.name!r},\n"
f" 'colors': {[mcolors.to_hex(color) for color in self.colors]},\n"
'})')
def __init__(self, *args, alpha=None, **kwargs):
"""
Parameters
----------
alpha : float, optional
The opacity for the entire colormap. Overrides the input
colors.
Other parameters
----------------
*args, **kwargs
Passed to `~matplotlib.colors.ListedColormap`.
"""
super().__init__(*args, **kwargs)
if alpha is not None:
self.set_alpha(alpha)
[docs] def append(self, *args, name=None, N=None, **kwargs):
"""
Append arbitrary colormaps onto this colormap.
Parameters
----------
*args
Instances of `ListedColormap`.
name : str, optional
The colormap name. Default is
``'_'.join(cmap.name for cmap in args)``.
N : int, optional
The number of colors in the colormap lookup table. Default is
the number of colors in the concatenated lists.
Other parameters
----------------
**kwargs
Passed to `~ListedColormap.copy`.
"""
if not args or not all(
isinstance(cmap, mcolors.ListedColormap) for cmap in args
):
raise TypeError(
f'Input arguments {args!r} must be instances of '
'ListedColormap.'
)
cmaps = (self, *args)
if name is None:
name = '_'.join(cmap.name for cmap in cmaps)
colors = [color for cmap in cmaps for color in cmap.colors]
return self.copy(colors, name, N or len(colors), **kwargs)
[docs] def save(self, path=None, alpha=True):
"""
Save the colormap data to a file.
Parameters
----------
path : str, optional
The output filename. If not provided, the colormap
is saved under ``~/.proplot/cycles/name.hex`` where ``name``
is the colormap name. Valid extensions are described in
the below table.
===================== ====================================
Extension Description
===================== ====================================
``.hex`` (default) Comma-delimited list of HEX strings.
``.rgb``, ``.txt`` 3-4 column table of channel values.
===================== ====================================
alpha : bool, optional
Whether to include an opacity column for ``.rgb``
and ``.txt`` files.
"""
dirname = os.path.join('~', '.proplot', 'cycles')
filename = self._parse_path(path, dirname, 'hex')
# Save lookup table colors
_, ext = os.path.splitext(filename)
data = self._get_data(ext[1:], alpha=alpha)
with open(filename, 'w') as fh:
fh.write(data)
print(f'Saved colormap to {filename!r}.')
[docs] def set_alpha(self, alpha):
"""
Set the opacity for the entire colormap.
Parameters
----------
alpha : float
The opacity.
"""
colors = [list(mcolors.to_rgba(color)) for color in self.colors]
for color in colors:
color[3] = alpha
self.colors = colors
self._init()
[docs] def shifted(self, shift=1, name=None):
"""
Return a cyclically shifted version of the colormap.
Parameters
----------
shift : float, optional
The number of places to shift, between ``-self.N`` and ``self.N``.
The default is ``1``.
name : str, optional
The new colormap name. Default is ``self.name + '_s'``.
"""
if not shift:
return self
if name is None:
name = self.name + '_s'
shift = shift % len(self.colors)
colors = list(self.colors)
colors = colors[shift:] + colors[:shift]
return self.copy(colors, name, len(colors))
[docs] def truncate(self, left=None, right=None, name=None):
"""
Return a truncated version of the colormap.
Parameters
----------
left : float, optional
The colormap index for the new "leftmost" color. Must fall between
``0`` and ``self.N``. For example,
``left=2`` deletes the two first colors.
right : float, optional
The colormap index for the new "rightmost" color. Must fall between
``0`` and ``self.N``. For example,
``right=4`` deletes colors after the fourth color.
name : str, optional
The new colormap name. Default is ``self.name + '_truncate'``.
"""
if left is None and right is None:
return self
if name is None:
name = self.name + '_truncate'
colors = self.colors[left:right]
return self.copy(colors, name, len(colors))
[docs] def copy(self, colors=None, name=None, N=None, *, alpha=None):
"""
Return a new colormap with relevant properties copied from this one
if they were not provided as keyword arguments.
Parameters
----------
name : str
The colormap name. Default is ``self.name + '_copy'``.
colors, N, alpha : optional
See `ListedColormap`. If not provided,
these are copied from the current colormap.
"""
if name is None:
name = self.name + '_copy'
if colors is None:
colors = self.colors
if N is None:
N = self.N
cmap = ListedColormap(colors, name, N, alpha=alpha)
cmap._rgba_bad = self._rgba_bad
cmap._rgba_under = self._rgba_under
cmap._rgba_over = self._rgba_over
return cmap
[docs] @docstring.add_snippets
@classmethod
def from_file(cls, path, warn_on_failure=False):
"""
Load color cycle from a file.
%(cmap.ext_table)s
%(cmap.from_file)s
"""
return cls._from_file(path, warn_on_failure=warn_on_failure)
# Rename methods
concatenate = warnings._rename_obj('concatenate', append)
truncated = warnings._rename_obj('truncated', truncate)
updated = warnings._rename_obj('updated', copy)
def _check_levels(levels, allow_descending=True):
"""
Ensure the levels are monotonic. If they are descending, either
reverse them or raise an error.
"""
levels = np.atleast_1d(levels)
if levels.ndim != 1 or levels.size < 2:
raise ValueError(f'Levels {levels} must be 1d vector with size >= 2.')
if isinstance(levels, ma.core.MaskedArray):
levels = levels.filled(np.nan)
if not np.all(np.isfinite(levels)):
raise ValueError(f'Levels {levels} contain invalid values.')
diffs = np.sign(np.diff(levels))
if all(diffs == 1):
descending = False
elif all(diffs == -1) and allow_descending:
levels = levels[::-1]
descending = True
elif allow_descending:
raise ValueError(f'Levels {levels} must be monotonic.')
else:
raise ValueError(f'Levels {levels} must be monotonically increasing.')
return levels, descending
def _interpolate_basic(x, x0, x1, y0, y1):
"""
Basic interpolation between pairs of fixed points.
"""
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
def _interpolate_extrapolate(xq, x, y):
"""
Efficient vectorized linear interpolation. Similar to `numpy.interp`
except this does not truncate out-of-bounds values (i.e. is reversible).
"""
# Follow example of make_mapping_array for efficient, vectorized
# linear interpolation across multiple segments.
# * Normal test puts values at a[i] if a[i-1] < v <= a[i]; for
# left-most data, satisfy a[0] <= v <= a[1]
# * searchsorted gives where xq[i] must be inserted so it is larger
# than x[ind[i]-1] but smaller than x[ind[i]]
# yq = ma.masked_array(np.interp(xq, x, y), mask=ma.getmask(xq))
x = np.asarray(x)
y = np.asarray(y)
xq = np.atleast_1d(xq)
idx = np.searchsorted(x, xq)
idx[idx == 0] = 1 # get normed value <0
idx[idx == len(x)] = len(x) - 1 # get normed value >0
distance = (xq - x[idx - 1]) / (x[idx] - x[idx - 1])
yq = distance * (y[idx] - y[idx - 1]) + y[idx - 1]
yq = ma.masked_array(yq, mask=ma.getmask(xq))
return yq
[docs]class DiscreteNorm(mcolors.BoundaryNorm):
"""
Meta-normalizer that discretizes the possible color values returned by
arbitrary continuous normalizers given a list of level boundaries. This
is applied to all colormap plots in ProPlot.
"""
# See this post: https://stackoverflow.com/a/48614231/4970632
# WARNING: Must be child of BoundaryNorm. Many methods in ColorBarBase
# test for class membership, crucially including _process_values(), which
# if it doesn't detect BoundaryNorm will try to use DiscreteNorm.inverse().
def __init__(
self, levels, norm=None, step=1.0, extend=None,
clip=False, descending=False,
):
"""
Parameters
----------
levels : list of float
The level boundaries.
norm : `~matplotlib.colors.Normalize`, optional
The normalizer used to transform `levels` and all data passed
to `~DiscreteNorm.__call__` before discretization. The ``vmin``
and ``vmax`` of the normalizer are set to the minimum and
maximum values in `levels`.
step : float, optional
The intensity of the transition to out-of-bounds colors as a
fraction of the adjacent step between in-bounds colors.
Default is ``1``.
extend : {'neither', 'both', 'min', 'max'}, optional
Which out-of-bounds regions should be assigned unique colormap
colors. The normalizer needs this information so it can ensure
the colorbar always spans the full range of colormap colors.
clip : bool, optional
Whether to clip values falling outside of the level bins. This
only has an effect on lower colors when extend is
``'min'`` or ``'both'``, and on upper colors when extend is
``'max'`` or ``'both'``.
descending : bool, optional
Whether the levels are meant to be descending. This will cause
the colorbar axis to be reversed when it is drawn with a
`~matplotlib.cm.ScalarMappable` that uses this normalizer.
Note
----
If you are using a diverging colormap with ``extend='max'`` or
``extend='min'``, the center will get messed up. But that is very
strange usage anyway... so please just don't do that :)
"""
# Parse input
# NOTE: This must be a subclass BoundaryNorm, so ColorbarBase will
# detect it... even though we completely override it.
if not norm:
norm = mcolors.Normalize()
elif isinstance(norm, mcolors.BoundaryNorm):
raise ValueError('Normalizer cannot be instance of BoundaryNorm.')
elif not isinstance(norm, mcolors.Normalize):
raise ValueError('Normalizer must be instance of Normalize.')
extend = extend or 'neither'
extends = ('both', 'min', 'max', 'neither')
if extend not in extends:
raise ValueError(
f'Unknown extend option {extend!r}. Options are: '
+ ', '.join(map(repr, extends)) + '.'
)
# Ensure monotonically increasing levels
levels, _ = _check_levels(levels, allow_descending=False)
bins, _ = _check_levels(norm(levels), allow_descending=False)
self.N = levels.size
self.clip = clip
self.boundaries = levels
self.vmin = norm.vmin = vmin = np.min(levels)
self.vmax = norm.vmax = vmax = np.max(levels)
vcenter = getattr(norm, 'vcenter', None)
# Get color coordinates corresponding to each bin, plus extra
# 2 color coordinates for out-of-bounds color bins.
# For *same* out-of-bounds colors, looks like [0, 0, ..., 1, 1]
# For *unique* out-of-bounds colors, looks like [0, X, ..., 1 - X, 1]
# NOTE: Critical that we scale the bin centers in "physical space"
# and *then* translate to color coordinates so that nonlinearities in
# the normalization stay intact. If we scaled the bin centers in
# *normalized space* to have minimum 0 maximum 1, would mess up
# color distribution. However this is still not perfect... get
# asymmetric color intensity either side of central point. So add
# special handling for diverging norms below to improve symmetry.
mids = np.zeros((levels.size + 1,))
mids[1:-1] = 0.5 * (levels[1:] + levels[:-1])
mids[0], mids[-1] = mids[1], mids[-2]
if extend in ('min', 'both'):
mids[0] += step * (mids[1] - mids[2])
if extend in ('max', 'both'):
mids[-1] += step * (mids[-2] - mids[-3])
if vcenter is None:
mids = _interpolate_basic(
mids, np.min(mids), np.max(mids), vmin, vmax
)
else:
mids = mids.copy()
mids[mids < vcenter] = _interpolate_basic(
mids[mids < vcenter], np.min(mids), vcenter, vmin, vcenter,
)
mids[mids >= vcenter] = _interpolate_basic(
mids[mids >= vcenter], vcenter, np.max(mids), vcenter, vmax,
)
dest = norm(mids)
# Attributes
# NOTE: If clip is True, we clip values to the centers of the end
# bins rather than vmin/vmax to prevent out-of-bounds colors from
# getting an in-bounds bin color due to landing on a bin edge.
# NOTE: With extend='min' the minimimum in-bounds and out-of-bounds
# colors are the same so clip=True will have no effect. Same goes
# for extend='max' with maximum colors.
# WARNING: For some reason must clip manually for LogNorm, or
# end up with unpredictable fill value, weird "out-of-bounds" colors
self._bmin = np.min(mids)
self._bmax = np.max(mids)
self._bins = bins
self._dest = dest
self._norm = norm
self._norm_clip = None
self._descending = descending
if isinstance(norm, mcolors.LogNorm):
self._norm_clip = (5e-249, None)
[docs] def __call__(self, value, clip=None):
"""
Normalize data values to 0-1.
Parameters
----------
value : numeric
The data to be normalized.
clip : bool, optional
Whether to clip values falling outside of the level bins.
Default is ``self.clip``.
"""
# Follow example of LinearSegmentedNorm, but perform no interpolation,
# just use searchsorted to bin the data.
norm_clip = self._norm_clip
if norm_clip: # special extra clipping due to normalizer
value = np.clip(value, *norm_clip)
if clip is None: # builtin clipping
clip = self.clip
if clip: # note that np.clip can handle masked arrays
value = np.clip(value, self._bmin, self._bmax)
xq, is_scalar = self.process_value(value)
xq = self._norm(xq)
yq = self._dest[np.searchsorted(self._bins, xq)]
yq = ma.array(yq, mask=ma.getmask(xq))
if is_scalar:
yq = np.atleast_1d(yq)[0]
return yq
[docs] def inverse(self, value): # noqa: U100
"""
Raise an error. Inversion after discretization is impossible.
"""
raise ValueError('DiscreteNorm is not invertible.')
[docs]class LinearSegmentedNorm(mcolors.Normalize):
"""
Normalizer that scales data linearly with respect to average *position*
in an arbitrary monotonically increasing level lists. This is the same
algorithm used by `~matplotlib.colors.LinearSegmentedColormap` to
select colors in-between indices in the segment data tables.
This is the default normalizer paired with `DiscreteNorm` whenever `levels`
are non-linearly spaced. Can be explicitly used by passing ``norm='segmented'``
to any command accepting ``cmap``.
"""
def __init__(self, levels, vmin=None, vmax=None, clip=False):
"""
Parameters
----------
levels : list of float
The level boundaries.
vmin, vmax : None
Ignored. `vmin` and `vmax` are set to the minimum and
maximum of `levels`.
clip : bool, optional
Whether to clip values falling outside of the minimum and
maximum levels.
Example
-------
In the below example, unevenly spaced levels are passed to
`~matplotlib.axes.Axes.contourf`, resulting in the automatic
application of `LinearSegmentedNorm`.
>>> import proplot as plot
>>> import numpy as np
>>> levels = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
>>> data = 10 ** (3 * np.random.rand(10, 10))
>>> fig, ax = plot.subplots()
>>> ax.contourf(data, levels=levels)
"""
levels = np.asarray(levels)
levels, _ = _check_levels(levels, allow_descending=False)
dest = np.linspace(0, 1, len(levels))
vmin, vmax = np.min(levels), np.max(levels)
super().__init__(vmin=vmin, vmax=vmax, clip=clip)
self._x = levels
self._y = dest
[docs] def __call__(self, value, clip=None):
"""
Normalize the data values to 0-1. Inverse
of `~LinearSegmentedNorm.inverse`.
Parameters
----------
value : numeric
The data to be normalized.
clip : bool, optional
Whether to clip values falling outside of the minimum and
maximum levels. Default is ``self.clip``.
"""
if clip is None: # builtin clipping
clip = self.clip
if clip: # numpy.clip can handle masked arrays
value = np.clip(value, self.vmin, self.vmax)
xq, is_scalar = self.process_value(value)
yq = _interpolate_extrapolate(xq, self._x, self._y)
if is_scalar:
yq = np.atleast_1d(yq)[0]
return yq
[docs] def inverse(self, value):
"""
Inverse operation of `~LinearSegmentedNorm.__call__`.
Parameters
----------
value : numeric
The data to be un-normalized.
"""
yq, is_scalar = self.process_value(value)
xq = _interpolate_extrapolate(yq, self._y, self._x)
if is_scalar:
xq = np.atleast_1d(xq)[0]
return xq
[docs]class DivergingNorm(mcolors.Normalize):
"""
Normalizer that ensures some central data value lies at the central
colormap color. The default central value is ``0``. Can be used by
passing ``norm='diverging'`` to any command accepting ``cmap``.
"""
def __init__(
self, vcenter=0, vmin=None, vmax=None, fair=True, clip=None
):
"""
Parameters
----------
vcenter : float, optional
The data value corresponding to the central position of the
colormap. The default is ``0``.
vmin, vmax : float, optional
The minimum and maximum data values.
fair : bool, optional
If ``True`` (default), the speeds of the color gradations on
either side of the center point are equal, but colormap colors may
be omitted. If ``False``, all colormap colors are included, but
the color gradations on one side may be faster than the other side.
``False`` should be used with great care, as it may result in
a misleading interpretation of your data.
clip : bool, optional
Whether to clip values falling outside of `vmin` and `vmax`.
"""
# NOTE: This post is an excellent summary of matplotlib's DivergingNorm history:
# https://github.com/matplotlib/matplotlib/issues/15336#issuecomment-535291287
# NOTE: This is a stale PR that plans to implement the same features.
# https://github.com/matplotlib/matplotlib/pull/15333#issuecomment-537545430
# Since proplot is starting without matplotlib's baggage we can just implement
# DivergingNorm like they would prefer if they didn't have to worry about
# confusing users: single class, default "fair" scaling that can be turned off.
super().__init__(vmin, vmax, clip)
self.vmin = vmin
self.vmax = vmax
self.vcenter = vcenter
self.fair = fair
[docs] def __call__(self, value, clip=None):
"""
Normalize data values to 0-1.
Parameters
----------
value : numeric
The data to be normalized.
clip : bool, optional
Whether to clip values falling outside of `vmin` and `vmax`.
Default is ``self.clip``.
"""
xq, is_scalar = self.process_value(value)
self.autoscale_None(xq) # sets self.vmin, self.vmax if None
if clip is None: # builtin clipping
clip = self.clip
if clip: # note that np.clip can handle masked arrays
value = np.clip(value, self.vmin, self.vmax)
if self.vmin > self.vmax:
raise ValueError('vmin must be less than or equal to vmax.')
elif self.vmin == self.vmax:
x = [self.vmin, self.vmax]
y = [0.0, 0.0]
elif self.vcenter >= self.vmax:
x = [self.vmin, self.vcenter]
y = [0.0, 0.5]
elif self.vcenter <= self.vmin:
x = [self.vcenter, self.vmax]
y = [0.5, 1.0]
elif not self.fair:
x = [self.vmin, self.vcenter, self.vmax]
y = [0, 0.5, 1.0]
else:
offset = max(
np.abs(self.vcenter - self.vmin),
np.abs(self.vmax - self.vcenter),
)
x = [self.vcenter - offset, self.vcenter + offset]
y = [0, 1.0]
yq = _interpolate_extrapolate(xq, x, y)
if is_scalar:
yq = np.atleast_1d(yq)[0]
return yq
[docs] def autoscale_None(self, z):
"""
Get vmin and vmax, and then clip at vcenter
"""
super().autoscale_None(z)
if self.vmin > self.vcenter:
self.vmin = self.vcenter
if self.vmax < self.vcenter:
self.vmax = self.vcenter
[docs]class ColorDatabase(dict):
"""
Dictionary subclass used to replace the builtin matplotlib color
database. This allows users to draw colors from named colormaps and color
cycles for any plotting command that accepts a `color` keyword arg.
See `~ColorDatabase.cache` for details.
"""
def __init__(self, mapping):
"""
Parameters
----------
mapping : dict-like
The colors.
"""
super().__init__(mapping)
self._cache = _ColorCache({})
[docs] def __setitem__(self, key, value):
"""
Add a color to the database and clear the cache.
"""
if not isinstance(key, str):
raise ValueError(f'Invalid color name {key!r}. Must be string.')
super().__setitem__(key, value)
self.cache.clear()
def __delitem__(self, key):
"""
Delete a color from the database and clear the cache.
"""
super().__delitem__(key)
self.cache.clear()
@property
def cache(self):
"""
A special dictionary subclass capable of retrieving colors
"on-the-fly" from registered colormaps and color cycles.
* For a smooth colormap, usage is e.g. ``color=('Blues', 0.8)``. The
number is the colormap index, and must be between 0 and 1.
* For a color cycle, usage is e.g. ``color=('colorblind', 2)``. The
number is the list index.
These examples work with any matplotlib command that accepts a `color`
keyword arg.
"""
return self._cache
class _ColorCache(dict):
def __getitem__(self, key):
# Matplotlib 'color' args are passed to to_rgba, which tries to read
# directly from cache and if that fails, sanitizes input, which
# raises error on receiving (colormap, idx) tuple. So we *have* to
# override cache instead of color dict itself.
rgb, alpha = key
if (
not isinstance(rgb, str) and np.iterable(rgb) and len(rgb) == 2
and isinstance(rgb[1], Number) and isinstance(rgb[0], str)
):
try:
cmap = _cmap_database[rgb[0]]
except (TypeError, KeyError):
pass
else:
if isinstance(cmap, ListedColormap):
if not 0 <= rgb[1] < len(cmap.colors):
raise ValueError(
f'Color cycle sample for {rgb[0]!r} cycle must be '
f'between 0 and {len(cmap.colors)-1}, '
f'got {rgb[1]}.'
)
rgb = cmap.colors[rgb[1]] # draw from list of colors
else:
if not 0 <= rgb[1] <= 1:
raise ValueError(
f'Colormap sample for {rgb[0]!r} colormap must be '
f'between 0 and 1, got {rgb[1]}.'
)
rgb = cmap(rgb[1]) # get color selection
rgba = mcolors.to_rgba(rgb, alpha)
return rgba
def _get_cmap(name=None, lut=None):
"""
Monkey patch for matplotlib `~matplotlib.get_cmap`. Permits case-insensitive
search of monkey-patched colormap database (which was broken in v3.2.0).
"""
if name is None:
name = rcParams['image.cmap']
if isinstance(name, mcolors.Colormap):
return name
cmap = _cmap_database[name]
if lut is not None:
cmap = cmap._resample(lut)
return cmap
[docs]class ColormapDatabase(dict):
"""
Dictionary subclass used to replace the `matplotlib.cm.cmap_d`
colormap dictionary. See `~ColormapDatabase.__getitem__` and
`~ColormapDatabase.__setitem__` for details.
"""
def __init__(self, kwargs):
"""
Parameters
----------
kwargs : dict-like
The source dictionary.
"""
for key, value in kwargs.items():
self.__setitem__(key, value)
def __delitem__(self, key):
"""
Delete the item from the database.
"""
key = self._sanitize_key(key, mirror=True)
super().__delitem__(key)
[docs] def __getitem__(self, key):
"""
Retrieve the colormap associated with the sanitized key name. The
key name is case insensitive.
* If the key ends in ``'_r'``, the result of ``cmap.reversed()`` is
returned for the colormap registered under the preceding name.
* If the key ends in ``'_s'``, the result of ``cmap.shifted(180)`` is
returned for the colormap registered under the preceding name.
* Reversed diverging colormaps can be requested with their "reversed"
name -- for example, ``'BuRd'`` is equivalent to ``'RdBu_r'``.
"""
# Sanitize key and handle suffixes
key = self._sanitize_key(key, mirror=True)
shift = key[-2:] == '_s'
if shift:
key = key[:-2]
reverse = key[-2:] == '_r'
if reverse:
key = key[:-2]
# Get item and issue nice error message
try:
value = super().__getitem__(key) # may raise keyerror
except KeyError:
raise KeyError(
f'Invalid colormap or cycle name {key!r}. Valid names are: '
+ ', '.join(map(repr, self)) + '.'
)
# Auto-reverse and auto-shift
if shift:
if hasattr(value, 'shifted'):
value = value.shifted(180)
else:
raise KeyError(
f'Item of type {type(value).__name__!r} '
'does not have shifted() method.'
)
if reverse:
if hasattr(value, 'reversed'):
value = value.reversed()
else:
raise KeyError(
f'Item of type {type(value).__name__!r} '
'does not have reversed() method.'
)
return value
[docs] def __setitem__(self, key, item):
"""
Store the colormap under its lowercase name. If the colormap is
a matplotlib `~matplotlib.colors.ListedColormap` or
`~matplotlib.colors.LinearSegmentedColormap`, it is converted to the
ProPlot `ListedColormap` or `LinearSegmentedColormap` subclass.
"""
if not isinstance(key, str):
raise KeyError(f'Invalid key {key!r}. Must be string.')
if isinstance(item, (ListedColormap, LinearSegmentedColormap)):
pass
elif isinstance(item, mcolors.LinearSegmentedColormap):
item = LinearSegmentedColormap(
item.name, item._segmentdata, item.N, item._gamma
)
elif isinstance(item, mcolors.ListedColormap):
item = ListedColormap(
item.colors, item.name, item.N
)
elif isinstance(item, mcolors.Colormap): # base class
pass
else:
raise ValueError(
f'Invalid colormap {item}. Must be instance of '
'matplotlib.colors.ListedColormap or '
'matplotlib.colors.LinearSegmentedColormap.'
)
key = self._sanitize_key(key, mirror=False)
super().__setitem__(key, item)
def __contains__(self, item):
"""
Test for membership using the sanitized colormap name.
"""
try: # by default __contains__ ignores __getitem__ overrides
self.__getitem__(item)
return True
except KeyError:
return False
def _sanitize_key(self, key, mirror=True):
"""
Return the sanitized colormap name. This is used for lookups *and*
assignments.
"""
if not isinstance(key, str):
raise KeyError(f'Invalid key {key!r}. Key must be a string.')
key = key.lower()
key = re.sub(r'\A(grays)(?:_r|_s)?\Z', 'greys', key)
reverse = key[-2:] == '_r'
if reverse:
key = key[:-2]
if mirror and not super().__contains__(key): # search for mirrored key
key_mirror = key
for pair in CMAPS_DIVERGING:
try:
idx = pair.index(key)
key_mirror = pair[1 - idx]
except (ValueError, KeyError):
continue
if super().__contains__(key_mirror):
reverse = not reverse
key = key_mirror
if reverse:
key = key + '_r'
return key
# Replace color database with custom database
if not isinstance(mcolors._colors_full_map, ColorDatabase):
_map = ColorDatabase(mcolors._colors_full_map)
mcolors._colors_full_map = _map
mcolors.colorConverter.cache = _map.cache
mcolors.colorConverter.colors = _map
# Replace colormap database with custom database
if mcm.get_cmap is not _get_cmap:
mcm.get_cmap = _get_cmap
if not isinstance(_cmap_database, ColormapDatabase):
_cmap_database = {
key: value for key, value in _cmap_database.items()
if key[-2:] != '_r' and key[-8:] != '_shifted'
}
_cmap_database = ColormapDatabase(_cmap_database)
setattr(mcm, _cmap_database_attr, _cmap_database)
# Deprecations
CmapDict = warnings._rename_obj('CmapDict', ColormapDatabase)
ColorDict = warnings._rename_obj('ColorDict', ColorDatabase)
MidpointNorm = warnings._rename_obj('MidpointNorm', DivergingNorm)
BinNorm = warnings._rename_obj('BinNorm', DiscreteNorm)