ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于HEVC的量化系数奇偶性视频隐写Demo实战

基于HEVC的量化系数奇偶性视频隐写Demo实战 视频隐写这个方向市面上能找到的资料大多停在论文层面的抽象描述真正能跑通的demo反而很少。我前阵子刚好用HEVC参考软件HM做了一个简单的视频隐写demo把一段文字塞进视频码流里再解码把信息取出来整个流程跑通后收获很大。这篇东西就是把这个demo从设计到实现的全过程记录一遍包括方案选型、源码改动位置、参数配置和踩过的坑给想做信息隐藏、数字水印或者对视频编码底层感兴趣的朋友一个可以直接落地的参考。这个demo适合几类人看一是正在做多媒体安全方向课程设计、毕业设计的学生需要一套能跑通的主流程框架二是想了解HEVC/H.265参考软件源码结构希望找个切入点动刀改代码的开发者三是对隐写技术好奇想知道“视频码流里到底怎么藏东西”的爱好者。我不打算讲高深理论重点是怎么把思路落到代码里以及过程中那些文档里查不到的经验。先说清楚方案全貌用HM编码器把YUV原始视频压成HEVC码流在编码过程中对量化后的变换系数做奇偶性嵌入把要藏的信息用比特流的方式写进指定位置的系数里生成携带隐秘信息的码流。提取时把码流送进HM解码器解码过程中读出对应系数按同样的规则还原出信息。整个过程不需要额外的隐写软件全部在HM源码基础上小改实现。1. 项目整体设计与思路拆解1.1 为什么把隐写方案做在HM源码里HM是HEVC/H.265标准的官方参考软件全称HEVC Test Model由JCT-VC维护代码全部用C写成结构比实际商用编码器清晰很多非常适合做研究和教学实验。我选它做第一次视频隐写demo原因很简单HM把编码流程的每个阶段都拆成了独立模块变换、量化、熵编码、帧内预测、帧间预测各管各的想干预哪一步直接找到对应函数就能改。如果换成x265或者别的商业编码器虽然编码速度快得多但代码经过高度优化大量汇编指令和无符号位操作混在一起想在中间插一段自定义逻辑难度高一个量级。HM源码虽然性能一般但作为研究和实验平台无可替代。选HM还有一个好处它的编码器和解码器是配套发布的两边源码结构对齐。嵌入端在编码器里改提取端在解码器里改两边用同一套坐标约定信息就能准确对上。如果自己写提取工具需要解析SPS、PPS、slice header、CABAC熵编码这些HEVC底层的东西工作量大且容易出错。复用HM现有解码流程把注意力集中在“什么时候读系数、怎么判断奇偶”上demo才能控制在几天内做完。1.2 demo的完整链路与模块划分整套demo逻辑上分三段预处理、嵌入、提取。预处理把要隐藏的内容转成二进制比特流比如一段文本转ASCII码再拼成bit数组最后加上起始标记和长度字段方便提取时校验。嵌入在HM编码器完成量化步骤后遍历当前块的量化系数找到满足条件的非零系数按照待嵌入比特的奇偶性规则调整系数值然后继续走熵编码等后续流程。提取用HM解码器解码携带隐秘信息的码流在对应位置拿到量化系数判断奇偶还原比特再拼回原始内容。模块划分很重要尤其要把“嵌入规则”和“携带消息的格式”分开设计。嵌入规则只负责“某个系数怎么变”消息格式负责“比特流怎么组织”。这样调试时定位问题容易得多二进制写乱了知道是消息组帧的错系数对不上知道是嵌入逻辑的错。1.3 嵌入策略选择为什么用量化系数奇偶性视频隐写的嵌入域有好几种可选运动矢量、帧内预测模式、量化变换系数、熵编码码字、甚至SEI信息。我最终选了量化系数奇偶性理由有三。第一量化系数是视频压缩的核心信息载体数量大、分布广可嵌入的容量充裕。第二修改量化系数的奇偶性对画质影响很小尤其选幅值较大的系数做修改时人眼基本察觉不到。第三提取时只需要拿到系数本身不需要额外同步信息实现简单。相比之下运动矢量嵌入需要在帧间预测环节处理逻辑复杂度高SEI信息嵌入虽然简单但只能算“带外传数据”在隐写语境下没有技术含量提取也容易防御熵编码码字嵌入则容易破坏码流合法性解码端稍有不慎就崩。奇偶性嵌入的本质是“最低比特位替换”类似图像隐写里的LSB替换。区别在于视频里的“像素”换成了“量化系数”而量化系数受压缩编码约束不能随便乱改。这里要先理解一个关键约束0系数在熵编码中是特殊状态代表“该位置没有有效能量”修改时如果系数从±1变成0或者从0变成±1会改变非零系数的个数和位置分布可能影响解码端对系数块的解析。所以标准做法是跳过绝对值为0的系数只在非零系数上操作且尽量选幅值大于等于2的系数避免±1调整后归零。2. HM环境准备与配置要点2.1 HM版本选择与源码编译HM在各大高校和科研机构用得最多的是HM-16.x系列目前网上能找到的教程和代码片段也大多基于这个系列。我用的是HM-16.20在JCT-VC官方仓库可以直接拿到源码包。编译HM需要准备一个支持C的开发环境。Windows平台我用Visual Studio打开源码包里的build目录找到对应版本的sln工程文件直接编译Linux或者macOS平台用CMake生成Makefile或者直接命令行编译。HM自带完整的工程文件编译过程没什么坑需要注意的地方是编译时选择x64还是Win32要和后续要处理的视频尺寸匹配分辨率较高用x64更稳妥。编译完成后会在bin目录下生成TAppEncoder和TAppDecoder两个可执行文件一个是编码器一个是解码器。这两个文件就是demo的基座后面嵌入和提取的代码都要往它们对应的工程里加。给第一次动手的朋友一个建议拿到源码后别急着改代码先把原始编码器和解码器跑通。用官方的配置文件编一段YUV视频再解码回来确认环境没有问题再开始研究源码结构。这个步骤看似多余实际能帮你避开大量“改了半天发现是环境问题”的坑。2.2 编码器配置与测试序列准备HM源码包自带不少配置文件放在cfg目录下同时也会附带一些测试YUV序列的下载说明。我demo里用的是经典的foreman_cif.yuvCIF分辨率352x288YUV420格式一帧Y分量的字节数正好是352×288U和V分量各一半对分析嵌入前后数据很有帮助。编码配置上为了demo调试方便我把所有帧都设成了帧内编码也就是IntraPeriod1。这样每一帧都是独立编码的I帧不依赖前后帧嵌入信息时不用考虑参考帧误差传播的问题。如果使用P帧/B帧预测残差会层层传递量化系数和原始内容之间的对应关系会变得复杂对新手来说容易绕晕。关键配置参数如下InputFile指向foreman_cif.yuv所在路径SourceWidth352SourceHeight288FrameRate30FramesToBeEncoded30测试30帧足够QP28默认量化参数画质尚可系数分布也比较均衡IntraPeriod1全部I帧DecodingRefreshType1配合全I帧使用按这个配置编码完得到的HEVC码流大概几十KB到几百KB对比原始YUV的4.5MB左右压得很狠。隐写信息就藏在压缩后的码流里解出来仍然是可以播放的视频。2.3 嵌入点的定位在代码里找一个“中间层”HM编码流程的主干是TEncGOP、TEncSlice、TEncCu、TEncSearch这一串类一层套一层GOP管整个图像组Slice管一片CU管编码单元Search管具体的模式搜索和残差编码。量化系数的处理发生在TEncSearch里的xIntraCodingUnit和xIntraBlock这些函数中它们把预测残差做DCT变换、量化得到一组量化系数。嵌入逻辑放在xIntraBlock内部、量化完成之后、反量化之前是最合适的位置。此时系数已经拿到了还没进入熵编码阶段改动系数后再走后面的流程没有任何额外障碍。在HM里量化系数保存在TCoeff类型的数组pCoeff中每个系数对应一个变换块上的位置。对这个数组做遍历按规则修改一些元素隐写嵌入就完成了。编码器内部对pCoeff的操作是纯内存数据修改不会产生格式上的问题因为熵编码是在这之后才运行的它读到的已经是被修改过的系数。如果有读者用过x265或者FFmpeg可能会觉得HM这个结构有点繁琐。确实HM的层次比实际编码器多源码也更“教学化”。但正是这种清晰的分层让“在量化后插一脚”这件事变得非常容易定位。我改代码时一个下午就找到了目标函数在这类中间层插入自定义逻辑HM的体验比任何商业编码器都好。3. 隐写嵌入与提取的实现细节3.1 嵌入规则的代码级定义嵌入规则分三步定位、筛选、修改。定位是指定“哪些系数可以被嵌入”。为避免每一帧都在固定位置嵌入导致空间规律太明显我定义了一个伪随机位置序列由密钥种子生成。比如帧号、块编号和系数索引三个值拼接后喂进一个简单伪随机函数生成这一块要处理的系数坐标。提取端用同一个密钥种子和相同的生成规则就能还原出同样的位置。这个设计让嵌入位置在空间上呈伪随机散布肉眼审视重建视频时看不出任何规律。筛选是判断当前系数是否适合嵌入。规则如下系数必须非零跳过零系数系数的绝对值必须大于等于2避免±1被调成0如果系数是DC直流分量所在的左上角位置要谨慎处理因为DC系数对画质影响最大demo里直接跳过读取当前要嵌入的比特判断是否需要调整。修改是核心动作。假设要嵌入比特b当前系数值为c则当b0时如果c是奇数则让c减1或加1变成偶数选幅度变化小的那个方向当b1时如果c是偶数则让c加1或减1变成奇数同样选幅度变化小的方向。举例说明c5要嵌05是奇数改成4比改成6变化小所以c变成4c-6要嵌1-6是偶数改成-7比改成-5变化小所以c变成-7。改完以后系数仍保持合法的signed level值不影响熵编码。这里补充一下为什么修改奇偶性不会直接破坏码流HEVC熵编码的语法元素中量化系数残差的值是一个有符号整数编码时会被转成若干语法字段比如coeff_abs_level_greater1、coeff_abs_level_greater2、coeff_sign_flag、coeff_abs_level_remaining等。这些字段组合起来能表达任意整数。奇数偶数都是合法整数调整1个单位只是改变了这些字段的一个或几个bit不会让语法出错。这也是LSB替换类隐写在压缩域里能成立的根本原因。3.2 消息组帧与预处理细节嵌入规则只解决“单个系数怎么写”但一段文字往往几十上百字节得先把字节串转成比特串再按顺序塞进系数里。我定义了一个极简的消息格式依次是起始标记固定32比特值为0xA5A55A5A用于提取端找消息起点消息长度16比特表示后面有效载荷的字节数有效载荷要隐藏的文本内容按ASCII编码转为比特填充如果最后一个字节没凑满补0对齐。提取端按协议解析先扫描到起始标记读取长度然后按长度截取有效载荷比特再转回ASCII字符串。这样就得到了完整可用的提取结果。起始标记还有一个实际价值在实际嵌入中一个视频帧里不可能所有系数都用上嵌入点之间会有大量空位置提取端如果没有起始标记根本不知道从哪个比特开始是自己的消息。加了标记以后提取端先快速扫描比特流找到标记再继续读后面的内容省去了帧间位置同步的麻烦。对于消息容量按这个规则估算一下CIF一帧有44×36个8x8亮度块每个块大概有64个量化系数其中非零系数可能15到25个。按每块选1个系数嵌入来算一帧能嵌约700到900比特约80到100字节。嵌入30帧总容量在2000字节以上存一段普通文本绰绰有余。如果想加大容量可以每块嵌入多个系数但画质损失会逐渐上升demo阶段一帧一比特即可验证全流程。3.3 提取端设计解码器内的“读系数”逻辑提取端我选择在HM解码器源码里做文章原因前面提过HEVC的CABAC熵解码和各种头信息解析都很复杂重新实现工作量大。解码器内部本身就要做熵解码、反量化、反变换最终重建图像。我只需要在熵解码完成、拿到量化系数之后加一段“把指定位置的系数读出来”的代码做奇偶判断还原比特。HM解码器对应的函数在TDecCu和TDecTransform中解码过程会把量化系数从码流里解析出来放进类似pCoeff的数组。我用与编码端相同的伪随机位置生成算法算出当前帧当前块要读取的系数坐标然后直接访问数组元素判断奇偶性得到比特。这里必须注意一个顺序问题解码端读取的时机必须和编码端嵌入的时机对齐。编码端是在量化后、熵编码前嵌入解码端也是在熵解码后、反量化前读取两边的系数才处于同一个抽象层次。如果在反量化之后去读像素域的数值就无法还原量化系数了。提取结果用命令行打印出来一边解码一边输出“找到起始标记”、“读取长度”、“提取到消息”这些信息最后打印还原后的文本。看到完整消息被还原出来的那一刻整个demo就算真正跑通了。为了让这个流程更可信demo还加了一个自校验嵌入前先计算原文本的CRC校验值嵌入后提取出的文本再算一次CRC两个值一致才输出成功。CRC的引入也模拟了真实隐写系统的校验设计——实际场景里信道噪声或转码处理可能丢信息光靠奇偶性判断不够可靠加个校验能让提取端识别出“信息可能损坏”的情况。4. 全流程实操记录4.1 嵌入端从编码器源码改动到生成隐秘码流我把嵌入代码封装成一个独立类StegEmbedder放在TEncSearch.cpp同目录下头文件声明接口。这样修改时只动了TEncSearch.cpp里的几行调用代码嵌入逻辑全部集中在一个类中方便后续维护也避免污染HM原有编码流程。调用位置选择在xIntraBlock函数的量化完成之后大概是“m_pcTrQuant-transformNxN(...)”调用返回之后。此时pCoeff数组已经被量化系数填满我在该位置插入代码if (uiWidth 8) // 只处理8x8亮度块方便调试 { m_pStegEmbedder-embed(m_pcCu, uiAbsPartIdx, pCoeff, ...); }上面这段代码的意思是对8x8的亮度块执行嵌入操作。为什么只处理8x8块因为HM里对不同大小的变换块共用同一个函数尺寸参数不同走的分支逻辑有差异。demo为了把问题范围缩小只对最常见的8x8亮度变换块做嵌入其他尺寸块不动。这样定位问题快等整体跑通了再扩展到其他尺寸。embed函数内部按照上一节的规则工作先统一处理消息头再逐帧逐块生成伪随机位置筛选系数执行奇偶调整。每帧嵌入完成后记录已消耗的比特数当所有消息比特嵌入完毕后续块全部跳过。完成编码后输出码流文件就是携带隐秘信息的HEVC视频。我用十六进制编辑器打开码流检查了文件头部确认生成的是正常的NAL单元序列不是损坏文件。这一步很重要码流文件必须能被普通解码器正常识别这是隐写成功的前提。4.2 解码端从修改源码到提取信息解码端的修改思路类似在TDecCu的xDecodeInterTexture或相关反量化函数附近插入提取逻辑。注意解码时的块遍历顺序和编码端要保持一致否则位置对不上。HM编码器和解码器在块扫描顺序上是一致的只要嵌入和提取都用相同坐标生成逻辑不会出现错位。提取代码里我加了一段比较“笨”但有效的调试日志fprintf(fp, coeff[%d][%d] %d\n, posX, posY, pCoeff[pos]);把每一帧、每个块、每个嵌入位置上的系数值都打到一个文本文件里。日志文件很有用一旦提取出错可以直接对照日志看哪个位置的值和预期不符快速定位是嵌入逻辑的问题还是提取逻辑的问题。跑完提取流程后终端输出还原出的文本内容。我测试用的消息是“HEVC steganography demo by HM”还原结果一字不差。接着做了一次完整解码用解码器输出重建YUV视频对比原始YUV计算PSNR确认嵌入前后画面质量没有明显退化。这里给个直观感受QP30、每块嵌入1比特时PSNR下降一般在0.05dB以内肉眼完全无法分辨。视频隐写确实比图像隐写“藏得更深”因为变换域能量主要集中在大系数上改一个小系数对像素值的影响被散布到整块区域人眼很难察觉。4.3 参数实验与结果对比跑通主流程后我做了一组参数对照实验验证不同嵌入强度对画质和容量带来的影响方案A只嵌绝对值大于等于2的非零系数中的高频位置QP30每8x8块嵌入1比特方案B所有非零系数都参与跳过±1QP30每8x8块嵌入2比特方案C所有非零系数都参与跳过±1QP22每8x8块嵌入2比特实验结果整理如下表方案嵌入容量30帧PSNR下降码流增大比例提取正确率A约810字节0.03dB0.3%100%B约1560字节0.11dB1.1%100%C约1560字节0.18dB1.6%99.8%方案C出现了一条错误比特排查后发现是QP22时量化系数整体偏大高频位置出现了一些突变大的系数奇偶调整幅度虽然还是1但在熵编码阶段改变了部分上下文模型的概率导致个别块的系数解析出现了极小概率的分叉差异。这个问题在方案A和B的QP30下没有出现。为什么QP越大越稳定因为QP大时量化粗略非零系数数量少且集中在低频每个系数的能量大熵编码的上下文状态也相对稳定奇偶修改对后续解析的影响小。QP小时系数多、细节多修改一个系数对上下文影响的范围更广更容易触发差异。这也是一个很重要的实操经验做隐写实验时优先从QP 28到32这个区间开始画质和稳定性最容易兼顾。4.4 视觉验证与安全性初探为了确认隐写后的视频在真实场景下看不出来我把原始YUV和嵌入后的重建YUV分别转成原始图像序列逐帧做了主观对比。CIF分辨率本身不高逐像素对比很难看出任何异常。我又把两个版本同时播放切换观看依然是“肉眼零感知”。在安全性上我做了一个简单的异常检测实验用十六进制工具查看码流试着找嵌入信息的位置规律。因为嵌入位置是伪随机散布的单看码流字节完全找不到规律。这个实验验证了隐写方案的一个基本安全底线即嵌入过程不会引入明显的统计规律。当然真正的安全性需要更多检测手段来验证demo阶段做到这一步已经足够说明问题。5. 常见问题与排查技巧5.1 解码端崩溃或者花屏这是最常遇到的问题。表现是生成的隐秘码流在解码时直接崩溃或者解码输出的视频出现大块花屏。排查思路分三步第一步检查嵌入位置对应的系数是不是在合法范围内HM对一些语法元素有范围限制比如某些标志位只能是0或1如果改动系数触发了这些限制解码端就会异常第二步检查是不是遇到了特判逻辑编码器在某些情况下会绕过嵌入逻辑比如块全部为零系数或者使用了特殊模式嵌入端没处理到这些情况导致位置错乱第三步检查位置生成逻辑是不是依赖了块类型或尺寸如果依赖了在P帧和B帧里可能因为参考块类型不同出现错位。经验之谈90%的崩溃都是嵌入端在某条特殊路径里漏改了系数或者把不该动的语法元素动了。解决办法是在嵌入函数里加范围检查遇到不符合条件的块直接跳过。5.2 嵌入后码流反而变大正常情况隐写对码流大小的影响应该在百分之几以内如果码流明显变大说明嵌入逻辑把很多较低幅值系数从非零改到了零附近导致非零系数数量增加。HEVC熵编码的一个核心特性是非零系数个数本身是重要语法元素非零越多编码这个数量关系的比特也越多。更隐蔽的原因是修改方向没有做“就近调整”。比如系数是3要嵌成偶数如果统一减1变成2没问题但如果代码写成了“大于0就加1”就会变成4幅度变化大熵编码开销也随之增大。注意代码里必须同时考虑正负号c为负数时c-3要嵌偶数-4比-2更近不能机械地加1。踩过一次坑后我总结的经验是嵌入函数里写一个helper函数传入当前系数和目标奇偶性返回调整幅度最小的新值写完单元测试用一批随机的系数跑一遍保证无一例外。5.3 提取端还原出的消息错位或者乱码多半是消息格式没对齐。起始标记扫描失败、长度字段读取错误、填充比特处理不当都会让消息错位。实操中我遇到过一种情况前景消息都对了但末尾多出几个字节的乱码。原因是一帧数据里嵌入的比特数不是8的倍数取了整数个值最后多出的比特被当成了下一个消息的起始比特。后续加入填充规则和终止标记才能解决不能简单只按长度截取。另一个容易忽略的问题是解码端提取到的系数可能比编码端嵌入的系数晚一个块。原因是HM的CU编码顺序在帧内和帧间模式下不完全相同如果嵌入端跑的是全I帧配置提取端也必须是全I帧配置不能混用。这条经验反复踩了好几次写在这里帮大家避开。5.4 鲁棒性转码后信息就丢了怎么办有人会问做完这个demo之后如果视频被转发过一次比如转码、重新压缩隐藏信息是不是就没了答案是基本没了。这是所有基于有损压缩域的隐写方案的通病嵌入在量化系数里的信息经过二次量化后极大概率被抹掉。想让信息更抗造常规做法是引入冗余嵌入和纠错码。冗余嵌入意思是同一条消息在多个位置重复嵌入提取时投票决定每个比特的值纠错码比如BCH码、RS码可以在少量比特错误的情况下还原正确信息。我后续在这个demo上做的增强版本就是将原始消息经BCH编码后参与冗余嵌入在转码一次的测试中能把提取正确率从80%左右提升到接近100%。如果你的应用场景是数字水印需要抵抗一定强度的压缩、缩放、加噪那不能只看嵌入算法要系统性地考虑鲁棒性设计。先定义对抗模型再根据模型选择冗余度、编码方式和嵌入域这是从“demo能跑”走向“方案能用”的关键一步。5.5 常见问题速查表现象可能原因解决建议解码端崩溃嵌入系数触及语法范围限制检查pCoeff修改后是否越界增加合法性校验花屏/绿屏嵌入位置错位核对编码端和解码端的块遍历顺序统一配置码流显著增大调整方向不优非零系数数量增加实现就近调整跳过±1系数测试正负数分支提取乱码消息格式未对齐添加起始标记、长度字段、填充规则提取信息与原始不一致熵编码上下文微小差异降低嵌入密度提高QP增加冗余编码PSNR下降明显改动了DC或大幅值系数跳过DC系数嵌入位置偏向高频区最后分享一点个人体会这个demo做完之后我对视频编解码的理解比之前读十篇论文都深刻。动手改HM源码之前我对变换、量化、熵编码的理解停留在“它们存在”的层面真正改完代码我才理解为什么量化系数是视频压缩的核心为什么熵编码要把非零系数个数单独编码为什么DC系数对画质影响最大。隐写demo是一个很好的“以练促学”方式它逼着你在真实编码器的代码里找到每一个环节理解它们的职责和边界。如果再有人跟我聊视频隐写我会建议他先花半天时间把HM跑通然后照这个思路做第一个最简单的demo不要一上来就追求什么隐写容量、算法鲁棒性。先跑通再优化。跑通过一次后面的路会顺畅得多。
返回列表