
简介面向需要将先进概念分割能力集成到.NET桌面或服务端程序、且已具备C#基础的开发者这份C#结合OnnxRuntime部署SAM3模型的完整工程包围绕可提示概念分割目标给出了从模型加载、OnnxRuntime会话配置、提示输入处理到分割结果输出的端到端实现。压缩包共315个文件、约653.87MB内含dll原生依赖库、h头文件与xml接口说明、cs源码与工程配置、nupkg组件包、pdb调试符号等目录结构清晰可在Visual Studio中直接还原并编译NuGet组件与原生库已完成版本匹配避免了手动配置依赖的繁琐过程。目前已有122人学习浏览作者在配套博客里还针对SAM3模型结构、提示点与框的构造方式以及常见运行报错做了进一步说明。可提示概念分割允许用户用少量线索指定目标从而取代全图逐像素分类在自动驾驶、医学图像分析、工业质检等场景中更具针对性。借助这份资源读者可以直接对照代码完成推理验证掌握提示信息映射为分割掩码的关键流程并据此改造出自己的分割工具进而迁移到更多实际业务中显著缩短从模型研究到应用落地的时间。1. SAM3 可提示概念分割的 C# 部署为什么值得折腾这一趟你手里有一份 SAM3 的 C# 部署资源很可能是从某个渠道下载的 rar 包解压出来放着 ONNX 模型文件和若干 C# 源码。接下来要面对的问题是这些模型文件到底怎么加载、提示坐标怎么送进去、出来的张量怎么变成一张能看的掩码图。SAM3 和前两代最大的差异在于支持概念这一抽象层——你不用再只能点一个坐标或画一个框而是可以对模型说分割画面里所有线缆或分割所有救生圈它返回同一概念下多个实例的掩码。这篇文章按一条可复现的链路来讲怎么把 SAM3 拆成 C# 能用的 ONNX 子模型、怎么在 OnnxRuntime 里跑通最小推理、点/框/文本提示的参数怎么设以及我在这条链路上反复翻车的几个坑。适合想绕开 Python 服务、在 .NET 桌面端直接做概念分割的工程师前提是你对 OnnxRuntime 会话和张量有基础认知。2. 把 SAM3 拆成可推理的 ONNX图像编码器与掩码解码器的导出边界2.1 可提示概念分割的推理流程一个模型还是三个模型先理清 SAM3 在 C# 侧推理的完整形态。它不是一个 ONNX 文件从头跑到尾的典型模型而是由图像编码器、提示编码器、掩码解码器协作完成的组合式结构。常见的部署方案是拆成两到三个子图图像编码器把输入图编码成图像嵌入image embedding提示编码器把你给的点、框、文本概念映射成提示嵌入掩码解码器把两部分拼起来输出分割掩码。实际工程里考虑到 C# 侧通常需要缓存图像嵌入避免每一帧都对完整图像做一次重编码一般把提示编码器和掩码解码器合并成一个 ONNX 文件或者干脆三部分各成一个文件。文本概念提示还要额外一个文本编码器——常见做法是复用 CLIP text encoder 或者 SAM3 自带的文本编码分支导出一个独立的 text_encoder.onnx。这个拆分边界直接决定了后面代码的写法。图像编码器输入一个 [1,3,1024,1024] 的浮点张量输出图像嵌入常见形状是 [1,256,64,64] 这个量级具体通道数取决于 checkpoint 配置。掩码解码器则要拼接图像嵌入、稀疏提示点或框、稠密提示掩码输入以及一个控制是否输出多掩码的标志。文本编码器的输入是 token ids 和 attention mask输出文本嵌入。如果你拿到的 rar 包里只有一个大模型文件大概率需要自己做一次切分导出如果本身就是分好的几个 onnx直接跳到下一章的加载代码。下面按图像编码器 掩码解码器 文本编码器三个子模型的拆分方式来讲这是 C# 侧可维护性最好的形态。2.2 导出前必须处理的动态轴与输入名约定导出时第一件要确认的事是动态轴。图像编码器的 batch 维建议设为动态但空间维固定 1024×1024因为 OnnxRuntime 对固定形状的推理更稳妥显存分配也好估。掩码解码器的提示数量num_points是变化的你这一帧给 1 个点下一帧可能给 5 个点所以 point_coords 和 point_labels 必须设成动态轴。文本编码器如果长到 77 个 tokenCLIP 标准长度就够用可以直接固定。ONNX opset 版本也要对齐运行时支持的区间。OnnxRuntime 1.16 之后默认支持 opset 17 以上但 SAM3 如果带比较新的注意力算子建议导出到 opset 17不要为了追新直接上 21/22运行时不认会比较难受。我一般控制在 14 到 17 之间低于 14 可能缺一些新算子表达高于 17 容易在低版本运行时上拒载。2.3 图像编码器的导出步骤与验证假设你在 Python 侧已经有 SAM3 的 PyTorch 权重图像编码器导出可以这样写import torch from sam3 import build_sam3 # 按你拿到的权重来源调整 model build_sam3(sam3_checkpoint).eval() torch.onnx.export( model.image_encoder, torch.randn(1, 3, 1024, 1024), sam3_image_encoder.onnx, input_names[image], output_names[image_embeddings], dynamic_axes{ image: {0: batch}, image_embeddings: {0: batch} }, opset_version17 )build_sam3这个名字按实际权重来源调整核心是确认model.image_encoder接受 [1,3,1024,1024] 输入。导出后先用 onnxruntime Python 侧做一次一致性验证拿同一张图、同一个随机提示比对 PyTorch 输出和 ONNX 输出的差值阈值设 1e-3。最容易出问题的是 BatchNorm 层被折叠导致数值偏差在 1e-2 以上但视觉上掩码看起来还算像样容易造成能跑但不准的假象。2.4 掩码解码器与文本编码器的导出要点掩码解码器导出的复杂度集中在提示输入侧。常见的输入列表是image_embeddings形状 [1, C, 64, 64]point_coords形状 [B, N, 2]N 为提示点数量point_labels形状 [B, N]正点为 1负点为 0mask_input形状 [1, 1, 256, 256]没有稠密提示时填全零text_embeddings形状 [1, 77, D]从文本编码器拿到的嵌入输出一般是两个masks和iou_predictions。masks在 ONNX 里通常是 [1, 多个候选, 256, 256]需要插值回原图坐标。插值层我建议不写进 ONNX留到 C# 后处理做因为坐标系和缩放方式跟着界面走放在运行时改起来更灵活。文本编码器如果单独导出注意不要绕过 tokenizer。C# 侧直接喂 token ids 整数数组到位即可形状固定 [1, 77]不足位置用 pad token 补。千万注意不要尝试在 C# 里造分词器那是另一条不归路。3. C# OnnxRuntime 加载 SAM3从 NuGet 到第一次拿到掩码3.1 初始化推理会话模型路径与执行提供程序C# 侧第一步是引入 Microsoft.ML.OnnxRuntime 包。在工程文件里加包引用后初始化三个会话using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var imageSession new InferenceSession(sam3_image_encoder.onnx); var maskSession new InferenceSession(sam3_mask_decoder.onnx); var textSession new InferenceSession(sam3_text_encoder.onnx);不要无条件用默认构造先探测设备上有哪些可用的执行提供程序。OnnxRuntime 在 C# 侧默认优先 CPU如果机器上有 NVIDIA GPU你需要在构造时追加 CUDAExecutionProvidervar cudaAvailable OrtEnv.Instance().GetAvailableProviders() .Any(p p CUDAExecutionProvider); var sessionOptions new SessionOptions(); if (cudaAvailable) { sessionOptions.AppendExecutionProvider_CUDA(0); } sessionOptions.AppendExecutionProvider_CPU(); var imageSession new InferenceSession(sam3_image_encoder.onnx, sessionOptions);这个顺序很关键CUDA EP 排在前面OnnxRuntime 才会优先把算子调度到 GPU。如果反了图像编码器仍然在 CPU 上跑推理一条 1024×1024 的图像可能要好几秒交互完全没法做。3.2 图像预处理保持长宽比缩放与居中填充SAM 家族的预处理本质上不是正方形拉伸而是保持长宽比缩放 短边填充。直接 Resize 到 1024×1024 会破坏几何关系后续提示点坐标也会跟着错位。用 ImageSharp 实现预处理是比较成熟的做法public static DenseTensorfloat Preprocess(byte[] imageBytes, int size 1024) { using var image Image.LoadRgba32(imageBytes); float scale Math.Min(size / (float)image.Width, size / (float)image.Height); int newW (int)Math.Round(image.Width * scale); int newH (int)Math.Round(image.Height * scale); image.Mutate(x x.Resize(newW, newH)); var padded new ImageRgba32(size, size); int offsetX (size - newW) / 2; int offsetY (size - newH) / 2; padded.Mutate(x x.DrawImage(image, new Point(offsetX, offsetY), 1f)); var tensor new DenseTensorfloat(new[] { 1, 3, size, size }); float[] mean { 123.675f, 116.28f, 103.53f }; float[] std { 58.395f, 57.12f, 57.375f }; for (int y 0; y size; y) for (int x 0; x size; x) { var px padded[x, y]; tensor[0, 0, y, x] (px.R - mean[0]) / std[0]; tensor[0, 1, y, x] (px.G - mean[1]) / std[1]; tensor[0, 2, y, x] (px.B - mean[2]) / std[2]; } return tensor; }这段代码里最不能忽略的是scale和offsetX/offsetY。它们要作为坐标映射的基准参数保留下来。如果你在 UI 上收到一个鼠标点击坐标必须先把坐标换算到填充后的画布坐标系再换算到 1024×1024 的模型坐标系否则会出现点明明点在物体上模型却分割别处的偏差。3.3 图像嵌入缓存与掩码解码一次编码、多次提示SAM 系列最实用的一点是图像编码和提示解码可以分离。一张图可以只编码一次然后把图像嵌入缓存起来后续多次修改提示点都只跑轻量的掩码解码器。这个特性对交互场景至关重要。封装一个 Runner 类public class Sam3Runner : IDisposable { private readonly InferenceSession _imageSession; private readonly InferenceSession _maskSession; private readonly InferenceSession _textSession; private DenseTensorfloat? _cachedEmbedding; public Sam3Runner(string imagePath, string maskPath, string textPath) { var opts new SessionOptions(); opts.AppendExecutionProvider_CUDA(0); opts.AppendExecutionProvider_CPU(); _imageSession new InferenceSession(imagePath, opts); _maskSession new InferenceSession(maskPath, opts); _textSession new InferenceSession(textPath, opts); } public void EncodeImage(byte[] imageBytes) { var input Preprocess(imageBytes); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image, input) }; using var outputs _imageSession.Run(inputs); var raw outputs.First(v v.Name image_embeddings).AsTensorfloat(); var data raw.ToArray(); _cachedEmbedding new DenseTensorfloat(data, raw.Dimensions); } }EncodeImage只做一次之后每次提示变化都复用_cachedEmbedding。掩码解码接在下面public DenseTensorfloat DecodeMask(float x, float y, string concept) { var textEmbed EncodeConcept(concept); var pointCoords new DenseTensorfloat(new[] { 1, 1, 2 }, new float[] { x, y }); var pointLabels new DenseTensorint(new[] { 1, 1 }, new int[] { 1 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image_embeddings, _cachedEmbedding!), NamedOnnxValue.CreateFromTensor(point_coords, pointCoords), NamedOnnxValue.CreateFromTensor(point_labels, pointLabels), NamedOnnxValue.CreateFromTensor(text_embeddings, textEmbed) }; using var outputs _maskSession.Run(inputs); var masksRaw outputs.First(v v.Name masks).AsTensorfloat(); var iouRaw outputs.First(v v.Name iou_predictions).AsTensorfloat(); var masks new DenseTensorfloat(masksRaw.ToArray(), masksRaw.Dimensions); var iou new DenseTensorfloat(iouRaw.ToArray(), iouRaw.Dimensions); int best 0; for (int i 0; i iou.Dimensions[1]; i) { if (iou[0, i] iou[0, best]) best i; } // 取第 best 个候选形状为 [1, 256, 256] var mask new DenseTensorfloat(new[] { 1, 256, 256 }); for (int yIdx 0; yIdx 256; yIdx) for (int xIdx 0; xIdx 256; xIdx) { mask[0, yIdx, xIdx] masks[0, best, yIdx, xIdx]; } return ApplySigmoid(mask); }EncodeConcept内部把 token ids 送进_textSession再取text_embeddings输出形状整理成 [1, 77, D]。这里要注意point_coords的形状我用 [1, 1, 2] 表示 batch 为 1、一个点、xy 坐标。导出 ONNX 时最好在模型里就把坐标输入形状定成 [B, N, 2]这样 C# 侧不用在多个版本间迁就。3.4 后处理sigmoid 与缩放到原图掩码输出是 logits数值范围可能在 (-10, 10) 之间直接当透明度用不行。想得到概率图必须做 sigmoidpublic static DenseTensorfloat ApplySigmoid(DenseTensorfloat input) { var result new DenseTensorfloat(input.Dimensions); for (int i 0; i input.Length; i) { result.SetValue(i, 1f / (1f (float)Math.Exp(-input.GetValue(i)))); } return result; }做完 sigmoid 后这张 256×256 的 mask 还要缩放回原图尺寸。缩放时要用双线性插值而不是最近邻否则边缘在 UI 上看起来像锯齿。ImageSharp 的 Resize 默认就是双线性直接用即可。需要注意的是预处理阶段做了 padding所以 mask 对应的有效区域不是整张图要把 padding 那部分裁掉再映射到原始图像的像素坐标这一步遗漏会让 mask 叠加位置整体偏移。4. 可提示概念分割的提示工程点、框、文本怎么组合4.1 点提示的坐标映射与正负标签点提示分为正点和负点。正点表示这里就是要分割的目标负点表示这里不是目标。多实例场景里负点非常有用画面中有两个同类的救生圈你只想要左边那个就在右边那个上补一个负点模型会避开它。坐标映射是一条链UI 坐标 → 原图坐标 → 填充后画布坐标 → 模型 1024×1024 坐标。假设原图是 1920×1080预处理时 scale 1024/1920 ≈ 0.533按宽计算。原图缩到 1024×576再在上下各补 224 行填充。那么 UI 坐标 (px, py) 换算成模型坐标是var modelX px * scale offsetX; var modelY py * scale offsetY;如果 UI 显示的是缩放后的预览图那就是另一套系数。总之预处理里用到的 scale 和 offset 必须贯穿到提示坐标处理。我把这几个值放进一个PreprocessInfo类里随图像编码结果一起缓存避免在代码不同地方重复计算。4.2 文本概念提示的阈值与多概念共存SAM3 的核心卖点是文本概念提示。你传入一个概念标签比如floor或wire模型返回这个概念的多个实例掩码。实际操作时,掩码解码器会为每个概念输出候选掩码每个候选带一个 IoU 预测值。这个 IoU 预测值可以作为置信度参考。阈值设置是影响体验的最大单点参数。完全依赖模型给出的分数是有风险的刚上手时建议把阈值调高比如 0.8 到 0.85宁漏勿错。原因很简单误检掩码叠加在界面上会让用户觉得完全不可用漏检反而可以通过再点一下提示来补救。多概念共存时通常做法是逐个概念跑一次掩码解码然后把所有概念的掩码做非极大值抑制。SAM3 输出的多个掩码之间可能有重叠特别当概念语义相近car 和 vehicle。如果你不做去重叠用户会看到分割结果互相覆盖透明度混合后脏乱。NMS 的 IoU 阈值可以设在 0.6低于这个重叠度保留高于则按置信度取一个。4.3 三种提示的组合策略先文本粗分再用点精修实际部署时的最优交互通常不是单靠一种提示。只用文本概念分割边界往往不够精细只用点提示每个实例都要点一下效率低。把两者串起来用用户输入概念词比如椅子跑一次文本概念分割拿到所有候选掩码列表展示候选掩码用户点选某一个实例对该实例的提升点坐标做一次掩码解码器精修拿用户点击的坐标作为点提示原图编码缓存不重跑文本嵌入复用第一次概念分割时的结果这样只多跑一次轻量掩码解码耗时通常在几十毫秒内交互上完全能接受。框提示我一般放在这类组合方案的最后——当文本和点的组合在复杂边界上反复出现误分割时给用户一个画框的操作框内约束候选区域。提示类型的取舍可以参考下面这张表提示类型适用场景主要参数常见问题点提示单实例精修、误检补救坐标、正负标签坐标映射不当导致偏移框提示明确目标区域、多目标区域约束左上角、右下角框太小模型没有上下文文本概念提示整类目标一次性分割概念词长度、置信度阈值阈值过低误检多文本点组合概念识别后精修概念词、点坐标文本嵌入和点嵌入拼接顺序出错参数方面框提示的输入布局在 SAM 系列里一般和点提示合在同一个point_coords张量一个框用两个点表示两个点的 label 都用正标签。如果你的掩码解码器导出的输入名不是point_coords以实际 onnx 文件为准从输出模型里导出输入元数据后对号入座。5. 部署 SAM3 的避坑清单我反复翻车过的 6 个场景5.1 现象推理结果全黑掩码值全是接近 0 的浮点数第一次跑出全黑我花了一个下午才反应过来。掩码输出是 logits数值范围大约在 -10 到 10直接转成灰度图时负值被压到 0自然全黑。模版代码里忘了带 sigmoid 就会出现这个情况。原因输出没过激活函数后续把 logits 当成概率用了。解决对masks输出按元素做 sigmoid再进入可视化或叠加逻辑。判断是否过 sigmoid 很简单看输出值是不是都落在 (0,1) 区间。如果看到负值千万别直接做阈值化。5.2 现象提示点明明点在物体上分割结果却跑到旁边这个问题几乎每个人都遇到过看起来像模型出了问题其实十有八九是坐标映射错了。原图 1920×1080输入端 1024×1024 中间经过缩放和填充坐标系数不对分割自然偏。原因只做了等比缩放忽略了填充偏移或者把 UI 预览坐标直接当成原图坐标。解决在预处理里把scale、offsetX、offsetY一起返回并写一个统一的UI2Model(Point p)函数所有提示点都走这个函数转换。不要在零零散散的调用处各算一遍系数。5.3 现象前一次分割的掩码残留在后一张图里连续处理多张图像时第二张图的分割结果里出现第一张图物体的残影。这个现象隐蔽因为数值不是全黑看起来像误检实际是图像嵌入缓存没有刷新。原因_cachedEmbedding在一次 EncodeImage 之后没有更新而 DecodeMask 一直复用旧嵌入。解决在 EncodeImage 入口处先清空旧嵌入引用或者引入一个imageVersion字段每次编码递增DecodeMask 时校验版本不一致直接抛异常。宁可报错也不要用脏数据出结果。5.4 现象OnnxRuntime 加载模型报 unknown operator加载sam3_mask_decoder.onnx时报错Could not find an implementation for the node...看起来像模型坏了。原因导出时 opset 版本太高或者带了运行时不支持的算子。解决确认 OnnxRuntime 版本NuGet 包尽量用 1.17 以上导出时把 opset 固定到 17。另一个隐蔽原因是模型里带着torch.nn.functional.scaled_dot_product_attention的某些变体OnnxRuntime 对 flash attention 的支持分版本如果出现这个可以考虑在导出脚本里先把注意力实现替换成标准多头注意力的实现。5.5 现象CPU 上图像编码 4 秒交互完全没有办法用在没有 GPU 的机器上跑光图像编码一次就 4 秒多。这种延迟做可提示分割交互几乎不可接受因为用户每框一个区域都要现场编码。原因图像编码器没有走 GPU或者机器本身没有 NVIDIA GPU。解决桌面端优先给 OnnxRuntime 挂上 CUDA EP如果纯 CPU 部署考虑两种方案一是把输入分辨率降到 768×768 并同步调整提示坐标换算系数精度损失可控二是预先离线编码关键帧运行时只做提示解码的轻量推理。5.6 现象文本概念输入中文识别效果突然变差同一个概念词英文如 floor 表现正常换成中文地板掩码质量明显下降。原因文本编码器训练语料里中文占比低或者 C# 侧 tokenizer 和导出前的 Python tokenizer 不一致。解决概念分割在生产环境尽量不要直接在推理侧输入自然语言。把用户输入的中文映射到一个受限词表比如 UI 里做成下拉框再用英文概念词传给模型。这个思路虽然土但稳定性和可控性都比自然语言直连好得多。6. 从跑通到可用验证分割精度与性能的落地习惯跑了半年 SAM 系列部署后我养成了一个习惯任何模型改动后先不着急进工程先跑三张标准测试图的回回归验证。一张是单物体简洁背景一张是多物体杂乱场景一张是光照极差的夜间图。每张图固定三个提示一个概念词、一个点、一个框。记录三组指标——IoU、推理耗时、CPU/GPU 内存峰值。一个可接受的指标基线是单物体场景 IoU 0.85 以上多物体场景 0.7 以上夜间场景 0.6 以上。如果夜间频繁翻车检查预处理阶段是不是把对比度做过强增强SAM 家族对输入像素域很敏感任何额外归一化都可能改变行为。性能方面还有一个容易忽略的点掩码解码器虽然是轻量模型但如果你传入的提示数量特别多超过 10 个点耗时会线性上升。交互端要做好负载控制限制单次提示点数不超过 8 个。真需要更多空间约束用框代替一堆点效率高得多。最后想说SAM3 的 C# 部署真正难的不是模型有多大而是组合式推理带来的工程问题。把图像编码、提示解码、文本编码三条链路各自封装好边界清晰每个模块都能独立测试。我在这套架构上踩过最多的坑就是顺手复用旧缓存现在所有缓存入口都带版本号校验。希望这些经验能帮你在部署 SAM3 时少走一段弯路。本文还有配套的精品资源点击获取