ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ONNXRuntime 部署 PP-MattingV2:从 PaddleSeg 到跨平台推理

ONNXRuntime 部署 PP-MattingV2:从 PaddleSeg 到跨平台推理 简介这份资源面向深度学习部署与计算机视觉方向的开发者提供在ONNXRuntime上运行PaddleSeg实时人像抠图模型PP-MattingV2的完整实践材料解决跨框架推理与发丝级抠图落地问题。压缩包共7个文件约2.85MB包含Python与C两种推理源码、模型文件、说明文档及示例图片其中Python版本便于快速验证C版本适合性能敏感场景图片可用于直接测试抠图效果。已有501人学习下载说明该方案在模型部署社区具有一定关注度。读者可借此理解ONNX模型转换与推理流程掌握PP-MattingV2的调用方式对比两种语言实现的接口差异并参考说明文档完成环境配置与结果验证适合希望将人像抠图模型集成到实际应用中的中高级开发者。1. ONNXRuntime 部署 PP-MattingV2从 PaddleSeg 训练权重到跨平台推理的完整路径人像抠图这件事在业务里最常见的诉求不是「效果能不能再高 0.5 个点」而是「同一套模型怎么在 Windows 的 C 服务里跑、在 Linux 的 Python 批处理里跑还不用装一整套 PaddlePaddle」。PaddleSeg 里的 PP-MattingV2 属于 Matting 系列里偏工程落地的分支它输出的是带 alpha 通道的软分割结果发丝、半透明区域比普通二分类分割干净得多。但 PaddleSeg 原生推理依赖 Paddle Inference部署到没有 Paddle 运行时的机器上就很别扭。把 PP-MattingV2 导出成 ONNX再用 ONNXRuntime 加载C 和 Python 共用同一份模型文件这件事的性价比很高。这篇笔记面向已经拿到 PaddleSeg 权重、准备做端侧或服务端部署的工程师把导出、预处理对齐、C/Python 双端推理、以及那些一踩一个准的坑讲清楚。2. 为什么选 ONNXRuntime 跑 PP-MattingV2算子、动态轴与部署形态2.1 PP-MattingV2 的网络结构决定了导出难点PP-MattingV2 的典型结构是「骨干网络 引导滤波式的细化头」。它和普通语义分割最大的区别在于输出不是单通道 logits而是经过 refine 之后的 alpha matte且中间会用到一些对分辨率敏感的操作比如上采样、concat、以及基于低分辨率引导图的滤波近似。这些操作在 Paddle 里能跑不代表导出 ONNX 后每个算子都能被 ONNXRuntime 的 CPU EP 正确执行。导出前要先确认两件事一是你用的 PaddleSeg 版本里 PP-MattingV2 的导出脚本是否已经支持--output_op none二是模型输入是否被固定成了1x3xHxW。很多翻车现场就是导出时写死了 512x512结果上线遇到 1080P 输入直接报 shape mismatch。常见做法是先用 PaddleSeg 自带的export.py导出静态图再用paddle2onnx转 ONNX。命令大致如下# 导出 Paddle 静态图注意 input_shape 按你实际业务的最大边来设 python tools/export.py \ --config configs/ppmatting/ppmattingv2_stdc1k.yml \ --model_path output/ppmattingv2/best_model/model.pdparams \ --save_dir output/export \ --input_shape 1 3 1024 1024 \ --output_op none--output_op none是关键它保证输出的是原始 alpha 预测而不是被 argmax 或 sigmoid 包一层的后处理结果。--input_shape这里给的是导出时的占位形状后面转 ONNX 时如果想让 H/W 动态需要在 paddle2onnx 阶段显式指定动态轴。# 转 ONNX把 H/W 设为动态batch 固定为 1 paddle2onnx \ --model_dir output/export \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file ppmattingv2.onnx \ --opset_version 11 \ --input_shape_dict {x: [1, 3, -1, -1]} \ --enable_onnx_checker True--opset_version 11是比较稳的选择再低可能缺算子再高部分 ONNXRuntime 版本兼容性反而变差。--enable_onnx_checker True会在导出后做一次结构校验能提前暴露大部分算子问题。转完之后用onnxruntime的 Python 接口跑一遍随机输入确认输出 shape 和数值范围正常再进入 C 端。2.2 动态轴不是越多越好很多人一上来就把 batch、H、W 全设成动态觉得这样最灵活。实际在 CPU 上动态 H/W 会触发 ONNXRuntime 反复做内存重分配和 kernel 选择推理耗时能比固定 shape 高出 30% 以上。我一般会这样取舍batch 固定为 1H/W 设成动态但在业务层做一次 resize把输入统一到 1024 长边短边按比例补齐。这样既保留了不同宽高比的适配能力又让 ONNXRuntime 在多数请求里命中同一套缓存。如果你确定业务输入就是固定分辨率比如直播推流固定 1280x720那就直接把 H/W 写死性能最好。动态轴只在「输入尺寸确实不可控」时才开。2.3 C 和 Python 共用一份 ONNX 的收益Python 端用onnxruntime的InferenceSessionC 端用Ort::Session两者加载的是同一个.onnx文件。这意味着预处理和后处理逻辑必须严格对齐否则会出现「Python 跑出来边缘干净C 跑出来一圈毛刺」的玄学问题。对齐的核心是三件事归一化参数、resize 插值方式、以及 alpha 输出的阈值处理。下面两章分别从 Python 和 C 两侧把这条链路走通。3. Python 侧最小可跑通链路预处理、推理与 alpha 后处理3.1 环境准备与依赖版本Python 端不需要 PaddlePaddle只需要onnxruntime、opencv-python、numpy。如果你用的是 GPU 推理换成onnxruntime-gpu但要注意 CUDA 和 cuDNN 版本匹配CPU 版则完全没这个烦恼。python -m pip install onnxruntime opencv-python numpy装完之后用下面这段代码验证 ONNXRuntime 能正常加载模型并打印输入输出节点信息。这一步能帮你确认模型有没有被正确导出以及输入节点名是不是你以为的那个。import onnxruntime as ort # 加载模型providers 按优先级排列 sess ort.InferenceSession( ppmattingv2.onnx, providers[CPUExecutionProvider] ) # 打印输入输出信息确认节点名和 shape for inp in sess.get_inputs(): print(input:, inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print(output:, out.name, out.shape, out.type)providers里如果写CUDAExecutionProvider但机器上没有对应运行库ONNXRuntime 会静默回退到 CPU不会报错这点要注意。输入节点名常见是x输出可能是save_infer_model/scale_0.tmp_0这类带路径的名字后面 C 端要用同一个名字别写错。3.2 预处理必须和 PaddleSeg 训练时一致PP-MattingV2 训练时的预处理通常是RGB 顺序、除以 255、再按 ImageNet 均值方差归一化。如果你在 Python 端用了 BGR 顺序或者漏了归一化输出 alpha 会整体偏移边缘发灰。import cv2 import numpy as np def preprocess(img_bgr, target_size1024): # PaddleSeg 默认用 RGBOpenCV 读进来是 BGR先转 img cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) h, w img.shape[:2] # 长边缩放到 target_size短边按比例保持宽高比 scale target_size / max(h, w) new_h, new_w int(round(h * scale)), int(round(w * scale)) img cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) # 归一化先 /255再减均值除方差 img img.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) img (img - mean) / std # HWC - CHW - NCHW img img.transpose(2, 0, 1)[np.newaxis, ...] return np.ascontiguousarray(img), (h, w, new_h, new_w)cv2.resize的插值方式要和训练时一致PaddleSeg 默认是双线性。np.ascontiguousarray不能省ONNXRuntime 对非连续内存会多做一次拷贝甚至在某些版本上直接报错。返回的(h, w, new_h, new_w)用于后处理时把 alpha 还原回原图尺寸。3.3 推理与 alpha 还原def infer(sess, img_bgr): input_name sess.get_inputs()[0].name output_name sess.get_outputs()[0].name blob, (h, w, new_h, new_w) preprocess(img_bgr) # 跑推理拿到 alpha alpha sess.run([output_name], {input_name: blob})[0] # 输出 shape 是 [1, 1, new_h, new_w]去掉 batch 和 channel alpha alpha[0, 0] # 裁掉 padding如果有并 resize 回原图尺寸 alpha cv2.resize(alpha, (w, h), interpolationcv2.INTER_LINEAR) # 截断到 [0,1]再转 uint8 alpha np.clip(alpha, 0.0, 1.0) alpha_u8 (alpha * 255).astype(np.uint8) return alpha_u8这里有个容易忽略的点如果你的预处理里做了 padding 到 32 的倍数后处理必须先裁掉 padding 再 resize。上面这段代码假设没有 padding只有等比缩放。np.clip之后转uint8是标准做法但如果你要做合成建议保留 float32 的 alpha合成时精度更高。把 alpha 和原图合成就能得到抠图结果def composite(img_bgr, alpha_u8, bg_color(0, 255, 0)): # alpha 归一化到 [0,1] 并扩到三通道 a (alpha_u8.astype(np.float32) / 255.0)[..., np.newaxis] bg np.zeros_like(img_bgr, dtypenp.float32) bg[:] bg_color fg img_bgr.astype(np.float32) # 标准 alpha 合成公式 out fg * a bg * (1 - a) return out.astype(np.uint8)这套 Python 链路跑通之后你就有了一个基准。C 端只要输出和这里逐像素对齐就说明部署没问题。4. C 侧 ONNXRuntime 推理从环境配置到逐像素对齐4.1 Windows 下的运行库与工程配置C 端最容易卡在环境上。ONNXRuntime 的 C API 依赖onnxruntime.dll和onnxruntime.lib同时还需要 Microsoft Visual C 2015-2022 Redistributable (x64)。如果你机器上缺这个运行库程序启动时会直接报找不到VCRUNTIME140.dll或MSVCP140.dll这不是 ONNXRuntime 的问题装一下 redistributable 就好。在 Visual Studio 里配置工程时需要设置三处头文件目录指向 ONNXRuntime 解压后的include库目录指向lib链接器输入加上onnxruntime.lib。运行时把onnxruntime.dll放到 exe 同目录或者加到 PATH 里。#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include vector #include array // 全局环境整个进程一个就够 static Ort::Env env(ORT_LOGGING_LEVEL_WARNING, ppmatting);Ort::Env建议做成全局或单例反复创建销毁会拖慢启动。日志级别用WARNING就行VERBOSE在批量推理时会把磁盘写满。4.2 加载模型与构造输入张量Ort::Session create_session(const std::wstring model_path) { Ort::SessionOptions opts; // 线程数按 CPU 核数设一般设为物理核数 opts.SetIntraOpNumThreads(4); opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); // Windows 下 model_path 用 wstring避免中文路径问题 return Ort::Session(env, model_path.c_str(), opts); }SetIntraOpNumThreads设成物理核数比较稳设成逻辑核数在部分 CPU 上反而因为超线程争抢变慢。ORT_ENABLE_ALL会启用所有图优化包括算子融合对 Matting 这种含大量 element-wise 操作的模型收益明显。构造输入张量时预处理逻辑必须和 Python 端逐行对应std::vectorfloat preprocess(const cv::Mat bgr, int target_size, int orig_h, int orig_w, int new_h, int new_w) { orig_h bgr.rows; orig_w bgr.cols; cv::Mat rgb; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB); float scale static_castfloat(target_size) / std::max(orig_h, orig_w); new_h static_castint(std::round(orig_h * scale)); new_w static_castint(std::round(orig_w * scale)); cv::Mat resized; cv::resize(rgb, resized, cv::Size(new_w, new_h), 0, 0, cv::INTER_LINEAR); // 转 float 并归一化 resized.convertTo(resized, CV_32FC3, 1.0 / 255.0); // 减均值除方差 std::vectorcv::Mat channels(3); cv::split(resized, channels); const float mean[3] {0.485f, 0.456f, 0.406f}; const float std_[3] {0.229f, 0.224f, 0.225f}; for (int c 0; c 3; c) { channels[c] (channels[c] - mean[c]) / std_[c]; } cv::merge(channels, resized); // HWC - CHW std::vectorfloat blob(3 * new_h * new_w); for (int c 0; c 3; c) { for (int i 0; i new_h * new_w; i) { blob[c * new_h * new_w i] resized.atcv::Vec3f(i / new_w, i % new_w)[c]; } } return blob; }这段代码里cv::INTER_LINEAR和 Python 端一致归一化参数也一致。blob的排布是 CHW和 ONNX 输入要求一致。注意resized.atcv::Vec3f的访问方式如果 new_h/new_w 很大这种逐像素访问会慢可以用指针遍历优化但逻辑上先保证正确。4.3 执行推理与 alpha 还原cv::Mat run_inference(Ort::Session session, const cv::Mat bgr) { int orig_h, orig_w, new_h, new_w; std::vectorfloat blob preprocess(bgr, 1024, orig_h, orig_w, new_h, new_w); // 输入 shape std::arrayint64_t, 4 input_shape{1, 3, new_h, new_w}; auto memory_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, blob.data(), blob.size(), input_shape.data(), input_shape.size()); // 输入输出节点名必须和 Python 端打印出来的一致 const char* input_names[] {x}; const char* output_names[] {save_infer_model/scale_0.tmp_0}; auto outputs session.Run(Ort::RunOptions{nullptr}, input_names, input_tensor, 1, output_names, 1); // 取输出shape 是 [1,1,new_h,new_w] float* out_ptr outputs[0].GetTensorMutableDatafloat(); cv::Mat alpha(new_h, new_w, CV_32FC1, out_ptr); // resize 回原图尺寸 cv::Mat alpha_resized; cv::resize(alpha, alpha_resized, cv::Size(orig_w, orig_h), 0, 0, cv::INTER_LINEAR); // 截断并转 uint8 cv::Mat alpha_u8; alpha_resized.convertTo(alpha_u8, CV_8UC1, 255.0); return alpha_u8; }input_names和output_names必须和模型里实际的名字一致写错会直接抛异常。outputs[0].GetTensorMutableDatafloat()拿到的是模型内部内存的指针cv::Mat构造时不会拷贝所以后续 resize 之前不能释放 outputs。convertTo里的255.0是缩放因子等价于 Python 里的* 255。4.4 用同一张图做 Python/C 对齐验证对齐验证的方法很简单同一张输入图Python 端存下 alpha 为 PNGC 端也存一份然后用 Python 读两张图算最大绝对差。如果最大差在 1 以内说明预处理和后处理基本对齐如果差了几十多半是 RGB/BGR 顺序或者归一化参数写错了。import cv2 import numpy as np a cv2.imread(alpha_py.png, cv2.IMREAD_GRAYSCALE).astype(np.float32) b cv2.imread(alpha_cpp.png, cv2.IMREAD_GRAYSCALE).astype(np.float32) diff np.abs(a - b) print(max diff:, diff.max(), mean diff:, diff.mean())这个验证步骤花不了几分钟但能省掉后面大量「为什么 C 效果不一样」的排查时间。5. 部署 PP-MattingV2 的避坑与排查五个血泪现场5.1 输出 alpha 整体偏灰或全白现象Python 端推理结果边缘模糊或者整张图 alpha 接近 1抠不出背景。原因归一化参数和训练时不一致。常见的是漏了减均值除方差或者用了 BGR 顺序。PP-MattingV2 训练时用的是 RGB ImageNet 归一化任何一步错都会导致输出分布偏移。解决把预处理单独抽成一个函数和 PaddleSeg 的transforms配置逐项对照。最稳妥的办法是用 PaddleSeg 的 Python 推理脚本跑同一张图存下输入 blob 的均值和方差再和你自己的预处理对比。5.2 ONNXRuntime 加载模型报算子不支持现象onnxruntime抛NOT_IMPLEMENTED或No Op registered for XXX。原因paddle2onnx 导出时 opset 版本选低了或者模型里用了 ONNXRuntime CPU EP 不支持的算子。PP-MattingV2 里如果有自定义的引导滤波算子导出后可能变成一组基础算子但如果 opset 太低某些融合算子无法表达。解决把--opset_version提到 11 或 12重新导出。如果还不行用onnxruntime的sess.get_providers()确认当前用的是 CPU EP再检查模型里是否有Resize的coordinate_transformation_mode不被支持。必要时用onnx-simplifier做一次图简化。5.3 C 端编译通过但运行时报找不到 dll现象exe 双击没反应或者命令行报The code execution cannot proceed because onnxruntime.dll was not found。原因onnxruntime.dll不在 exe 同目录也不在 PATH 里。另外如果缺 Microsoft Visual C Redistributable会报VCRUNTIME140.dll缺失。解决把 ONNXRuntime 解压目录下的lib/onnxruntime.dll拷到 exe 同目录。确认已安装 Microsoft Visual C 2015-2022 Redistributable (x64)。用dumpbin /dependents your.exe可以查看 exe 依赖哪些 dll逐个确认。5.4 动态 H/W 导致推理越来越慢现象服务跑一段时间后单次推理耗时从 80ms 涨到 300ms 以上。原因H/W 动态时ONNXRuntime 会为每个新 shape 重新做内存规划和 kernel 选择缓存越积越多。如果业务输入尺寸频繁变化性能会持续下降。解决在业务层做一次 resize把输入统一到固定尺寸比如长边 1024。如果必须支持多种尺寸限制在 2 到 3 种并在初始化时用这些尺寸各跑一次 warmup让 ONNXRuntime 提前建好缓存。5.5 Python 和 C 结果差几个像素现象对齐验证时 max diff 在 5 到 20 之间边缘位置有轻微偏移。原因cv2.resize在 Python 和 C 里的默认插值可能不同或者 Python 端用了INTER_LINEAR而 C 端用了INTER_CUBIC。另外如果 Python 端做了 padding 而 C 端没做resize 的映射关系会不一致。解决两端显式指定同一种插值方式统一用INTER_LINEAR。如果做了 padding确保两端 padding 的位置和数值一致后处理时先裁再 resize。对齐验证的 max diff 控制在 1 以内才算通过。6. 进阶技巧用固定 shape 缓存和半精度把 CPU 推理压到 50ms 以内前面跑通链路之后性能优化是下一个绕不开的话题。PP-MattingV2 在 CPU 上默认跑 1024x1024单次推理大概在 120 到 200ms 之间取决于 CPU 型号。如果业务要求实时比如 15fps 以上就需要做一些取舍。第一个技巧是固定 shape 加 warmup。把输入统一到 1024 长边短边补齐到 32 的倍数然后在服务启动时用一张全零图跑 3 到 5 次 warmup。ONNXRuntime 会在 warmup 阶段完成内存分配和 kernel 选择后续请求直接复用。实测这一步能把首次推理的 300ms 降到稳定后的 80ms 左右。第二个技巧是开启 ONNXRuntime 的图优化和内存复用。SetGraphOptimizationLevel(ORT_ENABLE_ALL)之外还可以设置opts.EnableCpuMemArena()和opts.EnableMemPattern()这两个默认开启但如果你手动关过记得打开。另外SetIntraOpNumThreads设成物理核数SetInterOpNumThreads设成 1因为 Matting 模型基本是单路串行inter-op 并行收益很小。第三个技巧是尝试半精度。ONNXRuntime 的 CPU EP 对 FP16 支持有限直接转 FP16 模型可能跑不起来或者更慢。更实际的做法是用onnxruntime的量化工具做动态量化把 Conv 和 MatMul 转成 INT8。量化后模型体积减半推理速度在支持 AVX512-VNNI 的 CPU 上能提升 30% 到 50%但 alpha 边缘可能会有轻微锯齿。如果业务对边缘质量要求极高量化要谨慎。from onnxruntime.quantization import quantize_dynamic, QuantType # 动态量化权重转 INT8 quantize_dynamic( ppmattingv2.onnx, ppmattingv2_int8.onnx, weight_typeQuantType.QInt8 )量化后的模型用同样的对齐脚本验证一遍如果 max diff 超过 10说明量化对 Matting 任务损伤偏大建议回退到 FP32。最后一个技巧是预处理和后处理的并行化。如果服务是多线程的把 resize 和归一化放到线程池里和推理重叠执行。C 端可以用std::asyncPython 端可以用concurrent.futures。但要注意 ONNXRuntime 的 session 本身是线程安全的多个线程可以共用一个 session不需要加锁。我自己的习惯是任何模型上线前先用固定 shape 跑 100 次取 P50 和 P95再决定要不要量化。不要一上来就追求极限性能先把正确性对齐再逐步压榨。这套流程走下来PP-MattingV2 在 ONNXRuntime 上的部署基本不会出大问题。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进