LSB隐写原理与MATLAB实战:容量计算、PSNR评估及卡方检测
2026/9/15 15:16:22 网站建设 项目流程

简介:LSB隐写术的MATLAB实现代码包,面向信息安全初学者与数字图像处理爱好者,用于理解最低有效位替换的基本原理与完整流程。包内含5个文件,包括3个MATLAB脚本和2张TIFF格式测试图像,整体大小约462KB,结构紧凑,便于直接运行和对照学习。目前已有360人学习下载。资源中的脚本覆盖了LSB信息嵌入、提取以及主程序调用等环节,配合Lena、Baboon等经典图像,可直观观察隐写前后视觉效果差异,并通过修改像素最低有效位实现秘密信息的隐藏与还原。该方法利用人眼对最低位变化不敏感的特性,在几乎不改变视觉观感的前提下完成数据嵌入。对于希望入门信息隐藏技术或开展相关课设实验的读者而言,这份代码提供了可复现的基础框架,能帮助快速掌握LSB实现的细节,同时为后续研究更安全的隐写算法打下基础。

1. LSB隐写在视觉不敏感区藏数据:先算清一张Lena图的真实容量

最早接触隐写的人,多半以为要先上频域变换、对抗训练才有意义。实际上信息隐藏的入门基准就是LSB替换——把像素最低位直接换成秘密位,视觉上几乎无感,代码不到四十行。

手头这套以lionvpo为代号的MATLAB实现,用LSBmain.m、LSB_en.m、LSB_de.m配Lena、Baboon两张无损TIFF图,把嵌入、提取、容量到反隐写检测的链路收在一处,适合课程设计、CTF图片隐写题和在工程里快速评估「这张图能藏多少」的人。

但LSB替换的边界很硬:512×512的RGB图满容量只有786432比特,扣掉长度头约98KB文本;中间但凡经过一次JPEG保存,载荷就整体蒸发。

2. 素材拆解:lionvpo文件结构与LSB替换的位平面原理

LSB替换的技术含量不在于那几行位运算,而在于三个前置决策:载体格式、扫描顺序、长度头。这三件事在lionvpo包里都能找到对应,先看原理再对文件,后面复现时才不会改出一堆隐蔽bug。

2.1 位平面权重:为什么最低位改了人眼无感

每个通道在计算机里是8位无符号整数,第8位是最高有效位,权重128;第1位是最低有效位,权重1。权重差异意味着改MSB相当于把像素亮度直接跳一半,而改LSB只是从灰度k走到k±1,换算成占比只有约0.39%。人眼在8bit显示设备上本来就不容易分辨相邻灰度级,何况大多数自然图像里相邻像素本身就存在±1的抖动。

位序位权重占256级满量程比例修改后视觉表现
第8位 (MSB)12850.20%亮度近乎翻倍,明显可见
第7位6425.10%大范围色带
第4位83.14%低对比区域能察觉
第1位 (LSB)10.39%多数场景无法分辨

这张表对新手友好,但它也提示了一个容易被忽略的点:视觉无感不等于统计无痕。LSB替换会把自然图像的奇偶分布拉向均匀,这正是后面卡方检测能抓到它的根源。

2.2 lionvpo包里五个文件各自承担的任务

解压后是五个文件,没有复杂工程结构,正好可以当MATLAB函数化拆分的范例:主控脚本负责调度,嵌入和提取拆成两个独立函数,测试图单独存放。lionvpo应该是这套工程的项目代号,不是某个第三方库名,读代码时不用去找对应的toolbox。

文件类型职责关键接口
LSBmain.m脚本主控:读图、组装秘密位流、调嵌入/提取直接运行
LSB_en.m函数LSB替换嵌入stego = LSB_en(cover, bits)
LSB_de.m函数LSB提取bits = LSB_de(stego, nbits)
Lena.tiff测试图512×512 RGB标准载体uint8,3通道
Baboon.tiff测试图纹理丰富,适合对照实验uint8,3通道

