Skip to content

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 (..., 3) array (or array-like) of sRGB values in [0, 1].

required

Returns:

Type Description
ndarray

numpy.ndarray: An (..., 3) array of CIELAB (L*, a*, b*), where

ndarray

L* runs 0 (black) to 100 (white) and a*/b* are the opponent

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
def srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
    """Convert sRGB colours to CIELAB.

    Args:
        rgb: An `(..., 3)` array (or array-like) of sRGB values in `[0, 1]`.

    Returns:
        numpy.ndarray: An `(..., 3)` array of CIELAB `(L*, a*, b*)`, where
        `L*` runs 0 (black) to 100 (white) and `a*`/`b*` are the opponent
        colour axes.

    Examples:
        ```python
        >>> 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

        ```
    """
    rgb = np.asarray(rgb, dtype=float)
    xyz = _srgb_to_linear(rgb) @ _M_RGB2XYZ.T
    t = xyz / _WHITE_D65
    f = np.where(t > _DELTA**3, np.cbrt(t), t / (3 * _DELTA**2) + 4 / 29)
    return np.stack(
        [116 * f[..., 1] - 16, 500 * (f[..., 0] - f[..., 1]), 200 * (f[..., 1] - f[..., 2])],
        axis=-1,
    )

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 (..., 3) array of CIELAB (L*, a*, b*).

required

Returns:

Type Description
ndarray

numpy.ndarray: An (..., 3) array of sRGB values clipped to [0, 1]

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
def lab_to_srgb(lab: np.ndarray) -> np.ndarray:
    """Convert CIELAB colours back to sRGB, clipped to the `[0, 1]` gamut.

    Args:
        lab: An `(..., 3)` array of CIELAB `(L*, a*, b*)`.

    Returns:
        numpy.ndarray: An `(..., 3)` array of sRGB values clipped to `[0, 1]`
        (out-of-gamut Lab colours are clamped, not wrapped).

    Examples:
        ```python
        >>> 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

        ```
    """
    lab = np.asarray(lab, dtype=float)
    fy = (lab[..., 0] + 16) / 116
    f = np.stack([fy + lab[..., 1] / 500, fy, fy - lab[..., 2] / 200], axis=-1)
    t = np.where(f > _DELTA, f**3, 3 * _DELTA**2 * (f - 4 / 29))
    rgb = _linear_to_srgb((t * _WHITE_D65) @ _M_XYZ2RGB.T)
    return np.clip(rgb, 0.0, 1.0)

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 (n, 3) sRGB lookup table in [0, 1].

Raises:

Type Description
ValueError

If fewer than two anchors are given, or n < 2.

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
def interp_perceptual(anchors: Sequence, n: int = 256) -> np.ndarray:
    """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.

    Args:
        anchors: Two or more colours (hex strings, names, or RGB triplets) to
            interpolate between, ordered low to high.
        n: Number of output colours. Defaults to 256.

    Returns:
        numpy.ndarray: An `(n, 3)` sRGB lookup table in `[0, 1]`.

    Raises:
        ValueError: If fewer than two anchors are given, or `n < 2`.

    Examples:
        ```python
        >>> 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

        ```
    """
    rgb = _to_rgb(anchors)
    if rgb.shape[0] < 2:
        raise ValueError("interp_perceptual needs at least two anchor colours")
    if n < 2:
        raise ValueError("n must be >= 2")
    lab = srgb_to_lab(rgb)
    seg = np.sqrt(((lab[1:] - lab[:-1]) ** 2).sum(axis=1))
    cum = np.concatenate([[0.0], np.cumsum(seg)])
    if cum[-1] == 0:  # all anchors identical
        return np.repeat(rgb[:1], n, axis=0)
    t = cum / cum[-1]
    x = np.linspace(0.0, 1.0, n)
    out = lab_to_srgb(np.column_stack([np.interp(x, t, lab[:, k]) for k in range(3)]))
    out[0], out[-1] = rgb[0], rgb[-1]  # snap exact endpoints
    return out

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
def perceptual_colormap(name: str, anchors: Sequence, n: int = 256) -> LinearSegmentedColormap:
    """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.

    Args:
        name: Name for the resulting colormap.
        anchors: Two or more colours to interpolate between.
        n: Number of quantisation levels. Defaults to 256.

    Returns:
        matplotlib.colors.LinearSegmentedColormap: The perceptually-interpolated map.

    Examples:
        ```python
        >>> 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)

        ```
    """
    return LinearSegmentedColormap.from_list(name, interp_perceptual(anchors, n), N=n)

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 (default), equalise the two endpoints' L* so the arms are lightness-symmetric.

True
name str

Name for the resulting colormap. Defaults to "diverging".

'diverging'

Returns:

Type Description
LinearSegmentedColormap

matplotlib.colors.LinearSegmentedColormap: The diverging colormap.

Raises:

Type Description
ValueError

