前言

前面的文章里,我们搭建的 YOLO 推理引擎都运行在服务器端(Python/C++)。这意味着如果你的产品有 1 万个用户同时使用,你就必须租用极其昂贵的 GPU 服务器来扛起这笔庞大的高并发推理开销。

有没有一种方案,可以让客户端“自己解决”AI 推理?

答案是:Web AI
通过 ONNX Runtime Web,我们可以把训练好的 YOLO 模型直接发送给用户的浏览器。用户的浏览器会调用本地设备的 CPU、WebGL 甚至最新的 WebGPU 接口,在本地完成 AI 运算,完全不占用我们的服务器算力。同时,由于数据无需上传服务器,这做到了绝对的用户隐私保护

本文将提供一个纯前端单文件(HTML + JS)实现的完整代码。读者只需复制保存到本地,即可双击在浏览器中跑通一个“零服务器成本”的实时摄像头目标检测应用!


一、整体技术工作流

在浏览器中运行 YOLO 的全套流程如下:

1
2
3
4
5
6
7
graph LR
A[YOLO PyTorch .pt] -->|第一步: Python 导出| B[ONNX 模型 .onnx]
B -->|第二步: 网页加载| C[ONNX Runtime Web]
D[摄像头 Video 帧] -->|第三步: Canvas 捕获| E[Letterbox 预处理为 Tensor]
E -->|第四步: 执行推理| C
C -->|输出 Raw Tensor| F[第五步: JS 解析坐标 & NMS 过滤]
F -->|第六步: 屏幕渲染| G[Canvas 绘制发光检测框]

二、第一步:导出适合浏览器的 ONNX 模型

首先,我们需要在 Python 环境中将 YOLO 模型导出为通用且经过结构优化的 ONNX 格式:

1
2
3
4
5
6
7
8
9
from ultralytics import YOLO

# 加载你的预训练或自定义模型
model = YOLO("yolo11n.pt")

# 导出为 ONNX 格式
# imgsz=640 表示预期的网页输入尺寸为 640x640
# simplify=True 会自动优化模型层级,去除不必要的冗余算子,减小体积
model.export(format="onnx", imgsz=640, simplify=True)

运行后,你将获得一个 yolo11n.onnx 文件(大小约 12MB 左右)。将该模型文件放入你网页的静态资源目录中。


三、纯前端单文件实现(开箱即用)

为了让落地最简单,我们编写一个自包含的 index.html,其中通过 CDN 引入了微软官方的 onnxruntime-web 库。

你只需要新建 index.html,复制以下完整代码。用浏览器直接打开即可运行(:为了能够调用摄像头,浏览器要求网页必须运行在 localhost 环境中,或者部署了 HTTPS 协议):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YOLO 纯前端实时目标检测 - ONNX Runtime Web</title>
<!-- 引入微软官方 ONNX Runtime Web 库 -->
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
<style>
body {
margin: 0;
padding: 0;
background-color: #0f0f13;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
h1 {
margin: 10px 0;
font-size: 1.8rem;
text-shadow: 0 0 10px rgba(0, 255, 200, 0.5);
}
.container {
position: relative;
width: 640px;
height: 480px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
border: 2px solid #333344;
}
#webcam {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* 镜像翻转,更自然 */
}
#canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
transform: scaleX(-1); /* 与视频保持一致镜像 */
}
.controls {
margin-top: 15px;
display: flex;
gap: 15px;
align-items: center;
}
button {
padding: 10px 20px;
background: linear-gradient(135deg, #00f2fe 0%, #4facfe 100%);
border: none;
border-radius: 6px;
color: white;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.4);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 242, 254, 0.6);
}
#status {
font-size: 0.9rem;
color: #aaaaaa;
}
#fps-display {
font-size: 1rem;
color: #00ffc8;
font-weight: bold;
}
</style>
</head>
<body>

<h1>YOLO 纯前端实时目标检测</h1>
<div id="status">正在初始化环境并加载模型,请稍候...</div>
<div id="fps-display">FPS: -- | 延迟: -- ms</div>