文件名本身是接口信号:_en是encode,_de是decode,main负责把两者串起来。这种「脚本+函数」的拆分方式是我在课程项目里比较认可的做法——算法逻辑留白小,换成其他格式的载体图时只需要改imread一行。

2.3 嵌入前必须定死的三个约定

第一,载体格式必须无损。TIFF、BMP、PNG都可以,JPEG不行:DCT量化过程会重写低比特位,嵌入的载荷在压缩阶段就丢了大半。这也是项目里测试图选.tiff而不是.jpg的原因。

第二,扫描顺序必须一致。MATLAB里cover(:)按列优先展平,而很多教材示意图画的是逐行扫描。只要嵌入和提取用同一套顺序,内部实现是无所谓的;但如果你把图拿到Python里用OpenCV处理,行列顺序和通道顺序都要重新对齐。我一般会在注释里显式写明「列优先/行优先」,避免跨语言时出低级错误。

第三,位流前要预留长度头。提取端拿到载密图时并不知道秘密信息有多长,常规做法是固定用前32位存一个uint32长度,后面才是真正的载荷位。这套约定在LSBmain.m里体现得最明显,下一章会直接落到代码上。

提示:这里说的「约定」本质上就是隐写协议。换一张图、换一个扫描方向、换一种位序,只要双方约定一致,编解码都能成立;约定不一致,提取出来就是乱码。

3. 嵌入端复现:LSB_en.m的位运算实现与容量计算

嵌入端最核心的问题就两个:秘密位怎么组织,位替换怎么写。前者决定提取端能不能还原,后者决定嵌入速度和质量。

3.1 先把秘密文本转成二进制位流

MATLAB里文本是字符数组,要嵌入就得先转成uint8字节再逐位拆开。下面这个函数从字节的最低有效位开始拆,顺序和提取端保持一致:

function bits = text_to_bits(msg) data = uint8(msg); % 中文字符按 UTF-8 编码拆成多字节 nbytes = numel(data); bits = zeros(nbytes * 8, 1); % 预分配,避免循环里动态扩容 for k = 1:nbytes for b = 1:8 bits((k-1)*8 + b) = bitget(data(k), b); % b=1 表示取 LSB end end end

逻辑说明:外层循环遍历每个字节,内层用bitget(data(k), b)从该字节的第1位(LSB)到第8位(MSB)逐个取出。选择LSB-first的理由是它和后面LSB替换的位号完全对应,阅读代码时不用来回换算。参数说明:msg是MATLAB字符串或字符数组,支持中文,但中文UTF-8一个汉字占3字节,最终位长会变成3×8×字数,在算容量时要折算进去。如果嫌双循环慢,可以用dec2bin加reshape,不过对于几十KB的文本,双循环的开销可忽略,可读性更重要。

3.2 位替换核心:清零最低位再叠加秘密位

LSB替换的标准实现有两类写法。一类是逐像素bitset,直观但慢;另一类是向量化:先用bitand把整张图的LSB清零,再把秘密位加回去。lionvpo这类课程代码通常用向量化版,处理512×512×3的Lena图只需要几毫秒:

function stego = LSB_en(cover, secret_bits) [h, w, c] = size(cover); % 高、宽、通道数 total = h * w * c; % 可嵌入的最大位数 if numel(secret_bits) > total error('秘密位长度 %d 超过容量 %d', numel(secret_bits), total); end flat = cover(:); % 按 MATLAB 列优先展平成列向量 flat = bitand(flat, uint8(254)); % 254 = 0b11111110,清零 LSB data = zeros(total, 1); % 补齐到整图位长,未用位置 0 data(1:numel(secret_bits)) = secret_bits; stego_flat = flat + uint8(data); % 秘密位叠加,像素最多变化 ±1 stego = reshape(stego_flat, h, w, c); end

逻辑说明:bitand(flat, uint8(254))把每个像素通道的最低位置0,其余7位原样保留,这一步是「替换」而不是「叠加」的关键——先用掩膜清掉旧位,再用加法写入新位。data向量前段放秘密位、后段补0,补0部分对应的像素其实没被修改,因为原LSB已经被清掉,再加0等于原值。参数说明:cover要求uint8类型,如果读进来是double,需要先类型转换;secret_bits是0/1列向量,函数里会在加法时自动转uint8。返回值stego与cover同尺寸同类型,可以直接imwrite保存成无损格式。

