Why Is My Land Cover Change Map Full of Speckle?

On this page

Problem statement

Subtract two land cover maps and the "change" map looks like salt and pepper: thousands of single pixels and tiny clusters scattered everywhere, few of them believable. Most come from the maps disagreeing about pixels on class edges, or about classes that are hard to tell apart, not from anything happening on the ground. Two maps made with different algorithm versions make it worse. Before measuring change, you need to know how much of it is speckle and decide on a minimum mapping unit (MMU), the smallest patch you are prepared to call real.

This page differences ESA WorldCover 2020 (v100) and 2021 (v200) for Oslo, measures the patches the differences form, and shows what minimum mapping units of different sizes keep.

Quick answer

Find changed pixels, group them into connected patches, and look at the size distribution before trusting any total:

changed = (Y2020 != Y2021) & INSIDE
labels, n = ndimage.label(changed, structure=np.ones((3, 3)))
sizes = np.bincount(labels.ravel())[1:]
print(f"{changed.sum() / INSIDE.sum():.1%} of Oslo's pixels differ, in {n:,} patches; median patch {np.median(sizes):.0f} pixels")
print(f"patches of 1–4 pixels: {(sizes <= 4).mean():.0%} of patches, {sizes[sizes <= 4].sum() / sizes.sum():.0%} of changed pixels")
7.6% of Oslo's pixels differ, in 56,776 patches; median patch 4 pixels
patches of 1–4 pixels: 54% of patches, 8% of changed pixels
Two views of central Oslo, about 3.7 by 4.6 kilometres, with WorldCover 2021 classes faded and differences from 2020 in magenta: on the left 12.5 percent of the view differs, as fine speckle and slivers along every edge between trees and buildings; on the right, after removing patches under 25 pixels, 5.7 percent remains as larger blobs. Two views of central Oslo, about 3.7 by 4.6 kilometres, with WorldCover 2021 classes faded and differences from 2020 in magenta: on the left 12.5 percent of the view differs, as fine speckle and slivers along every edge between trees and buildings; on the right, after removing patches under 25 pixels, 5.7 percent remains as larger blobs.
Differences between two map versions, before and after a minimum mapping unit.

Patches not pixels, what changed, a minimum mapping unit

1. Look at patches, not pixels

Speckle dominates the number of patches but not necessarily the area. The cumulative share of changed pixels by patch size shows where the change sits:

px_m2 = 43.2                                              # one 1/12,000° pixel at Oslo's latitude
for k in (1, 4, 10, 25, 100, 1000):
    print(f"patches up to {k:>4} pixels (~{k * px_m2 / 1e4:>5.2f} ha): {(sizes <= k).mean():5.1%} of patches, {sizes[sizes <= k].sum() / sizes.sum():5.1%} of changed pixels")
patches up to    1 pixels (~ 0.00 ha): 17.1% of patches,  1.2% of changed pixels
patches up to    4 pixels (~ 0.02 ha): 53.9% of patches,  8.5% of changed pixels
patches up to   10 pixels (~ 0.04 ha): 75.1% of patches, 18.9% of changed pixels
patches up to   25 pixels (~ 0.11 ha): 89.0% of patches, 35.0% of changed pixels
patches up to  100 pixels (~ 0.43 ha): 98.0% of patches, 65.7% of changed pixels
patches up to 1000 pixels (~ 4.32 ha): 100.0% of patches, 94.3% of changed pixels

2. Look at what changed

Rank the transitions. Pairs of classes that are easy to confuse, or edge classes such as trees in gardens, are the first suspects:

pairs = pd.Series(list(zip(Y2020[changed], Y2021[changed]))).value_counts(normalize=True).head(6) * 100
print(pairs.rename(lambda p: f"{WC[p[0]]} → {WC[p[1]]}").round(1).to_string())
Tree cover → Built-up       43.2
Tree cover → Grassland      14.9
Grassland → Tree cover       6.6
Grassland → Cropland         5.8
Cropland → Grassland         5.2
Bare/sparse → Tree cover     3.8

