ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

EMA-VFI视频帧插值代码深度拆解:光流估计与中间帧合成

EMA-VFI视频帧插值代码深度拆解:光流估计与中间帧合成 上两篇我们把视频帧插值里基于核和基于光流的经典思路过了一遍今天这篇安排的是系列第三篇ema-vfi代码拆解和分析。先说结论ema-vfi在视频帧插值这个方向里属于把“显式运动估计”这条路走得非常扎实的代表作它不像某些端到端网络那样直接回归一个中间帧的像素而是先估计相邻帧之间的双向光流再把光流投影到中间时刻最后结合两帧原图和运动信息一起合成结果。我第一次完整跑通这套代码时最大的感受就是结构清晰注释不多但一旦把每一行都看明白再去回看IFRNet、RIFE这些后续方法会轻松很多。所以这篇博文不打算泛泛地讲论文公式而是直接对着代码仓库来拆把训练、推理、网络结构、踩坑过程全部过一遍。不管你是刚接触视频帧插值还是已经跑过一些项目想在原理上再进一步这篇都应该能帮你省下不少自己啃源码的时间。1. ema-vfi解决什么问题先理解原理再动代码1.1 视频帧插值的两条路线视频帧插值要解决的事情说起来很朴素给两帧相邻画面把中间那一瞬间的帧给补出来。比如一段每秒30帧的视频你希望它变成每秒60帧那就要在每两帧中间插入一帧。但这件事的难点在于真实世界里物体是运动的有的地方被前景挡住有的地方背景露出来仅仅在两帧像素之间做线性混合运动边缘就会出现明显的鬼影。所以早期的方法大多走的是两条路一条是直接预测一个卷积核或者一个权重图把两帧的像素做流场式拼接代表方法有AdaCoF、softmax splatting这类另一条是显式估计运动信息先在相邻帧之间算光流再根据光流把像素搬运到中间时刻最后用合成网络补全遮挡区域。ema-vfi属于后者而且它在这条路上的设计非常完整运动估计、运动细化、帧合成三个模块各司其职谁负责什么一目了然。对于学习代码的人来说这种模块化程度高的仓库正是最适合逐段拆解的。1.2 ema-vfi的核心设计运动估计加中间投影ema-vfi的核心可以拆成两个关键词。第一个是“运动估计”也就是从相邻帧里求出每个像素往哪个方向动了多少。第二个是“中间投影”因为我们要补的帧在时间上位于两帧中间所以不能直接用相邻两帧之间的光流去warp而是要把这个光流换算成“从第一帧指向中间时刻”和“从第二帧指向中间时刻”的中间光流。很多人第一次看视频帧插值代码时会被这一步绕晕。其实可以用一个很朴素的例子来理解假设一辆车在画面里从左往右匀速移动第一帧它在x0的位置第二帧它在x10的位置那么中间帧它就应该在x5的位置。如果我们知道两帧之间的总位移是10像素那第一帧指向中间帧的位移就是5像素第二帧指向中间帧的位移则是-5像素。这个“按时间比例折算”的操作就是中间光流投影。ema-vfi在这里做得更细的地方在于它不满足于直接用相邻光流线性折算。因为有遮挡、有非线性运动简单折算出来的中间光流往往有很多错误所以它在代码里加入了一个EMA运动细化模块用两帧的图像特征和光流特征一起对中间光流做多次迭代修正。这也就是仓库中EMAMotion这个类存在的意义。2. 代码仓库整体布局从入口文件开始2.1 目录结构与核心文件ema-vfi的官方代码仓库结构不算复杂但初次接触时还是容易迷路因为训练、测试、模型定义、数据加载各有各的入口。我实际拉下来之后最关心的是这几个文件。仓库根目录下main.py是训练和测试的总入口所有参数都在这里被解析然后根据mode参数决定是进入训练还是测试流程。demo.py是单张图片对推理的脚本适合加载一个训练好的权重输入两张图直接输出中间帧。network目录是核心里面放着EMA-VFI的主网络定义loss.py定义了训练用的损失函数datasets目录处理训练数据集的读取和增强。我建议的学习路线是先看demo.py再看network最后回头看main.py。原因很简单demo.py的调用链最短能让你在几分钟内搞清楚“输入是什么、输出是什么、模型怎么被调用”而main.py里掺杂了大量分布式训练、日志、checkpoint保存的逻辑一上来就盯它容易劝退。2.2 配置文件里需要关注的参数ema-vfi没有单独的yaml配置文件所有超参数都集中在main.py的参数解析部分。第一次看的时候会觉得有点乱但其实只需要抓住几个关键的。数据相关的参数包括data_root指到Vimeo90K或者你自建的数据集目录还有batch_size和num_workers这两个直接影响训练速度和显存占用。训练相关的核心参数是lr、epochs和loss_type其中loss_type决定了使用L1、感知损失还是两者加权。实验记录相关的就是save_path和log_dir训练日志和权重分别写到这两个地方。我最想强调的是train_batch_size很多人刚开始跑这个项目时明明显卡不差却老是被OOM打断多半是batch size设得过大。ema-vfi的特征金字塔和可变形卷积本身就比较吃显存如果用的是12G显存的卡batch size从4开始往上调相对稳妥。3. 核心模块拆解光流估计、EMA更新和帧合成3.1 运动估计模块从相邻光流到中间光流进入network目录之后最先要拆的就是运动估计模块。这部分做的事情是输入相邻两帧输出一组从第一帧指向中间帧、从第二帧指向中间帧的光流。第一步是先做特征提取。代码里使用了一个权重共享的特征金字塔网络两帧图像分别经过这个网络得到多层特征从高分辨率但语义弱的底层特征到低分辨率但语义强的顶层特征一层层下来。做运动估计时要使用多尺度光流因为大位移和小位移在图像上的表现方式不同金字塔结构可以保证网络既能看到全局运动趋势又能保留局部细节。第二步才是真正意义上的光流估计。在这个模块内部会先计算出相邻两帧之间的双向光流也就是frame0指向frame1的flow_01以及frame1指向frame0的flow_10。得到这两个光流之后再通过时间比例因子t进行线性投影得到初始的中间帧光流flow_t0和flow_t1。这里有个小细节值得提一下。线性投影在均匀运动下是精确的但真实场景中绝大多数运动都不是均匀的。所以代码没有把投影结果直接拿来用而是把它作为初始值送入后续的细化模块用迭代更新的方式不断修正。这种“先给一个合理的初值再用网络去refine”的思路在很多低层视觉任务里都有类似设计。3.2 EMA运动细化名字叫EMA实际是迭代修正EMA这个缩写很容易让人联想到指数移动平均这也是不少人最初看代码时困惑的来源。但实际上在当前这个代码里EMA更多的是一种模块命名它所做的事可以理解成“基于误差反馈的迭代光流修正”。细化模块的输入包含三个部分上一轮估计出来的中间光流、两帧图像的特征、以及用光流warp后的特征差异。光流warp在视频帧插值里是一个极其基础的操作本质上就是“根据光流告诉我的偏移量把像素从原图位置搬到新位置上”。在两帧已经对齐的前提下如果光流完全正确那么warp后的特征在两帧之间应该高度一致如果哪里不一致说明那里的光流估算有问题差异图恰好就提供了修正方向。细化模块利用这种差异进行一次或者多次光流修正。代码里最核心的循环就是这样一个反复“计算误差、更新光流”的过程走到最后一步时输出的光流精度已经比初始的线性投影高很多。我在看代码的时候建议你把这个循环用print调出来观察每次迭代前后光流的数值变化会比只看最终结果更有体感。3.3 帧合成与损失计算最后一步怎么把像素拼起来光流修正完成之后下一步就是把两帧图像分别按照中间光流warp到中间时刻得到两个对齐后的候选帧。但两帧各有各的问题第一帧warp过来的图像在被第一帧遮挡住的区域会缺少有效内容第二帧同理。所以合成模块还要估计一个融合权重图决定哪些区域更相信第一帧哪些区域更相信第二帧。这个权重图的估计在代码里是和两帧warp后的特征拼接在一起经过几层卷积得到的。之后用这个权重对两个候选帧做加权求和再经过一个残差卷积网络补全细节最终得到合成的中间帧。损失函数的设置也值得说。ema-vfi的训练损失并不只有最终中间帧和真实中间帧之间的L1距离而是会配合感知损失一起使用。感知损失本质上是用一个预训练的VGG网络提取中间帧和GT的高层特征然后比较这些特征的差异。L1损失过于关注像素级的绝对误差有时候会忽略纹理和结构上的相似性感知损失则更关注“看起来像不像”。两个损失一起用像素准确性和视觉质量都能照顾到。default的配置里通常会给感知损失一个比较小的权重比如0.01到0.05之间。如果你发现训练出来的结果在纹理细节上偏模糊可以尝试稍微提高感知损失的权重但要注意别加得太大否则训练容易不稳定loss会出现肉眼可见的震荡。4. 实操过程从数据准备到训练推理4.1 数据准备与预处理ema-vfi官方默认使用的训练集是Vimeo90K一个包含大量视频片段的数据集每个训练样本是连续7帧中的一个三元组即前后两帧加上中间的真实帧用来监督训练。这个数据集体积不小下载和存放路径都必须和数据加载代码里的逻辑对应上。下载完成之后文件结构大概是这样vimeo90k根目录下有一个sequences文件夹里面按00001/0001/这种层级组织每个视频片段同时提供了tri_trainlist.txt和tri_testlist.txt记录哪些片段用于训练、哪些用于测试。数据集类在读取时会先加载这个索引文件再根据索引拼接出具体的三元组路径。预处理方面训练时会做随机裁剪默认crop size通常是256x256同时会做随机水平翻转和时间顺序翻转。时间顺序翻转的意思是有时候把更早的帧当作frame0有时候把更晚的帧当作frame0这样可以让网络不依赖帧的时序方向增加数据多样性对提升泛化能力很有帮助。实现上非常简单就是每次读取三元组时用random.random()决定要不要反向。我自己在第一次跑的时候踩过一个数据路径的坑官方代码里对Vimeo90K的目录拼接是基于相对路径写死的如果你把数据集放在了别的目录层级下一定要确保代码里拼接出来的路径和实际目录结构完全一致否则会在训练开始前报“file not found”的错误。这类错误一旦出现排查起来倒不算难但很打断节奏。4.2 训练流程命令、显存控制和checkpoint确认数据准备好之后训练的命令其实非常简洁核心就是指定mode为train并传入数据路径和输出路径。启动训练后代码会先打印一批配置信息然后开始逐epoch迭代。训练过程中的一个关键实际问题是显存控制。ema-vfi除了特征金字塔之外还涉及可变形卷积可变形卷积在计算时需要对每个采样点做额外的偏移预测和双线性采样这一部分非常吃显存。我自己的经验是一张12G显存的卡batch size设成4crop size设成256训练起来已经比较紧张如果再开一些额外的日志或者可视化很容易OOM。如果遇到OOM优先减小crop size而不是batch size因为crop size的变化直接改变每张图的计算量对训练稳定性影响也更可控。训练的另一个重要细节是学习率调度。官方配置使用的是cosine annealing也就是学习率在整个训练周期内从初始值按照余弦曲线逐渐衰减到接近0。这种调度方式在训练后期可以让模型在最优解附近更平稳地收敛而不是在一个固定的低学习率下震荡。如果你自己改成step decay建议把衰减节点和衰减系数调得更温和一些否则后期loss可能突然跳动。checkpoint保存这块代码默认会同时保存当前权重和最优权重最优的选择依据是在验证集上的PSNR或者SSIM。这一点对实验管理非常友好因为视频帧插值的训练通常要跑几十个epoch如果每个epoch都只覆盖保存一份权重中途坏点就前功尽弃了。4.3 模型推理加载权重、对齐尺寸、输出中间帧推理是理解一个模型最直接的入口ema-vfi的demo脚本写得很清楚。加载权重之后只需要传入两张相隔一帧的图像就能返回中间帧。但推理部分有几个实际细节必须注意。第一是输入图像的尺寸必须能被8整除因为网络内的特征金字塔会进行多次下采样如果尺寸不能对齐最终输出的张量大小会和输入不一致导致拼接或warp时报维度错误。如果输入图不是8的倍数需要先pad推理完成后再crop回来。第二是输入图像的取值范围。训练时图像被归一化到[-1, 1]区间推理时如果直接用0到255的uint8数据送入网络输出结果会非常奇怪颜色全乱。demo脚本里一般已经帮你做了归一化但你自己写推理脚本时很容易漏掉这一步我在给项目接其他模块时犯过这个错误排查了半天最后发现是数值范围不对。第三是权重文件的加载方式。官方给的预训练权重是用DataParallel方式训练的权重文件里的key会多出module.前缀。如果你在单卡环境下加载可能会报unexpected key或者missing key的警告需要在加载权重时做一个简单的字符串替换把module.去掉或者用strictFalse先加载进去再看缺什么。5. 训练和部署中的常见问题排查5.1 loss不降或者降得很慢不少人在训练ema-vfi时遇到的第一问题是loss长时间不下降或者下降得极其缓慢。这个问题的发生点通常在训练的第一个epoch特征是训练loss始终在初始值附近小幅波动。我遇到这种情况时第一反应是先确认数据加载是否正确。可以在dataloader里临时加一个断点打印一批输入tensor的数值范围看看图像是否被正确归一化三元组是否真的是连续帧。如果数据没问题再检查学习率设置ema-vfi这类光流驱动的模型对学习率比较敏感初始学习率太小会收敛得很慢太大则loss会在早期爆炸。还有一个比较隐蔽的原因是多卡训练时batch size看起来变大了但学习率没有相应线性缩放。如果用了4卡训练单卡batch size为4那全局batch size就是16学习率也要对应往上调。否则全局batch变大而学习率不变模型的收敛速度会明显变慢。5.2 中间帧出现重影或者模糊区域模型训练完成之后在验证集上查看生成结果时最容易发现的问题是运动边缘出现重影或者被遮挡区域的纹理一团糊。这个现象在视频帧插值里非常典型基本都和遮挡处理有关。遮挡区域里一个像素只在一帧中有可见信息另一帧中它被前景物体挡住因此warp过来的像素根本没有有效内容。ema-vfi通过融合权重来缓解这个问题但融合权重本身是一个纯数据驱动的估计如果训练数据里这类遮挡场景不够多模型就学不到合理的权重分配策略。针对这种情况可以尝试在训练时加大数据增强的强度尤其是多尺度随机裁剪和翻转让模型见过更多形态的运动模式。另一个可行思路是适当增加感知损失的权重让模型在纹理细节上更“较真”而不是只减小像素误差。感知损失对遮挡区域的模糊惩罚更强因为VGG特征在纹理结构上的差异会直接体现在loss里。如果只是做推理而不是训练遇到重影可以尝试对输入帧做一个轻微的高斯模糊预处理再把输出帧做锐化后处理但这属于治标不治本实际工程中还是更推荐在训练侧优化。5.3 显存不足和速度优化显存问题是视频帧插值训练里最常见的一类问题ema-vfi也不例外。但很多时候并不需要硬换一张更大的卡而是可以从代码层面做优化。最直接的优化手段是使用混合精度训练。代码仓库如果没有默认开启可以在训练脚本里加上amp相关内容将模型参数和部分计算过程转为半精度浮点数显存占用能直接减少将近一半同时速度还能提升。需要注意的是混合精度训练时loss的缩放值要设置合理否则可能出现loss变为nan的情况。第二个手段是减少特征金字塔的层数这属于网络结构的改动需要同时修改前向传播中特征读取的索引逻辑改成之后运算量下降非常明显但精度会有一定损失。如果你只是想在单卡上快速验证代码能否跑通这种做法很合适正式训练还是建议保留完整结构。第三个手段是把验证过程从训练循环里抽出来不要每个epoch都做一次完整验证。验证阶段需要前向计算整个验证集而且不能开dropout速度和训练差不多。改成每隔5个甚至10个epoch验证一次能节省不少时间。5.4 推理结果出现周期性错误还有一个我在实际部署中遇到过的问题是生成的中间帧每隔固定帧数出现一次颜色异常或者位置错位。这种情况一般不是模型本身的问题而是视频拆帧和合帧环节出的bug。视频帧插值的推理对象往往是完整视频我们需要先用ffmpeg把视频拆成图片序列然后逐对做插值最后再把插值后的图片序列重新合成视频。在这个过程中最容易出问题的是帧索引错位。比如你要把30帧的视频变成60帧原本是每两帧生成一帧结果代码里把输入对取成了相邻重复帧或者漏掉了某一段最终合成的视频就会出现周期性闪动。排查时最直接的方法是打印出每一对输入帧的文件名和索引核对是不是严格连续的相邻关系。另一个点是把插值后的图片序列先合成为一个临时视频肉眼快速浏览一遍比单看某一帧更能发现问题。6. 踩坑之后的几点体会这个项目我陆续看过几遍也用它做过一些工程上的二次开发印象最深的一点是像ema-vfi这种结构清晰的代码仓库反而最容易暴露出你对视频帧插值基础概念的理解漏洞。很多论文里一笔带过的术语比如双向光流、中间光流投影、可变形warp、感知损失权重在代码里全是藏不住的具体实现。只有真正动手改过这些模块你才会明白为什么项目里要做金字塔特征为什么需要融合权重为什么损失函数不是单纯的L1。如果有条件建议拿一个小视频片段用官方预训练权重先跑一次推理再自己打乱部分输入对观察输出帧的变化。这种“破坏性实验”对理解模型行为逻辑很有帮助比盯着tensor shape猜要直观得多。还有一点想提醒的是视频帧插值后面还有RIFE、IFRNet、AMT这些更快的实现但它们要么把隐式运动建模做到极致要么引入了Transformer结构。回看ema-vfi时很多设计其实都能在其中找到影子把它的代码吃透等于给后续阅读其他工作打下一个很扎实的地基。
返回列表