news 2026/9/4 1:02:31

浏览器端YOLOv5实时检测:PyTorch到TF.js的完整转换指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
浏览器端YOLOv5实时检测:PyTorch到TF.js的完整转换指南

简介:YOLOv5与TensorFlow.js的整合示例包,面向希望在Web端实现实时目标检测的开发者,解决模型部署到浏览器或Node.js环境时的跨平台集成问题。资源共29个文件,整体仅88KB,结构精炼:前端以HTML、CSS、JS页面资源为主,后端采用Python脚本,辅助sh安装部署脚本及cfg、yaml配置文件,方便在不同框架下快速迁移。目前已有289人学习关注,适合具备一定YOLO基础、希望延伸至Web端推理或轻量级部署的开发者。压缩包内提供了基于FastAPI、Flask、Bottle等多种后端框架的接入示例,并包含HLS流式处理相关前端静态资源;同时整理了gitignore、license、CodeCheck等工程规范文件,便于项目维护和合规使用。借助这套资源,可以减少环境配置和前后端联调中的重复劳动,快速搭建可运行的浏览器端YOLOv5检测应用。 最近有个需求反复被问到:训练好的YOLOv5模型,能不能不部署到服务器,直接在浏览器里跑实时检测?一开始我觉得这需求有点奇怪,毕竟常规路线都是走API调用,但实际落地场景其实挺多的——比如内部工具链里的摄像头质检演示、纯前端的数据标注辅助、甚至一些需要隐私保护的本地化识别场景,浏览器端推理确实能省掉一大截服务器带宽和中间链路。所以就有了这个YOLOv5-RT-TFJS项目:把PyTorch训练好的YOLOv5权重转换到TensorFlow.js格式,在浏览器里用WebGL后端做实时目标检测。这篇文章就把整个链路的完整步骤、转换踩坑、前后处理细节和实测性能一次说清楚。

1. 为什么非要折腾到浏览器里跑:场景驱动与技术选型

1.1 省掉服务器的诱惑与代价

浏览器端跑推理最大的好处有两点:零部署成本、零传输延迟。模型权重分片加载到本地后,图像数据不需要上传到服务器,摄像头画面直接在浏览器内存里完成推理,整个耗时只有模型推理时间加一点点前后处理时间,没有任何网络抖动。对Demo演示、快速原型验证这类场景来说,体验非常好。

代价也很明显:浏览器能用的算力是受限的。WebGL后端虽然能用GPU,但抽象层比CUDA厚重,运行效率跟原生PyTorch比有差距。所以这个方案适合轻量级模型,比如YOLOv5s、YOLOv5n级别,跑YOLOv5m都开始吃力。如果目标是YOLOv5l甚至x,那我建议死了这条心,老老实实上后端服务。

1.2 为什么选TensorFlow.js而不是ONNX WebAssembly

有些同学会问,PyTorch模型不是可以直接装onnxruntime-web跑吗?这条路也通,但实际体验下来有几个问题:

  • onnxruntime-web对WebGL的算子覆盖不如TensorFlow.js全面,YOLOv5导出ONNX后有些自定义算子需要额外处理。
  • TensorFlow.js提供了tf.browser.fromPixels这类专门优化过的浏览器API,视频帧转Tensor的效率更高。
  • TFJS的executeAsync支持多输出一次性拉取,YOLOv5的三个检测头输出刚好能一起拿回来,减少JS-WebGL边界的数据拷贝。

所以我选择的核心链路是:PyTorch权重 → ONNX → TensorFlow SavedModel → TF.js,这条链路在github上已经有很多成功案例,踩坑成本相对低。

1.3 项目最终呈现的效果

输入可以是<video>标签接摄像头,也可以是图片上传。视频流模式用requestAnimationFrame驱动抽帧,每帧resize到640x640,推理得到三个尺度的特征图,前端解码加NMS,最后用canvas把检测框画出来。选配项包括置信度阈值、IOU阈值、类别名导入。整个项目依赖全部在npm里,打包后可以丢到任意静态服务器。

2. 模型转换链路:从PyTorch权重到TF.js的全流程

2.1 转换环境准备

转换过程最好在Linux或macOS环境下做,Windows也能跑,但onnx转tf时偶尔会遇到路径编码问题。建议单独建一个Python虚拟环境,避免污染已有的深度学习环境:

python -m venv yolov5_tfjs_env source yolov5_tfjs_env/bin/activate pip install torch torchvision onnx onnx2tf tensorflow tensorflowjs

