☰
噪声水平估计引导的非盲去噪:密集层级网络解析与实践
2026/10/14 7:08:15 网站建设 项目流程

老实坦白说,我第一次看见“Densely Connected Hierarchical Network for Image Denoising(CVPRW2019)”这个标题时,心里第一反应是:又是一篇把DenseNet搬到去噪任务上的结构换皮论文?图像去噪这个赛道,每年出来的网络结构真的不少,但大多是在合成高斯噪声上刷PSNR,一旦拿到真实照片上,效果立刻打回原形。直到我把这篇论文完整读下来并且复现了一遍,才发现它真正值得关注的地方,不在“密集连接”这四个字,而是它把“真实噪声”这个长期被忽视的关键词带到了台前,并且用一个“先估计噪声水平、再做非盲去噪”的思路,把问题拆成了两个可控的子任务。这个思路放在今天看也不算过时,尤其是对做图像降质修复、移动端拍照后处理的朋友来说,参考价值很高。

1. 为什么真实噪声值得单独开一篇文章

1.1 合成高斯噪声与真实噪声的差距

大多数人第一次接触去噪,用的都是高斯噪声。给干净图加上一个和信号无关的、独立同分布的噪声,方差固定,然后让网络去学习映射。这个设定很美好,因为统计特性完全已知,训练数据要多少有多少。但真实相机拍出来的噪声,根本不是这么回事。

真实噪声至少有三个和合成假设显著不同的特点。第一是信号相关性。传感器上的光子散粒噪声服从泊松分布,信号强度越大的区域噪声方差越大,暗部和高光区域的噪声水平完全不同。第二是通道相关性。图像经过去马赛克、白平衡、色彩校正、色调映射这一整套ISP流程后,不同颜色通道之间的噪声已经耦合在一起,噪声不再是一个独立叠加在RGB像素上的标量扰动。第三是空间结构。传感器本身的暗电流、读出噪声、坏点,以及ISP里各种非线性操作,都会让噪声带上局部结构特征,而不是一摊均匀的雪花点。

所以如果在合成高斯噪声上训练一个模型,直接拿去处理真实照片,要么纹理被磨成塑料,要么噪点纹丝不动。这不是网络不够深、不够宽的问题,是训练数据本身和真实分布根本对不上。之前主流的BM3D、DnCNN这类方法,在合成噪声上天花板很高,但在真实图像上往往表现一般,原因就在这个domain gap上。

1.2 端到端方案的最大瓶颈

可能有人会问,既然合成噪声不对,那就搞一堆真实带噪图像对,端到端地训练一个深度网络不就行了?理论上是对的,但实际操作中几乎无法落地。要拿到“真实噪声-干净参照”的训练对,意味着同一个场景要拍两张曝光完全一致的照片,其中一张是无噪声的干净图。白天光线好长曝光可以做到,暗光环境基本不可能。就算用多帧合成的方式生成伪GT,也只能覆盖静态场景,运动物体的鬼影问题又来了。

所以真实图像去噪一直面临一个数据上的死局:合成数据好生成但没有GT的噪声分布不对,真实数据分布对但又拿不到成对训练样本。这篇论文对这个问题给出的解法,用一句话概括:既然端到端学一个“带噪图->干净图”的映射太困难,那就先让网络学会估计噪声的统计特性,再让去噪子网络在一个“已知噪声水平”的条件下做非盲去噪。

换句话说,它把原来那个黑盒式的“一键去噪”,拆成了两个半透明的盒子。噪声估计子网络负责回答“这张图哪里噪声大、哪里噪声小”,去噪子网络负责回答“知道了噪声分布之后,怎么把噪声去掉”。这两个子问题各自的监督信号,都比原来那个端到端目标更容易获得。

2. 网络拆解:两个子网络怎么分工合作

2.1 整体流程

先梳理一下整篇论文的推理流程。输入是一张带噪的sRGB图像,记为I_noisy。它同时经过两条路径:

  1. 噪声估计子网络接收I_noisy,输出一张和输入同分辨率的噪声水平图,记为sigma_hat。这张图上每个像素值表示该位置估计出来的噪声标准差。
  2. 把I_noisy和sigma_hat在通道维上拼接起来,一起输入去噪子网络,最终输出干净的图像。

这里最关键的细节是:噪声估计图不是全局一个标量,而是一整张和图像分辨率相同的图。为什么需要这么细致?因为真实噪声在空间上是变化很大的。同样是ISO 6400拍的一张夜景,阴影区域的噪声强度可能比高光区域高出一倍还要多,不同颜色的物体表面噪声也各有差异。如果全局只有一个sigma,去噪网络就不知道自己应该在哪个区域下手重一点、哪个区域下手轻一点,效果很难做好。

