Configuring pixel diff thresholds for anti-aliased map labels
Your map snapshot test is red, but nothing is actually broken: the only difference between baseline and candidate is a faint halo of one- and two-pixel alpha deltas tracing the edges of every street name and place label. That is anti-aliasing noise — fractional-alpha glyph edges that shift when font hinting, sub-pixel placement, or GPU texture filtering changes by the smallest amount between runs. This page walks through configuring a label-specific pixel-diff threshold (plus an SSIM gate and a morphological filter) that absorbs that halo while still failing on missing text, shifted coordinates, or a corrupted style expression.
This task is one concrete application of Dynamic Threshold Configuration, the parent reference that explains why a single global tolerance is the wrong tool for slippy maps. It assumes the deterministic capture machinery from Screenshot Capture, Sync & Comparison Logic is already in place — this guide changes only how the diff stage scores label regions, not how frames are captured.
Prerequisites
Step-by-step procedure
1. Isolate the label layer into its own region class
A global threshold cannot serve both crisp vector edges and fuzzy glyph edges at once. Render the label layers into a separate region so the comparator scores them under their own tolerance. With MapLibre/Mapbox, query the rendered text features and convert their screen-space bounding boxes into a mask the diff engine can consume — the same region-class contract the parent cluster defines.
const labelRegions = await page.evaluate(() => {
const map = window.__MAP__;
const textLayers = map.getStyle().layers
.filter((l) => l.layout && l.layout["text-field"])
.map((l) => l.id);
return map.queryRenderedFeatures({ layers: textLayers }).map((f) => {
const b = f.geometry; // collision box in screen px from the placement engine
return { layer: "labels", bbox: f._vertices ?? b };
});
});
2. Establish the noise floor from repeat captures
Never guess a tolerance — measure it. Capture the same locked viewport several times with no code change and compute the maximum per-region delta across the set. That value is your anti-aliasing noise floor
const deltas = [];
for (let i = 0; i < 8; i++) {
await gotoAndStabilize(page); // your capture-sync routine
const shot = await page.screenshot({ clip });
if (i > 0) deltas.push(regionDelta(prev, shot, labelRegions));
prev = shot;
}
const noiseFloor = Math.max(...deltas); // fraction of pixels that differ
const labelTolerance = noiseFloor * 1.25;
3. Set the per-pixel tolerance and enable anti-alias detection
Configure the comparator with the measured labelTolerance and turn on anti-alias detection so the engine skips pixels it recognises as edge-blending rather than counting them as differences. Keep the per-pixel threshold (the YIQ colour-distance cutoff) tight; widen the count budget, not the colour sensitivity, so a genuinely wrong glyph colour still trips.
import pixelmatch from "pixelmatch";
const diffCount = pixelmatch(baseline.data, candidate.data, diff.data, w, h, {
threshold: 0.1, // YIQ per-pixel colour distance — keep tight
includeAA: false, // skip detected anti-aliased edge pixels
alpha: 0.1,
});
const labelFail = diffCount / (w * h) > labelTolerance;
4. Add an SSIM gate so structure, not noise, decides failures
Per-pixel counting still over-reacts to dense label clusters. Add the Structural Similarity Index as an independent gate that tolerates sub-pixel jitter while catching structural breaks such as a dropped label. A region fails only when it breaches its gate — so a label region must miss the relaxed SSIM floor, not the strict vector one.
import { ssim } from "image-ssim";
const score = ssim(
{ data: baseline.data, width: w, height: h, channels: 4 },
{ data: candidate.data, width: w, height: h, channels: 4 }
).ssim;
const ssimFloor = 0.975; // relaxed floor for label regions
const pass = !labelFail && score >= ssimFloor;
The deeper trade-off between pixel-count and structural metrics is worked through in Diff Algorithm Tuning for Cartography.
5. Erode the diff mask to drop single-pixel fringing
Even with anti-alias detection, isolated one-pixel deltas survive along glyph boundaries. A 3×3 morphological erosion applied once or twice clears that fringe while preserving any solid blob big enough to be a real regression (missing word, shifted feature, recoloured fill).
function erode(mask, w, h) { // mask: Uint8Array, 1 = differs
const out = new Uint8Array(mask.length);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x;
out[i] = mask[i] && mask[i - 1] && mask[i + 1] &&
mask[i - w] && mask[i + w] ? 1 : 0;
}
}
return out;
}
6. Encode the tolerance in a version-controlled profile
Hard-coded numbers rot. Store the label tolerance, SSIM floor, and erosion passes in a YAML profile keyed by the runner environment, mirroring the profile contract from the parent cluster and the metadata stored by Baseline Management for Tile Servers. CI selects the profile from an environment variable so a software-GL runner and a hardware-GPU runner enforce their own calibrated label budgets.
profiles:
HEADLESS_CHROMIUM_SWIFTSHADER:
labels:
pixel_tolerance: 0.012 # ~1.2% of label-region pixels may differ
ssim_floor: 0.975
erosion_passes: 2
vector:
pixel_tolerance: 0.002
ssim_floor: 0.990
erosion_passes: 0
Verification
Confirm the configuration behaves before trusting it in CI:
A correctly tuned profile yields a green stability run and a red injected-regression run; if both are green, the gate is blind, and if both are red, the noise floor was mis-measured.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Label edges still fail every run | Per-pixel count budget too low, or anti-alias detection off | Raise pixel_tolerance to noiseFloor × 1.25 and set includeAA: false; do not loosen the colour threshold |
| Hidden/missing label slips through as a pass | Tolerance widened globally instead of per-region; erosion too aggressive | Scope the budget to the labels region only and cap erosion at 2 passes so word-sized blobs survive |
| Green locally, flaky in CI | Host GPU renders glyphs differently from the runner | Pin a software GL backend (swiftshader) and preload fonts so both environments rasterize identically; see Noise Reduction for Map Artifacts |
Frequently asked questions
Why not just raise one global pixel threshold until the labels stop failing?
Because that same widened budget then applies to road geometry, boundaries, and raster alignment, blinding the strict gates that catch real regressions. Keep the label tolerance in its own region class and leave vector regions tight — the whole point of Dynamic Threshold Configuration is that tolerance is spatial, not a single number.
Do I still need SSIM if pixelmatch already has anti-alias detection?
Yes. Anti-alias detection skips individual edge pixels but says nothing about whether the overall structure survived. SSIM is the independent perceptual gate that tolerates sub-pixel jitter across a dense label cluster while still failing when a whole label disappears. Run them as two gates and require both to pass.
How many erosion passes are safe before I start hiding regressions?
One or two 3×3 passes remove single- and two-pixel fringing, which is exactly the width of anti-aliased glyph edges. Three or more passes start eating word-sized differences, so cap erosion at two and let the SSIM gate, not the morphology, catch larger structural breaks.
My label positions shift by one pixel between runs — is that anti-aliasing or a real bug?
If a glyph or sprite arrives after the idle event it forces a post-capture label relayout, which looks like noise but is a capture-sync defect. Preload fonts and sprites before map init and gate capture on full hydration, as covered in Handling Async Tile Loading, before blaming the threshold.
Related
- Up to the parent reference: Dynamic Threshold Configuration, and the grandparent pipeline Screenshot Capture, Sync & Comparison Logic.
- Reducing false positives from WebGL rendering artifacts
- How to wait for all map tiles to load before screenshot
- Comparing pixel diff vs structural diff for GIS overlays