资讯详情

资讯详情

YOLOv5端侧部署实战:torch转ONNX再转ncnn全链路解析

1. 为什么要在 YOLOv5 上折腾 torch → onnx → ncnn 这条链路做过端侧部署的人大概都有这种体会训练阶段用 PyTorch 爽得飞起一旦要把模型塞进手机、嵌入式板子或者某些没有完整深度学习运行时的小设备里麻烦就来了。YOLOv5 本身是个很成熟的检测框架但它的原生权重是.pt格式直接往端侧搬基本不现实。这时候常见的做法就是走一条“中间格式”的路线把 PyTorch 模型先转成 ONNX再从 ONNX 转到 ncnn最后在端侧用 ncnn 的 C 接口推理。我这次要聊的就是这条链路torch → onnx → ncnn目标模型是 YOLOv5。为什么选 ncnn 而不是别的因为 ncnn 是纯 C 实现、无第三方依赖、对移动端 CPU 特别友好编译出来体积小ARM 架构上跑得也稳。ONNX 在这里扮演的是“通用中转站”的角色——PyTorch 直接转 ncnn 不是不行但工具链成熟度和可控性都不如先转 ONNX 再转 ncnn 来得踏实。这篇文章适合谁看如果你已经会用 YOLOv5 训练出自己的权重但卡在“怎么把它弄到端侧跑起来”这一步那这篇就是写给你的。我会把整条链路拆开讲每一步为什么这么做、参数怎么定、哪里最容易翻车、翻车了怎么排查。中间涉及的命令和配置我都会给全你可以直接照着复现。需要说明的是文中一些具体参数和踩坑细节是基于我自己的实践和这类部署任务的常见做法补充的不同版本的库可能有细微差异你以自己环境实测为准。先说结论性的判断这条链路的核心难点不在“转换命令本身”而在算子兼容性和输出层对齐。YOLOv5 里有几个算子在转 ONNX 和转 ncnn 时容易出问题尤其是后处理相关的部分。很多人第一次转完发现模型能跑但结果全错八成就是输出结构没对齐。下面我按顺序把每个环节讲透。2. 转换前的环境准备与版本选择逻辑2.1 版本搭配为什么比“装最新版”更重要这条链路上最容易忽视、但影响最大的就是版本。我见过太多人上来就pip install一堆最新版结果转 ONNX 时报一堆算子不支持或者转 ncnn 时直接崩。原因是ONNX 的 opset 版本、PyTorch 版本、onnx-simplifier 版本、ncnn 的转换工具版本这四者之间存在兼容矩阵。你随便升级其中一个就可能打破平衡。我的建议是锁定一套经过验证的组合而不是追新。下面这套是我实测比较稳的搭配以 YOLOv5 较新的代码库为基准组件推荐版本说明PyTorch1.10 ~ 1.13太新的版本导出 ONNX 时行为有变化ONNX1.12 左右与 opset 12/13 配合稳定onnx-simplifier0.4.x用于简化计算图去掉冗余节点onnx2ncnn随 ncnn 源码编译官方工具必须和 ncnn 库版本一致ncnn较新的 release端侧运行时建议源码编译这里有个关键点onnx2ncnn 这个工具是跟着 ncnn 源码一起编译出来的它的版本必须和你端侧链接的 ncnn 库版本一致否则可能出现“转换成功但加载失败”的诡异问题。我踩过一次坑转换工具是旧版、端侧库是新版结果模型加载时直接报参数不匹配排查了大半天才定位到版本。2.2 导出 ONNX 时的 opset 选择opset 版本决定了 ONNX 支持哪些算子。YOLOv5 里用到的算子比如SiLU激活、Resize、Concat等在不同 opset 下支持情况不一样。opset 太低某些算子表达不了opset 太高ncnn 的转换工具可能还没跟上。我的经验是opset 12 或 13比较稳妥。opset 12 对 YOLOv5 的算子覆盖已经够用而且 ncnn 对它的支持成熟。如果你用的是带SiLU的 YOLOv5 版本opset 12 能正常导出。导出命令大致是这样python export.py --weights yolov5s.pt --include onnx --opset 12 --img-size 640 640注意--img-size要和训练/推理时保持一致这个尺寸会固化到 ONNX 的输入维度里。如果你后面想支持动态尺寸需要额外加--dynamic参数但动态尺寸会引入更多算子复杂度转 ncnn 时更容易出问题。第一次跑通链路强烈建议先用固定尺寸等整条链路通了再考虑动态。2.3 一个容易被忽略的准备确认模型里有没有“非标准算子”YOLOv5 官方代码里有些版本会在后处理里用到一些自定义操作比如torch.nn.functional.interpolate的某些模式、或者自定义的Focus层老版本有新版本改成了卷积。这些在导出 ONNX 时可能被展开成一堆基础算子也可能直接报错。我的做法是导出前先看一眼模型结构确认没有特别冷门的算子。如果确实有要么在导出时用--grid之类的参数调整输出形式要么在模型定义里把该算子替换成等价的标准算子。这一步花十分钟能省后面几小时的排查。3. torch 导出 ONNX 的实操与常见报错3.1 导出命令背后的参数含义上面给的导出命令看起来简单但每个参数都有讲究。我拆开说--weights你的.pt权重路径。注意这里加载的是完整模型还是 state_dictYOLOv5 的 export 脚本默认能处理完整模型。--include onnx指定导出格式。可以同时写多个格式但建议一次只导一种方便定位问题。--opset 12前面说过了控制算子集版本。--img-size 640 640输入分辨率。这个值会写进 ONNX 的输入张量形状。--batch-size 1默认就是 1端侧推理一般也是单张保持默认即可。导出成功后你会得到一个.onnx文件。但别急着往下走先验证这个 ONNX 模型本身是否正确。验证方法是用 onnxruntime 跑一遍和 PyTorch 的输出对比。如果这一步就对不上后面转 ncnn 只会更乱。3.2 导出后必须做的数值对齐验证我习惯写一小段脚本做对齐核心思路是同一张输入图分别喂给 PyTorch 模型和 ONNX 模型比较输出的差异。如果最大误差在 1e-3 量级以内说明导出没问题如果差得离谱说明导出过程丢了信息。import torch import onnxruntime as ort import numpy as np # 构造随机输入模拟一张图 dummy np.random.rand(1, 3, 640, 640).astype(np.float32) # PyTorch 输出 model torch.load(yolov5s.pt, map_locationcpu)[model].float() with torch.no_grad(): pt_out model(torch.from_numpy(dummy))[0].numpy() # ONNX 输出 sess ort.InferenceSession(yolov5s.onnx) onnx_out sess.run(None, {sess.get_inputs()[0].name: dummy})[0] print(max diff:, np.abs(pt_out - onnx_out).max())这里有个细节YOLOv5 的模型 forward 返回的可能是一个列表或元组取[0]拿到主输出。不同版本返回结构略有差异你按自己版本调整。如果这一步误差很大先别怀疑 ONNX先检查输入预处理是否一致——比如 PyTorch 那边有没有做归一化、通道顺序对不对。很多时候误差大是因为输入没对齐而不是模型转换的问题。3.3 导出报错的典型类型与处理导出阶段常见的报错有几类我列一下第一类是“Unsupported operator”意思是某个算子在当前 opset 下没有对应实现。解决办法通常是提高 opset或者把该算子替换成等价组合。YOLOv5 里比较常见的是某些激活函数如果遇到可以尝试 opset 13。第二类是“TracerWarning”这是警告不是错误但值得关注。它提示某些操作在追踪时行为可能和实际推理不一致比如依赖张量值的控制流。YOLOv5 里如果后处理写进了 forward就可能触发这类警告。我的建议是把后处理从模型里剥离出去让导出的 ONNX 只负责“特征输出”后处理解码框、NMS放到端侧用 C 写。这样既避免追踪问题也让 ncnn 转换更干净。第三类是形状推断失败报某个维度是动态的但没标好。这通常和--dynamic有关固定尺寸下少见。4. ONNX 简化与转 ncnn 的关键操作4.1 为什么一定要过一遍 onnx-simplifier导出的原始 ONNX 里往往有一堆冗余节点比如恒等映射、多余的 reshape、可以折叠的常量运算。这些节点不影响正确性但会让 ncnn 转换更复杂甚至触发某些不支持的情况。onnx-simplifier 的作用就是把这些冗余干掉让计算图更干净。python -m onnxsim yolov5s.onnx yolov5s-sim.onnx跑完之后对比一下文件大小通常会小一些。更重要的是简化后的图节点更少转 ncnn 时出错概率明显降低。我实测过有些模型不简化直接转 ncnn 会报算子不支持简化后就过了——因为简化过程把那个算子折叠掉了。注意简化后一定要再跑一次数值对齐验证确认简化没有改变模型行为。绝大多数情况没问题但个别情况下 simplifier 可能过度优化导致输出偏差。4.2 onnx2ncnn 转换与输出层命名转换命令本身很简单onnx2ncnn yolov5s-sim.onnx yolov5s.param yolov5s.bin你会得到两个文件.param是网络结构描述.bin是权重。转换成功不代表能用接下来要做两件事。第一件是检查 param 文件里的输出层。打开.param文件最后几行会列出输出 blob 的名字。YOLOv5 通常有多个输出分支对应不同尺度的检测头你需要确认这些输出 blob 都在。如果发现输出被截断或者名字不对说明转换时输出层没识别对需要在转换前用工具指定输出节点。第二件是用 ncnn 的 benchmark 工具验证。ncnn 源码里带了一个benchncnn工具可以加载 param/bin 跑一遍看能不能正常前向、输出形状对不对。这一步能在上端侧之前把大部分问题暴露出来。4.3 输出层对齐整条链路最容易翻车的地方我要重点说这个因为十个人转 YOLOv5 到 ncnn八个会在这里翻车。YOLOv5 的输出不是最终的检测框而是三个尺度的特征图每个特征图上的每个位置有(5 num_classes)个值4 个框坐标 1 个置信度 类别数。这些值需要经过解码sigmoid、加上网格偏移、乘上 anchor才能变成真实框坐标。如果你在导出 ONNX 时把后处理也导进去了那输出就是最终框如果没导进去输出就是原始特征图。问题在于ncnn 转换后输出的顺序和形状可能和你预期的不一样。比如你以为输出是[1, 25200, 85]结果 ncnn 给你的是三个分开的 blob每个形状是[1, 255, 80, 80]这种。这时候如果你端侧代码按前者解析结果必然全错。我的做法是在导出 ONNX 时就明确输出结构并在 param 文件里核对。具体来说导出时让模型只输出三个特征图然后在端侧 C 代码里手动实现解码和 NMS。这样虽然多写点代码但可控性最强出问题也好定位。解码逻辑的核心是对每个特征图遍历每个位置取出(tx, ty, tw, th, obj, cls...)然后bx (sigmoid(tx) * 2 - 0.5 grid_x) * strideby (sigmoid(ty) * 2 - 0.5 grid_y) * stridebw (sigmoid(tw) * 2) ** 2 * anchor_wbh (sigmoid(th) * 2) ** 2 * anchor_h这套公式是 YOLOv5 的标准解码方式anchor 和 stride 要和你训练时用的一致。如果你发现框的位置整体偏移或者大小不对八成是 anchor 或 stride 用错了。5. 端侧 ncnn 推理的落地细节与性能调优5.1 加载模型与输入预处理端侧加载 ncnn 模型很直接ncnn::Net net; net.load_param(yolov5s.param); net.load_bin(yolov5s.bin); net.opt.use_vulkan_compute false; // 移动端 CPU 推理关掉输入预处理要和训练时严格一致letterbox 缩放保持长宽比短边补灰、归一化到 0~1、BGR 还是 RGB 要对。YOLOv5 默认是 RGB、归一化到 0~1。如果你端侧用了 BGR 或者没归一化检测结果会明显变差但又不至于完全错这种“半对半错”最难排查。letterbox 的实现要注意缩放比例取min(target_w / w, target_h / h)然后计算 padding。padding 的偏移量在解码框坐标时要还原回去否则框的位置会整体偏移。这个偏移量很多人忘了处理导致框看起来“差不多对但总是偏一点”。5.2 多尺度输出的解析顺序前面说了 YOLOv5 有三个尺度输出。在 ncnn 里这三个输出对应三个Extractor的 blob。你需要按 stride 从大到小或者从小到大取决于你的代码依次取出分别解码最后把所有框合并做 NMS。这里有个坑三个输出的顺序在 param 文件里不一定是你以为的顺序。我建议在代码里按 blob 名字取而不是按索引取。比如ncnn::Mat out; ex.extract(output_1, out); // 用名字而不是索引名字从 param 文件里看。这样即使转换时顺序变了你的代码也不会错。5.3 NMS 的实现与阈值选择NMS 我建议自己用 C 写不依赖 ncnn 的内置实现虽然 ncnn 有但自己写更可控。核心就是按置信度排序然后逐个比较 IoU超过阈值的抑制掉。阈值选择上置信度阈值conf threshold和 IoU 阈值nms threshold要根据你的场景调。一般 conf 取 0.25、IoU 取 0.45 是个不错的起点。如果你的场景小目标多conf 可以降到 0.1如果误检多就提高 conf。这个没有万能值得实测。性能上NMS 是 CPU 密集操作如果框特别多比如低阈值下几千个框NMS 会成为瓶颈。优化方法是先按 conf 过滤掉大部分框再做 NMS。我实测过先过滤能把 NMS 耗时降低一个数量级。5.4 性能调优的几个实用手段ncnn 在移动端 CPU 上的性能除了模型本身还受几个因素影响线程数net.opt.num_threads设置成 CPU 大核数量一般 2~4。设太多反而因为调度开销变慢。fp16 存储ncnn 支持 fp16 权重存储能减小模型体积、提升缓存命中率。开启方式是net.opt.use_fp16_storage true但要注意某些老设备不支持。输入尺寸640 是最常用的但如果你的场景目标大降到 416 甚至 320 能大幅提速精度损失可控。算子融合ncnn 的opt里有一些融合开关默认开着的别乱关。我做过一个对比同一模型在 640 输入下线程数从 1 调到 4耗时能降一半左右再往上加收益就很小了。所以线程数不是越多越好找到拐点就行。6. 整条链路的排查思路与经验总结6.1 分层验证每一步都别跳过这条链路最大的风险是“错误累积”——ONNX 导出错了转 ncnn 后错误被放大端侧跑出来完全没法看但你不知道是哪一步错的。所以我的核心经验是每一步都做独立验证绝不跳过。具体分层是PyTorch 输出 → ONNX 输出数值对齐误差 1e-3ONNX 输出 → 简化后 ONNX 输出数值对齐简化 ONNX → ncnn 输出用 benchncnn 验证形状和数值ncnn 输出 → 端侧解码后框和 PyTorch 后处理结果对比任何一层对不上就停在那层排查别往下走。这样能把问题范围缩到最小。6.2 几个高频问题的快速定位表我把踩过的坑整理成一张表方便你对号入座现象可能原因排查方向模型能加载但输出全 0输入预处理不对检查归一化、通道顺序框位置整体偏移letterbox 偏移没还原检查解码时的 padding 补偿框大小明显不对anchor 或 stride 用错核对训练配置里的 anchor检测结果比 PyTorch 差很多输出层解析顺序错核对 param 里的 blob 名转换时报算子不支持opset 或简化问题提高 opset 或过 simplifier加载时报参数不匹配转换工具与库版本不一致统一 ncnn 版本这张表基本覆盖了我遇到过的八成问题。遇到问题先查表能省很多时间。6.3 关于“要不要把后处理放进模型”的取舍最后聊一个设计层面的取舍后处理解码 NMS到底放模型里还是放端侧代码里放模型里的好处是端侧代码简单直接拿最终框坏处是导出 ONNX 时容易触发追踪问题转 ncnn 时算子兼容性风险高而且不同版本的 NMS 实现可能不一致。放端侧代码里的好处是模型干净、转换稳定、可控性强坏处是要自己写解码和 NMS代码量大一些。我的选择是放端侧。原因很简单这条链路已经够复杂了我不想再引入后处理带来的不确定性。而且端侧自己写 NMS还能针对场景做优化比如先过滤再 NMS。多写的那点代码比起排查转换问题的痛苦完全值得。如果你追求快速跑通、不在意极致可控性也可以尝试把后处理导进去但要做好心理准备可能会遇到各种算子问题。我的建议还是先按“模型只输出特征图”的方式跑通等链路稳定了再考虑要不要优化。整条链路走下来最深的体会就是转换本身不难难的是对齐和验证。工具命令就那么几条但每一步背后的数值一致性、形状一致性、顺序一致性才是决定成败的关键。把分层验证的习惯养起来这条链路其实没那么可怕。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →