Source code: Object-based image analysis in Python: OBIA habitat classification with Sentinel-2

Runnable Python scripts, requirements and console output for the tutorial “Object-based image analysis in Python: OBIA habitat classification with…

Last updated

These are the exact scripts used to produce every figure and number in Object-based image analysis in Python: OBIA habitat classification with Sentinel-2. Download them, install the requirements into a fresh virtual environment and run them from this folder.

This part also needs the series' shared module and sample design (habitat_common.py, prepare_data.py and the sample CSVs) in a sibling folder named ai-habitat-classification-common.

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

obia_habitat.py

obia_habitat.py
"""Object-based image analysis (OBIA) for habitat classification with Sentinel-2 and Python.
Part 3 of the "AI habitat classification with Python" series (Dark Peak, Peak District, UK).
Segments 2023 seasonal Sentinel-2 composites into image objects with scikit-image (SLIC and Felzenszwalb),
tunes the segmentation against Living England boundaries and by blocked cross-validation, computes per-object
spectral statistics, percentiles, shape metrics and GLCM texture with NumPy label aggregation, classifies the
objects with LightGBM, and compares the result with a pixel-wise LightGBM on the same seasonal features:
accuracy on the shared test samples, salt-and-pepper noise and patch counts. Exports a GeoPackage of habitat
polygons.
Run with: python obia_habitat.py
"""
import json
import platform
import sys
import time
import warnings
from importlib.metadata import version
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import geopandas as gpd
import lightgbm as lgb
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import skimage
from matplotlib.patches import Patch
from rasterio.features import shapes
from scipy import ndimage
from shapely.geometry import shape as as_shape
from skimage.segmentation import felzenszwalb, find_boundaries, slic
HERE = Path(__file__).resolve().parent
for p in (HERE, HERE.parent / "ai-habitat-classification-common"): # habitat_common.py: shared series module
sys.path.insert(0, str(p))
import habitat_common as hc # noqa: E402
T0 = time.perf_counter()
OUT = HERE / "output"
OUT.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
pd.set_option("display.width", 160)
pd.set_option("display.max_columns", 20)
KEYS = [c["key"] for c in hc.CLASSES]
NAMES = {c["key"]: c["name"] for c in hc.CLASSES}
COLOURS = {c["key"]: c["colour"] for c in hc.CLASSES}
ID_KEY = {c["id"]: c["key"] for c in hc.CLASSES}
ROWS, COLS = hc.shape()
PX_HA = hc.RES ** 2 / 1e4 # one 10 m pixel = 0.01 ha
def section(title):
print(f"\n=== {title} ===")
def nd(a, b):
with np.errstate(divide="ignore", invalid="ignore"):
return (a - b) / (a + b)
print("=== versions ===")
print(f"python {platform.python_version()} | numpy {np.__version__} | scikit-image {skimage.__version__} | "
f"lightgbm {lgb.__version__} | geopandas {gpd.__version__} | rasterio {version('rasterio')}")
# --- Step 1: shared data -----------------------------------------------------------------------------
section("Step 1: labels, samples and seasonal composites (shared with Part 1)")
labels = hc.label_raster() # Living England 2022-23 on the 10 m grid (0 = unlabelled)
samples = hc.load_samples()
train_s, test_s = samples[samples.split == "train"], samples[samples.split == "test"]
S = hc.s2_seasonal_composites() # {"winter", "spring", "summer", "autumn"} -> Dataset
rr, cc = np.indices((ROWS, COLS))
BLK = (rr // 250) * 10 + cc // 250 # 2.5 km block of every pixel (same ids as hc.block_id)
blocks = hc.load_blocks()
TEST_BLOCKS = blocks.block_id[blocks.split == "test"].to_numpy()
in_test = np.isin(BLK, TEST_BLOCKS)
print(f"{len(train_s):,} training and {len(test_s):,} test samples; grid {COLS} x {ROWS} pixels at {hc.RES} m")
# Pixel features: 10 bands and 3 indices per season = 52 layers, float32, NaN where a season had no clear view
FEAT = {}
for s, ds in S.items():
b = {k: ds[k].values.astype("float32") for k in hc.S2_BANDS}
FEAT.update({f"{k}_{s}": b[k] for k in hc.S2_BANDS})
FEAT[f"ndvi_{s}"] = nd(b["B08"], b["B04"])
FEAT[f"ndmi_{s}"] = nd(b["B08"], b["B11"])
FEAT[f"ndwi_{s}"] = nd(b["B03"], b["B08"])
FNAMES = list(FEAT)
F = np.stack([FEAT[k] for k in FNAMES]) # (52, rows, cols)
del FEAT
print(f"{len(FNAMES)} pixel feature layers ({F.nbytes / 1e9:.2f} GB in memory)")
# --- Step 2: the segmentation input ------------------------------------------------------------------
section("Step 2: segmentation input")
SEG_LAYERS = ["ndvi_spring", "ndvi_summer", "ndmi_spring", "B08_summer", "B11_summer", "B04_summer"]
def scaled(a):
"""Stretch a layer to 0-1 between its 2nd and 98th percentiles; fill gaps with the median."""
lo, hi = np.nanpercentile(a, [2, 98])
a = np.clip((a - lo) / (hi - lo), 0, 1)
return np.where(np.isnan(a), np.nanmedian(a), a).astype("float32")
IMG = np.dstack([scaled(F[FNAMES.index(k)]) for k in SEG_LAYERS])
print(f"segmentation image: {IMG.shape[1]} x {IMG.shape[0]} pixels x {IMG.shape[2]} layers ({', '.join(SEG_LAYERS)})")
def cut_by_blocks(seg):
"""Split any object that crosses a 2.5 km block edge, so no object is shared by training and test blocks."""
key = seg.astype(np.int64) * 100 + BLK
u, inv = np.unique(key.ravel(), return_inverse=True)
return inv.reshape(seg.shape).astype(np.int32), len(u)
def segment(method, param):
if method == "slic": # param = target mean object size in hectares
seg = slic(IMG, n_segments=int(ROWS * COLS / (param / PX_HA)), compactness=0.1, channel_axis=-1,
convert2lab=False, start_label=0)
else: # param = Felzenszwalb scale (larger = bigger objects)
with warnings.catch_warnings(): # six layers is intended: a multichannel image
warnings.simplefilter("ignore", RuntimeWarning)
seg = felzenszwalb(IMG, scale=param, sigma=0.5, min_size=20, channel_axis=-1)
return cut_by_blocks(seg)
# --- Step 3: per-object features with NumPy label aggregation -----------------------------------------
PCTL_LAYERS = [f"ndvi_{s}" for s in hc.SEASONS] + [f"ndmi_{s}" for s in hc.SEASONS] + \
["B08_summer", "B11_summer", "B04_spring"]
GLCM_LAYERS = ["B08_summer", "ndvi_spring"]
def object_stats(seg, n):
"""Mean and standard deviation of every layer, and 10th/50th/90th percentiles of a subset, per object."""
flat = seg.ravel()
cnt = np.bincount(flat, minlength=n)
start = np.concatenate([[0], np.cumsum(cnt)[:-1]])
out = {}
for i, name in enumerate(FNAMES):
v = F[i].ravel()
ok = ~np.isnan(v)
nv = np.bincount(flat[ok], minlength=n)
s1 = np.bincount(flat[ok], weights=v[ok], minlength=n)
s2 = np.bincount(flat[ok], weights=v[ok].astype(float) ** 2, minlength=n)
with np.errstate(divide="ignore", invalid="ignore"):
mean = s1 / nv
out[f"{name}_mean"] = mean
out[f"{name}_std"] = np.sqrt(np.maximum(s2 / nv - mean ** 2, 0))
if name in PCTL_LAYERS:
sv = v[np.lexsort((v, flat))] # sorted by object, then value (NaN last within each object)
for q in (10, 50, 90):
k = start + np.floor(q / 100 * np.maximum(nv - 1, 0)).astype(int)
out[f"{name}_p{q}"] = np.where(nv > 0, sv[np.minimum(k, flat.size - 1)], np.nan)
return out
def object_shape(seg, n):
"""Area, perimeter, compactness (4 pi A / P^2) and elongation from second moments, in pixel units."""
flat = seg.ravel()
area = np.bincount(flat, minlength=n).astype(float)
per = np.zeros(n)
for a, b in ((seg[:, :-1], seg[:, 1:]), (seg[:-1, :], seg[1:, :])):
d = a != b
per += np.bincount(a[d], minlength=n) + np.bincount(b[d], minlength=n)
for edge in (seg[0], seg[-1], seg[:, 0], seg[:, -1]):
per += np.bincount(edge, minlength=n)
r, c = rr.ravel().astype(float), cc.ravel().astype(float)
mr, mc = np.bincount(flat, r, n) / area, np.bincount(flat, c, n) / area
vr = np.bincount(flat, r * r, n) / area - mr ** 2 + 1 / 12
vc = np.bincount(flat, c * c, n) / area - mc ** 2 + 1 / 12
cv = np.bincount(flat, r * c, n) / area - mr * mc
root = np.sqrt((vr - vc) ** 2 + 4 * cv ** 2)
return {"area_ha": area * PX_HA, "perimeter_m": per * hc.RES, "compactness": 4 * np.pi * area / per ** 2,
"elongation": np.sqrt((vr + vc + root) / np.maximum(vr + vc - root, 1 / 6))}
def object_glcm(seg, n, name, levels=16):
"""Grey-level co-occurrence (GLCM) texture inside each object, from horizontal and vertical neighbour pairs.
All objects at once: quantise the layer to 16 levels, count (object, level_i, level_j) pairs with one
bincount, make the matrix symmetric and normalise it, then compute Haralick measures per object.
"""
a = F[FNAMES.index(name)]
edges = np.nanpercentile(a, np.linspace(0, 100, levels + 1)[1:-1])
q = np.digitize(a, edges).astype(np.int64)
q[np.isnan(a)] = -1
counts = np.zeros(n * levels * levels, np.int64)
for (sa, sb), (qa, qb) in (((seg[:, :-1], seg[:, 1:]), (q[:, :-1], q[:, 1:])),
((seg[:-1, :], seg[1:, :]), (q[:-1, :], q[1:, :]))):
same = (sa == sb) & (qa >= 0) & (qb >= 0)
counts += np.bincount((sa[same].astype(np.int64) * levels + qa[same]) * levels + qb[same],
minlength=n * levels * levels)
P = counts.reshape(n, levels, levels).astype(float)
P = P + P.transpose(0, 2, 1)
tot = P.sum((1, 2), keepdims=True)
P = np.divide(P, tot, out=np.zeros_like(P), where=tot > 0)
i, j = np.meshgrid(np.arange(levels), np.arange(levels), indexing="ij")
mu = (P * i).sum((1, 2))
var = (P * (i - mu[:, None, None]) ** 2).sum((1, 2))
with np.errstate(divide="ignore", invalid="ignore"):
feats = {
"contrast": (P * (i - j) ** 2).sum((1, 2)),
"homogeneity": (P / (1 + (i - j) ** 2)).sum((1, 2)),
"entropy": -(P * np.log2(np.where(P > 0, P, 1))).sum((1, 2)),
"asm": (P ** 2).sum((1, 2)),
"correlation": ((P * (i - mu[:, None, None]) * (j - mu[:, None, None])).sum((1, 2)) / var),
}
empty = tot.ravel() == 0
return {f"glcm_{name}_{k}": np.where(empty, np.nan, v) for k, v in feats.items()}
def object_features(seg, n):
f = object_stats(seg, n)
f.update(object_shape(seg, n))
for name in GLCM_LAYERS:
f.update(object_glcm(seg, n, name))
return pd.DataFrame(f)
def family(col):
if col.startswith("glcm_"):
return "Texture (GLCM)"
if col in ("area_ha", "perimeter_m", "compactness", "elongation"):
return "Shape"
if col.endswith("_mean"):
return "Spectral mean"
return "Spread and percentiles"
# --- Step 4: classifiers and evaluation helpers -------------------------------------------------------
PARAMS = dict(n_estimators=300, learning_rate=0.05, num_leaves=15, min_child_samples=10, subsample=0.8,
subsample_freq=1, colsample_bytree=0.5, reg_lambda=1.0, random_state=hc.SEED, verbose=-1)
def model():
return lgb.LGBMClassifier(**PARAMS)
def training_objects(seg, smp):
"""One row per object that contains training samples: its majority sample class and its block fold."""
df = pd.DataFrame({"obj": seg[smp.row, smp.col], "cls": smp.class_id.to_numpy(), "fold": smp.fold.to_numpy()})
g = df.groupby("obj").agg(cls=("cls", lambda s: s.mode().iloc[0]), fold=("fold", "first"), n=("cls", "size"))
return g
def cv_objects(seg, OF, cols=None):
"""Blocked 5-fold CV on the training blocks, scored on the training SAMPLES (each takes its object's class)."""
g = training_objects(seg, train_s)
X = OF.loc[g.index] if cols is None else OF.loc[g.index, cols]
sample_obj = seg[train_s.row, train_s.col]
acc = []
for k in range(5):
m = model().fit(X[g.fold != k], g.cls[g.fold != k])
val = (train_s.fold == k).to_numpy()
objs = np.unique(sample_obj[val])
pred = pd.Series(m.predict(X.loc[objs]), index=objs)
acc.append((pred.loc[sample_obj[val]].to_numpy() == train_s.class_id.to_numpy()[val]).mean())
return np.mean(acc), np.std(acc)
def confusion(y_true, y_pred):
return pd.crosstab(pd.Categorical(y_true, KEYS), pd.Categorical(y_pred, KEYS), dropna=False)
def summarise(cm):
m = cm.to_numpy().astype(float)
n = m.sum()
oa = np.trace(m) / n
pe_ = (m.sum(0) * m.sum(1)).sum() / n ** 2
pa = np.diag(m) / m.sum(1)
ua = np.diag(m) / np.where(m.sum(0) == 0, np.nan, m.sum(0))
with np.errstate(invalid="ignore"):
f1 = np.nan_to_num(2 * pa * ua / (pa + ua))
return {"oa": oa, "kappa": (oa - pe_) / (1 - pe_), "pa": pa, "ua": ua, "f1": f1}
def block_bootstrap(correct, block, reps=2000):
rng = np.random.default_rng(hc.SEED)
ub = np.unique(block)
idx_by_block = {b: np.flatnonzero(block == b) for b in ub}
boot = [correct[np.concatenate([idx_by_block[b] for b in rng.choice(ub, ub.size)])].mean() for _ in range(reps)]
return np.percentile(boot, [2.5, 97.5])
def evaluate(pred_map, name):
"""Accuracy at the shared test samples: each test pixel takes the class of the map at its location."""
yt = test_s.class_key.to_numpy()
yp = np.array([ID_KEY.get(v, "none") for v in pred_map[test_s.row, test_s.col]])
cm = confusion(yt, yp)
res = summarise(cm)
res["ci"] = block_bootstrap(yt == yp, test_s.block_id.to_numpy())
res["macro_f1"] = float(np.mean(res["f1"]))
res["cm"] = cm
valid = in_test & (labels > 0) & (pred_map > 0)
res["w2w"] = (labels[valid] == pred_map[valid]).mean()
print(f"{name:<34} OA {res['oa']:.3f} (95% CI {res['ci'][0]:.3f}-{res['ci'][1]:.3f}) macro F1 "
f"{res['macro_f1']:.3f} kappa {res['kappa']:.3f} wall-to-wall (test blocks) {res['w2w']:.3f}")
return res
def noise(m):
"""Salt-and-pepper metrics: share of pixels outvoted by their 3 x 3 neighbourhood, and patch counts."""
counts = np.stack([ndimage.uniform_filter((m == c).astype("float32"), 3, mode="nearest") for c in hc.CLASS_IDS])
own = np.take_along_axis(counts, (np.clip(m, 1, 9) - 1)[None].astype(np.int64), 0)[0]
outvoted = (own < counts.max(0) - 1e-6) & (m > 0)
eight = np.ones((3, 3), bool)
patches = sum(ndimage.label(m == c, structure=eight)[1] for c in hc.CLASS_IDS)
single = sum(int((np.bincount(ndimage.label(m == c, structure=eight)[0].ravel())[1:] == 1).sum())
for c in hc.CLASS_IDS)
area_km2 = (m > 0).sum() * hc.RES ** 2 / 1e6
return {"outvoted_3x3": float(outvoted.sum() / (m > 0).sum()), "patches": int(patches),
"patches_per_km2": float(patches / area_km2), "single_pixel_patches": single,
"mean_patch_ha": float((m > 0).sum() * PX_HA / patches)}
# --- Step 5: tune the segmentation ------------------------------------------------------------------
section("Step 5: segmentation candidates, scored on the training blocks only")
train_px = ~in_test & (labels > 0)
near_block_edge = np.isin(rr % 250, (0, 249)) | np.isin(cc % 250, (0, 249))
def edges(a):
b = np.zeros(a.shape, bool)
d = a[:, 1:] != a[:, :-1]
b[:, 1:] |= d
b[:, :-1] |= d
d = a[1:, :] != a[:-1, :]
b[1:, :] |= d
b[:-1, :] |= d
return b
LE_EDGE = edges(labels) & train_px & ~near_block_edge # Living England class boundaries (both sides, 1 px)
print(f"Living England class-boundary pixels in training blocks: {LE_EDGE.sum():,}")
def segmentation_quality(seg, n):
"""Boundary recall (LE boundaries within 10 m of an object edge) and achievable segmentation accuracy (ASA)."""
recall = (LE_EDGE & ndimage.binary_dilation(edges(seg), np.ones((3, 3), bool))).sum() / LE_EDGE.sum()
cm = np.bincount(seg[train_px].astype(np.int64) * 10 + labels[train_px], minlength=n * 10).reshape(n, 10)
asa = cm.max(1).sum() / cm.sum() # best wall-to-wall agreement if every object took its majority class
return recall, asa
CANDIDATES = [("slic", ha) for ha in (0.25, 0.5, 1, 2, 4, 8)] + [("felzenszwalb", s) for s in (10, 25, 50, 100, 200, 400)]
rows, SEGS = [], {}
for method, param in CANDIDATES:
t = time.perf_counter()
seg, n = segment(method, param)
t_seg = time.perf_counter() - t
recall, asa = segmentation_quality(seg, n)
OF = object_features(seg, n)
cv_mean, cv_sd = cv_objects(seg, OF)
rows.append({"method": method, "param": param, "objects": n, "mean_ha": ROWS * COLS * PX_HA / n,
"boundary_recall": recall, "asa": asa, "cv_oa": cv_mean, "cv_sd": cv_sd, "seg_s": t_seg})
SEGS[(method, param)] = (seg, n, OF)
print(f"{method:<12} {param:>5g} {n:>7,} objects, mean {ROWS * COLS * PX_HA / n:5.2f} ha boundary recall "
f"{recall:.3f} ASA {asa:.3f} blocked CV OA {cv_mean:.3f} +/- {cv_sd:.3f} ({t_seg:.0f} s)")
tune = pd.DataFrame(rows)
best = tune.loc[tune.cv_oa.idxmax()]
BEST = (best.method, best.param)
seg, n_obj, OF = SEGS[BEST]
SHOW_SEGS = [k for k in (("slic", 0.5), ("felzenszwalb", 100)) if k != BEST][:1] + [BEST, ("slic", 4)]
for k in [k for k in SEGS if k not in SHOW_SEGS]:
del SEGS[k] # keep only what the figures need
print(f"chosen by blocked CV: {best.method} {best.param:g} ({n_obj:,} objects, mean {best.mean_ha:.2f} ha)")
sizes = np.bincount(seg.ravel()) * PX_HA
print(f"object size: median {np.median(sizes):.2f} ha, 5th-95th percentile {np.percentile(sizes, 5):.2f}-"
f"{np.percentile(sizes, 95):.2f} ha; {OF.shape[1]} features per object")
# --- Step 6: which object features matter? ------------------------------------------------------------
section("Step 6: feature families (blocked CV on the training blocks)")
fam = pd.Series({c: family(c) for c in OF.columns})
print(fam.value_counts().to_string())
ablation = {}
for label, keep in (("Spectral mean only", ["Spectral mean"]),
("+ spread and percentiles", ["Spectral mean", "Spread and percentiles"]),
("+ shape", ["Spectral mean", "Spread and percentiles", "Shape"]),
("+ texture (all features)", ["Spectral mean", "Spread and percentiles", "Shape", "Texture (GLCM)"])):
cols = fam.index[fam.isin(keep)].tolist()
ablation[label] = cv_objects(seg, OF, cols)
print(f"{label:<28} {len(cols):>3} features CV OA {ablation[label][0]:.3f} +/- {ablation[label][1]:.3f}")
# --- Step 7: train the object classifier and map every object -----------------------------------------
section("Step 7: object-based classification")
g = training_objects(seg, train_s)
print(f"{len(train_s):,} training samples fall in {len(g):,} objects "
f"({(g.n > 1).sum()} objects hold more than one sample)")
t = time.perf_counter()
obj_model = model().fit(OF.loc[g.index], g.cls)
proba = obj_model.predict_proba(OF)
obj_class = obj_model.classes_[proba.argmax(1)].astype("uint8")
obj_conf = proba.max(1)
obia_map = obj_class[seg]
print(f"trained on {len(g):,} objects and classified {n_obj:,} objects in {time.perf_counter() - t:.1f} s")
hc.write_geotiff(OUT / "habitat_obia_2023.tif", obia_map, nodata=0)
# --- Step 8: the pixel-based comparison --------------------------------------------------------------
section("Step 8: pixel-based LightGBM on the same 52 seasonal features")
Xp = hc.sample_values(F, train_s)
t = time.perf_counter()
pix_model = model().fit(Xp, train_s.class_id)
pix_map = np.zeros((ROWS, COLS), "uint8")
for r0 in range(0, ROWS, 250):
chunk = F[:, r0:r0 + 250].reshape(len(FNAMES), -1).T
pix_map[r0:r0 + 250] = pix_model.predict(chunk).reshape(-1, COLS)
print(f"trained on {len(train_s):,} pixels and classified {ROWS * COLS:,} pixels in {time.perf_counter() - t:.1f} s")
pix_cv = []
for k in range(5):
m = model().fit(Xp[train_s.fold != k], train_s.class_id[train_s.fold != k])
pix_cv.append((m.predict(Xp[train_s.fold == k]) == train_s.class_id[train_s.fold == k]).mean())
print(f"pixel blocked CV OA {np.mean(pix_cv):.3f} +/- {np.std(pix_cv):.3f} (objects: {best.cv_oa:.3f} +/- {best.cv_sd:.3f})")
hc.write_geotiff(OUT / "habitat_pixel_lgbm_2023.tif", pix_map, nodata=0)
# A common shortcut: classify pixels, then give each object the majority vote of its pixels
votes = np.bincount(seg.ravel().astype(np.int64) * 10 + pix_map.ravel(), minlength=n_obj * 10).reshape(n_obj, 10)
vote_map = votes[:, 1:].argmax(1).astype("uint8")[seg] + 1
# --- Step 9: accuracy on the shared test samples -----------------------------------------------------
section("Step 9: accuracy on the 2,456 test samples (27 test blocks)")
R = {"pixel": evaluate(pix_map, "Pixel LightGBM"),
"vote": evaluate(vote_map, "Pixel LightGBM + object majority"),
"obia": evaluate(obia_map, "Object-based LightGBM (OBIA)")}
print("Part 1 baseline (spectral rules) OA 0.600 (95% CI 0.527-0.664) macro F1 0.630 kappa 0.547 "
"wall-to-wall (test blocks) 0.483")
res = R["obia"]
cm_show = res["cm"].copy()
cm_show.index = [f"ref {k}" for k in KEYS]
cm_show.columns = [k.split("_")[0][:5] for k in KEYS]
print("\nconfusion matrix, object-based (rows = Living England reference, columns = OBIA map):")
print(cm_show.to_string())
per_class = pd.DataFrame({f"{m}_{s}": R[m][s] for m in ("pixel", "obia") for s in ("pa", "ua", "f1")},
index=[NAMES[k] for k in KEYS])
print("\nper-class producer's (pa) and user's (ua) accuracy and F1, pixel versus object:")
print(per_class.round(3).to_string())
# --- Step 10: salt-and-pepper noise ------------------------------------------------------------------
section("Step 10: map noise over the whole study area")
NOISE = {"Living England (reference)": noise(np.where(labels > 0, labels, 0).astype("uint8")),
"Pixel LightGBM": noise(pix_map), "Pixel + object majority": noise(vote_map), "Object-based (OBIA)": noise(obia_map)}
noise_df = pd.DataFrame(NOISE).T
noise_df["outvoted_3x3"] = (100 * noise_df["outvoted_3x3"]).round(2)
print(noise_df.rename(columns={"outvoted_3x3": "outvoted_3x3_%"}).round(2).to_string())
area = pd.DataFrame({m: [(mp == c["id"]).sum() * hc.RES ** 2 / 1e6 for c in hc.CLASSES]
for m, mp in (("pixel_km2", pix_map), ("obia_km2", obia_map), ("living_england_km2", labels))},
index=[NAMES[k] for k in KEYS])
print("\nmapped area by class:")
print(area.round(1).to_string())
# --- Step 11: export habitat polygons ----------------------------------------------------------------
section("Step 11: export the objects as a GeoPackage of habitat polygons")
t = time.perf_counter()
geoms, ids = zip(*((as_shape(gm), int(v)) for gm, v in shapes(seg, transform=hc.transform())))
gdf = gpd.GeoDataFrame({"object_id": np.array(ids, "int32")}, geometry=list(geoms), crs=hc.CRS)
gdf = gdf.dissolve("object_id", as_index=False) # an object cut by a block edge can have two parts
ids = gdf.object_id.to_numpy()
gdf["class_id"] = obj_class[ids]
gdf["class_key"] = [ID_KEY[v] for v in gdf.class_id]
gdf["habitat"] = [NAMES[k] for k in gdf.class_key]
gdf["confidence"] = obj_conf[ids].round(3)
gdf["area_ha"] = OF.area_ha.to_numpy()[ids].round(2)
gdf["ndvi_summer_mean"] = OF.ndvi_summer_mean.to_numpy()[ids].round(3)
gdf["block_split"] = np.where(np.isin(np.bincount(seg.ravel(), BLK.ravel(), n_obj)[ids] /
np.bincount(seg.ravel(), minlength=n_obj)[ids], TEST_BLOCKS), "test", "train")
GPKG = OUT / "habitat_obia_2023.gpkg"
GPKG.unlink(missing_ok=True)
gdf.to_file(GPKG, layer="habitat_objects", driver="GPKG")
geoms, vals = zip(*((as_shape(gm), int(v)) for gm, v in shapes(obia_map, mask=obia_map > 0, transform=hc.transform())))
patches = gpd.GeoDataFrame({"class_id": np.array(vals, "uint8")}, geometry=list(geoms), crs=hc.CRS)
patches["habitat"] = [NAMES[ID_KEY[v]] for v in patches.class_id]
patches["area_ha"] = (patches.area / 1e4).round(2)
patches.to_file(GPKG, layer="habitat_patches", driver="GPKG")
print(f"{len(gdf):,} object polygons and {len(patches):,} dissolved habitat patches written to {GPKG.name} "
f"({GPKG.stat().st_size / 1e6:.0f} MB) in {time.perf_counter() - t:.0f} s")
print(gdf.drop(columns="geometry").head(3).to_string(index=False))
RUNTIME = time.perf_counter() - T0
print(f"\nruntime up to the export: {RUNTIME / 60:.1f} min (composites were already cached by prepare_data.py)")
# --- metrics.json for the series comparison table -----------------------------------------------------
metrics = {
"method": f"Object-based LightGBM on {best.method} segments ({best.param:g}, {n_obj} objects, mean {best.mean_ha:.2f} ha) "
f"with per-object spectral statistics, percentiles, shape and GLCM texture from 2023 seasonal Sentinel-2",
"oa": round(float(res["oa"]), 4), "oa_ci95": [round(float(v), 4) for v in res["ci"]],
"macro_f1": round(res["macro_f1"], 4), "kappa": round(float(res["kappa"]), 4),
"per_class": {k: {"pa": round(float(res["pa"][i]), 4), "ua": round(float(res["ua"][i]), 4),
"f1": round(float(res["f1"][i]), 4)} for i, k in enumerate(KEYS)},
"n_test": int(len(test_s)), "runtime_s": round(RUNTIME, 1),
"wall_to_wall_test_blocks": round(float(res["w2w"]), 4),
"cv_oa_train_blocks": [round(float(best.cv_oa), 4), round(float(best.cv_sd), 4)],
"pixel_comparison": {m: {"oa": round(float(R[m]["oa"]), 4), "oa_ci95": [round(float(v), 4) for v in R[m]["ci"]],
"macro_f1": round(R[m]["macro_f1"], 4), "kappa": round(float(R[m]["kappa"]), 4),
"wall_to_wall_test_blocks": round(float(R[m]["w2w"]), 4)} for m in ("pixel", "vote")},
"noise": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in NOISE.items()},
}
(OUT / "metrics.json").write_text(json.dumps(metrics, indent=2))
# --- Figures -----------------------------------------------------------------------------------------
ext = (hc.AOI_BOUNDS[0], hc.AOI_BOUNDS[2], hc.AOI_BOUNDS[1], hc.AOI_BOUNDS[3])
rgb = np.dstack([S["summer"][b].values for b in ("B04", "B03", "B02")])
rgb = np.nan_to_num(np.clip(rgb / np.nanpercentile(rgb, 99), 0, 1) ** 0.8, nan=1.0)
cmap, norm = hc.class_cmap()
legend = [Patch(color=COLOURS[k], label=NAMES[k]) for k in KEYS]
le = hc.load_labels()
SHORT = {"slic": "SLIC", "felzenszwalb": "Felzenszwalb"}
def km_axes(ax, step=5000):
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
ax.set_xticks(np.arange(np.ceil(x0 / step) * step, x1 + 1, step))
ax.set_yticks(np.arange(np.ceil(y0 / step) * step, y1 + 1, step))
ax.set_xticklabels([f"{v / 1000:g}" for v in ax.get_xticks()])
ax.set_yticklabels([f"{v / 1000:g}" for v in ax.get_yticks()])
ax.set_xlabel("Easting (km, British National Grid)")
ax.set_ylabel("Northing (km)")
def window(x0, y0, x1, y1):
c0, c1 = int((x0 - hc.AOI_BOUNDS[0]) / hc.RES), int((x1 - hc.AOI_BOUNDS[0]) / hc.RES)
r0, r1 = int((hc.AOI_BOUNDS[3] - y1) / hc.RES), int((hc.AOI_BOUNDS[3] - y0) / hc.RES)
return (slice(r0, r1), slice(c0, c1)), (x0, x1, y0, y1)
def overlay_edges(ax, lab, wext, colour):
e = find_boundaries(lab, mode="inner")
ax.imshow(np.ma.masked_where(~e, np.ones(e.shape)), cmap=matplotlib.colors.ListedColormap([colour]),
extent=wext, interpolation="nearest", vmin=0, vmax=1)
# Figure 1: segmentations in a 2.3 x 1.7 km window in the Edale valley (inside one block: no block-edge cuts)
W1, wext1 = window(410100, 385100, 412400, 386800)
fig, axes = plt.subplots(2, 2, figsize=(11.5, 8.6), sharex=True, sharey=True)
axes = axes.ravel()
axes[0].imshow(rgb[W1], extent=wext1)
le.cx[wext1[0]:wext1[1], wext1[2]:wext1[3]].boundary.clip_by_rect(*[wext1[i] for i in (0, 2, 1, 3)]).plot(
ax=axes[0], color="#fdae61", lw=0.7)
axes[0].set_title("Summer 2023 composite with Living England polygon boundaries")
panels = [(k, "#ffffbf") for k in SHOW_SEGS]
for ax, (key, col) in zip(axes[1:], panels):
s_, n_, _ = SEGS[key]
ax.imshow(rgb[W1], extent=wext1)
overlay_edges(ax, s_[W1], wext1, col)
row = tune[(tune.method == key[0]) & (tune.param == key[1])].iloc[0]
tag = " (chosen)" if key == BEST else ""
unit = " ha target" if key[0] == "slic" else " scale"
ax.set_title(f"{SHORT[key[0]]} {key[1]:g}{unit}{tag}: mean {row.mean_ha:.2f} ha, boundary recall "
f"{row.boundary_recall:.0%}", fontsize=8.5)
for ax in axes:
ax.set_xlim(wext1[0], wext1[1])
ax.set_ylim(wext1[2], wext1[3])
km_axes(ax, 500)
for ax in axes[:2]:
ax.set_xlabel("")
for ax in axes[1::2]:
ax.set_ylabel("")
fig.tight_layout()
fig.savefig(OUT / "01-segmentation-zoom.png")
plt.close(fig)
# Figure 2: segment tuning
fig, axes = plt.subplots(1, 3, figsize=(13, 4.2))
for method, col, mk in (("slic", "#2b83ba", "o"), ("felzenszwalb", "#d7191c", "s")):
d = tune[tune.method == method].sort_values("mean_ha")
for ax, ycol in zip(axes, ("boundary_recall", "asa", "cv_oa")):
ax.plot(d.mean_ha, d[ycol], marker=mk, color=col, label=SHORT[method])
if ycol == "cv_oa":
ax.fill_between(d.mean_ha, d.cv_oa - d.cv_sd, d.cv_oa + d.cv_sd, color=col, alpha=0.15)
axes[2].axhline(np.mean(pix_cv), color="#555555", ls="--", lw=1)
axes[2].text(tune.mean_ha.max(), np.mean(pix_cv) + 0.003, f"pixel LightGBM {np.mean(pix_cv):.3f}", fontsize=7.5,
color="#555555", ha="right", va="bottom")
axes[2].plot(best.mean_ha, best.cv_oa, "*", ms=14, color="#fdae61", mec="black", label="chosen")
titles = ("Boundary recall: Living England edges\nwithin 10 m of an object edge",
"Achievable accuracy (ASA): agreement if each\nobject took its majority Living England class",
"Blocked 5-fold CV accuracy of the\nobject classifier (training blocks)")
for ax, t_ in zip(axes, titles):
ax.set_xscale("log")
ax.set_xlabel("Mean object size (ha, log scale)")
ax.set_title(t_, fontsize=8.5)
ax.grid(alpha=0.3)
axes[0].legend(fontsize=8)
axes[2].legend(fontsize=8, loc="lower right")
fig.tight_layout()
fig.savefig(OUT / "02-segment-tuning.png")
plt.close(fig)
# Figure 3: feature importance
imp = pd.Series(obj_model.booster_.feature_importance("gain"), index=OF.columns)
imp = imp / imp.sum()
fam_share = imp.groupby(fam).sum().sort_values()
top = imp.sort_values().tail(15)
FAM_COL = {"Spectral mean": "#2b83ba", "Spread and percentiles": "#abdda4", "Shape": "#fdae61", "Texture (GLCM)": "#d7191c"}
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.8), gridspec_kw={"width_ratios": [1.5, 1]})
axes[0].barh(top.index, top.values, color=[FAM_COL[fam[c]] for c in top.index], edgecolor="#333333", lw=0.4)
axes[0].set_xlabel("Share of total gain")
axes[0].set_title("The 15 most important object features", fontsize=9)
axes[0].tick_params(axis="y", labelsize=7.5)
labels_abl = list(ablation)
axes[1].barh(range(len(labels_abl)), [ablation[k][0] for k in labels_abl], xerr=[ablation[k][1] for k in labels_abl],
color="#abdda4", edgecolor="#333333", lw=0.4, capsize=3)
lo_x = min(v[0] - v[1] for v in ablation.values()) - 0.03
for i, k in enumerate(labels_abl):
axes[1].text(lo_x + 0.003, i, f"{ablation[k][0]:.3f}", va="center", ha="left", fontsize=8)
axes[1].set_yticks(range(len(labels_abl)), labels_abl, fontsize=8)
axes[1].invert_yaxis()
axes[1].set_xlim(lo_x, max(v[0] + v[1] for v in ablation.values()) + 0.01)
axes[1].set_xlabel("Blocked CV accuracy (mean and s.d. of 5 folds)")
axes[1].set_title("Adding feature families one at a time\n(share of gain: " +
", ".join(f"{k.split(' (')[0].lower()} {v:.0%}" for k, v in fam_share.sort_values(ascending=False).items()) + ")",
fontsize=8.5)
axes[0].legend(handles=[Patch(color=c, label=k) for k, c in FAM_COL.items()], fontsize=7.5, loc="lower right")
fig.tight_layout()
fig.savefig(OUT / "03-object-features.png")
plt.close(fig)
# Figure 4: wall-to-wall OBIA map against Living England
fig, axes = plt.subplots(1, 2, figsize=(13, 5.6), sharey=True)
axes[0].imshow(np.ma.masked_equal(obia_map, 0), cmap=cmap, norm=norm, extent=ext, interpolation="nearest")
axes[0].set_title(f"Object-based map ({n_obj:,} objects), test-sample OA {res['oa']:.1%}")
axes[1].imshow(np.ma.masked_equal(labels, 0), cmap=cmap, norm=norm, extent=ext, interpolation="nearest")
both = (labels > 0) & (obia_map > 0)
axes[1].set_title(f"Living England 2022-23 (pixel agreement with OBIA: {(labels[both] == obia_map[both]).mean():.0%})")
for ax in axes:
km_axes(ax)
axes[1].set_ylabel("")
fig.legend(handles=legend, loc="lower center", ncol=9, fontsize=8, frameon=False, bbox_to_anchor=(0.5, -0.04))
fig.savefig(OUT / "04-obia-map-vs-living-england.png")
plt.close(fig)
# Figure 5: salt and pepper, zoomed: the Edale valley and the southern edge of Kinder Scout
W2, wext2 = window(409000, 384000, 413000, 387000)
fig, axes = plt.subplots(2, 2, figsize=(11.5, 9), sharex=True, sharey=True)
axes = axes.ravel()
axes[0].imshow(rgb[W2], extent=wext2)
axes[0].set_title("Summer 2023 Sentinel-2 composite")
for ax, (mp, t_) in zip(axes[1:], ((pix_map, "Pixel LightGBM"), (obia_map, "Object-based (OBIA)"),
(labels, "Living England 2022-23"))):
ax.imshow(np.ma.masked_equal(mp[W2], 0), cmap=cmap, norm=norm, extent=wext2, interpolation="nearest")
if mp is not labels:
nz = noise(mp[W2])
t_ += f": {nz['outvoted_3x3']:.1%} of pixels outvoted, {nz['patches_per_km2']:.0f} patches per km²"
ax.set_title(t_, fontsize=8.5)
for ax in axes:
km_axes(ax, 1000)
for ax in axes[:2]:
ax.set_xlabel("")
for ax in axes[1::2]:
ax.set_ylabel("")
fig.legend(handles=legend, loc="lower center", ncol=5, fontsize=8, frameon=False, bbox_to_anchor=(0.5, -0.06))
fig.tight_layout()
fig.savefig(OUT / "05-salt-and-pepper-zoom.png")
plt.close(fig)
# Figure 6: confusion matrix of the object-based map
m = res["cm"].to_numpy()
rown = m / m.sum(1, keepdims=True)
fig, ax = plt.subplots(figsize=(8.6, 7.4))
ax.imshow(rown, cmap="YlGnBu", vmin=0, vmax=1)
for i in range(len(KEYS)):
for j in range(len(KEYS)):
if m[i, j]:
ax.text(j, i, m[i, j], ha="center", va="center", fontsize=8, color="white" if rown[i, j] > 0.55 else "black")
short = [NAMES[k].replace(" woodland", "").replace(" grassland", " grass") for k in KEYS]
ax.set_xticks(range(len(KEYS)), [f"{s}\nUA {u:.0%}" for s, u in zip(short, res["ua"])], rotation=45, ha="right",
fontsize=7.5)
ax.set_yticks(range(len(KEYS)), [f"{s} PA {p:.0%}" for s, p in zip(short, res["pa"])], fontsize=7.5)
ax.set_xlabel("Object-based map (UA = user's accuracy)")
ax.set_ylabel("Living England reference (PA = producer's accuracy)")
ax.set_title(f"Test blocks: overall accuracy {res['oa']:.1%} (block-bootstrap 95% CI {res['ci'][0]:.1%} to "
f"{res['ci'][1]:.1%}), kappa {res['kappa']:.2f}", fontsize=9)
fig.savefig(OUT / "06-confusion-matrix.png")
plt.close(fig)
# Figure 7: pixel versus object, accuracy and noise
fig, axes = plt.subplots(1, 3, figsize=(13, 4.3))
names = ["Part 1 rules", "Pixel LightGBM", "Pixel + object\nmajority", "Object-based\n(OBIA)"]
oas = [0.600, R["pixel"]["oa"], R["vote"]["oa"], R["obia"]["oa"]]
cis = [(0.527, 0.664), R["pixel"]["ci"], R["vote"]["ci"], R["obia"]["ci"]]
f1s = [0.630, R["pixel"]["macro_f1"], R["vote"]["macro_f1"], R["obia"]["macro_f1"]]
cols = ["#bababa", "#d7191c", "#fdae61", "#2b83ba"]
x = np.arange(4)
axes[0].bar(x - 0.18, oas, 0.36, yerr=np.array([[o - c[0], c[1] - o] for o, c in zip(oas, cis)]).T, capsize=3,
color=cols, edgecolor="#333333", lw=0.4, label="Overall accuracy (block-bootstrap 95% CI)")
axes[0].bar(x + 0.18, f1s, 0.36, color=cols, alpha=0.55, edgecolor="#333333", lw=0.4, hatch="///", label="Macro F1")
for xi, v in zip(x, oas):
axes[0].text(xi - 0.18, 0.05, f"{v:.3f}", ha="center", fontsize=7.5, rotation=90, color="black")
axes[0].set_xticks(x, names, fontsize=7.5)
axes[0].set_ylim(0, 1)
axes[0].set_title("Accuracy on the 2,456 test samples", fontsize=9)
axes[0].legend(fontsize=7, loc="upper left")
nk = ["Pixel LightGBM", "Pixel + object majority", "Object-based (OBIA)", "Living England (reference)"]
nnames = ["Pixel\nLightGBM", "Pixel + object\nmajority", "Object-based\n(OBIA)", "Living\nEngland"]
ncols = ["#d7191c", "#fdae61", "#2b83ba", "#bababa"]
for ax, key, t_, fmt in ((axes[1], "outvoted_3x3", "Pixels outvoted by their 3 x 3 neighbours", "{:.1%}"),
(axes[2], "patches_per_km2", "Habitat patches per km²", "{:,.0f}")):
vals = [NOISE[k][key] for k in nk]
ax.bar(range(4), vals, color=ncols, edgecolor="#333333", lw=0.4)
for i, v in enumerate(vals):
ax.text(i, v, fmt.format(v), ha="center", va="bottom", fontsize=8)
ax.set_xticks(range(4), nnames, fontsize=7.5)
ax.set_title(t_ + ", whole study area", fontsize=9)
ax.set_ylim(0, max(vals) * 1.15)
axes[1].yaxis.set_major_formatter(matplotlib.ticker.PercentFormatter(1.0, decimals=0))
fig.tight_layout()
fig.savefig(OUT / "07-pixel-vs-object.png")
plt.close(fig)
print(f"saved 7 figures, 2 GeoTIFFs, {GPKG.name} and metrics.json to {OUT.name}/")