ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

用C# WinForms与APISR构建老动画视频超分工具:Vulkan加速实战

用C# WinForms与APISR构建老动画视频超分工具:Vulkan加速实战

这两年陪家里小朋友翻老动画,我遇到的最大尴尬是:孩子们看惯了高清设备,对老片源的反应出奇一致——“怎么这么糊?”。老动画不是故事不好,而是低分辨率、压缩损伤、隔行扫描这些历史包袱,在如今的 4K 屏上被放得干干净净。与其反复解释,不如直接动手。我给自己做了一个视频超分工具:界面用 C# WinForms 写,推理用 APISR 这套动画专用超分模型,底层的 GPU 加速交给 Vulkan,整个过程就是“把视频拆成帧、逐帧超分、再合成新视频”。这套工具不追求通用,目标很单一:把老动画以尽量高的画质带给孩子看。这篇文章就是我对这个项目的完整复盘,从架构设计到逐行实现,再到踩过的各种坑,适合想给私人片库做画质翻新的人,也适合想了解 WinForms 怎么结合现代推理引擎的桌面开发者。

1. 老动画为什么一到新屏幕就“糊”,以及 APISR 凭什么更合适

1.1 老片源的退化链,不是单纯“分辨率低”

很多人以为老动画模糊就是因为分辨率低,其实远不止这一层。一部二三十年前的动画,从母带开始就经历了一条缓慢但持续的“损伤链”:胶片时代的光学转印损失,模拟录像带时代的亮度串扰和色度降采样,DVD 时代的 MPEG-2 压缩伪影,再到网络传输阶段的二次重编码。每一环都在削弱画面里本该清晰的线条边缘和色块边界。到我们手里时,它已经不光是“像素不够多”,而是“信息本身已经被污染了”。

这意味着视频超分不能只做一件“放大”的事,还得做一件“去伪影”的事。如果只用最简单的插值放大,锯齿和块状伪影也会跟着被放大,观感反而更差。如果直接套用照片超分模型,又容易把动画里平坦的色块修出莫名其妙的纹理,线条也会变得油腻。我的理解是:老动画修复,首先要把“退化”这件事拆清楚,再做针对性处理。

1.2 APISR 与通用超分的不同:它把压缩损失也当成一类退化

APISR 之所以适合这个项目,核心在于它的设计思路。它不是一个单一模型,而是一整套面向动漫影像的超分方案,常见的有 2x、4x 规格,内部结构有卷积类也有 Transformer 类。我个人的使用感受是它特别处理了动画场景里最常见的几类损伤:线条周围的振铃、平坦区域的颜色带、低码率导致的块状伪影。它把这些“压缩损失”作为退化模型的一部分来训练,而不是像很多通用超分模型那样只盯着“缩小到模糊”这一种退化。

拿实际画面来说,老动画的黑色描边是非常敏感的。通用模型可能会把边缘还原得“圆润光滑”,但 APISR 的处理更接近“重新描边”的效果,线条干净,色块之间的边界清楚。这一点对动画修复非常关键。当然,它也有自己的脾气:如果源视频本身噪点大、码率极低,它会把噪点也当成有效信息放大,所以后面我会专门讲哪些素材不适合直接超分。

1.3 为什么选 Vulkan,而不是直接上 CUDA/DirectML

选 Vulkan 做推理加速,是被现实情况逼出来的。我希望这个工具能在我手头的机器上跑,也希望能借给朋友用。朋友那台机器不是最新款独显,如果绑死某一家的专属计算平台,工具对硬件的要求就太苛刻了。Vulkan 的优势是跨厂商:主流独显、集成显卡都有驱动支持,只要显卡驱动不太老,基本都能用。这意味着同一个工具在不同机器上都能开 GPU 加速,而不是“没某家显卡就只能干瞪眼”。

