WCAG Contrast Checking for Map Layers

Automated WCAG contrast validation for map layers works by parsing layer colour definitions, compositing transparent fills against their background, computing WCAG relative luminance ratios, and failing the build when any label or graphical element drops below the AA threshold — all before a tile is rendered or a PDF is exported.

WCAG contrast validation pipeline for map layers A four-stage horizontal pipeline diagram showing: 1) Parse Styles (extract fill, stroke, opacity from GL JSON or QML), 2) Composite Layers (Porter-Duff source-over alpha blending), 3) Compute Ratios (sRGB linearisation and WCAG luminance formula), 4) Gate Output (block CI on failure or emit JSON audit report). 1 2 3 4 Parse Styles fill / stroke / opacity from GL JSON or QML Composite Layers Porter-Duff source-over alpha blending Compute Ratios sRGB linearise → WCAG luminance formula Gate Output block CI or emit JSON audit report

Core Algorithm and Workflow

The difficulty in cartographic contrast validation is not the luminance formula itself — that is standardised — but correctly defining the background colour L2 for every element. A static #FFFFFF canvas is trivial. Terrain rasters, satellite basemaps, and multi-layer composites create dynamic, non-uniform backgrounds that require per-element resolution before the ratio can be computed.

The W3C Understanding Contrast (Minimum) specification defines relative luminance by first linearising each sRGB channel:

  • If c = channel / 255.0 and c <= 0.04045: linear value = c / 12.92
  • Otherwise: linear value = ((c + 0.055) / 1.055) ** 2.4

Then relative luminance L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin, and the contrast ratio is (L_lighter + 0.05) / (L_darker + 0.05).

WCAG classifies map elements across three threshold bands:

Element type AA threshold AAA threshold
Standard text (labels, annotations) 4.5:1 7:1
Large text (≥18pt or ≥14pt bold) 3:1 4.5:1
Graphical objects (boundaries, hydrology, icons, polygons) 3:1

The classification step is the most consequential engineering decision in the pipeline. A road label is text; its casing line is a graphical object. A choropleth polygon is a graphical object; its data-value annotation is text. Misclassifying these changes whether 4.5:1 or 3:1 applies, and therefore whether a given colour pair passes at all.

The full validation sequence for a production pipeline is:

  1. Parse style definitions. Extract fill-color, line-color, text-color, fill-opacity, and line-opacity from Mapbox/MapLibre GL JSON, QGIS .qml files, or CartoCSS. Also capture the background-color or the raster tile identity that forms L2.
  2. Resolve alpha compositing. For any layer with opacity < 1.0 or a colour that includes an alpha channel, apply the Porter-Duff source-over operator: composed = fg * alpha + bg * (1 - alpha). This produces the effective rendered colour.
  3. Calculate luminance and ratio. Linearise both the composed foreground and the background, compute L1 and L2, then derive the WCAG ratio.
  4. Route by element type. Apply 4.5:1 for text layers, 3:1 for graphical objects. Record the layer ID, rule expression, geographic coordinates if available, and the failing hex values.
  5. Fail-fast or report. In CI, exit non-zero on any AA violation. For batch audits, emit a structured JSON report. This step is the enforcement point in Accessibility Sync in Cartography, where contrast runs alongside label collision detection and colour palette harmonisation.

Production-Ready Python Implementation

The following module handles solid-colour symbology, alpha compositing, and data-driven choropleth ramp sampling. It requires no external dependencies and runs in QGIS Python console, headless CI runners, or any Python 3.9+ environment.

"""wcag_map_contrast.py — WCAG 2.2 contrast validation for cartographic layers.

Handles solid colours, alpha compositing, and data-driven colour ramps.
No external dependencies; requires Python 3.9+.
"""
from __future__ import annotations


# ---------------------------------------------------------------------------
# Colour utilities
# ---------------------------------------------------------------------------

def _srgb_to_linear(channel: int) -> float:
    """Convert an 8-bit sRGB channel (0–255) to a linear 0–1 value.

    Uses the 0.04045 threshold from WCAG 2.1/2.2 and IEC 61966-2-1.
    """
    c = channel / 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    """Parse a CSS hex colour string to an (R, G, B) integer tuple.

    Accepts 3-digit (#abc) and 6-digit (#aabbcc) forms; leading '#' optional.
    """
    clean = hex_color.lstrip("#")
    if len(clean) == 3:
        clean = "".join(c * 2 for c in clean)
    return (int(clean[0:2], 16), int(clean[2:4], 16), int(clean[4:6], 16))


def relative_luminance(hex_color: str) -> float:
    """Return the WCAG relative luminance (0–1) for a solid hex colour."""
    r, g, b = hex_to_rgb(hex_color)
    return (
        0.2126 * _srgb_to_linear(r)
        + 0.7152 * _srgb_to_linear(g)
        + 0.0722 * _srgb_to_linear(b)
    )


