ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# U2Net抠图实战:从ONNX模型导出到透明PNG输出

C# U2Net抠图实战:从ONNX模型导出到透明PNG输出 简介C# U2Net抠图源码是一套基于U-Net深度学习网络实现的图像分割项目面向希望在.NET环境中完成人像、前景物体或图像区域抠图的开发者和图像处理爱好者。网络采用对称的编码器-解码器结构编码端逐层下采样提取高层语义特征解码端上采样并融合低层细节最终输出像素级的分割掩码兼顾全局上下文与局部边缘。压缩包共81个文件包含9个C#源码文件、3个ONNX预训练模型u2net、u2netp、u2net_human_seg、Visual Studio解决方案与项目文件以及OpenCvSharp、ONNX Runtime等运行依赖整体大小约338.9MB。打开U2Net.sln可直接编译运行项目基于ONNX Runtime完成模型加载与前向推理借助OpenCvSharp处理图像读取、尺寸变换和颜色空间转换实现从输入图片到抠图结果的完整流程。目前已有1005人学习下载通过阅读和调试这套源码既能理解U-Net在图像分割中的关键设计也能掌握C#集成深度学习模型、调用OpenCV进行图像前后处理的具体工程实践适合兼顾算法学习与桌面应用开发的读者。1. C# U2Net 抠图真正的问题从来不在模型本身C# 做 U2Net 抠图第一反应是从 NuGet 拉一个库然后传入图片、输出 PNG。真正上手会发现 U2Net 是 PyTorch 生态里的显著目标检测模型C# 这边根本没有官方封装。绕开这条技术断层的方案不是自己训一个模型而是把训练好的 U2Net 权重转成 ONNX再借助 ONNX Runtime 在 C# 里加载推理整条链路才走得通。这个方案适合三类人做 WPF/WinForm 桌面工具的、在 C# 上位机里集成图像处理功能的以及需要批量产出透明底素材的素材处理脚本。它解决的是产品里“把前景从背景里抠出来”这一类需求不需要 GPU跨平台且推理代码量控制在几百行以内。接下来把模型导出、C# 侧加载、后处理合成和常见翻车点一条条捋清楚。2. 先把模型捞出来U2Net 导出 ONNX 的两种路径与选型2.1 U2Net 和 U2NetP 的差别直接决定你的推理耗时U2Net 全称是 U-Square Net靠双层嵌套的 U 型结构捕捉不同尺度的上下文信息RSU 模块Residual U-blocks让网络在不牺牲分辨率的前提下把感受野做得足够大所以显著物体边缘保留得比早期 U-Net 干净得多。官方给了两个常用权重完整版 u2net 和轻量版 u2netp。对比项U2NetU2NetP参数量约 44 MBpth 文件大小约 4.7 MB推理速度CPU单张 320×320 约 1.5~3 秒同样输入约 0.2~0.6 秒边缘精细度更好发丝和细小物体表现突出稍逊大体轮廓够用适合场景对质量有要求、不赶时间的离线批处理交互式抠图、C# 上位机实时管线我一般在 C# 工程里默认先用 U2NetP 跑通整条流程等确认推理、后处理都正确后再换成完整版压质量。因为换上完整模型只是换一个 .onnx 文件代码完全不用动这个时间差能让调试周期缩短好几倍。2.2 从 PyTorch 权重导出 ONNX导出脚本与输入输出节点官方仓库提供的是 .pth 权重C# 读不了得先转成 ONNX。常见做法是写一个 Python 导出脚本加载权重后调torch.onnx.export。注意导出时把输入尺寸固化到 320×320U2Net 原本就是在这个分辨率下训练的你的推理图片后续也要统一缩放到这个尺寸。import torch from model.u2net import U2NET net U2NET(3, 1) net.load_state_dict(torch.load(u2net.pth, map_locationcpu)) net.eval() dummy torch.randn(1, 3, 320, 320) torch.onnx.export( net, dummy, u2net.onnx, input_names[input], output_names[output], opset_version11, dynamic_axesNone, ) print(导出完成)这段脚本里有两个参数值得注意。opset_version设为 11 是为了兼容 ONNX Runtime 的老版本如果你的运行环境比较新设成 13 或更高也没问题但没必要追新。dynamic_axesNone把输入分辨率锁死成 320×320省掉动态尺寸在 C# 侧带来的 Tensor 形状判断。U2Net 本身是 Fully Convolutional 网络理论上可以接受任意分辨率输入但动态维度会让 OnnxRuntime 在某些 CPU 上触发额外的内存重排慢且容易出幺蛾子不建议为省一次 resize 惹这个麻烦。2.3 直接复用官方 ONNX 模型的另一个选择也有很多人直接下载别人已经导出的 u2net.onnx。这条路能省事但有个坑必须查清楚你拿到的 ONNX 输入输出节点名不一定叫input和output。U2Net 的网络有三个 SIDE 输出和一个 fuse 输出导出的模型里可能带output_1、output_2、output_3、output_fuse这类名字。拿到陌生模型时先做一次“体检”用 Python 读取节点信息再决定 C# 侧取哪个输出。import onnx model onnx.load(u2net.onnx) for inp in model.graph.input: print(输入:, inp.name) for out in model.graph.output: print(输出:, out.name)逻辑上直接取最后一个输出通常就是 fuse 后的结果但“通常”两个字不该靠赌。把节点名打印出来后C# 里用session.OutputNames对照一下最稳妥。没有真实依据不要乱猜作者导出的结构这一步花五分钟能避开后面一整天的排查。3. C# 侧推理管线搭建从 Session 到第一张 Mask3.1 NuGet 依赖与项目结构C# 侧只需要两个关键包Microsoft.ML.OnnxRuntime负责模型推理OpenCvSharp4负责图片读取和 resize。OpenCvSharp4 比 System.Drawing 好在原生支持 BGR 通道顺序和模型训练时的图片读取逻辑天然对齐。System.Drawing 用的是 RGB等会儿预处理里要额外多做一次通道翻转纯属给自己加活。项目结构按下面的方式组织后面扩展批处理、换背景都不会乱U2NetMatting/ ├── Models/ │ └── u2net.onnx ├── Services/ │ ├── U2NetInference.cs │ └── ImagePostProcessor.cs ├── Program.cs └── U2NetMatting.csprojU2NetInference只负责把图片转成 Tensor 并跑模型ImagePostProcessor负责把输出转成 Mask、合成透明 PNG。两个类职责分开后处理参数调起来不用反复动推理代码。3.2 初始化 Session 与输入 Tensor 的处理OnnxRuntime 的InferenceSession是线程安全的整个程序生命周期里只需要初始化一次。输入图片用 OpenCvSharp 读取后做 BGR 转 RGB、resize 到 320×320、转 float、除以 255、按 ImageNet 的 mean/std 做归一化最后排成 NCHW 布局。using OpenCvSharp; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class U2NetInference { private readonly InferenceSession _session; private readonly float[] _mean { 0.485f, 0.456f, 0.406f }; private readonly float[] _std { 0.229f, 0.224f, 0.225f }; public U2NetInference(string modelPath) { _session new InferenceSession(modelPath); } private DenseTensorfloat Preprocess(Mat image) { using var rgb new Mat(); Cv2.CvtColor(image, rgb, ColorConversionCodes.BGR2RGB); using var resized new Mat(); Cv2.Resize(rgb, resized, new Size(320, 320)); var tensor new DenseTensorfloat(new[] { 1, 3, 320, 320 }); for (int y 0; y 320; y) { for (int x 0; x 320; x) { Vec3b pixel resized.AtVec3b(y, x); tensor[0, 0, y, x] (pixel.Item0 / 255f - _mean[0]) / _std[0]; tensor[0, 1, y, x] (pixel.Item1 / 255f - _mean[1]) / _std[1]; tensor[0, 2, y, x] (pixel.Item2 / 255f - _mean[2]) / _std[2]; } } return tensor; } }Preprocess里每行代码都有讲究。ColorConversionCodes.BGR2RGB对应训练时 PyTorch 用 PIL 读图的通道顺序不做这一步最终 Mask 的内容和原图位置会对不上。resized用默认的双线性插值即可U2Net 对缩放大小的敏感度远低于对归一化参数的敏感度。归一化的均值方差必须是 ImageNet 那组数据换错一个参数输出 Mask 的对比度会严重下降大概率拿到一张灰蒙蒙的图。3.3 执行推理并拿到输出推理调用本身极简session.Run传入输入 Tensor输出是一个DisposableNamedOnnxValue集合。U2Net 的原始输出是 1×1×320×320 的 float 特征图范围不一定在 0 到 1 之间需要先做 Sigmoid 压缩到 [0,1] 区间才能当 Alpha 通道用。public Mat Infer(Mat image) { DenseTensorfloat inputTensor Preprocess(image); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, inputTensor) }; using var results _session.Run(inputs); var output results.Last().AsTensorfloat(); var mask new Mat(320, 320, MatType.CV_32FC1); for (int y 0; y 320; y) { for (int x 0; x 320; x) { float val output[0, 0, y, x]; mask.Atfloat(y, x) 1f / (1f (float)Math.Exp(-val)); } } return mask; }取results.Last()是基于 U2Net 导出时 fuse 输出排在最后一个的真实结构如果你的 ONNX 是从别处拿到的换成显式按输出名取更稳。Sigmoid 这里用1 / (1 exp(-val))手写比调MathNET.Numerics之类的库快得多也免掉一个依赖。这一步拿到的是 320×320 的单通道 float Mask还没法直接交付下一章做尺寸恢复和透明合成。4. 让 Mask 变成能交付的抠图后处理、合成与关键参数4.1 Sigmoid 与阈值为什么输出全看着像灰的模型输出的 Mask 经过 Sigmoid 后落在 0 到 1 之间但像素值集中在 0.1 到 0.9 这种中间区间直接拿去当透明度会得到半透明的前景边缘更是糊成一片。U2Net 的原始输出里前景区域通常高于 0.5背景低于 0.3真正需要纠结的只有边界那一圈。处理灰的关键是二值化阈值和 Alpha 的映射方式。如果只需透明 PNG不用着急二值化Alpha 通道直接用 float 值会让边缘更平滑。如果后续要做替换背景才把 Mask 按阈值截断public Mat ThresholdMask(Mat mask, float threshold 0.5f) { Mat binary new Mat(); Cv2.Threshold(mask, binary, threshold, 1.0, ThresholdTypes.Binary); return binary; }threshold选 0.5 适用于绝大多数室内外照片但光照复杂、前景和背景颜色相近时干脆把阈值降到 0.3 或者提高到 0.7看边缘毛刺表现再定。C# 里调起来就是改一个参数重新跑一遍代价很低。这个“玄学”调参过程很快建议把阈值暴露成公开属性方便 WPF 界面上拿 Slider 实时拖。4.2 把 Mask 放大回原图尺寸以及双线性插值的边界模型输入是 320×320原图可能是 4000×3000Mask 必须放大回原图分辨率才能和原图做像素级对齐。放大用INTER_LINEAR就够了用INTER_AREA会得到锯齿边缘用INTER_CUBIC更慢且对 Alpha 图没有任何正向收益。public Mat ResizeMaskToOriginal(Mat mask, Size originalSize) { Mat resized new Mat(); Cv2.Resize(mask, resized, originalSize, 0, 0, InterpolationFlags.Linear); return resized; }放大后 Mask 的值域不会被插值破坏依然落在 0 到 1 之间可以放心当 Alpha 用。边界上会出现半透明的过渡像素这是双线性插值的自然结果如果你要的是硬边先二值化再放大但发丝这类细结构会被二值化直接吃掉考虑到 U2NetP 本身对发丝就一般追求细节的话这步别省。4.3 合成透明 PNG用 GDI 还是 OpenCvSharp 的问题合成透明 PNG 是个经典的坑。System.Drawing 的Bitmap默认格式不带 Alpha你需要显式指定PixelFormat.Format32bppArgb。但更麻烦的是 GDI 对 PNG 的透明通道处理不如 OpenCvSharp 直接。这里用 OpenCvSharp 写回 BGRA 四通道原样保留前景颜色只替换 Alphapublic Mat Compose(Mat original, Mat mask) { Mat originalBgra new Mat(); Cv2.CvtColor(original, originalBgra, ColorConversionCodes.BGR2BGRA); Mat maskFloat new Mat(); mask.ConvertTo(maskFloat, MatType.CV_32FC1, 1.0 / 255.0); Mat[] channels Cv2.Split(originalBgra); channels[3] maskFloat; Cv2.Merge(channels, originalBgra); return originalBgra; }这段代码里ConvertTo的缩放系数要根据你的 Mask 值域来定。如果 Mask 是 0~255 的 CV_8UC1就除以 255 转成 0~1如果已经是 0~1 的 CV_32FC1直接复制进 Alpha 通道千万别再缩放一次。保存时Cv2.ImWrite(result.png, originalBgra)会自动编码 PNG 的透明通道比 GDI 少了那种“保存以后透明区变成黑色”的经典事故。5. U2Net 抠图排坑实录5 个让我翻过车的细节5.1 推理时偶发 Access Violation c0000005现象是 C# 程序跑一段时间后崩溃异常码c0000005Access Violation调用栈指向 OnnxRuntime 原生库内部。第一次遇到我还以为是 NuGet 包版本问题反复升降版本没用。原因出在DisposableNamedOnnxValue和Mat的释放顺序上。ONNX Runtime 输出 Tensor 的内存和原生推理引擎绑定你把输出 Tensor 转成 C# 数组后立刻释放了results但后续还在用mask原生内存已经失效。解决方法是把推理结果完整拷贝到托管数组后再 Dispose。using var results _session.Run(inputs); var output results.Last().AsTensorfloat(); float[] data output.ToArray(); // 之后只用 data不再触碰 results逻辑上ToArray()会在托管堆里复制一份彻底和原生内存脱离关系。那之后using块结束释放原生资源C# 侧照样安全访问数据。加上这段后 Access Violation 再也没有出现过。5.2 输出 Mask 全黑或全白先查归一化现象是 Mask 图像一眼看去要么全黑要么除了边缘一圈全白。原因几乎都是 Preprocess 里的归一化参数错误。有些人把 mean/std 照抄成0, 1忽略了数据预处理模型特征分布和推理时不一致Sigmoid 之后直接饱和。解决方法是把归一化参数严格设成 ImageNet 的标准值并且确认输入 Tensor 的通道顺序是 RGB。调试时打印一行中间值看归一化后的数据是否落在[-2, 2]区间不在这个范围就是预处理哪里算错了。5.3 尺寸对不上固定 320 输入引发的连锁反应现象是程序报错说期望的维度是[1,3,320,320]实际给的不是。通常会发生在读者用别人的代码时把Preprocess里的new Size(320, 320)改成了原图大小想“省一次缩放”。U2Net 导出时dynamic_axes是 None模型编译后输入张量形状是死的不是你想传什么就传什么。想省时间应该做的是把Preprocess和推理拆开只对输入图缩放输出 Mask 再放大回原尺寸。让模型直接处理原图是走不通的除非你重新用dynamic_axes导出但动态维度带来的性能损耗通常不值得。5.4 推理慢到没法用模型选型比线程池调优管用现象是普通 CPU 上跑一张图要 3~5 秒如果做成交互式工具体验基本报废。多数人第一反应是多线程或换 GPU但真实瓶颈是模型本身。U2Net 完整版在 C# 侧没有任何魔法可以加速最好的办法是换 U2NetP体积小十倍速度提升近十倍。另一个实践是我把SessionOptions的EnableMemoryPattern设为 false配合ExecutionMode.ORT_SEQUENTIAL减少内存分配抖动在老旧 CPU 上有 20% 左右的提升。var options new SessionOptions(); options.EnableMemoryPattern false; options.ExecutionMode ExecutionMode.ORT_SEQUENTIAL; _session new InferenceSession(modelPath, options);EnableMemoryPattern false让每次推理重新规划内存而不是复用缓存听着像退步但在输入尺寸固定的场景下反而避免了一些内存对齐带来的额外拷贝。如果你是在服务器上跑可以换ORT_PARALLEL在多核机器上确实能榨出一点并发收益。5.5 OpenCvSharp 和 OnnxRuntime 的 DLL 冲突现象是代码编译通过运行时报DllNotFoundException或者两个库都加载成功后其中一个调用时崩溃。原因在于 OpenCvSharp4 和 OpenCvSharp4.runtime.win 的版本要严格一致以及 OnnxRuntime 的原生 DLL 依赖 VC Redistributable。解决方法是 NuGet 里安装OpenCvSharp4同时装OpenCvSharp4.runtime.win两个包的版本号必须完全相同。另外确保目标机器装了 VC 2015-2022 RedistributableU2Net 全套跑起来需要的原生依赖就这两个排查起来不绕。6. 让抠图管线可验证、可批量最后一段进阶级代码6.1 复用 Session 做多线程批处理InferenceSession.Run是线程安全的可以多个线程同时调用。批量抠图时按 CPU 核心数开线程池每个线程独立处理一张图共享同一个 Session不会有锁竞争。Parallel.For(0, imagePaths.Length, new ParallelOptions { MaxDegreeOfParallelism 4 }, i { using var img Cv2.ImRead(imagePaths[i]); using var mask _inference.Infer(img); using var result _postProcessor.Compose(img, mask); Cv2.ImWrite(Path.Combine(outputDir, Path.GetFileName(imagePaths[i])), result); });MaxDegreeOfParallelism按 CPU 物理核心数来定超线程环境下设为物理核心数而不是逻辑线程数。设太高会导致 CPU 上下文切换开销吃掉并行收益这个参数我在 8 核机器上试过设 4 比设 8 更稳。6.2 边缘羽化与替换背景透明 PNG 输出只是第一步视频会议、直播场景需要抠图后替换背景。直接在合成背景时给 Alpha 加一个羽化半径避免替换后的边缘出现硬切感。用高斯模糊处理 Alpha半径 1~3 个像素足够public Mat FeatherMask(Mat mask, int radius 2) { Mat blurred new Mat(); Cv2.GaussianBlur(mask, blurred, new Size(radius * 2 1, radius * 2 1), 0); return blurred; }GaussianBlur的核大小必须是奇数radius取 2 就是 5×5 的核。羽化半径不要超过 3否则人物边缘会出现一圈半透明光晕和你想要的效果完全相反。背景替换时把原图 Alpha 放上面新背景放下面用AddWeighted按 Alpha 权重混合比逐像素操作快一个数量级。6.3 验证方法用 IoU 对比你的输出和人工抠图一个抠图管线敢不敢上线得有一组能说服自己的数据。准备 10 张典型图片用 GIMP 或 PS 手动抠出参考 Mask不用太精细大概轮廓即可和 U2Net 输出算交并比。这里可以借用 Python 的 skimage 指标也可以直接用 C# 手写public double ComputeIoU(Mat maskA, Mat maskB) { Mat inter new Mat(); Mat union new Mat(); Cv2.BitwiseAnd(maskA, maskB, inter); Cv2.BitwiseOr(maskA, maskB, union); double interCount Cv2.CountNonZero(inter); double unionCount Cv2.CountNonZero(union); return interCount / unionCount; }IoU 高于 0.85 的图片说明模型输出和人工结果高度一致0.7~0.85 之间说明边缘有一定差异但可接受低于 0.7 就得回头检查归一化、阈值或者模型选型。每批次调完参数跑一遍这组测试图别靠肉眼感觉判断质量数据不会骗人。做到这一步一套 C# U2Net 抠图管线已经脱离“能跑”的阶段进入“能交付”的状态。我做了这么多图像处理相关的工程最后养成的习惯是改任何一个参数都必须跑一遍 IoU 对比否则永远不知道上一次调整是变好还是变坏。希望这套从模型导出到验证收尾的流程对你有所帮助。本文还有配套的精品资源点击获取
返回列表