Tree cover to built-up makes up 43% of the differences, which would mean a thirtieth of Oslo built over in a single year. It is far more likely the newer algorithm drawing built-up land differently through the city's tree-lined suburbs. The next three pairs are classic confusions: grassland with trees and with cropland.

Cumulative curves against patch size on a log scale for 56,776 patches of difference: 54 percent of patches are 4 pixels or smaller but hold under 10 percent of the changed pixels; 89 percent of patches are 25 pixels or smaller and hold 35 percent; patches up to 100 pixels hold two thirds of the changed area. Cumulative curves against patch size on a log scale for 56,776 patches of difference: 54 percent of patches are 4 pixels or smaller but hold under 10 percent of the changed pixels; 89 percent of patches are 25 pixels or smaller and hold 35 percent; patches up to 100 pixels hold two thirds of the changed area.
Most patches are tiny; most of the changed area is not.

3. Apply a minimum mapping unit

rasterio.features.sieve removes patches below a size, replacing them with their surroundings. Apply it to the change mask and see what survives:

for mmu in (4, 25, 100):
    kept = features.sieve(changed.astype(np.uint8), size=mmu, connectivity=8) > 0
    print(f"MMU {mmu:>3} pixels (~{mmu * px_m2 / 1e4:.2f} ha): changed share {kept.sum() / INSIDE.sum():.2%}, "
          f"{ndimage.label(kept, structure=np.ones((3, 3)))[1]:,} patches remain")
MMU   4 pixels (~0.02 ha): changed share 7.17%, 30,896 patches remain
MMU  25 pixels (~0.11 ha): changed share 5.06%, 6,526 patches remain
MMU 100 pixels (~0.43 ha): changed share 2.68%, 1,164 patches remain

A minimum mapping unit of about a tenth of a hectare removes nine in ten patches but only a third of the changed area; at 0.4 ha the changed share falls from 7.6% to 2.7%. What remains is not proven change either, as the largest patches show.

Where the large change patches are

The data used on this page

ESA WorldCover 2020 (v100) and 2021 (v200), 10 m, tiles N57E009 and N60E009 from the public esa-worldcover S3 bucket, read as one window over the municipality of Oslo (boundary from geoBoundaries Norway ADM2, Geonorge, CC BY 4.0). Both versions share the same grid.

from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import requests
from rasterio import features
from rasterio.merge import merge
from scipy import ndimage

Path("data").mkdir(exist_ok=True)
BUCKET = "https://esa-worldcover.s3.eu-central-1.amazonaws.com"
F = Path("data/nor_adm2.geojson")
if not F.exists():
    F.write_bytes(requests.get("https://github.com/wmgeolab/geoBoundaries/raw/9469f09/releaseData/gbOpen/NOR/ADM2/geoBoundaries-NOR-ADM2.geojson", timeout=300).content)
adm2 = gpd.read_file(F)
OSLO = adm2[adm2.shapeName.str.startswith("Oslo")]
tiles = ("N57E009", "N60E009")
m20, T = merge([rasterio.open(f"/vsicurl/{BUCKET}/v100/2020/map/ESA_WorldCover_10m_2020_v100_{t}_Map.tif") for t in tiles], bounds=tuple(OSLO.total_bounds))
m21, T21 = merge([rasterio.open(f"/vsicurl/{BUCKET}/v200/2021/map/ESA_WorldCover_10m_2021_v200_{t}_Map.tif") for t in tiles], bounds=tuple(OSLO.total_bounds))
Y2020, Y2021 = m20[0], m21[0]
INSIDE = features.geometry_mask(OSLO.geometry, Y2021.shape, T, invert=True) & (Y2020 > 0) & (Y2021 > 0)
WC = {10: "Tree cover", 20: "Shrubland", 30: "Grassland", 40: "Cropland", 50: "Built-up", 60: "Bare/sparse", 70: "Snow/ice",
      80: "Water", 90: "Herbaceous wetland", 95: "Mangroves", 100: "Moss/lichen"}
