首页 / 资讯中心 / 文章详情

YOLOv5实时检测上浏览器:TFJS转换、推理与性能优化全指南

YOLOv5实时检测上浏览器:TFJS转换、推理与性能优化全指南 ★ FEATURED ARTICLE
简介将YOLOv5与TensorFlow.js结合的项目资源面向希望在Web端实现实时目标检测的前端、全栈开发者。将模型部署到浏览器或Node.js环境后可复用已有权重完成图片与视频的实时识别适合快速搭建演示原型、轻量级Web应用及边缘设备交互场景。压缩包共29个文件整体约88KB包含HTML/CSS/JS前端页面、Python服务端脚本、Shell部署脚本、YAML/CFG配置与Markdown说明文档前端页面负责界面展示与交互Python脚本用于启动不同服务端Shell脚本简化依赖安装和部署配置与文档则帮助调整参数并理解项目规则服务器部分按FastAPI、Flask、Bottle三种后端实现组织并带有HLS与静态页面等接入方式便于对照不同方案。目前已有289人浏览学习这份资源提供了完整项目骨架、多后端接入示例、环境安装与部署脚本还附带许可证、代码检查规则和变更记录能帮助开发者快速理清目录结构、按需选择部署形式并合法使用YOLOv5模型。1. YOLOv5-RT-TFJS把 YOLOv5 实时检测完整搬到浏览器看到“YOLOv5-RT-TFJS-yolov5”这个标题拆开其实就是四个要素YOLOv5 模型、RT实时推理、TFJSTensorFlow.js、浏览器端加载 YOLOv5 权重。当成一个整体来理解它就是一条“训练好的 YOLOv5 权重 → 转换成浏览器能加载的格式 → 打开网页直接跑实时目标检测”的完整落地链路。这个方案最典型的用处是省掉一台推理服务器。摄像头画面不出浏览器数据不出本机既没有视频流上传的带宽成本也没有 GPU 实例的账单压力。适合已经用 YOLOv5 训练过检测模型想把模型快速变成网页 Demo、内网工具或移动端 H5 服务的开发者。很多人以为“浏览器里跑 YOLOv5”一定是精度阉割版其实模型本身完整保留推理精度和 Python 端基本一致。真正决定项目成败的是转换链路、JS 后处理和 WebGL 显存管理这三件事。下面按一条能复现的路径逐层拆开。2. 模型转换从 PyTorch 权重到 TFJS 的三种落地路线与必调参数2.1 为什么一定绕不过 ONNX 这层TFJS 的 graph model 只认 TensorFlow 的 SavedModel 或 Keras H5 作为输入PyTorch 的.pt权重文件没法被直接读取。于是常规做法是走一条两跳以上的转换链.pt → ONNX → SavedModel → TFJS第一段用 PyTorch 的 ONNX 导出能力把模型结构和权重固化成一个计算图第二段用 ONNX 转 TF得到 SavedModel 目录第三段用tensorflowjs_converter把 SavedModel 打包成model.json加若干.bin权重分片。每段都有独立报错入口转换失败时能单步定位而不是一堆问题混在一起查不清。常见误用是在某个在线转换页面上一次性完成全流程。有的转换页确实能出结果但模型一旦带自定义算子比如后处理 NMS 或者特殊激活函数得到的就是一张“能加载但跑出 NaN”的黑匣子。自己控制这几条命令后续排错成本会低很多。2.2 锁环境torch 和 tensorflow 两套栈同住的注意事项转换期第一个拦路虎通常是 Python 环境。torch 和 tensorflow 对 numpy、protobuf 的版本要求经常互相打架表现为 onnx2tf 导入时报undefined symbol或 tensorflow 加载时直接崩。我一般会为转换单独建一个虚拟环境并且固定安装顺序。# 创建并激活独立环境 conda create -n yolo2tfjs python3.10 -y conda activate yolo2tfjs # 先装 torch 相关再装转换工具 pip install torch torchvision pip install onnx onnx2tf tensorflow tensorflowjs先装 torch 再装 tensorflow 这个顺序我踩过坑反过来装时 torch 引入的算子库符号解析经常被打乱导出 ONNX 阶段出现各种莫名段错误。另外如果安装后onnx2tf提示 numpy 版本不兼容直接把 numpy 锁在 2.x 以下重装一遍。2.3 路线一官方 export.py 一键导出 TFJS多数开源 YOLOv5 复现项目里都有一个export.py支持--include tfjs这是最顺的一跳。命令很短# 以轻量模型 yolov5s 为例 python export.py \ --weights yolov5s.pt \ --include tfjs \ --img 640 \ --batch-size 1 \ --simplify跑完后会根据权重文件名生成一个带_web_model后缀的目录里面是model.json和 1 个或几个.bin权重分片。这里几个参数必须说明--img 640固定模型输入分辨率。不要为了追求速度改成 320小目标检测会明显退化。运行期速度不够是另一回事那是推理端缩放的问题不在这里提前牺牲精度。--batch-size 1强烈建议锁死。转成动态 batch 后TFJS 在浏览器里加载时经常要求显式绑定具体 batch反而给自己找事。--simplify如果当前版本支持这个参数就加上它能把计算图里多余的节点清掉文件体积和加载时间都有改善。有些分支版本不支持--include tfjs或者转换时报某个算子不支持那就走下面的手工链路。2.4 路线二手动 ONNX → SavedModel → TFJS 的兜底链当一键脚本断了退回手工三连是最靠谱的做法。整个链条每一步都看得见产物出错位置一目了然。# 第一步导出 ONNX python export.py --weights yolov5s.pt --include onnx --img 640 # 第二步ONNX 转 TensorFlow SavedModel python -m onnx2tf -i yolov5s.onnx -o yolov5s_savedmodel # 第三步SavedModel 打包成 TFJS tensorflowjs_converter \ --input_formattf_saved_model \ --output_formattfjs_graph_model \ yolov5s_savedmodel \ yolov5s_tfjs这里要点是第二步的onnx2tf对输入维度很敏感。如果 ONNX 图里 batch 维度是动态的生成的 SavedModel 签名会带None第三步之后浏览器加载会报cannot infer shape。所以第一步导出 ONNX 时--batch-size 1必须带上。tensorflowjs_converter的产物建议单独放在一个静态目录里不要把model.json和.bin拆到不同目录TFJS 加载时会根据 model.json 里的相对路径找分片一旦跨目录引用部署阶段就得多配一条静态路由。2.5 路线三权重量化与产物自检第三条路不是从.pt开始的重新转换而是对 TFJS 产物做二次优化。转换完的权重默认是 FP32 浮点体积大、浏览器下载慢。tensorflowjs_converter支持在导出时直接把权重量化tensorflowjs_converter \ --input_formattf_saved_model \ --output_formattfjs_graph_model \ --quantize_float16 \ yolov5s_savedmodel \ yolov5s_tfjs_fp16FP16 量化对 YOLOv5 这种卷积为主的网络非常友好体积直接减半精度损失通常能控制在 1% 以内。不要轻易上--quantize_uint8检测头输出层的动态范围很大8bit 量化后边界框回归会出现明显抖动。转换完成后用下面三条做产物自检别急着进浏览器检查项预期表现不一致时排查方向目录里是否有 model.json 和 bin 分片至少一个 .bin体积和权重文件量级匹配转换中断或分片失败model.json 里 input 节点名称与 shape名称常见为 imagesshape 为[1,640,640,3]或[1,3,640,640]onnx2tf 输入排列需要固定 NHWCoutput 节点只有一层通道数为 85[1,25200,85]这类输出导出时混入了 NMS 或解码节点第二点要重点讲TFJS 在 WebGL 后端通常按 NHWC 处理输入。如果 model.json 里读出来是[1,3,640,640]加载一般也能成功但前处理里在 JS 端做维度置换会多一次 GPU 开销另一层是容易把人绕晕。我建议导出时直接锁定 NHWC 排列后面代码统一。3. 浏览器端推理TFJS 加载、前处理、NMS 与画框的最小完整代码3.1 初始化先定后端再等模型就绪TFJS 在浏览器里有 WebGL、WASM、CPU 三种常见后端。CPU 慢到没法跑实时检测WASM 能兜底但和 WebGL 差距明显正常情况首选 WebGL。初始化代码必须带后端回退不然在 iOS Safari 或部分老款安卓机上会直接抛异常。let model; async function init(modelUrl) { try { await tf.setBackend(webgl); console.log(use webgl backend); } catch (e) { console.warn(webgl 后端不可用回退到 wasm, e); await tf.setBackend(wasm); } await tf.ready(); model await tf.loadGraphModel(modelUrl); }tf.loadGraphModel接收的是指向model.json的 URL加载完成后权重分片会被自动拉取。注意这里的modelUrl如果是相对路径要保证网页所在的静态服务能正确返回.bin文件的application/octet-streamMIME 类型否则部分服务器会直接 404。参数说明setBackend要在ready()之前调用ready()会真正初始化后端上下文WebGL 纹理池、编译内核都在这一步创建。早期的 TFJS 版本里还存在tf.setBackend(webgl, true)第二参数控制强制刷新现在不建议再用统一靠异常回退。3.2 前处理把一帧视频变成 640×640 的浮点张量视频帧来源可以是video元素也可以是canvas。tf.browser.fromPixels能直接接收这两种对象省掉图像解码过程。function preprocess(source) { return tf.tidy(() { // source 可能是 HTMLVideoElement 或 ImageBitmap const tensor tf.browser.fromPixels(source).toFloat(); const resized tf.image.resizeBilinear(tensor, [640, 640]); // 归一化到 0~1并扩展成 batch 维度 [1, 640, 640, 3] return resized.div(255).expandDims(0); }); }tf.tidy是内存管理的关键。它会把内部创建的中间张量自动释放只保留函数返回值。如果没有这一层resized、tensor都会滞留在 WebGL 纹理池里跑几十帧后显存就爆了。一个必须说清的坑上面这段是直接拉伸。严格要和 Python 端结果一致前处理应该做 letterbox把视频帧按比例缩到 640 内四周填充 114 灰度边。直接拉伸适合画面接近正方形的视频源如果是 16:9 的横向视频物体形变会导致位置和宽高都有偏差。做法是把视频画面居中画到一个 640×640 的 canvas 上再把这个 canvas 丢给fromPixels后面代码完全不用改。3.3 后处理解码输出用 NMS 去掉重复框YOLOv5 检测头把三个尺度的预测拼成了一层输出形状通常是[1, 25200, 85]。25200 来自 80×80×3 加 40×40×3 加 20×20×385 是 4 个位置参数加 1 个 objectness 加 80 个类别分数。解码逻辑如下function sigmoid(x) { return 1 / (1 Math.exp(-x)); } function postprocess(output, inputSize, confThreshold) { // 同步取数WebGL 后端会做一次 GPU→CPU 拷贝 const data output.dataSync(); const numAnchors 25200; const numClasses 80; const stride 85; const boxes []; for (let i 0; i numAnchors; i) { const offset i * stride; // YOLOv5 输出的是中心点坐标和宽高以输入像素为单位 const cx data[offset]; const cy data[offset 1]; const w data[offset 2]; const h data[offset 3]; const objScore sigmoid(data[offset 4]); let bestClass -1; let bestScore 0; for (let c 0; c numClasses; c) { const clsScore sigmoid(data[offset 5 c]) * objScore; if (clsScore bestScore) { bestScore clsScore; bestClass c; } } if (bestScore confThreshold) continue; boxes.push({ x1: Math.max(0, (cx - w / 2) / inputSize), y1: Math.max(0, (cy - h / 2) / inputSize), x2: Math.min(1, (cx w / 2) / inputSize), y2: Math.min(1, (cy h / 2) / inputSize), score: bestScore, classId: bestClass }); } return nms(boxes, 0.45); }这里的坐标被除inputSize归一化到 0~1是为了后面画框时不受画布尺寸变化影响。NMS 的 IoU 计算中除法会被约掉所以这里的归一化不影响判断结果。NMS 实现直接用最经典的贪心策略function nms(boxes, iouThreshold) { boxes.sort((a, b) b.score - a.score); const keep []; while (boxes.length) { const current boxes.shift(); keep.push(current); boxes boxes.filter((box) { const iou calcIoU(current, box); return iou iouThreshold; }); } return keep; } function calcIoU(a, b) { const x1 Math.max(a.x1, b.x1); const y1 Math.max(a.y1, b.y1); const x2 Math.min(a.x2, b.x2); const y2 Math.min(a.y2, b.y2); const interW Math.max(0, x2 - x1); const interH Math.max(0, y2 - y1); const interArea interW * interH; const unionArea (a.x2 - a.x1) * (a.y2 - a.y1) (b.x2 - b.x1) * (b.y2 - b.y1) - interArea; return interArea / unionArea; }性能说明这里的dataSync()是同步阻塞操作。25200×85 大约是 214 万个浮点数数据量不大普通 PC 上一次拷贝加解码在 10ms 上下。移动端会慢一些但通常也低于 30ms。真正拖后腿的是 NMS 里的 O(n²) 比较所以上面postprocess先用confThreshold滤掉大量低置信度候选框进入 NMS 的通常只剩几百个这比直接对 25200 个框做排序快一个量级。3.4 主循环检测、画框、清理内存主循环用requestAnimationFrame驱动视频流持续播放检测可以隔帧执行。let lastInferenceTime 0; const INFERENCE_INTERVAL 100; // 每 100ms 检测一次约 10fps async function tick(timestamp) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); if (timestamp - lastInferenceTime INFERENCE_INTERVAL) { lastInferenceTime timestamp; const input preprocess(video); const output await model.execute(input); const boxes postprocess(output, 640, 0.25); drawBoxes(boxes); input.dispose(); output.dispose(); } requestAnimationFrame(tick); }model.execute返回的是 TFJS 张量对象后处理里虽然用dataSync()把数据拷到了 JS 数组但 GPU 侧的输出纹理仍然占用显存必须手动dispose。漏掉这一步帧率会随着时间越跑越低这是浏览器端最常见的翻车现场。绘图步骤如下先把当前视频帧画到 canvas 上再在 canvas 上按检测框strokeRect。由于postprocess已经归一化坐标画的时候乘回canvas.width和canvas.height即可。没有额外操作。4. 实时帧率优化先称量四个耗时环节再决定改分辨率还是上 Worker4.1 分阶段计时先找到瓶颈再动手优化前最忌凭感觉调参。我给推理链路插一圈计时把预处理、执行、后处理、绘制四段时间分别打点const t0 performance.now(); const input preprocess(video); const t1 performance.now(); const output await model.execute(input); const t2 performance.now(); const boxes postprocess(output, 640, 0.25); const t3 performance.now(); drawBoxes(boxes); const t4 performance.now(); console.log({ preprocess: (t1 - t0).toFixed(1), inference: (t2 - t1).toFixed(1), postprocess: (t3 - t2).toFixed(1), draw: (t4 - t3).toFixed(1) });从实际项目看瓶颈分布很有规律。PC 端 Chrome 里inference通常占大头但postprocess里dataSync()的 GPU→CPU 同步也可能到二三十毫秒取决于显卡驱动的管线刷新移动端反而preprocess的 resize 操作偶尔会和 WebGL 纹理上传叠加造成突发卡顿。不要只看平均值。检测这种周期性任务P90 延迟比平均延迟更有参考价值。我习惯打印最近 30 帧的耗时分布把超过 100ms 的帧单独标记。这类长尾帧大多来自后台标签页切回、浏览器 GC 或者 WebGL 上下文被系统回收优化方向完全不一样。4.2 参数对照表分辨率、置信度阈值、后端在没有改代码结构的情况下下面这张表基本覆盖了所有能快速调整的参数维度调参项推荐范围对帧率的影响对精度的影响模型输入尺寸640 / 512 / 416尺寸减小约 30%推理时间降约 40%小目标召回下降明显confThreshold0.25 / 0.3 / 0.4越高 NMS 候选越少低分框被过滤误检减少iouThreshold0.45 / 0.5对帧率影响很小重叠目标合并策略不同TFJS 后端webgl / wasmwebgl 通常快 2~4 倍基本无影响推理频率每帧 / 隔帧 / 每 100ms最大杠杆隔帧时目标快速移动会有拖影常见误用是只降输入尺寸不动推理频率。实际上浏览器实时检测的最大成本往往在model.execute上如果视频源本身只有 30fps你每帧做一次 640 推理哪怕模型只要 30ms也会吃掉一多半预算。先改成每两帧或每 100ms 推理一次帧率立刻翻倍观感下降有限。4.3 降低推理频率隔帧检测平滑旧框检测不需要和渲染同频。渲染保持 60fps 决定画面流畅度检测 5~10fps 就足以覆盖大多数场景。做法是维护一个“上一次的检测结果”缓存渲染帧直接画缓存里的框只在到达时间间隔时更新缓存。let cachedBoxes []; let nextInference 0; function tick(timestamp) { // 渲染永远不阻塞 ctx.drawImage(video, 0, 0, canvas.width, canvas.height); drawBoxes(cachedBoxes); if (timestamp nextInference) { nextInference timestamp 100; runInference().then((boxes) { cachedBoxes boxes; }); } requestAnimationFrame(tick); }这套写法把网络抖动也天然消化了。某一次runInference超过 200ms不会阻塞后续渲染最多是那一帧的框稍微滞后用户基本感知不到。4.4 上 Worker把 TFJS 推理移出主线程如果 4.2 和 4.3 都做完了帧率还不满足就该考虑 Web Worker。原理很简单把preprocess、model.execute、postprocess全部挪进 Worker主线程只负责绘制计算不占用 UI 渲染资源。主线程侧const worker new Worker(detect-worker.js); // 将视频帧转成 ImageBitmap 传给 WorkerTransferable 方式零拷贝 const bitmap await createImageBitmap(video); worker.postMessage({ type: predict, bitmap }, [bitmap]);Worker 侧// detect-worker.js importScripts(tf.min.js, yolov5.min.js); let model null; self.onmessage async (e) { if (e.data.type init) { await tf.setBackend(webgl); model await tf.loadGraphModel(e.data.url); self.postMessage({ type: ready }); return; } if (e.data.type predict model) { const input preprocess(e.data.bitmap); const output await model.execute(input); const boxes postprocess(output, 640, 0.25); input.dispose(); output.dispose(); self.postMessage({ type: result, boxes }); } };Worker 里用importScripts加载 TFJS需要单独构建一个给 Worker 用的 bundle不能直接复用主页面里的 ESM 依赖。这一步配置最费劲但收益也大iPhone 这类单线程资源有限的设备上主线程阻塞能从 80ms 降到 10ms 以内滑动手势和页面交互不再掉帧。5. 避坑清单转换和浏览器端五个典型翻车现场5.1 现象iOS Safari 上模型加载报 “Backend webgl has not been registered”原因iOS 上 WebGL 上下文可能因为页面在 iframe 中、内存压力、或者系统 WebGL 实现受限而无法创建。TFJS 在注册后端时没有拿到有效 context就抛了这条异常。页面在微信内置浏览器里更容易触发。解决初始化做多级回退先 webgl失败后 wasm 再兜底。另外建议把tf.setBackend放到用户点击“开始检测”按钮之后执行避免浏览器在页面加载阶段收紧 WebGL context 配额。5.2 现象跑了 10 秒后帧率从 30 掉到 8页面内存曲线只升不降原因几乎可以断定是张量泄漏。model.execute返回的 output 张量没有 dispose或者preprocess里的中间张量没有包进tf.tidy。WebGL 纹理不受 JS 垃圾回收直接管理引用一多就挂在 GPU 显存里。解决所有创建张量的函数统一用tf.tidy包裹异步返回的 output 在执行完dataSync()后立刻dispose。再留一条自查习惯每帧结束打印tf.memory().numTensors如果数字持续增长一定有泄漏。5.3 现象框的位置全乱了物体在画面左边框却出现在右下角原因输入坐标没对齐。TFJS 侧输入是 NHWC也就是[1, 640, 640, 3]如果转换出来的模型 input 是 NCHW[1, 3, 640, 640]fromPixels给的结果会被当成 3 张 640×640 的单通道图处理推理输出自然全乱。解决转换后用一段最小代码打印 input shape是 NCHW 就回到onnx2tf加上 NHWC 转换参数重新导出。不要在 JS 端做transpose来补救既多一次 GPU 开销又容易在多个浏览器上出现索引错位。5.4 现象模型在本地打开 HTML 正常部署到服务器后 404原因静态服务器没有给.bin文件配 MIME 类型或者把model.json和.bin放到了不同目录。TFJS 加载时按 model.json 里的相对路径请求分片路径一旦跨目录就会断。解决部署时把整个_web_model目录原样放到静态目录下并确认服务器对.json和.bin都返回application/octet-stream。如果用了 CDN还要注意分片是否被缓存策略拦截。5.5 现象NMS 在 iPhone 上耗时 300ms页面直接卡死原因候选框没有提前过滤。默认 25200 个 anchor只按 score 排序就全量进入 NMS每次 IoU 比较都是 O(n²)移动端 JS 引擎直接跑满主线程。解决后处理里先做两轮过滤。第一轮confThreshold过滤低分框第二轮按 score 降序只看前 200 个候选。检测场景里高置信度框通常不会超过 100 个这种裁剪对召回影响极小但耗时能降一个数量级。6. 验证与进阶用两端输出对齐判断这套链路值不值得投入6.1 用同一张固定图做输出对齐部署前一定要做一次“两端对齐”验证而不是只在浏览器里看看有没有框。准备一张测试图先在 Python 端跑一次推理记录每个框的坐标、类别和置信度再把同一张图放进浏览器跑 TFJS对比两者输出。对比时注意两点一是 IoU 一致性同一个目标双方框的 IoU 最好都在 0.6 以上二是类别一致性同一目标类别不能不同。坐标差几个像素属于正常因为 TFJS 转换或 FP16 量化会引入少量数值误差但如果出现大范围偏移回查 letterbox 前处理。这个验证的价值在于它把你的浏览器端结果和训练时的真实推理行为绑在一起。只做 Demo 演示看不出问题对不齐数据时才发现模型在 WebGL 上的输出早就已经偏离了原来的语义。6.2 进阶技巧把检测封装成独立函数我现在的习惯是把整个检测链路封装成一个不依赖 DOM 的纯函数输入是ImageBitmap输出是普通 JSON 数组。这样既可以在 Worker 里跑也能在 Node 环境里做自动化测试。以后换模型只需要改数据和 URL业务代码一行不用动。另一个建议是保留 FP16 和 FP32 两个版本一个给移动端用一个给 PC 端兜底。不要觉得多一份产物是浪费实际部署时这两个版本能覆盖完全不同的设备梯度省掉大量现场调参数的麻烦。6.3 值不值得做边界要清楚这套链路适合交付检测看板、内网工具、活动页互动这类场景体验好部署成本极低。但它不适合做高并发在线 API也不适合替代完整的多目标跟踪系统毕竟浏览器端的算力和内存上限就摆在那里。真要追求极致性能还是要看本地推理框架两者不是替代关系。我现在的固定流程是每次拿到新权重先用一条命令导出 TFJS用固定图做对齐验证合格后再进项目。这套习惯让我少踩了很多临时调参的坑也希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站