资讯动态

C# 调用 Unet ONNX 模型 GPU 推理完整链路

发布时间:2026/10/14 13:29:36 来源:尧图企业网站定制
简介面向需要在 C#/.NET 环境中部署深度学习语义分割模型的开发者这是一套围绕 UNet ONNX Runtime 推理构建的完整 Visual Studio 示例工程。工程以 UNetSegmentation 为核心封装类配合 Form1 界面、Program 入口与 App.config 配置演示从读取图片、初始化 ONNX Runtime 的 GPU 会话、执行前向推理到解析分割输出的完整链路其中 model1.onnx 为转换后的 UNet 分割模型10.png 充当测试输入打开即可运行并观察像素级分割结果。考虑到 UNet 本身采用编码-解码结构与跳跃连接来保留空间细节示例在图像预处理、结果后处理上也给出了与典型语义分割任务匹配的实现思路迁移到自己训练的分割模型时可直接对照修改。压缩包共 85 个文件主要由 dll、cs、onnx、exe、config 等构成dll 与 lib 存放运行时依赖cs 和 resx 提供源码与窗体资源onnx 为实际网络权重exe 与 pdb 为编译产物另有 JSON、txt 等辅助配置与说明整体约 287.21MB目录结构贴近标准 VS 工程便于定位入口和逐层阅读代码。已有 152 人学习下载适合刚接触 ONNX 部署的 C# 开发者作为起点项目。1. 先拆清楚标题C# 读 Unet 的 ONNX 模型做 GPU 图片推理链路比想象长用 C# 读取 Unet 语义分割导出的 onnx 模型做图片 GPU 推理表面看只是把模型路径传给推理库真正跑起来才发现自己面对的是执行提供者加载、输入张量排布、预处理归一化、后处理 argmax 四条独立链路每一条都有坑。语义分割和图像分类不一样输出是逐像素的类别图任何一步错位结果就是全黑图或条纹状错乱而且不会抛异常告诉你哪里错了。这套方案解决的是很实际的问题训练端用 Python 导出的 Unet 模型不重新训练、不部署 Python 运行时直接接进 C# 桌面工具或服务端推理管线。适合手里已经有一个 onnx 文件、目标机器有支持 CUDA 的显卡、想在 C# 侧跑推理的开发者。这篇笔记按环境准备、模型 I/O、预处理、推理调用、后处理、调优验证的顺序拆完整条链路代码可以直接改参数复用。2. 选型与前置依赖ONNX Runtime GPU 版从装包到确认 provider 生效2.1 C# 生态里为什么默认选 ONNX RuntimeC# 侧读 onnx 做推理常见路线有三条ONNX Runtime 的托管 API、OpenCV DNN 模块、显卡厂商的私有 SDK。私有 SDK 往往只绑定自家硬件C# 包装要么不完整要么不维护换卡就废OpenCV DNN 在语义分割模型上算子支持不齐遇到自定义算子直接翻车还不好排查。ONNX Runtime 是通用做法算子覆盖全、CPU/GPU 执行提供者可切换、NuGet 包集成简单训练端导出的标准 onnx 格式几乎都能直接加载这也是标题里转化的 onnx 模型能落到 C# 侧的根本原因。CPU 版包名不带后缀GPU 版包名带.Gpu两个不要同时装程序集加载顺序会互相干扰。2.2 GPU provider 的版本对齐与 DLL 部署GPU 推理不是装完包就结束。托管包只是壳真正的 GPU 执行依赖本机的 CUDA 运行时和 cuDNN 动态库。版本对不上时它不会降级兼容表现是创建会话时抛异常或者 CUDA provider 静默失效实际退到 CPU 上跑。常见的版本对应关系随包版本变动比如 1.16 系列的 GPU 包对应 CUDA 11.8 cuDNN 8.x更新的版本可能要求 CUDA 12.x。升级包版本之前先确认本机 CUDA/cuDNN 跟得上否则宁可锁旧版本。发布时把匹配版本的 CUDA 运行时和 cuDNN 动态库放到程序输出目录或确保系统能找到它们别指望每台部署机器都装一样的全局环境。提示驱动版本、CUDA Toolkit、cuDNN 三者不必完全一致运行时只看 CUDA runtime 和 cuDNN 的 DLL 能不能被加载版本匹配关系以 NuGet 包对应版本的发布说明为准。2.3 一段最小代码确认 CUDA provider 真的生效using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Gpu; // 新版本 API 需要显式创建 OrtEnv旧版本1.16 之前可不传 using var env OrtEnv.Create(unet-infer); var options new SessionOptions(); // 1.16 及以前可以直接按设备号追加 options.AppendExecutionProvider_CUDA(0); // 1.17 之后推荐用 OrtCUDAProviderOptions 控制设备号与显存优化 // var cudaOpts new OrtCUDAProviderOptions { DeviceId 0 }; // options.AppendExecutionProvider_CUDA(cudaOpts); options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; try { using var session new InferenceSession(unet.onnx, options, env); Console.WriteLine(可用 provider: string.Join(, , session.GetAvailableProviders())); } catch (Exception ex) { Console.WriteLine(会话创建失败: ex.Message); // 典型错误找不到 cudnn 或 cublas 动态库说明 GPU 环境没对齐 }逻辑说明先创建SessionOptions追加 CUDA 执行提供者再创建InferenceSession加载模型。GetAvailableProviders()返回实际注册成功的提供者列表打印出来一眼就能确认 GPU 是否真的挂上这是排查以为在跑 GPU 其实在跑 CPU的最快路径。参数说明AppendExecutionProvider_CUDA传的是设备号多卡机器选第二张卡传 1ORT_ENABLE_ALL打开全部图优化是吞吐和延迟的常规选项。环境自检清单检查项验证方式失败的典型现象显卡驱动命令行跑驱动自带状态工具确认 CUDA 可用创建会话找不到设备CUDA runtime DLL输出目录或 PATH 下存在对应 DLL抛 DllNotFoundExceptioncuDNN DLL输出目录或 PATH 下存在对应 DLL会话创建失败错误含 cudnn包版本NuGet 里确认是.Gpu后缀CUDA provider 无法追加3. 模型 I/O 与预处理把图片对齐到 Unet 的张量形状别靠猜3.1 先打印模型元数据输入输出名和形状都从这里拿Unet 的 onnx 输入常见形状是[N, C, H, W]比如[1, 3, 512, 512]输出是[N, numClasses, H, W]的 logits 或概率。但常见不等于你的模型就是这样输入尺寸、归一化方式、通道顺序、是否带 softmax全由导出脚本决定C# 侧看不到。第一步永远是读出模型元数据而不是打开代码猜。using Microsoft.ML.OnnxRuntime; using System.Text; using var env OrtEnv.Create(io-inspect); using var session new InferenceSession(unet.onnx, env); var sb new StringBuilder(); sb.AppendLine(--- Inputs ---); foreach (var kv in session.InputMetadata) { var m kv.Value; // 形状数组里的 -1 表示动态轴由运行时传入张量决定 sb.AppendLine(${kv.Key}: type{m.ElementType}, shape[{string.Join(, , m.Shape)}]); } sb.AppendLine(--- Outputs ---); foreach (var kv in session.OutputMetadata) { var m kv.Value; sb.AppendLine(${kv.Key}: type{m.ElementType}, shape[{string.Join(, , m.Shape)}]); } Console.WriteLine(sb.ToString());逻辑说明InputMetadata和OutputMetadata是字典键是模型里的张量名这个键会用在后面的Run调用里。代码说明如果输出形状是[1, 8, 512, 512]numClasses就是 8如果宽高是-1说明模型支持动态尺寸但实际输入仍要按训练尺寸传。把这个打印结果保存下来预处理和后处理的每个参数都以它为准不要相信任何人嘴里的标准 Unet。3.2 预处理Resize、归一化、通道顺序一步都不能错预处理必须和训练管线一致。常见 Unet 训练管线是先 resize 到固定尺寸再除以 255 或按 mean/std 归一化。通道顺序取决于训练脚本用 OpenCV 读图BGR还是 PIL/Torch 读图RGB。System.Drawing 读出的是 RGB模型如果是 BGR 训练的不交换 R/B 通道分割结果会出现区域性错分天空被标成建筑那种。using System.Drawing; int targetW 512, targetH 512; // 以 3.1 节打印的输入形状为准 using var src new Bitmap(imagePath); using var resized new Bitmap(src, new Size(targetW, targetH)); // NCHW 布局channel 0 存全部像素的 Rchannel 1 存 Gchannel 2 存 B var input new float[1 * 3 * targetH * targetW]; for (int y 0; y targetH; y) { for (int x 0; x targetW; x) { var c resized.GetPixel(x, y); int idx y * targetW x; // 归一化参数以训练脚本为准这里示例是 mean0.5, std0.5 input[idx] (c.R / 255f - 0.5f) / 0.5f; input[idx targetH * targetW] (c.G / 255f - 0.5f) / 0.5f; input[idx 2 * targetH * targetW] (c.B / 255f - 0.5f) / 0.5f; } }逻辑说明这里把每个像素的 R/G/B 分别写到三个通道平面属于 NCHW 的标准写法。索引错位是新手最常见的翻车点G 通道的起始位置是H * WB 通道是2 * H * W写错之后分割结果会出现沿水平方向的条纹状错乱。参数说明均值、方差、通道顺序都以模型训练脚本为准如果导出模型的人没给这些参数就用一张带真值分割结果的图做反推先试 RGB 1/255 看结果再试 BGR对比哪组和真值一致。GetPixel逐像素读取在 512×512 尺度下够用大图或视频流场景建议用LockBits批量读字节这个优化放第 6 章。3.3 创建输入张量并执行推理using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Gpu; using var env OrtEnv.Create(unet-infer); var options new SessionOptions(); options.AppendExecutionProvider_CUDA(0); options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; using var session new InferenceSession(unet.onnx, options, env); // 把预处理好的 float 数组包装成 OrtValue形状 [1, 3, H, W] using var inputTensor OrtValue.CreateTensorValueFromMemory( input, new long[] { 1, 3, targetH, targetW }); var inputs new Dictionarystring, OrtValue { { input, inputTensor } // input 是 3.1 节打印出的实际输入名 }; using var results session.Run(inputs); var outputData results[0].GetTensorDataAsSpanfloat();逻辑说明CreateTensorValueFromMemory把托管 float 数组拷成推理张量第二个参数是形状数组元素个数必须和提前分配的数组长度一致否则可能出现越界读的玄学错误而且不一定抛异常。Run接受输入名到OrtValue的字典键要和元数据里打印出来的名字完全一致。GetTensorDataAsSpanfloat会把输出从显存拷回 CPU 的托管数组这一步必须做因为后处理在 CPU 侧。旧版本 API 用的是NamedOnnxValue.CreateFromTensor1.17 之后推荐上面这种OrtValue写法碰到老代码不要混用两套。4. 后处理把 [1, C, H, W] 的概率图变成彩色分割掩码4.1 先判断输出是 logits 还是概率很多导出的 Unet 模型末尾带 softmax输出直接是 0 到 1 的概率也有不带的输出是 logits可能有负值或大于 1 的值。判断方法很简单打印输出数组的最大值和最小值看是否都落在 [0, 1] 区间。做类别索引时logits 和概率的 argmax 结果一致因为 softmax 是单调函数所以可以直接对输出做 argmax省掉一步 softmax 计算。唯一的例外是你要用置信度做阈值过滤比如把预测概率低于 0.5 的像素标为未知区域这时必须先算 softmax。4.2 对通道维度做 argmax注意内存布局int h 512, w 512, numClasses 8; // 以 3.1 节打印的输出形状为准 var classMap new int[h * w]; for (int i 0; i h * w; i) { int best 0; float bestVal outputData[i]; // 第 0 类在当前像素的值 for (int c 1; c numClasses; c) { float v outputData[c * h * w i]; if (v bestVal) { bestVal v; best c; } } classMap[i] best; }内存布局说明ONNX 输出是连续排布的 C×H×W 平面通道 c 的像素 i 位于c * h * w i而不是i * numClasses c。后者是 HWC 布局的写法搞混之后分割图会出现条纹状错乱且不会报任何错误。numClasses不要从业务逻辑里猜从输出形状拿如果输出形状里有动态轴先读结果的实际形状再分配数组。4.3 类别索引转彩色掩码并叠加到原图var palette new Color[] { Color.FromArgb(0, 0, 0), // 背景 Color.FromArgb(255, 0, 0), // 类别 1 Color.FromArgb(0, 255, 0), // 类别 2 // 按模型实际类别定义继续补 }; using var mask new Bitmap(w, h); for (int y 0; y h; y) { for (int x 0; x w; x) { int cls classMap[y * w x]; mask.SetPixel(x, y, palette[cls % palette.Length]); } } mask.Save(mask.png, System.Drawing.Imaging.ImageFormat.Png);逻辑说明类别索引和颜色的映射关系要单独存一份文件否则换人看图根本不知道每个颜色代表什么类别。如果模型输入做过 resize而你要在原图上叠加结果记得把掩码 resize 回原图尺寸再用Graphics.DrawImage以半透明方式叠上去。这样输出的图既能看到原始纹理又能看清分割边界。5. 避坑GPU 推理的五个常见问题按现象、原因、解法排查5.1 现象日志显示 CUDA provider 已注册但 GPU 占用为 0速度也没变化原因最常见是把 CPU 版包和 GPU 版包装进了同一个项目程序集加载顺序导致实际用了 CPU 版。另一个常见原因是输出目录缺少 CUDA/cuDNN 动态库provider 追加失败时不一定抛异常只是没挂上。解决确认 NuGet 里只有.Gpu后缀的包不要混装在会话创建后打印GetAvailableProviders()列表里没有CUDAExecutionProvider就说明没生效按第 2 章自检清单逐项查 DLL。5.2 现象创建会话抛异常错误信息里有 cudnn 或 cublas 字样原因GPU 执行提供者对 cuDNN 和 CUDA runtime 的版本很敏感版本不匹配不会兼容只会拒绝加载。常见场景是本机装了新版 CUDA而 ONNX Runtime 对应版本要求的是旧版。解决不折腾全局环境为项目准备独立目录放匹配版本的 CUDA runtime 和 cuDNN 动态库通过 PATH 或程序内加载指定目录。版本匹配关系以 NuGet 包对应版本的发布说明为准升级包之前先看本机环境跟不跟得上。5.3 现象分割结果沿水平方向呈条纹状错乱或整体错位原因条纹状通常是输出布局按 HWC 理解导致的也就是 4.2 节里搞错通道偏移整体错分则多半是预处理通道顺序反了RGB 和 BGR 对调。解决先用第 3 章的元数据打印确认输出形状再用一张训练集真值图做对照测试。条纹错乱改布局写法整体错分交换 R/B 通道两类问题不要混在一起排查。5.4 现象单张推理速度比 CPU 还慢显存占用却正常原因小模型加小输入时GPU 推理的显存拷贝和 kernel 启动开销占比太高CPU 反而更快。512×512 以下的单张推理GPU 优势不明显这个结果不奇怪。解决确认GraphOptimizationLevel设为ORT_ENABLE_ALL连续处理大量图片时 GPU 优势才体现出来可以把多张图拼成 batch 一次Run比如[4, 3, 512, 512]再和 CPU 对比一轮选型。5.5 现象连续推理几百张后内存持续上涨最终 OOM原因在循环里反复创建 session 或OrtValue或者Run返回的结果集合没有释放GC 来不及回收峰值内存。GPU 侧的显存异常增长优先排查是不是每次推理都重新创建了执行提供者或会话。解决session 全局复用inputTensor和results用using包裹确保及时释放连续推理时复用同一块输入缓冲区避免每次new一个大数组触发频繁 GC。6. 性能验证与优化从能跑到稳跑6.1 用 warm-up 加计时建立可信基线// 首次 Run 包含算子和显存的初始化开销不能计入性能数据 session.Run(inputs); Console.WriteLine(warm-up done); var sw System.Diagnostics.Stopwatch.StartNew(); for (int i 0; i 10; i) { session.Run(inputs); } sw.Stop(); Console.WriteLine($平均耗时: {sw.ElapsedMilliseconds / 10.0:F1} ms);GPU 推理的第一次调用会把图编译和显存分配全部做完这部分耗时和业务无关。统计性能前先 warm-up否则首次的几百毫秒会拉高平均值。生产环境建议程序启动时在后台线程预创建会话并跑一张空白图用户第一次点击推理时就不卡。连续大批量推理时把预处理后的图片拼成[N, 3, H, W]一次Run吞吐通常能翻倍。6.2 和 CPU 结果做交叉验证拿同一张输入图分别创建 CPU 会话不追加 CUDA provider和 GPU 会话推理比较两张 classMap逐像素一致才算真正跑通。不一致时先容忍浮点累计差异导致的 argmax 在类别边界翻边这种差异像素占比通常低于 0.1%如果错误像素占比明显更高基本是预处理没对齐不是浮点问题回到第 3 章检查通道顺序和归一化参数。6.3 一个长期建议我的习惯是把预处理、推理、后处理封装成三个独立接口推理模块只负责张量进出不碰图片格式。这样换 batch、换模型、换后处理逻辑都不用动核心代码排查问题也方便哪一段出错就在哪一段打日志。这套链路我踩得最深的一个教训是永远不要相信这个 onnx 是标准 Unet 导出这句话输入名、归一化参数、输出布局必须自己读出来确认一遍。先跑通 CPU 再切 GPU问题会少一半。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