From 0749daa2f987276f5addbeb0ae9e29f086e8a9ad Mon Sep 17 00:00:00 2001 From: youfu Date: Sun, 7 Jun 2026 20:54:13 +0800 Subject: [PATCH] v1: U-Net radio coverage prediction (2-channel input, baseline) --- .gitignore | 11 ++ src/dataset.py | 102 ++++++++++++++ src/evaluate.py | 111 +++++++++++++++ src/export_onnx.py | 45 ++++++ src/inspect_features.py | 72 ++++++++++ src/model.py | 98 +++++++++++++ src/plot_curves.py | 18 +++ src/test.py | 10 ++ src/train.py | 120 ++++++++++++++++ src/visualize.py | 82 +++++++++++ web/index.html | 295 ++++++++++++++++++++++++++++++++++++++++ web/maps/map_600.png | Bin 0 -> 2350 bytes web/maps/map_615.png | Bin 0 -> 2026 bytes web/maps/map_630.png | Bin 0 -> 1459 bytes web/maps/map_645.png | Bin 0 -> 2351 bytes web/maps/map_660.png | Bin 0 -> 2719 bytes web/maps/map_675.png | Bin 0 -> 2107 bytes 17 files changed, 964 insertions(+) create mode 100644 .gitignore create mode 100644 src/dataset.py create mode 100644 src/evaluate.py create mode 100644 src/export_onnx.py create mode 100644 src/inspect_features.py create mode 100644 src/model.py create mode 100644 src/plot_curves.py create mode 100644 src/test.py create mode 100644 src/train.py create mode 100644 src/visualize.py create mode 100644 web/index.html create mode 100644 web/maps/map_600.png create mode 100644 web/maps/map_615.png create mode 100644 web/maps/map_630.png create mode 100644 web/maps/map_645.png create mode 100644 web/maps/map_660.png create mode 100644 web/maps/map_675.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc79158 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +data/ +checkpoints/ +outputs/ +*.zip +__pycache__/ +*.pyc +.ipynb_checkpoints/ +.DS_Store +*.onnx +checkpoints_diag/ +checkpoints_v2/ \ No newline at end of file diff --git a/src/dataset.py b/src/dataset.py new file mode 100644 index 0000000..067ff6a --- /dev/null +++ b/src/dataset.py @@ -0,0 +1,102 @@ +""" +dataset.py — RadioMapSeer loader for radio coverage prediction. + +Input (2 channels): city building map + transmitter location +Target (1 channel) : radio coverage / path-loss map ("gain") + +Expected layout under data_root: + png/buildings_complete/{map}.png buildings vs background + png/antennas/{map}_{tx}.png transmitter marker + gain/DPM/{map}_{tx}.png coverage heatmap (target) + +Naming: map id 0..699, tx id 0..79 -> "{map}_{tx}.png" +""" + +from pathlib import Path + +import numpy as np +import torch +from torch.utils.data import Dataset +from PIL import Image + +# dataset.py lives in src/, so the project's data/ folder is one level up. +DEFAULT_DATA_ROOT = Path(r"D:\dataset\RadioMapSeer") + + +def _load_gray(path: Path, img_size: int | None = None) -> np.ndarray: + """Load a PNG as float32 in [0, 1]; optionally resize to img_size.""" + img = Image.open(path).convert("L") + if img_size is not None and img.size != (img_size, img_size): + img = img.resize((img_size, img_size), Image.BILINEAR) + return np.asarray(img, dtype=np.float32) / 255.0 + + +class RadioMapSeerDataset(Dataset): + """Returns (input[2,H,W], target[1,H,W]) tensors in [0, 1].""" + + SPLITS = { + "train": range(0, 500), + "val": range(500, 600), + "test": range(600, 700), + } + + def __init__(self, data_root=DEFAULT_DATA_ROOT, split="train", + sim="DPM", img_size=256, max_samples=None): + self.data_root = Path(data_root) + self.img_size = img_size + + self.gain_dir = self.data_root / "gain" / sim + self.build_dir = self.data_root / "png" / "buildings_complete" + self.ant_dir = self.data_root / "png" / "antennas" + + for d in (self.gain_dir, self.build_dir, self.ant_dir): + if not d.is_dir(): + raise FileNotFoundError(f"Expected folder not found: {d}") + + if split not in self.SPLITS: + raise ValueError(f"split must be train/val/test, got '{split}'") + allowed = set(self.SPLITS[split]) + + # Build the sample index by scanning the gain folder for "{map}_{tx}.png". + self.samples = [] + for f in sorted(self.gain_dir.glob("*.png")): + try: + map_id, tx_id = (int(x) for x in f.stem.split("_")) + except ValueError: + continue + if map_id in allowed: + self.samples.append((map_id, tx_id)) + + if not self.samples: + raise RuntimeError(f"No samples found in {self.gain_dir} — check naming.") + + if max_samples is not None: + self.samples = self.samples[:max_samples] + + def __len__(self): + return len(self.samples) + + def __getitem__(self, idx): + map_id, tx_id = self.samples[idx] + + buildings = _load_gray(self.build_dir / f"{map_id}.png", self.img_size) + antenna = _load_gray(self.ant_dir / f"{map_id}_{tx_id}.png", self.img_size) + gain = _load_gray(self.gain_dir / f"{map_id}_{tx_id}.png", self.img_size) + + x = np.stack([buildings, antenna], axis=0) # [2, H, W] + y = gain[None, :, :] # [1, H, W] + return torch.from_numpy(x), torch.from_numpy(y) + + +if __name__ == "__main__": + # Self-test: confirms paths, naming, shapes, and value ranges. + print("Data root:", DEFAULT_DATA_ROOT) + ds = RadioMapSeerDataset(split="train", img_size=256) + print(f"Train samples: {len(ds)}") + + x, y = ds[0] + print("Input shape :", tuple(x.shape), "| min/max:", float(x.min()), float(x.max())) + print("Target shape:", tuple(y.shape), "| min/max:", float(y.min()), float(y.max())) + + for s in ("val", "test"): + print(f"{s} samples:", len(RadioMapSeerDataset(split=s, img_size=256))) \ No newline at end of file diff --git a/src/evaluate.py b/src/evaluate.py new file mode 100644 index 0000000..be4253b --- /dev/null +++ b/src/evaluate.py @@ -0,0 +1,111 @@ +""" +evaluate.py — quantitative evaluation on held-out TEST maps (unseen cities). + +Reports U-Net RMSE (gray + dB), a log-distance path-loss baseline, and the +relative improvement. dB conversion: RadioMapSeer scales an ~80 dB pathloss +range into [0,1], so RMSE_dB = RMSE_gray * 80 (Levie et al.; Yapar et al.). +""" + +import argparse +import numpy as np +import torch +from torch.utils.data import DataLoader + +from dataset import RadioMapSeerDataset +from model import UNet + +DB_DYNAMIC_RANGE = 80.0 # dB spanned by the [0,1] gray scale + + +def tx_location(antenna_img): + return np.unravel_index(int(np.argmax(antenna_img)), antenna_img.shape) + + +def fit_log_distance(dataset, n_fit=200, seed=0): + """Fit gray ~ a + b*log10(distance) by least squares (distance-only baseline).""" + rng = np.random.default_rng(seed) + idxs = rng.choice(len(dataset), size=min(n_fit, len(dataset)), replace=False) + H = W = dataset.img_size + yy, xx = np.mgrid[0:H, 0:W] + logd_all, gray_all = [], [] + for i in idxs: + x, y = dataset[int(i)] + r, c = tx_location(x[1].numpy()) + dist = np.sqrt((yy - r) ** 2 + (xx - c) ** 2) + 1.0 + logd_all.append(np.log10(dist).ravel()) + gray_all.append(y.squeeze().numpy().ravel()) + logd, gray = np.concatenate(logd_all), np.concatenate(gray_all) + A = np.vstack([np.ones_like(logd), logd]).T + (a, b), *_ = np.linalg.lstsq(A, gray, rcond=None) + return float(a), float(b) + + +def baseline_predict(antenna_img, a, b): + H, W = antenna_img.shape + yy, xx = np.mgrid[0:H, 0:W] + r, c = tx_location(antenna_img) + dist = np.sqrt((yy - r) ** 2 + (xx - c) ** 2) + 1.0 + return np.clip(a + b * np.log10(dist), 0.0, 1.0) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--ckpt", default="checkpoints/best.pt") + p.add_argument("--img-size", type=int, default=256) + p.add_argument("--batch-size", type=int, default=32) + p.add_argument("--num-workers", type=int, default=4) + p.add_argument("--max-test", type=int, default=None, help="limit test samples for speed") + args = p.parse_args() + + device = "cuda" if torch.cuda.is_available() else "cpu" + + ckpt = torch.load(args.ckpt, map_location=device, weights_only=False) + base = ckpt.get("args", {}).get("base", 64) + model = UNet(2, 1, base=base).to(device) + model.load_state_dict(ckpt["model_state"]) + model.eval() + print(f"Loaded {args.ckpt} (epoch {ckpt.get('epoch', '?')})") + + test_ds = RadioMapSeerDataset(split="test", img_size=args.img_size, + max_samples=args.max_test) + print(f"Test samples: {len(test_ds)}") + + # --- U-Net error over the test set --- + loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False, + num_workers=args.num_workers, pin_memory=True) + sq_err, n_px = 0.0, 0 + with torch.no_grad(): + for x, y in loader: + x, y = x.to(device), y.to(device) + with torch.autocast(device_type=device, dtype=torch.float16, + enabled=(device == "cuda")): + pred = model(x) + sq_err += torch.sum((pred.float() - y.float()) ** 2).item() + n_px += y.numel() + unet_rmse = (sq_err / n_px) ** 0.5 + + # --- log-distance baseline: fit on train, evaluate on test --- + train_ds = RadioMapSeerDataset(split="train", img_size=args.img_size) + a, b = fit_log_distance(train_ds, n_fit=200) + print(f"Baseline fit: gray = {a:.3f} + ({b:.3f}) * log10(dist)") + + bsq, bn = 0.0, 0 + for i in range(len(test_ds)): + x, y = test_ds[i] + pred = baseline_predict(x[1].numpy(), a, b) + truth = y.squeeze().numpy() + bsq += float(np.sum((pred - truth) ** 2)) + bn += truth.size + base_rmse = (bsq / bn) ** 0.5 + + print("\n" + "=" * 54) + print(" EVALUATION ON UNSEEN TEST CITIES") + print("=" * 54) + print(f" U-Net (deep learning) RMSE: {unet_rmse:.4f} gray | {unet_rmse*DB_DYNAMIC_RANGE:5.2f} dB") + print(f" Log-distance baseline RMSE: {base_rmse:.4f} gray | {base_rmse*DB_DYNAMIC_RANGE:5.2f} dB") + print(f" Improvement over baseline: {100*(base_rmse-unet_rmse)/base_rmse:.1f}%") + print("=" * 54) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/export_onnx.py b/src/export_onnx.py new file mode 100644 index 0000000..bf836b0 --- /dev/null +++ b/src/export_onnx.py @@ -0,0 +1,45 @@ +""" +export_onnx.py — export the trained U-Net to ONNX and verify it matches PyTorch. +Run from the project root: python src\\export_onnx.py +""" +import numpy as np +import torch + +from model import UNet +from dataset import RadioMapSeerDataset + +CKPT = "checkpoints/best.pt" +OUT = "web/radio_unet.onnx" + +# --- load the trained model --- +ckpt = torch.load(CKPT, map_location="cpu", weights_only=False) +base = ckpt.get("args", {}).get("base", 64) +model = UNet(2, 1, base=base) +model.load_state_dict(ckpt["model_state"]) +model.eval() + +# --- export to ONNX (fixed 1x2x256x256 input) --- +dummy = torch.randn(1, 2, 256, 256) +torch.onnx.export( + model, dummy, OUT, + input_names=["input"], output_names=["coverage"], + opset_version=17, +) +print(f"Exported {OUT}") + +# --- verify ONNX output matches PyTorch on a real test sample --- +import onnxruntime as ort + +ds = RadioMapSeerDataset(split="test", img_size=256) +x, _ = ds[0] +xb = x.unsqueeze(0).numpy().astype(np.float32) + +with torch.no_grad(): + torch_out = model(x.unsqueeze(0)).numpy() + +sess = ort.InferenceSession(OUT, providers=["CPUExecutionProvider"]) +onnx_out = sess.run(["coverage"], {"input": xb})[0] + +max_diff = float(np.max(np.abs(torch_out - onnx_out))) +print(f"Max |PyTorch - ONNX| on a test sample: {max_diff:.2e}") +print("OK — outputs match" if max_diff < 1e-3 else "WARNING: large mismatch, stop and check") \ No newline at end of file diff --git a/src/inspect_features.py b/src/inspect_features.py new file mode 100644 index 0000000..cda439e --- /dev/null +++ b/src/inspect_features.py @@ -0,0 +1,72 @@ +""" +inspect_features.py — visualize U-Net feature maps at each encoder level for one +demo case, to check whether the transmitter ("tower") registers through the depth. + +Run: python src\\inspect_features.py +(or paste these blocks into Jupyter Lab cells to poke at individual channels) +""" +import numpy as np +import torch +import matplotlib.pyplot as plt +from pathlib import Path + +from dataset import RadioMapSeerDataset +from model import UNet + +device = "cuda" if torch.cuda.is_available() else "cpu" + +ckpt = torch.load("checkpoints/best.pt", map_location=device, weights_only=False) +base = ckpt.get("args", {}).get("base", 64) +model = UNet(2, 1, base=base).to(device) +model.load_state_dict(ckpt["model_state"]) +model.eval() + +# capture each level's output with forward hooks +acts = {} +def hook(name): + def fn(m, i, o): acts[name] = o.detach().float().cpu() + return fn + +model.inc.register_forward_hook(hook("inc 256")) +model.down1.register_forward_hook(hook("down1 128")) +model.down2.register_forward_hook(hook("down2 64")) +model.down3.register_forward_hook(hook("down3 32")) +model.down4.register_forward_hook(hook("down4 16 (bottleneck)")) + +# one demo case from the test set +ds = RadioMapSeerDataset(split="test", img_size=256) +x, y = ds[0] +tx = np.unravel_index(int(np.argmax(x[1].numpy())), (256, 256)) # (row, col) +with torch.no_grad(): + pred = model(x.unsqueeze(0).to(device)) + +print("Transmitter at (row, col):", tx) +for k, v in acts.items(): + print(f"{k}: shape {tuple(v.shape)}") + +levels = list(acts.keys()) +fig, axes = plt.subplots(2, 5, figsize=(20, 8)) + +axes[0, 0].imshow(x[0].numpy(), cmap="gray") +axes[0, 0].scatter([tx[1]], [tx[0]], c="red", marker="x", s=50) +axes[0, 0].set_title("input: buildings + Tx"); axes[0, 0].axis("off") +axes[0, 1].imshow(y.squeeze().numpy(), cmap="viridis") +axes[0, 1].set_title("ground truth"); axes[0, 1].axis("off") +axes[0, 2].imshow(pred.squeeze().cpu().numpy(), cmap="viridis") +axes[0, 2].set_title("prediction"); axes[0, 2].axis("off") +axes[0, 3].axis("off"); axes[0, 4].axis("off") + +# bottom row: max activation over channels at each level (best for spotting the Tx) +for ax, name in zip(axes[1], levels): + fmap = acts[name][0].amax(dim=0).numpy() # [h, w] + h = fmap.shape[0] + ax.imshow(fmap, cmap="magma") + ax.scatter([tx[1] * h / 256], [tx[0] * h / 256], + facecolors="none", edgecolors="cyan", s=70, linewidths=1.5) + ax.set_title(name, fontsize=10); ax.axis("off") + +fig.suptitle(f"U-Net feature maps (max over channels) — Tx at {tx}", fontsize=13) +fig.tight_layout() +Path("outputs").mkdir(exist_ok=True) +fig.savefig("outputs/feature_maps.png", dpi=110, bbox_inches="tight") +print("saved outputs/feature_maps.png") \ No newline at end of file diff --git a/src/model.py b/src/model.py new file mode 100644 index 0000000..9f62312 --- /dev/null +++ b/src/model.py @@ -0,0 +1,98 @@ +""" +model.py — U-Net for radio coverage map prediction. + +Input : [B, 2, H, W] (buildings + transmitter location) +Output: [B, 1, H, W] (predicted coverage map, values in [0, 1]) + +Classic encoder-decoder with skip connections (Ronneberger et al., 2015), +adapted for image-to-image regression instead of segmentation. +""" + +import torch +import torch.nn as nn + + +class DoubleConv(nn.Module): + """(conv 3x3 -> BatchNorm -> ReLU) applied twice.""" + def __init__(self, in_ch, out_ch): + super().__init__() + self.block = nn.Sequential( + nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True), + nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True), + ) + + def forward(self, x): + return self.block(x) + + +class Down(nn.Module): + """Halve resolution (max-pool) then DoubleConv.""" + def __init__(self, in_ch, out_ch): + super().__init__() + self.pool = nn.MaxPool2d(2) + self.conv = DoubleConv(in_ch, out_ch) + + def forward(self, x): + return self.conv(self.pool(x)) + + +class Up(nn.Module): + """Upsample, concatenate the skip connection, then DoubleConv.""" + def __init__(self, in_ch, skip_ch, out_ch): + super().__init__() + self.up = nn.ConvTranspose2d(in_ch, in_ch // 2, kernel_size=2, stride=2) + self.conv = DoubleConv(in_ch // 2 + skip_ch, out_ch) + + def forward(self, x, skip): + x = self.up(x) + x = torch.cat([skip, x], dim=1) # skip connection + return self.conv(x) + + +class UNet(nn.Module): + def __init__(self, in_channels=2, out_channels=1, base=64): + super().__init__() + self.inc = DoubleConv(in_channels, base) # 256 + self.down1 = Down(base, base * 2) # 128 + self.down2 = Down(base * 2, base * 4) # 64 + self.down3 = Down(base * 4, base * 8) # 32 + self.down4 = Down(base * 8, base * 8) # 16 (bottleneck) + + self.up1 = Up(base * 8, base * 8, base * 4) + self.up2 = Up(base * 4, base * 4, base * 2) + self.up3 = Up(base * 2, base * 2, base) + self.up4 = Up(base, base, base) + + self.outc = nn.Conv2d(base, out_channels, kernel_size=1) + + def forward(self, x): + x1 = self.inc(x) + x2 = self.down1(x1) + x3 = self.down2(x2) + x4 = self.down3(x3) + x5 = self.down4(x4) + + x = self.up1(x5, x4) + x = self.up2(x, x3) + x = self.up3(x, x2) + x = self.up4(x, x1) + return torch.sigmoid(self.outc(x)) + + +if __name__ == "__main__": + device = "cuda" if torch.cuda.is_available() else "cpu" + model = UNet(in_channels=2, out_channels=1, base=64).to(device) + n_params = sum(p.numel() for p in model.parameters()) + print(f"Device: {device}") + print(f"Parameters: {n_params:,}") + + x = torch.randn(4, 2, 256, 256, device=device) + y = model(x) + print("Input :", tuple(x.shape)) + print("Output:", tuple(y.shape), "| range:", round(float(y.min()), 3), "-", round(float(y.max()), 3)) + if device == "cuda": + print(f"Peak VRAM (forward, batch 4): {torch.cuda.max_memory_allocated()/1e9:.2f} GB") \ No newline at end of file diff --git a/src/plot_curves.py b/src/plot_curves.py new file mode 100644 index 0000000..63118ca --- /dev/null +++ b/src/plot_curves.py @@ -0,0 +1,18 @@ +import csv +import matplotlib.pyplot as plt + +epochs, tr, va = [], [], [] +with open("checkpoints_diag/history.csv") as f: + for row in csv.DictReader(f): + epochs.append(int(row["epoch"])) + tr.append(float(row["train_mse"])) + va.append(float(row["val_mse"])) + +plt.figure(figsize=(7, 4)) +plt.plot(epochs, tr, label="train MSE", marker="o") +plt.plot(epochs, va, label="val MSE", marker="s") +plt.xlabel("epoch"); plt.ylabel("MSE"); plt.legend(); plt.grid(alpha=0.3) +plt.title("Training vs. validation loss") +plt.tight_layout() +plt.savefig("outputs/loss_curves.png", dpi=120) +print("saved outputs/loss_curves.png") \ No newline at end of file diff --git a/src/test.py b/src/test.py new file mode 100644 index 0000000..2dab41e --- /dev/null +++ b/src/test.py @@ -0,0 +1,10 @@ +import numpy as np +from PIL import Image + +base = r"D:\Nextcloud-youfu\dev\code\radio-coverage-dl\data\png" +ant = np.asarray(Image.open(base + r"\antennas\600_0.png").convert("L")) +bld = np.asarray(Image.open(base + r"\buildings_complete\600.png").convert("L")) + +print("天线图里出现的数值:", np.unique(ant)) +print("天线亮点的像素个数:", int((ant > 127).sum())) +print("建筑图里出现的数值:", np.unique(bld)) \ No newline at end of file diff --git a/src/train.py b/src/train.py new file mode 100644 index 0000000..15f9d94 --- /dev/null +++ b/src/train.py @@ -0,0 +1,120 @@ +""" +train.py — train the U-Net on RadioMapSeer coverage maps. + +Examples: + # smoke test: tiny subset, 1 epoch — verify the loop and measure VRAM/speed + python src\\train.py --max-samples 200 --epochs 1 --batch-size 16 + + # full run (size it after the smoke test) + python src\\train.py --epochs 30 --batch-size 16 +""" + +import argparse +import time +from pathlib import Path + +import torch +import torch.nn as nn +from torch.utils.data import DataLoader + +from dataset import RadioMapSeerDataset +from model import UNet + + +def evaluate(model, loader, criterion, device, amp): + model.eval() + total, n = 0.0, 0 + with torch.no_grad(): + for x, y in loader: + x, y = x.to(device), y.to(device) + with torch.autocast(device_type=device, dtype=torch.float16, enabled=amp): + loss = criterion(model(x), y) + total += loss.item() * x.size(0) + n += x.size(0) + return total / n + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--epochs", type=int, default=30) + p.add_argument("--batch-size", type=int, default=16) + p.add_argument("--lr", type=float, default=1e-3) + p.add_argument("--img-size", type=int, default=256) + p.add_argument("--base", type=int, default=64) + p.add_argument("--max-samples", type=int, default=None, + help="Limit samples for quick smoke tests") + p.add_argument("--num-workers", type=int, default=4) + p.add_argument("--ckpt-dir", default="checkpoints") + args = p.parse_args() + + device = "cuda" if torch.cuda.is_available() else "cpu" + amp = (device == "cuda") + print(f"Device: {device} | AMP: {amp}") + + train_ds = RadioMapSeerDataset(split="train", img_size=args.img_size, + max_samples=args.max_samples) + val_max = None if args.max_samples is None else max(20, args.max_samples // 5) + val_ds = RadioMapSeerDataset(split="val", img_size=args.img_size, + max_samples=val_max) + print(f"Train: {len(train_ds)} | Val: {len(val_ds)}") + + dl_kwargs = dict(batch_size=args.batch_size, num_workers=args.num_workers, + pin_memory=True, persistent_workers=(args.num_workers > 0)) + train_loader = DataLoader(train_ds, shuffle=True, **dl_kwargs) + val_loader = DataLoader(val_ds, shuffle=False, **dl_kwargs) + + model = UNet(in_channels=2, out_channels=1, base=args.base).to(device) + criterion = nn.MSELoss() + optimizer = torch.optim.Adam(model.parameters(), lr=args.lr) + scaler = torch.amp.GradScaler(enabled=amp) + + ckpt_dir = Path(args.ckpt_dir) + ckpt_dir.mkdir(exist_ok=True) + best_val = float("inf") + + import csv + history_path = ckpt_dir / "history.csv" + with open(history_path, "w", newline="") as f: + csv.writer(f).writerow(["epoch", "train_mse", "val_mse"]) + + for epoch in range(1, args.epochs + 1): + model.train() + t0 = time.time() + running, n = 0.0, 0 + for i, (x, y) in enumerate(train_loader, 1): + x, y = x.to(device), y.to(device) + optimizer.zero_grad(set_to_none=True) + with torch.autocast(device_type=device, dtype=torch.float16, enabled=amp): + loss = criterion(model(x), y) + scaler.scale(loss).backward() + scaler.step(optimizer) + scaler.update() + running += loss.item() * x.size(0) + n += x.size(0) + if i % 50 == 0: + print(f" epoch {epoch} step {i}/{len(train_loader)} | loss {running/n:.5f}") + + train_loss = running / n + val_loss = evaluate(model, val_loader, criterion, device, amp) + dt = time.time() - t0 + print(f"[epoch {epoch}/{args.epochs}] train MSE {train_loss:.5f} | " + f"val MSE {val_loss:.5f} | val RMSE {val_loss**0.5:.5f} | {dt:.0f}s") + with open(history_path, "a", newline="") as f: + csv.writer(f).writerow([epoch, f"{train_loss:.6f}", f"{val_loss:.6f}"]) + + if val_loss < best_val: + best_val = val_loss + torch.save({"epoch": epoch, "model_state": model.state_dict(), + "val_loss": val_loss, "args": vars(args)}, + ckpt_dir / "best.pt") + print(f" -> saved best.pt (val MSE {val_loss:.5f})") + + if device == "cuda": + print(f" peak VRAM: {torch.cuda.max_memory_allocated()/1e9:.2f} GB") + torch.cuda.reset_peak_memory_stats() + + print(f"\nDone. Best val MSE: {best_val:.5f} (RMSE {best_val**0.5:.5f})") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/visualize.py b/src/visualize.py new file mode 100644 index 0000000..05f42b3 --- /dev/null +++ b/src/visualize.py @@ -0,0 +1,82 @@ +""" +visualize.py — render model predictions vs ground truth on unseen test maps. + +Loads checkpoints/best.pt and saves comparison panels to outputs/. + +Usage: + python src\\visualize.py + python src\\visualize.py --n 6 +""" + +import argparse +from pathlib import Path + +import numpy as np +import torch +import matplotlib.pyplot as plt + +from dataset import RadioMapSeerDataset +from model import UNet + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--ckpt", default="checkpoints/best.pt") + p.add_argument("--n", type=int, default=6, help="number of samples to render") + p.add_argument("--img-size", type=int, default=256) + p.add_argument("--out-dir", default="outputs") + p.add_argument("--seed", type=int, default=0) + args = p.parse_args() + + device = "cuda" if torch.cuda.is_available() else "cpu" + + ckpt = torch.load(args.ckpt, map_location=device, weights_only=False) + base = ckpt.get("args", {}).get("base", 64) + model = UNet(in_channels=2, out_channels=1, base=base).to(device) + model.load_state_dict(ckpt["model_state"]) + model.eval() + print(f"Loaded {args.ckpt} (epoch {ckpt.get('epoch', '?')}, " + f"val MSE {ckpt.get('val_loss', float('nan')):.5f})") + + ds = RadioMapSeerDataset(split="test", img_size=args.img_size) # unseen cities + rng = np.random.default_rng(args.seed) + idxs = rng.choice(len(ds), size=args.n, replace=False) + + out_dir = Path(args.out_dir) + out_dir.mkdir(exist_ok=True) + + rmses = [] + for k, idx in enumerate(idxs): + x, y = ds[int(idx)] + with torch.no_grad(): + pred = model(x.unsqueeze(0).to(device)).cpu().squeeze().numpy() + buildings, antenna = x[0].numpy(), x[1].numpy() + truth = y.squeeze().numpy() + err = np.abs(pred - truth) + rmse = float(np.sqrt(np.mean((pred - truth) ** 2))) + rmses.append(rmse) + + fig, axes = plt.subplots(1, 5, figsize=(18, 4)) + panels = [ + (buildings, "City map (buildings)", "gray", 1.0), + (antenna, "Transmitter", "gray", 1.0), + (truth, "Ground-truth coverage","viridis", 1.0), + (pred, "Predicted coverage", "viridis", 1.0), + (err, "Absolute error", "magma", float(err.max())), + ] + for ax, (img, title, cmap, vmax) in zip(axes, panels): + ax.imshow(img, cmap=cmap, vmin=0, vmax=vmax) + ax.set_title(title, fontsize=11) + ax.axis("off") + fig.suptitle(f"Test sample {idx} — RMSE {rmse:.4f}", fontsize=13) + fig.tight_layout() + out_path = out_dir / f"prediction_{k:02d}.png" + fig.savefig(out_path, dpi=110, bbox_inches="tight") + plt.close(fig) + print(f" saved {out_path} (RMSE {rmse:.4f})") + + print(f"\nDone. {args.n} panels in {out_dir}/ | mean RMSE {np.mean(rmses):.4f}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3e5eb4e --- /dev/null +++ b/web/index.html @@ -0,0 +1,295 @@ + + + + + +Radio Coverage Prediction — Deep Learning Demo + + + + +
+
+ + +
+ +

