☰
VTM编码单元CU划分递归逻辑与代码调试实战
2026/10/14 1:41:10 网站建设 项目流程

1. 从零啃VTM代码:CU划分到底在做什么

如果你正在读VTM的代码,大概率已经翻过几遍CompressGOP或者xCompressCU这类函数,然后被里面层层嵌套的递归、各种try/split分支搞得头大。我刚开始看的时候也一样,一个xCompressCU函数几百行,里面又是RdCost又是PartSplit,看完只觉得“好像懂了”,但真要自己改点东西,完全不知道从哪下手。

这篇就专门聊CU划分这一块。CU,Coding Unit,编码单元,是VTM里做预测、变换、量化的基本单位。你可以把它理解成“编码时的最小决策块”——一个CTU进来,先决定怎么切成CU,切完之后每个CU再决定用什么预测模式、怎么变换。所以CU划分是整个编码流程的骨架,骨架搭错了,后面再优化都是白搭。

VTM的CU划分核心逻辑集中在xCompressCU这个递归函数里。它做的事情说白了就一句话:给定一个CU,尝试各种划分方式,算出每种方式的最优代价,选最小的那个。听起来简单,但里面涉及几个关键问题:划分方式有哪几种、递归什么时候停、代价怎么算、快速算法怎么剪枝。这几个问题搞清楚了,CU划分的代码基本就通了。

这篇文章适合两类人:一是刚接触VTM、想搞清楚编码流程的在校学生或者转行做视频编码的工程师;二是已经在用VTM做实验、但想深入改划分逻辑发论文或者做优化的研究者。我会从整体设计思路讲到具体代码细节,再给出手把手的调试方法,最后分享几个我踩过的坑。代码基于VTM的通用版本,不同版本函数名可能略有差异,但核心逻辑一致。

2. CU划分的整体设计与递归框架

2.1 为什么VTM要用递归划分而不是固定分块

早期编码标准比如H.264,宏块大小固定16x16,划分方式也就那么几种,实现起来简单直接。但视频内容差异太大了——平坦区域用大块编码效率高,纹理复杂区域必须用小块才能保证质量。固定分块要么浪费码率,要么质量上不去。

VTM继承HEVC的 quadtree 思路并做了扩展,CTU最大可以到128x128,然后通过递归的方式往下切。递归的好处是自适应:编码器可以根据当前块的率失真代价,自己决定切不切、怎么切。平坦区域可能一个128x128的CU就够了,复杂区域可能一路切到8x8甚至4x4。

但递归的代价是计算量爆炸。一个128x128的CTU,如果每种划分都试一遍,组合数是天文数字。所以VTM里既有“全搜索”的暴力逻辑,也有大量快速算法来剪枝。理解这个矛盾,是读懂CU划分代码的关键。

2.2 划分模式的全景:从四叉到二叉树再到扩展四叉

VTM的CU划分不是简单的四叉树。它支持多种划分模式,这些模式定义在PartSplit枚举里。我整理了一下常见的几种:

划分模式含义适用场景
CTU_QUAD_SPLIT四叉划分,切成4个等大子块通用,最基础
CTU_HOR_SPLIT水平二分,切成上下两块水平纹理或运动边界
CTU_VER_SPLIT垂直二分,切成左右两块垂直纹理或运动边界
CTU_HOR_TR_SPLIT水平三分,按1:2:1切特殊纹理分布
CTU_VER_TR_SPLIT垂直三分,按1:2:1切特殊纹理分布
CTU_QUAD_SPLIT_HOR扩展四叉,先水平二分再各自二分VTM新增,灵活度更高
CTU_QUAD_SPLIT_VER扩展四叉,先垂直二分再各自二分同上

这里要注意,不是所有划分模式在所有层级都可用。比如128x128的CU不能直接做三分,因为最小子块会小于4x4。代码里有一堆isSplitAllowed之类的判断,就是在做这些约束。

提示:看代码时先找到PartSplit的定义,把所有划分模式列出来,然后对照xCompressCU里的switch分支,这样不会迷路。

2.3 递归的终止条件:什么时候不再往下切

