How to Choose and Test a Colour Ramp in Python

On this page

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

The helpers it calls โ€” sample, to_lab, simulate, min_distance and contrast_ratio โ€” are defined in step 1 and Example 1. Example 4 runs it on viridis, jet and YlGnBu.

Four objective tests to run on a colour ramp before it reaches a map. four tests, milliseconds each1monotonic lightnessordered data only2adjacent separation โ‰ฅ ฮ”E 10at your class count3safe under three deficienciesall pairs for categories4a legible label colourper band, not per mapRun these on any ramp somebody proposes, including the corporate one spatialworkflow.io
"Its lightness reverses three times" is a different conversation from "I do not like it".

What you get โ€” from Example 4 โ€” the measurements quoted on this page

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}

lightness over 32 samples
  viridis  reversals 0   largest step 2.59
  cividis  reversals 0   largest step 2.58
  YlGnBu   reversals 0   largest step 4.15
  Blues    reversals 0   largest step 3.08
  rainbow  reversals 1   largest step 5.08
  jet      reversals 3   largest step 9.14

minimum adjacent ฮ”E
  viridis  7 classes 23.5   8 classes 19.7   9 classes 16.7
  YlGnBu   7 classes 10.3   8 classes 9.4   9 classes 8.9

qualitative palettes, minimum ฮ”E between any two classes
  tab10   normal  27.7   protanopia  4.6   deuteranopia  7.3   tritanopia 15.2
  Dark2   normal  31.6   protanopia  2.1   deuteranopia  5.8   tritanopia 13.4
  Accent  normal  35.2   protanopia  2.7   deuteranopia 15.9   tritanopia 25.2

label contrast at 9 classes (WCAG 4.5:1)
  viridis  white 4/9, black 5/9
  YlGnBu   white 3/9, black 6/9

marine: lightness 24.7 โ†’ 95.6

same-lightness: lightness 24.7 โ†’ 24.4

Sample, then test lightness, separation and colour vision

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 once and jet three times, with a largest lightness step of 9.14 against viridis's 2.59.

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 three class counts, viridis gives a minimum adjacent ฮ”E of 23.5 at seven classes, 19.7 at eight and 16.7 at nine, while YlGnBu gives 10.3, 9.4 and 8.9. YlGnBu at nine classes has steps a reader cannot separate across the map, and dropping to eight does not fix it; seven does, just.

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.3 (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.

Minimum adjacent colour distance falling as the number of classes rises. the class count is part of the ramp choiceviridis, 7 classesฮ”E 23.5viridis, 9 classesฮ”E 16.7YlGnBu, 7 classesฮ”E 10.3YlGnBu, 9 classesฮ”E 8.9026When separation fails, reduce the classes โ€” do not reach for a different ramp spatialworkflow.io
A ramp is not usable or unusable on its own; it is usable at a number of classes.

A ramp test suite and a brand ramp that passes it

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)


# Colour-vision-deficiency simulation: Machado, Oliveira and Fernandes (2009),
# severity 1.0, applied to linear RGB.
CVD = {
    "protanopia":   np.array([[0.152286, 1.052583, -0.204868],
                              [0.114503, 0.786281, 0.099216],
                              [-0.003882, -0.048116, 1.051998]]),
    "deuteranopia": np.array([[0.367322, 0.860646, -0.227968],
                              [0.280085, 0.672501, 0.047413],
                              [-0.011820, 0.042940, 0.968881]]),
    "tritanopia":   np.array([[1.255528, -0.076749, -0.178779],
                              [-0.078411, 0.930809, 0.147602],
                              [0.004733, 0.691367, 0.303900]]),
}


def simulate(colours, kind):
    lin = np.clip(srgb_to_linear(np.asarray(colours)[:, :3]) @ CVD[kind].T, 0, 1)
    return np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055)


def min_distance(colours):
    """Smallest ฮ”E (CIE76) between any two of the colours."""
    lab = to_lab(colours)
    d = np.linalg.norm(lab[:, None, :] - lab[None, :, :], axis=-1)
    return float(d[np.triu_indices(len(lab), 1)].min())


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

