简介:面向需要为 Windows/Linux 桌面应用集成深度学习图像分割能力的 C# 开发者,这是一套完整的 OnnxRuntime 推理工程方案。项目以 BEN2 前景分割模型为推理核心,涵盖从图像预处理、模型推理到结果输出的完整链路,适合要在业务系统中落地前景/背景分离能力的图像处理从业者。包体共 270 个文件、约 701MB,主要包含 OnnxRuntime 依赖库、2 个 onnx 模型、C# 源码及 sln/csproj 工程配置,另有 xml、txt、md、config 等辅助文件,便于直接还原构建环境。内附 Onnx Demo 示例,可直观查看模型推理流程与运行效果。目前已有 54 人学习/下载。对刚接触深度学习部署的开发者,包中代码展示了如何将图像转换为模型可接受的输入格式并解析输出结果;对有经验的工程师,也可作为快速评估 BEN2 在 C# 侧集成效果、缩短项目启动周期的参考。
1. BEN2 前景分割在 C# 里怎么落地:和检测模型的根本区别
BEN2 是面向通用物体和人物的前景分割模型,输出单通道 alpha 掩码,像素级区分前景和背景。它和 YOLO 这类检测模型不同:检测只给框,BEN2 给每个像素的概率,适合证件照换背景、产品图透明化、直播抠像,以及上位机里的工件轮廓区域定位。在 C# 里跑 BEN2,最实际的路径就是 OnnxRuntime,因为它跨平台、支持 GPU、不需要训练环境,也基本是 .NET 桌面端集成分割模型的默认选择。
这篇文章写给准备把 BEN2 集成进 C# 程序的人。默认你懂一点 C# 和 OpenCvSharp,不懂 ONNX 张量流转也没关系。读完你能在本地跑通一条最小推理链路,也知道哪些参数会影响速度,哪些参数决定掩码边缘质量。
2. 组装 C# 推理环境:BEN2 模型、OnnxRuntime 包、原生依赖
很多人在 rar 包里看到一堆 DLL 和模型文件,第一反应是“引用进工程就能用”。实际上要理清楚的是三层:模型层的 .onnx 文件、运行时层的 OnnxRuntime 原生库、工程层的图像处理库。三层混在一起调试,最容易把问题搞成玄学。先把这三层拆开,后面每一步出错都能快速定位。
2.1 BEN2 的 ONNX 输入输出到底是什么形状
BEN2 这类分割模型基本走的是 BiRefNet 那条技术路线:编码器提取多尺度特征,解码器逐步恢复分辨率,最后输出一层 Sigmoid 概率图。导成 ONNX 后,输入张量形状通常固定为 [1, 3, 1024, 1024],少数版本是 768×768;输出为 [1, 1, 1024, 1024]。如果模型做过动态维度导出,输入里可能带 -1,但单张推理场景没必要开动态维度。
先打印元数据再写代码:
using Microsoft.ML.OnnxRuntime; using var session = new InferenceSession("ben2.onnx"); Console.WriteLine("Inputs:"); foreach (var kv in session.InputMetadata) { Console.WriteLine($" {kv.Key}: {string.Join(",", kv.Value.Dimensions)} {kv.Value.ElementType}"); } Console.WriteLine("Outputs:"); foreach (var kv in session.OutputMetadata) { Console.WriteLine($" {kv.Key}: {string.Join(",", kv.Value.Dimensions)} {kv.Value.ElementType}"); }这是比翻模型文档更可靠的检查办法。维度里打印的 -1 表示动态轴,实际运行时才确定;ElementType 一般是 FLOAT,如果模型被压成 FP16,C# 里读出来的张量就是 Half 而不是 float,后处理也要跟着换类型。
顺带说一个高频踩坑:ONNX 张量按 NCHW 连续存储,而 OpenCV 的 Mat 是 HWC。无论读图还是写图,都要把通道轴和空间轴区分清楚。把 Mat 的原始内存直接拷进 [1, 3, H, W] 张量而不做重排,本质是把 BGR 数据按 RGB 顺序喂给模型,通道顺序全乱,掩码自然不对。
2.2 NuGet 包、x64 平台和 native DLL 三者的关系
C# 工程引包很简单:
dotnet add package Microsoft.ML.OnnxRuntime # 有 NVIDIA GPU 时换这个 dotnet add package Microsoft.ML.OnnxRuntime.GPU但引包只是第一步。其一,OnnxRuntime 的 NuGet 包要求进程是 64 位。要在 csproj 里把平台目标设为 x64,或者在项目属性里勾选“首选 64 位”。因为引进去的 onnxruntime.dll 是原生库,进程位数必须一致;AnyCPU 运行时如果系统给选了 32 位,会抛 BadImageFormatException,这类报错很容易让人误以为是包没装好。
其二,CPU 包和 GPU 包不能同时引用。有人想“有显卡走 GPU,没显卡走 CPU”,结果把两个包都装上,运行时原生库签名冲突,程序直接崩溃。干净的做法是条件编译或在运行时判断 DLL 存在,不同时引用两个 NuGet。
其三,发布时要带上 native 目录。csproj 里我一般这样写:
<PropertyGroup> <RuntimeIdentifier>win-x64</RuntimeIdentifier> <SelfContained>false</SelfContained> <PlatformTarget>x64</PlatformTarget> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup>RuntimeIdentifier 指定后,dotnet publish -c Release会把对应平台的 native DLL 一起拷出。AllowUnsafeBlocks 是为后面用指针访问像素准备的,如果完全用托管方案可以不开。SelfContained=false 是框架依赖发布,目标机要装 .NET 8 runtime;想要单文件带上 runtime 就改为 true,体积多出几十 MB,但现场机器不用装环境。
2.3 会话级参数:线程、优化级别、内存模式一起设好
var options = new SessionOptions(); options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; options.SetIntraOpNumThreads(Environment.ProcessorCount / 2); options.SetInterOpNumThreads(1); options.EnableMemoryPattern = true; options.EnableProfiling = false;这几行参数的实际含义:ORT_ENABLE_ALL 让 OnnxRuntime 对计算图做算子融合,对推理速度影响最直接;IntraOp 线程数控制算子内并行,设成物理核心数即可;InterOp 是算子间流水线并行,单请求推理时设 1 能避免多余的调度开销。EnableMemoryPattern 本质是一次性申请一块内存池,后续每层推理都复用,避免反复 malloc;长时间运行的采集程序必须开着,否则内存碎片会随运行时间增长。
SessionOptions 还涉及执行提供程序。用 GPU 时要在创建 Session 之前把 CUDA EP 加进去:
options.AppendExecutionProvider_CUDA(0);如果没有 CUDA 设备,这行不会报错,OnnxRuntime 只是把 CUDA EP 标记为不可用,推理自动回退到 CPU。这就会造成一个隐蔽降级的坑:你以为在跑 GPU,实际一直在跑 CPU,速度慢得离谱还不报错。
3. C# 跑通 BEN2 完整推理链路:从像素到透明 PNG
这一章给最小可用的三段代码:预处理、推理、后处理。代码基于 .NET 6 或 .NET 8,引用了 OpenCvSharp4 和 Microsoft.ML.OnnxRuntime。
3.1 预处理:读图、Resize、BGR 转 RGB、NCHW 填充
BEN2 的固定输入是正方形。宽高不一致的图必须先 Resize,这里有两种策略:直接拉伸到正方形,或者等比例缩放后补边(letterbox)。两种策略要跟模型导出的前处理分支保持一致,交叉使用会出现奇怪的边缘线。另外要注意归一化方式,BEN2 常见有两种导出:一种直接除 255,一种用 ImageNet 均值方差。我把代码做成开关,两种都能跑:
using OpenCvSharp; using Microsoft.ML.OnnxRuntime.Tensors; public static DenseTensor<float> ToInputTensor(string imagePath, int size = 1024, bool useImagenetNorm = true) { using var src = Cv2.ImRead(imagePath, ImreadModes.Color); using var resized = new Mat(); Cv2.Resize(src, resized, new Size(size, size), 0, 0, InterpolationFlags.Area); var tensor = new DenseTensor<float>(new[] { 1, 3, size, size }); int area = size * size; float[] mean = { 0.485f, 0.456f, 0.406f }; float[] std = { 0.229f, 0.224f, 0.225f }; unsafe { byte* ptr = (byte*)resized.Data; int step = (int)resized.Step(); for (int y = 0; y < size; y++) { byte* row = ptr + y * step; for (int x = 0; x < size; x++) { int i = y * size + x; float r = row[x * 3 + 2] / 255f; float g = row[x * 3 + 1] / 255f; float b = row[x * 3 + 0] / 255f; if (useImagenetNorm) { r = (r - mean[0]) / std[0]; g = (g - mean[1]) / std[1]; b = (b - mean[2]) / std[2]; } tensor[0, 0, i] = r; tensor[0, 1, i] = g; tensor[0, 2, i] = b; } } } return tensor; }这段代码一次性完成了 BGR 转 RGB、归一化、Resize、NCHW 填充,比先 CvtColor 再拷贝少两张全图的内存占用。读取像素用的 Step() 而不是 width*3,是因为 OpenCV 的 Mat 行内存可能存在对齐填充,固定步长在宽度不是 4 的倍数时会错行,推理后掩码边缘会出现横向贯穿的伪影。如果你不想开 unsafe,改成resized.GetArray(out byte[] pixels)再循环,多一次拷贝但更安全,实时性要求不高时完全够用。
3.2 推理:会话复用与 Session.Run 的完整写法
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public sealed class Ben2Segmenter : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; private readonly string _outputName; public Ben2Segmenter(string modelPath) { var opts = new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, EnableMemoryPattern = true }; opts.SetIntraOpNumThreads(Environment.ProcessorCount / 2); opts.SetInterOpNumThreads(1); _session = new InferenceSession(modelPath, opts); _inputName = _session.InputMetadata.Keys.First(); _outputName = _session.OutputMetadata.Keys.First(); } public float[,] Infer(DenseTensor<float> input) { var feed = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_inputName, input) }; using var outputs = _session.Run(feed); var tensor = outputs.First(o => o.Name == _outputName).AsTensor<float>(); int h = tensor.Dimensions[2]; int w = tensor.Dimensions[3]; var result = new float[h, w]; for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) result[y, x] = tensor[0, 0, y, x]; return result; } public void Dispose() => _session.Dispose(); }这里有两个工程细节。第一,输入名和输出名在构造函数里取出来并缓存,因为不同模型的节点名不一样,直接写死 "input" 很可能跑不通。如果 Session.Run 报 name not found,先打印 InputMetadata 看真实名字。第二,InferenceSession 是线程安全的,可以在多路摄像头回调里并发调用 Run。真正的约束是 DenseTensor 不能同时被两个线程拿去推理,一个张量实例不能被共享,每个线程必须准备自己的输入。
输出结果用 float[,] 而不是 DenseTensor,是为了方便后处理直接写进 OpenCV 的 Mat。直接持有 DenseTensor 跨方法传递,会让底层 native 内存的生命周期难以管理,一旦忘记释放,程序长时间跑会内存持续上涨。
3.3 后处理:从概率图到 alpha 通道
模型输出的 1024×1024 概率图要还原到原图分辨率。如果预处理用的是 letterbox,就把掩码里有效区域按比例裁出来再缩放;如果用的是直接拉伸,就整张缩放。下面这段代码把概率图转成 8 位掩码:
using OpenCvSharp; public static Mat ExtractMask(float[,] prob, int srcW, int srcH, double threshold = 0.5, bool letterbox = false) { int h = prob.GetLength(0); int w = prob.GetLength(1); var mask = new Mat(h, w, MatType.CV_32FC1); unsafe { float* p = (float*)mask.Data; int idx = 0; for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) p[idx++] = prob[y, x]; } if (letterbox) { // 不同前处理脚本的补边位置不一样,有居中也有左上对齐 // 先拿一张测试图确认裁切坐标,再写死这个矩形的值 int x0 = 0, y0 = 0, side = Math.Min(w, h); mask = mask[new Rect(x0, y0, side, side)]; } var resized = new Mat(); Cv2.Resize(mask, resized, new Size(srcW, srcH), 0, 0, InterpolationFlags.Cubic); Cv2.Threshold(resized, resized, threshold, 255, ThresholdTypes.Binary); resized.ConvertTo(resized, MatType.CV_8UC1); return resized; }阈值默认 0.5 并不适合所有场景。做透明底时,模型对头发丝、玻璃边缘给的分数往往在 0.3~0.5 之间,阈值切得太干净会丢掉半透明发梢;做测量定位时,背景残渣的分数可能也有 0.2,阈值太低会把残渣包进来。我的习惯是分开处理:透明底用 0.35 配合开运算去掉噪点,测量定位用 0.7 以上并做闭运算填补小孔。
拿到掩码后合成透明 PNG:
public static Mat ComposeAlpha(Mat src, Mat mask8u) { var bg = new Mat(); Cv2.CvtColor(src, bg, ColorConversionCodes.BGR2BGRA); using var channels = Cv2.Split(bg); channels[3] = mask8u; Cv2.Merge(channels, bg); return bg; }特别注意:不要把模型输出的概率图直接当 alpha 用。模型输出是 0~1 的浮点数,alpha 通道要求 0~255,省略 ConvertTo 会把透明信息全部压在接近 0 的区间,合成后的图在 RGB 模式下看不出来,放到深色背景上才能发现整张图是“虚”的。
4. 性能调优:CPU 线程、GPU Provider、会话生命周期
链路跑通之后,下一步是调速度。BEN2 输入是 1024×1024,计算量比 YOLO 检测大不少,参数调优的空间也很大。
4.1 CPU 线程数怎么设才不白烧
var opts = new SessionOptions(); opts.SetIntraOpNumThreads(4); opts.SetInterOpNumThreads(1);BEN2 在 CPU 上的耗时主要由卷积算子决定。在一颗 6 核 12 线程的 Intel 处理器上,IntraOp 线程从 1 加到 6,耗时接近线性下降;加到 12 反而开始波动,因为超线程对浮点卷积几乎无收益,线程切换开销还变大了。所以我的起点是物理核心数,再根据实测微调。测量方法不复杂:准备 5 张特征不同的图,每个线程配置跑 10 次取中位数,耗时波动超过 15% 就说明调度抖动严重,这个配置不适合当前机器。
另一个和线程同等重要的因素是输入内存布局。OnnxRuntime 在遇到 NHWC 输入时,有些模型会自动插入 Transpose 算子,性能会有损失。BEN2 导出基本都是 NCHW,前处理就按 NCHW 填,不要想着省事把 HWC 塞进去让运行时帮你转。
4.2 GPU 不生效的排查顺序
GPU 场景的检查顺序应该是:确认 NuGet 引用的是 GPU 包,确认 NVIDIA 驱动正常,确认 cuDNN 版本匹配,最后看代码里的实际执行提供程序。启动时打印一次:
var providers = OrtEnv.Instance().GetAvailableProviders(); foreach (var p in providers) Console.WriteLine(p); // 如果包版本较新,OrtEnv 被标为过时,改用同包内的静态方法如果列表里有 CUDA,说明环境识别成功;列表里只有 CPU,说明 CUDA EP 加载失败。即使后面推理能出结果,也大概率在 CPU 上跑。另外要确认日志里有没有一行 “Added CUDA execution provider”,没有这行就是没启用成功。
首次推理时如果耗时两三秒,那是 CUDA EP 在初始化 cuDNN 的算法工作区,属于冷启动。业务代码里可以先喂一张全黑图做预热,把慢的初始化挡在实际任务之前。TensorRT EP 对 BEN2 这类网络收益有限,还容易引入算子兼容问题,一般不需要专门去配。
4.3 会话为何不能每帧重建
很多上位机工程有个坏习惯:每一帧都 new InferenceSession。这会导致模型文件反复读盘、计算图反复优化、native 内存只增不减。正确做法是程序启动时创建一个 Ben2Segmenter 长生命周期对象,摄像头回调或任务队列里只调 Infer。
内存回收方面,Run 返回的 results 对象持有大量 native 内存,必须在同一个调用链里用完就释放。用了using var outputs = _session.Run(feed)编译器会在方法末尾自动释放,但如果把输出对象缓存在成员变量里,内存就不会及时归还。另一个误区是在 Dispose 里调用 GC.Collect,OnnxRuntime 的 native 内存在非托管堆,GC.Collect 管不到,还会造成全局暂停丢帧。信任 InferenceSession 的 Dispose 内部释放逻辑,退出时统一释放即可。
5. BEN2 + OnnxRuntime 避坑实录:5 个容易翻车的点
写成“现象 → 原因 → 解决”的形式,可以直接当排查手册用。
5.1 现象:Session.Run 抛 “Input tensor name not found”
原因:模型输入节点名和代码里传的名字不一致。很多模型导出后的输入名不是 input,而是 images、pixel_values 等。
解决:先打印session.InputMetadata.Keys,把真实名字缓存到字段里。不要每帧去查字典,代码可读性也更差。
5.2 现象:掩码全黑、全白或一片灰
原因:三种常见情况。一是归一化方式不对,模型要均值方差归一化,代码里只除了 255;二是通道顺序错了,BGR 当 RGB 喂进去;三是模型输出需要 Sigmoid,直接把原始 logits 当了概率。
解决:归一化先改成(pixel/255 - mean) / std,mean 和 std 用 ImageNet 的 [0.485, 0.456, 0.406] / [0.229, 0.224, 0.225],如果原模型不要求就保留纯除 255。再逐项检查通道顺序和 Sigmoid。判断方法很简单,拿一张背景干净的人像图分别验证,黑或白通常就是这三个原因之一。
5.3 现象:GPU 加速没生效,程序还在跑 CPU
原因:最常见的是引了 CPU 版 NuGet,或者 cuDNN 缺失导致 CUDA EP 加载失败,OnnxRuntime 静默回退到 CPU。
解决:启动时打印GetAvailableProviders(),确认列表里有 CUDA;查看 session 日志是否出现 “Added CUDA execution provider”;把 cuDNN 的 DLL 放到输出目录,注意 64 位不要混 32 位。如果还不行,先用 NVIDIA 官方一个最简单的模型做最小验证,排除是 BEN2 模型本身的问题。
5.4 现象:运行时崩在 native 初始化,或者抛 AccessViolationException (c0000005)
原因:BEN2 是 64 位原生模型,进程被跑成 32 位时最容易出现这类崩溃;另一个原因是发布时漏了 native DLL,目标机器缺 VC++ 运行库。OpenCvSharp 的 Mat 指针在没有固定内存时被 GC 移动,也可能触发 AccessViolation,尤其是把 Mat 数据指针传出方法后再使用。
解决:csproj 固定 PlatformTarget=x64,发布时勾选包含本机库,并给安装包带上 VC++ Redistributable。代码里不要保存 Mat.Data 的指针跨方法使用,每次访问像素都在同一个 unsafe 块内完成。如果目标机 CPU 太老不支持 AVX,要考虑降级 OnnxRuntime 到旧版本。
5.5 现象:掩码边缘有清晰的正方形痕迹,或者主体被拉伸变形
原因:模型训练时用了 letterbox 补边,代码里却用了无脑拉伸;反过来也一样。预处理策略和训练策略不一致,输入图像内容与模型见过的分布完全不同。
解决:先确认模型的真实 resize 策略。拿一张宽高比明显不是 1:1 的测试图,分别用两种策略跑,对比边缘质量。letterbox 模式下后处理必须裁掉补边区域再缩放,否则正方形痕迹会留在最终结果里。边缘毛刺多时可以加形态学开闭运算,不要只调阈值。
6. 用灰度直方图和 ROI 叠加验证 BEN2 结果能不能交付
模型不翻车,不等于结果可以交付。最后一步是把跑通的链路变成能对外承诺的结果,我一般分三步验证。
第一步是统计校验。把输出掩码转成灰度图,打印直方图。如果绝大多数像素集中在 0 和 255 两个桶里,说明模型对这个场景的置信度很高,阈值怎么调都稳。如果中间灰大量存在,说明物体边缘包含透明或半透明区域,这类结果做生产交付前要引入自己的边缘规则:是保留半透明边缘用于合成,还是强制二值化用于测量。
第二步是 ROI 边界抽样。选 5 条横向线和 5 条纵向线,把掩码轮廓画到原图上,目测轮廓和真实物体边缘的偏差。如果轮廓在某些区域反复出现锯齿状缺口,大概率是补边方式错误或输入尺寸不足;如果偏差在全图均匀,多半是插值方式问题,把 mask 缩放改成 Cubic 再试。
第三步是应用级验证。做透明底输出时,把合成后的 BGRA 图放到深色和浅色背景上分别检查,半透明残渣会在深色背景下暴露得很明显。做测量定位时,对掩码做一次 FindContours,取面积最大轮廓,计算面积和质心,与人工标注结果对比误差率。这一步其实是在给模型兜底,因为你不知道固定阈值下去掉的到底是噪声还是真实边缘。
就个人习惯来说,我每次拿到新的 BEN2 模型导出,都会先建一个 test 目录,放 10 张涵盖人像、金属件、透明物体、复杂植被背景的图,跑通后把直方图、轮廓叠加图、单张耗时导出成一份验证报告。这套流程虽然要花一两个小时,但之后每次改预处理、换模型、调线程,都能立刻定位是回归还是提升,省掉大量“莫名变好又莫名变坏”的排查时间。希望这个习惯对你有帮助,也祝你能把 BEN2 顺利落到生产链路里。
本文还有配套的精品资源,点击获取