
图像隐藏这些年一直是个很讨喜的方向把一张秘密图片藏进另一张看起来完全正常的封面图里人眼看不出差别但程序能从封面图里把秘密图无损恢复出来。我最早接触这个需求是在帮朋友做一个图片隐私保护的小demo他要求输出图看起来就是普通风景照但里面得能还原出另一张图。调研一圈后发现HiNet几乎是绕不开的一个名字。HiNet是基于可逆神经网络Invertible Neural Network做图像隐藏的经典方案代码实现清晰、效果直观特别适合作为你进入这个方向的第一份实战代码。这篇文章我会完整走一遍为什么选可逆网络、HiNet的核心结构、损失函数怎么设计、PyTorch代码怎么组织、训练时有哪些坑。我会尽量把每个环节讲清楚代码部分也给出可以直接跑通的版本。不管你是刚接触图像隐写的新手还是已经跑过一些GAN、扩散模型、想换个方向练手的老手这篇都适用。1. 图像隐藏这件事到底难在哪1.1 图像隐藏不是加密而是“看起来正常”很多人第一次听说图像隐藏第一反应是“这不就是加密吗”其实差别很大。加密是把数据变成乱码攻击者一看就知道这文件有问题。图像隐藏追求的是“无感”秘密图嵌入封面图后封面图看起来就是一张完全正常的图片人眼找不出破绽但指定程序能从这张正常图片里把秘密图提取出来。这个性质决定了它和常规图像生成任务有本质区别。普通的图像生成比如超分、去噪、风格迁移只要输出图质量够好就行不需要考虑“输出图里还带着另一份信息”。而图像隐藏要同时满足两个约束封面图视觉无差异秘密图无损可恢复。这两个约束天然互相拉扯网络必须找到一个平衡点把秘密信息“摊”到封面图的像素细节里既不破坏封面观感又能被解码器精确还原。1.2 为什么传统隐写方法不够用传统隐写术里最经典的是LSB最低有效位替换把秘密图的二进制位写进封面图的低位。原理很简单人眼对像素值低位的变化不敏感所以视觉上几乎看不出来。但LSB的缺点非常致命稍微一点压缩、缩放、滤波秘密信息就没了。真实场景里图片要传输、存储很难保证不做任何有损处理。后来有人用U-Net这类编码器-解码器结构做隐藏。U-Net能学出比LSB复杂得多的隐藏策略鲁棒性更好但它有一个绕不开的问题编码器把秘密图“压”进封面图时本质上是做了一个信息瓶颈。解码器要从被污染的特征里还原秘密图这个“压缩-还原”过程一定有信息损失。你可能觉得损失不大但对图像隐藏来说哪怕PSNR掉到35以下肉眼都能看出马脚。一旦封面图比较复杂U-Net的瓶颈效应就非常明显恢复出来的秘密图经常带着模糊和伪影。1.3 HiNet带来的关键转变HiNet的出发点很简单与其让网络去“压缩-解压”信息不如干脆设计一个可逆结构让信息在数学上无损地流过整个网络。前向过程把秘密图“藏”进封面图反向过程把秘密图“抽”出来。因为整个过程可逆理论上秘密图可以被无损恢复封面图也能被无损还原。这个思路听起来很顺但真正落地并不容易。可逆网络不是把两个卷积拼在一起就行需要对每个模块做精心的数学设计让网络在“隐藏”和“提取”两个方向上都能保持稳定。HiNet是这方面落地比较完整、代码也比较清晰的方案所以很多后续工作都拿它当baseline。你在复现的时候跑通一个HiNet其实就理解了可逆图像隐藏这一类方法的核心套路。2. 方案选型为什么是“可逆神经网络”2.1 可逆网络的前向与反向可逆神经网络最初在生成模型领域用得比较多比如RealNVP、Glow。它的核心特点是网络每一层都是双射输入通过一系列变换得到输出输出也能通过逆变换精确还原输入。放到图像隐藏任务里这个性质太合适了。HiNet把秘密图和封面图的特征拼接起来让它们一起通过可逆层。前向时封面特征和秘密特征会“融合”成新的特征我们把其中一部分还原成图像得到的就是带有秘密信息的隐写图。反向时我们只用隐写图的特征作为输入走可逆层的逆过程就能把封面特征和秘密特征同时拆出来再还原成图像就得到了原始封面图和秘密图。关键点在于这个网络的前向和反向是同一套参数。也就是说模型不需要单独训练一个编码器、一个解码器而是一套参数同时干两件事。这样不仅省了一半参数量而且从数学上保证了信息不会在中间环节丢失。2.2 与U-Net思路的本质区别对比一下U-Net和可逆网络在处理隐藏任务时的差异你会对“可逆”理解得更深。U-Net的思路是编码器提取封面图的特征然后在特征空间里把秘密信息“加”进去再让解码器把带有秘密信息的特征还原成图像。问题是编码器下采样时空间分辨率降低信息从高维映射到低维这个过程一定伴随信息丢失。解码器再怎么努力也无法把丢失的信息凭空变回来。可逆网络则不同。它的核心操作是channel维度的拆分和加性变换不涉及不可逆的空间下采样HiNet用Haar小波做下采样而Haar变换本身就是无损的。所以信息量始终守恒。封面图和秘密图的特征经过可逆层后只是被重新排列、融合没有被丢弃任何分量。这就是为什么HiNet能恢复出更清晰的秘密图。2.3 用生活类比理解可逆过程如果你觉得“可逆”这个概念太抽象可以这样想把封面图比作一张白纸秘密图比作一幅要藏起来的画。U-Net的做法是把画缩小成一个小点塞进白纸的某个角落取出来的时候放大细节肯定糊了。可逆网络的做法是把画拆成一堆透明图层和纸的纹理压在一起取的时候按同样的规则再分离开原画什么样取出来还是什么样。当然实际实现里不可能做到绝对无损因为经过神经网络非线性激活后数值会有微小误差。但比起U-Net那种“压缩-解压”的信息瓶颈可逆网络的误差要小得多至少在视觉上能做到接近无损。3. HiNet核心细节拆解从Haar变换到可逆块3.1 Haar小波变换无损下采样的关键HiNet里最容易被忽略、但极其重要的一个模块是Haar小波变换。你可能好奇可逆网络不是说不下采样吗为什么还需要小波实际上可逆网络在channel维上做变换虽然不损失信息但如果不做空间下采样计算量会非常大而且网络很难捕捉到大范围的图像结构。Haar变换就是用来解决这个问题的。Haar变换把一张图拆成四个分量低频分量LL、水平细节LH、垂直细节HL和对角细节HH。低频分量保留了图像的大致内容三个高频分量保留了边缘和纹理。这四个分量的空间尺寸是原图的一半但通道数变成原来的4倍总信息量不变。因为Haar变换是线性正交变换逆变换就能无损恢复原图。HiNet先对封面图和秘密图分别做Haar变换然后把所有分量拼到一起作为可逆块的输入。这样网络可以在一个空间分辨率减半、通道数增加的特征空间里做融合计算效率高而且信息完全不丢。从实现角度看Haar变换是纯线性的可以写成矩阵运算也可以手写像素重排非常适合在GPU上跑。3.2 可逆块内部结构HiNet的可逆块沿用了RealNVP的additive coupling设计但做了一些改动。最简单的additive coupling是这样把输入张量在通道维上分成两份x1和x2。前向计算时y1 x1 phi(x2)y2 x2 rho(y1)其中phi和rho是两个任意的神经网络通常是几层卷积。由于phi和rho不要求可逆所以可以做得很复杂这让整个可逆块有足够的表达能力。反向计算时只需要简单移项x2 y2 - rho(y1)x1 y1 - phi(x2)你会发现反向过程完全不需要对phi和rho求逆只要按顺序减回去就行。这个设计非常巧妙既保证了可逆性又避免了对网络结构的严格限制。HiNet里通常会堆叠多个这样的可逆块。每个块的内部phi和rho一般用几层带激活函数的卷积实现。为了让网络更容易训练有的实现里还会在可逆块之间加入通道重排让每次拆分时通道的组合方式不同增强特征混合能力。3.3 损失函数怎么组合可逆网络虽然理论上能无损还原但实际训练时还是需要损失函数来约束它。HiNet训练的损失主要由三部分组成。第一部分是隐写损失。把前向输出中对应封面图的那部分特征还原成图像得到隐写图然后计算它和原始封面图之间的差异。常用L1损失加上SSIM损失。L1保证像素级接近SSIM保证结构感知上接近。很多时候L1已经能让PSNR很高但SSIM能更好地避免局部结构变形。第二部分是秘密图恢复损失。在反向过程中把隐写特征和零张量拼接输入可逆层的逆过程恢得秘密图的Haar系数再逆Haar变换得到恢复的秘密图和原始秘密图计算L1和SSIM损失。这部分是保证信息被正确编码的关键。第三部分是封面图恢复损失。反向过程同样能恢复封面图所以也可以顺便计算封面图恢复的损失。这个损失看似多余但它能约束可逆层让前向过程中封面图的信息不被破坏训练会更稳定。训练时这三部分损失按一定权重相加。我常用的权重是隐写损失1.0、秘密图恢复损失1.0、封面图恢复损失0.5。具体比例可以微调不必死守。3.4 评价指标PSNR和SSIM怎么看训练完总要评估效果。图像隐藏领域最常用的两个指标是PSNR峰值信噪比和SSIM结构相似性。PSNR的单位是dB数值越高越好。一般经验是隐写图和封面图的PSNR在35以上人眼基本看不出区别秘密图的恢复PSNR在30以上肉眼就只会有轻微模糊。如果你看到PSNR在40以上那就是非常理想的状态。SSIM的取值范围是0到1越接近1表示结构越相似。和PSNR关注像素误差不同SSIM更关注亮度、对比度、结构的一致性。两个指标要配合看如果一个高一个低说明恢复的结果要么有细微像素误差要么有局部结构变形。4. 手把手代码实现HiNet实战4.1 环境准备与数据预处理先说我跑代码的环境你可以按这个参考Python 3.9、PyTorch 1.13、CUDA 11.7GPU是单张RTX 3090。显存24G跑256x256的RGB图batch size设8比较稳。如果显存小建议先降到128x128或64x64把流程跑通再逐步加大。数据方面封面图可以随便找自然图像数据集比如COCO、Places、ImageNet的一部分都行。秘密图也不挑任何图片集都能用。但有个重要原则封面图和秘密图不要来自同一张图也不要太相似。否则网络会走捷径比如直接把秘密图信息藏在几乎不变的像素里训练时loss很低换到真实场景就拉胯。预处理统一做三步resize到目标尺寸、转Tensor、归一化到[-1, 1]。归一化范围很关键我刚开始用[0,1]训练收敛速度明显变慢换成[-1,1]之后loss下降肉眼可见地变快了。import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader from PIL import Image from torchvision import transforms import os class PairedImageDataset(Dataset): def __init__(self, cover_dir, secret_dir, size256): self.cover_dir cover_dir self.secret_dir secret_dir self.cover_files sorted(os.listdir(cover_dir)) self.secret_files sorted(os.listdir(secret_dir)) self.size size self.transform transforms.Compose([ transforms.Resize((size, size)), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) ]) def __len__(self): return min(len(self.cover_files), len(self.secret_files)) def __getitem__(self, idx): cover_path os.path.join(self.cover_dir, self.cover_files[idx % len(self.cover_files)]) secret_path os.path.join(self.secret_dir, self.secret_files[idx % len(self.secret_files)]) cover Image.open(cover_path).convert(RGB) secret Image.open(secret_path).convert(RGB) return self.transform(cover), self.transform(secret)4.2 Haar变换与可逆块代码Haar变换我用手写像素重排实现不用额外库。核心思路是把2x2的像素块重组成4个通道对应LL、LH、HL、HH。逆变换就是反过来。这个写法在GPU上跑起来很快而且梯度能正常回传。def haar_transform(x): # x: (N, C, H, W)H和W需要是偶数 N, C, H, W x.shape x x.view(N, C, H // 2, 2, W // 2, 2) x x.permute(0, 1, 3, 5, 2, 4).contiguous() x x.view(N, C * 4, H // 2, W // 2) return x def haar_inverse_transform(x): # x: (N, C*4, H, W) N, C4, H, W x.shape C C4 // 4 x x.view(N, C, 2, 2, H, W) x x.permute(0, 1, 4, 2, 5, 3).contiguous() x x.view(N, C, H * 2, W * 2) return x注意这只是把2x2像素块直接重排并不是严格意义上的Haar系数组合。真正的Haar变换还需要做加减运算但对可逆网络来说像素重排本身已经是一种无损的空间下采样方式而且更简单、更容易训练。如果你想做标准Haar可以在重排的基础上对4个通道做一次固定的1x1卷积权重是Haar矩阵的系数。这里我用简化版本效果已经够好。接着是可逆块。phi和rho我用两个小的卷积网络中间夹着LeakyReLU。这里不建议用BatchNorm因为batch size较小时BN会引入噪声影响可逆块的稳定性。可以用GroupNorm或InstanceNorm代替。class ConvSubnet(nn.Module): def __init__(self, in_ch, out_ch, mid_ch64): super().__init__() self.net nn.Sequential( nn.Conv2d(in_ch, mid_ch, 3, 1, 1), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(mid_ch, mid_ch, 3, 1, 1), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(mid_ch, out_ch, 3, 1, 1), ) def forward(self, x): return self.net(x) class InvBlock(nn.Module): def __init__(self, channels): super().__init__() assert channels % 2 0, channels must be even for split half channels // 2 self.phi ConvSubnet(half, half) self.rho ConvSubnet(half, half) def forward(self, x): x1, x2 torch.chunk(x, 2, dim1) y1 x1 self.phi(x2) y2 x2 self.rho(y1) return torch.cat([y1, y2], dim1) def inverse(self, y): y1, y2 torch.chunk(y, 2, dim1) x2 y2 - self.rho(y1) x1 y1 - self.phi(x2) return torch.cat([x1, x2], dim1)之所以用torch.chunk把通道分成两半是为了保证phi和rho的输入输出通道数一致这样加性耦合的加法和减法才能成立。你可能会问拆分后x1和x2各自包含哪些通道重要吗一开始不重要但为了混合信息我会在每经过两个可逆块后做一次通道重排。简单做法是用torch.roll把通道循环移动一半这样下次拆分的组合就变了。4.3 HiNet整体模型与训练循环HiNet的主体就是把Haar变换、多个可逆块、通道重排串起来。我用的配置是4个可逆块输入是封面图和秘密图各做Haar变换后拼接通道数为12 12 24RGB三通道乘以4个子带。每个可逆块内部处理24通道拆成两份12通道。前向过程同时承担隐藏和编码两个任务输入拼接后的特征经过可逆层输出也分为两部分。前一半对应隐写图的Haar系数后一半可以理解为秘密图的编码信息。反向恢复时把隐写图的Haar系数和与它等宽高的零张量拼接输入第一个可逆块的inverse逐块反推最终得到封面图和秘密图的Haar系数再逆变换还原图像。class HiNet(nn.Module): def __init__(self, blocks4): super().__init__() self.blocks blocks channels 24 # RGB * 4 haar subbands * 2(cat cover and secret) self.inv_blocks nn.ModuleList([ InvBlock(channels) for _ in range(blocks) ]) def encode(self, cover, secret): # cover, secret: (N, 3, H, W) h_cover haar_transform(cover) # (N, 12, H/2, W/2) h_secret haar_transform(secret) x torch.cat([h_cover, h_secret], dim1) # (N, 24, H/2, W/2) for i, block in enumerate(self.inv_blocks): x block(x) if i self.blocks - 1: x torch.roll(x, shifts6, dims1) h_stego, h_hidden torch.chunk(x, 2, dim1) stego haar_inverse_transform(h_stego) return stego, h_hidden def decode(self, stego): h_stego haar_transform(stego) B, C, H, W h_stego.shape h_dummy torch.zeros_like(h_stego) x torch.cat([h_stego, h_dummy], dim1) # 逆过程按相反顺序执行 for i in range(self.blocks - 1, -1, -1): if i self.blocks - 1: x torch.roll(x, shifts-6, dims1) x self.inv_blocks[i].inverse(x) h_cover, h_secret torch.chunk(x, 2, dim1) cover haar_inverse_transform(h_cover) secret haar_inverse_transform(h_secret) return cover, secret训练循环的核心点有两个。第一为了计算秘密图恢复损失每次前向之后都要做一次decode。第二优化器更新时从encode得到的stego和从decode恢复的secret都要参与loss计算。这里的decode过程完全走inverse路径梯度会正常回传到可逆块的参数上。由于可逆块的前向和逆向都能回传梯度网络能同时学会隐藏和提取。def train_step(model, opt, cover, secret, loss_fn): opt.zero_grad() stego, _ model.encode(cover, secret) rec_cover, rec_secret model.decode(stego) loss_stego loss_fn(stego, cover) loss_secret loss_fn(rec_secret, secret) loss_cover loss_fn(rec_cover, cover) * 0.5 loss loss_stego loss_secret loss_cover loss.backward() opt.step() return { loss_stego: loss_stego.item(), loss_secret: loss_secret.item(), loss_cover: loss_cover.item(), total: loss.item() }loss_fn我直接用L1和SSIM的组合。如果不想额外装SSIM库可以先只用L1跑通后面再加SSIM。PyTorch官方没有SSIM推荐装pytorch_msssim一行命令搞定。from pytorch_msssim import SSIM class HybridLoss(nn.Module): def __init__(self, ssim_weight0.3): super().__init__() self.l1 nn.L1Loss() self.ssim SSIM(data_range2.0, channel3) def forward(self, pred, target): return self.l1(pred, target) self.ssim_weight * (1 - self.ssim(pred, target))注意data_range要设置成2.0因为归一化后像素范围是[-1,1]跨度是2。这个细节很容易踩坑data_range不对SSIM算出来会整体偏小影响loss占比。4.4 推理脚本隐藏与提取训练完之后推理很简单。隐藏是encode的前半段提取是decode。torch.no_grad() def hide_and_reveal(model, cover, secret): model.eval() stego, _ model.encode(cover, secret) rec_cover, rec_secret model.decode(stego) return stego, rec_secret, rec_cover实际部署时接收方只需要拿到stego图和模型参数就能通过decode提取秘密图。整个流程对计算量要求很低一张256x256的图在3090上跑一次只需要几十毫秒。5. 实验记录与效果分析5.1 训练参数与数据集我这边跑的配置是4个可逆块输入分辨率256x256batch size 8Adam优化器学习率2e-4训练100个epoch。数据集用了COCO val中的5000张图当封面图秘密图用的是一组从网上抓的自然图像两者没有重叠。训练大约花了5个小时。显存占用在11G左右3090没有任何压力。如果你用1080Ti或者V100也能跑就是把batch size降到4。5.2 训练曲线与收敛现象从loss曲线看前500个step下降非常快尤其是秘密图恢复损失几乎是从一开始就在猛降。这说明可逆网络学“隐藏”并不难难的是隐写图的视觉质量。到1万步左右stego loss开始进入平台期在0.03附近波动。此时提取出来的秘密图已经比较清晰PSNR能到33左右。继续训练到5万步stego loss缓慢降到0.02秘密图PSNR可以稳定在36以上。我观察到一个有意思的现象封面图恢复损失和秘密图恢复损失几乎是同步下降的。这符合可逆网络的特性——因为前向和反向共享参数如果秘密图能被很好恢复说明特征没有被破坏封面图的信息自然也在。这就是可逆结构带来的红利你不太需要担心网络对封面图和秘密图“区别对待”。5.3 实际效果隐写图和恢复图质量肉眼对比隐写图和原始封面图在深色区域基本看不出差别。放大到200%能隐约看到纹理上有极细微的变化但正常人不会注意到。PSNR大概在38到42之间波动取决于具体图像。秘密图恢复效果也很理想。恢复图和原图在视觉上几乎一致只有极少数亮度渐变区域会有一些非常轻微的颜色偏差。PSNR一般在34到38之间SSIM在0.97以上。作为对比我用同样的数据训练了一个U-Net版本秘密图恢复PSNR只有29左右而且边缘明显有模糊感。HiNet里的两个分数都明显更高。不过要提醒一句这两个数字是在无任何图像压缩的前提下测的。如果隐写图被JPEG压缩后再提取HiNet的恢复质量会明显下降秘密图PSNR可能掉到28以下。这是目前可逆神经网络图像隐藏的通病很多后续论文都在解决这个问题。如果你只是做演示demo不经过压缩HiNet的表现已经足够惊艳。6. 常见问题与排查技巧实录6.1 问题速查表我把训练和推理过程中最容易遇到的问题整理成了一张表按出现频率排的。问题现象可能原因解决办法loss不下降或下降极慢学习率过大/过小或数据未归一化到[-1,1]先把学习率调到2e-4确认数据范围隐写图有密集网格伪影Haar变换实现有误或Haar重排后通道顺序不对单独用随机图测试haar和inverse是否可逆秘密图恢复出来偏色缺失颜色一致性约束加大SSIM损失权重检查归一化范围训练中期loss突然变成NaN学习率过高或卷积输出数值爆炸降低学习率到1e-4加入gradient clipping显存不够输入分辨率或batch size过大先降到128x128batch设为4跑通再加大封面图恢复很好但秘密图模糊秘密图恢复损失权重太低把loss_secret的权重提到1.5甚至2.0换数据集后效果骤降训练集和验证集分布差异太大最好让封面图和秘密图都覆盖多样的内容类型6.2 独家避坑封面图与秘密图的搭配这个坑我踩了很久才意识到封面图和秘密图的相似度对训练影响极大。我第一次训练时图省事从同一个数据集的5个类别里随机配对结果训练集上效果很好但一到用户提供的真实图片上就崩隐写图出现明显的色块。后来排查下来发现训练时有不少pair是同一类物体比如封面图和秘密图都是狗网络学到的是“在狗图像的纹理里做微调”而不是“通用地把秘密信息编入封面图”。当封面图变成完全不相关的风景图时这套策略就失效了。解决办法是尽量让封面图和秘密图来自差异较大的数据集。比如封面图用自然风景秘密图用文档扫描件或者人像。这样网络必须学习更通用的隐藏策略而不是依赖封面图的先验。这个经验在后续的泛化测试中帮助很大。6.3 小技巧减少网格伪影如果你在恢复图或隐写图上看到轻微的网格状纹理通常是两个原因一是Haar重排后逆变换的边界处理不够干净二是可逆块数量太少空间频率分工不够细。建议先检查Haar的可逆性随便生成一张随机图前向Haar再Inverse和原图逐像素对比误差应该为0。如果可逆性没问题还是出网格就增加可逆块数量或者把ConvSubnet中间的通道数提高。我在做256x256图像时4个块、64维子网够用。但如果你切到512x512建议加到6个块中间通道升到128效果会明显更干净。另外训练结束时把model.eval()敲上如果你忘了切模式某些用了Dropout或BN的实现会在推理时行为异常。HiNet里我没用BN但如果你参考其他代码加了BN这个坑一定要记住。7. 一次完整的验证流程建议如果你想把HiNet真正用起来我建议按这个顺序验证一遍。第一步先准备一个小数据集不用多各100张图就行尺寸统一压缩到128x128。第二步用默认参数训练50个epoch确认代码能跑通、loss能降下来。第三步用验证集选几张典型的图手动调用hide_and_reveal函数保存隐写图、恢复秘密图、恢复封面图放到一起对比。第四步计算这三张图的PSNR和SSIM对照我上表里的经验值检查。第五步做一个简单的鲁棒性测试。把隐写图保存成PNG再读回来看秘密图恢复质量如何。然后再保存成JPEG质量设为90、80、70三档记录PSNR变化。这一步能帮你搞清楚HiNet的真实上限在哪。我自己的测试结果是PNG无损保存后再提取秘密图PSNR几乎不掉JPEG质量90时掉2到3个dB质量70时掉得比较多接近6个dB。如果你需要应对压缩场景可以在这个基础上尝试加一些噪声增强训练但那就是另一个话题了。最后再说一个我实际操作中的体会。很多人一开始就想在512x512甚至1024x1024上跑结果显存爆了然后开始怀疑模型有问题。我的经验是先跑小分辨率把流程、loss、指标全部看明白再往上加。分辨率变大只是计算量的问题算法逻辑不会变。先小后大能省掉你大量调试时间。