ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++实战:使用ncnn部署YOLOv5实例分割模型全流程解析

C++实战:使用ncnn部署YOLOv5实例分割模型全流程解析 简介实例分割是计算机视觉中的核心任务在移动端和嵌入式设备上高效部署成为工程难点。YOLOv5-seg结合目标检测与像素级掩码生成ncnn作为轻量级推理框架针对ARM平台深度优化为C环境下的实时推理提供方案。本文从模型转换链路出发讲解将.pt转换为.param/.bin的步骤并深入解析letterbox预处理、输出结构解析、NMS与mask后处理等关键技术细节帮助开发者在资源受限场景下实现高效部署。 直接用C跑yolov5分割模型这个需求最近问的人是真的多。我自己在RK3588和手机端折腾过好几轮走了不少弯路也攒了些经验。网上关于yolov5检测的ncnn部署教程一抓一大把但一到分割实例分割版本资料就明显变少而且很多都是只给代码不给原理照着抄都不知道自己在抄什么。这篇就把我实际跑通的完整流程拆开讲清楚从模型转换到C推理再到mask后处理全程都有代码和踩坑记录。先说这个项目本身。yolov5的C版本使用ncnn进行分割本质上是把yolov5-seg训练出来的.pt模型转换成ncnn框架能读的.param和.bin格式然后在C环境里完成前处理、推理、后处理三件事。最终效果就是输入一张图片输出每个目标的检测框、类别和逐像素的mask掩码。这个方案最典型的使用场景是移动端App、边缘计算盒子、嵌入式设备因为这些平台要么跑不动Python环境要么对推理延迟和内存占用极其敏感。这篇博文适合下面几类人已经在用yolov5训练分割模型、想把它挪到C环境部署的把yolov5检测跑通了、想进一步做实例分割的还有被ncnn的模型转换和后处理折磨到头秃的。我不会只丢一堆代码了事尽量把为什么要这么做、底层是怎么算的都讲明白。1. 为什么非得是ncnnC而不是其他方案咱们先聊点实在的。yolov5分割模型部署市面上可选的路子不少直接用PyTorch的LibTorch、用OpenCV的DNN模块、用TensorRT、用ONNX Runtime当然还有ncnn。我自己全部试过一圈说说各自的处境。LibTorch部署最“原生”模型不用转格式代码写起来也顺手但库的体积非常大光依赖库就是几百MB起步而且对CPU的推理优化一般在ARM架构的板子上表现很普通。OpenCV的DNN模块虽然轻量但支持的算子有限yolov5-seg这种带多输出头的模型经常要魔改才能跑通而且mask后处理部分完全没有现成支持等于一半的活还得自己干。TensorRT在NVIDIA显卡上确实凶猛但TensorRT只能跑在N卡上换到瑞芯微、地平线、手机芯片上就完全无能为力了。ncnn的优势在于三点。第一它专门为手机和嵌入式平台做了深度优化ARM架构下的速度非常能打支持NEON指令集加速而且内存占用控制得很死。第二模型文件是文本格式的.param加二进制的.bin非常透明模型结构哪里出了问题可以直接打开文本看排查起来比黑盒格式舒服得多。第三ncnn的算子覆盖比较全yolov5-seg用到的卷积、上采样、sigmoid这些都能原生支持。那么问题来了ncnn部署分割和ncnn部署普通检测的区别到底在哪普通yolov5检测模型输出只有一个blob形状是[1, 25200, 85]以COCO为例5个框坐标加80个类别分数而yolov5-seg输出两个blob一个是[1, 25200, 117]另一个是[1, 32, 160, 160]的mask原型图protos。这个117里面包含85个检测信息、再加32个mask系数。后处理时要用这32个系数去对160x160的mask原型做线性组合才能还原出目标轮廓。这就是分割版本跟检测版本最本质的差别。注意这里说的分割是实例分割不是语义分割。语义分割是给每个像素分类别实例分割是区分“第1个人”和“第2个人”。yolov5-seg做的是后者。我收到过很多私信问“为什么我的分割模型转成ncnn之后跑出来的mask是花的、完全是噪点”十有八九是对这个117的组成结构理解错了解析输出的时候错位把mask系数当成类别分数在用。后面我会专门讲这部分。2. 模型转换链路从pt到param和bin正式写C之前必须先把模型文件搞定。这一步是整个项目里坑最多的地方我把完整的链路走一遍。2.1 从pt导出ONNX注意输出节点名称别搞混首先是导出ONNX。在yolov5仓库的目录下用官方脚本python export.py --weights yolov5s-seg.pt --include onnx --opset 12 --simplify这里有几个细节值得注意。--opset建议12太高的opset版本ncnn的转换器不一定认太低的话某些算子导出形式比较啰嗦转换时报错的概率也大。--simplify这个参数强烈建议加上它内部会调用onnx-simplifier对计算图做一轮化简把很多冗余的算子合并掉显著降低后面转ncnn的失败率。导出完成后先用onnxruntime跑一次推理做基准确认ONNX模型的输出是正常的。怎么确认把一张测试图喂进去看输出shape是否符合预期。yolov5s-seg在640x640输入下检测头的输出应该是[1, 25200, 117]mask原型的输出应该是[1, 32, 160, 160]。如果导出后输出节点名混乱有些版本叫output有些叫output0、output1后面在ncnn的param文件里就要按实际名字去取。建议用onnxruntime把输出名打出来看一眼import onnxruntime as ort sess ort.InferenceSession(yolov5s-seg.onnx) for node in sess.get_outputs(): print(node.name, node.shape)2.2 onnx2ncnn转换常见报错处理方案拿到ONNX文件之后用ncnn自带的转换工具onnx2ncnn yolov5s-seg.onnx yolov5s-seg.param yolov5s-seg.bin这个工具在ncnn仓库的build/tools/onnx/目录下编译ncnn的时候会一并生成。如果你不想自己编译也可以下载ncnn的release包里面已经带好了各平台的转换工具。转换过程中最常见的报错有两类。一类是“Unsupported operator”这种情况通常发生在没有做onnx-simplify的模型上想办法把ONNX导出加上--simplify能解决大部分问题。另一类是维度推导失败可以考虑用pnnx来替代onnx2ncnn。pnnx是ncnn作者专门做的模型转换工具对PyTorch模型的支持更好如果onnx2ncnn卡死不动直接改用pnnx往往会柳暗花明。转换完成后打开param文件重点检查两个输出的名字和shape。param文件是纯文本搜索Input和Output相关的行就能看到。以我这边转出来的为例Input images 0 1 640 640 ... Split splitncnn_0 3 1 184 185 186 ... Output output 0 1 25200 117 Output output1 0 1 32 160 160在这里有一个非常重要的判断点如果param里的输出维度是25200 x 117说明检测头输出里已经包含了每个anchor的mask系数。如果输出维度是25200 x 85或者其他数字那就要怀疑是不是导出的时候没有把分割头一起导出来或者版本不对。2.3 ncnnoptimize优化和FP16存储转换成功只是第一步接下来要做的优化很多人会忽略。用ncnnoptimize对模型做一次内存和计算优化ncnnoptimize yolov5s-seg.param yolov5s-seg.bin yolov5s-seg-opt.param yolov5s-seg-opt.bin 1最后一个参数1表示生成的模型用FP16存储权重0表示FP32。FP16能大幅减小bin文件体积推理速度也能提升但理论上会有极小精度损失。实测下来对于yolov5分割这个任务FP16的精度损失几乎可以忽略检测框和mask肉眼分辨不出差别但模型文件从140MB能缩到70MB左右。建议在开发调试阶段保留一份FP32的模型确认算法效果OK后再切换到FP16版本做性能优化。如果发现FP16版本mask边界出现锯齿或检测置信度轻微下降不要慌先用FP32排查是不是自己的后处理代码有bug再用FP16对照验证。2.4 自定义类别数时数字怎么改如果你用的是自己训练的数据集类别数不是80那检测头的输出维度就要改。假设你有num_classes个类别输出维度就变成了5 num_classes 32。比如你的数据集有2个类那维度就是5 2 32 39。这个数字在param文件里能找到在后处理代码里也要同步改。同时25200这个数字也跟输入尺寸和训练时的anchor配置有关。以640x640输入为例yolov5有三个检测层特征图大小分别是80x80、40x40、20x20加起来是80*80 40*40 20*20 8400。但yolov5在导出时每个anchor会乘以3组anchor的倍数常规配置下就是8400 * 3 25200。如果你改了anchor数量这个数字也要相应调整。最稳妥的方式是直接用onnxruntime跑一遍看输出的实际shape不要凭空算。3. C工程搭建CMake、依赖和模型加载模型文件准备好之后就可以开始搭C工程了。这一节的代码结构可以直接抄。3.1 依赖准备和CMakeLists配置需要准备的东西编译好的ncnn库、OpenCV用于图像读取和显示。如果你是在PC上调试直接用官方发布的ncnn库就行。如果是在ARM板子上跑建议自己交叉编译ncnn这样能针对目标平台的CPU特性开满优化。一份能用的CMakeLists.txt大概长这样cmake_minimum_required(VERSION 3.10) project(yolov5_seg_ncnn) set(CMAKE_CXX_STANDARD 11) find_package(OpenCV REQUIRED) find_package(ncnn REQUIRED) add_executable(yolov5_seg main.cpp) target_link_libraries(yolov5_seg ncnn ${OpenCV_LIBS})编译mkdir build cd build cmake .. make -j$(nproc)3.2 初始化ncnn网络实例模型的加载和初始化建议封装成一个类方便多处调用。初始化的核心代码#include ncnn/net.h ncnn::Net net; void init_model() { net.opt.use_vulkan_compute false; // 不使用GPU net.opt.num_threads 4; // 线程数按设备核心数调 net.opt.use_fp16_packed true; net.opt.use_fp16_storage true; net.opt.use_fp16_arithmetic false; // 保持FP32运算减少精度问题 int ret1 net.load_param(yolov5s-seg-opt.param); int ret2 net.load_model(yolov5s-seg-opt.bin); if (ret1 ! 0 || ret2 ! 0) { fprintf(stderr, 模型加载失败\n); exit(-1); } }这里特别说明一下几个选项。use_vulkan_compute如果你有GPU支持而且想用GPU推理可以打开但要注意Vulkan版本和驱动兼容问题实测很多ARM板子的Vulkan支持很差开了反而更慢。num_threads不是越大越好在四核设备上开4个线程是标准做法在八核设备上可以根据实测调整到6或8但线程数超过物理核心数之后性能不升反降。use_fp16_arithmetic我建议关掉因为mask生成涉及矩阵乘法和sigmoidFP16运算的精度损失在边界像素上会被放大而速度提升在CPU上并不明显性价比不高。初始化还有一个容易被忽视的点预热。ncnn的很多计算函数是懒加载的第一次推理时会触发内存分配和算子初始化导致首帧延迟特别高。正确的做法是初始化完成后用一张全黑图先跑一次空推理把预热工作做掉。3.3 输入输出的基本流程框架推理的完整流程分四步读取图像、图像预处理、forward推理、输出解析。cv::Mat image cv::imread(test.jpg); ncnn::Mat in ncnn::Mat::from_pixels_resize(image.data, ncnn::Mat::PIXEL_BGR, image.cols, image.rows, 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); ncnn::Mat det_out; ncnn::Mat proto_out; ex.extract(output, det_out); ex.extract(output1, proto_out);上面这段代码里输入节点的名字images要跟param文件里Input的名字一致输出节点的名字output和output1也要跟param文件里的Output名字一致。名字对不上extract会返回-1这是一个非常常见的低级错误。预处理这块要注意一个细节yolov5的训练里归一化是把图像像素值除以255所以mean为0norm为1/255。有些教程里写mean_vals{0,0,0}, norm_vals{0.00392,0.00392,0.00392}其实是同一个意思别搞混就行。更重要的是from_pixels_resize是直接拉伸缩放没有保持宽高比而yolov5训练时是letterbox处理即等比缩放并补灰边。如果这里用了直接拉伸图像会变形检测框也会偏。正确的做法是先做letterbox再转ncnn::Mat这个细节对mask精度的影响尤其明显。4. 预处理和推理letterbox细节与输出结构解析4.1 letterbox到底怎么做才对很多人觉得letterbox不重要随便resize一下能跑就行。但做分割任务的时候这件事会成为mask能不对上框的关键。letterbox的核心思想是把图像等比缩放到目标尺寸内多余的部分用灰色114, 114, 114填充。这样图像内容不会变形检测框相对坐标不产生非线性误差。void letterbox(const cv::Mat src, cv::Mat dst, int target_w, int target_h, float scale, float pad_x, float pad_y) { int img_w src.cols; int img_h src.rows; scale std::min((float)target_w / img_w, (float)target_h / img_h); int new_w (int)(img_w * scale); int new_h (int)(img_h * scale); pad_x (target_w - new_w) / 2.0f; pad_y (target_h - new_h) / 2.0f; cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); dst cv::Mat(target_h, target_w, CV_8UC3, cv::Scalar(114, 114, 114)); resized.copyTo(dst(cv::Rect((int)pad_x, (int)pad_y, new_w, new_h))); }推理完成后输出的检测框坐标是基于640x640的letterbox坐标系的要映射回原图需要做逆变换// 逆变换从letterbox坐标映射回原图 float x1 (x1_letterbox - pad_x) / scale; float y1 (y1_letterbox - pad_y) / scale; float x2 (x2_letterbox - pad_x) / scale; float y2 (y2_letterbox - pad_y) / scale;mask的缩放也是同理拿到mask在原图的坐标后直接用这个scale和pad_x、pad_y换算。如果不做letterbox直接用拉伸resize原图宽高比和目标宽高比不一致时mask会整体偏移框和轮廓对不齐——我在这里栽过一次看起来是mask“漂移”其实根源就是预处理没做letterbox。4.2 输出维度到底是多少别被网上的代码骗了打开前面转换好的param文件或者用代码打印一下det_out的维度。比较靠谱的做法是在C里加一行printf(det_out dims: %d %d %d %d\n, det_out.dims, det_out.w, det_out.h, det_out.c);正常情况下应该看到类似det_out dims: 3 117 25200 1的输出。注意ncnn的维度顺序是w, h, c对dims3的Mat来说w是25200h是117c是1。这意味着数据是一个25200行、117列的大二维数组。每一行代表一个候选框前85列是检测信息最后32列是mask系数。理解这个布局很关键。以COCO的80类为例85列里第0到第3列是框的x, y, w, h注意是中心点坐标形式第4列是objectness置信度第5到第84列是80个类别的分类分数第85到第116列就是mask用的32个系数。如果你自己训练的数据集类别数不同这个布局也要按比例调整。解析输出的伪代码for (int i 0; i det_out.h; i) { const float* row det_out.row(i); float obj_conf row[4]; // 找最大类别分数 float max_cls_score 0; int max_cls_id 0; for (int c 0; c num_classes; c) { float score row[5 c]; if (score max_cls_score) { max_cls_score score; max_cls_id c; } } float final_conf obj_conf * max_cls_score; if (final_conf conf_threshold) { float cx row[0]; float cy row[1]; float w row[2]; float h row[3]; float x1 cx - w / 2; float y1 cy - h / 2; float x2 cx w / 2; float y2 cy h / 2; // mask系数 float mask_coeffs[32]; for (int k 0; k 32; k) { mask_coeffs[k] row[5 num_classes k]; } // 保存到候选结构体 } }这里final_conf obj_conf * max_cls_score是yolov5官方计算最终置信度的方式也就是category score乘以objectness。有些新版yolov5在导出时已经合并了但为了保险起见自己代码里还是乘一遍不影响结果。det_out.h这个“h”在ncnn里等于25200遍历的时候也可以直接用det_out.h不过要理解这个h并不是图像高度而是候选框数量。4.3 一次推理的完整流程串联把前面的代码串起来一次完整的推理大概是读取图片做letterbox缩放得到640x640的BGR图用from_pixels转成ncnn::Mat减均值除方差创建Extractor设置输入输出ex.run()启动推理实际上ex.extract会自动触发推理不需要单独调用run分别取出检测输出和mask proto输出遍历检测输出做置信度过滤和框坐标还原到这里检测框已经拿到了但分割任务的重头戏才刚开始怎么从32个mask系数和160x160的mask原型还原出真正的mask下一节重点讲。5. 实例分割后处理NMS与mask生成这是整个项目最核心、也最容易出bug的部分。yolov5-seg的mask生成原理并不复杂用检测头输出的32个系数对mask原型图的32个通道做线性加权求和再经过sigmoid得到每个目标的分割掩码。5.1 NMS是怎么参与到分割里的在生成mask之前需要先做NMS非极大值抑制把冗余的检测框去掉。否则很多重叠框都会生成自己的mask画出来一团糊。NMS的流程按置信度从高到低对所有框排序取置信度最高的框跟其余框计算IoU把IoU大于阈值的框删掉重复直到没有剩余框IoU的计算float compute_iou(const Box a, const Box b) { float inter_x1 std::max(a.x1, b.x1); float inter_y1 std::max(a.y1, b.y1); float inter_x2 std::min(a.x2, b.x2); float inter_y2 std::min(a.y2, b.y2); float inter_area std::max(0.f, inter_x2 - inter_x1) * std::max(0.f, inter_y2 - inter_y1); float area_a (a.x2 - a.x1) * (a.y2 - a.y1); float area_b (b.x2 - b.x1) * (b.y2 - b.y1); float union_area area_a area_b - inter_area; return inter_area / union_area; }NMS的IoU阈值通常设为0.45置信度阈值在0.25到0.5之间。这个可以按实际场景调如果是密集小目标场景阈值要适当降低如果是稀疏大目标场景阈值可以高一些。需要特别注意的是NMS在分割任务里还有一个隐藏作用减少mask计算的次数。因为每个框的mask生成都是一次矩阵运算如果候选框有一万个全部计算mask会非常慢。先NMS过滤到几十个框再做mask性能提升立竿见影。5.2 mask原型如何变成目标掩码mask原型protos从模型里取出后的shape是[1, 32, 160, 160]。在ncnn里如果打印出来可能是dims: 4的Mat或者在某些版本里被压成了三维。实际访问的时候我更习惯把它拷贝到std::vectorcv::Mat里操作// proto_out 从 ex.extract 拿到的数据 int proto_h 160, proto_w 160, proto_c 32; std::vectorcv::Mat protos; for (int c 0; c proto_c; c) { cv::Mat proto(proto_h, proto_w, CV_32FC1); for (int i 0; i proto_h * proto_w; i) { proto.atfloat(i) ((float*)proto_out.data)[c * proto_h * proto_w i]; } protos.push_back(proto); }然后对每个NMS保留下来的框用它的32个mask系数去线性组合这32个原型cv::Mat mask cv::Mat::zeros(proto_h, proto_w, CV_32FC1); for (int c 0; c 32; c) { mask mask_coeffs[c] * protos[c]; } // sigmoid cv::exp(-mask, mask); mask 1.0f / (1.0f mask);这实际上就是在做mask sigmoid(coeff · protos)。得到的是一个160x160的浮点图每个像素值在0到1之间表示该像素属于这个目标的概率。然后把这个160x160的mask放大到检测框的尺寸int box_w (int)(x2 - x1); int box_h (int)(y2 - y1); cv::Mat mask_resized; cv::resize(mask, mask_resized, cv::Size(box_w, box_h)); // 二值化 cv::Mat mask_binary; cv::threshold(mask_resized, mask_binary, 0.5, 255, cv::THRESH_BINARY);最后把这个二值mask放到原图对应位置cv::Mat mask_full cv::Mat::zeros(orig_h, orig_w, CV_8UC1); mask_binary.copyTo(mask_full(cv::Rect((int)x1, (int)y1, box_w, box_h)));这里要特别注意框的坐标要先做letterbox逆变换回到原图坐标系。否则mask虽然形状对但位置是错的偏移量正好等于letterbox的pad。5.3 几个常见的mask后处理bug先说我踩过最久的一个坑mask系数需要经过sigmoid吗答案是不需要。yolov5-seg的检测头直接输出的mask系数就是用于线性组合的权重往proto上乘的时候不要再套sigmoid。sigmoid是在线性组合完成之后才做的。如果你在系数上多套了一个sigmoidmask会变得非常模糊边缘完全糊掉。第二个坑160x160还是128x128这取决于你训练模型时的输入尺寸和网络配置。yolov5s-seg默认的输入是640x640proto输出是160x160。如果你训练时改过输入尺寸proto的大小也会变。别在代码里写死160最好通过proto_out的实际维度去读取。第三个坑放大的目标尺寸不对。有人直接把mask从160x160放大到整张原图大小而不是放大到检测框大小。这样做视觉效果上好像也“能用”但会把不属于这个目标的背景像素也划进来尤其是两个目标靠得很近时mask会互相污染。正确做法是先放大到框的尺寸再放到对应位置。因为proto是按整张图训练的框之外的位置理论上模型不会输出有效响应但你放大到整图再裁切会引入边缘插值的模糊影响分割精度。第四个坑按坐标裁剪时box_w和box_h是负数。如果检测框有一部分超出图像边界cv::Rect的宽高会变成负数程序直接崩溃。做mask拷贝前要加防溢出判断float box_x1 std::max(0.f, x1); float box_y1 std::max(0.f, y1); float box_x2 std::min((float)orig_w, x2); float box_y2 std::min((float)orig_h, y2); if (box_x2 box_x1 || box_y2 box_y1) continue;5.4 裁剪mask到检测框的另一种思路实际操作中还有一种做法是不把mask放大到框的大小而是把检测区域内对应的proto区域切出来再做线性组合。这样计算量更小因为160x160是整图的proto对每个框都做一次160x160的矩阵运算如果框很小大量计算其实浪费了。不过这个优化需要在理解原理的基础上做第一次跑通流程时不建议这么做先把正确性保证好后面再优化性能。我的建议是先实现最简单的“整图mask线性组合 → sigmoid → 缩放到框尺寸 → 裁剪”流程跑通后记录一帧的耗时再决定要不要优化。很多情况下框的数量在NMS后只有几十个每个框做一次160x160的乘法总耗时并不大对比整个前向推理的时间占比非常小。6. 性能实测和几个影响帧率的隐藏因素6.1 不同平台的大致耗时分布我在三套环境上做过测试数据供参考。处理的是同一张640x640的输入模型是yolov5s-seg平台推理耗时后处理耗时总耗时Intel i7-12700H (纯CPU)120~180ms20~30ms150~210ms手机骁龙8Gen1 (纯CPU)250~350ms30~50ms300~400msRK3588 (纯CPU, 6线程)200~280ms25~40ms230~320ms需要说明的是这个数据是同一套代码在不同平台上的大致水平具体数字受ncnn编译选项、线程数、CPU调度影响很大。但结论是稳定的推理耗时占大头后处理里的mask生成并没有想象中那么慢。很多人上来就骂“ncnn跑分割太慢了”但实测一看后处理才占20ms不到大头全在卷积计算上。所以如果性能不达标优先考虑的是模型是不是太大了s模型和m模型速度差接近一倍、线程数是否合理、有没有开FP16存储。6.2 推理线程数真的越多越好吗不是。ncnn的多线程是基于OpenMP的在线程数超过CPU物理核心数之后线程切换的开销会吃掉性能提升。在某些ARM大小核架构的平台上盲目开8线程反而可能把任务调度到小核上导致性能雪崩。比较靠谱的调法先用std::thread::hardware_concurrency()获取逻辑核心数然后以核心数-1为上限做测试。你可以在程序里加一个benchmark模式依次用2、4、6、8线程跑同样的一批图片打印各线程数下的平均耗时找出最优配置。6.3 mask生成性能的隐藏瓶颈mask后处理有个隐藏性能瓶颈cv::exp和sigmoid操作。当检测框数量多的时候cv::exp会对160x160x32的矩阵做大量指数运算这些函数是逐元素的非常耗时。但更好的消息是ncnn的Extractor在extract出proto_out之后数据在内存里是连续排布的可以直接用指针操作减少cv::Mat的拷贝开销。另外如果在循环里重复用ncnn::Extractor ex net.create_extractor()每次创建都有一定开销。更好的做法是复用同一个Extractor实例ncnn::Extractor ex net.create_extractor(); for (auto img : images) { ex.input(images, in); ex.extract(output, det_out); ex.extract(output1, proto_out); }实测这个改动在某些ncnn版本里能省掉每次几毫秒的固定开销。6.4 长时间运行的稳定性问题嵌入式设备上长时间跑推理最怕的就是内存泄漏和莫名奇妙的帧率下降。排查这类问题我有几个习惯。第一每次推理循环里不要反复new和delete大块内存尽量复用预先分配好的cv::Mat和std::vector。第二关注ncnn::Mat的生命周期尽早release()大对象的引用比如proto_out用完后马上置空否则它在vector里一直占着内存。第三打印一下net的内存占用量可以用net.total_memory()看看模型加载后占了多少推测是否有异常增长。如果运行一段时间后帧率明显下降先看系统内存是不是被吃满了。ncnn在推理时会有一些临时的workspace这些内存在帧结束时会释放但如果Extractor被异常中断可能不会正确清理。一个比较土但有效的办法每个N帧重建一次网络实例。7. 一次完整排查案例mask全黑的思路复盘最后分享一次真实排查经历这个案例对遇到类似问题的人应该很有参考价值。现象检测框完全正常但分割的mask输出是全黑的。我一开始以为是后处理代码的问题反复检查了mask系数读取、线性组合、sigmoid这些步骤都没发现问题。然后我把中间变量打印出来发现proto_out里有很多NaN和Inf。这就不对劲了ncnn的推理输出不应该出现NaN。去翻param文件发现模型转换时用了FP16存储而我在net.opt里开了use_fp16_storage的同时又开了use_fp16_arithmetic在某个ARM平台的NEON指令上半精度运算发生了溢出。这个问题的根源是模型本身在训练时用了FP32精度转换为FP16后权重精度下降如果目标设备的FP16运算能力不足就会在某些层上产生数值溢出。解决方案有三个思路我最终采用了两步走把use_fp16_arithmetic关掉这个选项让ncnn用FP16做卷积计算是溢出的直接原因use_fp16_storage保留只让权重以FP16存储计算时自动转回FP32改完之后NaN消失mask正常输出。这个坑在纯x86平台不会暴露因为x86的FP16支持比较完善但在ARM平台上一测就现形。从这里得到的教训是做嵌入式部署任何“看起来正常但功能不对”的问题首先要检查的就是数值精度。不要急着怀疑自己的算法逻辑先确认模型输入输出的数字到底有没有问题。FP16优化这件事等代码逻辑全部跑通、验证完mask效果后再做不然排查bug时多一个变量难度几何级上升。再提醒一句之前说过的mask二值化的阈值官方给的是0.5但实际使用中可以按场景调整。如果mask边缘看起来“虚”把阈值调到0.6或0.7如果mask有断裂或者空洞调低到0.3到0.4会好一些。这个阈值不会影响检测框只影响mask的最终呈现调参的成本很低建议针对自己的业务数据做个简单的阈值扫描找最佳值。本文还有配套的精品资源点击获取
返回列表