akashch1512 commited on
Commit
98ddc8c
·
verified ·
1 Parent(s): 17aade5

Expose adaptive-bin uncertainty outputs

Browse files

Derive per-pixel height uncertainty from the adaptive-bin distribution, propagate it through tiled inference and TTA, and publish ndsm_std_m.npy for the DepthWizard frontend.

Files changed (4) hide show
  1. app.py +16 -6
  2. infer/engine.py +28 -11
  3. models/heads.py +10 -0
  4. models/tta.py +14 -5
app.py CHANGED
@@ -267,6 +267,7 @@ def _with_colorbar(colormap: np.ndarray, low: float, high: float) -> Image.Image
267
  def _write_artifacts(
268
  out_dir: Path, rgb: np.ndarray, height: np.ndarray, meta: dict, gsd_m: float,
269
  seg: np.ndarray | None = None, objects: dict | None = None,
 
270
  ) -> list[str]:
271
  """Everything a user can take away, written by the same code as the CLI."""
272
  out_dir.mkdir(parents=True, exist_ok=True)
@@ -287,6 +288,9 @@ def _write_artifacts(
287
 
288
  Image.fromarray(rgb).save(out_dir / "rgb.png")
289
  np.save(out_dir / "ndsm_m.npy", height.astype(np.float32))
 
 
 
290
  png16, affine = viz_mesh.encode_height_png16(height)
291
  (out_dir / "ndsm16.png").write_bytes(png16)
292
  files += ["ndsm_m.npy", "ndsm16.png", "rgb.png"]
@@ -324,7 +328,7 @@ def _bundle(out_dir: Path, files: list[str], name_stem: str) -> list[str]:
324
  # the frontend finds its inputs by name in this list, so a file it reads
325
  # (seg.png, objects.json) must be here and not only inside the zip
326
  keep = ["ndsm_m.npy", "meta.json", "terrain.glb", "height_colormap.png",
327
- "hillshade.png", "seg.png", "objects.json"]
328
  return [archive] + [str(out_dir / f) for f in keep if (out_dir / f).is_file()]
329
 
330
 
@@ -397,17 +401,17 @@ def _run_model(rgb, gsd_m, tta: bool):
397
  try:
398
  # the class map rides along on the same forward pass (under TTA it is
399
  # read off the identity view, so it costs no extra pass either)
400
- height, seg = predict_scene(
401
  model, rgb, gsd_m, spec, device,
402
  tta=bool(tta), amp_dtype=amp_dtype, overlap=_OVERLAP,
403
- batch_tiles=_TILE_BATCH, want_seg=True,
404
  )
405
  finally:
406
  # hand the card back clean; the next reservation is a fresh attach
407
  model.to("cpu")
408
  if device.type == "cuda":
409
  torch.cuda.empty_cache()
410
- return height, seg.astype(np.uint8)
411
 
412
 
413
  # `duration` may be a callable taking the same arguments as the function
@@ -438,7 +442,7 @@ def predict(image_path: str | None, gsd: float, tta: bool = False):
438
  max_side=_MAX_SIDE,
439
  )
440
 
441
- height, seg = _run_model_gpu(rgb, scene_meta.gsd_m, bool(tta))
442
 
443
  height = _sanitise(height)
444
  # Objects are an extra, never a reason to lose the height map: a failure
@@ -462,6 +466,11 @@ def predict(image_path: str | None, gsd: float, tta: bool = False):
462
  "height_max_m": high,
463
  "height_mean_m": float(np.nanmean(height)),
464
  "height_median_m": float(np.nanmedian(height)),
 
 
 
 
 
465
  "frac_below_1m": float((height < 1.0).mean()),
466
  "size_px": [int(height.shape[0]), int(height.shape[1])],
