geospatial1 / backend /inference.py
Umar666's picture
Deploy curated change-detection demo (FastAPI + React/MapLibre, Docker :7860); bundles pulled from the Model repo at startup
d1ac326 verified
Raw
History Blame Contribute Delete
10.4 kB
"""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,
}