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 ; the label tolerance must sit just above it. The effective label tolerance is the noise floor scaled by a safety margin:

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
Label-region pixel-diff decision flow with the strict vector path shown for contrast A vertical decision flow for scoring a label region. Step one measures the anti-aliasing noise floor from eight repeat captures, yielding eta-max. Step two sets the label tolerance to eta-max times 1.25, while the contrast note shows the vector region uses a strict tolerance of 0.002. Step three is the per-pixel count gate with includeAA set to false: if the differing-pixel fraction exceeds the label tolerance the region fails, otherwise it continues; the vector path instead counts anti-aliased edges. Step four erodes residual single-pixel fringing with at most two three-by-three passes, whereas the vector path uses zero erosion passes. Step five is the SSIM structural gate: if the score drops below 0.975 the region fails, otherwise it passes; the vector path uses a stricter floor of 0.990. A passing region absorbs anti-aliasing noise, while either gate failing routes to a single failure verdict that represents a real regression such as a missing label, a coordinate shift, or a recolour. 1 · Measure noise floor 8× repeat capture, no code change → η_max vector path: same 8× method 2 · Set label tolerance τ_label = η_max · (1 + 0.25) vector path: strict τ = 0.002 GATE 3 · Per-pixel count gate diff fraction > τ_label ? · includeAA: false vector path: counts AA edge pixels too 4 · Erode residual fringe ≤ 2 × (3×3) erosion — drops 1–2px fringe vector path: 0 erosion passes GATE 5 · SSIM structural gate score < 0.975 ? · tolerates sub-pixel jitter vector path: strict floor = 0.990 Region PASSES anti-aliasing halo absorbed, not flagged Region FAILS real cartographic regression: missing label · coordinate shift · recoloured glyph pass pass fail fail

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.