☰
YOLOv5实例分割ncnn C++部署实战指南
2026/10/14 23:00:55 网站建设 项目流程

简介:本资源是面向C++与移动端AI部署开发者的一站式YOLOv5-seg图像分割实践方案,聚焦于将YOLOv5实例分割模型通过ncnn框架高效部署至桌面及Android平台。资源包含完整可编译的C++推理代码、Android NDK集成示例、模型转换说明及多场景演示(含手部分割专项项目),解决深度学习模型在资源受限设备上的轻量化落地难题。压缩包共63个文件,涵盖4个核心cpp源码、28个头文件(h)、3个CMakeLists.txt构建脚本、3个Gradle配置文件、2个Java接口层文件,以及screenshot_android.gif动态演示和README.md详细使用指南等,整体大小为21.19MB。已有65人下载学习,提供从模型加载、预处理、推理到后处理的全链路代码实现,特别适合具备基础C++和OpenCV能力、正探索ncnn移动端部署的中级开发者快速上手并复现分割效果。 YOLOv5的C++部署用ncnn做实例分割,这个组合我在实际项目里打磨过好几轮,从最初能跑通到后来抠性能、调精度,踩了不少坑。最近刚好整理工程文件打包,顺手把整套思路和核心代码细节写出来,给正在做端侧部署或者准备把Python推理迁到C++的朋友一份能直接上手的参考。

这个方案解决的核心问题很简单:训练好的YOLOv5分割模型,在Python里跑得挺欢,但到了生产环境,要么没有Python运行时,要么性能吃紧,要么需要和C++业务代码深度集成。ncnn作为轻量级推理框架,对移动端和嵌入式平台友好,C++接口干净,部署起来心里有底。

如果你要搞的目标是“让模型在端侧跑起来,还要能拿到每个目标的掩膜”,那这篇基本覆盖了从模型转换、代码实现到编译部署的完整链路。就算你只是用YOLOv5做检测,不搞分割,里面的模型转换和C++推理框架思路也一样能套用。

1. 整体设计思路拆解

1.1 为什么偏偏是ncnn加C++

先聊技术选型。YOLOv5的推理有不少现成方案,TensorRT性能顶但绑死N卡,OpenVINO对Intel平台友好但移动端生态弱,ONNXRuntime通用但动态库体积和启动开销偏大。ncnn的优势在于全平台覆盖,小核数也能跑,对ARM架构的优化做得很扎实,而且是纯C++实现,接入现有C++工程时没有跨语言调用的负担。

C++版本的收益不只是性能。我在项目里遇到的实际需求是:相机端采集、图像处理、检测分割、结果上报全都写在一个C++服务里,如果用Python推理就得把C++的缓冲区拷给Python,这个拷贝开销在视频流场景下很肉疼。C++部署直接吃OpenCV的Mat数据,零拷贝进入前处理流程,内存可控,崩溃特征也清晰可查。

分割这个需求是另一个维度。很多业务要的不是“框住目标”,而是目标的具体轮廓。比如工业零件检测要看边缘是否完整,农业遥感要看地块边界,如果只给一个矩形框,下游没法做面积、周长这些量化分析。YOLOv5的实例分割版本(yolov5-seg)在检测头旁边加了一个掩膜分支,能在保持实时性的前提下输出每个目标的像素级分割结果,正好够用。

1.2 实例分割在ncnn里的运算逻辑

yolov5-seg的输出有两路。一路是检测头,输出格式类似yolov5的标准输出,每行包含cx、cy、w、h、类别分数、各类别置信度。另一路是原型掩膜(prototype masks),尺寸是[B, 32, 160, 160]左右,相当于把分割掩膜压缩到了一个32维的潜空间。

推理的时候,对每个检测框取它的掩膜系数(通常是1x32的向量),和原型掩膜做矩阵乘法,得到对应尺寸的粗糙掩膜,再经过sigmoid阈值化和尺寸缩放,映射回原图坐标。这就是网上常说的“mask系数加权合成”的核心操作。

