292 lines
15 KiB
HTML
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> |