+

+ +
+
4.1 dB
+
71%
+
~13M
+
<2 s
+
+ +

+

+ +

+

+ +

+

+
+
+ + + +
+
+
+

+ +
+
+

+ +
+
+
+
+
+
+ +

+

+ +

+
+
+
+
+
+
+
+ +

+
+ PythonPyTorchU-Net / CNN + ONNXONNX Runtime WebNumPy +
+ +
+
+ + + + \ No newline at end of file diff --git a/web/maps/map_600.png b/web/maps/map_600.png new file mode 100644 index 0000000000000000000000000000000000000000..186f948f97770bbc855e474fbd71d83dcef7c403 GIT binary patch literal 2350 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 IM6N<$g4Z8_ZU6uP literal 0 HcmV?d00001 diff --git a/web/maps/map_630.png b/web/maps/map_630.png new file mode 100644 index 0000000000000000000000000000000000000000..cf71ab8c5840b9f8b3932c1811639ed3a7ddd6e5 GIT binary patch literal 1459 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 V5{2L@W8we+002ovPDHLkV1mCXWvl=I literal 0 HcmV?d00001 diff --git a/web/maps/map_660.png b/web/maps/map_660.png new file mode 100644 index 0000000000000000000000000000000000000000..4ddbb3d768af9646f7a335fb6fd9bd1b0e53f016 GIT binary patch literal 2719 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 literal 0 HcmV?d00001