Color Palette Generation for Thematic Maps: A Programmatic Pipeline

Generate thematic map palettes algorithmically in CIELAB or CAM02-UCS space, validate WCAG contrast ratios programmatically, and export a machine-readable JSON manifest that any downstream renderer can consume — this eliminates manual hex selection, visual bias, and accessibility failures at scale.

Core Algorithm and Workflow

The palette generation problem has three distinct sub-problems: choosing a color space that models human perception accurately, sampling that space according to data type and class count, and validating the output against contrast standards before it ever reaches a renderer. Treating these as independent, composable steps makes the pipeline testable and reusable across batch workflows.

The complete workflow follows four deterministic steps:

  1. Data type classification. Identify whether your thematic variable is sequential (ordered numeric range such as population density or NDVI), diverging (deviation from a meaningful midpoint such as temperature anomaly or budget variance), or categorical (nominal classes such as land-cover type or administrative division). This determines palette architecture — monotonic lightness progression, dual sequential gradient with a neutral anchor, or maximized perceptual distance between unordered classes respectively.

  2. Colormap selection in a perceptually uniform space. Traditional RGB or HSV interpolation creates muddy midpoints and false gradients because those spaces are device-dependent and perceptually non-linear. Color Theory for GIS establishes why CIELAB and CAM02-UCS are preferred: equal numerical steps correspond to equal perceived differences. The colorcet library provides pre-computed colormaps validated in these spaces, removing the need for manual color space conversion in most workflows.

  3. Class-boundary sampling. Sample the chosen colormap at positions that mirror your statistical classification boundaries — not at uniform intervals unless you are using equal-interval classification. Misaligning color steps with data breaks (e.g., applying np.linspace(0,1,7) over a quantile-classified dataset) introduces perceptual distortion where visually similar colors represent different-sized data ranges. Generate breaks first with mapclassify or numpy.percentile, then derive sample positions from those breaks.

  4. WCAG contrast validation and manifest export. Apply the WCAG 2.2 relative luminance formula to each sampled color against the target background. Record results in a JSON manifest alongside the hex values, classification method, break values, and compliance status. Downstream renderers — QGIS via PyQGIS, ArcGIS Pro via arcpy.mp, or MapLibre GL — consume this manifest directly rather than accepting hard-coded hex values.

Color palette generation pipeline Four-stage flowchart: data type classification feeds into colormap selection in CIELAB/CAM02-UCS, then class-boundary sampling aligned to statistical breaks, then WCAG 2.2 contrast validation, producing a JSON manifest consumed by rendering engines. Data Type Sequential Diverging · Categorical Colormap Selection CIELAB / CAM02-UCS colorcet / matplotlib Class Sampling mapclassify breaks → sample positions WCAG Validation contrast ratio ≥ 3:1 → JSON manifest QGIS · ArcGIS Pro · MapLibre GL

Production-Ready Python Implementation

The following pipeline is copy-pasteable and handles all three palette types. It uses mapclassify to derive classification breaks from real data, samples colorcet colormaps at break-proportional positions, validates WCAG contrast ratios, and returns a structured manifest. Install dependencies with pip install colorcet mapclassify numpy matplotlib.

import numpy as np
import colorcet as cc
import mapclassify
import json
from matplotlib.colors import to_hex


# ── Helpers ──────────────────────────────────────────────────────────────────

def _relative_luminance(hex_color: str) -> float:
    """WCAG 2.2 relative luminance from a hex color string."""
    rgb = tuple(
        int(hex_color.lstrip("#")[i : i + 2], 16) / 255.0 for i in (0, 2, 4)
    )
    linear = [
        c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
        for c in rgb
    ]
    return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]


def _contrast_ratio(fg_hex: str, bg_hex: str) -> float:
    """WCAG 2.2 contrast ratio between two hex colors."""
    lum_fg = _relative_luminance(fg_hex)
    lum_bg = _relative_luminance(bg_hex)
    lighter, darker = max(lum_fg, lum_bg), min(lum_fg, lum_bg)
    return round((lighter + 0.05) / (darker + 0.05), 3)


# ── Colormap registry ────────────────────────────────────────────────────────

