把老动画“洗”清楚这件事我已经认认真真做了好几周。我最早做这个小工具纯粹是想把小时候收藏的480p老动画用本地电脑放到4K电视上给孩子看。折腾下来发现传统播放器的拉伸画面一团糊直接上超分模型又有一堆工程障碍。最后我用 C# WinForms 写了个桌面壳把 APISR 这个面向动漫的超分模型拉进来再用 Vulkan 做 GPU 推理和帧处理做成了一个小而完整的视频超分工具。这篇文章把我整个踩坑过程、方案取舍、关键实现和调参经验都整理出来供想复刻同样工具的玩家和技术爱好者参考。这个工具解决的核心问题说白了就三个老动画线条断、色块多、分辨率不够。它可以批量导入视频自动拆帧、超分、再合成最终产出一个清晰度高得多、且保留音轨字幕的新视频文件。整个链路里WinForms 负责交互APISR 负责画质重建Vulkan 负责把模型跑起来并处理帧格式转换。听起来简单实际上每层都有不少坑。下面我从头到尾拆一遍。1. 项目整体思路为什么是WinForms APISR Vulkan1.1 老动画画质问题的真实来源老动画和旧真人电影还不一样它的画面通常来自 DVD、VCD、早期电视采集甚至录像带转录。我手头这批素材大多只有 480p 或是 576p编码格式还是古老的 MPEG-2、H.264 High Profile 早期版本码率低、色度抽样是 4:2:0再加上压缩产生的振铃效应和色块放大到1080p之后满屏都是“脏点”。普通播放器的拉伸算法比如双线性或者 Lanczos只是在像素层面插值。线条边缘会被拉宽原本锐利的描边会变得灰蒙蒙色块也会被放大成大片噪点。这时候真正需要的是“图像重建”而不是“几何放大”。所谓AI超分本质是让模型学习低分辨率动画到高分辨率动画的映射它会在推理时补出高频线条细节同时把色块和噪声压下去。APISR 这类专为动画设计的模型比通用超分模型更懂“线稿”“赛璐璐色块”和“卡通化阴影”这些动画独有的画面结构。1.2 桌面壳为什么选 WinForms刚开始我也想图省事直接用 Python PyQt 写界面或者干脆做一套 Web 界面用浏览器打开。但实际跑起来发现这个项目最需要的三件事批量拖拽导入、任务进度可视、随时暂停取消。这些恰恰是 WinForms 最擅长的场景。WinForms 确实老但它是 .NET 生态里最“轻”的界面框架。启动快、内存占用低不需要像 Electron 那样带一个浏览器内核。对于一台纯做视频处理的机器能省一点资源是一点。更关键的是WinForms 的控件模型足够成熟ListBox、ProgressBar、NotifyIcon 这些组件处理批量任务队列非常顺手。我用它做任务列表一行一个视频一个进度条跟到底拖拽新文件进列表就能追加任务整个交互写起来不超过两百行。有人劝我用 WPF 或者 MAUI但说实话这个工具的核心竞争力在推理管线不在动画特效。WinForms 足够稳定不容易引入额外的 UI 线程问题对老机器也更友好。1.3 APISR 比普通放大强在哪一开始我用 FFmpeg 自带的 Lanczos 缩放确实锐了一点但属于“假清晰”一到运动场景就崩。后来换了 APISR质的差别才出来。APISR 的全称是 Anime Production Inspired Super-Resolution也就是说它参考了真实动画制作流程里的“线稿上色”“减色”“摄影处理”这些环节。传统超分模型容易把动画画面往“写实”方向拉导致线条变形、颜色浊化而 APISR 针对动画作画机制做了退化建模能让输出保持二次元的干净色块和流畅线条。我用的版本里主要模型是个轻量级网络对每帧做端到端的超分。它能处理的不止是放大还包括压缩伪影修复和部分去噪。它在低分辨率输入上的鲁棒性明显好过老一代 GAN 超分模型。最方便的是社区有导出过 ONNX 的版本我后面就是靠这个接入 Vulkan 推理的。1.4 Vulkan 在整个流程中的真实分工Vulkan 这个点很多人一看就懵觉得跟超分工具不搭。实际上我在系统里用了两处 Vulkan第一处也是核心是把 APISR 模型转成 ONNX 后用 ONNX Runtime 的 Vulkan 执行提供程序EP跑推理。这样能调用 GPU 算力同时避开某一家独大的计算生态。无论你是独立显卡还是核显只要驱动支持 Vulkan 1.2就能跑得起来。第二处是帧格式转换和颜色空间处理。解码器输出的通常不是模型想要的 RGB 布局编码器需要的也可能不是模型输出的格式。这些逐像素的转换我用 Vulkan compute shader 来做比 CPU 快很多也把解码、推理、编码三段之间彻底解耦。选 Vulkan 而不是其他地方还有一个很现实的原因老机器和混合显卡笔记本对这个 API 的支持已经相当完整而且 Vulkan 的跨厂商兼容性比某些私有方案好太多。我要把工具给我爸那台老电脑用不可能要求他换显卡。2. 开发环境与整体架构设计2.1 开发环境准备动手之前把环境列清楚可以少走两周弯路。我的配置参考如下操作系统Windows 10/11 64位开发工具任意支持 .NET 8 的 IDE.NET SDK8.0 或更高运行时依赖FFmpeg 7.x 可执行文件、Python 3.10 以上仅用于导出模型、Vulkan 驱动需支持 1.2NuGet 包OnnxRuntime用它的 Vulkan EP、System.IO.Pipelines处理帧流、System.Threading.Channels任务队列显卡这边我主要在一张 8GB 显存的退役中端卡上调试也在核显笔记本上跑通过。Vulkan EP 不挑厂商这是省心的地方但也意味着你不能假设所有驱动行为一致。2.2 整体架构和任务数据流工具不能把整个视频一次性塞进显存只能一帧一帧走管线。整体流程是WinForms 负责接收用户拖拽的文件和参数设置。后台任务调度器从队列里取出一个视频启动 FFmpeg 解码子进程。FFmpeg 把视频帧以原始 RGBA 格式管道输出。C# 端把 RGBA 帧交给 Vulkan compute shader做色彩空间转换和必要的裁剪。转换后的帧进入 APISR 的 ONNX 模型推理得到超高分辨率帧。输出帧再经过一次 Vulkan 后处理转为编码器需要的格式。启动第二个 FFmpeg 子进程等待编码器合成视频。完成之后关闭进程拷贝音轨和字幕输出最终文件。用两个独立 FFmpeg 子进程而不是在同一个进程内做解码编码的另一个理由是隔离崩溃风险。GPU 驱动偶尔会崩如果解码、推理、编码全在一个进程里一次异常就会连 UI 一起带走。分开之后即使推理进程挂了WinForms 界面还能弹出提示、保留任务状态。架构上我还加了一层任务持久化。每次开始处理前把任务信息写入一个 JSON 文件程序崩溃重启后能自动恢复未完成的任务。视频处理动辄几十分钟没有这个机制崩一次就是白等。2.3 模块接口设计为了让项目好扩展我把核心逻辑拆成几个模块。初始版本不需要过度设计但至少要定义清楚边界。public interface IVideoSuperResolution { Taskstring ProcessAsync(string inputPath, SuperResOptions options, CancellationToken ct); event EventHandlerProgressEventArgs ProgressChanged; } public sealed record SuperResOptions( int ScaleFactor, int TileSize, float DenoiseStrength, string EncoderCodec, int EncoderCrf);IVideoSuperResolution对 WinForms 层只暴露一个异步方法和一个进度事件。UI 不需要知道内部到底是用 ONNX Runtime 还是 Vulkan EP也不需要管 FFmpeg 参数。这个接口隔离后来帮我省了大事因为调试过程中我换过三次模型导出方式界面一行代码没改。2.4 任务队列实现WinForms 的消息循环不能阻塞所以所有处理都要放到后台任务里。我用 .NET 的ChannelT做了一个简单队列生产者是 UI 层的“添加任务”消费者是一个常驻的后台工作线程。每完成一个任务就从队列里取出下一个。取消用CancellationTokenSource控制用户点击“停止”时会先让解码进程退出再等 GPU 释放资源。这里有个经验不要让任务线程池无边增长。视频编码是很重的事情同时跑两个任务会把显存和磁盘IO吃满反而比串行更慢。我最后限制成一次只处理一个视频其他任务排队反而稳定很多。3. 核心环节实现从视频帧到AI增强帧3.1 用 FFmpeg 解出干净原始帧解码这部分我用的是 FFmpeg 子进程输出格式选择原始 RGB 数据流。命令大致是这样ffmpeg -i input.mkv -an -f rawvideo -pix_fmt rgba -vsync 0 -threads 4 -注意这里故意把音频去掉后面再单独处理。-pix_fmt rgba是为了让 C# 端拿到的数据不需要猜通道排列。-vsync 0避免因为帧率不均导致丢帧。C# 这边读取子进程标准输出我用的不是一次性读入而是用Stream.ReadAsync配合固定长度的帧缓冲循环using var process Process.Start(psi); var frameSize width * height * 4; var buffer new byte[frameSize]; while (await process.StandardOutput.BaseStream.ReadExactlyAsync(buffer, ct)) { var frame new RgbaFrame(width, height, buffer); await _frameQueue.Writer.WriteAsync(frame, ct); }这样做的核心原因是一整段视频的原始帧数据量大得惊人几十个GB都是常事绝对不能存成中间文件。用管道逐帧读进来推理完一帧丢一帧磁盘占用只取决于最终成品。3.2 APISR 模型准备导出成 ONNXAPISR 的本体是 PyTorch 模型直接用 Python 调用很方便但我想接入 C# 和 Vulkan EP就必须先转成 ONNX。导出脚本大致长这样import torch from apisr import APISR model APISR.from_pretrained(weights/apisr_base, scale2) model.eval() # 固定输入尺寸目标输入是 540x960输出 1080x1920 dummy torch.randn(1, 3, 540, 960) torch.onnx.export( model, dummy, apisr_2x.onnx, opset_version17, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}} )我建议你导出时就把输入分辨率固定下来不要天真地打开所有动态轴。为什么ONNX Runtime 的 Vulkan EP 对动态 shape 的支持还非常有限动态尺寸经常会触发算子的 CPU 回退速度瞬间掉到不可用。我最终的做法是预处理阶段先把原始帧等比缩放到模型最合适的输入尺寸推理后输出再缩放到目标分辨率。另外如果你主要做 4x 超分就直接导出 scale4 的模型比用 2x 连续跑两次效果好而且耗时更短。APISR 的退化建模是一次性完成的二次超分没有额外收益。3.3 推理运行时ONNX Runtime Vulkan EP在 C# 里加载 ONNX 模型并指定 Vulkan EP核心代码很简单var options new SessionOptions(); options.AppendExecutionProvider(Vulkan); options.SetSessionConfigEntry(session.intra_op.allow_spinning, 0); options.SetSessionConfigEntry(session.set_denormalized_tensor, 0); using var session new InferenceSession(onnxPath, options); using var inputTensor new DenseTensorfloat(new[] { 1, 3, inputHeight, inputWidth }); using var results session.Run(new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, inputTensor) }); var output results.First().AsTensorfloat();Vulkan EP 的一个特点是要自己注意输入数据的张量布局。DenseTensor 默认按 NCHW 排列和 APISR 导出的输入一致所以这里没问题。如果你手头模型的输入是 NHWC就需要额外转换。这里我再多说一句Vulkan EP 不是万能的某些算子如果没有对应的 Vulkan 实现ONNX Runtime 会悄悄把这些算子丢回 CPU。最后画面能算对但速度可能只有几个 FPS。我排查过一次发现模型里的某个自定义上采样算子导致了这种回退。解决办法是换用官方可导出的模型配置或者在导出时把风险算子替换成等价的常规算子。性能异常时你务必检查日志或打开 profiling。3.4 后处理和编码颜色空间与格式转换模型输出的是浮点的 RGB 图而编码器需要整型的 YUV 4:2:0这中间绝对不能直接强转。最典型的问题是“颜色范围”视频编码常用有限动态范围16-235而模型输出往往是全范围0-255。如果你把全范围数据直接塞进按有限范围解码的播放器画面会发灰黑色不黑白色不白。我用 Vulkan compute shader 写了一个后处理核一边做 RGB 到 YUV 转换一边做范围映射。核心片段如下#version 450 layout(local_size_x 16, local_size_y 16) in; layout(set 0, binding 0, rgba32f) uniform readonly image2D srcImg; layout(set 0, binding 1, rg8) uniform writeonly image2D dstImg; void main() { ivec2 p ivec2(gl_GlobalInvocationID.xy); vec4 rgba imageLoad(srcImg, p); vec3 rgb clamp(rgba.rgb, 0.0, 1.0); float y dot(rgb, vec3(0.299, 0.587, 0.114)); float u dot(rgb, vec3(-0.169, -0.331, 0.500)) 0.5; float v dot(rgb, vec3(0.500, -0.419, -0.081)) 0.5; y clamp(y, 16.0 / 255.0, 235.0 / 255.0); u clamp(u, 16.0 / 255.0, 240.0 / 255.0); v clamp(v, 16.0 / 255.0, 240.0 / 255.0); imageStore(dstImg, p, vec4(y, u, v, 1.0)); }转换完的采样数据我通过管道喂给编码器。编码 FFmpeg 命令我用的是ffmpeg -f rawvideo -pix_fmt yuv420p -s 1920x1080 -i pipe:0 \ -i audio.m4a -c:v libx264 -crf 17 -preset slow \ -c:a copy -movflags faststart output.mp4CRF 17 对动画来说已经足够。如果你存储不紧张16 到 18 之间画质差异很小但文件体积差异明显。我建议统一用 17省心。4. 实测效果、参数选择与性能调优4.1 测试样张与评价方法我挑了一段当年DVD里截下来的老动画时长约五分钟480p画面有很多细密线条和半透明特效层。放大到 1080p 之后我拿了三个版本对比普通 Lanczos 拉伸、APISR 2x、以及 APISR 2x 加轻量去噪后的结果。主观结论很清楚Lanczos 放大后画面边缘有白边和锯齿远处建筑线条糊成一团APISR 版本线条边缘干净头发丝能分出层次色块区域非常平整保留了明显的动漫质感。唯一需要警惕的是APISR 也会放大片源本身的胶片噪点所以 4x 高倍率下要配合降噪参数。4.2 放大倍数与去噪强度怎么搭配APISR 给了几个可调参数最影响观感的是去噪强度和放大倍数。我整理了一个参考表场景放大倍数去噪强度观察结果干净DVD转1080p2x低细节保留最好线条锐利带明显色块的VCD转720p2x中色块基本消除背景干净老电视台采集转4K4x高需要强压噪点但可能损失细发丝原生720p动画转4K2x/4x关本来画质好不要过度处理我的经验是能不开高去噪就不要开。APISR 的去噪本质是牺牲高频细节对“压缩损伤严重”的片源很有效但对画质还行的老片是负优化。建议先用低去噪跑一段 30 秒片段放大后在明显平面区域看色块有没有残留再决定要不要上调。4.3 tile size 与显存之间的取舍APISR 推理按整帧计算的话1080p 输入做 2x 输出峰值显存能到 3GB 左右4x 更是直接超过 8GB。老机器根本吃不消所以必须做分块推理也就是 tile。TileSize是推理时正方形块的边长。我实测下来256 的 tile任何显卡都能跑但是每帧采样次数多速度慢且块与块之间要留重叠避免接缝。512 的 tile在 8GB 显存上比较舒服速度也快大部分片源我都用这个。1024 的 tile显存要求 10GB 以上收益主要在减少了 tile 切换开销。重叠像素我设成 tile 尺寸的 1/8也就是 512 的 tile 之间重叠 64 像素。推理完成后中心区域保留重叠区域做羽化加权平均。这样画面基本看不到分块痕迹。4.4 性能数据与优化记录在同一台机器上我记录了三种配置处理五分钟片段的时间配置平均处理帧率峰值显存总耗时CPU 推理1.2 FPS0约 210 分钟Vulkan EPtile5129.8 FPS5.1GB约 25 分钟Vulkan EPtile102411.5 FPS9.6GB约 22 分钟说明一下Vulkan EP 下 CPU 几乎是闲着瓶颈基本在 FFmpeg 解码和管道读写上。我后来把解码线程数从 2 提到 4帧率涨了半帧继续提到 8 反而下降因为 CPU 管线切来切去浪费了时间。另一个有效优化是把推理线程的允许自旋打开。session.intra_op.allow_spinning设为 1 可以减少线程唤醒延迟对视频这种连续帧推理很友好。但要注意会让 CPU 在短等待时钟忙转笔记本上会略微费电。5. 常见问题与踩坑实录5.1 颜色偏绿、偏灰的老问题超分工具十有八九会遇到颜色问题。我第一次跑通时出来的视频整体偏绿暗部发灰像是戴了层半透明滤镜。排查了半天问题根本不是超分模型而是 FFmpeg 管道输出 RGBA 时默认使用了有限范围标记但 Vulkan shader 里又按时全范围做了钳制一来一去颜色空间彻底乱了。解决方法是统一约定解码端用-vf scalein_rangefull:out_rangefull把数据强制转为全范围推理后编码端再显式加-colorspace bt709 -color_range limited。所有环节都写明范围就不会再有意外。5.2 显存不足与 Vulkan 崩溃用 1024 tile 跑 4x 时我的 8GB 显卡一进入激烈运动场景就掉显存轻则报错重则驱动重置。这其实是两个问题一是峰值显存估算不足模型推理时中间张量峰值比输出帧大得多二是 Windows 的 GPU 共享内存机制让 Vulkan 能借用内存但一旦超过物理显存速度会断崖下降。我最终策略是显存低于 8GB 时强制切换 512 tile并在每次推理结束后调用Flush和Dispose把中间张量及时释放。代码里还加了异常捕获如果遇到OutOfMemoryException自动把当前帧重新用 256 tile 处理一次而不是整个任务崩溃重来。5.3 音轨和字幕消失我第一次测试时只处理了视频流输出文件没声音当时还觉得正常。后来想通超分只是换画面音轨完全没必要重新编码。正确做法是先用ffmpeg -i input -map 0:a -vn -c copy audio.m4a把音轨抽出来字幕则抽取成 ass 或 srt。超分后的视频是纯视频轨最后封装的时候再把音轨和字幕 mux 进去。这里有个坑如果原视频是 MKV 封装且包含多条音轨和章节信息-map 0时一定要仔细指定轨道。我一开始用默认的-map 0:a把多轨全塞进去结果目标文件的音轨顺序乱了。现在我会先跑ffprobe拿到轨道路径再一一对应。5.4 超分后出现“塑料感”和线条断裂参数调得太过头会出这个问题。有一阵我把去噪强度拉到接近最大结果背景是干净了但人物脸上的光影边界被磨得像橡皮泥描线也出现了断点。这个现象很典型模型把原本属于“作画细节”的高频信息当成了噪声。遇到这种情况先降低去噪强度如果还不满意就只放大到 2x 而不是 4x。另外不同导演的作画风格差异很大一个权重包不可能通吃。我后来维护了一个“风格预设”文件针对手绘质感和赛璐璐质感的片源分别存了两套参数。5.5 驱动兼容性的奇怪表现Vulkan EP 在 A 卡上跑得好好的换到核显或旧本子就报初始化失败。我查了一圈原来是这些机器自带的驱动版本太旧Vulkan 版本停留在 1.1而 ONNX Runtime 的 Vulkan EP 要求 1.2。这不是代码问题是驱动问题。解决办法是在启动时做一次能力检测private bool IsVulkanAvailable() { try { using var probe new InferenceSession(probingModel); probe.AppendExecutionProvider(Vulkan); return true; } catch { return false; } }检测通过就启用 Vulkan不通就直接回退到 CPU 计划路径。虽然慢但至少不会闪退。我还顺便在界面上给用户标出“当前使用 Vulkan 加速”或“当前使用 CPU 回退”避免误以为程序卡死。写到这里我突然想到最后一条很实用的体会做这种工具一定不要一开始就追求流程全自动。我建议你先把单帧超分跑通再去接视频管线先用命令行跑通一帧再接 WinForms。顺序反了你会同时调试 UI、管道、模型任何一个环节出错都难以定位。我自己就是先导出一张测试图手动比对了颜色和清晰度之后再开始整合整个管线的。如果你也想给家里的老动画换一遍新画质从 APISR 导出模型开始用两天搭一个最小闭环大概率不会后悔。看到孩子对着 4K 修复后的画面兴奋地喊“好清晰”那一刻你会觉得所有折腾都值了。