注意版本坑:tensorflowtensorflowjs的版本要匹配。我这里用的是tensorflow==2.13.1搭配tensorflowjs==4.15.0,实测稳定。onxx2tf默认走TF 2.x的SavedModel导出,版本太新反而可能出现算子兼容性问题。

2.2 第一步:PyTorch转ONNX

如果你用的是ultralytics官方YOLOv5仓库,导出命令很简单:

git clone https://github.com/ultralytics/yolov5 cd yolov5 python export.py --weights yolov5s.pt --include onnx --img 640

这里关键是--img 640,它决定了模型输入分辨率。浏览器端处理时,输入Tensor必须是这个尺寸。如果你的使用场景分辨率不固定,想要动态尺寸,可以加--dynamic参数,但TFJS的WebGL后端对动态shape支持不太好,强烈建议固定尺寸导出。我一般固定640,在浏览器端做letterbox处理。

导出完成后,用onnx.checker验证一下模型完整性:

import onnx model = onnx.load("yolov5s.onnx") onnx.checker.check_model(model) print("ONNX模型检查通过")

2.3 第二步:ONNX转TensorFlow SavedModel

这里推荐onnx2tf这个工具,它比老牌的onnx-tf更新,对ONNX算子覆盖更全,而且会自动处理一些TF不支持的算子:

onnx2tf -i yolov5s.onnx -o yolov5s_savedmodel

执行完会在yolov5s_savedmodel目录下生成saved_model.pbvariables目录。这里有个小细节:onnx2tf默认会把输出节点的名字改成类似IdentityIdentity_1这样的名称,后面在TFJS里executeAsync取输出时要靠索引而不是名字,所以不用太在意输出节点名称。

2.4 第三步:SavedModel转TF.js格式

最后一步用tensorflowjs官方转换器:

tensorflowjs_converter \ --input_format=tf_saved_model \ --output_format=tfjs_graph_model \ --signature_name=serving_default \ yolov5s_savedmodel \ yolov5s_tfjs

转换完成后,yolov5s_tfjs目录下会出现model.json和一组group1-shard1ofN.bin权重分片文件。这里的坑是,默认权重分片可能比较大,浏览器加载时如果服务器不支持Range请求,分片加载会失败。一般在Web服务器配置里确认支持Accept-Ranges: bytes即可,或者用更小的分片重新导出:

tensorflowjs_converter ... --weight_shard_size_bytes 4194304

这样每个分片4MB,加载更稳。

2.5 实测踩坑:OpSet版本与算子兼容

我在转换过程中遇到最烦的问题就是ONNX的OpSet版本。YOLOv5官方导出默认用的是OpSet 12或17,如果onnx2tf对应的TF版本解析不了某些算子(尤其是ResizeMul的组合),会直接报错。解决方式是导出ONNX时手动指定OpSet:

python export.py --weights yolov5s.pt --include onnx --opset 12

实测OpSet 12搭配onnx2tf成功概率最高。另外,如果模型训练时替换了激活函数(比如用了SiLU的变体),转换时也可能出问题,这种情况建议先转成标准的ReLU激活再导出,虽然会损失一点精度,但转换通畅。

3. 浏览器端推理管线搭建:加载TF.js模型与视频流处理

3.1 TF.js运行时配置

前端项目直接用npm安装依赖:

npm install @tensorflow/tfjs @tensorflow/tfjs-backend-webgl

初始化时强制指定WebGL后端:

import * as tf from '@tensorflow/tfjs'; import '@tensorflow/tfjs-backend-webgl'; await tf.setBackend('webgl'); await tf.ready();

这里有个容易犯的错误:如果同时引入了@tensorflow/tfjs-node或者本地后端的包,初始化时可能自动选择CPU后端,导致推理速度慢好几倍。调试时可以通过tf.getBackend()确认当前后端。

3.2 加载图模型与首次预热

图模型的加载方式和LayersModel不同,必须用tf.loadGraphModel

const model = await tf.loadGraphModel('/models/yolov5s_tfjs/model.json');

加载完毕后建议做一次预热推理,因为WebGL着色器是首次用到时才编译,预热能避免第一帧卡顿:

const dummy = tf.zeros([1, 640, 640, 3], 'int32'); await model.executeAsync(dummy); dummy.dispose();

注意这里用int32类型模拟输入像素值。YOLOv5导出的模型如果NCHW还是NHWC布局写死在图里,输入类型也会不同,如果你发现模型报错,改成tf.zeros([1, 3, 640, 640], 'float32')试一下。我们导出时通过onnx2tf--output_signaturedefs参数强制NHWC输出,让浏览器端处理更顺畅。

3.3 视频帧捕获与预处理

