Generating WCAG-Compliant Sequential Colormaps with Colorcet

Generating a WCAG-compliant sequential colormap means sampling N discrete class colors from one of colorcet’s perceptually uniform linear maps (the CET_L* family, built in CAM02-UCS), then auditing every sampled swatch’s WCAG contrast ratio against both your label text color and your basemap fill, and failing the build when any swatch drops below its required threshold (4.5:1 for text, 3:1 for graphical boundaries). This turns colormap selection from a subjective eyeball decision into a deterministic, testable gate that runs in CI alongside the rest of your map pipeline.

Core Algorithm and Workflow

A choropleth needs two properties from its color ramp that pull in different directions. Perceptual uniformity ensures that equal steps in the data produce equal steps in perceived color, so readers can rank classes correctly without a legend. Contrast compliance ensures the labels and boundaries drawn on top of those fills remain legible to low-vision users. Colorcet solves the first problem at the source: its linear maps are optimized in the CAM02-UCS color appearance space, where Euclidean distance approximates perceived difference, so sampling them evenly gives evenly-spaced classes for free. The audit solves the second. The workflow is four deterministic stages.

Sequential ramp with per-step WCAG contrast ratios and a pass/fail threshold Six evenly sampled swatches from a colorcet CET_L linear map are drawn left to right from dark to light. Each swatch is annotated with its measured WCAG contrast ratio against black label text. A dashed horizontal threshold line at 4.5:1 divides the chart; the two darkest swatches fall below the line and are marked FAIL, the four lighter swatches sit above it and are marked PASS. 4.5:1 AA text 2.1:1 s=0.00 FAIL 3.4:1 s=0.20 FAIL 5.0:1 s=0.40 PASS 7.2:1 s=0.60 PASS 10.6:1 s=0.80 PASS 15.3:1 s=1.00 PASS contrast ratio vs. black label text, per evenly-sampled swatch (s = position in CET_L map)
  1. Pick a perceptually uniform ramp. Choose a colorcet CET_L* linear map. These are constructed so lightness increases monotonically and perceived color difference tracks position, which is precisely the guarantee a sequential choropleth needs. The naming is systematic — CET_L01 through CET_L20 cover different hue paths (grey, fire, blue, kryo) while remaining perceptually linear.
  2. Sample N class colors evenly. A choropleth renders discrete classes, not a continuous gradient, so sample the colormap at N evenly spaced positions in [0, 1]. Because the underlying map is uniform in CAM02-UCS, evenly spaced samples are also evenly spaced in perception — no gamma correction of the sample positions required.
  3. Compute WCAG relative luminance. For each swatch, plus the label text color and the basemap fill, linearize the sRGB channels and take the weighted sum 0.2126·R + 0.7152·G + 0.0722·B. This is the luminance term the WCAG contrast formula consumes.
  4. Audit and gate. Compute (L_lighter + 0.05) / (L_darker + 0.05) for each swatch against text (threshold 4.5:1) and against the basemap boundary (threshold 3:1). Any swatch below its threshold fails the build.

This page implements the auditing side of the broader accessibility discipline covered in WCAG Contrast Checking for Map Layers, and the resulting swatch list feeds directly into the classification stage described under Data-Driven Classification.

Production-Ready Python Implementation

The script below samples a colorcet linear map, computes exact WCAG relative luminance and contrast ratios, and exits non-zero when any class swatch fails its threshold — the exit code is what lets you drop it straight into a CI step. It depends on colorcet==3.1.0 and matplotlib==3.9.2; the WCAG math follows the WCAG 2.1 relative luminance definition exactly.

import sys
import colorcet as cc
from matplotlib.colors import LinearSegmentedColormap


def relative_luminance(rgb: tuple[float, float, float]) -> float:
    """
    WCAG 2.1 relative luminance for an sRGB triple in [0, 1].

    Each channel is linearized (undoing the sRGB transfer function) before
    the luminance weights are applied.
    """
    def linearize(c: float) -> float:
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4

    r, g, b = (linearize(c) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contrast_ratio(rgb_a: tuple[float, float, float],
                   rgb_b: tuple[float, float, float]) -> float:
    """
    WCAG contrast ratio between two sRGB colors, always >= 1.0.

    ratio = (L_light + 0.05) / (L_dark + 0.05)
    """
    l1 = relative_luminance(rgb_a)
    l2 = relative_luminance(rgb_b)
    lighter, darker = max(l1, l2), min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)