从实现角度说,这种两段式设计也带来一个额外的好处:两个子网络可以分开训练、分开调试。比如在合成数据上先训练噪声估计子网络,观察它输出的sigma_hat是否符合我们注入噪声的分布。满意了之后再联合训练去噪部分。这种“可控、可监督”的训练过程,比一个端到端大黑盒要好诊断得多。

2.2 噪声估计子网络的设计

噪声估计子网络的结构其实并不算复杂,大致是一个比较浅的卷积骨干网络,前面若干层逐步下采样,中间提取上下文信息,后面再上采样恢复到原始分辨率,最后经过卷积输出单通道(或者按通道输出多通道)噪声水平图。设计上故意不用太深的网络,因为噪声估计本质上是在拟合一个相对平滑的统计场,不需要特别多的非线性表达能力,太深反而容易过拟合到具体噪声实例上。

有一个容易忽略的细节值得单独说一说:为什么输出的是sigma图,而不是预测一张“残差噪声图”?如果让网络直接预测噪声图像,它学到的其实是某一个具体噪声实例的采样结果。但去噪需要知道的是噪声的统计强度,而不是噪声长什么样。sigma图是噪声的二阶统计量,和具体采样无关,给去噪子网络提供的是“这里有多脏”的指导信息,而不是“这里的脏长什么样”。这样设计的一个直观好处是,去噪子网络不必去死记硬背某种噪声纹理,只需要根据sigma的指引做自适应的平滑即可,泛化能力会好很多。

2.3 去噪主干里的密集连接与层级结构

去噪子网络是整篇文章结构体现最充分的部分。它的主干可以理解为一个多尺度的层级结构,每一层内部都有密集连接块。所谓密集连接,就是块内每一层的输入,是前面所有层输出的拼接。例如第4层的输入是第1、2、3层的输出拼接之后再经过卷积。这样做的直接好处是特征复用,浅层保留了边缘、纹理等结构信息,深层提取语义和上下文信息,通过密集连接让梯度回传路径变短,训练更稳定。

但只看密集连接并不够。论文里的“Hierarchical”体现在另一个维度:不同尺度的层级之间也存在跨层连接。具体来说,网络不是简单的一条直筒,而是把不同分辨率的特征分支并行处理,并通过跨连接把浅层细节和深层语义融合到一起。这有点像特征金字塔的思路,但是以模块化的方式嵌入到主干中。

还有一个实现细节值得关注:为了在不降低分辨率的情况下扩大感受野,块内大量使用了空洞卷积(dilated convolution)。去噪任务和分类不一样,图像细节非常依赖像素级精度,频繁下采样会导致边缘信息不可逆的丢失。空洞卷积可以在不增加参数量的情况下让卷积核“看到”更大的范围,对真实噪声这种局部结构复杂的退化模式来说,比单纯加深网络管用。

2.4 噪声水平图是怎么“指导”去噪的

在去噪子网络里,噪声水平图的使用方式非常直接:拼接。在输入层把sigma_hat作为额外的通道拼到带噪图像后面,网络在卷积过程中自然学会了根据sigma的数值调整不同位置的滤波强度。

这种拼接方式看着简单,但比很多花哨的条件注入机制更实用。因为sigma图本身就是和图像逐像素对齐的,网络每一层都能通过卷积操作访问到它,相当于在每个位置都拿到了一个“该多大力度去噪”的开关。高sigma区域,网络倾向于用更强平滑;低sigma区域,网络会把更多注意力放在保留纹理细节上。

我在复现的时候曾经试着把sigma图换成调制系数或者门控信号,效果并没有比直接拼接更好,反而引入了额外的参数量和训练不稳定因素。所以这个设计上的“笨办法”其实是被实践验证过的有效做法,也说明论文里那些看起来简单的操作,往往是因为作者踩过更复杂方案的坑。

3. 损失函数与训练策略:真实噪声是怎么训出来的

3.1 三部分损失函数

论文的损失函数由三部分组成,分别是重建损失、噪声估计损失和全变分正则项。用公式表达就是:

L = L_rec + λ1 * L_noise + λ2 * L_TV

重建损失L_rec是去噪子网络输出和真值之间的均方误差。这是整个模型训练的主目标,直接约束去噪效果。噪声估计损失L_noise是噪声估计子网络输出的sigma_hat和真实噪声水平图之间的均方误差。它只在有监督信号时使用,也就是在合成噪声训练阶段,因为合成噪声的sigma是已知的。全变分正则项L_TV作用在sigma_hat上,目的是让噪声水平图在空间上保持平滑。

