
HiDDeN 这个工作我在做隐写相关课题的时候反复读过几遍第一次看的时候觉得思路很直接——用编码器把秘密信息嵌进图像再用解码器把它读出来中间加一个对抗判别器逼着含密图像看起来像自然图像。但真正动手复现才发现坑几乎全在训练细节里损失权重的量级、噪声层的设计、编码器和解码器的容量配比任何一处没对齐要么解码准确率上不去要么图像质量崩掉。这篇就把我复现 HiDDeN 的完整过程拆开讲从它到底解决了什么问题到每一处设计背后的动机再到我实际跑下来遇到的意外情况尽量把论文里没写清楚、但复现时绕不开的东西都补上。1. 先搞清楚 HiDDeN 到底在解决什么老问题1.1 传统隐写为什么在深度网络面前不够用在 HiDDeN 出现之前图像隐写大致分两类路子。一类是传统的空间域方法比如最低有效位替换把秘密比特直接写进像素的最低几位。这类方法容量大、实现简单但极其脆弱图像只要经过一次有损压缩或者轻微裁剪嵌入的信息基本就废了。另一类是变换域方法比如在 DCT、DWT 系数上做文章鲁棒性比空间域好一些但容量和不可感知性之间始终是个跷跷板调一个另一个就塌。更关键的问题是这些方法大多是手工设计的人先假设某种变换或某种统计特性适合藏信息然后围绕这个假设设计嵌入和提取规则。这种思路的天花板很明显——你只能利用人能想到的特征而真实图像在受到各种扰动后的统计变化远比手工规则能覆盖的复杂。HiDDeN 的核心主张就是与其手工设计嵌入规则不如让网络自己学一个。它把嵌入和提取分别交给编码器和解码器用一个端到端的训练目标把两者联合优化同时用一个判别器约束含密图像的视觉自然度。这个框架后来被大量隐写和数字水印工作借鉴算是把深度学习正式引入这个方向的一个标志性节点。1.2 编码器-解码器-判别器三件套的分工理解 HiDDeN 的结构最好像理解一个加密通信系统那样去看。发送方编码器拿到两样东西一张封面图像和一段要传的秘密信息。它的任务是把这两者融合成一张含密图像这张图既要看起来和原图几乎一样又要保证接收方解码器能从里面把秘密信息还原出来。解码器的任务就一个拿到含密图像输出原始的秘密比特序列。注意这里解码器是盲的它不需要原始封面图像作为参考这一点很重要因为实际场景里接收方通常拿不到原图。判别器则扮演质检员的角色。它同时看真实图像和含密图像试图区分哪个是生成的。编码器为了骗过判别器就必须让含密图像的分布尽量贴近真实图像分布。这个对抗过程就是 HiDDeN 保证视觉质量的主要手段。三者是联合训练的损失函数把三方的目标揉在一起。这个联合优化的设计是整篇论文最核心的工程贡献也是复现时最容易出问题的地方。1.3 为什么这个框架值得复现从工程角度看HiDDeN 的价值在于它提供了一个可扩展的骨架。你可以在噪声层里塞进任意类型的扰动——高斯噪声、裁剪、JPEG 压缩、缩放——网络就会学着去抵抗这些扰动。这意味着同一个框架改一改噪声层就能适配不同的鲁棒性需求不用重新设计整套算法。从学习角度看它是一个非常干净的生成模型 对抗训练 信息编码的综合案例。复现一遍等于把自编码器、对抗训练、损失权重调参这几件事都过了一遍。我当初选它作为复现目标就是看中它麻雀虽小五脏俱全。2. 把论文里的结构图翻译成能跑的代码2.1 编码器的输入拼接方式与通道设计编码器要同时吃进封面图像和秘密信息怎么把这两样东西拼到一起是个需要想清楚的问题。封面图像是三维张量形状是(batch, 3, H, W)秘密信息是一维比特序列形状是(batch, L)L 是比特长度。论文的做法是把秘密比特复制扩展成和图像空间尺寸一致的特征图然后在通道维度上和图像拼接。具体来说如果秘密信息长度是 L就把它 reshape 成(batch, L, 1, 1)再用广播机制扩展到(batch, L, H, W)最后和图像的 3 个通道 concat得到(batch, 3L, H, W)的输入。这里有个细节论文没强调但很关键秘密比特的扩展方式。如果只是简单复制网络很容易学到一种位置无关的嵌入模式导致鲁棒性变差。我在实现时试过用可学习的嵌入层先把比特映射成向量再扩展效果比纯复制略好但差别不算大。对于初次复现建议先用论文的复制方案跑通之后再考虑优化。编码器主体是一串卷积层每层后面接 BN 和 ReLU最后用一层卷积把通道数压回 3再过 Tanh 把输出限制在[-1, 1]。注意这里图像预处理也要把像素归一化到[-1, 1]否则 Tanh 的输出范围和输入对不上训练会很不稳定。class Encoder(nn.Module): def __init__(self, secret_len30, img_channels3): super().__init__() in_ch img_channels secret_len self.net nn.Sequential( nn.Conv2d(in_ch, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, img_channels, 3, padding1), nn.Tanh() ) def forward(self, img, secret): b, _, h, w img.shape s secret.view(b, -1, 1, 1).expand(b, -1, h, w) x torch.cat([img, s], dim1) return self.net(x)2.2 解码器为什么不需要原图也能工作解码器的输入只有含密图像输出是秘密比特序列。它的结构前半段和编码器类似是一串卷积加下采样把空间尺寸逐步压小最后用全局池化或者直接 flatten 接全连接层输出长度为 L 的向量。因为秘密信息是二值的输出层通常用 Sigmoid 把值压到(0, 1)训练时用 BCE 损失推理时以 0.5 为阈值二值化。这里有个容易忽略的点解码器的输出维度必须严格等于秘密长度 L多一个少一个都会导致训练目标错位。我踩过的一个坑是解码器的容量。一开始我为了省显存把解码器做得很浅结果发现即使不加任何噪声解码准确率也上不去。后来把卷积层数加到和编码器相当准确率立刻上来了。原因是解码器要从被编码器揉碎的特征里恢复出精确的比特容量不够就学不动。经验是解码器的参数量不要低于编码器太多两者大致对等比较稳。2.3 判别器在对抗训练里扮演的角色判别器就是一个标准的图像分类网络输入一张图输出它是真实图像还是含密图像的概率。结构上可以用几层卷积加下采样最后接全连接输出一个标量。判别器的损失是标准的对抗损失对真实图像输出高分对含密图像输出低分。编码器的对抗损失则相反它希望判别器对含密图像输出高分。这两者交替优化形成对抗。需要注意的是判别器不能太强也不能太弱。太强的话编码器还没学会怎么藏信息就被判别器压死了训练直接崩太弱的话对抗约束形同虚设含密图像质量上不去。我在实践中用的策略是判别器的学习率比编码器略低或者每隔几步才更新一次判别器让编码器有喘息的空间。3. 损失函数里每一项的权重都是玄学3.1 编码损失、解码损失、对抗损失的三角关系HiDDeN 的总损失由三部分组成编码器要让含密图像和原图尽量接近编码损失解码器要能准确还原秘密信息解码损失编码器还要骗过判别器对抗损失。编码损失通常用 MSE衡量含密图像和封面图像的像素差异。解码损失用 BCE衡量还原出的比特和原始比特的差异。对抗损失用 BCE衡量判别器对含密图像的判断。这三项的量级差异很大。MSE 在图像归一化到[-1, 1]之后典型值在 0.001 到 0.01 量级BCE 在训练初期可能在 0.5 到 1 之间。如果不做权重平衡解码损失会完全主导训练编码器只顾着藏信息图像质量崩掉。论文里给的权重配置是编码损失权重较高解码损失权重较低对抗损失权重更低。但具体数值论文没有完全公开我试下来比较稳的一组是编码损失权重 1.0解码损失权重 1.0对抗损失权重 0.001 到 0.01 之间。这个对抗权重需要根据判别器的强度微调。3.2 我实测下来比较稳的一组权重下面这组配置是我在 256x256 图像、秘密长度 30 比特、加高斯噪声和裁剪噪声的条件下跑通的损失项权重说明编码损失 MSE1.0保证图像质量解码损失 BCE1.0保证信息还原对抗损失 BCE0.005约束视觉自然度判别器损失1.0判别器自身训练需要说明的是这组数值不是唯一解它和你的网络容量、噪声强度、图像尺寸都相关。我的建议是先用这组跑通观察三项损失各自的下降曲线再针对性调整。如果发现含密图像有明显伪影就加大编码损失权重如果解码准确率上不去就加大解码损失权重如果图像看起来太干净但解码差可能是对抗太强适当降低对抗权重。3.3 损失权重调参的实操顺序调参不要一上来就三个一起调那样你根本不知道是谁在起作用。我的顺序是这样的先把对抗损失权重设为 0只训练编码器和解码器。这一步的目标是让解码准确率在没有对抗约束的情况下先上去同时观察图像质量。如果这一步都跑不通说明网络结构或学习率有问题。解码准确率稳定后逐步加入对抗损失从很小的权重开始比如 0.001观察图像质量是否改善、解码是否掉点。如果加入对抗后解码掉得厉害说明对抗太强降低权重或者降低判别器学习率。最后再微调编码损失和解码损失的比例找到图像质量和解码准确率的平衡点。这个顺序能帮你把问题定位到具体某一项而不是面对一个混沌的损失曲线干瞪眼。4. 噪声层才是鲁棒性的真正来源4.1 噪声层放在编码器和解码器之间的意义HiDDeN 的噪声层夹在编码器和解码器之间含密图像先经过噪声层再送进解码器。这个设计的意思是训练时就让解码器习惯被扰动过的含密图像这样推理时遇到真实扰动也能扛住。噪声层本身没有可学习参数它就是一个可微的扰动函数。之所以要求可微是因为梯度要能从解码器一路回传到编码器。如果噪声不可微编码器就学不到如何抵抗它。这也是为什么 HiDDeN 用的都是高斯噪声、裁剪、JPEG 近似这类可微或可近似可微的扰动。我一开始想加一个旋转噪声结果发现旋转操作在离散像素网格上不可微梯度传不过去训练直接失效。后来改用可微的仿射变换近似才勉强能用。这个教训是噪声层的设计首先要满足可微性其次才是扰动类型。4.2 高斯噪声、裁剪、JPEG 压缩的实现差异高斯噪声最好实现直接给含密图像加一个N(0, sigma)的随机张量就行sigma 控制强度。注意 sigma 要相对于图像归一化范围来设如果图像在[-1, 1]sigma 取 0.05 到 0.1 比较合理。裁剪噪声是随机把图像的一部分区域置零或者替换成随机值。实现时要注意裁剪区域的比例太小起不到鲁棒性训练的作用太大则解码器根本没法恢复。我一般用 10% 到 25% 的裁剪比例。JPEG 压缩是最麻烦的因为标准 JPEG 编解码不可微。常见做法是用一个可微的近似比如在 DCT 域做量化或者用一个小网络模拟 JPEG 的失真。论文里用的是可微的 JPEG 近似层。如果你不想实现这个可以先用高斯噪声加裁剪跑通JPEG 留到后面再补。class NoiseLayer(nn.Module): def __init__(self, noise_typegaussian, sigma0.05): super().__init__() self.noise_type noise_type self.sigma sigma def forward(self, x): if self.noise_type gaussian: return x torch.randn_like(x) * self.sigma elif self.noise_type crop: mask torch.ones_like(x) b, _, h, w x.shape ch, cw int(h * 0.2), int(w * 0.2) top torch.randint(0, h - ch, (1,)).item() left torch.randint(0, w - cw, (1,)).item() mask[:, :, top:topch, left:leftcw] 0 return x * mask return x4.3 噪声强度与解码准确率的权衡曲线噪声强度和鲁棒性之间不是线性关系。我实测下来随着 sigma 增大解码准确率会先缓慢下降然后在一个临界点附近急剧崩塌。这个临界点和秘密长度、网络容量都相关。我的经验是不要把噪声强度设到临界点附近因为那样训练会非常不稳定稍微换个随机种子结果就差很多。比较稳妥的做法是设在临界点的 60% 到 70% 位置留出余量。这样训练稳定推理时遇到比训练稍强的噪声也还能扛。另外训练时可以逐步增大噪声强度从弱到强让网络有个适应过程。这个课程学习的策略对稳定性帮助很大尤其是秘密长度较长的时候。5. 训练过程中那些论文没写的坑5.1 判别器过强导致编码器梯度消失这是我复现时遇到的第一个大坑。训练到几百步之后解码准确率突然开始掉含密图像质量也变差损失曲线剧烈震荡。排查了半天发现是判别器太强了它对含密图像的判断准确率接近 100%导致编码器的对抗梯度几乎为零编码器学不动同时解码器因为编码器输出变差也跟着退化。解决办法有两个一是降低判别器的学习率比如设成编码器的 0.1 倍二是给判别器的更新加个间隔比如每训练 5 步编码器才更新 1 步判别器。我用的是后者效果比较明显。另外给判别器的输入加一点噪声也有帮助能防止它过拟合到某个特定模式。5.2 秘密信息长度对收敛速度的影响秘密长度 L 直接决定了编码器和解码器要处理的信息量。L 越大收敛越慢而且对网络容量要求越高。我试过 L30、L64、L128 三档L30 大概几千步就能收敛L64 要一万步以上L128 则需要更大的网络和更长的训练时间而且很容易陷入解码准确率上不去的局部最优。如果你只是想复现验证建议从 L30 开始这是论文里比较常用的设置也最容易跑通。等跑通之后再尝试加长。加长的时候记得同步增加编码器和解码器的通道数否则容量不够。5.3 图像归一化范围与 Tanh 输出的匹配问题这个坑很隐蔽。编码器最后一层用 Tanh输出范围是[-1, 1]。如果你的输入图像归一化到了[0, 1]那么编码器输出的含密图像和输入图像就不在一个范围里编码损失计算出来的值会偏大而且解码器看到的输入分布和训练目标不一致。正确做法是把图像归一化到[-1, 1]和 Tanh 输出对齐。具体就是img img * 2 - 1。这个细节论文里没明说但复现时必须注意。我一开始没注意训练了很久解码准确率都上不去改成[-1, 1]之后立刻好转。5.4 批归一化在对抗训练里的副作用编码器和解码器里用了 BN这在普通自编码器里没问题但在对抗训练里会引入麻烦。BN 在训练时用批统计量推理时用滑动平均这两者的差异会导致含密图像在训练和推理时的分布不一致进而影响解码准确率。我的处理是把编码器里的 BN 换成 Instance Norm 或者干脆去掉解码器里保留 BN 影响不大。实测下来去掉编码器的 BN 之后训练稳定性有明显提升解码准确率的波动也小了。这个改动对最终性能影响不大但对复现的顺利程度影响很大。6. 复现结果与论文的差距分析6.1 解码准确率对不上的常见原因论文里报告的解码准确率在无噪声条件下接近 100%加噪声后也能保持在 95% 以上。我第一版复现出来无噪声只有 90% 左右加噪声直接掉到 70%。排查下来主要有三个原因一是图像归一化范围没对齐二是解码器容量不够三是对抗权重设得太大。把这三个问题修掉之后我的无噪声解码准确率到了 99% 以上加高斯噪声sigma0.05能保持在 95% 左右和论文基本对齐。裁剪噪声下稍差一些大概 90% 出头这个差距我认为主要来自裁剪噪声的实现细节和论文不完全一致。6.2 图像质量指标 PSNR 的实测值图像质量我用 PSNR 衡量。在编码损失权重 1.0、对抗权重 0.005 的配置下含密图像的 PSNR 大概在 32 到 35 dB 之间视觉上基本看不出差异。如果把对抗权重调大PSNR 会略降但视觉自然度可能更好调小则 PSNR 上升但可能出现轻微伪影。这里有个反直觉的点PSNR 高不代表视觉质量一定好。我遇到过 PSNR 36 dB 但局部有明显块状伪影的情况也遇到过 PSNR 33 dB 但整体看起来很自然的情况。所以评估时不能只看 PSNR最好配合人眼观察或者 SSIM 一起看。6.3 训练时间和显存占用的实测数据在单张 16GB 显存的卡上256x256 图像、秘密长度 30、batch size 16 的配置训练一个 epoch 大概需要几分钟收敛需要几十个 epoch。显存占用峰值在 8GB 左右主要花在编码器和解码器的中间特征图上。如果想省显存可以减小 batch size 或者降低图像尺寸。但 batch size 太小会影响 BN 的统计量如果你保留了 BN图像尺寸太小则会影响鲁棒性训练的效果。我的建议是优先保证图像尺寸batch size 可以适当减小。7. 几个可以立刻上手的改进方向7.1 用可学习噪声层替代固定噪声固定噪声层的缺点是它只能覆盖你预设的扰动类型。一个自然的改进是把噪声层也做成可学习的让网络自己学一个最难抵抗的扰动分布。这个思路在对抗训练里叫对抗扰动实现上就是让噪声层也参与梯度上升专门找编码器的弱点。我试过一个简化版用一个小的生成网络产生扰动和编码器对抗训练。效果确实比固定高斯噪声好但训练复杂度上去了收敛也更慢。如果你只是想复现 HiDDeN不建议一上来就做这个如果是要做研究这是个值得探索的方向。7.2 多尺度解码提升鲁棒性单尺度解码器在遇到缩放类扰动时比较吃力。一个改进是让解码器在多个尺度上提取特征最后融合。具体做法是在解码器的不同深度接出分支各自做全局池化后拼接再送进全连接层。这个改动对缩放和裁剪类扰动的鲁棒性提升比较明显代价是参数量增加。我在 L64 的设置下试过加噪声条件下解码准确率提升了 2 到 3 个百分点算是性价比不错的改进。7.3 把秘密信息编码成更鲁棒的形式直接嵌入原始比特序列对噪声比较敏感。一个思路是先用纠错码把秘密信息编码一遍再嵌入。这样即使部分比特被噪声破坏也能通过纠错码恢复出来。常用的纠错码有 BCH 码、Reed-Solomon 码等。实现上就是在编码器输入前加一个编码步骤解码器输出后加一个解码步骤。这个改动不涉及网络结构实现简单对鲁棒性提升明显。我在 L30 的设置下加了 BCH 纠错裁剪噪声下的解码准确率从 90% 提升到了 96% 左右。7.4 混合噪声训练的策略单一噪声训练出来的模型对没见过的噪声类型泛化能力有限。一个实用的策略是混合多种噪声一起训练比如高斯噪声、裁剪、缩放按一定比例混合。这样训练出来的模型鲁棒性更全面。混合时要注意各种噪声的强度要匹配不能让某一种噪声主导训练。我的做法是每种噪声单独调好强度然后在训练时随机选择一种施加而不是同时施加多种。同时施加多种噪声会让扰动过强解码器学不动。8. 复现这件事本身的一些体会HiDDeN 的论文写得算清楚的但复现的难点从来不在论文写了什么而在论文没写什么。损失权重的具体数值、噪声层的实现细节、归一化范围、BN 的处理这些在论文里要么一笔带过要么完全没提但恰恰是决定复现成败的关键。我的建议是复现任何一篇深度学习论文都先把最小可运行版本跑通也就是去掉所有花哨的东西用最简单的配置先让训练能跑起来、损失能下降。然后再逐步加回论文里的各种设计每加一个观察一次效果。这样你能清楚地知道每个设计到底起了什么作用而不是面对一个黑盒调参。另外复现时不要追求一步到位对齐论文的所有指标。先把无噪声条件下的解码准确率做到 99% 以上这是基础。基础打牢了再加噪声、加对抗、加各种改进。基础不牢的时候加任何东西都是白费力气因为你分不清是哪个环节出了问题。最后说一个我踩过的坑随机种子的影响比想象中大。同样的配置换个种子解码准确率可能差好几个百分点。所以复现时不要只跑一次就下结论至少跑三次取平均或者固定种子做对比实验。这一点在论文里通常不会提但实际做研究时非常重要。