1. 模型转换链路的整体设计与选型逻辑把YOLOv5从PyTorch一路搬到ncnn上跑这个事在边缘计算和移动端部署里算是经典操作了。整条链路说白了就是三次“翻译”PyTorch的权重先导出成ONNX这个中间格式再把ONNX转成ncnn自己的模型文件最后在目标平台上用ncnn的推理接口跑起来。为什么非要绕这么大一圈因为PyTorch那套动态图加Python运行时的组合在服务器上跑跑还行真塞进手机或者嵌入式板子里光是依赖体积和内存占用就够喝一壶的。ncnn是纯C写的没有第三方依赖编译出来就一个静态库ARM架构上跑起来效率相当能打这才是它被大量移动端项目选中的根本原因。ONNX在这条链路里扮演的是“通用中间语言”的角色。PyTorch和ncnn之间没有直接的转换通道ONNX相当于一个大家都认的交换格式。PyTorch官方对ONNX导出的支持比较成熟ncnn这边也有专门的onnx2ncnn工具来解析ONNX。中间加这一层好处是解耦——PyTorch版本升级了只要ONNX导出接口不变后面的流程基本不受影响ncnn要支持新的算子也只需要改onnx2ncnn的解析逻辑。这种设计思路在工程上很常见就像两个说不同语言的人通过一个翻译来沟通翻译本身要准确但双方不需要互相学对方的语言。YOLOv5这个模型本身有几个特点值得注意。它用的是CSPDarknet作为骨干网络里面大量使用了Focus层新版本改成了卷积、C3模块、SPP结构这些在ONNX里都有对应的算子表达。但问题在于YOLOv5的输出层有三个不同尺度的特征图每个特征图上要做解码操作这部分在PyTorch里是写在forward函数里的导出ONNX的时候需要特别处理。如果直接把整个模型扔进去导出解码部分会被当成计算图的一部分转ncnn的时候容易出问题。所以通常的做法是只导出到检测头之前的特征图输出解码逻辑在ncnn这边用C重新实现。这样做的好处是转换过程更稳定而且解码部分用C写性能也更好控制。还有一个选型上的考量是版本匹配。PyTorch、ONNX、ncnn这三个工具的版本之间是有兼容性要求的。比如PyTorch 1.7和1.8导出的ONNXopset版本不一样ncnn的onnx2ncnn工具对opset的支持也有差异。我一般建议用PyTorch 1.7到1.9之间的版本ONNX opset选11或者12ncnn用2021年之后的版本这个组合经过大量项目验证踩坑概率最低。太新的版本反而容易遇到算子不支持的问题因为ncnn的更新节奏跟PyTorch不完全同步。注意整个转换链路中最容易被忽视的是输入尺寸的固定。YOLOv5默认支持动态输入但ncnn对动态shape的支持有限导出ONNX时一定要把输入尺寸固定下来比如1x3x640x640否则后面转ncnn会报各种奇怪的错误。2. 从PyTorch到ONNX的导出细节与避坑要点2.1 导出前的模型准备与参数设置导出之前有几件事必须先做。第一是加载模型权重YOLOv5的官方代码里提供了export.py脚本但直接拿来用往往不够灵活。我习惯自己写一个导出脚本把模型加载、推理模式切换、输入构造这几步控制得更精细。模型加载用torch.load加载.pt文件然后取model.model拿到纯网络结构注意要把模型设为eval模式否则BN层和Dropout的行为会和推理时不一致导出的ONNX会带上训练专用的节点。输入构造这块需要创建一个符合模型输入要求的张量。YOLOv5的输入是NCHW格式通常是1x3x640x640。这里有个细节输入张量的值域要和训练时一致YOLOv5训练时做了归一化像素值除以255所以构造输入时要么用真实图片做预处理要么直接用torch.rand生成0到1之间的随机数。如果用torch.randn生成标准正态分布的数导出的ONNX虽然能转但推理结果会完全不对因为数值范围超出了模型预期的输入分布。导出时的opset_version参数很关键。opset 11对大多数YOLOv5的算子支持都很好包括Resize、Concat、Slice这些。opset 12增加了对某些新算子的支持但ncnn的解析器可能还没跟上。我实测下来opset 11是最稳的选择。另外要设置do_constant_foldingTrue这样一些常量计算会在导出时被折叠掉生成的ONNX图更简洁转ncnn时出错概率更低。import torch model torch.load(yolov5s.pt, map_locationcpu)[model].float() model.eval() dummy_input torch.rand(1, 3, 640, 640) torch.onnx.export( model, dummy_input, yolov5s.onnx, opset_version11, do_constant_foldingTrue, input_names[images], output_names[output], dynamic_axesNone )这段代码里dynamic_axes设为None就是强制固定输入尺寸。如果设了动态轴导出的ONNX会带动态维度ncnn那边处理起来很麻烦。output_names只给了一个名字因为YOLOv5的forward返回的是三个特征图的拼接结果实际上是一个列表但ONNX导出时会被合并成一个输出。这个细节后面转ncnn的时候要注意可能需要手动拆分。2.2 导出后的ONNX模型验证导出完成不代表万事大吉必须做一步验证。最直接的方法是用onnxruntime加载ONNX模型跑一遍推理和PyTorch的输出做对比。如果两者的输出差异在1e-4以内说明导出是成功的。如果差异很大通常是某个算子导出时行为不一致比如Resize的插值模式、Pad的填充方式等。验证的时候还要检查ONNX模型的结构。用netron这个工具打开ONNX文件看看计算图是否完整有没有多余的节点。特别要注意的是YOLOv5里的SiLU激活函数也叫Swish在ONNX里是用Sigmoid加Mul实现的如果看到这两个算子成对出现说明导出正常。如果看到的是HardSwish或者其他变体那可能是模型版本不对。还有一个常见问题是输出节点的数量。YOLOv5有三个检测头分别对应80x80、40x40、20x20的特征图。导出ONNX后这三个输出可能会被合并成一个大的输出张量形状是1x25200x85以640x640输入、80类为例。25200是三个尺度上所有锚框的总数85是4个坐标加1个置信度加80个类别概率。如果输出形状不对后面解码就会全乱套。实操心得导出ONNX后我习惯用onnxsim这个工具做一次简化。它会合并一些冗余算子把常量折叠做得更彻底生成的ONNX更干净。简化后的模型转ncnn成功率明显更高而且推理速度也会快一点。3. ONNX转ncnn的完整流程与参数调优3.1 onnx2ncnn工具的使用与输出文件解析ncnn提供了onnx2ncnn这个命令行工具用法很直接onnx2ncnn yolov5s.onnx yolov5s.param yolov5s.bin。执行完会生成两个文件.param是网络结构描述文件.bin是权重二进制文件。.param文件是纯文本的可以用文本编辑器打开看里面每一行描述一个层包括层类型、名称、输入输出、参数等。.bin文件是二进制不用管它但要注意它的体积如果异常小说明权重没导出来。转换过程中最常见的报错是“Unsupported operator”。ncnn不是支持所有ONNX算子遇到不支持的会直接报错退出。YOLOv5里可能遇到的坑包括Resize算子的坐标变换模式coordinate_transformation_mode如果设成了align_corners以外的值ncnn可能不认Slice算子的steps参数如果大于1也可能不支持。遇到这种情况要么改ONNX里的算子属性要么在ncnn这边手动实现对应的层。转换成功后.param文件里会有一个输入层和若干个输出层。输入层的名字通常是“images”输出层可能叫“output”或者别的。要确认输出层的数量YOLOv5应该有至少一个输出如果导出时做了特殊处理可能有三个。输出层的名字后面解码的时候要用到所以得记下来。3.2 param文件的必要修改onnx2ncnn生成的.param文件不能直接拿来用通常需要手动改几处。第一处是输入层原始的可能写成Input images 0 1 images但ncnn的输入层格式要求更明确一般要改成Input images 0 1 images看起来一样但有时候需要调整后面的维度信息。更关键的是如果输入尺寸是固定的可以在param里把输入层改成Input images 0 1 images 1 3 640 640这样ncnn就知道输入的具体形状。第二处是输出层。如果ONNX导出时输出是一个合并的大张量ncnn这边可能只有一个输出层。但YOLOv5的解码需要三个不同尺度的特征图所以要么在导出ONNX时就拆成三个输出要么在ncnn里用Slice层把大张量切开。我一般倾向于在导出ONNX时就处理好让ONNX直接输出三个特征图这样ncnn这边结构更清晰。第三处是某些层的参数调整。比如Resize层ncnn的Resize和ONNX的Resize在参数含义上可能有细微差别需要对照ncnn的文档确认。还有Concat层如果输入的顺序和ONNX里不一致拼接结果就会错。这些细节在param文件里都能看到改起来不难但需要耐心。# 转换命令 onnx2ncnn yolov5s.onnx yolov5s.param yolov5s.bin # 如果报错可以加verbose参数看详细日志 onnx2ncnn yolov5s.onnx yolov5s.param yolov5s.bin 2log.txt转换完成后可以用ncnn自带的benchmark工具测试一下模型能不能正常加载和推理。benchncnn这个工具会跑一遍前向计算输出每层的耗时。如果加载失败它会报错根据错误信息再回去改param文件。3.3 模型量化与优化选项ncnn支持模型量化可以把FP32的权重转成FP16或者INT8模型体积能缩小一半甚至更多推理速度也有提升。FP16量化比较简单用ncnn2fp16工具就能做精度损失很小基本可以忽略。INT8量化需要校准数据流程复杂一些但收益也更大特别是在ARM CPU上INT8的推理速度可能是FP32的两三倍。量化的时机是在onnx2ncnn之后。先生成FP32的param和bin然后用ncnn2fp16或者ncnn2int8工具转换。FP16量化后的文件后缀会变成.param和.bin但内容已经是FP16了。INT8量化会额外生成一个校准表文件推理时需要加载这个表。注意量化不是万能的。如果模型里有对数值精度敏感的层比如Softmax或者Sigmoid量化后精度可能会下降。YOLOv5的检测头里有SigmoidINT8量化后mAP可能会掉几个点。如果对精度要求高建议只用FP16量化或者对检测头部分保持FP32。4. ncnn推理代码的编写与解码实现4.1 加载模型与输入预处理ncnn的C接口很简洁加载模型就两行代码net.load_param(yolov5s.param)和net.load_model(yolov5s.bin)。加载完成后创建一个Extractor设置输入然后跑forward。输入数据的预处理要和训练时一致YOLOv5的预处理是图片缩放到640x640保持长宽比多余部分用灰色填充然后像素值归一化到0到1。ncnn的Mat类用来存数据输入是一个1x3x640x640的Mat。填充的时候要注意ncnn的Mat是通道优先的和OpenCV的Mat格式不一样。从OpenCV的Mat转到ncnn的Mat需要逐通道拷贝数据。如果图片是BGR格式还要转成RGB因为YOLOv5训练时用的是RGB。ncnn::Net net; net.load_param(yolov5s.param); net.load_model(yolov5s.bin); ncnn::Mat in ncnn::Mat::from_pixels_resize( rgb_data, ncnn::Mat::PIXEL_RGB, img_w, img_h, 640, 640 ); const float mean_vals[3] {0.f, 0.f, 0.f}; const float norm_vals[3] {1/255.f, 1/255.f, 1/255.f}; in.substract_mean_normalize(mean_vals, norm_vals); ncnn::Extractor ex net.create_extractor(); ex.input(images, in);这段代码里from_pixels_resize会自动做缩放但不会保持长宽比。如果要保持长宽比需要自己算缩放比例先缩放再填充。填充用ncnn::copy_make_border函数填充值设为114YOLOv5默认的灰色填充值。4.2 输出解码与NMS后处理ncnn跑完forward后输出是三个特征图形状分别是1x255x80x80、1x255x40x40、1x255x20x20。255是3个锚框乘以854个坐标加1个置信度加80个类别。解码的过程就是把这些特征图上的每个点还原成边界框坐标。解码的步骤对于每个特征图上的每个点取对应的锚框计算边界框的中心坐标和宽高。中心坐标是sigmoid后的值乘以2减去0.5再加上网格坐标。宽高是sigmoid后的值乘以2的平方再乘以锚框的宽高。这些计算在PyTorch里是自动的但在ncnn里要手动实现。// 以80x80特征图为例 const float* feat out80.channel(0); for (int c 0; c 3; c) { for (int y 0; y 80; y) { for (int x 0; x 80; x) { // 取85个值 float obj_conf sigmoid(feat[4]); if (obj_conf 0.5) continue; float cx (sigmoid(feat[0]) * 2 - 0.5 x) * stride; float cy (sigmoid(feat[1]) * 2 - 0.5 y) * stride; float w pow(sigmoid(feat[2]) * 2, 2) * anchor_w; float h pow(sigmoid(feat[3]) * 2, 2) * anchor_h; // 类别概率 for (int k 0; k 80; k) { float cls_conf sigmoid(feat[5 k]); float score obj_conf * cls_conf; // 保存框 } feat 85; } } }解码完成后所有框要一起做NMS。NMS的阈值一般设0.45置信度阈值设0.25。NMS的实现可以用ncnn自带的nms函数也可以自己写。自己写的话先按置信度排序然后逐个比较IoU大于阈值的就抑制掉。4.3 性能优化与多线程处理ncnn默认是单线程的可以通过设置线程数来加速。net.opt.num_threads 4可以开4个线程在ARM多核CPU上效果很明显。还有net.opt.use_packing_layout true这个选项会让ncnn用打包布局来存数据对某些算子有加速效果。net.opt.use_fp16_packed和net.opt.use_fp16_storage在支持FP16的硬件上能进一步提速。如果是在移动端还可以用ncnn的Vulkan后端。Vulkan是跨平台的GPU接口ncnn对它的支持已经比较成熟了。开启Vulkan后卷积等计算会跑在GPU上速度比CPU快很多。但Vulkan的初始化有开销适合连续推理的场景单次推理反而可能更慢。实操心得在ARM CPU上num_threads设成4或者8效果最好再大反而会因为线程调度开销导致性能下降。另外输入图片的尺寸对速度影响很大640x640比320x320慢4倍左右如果对精度要求不高可以适当降低输入尺寸。5. 常见问题排查与实战避坑指南5.1 转换阶段的典型报错与解决转换过程中最容易遇到的是算子不支持的问题。比如YOLOv5里的Focus层在旧版本里是用切片加拼接实现的ONNX里对应的是Slice和Concat。ncnn对Slice的支持有限如果步长不是1就会报错。解决办法是把Focus层替换成普通的卷积层YOLOv5的新版本已经这么做了直接用新版本的模型就能避开这个坑。另一个常见问题是Resize算子的模式不匹配。ONNX的Resize有几种插值模式ncnn只支持最近邻和双线性。如果ONNX里用的是三次插值ncnn就会报错。这时候需要改ONNX里的Resize节点把模式改成双线性。用onnx的Python API可以改或者用onnxsim重新导出。还有Concat层的轴参数问题。ONNX的Concat可以指定任意轴ncnn的Concat默认是按通道拼接。如果ONNX里是按宽度拼接ncnn这边就要调整。这种情况一般出现在特征图融合的地方需要仔细对照param文件里的参数。报错信息可能原因解决方法Unsupported operator: Slice步长不为1替换Focus层为卷积Unsupported operator: Resize插值模式不支持改为双线性插值Concat axis mismatch拼接轴不一致调整param文件中的axis参数Output shape mismatch输出层数量不对检查ONNX输出节点5.2 推理阶段的精度问题排查模型转过去了但推理结果不对这种情况比转换报错更让人头疼。最常见的原因是预处理不一致。PyTorch训练时用的归一化参数是mean[0,0,0]norm[1/255,1/255,1/255]如果ncnn这边用了别的参数结果就会偏。还有图片的通道顺序PyTorch用RGBOpenCV默认是BGR忘了转换的话颜色通道就反了检测结果会乱。解码逻辑的错误也很常见。YOLOv5的锚框是预先定义好的三组锚框分别对应三个尺度。如果锚框的数值用错了或者网格坐标的计算方式不对框的位置就会偏。我建议先用一张已知结果的图片做测试把ncnn的输出和PyTorch的输出逐层对比看哪一层的差异最大就能定位到问题所在。还有一个隐蔽的问题是sigmoid的实现。ncnn的sigmoid和PyTorch的sigmoid在数值上可能有微小差异特别是在输入值很大的时候。这种差异通常不影响最终结果但如果置信度阈值设得很高可能会导致某些框被漏掉。解决办法是适当降低置信度阈值或者用查表法实现sigmoid。5.3 性能调优的实战经验推理速度慢是另一个常见问题。在ARM CPU上YOLOv5s跑640x640单线程大概要200毫秒4线程能降到60毫秒左右。如果还嫌慢可以试试这几个方向降低输入尺寸到320x320速度能提升到15毫秒左右但小目标的检测精度会下降用INT8量化速度能再快一倍但需要校准数据用Vulkan后端在支持GPU的设备上能到10毫秒以内。内存占用也是移动端要关注的。ncnn的模型加载后权重占一部分内存推理时的中间特征图占另一部分。YOLOv5s的权重文件大概14MBFP16量化后7MBINT8量化后3.5MB。中间特征图的内存和输入尺寸成正比640x640输入下大概需要几十MB。如果内存紧张可以减小输入尺寸或者用ncnn的lightmode它会释放中间层的内存但会增加计算时间。注意ncnn的lightmode和num_threads不能同时开。lightmode会禁用多线程优化开了之后速度会明显下降。如果内存够用建议关掉lightmode用多线程来加速。6. 从转换到部署的完整链路复盘整条链路走下来最耗时的部分其实是调试。导出ONNX可能只要几分钟但验证和修bug可能要花几个小时。我的习惯是每完成一步就做一次验证导出ONNX后用onnxruntime跑一遍转ncnn后用benchncnn跑一遍写推理代码后用测试图片跑一遍。每一步都确认无误再往下走这样出问题的时候容易定位。版本管理也很重要。PyTorch、ONNX、ncnn的版本组合最好固定下来写在一个requirements文件里。我见过太多因为版本升级导致转换失败的案例明明之前跑得好好的换了台机器就不行了。把版本锁死能省掉很多麻烦。最后说一个容易被忽视的点ncnn的param文件里层的顺序和ONNX里的节点顺序不一定完全一致。onnx2ncnn会做一些优化比如合并BN层到卷积里这会导致param文件里的层数和ONNX节点数对不上。这是正常的不用慌。只要推理结果对层数多少无所谓。我在实际项目里踩过最大的坑是输入尺寸的动态性。有一次为了支持不同尺寸的输入在导出ONNX时设了动态轴结果转ncnn后推理结果完全错乱。后来改成固定尺寸重新导出问题就消失了。ncnn对动态shape的支持确实有限如果业务上需要多尺寸输入建议导出多个固定尺寸的模型根据输入大小选择对应的模型来跑虽然麻烦一点但稳定可靠。