def blend_alpha(fg_hex: str, bg_hex: str, alpha: float) -> str:
    """Composite a foreground colour over a background using Porter-Duff source-over.

    Args:
        fg_hex:  Foreground layer colour (e.g. fill-color from the style doc).
        bg_hex:  Background colour (canvas, raster tile representative, or
                 composited value of layers below).
        alpha:   Layer opacity in [0.0, 1.0].

    Returns:
        Effective rendered colour as a lowercase hex string (no alpha channel).
    """
    fg_r, fg_g, fg_b = hex_to_rgb(fg_hex)
    bg_r, bg_g, bg_b = hex_to_rgb(bg_hex)
    r = round(fg_r * alpha + bg_r * (1 - alpha))
    g = round(fg_g * alpha + bg_g * (1 - alpha))
    b = round(fg_b * alpha + bg_b * (1 - alpha))
    return f"#{r:02x}{g:02x}{b:02x}"


def wcag_contrast_ratio(fg_hex: str, bg_hex: str, alpha: float = 1.0) -> float:
    """Return the WCAG contrast ratio between a foreground and background colour.

    Automatically composites the foreground against the background when
    alpha < 1.0 before computing luminance, matching browser and GIS renderer
    behaviour.

    Args:
        fg_hex:  Foreground colour hex.
        bg_hex:  Background colour hex.
        alpha:   Foreground layer opacity (default 1.0 — fully opaque).

    Returns:
        Contrast ratio as a float (e.g. 4.73 for a pair meeting AA text).
    """
    effective_fg = blend_alpha(fg_hex, bg_hex, alpha) if alpha < 1.0 else fg_hex
    l1 = relative_luminance(effective_fg)
    l2 = relative_luminance(bg_hex)
    lighter, darker = max(l1, l2), min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)


# ---------------------------------------------------------------------------
# WCAG threshold routing
# ---------------------------------------------------------------------------

# Thresholds as per WCAG 2.2 SC 1.4.3 and SC 1.4.11
_TEXT_AA = 4.5
_LARGE_TEXT_AA = 3.0
_GRAPHICAL_AA = 3.0


def check_layer(
    layer_id: str,
    fg_hex: str,
    bg_hex: str,
    element_type: str,  # "text" | "large_text" | "graphical"
    alpha: float = 1.0,
) -> dict:
    """Validate a single layer colour pair against the correct WCAG AA threshold.

    Args:
        layer_id:      Identifier from the style document (e.g. 'road-label').
        fg_hex:        Foreground colour of the element.
        bg_hex:        Composited background colour at the element's position.
        element_type:  One of 'text', 'large_text', or 'graphical'.
        alpha:         Layer opacity in [0.0, 1.0].

    Returns:
        Dict with keys: layer_id, ratio, threshold, passes, effective_fg.
    """
    threshold_map = {
        "text": _TEXT_AA,
        "large_text": _LARGE_TEXT_AA,
        "graphical": _GRAPHICAL_AA,
    }
    if element_type not in threshold_map:
        raise ValueError(f"element_type must be one of {list(threshold_map)}")

    threshold = threshold_map[element_type]
    ratio = wcag_contrast_ratio(fg_hex, bg_hex, alpha)
    effective_fg = blend_alpha(fg_hex, bg_hex, alpha) if alpha < 1.0 else fg_hex
    return {
        "layer_id": layer_id,
        "ratio": round(ratio, 2),
        "threshold": threshold,
        "passes": ratio >= threshold,
        "effective_fg": effective_fg,
        "bg": bg_hex,
    }


# ---------------------------------------------------------------------------
# Data-driven ramp validation
# ---------------------------------------------------------------------------

def check_choropleth_ramp(
    layer_id: str,
    ramp_stops: list[str],
    bg_hex: str,
    alpha: float = 1.0,
) -> dict:
    """Validate a choropleth colour ramp by sampling worst-case contrast.

    WCAG requires the full domain of a data-driven style to be compliant.
    This function checks every stop and returns the minimum ratio found.

    Args:
        layer_id:    Style layer identifier.
        ramp_stops:  List of hex colours from the minimum to maximum data value.
        bg_hex:      Representative background colour (canvas or base tile).
        alpha:       Layer opacity.

    Returns:
        Dict with worst_ratio, worst_stop, passes (3:1 graphical threshold).
    """
    results = []
    for stop_hex in ramp_stops:
        ratio = wcag_contrast_ratio(stop_hex, bg_hex, alpha)
        results.append((ratio, stop_hex))

    worst_ratio, worst_stop = min(results, key=lambda x: x[0])
    return {
        "layer_id": layer_id,
        "worst_ratio": round(worst_ratio, 2),
        "worst_stop": worst_stop,
        "bg": bg_hex,
        "passes": worst_ratio >= _GRAPHICAL_AA,
    }


