How to Check a Map for Colour-Blind Readers

Problem statement

Roughly one man in twelve and one woman in two hundred has some form of colour vision deficiency. On a map that distinguishes categories by hue, that reader sees fewer categories than you drew โ€” and there is nothing on the page to tell them, or you, that it has happened.

The effect is not subtle. Measuring the minimum perceptual distance between the members of common qualitative palettes, in normal vision and under simulated deficiency:

palette   normal    deuteranopia   protanopia   tritanopia
tab10      27.7          7.2           4.6         15.2
Set1       32.7         10.0            โ€”           โ€”
Dark2        โ€”           5.8           2.1         13.4
Accent     35.2         15.9           2.7         25.2

A ฮ”E of 2.1 means two of the eight Dark2 colours are, for a reader with protanopia, the same colour. Four of tab10's forty-five pairs fall below ฮ”E 10 under deuteranopia.

These are default palettes in daily use, and the check that catches it takes about twenty lines.

Quick answer

Simulate the three deficiencies, measure the minimum distance between every pair, and fail the palette if anything falls below your threshold:

import numpy as np

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


def simulate(rgb, kind):
    """Linear-light matrix transform, then back to sRGB."""
    lin = srgb_to_linear(np.atleast_2d(rgb)[:, :3])
    out = np.clip(lin @ CVD[kind].T, 0, 1)
    return np.where(out <= 0.0031308, out * 12.92, 1.055 * out ** (1 / 2.4) - 0.055)


def check_palette(colours, threshold=10.0):
    report = {}
    for kind in ("normal", *CVD):
        seen = np.asarray(colours) if kind == "normal" else simulate(colours, kind)
        lab = to_lab(seen)
        d = np.linalg.norm(lab[:, None, :] - lab[None, :, :], axis=-1)
        iu = np.triu_indices(len(colours), 1)
        report[kind] = {"min_dE": round(float(d[iu].min()), 1),
                        "failing_pairs": [(int(i), int(j)) for i, j in zip(*iu)
                                          if d[i, j] < threshold]}
    return report
Four-stage flow: linearise, apply matrix, re-encode, measure distance.
The measurement is the point โ€” two patches side by side always look distinguishable.

Step-by-step solution

1. Know which deficiencies to test

Three matter, and they are not equally common:

  • Deuteranopia / deuteranomaly โ€” reduced or absent green sensitivity. The most common by a wide margin.
  • Protanopia / protanomaly โ€” reduced or absent red sensitivity. Also common, and it darkens reds as well as shifting them.
  • Tritanopia โ€” blue-yellow. Rare, and worth checking anyway because it is the one blue-heavy palettes fail.

Testing all three costs nothing extra once the matrices are in a dictionary.

2. Simulate in linear light, not in sRGB

The transformation matrices model how cone responses combine, which happens in linear light. Applying them to gamma-encoded sRGB values produces a picture that looks plausible and is wrong.

The simulate() function above linearises, applies the matrix, clips, and re-encodes. Skipping the linearisation is the most common implementation error in home-made CVD simulators.

3. Measure, do not eyeball

A simulated image looks strange to a reader with normal vision, and it is very easy to conclude "I can still tell those apart" while looking at two patches side by side at 200 pixels each. On a map they will be small, separated and surrounded by other colours.

The number is the point. Convert both simulated colours to CIE Lab* and take the Euclidean distance:

  • ฮ”E below 5 โ€” the same colour for that reader.
  • ฮ”E 5โ€“10 โ€” distinguishable only under favourable conditions.
  • ฮ”E above 10 โ€” reliably distinct.

4. Test all pairs for categories, adjacent pairs for ramps

For a qualitative palette any two categories can end up adjacent on the map, so every pair must clear the threshold. That is n(n-1)/2 comparisons โ€” 45 for a ten-colour palette.

For a sequential ramp, only adjacent classes need to be distinguishable, because the reader compares against the legend in order. A monotonic-lightness ramp passes CVD testing almost automatically, since lightness is unaffected by the deficiency.

