Spaces:
Sleeping
Sleeping
File size: 10,352 Bytes
d1ac326 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | """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
@dataclass
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)
@property
def input_size(self) -> int:
return int(self.preprocessing.get("input_size", 256))
@property
def threshold(self) -> float:
return float(self.preprocessing.get("output", {}).get("recommended_threshold", 0.5))
@property
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))
@property
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)
@property
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)
@property
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,
}
|