资讯动态

HEVC帧间预测MVP全解析:MV获取、传递与存储的代码链路

发布时间:2026/10/15 13:34:31 来源:尧图企业网站定制
刚开始啃HEVC参考软件HM代码的时候绝大多数人都会卡在帧间预测这一块。帧间预测是H.265压缩率的主要来源而MVPMotion Vector Prediction运动向量预测又是帧间预测里最绕的一环。我见过很多初学HEVC代码的同学花了两三周还在ME搜索和变换量化之间打转真正把MV的获取、传递和存储链路梳理清楚的屈指可数。其实这几个环节在代码层面是一条完整的单向流水线只要把“MV从哪里来、经过哪些数据结构、最终存到哪里、什么时候被再次读出来”这条主线抓住整个帧间预测的实现逻辑就豁然开朗了。这篇文章不是标准条文解读而是基于HM参考软件实现的一次代码阅读笔记同时也会牵扯到HEVC标准层面对MVP的约束。我会把MVP过程中MV的获取、传递、存储拆成三个独立但又互相咬合的话题来聊并且会补充我在读码和调试过程中踩过的一些坑。如果你正准备学习HEVC代码或者正在给某个视频编码器做帧间预测模块的开发和维护这篇文章可以帮你少走很多弯路。1. 从一帧画面说起为什么要单独研究MVP里的MV生命周期1.1 帧间预测的底层逻辑消除时间冗余视频编码之所以能压得这么狠核心在于时间冗余。视频里相邻两帧的内容往往高度相似与其完整编码当前帧每个像素不如以之前帧为参考描述“这一块从哪个位置搬过来的”。这个“搬移”参数就是运动向量Motion Vector简称MV它通常是一个二维坐标表示当前预测块相对参考块的水平偏移和垂直偏移。但这里有个容易搞混的点MV并不是高空掉下来的绝对值。编码器在进行运动估计ME时会在参考帧里搜索最匹配的块得到一个“真实”的MV但如果直接把完整MV用二进制写进码流代价太大了。于是标准设计了一个关键技巧先根据当前块周围已经编码/解码完成的运动信息预测出一个大概率非常接近真实MV的值也就是MVP然后只编码真实MV与MVP之间的差值MVD。解码端用同样的方式推导MVP再加上MVD就能无损还原真实MV。这个“预测-残差校正”的思路和帧内预测做像素残差是一模一样的道理只不过对象从像素换成了运动信息。1.2 MVP在编码器里的位置和作用MVP在编码器端主要有两个作用。第一个作用是为了划算地表示MV即AMVPAdvanced Motion Vector Prediction模式编码器构造一个候选MVP列表从中选一个作为预测值再传一个索引和MVD。第二个作用是彻底不传MVD的Merge模式编码器直接把某个候选的完整MV和参考索引搬过来用码流里只传一个候选索引连MVD都省了。不管哪种作用都逃不开一个核心操作从已经处理过的相邻块或时间同位块里“借”运动信息。既然是“借”就必须有一套严格可复现的读写规则。读码时你会发现标准里大量篇幅在描述候选位置的排序、可用性判断、缩放规则本质上就是在定义这套“借”的规章制度。这就直接引出了本文的核心命题MV是怎么算出来的怎么从块A传到块B又怎么从第n帧跑到第n2帧去参与预测。1.3 一次完整的MV生命周期概览为了让你先有一个整体地图我把MV的生命周期粗略画了一条链路后面每一节都会对应展开运动估计搜索得到初始MV。ME模块会在参考帧中搜索得到一组或多组率失真最优的MV候选。然后编码器构造MVP候选列表把预测MV算出来并计算出MVD。编码完成后MV信息和模式信息被写入到当前PU预测单元的运动参数中。当前帧的CTU编码树单元被刷新时所有PU的MV和参考索引会被收集并写入参考帧运动场。当后续帧做时域MVP时解码器或编码器再从运动场里读出同位块的MV做缩放后参与候选列表构造。你可以把这理解为工厂生产线的四个工位生产、包装、入库、出库。任何一环断了帧间预测都会直接崩掉。后面的章节我会按照这个顺序依次拆解。2. MV的获取编码端负责“生产”MV的两条路径2.1 运动估计搜索ME模块怎么算出真实MV运动估计的目标不是“找到最匹配的块”这么简单它需要在匹配代价和码率代价之间做权衡。HEVC参考软件HM里的运动估计主要分为整像素搜索和分像素细化两个阶段。整像素搜索阶段会在参考帧的一个搜索窗口内用SAD绝对误差和或SATD等准则评估候选位置找到整像素最佳位置之后再用插值滤波得到二分之一和四分之一像素精度的像素值继续做细化搜索。因为参考帧中并不存在真实的“中间像素”分像素位置的值全部要靠抽头滤波插出来所以运动补偿模块和插值滤波器在实现上必须高度配合。这一阶段产生的MV会以编码树单元CTU为粒度进行管理。HM的ME代码里每个CU都会维护一组预测运动信息候选在xEstimateInterPUV这类函数中会有专门的循环去调用xTZSearch系列函数。TZ搜索是HM里默认的快速搜索算法它结合了菱形搜索和光栅扫描比单纯的全搜索省很多运算量。你在读代码时不需要把TZ的每个参数都背下来但需要关注一个关键点ME搜索得到的MV在后续构造MVP列表时同样会被当作“已存在的运动信息”使用因为MV一旦确定下来当前CU在编码过程中就处于“可被周围块参考”的状态。这里有一个新手经常困惑的地方ME搜索出来的MV和MVP列表里的MV到底什么关系答案是ME产生的是“真实MV”MVP列表产生的是“预测MV”两者相减才是MVD。同一个运动矢量既可能作为真实值被编码又可能作为其他块的预测值被读取这就是MV传递的第一个含义信息复用。2.2 预测候选推导空域与时域MVP的生成规则MVP的候选来源分两大类空间域和时间域。空间域候选来自当前PU周围已经编码的相邻块HM里分五个位置取候选分别是左侧两块A0、A1上方三块B0、B1、B2。候选推导顺序是固定的先取A1再取B1然后B0、A0最后B2。取出来之后做可用性检查如果相邻块不存在、不在当前帧内、或者没有用帧间模式编码就直接跳过。这里和H.264的向量预测不同HEVC对空域候选的去重要求更严格相同运动参数的候选会被合并掉避免重复进入列表。目的很简单候选列表长度有限必须尽量装“信息量”大的选项。时间域候选是HEVC相比H.264变化最大的地方也是存储依赖最重的环节。它不参考当前帧周围的块而是跑到当前帧的参考帧里找到和当前块位置对应的同位块把同位块的运动向量拿过来用。因为当前块和参考块所在的帧时间距离不同拿来的MV不能直接用需要做一步缩放缩放因子由两个POC图像序列号距离决定即当前帧与当前参考帧的距离、当前参考帧与参考帧的参考帧的距离。这段逻辑在HM的xGetColMVP里写得很清楚核心变量就是iColPOC和iCurrPOC之间的比值。2.3 实际MV与预测MV的偏差处理MVD与参考索引有了真实MV和预测MVP编码器要计算差向量MVD。这个差向量和参考索引一起经过熵编码写入码流。解码端必须用完全相同的规则重新推导MVP才能正确恢复真实MV。因此整个推导过程不允许存在任何“猜测”每一处候选位置的选择、可用性判断、优先级排序都必须严格按照标准定义执行。这也是视频编码标准那么冗长的根本原因它必须让编码器与解码器在信息不完全一致的情况下仍能复现完全相同的决策路径。在实际代码里你会在编码器里看到大量getMVPIdx之类的函数它们负责返回最终选择的MVP在候选列表里的索引。这里有一个很容易被忽略的细节AMVP的候选列表只装两个候选项选MVP时通常通过率失真代价来比较而Merge模式的候选列表最多装五个Merge模式下不传MVD只传索引。编码器在选择Merge候选和AMVP候选时往往要跑一遍运动补偿甚至残差变换这个开销不小所以很多工程优化会直接通过早期跳过机制减少Merge候选的率失真计算。3. MV的传递从PU边界到参考像素的完整链路3.1 编码树结构中的传递路径CTU到CU再到PUHEVC的编码划分是树状的一帧图像先切成若干CTU每个CTU可以递归四分叉划分成CUCU再划分成PU。运动信息是挂在PU层面上的因为一个CU如果采用帧间模式至少包含一个PU每个PU拥有独立的MV和参考索引。所以从数据流的角度看MV的传递路径是CTU的候选信息先传递给CU的预测结构CU再根据模式决定PU如何切分最后每个PU从自身维护的运动参数结构里读取和写入MV。在HM代码里这个“挂载”过程体现在TComDataCU的成员变量上。每个CU都持有自己的CUMvField里面以固定粒度的数组保存了MV和参考索引。你在调试时可以打印某个CU内部每一个4x4块左上角的运动参数会发现同一个PU内部的运动参数是完全相同的。这就是所谓的“运动参数以PU为粒度共享”数据存储在最小粒度上但读写索引方式保证了块级共享。这里想多说一句真正看懂这段代码不能只看PU的划分逻辑还要看TComDataCU里那些getMotionVector和setMotionVector函数。它们接收的索引往往不是普通的CU序号而是按4x4块换算出来的平面索引。这种换算逻辑非常容易在改动代码时踩坑尤其是当PU尺寸不是4的整数倍时你在做边界裁剪时必须特别小心否则读一个运动向量会读到别的PU的数据。3.2 Merge模式与AMVP模式在传递方式上的差异Merge模式的特点是全继承当前PU不产生自己的新MV而是直接把候选列表里某个候选的完整运动参数MV、参考索引、预测方向复制到自己身上。整个传递过程是“纯拷贝”不经过任何缩放或修正。所以Merge模式特别适合那些处于大平坦区域中的块运动一致性很高的场景下压缩效率会非常好。AMVP模式则不同它只继承候选MV作为预测基准最终MV是预测基准加上MVD的结果。这导致一个后果AMVP模式下候选MV的运动矢量精度必须是已知的但参考索引可以由当前PU单独选择不一定和候选块相同。因此在传递流程里AMVP比Merge多了一步MVD的熵编码和解码也需要多传递一个MVP索引。从代码结构来看HM在预测搜索阶段会先跑一遍Merge候选的评估然后再评估常规的AMVP模式。你会发现getInterMergeCandidates和xGetMVPIdxCand这两条代码路径走的逻辑很不同前者会填充完整的候选结构体数组后者只填充MVP和索引。如果在工程上做优化这两块往往也是分开维护的因为Merge的候选结构体还需要保存参考帧列表方向而AMVP只需要保存一个MVP向量。3.3 逆搜索过程解码端如何复现MV解码端不具备运动估计能力它只能严格执行标准定义的推导流程。在AMVP模式下解码器先用当前位置周围已经解码完成的运动信息构造MVP候选列表然后从码流中读取MVP索引和MVD把二者相加得到实际MV再拿这个MV去参考帧中取像素做运动补偿。整个过程的准确度完全依赖“已解码运动信息”的一致性。这就是为什么标准强制要求所有块必须在空间和时间上都保持严格的依赖顺序你不能在右侧块还没解码时就去读它的MV做预测。在HM的解码端代码里TDecCu::xReconInter会通过m_cInterPrediction做运动补偿而在进入运动补偿之前TDecCu::decodeCU就已经把MVP和MVD组合好了。这里有一个很值得新手学习的小技巧解码端构造候选列表和编码端构造候选列表用的是同一套函数共享代码在工程上极大降低了编解码不一致的风险。如果你自己写编码器请务必保证这一步绝对复用不要在解码端“优化”掉任何候选去重或可用性检查。3.4 传递过程中的数据组织候选列表构建细节MVP的传递最终都归结为候选列表的构建。空域候选和第时域候选会先进入同一个池子然后按照既定顺序排序并去重。HM里有一个细节对空域候选而言并不是直接把相邻块的MV塞进池子就完事还需要记录这个MV是来自哪个参考帧列表、参考索引是多少。因为后续根据候选做运动补偿时这些信息要一起用。很多标准讲解只讲“MV的预测”实际上参考索引和列表方向同样是候选的一部分。去重逻辑也值得单独说。空域五个位置可能存在大量重复运动参数比如当前PU很大左下和右下其实处于同一个运动区域这时候列表里会留下完全相同或者按缩放后相同的候选。HM里的去重判断非常直接候选的MV、参考索引、预测方向全部相等才算重复。如果你改动参考索引的初始值导致默认候选是“无参考帧”状态很可能会跳过去重然后向列表里塞进一堆无效候选这个问题在调试时相当隐蔽。4. MV的存储参考帧运动场与缓存管理4.1 为什么必须缓存参考帧的MV时域MVP的依赖关系时域MVP是这个环节存在的唯一理由。当前帧做预测时要去参考帧里找同位块把这个同位块的MV作为候选。问题是参考帧的MV在解码完成后如果只保留重建像素而不保留运动信息后续帧就无法做时域MVP了。因此每个参考帧除了要保存像素缓冲区还必须保存一份运动参数场。这份运动参数场会和像素一起放进参考帧列表DPB中统一管理。从系统层面看这个缓存带来的内存开销相当可观。HEVC的MV数量级是很大的如果按4x4块粒度来存一帧1080p图像就有大约8万个运动矢量每个运动矢量如果拆成两个int16分量再加上参考索引和预测方向内存开销完全不是小数。也正因如此标准允许解码器以16x16或更粗的粒度导出运动参数来降低内存需求而参考软件HM出于精度考虑默认按4x4粒度维护后续的VTM等实现也同样选择了4x4的MotionBuf结构。4.2 存储粒度与内存布局聊到存储粒度很多初学者不理解为什么标准里对“最小导出单元”有这么多限制。简单解释一下时域MVP取同位块时要定位到中心位置如果运动场粒度太粗中心位置落入的块可能覆盖很大的面积导致提取到的运动信息不够精确。粒度过细则内存和带宽压力大所以在标准层面做了一定的平衡。HM实现中每个TComPic拥有一个额外的运动参数缓冲区它和亮度样本缓冲区尺寸相匹配。读取某个坐标处的运动信息时会根据坐标位置换算到对应4x4块的行列号。这里如果遇到不在参考帧范围内的坐标实现会直接返回不可用状态而不是越界访问。我在自己的开发中就遇到过这样的bug参考块坐标在分像素精度下做四舍五入后跳到帧边界上直接读取导致内存越界加一个边界钳制就稳定了。4.3 运动参数存储的更新时机与生命周期运动参数场不是一帧全部重建完成后才一次性生成的而是随着每个CU的解码过程实时更新的。当一个CU以帧间模式完成重建后它的运动参数立刻写入当前帧的运动场中。这个设计保证了后续CU做空域MVP时能读到刚解码完的相邻块信息。标准之所以要求编码顺序和解码顺序一致这是根本原因之一。更新时机带来的一个重要工程结论是你不能在整帧解码完成之后再统一填运动场。否则空域MVP推导会拿到尚未初始化的运动信息编解码结果就会错乱。在HM代码里你会看到setMotionField这样的函数在每个CU解码后就被调用。如果做并行解码或瓦片编码这块缓存的一致性就变得极难维护这也是很多并行编码器设计里最麻烦的问题之一。运动场生命周期与DPB绑定。一帧图像的运动场会一直存活直到它从DPB中被淘汰。如果开的是低延迟编码配置参考帧数量少运动场内存还能接受一旦开B帧层次编码DPB会缓存多帧参考图像运动场占用的内存经常能占到整个DPB缓冲区的三分之一左右。在做嵌入式移植时这往往是被忽略的一项内存开销。4.4 DPB与内存带宽的权衡DPB管理不该被当成纯粹的内存问题它对编码性能同样有直接影响。每次做时域MVP编码器都要去DPB中的某个参考帧读取运动场这是一块额外内存访问开销和运动补偿阶段读像素是并行的数据流。如果设计解码器时只优化像素读取而忽略运动场读取缓存命中率会很难看。在实践层面我建议做代码移植时把运动场从像素缓冲区中分离出来单独做成一个连续数组并保证和像素缓冲区使用相同的行数对齐。很多新手会把运动场拼在像素区的尾部表面省了分配次数实际上会导致按行读取时缓存行相互干扰得不偿失。另外如果芯片本地存储有限可以考虑把运动场的存储粒度提高到8x8或16x16来换空间。代价是时域MVP的精度会有轻微损失但这种损失在绝大多数码率下都小于百分之一是值得做的工程取舍。5. 实战排查学习HM代码时常见的几个问题5.1 空域候选访问越界问题空域候选推导需要访问当前PU左侧和上方的多个位置最容易出的问题就是坐标换算导致的越界。尤其是在CU划分不规则的情况下某些相邻块可能没有完成编码导致读取到的MV是无意义的初始化值。还有可能是当前块靠图像边界候选位置本身就不存在。HM代码里有大量的isAvailable类判断很多新手为了图省事把这些判断删掉结果编解码结果完全错乱。我的建议是不要只在逻辑上保留这些判断还要在DEBUG模式下加入坐标范围的断言。MV传递链路很长一旦发生越界错误会延迟到很多个帧之后才在图像上体现出来排查难度极大。特别是用O2优化编译时越界读可能不会立刻崩溃而是读到了之前的旧运动场数据这种错误几乎是隐形的。5.2 时域MV缩放精度问题时域候选的MV缩放是MVP里最容易产生编解码不一致的环节。参考软件里通常使用带精度的定点乘法来实现缩放比如先左移若干位再乘分子分母最后右移还原。如果你在移植时把中间运算的位宽或舍入方式改了哪怕只是差了1也可能让编码器推导的MVP和解码器推导的MVP不一致进而导致后续所有MV重建全部错位。排查这类问题有一个屡试不爽的办法在编码器端和解码器端各加一个调试开关把每个块的MVP候选值全部打印出来逐步比对。尤其是不同参考帧组合下缩放因子为负值或者分母为零的特殊情况一定要单独处理。HM里对这种情况会置候选不可用你在自己的实现里也必须有同样的分支。5.3 Merge候选列表的去重与长度控制Merge候选最多五个这个上限是硬性的。候选池子里可能填不满五个也可能因为重复过多被去重后明显减少。很多实现为了效率会在候选数不足时用一些补充手段填满列表比如直接复制第一个可用候选或者使用零运动向量。这些补充手段本身不复杂但必须保证编解码两端严格一致。我在实际调试中遇到过一个诡异问题编码器端的候选列表是五个解码端因为某个去重条件判断里参考索引值不一样只剩四个结果从第五个索引解出来的是完全错误的运动信息。最后定位原因竟然是一个CU在编码过程中被提前标记成了帧内模式参考索引被重置导致去重时判定逻辑分叉。这类问题很隐蔽排查时不要只盯着MVP推导也要检查当前块和相邻块的模式状态。5.4 运动场更新与缓存一致性问题如果你在参考软件上做了并行化改造最容易被坑的就是运动场更新的时序。帧间预测时空域候选读取的必须是当前帧已经完成重建的块时域候选读取的必须是DPB中完整帧的运动场。这两类数据的生命周期完全不同前者是边解码边写后者是整帧稳定之后才可读。一个比较稳妥的做法是给每帧运动场加一个状态标记标记为“写入中”“已完整”“已被替换”三态。任何时域MVP读取操作都要检查目标帧的状态不是“写入中”。我当时在一个多线程解码器里遇到花屏问题排查到最后就是某个线程在参考帧运动场还没写完整的时候就去读了加了一个状态锁之后立刻解决。这个经验不一定适用于所有项目但它提供了一个排查方向先怀疑缓存时序再怀疑计算公式。编解码这条技术路向来是细节决定成败。MVP里的MV获取、传递、存储这三件事单独看都不难难的是它们之间的耦合关系ME产生的MV要能精确落入运动场运动场的存储格式要能让空域和时域候选在毫秒级延迟下读取而候选列表又要把这些庞杂信息收敛成几个有限的索引值。如果把这条链路吃透了回头再看HEVC整个帧间预测部分你会觉得大部分代码都只是这条主链路上的具体实现分支。我自己读HM代码那段时间最大的收获就是学会了把“标准怎么说”和“代码怎么写”放在一起对照而不是孤立地啃某一个函数。如果你也在啃这块代码建议从TComDataCU的MV字段入手顺着一次完整的帧间CU编码流程把读和写都跟一遍效果会比只看文档好得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