# ---------------------------------------------------------------------------
# Usage examples
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import json

    # 1. Opaque text label: dark navy on near-white card
    label_result = check_layer(
        layer_id="place-city-label",
        fg_hex="#1a2e44",
        bg_hex="#f5f4f0",
        element_type="text",
    )
    print("City label:", json.dumps(label_result, indent=2))

    # 2. Semi-transparent polygon fill over a terrain-tan background
    poly_result = check_layer(
        layer_id="admin-fill",
        fg_hex="#e74c3c",
        bg_hex="#d4c5a9",
        element_type="graphical",
        alpha=0.70,
    )
    print("Admin fill:", json.dumps(poly_result, indent=2))

    # 3. Sequential blue ramp for a choropleth of population density
    ramp_blues = ["#deebf7", "#9ecae1", "#3182bd", "#08519c", "#08306b"]
    ramp_result = check_choropleth_ramp(
        layer_id="population-choropleth",
        ramp_stops=ramp_blues,
        bg_hex="#f5f4f0",
    )
    print("Choropleth ramp:", json.dumps(ramp_result, indent=2))

Performance Tuning and Cartographic Best Practices

  • Cache luminance by hex value. A map style typically reuses a palette of 20–80 distinct colours across thousands of feature rules. Memoising relative_luminance with a dict or functools.lru_cache(maxsize=256) eliminates repeated linearisation and cuts validation time to near-zero on large style documents.

  • Vectorise ramp sampling with NumPy. For choropleth layers generated from continuous data (e.g. a 256-stop ramp from a colorcet palette), convert the stop list to a NumPy array of RGB triples and compute all luminances in a single matrix operation rather than a Python loop. This reduces a 256-stop ramp check from ~1 ms to ~50 µs.

  • Sample the worst-case background dynamically. Satellite basemaps vary in brightness by hundreds of luminance units across a single viewport. Rather than picking a single representative tile, extract the lightest and darkest 1% of pixel values from the visible tile set and check the fill colour against both extremes. The darker background produces the lowest contrast for light fills; the lighter background for dark fills.

  • Classify element types at parse time, not per-check. Traversing a MapLibre GL style to classify every paint property as text, large-text, or graphical is expensive if done redundantly. Build a classification index — a dict mapping layer_id to element type — once during style ingestion, then look up cheaply during the per-feature loop.

  • Apply colour-theory constraints when adjusting failing pairs. When the pipeline must auto-correct a failing colour, shifting lightness in CIELAB space (rather than raw RGB) respects perceptual uniformity. This prevents the adjusted colour from appearing visually jarring even when the ratio numerically passes. The principles described in Color Theory for GIS apply directly: adjust L* first, then ensure hue shift stays within the same perceptual category (warm/cool/neutral).

Integration and Next Steps

QGIS Python console. Iterate QgsProject.instance().mapLayers().values(), extract QgsSymbolLayer colour properties, then call check_layer or check_choropleth_ramp for each. Write violations to a QgsMessageLog entry or a CSV side-car. Package the module as a QGIS Processing algorithm to expose it in the graphical modeller.

MapLibre GL / Mapbox GL styles. Parse the JSON style document recursively. For each layer, resolve paint["fill-color"], paint["line-color"], and paint["text-color"], along with their corresponding *-opacity values. The background luminance for a given layer is the background-color paint property or, for raster base layers, a sampled representative tile value. Feed resolved pairs to check_layer keyed by layer["id"].

CI/CD gating. Embed the module in a GitHub Actions step or GitLab CI job that runs immediately after style linting (e.g. after maplibre-gl-style-spec validate). Exit with code 1 if any check_layer result has "passes": false. Emit the full results list as a JSON artefact so engineers can trace violations directly back to the style property without re-running locally. This positions contrast validation as a deterministic gate within the broader Automated Cartographic Design Fundamentals pipeline, running alongside scale-dependent rendering and typographic checks rather than as a post-publication manual audit.

Vector tile generators. For tippecanoe or tileserver-gl pipelines, run contrast validation against the source GeoJSON style before tile generation, not after. Catching failures before the tiling step avoids regenerating tile pyramids — an operation that can take hours for large datasets at high zoom levels.


Back to Accessibility Sync in Cartography

  • Accessibility Sync in Cartography — the parent workflow that embeds contrast validation alongside label collision detection, projection-aware legibility scaling, and motion preference handling.
  • Color Theory for GIS — perceptually uniform palette construction and CIELAB colour adjustment strategies that feed into WCAG-compliant ramp design.
  • Color Palette Generation for Thematic Maps — how to generate sequential and diverging ramps that satisfy both cartographic convention and WCAG 3:1 contrast at every stop.