Expose adaptive-bin uncertainty outputs
Browse filesDerive 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.
- app.py +16 -6
- infer/engine.py +28 -11
- models/heads.py +10 -0
- 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),
|
|
|
|
| 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 |
-
|
| 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
|
| 70 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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
|