Land Cover Map Accuracy Explained

On this page

Problem statement

Every land cover map comes with an overall accuracy figure, and every user of the map quietly assumes it applies evenly. It does not. Overall accuracy averages over classes that are mapped very well and classes that are mapped poorly. It says nothing about whether a class's area is too large or too small. And it depends on how the reference sample was chosen. Reading a map properly means reading its confusion matrix: user's and producer's accuracy per class, and what the errors do to areas.

This page compares ESA WorldCover 2020 with 667 field-surveyed points of the EU's LUCAS 2018 Copernicus survey in Denmark. It computes the standard accuracy measures and shows what each one means for someone using the map.

Quick answer

Sample the map at the reference points, put both in the same classes, and cross-tabulate:

cm = pd.crosstab(PTS.mapped, PTS.ref)
overall = np.trace(cm.reindex(index=cm.columns, fill_value=0).to_numpy()) / cm.to_numpy().sum()
print(cm.rename(index=NAMES, columns=NAMES).to_string())
print(f"overall accuracy: {overall:.1%} of {len(PTS)} points")
ref         Artificial  Cropland  Woodland  Shrubland  Grassland  Bare land  Wetland
mapped                                                                              
Artificial           3         0         0          0          0          0        0
Cropland             0       302         0          1         28          1        0
Woodland             2         4       118          1          9          0        1
Grassland            1        59        11         10        109          1        1
Bare land            0         1         0          0          1          1        0
Wetland              0         0         0          0          2          0        0
overall accuracy: 79.9% of 667 points
Confusion matrix of ESA WorldCover 2020 against 667 LUCAS 2018 survey points in Denmark: rows are mapped classes and columns surveyed classes; the diagonal holds 302 cropland, 118 woodland and 109 grassland agreements; the largest errors are 59 cropland points mapped as grassland and 28 grassland points mapped as cropland; overall accuracy 79.9 percent. Confusion matrix of ESA WorldCover 2020 against 667 LUCAS 2018 survey points in Denmark: rows are mapped classes and columns surveyed classes; the diagonal holds 302 cropland, 118 woodland and 109 grassland agreements; the largest errors are 59 cropland points mapped as grassland and 28 grassland points mapped as cropland; overall accuracy 79.9 percent.
The full comparison. The diagonal is agreement; everything else is error, and its position says which way it goes.

User's and producer's accuracy, off-diagonal cells, area effects

1. User's and producer's accuracy

Rows are what the map says, columns what the surveyor found. User's accuracy, along a row, is the chance that a pixel mapped as a class really is that class: what a user of the map experiences. Producer's accuracy, down a column, is the chance that ground of a class was mapped as that class: how well the producer captured it.

classes = [c for c in cm.columns if c in cm.index]
acc = pd.DataFrame({"mapped": [cm.loc[c].sum() for c in classes], "reference": [cm[c].sum() for c in classes],
                    "user's": [cm.loc[c, c] / cm.loc[c].sum() for c in classes],
                    "producer's": [cm.loc[c, c] / cm[c].sum() for c in classes]}, index=[NAMES[c] for c in classes])
print(acc.round(2).to_string())
            mapped  reference  user's  producer's
Artificial       3          6    1.00        0.50
Cropland       332        366    0.91        0.83
Woodland       135        129    0.87        0.91
Grassland      192        149    0.57        0.73
Bare land        3          3    0.33        0.33
Wetland          2          2    0.00        0.00

Classes with a handful of points (built-up, bare, wetland here) have accuracies that mean little; with 3 points, one error moves the figure by a third.

2. Read the off-diagonal cells

The errors are not random. Find the largest:

off = cm.stack()
off = off[[m != r for m, r in off.index]].sort_values(ascending=False).head(4)
for (m, r), n in off.items():
    print(f"{n:3d} points surveyed as {NAMES[r]} but mapped as {NAMES[m]}")
crop_as_grass = PTS[(PTS.mapped == "E") & (PTS.ref == "B")]
print("what the surveyors recorded there:", crop_as_grass.LC1_LABEL.value_counts().head(3).to_dict())
 59 points surveyed as Cropland but mapped as Grassland
 28 points surveyed as Grassland but mapped as Cropland
 11 points surveyed as Woodland but mapped as Grassland
 10 points surveyed as Shrubland but mapped as Grassland
what the surveyors recorded there: {'Barley': 28, 'Temporary grasslands': 9, 'Common wheat': 5}

Grassland and cropland swap in both directions, and most of the cropland mapped as grass is cereal fields, barley above all. A cereal field is green, grass-like vegetation for much of the year, and a map with one label per year has to choose; temporary grass in rotation, which surveyors record as a crop, adds to the confusion. The classes are defined from different evidence.

