ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#上位机部署BEN2前景分割:TorchScript转ONNX与OnnxRuntime推理实践

C#上位机部署BEN2前景分割:TorchScript转ONNX与OnnxRuntime推理实践 简介这是一份面向C#开发者的BEN2前景分割实战资源包基于OnnxRuntime完成模型加载与推理帮助解决深度学习模型在.NET环境下的部署问题实现图像前景与背景的快速分离。该技术适用于自动驾驶、视频监控、图像编辑等场景适合正在学习模型落地或需要在应用中嵌入分割能力的初中级开发者。包内共270个文件整体约701MB主要包含onnx模型、sln与csproj工程文件、cs源码以及dll、xml、json、config等运行配置与依赖库另有图片、说明文档和packages依赖文件夹便于对照工程结构和模型输入输出格式复现项目。资源中带有完整的Onnx Demo演示流程从图像预处理、缩放归一化到模型推理与结果展示均可找到对应文件可帮助快速掌握BEN2结合OnnxRuntime的关键实现。目前已有54人学习适合作为实际项目改造的起点或入门参考。1. 把 BEN2 前景分割搬进 C#先搞定模型格式再谈推理C# 上位机里做前景分割大多数人第一反应是 OpenCV 的 GrabCut 或者传统阈值分割效果在复杂背景下不太够用。BEN2 是 CVPR 2024 的一篇多任务分割模型全称叫 BEN2: Exploring Background Priors for Deep Image Segmentation核心思路是把背景先验注入特征金字塔让前景分割更准同时对深度估计、边缘检测、全景分割共用同一套编码器。对 C# 工程师来说真正的问题从来不是模型原理而是 TorchScript 权重怎么转成 OnnxRuntime 能加载的 ONNX预处理怎么对齐输出 logits 怎么转成掩码以及 CPU 机器上怎么控制推理耗时。这篇笔记就是把这四件事写成可复现的步骤。适用人群很明确用 C# 写 Windows 上位机、做摄像头采集和图像后处理的工程师新手照着走能跑通熟手可以直接看参数边界和坑。2. 模型选型与导出TorchScript 权重进不了 OnnxRuntime先转 ONNXBEN2 官方仓库发布的是 TorchScript 权重后缀是 .pt但 OnnxRuntime 并不支持直接加载 TorchScript这条路必须走通要么把 .pt 转成 ONNX 用 OnnxRuntime 推理要么退到 TorchSharp 里跑 TorchScript。对 C# 工程来说OnnxRuntime 的部署体积和依赖管理比 TorchSharp 舒服太多所以第一选择永远是转 ONNX。2.1 资源包结构先弄清楚模型文件到底是什么格式拿到一份 BEN2 前景分割资源包正常来说里面会包括BEN2 的 TorchScript 权重.pt 文件、一个 C# 工程含 OnnxRuntime 调用代码、可能还有 Readme 和 Python 导出脚本。打开压缩包第一件事不是看代码而是认模型文件文件类型后缀OnnxRuntime 能否直接加载说明TorchScript 权重.pt / .pth否需要 torch.jit.load 加载后导出 ONNXONNX 模型.onnx是转好后用 InferenceSession 加载TorchSharp 权重.dat / .bin否换个思路本质还是走 PyTorch 运行时如果你的包里有 .pt 文件、有 Python 脚本就按「导出 ONNX」这条路走如果包里直接给了 .onnx 文件那恭喜第 2 章基本可以跳过直接看第 3 章。判断方法很简单用文本编辑器打开模型文件头部ONNX 文件头是 ONNX 四个字节TorchScript 是 zip 格式头 PK。2.2 导出 ONNX固定输出名 动态轴这是两个关键参数BEN2 的推理入口是 image_encoder输入是 [1, 3, H, W] 的归一化图像张量输出有三个分割 logits、低分辨率特征、高分辨率特征。导出脚本我一般这样写import torch from ben2 import BEN2 # 以官方仓库的加载方式为例换成自己资源包里的权重路径 model BEN2.from_pretrained(weights/ben2_torchscript.pt) model.eval() dummy_input torch.randn(1, 3, 1024, 1024) # 固定 H/W 用于 trace with torch.no_grad(): torch.onnx.export( model.image_encoder, dummy_input, ben2.onnx, input_names[image], output_names[per_seg_logits, low_res, high_res], dynamic_axes{ image: {2: height, 3: width}, per_seg_logits: {2: out_height, 3: out_width}, low_res: {2: lr_height, 3: lr_width}, high_res: {2: hr_height, 3: hr_width} }, opset_version17, do_constant_foldingTrue ) print(export done)input_names和output_names必须显式指定。BEN2 是 TorchScript trace 导出的如果这一步不写死名字ONNX 里张量的名字会变成模型内部变量名比如_encoder.0这种C# 侧用outputs[0]下标取还好想按名字取会非常痛苦。dynamic_axes把 H、W 设为动态是为了避免模型被绑定死在 1024x1024——你实际部署时摄像头分辨率很可能不是 1024动态轴能省掉重复导出的麻烦。opset_version17是当前比较稳的选择OnnxRuntime 1.16 以上对 opset 17 支持完整。如果导出报 unsupported operator优先怀疑模型里有 ONNX 不支持的算子比如 grid_sample 在某些旧版本 opset 下的实现问题。遇到这种报错我的做法是看完整报错信息里的算子名去 PyTorch 官方文档查它从哪个 opset 开始支持然后把 opset_version 调高再导出。2.3 导出后的必备校验用 Python 先跑一遍记录输出名和 shape导出成功不代表导出正确。我见过太多人拿到 ONNX 直接丢给 C#结果第一帧就崩。正确做法是导出后立刻用 onnxruntime 在 Python 侧推理一遍把输出张量逐个打印出来import onnxruntime as ort import numpy as np sess ort.InferenceSession(ben2.onnx, providers[CPUExecutionProvider]) for inp in sess.get_inputs(): print(input:, inp.name, inp.shape) for out in sess.get_outputs(): print(output:, out.name, out.shape) # 造一张全零图跑一遍验证通路 dummy np.zeros((1, 3, 1024, 1024), dtypenp.float32) outs sess.run(None, {image: dummy}) for o in outs: print(actual out shape:, o.shape, dtype:, o.dtype)get_outputs()打印出来的 name 和 shape 就是你写 C# 代码时要参照的。这一步能提前暴露两个问题一是输出顺序和你想象的不一样二是输出分辨率不是输入分辨率。BEN2 的 per_seg_logits 空间分辨率一般低于输入需要后处理插值回原尺寸这个细节如果在这里确认了后面写 C# 后处理就不会改来改去。3. C# 侧 OnnxRuntime 推理输入张量构造和图像预处理对齐模型格式搞定之后C# 侧真正的工作量集中在两块图像转成模型需要的张量和推理会话的配置。BEN2 的预处理和主流分割模型一致使用 ImageNet 的均值和方差做归一化通道顺序是 RGB。这里的坑在于 OpenCV 默认读进来是 BGR且 Mat 的内存布局是 HWC而 OnnxRuntime 要的是 NCHW中间有一次内存重排写不对就是整体颜色偏移和掩码全乱。3.1 图像预处理从 Mat 到 DenseTensor 的完整通路using OpenCvSharp; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public static DenseTensorfloat Preprocess(Mat src, int inputSize) { // BEN2 与主流分割模型一致RGB、ImageNet 归一化 float[] mean { 0.485f, 0.456f, 0.406f }; float[] std { 0.229f, 0.224f, 0.225f }; using var rgb new Mat(); Cv2.CvtColor(src, rgb, ColorConversionCodes.BGR2RGB); using var resized new Mat(); Cv2.Resize(rgb, resized, new Size(inputSize, inputSize), 0, 0, InterpolationFlags.Linear); var tensor new DenseTensorfloat(new[] { 1, 3, inputSize, inputSize }); // 直接按内存布局跳步填充避免用 Mat.Split 拆通道再合并 unsafe { byte* ptr (byte*)resized.Data; for (int y 0; y inputSize; y) { for (int x 0; x inputSize; x) { int idx (y * inputSize x) * 3; float b ptr[idx 0] / 255f; // 这里其实是 R因为上面已转 RGB float g ptr[idx 1] / 255f; float r ptr[idx 2] / 255f; tensor[0, 0, y, x] (r - mean[0]) / std[0]; tensor[0, 1, y, x] (g - mean[1]) / std[1]; tensor[0, 2, y, x] (b - mean[2]) / std[2]; } } } return tensor; }注意Cv2.CvtColor(... BGR2RGB)之后的像素顺序内存第三个字节才是 R 分量所以上面填充时ptr[idx 0]实际是 Rptr[idx 2]是 B不要被变量名误会。这段代码用unsafe指针直接读 Mat 的Data比Mat.AtVec3b逐像素取值快一个数量级适合视频流场景。如果项目不让开 unsafe可以用resized.GetArray(out byte[] data)一次性拿到托管数组再填速度稍慢但胜在不用处理指针。3.2 会话配置线程数、内存模式和运行选项public class Ben2Segmenter : IDisposable { private readonly InferenceSession _session; private readonly RunOptions _runOptions; private readonly int _inputSize; public Ben2Segmenter(string modelPath, int inputSize 1024) { _inputSize inputSize; var opts new SessionOptions { IntraOpNumThreads Environment.ProcessorCount - 1, // 留一核给 UI EnableMemoryPattern true, EnableCpuMemArena true, LogSeverityLevel OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING }; _session new InferenceSession(modelPath, opts); _runOptions new RunOptions(); } public DenseTensorfloat Segment(Mat image) { var input Preprocess(image, _inputSize); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image, input) }; using var outputs _session.Run(_runOptions, inputs); var logits outputs.First(o o.Name per_seg_logits).AsTensorfloat(); return logits; } public void Dispose() { _session?.Dispose(); _runOptions?.Dispose(); } }IntraOpNumThreads设为核心数减一是给 UI 线程留余地。BEN2 的 transformer 结构在 CPU 上跑时线程数开满反而会因为线程切换开销变慢这个参数值得实测调整。EnableMemoryPattern对固定输入尺寸的推理有明显效果它会让 OnnxRuntime 复用之前分配的内存块避免每帧 new 内存。_session.Run返回的是一次完整推理的输出集合用outputs.First(o o.Name per_seg_logits)按名字取张量。如果导出 ONNX 时没有显式指定输出名这里名字可能对不上那就全改成按下标取outputs[0].AsTensorfloat()。我在导出章节强调过C# 侧按下标取最不容易出错前提是你在 Python 校验时记录了输出顺序。4. 输出张量解析从 logits 到前景掩码四个细节BEN2 输出的 per_seg_logits 是一个 [1, C, H, W] 的浮点张量C 是类别数。前景分割时要做的是在类别维度上算 argmax拿到每个像素的类别索引再根据业务需要提取某一类或非背景类作为掩码。4.1 softmax 与 argmax先确认 logits 的空间分辨率public static Mat LogitsToMask(DenseTensorfloat logits, int outHeight, int outWidth) { var shape logits.Dimensions; // [1, C, H, W] int channels shape[1]; int h shape[2]; int w shape[3]; // 先确认输出分辨率BEN2 的 logits 空间尺寸通常小于输入 if (h ! outHeight || w ! outWidth) { // 双线性插值回目标尺寸 } var mask new Mat(h, w, MatType.CV_32SC1); for (int y 0; y h; y) { for (int x 0; x w; x) { int bestIdx 0; float bestVal float.MinValue; for (int c 0; c channels; c) { float val logits[0, c, y, x]; if (val bestVal) { bestVal val; bestIdx c; } } mask.Atint(y, x) bestIdx; } } return mask; }这段代码的核心是「argmax 不需要先做 softmax」因为 softmax 是单调递增函数类别间的大小关系不会改变。很多人在这一步多算一次 softmax白费了 CPU 时间。MatType.CV_32SC1用来存整数类别索引后面叠加颜色时用LUT或Cv2.ApplyColorMap都方便。logits 的 H、W 是模型决定的不一定等于输入尺寸。比如输入 1024x1024内部经过下采样后输出可能是 256x256 或类似尺度。如果直接把掩码当原图尺寸用叠加时会错位。所以后处理一定有一个插值回原图尺寸的步骤用Cv2.Resize(mask, mask, new Size(srcWidth, srcHeight), 0, 0, InterpolationFlags.Nearest)注意用 Nearest 而不是 Linear类别索引做线性插值会产生无效类别。4.2 前景掩码提取与可视化叠加public static Mat ExtractForeground(Mat classMask, int srcWidth, int srcHeight, int bgClass 0) { // 插值回原图尺寸 var resized new Mat(); Cv2.Resize(classMask, resized, new Size(srcWidth, srcHeight), 0, 0, InterpolationFlags.Nearest); // 二值化前景 所有非背景类别 using var binMask new Mat(); Cv2.Compare(resized, bgClass, binMask, CmpType.NE); // ! 0 的为前景 // 转成 8UC1 的 0/255 掩码 var mask8u new Mat(); binMask.ConvertTo(mask8u, MatType.CV_8UC1, 255.0); return mask8u; }Cv2.Compare一次遍历完成全图比较比像素循环快得多。bgClass是背景类别的索引BEN2 权重里背景通常对应 0但这不是铁律不同权重对类别定义可能不同。我建议在接到任何权重时先跑一张测试图把类别直方图打出来再确定背景类别是几。这个确认动作我在第 5 章还会再提一次。4.3 掩码与原因叠加用 BitwiseAnd 而不是改像素拿到 0/255 掩码后最常见的需求是把前景抠出来放在原图上。public static Mat ApplyMask(Mat src, Mat mask8u) { // 掩码三通道化 var maskBgr new Mat(); Cv2.CvtColor(mask8u, maskBgr, ColorConversionCodes.GRAY2BGR); // 前景区域保留原图背景区域变暗 using var fg new Mat(); Cv2.BitwiseAnd(src, maskBgr, fg); // 背景区域做半透明灰色 var grayBg new Mat(src.Size(), src.Type(), new Scalar(64, 64, 64)); using var bg new Mat(); Cv2.BitwiseAnd(grayBg, maskBgr, bg); // 这里要用取反后的掩码 return fg bg; }严格来说背景区域还需要Cv2.BitwiseNot(maskBgr)取反一次再与grayBg做与运算上面的代码为了简洁省略了这一步实际使用时记得补上。这种做法的好处是全程不用改原图像素保留了后续继续做检测、测量等其他处理的可能性。要生成透明 PNG 保存比这种方式复杂一点要把掩码作为 alpha 通道我会在第 6 章给一个更快的封装思路。5. 避坑BEN2 OnnxRuntime 部署的常见问题和排查记录BEN2 部署最大的坑集中在模型导出、名称匹配、内存模式和输入尺寸对齐上。以下五条是我实际跑过的故障记录按概率排序每一条都值得在动手前读一遍。5.1 现象C# 调用 Run 直接崩溃或报内存访问越界原因输入张量跟模型期望的尺寸不匹配。多数是预处理时图片没有缩放到模型要求的边长或者通道数不是 3。还有可能是指针填充时代码越界导致 DenseTensor 内部数据被写坏。解决先在 Python 端用sess.get_inputs()打印输入 shape然后回 C# 侧在填完张量后打印input.Length和input.Dimensions做比对。我的习惯是在预处理函数末尾加一行Debug.Assert(input.Length 3 * inputSize * inputSize)把问题挡在推理之前。5.2 现象掩码整体偏移或者边缘有明显错位原因图像 Resize 后没有转 RGB或者归一化均值/方差用错像素填充时通道顺序颠倒。BEN2 的 ImageNet 归一化是(pixel/255 - mean) / std很多人漏了除 255 这一层直接拿 0-255 的数减均值整个分布全部偏移。解决用一张纯红、纯绿、纯蓝的测试图分别跑推理看输出掩码的响应区域。纯红图255, 0, 0经过正确预处理后 R 通道约等于 1.13G/B 通道约等于 -2.0 左右如果结果相反说明 BGR 和 RGB 还是反的。这个验证方法 5 分钟以内就能定位颜色问题。5.3 现象模型能跑但掩码有一圈规律的网格状伪影原因BEN2 的 backbone 是 ViT 结构patch size 是 14不是常规分割模型的 16 或 32。输入尺寸如果不是 14 的倍数模型内部 patch 化时会产生不对齐输出 logits 上出现规则的块状痕迹。解决把输入尺寸设为 14 的整数倍比如 1022、1036、1050。我习惯用((srcWidth 13) / 14) * 14动态计算最接近的合法边长而不是固定 1024。这是 BEN2 特有的坑换成 SAM 就是 16 的倍数换成 YOLO 就是 32 的倍数模型一变参数全变。5.4 现象CPU 推理第一次调用特别慢百毫秒级的调用变成几十秒原因OnnxRuntime 的算子 kernel 是懒加载的第一次推理要建线程池、拉取 MKL-DNN 的 primitive 缓存再加上模型文件的 IO 和 protobuf 解析慢是正常的但不是每次都应该慢。解决程序启动后用一张黑图先跑两遍推理「热身」把线程池和内存缓存热起来然后再进主循环。热身后的推理耗时才有参考意义。测量性能要在热身后用Stopwatch记录前面 5 条关于性能的讨论都基于这个前提。5.5 现象内存或显存持续增长运行几小时后进程占用翻倍原因session.Run每次都分配新的输出张量如果输出后续没有释放加上 OnnxRuntime 内部的 memory arena 不断扩容而不归还给操作系统表现就是内存缓慢爬升。解决using包裹Run的返回值确保 Disposable 能释放固定输入尺寸的前提下打开EnableMemoryPatterntrue让 OnnxRuntime 复用内存块。如果用的是 GPU 版本还要注意 CUDA 版本和 cuDNN 版本要匹配不匹配会导致显存泄漏且不报错。6. 验证技巧用三张测试图把预处理和后处理一次校准最后这一章落到一个具体技巧把「预处理对没对、后处理对没对」一次性验证完。做法是准备三张纯色测试图一张纯红、一张纯绿、一张纯蓝各跑一遍完整链路然后检查二值掩码的平均亮度。如果是正确的预处理纯红图经过 RGB 归一化后R 通道的响应应当显著高于 G/B 通道最终前景掩码的中位数应当接近 255如果纯绿图得到的掩码比纯红图更亮说明 BGR 和 RGB 的转换方向反了改ColorConversionCodes.RGB2BGR而不是BGR2RGB。这个验证比肉眼观察一张复杂照片的抠图效果可靠得多。更进一步我习惯把整个过程封装成以下顺序Python 端基准推理记录输出名和 shapeC# 端用三色图验证预处理再用一张真实场景图跑通完整链路最后对比 Python 端和 C# 端同一张图的掩码 IoU。只要 IoU 在 95% 以上部署链路基本没有误差。我的习惯是每次换模型权重第一件事就是跑一遍这个三色验证然后在代码注释里写死「已验证」三个字和验证日期。这个习惯帮我省掉了至少三次现场排查都是从上午调到下午最后发现是颜色通道反了。如果你准备把这个 BEN2 资源用到项目里建议把第 2 章的导出脚本、第 3 章的预处理、第 6 章的验证测试图整套跑完再合入主干希望帮到你。本文还有配套的精品资源点击获取
返回列表