简介《H.264的整数DCT变换编码与量化过程》是一份面向视频编码学习者、算法研究人员与嵌入式多媒体开发者的技术文档聚焦 H.264 中 4×4 块整数 DCT 变换及配套量化流程帮助读者理解无乘法整数变换如何降低运算复杂度并规避编解码反变换误匹配。压缩包共 1 个 doc 文件约 194KB内容以期刊论文形式梳理变换原理、正反变换公式、BinDCT 提升结构、亮度与色度 DC 系数的 Hadamard 变换以及编码器从预测残差到熵编码前的变换与量化过程并说明量化参数和压缩效果的关联。已有 878 人学习适合作为视频压缩课程、论文研读或工程实现时的参考资料便于快速定位整数变换的关键推导、DC/AC 分块关系、移位处理细节并理解 BinDCT 仅用加法和 16 位算术移位的实现特点与误匹配规避思路。1. 一块残差进编码器之后H.264 的整数 DCT 变换与量化到底做了什么接手 H.264 编码器的残差通路时最先卡住的往往不是熵编码而是中间两步4×4 整数 DCT 变换和紧随其后的量化。浮点 DCT 在这里活不下来变换核里的无理数在定长实现中必然带来舍入差编码端和解码端各算各的重建图像会一点点漂走。H.264 的处理办法是把变换核压成只含 1、2、-1、-2 的整数矩阵用 16 位加减和移位完成 4×4 变换把归一化系数整体外提到量化环节再用 52 级非均匀量化步长把系数压到有限幅度。这套组合的价值不在公式漂亮而在于编解码两端能用完全相同的整数路径算出完全相同的结果。做编码器移植、FPGA 变换模块或者想手写一个能和参考解码器对齐的残差通路这份资料里的推导和参数表就是底稿。2. 整数 DCT 变换核把无理数从 4×4 矩阵里挤出去2.1 浮点 DCT 在定长实现里的两笔烂账标准 4×4 DCT 的核矩阵长这样第一行是四个 1/2第二行是 cos(π/8)、cos(3π/8)、-cos(3π/8)、-cos(π/8)第三行是 1/2、-1/2、-1/2、1/2第四行是 cos(3π/8)、-cos(π/8)、cos(π/8)、-cos(3π/8)。cos(π/8)≈0.9239cos(3π/8)≈0.3827都是无限不循环的无理数。第一笔账是运算代价一次 4×4 二维变换要 32 次浮点乘加在低端 DSP 或纯逻辑资源的 FPGA 上一个浮点乘法器动辄吃掉几百个 LUT一个宏块 16 个 4×4 块跑下来吞吐直接掉档。第二笔账更要命是误匹配。编码器用正变换核算一遍解码器用转置核反算一遍两边核里都含无理数都只能截断成有限位表示截断位置还不一定相同误差在预测环路里逐帧累积重建画面就会缓慢偏色、漂移直到下一个 IDR 帧才被强行拉回。这种漂移在现场看就是“播十分钟开始发灰、一刷新就好”排查起来特别费劲因为单帧对拍是吻合的。整数变换的意义不只是省乘法。它把“编码器和解码器算出同一个数”这件事从概率变成了确定事实所有中间结果都是 16 位整数加减和移位不存在舍入分支只要两端用同一张表、同一个移位量结果逐比特相同。参考解码器之所以能拿来当验收基线前提就在这里。2.2 从浮点核到整数核公因子外提的推导路线改造的思路不复杂把 4×4 DCT 核矩阵的每一行都提出一个公因子让剩下的部分全是小整数。第一行提 1/2剩下的就是 [1,1,1,1]第二行提出 2·cos(3π/8) 之后元素比例变成 [2,1,-1,-2]第三行提 1/2 后是 [1,-1,-1,1]第四行提出 2·cos(π/8) 之后是 [1,-2,2,-1]。于是正变换核写成Cf [[ 1, 1, 1, 1], [ 2, 1, -1, -2], [ 1, -1, -1, 1], [ 1, -2, 2, -1]]这个矩阵里只有 1、2、-1、-2乘法全部退化成加法和左移一位。二维正变换写成 W Cf · X · Cf^T其中 X 是 4×4 的预测残差块W 是变换后的系数块。但因为每行提走的公因子不一样W 里各位置的幅度比例已经被打乱了必须乘上一个后缩放矩阵 E 才能等价于真正的 DCT。E 的元素只有四种取值a²1/4、ab/2≈0.1581、b²/40.1a1/2b√(2/5)按位置分三档分布。反变换核由 Cf 的转置变形而来元素是 1、1/2、-1/2、-1也就是Ci [[ 1, 1, 1, 1], [ 1, 1/2,-1/2, -1], [ 1, -1, -1, 1], [1/2, -1, 1,-1/2]]反变换写成 X Ci^T · Y · Ci其中 1/2 用右移实现。这里有个纸上推导容易忽略的点右移会截断但反量化之后系数的幅度足够大截断误差落到最低位上对重建几乎没有可见影响所以标准允许这么做。2.3 缩放因子外提变换这一步故意不做归一化真正省事的设计在下一步。既然 E 是个逐点乘的常数矩阵那就干脆不在变换里乘它把这次乘法推到量化环节和量化本身的除法合并成一次“乘一个整数再右移”。这就是尺度 DCT 的思路变换只负责把能量往左上角集中幅度归一化交给量化表去吸收。好处是双向的。变换侧完全摆脱乘法和浮点一个 4×4 块只用加减和移位量化侧本来就要乘一个量化步长的倒数把 E 的因子并进去不增加任何额外操作。反方向同理反量化表 V 里预先吸收了 Ci 带来的缩放反变换只负责把系数搬回像素域。整条残差通路从预测残差到量化系数再到重建残差中间没有一次除法和一次浮点运算。用 Python 把这个链路写出来能直观看到哪些因子被推到了哪里import numpy as np Cf np.array([[1, 1, 1, 1], [2, 1, -1, -2], [1, -1, -1, 1], [1, -2, 2, -1]], dtypenp.int32) # 后缩放矩阵 E实际不显式乘这里只用于对照验证 a, b 0.5, np.sqrt(2.0 / 5.0) E np.array([[a*a, a*b/2, a*a, a*b/2], [a*b/2, b*b/4, a*b/2, b*b/4], [a*a, a*b/2, a*a, a*b/2], [a*b/2, b*b/4, a*b/2, b*b/4]]) def forward_transform(block): 4x4 整数正变换输入残差块输出未缩放的整数系数 W X np.asarray(block, dtypenp.int32) return Cf X Cf.T # 只有加法和左移无乘法 def forward_reference(block): 仅用于验证整数正变换 后缩放应逼近浮点 DCT 的系数 return forward_transform(block) * Eforward_transform返回的 W 是纯整数forward_reference乘上 E 之后才在数值上等价于标准 DCT 系数用它和浮点 DCT 对拍可以确认变换核没写反。注意Cf X Cf.T里的两次矩阵乘法在硬件上不是一个 4×4 乘一个 4×4而是拆成 8 次一维变换这一点下一章展开。参数上唯一要留意的是位宽残差范围是 -255..255一维变换后最大约 4×2551020二维之后约 408016 位有符号整数上限 32767足够覆盖不需要扩到 32 位——这也是这套变换能塞进窄数据通路的原因。提示Cf 的第二行和第四行系数是 2实现时用左移一位而不是乘法但要注意左移前先确认不会溢出 16 位。3. 蝶形结构与两级 DC 变换4×4 块的运算怎么压到加减法3.1 行列分离一个 4×4 变换拆成 8 次一维变换二维变换之所以能拆是因为核是可分离的W Cf · X · Cf^T 等价于先对 X 的每一列做一维变换得到中间矩阵再对中间矩阵的每一行做一次同样的变换。次序反过来先行后列结果一样实现时选哪种取决于你的数据在内存里是行优先还是列优先。拆开之后一个 4×4 块需要 8 次 4 点一维变换每次一维变换处理 4 个数。硬件上这意味着只需要一个 4 点变换单元配上转置缓存复用 8 次软件上就是两层循环。这个结构的价值在资源受限场景特别明显一个 4 点单元的面积远小于一个完整的 4×4 二维变换阵列代价只是多一点控制逻辑和中间存储。对比一下运算量。直接按矩阵乘法做一个 4×4 块需要 2×4×4×4128 次乘加拆成一维之后每次一维变换是 16 次乘加8 次共 128 次看似没省。真正省下来的是下一节的蝶形复用。3.2 一维蝶形12 次加法压到 8 次的复用逻辑把一维变换的四个输出展开写y0 x0x1x2x3y1 2x0x1-x2-2x3y2 x0-x1-x2x3y3 x0-2x12x2-x3。逐条算需要 12 次加减。但四个式子里有明显重复x0x3 在 y0 和 y2 里都出现x1x2 也是x0-x3 和 x1-x2 在 y1、y3 里各出现两次。先把这四个和差缓存下来s0 x0 x3 s1 x1 x2 d0 x0 - x3 d1 x1 - x2再组合输出y0 s0 s1 y2 s0 - s1 y1 (d0 1) d1 y3 d0 - (d1 1)总共 8 次加减加 2 次左移比逐条展开省掉 4 次运算而且乘 2 全部用移位实现。这就是原文里蝶形算法的实质不是换了数学是把公共子表达式提出来只算一次。def fdct_1d(x): 一维 4 点整数变换输入 4 个整数输出 4 个整数 x0, x1, x2, x3 x s0 x0 x3 s1 x1 x2 d0 x0 - x3 d1 x1 - x2 return [s0 s1, # y0 (d0 1) d1, # y1乘 2 用左移 s0 - s1, # y2 d0 - (d1 1)] # y3 def fdct_4x4(block): 先列后行的二维整数变换 tmp [fdct_1d([block[r][c] for r in range(4)]) for c in range(4)] # 逐列 mid [[tmp[c][r] for c in range(4)] for r in range(4)] # 转置 out [fdct_1d(mid[r]) for r in range(4)] # 逐行 return outfdct_1d的输入输出都是整数位宽按上一节估的 16 位走。fdct_4x4里那次转置是行列分离实现的关键硬件上对应一块 4×4 的寄存器阵列或者一个转置缓存如果省掉它直接按行做两遍得到的是 Cf·X·Cf 而不是 Cf·X·Cf^T系数会整体错乱这是手写实现时最常见的一类错误。另外注意 Python 里对负数是按位左移结果仍然正确但如果你改成乘 2 再比较语义一样性能上没差别只是可读性差一点。3.3 亮度 4×4 Hadamard 与色度 2×2 Hadamard做完 4×4 变换只是第一步。H.264 还有一个容易被略过的设计对 16×16 帧内预测的亮度宏块16 个 4×4 块变换完之后每个块左上角那个 DC 系数都不小而且彼此高度相关。直接把这 16 个 DC 系数各自量化编码等于浪费了一次压缩机会。标准做法是把它们抽出来按块在宏块中的位置拼成一个新的 4×4 矩阵对这个 DC 矩阵再做一次 4×4 Hadamard 变换H4 [[ 1, 1, 1, 1], [ 1, 1, -1, -1], [ 1, -1, -1, 1], [ 1, -1, 1, -1]]色度分量同理8×8 的色度由 4 个 4×4 块组成抽出 4 个 DC 系数拼成 2×2 矩阵做 2×2 Hadamard 变换核就是 [[1,1],[1,-1]]。Hadamard 变换有个很舒服的性质它的正变换和反变换形式完全相同矩阵本身是自己的转置做两次就回到原值差一个常数因子。这省掉了维护两套核的麻烦也避免了正反变换不一致的风险。变换后的 DC 块在码流里排在同一宏块所有 AC 系数之前解码端要先把它反变换回去再按原位置塞回各个 4×4 块。这一步漏做的后果很典型平坦区域比如纯色墙面、天空会出现明显的方块状亮度跳变因为 16 个 DC 系数被独立量化量化误差各不相同重建出来就是一片深浅不一的小方格。而纹理丰富区域反而看不出来因为 AC 系数盖过了 DC 的偏差。所以调试时如果只盯着复杂画面看很容易漏掉这个 bug。3.4 解码侧的顺序DC 先反变换、AC 先反量化编码方向是“变换 → 量化”解码方向却不能简单倒过来。对普通 AC 系数解码器先反量化得到 W再做反变换。但对 DC 系数路径标准规定先做 Hadamard 反变换再做反量化然后才把结果 MUX 回各个 4×4 块的左上角形成完整的系数块最后统一走反 DCT。这个顺序差异不是随意定的它和 DC 块的量化参数、缩放因子在码流里的组织方式有关。实现时如果图省事把 DC 块和 AC 块塞进同一个循环里先统一反量化再统一反变换结果不会崩但重建像素会有系统性偏差PSNR 掉个零点几 dB肉眼在灰阶渐变上能看出轻微条带。这种偏差最难查因为它不像崩溃那样有明确信号。反变换的最后一步是右移 6 位并做舍入把缩放因子彻底还原。舍入方式要和参考实现一致常见做法是加 32 再右移 6相当于四舍五入直接截断会在低幅值区域引入偏暗的倾向。4. 量化过程52 级 QP、乘子表与把除法换成移位4.1 QP 与 Qstep52 级步长的 12.25% 相对步进H.264 的量化步长不是线性增长而是按几何级数排布。QP 从 0 到 51 共 52 级每增加 6步长精确翻倍每增加 1步长乘以 2^(1/6)≈1.1225也就是 12.25%。基础档位是QP0123456121851Qstep0.6250.68750.81250.93751.1251.251.252.55.0320QP6 与 QP5 的步长在表上都是 1.25是因为 QP6 起进入下一档倍增实际值 1.25 与 0 档的 0.625 相差一倍注意查表时的下标取模。这种排布的好处是码率控制粒度细。相对步进恒定在 12.25%意味着无论在低码率还是高码率区间调一级 QP 对码率的影响比例大致相同不会出现“低码率调一级没反应、高码率调一级码率翻倍”的尴尬。做码率控制时这个恒定相对步进让基于比例调节的算法好写很多。4.2 把除法换成乘加右移MF 表与 qbits基本量化公式是 Zij round(Wij / Qstep)直接实现要一次浮点除法和一次取整在定长硬件上都是麻烦事。标准把它改写成整数乘加移位|Zij| (|Wij| * MFij f) qbits sign(Zij) sign(Wij) qbits 15 (QP // 6)MF 是一张按系数位置分三档的乘子表每档 6 个值用 QP 对 6 取模来索引位置类别QP%6012345(0,0)(0,2)(2,0)(2,2)131071191610082936281927282(0,1)(1,0)(1,2)(2,1)(1,3)(3,1)(2,3)(3,2)524346604194364733552893(1,1)(1,3)(3,1)(3,3)806674906554582552434559三档的区别来自缩放矩阵 E 的三种取值左上和右下的 a² 档、边缘的 ab/2 档、中心的 b²/4 档。MF 里已经把这些因子和量化步长的倒数合并成一个整数所以量化时既不用乘 E也不用除以 Qstep。qbits 里的 15 是给乘子表留的定点小数位QP 每加 6qbits 加 1正好对应步长翻倍。MF { a: [13107, 11916, 10082, 9362, 8192, 7282], # (0,0)(0,2)(2,0)(2,2) b: [5243, 4660, 4194, 3647, 3355, 2893], # 边缘位置 c: [8066, 7490, 6554, 5825, 5243, 4559], # (1,1)(1,3)(3,1)(3,3) } def mf_index(r, c): 按位置返回乘子表档位 if r % 2 0 and c % 2 0: return a if r % 2 1 and c % 2 1: return c return b def quantize(W, qp, intraTrue): qbits 15 qp // 6 # 帧内偏移约为 2^qbits/3帧间约为 2^qbits/6整数近似 f (1 qbits) // 3 if intra else (1 qbits) // 6 Z [[0] * 4 for _ in range(4)] for r in range(4): for c in range(4): w W[r][c] mf MF[mf_index(r, c)][qp % 6] z (abs(w) * mf f) qbits Z[r][c] -z if w 0 else z return Zquantize里三个参数各自有讲究。qp决定 qbits 和乘子表的索引是两个维度同时变化。intra切换偏移 f帧内用 2^qbits/3、帧间用 2^qbits/6这个差异下一节展开。对整个块取绝对值再取符号是因为右移对负数的行为和“四舍五入”不一致先取绝对值能保证量化误差方向可控。注意abs(w) * mf最大约 4080×13107≈5.3×10⁷超出 16 位这一步必须用 32 位中间变量是位宽规划里最容易漏的地方。4.3 反量化与 V 表重建侧的对称设计反量化做的是逆运算同样用表加移位。先查 V 表乘上量化系数再左移 (QP//6) 位位置类别QP%6012345(0,0)(0,2)(2,0)(2,2)101113141618边缘位置161820232529(1,1)(1,3)(3,1)(3,3)131416182023|Wij| |Zij| * Vij (QP // 6)V 表的数值比 MF 表小两个数量级因为左移的位数不同前向量化右移 15qb 位反量化只左移 qb 位剩下 15 位的定点缩放由 V 表本身的量级吸收。两个表配合起来整条链路上的缩放因子才刚好抵消重建系数回到和原始变换系数同一个数量级。实现反量化时最常见的问题是符号处理。前向量化里“先取绝对值再补符号”的操作在反量化里要再做一次但有些人会图省事在左移之后才补符号这没错错在把用在有符号负数上——C 语言里对有符号负数的左移是未定义行为不同编译器可能给出不同结果。稳妥写法是先取绝对值左移最后统一补符号或者全程用无符号中间量。4.4 帧内帧间偏移 f 的取值与码率影响f 这个偏移量看起来只是个舍入微调实际影响不小。量化公式里加 f 再右移等价于把量化判决门限往远离零的方向推了一点f 越大幅值小的系数越容易被量化成非零值保留下来码率上升细节保留更多f 越小小系数更容易被压成 0码率下降画面偏平滑。帧内预测之后的残差能量集中在低频且帧内块本身就是参考量化损失会一路传下去所以标准给帧内用更大的 f约 2^qbits/3。帧间预测残差通常更稀疏小幅值系数多是噪声压掉更划算所以用 2^qbits/6。这个差异在低码率下体现得很明显如果帧内块误用了帧间的 fI 帧会出现明显的细节糊化和后续 P 帧的连锁模糊如果帧间块误用了帧内的 f码率会比预期高出一截编码器看着“画质不错”但就是压不下去。实操里还有个细节f 是按 qbits 算出来的整数用移位加除法近似即可不必精确到 2^qbits/3 的浮点值。差个一两单位的偏移对整体码率的影响在千分之几量级不值得为此引入浮点。5. 落地排查整数变换量化实现里最容易翻车的五处5.1 系数幅度对得上重建图像整体偏灰现象拿参考解码器对拍量化系数逐个比对完全一致但重建 YUV 的亮度均值系统性偏低灰阶渐变区出现条带PSNR 比参考低 0.5 dB 以上。原因反变换最后一步的右移舍入写成了直接截断。反变换结果右移 6 位时正确做法是先加 32 再移位实现四舍五入直接 6对正数相当于向下取整每一级都往小偏一点累加到整帧就是整体偏暗。负数方向则相反会往更负偏视觉上表现为暗部细节被压死。解决把反变换输出统一改成(v 32) 6并确认中间变量是有符号 32 位。改完对拍一次均值偏差应该落在 ±0.5 个灰阶以内。这个坑我吃过一次当时查了两天才发现是一行移位的事。5.2 QP 索引错位画质随码率跳变现象QP 从小到大扫一遍画质不是平滑变化而是在某几个 QP 值上突然变好或变差码率曲线出现台阶。原因MF 表和 V 表的索引把QP // 6和QP % 6用反了或者把 qbits 写成了15 QP % 6。表现是 QP 每变化 6 的整数倍时参数才整体跳一次中间 5 级要么重复上一档要么提前进入下一档。查表时 QP6 和 QP5 的 Qstep 在数值上接近很容易让人误以为表没错。解决单独写一个自检函数把 QP0..51 的 qbits、MF 三档、V 三档全部打印出来逐行核对“每加 6qbits 加 1、步长翻倍”这条规律。表对不对用眼睛扫一遍比在代码里推理快得多。5.3 负数右移导致量化系数偏移一个现象正系数全部正确负系数的量化结果偶尔比参考值大 1 或小 1比率大概千分之一肉眼看不出来但码流比对必然不过。原因C 语言里对有符号负数的右移是实现定义行为多数编译器做算术右移向下取整而量化公式里的右移语义是“对绝对值做除法再取整”。两者在负数上差一个最低位。用 Python 更隐蔽//对负数也是向下取整和 C 的行为“碰巧一致”于是本地测通了移植到目标平台换编译器就出问题。解决量化与反量化全程先取绝对值移位完再补符号中间不出现负数移位。这条规矩写进代码规范比事后调试省事。5.4 DC 路径顺序写反平坦区域出方块现象纹理复杂区域重建正常纯色墙面、天空、渐变背景出现明显的小方块方块尺寸正好是 4×4 或 16×16。原因解码端把 DC 块和 AC 块放进同一个循环先统一反量化再统一反变换。而 DC 路径的正确顺序是先做 Hadamard 反变换、再反量化然后 MUX 回各块左上角最后才走反 DCT。顺序一反DC 系数的缩放因子作用在了错误的域上误差在平坦区没有 AC 系数掩盖直接显形。解决把 DC 路径单独拆成一个函数输入是 DC 块和 QP输出是 16 个或 4 个还原后的 DC 值中间不要和 AC 处理逻辑混在一起。拆开之后这段代码只有二十来行比混在大循环里好审十倍。5.5 蝶形中间变量复用导致结果错位现象单元测试里单个一维变换对得上整块 4×4 变换却错而且错得有规律——某些位置翻了倍某些位置符号反了。原因蝶形算法为了省加法把 s0、s1、d0、d1 四个中间量复用给四个输出。如果实现时在算 y1 之前就把 d0 覆盖掉或者行列两趟共用同一组寄存器没做隔离第二次变换读到的是被改过的值。这类 bug 在黑盒测试里表现为“偶尔错”很难定位。解决一维变换函数保持纯函数语义输入数组不做原地修改中间量用局部变量。硬件实现里给行列两趟各配一组寄存器或者加一个明确的转置缓存边界。多花几个触发器换掉一整类难查的错。6. 验证与进阶三个能当场跑通的对齐实验写完变换和量化最怕的是“看着能跑其实偏了”。我的习惯是先用三个小实验把链路钉死再上真实码流。第一个实验测增益。输入一个只有 DC 的块也就是 16 个像素全是同一个值 v正变换后只有 W[0][0] 非零值为 16v。这个 16 是 Cf 第一行四个 1 与列方向四个 1 相乘的结果属于可手算的锚点。如果你的实现算出别的数说明变换核或者行列顺序有问题不用往下测了。第二个实验测对称性。输入一个随机块和它的取反块两次变换的输出应该逐元素互为相反数。整数变换全程线性、无舍入这个性质必须严格成立只要有一处不满足就说明中间某步发生了截断或者溢出。第三个实验测端到端。把一块残差走完“变换 → 量化 → 反量化 → 反变换”和输入逐像素比对误差应该落在量化步长决定的范围内且不随 QP 变化出现异常跳变。验证项输入期望输出常见偏差原因DC 增益全 v 的 4×4 块W[0][0] 16v行列顺序反了、Cf 抄错线性对称随机块 X 与 -X输出互为相反数中途截断、有符号移位端到端重建随机残差 QP20误差 ≤ Qstep 量级舍入方式、缩放因子未抵消平坦区全零 AC、恒定 DC重建无方块DC 路径顺序错再往上一层是性能。整数变换的瓶颈通常不在算术而在数据搬移转置缓存、DC 抽取、MUX 回填这三处的访存模式决定了实际吞吐。软件实现里把 4×4 块按行连续存放、转置用寄存器轮换而不是内存拷贝往往比优化加法次数更有效。硬件上则可以按宏块流水一维变换单元跑两遍中间用一块小缓存隔开行列两趟。最后一个技巧关于调试。这套链路里所有中间量都是整数意味着可以逐级 dump 出来和参考实现对拍不存在“浮点误差导致对不上”的模糊地带。我一般会在变换后、量化后、反量化后各埋一个断言只在调试构建里打开把首个不一致的位置和 QP、块坐标一起打出来。这套断言搭起来不到一小时省下的排错时间是以天计的。血泪经验是不要等到整帧编码跑通才去对拍系数。第一次跑通单块的 DC 增益之后就把断言插进去后面每改一处都重跑一遍比事后拿着码流分析工具一点点回溯划算太多。从那以后我每次动变换或量化相关的代码都强制走一遍这三个实验加断言不管改动多小。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?