最后一个正则项的意图值得展开理解。如果不加这个约束,噪声估计子网络很容易把sigma_hat预测成碎片化、充满高频跳变的图。比如一个像素点突然预测出一个异常高的sigma值,去噪子网络就会在这个点上过度平滑,出现一块突兀的模糊区域。全变分正则本质上是一个先验:真实图像的噪声强度在空间上是缓变的,阴影区域再暗,相邻像素的噪声水平也不会相差一个数量级。这个先验极大减少了sigma估计的方差,是稳定训练的关键。

权重λ1和λ2的具体取值,论文给了推荐值。我复现时试过几个量级,发现一个经验区间可以做参考:λ1在0.5到1之间对噪声估计的收敛速度影响不大,λ2则对最终视觉质量非常敏感。λ2设大了sigma_hat会被抹成一片平坦,丢失空间自适应性;设小了sigma_hat会出现很多孤立亮点。先从论文推荐值开始,然后根据sigma_hat的可视化图微调,是我比较推荐的做法。

3.2 两阶段训练:合成数据先行,真实数据微调

训练过程分为两个阶段。第一阶段在合成数据上进行全监督训练。这个阶段的关键不是加普通高斯噪声,而是加Poisson-Gaussian噪声。为什么?因为这种噪声模型同时包含了信号相关项(泊松部分)和信号无关项(高斯部分),和真实相机噪声的物理模型更接近。用公式描述,就是每个通道都有n = sqrt(x + σ^2) * ε + n_g这样的混合形式,其中第一项受信号x影响,第二项是读出噪声等固定成分。

用这类噪声生成的训练对有两个好处:第一,我们可以精确知道每一张训练图的真实sigma图,让噪声估计子网络有明确的监督信号;第二,模型在训练阶段提前见识了“噪声强度随信号变化”的分布模式,迁移到真实图像上比只在固定高斯噪声上训练要稳健得多。

第二阶段是可选的真实数据微调。为什么只能说“可选”?因为真实噪声数据的成对样本几乎拿不到,公开的真实噪声基准集通常不会直接给出原始干净图,甚至有些基准集为了防作弊,真值图完全不对公众开放,只能用提交结果在线评测的方式拿到数值指标。这时候能做的微调策略就很有限:要么在网上找一些用低ISO长曝光得到的近似干净图凑成弱监督对,要么干脆不加第二阶段,把第一阶段训练好的模型直接拿去做推理,并在真实噪声集上做视觉评估。

我自己的实操体会是:第一阶段训好之后,如果合成数据分布覆盖得当(尤其是噪声强度范围足够广),真实噪声上的表现已经可以接受。第二阶段的微调更多是锦上添花,尤其在你有少量带真值的真实噪声数据时,只用很小的学习率再训几十个迭代就行。微调太猛反而会破坏第一阶段学到的稳定特征,很容易在真实数据上过拟合。

3.3 训练细节

几个训练参数可以给到一个可以直接复用的区间。

patch size建议用64或者128,太小的话去噪网络感受野的潜力发挥不出来,太大的话显存和训练速度压力大。数据增强用随机水平翻转加90度旋转,这几乎是图像复原类任务的标配,免费提升泛化能力。优化器选Adam,初始学习率设置在1e-4到1e-3之间。学习率调度建议用阶梯下降,每训练到原迭代数的三分之二和六分之五时各衰减一次到原来的十分之一,这种方式虽然老套,但在去噪任务上比余弦退火稳定。

训练过程中还需要加梯度裁剪,阈值设在1.0左右。去噪网络和噪声估计子网络联合训练时,梯度量级并不一致,噪声估计部分往往梯度更小,去噪部分梯度更大,一旦梯度爆炸,sigma_hat的输出就可能变成NaN,而且很难恢复。还有一些工程细节,比如批归一化层的使用要谨慎,尤其是小batch训练时BN的统计量不稳定,会影响去噪边缘的细节。我在复现时发现去掉部分BN层换成实例归一化,视觉结果在某些真实数据上反而更好。

4. 复现与调参实录:从论文到可用的去噪模型

4.1 数据准备与合成噪声生成

复现的第一步工作是准备训练数据。先准备一批干净的自然图像,来源可以是公开数据集,也可以是自己积累的摄影作品,但要注意版权和隐私问题。然后在训练过程中以patch为单位动态加噪,这样不需要提前把整张数据集存成带噪版本,省下大量磁盘空间。

