简介本资源面向C#开发者与计算机视觉方向的学习者提供基于PaddleInference与PP-HumanSeg的人像分割及背景替换完整源码方案适合需要在.NET桌面端集成人像抠图、绿幕替换或证件照换底等功能的开发者参考。项目基于VS2022与.NET Framework 4.8构建依赖OpenCvSharp4与Sdcb.PaddleInference并内置modnet-hrnet_w18、modnet-mobilenetv2、ppmatting-hrnet_w18-human_512、ppmattingv2-stdc1-human_512共4个分割模型便于对比不同模型在精度与速度上的表现。压缩包共99个文件约478.41MB包含26个dll依赖库、10个cs源码文件、8组pdmodel与pdiparams模型参数、8个yaml配置及若干缓存与工程文件结构完整可直接编译运行。资源保留较多底层调用代码与功能扩展点读者可在此基础上调整推理参数、替换模型或扩展背景处理逻辑。目前已有554人学习下载适合希望快速上手C#人像分割实战的开发者。1. 从一张证件照换底色说起C# 里跑 PP-HumanSeg 到底解决了什么手里有一批员工证件照要换底色蓝底改白底两百多张。用在线工具一张张传不现实。用 PS 批处理边缘发丝抠不干净换完底色人像周围一圈蓝边返工率极高。这时候你需要的不是抠图软件而是一个能嵌进 C# 桌面程序或上位机里的人像分割推理引擎——输入一张图输出一张 alpha 掩膜背景色想换什么换什么。PP-HumanSeg 是飞桨PaddlePaddle体系里专门做人像分割的模型系列轻量版模型体积小、推理快适合在本地 CPU 上跑。而 PaddleInference 是飞桨的推理引擎它提供了 C 和 C 的 APIC# 通过 P/Invoke 调用它封装的动态库就能在 .NET 环境里完成加载模型 → 预处理 → 推理 → 后处理 → 换背景这条完整链路。热搜里常出现 c#上位机、c#开发、c#高级编程这些词说明大量做工业软件、桌面工具的开发者都在找这条路——不是搞深度学习研究是要把 AI 能力塞进已有的 C# 工程里。这套方案适合谁适合有 C# 基础、需要在本地离线跑人像分割的开发者。不适合想一行代码调云 API 的人也不适合完全没碰过原生库调用的新手——你需要理解动态库加载、内存布局、图像通道顺序这些底层概念。但一旦跑通它就是可复用、可扩展、不依赖网络的本地能力。2. 拆开 PaddleInference 的调用链C# 怎么和飞桨推理引擎对话2.1 为什么选 PaddleInference 而不是 ONNX Runtime很多人第一反应是模型转成 ONNX用 ONNX Runtime 的 C# 包不就行了确实更省事。但选 PaddleInference 有几个现实理由。第一PP-HumanSeg 官方发布的推理模型就是飞桨格式.pdmodel .pdiparams转 ONNX 需要额外工具链转换过程中动态 shape、自定义算子容易出问题。第二PaddleInference 对飞桨模型的算子支持是原生的不需要担心某个算子 ONNX 不支持。第三如果你后续要换其他飞桨模型比如 OCR、检测调用链完全一致不用重新搭一套。代价是什么PaddleInference 没有官方 C# 绑定你得自己写 P/Invoke。这就是这套方案的核心工作量所在。热搜里 c#调用c出现access violation c0000005 这个关键词出现频率很高恰恰说明很多人在这个环节翻车——内存管理没对齐指针越界直接崩。2.2 推理引擎的四个核心对象PaddleInference 的 C API 围绕四个对象展开理解它们是写好 P/Invoke 的前提对象作用创建方式Config配置推理参数线程数、是否用 GPU、显存优化PaddleCreateConfigPredictor推理器持有模型和计算图PaddleCreatePredictorInputTensor输入张量承载预处理后的图像数据PaddleCreateTensorOutputTensor输出张量承载分割掩膜结果从 Predictor 获取这四个对象的生命周期必须严格管理。Config 创建 Predictor 后可以释放但 Predictor 必须活到所有推理结束。Tensor 的数据指针指向的是飞桨内部管理的显存或内存你不能自己去 free 它。这是第一个大坑后面避坑章节会细说。2.3 用 P/Invoke 声明最小可用的 API 集合下面这段代码是调用链的地基。我一般会把所有 DllImport 集中放在一个静态类里方便统一管理库路径和调用约定。using System; using System.Runtime.InteropServices; public static class PaddleNative { // 动态库名称Linux 下是 libpaddle_inference.soWindows 下是 paddle_inference.dll private const string DllName paddle_inference; // 创建配置对象返回配置句柄 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern IntPtr PaddleCreateConfig(); // 设置模型路径合并后的模型目录 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleSetModel(IntPtr config, string modelDir); // 设置 CPU 推理线程数 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleSetCpuMathLibraryNumThreads(IntPtr config, int numThreads); // 关闭 IR 优化调试时可开生产建议开启优化 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleSwitchIrOptim(IntPtr config, bool enable); // 根据配置创建推理器 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern IntPtr PaddleCreatePredictor(IntPtr config); // 获取输入张量句柄name 是模型输入层名称 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern IntPtr PaddleGetInputHandle(IntPtr predictor, string name); // 获取输出张量句柄 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern IntPtr PaddleGetOutputHandle(IntPtr predictor, string name); // 重塑张量形状data 是 int 数组size 是维度数 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleReshape(IntPtr tensor, int[] shape, int size); // 拷贝数据到输入张量data 是 float 数组指针 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleCopyFromCpu(IntPtr tensor, float[] data); // 执行推理 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleRun(IntPtr predictor); // 从输出张量拷贝数据到 CPU [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleCopyToCpu(IntPtr tensor, float[] data); // 释放各类对象 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleDestroyPredictor(IntPtr predictor); [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void PaddleDestroyConfig(IntPtr config); }这段代码的关键点在于调用约定必须用 Cdecl因为飞桨的 C API 是 C 风格导出。如果你写成 StdCall在 x64 下可能碰巧能跑但 x86 下栈会乱直接崩。另外 string 参数默认会被 marshal 成 ANSI如果模型路径含中文需要改成[MarshalAs(UnmanagedType.LPStr)]或统一用 UTF-8 编码处理。参数说明PaddleSetCpuMathLibraryNumThreads一般设成物理核心数设太大反而因为线程切换开销导致推理变慢。PaddleSwitchIrOptim生产环境设为 true它会做算子融合推理速度能提升 20% 到 40%。2.4 预处理把 Bitmap 变成模型要的 float 数组PP-HumanSeg 的输入要求是 NCHW 格式归一化到 [0,1] 或特定均值方差。不同版本的 PP-HumanSeg 预处理参数不一样轻量版通常是除以 255 再减均值除标准差或者直接除以 255。你需要看模型目录下的 inference.yml 或文档确认。using System.Drawing; using System.Drawing.Imaging; public static float[] PreprocessImage(Bitmap src, int targetW, int targetH) { // 缩放到模型输入尺寸PP-HumanSeg 轻量版常用 192x192 或 256x256 using var resized new Bitmap(targetW, targetH); using (var g Graphics.FromImage(resized)) { g.InterpolationMode System.Drawing.Drawing2D.InterpolationMode.Bilinear; g.DrawImage(src, 0, 0, targetW, targetH); } // NCHW: 1 * 3 * H * W float[] data new float[1 * 3 * targetH * targetW]; int channelSize targetH * targetW; // 锁定内存避免 GetPixel 逐像素读取的性能灾难 var bmpData resized.LockBits( new Rectangle(0, 0, targetW, targetH), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); try { unsafe { byte* ptr (byte*)bmpData.Scan0; int stride bmpData.Stride; for (int y 0; y targetH; y) { for (int x 0; x targetW; x) { int offset y * stride x * 3; // BGR 顺序飞桨模型通常按 BGR 训练 byte b ptr[offset]; byte g ptr[offset 1]; byte r ptr[offset 2]; int idx y * targetW x; // 归一化到 [0,1]按通道填入 NCHW data[0 * channelSize idx] b / 255.0f; data[1 * channelSize idx] g / 255.0f; data[2 * channelSize idx] r / 255.0f; } } } } finally { resized.UnlockBits(bmpData); } return data; }逻辑说明用 LockBits 拿到像素首地址直接按指针遍历比 GetPixel 快两个数量级。通道顺序必须是 BGR因为飞桨视觉模型默认按 OpenCV 的 BGR 读图训练。如果你用 RGB 顺序喂进去分割结果会出现明显偏移人像边缘会错位。归一化系数要和训练时一致不确定就查模型配套的配置文件。参数说明targetW 和 targetH 必须和模型输入层一致。PP-HumanSeg 轻量版常见输入是 192x192也有 256x256 的版本。你可以用 netron 打开 .pdmodel 看输入层 shape或者直接试——如果 reshape 时报维度不匹配就是尺寸设错了。3. 跑通第一次推理从加载模型到拿到掩膜3.1 完整的推理流程代码把前面的 P/Invoke 声明和预处理拼起来就是一次完整推理。下面这段是我实际项目里精简后的版本去掉了业务逻辑只保留核心链路。public static float[] RunHumanSeg(Bitmap src, string modelDir, int inputSize) { // 1. 创建配置 IntPtr config PaddleNative.PaddleCreateConfig(); PaddleNative.PaddleSetModel(config, modelDir); PaddleNative.PaddleSetCpuMathLibraryNumThreads(config, 4); PaddleNative.PaddleSwitchIrOptim(config, true); // 2. 创建推理器 IntPtr predictor PaddleNative.PaddleCreatePredictor(config); PaddleNative.PaddleDestroyConfig(config); // 配置用完即释放 // 3. 准备输入 float[] inputData PreprocessImage(src, inputSize, inputSize); IntPtr inputTensor PaddleNative.PaddleGetInputHandle(predictor, x); int[] inputShape new int[] { 1, 3, inputSize, inputSize }; PaddleNative.PaddleReshape(inputTensor, inputShape, 4); PaddleNative.PaddleCopyFromCpu(inputTensor, inputData); // 4. 推理 PaddleNative.PaddleRun(predictor); // 5. 取输出 IntPtr outputTensor PaddleNative.PaddleGetOutputHandle(predictor, save_infer_model/scale_0.tmp_1); // 输出是 1*1*H*W 的概率图 float[] outputData new float[inputSize * inputSize]; PaddleNative.PaddleCopyToCpu(outputTensor, outputData); // 6. 释放推理器 PaddleNative.PaddleDestroyPredictor(predictor); return outputData; }逻辑说明输入层名称 x 和输出层名称需要根据实际模型确认。不同版本 PP-HumanSeg 的层名可能不同用 netron 打开模型文件就能看到。输出是一个 [0,1] 之间的概率图值越接近 1 表示越可能是人像前景。参数说明PaddleDestroyConfig在创建 predictor 之后就可以调用因为 predictor 已经拷贝了配置信息。但PaddleDestroyPredictor必须等所有 tensor 数据都拷贝完之后再调否则 tensor 句柄会变成野指针。3.2 后处理概率图变二值掩膜再换背景拿到概率图之后需要做阈值化、缩放回原图尺寸、再做背景替换。public static Bitmap ReplaceBackground(Bitmap src, float[] mask, int maskSize, float threshold, Color bgColor) { int w src.Width; int h src.Height; Bitmap result new Bitmap(w, h, PixelFormat.Format24bppRgb); var srcData src.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); var dstData result.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); try { unsafe { byte* sp (byte*)srcData.Scan0; byte* dp (byte*)dstData.Scan0; int srcStride srcData.Stride; int dstStride dstData.Stride; for (int y 0; y h; y) { for (int x 0; x w; x) { // 最近邻采样掩膜映射回原图坐标 int mx x * maskSize / w; int my y * maskSize / h; float prob mask[my * maskSize mx]; int sOff y * srcStride x * 3; int dOff y * dstStride x * 3; if (prob threshold) { // 前景保留原像素 dp[dOff] sp[sOff]; dp[dOff 1] sp[sOff 1]; dp[dOff 2] sp[sOff 2]; } else { // 背景替换为目标色 dp[dOff] bgColor.B; dp[dOff 1] bgColor.G; dp[dOff 2] bgColor.R; } } } } } finally { src.UnlockBits(srcData); result.UnlockBits(dstData); } return result; }逻辑说明掩膜尺寸通常小于原图需要做坐标映射。这里用最近邻是为了速度如果追求边缘质量可以改成双线性插值。阈值 threshold 一般取 0.5但证件照场景建议取 0.6 到 0.7宁可少保留一点前景也不要让背景色渗进人像边缘。参数说明bgColor 是目标背景色白底用 Color.White蓝底用 Color.FromArgb(67, 142, 219) 这种标准证件照蓝。阈值调高会让边缘更干净但可能吃掉发丝调低会保留发丝但边缘可能有杂色需要根据实际图片质量权衡。3.3 模型文件从哪来、怎么放PP-HumanSeg 的推理模型需要从飞桨官方模型库获取通常包含三个文件model.pdmodel网络结构、model.pdiparams权重、inference.yml预处理配置。把这三个文件放在同一个目录下PaddleSetModel传这个目录路径即可。目录结构建议这样组织models/ humanseg/ model.pdmodel model.pdiparams inference.yml注意路径不要含中文和空格虽然理论上可以处理但 P/Invoke 的字符串 marshal 在跨平台时容易出玄学问题。我一般会在程序启动时把模型目录拷贝到临时目录再加载避免路径问题。4. 避坑指南C# 调 PaddleInference 最容易翻车的五个地方4.1 现象程序跑几次后直接崩溃报 AccessViolationException原因Tensor 的数据指针在 predictor 释放后被访问。很多人把PaddleDestroyPredictor放在 using 块里但输出数据的拷贝是异步的或者延迟的predictor 销毁后 tensor 句柄失效再访问就是野指针。解决确保PaddleCopyToCpu执行完毕后再销毁 predictor。如果用了多线程每个线程独立创建 predictor不要共享。另外PaddleCopyToCpu传入的 float 数组长度必须和输出 tensor 的元素总数完全一致少一个元素都会越界写。4.2 现象推理结果全黑或全白掩膜没有任何区分度原因预处理归一化参数不对。PP-HumanSeg 不同版本的归一化方式不同有的是除以 255有的是减均值除标准差。如果你用错了输入数据分布和训练时差异巨大模型输出会饱和。解决打开 inference.yml 看预处理配置。如果没有这个文件用 netron 看模型第一层是不是有 Sub 和 Div 操作从中反推均值和标准差。常见配置是 mean[0.5, 0.5, 0.5]std[0.5, 0.5, 0.5]等价于 (x/255 - 0.5) / 0.5。4.3 现象输出层名称找不到PaddleGetOutputHandle返回 IntPtr.Zero原因不同版本 PP-HumanSeg 的输出层名称不一样。有的叫save_infer_model/scale_0.tmp_1有的叫sigmoid_0.tmp_0还有的直接叫output。解决用 netron 打开 .pdmodel 文件看最后一层的名称。或者写个辅助函数遍历所有输出层名称打印出来。飞桨 C API 有PaddleGetOutputNames可以获取所有输出层名但需要额外的 P/Invoke 声明。4.4 现象CPU 推理速度极慢一张 192x192 的图要好几秒原因线程数设成了 1或者 IR 优化没开或者每次推理都重新创建 predictor。解决PaddleSetCpuMathLibraryNumThreads设成物理核心数PaddleSwitchIrOptim设为 true。最重要的是 predictor 要复用——创建一次多次推理不要每张图都创建销毁。如果图片量大可以做成 predictor 池多线程并行推理。4.5 现象边缘有蓝边或白边换底色后很明显原因掩膜阈值太低背景像素被误判为前景或者掩膜分辨率太低边缘过渡区域被硬切。解决阈值从 0.5 提到 0.65 左右。如果还不行对掩膜做一次形态学腐蚀把边缘收缩一两个像素。更高级的做法是用引导滤波对掩膜做边缘细化但那是另一个话题了。证件照场景下阈值 0.7 加一次 3x3 腐蚀基本能解决 90% 的蓝边问题。5. 扩展点从单张换底色到批量处理和更多玩法跑通单张之后真正的价值在于扩展。第一个扩展方向是批量处理把 predictor 做成单例开一个线程池每张图走一遍预处理、推理、后处理输出到指定目录。实测在 4 核 CPU 上192x192 输入单张推理约 80 到 120 毫秒两百张图不到半分钟跑完。第二个扩展方向是换背景图而不只是换纯色。把后处理里的纯色填充改成从背景图对应位置采样就变成了虚拟背景。注意背景图要先缩放到和原图一样大否则坐标对不上。第三个扩展方向是接摄像头做实时分割。用 DirectShow 或 MediaFoundation 抓帧每帧走一次推理。192x192 输入在 CPU 上大概能到 8 到 10 FPS够做演示。如果要更流畅可以换更小的输入尺寸或者上 GPU 版 PaddleInference。第四个扩展方向是模型热切换。把模型目录做成配置项程序启动时扫描可用模型列表用户可以在界面上切换。不同模型输入尺寸可能不同所以预处理里的 inputSize 也要跟着变。我一般会在模型目录里放一个 meta.json记录输入尺寸、归一化参数、输出层名称加载时先读这个文件。最后一个技巧如果你发现推理结果在某些图片上特别差先别怀疑代码把原图保存下来用 Python 版的 PaddleInference 跑一遍对比。如果 Python 也差那是模型本身的问题换模型或者调阈值如果 Python 好而 C# 差那一定是预处理或后处理的参数对不上。这个对比法帮我省了无数次瞎调试的时间。我自己的习惯是每接一个新模型先用 Python 跑通推理脚本把输入输出和中间结果都存成 npy 文件然后在 C# 里逐步对比每一步的数值。数值对上了结果自然就对了。这个笨办法看起来慢但比在 C# 里盲猜参数快得多。希望帮到你。本文还有配套的精品资源点击获取