Source code: Sentinel-1 SAR flood mapping in Python: the 2022 Pakistan floods
Runnable Python scripts, requirements and console output for the tutorial “Sentinel-1 SAR flood mapping in Python: the 2022 Pakistan floods”.
Last updated
These are the exact scripts used to produce every figure and number in Sentinel-1 SAR flood mapping in Python: the 2022 Pakistan floods. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- sar_flood_mapping.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython sar_flood_mapping.pysar_flood_mapping.py
"""Sentinel-1 SAR flood mapping in Python: the 2022 Pakistan floods around Manchar Lake and Dadu.
Searches the Microsoft Planetary Computer STAC API for radiometrically terrain-corrected(RTC) Sentinel-1 scenes, streams VV backscatter from Cloud Optimised GeoTIFFs onto a fixedgrid, converts to decibels, removes speckle with a Lee filter, picks a water thresholdwith Otsu's method and maps the flood extent and area through the 2022 monsoon.Run with: python sar_flood_mapping.py"""import platformfrom importlib.metadata import versionfrom pathlib import Path
import geopandas as gpdimport matplotlib
matplotlib.use("Agg")import matplotlib.dates as mdatesimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport planetary_computerimport pystac_clientimport rasterioimport rasterio.featuresfrom matplotlib.colors import ListedColormapfrom pyproj import Transformerfrom rasterio.enums import Resamplingfrom rasterio.transform import from_originfrom rasterio.vrt import WarpedVRTfrom rasterio.windows import from_boundsfrom scipy import ndimagefrom skimage.filters import threshold_otsu
HERE = Path(__file__).parentOUT = HERE / "output"OUT.mkdir(exist_ok=True)plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})pd.set_option("display.width", 120)
print("=== versions ===")print(f"python {platform.python_version()} | pystac-client {pystac_client.__version__} | " f"planetary-computer {version('planetary-computer')} | rasterio {rasterio.__version__} | " f"numpy {np.__version__} | scikit-image {version('scikit-image')}")
STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"BBOX = (67.45, 26.15, 68.25, 27.05) # lon/lat: Manchar Lake, Dadu and the Indus in Sindh, PakistanSEASON = "2022-06-01/2022-12-31"ORBIT = 42 # one relative orbit = identical viewing geometry on every dateBEFORE, AFTER = "2022-06-16", "2022-09-08"RES = 40 # analysis resolution in metresCRS = "EPSG:32642" # UTM zone 42N, the native CRS of these scenes
to_utm = Transformer.from_crs("EPSG:4326", CRS, always_xy=True)xs, ys = to_utm.transform([BBOX[0], BBOX[2], BBOX[0], BBOX[2]], [BBOX[1], BBOX[1], BBOX[3], BBOX[3]])x0, x1 = np.floor(min(xs) / RES) * RES, np.ceil(max(xs) / RES) * RESy0, y1 = np.floor(min(ys) / RES) * RES, np.ceil(max(ys) / RES) * RESWIDTH, HEIGHT = int((x1 - x0) / RES), int((y1 - y0) / RES)TRANSFORM = from_origin(x0, y1, RES, RES)PIXEL_KM2 = RES * RES / 1e6
def section(title: str) -> None: print(f"\n=== {title} ===")
def read_gamma0(href: str) -> np.ndarray: """Warp a remote RTC COG onto the analysis grid (averaging linear power), NaN where empty.""" with rasterio.open(href) as src, WarpedVRT(src, crs=CRS, transform=TRANSFORM, width=WIDTH, height=HEIGHT, resampling=Resampling.average, src_nodata=src.nodata, nodata=np.nan, dtype="float32") as vrt: return vrt.read(1)
def to_db(power: np.ndarray) -> np.ndarray: with np.errstate(divide="ignore", invalid="ignore"): return 10 * np.log10(np.where(power > 0, power, np.nan))
def lee_filter(img: np.ndarray, size: int = 7, enl: float = 4.4) -> np.ndarray: """Lee (1980) speckle filter on linear power. enl = equivalent number of looks.""" nan = np.isnan(img) img = np.where(nan, np.nanmedian(img), img) mean = ndimage.uniform_filter(img, size) var = np.maximum(ndimage.uniform_filter(img * img, size) - mean * mean, 0) ci2 = var / np.maximum(mean * mean, 1e-12) # local coefficient of variation, squared w = np.clip(1 - (1 / enl) / np.maximum(ci2, 1e-12), 0, 1) out = mean + w * (img - mean) # smooth flat areas, keep edges out[nan] = np.nan return out
# --- Step 1: search the catalogue ----------------------------------------------catalog = pystac_client.Client.open(STAC_URL, modifier=planetary_computer.sign_inplace)search = catalog.search(collections=["sentinel-1-rtc"], bbox=BBOX, datetime=SEASON, query={"sat:relative_orbit": {"eq": ORBIT}, "sat:orbit_state": {"eq": "ascending"}})items = sorted(search.items(), key=lambda i: i.datetime)section("STAC search")print(f"{len(items)} scenes from relative orbit {ORBIT} (ascending), {items[0].datetime:%d %b} to " f"{items[-1].datetime:%d %b %Y}")example = next(i for i in items if i.datetime.strftime("%Y-%m-%d") == AFTER)p = example.propertiesprint(example.id)print(f"assets: {', '.join(example.assets)}")print(f"polarisations: {p['sar:polarizations']} | mode {p['sar:instrument_mode']} | " f"pixel {p['sar:pixel_spacing_range']} m | ENL {p['sar:looks_equivalent_number']} | {p['proj:code']}")print("vv raster:bands:", example.assets["vv"].extra_fields["raster:bands"])ENL = p["sar:looks_equivalent_number"]
# --- Step 2: speckle at full resolution -------------------------------------------CHIP = (67.75, 26.70, 67.81, 26.76) # 6 km box around Dadu town and the floodwater surrounding itcx, cy = to_utm.transform([CHIP[0], CHIP[2]], [CHIP[1], CHIP[3]])with rasterio.open(example.assets["vv"].href) as src: chip = src.read(1, window=from_bounds(cx[0], cy[0], cx[1], cy[1], src.transform)).astype("float32")chip[chip == -32768] = np.nanchip_lee = lee_filter(chip, size=7, enl=ENL)section("speckle (10 m chip)")print(f"chip {chip.shape[1]} x {chip.shape[0]} pixels; linear gamma0 {np.nanmin(chip):.4f}..{np.nanmax(chip):.2f}")
def local_std(db: np.ndarray, size: int = 5) -> float: """Median of the 5 x 5 moving-window standard deviation: pixel-to-pixel noise, not scene contrast.""" m = ndimage.uniform_filter(db, size) return float(np.nanmedian(np.sqrt(np.maximum(ndimage.uniform_filter(db * db, size) - m * m, 0))))
print(f"pixel-to-pixel noise (median 5x5 std): raw {local_std(to_db(chip)):.2f} dB, Lee {local_std(to_db(chip_lee)):.2f} dB")
# --- Step 3: read every date on the 40 m grid ----------------------------------------section("reading VV on a 40 m grid")vv_db, dates = {}, []for it in items: d = it.datetime.strftime("%Y-%m-%d") power = read_gamma0(it.assets["vv"].href) # Each 40 m pixel averages 16 pixels (~4 independent resolution cells), so ENL rises ~4x. vv_db[d] = to_db(lee_filter(power, size=5, enl=ENL * 4)) dates.append(d) print(f"{d}: {np.isfinite(vv_db[d]).mean():.1%} valid, median {np.nanmedian(vv_db[d]):.1f} dB")
# --- Step 4: Otsu threshold on the flood image -------------------------------------------before, after = vv_db[BEFORE], vv_db[AFTER]valid = np.isfinite(before) & np.isfinite(after)thr = threshold_otsu(after[valid])section("Otsu threshold")print(f"VV threshold from {AFTER}: {thr:.1f} dB")print(f"median VV: water-side {np.median(after[valid & (after < thr)]):.1f} dB, " f"land-side {np.median(after[valid & (after >= thr)]):.1f} dB")
def water_mask(db: np.ndarray) -> np.ndarray: w = np.isfinite(db) & (db < thr) w = ndimage.binary_opening(w, iterations=1) # drop isolated dark pixels lab, n = ndimage.label(w) sizes = ndimage.sum(w, lab, index=np.arange(1, n + 1)) return np.isin(lab, 1 + np.flatnonzero(sizes >= 7)) # keep patches >= ~1 ha
water_before, water_after = water_mask(before) & valid, water_mask(after) & validflood = water_after & ~water_beforereceded = water_before & ~water_aftersection("flood extent")print(f"valid area: {valid.sum() * PIXEL_KM2:,.0f} km²")print(f"water on {BEFORE}: {water_before.sum() * PIXEL_KM2:,.0f} km²")print(f"water on {AFTER}: {water_after.sum() * PIXEL_KM2:,.0f} km²")print(f"newly flooded: {flood.sum() * PIXEL_KM2:,.0f} km² ({flood.sum() / valid.sum():.1%} of the area)")print(f"water before but not after: {receded.sum() * PIXEL_KM2:,.0f} km²")
# Change detection without a threshold: how much darker did each pixel get?diff = after - beforeprint(f"pixels at least 3 dB darker: {((diff < -3) & valid).sum() * PIXEL_KM2:,.0f} km²; " f"of the Otsu flood pixels, {(diff[flood] < -3).mean():.0%} dropped by 3 dB or more")
# --- Step 5: time series -------------------------------------------------------------rows = []for d in dates: v = np.isfinite(vv_db[d]) & valid w = water_mask(vv_db[d]) & v rows.append({"date": pd.Timestamp(d), "water_km2": round(w.sum() * PIXEL_KM2), "flooded_km2": round((w & ~water_before).sum() * PIXEL_KM2), "valid_pct": round(v.mean() * 100, 1)})ts = pd.DataFrame(rows)section("water area through the 2022 monsoon")print(ts.assign(date=ts.date.dt.strftime("%Y-%m-%d")).to_string(index=False))ts.to_csv(OUT / "flood_area_timeseries.csv", index=False)peak = ts.loc[ts.flooded_km2.idxmax()]print(f"peak: {peak.date:%d %B %Y}, {peak.flooded_km2:,} km² newly flooded")
# --- Step 6: save the flood extent -----------------------------------------------------classes = np.zeros((HEIGHT, WIDTH), "uint8")classes[water_before & water_after] = 1classes[flood] = 2classes[~valid] = 255with rasterio.open(OUT / "flood_extent_2022-09-08.tif", "w", driver="GTiff", height=HEIGHT, width=WIDTH, count=1, dtype="uint8", crs=CRS, transform=TRANSFORM, nodata=255, compress="deflate") as dst: dst.write(classes, 1)shapes = rasterio.features.shapes(flood.astype("uint8"), mask=flood, transform=TRANSFORM)polys = gpd.GeoDataFrame.from_features([{"geometry": g, "properties": {"class": "flood"}} for g, _ in shapes], crs=CRS)polys["area_km2"] = polys.area / 1e6polys.to_file(OUT / "flood_extent_2022-09-08.gpkg", driver="GPKG")section("vector output")print(f"{len(polys):,} flood polygons, total {polys.area_km2.sum():,.0f} km², largest {polys.area_km2.max():,.0f} km²")
# --- Step 7: figures --------------------------------------------------------------------extent = [0, (x1 - x0) / 1000, 0, (y1 - y0) / 1000]pretty = {d: pd.Timestamp(d).strftime("%d %b %Y") for d in dates}
def km_axes(ax): ax.set_xlabel("km east") ax.set_ylabel("km north")
def stretch(db, lo=-25, hi=0): return np.clip((db - lo) / (hi - lo), 0, 1)
rgb = np.dstack([stretch(before), stretch(after), stretch(after)])rgb[~valid] = 1fig, axes = plt.subplots(1, 3, figsize=(12, 4.6), constrained_layout=True)for ax, arr, title in ((axes[0], before, f"VV before, {pretty[BEFORE]}"), (axes[1], after, f"VV after, {pretty[AFTER]}")): im = ax.imshow(arr, cmap="gray", vmin=-25, vmax=0, extent=extent) ax.set_title(title) km_axes(ax)axes[2].imshow(rgb, extent=extent)axes[2].set_title("Change composite: R = before, G/B = after")km_axes(axes[2])fig.colorbar(im, ax=axes[:2], shrink=0.8, label="VV backscatter (dB)", location="bottom", aspect=40)fig.savefig(OUT / "01-vv-before-after.png")plt.close(fig)
cext = [0, (cx[1] - cx[0]) / 1000, 0, (cy[1] - cy[0]) / 1000]fig, axes = plt.subplots(1, 2, figsize=(10, 4.9), constrained_layout=True)for ax, arr, title in ((axes[0], chip, "Raw VV, 10 m: speckle"), (axes[1], chip_lee, "Lee filter, 7 × 7 window")): im = ax.imshow(to_db(arr), cmap="gray", vmin=-25, vmax=0, extent=cext) ax.set_title(f"{title} ({pretty[AFTER]})") km_axes(ax)fig.colorbar(im, ax=axes, shrink=0.85, label="VV backscatter (dB)")fig.savefig(OUT / "02-speckle-lee-filter.png")plt.close(fig)
fig, ax = plt.subplots(figsize=(8.5, 3.8))bins = np.linspace(-30, 5, 141)ax.hist(before[valid], bins=bins, color="#adb5bd", alpha=0.85, label=f"before ({pretty[BEFORE]})")ax.hist(after[valid], bins=bins, color="#1d6fa3", alpha=0.6, label=f"after ({pretty[AFTER]})")ax.axvline(thr, color="#e8590c", lw=2, label=f"Otsu threshold {thr:.1f} dB")ax.set_xlabel("VV backscatter (dB), Lee-filtered, 40 m")ax.set_ylabel("pixels")ax.set_title("Flood water adds a second, dark peak to the histogram")ax.legend(fontsize=8)ax.grid(axis="y", alpha=0.3)fig.savefig(OUT / "03-otsu-histogram.png")plt.close(fig)
fig, ax = plt.subplots(figsize=(8, 8.4), constrained_layout=True)ax.imshow(before, cmap="gray", vmin=-25, vmax=5, extent=extent)overlay = np.ma.masked_equal(np.where(classes == 255, 0, classes), 0)ax.imshow(overlay, cmap=ListedColormap(["#1d6fa3", "#e8590c"]), vmin=1, vmax=2, extent=extent, interpolation="nearest")handles = [plt.Rectangle((0, 0), 1, 1, color=c) for c in ("#1d6fa3", "#e8590c")]ax.legend(handles, [f"Water before and after ({(classes == 1).sum() * PIXEL_KM2:,.0f} km²)", f"Flooded by {pretty[AFTER]} ({flood.sum() * PIXEL_KM2:,.0f} km²)"], loc="lower right", fontsize=8)for name, lon, lat in (("Manchar Lake", 67.67, 26.42), ("Dadu", 67.78, 26.73), ("Sehwan", 67.86, 26.42)): ex, ey = to_utm.transform(lon, lat) ax.annotate(name, ((ex - x0) / 1000, (ey - y0) / 1000), xytext=(6, 6), textcoords="offset points", color="white", fontsize=8, fontweight="bold") ax.plot((ex - x0) / 1000, (ey - y0) / 1000, "o", ms=3, color="white")ax.set_title(f"Sentinel-1 flood extent, {pretty[AFTER]} vs {pretty[BEFORE]}")km_axes(ax)fig.savefig(OUT / "04-flood-extent-map.png")plt.close(fig)
fig, ax = plt.subplots(figsize=(8.5, 3.6))ax.fill_between(ts.date, ts.water_km2, color="#74c0fc", alpha=0.5, label="all open water")ax.plot(ts.date, ts.flooded_km2, "o-", color="#e8590c", ms=4, label=f"newly flooded vs {pretty[BEFORE]}")for d, v in zip(ts.date, ts.flooded_km2): if d == peak.date: ax.annotate(f"{v:,} km²", (d, v), xytext=(8, 4), textcoords="offset points", fontsize=8)ax.set_ylabel("area (km²)")ax.set_title(f"Water in the {extent[1]:.0f} × {extent[3]:.0f} km study area, Sentinel-1 orbit {ORBIT}, 2022")ax.legend(fontsize=8, loc="upper right")ax.grid(alpha=0.3)ax.xaxis.set_major_formatter(mdates.DateFormatter("%b"))fig.savefig(OUT / "05-flood-area-timeseries.png")plt.close(fig)