Example 4 โ€” the measurements quoted on this page

for ramp in ("viridis", "jet", "YlGnBu"):
    print(f"test_ramp({ramp!r}, 9):", test_ramp(ramp, 9))

print("\nlightness over 32 samples")
for ramp in ("viridis", "cividis", "YlGnBu", "Blues", "rainbow", "jet"):
    steps = np.diff(to_lab(sample(ramp, 32))[:, 0])
    reversals = int((np.diff(np.sign(steps)) != 0).sum())
    print(f"  {ramp:8} reversals {reversals}   largest step {np.abs(steps).max():.2f}")

print("\nminimum adjacent ฮ”E")
for ramp in ("viridis", "YlGnBu"):
    at = {n: np.linalg.norm(np.diff(to_lab(sample(ramp, n)), axis=0), axis=1).min() for n in (7, 8, 9)}
    print(f"  {ramp:8} " + "   ".join(f"{n} classes {v:.1f}" for n, v in at.items()))

print("\nqualitative palettes, minimum ฮ”E between any two classes")
for ramp in ("tab10", "Dark2", "Accent"):
    colours = sample(ramp, 8)
    seen = {k: min_distance(simulate(colours, k)) for k in CVD}
    print(f"  {ramp:7} normal {min_distance(colours):5.1f}   " +
          "   ".join(f"{k} {v:4.1f}" for k, v in seen.items()))

print("\nlabel contrast at 9 classes (WCAG 4.5:1)")
for ramp in ("viridis", "YlGnBu"):
    colours = sample(ramp, 9)
    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"  {ramp:8} white {w}/9, black {b}/9")

brand = brand_ramp("#1a3a6b", "#e8f4fd", name="marine")
flat = brand_ramp("#1a3a6b", "#5b2e0e", name="same-lightness")
for cmap in (brand, flat):
    L = to_lab(cmap(np.linspace(0, 1, 7))[:, :3])[:, 0]
    print(f"\n{cmap.name}: lightness {L[0]:.1f} โ†’ {L[-1]:.1f}")
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}

lightness over 32 samples
  viridis  reversals 0   largest step 2.59
  cividis  reversals 0   largest step 2.58
  YlGnBu   reversals 0   largest step 4.15
  Blues    reversals 0   largest step 3.08
  rainbow  reversals 1   largest step 5.08
  jet      reversals 3   largest step 9.14

minimum adjacent ฮ”E
  viridis  7 classes 23.5   8 classes 19.7   9 classes 16.7
  YlGnBu   7 classes 10.3   8 classes 9.4   9 classes 8.9

qualitative palettes, minimum ฮ”E between any two classes
  tab10   normal  27.7   protanopia  4.6   deuteranopia  7.3   tritanopia 15.2
  Dark2   normal  31.6   protanopia  2.1   deuteranopia  5.8   tritanopia 13.4
  Accent  normal  35.2   protanopia  2.7   deuteranopia 15.9   tritanopia 25.2

label contrast at 9 classes (WCAG 4.5:1)
  viridis  white 4/9, black 5/9
  YlGnBu   white 3/9, black 6/9

marine: lightness 24.7 โ†’ 95.6

same-lightness: lightness 24.7 โ†’ 24.4

Testing beats choosing

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.

Sampling a colour ramp from 0.06 to 0.94 instead of end to end. why the ends of a ramp are trimmedfull 0 โ†’ 1near-blacknear-whitelost to outlineslost to the pageends collide with ink and pagetrimmed 0.06 โ†’ 0.94dark, not blacklight, not whitekeeps nearly the whole rangeboth ends still visibleOne line, and a whole class of "why has that polygon vanished?" questions disappears spatialworkflow.io
The extremes of most ramps are near-black and near-white โ€” both collide with the map.

Alpha, no-data colours and printer gamuts

  • 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.