ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# OnnxRuntime SAM2:无需Python环境在.NET中跑通SAM2图像分割

C# OnnxRuntime SAM2:无需Python环境在.NET中跑通SAM2图像分割 简介C# OnnxRuntime SAM2 是一套面向 C# 开发者的图像分割推理项目将 Meta 的 SAM 模型与微软 OnnxRuntime 引擎结合解决在 .NET 环境下调用 ONNX 格式模型进行实时分割的难题。适合有 C# 基础、希望落地 SAM 分割技术的算法工程与桌面应用开发者可应用于医学影像分析、自动驾驶感知、智能监控等场景。压缩包共 312 个文件约 810.86MB包含 Visual Studio 解决方案文件sln、C# 源码cs、ONNX 模型与推理示例、依赖库dll/nupkg、配置文件与文档等便于直接编译、调试与二次开发。目前已有 482 人学习下载。通过该资源开发者可以快速掌握 OnnxRuntime 加载 SAM 模型、预处理图像、执行分割及后处理输出的完整流程省去自行搭建环境的繁琐步骤尤其适合需要快速集成图像分割能力到 C# 项目中的工程实践者。1. C# OnnxRuntime SAM2把 Segment Anything 2 请进 .NET 程序做工业质检的某开发者前几天问我能不能把 SAM2 跑在 C# 里不碰 Python 环境我说能而且不需要把 Python 装到产线工控机上。C# OnnxRuntime SAM2 这件事本质上就是把 SAM2 的 ONNX 模型接进 .NET 程序用 OnnxRuntime 做推理让图片分割能力直接内嵌到已有的 C# 业务系统里。它能解决的是「没有 Python 环境、又要用最新分割模型」的落地场景适合做 WinForms、WPF、ASP.NET Core 服务或者 Unity 里需要交互式分割的开发者。这篇笔记从模型准备、C# 调用、参数调到踩坑按我实际跑通的路子讲一遍。2. SAM2 的推理管线与 ONNX 适配先明白它在算什么2.1 SAM2 与 SAM 的差别为什么图像编码器可以复用SAM2 是分割一切模型系列的第二代它在视频跟踪上的能力比第一代强很多但如果你只做单张图片的交互式分割那核心逻辑和 SAM 几乎一致图像编码器先把整张图变成高维特征然后你给它一个点或者一个框作为提示它把提示和特征一起送进掩码解码器最后输出目标物体的轮廓。C# OnnxRuntime SAM2 的压缩包里通常会拆出三个 ONNX 文件图像编码器、提示编码器、掩码解码器原因就在这里——编解码是分开的图片只编一次多个点提示可以反复用同一个特征。理解了这条管线你就知道为什么不能只拿一个 onnx 文件一把梭。图像编码器的输入是一张标准化后的图输出是 256x64x64 的特征嵌入和一些辅助信息提示编码器接收点坐标、框坐标和对应的标签掩码解码器把特征和提示拼在一起输出 4 个候选掩码以及置信度分数。SAM2 和 SAM 的提示编码器几乎一样掩码解码器结构也相似最大的区别在于图像编码器内部对高分辨率特征的保留方式不同但对 ONNX Runtime 来说这只影响输入输出的张量形状不影响调用框架。如果你的 .rar 里只有 image_encoder.onnx、prompt_encoder.onnx、mask_decoder.onnx 这三个文件那通常配套的 C# 代码会分别加载它们。还有一种方式是做成一个组合模型把三个子图拼进一个 onnx用子图调用来模拟多输入输出但那样调试麻烦我一般不用。2.2 导出与精简先确认包里的模型能用我拿到一个 C# OnnxRuntime SAM2 的压缩包第一件事不是急着写 C#而是先把模型单独拎出来用 Python 脚本跑一遍随机输入确认每个 onnx 的输入输出名字和形状没被精简过。很多包里的模型是用官方权重导出后再剪枝过的输入名可能是 images 而不是原始的名字输出可能少了低分辨率掩码。这些信息直接用 OnnxRuntime 的 C# API 也能读但用 Python 先探一次更快。我常用这样的探针脚本把模型拓扑打出来import onnx from onnx import shape_inference model onnx.load(mask_decoder.onnx) model shape_inference.infer_shapes(model) graph model.graph for inp in graph.input: print(input:, inp.name, [d.dim_value if d.dim_value else d.dim_param for d in inp.type.tensor_type.shape.dim]) for out in graph.output: print(output:, out.name, [d.dim_value if d.dim_value else d.dim_param for d in out.type.tensor_type.shape.dim])逻辑说明这段代码不仅打印输入输出名还会把动态维度显示成字符串比如 H 或 W这决定你在 C# 里怎么申请 Tensor。SAM2 的图像编码器通常接受 1024x1024 的输入且要求宽高相等。如果探出来某个维度的值是 None 或者 0说明你的 OnnxRuntime 版本必须支持动态形状否则就要固定 batch 或固定分辨率重新导出。参数说明onnx.load 需要安装 onnx 库shape_inference 不是必须的但加上能补全缺失的维度和中间张量形状对排查维度对不上很有用。注意探针脚本的运行环境只需要 Python 3.8 以上不需要装 torch只要 onnx 这一个包。如果包里的 onnx 是加密或改过扩展名的探针会直接报错那就要找原版权重自己重新导。做完这一步你就拿到了三个模型各自的输入张量名、形状、类型。常见情况是图像编码器输入 float32[N,3,1024,1024]输出 float32[N,256,64,64] 和一个辅助输出提示编码器输入是 float32[M,2] 的点坐标、float32[M] 的标签、还有一个框输入可选掩码解码器输入是图像特征、点提示、框提示、掩码输入可选输出 float32[M1,4,256,256] 的掩码和置信度。后面所有 C# 代码都围绕这组张量来写。提示编码器在 SAM 里其实可以被掩码解码器内部的 prompt 处理模块替代也就是说如果你拿到的包只有两个 onnx一个图像编码器、一个 mask decoder那也是正常设计点提示和框提示会作为 mask_decoder 的直接输入由内置的 embedding 层处理。我倾向于把 prompt_encoder 单独拆出来因为这样可以在 C# 里缓存提示的嵌入向量多个掩码解码请求可以复用同一个提示嵌入交互体验更顺。3. C# 端最小实现从加载模型到输出掩码3.1 工程结构与 NuGet 引用拿到一个 C# OnnxRuntime SAM2 的 rar解压后里面如果是源码工程通常包含这几个目录Models放三个 onnx、ImageUtils图像预处理、Inference封装推理类、DemoWinForms 或控制台。我自己写的话会用 .NET 8 控制台项目做原型再迁到 WPF 或服务里。因为控制台最方便验证边界没有 UI 事件干扰。先建项目并引用 NuGet。你需要在 Visual Studio 的 NuGet 管理器中安装PackageReference IncludeMicrosoft.ML.OnnxRuntime Version1.17.3 / PackageReference IncludeSixLabors.ImageSharp Version3.1.5 /逻辑说明OnnxRuntime 是推理运行时ImageSharp 用来读图、缩放、转 Tensor。如果你用的是 .NET Framework 4.8OnnxRuntime 版本要选 1.15 以下的否则不支持.NET 6/8 直接用最新稳定版就行。ImageSharp 不是必须的你也可以用 System.Drawing但在 Linux 服务端跑 System.Drawing 有兼容问题所以我优先推荐 ImageSharp。参数说明OnnxRuntime 1.17.3 支持 ONNX opset 22 以内的模型SAM2 官方导出的模型一般是 opset 17 或 18这个版本覆盖得了。如果 .rar 里的模型是用更新版本导出的运行时会报Unsupported ONNX opset version那就降级模型导出时的 opset或者升级 OnnxRuntime 的预发布版。ImageSharp 3.x 的 API 和 2.x 有差异读图时注意命名空间。工程里我通常会建一个 Sam2Session 类把三个模型的会话都封装进去对外只暴露 Segment(image, points) 方法。这样业务层不用管张量拼接。3.2 图像编码把图片变成嵌入向量图像编码器做的事很简单图缩放成 1024x1024归一化到 [-1,1]然后转成 Tensor 喂进去。这里有一个坑SAM2 用的是 RGB 顺序而 ImageSharp 默认加载后是 BGR 像素排列方式你不能直接拿像素数组转 Tensor要先做通道重排。我写的编码方法如下public float[] PreprocessImage(ImageRgba32 image, int targetSize 1024) { image.Mutate(x x.Resize(new ResizeOptions { Size new Size(targetSize, targetSize), Mode ResizeMode.Stretch })); var tensor new float[1 * 3 * targetSize * targetSize]; var pixelSpan new SpanRgba32(); image.CopyPixelDataTo(pixelSpan); for (int y 0; y targetSize; y) { for (int x 0; x targetSize; x) { var p pixelSpan[y * targetSize x]; tensor[0 * targetSize * targetSize y * targetSize x] (p.R / 255f - 0.485f) / 0.229f; tensor[1 * targetSize * targetSize y * targetSize x] (p.G / 255f - 0.485f) / 0.229f; tensor[2 * targetSize * targetSize y * targetSize x] (p.B / 255f - 0.485f) / 0.229f; } } return tensor; }逻辑说明先强制拉伸到 1024x1024SAM 系列对长宽比不敏感直接拉伸不会明显掉点但如果你做的是像素级精确分割最好用等比缩放加 padding。注意我用的均值方差是 ImageNet 的0.485, 0.229这是 SAM 官方权重训练时用的统计值别改。像素按 RGB 顺序分别填进三个通道平面这就是 NCHW 布局。参数说明targetSize 默认 1024如果你的显存或内存紧张可以改成 512但分割质量会明显下降小目标可能直接丢失。CopyPixelDataTo 把整个图像数据拷贝到连续 span 里它要求传入 span 长度正好等于 image.Width * image.Height否则会抛异常。ResizeMode.Stretch 会改变高宽比如果你后续要画分割掩码叠加到原图上需要把掩码再缩放回原尺寸那时也要用 Stretch坐标才不会偏。接着调用 OnnxRuntime 执行图像编码public DenseTensorfloat EncodeImage(ImageRgba32 image) { var input PreprocessImage(image); using var tensor new DenseTensorfloat(input, new[] { 1, 3, 1024, 1024 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, tensor) }; using var results _imageEncoder.Run(inputs); var embedding results.First().AsTensorfloat().ToArray(); return new DenseTensorfloat(embedding, results.First().AsTensorfloat().Dimensions.ToArray()); }逻辑说明这里把一维 float 数组包装成 DenseTensor形状是 [1,3,1024,1024]。运行后拿到的是高维特征。results.First() 拿第一个输出如果你的 image_encoder 有多个输出SAM 的编码器还会输出一个低分辨率特征供后续使用需要按输出名获取不能只取第一个。我这边的模型第一个输出就是我们要的 256x64x64 嵌入。参数说明tensor 的维度必须和模型输入完全一致。如果你探针发现输入名不是 images要改成实际的名字。执行完的 embedding 是连续内存ToArray 会复制一份如果内存紧张可以改用 Buffer 直接访问。返回的 DenseTensor 建议保存下来因为后续每一次点提示的掩码解码都要用到它不要每次重新编码图片。3.3 Prompt 编码与掩码解码点提示就够了有了图像嵌入下一步就是把用户的点击坐标转成提示张量。SAM2 的 prompt 是由「点坐标 点标签」组成的点标签 1 表示前景0 表示背景。框提示可以叠加但大多数交互场景里两个点一个前景点、一个背景点就能分割出目标。我的实现里把 prompt_encoder 和 mask_decoder 分开调用。封装一个 PromptEncoderpublic DenseTensorfloat EncodePrompt(float[] coords, float[] labels) { int numPoints coords.Length / 2; using var coordTensor new DenseTensorfloat(coords, new[] { numPoints, 2 }); using var labelTensor new DenseTensorfloat(labels, new[] { numPoints }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(coords, coordTensor), NamedOnnxValue.CreateFromTensor(labels, labelTensor) }; using var result _promptEncoder.Run(inputs); var sparse result.First().AsTensorfloat(); return new DenseTensorfloat(sparse.ToArray(), sparse.Dimensions.ToArray()); }逻辑说明稀疏提示嵌入sparse embeddings是我们需要的它代表点或框的位置特征输出里可能还有一个 dense 的提示嵌入一般用不到。如果你的 prompt_encoder 输出有两三个张量请确认哪个是 sparse_embeddings哪个是 dense_embeddings。我的这个模型输出第一个是 sparse直接用。参数说明coords 是扁平化的坐标数组顺序是 x0,y0,x1,y1x 是图像宽方向y 是图像高方向单位是像素不需要归一化。labels 数组长度和点数一致。坐标必须是 float 类型int 要强转。对于背景点SAM 支持「负点击」可以帮它排除干扰区域我之前忽略背景点导致分割总是连带背景加了之后效果好很多。最后合成掩码解码器的输入这一块最容易出错public ImageRgba32 DecodeMask(DenseTensorfloat imageEmbedding, DenseTensorfloat sparsePrompt) { int numPrompts sparsePrompt.Dimensions[0]; using var embeddingTensor new DenseTensorfloat(imageEmbedding.ToArray(), imageEmbedding.Dimensions.ToArray()); using var promptTensor new DenseTensorfloat(sparsePromptToArray(), sparsePrompt.Dimensions.ToArray()); // mask decoder 还需要一个可选的掩码输入我们传一个全零张量 var zeroMask new float[1 * numPrompts * 256 * 256]; var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image_embedding, embeddingTensor), NamedOnnxValue.CreateFromTensor(sparse_prompt, promptTensor), NamedOnnxValue.CreateFromTensor(dense_prompt, new DenseTensorfloat(zeroMask, new[] {1, numPrompts, 256, 256})), NamedOnnxValue.CreateFromTensor(mask_input, new DenseTensorfloat(zeroMask, new[] {1, 1, 256, 256})) }; using var output _maskDecoder.Run(inputs); var masks output.First().AsTensorfloat(); // 取第一个候选掩码通常置信度最高 var best GetHighestConfidenceMask(output); return PostProcessMask(best); }逻辑说明mask_decoder 的输入在 SAM2 里比 SAM 多了一个 mask_input用于迭代修正。首次调用时 mask_input 传全零。dense_prompt 是 prompt encoder 输出的 dense 嵌入如果你没有单独跑 prompt encoder可以直接传全零张量但那样提示信息就丢了所以我这里把 sparsePrompt 直接传进去前提是你的模型导出时把 prompt 合并进了 mask decoder。如果你的模型是严格三件套请在 EncodePrompt 里同时拿 dense_prompt。参数说明dense_prompt 的维度必须是 [1, numPrompts, 256, 256]这个 256 不是图像尺寸而是 SAM 内部的嵌入分辨率不要自作聪明改成 64。掩码解码器输出多个候选项我通过 GetHighestConfidenceMask 从第二个输出里取置信度选分数最高对应的掩码。掩码的数值范围不是 0 到 1而是 logits要做 sigmoid 后阈值化成二值图。4. 常见问题排查加载、形状、内存三大灾区4.1 现象OnnxRuntime 加载就报 Op set not supported原因你的 onnx 模型是用新版 opset 导出的而你引用的 OnnxRuntime 版本太旧。某开发者第一次跑时模型 opset 是 19OnnxRuntime 1.15 不支持直接抛异常。解决查模型 opset用第 2 章的探针脚本打印 model.opset_import然后换对应版本的 OnnxRuntime。比如 opset 18 需要 OnnxRuntime 1.16 以上opset 19 需要 1.17 以上。如果不想换版本只能重新导出模型在导出时指定 opset17。4.2 现象Segment 后掩码全白或全黑原因掩码解码器输出的是 logits你没做 sigmoid 就直接当像素值用。我犯过这个错把输出直接乘以 255 赋给图片结果几乎没有灰色过渡。解决先对 logits 做 sigmoid1 / (1 exp(-x))然后以 0.5 阈值二值化或者用浮点掩码叠加。另外还有可能是你把掩码的尺寸搞错了mask_decoder 输出是 256x256需要 Resize 回原图尺寸再叠加否则看起来就是一块模糊的色块。4.3 现象第二次点击特别慢看起来像卡死原因每次 Segment 都重新跑了图像编码器。图像编码器是最重的一步1024x1024 的输入在 CPU 上可能要吃 1 到 2 秒如果你在循环里反复调用 EncodeImage交互当然卡。解决把图像编码的结果缓存起来只有图片切换时才重新编码点提示只用重建 prompt 和跑 mask decoder。这是 SAM 官方交互的常见做法你的 C# 工程里应该设计一个 Session 级缓存。4.4 现象内存暴涨推理一次涨几百兆不回落原因OnnxRuntime 的会话默认会申请工作空间而且和 System.Drawing 的 Bitmap 或 ImageSharp 的 Image 没有及时 Dispose 互相叠加。我排查时发现每次循环都 new Image 不释放托管堆涨到几个 GB。解决所有图像处理对象用 using 包裹OnnxRuntimeSession 不要反复创建一个模型一个 Session 生命周期跟随程序大 Tensor 可以调用 GC.Collect 但不要频繁关键是避免在循环里创建新 Session。4.5 现象输入坐标是屏幕坐标但结果偏到其他地方原因掩码输出的坐标和图像编码器的输入尺寸是对应的。如果你传入的点提示是 UI 上显示的原图像素坐标但图像编码器之前把图 Resize 成了 1024x1024坐标也必须按同样比例缩放。解决给原图建一个长宽到 1024 的缩放因子点坐标先缩放再送进 prompt encoder。用 ResizeMode.Stretch 时缩放因子分别是目标宽/原宽、目标高/原高注意 x 和 y 的因子可能不一样分开放不要混用。5. 进阶把 SAM2 接进业务前要做的三件事第一件事是量化。图像编码器是全模型里最重的部分在 CPU 推理时是最大瓶颈。我一般在导出时用 OnnxRuntime 的 dynamic quantization 工具把权重从 float32 转成 int8。转换后精度损失对交互式分割影响不大但速度可以提升 2 到 3 倍。代价是第一次加载会略微变慢因为需要做校准。如果你的 .rar 包里有 fp16 版本在支持 fp16 的 GPU 上推理更快但 CPU 上 fp16 反而会慢所以保留一份 fp32 模型做后备。我踩过的坑是只保留 int8 模型结果遇到边缘场景分割质量崩了后来保留 fp32 做开关切换。第二件事是做超时与并发控制。OnnxRuntime 的 Run 方法是线程安全的吗同一个 Session 对象可以被多个线程同时调用但如果你是多个业务请求共用同一个模型推荐每个线程一个 Session或者用信号量把并发数限制在 CPU 核心数以内。我写过一个 WPF 应用用户在图片上拖拽时连续产生多个点击事件每个都触发异步 Segment结果 OnnxRuntime 内部并行竞争出现结果错乱。后来我用了一个 Channel 串行化推理请求交互反而更流畅因为掩码解码本身只要几十毫秒串行不会明显感知延迟。第三件事是把掩码转成业务可用的结果。二值掩码除了可视化外通常还要算面积、轮廓、外接矩形。ImageSharp 里有个代价比较高的操作是像素遍历我建议把掩码转成 bool 数组后用简单的游程编码做面积统计不要直接操作 Image 对象。一次分割输出 256x256 的 bool 数组遍历一遍算面积只需要几十微秒但如果你先转成 Bitmap 再用 GetPixel一次就要几毫秒交互时可能拖累 UI。这一个月我前后跑通了三次不同的 SAM2 包每一次的坑都集中在张量形状和 opset 版本上。现在拿到新包第一件事就是跑探针脚本再把首帧图像编码结果缓存住。这个习惯省了我大量排查时间。C# OnnxRuntime SAM2 的落地路径并不神秘模型是现成的运行时也是公开的难的是把提示数据的形状和模型的预期对齐。多做几次随机输入验证把探针脚本和 C# 的调试器配合起来你也能稳定跑出一个能用的分割服务。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进