简介:围绕H.264整数DCT变换编码与量化过程的单篇文档,面向视频编码学习者、课程设计及算法实现人员,帮助梳理4×4块无乘法整数变换的原理、实现方式及其与量化的衔接。资源包共1个doc文件,约194KB,篇幅集中,便于快速查阅公式推导、变换核结构以及编码器中的处理流程。内容从浮点DCT带来的运算复杂和编解码误匹配问题切入,展开基于提升结构的BinDCT、仅含加法与16位算术移位的实现方式,并说明亮度与色度DC系数经Hadamard变换的处理及量化参数调整思路。已有880人学习,适合作为H.264变换量化模块的案头参考,也可配合标准文档核对正反变换公式与残差数据处理细节。
1. 整数 DCT 变换:H.264 里那个「名不副实」的变换
H.264 里被反复提起的整数 DCT 变换,严格讲已经不是 DCT 了。它没有余弦函数,没有无理数核,没有任何一次浮点乘法,甚至连归一化都在变换这一步被拆掉——缩放因子被整体挪到量化环节去消化。一套视频编码标准在变换上做这种改名换姓式的操作,动机并不神秘:定点运算便宜,而且只有精确的整数,才能让编码器和解码器在反变换之后得到逐比特一致的输出。
这件事值得搞懂,因为它是整条压缩链路里最容易被误解的一环。多数人知道「H.264 用 4×4 整数变换替代了 8×8 浮点 DCT」,却说不清为什么 4×4、为什么核矩阵长成那几个小整数、为什么量化里会冒出一张查表的乘法因子。这些答案连在一起,就是整数 DCT 变换与量化能够成立的全部理由。
2. 浮点 DCT 为什么必须被换掉:误匹配的根因
2.1 无理数核与有限精度:两件注定对不上的事
经典 DCT 的变换核是余弦值,本质是无理数。而任何有限精度的浮点表示都只是无理数的一个近似,再加上运算过程中每一级舍入,同一组输入系数在两条不同的实现路径上算出来的结果,最低几位几乎不可能完全一致。问题在于视频编码是一个闭环:编码器要自己跑一遍反变换来重建参考帧,解码器也要跑一遍反变换来重建参考帧,两次重建的输入是同一批系数,输出却可能差一点点。
这一点点差异在单帧上看不出来,因为人类视觉对 ±1 的像素误差毫无感知。但视频不是单帧,参考帧会被后续帧拿去做运动补偿和帧内预测,误差就此进入下一轮的残差计算。这是典型的后悔药场景:错过的精度补不回来。
提示:标准对解码器输出规定的是容差,而不是逐比特一致。这意味着两份都「合规」的解码实现,长期播放同一段码流,画面可能越走越偏。
2.2 闭环里的误差为什么会累积而不是自愈
编码链路的结构决定了残差是相对预测值计算的:重建像素 = 预测像素 + 反变换输出的残差。如果反变换的输出比正变换的输入小了 1,这个 1 不会在下一帧被重新计算出来,它会留在参考帧里,下一帧在这个已经偏了的预测上继续算残差,于是偏差被继承。
更麻烦的是低频。变换后的 DC 和低阶 AC 系数承载了块内绝大部分能量,它们对应的基函数在空间上是缓慢变化的平面,一旦 DC 出现系统性偏置,整块都会平移。累积若干帧之后,表现就是画面整体变亮或变暗、肤色区域偏色、平坦区域出现缓慢移动的块状纹路。这类现象在现场排查时极其玄学,因为单帧对比几乎看不出差别,只有把编解码循环跑上几百帧才会显形。
浮点 DCT 的第二个问题是成本。一次 4×4 二维 DCT 有相当数量的浮点乘加,在通用 CPU 上为了精度往往要用双精度,在专用硬件上则是面积和功耗。低端设备、实时监控、移动端这些场景,浮点单元本身就是稀缺资源。所以更换变换,动机是双份的:既要精度可控,又要运算便宜。
2.3 三步改造:把无理数赶出变换核
从 DCT 走到 H.264 的整数变换,可以拆成三个动作,每一步都在做减法。
第一步,把 4 点 DCT-II 的核写成显式矩阵,元素是 cos(π/8)、cos(3π/8)、cos(5π/8)、cos(7π/8) 这些常数。第二步,把这些常数按比例近似成整数比——H.264 的核矩阵就是这组近似的产物:
[ 1 1 1 1 ] Cf = [ 2 1 -1 -2 ] [ 1 -1 -1 1 ] [ 1 -2 2 -1 ]这个矩阵的每一行都是正交的,任意两行内积为零,所以它保持了 DCT 的去相关能力,能量依然能集中到左上角。但它不是归一化正交矩阵:四行的范数平方分别是 4、10、4、10,互不相等。
第三步,正是这个「不等」提供了操作空间。把每行的范数提出来,行 i 和列 j 的归一化因子相乘,就得到一个点乘矩阵 E,二维变换写成:
Y = ( Cf · X · Cf^T ) ⊗ Ef其中 Ef 的四个独立元素是 a²、ab/2、b²/4,a 和 b 由范数决定。这个矩阵不再参与变换本体,它被整体移交给量化,与量化步长的除法合并成一次乘法查表。常见的实现思路是:变换负责把能量搬到正确的位置,缩放负责把数值拉回正确的尺度,两件事分开做,各自都能做得更便宜。
这一步是整个设计的枢纽。归一化不是被省略,而是被延迟了——延迟到量化那种本来就要做乘法的环节里,让一次乘法干两件事。删掉归一化之后,变换核里只剩下 1 和 2,乘 2 可以用左移实现,整数变换的「无乘法」就是这么来的。
3. 4×4 整数变换:核矩阵、行列分离与蝶型实现
3.1 第二行为什么是 2,1,-1,-2
核矩阵的四行对应四个基函数,第 0 行是全 1,代表直流分量;第 2 行是 1,-1,-1,1,在四个采样点上换了两次符号,接近半个周期,是最高频分量。第 1 行和第 2 行分别是奇对称的二次和三次变化,用 2 和 1 来近似原本的正弦比值。
原始 4 点 DCT 核里,奇对称行的元素比例是 cos(π/8) : cos(3π/8) ≈ 0.9239 : 0.3827 ≈ 2.414 : 1。用 2 : 1 去近似,比例误差大约 17%。这个误差听起来不小,但它作用在基函数上,只影响该频段分量的幅度,不影响位置;而幅度上的偏差会被后面的量化整数量化掉——量化步长最小也是 0.625,且是分级放大的,17% 的基函数偏差在粗量化下几乎不可见。
代价是压缩效率略微下降,收益是整行运算只需要一次左移加一次加法。这笔账在后面第 6 章还会算一次,这里先记住:整数变换不是 DCT 的精确实现,而是一个被量化误差掩护住的近似。
3.2 二维拆成两次一维:先列后行还是先行后列
二维变换直接算的复杂度是每输出一个系数要做 4×4 次乘加,整块 64 次。利用变换核可分离的性质,二维变换可以拆成两次一维:先对每一列做一维变换,再对结果的每一行做一次。由于矩阵乘法满足结合律,先列后行和先行后列结果完全一致,实际实现里通常选对缓存更友好的那个顺序。
拆开之后,运算量变成 4 列 × 每列 8 次加法 + 4 行 × 每行 8 次加法,共 64 次加法、0 次通用乘法,只有乘 2 的左移。这个数量级对硬件极其友好:没有乘法器,没有流水线冲突,一个简单的加法树就能跑完。
3.3 蝶型结构:12 次加法怎么压到 8 次
如果老老实实按矩阵行的定义逐项展开,第一列的一维变换是四个独立的线性组合,每个组合 3 次加法,共 12 次加法,其中包含 4 次乘 2。但只要观察一下就会发现,x0 + x3 这个子式同时被第 0 行和第 2 行用到,x1 + x2 也是;x0 - x3 和 x1 - x2 则被第 1 行和第 3 行共用。把这四个中间量先算出来存着,就能砍掉重复计算。
/* 一维 4 点整数正变换:输入 x0..x3,输出写入 y[0..3] */ static inline void fdct_1d(int x0, int x1, int x2, int x3, int *y) { int s0 = x0 + x3; /* 同时供给 y[0] 与 y[2] */ int s1 = x1 + x2; int s2 = x1 - x2; /* 同时供给 y[1] 与 y[3] */ int s3 = x0 - x3; y[0] = s0 + s1; /* 基向量 1 1 1 1 */ y[1] = (s3 << 1) + s2; /* 基向量 2 1 -1 -2,乘 2 用左移 */ y[2] = s0 - s1; /* 基向量 1 -1 -1 1 */ y[3] = s3 - (s2 << 1); /* 基向量 1 -2 2 -1 */ }四个中间量 s0 到 s3 各用一次加法得到,四个输出再用四次加法拼出来,总计 8 次加法、2 次左移,比直接展开少 4 次加法。这就是蝶型算法的全部秘密,它没有改变计算结果,只是把公共子表达式提取出来复用。
参数含义很直白:x0 到 x3 是同一列(或同一行)上相邻的四个残差采样,取值由预测残差决定,通常在 -255 到 255 之间;y 数组依次存放 DC、垂直/水平一阶、对角二阶、最高频四个系数。实际实现中这个函数会被调用 8 次——4 次处理列,4 次处理行,中间结果存在一个 4×4 的临时数组里。
注意:行列两次变换之间不要提前做任何缩放。缩放必须留到全部 64 个系数算完之后,与量化一起做,否则中间结果会被放大或截断,且无法与解码端对齐。
3.4 16 位算术的边界:中间结果到底要留几位
H.264 规范里,正变换的输入是 9 位有符号残差,变换过程的中间值要求能落在 16 位有符号范围内。这个约束是可以推出来的:一维变换后,最坏情况下某个输出是四路同号相加,幅度约为输入最大值的 4 倍;二维之后还会再乘一次 4,理论上能到输入上限的 16 倍。255 × 16 略大于 4000,远在 32767 之内,所以 16 位确实够用。
但这是有前提的:输入必须是预测残差,而不是原始像素。性能对比实验里最典型的错误就是把原始像素直接送进变换,幅度立刻翻几倍,加上某些块的全高频内容,16 位就会溢出,表现为画面里的亮斑、色斑或者整块发白的「雪花」。稳妥的做法是中间结果统一用 32 位存,最后一次性地做饱和裁剪,只在确定要上硬件时才按规范的位宽去抠。
反变换这一侧的处理是不对称的。反变换核里含有 1/2 的因子,H.264 的做法是用右移实现。按常理右移会丢精度,但这里不会:反变换的输入是反量化之后的结果,幅度已经被放大过,右移一位丢掉的那点信息远小于量化本身引入的误差。原文对这个点的判断是「反量化后的结果足够地大,所以不会出现截断误差的问题」,这是把精度预算从上到下分配好的结果,不是随手写的一个移位。
4. DC 系数的二次变换与量化:从 Hadamard 到 52 级步长
4.1 为什么要把 DC 单独抽出来再变一次
每个 4×4 块做完变换后,左上角的 DC 系数集中了块内绝大部分能量——在平坦区域,它甚至能占到九成以上。一个 16×16 的亮度宏块包含 16 个 4×4 块,也就有 16 个 DC。这 16 个 DC 之间高度相关,因为它们描述的是宏块内相邻区域的平均亮度,而自然图像的平均亮度在空间上是缓慢变化的。
各自独立量化这 16 个 DC,等于把一个平滑变化的二维信号拆成 16 个孤立标量分别编码,浪费了大量冗余。H.264 的做法是把它们抽出来重新排成一个 4×4 阵列,再做一次变换,把这层残留的相关性也压掉。这一步发生在 Intra_16×16 帧内模式下的亮度分量上;色度分量则是 4 个 4×4 块抽出 DC 组成 2×2 阵列,做一次 2×2 的变换。
这也解释了为什么图 2 那类系数分配图里,DC 的位置会被单独标号:解码端必须知道哪些系数走的是「二次变换」通路,哪些走的是普通通路,两者的反量化缩放完全不同。
4.2 Hadamard 变换:正反同形,连查表都省了
DC 二次变换用的是 Hadamard 变换而不是 DCT。原因是 Hadamard 矩阵的元素只有 ±1,正变换和反变换形式完全一致,且矩阵本身自逆(差一个常数因子),实现上只需要加减和一次右移。
/* Intra16x16 亮度 DC 的 4x4 Hadamard 变换,正变换与反变换同形 */ static void hadamard4x4(int d[4][4]) { int i, t0, t1, t2, t3; /* 先对每一列做蝶型加减 */ for (i = 0; i < 4; i++) { t0 = d[0][i] + d[3][i]; t1 = d[1][i] + d[2][i]; t2 = d[1][i] - d[2][i]; t3 = d[0][i] - d[3][i]; d[0][i] = t0 + t1; d[1][i] = t3 + t2; d[2][i] = t0 - t1; d[3][i] = t3 - t2; } /* 再对每一行做同样的运算,最后统一右移一位完成除以 2 */ for (i = 0; i < 4; i++) { t0 = d[i][0] + d[i][3]; t1 = d[i][1] + d[i][2]; t2 = d[i][1] - d[i][2]; t3 = d[i][0] - d[i][3]; d[i][0] = (t0 + t1) >> 1; d[i][1] = (t3 + t2) >> 1; d[i][2] = (t0 - t1) >> 1; d[i][3] = (t3 - t2) >> 1; } }这段代码里有两点值得注意。其一,四个中间量 t0..t3 的复用方式和 4 点整数变换一模一样,因为 Hadamard 矩阵的加减结构和它同构,区别只在于没有乘 2 的项,全部是 ±1。其二,右移 1 位放在第二轮循环的赋值里,意味着归一化在变换内部就完成了,不像主变换那样把缩放留给量化——因为 Hadamard 的缩放因子是 2 的整数次幂,移位即可精确完成,不存在无理的缩放矩阵。
色度那一路的 2×2 变换更简单,矩阵是 [1 1; 1 -1],四个输入变成四个加减组合,同样右移一位。
4.3 52 级非均匀步长:QP 每加 6,步长翻一倍
量化是变换域压缩真正产生有损的地方。H.264 用的是等比例标量量化,基本形式是量化值等于变换系数除以量化步长再取整。步长由量化参数 QP 决定,QP 取 0 到 51,共 52 个档位。
关键在于这 52 个档位不是等距的。步长的规律是:QP 每增加 6,步长精确翻倍;每增加 1,步长增加约 12.25%。12.25% 这个数字来自 2 的六次方根减一,约等于 0.1225,也就是说 52 个档位实际上是一条几何级数。
| QP | 0 | 1 | 2 | 3 | 4 | 5 | 6 | … | 12 |
|---|---|---|---|---|---|---|---|---|---|
| Qstep | 0.625 | 0.6875 | 0.75 | 0.875 | 1.0 | 1.125 | 1.25 | … | 2.5 |
做成几何级数而不是等差数列,是为了码率控制。等距步长在低码率端的相对变化太剧烈——步长从 1 跳到 2,压缩率直接翻倍,画质断崖;而在高码率端又太迟钝。几何级数让每一档带来的相对变化恒定在 12.25%,无论当前处在什么码率区间,微调一档 QP 得到的主观画质变化都差不多,码率控制才能做细。
4.4 变换与量化的融合:MF 表、qbits 与偏移量
量化公式里有一个除以 Qstep 的除法,除法在硬件上昂贵且不可流水化。H.264 的处理办法是把除法变成乘以倒数,再把倒数做成一张按 QP 和系数位置索引的查表。
推导链条是这样的:量化输入是变换输出 Y,而 Y 里还含着前面没做的点乘缩放 Ef。于是量化值等于 Y 乘以 Ef 再除以 Qstep 取整,把 Ef 和 1/Qstep 乘在一起,得到一个与 QP 和位置都有关的因子 MF。同时为了避免浮点,整体乘上 2 的幂次做定点化,除法就变成了右移:
/* H.264 4x4 前向量化:Y 为变换输出,QP 为量化参数 */ static int quant_coeff(int Y, int pos, int QP, int is_intra) { /* MF 由 QP%6 和系数在块内的位置共同决定,实现中查表得到 */ int mf = mf_table[QP % 6][pos_group[pos]]; int qbits = 15 + (QP / 6); /* 每加 6 个 QP,右移多一位 */ int f = is_intra ? (1 << qbits) / 3 : (1 << qbits) / 6; /* 偏移量 f 保证取整是四舍五入而非截断 */ return (Y * mf + f) >> qbits; }mf_table 的行是 QP 对 6 取余的六种情况,列是三组位置分类:四个角(DC 附近)、上下左右边缘、内部四个。三组数值之所以不同,正是因为 Ef 在块内的取值不是常数——这正是「变换核不正交」的代价被量化吸收的地方。
qbits 的构造也很有意思:15 是定点化的基准位宽,QP 每加 6 就多移一位,恰好对应当前步长翻倍。偏移量 f 是帧内取 2 的 qbits 次方除以 3,帧间取除以 6,作用是让取整变成四舍五入,同时帧内偏移更大,倾向于保留更多系数,因为帧内预测的残差通常更接近零,小系数更需要被保留。
注意:这里对负数做右移有陷阱。多数平台上的算术右移等价于向下取整而非向零取整,如果偏移量 f 取成正数,负系数的量化结果会系统性偏小。这是实现里最容易被忽略的一处偏置,第 5 章会专门展开。
反量化则反过来:乘以一张 V 表,再左移 floor(QP/6) 位。V 表的数值和 MF 表是互为倒数的近似关系,同样按 QP 对 6 取余和位置分组。整条链路里,变换负责能量集中,量化负责精度截断,两张表负责把中间那层缩放找补回来,三方配合才能让重建值落回正确的量级。
5. 避坑指南:整数变换与量化里最容易翻车的五个地方
5.1 变换链路里的三个坑
现象一:解码画面出现规则的 4×4 网格状块效应,平坦区域尤其明显,边缘还伴随亮度偏移。
原因通常是把 DC 通路的处理顺序搞反了。普通 AC 系数的链路是反量化 → 反变换;但 Intra_16×16 的亮度 DC 和色度 DC 走的是另一条路:先做反 Hadamard 变换,再做反量化,最后把还原出的 16 个 DC 填回各自的 4×4 块,与 AC 系数拼成完整块,才进入反 DCT。顺序反过来,DC 就会在反量化阶段被多乘一次巨额的 V 因子,量级彻底不对。
解决办法是对照解码流程把每条支路单独画一遍:DC 支路走「反 Hadamard → 反量化(专用缩放)→ 填回」,AC 支路走「反量化 → 反 DCT」,两条支路在装配点汇合。装配那个动作在实现里常被写成一个查表填值的小循环,别小看它,它是唯一能验证两条支路是否对上的地方。
现象二:纹理复杂的块局部发白、出现亮斑或色斑,平滑块完全正常。
原因多半是中间结果溢出。如第 3 章所述,4×4 变换在输入满幅残差时,二维输出的峰值理论上接近输入的 16 倍,255 × 16 虽然还在 16 位内,但一旦输入不是残差而是原始像素,或者防护逻辑里跳过了饱和裁剪,就会越界。C 语言里 int16 越界是未定义行为,实测表现依编译器和架构而异,x86 上可能静默回绕,ARM 上也可能是,同一份代码换平台就出现不了,非常难查。
解决办法有两条:中间数组一律用 32 位,只在写回缓存前做一次饱和;以及写一个只喂 ±255 极值输入的单元测试,把每个中间变量的峰值打印出来,亲眼确认它落在预期范围内。我见过不止一个实现是靠这个笨办法定位到问题的。
现象三:把 4×4 的缩放矩阵直接套在 8×8 变换上,或者反过来,导致整体亮度和对比度偏移。
原因是对 Ef 的理解停留在「一个归一化矩阵」,没意识到它和变换核是绑定的一对。换了核矩阵,行范数就变了,Ef 的四个独立元素都得重算。有人为了做性能对比,把 4×4 换成一个自己推导的 8×8 整数核,核换了却没改量化表,结果是每次重建都整体偏暗百分之几,而且这个偏置是乘性的,暗部不明显、亮部被拉伸,看起来像 gamma 出了问题。
解决办法是把这个不变量写进注释:Ef 的每个元素等于对应行列范数乘积的倒数。只要记住这一条,任何新核的缩放矩阵都能现推,不依赖记忆。
5.2 量化与重建链路里的两个坑
现象四:重建图像整体偏暗,暗部细节丢失,且偏置随 QP 增大而加剧。
原因是量化时的取整方向。大多数实现写成(Y * mf + f) >> qbits,如果平台上的算术右移对负数等价于向下取整,那么负系数会被比正系数多压一点,而残差正负基本对称,长期统计下来就表现为整体向负方向偏移。
解决办法是让偏移量和取整方式匹配。如果右移是算术右移,偏移量必须足够大且方向正确,使得正负两侧的取整都是四舍五入;更稳妥的做法是显式写一个对称的取整函数,或者量化前先判断符号、对绝对值操作再还原符号。代价是多一次分支,收益是消掉一个随 QP 放大的系统性偏置。
现象五:码率控制曲线在某个 QP 附近突然跳变,或者明明只调了一档 QP,码率变化却有百分之十几。
原因不外乎两个。一是把 QP 当成线性量在使用,比如按QP + 1去估算码率变化,但 QP 每加 1 步长增加 12.25%,每加 6 才翻倍,这是一条指数曲线,在低 QP 段一档的影响远小于高 QP 段。二是量化表索引写错,把QP / 6和QP % 6用反了,前者决定移位量,后者决定查表的行,用反之后步长就是乱的,但代码能跑、能出图,只是码率不对。
解决办法是把 QP 到 Qstep 的映射做成一张显式的 52 项表,在调试构建里断言实际使用的 Qstep 与表值一致。这样任何一处索引写反都会在第一个测试用例上暴露,而不是等到码率曲线画出来才发现。
6. 设计取舍:4×4、无乘法与那不到 1dB 的压缩率损失
把整条链路看完,会发现 H.264 在变换和量化上的每一个选择都是在做减法,而且是有代价的减法。
4×4 而不是 8×8,直接损失了频域分辨率。块越小,可用的基函数越少,对平滑渐变区域的去相关能力越弱,同码率下的压缩效率比 8×8 低一截。但小块的边界更贴近真实物体边缘,块效应的影响范围小,主观画质反而更好;更重要的是,4×4 变换的中间结果能塞进 16 位,硬件上可以用一个很小的加法阵列完成,片上缓存也不用重新设计。用 8×8 换来的一点压缩率,要拿面积、功耗和延迟去换,在当时的工艺条件下不划算。
核矩阵用 2 和 1 近似而非更精确的无理数比例,代价是基函数失真约 17%,收益是整行运算只剩加法与移位。这个失真是乘性的,会被后面的量化吸收——当步长最小都是 0.625 时,17% 的基函数偏差落到重建像素上往往不足 1 个灰度级。换句话说,它是被量化误差掩护住的近似,只有在极低 QP 的高码率场景下才可能被察觉。
把归一化从变换挪到量化,代价是量化表不再是一个纯对角的东西,MF 表必须按系数在块内的位置分组,编码器和解码器都要维护这张表,任何一处不对齐都会造成整体偏置。收益是变换本体彻底摆脱了乘法和无理数,实现上可以做到只有加法和移位,同时把解码端的输出精度问题从「依赖浮点实现」变成了「依赖整数查表」,误匹配的可能性被降到零。
验证这套东西是否实现正确,我一般用三步。第一步,喂一个全零残差块,确认输出全零,且反变换回来还是全零——这条能抓出偏移量和取整方向的错误。第二步,喂一个纯 DC 块(比如全 128 的残差),确认能量只落在左上角,且反变换重建值与输入完全一致——这条能抓出缩放矩阵的配对错误。第三步,用一段真实视频跑编解码闭环,逐帧计算重建帧与原始帧的 PSNR,把曲线和参考实现对比,偏差超过 0.5dB 就要回头看量化表的索引。这三步跑下来,基本能覆盖变换与量化链路里的绝大多数实现错误。
从那以后我每次碰变换编码相关的东西,不管代码看起来多简单,都会先写一个极值输入的单元测试,把那几个中间变量的峰值打印出来看一眼——血泪经验换来的习惯,比事后对着画面调半天有用得多。希望帮到你。
本文还有配套的精品资源,点击获取