这套逻辑在ncnn里实现的时候,有几个地方容易踩坑。第一,输出blob的名字要和onnx导出时保持一致,别想当然,用Netron打开onnx确认一下。第二,ncnn里对blob做reshape和extract的输入输出尺寸必须匹配,维度错了会在运行时直接崩溃。第三,原型掩膜和检测输出的顺序在转模型后可能重新排列,需要逐个print维度确认。

1.3 整体部署链路

整个工程走下来是四个环节:

  1. Python侧用yolov5-seg训练/微调模型,导出ONNX。
  2. onnx2ncnn工具把ONNX转成ncnn的param和bin,再跑ncnnoptimize做算子融合和量化。
  3. C++侧编写推理代码,包括模型加载、图像预处理、前向传播、后处理(NMS + mask解码)。
  4. CMake配置编译,联调优化,跑通性能。

下面按这个链路逐段展开,把关键参数和代码片段都放出来。

2. 环境准备与模型转换

2.1 Python侧模型准备细节

假设你手里已经有一个训练好的yolov5-seg模型,如果没有,用ultralytics/yolov5仓库训练一个自己的数据集也不难。这里有个容易被忽略的点:训练时的输入尺寸最好和你部署时的期望尺寸一致,比如统一用640x640。如果训练用640,部署时转成416,精度会明显掉,因为anchor的尺度分布是按训练尺寸统计的。

模型训练完成后,导出ONNX的命令一般是:

python export.py --weights best.pt --include onnx --opset 12 --batch-size 1 --dynamic False

注意opset建议用12到14之间。opset太低会导致某些算子无法转换,opset太高ncnn支持的算子表可能不匹配,我在实际对比里ops12兼容性最稳。

导出后的onnx用Netron打开,重点确认两个信息:输入节点的名字(通常是images),输出节点的名字和维度。这一步骤省不得,后面写C++代码时要用到。

2.2 ONNX转ncnn模型的操作

ncnn提供了模型转换工具,在编译ncnn之后会生成onnx2ncnn可执行文件。当前目录下执行:

./onnx2ncnn yolov5-seg.onnx yolov5-seg.param yolov5-seg.bin

这步一般会遇到几个警告,比较常见的是Unsupported Resize or ROI Pooling提示。yolov5的head部分有个上采样操作,onnx2ncnn对动态尺寸支持有限,转换后可能输出尺寸错误。遇到这种情况,有几个处理方向:

  1. 在onnx里把Resize的scales换成固定尺寸的size输入。
  2. 手动修改生成的.param文件,把上采样层改成固定的interp层。
  3. 用ncnnoptimize先跑一遍,看是否能自动优化掉。

多数情况下,最有效的是在导出onnx前用固定shape导出,避免动态维度。

转换成功后,跑模型优化:

./ncnnoptimize yolov5-seg.param yolov5-seg.bin yolov5-seg-opt.param yolov5-seg-opt.bin 0

最后的0表示fp32存储,如果端侧支持fp16可以改1会有明显速度提升。但fp16在部分低端ARM芯片上有精度损失,检测框、掩膜边界可能变毛糙,建议先fp32验证链路,再切fp16调优。

2.3 模型文件与输出尺寸规划

转换完成后得到的.param文件里,可以看到每一层的输入输出尺寸。尤其关注image输入层和两个输出层的blob名字。比如我这边最后yolov5-seg的检测输出名是output,掩膜输出名是proto,这个在后面C++代码里要直接引用。

如果你训练时输入尺寸是640x640,那检测输出的feature map尺寸大致是640/8=80、640/16=40、640/32=20三个尺度,但ncnn转换后通常会把三个尺度的输出合并成一个batch,所以extract出来的检测结果维度是[1, 25200, 5+num_classes+32](以coco 80类为例就是5+80+32=117)。proto输出是[1, 32, 160, 160]。可以把这些维度记下来,后处理要按这个来解析。

这里顺带提醒一句:不同版本的yolov5-seg结构略有差异,有的输出是mask系数拼接在检测head之后,有的用独立分支输出,解析的时候务必按当时onnx导出的实际结构来写。

3. C++推理代码实现解析

3.1 工程结构与构建依赖

C++工程只需要三个依赖:opencv(图像加载和绘制)、ncnn(推理)、标准库。我用CMake组织工程,目录基本长这样:

