尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

AVS3参考软件HPM4.0源码阅读指南:从编译到掌握核心模块

AVS3参考软件HPM4.0源码阅读指南:从编译到掌握核心模块 两年前我第一次打开AVS3的HPM4.0参考软件源码时第一反应是这比我预想中大太多了。几十个目录、上百个源文件、几十万行代码而且没有注释的地方占了大半。更麻烦的是当时网上能搜到的资料基本都停留在“AVS3比HEVC节省多少码率”“AVS3是面向8K的新一代标准”这类层面真正告诉你“这些性能是从哪一行代码里长出来的”的文档几乎没有。这篇文章想聊的就是我一个人把HPM4.0从“能编译”磨到“能看懂”、再磨到“能改着做实验”的完整路径。如果你是刚接触AVS3的研究生、从H.264/HEVC转过来想快速上手的工程师或者单纯想搞明白参考软件内部到底怎么工作的爱好者这篇东西应该能帮你省掉不少瞎转悠的时间。它不会逐行给你讲代码但会给你一张地图——有了地图读代码就只是按图索骥的事。1. 先别急着读代码HPM4.0在AVS3生态里的定位很多人拿到HPM4.0源码后的第一个动作是双击打开某个.cpp文件从头开始读这基本等于拿到一本字典然后决定从A字母背到Z字母不是不行但效率极低。在碰代码之前我们得先搞清楚参考软件在整个AVS3生态里到底扮演什么角色。AVS3标准文档描述的是一套“抽象的语法和解码规则”——它规定了码流长什么样、解码器应该怎么解释这串比特。至于编码器怎么把原始像素变成这串比特标准文档基本不管那是编码器的自由发挥空间。而参考软件HPM4.0是AVS工作组发布的一份“既能编码又能解码”的可执行参考实现。它的意义有两个对解码器来说它是检验码流合法性的基准对研究者来说它是新算法落地的试验田。这里要解释一下HPM这几个字母。如果你经常逛AVS相关的代码仓库会发现参考软件有很多系列RD系列、HPM系列、还有后来的各种分支。HPM是High Performance Mode的意思翻译过来就是“高性能模式”HPM4.0是这个模式系列里的一个具体版本配置。它跟RD系列最大的区别在于HPM里默认开启的工具组合更“激进”以编码效率优先不太在意编码耗时。简单说同一条视频序列喂进去HPM4.0跑出来的码率更低、质量更好代价是编码时间感人——我曾经拿一个1080p的短序列测过单帧编码时间能用“秒”来计。这不是bug这是设计取向。所以你在读代码的时候心態首先要摆正HPM4.0是一份为了“验证标准工具集性能上限”而存在的代码不是一份为了“工业界实时编码”而优化的代码。很多你在论文里看到的工具比如扩展四叉树划分、解码端帧内模式推导、仿射运动补偿在HPM4.0里都是以“默认开启”或“配置可开”的形式存在的。你要做的事情是在这份代码里找到它们各自住在哪、跟邻居怎么通信、为什么这么设计。读参考软件还有一个隐性好处它是标准文档的“唯一无歧义说明书”。标准文档里一句话“对参考像素进行滤波”可能对应代码里几十行你对齐半天都搞不清逻辑的实现。代码不会说谎哪怕它的变量命名再随意跑起来是什么行为就是什么行为。所以我的建议是标准文档要备着但遇到理解分歧时以代码实际行为为准。如果你手里的版本号跟我这里说的细节对不上比如函数名略有出入、配置文件选项不一样不用慌参考软件迭代很快不同tag之间的代码差异经常存在你以自己那份源码为基准就行。2. 环境准备让HPM4.0先跑起来再谈阅读在读任何代码之前先让它编译通过、跑出码流、再跑回解码出重建视频这步一定要先做。为什么因为只有当你能亲手验证“代码确实在干活”后续你才敢在关键位置下断点、改参数、观察行为变化。一个永远处于“正在编译”状态的工程你是没有办法建立信心的。先说获取源码。HPM4.0并没有像一些开源项目那样挂在一个固定官网下载通常是通过AVS工作组的代码仓库获取或者在各院校、研究机构分享的镜像里找到。拿到手后先看顶层目录结构。AVS3参考软件的代码组织方式跟HM、VTM这些编码器参考软件是一脉相承的大致会分为这样几个部分编码器应用层类似TAppEncoder、解码器应用层类似TAppDecoder、公共库/核心算法库类似TLibEncoder和TLibCommon、以及数据/配置文件目录。编译之前建议先检查一下你的CMake版本和编译器版本。HPM4.0这个年代的项目对C标准的要求不算苛刻但老版本编译器遇到新代码照样会报一堆“语法错误”其实不是代码错是编译器太老。我自己在Ubuntu上用的是gcc 7.5以上的版本Windows上用的VS2019都能顺利编过。如果你是第一次编译直接用CMake生成Makefile或者VS工程就行一般不需要额外装第三方依赖库——这一点比很多开源项目省心。编译常见失败之一找不到某个头文件或者报“xxx未定义”。这类问题大概率是某些工具没有默认开启源码里的条件编译宏被顶掉了。解决办法不是硬改代码而是去查CMakeLists.txt里跟这个宏相关的选项重新配置后再编。编译通过之后找一个标准测试序列来跑。AVS3常见的测试序列是YUV格式比如Class A到Class E的各类分辨率。如果你是第一个晚上就想看到效果别拿高分辨率跑选一个352x288或者416x240的小序列几帧就行。命令行大致长这样./hpm_encoder -c encoder.cfg配置文件encoder.cfg里最重要的几项是InputFile输入YUV文件的路径SourceWidth、SourceHeight输入图像尺寸FramesToBeEncoded编码帧数FrameRate帧率IntraPeriodGOP里的I帧间隔-1表示全I帧QP量化参数越大码率越低、质量越差如果你跑的是全I帧IntraPeriod-1那输出就是一个帧内编码码流可以用来验证帧内预测那条链路如果想跑带运动搜索的就得配置成IPPP或者IBBB的结构。第一次跑通建议用全I帧因为链路短出问题时好排查。跑通编码之后记得把解码器也跑一遍。参考软件的解码器通常是一个单独的可执行文件命令行格式一般是./hpm_decoder -b output.bin -o rec.yuv解码出来的rec.yuv跟原始输入YUV做一下PSNR对比数值正常比如QP32时PSNR在35dB左右说明整个编码解码闭环是通的。到这步环境准备才算真正完成。调试版本和Release版本的选择也值得说一句。你阅读代码期间建议编一个Debug版或者RelWithDebInfo版这样能在函数里下断点、看变量值。但Debug版跑起来是真的慢尤其HPM这类高性能模式配置一帧跑几十秒很正常。我的做法是同时在build目录下编两个版本一个Debug版用来跟踪逻辑一个Release版用来跑实验拿数据。不要指望一个版本解决所有问题。3. 主调用链从main函数到图像压缩环境通了之后整个人就有点底气了。接下来要做的不是随便点开一个文件开读而是沿着“入口 - 配置 - 编码一帧 - 编码一个块”这条主干走一遍把整棵树的骨架摸出来。参考软件的程序入口一般在编码器应用层一个类似encmain.cpp的文件里。从main函数进去你会看到它做了几件事解析命令行参数、创建编码器实例、初始化编码器、然后进入一个循环不断喂图像进去。这个循环是编码器的主循环读一帧YUV数据调用编码器的encode函数传入帧数据encode内部会先做帧级初始化然后按光栅扫描顺序遍历这一帧的所有CTU编码树单元每个CTU进入递归的xCompressCU函数开始划分和模式决策编码完一帧后输出码流回到循环读下一帧。如果你在xCompressCU这个函数入口下一个断点你会惊恐地发现这个函数会被调用几万次——别慌那恰恰说明你摸到了整棵决策树的根部。xCompressCU是什么它是编码器里最核心的递归函数对每个编码单元做“划分还是不划分、用什么预测模式”的决策。它的逻辑大体是先尝试把当前块当做一个整体进行编码计算率失真代价RD Cost再尝试将它划分成若干子块具体子块形状由划分结构决定对每个子块递归调用xCompressCU比较“不划分”和“划分”的代价更小的胜出决定最终的编码树结构。这个递归在代码里通常表现为第一次调用时传入的是整个CTU然后函数内部根据划分标志对子区域逐一递归调用自身。理解了这个递归你基本就理解了整个编码器为什么要这样组织代码——因为码流里本来就存的是一棵四叉树兼容其他划分的树结构代码只是把这棵树的构建过程用递归实现了。读完主干之后你再看其他模块视角会完全不一样。帧内预测不是在某个角落里独立存在的代码它是在xCompressCU决策“这个块用帧内模式”时被调用的工具函数帧间预测也是只是在决策“用帧间模式”时被调用的另一套工具。所以主线永远是xCompressCU其他全是它的支线。这里还要提醒一点参考软件的调用链特别深一个函数套一个函数很容易跟丢。我的习惯是每追一个调用就记一笔笔记画一画调用关系。不需要画得很漂亮自己能看懂就行。比如encode(帧) - xCompressCU(CTU) - xCheckRDCostIntra(当前CU) - estIntraPredQT(遍历预测模式) - predIntraAng(实际做预测) - xTransformAndQuantize(变换量化) - xCheckRDCostInter(当前CU) - xEstimateMv(运动估计)这张草图就是你读代码的导航图。4. 数据结构地图CTU、CU、PU、TU之间的关系读编码器代码最容易让人崩溃的不是算法多复杂而是数据结构之间绕来绕去的指针关系。AVS3参考软件里几个高频出现的类的名字大概是CodingUnit编码单元、PredictionUnit预测单元、TransformUnit变换单元、以及对应的CTU根节点。搞懂它们之间的关系等于拿到了整个代码库的地图。先打个生活化的比方。CTU就像一整块土地编码器决定把它切成几块地皮每块地皮是一个编码单元CU每块地皮上怎么盖房子是预测单元PU的事房子内部怎么装修是变换单元TU的事。这三者并不是完全独立的——AVS3里有联合划分的概念PU和TU的分割形状经常是从CU的划分里继承下来的具体继承规则在代码里对应着一张张查表函数和条件分支。在代码里通常每个CTU会有一个根节点对象里面挂着一组子节点指针。每个子节点指向一个CUCU内部又通过firstPU、nextPU这样的指针把所有预测单元串起来变换单元之间也有类似的链表关系。你在调试时打印一个CTU的指针结构看到的其实就是一棵树根是CTU枝干是嵌套的CU划分叶子是PU和TU。这块有个特别容易看晕的地方参考软件里同一个功能往往有多个入口。比如“获取当前CU的宽度高度”你可能会在至少三四个类里看到类似的成员函数或局部变量。这是因为不同模块对数据的访问角度不同帧内预测关心的是参考像素范围变换量化关心的是残差块尺寸熵编码关心的是语法元素的上下文。同一个CU在不同阶段会被不同模块以不同方式“切”开来看。所以读的时候不要试图找一个“万能数据结构”而是要顺着当前模块的视角去找对应的访问方式。我在读这块时踩过一个很实在的坑想当然地认为PU的划分方式跟HEVC里一样结果对着代码找半天没找到预想中的“对称划分”标志。后来才意识到AVS3的划分体系比HEVC复杂不少除了常规的四叉树QT划分还引入了扩展四叉树EQT、二叉树BT和三叉树TT等划分方式。这些划分方式在代码里通常体现在划分标志位、划分类型枚举值以及对应的递归子块尺寸计算上。你如果带着HEVC的固有印象去读很容易把自己绕晕。我的建议是第一步把CTU怎么变成CU的整个过程画出来包括每个划分标志位对应的子块宽高第二步搞清楚PU怎么从CU继承划分它携带哪些与预测相关的信息帧内模式号、运动矢量、参考帧索引等第三步搞明白TU的划分边界在哪些情况下会跟PU不一致。这三步做完你基本就能自如地“按图索骥”了——看到一个算法模块你能迅速判断它作用在哪个结构上、访问哪些字段。如果嫌代码里直接跳转太累可以在调试器里观察。比如在xCompressCU里加个断点用调试器看当前CodingUnit对象的成员变量你会直观地看到宽高、深度、划分标志、预测模式这些字段的变化。这种观察比干读代码学得快得多。5. 帧内预测沿着RDO这条路走一遍在主干和数据结构都有了大致概念之后建议挑一个具体模块深入进去我推荐从帧内预测入手。为什么选它因为帧内预测不涉及运动估计那套巨复杂的搜索逻辑核心就一件事从周围已重建像素预测当前块然后比较各种预测模式哪个更好。逻辑链路短却贯穿了预测、变换、量化、率失真优化这些核心机制性价比极高。帧内预测的入口通常是xCheckRDCostIntra这类函数。它干的事情是对当前CU遍历所有候选帧内预测模式计算每种模式的率失真代价选出最优模式。AVS3的帧内预测候选模式跟HEVC那一套很不一样除了Planar、DC和传统角度模式外还有更多细分的角度模式以及一些解码端推导工具比如DIMD解码端帧内模式推导和TIMD模板帧内模式推导。不过HPM4.0里不是所有工具在所有配置下都默认开需要看配置开关。在这一函数内部流程大体是这样准备参考像素。预测不是凭空算的它要用当前块左侧和上方的已重建像素作为参考。代码里会先把这些像素拷贝到一个缓冲区可能还会做滤波平滑。这一步对应标准里的“参考像素滤波”对每种候选模式调用实际的预测函数生成预测像素块计算原始像素与预测像素的残差然后交给变换量化模块用编码残差所需的比特数和失真度算出RD Cost所有模式比完后取最小值对应的模式。你可能会问为什么帧内预测的代码里会牵扯到变换量化因为率失真代价里的“率”指的是把残差真正编码成比特之后的花费如果不走到变换量化就估不准。这也是为什么参考软件跑得慢——它为了精确衡量代价宁愿把后续的编码流程提前走一遍。我个人觉得在帧内预测这条链上最值得停下来多看一眼的是“参考像素准备”那一段。代码不长但坑不少边界情况当前块在图像边缘时左侧或上方没有像素怎么办、参考像素的滤波强度怎么选择、不同预测模式对参考像素的依赖差异。这些细节在标准文档里往往是一句话带过但真到优化算法或者硬件实现时全是要命的细节。读帧内预测时有个小技巧特别管用把预测模式的编号和像素重建结果绑定起来看。比如你用一个很小的测试序列在某个CU上打印每种模式对应的预测像素块你会很直观地看到角度模式为什么能压住纹理方向的残差Planar模式为什么在平滑区域表现好。这种感性认识是纯看代码得不到的。另一个建议是顺手给代码画一条“数据流”原始像素 - 参考像素 - 预测像素 - 残差 - 变换系数 - 重建像素。每到一个节点就找到对应的函数和数据结构标记下来。这条数据流不仅适用于帧内预测整份代码都是沿着这条流组织的。摸透了它你甚至能猜到一个陌生函数大概在流的哪个位置。6. 帧间预测运动估计、AMVP与Merge模式帧间预测是编码器里代码量最大、逻辑最绕的部分但读它的方法其实跟帧内预测一样沿着决策链走搞清楚每一步在比较什么、选什么。HPM4.0里的帧间预测核心要解决的问题是给定当前块在参考帧里找到一块最相似的区域把两者的位移用运动矢量MV表示出来并对运动信息本身做编码。在xCompressCU的决策树里帧间预测的入口一般是xCheckRDCostInter。它内部会做几个关键步骤第一步是运动估计。参考软件里通常有个专门的运动估计函数比如xEstimateMv一类的存在。它先做一个整像素搜索在搜索范围内找到匹配代价最低的位置然后在整像素附近做亚像素精化比如1/2像素、1/4像素甚至1/8像素的插值搜索。AVS3对运动矢量的精度支持包括整数、半像素、四分之一像素等不同档位具体用哪个档位由AMVR自适应运动矢量精度机制决定。第二步是运动信息的表示。AVS3支持多种运动信息编码方式最基础的两种就是AMVP先进运动矢量预测和Merge模式。AMVP的思路是先从一个候选列表里选出一个预测运动矢量MVP然后编码“当前MV与MVP的差值”这样能省比特Merge模式的思路更极端直接从候选列表里选一个运动信息作为当前块的运动信息几乎不用编码差值。第三步是运动补偿。选定了运动信息后从参考帧里把对应位置的像素块“搬”过来作为预测块。这一步看起来简单但AVS3里还有其他工具叠加在这个基础上比如仿射运动补偿Affine它允许块内不同位置有不同的运动矢量适合旋转、缩放等复杂运动还有基于子块的时间运动矢量预测SbTMVP这类更高级的工具。在代码里这些工具各有各的入口函数和对应的候选构建逻辑。读这块的时候我特别想强调一个词候选列表。AMVP有AMVP的候选列表Merge有Merge的候选列表每个列表的构建顺序、剔除重复候选的规则、候选取数上限都是标准里明确规定的也都一五一十地写在了代码里。我看过很多人在读帧间预测代码时一头扎进运动估计的搜索循环里结果把候选列表这块给跳过去了。这是本末倒置的——候选列表决定了运动信息的竞争范围它的设计逻辑反而更值得理解。我的建议是先从Merge模式读起它没有运动搜索逻辑更短方便你理解“候选列表 - 选最优 - 编码索引”这个套路。然后回到AMVP看它多出来的“预测差值编码”是怎么实现的。最后再去看运动估计的搜索循环那时候你已经不会被绕晕了。如果你是从HEVC那一代参考软件转过来的这里的代码风格你会觉得似曾相识但细节变化很大。AVS3的Merge候选列表类型更多、还包含了历史候选、成对平均候选等构成方式每个候选类型的优先级在代码里都有明确的插入顺序。读懂这个顺序你就能理解为什么相同运动信息在不同位置编码出来的比特数不一样。7. 变换、量化与重建环路从残差到系数再到重建像素预测做完之后剩下的殘差要去掉空间冗余这一步就是变换量化。在很多介绍视频编码的文章里“变换量化”通常是一笔带过的好像它们就是DCT加除法。但真到代码层面你会发现处处是坑而且这些坑直接影响最终输出码流。先说变换。AVS3的变换以块为单位先把残差像素块从空间域变到频率域得到变换系数。HPM4.0里不同尺寸的块会用不同的变换核比如DCT-II、DST等代码里通常以查表的形式把变换矩阵预存起来调用时按块尺寸选表。我第一次在代码里看到那几十个矩阵常量时一度以为自己在看某本数学手册。在变换代码里有一个细节特别值得看变换是否支持跳过。某些模式下比如帧内预测质量已经很好的块可能残差非常小这时直接对残差做变换反而产生大量非零系数不利于压缩。因此参考软件里有变换跳过Transform Skip机制代码里对应的分支就是在那次率失真比较里给“跳过变换”加一个候选。这类开关在HPM4.0里不是默认全开的配置开关一关代码里对应的分支就绕过去了。量化部分的作用是把连续范围的变换系数映射到有限的量化级别上。代码里量化通常用一个查表加移位的方法实现避免浮点运算。对应的反量化在解码器里也有实现思路是逆运算。这里有一个容易踩的坑正反量化的精度不匹配。参考软件的设计目标是“编码端算出来的系数解码端能无损还原”所以正反量化必须严格对齐。你如果在代码里改动量化步长解析、移位数量或者舍入方式非常容易造成编解码不一致然后输出一堆花屏。我踩过一次之后就养成了“动量化必做编解码闭环验证”的习惯。重建环路则是把预测像素和量化反变换后的残差加起来得到重建像素。重建像素会存入参考缓冲供后续块做帧内预测或帧间预测时取参考像素。所以你在代码里会看到编码器每编码完一个块会立刻把重建像素写回某个缓冲区。这个“边编码边重建”的流程是视频编码器跟普通图像压缩器最大的区别之一——解码器未来就是靠着同样顺序的重建像素工作的任何顺序偏差都会导致飘移。读这部分代码时我建议把重点放在“系数数据的流转”上残差进变换得到系数、系数进量化得到量化系数、量化系数被熵编码写入码流的同时、也走了反量化和反变换变成重建残差、最后跟预测像素相加进重建缓冲。每一步的缓冲区和中间变量在代码里都有对应物理清这个流转你对整个编码器的理解会突然变得非常立体。另外别忘了扫描顺序。量化系数在写入码流前不是随便排的要按特定的扫描顺序排列让零系数的分布更有利于压缩。AVS3里的扫描方式会根据帧内预测模式的方向做调整这部分代码看逻辑很简单但它跟帧内预测的方向性绑在一起理解它需要回看帧内预测那一章。这种“看似独立的模块其实互相咬合”的现象在参考软件里到处都是。8. 几个实操里最容易踩的坑到这里代码阅读的主线基本走完了但我知道你在实际操作中一定会碰到一堆跟“读代码”本身无关、却足以让你摔跟头的问题。我把这几年在HPM4.0上踩过的坑集中写一下希望能帮你省点时间。第一个坑配置文件和代码宏对不上。HPM4.0里有大量条件编译宏很多工具是“编译期决定存不存在配置期决定开不开”。如果你改了一个配置选项却发现行为没变大概率是那个工具对应的代码被你手里这个build版本给裁剪掉了。排查方法很简单在源码里搜配置项对应的宏名确认它被定义并且对应的条件编译分支真的在编译范围内。看到宏定义不等于宏生效还得看它有没有被#ifdef包住。第二个坑Debug和Release行为不一致。参考软件里偶尔会有依赖未初始化内存的行为Debug版会把未初始化的变量置成固定值看起来一切正常Release版编译器优化后未初始化变量可能是任何值结果码流完全不可复现。如果你在改代码过程中发现“我啥都没动但跑两次结果不一样”先怀疑未初始化变量再怀疑多线程竞争。HPM4.0的编码器在多线程配置下输出不一定可复现——这是参考软件的家常便饭不代表你的代码改错了。第三个坑改代码后忘记做编解码闭环验证。参考软件的编码器和解码器是两个独立程序你改了编码端的语法元素写码但忘了同步改解码端就会出现“编码器跑得挺好解码器解出一片花”的惨剧。我的习惯是只要动了语法元素、变换量化、预测模式相关代码立刻编解码闭环跑一个极小的序列哪怕只有一帧也要验证重建YUV和源YUV的PSNR在正常水平。第四个坑在错误的地方断点。参考软件的调用次数极其恐怖有些函数一帧里会被调用几十万次。你要是随便在某个热点函数上下断点会愣在调试器前面怀疑人生。正确做法是加条件断点比如只对某个特定坐标的CU、某种特定尺寸的块触发断点。调试器支持条件表达式的话把条件设成cu.csx 128 cu.csy 64这种坐标判断能极大缩小排查范围。第五个坑忽略编码器的“非确定性”。HPM4.0默认配置下编码器内部可能会有依赖哈希或者未定义遍历顺序的地方导致同一份配置跑两遍中间某几帧的比特数略有波动。这通常不影响性能结论但如果你在做“改前改后对比实验”一定不要拿单次运行的码率当基准至少跑三遍取中位数或者干脆把多线程关掉很多参考软件的重复性问题跟多线程有关让编码器以单线程确定性的方式输出。从我自己的体会来说读HPM4.0代码最磨人的阶段不是刚开始而是“觉得自己已经懂了、一改就翻车”的中间段。代码规模摆在那里各种工具之间的交互细节多到超出预期。但反过来讲如果你能把参考软件里某一套工具从前到后读通、改通、验证通你对整个AVS3标准的理解会远超那些只读标准文档的人——因为代码里藏着标准文档所有没说出口的实现细节。最后再分享一个我一直在用的小技巧每读完一个模块就试着在某个不起眼的位置改一个参数比如把某个候选列表长度上限从5改成4或者把某个搜索范围的初始值扩大一倍然后跑一次编码观察码率和PSNR的变化。这个过程会逼着你去确认“这个参数到底影响哪条链路、通过什么机制影响”比任何阅读笔记都更能检验你是不是真读懂了。如果改动之后结果跟你预想的一致恭喜你这块代码你已经拿下了。
返回列表