
简介面向需要落地人像抠图能力的C#开发者这里提供的是基于OnnxRuntime部署RMBG-2.0模型、实现高精度背景去除的完整工程方案可适用于视频通话、虚拟现实、游戏互动等实时处理场景对头发丝、衣服纹理等复杂边缘也有较好的分离效果。压缩包共234个文件、约313.76MB包含dll动态库、cs源码文件、xml/json配置、txt说明、图片样例等兼顾运行依赖与示例代码目录结构清晰便于按模块查阅适合直接作为项目基础。已有147人浏览/学习。随包附带可运行的演示项目能直观呈现人像与背景分离效果参照C#示例代码可快速掌握OnnxRuntime加载模型、执行推理的完整流程降低环境配置和依赖调用的上手门槛便于后续二次开发。对需要在Windows/Linux等不同硬件环境部署RMBG-2.0的开发者该资源在工程组织与跨平台依赖处理方面同样具备实践参考价值。1. 把 C# 项目里的人像抠图换掉为什么是 RMBG-2.0 而不是旧方案做 C# 上位机或者 WPF 桌面工具时一旦涉及人像抠图、证件照换底色、商品图去背景最头疼的不是算法选型而是“这套东西能不能离线跑、能不能塞进 .NET 管线”。GrabCut 对深色头发和半透明边缘直接翻车肤色阈值遇到复杂背景就废云接口又要考虑联网和费用。RMBG-2.0 是这个场景里少数能离线部署的开源背景去除模型基于 BiRefNet 结构对半身人像、复杂背景、蓬松发丝都有不错的表现。配合 OnnxRuntime 的 C# 接口一个 NuGet 包就能在 Windows/Linux 下跑推理不用搭 Python 环境。这篇文章把从模型接口、预处理、推理到合成长图的一条完整 C# 链路写清楚也把最容易翻车的几个坑逐个拆开。2. 推理前先清账三件事Onnx 接口、预处理与张量布局2.1 RMBG-2.0 的 Onnx 接口长什么样RMBG-2.0 官方发布的是 PyTorch 权重社区转换出的 Onnx 版本在输入输出命名上不统一。常见方案里输入节点叫input或pixel_values输出节点叫output或mask张量形状一般是[1,3,H,W]输入、[1,1,H,W]或[1,H,W]输出。动态高度宽度是常态这给 C# 里构造DenseTensor带来了第一个麻烦你不知道固定 shape 该填多少只能在运行时拿到预处理后的尺寸再去创建。我拿到手先做一件事把 Onnx 文件拖进 Netron或者用下面这段 Python 把名字和形状打出来免得后面在 C# 里瞎猜。import onnx m onnx.load(rmbg20.onnx) for i in m.graph.input: print(input:, i.name, [d.dim_value for d in i.type.tensor_type.shape.dim]) for o in m.graph.output: print(output:, o.name, [d.dim_value for d in o.type.tensor_type.shape.dim])输出里如果某个维度dim_value是 0 或显示为动态说明这个维度可以变化。input名和output名记下来C# 端NamedOnnxValue.CreateFromTensor(input, tensor)里的字符串必须和这里完全一致。模型本身对输入分辨率没有硬性要求但训练时以 1024 左右为主超过 2048 后边缘质量提升有限推理耗时反而明显上涨所以后面我会把最长边限到 1024 或 2048 两档。2.2 预处理三件套RGB 顺序、归一化与 resize很多人第一次跑通模型拿到全黑掩码八成是预处理顺序错了。Onnx 版 RMBG-2.0 的输入约定有两个流派一派要求喂[0,1]的 RGB 张量归一化已经包进了模型另一派要求喂[0,255]的 RGB模型内部自己算。前者一旦你把像素除以 255 后再传掩码会整体偏灰后者如果你不除掩码边缘会溢出。我的处理方式是把预处理函数里归一化因子做成参数同一份代码两档都支持先按[0,1]跑一张掩码发黑就切[0,255]再跑一张。resize 也讲究必须保持长宽比否则人像会被横向拉伸输出掩码虽然也能用但和原图合成时会出现脸型不对的诡异效果。做法是取最长边做基准缩放短边跟着比例走短边不足的部分不需要补边因为模型是纯卷积结构不依赖固定尺寸。2.3 Bitmap 到 [1,3,H,W]一个能直接用的预处理函数C# 里把Bitmap转成 OnnxRuntime 要的 float 张量最稳的路径是LockBits拿到内存指针再用Marshal.Copy拷到托管数组然后手动按 NCHW 排布。这里有个细节Format24bppRgb在内存里实际是 BGR 字节序必须把 B 和 R 换回来否则抠图结果边缘会有一圈假色。public static float[] PreprocessToTensor(Bitmap src, int maxSide, out int targetW, out int targetH, bool norm01 true) { int origW src.Width, origH src.Height; float scale Math.Min(1f, maxSide / (float)Math.Max(origW, origH)); targetW Math.Max(1, (int)Math.Round(origW * scale)); targetH Math.Max(1, (int)Math.Round(origH * scale)); using var resized new Bitmap(src, targetW, targetH); var rect new Rectangle(0, 0, targetW, targetH); var bmpData resized.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); int stride bmpData.Stride; byte[] pixels new byte[stride * targetH]; Marshal.Copy(bmpData.Scan0, pixels, 0, pixels.Length); resized.UnlockBits(bmpData); float[] tensor new float[3 * targetH * targetW]; int planeSize targetH * targetW; float norm norm01 ? 255f : 1f; for (int row 0; row targetH; row) { int rowBase row * stride; for (int col 0; col targetW; col) { int idx rowBase col * 3; byte b pixels[idx]; byte g pixels[idx 1]; byte r pixels[idx 2]; int outIdx row * targetW col; tensor[outIdx] r / norm; // C0 R tensor[planeSize outIdx] g / norm; // C1 G tensor[2 * planeSize outIdx] b / norm; // C2 B } } return tensor; }逻辑说明LockBits取出的是连续内存stride可能大于targetW * 3因为 GDI 会按 4 字节对齐每行所以逐行计算rowBase而不是用row * targetW * 3去索引。三个通道分别写入tensor的 R、G、B 平面符合 Onnx 的 NCHW 排布。norm01参数控制归一化档位默认按[0,1]处理如果模型要求[0,255]传false即可不用改其他代码。这段代码的性能对单张图片足够批量场景里每个 Bitmap 都重新申请pixels和tensor数组会有 GC 压力后面第 5 章会提怎么复用缓冲区。3. 推理与合成SessionOptions 设置、掩码提取、背景替换3.1 SessionOptions上线前必须设置的四个选项InferenceSession是 OnnxRuntime 在 C# 里的入口很多人直接new InferenceSession(path)就跑了桌面端没问题但放到服务或批量任务里会出幺蛾子。我一般会显式设置SessionOptions四个选项值得逐个说。第一个是IntraOpNumThreads控制算子内部线程数默认会拉满逻辑核推理时把整台机器 CPU 吃满和 UI 线程抢资源桌面工具里我会限到物理核数的一半。第二个是GraphOptimizationLevel默认ORT_ENABLE_ALL这个保持默认就好除非你推理结果异常需要排查底层算子。第三个是EnableMemoryPattern默认开启能把反复推理的内存分配压下去批量场景建议保持开启。第四个是LogSeverityLevel设成ErrorOnnxRuntime 在控制台输出的信息流很吵出问题时再调回Verbose看细节。var options new SessionOptions(); options.IntraOpNumThreads Environment.ProcessorCount / 2; options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; options.EnableMemoryPattern true; options.LogSeverityLevel OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR; var session new InferenceSession(modelPath, options);参数说明IntraOpNumThreads不是越大越好超过 8 以后对单张图片推理加速极微反而增加线程切换开销Environment.ProcessorCount / 2是保守值后续可实际计时再调。如果机器有 NVIDIA 显卡且装了 CUDA 版 OnnxRuntime还可以在options.AppendExecutionProvider_CUDA(0)里指定设备CPU 版和 GPU 版不能混装后面避坑章再展开。3.2 推理主流程从命名张量到掩码提取预处理拿到的是展平 float 数组推理前要包成DenseTensorfloat维度写[1, 3, targetH, targetW]。然后放进NamedOnnxValue名字必须匹配 2.1 里打印出来的输入节点名。运行session.Run返回的是DisposableNamedOnnxValue集合记得用using释放掩码输出可能是[1,1,H,W]也可能是[1,H,W]取值时代码要兼容两种形状。public float[] RunMask(Bitmap source, int maxSide, out int maskW, out int maskH) { float[] preprocessed PreprocessToTensor(source, maxSide, out maskW, out maskH, true); var memory new Memoryfloat(preprocessed); var tensor new DenseTensorfloat(memory, new[] { 1, 3, maskH, maskW }); using var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, tensor) }; using var results _session.Run(inputs); var output results.First().AsTensorfloat(); float[] mask new float[maskW * maskH]; int plane maskW * maskH; if (output.Length plane) // 输出是 [1, H, W] { for (int i 0; i plane; i) mask[i] output[0, i / maskW, i % maskW]; } else // 输出是 [1, 1, H, W] { for (int i 0; i plane; i) mask[i] output[0, 0, i / maskW, i % maskW]; } return mask; }逻辑说明DenseTensor的构造函数第一个参数是Memoryfloat第二个是维度数组Run的输入集合用using包住确保推理中间对象及时释放。取掩码时先判断output.Length是否等于maskW * maskH等于说明少了一个维度走[1,H,W]分支否则按[1,1,H,W]索引同时兼容了不同导出版。results.First()默认拿第一个输出如果你的模型有多个输出节点这里改成按输出名过滤更稳。3.3 合成输出mask resize 回原图并替换背景模型输出的掩码分辨率是推理尺寸不是原图尺寸。要合到原图上不能直接缩放原图去匹配掩码而是把掩码缩放回原图大小否则原图细节全丢。掩码是单通道 float我先转成灰度Bitmap再用 GDI 的Graphics.DrawImage做高质量缩放最后逐像素混合。public static Bitmap MaskToBitmap(float[] mask, int w, int h) { var bmp new Bitmap(w, h, PixelFormat.Format32bppArgb); var data bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); byte[] argb new byte[w * h * 4]; for (int i 0; i mask.Length; i) { byte v (byte)(Math.Clamp(mask[i], 0f, 1f) * 255f); argb[i * 4] v; // B argb[i * 4 1] v; // G argb[i * 4 2] v; // R argb[i * 4 3] 255; // A } Marshal.Copy(argb, 0, data.Scan0, argb.Length); bmp.UnlockBits(data); return bmp; }掩码转灰度图后new Bitmap(source, newW, newH)缩放一次就得到和原图同尺寸的 alpha。合成时把原图逐像素和背景混合公式是output fg * alpha bg * (1 - alpha)。alpha 是 0 到 1 的值掩码里 255 对应前景完全不透明0 对应完全透明中间值就是半透明头发丝边缘的效果全靠这段。public static Bitmap Composite(Bitmap fg, Bitmap bg, Bitmap mask) { var output new Bitmap(fg.Width, fg.Height, PixelFormat.Format24bppRgb); var fgData fg.LockBits(new Rectangle(0, 0, fg.Width, fg.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); var bgData bg.LockBits(new Rectangle(0, 0, bg.Width, bg.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); var mData mask.LockBits(new Rectangle(0, 0, mask.Width, mask.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); var outData output.LockBits(new Rectangle(0, 0, output.Width, output.Height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); // 逐行逐像素混合代码略公式就是 fg * alpha bg * (1 - alpha) fg.UnlockBits(fgData); bg.UnlockBits(bgData); mask.UnlockBits(mData); output.UnlockBits(outData); return output; }参数说明背景图尺寸必须和前景一致不一致时先用Graphics.DrawImage把背景缩放到位掩码是 32bppArgb但实际只用它的灰度值A 通道固定填了 255所以读掩码时直接取 B 通道当 alpha 就行。合成循环里建议用unsafe指针遍历Marshal.Copy到数组再写回也可以但性能会慢一倍左右。4. 部署避坑清单五个让抠图直接翻车的真实问题4.1 掩码全黑或全白现象推理完成输出数组全是 0 或全是 255抠出来什么都没有。原因八成是预处理归一化档位和模型要求不符。[0,1]的模型你喂了[0,255]输入数值整体放大sigmoid 输出饱和到 1反过来输入数值过小输出全部逼近 0。另外 RGB 顺序错误不会导致全黑但会导致边缘假色这一点不要混淆。解决预处理函数里把norm01参数切到另一档重跑一次。如果两档都失败检查 2.1 打印出的输入节点里是否包含div或Sub算子有的话说明模型内部已做归一化C# 端直接喂[0,255]。从那时起我处理任何新 Onnx 抠图模型第一件事永远是先用一张纯色测试图跑通黑白通道再换真人图。4.2 抠出来的边缘带一圈绿现象换背景后人像发丝边缘裹了一层绿色或紫色光环深色衣服上特别明显。原因原图 JPEG 压缩或拍摄时镜头色散让边缘像素本身混入了背景色。RMBG-2.0 的掩码虽然把前景标出来了但前景像素颜色没有去缝合成时就带着一圈原背景的色。解决对掩码做 1 到 2 像素的腐蚀把边缘最外圈前景像素删掉再用高斯模糊把掩码边缘羽化。腐蚀会让轮廓略微内收但对发丝这类细结构基本无感色边消失的效果却是肉眼可见的。腐蚀可以用简单的Erosion形态学算子OpenCVSharp 里有现成接口不想引依赖就自己写一个 3x3 最小值滤波。4.3 CPU 推理速度慢到没法用现象一张 1024x1024 的图CPU 推理耗时超过 3 秒批量处理 100 张图要等五分钟。原因两个来源。一是IntraOpNumThreads没有设置OnnxRuntime 默认创建大量线程线程切换开销远超计算本身二是每次推理都新建InferenceSession模型加载和内存图优化重复执行。后者是特别容易忽略的点。解决运行前先打印session的耗时分布。线程数从 4 开始逐档往上试4、6、8取耗时最低的那档再把InferenceSession提升为类成员整个进程只初始化一次复用同一会话。批量场景里还能把输入图片放进BlockingCollection用 2 到 3 个推理线程并发跑总吞吐能翻一倍。4.4 大图推理直接内存暴涨现象原图 4000x3000预处理后直接推理内存占用冲到 2GB甚至报OutOfMemoryException。原因把原图直接塞进模型模型输入是[1,3,4000,3000]光输入张量就 144MB中间特征图叠加起来轻松到 GB 级。OnnxRuntime 的动态 shape 会为每个新尺寸重新分配工作区频繁切换尺寸也会让内存池碎片化。解决加一道最长边保护。maxSide设 1024 或 2048推理用小图掩码再resize回原图原图本身不参与前向计算。遇到内存池持续增大用session.Dispose()重建会话或者把ServerGarbageCollection关掉桌面工具用工作站 GC 更稳。4.5 Session.Run 报输入维度不匹配现象报错信息类似Input input has shape {1,3,1024,1024} but expected {1,3,512,512}或者维度完全对不上。原因模型输入是固定 shape 的导出版不是动态 shape。或者 C# 端DenseTensor的维度数组顺序写反了把[1,3,H,W]写成了[1,H,W,3]。解决看报错里的 expected shape把模型固定尺寸记下来预处理时把maxSide改成固定值并强制 padding 到该尺寸。非动态模型不能直接喂任意分辨率要么用Resize直接拉伸到固定尺寸要么先保持长宽比缩放再补边。补边用 0 值即可因为模型内部有归一化0 对应黑色背景不影响掩码结果。5. 抠图交付前最后一步半透明边缘、效果自检与收尾习惯5.1 边缘羽化把硬边抠成自然过渡RMBG-2.0 输出的掩码本身带软边但经过我们的双线性resize和阈值截断后半透明区域会被砍掉一部分。我的做法是抠图完成后再做一次羽化掩码先做 1 像素腐蚀去掉色边再做 2 像素膨胀把轮廓放回原位最后用 3x3 高斯模糊把过渡带拉开。这套组合在证件照换底色场景里已经把边缘做得相当干净。羽化后合成公式不变但 alpha 值从 0/1 两档变成 0 到 1 渐变发丝不再是一根根硬线条而是有体积感的一缕缕。5.2 效果自检用像素指标代替肉眼批量处理几百张图靠眼睛一张张看会漏掉问题。我会在流水线里加一个自检步骤统计掩码里 0 到 1 之间像素占比、前景区域面积、边缘梯度均值三个指标。半透明像素占比超过 8% 通常说明羽化过度边缘梯度均值突然增大说明出现了硬边或色边。这个检查不涉及真实标注只能抓明显异常但对工程交付足够。真正要核对精度时拿 20 张手工标注的图用 IoU 算掩码和 Ground Truth 的重合度误差在 3% 以内基本就能放心上线。从那以后我每接到一个新抠图模型的集成任务都强制自己先跑一张纯色测试图验证预处理、再跑一张真人图看边缘、最后跑一批样图看指标波动三步走完才交给业务方。这个习惯帮我避开了至少三次“模型是好的、管线是反的”的尴尬希望帮到你。本文还有配套的精品资源点击获取