简介:面向C#开发者,将OnnxRuntime与SAM2结合,实现高效图像分割的完整工程资源,适用于医学影像分析、自动驾驶感知、智能监控等需要目标轮廓识别的落地场景。压缩包共312个文件,约810.86MB,涵盖sln解决方案、C#源码、ONNX模型、示例Demo及大量依赖项,其中dll、nupkg、lib等保障跨平台编译运行,6个onnx文件为推理核心,xml、json、config等构成模型参数与配置,整体结构清晰,便于直接研究。已有481人学习/下载。内含完整解决方案与Onnx SAM Demo示例,演示从加载ONNX模型到集成C#程序,再到图像实时分割输出的完整流程;packages与各平台运行文件(so、dylib、aar等)可降低环境配置门槛,使开发者聚焦算法落地,快速将SAM2集成到自己的应用中。适合希望将前沿分割模型引入.NET技术栈的中高级C#工程师,无论用于医疗图像辅助诊断、车载视觉模块还是工业质检,都能在此基础上快速扩展应用。
1. C# OnnxRuntime SAM2:把 SAM2 分割图像直接跑进你的 C# 程序
C# OnnxRuntime SAM2 这套资源,解决的是不依赖 Python 环境、在 C# 里完成 SAM2 图像分割的问题。它的核心思路是把 SAM2 模型导出成 ONNX,再通过 OnnxRuntime 动态库在 C# 里加载,从图像编码、prompt 点坐标输入到最终 mask 输出,整条链路都能放进 WinForms、WPF 甚至上位机采集服务里。适合两类人:一类是想把 SAM2 集成进现有 C# 工业视觉软件、省掉进程间调用 Python 的工程师;另一类是在做半自动标注工具、图像抠图、区域提取,需要“点一下就能分割目标物体”的桌面端开发者。用它的预期不是开箱即用,你仍然要处理模型导出、张量对齐和坐标换算,但跑通之后比维护一个 Python 推理子进程稳得多。
2. 环境与模型准备:把 SAM2 导出成 ONNX,再在 C# 里加载
2.1 为什么 SAM2 要拆成 image encoder 和 mask decoder 两个模型
SAM2 不是一个小模型。它的 image encoder 用的是 Hiera 这类 ViT 变体,输出的是多尺度特征,mask decoder 再根据这些特征和 prompt 生成分割结果。如果端到端导出一个 ONNX,一方面模型文件非常大,另一方面 prompt 数量一变往往就要重新导出一次,部署起来非常僵硬。落地时常见的做法是拆成两个推理 Session:image encoder 负责把图像编码成特征张量,mask decoder 负责接收点和框等 prompt 并输出 mask。
这种拆分对 C# 工程还有一个额外的好处:同一张图可以先只跑一次 encoder,然后给不同 prompt 反复调用 decoder。比如你做标注工具时,鼠标每点一下只是多跑一次轻量的 decoder,而不是把整张 1024×1024 图像重新塞进大模型,耗时和显存都能明显降下来。
拆出来的两个 ONNX 文件,输入输出命名在不同导出脚本里并不完全统一。常见导出脚本会给出 image encoder 的输入image、输出image_features或embeddings,mask decoder 的输入point_coords、point_labels,输出masks和iou_predictions。所以在写业务代码之前,第一步永远是打印模型 I/O,而不是凭记忆硬编码张量名。
using Microsoft.ML.OnnxRuntime; public static void PrintIo(InferenceSession session) { foreach (var input in session.InputMetadata) { Console.WriteLine($"input: {input.Key}, shape=" + string.Join("x", input.Value.Dimensions) + $", type={input.Value.ElementDataType}"); } foreach (var output in session.OutputMetadata) { Console.WriteLine($"output: {output.Key}, shape=" + string.Join("x", output.Value.Dimensions) + $", type={output.Value.ElementDataType}"); } }这段代码的作用是把 ONNX 当成一个黑匣子,先摸清它的输入张量名、维度和数据类型。SAM2 相关模型导出时经常出现动态维度,比如 point 数量是None,如果后面拼张量时硬编码维度,很容易报 mismatch。先打印出来,你会知道该传[1, N, 2]还是[N, 2],该用float32还是int64。
2.2 NuGet 依赖与 InferenceSession 初始化
C# 侧跑 OnnxRuntime 其实就是引用官方动态库的托管包装。主干依赖只需要两个:Microsoft.ML.OnnxRuntime负责 inference,OpenCvSharp4.Windows负责图像读写、缩放和 mask 绘制。如果要用 GPU,再把对应的执行提供者包加上。
dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4.Windows # GPU 按需选一个,不是三个都装 dotnet add package Microsoft.ML.OnnxRuntime.Gpu dotnet add package Microsoft.ML.OnnxRuntime.DirectML| 执行设备 | NuGet 包 | 适用场景 |
|---|---|---|
| CPU | Microsoft.ML.OnnxRuntime | 通用,兼容性最好,适合做功能验证 |
| NVIDIA GPU | Microsoft.ML.OnnxRuntime.Gpu | 服务端批量分割,延迟要求高 |
| Windows 任意 GPU | Microsoft.ML.OnnxRuntime.DirectML | AMD/Intel/NVIDIA 都能跑,适合本机桌面工具 |
注意一个常见误区:装了Gpu包并不代表自动用 GPU,你必须在SessionOptions里显式追加执行提供者。我在工程里一般封装一个Sam2Session类,把两个模型的生命周期都接进来。
using Microsoft.ML.OnnxRuntime; public class Sam2Session : IDisposable { private readonly InferenceSession _encoderSession; private readonly InferenceSession _decoderSession; public Sam2Session(string encoderPath, string decoderPath, bool useCuda = false) { var options = new SessionOptions(); options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; options.EnableMemoryPattern = true; if (useCuda) { // 使用 CUDA EP 前,确认本机有对应版本的 CUDA/cuDNN options.AppendExecutionProvider_CUDA(0); } _encoderSession = new InferenceSession(encoderPath, options); _decoderSession = new InferenceSession(decoderPath, options); } public InferenceSession Encoder => _encoderSession; public InferenceSession Decoder => _decoderSession; public void Dispose() { _encoderSession?.Dispose(); _decoderSession?.Dispose(); } }GraphOptimizationLevel.ORT_ENABLE_ALL是让 OnnxRuntime 做尽可能多的图优化,对 SAM2 这种大模型收益明显。EnableMemoryPattern = true则让连续推理尽量复用内存分配,后面做批量分割时很有用。如果机器上同时装了 DirectML 和 CUDA,不要在同一 Session 里叠两个 EP,实测很容易在算子分配时翻车,二选一就好。
2.3 先做一次最小推理,验证模型文件本身没问题
模型能不能正常跑,不应该拖到业务代码写完才验证。拿到 ONNX 后我会先用最笨的输入跑一次空推理。image encoder 的输入就用一张全零图,mask decoder 的输入就用一个零张量,先确认 Session 能建起来、Run 不崩,再检查输出张量的 shape 是否和导出时一致。
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var encoderInput = new DenseTensor<float>(new[] { 1, 3, 1024, 1024 }); using var encoderOut = encoder.Encoder.Run(new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("image", encoderInput) }); foreach (var value in encoderOut) { Console.WriteLine($"encoder out: {value.Name}, " + $"shape={string.Join("x", value.AsTensor<float>().Dimensions)}"); }如果这段在空输入上报错,先别怀疑代码,优先确认 ONNX 文件的输入名是不是image。有些导出脚本会叫input或者images。发现不一致,直接把CreateFromTensor的名字改成打印出来的实际值。最小推理的作用就是把“模型文件本身的问题”和“业务代码问题”隔离开,否则后面所有报错都混在一起,排查会非常痛苦。
3. 从图像到 mask:C# 完整推理流程
3.1 图像预处理:等比缩放、填充到 1024×1024、按 ImageNet 均值归一化
SAM2 的 image encoder 一般要求正方形输入,常见目标是 1024×1024。直接把原图压扁成正方形会严重破坏长宽比,分割边缘会“变形”,正确做法是等比缩放后填充到正方形。SAM 系列模型在训练时用的是 RGB 图像,而 OpenCvSharp 读出来是 BGR,通道顺序也要换,否则分割区域会出现诡异的色偏偏移。
using OpenCvSharp; public static DenseTensor<float> PreprocessImage( Mat bgr, int targetSize, out Rect letterboxRect, out double scale) { // OpenCV 默认是 BGR,SAM2 训练时用的是 RGB var rgb = new Mat(); Cv2.CvtColor(bgr, rgb, ColorConversionCodes.BGR2RGB); int h = rgb.Rows, w = rgb.Cols; scale = targetSize / (double)Math.Max(h, w); int newW = (int)Math.Round(w * scale); int newH = (int)Math.Round(h * scale); var resized = new Mat(); Cv2.Resize(rgb, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); // 右侧和下方用 0 填充到正方形 var padded = Mat.Zeros(targetSize, targetSize, MatType.CV_8UC3); resized.CopyTo(padded[new Rect(0, 0, newW, newH)]); letterboxRect = new Rect(0, 0, newW, newH); var tensor = new DenseTensor<float>(new[] { 1, 3, targetSize, targetSize }); var span = tensor.Buffer.Span; int offset = 0; // ImageNet 归一化,mean/std 用 SAM2 导出脚本默认值 float[] mean = { 0.485f, 0.456f, 0.406f }; float[] std = { 0.229f, 0.224f, 0.225f }; for (int y = 0; y < targetSize; y++) for (int x = 0; x < targetSize; x++) { var color = padded.At<Vec3b>(y, x); span[offset] = (color[0] / 255f - mean[0]) / std[0]; span[offset + targetSize * targetSize] = (color[1] / 255f - mean[1]) / std[1]; span[offset + 2 * targetSize * targetSize] = (color[2] / 255f - mean[2]) / std[2]; offset++; } return tensor; }这段代码里最关键的不是归一化,而是letterboxRect和scale。后面 prompt 做坐标换算、mask 输出裁掉填充区,都要用到这两个值。我见过很多工程直接把 mask 输出整张贴回原图,结果图像右侧和下边多出一圈黑的,根源就是忘了letterboxRect。At<Vec3b>方式遍历像素在调试阶段完全够用,批量场景可以改成 unsafe 指针,但逻辑一定保持一致。
3.2 组装 prompt 输入:点提示和框提示的坐标换算
SAM2 的 mask decoder 输入由 prompt 决定。最常用的是点提示,比如在目标物体内部点一下表示“分割这个”,再点一个负样本表示“不要这个”。和图像一样,prompt 坐标也必须跟着刚才的scale变换,不能直接拿原图像素坐标喂给 1024×1024 模型。
public static PointF ScalePoint(float x, float y, double scale) { return new PointF((float)(x * scale), (float)(y * scale)); }假设原图里一个物体的中心点是 (x = 640, y = 300),预处理时长边从 1920 缩到 1024,scale就是 0.533,那么喂给 decoder 的坐标应该是(640 * 0.533, 300 * 0.533)。如果你在标注工具里让用户点击,一定要把这个换算封装在鼠标事件里。坐标算错最典型的现场是:mask 出来了,但分割区域始终和点击位置差一段距离。
接着把点坐标、标签组装成 decoder 输入张量。注意point_labels在 ONNX Runtime 里通常要求float32,不是int64,这是很容易踩的一个类型坑。
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public static List<NamedOnnxValue> BuildDecoderInputs( string[] inputNames, DenseTensor<float> imageFeatures, PointF[] points, bool[] positiveFlags) { int n = points.Length; var pointCoords = new DenseTensor<float>(new[] { 1, n, 2 }); var pointLabels = new DenseTensor<float>(new[] { 1, n }); for (int i = 0; i < n; i++) { pointCoords[0, i, 0] = points[i].X; pointCoords[0, i, 1] = points[i].Y; pointLabels[0, i] = positiveFlags[i] ? 1f : 0f; } var inputs = new List<NamedOnnxValue>(); var nameToTensor = new Dictionary<string, Tensor<float>> { { "image_features", imageFeatures }, { "point_coords", pointCoords }, { "point_labels", pointLabels } }; foreach (var name in inputNames) { if (nameToTensor.TryGetValue(name, out var tensor)) { inputs.Add(NamedOnnxValue.CreateFromTensor(name, tensor)); } } return inputs; }这里用字典做了一层适配,目的是让你不依赖某个固定模型导出名。如果模型里还要求mask_input,通常会有一个 256×256 的零张量入口,直接补一个同尺寸DenseTensor<float>进去即可。框提示类似,把box张量按[1, 1, 4]组装,前两个数是最小 x/y,后两个数是最大 x/y。
3.3 执行推理并选择最优 mask
decoder 跑一次可能会输出多个候选 mask,原因是当一个 prompt 有歧义时,模型会同时预测几个可能的分割结果。每个 mask 都配一个iou_predictions,我们要选 IoU 分数最高的。默认拿 0.5 阈值二值化 mask,再继续后续绘制。
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public static DenseTensor<float> RunDecoderAndPickBest( Sam2Session sam, DenseTensor<float> imageFeatures, PointF[] points, bool[] positiveFlags, string[] decoderInputNames) { var inputs = BuildDecoderInputs(decoderInputNames, imageFeatures, points, positiveFlags); using var outputs = sam.Decoder.Run(inputs); var masksTensor = outputs.First(o => o.Name == "masks").AsTensor<float>(); var iouTensor = outputs.First(o => o.Name == "iou_predictions").AsTensor<float>(); int maskCount = iouTensor.Dimensions[1]; int bestIdx = 0; for (int i = 1; i < maskCount; i++) { if (iouTensor[0, i] > iouTensor[0, bestIdx]) { bestIdx = i; } } var shape = masksTensor.Dimensions; int h = shape[2]; int w = shape[3]; var maskResult = new DenseTensor<float>(new[] { 1, 1, h, w }); for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { maskResult[0, 0, y, x] = masksTensor[0, bestIdx, y, x] > 0.5f ? 1f : 0f; } return maskResult; }using var outputs不是习惯问题,而是内存问题。SAM2 decoder 的输出张量不小,如果每次调用都不释放,连续分割几十张图后内存会上涨得非常明显,这也是后面避坑章节要展开的一点。拿到maskResult后,它还是在 1024×1024 特征空间里的结果,下一步必须裁剪并缩放回原图尺寸。
3.4 把 mask 画回原图,裁掉填充区域
mask 现在是正方形,需要先裁掉右侧和下方的填充区域,只保留letterboxRect覆盖的实际图像区域,再缩放到原图尺寸。如果直接整张缩放,填充区的噪声也会被放大,视觉上就会出现方块状边缘。
using OpenCvSharp; public static Mat DrawMaskOverlay( Mat original, DenseTensor<float> mask1024, Rect letterboxRect) { int cropW = letterboxRect.Width; int cropH = letterboxRect.Height; var maskArea = new Mat(cropH, cropW, MatType.CV_32FC1); for (int y = 0; y < cropH; y++) for (int x = 0; x < cropW; x++) { maskArea.Set<float>(y, x, mask1024[0, 0, y, x]); } var maskResized = new Mat(); Cv2.Resize(maskArea, maskResized, new Size(original.Width, original.Height), 0, 0, InterpolationFlags.Nearest); var overlay = original.Clone(); for (int y = 0; y < overlay.Rows; y++) for (int x = 0; x < overlay.Cols; x++) { if (maskResized.Get<float>(y, x) > 0.5f) { Vec3b color = original.At<Vec3b>(y, x); color[0] = (byte)(color[0] * 0.35 + 0 * 0.65); color[1] = (byte)(color[1] * 0.35 + 255 * 0.65); color[2] = (byte)(color[2] * 0.35 + 0 * 0.65); overlay.Set(y, x, color); } } return overlay; }InterpolationFlags.Nearest在这里是刻意选的。mask 是二值图,膨胀到原图尺寸时用线性插值会产生边缘过渡,阈值化后反而不干净。最近邻插值能保持 mask 边界锐利,后续做连通域分析或者计算面积也更可靠。绘制时我用绿色半透明叠加,实际工程里你可以把 mask 转成轮廓再画,视觉上更专业。
4. 常见问题与避坑:shape、类型、内存和推理精度
4.1 现象:OnnxRuntime 报 input shape mismatch
第一次加载 ONNX 就报Expected dim to be ... but got ...,这是最典型的翻车现场。原因通常有两个:一是模型导出时存在动态轴,二是你在 C# 侧硬编码了[1, 3, 1024, 1024]但模型实际要求[1, 3, 1024, 1024]之外的动态维度。我遇到过 mask decoder 的point_coords维度是[None, 2],而代码里拼成了[1, N, 2],报错信息不会直接告诉你期望几维,只会给出一个抽象的形状。解决方法很朴素:先用PrintIo打印输入元数据,逐条对照写代码。如果打印出来是动态维度,尽量固定 N 的值,比如一次性最多传 8 个点,prompt 不够就用零填充,并把对应标签设为 0 或负值,让模型忽略多余的点。
4.2 现象:分割出的 mask 整体偏移,或者出现黑边
mask 出来了但位置不对,十有八九是坐标空间没对齐。一个原因是 prompt 点没有乘scale,另一个原因是输出 mask 没有按letterboxRect裁剪就直接贴回原图。前者表现是分割目标与实际点击位置分离,后者表现是图像右侧和下缘出现黑边或噪声区。解决方式是把scale和letterboxRect作为预处理结果返回,在 prompt 组装和 mask 绘制两个阶段同时使用。血泪经验是:这两个值不要放在局部变量里,最好挂在分割上下文对象上,否则画图画到一半很容易弄丢。
4.3 现象:CPU 推理一张图要十几秒,完全没法交互
SAM2 的 image encoder 体量摆在那里,纯 CPU 跑一次编码经常是秒级甚至十几秒。这不一定是代码写得慢,更多是模型本身计算量大。解决思路有三条:第一,有 NVIDIA 显卡就切 CUDA EP,没有就用 DirectML;第二,如果场景是固定图像多次点击,只在第一次点击时跑 encoder,后续点击复用imageFeatures;第三,下载时尽量选轻量级变体,比如官方提供的较小 checkpoint,而不是一上来就上最大模型。这里要特别说明:decoder 很轻,真正吃时间的是 encoder,所以“复用特征”是交互式分割工具最重要的性能优化手段。
4.4 现象:连续分割几十张图后,内存只涨不降
内存持续上涨最常见的原因不是 OnnxRuntime 本身泄漏,而是每次推理都创建InferenceSession,或者没有释放Run返回的对象。InferenceSession一旦创建会加载模型图和权重,整个生命周期里应该只有一个实例。正确的做法是把它封装成单例,用using var outputs = session.Run(...)让输出对象在作用域结束时自动释放。另外,如果你把 encoder 输出的imageFeatures留着复用,要注意它来自Run结果,某些情况下不会自动随原输出释放,稳妥做法是复制到新的DenseTensor再缓存。
4.5 现象:换了 GPU 后 Session 创建失败,或者推理结果和 CPU 不一致
AppendExecutionProvider_CUDA失败通常不是代码问题,而是 CUDA/cuDNN 版本和 OnnxRuntime 包版本不匹配。OnnxRuntime 每个 release 都会在文档里写清楚对应的 CUDA 版本,升级包版本时这个也必须同步。另一个容易忽略的点是 GPU 和 CPU 推理结果在数值上会有极小的差异,SAM2 的 mask 阈值化后一般看不出区别,但如果你拿float原始输出去做精确对比,会发现最后几位不同。我的习惯是 CPU 做功能基准,GPU 做性能验证,两个结果用 IoU 对比,大于 0.98 就认为正常。
5. 进阶:固定图像多次点击与批量 prompt 的落地技巧
交互式分割最常见的场景是:同一张图,用户点第一下,出来一个 mask,接着点第二下,mask 被修正。如果每次点击都把整张图重新编码一遍,性能上非常浪费。正确做法是把 image encoder 的输出缓存下来,每次点击只跑 decoder。
private DenseTensor<float>? _cachedFeatures; private DenseTensor<float> GetImageFeatures(Sam2Session sam, Mat image) { if (_cachedFeatures != null) { return _cachedFeatures; } var imageTensor = PreprocessImage(image, 1024, out _, out _); using var encoderOut = sam.Encoder.Run(new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("image", imageTensor) }); // 输出是推理临时对象,补一个 deep copy 再缓存 var original = encoderOut.First().AsTensor<float>(); _cachedFeatures = new DenseTensor<float>(original.Dimensions.ToArray()); original.Buffer.Span.CopyTo(_cachedFeatures.Buffer.Span); return _cachedFeatures; }这里必须 deep copy,因为encoderOut在using块结束时会释放底层内存。直接引用原张量,下次点击时可能已经读到脏数据。这个坑我在第一次做连续点击版本时踩过,后来所有缓存推理输出的地方,都强制走一次CopyTo,再没有出现过 mask 时好时坏的问题。
批量 prompt 也有技巧。如果是离线处理一批图,尽量把图像张量攒成 batch,比如一次Run传 4 张图,前提是 ONNX 导出时支持 batch 维度。如果导出的是静态 batch=1,强行换 batch 会报 mismatch,这时候就不要硬来,逐张推理即可。固定图像多次点击的场景反而更适合把 encoder 做成 batch=1 的静态模型,decoder 则固定 prompt 数量,尽量减少动态维度带来的额外开销。
验证整个工程是否跑对,我建议准备一组 known-good 数据:用 Python 官方推理给同一张图、同一组 prompt 生成参考 mask,保存下来。C# 侧跑完后计算两个 mask 的 IoU,低于 0.97 就说明某个环节和官方实现不一致,需要排查预处理或坐标换算。从那以后我每次拿到新的分割模型,都先走这一步再继续写业务代码,省下来的排查时间远比预处理耗时多。希望帮到你。
本文还有配套的精品资源,点击获取