递归不能无限往下,必须有终止条件。VTM里主要有几个约束:

  • 最小CU尺寸:由SPS里的minCUWidth和minCUHeight决定,通常是4x4或8x8。到了这个尺寸就不能再切了。
  • 最大划分深度:maxMTTDepth和maxBTDepth分别控制二叉树和多类型树的深度。超过深度限制就不能再用对应的划分模式。
  • 图像边界:如果CU超出图像边界,某些划分方式会导致子块完全在图像外,这种划分直接跳过。
  • SPS配置:有些划分模式可以通过配置开关关闭,比如sps_btt_flag控制二叉树是否启用。

这些条件在代码里分散在各处,我建议你画一张决策流程图,把每个判断条件标上去,这样递归逻辑就一目了然了。

2.4 代价函数:RD Cost是怎么算出来的

CU划分的核心决策依据是率失真代价,也就是RD Cost。公式很简单:

RD Cost = Distortion + Lambda * Rate

Distortion是重建图像和原始图像的失真,通常用SSE或SAD。Rate是编码这个CU需要的比特数,包括预测模式、残差、划分标志等。Lambda是拉格朗日乘子,控制码率和失真的权衡。

但实际代码里,RD Cost的计算分好几个层次。在划分阶段,VTM通常先用SATD或者简化代价做快速筛选,只有候选者才会做完整的RD计算。这就是为什么你看代码时会发现calcRdCost被调用的次数远少于xCompressCU的递归次数。

注意:不同版本的VTM在代价计算上差异很大,尤其是VTM 10之后引入了很多基于神经网络的快速算法。如果你看的是老版本,逻辑会相对单纯一些。

3. 核心代码细节:xCompressCU逐层拆解

3.1 函数入口与初始化:进来先干什么

xCompressCU的签名大概长这样:

void EncCu::xCompressCU( CodingStructure *&tempCS, CodingStructure *&bestCS, Partitioner &partitioner, const EncTestMode &encTestMode )

参数里几个关键对象:

  • tempCS:当前尝试的编码结构,每次尝试划分时会重置
  • bestCS:目前为止最优的编码结构,递归返回时会把最优结果存进去
  • partitioner:划分器,管理当前CU的位置、尺寸、深度等信息
  • encTestMode:编码测试模式,包含当前是做什么类型的决策

函数一进来,先做一堆初始化:设置当前CU的预测模式候选、初始化代价为最大值、检查各种跳过条件。这部分代码比较琐碎,但有几个点值得注意:

  • partitioner.currArea()返回当前CU的区域信息,后面所有操作都基于这个
  • encTestMode里的optimalMode决定当前是尝试帧内还是帧间
  • 如果当前CU尺寸已经等于最小CU,直接进入预测模式决策,不再尝试划分

我刚开始看的时候经常搞混tempCS和bestCS,后来想明白了:tempCS是草稿纸,每次尝试新划分时在上面算;bestCS是最终答案,只有比当前最优更好时才会更新。

3.2 尝试不划分:先算当前CU的最优代价

递归的第一步,是先假设不划分,直接对当前CU做完整的预测、变换、量化,算出一个RD Cost。这个代价作为“基准”,后面所有划分尝试都要跟它比。

这部分代码通常在xCheckRDCostIntra或者xCheckRDCostInter里。帧内的话会遍历所有帧内预测模式,帧间的话会做运动估计和运动补偿。算完之后得到一个bestCS的初始值。

这里有个细节:VTM里帧内和帧间的尝试顺序会影响最终结果,因为有些快速算法会基于已有结果做剪枝。但理论上,如果全搜索,顺序不影响最优性。

实操心得:如果你想验证代码逻辑,可以在这里加个断点,看看一个平坦区域的CU是不是直接就不划分了,而复杂区域会继续往下递归。这是最直观的理解方式。

3.3 遍历所有划分模式:switch分支里的秘密

不划分的代价算完之后,就进入划分尝试的循环。代码结构大概是:

