Spaces:
Running on Zero
Running on Zero
File size: 12,305 Bytes
b710eca | 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 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | """Class map + nDSM -> discrete 3D objects: trees, buildings, water.
The seg head says *what* each pixel is and the height heads say *how tall*. A
renderer wants neither: it wants "a 9 m tree here, a 12 m building with this
footprint there". This module is that step. It is pure numpy / scipy /
scikit-image and runs on the CPU after the GPU reservation has been handed back.
trees tree pixels >= `min_tree_height_m`, split into crowns by a
watershed on the smoothed canopy height, seeded at its peaks
buildings building components; height = median of the footprint's core
(edges eroded away, where the head blends roof into ground)
water water components, outline only
Output contract (`objects.json`, `version` 1)
---------------------------------------------
Positions are in **pixels of the height grid**, origin at the top-left *corner*
of the grid: pixel (row, col) covers x in [col, col + 1), y in [row, row + 1),
so its centre is (col + 0.5, row + 0.5). Multiply by `grid.gsd_m` for metres.
Heights, radii and areas are already metric.
trees[] {x, y, h, r} crown centroid, top height (m), crown radius (m)
buildings[] {poly, h, h_max, area_m2}
water[] {poly, area_m2}
`poly` is the outer ring only, as [[x, y], ...], first point not repeated,
clockwise on screen (x right, y down). Every list is sorted largest-first and
capped, and `truncated` says whether a cap was hit.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
import numpy as np
from config import CLASS_NAMES
SCHEMA_VERSION = 1
TREE_ID = CLASS_NAMES.index("tree")
BUILDING_ID = CLASS_NAMES.index("building")
WATER_ID = CLASS_NAMES.index("water")
@dataclass
class ObjectParams:
# trees
min_tree_height_m: float = 2.0 # below this it is shrub, drawn as ground
min_crown_sep_m: float = 2.5 # peaks closer than this are one crown
canopy_smooth_m: float = 0.75 # Gaussian sigma on the canopy before peak finding
min_tree_area_m2: float = 3.0 # watershed slivers smaller than this are dropped
max_trees: int = 20_000
# buildings
min_building_area_m2: float = 20.0
min_building_height_m: float = 2.0 # a "building" lower than this is a misread slab
building_edge_m: float = 1.0 # eroded off the footprint before taking the median
building_hole_m2: float = 25.0 # courtyards smaller than this are filled
max_buildings: int = 5_000
# water
min_water_area_m2: float = 50.0
max_water: int = 500
# shared
open_m: float = 1.0 # morphological opening: drops features thinner than this
outline_tolerance_m: float = 0.75 # Douglas-Peucker tolerance for footprints
def extract_objects(seg: np.ndarray, height: np.ndarray, gsd_m: float,
params: ObjectParams | None = None) -> dict:
"""`seg` (H, W) class ids, `height` (H, W) metres, same grid -> objects dict."""
if seg.shape != height.shape:
raise ValueError(f"class map {seg.shape} and height map {height.shape} differ")
if not gsd_m or gsd_m <= 0:
raise ValueError(f"gsd_m must be positive, got {gsd_m}")
p = params or ObjectParams()
seg = np.asarray(seg)
height = np.where(np.isfinite(height), height, 0.0).astype(np.float32)
trees, trees_cut = _trees(seg == TREE_ID, height, gsd_m, p)
buildings, buildings_cut = _buildings(seg == BUILDING_ID, height, gsd_m, p)
water, water_cut = _water(seg == WATER_ID, gsd_m, p)
H, W = seg.shape
return {
"version": SCHEMA_VERSION,
"grid": {"width": int(W), "height": int(H), "gsd_m": float(gsd_m),
"origin": "top-left corner; pixel centre = (col + 0.5, row + 0.5)"},
"units": {"x": "px", "y": "px", "poly": "px", "h": "m", "h_max": "m",
"r": "m", "area_m2": "m^2"},
"trees": trees,
"buildings": buildings,
"water": water,
"counts": {"trees": len(trees), "buildings": len(buildings), "water": len(water)},
"truncated": {"trees": trees_cut, "buildings": buildings_cut, "water": water_cut},
"params": asdict(p),
}
# ---------------------------------------------------------------------
# masks
# ---------------------------------------------------------------------
def _drop_small(mask: np.ndarray, min_px: int) -> np.ndarray:
from scipy import ndimage as ndi
if min_px <= 1 or not mask.any():
return mask
lab, _ = ndi.label(mask)
sizes = np.bincount(lab.ravel())
keep = sizes >= min_px
keep[0] = False
return keep[lab]
def _clean(mask: np.ndarray, gsd_m: float, min_area_m2: float, open_m: float,
hole_m2: float = 0.0) -> np.ndarray:
"""Opening, then small-blob removal, then (optionally) small-hole filling."""
from scipy import ndimage as ndi
radius = int(round(open_m / (2.0 * gsd_m)))
if radius > 0 and mask.any():
# a square, not a disk: a disk's opening clips every footprint corner
size = 2 * radius + 1
mask = ndi.binary_opening(mask, structure=np.ones((size, size), bool))
mask = _drop_small(mask, int(np.ceil(min_area_m2 / gsd_m**2)))
if hole_m2 > 0:
# a hole is a background component that is not the big outside one
mask = mask | ~_drop_small(~mask, int(np.ceil(hole_m2 / gsd_m**2)))
return mask
# ---------------------------------------------------------------------
# trees
# ---------------------------------------------------------------------
def _trees(mask: np.ndarray, height: np.ndarray, gsd_m: float,
p: ObjectParams) -> tuple[list[dict], bool]:
from scipy import ndimage as ndi
from skimage.feature import peak_local_max
from skimage.segmentation import watershed
mask = _clean(mask, gsd_m, p.min_tree_area_m2, p.open_m)
mask &= height >= p.min_tree_height_m
mask = _drop_small(mask, int(np.ceil(p.min_tree_area_m2 / gsd_m**2)))
if not mask.any():
return [], False
canopy = np.where(mask, height, 0.0).astype(np.float32)
sigma = p.canopy_smooth_m / gsd_m
smooth = ndi.gaussian_filter(canopy, sigma) if sigma >= 0.3 else canopy
# Tie-break plateaus towards the blob interior (1 mm per pixel from the
# edge): a flat-topped canopy otherwise yields a grid of equal "peaks",
# i.e. one tree becomes four. Far below any real crown relief.
smooth = smooth + 1e-3 * ndi.distance_transform_edt(mask).astype(np.float32)
comps, n_comps = ndi.label(mask)
min_dist = max(1, int(round(p.min_crown_sep_m / gsd_m)))
peaks = peak_local_max(smooth, min_distance=min_dist, labels=comps,
exclude_border=False)
markers = np.zeros(mask.shape, np.int32)
if len(peaks):
markers[peaks[:, 0], peaks[:, 1]] = np.arange(1, len(peaks) + 1)
# a flat-topped blob can yield no peak at all; it is still one tree
seeded = np.zeros(n_comps + 1, bool)
seeded[comps[markers > 0]] = True
missing = np.flatnonzero(~seeded[1:]) + 1
if len(missing):
pos = ndi.maximum_position(smooth, comps, missing)
start = int(markers.max()) + 1
for k, (r, c) in enumerate(pos):
markers[int(r), int(c)] = start + k
crowns = watershed(-smooth, markers, mask=mask)
# per-crown statistics, vectorised: tens of thousands of crowns is normal
rr, cc = np.nonzero(crowns)
lab = crowns[rr, cc]
n_lab = int(crowns.max()) + 1
count = np.bincount(lab, minlength=n_lab).astype(np.float64)
ok = count > 0
cy = np.bincount(lab, rr, n_lab)[ok] / count[ok]
cx = np.bincount(lab, cc, n_lab)[ok] / count[ok]
# raw maximum: the height head is already smooth at pixel scale (decoded at
# H/2 and resampled from the canonical GSD), and any filter here shaves the
# apex off a conical crown — a 3x3 median read an 18 m tree as 17.1 m
h = np.zeros(n_lab, np.float64)
np.maximum.at(h, lab, height[rr, cc])
h = h[ok]
area_m2 = count[ok] * gsd_m**2
keep = (area_m2 >= p.min_tree_area_m2) & (h >= p.min_tree_height_m)
cy, cx, h, area_m2 = cy[keep], cx[keep], h[keep], area_m2[keep]
order = np.argsort(-h, kind="stable")
truncated = len(order) > p.max_trees
order = order[:p.max_trees]
trees = [
{"x": round(float(cx[i]) + 0.5, 2), "y": round(float(cy[i]) + 0.5, 2),
"h": round(float(h[i]), 2),
"r": round(float(np.sqrt(area_m2[i] / np.pi)), 2)}
for i in order
]
return trees, truncated
# ---------------------------------------------------------------------
# footprints
# ---------------------------------------------------------------------
def _outline(component: np.ndarray, origin: tuple[int, int],
tolerance_px: float) -> list[list[float]] | None:
"""Outer ring of a single boolean component, in corner-origin pixels."""
from skimage.measure import approximate_polygon, find_contours
padded = np.pad(component, 1).astype(np.uint8)
contours = find_contours(padded, 0.5)
if not contours:
return None
ring = max(contours, key=len)
if tolerance_px > 0:
ring = approximate_polygon(ring, tolerance=tolerance_px)
if len(ring) > 1 and np.allclose(ring[0], ring[-1]):
ring = ring[:-1]
if len(ring) < 3:
return None
# (row, col) index space of the padded crop -> corner-origin (x, y) on the grid
y = ring[:, 0] - 1 + origin[0] + 0.5
x = ring[:, 1] - 1 + origin[1] + 0.5
# shoelace > 0 with y pointing down is clockwise on screen
if np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y) < 0:
x, y = x[::-1], y[::-1]
return [[round(float(a), 2), round(float(b), 2)] for a, b in zip(x, y)]
def _components(mask: np.ndarray):
from scipy import ndimage as ndi
lab, _ = ndi.label(mask)
for i, sl in enumerate(ndi.find_objects(lab), start=1):
if sl is not None:
yield sl, lab[sl] == i
def _buildings(mask: np.ndarray, height: np.ndarray, gsd_m: float,
p: ObjectParams) -> tuple[list[dict], bool]:
from scipy import ndimage as ndi
mask = _clean(mask, gsd_m, p.min_building_area_m2, p.open_m, p.building_hole_m2)
erode = int(round(p.building_edge_m / gsd_m))
tol = p.outline_tolerance_m / gsd_m
out = []
for sl, comp in _components(mask):
core = ndi.binary_erosion(comp, iterations=erode) if erode else comp
# thin wings erode to nothing; fall back to the whole footprint
if core.sum() < max(4, 0.1 * comp.sum()):
core = comp
hv = height[sl][core]
h = float(np.median(hv))
if h < p.min_building_height_m:
continue
poly = _outline(comp, (sl[0].start, sl[1].start), tol)
if poly is None:
continue
out.append({"poly": poly, "h": round(h, 2),
"h_max": round(float(np.percentile(hv, 95)), 2),
"area_m2": round(float(comp.sum()) * gsd_m**2, 1)})
out.sort(key=lambda b: -b["area_m2"])
return out[:p.max_buildings], len(out) > p.max_buildings
def _water(mask: np.ndarray, gsd_m: float, p: ObjectParams) -> tuple[list[dict], bool]:
mask = _clean(mask, gsd_m, p.min_water_area_m2, p.open_m, p.min_water_area_m2)
tol = p.outline_tolerance_m / gsd_m
out = []
for sl, comp in _components(mask):
poly = _outline(comp, (sl[0].start, sl[1].start), tol)
if poly is not None:
out.append({"poly": poly, "area_m2": round(float(comp.sum()) * gsd_m**2, 1)})
out.sort(key=lambda w: -w["area_m2"])
return out[:p.max_water], len(out) > p.max_water
def summarise(objects: dict) -> str:
"""One status line: counts plus the mean tree / building height."""
t, b, w = objects["trees"], objects["buildings"], objects["water"]
parts = [f"{len(t)} trees"]
if t:
parts[-1] += f" (mean {np.mean([o['h'] for o in t]):.1f} m)"
parts.append(f"{len(b)} buildings")
if b:
parts[-1] += f" (median {np.median([o['h'] for o in b]):.1f} m)"
parts.append(f"{len(w)} water bodies")
if any(objects["truncated"].values()):
parts.append("capped — see objects.json `truncated`")
return " · ".join(parts)
|