☰
浏览器端跑YOLOv5s:模型量化与实时目标检测实战
2026/10/4 16:26:29 网站建设 项目流程

摄像头打开,页面没有跳转,画面里的检测框就跟着人走,浏览器右下角一个不起眼的标签页承担了全部推理计算。这不是科幻效果,就是我把一个 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_location='cpu') model.eval() dummy = torch.zeros(1, 3, 640, 640) torch.onnx.export( model, dummy, 'yolov5s.onnx', input_names=['images'], output_names=['output'], dynamic_axes=None, # 关闭动态维度 opset_version=12 ) print('export ok')

注意opset_version不是越高越好,onnxruntime-web 对太新的算子集支持会有滞后。我试过 17 导出的模型在部分浏览器上报“unsupported operator”错误,降回 12 就正常了。这是第一个坑。

2.2 选对推理后端:WebAssembly、WebGL 还是 WebGPU

ONNX Runtime Web 提供了三种主要执行后端:CPU 上的 WebAssembly、GPU 上的 WebGL(旧)、GPU 上更现代的 WebGPU。这三者各有明确的使用边界。

后端运行设备优点缺点
WebAssembly (WASM)CPU跨浏览器兼容性最好,支持离线包,稳定速度相对慢,容易被主线程阻塞
WebGL2GPU比 WASM 快,兼容性尚可纹理转换和回读开销大,算子覆盖受限,精度有风险
WebGPUGPU性能最接近原生,算子支持更好目前只覆盖最新 Chrome/Edge,Safari 还在预览

我的生产建议很简单:默认走 WASM,支持 WebGPU 的设备优先切换 WebGPU,WebGL 作为中间兼容层。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 为例的落地选型清单

把整个选型变成可操作清单,大概是下面这六步:

  1. 在 PyTorch 里导出固定输入尺寸 640×640 的 ONNX 模型。
  2. 用 onnxruntime 的量化工具把模型从 FP32 压到 INT8。
  3. 将量化后模型上传到支持 Range 请求的 CDN 或静态目录。
  4. 在页面加载时通过fetch读取二进制到 ArrayBuffer。
  5. 利用 onnxruntime-web 创建 Session,指定webgpu优先、wasm兜底。
  6. 每次摄像头帧到达时,先做 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_type=QuantType.QInt8 )

动态量化主要压缩权重,激活值仍然用浮点,所以精度损失相对可控。我用 COCO 测试集上跑了一遍,量化后 mAP 大约下降 2 个百分点,在浏览器端这种不用做科研级精度的场景里完全能接受。

剪枝是更激进的手段,但对普通开发者来说,重训练成本太高。我更推荐一个性价比高的思路:先量化,再把输入分辨率降级。640 分辨率降到 416,计算量几乎只剩原来的 40%,检测精度对大多数中距离目标几乎没有影响,帧率却提升一倍。这比引入依赖关系复杂的剪枝框架要实用得多。

3.2 推理速度:如何让标签页跑到 30 FPS

一个浏览器标签页要实时跑视觉 AI,绝不能只关注模型推理本身。我给一个完整的性能拆解:

  • 模型推理时间:在 WebGPU 上占 10ms 到 20ms,在 WASM 上占到 30ms 到 60ms。
  • 图像预处理时间:浏览器里最重的操作是canvas.drawImage+getImageData,一次 640×640 的缩放和 RGB 提取可能花 5ms 左右。
  • 后处理时间:YOLOv5 输出 25200 个候选框,如果不做任何优化,纯 JS 的 NMS 可能要花 5ms 到 15ms,大分辨率下甚至会超过推理时间。

所以想要稳定 30 FPS,每一步都要抠。我最终采用的方案是:

  1. 推理放到 Web Worker 里,避免session.run()阻塞主线程,让页面 UI 保持流畅。
  2. 预处理用 OffscreenCanvas 在 Worker 里完成,减少主线程 Canvas 的并发压力。
  3. 后处理不遍历所有 25200 个候选框,先把置信度低于 0.3 的框直接过滤掉,再对剩下的做 NMS。
  4. 输入尺寸从 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 id="video" autoplay playsinline muted></video> <canvas id="canvas"></canvas> <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.1/dist/ort.min.js"></script>
const 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.2s15150MB
iPhone 14Safari 17WASM2.8s8100MB
安卓骁龙 888Chrome 126WASM2.0s12140MB

从这个表能看到几个规律:WebGPU 对桌面端 FPS 提升非常大;iPhone Safari 因为 WebGPU 没开放,只能用 WASM,FPS 只有 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 以后最大的体会是:把神经网络塞进一个浏览器标签页,听起来很酷,但本质上是工程妥协。模型要拆、精度要降、内存要抠、兼容性要铺路,每一项都是细节。如果下一次你要做类似项目,别急着写代码,先找一张纸写下上面四个数字。这个习惯,能帮你省掉至少一周的调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询