print(f"same grid: {T == T21}; {INSIDE.sum():,} pixels inside Oslo")
same grid: True; 10,448,698 pixels inside Oslo

Example 1: where the big patches are

big = np.argsort(sizes)[::-1][:3] + 1
for k in big:
    rows, cols = np.nonzero(labels == k)
    lon, lat = T * (cols.mean(), rows.mean())
    tr = pd.Series(list(zip(Y2020[rows, cols], Y2021[rows, cols]))).value_counts().index[0]
    print(f"patch of {sizes[k - 1]:,} pixels at {lat:.4f} N, {lon:.4f} E: mostly {WC[tr[0]]} → {WC[tr[1]]}")
patch of 7,336 pixels at 59.8947 N, 10.7774 E: mostly Grassland → Cropland
patch of 5,674 pixels at 60.0660 N, 10.5860 E: mostly Grassland → Water
patch of 3,305 pixels at 59.9652 N, 10.6307 E: mostly Grassland → Cropland

Map errors show up as change

Every land cover map has errors, and they concentrate on class edges and between similar classes. When two maps are differenced, each map's errors appear as change, so the change map shows the union of both maps' errors along with any real change. The errors are mostly small and scattered because they follow edges and mixed pixels; real change, such as a new housing estate or a felled stand, usually forms compact patches. That difference in shape is what a minimum mapping unit exploits.

Share of Oslo flagged as changed between WorldCover 2020 and 2021 under increasing minimum mapping units. how much "change" survives a minimum mapping unitevery differing pixel7.6% of OslosieveMMU 4 pixels (0.02 ha)7.17%sieveMMU 25 pixels (0.11 ha)5.06%sieveMMU 100 pixels (0.43 ha)2.68%Even the largest surviving patches are grass and crop confusions spatialworkflow.io
How much change survives each minimum mapping unit.

Here the two maps were also made with different versions of the algorithm, so their differences mix method and change; the comparison is a useful worst case. Product series designed for change, such as NLCD or Annual NLCD, detect change first and relabel only the changed pixels, which suppresses much of this. Whatever the source, report change with its minimum mapping unit, and look at the largest patches by eye before trusting a total.

Connectivity, sieving and majority filters

Land cover change speckle: problems and fixes. speckle problemsSymptomFixsalt-and-pepper changelabel patches; set a minimummapping unita building boom in one yeardifferent versions: method, notchangegrass ↔ crop swapssimilar classes: group or verifyMMU means different areasset it in hectares, convert perlatitudeWorldCover 2020 v100 vs 2021 v200, Oslo spatialworkflow.io
Speckle problems and fixes.
  • Connectivity: 8-connectivity joins diagonal neighbours into one patch; 4-connectivity splits them. State which.
  • Pixel area varies: in EPSG:4326 an MMU in pixels is a different area at each latitude; set it in hectares and convert.
  • Sieve the maps or the change? Sieving each map before differencing removes isolated pixels from both; sieving the change mask removes small changes. They give different results.
  • Majority filters (scipy.ndimage with a mode filter) smooth class maps but also erode thin real features such as rivers and roads.
  • Accuracy: an MMU removes noise but not systematic disagreement; large patches can still be method differences.

FAQ

Why does my land cover change map look noisy?

Each map's classification errors, concentrated on edges and between similar classes, appear as change when the maps are differenced.

What is a minimum mapping unit?

The smallest patch a map treats as a feature; change patches smaller than it are dropped or merged into their surroundings.

How do I remove small patches from a raster in Python?

rasterio.features.sieve(array, size=n) replaces connected patches smaller than n pixels with the value of their largest neighbour.

Can I compare ESA WorldCover 2020 and 2021 for change?

Not reliably: the two versions were made with different algorithms, so their differences mix method changes with real change.