How to Choose and Test a Colour Ramp in Python

Problem statement

Somebody asks for the map in the corporate palette. Or the default viridis looks wrong beside the report's other figures. Or the data is a rate that needs seven classes and the chosen ramp only has five that are distinguishable.

Choosing a ramp by eye fails in specific, repeatable ways, and all of them are testable in a few lines:

  • the lightness is not monotonic, so the map shows contours the data does not have
  • adjacent classes are too close to tell apart at legend size
  • two categories collapse into one for a colour-blind reader
  • no single label colour is legible across the ramp

This guide is the test suite. Run it on any ramp โ€” yours, a colleague's, or a package default โ€” before it reaches a map.

Quick answer

Four tests, all cheap, all objective:

def test_ramp(name_or_colours, n_classes=7):
    """1. monotonic lightness  2. adjacent separation
       3. colour-blind safety  4. text contrast"""
    colours = sample(name_or_colours, n_classes)
    lab = to_lab(colours)

    steps = np.diff(lab[:, 0])
    monotonic = bool(np.all(steps > 0) or np.all(steps < 0))

    adjacent = np.linalg.norm(np.diff(lab, axis=0), axis=1)
    separated = bool(adjacent.min() >= 10)

    cvd_min = min(min_distance(simulate(colours, k)) for k in
                  ("deuteranopia", "protanopia", "tritanopia"))

    white_ok = sum(contrast_ratio(c, (1, 1, 1)) >= 4.5 for c in colours)
    black_ok = sum(contrast_ratio(c, (0, 0, 0)) >= 4.5 for c in colours)

    return {"monotonic_lightness": monotonic,
            "min_adjacent_dE": round(float(adjacent.min()), 1),
            "min_dE_under_cvd": round(cvd_min, 1),
            "label_colour": "white" if white_ok > black_ok else "black",
            "steps_needing_the_other": n_classes - max(white_ok, black_ok)}
>>> test_ramp("viridis", 9)
{'monotonic_lightness': True, 'min_adjacent_dE': 16.7,
 'min_dE_under_cvd': 7.7, 'label_colour': 'black', 'steps_needing_the_other': 4}

>>> test_ramp("jet", 9)
{'monotonic_lightness': False, 'min_adjacent_dE': 18.4,
 'min_dE_under_cvd': 10.4, 'label_colour': 'black', 'steps_needing_the_other': 3}

>>> test_ramp("YlGnBu", 9)
{'monotonic_lightness': True, 'min_adjacent_dE': 8.9,
 'min_dE_under_cvd': 6.0, 'label_colour': 'black', 'steps_needing_the_other': 3}
Four vertical steps: monotonic lightness, adjacent separation, CVD safety, label contrast.
"Its lightness reverses twice" is a different conversation from "I do not like it".

Step-by-step solution

1. Sample the ramp at the number of classes you will actually use

A ramp is a continuous function; a choropleth uses a handful of samples from it. Test the samples, not the function โ€” a ramp can be perfectly uniform and still produce two indistinguishable classes at seven steps.

import numpy as np, matplotlib


def sample(ramp, n):
    if isinstance(ramp, str):
        cmap = matplotlib.colormaps[ramp]
        if hasattr(cmap, "colors") and len(getattr(cmap, "colors", [])) <= 20:
            return np.array(cmap.colors[:n])           # qualitative
        return cmap(np.linspace(0.06, 0.94, n))[:, :3]  # trim the extremes
    return np.asarray([matplotlib.colors.to_rgb(c) for c in ramp])

Trimming the extremes is deliberate. The very ends of many ramps are near-black and near-white, which collide with the text and the background.

2. Test monotonic lightness

For any ordered variable this is the first gate. Convert to CIE Lab*, take the lightness channel, and check that the differences never change sign.

Measured on 32 samples, viridis, cividis, YlGnBu and Blues are monotonic; rainbow reverses twice and jet three times, with a largest lightness step of 9.51 against viridis's 2.94.

3. Test adjacent separation

Two classes that a reader cannot tell apart are one class. Measure the perceptual distance between consecutive samples:

  • ฮ”E below 5 โ€” indistinguishable at legend size.
  • ฮ”E 5โ€“10 โ€” distinguishable side by side, not across the map.
  • ฮ”E above 10 โ€” safe.

If the minimum adjacent distance is too small, reduce the number of classes rather than reaching for a different ramp. Measured on the same ramp at two class counts, viridis gives a minimum adjacent ฮ”E of 23.5 at seven classes and 16.7 at nine, while YlGnBu gives 10.3 and 8.9 โ€” so YlGnBu at nine classes has two steps a reader cannot separate, and the fix is eight classes, not a new ramp.

Five well-separated classes beat nine that blur.

4. Test under colour vision deficiency

Simulate deuteranopia, protanopia and tritanopia, then measure the minimum distance between all pairs โ€” not just adjacent ones, because for a qualitative palette any two categories may end up side by side.

