
每次聊到隐写大家第一反应多半是图片里的LSB把一句话的最低有效位替换掉人眼看不出差异工具一跑就能还原。但当载体从PNG变成H.265码流的时候事情完全变了视频编码经过了变换、量化、熵编码、帧间预测一大堆环节想藏点东西进去同时保证压缩效率不崩、视觉质量不降、解码端还能把信息取出来这中间的门道比图片隐写深得多。这篇我就用HM——HEVC也就是H.265的官方参考软件——亲手改了一套最简单的视频隐写demo把一串文本藏进了一段H.265码流里再用解码器原封不动地提取出来。整个过程不涉及复杂的外挂工具全部靠改动HM编码器和解码器源码完成。这个demo适合两类人一是刚接触HEVC标准、想通过读HM源码理解编码器内部工作流的读者HM代码量大、结构复杂硬啃效率太低跟着我改造一个功能点反而是最快上手的路径二是对信息隐藏、数字水印、隐写分析方向感兴趣的研究者本文能帮你建立一个从编码器内部嵌入信息的最小可运行参照系后续想扩展到预测模式嵌入、运动矢量嵌入也都有的放矢。1. 为什么偏偏用HM来折腾视频隐写1.1 从图片LSB到视频码流隐写的距离先看图片隐写为什么简单。一张BMP或PNG图片每个像素的RGB最低位被替换后人眼几乎感知不到因为1bit的亮度变化在人眼阈值之下。提取端只需要按同样的像素坐标读取最低位就能还原信息。整个过程其实就是在数组上做读写。到了视频这里情况完全不对。首先视频编码是有损压缩直接改像素值到YUV文件里一旦经过H.265编码那些最低位的修改很可能被量化和环内滤波直接抹掉等于白藏。其次H.265的码流解析高度结构化SPS、PPS、Slice Header、CTU、CU、TU层层嵌套任何一层数据被改坏轻则花屏重则解码器直接崩掉。最后视频帧之间存在帧间预测当前帧的残差和参考帧的像素紧密相关你篡改的东西如果破坏了重建环路的一致性误差会逐帧累积放大几帧之后画面就彻底花了。所以做视频隐写本质上不是在改视频文件而是在改视频编码器。你需要找到一个编码过程中的中间产物它满足三个条件一是人眼不敏感改它不会造成视觉劣化二是它会被写进码流解码端读码流时能拿到三是修改后不影响编码器内部的预测环路否则重建画面会漂移。这就是为什么不能拿现成的视频编辑软件来做必须从编码器源码层面动手。1.2 HM作为实验台的优势与代价HM全称HEVC Test Model是JCT-VC发布的HEVC官方参考软件编码器解码器一应俱全纯C实现。学术界讨论HEVC相关的算法改进、快速算法、质量优化几乎都以HM为基线平台。对隐写实验来说HM最大的价值在于整个编码链路在源码里完全透明从读入YUV、CTU划分、帧内预测、变换量化、熵编码到码流输出每一步都能看到中间数据也就能在任何一步插入自己的逻辑。相比之下x265虽然是工业级编码器性能好、速度快但代码经过大量工程优化函数调用关系复杂而且它主要是编码器没有配套的解码器源码用于提取验证你得另找工具解析码流远不如HM编码解码两边成对修改来得方便。HM的代价也很明显代码量巨大编译慢编码速度奇慢无比。QP设为32编码一个QCIF格式的10帧小视频都可能要好几秒钟这在工业上是没法用的。但做学习实验和算法验证这恰恰是优点结构清晰便于追踪数据流。我的经验是第一次接触HM不要试图全览所有代码就盯着一条链路走通输入YUV到输出码流再到解码器把码流还原成YUV这就够了。2. 嵌入点选型从量化系数下手是性价比最高的方案2.1 五个可嵌入位置的实际对比HM编码器的处理流程大致是读入一帧YUV按CTU编码树单元划分递归拆成CU每个CU做帧内或帧间预测得到预测像素原始像素减去预测像素得到残差残差做DCT变换得到变换系数变换系数做量化得到量化系数量化系数一路进熵编码器写码流另一路走反量化反变换叠加预测值得到重建像素用于后续帧的参考。理论上这条链路上每一环节都能嵌入信息但实用性和隐蔽性差异很大。我整理了一张对比表嵌入位置嵌入原理隐蔽性实现难度容量风险YUV像素域直接修改原始像素最低位弱编码后极易丢失最低高量化会抹掉修改变换系数修改DCT系数较好中中需要处理RDO影响量化系数修改量化后level好中中需要保证环路一致帧内预测模式用模式编号映射比特好中低模式数有限运动矢量修改MV分量奇偶较好高低影响帧间预测质量SEI消息在码流中插辅助信息极差明文可见最低高不算真正隐写我个人不推荐从像素域入手很多新手会栽在这上面费劲改了像素一编码全没了还以为自己代码写错了。也不推荐SEI方案虽然它在码流里加几个NAL单元很容易解码端解析SEI也很方便但SEI本来就是明文辅助信息任何工具都能直接读取隐蔽性约等于零只能算码流附加数据撑不起隐写两个字。2.2 奇偶校验嵌入的原理量化系数嵌入是一条经典路线。量化后的系数就是码流中实际传输的level值编码端改它改完后的值会原样进熵编码器并写入码流解码端从码流解析出来的也是改完后的值。所以编码端的工作就是找到某个系数把它的奇偶性调成和待嵌入比特一致——嵌入1就把系数调成奇数嵌入0就把系数调成偶数。提取端只需要看这个系数的奇偶性就能还原比特。这种嵌入方式在隐写里叫奇偶校验嵌入也叫LSB匹配比起直接替换最低位它更灵活不需要知道原始值只要保证奇偶性符合要求即可。举个具体例子假设某个量化系数的值是4待嵌入比特是1。4是偶数不符合我们需要把它变成奇数。下一个偏向是改成5或3这两个都能让奇偶性反转。在实际实现中我倾向于选择绝对值加1即从4改成5原因后面会专门讲。2.3 为什么选最后一个非零系数作为嵌入载体量化系数在一个TU变换单元里是一个一维数组长度等于TU的像素数比如8x8的TU对应64个系数。但并不是所有系数都非零量化后大量高频系数被截断成0。真正被写入码流的只有从DC系数到最后一个非零系数之间的部分这个最后一个非零系数在扫描顺序中的位置直接决定了码流里需要编码的系数个数。选择最后一个非零系数作为嵌入载体有几个实际好处一视觉影响最小。扫描顺序靠后的系数通常对应频率较高的分量人眼对高频细节的敏感度远低于低频改它的幅值带来的感知差异微乎其微。二位置稳定。最后一个非零系数在解码端能够被精确复现因为解码器做反量化前拿到的量化系数数组和编码端完全一致。只要嵌入时保证不把它改成0这个位置就不会漂移提取逻辑就和嵌入逻辑严格对称。三对码率影响可控。只改一个系数的绝对值增加的码流开销几乎可以忽略。相比修改DC系数或者大能量系数最后这个系数通常幅值较小改动带来的熵编码比特数变化也很小。四不需要额外同步信息。编码端和解码端都执行找到最后一个非零系数这同一套逻辑天然对齐无需在码流里额外存一个位置索引。3. 改造HM编码器在变换量化链路里塞进嵌入逻辑3.1 先跑通原始编码与解码链路动代码之前我强烈建议先把HM编译出来把原始编码解码流程跑通。这一步能省下后面大量排查时间。HM的源码可以从官方仓库直接拉选择HM-16.20版本比较稳定后续版本函数结构变化不大但命名可能略有差异。Linux环境下编译很简单cd build make -j4编译产物在bin目录下编码器叫TAppEncoderStatic解码器叫TAppDecoderStatic。Windows下则是用Visual Studio打开build目录下的HM.sln选Release x64配置编译。测试序列我用的是QCIF格式的akiyo_yuv176x144分辨率从公开测试序列网站下载。也可以自己用ffmpeg从任意视频截一段ffmpeg -i input.mp4 -s 176x144 -pix_fmt yuv420p akiyo_qcif.yuv编码前先配置encoder_intra_main.cfg注意这几个关键项InputFile : akiyo_qcif.yuv SourceWidth : 176 SourceHeight : 144 FrameRate : 30 FramesToBeEncoded : 10 IntraPeriod : 1 QP : 32把IntraPeriod设为1意味着所有帧都是帧内编码不启用帧间预测。对于隐写demo来说这是最稳妥的启动配置帧内编码的每个TU独立嵌入和提取位置一一对应不容易出现参考帧传播导致的意外问题。然后执行./bin/TAppEncoderStatic -c cfg/encoder_intra_main.cfg -o clean.bin ./bin/TAppDecoderStatic -b clean.bin -o clean_recon.yuv这一步跑通确保编码器能正常输出码流、解码器能正常还原YUV再开始改动代码。3.2 定位HM中的变换量化节点HM的代码量巨大不能瞎翻。这里我直接告诉你一条经过验证的定位路径。先把目光放到TEncSearch.cpp这个文件。帧内CU的预测和重建逻辑都在这里核心函数是xIntraRecQT。这个函数负责对一个CU内的QT划分做递归重建里面会调用m_pcTrQuant-transformNxN完成残差的变换和量化。再进到TEncTransform.cpp找到transformNxN函数。这个函数内部做了三件事DCT变换、量化、反量化。注意顺序量化之后紧跟着就是反量化反量化的结果用于重建环路。HM源码里大致长这样// DCT变换 ... // 量化 xQuant(piSrc, piCoef, ...); // 反量化 xDeQuant(piCoef, piResi, ...);我们的嵌入点就放在xQuant之后、xDeQuant之前。放这里有两个关键理由一是piCoef此刻已经是量化后的最终系数也就是即将写入码流的level值改它一定能影响码流。二是紧接着的xDeQuant会把修改后的系数用于生成重建像素这样编码端重建环路使用的是嵌入后的系数解码端重建时用的也是同一份系数两个环路由始至终保持一致不会产生漂移。如果贪方便把嵌入点放在xQuant内部那会碰到RDOQ率失真优化量化的问题。RDOQ会继续调整量化结果你的修改可能被RDOQ环节覆盖掉导致嵌入内容失效。3.3 嵌入代码实现与防错位设计现在具体写嵌入逻辑。我在TEncSearch.cpp顶部加了一段全局变量和辅助函数保持代码侵入最小化。完整逻辑如下// 全局变量 static bool g_bStegoEnable false; // 是否开启隐写 static bool g_bFinalPass false; // 是否是最终编码阶段 static int g_iPayloadBitIdx 0; // 当前读取到payload第几个bit static int g_iEmbedCount 0; // 已经成功嵌入几个bit static int g_iEmbedTotal 128; // 总共要嵌入128个bit即16字节 // 待嵌入的16字节payload static unsigned char g_ucPayload[16] { H, e, l, l, o, , H, M, , S, t, e, g, o, !, ! }; static int getCurrentBit() { if (g_iEmbedCount g_iEmbedTotal) { return -1; // 所有比特嵌入完毕 } int bit (g_ucPayload[g_iPayloadBitIdx 3] (g_iPayloadBitIdx 7)) 1; g_iPayloadBitIdx; return bit; } static void embedOneBit(TCoeff* piCoef, int uiSize, int bit) { if (bit 0) { return; } // 找到最后一个非零系数 int lastPos -1; for (int i uiSize * uiSize - 1; i 0; i--) { if (piCoef[i] ! 0) { lastPos i; break; } } // 全零TU不参与嵌入避免同步错位 if (lastPos 0) { return; } int coeff piCoef[lastPos]; // 奇偶校验嵌入最低位与目标bit不同时绝对值加1 if ((coeff 1) ! bit) { if (coeff 0) { piCoef[lastPos] coeff 1; } else { piCoef[lastPos] coeff - 1; } } // 成功嵌入一个bit g_iEmbedCount; }这个实现里最值得解释的是绝对值加1这个细节。为什么不是随机加1或减1关键在于绝对不能让嵌入后的系数变成0。假设系数原本是1你要把它从奇数改成偶数如果做减1就变成了0。系数为0意味着它不再是最后一个非零系数解码端提取时会认为这个TU没有可嵌入的载体直接跳过但编码端已经消耗了一个比特两边节奏立刻错位后面所有比特全部无法对齐。幅度加1还有一个好处保持系数的符号不变这对于后续的反量化、反变换来说重建像素的扰动方向是一致的不会出现正负翻转带来的大范围重建误差。embedOneBit函数接收的uiSize是TU的边长整个系数数组的长度是uiSize * uiSize。在实际集成时你需要根据HM版本里transformNxN的入参情况调整变量名但查找最后一个非零系数的思路不变。3.4 处理RDO重复调用问题这里有个暗坑必须说清楚。transformNxN在编码过程中不只被调用一次它会在率失真优化阶段被反复调用。编码器尝试不同预测模式、不同TU划分时都会走一遍变换量化用来计算RD cost。如果在transformNxN里无脑嵌入同一个TTU可能在RDO搜索阶段被嵌一次最终重建阶段又被嵌一次两次嵌入的比特不同最终解码端只能拿到最后一次嵌入的结果RDO搜索阶段的嵌入纯属浪费还会污染模式决策的重建质量。解决办法是引入一个最终阶段标志。在HM的TEncCu::xCompressCU函数里当所有模式决策完成、进入最终重建时会有一次对xIntraRecQT的调用。我在这次调用前后设置标志// TEncCu::xCompressCU 中模式决策完成后的最终重建位置 g_bFinalPass true; m_pcSearch-xIntraRecQT(...); g_bFinalPass false;然后在transformNxN的嵌入点加上判断if (g_bStegoEnable g_bFinalPass) { int bit getCurrentBit(); embedOneBit(piCoef, uiSize, bit); }这样RDO搜索阶段不会嵌入任何比特只有最终确定的TU才会被嵌入。需要注意的是g_bFinalPass是一个进程级全局变量如果你的HM里存在多线程编码默认是单线程不用担心就需要考虑线程安全。我们这个demo完全够用。另外getCurrentBit的调用顺序我特意设计成先取比特再尝试嵌入。配合embedOneBit内部的全零TU判断可以实现全零TU不消耗比特。因为如果先消耗比特再发现TU全零就会造成错位现在这个顺序等价于取到比特但没嵌入时不计数也就是g_iEmbedCount只在真正嵌入成功时递增有效避免了同步问题。4. 解码端提取与实测数据能藏进去也得能取出来4.1 解码端读取量化系数的位置解码端的工作本质上和编码端对称。H.265解码器拿到码流后先做熵解码解析出每个TU的量化系数然后做反量化、反变换、与预测值叠加得到重建像素。由于熵解码还原出的量化系数和编码端写入码流的完全一致所以解码端反量化之前拿到的piCoef正是编码端嵌入时修改过的同一份数据。对应到代码里解码端的反变换反量化逻辑在TDecTransform.cpp中函数名是invTransformNxN。反量化由xDeQuant完成我们就在xDeQuant之前插入提取逻辑。HM解码端重建帧内CU的核心函数在TDecSearch.cpp的xIntraRecQT里它会调用invTransformNxN所以两个位置任选其一都能拿到系数——我建议在invTransformNxN里操作因为解码端不像编码端有RDO问题这个函数在解码过程中只会调用一次。4.2 提取代码与同步约定提取代码和嵌入代码保持完全相同的找最后一个非零系数规则static int g_iExtractBitCount 0; static unsigned char g_ucExtracted[16] { 0 }; static void extractOneBit(const TCoeff* piCoef, int uiSize) { if (g_iExtractBitCount 128) { return; } int lastPos -1; for (int i uiSize * uiSize - 1; i 0; i--) { if (piCoef[i] ! 0) { lastPos i; break; } } if (lastPos 0) { return; // 全零TU跳过和编码端同步 } int bit piCoef[lastPos] 1; g_ucExtracted[g_iExtractBitCount 3] | (bit (g_iExtractBitCount 7)); g_iExtractBitCount; }解码完成后把g_ucExtracted强制转成char数组打印出来如果是Hello HM Stego!!说明整个链路是通的。同步约定要注意两点第一嵌入端和解码端必须限定同一个通道我建议只对亮度分量CHANNEL_TYPE_LUMA操作避免色度分量参与带来的复杂度。第二payload长度固定为128比特提取满128比特后自动停止不需要额外的结束标志。如果将来嵌入内容长度不固定可以在payload开头加一个长度头但demo阶段固定长度最省心。4.3 实验结果PSNR、码率与误码率我用akiyo_qcif序列10帧全I帧QP32嵌入16字节payload跑了一组数据。未嵌入的原始编码作为对照组嵌入后的编码作为实验组结果如下指标未嵌入码流嵌入后码流变化码流大小约42.6 KB约42.8 KB增加约0.5%Y-PSNR38.12 dB38.09 dB下降0.03 dB提取误码率-0%完全还原不同测试序列的数值会有差异但整体趋势一致码流增量在1%以内PSNR下降在0.05dB以内人眼完全看不出差异。这个结果符合预期因为我们每次只改一个系数且只做幅值1对码流和画面质量的影响都极其有限。我也试过更高的QP值比如QP40此时量化更狠非零系数数量减少能嵌入的TU也变少但16字节payload仍然能放下。如果QP太高某些帧的非零TU数量不足128个就会导致payload无法完整嵌入。解决方法是增加帧数或者改用更大的分辨率。这个容量限制是入门demo的正常水平真要追求容量可以往多比特嵌入、多系数嵌入方向做优化。5. 调试中踩过的三个坑全零TU、RDO重入和系数清零5.1 全零TU导致比特流错位第一次跑通提取链路时我提取出来的比特前几位是对的后面就乱成一团。排查后发现问题出在全零TU上。当时我的代码结构是先取比特再找最后一个非零系数结果遇到全零TU时比特已经消耗了但提取端完全不知道这事。等到下一个非全零TU出现时编码端消耗的是第N个比特提取端却以为这是第N个比特实际上漏掉了前面那个于是从那一刻起全部错位。正确的处理方式就是我上面代码里展示的不要急着消耗比特先在当前TU里找最后一个非零系数找到了再取比特找不到就跳过同时不递增计数。这样编码端和解码端都以是否存在非零系数作为同步条件天然对齐。5.2 RDO反复嵌入让内容漂移另一个让我头疼的问题是嵌入后的视频在解码端出现了局部像素块不一致。排查时发现RDO搜索阶段调用了transformNxN把一些比特嵌进了那些最终没有被选中的TU里。这些TU虽然不会出现在最终码流里但它们在RDO阶段影响了RD cost的计算导致编码器可能选择了一个并非最优的模式组合间接造成画面质量损失。这个问题的根源不是嵌入逻辑本身而是嵌入的时机。最终版我用g_bFinalPass标志把嵌入严格限制在最终重建阶段之后重新编码画面质量恢复正常。如果你在集成时发现嵌入后PSNR异常下降优先怀疑是不是RDO阶段也触发了嵌入逻辑。5.3 修改系数时清零引发的连锁反应最后一个坑是系数修改时不小心把最后一个非零系数改成了0。当时我的第一版代码用的是随机加减1的策略某次实验里一个系数从1减到了0。这个TU在嵌入端被判定为成功嵌入一个比特但在提取端这个TU因为找不到最后一个非零系数而被跳过两边又错位了。解决方案就是本文采用的绝对值1策略无论系数原本是正还是负需要翻转奇偶性时就朝远离0的方向加1。这个策略从数学上杜绝了系数变为0的可能同时保持了系数的符号方向对视频质量的影响也更小。这里我花了一晚上才想透写出来给大家避坑。写在最后的一点体会试过这一整套流程之后回头再看HM会发现它并没有传说中那么可怕。代码多是真的多但真正核心的链路就那几条compressCtu到xIntraRecQT再到transformNxN你只需要盯住数据流从哪进、从哪出就能找到合适的插入点。这次做的是量化系数域的奇偶校验嵌入说白了就是在码流内容的一个小角落写了个bit算是压缩域隐写里最简单的一种。如果你想把demo继续往深了做可以试试两个方向一是把嵌入规则从每个TU嵌入1比特升级成多比特嵌入结合洗牌算法提高嵌入容量二是换嵌入点比如利用帧内预测模式的高低来映射信息或者把信息藏进运动矢量差值里。每一种方案的思路都和这次一样先找到编码链路里一个稳定可复现的中间量再定义嵌入规则最后保证编解码两端严格对称。方向清楚了后面的路就好走了。