Files
radio-coverage-dl/web/index.html

288 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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" disabled></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 · soon",
m_error: "RMSE on unseen 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: "In progress. Replaces transposed-convolution upsampling with resize-convolution to remove the checkerboard texture. Not yet available.",
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. Error reported on held-out test cities.",
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 · 即将推出",
m_error: "未见城市上的 RMSE",
notice_v1: "基线模型2 通道输入:建筑 + 发射机)。已知问题:覆盖在发射机周围呈方形截断——远场为空白,因为网络无法将信号源的影响传播到远处。",
notice_v2: "新增「到发射机的距离」输入通道3 通道)。已修复:方形截断——覆盖现已贯穿整张地图,远场射线与远处建筑阴影都能呈现。已知问题:平滑区域出现轻微棋盘格纹理,来自转置卷积上采样。",
notice_v3: "开发中。用 resize 卷积替换转置卷积上采样,以消除棋盘格纹理。暂未上线。",
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: "数据集RadioMapSeerYapar 等2022CC 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: false },
};
const METRICS = { v1: "0.052", v2: "0.034", v3: "—" };
const SIZE = 256;
let currentLang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
let activeVersion = "v2";
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)
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>