#!/usr/bin/env python3
"""
Tools for registering and visualizing colormaps, color cycles, color string
names, and fonts. New colormap classes, new colormap normalizer
classes, and new constructor functions for generating instances of these
classes. Related utilities for manipulating colors. See
:ref:`Colormaps`, :ref:`Color cycles`, and :ref:`Colors and fonts`
for details.
"""
# Potential bottleneck, loading all this stuff? *No*. Try using @timer on
# register functions, turns out worst is colormap one at 0.1 seconds.
import os
import re
import json
import glob
import cycler
from xml.etree import ElementTree
from numbers import Number, Integral
from matplotlib import rcParams
import numpy as np
import numpy.ma as ma
import matplotlib.colors as mcolors
import matplotlib.cm as mcm
from .utils import _warn_proplot, _notNone, _timer
from .external import hsluv
__all__ = [
'BinNorm', 'CmapDict', 'ColorDict',
'LinearSegmentedNorm',
'LinearSegmentedColormap',
'ListedColormap',
'MidpointNorm', 'PerceptuallyUniformColormap',
'cmaps', 'colors', 'cycles', 'fonts',
'make_mapping_array',
'register_cmaps', 'register_colors', 'register_cycles', 'register_fonts',
'saturate', 'shade', 'show_cmaps', 'show_channels',
'show_colors', 'show_colorspaces', 'show_cycles', 'show_fonts',
'to_rgb', 'to_xyz',
'Colormap', 'Colors', 'Cycle', 'Norm',
]
# Colormap stuff
CMAPS_TABLE = {
# Assorted origin, but these belong together
'Grayscale': (
'Grays', 'Mono', 'GrayCycle',
),
# Builtin
'Matplotlib originals': (
'viridis', 'plasma', 'inferno', 'magma', 'cividis',
'twilight', 'twilight_shifted',
),
# seaborn
'Seaborn originals': (
'Rocket', 'Mako', 'IceFire', 'Vlag',
),
# PerceptuallyUniformColormap
'ProPlot sequential': (
'Fire',
'Stellar',
'Boreal',
'Marine',
'Dusk',
'Glacial',
'Sunrise', 'Sunset',
),
'ProPlot diverging': (
'Div', 'NegPos', 'DryWet',
),
# cmOcean
'cmOcean sequential': (
'Oxy', 'Thermal', 'Dense', 'Ice', 'Haline',
'Deep', 'Algae', 'Tempo', 'Speed', 'Turbid', 'Solar', 'Matter',
'Amp', 'Phase',
),
'cmOcean diverging': (
'Balance', 'Delta', 'Curl',
),
# ColorBrewer
'ColorBrewer2.0 sequential': (
'Purples', 'Blues', 'Greens', 'Oranges', 'Reds',
'YlOrBr', 'YlOrRd', 'OrRd', 'PuRd', 'RdPu', 'BuPu',
'PuBu', 'PuBuGn', 'BuGn', 'GnBu', 'YlGnBu', 'YlGn'
),
'ColorBrewer2.0 diverging': (
'Spectral', 'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdGY',
'RdBu', 'RdYlBu', 'RdYlGn',
),
# Nice diverging maps
'Other diverging': (
'ColdHot', 'CoolWarm', 'BR',
),
# SciVisColor
'SciVisColor blues': (
'Blue0', 'Blue1', 'Blue2', 'Blue3', 'Blue4', 'Blue5',
'Blue6', 'Blue7', 'Blue8', 'Blue9', 'Blue10', 'Blue11',
),
'SciVisColor greens': (
'Green1', 'Green2', 'Green3', 'Green4', 'Green5',
'Green6', 'Green7', 'Green8',
),
'SciVisColor oranges': (
'Orange1', 'Orange2', 'Orange3', 'Orange4', 'Orange5',
'Orange6', 'Orange7', 'Orange8',
),
'SciVisColor browns': (
'Brown1', 'Brown2', 'Brown3', 'Brown4', 'Brown5',
'Brown6', 'Brown7', 'Brown8', 'Brown9',
),
'SciVisColor reds and purples': (
'RedPurple1', 'RedPurple2', 'RedPurple3', 'RedPurple4',
'RedPurple5', 'RedPurple6', 'RedPurple7', 'RedPurple8',
),
# Builtin maps that will be deleted; categories are taken from comments in
# matplotlib source code. Some of these are really bad, some are segmented
# maps when the should be color cycles, and some are just uninspiring.
'MATLAB': (
'bone', 'cool', 'copper', 'autumn', 'flag', 'prism',
'jet', 'hsv', 'hot', 'spring', 'summer', 'winter', 'pink', 'gray',
),
'GNUplot': (
'gnuplot', 'gnuplot2', 'ocean', 'afmhot', 'rainbow',
),
'GIST': (
'gist_earth', 'gist_gray', 'gist_heat', 'gist_ncar',
'gist_rainbow', 'gist_stern', 'gist_yarg',
),
'Other': (
'binary', 'bwr', 'brg', # appear to be custom matplotlib
'cubehelix', 'wistia', 'CMRmap', # individually released
'seismic', 'terrain', 'nipy_spectral', # origin ambiguous
'tab10', 'tab20', 'tab20b', 'tab20c', # merged colormap cycles
)
}
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')
))
# Named color filter props
COLORS_SPACE = 'hcl' # color "distincness" is defined with this space
COLORS_THRESH = 0.10 # bigger number equals fewer colors
COLORS_TRANSLATIONS = tuple((re.compile(regex), sub) for regex, sub in (
('/', ' '),
('\'s', ''),
(r'\s?majesty', ''), # purple mountains majesty is too long
('reddish', 'red'), # remove 'ish'
('purplish', 'purple'),
('bluish', 'blue'),
(r'ish\b', ''),
('grey', 'gray'),
('pinky', 'pink'),
('greeny', 'green'),
('bluey', 'blue'),
('purply', 'purple'),
('purpley', 'purple'),
('yellowy', 'yellow'),
('robin egg', 'robins egg'),
('egg blue', 'egg'),
('bluegray', 'blue gray'),
('grayblue', 'gray blue'),
('lightblue', 'light blue'),
)) # prevent registering similar-sounding names
COLORS_IGNORE = re.compile('(' + '|'.join((
'shit', 'poop', 'poo', 'pee', 'piss', 'puke', 'vomit', 'snot',
'booger', 'bile', 'diarrhea',
)) + ')') # filter these out, let's try to be professional here...
COLORS_INCLUDE = (
'charcoal', 'sky blue', 'eggshell', 'sea blue', 'coral', 'aqua',
'tomato red', 'brick red', 'crimson',
'red orange', 'yellow orange', 'yellow green', 'blue green',
'blue violet', 'red violet',
) # common names that should always be included
COLORS_OPEN = (
'red', 'pink', 'grape', 'violet',
'indigo', 'blue', 'cyan', 'teal',
'green', 'lime', 'yellow', 'orange', 'gray'
)
COLORS_BASE = {
'blue': (0, 0, 1),
'green': (0, 0.5, 0),
'red': (1, 0, 0),
'cyan': (0, 0.75, 0.75),
'magenta': (0.75, 0, 0.75),
'yellow': (0.75, 0.75, 0),
'black': (0, 0, 0),
'white': (1, 1, 1),
}
# *Proprietary* sans serif fonts that may or may not be on user system
# plus the fonts installed by matplotlib
# NOTE: Add to this as needed!
FONTS_SANS = [
'Arial',
'Avant Garde',
'Avenir',
'Bitstream Vera', # matplotlib
'Computer Modern Sans Serif',
'DejaVu Sans', # matplotlib
'Frutiger',
'Futura',
'Geneva',
'Gill Sans',
'Helvetica',
'Lucida Grande',
'Lucid',
'Myriad Pro',
'Optima',
'Tahoma',
'Trebuchet MS',
'Univers',
'Verdana',
]
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_rgb`.
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]
[docs]def shade(color, scale=1):
"""
Scale the luminance channel of the input color.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgb`.
scale : float, optoinal
The luminance channel is multiplied by this value.
Returns
-------
color
The new RGB tuple.
"""
*color, alpha = to_rgb(color, alpha=True)
color = [*hsluv.rgb_to_hsl(*color)]
# multiply luminance by this value
color[2] = max(0, min(color[2] * scale, 100))
color = [*hsluv.hsl_to_rgb(*color)]
return (*color, alpha)
[docs]def saturate(color, scale=0.5):
"""
Scale the saturation channel of the input color.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgb`.
scale : float, optoinal
The HCL saturation channel is multiplied by this value.
Returns
-------
color
The new RGB tuple.
"""
*color, alpha = to_rgb(color, alpha=True)
color = [*hsluv.rgb_to_hsl(*color)]
# multiply luminance by this value
color[1] = max(0, min(color[1] * scale, 100))
color = [*hsluv.hsl_to_rgb(*color)]
return (*color, alpha)
[docs]def to_rgb(color, space='rgb', cycle=None, alpha=False):
"""
Translate the color in *any* format and from *any* colorspace to an RGB
tuple. This is a generalization of `matplotlib.colors.to_rgb` and the
inverse of `to_xyz`.
Parameters
----------
color : str or length-3 list
The color specification. Can be a tuple of channel values for the
`space` colorspace, a hex string, a registered color name, a cycle
color, or a colormap color (see `ColorDict`).
If `space` is ``'rgb'``, this is a tuple of RGB values, and any
channels are larger than ``2``, the channels are assumed to be on
a ``0`` to ``255`` scale and are therefore divided by ``255``.
space : {'rgb', 'hsv', 'hsl', 'hpl', 'hcl'}, optional
The colorspace for the input channel values. Ignored unless `color` is
an container of numbers.
cycle : str or list, optional
The registered color cycle name. Default is :rc:`cycle`. Ignored unless
`color` is a color cycle string, e.g. ``'C0'``, ``'C1'``, ...
alpha : bool, optional
Whether to preserve the opacity channel, if it exists. Default
is ``False``.
Returns
-------
color
The RGB tuple.
"""
# Convert color cycle strings
if isinstance(color, str) and re.match('^C[0-9]$', color):
if isinstance(cycle, str):
try:
cycle = mcm.cmap_d[cycle].colors
except (KeyError, AttributeError):
cycles = sorted(name for name, cmap in mcm.cmap_d.items(
) if isinstance(cmap, ListedColormap))
raise ValueError(
f'Invalid cycle {cycle!r}. Options are: '
+ ', '.join(map(repr, cycles)) + '.')
elif cycle is None:
cycle = rcParams['axes.prop_cycle'].by_key()
if 'color' not in cycle:
cycle = ['k']
else:
cycle = cycle['color']
else:
raise ValueError(f'Invalid cycle {cycle!r}.')
color = cycle[int(color[-1]) % len(cycle)]
# Translate RGB strings and (cmap,index) tuples
opacity = 1
if isinstance(color, str) or (np.iterable(color) and len(color) == 2):
try:
*color, opacity = mcolors.to_rgba(color) # ensure is valid color
except (ValueError, TypeError):
raise ValueError(f'Invalid RGB argument {color!r}.')
# Pull out alpha channel
if len(color) == 4:
*color, opacity = color
elif len(color) != 3:
raise ValueError(f'Invalid RGB argument {color!r}.')
# Translate arbitrary colorspaces
if space == 'rgb':
try:
if any(c > 2 for c in color):
color = [c / 255 for c in color] # scale to within 0-1
color = tuple(color)
except (ValueError, TypeError):
raise ValueError(f'Invalid RGB argument {color!r}.')
elif space == 'hsv':
color = hsluv.hsl_to_rgb(*color)
elif space == 'hpl':
color = hsluv.hpluv_to_rgb(*color)
elif space == 'hsl':
color = hsluv.hsluv_to_rgb(*color)
elif space == 'hcl':
color = hsluv.hcl_to_rgb(*color)
else:
raise ValueError('Invalid color {color!r} for colorspace {space!r}.')
# Return RGB or RGBA
if alpha:
return (*color, opacity)
else:
return color
[docs]def to_xyz(color, space='hcl', alpha=False):
"""
Translate color in *any* format to a tuple of channel values in *any*
colorspace. This is the inverse of `to_rgb`.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgb`.
space : {'hcl', 'hpl', 'hsl', 'hsv', 'rgb'}, optional
The colorspace for the output channel values.
alpha : bool, optional
Whether to preserve the opacity channel, if it exists. Default
is ``False``.
Returns
-------
color
Tuple of colorspace `space` channel values.
"""
# Run tuple conversions
# NOTE: Don't pass color tuple, because we may want to permit
# out-of-bounds RGB values to invert conversion
*color, opacity = to_rgb(color, alpha=True)
if space == 'rgb':
pass
elif space == 'hsv':
color = hsluv.rgb_to_hsl(*color) # rgb_to_hsv would also work
elif space == 'hpl':
color = hsluv.rgb_to_hpluv(*color)
elif space == 'hsl':
color = hsluv.rgb_to_hsluv(*color)
elif space == 'hcl':
color = hsluv.rgb_to_hcl(*color)
else:
raise ValueError(f'Invalid colorspace {space}.')
if alpha:
return (*color, opacity)
else:
return color
def _clip_colors(colors, clip=True, gray=0.2):
"""
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``.
"""
# Clip colors
colors = np.array(colors)
over = (colors > 1)
under = (colors < 0)
if clip:
colors[under] = 0
colors[over] = 1
else:
colors[(under | over)] = gray
# Message
# NOTE: Never print warning because happens when using builtin maps
# message = 'Clipped' if clip else 'Invalid'
# for i,name in enumerate('rgb'):
# if under[:,i].any():
# _warn_proplot(f'{message} {name!r} channel ( < 0).')
# if over[:,i].any():
# _warn_proplot(f'{message} {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:
_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):
"""
Return a string containing the colormap colors for saving.
Parameters
----------
ext : {'hex', 'txt', 'rgb', 'rgba'}
The filename extension.
colors : list of color-spec
The colors.
"""
# 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', 'rgba'):
rgb = mcolors.to_rgba if ext == 'rgba' else mcolors.to_rgb
data = [rgb(color) for color in colors]
data = '\n'.join(','.join(str(num) 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
[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 + '})'
def __init__(self, *args, cyclic=False, alpha=None, **kwargs):
"""
Parameters
----------
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).
alpha : float, optional
The opacity for the entire colormap. Overrides the input
segment data.
*args, **kwargs
Passed to `~matplotlib.colors.LinearSegmentedColormap`.
"""
super().__init__(*args, **kwargs)
self._cyclic = cyclic
if alpha is not None:
self.set_alpha(alpha)
def _resample(self, N):
"""Returns a resampled copy of the colormap."""
return self.updated(self, N=N)
[docs] def concatenate(self, *args, ratios=1, name=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.concatenate(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
Number of points in the colormap lookup table.
Default is :rc:`image.lut` times ``len(args)``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
Returns
-------
`LinearSegmentedColormap`
The colormap.
"""
# Try making a simple copy
if not args:
raise ValueError(
f'Got zero positional args, you must provide at least one.'
)
if not all(isinstance(cmap, type(self)) for cmap in args):
raise ValueError(
f'Colormaps {cmap.name + ": " + repr(cmap) for cmap in args} '
f'must all belong to the same class.'
)
cmaps = (self, *args)
spaces = {cmap.name: getattr(cmap, '_space', None) for cmap in cmaps}
if len({*spaces.values(), }) > 1:
raise ValueError(
'Cannot merge PerceptuallyUniformColormaps that use '
'different colorspaces: '
+ ', '.join(map(repr, spaces)) + '.'
)
N = kwargs.pop('N', None)
N = N or len(cmaps) * rcParams['image.lut']
if name is None:
name = '_'.join(cmap.name for cmap in cmaps)
# Combine the segmentdata, and use the y1/y2 slots at merge points so
# we never interpolate between end colors of different colormaps
segmentdata = {}
ratios = ratios or 1
if isinstance(ratios, Number):
ratios = [1] * len(cmaps)
# so if 4 cmaps, will be 1/4
ratios = np.array(ratios) / np.sum(ratios)
x0 = np.concatenate([[0], np.cumsum(ratios)]) # coordinates for edges
xw = x0[1:] - x0[:-1] # widths between edges
for key in self._segmentdata.keys():
# 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 ValueError(
'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:]):
_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
return self.updated(name=name, segmentdata=segmentdata, **kwargs)
[docs] def punched(self, cut=None, name=None, **kwargs):
"""
Return a version of the colormap with the center "punched 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 + '_punched'``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
Returns
-------
`LinearSegmentedColormap`
The colormap.
"""
cut = _notNone(cut, 0)
if cut == 0:
return self
if name is None:
name = self.name + '_punched'
# Decompose cut into two truncations followed by concatenation
left_center = 0.5 - cut / 2
right_center = 0.5 + cut / 2
cmap_left = self.truncated(0, left_center)
cmap_right = self.truncated(right_center, 1)
return cmap_left.concatenate(cmap_right, name=name)
[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'``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
"""
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.updated(name, segmentdata, **kwargs)
[docs] def save(self, path=None):
"""
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-column table of comma-delimited RGB values.
``.rgba`` As with ``.rgb``, but with an opacity (or "alpha") column.
===================== ==========================================================
""" # noqa
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 file:
json.dump(data, file, indent=4)
# Save lookup table colors
else:
data = self._get_data(ext[1:])
with open(filename, 'w') as f:
f.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.
"""
self._segmentdata['alpha'] = [(0, alpha, alpha), (1, 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=None, 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. If ``None``,
the original colormap is returned.
name : str, optional
The name of the new colormap. Default is
``self.name + '_shifted'``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
"""
shift = ((shift or 0) / 360) % 1
if shift == 0:
return self
if name is None:
name = self.name + '_shifted'
if not self._cyclic:
_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.truncated(shift, 1)
cmap_right = self.truncated(0, shift)
return cmap_left.concatenate(
cmap_right, ratios=(1 - shift, shift), name=name)
[docs] def truncated(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 + '_truncated'``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
"""
# Bail out
left = max(_notNone(left, 0), 0)
right = min(_notNone(right, 1), 1)
if left == 0 and right == 1:
return self
if name is None:
name = self.name + '_truncated'
# 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:]):
_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.updated(name, segmentdata, **kwargs)
[docs] def updated(
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 + '_updated'``.
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 + '_updated'
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] @staticmethod
def from_file(path, warn_on_failure=False):
"""
Load colormap from a file.
Valid file extensions are described in the below table.
===================== =============================================================================================================================================================================================================
Extension Description
===================== =============================================================================================================================================================================================================
``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10':
``.xml`` XML files with ``<Point .../>`` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values.
``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values.
``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate.
``.rgba``, ``.xrgba`` As with ``.rgb``, ``.xrgb``, but with a trailing opacity (or "alpha") column.
===================== =============================================================================================================================================================================================================
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
return _from_file(path, listed=False, warn_on_failure=warn_on_failure)
[docs] @staticmethod
def from_list(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 ValueError(f'Colors must be iterable, got colors={colors!r}')
if (np.iterable(colors[0]) and len(colors[0]) == 2
and not isinstance(colors[0], str)):
coords, colors = zip(*colors)
colors = [to_rgb(color, alpha=True) 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 LinearSegmentedColormap(name, cdict, **kwargs)
[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.
*args, **kwargs
Passed to `~matplotlib.colors.ListedColormap`.
"""
super().__init__(*args, **kwargs)
if alpha is not None:
self.set_alpha(alpha)
[docs] def concatenate(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.
"""
if not args:
raise ValueError(
f'Got zero positional args, you must provide at least one.'
)
if not all(isinstance(cmap, type(self)) for cmap in args):
raise ValueError(
f'Input arguments {args} must all be 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.updated(colors, name, N or len(colors))
[docs] def save(self, path=None):
"""
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-column table of comma-delimited RGB values.
``.rgba`` As with ``.rgb``, but with an opacity (or "alpha") column.
===================== ==========================================================
""" # noqa
dirname = os.path.join('~', '.proplot', 'cmaps')
filename = self._parse_path(path, dirname, 'hex')
# Save lookup table colors
_, ext = os.path.splitext(filename)
data = self._get_data(ext[1:])
with open(filename, 'w') as f:
f.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=None, 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``.
If ``None``, the original colormap is returned.
name : str, optional
The new colormap name. Default is ``self.name + '_shifted'``.
"""
if not shift:
return self
if name is None:
name = self.name + '_shifted'
shift = shift % len(self.colors)
colors = [*self.colors] # ensure list
colors = colors[shift:] + colors[:shift]
return self.updated(colors, name, len(colors))
[docs] def truncated(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 + '_truncated'``.
"""
if left is None and right is None:
return self
if name is None:
name = self.name + '_truncated'
colors = self.colors[left:right]
return self.updated(colors, name, len(colors))
[docs] def updated(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 + '_updated'``.
colors, N, alpha : optional
See `ListedColormap`. If not provided,
these are copied from the current colormap.
"""
if name is None:
name = self.name + '_updated'
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] @staticmethod
def from_file(path, warn_on_failure=False):
"""
Load color cycle from a file.
Valid file extensions are described in the below table.
===================== =============================================================================================================================================================================================================
Extension Description
===================== =============================================================================================================================================================================================================
``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10':
``.xml`` XML files with ``<Point .../>`` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values.
``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values.
``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate.
``.rgba``, ``.xrgba`` As with ``.rgb``, ``.xrgb``, but with a trailing opacity (or "alpha") column.
===================== =============================================================================================================================================================================================================
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
return _from_file(path, listed=True, warn_on_failure=warn_on_failure)
[docs]class CmapDict(dict):
"""Dictionary subclass used to replace the `matplotlib.cm.cmap_d`
colormap dictionary. See `~CmapDict.__getitem__` and
`~CmapDict.__setitem__` for details."""
def __init__(self, kwargs):
"""
Parameters
----------
kwargs : dict-like
The source dictionary.
"""
for key, value in kwargs.items():
if not isinstance(key, str):
raise KeyError(f'Invalid key {key}. Must be string.')
self.__setitem__(key, value, sort=False)
for record in (cmaps, cycles):
record[:] = sorted(record)
def __delitem__(self, key):
"""Delete the item from the list records."""
super().__delitem__(self, key)
for record in (cmaps, cycles):
try:
record.remove(key)
except ValueError:
pass
[docs] def __getitem__(self, key):
"""Retrieve the colormap associated with the sanitized key name. The
key name is case insensitive. If it ends in ``'_r'``, the result of
``cmap.reversed()`` is returned for the colormap registered under
the name ``key[:-2]``. If it ends in ``'_shifted'``, the result of
``cmap.shifted(180)`` is returned for the colormap registered under
the name ``cmap[:-8]``. Reversed diverging colormaps can be requested
with their "reversed" name -- for example, ``'BuRd'`` is equivalent
to ``'RdBu_r'``."""
key = self._sanitize_key(key, mirror=True)
shift = (key[-8:] == '_shifted')
if shift:
key = key[:-8]
reverse = (key[-2:] == '_r')
if reverse:
key = key[:-2]
value = super().__getitem__(key) # may raise keyerror
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, sort=True):
"""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 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 item is None:
return
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)
record = cycles if isinstance(item, ListedColormap) else cmaps
record.append(key)
if sort:
record[:] = sorted(record)
return 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."""
if not isinstance(key, str):
raise KeyError(f'Invalid key {key!r}. Key must be a string.')
key = key.lower()
reverse = False
if key[-2:] == '_r':
key = key[:-2]
reverse = True
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
[docs] def get(self, key, *args):
"""Retrieve the sanitized colormap name."""
key = self._sanitize_key(key, mirror=True)
return super().get(key, *args)
[docs] def pop(self, key, *args):
"""Pop the sanitized colormap name."""
key = self._sanitize_key(key, mirror=True)
for record in (cmaps, cycles):
try:
record.remove(key)
except ValueError:
pass
return super().pop(key, *args)
[docs] def update(self, *args, **kwargs):
"""Update the dictionary with sanitized colormap names."""
if len(args) == 1:
kwargs.update(args[0])
elif len(args) > 1:
raise TypeError(
f'update() expected at most 1 arguments, got {len(args)}.'
)
for key, value in kwargs.items():
self[key] = value
class _ColorMappingOverride(mcolors._ColorMapping):
"""Mapping whose cache attribute is a `ColorDict` dictionary."""
def __init__(self, mapping):
super().__init__(mapping)
self.cache = ColorDict({})
[docs]class ColorDict(dict):
"""This class overrides the builtin matplotlib color cache, allowing
users to draw colors from *named colormaps and color cycles* for any
plotting command that accepts a `color` keyword arg.
See `~ColorDict.__getitem__` for details."""
[docs] def __getitem__(self, key):
"""
Allows user to select colors from arbitrary named 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.
"""
# 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 = mcm.cmap_d[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]}.'
)
# draw color from the list of colors, using index
rgb = cmap.colors[rgb[1]]
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]}.'
)
# interpolate color from colormap, using key in range 0-1
rgb = cmap(rgb[1])
rgba = mcolors.to_rgba(rgb, alpha)
return rgba
return super().__getitem__((rgb, alpha))
[docs]def Colors(*args, **kwargs):
"""Pass all arguments to `Cycle` and return the list of colors from
the cycler object."""
cycle = Cycle(*args, **kwargs)
return [dict_['color'] for dict_ in cycle]
[docs]def Colormap(
*args, name=None, listmode='perceptual', fade=None, cycle=None,
shift=None, cut=None, left=None, right=None, reverse=False,
save=False, save_kw=None, **kwargs
):
"""
Generate or retrieve colormaps and optionally merge and manipulate
them in a variety of ways. Used to interpret the `cmap` and `cmap_kw`
arguments when passed to any plotting method wrapped by
`~proplot.wrappers.cmap_changer`.
Parameters
----------
*args : colormap-spec
Positional arguments that individually generate colormaps. If more than
one argument is passed, the resulting colormaps are merged. Arguments
are interpreted as follows.
* If `~matplotlib.colors.Colormap` or a registered colormap name, the
colormap is simply returned.
* If a filename string with valid extension, the colormap data will
be loaded. See `register_cmaps` and `register_cycles`.
* If RGB tuple or color string, a `PerceptuallyUniformColormap` is
generated with `~PerceptuallyUniformColormap.from_color`. If the
string ends in ``'_r'``, the monochromatic map will be *reversed*,
i.e. will go from dark to light instead of light to dark.
* If list of RGB tuples or color strings, a
`PerceptuallyUniformColormap` is generated with
`~PerceptuallyUniformColormap.from_list`.
* If dictionary containing the keys ``'hue'``, ``'saturation'``, and
``'luminance'``, a `PerceptuallyUniformColormap` is generated with
`~PerceptuallyUniformColormap.from_hsl`.
name : str, optional
Name under which the final colormap is registered. It can then be
reused by passing ``cmap='name'`` to plotting functions like
`~matplotlib.axes.Axes.contourf`.
fade : float, optional
The maximum luminosity used when generating colormaps with
`PerceptuallyUniformColormap.from_color`. Default is ``100`` when
calling `Colormap` directly, and ``90`` when `Colormap` is called by
`Cycle` (this prevents having pure white in the color cycle).
For example, ``plot.Colormap('blue', fade=80)`` generates a blue
colormap that fades to a pale blue with 80% luminance.
cycle : str or list of color-spec, optional
The registered cycle name or a list of colors used to interpret cycle
color strings like ``'C0'`` and ``'C2'`` when generating colormaps
with `PerceptuallyUniformColormap.from_color`. Default is colors
from the currently active property cycler.
For example, ``plot.Colormap('C0', 'C1', 'C2', cycle='538')``
generates a colormap using colors from the ``'538'`` color cycle.
listmode : {'perceptual', 'linear', 'listed'}, optional
Controls how colormaps are generated when you input list(s) of colors.
If ``'perceptual'``, a `PerceptuallyUniformColormap` is generated with
`PerceptuallyUniformColormap.from_list`. If ``'linear'``,
a `~matplotlib.colors.LinearSegmentedColormap` is generated with
`~matplotlib.colors.LinearSegmentedColormap.from_list`. If
``'listed'``, the `~matplotlib.colors.ListedColormap` is generated.
Default is ``'perceptual'`` when calling `Colormap` directly, and
``'listed'`` when `Colormap` is called by `Cycle`.
cut : float, optional
Passed to `LinearSegmentedColormap.punched`.
This applies to the final *merged* colormap.
left, right : float, optional
Passed to `LinearSegmentedColormap.truncated` or
`ListedColormap.truncated`. These apply to *each colormap*
individually.
reverse : bool, optional
Passed to `LinearSegmentedColormap.reversed` or
`ListedColormap.reversed`. This applies to *each colormap*
individually.
shift : float, optional
Passed to `LinearSegmentedColormap.shifted` or
`ListedColormap.shifted`. This applies to the final *merged* colormap.
save : bool, optional
Whether to call the colormap save method, i.e.
`LinearSegmentedColormap.save` or
`ListedColormap.save`.
save_kw : dict-like, optional
Ignored if `save` is ``False``. Passed to the colormap save method,
i.e. `LinearSegmentedColormap.save` or
`ListedColormap.save`.
**kwargs
Passed to `LinearSegmentedColormap.concatenate` or
`ListedColormap.concatenate`. Each of these functions accepts
arbitrary colormap settings.
Returns
-------
`~matplotlib.colors.Colormap`
A `~matplotlib.colors.LinearSegmentedColormap` or
`~matplotlib.colors.ListedColormap` instance.
"""
# Initial stuff
# TODO: Play with using "qualitative" colormaps in realistic examples,
# how to make colormaps cyclic.
if not args:
raise ValueError(
f'Colormap() requires at least one positional argument.'
)
if listmode not in ('listed', 'linear', 'perceptual'):
raise ValueError(
f'Invalid listmode={listmode!r}. Options are: '
"'listed', 'linear', 'perceptual'."
)
tmp = '_no_name'
cmaps = []
for i, cmap in enumerate(args):
# Load registered colormaps and maps on file
# TODO: Document how 'listmode' also affects loaded files
if isinstance(cmap, str):
if '.' in cmap:
if os.path.isfile(os.path.expanduser(cmap)):
if listmode == 'listed':
cmap = ListedColormap.from_file(cmap)
else:
cmap = LinearSegmentedColormap.from_file(cmap)
else:
raise FileNotFoundError(
f'Colormap or cycle file {cmap!r} not found '
'or failed to load.'
)
else:
try:
cmap = mcm.cmap_d[cmap]
except KeyError:
pass
# Convert matplotlib colormaps to subclasses
if isinstance(cmap, (ListedColormap, LinearSegmentedColormap)):
pass
elif isinstance(cmap, mcolors.LinearSegmentedColormap):
cmap = LinearSegmentedColormap(
cmap.name, cmap._segmentdata, cmap.N, cmap._gamma)
elif isinstance(cmap, mcolors.ListedColormap):
cmap = ListedColormap(
cmap.colors, cmap.name, cmap.N)
# Dictionary of hue/sat/luminance values or 2-tuples representing
# linear transition
elif isinstance(cmap, dict):
cmap = PerceptuallyUniformColormap.from_hsl(tmp, **cmap)
# List of color tuples or color strings, i.e. iterable of iterables
elif (not isinstance(cmap, str) and np.iterable(cmap)
and all(np.iterable(color) for color in cmap)):
try:
colors = [to_rgb(color, cycle=cycle, alpha=True)
for color in cmap]
except (ValueError, TypeError):
raise ValueError(f'Invalid color(s) in list {cmap!r}.')
if listmode == 'listed':
cmap = ListedColormap(colors, tmp)
elif listmode == 'linear':
cmap = LinearSegmentedColormap.from_list(tmp, colors)
else:
cmap = PerceptuallyUniformColormap.from_list(tmp, colors)
# Monochrome colormap from input color
else:
ireverse = (isinstance(cmap, str) and cmap[-2:] == '_r')
if ireverse:
cmap = cmap[:-2]
try:
color = to_rgb(cmap, cycle=cycle, alpha=True)
except (ValueError, TypeError):
msg = f'Invalid cmap, cycle, or color {cmap!r}.'
if isinstance(cmap, str):
msg += (
f'\nValid cmap and cycle names: '
+ ', '.join(map(repr, sorted(mcm.cmap_d))) + '.'
f'\nValid color names: '
+ ', '.join(map(repr, sorted(
mcolors.colorConverter.colors))
) + '.'
)
raise ValueError(msg)
cmap = PerceptuallyUniformColormap.from_color(tmp, color, fade)
if ireverse:
cmap = cmap.reversed()
# Cut the edges and/or reverse the map
if left is not None or right is not None:
cmap = cmap.truncated(left, right)
if reverse:
cmap = cmap.reversed()
cmaps.append(cmap)
# Merge the result of this arbitrary user input
if len(cmaps) > 1: # more than one map?
cmap = cmaps[0].concatenate(*cmaps[1:], **kwargs)
elif kwargs: # modify any props?
cmap = cmaps[0].updated(**kwargs)
# Cut the center and roate the colormap
if cut is not None:
cmap = cmap.punched(cut)
if shift is not None:
cmap = cmap.shifted(shift)
# Initialize
if not cmap._isinit:
cmap._init()
# Register and save the colormap
if name is None:
name = cmap.name # may have been modified by e.g. .shifted()
else:
cmap.name = name
mcm.cmap_d[name] = cmap
if save:
save_kw = save_kw or {}
cmap.save(**save_kw)
return cmap
[docs]def Cycle(
*args, N=None, name=None,
marker=None, alpha=None, dashes=None, linestyle=None, linewidth=None,
markersize=None, markeredgewidth=None,
markeredgecolor=None, markerfacecolor=None,
save=False, save_kw=None, **kwargs
):
"""
Generate and merge `~cycler.Cycler` instances in a variety of ways.
Used to interpret the `cycle` and `cycle_kw` arguments when passed to
any plotting method wrapped by
`~proplot.wrappers.cycle_changer`.
If you just want a list of colors instead of a `~cycler.Cycler` instance,
use the `colors` function. If you want a `~cycler.Cycler` instance that
imposes black as the default color and cycles through properties like
``linestyle`` instead, call this function without any positional arguments.
Parameters
----------
*args : colormap-spec or cycle-spec, optional
Positional arguments control the *colors* in the `~cycler.Cycler`
object. If more than one argument is passed, the resulting cycles are
merged. Arguments are interpreted as follows.
* If `~cycler.Cycler`, nothing more
is done.
* If list of RGB tuples or color strings, these
colors are used.
* If `~matplotlib.colors.ListedColormap`, colors from the ``colors``
attribute are used.
* If string color cycle name, that `~matplotlib.colors.ListedColormap`
is looked up and its ``colors`` attribute is used. See `cycles`.
* Otherwise, the argument is passed to `Colormap`, and colors
from the resulting `~matplotlib.colors.LinearSegmentedColormap`
are used. See the `N` argument.
If the last positional argument is numeric, it is used for the `N`
keyword argument.
N : float or list of float, optional
For `~matplotlib.colors.ListedColormap`\ s, this is the number of
colors to select. For example, ``Cycle('538', 4)`` returns the first 4
colors of the ``'538'`` color cycle.
For `~matplotlib.colors.LinearSegmentedColormap`\ s, this is either
a *list of sample coordinates* used to draw colors from the map, or an
*integer number of colors* to draw. If the latter, the sample
coordinates are ``np.linspace(0, 1, samples)``. For example,
``Cycle('Reds', 5)`` divides the ``'Reds'`` colormap into five evenly
spaced colors.
name : str, optional
Name of the resulting `~matplotlib.colors.ListedColormap` used to
register the color cycle. Default name is ``'no_name'``.
marker, alpha, dashes, linestyle, linewidth, markersize, markeredgewidth, markeredgecolor, markerfacecolor : list of specs, optional
Lists of `~matplotlib.lines.Line2D` properties that can be added to
the `~cycler.Cycler` instance. If the lists have unequal length, they
will be filled to match the length of the longest list. See
`~matplotlib.axes.Axes.set_prop_cycle` for more info on cyclers.
Also see the `line style reference \
<https://matplotlib.org/gallery/lines_bars_and_markers/line_styles_reference.html>`__,
the `marker reference \
<https://matplotlib.org/3.1.0/gallery/lines_bars_and_markers/marker_reference.html>`__,
and the `custom dashes reference \
<https://matplotlib.org/3.1.0/gallery/lines_bars_and_markers/line_demo_dash_control.html>`__.
save : bool, optional
Whether to save the `ListedColormap` associated with this cycle.
See `ListedColormap.save`.
save_kw : dict-like, optional
Ignored if `save` is ``False``. Passed to `ListedColormap.save`
for the `ListedColormap` associated with this cycle.
**kwargs
Passed to `Colormap` when the input is not already a `~cycler.Cycler`
instance.
Returns
-------
`~cycler.Cycler`
A cycler instance that can be passed to
`~matplotlib.axes.Axes.set_prop_cycle`.
""" # noqa
# Add properties
props = {}
nprops = 0
for key, value in (
('marker', marker),
('alpha', alpha),
('dashes', dashes),
('linestyle', linestyle),
('linewidth', linewidth),
('markersize', markersize),
('markeredgewidth', markeredgewidth),
('markeredgecolor', markeredgecolor),
('markerfacecolor', markerfacecolor),
):
if value is not None:
if isinstance(value, str) or not np.iterable(value):
raise ValueError(
f'Invalid {key!r} property {value!r}. '
f'Must be list or tuple of properties.'
)
nprops = max(nprops, len(value))
props[key] = [*value] # ensure mutable list
# If args is non-empty, means we want color cycle; otherwise is black
if not args:
props['color'] = ['k'] # ensures property cycler is non empty
if kwargs:
_warn_proplot(f'Ignoring Cycle() keyword arg(s) {kwargs}.')
# Merge cycler objects
elif all(isinstance(arg, cycler.Cycler) for arg in args):
if kwargs:
_warn_proplot(f'Ignoring Cycle() keyword arg(s) {kwargs}.')
if len(args) == 1:
return args[0]
else:
props = {}
for arg in args:
for key, value in arg.by_key():
if key not in props:
props[key] = []
props[key].extend([*value])
return cycler.cycler(**props)
# Build and register a ListedColormap
else:
# Collect samples
if args and isinstance(args[-1], Number):
# means we want to sample existing colormaps or cycles
args, N = args[:-1], args[-1]
kwargs.setdefault('fade', 90)
kwargs.setdefault('listmode', 'listed')
cmap = Colormap(*args, **kwargs) # the cmap object itself
if isinstance(cmap, ListedColormap):
colors = cmap.colors[:N] # if N is None, does nothing
else:
N = _notNone(N, 10)
if isinstance(N, Integral):
x = np.linspace(0, 1, N) # from edge to edge
elif np.iterable(N) and all(
isinstance(item, Number) for item in N):
x = np.array(N)
else:
raise ValueError(f'Invalid samples {N!r}.')
N = len(x)
colors = cmap(x)
# Register and save the samples as a ListedColormap
name = name or '_no_name'
cmap = ListedColormap(colors, name=name, N=N)
mcm.cmap_d[name] = cmap
if save:
save_kw = save_kw or {}
cmap.save(**save_kw)
# Add to property dict
nprops = max(nprops, len(colors))
props['color'] = [
tuple(color) if not isinstance(color, str) else color
for color in cmap.colors
] # save the tupled version!
# Build cycler, make sure lengths are the same
for key, value in props.items():
if len(value) < nprops:
value[:] = [
value[i % len(value)] for i in range(nprops)
] # make loop double back
cycle = cycler.cycler(**props)
cycle.name = name
return cycle
[docs]def Norm(norm, levels=None, **kwargs):
"""
Return an arbitrary `~matplotlib.colors.Normalize` instance.
Used to interpret the `norm` and `norm_kw` arguments when passed to any
plotting method wrapped by `~proplot.wrappers.cmap_changer`.
Parameters
----------
norm : str or `~matplotlib.colors.Normalize`
Key name for the normalizer. The recognized normalizer key names
are as follows.
=============================== ===============================
Key(s) Class
=============================== ===============================
``'midpoint'``, ``'zero'`` `MidpointNorm`
``'segments'``, ``'segmented'`` `LinearSegmentedNorm`
``'none'``, ``'null'`` `~matplotlib.colors.NoNorm`
``'linear'`` `~matplotlib.colors.Normalize`
``'log'`` `~matplotlib.colors.LogNorm`
``'power'`` `~matplotlib.colors.PowerNorm`
``'symlog'`` `~matplotlib.colors.SymLogNorm`
=============================== ===============================
levels : array-like, optional
Level *edges*, passed to `LinearSegmentedNorm` or used to determine
the `vmin` and `vmax` arguments for `MidpointNorm`.
**kwargs
Passed to the `~matplotlib.colors.Normalize` initializer.
See `this tutorial \
<https://matplotlib.org/tutorials/colors/colormapnorms.html>`__
for more info.
Returns
-------
`~matplotlib.colors.Normalize`
A `~matplotlib.colors.Normalize` instance.
"""
if isinstance(norm, mcolors.Normalize):
return norm
if isinstance(norm, str):
# Get class
norm_out = normalizers.get(norm, None)
if norm_out is None:
raise ValueError(
f'Unknown normalizer {norm!r}. Options are: '
+ ', '.join(map(repr, normalizers.keys())) + '.'
)
# Instantiate class
if norm_out is LinearSegmentedNorm:
if not np.iterable(levels):
raise ValueError(
f'Need levels for normalizer {norm!r}. '
f'Received levels={levels!r}.'
)
kwargs.update({'levels': levels})
norm_out = norm_out(**kwargs) # initialize
else:
raise ValueError(f'Unknown norm {norm_out!r}.')
return norm_out
[docs]class BinNorm(mcolors.BoundaryNorm):
"""
This normalizer is used for all colormap plots. It can be thought of as a
"meta-normalizer": It first scales the data according to any
arbitrary `~matplotlib.colors.Normalize` class, then maps the normalized
values ranging from 0-1 into **discrete** levels.
Consider input levels of ``[0, 3, 6, 9, 12, 15]``. The algorithm is
as follows.
1. `levels` are normalized according to the input normalizer `norm`.
If it is ``None``, they are not changed. Possible normalizers include
`~matplotlib.colors.LogNorm`, which makes color transitions linear in
the logarithm of the value, or `LinearSegmentedNorm`, which makes
color transitions linear in the **index** of the level array.
2. Possible colormap coordinates, corresponding to bins delimited by the
normalized `levels` array, are calculated. In this case, the bin
centers are simply ``[1.5, 4.5, 7.5, 10.5, 13.5]``, which gives us
normalized colormap coordinates of ``[0, 0.25, 0.5, 0.75, 1]``.
3. Out-of-bounds coordinates are added. These depend on the value of the
`extend` keyword argument. For `extend` equal to ``'neither'``,
the coordinates including out-of-bounds values are
``[0, 0, 0.25, 0.5, 0.75, 1, 1]`` -- out-of-bounds values have the same
color as the nearest in-bounds values. For `extend` equal to ``'both'``,
the bins are ``[0, 0.16, 0.33, 0.5, 0.66, 0.83, 1]`` --
out-of-bounds values are given distinct colors. This makes sure your
colorbar always shows the **full range of colors** in the colormap.
4. Whenever `BinNorm.__call__` is invoked, the input value normalized by
`norm` is compared against the normalized `levels` array. Its bin index
is determined with `numpy.searchsorted`, and its corresponding
colormap coordinate is selected using this index.
"""
# 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 BinNorm.inverse().
def __init__(
self, levels, norm=None, clip=False,
step=1.0, extend='neither'
):
"""
Parameters
----------
levels : list of float
The discrete data levels.
norm : `~matplotlib.colors.Normalize`, optional
The normalizer used to transform `levels` and all data passed
to `BinNorm.__call__` *before* discretization.
step : float, optional
The intensity of the transition to out-of-bounds color, as a
faction of the *average* step between in-bounds colors.
Default is ``1``.
extend : {'neither', 'both', 'min', 'max'}, optional
Which direction colors will be extended. No matter the `extend`
option, `BinNorm` ensures colors always extend through the
extreme end colors.
clip : bool, optional
A `~matplotlib.colors.Normalize` option.
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 :)
"""
# Declare boundaries, vmin, vmax in True coordinates.
# Notes:
# * Idea is that we bin data into len(levels) discrete x-coordinates,
# and optionally make out-of-bounds colors the same or different
# * Don't need to call parent __init__, this is own implementation
# Do need it to subclass BoundaryNorm, so ColorbarBase will detect it
# See BoundaryNorm:
# https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/colors.py
levels = np.atleast_1d(levels)
if levels.size <= 1:
raise ValueError('Need at least two levels.')
elif ((levels[1:] - levels[:-1]) <= 0).any():
raise ValueError(
f'Levels {levels} passed to Normalize() must be '
'monotonically increasing.'
)
if extend not in ('both', 'min', 'max', 'neither'):
raise ValueError(
f'Unknown extend option {extend!r}. Choose from '
'"min", "max", "both", "neither".'
)
# Determine color ids for levels, i.e. position in 0-1 space
# Length of these ids should be N + 1 -- that is, N - 1 colors
# for values in-between levels, plus 2 colors for out-of-bounds.
# * 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]
# where the offset X equals step/len(levels).
# First get coordinates
if not norm:
# WARNING: Normalization to 0-1 must always take place first,
# required by colorbar_factory ticks manager.
norm = mcolors.Normalize()
elif not isinstance(norm, mcolors.Normalize):
raise ValueError(
'Normalizer must be matplotlib.colors.Normalize, '
f'got {type(norm)}.'
)
elif isinstance(norm, mcolors.BoundaryNorm):
raise ValueError(
f'Normalizer cannot be an instance of '
'matplotlib.colors.BoundaryNorm.'
)
x_b = norm(levels)
x_m = (x_b[1:] + x_b[:-1]) / 2 # get level centers after norm scaling
y = (x_m - x_m.min()) / (x_m.max() - x_m.min())
if isinstance(y, ma.core.MaskedArray):
y = y.filled(np.nan)
y = y[np.isfinite(y)]
# Account for out of bounds colors
# WARNING: For some reason must clip manually for LogNorm, or
# end up with unpredictable fill value, weird "out-of-bounds" colors
offset = 0
scale = 1
eps = step / (y.size - 1)
# eps = step/levels.size
if extend in ('min', 'both'):
offset = eps
scale -= eps
if extend in ('max', 'both'):
scale -= eps
# insert '0' (arg 3) before index '0' (arg 2)
y = np.concatenate(([0], offset + scale * y, [1]))
self._norm = norm
self._x_b = x_b
self._y = y
if isinstance(norm, mcolors.LogNorm):
self._norm_clip = (5e-249, None)
else:
self._norm_clip = None
# Add builtin properties
# NOTE: Are vmin/vmax even used?
self.boundaries = levels
self.vmin = levels.min()
self.vmax = levels.max()
self.clip = clip
self.N = levels.size
[docs] def __call__(self, xq, clip=None):
"""Normalize data values to 0-1."""
# Follow example of LinearSegmentedNorm, but perform no interpolation,
# just use searchsorted to bin the data.
norm_clip = self._norm_clip
if norm_clip:
xq = np.clip(xq, *norm_clip)
xq = self._norm(xq)
# which x-bin does each point in xq belong to?
yq = self._y[np.searchsorted(self._x_b, xq)]
mask = ma.getmaskarray(xq)
return ma.array(yq, mask=mask)
[docs] def inverse(self, yq):
"""Raise an error. Inversion after discretization is impossible."""
raise RuntimeError('BinNorm is not invertible.')
[docs]class LinearSegmentedNorm(mcolors.Normalize):
"""
This is the default normalizer paired with `BinNorm` whenever `levels`
are non-linearly spaced. The normalized value is linear with respect to
its average index in the `levels` vector, allowing uniform color
transitions across arbitrarily spaced monotonically increasing values.
It accomplishes this following the example of the
`~matplotlib.colors.LinearSegmentedColormap` source code, by performing
efficient, vectorized linear interpolation between the provided boundary
levels.
Can be used by passing ``norm='segmented'`` or ``norm='segments'`` to any
command accepting ``cmap``. The default midpoint is zero.
"""
def __init__(self, levels, vmin=None, vmax=None, **kwargs):
"""
Parameters
----------
levels : list of float
The discrete data levels.
vmin, vmax : None
Ignored, because `vmin` and `vmax` are set to the minimum and
maximum of `levels`.
**kwargs
Passed to `~matplotlib.colors.Normalize`.
"""
levels = np.atleast_1d(levels)
if levels.size <= 1:
raise ValueError('Need at least two levels.')
elif ((levels[1:] - levels[:-1]) <= 0).any():
raise ValueError(
f'Levels {levels} passed to LinearSegmentedNorm must be '
'monotonically increasing.'
)
vmin, vmax = levels.min(), levels.max()
super().__init__(vmin, vmax, **kwargs) # second level superclass
self._x = levels
self._y = np.linspace(0, 1, len(levels))
[docs] def __call__(self, xq, clip=None):
"""Normalize the data values to 0-1. Inverse
of `~LinearSegmentedNorm.inverse`."""
# 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]]
x = self._x # from arbitrarily spaced monotonic levels
y = self._y # to linear range 0-1
xq = np.atleast_1d(xq)
ind = np.searchsorted(x, xq)
ind[ind == 0] = 1
ind[ind == len(x)] = len(x) - 1
distance = (xq - x[ind - 1]) / (x[ind] - x[ind - 1])
yq = distance * (y[ind] - y[ind - 1]) + y[ind - 1]
mask = ma.getmaskarray(xq)
return ma.array(yq, mask=mask)
[docs] def inverse(self, yq):
"""Inverse operation of `~LinearSegmentedNorm.__call__`."""
x = self._x
y = self._y
yq = np.atleast_1d(yq)
ind = np.searchsorted(y, yq)
ind[ind == 0] = 1
ind[ind == len(y)] = len(y) - 1
distance = (yq - y[ind - 1]) / (y[ind] - y[ind - 1])
xq = distance * (x[ind] - x[ind - 1]) + x[ind - 1]
mask = ma.getmaskarray(yq)
return ma.array(xq, mask=mask)
[docs]class MidpointNorm(mcolors.Normalize):
"""
Ensures a "midpoint" always lies at the central colormap color.
Can be used by passing ``norm='midpoint'`` to any command accepting
``cmap``. The default midpoint is zero.
"""
def __init__(self, midpoint=0, vmin=None, vmax=None, clip=None):
"""
Parameters
----------
midpoint : float, optional
The midpoint, or the data value corresponding to the normalized
value ``0.5`` -- halfway down the colormap.
vmin, vmax, clip
The minimum and maximum data values, and the clipping setting.
Passed to `~matplotlib.colors.Normalize`.
"""
# Bigger numbers are too one-sided
super().__init__(vmin, vmax, clip)
self._midpoint = midpoint
[docs] def __call__(self, xq, clip=None):
"""Normalize data values to 0-1. Inverse of `~MidpointNorm.inverse`."""
# Get middle point in 0-1 coords, and value
# Notes:
# * Look up these three values in case vmin/vmax changed; this is
# a more general normalizer than the others. Others are 'parent'
# normalizers, meant to be static more or less.
# * searchsorted gives where xq[i] must be inserted so it is larger
# than x[ind[i]-1] but smaller than x[ind[i]]
# x, y = [self.vmin, self._midpoint, self.vmax], [0, 0.5, 1]
if self.vmin >= self._midpoint or self.vmax <= self._midpoint:
raise ValueError(
f'Midpoint {self._midpoint} outside of vmin {self.vmin} '
f'and vmax {self.vmax}.'
)
x = np.array([self.vmin, self._midpoint, self.vmax])
y = np.array([0, 0.5, 1])
xq = np.atleast_1d(xq)
ind = np.searchsorted(x, xq)
ind[ind == 0] = 1 # in this case will get normed value <0
# in this case, will get normed value >0
ind[ind == len(x)] = len(x) - 1
distance = (xq - x[ind - 1]) / (x[ind] - x[ind - 1])
yq = distance * (y[ind] - y[ind - 1]) + y[ind - 1]
mask = ma.getmaskarray(xq)
return ma.array(yq, mask=mask)
[docs] def inverse(self, yq, clip=None):
"""Inverse operation of `~MidpointNorm.__call__`."""
# Invert the above
# x, y = [self.vmin, self._midpoint, self.vmax], [0, 0.5, 1]
# return ma.masked_array(np.interp(yq, y, x))
# Performs inverse operation of __call__
x = np.array([self.vmin, self._midpoint, self.vmax])
y = np.array([0, 0.5, 1])
yq = np.atleast_1d(yq)
ind = np.searchsorted(y, yq)
ind[ind == 0] = 1
ind[ind == len(y)] = len(y) - 1
distance = (yq - y[ind - 1]) / (y[ind] - y[ind - 1])
xq = distance * (x[ind] - x[ind - 1]) + x[ind - 1]
mask = ma.getmaskarray(yq)
return ma.array(xq, mask=mask)
def _get_data_paths(dirname):
"""Return data folder paths in reverse order of precedence."""
# When loading colormaps, cycles, and colors, files in the latter
# directories overwrite files in the former directories. When loading
# fonts, the resulting paths need to be *reversed*.
return [
os.path.join(os.path.dirname(__file__), dirname),
os.path.join(os.path.expanduser('~'), '.proplot', dirname)
]
def _from_file(filename, listed=False, warn_on_failure=False):
"""Read generalized colormap and color cycle files."""
filename = os.path.expanduser(filename)
if os.path.isdir(filename): # no warning
return
# 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):
if warn_on_failure:
_warn_proplot(msg)
else:
raise RuntimeError(msg)
# Directly read segmentdata json file
# NOTE: This is special case! Immediately return name and cmap
N = rcParams['image.lut']
name, ext = os.path.splitext(os.path.basename(filename))
ext = ext[1:]
cmap = None
if ext == 'json':
with open(filename, 'r') as f:
data = json.load(f)
kw = {}
for key in ('cyclic', 'gamma', 'gamma1', 'gamma2', 'space'):
kw[key] = data.pop(key, None)
if 'red' in data:
cmap = LinearSegmentedColormap(name, data, N=N)
else:
cmap = PerceptuallyUniformColormap(name, data, N=N, **kw)
if name[-2:] == '_r':
cmap = cmap.reversed(name[:-2])
# Read .rgb, .rgba, .xrgb, and .xrgba files
elif ext in ('txt', 'rgb', 'xrgb', 'rgba', 'xrgba'):
# 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).readlines() if line.strip()]
try:
data = [[float(num) for num in line] for line in data]
except ValueError:
_warn_or_raise(
f'Failed to load {filename!r}. Expected a table of comma '
'or space-separated values.'
)
return
# Build x-coordinates and standardize shape
data = np.array(data)
if data.shape[1] != len(ext):
_warn_or_raise(
f'Failed to load {filename!r}. Got {data.shape[1]} columns, '
f'but expected {len(ext)}.'
)
return
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 IOError:
_warn_or_raise(
f'Failed to load {filename!r}.'
)
return
x, data = [], []
for s in doc.getroot().findall('.//Point'):
# Verify keys
if any(key not in s.attrib for key in 'xrgb'):
_warn_or_raise(
f'Failed to load {filename!r}. Missing an x, r, g, or b '
'specification inside one or more <Point> tags.'
)
return
# 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):
_warn_or_raise(
f'Failed to load {filename!r}. Unexpected number of channels '
'or mixed channels across <Point> tags.'
)
return
# Read hex strings
elif ext == 'hex':
# Read arbitrary format
string = open(filename).read() # into single string
data = re.findall('#[0-9a-fA-F]{6}', string) # list of strings
if len(data) < 2:
_warn_or_raise(
f'Failed to load {filename!r}. Hex strings not found.'
)
return
# Convert to array
x = np.linspace(0, 1, len(data))
data = [to_rgb(color) for color in data]
else:
_warn_or_raise(
f'Colormap or cycle file {filename!r} has unknown extension.'
)
return
# 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.
if not cmap:
x, data = np.array(x), np.array(data)
# for some reason, some aren't in 0-1 range
x = (x - x.min()) / (x.max() - x.min())
if (data > 2).any(): # from 0-255 to 0-1
data = data / 255
if name[-2:] == '_r':
name = name[:-2]
data = data[::-1, :]
x = 1 - x[::-1]
if listed:
cmap = ListedColormap(data, name, N=len(data))
else:
data = [(x, color) for x, color in zip(x, data)]
cmap = LinearSegmentedColormap.from_list(name, data, N=N)
# Return colormap or data
return cmap
[docs]@_timer
def register_cmaps():
"""
Register colormaps packaged with ProPlot or saved to the
``~/.proplot/cmaps`` folder. This is called on import. Maps are registered
according to their filenames -- for example, ``name.xyz`` will be
registered as ``'name'``.
For a table of valid extensions, see `LinearSegmentedColormap.from_file`.
To visualize the registered colormaps, use `show_cmaps`.
"""
for i, path in enumerate(_get_data_paths('cmaps')):
for filename in sorted(glob.glob(os.path.join(path, '*'))):
cmap = LinearSegmentedColormap.from_file(
filename, warn_on_failure=True
)
if not cmap:
continue
if i == 0 and cmap.name.lower() in ('phase', 'graycycle'):
cmap._cyclic = True
mcm.cmap_d[cmap.name] = cmap
[docs]@_timer
def register_cycles():
"""
Register color cycles packaged with ProPlot or saved to the
``~/.proplot/cycles`` folder. This is called on import. Cycles are
registered according to their filenames -- for example, ``name.hex`` will
be registered under the name ``'name'`` as a
`~matplotlib.colors.ListedColormap` map (see `Cycle` for details).
For a table of valid extensions, see `ListedColormap.from_file`.
To visualize the registered colormaps, use `show_cmaps`.
"""
for path in _get_data_paths('cycles'):
for filename in sorted(glob.glob(os.path.join(path, '*'))):
cmap = ListedColormap.from_file(
filename, warn_on_failure=True
)
if not cmap:
continue
if isinstance(cmap, LinearSegmentedColormap):
cmap = ListedColormap(colors(cmap), name=cmap.name)
mcm.cmap_d[cmap.name] = cmap
cycles.append(cmap.name)
[docs]@_timer
def register_colors(nmax=np.inf):
"""
Add color names packaged with ProPlot or saved to the ``~/.proplot/colors``
folder. ProPlot loads the crowd-sourced XKCD color
name database, Crayola crayon color database, and any user input
files, then filters them to be "perceptually distinct" in the HCL
colorspace. Files must just have one line per color in the format
``name : hex``. Whitespace is ignored.
This is called on import. Use `show_colors` to generate a table of the
resulting colors.
"""
# Reset native colors dictionary and add some default groups
# Add in CSS4 so no surprises for user, but we will not encourage this
# usage and will omit CSS4 colors from the demo table.
colors.clear()
base = {}
base.update(mcolors.BASE_COLORS)
base.update(COLORS_BASE) # full names
mcolors.colorConverter.colors.clear() # clean out!
mcolors.colorConverter.cache.clear() # clean out!
for name, dict_ in (('base', base), ('css', mcolors.CSS4_COLORS)):
mcolors.colorConverter.colors.update(dict_)
colors[name] = sorted(dict_)
# Load colors from file and get their HCL values
dicts = {}
seen = {*base} # never overwrite base names, e.g. 'blue' and 'b'!
hcls = []
data = []
for i, path in enumerate(_get_data_paths('colors')):
if i == 0:
paths = [ # be explicit because categories matter!
os.path.join(path, base)
for base in ('xkcd.txt', 'crayola.txt', 'opencolor.txt')
]
else:
paths = sorted(glob.glob(os.path.join(path, '*.txt')))
for file in paths:
cat, _ = os.path.splitext(os.path.basename(file))
with open(file, 'r') as f:
pairs = [
tuple(item.strip() for item in line.split(':'))
for line in f.readlines()
if line.strip() and line.strip()[0] != '#'
]
if not all(len(pair) == 2 for pair in pairs):
raise RuntimeError(
f'Invalid color names file {file!r}. '
f'Every line must be formatted as "name: color".'
)
# Categories for which we add *all* colors
if cat == 'opencolor' or i == 1:
dict_ = {name: color for name, color in pairs}
mcolors.colorConverter.colors.update(dict_)
colors[cat] = sorted(dict_)
continue
# Filter remaining colors to *unique* colors
j = 0
if cat not in dicts:
dicts[cat] = {}
for name, color in pairs: # is list of name, color tuples
j += 1
if j > nmax: # e.g. for xkcd colors
break
for regex, sub in COLORS_TRANSLATIONS:
name = regex.sub(sub, name)
if name in seen or COLORS_IGNORE.search(name):
continue
seen.add(name)
hcls.append(to_xyz(color, space=COLORS_SPACE))
data.append((cat, name, color)) # category name pair
# Remove colors that are 'too similar' by rounding to the nearest n units
# WARNING: Unique axis argument requires numpy version >=1.13
hcls = np.array(hcls)
if hcls.size > 0:
hcls = hcls / np.array([360, 100, 100])
hcls = np.round(hcls / COLORS_THRESH).astype(np.int64)
_, idxs, _ = np.unique(
hcls, return_index=True, return_counts=True, axis=0)
for idx, (cat, name, color) in enumerate(data):
if name in COLORS_INCLUDE or idx in idxs:
dicts[cat][name] = color
for cat, dict_ in dicts.items():
mcolors.colorConverter.colors.update(dict_)
colors[cat] = sorted(dict_)
[docs]@_timer
def register_fonts():
"""Add fonts packaged with ProPlot or saved to the ``~/.proplot/fonts``
folder, if they are not already added. Detects ``.ttf`` and ``.otf`` files
-- see `this link \
<https://gree2.github.io/python/2015/04/27/python-change-matplotlib-font-on-mac>`__
for a guide on converting various other font file types to ``.ttf`` and
``.otf`` for use with matplotlib."""
# Add proplot path to TTFLIST and rebuild cache *only if necessary*
# * Nice gallery of sans-serif fonts:
# https://www.lifewire.com/classic-sans-serif-fonts-clean-appearance-1077406 # noqa
# * Sources for downloading more fonts:
# https://fonts.google.com/?category=Sans+Serif
# https://www.cufonfonts.com
# WARNING: If you include a font file with an unrecognized style,
# matplotlib may use that font instead of the 'normal' one! Valid styles:
# 'ultralight', 'light', 'normal', 'regular', 'book', 'medium', 'roman',
# 'semibold', 'demibold', 'demi', 'bold', 'heavy', 'extra bold', 'black'
# https://matplotlib.org/api/font_manager_api.html
# For macOS the only fonts with 'Thin' in one of the .ttf file names
# are Helvetica Neue and .SF NS Display Condensed. Never try to use these!
paths = ':'.join(_get_data_paths('fonts')[::-1]) # user paths come first
if 'TTFPATH' not in os.environ:
os.environ['TTFPATH'] = paths
elif paths not in os.environ['TTFPATH']:
os.environ['TTFPATH'] += (':' + paths)
# Detect user-input .ttc fonts
import matplotlib.font_manager as mfonts
fnames_proplot = {*mfonts.findSystemFonts(paths.split(':'))}
fnames_proplot_ttc = {
file for file in fnames_proplot if os.path.splitext(file)[1] == '.ttc'
}
if fnames_proplot_ttc:
_warn_proplot(
'Ignoring the following .ttc fonts because they cannot be '
'saved into PDF or EPS files (see matplotlib issue #3135): '
+ ', '.join(map(repr, sorted(fnames_proplot_ttc)))
+ '. Please consider expanding them into separate .ttf files.'
)
# Rebuild font cache only if necessary! Can be >50% of total import time!
fnames_all = {font.fname for font in mfonts.fontManager.ttflist}
fnames_proplot -= fnames_proplot_ttc
if not fnames_all >= fnames_proplot:
if hasattr(mfonts.fontManager, 'addfont'):
for fname in fnames_proplot:
mfonts.fontManager.addfont(fname)
mfonts.json_dump(mfonts.fontManager, mfonts._fmcache)
else:
_warn_proplot('Rebuilding font manager.')
mfonts._rebuild()
# Remove ttc files *after* rebuild
mfonts.fontManager.ttflist = [
font for font in mfonts.fontManager.ttflist
if os.path.splitext(font.fname)[1] != '.ttc'
]
# Populate font name lists, with proplot fonts *first*
fonts_proplot = sorted({
font.name for font in mfonts.fontManager.ttflist
if any(path in font.fname for path in paths.split(':'))
})
fonts_system = sorted({
font.name for font in mfonts.fontManager.ttflist
if not any(path in font.fname for path in paths.split(':'))
})
fonts[:] = [*fonts_proplot, *fonts_system]
def _draw_bars(cmapdict, length=4.0, width=0.2):
"""
Draw colorbars for "colormaps" and "color cycles". This is called by
`show_cycles` and `show_cmaps`.
"""
# Figure
from . import subplots
naxs = len(cmapdict) + sum(map(len, cmapdict.values()))
fig, axs = subplots(
nrows=naxs, axwidth=length, axheight=width,
share=0, hspace=0.03,
)
iax = -1
nheads = nbars = 0 # for deciding which axes to plot in
a = np.linspace(0, 1, 257).reshape(1, -1)
a = np.vstack((a, a))
for cat, names in cmapdict.items():
if not names:
continue
nheads += 1
for imap, name in enumerate(names):
iax += 1
if imap + nheads + nbars > naxs:
break
ax = axs[iax]
if imap == 0: # allocate this axes for title
iax += 1
ax.set_visible(False)
ax = axs[iax]
cmap = mcm.cmap_d[name]
ax.imshow(
a, cmap=name, origin='lower', aspect='auto',
levels=cmap.N
)
ax.format(
ylabel=name,
ylabel_kw={'rotation': 0, 'ha': 'right', 'va': 'center'},
xticks='none', yticks='none', # no ticks
xloc='neither', yloc='neither', # no spines
title=(cat if imap == 0 else None)
)
nbars += len(names)
[docs]def show_channels(
*args, N=100, rgb=True, saturation=True, minhue=0,
maxsat=500, width=100, axwidth=1.7
):
"""
Show how arbitrary colormap(s) vary with respect to the hue, chroma,
luminance, HSL saturation, and HPL saturation channels, and optionally
the red, blue and green channels. Adapted from `this example \
<https://matplotlib.org/3.1.0/tutorials/colors/colormaps.html#lightness-of-matplotlib-colormaps>`__.
Parameters
----------
*args : colormap-spec, optional
Positional arguments are colormap names or objects. Default is
:rc:`image.cmap`.
N : int, optional
The number of markers to draw for each colormap.
rgb : bool, optional
Whether to also show the red, green, and blue channels in the bottom
row. Default is ``True``.
saturation : bool, optional
Whether to show the HSL and HPL saturation channels alongside the
raw chroma.
minhue : float, optional
The minimum hue. This lets you rotate the hue plot cyclically.
maxsat : float, optional
The maximum saturation. Use this to truncate large saturation values.
width : int, optional
The width of each colormap line in points.
axwidth : int or str, optional
The width of each subplot. Passed to `~proplot.subplots.subplots`.
Returns
-------
`~proplot.subplots.Figure`
The figure.
"""
# Figure and plot
from . import subplots
if not args:
raise ValueError(f'At least one positional argument required.')
array = [[1, 1, 2, 2, 3, 3]]
labels = ('Hue', 'Chroma', 'Luminance')
if saturation:
array += [[0, 4, 4, 5, 5, 0]]
labels += ('HSL saturation', 'HPL saturation')
if rgb:
array += [np.array([4, 4, 5, 5, 6, 6]) + 2 * int(saturation)]
labels += ('Red', 'Green', 'Blue')
fig, axs = subplots(
array=array, span=False, share=1,
axwidth=axwidth, axpad='1em',
)
# Iterate through colormaps
mc, ms, mp = 0, 0, 0
cmaps = []
for cmap in args:
# Get colormap and avoid registering new names
name = cmap if isinstance(cmap, str) else getattr(cmap, 'name', None)
cmap = Colormap(cmap, N=N) # arbitrary cmap argument
if name is not None:
cmap.name = name
cmap._init()
cmaps.append(cmap)
# Get clipped RGB table
x = np.linspace(0, 1, N)
lut = cmap._lut[:-3, :3].copy()
rgb_data = lut.T # 3 by N
hcl_data = np.array([to_xyz(color, space='hcl')
for color in lut]).T # 3 by N
hsl_data = [to_xyz(color, space='hsl')[1] for color in lut]
hpl_data = [to_xyz(color, space='hpl')[1] for color in lut]
# Plot channels
# If rgb is False, the zip will just truncate the other iterables
data = (*hcl_data,)
if saturation:
data += (hsl_data, hpl_data)
if rgb:
data += (*rgb_data,)
for ax, y, label in zip(axs, data, labels):
ylim, ylocator = None, None
if label in ('Red', 'Green', 'Blue'):
ylim = (0, 1)
ylocator = 0.2
elif label == 'Luminance':
ylim = (0, 100)
ylocator = 20
elif label == 'Hue':
ylim = (minhue, minhue + 360)
ylocator = 90
y = y - 720
for _ in range(3): # rotate up to 1080 degrees
y[y < minhue] += 360
else:
if label == 'Chroma':
mc = max(min(max(mc, max(y)), maxsat), 100)
m = mc
elif 'HSL' in label:
ms = max(min(max(ms, max(y)), maxsat), 100)
m = ms
else:
mp = max(min(max(mp, max(y)), maxsat), 100)
m = mp
ylim = (0, m)
ylocator = ('maxn', 5)
ax.scatter(x, y, c=x, cmap=cmap, s=width, linewidths=0)
ax.format(title=label, ylim=ylim, ylocator=ylocator)
# Formatting
suptitle = ', '.join(repr(cmap.name) for cmap in cmaps[:-1]) + (
', and ' if len(cmaps) > 2 else ' and ' if len(cmaps) == 2 else ' '
) + f'{repr(cmaps[-1].name)} colormap' + ('s' if len(cmaps) > 1 else '')
axs.format(
xlocator=0.25, xformatter='null',
suptitle=f'{suptitle} by channel', ylim=None, ytickminor=False,
)
# Colorbar on the bottom
for cmap in cmaps:
fig.colorbar(cmap,
loc='b', span=(2, 5),
locator='null', label=cmap.name, labelweight='bold')
return fig
[docs]def show_colorspaces(luminance=None, saturation=None, hue=None, axwidth=2):
"""
Generate hue-saturation, hue-luminance, and luminance-saturation
cross-sections for the HCL, HSL, and HPL colorspaces.
Parameters
----------
luminance : float, optional
If passed, saturation-hue cross-sections are drawn for
this luminance. Must be between ``0` and ``100``. Default is ``50``.
saturation : float, optional
If passed, luminance-hue cross-sections are drawn for this
saturation. Must be between ``0` and ``100``.
hue : float, optional
If passed, luminance-saturation cross-sections
are drawn for this hue. Must be between ``0` and ``360``.
axwidth : str or float, optional
Average width of each subplot. Units are interpreted by
`~proplot.utils.units`.
Returns
-------
`~proplot.subplots.Figure`
The figure.
"""
# Get colorspace properties
hues = np.linspace(0, 360, 361)
sats = np.linspace(0, 120, 120)
lums = np.linspace(0, 99.99, 101)
if luminance is None and saturation is None and hue is None:
luminance = 50
if luminance is not None:
hsl = np.concatenate((
np.repeat(hues[:, None], len(sats), axis=1)[..., None],
np.repeat(sats[None, :], len(hues), axis=0)[..., None],
np.ones((len(hues), len(sats)))[..., None] * luminance,
), axis=2)
suptitle = f'Hue-saturation cross-section for luminance {luminance}'
xlabel, ylabel = 'hue', 'saturation'
xloc, yloc = 60, 20
elif saturation is not None:
hsl = np.concatenate((
np.repeat(hues[:, None], len(lums), axis=1)[..., None],
np.ones((len(hues), len(lums)))[..., None] * saturation,
np.repeat(lums[None, :], len(hues), axis=0)[..., None],
), axis=2)
suptitle = f'Hue-luminance cross-section for saturation {saturation}'
xlabel, ylabel = 'hue', 'luminance'
xloc, yloc = 60, 20
elif hue is not None:
hsl = np.concatenate((
np.ones((len(lums), len(sats)))[..., None] * hue,
np.repeat(sats[None, :], len(lums), axis=0)[..., None],
np.repeat(lums[:, None], len(sats), axis=1)[..., None],
), axis=2)
suptitle = 'Luminance-saturation cross-section'
xlabel, ylabel = 'luminance', 'saturation'
xloc, yloc = 20, 20
# Make figure, with black indicating invalid values
# Note we invert the x-y ordering for imshow
from . import subplots
fig, axs = subplots(
ncols=3, share=0, axwidth=axwidth, aspect=1, axpad=0.05
)
for ax, space in zip(axs, ('hcl', 'hsl', 'hpl')):
rgba = np.ones((*hsl.shape[:2][::-1], 4)) # RGBA
for j in range(hsl.shape[0]):
for k in range(hsl.shape[1]):
rgb_jk = to_rgb(hsl[j, k, :].flat, space)
if not all(0 <= c <= 1 for c in rgb_jk):
rgba[k, j, 3] = 0 # black cell
else:
rgba[k, j, :3] = rgb_jk
ax.imshow(rgba, origin='lower', aspect='auto')
ax.format(xlabel=xlabel, ylabel=ylabel, suptitle=suptitle,
grid=False, xtickminor=False, ytickminor=False,
xlocator=xloc, ylocator=yloc, facecolor='k',
title=space.upper(), titleweight='bold')
return fig
[docs]def show_colors(nhues=17, minsat=20):
"""
Generate tables of the registered color names. Adapted from
`this example <https://matplotlib.org/examples/color/named_colors.html>`__.
Parameters
----------
nhues : int, optional
The number of breaks between hues for grouping "like colors" in the
color table.
minsat : float, optional
The threshold saturation, between ``0`` and ``100``, for designating
"gray colors" in the color table.
Returns
-------
figs : list of `~proplot.subplots.Figure`
The figure.
"""
# Test used to "categories" colors
breakpoints = np.linspace(0, 360, nhues)
def _color_filter(i, hcl): # noqa: E306
gray = hcl[1] <= minsat
if i == 0:
return gray
color = breakpoints[i - 1] <= hcl[0] < breakpoints[i]
if i == nhues - 1:
color = color or color == breakpoints[i] # endpoint inclusive
return not gray and color
# Draw figures for different groups of colors
figs = []
from . import subplots
for cats in (
('opencolor',),
tuple(name for name in colors if name not in ('css', 'opencolor'))
):
# Dictionary of colors for that category
data = {}
for cat in cats:
for color in colors[cat]:
data[color] = mcolors.colorConverter.colors[color]
# Group colors together by discrete range of hue, then sort by value
# For opencolors this is not necessary
if cats == ('opencolor',):
wscale = 0.5
swatch = 1.5
nrows, ncols = 10, len(COLORS_OPEN) # rows and columns
names = np.array([
[name + str(i) for i in range(nrows)]
for name in COLORS_OPEN
])
nrows = nrows * 2
ncols = (ncols + 1) // 2
names.resize((ncols, nrows))
# Get colors in perceptally uniform space, then group based on hue
# thresholds
else:
ncols = 4
wscale = 0.8
swatch = 1.2
hclpairs = [
(name, to_xyz(color, COLORS_SPACE))
for name, color in data.items()
]
hclpairs = [
sorted(
[pair for pair in hclpairs if _color_filter(i, pair[1])],
key=lambda x: x[1][2]
)
for i in range(nhues)
]
names = np.array([
name for ipairs in hclpairs for name, _ in ipairs
])
nrows = len(names) // ncols + 1
names.resize((ncols, nrows))
# Draw swatches as lines
fig, ax = subplots(
width=8 * wscale * (ncols / 4),
height=5 * (nrows / 40),
left=0, right=0, top=0, bottom=0, tight=False
)
X, Y = fig.get_dpi() * fig.get_size_inches() # size in dots
hsep, wsep = Y / (nrows + 1), X / ncols # height and width in dots
for col, inames in enumerate(names):
for row, name in enumerate(inames):
if not name:
continue
y = Y - hsep * (row + 1)
xi = wsep * (col + 0.05)
xf = wsep * (col + 0.25 * swatch)
yline = y + hsep * 0.1
xtext = wsep * (col + 0.25 * swatch + 0.03 * swatch)
ax.text(xtext, y, name, ha='left', va='center')
ax.plot(
[xi, xf], [yline, yline],
color=data[name], lw=hsep * 0.6,
solid_capstyle='butt', # do not stick out
)
# Apply formatting
ax.format(
xlim=(0, X), ylim=(0, Y),
grid=False, yloc='neither', xloc='neither'
)
figs.append(fig)
return figs
[docs]def show_cmaps(*args, N=None, unknown='User', **kwargs):
"""
Generate a table of the registered colormaps or the input colormaps.
Adapted from `this example \
<http://matplotlib.org/examples/color/colormaps_reference.html>`__.
Parameters
----------
*args : colormap-spec, optional
Colormap names or objects.
N : int, optional
The number of levels in each colorbar. Default is
:rc:`image.lut`.
unknown : str, optional
Category name for colormaps that are unknown to ProPlot. The
default is ``'User'``.
length : float or str, optional
The length of the colorbars. Units are interpreted by
`~proplot.utils.units`.
width : float or str, optional
The width of the colorbars. Units are interpreted by
`~proplot.utils.units`.
Returns
-------
`~proplot.subplots.Figure`
The figure.
"""
# Have colormaps separated into categories
N = _notNone(N, rcParams['image.lut'])
if args:
names = [Colormap(cmap, N=N).name for cmap in args]
else:
names = [
name for name in mcm.cmap_d.keys() if
isinstance(mcm.cmap_d[name], LinearSegmentedColormap)
]
# Get dictionary of registered colormaps and their categories
cmapdict = {}
names_all = list(map(str.lower, names))
names_known = sum(map(list, CMAPS_TABLE.values()), [])
cmapdict[unknown] = [name for name in names if name not in names_known]
for cat, names in CMAPS_TABLE.items():
cmapdict[cat] = [name for name in names if name.lower() in names_all]
# Return figure of colorbars
return _draw_bars(cmapdict, **kwargs)
[docs]def show_cycles(*args, **kwargs):
"""
Generate a table of registered color cycles or the input color cycles.
Parameters
----------
*args : colormap-spec, optional
Cycle names or objects.
length : float or str, optional
The length of the colorbars. Units are interpreted by
`~proplot.utils.units`.
width : float or str, optional
The width of the colorbars. Units are interpreted by
`~proplot.utils.units`.
Returns
-------
`~proplot.subplots.Figure`
The figure.
"""
# Get the list of cycles
if args:
names = [cmap.name for cmap in args]
else:
names = [
name for name in mcm.cmap_d.keys() if
isinstance(mcm.cmap_d[name], ListedColormap)
]
# Return figure of colorbars
cmapdict = {'Color cycles': names}
return _draw_bars(cmapdict, **kwargs)
[docs]def show_fonts(*args, size=12, text=None):
"""
Generate a table of fonts. If a glyph for a particular font is unavailable,
it is replaced with the "ยค" dummy character.
Parameters
----------
*args
The font family names. If none are provided, the available sans-serif
fonts, the fonts in your ``.proplot/fonts`` folder, and the fonts
provided by ProPlot are shown.
size : float, optional
The font size in points.
text : str, optional
The sample text. The default sample text includes the Latin letters,
Greek letters, Arabic numerals, and some mathematical symbols.
"""
from . import subplots
if not args:
import matplotlib.font_manager as mfonts
args = sorted({
font.name for font in mfonts.fontManager.ttflist
if font.name[:1] != '.' and (
font.name in FONTS_SANS
or any(path in font.fname for path in _get_data_paths('fonts'))
)
})
# Text
if text is None:
text = 'the quick brown fox jumps over a lazy dog' '\n' \
'THE QUICK BROWN FOX JUMPS OVER A LAZY DOG' '\n' \
'(0) + {1\N{DEGREE SIGN}} \N{MINUS SIGN} [2*] - <3> / 4,0 ' \
r'$\geq\gg$ 5.0 $\leq\ll$ ~6 $\times$ 7 ' \
r'$\equiv$ 8 $\approx$ 9 $\propto$' '\n' \
r'$\alpha\beta$ $\Gamma\gamma$ $\Delta\delta$ ' \
r'$\epsilon\zeta\eta$ $\Theta\theta$ $\kappa\mu\nu$ ' \
r'$\Lambda\lambda$ $\Pi\pi$ $\xi\rho\tau\chi$ $\Sigma\sigma$ ' \
r'$\Phi\phi$ $\Psi\psi$ $\Omega\omega$ !?&#%'
# Create figure
f, axs = subplots(
ncols=1, nrows=len(args), space=0,
axwidth=4.5, axheight=1.2 * (text.count('\n') + 2.5) * size / 72,
fallback_to_cm=False
)
axs.format(xloc='neither', yloc='neither',
xlocator='null', ylocator='null', alpha=0)
axs[0].format(title='Fonts demo', titlesize=size,
titleloc='l', titleweight='bold')
for i, ax in enumerate(axs):
font = args[i]
ax.text(0, 0.5, f'{font}:\n{text}',
fontfamily=font, fontsize=size,
weight='normal', ha='left', va='center')
return f
# Apply custom changes
mcm.cmap_d['Grays'] = mcm.cmap_d.pop('Greys', None) # 'Murica (and consistency with registered colors) # noqa
mcm.cmap_d['Spectral'] = mcm.cmap_d['Spectral'].reversed(
name='Spectral') # make spectral go from 'cold' to 'hot'
for _name in CMAPS_TABLE['Matplotlib originals']: # initialize as empty lists
if _name == 'twilight_shifted':
mcm.cmap_d.pop(_name, None)
else:
_cmap = mcm.cmap_d.get(_name, None)
if _cmap and isinstance(_cmap, mcolors.ListedColormap):
mcm.cmap_d.pop(_name, None) # removes the map from cycles list!
mcm.cmap_d[_name] = LinearSegmentedColormap.from_list(
_name, _cmap.colors, cyclic=('twilight' in _name))
for _cat in ('MATLAB', 'GNUplot', 'GIST', 'Other'):
for _name in CMAPS_TABLE[_cat]:
mcm.cmap_d.pop(_name, None)
# Initialize customization folders and files
_rc_folder = os.path.join(os.path.expanduser('~'), '.proplot')
if not os.path.isdir(_rc_folder):
os.mkdir(_rc_folder)
for _rc_sub in ('cmaps', 'cycles', 'colors', 'fonts'):
_rc_sub = os.path.join(_rc_folder, _rc_sub)
if not os.path.isdir(_rc_sub):
os.mkdir(_rc_sub)
#: List of registered colormap names.
cmaps = [] # track *downloaded* colormaps
#: List of registered color cycle names.
cycles = [] # track *all* color cycles
#: Lists of registered color names by category.
colors = {}
#: Registered font names.
fonts = []
# Apply monkey patches to top level modules
if not isinstance(mcm.cmap_d, CmapDict):
_dict = {
key: value for key, value in mcm.cmap_d.items() if key[-2:] != '_r'
}
mcm.cmap_d = CmapDict(_dict)
if not isinstance(mcolors._colors_full_map, _ColorMappingOverride):
_map = _ColorMappingOverride(mcolors._colors_full_map)
mcolors._colors_full_map = _map
mcolors.colorConverter.cache = _map.cache # re-instantiate
mcolors.colorConverter.colors = _map # re-instantiate
# Call driver funcs
register_colors()
register_cmaps()
register_cycles()
register_fonts()
#: Dictionary of possible normalizers. See `Norm` for a table.
normalizers = {
'none': mcolors.NoNorm,
'null': mcolors.NoNorm,
'zero': MidpointNorm,
'midpoint': MidpointNorm,
'segments': LinearSegmentedNorm,
'segmented': LinearSegmentedNorm,
'log': mcolors.LogNorm,
'linear': mcolors.Normalize,
'power': mcolors.PowerNorm,
'symlog': mcolors.SymLogNorm,
}