Source code: Deep learning inference on large rasters: sliding-window prediction, COGs and polygons in Python

Runnable Python scripts, requirements and console output for the tutorial “Deep learning inference on large rasters: sliding-window prediction, COGs and…

Last updated

These are the exact scripts used to produce every figure and number in Deep learning inference on large rasters: sliding-window prediction, COGs and polygons in Python. Download them, install the requirements into a fresh virtual environment and run them from this folder.

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

predict_large_raster.py

predict_large_raster.py
"""Deep learning for satellite imagery, part 3: run a trained U-Net over a large, unseen Sentinel-2 raster.
Streams a Sentinel-2 L2A summer 2021 composite of Leicester (UK), an area the model never saw, writes it as a Cloud
Optimised GeoTIFF, and runs the U-Net from part 2 over it with tiled sliding-window inference: overlapping 256 px
windows read straight from the GeoTIFF, blended with a linear-ramp weight so tile edges leave no seams. Writes the
class map and a confidence layer as COGs, vectorises the classes to simplified polygons in a GeoPackage, and
compares everything with the same reference labels used for training (Overture buildings over ESA WorldCover).
Run with: python predict_large_raster.py (it runs parts 1 and 2 first if the model is missing)
"""
import os
import platform
import subprocess
import sys
import time
import urllib.request
from importlib.metadata import version
from pathlib import Path
import dask
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import odc.stac
import pandas as pd
import planetary_computer
import pystac_client
import rasterio
import rasterio.features
import rasterio.shutil
import segmentation_models_pytorch as smp
import shapely
import torch
import xarray as xr
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch, Rectangle
from odc.geo.geobox import GeoBox
from overturemaps import core as overture
from pyproj import Transformer
from rasterio.transform import from_origin
from rasterio.windows import Window
from scipy import ndimage
from shapely.geometry import box, shape
HERE = Path(__file__).parent
OUT, DATA = HERE / "output", HERE / "data"
for d in (OUT, DATA):
d.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
pd.set_option("display.width", 140)
dask.config.set(scheduler="threads", num_workers=8)
torch.set_num_threads(8)
print("=== versions ===")
print(f"python {platform.python_version()} | torch {torch.__version__} | segmentation-models-pytorch {smp.__version__} | "
f"rasterio {rasterio.__version__} (GDAL {rasterio.__gdal_version__}) | geopandas {gpd.__version__} | "
f"shapely {shapely.__version__} | odc-stac {odc.stac.__version__}")
def section(title: str) -> None:
print(f"\n=== {title} ===")
# --- Step 1: load the model from part 2 ----------------------------------------------------------
PART2 = HERE.parent / "train-unet-satellite-segmentation-pytorch"
CKPT = Path(os.environ.get("MODEL_PATH", PART2 / "output" / "unet_resnet18_s2_nottingham.pt"))
if not CKPT.exists():
script = PART2 / "train_unet.py"
if not script.exists(): # skipped part 2: fetch its script from the site
PART2.mkdir(parents=True, exist_ok=True)
url = os.environ.get("TGIS_CODE_URL", "https://www.technicalgis.com/code")
urllib.request.urlretrieve(f"{url}/train-unet-satellite-segmentation-pytorch/train_unet.py", script)
print(f"model not found: running {script.name} (part 2, which runs part 1 if needed) first")
subprocess.run([sys.executable, str(script)], check=True, cwd=PART2)
ckpt = torch.load(CKPT, map_location="cpu", weights_only=False)
model = smp.Unet(ckpt["encoder"], encoder_weights=None, in_channels=ckpt["in_channels"], classes=len(ckpt["classes"]))
model.load_state_dict(ckpt["state_dict"])
model.eval()
CLASSES, COLOURS, BANDS = ckpt["classes"], ckpt["colours"], ckpt["bands"]
NC, IGNORE = len(CLASSES), 255
section("model")
print(f"{CKPT.name}: U-Net / {ckpt['encoder']}, {ckpt['in_channels']} input channels, classes {CLASSES}")
def to_input(x: np.ndarray) -> torch.Tensor:
"""Same preprocessing as in training: reflectance / 10000, with a zero channel where B10 would be."""
x = torch.from_numpy(x.astype("float32") / ckpt["scale"])
i = ckpt["b10_zero_index"]
return torch.cat([x[..., :i, :, :], torch.zeros_like(x[..., :1, :, :]), x[..., i:, :, :]], dim=-3)
# --- Step 2: a Sentinel-2 composite of a new area, saved as a Cloud Optimised GeoTIFF ---------------
NAME = "leicester"
CENTRE = (-1.13, 52.635) # Leicester, about 35 km south of the Nottingham training area
SIZE_PX, RES, CRS, YEAR = 3000, 10, "EPSG:32630", 2021
cx, cy = Transformer.from_crs(4326, CRS, always_xy=True).transform(*CENTRE)
size = SIZE_PX * RES
x0, y1 = round(cx - size / 2, -1), round(cy + size / 2, -1)
geobox = GeoBox.from_bbox((x0, y1 - size, x0 + size, y1), crs=CRS, resolution=RES)
TRANSFORM = from_origin(x0, y1, RES, RES)
H = W = SIZE_PX
bbox_ll = tuple(gpd.GeoSeries([box(x0, y1 - size, x0 + size, y1)], crs=CRS).to_crs(4326).total_bounds)
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace)
section("new area")
print(f"{NAME}: {H} x {W} pixels of {RES} m ({size / 1000:.0f} km square); lon/lat bounds "
f"{tuple(round(float(v), 3) for v in bbox_ll)}")
s2_cog = DATA / f"s2_{NAME}_{YEAR}_cog.tif"
if not s2_cog.exists():
items = catalog.search(collections=["sentinel-2-l2a"], bbox=bbox_ll, datetime=f"{YEAR}-05-01/{YEAR}-08-31",
query={"eo:cloud_cover": {"lt": 40}}).item_collection()
odc.stac.configure_rio(cloud_defaults=True)
ds = odc.stac.load(items, bands=BANDS + ["SCL"], geobox=geobox, groupby="solar_day", dtype="uint16", nodata=0,
chunks={"time": 1, "x": 1500, "y": 1500}, resampling={"*": "bilinear", "SCL": "nearest"})
base = {i.datetime.date(): float(i.properties["s2:processing_baseline"]) for i in items}
offset = xr.DataArray([1000 if base[pd.Timestamp(t).date()] >= 4.0 else 0 for t in ds.time.values],
dims="time", coords={"time": ds.time})
scl = ds["SCL"].compute()
cloudy = ndimage.binary_dilation(np.isin(scl.values, [3, 8, 9, 10]), iterations=3, axes=(1, 2))
clear = scl.isin([4, 5, 6, 7]) & ~xr.DataArray(cloudy, coords=scl.coords, dims=scl.dims)
frac = clear.mean(("y", "x")).to_series()
keep = frac.index[frac >= 0.2]
print(f"{len(items)} items, {len(frac)} dates, {len(keep)} at least 20% clear: "
f"{', '.join(f'{t:%d %b}' for t in keep)}")
refl = (ds[BANDS].sel(time=keep).astype("float32") - offset.sel(time=keep))
med = refl.where(clear.sel(time=keep)).chunk({"time": -1, "y": 500, "x": 500}).median("time").compute()
raw = refl.where(ds["SCL"].sel(time=keep) > 0).chunk({"time": -1, "y": 500, "x": 500}).median("time").compute()
med = med.fillna(raw)
stack = np.stack([med[b].values for b in BANDS])
stack = np.where(np.isnan(stack), 0, np.clip(stack, 1, 65535)).astype("uint16")
with rasterio.open(s2_cog, "w", driver="COG", height=H, width=W, count=len(BANDS), dtype="uint16", crs=CRS,
transform=TRANSFORM, nodata=0, compress="deflate", blocksize=512) as dst:
dst.write(stack)
dst.descriptions = tuple(BANDS)
with rasterio.open(s2_cog) as src:
print(f"{s2_cog.name}: {src.count} bands, {src.profile['blockxsize']} px internal tiles, "
f"overviews {src.overviews(1)}, {s2_cog.stat().st_size / 1e6:.0f} MB on disk")
# --- Step 3: tiled sliding-window inference with overlap and blending -------------------------------
TILE, OVERLAP = 256, 64
STRIDE = TILE - OVERLAP
def starts(n: int) -> list[int]:
"""Window origins every STRIDE pixels, with the last window flush with the raster edge."""
s = list(range(0, n - TILE, STRIDE))
return s + [n - TILE]
# A weight that ramps linearly from near 0 at a window's edge to 1 at OVERLAP pixels in, so each pixel's
# prediction comes mostly from the windows in which it sits far from the edge.
ramp = np.minimum(np.arange(TILE) + 1, OVERLAP) / OVERLAP
ramp = np.minimum(ramp, ramp[::-1])
WEIGHT = np.outer(ramp, ramp).astype("float32")
def predict(path: Path, overlap: bool) -> tuple[np.ndarray, np.ndarray, float]:
"""Return (class map, max softmax probability, seconds). Reads one window at a time from the GeoTIFF."""
t0 = time.time()
rows = starts(H) if overlap else list(range(0, H, TILE))
cols = starts(W) if overlap else list(range(0, W, TILE))
probs = np.zeros((NC, H, W), "float32")
wsum = np.zeros((H, W), "float32")
with rasterio.open(path) as src, torch.no_grad():
for r in rows:
for c in cols:
x = src.read(window=Window(c, r, TILE, TILE), boundless=True, fill_value=0) # pads past the edge
h, w = min(TILE, H - r), min(TILE, W - c)
p = torch.softmax(model(to_input(x[None])), 1)[0].numpy()[:, :h, :w]
wt = WEIGHT[:h, :w] if overlap else 1.0
probs[:, r:r + h, c:c + w] += p * wt
wsum[r:r + h, c:c + w] += wt
probs /= wsum
return probs.argmax(0).astype("uint8"), probs.max(0), time.time() - t0
pred, conf, secs = predict(s2_cog, overlap=True)
naive, _, secs_naive = predict(s2_cog, overlap=False)
n_win = len(starts(H)) * len(starts(W))
section("sliding-window inference")
print(f"blended: {n_win} windows of {TILE} px with {OVERLAP} px overlap in {secs:.0f} s "
f"({H * W / 1e6 / secs:.2f} Mpx/s, {size ** 2 / 1e6 / secs:.0f} km2/s)")
print(f"naive: {int(np.ceil(H / TILE)) ** 2} windows, no overlap, in {secs_naive:.0f} s")
edge = np.zeros((H, W), bool)
for k in range(TILE, H, TILE):
edge[k - 8:k + 8, :] = True
edge[:, k - 8:k + 8] = True
diff = naive != pred
print(f"naive and blended maps differ on {diff.mean():.1%} of pixels: {diff[edge].mean():.1%} within 8 px of a "
f"naive tile edge, {diff[~edge].mean():.1%} elsewhere")
# --- Step 4: write the class map and confidence as Cloud Optimised GeoTIFFs --------------------------
cog = dict(driver="COG", height=H, width=W, count=1, crs=CRS, transform=TRANSFORM, compress="deflate",
blocksize=512)
class_cog, conf_cog = OUT / f"unet_classes_{NAME}_{YEAR}.tif", OUT / f"unet_confidence_{NAME}_{YEAR}.tif"
# The COG driver builds overviews and cannot take a colour map afterwards, so write a GTiff first and translate.
tmp = DATA / "classes_tmp.tif"
with rasterio.open(tmp, "w", driver="GTiff", height=H, width=W, count=1, dtype="uint8", crs=CRS,
transform=TRANSFORM, nodata=IGNORE) as dst:
dst.write(pred, 1)
dst.write_colormap(1, {i: tuple(int(c[k:k + 2], 16) for k in (1, 3, 5)) + (255,) for i, c in enumerate(COLOURS)})
dst.update_tags(1, **{f"class_{i}": n for i, n in enumerate(CLASSES)})
rasterio.shutil.copy(tmp, class_cog, driver="COG", compress="deflate", blocksize=512, overview_resampling="mode")
tmp.unlink()
with rasterio.open(conf_cog, "w", dtype="uint8", nodata=0, overview_resampling="average", **cog) as dst:
dst.write(np.clip(np.round(conf * 100), 1, 100).astype("uint8"), 1)
section("Cloud Optimised GeoTIFFs")
for f in (class_cog, conf_cog):
with rasterio.open(f) as src:
print(f"{f.name}: {src.profile['driver']}, {src.dtypes[0]}, tiles {src.block_shapes[0]}, "
f"overviews {src.overviews(1)}, layout {src.tags(ns='IMAGE_STRUCTURE').get('LAYOUT')}, "
f"{f.stat().st_size / 1e6:.1f} MB")
print(f"mean confidence {conf.mean():.2f}; pixels below 0.6: {(conf < 0.6).mean():.1%}")
# --- Step 5: vectorise to simplified polygons and save a GeoPackage --------------------------------
t0 = time.time()
MIN_PX = 4 # drop specks smaller than 4 pixels (400 m2)
sieved = rasterio.features.sieve(pred, size=MIN_PX, connectivity=4)
polys = [(shape(g), int(v)) for g, v in rasterio.features.shapes(sieved, transform=TRANSFORM, connectivity=4)]
gdf = gpd.GeoDataFrame({"class_id": [v for _, v in polys]}, geometry=[g for g, _ in polys], crs=CRS)
gdf["class"] = gdf["class_id"].map(dict(enumerate(CLASSES)))
n_raw = int(shapely.get_num_coordinates(gdf.geometry.values).sum())
gdf["geometry"] = gdf.simplify(RES / 2, preserve_topology=True) # 5 m: removes the pixel staircase
n_simple = int(shapely.get_num_coordinates(gdf.geometry.values).sum())
gdf["area_m2"] = gdf.area.round(0)
gpkg = OUT / f"unet_landcover_{NAME}_{YEAR}.gpkg"
gdf.to_file(gpkg, layer="landcover", driver="GPKG")
section("vectorise")
print(f"sieved specks < {MIN_PX} px: {(sieved != pred).mean():.1%} of pixels changed")
print(f"{len(gdf):,} polygons in {time.time() - t0:.0f} s; vertices {n_raw:,} -> {n_simple:,} after simplify "
f"({1 - n_simple / n_raw:.0%} fewer)")
print(gdf.groupby("class").agg(polygons=("area_m2", "size"), area_km2=("area_m2", lambda a: a.sum() / 1e6),
median_m2=("area_m2", "median")).reindex(CLASSES).round(2).to_string())
print(f"saved {gpkg.name} ({gpkg.stat().st_size / 1e6:.1f} MB)")
# --- Step 6: reference labels for the new area and an honest score ---------------------------------
wc_items = catalog.search(collections=["esa-worldcover"], bbox=bbox_ll,
query={"esa_worldcover:product_version": {"eq": "2.0.0"}}).item_collection()
wc = odc.stac.load(wc_items, bands=["map"], geobox=geobox, resampling="nearest")["map"].isel(time=0).values
bld_file = DATA / f"overture_buildings_{NAME}.parquet"
if not bld_file.exists():
b = overture.geodataframe("building", bbox=bbox_ll, stac=True).set_crs(4326, allow_override=True)
b[["id", "geometry"]].to_parquet(bld_file)
bld = gpd.read_parquet(bld_file).to_crs(CRS)
SUB = 5
fine = rasterio.features.rasterize(((g, 1) for g in bld.geometry), out_shape=(H * SUB, W * SUB),
transform=from_origin(x0, y1, RES / SUB, RES / SUB), dtype="uint8")
cover = fine.reshape(H, SUB, W, SUB).mean((1, 3))
del fine
ref = np.full((H, W), IGNORE, "uint8")
ref[np.isin(wc, [20, 30, 40, 90, 100])] = 3
ref[wc == 10] = 2
ref[np.isin(wc, [50, 60])] = 1
ref[wc == 80] = 4
ref[cover >= 0.5] = 0
ok = ref != IGNORE
cm = np.bincount(ref[ok].astype(int) * NC + pred[ok], minlength=NC * NC).reshape(NC, NC)
tp = np.diag(cm)
fp, fn = cm.sum(0) - tp, cm.sum(1) - tp
score = pd.DataFrame({"IoU": tp / (tp + fp + fn), "F1": 2 * tp / (2 * tp + fp + fn), "precision": tp / (tp + fp),
"recall": tp / (tp + fn), "ref share": cm.sum(1) / cm.sum(),
"pred share": cm.sum(0) / cm.sum()}, index=CLASSES)
notts = pd.DataFrame(ckpt["test_scores"])
section(f"accuracy against the reference labels ({len(bld):,} Overture buildings, ESA WorldCover 2021)")
print(score.round(3).to_string())
print(f"overall accuracy {tp.sum() / cm.sum():.3f}, mean IoU {score['IoU'].mean():.3f} "
f"(Nottingham test blocks: mean IoU {notts['IoU'].mean():.3f})")
print("confusion matrix (rows: reference, columns: prediction, % of each reference class):")
print(pd.DataFrame(100 * cm / cm.sum(1, keepdims=True), index=CLASSES, columns=CLASSES).round(1).to_string())
# Object view: which Overture buildings does the predicted building layer find?
pred_bld = gdf[gdf["class"] == "Building"].union_all()
bld["area_m2"] = bld.area
bld["found"] = bld.intersects(pred_bld)
bins = [0, 50, 100, 200, 500, 2000, np.inf]
labels = ["<50", "50-100", "100-200", "200-500", "500-2000", ">2000"]
by_size = bld.groupby(pd.cut(bld["area_m2"], bins, labels=labels), observed=False)["found"].agg(["size", "mean"])
by_size.columns = ["buildings", "share found"]
print("\nOverture buildings touched by a predicted building polygon, by footprint area (m2):")
print(by_size.round(3).to_string())
print(f"all buildings: {bld['found'].mean():.1%}; area-weighted: "
f"{(bld['area_m2'] * bld['found']).sum() / bld['area_m2'].sum():.1%}")
bpoly = gdf[gdf["class"] == "Building"]
hit = bpoly.intersects(bld.union_all())
print(f"predicted building polygons that touch no Overture footprint: {(~hit).sum()} of {len(bpoly)} "
f"({bpoly.loc[~hit, 'area_m2'].sum() / bpoly['area_m2'].sum():.1%} of predicted building area)")
# --- Figures ------------------------------------------------------------------------------------
cmap = ListedColormap(COLOURS)
legend = [Patch(color=c, label=n) for c, n in zip(COLOURS, CLASSES)]
with rasterio.open(s2_cog) as src:
s2 = src.read()
RGB = np.clip(s2[[3, 2, 1]].transpose(1, 2, 0) / 2500, 0, 1) ** 0.8
extent = (x0 / 1000, (x0 + size) / 1000, (y1 - size) / 1000, y1 / 1000)
def frame(ax, title, km=True):
ax.set_title(title, loc="left", fontsize=9)
if km:
ax.set_xlabel("UTM 30N easting (km)")
ax.set_ylabel("northing (km)")
# 1. the input and the sliding windows
fig, ax = plt.subplots(figsize=(6.4, 6.2), constrained_layout=True)
ax.imshow(RGB, extent=extent)
for r in starts(H):
for c in starts(W):
ax.add_patch(Rectangle(((x0 + c * RES) / 1000, (y1 - (r + TILE) * RES) / 1000), TILE * RES / 1000,
TILE * RES / 1000, fill=False, edgecolor="white", lw=0.4, alpha=0.7))
r, c = starts(H)[6], starts(W)[6]
ax.add_patch(Rectangle(((x0 + c * RES) / 1000, (y1 - (r + TILE) * RES) / 1000), TILE * RES / 1000, TILE * RES / 1000,
fill=False, edgecolor="#ff5c74", lw=1.6))
frame(ax, f"Leicester, Sentinel-2 composite 2021: {n_win} overlapping {TILE} px windows")
fig.savefig(OUT / "01-input-and-windows.png")
plt.close(fig)
# 2. seams: naive tiling against blended windows, zoomed on one tile corner
zr, zc = 3 * TILE - 160, 3 * TILE - 160
z = np.s_[zr:zr + 320, zc:zc + 320]
fig, axes = plt.subplots(1, 3, figsize=(11, 4), constrained_layout=True)
for ax, data, title in [(axes[0], naive[z], "Naive tiles, no overlap"),
(axes[1], pred[z], f"Overlapping windows, blended ({OVERLAP} px)"),
(axes[2], None, "Pixels that differ")]:
if data is None:
ax.imshow(RGB[z])
d = diff[z]
ax.imshow(np.ma.masked_where(~d, d), cmap=ListedColormap(["#ff00c8"]), interpolation="nearest")
else:
ax.imshow(data, cmap=cmap, vmin=-0.5, vmax=4.5, interpolation="nearest")
for k in (160,):
ax.axhline(k - 0.5, color="black", lw=0.6, ls=":")
ax.axvline(k - 0.5, color="black", lw=0.6, ls=":")
ax.set_title(title, loc="left", fontsize=9)
ax.set_xticks([])
ax.set_yticks([])
fig.legend(handles=legend, loc="lower center", ncol=5, fontsize=8, frameon=False, bbox_to_anchor=(0.5, -0.07))
fig.savefig(OUT / "02-seams-naive-vs-blended.png")
plt.close(fig)
# 3. the final map against the reference
fig, axes = plt.subplots(1, 2, figsize=(11, 5.3), constrained_layout=True)
axes[0].imshow(pred, cmap=cmap, vmin=-0.5, vmax=4.5, extent=extent, interpolation="nearest")
frame(axes[0], f"U-Net prediction (mean IoU {score['IoU'].mean():.2f} against the reference)")
axes[1].imshow(np.ma.masked_equal(ref, IGNORE), cmap=cmap, vmin=-0.5, vmax=4.5, extent=extent,
interpolation="nearest")
frame(axes[1], "Reference: Overture buildings over ESA WorldCover 2021")
axes[1].legend(handles=legend, loc="upper left", bbox_to_anchor=(1.01, 1), fontsize=8, frameon=False)
fig.savefig(OUT / "03-prediction-vs-reference.png")
plt.close(fig)
# 4. where it fails: a 3 x 3 km zoom on the city centre
cr, cc = H // 2 - 150, W // 2 - 150
z = np.s_[cr:cr + 300, cc:cc + 300]
err = np.where(ref[z] == IGNORE, 0, (pred[z] != ref[z]).astype(int))
fig, axes = plt.subplots(1, 4, figsize=(13, 3.6), constrained_layout=True)
axes[0].imshow(RGB[z])
axes[1].imshow(np.ma.masked_equal(ref[z], IGNORE), cmap=cmap, vmin=-0.5, vmax=4.5, interpolation="nearest")
axes[2].imshow(pred[z], cmap=cmap, vmin=-0.5, vmax=4.5, interpolation="nearest")
axes[3].imshow(conf[z], cmap="magma", vmin=0.2, vmax=1)
axes[3].contour(err, levels=[0.5], colors="#00e5ff", linewidths=0.4)
for ax, t in zip(axes, ["Sentinel-2 true colour, central Leicester", "Reference labels", "U-Net prediction",
f"Confidence, with errors outlined ({err.mean():.0%} wrong)"]):
ax.set_title(t, loc="left", fontsize=9)
ax.set_xticks([])
ax.set_yticks([])
fig.colorbar(axes[3].images[0], ax=axes[3], shrink=0.8, label="max softmax probability")
fig.legend(handles=legend, loc="lower center", ncol=5, fontsize=8, frameon=False, bbox_to_anchor=(0.45, -0.08))
fig.savefig(OUT / "04-city-centre-errors.png")
plt.close(fig)
# 5. vector polygons over the image, with Overture buildings for comparison
zx0, zy1 = x0 + (cc + 75) * RES, y1 - (cr + 75) * RES
zb = box(zx0, zy1 - 1500, zx0 + 1500, zy1)
fig, axes = plt.subplots(1, 2, figsize=(10.5, 5.2), constrained_layout=True)
for ax, title in zip(axes, ["Simplified U-Net polygons (GeoPackage)", "Overture building footprints (reference)"]):
ax.imshow(RGB, extent=(x0, x0 + size, y1 - size, y1))
ax.set_xlim(zb.bounds[0], zb.bounds[2])
ax.set_ylim(zb.bounds[1], zb.bounds[3])
ax.set_title(title, loc="left", fontsize=9)
ax.set_xticks([])
ax.set_yticks([])
sub = gdf[gdf.intersects(zb) & gdf["class"].isin(["Building", "Water", "Trees"])].clip(zb)
for k, col in [("Trees", "#7fd17f"), ("Water", "#4fa3ff"), ("Building", "#ff3b5c")]:
sub[sub["class"] == k].boundary.plot(ax=axes[0], color=col, lw=0.9)
bld[bld.intersects(zb)].clip(zb).boundary.plot(ax=axes[1], color="#ffd400", lw=0.4)
axes[0].legend(handles=[Patch(edgecolor=c, facecolor="none", label=k) for k, c in
[("Building", "#ff3b5c"), ("Trees", "#7fd17f"), ("Water", "#4fa3ff")]],
loc="lower left", fontsize=8, framealpha=0.85)
fig.savefig(OUT / "05-vector-polygons.png")
plt.close(fig)
# 6. per-class IoU: Nottingham test blocks against Leicester
fig, ax = plt.subplots(figsize=(8.5, 3.4), constrained_layout=True)
xs = np.arange(NC)
for k, (vals, lab, col) in enumerate([(notts["IoU"].reindex(CLASSES), "Nottingham test blocks", "#4a5553"),
(score["IoU"], "Leicester (new area)", "#c21f3a")]):
ax.bar(xs + (k - 0.5) * 0.36, vals, 0.36, color=col, label=lab)
for x, v in zip(xs + (k - 0.5) * 0.36, vals):
ax.text(x, v + 0.01, f"{v:.2f}", ha="center", va="bottom", fontsize=7)
ax.set_xticks(xs, CLASSES)
ax.set_ylim(0, 1)
ax.set_ylabel("IoU")
ax.set_title("Per-class IoU: held-out blocks in the training city against a new city", loc="left", fontsize=9)
ax.legend(fontsize=8, frameon=False)
ax.grid(axis="y", alpha=0.3)
fig.savefig(OUT / "06-iou-nottingham-vs-leicester.png")
plt.close(fig)
print(f"\nfigures written to {OUT.name}/")