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.
- accuracy_assessment.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython accuracy_assessment.pyaccuracy_assessment.py
"""Land cover map accuracy assessment in Python: ESA WorldCover 2021 against LUCAS 2022 fieldsurvey points in Ireland, with Olofsson et al. (2014) stratified estimators, error-adjustedarea 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 sysimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport pandas as pdimport geopandas as gpdimport requestsimport rasteriofrom rasterio.features import geometry_maskfrom rasterio.windows import Window, from_boundsfrom rasterio.enums import Resamplingfrom shapely.geometry import boxfrom pyproj import Geodimport matplotlibmatplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.colors import ListedColormap, BoundaryNormfrom matplotlib.patches import Patchfrom 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 = 42rng = 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 areasection("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.boundstiles = [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 pixelsarea_m2 = np.zeros(256)pix = np.zeros(256, dtype=np.int64)win_counts = [] # per-window class counts, for sampling in Step 8for 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 / sparsearea_m2[60] += area_m2[100]; pix[60] += pix[100]; area_m2[100] = pix[100] = 0for _, _, _, cnt in win_counts: cnt[60] += cnt[100]; cnt[100] = 0classes = [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 pointssection("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 weightssection("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.Nprint("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 pointsection("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"] = 0lucas["pure3x3"] = 0.0for 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"] = purelucas.loc[lucas.mapc == 100, "mapc"] = 60pts = 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 matrixsection("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() ** 2kappa = (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 estimatessection("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 errorscell_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 ratesp_u, oa_u, ua_u, pa_u, pref_u, _ = estimate(m, r, np.ones_like(w), W, K)
A_km2 = A_total / 1e6res = 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_dispe_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 caveatssection("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.yearprint(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 planningsection("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 pointsU_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.01n_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 arearest = n_total - comp.sum()big = W * n_total > 100comp[big] = np.round(100 + rest * W[big] / W[big].sum())allocs["Compromise (min 100)"] = compfor 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 replacementwc_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_classnew = new.sample(frac=1, random_state=SEED).reset_index(drop=True) # shuffle so interpreters can't guessnew.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())
# ---------------------------------------------------------------- Figuressection("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 pointsres_deg = 1 / 400 # 250 m: majority (mode) of 25 x 25 pixelsmx0, my0, mx1, my1 = -10.75, 51.35, -5.85, 55.45W_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.reffor 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 matrixfig, 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% CIsfig, axes = plt.subplots(1, 2, figsize=(12, 4.8), sharey=True, constrained_layout=True)y = np.arange(K)ok = n_i >= 5for 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 estimatesfig, 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 homogeneityfig, 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 planningfig, 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.27pal = ["#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/")