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.
- obia_habitat.py
- requirements.txt
- console.txt: the console output recorded when we ran it
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.
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython obia_habitat.pyobia_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-objectspectral statistics, percentiles, shape metrics and GLCM texture with NumPy label aggregation, classifies theobjects 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 habitatpolygons.Run with: python obia_habitat.py"""import jsonimport platformimport sysimport timeimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import matplotlib
matplotlib.use("Agg")import geopandas as gpdimport lightgbm as lgbimport matplotlib.patheffects as peimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport skimagefrom matplotlib.patches import Patchfrom rasterio.features import shapesfrom scipy import ndimagefrom shapely.geometry import shape as as_shapefrom skimage.segmentation import felzenszwalb, find_boundaries, slic
HERE = Path(__file__).resolve().parentfor 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"} -> Datasetrr, 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 viewFEAT = {}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 FEATprint(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 needprint(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_HAprint(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 pixelsvotes = 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).Tnoise_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 partsids = 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() - T0print(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 tuningfig, 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 importanceimp = 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.03for 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 Englandfig, 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 ScoutW2, 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 mapm = 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 noisefig, 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}/")