5. Fix by reducing hue dependence, not by hunting for a palette

When a palette fails, the options in order of effectiveness:

  1. Fewer categories. Eight hues cannot be made safe; five can.
  2. Vary lightness as well as hue. Lightness survives all three deficiencies intact.
  3. Add a second visual variable โ€” hatching, texture, marker shape, a direct label.
  4. Choose a CVD-safe palette such as Okabeโ€“Ito, Set2 or a viridis-derived qualitative set.

Direct labelling is the strongest of these and the most under-used: a category with its name written on it needs no colour at all.

6. Check the ramp against the basemap and the labels too

The measurement is usually run on the data palette alone. The reader sees the data over a basemap, with labels on top, and any of those combinations can collapse.

Composite the actual colours โ€” data colour over basemap at its real opacity โ€” before testing, or the numbers describe a map nobody sees.

Four fixes for a failing palette, from choosing another palette to direct labelling.
Direct labelling is the most effective and the most under-used.

Code examples

Example 1 โ€” a full palette report

def cvd_report(colours, names=None, threshold=10.0):
    names = names or [f"class {i}" for i in range(len(colours))]
    result = check_palette(colours, threshold)

    print(f"{'vision':14} {'min ฮ”E':>7}  failing pairs")
    for kind, data in result.items():
        pairs = ", ".join(f"{names[i]}/{names[j]}" for i, j in data["failing_pairs"][:4])
        more = "" if len(data["failing_pairs"]) <= 4 else f" (+{len(data['failing_pairs']) - 4})"
        verdict = "ok" if not data["failing_pairs"] else pairs + more
        print(f"{kind:14} {data['min_dE']:7.1f}  {verdict}")

    worst = min(result[k]["min_dE"] for k in result if k != "normal")
    print(f"\nworst case across deficiencies: ฮ”E {worst:.1f}   "
          f"{'usable' if worst >= threshold else 'NOT SAFE โ€” reduce categories or add a second variable'}")
    return result
vision          min ฮ”E  failing pairs
normal            27.7  ok
deuteranopia       7.2  blue/purple, orange/olive, green/red, pink/cyan
protanopia         4.6  blue/purple, orange/green
tritanopia        15.2  ok

worst case across deficiencies: ฮ”E 4.6   NOT SAFE โ€” reduce categories or add a second variable

Example 2 โ€” rendering the map as a colour-blind reader sees it

import io
import numpy as np
from PIL import Image


def simulate_figure(fig, kind="deuteranopia", dpi=120):
    """Render the whole map through the simulation, basemap and labels included."""
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
    buf.seek(0)
    img = np.asarray(Image.open(buf).convert("RGB"), dtype=float) / 255.0
    h, w, _ = img.shape
    out = simulate(img.reshape(-1, 3), kind).reshape(h, w, 3)
    return Image.fromarray((np.clip(out, 0, 1) * 255).astype(np.uint8))


def save_all_simulations(fig, stem="map"):
    for kind in ("deuteranopia", "protanopia", "tritanopia"):
        simulate_figure(fig, kind).save(f"{stem}_{kind}.png")
    print(f"wrote {stem}_deuteranopia.png and two more โ€” look at all three")

Simulating the finished figure rather than the palette catches the interactions: a category that is safe against white and unsafe against the basemap, or a label whose halo disappears.

Example 3 โ€” adding a second visual variable when colour is not enough

import matplotlib.pyplot as plt

HATCHES = ["", "///", "...", "xxx", "\\\\\\", "+++", "ooo", "***"]