The measured baseline for common palettes: tab10 falls from ฮ”E 27.7 to 7.2 (deuteranopia) and 4.6 (protanopia); Dark2 reaches 2.1 under protanopia; Accent reaches 2.7. Those are palettes in daily use.

5. Choose the label colour from the ramp, not by habit

Compute the WCAG contrast ratio of white and black text against each class colour. Measured on nine steps: white text clears 4.5:1 on 4 of viridis's 9 and 3 of YlGnBu's 9; black clears it on 5 and 6 respectively.

Neither colour works everywhere, so pick the majority colour and handle the rest with a halo:

label.set_path_effects([pe.withStroke(linewidth=2.0, foreground="white")])

6. Build a custom ramp properly if you need one

A corporate palette usually specifies two or three brand colours. Interpolating between them in sRGB produces muddy midpoints and non-monotonic lightness. Interpolate in a perceptual space and then re-test โ€” the tests above are exactly what tells you whether the brand ramp is usable as a data ramp, or only as an accent.

Bar chart of minimum adjacent colour distance for two ramps at seven and nine classes.
A ramp is not usable or unusable on its own; it is usable at a number of classes.

Code examples

Example 1 โ€” the full ramp test suite

import numpy as np
import matplotlib
import matplotlib.colors as mc


def srgb_to_linear(c):
    c = np.asarray(c, dtype=float)
    return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)


def to_lab(rgb):
    rgb = np.atleast_2d(np.asarray(rgb, dtype=float))[:, :3]
    m = np.array([[0.4124, 0.3576, 0.1805],
                  [0.2126, 0.7152, 0.0722],
                  [0.0193, 0.1192, 0.9505]])
    xyz = srgb_to_linear(rgb) @ m.T / np.array([0.95047, 1.0, 1.08883])
    d = 6 / 29
    f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4 / 29)
    return np.stack([116 * f[:, 1] - 16, 500 * (f[:, 0] - f[:, 1]),
                     200 * (f[:, 1] - f[:, 2])], axis=1)


def contrast_ratio(fg, bg):
    def lum(c):
        lin = srgb_to_linear(np.asarray(c)[:3])
        return float(0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2])
    a, b = lum(fg), lum(bg)
    hi, lo = max(a, b), min(a, b)
    return (hi + 0.05) / (lo + 0.05)


def report(ramp, n=7, ordered=True):
    colours = sample(ramp, n)
    lab = to_lab(colours)
    steps = np.diff(lab[:, 0])
    adjacent = np.linalg.norm(np.diff(lab, axis=0), axis=1)

    name = ramp if isinstance(ramp, str) else "custom"
    print(f"\n{name}  ({n} classes)")
    if ordered:
        ok = np.all(steps > 0) or np.all(steps < 0)
        print(f"  lightness      {lab[0, 0]:5.1f} โ†’ {lab[-1, 0]:5.1f}   "
              f"{'monotonic' if ok else 'NOT MONOTONIC โ€” will draw false contours'}")
    print(f"  adjacent ฮ”E    min {adjacent.min():5.1f}   "
          f"{'ok' if adjacent.min() >= 10 else 'classes will blur'}")
    for kind in ("deuteranopia", "protanopia", "tritanopia"):
        seen = to_lab(simulate(colours, kind))
        d = np.linalg.norm(seen[:, None, :] - seen[None, :, :], axis=-1)
        iu = np.triu_indices(n, 1)
        print(f"  {kind:13} min ฮ”E {d[iu].min():5.1f}   "
              f"{'ok' if d[iu].min() >= 10 else 'confusable pairs'}")
    w = sum(contrast_ratio(c, (1, 1, 1)) >= 4.5 for c in colours)
    b = sum(contrast_ratio(c, (0, 0, 0)) >= 4.5 for c in colours)
    print(f"  labels         white passes {w}/{n}, black passes {b}/{n}")
    return colours

Example 2 โ€” building a brand ramp that survives the tests

import numpy as np
import matplotlib.colors as mc


def lab_to_rgb(lab):
    L, a, b = lab[:, 0], lab[:, 1], lab[:, 2]
    fy = (L + 16) / 116
    fx, fz = fy + a / 500, fy - b / 200
    d = 6 / 29
    f = lambda t: np.where(t > d, t ** 3, 3 * d * d * (t - 4 / 29))
    xyz = np.stack([f(fx), f(fy), f(fz)], 1) * np.array([0.95047, 1.0, 1.08883])
    m = np.linalg.inv(np.array([[0.4124, 0.3576, 0.1805],
                                [0.2126, 0.7152, 0.0722],
                                [0.0193, 0.1192, 0.9505]]))
    lin = np.clip(xyz @ m.T, 0, 1)
    return np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055)


def brand_ramp(start_hex, end_hex, n=256, name="brand"):
    """Interpolate in L*a*b* so lightness moves smoothly and monotonically."""
    a, b = to_lab([mc.to_rgb(start_hex)])[0], to_lab([mc.to_rgb(end_hex)])[0]
    t = np.linspace(0, 1, n)[:, None]
    ramp = lab_to_rgb(a[None, :] * (1 - t) + b[None, :] * t)
    return mc.LinearSegmentedColormap.from_list(name, ramp)

