简介:基于ncnn部署yolov5及量化的完整工程包,面向移动端与嵌入式视觉开发者,聚焦如何将PyTorch版YOLOv5转换为ncnn格式,并通过INT8量化压缩模型体积、提升推理速度,解决资源受限设备上的实时目标检测部署难题。压缩包共257个文件,约205MB,包含141个C++头文件、onnx/pt原始与中间权重、ncnn的param/bin优化模型、proto协议文件、CMake构建脚本、lib静态库、exe工具以及Visual Studio完整解决方案(sln/vcxproj)与编译中间产物,目录结构完整,既可用于学习转换流程,也可直接承接二次开发。源码覆盖模型加载、预处理、推理、后处理NMS和CPU多核/OpenMP加速等关键环节,并含有优化前后的模型文件对比;量化方面给出离线转换与参数优化后的产物,配合依赖库和VS工程配置,能显著降低环境搭建与排错成本。资源已有486人学习,适合有深度学习基础、正在将检测算法落地到移动端的工程师参考复用,也可作为团队内部技术预研的起点。
1. 用ncnn量化部署yolov5:先想清楚整条链路
在边缘端跑目标检测,很多人踩的第一个坑不是模型精度不够,而是训练好的yolov5权重没法直接被CPU推理库读取。PyTorch的.pt文件绑定了Python运行时和算子逻辑,要落到Android、嵌入式Linux或国产CPU上,通常先把模型导出成ONNX,再转成ncnn的.param/.bin格式,最后做fp16或int8量化。这条链路解决的是“推理框架选型、模型体积压缩、精度损失可控”三个问题:ncnn不依赖CUDA和第三方运行时,int8量化后模型体积能压到原始fp32的四分之一,单核CPU也能跑到可用帧率。适合做边缘计算盒、工业检测终端,或者只是想把几十MB的模型瘦身上线的人。
2. yolov5转ncnn:ONNX导出与量化选型
2.1 导出yolov5的ONNX时关掉哪些开关
用ncnn跑yolov5,第一步是把.pt转成ONNX。官方仓库的export.py封装了导出逻辑,但有几个参数直接影响后续onnx2ncnn是否顺利。
python export.py --weights yolov5s.pt --img 640 --batch 1 \ --include onnx --opset 12 --simplify这里--img 640让输入是固定尺寸,--batch 1锁定动态batch,--opset 12是ncnn兼容性最好的算子集版本。--simplify需要先安装onnx-simplifier,它会折叠掉一些冗余的Reshape和Transpose,让计算图更干净。
导出后建议用onnx.checker或onnxruntime跑一次推理确认没有NaN输出。常见问题是漏加--simplify导致图里有一长串Identity节点;或者用了--dynamic导出动态尺寸,虽然ncnn后来也支持动态shape,但动态维度会把量化校准表弄复杂。我一般固定640输入,客户端再自己做letterbox。
yolov5的Detect头在导出时会被特殊处理,输出是三个尺度的检测结果拼在一起,ONNX末尾是一个Concat和Reshape,最终输出维度是[1, 25200, 85](coco 80类)。ncnn对这类图很熟悉,只要没引入自定义算子,转换通常一遍过。
2.2 ONNX算子兼容性:onnx2ncnn最常见失败点
onnx2ncnn失败往往不是框架问题,而是onnx里出现ncnn尚未支持的op。yolov5常见兼容性风险如下:
| 算子/行为 | 风险 | 处理方式 |
|---|---|---|
opset >= 13的Split | ncnn旧版本不认num_outputs属性 | 固定用--opset 12导出 |
DynamicQuantizeLinear | 量化算子转换麻烦 | 导出时不要启动--int8,ncnn侧自己做量化 |
Swish/SiLU | ncnn已支持,但某些源码版本未注册 | 升级ncnn到最新release |
| 自定义NMS层 | onnx不支持NMS导出 | 把NMS放到后处理C++代码里 |
输入名不是images | 后续写代码容易搞混 | 导出后用onnx.reshape固定输入名 |
遇到转换报错,别急着改模型结构,先看ncnn源码的onnx2ncnn.cpp里有没有对应op实现。yolov5大部分层不是难点,真正容易卡的是opset版本和Split的num_outputs。把opset降到12,绝大多数兼容性问题都能绕开。
2.3 fp16还是int8:量化粒度与精度、速度的矛盾
ncnn的“量化”通常分两级。第一级是fp16存储,把权重从32位浮点改成16位浮点,模型体积减半,精度几乎没有损失;第二级是int8存储,用校准数据集统计每层输入的数值范围,把浮点权重和激活映射到8位整数,体积再减一半,同时利用ARM的int8指令(如dotprod)获得明显加速。
两者的选择要看硬件和场景。下表是我常用的选型参考:
| 指标 | fp16 | int8 |
|---|---|---|
| 模型体积 | 约为fp32的50% | 约为fp32的25% |
| 精度损失 | 基本无损,mAP下降<0.1% | 通常下降0.5%~2.0%之间 |
| 速度提升 | CPU上有限,GPU/Vulkan上明显 | ARMv8.2+平台有较大提升 |
| 是否需要校准集 | 不需要 | 需要100~1000张有代表性图片 |
| 部署难度 | 低 | 中,需处理输出层量化偏差 |
如果你的目标设备是带Vulkan的GPU,fp16已经能拿到很大收益。如果设备是ARM CPU,尤其支持int8点积指令,int8才是真正的性能关键。yolov5s转int8后,在RK3588这类平台上延迟能比fp16再降30%以上,代价是mAP可能掉1个点左右。量化工作大多集中在“校准集怎么选”和“哪些层不量化”这两个问题上。
3. 在Ubuntu下生成onnx2ncnn并完成yolov5量化
3.1 编译ncnn工具链:依赖与最小cmake
onnx2ncnn和量化工具都在ncnn源码里,先克隆再编译:
git clone https://github.com/Tencent/ncnn.git cd ncnn mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release \ -DNCNN_BUILD_TOOLS=ON \ -DNCNN_BUILD_EXAMPLES=ON \ -DNCNN_VULKAN=ON \ -DNCNN_BUILD_BENCHMARK=ON .. make -j$(nproc)重点说两个选项:-DNCNN_BUILD_TOOLS=ON会编译出onnx2ncnn、ncnnoptimize、ncnn2table、ncnnint8这些可执行文件;-DNCNN_VULKAN=ON让ncnn在支持Vulkan的机器上能调用GPU,量化时也更快。如果目标设备没有Vulkan,这里可以关掉,但后续C++工程里也要保持和编译时一致。
编译依赖需要protobuf、opencv和glslang。Ubuntu下直接apt install libprotobuf-dev protobuf-compiler libopencv-dev就够。不建议用系统自带的老protobuf,首选protobuf 3.x版本。
编译完成后检查build/tools/onnx/onnx2ncnn是否存在。这一步很关键:很多人下载了别人编译好的ncnn,换机器跑onnx2ncnn报缺少.so,自己编译一次能省掉很多环境问题。
3.2 onnx2ncnn把yolov5s.onnx转成fp16 param/bin
./build/tools/onnx/onnx2ncnn yolov5s.onnx yolov5s.param yolov5s.bin命令格式是“输入onnx + 输出param + 输出bin”。转出来的param是文本格式的图结构,bin是二进制的权重数据。yolov5s转出来大概几十MB,这时的bin还是fp32原始权重。
转完先看param尾部,通常会有Reshape和Concat组成的Detect层结构。如果中间有MemoryData类型的节点数量远多于常规CNN,说明ONNX里有大量常量折叠没做干净,回到export.py加上--simplify重新导出。
3.3 ncnnoptimize图优化:fp16存储的关键一步
./build/tools/optimize/ncnnoptimize yolov5s.param yolov5s.bin yolov5s-opt.param yolov5s-opt.bin 1最后一个参数1表示开启fp16存储。这个工具会先做一次常量折叠和算子融合,再把能转fp16的权重转成半精度。只看体积的话,转完的bin应当比原始bin小一半左右。
注意,ncnnoptimize也会把一些层融合成Gemm、ConvolutionDepthWise这类ncnn自研实现,后续表格文件是基于优化后的模型生成的。所以流程顺序一定是:先ncnnoptimize,再拿优化后的param/bin去量化,不要在原始ONNX转出来的模型上直接跑int8。
3.4 int8校准集:从yolov5训练集里抽多少张合适
int8量化需要统计激活值的真实分布,校准集要有代表性。不要用测试集,也不要只拿一类目标。常见做法是从训练集里随机抽200~2000张,覆盖不同光照、不同目标尺寸和不同背景。
配一个文本文件,每行一张图片的绝对路径:
/home/user/calib/0001.jpg /home/user/calib/0002.jpg /home/user/calib/0003.jpg图片分辨率不一定要和训练尺寸一致,ncnn2table在处理时会先做一次resize到目标shape。但尽量选接近原始训练分布的数据。如果目标是检测小物体,校准图里不能全是大目标场景,否则量化会把小目标对应的激活值截断掉。
3.5 跑ncnn2table和ncnnint8的完整命令与参数
./build/tools/quantize/ncnn2table yolov5s-opt.param yolov5s-opt.bin \ calib_list.txt yolov5s.table \ mean=0,0,0 norm=0.003921,0.003921,0.003921 shape=640,640,3 pixel=0 thread=8 method=kl参数含义如下:
mean和norm表示预处理参数,这里对应yolov5做归一化时除以255,mean填0。shape是输入宽高和通道,顺序是W,H,C。如果yolov5输入是640x640,就写640,640,3。pixel=0表示输入按BGR顺序,这一点和PyTorch训练时保持一致。thread=8用8线程加速校准数据前向,数据集大时能明显省时间。method=kl是校准算法。kl对yolov5这类检测模型通常更稳,实测中比min/max损失小;但数据分布极端时也可以试aciq。
生成yolov5s.table后,再执行:
./build/tools/quantize/ncnnint8 yolov5s-opt.param yolov5s-opt.bin \ yolov5s-int8.param yolov5s-int8.bin yolov5s.table这一步把fp16的model计算成int8权重,输出新的param和bin。拿到yolov5s-int8.param后先grep一下Quantize和Dequantize节点数量,如果detect层前没有Dequantize,说明输出层被强行量化了,后续做后处理时大概率会出现类型不符。此时要用编辑器把param里对应输出层的flag从1改成0,或者从原始模型copy一个未量化的检测头拼接进去。
4. 用C++推理代码调用量化后的yolov5
4.1 Net加载int8模型并设置线程和低功耗
ncnn不是像OpenCV那样调一个函数就完事,它要求你先加载结构文件param,再加载权重bin,之后通过Extractor做推理。
#include "net.h" ncnn::Net net; net.opt.use_vulkan_compute = true; net.opt.num_threads = 4; net.opt.use_fp16_packed = true; net.opt.use_fp16_storage = true; net.opt.use_bf16_storage = false; net.load_param("yolov5s-int8.param"); net.load_model("yolov5s-int8.bin");load_param和load_model的顺序不能颠倒。use_vulkan_compute要和你编译ncnn时开不开Vulkan对应;设备不支持时打开只会在运行时提示vulkan not supported,不会崩溃。use_fp16_packed和use_fp16_storage对int8模型影响不大,但对fp16模型非常关键。
4.2 预处理:letterbox保持宽高比并填成640x640
直接resize会破坏目标宽高比,导致检测框偏移。yolov5官方用了letterbox,也就是等比缩放后填充灰色边缘。
#include "mat.h" int letterbox(const cv::Mat& src, cv::Mat& dst, int target_w, int target_h) { float ratio = std::min(1.0f * target_w / src.cols, 1.0f * target_h / src.rows); int new_w = std::round(src.cols * ratio); int new_h = std::round(src.rows * ratio); cv::resize(src, dst, cv::Size(new_w, new_h)); int pad_w = (target_w - new_w) / 2; int pad_h = (target_h - new_h) / 2; cv::copyMakeBorder(dst, dst, pad_h, target_h - new_h - pad_h, pad_w, target_w - new_w - pad_w, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); return 0; } // 调用 cv::Mat rgb; cv::cvtColor(bgr_img, rgb, cv::COLOR_BGR2RGB); cv::Mat letterboxed; letterbox(rgb, letterboxed, 640, 640); ncnn::Mat in = ncnn::Mat::from_pixels(letterboxed.data, ncnn::Mat::PIXEL_RGB, letterboxed.cols, letterboxed.rows); const float mean[3] = {0.f, 0.f, 0.f}; const float norm[3] = {1 / 255.f, 1 / 255.f, 1 / 255.f}; in.substract_mean_normalize(mean, norm);这里有个容易错的地方:ncnn的from_pixels顺序有PIXEL_RGB和PIXEL_BGR两种。如果训练时用了RGB顺序且量化table里写pixel=0,推理代码就要用PIXEL_BGR或pixel=1保持一致。我通常会统一成RGB,避免在调试时纠结颜色通道反了导致检测置信度不稳。
4.3 后处理:从输出Mat到NMS
yolov5的ONNX输出是一个[1, 25200, 85]的二维展开张量,每行是cx,cy,w,h,obj,cls0,cls1,...。ncnn读取时通常把它当成三维Mat,但实际数据是连续排布的,直接按out.channel(0)取指针就够。
#include "layer.h" std::vector<cv::Rect> boxes; std::vector<float> scores; std::vector<int> class_ids; ncnn::Mat out; ex.extract("output", out); int num_boxes = out.h; // 25200 int num_classes = out.c - 5; for (int i = 0; i < num_boxes; ++i) { const float* ptr = out.row(i); float obj_conf = ptr[4]; if (obj_conf < 0.25) continue; for (int j = 0; j < num_classes; ++j) { float cls_conf = ptr[5 + j]; float final_conf = obj_conf * cls_conf; if (final_conf > 0.25) { float cx = ptr[0]; float cy = ptr[1]; float w = ptr[2]; float h = ptr[3]; // 记录框和分数,等NMS } } } // 用标准NMS过滤重叠框 ncnn::nms(boxes, scores, class_ids, 0.45, ncnn::NMS_SUPPRESS);out.h不一定等于25200,取决于param里的Reshape排列。如果发现out.c是25200而out.h是85,就把行列互换,排查方法是在一个已知图片上打印out.w, out.h, out.c和第一个值的关系。
5. 量化后的精度验证、敏感度分析与混合量化技巧
5.1 用验证集和mAP@0.5判断量化损失
int8量化不是转完就能上线,先做一次精度对比。用同一批验证图片,原fp16模型和int8模型分别跑推理,统计mAP@0.5。不想写完整mAP脚本,可以直接对比每个类别的召回率或前100张图的平均置信度差异。
yolov5官方val.py只能测PyTorch模型。ncnn模型通常我会写一个轻量脚本:读入图片,通过ncnn推理输出框,再和你训练时的.txt标注计算IoU。量化损失超过2个mAP点就说明校准集或参数不对,不要继续往下做部署。
5.2 逐层敏感度分析与混合量化
有时候不是所有层都适合int8。比如第一个卷积直接吃原始像素,量化误差容易放大;Detect层前面的卷积决定最终输出,也常出现漂移。ncnnint8生成的param里,每一层后面会带一个整型flag,1表示该层被量化为int8。
把敏感层的flag改为0后,保留该层为fp16计算,这就是ncnn的混合量化。修改param后重新加载,bin不用换。实际操作时我习惯先量化全部,然后逐层改回fp16看mAP变化,找前三名最敏感层。这个过程虽然手动,但效果通常比强行全int8好,而且计算量不大,因为只需要改文本文件。
5.3 校准集和超参数的几个坑
校准集数量不是越大越好。我用过2000张和300张,后者经常出更好的mAP,因为校准集太杂会把激活值分布拉宽,导致量化步长过大。校准算法选择上,kl适合正态分布的数据,aciq在低比特量化时更稳。如果目标设备是ARMv8.2且支持int8点积,注意打开net.opt.use_bf16_storage = false,否则某些层可能悄悄回退到bf16。另一个容易忽略的是输出层前后的Dequantize节点:要确认Detect前是fp32输出,否则后处理里的阈值会变得不可理喻。遇到这种问题,直接在param里把Detect前的卷积flag改成0,让检测头保持fp16精度。
本文还有配套的精品资源,点击获取