Bar chart of user's and producer's accuracy by class with the number of reference points: cropland 0.91 and 0.83 from 366 points, woodland 0.87 and 0.91 from 129, grassland 0.57 and 0.73 from 149, while artificial, bare land and wetland have 6, 3 and 2 points and unreliable figures. Bar chart of user's and producer's accuracy by class with the number of reference points: cropland 0.91 and 0.83 from 366 points, woodland 0.87 and 0.91 from 129, grassland 0.57 and 0.73 from 149, while artificial, bare land and wetland have 6, 3 and 2 points and unreliable figures.
Per-class accuracy, with the sample behind each bar.

3. See what errors do to area

When errors in one direction outnumber errors in the other, the mapped area of a class is biased. With the map's class proportions as weights, the reference proportions can be estimated from the matrix:

W = MAP_SHARE.reindex(cm.index).fillna(0)                          # share of Denmark's land in each mapped class
P = cm.div(cm.sum(axis=1), axis=0).mul(W, axis=0)                  # estimated share of land in (map row, reference column)
est = P.sum(axis=0)
compare = pd.DataFrame({"mapped share": W, "estimated true share": est}).reindex(classes).rename(NAMES) * 100
print(compare.round(1).to_string())
            mapped share  estimated true share
Artificial           2.3                   2.7
Cropland            46.9                  52.6
Woodland            19.6                  18.8
Grassland           30.3                  23.2
Bare land            0.3                   0.4
Wetland              0.7                   0.3

The map shares come from the 1:16 overview of the map, about 150 m, which under-counts scattered classes such as buildings. This is the stratified estimator of Olofsson and colleagues (2014). It is only valid when the reference points are a probability sample of the map, which these are not quite: LUCAS Copernicus points were surveyed where the land cover is homogeneous for some tens of metres. Treat the result as an illustration of the method, not a corrected map of Denmark.

How certain the overall accuracy is

The data used on this page