If n < 4. The colormap is built from two perceptual arms (low->center and center->high), each needing at least two samples, so n must be at least 4.

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
def make_diverging(
    low,
    high,
    n: int = 256,
    center: str = "#f4f4f4",
    balance: bool = True,
    name: str = "diverging",
) -> LinearSegmentedColormap:
    """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.

    Args:
        low: Colour for the low end of the scale.
        high: Colour for the high end of the scale.
        n: Total number of levels. Defaults to 256.
        center: The neutral midpoint colour. Defaults to a near-white grey.
        balance: If `True` (default), equalise the two endpoints' `L*` so the
            arms are lightness-symmetric.
        name: Name for the resulting colormap. Defaults to `"diverging"`.

    Returns:
        matplotlib.colors.LinearSegmentedColormap: The diverging colormap.

    Raises:
        ValueError: If `n < 4`. The colormap is built from two perceptual arms
            (`low`->`center` and `center`->`high`), each needing at least two
            samples, so `n` must be at least 4.

    Examples:
        ```python
        >>> 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

        ```
    """
    if n < 4:
        raise ValueError(
            f"make_diverging needs n >= 4 (two perceptual arms of >= 2 samples "
            f"each); got n={n}."
        )
    lo = srgb_to_lab(_to_rgb([low])[0])
    hi = srgb_to_lab(_to_rgb([high])[0])
    if balance:
        lo[0] = hi[0] = min(lo[0], hi[0])
    low_rgb, high_rgb = lab_to_srgb(lo), lab_to_srgb(hi)
    half = n // 2
    arm_lo = interp_perceptual([low_rgb, center], half)
    arm_hi = interp_perceptual([center, high_rgb], n - half)
    return LinearSegmentedColormap.from_list(name, np.vstack([arm_lo, arm_hi]), N=n)

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 (min, max) L* band candidates must fall in, so colours are neither too dark nor too washed out. Defaults to (35, 82).

(35.0, 82.0)
c_min float

Minimum chroma (sqrt(a*^2 + b*^2)) a candidate must have, so colours stay vivid enough to tell apart. Defaults to 25.

25.0

Returns:

Type Description
list[str]

list[str]: n hex colour strings.

Raises:

Type Description
ValueError

If n < 1, or if fewer than n candidate colours satisfy l_range and c_min (the filtered gamut cannot supply n distinct colours) -- widen l_range, lower c_min, or reduce n.

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
def make_categorical(
    n: int, l_range: tuple[float, float] = (35.0, 82.0), c_min: float = 25.0
) -> list[str]:
    """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.

    Args:
        n: Number of distinct colours to generate.
        l_range: Inclusive `(min, max)` `L*` band candidates must fall in, so
            colours are neither too dark nor too washed out. Defaults to
            `(35, 82)`.
        c_min: Minimum chroma (`sqrt(a*^2 + b*^2)`) a candidate must have, so
            colours stay vivid enough to tell apart. Defaults to `25`.

    Returns:
        list[str]: `n` hex colour strings.

    Raises:
        ValueError: If `n < 1`, or if fewer than `n` candidate colours satisfy
            `l_range` and `c_min` (the filtered gamut cannot supply `n` distinct
            colours) -- widen `l_range`, lower `c_min`, or reduce `n`.

    Examples:
        ```python
        >>> 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

        ```
    """
    if n < 1:
        raise ValueError("n must be >= 1")
    grid = np.linspace(0.04, 0.96, 14)
    cand = np.array(np.meshgrid(grid, grid, grid)).reshape(3, -1).T
    lab = srgb_to_lab(cand)
    chroma = np.hypot(lab[:, 1], lab[:, 2])
    keep = (lab[:, 0] >= l_range[0]) & (lab[:, 0] <= l_range[1]) & (chroma >= c_min)
    cand, lab = cand[keep], lab[keep]
    if len(cand) < n:
        raise ValueError(
            f"make_categorical cannot produce {n} distinct colours: only "
            f"{len(cand)} candidate(s) satisfy l_range={l_range} and "
            f"c_min={c_min}. Widen l_range, lower c_min, or reduce n."
        )
    start = int(np.argmax(np.hypot(lab[:, 1], lab[:, 2])))
    chosen = [start]
    dmin = np.sqrt(((lab - lab[start]) ** 2).sum(axis=1))
    for _ in range(n - 1):
        k = int(np.argmax(dmin))
        chosen.append(k)
        dmin = np.minimum(dmin, np.sqrt(((lab - lab[k]) ** 2).sum(axis=1)))
    return [mcolors.to_hex(cand[i]) for i in chosen]

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 Colormap, or an (m, 3) sRGB lookup table.

required
n int

Number of samples to take when cmap is a Colormap. Defaults 256.

256

Returns:

Name Type Description
float float

The coefficient of variation of the per-step DeltaE.

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
def perceptual_uniformity(cmap: Colormap | np.ndarray, n: int = 256) -> float:
    """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.

    Args:
        cmap: A matplotlib `Colormap`, or an `(m, 3)` sRGB lookup table.
        n: Number of samples to take when `cmap` is a `Colormap`. Defaults 256.

    Returns:
        float: The coefficient of variation of the per-step `DeltaE`.

    Examples:
        ```python
        >>> 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

        ```
    """
    if isinstance(cmap, Colormap):
        lut = cmap(np.linspace(0, 1, n))[:, :3]
    else:
        lut = np.asarray(cmap, dtype=float)[:, :3]
    lab = srgb_to_lab(lut)
    de = np.sqrt(((lab[1:] - lab[:-1]) ** 2).sum(axis=1))
    mean = de.mean()
    if mean == 0:
        return 0.0
    return float(de.std() / mean)

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()

A diverging map and a categorical palette#

from cleopatra.styling.perceptual import make_diverging, make_categorical

diverging = make_diverging("#762a83", "#1b7837")  # purple ↔ green, Lab-balanced
classes = make_categorical(12)                     # 12 distinguishable class colours