def plot_categories_safely(gdf, column, ax=None, cmap="Set2", use_hatch=True,
                           label_directly=True):
    """Colour plus hatching plus direct labels: three signals, any two of which
    survive on their own."""
    ax = ax or plt.subplots(figsize=(8, 6))[1]
    categories = sorted(gdf[column].dropna().unique())
    colours = plt.get_cmap(cmap)(np.linspace(0, 1, len(categories)))

    for i, category in enumerate(categories):
        subset = gdf[gdf[column] == category]
        subset.plot(ax=ax, facecolor=colours[i], edgecolor="white", linewidth=0.5,
                    hatch=HATCHES[i % len(HATCHES)] if use_hatch else None,
                    label=str(category))
        if label_directly and len(subset):
            biggest = subset.loc[subset.geometry.area.idxmax()]
            point = biggest.geometry.representative_point()
            ax.annotate(str(category), (point.x, point.y), fontsize=6.5,
                        ha="center", color="#1e293b")
    ax.set_axis_off()
    return ax

Explanation

Why the simulation matrices are an approximation and still worth using

The Machado matrices model dichromacy as a linear transformation of cone responses. Real colour vision deficiency varies in severity, and anomalous trichromacy โ€” the more common, milder form โ€” sits between normal vision and the full simulation.

That makes the simulation conservative in one direction and optimistic in another: a palette that passes at full severity is safe for milder cases, which is exactly the property you want from a test. Treat the numbers as a screening tool, not as a physiological claim.

Why lightness is the reliable channel

All three deficiencies affect the chromatic channels and leave the achromatic one essentially intact. That is why a sequential ramp with monotonic lightness passes CVD testing almost by construction, and why viridis โ€” designed with this in mind โ€” remains readable under all three simulations.

It is also why "vary lightness as well as hue" is the most effective single fix for a failing qualitative palette: it moves the distinction onto the channel that survives.

Why the tritanopia column still matters

Tritanopia is rare enough that people skip it, and blue-heavy cartographic palettes are common enough that it is the one they fail. A map that distinguishes water, land and administrative areas by blue-yellow contrast can collapse for a tritanope while passing both red-green tests.

Testing all three is one extra dictionary entry. There is no reason to check two.

Why direct labelling beats palette hunting

Every palette is a compromise between the number of categories and the separation between them, and beyond about five categories no hue-based palette survives all three deficiencies at ฮ”E 10.

Writing the category name on the largest polygon of each class removes the dependency entirely. The colour then becomes a convenience rather than the encoding, which is the right relationship for categorical data on a map.

Grid of three colour vision deficiencies with what each affects and its typical failure.
Deuteranomaly and protanomaly together affect roughly one man in twelve.

Edge cases or notes

  • Simulate the composited colours, including alpha and basemap, not the raw palette.
  • Anomalous trichromacy is more common than dichromacy; full-severity simulation is the conservative test.
  • Greyscale printing is a fourth test and often the harshest โ€” a palette that survives it survives most things.
  • Do not rely on red/green for good/bad. It is the single most common failure, and semantics make it worse.
  • Marker shape works for points, hatching for polygons, dashes for lines.
  • Keep the number of categories down. Five safe classes beat nine that some readers cannot separate.
  • Legend order should follow the data, not the palette, so the reader can use position as a cue.
  • Publish the simulation images alongside the map in internal review; it ends the debate quickly.

FAQ

How common is colour vision deficiency?

Roughly one man in twelve and one woman in two hundred. On a hue-encoded map that is a substantial fraction of readers seeing fewer categories than you drew.

Which types should I simulate?

Deuteranopia and protanopia always โ€” they are the common ones โ€” and tritanopia as well, because blue-heavy cartographic palettes are the ones that fail it.

What ฮ”E threshold should I use?

About 10. Below 5 the two colours are the same for that reader; between 5 and 10 they are distinguishable only side by side under good conditions.

Are the default matplotlib palettes safe?

Not all of them. Measured, tab10 falls to ฮ”E 7.2 under deuteranopia and 4.6 under protanopia, and Dark2 falls to 2.1 under protanopia.

What do I do when a palette fails?

Reduce the number of categories, vary lightness as well as hue, add hatching or marker shape, and label directly. Direct labelling removes the dependency on colour entirely.

Do sequential ramps need this check?

Less urgently. A ramp with monotonic lightness passes almost by construction, because the deficiencies affect the chromatic channels and leave lightness intact.