"""Build-time Sentinel-2 AOI bake for the curated Sentinel-2 (Track-B) tab — M5 Phase 4. **BUILD-TIME ONLY.** This is the *only* place Planetary Computer STAC / rasterio run. Its deps (``pystac-client``, ``planetary-computer``, ``rasterio``) are **build-time only** and are never added to ``app/backend/requirements.txt`` — the runtime Space image stays STAC-free and serves the baked cache exactly like the aerial curated mode (no runtime inference, no runtime STAC, no GPU). For each confirmed AOI (a real-world location with large, obvious change visible even at 10 m) it: 1. Resolves the two confirmed low-cloud S2 **L2A** acquisitions from PC STAC (exact day + tile). 2. Reads a fixed window of bands **B04,B03,B02,B08** (R,G,B,NIR) around the AOI centre. Co-registration is trivial: each AOI's before/after share one MGRS tile → identical UTM grid, so the same pixel window is aligned by construction (asserted). 3. Harmonises reflectance across processing baselines (baseline ≥ 04.00 carries ``BOA_ADD_OFFSET`` ``-1000``; OSCD predates it) so both dates and the model input match training's radiometry. 4. Renders 8-bit RGB display PNGs (joint percentile stretch so before/after are radiometrically comparable). 5. Runs the OSCD 4-band ONNX bundle **offline** with its own tile-stitch — deliberately *not* the ``inference.py`` path, which is hard-coded RGB (3-band). ``inference.py._overlay_png`` IS band-agnostic and is reused for the amber-fill + outline overlay. 6. Bakes ``before.png`` / ``after.png`` / ``overlay.png`` + ``manifest.json`` + a predictions cache under the gitignored ``app/backend/data/sentinel2/`` — the cache schema the app already serves. Usage (from the repo root, with the CPU ``.venv`` that has the STAC/rasterio deps):: python app/backend/build_sentinel2.py # bake all AOIs python app/backend/build_sentinel2.py --only gerd_reservoir --window 1536 """ from __future__ import annotations import argparse import base64 import json import sys import time from dataclasses import dataclass, field from pathlib import Path from typing import Any import numpy as np import onnxruntime as ort import planetary_computer as pc import pystac_client import rasterio from PIL import Image from rasterio.warp import transform as warp_transform from rasterio.windows import Window # reuse the band-agnostic overlay renderer from inference.py (flat import, as in app.py) sys.path.insert(0, str(Path(__file__).resolve().parent)) from inference import _overlay_png # noqa: E402 _HERE = Path(__file__).resolve().parent _REPO = _HERE.parent.parent BUNDLE_DIR = _REPO / "bundles" / "oscd_s2_baseline" OUT_DIR = _HERE / "data" / "sentinel2" MODEL_ID = "oscd_s2_baseline" STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1" # Sentinel-2 bands in the OSCD band order [R, G, B, NIR]. BANDS = ("B04", "B03", "B02", "B08") @dataclass class AOI: """A curated Sentinel-2 change site: two confirmed low-cloud acquisitions on one MGRS tile.""" id: str title: str description: str lat: float lon: float tile: str # MGRS tile, e.g. "T40RCP" — before/after share it → aligned by construction date_before: str # "YYYY-MM-DD" date_after: str window: int = 1024 # crop side in 10 m pixels (10.24 km); tuned per feature scale # The 5 confirmed AOIs (arid/engineered, crispest imagery — reclamation / reservoir / airport / # solar / desert city). Dates + tiles verified low-cloud S2 L2A with all four bands present. AOIS: list[AOI] = [ AOI( "dubai_deira", "Dubai — Deira Islands reclamation", "Land reclaimed from the Persian Gulf: new coastline and island fill off Deira.", 25.30, 55.34, "T40RCP", "2016-03-03", "2023-04-28", window=1024, ), AOI( "gerd_reservoir", "Grand Ethiopian Renaissance Dam — reservoir filling", "The Blue Nile reservoir behind the GERD fills — open water where there was valley.", 11.21, 35.09, "T36PYT", "2020-02-14", "2023-12-25", window=1536, ), AOI( "beijing_daxing", "Beijing Daxing International Airport", "A greenfield mega-airport built from farmland — terminal, aprons and runways appear.", 39.51, 116.41, "T50SMJ", "2016-10-10", "2020-10-04", # same-season (early-Oct), same-orbit (R075) after — minimal crop delta window=1024, ), AOI( "bhadla_solar", "Bhadla Solar Park, Rajasthan", "One of the world's largest solar parks spreads across the Thar desert.", 27.53, 71.91, "T42RYR", "2017-12-17", "2021-12-16", window=1280, ), AOI( "egypt_newcapital", "New Administrative Capital, Egypt", "A new capital city rises from open desert east of Cairo.", 30.01, 31.75, "T36RUU", "2016-08-27", "2023-08-26", window=1280, ), ] # ------------------------------------------------------------------------------------------------- # OSCD 4-band ONNX inference (standalone tile-stitch; the eval-harness / bundle preprocessing) # ------------------------------------------------------------------------------------------------- @dataclass class OscdModel: """The OSCD 4-band ONNX bundle + its documented preprocessing (band order, scaling, norm).""" pre: dict[str, Any] _session: ort.InferenceSession = field(repr=False) @classmethod def load(cls, bundle_dir: Path) -> OscdModel: pre = json.loads((bundle_dir / "preprocessing.json").read_text()) so = ort.SessionOptions() so.intra_op_num_threads = 2 sess = ort.InferenceSession( str(bundle_dir / "model.onnx"), sess_options=so, providers=["CPUExecutionProvider"] ) return cls(pre=pre, _session=sess) @property def mean(self) -> np.ndarray: return np.asarray(self.pre["normalization"]["mean"], dtype=np.float32).reshape(-1, 1, 1) @property def std(self) -> np.ndarray: return np.asarray(self.pre["normalization"]["std"], dtype=np.float32).reshape(-1, 1, 1) @property def threshold(self) -> float: return float(self.pre["output"]["recommended_threshold"]) @property def tile_size(self) -> int: return int(self.pre.get("tiling", {}).get("tile_size", 256)) @property def input_size(self) -> int: return int(self.pre.get("input_size", 256)) def _standardize(self, refl: np.ndarray) -> np.ndarray: """(4, H, W) float32 reflectance -> standardized model input (÷scale already applied).""" return (refl - self.mean) / self.std def predict( self, before: np.ndarray, after: np.ndarray ) -> tuple[np.ndarray, np.ndarray, int, float]: """Tile the scene into ``tile_size`` crops, run each 4-band pair through the ONNX model, and stitch the probability map — the same per-tile procedure as ``src/evaluate.py`` and the bundle's documented preprocessing. Returns ``(mask, prob, n_tiles, elapsed_ms)``. ``before``/``after`` are ``(4, H, W)`` standardized inputs. The model is fully convolutional with dynamic H/W and ``input_size == tile_size``, so each tile is fed at its native size.""" _, h, w = before.shape tile = self.tile_size prob = np.zeros((h, w), dtype=np.float32) n_tiles = 0 t0 = time.perf_counter() for y0 in range(0, h, tile): for x0 in range(0, w, tile): y1, x1 = min(y0 + tile, h), min(x0 + tile, w) b = before[:, y0:y1, x0:x1] a = after[:, y0:y1, x0:x1] x = np.stack([b, a], axis=0)[None].astype(np.float32) # (1, 2, 4, t, t) logits = self._session.run(["logits"], {"input": x})[0] prob[y0:y1, x0:x1] = 1.0 / (1.0 + np.exp(-logits[0, 0])) n_tiles += 1 elapsed_ms = (time.perf_counter() - t0) * 1000.0 mask = prob >= self.threshold return mask, prob, n_tiles, elapsed_ms # ------------------------------------------------------------------------------------------------- # STAC fetch + windowed read + radiometric harmonisation # ------------------------------------------------------------------------------------------------- COVER_MIN = 0.995 # a scene must fill the AOI window; same MGRS tile ≠ same orbit swath coverage CLOUD_MAX = 25.0 # generous ceiling for a fallback; confirmed scenes are all < 1% def open_catalog() -> pystac_client.Client: return pystac_client.Client.open(STAC_URL, modifier=pc.sign_inplace) def _day(item: Any) -> str: return item.datetime.strftime("%Y-%m-%d") def _coverage(item: Any, aoi: AOI) -> float: """Fraction of the AOI window with valid (non-nodata) data. A given acquisition only fills the part of its MGRS tile its orbit swath covers, so ``eo:cloud_cover`` alone is not enough.""" with rasterio.open(item.assets["B04"].href) as ds: dn = ds.read(1, window=_window_from_center(ds, aoi.lon, aoi.lat, aoi.window)) return float((dn > 0).mean()) def find_item(cat: pystac_client.Client, aoi: AOI, date: str) -> tuple[Any, str, bool]: """Resolve an S2 L2A item that actually *covers* the AOI window. Prefers the confirmed ``date`` (lowest cloud if reprocessed twice); if that acquisition's swath misses the window, falls back to the lowest-cloud covering scene that month on the same tile. Returns ``(item, day, fb)``.""" y, m, _ = date.split("-") search = cat.search( collections=["sentinel-2-l2a"], intersects={"type": "Point", "coordinates": [aoi.lon, aoi.lat]}, datetime=f"{y}-{m}-01/{y}-{m}-28", ) want_tile = aoi.tile.lstrip("T") same_tile = [ it for it in search.items() if it.properties.get("s2:mgrs_tile") == want_tile and it.datetime is not None ] if not same_tile: raise RuntimeError(f"{aoi.id}: no S2 L2A item on tile {aoi.tile} in {y}-{m}") cloud = lambda it: it.properties.get("eo:cloud_cover", 100.0) # noqa: E731 # 1) the confirmed day, if its swath covers the window for it in sorted((x for x in same_tile if _day(x) == date), key=cloud): if _coverage(it, aoi) >= COVER_MIN: return it, date, False # 2) fallback — any covering low-cloud scene that month; lowest cloud, then nearest target day target = int(date[8:10]) covering = [ it for it in same_tile if cloud(it) <= CLOUD_MAX and _coverage(it, aoi) >= COVER_MIN ] if not covering: raise RuntimeError( f"{aoi.id}: no covering low-cloud S2 scene near {date} on tile {aoi.tile}" ) covering.sort(key=lambda it: (cloud(it), abs(int(_day(it)[8:10]) - target))) chosen = covering[0] return chosen, _day(chosen), True def _boa_offset(item: Any) -> float: """DN offset to subtract before ÷10000. Baseline ≥ 04.00 (post 2022-01-25) carries ``BOA_ADD_OFFSET = -1000``; OSCD predates it, so we harmonise every scene to that convention.""" baseline = item.properties.get("s2:processing_baseline") try: if baseline is not None and float(baseline) >= 4.0: return 1000.0 except (TypeError, ValueError): pass # fallback on acquisition date if the baseline field is absent dt = item.datetime if dt is not None and dt.strftime("%Y-%m-%d") >= "2022-01-25": return 1000.0 return 0.0 def _window_from_center(ds: rasterio.io.DatasetReader, lon: float, lat: float, win: int) -> Window: """A ``win×win`` pixel window centred on (lon,lat), clamped inside the raster.""" xs, ys = warp_transform("EPSG:4326", ds.crs, [lon], [lat]) row, col = ds.index(xs[0], ys[0]) col_off = int(round(col - win / 2)) row_off = int(round(row - win / 2)) col_off = max(0, min(col_off, ds.width - win)) row_off = max(0, min(row_off, ds.height - win)) return Window(col_off, row_off, win, win) def read_reflectance(item: Any, aoi: AOI) -> tuple[np.ndarray, tuple[float, float]]: """Read the AOI window for all four bands and return harmonised reflectance ``(4, win, win)`` plus the geographic ``(transform_a, origin)`` fingerprint used to assert co-registration.""" offset = _boa_offset(item) bands: list[np.ndarray] = [] grid_fp: tuple[float, float] | None = None win: Window | None = None for band in BANDS: href = item.assets[band].href # already pc-signed via sign_inplace modifier with rasterio.open(href) as ds: if win is None: win = _window_from_center(ds, aoi.lon, aoi.lat, aoi.window) grid_fp = (ds.transform.a, ds.transform.c + ds.transform.f) dn = ds.read(1, window=win).astype(np.float32) refl = np.clip((dn - offset) / 10000.0, 0.0, None) bands.append(refl) assert grid_fp is not None return np.stack(bands, axis=0), grid_fp def stretch_rgb( before_rgb: np.ndarray, after_rgb: np.ndarray, lo_pct: float = 2.0, hi_pct: float = 98.0 ) -> tuple[np.ndarray, np.ndarray]: """Joint per-channel percentile stretch of two ``(3, H, W)`` reflectance stacks → uint8 HxWx3. Shared bounds (computed over both dates) keep before/after radiometrically comparable, so the swipe slider shows real change, not a stretch artefact.""" def to_uint8(rgb: np.ndarray, lo: np.ndarray, hi: np.ndarray) -> np.ndarray: out = np.empty(rgb.shape, dtype=np.float32) for c in range(3): span = max(float(hi[c] - lo[c]), 1e-6) out[c] = np.clip((rgb[c] - lo[c]) / span, 0.0, 1.0) return (out * 255.0).round().astype(np.uint8).transpose(1, 2, 0) both = np.concatenate([before_rgb, after_rgb], axis=1) # (3, 2H, W) lo = np.array([np.percentile(both[c], lo_pct) for c in range(3)], dtype=np.float32) hi = np.array([np.percentile(both[c], hi_pct) for c in range(3)], dtype=np.float32) return to_uint8(before_rgb, lo, hi), to_uint8(after_rgb, lo, hi) # ------------------------------------------------------------------------------------------------- # Bake # ------------------------------------------------------------------------------------------------- def _overlay_data_url_to_png(data_url: str, path: Path) -> None: """Write the base64 PNG produced by ``_overlay_png`` to ``path``.""" _, _, b64 = data_url.partition(",") path.write_bytes(base64.b64decode(b64)) def bake_aoi(cat: pystac_client.Client, model: OscdModel, aoi: AOI) -> tuple[dict, dict]: """Fetch, run the model offline, write the cache artefacts. Returns ``(manifest, pred)``.""" item_b, day_b, fb_b = find_item(cat, aoi, aoi.date_before) item_a, day_a, fb_a = find_item(cat, aoi, aoi.date_after) cloud_b = float(item_b.properties.get("eo:cloud_cover", 0.0)) cloud_a = float(item_a.properties.get("eo:cloud_cover", 0.0)) fb = lambda flag, want, got: ( # noqa: E731 f" (fallback from {want}: confirmed swath misses AOI)" if flag else "" ) print( f"[{aoi.id}] before={day_b} {item_b.id} ({cloud_b:.2f}%)" f"{fb(fb_b, aoi.date_before, day_b)}\n" f" after={day_a} {item_a.id} ({cloud_a:.2f}%){fb(fb_a, aoi.date_after, day_a)}" ) refl_b, grid_b = read_reflectance(item_b, aoi) refl_a, grid_a = read_reflectance(item_a, aoi) # co-registration check: same MGRS tile => identical grid (pixel size + origin) within tolerance if not (abs(grid_b[0] - grid_a[0]) < 1e-6 and abs(grid_b[1] - grid_a[1]) < 1.0): raise RuntimeError(f"{aoi.id}: before/after grids differ ({grid_b} vs {grid_a})") before_rgb_u8, after_rgb_u8 = stretch_rgb(refl_b[:3], refl_a[:3]) inp_b = model._standardize(refl_b) inp_a = model._standardize(refl_a) mask, prob, n_tiles, elapsed_ms = model.predict(inp_b, inp_a) h, w = mask.shape overlay_url = _overlay_png(mask, (w, h)) out = OUT_DIR / aoi.id out.mkdir(parents=True, exist_ok=True) Image.fromarray(before_rgb_u8, mode="RGB").save(out / "before.png") Image.fromarray(after_rgb_u8, mode="RGB").save(out / "after.png") _overlay_data_url_to_png(overlay_url, out / "overlay.png") changed_frac = float(mask.mean()) mean_conf_changed = float(prob[mask].mean()) if mask.any() else 0.0 stats = { "changed_fraction": changed_frac, "changed_percent": round(100.0 * changed_frac, 2), "mean_confidence_changed": round(mean_conf_changed, 4), "mean_confidence_overall": round(float(prob.mean()), 4), "changed_pixels": int(mask.sum()), "total_pixels": int(mask.size), } manifest = { "id": aoi.id, "title": aoi.title, "description": aoi.description, "source": "Sentinel-2 L2A · 10 m", "tile": aoi.tile, "center": [aoi.lon, aoi.lat], "width": w, "height": h, "date_before": day_b, "date_after": day_a, "cloud_before": round(cloud_b, 2), "cloud_after": round(cloud_a, 2), "scene_before": item_b.id, "scene_after": item_a.id, } prediction = { "overlay_png": overlay_url, "threshold": model.threshold, "is_placeholder": False, "stats": stats, "elapsed_ms": round(elapsed_ms, 1), "input_size": model.input_size, "n_tiles": n_tiles, "pair_id": aoi.id, "model_id": MODEL_ID, } print( f" baked {w}×{h}px · {n_tiles} tiles · changed {stats['changed_percent']}% · " f"{elapsed_ms:.0f} ms" ) return manifest, prediction def main() -> None: ap = argparse.ArgumentParser(description="Bake curated Sentinel-2 AOIs (build-time).") ap.add_argument("--only", default=None, help="bake a single AOI id (default: all)") ap.add_argument("--window", type=int, default=None, help="override crop side in px for --only") ap.add_argument("--bundle", default=str(BUNDLE_DIR), help="OSCD ONNX bundle dir") args = ap.parse_args() aois = AOIS if args.only is None else [a for a in AOIS if a.id == args.only] if not aois: raise SystemExit(f"no AOI matches --only {args.only!r}") if args.window is not None: for a in aois: a.window = args.window model = OscdModel.load(Path(args.bundle)) cat = open_catalog() OUT_DIR.mkdir(parents=True, exist_ok=True) # merge with any existing manifest/cache so `--only` bakes are incremental manifest_path = OUT_DIR / "manifest.json" cache_path = OUT_DIR / "_predictions.json" manifest_by_id: dict[str, dict] = {} if manifest_path.exists(): for e in json.loads(manifest_path.read_text()).get("pairs", []): manifest_by_id[e["id"]] = e cache: dict[str, dict] = json.loads(cache_path.read_text()) if cache_path.exists() else {} for aoi in aois: # STAC/COG network reads are occasionally flaky (RemoteDisconnected) — retry the whole AOI. last: Exception | None = None for attempt in range(1, 5): try: manifest, prediction = bake_aoi(cat, model, aoi) manifest_by_id[aoi.id] = manifest cache[aoi.id] = prediction break except Exception as exc: # noqa: BLE001 — build-time resilience over flaky network reads if isinstance(exc, RuntimeError) and "no covering" in str(exc): raise # a real coverage failure, not a transient network blip — surface it last = exc print(f" [{aoi.id}] attempt {attempt}/4 failed ({exc}); retrying…") time.sleep(3 * attempt) else: raise RuntimeError(f"{aoi.id}: baking failed after retries") from last ordered = [manifest_by_id[a.id] for a in AOIS if a.id in manifest_by_id] manifest_path.write_text(json.dumps({"pairs": ordered}, indent=2)) cache_path.write_text(json.dumps(cache)) print(f"\nwrote {manifest_path.relative_to(_REPO)} ({len(ordered)} AOIs) + {cache_path.name}") if __name__ == "__main__": main()