<br>
<div class="container">
<!-- 视频流输入 -->
<video id="webcam" autoplay playsinline muted></video>
<!-- Canvas 用于绘制检测框 -->
<canvas id="canvas" width="640" height="480"></canvas>
</div>

<div class="controls">
<button id="start-btn" disabled>开启摄像头检测</button>
</div>

<script>
// COCO 数据集 80 个类别映射
const CLASS_NAMES = [
"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light",
"fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", "dog", "horse", "sheep", "cow",
"elephant", "bear", "zebra", "giraffe", "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee",
"skis", "snowboard", "sports ball", "kite", "baseball bat", "baseball glove", "skateboard", "surfboard",
"tennis racket", "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple",
"sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", "couch",
"potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse", "remote", "keyboard", "cell phone",
"microwave", "oven", "toaster", "sink", "refrigerator", "book", "clock", "vase", "scissors", "teddy bear",
"hair drier", "toothbrush"
];

let session = null;
let webcamElement = document.getElementById("webcam");
let canvasElement = document.getElementById("canvas");
let ctx = canvasElement.getContext("2d");
let statusElement = document.getElementById("status");
let fpsElement = document.getElementById("fps-display");
let startBtn = document.getElementById("start-btn");
let isRunning = false;

// ==================== 1. 初始化并加载 ONNX 模型 ====================
async function initApp() {
try {
// 如果本地没有运行 WebGPU/WebGL 加速,默认采用 WebGL 或 WASM
// 我们在 Session 参数中开启 WebGL 硬件加速提高帧率
const options = {
executionProviders: ['webgl', 'wasm'],
};

statusElement.innerText = "正在从网络下载 YOLO ONNX 模型并配置渲染器 (约 12MB)...";

// 加载放置在同目录下的 yolo11n.onnx
// 为了本地快速预览,这里可以用公网测试模型路径(请确保静态服务器能正常读取本地文件)
session = await ort.InferenceSession.create("./yolo11n.onnx", options);

statusElement.innerText = "模型加载成功!";
startBtn.disabled = false;
} catch (err) {
statusElement.innerText = "模型加载失败,请确保 yolo11n.onnx 放置在 HTML 同级目录下且配置了本地静态服务器。";
console.error(err);
}
}

// ==================== 2. 图像预处理 (Preprocessing) ====================
// YOLO 格式需要 1x3x640x640 的归一化 Float32 数组输入 (NCHW)
function preprocess(videoFrame, modelWidth, modelHeight) {
// 临时创建一个小 Canvas 进行缩放
const canvas = document.createElement("canvas");
canvas.width = modelWidth;
canvas.height = modelHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(videoFrame, 0, 0, modelWidth, modelHeight);

// 获取缩放后的 RGBA 数据
const imgData = ctx.getImageData(0, 0, modelWidth, modelHeight);
const data = imgData.data;

// 构建 NCHW Float32 扁平化矩阵
const float32Array = new Float32Array(3 * modelWidth * modelHeight);

// 归一化 (1 / 255) 并按 R-G-B 通道重排(将 HWC 转为 CHW)
for (let i = 0; i < modelWidth * modelHeight; i++) {
float32Array[i] = data[i * 4] / 255.0; // R 通道
float32Array[i + modelWidth * modelHeight] = data[i * 4 + 1] / 255.0; // G 通道
float32Array[i + 2 * modelWidth * modelHeight] = data[i * 4 + 2] / 255.0; // B 通道
}

return new ort.Tensor("float32", float32Array, [1, 3, modelWidth, modelHeight]);
}

// ==================== 3. 主推理闭环 ====================
let lastFrameTime = performance.now();

async function detectFrame() {
if (!isRunning) return;

const startTime = performance.now();

// 1. 图像预处理
const inputTensor = preprocess(webcamElement, 640, 640);

// 2. 执行模型推理
const feeds = { [session.inputNames[0]]: inputTensor };
const results = await session.run(feeds);
const output = results[session.outputNames[0]]; // 形状为 [1, 84, 8400]

// 3. 后处理(解析输出张量并运行 NMS 过滤)
const boxes = postprocess(output.data, 0.45, 0.45);

// 4. 清除并重新绘制 Canvas 画面
drawDetections(boxes);

// 5. 计算 FPS 与推理延迟
const endTime = performance.now();
const latency = Math.round(endTime - startTime);
const fps = Math.round(1000 / (endTime - lastFrameTime));
lastFrameTime = endTime;
fpsElement.innerText = `FPS: ${fps} | 推理延迟: ${latency} ms`;

// 循环下一帧
requestAnimationFrame(detectFrame);
}

