Noise Reduction for Map Artifacts
A slippy map produces a class of pixel-level variation that has nothing to do with the application under test: anti-aliasing jitter along label glyphs, hairline seams where adjacent tiles meet, sub-pixel halos around vector strokes, and GPU compositing differences that shift colour values by one or two levels between otherwise identical runs. These map artifacts are benign — the cartography is correct — yet a naive comparator counts every differing pixel and fails the build. Noise reduction is the discipline of removing this transient, non-semantic variation before the diff stage decides what counts as a regression, so that the only failures surfaced are genuine cartographic defects: a dropped layer, a mislabelled feature, a broken symbol, a shifted basemap. The hard constraint is asymmetric — filter too little and CI stays flaky, filter too much and real regressions slide through under cover of the noise floor.
This page extends the capture and diff internals documented in Screenshot Capture, Sync & Comparison Logic; it assumes you already have deterministic capture in place and focuses purely on the filtering stage that sits between a stabilised screenshot and the comparator that gates your pipeline.
What Counts as Map Artifact Noise
Map artifact noise is any pixel difference between two renders of the same map state that does not correspond to a change in the underlying data, style, or geometry. It falls into four recurring categories, and distinguishing them matters because each demands a different countermeasure:
- Anti-aliasing jitter. Sub-pixel positioning of vector strokes and label glyphs distributes alpha across edge pixels differently on each render. This is high-frequency, low-amplitude noise concentrated on edges — the dominant source of false positives in label-heavy basemaps.
- Tile stitching seams. When raster or vector tiles composite, the boundary row of pixels between adjacent tiles can differ by a level or two depending on fetch order and resampling. These appear as faint horizontal or vertical lines on a fixed pixel grid.
- GPU compositing variance. Floating-point rasterisation of gradients, hillshades, and extruded 3D features is not bit-identical across drivers, GPU generations, or a software fallback such as SwiftShader. The difference is spatially diffuse and channel-correlated.
- Premultiplied-alpha and colour-space drift. Reading back a WebGL framebuffer with the wrong
premultipliedAlphaassumption, or composing over a non-deterministic background, shifts whole regions by a constant offset.
The semantic signal you must preserve — what a regression actually looks like — is the opposite of all four: it is structural (a feature changes shape or position by whole pixels), localised to meaningful geometry, and persistent across repeated runs. Effective noise reduction exploits exactly this contrast. Anti-aliasing noise is high-frequency and run-to-run unstable; a real regression is lower-frequency and reproducible. The whole strategy reduces to attenuating the former without touching the latter, which is why a perceptual comparator such as the Structural Similarity Index pairs so well with a light spatial pre-filter — the underlying metric is the same one tuned in Diff Algorithm Tuning for Cartography.
A useful framing is that the captured frame is a clean cartographic signal plus an additive noise field:
Pre-diff filtering aims to suppress the
Architecture: Where Filtering Lives in the Pipeline
Noise reduction is not a single function; it is a layered defence applied at three distinct stages, ordered from cheapest and most reliable to most lossy. Push as much suppression as possible upstream, because every artifact you prevent at capture time is one you never have to filter — and filtering is inherently lossy.
Stage 1 — Prevention at capture. The highest-leverage noise reduction happens before a pixel is ever read back, by removing the source of non-determinism. This is the work covered across the parent pillar: gating on the renderer’s idle event, normalising device pixel ratio, draining the tile queue via Handling Async Tile Loading, and locking the camera through Viewport & Zoom Sync Strategies. A frame captured deterministically carries far less noise to begin with.
Stage 2 — Pre-diff image filtering. What survives capture is attenuated by transforming both the baseline and the candidate identically before comparison — a light Gaussian blur to dissolve anti-aliasing edges, optional morphological erosion to close one-pixel seams, and colour quantisation to absorb single-level GPU drift. The cardinal rule: apply the exact same filter to both images. Filtering only the candidate introduces a systematic bias that the comparator will read as a regression.
Stage 3 — Comparator tolerance. Whatever noise remains after filtering is handled by the comparison metric and its thresholds, owned by Dynamic Threshold Configuration. This is the last line of defence and the easiest to abuse — inflating a global tolerance to silence noise also blinds the comparator to real defects, so it should absorb only the residual the first two stages could not.
Keep these stages as separate, individually configurable units. The filter parameters belong in a version-controlled config alongside the tolerance profiles, not scattered through test bodies, so that a change to the noise floor is a reviewable diff rather than an invisible drift. Masking of genuinely dynamic UI — attribution widgets, live coordinate readouts — is a separate concern handled by the Dynamic Element Masking & UI Stability discipline and should not be conflated with statistical noise suppression; masking removes regions you choose to ignore, filtering attenuates noise across regions you still test.
Step-by-Step Implementation
The following procedure hardens a Playwright + pixelmatch/sharp stack against map artifact noise. Adapt the engine names freely; the stages are what matter.
-
Capture against a verified idle signal. Resolve a render-ready promise only after the map fires its terminal render event, then settle the DOM. This removes most temporal noise before any filtering runs.
async function waitForMapReady(page) { await page.evaluate(() => new Promise((resolve) => { const map = window.__map; // MapLibre GL / Mapbox GL instance const done = () => { if (map.loaded() && !map.isMoving()) resolve(); }; map.once('idle', done); })); await page.evaluate(() => document.fonts.ready); await page.waitForLoadState('networkidle'); } -
Pin the rendering context. Normalise device pixel ratio and force deterministic WebGL attributes so the framebuffer you read back matches what was composited. Residual GPU artifacts that survive this are handled in the dedicated guide on reducing false positives from WebGL rendering artifacts.
const context = await browser.newContext({ deviceScaleFactor: 1, // collapse DPR variance viewport: { width: 1280, height: 720 }, forcedColors: 'none', }); // launch flags for headless Chromium // --use-gl=angle --use-angle=swiftshader --disable-gpu-rasterization -
Capture baseline and candidate through the same path. Both images must originate from an identical capture function — same viewport, same idle gate, same colour profile — so that only application changes can differ.
-
Apply identical pre-diff filtering to both images. A small Gaussian blur dissolves anti-aliasing edges; the kernel radius is the single most important noise-reduction knob.
const sharp = require('sharp'); async function denoise(buf) { return sharp(buf) .blur(0.6) // sigma ~0.6 px: kills AA jitter, keeps structure .median(1) // morphological median: closes 1px tile seams .toColourspace('srgb') .raw().toBuffer(); } const baseFiltered = await denoise(baselinePng); const candFiltered = await denoise(candidatePng); -
Compare with a perceptual-aware metric and a structural gate. Run
pixelmatchwith anti-aliasing detection enabled, or compute SSIM per region, so sub-pixel shifts are tolerated while whole-pixel structural breaks fail.const pixelmatch = require('pixelmatch'); const diffPixels = pixelmatch(baseFiltered, candFiltered, diffOut, w, h, { threshold: 0.08, // per-pixel colour delta tolerance includeAA: false, // ignore detected anti-aliased pixels alpha: 0.3, }); const failed = diffPixels > w * h * 0.0015; // structural change budget -
Emit a diagnostic diff artifact on failure. Persist the filtered baseline, filtered candidate, and the highlighted diff so a reviewer can confirm a flagged change is structural before the build is rejected.
The pre-diff Gaussian blur applies a separable kernel whose weights follow
Choose
Cross-Browser and Cross-Environment Considerations
Map artifact noise is fundamentally an environment-portability problem, so the filtering strategy has to account for where renders diverge:
- GPU floating-point variance. Chromium on a hardware GPU, WebKit on Apple silicon, and a SwiftShader CPU fallback rasterise the same gradient, hillshade, or extruded building differently — often by one to two channel levels across a wide region. The most deterministic fix is to pin one rendering backend (commonly ANGLE/SwiftShader) across every runner so this noise term vanishes rather than being filtered.
- Anti-aliasing toggles. WebGL
antialias: trueproduces driver-dependent edge sampling. Settingantialias: falseand letting the map style own its own line/label smoothing yields far more reproducible edges, shrinkingbefore any blur is applied. - OS font stacks and hinting. ClearType, GDI, and CoreText hint glyphs differently, so label regions carry the most anti-aliasing noise. Preload the style’s web fonts before map initialisation to eliminate glyph-substitution flicker — a class of noise no blur can safely absorb because it is structural, not high-frequency.
- Colour management. Browsers may apply display colour profiles that shift readback values. Force a fixed colour space (
srgb) at both capture and filter time so a runner’s monitor profile cannot leak into the diff. - Containerisation. Run capture and filtering inside the same pinned container image — identical GPU drivers, font packages, and headless flags — across local and CI. A baseline authored in one environment and enforced in another is the single largest avoidable source of “noise” that is really environmental drift, and it interacts directly with baseline management for tile servers.
Threshold & Parameter Reference
Starting points for MapLibre GL / Mapbox GL workloads. Calibrate the blur radius and structural budget against your own false-positive history before tightening.
| Parameter | Vector geometry | Raster basemap / hillshade | Anti-aliased labels | Notes |
|---|---|---|---|---|
| Gaussian blur sigma (px) | 0.4–0.6 | 0.5–0.8 | 0.6–0.9 | Span the AA transition band; >1.0 dissolves thin lines |
| Median / erosion radius (px) | 0 | 1 | 0–1 | Closes tile seams; skip for crisp vector edges |
| Per-pixel colour tolerance | 0.05–0.08 | 0.10–0.15 | 0.08–0.12 | Fraction of channel range counted as a difference |
| SSIM floor | 0.99 | 0.985 | 0.975 | Region fails below this perceptual score |
| Structural change budget | 0.05–0.15% | 0.2–0.5% | 0.1–0.3% | Share of pixels allowed to differ after filtering |
includeAA (anti-alias detection) |
false | false | false | Let the comparator ignore detected AA pixels |
| Device scale factor | 1 | 1 | 1 | Collapse DPR variance before filtering |
| Idle stabilisation window | 300–500 ms | 300–500 ms | 400–600 ms | Quiet period after last draw call |
| Flaky-quarantine trigger | >2% FP rate | >2% FP rate | >2% FP rate | Auto-quarantine for manual review above this |
Common Pitfalls
- Filtering only the candidate image. Applying blur or quantisation to one side and comparing against an unfiltered baseline injects a systematic, full-frame difference. Fix: run the identical filter function on both images and store the filtered baseline as the reference.
- Over-blurring away real geometry. A large blur radius silences anti-aliasing noise but also dissolves 1 px road casings, hairline borders, and small symbols, hiding genuine regressions. Fix: keep
at the minimum that clears the noise floor (≈0.4–0.8 px) and rely on the structural budget for the rest. - Treating GPU variance with filtering instead of pinning. Diffuse, channel-correlated GPU noise resists blurring because it is not high-frequency. Fix: pin one rendering backend across runners (SwiftShader/ANGLE) so the variance is eliminated at source rather than smeared.
- Capturing before quiescence. A baseline taken from a half-composited frame bakes transient noise into the reference, so every subsequent run oscillates. Fix: gate every capture on a verified
idlesignal plus a minimum stabilisation window before filtering. - Conflating masking with denoising. Using a near-infinite tolerance or a giant blur to silence an inherently dynamic widget (attribution, live cursor coordinates) leaves a blind spot that drifts. Fix: exclude genuinely dynamic regions through the Dynamic Element Masking & UI Stability layer and keep filtering for statistical noise only.
Frequently Asked Questions
Should I reduce noise by filtering images or by raising the diff threshold?
Prefer filtering, but use both in their proper place. A light, symmetric pre-diff blur attenuates anti-aliasing noise everywhere without losing structural sensitivity, whereas a raised global threshold blinds the comparator uniformly — including over the geometry where regressions hide. Filter first to remove the noise you can characterise, then let the comparator’s tolerance absorb only the small residual.
What blur radius should I use without hiding real regressions?
Start at a Gaussian sigma of about 0.6 px and adjust within 0.4–0.9 px. That range spans the anti-aliasing transition band of typical labels and strokes while leaving whole-pixel structural features intact. Above roughly 1 px the blur begins to dissolve thin 1 px lines, converting false positives into false negatives — verify on a known-regression fixture before going higher.
Why do identical map states still differ pixel-for-pixel between CI runs?
Almost always GPU floating-point variance, a device-pixel-ratio mismatch, an anti-aliasing toggle, or a colour-profile difference between environments. These are environmental, not application, changes. Pin a single rendering backend, force deviceScaleFactor: 1 and a fixed srgb colour space, and run capture inside one container image so the noise term disappears instead of needing to be filtered away.
How do I keep tile stitching seams from failing the diff?
Seams are one-pixel-wide differences on the tile grid boundary. A morphological median or erosion with a 1 px radius closes them, and draining the tile queue so every tile is present and parsed before capture prevents partial-composite seams entirely. If seams persist, confirm tiles are served from a deterministic local replay rather than a live endpoint with variable resampling.
Related
- Up to the parent reference: Screenshot Capture, Sync & Comparison Logic
- Deep dive on this topic: Reducing false positives from WebGL rendering artifacts
- Sibling: Dynamic Threshold Configuration
- Sibling: Handling Async Tile Loading
- Sibling: Viewport & Zoom Sync Strategies
- Cross-topic: Diff Algorithm Tuning for Cartography