Setting up baseline image versioning for web maps

You upgraded maplibre-gl by a patch release, or merged a one-line style.json tweak, and suddenly every visual test in the suite reports a diff. None of them are bugs — the renderer simply placed a label halo two sub-pixels to the left. The opposite failure is just as common: a genuine regression hides because the baseline filename was overwritten on the last main build and there is no record of what the map should have looked like before the change. Both problems are symptoms of treating map screenshots as disposable files rather than versioned artifacts. This page gives a concrete procedure for versioning web map baselines as content-addressed objects keyed to the exact inputs that produced them, so an intentional style change resolves to a new baseline instead of flagging a false regression, and every historical render stays auditable.

This is one task inside Baseline Management for Tile Servers, the guide that covers capturing, storing, and comparing tile-server output. It sits under the broader discipline described in Web Map Visual Testing Fundamentals & Toolchains, and it assumes you have already pinned the camera per Viewport & Zoom Sync Strategies — a baseline is only reproducible if the view that produced it is.

Prerequisites

Why filenames and counters fail

Baseline versioning cannot rely on timestamped filenames, sequential counters, or branch-based naming. These schemes encode when or where a baseline was captured, not what produced it — so they cannot tell a legitimate style update apart from a defect, and they silently overwrite history on every merge. Content addressing inverts the model: the identity of a baseline is derived from a hash of its inputs, so the same inputs always resolve to the same key and any change to the inputs resolves to a new one. A pull request that edits the style or bumps the renderer produces a new hash, a missing artifact, and an explicit approval prompt — never a phantom regression against a stale image.

A counter names when a baseline was taken; a hash names what it depicts Two naming schemes are traced through the same three events. Under a counter, a style change produces version two, a viewport change produces version three, and a revert of the style change produces version four — the file that is byte-identical to version two now has a name that says otherwise, and nothing in the name says which inputs produced it. Under a content hash of the input vector, the same three events produce hash A, hash B, and then hash A again: the revert lands back on the artifact that already exists, the store deduplicates, and the name states exactly which inputs the image belongs to. A revert should land on the artifact that already exists counter input hash v1 v2 · style edit v3 · viewport v4 · revert a41f9c 7b02e1 c93d55 7b02e1 (exists) byte-identical to v2, named as if new deduplicated, nothing to re-bless the counter also cannot answer “which style and viewport is v3?” without a second lookup

Step-by-step procedure

1. Define the deterministic input vector and hash the baseline

Enumerate every input that can change a pixel and fold them into a single content-addressed key. For a slippy map that set is the style definition, the viewport geometry and device pixel ratio, the camera pose, and the rendering engine build:

baseline-{sha256(style.json)}-{width}x{height}@{dpr}-{center.lat}_{center.lng}-z{zoom}-{engine_version}.png

Each component pins one source of variance:

  • sha256(style.json) hashes the complete MapLibre/Mapbox GL style specification — layer ordering, paint properties, and source references included. Any edit to the style yields a new key.
  • {width}x{height}@{dpr} locks viewport dimensions and device pixel ratio. A map rendered at @2x produces a fundamentally different raster than at @1x because of sub-pixel rendering and font hinting.
  • {center.lat}_{center.lng}-z{zoom} captures the exact geographic frame. Fractional coordinate drift as small as 0.000001 shifts tile boundaries and feature placement.
  • {engine_version} pins the mapping library (maplibre-gl@5.6.0, leaflet@1.9.4). Engine upgrades routinely change text placement, line joins, and raster blending.
import { createHash } from 'node:crypto';

function baselineKey({ style, width, height, dpr, center, zoom, engine }) {
  const styleHash = createHash('sha256')
    .update(JSON.stringify(style))
    .digest('hex')
    .slice(0, 12);
  return `baseline-${styleHash}-${width}x${height}@${dpr}` +
    `-${center.lat}_${center.lng}-z${zoom}-${engine}.png`;
}

When a PR changes the style or the engine, the pipeline resolves to a key that does not yet exist in the registry, and the diff is reframed as “approve a new baseline” rather than “investigate a regression.”