yolov5-seg-ncnn/ ├── CMakeLists.txt ├── src/ │ └── main.cpp ├── models/ │ ├── yolov5-seg-opt.param │ └── yolov5-seg-opt.bin └── images/

CMakeLists.txt里的核心配置:

cmake_minimum_required(VERSION 3.10) project(yolov5_seg_ncnn) set(CMAKE_CXX_STANDARD 11) find_package(OpenCV REQUIRED) include_directories(${OpenCV_INCLUDE_DIRS}) # ncnn 的头文件目录和库路径按实际安装位置修改 include_directories(/usr/local/include/ncnn) link_directories(/usr/local/lib) add_executable(yolov5_seg src/main.cpp) target_link_libraries(yolov5_seg ncnn ${OpenCV_LIBS})

如果你在嵌入式平台交叉编译,需要额外指定工具链,并确认ncnn和OpenCV都编译了对应架构的版本。这块后面第四节再说。

3.2 加载模型与初始化工作

初始化集中放在一个函数里,ncnn的Net对象承载模型参数和权重。我习惯把输入尺寸、阈值这些做成配置项,调试时不用重新编译。

先定义后处理需要的结构体:

struct Object { cv::Rect_<float> rect; // 检测框 int label; // 类别id float prob; // 置信度 std::vector<float> mask; // 原型掩膜系数 32维 cv::Mat mask_img; // 生成的实例掩膜,二值图 };

加载模型:

ncnn::Net net; net.opt.use_vulkan_compute = true; // 有GPU环境就跑GPU net.opt.use_fp16_packed = true; // 有精度冗余时可以开 int ret = net.load_param("models/yolov5-seg-opt.param"); if (ret != 0) { fprintf(stderr, "load_param failed\n"); return -1; } ret = net.load_model("models/yolov5-seg-opt.bin"); if (ret != 0) { fprintf(stderr, "load_model failed\n"); return -1; }

use_vulkan_compute这个选项需要ncnn在编译时开启Vulkan支持,没有GPU环境或者没编Vulkan就设为false,纯CPU也能跑,只是慢一些。我之前在一台没有GPU的服务器上跑纯CPU版,平均一次推理大概200ms,开启Vulkan后能降到40ms左右,差距很明显。

3.3 图像预处理:letterbox和归一化

预处理有三步:等比缩放、补边、归一化。这个流程和Python侧训练时的预处理必须保持完全一致,否则模型看到的数据分布对不上,精度会莫名其妙掉一截。

等比缩放和补边用letterbox,目的就是把任意尺寸的输入变成640x640而不破坏宽高比。实现如下:

cv::Mat letterbox(const cv::Mat& src, int target_size, float& scale, int& pad_w, int& pad_h) { int w = src.cols; int h = src.rows; scale = std::min(target_size * 1.0f / w, target_size * 1.0f / h); int new_w = int(w * scale); int new_h = int(h * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h), 0, 0, cv::INTER_LINEAR); pad_w = (target_size - new_w) / 2; pad_h = (target_size - new_h) / 2; cv::Mat canvas(target_size, target_size, CV_8UC3, cv::Scalar(114, 114, 114)); resized.copyTo(canvas(cv::Rect(pad_w, pad_h, new_w, new_h))); return canvas; }

注意补边用的填充像素值,yolov5默认是114,别改成0。我调试时就干过这事,改完发现检测框位置整体偏移。

接着把OpenCV的BGR数据转换到ncnn的输入格式:

ncnn::Mat in = ncnn::Mat::from_pixels_resize(canvas.data, ncnn::Mat::PIXEL_BGR, target_size, target_size, target_size, target_size); in.substract_mean_normalize(mean_vals, norm_vals);

mean_vals通常是{0.0f,0.0f,0.0f},norm_vals通常是{1/255.0f,1/255.0f,1/255.0f}。注意yolov5的处理是直接除以255,不走ImageNet的mean/std,之前有人习惯性套ImageNet参数导致效果崩了。

3.4 前向推理与获取输出

输入准备好后,构造ncnn的Extractor执行推理:

