尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

基于CNN的LSB隐写图像检测:PyTorch实现低嵌入率可靠识别方案

发布时间:2026/9/28 1:22:03

资讯中心
01
ARTICLE

基于CNN的LSB隐写图像检测:PyTorch实现低嵌入率可靠识别方案

基于CNN的LSB隐写图像检测:PyTorch实现低嵌入率可靠识别方案
简介图像隐写与隐写分析课程设计资源包适合需要完成相关课设或毕业论文的高校学生也可作为LSB算法与卷积神经网络结合入门的参考。项目从信息隐藏基本概念入手先实现基于Python的LSB空域替换算法再扩展到变换域JPEG系数的隐写嵌入同时提供以PDF文件为载体的信息隐藏实现后半部分聚焦基于深度卷积神经网络的隐写分析并在Matlab环境下与SRM、SCA等传统手工特征方法对比帮助理解不同检测思路的优势。压缩包共137个文件以py源码、m脚本、pdf文献和docx设计报告为核心另有ipynb实验记录、mat数据样本、prototxt模型配置及caj文献等资源包整体约304.81MB目录按源码、报告、数据、文献划分便于按需查阅。目前已有1135人学习下载覆盖“算法原理—实验复现—写作参考”完整链路对课程设计、综合实践或论文实验有较强的参考价值。1. 这份代码包到底在做什么CNN如何帮你找出LSB隐写的痕迹同样是“图片里藏了东西”LSBLeast Significant Bit隐写把秘密数据写进像素的最低一位人眼几乎无法察觉但最低位的统计规律会被破坏。传统直方图、RS分析在嵌入率较高时还能用一旦嵌入率降到0.2 bpp以下基本就靠猜。这个基于Python实现的卷积神经网络CNNLSB隐写分析方案把问题转成了图像二分类输入一张图片模型输出“干净”或“被LSB嵌入”。做安全取证、信息隐藏研究、以及想在CTF或业务审计里筛选可疑图片的人可以直接照搬这套数据生成、训练和评估流程。它不依赖外部指令特征也不需要事先知道秘密内容是当前落地最稳的盲检路线之一。2. LSB隐写检测为什么非要用网络先搞懂嵌入与检出的基本空间2.1 LSB替换改的是哪一位以及嵌入率对检测难度的影响LSB替换隐写简单说就是把图像每个像素的二进制最低位改成秘密比特。对彩色图就是R、G、B三个通道各自的最低位对灰度图就是唯一通道的最低位。改完之后的像素值只会变化1因为最低位贡献的数值是1所以人眼在正常显示下基本看不出差异但这并不意味着图像没有变化。检测难度的核心参数叫嵌入率单位是bppbit per pixel。它的定义是实际嵌入的秘密比特总数除以图像所能承载的最大比特数。灰度图容量是宽乘高彩色图容量再乘3。嵌入率0.5意味着有一半可用的最低位被随机替换成了秘密数据嵌入率0.1则只有十分之一被替换。替换比例越低和原始图像自身的噪声混在一起后LSB痕迹就越弱。这是我一开始容易忽略的地方同样是LSB嵌入率0.8和嵌入率0.1检测难度完全不是一个量级。所以后面所有实验都要把嵌入率作为参数显式控制。2.2 直方图和RS分析的边界低嵌入率是传统方法的软肋传统检测方法里最直观的是对灰度图像做像素值直方图。比如统计0~255每个值出现次数LSB替换后相邻偶数和奇数灰度的频次会被拉平。原因是秘密比特0和1等概率出现相当于把原图像最低位的0/1分布向0.5均匀化。用这个双峰对Pair of Values的变化可以构造一个简单的统计量做判别。RS分析Regular/Singular groups稍微高级一些它把图像分成固定小像素块用翻转函数计算扰动后各组比例的变化从而估计嵌入率。它比直方图稳但依然依赖对图像自然统计的先验假设。碰到嵌入率0.1以下、或者图像内容本身纹理极丰富、噪声极强的情况这两种方法的输出基本接近随机猜测。这也是很多老伙计明明写了分析脚本最后发现准确率不过半的原因——不是算法写错是信号太弱传统特征提取不够。2.3 CNN凭什么能捡回这些信号残差特征与模型设计思路CNN的优势在于它把“盯着直方图”升级成“扫描局部空间模式”。LSB嵌入给图像注入的噪声很小但噪声的空间分布并不是随机的图像内容而是秘密比特自己的模式。卷积核可以近似高通滤波器把像素和周围像素的差异残差提取出来再在残差上做统计建模网络就能捕捉到那种“被刻意改过的痕迹”。我一般会在网络入口处先过一层固定权重的3x3高通滤波核比如中心为4、上下左右为-1让第一层直接输出残差图而不是让网络在原始像素上盲学。这样做的理由是LSB痕迹在残差空间里比在原始像素空间里更明显。网络后续再叠加卷积层就是在残差图上学高阶统计。对于一份入门级项目这比直接扔一个ResNet回去更贴近隐写分析的核心思路。后面第四章的模型就是按这个思路搭的。3. 准备训练集自己生成LSB隐写样本并按图像分组拆数据3.1 目录设计和样本生成脚本cover/stego一一对应训练一个隐写分析模型最忌讳的就是去网上找一堆人家已经生成好的“隐写数据集”。因为你不知道嵌入率、不知道有没有二次压缩、不知道正负样本是否同源这些都会让训练变成黑匣子。正确做法是自己准备一批干净图片cover载体图在用脚本生成对应的隐写图stego。目录结构我一般是这样data/ cover/ # 原始干净图片PNG或BMP stego_0.5/ # 嵌入率0.5的隐写图文件名与cover完全对应 stego_0.2/ # 嵌入率0.2的隐写图 test_split.txt # 测试集图像ID列表 train_split.txt # 训练集图像ID列表生成隐写样本的核心逻辑是读取封面图把像素按位展开取其中一部分最低位替换成随机秘密比特再保存成PNG。这个脚本只能自己写不能用网上现成的GUI工具因为你要精确控制嵌入率。下面这段是我常用的生成函数。import numpy as np from PIL import Image import os import random def build_lsb_stego(cover_path, stego_path, embed_rate0.5, seed42): # 统一以灰度图读入减少通道顺序问题 img Image.open(cover_path).convert(L) img_arr np.array(img).copy() h, w img_arr.shape # 总的可用比特数 像素总数 total_bits h * w # 按嵌入率确定实际嵌入多少位 n_embed int(total_bits * embed_rate) # 用固定随机种子生成秘密比特和嵌入位置便于复现 rng np.random.RandomState(seed) secret_bits rng.randint(0, 2, sizen_embed) positions rng.permutation(total_bits)[:n_embed] # 把图片展平成一维按位置替换最低位 flat img_arr.ravel() flat[positions] (flat[positions] 0xFE) | secret_bits stego_arr flat.reshape(h, w) # 保存必须用PNG不能用JPEGJPEG会破坏LSB痕迹 Image.fromarray(stego_arr.astype(np.uint8)).save(stego_path) # 执行示例把data/cover下所有图片生成嵌入率0.5的stego for fname in os.listdir(data/cover): if fname.endswith((.png, .bmp)): build_lsb_stego( os.path.join(data/cover, fname), os.path.join(data/stego_0.5, fname), embed_rate0.5, seedrandom.randint(0, 100000) )代码里最关键的是flat[positions] (flat[positions] 0xFE) | secret_bits这一句0xFE是二进制11111110按位与之后最低位被置0再用按位或把秘密比特写进去最后只改变最低位。embed_rate控制替换比例seed保证同一个封面生成的stego可以复现。3.2 嵌入率参数化同一张图生成多档难度的样本单一嵌入率训练出来的模型换一个嵌入率的测试集准确率会掉得很厉害。所以我通常会对每张cover生成多档stego比如0.1、0.2、0.5、0.8四档然后混合在一起训练。这样模型学到的不只是某一个特定强度下的痕迹而是LSB替换这个操作本身的统计特征。注意不同嵌入率的stego不能放在同一个目录下当普通图片用一定要在文件名里带上嵌入率标记比如00001_0.5.png。训练时从文件名解析嵌入率能用来分析模型在不同难度下的表现。如果生成数据时没有记录嵌入率后期排查会很痛苦相当于给自己留了个后悔药却不吃。另外生成时不要把同一个随机种子用在所有图片上。每张图应该用不同seed生成秘密比特否则模型可能学到固定的秘密序列特征而不是LSB痕迹。训练后再用新seed生成测试集验证模型是否真的泛化。3.3 按图像ID划分数据断开同源样本的“作弊通道”这是整个数据准备里最容易被忽略、却最致命的一步。假设你有100张cover和对应的100张stego如果直接随机划分训练集和测试集那么同一张cover的stego很可能既出现在训练集又出现在测试集。CNN会走捷径——记住图像内容而不是LSB痕迹测试准确率虚高到接近100%。正确的划分维度是“图像ID分组”。也就是说把cover和它的所有stego看作一个不可切割的组训练集和测试集只能按组划分。下面的代码示范了怎么处理import os from sklearn.model_selection import train_test_split def build_split(cover_dir, test_ratio0.2, seed123): ids [] for fname in os.listdir(cover_dir): # 去掉扩展名作为唯一图像ID ids.append(os.path.splitext(fname)[0]) ids sorted(ids) train_ids, test_ids train_test_split( ids, test_sizetest_ratio, random_stateseed ) with open(train_split.txt, w) as f: f.write(\n.join(train_ids)) with open(test_split.txt, w) as f: f.write(\n.join(test_ids)) print(ftrain{len(train_ids)}, test{len(test_ids)}) build_split(data/cover, test_ratio0.2)训练时只需要在Dataset里根据这个split文件过滤样本。划分完成后用set()检查一下train和test的ID没有交集这一步值得花30秒写个断言。数据泄露如果发生在隐写分析里模型训练完就是一个过拟合记忆器真正部署时遇到新的干净图片立刻原形毕露。4. 用PyTorch训练一个轻量CNN网络结构、数据加载与评估4.1 模型结构选择先跑通小网络再考虑SRNet隐写分析领域有专门设计的SRNet、Zhu-Net等深度网络但在一份入门级项目里先跑通一个可解释的轻量CNN更有价值。我用的是一个带高通前置的两卷积块结构第一层是固定的3x3高通滤波核后面接卷积BNReLU做特征提取最后全局平均池化二分类。如果这个结构在嵌入率0.5下能稳定跑到90%以上再往上换SRNet不迟。import torch import torch.nn as nn def make_highpass_kernel(): # 3x3高通滤波中心4上下左右-1其他0 kernel torch.zeros(1, 1, 3, 3) kernel[0, 0, 1, 1] 4.0 kernel[0, 0, 0, 1] -1.0 kernel[0, 0, 2, 1] -1.0 kernel[0, 0, 1, 0] -1.0 kernel[0, 0, 1, 2] -1.0 # 固定权重不参与训练 return nn.Parameter(kernel, requires_gradFalse) class LSBStegoNet(nn.Module): def __init__(self): super().__init__() self.highpass nn.Conv2d(1, 1, kernel_size3, padding1, biasFalse) self.highpass.weight make_highpass_kernel() self.features nn.Sequential( nn.Conv2d(1, 16, 3, padding1, biasFalse), nn.BatchNorm2d(16), nn.ReLU(inplaceTrue), nn.Conv2d(16, 32, 3, stride2, padding1, biasFalse), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 64, 3, stride2, padding1, biasFalse), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), ) self.pool nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(64, 2) def forward(self, x): x self.highpass(x) # 输出残差图 x self.features(x) x self.pool(x).flatten(1) x self.fc(x) return x这个模型的参数总共不到6万单卡CPU都能训练。高通卷积固定权重不更新保证网络第一层永远在提取残差而不是学成一堆无关的边缘检测器。stride2的下采样代替池化减少信息丢失。输入图片尺寸统一到128x128显存占用很低。4.2 数据加载与增强只做无损变换Dataset要同时加载cover和stego。通常做法是一个batch里一半cover一半stego或者把stego取正样本标签1、cover取标签0。数据增强只允许两种随机水平翻转和随机裁剪。这里的“随机裁剪”必须是先裁剪到某个尺寸而不做缩放因为任何插值都会改变像素值可能抹掉LSB痕迹。from torch.utils.data import Dataset import torchvision.transforms as T class PairDataset(Dataset): def __init__(self, split_file, data_roots, size128): self.image_ids open(split_file).read().splitlines() # data_roots {cover: ..., stego_0.5: ...} self.data_roots data_roots self.size size def __len__(self): return len(self.image_ids) * 2 def __getitem__(self, idx): # 偶数索引取cover奇数索引取stego保证batch内正负各半 is_stego idx % 2 1 file_id self.image_ids[idx // 2] if is_stego: img_path os.path.join(self.data_roots[stego_0.5], file_id .png) label 1 else: img_path os.path.join(self.data_roots[cover], file_id .png) label 0 img Image.open(img_path).convert(L) # 先随机裁到目标尺寸不做任何resize img T.RandomCrop(self.size)(img) img T.RandomHorizontalFlip()(img) img T.ToTensor()(img) return img, labelDataset里偶数为cover、奇数为stego这样DataLoader的batch会自动保持1:1省去再写sampler的麻烦。RandomCrop自动把大图裁成128x128每次都可能裁到不同区域等效于增加了样本量但没有引入插值误差。ToTensor会除以255属于无损归一化不会破坏LSB痕迹。4.3 训练循环与超参数让loss在30个epoch内收敛训练脚本不复杂但超参数要对路。优化器用Adam初始学习率1e-3weight_decay用1e-4防过拟合损失函数用CrossEntropyLoss输出logits不需要手写softmax。批次大小6430个epoch。下面给出主训练循环。import torch.optim as optim def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss, correct, total 0, 0, 0 for x, y in loader: optimizer.zero_grad() out model(x) loss criterion(out, y) loss.backward() optimizer.step() total_loss loss.item() pred out.argmax(1) correct (pred y).sum().item() total y.size(0) return total_loss / len(loader), correct / total model LSBStegoNet() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) train_loader DataLoader( PairDataset(train_split.txt, data_roots), batch_size64, shuffleTrue ) val_loader DataLoader( PairDataset(test_split.txt, data_roots), batch_size64, shuffleFalse ) for epoch in range(30): loss, acc train_one_epoch(model, train_loader, criterion, optimizer) if (epoch 1) % 5 0: print(fepoch {epoch1}: loss{loss:.4f}, acc{acc:.4f}) if acc 0.97: break训练循环里的out.argmax(1)是取二分类输出概率最高的类。准确率只作粗筛条件真正评估要放到下一节的指标里。30个epoch后准确率如果还在50%附近不要去调学习率先回去查数据划分和嵌入率。这两个问题不解决调参是玄学。4.4 评估输出准确率不是唯一指标还要看F1隐写分析是正负样本严格1:1的二分类准确率看着还行但F1更能反映模型的平衡能力。尤其当你把多档嵌入率混在一起训练低嵌入率的stego会拖低召回率。评估脚本必须输出混淆矩阵、precision、recall、F1。from sklearn.metrics import precision_recall_fscore_support, confusion_matrix def evaluate(model, loader): model.eval() all_pred, all_y [], [] with torch.no_grad(): for x, y in loader: out model(x) all_pred.extend(out.argmax(1).tolist()) all_y.extend(y.tolist()) p, r, f1, _ precision_recall_fscore_support( all_y, all_pred, averagebinary, pos_label1 ) cm confusion_matrix(all_y, all_pred) print(fP{p:.4f}, R{r:.4f}, F1{f1:.4f}) print(confusion matrix:) print(cm)F1低于准确率通常意味着模型在拼命把图片判成stego来刷准确率这在部署时会弄出大量误报。我一般要求F1和准确率差距不超过2个百分点否则就调一下判决阈值。也可以用阈值扫描让业务方自己决定是宁可漏报还是宁可误报。5. 避坑专项我在LSB隐写分析里最常见的5个翻车点5.1 现象训练集准确率99%测试集只有51%原因数据划分时没有按图像ID分组同源cover和stego混进了两个集合。CNN发现了“这两张图内容完全一样”这个捷径直接记忆图像内容而没有学LSB痕迹。这是我见过最多的翻车现场没有之一。解决重写划分逻辑用split文件按cover ID分组。划分后用set(train_ids).isdisjoint(set(test_ids))断言一次输出False就报错。生成stego时文件名保持和cover一致就是为了让这个断言能跑通。5.2 现象换一批图片准确率掉到50%之前白训了原因训练数据里大量图像经过了JPEG压缩或者直接用了JPEG格式的封面图。JPEG是有损格式像素值已经被块效应干扰把LSB最低位那点痕迹完全盖住了。模型学到的其实是JPEG压缩痕迹和LSB痕迹的混合体。解决数据源头只用PNG、BMP这种无损格式。如果手头只有JPEG图片先用图像处理库转成PNG但要注意转换过程本身就是一次有损解码转出来的图也别指望能提升检测效果。最稳妥的办法是下载一批高分辨率PNG原图重新切。5.3 现象同一个数据PIL读和OpenCV读结果完全不同原因PIL的convert(L)和OpenCV的cv2.imread(..., 0)在灰度转换公式上基本一致但OpenCV默认按BGR读彩色图PIL按RGB。如果生成stego时用PIL训练读取时用OpenCV读彩色图通道顺序错位会导致同一个位置像素值对不上相当于给模型灌了错误标签。解决全流程统一读图方式。我习惯全部用PIL因为集成了Image打开、灰度转换、保存PNG通道语义明确。如果项目里非得用OpenCV那就在生成和训练两边都显式cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)不要依赖默认。5.4 现象加了数据增强后模型不收敛准确率在0.5附近抖动原因用了RandomResizedCrop或T.Resize这类带缩放的增强。缩放过程中插值算法改变了像素值相当于把很多样本变成了“既不是cover也不是stego”的第三类模型完全被搅晕。解决增强只保留RandomCrop和RandomHorizontalFlip。如果输入尺寸不统一先做一次无损CenterCrop到可接受大小再走随机裁剪。绝对不要resize。这条规则对隐写分析通用也是我最常提醒别人的一条。5.5 现象loss下降正常但测试准确率一直上不去原因嵌入率设太低或者测试集嵌入率与训练集不一致。比如训练集全是嵌入率0.8测试集都是0.1模型在不同强度间不泛化。也可能是秘密比特生成时固定了同一个seed模型偷偷记住了秘密序列。低嵌入率本身就难0.1以下任何模型都会接近随机这不算修复bug而是任务难度上限。解决在文件名里记录嵌入率按0.2/0.5/0.8混合训练。测试时逐嵌入率看准确率曲线才能看出哪里是模型的问题、哪里是任务本身的物理极限。生成秘密比特时每张图换seedseed随文件名走方便复现但不固定。6. 进阶验证用嵌入率梯度测试确认模型学的不是图像内容6.1 多档嵌入率测试脚本模型训练完不要只测一个嵌入率就下结论。我固定会跑一遍“嵌入率梯度测试”用同一个封面集按0.05、0.1、0.2、0.3、0.5、0.8、1.0生成七组stego让模型对每组分别算准确率。如果模型真的学到了LSB痕迹准确率会随着嵌入率上升单调上升在0.5左右明显变大如果曲线平得像条直线那就说明模型大概率在靠图像内容分类。def test_embed_rate_curve(model, cover_dir, split_file): import numpy as np rates [0.05, 0.1, 0.2, 0.3, 0.5, 0.8, 1.0] for rate in rates: stego_dir fdata/stego_tmp_{rate} os.makedirs(stego_dir, exist_okTrue) # 对每个测试ID生成当前嵌入率的stego for fid in open(split_file).read().splitlines(): build_lsb_stego( os.path.join(cover_dir, fid .png), os.path.join(stego_dir, fid .png), embed_raterate, seedhash(fid) % 100000 ) acc evaluate_simple(model, cover_dir, stego_dir) print(frate{rate:.2f}, acc{acc:.4f})注意这里的build_lsb_stego和第三章相比没有变化只是每次换嵌入率生成临时目录。跑完这个曲线如果发现0.05附近接近50%、0.8附近90%以上这条曲线本身就是你的模型报告。6.2 对照实验与我的收尾习惯另一个很值得做的对照是LSB匹配嵌入也就是把最低位改成随机增加或减少1。LSB替换和LSB匹配在直方图上的表现完全不同如果你的模型对LSB匹配也有一定检出率说明它学到的是更广义的“像素扰动痕迹”这对部署更有利。我习惯把所有评估结果记录成一张表随模型一起保存。嵌入率 | 准确率 | 召回率 | 备注 0.20 | 0.71 | 0.68 | LSB替换 0.50 | 0.93 | 0.91 | LSB替换 0.80 | 0.97 | 0.96 | LSB替换这个动作不那么热闹但能让模型的可信度变得透明。以前我总爱在训练结束后直接看测试集准确率后来被一次同源数据泄露的翻车教育过才意识到“模型对图像内容不敏感”比“在某份测试集上准”重要得多。现在这套曲线和对照实验已经成了我的固定动作希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。