另一个原因是 ONNX Runtime 提供了 Vulkan 执行后端(Execution Provider),我可以把 APISR 模型导出成 ONNX 格式,然后在 C# 里直接调用 ONNX Runtime 的 .NET 绑定跑推理,不用自己写 Vulkan 的繁琐管线,也不用碰 C++ 层。这套组合很舒服:上层是 WinForms 负责交互,中间层是 .NET 调用推理引擎,底层是 Vulkan 去和 GPU 打交道。对比下来,DirectML 在 Windows 上也不错,但算子覆盖和不同显卡的表现差异更大一些;Vulkan 的兼容面更广,而且跨平台潜力更好。

2. 工具的整体架构:WinForms 只负责界面,核心是三段流水线

2.1 数据流:拆帧 → 推理 → 合成,三条队列解耦

这个工具虽然外表是个 WinForms 窗口,但它本质上是一个“流水线作业系统”。我把它拆成三个阶段:拆帧、推理、合成。拆帧阶段用 FFmpeg 把视频转成 PNG 序列;推理阶段对每一帧做超分;合成阶段再把超分后的 PNG 序列和原始音频合并成新的视频文件。三个阶段各自跑在独立的任务里,中间用队列衔接,上下游速度不匹配时依靠队列缓冲。

为什么要这样设计?因为视频超分是一个漫长的任务,一集 24 分钟的动画拆出来可能有两万多帧。如果做成“拆一帧、处理一帧、写一帧”的串行流程,任何一步慢下来都会拖垮整体。三队列解耦之后,三段可以并行:FFmpeg 拆帧只管往队列里塞,推理线程只管取帧推理,合成线程只等在输出端收结果。我的实际经验是,拆帧通常快于推理,而推理通常快于编码,所以队列能把三个速度差异消化掉,整体吞吐明显比串行方案高。

2.2 线程模型与进度回传:别让 UI 卡死

WinForms 的单线程 UI 模型很出名,一旦在 UI 线程上做耗时操作,窗口就会转圈、无响应,用户的第一反应是“程序死了”。我的做法是整个流水线都放到后台任务里跑,UI 只保留一个状态机。开始按钮触发一个后台任务,把所有阶段串联起来;进度条和当前预览图通过事件或者委托回传。这里有个容易踩的坑:后台线程不能直接改控件属性,必须用Invoke切换到 UI 线程,或者使用Progress<T>这类会帮我跨线程传值的机制。我刚开始偷懒直接改了控件,很快就遇到了随机崩溃,后来老实改成消息回传,一次都没再出问题。

取消和暂停我也做了。取消不是简单杀进程,而是设置一个 CancellationToken,让当前帧处理完后再检查标记、有序退出。暂停则用了一个门闩信号。这些细节看起来小,但实际处理几万帧的过程中,用户一定会想“先暂停看一下效果”或者“取消重新配参数”。没有这两个功能工具基本没法日常用。

2.3 进程封装 FFmpeg:用 -progress 读进度,而不是解析日志文本

FFmpeg 是个独立的命令行程序,C# 里启动它很容易,难的是拿到可靠进度。很多网上示例是重定向输出流,然后逐行解析文本日志,这其实很脆弱,因为 FFmpeg 的日志格式会随着版本变动,而且编码进度和拆帧进度混杂在一起,解析起来又累又容易出错。我改用-progress pipe:1参数,让 FFmpeg 输出结构化的 key=value 进度数据,比如out_time_ms、frame、progress这些字段,C# 那边逐行读取、按字段更新进度。这套方式稳定得多,编码器不同也基本不受影响。

启动 FFmpeg 进程时还要注意一件事:参数尽量用数组形式传,不要拼成一条长字符串。视频路径里经常有空格、中文,甚至特殊字符,拼字符串很容易出幺蛾子。使用参数数组能避免命令行转义问题,这是我在第一次遇到中文路径处理失败后长记性的地方。

3. 逐模块实现:拆帧、预处理、分块推理、合成

3.1 拆帧:无损 PNG,别用 JPEG 偷懒

拆帧这一步直接决定画质上限。我见过不少人的超分方案在拆帧时用-q:v 2导 JPEG,理由是“省空间”。这等于在超分之前先做了一次有损压缩,等于给本就模糊的老动画又添了一层伤。我的做法是用 PNG 无损序列,带透明通道都不需要,直接 RGB 三通道就够了。拆帧命令大致是这个样子:

