简介:面向图像隐写与隐写分析课程设计及毕业设计场景,这套以 Python 语言实现最低有效位信息隐藏与卷积神经网络隐写分析的完整资源包,覆盖空域与变换域 JPEG 信息隐藏、PDF 文件信息隐藏等扩展方向,适合需要在真实图像与文档数据上复现算法、对比隐写分析指标并完成设计报告的学生。压缩包共 137 个文件、约 304.81MB,内容以 m/py 源码、ipynb 实验笔记和 docx 设计报告为主体,并配有 pdf/caj 参考文献及 png、prototxt 等辅助文件,目录结构清晰,便于按代码、文档、文献三大模块快速检索。目前已有 1135 人学习,在隐写分析方向具有较高参考热度。除最低有效位嵌入与提取外,还提供基于卷积神经网络的隐写分析模型、Matlab SRM/SCA 分析代码以及 PDF 隐藏实现,配合设计报告、实验笔记与大量文献资料,可支撑从原理理解、算法复现、指标对比到报告撰写的完整学习闭环。
1. 用 CNN 做 LSB 隐写分析:这个标题到底在解决什么问题
一张 512×512 的风景照,人眼看不出任何异常,但二进制文件里每一个像素的最低有效位可能已经被替换成了另一段信息。这就是基于 LSB(Least Significant Bit)的信息隐藏。而“隐写分析”要做的,是在不提前知道密钥、不对比原始载体的情况下,判断一张图里是否藏了数据。标题里把 Python、CNN 和 LSB 放在一起,本质上是训练一个二分类卷积网络:输入一张疑似隐写图像,输出它是正常载体还是经过 LSB 嵌入的隐写图像。传统方法在低嵌入率下检测力骤降,CNN 能自动捕捉最低位平面上的统计扰动,这也是这个方向能落地成代码包的原因。这个方案适合三类人:做数字取证的从业者、研究信息隐藏对抗的在校学生、把深度学习往安全场景落的工程师。
2. 隐写数据准备:把 LSB 嵌入做成一键批量化工具
CNN 是有监督模型,第一步永远是造数据。隐写分析的数据集构造比普通图像分类多一层:负样本是干净原图,正样本必须由我们自己用 LSB 算法嵌入生成。这一步做得不严谨,后面所有训练结果都不可信。
2.1 LSB 嵌入为什么是隐写分析的“基准靶子”
LSB 嵌入的原理一句话就能说清:把秘密信息的二进制位,逐个覆盖到像素值的最低一位上。一个 8 位灰度值 200(二进制 11001000),最低位是 0,如果把秘密位 1 写进去,就变成 201(11001001)。RGB 图像每个像素有 R、G、B 三个通道,每个通道各有 1 个最低位,所以一个像素最多能藏 3 bit 信息。
嵌入后图像在视觉上几乎无差别,因为最低位只改变像素值的 1/255,人眼无法分辨。但它改变了像素的统计分布——例如相邻像素最低位的相关性、颜色通道之间的关联性都会被破坏。隐写分析就是抓住这种统计扰动。CNN 相比传统卡方检验和 RS 分析的最大优势在于:它不需要人工设计统计特征,而是直接从大量正负样本里学出“什么样的最低位模式是自然的,什么样的模式是嵌入过的”。
2.2 最小可跑的 LSB 嵌入函数
我通常直接用 NumPy 写嵌入函数,不引入额外依赖。下面这个版本支持指定嵌入位数和随机种子,方便复现。
import numpy as np from PIL import Image def lsb_embed(img_path: str, payload: bytes, n_bits: int, seed: int = 42) -> np.ndarray: """ 将 payload 的二进制位嵌入 img 随机像素的最低有效位。 img_path: 载体图像路径 payload: 需要隐藏的原始字节 n_bits: 总共嵌入多少位,必须小于等于 h*w*c seed: 随机种子,保证每次嵌入选择的像素位置一致 """ img = np.array(Image.open(img_path).convert("RGB")).astype(np.uint8) h, w, c = img.shape total = h * w * c assert n_bits <= total, "嵌入位数超过图像容量" # 把 payload 展开成比特流 bit_stream = [] for byte in payload: for i in range(8): bit_stream.append((byte >> (7 - i)) & 1) # 比特流不足时用随机位补齐 rng = np.random.default_rng(seed) while len(bit_stream) < n_bits: bit_stream.append(int(rng.integers(0, 2))) # 随机挑选 n_bits 个像素通道位置 positions = rng.choice(total, n_bits, replace=False) flat = img.reshape(-1) for idx, bit in zip(positions, bit_stream[:n_bits]): # 清除最低位后写入新位 flat[idx] = (flat[idx] & 0xFE) | bit return img.reshape(h, w, c)这段逻辑里有三个关键点。第一,(flat[idx] & 0xFE) | bit是 LSB 替换的标准写法:0xFE是 11111110,按位与后把最低位清零,再按位或写入秘密位。第二,位置随机选择很关键——如果总是从图像左上角开始顺序嵌入,网络会直接学会“看左上角”,而不是学会“判断统计扰动”。第三,replace=False保证同一个像素通道不会被重复嵌入两次,避免后写入的位覆盖先写入的位。
调用方式如下:
img = lsb_embed("cover.png", b"hidden message", n_bits=3000, seed=7) Image.fromarray(img).save("stego_seed7.png")这里n_bits=3000表示只嵌入 3000 bit。对于一张 512×512 的 RGB 图,总容量是 786432 bit,3000 bit 相当于嵌入率 0.0038 bpp(bit per pixel),属于极低嵌入率,人眼和多数传统检测器都很难发现。
2.3 批量生成训练集:两个目录搞定数据划分
单张手工生成没有意义,训练 CNN 需要成百上千张。我一般按下面的流程批量构造:准备一个干净的载体图目录,对每张图以预设的多个嵌入率生成隐写图,再把载体图和隐写图分别放进cover与stego目录。标签由目录隐式给出,读取时不需要额外维护 CSV。
import os import numpy as np from PIL import Image def build_dataset(src_dir: str, out_dir: str, rates=(0.1, 0.2, 0.4, 0.8, 1.2), per_rate: int = 200, seed: int = 42) -> None: """ 批量生成训练数据。 src_dir: 载体图像目录,支持 jpg/png out_dir: 输出目录,会创建 cover 和 stego 两个子目录 rates: 嵌入率列表,单位 bpp;0 表示不嵌入 per_rate: 每种嵌入率生成多少张隐写图 """ cover_dir = os.path.join(out_dir, "cover") stego_dir = os.path.join(out_dir, "stego") os.makedirs(cover_dir, exist_ok=True) os.makedirs(stego_dir, exist_ok=True) files = [f for f in os.listdir(src_dir) if f.lower().endswith((".png", ".jpg", ".jpeg"))] assert len(files) >= 1, "载体图目录为空" rng = np.random.default_rng(seed) global_idx = 0 for rate in rates: for img_name in files[:per_rate]: img_path = os.path.join(src_dir, img_name) img = np.array(Image.open(img_path).convert("RGB")).astype(np.uint8) h, w, c = img.shape n_bits = int(h * w * c * rate) if n_bits == 0: Image.fromarray(img).save( os.path.join(cover_dir, f"cover_{global_idx:05d}.png")) else: payload = os.urandom(max(1, n_bits // 8)) embedded = lsb_embed(img_path, payload, n_bits, seed + global_idx) Image.fromarray(embedded).save( os.path.join(stego_dir, f"stego_{global_idx:05d}.png")) global_idx += 1这块批量逻辑有一个设计取舍:载体图也不做任何额外处理,直接保存一份副本到cover目录。原因是为了保证正负样本的“源图像”一致——如果cover用的是另一批图,网络可能学到“图像内容差异”而非“LSB 嵌入痕迹”。嵌入率列表里我通常不写太大,超过 1.5 bpp 的隐写图在像素最低位上会有肉眼可辨的噪点,也不符合真实对抗场景。os.urandom生成的载荷是随机字节,比固定字符串更接近真实秘密信息的形态,避免网络学到特定字节模式。
3. 卷积神经网络的检测结构:小图块输入与轻量卷积设计
数据准备好之后,下一步是把 CNN 结构定下来。这个任务和 ImageNet 分类不同:不需要识别“猫还是狗”,只需要判断“像素统计是否异常”。因此网络不必很深,但输入方式和局部感受野的设计直接影响检测上限。
3.1 输入裁剪尺寸:为什么不用整张大图
LSB 嵌入的痕迹是像素级的,理论上输入整张大图让网络自己找线索也行,但实际工程里没人这么干。一方面 GPU 显存扛不住高分辨率大 batch;另一方面,LSB 扰动的信号非常微弱,如果网络感受野很快变大,浅层特征里那些“最低位异常”的细节会被池化层直接抹掉。
我通常的做法是随机裁剪固定尺寸的图块。常见选择是 64×64 或 128×128。64 更省显存,128 能保留更多纹理上下文。裁剪的另一个好处是数据量放大——一张 512×512 的图可以裁出几十个不重叠的 64×64 块,相当于免费扩充了样本量。
但必须注意:数据划分要在“整图”层面做,不能在“图块”层面做。也就是先决定某张原图进训练集还是测试集,再从这张图里裁块。如果让同一张图的块同时出现在训练集和测试集,模型会通过记忆图像内容“作弊”,验证指标虚高。这个坑我放在第 5 章详细展开。
3.2 适合 LSB 检测的小型 CNN 结构
在动手写网络之前,先确定一个原则:这个任务的经验法则是“浅层卷积要短小精悍,不要在早期用大卷积核”。大卷积核会把最低位的噪声和周围纹理过早混合,导致特征失去区分度。
下面这个结构是我多次实验后觉得性价比最高的:3 个卷积块,每个块是“3×3 卷积 + 批归一化 + ReLU + 2×2 最大池化”,最后接全局平均池化和全连接层。用 PyTorch 实现:
import torch.nn as nn class LSBDetector(nn.Module): def __init__(self, in_channels: int = 3, num_classes: int = 2): super().__init__() self.features = nn.Sequential( # 块 1: 64x64 -> 32x32 nn.Conv2d(in_channels, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 块 2: 32x32 -> 16x16 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 块 3: 16x16 -> 8x8 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, 256), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))这个网络的计算路径是:64×64×3 → 32×32×32 → 16×16×64 → 8×8×128 → 128 维特征 → 2 类 logits。参数总量约 60 万,单张 64×64 图在普通消费级 GPU 上推理只需几毫秒。
三个设计细节值得说明。第一,BatchNorm2d放在每个卷积之后,对这类“微弱信号”任务非常关键,它能把特征分布拉回零均值单位方差,放大 LSB 造成的微小偏移。第二,最后一层用AdaptiveAvgPool2d(1)替代 Flatten 后接全连接,好处是网络对输入尺寸不那么敏感,换 128×128 输入也能跑,只是中间特征图尺寸变化。第三,Dropout 放在全连接前,用来压制过拟合——这个任务的正负样本差异极小,模型很容易死记硬背训练集的噪声模式。
3.3 数据集类:随机裁剪 + 标签配对
之前生成的cover和stego目录是同名对应的:cover_00001和stego_00001来自同一张源图。数据集类需要利用这个配对关系,并且每次迭代随机裁剪不同位置,相当于给模型提供无限多的样本视角。
import os import random from PIL import Image from torch.utils.data import Dataset from torchvision import transforms class CoverStegoDataset(Dataset): def __init__(self, root: str, split: str = "train", patch_size: int = 64, seed: int = 0): self.cover_dir = os.path.join(root, split, "cover") self.stego_dir = os.path.join(root, split, "stego") self.patch_size = patch_size self.files = sorted(os.listdir(self.cover_dir)) self.to_tensor = transforms.ToTensor() random.seed(seed) def __len__(self): return len(self.files) def __getitem__(self, idx): fname = self.files[idx] cover = Image.open(os.path.join(self.cover_dir, fname)).convert("RGB") stego = Image.open(os.path.join(self.stego_dir, fname)).convert("RGB") # 随机选一个裁剪位置,同一位置同时裁 cover 和 stego w, h = cover.size x = random.randint(0, w - self.patch_size) y = random.randint(0, h - self.patch_size) box = (x, y, x + self.patch_size, y + self.patch_size) cover_patch = cover.crop(box) stego_patch = stego.crop(box) # 随机翻转,翻转对 cover 和 stego 施加相同操作 if random.random() < 0.5: cover_patch = cover_patch.transpose(Image.FLIP_LEFT_RIGHT) stego_patch = stego_patch.transpose(Image.FLIP_LEFT_RIGHT) return self.to_tensor(stego_patch), 1, self.to_tensor(cover_patch), 0这里每次返回四个对象:隐写图、标签 1、载体图、标签 0。返回一对而非单个样本,是为了在训练时构造“对比对”更方便——你可以用二分类交叉熵,也可以改造成孪生网络做相似度学习,后者在某些低嵌入率场景下更稳。裁剪时要保证patch_size不超过图像短边,否则random.randint会报错,代码里没做校验,实际使用时一定要在初始化函数里过滤掉短边小于patch_size的图。
4. 训练与评估:把超参、损失和嵌入率对应起来
模型结构定了,数据迭代器也跑了,接下来就是训练。这一章我直接给出可复现的训练配置,并解释每个参数为什么这么设。
4.1 损失函数、优化器与超参起点
二分类任务默认用交叉熵损失。优化器我首选 Adam,初期收敛快,配合weight_decay做正则化。一个容易被忽略的点是:这个任务的训练 epoch 不需要很多,20 个 epoch 以内基本就能收敛,因为正负样本差异极小,模型学的是“统计噪声模式”,不是复杂语义。我常用的初始配置如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 64×64 | 显存友好,单卡可跑大 batch |
| Batch Size | 128 | 样本对形式时相当于 64 对 |
| 初始学习率 | 1e-3 | Adam 默认配置,训练平稳 |
| Weight Decay | 1e-4 | 压过拟合,比 1e-5 更有效 |
| Epoch 数 | 20 | 配合早停,一般 15 轮内收敛 |
| 学习率衰减 | Cosine | 最后几轮收敛更干净 |
| 标签平滑 | 0.05 | 防止模型对“微弱信号”过度自信 |
标签平滑在这里不是花架子。LSB 隐写分析的决策边界很模糊,低嵌入率样本和正常样本在特征空间里几乎重叠。如果模型输出概率直接逼近 0 或 1,通常意味着它把训练集噪声当成了稳定模式,测试集上一碰就碎。标签平滑可以把目标概率从 0/1 软化为 0.05/0.95,让模型保留一定的“不确定性空间”。
4.2 训练循环与评估指标
训练循环本身不复杂,但评估不能只看 accuracy。隐写分析场景对假阳性率(FPR)极其敏感——把正常图像误判为隐写图,比漏掉一张隐写图的代价高得多。下面的代码同时输出准确率、FPR、FNR 和 AUC:
import torch from sklearn.metrics import roc_auc_score def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct = 0.0, 0 total = 0 for x_stego, y_stego, x_cover, y_cover in loader: x = torch.cat([x_stego, x_cover], dim=0).to(device) y = torch.cat([y_stego, y_cover], dim=0).to(device) optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() total_loss += loss.item() * x.size(0) correct += (out.argmax(1) == y).sum().item() total += x.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, device): model.eval() y_true, y_score, y_pred = [], [], [] for x_stego, y_stego, x_cover, y_cover in loader: x = torch.cat([x_stego, x_cover], dim=0).to(device) y = torch.cat([y_stego, y_cover], dim=0).to(device) logits = model(x) prob = torch.softmax(logits, dim=1)[:, 1].cpu().numpy() pred = logits.argmax(1).cpu().numpy() y_true.extend(y.cpu().numpy()) y_score.extend(prob) y_pred.extend(pred) y_true = [int(v) for v in y_true] # 计算混淆矩阵指标 tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1) tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0) fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1) fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0) fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0 fnr = fn / (fn + tp) if (fn + tp) > 0 else 0.0 auc = roc_auc_score(y_true, y_score) acc = (tp + tn) / len(y_true) return {"acc": acc, "fpr": fpr, "fnr": fnr, "auc": auc}评估输出重点关注auc和fpr。我训练过程中如果看到acc在 98% 以上但fpr高于 2%,第一反应不是模型“学得好”,而是数据泄露了。真实场景里隐写图像占比极低,正常图像的 1% 被误判就可能导致大量错案,所以fpr比acc更能反映模型实战价值。
4.3 嵌入率与模型精度的对应关系
训练完成之后,必须做一张“嵌入率-准确率”曲线。做法很简单:用不同嵌入率分别生成一批测试隐写图,逐个输入模型看准确率。我遇到过不少新手只在一个嵌入率上测试,比如 1.0 bpp,模型表现很好,换到 0.1 bpp 立刻崩盘。
这是 LSB 隐写分析的普遍规律:嵌入率越低,检测难度指数上升。0.4 bpp 以上达到 95% 以上准确率不代表模型合格,真正要看的拐点在 0.1 bpp 到 0.2 bpp 之间。如果模型在 0.1 bpp 时准确率还明显高于随机水平(55% 以上),说明网络确实学到了一些有效信号。如果完全掉到 50% 附近,说明模型只是在记忆训练集特定嵌入模式的统计指纹,泛化性为零。
一种有效提升低嵌入率泛化能力的训练策略是“混合嵌入率”:训练集里不要只放 0.4 bpp 的样本,而是把 0.1、0.2、0.4、0.8 按比例混合。模型被迫在多种强度的噪声模式中找共性,而不是死磕某个特定模式。这个策略对最终测试成绩的提升往往比换网络结构更明显。
5. 避坑:CNN 隐写分析最容易翻车的五个问题
整个流程跑通不难,难在指标可信、结果可复现。这一章整理了我反复踩过的几个坑,每一条都是实际运行中遇到过的问题,有些甚至让整套实验白跑了一周。
5.1 验证集 AUROC 虚高:数据划分把同一张图拆进了两边
现象:训练时 loss 正常下降,验证集 AUROC 冲到 0.99,准确率超过 97%,但换一批全新图像测试,效果立刻跌到 60% 以下。原因:数据集的cover和stego都来自同一个源图池,如果按文件名随机切分,同一张源图的载体图和隐写图可能一个进训练集、一个进验证集。模型只需要记住该图像的纹理特征就能区分正负样本,而不是学会检测 LSB 痕迹。解决:划分必须在源图层面完成。先把所有源图按随机种子分成 train/val/test 三份,再分别对每一份调用批量生成脚本,保证同源图的 cover 和 stego 永远在同一边。
5.2 复训结果波动大:三个随机源没有锁死
现象:同一份数据、同一套参数,跑三次训练,准确率相差 5 个百分点以上。原因:随机裁剪位置、数据增强翻转、网络权重初始化、嵌入时的像素选择,这四处都在引入随机性。任何一个没固定,结果都不可复现。解决:在训练脚本入口设置全局随机种子,包括numpy.random.seed、random.seed、torch.manual_seed和torch.cuda.manual_seed_all。另外,批量生成隐写图时,嵌入函数要接收并记录seed参数,这样测试阶段替换不同载荷时不会改变嵌入模式。
5.3 准确率高但实战不可用:只看 ACC 不看 FPR
现象:模型在测试集上准确率 96%,看起来很漂亮,但部署到真实场景后每天误报几百张正常图片。原因:隐写分析数据集通常是平衡的(正负样本各一半),但真实场景正样本占比极低。一个 FPR 为 4% 的模型,在 10000 张正常图片里会误报 400 张,根本无法使用。解决:训练过程以 FPR 和 AUC 为主要监控指标,而不是 accuracy。我在评估函数里固定输出fpr,目标是让 FPR 控制在 1% 以下,同时保持 TPR(召回率)在 80% 以上。在最终部署时,还可以把输出阈值从默认的 0.5 调高到 0.9,用更保守的决策换取更低误报。
5.4 网络收敛慢:Batch Size 和输入尺寸打架
现象:训练到第 10 个 epoch,loss 还在 0.69 附近徘徊,准确率一直卡在 50%。原因之一:Batch Size 太小或输入尺寸太大,梯度噪声过高,模型学不到稳定的统计信号。这个任务和普通分类不同,LSB 信号非常微弱,需要足够大的 batch 来平滑梯度。我测试过 16 和 128 的差距:batch 16 时模型几乎不收敛,batch 128 时 5 个 epoch 就能明显看到 loss 下降。解决:优先保证 batch 不小于 64;如果显存不足,把输入尺寸从 128×128 降到 64×64,这个任务 64 的检测能力损失很小,但显存占用只有原来的四分之一。
5.5 单通道嵌入让模型“作弊”:颜色通道的隐藏捷径
现象:模型训练时表现极好,但换一种嵌入工具(比如只嵌入 Y 通道或只嵌 R 通道)后大幅失效。原因:如果嵌入时只修改 R 通道,R 通道的最低有效位统计特征会与 G、B 通道显著不同,模型只需要学会“看通道颜色差异”就能轻松分类。这条捷径不是真正的隐写检测能力。解决:嵌入时三个通道都要参与,并且每个通道被选中的概率尽量均衡。正因为这个原因,通用的隐写分析数据集都会强调“充分随机化”,包括随机像素位置、随机通道、随机嵌入顺序。
6. 低嵌入率检测优化:混合嵌入率与多裁剪投票
把基础流程跑通之后,真正决定项目价值的是低嵌入率下的表现。这里分享两个我一直在用的优化手段,不需要改网络结构,只需调整训练策略和推理方式。
第一个是混合嵌入率冷启动。先只用 0.8 bpp 的样本训练 5 个 epoch,让模型快速掌握“最低位有扰动”这一基本特征,然后切换成混合嵌入率(0.1 到 0.4 随机采样)继续训练。这种从易到难的课程式训练比一开始直接扔低嵌入率样本收敛更快,最终在 0.1 bpp 上的准确率通常能提升 5 到 8 个百分点。
第二个是推理阶段的多裁剪投票。单张测试图的 64×64 块可能恰好落在平坦区域,LSB 信号被图像内容淹没。推理时随机裁剪 N 个块分别预测再投票,比只裁一个块稳定得多。代码不超过十行:
@torch.no_grad() def predict_vote(model, img_path: str, device, patch_size=64, num_crops=10): model.eval() img = Image.open(img_path).convert("RGB") w, h = img.size votes = [] for _ in range(num_crops): x = random.randint(0, w - patch_size) y = random.randint(0, h - patch_size) patch = img.crop((x, y, x + patch_size, y + patch_size)) tensor = transforms.ToTensor()(patch).unsqueeze(0).to(device) prob = torch.softmax(model(tensor), dim=1)[0, 1].item() votes.append(prob) return np.mean(votes)投票次数从 1 提到 10,能明显提升低嵌入率的检出率。但要注意每张图推理耗时也乘以 10,实际部署时可以在准确率和延迟之间取折中,比如只裁 5 块取中位数。
到现在我还保留一个习惯:每次训练完后,先跑一遍全测试集看分布,再单独把 0.1 bpp 的样本抽出来看。如果低嵌入率结果不稳,那就说明前面某一步的随机性没有控制住。这个“先查随机性再调网络”的思路帮我避开了很多无效调参。希望帮到你。
本文还有配套的精品资源,点击获取