_CMAP_REGISTRY = {
    # colorcet perceptually uniform colormaps (all validated in CIELAB)
    "sequential": cc.cm.linear_grey_10_95_c0,
    "sequential_blue": cc.cm.linear_blue_5_95_c73,
    "sequential_heat": cc.cm.linear_kryw_0_100_c71,  # monotonic, safe for print
    "diverging": cc.cm.diverging_bwr_40_95_c42,
    "diverging_isoluminant": cc.cm.diverging_isoluminant_cjm_75_c23,
    "categorical": cc.cm.glasbey_category10,
    "categorical_dark": cc.cm.glasbey_dark,
}


# ── Core generator ───────────────────────────────────────────────────────────

def generate_thematic_palette(
    data_values: np.ndarray,
    data_type: str,
    classification: str = "quantiles",
    n_classes: int = 7,
    bg_hex: str = "#FFFFFF",
    cmap_key: str | None = None,
) -> dict:
    """
    Generate a perceptually uniform palette for a thematic map.

    Parameters
    ----------
    data_values : np.ndarray
        1-D array of the numeric attribute values being mapped.
    data_type : str
        One of 'sequential', 'diverging', or 'categorical'.
    classification : str
        mapclassify scheme: 'quantiles', 'natural_breaks', 'equal_interval',
        'standard_deviation', or 'fisher_jenks'.
    n_classes : int
        Number of color classes (3–9 recommended for choropleth maps).
    bg_hex : str
        Hex color of the map background for contrast ratio calculation.
    cmap_key : str | None
        Override the default colormap key from _CMAP_REGISTRY.

    Returns
    -------
    dict
        JSON-serializable manifest with hex palette, breaks, contrast ratios,
        and WCAG compliance flag.
    """
    if data_type not in ("sequential", "diverging", "categorical"):
        raise ValueError(
            "data_type must be 'sequential', 'diverging', or 'categorical'."
        )

    # ── 1. Derive classification breaks ──────────────────────────────────────
    if data_type == "categorical":
        # Categorical data: unique integer class IDs, no statistical breaks
        unique_ids = np.unique(data_values.astype(int))
        n_classes = min(n_classes, len(unique_ids))
        breaks = list(map(int, unique_ids[:n_classes]))
        sample_positions = np.linspace(0.0, 1.0, n_classes)
    else:
        scheme_map = {
            "quantiles": mapclassify.Quantiles,
            "natural_breaks": mapclassify.NaturalBreaks,
            "equal_interval": mapclassify.EqualInterval,
            "standard_deviation": mapclassify.StdMean,
            "fisher_jenks": mapclassify.FisherJenks,
        }
        if classification not in scheme_map:
            raise ValueError(f"Unknown classification scheme: {classification!r}")
        classifier = scheme_map[classification](data_values, k=n_classes)
        breaks = [float(b) for b in classifier.bins]
        # Map each break proportionally into [0, 1] for colormap sampling
        b_min, b_max = breaks[0], breaks[-1]
        sample_positions = np.array(
            [(b - b_min) / (b_max - b_min) if b_max > b_min else 0.5 for b in breaks]
        )

    # ── 2. Sample colormap at break-proportional positions ───────────────────
    resolved_key = cmap_key or data_type
    cmap = _CMAP_REGISTRY.get(resolved_key)
    if cmap is None:
        raise ValueError(f"No colormap registered for key: {resolved_key!r}")

    hex_colors = [to_hex(cmap(float(pos))) for pos in sample_positions]

    # ── 3. WCAG contrast validation ──────────────────────────────────────────
    contrast_ratios = [_contrast_ratio(c, bg_hex) for c in hex_colors]
    # WCAG 2.2 SC 1.4.11: graphical objects (polygon fills) need ≥ 3.0 : 1
    wcag_polygon_pass = all(r >= 3.0 for r in contrast_ratios)
    # Text overlays need ≥ 4.5 : 1 (AA) — flagged separately
    wcag_text_pass = all(r >= 4.5 for r in contrast_ratios)

    # ── 4. Assemble manifest ─────────────────────────────────────────────────
    return {
        "data_type": data_type,
        "classification": classification,
        "n_classes": n_classes,
        "background": bg_hex,
        "colormap": resolved_key,
        "breaks": breaks,
        "palette": hex_colors,
        "contrast_ratios": contrast_ratios,
        "wcag_polygon_pass": wcag_polygon_pass,   # SC 1.4.11, ≥ 3.0 : 1
        "wcag_text_pass": wcag_text_pass,          # SC 1.4.3, ≥ 4.5 : 1
    }