ncnn::Extractor ex = net.create_extractor(); ex.input("images", in); // "images" 要和onnx里的输入节点名一致 ncnn::Mat det_out, proto_out; ex.extract("output", det_out); // 检测+mask系数输出 ex.extract("proto", proto_out); // 原型掩膜输出

这里有两个常见的崩溃场景。一个是blob名字写错,extract找不到对应层直接报错;另一个是输出维度不符合预期,比如det_out的通道排列不是你理解的那样。建议在debug模式下把det_out.dims、w、h、c这些值打出来确认一下。

det_out的访问方式是一个行一个目标:

int num_anchors = det_out.h; // 比如25200 int num_attrs = det_out.w; // 比如117 std::vector<Object> candidates; for (int i = 0; i < num_anchors; i++) { const float* row = (const float*)det_out.row(i); float cx = row[0]; float cy = row[1]; float w = row[2]; float h = row[3]; float box_conf = row[4]; // 先筛一遍背景,减少后续计算量 if (box_conf < 0.25f) continue; for (int j = 0; j < num_classes; j++) { float cls_conf = row[5 + j]; float final_conf = box_conf * cls_conf; if (final_conf >= conf_threshold) { Object obj; obj.rect.x = cx - w * 0.5f; obj.rect.y = cy - h * 0.5f; obj.rect.width = w; obj.rect.height = h; obj.label = j; obj.prob = final_conf; obj.mask.assign(row + 5 + num_classes, row + 5 + num_classes + 32); candidates.push_back(obj); break; } } }

注意这里的坐标是相对640x640输入图的归一化坐标,后处理做完后要映射回原图尺寸。

3.5 后处理:NMS和mask解码

候选框直接用NMS去重,简单实现可以用OpenCV的cv::dnn::NMSBoxes,但输出的数据结构不太符合我们需求,我更倾向手动写。目标数量不大的情况下,朴素NMS足够:

static float intersection_over_union(const Object& a, const Object& b) { float ix1 = std::max(a.rect.x, b.rect.x); float iy1 = std::max(a.rect.y, b.rect.y); float ix2 = std::min(a.rect.x + a.rect.width, b.rect.x + b.rect.width); float iy2 = std::min(a.rect.y + a.rect.height, b.rect.y + b.rect.height); float inter = std::max(0.0f, ix2 - ix1) * std::max(0.0f, iy2 - iy1); float area_a = a.rect.width * a.rect.height; float area_b = b.rect.width * b.rect.height; float uni = area_a + area_b - inter; return uni > 0.0f ? inter / uni : 0.0f; } std::vector<Object> nms(const std::vector<Object>& candidates, float nms_thresh) { std::vector<Object> result; std::vector<int> indices(candidates.size()); for (int i = 0; i < indices.size(); i++) indices[i] = i; std::sort(indices.begin(), indices.end(), [&](int a, int b) { return candidates[a].prob > candidates[b].prob; }); std::vector<bool> suppressed(candidates.size(), false); for (int idx : indices) { if (suppressed[idx]) continue; result.push_back(candidates[idx]); for (int jdx : indices) { if (suppressed[jdx]) continue; if (idx == jdx) continue; if (candidates[idx].label == candidates[jdx].label && intersection_over_union(candidates[idx], candidates[jdx]) > nms_thresh) { suppressed[jdx] = true; } } } return result; }

NMS的IOU阈值按你的场景调,通用目标检测用0.45比较稳,密集小目标可以适当降到0.3。类别不同可以放宽到0.5左右避免漏检。

接下来是核心的mask解码。原型掩膜proto_out的维度是[32, 160, 160],对每个NMS后的目标取它的32个mask系数,和proto做加权求和,得到一个160x160的得分图,再放大到640x640并裁剪到检测框范围内,最后判断是否属于该目标:

