如果你做过H.264或者HEVC的编码器,一定听说过CABAC这个名字。它是视频编码里绕不过去的一块硬骨头,也是我当年入门时啃得最久的模块。前面几篇笔记聊过了指数哥伦布码和CAVLC,今天开一个新的小系列,专门讲CABAC。这篇是基础篇,不堆公式,先把三件事讲清楚:CABAC在编码器里到底干了什么、算术编码为什么能把压缩率做到接近理论极限、以及二值化、上下文建模、自适应更新这三个核心组件各自解决什么问题。不管你以后是读标准、写软解还是做硬件编解码器,脑子里有了这张地图,后面再碰tabRangeLPS那些表格就不会慌了。
1. CABAC在编码器里的位置:为什么它是“最后一道关卡”
1.1 一条典型的视频编码流水线
视频编码的流水线大致是这样:帧内/帧间预测消除空间和时间冗余,变换把残差块的能量集中到低频,量化把细节砍掉换码率,最后一步就是熵编码。前面所有模块输出的量化系数、预测模式、运动矢量差、块划分信息等,统称语法元素。这些元素里有大量数值,也有大量0/1开关,熵编码负责把它们变成真正的二进制比特流。
熵编码为什么重要?因为语法元素的出现概率极不均衡。比如一个残差块里,绝大多数位置的非零系数标记是0;而少数位置一旦有非零系数,它的数值又往往集中在小值上。如果所有元素都用定长码,码率会爆炸。所以熵编码的任务本质上是“根据出现概率分配码长”:越常见的值给越短的码,越稀罕的值给越长的码,整体向信息熵靠拢。
CABAC(Context-based Adaptive Binary Arithmetic Coding)就是干这件事的。它从H.264/AVC开始进入主流标准,到HEVC、VVC里成了唯一的熵编码方案。它能做到非常接近信息论极限的压缩率,但代价是计算和实现复杂度远高于传统的变长码。
1.2 H.264为什么留着CAVLC,HEVC却只留CABAC
H.264时代同时定义了CAVLC和CABAC两套熵编码。CAVLC结构简单、解码快,适合低端设备;CABAC压缩率更高,同等画质下大概能省10%到15%的码率,但实现复杂,解码时串行依赖强。所以H.264把选择权留给了编码器:追求压缩率就上CABAC,追求低功耗低成本就用CAVLC。
到了HEVC时代,情况变了。编码单元更大、预测更精细、变换系数更多,熵编码环节的水涨船高;同时芯片能力也上来了,维护两套熵编码器的成本反而变得不划算。HEVC干脆砍掉CAVLC,只保留CABAC。这也是为什么今天学视频编码,CABAC基本是必修课——你绕不开它。
需要强调一点:CABAC不是一个单一算法,它是一个框架。二值化、上下文建模、算术编码引擎、常规/旁路双路径,这四块合在一起才叫CABAC。下面先讲它的内核:算术编码。
2. 算术编码的本质:把一串符号缩进一个数里
2.1 为什么变长码在概率问题上总吃亏
Huffman码和指数哥伦布码有个共同的局限:每个符号的码长必须是整数个比特。如果一个符号的出现概率是0.7,信息论上它的理想码长是-log2(0.7)≈0.51比特,但变长码再怎么优化,最少也得给它1比特。符号一多,这种“整数化舍入损失”就不断积累。
算术编码走的是另一条路:它不单独给符号定码长,而是把整个待编序列映射到[0,1)区间里的一个子区间,再输出这个子区间对应的二进制小数。概率0.7的符号在这个机制里真的可以只占0.51比特,因为多个符号的“比特预算”共享了——这组符号少用一点,那组符号就能多用一点,互相拆借。
2.2 一个手工可算的小例子
假设只有两个符号A和B,概率分别是0.75和0.25。现在编序列“AAB”:
- 初始区间是[0,1)。
- 读第一个A:A占左端75%,新区间取[0,0.75)。
- 读第二个A:在[0,0.75)内继续取左端75%,得到[0,0.5625)。
- 读B:在[0,0.5625)内取右端25%,得到[0.421875,0.5625)。
最终区间里随便挑一个数,比如0.5,它的二进制是0.1。也就是说,三个符号“AAB”只需要1个比特就表示完了。如果用Huffman,A给1比特、B给2比特,AAB要4比特。差距就在这里。
原理并不玄:区间[0.421875,0.5625)整个都落在0.5以上,编码器凭这一点就能断定输出的第一位是1,后面不管再来多少符号都不会改变这个结论。于是这一位就“定”下来了。重复这个过程,整个序列的比特其实是在区间不断收缩的过程中一点一点“挤”出来的。
这个例子还说明一个关键点:算术编码的压缩效果高度依赖概率估计的准确性。概率给得准,区间收缩快,输出比特少;概率给得离谱,区间收缩慢,压缩率甚至不如定长码。CABAC里的“自适应”三个字,所有精力都花在让概率估计尽量贴合当前码流上。
2.3 从字符级到二进制:为什么CABAC只用0和1做算术编码
上面的例子是符号级算术编码。但真实码流里语法元素五花八门,直接为每一种语法元素维护一套符号级概率模型不现实。CABAC的做法是先统一口径:把一切语法元素都转成二进制串,然后只对0/1符号做算术编码。这样概率模型只需要维护“这个bin取1的概率”,模型数量可控,二值算术编码器的实现也统一。
代价是什么?一个非二值的大数值(比如变换系数层级等于17)要先拆成好几个bin,每个bin的语义不同、概率分布也不同。于是就有了另外两个核心组件:二值化决定怎么拆,上下文建模决定每个bin用哪套概率。下面拆开讲。
3. CABAC三件套:二值化、上下文选择、概率自适应
3.1 二值化:把语法元素翻译成bin串
H.264/HEVC里常用的二值化方案就那么几种,列个表看得更清楚:
| 方案 | 规则 | 典型用途 |
|---|---|---|
| Unary(一元码) | 值n写成n个1再跟一个0(或反过来),长度n+1 | 小范围无界数值 |
| Truncated Unary(截断一元码) | 到达最大值cMax时省略最后的终止0 | 有上界的小数值 |
| k阶指数哥伦布(EGk) | 前缀加后缀,能表达长尾大值 | 大数值的剩余段 |
| Fixed-Length(定长码) | 固定长度直接写二进制 | 均匀分布且有上界 |
| 组合方案 | 一段用一元/截断一元,超出阈值后用EGk | 变换系数层级 |
以变换系数层级为例:小值0、1、2出现概率极高,用一元码很合适;但偶尔会冒出一个巨大的系数,一元码要写几十个bit,太浪费。标准里常见的设计是分段式:小段用一元/截断一元,超过阈值后改用指数哥伦布编后缀。这样既照顾了高频小值,又不会让大值爆炸。
二值化并不是越简单越好,而是要和“这个语法元素的概率分布形状”匹配。看标准原文时看到各种二值化方案,心里要清楚:它们其实是在对分布做分段拟合。
3.2 上下文建模:看到邻居再决定用哪个概率表
二值化把语法元素拆成bin,下一个问题是:每个bin用哪套概率模型?如果全场只用一个全局模型,那就退化成普通的算术编码,没办法利用内容差异。CABAC的核心思路是上下文——根据已经解码/编码的信息,让同一个bin在不同条件下使用不同的概率模型。
拿H.264里残差块的significant_coeff_flag举例,这个标记表示某个位置是否有非零系数,本身是个0/1符号。如果只看全局统计,它区分不了“平坦区域”和“纹理密集区域”:平坦区域绝大多数位置都是0,纹理区域非零比例高得多。CABAC的做法是用周围已解码信息来挑选模型,比如左侧和上方块是否有非零系数、当前块内已经出现了多少个非零系数,条件不同,用的概率模型就不同。这就是条件熵的工程化实现。
每个上下文在标准里是一个索引(ctxIdx),工程实现上是一张小表:每个条目存两个值——pStateIdx(概率状态索引,对应LPS概率)和valMPS(大概率符号是0还是1)。H.264为CABAC定义了数百个上下文模型,HEVC也在这个量级。这些模型的初始值是设计阶段对大量视频序列做统计得到的“通用先验”,编码过程中再靠自适应更新去贴合具体内容。
3.3 自适应更新:MPS/LPS与64个概率状态
自适应是CABAC最聪明的一环。每编一个bin,编码器都要根据实际值更新当前上下文的概率估计。具体机制引入了MPS/LPS:
- MPS(大概率符号):当前上下文里出现概率更高的符号;
- LPS(小概率符号):另一个符号。
上下文记录的核心是“LPS的概率”,用64个离散状态表示。状态0对应LPS概率约0.5,也就是几乎等概;状态63对应LPS概率非常小。每编一个bin:
- 如果编的是MPS,概率状态向LPS概率更小的方向挪一格;
- 如果编的是LPS,概率状态向LPS概率更大的方向跳几格,而且状态一旦退回0,MPS要翻转——因为连续编出LPS说明之前对“大概率符号”的判断反了。
这种“慢升快降”的非对称更新是刻意设计的:小概率事件一旦发生,往往意味着概率模型方向有问题,需要快速改正。64个状态配合查表实现,全程避开浮点运算,这让CABAC可以在芯片上用很低的代价实现。
有个细节值得注意:H.264的上下文初始值不是固定常数,而是和当前slice的量化参数QP挂钩。标准附录里按cabac_init_idc分了组,给出一组(m,n)参数,再用QP做一次线性映射得到初始状态。道理很直观:QP大意味着量化粗糙,残差系数更稀疏,概率起点当然不能和低QP一样。这个细节后面讲踩坑时还要提。
4. 常规编码与旁路编码:两条路径为什么要分开
4.1 常规模式:有上下文、有自适应
二值化得到的bin串要真正编进算术编码器时,H.264/HEVC提供两条路径。常规模式走的是完整流程:选上下文→查表细分区间→更新状态。概念骨架如下:
// 概念示意代码,非标准原文 q = (R >> 6) & 0x03; // 把区间宽度量化成4档 R_LPS = tabRangeLPS[ctx.state][q]; R = R - R_LPS; if (bin != ctx.MPS) { // 编的是LPS:区间整体跳到右端 L = L + R; R = R_LPS; ctx.state = transIdxLPS[ctx.state]; if (ctx.state == 0) ctx.MPS ^= 1; // 大概率符号翻转 } else { // 编的是MPS:区间左端收缩即可 ctx.state = transIdxMPS[ctx.state]; } // 归一化:区间宽度小于256时翻倍,并向外输出比特 while (R < 256) { R <<= 1; L <<= 1; put_bit(/* L的高位,同时处理进位 */); }这里R是当前区间宽度,L是区间下沿。初始化时R=510,L=0。每编一个bin,区间要么在左侧收缩,要么跳到右侧一个小段;当区间宽度小于256时,说明某些高位比特已经不可能再变,就把R和L一起左移翻倍,腾出空间给下一个bin。这个归一化过程是整个CABAC吞吐的关键——每个bin最多可能一次不输出,也可能连续输出多个bit。
4.2 旁路模式:去掉上下文,固定等概
有些bin的0/1统计几乎不依赖任何上下文,而且接近等概率。典型代表是指数哥伦布后缀的bin、符号位、部分大系数的剩余层级bin。给这些bin维护几十个上下文模型纯属浪费,自适应反而可能适得其反。于是CABAC提供第二条路径——旁路模式:不查上下文,固定认为P(0)=P(1)=0.5,每编一个bin直接把当前区间正中剖成两半。
// 概念示意代码 if (bin == 1) { L = L + (R >> 1); } R = R - (R >> 1); // 同样需要归一化,但不需要更新任何上下文 while (R < 256) { R <<= 1; L <<= 1; put_bit(/* ... */); }旁路模式少了查表、少了状态更新、少了MPS判断,处理速度快很多,代价是不能自适应,每个bin的信息容量上限就是1比特。标准设计的原则很清楚:值得精雕细琢的bin走常规模式,大量低信息价值的bin走旁路。两种模式共享同一个R/L状态,编码顺序如何交叉都不影响解码正确性,具体哪个bin走哪条路完全由语法元素的规范决定。
4.3 从信息量的角度理解两条路径
常规模式的bin数量少,但每个bin承载的“决策价值”高,因为概率被上下文条件化之后偏离0.5越远,单bin信息量越大。旁路模式的bin则接近抛硬币,每个bin最多1比特,编起来省事但信息密度低。CABAC把有限的上下文资源分配给“最值得条件化、最偏离等概分布”的bin,把其余大量bin用等概快速打发掉——这是一个非常工程化的折中。
5. CABAC与CAVLC:压缩收益到底从哪里来
5.1 CAVLC只是“会选表的变长码”
H.264里的CAVLC全称是Context-Adaptive Variable Length Coding,它也有“上下文自适应”这几个字,但它的自适应范围非常有限:只是在几套预设计好的VLC码表之间切换。根据周围已编码块的信息选一张表,表选定后,每个符号用表里写死的码字编码。表内码字是固定的Huffman码,不能根据当前码流的实时统计变化。
5.2 三条根本性的差距
| 维度 | CAVLC | CABAC |
|---|---|---|
| 编码方式 | 变长码(Huffman结构) | 算术编码 |
| 码长粒度 | 整数bit | 可以做到分数bit |
| 概率自适应 | 只能在有限张表间切换 | 每个bin的上下文逐次更新 |
| 条件化粒度 | 粗(块的邻居信息) | 细(语法元素、bin位置、邻居信息组合) |
| 实现复杂度 | 低 | 高,串行依赖强 |
压缩收益主要来自三点:
- 算术编码替代变长码,消除了整数码长的舍入损失;
- 细粒度的上下文条件化,让概率模型贴住内容的局部统计;
- 逐bin的自适应更新,让概率模型跟随非平稳的内容变化。
这三条加在一起,让H.264在同等画质下大约能比CAVLC省10%到15%的码率。低码率下收益尤其明显,因为残差和运动信息占比大,而CABAC对这些信息“精打细算”的能力远强于CAVLC。
5.3 HEVC为什么只留CABAC
到HEVC时代,编码树更大、变换更大、系数更多,熵编码环节的负担更重;同时编解码芯片普及,CABAC的硬件成本被摊销。保留CAVLC意味着要同时维护两套熵编码逻辑、两套测试向量、两套优化工作,收益却只是覆盖极小众的低端场景。所以HEVC直接砍掉CAVLC,CABAC成为默认且唯一的熵编码器。
代价也不是没有。CABAC是典型的串行结构,每编一个bin都依赖上一个bin留下的R/L状态和上下文状态,硬件很难像DCT那样大量并行。HEVC为此引入了波前并行处理(WPP)等补救手段,但这属于“并行化补救”,CABAC本身的串行本质没有变。做硬件的人对这一点体会最深。
6. 工程实现与调试:几个容易踩的深坑
6.1 上下文初始化:QP、cabac_init_idc、slice边界
我见过最典型的CABAC bug是“换一个QP就花屏”。原因多半是上下文初始化没有跟着QP走。H.264的上下文初始状态由当前slice的QP和cabac_init_idc共同决定,cabac_init_idc又和slice类型(I/P/B)相关。同一个语法元素,在I slice和B slice里的先验分布不同,初始值自然不同。每次slice开始,所有上下文必须重置,否则上一个slice遗留的状态会被下一个slice沿用,轻则压缩率下降,重则直接解不出。
排查技巧:拿标准码流和参考解码器输出做逐bin对比,在第一个不一致的地方停下来,回头看那个bin所属的上下文索引和初始化值。绝大多数初始化问题能在几小时内定位。
6.2 归一化和进位:看着简单,写起来全是坑
算术编码的R/L是共享状态,归一化时左移输出高位比特,但输出不是“定了就能走”。当区间横跨0.5附近时,编码器暂时无法确定该输出1还是0,只能把决策挂起。H.264的实现里有个bits_outstanding机制:先不输出,等后续区间收缩,再一次性释放一串前置的1或0;中途一旦进位,还会反向修改已经写出去的字节。这个进位回灌是工程实现里最烦的部分,处理不好会在特定码流上出现字节错位。
工程上常见做法是维护一个未定字节计数器,输出时人为延迟一个字节;进位发生时把前一个字节加1,并把之前连续为0xFF的字节一并“滚”成0x00。x264的cabac.c里这段逻辑写得很清楚,建议先读懂那段代码再看标准条文。
6.3 调试CABAC:别把“能跑”当成“正确”
CABAC解码有个隐蔽问题:区间连续、高位比特推迟输出,解码器即使有细微错误也可能“侥幸”多跑几步才暴露,甚至某些错误码流上完全不报错,只表现为画面有细微差异。所以验证CABAC实现不能只看跑通,要做两件事:
- 用标准测试序列配合参考软件(JM、HM、VTM)做逐bit对比,覆盖不同QP、不同slice类型、大运动和小运动场景;
- 在解码器内部加断言:R和L必须始终处于合法范围;每解完一个bin,上下文状态索引必须合法。调试版本开着断言跑几千帧,很多偶发问题就现形了。
6.4 性能:CABAC经常是整条链路的木桶短板
软解时,CABAC的解码速度常常是整个解码器里最低的,尤其在低码率高分辨率场景。几个实用的优化方向:查表尽量合并,range和state的二维查表可以预先缓存;能走旁路的语法元素尽量走旁路,减少每次bin的上下文读取;硬件实现时把上下文SRAM分bank,减少读写冲突。视频编码圈有句话:“学CABAC,入门一天,调对吞吐一个月。”我第一次把CABAC解码器集成进播放器时深有体会——其他模块都满负荷跑着,就它卡在单线程串行依赖上。
CABAC是那种“原理三句话、实现一整天”的模块。把二值化、上下文建模、自适应更新、常规/旁路双路径这几块拆开理解之后,你再看H.264/H.265标准里的CABAC章节,会发现它其实是一套非常工程化的框架:用上下文条件化换信息量,用自适应换鲁棒性,用旁路换吞吐,用查表换硬件可实现性。我个人建议,学CABAC时不要一上来啃tabRangeLPS和状态转移表,先拿一个开源解码器的源码,把“一个bin从进入编码器到变成比特流”的完整路径走一遍,再回头看标准,会有种豁然开朗的感觉。下一篇我会接着写HEVC里CABAC的具体变化,包括上下文初始化参数的推导、变换系数编码里Rice参数的自适应,以及WPP对CABAC并行化的实际影响。