ffmpeg -i input.mkv -vsync 0 -frame_pts true -start_number 0 frames/%08d.png

-vsync 0的作用是不要自动调整帧率,保证每一帧都原样落盘。老动画里有大量重复帧和静止画面,如果 FFmpeg 自作聪明地做帧率转换,输出帧和原时间轴就对不上了,后面合成时会出灾难性的音画不同步。我再强调一次:这个话题上“精确”比“省空间”重要得多。

3.2 预处理与后处理:像素顺序和归一化是绕不开的细节

APISR 模型在 ONNX 里输入输出通常是 NCHW 格式,数值范围是归一化到 [-1, 1] 或者 [0, 1] 的张量,颜色通道顺序是 RGB 还是 BGR 也取决于导出时的预处理约定。我从 PNG 读出的帧通常已经是 RGB,但为了保险,我会在模型配置里写明颜色顺序和均值/方差,而不是盲目照搬别人的代码。

我的统一处理流程是:读取帧 → 转为浮点张量 → 除以 255 → 减去均值 → 除以方差 → 送入模型。模型输出后反过来:乘方差、加均值、乘 255、四舍五入截断到 [0,255],再转成字节数组。这个流程听上去繁琐,但漏掉任何一步,画面就会发灰、偏色或者过曝。实际调试时,我一般会先拿一张固定图片跑一次,对比输入原图和输出图,确认颜色和亮度变化正常,再启动整集长跑。

3.3 分块推理:显存不够时的 tile 拼接与边缘融合

超分模型的输入尺寸有限,显存也有限,不能把一整帧 1920x1080 直接丢进去超分成 3840x2160。所以我把一帧切成一堆小块,分别推理后再拼回去。这里最核心的两个参数是 tile 大小和 overlap。tile 越大,推理效率越高,但显存压力也越大;overlap 越大,块与块之间的连续性越自然,但重复计算也越多。

拼接时如果直接硬切硬拼,Tile 边界处经常会出现一道肉眼可见的接缝,因为模型对每个块的边缘处理不会和相邻块完全一致。解决办法有两个,一个是重叠区域留出来,另一个是在重叠区做渐变融合,让两边的像素按权重平滑过渡。我做了一个简单的重叠区线性 alpha 混合:中心区域完全信任当前块的推理结果,边缘区域逐步过渡到相邻块的结果。步骤如下:

// 伪代码:重叠区线性融合 for (int y = 0; y < tileH; y++) for (int x = 0; x < tileW; x++) { float alphaX = CalcOverlapAlpha(x, tileW, overlap); float alphaY = CalcOverlapAlpha(y, tileH, overlap); float alpha = Math.Min(alphaX, alphaY); outputFrame[gy, gx] = alpha * currentTile[gy, gx] + (1 - alpha) * previousBlend[gy, gx]; }

这个方案不算完美,但对线条类内容非常有效。我实测下来,只要 overlap 给到 tile 尺寸的 1/8 到 1/4,肉眼几乎看不出接缝。

3.4 合帧与音频保留:避免二次有损压缩,时间轴别搞丢

推理完成后的 PNG 序列,我统一交给 FFmpeg 合成。视频编码参数我一般用 CRF 模式,而不是固定的码率,这样能保证画质优先。下面是一条实际可用的合成命令:

ffmpeg -framerate 23.976 -i frames/%08d.png -i audio.m4a \ -map 0:v:0 -map 1:a:0 \ -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p \ -c:a copy -shortest output.mkv

音频这里有一个重要原则:必须保留原始音频,不要重新编码。老动画的音频轨如果本身是 AAC 或者 AC3,直接 copy 流过去,不改动任何东西。重新编码不仅耗时,而且纯属多此一举。-shortest参数用来防止视频轨比音频长出一截,导致片尾黑屏几秒钟。还有一个关键点:拆帧时记录的帧率必须原样传回给编码器,动画经常是 23.976、25、29.97 这种非整数帧率,搞错了就是音画不同步。

4. 实测效果与瓶颈:这个工具能救哪些片子,救不了哪些

4.1 三类片源的表现差异

