ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# 部署 APISR 动漫超分辨率:OnnxRuntime 实战指南

C# 部署 APISR 动漫超分辨率:OnnxRuntime 实战指南

简介:面向希望在.NET环境中部署深度学习模型的C#开发者,一份完整的APISR动漫超分辨率推理方案。利用OnnxRuntime加载ONNX格式的APISR模型,可对动漫图片进行高质量上采样,解决C#工程中集成图像超分模型的落地难题。压缩包共275个文件,主要包含C#源码与解决方案、ONNX模型文件、DLL依赖库、XML配置、TXT说明文档等,另有样例图片与演示项目,整体约374MB。已有57人学习下载。包内提供可直接运行的演示工程和依赖包,涵盖从模型初始化、输入图像预处理到推理输出与结果保存的完整调用链路;packages目录已将所需第三方依赖统一整理,配合说明文件可快速理解各文件职责。目录结构区分源码、模型、依赖与文档,便于按需取用,适合有基础C#经验并希望快速上手OnnxRuntime部署的开发者参考,也可作为在.NET中集成其他ONNX模型的通用范本。

1. C# 跑 APISR 动漫超分辨率:这可能是最适合落地的 OnnxRuntime 路线

C# 工程师想把超分辨率模型接进自己的 WinForms / WPF / 上位机项目,最尴尬的是模型选型。Real-ESRGAN 太重,SwinIR 又大多只有 PyTorch 版本,转 ONNX 之后算子兼容性也要折腾一轮。APISR 是 2024 年开源的一套动漫图像超分网络,专攻脏线条、高噪点、色块边缘粗糙的卡通素材,放大 2 倍和 4 倍的效果非常干净。配合 OnnxRuntime 部署,C# 端只需要引入两个 NuGet 包就能把整套推理跑起来。这份资源包里正好是完整的实战工程:模型导出脚本、ONNX 权重、C# 推理类和前后处理工具,改改路径就能用。适合做动漫素材库增强、老片修复预处理、游戏立绘放大的开发者;如果你只是想本地把一张模糊图变清晰,它也能直接当工具用。

2. APISR 原理与模型导出:先搞清输入输出再谈部署

2.1 APISR 的网络结构与推理行为

APISR 的全称是 Anime Production Inspired Real-World Anime Super-Resolution,从名字就能看出它不是通用超分模型。通用超分模型在摄影图上表现很好,但对动漫原画里的硬边缘、大面积平色区域往往会出现锯齿或伪影。APISR 的训练数据来自真实动漫制作流程,包括缩放、压缩、噪声叠加后的劣化图像,所以它对线条的处理更接近“恢复原画”,而不是单纯放大。

推理行为上,APISR 模型内部带有 GAN 结构,输出带有生成器的锐化倾向。和 Real-ESRGAN 相比,它在 2 倍超分时就能得到比较可用的结果,4 倍超分时边缘振铃也更少。模型输入是 RGB 顺序的普通图像张量,数值范围 0 到 1,不需要 ImageNet 标准化。输出同样是 RGB 张量,高和宽分别放大 scale 倍。部署时最容易搞错的就是这一点,后面避坑章节会专门讲。

资源包里常见两种配置:2 倍模型和 4 倍模型。导出时直接决定你要哪种放大倍数,因为模型内部已经把 scale 编译成固定算子,运行时不能切换。换倍数必须重新导出 ONNX。

模型配置输入张量输出张量典型用途
APISR_x2[1, 3, H, W][1, 3, 2H, 2W]动画帧增强、实时预览
APISR_x4[1, 3, H, W][1, 3, 4H, 4W]老番修复、立绘放大

H 和 W 在导出时最好设置为动态维度,这样 C# 端可以输入任意分辨率,不用先缩放到固定尺寸。

2.2 把 PyTorch 权重导出为 ONNX:opset 与动态轴设置

资源包里的模型导出脚本一般是 Python 写的。APISR 官方权重是 PyTorch 格式,导出前先确认模型已经变成 eval 模式。常见做法是写一段脚本,加载权重后用一个全 0 或全 1 的假张量走一遍前向,确认网络能完整跑通,再执行 torch.onnx.export。这里给一份最简导出脚本作为参考:

