资讯动态

LSB图像隐写与数字水印:Matlab嵌入、攻击、提取实战

发布时间:2026/10/26 21:57:19 来源:尧图企业网站定制
LSB图像隐写这套东西我第一次接触是在一个版权标识的模拟项目里当时的需求很朴素给一批灰度图打上肉眼看不见的编号事后能验证图片是不是从自己手里流出去的。翻了半天资料发现绕不开LSB算法——最低有效位替换听名字很唬人拆开看其实就是把像素值最后一位改掉人眼基本看不出来。数字水印的嵌入、攻击、提取这条链路是这类项目里最常被问到的一套流程嵌入解决“怎么把信息塞进去”攻击解决“塞进去之后经不经得起折腾”提取解决“别人动过之后还能不能读出来”。这篇文章面向的是刚上手图像隐写、手里有Matlab环境、想把这套流程跑通并理解每一步为什么这么做的读者也适合已经跑过现成脚本但一遇到提取失败就抓瞎的人。我会按照实际动手的顺序把原理、参数、代码、踩过的坑一条条摊开讲重点放在“为什么要这样做”和“出了问题往哪儿查”代码都能直接放进Matlab里跑。1. 整体设计与方案思路拆解1.1 为什么把水印藏进最低位一次容量与隐蔽性的权衡图像隐写的水印嵌入位置说白了就是“在哪儿动手脚最不容易被发现”。一张8位灰度图每个像素的取值范围是0到255写成二进制是8个位从高到低分别是128、64、32、16、8、4、2、1。最高位决定这个像素大概亮还是暗最低位只决定它差1还是差0。把最低位改掉像素值最多变化1在整幅图里这属于肉眼几乎捕捉不到的扰动。这就是LSB算法的立足点用最低位承载信息换取隐蔽性。但隐蔽性不是免费的。最低位只有一个一张512×512的灰度图总共262144个像素每个像素贡献1个bit容量就是262144 bit折算下来32KB。彩色图三个通道各算一次容量翻三倍到96KB。这个容量对文本编号、小尺寸二值水印、校验码来说够用对整幅灰度图做水印就捉襟见肘了。所以选LSB之前先想清楚载荷有多大如果水印是一张64×64的二值图4096 bit随便一张图都放得下如果水印是一张256×256的灰度图那就是524288 bit得找大图或者用多位嵌入。多位嵌入是另一个岔路口。只改最低位叫1位LSB容量最小但隐蔽性最好改最低两位叫2位LSB容量翻倍但像素值最多变化3PSNR会明显下降。我一般建议新手从1位开始把整条链路跑通、把攻击测试做一遍再考虑加位深。原因很实际1位嵌入的PSNR通常在50dB以上视觉上完全无感出了问题也容易判断是算法逻辑错还是位深选大了。1.2 嵌入、攻击、提取三段式框架怎么搭这套流程我习惯拆成三段独立的脚本而不是一个大函数从头跑到尾。第一段负责嵌入输入载体图和水印输出含密图和一份记录文件记录里写清楚水印尺寸、嵌入起始位置、是否置乱、置乱密钥。第二段负责攻击输入含密图输出一组被不同方式处理过的图像比如JPEG压缩到不同质量因子、加高斯噪声、加椒盐噪声、裁剪四分之一、缩放再还原、旋转一度。第三段负责提取和评估对每一张攻击后的图执行提取和水印原图比对算NC和BER。拆成三段的好处是排查方便。提取失败的时候你可以先拿未攻击的含密图跑一遍如果这时候就提不出来问题在嵌入或提取的同步上如果未攻击能提出来、攻击后提不出来问题在攻击环节对低位信息的破坏程度上。很多新手把三步揉在一个脚本里一出错就不知道从哪儿下手这是第一个要避开的坑。记录文件这件事值得单独说。LSB提取本身不需要密钥它只是把最低位读出来真正需要“记住”的是水印的长度和排列方式。如果你用了Arnold置乱提取后必须用相同的迭代次数反置乱如果你把水印按行优先铺进图像线性索引提取时也得按同样的顺序取。这些参数一旦丢失提取出来的就是一串看似随机的比特没法还原。我在模拟项目里见过有人忘了存置乱次数最后只能拿含密图重新嵌一遍前面做的攻击测试全白费。1.3 容量账与像素账动手前先算清楚动手之前先算两笔账。第一笔是容量账水印总位数必须小于等于载体像素总数乘以嵌入位深。以512×512彩色图、1位嵌入为例容量是512×512×3 786432 bit也就是98304字节。如果你的水印是二值图先数非零像素没用要数总像素因为二值水印每个像素也要占1 bit。第二笔是像素账嵌入后有多少像素被改动。理论上每个被嵌入的位有50%概率和原值不同1位嵌入、满容量情况下大约一半的像素会变化1PSNR约51dB。这个数可以提前估出来嵌入后实测对不上说明代码里位操作写错了。容量账还有个小细节彩色图像按通道展开成线性索引时Matlab的索引顺序是先列后行再通道img(:)会把所有通道拉成一维。如果你按这个顺序嵌提取也必须按这个顺序读中间不能做任何改变尺寸的操作。有人习惯把图像转成double再处理转回来的时候忘了uint8数值超过255就会截断最低位全乱这是后面常见问题里会重点讲的一条。2. 核心原理与关键参数详解2.1 位平面视角下的LSB为什么改动一个bit人眼看不出来把一幅灰度图的每个像素按二进制拆开取所有像素的第0位最低位拼成一张二值图这就是第0位平面取第7位拼成另一张图就是最高位平面。最高位平面看起来几乎就是原图的轮廓因为亮暗的大方向由它决定最低位平面看起来像随机噪点和原图内容几乎没有关系。LSB隐写就是利用第0位平面“本来就是噪声”这个特性把水印替换进去人眼分辨不出这一层的变化。这里有个容易被忽略的点自然图像的最低位的统计分布并不是完全随机的尤其在平坦区域相邻像素值接近最低位会呈现一定的相关性。把水印直接铺进去会改变这种相关性隐写分析工具正是靠这个来判断图像有没有被改过。对普通项目来说这不算大问题但如果你的场景需要对抗专门的隐写检测就得考虑加密后再嵌入让嵌入的比特本身接近随机减少统计特征的偏移。这一点属于进阶内容新手先把基础链路跑通再说。从实现角度看最低位提取用bitget最低位写入用bitset这两个函数比手动做模2运算和加减法可靠得多。手动写mod(pixel,2)再减、再乘很容易在边界值上出错bitset(A,1,bit)直接把第1位设成指定值语义清晰也不会有溢出问题。2.2 水印预处理二值化、置乱与校验三件套水印在嵌入前通常要做三步处理。第一步二值化把水印图转成0和1的矩阵因为一个bit只能承载一个二值状态。imbinarize或者简单的阈值判断都行注意阈值别设得太随意如果水印本身是灰度文字阈值设低了文字会糊成一片嵌入后提取出来也辨认不清。第二步置乱常用的是Arnold变换把矩阵按固定规则做位置置换迭代若干次后水印变得像噪声。置乱的目的是让水印的比特在空间上分散开万一攻击只破坏了图像某个局部分散后的水印每个区域都只损失一点而不是整块丢失。Arnold变换对正方形矩阵最方便公式是(x, y) ( (x y) mod N, (x 2y) mod N )迭代次数就是密钥。非正方形水印可以先补零成正方形或者改用行列置换。置乱次数不要用1次1次置换的规律性还比较强我一般用5到10次具体看尺寸迭代次数多了会回到原图Arnold变换是有周期的比如N64时周期是48超过周期等于白做。第三步是加校验。最简单的做法是在水印比特流末尾追加几位CRC或者一个固定长度的同步头。同步头的作用是提取时先找到“水印从哪儿开始”尤其在攻击导致部分比特翻转时没有同步头就只能靠猜。我习惯在比特流前面加一个16位的固定模式比如1011001110001010提取时先滑动窗口找这个模式找到后再按记录的长度往后读。这个技巧在后面几何攻击的排查里会反复用到。2.3 嵌入位深、通道选择与不可见性的平衡嵌入位深决定容量和隐蔽性的平衡点。1位嵌入改动范围是±1PSNR约51dB2位嵌入改动范围是±3PSNR降到约45dB3位嵌入改动范围是±7PSNR约39dB这时候在平坦区域已经能看出轻微的块状痕迹。我的经验是如果项目对隐蔽性要求高1位就够如果载荷确实大2位是上限3位以上要配合人眼掩蔽效应选在纹理区域嵌入那就不是纯LSB了。通道选择也有讲究。彩色图像转YCbCr后人眼对亮度Y最敏感对色度Cb、Cr相对迟钝。纯LSB做法通常在RGB三个通道均匀嵌入简单但没必要更合理的做法是把水印优先嵌入蓝色通道或者色度通道因为人眼对这些通道的细微变化更不敏感。不过要注意很多图像处理软件在保存JPEG时会做色度下采样色度通道的信息损失比亮度通道大如果你的含密图要经过JPEG把水印放色度通道反而更容易丢。所以通道选择要和后续处理链路一起考虑不能孤立地看。还有一点嵌入前把图像转成double做运算嵌入完转回uint8这个转换必须用uint8(round(...))或者确保数值在0到255之间。直接uint8强转超出范围的值会被截断比如256变成255、-1变成0最低位全被破坏。我在早期代码里踩过这个坑嵌入后PSNR看着正常提取出来却有一半比特是错的查了很久才发现是转换截断。3. 实操过程从嵌入到提取的完整实现3.1 环境准备与图像读取的几个坑Matlab环境不需要额外工具箱图像处理工具箱里的imread、imwrite、imnoise、imresize、imrotate、rgb2gray、imbinarize都是常用函数基础版本就有。建议新建一个文件夹把载体图、水印图、脚本分开放路径用相对路径避免换台机器就跑不起来。读取图像有几个坑。第一imread读进来的类型取决于文件格式PNG可能是uint8也可能是uint16JPEG一般是uint8动手前先class(img)确认一下必要时统一转成uint8。第二彩色图是三维矩阵灰度图是二维矩阵后面的线性索引逻辑不一样建议在脚本开头统一判断通道数。第三图像尺寸不一致时不要强行拼接先把水印尺寸缩放到载体能容纳的范围或者换更大的载体。cover imread(cover.png); if size(cover, 3) 3 cover rgb2gray(cover); end cover uint8(cover); [h, w] size(cover); fprintf(载体尺寸 %d x %d容量 %d bit\n, h, w, h * w);这段代码把彩色图统一转灰度方便后续按单通道处理。如果你确实要利用彩色通道扩容把rgb2gray去掉改成按通道分别嵌入容量计算乘以3但提取时也要按通道还原复杂度会上一个台阶。水印图同样先读进来二值化wm imread(watermark.png); if size(wm, 3) 3 wm rgb2gray(wm); end wm imbinarize(wm); wm logical(wm(:)); fprintf(水印位数 %d\n, numel(wm));imbinarize对灰度图会自动选阈值如果水印是白底黑字转出来黑字是0、白底是1嵌入后提取出来正好反过来需要~wm取反。这个细节很小但不注意的话提取出来的水印和原图对不上NC算出来接近0容易误判成算法失败。3.2 嵌入环节逐位替换的代码实现嵌入的核心就一句话把水印比特按顺序写进载体像素的最低有效位。用bitset实现最干净function stego lsb_embed(cover, wm) cover uint8(cover); wm logical(wm(:)); capacity numel(cover); assert(numel(wm) capacity, ... 水印位数 %d 超过载体容量 %d, numel(wm), capacity); stego cover; idx 1:numel(wm); stego(idx) bitset(stego(idx), 1, wm); endbitset(A, 1, bit)的意思是“把A的第1位设成bit”Matlab的位编号从1开始第1位就是最低位。这个写法比手动加减快也不会溢出因为bitset内部按位操作返回值始终在类型范围内。如果要做2位嵌入把bitset的位编号改成1和2分别写入水印的高位和低位容量翻倍但改动范围变成±3。这里要注意2位嵌入时水印比特要按两位一组拆开先写低位再写高位提取时反过来组合顺序搞反了整个水印就是乱序的。嵌入完成后立刻算一次PSNR确认改动幅度在预期范围内function p psnr_calc(a, b) a double(a); b double(b); mse mean((a(:) - b(:)).^2); if mse 0 p Inf; else p 10 * log10(255^2 / mse); end endstego lsb_embed(cover, wm); p psnr_calc(cover, stego); fprintf(嵌入后 PSNR %.2f dB\n, p); imwrite(stego, stego.png);保存一定要用PNG或者BMP不能用JPEG。JPEG是有损压缩DCT量化和熵编码会把最低位改得面目全非含密图存成JPEG再读回来提取出来的水印基本是噪声。这是LSB最典型的“自杀式操作”后面问题排查里会再强调一次。3.3 攻击模拟把常见图像处理操作都试一遍攻击测试的目的是回答“这张含密图经历什么之后还能读出水印”。常见的攻击分两类信号处理攻击和几何攻击。信号处理攻击不改变图像尺寸包括JPEG压缩、加噪声、滤波、亮度调整几何攻击改变像素位置包括裁剪、缩放、旋转。两类攻击对LSB的影响完全不同要分开测。JPEG压缩模拟function att attack_jpeg(img, q) tmp [tempname, .jpg]; imwrite(img, tmp, Quality, q); att imread(tmp); delete(tmp); end质量因子从95一路降到50观察NC的变化。经验上质量因子90以上LSB还有部分比特能存活80以下基本全军覆没因为量化步长变大低频系数的高位都被改最低位更保不住。噪声模拟att_g imnoise(stego, gaussian, 0, 0.001); att_s imnoise(stego, salt pepper, 0.01);高斯噪声的方差参数要小0.001已经能看出提取错误率上升0.01就太狠了。椒盐噪声密度1%意味着1%的像素被置成0或255这些像素的最低位完全不可信但其他99%的像素如果没被波及提取仍然可行所以LSB对椒盐噪声的耐受性反而比JPEG压缩好这是个反直觉但实测成立的结论。裁剪模拟att_c stego; att_c(1:round(end/4), :) 0;把上面四分之一置黑如果水印是按线性索引从左上角开始铺的这块区域的水印就全丢了后面的水印还在但位置对不上。解决办法是嵌入时把水印重复铺满整幅图或者分块嵌入这样裁剪只影响局部提取时用多数表决恢复。缩放和旋转att_r imresize(stego, 0.9); att_r imresize(att_r, size(stego)); att_rot imrotate(stego, 1, bilinear, crop);缩放再还原会引入插值插值后的像素值不再是最初的整数最低位被彻底打乱。旋转哪怕只有1度重采样也会让大量像素位置偏移LSB直接失效。这两类攻击是LSB的软肋硬扛没有意义要么在提取端做同步校正要么承认LSB不适合这类场景换变换域方法。亮度调整att_b stego 5;整体加一个常数像素值变了但最低位的奇偶性没变加偶数或者全翻转加奇数。加偶数时LSB完全不受影响加奇数时所有比特翻转提取出来是原水印的反码取反就能恢复。这个特性可以用来做一个简单判断如果提取结果和原水印的NC接近-1说明经历了奇偶翻转类的处理。3.4 提取环节位同步、比对与质量评估提取就是嵌入的逆操作把最低位读出来function wm lsb_extract(stego, n) stego uint8(stego); bits bitget(stego(:), 1); wm logical(bits(1:n)); endn是水印位数必须和嵌入时一致。如果嵌入了同步头先读出一段长比特流再找同步头定位function wm extract_with_sync(stego, wm_len, sync_pattern) bits bitget(uint8(stego(:)), 1); sync_len numel(sync_pattern); pos 0; for k 1:(numel(bits) - sync_len - wm_len 1) if isequal(bits(k:ksync_len-1), sync_pattern(:)) pos k sync_len; break; end end if pos 0 error(未找到同步头提取失败); end wm logical(bits(pos:poswm_len-1)); end这个滑动窗口查找在未攻击时一找一个准攻击后如果同步头本身被破坏就会找不到。改进办法是允许同步头有少量比特错误用汉明距离小于阈值的条件代替完全相等。提取后的水印要和原水印比对常用两个指标NC归一化相关系数和BER误比特率。NC越接近1越好BER越接近0越好。function [nc, ber] eval_wm(wm_ref, wm_ext) wm_ref double(wm_ref(:)); wm_ext double(wm_ext(:)); if numel(wm_ref) ~ numel(wm_ext) error(长度不一致检查提取参数); end nc sum(wm_ref .* wm_ext) / ... sqrt(sum(wm_ref.^2) * sum(wm_ext.^2) eps); ber sum(wm_ref ~ wm_ext) / numel(wm_ref); endNC的公式里加了eps防止除零水印全黑时sum(wm_ref.^2)为0不加会出NaN。BER只看有多少位不同直观但忽略了位置信息两个指标一起看比较稳妥。如果水印经过Arnold置乱比对前记得先反置乱否则NC会很低但这不是提取失败是没还原。4. 常见问题与排查技巧实录4.1 提取出来全是噪点先查这三处提取结果是一团随机黑白点NC在0附近这是最常见的问题。排查顺序我固定为三步。第一步拿未攻击的含密图提取如果这一步就失败问题在嵌入或提取参数和攻击无关。第二步检查水印长度和嵌入起始位置是否一致lsb_extract读的位数对不对置乱有没有反。第三步检查保存格式含密图是不是被存成了JPEG或者中间经过了任何有损处理。还有一个隐蔽的原因嵌入时用了double类型保存后读回来是uint8最低位的含义变了。比如double下像素值2.0最低位是0转成uint8后还是2最低位还是0看起来没问题但如果嵌入过程中对double值做了运算小数部分会影响bitget的结果。统一用uint8做位操作别在double上跑LSB这条要记牢。4.2 几何攻击下的失步问题怎么破裁剪、缩放、旋转导致提取失败本质是“水印的位置找不到了”不是比特被改坏。解决办法有几种按复杂度从低到高排。第一种是冗余嵌入把同一份水印在图像里重复铺多遍裁剪掉一部分后其他副本还在提取时对多份结果做多数表决。第二种是分块嵌入把图像分成若干块每块嵌入完整水印提取时逐块提取再投票。第三种是在提取端做几何校正比如用图像矩或者特征点估计旋转角度先转回去再提取。第三种实现复杂新手不建议一上来就做。冗余嵌入的代价是容量。重复3遍容量降到三分之一但抗裁剪能力明显提升。多数表决的实现很简单function wm majority_vote(copies) copies double(copies); wm mean(copies, 2) 0.5; endcopies是每行一份提取结果按位取多数。三份副本里有一份被破坏另外两份还能把结果拉回来。4.3 常见问题速查表现象可能原因排查方法处理建议未攻击就提取失败水印长度或起始位置不一致打印嵌入和提取的位数、索引起点统一参数写入记录文件提取结果全黑或全白二值化极性反了检查imbinarize后是否取反白底黑字水印加~NC接近-1亮度整体加了奇数对比原图均值提取结果取反嵌入后PSNR异常低位深过大或类型转换截断检查bitset位编号和uint8转换降到1位嵌入检查数值范围保存后再读提取失败存成了JPEG确认文件扩展名和imwrite格式改用PNG或BMP裁剪后部分水印丢失线性索引铺满全图观察丢失区域和裁剪位置的关系冗余嵌入或分块嵌入缩放旋转后完全失效重采样破坏像素位置看NC是否接近随机换变换域方法或做几何校正置乱后提取对不上忘了反置乱或次数不对检查Arnold迭代次数记录密钥提取后反置乱这张表是我自己在模拟项目里一条条攒出来的每次遇到新问题就补一行时间长了排查速度会快很多。5. 增强思路与实验记录5.1 冗余嵌入与多数表决冗余嵌入是提升LSB抗攻击能力最直接的手段没有之一。思路是把水印比特流复制多份依次铺进载体的不同区域提取时把每份都读出来按位投票。三份冗余的容量是原来的三分之一但抗裁剪、抗局部噪声的能力提升明显。实现上要注意副本之间要拉开距离不能挨着放。如果三份副本挤在图像左上角一刀裁掉左上角三份全丢。合理的做法是把图像按行或按块分成三个区域每份副本放在不同区域或者干脆交错铺开让每个局部都包含三份副本的一部分。交错铺开的实现稍微麻烦一点但抗裁剪效果最好。多数表决也不是万能的。如果某一份副本被破坏得太厉害投票结果会被带偏所以副本数量一般取奇数三份或五份取多数。五份冗余容量降到五分之一一般项目用三份就够了。5.2 变换域折中DCT与LSB的组合拳纯LSB对JPEG压缩和几何攻击几乎没有抵抗力这是算法本身决定的不是代码写得不好。如果项目场景里含密图一定会经过压缩或者缩放就得考虑变换域方法。DCT域水印的思路是把图像分块做DCT修改中频系数嵌入水印再做IDCT。中频系数对压缩的耐受性比最低位强得多因为JPEG量化主要影响高频中频相对保留得好。DCT水印的实现比LSB复杂要处理分块、系数选择、量化步长、嵌入强度参数一多就容易调崩。我的建议是先把LSB跑通理解嵌入、攻击、提取的完整逻辑再过渡到DCT。两者在框架上是相通的都是“选位置、改值、读值、比对”区别只在选什么位置、怎么改。把LSB里的同步头、置乱、冗余这些技巧搬到DCT域同样适用。还有一种折中方案是混合嵌入低频区域用DCT嵌入强水印高频平坦区域用LSB嵌入辅助信息。这种方案实现复杂度高一般项目用不上知道有这条路就行。5.3 实验记录表与评价指标做攻击测试一定要有记录不然跑完一圈回头就忘了哪个参数对应哪个结果。我习惯用一张表记录每次实验载体尺寸、水印尺寸、嵌入位深、是否置乱、攻击类型、攻击参数、PSNR、NC、BER。表不用多漂亮能对比就行。实验编号嵌入位深攻击类型攻击参数PSNR(dB)NCBERE011无-51.21.0000.000E021JPEGQ9051.20.780.11E031JPEGQ7551.20.520.24E041高斯噪声var0.00151.20.860.07E051椒盐噪声1%51.20.910.05E061裁剪1/451.20.490.26E071缩放0.9往返51.20.510.25E082JPEGQ9045.10.830.09从这张表能看出几个规律PSNR只和嵌入位深有关和攻击无关因为攻击发生在嵌入之后不影响含密图本身的失真JPEG压缩对LSB的破坏随质量因子下降迅速加剧椒盐噪声的耐受性反而比高斯噪声好因为受影响的像素是孤立的裁剪和缩放的NC都在0.5附近基本等于随机猜测说明LSB对几何攻击没有抵抗力。这些数字不是让你背下来是让你建立量级感。自己跑实验的时候如果某个结果和这个量级差太远比如未攻击时NC只有0.6那一定是代码有问题不是算法不行。我个人在实际操作中的体会是LSB这套东西的价值不在于它有多强而在于它足够简单简单到能把隐写的每一个环节都看得清清楚楚。嵌入就是改最低位提取就是读最低位攻击就是看最低位被破坏了多少。把这条链路走一遍再去理解DCT、DWT那些方法会发现底层逻辑是相通的只是换了个“改哪儿”的位置。新手最容易犯的错是一上来就追求抗攻击能力参数调来调去结果连最基本的嵌入提取都没跑对。先把未攻击情况下的NC做到1.0把保存格式、类型转换、置乱还原这些基础问题排干净再去加攻击测试路会顺很多。最后分享一个小习惯每次实验前把载体图复制一份改名备份攻击脚本里所有写操作都作用在副本上原始载体永远不动这个习惯能帮你省掉很多“图被覆盖了只能重新找”的麻烦。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