每次聊到隐写,大家第一反应多半是图片里的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_yuv,176x144分辨率,从公开测试序列网站下载。也可以自己用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帧,QP=32,嵌入16字节payload跑了一组数据。未嵌入的原始编码作为对照组,嵌入后的编码作为实验组,结果如下:
| 指标 | 未嵌入码流 | 嵌入后码流 | 变化 |
|---|---|---|---|
| 码流大小 | 约42.6 KB | 约42.8 KB | 增加约0.5% |
| Y-PSNR | 38.12 dB | 38.09 dB | 下降0.03 dB |
| 提取误码率 | - | 0% | 完全还原 |
不同测试序列的数值会有差异,但整体趋势一致:码流增量在1%以内,PSNR下降在0.05dB以内,人眼完全看不出差异。这个结果符合预期,因为我们每次只改一个系数且只做幅值+1,对码流和画面质量的影响都极其有限。
我也试过更高的QP值比如QP=40,此时量化更狠,非零系数数量减少,能嵌入的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比特升级成多比特嵌入,结合洗牌算法提高嵌入容量;二是换嵌入点,比如利用帧内预测模式的高低来映射信息,或者把信息藏进运动矢量差值里。每一种方案的思路都和这次一样:先找到编码链路里一个稳定可复现的中间量,再定义嵌入规则,最后保证编解码两端严格对称。方向清楚了,后面的路就好走了。