// ==================== 4. 后处理与 NMS (非极大值抑制) ====================
// YOLOv8/v11 模型的输出是一个 [84, 8400] 的扁平化一维数组。
// 84 行对应:[x_center, y_center, width, height, 80个类别的置信度分值]
// 8400 列对应:生成的全局候选框数量。
function postprocess(outputData, confThreshold, iouThreshold) {
const boxes = [];
const numClasses = 80;
const numCandidates = 8400; // 对应全局锚框数

for (let c = 0; c < numCandidates; c++) {
// 找出 80 个类别中置信度最高的那一个
let maxScore = 0;
let classId = -1;
for (let i = 0; i < numClasses; i++) {
const score = outputData[(4 + i) * numCandidates + c];
if (score > maxScore) {
maxScore = score;
classId = i;
}
}

// 过滤低置信度框
if (maxScore > confThreshold) {
const cx = outputData[0 * numCandidates + c];
const cy = outputData[1 * numCandidates + c];
const w = outputData[2 * numCandidates + c];
const h = outputData[3 * numCandidates + c];

// 转换为左上角和右下角坐标
const x1 = (cx - w / 2);
const y1 = (cy - h / 2);

boxes.push({
box: [x1, y1, w, h],
score: maxScore,
classId: classId
});
}
}

// 运行 NMS 算法,去除重叠的冗余框
return nms(boxes, iouThreshold);
}

// JavaScript 实现交并比(IoU)算法及 NMS
function nms(boxes, iouThreshold) {
// 按得分高低从大到小排序
boxes.sort((a, b) => b.score - a.score);
const result = [];

while (boxes.length > 0) {
const current = boxes.shift();
result.push(current);

boxes = boxes.filter(box => {
const iou = calculateIoU(current.box, box.box);
return iou < iouThreshold;
});
}
return result;
}

function calculateIoU(box1, box2) {
const [x1, y1, w1, h1] = box1;
const [x2, y2, w2, h2] = box2;

const area1 = w1 * h1;
const area2 = w2 * h2;

const interX1 = Math.max(x1, x2);
const interY1 = Math.max(y1, y2);
const interX2 = Math.min(x1 + w1, x2 + w2);
const interY2 = Math.min(y1 + h1, y2 + h2);

const interW = Math.max(0, interX2 - interX1);
const interH = Math.max(0, interY2 - interY1);
const interArea = interW * interH;

const unionArea = area1 + area2 - interArea;
return unionArea === 0 ? 0 : interArea / unionArea;
}

// ==================== 5. 屏幕绘制渲染 (Canvas Rendering) ====================
function drawDetections(boxes) {
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);

boxes.forEach(det => {
const [x, y, w, h] = det.box;
const score = det.score;
const className = CLASS_NAMES[det.classId];

// 缩放映射:YOLO模型输入是 640x640,这里需要转换回网页中 640x480 的实际显示坐标
const scaleX = canvasElement.width / 640;
const scaleY = canvasElement.height / 640;

const drawX = x * scaleX;
const drawY = y * scaleY;
const drawW = w * scaleX;
const drawH = h * scaleY;

// 绘制发光的半透明警告框
ctx.strokeStyle = "#00ffc8";
ctx.lineWidth = 3;
ctx.shadowBlur = 10;
ctx.shadowColor = "#00ffc8";
ctx.strokeRect(drawX, drawY, drawW, drawH);

// 重置阴影,绘制文字背景
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(0, 255, 200, 0.85)";
const labelText = `${className} (${(score * 100).toFixed(0)}%)`;
ctx.font = "bold 14px Arial";

const textWidth = ctx.measureText(labelText).width;
ctx.fillRect(drawX, drawY - 24, textWidth + 12, 24);

// 绘制标签文本
ctx.fillStyle = "#000000";
ctx.fillText(labelText, drawX + 6, drawY - 7);
});
}