import torch from apisr import APISR model = APISR.from_pretrained("APISR_GAN_x4") model.eval() dummy = torch.randn(1, 3, 64, 64) torch.onnx.export( model, dummy, "apisr_x4.onnx", opset_version=17, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch", 2: "height", 3: "width"} }, do_constant_folding=True ) print("export done")

导出时最值得注意的两个参数是opset_version和dynamic_axes。OnnxRuntime 对高版本算子支持很积极,opset 17 在 1.17 系列的 OnnxRuntime 上已经跑得很成熟。dynamic_axes把高和宽声明为动态,C# 端传给模型的张量就可以是任意尺寸;如果这里不设置,导出的模型会把 64x64 当成唯一合法尺寸,C# 端输入一个 320x180 的图直接异常退出了。do_constant_folding对 GAN 这类大模型建议打开,能把一部分固定计算提前折叠掉,减少运行时开销。

导出过程中如果报错,多半是模型内部某个 reshape 或插值算子对动态轴支持不好。我的处理办法是先改成固定尺寸导出验证整条链路,再改回动态轴重导。动态轴实在不行,就在 C# 端增加统一缩放逻辑,先缩到模型支持的尺寸附近再推理。

2.3 用 Python 先跑一遍 ONNX:确认数值没跑偏

模型导出之后,不要直接去写 C# 代码,先在 Python 端用 onnxruntime 跑一遍,和 PyTorch 输出对一下数值。这一步像是黑匣子验证,能提前排除模型导出阶段引入的算子偏差。代码大概这样:

import onnxruntime as ort import numpy as np import torch model = APISR.from_pretrained("APISR_GAN_x4") model.eval() x = torch.randn(1, 3, 64, 64).numpy() pt_out = model(torch.from_numpy(x)).detach().numpy() so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("apisr_x4.onnx", so, providers=["CPUExecutionProvider"]) onnx_out = sess.run(["output"], {"input": x})[0] diff = np.max(np.abs(onnx_out - pt_out)) print("max diff:", diff)

np.max(np.abs(onnx_out - pt_out))这个数值如果小于 1e-4,说明导出的模型数值行为基本一致;如果到 1e-2 这种量级,说明某个算子的实现有差异。常见的原因是 APISR 里的上采样算子或归一化层在 ONNX 里被重排了,在 C# 端表现就是输出图像偏亮或偏暗,容易误判成后处理问题。另外需要注意 PyTorch 模型在model(torch.from_numpy(x))之前必须已经处于 eval 模式,否则 dropout 或 batchnorm 的行为差异会直接污染对比结果。

2.4 量化与半精度:这一步不用着急做

导出成 FP32 ONNX 之后,文件体积比较大,在 CPU 上推理也比较慢,自然会想到转 FP16 或 INT8。FP16 导出能让体积减半,GPU 推理时显存占用也更友好,但 CPU 端跑 FP16 反而更慢,因为大部分 CPU 没有高效 FP16 计算单元,OnnxRuntime 内部要转换成 FP32 再算。INT8 量化对 APISR 这类 GAN 结构尤其敏感,生成器的输出是图像,量化误差会被放大成可见伪影,连线和色块边缘很容易碎。所以我一般建议第一版部署先用 FP32 跑通功能,确认图像质量没问题,再去尝试精度优化。资源包里如果有 FP16 或量化后的版本,也建议先用原始 FP32 版本做基准。

3. C# 端推理实现:OnnxRuntime 加载 APISR 模型的全流程

3.1 工程配置与 NuGet 依赖清单

部署 APISR 不需要在 C# 端安装任何深度学习框架,OnnxRuntime 的 NuGet 包已经集成了推理引擎。图像解码和缩放我习惯用 OpenCvSharp,因为后续超分完还要做边缘锐化或直方图调整,用 Mat 一条链路下来最舒服。新建一个 .NET 6 或 .NET 8 的 WinForms 工程后,引入下面这几个包就可以:

NuGet 包名版本建议作用
Microsoft.ML.OnnxRuntime1.17.x模型加载与 CPU 推理
Microsoft.ML.OnnxRuntime.GPU1.17.x(可选)在 CUDA 环境启用 GPU 推理
OpenCvSharp4.Windows4.8.x图像读取、缩放、通道转换
OpenCvSharp4.Extensions4.8.xMat 与 Bitmap 互转

先装 CPU 包把流程跑通,再考虑 GPU 包。GPU 包和 CUDA 版本绑定得很死,装错了会在加载模型时直接报找不到 cudart64 相关 DLL,具体问题在避坑章节展开。

3.2 核心推理代码:加载、预处理、Run、后处理

这是资源包最核心的部分。C# 端完整流程分为四步:加载模型、预处理图像、执行推理、把输出张量转回图像。下面这段代码可以直接套用:

using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; public class ApisrInference : IDisposable { private readonly InferenceSession _session; private readonly int _scale; public ApisrInference(string modelPath, int scale) { // 复用 Session,避免每次推理都重新加载模型 var options = new SessionOptions(); options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; _session = new InferenceSession(modelPath, options); _scale = scale; } public Mat Upscale(Mat srcBgr) { using Mat rgb = new Mat(); Cv2.CvtColor(srcBgr, rgb, ColorConversionCodes.BGR2RGB); int h = rgb.Height; int w = rgb.Width; var input = new DenseTensor<float>(new[] { 1, 3, h, w }); // 把 HWC 的 Mat 转成 CHW 的 float 张量,同时归一化到 [0,1] var inputArray = input.ToArray(); for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { var p = rgb.At<Vec3b>(y, x); inputArray[(0 * 3 + 0) * h * w + y * w + x] = p[0] / 255f; inputArray[(0 * 3 + 1) * h * w + y * w + x] = p[1] / 255f; inputArray[(0 * 3 + 2) * h * w + y * w + x] = p[2] / 255f; } } var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", input) }; using var results = _session.Run(inputs); var output = results.First().AsTensor<float>(); // 输出形状是 [1, 3, scale*H, scale*W] int outH = h * _scale; int outW = w * _scale; var outMat = new Mat(outH, outW, MatType.CV_8UC3); for (int y = 0; y < outH; y++) { for (int x = 0; x < outW; x++) { var b = (byte)Math.Clamp(output[(0, 0, y, x)] * 255f, 0, 255); var g = (byte)Math.Clamp(output[(0, 1, y, x)] * 255f, 0, 255); var r = (byte)Math.Clamp(output[(0, 2, y, x)] * 255f, 0, 255); outMat.Set(y, x, new Vec3b(b, g, r)); } } return outMat; } public void Dispose() => _session.Dispose(); }

这个实现逻辑不复杂,但有两个细节值得说明。第一是_session必须复用,InferenceSession的构造函数会加载模型权重、申请内存池,每秒创建一次会让推理慢一个数量级。第二是using var results = _session.Run(inputs);,Run返回的对象里包装了输出 Tensor,不释放的话连续推理几百张图后内存会持续上涨。预处理部分嵌套循环看起来笨,但结构最直观;实际性能敏感时可以用Mat.Split()分离通道,再把三个通道的 float 数组按序拼成 CHW,这样能省掉大量At调用,速度更快,资源包里两种写法都保留了。

3.3 参数怎么调:CPU/GPU 切换、线程与内存

OnnxRuntime 的 CPU 推理默认会使用所有物理核心,这在后台任务里会明显卡 UI 线程。控制并发最简单的方式是设置SessionOptions的线程数:

options.SetIntraOpNumThreads(Math.Max(2, Environment.ProcessorCount / 2)); options.SetSessionThreadPoolSize(2);

对于 4 倍超分这种计算密集任务,线程数设成物理核心数的一半是性价比最高的。全核跑时推理时间只减少 10% 左右,但 CPU 占用率冲到 100%,WinForms 界面拖动卡顿非常明显。如果目标机器有支持 CUDA 的 N 卡,要改成 GPU 推理,代码变化很小:

options.AppendExecutionProvider("CUDA"); options.AppendExecutionProvider("CPU"); // 备用兜底

注意 GPU 包只有在 CUDA 与 cuDNN 版本匹配时才会生效,有 GPU 却加载失败时不会报错,而是静默用 CPU 跑。所以我在工程里都会加一段启动日志,打印当前生效的 execution provider;看到CUDAExecutionProvider出现在输出里才确认真正用上了 GPU。

3.4 文件清单与运行入口

压缩包解压后目录结构大概是这样的:

路径内容
models/apisr_x4.onnx导出的 ONNX 权重
scripts/export_onnx.pyPyTorch 转 ONNX 脚本
scripts/verify_onnx.pyPython 端数值验证脚本
src/C# 工程源码
src/Program.cs命令行入口,读图、推理、写图
src/ApisrInference.cs推理封装类

先用命令行入口测试最省事:Program.cs接收输入图片路径和输出图片路径,加载模型后调用Upscale。我习惯先跑命令行模式验证整条链路,再集成到 WinForms 或 WPF 界面里,这样排查问题时至少能区分是界面问题还是推理问题。

4. 部署避坑:五个常见问题与排查顺序

4.1 排查工具:把“跑不起来”拆成三层

部署 APISR 遇到问题,先不要急着改代码。我一般按三层排查:模型层、数据层、环境层。模型层用 Python 脚本确认 ONNX 能正常推理,数据层检查输入输出的形状和数值范围,环境层确认 NuGet 包版本和本机 DLL。最快的诊断方式是加载 session 后把模型信息打出来,看看输入名、输出名和维度是不是和预期一致:

foreach (var kvp in _session.InputMetadata) { Console.WriteLine($"Input: {kvp.Key} {kvp.Value.Dimensions} {kvp.Value.ElementDataType}"); }

这一步能直接暴露模型是否带动态轴。如果打印出来是[1, 3, 64, 64],说明导出脚本里的 dynamic_axes 没生效,后面无论 C# 写得多对都会出问题。

4.2 色彩整体偏绿偏蓝:通道顺序问题

现象:超分后的图对比原图,红色变成了青色,人脸肤色发绿,看起来像胶片负片效果。

原因:模型训练时用的是 RGB 通道顺序,OpenCvSharp 读进来的是 BGR,直接喂给模型等于把 R 和 B 通道互换。APISR 对通道敏感度极高,一旦通道错乱,图像整体偏色会非常明显。

解决:推理前调用Cv2.CvtColor(srcBgr, rgb, ColorConversionCodes.BGR2RGB),后处理再转回去。我见过不少人在后处理输出时只取了 R 通道却不做整体转换,结果图像依然偏色,本质上还是通道序没掰正。

4.3 输出图像边缘出现黑边:尺寸没对齐

现象:超分结果四周出现不等的黑色或模糊条纹,有的图明显,有的图不明显。

原因:APISR 内部有多个 stride 为 2 的下采样卷积,输入尺寸必须能被放大倍数整除。比如 4 倍模型,输入宽高如果不是 4 的倍数,边缘处会计算到不完整像素,推理引擎只能补零,黑边就是这么来的。

解决:推理前先对输入做对齐,常见做法是把宽高修正到能被 scale 整除:

int alignedW = w - (w % scale); int alignedH = h - (h % scale); Cv2.Resize(srcBgr, srcAligned, new Size(alignedW, alignedH));

注意对齐放在通道转换之后、归一化之前。如果不想改变构图,也可以先按原尺寸推理,再把边缘多出来的几个像素裁掉,但这样会丢失画面内容,不如提前重采样。

4.4 换台机器就报“找不到 DLL”:OpenCV 运行库问题

现象:开发机上跑得好好的,打包发给同事,启动就报System.DllNotFoundException: opencv_core480.dll。

原因:OpenCvSharp4.Windows 依赖 VC++ 运行库和本机 OpenCV 原生 DLL,如果目标机器没有安装对应运行环境,程序直接崩。这不是 OnnxRuntime 的问题,是 OpenCV 的经典坑。

解决:把opencv_core480.dll、opencv_imgproc480.dll等几个 DLL 拷贝到输出目录,同时在项目属性里打开“复制本地依赖项”。更省心的做法是图像处理层改用 ImageSharp,纯托管实现,不依赖本机 DLL,对部署环境更友好,代价是图像操作性能弱一些。

4.5 多线程下内存暴涨:Result 未释放 / Session 反复创建

现象:做批量超分任务,处理到几百张时进程占内存 2GB 以上,甚至 OutOfMemory。

原因:两个隐患叠加。Run返回的IDisposableReadOnlyCollection<NamedOnnxValue>没有 using,输出 Tensor 每次申请的内存等 GC 回收;另一处是每次调用都 new InferenceSession,底层内存池全部重建。

解决:Session 改成单例,强约束自己只在程序启动时创建一次;所有Run的返回值一律 using 包裹。这两个改掉后,内存曲线会变得非常平稳。

另外还有一个容易被忽略的细节:如果推理模型时反复传递同一个DenseTensor对象,OnnxRuntime 不会自动把上次计算结果清零,输入张量里的旧数据可能污染本次推理结果。稳妥的做法是每次推理前重新创建DenseTensor。

5. 性能优化与验证:让 APISR 在低配机器上跑得动

5.1 滑窗分块:把大图拆成小图再拼回来

4 倍超分一张 1920x1080 的图,在纯 CPU 机器上可能要跑十几秒,而且整张图直接推理会占用大量内存。常见的做法是滑窗分块,把大图切成若干小块分别推理,再拼回完整大图。切块尺寸要先对齐放大倍数:

int scale = 4; int tileSize = 320; tileSize -= tileSize % scale;

分块后每块独立推理,拼接时相邻块之间留 8 到 16 像素重叠,重叠区域做简单的加权融合,能避免接缝处的亮度跳变。这个思路对 2000 像素以上的长图尤其有效,内存占用从 2GB 级别降到 200MB 级别。

5.2 验证方法:超分效果到底对不对

验证部署是否成功,不要只看能不能跑出图。我的固定流程是准备一张带细线条的动漫截图,同时跑三次:原图双三次放大、APISR 输出、APISR 输出后降采样回原尺寸。如果降采样回原尺寸的图和原图差异很大,说明颜色映射或尺寸计算有问题;如果边缘出现黑白锯齿,说明预处理或模型版本有问题。最后再放大到 200% 目测线条是否顺滑、噪点是否被锐化成颗粒。

资源包里的 APISR 推理工程本身不复杂,大头都在前处理对齐和 Session 复用上面。我最早部署这套模型时图省事没做尺寸对齐,结果在客户现场出的图边缘一直带黑条,排查了大半天才发现是宽度不能被 4 整除。从那以后每次换模型,我都会强制在笔记本上走一遍三张图的验证流程:原图、超分图、降采样回原尺寸图,先在本地确认没问题再交付。这个习惯帮我挡掉了后面至少三次线上翻车,希望也能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进