和逐像素bitset方案相比,向量化的优点是避免MATLAB循环解释器开销;缺点是理解门槛稍高,调试时可以检查stego(idx)与cover(idx)的差值是否只落在0、±1三种取值上。

3.3 容量怎么算:头结构决定净荷

嵌入函数本身只关心位够不够放,但工程上必须留长度头。完整调用是这样:

% LSBmain.m 中的关键流程 cover = imread('Lena.tiff'); bits = text_to_bits('Hello, LSB'); header = de2bi(numel(bits), 32, 'left-msb'); % 32 位长度头 seq = [header(:); bits(:)]; stego = LSB_en(cover, seq);

逻辑说明:de2bi把十进制长度转成32位二进制向量,'left-msb'指定高位在前,和提取端的bi2de方向必须一致。如果没有Communications Toolbox,用bitget循环逐位拆也可以,只是代码长一点。参数说明:header固定32位,seq是头部加正文的完整待嵌入位流,顺序就是后续提取端的解析顺序。

Lena.tiff是512×512×3,原始容量512×512×3=786432位,每通道贡献1位。扣除32位长度头,净荷786400位,折合98300字节。按纯ASCII文本算能存约96KB;按UTF-8中文算,一个汉字3字节,约32700字——这个数字对大多数课程设计和CTF题目的flag长度绰绰有余。Baboon.tiff同样是512×512,容量相同,差别在纹理分布,不影响容量计算。

计算项数值说明
像素总数262144512×512
通道数3RGB 各贡献 1 bit
总容量786432 bit满嵌入
长度头开销32 bituint32
净载荷786400 bit ≈ 98.3 KBASCII 文本

注意:实际写入时我一般只用到净容量的90%,给文件尾、换行符和可能的对齐余量留空间。容量计算只是上限,不代表每次真的塞满。

4. 提取端还原:LSB_de.m解码逻辑、PSNR评估与常见排错

提取端写起来比嵌入端短,但坑反而多,因为提取端无法拿载密图反推原始载体,所有还原依据都来自协议本身。

4.1 LSB_de.m的对称提取

提取函数是嵌入的逆过程,读取最低位即可:

function bits = LSB_de(stego, nbits) flat = stego(:); % 与嵌入端同样的展平顺序 lsb = bitand(flat, uint8(1)); % 1 = 0b00000001,取出最低位 bits = double(lsb(1:nbits)); % 截取前 nbits 位 end

逻辑说明:bitand(flat, uint8(1))等价于mod(flat,2),但位运算语义更清楚。提取端必须和嵌入端使用完全相同的展平顺序和位序约定,所以LSB_de里没有再传扫描方向参数——约定写死在函数里,比留参数更不容易两边不一致。

拿到长度头后再提取正文:

function msg = LSB_extract_with_header(stego) bits = LSB_de(stego, 32); % 先读长度头 nbits = bi2de(bits', 'left-msb'); % 解析出秘密位长度 payload = LSB_de(stego, 32 + nbits); % 读载荷区 payload = payload(33:end); % 跳过头部 nbytes = ceil(numel(payload) / 8); % 末尾不足8位补零 h = de2bi(payload, 8, 'left-msb'); % 每8位重排成字节 msg = char(bi2de(h)'); end

逻辑说明:第一次调用只取32位,解析出nbits后第二次调用一次性读完头部加正文,最后按8位分组还原成字符。参数说明:bi2de的'left-msb'要和嵌入端de2bi方向一致,否则长度值会错乱。这套提取流程不需要知道载体原图,只依赖载密图和协议约定。

4.2 PSNR量化视觉差异:51dB是LSB替换的天花板

「看不看得出来」不能靠感觉。课程设计里最常用的客观指标是PSNR,直接用MSE换算:

function val = psnr_db(a, b) mse = mean((double(a(:)) - double(b(:))).^2); val = 10 * log10(255^2 / mse); end

