资讯动态

C# 调用 OnnxRuntime 跑 SAM2:Encoder/Decoder 分离与推理避坑指南

发布时间:2026/10/9 17:28:05 来源:尧图企业网站定制
简介C# OnnxRuntime SAM2工程聚焦C#与OnnxRuntime的集成实践将Meta AI的SAM2图像分割模型封装为可调用的本地推理方案面向需要在.NET环境中实现图像分割功能的开发者覆盖从模型加载到实时分割的完整链路。压缩包共312个文件包含完整的Visual Studio解决方案、13个C#源文件、6个ONNX模型权重以及配套的65个DLL依赖库、XML配置与文档说明体积约810.86MB结构上按工程、演示代码和第三方包分层组织方便直接编译与调试。目前已有482人学习适合医疗影像分析、自动驾驶感知、智能监控等场景的落地验证。借助该项目开发者可快速将SAM2模型接入C#应用理解OnnxRuntime调用流程减少跨语言集成与环境配置的踩坑成本同时借助演示示例掌握提示词分割等关键操作是拓展深度学习应用边界的高质量参考。1. 用 C# 跑 SAM2为什么要把“分割一切”搬进 .NET 项目做桌面端图像标注工具时点一下就想把物体抠出来后台又不能挂 Python 服务C# 直接调 OnnxRuntime 跑 SAM2 是最省事的路线。SAM2 是分割模型里对提示交互最友好的那类点一个坐标就给 mask框选、涂一笔也都能响应这个 .rar 里装的就是把 SAM2 转成 ONNX 后在 C# 侧推理的整套工程——模型文件、C# 项目、示例图片都齐了。这篇笔记我会把 encoder 和 decoder 的分工、C# 推理的完整流程以及最容易翻车的地方讲清楚。适合谁正在做 .NET 桌面应用、想把分割能力离线塞进产品的开发者看完照着做基本能跑通。2. SAM2 ONNX 模型拆解Encoder 与 Decoder 的分工和导出产物2.1 SAM2 和上一代分割模型的差异为什么 SAM2 的 ONNX 更值得做SAM2 全称是 Segment Anything Model 2定位是“可提示分割”你给一个点、一个框或者一个粗略的涂鸦它就能把目标物体完整地切出来。老一代分割模型要么只能分割固定类别要么需要微调才能适配新物体SAM2 靠提示交互不需要针对每个类单独训练这在 C# 侧落地时价值很大——模型文件是通用的换场景不用重新训练。和它的前代相比SAM2 的图像编码器换成了层级结构的 backbone不再依赖笨重的 ViT 大块。这意味着 encoder 推理速度更快内存占用也小一些。对于 .NET 开发者来说还有一点友好官方仓库提供了 ONNX 导出脚本导出产物可以直接用 OnnxRuntime 加载不需要自己写模型转换脚本。视频分割是 SAM2 比较亮眼的新能力但 C# 侧实现视频态要维护 memory bank复杂度会高不少。我一般建议先用图像态跑通视频后续再说。2.2 模型为什么要拆成两个文件encoder 和 decoder 的导出逻辑拿到 .rar 解压后最常见的模型文件是两张sam2_hiera_xxx_encoder.onnx 和 sam2_hiera_xxx_decoder.onnx。第一次接触的人会疑惑为什么不合成一个因为这两者的调用频率完全不对等。encoder 的职责是把一张图像变成高维特征输入是一张 3 通道图像输出是图像嵌入向量和两个高分辨率特征图。这个特征跟“提示”没关系同一张图不管你点哪里encoder 的结果都一样。decoder 的职责是拿到图像特征再结合你给的坐标点、框、掩码这些提示输出分割结果。所以交互场景里encoder 整张图只需要跑一次decoder 每点一下、每拖一次框就要跑一次。官方导出脚本默认就是分开导出的。这样设计在应用层非常合理鼠标点击时只跑 decoder响应速度快GPU 或 CPU 压力小。如果合成一个模型每次点击都要重新过一遍图像编码器交互式标注根本没法用。C# 侧的做法是分开建 InferenceSessionencder 的 session 长驻decoder 的 session 每次点击时调 Run。decoder 的输入不止是坐标点这么简单。从导出摘要看它还需要 image_embeddings、两个 high_res_features、point_coords、point_labels、mask_input、has_mask_input、orig_im_size 这七类输入。其中 high_res_features 是 encoder 输出的额外特征用于保留细节mask_input 和 has_mask_input 用于多轮迭代——上一轮预测的低分辨率 mask 可以作为下一轮的提示让结果越来越稳。C# 里这些都要手动构造 DenseTensor很多新手上手时就是在这里被绕晕的。2.3 拿到压缩包后先检查这几样东西避免一上来就白跑解压 .rar 后不要急着编译先花两分钟确认三件事。第一模型文件的 opset 版本这个决定你要装哪个版本的 Microsoft.ML.OnnxRuntime。你可以用 Netron 打开模型看属性或者跑一小段 Python 用 onnx 库读出来。如果 opset 超过 15OnnxRuntime 版本最好不低于 1.15低于这个阈值会直接报 unsupported opset。第二确认有没有 sample 图片和对应的 prompt 坐标。很多打包工程会附带一张测试图和几个标记点这是验证推理流程是否正确的锚点——你跑出来的 mask 应该大致覆盖目标物体而不是全黑或全白。没有的话用自己手头一张简单场景图也行但要把预处理那套尺寸和归一化参数弄对。第三看一眼说明文档里写的 OnnxRuntime 版本以及是 CPU 还是 GPU 版。显卡推理需要 OnnxRuntime.GPU 包而且 GPU 包内部依赖的 CUDA 和 cuDNN 版本比较挑环境桌面应用发布时这一块翻车率不低。我的习惯是只要不是对延迟特别敏感第一版先用 CPU 版跑通再决定要不要上 GPU。3. C# 侧环境搭建NuGet 包选型与 OnnxRuntime 版本对齐3.1 需要的包OnnxRuntime 主包、Extensions 图像接口、读图方案C# 跑 ONNX 模型入口只有一个Microsoft.ML.OnnxRuntime。这个包是官方维护的推理运行时支持 CPU、GPU、NPU 多种后端。通常只需要这一个包就能完成张量输入输出但图像处理不能只靠它SAM2 的预处理涉及 resize、padding、归一化如果用纯托管代码手动操作像素性能差而且容易写出隐藏 bug。我一般会再装 Microsoft.ML.OnnxRuntime.Extensions。这个包提供 OpenCV 风格的图像接口可以直接把 Mat 转成模型需要的张量省掉一大段手动像素循环。Extensions 包的版本号和主包必须一致——装 1.15.1 的主包配 1.15.1 的 Extensions否则运行时经常撞出 DllNotFound 或者方法签名对不上的玄学异常。读图方面有两种常见路线。一种是 OpenCvSharp4读取后直接得到 Mat和 Extensions 的接口衔接最顺。另一种是 SixLabors.ImageSharp纯托管、跨平台更稳但要先把图像数据转成 byte 数组再塞给 OpenCV 接口。桌面端我做标注工具时推荐 OpenCvSharp4因为后面要做 mask 可视化、轮廓提取也都要用到它。3.2 项目结构把模型文件放在输出目录路径不要写死工程目录里建议单独建 Models 文件夹放两个 onnx 文件同时把它们的“复制到输出目录”属性设为“始终复制”。新手经常遇到的现象是编译正常运行时找不到模型文件一查是文件还在源码目录没被拷贝到 bin/Debug 下。样例结构大致是这样的Sam2Demo/ ├── Models/ │ ├── sam2_hiera_encoder.onnx │ └── sam2_hiera_decoder.onnx ├── Images/ │ └── sample.jpg ├── Program.cs └── Sam2Demo.csprojProgram.cs 里加载模型时用相对路径会更容易换机器部署var encoderPath Path.Combine(AppContext.BaseDirectory, Models, sam2_hiera_encoder.onnx); var decoderPath Path.Combine(AppContext.BaseDirectory, Models, sam2_hiera_decoder.onnx); using var encoderSession new InferenceSession(encoderPath); using var decoderSession new InferenceSession(decoderPath);这里用 AppContext.BaseDirectory 而不是当前工作目录是因为桌面应用有时候工作目录不是 exe 所在目录直接写“Models/xxx.onnx”可能跑到别的路径下找不到文件。这一条算是我踩过几次后的习惯尤其是服务化启动时特别容易中招。3.3 最小加载验证先把模型能跑起来这件事确认掉不要一上来就写完整推理链路先做一个最小验证确认模型能被 OnnxRuntime 正确加载。这一步能过滤掉大部分版本不匹配、文件损坏的问题成本只有几行代码。using Microsoft.ML.OnnxRuntime; using System; var encoderPath Path.Combine(AppContext.BaseDirectory, Models, sam2_hiera_encoder.onnx); using var session new InferenceSession(encoderPath); Console.WriteLine( Encoder 输入 ); foreach (var kv in session.InputMetadata) { Console.WriteLine(${kv.Key}: {string.Join(,, kv.Value.Dimensions)}); } Console.WriteLine( Encoder 输出 ); foreach (var kv in session.OutputMetadata) { Console.WriteLine(${kv.Key}: {string.Join(,, kv.Value.Dimensions)}); }这段代码会打印 encoder 的每个输入名称和维度比如 image 应该是 [1,3,1024,1024]输出里能看见 image_embeddings 是 [1,256,64,64]。确认这一层没问题再往下写预处理和推理。如果有报错优先看异常信息里的节点名称。常见的错是 DllNotFoundException说明 VC 运行库缺失另一种是 Model opset 版本太高Runtime 不认。前一个去装 vc_redist后一个升级 OnnxRuntime NuGet 包。4. 用 C# 实现 SAM2 推理从图像预处理到 mask 输出的完整流程4.1 图像读取与 resize保持长宽比再 pad缩放的底层规则必须对齐SAM2 的 encoder 要求输入是 1024×1024 的正方形。实际操作时不能直接把图拉伸到 1024×1024那样物体会变形分割边缘也会跟着歪。正确做法是先把长边缩放为 1024短边等比缩放然后用灰色填充把图片补到正方形。这个灰色填充值模型训练时用的是 114和很多图像分类模型一样。用 OpenCvSharp4 做这一步代码是这样的using OpenCvSharp; Mat original Cv2.ImRead(imagePath, ImreadModes.Color); Mat rgb new Mat(); Cv2.CvtColor(original, rgb, ColorConversionCodes.BGR2RGB); float scale 1024f / Math.Max(rgb.Width, rgb.Height); int newW (int)Math.Round(rgb.Width * scale); int newH (int)Math.Round(rgb.Height * scale); Mat resized new Mat(); Cv2.Resize(rgb, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); Mat padded new Mat(new Size(1024, 1024), MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(padded[new Rect(0, 0, newW, newH)]);注意 CopyTo 的目标位置是左上角对齐这样图像不会在正方形里居中漂移。后续提示坐标换算时要记住这个左上角对齐的规则避免坐标偏半个图。缩放系数 scale 要保存下来因为 decoder 输出的 mask 最后要还原到原图大小需要这个系数。很多人在这一步只算了新尺寸没存 scale后面就用回不去了。4.2 归一化与张量构造mean/std 参数错一个mask 质量就差一截图像张量在送入 encoder 前要做归一化。SAM2 的归一化参数跟在训练时保持一致mean 是 [123.675, 116.28, 103.53]std 是 [58.395, 57.12, 57.375]注意是 BGR 转成 RGB 之后按 RGB 顺序减。C# 里不能用 OpenCV 的 blob 接口直接归一化因为 SAM2 的 mean/std 是 RGB 顺序而 OpenCV 的 blob from 默认按 BGR。所以稳妥的做法是手动遍历像素。1024×1024 的图遍历三次虽然不慢但也可以先算好归一化后存入 float 数组再构造成张量。var input new float[3 * 1024 * 1024]; unsafe { byte* ptr (byte*)padded.Data; int pixelCount 1024 * 1024; for (int i 0; i pixelCount; i) { float b ptr[i * 3 0]; float g ptr[i * 3 1]; float r ptr[i * 3 2]; input[i] (r - 123.675f) / 58.395f; input[pixelCount i] (g - 116.28f) / 57.12f; input[2 * pixelCount i] (b - 103.53f) / 57.375f; } } var imageTensor new DenseTensorfloat(input, new[] { 1, 3, 1024, 1024 });这段代码用 unsafe 直接访问像素内存性能比逐像素走 Mat.Get 快很多。项目里需要启用 AllowUnsafeBlocks 编译选项Program.cs 顶部加一句 unsafe 关键字就行。如果不想用 unsafe可以用 Marshal 把内存拷过去但速度会慢一些交互场景下不太划算。构造好张量后在 C# 里要用 TensorElementType.Float 明确标注因为 OnnxRuntime 默认张量是 double模型要的是 float类型不匹配会直接抛异常。4.3 Encoder 推理与特征缓存image embedding 只算一次调用 encoder 只需要把 imageTensor 传给名为 image 的输入节点。不同导出版本输入名不一样常见的是 image也有叫 input_image 的建议用上一章的最小验证代码先打印一下输入名再硬编码。using var encoderResult encoderSession.Run(new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image, imageTensor) }); var imageEmbeddings encoderResult .First(x x.Name image_embeddings) .AsTensorfloat(); var highResFeat0 encoderResult .First(x x.Name high_res_features_0) .AsTensorfloat(); var highResFeat1 encoderResult .First(x x.Name high_res_features_1) .AsTensorfloat();这里有个关键点encoderResult 是 IDisposable但 imageEmbeddings 只是 Tensor 的视图。如果不及时释放结果embedding 数据会被回收如果一直不释放会内存泄漏。正确做法是把从 encoder 拿到的三个张量拷贝一份到自己的数组里然后再 dispose encoderResult。float[] embeddingArr imageEmbeddings.ToArray(); float[] high0Arr highResFeat0.ToArray(); float[] high1Arr highResFeat1.ToArray(); encoderResult.Dispose();缓存的 embedding 就是 [1,256,64,64] 的数组后面每次点击都只用它不再碰 encoder。这一步是交互式标注速度快不快的分水岭。4.4 Decoder 推理构造 point prompt 输入注意坐标系decoder 的输入里point_coords 用的是输入给 encoder 的那张 1024×1024 图的坐标系不是原图坐标系。也就是说原图上你点击的点坐标要先乘以 scale 换算成缩放后的坐标才是正确的 point_coords。假设原图宽 2000高 1500scale 1024/2000 0.512。点击点原图坐标 (x,y)缩放后坐标就是 (xscale, yscale)。这一步漏了mask 会落在完全错误的位置而且是经典翻车点。int promptX (int)(clickX * scale); int promptY (int)(clickY * scale); var pointCoords new DenseTensorfloat(new[] { promptX, promptY }, new[] { 1, 2, 2 }); var pointLabels new DenseTensorfloat(new[] { 1f }, new[] { 1, 1 });注意看维度point_coords 是 [1, 2, 2]这里第一个 2 表示“两个点坐标”但我们只给一个点所以构造张量时把第二个维度留一但实际数据是 [x, y] 两个值。很多 C# 新手会在这里纠结维度然后写错 shape。稳妥的方式是看 decoder 的 InputMetadata把 point_coords 的维度打出来按那个去构造。如果不想只给单点也可以传入多个点比如前景多个点都标记为 1背景点标记为 0 或 -1。point_labels 的值语义是1 表示前景点0 表示背景点-1 表示不确定点。背景点的坐标也要经过同样的 scale 换算。mask_input 和 has_mask_input 的构造如下。第一轮没有 mask 提示mask_input 填全零 [1,1,256,256]has_mask_input 填 0。var maskInput new DenseTensorfloat(new float[1 * 1 * 256 * 256], new[] { 1, 1, 256, 256 }); var hasMaskInput new DenseTensorfloat(new[] { 0f }, new[] { 1 }); var origImSize new DenseTensorfloat(new[] { original.Height, original.Width }, new[] { 2 });orig_im_size 的值是原图的高度和宽度这个数组会被 decoder 用来把低分辨率 mask 恢复到原图尺寸。传反了宽高mask 会被压扁或拉长。4.5 后处理从低分辨率分数图到可视化 maskdecoder 的输出有三个masks、iou_predictions、low_res_masks。masks 的 shape 是 [1,4,256,256]四个候选 mask 对应不同的分割精细度iou_predictions 是 [1,4]用来判断哪个候选最好。实际使用中最简单的方案是直接取 iou 分数最高的那个 mask也就是 softmax 后 argmax 的索引。或者用固定索引 0但效果会差一点。var masksTensor decoderResult .First(x x.Name masks) .AsTensorfloat(); var iouTensor decoderResult .First(x x.Name iou_predictions) .AsTensorfloat(); int bestIdx 0; for (int i 1; i iouTensor.Length; i) { if (iouTensor[i] iouTensor[bestIdx]) bestIdx i; }拿到 bestIdx 对应的 256×256 分数图后要做 sigmoid。模型输出的是 logits需要通过 sigmoid 转成概率然后阈值 0.0 是一个经典默认值——SAM 系列模型的输出阈值一般取 0而不是看概率是否大于 0.5。阈值大于 0 会裁掉边缘的软区域小于 0 则会让 mask 膨胀一些。float[] scores new float[256 * 256]; for (int i 0; i 256 * 256; i) { int idx bestIdx * 256 * 256 i; float prob 1f / (1f MathF.Exp(-masksTensor[idx])); scores[i] prob 0.0f ? 1.0f : 0.0f; }最后把 256×256 的 mask 缩放回原图大小。注意不能直接 resize 到原图宽高因为 pad 的部分还没裁掉。正确顺序是先 resize 到缩放后的尺寸newW, newH再把这个区域映射回原图的左上角其余部分裁掉。Mat maskMat new Mat(256, 256, MatType.CV_32F, scores); Mat resizedMask new Mat(); Cv2.Resize(maskMat, resizedMask, new Size(newW, newH), 0, 0, InterpolationFlags.Nearest); Mat finalMask Mat.Zeros(original.Height, original.Width, MatType.CV_8UC1); resizedMask.ConvertTo(resizedMask, MatType.CV_8UC1, 255.0); resizedMask.CopyTo(finalMask[new Rect(0, 0, newW, newH)]);Nearest 插值能保证 mask 边缘是硬边不会产生灰色过渡。如果用了线性插值边缘会出现半透明像素后续做轮廓提取会多一堆假轮廓。这一步踩过坑的同行应该都有印象。5. SAM2 落地的 4 个高频踩坑点从内存翻车到 mask 位置偏移5.1 坑 1mask 整体偏移且边缘被拉伸——orig_im_size 传错现象分割结果主体是对的但整体往右下角偏了一段距离边缘还有明显的拉伸变形像是 mask 被不均匀地拉宽了。原因decoder 的 orig_im_size 输入传成了缩放后的尺寸或者宽高顺序反了。这个参数的作用是告诉 decoder 原始图像分辨率用以还原坐标。如果传成了 1024 或者反了宽高坐标换算就全盘错乱。解决orig_im_size 一定填 original.Height 和 original.Width也就是 Mat 的 Height 和 Width。顺序不能反。拿到 mask 后先在 256×256 尺度上可视化一次确认没偏移再缩放回原图。这一条是新手最常见的问题半天排查不出来最后发现就两个数字的顺序。5.2 坑 2点击一个点mask 是乱七八糟的一片——point_coords 坐标系没统一现象点的位置明明在目标上分割结果却覆盖了半个背景或者 mask 出现在完全无关的区域。原因point_coords 没有乘 scale 换算到 1024 坐标系。假设原图 3000×2000点击点在右下角原图坐标 (2500,1500)如果不乘 0.512币 decorder 会认为点在很靠边的位置结果当然错位。解决在做归一化时就把 scale 保存下来然后在构造 point_coords 时乘以 scale。如果是多轮点击每个点都要乘同一个 scale。代码里加一个断言缩放后的坐标必须在 [0,1024] 范围内超了就说明 scale 计算错误。5.3 坑 3内存缓慢上涨十几轮交互后 GC 压力陡增现象标注工具连续操作二十轮内存从 300MB 涨到 800MB操作越来越卡甚至出现 OutOfMemory。原因每次点击都调 encoderSession.Run 重新算 embedding或者拿到 tensor 后没 dispose 结果集。OnnxRuntime 的 Run 返回值持有非托管资源不释放就等 GC 回收但高频率交互场景下 GC 根本来不及。解决第一确认 encoder 的调用只在图片切换时执行一次缓存 embedding 数组第二所有 Run 结果用 using 包裹或手动 Dispose第三把 embedding 数据 ToArray 拷出来再释放结果集。做了这三点内存增长基本能控制住。5.4 坑 4多轮点击优化不稳定第二轮反而变差——mask_input 处理不当现象第一轮点得挺好第二轮追加一个点后 mask 反而碎了或者直接把第一轮的结果清空了。原因多轮交互时没有把第一轮的低分辨率 mask 传回 decoder。SAM2 设计里has_mask_input 置 1 并传入上一轮的低分辨率 mask模型才能“记得”之前的分割结果。如果每次都是零 mask模型等于每次从零开始没有利用历史信息。解决第二轮开始把上一轮 low_res_masks 中最佳候选的那个 256×256 分数图sigmoid 后作为 mask_inputhas_mask_input 设为 1。需要把 low_res_masks 拷出来缓存。注意低分辨率 mask 要经过 sigmoid不要直接传 logits否则数值范围对不上。6. 让 SAM2 跑得更顺手批量处理、mask 转 Polygon 与交互式点击的进阶做法6.1 用固定 embedding 实现多轮点击把标注工具的延迟压到百毫秒级单图多轮点击是交互标注最常用的场景。架构上把 encoder 的结果固定在内存里decoder 每次只接受新的 prompt 坐标这样一轮交互的耗时只在 decoder 上。CPU 版 decoder 单次推理通常 20-60msGPU 版大多在 5ms 左右完全撑得起实时标注交互。如果还要再快一点可以考虑在解码器输入里同时传入多个候选点模型会一次性输出多个候选的分割结果。比如你点了三个点可以全部放在 point_coords 里一次推理比三次单独推理快不少。注意点越多 decoder 内存占用越高这个方法适合一组点数不超过 10 的场景。6.2 把 mask 转成 Polygon给标注平台和 JSON 导出用很多标注工具不认 mask 位图要求的是多边形坐标。OpenCV 里可以用 FindContours 提取轮廓再用 approxPolyDP 平滑导出为 JSON 数组。Mat mask8u finalMask.Clone(); Cv2.FindContours(mask8u, out Point[][] contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); var polygons new ListListPoint(); foreach (var contour in contours) { var approx Cv2.ApproxPolyDP(contour, 2.0, true); if (approx.Length 3) polygons.Add(approx.ToList()); }approxPolyDP 的 epsilon 参数控制平滑力度2.0 适合一般的物体轮廓太大会把细节抹平太小会保留噪声。这个参数可以做成配置文件标注场景一般用 1.0 到 3.0 之间。6.3 验证流程从 mask 全黑到精确分割用三步自查法收尾我习惯在一套新环境里跑 SAM2 时用“三步自查”来判断链路是否健康。第一步拿一张简单图点一个明显的中心点看 mask 是否覆盖目标核心区域第二步换一个背景点看 mask 是否避开了前景第三步多轮追加点看 mask 是否有延续性。三步都通过说明预处理、坐标换算、后处理链路都是通的哪一步不对就用对应的坑去排查。这套流程我从图像分割做标注工具起一直用到现在。C# 调 OnnxRuntime 跑 SAM2最大的价值是离线、可控、不依赖 Python 环境只要能跨过坐标换算和缓存这两道坎它就能稳定工作在桌面端和服务器端。如果后续要做视频分割核心框架不用改只需要额外维护 memory bank 和帧间状态思路也是从这套 encoder/decoder 分离逻辑延展出去的。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