
简介这是一套基于PyTorch构建的端到端图像水印嵌入与提取系统利用深度卷积网络实现鲁棒水印的联合优化覆盖COCO、ImageNet、Cifar10与QRCode四类主流数据集可满足深度学习课程设计、毕业设计、科研复现及工程原型验证等场景需求。压缩包共58个文件其中7个Python模块按职责清晰拆分model.py负责网络结构、dataset.py封装数据加载、loss.py实现不可见性与鲁棒性联合损失、callbacks.py集成TensorBoard日志监控另有train_config.py统一管理超参此外还包含2篇PDF原理文档论文与残差块设计说明、40张示例图片及完整README整体仅3.88MB结构精简易上手。目前已有27人学习下载。代码实测可在Python 3.8与PyTorch 1.9环境稳定运行支持普通GPU或CPU训练运行main.py即可启动完整流程权重与日志自动保存配套说明与源码便于深入理解端到端水印的损失设计和模块组织适合希望快速跑通基线并在此基础上改进算法的开发者。1. 端到端图像水印嵌入与提取系统到底解决什么问题传统水印方案要么是明晃晃贴在角落的可见水印截掉就没了要么是 DCT/DWT 域里的手工特征调制碰上裁剪、缩放、JPEG 重压缩提取率立刻跳水。这几年深度学习把水印做成一个端到端的嵌入-提取闭环输入一张原图和一段二进制信息网络把信息调制进像素里人眼看着没变化但不管图片被怎么折腾解码器都能把二进制信息再抠出来。这套用 Python 实现、覆盖 COO、ImageNet、Cifar10、QRCode 多数据集并带完整训练流程的方案解决的就是「水印扛不扛攻击、能不能规模化训练」这两个最实际的问题。适合谁做版权保护的、做图片溯源追踪的、以及想把二维码信息直接藏进图片里的开发者。这篇文章不讲玄学只讲网络怎么搭、数据怎么喂、训练怎么不翻车。2. 端到端水印框架拆解编码器、解码器与信息瓶颈2.1 水印嵌入的本质不是加个 Logo是调制像素统计特征端到端水印的操作对象不是图片的空间域像素值本身而是特征分布。编码器拿到的输入是两样东西一张 H×W×3 的载体图 I和一段长度为 L 的二进制序列 M称为 Payload。输出是一张和水印图嵌入后的图 I人眼看去 I 和 I 几乎没有差异但 I 的局部统计特征里已经编码进了 M 的全部信息。我在实际项目里有个很核心的经验嵌入强度不要用全局标量去控制最好让网络自己学会逐像素调制。常见做法是在编码器末尾接一个 tanh 激活把残差限制在 [-ε, ε] 区间ε 是手动设置的强度上限。这样既避免了生成图颜色整体偏移又给了解码器明确的信号边界。import torch import torch.nn as nn class Encoder(nn.Module): def __init__(self, payload_len64, eps0.15): super().__init__() # 下采样路径提取多尺度特征 self.conv1 nn.Conv2d(3, 64, 3, padding1) self.conv2 nn.Conv2d(64, 128, 3, stride2, padding1) self.conv3 nn.Conv2d(128, 256, 3, stride2, padding1) # Payload 先扩展成与特征图同尺寸的 mask self.fc nn.Linear(payload_len, 256 * 8 * 8) # 上采样恢复原图尺寸 self.deconv1 nn.ConvTranspose2d(256 256, 128, 4, stride2, padding1) self.deconv2 nn.ConvTranspose2d(128, 64, 4, stride2, padding1) self.out nn.Sequential( nn.Conv2d(64, 3, 3, padding1), nn.Tanh() ) self.eps eps def forward(self, image, payload): # payload 广播成空间特征拼进编码器 p self.fc(payload).view(payload.size(0), 256, 8, 8) x torch.relu(self.conv1(image)) x torch.relu(self.conv2(x)) x torch.relu(self.conv3(x)) x torch.cat([x, p], dim1) x torch.relu(self.deconv1(x)) x torch.relu(self.deconv2(x)) residual self.out(x) # 残差限制在 eps 范围内保证视觉质量 return image residual * self.eps这段代码的核心是 Payload 先通过全连接扩展成 8×8 的空间分布再和图像特征做拼接。好处是信息不是藏在某个固定区域而是散布到全图所有位置后面即使被随机裁剪掉一半像素另一半像素里依然保有完整信息的冗余。eps 参数就是「水印可见度」的旋钮设 0.05 基本看不出差别设 0.3 在纯色区域能察觉轻微纹理噪声。我一般从 0.1 起步之后按提取率逐步微调。2.2 解码器的设计从被污染的图像里把比特位抠出来解码器比编码器更敏感它决定了系统的上限。它的输入 I 可能是 I 经过 JPEG 压缩、缩放、加噪甚至裁剪后的版本输出是一个 L 维向量每一位代表该比特为 1 的概率。这里最容易犯的错是把解码器做成一个全连接网络直接拍平像素真实场景里一旦出现裁剪像素的位置全错位全连接层基本报废。正确思路是解码器也用卷积网络保持空间平移不变性最后用全局平均池化把特征压成固定长度的向量。这样不管输入图被裁剪成什么尺寸只要卷积核能滑动特征就能被提取。class Decoder(nn.Module): def __init__(self, payload_len64): super().__init__() # 输入可能是任意尺寸用纯卷积保持平移不变 self.features nn.Sequential( nn.Conv2d(3, 64, 3, padding1), nn.ReLU(), nn.Conv2d(64, 128, 3, stride2, padding1), nn.ReLU(), nn.Conv2d(128, 256, 3, stride2, padding1), nn.ReLU(), nn.Conv2d(256, 512, 3, stride2, padding1), nn.ReLU(), ) self.head nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, payload_len) ) def forward(self, stego_image): # 输出 logits配合 BCEWithLogitsLoss 使用 x self.features(stego_image) return self.head(x)这里有个细节值得注意AdaptiveAvgPool2d(1) 让解码器不关心输入分辨率训练时用 128×128测试时来了张 512×512 的图也能直接跑。解码器的最后一层不加 Sigmoid因为训练时用的 BCEWithLogitsLoss 内部自带 Sigmoid提前加上反而容易造成梯度消失。2.3 信息瓶颈Payload 长度选多少直接决定系统成败Payload 长度 L 是整套系统最关键的约束条件。L 太小比如 8 位能表达的信息太少只够存个编号L 太大比如 512 位解码器很难把这么多独立的比特从有限的像素冗余里准确还原提取率会断崖式下跌。我做过的实验里64 位是视觉质量和提取率的甜点区间128 位也能跑但需要把嵌入强度 eps 往上调 0.05 左右。另一个问题是 Payload 的比特分布。如果直接随机生成 0/1 序列网络很容易记住某些固定模式。我习惯对 Payload 做一次随机置乱用固定随机种子相当于给信息加了密也让网络训练时面对的分布更均匀。这个置乱表要保存在系统外部提取时先还原再解析不然后面算法版本升级旧图的水印全解不出来。3. 多数据集支持COO、ImageNet、Cifar10、QRCode 的加载与适配3.1 四个数据集在系统里分别扮演什么角色标题里列的四个数据集不是随便凑数的它们的差异恰好覆盖了水印系统落地时遇到的各类场景。Cifar10 是 32×32 的小图训练速度快适合开发阶段频繁调试网络结构ImageNet 子集图片分辨率高、语义丰富用来验证水印在真实摄影图片上的不可见性COO 这个自建数据集某公司内部收集的物体居中样本集合和 ImageNet 最大的区别在于背景干净、主体突出水印在这种图上的表现会和自然图像完全不同——背景平坦区域多残差稍微大一点就穿帮QRCode 作为数据集的特殊性在于它的 Payload 本身是结构化的二维码内容不是随机比特解码时除了要还原比特位还要过一遍二维码容错校验相当于给水印系统套了一层纠错保险。import torch from torch.utils.data import Dataset from torchvision import datasets, transforms import cv2 import numpy as np class WatermarkDataset(Dataset): def __init__(self, name, root./data, payload_len64, splittrain): self.payload_len payload_len self.split split if name cifar10: self.data datasets.CIFAR10( rootroot, train(split train), downloadTrue, transformtransforms.ToTensor() ) elif name imagenet_sub: # 只取前 N 个类做轻量验证完整版直接指向 ImageFolder self.data datasets.ImageFolder( rootf{root}/imagenet/{split}, transformtransforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor() ]) ) elif name coo: # 自建数据集目录结构同 ImageFolder self.data datasets.ImageFolder( rootf{root}/coo/{split}, transformtransforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor() ]) ) else: raise ValueError(fUnknown dataset: {name}) def __len__(self): return len(self.data) def __getitem__(self, idx): img, _ self.data[idx] # 随机生成 Payload每一位独立均匀分布 payload torch.randint(0, 2, (self.payload_len,)).float() return img, payload这个统一接口的关键在于 Payload 是随数据即时生成的而不是预先存在硬盘上。好处是每一轮 epoch 网络见到的都是全新的水印内容天然避免了对训练集的过拟合。坏处是如果后续要做「固定水印 固定图片」的测试集对比需要额外保存一批 seed让每次生成的 Payload 可复现。3.2 分辨率适配Cifar10 的 32×32 怎么和 ImageNet 的 224×224 共存直接把不同分辨率的数据一起喂给网络是行不通的。常见的做法是全部 Resize 到同一个尺寸但这个尺寸的选择有讲究。我一般分为两档快速实验用 64×64正式训练用 128×128。64 能在一个消费级显卡上把 batch 开到 64训练一轮只要几分钟适合验证 loss 能不能降128 是视觉质量和训练速度的折中再往上到 224显存占用翻倍收益却只是解码器对缩放攻击的鲁棒性略好一点。Resize 的方式也要注意我踩过坑的是简单粗暴用cv2.resize(img, (w, h), interpolationcv2.INTER_LINEAR)来处理全部数据集结果发现 Cifar10 本身像素颗粒大线性插值会把边缘模糊掉编码器学出来的残差全是高频噪声。后来对 Cifar10 改用INTER_CUBIC保留边缘锐度水印提取率和视觉质量都有明显提升。这属于数据预处理层面的细节但直接影响后面网络的收敛速度。3.3 QRCode 的接入方式把二进制 Payload 换成二维码内容接入 QRCode 的思路有两种很多文章混为一谈。第一种是把二维码图片当作载体图的局部贴上去这是可见水印不在本文讨论范围第二种是把二维码的内容比如一串 URL 或者文本先编码成二进制字符串再切成长度为 payload_len 的块作为 Payload 送入编码器。第二种才叫端到端隐藏式嵌入。实际操作里把文本转比特很容易先 UTF-8 编码再按位展开不够长就填充太长就做截断加哈希校验。import qrcode import io from PIL import Image def qr_content_to_payload(text, payload_len64): # 先按 UTF-8 转成字节流 byte_data text.encode(utf-8) # 按位展开成 0/1 列表 bit_list [] for b in byte_data: for i in range(7, -1, -1): bit_list.append((b i) 1) # 超出 payload_len 的部分截断不足则循环填充 if len(bit_list) payload_len: bit_list bit_list[:payload_len] else: idx 0 while len(bit_list) payload_len: bit_list.append(bit_list[idx % len(bit_list)]) idx 1 return torch.tensor(bit_list, dtypetorch.float32) def generate_qr_reference(text): # 生成二维码图像作为解码质量的参照物 qr qrcode.QRCode(version5, error_correctionqrcode.constants.ERROR_CORRECT_H) qr.add_data(text) qr.make(fitTrue) img qr.make_image(fill_colorblack, back_colorwhite) return img.resize((128, 128))提取端要做的事情是反过程从解码器拿到 64 位比特后先拼回字节再做 UTF-8 解码。这里最折磨人的问题是比特错位——如果训练时用的是「按位展开再循环填充」的逻辑提取时必须严格按照同样顺序拼装差一位整个文本就乱码。我的建议是把比特处理逻辑封装成一个单独的PayloadCodec类训练和推理都走同一个类避免两套实现在某次重构后悄悄分叉。4. 完整训练流程损失配比、训练循环与收敛判断4.1 损失函数怎么配四个损失项的权重关系端到端水印训练本质是一个多目标优化问题。我常用的损失函数拆成四块L_rec 是载体图与嵌入图的像素级重建误差用 L1 或 MSE保证视觉一致性L_ext 是解码器输出与原始 Payload 的交叉熵保证信息能提取出来L_adv 是判别器损失让嵌入图更接近自然图像分布这属于锦上添花前期可以不加L_perc 是感知损失用预训练 VGG 的特征距离替代逐像素距离。def watermarked_loss(orig_img, stego_img, payload, decoded, lambda_rec1.0, lambda_ext2.0): # 像素级重建L1 比 MSE 对极端残差更宽容 l_rec torch.mean(torch.abs(orig_img - stego_img)) # 提取损失BCEWithLogits 自带 sigmoid 稳定性更好 l_ext nn.functional.binary_cross_entropy_with_logits( decoded, payload, reductionmean ) return lambda_rec * l_rec lambda_ext * l_ext, l_rec, l_ext权重配比是最吃经验的部分。lambda_ext 设太小网络会偷懒直接让嵌入图等于原图解码器随便猜loss 也能降得很低lambda_rec 设太小网络会疯狂往图里塞噪声提取率上去了图却没法看了。我调过很多组配比比较稳的起始点是 lambda_rec1.0, lambda_ext2.0。这里有个判断技巧观察训练早期 l_rec 和 l_ext 的绝对值量级如果 l_rec 是 0.01 而 l_ext 是 0.5那梯度基本被提取损失主导需要把 lambda_rec 往上提或者用梯度归一化处理。4.2 训练循环与关键超参数BATCH_SIZE、学习率、EMA训练环节有个经常被忽略的点水印系统要同时对「嵌入质量」和「提取准确率」负责很容易因为 batch 内样本的难度差异导致震荡。我一般会给解码器单独配一个低一点的学习率比如编码器 1e-4、解码器 5e-5因为解码器如果学得太快会让编码器得到一个「过于容易」的解码任务残差信号反而没有充分散布。另外强烈建议开 EMA指数移动平均保存一份模型参数的滑动平均推理时用 EMA 版本提取率通常能稳定提升 23 个百分点这个收益几乎零成本。optimizer torch.optim.AdamW( [ {params: encoder.parameters(), lr: 1e-4}, {params: decoder.parameters(), lr: 5e-5}, ], weight_decay1e-5 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max200) ema_params {} torch.no_grad() def update_ema(decay0.999): for name, param in encoder.named_parameters(): if name not in ema_params: ema_params[name] param.data.clone() else: ema_params[name].mul_(decay).add_(param.data, alpha1 - decay) for epoch in range(200): for img, payload in train_loader: img, payload img.cuda(), payload.cuda() stego encoder(img, payload) decoded decoder(stego) loss, l_rec, l_ext watermarked_loss(img, stego, payload, decoded) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(encoder.parameters(), 1.0) torch.nn.utils.clip_grad_norm_(decoder.parameters(), 1.0) optimizer.step() update_ema() scheduler.step() print(fepoch {epoch}: loss{loss.item():.4f} rec{l_rec.item():.4f} ext{l_ext.item():.4f})这里的clip_grad_norm_很重要。编码器和解码器联合训练时梯度经过编码器回传的路径长偶尔会出现个别 batch 的梯度爆炸如果不做裁剪参数会直接飞到 NaN。我之前没有做裁剪损失曲线在 30 轮左右突然断崖式变成 nan白白浪费了十几个小时的训练时间。梯度裁剪的参数 1.0 是经验值如果你的输入做了标准化可以尝试调成 0.5。4.3 训练中止与恢复Checkpoint 策略与收敛判断判断水印模型「有没有练好」不看 loss 曲线看两个硬指标验证集上的比特提取准确率Accuracy和嵌入图 PSNR。我见过不少人在 loss 降不动了就开始折腾网络结构其实系统性的做法是每 5 个 epoch 跑一次验证把提取准确率和 PSNR 记录到 CSV训练结束后回头看一眼两个指标有没有达到预设目标例如准确率 ≥ 98%PSNR ≥ 36dB。如果准确率卡在 90% 上不去优先调 lambda_ext 和 eps如果 PSNR 低于 32dB图已经能看到明显纹理优先降 eps。Checkpoint 一定要存两份一份是当前步数的可断点续训版本包含 optimizer 和 scheduler 的状态另一份是最佳验证指标版本的含 EMA 参数快照。我习惯把 EMA 参数单独存成.pt文件因为续训时如果用 EMA 参数继续训练模型会不稳定。这个习惯帮我避免过好几次「验证效果好、一续训就崩」的尴尬场景。5. 避坑与排查端到端水印训练最常见的五个翻车点5.1 提取准确率卡在 50% 附近和随机猜一样现象训练了 50 个 epochloss 在降但验证集上的比特准确率始终在 50%55% 之间徘徊。原因很可能是解码器输出端的归一化和损失函数不匹配——如果解码器最后一层用了 Sigmoid又叠加了 BCEWithLogitsLoss内部自带 Sigmoid数值上会出现双重压缩梯度大幅衰减。解决只用 BCEWithLogitsLoss解码器末尾不接任何激活或者改用F.binary_cross_entropy(decoded.sigmoid(), payload)两种写法选一种不要混用。5.2 嵌入图质量很好但一碰 JPEG 压缩就全丢现象无攻击时提取率 99%加上 JPEG 质量因子 80 的压缩后掉到 40%。原因在于训练时没有做过 JPEG 近似模拟解码器只见过纯净的嵌入图对频域变化毫无泛化能力。解决在训练循环里对 stego 图做随机可微的 JPEG 模拟——常见做法是用torchvision.transforms里的高斯模糊加随机量化噪声近似或者直接用jpeg4py做不可微扰动反向传播跳过这一步。我给训练管线里加的扰动顺序是「随机 JPEG → 随机高斯噪声 → 随机裁剪一小块」每种攻击的发生概率设成 0.3训练出来的模型抗攻击能力非常可观。5.3 训练初期 PSNR 异常高但后来突然暴跌现象前 20 个 epoch PSNR 稳定在 40dB突然在 25 轮降到 28dB肉眼可见水印纹理。原因多半是 eps 没有做退火——网络发现把残差撑满上限可以轻松完成解码任务梯度引导它拼命往残差上限逼近。解决把 eps 设置为前 30 轮从 0.05 线性升到 0.15。这个方案是我在一个模拟项目里试出来的效果立竿见影前期低 eps 让编码器先学会和原图对齐后期再放宽边界学习如何塞信息。5.4 QRCode 内容解码出来全是乱码但比特准确率很高现象单独看比特准确率 97%但把比特拼回文本后全是乱码。原因在于 PayloadCodec 的比特顺序不一致——训练时随机生成的是「高位在前」序列推理时拼接却用了「低位在前」的顺序或者填充逻辑在某个版本里被改动过一处。解决给 PayloadCodec 写一个自校验函数每次训练前随机生成 10 条文本编码进图再提取出来对比原始文本确保整个编解码链路端到端一致。这个自校验函数建议常驻测试集里防止一次无意的重构就静默破坏整个系统。5.5 多个数据集混训时模型偏向 Cifar10ImageNet 上提取率偏低现象混合数据集训练最终在 Cifar10 上提取率 99%在 ImageNet 子集上只有 88%。原因有两种可能Cifar10 尺寸小、类别简单每轮 epoch 能跑更多 batch模型被简单样本绑架或者两种数据集的图像分布差异太大模型学了一个偏向某一侧的中间表示。解决一是对数据集做容量均衡采样让每个 epoch 里四个数据集的样本数大致一致二是给不同数据集分配不同的嵌入强度 eps——Cifar10 用 0.1ImageNet 用 0.15自建 COO 用 0.12QRCode 用 0.18。原因也好理解复杂图需要更强的嵌入信号才能保证提取率但这个经验值不是万能的需要按自己的数据分布调整。6. 鲁棒性验证与进阶技巧从「能跑」到「敢上线」模型训练收敛之后最重要的一步是用一套固定的攻击基准去验收。我在模拟项目里做过一份攻击矩阵JPEG 压缩分别测质量因子 90、70、50几何攻击测随机裁剪到 70%、缩放回原尺寸噪声攻击测高斯噪声标准差 0.01、0.03。每个攻击场景跑 100 张图统计比特错误率。这个表看起来死板但它是判断「这个水印系统能不能顶到线上」的唯一根据。如果某个场景的错误率超了 2%QRCode 解码还能靠容错撑住但纯文本 Payload 就可能出错这时就要针对该场景增加对应的攻击模拟训练或者提高 eps。6.1 攻击模拟的注入方式和嵌入强度的曲线调度给训练管线加入攻击模拟时有个进阶技巧是「课程式鲁棒训练」前 50 轮不注入任何攻击让网络先把基本的嵌入-提取闭环跑通50 轮之后按 epoch 逐步提高攻击强度比如每 10 轮把 JPEG 质量从 95 降到 80噪声标准差从 0 提到 0.03。这种方式比一开始就上最强攻击稳定得多——模型还没学会走路就被推去爬山loss 很容易发散课程式训练则让网络逐步适应更难的任务最终收敛出的解也更均衡。6.2 业务验证把密钥、版本号和水印载荷管理起来真正放进业务环境时水印系统不只是两个神经网络还要想清楚三件事密钥管理、版本兼容和负载格式。密钥管理指 Payload 置乱的随机种子以及编码表不能硬编码在模型文件里要单独配置化版本兼容指旧的模型提取新的图或者新版模型提取旧图都要有明确的算法版本协商机制负载格式建议第一版就预留 4 字节的元信息位存放水印算法版本号和校验码后面升级算法时能低代价兼容老图。6.3 一个实用的验证脚本批量跑提取率和 PSNR最后给出一个能直接用的小工具脚本用来批量验证某张图在指定攻击下的表现。我通常会在训练完成后把最佳 epoch 的模型跑一遍全流程验收输出一张「攻击类型 × 指标」的小表格这比盯着一堆 loss 曲线直观得多。def evaluate_model(encoder, decoder, img, payload, attack_fnNone): encoder.eval(); decoder.eval() with torch.no_grad(): stego encoder(img.unsqueeze(0), payload.unsqueeze(0)) if attack_fn is not None: stego attack_fn(stego) decoded decoder(stego) # 比特准确率按 0.5 阈值二值化 pred_bits (decoded 0).float() bit_acc (pred_bits payload.unsqueeze(0)).float().mean().item() # PSNR 用原图和嵌入图计算 mse torch.mean((img - stego[0]) ** 2).item() psnr 10 * np.log10(1.0 / mse) return bit_acc, psnr, stego[0] # 用法 # bit_acc, psnr, _ evaluate_model(enc, dec, img, payload, # attack_fnlambda x: x * 0.9) # 模拟轻微亮度攻击这个函数里的 attack_fn 是个灵活插槽你可以把前面说的 JPEG 模拟、高斯噪声、裁剪统统包装成可调用对象塞进去不用为每种攻击单独写一套验证逻辑。参数方面evaluate_model明确要求输入 img 和 payload 都带 batch 维度函数里处理的是单位 batch 的场景如果你的验证数据是批量多张图把unsqueeze去掉即可。我现在养成的习惯是每次调整任何环节——数据增强、网络宽度、嵌入强度都必跑一遍这个评估函数把结果和上一个版本打印在同一屏对比。水印系统这个东西单独看 loss 根本看不出差别一旦对比同一张原图在不同模型下嵌入图的 PSNR 和解码器的提取结果哪个改了之后是变好还是变坏就一目了然。这套流程帮我在某公司的上线项目里避开了「看着指标很好、实际攻击全崩」的大坑也让我越来越相信端到端水印不是黑匣子每一步实验做得干净系统的可靠性就是能一点点堆出来的。希望帮到你。本文还有配套的精品资源点击获取