467
  "scene": {
@@ -488,7 +497,8 @@ def predict(image_path: str | None, gsd: float, tta: bool = False):
488
  stem = _safe_stem(image_path)
489
  out_dir = Path(tempfile.mkdtemp(prefix="dw_"))
490
  files = _write_artifacts(
491
- out_dir, rgb, height, meta, scene_meta.gsd_m, seg=seg, objects=objects
 
492
  )
493
 
494
  # --- the iframe payload, capped and re-stated in its own metres ------
 
267
  def _write_artifacts(
268
  out_dir: Path, rgb: np.ndarray, height: np.ndarray, meta: dict, gsd_m: float,
269
  seg: np.ndarray | None = None, objects: dict | None = None,
270
+ uncertainty: np.ndarray | None = None,
271
  ) -> list[str]:
272
  """Everything a user can take away, written by the same code as the CLI."""
273
  out_dir.mkdir(parents=True, exist_ok=True)
 
288
 
289
  Image.fromarray(rgb).save(out_dir / "rgb.png")
290
  np.save(out_dir / "ndsm_m.npy", height.astype(np.float32))
291
+ if uncertainty is not None and uncertainty.shape == height.shape:
292
+ np.save(out_dir / "ndsm_std_m.npy", uncertainty.astype(np.float32))
293
+ files.append("ndsm_std_m.npy")
294
  png16, affine = viz_mesh.encode_height_png16(height)
295
  (out_dir / "ndsm16.png").write_bytes(png16)
296
  files += ["ndsm_m.npy", "ndsm16.png", "rgb.png"]
 
328
  # the frontend finds its inputs by name in this list, so a file it reads
329
  # (seg.png, objects.json) must be here and not only inside the zip
330
  keep = ["ndsm_m.npy", "meta.json", "terrain.glb", "height_colormap.png",
331
+ "hillshade.png", "seg.png", "objects.json", "ndsm_std_m.npy"]
332
  return [archive] + [str(out_dir / f) for f in keep if (out_dir / f).is_file()]
333
 
334
 
 
401
  try:
402
  # the class map rides along on the same forward pass (under TTA it is
403
  # read off the identity view, so it costs no extra pass either)
404
+ height, seg, uncertainty = predict_scene(
405
  model, rgb, gsd_m, spec, device,
406
  tta=bool(tta), amp_dtype=amp_dtype, overlap=_OVERLAP,
407
+ batch_tiles=_TILE_BATCH, want_seg=True, want_std=True,
408
  )
409
  finally:
410
  # hand the card back clean; the next reservation is a fresh attach
411
  model.to("cpu")
412
  if device.type == "cuda":
413
  torch.cuda.empty_cache()
414
+ return height, seg.astype(np.uint8), uncertainty
415
 
416
 
417
  # `duration` may be a callable taking the same arguments as the function
 
442
  max_side=_MAX_SIDE,
443
  )
444
 
445
+ height, seg, uncertainty = _run_model_gpu(rgb, scene_meta.gsd_m, bool(tta))
446
 
447
  height = _sanitise(height)
448
  # Objects are an extra, never a reason to lose the height map: a failure
 
466
  "height_max_m": high,
467
  "height_mean_m": float(np.nanmean(height)),
468
  "height_median_m": float(np.nanmedian(height)),
469
+ "uncertainty": {
470
+ "definition": "standard deviation of the fused adaptive-bin height distribution",
471
+ "std_median_m": float(np.nanmedian(uncertainty)),
472
+ "std_mean_m": float(np.nanmean(uncertainty)),
473
+ },
474
  "frac_below_1m": float((height < 1.0).mean()),
475
  "size_px": [int(height.shape[0]), int(height.shape[1])],
