Source code: Earthquake damage mapping with Sentinel-1 in Python: log ratio and z-scores in Antakya

Runnable Python scripts, requirements and console output for the tutorial “Earthquake damage mapping with Sentinel-1 in Python: log ratio and z-scores in…

Last updated

These are the exact scripts used to produce every figure and number in Earthquake damage mapping with Sentinel-1 in Python: log ratio and z-scores in Antakya. 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 damage_mapping.py

damage_mapping.py

damage_mapping.py
"""Earthquake damage mapping with Sentinel-1 in Python: log ratio, pre-event z-scores and an agreement table.
Streams Sentinel-1 RTC (gamma0 VV and VH, 10 m) for Antakya, Hatay, Turkey, from the Microsoft Planetary
Computer (no account or key): a pre-event stack from October 2022 to 5 February 2023 and the first
post-event acquisition after the 6 February 2023 Kahramanmaras earthquakes, on three relative orbits.
Computes the log ratio and a z-score against the pre-event stack, masks to ESA WorldCover built-up land,
aggregates to a 200 m grid and scores the result against the HOT/OpenStreetMap "destroyed buildings"
reference (ODbL) with ROC AUC and a confusion table.
Run with: python damage_mapping.py
"""
import io
import json
import platform
import time
import zipfile
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import geopandas as gpd
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import planetary_computer
import pystac_client
import rasterio
import requests
import scipy
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT
from rasterio.warp import transform_bounds
from scipy import stats
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)
print("=== versions ===")
print(f"python {platform.python_version()} | numpy {np.__version__} | scipy {scipy.__version__} | "
f"rasterio {rasterio.__version__} | geopandas {gpd.__version__} | pystac-client {pystac_client.__version__}")
def section(title: str) -> None:
print(f"\n=== {title} ===", flush=True)
# --- Step 1: pre- and post-event Sentinel-1 RTC on a common 10 m grid -----------------
BBOX = (36.10, 36.15, 36.22, 36.25) # lon/lat: Antakya (Hatay), Turkey
EVENT = pd.Timestamp("2023-02-06 01:17", tz="UTC")
ORBITS = {14: "ascending", 21: "descending", 94: "descending"}
CRS = "EPSG:32637" # UTM 37N
b = [round(v, -1) for v in transform_bounds(4326, CRS, *BBOX)]
TR = from_origin(b[0], b[3], 10, 10)
W, H = int((b[2] - b[0]) / 10), int((b[3] - b[1]) / 10)
section("Step 1: search and read Sentinel-1 RTC")
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace)
print(f"grid: {W} x {H} pixels of 10 m in {CRS}")
def read_band(item, band: str) -> np.ndarray:
"""Warp one RTC asset onto the common grid; 0 is nodata in the COGs."""
with rasterio.open(item.assets[band].href) as src, \
WarpedVRT(src, crs=CRS, transform=TR, width=W, height=H, nodata=0,
resampling=Resampling.bilinear) as vrt:
a = vrt.read(1).astype("float32")
a[a <= 0] = np.nan
return a
def load_orbit(orbit: int) -> dict:
"""Return {"dates", "vv" (t, y, x), "vh" (t, y, x)} for one relative orbit, cached as .npz."""
cache = DATA / f"s1_orbit{orbit:03d}.npz"
if not cache.exists():
items = list(catalog.search(collections=["sentinel-1-rtc"], bbox=BBOX, datetime="2022-10-01/2023-02-19",
query={"sat:relative_orbit": {"eq": orbit}}).items())
jobs = [(i, band) for i in items for band in ("vv", "vh")]
with ThreadPoolExecutor(8) as ex:
arrays = list(ex.map(lambda j: read_band(*j), jobs))
by_date = defaultdict(lambda: defaultdict(list)) # several slices can cover the town on one pass
for (item, band), a in zip(jobs, arrays):
by_date[item.datetime.strftime("%Y-%m-%dT%H:%M")][band].append(a)
dates = sorted(by_date)
merged = {band: np.stack([np.nanmax(np.stack(by_date[d][band]), 0) for d in dates]) for band in ("vv", "vh")}
np.savez_compressed(cache, dates=np.array(dates), **merged)
z = np.load(cache)
return {"dates": pd.to_datetime(z["dates"]).tz_localize("UTC"), "vv": z["vv"], "vh": z["vh"]}
t0 = time.perf_counter()
s1 = {orbit: load_orbit(orbit) for orbit in ORBITS}
rows = []
for orbit, d in s1.items():
pre = d["dates"][d["dates"] < EVENT]
post = d["dates"][d["dates"] > EVENT]
rows.append({"orbit": orbit, "pass": ORBITS[orbit], "pre_scenes": len(pre), "first_pre": f"{pre[0]:%d %b %Y}",
"last_pre": f"{pre[-1]:%d %b %Y}", "post_scenes": len(post), "first_post": f"{post[0]:%d %b %Y}",
"valid_%": round(100 * np.isfinite(d['vv'][-1]).mean(), 1)})
print(pd.DataFrame(rows).to_string(index=False))
print(f"read in {time.perf_counter() - t0:.1f} s")
# --- Step 2: the reference: destroyed buildings mapped in OpenStreetMap (HOT export) ---
section("Step 2: download the HOT/OSM destroyed-buildings reference")
REF = DATA / "hotosm_tur_destroyed_buildings.gpkg"
if not REF.exists():
pkg = requests.get("https://data.humdata.org/api/3/action/package_show",
params={"id": "hotosm_tur_destroyed_buildings"}, timeout=60).json()["result"]
url = next(r["url"] for r in pkg["resources"] if r["url"].endswith(".zip") and "gpkg" in r["url"])
with zipfile.ZipFile(io.BytesIO(requests.get(url, timeout=300).content)) as zf:
name = next(n for n in zf.namelist() if n.endswith(".gpkg"))
REF.write_bytes(zf.read(name))
ref = gpd.read_file(REF)
print(f"{len(ref)} destroyed-building polygons in Turkey | sources: {ref['source'].value_counts().to_dict()}")
ref = ref.to_crs(CRS)
ref["geometry"] = ref.geometry.centroid
x0, y0 = TR.c, TR.f
ref = ref[(ref.geometry.x > x0) & (ref.geometry.x < x0 + W * 10) & (ref.geometry.y < y0) & (ref.geometry.y > y0 - H * 10)]
print(f"{len(ref)} of them inside the Antakya study area")
# --- Step 3: a built-up mask from ESA WorldCover 2021 ---------------------------------
section("Step 3: ESA WorldCover 2021 built-up mask")
wc_cache = DATA / "worldcover_2021.npy"
if not wc_cache.exists():
wc_item = next(i for i in catalog.search(collections=["esa-worldcover"], bbox=BBOX).items() if "2021" in i.id)
with rasterio.open(wc_item.assets["map"].href) as src, \
WarpedVRT(src, crs=CRS, transform=TR, width=W, height=H, resampling=Resampling.nearest) as vrt:
np.save(wc_cache, vrt.read(1))
wc = np.load(wc_cache)
built = wc == 50
print(f"built-up: {built.mean():.1%} of the study area ({built.sum() * 100 / 1e6:.1f} km2)")
# --- Step 4: log ratio and z-score against the pre-event stack ------------------------
section("Step 4: log ratio and pre-event z-score per orbit and polarisation")
change = {}
rows = []
for orbit, d in s1.items():
pre = np.asarray(d["dates"] < EVENT)
i_post = int(np.flatnonzero(~pre)[0])
for pol in ("vv", "vh"):
db = 10 * np.log10(d[pol])
stack_db, post_db = db[pre], db[i_post]
log_ratio = post_db - stack_db[-1] # dB difference = 10 log10(post / pre)
mu, sd = stack_db.mean(0), stack_db.std(0, ddof=1)
z = (post_db - mu) / sd
z_pre = (stack_db[-1] - stack_db[:-1].mean(0)) / stack_db[:-1].std(0, ddof=1) # a no-event control
change[(orbit, pol)] = {"log_ratio": log_ratio, "z": z, "z_control": z_pre}
rows.append({"orbit": orbit, "pol": pol.upper(), "post": f"{d['dates'][i_post]:%d %b}",
"built_sd_dB": round(float(np.nanmedian(sd[built])), 2),
"|LR|>3dB_%": round(100 * float(np.nanmean(np.abs(log_ratio[built]) > 3)), 1),
"|z|>2_%": round(100 * float(np.nanmean(np.abs(z[built]) > 2)), 1),
"|z|>2_%_control": round(100 * float(np.nanmean(np.abs(z_pre[built]) > 2)), 1)})
print(pd.DataFrame(rows).to_string(index=False))
print("control = last pre-event date scored against the earlier pre-event dates (no earthquake in between)")
LC = {10: "tree cover", 30: "grassland", 40: "cropland", 50: "built-up"}
rows = []
for code, name in LC.items():
mask = wc == code
ev = [np.nanmean(np.abs(c["z"][mask]) > 2) for c in change.values()]
ct = [np.nanmean(np.abs(c["z_control"][mask]) > 2) for c in change.values()]
rows.append({"land cover": name, "pixels": int(mask.sum()), "|z|>2 % event": round(100 * float(np.mean(ev)), 1),
"|z|>2 % control": round(100 * float(np.mean(ct)), 1)})
print("\nexceedance by WorldCover class (mean of the 6 orbit/polarisation pairs):")
print(pd.DataFrame(rows).to_string(index=False))
# --- Step 5: aggregate to a 200 m grid and attach the reference -----------------------
section("Step 5: aggregate to a 200 m grid")
def grid_stats(cell_px: int):
h, w = H // cell_px, W // cell_px
def agg(a):
a = np.where(built, a, np.nan)[:h * cell_px, :w * cell_px].reshape(h, cell_px, w, cell_px)
return np.nanmean(a, axis=(1, 3))
built_frac = built[:h * cell_px, :w * cell_px].reshape(h, cell_px, w, cell_px).mean(axis=(1, 3))
cols = ((ref.geometry.x - TR.c) / (10 * cell_px)).astype(int).to_numpy()
rws = ((TR.f - ref.geometry.y) / (10 * cell_px)).astype(int).to_numpy()
ok = (rws < h) & (cols < w)
count = np.zeros((h, w))
np.add.at(count, (rws[ok], cols[ok]), 1)
z_index = np.mean([agg((np.abs(c["z"]) > 2).astype("float32")) for c in change.values()], axis=0)
lr_index = np.mean([agg(np.abs(c["log_ratio"])) for c in change.values()], axis=0)
ctrl_index = np.mean([agg((np.abs(c["z_control"]) > 2).astype("float32")) for c in change.values()], axis=0)
per_combo = {k: agg((np.abs(c["z"]) > 2).astype("float32")) for k, c in change.items()}
return built_frac, count, z_index, lr_index, ctrl_index, per_combo
def auc(score, label):
"""ROC AUC via the Mann-Whitney U statistic (probability a damaged cell outranks an undamaged one)."""
r = stats.rankdata(score)
n1, n0 = label.sum(), (~label).sum()
return float((r[label].sum() - n1 * (n1 + 1) / 2) / (n1 * n0))
CELL = 20 # 20 x 10 m = 200 m
built_frac, count, z_index, lr_index, ctrl_index, per_combo = grid_stats(CELL)
sel = built_frac >= 0.5 # score only cells that are at least half built-up
label = count[sel] >= 1
print(f"{sel.sum()} cells of 200 m are >= 50 % built-up; {label.sum()} contain >= 1 destroyed building "
f"({int(count[sel].sum())} of {len(ref)} reference buildings fall in scored cells)")
print(f"damage index (mean share of built pixels with |z| > 2 over 6 orbit/polarisation pairs): "
f"median {np.median(z_index[sel]):.3f}, 95th pct {np.percentile(z_index[sel], 95):.3f}")
section("Step 6: how well does each score rank the reference cells? (ROC AUC, 0.5 = random)")
rows = [{"score": "|log ratio|, mean of 6", "AUC": auc(lr_index[sel], label)},
{"score": "share |z|>2, mean of 6", "AUC": auc(z_index[sel], label)}]
rows += [{"score": f"share |z|>2, orbit {o} {p.upper()}", "AUC": auc(v[sel], label)} for (o, p), v in per_combo.items()]
auc_table = pd.DataFrame(rows)
print(auc_table.round(3).to_string(index=False))
rho, p_rho = stats.spearmanr(z_index[sel], count[sel])
print(f"Spearman rho (damage index vs number of destroyed buildings per cell) = {rho:.3f}, p = {p_rho:.1e}")
sens = []
for cell_px in (10, 20, 40):
bf, cnt, zi, li, _, _ = grid_stats(cell_px)
s = bf >= 0.5
for k in (1, 3):
lab = cnt[s] >= k
sens.append({"cell_m": cell_px * 10, "damaged_if_>=": k, "cells": int(s.sum()), "damaged": int(lab.sum()),
"AUC_logratio": round(auc(li[s], lab), 3), "AUC_zindex": round(auc(zi[s], lab), 3)})
print("\nsensitivity to cell size and reference threshold:")
print(pd.DataFrame(sens).to_string(index=False))
# --- Step 7: threshold without the reference, then build the agreement table ---------
section("Step 7: unsupervised thresholds and the agreement table")
def otsu(values: np.ndarray, bins: int = 64) -> float:
"""Otsu's threshold: maximise the between-class variance of a histogram."""
hist, edges = np.histogram(values, bins)
centres = (edges[:-1] + edges[1:]) / 2
best, best_var = centres[0], -1.0
for k in range(1, bins):
w0, w1 = hist[:k].sum(), hist[k:].sum()
if w0 == 0 or w1 == 0:
continue
m0, m1 = (hist[:k] * centres[:k]).sum() / w0, (hist[k:] * centres[k:]).sum() / w1
if w0 * w1 * (m0 - m1) ** 2 > best_var:
best, best_var = centres[k], w0 * w1 * (m0 - m1) ** 2
return float(best)
def agreement(pred: np.ndarray, lab: np.ndarray) -> dict:
tp, fp = int((pred & lab).sum()), int((pred & ~lab).sum())
fn, tn = int((~pred & lab).sum()), int((~pred & ~lab).sum())
n = tp + fp + fn + tn
po = (tp + tn) / n
pe = ((tp + fp) * (tp + fn) + (fn + tn) * (fp + tn)) / n**2
return {"tp": tp, "fp": fp, "fn": fn, "tn": tn, "flagged": tp + fp, "precision": round(tp / max(tp + fp, 1), 3),
"recall": round(tp / (tp + fn), 3), "f1": round(2 * tp / (2 * tp + fp + fn), 3),
"overall_accuracy": round(po, 3), "kappa": round((po - pe) / (1 - pe), 3)}
v = z_index[sel]
thresholds = {"control 99th pct": float(np.percentile(ctrl_index[sel], 99)),
"Otsu on event index": otsu(v),
"event mean + 1 sd": float(v.mean() + v.std()),
"event mean + 2 sd": float(v.mean() + 2 * v.std())}
rows = [{"rule": k, "threshold": round(t, 3), **agreement(v > t, label)} for k, t in thresholds.items()]
print(pd.DataFrame(rows).drop(columns=["tp", "fp", "fn", "tn"]).to_string(index=False))
threshold = thresholds["Otsu on event index"]
pred = v > threshold
m = agreement(pred, label)
tp, fp, fn, tn = m["tp"], m["fp"], m["fn"], m["tn"]
print(f"\nconfusion table at the Otsu threshold ({threshold:.3f}), 200 m built-up cells:")
conf = pd.DataFrame([[tp, fn, tp + fn], [fp, tn, fp + tn], [tp + fp, fn + tn, tp + fp + fn + tn]],
index=["reference: >= 1 destroyed", "reference: none mapped", "total"],
columns=["SAR: damaged", "SAR: not flagged", "total"])
print(conf.to_string())
print(f"odds ratio {(tp * tn) / (fp * fn):.1f}; Fisher exact p = {stats.fisher_exact([[tp, fn], [fp, tn]])[1]:.1e}")
metrics = {"threshold_otsu": round(threshold, 3), "cells": len(v), **{k: m[k] for k in
("tp", "fp", "fn", "tn", "precision", "recall", "f1", "overall_accuracy", "kappa")}}
# --- Step 8: figures and metrics ------------------------------------------------------
section("Step 8: figures")
EXT = (0, W / 100, 0, H / 100) # km from the south-west corner
km = (ref.geometry.x - TR.c) / 1000, (ref.geometry.y - (TR.f - H * 10)) / 1000
d21 = s1[21]
pre21 = np.asarray(d21["dates"] < EVENT)
i21 = int(np.flatnonzero(~pre21)[0])
fig, ax = plt.subplots(1, 3, figsize=(13, 4.6), sharey=True, layout="constrained")
panels = [(10 * np.log10(np.nanmean(d21["vv"][pre21], 0)), "gray", (-20, 5), "pre-event mean VV, 11 dates"),
(10 * np.log10(d21["vv"][i21]), "gray", (-20, 5), f"post-event VV, {d21['dates'][i21]:%d %b %Y}"),
(change[(21, "vv")]["log_ratio"], "RdBu_r", (-6, 6), "log ratio, 10 Feb vs 29 Jan")]
for a, (img, cmap, (lo, hi), title) in zip(ax, panels):
im = a.imshow(img, cmap=cmap, vmin=lo, vmax=hi, extent=EXT)
a.set_title(title)
a.set_xlabel("km east")
fig.colorbar(im, ax=a, shrink=0.8, label="dB")
ax[0].set_ylabel("km north")
fig.suptitle("Sentinel-1 RTC, relative orbit 21 (descending), Antakya")
fig.savefig(OUT / "01-pre-post-logratio.png")
plt.close(fig)
fig, ax = plt.subplots(1, 2, figsize=(11, 5), sharey=True)
for a, key, title in [(ax[0], "z_control", "control: 29 Jan vs 10 earlier dates (no event)"),
(ax[1], "z", "event: 10 Feb vs 11 pre-event dates")]:
z = np.where(built, change[(21, "vh")][key], np.nan)
a.imshow(np.where(built, np.nan, 1), cmap="Greys", vmin=0, vmax=4, extent=EXT)
im = a.imshow(z, cmap="RdBu_r", vmin=-4, vmax=4, extent=EXT)
a.scatter(*km, s=2, c="k", lw=0, label="destroyed building (OSM)")
a.set_title(title)
a.set_xlabel("km east")
ax[0].set_ylabel("km north")
ax[1].legend(loc="lower right", markerscale=4, fontsize=8)
fig.colorbar(im, ax=ax, shrink=0.8, label="z-score, VH (built-up pixels only)")
fig.savefig(OUT / "02-zscore-control-vs-event.png")
plt.close(fig)
# per-date exceedance: each pre-event date scored against the other pre-event dates (leave one out)
h, w = H // CELL, W // CELL
cnt_px = np.kron(count, np.ones((CELL, CELL)))
cnt_px = np.pad(cnt_px, ((0, H - cnt_px.shape[0]), (0, W - cnt_px.shape[1])))
in_sel = np.kron(sel, np.ones((CELL, CELL))).astype(bool)
in_sel = np.pad(in_sel, ((0, H - in_sel.shape[0]), (0, W - in_sel.shape[1])))
grp_dmg, grp_ok = built & in_sel & (cnt_px >= 3), built & in_sel & (cnt_px == 0)
fig, ax = plt.subplots(1, 3, figsize=(12, 3.4), sharey=True, layout="constrained")
for a, orbit in zip(ax, ORBITS):
d = s1[orbit]
db = 10 * np.log10(d["vv"])
pre = np.flatnonzero(np.asarray(d["dates"] < EVENT))
i_post = int(np.flatnonzero(np.asarray(d["dates"] > EVENT))[0])
series = {"dmg": [], "ok": []}
for t in list(pre) + [i_post]:
others = [i for i in pre if i != t]
z = (db[t] - db[others].mean(0)) / db[others].std(0, ddof=1)
series["dmg"].append(np.nanmean(np.abs(z[grp_dmg]) > 2))
series["ok"].append(np.nanmean(np.abs(z[grp_ok]) > 2))
dates = d["dates"][list(pre) + [i_post]]
a.plot(dates, series["dmg"], "o-", c="#d7191c", ms=3, label=">= 3 destroyed buildings in cell")
a.plot(dates, series["ok"], "o-", c="#2c7bb6", ms=3, label="no destroyed building mapped")
a.axvline(EVENT, c="k", ls="--", lw=0.8)
a.set_title(f"orbit {orbit} ({ORBITS[orbit]}), VV")
a.xaxis.set_major_locator(mdates.MonthLocator(bymonth=[10, 12, 2]))
a.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax[0].set_ylabel("share of built pixels with |z| > 2")
ax[0].legend(fontsize=7, loc="upper left")
fig.savefig(OUT / "03-exceedance-time-series.png")
plt.close(fig)
fig, ax = plt.subplots(1, 2, figsize=(11, 5), sharey=True)
gext = (0, w * CELL / 100, 0, h * CELL / 100)
im0 = ax[0].imshow(np.where(sel, z_index, np.nan), cmap="YlOrRd", vmin=0.1, vmax=0.45, extent=gext)
ax[0].set_title("SAR damage index (200 m cells >= 50 % built-up)")
fig.colorbar(im0, ax=ax[0], shrink=0.75, label="share of built pixels with |z| > 2")
im1 = ax[1].imshow(np.where(sel, count, np.nan), cmap="Purples", vmin=0, vmax=15, extent=gext)
ax[1].set_title("reference: destroyed buildings per cell (OSM)")
fig.colorbar(im1, ax=ax[1], shrink=0.75, label="buildings", extend="max")
for a in ax:
a.set_facecolor("#e6e6e6")
a.set_xlabel("km east")
ax[0].set_ylabel("km north")
fig.savefig(OUT / "04-damage-index-vs-reference.png")
plt.close(fig)
def roc(score, lab):
order = np.argsort(-score)
l = lab[order]
return np.r_[0, np.cumsum(~l) / (~l).sum()], np.r_[0, np.cumsum(l) / l.sum()]
fig, ax = plt.subplots(figsize=(4.6, 4.3))
for score, name, col in [(z_index[sel], "share |z|>2 (pre-event stack)", "#d7191c"),
(lr_index[sel], "|log ratio| (one pre date)", "#2c7bb6")]:
fpr, tpr = roc(score, label)
ax.plot(fpr, tpr, c=col, label=f"{name}: AUC {auc(score, label):.2f}")
ax.plot([0, 1], [0, 1], c="0.6", ls=":", label="random")
ax.plot(fp / (fp + tn), tp / (tp + fn), "ko", label="Otsu threshold")
ax.set_xlabel("false positive rate (cells with no mapped damage)")
ax.set_ylabel("true positive rate (cells with destroyed buildings)")
ax.legend(fontsize=7, loc="lower right")
fig.savefig(OUT / "05-roc.png")
plt.close(fig)
cat_grid = np.full((h, w), np.nan)
p_full = np.zeros((h, w), bool)
p_full[sel] = pred
l_full = count >= 1
cat_grid[sel & p_full & l_full] = 3
cat_grid[sel & p_full & ~l_full] = 2
cat_grid[sel & ~p_full & l_full] = 1
cat_grid[sel & ~p_full & ~l_full] = 0
cols4 = ["#f0f0f0", "#2c7bb6", "#fdae61", "#d7191c"]
fig, ax = plt.subplots(figsize=(6, 5.5))
ax.set_facecolor("#ffffff")
ax.imshow(cat_grid, cmap=ListedColormap(cols4), vmin=-0.5, vmax=3.5, extent=gext, interpolation="nearest")
ax.legend(handles=[Patch(color=c, label=f"{l} ({v})") for c, l, v in
zip(cols4[::-1], ["both: damaged", "SAR only", "reference only", "neither"], [tp, fp, fn, tn])],
loc="lower right", fontsize=8)
ax.set_title(f"agreement at the Otsu threshold (kappa {metrics['kappa']:.2f})")
ax.set_xlabel("km east")
ax.set_ylabel("km north")
fig.savefig(OUT / "06-agreement-map.png")
plt.close(fig)
metrics.update({"auc_zindex": round(auc(z_index[sel], label), 3), "auc_logratio": round(auc(lr_index[sel], label), 3),
"spearman_rho": round(float(rho), 3), "reference_buildings_in_area": int(len(ref))})
(OUT / "metrics.json").write_text(json.dumps(metrics, indent=2))
print("saved", ", ".join(sorted(p.name for p in OUT.glob("*.png"))))
print(f"total run time {time.perf_counter() - t0:.1f} s")