C#调用U2Net实现本地批量抠图:ONNX Runtime部署全流程
发布时间:2026/10/1 2:35:08来源:尧图网络
简介这是一份面向C#开发者的U2Net抠图源码工程基于U-Net深度学习模型完成高精度图像分割适合需要在.NET环境中落地AI抠图或研究图像语义分割的开发者。资源包共81个文件包含Visual Studio解决方案、C#窗体程序源码、预训练ONNX模型通用抠图、快速版本及人像分割、依赖的DLL运行库和配置文件整体约338.9MB工程结构清晰编译后即可直接体验抠图效果。工程内部使用ONNX Runtime执行模型推理并借助OpenCvSharp完成图像格式转换、颜色空间变换等预处理与后处理覆盖从加载模型到输出分割结果的完整流程。对于希望掌握深度学习模型在Windows桌面端部署的开发者这份源码提供了可运行的最小示例能够帮助理解U-Net的编码器解码器结构、模型调用方式以及图像分割的工程化细节。该资源已有1005人学习下载适合作为C#结合深度学习的入门参考项目。1. C# U2Net 抠图源码把深度学习抠图跑进本地桌面程序的第三条路接到一个内部工具需求给产品图批量抠背景图不能上传公网预算又买不起按张计费的抠图接口。试过 OpenCV 传统分割边缘全是锯齿手动用 GIMP 一张张抠几百张图能让人崩溃到直接想转行。最后定下来用 C# 调 U2Net 做本地抠图走 ONNX Runtime 推理。U2Net 是显著性目标检测网络能输出和原图同尺寸的掩码不需要先画检测框再分割头发丝、羽毛边缘和半透明物体保留得明显比传统算法好效果可以拿 OBS 的 AI 智能抠图插件做参照。这篇把选型理由、能跑的 C# 源码结构、每一处参数设置和踩坑记录完整写出来适合做桌面工具、上位机或者批量处理脚本的 C# 开发者照着就能把抠图模块嵌进自己工程。2. 先看懂 U2Net 在算什么模型结构、选型对比与 ONNX 导出很多人拿到一个 U2Net 抠图源码就直接开始写 C# 代码结果预处理、后处理全按自己猜的来出来的 mask 一团糟。先把模型在算什么讲清楚后面所有代码才有依据。2.1 U2Net 的 RSU 模块为什么它能同时吃住整体轮廓和头发丝细节U2Net 的核心是 RSUReSidual U-block模块。传统分割模型靠一层层下采样扩大感受野但下采样会丢细节所以 U2Net 采用了类似 U-Net 的编码器-解码器结构让每一层都能融合多尺度特征。RSU 模块内部有一个小型的 U 形结构输入分辨率没有被粗暴压缩到 1/32而是保留了大尺寸特征图这就是它能在显著性检测上把边缘抠得干净的原因。对做抠图的工程人员来说不需要把论文全部啃完只需要记住三点第一U2Net 输出的是一个和输入分辨率相同的单通道概率图sigmoid 之后数值在 0 到 1 之间不是分类标签第二它直接在整图上做分割不需要像 Mask R-CNN 那样先出检测框第三它的推理输入是 320x320不管你原图是 1000 宽还是 5000 宽进模型前都要缩到 320。这三点直接决定了后面预处理和后处理的写法。2.2 选 u2net 还是 u2netp模型体积、推理速度和边缘精度的取舍官方开源了 u2net 和 u2netp 两个权重。u2net 是完整版参数量约 4470 万按 float32 存储ONNX 文件大约 170MB 出头u2netp 是轻量版参数量约 470 万ONNX 大约 19MB。两者的差异不是一点半点选错会在部署阶段很被动。对比项u2netu2netp参数量约 44.7M约 4.7MONNX 体积float32 估算约 170MB约 19MBCPU 推理单张耗时320x320约 180ms 到 400ms约 30ms 到 80ms边缘细节保留更好适合人像发丝、皮毛够用倒影和镂空处会糊典型场景精修人像、电商白底图批量粗抠、实时预览、上位机快速分类我一般建议先拿 u2netp 把流程跑通因为 C# 侧代码完全不用改只是换一个 ONNX 文件。如果测试图里发丝、透明物体多再换 u2net。实际项目里很多批量抠图需求用 u2netp 已经够了省下来的推理时间可以多跑几轮后处理。2.3 把 PyTorch 权重转成 ONNX抠图源码里最容易断的一环网上流传的 C# U2Net 源码通常包含一个 ONNX 模型文件但如果拿到的是 .pth 权重就得自己转。转换本身不难坑在于 U2Net 的 forward 返回的是多尺度的 d1 到 d7 七张特征图导出时只要 d1 那一张。另外官方训练用的是 BCEWithLogitsLoss所以模型直接输出的是 logits不是概率这一点要在导出时搞清楚后处理才能对应做 sigmoid。import torch from model.u2net import U2NET model U2NET(in_ch3, out_ch1) state torch.load(u2net.pth, map_locationcpu) model.load_state_dict(state[state_dict] if state_dict in state else state) model.eval() dummy torch.randn(1, 3, 320, 320) torch.onnx.export( model, dummy, u2net.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version12, ) print(export ok)这段代码里最关键的是dynamic_axes只把 batch 维设成动态高和宽保持固定的 320x320。这样导出和部署最简单也最稳。有些教程把宽高也设成动态C# 侧就能传任意尺寸图进去但推理速度反而会变慢而且个别 ONNX Runtime 版本对动态 H/W 的算子支持有差异容易在 DML 加速时翻车。老实固定 320预处理负责缩放是最省心的路线。opset_version 选 12 比较稳妥。选太高老的 ONNX Runtime 1.x 版本可能不认选太低部分算子导出报错。转换完可以用onnxruntime的 Python API 快速验证一次输出形状确认是[batch, 1, 320, 320]再拿到 C# 用。3. C# 端跑通 U2Net 抠图的最小工程ONNX Runtime 加载与预处理模型准备好之后C# 侧要解决三件事装对依赖、把图像转成模型要的 NCHW 张量、把输出张量变回 Mat。下面这段是我在项目里反复用的一套结构。3.1 项目依赖与 NuGet 包ONNX Runtime、OpenCvSharp 还是 ImageSharpC# 调 ONNX 模型官方推荐的是Microsoft.ML.OnnxRuntime。图像处理这块有两个流派OpenCvSharp4和ImageSharp。OpenCvSharp 封装的是原生 OpenCV速度快、API 熟悉但要注意 x64 发布和 VC 运行库ImageSharp 是纯托管跨平台方便但大图缩放的性能不如 OpenCV。我做桌面工具和上位机场景优先 OpenCvSharp4后处理里腐蚀、膨胀、高斯模糊这些形态学操作直接有现成函数。创建项目时目标平台选 x64csproj里加上包引用ItemGroup PackageReference IncludeMicrosoft.ML.OnnxRuntime Version1.16.3 / PackageReference IncludeOpenCvSharp4 Version4.8.0.20230708 / PackageReference IncludeOpenCvSharp4.runtime.win Version4.8.0.20230708 / /ItemGroup版本号按自己项目实际环境调整不用刻意追新。ONNX Runtime 的 NuGet 包会自动带入对应平台的原生 dllruntime.win那个包负责把 OpenCV 的原生库拷到输出目录。这里有个常见错误是只加了OpenCvSharp4没加runtime.win编译能过一跑就报找不到opencv_videoio_ffmpeg之类的 dll这个后面避坑章节会细说。3.2 图像预处理resize 到 320x320 之前必须做的三件事U2Net 训练时图像用的是 ImageNet 的归一化参数顺序是先缩放像素到 0 到 1再减均值除方差。同时 OpenCV 读进来的是 BGR模型要的是 RGB通道顺序必须在前处理里翻过来。另外直接把非正方形图缩成 320x320 会拉伸变形。public static DenseTensorfloat Preprocess(Mat src, out Mat resizedBgr) { const int size 320; var resized new Mat(); Cv2.Resize(src, resized, new Size(size, size)); resizedBgr resized.Clone(); var rgb new Mat(); Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB); rgb.ConvertTo(rgb, MatType.CV_32FC3, 1.0 / 255.0); float[] mean { 0.485f, 0.456f, 0.406f }; float[] std { 0.229f, 0.224f, 0.225f }; var data new float[1 * 3 * size * size]; for (int y 0; y size; y) { for (int x 0; x size; x) { var p rgb.AtVec3f(y, x); int idx y * size x; data[0 * size * size idx] (p[0] - mean[0]) / std[0]; data[1 * size * size idx] (p[1] - mean[1]) / std[1]; data[2 * size * size idx] (p[2] - mean[2]) / std[2]; } } return new DenseTensorfloat(data, new[] { 1, 3, size, size }); }逐像素循环是为了把 HWC 的 Mat 展开成 CHW 的平面数组再做归一化。这段性能不是最优但清晰跑 320x320 总耗时也就几毫秒不是瓶颈。resizedBgr记得 Clone 一份后面合并 mask 时要和输入尺寸严格对齐直接复用缩放后的图可以减少一次 Resize。注意rgb.ConvertTo用了1.0/255.0所以Vec3f已经落在 0 到 1 区间后面就不要重复除 255。3.3 推理与后处理从 output tensor 到可用的 alpha 通道推理代码非常短InferenceSession创建之后每次把预处理好的张量塞进去取第一个输出即可。但输出是 logits必须先过 sigmoid再缩回原图尺寸。很多人直接拿输出当 mask 用结果整张图都是灰色的就是因为漏了这一步。public static Mat InferMask(InferenceSession session, Mat src) { var input Preprocess(src, out Mat resizedBgr); using var results session.Run(new[] { NamedOnnxValue.CreateFromTensor(input, input) }); var output results.First().AsTensorfloat(); int h output.Dimensions[2]; int w output.Dimensions[3]; var mask new Mat(h, w, MatType.CV_32FC1); for (int y 0; y h; y) for (int x 0; x w; x) mask.Atfloat(y, x) Sigmoid(output[0, 0, y, x]); var resizedMask new Mat(); Cv2.Resize(mask, resizedMask, src.Size(), 0, 0, InterpolationFlags.Linear); resizedMask.ConvertTo(resizedMask, MatType.CV_8UC1, 255.0); return resizedMask; } private static float Sigmoid(float v) 1f / (1f (float)Math.Exp(-v));AsTensorfloat()返回的是多维张量索引顺序是 batch、channel、height、width一定不要写反。Cv2.Resize用InterpolationFlags.Linear边缘过渡自然用 Nearest 会出现明显锯齿这在客服发图场景里一眼就会被看出来。最后ConvertTo乘 255把浮点概率变成 0 到 255 的灰度图这张灰度图就是 alpha 通道的前身。4. 从 mask 到透明 PNGC# 侧后处理合成与批量抠图实现拿到一张灰度 mask 只是第一步真正交付的是透明背景 PNG 或者替换背景的结果图。这一步的质量取决于你对 mask 做的形态学处理和通道合成方式。4.1 从 mask 到 alpha阈值、羽化和腐蚀膨胀的取舍模型输出的灰度图里前景边缘往往有一些半透明过渡带。直接二值化会把过渡带一刀切边缘发硬完全不做阈值又会让背景残留白边。我的处理顺序是先做一次轻度的中值滤波或者高斯模糊去掉单像素噪点再用 Otsu 自动算阈值做二值化最后对二值图做一次腐蚀再膨胀开运算把背景里的零星亮点清掉。public static Mat CleanMask(Mat rawMask) { var blurred new Mat(); Cv2.GaussianBlur(rawMask, blurred, new Size(3, 3), 0); var binary new Mat(); Cv2.Threshold(blurred, binary, 0, 255, ThresholdTypes.Otsu); var kernel Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3)); var opened new Mat(); Cv2.MorphologyEx(binary, opened, MorphTypes.Open, kernel); // 边缘轻微羽化把二值图再模糊一次让头发丝过渡更自然 var feather new Mat(); Cv2.GaussianBlur(opened, feather, new Size(5, 5), 0); return feather; }注意 Otsu 的ThresholdTypes.Otsu要求输入是单通道 8 位图所以必须在上一章的灰度图基础上调用。腐蚀膨胀的核大小不要超过 5否则会把细小的发丝直接抹掉。最后那一次羽化模糊决定了透明边缘是生硬还是柔和人像抠图建议保留纯产品图可以省略。4.2 合成透明 PNGOpenCvSharp 的通道操作与 ImageSharp 的替代方案透明 PNG 的本质是 BGRA 四通道图alpha 通道放 mask。用 OpenCvSharp 的做法是先把原图转成 BGRA拆通道替换第 4 个通道再合并写盘。public static void SaveTransparentPng(Mat src, Mat mask, string outputPath) { var bgra new Mat(); Cv2.CvtColor(src, bgra, ColorConversionCodes.BGR2BGRA); Cv2.Split(bgra, out var channels); Mat maskResized new Mat(); Cv2.Resize(mask, maskResized, src.Size(), 0, 0, InterpolationFlags.Linear); channels[3] maskResized; Cv2.Merge(channels, bgra); Cv2.ImWrite(outputPath, bgra); }channels是Mat[]替换 alpha 通道前确认 mask 已经转成CV_8UC1否则Cv2.Merge会报类型不一致。如果目标环境不能依赖 OpenCV 原生库可以改用 ImageSharp把 mask 逐像素读出来构造Rgba32写进 PNG。纯托管方案在 Linux 容器里也跑得通但性能上限低批量处理几千张图时差距明显。4.3 批量抠图调度Task.Run 还是并行流注意线程安全批量处理的核心问题不是速度而是共享状态的线程安全。ONNX Runtime 的InferenceSession.Run是线程安全的多个线程可以并发调同一个 session但 OpenCvSharp 的Mat不是同一个 Mat 不能同时被多个线程读写。所以批量处理时每个任务内部要独立创建自己的 Mat 变量不要复用外层对象。public static void BatchProcess(string inputDir, string outputDir, string modelPath) { var session new InferenceSession(modelPath); var files Directory.GetFiles(inputDir, *.png) .Concat(Directory.GetFiles(inputDir, *.jpg)).ToArray(); Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism 4 }, file { try { using var src new Mat(file, ImreadModes.Color); var mask InferMask(session, src); var cleaned CleanMask(mask); var outPath Path.Combine(outputDir, Path.GetFileNameWithoutExtension(file) .png); SaveTransparentPng(src, cleaned, outPath); mask.Dispose(); cleaned.Dispose(); } catch (Exception ex) { Console.WriteLine(${file} 失败: {ex.Message}); } }); session.Dispose(); }MaxDegreeOfParallelism设在 4 左右比较合理。设太高CPU 推理和图像缩放相互抢线程吞吐量反而下降。每个循环里new Mat(file)读取的图是独立的mask 和 cleaned 用完要Dispose否则一批 1000 张图跑下来内存会被未释放的 Mat 撑爆这在 C# 里是非常典型的隐性内存泄漏。5. 抠图踩坑与排查C# 调用 U2Net 常见的 5 个问题记录这部分是血泪经验汇总。每一条都是真实场景里遇到过的问题按现象、原因、解决三步记录可以直接当排查手册用。5.1 现象调用原生库时抛 AccessViolation C0000005 直接崩溃程序一运行到InferenceSession.Run或者Cv2.Resize就崩事件查看器里是 C0000005。原因通常有两类一是 ONNX Runtime 输出目录缺少原生 dll但编译期不报错运行期加载失败导致非法访问二是 OpenCvSharp 的 Mat 被 GC 回收原生代码还在用野指针。排查时先把所有 NuGet 包的运行时 dll 确认拷全再检查有没有跨线程共用 Mat。解决启用项目 x64 平台确认Microsoft.ML.OnnxRuntime和OpenCvSharp4.runtime.win的 dll 都在输出目录代码里所有Run调用包在using里Mat 使用完毕立即释放。还崩的话用dotnet-dump抓崩溃现场看崩溃发生在哪个模块如果是onnxruntime.dll内部多半是模型与算子版本不兼容换 opset 12 重新导出。5.2 现象输出的 mask 是一张布满噪点的花屏图模型推理成功但保存出来的 mask 没有主体轮廓全是随机亮点。这种情况几乎都是预处理归一化参数不对。有人直接把原图的 0 到 255 像素值喂进模型U2Net 期望的是 ImageNet 归一化后的分布输入分布错了logits 全乱。解决严格按 3.2 的流程先归一化到 0 到 1再减均值[0.485, 0.456, 0.406]、除方差[0.229, 0.224, 0.225]。另外检查 BGR 转 RGB 是否做了通道顺序反了也会导致 mask 出现规则性错误比如主体区域是黑色、背景反而是白色。5.3 现象原图 4K 高清图抠出来的边缘有明显的锯齿和块状模型输入固定 320x3204K 图缩到 320 再放大回来边缘信息损失严重。如果直接对缩小的 mask 做膨胀或腐蚀锯齿会进一步放大。这不是 U2Net 的锅是后处理尺度的问题。解决在Cv2.Resize恢复原尺寸时用InterpolationFlags.Linear不要用 Nearest。另外可以把CleanMask里的高斯模糊核改成奇数值比如 5能有效柔化边缘。如果原图宽高比接近 1还可以考虑直接用 letterbox 填充而不是拉伸等会第 6 章会讲具体做法。5.4 现象PNG 透明背景里出现一圈偏色白边抠出来的人像边缘在深色背景下能明显看到一圈发白发灰的边。原因是模型输出的 mask 在前景边缘处是半透明过渡而原图的边缘像素本身混合了背景色直接作为 RGB 保留半透明叠加后就成了白边。解决两种思路。最简单的是把 mask 做一次腐蚀让边缘向内收缩 1 到 2 像素牺牲一点细节换干净效果进阶做法是对边缘像素做颜色去溢色处理把 RGB 朝前景主体颜色方向压缩。批量场景推荐先腐蚀性价比最高。5.5 现象连续处理几十张图后内存暴涨最后 OutOfMemory单个 Mat 不释放循环 100 次后内存就上去了。问题通常出在Preprocess里的resizedBgr和rgb没有释放以及InferMask内部的临时 Mat。C# 开发者习惯依赖 GC但 OpenCvSharp 的 Mat 封装的是非托管内存GC 不及时回收就会累积。解决给所有方法里的中间 Mat 包using var或者用using (var mat ...)。批量处理的最终版里我甚至在Preprocess返回前就Dispose掉rgb只保留必须的resizedBgr。写完代码后用dotnet-counters观察托管堆和非托管内存确认处理 1000 张图内存曲线平稳再交付。6. 进一步打磨GPU 加速、量化压缩与结果验证方法流程跑通以后剩下的都是工程优化问题。这一章说三个最关键的推理加速、模型瘦身、验证闭环。6.1 用 DirectML 还是 CUDA上位机场景的取舍桌面端加速有两条路。NVIDIA 显卡用Microsoft.ML.OnnxRuntime.Gpu能调用 CUDA 执行器性能最强但部署时要求目标机器装了对应版本的 CUDA 和 cuDNN运维成本高很多工厂和办公室电脑是核显或 A 卡直接没法用。另一条是Microsoft.ML.OnnxRuntime.DirectML走 DirectML 执行器N 卡 A 卡核显通吃部署时不用装 CUDA代码上只差一行var options new SessionOptions(); options.AppendExecutionProvider_DML(); var session new InferenceSession(modelPath, options);加了AppendExecutionProvider_DML后同一张 320x320 图u2net 的 CPU 400ms 能降到 100ms 以内。注意 DML 包和 CPU 包不能同时引用同一个版本直接换成Microsoft.ML.OnnxRuntime.DirectML即可。如果 DML 初始化失败代码要有降级逻辑捕获异常后用纯 CPU 的InferenceSession重试保证功能不中断。6.2 模型瘦身float16 转换和 u2netp 的边界把 u2net 的 ONNX 转成 float16体积能减半部分算子推理也更快但 ONNX Runtime 在 CPU 上对 float16 支持不稳定经常出现精度丢失导致 mask 出现空洞。我试过几次最后放弃了因为收益不大风险不小。更稳妥的做法是选 u2netp。在批量电商白底图场景u2netp 的边缘质量已经够用19MB 的模型随便拷贝部署。如果目标边缘精度必须达到 u2net 的水平就别折腾量化直接上完整模型配 DML 加速CPU 性能不够时加一张入门级显卡比花时间调试精度问题划算得多。6.3 验证方法掩码 IoU 与肉眼抽检的双重标准很多人调完参数只看一两张图就交付这是最危险的。我的习惯是准备一组固定测试集至少 20 张覆盖人像、毛绒玩具、玻璃杯、白底产品四种典型场景每张手动画一个基准掩码。然后写个小脚本计算模型输出和基准掩码的 IoU作为客观指标public static float ComputeIoU(Mat pred, Mat groundTruth) { Mat intersection new Mat(); Mat union new Mat(); Cv2.BitwiseAnd(pred, groundTruth, intersection); Cv2.BitwiseOr(pred, groundTruth, union); float inter Cv2.CountNonZero(intersection); float uni Cv2.CountNonZero(union); return uni 0 ? 0 : inter / uni; }IoU 能抓住整体退化但抓不住边缘观感。白边、锯齿、半透明残留这类问题只能靠肉眼抽检。正确的流程是每轮参数调整后先跑 IoU 确认没有断崖式下降再人工看 8 到 10 张代表图。两边都过了才往生产环境推。我现在每个新项目固定先建基准集模型不动、代码不动只看基准集指标变化来定版本预处理改一行都能及时发现回归。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网