Interpolating in Lab* guarantees the lightness endpoints are respected and the path between them is smooth. It does not guarantee the result is a good ramp โ€” run report() on it, because two brand colours with the same lightness produce a ramp with no lightness range at all, which is unusable for ordered data whatever its hue.

Example 3 โ€” a legend that shows the ramp's real class breaks

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches


def class_legend(ax, colours, breaks, unit="", title=None, fontsize=7):
    """A discrete legend that names the interval, not the class number."""
    handles = []
    for i, colour in enumerate(colours):
        lo, hi = breaks[i], breaks[i + 1]
        label = (f"{lo:,.0f} โ€“ {hi:,.0f}{unit}" if i < len(colours) - 1
                 else f"{lo:,.0f}{unit} and over")
        handles.append(mpatches.Patch(facecolor=colour, edgecolor="white",
                                      linewidth=0.6, label=label))
    legend = ax.legend(handles=handles, title=title, loc="lower left",
                       fontsize=fontsize, title_fontsize=fontsize + 0.5,
                       frameon=False, handlelength=1.4, handleheight=1.0,
                       labelspacing=0.35)
    legend._legend_box.align = "left"
    return legend

Explanation

Why testing beats choosing

Colour advice is plentiful and contradictory, and a ramp that is fine for one dataset at five classes is unreadable for another at nine. The tests here take milliseconds and answer the question for the specific case in front of you.

They also make the conversation with a designer or a brand team concrete. "This ramp's lightness reverses twice, so the map will show two contours that are not in the data" is a different discussion from "I do not like it".

Why to trim the ends of a ramp

Most sequential ramps run from near-black to near-white so that they use the full lightness range. In a map, both ends are problems: the dark end collides with black outlines and text, and the light end disappears into a white background.

Sampling from about 0.06 to 0.94 keeps almost all the lightness range while leaving both ends distinguishable from the page and from the ink. It is a one-line change that removes a whole class of "why has that polygon vanished?" questions.

Why perceptual distance and not RGB distance

RGB distance is not perceptual: two colours 30 units apart in the blue channel look far more similar than two colours 30 units apart in green. CIE Lab* was built so that Euclidean distance approximates perceived difference, which is what makes a ฮ”E threshold meaningful.

The approximation is imperfect โ€” CIEDE2000 refines it โ€” but a plain ฮ”E in Lab* is more than good enough to separate "two classes" from "one class drawn twice".

Why the number of classes is part of the ramp choice

A ramp is not usable or unusable on its own; it is usable at a number of classes. viridis at five classes has generous separation; at eleven the adjacent steps close up.

That is why the test takes n_classes as an argument, and why the right response to a failing separation test is usually to reduce the classes rather than to change the ramp. The reader's ability to distinguish colours is the constraint, and the class count is the variable you control.

Two panels comparing a full colour ramp with one trimmed at both ends.
The extremes of most ramps are near-black and near-white โ€” both collide with the map.

Edge cases or notes

  • Test the palette you will ship, including any alpha applied to it โ€” transparency changes the effective colour.
  • Qualitative palettes need all-pairs testing, not adjacent-pairs: any two categories can end up neighbouring on the map.
  • A ramp with no lightness range cannot encode an ordered variable, whatever its hues.
  • Reserve a colour outside the ramp for "no data" and make it obviously different โ€” grey, or hatched.
  • Alpha over a basemap breaks every measurement here. Composite the colour against the actual background before testing.
  • Printers gamut-clip saturated colours; a ramp that tests well on screen can lose separation in CMYK.
  • matplotlib.colormaps[name] is the modern accessor โ€” plt.cm.get_cmap is deprecated.
  • Keep the tested palette in a module, not copied into each script.

FAQ

How do I know whether a colour ramp is any good?

Test four things: monotonic lightness, adjacent separation above ฮ”E 10, minimum distance under colour-vision simulation, and whether one text colour is legible across it.

What is a good minimum ฮ”E between classes?

About 10. Below 5 the classes are indistinguishable at legend size; between 5 and 10 they can be told apart side by side but not across a map.

Can I use my organisation's brand colours as a data ramp?

Only if they have a lightness range. Interpolate in Lab* rather than sRGB, then run the tests โ€” two brand colours at similar lightness cannot encode an ordered variable.

Why trim the ends of a ramp?

The extremes are usually near-black and near-white, which collide with outlines, text and the page. Sampling from about 0.06 to 0.94 keeps the range and avoids both.

How many classes can a ramp support?

It depends on the ramp. Test at the class count you intend to use; if adjacent separation falls below ฮ”E 10, reduce the classes rather than changing the ramp.

Does transparency affect these tests?

Yes, completely. Composite the colour against the actual background first โ€” an alpha-blended ramp is a different set of colours from the one you tested.