2026/10/9 16:48:38

C# OnnxRuntime部署SAM2:图像分割的完整工程实践

C# OnnxRuntime部署SAM2:图像分割的完整工程实践 简介面向C#开发者的SAM2图像分割推理资源借助OnnxRuntime将Meta最新分割模型部署到.NET环境中解决了C#生态缺少前沿深度学习落地示例的痛点。项目面向医学影像辅助诊断、自动驾驶道路感知和智能视频监控等实际场景适合具备基础C#编程能力、希望接触分割模型的开发者也适合需要快速验证ONNX推理流程的算法工程师。压缩包共312个文件约811MB除解决方案文件和13个C#源码外还包含6个ONNX模型、65个DLL运行库、XML配置、PDB调试符号、NuGet依赖包等目录内的Onnx SAM Demo示例可以直接对照练习。目前已有482人学习下载。借助Visual Studio打开后可以沿示例代码理解从模型加载、图像预处理到分割掩码输出的完整流程同时还能查看不同文件在推理链路中的作用显著降低将SAM2集成到C#应用中的门槛。1. C# OnnxRuntime 跑 SAM2这份分割资源到底能解决什么问题C# 开发者想用 SAM2 做图像分割最容易卡在环境上模型是 PyTorch 的跑服务要装 Python还要带着一堆依赖进生产环境。这份 C# OnnxRuntime SAM2 资源包把整条链路换成了 C#加载 ONNX 模型、预处理、推理、掩码解码全部在一个 .NET 工程里完成离线也能跑。它适合做桌面工具、内部质检平台或者任何不想引入 Python 服务的跨平台场景。你拿到手不需要懂 PyTorch按步骤把模型文件放好改改路径就能看到第一个分割结果对已经上手的人来说资源里暴露的封装边界和推理参数会告诉你哪些地方需要按自己的业务重写。2. 模型与运行时的选型从 ONNX 导出看懂 SAM2 的输入输出在碰 C# 之前先得把 SAM2 在推理时到底干了什么讲清楚。很多新手翻车的点不在 C# 代码而在模型输入输出没对齐。这一章先把两段式结构讲透再给导出方案和输入输出清单后面写代码就是照单点菜。2.1 SAM2 的分割流程其实是一条两阶段流水线SAM2 不是单一模型而是“图像编码器 提示解码器”两段结构。图像编码器负责把整张图缩成一个稠密的特征张量提示解码器再根据点、框、掩码这些提示从特征里解码出目标掩码。这个拆法和传统分割网络差别很大传统网络一次前向出全图结果SAM2 要先理解全图再针对提示做局部解码所以点提示、框提示、自动分割都能共用同一份图像特征。在 C# 侧最稳妥的落地方式就是把 SAM2 导出成两个 ONNX 文件一个 encoder一个 decoder。encoder 吃原始图像输出 image embeddingsdecoder 吃 embeddings 加用户提示输出掩码 logits 和 IoU 预测。导出时把两段接口固定下来后面写 C# 代码就可以按这两个模型的输入输出名来对接。解码器输出的 logits 是低分辨率张量常见的导出配置是 256 × 256要放大回原图尺寸才能用。这一步很多人直接用阈值二值化结果边缘各种锯齿正确做法是先做双线性插值再进 sigmoid 和阈值。另一个容易忽略的点是decoder 会同时输出好几个候选掩码每个候选配一个 IoU 预测分数要取分数最高的那个而不是默认取第一个。2.2 把 PyTorch 模型转成 ONNX 的两步操作资源包里的 ONNX 模型一般已经转换好了但如果你需要用自己的权重重新导出流程并不复杂。导出代码核心是torch.onnx.export关键是给 encoder 和 decoder 分别指定动态轴。import torch # 假设 sam2_model 是已经加载好的模型 # encoder 导出输入图像输出 image embeddings torch.onnx.export( sam2_model.image_encoder, torch.randn(1, 3, 1024, 1024), sam2_encoder.onnx, input_names[image], output_names[image_embeddings], dynamic_axes{image: {0: batch}}, opset_version17 ) # decoder 导出dummy_embeddings 形状以模型实际输出为准 dummy_embeddings torch.randn(1, 256, 64, 64) torch.onnx.export( sam2_model.sam_mask_decoder, (dummy_embeddings, torch.randn(1, 1, 2), # point_coords: 点坐标 Nx2 torch.randn(1, 1), # point_labels: 点标签 torch.randn(1, 1, 256, 256), # mask_input: 掩码提示 torch.randn(1)), # has_mask_input: 是否带掩码提示 sam2_decoder.onnx, input_names[image_embeddings, point_coords, point_labels, mask_input, has_mask_input], output_names[low_res_masks, iou_predictions], dynamic_axes{ point_coords: {0: num_points}, point_labels: {0: num_points}, low_res_masks: {0: num_masks} }, opset_version17 )这里dynamic_axes指定哪些维度是动态的。encoder 里 batch 维度放开一次可以喂多张图decoder 里点的数量放开单点提示、多点提示、框提示可以共用同一份导出模型。opset_version建议和运行时的要求对齐太高或太低都会在加载时报兼容性错误。导出完建议立刻打印一份输入输出维度清单存到工程目录当参考后面排查坐标错位会很有用。2.3 输入张量定长还是变长从维度里读懂模型SAM2 的 ONNX 导出通常要求输入图像固定为 1024 × 1024原因是位置编码在训练时按这个尺寸设计。动态尺寸不是不行但需要额外处理位置编码不建议在 C# 侧做。图像预处理时把任意尺寸缩放、填充到 1024 × 1024并且记录缩放比例备用。图像张量的布局是 NCHW数据要归一化到 0~1 范围然后按通道减去均值、除以标准差。标准均值是 [0.485, 0.456, 0.406]标准差是 [0.229, 0.224, 0.225]这组数在图像分割任务里是通用的和分类模型用的归一化参数一致。点坐标的坐标系也要提前统一。模型内部处理的是 1024 × 1024 空间如果你在原图上点击了一个点必须先把坐标按缩放比例映射到 1024 空间再传给 decoder。这里少乘一个比例提示点就会飘这是出错率最高的地方。如果图像长宽比不一致缩放后还要考虑填充偏移坐标映射时要把偏移量也加进去。运行时的选择上CPU 版 OnnxRuntime 部署最简单但速度一般GPU 版需要额外注意版本匹配。资源包里通常会提供对应的运行时说明没有的话先跑 CPU 验证结果再换 GPU。注意模型文件名、输入输出名在不同权重里可能不一样拿到资源包后第一件事是用工具打开 onnx 文件核对名字而不是直接改路径跑。3. 搭建 C# 工程加载两个 ONNX 模型并把张量接起来这一章直接落到工程实现。下面代码的写法不依赖具体 UI 框架控制台、WPF、WinForms 都能搬。3.1 工程与依赖一个 .NET 项目就够了新建一个 .NET 控制台项目引三个核心依赖OnnxRuntime 主库、System.Drawing 或 ImageSharp 用来读写图像、以及轻量 JSON 库用来读提示配置。OnnxRuntime 的主包会带一个OrtExtensions在做 Tensor 和高维数组之间转换时比较方便。如果你在资源包里看到已有的工程文件不要急着升级依赖版本先按锁定的版本跑通再考虑升级。程序集引用上有个小经验如果目标机器没有 GPU一开始就装 CPU 版 OnnxRuntime避免版本冲突干扰问题排查。GPU 版在 CUDA 和驱动不匹配时会直接崩溃这个放到第五章详细说。3.2 图像预处理读图、缩放、归一化图像输入前要转成 RGB、resize 到 1024 × 1024、再转成张量。注意读取时如果源图是 BGR必须先转通道顺序否则分割出来的目标颜色会错乱但形状看起来又正常这种问题排查起来很费时间。下面用 ImageSharp 做示例using var image Image.LoadRgb24(imagePath); int origW image.Width, origH image.Height; // resize 到模型输入尺寸 image.Mutate(x x.Resize(1024, 1024)); float[] data new float[3 * 1024 * 1024]; for (int y 0; y 1024; y) for (int x 0; x 1024; x) { var px image[x, y]; data[y * 1024 x] (px.R / 255f - 0.485f) / 0.229f; data[1024 * 1024 y * 1024 x] (px.G / 255f - 0.456f) / 0.224f; data[2 * 1024 * 1024 y * 1024 x] (px.B / 255f - 0.406f) / 0.225f; } var tensor new DenseTensorfloat(data, new[] { 1, 3, 1024, 1024 });这段代码把图片像素按 NCHW 布局填进去。R 通道整体放在前 1024×1024 个元素G 和 B 依次往后放通道顺序绝对不能调换。归一化的三个常量对应 ImageNet 的 mean 和 std是 SAM2 训练时的标准配置不要因为“看起来差不多”而省略。origW/origH要保存下来后面做提示点映射和最终掩码恢复尺寸都要用。3.3 推理主流程Encoder 和 Decoder 顺序调用加载两个模型会话先跑 encoder 拿 image embeddings再拼提示输入跑 decoder。C# 侧用OrtValue创建张量能够明确指定形状避免DenseTensor在动态维度上的形状推断问题。using var encoderSession new InferenceSession(sam2_encoder.onnx); using var decoderSession new InferenceSession(sam2_decoder.onnx); // 获取会话自带的内存信息CPU 环境默认是 CPU 分配器 using var memoryInfo encoderSession.GetMemoryInfo(); // 先编码 using var inputTensorValue OrtValue.CreateTensorValueFromMemory( memoryInfo, data, new long[] { 1, 3, 1024, 1024 }); var encoderOutput encoderSession.Run( new[] { image }, new[] { inputTensorValue }, new[] { image_embeddings }); using var embeddings encoderOutput[0]; // 构造提示一个前景点坐标已经映射到 1024 空间 float[] pointCoords new float[] { 512f, 512f }; float[] pointLabels new float[] { 1f }; float[] maskInput new float[1 * 1 * 256 * 256]; // 不用掩码提示时全 0 float[] hasMask new float[] { 0f }; using var coordsTensor OrtValue.CreateTensorValueFromMemory( memoryInfo, pointCoords, new long[] { 1, 1, 2 }); using var labelsTensor OrtValue.CreateTensorValueFromMemory( memoryInfo, pointLabels, new long[] { 1, 1 }); using var maskTensor OrtValue.CreateTensorValueFromMemory( memoryInfo, maskInput, new long[] { 1, 1, 256, 256 }); using var hasMaskTensor OrtValue.CreateTensorValueFromMemory( memoryInfo, hasMask, new long[] { 1 }); var decoderInputs new[] { embeddings, coordsTensor, labelsTensor, maskTensor, hasMaskTensor }; var decoderOutput decoderSession.Run( new[] { image_embeddings, point_coords, point_labels, mask_input, has_mask_input }, decoderInputs, new[] { low_res_masks, iou_predictions });这里有几个值得展开的点。第一point_coords的形状是[1, 1, 2]含义分别是 batch、点的数量、坐标维度多个点就改成[1, N, 2]。第二encoder 输出的OrtValue直接传给 decoder不需要先转成数组再转回来OnnxRuntime 内部能直接复用这块显存或内存省一次拷贝。第三maskInput只有在给模型提供掩码提示时才需要真实数据不提供时用全 0 张量同时把hasMask设成 0。3.4 掩码后处理从 logits 到可视化结果decoder 输出的low_res_masks是 logits不是最终掩码。要先取 sigmoid 再 resize 到原图尺寸。常见做法是先把 logits 按行展开成二维矩阵C# 里用循环做双线性插值到原图尺寸// 取最佳掩码按 iou_predictions 分数选择 var iouData decoderOutput[1].GetTensorDataAsSpanfloat(); int best iouData[0] iouData[1] ? 0 : 1; // 从 low_res_masks 中取出第 best 个候选 var maskSpan decoderOutput[0].GetTensorDataAsSpanfloat(); int mw 256, mh 256; float[,] logits new float[mh, mw]; for (int y 0; y mh; y) for (int x 0; x mw; x) logits[y, x] maskSpan[best * mw * mh y * mw x]; // 双线性插值到原图尺寸省略 BilinearResize 实现 float[,] upsampled BilinearResize(logits, mw, mh, origW, origH); byte[,] mask new byte[origH, origW]; for (int y 0; y origH; y) for (int x 0; x origW; x) { float v 1f / (1f MathF.Exp(-upsampled[y, x])); mask[y, x] v 0.5 ? (byte)255 : (byte)0; }这段代码的关键是best的选取。SAM2 针对每个提示会输出多个候选掩码分数由 IoU 预测给出默认取分数最高的那个候选就能得到稳定结果。如果你要保留所有候选做多目标分析也可以把整个 maskSpan 按候选数切分逐个做 sigmoid 和插值。阈值 0.5 是常见默认值换成 0.3 会得到更膨胀的轮廓具体按业务需求调节。4. 点提示、框提示与 Everything 模式把模型接到具体需求SAM2 最实用的地方在于提示方式多样。第三章的流程只演示了单点实际业务里还会遇到框提示、多点、全图自动分割。4.1 点提示单点分割与多点融合点提示的输入是坐标加标签标签 1 表示前景0 表示背景。单点分割最直接你在图上点一个位置SAM2 会尽量把包含该点的完整目标分割出来。多点提示用于目标轮廓复杂的情况比如一堆重合物体里只想分一个。下面代码演示多点提示的拼接和坐标映射// 多点提示pointCoords 是平面数组 [x1, y1, x2, y2, x3, y3] float[] pointCoords new float[] { 120f, 80f, 300f, 220f, 500f, 400f }; float[] pointLabels new float[] { 1f, 1f, 0f }; // 映射到 1024 空间 float scaleX 1024f / origW; float scaleY 1024f / origH; for (int i 0; i pointCoords.Length; i 2) { pointCoords[i] * scaleX; pointCoords[i 1] * scaleY; } // 然后按 [1, 3, 2] 形状创建 OrtValue 传给 decoder多点的关键在标签设计把想选中的目标上的点都标 1把同区域内不想选中的点标 0SAM2 的注意力机制会结合这些约束更新掩码。如果分割结果总是不理想优先调整的是点的位置和数量而不是模型参数。标签全设成 1 并不等于“多选”了多个目标SAM2 默认把提示当成同一个目标的多个约束这点和分类任务里的多标签语义不一样。如果一批独立目标要分别分割不要把所有点拼成一个提示输入。正确做法是循环多次调用 decoder或者在模型导出时放开 batch 维度让每个 batch 元素对应一个独立目标。批量推理能明显提升吞吐但要注意内存占用网格点提示这种大批量场景尤其明显。4.2 框提示从检测框到分割结果框提示可以理解为“只给一个矩形框让模型在框内找目标”。实际处理上不需要写额外逻辑把框的左上角和右下角作为两个前景点放进point_coords就行。SAM2 的 decoder 能识别出这是一组框约束并生成框内目标的掩码// 检测框left, top, right, bottom float[] box new float[] { 100f, 80f, 600f, 500f }; float[] coords new float[] { box[0] * scaleX, box[1] * scaleY, box[2] * scaleX, box[3] * scaleY }; float[] labels new float[] { 1f, 1f };如果你的框来自目标检测网络需要注意坐标系定义有的检测器输出的是中心点加宽高有的输出左上角和右下角转换时少一步会让框错位。框提示的输出掩码有时会比框略大因为有部分目标是可形变或边缘模糊的这是模型根据全局上下文推断的结果不一定是 bug。框和点也能混合使用当单个框包含多个目标时在目标上补一个前景点可以帮模型锁定你要分割的那个。做法是把框的两个角点和补充点一起放进point_coords标签全部设 1模型会自动把框约束和点约束结合起来。4.3 Everything 模式全图自动分割的实现Everything 模式不靠人给提示而是自己生成网格点逐点跑解码器最后把重叠掩码合并。网格越密召回越高但解码器要跑的次数也越多。C# 侧把提示点按 batch 维度堆叠能显著提速一次 decoder 调用处理多个点比循环调用快数倍int gridSize 32; int numPoints gridSize * gridSize; float[] allCoords new float[numPoints * 2]; float[] allLabels new float[numPoints]; for (int gy 0; gy gridSize; gy) for (int gx 0; gx gridSize; gx) { // 每个网格点放在格子中心坐标映射到 1024 空间 float cx (gx 0.5f) / gridSize * 1024f; float cy (gy 0.5f) / gridSize * 1024f; int idx gy * gridSize gx; allCoords[idx * 2] cx; allCoords[idx * 2 1] cy; allLabels[idx] 1f; } // 一次性送入 decoderpoint_coords 形状为 [1, 1024, 2]Everything 模式还有个隐藏细节重叠掩码合并时IoU 阈值和面积阈值会明显影响输出目标数量。阈值设太紧会把一个完整目标切成几块设太松又会合并掉独立目标。这个参数在多目标图像上要多调几轮不能一套参数打天下。网格密度从 32 调到 64推理时间会变成四倍但召回提升往往没有这么明显实际项目建议先跑 32 再根据效果决定是否加密。5. 避坑记录五个反复踩到的问题与处理方式这部分内容来自我拆这套资源时的真实排查过程每一条都值得记下来。能避开这几条基本就能把资源包稳定跑起来。5.1 分割结果全黑或全白归一化方式不对现象无论点提示怎么给输出 mask 要么全 0 要么全 255IoU 分数也异常高或异常低。原因图像归一化做成了“除以 255 之后直接作为模型输入”没有按通道减均值、除标准差。SAM2 的权重在带归一化的输入分布上训练直接把像素值喂进去特征的激活范围完全不对。解决按第三章的写法每个像素除以 255 后减去 [0.485, 0.456, 0.406]再除以 [0.229, 0.224, 0.225]。先减均值再除标准差顺序不能调换。5.2 点提示位置偏移原图坐标没有映射到 1024 空间现象点击目标中心分割出来的却是旁边的物体有时甚至输出空掩码。原因模型输入是 1024 × 1024而 UI 上取点用的是原图坐标。原图如果是 1920 × 1080坐标映射错位接近一倍提示点自然落到错误位置。解决预处理时记录scaleX 1024f / origW; scaleY 1024f / origH。所有提示点乘对应比例。如果图像长宽比不一致而采用 padding 方案坐标映射还要考虑 pad 偏移量不能只用比例。5.3 Everything 模式慢到没法用每次点单独推理现象32 × 32 网格跑一张 1024 图要十几秒。原因代码里用循环对每个网格点单独调 decoder 推理。SAM2 的 decoder 可以一次处理 batch 个点单独调用等于把大部分时间花在会话调度和输入输出转换上。解决把网格点按[1, N, 2]拼接labels 按[1, N]准备一次完成所有点推理。我这样改动后1024 个点的 Everything 推理时间压到了单点调用的十分之一以内。5.4 掩码边缘全是锯齿logits 后处理少了插值现象分割结果形状大致正确但边缘有严重锯齿和方块感小目标尤其明显。原因解码器输出的是低分辨率 logits很多代码直接按索引二值化再拉伸显示。硬上采样的结果就是锯齿。解决先用双线性插值把 logits 放大到原图尺寸再取 sigmoid 和阈值。资源包或第三方图像库通常已经有插值函数优先复用重点验证边界条件别溢出。5.5 GPU 加载直接崩溃OnnxRuntime 与 CUDA 版本不匹配现象换 GPU 版后程序启动即崩溃或者第一次Run时报内存错误。原因OnnxRuntime GPU 版对 CUDA 版本有强制要求装错版本或驱动加载阶段就能报错。另一个隐藏原因是同一进程同时加载 CPU 和 GPU 会话两个库的底层分配器冲突。解决先卸载 GPU 包换 CPU 跑通全流程确认结果正确后再装 GPU 版并保持和资源包内部依赖完全一致。GPU 跑通后也要做一次结果对照防止精度差异导致掩码抖动。6. 验证与优化把分割结果钉死在可信区间拿到分割结果不等于能交付还要做两件事一是验证结果正确二是把性能调到能上生产。6.1 用回归样本验证模型部署准备几张带标准答案的测试图每张图跑三个固定提示点和一个固定框记录输出掩码的像素级 IoU。把这个结果作为基线之后每次改代码、换依赖、调整预处理都要重新跑一遍这批脚本确认指标没有回退。如果你手头没有标准答案可以选择一个目标区域人工确认掩码覆盖范围和实际目标重合度超过九成就算通过。我一般会把十张图跑完后的结果导出成 PNG 放在输出目录里肉眼扫一遍再放行。6.2 性能优化缓存 encoder 输出只跑 decoder单张图只推理一次时Encoder 的耗时占比很高。实际业务中很多场景是同一张图需要反复换提示点试分割结果比如标注工具里点一下出一个轮廓。这时候正确做法是把 encoder 的输出缓存起来换提示点时只跑 decoderpublic class Sam2CachedInference { private readonly OrtValue _cachedEmbeddings; public Sam2CachedInference(string imagePath) { using var encoderSession new InferenceSession(sam2_encoder.onnx); // 省略预处理和 Run 入参只保留关键流程 var output encoderSession.Run(...); _cachedEmbeddings output[0]; } public OrtValue DecodePoint(float x, float y) { // 每次只跑 decoder复用已缓存的 image embeddings using var decoderSession new InferenceSession(sam2_decoder.onnx); return decoderSession.Run(...)[0]; } }注意缓存的是 encoder 输出OrtValue不是输入图像因为 encoder 的计算量远大于 decoder而缓存 embeddings 后换十个提示点仍只需一次 encoder 推理。资源包里如果是按这个思路封装的性能没问题如果是每次都重跑完整流程建议改成这个缓存结构。从那以后我每次部署前都会强制走一遍先用 CPU 跑基线再做提示缓存优化最后才测 GPU 加速。这套流程踩过不少坑希望帮到你。本文还有配套的精品资源点击获取