Resolving a content-addressed baseline key from its input vector Four deterministic inputs — the style.json, the viewport width by height at device pixel ratio, the center latitude, longitude and zoom, and the engine version — are folded into a single sha256 hash, which forms a content-addressed baseline filename. The pipeline asks whether that key already exists in the registry: if yes, the candidate is pixel-compared against the stored baseline; if no, a fresh image is captured and held for QA approval as a new baseline rather than flagged as a regression. Deterministic input vector style.json layers · paint · sources viewport W×H @ DPR camera pose lat/lng · zoom engine version maplibre-gl@5.6.0 sha256 hash canonicalize → digest Content-addressed key baseline-{hash}-WxH@dpr-…-z{zoom}.png Exists in registry? Pixel compare vs stored baseline Capture + hold await QA approval yes no

2. Lock the headless browser and WebGL rendering context

A content-addressed key is only meaningful if identical inputs produce identical pixels. Neutralize hardware-dependent rasterization by launching Chromium with explicit flags that force CPU rendering:

chromium \
  --headless=new \
  --disable-gpu-compositing \
  --force-device-scale-factor=1.0 \
  --no-sandbox \
  --disable-software-rasterizer \
  --use-gl=swiftshader

Forcing SwiftShader moves rasterization onto the CPU, eliminating the driver-specific floating-point discrepancies that otherwise differ between CI runners, developer laptops, and cloud VMs. Then configure the WebGL context to suppress non-deterministic smoothing when you construct the map:

const map = new maplibregl.Map({
  container: 'map',
  style: 'style.json',
  center: [-122.4194, 37.7749],
  zoom: 12,
  antialias: false,
  preserveDrawingBuffer: true,
  failIfMajorPerformanceCaveat: false
});

antialias: false removes MSAA, which is notoriously variable across GPU architectures, and preserveDrawingBuffer: true keeps the framebuffer intact after the render pass so the runner can read back the exact pixels.

3. Intercept tile requests and serve deterministic fixtures

Live tile endpoints introduce latency jitter, partial loads, and cache-busting differences that invalidate any byte-stable comparison. Mock every external tile request and fulfil it from pre-baked fixtures so each capture renders against an identical tile set:

await page.route('**/*.png', async (route) => {
  const tilePath = route.request().url().split('/').slice(-3).join('/');
  await route.fulfill({
    path: `./fixtures/tiles/${tilePath}`,
    status: 200,
    headers: { 'Cache-Control': 'no-store' }
  });
});

Route the vector and WebP patterns (**/*.pbf, **/*.webp) the same way. Fixture-backed capture is the deterministic-tile-capture foundation that the rest of Baseline Management for Tile Servers builds on — without it, the hash in step 1 keys an image the renderer cannot reliably reproduce.

4. Store the baseline as an immutable, annotated artifact

Pair every baseline image with a manifest recording the inputs that generated it. The manifest is what makes a diff auditable months later and what lets CI decide whether a key is expected to exist:

{
  "baseline_id": "baseline-a1b2c3d4e5f6-1920x1080@1.0-37.7749_-122.4194-z12-5.6.0.png",
  "style_hash": "a1b2c3d4e5f6",
  "viewport": { "width": 1920, "height": 1080, "dpr": 1.0 },
  "center": { "lat": 37.7749, "lng": -122.4194 },
  "zoom": 12,
  "engine": "maplibre-gl@5.6.0",
  "generated_at": "2026-05-14T10:32:00Z",
  "ci_run_id": "gh-actions-8842",
  "approved_by": "qa-lead@example.com"
}

Write the PNG to object storage with a lifecycle policy: archive approved baselines after 90 days and delete unapproved artifacts after 30. Reserve Git LFS for small, curated sets — a tile-pyramid project blows past repository limits quickly. Keep feature branches in isolated baseline namespaces so experimental styling never overwrites the canonical store; only a merge to main promotes an approved diff into the versioned set.

What travels alongside a baseline image, and which question each field answers A stored baseline is shown as an image plus a sidecar of annotations, with the question each annotation exists to answer. The input hash answers which inputs produced it. The engine and version answer which renderer drew it. The approving user and timestamp answer who accepted it and when. The commit answers what change it was blessed for. The capture duration and retry count answer whether the run that produced it was healthy. A note explains the practical test for a good annotation set: an engineer looking at a failing diff a year later should not need to open a CI log to know what they are comparing against. The sidecar is what makes a baseline reviewable a year later baseline.png immutable content-addressed never overwritten, only superseded inputHash which inputs produced it? engine + version which renderer drew it? approvedBy + at who accepted it, and when? commit blessed for what change? captureMs + retries was the producing run healthy? the test: reading a failing diff a year on should not require opening a CI log

