1. 视频生成对抗网络到底在做什么,为什么比图像生成难一个量级
视频生成对抗网络(Video GAN)这个方向,我第一次真正上手是在一个短视频素材自动扩增的需求里:手上只有几百段十几秒的素材,想让模型自己"续写"出合理的新片段,用来做下游任务的样本补充。当时天真地以为,图像那边的 GAN 我已经玩得很熟了,把网络换成 3D 卷积不就完事了。结果第一版跑起来,画面糊成一团不说,前后帧还像在玩"随机换脸",人物衣服的颜色每隔两帧变一次。那个阶段我才真正意识到,Video GAN 的核心难点压根不在"画得像不像",而在"时间轴上连不连得住"。
所以这篇内容我想把这件事从头捋一遍:Video GAN 是什么、它解决的问题边界在哪、架构为什么这样选、训练为什么这么难、参数怎么算、坑怎么排。我不会把它写成论文导读,而是按一个真做过项目的人会怎么想、怎么试、怎么翻车再爬起来的路子来讲。适合谁看:手上有过图像 GAN 训练经验、想往视频方向延展的人;做视频数据增广、短视频特效、帧预测、动作序列模拟的工程同学;以及刚看完几篇论文、准备开写第一版代码但不知道从哪下手的人。纯小白也能看,我会在关键位置用生活化的类比解释清楚,但坦白说,涉及显存和训练稳定性那几节,有点基础会舒服很多。
提示:后面所有涉及具体分辨率、帧数、通道数的参数,都是基于"消费级或单卡工作站能跑得动"这个前提给的工程取值,不是理论最优。你要是手握多卡集群,很多取舍可以直接推翻。
2. 整体方案设计与技术选型拆解
2.1 先把问题形态看清楚:多出来的那一维到底带来了什么
图像生成的数据张量是N×C×H×W,视频是N×T×C×H×W,多了一个时间维 T。表面看只是多一个循环维度,实际影响是三重的。
第一重是计算量。假设单帧生成一次前向的开销是 1,那么 T=16 帧的处理,如果不是简单地"逐帧独立生成",而是做真正的时空卷积,开销大致会增长到接近 T 倍甚至更多。这个增长不是线性的加法,而是直接把你原本 8G 显存能跑的实验,推到 24G 都不一定塞得下。
第二重是数据分布。图像 GAN 判别器看一张图,判断"这是真的还是假的"。视频判别器如果只看单帧,那它有极大概率放过一堆"每帧都逼真、连起来像抽风"的样本。这就是早期 Video GAN 最典型的失败模式:帧质量指标很漂亮,视觉上根本没法看。
第三重是一致性约束的表达。什么叫"连贯"?人物走路时腿部摆动要有节奏,镜头平移时背景要有视差,光照变化要连续,物体不能凭空出现或消失。这些都不是一个简单的像素级损失能描述的。你在损失函数里加什么项,直接决定模型学会哪种"连贯"。
我个人的判断是:把这三重影响想清楚,比抄一个网络结构重要得多。很多人上来就找 SOTA 的生成器,结果卡在判别器设计上,跑一个月没进展。
2.2 主流三条技术路线的取舍
Video GAN 往细了分,架构上有三条常见路子,各有明确的适用场景,选错了会很痛苦。
| 路线 | 核心做法 | 优势 | 代价 | 适合场景 |
|---|---|---|---|---|
| 逐帧独立生成 + 后处理对齐 | 复用成熟图像 GAN 逐帧出图,再用光流或插帧做时序对齐 | 实现最快,能直接吃现成的图像模型权重 | 时序一致性全靠后处理兜底,长序列容易累积漂移 | 短片段、对连贯性要求不苛刻的素材增广 |
| 2D 骨架 + 时序模块 | 主干用 2D 卷积,在中间层插入时间维的一维卷积或注意力 | 显存友好,训练相对稳 | 时序建模能力偏弱,快速运动容易糊 | 中等分辨率、运动幅度不大的场景 |
| 全 3D 时空卷积 | 卷积核直接做成(3,3,3),从第一层就建模时空 | 时序表达最强,长程连贯性好 | 显存和算力压力最大,训练调参难度高 | 高保真短片段、帧预测、动作序列生成 |
我实际项目里走的是第三条,但做了妥协:生成器主干在低分辨率上用 3D 卷积,高分辨率阶段退回 2D 加时间注意力的混合结构。理由很实在——全 3D 在 128×128 以上分辨率时,显存直接顶到天花板,训练一个 epoch 要等太久,迭代速度慢到无法做有效实验。这个混合结构牺牲了一点理论上的时序建模能力,但换来了每天能跑十几组对照实验,整体收益是正的。
注意:不要因为某篇论文用了纯 3D 结构就无脑照搬。论文的算力预算和你的完全不是一回事,能不能快速迭代,决定了你最后能不能调出一个能用的模型。
2.3 判别器设计才是这套东西的灵魂
我踩过最大的坑就在这里。第一版我用了标准的图像判别器,把 T 帧当 batch 维塞进去,结果训练到中期,判别器几乎必输,生成器输出一堆"静态画面轻微抖动"的样本——因为只要每帧都真,判别器就给高分,模型很快学会了这个捷径。
后来改成双判别器结构,效果立刻不一样:
- 帧判别器:只看单帧,负责把每一帧的纹理、颜色、结构质量拉上来,收敛快,梯度信号密集。
- 时空判别器:输入是完整片段,用 3D 卷积或时间维下采样,负责判断"这段运动是否自然"。它看到的不是单帧像素,而是帧与帧之间的差分模式。
这两个判别器的损失加权要单独调。我实测下来,帧判别器的权重给 0.6~0.8,时空判别器给 0.2~0.4 比较稳。时空判别器权重太高,训练早期它的判断几乎全是噪声,会把生成器带偏,画面质量直接崩。反过来权重太低,模型就退化成逐帧生成器,前面说的闪烁问题立刻回来。
还有一个细节值得说:时空判别器的输入最好是帧差分或者光流的近似表达,而不是原始像素堆叠。直接堆原始像素,判别器很容易靠背景颜色这类静态特征作弊,学不到真正的运动模式。我试过把输入改成"当前帧 + 与前一帧的差值通道",判别器对运动异常样本的识别能力明显提升,生成结果的抖动也少了。
2.4 损失函数的组合逻辑
单靠对抗损失训视频,基本上是在给自己找麻烦。我最终的损失是四项加权:
- 对抗损失:主干,负责分布对齐。用 hinge 形式比原始交叉熵稳,这是我对比过多次的结论。
- 重建损失:如果是有条件的帧预测任务,前若干帧给真值,后面预测,那 L1 重建损失能显著加速早期收敛。权重给 10 左右,太高会让画面偏模糊。
- 时序差分损失:对生成片段和真实片段各算一次帧间差分,再比较两者的统计量(均值、方差)。这一项很便宜,但对抑制闪烁特别有效。
- 特征匹配损失:拿判别器中间层特征做 L2 约束,是老技巧了,但对稳定训练确实有用,尤其在判别器已经训得比较强的时候。
加权系数不是拍脑袋定的,我的做法是:先只开对抗损失跑通,确认生成器能出东西;然后逐项加,每加一项跑 2~3 万步看指标和肉眼效果,涨了就留,掉了就调权重或砍掉。这个流程慢,但比一次性堆满损失然后对着一条平掉的曲线发呆要高效得多。
2.5 显存和算力的账要提前算
这一节我建议每个准备动手的人都认真算一遍,不然你会在训练启动的第三分钟收到一个显存溢出。
假设:分辨率 64×64,片段 16 帧,通道 3,batch size 8,生成器使用了 4 级下采样/上采样的 3D 卷积结构,中间层最大通道 256。
粗略估算激活值占用(推理视角,训练还要加上梯度,大致再乘 2~3 倍):
- 输入张量:8 × 16 × 3 × 64 × 64 × 4 字节 ≈ 6.3 MB
- 第一层输出(64 通道,保持分辨率):8 × 16 × 64 × 64 × 64 × 4 ≈ 134 MB
- 第二层下采样后(128 通道,32×32):8 × 16 × 128 × 32 × 32 × 4 ≈ 67 MB
- 第三层(256 通道,16×16):8 × 16 × 256 × 16 × 16 × 4 ≈ 33 MB
看起来还好?别忘了这是单层的瞬时值,实际训练要保留每一层的激活用于反向传播,还要加上判别器的激活、优化器状态(Adam 每个参数两份动量)。经验上,整个训练跑起来的总占用大概是上面算出来单层峰值的十几倍。所以一个看起来很温和的配置,实际可能吃掉 15~20G。
我的建议是:先把 batch size 设成 2,分辨率设 64,帧数设 8,跑通再说。确认能跑再往上推,每次只推一个维度(先加帧数,或先加分辨率),这样你能清楚知道瓶颈在哪。同时开梯度累积来模拟大 batch,比硬堆 batch size 划算得多。
3. 核心细节解析与实操要点
3.1 数据准备:帧率、分辨率、片段长度怎么定
这三个参数是相互制约的,我按重要性排序说。
帧率:不要迷信高帧率。原始素材如果是 30fps,直接抽成 16 帧一段,时间跨度只有半秒,模型学到的运动幅度非常有限,最后生成的画面会像慢动作。我的经验是训练和生成都用 8~12fps 的重采样,同一个 16 帧片段覆盖 1.3~2 秒,运动信息更丰富,而且相邻帧差异够大,模型更容易学到"变化"本身。
分辨率:从 64×64 起步是明智的。64 分辨率下你能在单卡上快速验证架构和损失设计是否有问题。等整体链路通了,再用"低分辨率预训练 + 高分辨率微调"的方式往上推。直接上 256×256 的纯 3D 结构,我没见过几个在单卡上顺利训出来的。
片段长度:8、16、32 是三个常见档位。8 帧能学会局部运动,16 帧能学会简单动作循环,32 帧开始需要模型有真正的长程记忆能力,难度陡增。我一般先用 8 帧把模型调稳,再上 16。
数据清洗这块别省事。我见过太多人栽在这里:素材里混了几段静止画面、几段镜头剧烈切换的剪辑,模型学出来的结果就是"要么完全不动,要么突然跳变"。所以做数据管线时至少过三道筛:帧间差分均值过低的(静止)剔掉,差分方差过高的(切换)剔掉,连续帧亮度突变超过阈值的剔掉。这三条规则很土,但极其有效,我每次都会写。
3.2 网络结构里的几个关键参数
卷积核时间维大小:(3,3,3)是默认选择,但第一层我建议用(1,3,3)或者(3,3,3)后接一个时间维 stride=2 的下采样,这样能快速压缩时间维度,省显存。中间层保持(3,3,3),最后一层再回到(3,3,3)或(1,1,1)结合上采样。
潜变量维度:如果做无条件生成,潜向量一般 128~256 维,然后在时间维上扩展成T×Z,也就是每帧一个噪声向量。这里有个坑——如果每帧噪声完全独立,模型会倾向逐帧生成,一致性差;如果完全相同的噪声广播到所有帧,运动多样性又会变差。我的做法是"共享主噪声 + 每帧小扰动",具体是主向量 128 维共享,每帧再加 16 维独立的扰动向量拼接进去。这个设计不复杂,但对运动多样性和一致性的平衡效果很好。
归一化层:3D 结构里用 GroupNorm 比 BatchNorm 稳。原因很实际,视频 batch 本来就小,BatchNorm 的统计量在这么小的 batch 上噪音很大,容易让训练发散。GroupNorm 跟 batch 无关,在视频任务里几乎是默认选项。至于 Spectral Norm,加在判别器上能显著缓解判别器过强的问题,代价是训练速度慢 20% 左右,我觉得值。
3.3 训练稳定性的几个实操开关
这部分内容基本都是从翻车里总结出来的,文档上不太会写。
两阶段学习率(TTUR):判别器学习率给生成器的 2~4 倍。原因很直白,视频任务的判别器任务更简单(判断真假比生成容易多了),给它高一点的学习率能让它跟上生成器的进步速度,不至于一开始就被压着打,也不至于一下变得太强。
判别器更新频率:图像 GAN 里常见的做法是判别器多更新几次,视频里我建议反过来,判别器每 1~2 步更新一次,生成器每步都更新。因为时空判别器本身就不稳定,更新太频繁会让它输出的信号变得极不可靠。
梯度裁剪:必开,max_norm设 1.0 或者稍大。视频任务的梯度范数波动比图像大得多,不加裁剪,偶尔一次异常梯度就能把前面几万步的成果全毁掉。
噪声注入:给判别器的输入加一点高斯噪声,标准差 0.05 左右,能有效防止判别器记住训练样本。这招有点老,但在小数据集上特别管用。
随机片段的采样策略:不要只用固定起点切片段,训练时随机起点采样,能大幅提升数据利用率。我是每个 epoch 重新随机切一次起点,实测收敛会更快一点。
3.4 评估指标怎么用才不骗自己
这一块我想重点提醒。视频生成的评估比图像难得多,单看一个指标一定会被骗。
FVD(Fréchet Video Distance)是常用的整体分布指标,用预训练的视频特征提取器算特征分布的差异。它的好处是同时考虑了画面质量和时序连贯性,坏处是,它需要一定量的样本才有统计意义,而且对短片段、快速运动的场景不太敏感。
PSNR / SSIM只适合有真值对照的帧预测任务,用来比较生成帧和真实帧。这两个指标偏高但画面模糊是常见现象,别只盯着数字。
我自己的做法是三层验证:指标(FVD 为主)+ 固定随机种子的可视化抽样(每周看一次,肉眼判断闪烁和运动合理性)+ 小规模人工打分(找 2~3 个同事盲评 20 段,只问"这段视频看起来自然吗")。第三层最土,但最准。我踩过一次坑,FVD 降了 15%,大家挺高兴,结果抽样一看,模型学会了生成"缓慢平移的静态场景",指标好是因为整体分布更接近真实数据的平均值,实际上多样性已经塌了。
4. 实操过程与核心环节实现
4.1 环境与目录结构
环境不用复杂,主流深度学习框架 + 一张 24G 显存的卡就够起步。我用的配置是框架 2.x 版本,混合精度训练默认开启,能省 30% 左右的显存。
目录我建议这样组织,后面加实验会轻松很多:
project/ ├── data/ │ ├── raw/ # 原始素材 │ └── clips/ # 切好的片段,按 split 分目录 ├── configs/ # 每次实验一个 yaml ├── models/ │ ├── generator.py │ └── discriminator.py ├── losses/ ├── train.py ├── sample.py # 抽样可视化 └── logs/配置和代码分离这件事,前期觉得麻烦,等你跑到第 30 组实验,想把三周前的配置复现一遍时,就知道值了。
4.2 数据管线:切片、筛选、增强
数据管线的核心是三件事:按随机起点切片段、过滤劣质片段、做与视频兼容的增强。
import torch from torch.utils.data import Dataset import random class ClipDataset(Dataset): def __init__(self, video_paths, clip_len=16, size=64, diff_min=0.01, diff_max=0.25): self.paths = video_paths self.clip_len = clip_len self.size = size self.diff_min = diff_min # 低于此值视为静止片段 self.diff_max = diff_max # 高于此值视为镜头切换 def _is_valid(self, clip): # clip: T x C x H x W, 已归一化到 [0,1] d = (clip[1:] - clip[:-1]).abs().mean(dim=(1, 2, 3)) m = d.mean().item() return self.diff_min < m < self.diff_max def __getitem__(self, idx): # 简化写法:实际应从预处理好的片段缓存里读 for _ in range(8): # 最多重试 8 次,避免死循环 start = random.randint(0, 10_000) clip = self._load_clip(self.paths[idx], start, self.clip_len) if clip is None or not self._is_valid(clip): continue # 只做空间增强,时间维不要翻转 if random.random() < 0.5: clip = torch.flip(clip, dims=[3]) return clip # 兜底:返回全零片段,训练时跳过 return torch.zeros(self.clip_len, 3, self.size, self.size)这里有两个容易忽略的点。第一,时间维绝对不能随机翻转或打乱,那会直接破坏运动方向,模型学出来的东西会变得混乱。第二,颜色抖动要慎用,视频里相邻帧独立做颜色抖动,会人为制造出闪烁,等于给模型喂了错误的一致性信号。如果一定要做,就得保证整个片段共用同一组抖动参数,而不是逐帧随机。
4.3 生成器与判别器的核心结构
生成器我用的是"低分辨率 3D 主干 + 高分辨率 2D 上采样"的混合结构,下面是最小可跑版本的核心部分:
import torch import torch.nn as nn class ResBlock3D(nn.Module): def __init__(self, ch): super().__init__() self.net = nn.Sequential( nn.GroupNorm(8, ch), nn.SiLU(), nn.Conv3d(ch, ch, 3, padding=1), nn.GroupNorm(8, ch), nn.SiLU(), nn.Conv3d(ch, ch, 3, padding=1), ) def forward(self, x): return x + self.net(x) class Generator(nn.Module): def __init__(self, z_dim=128, frame_z=16, base=64, t_len=16): super().__init__() self.t_len = t_len self.base = base # 共享主噪声 + 每帧扰动 self.shared = nn.Linear(z_dim, base * 4 * 4 * 4) self.per_frame = nn.Linear(frame_z, base * 4 * 4 * 4) layers = [ ResBlock3D(base), nn.Upsample(scale_factor=(1, 2, 2)), # 只扩空间,不动时间 ResBlock3D(base // 2 if base > 64 else 64), nn.Upsample(scale_factor=(2, 2, 2)), # 时间也开始上采样 ] self.body = nn.Sequential(*layers) self.to_rgb = nn.Conv3d(64, 3, 1) def forward(self, z, zf): b = z.shape[0] s = self.shared(z).view(b, -1, 4, 4, 4) # B x C x 1 x 4 x 4 p = self.per_frame(zf).view(b, self.t_len, -1, 4, 4, 4) h = s.unsqueeze(1) + p # 广播出时间维 h = h.view(b * self.t_len, -1, 4, 4, 4) out = self.body(h) out = self.to_rgb(out) out = out.view(b, self.t_len, 3, out.shape[-2], out.shape[-1]) return out.permute(0, 2, 1, 3, 4) # B x 3 x T x H x W判别器的关键是双头设计,一个头输出单帧真伪,一个头输出片段整体真伪:
class DualDiscriminator(nn.Module): def __init__(self, base=64): super().__init__() self.stem = nn.Sequential( nn.Conv3d(3, base, 3, stride=2, padding=1), # 时间空间同时下采样 nn.LeakyReLU(0.2), nn.Conv3d(base, base * 2, 3, stride=(2, 2, 2), padding=1), nn.LeakyReLU(0.2), ) self.frame_head = nn.Linear(base * 2, 1) # 帧级 self.clip_head = nn.Linear(base * 2, 1) # 时空级 def forward(self, x): h = self.stem(x) # B x C x T' x H' x W' h = h.mean(dim=(3, 4)) # 池化掉空间,保留时间 frame_score = self.frame_head(h).mean(dim=1) # 对时间平均 clip_score = self.clip_head(h.mean(dim=2)) # 对时间池化 return frame_score, clip_score实际部署时,这部分还要在判别器上加谱归一化,并在输入前拼接帧差分通道,前面已经说过原因,这里不再重复。
4.4 训练循环里的顺序与权重
训练循环本身不复杂,关键是顺序、权重和日志。我用的顺序是:先更新判别器(用真实样本和分离梯度的生成样本),再更新生成器,判别器每步都更新但学习率更低。
def train_step(gen, disc, real, opt_g, opt_d, lambda_frame=0.7): b, c, t, h, w = real.shape z = torch.randn(b, 128, device=real.device) zf = torch.randn(b, t, 16, device=real.device) # ---- 判别器 ---- fake = gen(z, zf).detach() d_real_f, d_real_c = disc(real) d_fake_f, d_fake_c = disc(fake) loss_d = (torch.relu(1 - d_real_f).mean() + torch.relu(1 + d_fake_f).mean() + 0.5 * (torch.relu(1 - d_real_c).mean() + torch.relu(1 + d_fake_c).mean())) opt_d.zero_grad(); loss_d.backward(); opt_d.step() # ---- 生成器 ---- fake = gen(z, zf) g_f, g_c = disc(fake) adv = -g_f.mean() - 0.5 * g_c.mean() # 时序差分损失:让生成片段的帧差统计贴近真实 df = (fake[:, :, 1:] - fake[:, :, :-1]).abs().mean(dim=(1, 2, 3, 4)) dr = (real[:, :, 1:] - real[:, :, :-1]).abs().mean(dim=(1, 2, 3, 4)) loss_t = (df.mean() - dr.mean()).abs() loss_g = adv + 0.5 * loss_t opt_g.zero_grad(); loss_g.backward() torch.nn.utils.clip_grad_norm_(gen.parameters(), 1.0) opt_g.step() return loss_d.item(), loss_g.item()这里我想多解释一句loss_t的设计意图。它约束的其实是"运动量"的全局统计,不是逐像素对齐,所以它不会强迫模型生成跟真实样本一模一样的动作,只要求整体的运动强度落在合理区间。这个约束很宽松,但对抑制"画面静止不动"这种模式崩溃特别有效。我在两组实验里对比过,加这一项之后,静止样本占比从 30% 多降到 5% 以下。
另外,clip_grad_norm_只加在生成器上是刻意的。判别器那边我有谱归一化兜着,再加梯度裁剪反而会让学习变慢。这个搭配是我试了几轮才定下来的,不是通用答案,你可以自己做过对照。
4.5 参数选择的一个完整推算示例
举一个我实际用过的配置推导过程,给你做参考。
目标:64×64 分辨率,16 帧片段,单卡 24G 显存,希望 batch size 至少能到 8。
第一步,先按 batch=2 跑起来,观察显存峰值。假设观察到 13G。
第二步,估算 batch=8 时的显存。除模型参数和优化器状态是固定开销外,激活值部分大致和 batch 成正比。假设固定开销 4G,激活部分 batch=2 时是 9G,那么 batch=8 大约需要 4 + 36 = 40G,超了。
第三步,调整。可选手段按性价比排序:开启混合精度(省约 30%)、把帧数降到 12(省 25%)、把主干最大通道从 256 降到 192(省 30% 左右)、开梯度累积。我最后选的是混合精度 + 帧数降到 12 + 梯度累积 2 步,等效 batch 到 8,显存落在 21G 左右,留了余量。
这个过程中最忌讳的是"先训练,爆了再想办法"。提前用一次小规模试跑量出显存峰值,比后面反复重启要省太多时间。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 最可能的原因 | 优先尝试的排查手段 |
|---|---|---|
| 相邻帧颜色无规律跳变 | 逐帧做颜色增强,或生成器噪声逐帧独立且无共享分量 | 关掉逐帧颜色抖动;改成共享噪声 + 小扰动 |
| 画面整体静态,只有轻微抖动 | 时空判别器权重过低,或模型找到"每帧都真"的捷径 | 提高时空判别器权重;判别器输入拼接帧差分通道 |
| 训练几百步后损失爆炸 | 生成器梯度异常,缺梯度裁剪 | 开梯度裁剪、降生成器学习率、检查数据里有无异常样本 |
| 生成片段开头还行,后面越来越糊 | 长序列建模能力不足,误差累积 | 缩短片段长度重训,或改用自回归式分段生成 |
| 训练集上效果好,新样本一片噪声 | 判别器过拟合,生成器没学到分布 | 判别器输入加噪声、开谱归一化、增加数据量 |
| FVD 一直不降 | 学习率配比失衡,或数据分布太杂 | 检查 TTUR 比例,做数据去重和劣质片段清洗 |
| 输出全是同一段内容 | 模式崩溃 | 加小批量判别或增加潜变量多样性约束 |
5.2 几个典型故障的逐条分析
闪烁问题是最常见的,我遇到的第一版就是。表现是每帧单看都不错,连起来像老式日光灯在闪。根本原因是模型没有获得任何跨帧的强约束,只要每帧的真假判断过关,它就没有动力去维持一致性。解决办法我在前面已经提过:帧差分损失 + 判别器看差分输入。这里补一句,帧差分损失的权重不要一次给太大,我给到 0.3 左右开始有感觉,给到 1.0 以上画面会明显变模糊,因为模型会倾向于生成"变化很小"的画面来让差分统计更接近真实值的均值。
判别器过强这个坑特别隐蔽。表现是生成器的损失一直在高位震荡,看不出下降趋势。你可以这样快速判断:每 100 步记录一下判别器对真实样本和生成样本的平均打分,如果真实样本稳定在 0.95 以上,生成样本稳定在 0.05 以下,那就是判别器太强了。处理方法:给判别器降学习率、给它加输入噪声、减少它的更新频率,或者停掉判别器更新几百步让生成器追一追。
长序列后半段崩坏。这个现象在 T=32 以上特别明显。我的处理方式比较务实:不硬扛。先把训练片段定在 8~16 帧,生成时用滑动窗口分段生成再拼接,拼接处用几帧过渡做混合。这不算优雅,效果却足够好,而且工程复杂度低。硬要让模型一次生成 64 帧,在单卡条件下很难拿出让人满意的结果。
潜变量失效,也就是不同随机输入生成几乎一样的内容。表现很隐蔽,因为画面质量本身没问题,只是多样性差。检查方法很简单:固定其他条件,只用 8 个不同的潜向量生成,对比一下两两之间的差异(可以用简单的像素差或特征距离)。如果差异极小,就说明潜变量没被用起来。常见原因是生成器容量过剩而数据量太少,或者重建类损失权重过高把多样性压平了。
提示:所有排查动作都要建立在日志完备的基础上。我每 100 步必记:两个判别器头的真实/生成打分均值、生成器总损失、帧差分统计、当前学习率、显存峰值。这五项数据在排查时几乎能定位八成的故障。
5.3 我踩过之后才明白的几条
第一条,先保证能跑,再谈好看。我一开始就追求 128 分辨率 16 帧的完整效果,结果两周时间全花在显存和训练稳定性上,架构和损失的设计验证几乎没推进。后来退到 64 分辨率 8 帧,三天跑通了完整链路,后面所有改进都有了对照基线。这个顺序不能反。
第二条,可视化抽样的频率要高于看指标的频率。指标是滞后的、聚合的,很多问题(比如模式崩溃、静默失效)在指标上要过很久才显现。我现在的习惯是每天固定抽 16 段可视化,存到一个固定目录,按日期命名,翻一下就知道模型最近在往哪个方向走。
第三条,数据质量对结果的影响远超架构微调。我做过一次对照:同一套模型,一组用未清洗数据,一组只加了那三条很土的筛选规则(静止剔除、切换剔除、亮度突变剔除),其他完全一样。清洗组的 FVD 好了不少,画面抖动也显著更少。所以每次有人问我"架构怎么改能提升",我先问的是"数据洗了吗"。
第四条,别迷信一次生成完整片段。分段生成 + 过渡混合这个思路看着不高级,但在工程上极其可靠,而且它把"长程一致性"这个硬问题拆成了若干个小问题,每个都更容易解。我在实际交付里反而更常用这个方案,纯端到端的长片段生成,只在算力充裕、内容简单的场景下用过。
最后说个我在调这套东西时形成的小习惯:每次改配置,只改一个变量,并且把改动写在日志文件第一行。听着很基础,但视频训练的反馈周期长,一个实验动辄一两天,不同变量混在一起改,你根本不知道自己是被哪一项坑了。我吃过亏之后,现在哪怕是改个学习率,也会在配置里把旧值注释掉留个痕。这个习惯本身不产生任何技术价值,但它让我少走了太多弯路。