简介: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。
| 对比项 | U2Net | U2NetP |
|---|---|---|
| 参数量 | 约 44 MB(pth 文件大小) | 约 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×320,U2Net 原本就是在这个分辨率下训练的,你的推理图片后续也要统一缩放到这个尺寸。
import torch from model.u2net import U2NET net = U2NET(3, 1) net.load_state_dict(torch.load("u2net.pth", map_location="cpu")) net.eval() dummy = torch.randn(1, 3, 320, 320) torch.onnx.export( net, dummy, "u2net.onnx", input_names=["input"], output_names=["output"], opset_version=11, dynamic_axes=None, ) print("导出完成")这段脚本里有两个参数值得注意。opset_version设为 11 是为了兼容 ONNX Runtime 的老版本,如果你的运行环境比较新,设成 13 或更高也没问题,但没必要追新。dynamic_axes=None把输入分辨率锁死成 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 到第一张 Mask
3.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 DenseTensor<float> 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 DenseTensor<float>(new[] { 1, 3, 320, 320 }); for (int y = 0; y < 320; y++) { for (int x = 0; x < 320; x++) { Vec3b pixel = resized.At<Vec3b>(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) { DenseTensor<float> inputTensor = Preprocess(image); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", inputTensor) }; using var results = _session.Run(inputs); var output = results.Last().AsTensor<float>(); 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.At<float>(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×3000,Mask 必须放大回原图分辨率才能和原图做像素级对齐。放大用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 四通道,原样保留前景颜色,只替换 Alpha:
public 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# 程序跑一段时间后崩溃,异常码c0000005(Access Violation),调用栈指向 OnnxRuntime 原生库内部。第一次遇到我还以为是 NuGet 包版本问题,反复升降版本没用。原因出在DisposableNamedOnnxValue和Mat的释放顺序上。ONNX Runtime 输出 Tensor 的内存和原生推理引擎绑定,你把输出 Tensor 转成 C# 数组后立刻释放了results,但后续还在用mask,原生内存已经失效。解决方法是把推理结果完整拷贝到托管数组后再 Dispose。
using var results = _session.Run(inputs); var output = results.Last().AsTensor<float>(); 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 Redistributable,U2Net 全套跑起来需要的原生依赖就这两个,排查起来不绕。
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 对比,否则永远不知道上一次调整是变好还是变坏。希望这套从模型导出到验证收尾的流程对你有所帮助。
本文还有配套的精品资源,点击获取