Stones C3: add TerraMind-NYC adapter wrapper (lulc + buildings)
Browse filesAdds app/context/terramind_nyc.py wrapping the Apache-2.0 LoRA family
published at msradam/TerraMind-NYC-Adapters. Exposes two specialist
entry points consumable by the FSM:
lulc(s2l2a, s1rtc, dem) -> 5-class macro NYC land-cover
buildings(s2l2a, s1rtc, dem) -> binary NYC building-footprint
Per-call shape:
{ok, n_pixels, shape, class_fractions, dominant_class, dominant_pct,
adapter, repo, elapsed_s} (lulc)
{ok, n_pixels, shape, pct_buildings, n_building_components,
class_labels, adapter, repo, elapsed_s} (buildings)
Lazy load — adapter weights pull from HF Hub on first call and cache
to ~/.cache/huggingface/hub. Base TerraMind 1.0 weights are downloaded
by terratorch's EncoderDecoderFactory, not redistributed by us.
CHIP-SIZE TRAP. TerraMind's positional embeddings don't generalise off
224x224. Inference goes through terratorch.tasks.tiled_inference with
a 224x224 / 128 stride window so chip sizes != 224 are handled
correctly — calling task.model({...}) directly on a larger chip
returns silent garbage. The dict-of-modalities form is supported by
terratorch >= the version pinned in this repo.
Gated by RIPRAP_TERRAMIND_NYC_ENABLE (default 1). Deployments without
terratorch / peft / safetensors / huggingface_hub installed silently
no-op via the same skipped-result shape every other heavy specialist
emits — no noisy ModuleNotFoundError in the FSM trace.
C4 wires this into the FSM as step_terramind_lulc and
step_terramind_buildings; the chip-cache (so a single S2L2A+S1RTC+DEM
fetch per query feeds both calls) lives in C4.
tests/test_terramind_nyc.py exercises the gate paths, public API,
and result-dict shapes without downloading any weights.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app/context/terramind_nyc.py +360 -0
- tests/test_terramind_nyc.py +112 -0
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TerraMind-NYC adapters — LULC and Buildings inference for NYC chips.
|
| 2 |
+
|
| 3 |
+
Wraps the Apache-2.0 [`msradam/TerraMind-NYC-Adapters`](https://huggingface.co/msradam/TerraMind-NYC-Adapters)
|
| 4 |
+
LoRA family fine-tuned on NYC EO chips (Sentinel-2 L2A + Sentinel-1 RTC
|
| 5 |
+
+ Copernicus DEM, temporal stack of 4) on AMD MI300X via AMD Developer
|
| 6 |
+
Cloud. Exposes two specialist entry points:
|
| 7 |
+
|
| 8 |
+
lulc(s2l2a, s1rtc, dem) -> 5-class macro NYC LULC mask
|
| 9 |
+
buildings(s2l2a, s1rtc, dem) -> binary NYC building footprint mask
|
| 10 |
+
|
| 11 |
+
The base TerraMind 1.0 weights are downloaded by terratorch on first
|
| 12 |
+
call; the LoRA adapter + UNet decoder weights come from the HF repo and
|
| 13 |
+
are cached to `~/.cache/huggingface/hub`.
|
| 14 |
+
|
| 15 |
+
CHIP-SIZE TRAP. TerraMind's positional embeddings don't generalise off
|
| 16 |
+
its training resolution (224×224). Calling `task.model({...})` on a
|
| 17 |
+
chip ≠ 224×224 produces silent garbage. We therefore wrap inference
|
| 18 |
+
with `terratorch.tasks.tiled_inference.tiled_inference`, which slides
|
| 19 |
+
a 224×224 crop window across the chip and stitches per-window logits.
|
| 20 |
+
This matches the patch in
|
| 21 |
+
`experiments/18_terramind_nyc_lora/shared/inference_ensemble.py` that
|
| 22 |
+
the plan flags as required for production.
|
| 23 |
+
|
| 24 |
+
Gated by RIPRAP_TERRAMIND_NYC_ENABLE — deployments without the deps
|
| 25 |
+
installed (HF Spaces' Py3.10 cone, plain Ollama dev VMs) silently no-op
|
| 26 |
+
through the same skipped-result shape every other heavy specialist
|
| 27 |
+
emits.
|
| 28 |
+
|
| 29 |
+
This module does NOT fetch its own S2/S1/DEM chips. C4 wires it into
|
| 30 |
+
the FSM with a shared chip cache so the LULC and Buildings calls
|
| 31 |
+
don't each refetch ~150 MB of imagery.
|
| 32 |
+
"""
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import logging
|
| 36 |
+
import os
|
| 37 |
+
import threading
|
| 38 |
+
import time
|
| 39 |
+
from typing import Any
|
| 40 |
+
|
| 41 |
+
log = logging.getLogger("riprap.terramind_nyc")
|
| 42 |
+
|
| 43 |
+
ENABLE = os.environ.get("RIPRAP_TERRAMIND_NYC_ENABLE", "1").lower() in ("1", "true", "yes")
|
| 44 |
+
DEVICE = os.environ.get("RIPRAP_TERRAMIND_NYC_DEVICE", "cpu")
|
| 45 |
+
ADAPTERS_REPO = "msradam/TerraMind-NYC-Adapters"
|
| 46 |
+
|
| 47 |
+
# Per-task config knobs the HF README's quick-start fixes for these
|
| 48 |
+
# adapters. Mirrored from experiments/18_terramind_nyc_lora/adapters/*/
|
| 49 |
+
# config.yaml so a single source of truth lives next to the inference
|
| 50 |
+
# code rather than being scraped from YAML at runtime.
|
| 51 |
+
ADAPTER_SPECS: dict[str, dict[str, Any]] = {
|
| 52 |
+
"lulc": {
|
| 53 |
+
"subdir": "lulc_nyc",
|
| 54 |
+
"num_classes": 5,
|
| 55 |
+
"class_labels": [
|
| 56 |
+
"Trees / vegetation",
|
| 57 |
+
"Cropland",
|
| 58 |
+
"Built / impervious",
|
| 59 |
+
"Bare ground",
|
| 60 |
+
"Water",
|
| 61 |
+
],
|
| 62 |
+
},
|
| 63 |
+
"buildings": {
|
| 64 |
+
"subdir": "buildings_nyc",
|
| 65 |
+
"num_classes": 2,
|
| 66 |
+
# The decoder emits class 0 = background, class 1 = building.
|
| 67 |
+
"class_labels": ["Background", "Building footprint"],
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# Tile-window size — TerraMind's training resolution. Stride < window
|
| 72 |
+
# yields overlap (smooths seams from window-boundary classification
|
| 73 |
+
# noise); 96 px overlap matches the experiments/18 ensemble.
|
| 74 |
+
TILE_SIZE = 224
|
| 75 |
+
TILE_STRIDE = 128
|
| 76 |
+
|
| 77 |
+
# One-shot lazy-init guards. The base TerraMind weights are heavy
|
| 78 |
+
# (~1.6 GB) and we want to load them once across LULC and Buildings.
|
| 79 |
+
_INIT_LOCK = threading.Lock()
|
| 80 |
+
_BASE_LOADED = False
|
| 81 |
+
_ADAPTERS: dict[str, Any] = {} # name -> built terratorch task on DEVICE
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _has_required_deps() -> tuple[bool, str | None]:
|
| 85 |
+
"""Probe the heavy-EO deps. Same shape as prithvi_live's check —
|
| 86 |
+
a missing dep (terratorch / peft / safetensors / hf_hub) returns a
|
| 87 |
+
clean `skipped: deps_unavailable` outcome instead of a noisy
|
| 88 |
+
ModuleNotFoundError in the trace."""
|
| 89 |
+
missing: list[str] = []
|
| 90 |
+
for name in ("terratorch", "peft", "safetensors", "huggingface_hub",
|
| 91 |
+
"torch", "yaml"):
|
| 92 |
+
try:
|
| 93 |
+
__import__(name)
|
| 94 |
+
except ImportError:
|
| 95 |
+
missing.append(name)
|
| 96 |
+
if missing:
|
| 97 |
+
return False, ", ".join(missing)
|
| 98 |
+
return True, None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
_DEPS_OK, _DEPS_MISSING = _has_required_deps()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _ensure_adapter(adapter_name: str):
|
| 105 |
+
"""Build the terratorch SemanticSegmentationTask, inject the LoRA
|
| 106 |
+
scaffold, load the published Δ + decoder weights, return the task.
|
| 107 |
+
|
| 108 |
+
Per-task tasks share the TerraMind base inside terratorch's model
|
| 109 |
+
factory — calling SemanticSegmentationTask twice loads the base
|
| 110 |
+
twice in fp32 (~3.3 GB resident on CPU). For a two-task family this
|
| 111 |
+
is acceptable; we don't need the cross-task weight sharing the
|
| 112 |
+
experiments/18 ensemble does. If memory becomes a problem, swap
|
| 113 |
+
this for a single-task / hot-swap-adapter implementation.
|
| 114 |
+
"""
|
| 115 |
+
if adapter_name not in ADAPTER_SPECS:
|
| 116 |
+
raise KeyError(f"unknown adapter {adapter_name!r}; "
|
| 117 |
+
f"expected one of {list(ADAPTER_SPECS)}")
|
| 118 |
+
if adapter_name in _ADAPTERS:
|
| 119 |
+
return _ADAPTERS[adapter_name]
|
| 120 |
+
|
| 121 |
+
with _INIT_LOCK:
|
| 122 |
+
if adapter_name in _ADAPTERS:
|
| 123 |
+
return _ADAPTERS[adapter_name]
|
| 124 |
+
|
| 125 |
+
spec = ADAPTER_SPECS[adapter_name]
|
| 126 |
+
log.info("terramind_nyc: building task for %s", adapter_name)
|
| 127 |
+
|
| 128 |
+
from huggingface_hub import snapshot_download
|
| 129 |
+
from peft import LoraConfig, inject_adapter_in_model
|
| 130 |
+
from safetensors.torch import load_file
|
| 131 |
+
from terratorch.tasks import SemanticSegmentationTask
|
| 132 |
+
|
| 133 |
+
# 1. Pull the requested adapter subtree from the HF repo.
|
| 134 |
+
adapter_root = snapshot_download(
|
| 135 |
+
ADAPTERS_REPO,
|
| 136 |
+
allow_patterns=[f"{spec['subdir']}/*"],
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# 2. Build the standard terratorch task with the same model_args
|
| 140 |
+
# the published HF_README quick-start uses.
|
| 141 |
+
task = SemanticSegmentationTask(
|
| 142 |
+
model_factory="EncoderDecoderFactory",
|
| 143 |
+
model_args=dict(
|
| 144 |
+
backbone="terramind_v1_base",
|
| 145 |
+
backbone_pretrained=True,
|
| 146 |
+
backbone_modalities=["S2L2A", "S1RTC", "DEM"],
|
| 147 |
+
backbone_use_temporal=True,
|
| 148 |
+
backbone_temporal_pooling="concat",
|
| 149 |
+
backbone_temporal_n_timestamps=4,
|
| 150 |
+
necks=[
|
| 151 |
+
{"name": "SelectIndices", "indices": [2, 5, 8, 11]},
|
| 152 |
+
{"name": "ReshapeTokensToImage", "remove_cls_token": False},
|
| 153 |
+
{"name": "LearnedInterpolateToPyramidal"},
|
| 154 |
+
],
|
| 155 |
+
decoder="UNetDecoder",
|
| 156 |
+
decoder_channels=[512, 256, 128, 64],
|
| 157 |
+
head_dropout=0.1,
|
| 158 |
+
num_classes=spec["num_classes"],
|
| 159 |
+
),
|
| 160 |
+
loss="ce", lr=1e-4, freeze_backbone=False, freeze_decoder=False,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# 3. Inject the LoRA scaffold the adapter weights were trained
|
| 164 |
+
# against. Same hyperparameters every adapter in this family
|
| 165 |
+
# used (see experiments/18 adapters/_template/config.yaml).
|
| 166 |
+
inject_adapter_in_model(LoraConfig(
|
| 167 |
+
r=16, lora_alpha=32, lora_dropout=0.05,
|
| 168 |
+
target_modules=["attn.qkv", "attn.proj"], bias="none",
|
| 169 |
+
), task.model.encoder)
|
| 170 |
+
|
| 171 |
+
# 4. Restore Δ matrices (encoder LoRA) and the decoder/neck/head
|
| 172 |
+
# weights from the safetensors bundle. The encoder.* prefix
|
| 173 |
+
# is stripped because the encoder state-dict is rooted at
|
| 174 |
+
# the encoder module, not the task.
|
| 175 |
+
adapter_dir = f"{adapter_root}/{spec['subdir']}"
|
| 176 |
+
lora_state = load_file(f"{adapter_dir}/adapter_model.safetensors")
|
| 177 |
+
head_state = load_file(f"{adapter_dir}/decoder_head.safetensors")
|
| 178 |
+
encoder_state = {
|
| 179 |
+
k.removeprefix("encoder."): v
|
| 180 |
+
for k, v in lora_state.items() if k.startswith("encoder.")
|
| 181 |
+
}
|
| 182 |
+
task.model.encoder.load_state_dict(encoder_state, strict=False)
|
| 183 |
+
for sub in ("decoder", "neck", "head", "aux_heads"):
|
| 184 |
+
sub_state = {
|
| 185 |
+
k[len(sub) + 1:]: v
|
| 186 |
+
for k, v in head_state.items() if k.startswith(sub + ".")
|
| 187 |
+
}
|
| 188 |
+
if sub_state and hasattr(task.model, sub):
|
| 189 |
+
getattr(task.model, sub).load_state_dict(sub_state,
|
| 190 |
+
strict=False)
|
| 191 |
+
|
| 192 |
+
# 5. Move to the configured device. CUDA only if the caller
|
| 193 |
+
# asked AND a CUDA device is actually available — silently
|
| 194 |
+
# fall back to CPU otherwise.
|
| 195 |
+
target_device = DEVICE
|
| 196 |
+
if target_device == "cuda":
|
| 197 |
+
import torch
|
| 198 |
+
if not torch.cuda.is_available():
|
| 199 |
+
log.warning("terramind_nyc: CUDA unavailable, falling back to CPU")
|
| 200 |
+
target_device = "cpu"
|
| 201 |
+
task = task.to(target_device).eval()
|
| 202 |
+
|
| 203 |
+
_ADAPTERS[adapter_name] = task
|
| 204 |
+
log.info("terramind_nyc: %s ready on %s", adapter_name, target_device)
|
| 205 |
+
return task
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _tiled_predict(task, modality_chips: dict, num_classes: int):
|
| 209 |
+
"""Run the task's encoder-decoder forward in 224×224 tiles, returning
|
| 210 |
+
a (1, num_classes, H, W) logits tensor stitched from the windows.
|
| 211 |
+
|
| 212 |
+
TerraMind's positional embeddings are tied to the 224×224 training
|
| 213 |
+
resolution. terratorch's tiled_inference helper slides a window
|
| 214 |
+
across the input modalities (it accepts a dict of per-modality
|
| 215 |
+
tensors as long as all modalities share H×W), runs the model on
|
| 216 |
+
each crop, and averages overlapping logits. Without it, larger
|
| 217 |
+
chips return silent garbage; smaller chips error on the encoder
|
| 218 |
+
ViT.
|
| 219 |
+
"""
|
| 220 |
+
import torch
|
| 221 |
+
from terratorch.tasks.tiled_inference import tiled_inference
|
| 222 |
+
|
| 223 |
+
# tiled_inference invokes `model_forward(patch)` per tile. The task
|
| 224 |
+
# model returns a ModelOutput-like with .output OR a plain tensor;
|
| 225 |
+
# coerce to tensor either way.
|
| 226 |
+
def _forward(x, **_extra):
|
| 227 |
+
out = task.model(x)
|
| 228 |
+
return out.output if hasattr(out, "output") else out
|
| 229 |
+
|
| 230 |
+
with torch.no_grad():
|
| 231 |
+
logits = tiled_inference(
|
| 232 |
+
_forward,
|
| 233 |
+
modality_chips,
|
| 234 |
+
out_channels=num_classes,
|
| 235 |
+
h_crop=TILE_SIZE,
|
| 236 |
+
w_crop=TILE_SIZE,
|
| 237 |
+
h_stride=TILE_STRIDE,
|
| 238 |
+
w_stride=TILE_STRIDE,
|
| 239 |
+
average_patches=True,
|
| 240 |
+
blend_overlaps=True,
|
| 241 |
+
padding="reflect",
|
| 242 |
+
)
|
| 243 |
+
return logits
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _summarize_lulc(pred, class_labels: list[str]) -> dict[str, Any]:
|
| 247 |
+
"""Per-class pixel fraction + dominant class from an integer mask."""
|
| 248 |
+
import numpy as np
|
| 249 |
+
pred_np = pred.detach().cpu().numpy() if hasattr(pred, "detach") else np.asarray(pred)
|
| 250 |
+
flat = pred_np.reshape(-1)
|
| 251 |
+
n = max(int(flat.size), 1)
|
| 252 |
+
fractions: dict[str, float] = {}
|
| 253 |
+
for idx, label in enumerate(class_labels):
|
| 254 |
+
pct = 100.0 * float((flat == idx).sum()) / n
|
| 255 |
+
if pct > 0:
|
| 256 |
+
fractions[label] = round(pct, 2)
|
| 257 |
+
dominant_idx = int(max(range(len(class_labels)),
|
| 258 |
+
key=lambda i: int((flat == i).sum())))
|
| 259 |
+
return {
|
| 260 |
+
"ok": True,
|
| 261 |
+
"n_pixels": int(flat.size),
|
| 262 |
+
"shape": list(pred_np.shape),
|
| 263 |
+
"class_fractions": fractions,
|
| 264 |
+
"dominant_class": class_labels[dominant_idx],
|
| 265 |
+
"dominant_pct": fractions.get(class_labels[dominant_idx], 0.0),
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _summarize_buildings(pred, class_labels: list[str]) -> dict[str, Any]:
|
| 270 |
+
"""Building-pixel coverage + simple connected-component count."""
|
| 271 |
+
import numpy as np
|
| 272 |
+
pred_np = pred.detach().cpu().numpy() if hasattr(pred, "detach") else np.asarray(pred)
|
| 273 |
+
mask = (pred_np == 1).astype("uint8")
|
| 274 |
+
n_total = max(int(mask.size), 1)
|
| 275 |
+
pct_built = 100.0 * float(mask.sum()) / n_total
|
| 276 |
+
# Connected-component count is a cheap signal of "how many distinct
|
| 277 |
+
# buildings does this chip cover" — useful for the briefing without
|
| 278 |
+
# paying for full polygonisation.
|
| 279 |
+
n_components: int | None = None
|
| 280 |
+
try:
|
| 281 |
+
from scipy.ndimage import label
|
| 282 |
+
_, n_components = label(mask)
|
| 283 |
+
except Exception: # scipy is optional in some HF Spaces build cones
|
| 284 |
+
log.debug("terramind_nyc: scipy.ndimage unavailable; "
|
| 285 |
+
"skipping component count")
|
| 286 |
+
return {
|
| 287 |
+
"ok": True,
|
| 288 |
+
"n_pixels": int(mask.size),
|
| 289 |
+
"shape": list(mask.shape),
|
| 290 |
+
"pct_buildings": round(pct_built, 2),
|
| 291 |
+
"n_building_components": n_components,
|
| 292 |
+
"class_labels": class_labels,
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _run(adapter_name: str, modality_chips: dict, summarizer):
|
| 297 |
+
"""Common boilerplate: gate, time, load, tiled predict, summarize."""
|
| 298 |
+
if not ENABLE:
|
| 299 |
+
return {"ok": False,
|
| 300 |
+
"skipped": "RIPRAP_TERRAMIND_NYC_ENABLE=0"}
|
| 301 |
+
if not _DEPS_OK:
|
| 302 |
+
return {"ok": False,
|
| 303 |
+
"skipped": f"deps unavailable on this deployment: "
|
| 304 |
+
f"{_DEPS_MISSING}"}
|
| 305 |
+
if not modality_chips:
|
| 306 |
+
return {"ok": False, "err": "no modality chips supplied"}
|
| 307 |
+
t0 = time.time()
|
| 308 |
+
try:
|
| 309 |
+
task = _ensure_adapter(adapter_name)
|
| 310 |
+
spec = ADAPTER_SPECS[adapter_name]
|
| 311 |
+
logits = _tiled_predict(task, modality_chips, spec["num_classes"])
|
| 312 |
+
# logits: (B, C, H, W). Argmax to per-pixel class id.
|
| 313 |
+
pred = logits.argmax(dim=1).squeeze(0)
|
| 314 |
+
result = summarizer(pred, spec["class_labels"])
|
| 315 |
+
result["elapsed_s"] = round(time.time() - t0, 2)
|
| 316 |
+
result["adapter"] = adapter_name
|
| 317 |
+
result["repo"] = ADAPTERS_REPO
|
| 318 |
+
return result
|
| 319 |
+
except Exception as e:
|
| 320 |
+
log.exception("terramind_nyc.%s failed", adapter_name)
|
| 321 |
+
return {"ok": False, "err": f"{type(e).__name__}: {e}",
|
| 322 |
+
"elapsed_s": round(time.time() - t0, 2)}
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def lulc(s2l2a, s1rtc=None, dem=None) -> dict[str, Any]:
|
| 326 |
+
"""5-class NYC macro land-cover.
|
| 327 |
+
|
| 328 |
+
Inputs are torch tensors. The temporal models we trained expect
|
| 329 |
+
[C, T, H, W] (preferred) or [C, H, W] (will be expanded to T=1).
|
| 330 |
+
Pass S1 and DEM if you have them — the published adapter was
|
| 331 |
+
trained on the full triplet and accuracy degrades when modalities
|
| 332 |
+
are dropped.
|
| 333 |
+
"""
|
| 334 |
+
chips = {"S2L2A": s2l2a}
|
| 335 |
+
if s1rtc is not None:
|
| 336 |
+
chips["S1RTC"] = s1rtc
|
| 337 |
+
if dem is not None:
|
| 338 |
+
chips["DEM"] = dem
|
| 339 |
+
return _run("lulc", chips, _summarize_lulc)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def buildings(s2l2a, s1rtc=None, dem=None) -> dict[str, Any]:
|
| 343 |
+
"""Binary NYC building-footprint mask. Same input contract as lulc()."""
|
| 344 |
+
chips = {"S2L2A": s2l2a}
|
| 345 |
+
if s1rtc is not None:
|
| 346 |
+
chips["S1RTC"] = s1rtc
|
| 347 |
+
if dem is not None:
|
| 348 |
+
chips["DEM"] = dem
|
| 349 |
+
return _run("buildings", chips, _summarize_buildings)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def warm():
|
| 353 |
+
"""Optional pre-load — amortizes the first-query model build cost."""
|
| 354 |
+
if not ENABLE or not _DEPS_OK:
|
| 355 |
+
return
|
| 356 |
+
try:
|
| 357 |
+
for name in ADAPTER_SPECS:
|
| 358 |
+
_ensure_adapter(name)
|
| 359 |
+
except Exception:
|
| 360 |
+
log.exception("terramind_nyc: warm() failed; specialists will no-op")
|
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the TerraMind-NYC adapter wrapper.
|
| 2 |
+
|
| 3 |
+
These tests don't actually load weights or run inference — they verify
|
| 4 |
+
the gate paths (ENABLE=0, missing deps), the public API surface, and
|
| 5 |
+
the result-dict shape so the FSM specialist can consume the output
|
| 6 |
+
without surprises. Real end-to-end smoke testing happens in commit 4
|
| 7 |
+
once the FSM action wires this in and a chip cache is available.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import importlib
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _reload_with_env(**env):
|
| 18 |
+
"""Reimport the module with mutated environment so module-level
|
| 19 |
+
constants (ENABLE, DEVICE) re-evaluate."""
|
| 20 |
+
for k, v in env.items():
|
| 21 |
+
if v is None:
|
| 22 |
+
os.environ.pop(k, None)
|
| 23 |
+
else:
|
| 24 |
+
os.environ[k] = v
|
| 25 |
+
import app.context.terramind_nyc as m
|
| 26 |
+
return importlib.reload(m)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_module_imports_without_loading_weights():
|
| 30 |
+
"""Importing the module must not download or build the base model."""
|
| 31 |
+
m = _reload_with_env()
|
| 32 |
+
# Adapter cache empty by default.
|
| 33 |
+
assert m._ADAPTERS == {}
|
| 34 |
+
assert {"lulc", "buildings"} <= set(m.ADAPTER_SPECS)
|
| 35 |
+
assert m.ADAPTERS_REPO == "msradam/TerraMind-NYC-Adapters"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_disabled_returns_skipped_outcome():
|
| 39 |
+
m = _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="0")
|
| 40 |
+
assert m.ENABLE is False
|
| 41 |
+
out = m.lulc(None)
|
| 42 |
+
assert out == {"ok": False, "skipped": "RIPRAP_TERRAMIND_NYC_ENABLE=0"}
|
| 43 |
+
out = m.buildings(None, s1rtc=None, dem=None)
|
| 44 |
+
assert out == {"ok": False, "skipped": "RIPRAP_TERRAMIND_NYC_ENABLE=0"}
|
| 45 |
+
# Restore default for the rest of the suite.
|
| 46 |
+
_reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="1")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_unknown_adapter_raises_keyerror():
|
| 50 |
+
m = _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="1")
|
| 51 |
+
with pytest.raises(KeyError):
|
| 52 |
+
m._ensure_adapter("nonsense")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_summarize_lulc_shape():
|
| 56 |
+
"""_summarize_lulc emits the dict shape the FSM doc-builder will
|
| 57 |
+
consume — class fractions, dominant class, dominant pct, n_pixels."""
|
| 58 |
+
import numpy as np
|
| 59 |
+
m = _reload_with_env()
|
| 60 |
+
pred = np.array([[0, 0, 0],
|
| 61 |
+
[2, 2, 2],
|
| 62 |
+
[4, 4, 4]])
|
| 63 |
+
labels = ["Trees", "Cropland", "Built", "Bare", "Water"]
|
| 64 |
+
out = m._summarize_lulc(pred, labels)
|
| 65 |
+
assert out["ok"] is True
|
| 66 |
+
assert out["n_pixels"] == 9
|
| 67 |
+
assert out["shape"] == [3, 3]
|
| 68 |
+
# Three classes appeared, equally; dominant is the FIRST in argmax tie.
|
| 69 |
+
assert set(out["class_fractions"]) == {"Trees", "Built", "Water"}
|
| 70 |
+
for v in out["class_fractions"].values():
|
| 71 |
+
assert v == pytest.approx(33.33, abs=0.1)
|
| 72 |
+
assert out["dominant_class"] in {"Trees", "Built", "Water"}
|
| 73 |
+
assert out["dominant_pct"] > 0
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_summarize_buildings_shape():
|
| 77 |
+
import numpy as np
|
| 78 |
+
m = _reload_with_env()
|
| 79 |
+
pred = np.array([[0, 0, 1],
|
| 80 |
+
[0, 1, 1],
|
| 81 |
+
[0, 0, 0]])
|
| 82 |
+
labels = ["Background", "Building footprint"]
|
| 83 |
+
out = m._summarize_buildings(pred, labels)
|
| 84 |
+
assert out["ok"] is True
|
| 85 |
+
assert out["n_pixels"] == 9
|
| 86 |
+
assert out["pct_buildings"] == pytest.approx(33.33, abs=0.1)
|
| 87 |
+
assert out["class_labels"] == labels
|
| 88 |
+
# scipy.ndimage may or may not be installed; the helper degrades
|
| 89 |
+
# rather than raising. If it's installed, two diagonal/adjacent
|
| 90 |
+
# building pixels should land in one connected component.
|
| 91 |
+
assert out["n_building_components"] in {None, 1}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_public_api_signatures():
|
| 95 |
+
m = _reload_with_env()
|
| 96 |
+
import inspect
|
| 97 |
+
for fn in (m.lulc, m.buildings):
|
| 98 |
+
sig = inspect.signature(fn)
|
| 99 |
+
params = list(sig.parameters)
|
| 100 |
+
# Caller may pass S2 only OR S2+S1+DEM.
|
| 101 |
+
assert params[0] == "s2l2a"
|
| 102 |
+
assert "s1rtc" in params
|
| 103 |
+
assert "dem" in params
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_warm_is_no_op_when_disabled():
|
| 107 |
+
"""warm() must not download anything when ENABLE=0 or deps missing."""
|
| 108 |
+
m = _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="0")
|
| 109 |
+
# No exceptions, no side effects.
|
| 110 |
+
m.warm()
|
| 111 |
+
assert m._ADAPTERS == {}
|
| 112 |
+
_reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="1")
|