for( auto split : splits ) { if( !isSplitAllowed( split ) ) continue; // 保存当前状态 // 执行划分 // 递归调用xCompressCU // 比较代价,更新最优 // 恢复状态 }

splits是一个PartSplit的列表,包含了当前CU允许的所有划分模式。这个列表不是固定的,会根据CU尺寸、深度、SPS配置动态生成。

每个划分模式的处理逻辑类似,但细节不同。以四叉划分为例:

  1. 调用partitioner.splitCurrArea把当前CU切成4个子块
  2. 对每个子块递归调用xCompressCU
  3. 4个子块的代价加起来,加上划分标志的比特数,得到总代价
  4. 如果总代价小于当前最优,更新bestCS

二叉树划分类似,只是切成2块。三分划分切成3块,比例是1:2:1。

这里最容易出错的地方是状态保存和恢复。递归之前要保存当前tempCS的状态,递归之后要恢复,否则会影响后续划分模式的尝试。VTM里用CodingStructure的save和restore方法来做这件事,代码里到处是tempCS->save()和tempCS->restore()。

3.4 递归调用与代价比较:最优解是怎么选出来的

递归调用的核心逻辑可以用伪代码表示:

bestCost = cost of no split for each split mode: if not allowed: continue split current CU totalCost = 0 for each sub-CU: subCost = xCompressCU(sub-CU) totalCost += subCost totalCost += split flag bits if totalCost < bestCost: bestCost = totalCost bestCS = tempCS restore state return bestCost

看起来简单,但实际代码里有很多优化。比如:

  • 提前终止:如果当前累加的代价已经超过bestCost,后面的子块就不用算了,直接跳过。这叫“代价剪枝”。
  • 快速筛选:先用简化的代价函数(比如SATD)筛一遍,只有有希望的划分才做完整RD计算。
  • 上下文继承:子块的某些编码参数可以从父块继承,减少计算量。

这些优化是VTM性能的关键,但也是代码最难懂的地方。我的建议是先把主逻辑理清楚,再去看这些优化,不然容易被细节淹没。

3.5 特殊处理:边界CU和最小CU

边界CU的处理是个坑。如果CU部分超出图像边界,VTM会用padding的方式填充,但划分的时候要特别小心。比如一个64x64的CU只有右下角32x32在图像内,那四叉划分之后有些子块完全在图像外,这种划分就没意义。

代码里通常用partitioner.isSplitAllowed()来统一判断,但不同版本的实现不一样。有的版本会在xCompressCU入口就检查边界,有的版本在划分循环里检查。

最小CU的处理相对简单:到了最小尺寸,直接做预测模式决策,不再尝试划分。但要注意,最小CU的尺寸不一定是4x4,取决于SPS配置。有些配置下最小CU是8x8,这时候4x4的划分根本不会出现。

4. 实操:手把手调试CU划分过程

4.1 环境准备与编译配置

要调试VTM,首先得能编译。VTM用CMake构建,基本流程:

mkdir build cd build cmake .. -DCMAKE_BUILD_TYPE=Debug make -j8

Debug模式很重要,因为你要加断点、看变量。Release模式编译快但调试信息不全。

编译完之后,用测试序列跑一下:

./bin/EncoderApp -c cfg/encoder_intra_vtm.cfg -i test.yuv -wdt 416 -hgt 240 -f 1

配置文件里可以控制很多参数,比如MaxCUWidth、MaxCUHeight、MaxPartitionDepth等。调试CU划分时,建议先用小分辨率、少帧数,不然跑一次太慢。

4.2 加日志:打印每次划分决策

最直接的调试方法是在xCompressCU里加日志。我通常会在函数入口打印当前CU的位置和尺寸:

std::cout << "xCompressCU: pos=(" << partitioner.currArea().blocks[0].pos().x << "," << partitioner.currArea().blocks[0].pos().y << ") size=" << partitioner.currArea().blocks[0].size().width << "x" << partitioner.currArea().blocks[0].size().height << " depth=" << partitioner.currArea().depth << std::endl;

然后在划分决策的地方打印最终选了哪种划分:

std::cout << " best split: " << bestCS->bestPartition << " cost=" << bestCost << std::endl;

跑一个小序列,日志会很多,但能清楚看到递归的整个过程。我第一次跑的时候,看到平坦区域确实很快就停了,复杂区域一路切到8x8,跟理论完全对得上,那种“原来如此”的感觉很爽。

4.3 用调试器观察递归栈

日志适合看整体流程,但要看具体变量值,还是得用GDB或者Visual Studio的调试器。在xCompressCU入口加断点,然后单步执行,观察:

  • partitioner.currArea()的变化
  • tempCS和bestCS的指针地址
  • bestCost的更新过程
  • 递归调用的深度

我建议用一个非常小的序列,比如64x64的一帧,这样递归次数少,容易跟踪。你可以手动记录每次递归的输入和输出,画成一棵树,这样对整个流程的理解会深刻很多。

注意:VTM的递归很深,调试器里栈帧可能几十层,别被吓到。从最外层开始,一层一层往下看,重点关注划分决策发生的那几层。

4.4 修改划分逻辑做实验

理解代码之后,可以试着改点东西。比如:

  • 禁用某种划分:在splits列表里去掉CTU_HOR_SPLIT,看看码率和质量怎么变
  • 修改代价函数:把Lambda调大或调小,观察划分倾向的变化
  • 加自定义剪枝:比如“如果当前CU的SATD代价已经很低,直接跳过划分”

这些实验能帮你真正理解每个参数和逻辑的作用。我当初为了搞懂三分划分,专门写了个配置只允许三分,跑出来的结果跟预期一致,那种验证感比看十遍代码都强。

5. 常见问题与排查技巧实录

5.1 递归不终止或者栈溢出

这是新手最容易遇到的问题。原因通常是终止条件没写对,或者状态恢复有问题导致无限递归。排查步骤:

  1. 检查partitioner的深度是否正确递增
  2. 检查最小CU尺寸的判断是否生效
  3. 检查isSplitAllowed是否在所有路径上都返回了正确结果

我遇到过一次,是因为改了maxMTTDepth但忘了同步改maxBTDepth,导致二叉树划分一直往下切,最后栈溢出。这种问题看日志最直接,递归深度异常增长一眼就能看出来。

5.2 划分结果跟预期不符

有时候你会发现某个区域明明很平坦,却切得很碎;或者复杂区域反而没怎么切。可能的原因:

现象可能原因排查方法
平坦区域过度划分Lambda太小,码率权重低检查Lambda计算
复杂区域不划分快速算法剪枝太激进关闭快速算法对比
划分模式单一SPS配置限制了划分类型检查sps_btt_flag等
边界CU异常padding或边界判断有bug打印边界CU的划分过程

我建议先用全搜索模式跑一遍,作为基准,然后再开快速算法,对比差异。这样能快速定位是算法问题还是配置问题。

5.3 代价计算异常

RD Cost出现负数或者异常大的值,通常是:

  • Distortion计算溢出
  • Rate计算包含了不该包含的比特
  • Lambda为0或者未初始化

这类问题在Debug模式下用断言最容易发现。VTM里有很多CHECK宏,如果编译时开了断言,异常值会直接报错。

5.4 性能太慢跑不动

全搜索的CU划分计算量巨大,跑一个1080p序列可能要几个小时。加速方法:

  • 用--fast相关的配置选项
  • 减小搜索范围,比如限制最大CU尺寸
  • 只跑几帧,不要跑完整序列
  • 用Profile工具找到热点函数,针对性优化

我通常调试时用416x240的序列,跑10帧以内,这样一次实验几分钟就能出结果。

5.5 版本差异导致的代码对不上

VTM版本更新很快,不同版本的xCompressCU差异可能很大。比如VTM 5和VTM 10的划分逻辑就有明显区别。如果你看的教程和你的代码对不上,先确认版本号。代码里的VTM_VERSION宏可以查。

我的建议是选定一个版本就不要再换,除非有明确需求。不同版本的函数名、参数、甚至整体架构都可能变,来回切换只会增加困惑。

6. 我个人在实际操作中的体会

啃VTM代码这件事,最忌讳的就是“从头到尾读一遍”。我试过,读到一半就忘了前面,效率极低。后来我换了个方法:先跑起来,再加日志,再改代码,最后才去读细节。先让编码器跑起来,看它实际怎么划分;然后加日志,把递归过程打印出来;然后试着改一个参数,看结果怎么变;最后才去读代码里对应的逻辑。这样每一步都有反馈,理解得特别扎实。

CU划分这块,核心就是递归加代价比较。把xCompressCU的框架搞懂,剩下的都是细节。细节可以慢慢补,但框架一定要先建立起来。另外,别怕改代码,VTM是开源的,改坏了重新编译就行。我当初为了搞懂三分划分,把相关代码注释掉又加回来,折腾了好几次,但每次都有新收获。

最后分享一个小技巧:如果你觉得递归太绕,可以自己用Python写一个简化版的CU划分模拟器,把划分模式、代价函数、递归逻辑都实现一遍。不用考虑编码细节,只关注划分决策。写完之后再回头看VTM的代码,会发现清晰很多。这个模拟器我到现在还留着,改算法的时候先用它验证思路,没问题了再往VTM里移植,省了很多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询