LUCAS 2018 Copernicus in-situ land cover from the EU Joint Research Centre (d'Andrimont et al. 2021), points marked COPERNICUS_CLEANED in Denmark with their level-1 Copernicus land cover; ESA WorldCover 2020 (v100) sampled at the theoretical point locations from the public S3 bucket; Denmark's outline from geoBoundaries.

import io
import zipfile
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import rasterio.features
import requests
from rasterio.enums import Resampling

Path("data").mkdir(exist_ok=True)
Z = Path("data/LUCAS_2018_Copernicus.zip")
if not Z.exists():
    Z.write_bytes(requests.get("https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/LUCAS/LUCAS_2018_Copernicus.zip", timeout=600).content)
cols = ["POINT_ID", "NUTS0", "TH_LAT", "TH_LONG", "CPRN_LC", "LC1", "LC1_LABEL", "COPERNICUS_CLEANED"]
lucas = pd.read_csv(zipfile.ZipFile(Z).open("LUCAS_2018_Copernicus_attributes.csv"), usecols=cols, low_memory=False)
PTS = lucas[(lucas.NUTS0 == "DK") & lucas.COPERNICUS_CLEANED].copy()
WC = "https://esa-worldcover.s3.eu-central-1.amazonaws.com/v100/2020/map/ESA_WorldCover_10m_2020_v100_{}_Map.tif"
tile = lambda lat, lon: f"N{int(np.floor(lat / 3) * 3):02d}E{int(np.floor(lon / 3) * 3):03d}"
PTS["tile"] = [tile(a, b) for a, b in zip(PTS.TH_LAT, PTS.TH_LONG)]
values = {}
for t, g in PTS.groupby("tile"):
    with rasterio.open("/vsicurl/" + WC.format(t)) as src:
        values.update({pid: int(v[0]) for pid, v in zip(g.POINT_ID, src.sample(list(zip(g.TH_LONG, g.TH_LAT))))})
WC2LUCAS = {10: "C", 20: "D", 30: "E", 40: "B", 50: "A", 60: "F", 70: "F", 80: "G", 90: "H", 95: "H", 100: "E"}
PTS["mapped"] = PTS.POINT_ID.map(values).map(WC2LUCAS)
PTS["ref"] = PTS.CPRN_LC.str[0]
NAMES = {"A": "Artificial", "B": "Cropland", "C": "Woodland", "D": "Shrubland", "E": "Grassland", "F": "Bare land", "G": "Water", "H": "Wetland"}
DK = Path("data/dnk_adm0.geojson")
if not DK.exists():
    DK.write_bytes(requests.get("https://github.com/wmgeolab/geoBoundaries/raw/9469f09/releaseData/gbOpen/DNK/ADM0/geoBoundaries-DNK-ADM0_simplified.geojson", timeout=300).content)
dk = gpd.read_file(DK)
counts = pd.Series(0.0, index=list(NAMES))
for t in sorted(PTS.tile.unique()):
    with rasterio.open("/vsicurl/" + WC.format(t)) as src:              # read the 1:16 overview, about 150 m pixels
        ov = src.read(1, out_shape=(src.height // 16, src.width // 16), resampling=Resampling.nearest)
        ovt = src.transform * src.transform.scale(16, 16)
    inside = rasterio.features.geometry_mask(dk.geometry, ov.shape, ovt, invert=True)
    lat = ovt.f + (np.arange(ov.shape[0]) + 0.5) * ovt.e
    w = np.cos(np.radians(lat))[:, None] * np.ones_like(ov, dtype=float)    # pixel area shrinks with latitude
    for code, cls in WC2LUCAS.items():
        counts[cls] += w[inside & (ov == code)].sum()
land = counts.drop("G")
MAP_SHARE = land / land.sum()
print(f"{len(PTS)} LUCAS points in Denmark; WorldCover sampled at all: {PTS.mapped.notna().all()}; tiles {sorted(PTS.tile.unique())}")
667 LUCAS points in Denmark; WorldCover sampled at all: True; tiles ['N54E006', 'N54E009', 'N54E012', 'N57E006', 'N57E009']

Example 1: how certain is the overall accuracy?

n = len(PTS); se = np.sqrt(overall * (1 - overall) / n)
print(f"overall accuracy {overall:.1%} ± {1.96 * se:.1%} (95% interval, {n} points)")
print(f"grassland user's accuracy {acc.loc['Grassland', "user's"]:.0%} ± {1.96 * np.sqrt(acc.loc['Grassland', "user's"] * (1 - acc.loc['Grassland', "user's"]) / cm.loc['E'].sum()):.0%}")
overall accuracy 79.9% ± 3.0% (95% interval, 667 points)
grassland user's accuracy 57% ± 7%

What a confusion matrix compares

A confusion matrix compares a map with reference observations at the same places. Its diagonal is agreement; everything else is error, and the position of each error says which way it goes. Overall accuracy adds up the diagonal and hides the structure. User's accuracy tells a map user how far to trust a pixel of a given class. Producer's accuracy tells how much of the real class the map found. A class can score well on one and badly on the other: here grassland is found fairly well but over-mapped, so a grassland pixel is right only about three times in five.

User's and producer's accuracy, read from the WorldCover and LUCAS comparison for grassland in Denmark. two accuracies for one class: grasslanduser's accuracy: 57%✓read along the map row✓109 of 192 points mapped grass right✓what a map user experiences✓low: grassland is over-mappedproducer's accuracy: 73%✓read down the reference column✓109 of 149 real grassland found✓what the map producer achieved✓28 grass points went to croplandWorldCover 2020 against LUCAS 2018 Copernicus, Denmark, 667 points spatialworkflow.io
Two accuracies for one class.

Accuracy figures are only as good as the reference sample. Points must be independent of the map, labelled with the same class definitions, and chosen by a known random design, so that each point's weight in the estimate is known. Field surveys such as LUCAS are the best available reference in Europe, but their class definitions (land use mixed into cover) and point selection differ from the map's. Errors measured with them combine map errors with these differences, which is why published accuracies vary so much between assessments of the same map.

Location error, time and small classes

Land cover accuracy problems and fixes. reading accuracy figuresTrapInstead"80% accurate" read for everyclassread per-class accuracies1.00 accuracy from 3 pointsreport n; it means littlemapped areas taken as trueerror-adjusted estimate from thematrixany points used as referencea probability sample, same classesWorldCover 2020, LUCAS 2018, Denmark spatialworkflow.io
Reading accuracy figures.
  • Location error: a point near a class boundary can fall on either side of it; LUCAS Copernicus chose homogeneous surroundings partly to reduce this.
  • Time: the survey is from 2018 and the map from 2020; real change in between counts as error.
  • Crosswalks: mapping WorldCover classes onto LUCAS letters is a choice, and moss and lichen (WorldCover 100) has no clean equivalent.
  • Water: lakes and sea are excluded here as reference points are on land; the map shares are land-only for that reason.
  • Small classes: report the number of reference points beside every per-class accuracy.

FAQ

What is the difference between user's and producer's accuracy?

User's accuracy is the share of pixels mapped as a class that really are that class; producer's accuracy is the share of the real class that was mapped as it.

Is overall accuracy enough to judge a land cover map?

No. It averages over classes; a map can be 80% accurate overall and much less reliable for the class you care about.

Can I correct land cover areas for map errors?

Yes, with the stratified estimator of Olofsson et al. (2014), if the reference sample is a probability sample of the map.

Where can I find reference data to check a land cover map?

In Europe, the LUCAS survey; elsewhere, validation datasets published with global maps, or a sample interpreted from high-resolution imagery.