简介:这份资源面向需要在移动端或嵌入式设备上落地人像分割功能的C++开发者,提供基于PP-HumanSeg lite轻量模型的NCNN部署方案。包内共7个文件,约16.39MB,包含onnx与ncnn两类模型文件(param、bin)以及cpp、h源码,覆盖从模型加载到推理输出的完整链路。代码以HumanSeg类封装分割逻辑,主入口负责调用与结果处理,模型目录则存放转换后的网络权重,方便直接替换或二次训练。已有1457人学习下载,说明该方案在实时人像分割场景中具备一定参考价值。读者可据此快速搭建可运行的C++推理工程,理解ONNX到NCNN的模型转换与部署流程,掌握移动端前向计算、输入预处理与掩码后处理等关键环节,并在此基础上接入虚拟背景、视频会议或直播抠像等应用。
1. PP-HumanSeg lite 上 NCNN:为什么这套组合值得你花一个下午跑通
如果你手上有一台没有独显的工控机、树莓派或者老款笔记本,却要跑实时人像分割,大概率会经历一轮模型选型焦虑:MediaPipe 自带的 Selfie Segmentation 精度一般,换背景边缘毛刺明显;换成大模型,CPU 上单帧几百毫秒,视频直接卡成 PPT。PP-HumanSeg lite 就是为这个场景准备的——它是 PaddleSeg 系列里专门做人像分割的轻量模型,输入 192×192 或 398×398,参数量小、边缘干净,官方在 Paddle 侧给了完整的训练和导出链路。但真正落地到 C++ 工程里,很多人卡在「Paddle 推理库太重、依赖难装」这一步。NCNN 就是绕开这个问题的常见做法:腾讯开源的纯 C++ 推理框架,无第三方依赖,编译出来一个静态库,模型转成.param+.bin两个文件,塞进你的 C++ 工程里就能跑。这套「PP-HumanSeg lite + NCNN + C++」的组合,适合做视频会议虚拟背景、直播抠像、证件照换底、边缘设备人像预处理这类需求。下面我按自己实际部署的顺序,把模型导出、转换、C++ 推理、后处理、踩坑一条线讲清楚,你照着能复现。
2. 从 Paddle 权重到 NCNN 模型:导出与转换的完整链路
2.1 为什么不能直接拿 Paddle 的 inference model 喂给 NCNN
PaddleSeg 训练完导出的是model.pdparams或者inference.pdmodel+inference.pdiparams,这是 Paddle 自己的序列化格式,NCNN 读不了。中间必须经过 ONNX 这一层。整条链路是:Paddle 动态图权重 → 静态图 inference model → ONNX → NCNN。每一步都有坑,尤其是动态输入尺寸和算子支持。
先确认你的环境。PaddleSeg 对 PaddlePaddle 版本敏感,我一般锁在 2.5.x 配 PaddleSeg 2.8,太新的版本导出 ONNX 时容易遇到paddle2onnx算子映射缺失。安装命令如下:
# 建议在 conda 独立环境里做,避免污染主环境 conda create -n humanseg python=3.9 -y conda activate humanseg # PaddlePaddle CPU 版即可,导出阶段不需要 GPU pip install paddlepaddle==2.5.2 -i https://mirror.baidu.com/pypi/simple pip install paddleseg==2.8.0 pip install paddle2onnx==1.0.6 pip install onnx==1.14.0 onnxruntime==1.16.0参数说明:paddlepaddle选 CPU 版是因为导出只做图变换,不跑前向;paddle2onnx版本必须和 Paddle 匹配,1.0.6 对应 2.5.x 比较稳;onnx锁 1.14 是因为更高版本和paddle2onnx的 IR 版本偶尔冲突。
2.2 导出 PP-HumanSeg lite 的 inference model
PP-HumanSeg lite 的配置文件在 PaddleSeg 仓库的configs/pp_humanseg/下,lite 版本对应pp_humanseg_lite_192x192.yml或398x398。假设你已经下载了官方提供的预训练权重(PaddleSeg 的 model zoo 里有,文件名类似pp_humanseg_lite_192x192_78930),导出命令:
# 进入 PaddleSeg 根目录 python tools/export.py \ --config configs/pp_humanseg/pp_humanseg_lite_192x192.yml \ --model_path pretrained/pp_humanseg_lite_192x192/model.pdparams \ --save_dir output/humanseg_lite执行完output/humanseg_lite/下会有model.pdmodel和model.pdiparams。这里有个关键点:export.py默认会做一次前向,如果配置文件里的val_dataset路径不存在会报错,可以在 yml 里把val_dataset注释掉,或者加--input_shape 1 3 192 192显式指定输入形状。我一般显式指定,避免动态 shape 导出后 ONNX 里带一堆-1,NCNN 转换时对动态维度支持不好。
2.3 转 ONNX 时把动态轴固定住
paddle2onnx \ --model_dir output/humanseg_lite \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file humanseg_lite.onnx \ --opset_version 11 \ --input_shape_dict "{'x': [1, 3, 192, 192]}"参数说明:--opset_version 11是 NCNN 的onnx2ncnn支持最完整的版本,别贪高;--input_shape_dict把输入固定成1×3×192×192,batch 固定为 1,因为部署时基本是单帧推理。如果你的场景需要 398×398,把最后两个数改掉,但注意 NCNN 转换后模型体积和推理时间都会涨。
转完用 onnxruntime 验一下输出形状:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("humanseg_lite.onnx") # 输入名从 sess.get_inputs()[0].name 拿,通常是 'x' inp = np.random.randn(1, 3, 192, 192).astype(np.float32) out = sess.run(None, {"x": inp}) print(out[0].shape) # 期望 (1, 2, 192, 192) 或 (1, 1, 192, 192)逻辑说明:PP-HumanSeg lite 输出是 2 通道(背景/人像)的 logits,经过 argmax 得到 mask。如果输出是 1 通道,说明导出时把 softmax 融进去了,后处理要相应调整。这一步不做验证,后面 NCNN 出来结果不对你根本不知道是哪一层出的问题。
2.4 onnx2ncnn 转换与模型精简
NCNN 官方提供onnx2ncnn工具,需要先编译 NCNN 源码。编译流程后面讲,这里假设你已经有了onnx2ncnn可执行文件:
onnx2ncnn humanseg_lite.onnx humanseg_lite.param humanseg_lite.bin # 用 ncnnoptimize 做算子融合和 fp16 压缩 ncnnoptimize humanseg_lite.param humanseg_lite.bin \ humanseg_lite_opt.param humanseg_lite_opt.bin 65536参数说明:ncnnoptimize最后一个参数65536表示 fp16 存储(65536是 flag,不是精度值),模型体积能砍掉近一半,CPU 推理速度也有提升。但注意:如果你的目标平台是某些老 ARM 芯片,fp16 可能不被支持,那就传0保持 fp32。转换后打开.param文件看一眼第一行,应该是7767517开头的魔数,第二行是层数和 blob 数。如果层数明显偏少,说明转换时丢了算子,得回去查onnx2ncnn的报错日志。
3. NCNN C++ 工程搭建:从编译到第一次推理出图
3.1 编译 NCNN:静态库 + Vulkan 按需选
NCNN 的编译本身不复杂,但选项决定你后面部署的难易。我一般这么配:
git clone https://github.com/Tencent/ncnn.git cd ncnn && mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release \ -DNCNN_VULKAN=OFF \ -DNCNN_BUILD_TOOLS=ON \ -DNCNN_BUILD_EXAMPLES=OFF \ -DNCNN_OPENMP=ON \ -DCMAKE_INSTALL_PREFIX=../install .. make -j$(nproc) make install参数说明:NCNN_VULKAN=OFF是因为人像分割在 CPU 上 192×192 输入已经能到 30fps 以上,开 Vulkan 反而增加部署复杂度(需要 Vulkan SDK 和驱动);NCNN_BUILD_TOOLS=ON是为了拿到onnx2ncnn和ncnnoptimize;NCNN_OPENMP=ON开启多线程,CPU 推理必开。编译完install/下有include/、lib/、bin/,C++ 工程直接链libncnn.a即可。
3.2 C++ 推理代码:加载、预处理、前向、后处理
下面是一段能直接跑的最小推理代码,输入一张 BGR 的cv::Mat,输出人像 mask:
#include <opencv2/opencv.hpp> #include "net.h" // 输入 192x192,输出 2 通道 logits static const int TARGET_W = 192; static const int TARGET_H = 192; cv::Mat humanseg_infer(ncnn::Net& net, const cv::Mat& bgr) { // 1. 预处理:resize 到 192x192,保持简单缩放 cv::Mat resized; cv::resize(bgr, resized, cv::Size(TARGET_W, TARGET_H)); // 2. 转 ncnn::Mat,注意 NCNN 是 RGB 顺序且归一化到 [0,1] ncnn::Mat in = ncnn::Mat::from_pixels( resized.data, ncnn::Mat::PIXEL_BGR2RGB, TARGET_W, TARGET_H); // PP-HumanSeg 训练时用的 mean/std,必须和训练配置一致 const float mean_vals[3] = {0.5f * 255.f, 0.5f * 255.f, 0.5f * 255.f}; const float norm_vals[3] = {1.f / 255.f, 1.f / 255.f, 1.f / 255.f}; in.substract_mean_normalize(mean_vals, norm_vals); // 3. 前向 ncnn::Extractor ex = net.create_extractor(); ex.set_num_threads(4); ex.input("x", in); // "x" 是 onnx 里的输入名,转换后保持一致 ncnn::Mat out; ex.extract("save_infer_model/scale_0.tmp_1", out); // 输出 blob 名以 param 文件为准 // 4. 后处理:2 通道取 argmax,得到 0/1 mask cv::Mat mask(TARGET_H, TARGET_W, CV_8UC1); for (int y = 0; y < TARGET_H; ++y) { const float* p0 = out.channel(0).row(y); const float* p1 = out.channel(1).row(y); uchar* pm = mask.ptr<uchar>(y); for (int x = 0; x < TARGET_W; ++x) { pm[x] = (p1[x] > p0[x]) ? 255 : 0; } } // 5. 放大回原图尺寸 cv::Mat full_mask; cv::resize(mask, full_mask, bgr.size(), 0, 0, cv::INTER_LINEAR); return full_mask; }逻辑说明:预处理里的mean_vals和norm_vals是最容易翻车的地方。PP-HumanSeg 训练配置里Normalize的 mean 是[0.5, 0.5, 0.5]、std 是[0.5, 0.5, 0.5],换算到 0-255 尺度就是上面写的。如果你直接抄别的模型的[104, 117, 123],输出会是一团糊。输出 blob 名不要猜,打开.param文件最后几行看,通常是save_infer_model/scale_0.tmp_1这种,不同导出方式名字会变。
3.3 输入输出 blob 名怎么确认
很多人卡在ex.input("x", in)报找不到 blob。确认方法:打开humanseg_lite_opt.param,第一行是魔数,第二行是层数 blob数,从第三行开始每行是一个层定义,格式是层类型 层名 输入blob数 输出blob数 ...。输入层通常是Input x 0 1 x,所以输入名是x。输出 blob 找最后一层的输出名,或者用 Netron 打开 ONNX 看输出节点名,转换后一般会保留。如果实在找不到,可以在 C++ 里遍历net.blobs()打印所有 blob 名,但 NCNN 的 API 不直接暴露,稳妥办法还是看 param 文件。
3.4 后处理优化:边缘羽化和阈值
argmax 出来的 mask 是硬边,直接贴到原图上会有锯齿。实际项目里我一般做两件事:一是对 logits 做 softmax 得到概率,再用alpha = sigmoid做透明度混合;二是对 mask 做一次 3×3 的高斯模糊,边缘过渡自然很多。如果追求速度,至少把cv::resize的插值从INTER_NEAREST换成INTER_LINEAR,成本几乎为零但观感提升明显。
4. 避坑与排查:部署 PP-HumanSeg lite 时最容易翻车的 5 个点
4.1 现象:推理结果全黑或全白,mask 没有任何人像
原因:预处理归一化参数和训练不一致,或者输入通道顺序错了。PP-HumanSeg 训练用的是 RGB + mean 0.5/std 0.5,如果你用 BGR 直接喂进去,或者 mean 用了 ImageNet 的[0.485, 0.456, 0.406],输出 logits 会整体偏移,argmax 后要么全背景要么全人像。
解决:在 Python 侧用同一张图跑一遍 Paddle 推理,把中间 tensor 打出来,和 C++ 侧对比。最直接的办法是把 C++ 预处理后的ncnn::Mat存成图片,和 Python 预处理后的图做像素级 diff,差超过 1 就说明归一化有问题。
4.2 现象:onnx2ncnn报Unsupported operator Resize或转换后层数缺失
原因:paddle2onnx导出的 ONNX 里 Resize 算子的coordinate_transformation_mode或mode属性 NCNN 不支持。PP-HumanSeg lite 的上采样用的是双线性插值,某些 opset 下会导出成 NCNN 不认的变体。
解决:把--opset_version降到 11,并且在 PaddleSeg 的 yml 里确认上采样方式是bilinear而不是nearest。如果还是不行,用onnx-simplifier先过一遍:pip install onnxsim && onnxsim humanseg_lite.onnx humanseg_lite_sim.onnx,把冗余算子消掉再转。
4.3 现象:C++ 程序编译通过,运行时报find_blob_index_by_name x failed
原因:输入 blob 名不对。ONNX 转换到 NCNN 后,输入名可能被改写,比如加了前缀或者变成input.1。
解决:打开.param文件,找到Input那一行,第一个字段后面的名字就是输入 blob 名。如果 param 里写的是Input input 0 1 x,那输入名是x;如果写的是Input input 0 1 input.1,那就要用input.1。别凭记忆写,一定看文件。
4.4 现象:推理速度远低于预期,192×192 输入单帧超过 100ms
原因:NCNN 编译时没开 OpenMP,或者set_num_threads设成了 1,或者模型没做ncnnoptimize融合。
解决:确认编译选项-DNCNN_OPENMP=ON,C++ 里ex.set_num_threads(4)(按 CPU 核心数设,一般 4 或 8)。另外检查是否误用了 fp32 模型,ncnnoptimize后的 fp16 模型在支持 NEON 的 ARM 上速度差异明显。如果还慢,用ncnn::get_cpu_count()看实际可用核心数,容器环境里可能被限制了。
4.5 现象:视频流处理时内存持续增长,跑几小时 OOM
原因:每帧都create_extractor()但没有复用,或者ncnn::Mat在循环里反复分配大块内存没释放。
解决:ncnn::Extractor可以复用,但注意它不是线程安全的,多线程要每个线程一个 extractor。更关键的是ncnn::Mat的分配,如果每帧输入尺寸固定,可以预分配一个ncnn::Mat反复用from_pixels填充。另外 OpenCV 的cv::Mat在 resize 时也会分配,建议用cv::Mat::create预分配输出 buffer。
5. 进阶技巧:让 PP-HumanSeg lite 在 NCNN 上再快 30% 的两个手段
第一个手段是输入尺寸的动态选择。192×192 是精度和速度的平衡点,但如果你做的是视频会议背景虚化,人物占画面比例大,其实可以降到 160×160 甚至 128×128,速度线性下降,边缘质量在虚化场景下肉眼几乎看不出差别。改法很简单:导出 ONNX 时把--input_shape_dict改成{'x': [1, 3, 160, 160]},C++ 里TARGET_W/TARGET_H同步改,后处理 resize 回原图。我实测在 4 核 ARM 上,192 到 160 能省 25% 左右的时间。
第二个手段是输出层裁剪。PP-HumanSeg lite 最后输出 2 通道,但如果你只需要人像的 alpha 通道,可以在ncnnoptimize之后手动改.param,把最后一层Softmax或ArgMax去掉,直接取第 1 通道的 logits 做 sigmoid。这样省掉一次全图 softmax,192×192 下大概省 3-5ms。改 param 有风险,改完一定用同一张图对比输出,确认数值一致再上线。
验证方法上,我习惯做一个「黄金样本」回归:固定一张带人像的测试图,Python 侧 Paddle 推理存下 mask 作为基准,C++ 侧每次改完代码或模型都跑一遍,算 mask 的 IoU。IoU 低于 0.98 就说明改动引入了偏差,得回查。这个习惯帮我拦下过好几次「以为只是改了预处理、结果把归一化改错」的事故。
最后说个血泪教训:NCNN 的.param文件是纯文本,很多人图省事直接手改,改完不验证就打包发版。我有一次把输出 blob 名改错了一个字符,程序不报错,只是输出全零,排查了一下午才发现。后来我定了个规矩:任何对 param/bin 的改动,必须过一遍黄金样本回归,IoU 达标才算完。这套流程跑顺之后,PP-HumanSeg lite + NCNN 在边缘设备上做人像分割,稳定性和速度都够用,值得你投入。希望帮到你。
本文还有配套的精品资源,点击获取