5. Tune the diff so cartographic variance is not a regression

Standard image comparators — pixel-perfect XOR, SSIM, perceptual hashing — misfire on map output without calibration, because high-frequency line work and anti-aliased labels produce benign sub-pixel variation even under the locked conditions above. Apply three corrections before the comparator gates the build:

  1. Region-of-interest masking excludes dynamic chrome — attribution text, compass, scale bar — from the comparison.
  2. Color-space normalization converts every capture to sRGB with explicit gamma so ICC-profile mismatches do not register as colour diffs.
  3. Adaptive tolerance applies a strict bound to vector line work and a looser one to raster hillshade or satellite imagery.

A region passes when its per-pixel difference stays under a content-aware tolerance — roughly for crisp line work versus for imagery. The full treatment of these metrics lives in Diff Algorithm Tuning for Cartography; for the per-region mechanics, see Dynamic Threshold Configuration.

6. Gate the comparison in CI/CD

Wire the resolution logic into the pipeline as a blocking step. On each pull request the runner computes the expected key, checks the registry, captures a new image if the key is missing, and otherwise runs the calibrated diff:

- name: Resolve and compare map baseline
  run: |
    KEY=$(node scripts/baseline-key.js)
    if aws s3 ls "s3://map-baselines/$KEY"; then
      node scripts/capture.js --out current.png
      node scripts/diff.js --baseline "s3://map-baselines/$KEY" --candidate current.png
    else
      echo "::notice::New baseline $KEY — capturing and flagging for QA approval"
      node scripts/capture.js --out "new/$KEY"
      aws s3 cp "new/$KEY" "s3://map-baselines/pending/$KEY"
    fi

On merge to main, run a reconciliation step that promotes approved pending/ artifacts into the canonical namespace, updates the manifest, and invalidates stale CDN caches so downstream consumers pick up the new visual contract.

Verification

Confirm the versioning scheme behaves before you trust it as a gate:

A correct setup gives a stable key, a clean stability run, and a new baseline prompt — never a silent overwrite — the moment an input legitimately changes.

Troubleshooting

Symptom Likely cause Fix
Every PR flags a regression even with no style change The hash includes a volatile field (timestamp, run id, absolute fixture path) so the key drifts each run Hash only reproducible inputs — the canonicalized style.json, viewport, camera, and engine version — as in step 1
Baseline passes locally, diffs only in CI Host GPU composites differently from the runner, so the captured pixels differ for an identical key Force the swiftshader backend and antialias: false per step 2 so rasterization is CPU-identical everywhere
A real regression slipped through unnoticed A main build overwrote the previous baseline at a reused filename, erasing the reference Switch to content-addressed keys plus an immutable, namespaced store (steps 1 and 4) so history is never overwritten

Frequently asked questions

Should the baseline key hash the rendered image or its inputs?

Hash the inputs, not the output. Hashing the rendered PNG makes the key change on every benign sub-pixel variation, which defeats the purpose. Hashing the canonicalized style.json, viewport, camera, and engine version means the same intent always resolves to the same key, and only a deliberate input change produces a new one.

What belongs inside sha256(style.json)?

Serialize the style deterministically first — stable key ordering, no whitespace noise — then hash the whole specification: layers, paint and layout properties, source URLs, sprite and glyph references. Anything that can alter a pixel must be inside the hash; anything cosmetic (comments, formatting) must be normalized out before hashing so it does not churn the key.

How do I migrate existing timestamped baselines to content-addressed keys?

Run a one-off backfill: for each legacy image whose generating inputs you still know, recompute the key from step 1, write the image under the new key, and emit a manifest. Where the inputs are unknown, mark the image for re-capture rather than guessing — an unverifiable baseline is worse than a missing one because it gates merges against an unknown reference.

Should feature branches share the canonical baseline store?

No. Keep feature branches in an isolated namespace (for example pending/<branch>/) so experimental styling cannot overwrite the production reference. Only a merge to main promotes an approved artifact into the canonical set, which prevents cross-contamination between in-progress and shipped map states.