Styling and color¶
Everything about how a figure looks: the theme it is drawn with, the colors and the colormaps in it, how a number becomes a position on one, and how to check that what you chose survives being printed in gray or read by someone with a color vision deficiency.
The organizing idea is that none of it is global state. There is no
rcParams and no style.use(). A Theme is an immutable object you pass to
subplots, and a colormap is a value you pass to a mark.
What a figure says — the face it is set in, styling one word of a label, and
$…$ mathematics — is the next notebook.
import math
import random
import pyplotrs as pp
from pyplotrs import color, colormaps, norms, palettes
random.seed(2)
t = [i * 0.15 for i in range(61)]
wave = [math.sin(x) for x in t]
damped = [math.sin(x) * math.exp(-0.2 * x) for x in t]
field = [[math.sin(0.22 * j) * math.cos(0.28 * i) + 0.4 * math.exp(-((i - 15) ** 2 + (j - 20) ** 2) / 60)
for j in range(44)] for i in range(32)]
print(len(colormaps.available()), "colormaps ·", len(palettes.available()), "palettes")
127 colormaps · 25 palettes
Themes¶
Three presets ship with the library, and they are deliberately one design seen under three lights: same type scale, same rules, same spines. Only the color changes, so a figure keeps its layout when you switch between them.
def demo(theme):
"""The same figure, drawn with whichever theme is passed in."""
fig, ax = pp.subplots(figsize=(300, 190), theme=theme)
ax.line(t, wave, label="sin")
ax.line(t, damped, label="damped", linestyle="dashed")
ax.scatter(t[::10], [wave[i] for i in range(0, len(t), 10)], label="samples")
ax.set(title=f"theme = {theme!r}" if isinstance(theme, str) else "derived theme",
xlabel="t", ylabel="amplitude")
ax.legend(fontsize=8)
return fig
demo("default")
# `grayscale` (alias `bw`): the same theme with the color taken
# out, for a figure that has to survive a mono printer. Past three or four
# series, pair it with distinct line styles - luminance alone runs out.
demo("grayscale")
# `dark`: ink and paper swapped. It states a page of its own, which no
# light theme does, so the background travels into .png, .pdf, .svg and .html
# alike rather than leaving light text on whatever the viewer paints.
demo("dark")
Deriving your own¶
with_ returns a new theme with some fields replaced; the original is
untouched, so a derived theme can never leak back into whatever else is using
the base. Every field of Theme is available: palette, spines,
spine_width, grid, grid_color, line_width, the five type sizes, the
three weights, and the legend/axes fills.
house_style = pp.themes.default.with_(
palette=palettes.get("Dark2"),
grid=True,
grid_color=(235, 235, 235, 255),
line_width=2.0,
title_weight="bold",
spines=("left", "bottom", "top", "right"),
tick_label_size=8.0,
)
demo(house_style)
# A theme is a value, so two figures in one process - or one thread each - can
# hold different ones with nothing shared between them.
print("base palette :", pp.themes.default.palette[:3])
print("derived :", house_style.palette[:3])
print("base untouched:", pp.themes.default.grid, "· derived:", house_style.grid)
print("built-ins :", ["default", "grayscale", "dark", "bw (alias)"])
base palette : ((0, 0, 0, 255), (230, 159, 0, 255), (86, 180, 233, 255)) derived : ((27, 158, 119, 255), (217, 95, 2, 255), (117, 112, 179, 255)) base untouched: False · derived: True built-ins : ['default', 'grayscale', 'dark', 'bw (alias)']
Colors¶
Five spellings, all accepted anywhere a color is:
color="C2" # index the ACTIVE theme's palette
color="steelblue" # CSS / matplotlib color name
color="#4682b4" # hex, with or without alpha
color=(0.27, 0.51, 0.71) # floats in 0-1, matplotlib style
color=(70, 130, 180) # bytes in 0-255, the native form
"C0"…"Cn" is the one worth changing your habits for: it indexes this
figure's palette rather than a fixed global cycle, so restyling a figure moves
every "C3" in it at once. The default palette is Okabe-Ito, which is designed
to stay distinguishable under the common color vision deficiencies.
It is in the order Okabe and Ito published it, so C0 is black: a lone
series has nothing to contrast against, and a hue on it would encode nothing.
Color starts at C1.
fig, axs = pp.subplots(1, 2, figsize=(500, 180))
# The default palette, swatch by swatch.
okabe_ito = pp.themes.default.palette
for i in range(len(okabe_ito)):
axs[0].rectangle((i, 0), 0.86, 1.0, facecolor=f"C{i}")
axs[0].text(i + 0.43, -0.35, f"C{i}", ha="center", fontsize=8)
axs[0].set(title="the default palette (Okabe-Ito)", xlim=(-0.2, len(okabe_ito)),
ylim=(-0.6, 1.1))
axs[0].axis("off")
# The same five spellings, all naming one blue.
spellings = [("C5", "C5"), ("name", "steelblue"), ("hex", "#4682b4"),
("0-1 floats", (0.27, 0.51, 0.71)), ("0-255 bytes", (70, 130, 180))]
for i, (spelling, value) in enumerate(spellings):
row = len(spellings) - 1 - i # first in the list, first on the page
axs[1].line([0, 1], [row, row], color=value, linewidth=6)
axs[1].text(1.08, row, spelling, va="center", fontsize=8)
axs[1].set(title="five ways to say one blue", xlim=(0, 1.75), ylim=(-0.6, 4.6))
axs[1].axis("off")
fig
Colormaps¶
127 colormaps ship with the library, in five families. colormaps.available()
lists them all; passing a category lists one family.
for category in colormaps.CATEGORIES:
names = colormaps.available(category)
print(f"{category:<21} {len(names):>3} {', '.join(names[:7])} …")
perceptually_uniform 5 cividis, inferno, magma, plasma, viridis … sequential 69 Blues, BuGn, BuPu, GnBu, Grays, Greens, Greys … diverging 27 BrBG, PRGn, PiYG, PuOr, RdBu, RdGy, RdYlBu … cyclic 6 cet_colorwheel, cet_isolum, cmo_phase, hsv, twilight, twilight_shifted … miscellaneous 20 CMRmap, brg, cet_rainbow, cet_rainbow4, cmo_topo, cubehelix, flag …
# A reference strip: one row per colormap, the unit interval left to right.
selection = ["viridis", "plasma", "inferno", "magma", "cividis",
"Blues", "YlOrRd", "cmo_thermal",
"RdBu", "coolwarm", "PuOr", "cmo_balance",
"twilight", "cmo_phase",
"turbo", "cet_rainbow"]
ramp = [[j / 255 for j in range(256)]]
fig, axs = pp.subplots(len(selection), 1, figsize=(430, 32 * len(selection)))
for ax, name in zip(axs, selection):
ax.imshow(ramp, cmap=name, extent=(0, 1, 0, 1))
ax.text(-0.02, 0.5, name, ha="right", va="center", fontsize=8)
ax.set(xlim=(-0.34, 1.0), ylim=(0, 1))
ax.axis("off")
fig.set(suptitle="Sixteen of the 127, one per family and source")
fig
# A colormap is a callable: give it a position in [0, 1], get the RGBA back.
# `.reversed()` returns a new one; the "_r" name suffix works too.
viridis = colormaps.get_cmap("viridis")
print("viridis(0.0) =", viridis(0.0))
print("viridis(0.5) =", viridis(0.5))
print("viridis(1.0) =", viridis(1.0))
print("reversed =", viridis.reversed()(0.0), "· by name:",
colormaps.get_cmap("viridis_r")(0.0))
# So a discrete series can be sampled straight out of a continuous map.
fig, ax = pp.subplots(figsize=(340, 190))
for i in range(7):
ax.line(t, [math.sin(x + i * 0.35) * math.exp(-0.15 * x) for x in t],
color=viridis(i / 6), linewidth=1.4)
ax.set(title="seven series sampled from viridis", xlabel="t", ylabel="amplitude")
fig
viridis(0.0) = (68, 1, 84, 255) viridis(0.5) = (33, 145, 140, 255) viridis(1.0) = (253, 231, 37, 255) reversed = (253, 231, 37, 255) · by name: (253, 231, 37, 255)
Norms: how a value becomes a position on the map¶
By default the data range maps linearly onto [0, 1]. A norm changes that
mapping, which is the difference between a readable image and a wash.
# One field with a long positive tail, and one signed field with a meaningful
# zero, so each norm has a job worth doing.
skewed = [[math.exp(3 * (v + 1)) for v in row] for row in field]
fig, axs = pp.subplots(2, 2, figsize=(500, 320))
a = axs[0][0].imshow(skewed, cmap="viridis")
fig.colorbar(a, label="value")
axs[0][0].set(title="linear: the tail eats the range", xticks=[], yticks=[])
b = axs[0][1].imshow(skewed, cmap="viridis", norm=norms.LogNorm())
fig.colorbar(b, label="value")
axs[0][1].set(title="LogNorm()", xticks=[], yticks=[])
# A diverging map is only honest when its middle sits on the reference value.
c = axs[1][0].contourf(field, levels=14, cmap="RdBu",
norm=norms.TwoSlopeNorm(vcenter=0.0, vmin=-1.0, vmax=1.4))
fig.colorbar(c, label="anomaly")
axs[1][0].set(title="TwoSlopeNorm(vcenter=0)", xticks=[], yticks=[])
# Boundaries turn a continuum into named classes, each with one flat color.
d = axs[1][1].contourf(field, levels=14, cmap="viridis",
norm=norms.BoundaryNorm([-1.0, -0.4, 0.0, 0.4, 1.4]))
fig.colorbar(d, label="class", ticks=[-0.4, 0.0, 0.4])
axs[1][1].set(title="BoundaryNorm([…])", xticks=[], yticks=[])
fig
Color science¶
pyplotrs.color is the part that has no matplotlib equivalent: conversions to
perceptual spaces, a distance metric that matches what eyes do, and two
measurements you can run on a colormap before you publish with it.
teal = (0, 158, 115)
print("sRGB ", teal)
print("Oklab ", tuple(round(v, 4) for v in color.to_oklab(teal)))
print("Oklch ", tuple(round(v, 3) for v in color.to_oklch(teal)))
print("CIE Lab ", tuple(round(v, 2) for v in color.to_lab(teal)))
print("CAM16-UCS ", tuple(round(v, 2) for v in color.to_cam16ucs(teal)))
print("linear ", tuple(round(v, 4) for v in color.to_linear(teal)))
print()
# `distance` is CAM16-UCS: "how different do these look", not "how far apart are
# the numbers". Two pairs with similar RGB gaps can be very different to look at.
orange, blue = (230, 159, 0), (0, 114, 178)
print("distance(orange, blue) =", round(color.distance(orange, blue), 1))
print("distance(orange, teal) =", round(color.distance(orange, teal), 1))
sRGB (0, 158, 115) Oklab (0.6198, -0.1254, 0.0325) Oklch (0.62, 0.13, 165.459) CIE Lab (57.74, -45.74, 12.76) CAM16-UCS (58.94, 30.35, 167.56) linear (0.0, 0.3419, 0.1714) distance(orange, blue) = 67.7 distance(orange, teal) = 47.4
# How the palette reads to someone with each kind of dichromacy.
kinds = ["protanopia", "deuteranopia", "tritanopia"]
palette = pp.themes.default.palette
fig, ax = pp.subplots(figsize=(460, 190))
for row, kind in enumerate(["normal vision", *kinds]):
for i, rgba in enumerate(palette):
rgb = rgba[:3]
shown = rgb if row == 0 else color.simulate_cvd(rgb, kinds[row - 1])
ax.rectangle((i, -row), 0.88, 0.88, facecolor=shown)
ax.text(-0.25, -row + 0.44, kind, ha="right", va="center", fontsize=8)
ax.set(xlim=(-3.4, len(palette)), ylim=(-3.3, 1.1),
title="Okabe-Ito under simulated color vision deficiency")
ax.axis("off")
fig
# Two numbers worth checking before you commit to a colormap.
#
# perceptual_uniformity roughness of the CAM16-UCS step size: 0 is perfect,
# larger means some regions compress more data range
# into less visual change than others.
# cvd_safe_report worst-case distinguishability under each deficiency,
# 1.0 = untouched, below ~0.5 = a real concern.
print(f"{'colormap':<16}{'roughness':>10} protan deuter tritan")
for name in ["viridis", "cividis", "magma", "Blues", "RdBu", "turbo", "jet",
"rainbow"]:
roughness = color.perceptual_uniformity(name)
report = color.cvd_safe_report(name)
print(f"{name:<16}{roughness:>10.3f} "
+ " ".join(f"{report[k]:6.2f}" for k in kinds))
colormap roughness protan deuter tritan viridis 0.246 0.39 0.58 0.54 cividis 0.277 0.90 0.91 0.64 magma 0.472 0.45 0.71 0.58 Blues 0.252 0.80 0.91 0.93 RdBu 0.238 0.84 0.95 1.00 turbo 0.386 0.25 0.31 0.54 jet 0.465 0.55 0.23 0.31 rainbow 0.398 0.32 0.12 0.49
The two columns measure different things, and a map can do well on one and badly on the other:
- Roughness is about the data.
viridis,RdBuandBluesstep evenly (~0.24-0.25), so equal steps in the data look like equal steps in color.jetandrainboware roughly twice as rough: they compress whole stretches of the range into one apparent color and expand others into false detail, which is how a rainbow-mapped image acquires bands that are not in the data. - CVD is about the reader.
cividiswas built for it and shows it (0.90 / 0.91 / 0.64).viridisis smooth but loses roughly half its worst-case contrast under protanopia.rainbowunder deuteranopia keeps 0.12 — its distinctions are, for that reader, gone.
cividis is the safe default when you do not know who is reading; viridis is
the better quantitative map; jet and rainbow are not defensible as either.
Here is the same field under a uniform map and under jet, with the roughness
printed above:
fig, axs = pp.subplots(1, 2, figsize=(460, 190))
for ax, name in zip(axs, ("viridis", "jet")):
mark = ax.imshow(field, cmap=name)
fig.colorbar(mark)
ax.set(title=f"{name} (roughness {color.perceptual_uniformity(name):.2f})",
xticks=[], yticks=[])
fig
Choosing¶
| The data is | Use a | For example |
|---|---|---|
| A magnitude with no meaningful middle | sequential, perceptually uniform map | viridis, magma, cividis |
| A deviation from a reference | diverging map + TwoSlopeNorm |
RdBu, PuOr, cmo_balance |
| An angle or a phase | cyclic map | twilight, cmo_phase |
| A handful of unordered categories | the theme palette (C0…Cn) |
Okabe-Ito, Dark2 |
| Many unordered categories | a categorical palette built for it | cet_glasbey |
| Spanning several orders of magnitude | a uniform map + LogNorm |
viridis |
| Classes, not a continuum | a uniform map + BoundaryNorm |
viridis |
Next: text and math — the face a figure is set
in, styling one word of a label, and $…$ with no LaTeX installation.