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.

Where noise reduction lives: three layered defence stages from capture to comparator A top-to-bottom flow of four pipeline boxes feeding a compare decision. Stage 1 (prevention at capture) groups deterministic capture on the idle event with fonts ready, viewport stabilization with locked center, zoom and device pixel ratio, and network determinism with mocked tiles and a drained queue. Stage 2 (pre-diff filtering) applies an identical Gaussian blur plus morphology to baseline and candidate. Stage 3 (comparator tolerance) is a compare decision on SSIM and pHash: within tolerance the run passes; a structural change flags a regression. Left-hand brackets label the three stages, ordered cheapest and most reliable at the top to most lossy at the bottom. Stage 1 Prevention at capture Stage 2 Pre-diff filtering Stage 3 Comparator tolerance within tolerance structural change Deterministic capture idle event · fonts ready Viewport stabilization locked center · zoom · DPR Network determinism mocked tiles · queue drained Pre-diff filtering same Gaussian blur + morphology, both images Compare SSIM · pHash Pass ✓ Flag regression

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 premultipliedAlpha assumption, 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 terms — which are high-frequency or constant-offset — while leaving , which carries the regression, structurally intact.

Four categories of map artifact noise, placed by where they can actually be removed A two-by-two arrangement. The vertical axis is how early the noise can be removed, from at capture at the top to only at comparison at the bottom. The horizontal axis is how localised it is, from scattered on the left to structured on the right. Rasterizer dither sits top-left: scattered and removable at capture by pinning the GL backend. Tile seam and edge fringing sits top-right: structured and removable at capture by pinning device pixel ratio and tile size. Glyph hinting jitter sits bottom-left: scattered and only removable at comparison, with anti-alias detection. Sprite and icon atlas shifts sit bottom-right: structured and only removable at comparison, by masking the region. The caption states the operating rule: anything reachable in the top row must never be handled by loosening a threshold. Where each kind of noise can actually be removed REMOVABLE AT CAPTURE ONLY AT COMPARISON rasterizer dither scattered single pixels from the GL path fix: pin ANGLE + SwiftShader tile seams & edge fringing thin structured lines on tile boundaries fix: pin DPR and tile size glyph hinting jitter subpixel drift along label edges fix: anti-alias detection + erosion sprite atlas shifts icons re-packed at a different offset fix: mask the region, pin the sprite hash scattered ← how localised the noise is → structured

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 section: 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.

Filter order: the same operations applied to both images, in a fixed sequence Two identical processing chains run in parallel, one on the baseline and one on the candidate, and only then meet at the comparator. Each chain applies the same four steps in the same order: strip metadata, normalise the alpha channel, apply a Gaussian blur with sigma of one, then a morphological erosion of one pixel. A brace shows both chains must be byte-identical in configuration. The comparator at the right receives the two filtered images. A warning beneath states that applying a filter to only one side, or in a different order, manufactures a difference that no threshold can distinguish from a regression. Both images, same filters, same order — or the diff is meaningless baseline candidate strip metadata normalise alpha blur σ = 1.0 erode 1 px strip metadata normalise alpha blur σ = 1.0 erode 1 px SSIM + px filtering is lossy: every step here also erases small real regressions, so each one must earn its place a filter applied to one side only manufactures a difference no threshold can explain

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.

  1. 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');
    }
    
  2. 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
    
  3. 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.

  4. 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);
    
  5. Compare with a perceptual-aware metric and a structural gate. Run pixelmatch with 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
    
  6. 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 just large enough to span the anti-aliasing transition band (typically 0.4–0.8 px) and no larger: beyond ~1 px the blur begins to dissolve genuine thin-line geometry such as 1 px road casings, trading false positives for false negatives.

Measuring Whether a Filter Was Worth Adding

Every filter in the chain is a hypothesis: that it removes more noise than signal. That hypothesis is testable, and the test costs one afternoon.

Assemble two labelled corpora from artifacts the suite has already produced. The first holds pairs known to be noise — repeat captures of identical code, ideally taken across the range of runners and browser versions in use. The second holds pairs known to be genuine regressions, harvested from previously reviewed failures with the reviewer’s verdict attached. Score both corpora with the filter chain enabled and again with the candidate filter removed.

A filter earns its place when it moves the noise corpus’s scores down substantially while leaving the regression corpus’s scores essentially unchanged. A filter that lowers both — which is what a blur with an over-large sigma does — is buying a quieter suite by making it blind, and the corpora make that trade visible as a number rather than leaving it to intuition. Keep both corpora in version control beside the profile; they are the only artifacts that let a future engineer re-evaluate a filter someone added years earlier for reasons nobody wrote down.

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: true produces driver-dependent edge sampling. Setting antialias: false and letting the map style own its own line/label smoothing yields far more reproducible edges, shrinking before 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.

Noise is a property of the pair, not of the image

A useful reframing when tuning: there is no such thing as a noisy capture, only a noisy pair. The same PNG that produces a clean diff against a baseline captured on the same runner produces a fringed one against a baseline captured on a different GL backend. This is why noise measurements taken on a developer laptop rarely transfer to CI, and why a filter tuned against locally generated pairs frequently under-performs once it meets real cross-runner variation.

The practical consequence is that the noise corpus must be produced the way the suite actually runs: across the full set of runner classes, browser versions and backends in use, over enough runs to include the unlucky ones. A corpus of twenty pairs from a single machine measures that machine. A corpus of two hundred pairs spread across the real fleet measures the thing the threshold has to survive.

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 idle signal 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.