对LSB替换,PSNR有个理论天花板:满嵌入时每个位以50%概率翻转,像素值变化0或±1,MSE期望0.5,对应PSNR≈51.14dB。这个值不随图像内容变化,是LSB替换算法本身的属性。部分嵌入时MSE按比例缩小,PSNR相应升高:

嵌入方案等效比例理论MSE理论PSNR
全通道满嵌入100%0.5051.14 dB
前50%像素50%0.2554.15 dB
仅蓝通道33.3%0.16755.91 dB

实操时如果算出来PSNR显著低于50dB,说明嵌入过程不干净:可能不小心改了高比特位,或者载体和载密图之间发生过格式转换。反过来,PSNR高于55dB也不能证明安全——它只衡量质量损失,不衡量统计特征。

4.3 提取乱码先查这三处

提取出乱码时,问题几乎总在协议不一致,而不是算法错。排错顺序我一般固定是:

  1. 位序。确认嵌入端bitget和提取端bitand用了同一约定。快速验证办法:嵌入一个已知字节0xAA(二进制10101010),提取后对比,LSB-first和MSB-first会得出完全不同的结果。
  2. 通道与展平顺序。MATLAB第三维是RGB;如果图在中间被imwrite或其他语言库重新保存过,通道顺序可能变成BGR,提取时位流整体错位。用Baboon.tiff做交叉测试最容易暴露这个问题。
  3. 格式链路过了一次有损压缩。TIFF保存时如果选了JPEG压缩选项,同样丢LSB。确认全程无压缩:读入用imread,写出用无损设置,或者直接改成PNG中间格式。

注意:乱码排查时不要先怀疑随机数,先做上述三项检查。LSB的嵌入和提取是确定性逆运算,只要协议一致,结果必然完全还原。

5. 反隐写视角:用卡方统计量验证LSB替换痕迹

5.1 为什么替换会暴露:灰度对趋于平衡

自然图像相邻灰度值(2k和2k+1)的像素数通常不平衡,因为真实场景里亮度分布是连续的,落在偶数与奇数灰度上的概率天然有偏差。LSB替换把每个像素的LSB强行写成秘密位,秘密位近似随机0/1,于是每个灰度对(2k,2k+1)的计数会被拉向均值。卡方统计量正是量化这种「过度均匀」的工具:统计量越大,图像越不像自然图。

这里要区分LSB替换和LSB匹配:替换是直接写0/1,像素只发生0或±1的确定性变化;匹配则在保持LSB为所需值的前提下随机±1,奇偶分布同样被拉平,但数值分布更接近自然,卡方统计的检出率会下降。这是两代隐写算法在统计特征上的本质差异。

5.2 一个可跑的卡方检测脚本

function pval = chi2_lsb_detect(img) if ndims(img) == 3 gray = rgb2gray(uint8(img)); % Lena/Baboon 都是 RGB,先转灰度 else gray = uint8(img); end h = imhist(gray, 256); % 256 级灰度直方图 obs = reshape(h, 2, 128); % 每列是 (2k, 2k+1) 计数 total = sum(obs, 1); valid = total > 0; % 没有像素的灰度对不参与统计 expc = total / 2; stat = sum((obs(1, valid) - expc(valid)).^2 ./ expc(valid)); pval = chi2cdf(stat, sum(valid) - 1, 'upper'); end

逻辑说明:imhist统计每个灰度级出现次数,reshape成2×128后每一列对应一个灰度对;卡方统计量用观测计数与期望计数(两者均值)的偏差平方除以期望求和。参数说明:返回值是上尾概率,p值越小越拒绝「自然图」假设,一般小于0.05就判定存在LSB替换痕迹。对lionvpo包生成的载密图跑一遍,嵌入前后的Lena会有数量级差异。这个检测的已知边界是:只对按固定顺序铺满全部像素的嵌入敏感,如果嵌入位置由密钥随机化,全局卡方会被稀释,取证场景里通常要做滑动窗口局部统计,再叠加RS分析做交叉确认。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询