加噪的逻辑需要写清楚。给定一个干净patch,先计算它的信号相关sigma图,再在每个像素位置上生成服从混合噪声分布的随机值,加到图像上。之后的归一化处理要把图像数值统一到0到1区间,sigma图也保持同样的量纲。这里有一个很容易踩的坑:如果代码里把图像归一化到0到1再计算泊松噪声,但训练标签还是0到255的原始值,网络会陷入很大的数值波动,表现为训练loss降不下来,或者输出图像整体偏灰。

我提供一个合成噪声生成的简化实现思路:先对干净图像x做线性拉伸,让它的均值处于一个合理范围,再计算加性噪声。泊松部分的噪声方差近似等于像素值本身,所以sigma_map可以近似为sqrt(x + sigma_g^2),其中sigma_g是固定的高斯噪声标准差。用这个sigma_map在每个通道上生成高斯随机数,叠加到图像上,就得到了一个带信号相关性质的噪声模拟。

4.2 模型定义与关键代码

模型定义的核心是两类结构:噪声估计子网络和去噪主干。给出一个可运行的简化PyTorch版本,方便直接改造成自己的项目。

import torch import torch.nn as nn class NoiseEstimator(nn.Module): def __init__(self): super().__init__() # 下采样提取上下文 self.down1 = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(32, 32, 3, padding=1), nn.ReLU(inplace=True) ) self.pool1 = nn.Conv2d(32, 64, 3, stride=2, padding=1) self.pool2 = nn.Conv2d(64, 128, 3, stride=2, padding=1) # 上采样恢复到原分辨率 self.up1 = nn.ConvTranspose2d(128, 64, 4, stride=2, padding=1) self.up2 = nn.ConvTranspose2d(64, 32, 4, stride=2, padding=1) self.head = nn.Conv2d(32, 1, 3, padding=1) def forward(self, x): f = self.down1(x) f1 = self.pool1(f) # 这里是简单版,实际可加入多尺度融合 f2 = self.pool2(f1) f2 = self.up1(f2) f2 = f2 + f1 # 跳连 f2 = self.up2(f2) f2 = f2 + f sigma = self.head(f2) return sigma
class DenseBlock(nn.Module): def __init__(self, in_ch, growth=16, dilations=[1, 2, 4, 8]): super().__init__() self.layers = nn.ModuleList() for d in dilations: ch = in_ch + growth * len(self.layers) self.layers.append(nn.Sequential( nn.Conv2d(ch, growth, 3, padding=d, dilation=d), nn.ReLU(inplace=True) )) def forward(self, x): feats = [x] for layer in self.layers: feats.append(layer(torch.cat(feats, dim=1))) return torch.cat(feats, dim=1)

去噪主干则把多个DenseBlock串联,并将噪声估计网络的输出作为额外通道在输入端拼接:

class DenoiseNet(nn.Module): def __init__(self, in_ch=3 + 1): super().__init__() self.head = nn.Conv2d(in_ch, 64, 3, padding=1) self.db1 = DenseBlock(64) self.db2 = DenseBlock(64 + 64) # 简化示意 self.tail = nn.Conv2d(64 + 64, 3, 3, padding=1) def forward(self, noisy, sigma_map): x = torch.cat([noisy, sigma_map], dim=1) x = self.head(x) x = torch.cat([x, self.db2(self.db1(x))], dim=1) out = self.tail(x) return out

代码只是一个骨架,但足以跑通训练流程。真正复现时,把DenseBlock的层数加到4到6层,并引入多分支不同空洞率来扩大感受野,效果会更接近原论文的设定。

4.3 训练流程与观测指标

训练时整体流程是:从干净图上随机裁patch,加合成噪声,输入噪声估计子网络得到sigma_hat,再把带噪图和sigma_hat拼接后输入去噪主干,得到去噪结果。三个损失项同时参与反向传播,权重系数设置好后整体训练。

训练过程中除了盯着去噪结果的PSNR,我强烈建议同时也把sigma_hat的可视化输出保存下来。一个正常的噪声估计结果,应该是和原图明暗结构高度相关且空间平滑的图。暗部区域数值高,亮部区域数值低,边缘没有剧烈跳变。如果看到的sigma图是一团混乱的高频噪声,说明噪声估计子网络没有收敛,或者TV正则权重太小。只看去噪PSNR很难发现这个问题,因为它可能在验证集上表现尚可,但迁移到真实数据时崩得很惨。

验证阶段,如果有干净真值,直接计算PSNR和SSIM。如果没有,只能把去噪结果放大到100%对比纹理细节,观察有没有油画的磨皮感,以及边缘是否保持锐利。

4.4 推理阶段的免费提升:self-ensemble

