Perceptual colour toolkit#
The cleopatra.styling.perceptual module builds good colormaps the way scientific palette
libraries do — by working in a perceptually-uniform colour space rather than
RGB — using only numpy and matplotlib (no extra dependency, and nothing
imported from or copied out of cmocean / colorcet).
matplotlib interpolates colormaps in RGB, which is perceptually non-uniform: equal
data steps map to visually uneven steps, so hand-authored ramps band and have dead
zones. The one primitive here — a closed-form sRGB ↔ CIELAB transform — fixes
that, and everything else is built on top of it.
For scientific-grade sequential and cyclic maps, prefer matplotlib's own
viridis family and twilight (already optimised in CAM02-UCS); this toolkit
earns its keep on diverging, categorical, and smoothing bespoke domain
ramps.
Colour-space transform#
The pure-numpy sRGB ↔ CIELAB conversion that underlies everything else.
cleopatra.styling.perceptual.srgb_to_lab(rgb)
#
Convert sRGB colours to CIELAB.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb
|
ndarray
|
An |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
numpy.ndarray: An |
ndarray
|
|
ndarray
|
colour axes. |
Examples:
>>> import numpy as np
>>> from cleopatra.styling.perceptual import srgb_to_lab
>>> bool(np.allclose(srgb_to_lab(np.array([0.0, 0.0, 0.0])), [0, 0, 0], atol=1e-6))
True
>>> float(round(srgb_to_lab(np.array([1.0, 1.0, 1.0]))[0], 2))
100.0
Source code in src/cleopatra/styling/perceptual.py
cleopatra.styling.perceptual.lab_to_srgb(lab)
#
Convert CIELAB colours back to sRGB, clipped to the [0, 1] gamut.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lab
|
ndarray
|
An |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
numpy.ndarray: An |
ndarray
|
(out-of-gamut Lab colours are clamped, not wrapped). |
Examples:
>>> import numpy as np
>>> from cleopatra.styling.perceptual import srgb_to_lab, lab_to_srgb
>>> rgb = np.array([0.2, 0.6, 0.9])
>>> bool(np.allclose(lab_to_srgb(srgb_to_lab(rgb)), rgb, atol=1e-6))
True
Source code in src/cleopatra/styling/perceptual.py
Perceptual interpolation#
Interpolate colour anchors in CIELAB (at uniform perceptual arc-length), so a ramp
progresses evenly to the eye. perceptual_colormap is a drop-in, perceptually-even
replacement for matplotlib.colors.LinearSegmentedColormap.from_list.
cleopatra.styling.perceptual.interp_perceptual(anchors, n=256)
#
Interpolate colour anchors in CIELAB at uniform perceptual arc-length.
Unlike RGB interpolation, consecutive output colours are (near) equally spaced in perceived colour difference, so the ramp reads as an even progression. The exact endpoint anchors are preserved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
anchors
|
Sequence
|
Two or more colours (hex strings, names, or RGB triplets) to interpolate between, ordered low to high. |
required |
n
|
int
|
Number of output colours. Defaults to 256. |
256
|
Returns:
| Type | Description |
|---|---|
ndarray
|
numpy.ndarray: An |
Raises:
| Type | Description |
|---|---|
ValueError
|
If fewer than two anchors are given, or |
Examples:
>>> import numpy as np
>>> from cleopatra.styling.perceptual import interp_perceptual
>>> lut = interp_perceptual(["#ffffff", "#ff6a00", "#2a0800"], n=16)
>>> lut.shape
(16, 3)
>>> bool(np.allclose(lut[0], [1, 1, 1])) # first anchor preserved exactly
True
Source code in src/cleopatra/styling/perceptual.py
cleopatra.styling.perceptual.perceptual_colormap(name, anchors, n=256)
#
Build a LinearSegmentedColormap from anchors interpolated in CIELAB.
A perceptually-uniform, drop-in replacement for
matplotlib.colors.LinearSegmentedColormap.from_list: the same call shape and
(continuous) return type, but the anchors are interpolated in CIELAB so the
ramp reads as an even progression rather than banding.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Name for the resulting colormap. |
required |
anchors
|
Sequence
|
Two or more colours to interpolate between. |
required |
n
|
int
|
Number of quantisation levels. Defaults to 256. |
256
|
Returns:
| Type | Description |
|---|---|
LinearSegmentedColormap
|
matplotlib.colors.LinearSegmentedColormap: The perceptually-interpolated map. |
Examples:
>>> from cleopatra.styling.perceptual import perceptual_colormap
>>> cmap = perceptual_colormap("dust", ["#ffffff", "#ff6a00", "#2a0800"])
>>> cmap.name
'dust'
>>> tuple(float(round(v, 3)) for v in cmap(0.0)) # starts at the first anchor
(1.0, 1.0, 1.0, 1.0)
Source code in src/cleopatra/styling/perceptual.py
Generators#
Build a perceptually-uniform diverging map from two endpoint colours, or a set of maximally-distinguishable categorical colours (the glasbey max-min method) — both from scratch, no colour data required.
cleopatra.styling.perceptual.make_diverging(low, high, n=256, center='#f4f4f4', balance=True, name='diverging')
#
Construct a perceptually-uniform diverging colormap from two endpoints.
Builds two Lab-interpolated arms from a light neutral center out to each
endpoint, giving the symmetric lightness profile (a peak at the centre) a
good diverging map needs. With balance=True the two endpoints are forced
to equal lightness first, so neither side visually dominates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
low
|
Colour for the low end of the scale. |
required | |
high
|
Colour for the high end of the scale. |
required | |
n
|
int
|
Total number of levels. Defaults to 256. |
256
|
center
|
str
|
The neutral midpoint colour. Defaults to a near-white grey. |
'#f4f4f4'
|
balance
|
bool
|
If |
True
|
name
|
str
|
Name for the resulting colormap. Defaults to |
'diverging'
|
Returns:
| Type | Description |
|---|---|
LinearSegmentedColormap
|
matplotlib.colors.LinearSegmentedColormap: The diverging colormap. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from cleopatra.styling.perceptual import make_diverging, srgb_to_lab
>>> cmap = make_diverging("#762a83", "#1b7837")
>>> cmap.N
256
>>> bool(srgb_to_lab(cmap(0.5)[:3])[0] > srgb_to_lab(cmap(0.0)[:3])[0])
True
Source code in src/cleopatra/styling/perceptual.py
cleopatra.styling.perceptual.make_categorical(n, l_range=(35.0, 82.0), c_min=25.0)
#
Generate n maximally-distinguishable categorical colours (glasbey method).
Greedily selects, from a mid-lightness / chromatic gamut, the colour whose minimum CIELAB distance to those already chosen is largest -- the same max-min strategy the glasbey / colorcet categorical palettes use. Fully deterministic and dependency-free.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of distinct colours to generate. |
required |
l_range
|
tuple[float, float]
|
Inclusive |
(35.0, 82.0)
|
c_min
|
float
|
Minimum chroma ( |
25.0
|
Returns:
| Type | Description |
|---|---|
list[str]
|
list[str]: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from cleopatra.styling.perceptual import make_categorical
>>> cols = make_categorical(5)
>>> len(cols) == len(set(cols)) == 5 # all distinct
True
>>> all(c.startswith("#") for c in cols)
True
Source code in src/cleopatra/styling/perceptual.py
Diagnostic#
Score how perceptually even a colormap's steps are — useful for comparing an
RGB-interpolated ramp against its interp_perceptual counterpart.
cleopatra.styling.perceptual.perceptual_uniformity(cmap, n=256)
#
Score how perceptually even a colormap's steps are (0 == perfectly even).
Returns the coefficient of variation of the per-step CIELAB distance: the
standard deviation of DeltaE between consecutive samples divided by their
mean. Lower is more uniform. Useful for comparing an RGB-interpolated ramp
against its interp_perceptual counterpart.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cmap
|
Colormap | ndarray
|
A matplotlib |
required |
n
|
int
|
Number of samples to take when |
256
|
Returns:
| Name | Type | Description |
|---|---|---|
float |
float
|
The coefficient of variation of the per-step |
Examples:
>>> from cleopatra.styling.perceptual import perceptual_colormap, perceptual_uniformity
>>> from matplotlib.colors import LinearSegmentedColormap
>>> anchors = ["#ffffff", "#ff5fc9", "#200018"]
>>> lab = perceptual_uniformity(perceptual_colormap("p", anchors))
>>> rgb = perceptual_uniformity(LinearSegmentedColormap.from_list("r", anchors))
>>> bool(lab < rgb) # Lab interpolation is more even than RGB
True
Source code in src/cleopatra/styling/perceptual.py
Examples#
A smoother domain ramp#
import numpy as np
import matplotlib.pyplot as plt
from cleopatra.styling.perceptual import perceptual_colormap, perceptual_uniformity
anchors = ["#ffffff", "#ff6a00", "#7a1500", "#2a0800"] # a "dust" ramp
cmap = perceptual_colormap("dust", anchors)
# far more perceptually even than an RGB LinearSegmentedColormap.from_list
print(perceptual_uniformity(cmap)) # ~0.02 (RGB build scores ~0.18)
fig, ax = plt.subplots(figsize=(6, 1))
ax.imshow(np.linspace(0, 1, 256).reshape(1, -1), aspect="auto", cmap=cmap)
ax.set_axis_off()