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.
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@2xproduces a fundamentally different raster than at@1xbecause of sub-pixel rendering and font hinting.{center.lat}_{center.lng}-z{zoom}captures the exact geographic frame. Fractional coordinate drift as small as0.000001shifts 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.”
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.
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:
- Region-of-interest masking excludes dynamic chrome — attribution text, compass, scale bar — from the comparison.
- Color-space normalization converts every capture to sRGB with explicit gamma so ICC-profile mismatches do not register as colour diffs.
- 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
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.
Related
- Up to the parent cluster Baseline Management for Tile Servers, and the grandparent reference Web Map Visual Testing Fundamentals & Toolchains.
- How to wait for all map tiles to load before screenshot
- Diff Algorithm Tuning for Cartography