
摄像头打开页面没有跳转画面里的检测框就跟着人走浏览器右下角一个不起眼的标签页承担了全部推理计算。这不是科幻效果就是我把一个 YOLOv5s 模型塞进了浏览器后得到的真实画面。这几年“端侧 AI”很火手机芯片、嵌入式设备、智能摄像头都在谈本地推理而我做的方向稍微偏门一点让一个普通浏览器标签页直接跑视觉神经网络不依赖后端服务器也不需要用户安装任何 App。这个概念用一句话说就是把训练好的模型转换成浏览器能执行的格式然后在 JavaScript 里完成推理摄像头视频流全程留在本地。它能解决的问题很直接隐私视频不上传、延迟省去网络往返、成本不占服务器资源、离线模型文件加载完之后断网也能用。这篇文章我打算用我做过的 YOLOv5 目标检测 Demo 为主线完整复盘模型转换、量化压缩、浏览器推理、摄像头实时识别的全过程把踩过的坑和优化手段一并交代。适合准备做端侧 AI 的前端工程师、对算法落地感兴趣的后端同学以及所有想搞清楚“浏览器里跑神经网络到底可不可行”的人。先说结论可行但远没有网上展示 Demo 那么轻松。如果你只跑一个 MobileNet 级别的分类模型确实很快但如果想做实时视觉检测模型体积、推理速度、内存占用、浏览器兼容性都是硬骨头。接下来我从“为什么值得做”开始一步步拆解这套工程化方案。1. 为什么非要把神经网络塞进浏览器1.1 端侧视觉 AI 不是炫技是实打实的场景需求过去很多视觉识别功能都是“前端拍一张照传到服务器服务器返回结果”这种方法技术成熟但有几个很尴尬的问题。第一个是隐私。摄像头画面一旦离开设备很多用户心里就不舒服。尤其是一些商业场景比如门店客流统计、远程考勤、在线监考用户听到“视频上传”四个字就可能直接拒绝使用。如果推理发生在浏览器本地视频流不出发送最终只把识别结果一个检测框、一个数字传出去隐私压力会小很多。第二个是延迟。服务器推理的耗时由三部分组成网络上行传输时间、服务器排队加推理时间、结果下行时间。就算后端用 GPU一次端到端往返稳定在 50ms 以内也很困难而在浏览器本地跑一个 YOLOv5s单次推理通常在 15ms 到 40ms 之间。对于手势操作、自动售货机这类需要跟手交互的场景差几十毫秒的体感是明显的。第三个是成本。一个视觉模型如果放到云端高并发下 GPU 服务器的账单会非常可观。把推理下沉到用户浏览器相当于把一部分计算成本转嫁给了用户手里的设备企业不需要为每一次识别请求付费。离线也是同样重要的点模型文件通过 CDN 加载一次之后断网状态照样能出检测框这在老旧工业设备、偏远地区巡检场景里很实用。但“塞进浏览器”并不是万能药。浏览器注定不是为重型计算设计的模型不能太大分辨率不能太高低端手机上的体验也会很吃力。所以这套方案适合的不是“所有 AI 需求”而是那些模型体积小、单帧计算量可接受、延迟敏感、强隐私的场景。1.2 到底哪些视觉 AI 场景适合放在浏览器里做我梳理过一批常见视觉任务下面这个表格可以直接作为方案选型参考场景是否适合端侧浏览器部署原因实时目标检测人、车、商品非常适合视频流天然在本地丢几帧也可以接受隐私优势大手势识别、姿态估计非常适合低延迟要求高模型可以压缩到很小人脸关键点检测、美颜滤镜非常适合运算量小部署稳定用户隐私诉求强图片分类、扫码 OCR比较适合单张推理延迟敏感且不需要服务端数据库大规模人脸检索、向量比对不太适合特征库太大浏览器内存装不下超高分辨率图像分割、大模型生成任务目前勉强对显存和浏览器内存要求过高只能做降级方案判断一个模型能不能塞进浏览器我一般只看四个硬指标模型文件是否小于 20MB、单帧计算量是否低于几十 GFLOPs、算子是否能被 onnxruntime-web 或 TensorFlow.js 支持、输出结果是否不需要维护超大字典。如果四个条件都能满足浏览器端执行就是合理的。这个过程像判断一道菜是否适合小厨房做厨房设备有限适合做快菜不适合做流水宴席。2. 技术选型从模型到浏览器有三座桥2.1 模型先得能“住”进浏览器格式与转换用 PyTorch 或 TensorFlow 训练出来的模型不能直接被浏览器读取。目前主流有两个可落地的格式TensorFlow.js 的 Graph Model 和 ONNX 格式。前者配合 TensorFlow.js 运行时使用后者配合 onnxruntime-web 使用。另外还有一个 WebNN 标准但目前浏览器支持面太窄我暂时没有纳入生产方案。我的实际路线是 PyTorch - ONNX - onnxruntime-web。原因有三点一是 PyTorch 导出 ONNX 生态最成熟网上资料多二是 onnxruntime-web 的算子覆盖率比 TensorFlow.js 更稳遇到问题好排查三是 ONNX 格式后续还能在手机端、嵌入式端继续复用同一份模型资产放哪都行。导出模型时有几个细节必须注意。输入尺寸必须固定浏览器端最怕动态维度。如果你导出一个[1, 3, H, W]的模型H 和 W 是动态的最后在 JS 里填数组时会非常麻烦而且 WebAssembly 后端的动态维度支持做得一般。我用的 YOLOv5s 固定为 640×640 输入导出命令大概是这样的import torch from models.experimental import attempt_load model attempt_load(yolov5s.pt, map_locationcpu) model.eval() dummy torch.zeros(1, 3, 640, 640) torch.onnx.export( model, dummy, yolov5s.onnx, input_names[images], output_names[output], dynamic_axesNone, # 关闭动态维度 opset_version12 ) print(export ok)注意opset_version不是越高越好onnxruntime-web 对太新的算子集支持会有滞后。我试过 17 导出的模型在部分浏览器上报“unsupported operator”错误降回 12 就正常了。这是第一个坑。2.2 选对推理后端WebAssembly、WebGL 还是 WebGPUONNX Runtime Web 提供了三种主要执行后端CPU 上的 WebAssembly、GPU 上的 WebGL旧、GPU 上更现代的 WebGPU。这三者各有明确的使用边界。后端运行设备优点缺点WebAssembly (WASM)CPU跨浏览器兼容性最好支持离线包稳定速度相对慢容易被主线程阻塞WebGL2GPU比 WASM 快兼容性尚可纹理转换和回读开销大算子覆盖受限精度有风险WebGPUGPU性能最接近原生算子支持更好目前只覆盖最新 Chrome/EdgeSafari 还在预览我的生产建议很简单默认走 WASM支持 WebGPU 的设备优先切换 WebGPUWebGL 作为中间兼容层。WASM 虽然不算快但做 640×640 的 YOLOv5s 推理还是能在桌面端跑 15 FPS 左右一旦切到 WebGPU同样的模型轻松上 30 FPS。至于 WebGL如果你想把它当万能方案用可能会被纹理格式转换、GPU 回读延迟、部分算子不兼容这几个问题反复折磨。创建 Session 时指定多个执行后端是一种常见容灾写法。onnxruntime-web 会按顺序挑选第一个可用的执行方案const session await ort.InferenceSession.create(/model/yolov5s_int8.onnx, { executionProviders: [webgpu, wasm], // 优先 WebGPU不支持就回退 WASM graphOptimizationLevel: all });这样在普通浏览器上也能跑起来不会因为 GPU 能力不足而直接白屏。2.3 以 YOLOv5s 为例的落地选型清单把整个选型变成可操作清单大概是下面这六步在 PyTorch 里导出固定输入尺寸 640×640 的 ONNX 模型。用 onnxruntime 的量化工具把模型从 FP32 压到 INT8。将量化后模型上传到支持 Range 请求的 CDN 或静态目录。在页面加载时通过fetch读取二进制到 ArrayBuffer。利用 onnxruntime-web 创建 Session指定webgpu优先、wasm兜底。每次摄像头帧到达时先做 resize 和归一化再session.run()拿到输出。在这个过程中TensorFlow.js 不是不能用。我当初也试过把 YOLOv5 转成 TFJS转换工具确实能用但后处理和部分算子在 tfjs 下的坑比 onnxruntime-web 多比如有些模型转换后运算精度降得厉害、自定义 NMS 又要重新实现综合下来不如 ONNX 一条链干净。所以如果你的任务是自定义视觉模型我建议第一选择就是 ONNX Runtime Web。3. 浏览器端视觉 AI 的工程硬骨头3.1 模型尺寸决定生死INT8 量化与剪枝实测浏览器标签页不是本地磁盘模型文件最终要经过网络加载到内存里。一个 500MB 的模型放在服务器上不算什么但要是放到网页里用户在 4G 网络下可能等三分钟才能加载完这产品基本就废了。我的经验是端侧视觉模型的目标体量最好控制在 10MB 以内。以 YOLOv5s 为例原始 FP32 ONNX 模型大约是 28MB直接部署在浏览器里已经偏大了。我做了两件事先做 INT8 动态量化模型体积立刻降到 8MB 左右再结合输入分辨率从 640 降到 416 的配置最终实际运行的模型只有 5.2MB加载时间从 1.5 秒降到 800ms 以内。量化代码相当简单from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( yolov5s.onnx, yolov5s_int8.onnx, weight_typeQuantType.QInt8 )动态量化主要压缩权重激活值仍然用浮点所以精度损失相对可控。我用 COCO 测试集上跑了一遍量化后 mAP 大约下降 2 个百分点在浏览器端这种不用做科研级精度的场景里完全能接受。剪枝是更激进的手段但对普通开发者来说重训练成本太高。我更推荐一个性价比高的思路先量化再把输入分辨率降级。640 分辨率降到 416计算量几乎只剩原来的 40%检测精度对大多数中距离目标几乎没有影响帧率却提升一倍。这比引入依赖关系复杂的剪枝框架要实用得多。3.2 推理速度如何让标签页跑到 30 FPS一个浏览器标签页要实时跑视觉 AI绝不能只关注模型推理本身。我给一个完整的性能拆解模型推理时间在 WebGPU 上占 10ms 到 20ms在 WASM 上占到 30ms 到 60ms。图像预处理时间浏览器里最重的操作是canvas.drawImagegetImageData一次 640×640 的缩放和 RGB 提取可能花 5ms 左右。后处理时间YOLOv5 输出 25200 个候选框如果不做任何优化纯 JS 的 NMS 可能要花 5ms 到 15ms大分辨率下甚至会超过推理时间。所以想要稳定 30 FPS每一步都要抠。我最终采用的方案是推理放到 Web Worker 里避免session.run()阻塞主线程让页面 UI 保持流畅。预处理用 OffscreenCanvas 在 Worker 里完成减少主线程 Canvas 的并发压力。后处理不遍历所有 25200 个候选框先把置信度低于 0.3 的框直接过滤掉再对剩下的做 NMS。输入尺寸从 640 降到 416 或甚至 320看场景决定。降到 320 时候选框数量只有原来的四分之一后处理开销大幅下降。Web Worker 的使用方式大概是这样// main.js const worker new Worker(/worker.js); async function loop() { const imageBitmap await createImageBitmap(video); worker.postMessage({ image: imageBitmap }, [imageBitmap]); } // worker.js self.onmessage async (e) { const input preProcess(e.data.image); const tensor new ort.Tensor(float32, input, [1, 3, 640, 640]); const result await session.run({ images: tensor }); const boxes postProcess(result.output.data); self.postMessage({ boxes }); };这里有个很关键的点createImageBitmap出来的对象可以直接通过 Transferable 转移不需要结构化克隆视频帧数据量再大也不会卡主线程。3.3 内存掉进陷阱一个标签页能吞多少内存浏览器对单个标签页的内存是有限制的尤其 iOS Safari后台标签页内存压力一大就会被系统回收。我在做实时检测时遇到过内存从 100MB 涨到 500MB 的诡异情况排查到最后发现是内存泄漏。第一个元凶是 Session 的输出。YOLOv5 的输出 Tensor 形状是[1, 25200, 85]对应的ArrayBuffer大约 8.5MB。如果你每次推理都新建输出变量而不释放旧引用经过几十帧后内存就会堆积。正确的做法是让result.output.data尽快消费完并让引用置空或者复用同一个输出数组。第二个元凶是预处理阶段的中间数组。getImageData返回一个Uint8ClampedArray如果你再用Array.from()转成普通数组会产生新的拷贝。镜头开着的时候每一帧都多出几 MB 垃圾GC 来不及回收内存在几秒钟内就会涨上去。我最终改成直接拿imageData.data.buffer在原 buffer 上做转换避免一切不必要的拷贝。第三个元凶是 WebGL/WebGPU 上的纹理对象。如果你反复创建输入纹理而不释放旧纹理会在 GPU 显存里堆积。使用 onnxruntime-web 时Tensor最好尽量复用遇到形状不变的情况可以在第一次推理时创建 Tensor后续一直替换底层数据const inputTensor new ort.Tensor(float32, inputBuffer, [1, 3, 640, 640]); // 不要把 inputTensor 丢给 GC留着继续用 await session.run({ images: inputTensor });这一段优化做完我的页面在桌面 Chrome 上稳定在 120MB 左右在移动端 Safari 上控制到了 100MB 以下运行半小时没有出现白屏。4. 实操用一套真实 Demo 把端侧视觉 AI 跑起来4.1 基础页面摄像头 模型 推理循环下面给出一段可以跑通的基础示例代码。页面 UI 只需要一个video元素用于显示摄像头画面一个canvas元素叠在上面画检测框。推理部分我以 onnxruntime-web 为例video idvideo autoplay playsinline muted/video canvas idcanvas/canvas script srchttps://cdn.jsdelivr.net/npm/onnxruntime-web1.17.1/dist/ort.min.js/scriptconst video document.getElementById(video); const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const session await ort.InferenceSession.create(/yolov5s_int8.onnx, { executionProviders: [wasm], graphOptimizationLevel: all }); // 启动摄像头 navigator.mediaDevices.getUserMedia({ video: true }) .then(stream { video.srcObject stream; }) .catch(err console.error(摄像头权限失败, err)); function preProcess(source) { const S 640; const tmpCanvas new OffscreenCanvas(S, S); const tmpCtx tmpCanvas.getContext(2d, { willReadFrequently: true }); tmpCtx.drawImage(source, 0, 0, S, S); const imageData tmpCtx.getImageData(0, 0, S, S); const rgb new Float32Array(3 * S * S); const data imageData.data; for (let i 0, j 0; i data.length; i 4, j 3) { rgb[j] data[i] / 255.0; rgb[j 1] data[i 1] / 255.0; rgb[j 2] data[i 2] / 255.0; } return rgb; } async function detect() { if (video.readyState 2) { requestAnimationFrame(detect); return; } const input preProcess(video); const tensor new ort.Tensor(float32, input, [1, 3, 640, 640]); const result await session.run({ images: tensor }); const boxes postProcess(result.output.data); drawBoxes(boxes); requestAnimationFrame(detect); } function drawBoxes(boxes) { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const b of boxes) { ctx.strokeStyle #00ff00; ctx.lineWidth 2; ctx.strokeRect(b.x, b.y, b.w, b.h); ctx.fillText(b.label, b.x, b.y - 5); } } requestAnimationFrame(detect);注意我在getContext(2d, ...)里加了willReadFrequently: true。这个参数可以减少getImageData调用的性能惩罚很多人不知道。没加之前同一份 resize 代码在电脑上要花 8ms加上后降到 4ms 左右效果非常明显。4.2 后处理是前端最容易被忽视的瓶颈YOLOv5 的原始输出是一个[1, 25200, 85]的浮点张量含义是 640×640 输入下预测了 25200 个锚框每个框有 85 个数值中心点坐标、宽高、置信度、80 个类别的分数。如果直接对这个全量输出做 NMS在 JS 里会很慢因为 NMS 需要对所有可用的框做排序和两两比较。我的做法是先按置信度过滤一次score 0.3的框才进入候选数组然后按置信度倒序排序最后做循环消重。如果输入尺寸降到 320候选框数量从 25200 降到 6300后处理时间可以压到 2ms 甚至更低。这个优化比你在模型推理侧抠几毫秒要划算得多。function postProcess(outputs) { const numClasses 80; const boxes []; const rows outputs.length / 85; for (let r 0; r rows; r) { const offset r * 85; const objScore outputs[offset 4]; if (objScore 0.3) continue; let maxCls 0, maxScore 0; for (let c 0; c numClasses; c) { const s outputs[offset 5 c]; if (s maxScore) { maxScore s; maxCls c; } } const score objScore * maxScore; if (score 0.3) continue; const x outputs[offset] - outputs[offset 2] / 2; const y outputs[offset 1] - outputs[offset 3] / 2; const w outputs[offset 2]; const h outputs[offset 3]; boxes.push({ x, y, w, h, score, cls: maxCls }); } return nms(boxes, 0.5); }这个函数虽然简单但在大分辨率输入下也要跑几毫秒。更复杂一点的方案是通过 ONNX 自定义算子把 NMS 做到模型内部工程量大不小我个人建议在项目初期先用纯 JS 版凑合。4.3 实测结果桌面端、移动端和 Safari我把我做过的测试结果汇总了一下模型统一是 INT8 量化的 YOLOv5s输入 640×640后端优先 WebGPU。设备浏览器执行后端加载时间平均 FPS内存占用MacBook Pro M1Chrome 126WebGPU0.8s28120MB普通 Win10 台式机Chrome 126WASM1.2s15150MBiPhone 14Safari 17WASM2.8s8100MB安卓骁龙 888Chrome 126WASM2.0s12140MB从这个表能看到几个规律WebGPU 对桌面端 FPS 提升非常大iPhone Safari 因为 WebGPU 没开放只能用 WASMFPS 只有 8对于实时检测来说有点勉强安卓手机的 CPU 算力也明显弱于桌面端。所以如果你要做移动端优先的产品要么换更轻的模型比如 NanoDet、MobileNet SSD要么把输入分辨率降到 320。不要指望一个在桌面电脑上跑 30 FPS 的模型到了手机上也能 30 FPS这里面差距就是硬件算力决定的。5. 常见问题排查与避坑速查5.1 模型加载慢用 Cache API 和 Service Worker 预缓存很多网站在首次加载模型时会让用户等很久。除了把模型压缩到 5MB 以内另一个有效方案是提前用 Cache API 把模型文件缓存下来。// 在页面主线程预请求模型 const cache await caches.open(ai-model-v1); const cached await cache.match(/yolov5s_int8.onnx); if (!cached) { await cache.add(/yolov5s_int8.onnx); }第二次打开页面时模型可以直接从本地缓存加载省掉网络请求时间。如果配合 Service Worker 做预缓存和版本管理体验会更好。一个要注意的坑模型文件如果更新了版本记得更换缓存 key比如ai-model-v2否则浏览器会一直用旧模型。5.2 首次推理卡几秒给浏览器一次“热身”无论 WASM 还是 WebGPU第一次调用session.run时都需要做大量初始化WASM 要编译和实例化WebGPU 要编译相关的 shader。这个时间有时候能达到 2 秒以上在实时摄像头场景里表现为——视频画面出来了检测框却迟迟不出现。解决办法是预热推理。模型加载完成后先用一个全零的小输入跑一次推理再开始正式的摄像头循环。预热阶段产生的输出不要分配大数组简单消费掉即可。我一般放在页面加载完成后立刻执行const warmupTensor new ort.Tensor(float32, new Float32Array(1 * 3 * 64 * 64), [1, 3, 64, 64]); await session.run({ images: warmupTensor });64×64 的输入足够触发算子编译但耗时比 640×640 小得多。预热之后第一帧正式推理的延迟会明显降低。5.3 兼容性Safari 和 WebGL 的坑Safari 目前依然不支持 WebGPU所以在 iPhone 上所有 GPU 加速路径都走不通。如果你使用了executionProviders: [webgpu]Safari 会直接报错必须检测navigator.gpu是否存在再决定执行后端const providers []; if (navigator.gpu) providers.push(webgpu); providers.push(wasm); const session await ort.InferenceSession.create(/model.onnx, { executionProviders: providers });另一个坑是 iOS Safari 对标签页内存限制比较苛刻。一个页面如果长期占 150MB 以上短暂退到后台再回来时标签页可能已经被回收进入浏览器后是白屏状态。我的建议是在移动端尽量使用 WASM 低分辨率内存控制在 100MB 以内并在页面重新可见时做一次状态恢复。5.4 数据同步Transferable 别被结构化克隆坑了如果你把预处理放在主线程推理放在 Worker最自然的写法是worker.postMessage(arr)。但如果arr是普通数组postMessage 会对它做结构化克隆数据越大越慢而且会暂时卡住主线程。正确做法是使用 Transferable把ArrayBuffer的所有权转移给 Worker// 主线程 const buffer imageData.data.buffer; worker.postMessage({ buffer }, [buffer]);转移之后主线程不能再访问这个 buffer但每一帧你都从imageData里拿到一个新的 buffer所以不影响。这个细节不处理好实时视频流会经常出现卡顿甚至掉帧。5.5 两个容易被忽视的隐藏坑第一个是算子不支持。模型能导出不代表浏览器能推理。有些 PyTorch 操作导出成 ONNX 后在 onnxruntime-web 上并没有对应 kernel最常见的比如NonMaxSuppression、某些高级ScatterND组合。遇到这种情况要么在后端用 JS 重新实现要么改模型结构避开这些操作。建议在选型阶段就把模型跑一次ort.env.wasm的浏览器导出尽早发现算子问题不要等整个流程写完再调试。第二个是模型更新导致的缓存雪崩。如果你把模型文件放到 CDN但 CDN 的 HTTP 缓存策略不合理用户更新页面时还会加载旧模型。我建议模型文件名带上 hash比如yolov5s_int8_v2.onnx新版本发布后文件名变化CDN 自然回源同时配合 Cache API 版本管理确保预缓存不会永远卡在旧版本。最后分享一个小技巧前面讲了不少工程细节最后说一个我个人认为最容易被忽视也最实用的习惯在写正式代码之前先用性能预算把值算出来。模型文件多大、目标设备什么 GPU、需要跑多少帧、内存上限多少这四个数字先写死在文档里然后每一步优化都对照这个预算来。比如你目标是 30 FPS单帧总时间就必须控制在 33ms 以内推理如果占 20ms留给前后处理的时间就只剩 13ms那你自然知道该把输入分辨率降到多少、后处理要优化到什么程度。我做完这个 Demo 以后最大的体会是把神经网络塞进一个浏览器标签页听起来很酷但本质上是工程妥协。模型要拆、精度要降、内存要抠、兼容性要铺路每一项都是细节。如果下一次你要做类似项目别急着写代码先找一张纸写下上面四个数字。这个习惯能帮你省掉至少一周的调试时间。