C# OnnxRuntime集成SAM2:桌面端离线图像分割与踩坑实践
简介面向C#开发者的OnnxRuntime SAM2图像分割推理项目资源包适合希望在.NET环境中接入SAM2模型、实现图像/视频实时分割的深度学习与视觉应用开发者。压缩包共312个文件、约810.86MB内容以dll、xml、pdb等运行与依赖库文件为主同时包含onnx模型、cs源代码、sln解决方案文件及Demo示例项目内还整理好了packages依赖、targets/props构建配置与示例图片结构覆盖项目配置、第三方包和演示代码便于直接打开调试并替换模型进行二次开发。已有481人学习/下载。其价值在于把Meta SAM2模型与OnnxRuntime推理链路完整封装到C#工程中开发者可借此快速掌握加载ONNX格式模型、调用分割能力并集成到图像分析、医疗影像、自动驾驶监控等场景的完整思路项目中附带的示例与依赖清单也能减少环境搭建与排错成本适合作为C#侧部署SAM2的起步模板。1. C# OnnxRuntime SAM2桌面端无需 Python 服务也能跑 SAM2 分割很多 C# 上位机工程师第一次拿到 SAM2 的 ONNX 模型时第一反应是“要不要用 Python 搭个分割服务C# 这边再走 HTTP 调用”。实际做过一轮就会发现用 OnnxRuntime 动态库直接在你的 WinForm 或 WPF 进程里加载 SAM2 的 encoder 和 decoder完全能完成 prompt 分割部署形态也从“应用 Python 环境”缩成一个 exe 加几个 dll。这条路径适合工业质检、自动化设备、离线分析这类要求本地闭环的场景但坑也明确输入输出张量、模型生命周期和内存释放都要自己管稍不注意就会把进程搞崩。下面我把 C# 侧接 SAM2 的完整思路、最小代码和踩坑记录摊开讲。2. 先看懂 SAM2 的 ONNX 产物encoder、decoder 与 prompt 三个入口2.1 拿到模型后先分清哪些是必要文件从模型仓库导出 ONNX 时通常不是单个文件而是按模块拆成若干个。常见的做法是把 SAM2 拆成“图像编码器”和“掩码解码器”两组图像编码器负责把一张图变成固定维度的 embedding掩码解码器负责把用户给的框或点连同 embedding 变成 mask。日常做 C# 上位机集成时你真正要关心的就是这个 encoder 和 decoder而不是一整套训练脚本。我自己拿到压缩包后会先做一次文件摸底把每个 onnx 文件拖进 Netron 看一眼输入输出形状。这一步花不了五分钟但能省掉后面至少一个下午的调试时间。你需要记下来的信息包括encoder 的输入名字和输入尺寸、输出 embedding 的名字和形状、decoder 的输入有几个、prompt 是分开传还是拼成一个张量。很多开源的导出脚本会默认用“image”这类名字但总有人改了封装方式直接用现成的变量名去跑多半会翻车。2.2 为什么 C# 侧选 OnnxRuntime 而不是换框架选 OnnxRuntime 而不是其他推理框架主要理由是它能“住在”你的 C# 进程里。做上位机的人最怕那种启动一个 Python 子进程、靠标准输出往回传结果的方案一旦子进程崩了、路径变了或者环境变量丢了整个设备就卡在那边。OnnxRuntime 通过 NuGet 包把原生推理库带进来C# 直接调用不存在跨进程通信的延迟和状态同步问题。参数选择上CPU 环境就用默认的 SessionOptions最多把线程数调一调有 NVIDIA 显卡就把 CUDA 执行提供程序加上不想折腾 CUDA 环境的机器可以考虑 DirectML 执行提供程序部署体积更小但对显卡的兼容性不如 CUDA 稳定。我一般会优先在开发机上把 CPU 路径跑通再决定要不要引入 GPU这样定位问题会容易很多。注意OnnxRuntime 的 NuGet 包版本和你拿到的 ONNX 模型算子版本要匹配否则一运行就可能报“unsupported operator”。先跑通 CPU 路径再谈 GPU 加速。3. 用 OnnxRuntime 动态库在 C# 里加载并跑通 SAM2最小代码3.1 创建 Session 并确认输入输出名先创建一个控制台项目安装Microsoft.ML.OnnxRuntime包。下面这段代码的作用是加载 encoder 模型并把它的输入输出名全部打出来避免手写死参数using Microsoft.ML.OnnxRuntime; var options new SessionOptions(); options.AppendExecutionProvider_CPU(); // 先跑 CPU后续再切 CUDA using var session new InferenceSession(sam2_encoder.onnx, options); Console.WriteLine(--- Inputs ---); foreach (var kv in session.InputMetadata) { // kv.Key 是输入名kv.Value 包含形状和类型 Console.WriteLine(${kv.Key}: {kv.Value.ElementType} {string.Join(,, kv.Value.Dimensions)}); } Console.WriteLine(--- Outputs ---); foreach (var kv in session.OutputMetadata) { Console.WriteLine(${kv.Key}: {kv.Value.ElementType} {string.Join(,, kv.Value.Dimensions)}); }这段代码有两个作用一是验证模型文件能正常加载二是把真实输入输出名打出来。有人直接从网上抄了变量名“images”和“image_embeddings”结果自己导出的模型叫“input_tensor”运行时报错才回来看元数据浪费不少时间。参数说明AppendExecutionProvider_CPU()是显式指定用 CPU不给它调用也没关系但写上能让维护的人一眼看清当前用的执行提供程序。InferenceSession实现了 IDisposable建议用using包住避免 session 占用的原生内存迟迟不释放。3.2 编码图像把 Bitmap 转成 float 张量并拿到 image embeddingSAM2 的 encoder 一般接收 1024×1024 的 RGB 输入输出的是 256×64×64 形状的 embedding可能还有一个高分辨率特征输出。先写图像预处理private static float[] PreprocessImage(Bitmap bmp, int targetW, int targetH) { using var resized new Bitmap(bmp, new Size(targetW, targetH)); var data new float[3 * targetH * targetW]; int idx 0; for (int y 0; y targetH; y) { for (int x 0; x targetW; x) { var pixel resized.GetPixel(x, y); // SAM2 常用 ImageNet 均值方差归一化具体以导出脚本为准 data[idx] (pixel.R / 255f - 0.485f) / 0.229f; data[idx targetH * targetW] (pixel.G / 255f - 0.456f) / 0.224f; data[idx 2 * targetH * targetW] (pixel.B / 255f - 0.406f) / 0.225f; idx; } } return data; }这段预处理把图像从 HWC 转成 CHW 排布并按 ImageNet 的均值和方差归一化。通道顺序是 RGB和 OpenCV 的 BGR 不一样很多分割结果不对的问题就出在这里。GetPixel在循环里调用性能不高但做验证够用正式集成时建议换成 LockBits 或直接用 OpenCvSharp 的 Mat 转 float 数组速度差别很大。然后跑 encoderusing Microsoft.ML.OnnxRuntime.Tensors; string encoderPath sam2_encoder.onnx; var imgData PreprocessImage(bitmap, 1024, 1024); using var encoder new InferenceSession(encoderPath, options); var inputTensor new DenseTensorfloat(imgData, new[] { 1, 3, 1024, 1024 }); var encoderInputs new ListOrtValue { OrtValue.CreateTensorValueFromMemory(inputTensor.ToArray(), new long[] { 1, 3, 1024, 1024 }) }; using var encoderOutput encoder.Run(encoderInputs); var embeddings encoderOutput.First().AsTensorfloat();Run返回的是一个IDisposableReadOnlyCollectionOrtValue不释放的话每跑一次就漏一块原生内存。DenseTensorfloat是内存连续的张量传给 OnnxRuntime 不需要额外拷贝。这里的关键参数是new long[] { 1, 3, 1024, 1024 }的批次维和通道维C# 数组顺序必须和模型要求的 NCHW 一致不能把 H、W 写反。3.3 decoder把坐标 prompt 和 image embedding 变成 maskmask decoder 的输入一般有图像 embedding、高分辨率特征和用户给出的 point/box。这里的高分辨率特征是从 encoder 输出里拿的而不是重新计算。下面是 decoder 推理的骨架using var decoder new InferenceSession(sam2_decoder.onnx, options); // 假设 decoder 的输入名分别是 image_embedding、high_res_feats_0 等 var decoderInputs new ListOrtValue(); decoderInputs.Add(OrtValue.CreateTensorValueFromMemory(embeddingArray, embeddingShape)); decoderInputs.Add(OrtValue.CreateTensorValueFromMemory(highRes0Array, highRes0Shape)); // ... 按元数据顺序把 prompt 坐标和 label 拼进去 using var outputs decoder.Run(decoderInputs); var maskLogits outputs.First().AsTensorfloat();这段代码把 decoder 的输入按名称逐个拼进去。注意 decoder 的输入顺序和模型导出时的顺序不一定一致我最稳的做法是先把InputMetadata的 key 存成一个数组再按名字去匹配数据来源而不是靠位置猜。prompt 坐标在 C# 侧是一个 float 数组点的格式是[x1, y1, x2, y2, ...]label 对应的是“前景、背景”的标记比如[1, 0]。这个坐标是模型输入尺寸下的坐标不是原图坐标后面会专门讲换算。3.4 把输出 logits 转成 Bitmapdecoder 输出的 mask logits 一般是 float 张量值域可能很大需要经过 Sigmoid 转成概率才能得到真正可用的 maskBitmap ToMaskBitmap(float[] logits, int maskH, int maskW) { var bmp new Bitmap(maskW, maskH); for (int y 0; y maskH; y) { for (int x 0; x maskW; x) { float p 1f / (1f (float)Math.Exp(-logits[y * maskW x])); byte v p 0.5f ? (byte)255 : (byte)0; bmp.SetPixel(x, y, Color.FromArgb(v, v, v)); } } return bmp; }这一步最容易出现“输出全黑或全白”的现象原因就是没做 Sigmoid直接把原始 logits 当阈值用。Math.Exp计算负数值可能溢出所以对 logits 做一次截断会更安全比如限制在 [-50, 50]。这个 0.5 阈值不是固定的对某些目标可以降到 0.3建议做成配置项方便现场调参。4. 图片与视频两种工作流embedding 缓存和 memory 状态不能乱放4.1 图片分割一次推理embedding 只算一次单张图做分割时最常见的时间浪费是“每个 prompt 都重新跑一遍 encoder”。如果你的上位机界面允许用户点几个点、来回调整 prompt每一帧都全链路推理CPU 机器直接卡成幻灯片。正确做法是把 encoder 的输出缓存起来只重跑 decoderprivate Dictionarystring, Tensorfloat _embeddingCache new(); byte[] RunSingleImagePrompt(Bitmap image, float[] points, float[] labels) { string key ${image.Width}x{image.Height}; if (!_embeddingCache.ContainsKey(key)) { // 先把图片过 encoder把 image_embeddings 和 high_res_feats 都缓存 _embeddingCache[key] RunEncoder(image); } var cached _embeddingCache[key]; return RunDecoder(cached, points, labels); }参数上要格外注意缓存 key 里不能只放图像大小如果现场有不同亮度的图像同一张尺寸但不同内容embdding 会张冠李戴。最好的方式是把图像的 MD5 或一个递增的帧号当作 key只有 prompt 变化时走 decoder图像变化时更新缓存。C# 线程方面如果分割是在后台线程跑的这个字典需要加锁或用 ConcurrentDictionary否则多路相机同时取图会让缓存被并发写穿。4.2 视频连续帧memory 状态每条分割线程一套视频分割和图片分割最大的差别是 SAM2 会维护一组 memory 状态前一帧的特征、当前帧的预测结果都会影响后续帧。如果你把这组状态放在静态字段里两路视频同时处理就会互相覆盖出现“A 路的 mask 跑到了 B 路画面里”这种诡异现象。解决方案是为每一路视频创建一个独立的实例把所有 memory 相关的张量封装在一个类里public class VideoSegmentationSession : IDisposable { private InferenceSession _decoder; private Dictionarystring, Tensorfloat _memory new(); public void ProcessFrame(Bitmap frame, float[] box) { var inputs BuildFrameInputs(frame, box); if (_memory.Count 0) { // 把上一帧保存的 memory 张量补到输入里 } using var outputs _decoder.Run(inputs); UpdateMemory(outputs); } public void Dispose() _decoder?.Dispose(); }这段结构解决“多路视频状态串线”的问题。每一路创建一个VideoSegmentationSession内部保存自己的_memory字典session 销毁时内存跟着清理。CPU 机器做 1080p 视频逐帧分割本来就非常吃力再加多路并发纯属给自己挖坑建议先用单路验证性能和效果。WinForm 界面上更新状态栏和进度条时记得用 Task 或 BackgroundWorker 包住分割循环不要在 UI 线程里跑推理不然界面会一直转圈。4.3 预热和超时正式分割前的两个例行动作OnnxRuntime 的 Session 在第一次Run时要做算子初始化、内存分配耗时可能比后续推理长好几倍。我一般会在程序启动后挑一张固定测试图跑一次完整流程把这个预热过程对用户的“第一次点击”隐藏掉。public void Warmup() { var stub new Bitmap(64, 64); var data PreprocessImage(stub, 64, 64); using var input OrtValue.CreateTensorValueFromMemory(data, new long[] { 1, 3, 64, 64 }); // 用最小尺寸跑一次 encoder再跑一次 decoder不求结果只求初始化完成 }预热还有个作用把程序集搜索路径找对。如果用的是 32 位进程而 OnnxRuntime 装的是 64 位版本预热阶段就会抛出 BadImageFormatException这种基础问题越早暴露越好。提示如果你对某一次推理设置超时不要用一个单独的 Task.Delay 去“取消”推理因为 OnnxRuntime 的 Run 调用不一定响应 CancellationToken。更好的做法是从流程上限制模型文件越大、输入图越大越要把耗时上限体现在任务队列里而不是在推理中间强杀。5. C# 接 SAM2 常见的 5 个坑从导出到内存逐个排查5.1 现象第一次推理耗时特别长后续也不稳定原因Session 加载时分配算子内核CPU 推理还涉及线程池初始化。另一个隐藏原因是InferenceSession被反复创建每次 new 都重新加载模型等于把模型的解析和权重读取重做了一遍。解决全局只创建一次InferenceSession用单例持有。设置options.SetIntraOpNumThreads(Environment.ProcessorCount / 2)线程数不是越多越好超线程机器上线程太多反而拉高调度开销。上线前做一次预热把首次推理的额外耗时移到启动阶段。5.2 现象mask 输出全黑或全白原因输出张量是 logits直接拿正负判断阈值。SAM2 decoder 输出的值域很大负几百到正几百都很常见不做 Sigmoid 就设阈值等于瞎猜。解决对 logits 做1 / (1 exp(-x))再按 0.5 切割。如果仍然偏黑或偏白检查预处理时的归一化均值方差是否与导出脚本一致。很多开源导出脚本用的是 ImageNet 统计量但也有一些自定义训练数据集的归一化参数不一致这个值错了所有结果都不对。5.3 现象程序跑一段时间内存涨到几个 GB然后被系统杀掉原因Run返回的OrtValue集合没有释放。C# 侧做对象引用很容易忘掉IDisposableReadOnlyCollectionOrtValue本质上持有原生内存等 GC 回收往往已经太晚。还有一类情况是 decoder 的输出里包含 memory 张量这些张量被放进静态字典后永远不被清理。解决每次Run都用using包住返回值。对于视频分割的 memory 缓存每处理完一路视频必须调用清理方法把_memory里的张量逐个 Dispose 再清空列表。上线前用“连续分割 500 帧”做一次压力测试内存曲线持续上涨说明释放逻辑有漏洞。5.4 现象开发机正常客户的旧电脑一启动就报“找不到指定模块”或直接崩溃原因OnnxRuntime 新版本对 CPU 指令集有要求比较老的 CPU 缺少 AVX2 或者相关指令时会加载失败。这不是 C# 代码逻辑问题而是原生库兼容性。解决锁定一个较旧的 OnnxRuntime 版本优先看Microsoft.ML.OnnxRuntime包发布说明里对 CPU 的最低要求。部署前在目标机器上跑一个最小加载示例而不是把整个上位机搬过去才发现问题。现场机器千奇百怪这一步省不掉。5.5 现象鼠标点击的点和分割结果对不上越靠近边缘偏差越大原因prompt 坐标用的是原图坐标但模型输入是 1024×1024 的缩放后坐标中间少了一步坐标映射。如果图像比例不是 1:1缩放是拉伸而不是等比裁剪坐标错位会更明显。解决定义统一的坐标变换函数把原始坐标映射到模型输入坐标float MapCoord(float orig, float origSize, float modelSize) { return orig * modelSize / origSize; }如果是等比缩放加 padding换算方式更复杂要先把 padding 偏移量减掉再缩放。现场反馈点在物体边缘但分割结果偏了时先查这一步不要急着调阈值。6. 把 SAM2 当上位机模块验收用 IoU 脚本验证分割结果6.1 固定测试图与固定 prompt跑一个可对比的 IoU我现在的习惯是每接完一套 SAM2 流程先做一组“固定三张图、每张图固定三个 prompt”的验证用例。把鼠标点击的坐标、预期 mask 面积占比写进一个 JSON 文件分割完成后输出实际面积和 IoU这样后续升级 OnnxRuntime 版本或换模型文件时能立刻看出结果是否回退。下面是一个粗略的像素比对逻辑float CalcIoU(bool[] maskA, bool[] maskB) { int inter 0, union 0; for (int i 0; i maskA.Length; i) { if (maskA[i] maskB[i]) inter; if (maskA[i] || maskB[i]) union; } return union 0 ? 1f : (float)inter / union; }要点是“对比用的 mask 必须在同一尺寸下生成”如果测试图和实际设备的相机分辨率不一致IoU 本身没有参考意义。我会把基准 mask 保存成 PNG和运行结果做一次性尺寸对齐后再算不做隐式的自动缩放。6.2 性能卡尺先定 CPU/GPU 再谈算法优化给 SAM2 做验收时我建议只记录三组数字冷启动耗时、预热后单次 encoder 耗时、预热后 decoder 耗时。记录格式可以很简单场景encoder 耗时decoder 耗时备注1024×1024 CPU 单线程待测待测线程数 CPU 物理核数一半1024×1024 CPU 多线程待测待测尝试 4 / 8 线程1024×1024 GPU待测待测CUDA 或 DirectML这三个数字决定你后续是优化预处理、换 GPU 还是改交互逻辑。SAM2 这类大模型的 CPU 推理速度很难满足高频实时分割如果你的设备要求每秒处理多帧建议在项目调研阶段就把 GPU 方案放进去而不是等 CPU 跑不动了再紧急换硬件。6.3 我踩过最深的一个坑和现在的固定习惯之前做设备端分割交付时我把视频分割的 memory 字典写成了静态字段两路相机一起跑分割结果互相串线现场背锅背了整整三天。从那以后凡是有状态的模型推理一律按“一个实例一路数据”来封装绝不共用静态状态。做完分割后立即 Dispose 输出张量不把释放延迟交给 GC。希望这些习惯和坑能帮到你SAM2 接 C# 并没有想象中那么复杂把模型文件、张量排布和生命周期管好剩下的事情就是慢慢调 prompt 和阈值了。本文还有配套的精品资源点击获取