def sample_class_colors(cmap: LinearSegmentedColormap, n: int,
                        lo: float = 0.0, hi: float = 1.0
                        ) -> list[tuple[float, float, float]]:
    """
    Sample N class colors evenly from a colormap over the range [lo, hi].

    Restricting [lo, hi] lets you trim the extreme ends of a linear map that
    would otherwise collide with the label or basemap color.
    """
    if n == 1:
        positions = [(lo + hi) / 2]
    else:
        step = (hi - lo) / (n - 1)
        positions = [lo + i * step for i in range(n)]
    return [cmap(p)[:3] for p in positions]  # drop alpha


def audit_colormap(
    cmap_name: str = "CET_L18",
    n_classes: int = 6,
    label_rgb: tuple[float, float, float] = (0.0, 0.0, 0.0),   # black text
    basemap_rgb: tuple[float, float, float] = (0.96, 0.96, 0.94),  # paper basemap
    text_threshold: float = 4.5,   # WCAG AA normal text
    graphic_threshold: float = 3.0,  # WCAG AA non-text (1.4.11)
    sample_lo: float = 0.0,
    sample_hi: float = 1.0,
) -> tuple[list[dict], bool]:
    """
    Sample a colorcet linear map and audit each swatch's WCAG contrast.

    Returns (rows, ok) where rows is one dict per swatch and ok is False if
    any swatch violates its threshold.

    Notes
    -----
    - `label_rgb` contrast uses the 4.5:1 text threshold: this is the ratio a
      value printed *inside* a polygon must clear.
    - `basemap_rgb` contrast uses the 3:1 graphics threshold: this governs the
      boundary between a fill and the surrounding map background.
    """
    cmap = getattr(cc, "m_" + cmap_name.lower(), None) or cc.cm[cmap_name]
    swatches = sample_class_colors(cmap, n_classes, sample_lo, sample_hi)

    rows: list[dict] = []
    ok = True
    for i, rgb in enumerate(swatches):
        text_cr = contrast_ratio(rgb, label_rgb)
        base_cr = contrast_ratio(rgb, basemap_rgb)
        text_pass = text_cr >= text_threshold
        base_pass = base_cr >= graphic_threshold
        ok = ok and text_pass and base_pass
        rows.append({
            "class": i,
            "hex": "#%02x%02x%02x" % tuple(round(c * 255) for c in rgb),
            "text_cr": round(text_cr, 2),
            "text_pass": text_pass,
            "basemap_cr": round(base_cr, 2),
            "basemap_pass": base_pass,
        })
    return rows, ok


def format_report(rows: list[dict]) -> str:
    header = f"{'cls':>3} {'hex':>8} {'text':>6} {'ok':>3} {'base':>6} {'ok':>3}"
    lines = [header, "-" * len(header)]
    for r in rows:
        lines.append(
            f"{r['class']:>3} {r['hex']:>8} "
            f"{r['text_cr']:>6} {'Y' if r['text_pass'] else 'N':>3} "
            f"{r['basemap_cr']:>6} {'Y' if r['basemap_pass'] else 'N':>3}"
        )
    return "\n".join(lines)


if __name__ == "__main__":
    # A 6-class choropleth on a warm-paper basemap with black value labels.
    # CET_L18 is a light-to-dark fire ramp; trimming the light end (hi=0.9)
    # keeps the palest swatch from colliding with black text at 4.5:1.
    rows, ok = audit_colormap(
        cmap_name="CET_L18",
        n_classes=6,
        label_rgb=(0.0, 0.0, 0.0),
        basemap_rgb=(0.96, 0.96, 0.94),
        sample_lo=0.15,
        sample_hi=0.90,
    )
    print(format_report(rows))
    if not ok:
        print("\nWCAG contrast audit FAILED: at least one swatch is non-compliant.")
        sys.exit(1)
    print("\nWCAG contrast audit passed.")
    sys.exit(0)

Wire the same entry point into a CI job so a non-compliant palette can never merge:

