资讯动态

H.266/VVC帧内编码优化:梯度预筛选与早终止实战

发布时间:2026/10/21 7:04:03 来源:尧图企业网站定制
H.266/VVC帧内编码算法优化这个话题我在做视频编码器优化项目时啃了大半年。H.266/VVC作为新一代视频编码标准相比上一代HEVC在压缩效率上提升明显代价就是编码复杂度直接翻了几倍尤其是帧内编码环节。帧内编码负责处理I帧和随机接入帧在All Intra测试配置下几乎完全决定编码器性能也是优化空间最大、见效最明显的一块。本文要讲的就是我在这个项目里做的具体优化在尽量不损失率失真性能的前提下把帧内编码时间压下来并且提供一套可以直接参考的代码实现。这篇文章适合正在做视频编码算法优化的人、研究帧内预测原理的学生以及想了解VVC编码器内部逻辑的工程师。优化点主要落在帧内模式决策环节核心思路是用纹理梯度快速收窄预测模式的搜索范围结合SATD粗筛和划分早终止把原来“穷举所有可能性”的模式搜索变成“有方向的精准搜索”。跟着这篇文章走一圈你能搞清楚帧内编码耗时的真正来源也能拿到一版能跑通、能对比、能继续迭代的优化代码。1. 项目背景与优化目标1.1 帧内编码为什么是性能瓶颈先弄清楚帧内编码在VVC里到底做了什么事。一个帧被切割成一个个编码块CU每个块要从左侧、上方、左上、右上这些“已经重建好的参考像素”里预测出当前块的内容预测得越准残差越小压缩率就越高。为了把残差压到极致VVC把帧内预测模式扩展到了67种1种Planar平面预测、1种DC直流预测、65种角度预测。预测完残差后还要做变换、量化、熵编码最后在解码端算一遍重建像素用来给后面的块当参考。听起来不复杂但编码器要做的事比这个多得多。每个CU在编码前要先决定怎么划分是用四叉树、二叉树还是三叉树划分深度到几层每种划分方式都要跑一遍帧内预测和率失真优化RDO而每个PU又要在67种模式里找出最合适的那个。这就形成了“划分 * 模式”两级组合爆炸。参考编码器在这个环节可以说是“暴力搜索”每一种可能都算率失真代价取代价最小的那条路径。这样搞出来的码流当然率失真最优但编码时间也是真的感人。在AI配置全帧内下帧内编码可以占到总编码时间的绝大部分而其中模式决策部分又是大头。更要命的是VVC还加了几个新工具MIP矩阵加权帧内预测、ISP帧内子分区、CCLM跨分量线性预测、MRL多参考行。每个工具都有自己的模式候选参与率失真计算的时候都是真金白银的时间开销。工具虽好但实际编码时它们的收益高度依赖内容尤其在纹理简单、方向性强的块上很多候选模式本质上就是陪跑。所以优化帧内编码的第一步是搞清楚哪些计算占了时间哪些又是可以跳过的。1.2 优化目标与路线取舍动手之前先把目标定清楚。无脑砍模式搜索时间而不顾率失真性能那是在做“假优化”做出来的编码器压缩率掉得没法看。反过来为了保压缩率完全不砍复杂度那也不用谈优化了。所以这个项目当时定了一条比较实用的标准线在All Intra配置下多个分辨率测试序列整体BD-rate损失控制在0.5%以内编码时间争取下降30%以上。这条线既能验证算法思路有效又不至于在标准对比时被质疑压缩率劣化太明显。围绕这个目标我选的优化路线集中在三个层面第一模式层面的预筛选。在跑完整RDO之前先用纹理梯度估计当前块的主方向把67种角度模式缩小到一个很小的候选子集。这个子集能覆盖大部分真实编码器会选中的模式。第二RMD粗筛环节的收窄。VVC参考编码器本身有粗略模式决策RMD会先用SATD从所有角度模式里筛出一批候选再送完整RDO。我在这层做文章结合梯度方向从源头减少进入SATD计算的模式数同时保证候选的质量。第三划分早终止。用空间相邻块已编码的深度信息加上当前块的纹理复杂度提前判断某些划分方式没有必要直接剪枝跳过。这三层优化相互独立可以单独开关也能叠加使用。项目代码里我保留了三个独立的开关就是为了方便对比每一层优化分别带来多少收益。这是做编码器优化很重要的习惯永远不要把一个“混合优化”当成一个整体去看你根本不知道是谁在起作用。2. 核心优化思路与原理分析2.1 用Sobel梯度快速定位预测主方向帧内角度预测的物理含义很直接当前块内容如果是顺着某个方向变化那么沿着那个方向把参考像素“延伸”过来残差就会很小。所以如果能提前知道块的主纹理方向就能避开大多数完全没关系的角度模式。梯度算子正好干这个事。Sobel算子用两个卷积核分别计算水平方向和垂直方向的灰度变化合成后得到梯度的幅值和方向。算出来的梯度方向指向灰度变化最剧烈的方向而帧内预测要的是“变化最平缓的方向”两者正好互相垂直。所以拿到梯度方向后要转90度才是理论上最贴合的角度预测方向。这个思路不是新东西HEVC时代就有人这么干过但在VVC上直接套用会踩坑。原因很简单VVC的角度模式从HEVC的33种变成了65种角度分辨率翻了一倍。梯度方向估计本身有误差如果直接把估计方向映射到最接近的模式经常会出现梯度算出来45度实际最佳模式却在接近垂直的位置。所以工程实现上不能只取一个模式而是取“梯度方向为中心的一条邻域”把这个邻域内的模式都纳入候选。这个邻域宽度就成了核心参数后面讲代码实现时会详细说。2.2 RMD粗筛与完全RDO的分工逻辑理解RMD之前先理解两个成本函数的区别。SATD是经过哈达玛变换后的绝对误差和计算量小而且因为做了变换域操作某种程度上比SAD更接近人对失真的感知。SSE是原始残差的平方和如果再做一次率失真计算还叠加码率估计那就是完整RDO的全部代价了。完整RDO结果准确但成本高到不能对每种模式都来一遍。参考编码器的思路是在RMD阶段用SATD把67种模式粗筛成很少的几个候选然后只对这几个候选做完整RDO。这个策略本身没有问题问题是67种模式全算一遍SATD也不便宜。我们优化的思路是拿梯度方向大幅缩小SATD阶段需要评估的模式数量从67个降到十几个然后再在这些模式里做RMD粗筛。这里有个容易被忽略的细节Planar和DC模式在平滑区域经常直接胜出而且它们成本极低不用做角度搜索。正确做法是让Planar和DC始终作为必选候选它们不需要梯度筛选也不会增加明显开销。真正用梯度收窄的是66种角度模式里的绝大部分“冗余方向”。2.3 划分早终止的决策依据VVC的编码单元划分结构叫QTMT四叉树加多类型树。一个块可以继续四等分也可以做水平/垂直二分、三分。每一层划分候选都嵌套着帧内模式搜索所以划分深度每加深一层耗时近似指数增长。但文本上很多块其实是大块更合适天空、墙面、纯色背景纹理平坦根本没有必要往下划分。早终止的决策依据是两个信号的组合。一是空间相关性左侧和上方已编码CU的划分深度能暗示当前CU的合理深度。邻居都已经在浅层终止划分了当前块大概率也不需要深划分。二是当前块的纹理复杂度和代价信号如果SATD已经很低说明大块预测已经足够准确继续划分很难获得明显收益那么这个划分分支就可以剪掉。两者配合使用比单独用任何一个都稳。只用邻居深度遇到图像内容突变时会误判只用代价阈值又要费大量时间先算出完整代价才能判断。先说邻居深度如果它预测的“浅层”信号很强就直接跳过深划分候选这样连代价都不需要算完。3. 核心代码实现与细节解析3.1 梯度计算和角度到模式映射的实现先看最底层的梯度计算。我用的代码精简成了C风格函数便于嵌入任何C/C编码器框架。实际工程中Pixel类型通常是unsigned char需要考虑整数溢出这里做了饱和处理。typedef struct GradInfo { int dx; // 水平梯度 int dy; // 垂直梯度 int mag; // 梯度幅值近似值 int angleMode; // 映射后的VVC帧内角度模式 } GradInfo; static GradInfo calcSobel(const Pixel *src, int stride, int blkX, int blkY) { int dx 0; int dy 0; int sum 0; const int shift 3; // 遍历当前块内部的所有像素累加Sobel梯度响应 for (int y 1; y blkY - 1; y) { for (int x 1; x blkX - 1; x) { int p00 src[(y - 1) * stride x - 1]; int p01 src[(y - 1) * stride x]; int p02 src[(y - 1) * stride x 1]; int p10 src[y * stride x - 1]; int p12 src[y * stride x 1]; int p20 src[(y 1) * stride x - 1]; int p21 src[(y 1) * stride x]; int p22 src[(y 1) * stride x 1]; // Sobel卷积核 dx (p02 2 * p12 p22) - (p00 2 * p10 p20); dy (p20 2 * p21 p22) - (p00 2 * p01 p02); } } dx shift; dy shift; GradInfo g; g.dx dx; g.dy dy; g.mag abs(dx) abs(dy); // 用绝对值之和近似幅值避免开方 // 由梯度方向映射到帧内预测角度模式 g.angleMode gradientToMode(dx, dy); return g; }关键点在gradientToMode这个映射函数。VVC角度模式索引和实际方向的夹角不是线性对应关系VTM参考源码里有一张intraPredAngle查表记录了每个模式对应的位移数值。在你自己的实现里最好不要现场用atan2函数去算浮点角度再逆映射开销高且容易出现边界误差。正确做法是预先把每个角度模式对应的方向向量离线算好做成一张小表运行时按照dx/dy的比值查表逼近。我用的是简化映射思路先判断梯度方向落在哪个象限再根据dx和dy的比例关系映射到基础模式然后加上邻域偏移。这样做损失一定精度但后面候选搜索本来就会覆盖邻域所以映射误差可以被吸收。static int gradientToMode(int dx, int dy) { if (abs(dx) TH_EPS abs(dy) TH_EPS) { return 1; // DC模式平坦区域 } // 计算角度注意dx、dy与VVC角度坐标系的关系 double angle atan2((double)dy, (double)dx) * 180.0 / 3.14159265358979; while (angle 0) angle 180.0; while (angle 180.0) angle - 180.0; // VVC中模式18对应水平模式50对应垂直模式34对应正对角线 // 这里仅做粗糙映射实际工程建议用离线的精确映射表 int mode (int)floor(angle * 64.0 / 180.0 0.5) 2; if (mode 2) mode 2; if (mode 66) mode 66; return mode; }注意上述映射是简化版本主要用来说明算法流程。商用实现里必须结合VVC标准中的角度表做精确定位否则边界区域的花屏问题会缠绕你很久。梯度计算还有个优化点不用对块内每个像素都做完整卷积。常见块尺寸是32x32或64x64全尺寸遍历算一遍Sobel也不便宜。实际方案是在块内做均匀采样比如每4个像素取一个参与计算梯度方向仍然能估准计算量直接降一个数量级。方向性内容本身在空间上连续梯度不会因为抽样而突变。3.2 SATD粗筛与候选模式搜索第二步是RMD阶段。参考编码器在RMD阶段对所有角度模式计算SATD然后保留若干个低代价候选。我们的优化版本不再遍历全部模式而是围绕梯度方向模式生成候选区间然后只在这个区间内算SATD。SATD本身需要一个快速Hadamard变换实现下面是一段4x4的Hadamard变换代码用于把残差块变换到Hadamard域。大块可以分块做多次最后把绝对值累加。static int hadamard4x4(int16_t *diff, int stride) { int satd 0; int16_t tmp[16]; int16_t m[16]; // 输入残差按4x4块读取 for (int i 0; i 4; i) { for (int j 0; j 4; j) { m[i * 4 j] diff[i * stride j]; } } // 行变换 for (int i 0; i 4; i) { int a m[i * 4 0] m[i * 4 3]; int b m[i * 4 1] m[i * 4 2]; int c m[i * 4 0] - m[i * 4 3]; int d m[i * 4 1] - m[i * 4 2]; tmp[i * 4 0] a b; tmp[i * 4 1] c d; tmp[i * 4 2] a - b; tmp[i * 4 3] c - d; } // 列变换 for (int j 0; j 4; j) { int a tmp[0 j] tmp[12 j]; int b tmp[4 j] tmp[8 j]; int c tmp[0 j] - tmp[12 j]; int d tmp[4 j] - tmp[8 j]; m[0 j] a b; m[4 j] c d; m[8 j] a - b; m[12 j] c - d; } for (int i 0; i 16; i) { satd abs(m[i]); } return satd 1; }有了SATD基础函数候选模式搜索主流程就很清晰了。先算梯度得到基础模式然后按幅度大小决定展开邻域半径最后标定所有候选模式。代码框架如下#define PLANAR_IDX 0 #define DC_IDX 1 typedef struct ModeCand { int mode; int cost; } ModeCand; static void searchCandidates(int16_t *diff, const GradInfo *grad, int blockSize, ModeCand *candList, int *candNum) { int list[69]; int num 0; // Planar和DC始终参与后续RDO list[num] PLANAR_IDX; list[num] DC_IDX; // 根据梯度幅值决定角度模式邻域半径 int range; if (grad-mag 64) { range 10; // 弱纹理区域方向性不明确多保留一些模式 } else if (grad-mag 512) { range 6; // 中等方向性 } else { range 4; // 方向性很强可以大胆收窄 } for (int m grad-angleMode - range; m grad-angleMode range; m) { int mode m; if (mode 2) mode 64; if (mode 66) mode - 64; bool dup false; for (int i 0; i num; i) { if (list[i] mode) { dup true; break; } } if (!dup) list[num] mode; } // 为了方便后续处理也可以把MIP候选和ISP相关模式补充进来 // 我习惯在Stage开关中单独控制避免过度耦合。 ModeCand tmp[69]; int tmpNum 0; for (int i 0; i num; i) { int satd calcPredAndSATD(diff, blockSize, list[i]); tmp[tmpNum].mode list[i]; tmp[tmpNum].cost satd; tmpNum; } // 按代价排序保留前N个 sortByCost(tmp, tmpNum); int keep (grad-mag 64) ? 8 : 5; *candNum (keep tmpNum) ? keep : tmpNum; for (int i 0; i *candNum; i) { candList[i] tmp[i]; } }两句关键心得第一保留Planar和DC这两个廉价模式非常重要它们偶尔会在纹理平坦或方向杂乱的块里凭借极低码率胜出漏掉它们会让压缩率明显变差。第二边界绕回处理别写错。VVC角度模式2和66在方向上是相邻的边界模式下搜索邻域时要绕回否则在接近水平或接近垂直方向上会漏掉真正好的候选。calcPredAndSATD函数要做的事是执行一次角度预测算残差再做Hadamard变换得到SATD。真实的VVC编码器里参考像素要经过滤波还需要考虑MRL多参考行。优化版本先不引入那些高级工具只做最基本的角度预测够用来粗排名。3.3 划分早终止的控制逻辑划分早终止在编码器的主递归函数里实现。主流程是给定一个CU尝试不划分、四叉划分、二叉树划分、三叉划分每一种划分递归处理子块最后取代价最小的方案。早终止要拦截的是“深划分”的递归调用。代码如下typedef struct NeighborDepth { int leftDepth; int aboveDepth; int valid; } NeighborDepth; static bool earlyTerminateSplit(const CU *curCU, const NeighborDepth *nb) { if (!nb-valid) return false; int maxNb (nb-leftDepth nb-aboveDepth) ? nb-leftDepth : nb-aboveDepth; int curDepth curCU-depth; // 邻居都停在浅层当前块也倾向于浅层 if (maxNb curDepth) { return true; } // 第二道信号平坦区域尝试用大块预测这也暗示深划分收益有限 int sad curCU-bestSad; int blockArea curCU-width * curCU-height; int threshold curCU-lambda * (blockArea 2); if (sad threshold maxNb curDepth) { return true; } return false; }这个函数调用要放在递归函数进入更深的划分尝试之前。真正常踩的坑是“代价尺度不统一”早终止函数里比较的sad可能是SATD或者某种简单的SAD而curCU-lambda是基于码率代价的两者尺度不一样直接比较很容易误剪枝。解决办法是在使用前先确认代价来源或者干脆只用“邻居深度”这一个信号做硬剪枝把代价阈值作为可选项而且阈值要随着QP做单调调整。深划分剪枝对大块收益最明显。64x64的CU如果停在32x32那么后面三种块尺寸的搜索直接不做了理论上省掉的时间非常可观。但当块尺寸到了16x16以下误剪的代价变高因为小块预测不准更容易漏模式。所以我在代码里把早终止开关按深度分级深度大于2对应小尺寸CU时不启用早终止。这个小细节在实测中非常关键代价几乎没有增加却避免了在细小纹理区域出现明显画质劣化。4. 实验配置与结果分析4.1 测试环境与对照方案实验全部在All Intra配置下进行量化参数取22、27、32、37四档。测试序列按分辨率和内容特性分了几个类别覆盖8K、4K、1080p、720p这几种常见分辨率序列内容尽量混合了自然画面、屏幕内容、运动缓和的场景。优化版本和参考编码器做同一组编码记录码率、峰值信噪比和编码时间。对比维度有两个核心指标BD-rate衡量同等画质下码率的差异正值表示优化后码率上升压缩率下降编码时间节省率表示优化版本相对参考编码器少花的时间百分比。4.2 整体性能结果与解读完整数据很长这里给出整体趋势的典型结果序列类别分辨率BD-rate变化编码时间节省8K类内容7680x43200.31%36.2%4K类内容3840x21600.23%33.5%1080p内容1920x10800.18%31.8%720p内容1280x7200.21%29.7%这个结果整体符合预期。高分辨率内容里大块占多数划分早终止的剪枝比率更高所以时间节省更明显。BD-rate平均在0.2%到0.3%附近波动没有突破0.5%的底线。单独看某一层优化梯度预筛选带来的时间节省最明显但BD-rate损失也最大划分早终止贡献的时间节省次之画质损失却很小SATD候选数量收窄在两者之间。横向理解这几个数字帧内模式决策的总时间构成里角度模式完整RDO占比很高。我的优化把送入RDO的候选从默认的十几个减少到五到八个时间自然大幅下降。损失主要来自有时候真正的最优模式不在候选集合里尤其在纹理方向复杂混合的块上。4.3 参数调节的经验分享这套算法一共三个核心参数梯度邻域半径range、SATD候选保留个数keep、早终止阈值threshold。它们之间是联动关系不能单独调。参数调优的实用经验是梯度幅值和range建立分段映射不要用单一固定值。弱纹理块方向性弱候选可以给宽一点强纹理方向明确候选可以大胆收窄。实测用三档分段比单一线性调整效果好很多。keep的取值和range有关range已经收窄到10个候选以内时keep取5和keep取8的BD-rate差异很小但时间差异明显。早终止阈值是最敏感的。阈值调得激进编码时间可能节省到50%以上但BD-rate会飙升到接近1%。我的建议是从保守值起步每次以2倍步长加大阈值每调整一次跑一两个低分辨率序列做回归对比找到那个“时间还在降、但BD-rate开始陡增”的拐点然后回退一档。编码器优化的参数永远不要在理论值上停留多跑几组数据比什么都实在。重要对比实验时优化版本和参考编码器必须使用完全相同的QP和展开深度设置否则出来的时间差异和码率差异没有可比性。我最初犯过这个错误优化版本默认关掉了某个工具导致BD-rate异常偏大排查了很久才发现是配置差异。5. 常见问题与调试经验5.1 模式映射偏移导致的花屏问题第一次把梯度映射代码接进编码器时最典型的问题就是某些序列画质突然崩坏或者BD-rate暴增几个百分点但时间确实大幅下降了。原因通常在角度到模式映射这一步。排查思路很简单先关闭所有优化确认参考编码器输出正常。然后只打开梯度预筛选把range参数调得极大相当于没有收窄再逐渐缩小观察哪个range下先出现异常。如果模式映射坐标系本身有方向性错误会导致不管range多大都存在系统性偏移。我遇到的典型情况是dx、dy的符号取反导致水平和垂直方向互换。解决方案是写一个“模式扫描探针”对已知方向纹理的测试图跑一遍输出每个块的梯度方向和最终选中的最优模式人工对照是否符合预期。5.2 早终止引起的时间波动和码率异常早终止误剪枝的直接表现不是花屏而是BD-rate离散跳变某些序列、某些QP档位下BD-rate突然变得很难看。这类问题最隐蔽因为不是每个序列都会触发。根本原因通常是两个一是阈值与QP尺度不匹配二是邻居深度信息在当前内容区域失效。解决办法是把早终止拆得更细比如只对运动平缓内容生效或者引入帧级QP补偿。我在代码里加了个“安全阀”只要当前块的残差能量超过某个上限一律禁止剪枝把误判的影响限制在局部。这个安全阀牺牲一点时间节省换来的稳定性值得。5.3 调试编码器方案的高效套路贴身调试编码器优化我总结了一套相对顺手的流程。第一善用计数器。在剪枝分支、候选搜索入口、RDO入口插入递增计数器编码结束后打印。这样你能直接看出优化的命中率梯度预筛选到底把多少块导入了窄搜索早终止函数到底拦截了多少次划分尝试哪里没生效一目了然。第二固定输入做回归。不要把大段视频直接拿来测优化迭代时会耗费大量时间。用几百帧的短序列、固定QP、单分辨率把完整编码结果跑出来对比。有了能快速跑通的基准每次改代码都能在几分钟内拿到结果。第三二分排查优化叠加问题。三层优化同时开可能互相干扰。排查某个异常时先关闭全部优化然后逐层开启每开一层就对比一次BD-rate和时间。这能精确锁定是哪一层引入的问题。调试过程中最深的体会是编码器是一个处处耦合的系统一个“聪明”的优化动作经常在另一个环节引出新的问题。改任何参数前先想清楚它会影响到哪条路径再动手远比跑完一遍看结果然后瞎猜高效。写在最后的一点实际心得项目收尾时回看整个优化过程最大的体会是帧内编码算法优化的核心不在于花哨的策略而在于对搜索空间“无谓消耗”的精确识别。梯度预筛选、SATD粗筛、划分早终止这三板斧没有用到任何高深工具每一步都是在问同一个问题——这个模式到底值不值得花时间算最后分享一个小技巧在做任何优化大改动之前先在参考编码器上跑一遍统计脚本统计不同序列在每种块尺寸下、每种帧内模式最终被选中的概率。这些数据会直观地告诉你哪些模式在哪些场景下几乎永远被跳过哪些划分深度在哪些纹理结构下几乎从不出现。有了这类统计做依据再去设计预筛选和剪枝策略你调的就不是“感觉”而是“数据”。这个习惯在后续更大规模的编码器优化里帮我避开了大量无效投入。如果你也在做类似方向不妨先从这份统计开始。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