我拿三类片源做了验证:DVD 画质的老动画、数码重制版的 720p 动画、某平台低码率压过的动画。结果差异很大。DVD 源画面本身模糊但有“原始信息”,超分出来后线条变清晰,观感提升明显,这是最理想的素材。数码重制版本身已经比较干净,超分能提升一些锐度,但提升幅度有限,偶尔还会把轻微的噪点放大。低码率流媒体源是最难处理的,画面上已经存在明显的块状伪影和色带,超分模型会把一些块状结构误解为内容细节,出来的效果参差不齐。

所以我一向建议:先检查源视频的码率和画面质量,如果源本身已经一塌糊涂,超分不是万能药。我甚至会给工具加一个“源质量预检”提示,用平均码率和分辨率简单推算一下,给用户一个预估建议,而不是让工具对任何视频都无脑执行。

4.2 耗时与显存消耗:一个可以自己估算的公式

推理耗时主要取决于模型规格、分辨率和显卡性能,几乎没有固定数。但有一点可以量化:一帧耗时乘以总帧数,就是总推理时间。比如一集动画拆出 21600 帧,单帧处理 0.12 秒,那么仅仅推理就需要 43 分钟,拆帧和编码还要额外算。如何估算显存呢?我大致按这个公式算:单个 tile 的分辨率 × 3 通道 × 4 字节(FP32)× 批量数,再乘上中间层的膨胀系数。用 512x512 的 tile 跑 4x 模型,中间张量大概会膨胀 10 到 20 倍,所以单帧临时显存可能在 1GB 到 3GB 之间。实际跑下来,大部分中端独显和部分大显存核显都能承受,但如果是老核显就要把 tile 降到 256。

4.3 我踩过的几个坑:算子缺失、色带、接缝、内存泄漏

ONNX Runtime 的 Vulkan 后端不是对所有模型原样兼容的。APISR 的一些变体里包含的算子,在 Vulkan 执行后端上支持并不完整,我一开始遇到直接报错“无法找到实现”。我的解决思路不是修改模型,而是把模型导出时做算子替换,把不支持的结构改写成等价的支持结构。这方面花了一些时间,但也因此理解了为什么很多人最终会转 DirectML 兜底。如果实在改不动模型,我也会在工具里留一个执行后端的切换选项,Vulkan 不行就切 DirectML,宁可多一层兼容,也不要让用户干瞪眼。

FP16 也是个坑。为了省显存,我试过把模型转成 FP16 精度跑,结果动画里大面积平坦色块出现了明显的色带。原因很简单:动画色块颜色变化少,一旦数值精度不够,细微的渐变被量化成肉眼可见的台阶。所以我现在默认用 FP32 推理,除非显卡显存实在紧张,才考虑 FP16 并接受轻微画质损失。

内存泄漏是我踩得最深的一次。跑整集任务时,我观察到内存随时间线性上涨,最后直接爆掉。排查了很久,发现问题出在队列里堆积的位图对象没有被及时释放。WinForms 里读取的 Bitmap 用完后要么手动 Dispose,要么用 using 块,不能只靠垃圾回收。几万帧累积下来,哪怕每帧只漏一点点,最后也是天文数字。修复之后,长任务的驻留内存稳定在一个固定水平。

5. 调参建议与时间一致性处理

5.1 我常用的参数组合表

用这套工具做了大量实测之后,我总结出几组比较稳妥的参数组合,供大家参考:

片源类型超分倍率tile 尺寸overlap编码参数说明
DVD 老动画2x 或 4x51264CRF 18,slow线条清晰,推荐 2x 保留自然感
数码重制 720p2x51264CRF 18,slow提升有限,不要盲目上 4x
低码率流媒体2x25632CRF 17,slow建议先做轻降噪,否则伪影放大
显存紧张机器2x25632CRF 20,medium以稳定为主,画质让位给可用性

这里面有个容易产生的误解:倍率越大越好。我在 4x 模式下确实能看到更多细节,但同时也把源视频里的噪声、抖动都放大了。多数情况下 2x 比 4x 更“耐看”,尤其当源视频本身干净度一般时。如果追求极致,我的建议是分两步走:先做一个轻量的源视频预处理,再做超分,而不是一步到位拉满倍率。

