Source code: Land cover accuracy assessment in Python: confusion matrix, Olofsson area estimates and sample size

Runnable Python scripts, requirements and console output for the tutorial “Land cover accuracy assessment in Python: confusion matrix, Olofsson area…

Last updated

These are the exact scripts used to produce every figure and number in Land cover accuracy assessment in Python: confusion matrix, Olofsson area estimates and sample size. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python accuracy_assessment.py

accuracy_assessment.py

accuracy_assessment.py
"""Land cover map accuracy assessment in Python: ESA WorldCover 2021 against LUCAS 2022 field
survey points in Ireland, with Olofsson et al. (2014) stratified estimators, error-adjusted
area estimates and 95% confidence intervals, and sample-size planning for a new design.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/land-cover-accuracy-assessment-python/
Data: ESA WorldCover 2021 v200 (CC BY 4.0), Eurostat LUCAS 2022 microdata and LUCAS master grid
(Eurostat, reuse authorised with acknowledgement), GISCO NUTS 2021 boundaries (EuroGeographics).
"""
import sys
import warnings
from importlib.metadata import version
from pathlib import Path
import numpy as np
import pandas as pd
import geopandas as gpd
import requests
import rasterio
from rasterio.features import geometry_mask
from rasterio.windows import Window, from_bounds
from rasterio.enums import Resampling
from shapely.geometry import box
from pyproj import Geod
import matplotlib
matplotlib.use("Agg") # render to files only; no GUI backend
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap, BoundaryNorm
from matplotlib.patches import Patch
from matplotlib.lines import Line2D
warnings.filterwarnings("ignore")
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9,
"figure.facecolor": "white", "axes.facecolor": "white"})
DATA = Path("data"); DATA.mkdir(exist_ok=True)
OUT = Path("output"); OUT.mkdir(exist_ok=True)
SEED = 42
rng = np.random.default_rng(SEED)
N_BOOT = 1000
WC_URL = "https://esa-worldcover.s3.eu-central-1.amazonaws.com/v200/2021/map/ESA_WorldCover_10m_2021_v200_{}_Map.tif"
LUCAS_URL = "https://ec.europa.eu/eurostat/documents/205002/17561401/IE_LUCAS_2022.csv"
GRID_URL = "https://ec.europa.eu/eurostat/documents/d/lucas/lucas-master-grid-2016"
NUTS_URL = "https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_01M_2021_4326_LEVL_0.geojson"
# ESA WorldCover legend: code -> (short name, official colour)
WC = {10: ("Tree cover", "#006400"), 20: ("Shrubland", "#ffbb22"), 30: ("Grassland", "#ffff4c"),
40: ("Cropland", "#f096ff"), 50: ("Built-up", "#fa0000"), 60: ("Bare / sparse", "#b4b4b4"),
70: ("Snow and ice", "#f0f0f0"), 80: ("Water", "#0064c8"), 90: ("Herbaceous wetland", "#0096a0"),
95: ("Mangroves", "#00cf75"), 100: ("Moss and lichen", "#fae6a0")}
def download(url, path):
if not path.exists():
r = requests.get(url, timeout=900, headers={"User-Agent": "TechnicalGIS-tutorial"})
r.raise_for_status()
path.write_bytes(r.content)
return path
def section(title):
print(f"\n=== {title} ===")
section("versions")
print(f"python {sys.version.split()[0]} | numpy {version('numpy')} | pandas {version('pandas')} | "
f"rasterio {version('rasterio')} | geopandas {version('geopandas')} | matplotlib {version('matplotlib')}")
# ---------------------------------------------------------------- Step 1: the map and the study area
section("study area and WorldCover tiles")
nuts = gpd.read_file(download(NUTS_URL, DATA / "NUTS_RG_01M_2021_4326_LEVL_0.geojson"))
ireland = nuts[nuts.NUTS_ID == "IE"].to_crs("EPSG:4326")
ie_geom = ireland.geometry.iloc[0]
x0, y0, x1, y1 = ie_geom.bounds
tiles = [f"N{lat:02d}W{-lon:03d}" for lat in range(int(np.floor(y0 / 3) * 3), int(y1) + 1, 3)
for lon in range(int(np.floor(x0 / 3) * 3), int(x1) + 1, 3)]
tiles = [t for t in tiles if box(-int(t[4:]), int(t[1:3]), -int(t[4:]) + 3, int(t[1:3]) + 3).intersects(ie_geom)]
paths = {t: download(WC_URL.format(t), DATA / f"WorldCover_2021_{t}.tif") for t in tiles}
geod = Geod(ellps="WGS84")
print(f"Ireland (NUTS IE, 1:1M): bounds {x0:.2f}..{x1:.2f} E, {y0:.2f}..{y1:.2f} N, "
f"geodesic area {abs(geod.geometry_area_perimeter(ie_geom)[0]) / 1e6:,.0f} km2")
print(f"WorldCover 2021 v200 tiles: {', '.join(tiles)}")
# ---------------------------------------------------------------- Step 2: map class areas (pixel counting)
def row_areas(transform, row0, nrows):
"""Ellipsoidal area (m2) of one pixel in each row: 10 m pixels in EPSG:4326 shrink towards the pole."""
lat_top = transform.f + transform.e * np.arange(row0, row0 + nrows)
lat_bot = lat_top + transform.e
lon0, lon1 = 0.0, transform.a
return np.array([abs(geod.polygon_area_perimeter([lon0, lon1, lon1, lon0], [t, t, b, b])[0])
for t, b in zip(lat_top, lat_bot)])
section("map class areas from pixel counts")
STEP = 3000 # rows per window: 3000 x 36000 pixels
area_m2 = np.zeros(256)
pix = np.zeros(256, dtype=np.int64)
win_counts = [] # per-window class counts, for sampling in Step 8
for t, p in paths.items():
with rasterio.open(p) as src:
for r0 in range(0, src.height, STEP):
win = Window(0, r0, src.width, min(STEP, src.height - r0))
wb = rasterio.windows.bounds(win, src.transform)
part = ie_geom.intersection(box(*wb))
if part.is_empty:
continue
wt = rasterio.windows.transform(win, src.transform)
inside = geometry_mask([part], out_shape=(win.height, win.width), transform=wt, invert=True)
arr = src.read(1, window=win)
rows = np.nonzero(inside)[0]
cls = arr[inside]
counts = np.bincount(rows * 256 + cls, minlength=win.height * 256).reshape(win.height, 256)
area_m2 += (counts * row_areas(src.transform, r0, win.height)[:, None]).sum(0)
pix += counts.sum(0)
win_counts.append((t, r0, win.height, counts.sum(0)))
# Moss and lichen covers 199 pixels (0.02 km2) in Ireland, too rare to assess: merge it into bare / sparse
area_m2[60] += area_m2[100]; pix[60] += pix[100]; area_m2[100] = pix[100] = 0
for _, _, _, cnt in win_counts:
cnt[60] += cnt[100]; cnt[100] = 0
classes = [c for c in WC if pix[c] > 0]
A_total = area_m2[classes].sum()
map_area = pd.DataFrame({"class": [WC[c][0] for c in classes], "pixels": pix[classes],
"area_km2": area_m2[classes] / 1e6}, index=classes)
map_area["W"] = map_area.area_km2 / map_area.area_km2.sum()
print(f"{pix[classes].sum():,} pixels inside Ireland; mapped area {A_total / 1e6:,.0f} km2 "
f"(nodata pixels: {pix[0]:,})")
print(map_area.to_string(formatters={"pixels": "{:,}".format, "area_km2": "{:,.1f}".format, "W": "{:.4f}".format}))
# ---------------------------------------------------------------- Step 3: LUCAS 2022 reference points
section("LUCAS 2022 reference data")
lucas = pd.read_csv(download(LUCAS_URL, DATA / "IE_LUCAS_2022.csv"), low_memory=False)
obs_names = {1: "field, <= 100 m", 2: "field, > 100 m", 3: "photo-interpreted in the field",
7: "photo-interpreted in the office"}
sdate = pd.to_datetime(lucas.SURVEY_DATE, format="%m/%d/%Y %H:%M")
print(f"{len(lucas):,} LUCAS 2022 points in Ireland, surveyed {sdate.min():%d %b %Y} .. {sdate.max():%d %b %Y}")
for k, v in lucas.SURVEY_OBS_TYPE.value_counts().sort_index().items():
print(f" observation type {k} ({obs_names.get(k, '?')}): {v:,}")
def lucas_to_worldcover(lc1, lu1):
"""Crosswalk from the LUCAS 2022 land cover legend (LC1) to the ESA WorldCover legend."""
a, two = lc1[0], lc1[:2]
if a == "A":
return 50 # roofed and non-roofed artificial surfaces
if a == "B":
if lc1 == "B55":
return 30 # temporary grassland (sown ley): WorldCover sees grass
if two == "B7":
return 10 # fruit trees
if two == "B8" or lc1 == "Bx2":
return 20 # vineyards, nurseries and other permanent woody crops
return 40 # arable crops, incl. Bx1 (arable land, photo-interpreted)
if a == "C":
return 10 # broadleaved, coniferous and mixed woodland
if a == "D":
return 20
if a == "E":
return 30
if a == "F":
if lc1 == "F40" and str(lu1).startswith("U11"):
return 40 # bare arable soil between crops (agricultural use)
return 60 # rocks, sand, lichens and moss (F30), other bare soil
if a == "G":
return 70 if lc1 == "G50" else 80
if a == "H":
return 60 if lc1 in ("H22", "H23") else 90 # salines and intertidal flats are unvegetated
raise ValueError(lc1)
lucas["ref"] = [lucas_to_worldcover(a, b) for a, b in zip(lucas.SURVEY_LC1, lucas.SURVEY_LU1)]
xwalk = lucas.groupby(["SURVEY_LC1"]).ref.first().reset_index()
print("crosswalk LUCAS LC1 -> WorldCover:")
for code, grp in xwalk.groupby("ref"):
print(f" {WC[code][0]:<18} <- {' '.join(grp.SURVEY_LC1)}")
moved = lucas[(lucas.SURVEY_LC1 == "F40") & (lucas.ref == 40)]
print(f"F40 bare soil on agricultural land counted as cropland: {len(moved)}; "
f"B55 temporary grassland counted as grassland: {(lucas.SURVEY_LC1 == 'B55').sum()}")
# ---------------------------------------------------------------- Step 4: sampling design weights
section("LUCAS 2022 design weights from the master grid")
grid_csv = DATA / "lucas_master_grid_2016_IE.csv"
if not grid_csv.exists():
import openpyxl
xlsx = download(GRID_URL, DATA / "lucas_master_grid_2016_v2.xlsx")
ws = openpyxl.load_workbook(xlsx, read_only=True)["Master_web"]
rows = ws.iter_rows(values_only=True)
hdr = next(rows)
keep = [r for r in rows if r[1] == "IE"]
pd.DataFrame(keep, columns=hdr).to_csv(grid_csv, index=False)
grid = pd.read_csv(grid_csv)
grid = grid[grid.NUTS0_21 == "IE"]
grid["cell"] = grid.NUTS2_21 + "_S" + grid.STR18.astype(str)
cells = grid.groupby("cell").agg(N=("POINT_ID", "size"), n=("LUCAS_FLAG", "sum"))
print(f"master grid: {len(grid):,} points on the 2 km grid in Ireland, {grid.LUCAS_FLAG.sum():,} in the 2022 sample")
str18 = {1: "arable", 2: "permanent crops", 3: "grass", 4: "wooded", 5: "shrubs", 6: "bare/rare vegetation",
7: "artificial", 8: "inland water", 9: "transitional/coastal water", 10: "impossible to PI"}
rates = grid.groupby("STR18").agg(N=("POINT_ID", "size"), n=("LUCAS_FLAG", "sum")).astype(int)
rates["rate"] = rates.n / rates.N
print("selection rate by 2018 photo-interpretation stratum (STR18):")
for s, rr in rates.iterrows():
print(f" {s:>2} {str18[s]:<27} {int(rr.n):>5,} of {int(rr.N):>5,} ({rr.rate:.0%})")
cells["w"] = cells.N / cells.n.replace(0, np.nan)
lucas = lucas.merge(grid[["POINT_ID", "cell", "STR18", "LUCAS_FLAG"]], on="POINT_ID", how="left")
lucas["w"] = lucas.cell.map(cells.w)
print(f"design cells (NUTS 2 x STR18): {len(cells)}; weights {lucas.w.min():.2f} .. {lucas.w.max():.2f}; "
f"sum of weights {lucas.w.sum():,.0f} = grid points represented "
f"(x 4 km2 = {lucas.w.sum() * 4:,.0f} km2)")
print(f"grid points in cells with no sample (not represented): {int(cells.N[cells.n == 0].sum())}")
# ---------------------------------------------------------------- Step 5: read the map at each point
section("map labels at the LUCAS points")
lucas["tile"] = [f"N{int(np.floor(la / 3) * 3):02d}W{int(-np.floor(lo / 3) * 3):03d}"
for la, lo in zip(lucas.POINT_LAT, lucas.POINT_LONG)]
lucas["mapc"] = 0
lucas["pure3x3"] = 0.0
for t, grp in lucas.groupby("tile"):
with rasterio.open(paths[t]) as src:
rc = [src.index(lo, la) for lo, la in zip(grp.POINT_LONG, grp.POINT_LAT)]
maps, pure = [], []
for r, c in rc:
w = src.read(1, window=Window(c - 1, r - 1, 3, 3), boundless=True, fill_value=0)
maps.append(w[1, 1]); pure.append((w == w[1, 1]).mean())
lucas.loc[grp.index, "mapc"] = maps
lucas.loc[grp.index, "pure3x3"] = pure
lucas.loc[lucas.mapc == 100, "mapc"] = 60
pts = lucas[lucas.mapc.isin(classes) & lucas.w.notna()].copy()
print(f"points used: {len(pts):,} (dropped {len(lucas) - len(pts)} with map nodata or no design weight)")
print(f"points whose 3x3 pixel block is all one class: {(pts.pure3x3 == 1).mean():.1%}")
K = len(classes)
idx = {c: i for i, c in enumerate(classes)}
m = pts.mapc.map(idx).to_numpy()
r = pts.ref.map(idx).to_numpy()
w = pts.w.to_numpy()
W = map_area.W.to_numpy()
names = [WC[c][0] for c in classes]
# ---------------------------------------------------------------- Step 6: the confusion matrix
section("sample confusion matrix (counts; rows = map, columns = reference)")
cm = np.bincount(m * K + r, minlength=K * K).reshape(K, K)
cm_df = pd.DataFrame(cm, index=names, columns=names)
print(cm_df.to_string())
oa_naive = np.trace(cm) / cm.sum()
ua_naive = np.diag(cm) / cm.sum(1)
pa_naive = np.diag(cm) / cm.sum(0)
print(f"\nnaive overall accuracy (count of agreeing points / all points): {oa_naive:.3f}")
pe = (cm.sum(1) * cm.sum(0)).sum() / cm.sum() ** 2
kappa = (oa_naive - pe) / (1 - pe)
print(f"Cohen's kappa: {kappa:.3f} (chance agreement {pe:.3f})")
def estimate(m, r, w, W, K):
"""Olofsson et al. (2014) stratified estimators with the map classes as (post-)strata.
Within each map class the reference proportions are design-weighted ratio estimates."""
sw = np.bincount(m, weights=w, minlength=K)
nij = np.bincount(m * K + r, weights=w, minlength=K * K).reshape(K, K)
prop = np.divide(nij, sw[:, None], out=np.zeros_like(nij), where=sw[:, None] > 0) # P(ref j | map i)
p = W[:, None] * prop # area-proportion error matrix
oa = np.trace(p)
ua = np.diag(p) / p.sum(1)
pa = np.diag(p) / p.sum(0)
return p, oa, ua, pa, p.sum(0), prop
def kish(m, w, K):
return np.bincount(m, weights=w, minlength=K) ** 2 / np.bincount(m, weights=w ** 2, minlength=K)
def olofsson_se(prop, W, n, p):
"""Standard errors from Olofsson et al. (2014), equations 5 to 7 and 10, with n the per-stratum sample size.
A stratum with a single point has no variance estimate; n - 1 is floored at 1 so it contributes ~0."""
K = len(W)
n = np.maximum(n, 2.0)
ua = np.diag(prop)
se_oa = np.sqrt(np.sum(W ** 2 * ua * (1 - ua) / (n - 1)))
se_ua = np.sqrt(ua * (1 - ua) / (n - 1))
se_area = np.sqrt(((W[:, None] ** 2) * prop * (1 - prop) / (n[:, None] - 1)).sum(0))
Nj = p.sum(0)
pa = np.diag(p) / Nj
se_pa = np.zeros(K)
for j in range(K):
other = sum(W[i] ** 2 * prop[i, j] * (1 - prop[i, j]) / (n[i] - 1) for i in range(K) if i != j)
se_pa[j] = np.sqrt((W[j] ** 2 * (1 - pa[j]) ** 2 * ua[j] * (1 - ua[j]) / (n[j] - 1)
+ pa[j] ** 2 * other) / Nj[j] ** 2)
return se_oa, se_ua, se_pa, se_area
# ---------------------------------------------------------------- Step 7: area-adjusted estimates
section("area-adjusted error matrix (estimated % of Ireland; rows = map, columns = reference)")
p, oa, ua, pa, pref, prop = estimate(m, r, w, W, K)
print(pd.DataFrame(100 * p, index=names, columns=names).round(2).to_string())
n_i = np.bincount(m, minlength=K)
n_eff = kish(m, w, K)
se_oa, se_ua, se_pa, se_area = olofsson_se(prop, W, n_eff, p)
# stratified bootstrap over the LUCAS design cells, as a check on the analytical standard errors
cell_codes, cell_idx = np.unique(pts.cell.to_numpy(), return_inverse=True)
members = [np.nonzero(cell_idx == k)[0] for k in range(len(cell_codes))]
boot = {"oa": [], "ua": [], "pa": [], "area": []}
for b in range(N_BOOT):
s = np.concatenate([rng.choice(g, size=len(g), replace=True) for g in members])
_, oab, uab, pab, prb, _ = estimate(m[s], r[s], w[s], W, K)
boot["oa"].append(oab); boot["ua"].append(uab); boot["pa"].append(pab); boot["area"].append(prb)
boot = {k: np.array(v) for k, v in boot.items()}
# what you get if you ignore LUCAS's unequal selection rates
p_u, oa_u, ua_u, pa_u, pref_u, _ = estimate(m, r, np.ones_like(w), W, K)
A_km2 = A_total / 1e6
res = pd.DataFrame({
"class": names, "n": n_i, "n_eff": n_eff.round(0).astype(int),
"UA_naive": ua_naive, "UA": ua, "UA_ci": 1.96 * se_ua,
"PA_naive": pa_naive, "PA": pa, "PA_ci": 1.96 * se_pa,
"map_km2": W * A_km2, "adj_km2": pref * A_km2, "adj_ci_km2": 1.96 * se_area * A_km2,
"boot_ci_km2": 1.96 * np.nanstd(boot["area"], 0) * A_km2, "unweighted_km2": pref_u * A_km2,
})
fmt = {"UA_naive": "{:.3f}".format, "UA": "{:.3f}".format, "UA_ci": "{:.3f}".format,
"PA_naive": "{:.3f}".format, "PA": "{:.3f}".format, "PA_ci": "{:.3f}".format,
"map_km2": "{:,.0f}".format, "adj_km2": "{:,.0f}".format, "adj_ci_km2": "{:,.0f}".format,
"boot_ci_km2": "{:,.0f}".format, "unweighted_km2": "{:,.0f}".format}
print()
print(res.to_string(index=False, formatters=fmt))
print(f"\noverall accuracy (area-weighted, design-weighted): {oa:.3f} +/- {1.96 * se_oa:.3f} "
f"(bootstrap +/- {1.96 * np.nanstd(boot['oa']):.3f}); ignoring the design weights: {oa_u:.3f}; "
f"naive count-based: {oa_naive:.3f}")
q_dis = 0.5 * np.abs(p.sum(0) - p.sum(1)).sum()
a_dis = (1 - oa) - q_dis
pe_p = (p.sum(0) * p.sum(1)).sum()
print(f"Pontius & Millones: quantity disagreement {q_dis:.3f}, allocation disagreement {a_dis:.3f} "
f"(total {1 - oa:.3f}); kappa on the area-adjusted matrix {(oa - pe_p) / (1 - pe_p):.3f}")
res.to_csv(OUT / "accuracy_and_area.csv", index=False)
print(f"note: map shrubland has n = {n_i[names.index('Shrubland')]} point, so its user's accuracy is not estimable")
# ---------------------------------------------------------------- Step 8: point vs pixel and other caveats
section("sensitivity: observation type, homogeneity and the Copernicus module")
def group_oa(mask):
return estimate(m[mask], r[mask], w[mask], W, K)[1], int(mask.sum())
sens = []
ot = pts.SURVEY_OBS_TYPE.to_numpy()
for k, label in [(1, "Field, <= 100 m"), (2, "Field, > 100 m"), (3, "PI in the field"), (7, "PI in the office")]:
sens.append(("Observation type", label, *group_oa(ot == k)))
pure = pts.pure3x3.to_numpy()
sens.append(("3x3 map window", "All 9 pixels agree", *group_oa(pure == 1)))
sens.append(("3x3 map window", "Mixed", *group_oa(pure < 1)))
ext = pts[["SURVEY_CPRN_LC1N", "SURVEY_CPRNC_LC1E", "SURVEY_CPRNC_LC1S", "SURVEY_CPRNC_LC1W"]].min(1).to_numpy()
cop = ~np.isnan(ext)
sens.append(("Copernicus module", "Same cover >= 20 m all round", *group_oa(cop & (ext >= 20))))
sens.append(("Copernicus module", "Same cover < 20 m", *group_oa(cop & (ext < 20))))
sens = pd.DataFrame(sens, columns=["group", "subset", "OA", "n"])
print(sens.to_string(index=False, formatters={"OA": "{:.3f}".format}))
year = pd.to_datetime(pts.SURVEY_DATE, format="%m/%d/%Y %H:%M").dt.year
print(f"survey year: 2022 {int((year == 2022).sum()):,}, 2023 {int((year == 2023).sum()):,} points "
f"(WorldCover 2021 describes 2021)")
grass_as_crop = ((pts.mapc == 40) & (pts.ref == 30))
print(f"map cropland, LUCAS grassland: {grass_as_crop.sum()} points, of which B55 temporary grassland "
f"{((pts.SURVEY_LC1 == 'B55') & grass_as_crop).sum()}")
bog = pts.SURVEY_LC1 == "H12"
print("LUCAS peat bog (H12) points by map class: " +
", ".join(f"{WC[c][0]} {v}" for c, v in pts[bog].mapc.value_counts().items()))
# ---------------------------------------------------------------- Step 9: sample-size planning
section("sample size for a new stratified random sample of the map")
# conjectured user's accuracies from this assessment; 0.5 (the worst case) where n < 30 points
U_plan = np.where(n_i >= 30, np.clip(ua, 0.5, 0.95), 0.5)
print("planning user's accuracies: " + ", ".join(f"{names[i]} {U_plan[i]:.2f}" for i in range(K)))
S = np.sqrt(U_plan * (1 - U_plan))
target_se = 0.01
n_total = int(np.ceil((np.sum(W * S) / target_se) ** 2))
print(f"Cochran (1977) eq. 5.25 with target SE(OA) = {target_se}: n = {n_total}")
def expected_se(n_alloc):
"""Expected SE of OA and of each UA for an allocation, from the conjectured user's accuracies."""
v = U_plan * (1 - U_plan) / (np.maximum(n_alloc, 2) - 1)
return np.sqrt(np.sum(W ** 2 * v)), np.sqrt(v)
allocs = {}
allocs["Proportional"] = np.maximum(np.round(n_total * W), 2)
allocs["Equal"] = np.full(K, np.round(n_total / K))
comp = np.full(K, 100.0) # at least 100 per class, the rest in proportion to area
rest = n_total - comp.sum()
big = W * n_total > 100
comp[big] = np.round(100 + rest * W[big] / W[big].sum())
allocs["Compromise (min 100)"] = comp
for name, a in allocs.items():
so, su = expected_se(a)
print(f"{name:<21} n = {int(a.sum()):>5}: SE(OA) {so:.4f}; SE(UA) " +
", ".join(f"{names[i].split()[0]} {su[i]:.3f}" for i in range(K)))
print("compromise allocation: " + ", ".join(f"{names[i]} {int(comp[i])}" for i in range(K)))
# draw the compromise sample: a stratified random sample of map pixels, without replacement
wc_df = pd.DataFrame([(t, r0, h, *cnt[classes]) for t, r0, h, cnt in win_counts],
columns=["tile", "row0", "rows", *classes])
draws = []
for i, c in enumerate(classes):
per_win = rng.multivariate_hypergeometric(wc_df[c].to_numpy(), int(comp[i]))
for (t, r0, h), k in zip(wc_df[["tile", "row0", "rows"]].itertuples(index=False), per_win):
if k:
draws.append((t, r0, h, c, k))
sample = []
for (t, r0, h), grp in pd.DataFrame(draws, columns=["t", "r0", "h", "c", "k"]).groupby(["t", "r0", "h"]):
with rasterio.open(paths[t]) as src:
win = Window(0, r0, src.width, h)
wt = rasterio.windows.transform(win, src.transform)
part = ie_geom.intersection(box(*rasterio.windows.bounds(win, src.transform)))
inside = geometry_mask([part], out_shape=(h, src.width), transform=wt, invert=True)
arr = src.read(1, window=win)
for c, k in zip(grp.c, grp.k):
rr, cc = np.nonzero(inside & (arr == c))
pick = rng.choice(len(rr), size=k, replace=False)
xs, ys = rasterio.transform.xy(wt, rr[pick], cc[pick])
sample += [(c, WC[c][0], x, y) for x, y in zip(xs, ys)]
new = gpd.GeoDataFrame(pd.DataFrame(sample, columns=["map_code", "map_class", "lon", "lat"]),
geometry=gpd.points_from_xy([s[2] for s in sample], [s[3] for s in sample]), crs="EPSG:4326")
new["reference"] = "" # to be filled in by an interpreter, blind to map_class
new = new.sample(frac=1, random_state=SEED).reset_index(drop=True) # shuffle so interpreters can't guess
new.to_file(OUT / "worldcover_ireland_validation_sample.gpkg", driver="GPKG")
print(f"wrote {len(new):,} stratified random sample points to output/worldcover_ireland_validation_sample.gpkg")
print(new.map_class.value_counts().to_string())
# ---------------------------------------------------------------- Figures
section("figures")
cmap = ListedColormap([WC[c][1] for c in classes])
norm = BoundaryNorm(np.arange(K + 1) - 0.5, K)
# Figure 1: the map and the reference points
res_deg = 1 / 400 # 250 m: majority (mode) of 25 x 25 pixels
mx0, my0, mx1, my1 = -10.75, 51.35, -5.85, 55.45
W_px, H_px = int(round((mx1 - mx0) / res_deg)), int(round((my1 - my0) / res_deg))
mosaic = np.zeros((H_px, W_px), dtype=np.uint8)
mtrans = rasterio.transform.from_origin(mx0, my1, res_deg, res_deg)
for t, pth in paths.items():
with rasterio.open(pth) as src:
b = src.bounds
ix0, ix1 = max(b.left, mx0), min(b.right, mx1)
iy0, iy1 = max(b.bottom, my0), min(b.top, my1)
if ix0 >= ix1 or iy0 >= iy1:
continue
sw = from_bounds(ix0, iy0, ix1, iy1, src.transform).round_offsets().round_lengths()
dw = from_bounds(ix0, iy0, ix1, iy1, mtrans).round_offsets().round_lengths()
mosaic[dw.row_off:dw.row_off + dw.height, dw.col_off:dw.col_off + dw.width] = src.read(
1, window=sw, out_shape=(dw.height, dw.width), resampling=Resampling.mode)
inside_lr = geometry_mask([ie_geom], out_shape=mosaic.shape, transform=mtrans, invert=True)
lut = np.full(256, np.nan); lut[classes] = np.arange(K)
img = np.where(inside_lr, lut[mosaic], np.nan)
fig, axes = plt.subplots(1, 2, figsize=(11, 6.6), constrained_layout=True)
ext_ = (mx0, mx1, my0, my1)
axes[0].imshow(img, cmap=cmap, norm=norm, extent=ext_, interpolation="nearest")
axes[0].set_title("ESA WorldCover 2021 (10 m, shown at 250 m)")
handles = [Patch(facecolor=WC[c][1], edgecolor="0.4", lw=0.4, label=f"{WC[c][0]} ({100 * map_area.W[c]:.1f}%)")
for c in classes]
axes[0].legend(handles=handles, loc="lower right", fontsize=7, title="Map class (share of area)",
title_fontsize=7, framealpha=0.95)
axes[1].imshow(np.where(inside_lr, 0.0, np.nan), cmap=ListedColormap(["#e6e6e6"]), extent=ext_)
agree = pts.mapc == pts.ref
for ok, col, lab in [(True, "#0072B2", "Map agrees with LUCAS"), (False, "#D55E00", "Map disagrees")]:
s = pts[agree == ok]
axes[1].scatter(s.POINT_LONG, s.POINT_LAT, s=1.6, c=col, lw=0, label=f"{lab} ({len(s):,})")
axes[1].set_title(f"{len(pts):,} LUCAS 2022 survey points")
axes[1].legend(loc="lower right", markerscale=5, fontsize=7.5, framealpha=0.95)
for ax in axes:
ax.set_xlim(mx0, mx1); ax.set_ylim(my0, my1); ax.set_aspect(1 / np.cos(np.radians(53.4)))
ax.set_xlabel("Longitude (°)"); ax.set_ylabel("Latitude (°)")
fig.savefig(OUT / "01-worldcover-lucas-map.png"); plt.close(fig)
# Figure 2: confusion matrix (counts) and area-adjusted error matrix
fig, axes = plt.subplots(1, 2, figsize=(12.5, 5.6), constrained_layout=True)
short = [n.replace("Herbaceous wetland", "Herb. wetland").replace("Moss and lichen", "Moss/lichen") for n in names]
for ax, mat, title, fmtf, cm_name in [
(axes[0], cm, "Sample counts (LUCAS points)", lambda v: f"{int(v):,}", "Blues"),
(axes[1], 100 * p, "Area-adjusted error matrix (% of Ireland)", lambda v: f"{v:.2f}", "Blues")]:
im = ax.imshow(np.log10(mat + (1 if mat is cm else 0.01)), cmap=cm_name)
for i in range(K):
for j in range(K):
v = mat[i, j]
if v > 0:
dark = im.norm(np.log10(v + (1 if mat is cm else 0.01))) > 0.6
ax.text(j, i, fmtf(v), ha="center", va="center", fontsize=6.5,
color="white" if dark else "black", fontweight="bold" if i == j else None)
ax.set_xticks(range(K), short, rotation=45, ha="right"); ax.set_yticks(range(K), short)
ax.set_xlabel("Reference class (LUCAS 2022)"); ax.set_ylabel("Map class (WorldCover 2021)")
ax.set_title(title)
cb = fig.colorbar(im, ax=ax, shrink=0.8)
cb.set_label("log10(points + 1)" if mat is cm else "log10(% of area + 0.01)")
fig.savefig(OUT / "02-confusion-matrices.png"); plt.close(fig)
# Figure 3: user's and producer's accuracy, naive vs stratified with 95% CIs
fig, axes = plt.subplots(1, 2, figsize=(12, 4.8), sharey=True, constrained_layout=True)
y = np.arange(K)
ok = n_i >= 5
for ax, est, ci, naive, title in [(axes[0], np.where(ok, ua, np.nan), 1.96 * se_ua, np.where(ok, ua_naive, np.nan), "User's accuracy (map reliability)"),
(axes[1], pa, 1.96 * se_pa, pa_naive, "Producer's accuracy (omission)")]:
lo, hi = np.clip(est - ci, 0, 1), np.clip(est + ci, 0, 1)
ax.errorbar(est, y, xerr=[est - lo, hi - est], fmt="o", color="#0072B2", ms=5, capsize=3,
label="Stratified estimate (Olofsson) with 95% CI")
ax.scatter(naive, y + 0.25, marker="x", color="#D55E00", s=28, label="Naive (sample counts)")
ax.set_xlim(0, 1.02); ax.set_title(title); ax.set_xlabel("Accuracy")
ax.axvline(oa, color="0.5", ls="--", lw=0.8)
ax.grid(axis="x", color="0.9")
axes[0].set_yticks(y, [f"{nm} (n={n_i[i]})" for i, nm in enumerate(names)]); axes[0].invert_yaxis()
h, l = axes[1].get_legend_handles_labels()
fig.legend(h, l, loc="outside lower center", ncols=2, fontsize=8)
axes[0].text(oa + 0.01, K - 0.6, f"OA {oa:.3f}", color="0.35", fontsize=7.5)
for i in np.nonzero(~ok)[0]:
axes[0].text(0.02, i, f"not estimable (n = {n_i[i]})", va="center", fontsize=7.5, color="0.35")
fig.savefig(OUT / "03-users-producers-accuracy.png"); plt.close(fig)
# Figure 4: area estimates
fig, ax = plt.subplots(figsize=(9, 4.8), constrained_layout=True)
order = np.argsort(-res.map_km2.to_numpy())
yy = np.arange(K)
ax.barh(yy - 0.2, res.map_km2.to_numpy()[order], height=0.38, color="#bbbbbb", label="Pixel counting (map area)")
ax.barh(yy + 0.2, res.adj_km2.to_numpy()[order], height=0.38, color="#0072B2",
xerr=res.adj_ci_km2.to_numpy()[order], capsize=3, error_kw={"lw": 1, "ecolor": "black"},
label="Error-adjusted estimate with 95% CI")
ax.set_yticks(yy, [names[i] for i in order]); ax.invert_yaxis()
ax.set_xscale("log"); ax.set_xlabel("Area (km², log scale)")
for k, i in enumerate(order):
ax.text(res.map_km2[i] * 1.12, k - 0.2, f"{res.map_km2[i]:,.0f}", va="center", fontsize=7, color="0.3")
ax.text((res.adj_km2[i] + res.adj_ci_km2[i]) * 1.12, k + 0.2,
f"{res.adj_km2[i]:,.0f} ± {res.adj_ci_km2[i]:,.0f}", va="center", fontsize=7)
ax.set_xlim(0.5, 3e5)
fig.legend(loc="outside lower center", ncols=2, fontsize=8); ax.grid(axis="x", color="0.9", which="both"); ax.set_axisbelow(True)
ax.set_title("Area of each land cover class in Ireland: map vs error-adjusted")
fig.savefig(OUT / "04-area-estimates.png"); plt.close(fig)
# Figure 5: sensitivity to observation type, map homogeneity and reference homogeneity
fig, ax = plt.subplots(figsize=(8.5, 4.2), constrained_layout=True)
cols = {"Observation type": "#56B4E9", "3x3 map window": "#E69F00", "Copernicus module": "#009E73"}
yy = np.arange(len(sens))
ax.barh(yy, sens.OA, color=[cols[g] for g in sens.group])
for i, rw in sens.iterrows():
ax.text(rw.OA + 0.005, i, f"{rw.OA:.3f} (n={rw.n:,})", va="center", fontsize=7.5)
ax.set_yticks(yy, sens.subset); ax.invert_yaxis(); ax.set_xlim(0.4, 1.0)
ax.axvline(oa, color="0.4", ls="--", lw=0.8)
ax.set_xlabel("Overall accuracy (area- and design-weighted)")
ax.legend(handles=[Patch(color=c, label=g) for g, c in cols.items()] +
[Line2D([], [], color="0.4", ls="--", lw=0.8, label=f"All points ({oa:.3f})")], loc="lower right", fontsize=7.5)
fig.savefig(OUT / "05-point-vs-pixel.png"); plt.close(fig)
# Figure 6: sample-size planning
fig, axes = plt.subplots(1, 2, figsize=(12, 4.6), constrained_layout=True)
ns = np.arange(100, 3001, 50)
axes[0].plot(ns, np.sum(W * S) / np.sqrt(ns), color="#0072B2")
axes[0].axhline(target_se, color="0.5", ls="--", lw=0.8)
axes[0].axvline(n_total, color="#D55E00", lw=0.8)
axes[0].text(n_total + 40, 0.025, f"n = {n_total}", color="#D55E00")
axes[0].set_xlabel("Total sample size n"); axes[0].set_ylabel("SE of overall accuracy")
axes[0].set_title("Cochran's formula: SE(OA) ≈ Σ W$_i$ S$_i$ / √n"); axes[0].grid(color="0.9")
bw = 0.27
pal = ["#bbbbbb", "#56B4E9", "#0072B2"]
for k, (name, a) in enumerate(allocs.items()):
su = expected_se(a)[1]
axes[1].bar(np.arange(K) + (k - 1) * bw, 1.96 * su, width=bw, color=pal[k], label=f"{name}")
for i in np.nonzero(1.96 * su > 0.3)[0]:
axes[1].text(i + (k - 1) * bw, 0.285, f"{1.96 * su[i]:.2f}", rotation=90, ha="center", va="top", fontsize=7)
axes[1].set_xticks(range(K), short, rotation=45, ha="right")
axes[1].set_ylabel("Expected 95% CI half-width of user's accuracy")
axes[1].set_title(f"Allocating n = {n_total} between the map classes"); axes[1].legend(fontsize=7.5)
axes[1].set_ylim(0, 0.3); axes[1].grid(axis="y", color="0.9"); axes[1].set_axisbelow(True)
fig.savefig(OUT / "06-sample-size-planning.png"); plt.close(fig)
print("wrote 6 figures to output/")