这里有一个推理阶段不用重新训练就能提升效果的小技巧:self-ensemble,也叫几何自集成。做法是把输入图像做8种几何变换(翻转加旋转),分别输入网络得到8个去噪结果,然后把这些结果变换回原始方向,最后取平均作为最终输出。

实现上很简单,但收益比较明显。我在复现中用这个技巧在合成噪声测试集上平均提升了0.2到0.5个dB的PSNR,在真实噪声图像上的视觉效果也更干净、更少伪影。原因很好理解:网络对某个特定方向的纹理响应存在方向偏差,通过多方向推理取平均,相当于把这种方向偏差抵消掉了。这个方法唯一的代价是推理时间变成原来的8倍,如果用在移动端实时处理,需要根据算力情况取舍。

5. 常见问题与踩坑记录

5.1 问题速查表

现象可能原因解决方案
sigma图退化成全零或常数噪声估计损失权重太小,模型选择忽略该分支提高lambda1,或先固定去噪主干单独训练噪声估计网络
sigma图充满高频噪声TV正则权重太小增大lambda2,观察sigma图是否变平滑
输出图像整体偏灰数据归一化范围不一致,标签和输入不在同一量纲统一使用0-1范围,检查噪声注入代码的数值范围
PSNR高但纹理过度平滑训练时噪声强度范围过窄,整体偏向强去噪扩大加噪范围,加入小噪声样本平衡训练
真实图像上出现块状伪影训练patch的统计量分布和真实图像不一致使用更大的patch或加入全局归一化
训练loss震荡或爆炸梯度量级过大,两个子网络不均衡开启梯度裁剪,初始化时把去噪主干最后一个卷积层偏置置零
边界出现严重伪影卷积padding方式不一致,输入和输出尺寸没对齐统一输出尺寸,必要时用反射padding替代零padding

5.2 我踩得最深的三个坑

第一个坑是sigma图退化。第一次把整个网络接好端到端训练,跑了十几个epoch,去噪PSNR一直上不去,检查中间输出发现sigma_hat已经被优化到了全零。原因分析下来,是去噪主干太强,即使输入sigma为全零,它也能靠自身特征勉强做出去噪效果,于是模型干脆把噪声估计分支的梯度屏蔽了,反正不影响主目标。解决办法是先冻结去噪主干,只让噪声估计子网络训练几十个迭代,等sigma_hat已经能反映输入噪声分布了,再解冻联合训练。这个操作非常关键,建议所有复现者都这样做。

第二个坑是输出偏灰。这个问题的本质是数值范围错位。有一次我把干净图按0到255范围做标签,但输入图像已经归一化到0到1,模型输出被约束在0到1附近,反归一化换算时又忘了恢复均值,结果整个图像像蒙了一层灰纱。这个错误很隐蔽,因为PSNR在数值上看起来还不算太低,但视觉上完全不可用。排查方法是直接打印网络输出的像素值范围,检查是否和标签的像素值范围一致,不浪费时间在诡异的视觉现象上。

第三个坑是真实噪声上过度平滑。合成训练时我只用了中等强度的噪声范围,模型在验证集上PSNR很好看,但拿到高ISO真实照片上,细节全被磨掉了。原因是训练数据里根本没有覆盖真实世界里那种极端噪声情况,模型没见过的强度就只能按“最强去噪”来处理。解决方法是把加噪范围扩大到覆盖多个ISO档位,并且在合成噪声里加入更大的sigma上限。这个经验让我意识到,真实去噪模型的数据增强,核心不是翻转旋转,而是噪声强度的充分覆盖。

最后再分享一个小技巧

关于这篇论文,实际跑完一圈之后,我最大的感受是:真实噪声去噪的关键不在网络结构有多深,而在于能不能把问题拆成一个有监督、可诊断的子任务。噪声估计子网络训练好之后,它不仅服务于去噪,本身也是一个很有用的副产品。比如可以用sigma_hat做图像质量分析的辅助特征,判断一张照片哪些区域噪点超标;也可以在前置相机流程中根据sigma图做自适应增强,拍暗光时对高噪声区域做额外处理。这就是所谓的“可控”带来的价值。

另外,如果你确实需要在生产环境里使用这个模型,建议在推理前对输入图像做一个简单的噪声强度预检。如果整张图的sigma_hat均值都很低,可以直接跳过比较重的去噪路径,或者切换到一个轻量版的去噪网络处理,这样可以在画质几乎不变的情况下省下不少耗时。这些小改动不需要重新训练,只是在已经训练好的模型外围加一层调用逻辑,性价比很高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询