2026/8/31 22:54:05

C#集成U2Net和ONNX Runtime实现本地一键抠图

C#集成U2Net和ONNX Runtime实现本地一键抠图 简介本资源是一套基于C#实现U2Net图像抠图的完整开源项目面向.NET开发者与计算机视觉初学者解决在Windows平台下利用深度学习模型进行高精度人像/物体分割的实际需求。压缩包含81个文件总计338.9MB涵盖Visual Studio解决方案U2Net.sln、核心C#源码9个.cs文件、ONNX预训练模型u2net.onnx等3个、运行依赖库11个dll含onnxruntime、OpenCvSharp等、配置文件及资源文件结构清晰开箱即用。已有1000人学习下载配套博文详细解析环境配置、模型加载、图像预处理、推理预测与后处理全流程。读者可直接编译运行exe程序体验实时抠图效果深入理解U-Net编码器-解码器结构在C#中的工程化落地并复用代码集成至自有图像处理系统或桌面应用。 最近在做一个图像处理的小工具需要给用户提供一键抠图的能力。之前团队里一直用PS手动抠效率实在感人后来调研了一圈发现U2Net这个显著性检测模型在抠图任务上表现相当能打就决定把它集成到C#桌面程序里。这篇文章就分享一下整个调研、部署和调优的过程希望对想在.NET生态里用上深度学习抠图能力的朋友有点帮助。1. 为什么选U2Net而不是抠图SDK或传统色键先说结论如果只是想在C#里快速实现抠图U2Net是目前综合成本最低的方案没有之一。它不需要GPU服务器不依赖外部API离线可用模型文件也不大边缘质量在开源方案里属于第一梯队。1.1 传统抠图方案的三大痛点在决定用U2Net之前我其实试过几条路各有各的坑。第一类是色键抠图也就是绿幕/蓝幕方案。这个方案要求拍摄环境必须可控背景是纯色否则边缘会带着一圈诡异的色边。我们的使用场景是用户随手拍一张照片上传背景五花八门根本没法用。第二类是传统图像分割算法比如GrabCut。OpenCV里就有现成实现但GrabCut需要用户交互得手动框一个前景区域而且对边缘毛发、透明物体这些情况基本束手无策。自动化程度太低不符合一键抠图的产品需求。第三类是调用在线抠图API。效果确实好但问题是图片要上传到第三方服务器涉及隐私合规而且每次调用都要花钱还得处理网络异常、并发配额这些问题。对一个桌面工具来说这体验太割裂了。U2Net的优势就在于它是一个完全本地运行的深度学习模型输入一张图直接输出对应的显著性区域蒙版不需要任何用户交互也没有环境限制。1.2 U2Net这个模型到底做了什么U2Net的全称是U-Square Net2020年发表在CVPR上作者是Xuebin Qin等人。它的核心设计是嵌套的U型结构——U-Net大家比较熟悉U2Net相当于在U-Net的每个stage内部再套一层小的U型结构所以叫U-Square。这种设计带来的直接好处是模型可以在不加深整体网络太夸张的情况下同时捕捉多尺度特征。简单说它能同时看到物体的整体轮廓和细节边缘这两者对抠图来说都至关重要。整体轮廓决定了哪些像素属于前景细节边缘决定了前景和背景的交界处处理得干不干净。U2Net训练在DUTS数据集上这个数据集有10553张训练图像覆盖了大量真实场景。模型输出的不是硬性的0/1分割图而是一张概率图每个像素的值在0到1之间表示该像素属于前景的概率。这个特性很重要后面做Alpha抠图的时候会用到。1.3 为什么可以用ONNX Runtime跑在CPU上U2Net原版是PyTorch实现的但直接让C#去调PyTorch模型很别扭。这里有一个成熟的方案把PyTorch模型导出为ONNX格式然后在C#里用ONNX Runtime加载推理。ONNX Runtime是微软开源的跨平台推理引擎对PyTorch导出的模型兼容性极好支持GPUCUDA、DirectML也支持纯CPU。U2Net的模型结构不算特别大完整版大概170MB左右还有一个轻量版U2NetP约4.7MB压缩后甚至不到1MB。在纯CPU上推理一张512x512的图用完整版大概几百毫秒用轻量版只要几十毫秒这个性能完全能满足桌面工具的需求。2. 环境准备与模型导出最容易踩坑的开局2.1 C#项目的基础依赖我用的是.NET 8Windows桌面场景UI框架选了WPF。项目需要三个NuGet包PackageReference IncludeMicrosoft.ML.OnnxRuntime Version1.18.0 / PackageReference IncludeOpenCvSharp4 Version4.9.0.20240103 / PackageReference IncludeOpenCvSharp4.runtime.win Version4.9.0.20240103 /ONNX Runtime负责跑模型OpenCvSharp负责图像解码、缩放、色彩空间转换这些预处理以及后处理。为什么不直接用WPF自带的Bitmap处理因为它对像素级操作的支持太弱了而且性能拉胯OpenCV在这块是碾压级的优势。提示OpenCvSharp4.runtime.win这个包必须在运行时随程序集一起部署它包含了OpenCV的原生DLL少了它初始化时会直接抛DllNotFoundException。另外OpenCvSharp4和OpenCvSharp4.runtime.win的版本号必须完全一致否则会出现奇怪的运行时错误。2.2 模型获取优先用官方导出的ONNX把PyTorch模型转成ONNX本身不是特别难但你没必要自己动手。U2Net作者官方仓库里就提供了转换脚本而且社区里已经有人把转换好的ONNX模型传到HuggingFace上了直接下载就能用。我使用的是ONNX版本完整版路径是u2net.onnx轻量版是u2netp.onnx。两个模型输入输出格式完全一致只是网络深度和参数量不同。如果你对硬件要求不高建议直接上完整版边缘质量确实好一个档次。如果你确实想自己转换核心代码如下import torch from model import U2NET model U2NET(3, 1) model.load_state_dict(torch.load(u2net.pth, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 320, 320) torch.onnx.export(model, dummy_input, u2net.onnx, input_names[input], output_names[output], opset_version11, dynamic_axes{input: {0: batch, 2: height, 3: width}, output: {0: batch, 2: height, 3: width}})注意这里设置了dynamic_axes允许输入输出尺寸动态变化。但实际用的时候我建议还是固定尺寸原因在后面预处理部分说明。2.3 ONNX Runtime初始化与模型加载C#端的模型加载代码很简洁using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class U2NetMatting { private InferenceSession _session; public U2NetMatting(string modelPath) { var options new SessionOptions { GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL, ExecutionMode ExecutionMode.ORT_SEQUENTIAL }; options.AppendExecutionProvider_CPU(); _session new InferenceSession(modelPath, options); } }GraphOptimizationLevel设置成ORT_ENABLE_ALLONNX Runtime会自动对计算图做各种优化比如算子融合、常量折叠、冗余节点消除这些优化在CPU上能带来20%到40%的提速。ExecutionMode默认是顺序执行不要轻易改成并行模式U2Net这种串行网络改成并行反而可能变慢。3. 预处理流水线抠图质量的第一道分水岭图像进入模型之前必须经过一整套预处理流程。这套流程的每个细节都直接影响最终抠图质量尤其是边缘的平整度。3.1 尺寸调整Resize的隐藏陷阱U2Net官方实现中训练时会把输入图像Resize到320x320。但这个Resize不是简简单单把宽高拉成320就行而是先保持长宽比缩放然后做边缘填充凑成正方形。具体流程是把图像的长边缩放到320短边按相同比例缩放然后在短边两侧填充像素值0补到320宽度。填充值用的是黑色但对结果影响不大因为模型最终只对中心区域做出有效预测填充区会被忽略。public static Mat ResizeWithPadding(Mat src, int targetSize 320) { int w src.Width; int h src.Height; float scale Math.Min((float)targetSize / w, (float)targetSize / h); int newW (int)Math.Round(w * scale); int newH (int)Math.Round(h * scale); var resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Area); var canvas new Mat(targetSize, targetSize, MatType.CV_8UC3, Scalar.All(0)); int offsetX (targetSize - newW) / 2; int offsetY (targetSize - newH) / 2; resized.CopyTo(new Mat(canvas, new Rect(offsetX, offsetY, newW, newH))); resized.Dispose(); return canvas; }这里有个值得注意的细节缩小时Cv2.Resize的插值算法推荐用InterpolationFlags.Area也就是像素面积关系重采样。它对缩小的图像效果比默认的双线性插值更平滑边缘锯齿更少。而如果是放大则用双线性或双三次。3.2 通道顺序BGR和RGB的经典翻车现场OpenCV读入的图片默认是BGR通道顺序但PyTorch模型训练时用的是RGB顺序。如果直接把BGR图喂给模型出来的蒙版会错得非常离谱——部分场景甚至整张图全黑。正确做法是先把BGR转成RGBvar rgb new Mat(); Cv2.CvtColor(padded, rgb, ColorConversionCodes.BGR2RGB);转换成RGB之后还要记得把数据从HWC布局转成CHW布局也就是从高x宽x通道变成通道x高x宽。ONNX Runtime的Tensor要求输入是NCHW格式。这个转换用OpenCV的Split加Merge或者直接遍历像素都行但性能最优的做法是先把Mat的数据接出来然后用指针内存拷贝。3.3 归一化均值方差的数学原理U2Net官方预处理中图像的归一化参数是mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]。这是ImageNet数据集的全局统计值U2Net在训练时用了这些参数做数据增强。归一化公式是(pixel / 255 - mean) / std。这里有两个容易踩的坑。第一必须先把像素值从0到255缩放到0到1再做减均值除方差顺序不能反。第二这个缩放不是线性缩放那么简单它对三个通道分别用了不同的均值和方差本质是把图像在颜色空间里做了一次白化让三个通道的分布更接近标准正态分布有助于模型更稳定地收敛和推理。C#端实现public static float[] NormalizeToTensor(Mat rgbMat, int targetSize 320) { var tensorData new float[3 * targetSize * targetSize]; int index 0; float[] mean { 0.485f, 0.456f, 0.406f }; float[] std { 0.229f, 0.224f, 0.225f }; unsafe { // 使用指针遍历像素比GetIndex逐像素快一个数量级 for (int c 0; c 3; c) { for (int y 0; y targetSize; y) { byte* rowPtr (byte*)rgbMat.DataPointer y * rgbMat.Step; for (int x 0; x targetSize; x) { byte pixelValue rowPtr[x * 3 c]; tensorData[index] (pixelValue / 255f - mean[c]) / std[c]; } } } } return tensorData; }注意这里用了unsafe指针遍历性能比Mat.GetArray快很多。WPF上位机如果处理大图这种级别的优化是必须的否则每张图几百毫秒的预处理延迟会让用户体验很糟糕。3.4 固定尺寸与动态尺寸的取舍前面提到ONNX导出时我设置了dynamic_axes理论上可以输入任意尺寸。但实测下来U2Net对输入尺寸不太敏感但固定到320x320的效率最优。原因有两方面第一模型训练时特征图尺寸是固定的推理时虽然可以任意尺寸但尺寸变化会引入插值误差边缘质量会略有下降。第二ONNX Runtime对固定形状输入可以做更激进的内存规划和算子优化动态形状会跳过这些优化。所以实际项目中我统一把所有图片Resize到320x320效果和性能最平衡。4. 推理与后处理从概率图到透明PNG4.1 构造输入Tensor并执行推理ONNX Runtime的输入Tensor需要包装成DenseTensorfloat维度是[1, 3, 320, 320]。public Mat Predict(Mat srcBgr) { using var padded ResizeWithPadding(srcBgr, 320); using var rgb new Mat(); Cv2.CvtColor(padded, rgb, ColorConversionCodes.BGR2RGB); float[] inputData NormalizeToTensor(rgb, 320); var inputTensor new DenseTensorfloat(inputData, new[] { 1, 3, 320, 320 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, inputTensor) }; using var results _session.Run(inputs); var output results.First().AsTensorfloat(); // output维度是 [1, 1, 320, 320] }NamedOnnxValue.CreateFromTensor的第一个参数必须跟模型输入名严格一致。如果你不确定输入名是什么可以用_session.InputMetadata来查foreach (var (key, value) in _session.InputMetadata) { Console.WriteLine($Input: {key}, shape: {string.Join(,, value.Dimensions)}); }输出名同理可以通过_session.OutputMetadata查询。我用的模型输入名是input输出名是output但不同的ONNX导出源命名可能不同写代码时最好做一次动态查询不要硬编码。4.2 从输出张量到Alpha蒙版U2Net的输出是一个[1, 1, 320, 320]的浮点张量每个值经过Sigmoid激活后落在0到1之间。官方实现里模型本身的最后并没有显式做Sigmoid而是在后处理里做的。在C#里提取输出并做Sigmoidpublic Mat GetAlphaMask(Tensorfloat output, int targetSize 320) { var alpha new Mat(targetSize, targetSize, MatType.CV_32FC1, Scalar.All(0)); unsafe { for (int y 0; y targetSize; y) { float* rowPtr (float*)alpha.DataPointer y * alpha.Step / sizeof(float); for (int x 0; x targetSize; x) { float value output[0, 0, y, x]; rowPtr[x] 1.0f / (1.0f (float)Math.Exp(-value)); } } } return alpha; }然后把这个320x320的浮点蒙版缩放到原图尺寸用InterpolationFlags.Linear双线性插值。4.3 阈值处理与柔边优化Sigmoid输出是概率值直接当Alpha用的话背景区域概率接近0和前景区域概率接近1之间会有一个渐变过渡带这正是我们想要的因为自然图像的边缘本来就是渐变的。但如果直接用它做Alpha你会发现半透明区域特别多看起来像一层薄雾。需要一个后处理步骤来收紧边缘。我实测下来的最佳方案是对Alpha做一次高斯模糊半径1到2像素然后用一个阈值做重映射。阈值设在0.3到0.4之间。小于阈值的调为0大于阈值的按照公式(alpha - threshold) / (1 - threshold)线性拉伸。这样做的好处是既保留了边缘的柔和过渡又去掉了那些概率较低、容易产生鬼影的模糊像素。public static Mat PostProcessAlpha(Mat alpha, float threshold 0.35f) { var blurred new Mat(); Cv2.GaussianBlur(alpha, blurred, new Size(3, 3), 0); var result new Mat(); Cv2.Threshold(blurred, result, threshold, 1.0, ThresholdTypes.Tozero); // 线性拉伸 Cv2.Divide(result, new Scalar(1.0 - threshold), result); return result; }4.4 合成透明PNG把蒙版应用回原图有了Alpha蒙版最后一步就是把原图和蒙版合成透明PNG。这一步有两种做法直接把Alpha通道合并到原图的BGRA通道里或者用蒙版对前景做颜色校正去色边。最简单的做法public static void SaveTransparentPng(Mat srcBgr, Mat alpha, string outputPath) { var alpha8U new Mat(); alpha.ConvertTo(alpha8U, MatType.CV_8UC1, 255, 0); var bgra new Mat(); Cv2.CvtColor(srcBgr, bgra, ColorConversionCodes.BGR2BGRA); // 把alpha通道写入BGRA的第4通道 var channels Cv2.Split(bgra); channels[3] alpha8U; Cv2.Merge(channels, bgra); Cv2.ImWrite(outputPath, bgra); foreach (var ch in channels) ch.Dispose(); bgra.Dispose(); alpha8U.Dispose(); }如果你追求更好的效果还需要做一步去色边Color Decontamination。因为物体边缘的像素混合了背景色直接保留原色会导致抠出来的图边缘有一圈背景色晕边。常用的方法是用Alpha蒙版做前景颜色的重新估计// 简化版对边缘区域做颜色与背景色的线性混合补偿这一步在U2Net抠图后非常有价值尤其是原图背景颜色鲜艳的时候。5. 工程化落地性能优化与踩坑记录5.1 CPU推理性能实测我用一个i5-1240P处理器的笔记本做了基准测试输入一张1920x1080的照片阶段耗时毫秒图像解码 Resize 填充25~35BGR转RGB 归一化15~20ONNX推理350~450Sigmoid 阈值 缩放10~15合成透明PNG5~15总计400~550完整版U2Net在CPU上的推理时间大约400毫秒作为桌面工具完全可以接受。如果换成U2NetP轻量版推理时间能压到80毫秒以内效果略打折扣但速度很可观。5.2 内存管理与Dispose陷阱ONNX Runtime的InferenceSession是线程安全的可以多线程并发调用不需要为每张图重建Session。但每次Session.Run返回的DisposableNamedOnnxValue集合必须及时释放否则会内存泄漏。我一开始没注意这个问题连续处理200多张图后内存飙升到2GB。排查后发现是results对象没有Dispose。正确写法是用usingusing (var results _session.Run(inputs)) { var output results.First().AsTensorfloat(); // 这里要注意output只是视图不能长时间持有 }如果你需要把输出Tensor保留到方法返回之后先ToArray()拷贝一份不要直接引用。5.3 OpenCvSharp和WPF的互操作WPF的BitmapSource和OpenCV的Mat之间的转换是个高频需求。我封装了两个函数public static BitmapSource MatToBitmapSource(Mat mat) { using var bitmap mat.ToBitmap(); // OpenCvSharp自带转换 var hBitmap bitmap.GetHbitmap(); try { return Imaging.CreateBitmapSourceFromHBitmap(hBitmap, IntPtr.Zero, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions()); } finally { DeleteObject(hBitmap); } } public static Mat BitmapSourceToMat(BitmapSource source) { var encoder new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(source)); using var stream new MemoryStream(); encoder.Save(stream); stream.Position 0; return Cv2.ImRead(stream.ToArray(), ImreadModes.Color); }注意GetHbitmap出来的句柄用完后必须DeleteObject释放否则GDI句柄泄漏程序跑几个小时就会开始出现渲染异常。5.4 一个容易被忽略的坑多线程UI卡顿ONNX推理本身是CPU密集型的放在UI线程上跑会直接把界面卡死。必须用Task.Run做异步private async void OnCutoutButtonClick(object sender, RoutedEventArgs e) { if (_currentMat null) return; cutoutButton.IsEnabled false; progressBar.Visibility Visibility.Visible; try { var result await Task.Run(() _matting.Process(_currentMat)); previewImage.Source MatToBitmapSource(result); } catch (Exception ex) { MessageBox.Show($抠图失败: {ex.Message}); } finally { cutoutButton.IsEnabled true; progressBar.Visibility Visibility.Collapsed; } }这里_matting.Process包含完整的预处理、推理、后处理链路。如果需要处理批量图片可以用Parallel.ForEach配合ConcurrentBag收集结果注意Session.Run的线程安全性——ONNX Runtime保证同一个Session可以多线程并发调用。5.5 边缘质量提升的两招第一招适当增大输入分辨率。U2Net对输入尺寸的容忍度很高把320改成512边缘精细度会有肉眼可见的提升但推理耗时也会相应增加。我测过512x512输入在同样的CPU上推理时间约700毫秒提升够大还是可以接受的。第二招用Dilation和Erosion做蒙版后处理。对前景蒙版做一次轻微的形态学膨胀可以让边缘向外扩展几个像素避免抠图结果边缘发虚。6. 扩展到实时视频与批量处理6.1 视频流的逐帧抠图U2Net的一个优势是它的本质是逐帧处理所以可以应用在视频抠图上。我在测试中尝试过对摄像头视频流做实时抠图在U2NetP轻量版CPU下勉强能跑到每秒8到10帧效果比传统色键方案好很多。核心思路是视频帧从VideoCapture读取转成Mat后直接走同样的预处理和推理流程最后把Alpha合成到绿幕背景上public Mat VideoFrameToMatting(Mat frame) { var alpha _matting.GetAlpha(frame); // 合成到新背景 var foreground new Mat(); Cv2.CvtColor(frame, foreground, ColorConversionCodes.BGR2BGRA); var channels Cv2.Split(foreground); alpha.ConvertTo(channels[3], MatType.CV_8UC1, 255, 0); Cv2.Merge(channels, foreground); // 与背景混合 var background LoadBackground(); Cv2.AddWeighted(foreground, 1, background, 0, 0, foreground); return foreground; }6.2 批量目录抠图批量处理的代码更简单就是对一组图片循环调用同一个Process方法。我加了进度回调和取消支持public async Task BatchProcessAsync(IEnumerablestring files, IProgress(int current, int total) progress, CancellationToken cancellationToken) { var fileList files.ToList(); for (int i 0; i fileList.Count; i) { cancellationToken.ThrowIfCancellationRequested(); using var mat Cv2.ImRead(fileList[i], ImreadModes.Color); if (mat.Empty()) continue; var result _matting.Process(mat); var outputPath Path.Combine(_outputDir, Path.GetFileNameWithoutExtension(fileList[i]) _cutout.png); result.SaveToPng(outputPath); result.Dispose(); progress?.Report((i 1, fileList.Count)); } }实测批量处理200张800x600图片总耗时约2分钟平均每张0.6秒。6.3 结合AForge的摄像头参数控制如果项目要用到摄像头实时视频可以搭配AForge.NET框架来管理摄像头设备。AForge的VideoCaptureDevice可以设置分辨率、帧率、亮度、对比度等摄像头属性用起来很方便。var videoSource new AForge.Video.DirectShow.VideoCaptureDevice(deviceMoniker); videoSource.VideoResolution videoSource.VideoCapabilities.FirstOrDefault(res res.FrameSize.Width 640); videoSource.DesiredFrameRate 15; videoSource.NewFrame OnNewFrame;AForge的NewFrame事件会返回Bitmap转成Mat后再走U2Net的推理链路即可。这里要特别提醒一下NewFrame事件在摄像头工作线程里触发不能直接在里面做UI操作得用Dispatcher.BeginInvoke切回UI线程。7. 模型选择建议与后续优化方向如果在实际项目里落地我建议先想清楚你的场景对实时性的要求。离线单张图片处理直接上完整版U2Net效果最好。实时视频/摄像头预览用U2NetP轻量版帧率60帧/秒做不到但15帧/秒以内是可以的。如果对透明物体、细毛发这类极端场景有需求可以考虑换成U2Net的升级版U2Net-Fusion或者加上语义分割模型辅助。我在实际使用中发现的一个小技巧是U2Net对中心物体的显著性检测效果最好。如果目标物体不在画面中心可以先对图像做一次显著性区域裁剪把目标物体移到中心再送进模型这样输出的蒙版质量会显著提升。这个操作不需要额外模型直接设定一个滑动窗口遍历原图找显著性得分最高的区域即可。另外ONNX Runtime除了CPU后端还支持DirectMLWindows上的GPU加速。在支持DirectML 12的设备上推理速度能提升5倍左右。代价是需要额外引入Microsoft.ML.OnnxRuntime.DirectML包并且模型初始化时间会增加几百毫秒。如果你的用户设备普遍有独立显卡这个方案值得试。最后提醒一个很多人会忽略的问题U2Net的输出蒙版中心区域质量很好但边缘填充区域就是预处理时补零的部分的预测值不太稳定。如果你发现抠图结果四周发暗多半是填充区域被当成了背景可以在后处理时直接把蒙版的四周边缘裁掉一圈或者预处理时用边缘复制代替纯零填充效果会好很多。整体来说U2Net ONNX Runtime C#这套方案让我在完全离线、无GPU的条件下实现了体验良好的抠图功能。代码量不大但涉及图像处理、深度学习部署和性能优化几个方向的交叉希望这篇分享能帮大家少走一些弯路。本文还有配套的精品资源点击获取