Download code/height_train.py from Dilavesh/altimap-height: direct link, hf CLI and curl.
- Browser
- Download file 16.4 kB
-
https://huggingface.co/Dilavesh/altimap-height/resolve/main/code/height_train.py
- Command line
-
hf download hf://Dilavesh/altimap-height/code/height_train.py
-
curl -L -o height_train.py https://huggingface.co/Dilavesh/altimap-height/resolve/main/code/height_train.py
16.4 kB
| """Fine-tune RS3DAda for metric height + land cover. | |
| python -m viewer.height_train --hours 8 --out runs/ft # v1: GAMUS, BitFit | |
| python -m viewer.height_train --ckpt runs/ft/best.pth --out runs/v2 --hours 7.5 \\ | |
| --batch 8 --unfreeze-blocks 8 --lr 5e-5 --encoder-lr 1e-5 --height-weight 10 \\ | |
| --syn-data synrs3d --syn-fraction 0.3 --degrade 0.5 # v2 overnight | |
| Encoder (DINOv2 ViT-L, `pretrained.*`): biases always train (BitFit, best of five | |
| PEFT strategies for height in arXiv:2505.06905); `--unfreeze-blocks N` also trains | |
| the last N transformer blocks at `--encoder-lr`. Decoder and both heads train fully. | |
| Data: GAMUS (real 0.33 m aerial, LiDAR nDSM) optionally mixed with SynRS3D | |
| (synthetic, many high-rises and hilly terrain, 0.3-1 m) at `--syn-fraction` of | |
| each epoch. SynRS3D tiles are resampled to GAMUS's 0.33 m from their folder's | |
| GSD range, so both sources match what inference feeds the model. `--degrade` | |
| adds satellite-style degradation (coarser sensor, blur, haze, noise) to that | |
| fraction of samples, for robustness on imagery that isn't 0.33 m aerial. | |
| The LR schedule follows wall-clock time (cosine to the --hours deadline), so it | |
| always completes inside the budget whatever the step speed. Keeps | |
| runs/.../best.pth by validation RMSE on real GAMUS val tiles. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| import random | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| from viewer.gamus_dataset import find_tiles, load_tile | |
| from viewer.height_eval import DATA, evaluate, train_mean_height | |
| from viewer.height_model import GAMUS_GSD_M, GAMUS_TO_OEM, PATCH, clean_height, load_model, normalize | |
| # SynRS3D folder name -> ground sampling distance range (dataset README). | |
| SYN_GSD = {"_g005_": (0.05, 0.3), "_g05_": (0.3, 0.6), "_g1_": (0.6, 1.0)} | |
| def _resize(a: np.ndarray, size: int, nearest: bool = False) -> np.ndarray: | |
| from PIL import Image | |
| mode = Image.NEAREST if nearest else Image.BILINEAR | |
| if a.ndim == 3 or a.dtype == np.uint8: | |
| return np.asarray(Image.fromarray(a).resize((size, size), mode)) | |
| return np.asarray(Image.fromarray(a.astype(np.float32), mode="F").resize((size, size), mode)) | |
| def degrade(rgb: np.ndarray, rng) -> np.ndarray: | |
| """Satellite-style degradation of an HxWx3 uint8 crop (size unchanged).""" | |
| from scipy import ndimage | |
| out = rgb.astype(np.float32) | |
| size = rgb.shape[0] | |
| if rng.random() < 0.5: # coarser sensor (0.43-2 m, log-uniform), resampled back up to 0.33 m; | |
| f = float(np.exp(rng.uniform(np.log(1.3), np.log(6.0)))) # eval spans 0.35-10 m, main case 0.6 m | |
| small = _resize(np.clip(out, 0, 255).astype(np.uint8), max(8, int(size / f))) | |
| out = _resize(small, size).astype(np.float32) | |
| if rng.random() < 0.3: | |
| out = ndimage.gaussian_filter(out, sigma=(rng.uniform(0.4, 1.2),) * 2 + (0,)) | |
| if rng.random() < 0.3: # atmospheric haze | |
| a = rng.uniform(0.7, 0.95) | |
| out = out * a + (1 - a) * rng.uniform(170, 225, size=3) | |
| if rng.random() < 0.3: | |
| out = 255.0 * (np.clip(out, 0, 255) / 255.0) ** rng.uniform(0.7, 1.4) | |
| if rng.random() < 0.4: # saturation | |
| grey = out.mean(axis=2, keepdims=True) | |
| out = grey + (out - grey) * rng.uniform(0.6, 1.3) | |
| if rng.random() < 0.3: | |
| out = out + rng.normal(0, rng.uniform(2, 6), out.shape) | |
| return np.clip(out, 0, 255).astype(np.uint8) | |
| def augment(rgb: np.ndarray, labels: np.ndarray, agl: np.ndarray, rng, degrade_p: float): | |
| """Random PATCH crop (inputs must be >= PATCH), rot90/flip, colour, optional degradation. | |
| Heights are rotation/flip invariant, so geometric augmentation is free.""" | |
| h, w = agl.shape | |
| r, c = rng.integers(0, h - PATCH + 1), rng.integers(0, w - PATCH + 1) | |
| rgb, labels, agl = (a[r:r + PATCH, c:c + PATCH] for a in (rgb, labels, agl)) | |
| k = int(rng.integers(0, 4)) | |
| rgb, labels, agl = (np.rot90(a, k) for a in (rgb, labels, agl)) | |
| if rng.random() < 0.5: | |
| rgb, labels, agl = (a[:, ::-1] for a in (rgb, labels, agl)) | |
| gain = rng.uniform(0.9, 1.1) * rng.uniform(0.95, 1.05, size=3) | |
| rgb = np.clip(rgb.astype(np.float32) * gain + rng.uniform(-10, 10), 0, 255).astype(np.uint8) | |
| if rng.random() < degrade_p: | |
| rgb = degrade(np.ascontiguousarray(rgb), rng) | |
| return (np.ascontiguousarray(normalize(rgb)), np.ascontiguousarray(agl, dtype=np.float32), | |
| np.ascontiguousarray(labels).astype(np.int64)) | |
| class GamusCrops: | |
| def __init__(self, tiles, degrade_p: float = 0.0): | |
| self.tiles = tiles | |
| self.degrade_p = degrade_p | |
| def __len__(self) -> int: | |
| return len(self.tiles) | |
| def __getitem__(self, idx: int): | |
| rng = np.random.default_rng() | |
| rgb, cls, agl = load_tile(self.tiles[idx]) | |
| return augment(rgb, GAMUS_TO_OEM[cls], clean_height(agl), rng, self.degrade_p) | |
| def find_synrs3d(root: Path) -> list[tuple]: | |
| """[(rgb, ndsm, ss_mask, (gsd_lo, gsd_hi))] for every SynRS3D tile under `root`, | |
| whatever the zip layout (it searches for the `opt` image folders).""" | |
| items = [] | |
| for opt in sorted(p for p in Path(root).rglob("opt") if p.is_dir()): | |
| base = opt.parent | |
| gsd = next((v for k, v in SYN_GSD.items() if k in f"_{base.name}_".replace("__", "_")), | |
| (0.3, 0.6)) | |
| for img in sorted(opt.glob("*.tif")): | |
| h, c = base / "gt_nDSM" / img.name, base / "gt_ss_mask" / img.name | |
| if h.exists() and c.exists(): | |
| items.append((img, h, c, gsd)) | |
| return items | |
| def _read(path: Path, bands) -> np.ndarray: | |
| import rasterio | |
| with rasterio.open(path) as src: | |
| return src.read(bands) | |
| class SynCrops: | |
| """SynRS3D tiles resampled to 0.33 m (from their folder's GSD range) and padded to PATCH.""" | |
| def __init__(self, items, degrade_p: float = 0.0): | |
| self.items = items | |
| self.degrade_p = degrade_p | |
| def __len__(self) -> int: | |
| return len(self.items) | |
| def __getitem__(self, idx: int): | |
| rng = np.random.default_rng() | |
| img, hp, cp, (g_lo, g_hi) = self.items[idx] | |
| rgb = np.transpose(_read(img, [1, 2, 3]), (1, 2, 0)) | |
| if rgb.dtype != np.uint8: | |
| a = rgb.astype(np.float32) | |
| lo, hi = np.percentile(a, [1, 99]) | |
| rgb = np.clip((a - lo) / max(hi - lo, 1e-6) * 255, 0, 255).astype(np.uint8) | |
| agl = clean_height(_read(hp, 1)) | |
| cls = _read(cp, 1) | |
| labels = np.where((cls >= 1) & (cls <= 8), cls.astype(np.int64) - 1, 255).astype(np.uint8) | |
| size = int(round(rgb.shape[0] * rng.uniform(g_lo, g_hi) / GAMUS_GSD_M)) | |
| size = max(64, min(size, 2048)) | |
| rgb, labels, agl = _resize(rgb, size), _resize(labels, size, nearest=True), _resize(agl, size) | |
| if size < PATCH: | |
| pad = PATCH - size | |
| rgb = np.pad(rgb, ((0, pad), (0, pad), (0, 0)), mode="reflect") | |
| labels = np.pad(labels, ((0, pad), (0, pad)), constant_values=255) | |
| agl = np.pad(agl, ((0, pad), (0, pad)), constant_values=np.nan) | |
| return augment(rgb, labels, agl, rng, self.degrade_p) | |
| def losses(height, logits, ref, labels, height_weight_m: float = 0.0): | |
| import torch | |
| import torch.nn.functional as F | |
| def masked_mean(x, m): # an all-masked batch gives 0, never NaN (NaN grads would poison the weights) | |
| m = m.float() | |
| return (x * m).sum() / m.sum().clamp(min=1.0) | |
| valid = torch.isfinite(ref) | |
| d = height - torch.nan_to_num(ref) | |
| if height_weight_m > 0: # long tail: a pixel at h metres counts (1 + h/height_weight_m) times | |
| w = valid.float() * (1.0 + torch.nan_to_num(ref).clamp(min=0) / height_weight_m) | |
| l1 = (d.abs() * w).sum() / w.sum().clamp(min=1.0) | |
| else: | |
| l1 = masked_mean(d.abs(), valid) | |
| gx = masked_mean((d[:, :, 1:] - d[:, :, :-1]).abs(), valid[:, :, 1:] & valid[:, :, :-1]) | |
| gy = masked_mean((d[:, 1:, :] - d[:, :-1, :]).abs(), valid[:, 1:, :] & valid[:, :-1, :]) | |
| if (labels != 255).any(): | |
| ce = F.cross_entropy(logits, labels, ignore_index=255) | |
| else: | |
| ce = logits.sum() * 0.0 | |
| return l1, gx + gy, ce | |
| def block_index(name: str) -> int | None: | |
| """Transformer block number of an encoder parameter ('pretrained.blocks.5.attn...' or, | |
| with chunked blocks, 'pretrained.blocks.0.5.attn...'), else None.""" | |
| parts = name.split(".") | |
| if len(parts) < 4 or parts[:2] != ["pretrained", "blocks"]: | |
| return None | |
| return int(parts[3]) if parts[3].isdigit() else int(parts[2]) | |
| def trainable_groups(model, unfreeze_blocks: int = 0): | |
| """Freeze the encoder except biases and the last `unfreeze_blocks` blocks (+ final norm). | |
| -> (encoder params, decoder/head params).""" | |
| names = [n for n, _ in model.named_parameters()] | |
| if not any(n.startswith("pretrained.") for n in names): | |
| children = [n for n, _ in model.named_children()] | |
| raise RuntimeError(f"no 'pretrained.' encoder params; top-level modules are {children}") | |
| indices = [i for i in map(block_index, names) if i is not None] | |
| first_open = (max(indices) + 1 - unfreeze_blocks) if indices and unfreeze_blocks > 0 else 10**9 | |
| encoder, rest = [], [] | |
| for n, p in model.named_parameters(): | |
| if not n.startswith("pretrained."): | |
| p.requires_grad = True | |
| rest.append(p) | |
| continue | |
| idx = block_index(n) | |
| p.requires_grad = (n.endswith(".bias") | |
| or (idx is not None and idx >= first_open) | |
| or (unfreeze_blocks > 0 and n.startswith("pretrained.norm."))) | |
| if p.requires_grad: | |
| encoder.append(p) | |
| return encoder, rest | |
| def bitfit(model) -> int: | |
| encoder, rest = trainable_groups(model, 0) | |
| return sum(p.numel() for p in encoder + rest) | |
| def main() -> None: | |
| import torch | |
| from torch.utils.data import ConcatDataset, DataLoader, WeightedRandomSampler | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--gamus", type=Path, default=DATA / "gamus") | |
| ap.add_argument("--synrs3d", type=Path, default=DATA / "SynRS3D", help="SynRS3D code repo (model class)") | |
| ap.add_argument("--ckpt", type=Path, default=DATA / "SynRS3D/pretrain/RS3DAda_vitl_DPT_height.pth") | |
| ap.add_argument("--out", type=Path, default=Path("runs/ft")) | |
| ap.add_argument("--hours", type=float, default=8.0) | |
| ap.add_argument("--batch", type=int, default=4) | |
| ap.add_argument("--lr", type=float, default=1e-4, help="decoder, heads and encoder biases") | |
| ap.add_argument("--encoder-lr", type=float, default=1e-5, help="unfrozen encoder blocks") | |
| ap.add_argument("--unfreeze-blocks", type=int, default=0) | |
| ap.add_argument("--height-weight", type=float, default=0.0, | |
| help="metres; >0 weights each pixel's L1 by 1 + height/this (0 = plain L1)") | |
| ap.add_argument("--syn-data", type=Path, default=None, help="extracted SynRS3D tiles") | |
| ap.add_argument("--syn-fraction", type=float, default=0.3) | |
| ap.add_argument("--degrade", type=float, default=0.0, help="fraction of samples degraded") | |
| ap.add_argument("--val-every", type=int, default=1500) | |
| ap.add_argument("--val-tiles", type=int, default=150) | |
| ap.add_argument("--workers", type=int, default=8) | |
| args = ap.parse_args() | |
| args.out.mkdir(parents=True, exist_ok=True) | |
| t_start = time.time() | |
| deadline = t_start + args.hours * 3600 | |
| tiles = find_tiles((args.gamus,)) | |
| train = [t for t in tiles if t.split == "train"] | |
| val = sorted((t for t in tiles if t.split == "val"), key=lambda t: t.scene_id) | |
| val = random.Random(0).sample(val, min(args.val_tiles, len(val))) | |
| mean_h = train_mean_height(tiles) | |
| syn = find_synrs3d(args.syn_data) if args.syn_data else [] | |
| print(f"{len(train)} GAMUS train tiles, {len(syn)} SynRS3D tiles, {len(val)} val tiles", flush=True) | |
| if not train or not val: | |
| raise SystemExit(f"need train and val tiles under {args.gamus}") | |
| torch.backends.cuda.matmul.allow_tf32 = True | |
| torch.backends.cudnn.allow_tf32 = True | |
| torch.backends.cudnn.benchmark = True | |
| model, device = load_model(args.ckpt, args.synrs3d) | |
| encoder, rest = trainable_groups(model, args.unfreeze_blocks) | |
| print(f"trainable params: encoder {sum(p.numel() for p in encoder):,} " | |
| f"(last {args.unfreeze_blocks} blocks + biases), decoder/heads {sum(p.numel() for p in rest):,}", | |
| flush=True) | |
| enc_bias = [p for p in encoder if p.ndim == 1] | |
| enc_weights = [p for p in encoder if p.ndim > 1] | |
| groups = [{"params": rest + enc_bias, "lr": args.lr}] | |
| if enc_weights: | |
| groups.append({"params": enc_weights, "lr": args.encoder_lr}) | |
| params = rest + encoder | |
| opt = torch.optim.AdamW(groups, weight_decay=1e-4) | |
| warmup = 500 | |
| def lr_at(step: int) -> float: # warmup by steps, then cosine over wall-clock time | |
| if step < warmup: | |
| return (step + 1) / warmup | |
| t = min(1.0, (time.time() - t_start) / (0.97 * (deadline - t_start))) | |
| return 0.02 + 0.98 * 0.5 * (1 + math.cos(math.pi * t)) | |
| sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_at) | |
| datasets = [GamusCrops(train, args.degrade)] | |
| weights = [np.full(len(train), 1.0 / len(train))] | |
| if syn: | |
| f = args.syn_fraction | |
| datasets.append(SynCrops(syn, args.degrade)) | |
| weights = [weights[0] * (1 - f), np.full(len(syn), f / len(syn))] | |
| data = ConcatDataset(datasets) | |
| epoch = len(train) + (len(syn) if syn else 0) | |
| sampler = WeightedRandomSampler(np.concatenate(weights), num_samples=epoch, replacement=True) | |
| loader = DataLoader(data, batch_size=args.batch, sampler=sampler, drop_last=True, | |
| num_workers=args.workers, pin_memory=True, | |
| persistent_workers=args.workers > 0) | |
| log = (args.out / "log.jsonl").open("a") | |
| # Survives restarts: rerunning with --ckpt runs/.../last.pth must not let a | |
| # worse first validation overwrite an earlier, better best.pth. | |
| best_file = args.out / "best.json" | |
| best = json.loads(best_file.read_text())["rmse"] if best_file.exists() else float("inf") | |
| step, t0 = 0, time.time() | |
| model.train() | |
| while time.time() < deadline: | |
| for x, ref, labels in loader: | |
| x, ref, labels = (a.to(device, non_blocking=True) for a in (x, ref, labels)) | |
| with torch.autocast("cuda", dtype=torch.bfloat16): | |
| out = model(x) | |
| height, logits = out["regression"].float()[:, 0], out["segmentation"].float() | |
| l1, grad, ce = losses(height, logits, ref, labels, args.height_weight) | |
| loss = l1 + 0.5 * grad + 0.2 * ce | |
| opt.zero_grad(set_to_none=True) | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_(params, 1.0) | |
| opt.step() | |
| sched.step() | |
| step += 1 | |
| if step == 200: | |
| print(f"{(time.time() - t0) / 200:.3f} s/step, " | |
| f"GPU peak {torch.cuda.max_memory_allocated() / 1e9:.1f} GB", flush=True) | |
| if step % 50 == 0: | |
| rec = {"step": step, "loss": loss.item(), "l1": l1.item(), "grad": grad.item(), | |
| "ce": ce.item(), "lr": sched.get_last_lr()[0]} | |
| print(json.dumps(rec), flush=True) | |
| log.write(json.dumps(rec) + "\n") | |
| last = time.time() >= deadline | |
| if step % args.val_every == 0 or last: | |
| model.eval() | |
| res = evaluate(model, device, val, mean_h)["scores"]["ALL"]["model"] | |
| model.train() | |
| rec = {"step": step, "val": res} | |
| print(json.dumps(rec), flush=True) | |
| log.write(json.dumps(rec) + "\n") | |
| log.flush() | |
| state = {"model": model.state_dict(), "step": step, "val": res} | |
| torch.save(state, args.out / "last.pth") | |
| if res["rmse"] < best: | |
| best = res["rmse"] | |
| torch.save(state, args.out / "best.pth") | |
| best_file.write_text(json.dumps({"rmse": best, "step": step, "val": res})) | |
| print(f"new best val RMSE {best:.3f} m at step {step}", flush=True) | |
| if last: | |
| break | |
| print(f"done: {step} steps, best val RMSE {best:.3f} m", flush=True) | |
| if __name__ == "__main__": | |
| main() | |