084、RealBasicVSR:真实场景视频超分的端到端解决方案与训练技巧
做视频超分的人,十有八九都遇到过这种尴尬:在公开数据集上跑得飞起的模型,一扔到真实监控视频或者老电影上,直接翻车。模糊、噪声、压缩伪影混在一起,模型要么疯狂输出马赛克,要么把噪声放大成雪花屏。我当年调BasicVSR的时候,就被这种“实验室王者、实战青铜”的落差折磨过好几周。
后来读到RealBasicVSR这篇工作,说实话,第一反应是“终于有人把坑填上了”。它不像之前那些方法,把退化模型假设得干干净净——高斯模糊加高斯噪声,再下采样,然后指望网络学个逆映射。真实世界的退化哪那么规矩?压缩伪影是非线性的,运动模糊是空间变化的,噪声还可能是彩色的。RealBasicVSR的思路很直接:别绕弯子,直接在真实退化数据上训,同时把模型结构改得能扛住这些脏活。
从退化模型说起:别再假设世界是高斯分布了
很多视频超分方法,包括BasicVSR,训练时用的退化模型是经典的双三次下采样加高斯噪声。这在学术benchmark上够用,但真实场景里,视频经过采集、编码、传输、解码,每一步都在引入非理想退化。比如H.264压缩产生的块效应,那是空间上成块出现的,不是高斯噪声那种均匀分布。再比如老电影里的胶片颗粒噪声,纹理细密、亮度相关,跟高斯白噪声完全是两码事。
RealBasicVSR的解决方案是搞了个“二阶退化模型”。第一阶模拟常见的模糊和噪声,第二阶专门处理压缩伪影和更复杂的噪声。具体实现上,他们用了一组随机化的操作:模糊核从各向异性高斯分布里采样,噪声包括高斯噪声、泊松噪声,甚至还有JPEG压缩模拟。训练时这些参数随机组合,相当于给模型喂了各种“毒打”,让它学会在真实场景下也能稳住。
代码里有个细节我印象很深——他们实现模糊核采样时,特意加了个“各向异性”参数。别小看这个,各向同性高斯核只能模拟对称模糊,但真实运动模糊往往是沿运动方向拉长的。我当时复现的时候,第一次写成了各向同性,结果模型对斜向运动的处理明显差一截。后来改成各向异性采样,效果立竿见影。
# 这里踩过坑:各向异性核的协方差矩阵要随机旋转# 别这样写:直接固定角度,那样只能处理水平/垂直运动defsample_anisotropic_kernel(size=21,min_var=0.5,max_var=8.0):# 随机生成两个方向上的方差var1=np.random.uniform(min_var,max_var)var2=np.random.uniform(min_var,max_var)# 随机旋转角度angle=np.random.uniform(0,np.pi)# 构造协方差矩阵并旋转cov=np.array([[var1,0],[0,var2]])rot=np.array([[np.cos(angle),-np.sin(angle)],[np.sin(angle),np.cos(angle)]])cov_rot=rot @ cov @ rot.T# 生成高斯核kernel=np.zeros((size,size))center=size//2foriinrange(size):forjinrange(size):vec=np.array([i-center,j-center])kernel[i,j]=np.exp(-0.5*vec @ np.linalg.inv(cov_rot)@ vec)returnkernel/kernel.sum()网络结构:把BasicVSR的骨架加固一下
RealBasicVSR的网络主体还是基于BasicVSR的残差块和光流对齐模块,但做了几个关键改动。最核心的是引入了“长距离时序传播”和“双向光流”的改进。BasicVSR用的是单向传播,从第一帧往后推,或者从最后一帧往前推。但真实视频里,遮挡和运动突变很常见,单向传播容易把错误累积起来。RealBasicVSR改成双向传播,同时利用前后帧的信息,相当于给模型装了个“前后眼”。
另一个改动是特征对齐部分。BasicVSR用光流做warping,但光流本身在遮挡区域和运动边界上不可靠。RealBasicVSR加了一个“流场细化”模块,用一个小网络对原始光流做修正。这个模块很轻量,但效果明显——尤其是在快速运动的场景里,修正后的光流能减少很多鬼影。
我试过把流场细化模块去掉,直接拿BasicVSR的光流用,结果在快速旋转的物体边缘,超分结果出现了明显的抖动。加上细化模块后,边缘稳定多了。这个模块的设计思路其实挺朴素的:光流估计网络(比如SPyNet)输出的流场是粗粒度的,细化网络用特征图上的局部信息去微调每个像素的偏移量。
# 流场细化模块,输入是粗光流和特征图,输出是修正后的光流# 别这样写:直接对光流做平滑滤波,那会抹掉运动细节classFlowRefine(nn.Module):def__init__(self,in_channels=64):super().__init__()# 用3x3卷积做局部修正self.conv1=nn.Conv2d(in_channels+2,64,3,padding=1)self.conv2=nn.Conv2d(64,64,3,padding=1)self.conv3=nn.Conv2d(64,2,3,padding=1)defforward(self,flow,feat):# 把光流和特征图拼起来x=torch.cat([flow,feat],dim=1)x=F.relu(self.conv1(x))x=F.relu(self.conv2(x))delta=self.conv3(x)# 这里踩过坑:delta要加tanh限制范围,不然修正量可能太大delta=torch.tanh(delta)*5.0# 最大修正5个像素returnflow+delta训练技巧:数据增强和损失函数里的门道
RealBasicVSR的训练策略里,最值得借鉴的是“随机退化序列”的生成方式。他们不是简单地对每一帧独立做退化,而是保证退化参数在时序上连续变化。比如模糊核的方差随时间缓慢变化,噪声强度也随时间波动。这样模拟出来的视频更接近真实场景——真实视频的退化不会每帧突变,但也不是完全不变。
损失函数方面,他们用了Charbonnier损失代替L1损失。Charbonnier损失是L1的平滑版本,在梯度接近零时更稳定。我实际测试下来,Charbonnier损失在训练初期收敛更快,而且对噪声的鲁棒性更好。另外他们还加了一个感知损失,用VGG网络提取特征,计算特征空间的差异。这个感知损失对纹理细节的恢复帮助很大,但要注意权重不能太大,否则容易产生过度锐化的伪影。
训练时还有一个细节:他们用了“梯度裁剪”和“学习率warmup”。梯度裁剪是为了防止光流估计部分梯度爆炸——光流网络的梯度有时候会突然变大,尤其是运动剧烈的时候。warmup则是让模型先在小学习率下稳定一段时间,再进入正常训练。这两个技巧虽然简单,但能避免很多训练崩溃的情况。
# Charbonnier损失实现,比L1更平滑# 这里踩过坑:epsilon不能设太大,否则损失函数会变得太平滑defcharbonnier_loss(pred,target,epsilon=1e-3):diff=pred-target loss=torch.sqrt(diff**2+epsilon**2)returnloss.mean()实际部署时的血泪教训
把RealBasicVSR部署到实际项目里,有几个坑不得不提。
第一个是输入分辨率。模型在训练时用的输入是256x256的patch,但实际视频分辨率可能很大,比如1920x1080。直接整帧推理的话,显存会爆。我的做法是分块推理,但要注意块与块之间的边界处理——直接用padding的话,边界处会有明显的拼接痕迹。更好的做法是让块之间有重叠,然后对重叠区域做加权平均。权重可以用高斯分布,中心权重高,边缘权重低。
第二个是光流估计的精度。RealBasicVSR用的光流网络是SPyNet,这个网络在低分辨率下表现不错,但高分辨率下速度慢。如果对实时性有要求,可以考虑用RAFT或者PWC-Net的轻量版本。不过要注意,换光流网络后,流场细化模块可能需要重新训练,因为不同光流网络的输出分布不一样。
第三个是模型大小。RealBasicVSR的参数量大概在10M左右,对于移动端部署来说还是偏大。我试过用知识蒸馏,把大模型的知识迁移到一个小模型上。具体做法是让大模型生成伪标签,小模型去拟合。蒸馏后的模型参数量可以压缩到3M左右,PSNR只下降0.2dB,但推理速度快了一倍。
个人经验:什么时候该用RealBasicVSR
如果你手里的视频退化比较干净,比如只是分辨率低但噪声很小,那BasicVSR就够用了,没必要上RealBasicVSR。RealBasicVSR的优势在于处理混合退化——既有模糊又有噪声还有压缩伪影。比如老电影修复、监控视频增强、网络视频质量提升,这些场景下RealBasicVSR的效果明显更好。
另外,RealBasicVSR的训练数据要求比较高。你需要准备大量的真实低分辨率-高分辨率对,或者用二阶退化模型生成。如果数据量不够,模型容易过拟合。我的建议是至少准备10万对以上的训练样本,而且退化参数的随机范围要足够大,覆盖各种可能的退化情况。
最后说一句,论文里的超参数不一定适合你的数据。比如Charbonnier损失的epsilon、感知损失的权重、学习率warmup的步数,这些都需要根据实际数据调。我自己的经验是,先在小数据集上跑一组网格搜索,找到最优参数组合,再在大数据集上训练。虽然麻烦,但比直接套用论文参数靠谱得多。