简介:这是一份面向计算机相关专业毕业设计/课程设计的图像隐写分析与去除系统完整项目,基于SRNet与DDSP方法,使用Python实现,并提供图形界面与详细设计报告。资源包共169个文件,约7.77MB,涵盖47个Python源码、PyQt界面(.ui/.pyc)、pgm图像样本、xml配置以及docx设计报告、pptx答辩演示和md说明文档,结构清晰,便于直接运行与二次开发。目前已有68人学习,代码经过测试,运行稳定,适合AI、通信、电子信息等专业学生用于项目演示、作业提交或初学者进阶。除完整算法实现外,还附有设计报告编写参考、训练日志(events.out.tfevents)及测试输出,可帮助读者理解隐写分析流程并复现实验,具备一定基础后还能扩展功能以完成差异化设计。
1. 图像隐写分析与去除系统到底在解决什么问题
往一张风景照里塞一段文字,肉眼看不出来,这是图像隐写;把疑似塞了东西的图拿出来,判断它有没有被改过,这是隐写分析;再进一步,把隐藏的信息抹掉,这叫隐写去除。本科毕业设计里常见的一张牌是“SRNet+DDSP”:前一个网络做检测,后一个网络做去除。这篇给你一条能跑通的最小路径——从数据组织、训练命令到踩坑,照着做能交差,改一改也能当取证系统的原型。适合正在做类似毕设、或者想用现成方案快速验证图像隐写分析这套流程的人。
2. 从原理到选型:SRNet 检测、DDSP 去除,这条流水线为什么成立
2.1 隐写分析的检测本质:为什么只看像素不够
隐写分析有个很反直觉的前提:人在屏幕上看到的图像,和算法下判断时用的图像,不是同一个东西。LSB 替换是隐写里最基础的玩法,把每个像素的最低比特位换成消息位,肉眼看不出来,但直方图会出现成对的奇偶统计异常。稍微复杂一点的 LSB matching、WOW、S-UNIWARD 这类自适应隐写,会刻意把改动集中在纹理复杂区域,人眼和简单统计都很难抓到。
传统方法用 SRM(富模型)手工设计残差滤波器组,提取高维特征再喂给分类器。特征维度动辄几万,训练慢,换一个嵌入算法就要重新调特征。深度学习的方法本质上是在学残差:把原始图像过一组滤波器,得到“预测误差图”,网络在这个误差图上学区分“被改过”和“没被改过”。SRNet 这个名字在这个领域的意义,就是告诉你检测的关键不在像素本身,而在像素之间的局部相关性是不是被破坏了。
所以搭建这个系统时,第一件事是调整心态:不要指望一个普通分类网络直接吃三通道 RGB 就能跑出好指标。输入最好是灰度图,尺寸统一,训练时配合高通滤波预处理,让网络把注意力放在残差分量上。这决定了后面所有数据加载代码怎么写。
2.2 SRNet 和 DDSP 的分工依据:检测与恢复是两类任务
很多一开始做这个毕设方向的人会问:能不能用一个网络同时完成“检测+去除”?我的回答是:能拼成一条流水线,但不要试图训练一个端到端模型同时干两件事。原因是两个任务的目标函数是冲突的。检测器需要的是判别性,它对图像做的是“找异常”的归纳;去除网络需要的是重建性,它要做的是“把异常抹平”的生成。把这两个目标塞进同一个损失函数,工程上会反复打架。
常见做法是拆成两个独立网络,这也是这个标题里SRNet+DDSP这个组合的意义。SRNet 结构上类似带残差连接的深度卷积网络,输入一张图,输出一个二分类概率,或者一张与输入同尺寸的概率掩码,用来定位隐写发生的区域。DDSP 在这里通常指一个基于深度可分离卷积的去除网络,输入隐写图或隐写图+掩码,输出一张“被清洗”后的图像,目标是让输出图既可以被人眼接受,又无法被检测器识别出隐写痕迹。
这个分工还有一个工程好处:两个网络可以分别训练、分别调参。先训练 SRNet 拿到稳定的检测器,再固定检测器参数去训练 DDSP。检测器相当于给去除网络提供“考试标准”,去除网络反复试错,直到输出的图在检测器眼里和干净图没有区分度。这比同时训两个网络的可复现性高很多,也符合论文里做消融实验的习惯。
2.3 自建数据集与标注:LSB 嵌入脚本和 zip 数据组织
公开的隐写分析数据集不像 ImageNet 那么好找,多数毕业设计用的是 BOSSBase 这类载体图库,然后自己写脚本嵌入消息。这一步不要偷懒,因为后面所有指标都依赖数据是怎么生成的。
我一般会这样做:准备两个原始图库,一个训练用,一个验证用,互不重叠。训练时每次从图库里随机取一张灰度图,先统一缩放成 256×256,再随机决定要不要嵌入,以及用多大的嵌入率。嵌入率通常用 bpp(bits per pixel)表示,0.4 bpp 意味着平均每个像素有 0.4 个比特被改动。真样本和干净样本各占一半,避免类别不平衡。
数据准备阶段就要考虑分发问题。毕设打包时,整个数据集加代码汇总成一个 zip 分发给导师或队友是常见操作。这里有两个习惯值得养成:第一,zip 包内目录固定为data/raw、data/train、data/test、checkpoints、output五层,训练脚本读取路径写死相对路径,解压后不用改配置;第二,压缩时不设置密码,因为毕设答辩时需要当场解压演示,密码现场才想起来会非常窘迫。如果确实要加密,至少做到“压缩包密码”和“伪加密”区分清楚,这一点放在后面的避坑章节专门说。
3. 把检测器跑起来:SRNet 训练的最小可复现流程
3.1 环境准备:CUDA、PyTorch 与目录结构
先说能跑的硬件基线。SRNet 这个量级的网络,显存 6GB 以上的 NVIDIA 显卡就够训练,8GB 可以比较舒服地跑 batch size 16。实在没有 GPU,用 CPU 训 256×256 的图会慢到让人失去信心,不推荐。PyTorch 版本在 1.10 以上即可,CUDA 用 11.3 或者 11.6 都行,关键是和 PyTorch 版本匹配。
conda create -n stego python=3.8 -y conda activate stego pip install torch==1.10.0+cu113 torchvision==0.11.0+cu113 \ --extra-index-url https://download.pytorch.org/whl/cu113 pip install opencv-python numpy tqdm scikit-image依赖越少越好。opencv 用来读图和缩放,numpy 做嵌入操作,scikit-image 只用来算 PSNR 和 SSIM,tqdm 是训练进度条。装完依赖后建目录:
mkdir -p data/raw data/train data/test checkpoints output这样做的原因是后续所有脚本都会用相对路径,换机器解压 zip 后不用改代码。
3.2 数据加载器与 SRNet 训练循环
数据加载器里同时处理两件事:读图、制造隐写样本。训练集里没有预先标注好的“隐写图”,而是在读取时动态嵌入,这样每一轮 epoch 看到的隐写样本都不同,相当于无限扩增数据集。这也符合这个领域的一贯做法:隐写检测器的训练样本就是“干净图+实时嵌入的隐写图”的对。
import cv2 import numpy as np import torch from torch.utils.data import Dataset def embed_lsb(img, bits): """随机改动 n 个像素的最低位,注入随机比特流。 bits 是嵌入率,单位 bpp,0.4 表示每像素平均改 0.4 比特。 """ out = img.copy() h, w = out.shape n = int(h * w * bits) idx = np.random.choice(h * w, n, replace=False) y, x = np.unravel_index(idx, (h, w)) out[y, x] = (out[y, x] & 0xFE) | np.random.randint(0, 2, n) return out class StegoDataset(Dataset): def __init__(self, img_dir, bits_range=(0.2, 0.5)): self.paths = [os.path.join(img_dir, p) for p in os.listdir(img_dir)] self.bits_range = bits_range def __len__(self): return len(self.paths) def __getitem__(self, i): img = cv2.imread(self.paths[i], cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (256, 256)) bits = np.random.uniform(*self.bits_range) stego = embed_lsb(img, bits) label = 1 # 本类只返回隐写图,干净图由外部组合 return (torch.from_numpy(stego).float() / 255.0).unsqueeze(0), \ torch.tensor(label, dtype=torch.long)这段代码里的bits_range是对训练样本难度的控制。嵌入率越高,检测越容易;一直用 0.4 以上训练,模型对低嵌入率的样本会非常迟钝。我习惯让每一批数据在 0.2 到 0.5 bpp 之间随机,让网络见过不同难度的样本。
SRNet 的教学版实现可以简化成“stem + 一组残差块 + 全局池化”的结构:
import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, ch): super().__init__() self.body = nn.Sequential( nn.Conv2d(ch, ch, 3, padding=1, bias=False), nn.BatchNorm2d(ch), nn.ReLU(inplace=True), nn.Conv2d(ch, ch, 3, padding=1, bias=False), nn.BatchNorm2d(ch), ) self.relu = nn.ReLU(inplace=True) def forward(self, x): return self.relu(x + self.body(x)) class SRNet(nn.Module): def __init__(self, in_ch=1, base=16): super().__init__() self.stem = nn.Sequential( nn.Conv2d(in_ch, base, 5, padding=2), nn.ReLU(inplace=True), ) self.blocks = nn.Sequential(*[ConvBlock(base) for _ in range(8)]) self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(base, 2), ) def forward(self, x): x = self.stem(x) x = self.blocks(x) return self.head(x)这里故意把第一层卷积核设成 5×5,是因为隐写嵌入的影响往往覆盖相邻像素,太小的感受野可能只看到孤立噪声。残差块里必须带 BatchNorm,没有 BN 的版本在隐写这类微弱信号任务上收敛会慢很多。
训练循环本身不复杂:
from torch.utils.data import DataLoader import torch.nn.functional as F clean_ds = StegoDataset("data/raw", return_clean=True) # 返回干净图 stego_ds = StegoDataset("data/raw") combine = torch.utils.data.ConcatDataset([clean_ds, stego_ds]) loader = DataLoader(combine, batch_size=32, shuffle=True, num_workers=4) net = SRNet().cuda() opt = torch.optim.Adam(net.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(opt, step_size=10, gamma=0.5) for epoch in range(30): for x, y in loader: x, y = x.cuda(), y.cuda() opt.zero_grad() logits = net(x) loss = F.cross_entropy(logits, y) loss.backward() opt.step() scheduler.step() print(f"epoch {epoch} loss {loss.item():.4f}")交叉熵在这里足够。对二分类任务,不需要一开始就上加权损失。训练 20 到 30 个 epoch 后,验证集准确率一般能到 90% 以上,前提是数据没有泄漏,具体泄漏场景见避坑章。
3.3 阈值、嵌入率与检测指标:别只看准确率
毕业答辩时最常见的一个问题是“准确率多少”。但只报准确率在隐写分析里容易被质问,因为类别不平衡会骗人。如果你的验证集里干净图和隐写图各一半,准确率 90% 看起来很好,但把阈值调到只输出“干净”也能有 50% 准确率,这个指标说明不了检测能力。
我建议验证阶段固定三组数据:嵌入率 0.1、0.2、0.4 的隐写图各一批,加上一批干净图。然后画 ROC 曲线,重点看低嵌入率下的 AUC,以及阈值 0.5 时的虚警率。计算方式如下:
from sklearn.metrics import roc_auc_score, roc_curve net.eval() preds, labels = [], [] with torch.no_grad(): for x, y in val_loader: preds.append(net(x.cuda()).softmax(dim=1)[:, 1].cpu()) labels.append(y) preds = torch.cat(preds).numpy() labels = torch.cat(labels).numpy() print("AUC:", roc_auc_score(labels, preds)) fpr, tpr, _ = roc_curve(labels, preds)实际部署时,阈值不是 0.5。我的习惯是在验证集上找到虚警率低于 1% 时对应的阈值,把它作为系统的判定边界。这样在“声称某张图有隐写”时才比较有底气,宁可漏报不可误报——这个领域误报的代价通常更高。
训练完成后把模型权重存成checkpoints/srnet_best.pth,同时把验证集 AUC 和阈值记录在一个 txt 里。后面做 DDSP 去除效果评估时,这个检测器就是考官。
4. DDSP 隐写去除:参数怎么调、输出怎么验收
4.1 DDSP 结构:深度可分离卷积做去除的理由
去除网络的目标是“洗掉”隐写痕迹。注意,不是“还原”原图,而是“让图看起来干净且无法被检测”。这两个目标在实际操作中是有差别的。LSB 隐写改的是最低位,理论上可以无损还原;但 S-UNIWARD 这类方法引入的扰动是分布式的,无法精确恢复。所以去除网络本质上是一个带条件的图像重建任务。
DDSP 这种基于深度可分离卷积的结构,在去除任务上比普通残差网络更合适,原因有两点。第一,深度可分离卷积把空间相关性和通道相关性分开建模,参数量更低,对小数据集更友好——毕设场景下数据量本来就不大,大模型容易过拟合。第二,去除任务需要比较大的感受野,可分离卷积可以用 3×3 的深度卷积换更宽的感受野,在相同参数预算下更有利。
下面是一个可用的去除网络骨架:
import torch.nn as nn class DepthSepBlock(nn.Module): def __init__(self, ch): super().__init__() self.dw = nn.Conv2d(ch, ch, 3, padding=1, groups=ch) self.pw = nn.Conv2d(ch, ch, 1) self.bn = nn.BatchNorm2d(ch) self.relu = nn.ReLU(inplace=True) def forward(self, x): return self.relu(x + self.bn(self.pw(self.dw(x)))) class DDSP(nn.Module): def __init__(self, in_ch=1, base=32): super().__init__() self.pre = nn.Sequential( nn.Conv2d(in_ch, base, 3, padding=1), nn.ReLU(inplace=True), DepthSepBlock(base), DepthSepBlock(base), ) self.mid = nn.Sequential(*[DepthSepBlock(base) for _ in range(6)]) self.post = nn.Sequential( DepthSepBlock(base), nn.Conv2d(base, in_ch, 3, padding=1), ) def forward(self, x): return self.post(self.mid(self.pre(x))) + x尾部加了一个全局残差+ x,强制网络学习的是“需要抹除的扰动”,而不是从零重建整张图。这个设计直接简化了训练目标,输出图在结构上天然接近原图。
4.2 损失函数与训练参数表
去除网络的损失函数是这项工作最容易翻车的地方。只用 MSE,训练出来的图会过度平滑,人脸皮肤的纹理被磨掉,检测器可能确实认不出来了,但人眼一眼看出被处理过;只加对抗损失,又可能出现伪影。常见做法是三个损失加权组合:像素损失管整体亮度结构,感知损失管高频纹理,对抗损失管“是否像真实数据”。
def perceptual_loss(x, y, vgg): """取 VGG16 前两个 relu 层的特征做 L1 距离,更贴合视觉感知。""" fx = vgg(x) fy = vgg(y) return F.l1_loss(fx, fy) # 训练主循环核心 for stego, clean in loader: out = ddsp(stego) l_pix = F.mse_loss(out, clean) l_per = perceptual_loss(out, clean, vgg) l_adv = -torch.mean(disc(out)) loss = l_pix + 0.1 * l_per + 0.01 * l_adv opt.zero_grad() loss.backward() opt.step()这里l_adv需要配合判别器网络,完整代码会多一块。如果不想引入对抗训练,可以先把l_adv去掉,只用l_pix + 0.1 * l_per,训练稳定性会好很多,代价是纹理自然度略低。
下面这张参数表是我在类似项目里的经验值,可直接作为起点:
| 超参 | 常见取值 | 作用 | 调大/调小的表现 |
|---|---|---|---|
| 学习率 | 1e-4 | DDSP 主学习率 | 1e-3 时损失明显震荡 |
| batch size | 16 | 显存约 11GB | 过小导致 BN 不稳定 |
| 像素损失权重 | 1.0 | 保持整体结构 | 减小则颜色偏移 |
| 感知损失权重 | 0.1 | 保持纹理自然 | 过大则过度平滑 |
| 对抗损失权重 | 0.01 | 提升真实感 | 过大则出现彩色伪影 |
| 优化器 | Adam | 默认 betas | 不建议换 SGD |
训练 DDSP 的数据集和训练 SRNet 完全同源,但注意这里需要的是三元组:隐写图、干净原图、检测掩码。隐写图是输入,干净原图是监督信号,掩码只在需要引导注意力时用。掩码可以由 SRNet 的特征图阈值化得到,也可以直接从 LSB 嵌入位置生成——后者更准确,因为嵌入时记录了坐标。
4.3 去除效果验收:PSNR/SSIM 与剩余检出率
判断去除系统好不好,不能只看 PSNR。一张图 PSNR 很高,说明和原图差异小,但如果隐写信息还残留,检测器依然能认出来,那么这个去除就是失败的。反过来,PSNR 低但检测器认不出来,说明图被改得很重,虽然隐写被抹掉了,但图像已经不可用。
我习惯用两个维度同时验收:质量维度和安全维度。质量维度看 PSNR 和 SSIM,安全维度看不经过任何额外处理时,把输出图直接丢给训练好的 SRNet,统计被判定为隐写图的概率,记为“剩余检出率”。
def eval_removal(detector, ddsp, stego_dir, clean_dir): detector.eval(); ddsp.eval() remain = 0 total = 0 for name in os.listdir(stego_dir): x = load_image(os.path.join(stego_dir, name)) out = ddsp(x.unsqueeze(0).cuda()).squeeze(0).cpu() with torch.no_grad(): prob = detector(out.unsqueeze(0).cuda()).softmax(dim=1)[0, 1].item() if prob > 0.5: remain += 1 total += 1 print(f"剩余检出率: {remain / total * 100:.1f}%") # 同时计算 PSNR / SSIM验收的标准我会卡两条:剩余检出率降到 10% 以下,同时 PSNR 不低于 35dB。达到这两条,去除系统才具备“实用”的资格。如果只满足其中一条,说明损失函数权重或网络结构还需要调。
验证通过后,把去除后的输出统一放到output/cleaned目录,再压缩成一个 zip 交付。这一步的好处是导师或评审可以直接解压查看批量结果,而不需要自己跑代码。打包时建议保留一张映射表,标注每个输出文件对应哪个原始隐写图,方便复核。
5. 避坑清单:这类毕设最常见的五个翻车点
5.1 掩码错位:resize 插值让标签和特征对不上
现象:训练时损失一直下降,但验证集上检测器对每一张图都输出同一类,排查发现是掩码和特征图错位。 原因:原图是 512×512,构建数据集时用cv2.resize缩成 256×256,但掩码是用另一套脚本生成的,没有跟着缩放,或者缩放的插值方式不同,导致掩码和图像内容错位。 解决:不要在预处理里分离“图像缩放”和“掩码缩放”。把图像读进来后立刻统一尺寸,掩码用相同函数、相同插值方式变换。写数据加载器时强制断言img.shape == mask.shape,不匹配直接抛异常,宁可让训练中断,也不要带着错位数据跑完整轮。
5.2 数据泄露:同源图像重复出现在训练和测试里
现象:训练集准确率 99%,验证集准确率却只有 75%,两者差距大得不合理。 原因:原始图像只准备了几百张,划分训练集和测试集时没有保证严格互斥,测试集里有些图是从同一张原图缩放或裁剪出来的。隐写检测器对图像内容的记忆能力很强,见过相似的图就能“猜”出来。 解决:划分数据集时按原始文件名哈希取模,保证同一张源图衍生出的所有变体只进入一个集合。更严格的做法是准备两个完全独立的图库,一个只做训练,一个只做验证,连拍摄环境都不一样。
5.3 显存溢出与 BN 层失效:batch size 过小的连锁反应
现象:12GB 显存跑 batch size 32 直接 OOM,改成 8 之后训练损失开始剧烈震荡。 原因:BatchNorm 在小 batch 下统计量不稳定。隐写检测特征本身非常微弱,BN 统计量一旦震荡,整个网络学习信号就被噪声淹没。 解决:如果显存确实不够,优先降图像分辨率到 192×192,而不是降 batch size。还不行,就固定 BN 层的 running mean 和 running var,只在第一轮迭代统计一次。另一种做法是梯度累积,每 4 个小 batch 更新一次参数,等效拉大 batch。
5.4 zip 伪加密:压缩包打不开不是密码问题
现象:拿到手的数据集 zip 双击后提示需要密码,但资料说明里根本没给密码,或者在“本就无密码”的包上弹密码框。 原因:zip 伪加密。压缩包里有一个通用标志位被置为 1,但真实内容并未加密,解压软件会根据这个标志位弹出密码输入框。 解决:先用 7-Zip 打开,能直接看到目录说明内容大概率是伪加密。也可以用一个 Python 脚本把本地文件头和中央目录头里的加密标志位清零,重新保存后就能正常解压。
import struct def fix_fake_zip(src, dst): with open(src, "rb") as f: data = bytearray(f.read()) for sig in (b"PK\x03\x04", b"PK\x01\x02"): pos = 0 while True: pos = data.find(sig, pos) if pos < 0: break off = pos + 6 if sig == b"PK\x03\x04" else pos + 8 flag = data[off] | (data[off + 1] << 8) if flag & 1: flag &= 0xFFFE data[off] = flag & 0xFF data[off + 1] = (flag >> 8) & 0xFF pos += len(sig) with open(dst, "wb") as f: f.write(bytes(data))参数说明:PK\x03\x04是本地文件头签名,PK\x01\x02是中央目录头签名,两个位置里的通用标志偏移不同,所以代码里分了两套偏移。这个脚本只清理伪加密位,对真实加密包无效。
5.5 去除网络损失发散:MSE 当唯一指标的血泪
现象:DDSP 训练到第 3 个 epoch,loss 突然变成 NaN,之后每次重新启动,到同一位置就崩。 原因:对抗损失权重设成了 0.1,判别器收敛太快,把生成器损失压到无穷大;或者学习率太高,像素损失梯度爆炸。只盯着 MSE 看,看不出生成结果已经染上了彩色伪影。 解决:先关掉对抗损失,只用像素+感知损失把模型训到一个稳定水平,保存 checkpoint 后再加对抗项继续微调。训练时给梯度加clip_grad_norm_,限制在 5.0 以内:
torch.nn.utils.clip_grad_norm_(ddsp.parameters(), 5.0) opt.step()这个习惯能救回很多次训练事故,属于那种“看起来不起眼但必须写”的代码。
6. 验证系统有效性的三个进阶手段:跨域、消融与盲测
检测和去除都能跑通之后,毕设还差最后一步:证明这套系统不是只在自建的玩具数据上有效。我在审核类似项目时,第一眼看的是有没有做跨嵌入算法验证。只用 LSB 训练出来的模型,拿到 S-UNIWARD 生成的隐写图上大概率直接失效。正确的做法是留出一批从未参与训练的其他嵌入算法样本,比如 JSteg、LSB matching,跑一遍检测和去除,把结果作为泛化性证据放进报告。哪怕结果不理想,也比只报 LSB 一组数据诚实得多,评审反而会给分。
第二个手段是消融对比。分别记录四个配置下的“剩余检出率”:只有 SRNet 不接 DDSP、SRNet+像素损失版 DDSP、SRNet+像素+感知损失版 DDSP、完整版。表格里四行数据,清晰地展示每一层损失函数带来的增益。这类结果可复现性强,答辩时经得起追问,比“效果不错”这种话有说服力得多。
第三个手段是盲测。找一批不在训练图库里的真实照片,包括手机拍摄的、网络上抓下来的、经过压缩软件重新保存的。对这些图执行嵌入、检测、去除三步,统计系统对非理想输入的表现。这一步通常能暴露之前所有验证都没发现的退化:经过 JPEG 重压缩的隐写图,检测器会误报或漏报。
做这套验证的一个个人体会是:指标要提前定,不要等实验结果出来再挑好看的写。我在预实验阶段就把“剩余检出率<10%、PSNR>35dB、实验规模至少覆盖三种嵌入算法”这三条线刻在报告第一页,后面所有调参都朝着这三条线去。这样整个项目收尾时有明确达标判定,不会陷入“再训一个版本看看”的无限循环。这套流程走完,你手里那份打包好的毕设资料才真正有含金量,别人解压后能按同样的步骤复现,而不是看一套“仅此一次”的调参神话。希望这个方向的经验帮到你。
本文还有配套的精品资源,点击获取