Files
radio-coverage-dl/web/index.html
2026-06-16 17:47:20 +08:00

292 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Radio Coverage Prediction — Deep Learning Demo</title>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
<style>
:root { --bg:#0f1115; --panel:#1a1d24; --line:#2a2e37; --fg:#e6e8eb; --muted:#9aa0a6; --accent:#3ddc84; --warn:#f0a93d; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font-family: system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",Roboto,sans-serif; line-height:1.6; }
.wrap { max-width:920px; margin:0 auto; padding:28px 20px 72px; }
.topbar { display:flex; justify-content:flex-end; gap:6px; margin-bottom:16px; }
.lang { cursor:pointer; border:1px solid var(--line); background:transparent; color:var(--muted); padding:5px 12px; border-radius:20px; font-size:.85rem; }
.lang.active { background:var(--accent); color:#0f1115; border-color:var(--accent); font-weight:500; }
h1 { font-size:1.7rem; margin:0 0 6px; }
.sub { color:var(--muted); margin:0 0 24px; }
h2 { font-size:1.15rem; margin:34px 0 10px; }
.vers { display:flex; gap:10px; flex-wrap:wrap; margin:0 0 14px; }
.ver-btn { cursor:pointer; border:1px solid var(--line); background:var(--panel); color:var(--fg);
padding:10px 18px; border-radius:10px; font-size:.95rem; font-weight:500; }
.ver-btn.active { background:var(--accent); color:#0f1115; border-color:var(--accent); }
.ver-btn:disabled { opacity:.4; cursor:not-allowed; }
.notice { background:var(--panel); border-left:3px solid var(--accent); border-radius:0 8px 8px 0;
padding:14px 16px; margin:0 0 18px; font-size:.95rem; }
.metricbar { display:flex; gap:14px; flex-wrap:wrap; margin:0 0 8px; }
.metric { background:var(--panel); border-radius:10px; padding:10px 16px; }
.metric .v { font-size:1.4rem; font-weight:500; color:var(--accent); }
.metric .l { color:var(--muted); font-size:.82rem; }
.demo { background:var(--panel); border-radius:14px; padding:20px; }
.row { display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start; }
.col { flex:1 1 256px; }
canvas { width:100%; max-width:340px; height:auto; image-rendering:pixelated; border-radius:8px; background:#000; cursor:crosshair; display:block; }
.col h3 { font-size:.95rem; margin:0 0 8px; font-weight:500; }
.controls { margin:0 0 18px; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
select { background:#0f1115; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px 10px; }
.status { color:var(--muted); font-size:.9rem; margin-top:14px; min-height:1.2em; }
.hint { color:var(--muted); font-size:.85rem; }
.legend { height:12px; border-radius:6px; margin-top:6px; background:linear-gradient(90deg,#440154,#3b528b,#21918c,#5ec962,#fde725); }
.legend-labels { display:flex; justify-content:space-between; color:var(--muted); font-size:.75rem; }
footer { margin-top:36px; color:var(--muted); font-size:.8rem; border-top:1px solid var(--line); padding-top:16px; }
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<button class="lang" data-lang="en">EN</button>
<button class="lang" data-lang="zh">中文</button>
</div>
<h1 data-i18n="title"></h1>
<p class="sub" data-i18n="subtitle"></p>
<h2 data-i18n="ver_h"></h2>
<div class="vers">
<button class="ver-btn" data-v="v1" data-i18n="btn_v1"></button>
<button class="ver-btn" data-v="v2" data-i18n="btn_v2"></button>
<button class="ver-btn" data-v="v3" data-i18n="btn_v3"></button>
<button class="ver-btn" data-v="v4" data-i18n="btn_v4"></button>
</div>
<div class="notice" id="verNotice"></div>
<div class="metricbar">
<div class="metric"><div class="v" id="verMetric"></div><div class="l" data-i18n="m_error"></div></div>
</div>
<h2 data-i18n="demo_h"></h2>
<div class="demo">
<div class="controls">
<label for="mapSel" data-i18n="select_label"></label>
<select id="mapSel"></select>
<span class="hint" data-i18n="click_hint"></span>
</div>
<div class="row">
<div class="col">
<h3 data-i18n="input_title"></h3>
<canvas id="inputCanvas" width="256" height="256"></canvas>
</div>
<div class="col">
<h3 data-i18n="output_title"></h3>
<canvas id="outputCanvas" width="256" height="256"></canvas>
<div class="legend"></div>
<div class="legend-labels"><span data-i18n="legend_weak"></span><span data-i18n="legend_strong"></span></div>
</div>
</div>
<div class="status" id="status"></div>
</div>
<h2 data-i18n="how_h"></h2>
<p data-i18n="how_p"></p>
<footer data-i18n="footer"></footer>
</div>
<script>
const T = {
en: {
title: "Predicting Wireless Coverage with Deep Learning",
subtitle: "A U-Net predicts a base station's signal coverage across a city — running live in your browser. Switch between model versions to see how it improved.",
ver_h: "Model version",
btn_v1: "v1 · baseline", btn_v2: "v2 · distance fix", btn_v3: "v3 · checkerboard fix", btn_v4: "v4 · WNet refine",
m_error: "RMSE on held-out cities",
notice_v1: "Baseline (2-channel input: buildings + transmitter). Known issue: coverage cuts off in a square around the transmitter — the far field stays blank because the network can't propagate the source that far.",
notice_v2: "Adds a distance-to-transmitter input channel (3-channel). Fixed: the square cutoff — coverage now spans the whole map, with far-field rays and distant building shadows. Known issue: faint checkerboard texture in smooth areas, from transposed-convolution upsampling.",
notice_v3: "Replaces transposed-convolution upsampling with resize-convolution. Fixed: the checkerboard texture — smooth regions are clean. Known issue: along a long unobstructed corridor the far-field beam fades before reaching the edge — a residual receptive-field limit.",
notice_v4: "Two cascaded U-Nets — a coarse predictor feeds a refiner. Because the refiner's input already contains a global coverage estimate, every pixel gets long-range context (a larger effective receptive field). Fixed: the fading corridor — coverage stays bright to the edge. Lowest error of all four versions.",
demo_h: "Try it yourself",
select_label: "City layout:", click_hint: "Click the map to place a transmitter.",
input_title: "City map — click to place transmitter", output_title: "Predicted coverage",
legend_weak: "weak", legend_strong: "strong",
how_h: "How it works",
how_p: "The model takes the building layout and the transmitter location as image channels and outputs a coverage heatmap, learned from the public RadioMapSeer dataset (5.9 GHz urban propagation). It was given no propagation equations — it learned signal falloff and building shadowing from data. The model runs entirely in your browser via ONNX Runtime Web; nothing is sent to a server.",
footer: "Dataset: RadioMapSeer (Yapar et al., 2022), CC BY 4.0. Independent portfolio project. Errors are on held-out cities the model never trained on.",
st_loading: "Loading model…", st_ready: "Pick a layout and click to place a transmitter.",
st_running: "Running inference…",
st_done: (ms) => "Predicted in " + ms + " ms. Click again to move the transmitter.",
st_error: (m) => "Error: " + m
},
zh: {
title: "基于深度学习的无线信号覆盖预测",
subtitle: "一个 U-Net 在你的浏览器中实时预测基站的信号覆盖。切换不同模型版本,看看它是如何一步步改进的。",
ver_h: "模型版本",
btn_v1: "v1 · 基线", btn_v2: "v2 · 距离修复", btn_v3: "v3 · 棋盘格修复", btn_v4: "v4 · WNet 精修",
m_error: "留出城市上的 RMSE",
notice_v1: "基线模型(2 通道输入:建筑 + 发射机)。已知问题:覆盖在发射机周围呈方形截断——远场为空白,因为网络无法将信号源的影响传播到远处。",
notice_v2: "新增「到发射机的距离」输入通道(3 通道)。已修复:方形截断——覆盖现已贯穿整张地图,远场射线与远处建筑阴影都能呈现。已知问题:平滑区域出现轻微棋盘格纹理,来自转置卷积上采样。",
notice_v3: "用 resize 卷积(双线性上采样 + 卷积)替换转置卷积。已修复:棋盘格纹理消失,平滑区域变得干净。已知问题:在无遮挡的长走廊中,远场光束在到达边缘前逐渐变暗——残留的感受野限制。",
notice_v4: "两个级联 U-Net——粗预测网络的输出送入精修网络。由于精修网络的输入已包含一张全局覆盖估计,每个像素都获得了长程上下文(更大的有效感受野)。已修复:长走廊衰减,覆盖一直明亮延伸到边缘。四个版本中误差最低。",
demo_h: "在线体验",
select_label: "城市布局:", click_hint: "点击地图以放置发射机。",
input_title: "城市地图——点击放置发射机", output_title: "预测覆盖",
legend_weak: "弱", legend_strong: "强",
how_h: "实现原理",
how_p: "模型以建筑布局和发射机位置作为图像通道输入,输出覆盖热力图,训练自公开的 RadioMapSeer 数据集(5.9 GHz 城市传播)。它没有被告知任何传播公式,完全从数据中学会了信号衰减与建筑遮蔽。模型通过 ONNX Runtime Web 完全在你的浏览器中运行,不向服务器发送任何数据。",
footer: "数据集:RadioMapSeer(Yapar 等,2022),CC BY 4.0 许可。独立作品集项目。误差基于模型从未训练过的留出城市。",
st_loading: "正在加载模型…", st_ready: "选择布局并点击放置发射机。",
st_running: "正在推理…",
st_done: (ms) => "推理完成,用时 " + ms + " 毫秒。再次点击可移动发射机。",
st_error: (m) => "错误:" + m
}
};
const VERSIONS = {
v1: { model: "radio_unet_v1.onnx", channels: 2, available: true },
v2: { model: "radio_unet_v2.onnx", channels: 3, available: true },
v3: { model: "radio_unet_v3.onnx", channels: 3, available: true },
v4: { model: "radio_unet_v4.onnx", channels: 3, available: true },
};
const METRICS = { v1: "0.052", v2: "0.034", v3: "0.030", v4: "0.021" };
const SIZE = 256;
let currentLang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
let activeVersion = "v4";
const sessions = {};
let buildings = null, txRC = null;
const statusEl = document.getElementById("status");
const inCanvas = document.getElementById("inputCanvas");
const outCanvas = document.getElementById("outputCanvas");
const inCtx = inCanvas.getContext("2d", { willReadFrequently: true });
const outCtx = outCanvas.getContext("2d");
const mapSel = document.getElementById("mapSel");
const VIRIDIS = [[68,1,84],[72,40,120],[62,74,137],[49,104,142],[38,130,142],
[31,158,137],[53,183,121],[109,205,89],[180,222,44],[253,231,37]];
function viridis(t){
t = Math.max(0, Math.min(1, t));
const x = t*(VIRIDIS.length-1), i = Math.floor(x), f = x-i;
const a = VIRIDIS[i], b = VIRIDIS[Math.min(i+1, VIRIDIS.length-1)];
return [a[0]+(b[0]-a[0])*f, a[1]+(b[1]-a[1])*f, a[2]+(b[2]-a[2])*f];
}
function applyLang(lang){
currentLang = lang;
document.documentElement.lang = (lang === "zh") ? "zh" : "en";
document.querySelectorAll("[data-i18n]").forEach(el=>{
const v = T[lang][el.getAttribute("data-i18n")];
if (typeof v === "string") el.textContent = v;
});
document.querySelectorAll(".lang").forEach(b=> b.classList.toggle("active", b.dataset.lang === lang));
updateVersionInfo();
}
function updateVersionInfo(){
document.getElementById("verNotice").textContent = T[currentLang]["notice_" + activeVersion];
document.getElementById("verMetric").textContent = METRICS[activeVersion];
document.querySelectorAll(".ver-btn").forEach(b=> b.classList.toggle("active", b.dataset.v === activeVersion));
}
async function loadSession(v){
if (sessions[v]) return sessions[v];
statusEl.textContent = T[currentLang].st_loading;
sessions[v] = await ort.InferenceSession.create(VERSIONS[v].model, { executionProviders: ["wasm"] });
return sessions[v];
}
async function setVersion(v){
if (!VERSIONS[v].available) return;
activeVersion = v;
updateVersionInfo();
await loadSession(v);
if (txRC) await runPrediction();
else statusEl.textContent = T[currentLang].st_ready;
}
function loadMap(id){
return new Promise((resolve)=>{
const img = new Image();
img.onload = ()=>{
inCtx.drawImage(img, 0, 0, SIZE, SIZE);
const data = inCtx.getImageData(0,0,SIZE,SIZE).data;
buildings = new Float32Array(SIZE*SIZE);
for(let p=0; p<SIZE*SIZE; p++) buildings[p] = data[p*4] / 255;
outCtx.clearRect(0,0,SIZE,SIZE);
txRC = null;
resolve();
};
img.src = "maps/map_"+id+".png";
});
}
function buildInput(){
const ch = VERSIONS[activeVersion].channels;
const input = new Float32Array(ch*SIZE*SIZE);
input.set(buildings, 0); // channel 0: buildings
const [r,c] = txRC;
input[SIZE*SIZE + r*SIZE + c] = 1.0; // channel 1: transmitter
if (ch === 3){ // channel 2: distance to Tx (v2/v3/v4)
const off = 2*SIZE*SIZE, norm = Math.SQRT2 * SIZE;
for(let yy=0; yy<SIZE; yy++)
for(let xx=0; xx<SIZE; xx++)
input[off + yy*SIZE + xx] = Math.sqrt((yy-r)*(yy-r)+(xx-c)*(xx-c)) / norm;
}
return new ort.Tensor("float32", input, [1, ch, SIZE, SIZE]);
}
async function runPrediction(){
statusEl.textContent = T[currentLang].st_running;
const session = await loadSession(activeVersion);
const t0 = performance.now();
const out = await session.run({ input: buildInput() });
const dt = performance.now()-t0;
const cov = out.coverage.data;
const imgData = outCtx.createImageData(SIZE,SIZE);
for(let p=0; p<SIZE*SIZE; p++){
const [rr,gg,bb] = viridis(cov[p]);
imgData.data[p*4]=rr; imgData.data[p*4+1]=gg; imgData.data[p*4+2]=bb; imgData.data[p*4+3]=255;
}
outCtx.putImageData(imgData, 0, 0);
drawTxMarker();
statusEl.textContent = T[currentLang].st_done(dt.toFixed(0));
}
function drawTxMarker(){
if(!txRC) return;
const [r,c] = txRC;
outCtx.beginPath(); outCtx.arc(c, r, 3, 0, 2*Math.PI);
outCtx.fillStyle = "#ff3b3b"; outCtx.fill();
outCtx.lineWidth = 1; outCtx.strokeStyle = "#fff"; outCtx.stroke();
}
inCanvas.addEventListener("click", async (e)=>{
if(!buildings) return;
const rect = inCanvas.getBoundingClientRect();
txRC = [Math.floor((e.clientY-rect.top)/rect.height*SIZE),
Math.floor((e.clientX-rect.left)/rect.width*SIZE)];
await runPrediction();
});
async function init(){
[600,615,630,645,660,675].forEach((id,i)=>{
const o = document.createElement("option");
o.value = id; o.textContent = (currentLang==="zh"?"布局 ":"Layout ")+(i+1);
mapSel.appendChild(o);
});
mapSel.addEventListener("change", ()=> loadMap(mapSel.value));
document.querySelectorAll(".lang").forEach(b=> b.addEventListener("click", ()=> applyLang(b.dataset.lang)));
document.querySelectorAll(".ver-btn").forEach(b=> b.addEventListener("click", ()=> setVersion(b.dataset.v)));
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/";
ort.env.wasm.numThreads = 1;
applyLang(currentLang);
await loadMap(600);
await loadSession(activeVersion);
statusEl.textContent = T[currentLang].st_ready;
}
init().catch(err=>{ statusEl.textContent = T[currentLang].st_error(err.message); console.error(err); });
</script>
</body>
</html>