5.2 闪烁问题:单帧超分的天然缺陷,以及缓解思路

视频超分和单图超分的一个本质区别是:视频有连续时间轴。单帧逐帧超分时,每一帧都是独立推理的,模型对同一画面的细微判断可能不同,比如线条边缘的金色润色时有时无,或者平坦区域轻微明暗波动,在播放时就会表现为“闪烁”。这是逐帧超分方案的天然缺陷,APISR 也不能完全免疫。

缓解思路我试过几种。第一种是简单且有效的:降低单帧处理的激进度,比如用更小的倍率、更保守的模型版本,闪烁会明显减轻。第二种是后处理层面做时间平滑,对相邻帧之间的像素做轻量融合,但这要小心产生拖影。第三种是把视频的 GOP 结构考虑进去,只对关键帧做超分,中间帧做运动补偿,这相当于引入视频超分算法,复杂度会高很多。我的工具目前停留在第二种,效果在接受范围内。如果有人追求完美时间一致性,恐怕不能只靠“逐帧超分”这个思路解决,得有更专门的处理框架。

5.3 “超分完反而更糊”的排查清单

我遇到过不少次“怎么超完更糊了”的情况,大部分原因其实很固定。排查顺序我总结成一张清单:

  • 源视频质量太差:先看源码率和分辨率,如果源已经满是压缩伪影,超分只会放大,要先用轻降噪再超分。
  • 倍率过大:4x 未必比 2x 好,试试降回 2x。
  • 预处理顺序错了:颜色顺序、归一化参数错了,画面会出现灰蒙蒙的情况。
  • 编码参数太激进:CRF 值太高(比如大于 22)或者用了 too fast 的 preset,细节会被抹掉。
  • 重复有损压缩:拆帧用了 JPEG 或者中间文件再压过一次,画质上限已经被压低了。
  • 播放器没关锐化:有些播放器默认带后处理滤镜,会把超分结果再锐化一遍,看着反而脏。

顺着这张清单查,绝大多数“更糊”都有明确答案。

6. 最后想跟同路人分享的三件事

6.1 这个工具现在最合适的定位

做完这个工具之后,我对它的定位越来越清楚:它不是要给所有老视频做“医学级修复”,而是为私人片库提供一条低成本翻新路径。适合那些手里有大量老动画资源、希望在新设备上获得更好观感的人。它真正强在流程完整:WinForms 给你一个可操作的面板,APISR 负责动画内容本身的超分质量,Vulkan 后端让硬件门槛低了一大截。作为个人项目,它已经完成了最初的目标。

6.2 后续可以扩展的方向:字幕处理、去隔行、序列帧

老动画里还有两个绕不开的东西:字幕和隔行扫描。很多老资源是软字幕,超分过程中字幕本身也会被放大,但字幕边缘和画面边缘的特性完全不同,可能需要单独处理或者用字幕渲染层替代。隔行扫描则是另一个历史遗留问题,老 DVD 素材里常见的梳状波纹,需要在超分前做去隔行处理,否则模型会把交错条纹当细节强化。这两个方向我还没有完全解决,但已经有了处理框架,只是优先级排在后面。

6.3 把修复后视频交给孩子前,播放器端的最后一公里

最后的提醒可能最容易被忽视:超分出来的视频质量再好,如果播放器里的锐化、降噪、动态对比度等后处理滤镜还开着,观感会被打回原形。我给孩子播放时,通常会把播放器的画面增强选项全部关掉,让超分结果原汁原味地呈现。实际体验下来,修复过的老动画在新屏幕上确实不再“劝退”。看到孩子安静地把一集老动画看完,没有抱怨画质,我觉得这套工具做得值。

如果你也在捣鼓老片库翻新,我的建议是别一上来追求完美:先跑通整条流水线,拿两三集做参数试验,再批量处理。每一步的坑都会教你不少东西,而孩子们真正需要的,其实是一个不那么糊、能看清故事情节的老朋友。

RELATED READING

延伸阅读

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