简介:在桌面应用与上位机开发中,深度学习模型的集成常常依赖Python服务,导致部署复杂、环境臃肿。其实,借助ONNX这一开放的模型交换格式与微软自家的推理引擎OnnxRuntime,C#开发者完全可以脱离Python环境,将PyTorch模型直接嵌入WinForms或WPF项目。本实践以DDColor图像着色模型为例,详细讲解如何将预训练权重导出为ONNX文件,并通过C#完成图像预处理、推理与后处理全链路。从Lab颜色空间转换、Tensor张量构造到OpenCvSharp的图像操作,每一步都贴合实际工程场景。无论是老照片修复、黑白影像上色,还是灰度监控图可视化,这套C# + OnnxRuntime方案都能提供稳定高效的推理能力,为桌面应用赋予AI视觉功能提供了一条轻量化、易分发的落地路径。 做C#上位机这么多年,其实一直有个执念:想让桌面程序直接跑深度学习模型,而不是动不动就拉个Python服务在后台蹲着。最近因为一个老照片修复的需求,我拿到了一个标题叫“C# OnnxRuntime 部署 DDColor.rar”的压缩包,里面是一个打包好的DDColor图像着色模型和相关的C#工程。花了两天时间把整个链路跑通之后,我觉得这套方案非常值得分享出来——尤其是对C#开发者来说,用OnnxRuntime把PyTorch的模型集成进WinForms/WPF项目里,这条路完全走通了。这篇博客就把我拆包、推理、踩坑的全过程记录下来,从模型转换到C#推理代码,再到成图效果,都给你说透。
1. 项目核心思路:DDColor与OnnxRuntime的搭配逻辑
1.1 DDColor是什么,能处理什么问题
DDColor是ICCV 2023的一篇图像着色论文,全称是Towards Photo-Realistic Image Colorization via Dual Decoders。和传统的着色模型不太一样,它用的是双解码器结构:一个解码器负责生成全局的颜色风格,另一个解码器负责恢复图片细节和边缘信息,最后融合成最终的A/B色度通道。换句话说,它不只是简单地把灰度图染上一层颜色,而是会先“理解”画面里的语义内容——比如知道这是一片天空,那是一块草地——再按语义来给对应区域上色。所以它的成色效果比DeOldify这类老模型要自然得多,很少出现那种颜色溢出或者整张图偏色的情况。
这套模型对老照片修复、黑白影像上色、监控灰度图可视化都有很好的实用价值。更关键的是,DDColor的模型权重和推理代码都是开源的,这就给C#工程集成提供了前提。我们不需要去自己训练模型,只要把预训练权重转成ONNX格式,再用OnnxRuntime这个跨平台推理引擎去加载,就能在纯C#环境下跑通整个着色流程。
1.2 为什么选C# + OnnxRuntime,而不是继续待在Python
很多C#开发者在做AI功能时第一反应是:Python调模型多方便啊,C#不适合干这个。这种想法放在两年前还有一定道理,但现在真的变了。OnnxRuntime是微软自己出品的推理引擎,C#是微软的亲儿子,两者的配合度远比想象中高。你不需要去理解PyTorch的整个运行机制,不需要管Python环境、pip依赖、conda虚拟环境这些破事,只需要一个ONNX模型文件加一个NuGet包,就能完成推理。
我选择C# + OnnxRuntime还有几个非常务实的理由。第一个是部署形态:上位机软件用户不可能去装Python环境,而C#可以直接把OnnxRuntime的native DLL和模型文件一起打包进发布目录,用户拿到就能跑。第二个是线程控制:C#里可以很方便地用async/await、Task、BlockingCollection这些来做图像队列调度,和上位机已有的串口通信、PLC通讯逻辑能无缝整合。第三个是生态互补:OpenCvSharp提供了完整的图像处理API,配合OnnxRuntime后,整个图像预处理、推理、后处理都能在C#一个进程内完成。
1.3 整体架构设计
我最终采用的架构很简单,就是典型的“图像处理 + 推理引擎”两级结构。上层是WinForms界面,负责选择图片、触发处理和显示结果;中间层是一个ImageColorizer类,封装了DDColor推理的完整逻辑;底层依赖两个核心库——OpenCvSharp负责图像编解码、颜色空间转换和尺寸调整,OnnxRuntime负责加载DDColor的ONNX模型并执行前向推理。
整个处理流程可以拆成四个环节:读取灰度图或彩色图、把图像转换到Lab颜色空间并取L亮度通道、将L通道输入模型推理得到A/B色度通道、最后把L/A/B三通道合并并转换回BGR彩色图。这套流程和DDColor官方Python代码的逻辑完全一致,区别只是把skimage和torch的调用换成了OpenCvSharp和OnnxRuntime。
值得强调的是,这里的核心难点不在于推理本身,而在于图像预处理和后处理时的颜色空间转换。DDColor模型输入是三通道的L通道灰度图,输出是A/B两个色度通道,我们必须保证C#端的Lab转换结果和官方Python端用skimage的rgb2lab结果足够接近,否则成图效果会明显偏色。
2. 模型准备:从PyTorch权重到ONNX推理文件
2.1 获取预训练权重
DDColor的官方仓库在GitHub上,直接搜DDColor就能找到,项目来自piddnad。仓库里提供了完整的训练和推理代码。我们要用的是其中最核心的ddcolor_model_twins和ddcolor_model_convnext这两个预训练权重之一。我实际测试中选择了基于ConvNeXt的版本,因为它的ONNX导出尺寸相对较小,而且在C#环境下推理速度更可控。
下载权重时要注意版本对应关系,官方仓库里不同分支可能对应不同的模型结构。建议直接使用master分支上的权重文件,文件名一般是pytorch_model.bin或者类似命名。下载完成后,先用Python脚本跑一下官方提供的demo,确认识别效果正常,再做ONNX导出。这一步很重要——如果直接在PyTorch里跑出来的效果就不对,那后面无论怎么部署都没有意义。
另外,如果读者在GitHub下载总是失败,可以考虑先下载到本地再用公司内网传输,或者用国内的开源镜像站。我当时是在一台有稳定网络的机器上把权重下载好,再拷到开发机上操作的。
2.2 导出ONNX的关键步骤
导出ONNX是整个过程里最容易翻车的一步。DDColor官方仓库里其实已经提供了导出脚本,路径大概是script/export_onnx.py这样的位置。这个脚本的思路很清晰:加载预训练权重,构造一个假输入,然后调用torch.onnx.export把模型结构固化下来。
如果你找不到导出脚本,直接自己写也不难,核心导出代码大概是这样的:
import torch from ddcolor import DDColor model = DDColor(model_name='convnext', input_size=(256, 256)) checkpoint = torch.load('pytorch_model.bin', map_location='cpu') model.load_state_dict(checkpoint['model_state_dict'] if 'model_state_dict' in checkpoint else checkpoint, strict=False) model.eval() dummy_input = torch.randn(2, 3, 256, 256) torch.onnx.export( model, dummy_input, 'ddcolor.onnx', input_names=['input'], output_names=['output'], dynamic_axes={ 'input': {0: 'batch', 2: 'height', 3: 'width'}, 'output': {0: 'batch', 2: 'height', 3: 'width'} }, opset_version=11 ) print("export done")需要注意几个关键点。第一,dynamic_axes务必加上,这样导出的模型才能支持任意输入尺寸。如果不加,之后在C#里就只能用256x256的固定分辨率,那对大图做处理时非常尴尬。第二,opset_version不要太高,11左右的版本兼容性最好,OnnxRuntime对低版本算子的支持很成熟。第三,导出前一定要把模型切换到eval模式并调用torch.no_grad()上下文,否则模型里的dropout和batchnorm会干扰导出结果。
另外提醒一下,PyTorch导出ONNX建议在当前模型官方指定的Python环境下操作。如果环境里缺少依赖,可以新建一个conda环境,按requirements.txt安装,版本差一两个小版本通常不会有大问题。
如果读者不想自己去转换,也可以在网上搜索“ddcolor onnx”直接找现成的模型文件。但要注意来源可靠性,建议优先找和官方仓库关联的转换版本,避免被人动过手脚,特别是涉及商业项目时更要注意模型文件的来源可信度。
2.3 用Netron确认输入输出结构
拿到ONNX模型后,第一件事就是用Netron打开看一下结构。Netron是一个在线打开的神经网络可视化工具,直接拖拽模型文件到网页上就能看。我们需要重点确认三个信息:输入节点的名称、输入张量的Shape格式、输出节点的名称和Shape格式。
按照官方导出脚本,输入节点名一般是input,Shape是(1, 3, 256, 256)这样的动态格式,代表batch size为1、三通道、高度和宽度动态变化。输出节点名一般是output,Shape是(1, 2, 256, 256),代表两通道的A/B色度信息。这里的三通道输入不是RGB三通道,而是把L通道复制成三份——也就是说,DDColor虽然要求输入三通道张量,但三通道的内容是完全相同的灰度亮度值。
这个结构认知非常关键,直接影响后面C#代码里tensor的填充方式。我见过不少人在这一步栽跟头,把灰度图按单通道填充进去,结果推理直接报错;还有人把三通道理解为RGB彩色图输入,导致后处理时颜色完全错乱。
3. C#工程实现:从图像预处理到推理后处理
3.1 创建项目与引入NuGet包
创建C#工程这一步没有什么特别之处,我用的是.NET 6 + WinForms,因为上位机项目还在用.NET Framework的太常见了,但对新项目我建议直接用.NET 6以上版本,这样NuGet依赖处理会省心很多。需要引入三个NuGet包:
- Microsoft.ML.OnnxRuntime(CPU推理)
- OpenCvSharp4(图像处理)
- OpenCvSharp4.runtime.win(OpenCV原生库)
引入时要注意平台目标。OnnxRuntime和OpenCvSharp都分x86和x64版本,开发机上一般用x64就好。如果不小心在x86模式下加载x64的native DLL,运行时会出现BadImageFormatException异常,排查起来很麻烦。我通常在项目属性里直接把平台目标设为x64,这样最省心。
还有一点,Microsoft.ML.OnnxRuntime的版本和Microsoft.ML.OnnxRuntime.Gpu(如果要用GPU)之间最好不要混用,否则容易出现native DLL重复加载的问题。纯CPU场景就只用CPU包,需要CUDA加速时再换Gpu包。OnnxRuntime 1.16以后的版本对CUDA 11.8支持得很成熟,但会让部署体积增加很多,读者可以根据实际场景衡量。
3.2 图像预处理:灰度图转Lab、调整尺寸、归一化
DDColor模型的预处理逻辑和常见的分类模型不一样,它不接受BGR/RGB原始图像,需要先把图像转到Lab颜色空间,取出L亮度通道,再进行归一化。所以在C#端第一步就是用OpenCvSharp把BGR图像转换成Lab:
using OpenCvSharp; Mat bgr = Cv2.ImRead(inputPath, ImreadModes.Color); Mat lab = new Mat(); Cv2.CvtColor(bgr, lab, ColorConversionCodes.BGR2Lab); Mat[] labChannels = Cv2.Split(lab); Mat lChannel = labChannels[0]; // L通道,0-255 Mat aChannel = labChannels[1]; // 官方图像A通道,0-255 Mat bChannel = labChannels[2]; // 官方图像B通道,0-255这里有个容易混淆的点:OpenCV的BGR2Lab转换出来的L通道范围是0-255,但DDColor官方Python代码用的是skimage的rgb2lab,L通道范围是0-100。好在DDColor模型对输入做了归一化处理,实际测试中直接把0-255的L通道值除以255缩放到0-1即可,不必纠结L通道具体范围。关键是保持数值统一规律。
接着需要把L通道调整到模型输入尺寸,并归一化到0-1浮点范围。我建议实际处理时把输入尺寸设置为256x256,通过Cv2.Resize实现。把L通道resize后,需要用OpenCvSharp的MatToArray方法把像素值提取到float数组里,然后每个元素除以255.0f。最后把这个长度为256*256的一维数组复制三份,填进形状为(1, 3, 256, 256)的DenseTensor中:
Mat resizedL = new Mat(); Cv2.Resize(lChannel, resizedL, new Size(256, 256)); float[] pixels = new float[256 * 256]; for (int y = 0; y < 256; y++) { for (int x = 0; x < 256; x++) { pixels[y * 256 + x] = resizedL.At<byte>(y, x) / 255.0f; } } float[] inputData = new float[3 * 256 * 256]; for (int c = 0; c < 3; c++) { Array.Copy(pixels, 0, inputData, c * 256 * 256, 256 * 256); }这里不建议直接使用Mat.GetArray一次性提取,因为OpenCV的Mat在内存中可能存在行对齐填充,直接按一维数组读取容易错位。用At (y, x)虽然慢一点,但能保证像素位置完全正确。对于只有256x256的尺寸来说,这个性能开销完全可以接受。
3.3 核心推理代码
推理部分用OnnxRuntime写成这样:
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var session = new InferenceSession("ddcolor.onnx"); // 或指定完整路径 var inputMeta = session.InputMetadata; string inputName = inputMeta.Keys.First(); var inputShape = inputMeta[inputName].Dimensions; Console.WriteLine($"input: {inputName} {string.Join(",", inputShape)}"); var inputTensor = new DenseTensor<float>(inputData, new[] { 1, 3, 256, 256 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(inputName, inputTensor) }; using (var results = session.Run(inputs)) { var output = results.First().AsTensor<float>(); // output shape: [1, 2, 256, 256] float[] outputData = output.ToArray(); }这段代码的核心逻辑已经基本完整。需要注意InferenceSession最好用using或者做成单例,不要在每张图片推理时都new一个session,因为模型加载和初始化本身有较大开销。在我的机器上,加载DDColor模型需要大约几百毫秒,这部分时间完全可以通过复用session来省掉。
关于InferenceSession的线程安全性,OnnxRuntime官方文档说明Run方法是线程安全的,所以可以放心地在多个线程里复用同一个session实例。我在实测中同时让4个线程各自推理不同图片,没有出现崩溃或数据错乱的情况。
3.4 后处理:ab通道回采、融合、转回RGB
拿到模型的输出张量后,需要做一系列后处理才能得到最终彩色图。模型输出的ab通道范围大约是-1到1,需要先映射到0-255的整数范围,才能和OpenCV的Lab图像数据兼容:
int size = 256 * 256; byte[] abA = new byte[size]; byte[] abB = new byte[size]; for (int i = 0; i < size; i++) { float aVal = (outputData[i] + 1.0f) * 128.0f; // 第一个通道 float bVal = (outputData[size + i] + 1.0f) * 128.0f; // 第二个通道 abA[i] = (byte)Math.Clamp(aVal, 0, 255); abB[i] = (byte)Math.Clamp(bVal, 0, 255); }接着用这三个通道构造一个Lab空间的Mat,然后通过OpenCV的Lab2BGR转换出最终彩色图。注意这里使用原来的L通道(原分辨率)配合上采样后的ab通道:
Mat lMat = new Mat(256, 256, MatType.CV_8UC1); Mat aMat = new Mat(256, 256, MatType.CV_8UC1); Mat bMat = new Mat(256, 256, MatType.CV_8UC1); Marshal.Copy(lChannelPixels, 0, lMat.Data, lChannelPixels.Length); Marshal.Copy(abA, 0, aMat.Data, abA.Length); Marshal.Copy(abB, 0, bMat.Data, abB.Length); Mat[] labChannels = { lMat, aMat, bMat }; Mat labOutput = new Mat(); Cv2.Merge(labChannels, labOutput); Mat bgrOutput = new Mat(); Cv2.CvtColor(labOutput, bgrOutput, ColorConversionCodes.Lab2BGR);然后用Resize把输出图调整回原始图像的宽高即可。如果要处理大图,我建议直接让模型输出更大尺寸,比如把输入设成原图等比例缩放后的尺寸,而不是非得256x256后让后处理去放大。模型对输入尺寸有一定的泛化能力,但分辨率大幅变化会明显影响着色质量。
4. 性能优化与踩坑实录
4.1 执行提供程序选型:CPU、CUDA、DirectML
OnnxRuntime支持多种执行提供程序,最常用的是CPU、CUDA、DirectML三种。我最初只在CPU上跑,用256x256输入,单张图推理耗时大约2到4秒,具体看CPU性能。这个速度对单张老照片处理来说是能接受的,但如果要批量处理一批照片就会显得很慢。
如果你的机器有NVIDIA显卡,可以换成Microsoft.ML.OnnxRuntime.Gpu包,并加上CUDA执行提供程序。实测下来推理耗时可以从3秒降到0.3到0.5秒,提升非常明显。但要注意CUDA版本和显卡驱动的依赖关系,OnnxRuntime 1.16.3对应CUDA 11.8和cuDNN 8.7,版本不对会在初始化时报错。C#代码如下:
var sessionOptions = new SessionOptions(); sessionOptions.AppendExecutionProvider_CUDA(0); sessionOptions.AppendExecutionProvider_CPU(); var session = new InferenceSession("ddcolor.onnx", sessionOptions);如果你不想操心CUDA那一堆环境变量,可以用DirectML方案,只需要引入Microsoft.ML.OnnxRuntime.DirectML包,然后调用AppendExecutionProvider_DML()。DirectML的好处是适配几乎所有支持DirectX 12的显卡,无需单独安装CUDA工具链,但推理速度比CUDA略慢,在10系显卡上大概0.5到1秒之间。这个方案对无法控制目标机器环境的上位机项目是很友好的。
4.2 大图处理时的内存与耗时控制
对于超过2000像素的大图,直接以原图分辨率输入模型会导致内存暴涨和推理时间线性增加。我实测3000x2000的图,如果直接以原图尺寸推理,CPU模式可能耗时20秒以上,而且内存峰值超过500MB,这在一个常年运行的上位机程序里是不可接受的。
常规做法是把长边限制在512或640像素。具体来说,先计算缩放比例,把长边缩放到640,短边按比例缩放。推理输出后再把ab通道上采样回原图尺寸。这样做的好处是推理时间稳定在1到2秒,内存占用小,成色质量也没有肉眼可见的差异。DDColor的特征提取网络在256到512分辨率范围内效果差别不大,大幅超过这个范围反而会因为细节信息过载而产生颜色噪点。
内存控制上还要注意Mat对象的释放。OpenCvSharp的Mat实现了IDisposable,处理大图时应尽量用using语句包裹,并及时调用Dispose。我在批量处理100张图时如果不及时释放Mat,内存占用会一路涨到接近1GB,GC都救不回来。后来把所有中间Mat都改成using方式,内存峰值稳定在200MB左右。
4.3 常见异常与排查速查表
这个环节必须单独列一个表,因为我在这个项目里踩的坑比写代码的时间还多。最典型的是首次运行就报System.DllNotFoundException: Unable to load DLL 'onnxruntime':原因是OnnxRuntime的native DLL没有被正确复制到输出目录,或者缺少Visual C++运行库。解决办法是在项目里检查是否引用了运行时包Microsoft.ML.OnnxRuntime.Managed,同时确保输出目录有onnxruntime.dll,并且安装VC++ Redistributable 2019以上版本。
另外很常见的是System.BadImageFormatException,这个通常是平台目标不匹配,比如OnnxRuntime是x64的原生库,但项目编译成了x86,解决办法是右键项目属性把平台目标改成x64。还有一个容易被忽略的问题是推理时报告输入名称错误,比如RuntimeError: Input 'input' is missing。如果你的ONNX文件是用其他工具导出的,输入节点名可能不是默认的input,需要先用Netron确认实际名称,再修改C#代码里对应字符串。
最后是效果层面的大坑——推理完成后图像整体偏紫或偏绿。这个问题十有八九是ab通道的映射范围搞错了。模型输出接近-1到1,要映射到0-255时必须先加1再乘128,如果直接乘128或直接把负值强转byte,颜色通道就会像被撕裂一样出现诡异的色调。我第一次部署时因为赶进度没有仔细看输出分布,就在这一步翻了车,成品图简直是灾难现场。后来用Netron和Python端输出对比,才发现是范围映射的问题。
下面是我把这次排障过程中遇到的主要问题整理成的一张速查表,基本覆盖了C# OnnxRuntime部署DDColor时最常见的异常类型:
| 异常现象 | 可能原因 | 解决方法 |
|---|---|---|
| DllNotFoundException: onnxruntime | 缺少VC++运行库或native DLL未复制 | 安装VC++ 2019/2022运行库,检查输出目录 |
| BadImageFormatException | 平台目标与native DLL不匹配 | 项目平台目标设为x64 |
| 推理失败提示输入名错误 | ONNX输入节点名不是默认值 | Netron查看实际名称,同步修改代码 |
| 图像输出偏紫/偏绿 | ab通道范围映射错误 | 输出值先+1再乘128,再做Clamp |
| 输出图像有明显色块 | 输入L通道值未归一化或范围不对 | 检查L通道是否在0-255且除以255.0f |
| 批量处理时内存持续上涨 | Mat对象未及时释放 | 用using包裹或finally中Dispose |
| GPU推理报CUDA相关错误 | CUDA版本和OnnxRuntime不匹配 | 换对应版本的Gpu包或改用DirectML |
5. 完整Demo与实测效果
5.1 WinForms展示Demo
为了让整个流程直观可验证,我写了一个简单的WinForms窗体,界面上就三个控件:一个Open按钮选择图片、一个PictureBox显示原图和一个PictureBox显示着色结果。后台在Task.Run里执行推理,避免阻塞UI线程。核心逻辑都封装在ImageColorizer类里,Demo页面只负责调用。这样项目结构清晰,后面如果集成到真正的上位机里,只需要把ImageColorizer类整体搬过去就行。
界面切换图片时,我会先做一个低分辨率的预览图,用输入尺寸推理并快速显示,让用户看到大概效果;然后异步放大到高分辨率进行一次精细推理,完成后替换成高清图。这个交互设计在实际使用中很受欢迎,因为用户在点击后不到1秒就能看到反馈,不会干等。
5.2 实测效果与耗时
用一张上世纪的黑白人像照做测试,模型输入256x256,OpenCvSharp的Lab通道转换配合OnnxRuntime的CPU推理,总耗时约3.2秒。着色结果里人物的肤色还原得比较自然,背景里树叶的绿色没有出现大面积溢出,说明DDColor的语义理解能力确实比传统方法强。我又拿了一张本身就是黑白的老建筑照片做测试,砖墙颜色偏暖、天空偏蓝,整体质感非常接近真实老照片修复的效果。
在同样的CPU环境下,把输入尺寸提高到512时耗时约7秒,效果细节明显更丰富。如果打开DirectML执行提供程序,在GTX 1060上512尺寸的推理可以压到1秒以内。这个性能表现对老照片修复、归档场景来说完全够用。
5.3 扩展思路
这个部署方案做完之后,可以做的扩展方向还有好几个。一是接入摄像头实时流:由于DDColor对单帧耗时要求较高,可以先在低分辨率上做快速着色,再结合流处理框架做实时预览。二是结合超分辨率模型:先对老照片做超分,再做着色,两个ONNX模型串联在同一个C#进程里,OnnxRuntime对这种多模型串联场景支持得很好。三是封装成REST API,用ASP.NET Core承载,让前端或其他系统通过HTTP调用着色服务,这样就把着色能力完全平台化了。
最后再说一个项目里的小细节。如果你在使用过程中发现每次初始化InferenceSession都要等很久,可以在程序启动时提前加载模型,并用一个静态字段保存session实例。这样后续每次调用都省去模型加载时间。另外,如果你的目标是Windows系统,可以考虑用ReadyToRun方式发布,减少启动时JIT编译带来的额外延迟。
6. 项目总结与经验心得
这次用C# OnnxRuntime部署DDColor,从拿到压缩包到跑通全流程,总共花了两天时间,其中大部分时间都耗在了颜色空间转换和ab通道范围映射这些细节上。走完这一遍之后,我的直接感受是:C#深度集成AI模型的门槛已经被OnnxRuntime降得足够低了,真正卡人的不再是推理引擎本身,而是你对模型输入输出语义的理解是否到位。
有个小经验可以分享给后面做这个项目的读者:在动手写C#代码之前,先用Python把官方推理流程跑通,然后把每一步的中间结果(比如L通道值、模型输出shape、ab通道的数值范围)打印出来,后面在C#里逐一对应检查。这样能省去大量猜测时间,因为颜色空间的坑如果不在最开始堵住,到后期排查起来会非常痛苦。
我也建议有条件的读者多试试DirectML的执行提供程序,它让AMD和Intel核显也能参与推理加速,在产线上部署比CUDA方案灵活得多。按我个人经验,Deep Learning部署的重点其实是工程化稳定性和资源控制——在这一点上,C#天生就有优势,配合OnnxRuntime,它是一门被低估的AI部署语言。
本文还有配套的精品资源,点击获取