用原生navigator.mediaDevices.getUserMedia拿摄像头流,然后把<video>元素挂到页面上隐藏起来,每帧用requestAnimationFrame抽取。核心预处理函数:

function preprocess(video, targetSize = 640) { const tensor = tf.browser.fromPixels(video) .resizeBilinear([targetSize, targetSize]) .expandDims(0) .div(255.0); return tensor; }

tf.browser.fromPixels是把HTMLVideoElement直接转成Tensor的内置API,内部走的是WebGL纹理拷贝,效率远高于先画到canvas再getImageDataresizeBilinear这一步可以换成resizeNearestNeighbor,实测对检测精度影响不大但速度略快,看你的场景取舍。

3.4 executeAsync与输出张量管理

YOLOv5导出后有三个输出,对应三个检测头。使用executeAsync一次性取回全部输出:

const input = preprocess(video); const outputs = await model.executeAsync(input); // outputs是数组 // outputs[0]: shape [1, 20, 20, 255] // outputs[1]: shape [1, 40, 40, 255] // outputs[2]: shape [1, 80, 80, 255]

以COCO数据集为例,类别数80,每个网格位置预测3个anchor,所以通道数是(5 + 80) * 3 = 255。如果你的模型类别数是别的值,比如只检测几个类别,这个数字会变。判断通道数的公式别记错:(5 + numClasses) * numAnchors

executeAsync返回的Tensor用完必须手动dispose(),否则会内存泄漏。在每帧处理函数末尾统一清理:

input.dispose(); outputs.forEach(t => t.dispose());

4. 后处理细节:anchors、解码公式与NMS实战

4.1 为什么导出结构里没有NMS

很多人在转换前会问,YOLOv5官方导出时不是有--include nms参数吗?加上这个确实会生成端到端带NMS的模型,输出直接是检测结果。但实测在TFJS里跑这种模型有两个问题:一是图里的NMS算子是用TF的自定义操作实现的,WebGL后端支持不稳定;二是在浏览器端做NMS其实很灵活,阈值调起来方便。所以我选择导出纯净的raw模型,把NMS留在JavaScript里做。

4.2 解码公式与anchors配置

YOLOv5使用的anchor是预设在模型里的,与训练配置一致。YOLOv5s在640分辨率下三类anchor:

