Spaces:
Sleeping
Sleeping
| """ONNX bundle inference for the curated Space (Track A, CPU onnxruntime). | |
| A *bundle* is exactly what ``src/export.py`` emits (PRD §3/§9): a directory with ``model.onnx`` | |
| and ``preprocessing.json`` (normalization, input size, band order, tiling, recommended threshold). | |
| The demo consumes only the bundle — it never imports the training code. This module discovers | |
| bundles under ``BUNDLES_DIR``, lazily builds an ``onnxruntime`` session per model, and runs a | |
| before/after RGB pair through the documented preprocessing to produce a change-mask overlay + stats. | |
| The preprocessing here mirrors ``src/data/levircd.py`` + the export contract: resize each date to | |
| the bundle's ``input_size`` (DINOv2 needs the fixed 448 grid; the CNN tiers accept it too), scale to | |
| [0, 1], standardize with the bundle's mean/std, stack the two dates to ``(1, 2, 3, S, S)``. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import io | |
| import json | |
| import time | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import onnxruntime as ort | |
| from PIL import Image, ImageFilter | |
| # Amber "thermal" change signal, painted directly into the overlay PNG (the frontend no longer | |
| # hue-rotates). Rendered as a translucent fill with a brighter, crisp 1-2px outline so the buildings | |
| # underneath stay visible. The opacity slider (CSS) scales the whole overlay. | |
| FILL_RGB = (255, 141, 52) | |
| EDGE_RGB = (255, 201, 112) | |
| FILL_ALPHA = 130 # ~51% in-PNG; slider default 0.75 -> ~0.38 effective fill | |
| EDGE_ALPHA = 255 | |
| class Bundle: | |
| """A loaded model bundle: its preprocessing contract + a lazily-created ORT session.""" | |
| model_id: str | |
| root: Path | |
| preprocessing: dict[str, Any] | |
| metrics_card: str | |
| _session: ort.InferenceSession | None = field(default=None, repr=False) | |
| def input_size(self) -> int: | |
| return int(self.preprocessing.get("input_size", 256)) | |
| def threshold(self) -> float: | |
| return float(self.preprocessing.get("output", {}).get("recommended_threshold", 0.5)) | |
| def tile_size(self) -> int: | |
| """Native tile the model was trained on (0.5 m/px). The full scene is tiled into these, | |
| each inferred at ``input_size`` and stitched — the bundle's documented preprocessing.""" | |
| return int(self.preprocessing.get("tiling", {}).get("tile_size", self.input_size)) | |
| def mean(self) -> np.ndarray: | |
| m = self.preprocessing.get("normalization", {}).get("mean", [0.485, 0.456, 0.406]) | |
| return np.asarray(m, dtype=np.float32).reshape(3, 1, 1) | |
| def std(self) -> np.ndarray: | |
| s = self.preprocessing.get("normalization", {}).get("std", [0.229, 0.224, 0.225]) | |
| return np.asarray(s, dtype=np.float32).reshape(3, 1, 1) | |
| def is_placeholder(self) -> bool: | |
| return "RANDOM-INIT" in str(self.preprocessing.get("weights", "")) | |
| def session(self) -> ort.InferenceSession: | |
| if self._session is None: | |
| so = ort.SessionOptions() | |
| so.intra_op_num_threads = 2 # HF free tier is ~2 vCPU; ORT clamps to the host anyway | |
| self._session = ort.InferenceSession( | |
| str(self.root / "model.onnx"), | |
| sess_options=so, | |
| providers=["CPUExecutionProvider"], | |
| ) | |
| return self._session | |
| def summary(self) -> dict[str, Any]: | |
| cfg_name = self.preprocessing.get("dinov2_note") | |
| return { | |
| "id": self.model_id, | |
| "input_size": self.input_size, | |
| "dynamic_hw": bool(self.preprocessing.get("dynamic_hw", False)), | |
| "threshold": self.threshold, | |
| "band_order": self.preprocessing.get("band_order", ["R", "G", "B"]), | |
| "is_placeholder": self.is_placeholder, | |
| "fixed_grid": cfg_name is not None, | |
| } | |
| def _to_input(img: Image.Image, size: int, mean: np.ndarray, std: np.ndarray) -> np.ndarray: | |
| """PIL RGB -> normalized ``(3, size, size)`` float32 (resize bilinear, /255, standardize).""" | |
| arr = np.asarray(img.convert("RGB").resize((size, size), Image.BILINEAR), dtype=np.float32) | |
| chw = arr.transpose(2, 0, 1) / 255.0 | |
| return (chw - mean) / std | |
| def _overlay_png(mask: np.ndarray, out_size: tuple[int, int]) -> str: | |
| """Render the change mask as a translucent amber fill + a crisp outline, at display resolution. | |
| Display-only: the ``mask`` is the model's thresholded output (stats are computed from it | |
| upstream, unchanged). Here we only (a) lightly smooth the contour to drop stair-step edges, | |
| (b) fill each changed region at a low, uniform alpha so buildings stay visible, and (c) trace a | |
| brighter 1-2px outline around each region. Returned as a PNG data URL. | |
| """ | |
| binary = Image.fromarray((mask.astype(np.uint8) * 255), mode="L") | |
| # smooth the boundary (blur then re-binarize) — removes the stair-stepping of a hard mask | |
| smoothed = np.asarray(binary.filter(ImageFilter.GaussianBlur(1.2))) >= 128 | |
| sm_img = Image.fromarray((smoothed.astype(np.uint8) * 255), mode="L") | |
| dil = np.asarray(sm_img.filter(ImageFilter.MaxFilter(3))) >= 128 # ~1px grow | |
| ero = np.asarray(sm_img.filter(ImageFilter.MinFilter(3))) >= 128 # ~1px shrink | |
| edge = dil & ~ero # ~2px band straddling the boundary | |
| h, w = mask.shape | |
| rgba = np.zeros((h, w, 4), dtype=np.uint8) | |
| for c in range(3): | |
| rgba[..., c] = np.where(smoothed, FILL_RGB[c], 0) | |
| rgba[..., 3] = np.where(smoothed, FILL_ALPHA, 0).astype(np.uint8) | |
| for c in range(3): | |
| rgba[edge, c] = EDGE_RGB[c] | |
| rgba[edge, 3] = EDGE_ALPHA | |
| im = Image.fromarray(rgba, mode="RGBA") | |
| if im.size != out_size: | |
| im = im.resize(out_size, Image.BILINEAR) | |
| buf = io.BytesIO() | |
| im.save(buf, format="PNG") | |
| return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii") | |
| class BundleRegistry: | |
| """Discovers and caches model bundles under ``bundles_dir``.""" | |
| def __init__(self, bundles_dir: str | Path) -> None: | |
| self.bundles_dir = Path(bundles_dir) | |
| self._bundles: dict[str, Bundle] = {} | |
| self.reload() | |
| def reload(self) -> None: | |
| self._bundles.clear() | |
| if not self.bundles_dir.exists(): | |
| return | |
| for child in sorted(self.bundles_dir.iterdir()): | |
| pre = child / "preprocessing.json" | |
| onnx = child / "model.onnx" | |
| if not (pre.exists() and onnx.exists()): | |
| continue | |
| card = child / "metrics_card.md" | |
| self._bundles[child.name] = Bundle( | |
| model_id=child.name, | |
| root=child, | |
| preprocessing=json.loads(pre.read_text()), | |
| metrics_card=card.read_text() if card.exists() else "", | |
| ) | |
| def ids(self) -> list[str]: | |
| return list(self._bundles) | |
| def get(self, model_id: str) -> Bundle: | |
| if model_id not in self._bundles: | |
| raise KeyError(model_id) | |
| return self._bundles[model_id] | |
| def summaries(self) -> list[dict[str, Any]]: | |
| return [b.summary() for b in self._bundles.values()] | |
| def _infer_tile( | |
| self, bundle: Bundle, before_t: Image.Image, after_t: Image.Image | |
| ) -> np.ndarray: | |
| """One native tile through the model -> per-pixel change probability (model output grid).""" | |
| size = bundle.input_size | |
| x = np.stack( | |
| [ | |
| _to_input(before_t, size, bundle.mean, bundle.std), | |
| _to_input(after_t, size, bundle.mean, bundle.std), | |
| ], | |
| axis=0, | |
| )[None].astype(np.float32) # (1, 2, 3, S, S) | |
| logits = bundle.session().run(["logits"], {"input": x})[0] | |
| return 1.0 / (1.0 + np.exp(-logits[0, 0])) # (S, S) | |
| def predict(self, model_id: str, before: Image.Image, after: Image.Image) -> dict[str, Any]: | |
| """Tile the full scene into native tiles, infer each, stitch the probability map, then | |
| threshold + render. This is the bundle's documented preprocessing (``tiling.tile_size``); | |
| the model, threshold and per-pixel metric are unchanged — they are just applied per tile, | |
| exactly as the evaluation harness does. Feeding the whole scene in one pass would break the | |
| model (a 0.5 m/px model at ~4x the trained field of view detects almost nothing).""" | |
| bundle = self.get(model_id) | |
| before = before.convert("RGB") | |
| after = after.convert("RGB") | |
| w, h = before.size | |
| tile = bundle.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): | |
| x1, y1 = min(x0 + tile, w), min(y0 + tile, h) | |
| box = (x0, y0, x1, y1) | |
| p = self._infer_tile(bundle, before.crop(box), after.crop(box)) # (S, S) | |
| # resize this tile's probability back to its native footprint, then place it | |
| p_tile = np.asarray( | |
| Image.fromarray(p.astype(np.float32), mode="F").resize( | |
| (x1 - x0, y1 - y0), Image.BILINEAR | |
| ), | |
| dtype=np.float32, | |
| ) | |
| prob[y0:y1, x0:x1] = p_tile | |
| n_tiles += 1 | |
| elapsed_ms = (time.perf_counter() - t0) * 1000.0 | |
| thr = bundle.threshold | |
| mask = prob >= thr # stitched native-resolution mask; stats derive from this | |
| overlay = _overlay_png(mask, (w, h)) | |
| changed_frac = float(mask.mean()) | |
| mean_conf_changed = float(prob[mask].mean()) if mask.any() else 0.0 | |
| return { | |
| "overlay_png": overlay, | |
| "threshold": thr, | |
| "is_placeholder": bundle.is_placeholder, | |
| "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), | |
| }, | |
| "elapsed_ms": round(elapsed_ms, 1), | |
| "input_size": bundle.input_size, | |
| "n_tiles": n_tiles, | |
| } | |