cv::Mat generate_mask(const ncnn::Mat& proto_out, const Object& obj, int target_size) { int mask_w = proto_out.w; // 160 int mask_h = proto_out.h; // 160 int mask_c = proto_out.c; // 32 // 将原型掩膜提取为OpenCV Mat std::vector<cv::Mat> proto_channels; for (int c = 0; c < mask_c; c++) { cv::Mat m(mask_h, mask_w, CV_32FC1, (void*)proto_out.channel(c)); proto_channels.push_back(m.clone()); } cv::Mat proto_mat; cv::merge(proto_channels, proto_mat); // 160x160x32 // 用检测头给出的mask系数做加权求和 cv::Mat mask_feature(mask_h, mask_w, CV_32FC1, cv::Scalar(0.0f)); for (int c = 0; c < mask_c; c++) { mask_feature += proto_channels[c] * obj.mask[c]; } // sigmoid cv::Mat mask_sigmoid; cv::exp(-mask_feature, mask_sigmoid); mask_sigmoid = 1.0f / (1.0f + mask_sigmoid); // 放大到输入尺寸(640x640) cv::Mat mask_resized; cv::resize(mask_sigmoid, mask_resized, cv::Size(target_size, target_size), 0, 0, cv::INTER_LINEAR); // 根据letterbox的补偿信息,裁剪掉填充区域,映射到原图坐标 // 这里需要把之前letterbox保存的scale、pad_w、pad_h传进来 float x1 = (obj.rect.x - pad_w) / scale; float y1 = (obj.rect.y - pad_h) / scale; float x2 = (obj.rect.x + obj.rect.width - pad_w) / scale; float y2 = (obj.rect.y + obj.rect.height - pad_h) / scale; cv::Rect crop_rect(MAX(0, (int)x1), MAX(0, (int)y1), MIN(src_w - 1, (int)x2) - MAX(0, (int)x1), MIN(src_h - 1, (int)y2) - MAX(0, (int)y1)); cv::Mat mask_crop = mask_resized(cv::Rect((int)obj.rect.x, (int)obj.rect.y, (int)obj.rect.width, (int)obj.rect.height)); // 二值化 cv::Mat mask_bin; cv::threshold(mask_crop, mask_bin, 0.5, 255, cv::THRESH_BINARY); mask_bin.convertTo(mask_bin, CV_8UC1); return mask_bin; }

这里有几个细节值得注意。第一个是proto的通道顺序,ncnn的Mat通道是c-w-h排列,channel(c)拿到的是一整张特定通道的图,别跟OpenCV的HWC混淆。第二个是mask系数在det_out里的拼接位置,前面代码里是从row[5+num_classes]开始取32个值,对应于yolov5-seg把mask系数拼接在每行末尾的设计。第三个是sigmoid之后的最佳阈值,我试过0.4到0.6,0.5是个不错的中间值,但如果你做的是遥感和工业检测这类召回优先的场景,0.4会更合适。

3.6 可视化叠加与工程验证

最后把检测框和分割结果画到原图上,验证整体流程是否跑通:

cv::Mat vis_img = src.clone(); for (auto& obj : objects) { // 画掩膜 cv::Mat mask_color; cv::cvtColor(obj.mask_img, mask_color, cv::COLOR_GRAY2BGR); mask_color *= 255; // 用颜色填充掩膜区域 for (int y = 0; y < mask_color.rows; y++) { for (int x = 0; x < mask_color.cols; x++) { if (obj.mask_img.at<uchar>(y, x) > 0) { vis_img.at<cv::Vec3b>(y + (int)obj.rect.y, x + (int)obj.rect.x) = cv::Vec3b(0, 200, 0); // 绿色区域 } } } // 画检测框 cv::rectangle(vis_img, obj.rect, cv::Scalar(0, 0, 255), 2); // 类别和置信度文本 char text[128]; snprintf(text, sizeof(text), "%d %.2f", obj.label, obj.prob); cv::putText(vis_img, text, cv::Point(obj.rect.x, obj.rect.y - 5), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 255, 0), 1); } cv::imwrite("result.jpg", vis_img);

第一次跑通后,可以拿训练集里几张图做对比,看看掩膜是否贴合目标边缘、多目标是否互相串扰。如果掩膜出现大面积错位或者重叠,重点查后处理中坐标是否加了letterbox回退偏移,这部分是我调试时最容易出问题的地方。

4. 编译部署、性能调优与常见问题

4.1 CMake编译与交叉编译注意点

桌面端编译基本没什么坑,安装好OpenCV和ncnn后直接:

mkdir build && cd build cmake .. make -j$(nproc)

嵌入式平台就要注意几个问题。交叉编译时ncnn和OpenCV必须先用同一个交叉编译器分别编译出来,不能拿x86的静态库丢到ARM板子上去链接。其次,ncnn的编译开关里有一个-DNCNN_VULKAN=ON,如果目标平台支持GPU推理就打开,不支持就关掉,否则运行时检测到Vulkan不可用会退化成CPU,反而增加不必要的初始化开销。

CMakeLists里链接OpenCV时,如果交叉编译环境中OpenCV是用pkg-config管理的,可以改成:

find_package(PkgConfig REQUIRED) pkg_search_module(OpenCV REQUIRED opencv4) include_directories(${OpenCV_INCLUDE_DIRS}) target_link_libraries(yolov5_seg ncnn ${OpenCV_LIBRARIES})

4.2 性能瓶颈定位与提速

把推理拆分计时后发现,CPU平台上前处理加后处理时间占到总耗时的30%左右,不能只盯着模型推理时间。后处理里的逐像素循环效率很低,我在一个4K视频流场景里,分割结果绘制环节拖慢了整体FPS。

优化的几个方向:

  • 模型推理改用fp16精度,配合Vulkan加速,在支持的芯片上速度几乎翻倍。
  • 后处理里的sigmoid和resize尽量用cv::Mat的向量化操作,避免for循环遍历。
  • 检测阈值调高一点,比如confidence从0.25提到0.4,能减少大量无效候选框的mask解码开销。
  • 如果对掩膜精度要求不高,可以只对检测框内的区域做mask解码,省掉整图操作。

4.3 常见问题排查速查表

这些是实际跑项目时反复踩过的问题,整理成表方便查询。

现象原因排查方向
extract找不到输出层blob名字和onnx不一致用Netron打开onnx确认输出节点名,检查param里的层名
检测框位置偏左上或右下letterbox的pad_w/pad_h回退出错确认后处理坐标换算是减pad再除scale
分割掩膜全是噪声点mask系数取值位置错误检查det_out每行的属性数量,确认mask系数从哪个索引开始
推理速度只有CPU一半use_vulkan_compute开了但ncnn没编译Vulkan重编ncnn,打开NCNN_VULKAN选项
同一目标出现多个框NMS阈值需要调整或类别不分确认NMS是否按类别分别执行,或者调低nms_thresh
模型能load但推理结果全0输入归一化的mean/norm设置错误yolov5是除以255,不要套ImageNet的mean/std
裁剪掩膜尺寸为负导致崩溃检测框在图像边缘越界对rect做clamp到图像边界再取mask
fp16推理结果明显变差低端GPU对fp16支持差切回fp32,或对关键层保留fp32

4.4 关于工程输出的额外建议

整个工程打包成zip后,建议里面放一个README说明模型来源和ncnn版本。ncnn接口在不同版本之间偶有变动,比如某个版本把extract的返回值从0改成负数,或者net.opt里新增了选项。我遇到过一位朋友下载了老版本工程,用的ncnn版本又新一两个小版本,结果编译时接口对不上。所以在工程里写清楚“基于ncnn版本YYYYMMDD编译”非常有必要。

另外,模型文件不要省。我见过很多分享里只给代码不给模型,或者给一个转换了一半的param文件,新手跑起来一脸懵。如果你打算把这个工程分享出去,建议把训练好的模型和转换后的ncnn模型一起放进包里,方便复现。

最后再分享一个我在实调中的体会:ncnn部署yolov5-seg的坑,大多不在模型本身,而在“坐标系的来回换算”和“blob维度的心中有数”。只要把onnx的结构吃透,把letterbox的scale和pad保存好,先把检测框跑正,再调试mask解码,整个流程理顺后,后面换模型换数据集都只是改配置的事。这个方案我后来在视频流场景里连续跑了半个月,进程稳定,内存没有明显增长,说明ncnn这块底子确实扎实。你如果要拿来用,先把检测框跑对,再加分割,拆开来调试,能省下大量时间。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询