const ANCHORS = [ [[10, 13], [16, 30], [33, 23]], // 对应80x80特征图 [[30, 61], [62, 45], [59, 119]], // 对应40x40特征图 [[116, 90], [156, 198], [373, 326]] // 对应20x20特征图 ]; const STRIDES = [8, 16, 32];

特征图尺寸和stride是成反比的:输入640,stride 8对应80x80,stride 16对应40x40,stride 32对应20x20。这个匹配关系就是anchor配比的核心逻辑。

解码的核心公式(每个网格位置、每个anchor):

const cellX = (Math.sigmoid(xy[0]) * 2 - 0.5 + gridX) * stride; const cellY = (Math.sigmoid(xy[1]) * 2 - 0.5 + gridY) * stride; const boxW = Math.pow(Math.sigmoid(wh[0]) * 2, 2) * anchorW; const boxH = Math.pow(Math.sigmoid(wh[1]) * 2, 2) * anchorH; const objScore = Math.sigmoid(obj); const classScores = classLogits.map(logit => Math.sigmoid(logit) * objScore);

公式里的乘2减0.5和乘2平方,是YOLOv5在v6.0之后引入的解码修正,让中心点偏移范围从0到1变成-0.5到1.5,能提高收敛速度。如果碰到老版本模型(v5.0之前的权重),解码公式不同,中心点是sigmoid加网格偏移,宽高是exp乘以anchor,使用前要确认你的权重版本。

4.3 Tensor操作还是循环操作

这里有一个性能分水岭:直接在Tensor层面做解码,利用WebGL并行计算,速度很快但代码复杂;用数组循环做解码,代码直观但纯JS单线程跑起来很慢。实测在60fps的摄像头流上,每帧有(20*20 + 40*40 + 80*80) * 3 = 25200个候选框,纯JS循环解码每帧耗时约30ms,基本就要丢帧了。

我的做法是先用Tensor操作做一次简化筛选,只保留置信度高于阈值的网格索引,再对这些索引用普通JS数组做精确解码。这样能把进入循环的候选框数量从25200降到几百个,解码耗时控制在2ms以内。具体流程:

// 1. 先取置信度最大值和对应的类别索引,降低维度 const [maxScores, maxIndices] = tf.topk(classLogits, 1); // 2. 再和objScore相乘,过滤低于阈值的 const mask = tf.greater(maxScores, minConf); const validIndices = tf.where(mask).dataSync(); // 3. 后面只对validIndices对应的网格做JS解码

4.4 NMS实现要点与自适应滤波

NMS我用的是tf.image.nonMaxSuppression封装好的TensorFlow.js函数,没必要自己造轮子:

const selected = await tf.image.nonMaxSuppression( boxes, // shape [numBoxes, 4] scores, // shape [numBoxes] maxOutputSize, // 最大输出框数 iouThreshold, scoreThreshold );

但这里有个大坑:nonMaxSuppression在WebGL后端底层是走CPU实现的,如果输入候选框超过几千个,反而会出现明显卡顿。所以一定要在NMS之前先用minConf阈值过滤掉低置信度的框。这个过滤逻辑和前一步的topk筛选是配合使用的。

NMS的IOU阈值我的经验值是0.45到0.5之间。目标重叠多就选0.4,目标稀疏就选0.5。置信度阈值则看场景,演示场景我习惯0.25,因为要容忍更多误检来展示识别能力;实际工程落地建议0.4起步,尤其是误检代价高的场景。

4.5 从模型坐标到画布坐标

解码得到的框坐标是相对于640x640输入图的。如果实际画面不是正方形,我处理的方法是:先按照宽高比把原始视频帧缩放到640x640的letterbox尺寸,四周用灰边填充,解码后的坐标需要做一次反向映射,去掉灰边,还原到原始视频坐标。

function letterboxReverse(box, originalW, originalH, targetSize = 640) { const scale = Math.min(targetSize / originalW, targetSize / originalH); const resizedW = originalW * scale; const resizedH = originalH * scale; const offsetX = (targetSize - resizedW) / 2; const offsetY = (targetSize - resizedH) / 2; return [ (box[0] - offsetX) / scale, (box[1] - offsetY) / scale, (box[2] - offsetX) / scale, (box[3] - offsetY) / scale ]; }

如果直接用resizeBilinear把非正方形画面拉伸到640x640,虽然省事,但检测框的宽高比会变形,后面画框时还要再做一次等比缩放,反而更容易出问题。

5. 实时性能优化与踩坑记录

5.1 前端实测数据:GPU切换与帧率表现

在我测试的几台设备上,使用WebGL后端的效果如下:

设备GPU推理耗时(ms)帧率(FPS)备注
MacBook Pro M1Apple M1 GPU18-25约30稳定,偶尔波动
ThinkPad X1 CarbonIntel Iris Xe30-40约20发热后掉帧严重
台式机RTX 3070NVIDIA CUDA28-35约25浏览器端吃显卡驱动优化
安卓旗舰手机Adreno 66035-50约15作为参考

注意RTX 3070的推理耗时没有比核显快多少,原因是WebGL后端在NVIDIA驱动上走的是GLSL着色器,不是WebGPU路线,计算密度上不去。想追求极致性能,可以关注WebGPU后端,但目前还不太成熟。

5.2 每帧推理的pipeline化

浏览器端实时推理最怕的是主线程被阻塞。我调整代码结构时把几个操作做成了同步链:

async function detectFrame() { if (isProcessing) return; // 防止上一帧还没结束就处理下一帧 isProcessing = true; const input = preprocess(video); const outputs = await model.executeAsync(input); const boxes = decodeOutputs(outputs); const filtered = nmsFilter(boxes); drawBoxes(filtered); input.dispose(); outputs.forEach(t => t.dispose()); isProcessing = false; } function loop() { requestAnimationFrame(loop); detectFrame(); }

isProcessing标志位是关键,否则每次requestAnimationFrame都会触发新一轮推理,GPU负载会线性增长,帧率反而崩溃。实际测试中每帧间隔保持在50ms左右,也就是20FPS,展示效果完全够用,还能留出余量给其他前端逻辑。

5.3 稳定性专项:内存泄漏排查

浏览器端跑推理最阴间的bug就是内存泄漏。跑了几分钟后页面卡死,Chrome的Performance面板显示GPU内存持续上升。排查后发现两个问题:

一是tf.browser.fromPixels返回的Tensor如果不及时dispose(),WebGL纹理内存不会立即释放。我在每帧末尾统一处理所有Tensor后,内存曲线平稳了。

二是executeAsync输出的Tensor数组里,有些中间Tensor(例如topk产生的索引Tensor)虽然不在最终输出里,但如果没有用tf.tidy()包裹,同样会泄漏。我的做法是外层包一个tf.tidy(),让所有中间Tensor自动回收:

const results = await tf.tidy(() => { const input = preprocess(video); return model.executeAsync(input); });

还有个小技巧:如果使用了dataSync()(比如NMS前要取置信度数组),这个方法会阻塞主线程并把数据从GPU拷贝回CPU,如果每一帧都调用就会明显卡顿。优化方案是只在置信度阈值过滤的候选框数量变化时才更新数据,或者干脆用await tensor.data()这个异步方案,释放主线程。

5.4 模型尺寸与加载速度的取舍

yolov5s.pt大约28MB,导出成TFJS后,权重分片总大小大约在15MB左右,网络加载速度影响明显。如果你的场景对内网加载速度敏感,有两个优化方向:

一是改用yolov5n模型,TFJS权重只有6-8MB,加载快,推理速度也快,代价是mAP从s版的37.4掉到28.0左右(COCO数据集),小目标检测能力下降明显。适合摄像头距离近、目标大的场景。

二是打开gzip压缩。TFJS权重分片是二进制格式,压缩率很高,实测gzip后能压缩到原来的一半左右。确保nginx或任意静态服务器对.bin.json启用了gzip_staticbrotli

5.5 兼容性自查与常见白屏问题

浏览器端项目最怕的就是白屏,而且往往控制台都没报错。我遇到的几个典型情况:

  • 模型加载时fetch失败,检查模型路径是否被构建工具(Vite/Webpack)正确处理。我在Vite项目里直接把模型目录放到了public目录,而不是通过import引入。
  • WebGL上下文丢失。长页面里如果canvas标签过多,GPU上下文会被浏览器回收。给canvas单独设置尺寸而不是继承默认,并且避免在隐藏页面里继续跑推理。
  • 跨域问题:不要把模型放在CDN上又用不同域名访问,TFJS的fetch不支持跨域就没法加载。最好和页面同源。

6. 一个基于经验的自检清单与扩展思路

最后分享一个我每次调试这类项目都会过的自检清单,能省掉大量定位问题的时间:

  1. 模型输入尺寸:检查model.json里的input shape是不是[1, 640, 640, 3],如果变成[1, 3, 640, 640]说明转换时没有NHWC转换成功。
  2. 输出通道数:三个head的输出最后一个维度是不是(5+类别数)*3,如果对不上,大概率是导出时类别数与训练时不一致。
  3. 置信度阈值过滤:在NMS前必须过滤,否则候选框数量太多,CPU端NMS会成为瓶颈。
  4. 内存曲线:跑10分钟看Chrome的内存曲线是不是平稳的,有任何单调上涨都要怀疑Tensor没释放。

这个项目的后续扩展思路其实很多。一个是把模型切分成不同尺寸的版本,根据用户设备的WebGL能力动态选择加载对应版本;另一个是尝试WebGPU后端,推理速度有翻倍空间;还有一个是把标注结果直接回传训练集,形成浏览器端标注-训练-部署的闭环。我自己下一步打算测试一下加了TinyTrack结构蒸馏出的YOLOv5n在实际浏览器端的表现,到时候再开一篇单独聊。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 9:39:03

B 站更新|软考系统架构师第七章全集上线|安全架构设计理论与实践

各位备考软考系统架构设计师的小伙伴&#xff0c;大家好&#xff01; 继第六章信息安全技术基础知识完结之后&#xff0c;第七章「安全架构设计理论与实践」全套 4 集视频现已在 B 站全部更新完成。 本章属于考试高频重点章节&#xff0c;模型多、概念辨析题密集。包含主动 /…

作者头像 李华
网站建设 2026/9/4 9:38:27

深耕工业 AI 视觉|合米科技,全栈自研 AI SOP 视觉防错解决方案。

摘要在制造业提质降本的浪潮下&#xff0c;产线人为错装、漏装、SOP 执行不到位&#xff0c;依旧是众多工厂难以根除的质量顽疾。市面上不少视觉方案多为组装集成模式&#xff0c;硬件外购、算法外包、平台二次开发&#xff0c;软硬件适配断层&#xff0c;现场调试难、迭代慢&a…

作者头像 李华
网站建设 2026/9/2 19:53:45

从晶体管到H桥:嵌入式驱动电路设计核心与实战避坑指南

在嵌入式开发和硬件项目中&#xff0c;我们常常需要控制比单片机GPIO输出能力大得多的负载&#xff0c;比如电机、继电器、LED灯带或大功率加热器。直接连接往往导致单片机损坏或负载无法正常工作。这时&#xff0c;驱动电路就成为了连接“大脑”&#xff08;控制信号&#xff…

作者头像 李华