# ── Usage example ────────────────────────────────────────────────────────────

if __name__ == "__main__":
    rng = np.random.default_rng(42)
    # Simulate a realistic population density column (persons / km²)
    pop_density = rng.lognormal(mean=5.5, sigma=1.2, size=500)

    manifest = generate_thematic_palette(
        data_values=pop_density,
        data_type="sequential",
        classification="quantiles",
        n_classes=7,
        bg_hex="#F5F5F0",
    )
    print(json.dumps(manifest, indent=2))

The sample_positions array maps each statistical break proportionally into [0, 1] on the colormap rather than spacing colors at fixed uniform intervals. This means a quantile-classified dataset with unequal range widths still encodes the actual data distribution rather than imposing a false linear progression.

Performance Tuning and Cartographic Best Practices

  • Pin colorcet and mapclassify versions in your requirements file. Colormap definitions and classification algorithms both change between minor releases. A locked requirements.txt (colorcet==3.1.0, mapclassify==2.6.1) prevents silent output drift when your CI environment updates packages.

  • Pre-compute and cache the manifest; never regenerate it per tile. In a headless tile-generation pipeline, calling generate_thematic_palette once and persisting the JSON manifest to disk (or an object-store bucket) reduces palette computation from O(n tiles) to O(1). Tile renderers read the manifest at startup rather than recomputing it mid-run.

  • Generate separate print and digital manifests. CMYK conversion typically shifts perceptual lightness by 10–15% relative to sRGB. Produce a second manifest with a print-optimized colormap (e.g., linear_kryw_0_100_c71 verified against the Fogra39 ICC profile) and validate it separately. Refer to Scale Mapping for Web and Print for DPI and color space configuration in export pipelines.

  • Validate text label contrast separately from polygon fill contrast. The script flags wcag_polygon_pass (3:1) and wcag_text_pass (4.5:1) independently. In practice, dark-hued sequential classes near the high-value end will pass polygon contrast but fail text contrast — generate a second text-color array (white or black) per class and include it in the manifest as label_colors.

  • Use glasbey_dark for categorical palettes on light backgrounds. The default glasbey_category10 targets mid-range lightness. On light map backgrounds (#FFFFFF or #F5F5F0), switching to glasbey_dark improves polygon contrast without sacrificing the class separability that the glasbey algorithm guarantees. As established in Accessibility Sync in Cartography, embedding accessibility checks directly into the generation step is always preferable to post-processing remediation.

Integration and Next Steps

The JSON manifest produced by generate_thematic_palette is the handoff artifact between palette generation and every downstream renderer in your pipeline.

QGIS via PyQGIS: Deserialize the manifest and inject hex values into a QgsGraduatedSymbolRenderer using QgsRendererRange objects. Assign each class break from manifest["breaks"] directly so QGIS displays the same statistical boundaries that drove colormap sampling.

ArcGIS Pro via arcpy.mp: Apply the palette to a graduated colors renderer through the arcpy.mp.ArcGISProject API. Write the breaks and palette arrays into a .lyrx JSON template and load it with updateRenderer. The manifest schema maps cleanly to the classBreakValues and classBreakLabels arrays in the layer file format.

MapLibre GL / Mapbox GL: Convert the manifest into a step or interpolate expression. Sequential palettes translate to interpolate ["linear"] expressions with break-to-color stop pairs. Categorical palettes translate to match expressions keyed on unique class IDs.

CI integration: Commit the manifest JSON to version control alongside your data preprocessing scripts. Any dataset update triggers a generate_thematic_palette run in CI, and the diff in the manifest file makes color regressions visible in code review — a practice that aligns with the systematic style management described in Automated Cartographic Design Fundamentals.

Parent cluster: Color Theory for GIS — perceptual color space rules, classification methods, and automated color assignment for GIS workflows.

Sibling pages:

  • Accessibility Sync in Cartography — embed WCAG contrast checks across all map style parameters at generation time, not as a post-processing step.
  • Scale Mapping for Web and Print — configure DPI, ICC profiles, and CRS-aware scale calculations for the print and digital export pipelines that consume your palette manifest.