1. 帧间预测里最容易被忽略的两个模式:Skip 和 Direct
做视频编解码的朋友,尤其是啃过 H.264/AVC 标准的,大概率都有过这种体验:帧内预测还好,无非就是九种模式挨个算一遍 SATD 或者 SAD,选最小的那个就完事了。但一到帧间预测,尤其是 B 片和 P 片里的 Skip 模式和 Direct 模式,很多人就开始犯迷糊了——运动矢量到底从哪来的?参考帧索引又是怎么定的?为什么有时候明明没传 MV,解码器却能“凭空”恢复出一个运动矢量?
这篇文章就专门把 H.264/AVC 中Skip 模式和Direct 模式下运动矢量(MV)和参考帧索引(ref_idx)的推导过程掰开揉碎讲清楚。如果你正在做编解码器的开发、优化,或者正在写自己的 H.264 解码器,再或者你是个对视频压缩底层原理感兴趣的学生,这篇内容应该能帮你省下不少翻标准文档的时间。我会尽量用大白话把推导逻辑讲透,同时给出可以直接对照代码实现的步骤。
先说结论:Skip 和 Direct 这两个模式,本质上都是“不传或少传运动信息”的编码工具。它们的核心思想是利用时间域上的相关性和空间域上的相关性,让解码器自己把 MV 和 ref_idx 推出来。推得准,码率就省下来了;推不准,残差就大,反而得不偿失。所以理解推导规则,是写好编码器决策逻辑的前提。
2. 为什么需要 Skip 和 Direct:从码率账本说起
2.1 一个 MV 到底要花多少比特
在 P 片中,每个宏块如果走普通帧间预测,需要传输运动矢量差(MVD)、参考帧索引(ref_idx)以及残差系数。MVD 本身经过熵编码后,在低码率场景下可能占到整个宏块开销的 30% 到 50%。你想想,一个 16x16 的宏块,如果纹理很平坦、运动又很规则,传一堆 MVD 和残差其实非常浪费。
Skip 模式就是针对这种情况设计的:整个宏块不传任何运动信息,也不传残差系数。解码器根据邻居宏块的 MV 推导出一个预测 MV,直接拿来做运动补偿,重建像素就是预测像素。这在会议视频、监控画面这种背景大面积静止或匀速平移的场景里,能省下大量比特。
2.2 Direct 模式在 B 片里的特殊地位
B 片比 P 片多了一个方向选择——前向、后向、双向。如果每个宏块都要显式传两个方向的 MV 和 ref_idx,开销直接翻倍。Direct 模式允许 B 片宏块不传 MV 和 ref_idx,由解码器根据已解码信息推导出前向和后向的运动信息。它分两种:时间 Direct(Temporal Direct)和空间 Direct(Spatial Direct)。这两种模式的推导逻辑完全不同,也是本文的重点。
从编码器角度看,Skip 和 Direct 是“零运动开销”或“极低运动开销”的候选模式。在做率失真优化(RDO)时,它们通常和普通 Inter 16x16、Inter 16x8 等模式一起参与比较。如果 Skip/Direct 的率失真代价更低,就选它。理解推导规则,才能正确计算它们的率失真代价。
3. Skip 模式的 MV 和 ref_idx 推导:P 片与 B 片的差异
3.1 P 片 Skip:中值预测一把梭
P 片中的 Skip 模式,推导规则相对简单。对于当前宏块,取左边宏块 A、上边宏块 B、右上宏块 C(如果 C 不可用就用 D 替代)的运动矢量。然后对这三个 MV 的每个分量分别取中值:
- mvL0_x = Median(mvA_x, mvB_x, mvC_x)
- mvL0_y = Median(mvA_y, mvB_y, mvC_y)
参考帧索引 ref_idx 呢?P 片 Skip 模式下,ref_idx 固定为 0。也就是说,它只参考最近的那一帧。这个设计很好理解:Skip 模式追求极致的低开销,如果还要传 ref_idx,那就不是 Skip 了。固定 ref_idx=0 虽然不够灵活,但在大多数低运动场景下够用。
注意:这里的中值预测和普通 Inter 模式的 MVP(运动矢量预测)用的是同一套邻居宏块。区别在于,普通 Inter 模式会传 MVD 来修正,而 Skip 模式直接拿预测值当最终 MV。
3.2 B 片 Skip:双向 MV 的推导逻辑
B 片的 Skip 模式就复杂一些了。它需要推导出前向(L0)和后向(L1)两个 MV,以及对应的 ref_idx。推导过程分两种情况:
情况一:如果邻居宏块中存在后向 MV(即使用了 L1 参考帧),那么:
- 对 L0 的 MV,取邻居中所有 L0 MV 的中值;如果某个邻居没有 L0 MV,则其 L0 MV 视为 0。
- 对 L1 的 MV,取邻居中所有 L1 MV 的中值;如果某个邻居没有 L1 MV,则其 L1 MV 视为 0。
- ref_idx 方面,L0 的 ref_idx 取邻居中 L0 ref_idx 的最小值(通常为 0),L1 同理。
情况二:如果邻居宏块中不存在后向 MV,那么 B 片 Skip 退化为类似 P 片 Skip 的行为:只推导 L0 的 MV,L1 的 MV 置为 0,且 ref_idx 都取 0。此时实际上只做前向预测。
这里有个容易踩的坑:邻居宏块的可用性判断。如果左边宏块是帧内预测,它的 MV 视为 0;如果上边宏块不存在(比如当前宏块在图像最上面一行),也要做相应处理。标准里对“不可用”有明确定义,实现时必须严格对照,否则会出现解码器与编码器不一致的问题。
3.3 实操中的注意事项
在实际写代码时,Skip 模式的 MV 推导需要维护一个“邻居 MV 缓存”。每解码完一个宏块,就要把它的 MV 和 ref_idx 写入缓存,供后续宏块使用。这个缓存的更新时机很关键:必须在当前宏块重建完成之后、下一个宏块开始之前更新。如果顺序搞错,会导致 MV 预测出现偏差,进而影响整个帧的重建质量。
另外,中值预测的计算要注意整数溢出问题。MV 分量是有符号的,范围通常在 -8192 到 8191 之间(取决于 profile 和 level)。取中值时用 int 类型就够了,但如果你用 uint16 存储,一定要先转成有符号数再比较。
4. Direct 模式深度拆解:时间 Direct 与空间 Direct
4.1 时间 Direct 模式的核心思想
时间 Direct 模式(Temporal Direct)是 B 片中最常用的 Direct 模式之一。它的核心思想是:利用后向参考帧中对应位置宏块的运动信息,来推导当前宏块的前向和后向 MV。
具体来说,假设当前宏块在 B 帧中的位置是 (x, y),后向参考帧(通常是 P 帧或 I 帧)中同一位置的宏块为 colocated 宏块。如果这个 colocated 宏块使用的是 L0 预测(即它有一个指向更早帧的 MV),那么这个 MV 就记为 mvCol,它指向的参考帧记为 refCol。
然后,根据当前帧与前后参考帧的时间距离,做线性缩放:
- 设当前帧与 L0 参考帧的时间距离为 td(即当前帧 POC 减去 L0 参考帧 POC),当前帧与 L1 参考帧的时间距离为 tb(即 L1 参考帧 POC 减去当前帧 POC)。
- 前向 MV:mvL0 = mvCol * tb / td
- 后向 MV:mvL1 = mvCol * (td - tb) / td
注意这里的符号方向。标准里对 td 和 tb 的定义有明确的正负号规则,实现时如果搞反了,MV 方向就会完全错误。
4.2 时间 Direct 的 ref_idx 推导
ref_idx 的推导相对直接:L0 的 ref_idx 取 colocated 宏块的 ref_idx(即 refCol),L1 的 ref_idx 固定为 0。这是因为时间 Direct 假设后向参考帧中对应位置的运动信息可以“借用”过来,而 L1 参考帧通常就是最近的那一帧。
但这里有个特殊情况:如果 colocated 宏块的 MV 超出范围,或者 colocated 宏块是帧内预测,那么时间 Direct 模式会退化为“前向 MV 为 0,后向 MV 为 0”的情况,相当于不做运动补偿,直接用当前帧的预测像素。这种退化情况在实现时必须处理,否则会读到无效的 MV。
4.3 空间 Direct 模式的推导逻辑
空间 Direct 模式(Spatial Direct)不依赖后向参考帧的 colocated 宏块,而是完全利用当前帧内邻居宏块的运动信息来推导。它的推导过程分三步:
第一步,检查邻居宏块 A、B、C 的 ref_idx。如果 A、B、C 中至少有一个使用了 L0 且 ref_idx 为 0,且至少有一个使用了 L1 且 ref_idx 为 0,则进入双向推导;否则进入单向推导。
第二步,双向推导时,L0 的 MV 取邻居中 L0 MV 的中值,L1 的 MV 取邻居中 L1 MV 的中值。ref_idx 都取 0。
第三步,单向推导时,如果邻居中只有 L0 信息,则只推导 L0 MV,L1 MV 置 0;反之亦然。
空间 Direct 的优势在于它不依赖后向参考帧,因此在随机访问场景下更鲁棒。但它的预测精度通常不如时间 Direct,因为邻居宏块的运动信息不一定能很好地代表当前宏块。
4.4 两种 Direct 模式的对比与选择
| 特性 | 时间 Direct | 空间 Direct |
|---|---|---|
| 依赖信息 | 后向参考帧 colocated 宏块 | 当前帧邻居宏块 |
| MV 精度 | 较高,利用时间相关性 | 较低,利用空间相关性 |
| 鲁棒性 | 依赖后向帧可用性 | 不依赖后向帧 |
| 适用场景 | 常规 B 片编码 | 随机访问、低延迟场景 |
| 计算复杂度 | 需要访问 colocated 宏块 | 只需邻居信息 |
编码器在选择时,通常会根据序列特性做自适应。比如在低延迟配置下,后向参考帧可能不可用,此时空间 Direct 更合适;而在常规广播场景下,时间 Direct 往往能带来更好的压缩效率。
5. 从标准到代码:推导过程的实现要点
5.1 数据结构设计
实现 Skip 和 Direct 推导,首先需要设计好数据结构。建议为每个宏块维护以下信息:
- mvL0[16]、mvL1[16]:每个 4x4 子块的运动矢量(H.264 支持 4x4 到 16x16 的分区)
- refIdxL0[16]、refIdxL1[16]:每个子块的参考帧索引
- predMode:预测模式(帧内、帧间、Skip、Direct)
- 邻居可用性标志
对于 colocated 宏块,还需要额外存储其 mvCol 和 refCol。这些信息在解码后向参考帧时就要保存下来,供后续 B 帧使用。
5.2 时间距离的计算
时间距离 td 和 tb 的计算必须使用 POC(Picture Order Count)。POC 是 H.264 中用来标识帧显示顺序的变量,它和编码顺序无关。计算时要注意:
- td = Clip3(-128, 127, POC_curr - POC_L0)
- tb = Clip3(-128, 127, POC_curr - POC_L1)
如果 td 为 0,则直接令 mvL0 = mvCol,mvL1 = 0,避免除零错误。这个边界条件在实际码流中虽然少见,但必须处理。
5.3 MV 缩放的整数运算
标准里给出的缩放公式是:
mvL0 = (mvCol * tb + td/2) / td mvL1 = (mvCol * (td - tb) + td/2) / td注意这里的除法是整数除法,且要处理负数舍入问题。C 语言中整数除法是向零截断,而标准要求的是向负无穷方向舍入。所以实现时通常需要自己写一个带舍入的除法函数,或者用查表法加速。
实操心得:在 x86 平台上,可以用
(a + b/2) / b来近似,但当 a 为负数时结果可能差 1。建议直接用标准里的公式,配合条件判断修正。
5.4 邻居宏块的可用性判断
邻居宏块不可用的情况包括:超出图像边界、属于不同 slice、是帧内预测宏块。对于帧内宏块,其 MV 视为 0,ref_idx 视为 -1(表示无效)。在取中值时,如果所有邻居都无效,则 MV 直接置 0。
这个判断逻辑在实现时容易写错,尤其是 slice 边界的情况。建议单独写一个函数is_mb_available(x, y),把所有边界条件集中处理。
6. 常见问题与排查技巧实录
6.1 解码器与编码器 MV 不一致
这是最常见的问题。表现是解码出来的画面在 Skip/Direct 宏块处出现明显块效应或错位。排查思路:
- 检查邻居 MV 缓存的更新时机。必须在当前宏块重建完成后立即更新,不能提前也不能延后。
- 检查中值预测的输入顺序。标准里对 A、B、C、D 的选取有明确规则,顺序错了结果就错了。
- 检查时间 Direct 的 td/tb 计算。POC 的获取是否正确?有没有用编码顺序代替显示顺序?
6.2 时间 Direct 模式下 colocated 宏块不可用
如果后向参考帧中对应位置的宏块是帧内预测,或者该帧本身是 I 帧,那么 colocated 宏块不可用。此时标准规定:mvCol = 0,refCol = -1,最终推导出的 mvL0 和 mvL1 都为 0。实现时要显式处理这种情况,不能直接读内存。
6.3 MV 缩放溢出
当 td 很大而 tb 也很大时,mvCol * tb 可能溢出 16 位甚至 32 位。建议用 64 位整数做中间计算,最后再截断到有效范围。标准里对 MV 的范围有约束,超出范围的 MV 要 clip 到 [-8192, 8191]。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| Skip 宏块画面错位 | 邻居 MV 缓存未更新 | 打印每个宏块的 MV,对比参考解码器 |
| Direct 宏块出现块效应 | td/tb 计算错误 | 检查 POC 获取逻辑 |
| 解码器崩溃 | colocated 宏块指针为空 | 增加空指针判断 |
| 画面整体偏移 | MV 缩放舍入错误 | 用标准公式重新实现除法 |
| 随机访问时花屏 | 空间 Direct 推导错误 | 检查邻居 ref_idx 判断逻辑 |
6.5 独家避坑技巧
我在实现 H.264 解码器时踩过最大的坑是:时间 Direct 模式下,colocated 宏块的 MV 需要从后向参考帧的运动补偿结果中获取,而不是从码流中直接读取。因为后向参考帧可能本身也是 B 帧或 P 帧,它的 MV 是经过解码重建后的最终 MV。如果直接从码流读,可能会读到未修正的 MVD,导致推导错误。
另一个坑是:B 片 Skip 模式在邻居没有 L1 信息时,L1 的 MV 不是简单置 0,而是要根据 L0 的 MV 做反向推导。标准里对此有详细规定,但很多开源实现为了简化直接置 0,导致码流兼容性问题。
7. 性能优化与扩展思考
7.1 查表法加速 MV 缩放
MV 缩放涉及除法和乘法,在软件解码器中是热点操作。可以用查表法预计算 td 和 tb 的所有可能组合(td 范围 -128 到 127,tb 范围 -128 到 127),把缩放因子存成定点数表。这样每次缩放只需要一次乘法和一次移位,速度能提升 3 到 5 倍。
7.2 SIMD 并行化
Skip 和 Direct 的推导是按宏块进行的,但宏块内的 4x4 子块可以并行处理。用 SSE 或 AVX 指令可以同时计算多个子块的 MV 中值和缩放。不过要注意,邻居依赖关系可能导致数据竞争,需要仔细设计数据布局。
7.3 与 HEVC 的对比
HEVC 中的 Merge 模式和 AMVP 模式,本质上是对 H.264 Skip/Direct 的改进和扩展。Merge 模式相当于 Skip 的增强版,支持更多候选 MV;AMVP 则把 MVP 和 MVD 分开处理。理解 H.264 的推导逻辑,对学习 HEVC 的帧间预测非常有帮助。
7.4 实际编码中的模式选择策略
在编码器中,Skip 和 Direct 模式通常作为 RDO 的候选。我的经验是:对于静态背景或匀速运动区域,优先尝试 Skip;对于有遮挡或复杂运动的 B 片区域,优先尝试时间 Direct;在低延迟配置下,空间 Direct 更稳妥。具体阈值需要根据序列特性调优,没有万能参数。
最后分享一个小技巧:在调试 Skip/Direct 推导时,可以先把所有邻居 MV 打印出来,手动算一遍中值和缩放,再和代码输出对比。这个方法虽然笨,但能快速定位是逻辑错误还是数据错误。另外,建议用标准测试序列(如 Foreman、Mobile)做验证,这些序列的运动特性覆盖比较全面,能暴露大部分边界问题。