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}
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.
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.
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_cmapis deprecated.- Keep the tested palette in a module, not copied into each script.
Internal links
- Colour on maps explained: sequential, diverging and qualitative โ which family the data needs
- How to check a map for colour-blind readers โ the CVD simulation in depth
- Choropleth classification explained โ choosing the breaks the ramp will colour
- Accessible maps explained: contrast, text and alternatives โ the contrast thresholds
- How to build a reusable map style module โ where the tested palette lives
- How to make a choropleth map in GeoPandas โ applying the ramp
- Fixing choropleth colours that look wrong โ when the problem is the norm, not the ramp
- Visual hierarchy explained: what a map reader sees first โ the ramp's place in the hierarchy
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.