ARTICLE DETAIL

资讯详情

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

Jsteg算法:基于DCT系数LSB替换的JPEG隐写技术

Jsteg算法:基于DCT系数LSB替换的JPEG隐写技术 简介这是一份基于JPEG图像实现Jsteg信息隐藏算法的Matlab源码资源面向信息隐藏、数字水印与图像处理方向的学习者和研究者。资源提供完整的算法实现涵盖DCT变换、量化、ZigZag扫描、Huffman编解码等JPEG核心流程并重点演示了如何通过替换DCT系数的最低有效位LSB将秘密信息嵌入图像同时给出对应的提取过程。压缩包共19个文件以.m源码文件为主附带jpeg编解码说明文档、常用测试图像cameraman.tif、lena512.bmp及备份文件整体仅272KB轻量紧凑便于离线查阅与快速运行。已有660人学习下载。借助源码、说明文档与测试图像的组合读者可以对照理解Jsteg在量化表与Y分量上的操作细节复现完整的隐藏与提取链路为后续改进隐写容量或安全性提供可扩展基础。1. Jsteg算法把秘密写进JPEG的DCT系数而不是像素Jsteg算法是最经典的JPEG域信息隐藏方法几乎所有后来的DCT域隐写算法都在它的基础上做减法。它把秘密消息嵌入到一张JPEG图像量化后的DCT系数最低有效位中而不是直接修改像素值。相比空域LSB替换Jsteg有更好的抗压缩能力和视觉隐蔽性因为修改发生在量化系数上对像素的影响通常不到1个灰度级。适合图像取证、隐写分析、CTF逆向以及想理解“JPEG如何一步步变成可以藏东西的容器”的人。下面按原理、实现、参数、检测四步把这个算法拆到底。2. 从DCT量化系数到Zig-Zag顺序Jsteg算法的嵌入位点怎么选把JPEG压缩链路画成算法流程图Jsteg的嵌入位置就在量化之后、熵编码之前。JPEG 编码不是直接把像素写进文件而是先做频域变换、再压缩统计冗余。这一节先交代这条管线上每一步做了什么再说明Jsteg为什么只选中其中一部分系数。2.1 JPEG熵编码之前发生了什么DCT、量化与系数分布一条完整的JPEG压缩链路可以按顺序拆成六步RGB转YCbCr亮度与色度分量分离每个分量切成8x8小块像素值减去128做电平偏移对每个块做DCT-II变换得到64个频域系数用量化表对系数做整数量化高频系数大量变成0直流(DC)系数做差分编码交流(AC)系数按Zig-Zag顺序做游程编码熵编码主要是Huffman编码输出最终位流。前四步决定了Jsteg能碰哪些数据。量化后的8x8系数矩阵里左上角是直流分量和低频系数数值通常较大右下角高频系数大部分是0。一个典型的512x512灰阶图像在质量因子75时每个8x8块平均只有10到20个非零系数其中绝对值等于1的系数又在非零系数里占不小比例。Zig-Zag顺序决定了系数写入熵编码器的次序也直接决定了嵌入和提取共用哪一条“路径”。JPEG标准把64个系数按从低频到高频的斜向顺序排列这对后续游程编码更有利。实现Jsteg时这个顺序必须和提取端完全一致否则解出来的位串从第一个不同步的位置开始就全是乱码而不是只错一个bit。2.2 Jsteg嵌入规则跳过0和1用LSB替换绝对值大于1的系数Jsteg的嵌入规则用一句话就能说清在量化DCT系数中忽略0和±1对绝对值大于1的系数把它的最低有效位替换成要隐藏的消息位。待处理系数是否嵌入嵌入后可能取到的值0否0-1 / 1否-1 / 1正数如2、3、...是2或3、2或3负数如-2、-3、...是-2或-3、-2或-3判断和替换逻辑可以抽成两个纯函数def embeddable(coeff: int) - bool: Jsteg跳过0和±1只处理绝对值大于1的系数 return abs(coeff) 1 def replace_lsb(coeff: int, bit: int) - int: 把系数绝对值的LSB替换为bit负号保留 mag abs(coeff) new_mag (mag ~1) | bit return new_mag if coeff 0 else -new_mag参数说明bit只取0或1。(mag ~1) | bit先清空绝对值的最低位再放入消息位因此嵌入前后系数绝对值的差值最大是1。负数直接对绝对值操作避免在Python里直接对负数做位与会得到不同语义。embeddable之所以要跳过1是因为1变成0会让原本的非零系数消失游程编码突然多出一段零1变成2又会把熵编码的位宽从很短的一段拉长文件体积和统计特征都会突变。跳过0的理由更直接0一旦变成±1高频区域凭空多出许多非零系数视觉噪声和文件膨胀都不可控。2.3 提取端必须严格对齐顺序、长度前缀与同步提取是嵌入的逆过程把JPEG解码到量化DCT系数按同样的Zig-Zag顺序读每个绝对值大于1的系数的LSB。关键点有三个第一个是块顺序。嵌入通常按从左到右、从上到下的块序进行提取端也必须按相同块序遍历否则位序列全部偏移。第二个是块内顺序。块内系数必须按照Zig-Zag顺序读取不能按行优先读取。JPEG在熵编码阶段就是这么扫描的接收端天然知道这一点。第三个是消息长度。接收端不知道要读多少位所以需要在消息开头写一个固定的长度前缀。常见做法是用4字节小端整数记录后续负载的字节数提取端先读32位还原长度再继续取N个字节。提示嵌入端的跳过条件和提取端不一致时不会出现“部分乱码”而是从第一个不同步的系数开始全面错位。最小实现里最好把embeddable抽成公共函数嵌入和提取都调用同一个逻辑。3. Python实现Jsteg算法的完整流程从图像到DCT系数再回到图像要修改一张已有JPEG的内部DCT系数最直接的办法是解析JPEG位流、改完量化系数再重写熵编码。这部分工作量大而且和具体JPEG编码器实现耦合。为了先把Jsteg的核心逻辑讲透这一节用Python实现一条完整的最小链路从图像像素做DCT、量化、嵌入、反量化、逆DCT最终在内存中验证提取结果。它与JPEG文件里的数学过程等价只差最后的Huffman编码壳。3.1 构建DCT矩阵与亮度量化表先定义DCT变换矩阵和JPEG标准亮度量化表以及Zig-Zag扫描顺序。import numpy as np from PIL import Image def dct_matrix(n: int 8) - np.ndarray: 生成n x n的正交归一化DCT-II变换矩阵 C np.zeros((n, n)) for i in range(n): for j in range(n): C[i, j] np.sqrt(2 / n) * np.cos((2 * j 1) * i * np.pi / (2 * n)) C[0, :] np.sqrt(1 / n) return C # JPEG标准亮度量化表对应质量因子大约50 Q np.array([ [16, 11, 10, 16, 24, 40, 51, 61], [12, 12, 14, 19, 26, 58, 60, 55], [14, 13, 16, 24, 40, 57, 69, 56], [14, 17, 22, 29, 51, 87, 80, 62], [18, 22, 37, 56, 68, 109, 103, 77], [24, 35, 55, 64, 81, 104, 113, 92], [49, 64, 78, 87, 103, 121, 120, 101], [72, 92, 95, 98, 112, 100, 103, 99] ]) # 按JPEG约定给出Zig-Zag顺序扁平下标 行 * 8 列 ZIGZAG [ 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63 ]代码说明dct_matrix生成的C满足正交性二维DCT用C block C.T实现逆变换用C.T block C无需单独定义逆矩阵。量化表来自JPEG标准附录不是随机数它决定了每个频带系数的舍入粒度。ZIGZAG则固定了“每块内先看哪些系数”的顺序后面嵌入和提取都依赖这个数组。3.2 嵌入函数Zig-Zag顺序下完成LSB替换把图像转成8x8块逐块做DCT和量化然后按Zig-Zag顺序修改可用系数。def block_dct_quantize(img: np.ndarray, C: np.ndarray, Q: np.ndarray): 对整幅灰度图分块做DCT并量化返回块列表 h, w img.shape blocks [] for y in range(0, h, 8): for x in range(0, w, 8): block img[y:y8, x:x8].astype(float) - 128.0 coeff C block C.T blocks.append(np.rint(coeff / Q).astype(np.int32)) return blocks def jsteg_embed(blocks, bits: list) - int: 修改量化DCT系数的LSB返回实际写入的bit数 bit_idx 0 for block in blocks: flat block.flatten() order [flat[k] for k in ZIGZAG] for pos in range(64): if bit_idx len(bits): break v order[pos] if abs(v) 1: sign 1 if v 0 else -1 order[pos] sign * ((abs(v) ~1) | bits[bit_idx]) bit_idx 1 for i, k in enumerate(ZIGZAG): flat[k] order[i] block[:] flat.reshape(8, 8) return bit_idx函数说明block_dct_quantize里np.rint(...).astype(np.int32)把量化后的浮点结果收成整数避免后续位运算在浮点数上出错。jsteg_embed先把块展平成一维数组再按ZIGZAG取出顺序视图order处理完后再按ZIGZAG下标回写。abs(v) 1这个条件同时过滤了0和±1负号用sign单独保存绝对值变化不超过1。3.3 提取函数按相同顺序读取并还原消息def jsteg_extract(blocks, max_bits: int): 从量化DCT系数中提取最多max_bits个bit bits [] for block in blocks: flat block.flatten() for k in ZIGZAG: if abs(flat[k]) 1: bits.append(abs(flat[k]) 1) if len(bits) max_bits: return bits return bits def bits_to_bytes(bits: list) - bytes: 把LSB顺序的bit列表还原成字节低位在前 byte_len len(bits) // 8 out bytearray(byte_len) for i in range(byte_len): for j in range(8): out[i] | bits[i * 8 j] j return bytes(out)参数说明max_bits用于限制读取数量调用方一般先传32读长度前缀再传消息字节数 * 8读负载。bits_to_bytes把每8个bit按低位在前拼接成一个字节这和嵌入端构造bit流的顺序必须一致。如果你在自己的实现里用了高位在前只要两端统一结果也能解但千万别一端低位一端高位。3.4 实例运行嵌入、提取与格式说明把上面函数串起来跑一遍完全在量化系数层面验证msg Jsteg hides in DCT coefficients payload len(msg).to_bytes(4, little) msg.encode(utf-8) bitstream [(b i) 1 for b in payload for i in range(8)] img np.array(Image.open(input.png).convert(L)) img img[:img.shape[0] - img.shape[0] % 8, :img.shape[1] - img.shape[1] % 8] C dct_matrix() blocks block_dct_quantize(img, C, Q) written jsteg_embed(blocks, bitstream) if written len(bitstream): raise ValueError(f载体容量不足需要{len(bitstream)}bit实际{written}bit) recv_bits jsteg_extract(blocks, 32 len(msg) * 8) recv_len int.from_bytes(bits_to_bytes(recv_bits[:32]), little) recovered bits_to_bytes(recv_bits[32:32 recv_len * 8]) print(写入bit数:, written) print(恢复消息:, recovered.decode(utf-8))这段代码里len(msg).to_bytes(4, little)生成4字节长度前缀bitstream按每字节低位在前展开成bit列表。重点在于嵌入和提取都作用于同一份量化系数没有经过像素重建所以不会引入舍入误差。真实JPEG里量化后的系数会被Huffman编码器无损写入文件解码端也能无损还原因此这里的内存验证和真实JPEG链路是一致的。如果只想要一份视觉预览图可以反量化、逆DCT后保存PNG但要清楚8bit像素重建会带来取整误差直接存PNG再读回来重新量化少数边界系数可能翻转所以不能用PNG文件当作可提取的载体。真实场景应该直接修改JPEG的量化系数并重写位流。4. Jsteg实战参数调整容量、质量因子与不可逆性4.1 质量因子如何影响Jsteg可用容量JPEG里的质量因子quality不是直接参与计算的它通过缩放量化表来起作用。libjpeg和Pillow都遵循同一套公式quality 50 时scale 5000 / quality quality 50时scale 200 - 2 * quality Q floor((Q * scale 50) / 100) Q clip(Q, 1, 255)quality越高scale越小量化表项越精细量化后保留的非零系数就越多Jsteg能选的位点也更多。质量因子量化表缩放倍数非零系数趋势文件大小适用场景650.70偏少块内高频大量归零小隐蔽优先容量要求低750.50中等默认平衡点正常大多数情况选75850.30明显增多可用位大约多30%大需要嵌入较长消息950.10接近无损几乎所有块都有可用位很大实验、内部测试当你拿到一张待嵌入的JPEG时质量因子已经写死在文件DQT段里了。你要做的不是重新用某个质量因子压缩而是按文件里那套量化表读出系数嵌入后再以相同量化表和熵编码方式写回去。如果用Pillow打开再另存Pillow会用自己算出的量化表重新量化嵌入位很容易被二次量化破坏。4.2 自定义质量因子的量化表生成自己生成量化表时可以直接把JPEG的缩放公式写到代码里def scaled_quant_table(Q: np.ndarray, quality: int) - np.ndarray: 按libjpeg方式缩放量化表输入quality范围1~100 if quality 50: scale 5000 / quality else: scale 200 - 2 * quality q_scaled np.floor((Q.astype(float) * scale 50.0) / 100.0) return np.clip(q_scaled, 1, 255).astype(np.int32)参数说明quality是整数但不是直接乘在DCT系数上先乘在量化表上。表项被scale放大后除法步长更大高频系数更容易变成0表项被scale缩小后量化更细。np.clip下限取1是为了避免quality特别高时出现0除数是0会让DCT系数全部溢出。生成后把它替换第3节的Q重新跑嵌入就能对比不同质量因子下的实际容量。4.3 彩色图像Y分量优先于CbCr分量彩色JPEG通常先把RGB转成YCbCr亮度Y和两个色度分量分别做DCT量化。对Jsteg来说有三个可选策略嵌入方案容量提升视觉风险只嵌Y分量基准很小纹理区域几乎无感全部三个分量都嵌约提升40%到60%色度分量改动容易在肤色和天空区域出现色块只嵌色度分量约提升30%边缘色度失真明显不推荐常见做法是在Y分量单独嵌入。色度分量原本就经过下采样量化步长偏大改LSB后重建的色差更容易被人眼察觉。如果你确实需要额外容量可以把Y分量用满后再按需使用CbCr但要注意解码端必须知道消息到底在哪些分量里这可以在消息头的保留字段里标记。4.4 容量估算、消息长度控制与三类常见坑嵌入前先统计可用系数避免写到一半才发现空间不够def count_embed_capacity(img: np.ndarray, C: np.ndarray, Q: np.ndarray) - int: blocks block_dct_quantize(img, C, Q) count 0 for blk in blocks: flat blk.flatten() count sum(1 for k in ZIGZAG if abs(flat[k]) 1) return count一个512x512的灰阶图像在quality75时可用系数通常有几万个bit大约能放进2KB到4KB文本。纹理复杂的照片可用系数更多平滑渐变天空图则明显偏少。彩色图全分量嵌入时容量再乘1.4到1.6。如果消息长度接近容量上限先做一层zlib压缩通常能省下一半空间。实战中还有三个高频问题要避开。第一是负系数位运算。在Python里直接对负数做 ~1结果不是把绝对值最低位清空而是会影响补码高位。必须按第2.2节的replace_lsb那样先取绝对值再处理。第二是重复压缩。Jsteg只能嵌一次。嵌入后的图像再被任何JPEG编码器保存一次量化系数会整体变化原有LSB基本无法保留。因此它适合一次性分发不适合反复编辑后继续提取。第三是文件膨胀。非0非1系数的LSB被替换后熵编码统计会小幅偏移文件通常增加0.5%到3%。如果跳过0和1的规则没实现好比如连1也改了文件可能膨胀10%以上。遇到文件体积异常上升先检查是否在嵌入条件里漏掉了abs(coeff) 1判断。5. 从隐写分析视角验证Jsteg直方图结对现象与改进方向5.1 用DCT系数直方图确认是否嵌入了LSB消息Jsteg嵌入后会留下一个非常明显的统计特征量化DCT系数中绝对值大于1的奇偶系数数量会被拉平。原图里系数2和3、4和5的高度往往有明显落差而经过Jsteg替换LSB后相邻奇偶系数的出现次数会趋于相等形成直方图上的“结对”现象。这是Jsteg最直接的检测证据不需要训练模型几十行numpy就能验证。对比嵌入前后直方图的代码片段如下def coeff_histogram(blocks, lo: int -9, hi: int 10) - np.ndarray: 统计绝对值大于1的DCT系数分布 vals [] for blk in blocks: flat blk.flatten() for k in ZIGZAG: if abs(flat[k]) 1: vals.append(int(flat[k])) hist, _ np.histogram(vals, binsnp.arange(lo, hi 1)) return hist orig_blocks block_dct_quantize(img, C, Q) stego_blocks block_dct_quantize(img, C, Q) # 这里stego_blocks应由嵌入后的系数组成示例代码只演示统计对比 h_orig coeff_histogram(orig_blocks) h_stego coeff_histogram(stego_blocks) pair_diff_orig abs(h_orig[2] - h_orig[3]) pair_diff_stego abs(h_stego[2] - h_stego[3]) print(pair_diff_orig, pair_diff_stego)代码逻辑h_orig和h_stego分别统计嵌入前后各系数值的出现次数。注意stego_blocks在实际取证时来自可疑图像演示里要替换成真正嵌入过的块。pair_diff_stego明显小于pair_diff_orig说明2和3之间、4和5之间的计数差被LSB替换抹平了。再配合卡方检验或奇偶差累计就能对整幅图做盲检测。5.2 从Jsteg到F5矩阵嵌入与统计补偿Jsteg的价值更多在“把问题定义清楚”真正投入生产时它有明显弱点对所有可用系数无差别替换LSB嵌入率越高直方图结对越明显。后续图像算法研究里有三条主要改进路线。第一条是F5算法。它引入矩阵嵌入用多个系数协同表达少量消息位平均每个消息位需要的系数修改次数大幅下降。修改变少奇偶结对痕迹也随之减弱这也是F5成为Jsteg主要替代品的原因。第二条是OutGuess。它在嵌入完成后用未参与嵌入的系数做直方图补偿把系数分布尽量拉回原始状态。对付卡方检验和简单的视觉对比更有效但容量会折损。第三条是自适应嵌入。优先选择纹理区域、边缘块的系数嵌入平滑区域完全不动。纹理复杂处人眼对微小系数变化不敏感同时系数分布也更接近自然统计。今天很多JPEG隐写方法仍然沿用这个思路只是把“选择哪些块”换成失真函数驱动的编码框架。即使JPEG XS这类面向低延迟分发的新编码使用了不同的变换结构Jsteg遗留下来的“选位、跳零、保奇偶”问题依然存在。做图像取证的人拿到一份JPEG隐写样本第一步不是上深度学习模型而是先画系数直方图、算相邻奇偶差几行numpy能筛掉很大一部分LSB类嵌入剩下的再交给更精细的统计模型这条路在DCT域隐写分析里至今有效。本文还有配套的精品资源点击获取
返回列表