# .github/workflows/palette-audit.yml
name: palette-audit
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install colorcet==3.1.0 matplotlib==3.9.2
      - run: python audit_colormap.py  # non-zero exit fails the build

Performance Tuning and Cartographic Best Practices

  • Trim the sample range before you swap ramps. When a swatch fails, the cheapest fix is usually sample_lo/sample_hi. The extreme ends of a linear map are near-white and near-black, which are exactly the values that collide with black or white text. Sampling [0.15, 0.9] instead of [0, 1] sacrifices a little dynamic range for a palette that clears 4.5:1 across every class.

  • Drive label polarity from each swatch’s own luminance. Rather than forcing one text color across the whole ramp, compute relative_luminance(swatch) per class and choose black text when it exceeds roughly 0.18 and white below it. This per-swatch decision is the single most effective way to keep a wide-range sequential ramp compliant, and it composes naturally with the palette-building logic in Color Palette Generation for Thematic Maps.

  • Audit boundaries at 3:1, not 4.5:1. The fill-to-basemap edge and the fill-to-fill edge are graphical objects governed by WCAG 1.4.11, which requires only 3:1. Holding those to the text threshold needlessly rejects usable ramps. Keep the two thresholds separate in the audit, as the code above does.

  • Prefer fewer, well-separated classes. Perceptual uniformity guarantees even spacing, but human class discrimination degrades past about seven fills. Reducing n_classes widens the perceptual gap between adjacent swatches and simultaneously raises the minimum contrast of the darkest class against light text — a rare case where accessibility and legibility improve together.

  • Pin the colorcet version. The CET_L* maps are stable across releases, but pinning colorcet==3.1.0 guarantees the exact sampled hex values, which matters when the audit’s pass/fail boundary sits close to a threshold. An unpinned upgrade can silently flip a marginal swatch.

Integration and Next Steps

The audit returns plain dictionaries, so it drops into whatever styling layer you already run. Register the sampled swatches as a matplotlib ListedColormap for static choropleth export, or emit them as the stops array of a Mapbox GL / MapLibre step expression for web delivery. Because the audit is pure Python with an exit code, it belongs in the same gate that runs your classification and projection checks — a palette that fails contrast should block the build exactly as a broken projection would.

For choropleths specifically, run the audit against the same class breaks your classifier produces so the swatch count matches the data, a pairing covered in Choosing Jenks vs. Quantile Classification for Choropleths. When your thematic data is bivariate or has a meaningful midpoint, a sequential ramp is the wrong tool — reach instead for the two-ended construction in Diverging Colormap Selection for Bivariate Thematic Maps, which applies the same CAM02-UCS and WCAG auditing to a divergent palette.


Frequently Asked Questions

Why sample colorcet maps instead of matplotlib’s viridis for choropleths?

viridis is perceptually uniform and a perfectly good default, but colorcet ships a much larger family of CAM02-UCS-optimized linear maps — CET_L01 through CET_L20 plus isoluminant and fixed-hue variants — so you can pick a ramp whose lightness range actually clears your WCAG threshold against a specific label color. Isoluminant maps, for example, deliberately hold lightness constant, which is exactly wrong for a choropleth that must contrast with black text; having the whole set named makes that trade-off explicit rather than hidden inside one blessed default.

Which WCAG threshold applies to a choropleth swatch: 4.5:1 or 3:1?

It depends on what sits on the swatch. A value printed inside a polygon is text and must meet 4.5:1 for AA (3:1 for large text at 18 pt, or 14 pt bold). The boundary between two adjacent fills, or a fill against the basemap, is a graphical object and only needs 3:1 under WCAG 1.4.11. Audit both relationships separately — the code above computes text_cr and basemap_cr independently — because a swatch can pass against the basemap while failing against its label.

How do I fix a swatch that fails the contrast audit?

Three levers, in order of preference. First, restrict the sampled range (sample_lo/sample_hi) so you skip the near-white or near-black extremes that collide with your text color. Second, switch label polarity per swatch — white text over the dark end, dark text over the light end — driven by each swatch’s own luminance. Third, choose a different CET_L map whose lightness profile clears the threshold across its whole range. Reducing the class count also helps, because it widens the perceptual gap between adjacent swatches and lifts the darkest class away from the failure boundary.


Back to Color Theory for GIS