476
  "scene": {
 
497
  stem = _safe_stem(image_path)
498
  out_dir = Path(tempfile.mkdtemp(prefix="dw_"))
499
  files = _write_artifacts(
500
+ out_dir, rgb, height, meta, scene_meta.gsd_m, seg=seg, objects=objects,
501
+ uncertainty=uncertainty,
502
  )
503
 
504
  # --- the iframe payload, capped and re-stated in its own metres ------
infer/engine.py CHANGED
@@ -57,12 +57,12 @@ class _Forward:
57
  start at the size that worked instead of rediscovering the limit each time.
58
  """
59
 
60
- def __init__(self, model, device, amp_dtype, max_batch: int, want_seg: bool):
61
  self.model, self.device, self.amp_dtype = model, device, amp_dtype
62
- self.max_batch, self.want_seg = max(1, int(max_batch)), want_seg
63
 
64
  def __call__(self, x):
65
- preds, segs, i = [], [], 0
66
  while i < x.shape[0]:
67
  n = self.max_batch
68
  try:
@@ -77,12 +77,15 @@ class _Forward:
77
  print(f"[engine] OOM at {n} tiles/forward; retrying at {self.max_batch}")
78
  continue
79
  preds.append(out["fused"].float())
 
 
80
  if self.want_seg:
81
  # the ignore channel is a training target, never a prediction
82
  segs.append(out["seg"][:, :SEG_IGNORE_INDEX].float().argmax(1))
83
  del out
84
  i += n
85
- return torch.cat(preds), (torch.cat(segs) if self.want_seg else None)
 
86
 
87
 
88
  @torch.no_grad()
@@ -90,6 +93,7 @@ def predict_canonical(
90
  model, rgb_canon_u8: np.ndarray, spec: PreprocSpec, device,
91
  *, tta: bool = False, tta_scales=(1.0,), amp_dtype=None,
92
  overlap: float = 0.25, batch_tiles: int = 0, want_seg: bool = False,
 
93
  progress=None,
94
  ):
95
  """Sliding-window prediction over an image already at the canonical GSD.
@@ -135,6 +139,7 @@ def predict_canonical(
135
  std = torch.tensor(spec.std, dtype=torch.float32, device=device).view(1, 3, 1, 1)
136
  win = torch.from_numpy(hann2d(S)).to(canvas_dev)
137
  acc = torch.zeros((Hp, Wp), dtype=torch.float32, device=canvas_dev)
 
138
  wsum = torch.zeros((Hp, Wp), dtype=torch.float32, device=canvas_dev)
139
  # Labels cannot be averaged, so each pixel keeps the label from the tile it
140
  # sits most centrally in (largest Hann weight). Last-write-wins put a
@@ -148,7 +153,7 @@ def predict_canonical(
148
  fwd = _Forward(
149
  model, device, amp_dtype,
150
  batch_tiles * views if batch_tiles > 0 else auto_forward_batch(device, S),
151
- want_seg,
152
  )
153
  step = max(1, fwd.max_batch // views) # tiles per step; TTA multiplies them
154
 
@@ -177,15 +182,19 @@ def predict_canonical(
177
  tiles = cut(chunk)
178
  t = (tiles.permute(0, 3, 1, 2).float().div_(255.0) - mean) / std
179
  if tta:
180
- pred, seg = tta_predict_parallel(fwd, t, tuple(tta_scales), patch=spec.patch)
181
  else:
182
- pred, seg = fwd(t)
183
  pred = pred[:, 0].to(canvas_dev)
 
 
184
  if seg is not None:
185
  seg = seg.to(device=canvas_dev, dtype=torch.int16)
186
 
187
  for i, (y, x) in enumerate(chunk):
188
  acc[y:y + S, x:x + S] += pred[i] * win
 
 
189
  wsum[y:y + S, x:x + S] += win
190
  if seg is not None:
191
  best = seg_best[y:y + S, x:x + S]
@@ -201,8 +210,12 @@ def predict_canonical(
201
  pool.shutdown(wait=True)
202
 
203
  height = (acc / wsum.clamp_min(1e-6))[:H, :W].cpu().numpy()
 
 
 
 
204
  seg_out = seg_acc[:H, :W].cpu().numpy() if seg_acc is not None else None
205
- return height.astype(np.float32), seg_out
206
 
207
 
208
  def _autocast(device, amp_dtype):
@@ -226,7 +239,7 @@ def count_tiles(h: int, w: int, src_gsd_m: float, spec: PreprocSpec,
226
  def predict_scene(
227
  model, rgb_u8: np.ndarray, src_gsd_m: float, spec: PreprocSpec, device,
228
  *, tta: bool = False, tta_scales=(1.0,), amp_dtype=None, overlap: float = 0.25,
229
- batch_tiles: int = 0, want_seg: bool = False, progress=None,
230
  ):
231
  """RGB at any GSD -> nDSM in metres on the input grid (+ optional class map).
232
 
@@ -237,11 +250,15 @@ def predict_scene(
237
  canon_hw = gsd_to_shape(H, W, src_gsd_m, spec.canonical_gsd_m)
238
  rgb_c = resize(rgb_s, canon_hw, "bilinear")
239
 
240
- height_c, seg_c = predict_canonical(
241
  model, rgb_c, spec, device, tta=tta, tta_scales=tta_scales,
242
  amp_dtype=amp_dtype, overlap=overlap, batch_tiles=batch_tiles,
243
- want_seg=want_seg, progress=progress,
244
  )
 
245
  height = resize(height_c, (H, W), "bilinear").astype(np.float32)
246
  seg = resize(seg_c.astype(np.int32), (H, W), "nearest") if seg_c is not None else None
 
 
 
247
  return height, seg
 
57
  start at the size that worked instead of rediscovering the limit each time.
58
  """
59
 
60
+ def __init__(self, model, device, amp_dtype, max_batch: int, want_seg: bool, want_std: bool):
61
  self.model, self.device, self.amp_dtype = model, device, amp_dtype
62
+ self.max_batch, self.want_seg, self.want_std = max(1, int(max_batch)), want_seg, want_std
63
 
64
  def __call__(self, x):
65
+ preds, stds, segs, i = [], [], [], 0
66
  while i < x.shape[0]:
67
  n = self.max_batch
68
  try:
 
77
  print(f"[engine] OOM at {n} tiles/forward; retrying at {self.max_batch}")
78
  continue
79
  preds.append(out["fused"].float())
80
+ if self.want_std:
81
+ stds.append(out["b_std"].float())
82
  if self.want_seg:
83
  # the ignore channel is a training target, never a prediction
84
  segs.append(out["seg"][:, :SEG_IGNORE_INDEX].float().argmax(1))
85
  del out
86
  i += n
87
+ return (torch.cat(preds), torch.cat(stds) if self.want_std else None,
88
+ torch.cat(segs) if self.want_seg else None)
89
 
90
 
91
  @torch.no_grad()
 
93
  model, rgb_canon_u8: np.ndarray, spec: PreprocSpec, device,
94
  *, tta: bool = False, tta_scales=(1.0,), amp_dtype=None,
95
  overlap: float = 0.25, batch_tiles: int = 0, want_seg: bool = False,
96
+ want_std: bool = False,
97
  progress=None,
98
  ):
99
  """Sliding-window prediction over an image already at the canonical GSD.
 
139
  std = torch.tensor(spec.std, dtype=torch.float32, device=device).view(1, 3, 1, 1)
140
  win = torch.from_numpy(hann2d(S)).to(canvas_dev)
141
  acc = torch.zeros((Hp, Wp), dtype=torch.float32, device=canvas_dev)
142
+ acc_m2 = torch.zeros_like(acc) if want_std else None
143
  wsum = torch.zeros((Hp, Wp), dtype=torch.float32, device=canvas_dev)
144
  # Labels cannot be averaged, so each pixel keeps the label from the tile it
145
  # sits most centrally in (largest Hann weight). Last-write-wins put a
 
153
  fwd = _Forward(
154
  model, device, amp_dtype,
155
  batch_tiles * views if batch_tiles > 0 else auto_forward_batch(device, S),
156
+ want_seg, want_std,
157
  )
158
  step = max(1, fwd.max_batch // views) # tiles per step; TTA multiplies them
159
 
 
182
  tiles = cut(chunk)
183
  t = (tiles.permute(0, 3, 1, 2).float().div_(255.0) - mean) / std
184
  if tta:
185
+ pred, pred_std, seg = tta_predict_parallel(fwd, t, tuple(tta_scales), patch=spec.patch)
186
  else:
187
+ pred, pred_std, seg = fwd(t)
188
  pred = pred[:, 0].to(canvas_dev)
189
+ if pred_std is not None:
190
+ pred_std = pred_std[:, 0].to(canvas_dev)
191
  if seg is not None:
192
  seg = seg.to(device=canvas_dev, dtype=torch.int16)
193
 
194
  for i, (y, x) in enumerate(chunk):
195
  acc[y:y + S, x:x + S] += pred[i] * win
196
+ if acc_m2 is not None:
197
+ acc_m2[y:y + S, x:x + S] += (pred_std[i].square() + pred[i].square()) * win
198
  wsum[y:y + S, x:x + S] += win
199
  if seg is not None:
200
  best = seg_best[y:y + S, x:x + S]
 
210
  pool.shutdown(wait=True)
211
 
212
  height = (acc / wsum.clamp_min(1e-6))[:H, :W].cpu().numpy()
213
+ std_out = None
214
+ if acc_m2 is not None:
215
+ variance = (acc_m2 / wsum.clamp_min(1e-6) - (acc / wsum.clamp_min(1e-6)).square()).clamp_min(0)
216
+ std_out = variance[:H, :W].sqrt().cpu().numpy().astype(np.float32)
217
  seg_out = seg_acc[:H, :W].cpu().numpy() if seg_acc is not None else None
218
+ return (height.astype(np.float32), seg_out, std_out) if want_std else (height.astype(np.float32), seg_out)
219
 
220
 
221
  def _autocast(device, amp_dtype):
 
239
  def predict_scene(
240
  model, rgb_u8: np.ndarray, src_gsd_m: float, spec: PreprocSpec, device,
241
  *, tta: bool = False, tta_scales=(1.0,), amp_dtype=None, overlap: float = 0.25,
242
+ batch_tiles: int = 0, want_seg: bool = False, want_std: bool = False, progress=None,
243
  ):
244
  """RGB at any GSD -> nDSM in metres on the input grid (+ optional class map).
245
 
 
250
  canon_hw = gsd_to_shape(H, W, src_gsd_m, spec.canonical_gsd_m)
251
  rgb_c = resize(rgb_s, canon_hw, "bilinear")
252
 
253
+ prediction = predict_canonical(
254
  model, rgb_c, spec, device, tta=tta, tta_scales=tta_scales,
255
  amp_dtype=amp_dtype, overlap=overlap, batch_tiles=batch_tiles,
256
+ want_seg=want_seg, want_std=want_std, progress=progress,
257
  )
258
+ height_c, seg_c = prediction[:2]
259
  height = resize(height_c, (H, W), "bilinear").astype(np.float32)
260
  seg = resize(seg_c.astype(np.int32), (H, W), "nearest") if seg_c is not None else None
261
+ if want_std:
262
+ std = resize(prediction[2], (H, W), "bilinear").astype(np.float32)
263
+ return height, seg, std
264
  return height, seg
models/heads.py CHANGED
@@ -98,11 +98,21 @@ class DepthWizardNetV3(nn.Module):
98
  b = self.head_b(x)
99
  fused, alpha = self.fusion(x, a, b["height"])
100
 
 
 
 
 
 
 
 
 
 
101
  up = lambda t: F.interpolate(t, size=hw, mode="bilinear", align_corners=False) # noqa: E731
102
  return {
103
  "a": up(a),
104
  "b": up(b["height"]),
105
  "fused": up(fused),
 
106
  "alpha": up(alpha),
107
  "seg": up(self.head_c(x)),
108
  # kept at half res — the bin loss is computed against a downsampled
 
98
  b = self.head_b(x)
99
  fused, alpha = self.fusion(x, a, b["height"])
100
 
101
+ # Head B is a per-pixel distribution over adaptive height bins. Its
102
+ # standard deviation is the predictive spread; scale by the fusion
103
+ # gate because only Head B contributes uncertainty to the fused height.
104
+ probs = F.softmax(b["logits"].float(), dim=1)
105
+ centres = b["centres"].float()[:, :, None, None]
106
+ b_height = b["height"].float()
107
+ variance = (probs * (centres - b_height) ** 2).sum(dim=1, keepdim=True)
108
+ b_std = variance.clamp_min(0).sqrt() * (1.0 - alpha.float())
109
+
110
  up = lambda t: F.interpolate(t, size=hw, mode="bilinear", align_corners=False) # noqa: E731
111
  return {
112
  "a": up(a),
113
  "b": up(b["height"]),
114
  "fused": up(fused),
115
+ "b_std": up(b_std),
116
  "alpha": up(alpha),
117
  "seg": up(self.head_c(x)),
118
  # kept at half res — the bin loss is computed against a downsampled
models/tta.py CHANGED
@@ -66,12 +66,12 @@ def tta_predict_parallel(forward, image, scales=(1.0,), patch: int = 16):
66
  views stack along the batch axis and `forward` sees one (8B, 3, S, S) batch
67
  per scale; it is free to split that into whatever fits in memory.
68
 
69
- `forward(x) -> (pred (N, 1, h, w) float32, seg (N, h, w) long | None)`.
70
- Returns the view-averaged height and the class map of the identity view, so
71
- the class map no longer costs its own extra pass.
72
  """
73
  b, _, h, w = image.shape
74
  acc = torch.zeros((b, 1, h, w), device=image.device, dtype=torch.float32)
 
75
  seg_id, n = None, 0
76
  for s in scales:
77
  if abs(s - 1.0) < 1e-6:
@@ -81,14 +81,23 @@ def tta_predict_parallel(forward, image, scales=(1.0,), patch: int = 16):
81
  nw = max(patch, int(round(w * s / patch)) * patch)
82
  img_s = F.interpolate(image, size=(nh, nw), mode="bilinear", align_corners=False)
83
  views = torch.cat([_apply(img_s, k, flip) for k, flip in _D4])
84
- pred, seg = forward(views)
85
  for i, (k, flip) in enumerate(_D4):
86
  p = _invert(pred[i * b:(i + 1) * b], k, flip)
 
 
 
 
 
87
  if p.shape[-2:] != (h, w):
88
  p = F.interpolate(p, size=(h, w), mode="bilinear", align_corners=False)
 
89
  acc += p
 
90
  n += 1
91
  # _D4[0] is the identity; only the full-resolution pass supplies labels
92
  if seg is not None and seg_id is None and img_s is image:
93
  seg_id = seg[:b]
94
- return acc / max(1, n), seg_id
 
 
 
66
  views stack along the batch axis and `forward` sees one (8B, 3, S, S) batch
67
  per scale; it is free to split that into whatever fits in memory.
68
 
69
+ `forward(x) -> (pred, std, seg)`. Average first and second moments across
70
+ views so the returned spread includes both bin uncertainty and TTA disagreement.
 
71
  """
72
  b, _, h, w = image.shape
73
  acc = torch.zeros((b, 1, h, w), device=image.device, dtype=torch.float32)
74
+ acc_m2 = torch.zeros_like(acc)
75
  seg_id, n = None, 0
76
  for s in scales:
77
  if abs(s - 1.0) < 1e-6:
 
81
  nw = max(patch, int(round(w * s / patch)) * patch)
82
  img_s = F.interpolate(image, size=(nh, nw), mode="bilinear", align_corners=False)
83
  views = torch.cat([_apply(img_s, k, flip) for k, flip in _D4])
84
+ pred, pred_std, seg = forward(views)
85
  for i, (k, flip) in enumerate(_D4):
86
  p = _invert(pred[i * b:(i + 1) * b], k, flip)
87
+ p_slice = pred[i * b:(i + 1) * b]
88
+ m2_slice = p_slice.square()
89
+ if pred_std is not None:
90
+ m2_slice = m2_slice + pred_std[i * b:(i + 1) * b].square()
91
+ m2 = _invert(m2_slice, k, flip)
92
  if p.shape[-2:] != (h, w):
93
  p = F.interpolate(p, size=(h, w), mode="bilinear", align_corners=False)
94
+ m2 = F.interpolate(m2, size=(h, w), mode="bilinear", align_corners=False)
95
  acc += p
96
+ acc_m2 += m2
97
  n += 1
98
  # _D4[0] is the identity; only the full-resolution pass supplies labels
99
  if seg is not None and seg_id is None and img_s is image:
100
  seg_id = seg[:b]
101
+ mean = acc / max(1, n)
102
+ std = (acc_m2 / max(1, n) - mean.square()).clamp_min(0).sqrt()
103
+ return mean, std, seg_id