1. 为什么要在 YOLOv5 上折腾 torch→onnx→ncnn 这条链路
做过端侧部署的朋友大概率都经历过这个场景:模型在服务器上用 PyTorch 训练得好好的,mAP 也漂亮,但一到手机、嵌入式板子或者边缘设备上就抓瞎——PyTorch 那套运行时太重,依赖多、体积大、启动慢,根本塞不进产品里。这时候就需要一条“训练框架 → 通用中间格式 → 端侧推理引擎”的转换链路,而torch → onnx → ncnn就是目前业界最成熟、踩坑资料最多的一条路。
我这次要聊的就是把 YOLOv5 从 PyTorch 权重一路转到 ncnn,最终在端侧跑起来。为什么选 ncnn 而不是别的?几个现实原因:它是纯 C++ 实现、无第三方依赖、对移动端 ARM 架构做了大量手写汇编优化、编译出来体积小,而且对 YOLO 系列的支持非常成熟。ONNX 在这里扮演的是“翻译中间人”的角色——PyTorch 和 ncnn 之间没有直接通道,得靠 ONNX 这个通用格式过渡。
这条链路适合谁看?如果你正在做目标检测的端侧落地,手上有 YOLOv5 的.pt权重,目标平台是 Android、iOS、树莓派或者各种 ARM 开发板,那这篇基本就是给你写的。哪怕你之前没碰过 ncnn,只要会基本的 Python 和命令行操作,跟着走也能跑通。我会把每一步的为什么这么做、参数怎么算、哪里容易翻车都讲清楚,而不是甩几条命令就完事。
整条链路可以拆成四个阶段:PyTorch 导出 ONNX、ONNX 简化与校验、ONNX 转 ncnn、ncnn 推理验证。每个阶段都有各自的坑,尤其是 YOLOv5 这种带 Focus 层、多输出分支、动态 anchor 的模型,转换时不做处理直接转,十有八九会失败或者结果对不上。下面我按顺序拆开讲。
2. 转换链路的整体设计与关键取舍
2.1 三段式链路各自的角色定位
先把这个链路的“分工”理清楚,不然后面遇到问题不知道该在哪一环排查。
PyTorch 是训练和原始推理框架,它的计算图是动态的,导出时会被 trace 成静态图。ONNX 是开放神经网络交换格式,本质是一个标准化的计算图描述文件(protobuf 序列化),它不绑定任何框架,所以能当“中间语言”。ncnn 是腾讯开源的高性能端侧推理框架,它有自己的模型格式(.param描述网络结构 +.bin存权重)。
提示:ONNX 不是“越新越好”。ncnn 的 ONNX 解析器对 opset 版本有兼容范围,opset 太高会碰到不认识的算子,太低又可能缺算子。YOLOv5 场景下 opset 11 或 12 是最稳的区间。
为什么不让 PyTorch 直接转 ncnn?因为 ncnn 官方工具链只认 ONNX(以及少量 Caffe 格式),没有 PyTorch 直转通道。所以 ONNX 这一环是绕不开的,它的质量直接决定后面能不能转成功。
2.2 为什么 YOLOv5 转换比普通分类模型麻烦
普通分类网络(比如 ResNet)转换很顺,因为它的结构规整、单输出、算子常规。YOLOv5 麻烦在几个地方:
- Focus 层:早期 YOLOv5 用 Focus 做下采样,本质是切片(slice)+ concat 操作。这个结构在导出 ONNX 时容易产生奇怪的算子组合,ncnn 解析时可能报错。后来官方改成了 6x6 卷积下采样,就是为了部署友好。
- 多输出分支:YOLOv5 有三个检测头输出(对应 stride 8/16/32),输出是三个不同尺度的特征图。转换时要确保三个输出都被正确保留,不能只留一个。
- 动态维度:如果导出时用了动态 batch 或动态尺寸,ncnn 处理起来会麻烦,端侧一般固定输入尺寸,所以导出时就该固定。
- 后处理:YOLOv5 的 NMS、anchor 解码这些如果在模型里,转出来会很痛苦;正确做法是把后处理剥离出去,模型只输出原始预测张量,NMS 在 C++ 侧自己写。
理解了这些,你就明白为什么很多人“照着教程转”却失败——教程往往用的是简化模型或者老版本,没覆盖这些细节。
2.3 方案选型的几个关键决策
在动手前,有几个决策点需要先定下来,它们会影响后续所有步骤:
| 决策点 | 选项 | 我的建议 | 理由 |
|---|---|---|---|
| 输入尺寸 | 640x640 / 416x416 / 320x320 | 按端侧算力定,一般 640 或 416 | 尺寸越小越快但精度降,需实测权衡 |
| 是否固定 batch | 固定 1 / 动态 | 固定为 1 | 端侧基本单帧推理,动态维度徒增麻烦 |
| opset 版本 | 11 / 12 / 13 | 11 或 12 | ncnn 兼容性最好 |
| 后处理位置 | 模型内 / C++ 侧 | C++ 侧 | 模型内后处理转换易出错且不灵活 |
| 是否简化 ONNX | 简化 / 不简化 | 简化 | 去掉冗余算子,提升转换成功率 |
这些决策不是拍脑袋,每一条背后都是踩坑换来的。比如后处理放哪,我一开始图省事想塞进模型,结果 ONNX 里出现了一堆 ncnn 不支持的算子,折腾半天还是老老实实挪到 C++ 侧。
3. 从 PyTorch 导出 ONNX 的实操细节
3.1 环境准备与版本匹配
这一步最容易被忽视,但版本不匹配是转换失败的头号原因。我建议用一套经过验证的组合,而不是盲目追新:
# 建议的版本组合(实测稳定) python==3.8 torch==1.10.0 torchvision==0.11.0 onnx==1.10.1 onnxsim==0.4.1 onnxruntime==1.10.0为什么锁这些版本?torch 1.10 导出的 ONNX 图结构相对干净,onnxsim 0.4.x 对 YOLOv5 的图简化效果好,onnxruntime 用来做数值校验。如果你用 torch 2.x,导出的图可能带一些新算子,ncnn 解析器不一定认。
注意:不要在同一环境里混装多个 torch 版本,conda 环境隔离是最省心的做法。我见过有人系统里装了三个 torch,导出时调用的根本不是想要的那个,排查了半天。
3.2 导出脚本的关键参数
YOLOv5 官方仓库自带export.py,但直接跑默认参数往往不够。核心是控制好输入尺寸、opset、以及是否包含后处理。下面是我常用的导出逻辑(基于官方脚本改造):
import torch # 加载模型,注意 map_location 和 eval 模式 model = torch.load('yolov5s.pt', map_location='cpu')['model'].float() model.eval() # 必须!否则 BN 层和 dropout 行为不对 # 构造 dummy input,尺寸要和端侧一致 dummy = torch.zeros(1, 3, 640, 640) # 导出 torch.onnx.export( model, dummy, 'yolov5s.onnx', opset_version=12, # 关键参数 input_names=['images'], output_names=['output'], # 多输出时这里要对应 dynamic_axes=None, # 固定维度,不设动态轴 do_constant_folding=True, # 常量折叠,简化图 )几个参数必须解释清楚:
model.eval():训练模式下的 BatchNorm 会更新 running stats,dropout 会随机丢弃,导出时必须切到 eval,否则推理结果完全不对。opset_version=12:前面说过,11/12 最稳。13 及以上可能引入 ncnn 不支持的算子。dynamic_axes=None:固定输入维度。端侧推理尺寸固定,动态轴只会让 ncnn 转换更复杂。do_constant_folding=True:把能提前算的常量算掉,减小图规模,提升转换成功率。
3.3 多输出分支的处理
YOLOv5 的检测头输出是三个张量。如果你用的是官方 export.py,它会自动处理成单个拼接输出或者三个独立输出。我倾向于保留三个独立输出,因为 ncnn 侧解析更直观:
output_names=['output0', 'output1', 'output2']导出后可以用 netron 打开 ONNX 文件,确认输出节点数量对不对。如果只有一个输出,说明模型内部做了 concat,那 ncnn 侧解析逻辑就要相应调整。这一步一定要肉眼确认,别想当然。
提示:netron 是可视化 ONNX 的神器,导出后第一件事就是用 netron 打开看一眼,确认输入输出、算子类型、有没有奇怪的节点。很多问题在这一步就能提前发现。
3.4 导出后的数值校验
导出完不能直接拿去转 ncnn,得先验证 ONNX 和 PyTorch 的输出是否一致。用 onnxruntime 跑一遍,和 PyTorch 结果对比:
import numpy as np import onnxruntime as ort # PyTorch 推理 with torch.no_grad(): torch_out = model(dummy).numpy() # ONNX 推理 sess = ort.InferenceSession('yolov5s.onnx') onnx_out = sess.run(None, {'images': dummy.numpy()}) # 对比 diff = np.abs(torch_out - onnx_out[0]).max() print('max diff:', diff) # 应该在 1e-4 以内如果 diff 很大(比如 0.1 以上),说明导出有问题,常见原因是 eval 模式没切、opset 不兼容、或者模型里有自定义算子。这个校验步骤能帮你把问题挡在 ONNX 阶段,而不是等到 ncnn 转换失败再回头查。
4. ONNX 简化与转 ncnn 全流程
4.1 用 onnxsim 做图简化
原始导出的 ONNX 图往往有很多冗余节点(多余的 reshape、transpose、identity 等),这些冗余不仅拖慢推理,还可能在 ncnn 转换时报错。onnxsim 能自动做常量折叠、算子融合、冗余消除:
onnxsim yolov5s.onnx yolov5s_sim.onnx跑完后对比一下文件大小,通常会小一些,节点数也会减少。但要注意:onnxsim 偶尔会“简化过头”,把一些必要的结构改掉,导致输出对不上。所以简化后必须再做一次数值校验,确认输出和简化前一致。
注意:如果 onnxsim 报错或者简化后结果不对,可以加
--skip-optimization或者干脆跳过简化,直接用原始 ONNX 转 ncnn。简化是优化项,不是必选项。
4.2 编译 ncnn 工具链
ncnn 转换需要用到官方提供的onnx2ncnn工具,这个工具在编译 ncnn 时会一起生成。编译流程大致如下:
git clone https://github.com/Tencent/ncnn.git cd ncnn mkdir build && cd build cmake -DNCNN_VULKAN=OFF -DNCNN_BUILD_TOOLS=ON .. make -j$(nproc)编译完成后,build/tools/onnx2ncnn就是我们要的转换工具。NCNN_VULKAN=OFF是因为端侧一般用 CPU 推理,不需要 Vulkan 支持,关掉能减小体积、加快编译。
编译过程中常见的坑:cmake 版本太低、protobuf 缺失、编译器不支持 C++11。这些在编译报错信息里都能看出来,按提示装依赖即可。
4.3 执行 ONNX 到 ncnn 的转换
转换命令很直接:
./onnx2ncnn yolov5s_sim.onnx yolov5s.param yolov5s.bin跑完后会生成两个文件:.param是网络结构描述(文本格式,可以直接看),.bin是权重(二进制)。转换过程中终端会打印一些信息,重点关注有没有Unsupported或Shape not supported之类的警告。
如果转换报错,常见原因和对策:
| 报错信息 | 原因 | 对策 |
|---|---|---|
| Unsupported op: xxx | ncnn 不支持该算子 | 换 opset 重导,或改写模型结构 |
| Shape not supported | 动态维度未固定 | 导出时固定所有维度 |
| Slice param error | Focus 层切片参数异常 | 改用 6x6 卷积版本模型 |
| Concat axis error | 拼接轴不明确 | 检查 ONNX 图,必要时手动改 param |
转换成功后,.param文件是可以直接打开看的,里面每一行描述一个层。我建议至少扫一眼,确认输入层、输出层、以及有没有明显异常的层。
4.4 param 文件的手动修正
有时候转换能过,但 param 文件里有小问题,需要手动改。最常见的是输入输出层的名字和形状。比如输入层可能被写成:
Input images 0 1 images如果端侧代码里用的输入名不是images,就要改这里保持一致。输出层同理。另外,如果模型有多个输出,param 文件末尾会有多行输出声明,要确认数量对得上。
提示:param 文件第一行是 magic number(通常是 7767517),第二行是层数和 blob 数,这两行不要动。改的是具体的层定义。
5. ncnn 推理验证与结果对齐
5.1 写一个最小推理程序
转换完不能只看文件生成了就完事,得实际跑一遍推理,确认输出正确。ncnn 的 C++ API 很简洁,一个最小推理程序大概长这样:
#include "net.h" #include <opencv2/opencv.hpp> int main() { ncnn::Net net; net.opt.use_vulkan_compute = false; net.load_param("yolov5s.param"); net.load_model("yolov5s.bin"); cv::Mat img = cv::imread("test.jpg"); // resize 到 640x640,归一化 ncnn::Mat in = ncnn::Mat::from_pixels_resize( img.data, ncnn::Mat::PIXEL_BGR, img.cols, img.rows, 640, 640); const float mean[3] = {0, 0, 0}; const float norm[3] = {1/255.f, 1/255.f, 1/255.f}; in.substract_mean_normalize(mean, norm); ncnn::Extractor ex = net.create_extractor(); ex.input("images", in); ncnn::Mat out; ex.extract("output0", out); // ... 解析输出 return 0; }这里有几个细节:from_pixels_resize做的是 letterbox 还是直接 resize,会影响精度。YOLOv5 训练时用的是 letterbox(保持长宽比、灰边填充),推理时最好也保持一致,否则检测框会偏。我一开始图省事直接 resize,结果小目标检测明显变差,后来改成 letterbox 才对上。
5.2 输出张量的解析
YOLOv5 的输出是[batch, num_anchors, 85]这种形状(85 = 4 框坐标 + 1 置信度 + 80 类别)。ncnn 的 Mat 输出需要按这个布局解析。三个输出分支对应三个尺度,每个尺度的 anchor 数量不同(80x80、40x40、20x20 网格乘以 3 个 anchor)。
解析的核心步骤:
- 遍历每个输出分支的每个 anchor。
- 取出框坐标(cx, cy, w, h)和置信度、类别分数。
- 置信度低于阈值的直接跳过。
- 坐标从特征图尺度还原到原图尺度(乘以 stride,再考虑 letterbox 的偏移和缩放)。
- 收集所有候选框,做 NMS。
NMS 建议自己用 C++ 实现,逻辑不复杂,但要注意 IoU 阈值的选择(一般 0.45)和类别间是否独立做 NMS(YOLOv5 默认类别独立)。
5.3 和 ONNX 结果做数值对齐
验证 ncnn 是否正确,最靠谱的方法是和 ONNX 输出逐元素对比。用同一张图、同样的预处理,分别跑 ONNX 和 ncnn,对比输出张量:
// 伪代码:对比 ncnn 输出和 ONNX 输出 float max_diff = 0; for (int i = 0; i < out.total(); i++) { float d = std::abs(out[i] - onnx_ref[i]); max_diff = std::max(max_diff, d); } printf("max diff: %f\n", max_diff);正常情况下 diff 应该在 1e-3 以内。如果 diff 很大,排查方向:预处理是否一致(归一化参数、letterbox)、输出层名字是否取对、param 文件是否被改错。
注意:ncnn 默认可能开启 fp16 存储(
opt.use_fp16_storage),这会引入一定精度损失。如果对精度要求高,可以关掉 fp16,用 fp32 推理,代价是速度稍慢、内存稍大。
6. 常见问题与排查技巧实录
6.1 转换阶段的高频报错
这一节我把实际踩过的坑整理成速查表,方便你对号入座:
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| onnx2ncnn 直接崩溃 | ONNX 图有 ncnn 不支持的算子 | 用 netron 看算子,换 opset 重导 |
| 转换成功但推理输出全 0 | 输入名/输出名不匹配 | 检查 param 文件首尾层名字 |
| 检测框位置整体偏移 | letterbox 预处理不一致 | 统一训练和推理的预处理方式 |
| 置信度普遍偏低 | 归一化参数错误 | 确认 mean/norm 和训练一致 |
| 小目标漏检严重 | 输入尺寸被压缩太小 | 提高输入分辨率或改小 stride |
| 推理速度远低于预期 | 未开多线程/未用 fp16 | 设置 num_threads,开启 fp16 |
6.2 精度对不上的排查思路
精度问题是转换里最头疼的,因为它不报错,只是结果“差一点”。我的排查顺序是:
- 先确认 PyTorch 和 ONNX 一致:如果这一步就不一致,问题在导出,不在 ncnn。
- 再确认 ONNX 和 ncnn 一致:用同一输入对比输出张量。
- 最后确认预处理一致:这是最容易被忽略的。训练时的归一化、letterbox 参数、BGR/RGB 顺序,任何一项不一致都会导致精度下降。
我遇到过一次,排查了两天,最后发现是 ncnn 里用了 BGR 而训练用的是 RGB,通道顺序反了,检测框全乱。这种问题不看代码根本发现不了。
6.3 性能优化的几个实用手段
转换跑通只是第一步,端侧还要考虑速度。几个实测有效的优化:
- 开启 fp16:
net.opt.use_fp16_packed = true; net.opt.use_fp16_storage = true;,ARM 上通常有 20%-40% 提速。 - 多线程:
net.opt.num_threads = 4;,根据 CPU 核心数设置,不是越多越好,超过物理核心数反而下降。 - 输入尺寸权衡:640 降到 416,速度大概提升一倍,精度降几个点,看业务能不能接受。
- 算子融合:ncnn 编译时开启
NCNN_OPENMP和NCNN_THREADS,能自动并行化部分算子。
提示:优化前一定要先建立性能基线,用同一张图跑 100 次取平均,别凭感觉说“快了”。我见过有人改了参数觉得快了,一测反而慢了,因为引入了额外的内存拷贝。
6.4 版本升级带来的兼容性坑
ncnn 更新比较活跃,新版本可能改了 param 格式或者算子行为。如果你从旧版本升级,可能会遇到:
- 旧 param 文件在新版本加载失败(格式变了)。
- 某些算子行为微调,导致输出有细微差异。
- 编译选项默认值变化。
我的建议是:生产环境锁定 ncnn 版本,不要盲目追新。升级前先在测试环境跑一遍完整的数值对齐,确认没问题再上。
7. 我在这条链路上的一些个人体会
整条 torch→onnx→ncnn 链路走下来,最大的感受是:问题往往不在转换工具本身,而在“一致性”。训练和推理的预处理要一致、导出和推理的维度要一致、ONNX 和 ncnn 的算子语义要一致。任何一个环节的“差不多”,最后都会变成精度上的“差很多”。
另外一个体会是,别怕看 param 文件。很多人把它当黑盒,出问题就懵。其实它就是纯文本,每一层什么类型、输入输出是什么、参数多少,写得清清楚楚。花十分钟学会看它,排查效率能翻倍。
最后分享一个小技巧:转换完成后,先别急着集成到端侧工程里,用 ncnn 自带的benchncnn工具跑一下,确认模型能加载、能推理、速度大概多少。这一步能帮你快速判断模型本身有没有问题,而不是把工程问题和模型问题混在一起排查。等 benchncnn 跑通了,再往业务代码里集成,心里就有底了。