ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

视频编码CABAC基础:从熵编码到算术编码的实践指南

视频编码CABAC基础:从熵编码到算术编码的实践指南 做视频编解码这两年心里最绕不开的一个概念就是熵编码而在H.264之后真正把压缩效率拉开差距的正是CABAC。CABAC这名字听起来像黑话拆开看就是“上下文自适应的二进制算术编码”。很多朋友学CABAC容易卡住原因是它和之前的Huffman、CAVLC思维模式完全不一样不再给每个符号单独设计码字而是把整个符号序列当成一个整体来编码。这一篇是基础篇不堆标准草案我会尽量从原理、工程、踩坑三个角度把CABAC为什么能省码率、内部怎么工作、实现时要注意什么讲清楚。适合刚接触视频编码的同学也适合被H.266新特性弄得焦头烂额想回来补基础的工程师。1. 从熵编码到CABAC为什么躲不开它1.1 熵编码到底在编码什么视频编码链路里预测、变换、量化把空间和时间冗余去掉了但剩下的语法元素——运动矢量、残差系数、预测模式、划分标志——依然有很强的统计规律。熵编码的任务就是在不损失信息的前提下把这些符号尽可能压紧。熵编码本质上是根据符号概率分配比特。给高概率事件分配短码低概率事件分配长码平均码长接近信息熵。这就是香农第一定理的思路。Huffman编码、算术编码、基于查表的变长编码都属于这一类。区别在于颗粒度Huffman每次给一个符号分配整数个比特算术编码则可以打破“整数比特”的限制把一个符号编码成不到1比特。很多人在初学时会有一个疑问既然视频编码已经做了量化有损压缩都做完了熵编码还能做什么其实熵编码是无损压缩它不能改变量化引入的失真但它能决定同样的符号信息用多少bit塞进码流。熵编码效率高意味着相同画质下码率更低或者相同码率下画质更好这也是CABAC的价值所在。1.2 CAVLC和CABAC的选型之争H.264时代标准同时给了CAVLC和CABAC两种选择。CAVLC是基于上下文的自适应变长编码实现简单、吞吐高、硬件友好但压缩效率略低CABAC用算术编码替代变长码压缩效率通常能提升10%到15%代价是计算复杂度和硬件面积上去了。到了H.265/HEVCCABAC已经成为唯一的熵编码器。H.266/VVC依然沿用CABAC框架但在二进制化、上下文模型、多核并行上做了大量扩展。可以说搞懂了CABAC后面看新标准里的熵编码相关提案都会轻松很多。为什么变长码做不到CABAC的效果最简单的例子一个符号出现概率是0.9理论上只需要0.15bit。变长码最少也要给1bit因为码字是按“个”存在的没法切碎。算术编码的办法是让多个符号共享同一个区间概率高的符号占用区间长度大最后整个序列输出的总bit数可以逼近理论下限。这就是CABAC能省码率的根本原因。1.3 基础篇要解决的三件事CABAC的内容很多标准文档几百页直接啃容易迷失。这一篇我只讲三件事。第一CABAC为什么能省码率。这需要理解算术编码的区间划分逻辑特别是“小数比特”怎么来的。第二CABAC是怎么组织的。它不是一个孤零零的算术编码引擎而是由二进制化、上下文建模、概率状态更新、区间编码四部分配合完成。只讲算术编码不讲上下文等于只见树木不见森林。第三工程实现里有哪些关键点。包括初始化、状态转移表、regular/bypass/terminate三种模式、常见兼容性坑。基础篇先建立一个完整的地图后面再逐步深入细节。2. 算术编码原理CABAC的地基2.1 信息熵与“小数比特”信息论里一个事件如果发生概率是p它的信息量是-log2(p)比特。概率0.5对应1bit0.1对应约3.32bit0.9对应约0.15bit。所谓“小数比特”不是说物理上能输出半个bit而是说多个事件放在一起编码时概率高的整体上可以摊薄成本。举个例子。假设要编码100个事件每个事件概率都是0.9。理论总信息量是100乘以0.15等于15bit平均每个事件0.15bit。变长码无论如何做不到这一点因为每个符号至少要分配一个码字码字最小长度就是1bit最后至少100bit。算术编码则可以把这100个事件映射到一个0到1之间的区间最后用少数几个bit就能表达这个区间。这里的“少数几个bit”分摊到每个事件头上就能出现小于1bit的情况。CABAC正是基于这个思想只不过它编码的不是原始语法元素而是二进制化的bin序列。先对每个bin做概率估计再用算术编码把一串bin压到一起。2.2 区间划分的直觉想象一条长度为range的线段线段的起点叫low终点是lowrange。每次输入一个bin就根据这个bin的概率分布把线段分成两份一份对应bin0一份对应bin1。编码器根据实际出现的bin值保留对应的子线段丢掉另一部分然后把新的low和range更新为这个子线段的起点和长度。这个过程不断重复线段会越来越短能表示当前的整个符号序列的区间越来越精确。编码结束后输出区间内的任意一个数解码器就能根据同样的概率模型反向还原出整个序列。这就是算术编码最核心的直觉用区间的位置和长度传递信息而不是为每个符号单独发一个码字。有人会问输出区间里的一个数怎么保证解码器还原出完整序列关键在于编码器和解码器使用一模一样的概率模型和划分方式。只要区间划分规则相同给定最终区间内的一个数解码器可以一步步倒推每个bin取值。2.3 定点化range、low与重归一化理论上的算术编码需要无限精度的实数工程里不可能。CABAC的做法是用有限位宽的整数来表示low和range比如H.264/HEVC里用9bit表示rangelow用更大位宽。区间不断缩小时为了保证精度需要左移扩大range每次左移都可能输出一个bit。这整个过程称为重归一化。编码一个bin后如果range小于某个阈值比如256就持续左移range和low直到range重新落在容许范围内。左移时low的最高位会从整数部分移出来成为输出比特流的一个bit。区间越小需要的左移次数越多输出的bit也越多。理解重归一化很重要因为CABAC的绝大部分复杂度都集中在这段循环里。软件优化通常会在这一步做展开因为一个bin最多触发几次重归一化用if条件判断比while循环更可控。硬件设计更是会把重归一化的逻辑和range更新合并成组合逻辑。2.4 查表近似为什么不用浮点乘法每次编码bin时理论上要做range乘以概率的乘法。如果直接用浮点运算编码器和解码器的舍入行为很难保持一致而且浮点乘法在硬件里面积大、延迟高。CABAC标准采用了一个巧妙的办法把LPS概率离散成64个状态把range离散成4个量化档预计算一张rangeLPS表。编码时只需查表不需要乘法。这样做相当于用近似区间更新替代精确乘法。量化误差是存在的但编码器和解码器用完全相同的近似方法所以不会产生失步。查表近似的代价是压缩效率有极其微小的损失可换来的是实现简单、速度快、软硬件一致。这是CABAC能在视频编码标准中落地的关键工程决策。3. CABAC的四个环节拆解3.1 二进制化把语法元素变成bin串CABAC只处理0和1。视频编码里的语法元素很多不是二进制运动矢量差值可能是-32到32的整数变换系数level可能是0到几百预测模式有几种几十种。我们得先把这些多值符号映射成一个bin序列这个过程就是二进制化。二进制化不是随便编码。常用方式有一元码、截断一元码、定长码、k阶指数哥伦布码以及视频编码里常见的UEGk也就是一元码加指数哥伦布组合。比如HEVC里的运动矢量差值用UEG3前缀是一元码后缀是固定长度的指数哥伦布码。这样设计是因为小数值很常见一元码能让小值用很少的bin表达大值虽然bin变长但概率低整体收益高。为什么不在算术编码里直接处理多值符号因为二值化可以把一个复杂符号拆成几个有独立分布的bin每个bin可以选不同上下文从而更精细地捕捉概率特性。比如“绝对值是否大于1”和“绝对值是否大于2”这两个bin的统计规律完全不同分开建模更高效。这是CABAC在H.265后越来越依赖二进制化的原因。3.2 上下文建模ctxIdx怎么确定上下文建模是CABAC名字里“context-adaptive”的来源。同一个语法元素在不同条件下概率分布不同。上下文建模的任务就是根据当前编码状态选择对应的概率模型让概率估计更准确。每个概率模型用一个上下文索引ctxIdx标识。标准会为每个语法元素定义一组ctxIdx并说明根据什么条件选择。常见的条件包括当前块左侧和上方的邻居状态、当前bin在语法元素中的位置、变换块大小、已编码的相邻系数情况等。关键是编码器和解码器都必须能用相同规则推导出同一个ctxIdx不需要在码流里额外传输。举一个H.264的例子coded_block_flag要表示一个块内是否有非零系数。如果左侧块有非零系数那么当前块也更有可能有概率模型就应该选偏向“有”的那一类。ctxIdx的选取就让熵编码器自动学到这种空间相关性。基础篇里不用背表但要明白一个道理上下文建模的本质是把概率估计从全局平均变成局部条件越准确的条件概率码率越接近熵极限。3.3 概率状态机MPS/LPS与状态转移二值符号只有0和1建模时可以只关心两个量MPS最可能符号还有LPS最不可能符号以及LPS的概率。维护LPS概率而不是MPS概率是为了让状态转移表只覆盖小概率一侧减少状态数量。CABAC用64个概率状态表示LPS概率从大约0.5降到约0.0039。每一个状态都有一个next_state_lps和next_state_mps。编码完一个bin后如果bin等于MPS则概率向0方向微调如果bin等于LPS则概率向0.5方向大幅调整。这个不对称的调整逻辑反映了“出现LPS意味着之前模型低估了LPS概率”的直觉。使用离散状态表而不是连续更新公式保证了编码器和解码器完全同步也避免了浮点数误差。对工程师来说状态表就是一张固定数组用当前state和是否命中MPS查下一个state。这个特性让CABAC在软硬件实现上都有章可循。3.4 三种编码模式regular/bypass/terminateCABAC引擎里有三种编码模式这是理解整个编码流程的关键。Regular模式用于有上下文建模的bin。编码时需要取当前ctx的概率状态参与区间更新还要更新状态。多数语法元素的前缀标志都走regular模式。Bypass模式用于概率分布接近均匀的bin。比如符号位、部分系数的后缀bin。旁路模式下不做上下文建模区间固定对半划分每编码一个bin近似消耗1bit。这样能提高吞吐也让那些确实没有明显上下文规律的bin不再浪费状态维护成本。Terminate模式用于特殊终止符比如slice结束标志。它使用一个固定的小概率值一旦编码的是终止符解码器就知道整个slice的CABAC数据结束了。如果终止符没有被激活bin会用一个接近1的概率参与编码但依然要更新区间。三种模式的区别不仅影响码流长度还直接影响实现复杂度。很多初学CABAC的人会忽略bypass和terminate的特殊性导致编解码不匹配。后面我会专门讲这个坑。4. 从原理到工程一个bin的完整旅程4.1 编码一个bin的标准流程这里给出一个非常接近实际代码的处理流程以regular模式为例。根据语法元素和邻居条件确定ctxIdx。从上下文中取出state和MPS。将range右移若干位并量化到4档结合state查表得到rangeLPS。如果当前bin等于MPS则区间右侧缩小range减去rangeLPSlow不变。如果当前bin等于LPS则区间跳到右侧range等于rangeLPSlow加上原range减去rangeLPS后的值。对新的range和low执行重归一化如果需要则输出bit。根据bin是否等于MPS查状态转移表更新当前ctx的state和MPS。在bypass模式下没有上下文直接把range对半切LPS和MPS概率各0.5不需要查表重归一化也相对固定。terminate模式则使用固定概率终止时直接输出并结束。流程看起来不复杂但每步之间都有依赖尤其是range和low的更新必须串行。这也是CABAC在硬件加速上最大的难点。4.2 一个数值例子串一遍我用一个简化例子帮大家建立直觉不严格对应标准但思路一致。假设当前range等于256量化后查表得到rangeLPS等于25。如果编码的bin是MPS那么新range变为231low不变。如果编码的bin是LPS那么新range变为25low需要增加231。随后执行重归一化因为224小于256我们这里用H.264的阈值range需要左移一次或多次每次左移都会从low中输出高位bit。从这个例子能看出出现LPS时区间会急剧缩小需要多次左移输出更多bit。出现MPS时区间只缩小一点大概率只需要一次左移甚至不需要。所以高概率的MPS确实更“省bit”而LPS事件会拉高码率符合信息论的直觉。再强调一次这里数值只是示意。实际H.264/H.265里range初始值、查表精度、归一化阈值都有明确标准写代码时必须以标准为准。4.3 状态转移表的软件实现在代码层面CABAC状态转移表可以这样理解。static const uint8_t next_state_mps[64] { /* 从state 0到state 63的MPS转移 */ }; static const uint8_t next_state_lps[64] { /* 从state 0到state 63的LPS转移 */ };实际工程里每个语法元素类别会初始化一组状态和MPS。编码一个regular bin后使用类似下面的代码更新if (bin mps) { state next_state_mps[state]; } else { if (state 0) { mps !mps; } state next_state_lps[state]; }特别注意state等于0时切换MPS这个细节。state 0代表LPS概率约0.5此时MPS不再是稳定的“最可能符号”一旦编码了一个LPS就说明原来的MPS可能不再占优需要交换MPS。这点非常容易漏掉漏掉的直接后果是编码器的概率模型和解码器不一致码流立刻出错。4.4 串行依赖与后续优化空间CABAC一个bin的区间更新必须等前一个bin完成这天然是串行的。软件实现里无所谓动辄几十个核的编解码器可以靠单核性能硬扛但硬件设计里每bin的时延直接决定吞吐率上限。后续标准的优化方向主要有两类。一类是减少需要上下文建模的bin数量比如让更多语法元素走bypass降低状态更新压力。另一类是打破串行依赖比如H.266中研究的单周期多bin编码、概率估计预更新等技术。基础篇不建议第一时间扑到这些高级优化上先把串行引擎写对再用工具去测量每个bin的耗时这才有优化依据。我见过太多人一上来就搞“多bin并行”结果代码完全没法维护连参考模型都跑不过。先跑通再优化永远是入门铁律。5. CABAC基础实战看懂初始化、ctxIdx与调试5.1 概率同步为什么不需要传概率表CABAC的最大特点之一是概率模型可以自适应变化但编码器并不显式把概率传给解码器。原因很简单显式传输概率表的开销太大。按CABAC的语法元素数量每一帧都传一份完整的概率表会吃掉大量码率得不偿失。所以标准采用的方式是编码器和解码器在slice开始时根据片类型和量化参数QP使用相同的规则初始化所有上下文。随后每编码一个bin双方同步更新概率。只要解码顺序和编码顺序一致概率模型就会严格同步。这种设计把“概率表传输”变成了“概率表推导”省掉的码率非常可观。这也是为什么CABAC实现中绝对不能乱改状态更新顺序。一个语法元素解析顺序错了可能后面所有ctx都对不上而且很难定位。5.2 上下文初始化与QP的关系CABAC上下文初始化不是随便给一个固定概率。标准为每个ctxIdx定义了一组初始化参数比如H.264里的m和n。初始化概率会随slice的QP变化因为量化参数不同残差系数、运动矢量等统计特性也不同。QP大时量化粗残差系数多为0概率模型就要偏向“简单”一侧QP小时残差丰富模型需要更偏向复杂。软件实现通常会在打开编码器时预计算一份按QP索引的初始化表把每个QP对应的state和MPS提前算好。这样编码每个slice时只需要按QP查表不需要现场做公式计算。调试时也能直接查看某一QP下的概率模型是否合理。一个小技巧如果你想在参考代码里快速验证初始化逻辑可以打印出第一个bin编码前后的state变化。如果是JM、VTM这类参考软件通常都有trace选项能看到每个bin的ctxIdx和状态变化。5.3 常见语法元素的ctx分配示例不同标准中ctxIdx分配差异很大但设计逻辑是相通的。以HEVC为例每个CTU的split_cu_flag会按深度和邻居划分使用不同的ctx因为浅层和深层的划分概率明显不同merge_flag会根据是否Merge模式选择ctx变换系数绝对值大于1和大于2的标志也有各自独立的ctx集合。有些bin不需要上下文比如mvd的后缀部分、coeff的剩余level它们在标准里明确标为bypass。为什么因为这些值的概率分布在不同块之间差别不大而且值域很宽用固定0.5概率编码反而简单高效。看到这种标志时不要再试图给它分配ctx。基础篇阶段我建议拿一个简单的配置文件跑几帧码流用参考软件导出trace逐个语法元素看ctxIdx。把“regular、bypass、terminate”的分布记下来比背标准里的表格有效得多。5.4 我第一次调CABAC用的三把工具很多同学问我CABAC出了问题怎么排查。我自己一般用三个手段。第一参考软件trace对比。VTM或JM开trace把所有bin的ctxIdx、状态和输出bit打出来。自己实现输出相同格式diff一下立刻能看出是哪个bin出现了偏差。第二单元测试。把CABAC引擎单独抽出来构造一组已知bin串编码后再解码检查是否还原。这个测试可以在编码器外层直接做不需要完整跑视频定位速度非常快。第三bit成本分析。对一个语法元素统计每个bin的实际平均编码bit可以通过状态概率估算。如果某个bin长期接近1bit说明上下文区分度不高可以考虑调ctx或者合并状态。这算进阶用法但对理解建模质量很有帮助。6. 常见问题与我踩过的坑6.1 软件实现比CAVLC还慢第一次用CABAC替换掉CAVLC后很多人会发现编码速度不升反降这是正常的。CABAC每bin都要做查表和重归一化流程比查变长码表重得多。再加上如果不小心把所有bin都走了regular模式性能会更难看。优化时可以从这几处下手尽量让没有规律的bin走bypass减少状态更新重归一化次数其实有限用if逻辑展开代替while循环状态查表用连续内存避免cache miss对于MPS和LPS的判断可以设计成无分支代码减少分支预测惩罚。但还是一句话优化前先确认正确性。CABAC状态一旦更新错解码端不会给你任何提示直接黑屏或者花屏。建议先把单元测试做扎实再上优化。6.2 比特流里的下溢、进位与防竞争字节CABAC在输出bit时有两个经典问题。一个是区间下溢low和range的精度不够时需要暂存一些bit等进位确定后再输出。另一个是进位传播连续输出0xFFFFFF时新加入的进位会一路向上传递处理不当会破坏码流。好在标准已经给出了具体处理方法包括在输出时进行字节填充、以及避免出现起始码冲突。H.264/H.265里要求CABAC编码过程中如果出现连续0x000000就要插入一个0x03作为防竞争字节。这不是可选的是实现必须做的。很多自研编解码器在码流兼容性上栽跟头都出现在这些边边角角。我踩过的坑是早期实现只检查了NALU start code前的防竞争忘了CABAC内部也会产生类似问题结果码流在某些播放器上可以播在另一些上直接丢失后续数据。最后一行行对比byte才发现是0x03插入位置不对。现在我在任何新方案落地前都会专门做一次码流特殊字节压力测试。6.3 解码概率对不上基本都是时序问题CABAC解码概率对不上最常见的原因不是状态更新公式写错而是语法元素的解析顺序和编码顺序不一致。视频编码标准对每个语法元素的遍历顺序要求非常严比如先编码亮度还是色度先编码左上还是右下不同划分下顺序不同。一旦顺序错了其实每个bin的内容可能还是能对上但ctxIdx已经错位。这种错误不会马上导致崩溃而是表现为码流出现零星错误、画面局部花块。排查时如果发现前面几个slice正常后面越来越差优先怀疑时序而不是概率表。我的经验是在编解码器里加上某个语法元素的bin计数记录每个ctxIdx的使用次数通过对比编码器和解码器的计数来找错。这个方法比肉眼盯代码高效很多。6.4 为硬件加速提前想清楚的事如果以后要做硬件CABAC基础篇就得提前铺垫几个认知。单bin引擎里range更新和low更新是主要延时路径查rangeLPS表可以用组合逻辑但重归一化的位数通常依赖当前range的数值不能简单固定周期。多bin预测是硬件加速的主流手段思路是在当前bin还没完成前预先猜测后续bin的概率区间变化从而并行处理多个bin。这个方向对算法理解要求极高不适合刚入门时碰。还有一个是概率更新能否推迟有些bin走bypass不会影响regular上下文状态可以在流水线里穿插处理提升吞吐。但基础阶段先在软件里把串行引擎打磨好深刻理解每一处的标准约束再去看硬件设计文档会顺畅很多。技术没有捷径CABAC尤其如此。我自己最初学CABAC时也被那些状态表、上下文表劝退过好几回。后来沉下心把参考软件的CABAC模块一行行跟着调试把每个bin的区间变化画在纸上才慢慢找到感觉。现在再看新标准里的熵编码提案发现本质上还是那些东西怎么建模更准怎么更新更省怎么把串行化并行。希望这篇基础篇能帮你把它从“黑话”变成“工具”。下一篇我再拆二进制化和上下文建模的细节尤其是H.266里那些新改动的逻辑到时候继续聊。
返回列表