// ==================== 6. 摄像头调用管理 ====================
async function startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480, facingMode: "user" },
audio: false
});
webcamElement.srcObject = stream;

webcamElement.onloadedmetadata = () => {
isRunning = true;
detectFrame();
};

startBtn.innerText = "正在检测中...";
startBtn.disabled = true;
} catch (err) {
alert("摄像头获取失败,请确保当前网页以 localhost 或 HTTPS 方式访问并已授予摄像头访问权限。");
console.error(err);
}
}

startBtn.addEventListener("click", startCamera);

// 自动初始化
window.onload = initApp;
</script>
</body>
</html>

四、核心算法 JS 翻译详解

在 Python 中,model(image) 默默为我们打通了预处理和后处理。但在纯前端环境中,我们必须手动写完这一套底层的算法翻译

1. 将图像打平并重组为 Tensor(预处理)

网页的普通图片是 RGBARGBARGBA… 的扁平结构(HWC 格式,高度-宽度-通道)。而 YOLO 卷积层要求的输入是 1 x 3 x 640 x 640 的 NCHW(批次-通道-高度-宽度)形式。
因此,我们在 preprocess 函数中,需要将每个像素的 RGB 分离出来,依次排布到连续的浮点内存数组中。

2. 重排解析输出张量(后处理)

YOLO 的 ONNX 输出是一个 84 x 8400 的大数组。这 8400 列对应了整张图上所有的检测候选锚框。
我们需要利用双重循环:

  1. 外层循环遍历 8400 个候选锚点。
  2. 内层循环取出该锚点对应的 80 个类别分值,找出分值最高的一项。
  3. 过滤掉低于置信度(如低于 0.45)的无效框,再将剩余的 x, y, w, h 数据转换为便于 Canvas 绘制的左上角坐标系统。

五、Web AI 性能飞跃提速技巧

  1. 开启 WebGL 或 WebGPU 硬件加速
    在初始化 InferenceSession 时,通过传入 'webgl''webgpu'(最新版 chrome 已支持):

    1
    2
    3
    const session = await ort.InferenceSession.create("model.onnx", {
    executionProviders: ['webgpu', 'webgl']
    });

    相较于 CPU 纯 WASM 软解推理,WebGL 和 WebGPU 推理能直接把帧率拉升 5 到 10 倍,使普通轻薄本甚至手机也能流畅跑在 30 FPS 以上。

  2. ONNX 模型的 INT8 动态量化压缩
    原本的 FP32 模型大小为 12MB,这在移动端网页加载时稍显臃肿。我们可以利用 Python 的 onnxruntime.quantization 库将其压缩成 8位整型(INT8):

    1
    2
    3
    4
    5
    6
    7
    from onnxruntime.quantization import quantize_dynamic, QuantType

    quantize_dynamic(
    model_input="yolo11n.onnx",
    model_output="yolo11n_int8.onnx",
    weight_type=QuantType.QUInt8
    )

    量化后,模型体积会从 12MB 暴降至 3.2MB!加载时间提速近 4 倍,且在大浏览器客户端中检测精度几乎没有肉眼可见的损失。


结语

通过 ONNX Runtime Web 和一小段精简的 JavaScript 后处理逻辑,我们成功实现了完全去后端化、0 运维服务器成本的网页实时 AI 目标检测。这种部署方式非常适用于对隐私极其敏感的业务、或者是想将 AI 功能打包嵌入到 React Native/Electron 桌面和移动客户端中的敏捷开发者。

通过这个 YOLO 深度系列文章:

  1. 我们从 model(img) 入门
  2. 走过了 自定义数据集标注与训练
  3. 实现了 YOLO + ByteTrack 实时人车流量双向追踪计数系统
  4. 联动了 国标视频协议(GB28181/JT1078)开发了高吞吐流媒体安防告警抓拍
  5. 最终落地了 纯前端端侧 Web AI 零成本硬件推理

这一套完整的技术版图,不仅能让你的博客建立起绝对的计算机视觉技术壁垒,更能帮助广大开发者直接将 AI 技术用于各种工业与商业生产实践中!