Simulating Color Blindness for Map Palette Validation
Validating a map palette for color blindness means running every class color through a simulation of protanopia, deuteranopia and tritanopia, then measuring the perceptual distance between the simulated class colors and failing the build if any pair becomes too close to tell apart. The reliable Python stack is colorspacious for the simulation — its cvd_space applies the Machado (2009) matrices — plus a deltaE computed in the CAM02-UCS uniform space, wrapped in a validator that returns a non-zero exit code so CI blocks a palette that merges two categories for color-blind readers.
Core Algorithm and Workflow
A palette that looks well separated to a trichromat can collapse two or more classes into a single apparent color under a color-vision deficiency (CVD). Roughly 8 percent of men have some form of red-green deficiency, so a choropleth whose “low” and “medium” classes merge under deuteranopia is silently wrong for a large slice of readers. The fix is deterministic and testable: simulate, measure, threshold.
The workflow has four deterministic stages that map directly onto the validator below.
- Collect the class colors. Pull the exact sRGB values your renderer assigns to each map class. For a categorical palette that is the list of fill hex codes; for a choropleth it is the discrete colors emitted by your classifier, not the continuous colormap. This is the same palette that upstream tools such as color palette generation for thematic maps produce.
- Simulate each dichromacy. Run every color through
colorspaciouscvd_spacethree times — once each forprotanomaly,deuteranomalyandtritanomalyatseverity=100. At full severity these approximate protanopia, deuteranopia and tritanopia, applying the Machado (2009) linear matrices in linear-light RGB. - Compute pairwise deltaE. Convert the simulated colors into CAM02-UCS — a perceptually uniform space where Euclidean distance is a defensible proxy for perceived difference — and measure
deltaEbetween every unordered pair of classes. - Threshold and fail. For each pair take the minimum
deltaEacross the three simulations (the worst-case reader). If that minimum drops below your legibility threshold, the pair has collapsed; the validator records it and exits non-zero.
Why CAM02-UCS rather than a naive RGB or even CIELAB distance? Euclidean distance in sRGB badly mispredicts perceived difference, and CIE76 deltaE in Lab is uneven across the gamut. CAM02-UCS was built so that a fixed numeric distance corresponds to a roughly constant perceived difference anywhere in the space, which is exactly the property a threshold gate needs. The same reasoning underpins the contrast work in WCAG contrast checking for map layers; here the axis is hue and chroma separation rather than luminance contrast.
Production-Ready Python Implementation
The script below is a complete validator. It depends on colorspacious==1.1.2 and numpy>=1.24. colorspacious.cspace_convert handles both the CVD simulation (via a cvd_space dict) and the conversion into CAM02-UCS, so the whole pipeline stays in one well-tested library. See the colorspacious documentation for the full space and transform reference.
#!/usr/bin/env python3
"""Fail CI when a map palette collapses under simulated colour-vision deficiency.
Usage:
python validate_palette.py '#1b9e77' '#d95f02' '#7570b3' '#e7298a'
Exit code 0 if every class pair stays distinct under all three dichromacies,
1 otherwise.
"""
from __future__ import annotations
import sys
from itertools import combinations
import numpy as np
from colorspacious import cspace_convert
# Minimum CAM02-UCS deltaE that keeps two adjacent map classes distinguishable.
# Tuned against rendered output; raise it for low-DPI print or distant viewing.
DELTA_E_THRESHOLD = 15.0
# The three dichromacies, each expressed as a colorspacious cvd_space at full
# severity. severity=100 approximates the corresponding dichromatic vision.
CVD_TYPES = {
"protanopia": {"name": "sRGB1+CVD", "cvd_type": "protanomaly", "severity": 100},
"deuteranopia": {"name": "sRGB1+CVD", "cvd_type": "deuteranomaly", "severity": 100},
"tritanopia": {"name": "sRGB1+CVD", "cvd_type": "tritanomaly", "severity": 100},
}
def hex_to_rgb1(hex_color: str) -> np.ndarray:
"""Convert '#rrggbb' to an sRGB array scaled to the 0-1 range."""
h = hex_color.lstrip("#")
if len(h) != 6:
raise ValueError(f"Expected a 6-digit hex colour, got {hex_color!r}")
return np.array([int(h[i:i + 2], 16) for i in (0, 2, 4)], dtype=float) / 255.0
def simulate_cvd(rgb1: np.ndarray, cvd_space: dict) -> np.ndarray:
"""Apply a colour-vision-deficiency simulation, returning simulated sRGB (0-1)."""
simulated = cspace_convert(rgb1, cvd_space, "sRGB1")
return np.clip(simulated, 0.0, 1.0)
def delta_e_ucs(rgb1_a: np.ndarray, rgb1_b: np.ndarray) -> float:
"""Perceptual distance between two sRGB colours in CAM02-UCS."""
a = cspace_convert(rgb1_a, "sRGB1", "CAM02-UCS")
b = cspace_convert(rgb1_b, "sRGB1", "CAM02-UCS")
return float(np.linalg.norm(a - b))
def validate_palette(hex_colors: list[str],
threshold: float = DELTA_E_THRESHOLD) -> list[dict]:
"""Return one record per class pair that collapses under any dichromacy.
Each record carries the offending pair, the dichromacy that caused the
worst collapse, and the minimum deltaE observed across all three.
"""
rgb = {c: hex_to_rgb1(c) for c in hex_colors}
failures: list[dict] = []
for c_a, c_b in combinations(hex_colors, 2):
worst_type, worst_de = None, float("inf")
for cvd_name, cvd_space in CVD_TYPES.items():
sim_a = simulate_cvd(rgb[c_a], cvd_space)
sim_b = simulate_cvd(rgb[c_b], cvd_space)
de = delta_e_ucs(sim_a, sim_b)
if de < worst_de:
worst_de, worst_type = de, cvd_name
if worst_de < threshold:
failures.append({
"pair": (c_a, c_b),
"cvd_type": worst_type,
"delta_e": round(worst_de, 2),
})
return failures
def main(argv: list[str]) -> int:
palette = argv[1:]
if len(palette) < 2:
print("Provide at least two hex colours to validate.", file=sys.stderr)
return 2
failures = validate_palette(palette)
if not failures:
print(f"PASS: {len(palette)} classes stay distinct "
f"(deltaE >= {DELTA_E_THRESHOLD}) under all three dichromacies.")
return 0
print(f"FAIL: {len(failures)} class pair(s) collapse under simulated CVD:",
file=sys.stderr)
for f in failures:
a, b = f["pair"]
print(f" {a} <-> {b}: deltaE={f['delta_e']} "
f"under {f['cvd_type']} (threshold {DELTA_E_THRESHOLD})",
file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Running the validator against a palette that is safe for trichromats but weak under red-green deficiency prints the exact offending pairs and the dichromacy that broke them, then exits 1 — which is all a CI step needs to block the merge.
Performance Tuning and Cartographic Best Practices
- Simulate the composited color, not the source hex. If a class fills at partial opacity over a basemap, alpha-blend it to its effective sRGB value before simulation. Validating the raw palette while the map composites those fills over terrain is the single most common way a passing gate still ships an unreadable map.
- Treat the threshold as a tunable policy constant. A CAM02-UCS
deltaEnear 1.0 is a just-noticeable difference under laboratory viewing, but small adjacent map polygons seen through anti-aliasing need far more headroom. Start at 12 to 15 and raise it for low-DPI print or maps read at a distance; lock the chosen value in one place so every palette is judged identically. - Test mid-severity, not only full dichromacy. Anomalous trichromacy is more common than complete dichromacy. Run a second pass at
severity=50for each type to catch palettes that are fine at the extremes but ambiguous for the larger partial-deficiency population. - Cache the CAM02-UCS conversion per color. For an N-class palette the pairwise loop is O(N squared), but each color’s simulated and CAM02-UCS values are reused across every pair. Precompute them once into a dict; for typical palettes of 5 to 12 classes the whole validation then runs in single-digit milliseconds.
- Report the worst-case dichromacy, not a pass/fail bit. Emitting the specific pair and the CVD type that broke it turns the gate into an actionable design note. A designer who sees “C2 and C3 collapse under deuteranopia” can nudge one hue rather than reshuffle the entire ramp — guidance that ties back to the foundations in color theory for GIS.
Integration and Next Steps
The validator is a plain script with an exit code, so it drops into any pipeline without adaptation.
- Pre-commit / local. Register it as a
pre-commithook that reads the palette from your style config and blocks a commit that introduces a collapsing pair, giving designers feedback before CI ever runs. - GitHub Actions. Add a step that invokes
python validate_palette.py "$(python -c 'import json,sys; ...')"against each palette in the repo; a non-zero exit fails the job and annotates the pull request with the offending pairs. - Palette generation loop. Call
validate_palettedirectly from the code that builds ramps and reject candidates in-process, so a generator only ever emits CVD-safe palettes — pairing naturally with automated color palette generation for thematic maps. - Combined accessibility gate. Run this alongside the luminance-contrast test from WCAG contrast checking for map layers so one CI job certifies both that classes are distinguishable to color-blind readers and that labels stay legible against every fill.
Together these checks let the broader accessibility sync in cartography practice treat color accessibility as a build artifact rather than a manual review step.
Frequently Asked Questions
Why simulate with cvd_space instead of a fixed color-blind lookup table?
colorspacious cvd_space applies the Machado, Oliveira and Fernandes (2009) matrices, which are parameterised by a continuous severity from 0 to 100. This lets you model anomalous trichromacy — partial deficiency — as well as full dichromacy, so you can validate against the realistic mid-severity population rather than only the worst case. A fixed lookup table hard-codes one severity and cannot be tuned per audience or per output medium.
What deltaE threshold should fail the build?
In CAM02-UCS a deltaE near 1.0 is a just-noticeable difference under ideal viewing, but map classes are small, adjacent and usually seen through anti-aliasing, so a working floor of 12 to 15 keeps categories distinct in practice. Raise the threshold for choropleths viewed at a distance or printed at low DPI, and treat the value as a policy constant you tune against real rendered output rather than a universal number.
Should I simulate on the raw palette or the rendered map?
Simulate the exact fill colors that reach the reader, which includes any alpha compositing over the basemap. If a class is drawn at 70 percent opacity over a grey basemap, blend it to its effective sRGB value first, then simulate. Validating the source palette hex while the map composites those colors over terrain is the most common reason a passing validator still ships an unreadable map.
Related
- Accessibility Sync in Cartography — the parent overview covering how color, contrast and label accessibility checks are kept in sync across an automated cartographic pipeline.
- WCAG Contrast Checking for Map Layers — the companion luminance-contrast gate that pairs with this color-blindness validator in a single CI accessibility job.
- Color Theory for GIS — the perceptual-color foundations behind CAM02-UCS distance and hue separation that make these thresholds meaningful.