简介:一个基于Python实现的图像去雨(Deraining)项目,面向图像处理与计算机视觉学习者,旨在去除照片中的雨滴干扰,提升恶劣天气下拍摄图像的清晰度。项目围绕预处理、特征提取、雨滴建模与背景恢复等关键步骤展开,并可能借助OpenCV、scikit-image或卷积神经网络等常见技术来识别和分离雨滴。压缩包共4个文件,包含2篇PDF学术论文、1份Python源代码和1份说明文档,整体大小仅3.72MB,体量轻巧,便于下载后快速阅读和实践。已有199人学习下载,适合希望系统理解去雨算法原理并动手复现的初学者或研究人员。通过这份资源,读者可以对照论文掌握ICONIP等前沿去雨方法,参考源码理解CNN在雨滴分割与图像重建中的具体应用,同时借助说明文档快速跑通流程,为后续开展相关实验或项目打下基础。 Derain这个词,圈内人一看就知道是single image deraining(单张图像去雨)的简称。用一个神经网络把附着在画面上的雨线、雨雾去掉,还原出干净的背景内容,这在自动驾驶、安防监控、户外摄影这些场景里都是刚需能力。我这次做的就是一个从零搭建的Derain项目,从雨图合成、网络设计到训练推理全流程跑通,整个过程踩了不少坑,也沉淀了比较完整的实操经验,这篇就把它完整拆开,方便想入手图像复原方向的朋友直接参考。
1. 项目定位:Derain到底要解决什么问题
1.1 去雨任务的数学表达
图像去雨任务本质上是一个病态逆问题。我们观察到带雨图像O,希望恢复出无雨的清晰背景B。经典雨图模型可以写成:
O = B + S
其中S代表雨线层。但这只是一个非常原始的线性叠加,实际雨图还要复杂得多——雨线有方向性、有透明度变化、有运动模糊造成的拖影,远处还有雨雾导致的对比度衰减。更贴近物理的写法是:
O = α(B + S) + (1 - α)B
这个公式的意思是:在雨线覆盖的区域,观测值由背景和雨线混合而成,混合系数α控制雨的密度和强度。之所以要把这部分讲清楚,是因为合成数据的质量直接决定了模型的上限。网络结构再复杂,数据里的雨纹不符合物理规律,训练出来也就是个花架子。
1.2 为什么去雨比去噪更难
很多人会拿去雨和去噪做对比,实际上两者的难度不在一个量级。噪声是随机的、各向同性的,网络很容易学习到“平滑”这个策略;但雨线是结构化的——它有方向、有明暗过渡、有空间分布规律。更麻烦的是,雨线的尺度跨度极大,近处的雨滴粗大清晰,远处的雨丝细如发丝,甚至还有雨雾产生的全局亮度影响。
这就是为什么当时这个项目做技术选型时,我特意避开了单纯的CNN堆叠,而是采用了残差学习配合多尺度特征融合的思路。网络不去直接预测清晰图像,而是先预测“雨纹残差”,再用输入减掉残差得到清晰图。这个设计能让网络把注意力集中在结构化的雨线上,而不是花大量参数去重建背景纹理。这个思路在PReNet、RESCAN这些经典去雨论文里都被验证过,属于经过实战检验的可靠方向。
2. 数据先行:雨图是“造”出来的
2.1 数据合成细节
去雨领域和超分、去噪一样,最头疼的还是训练数据。目前业界并没有大规模、高质量的真实成对雨图数据集,主流的做法都是在清晰图上合成雨纹。我实测下来,这套合成方案的效果足够支撑一个演示级甚至准产品级的模型训练。
雨线合成的核心是用带方向的运动核生成不同角度、不同长度的雨线条纹:
import cv2 import numpy as np def generate_rain_streak(h, w, angle_deg=70, length=30, thickness=1.5, blur_sigma=1.2): """ 生成单条雨线,angle_deg为雨滴下落方向角 返回归一化的雨线强度图 """ mask = np.zeros((h, w), dtype=np.float32) # 随机选择一个起点,沿着指定角度绘制直线 x0 = np.random.randint(0, w) y0 = np.random.randint(0, h) dx = int(np.sin(np.deg2rad(angle_deg)) * length) dy = int(np.cos(np.deg2rad(angle_deg)) * length) cv2.line(mask, (x0, y0), (x0 + dx, y0 + dy), 1.0, int(thickness), cv2.LINE_AA) # 高斯模糊模拟相机运动导致的拖影效果 mask = cv2.GaussianBlur(mask, (0, 0), blur_sigma) return mask def synthesize_rainy_image(clean_img, streak_num=30, alpha_range=(0.4, 0.8)): h, w = clean_img.shape[:2] streak_layer = np.zeros_like(clean_img, dtype=np.float32) for _ in range(streak_num): angle = np.random.uniform(-15, 15) # 雨线角度有一定随机性 length = np.random.uniform(15, 60) # 雨线长度 thickness = np.random.uniform(1, 2) # 雨线粗细 streak = generate_rain_streak(h, w, angle_deg=angle, length=length, thickness=thickness) alpha = np.random.uniform(*alpha_range) # 雨线强度 streak_layer[..., 0] = np.maximum(streak_layer[..., 0], streak * alpha) streak_layer[..., 1] = np.maximum(streak_layer[..., 1], streak * alpha) streak_layer[..., 2] = np.maximum(streak_layer[..., 2], streak * alpha) rainy_img = clean_img.astype(np.float32) + streak_layer * 255.0 rainy_img = np.clip(rainy_img, 0, 255).astype(np.uint8) return rainy_img, streak_layer这里有几个关键细节值得展开。第一,雨线方向不能太集中在同一个角度,否则训练出来的模型只会对特定雨强有效,换个角度就失效了,所以angle在-15到15度之间随机采样。第二,雨线是逐条用max叠加而不是直接求和,这样能模拟真实雨场景中雨线交叠、遮挡的视觉效果。第三,雨线做高斯模糊非常关键,真实的雨在曝光时间内是有运动轨迹的,边缘不会锐利得像刀割,模糊后的雨线能让模型学会处理更真实的雨痕。
2.2 训练集组织和增强策略
数据集我采用的是公开的BSD400、Rain100L这些图像库做底图,再加上一部分自己收集的街景高清图,统一缩放到256x256分辨率作为训练patch。一批样本里混合了小雨、中雨、大雨三种强度,比例大概在3:4:3。这样做的原因是,如果只训练大雨样本,模型会倾向于把整张图压暗,因为它在数学上找到了一个“更省力”的解,小雨区域的残差方向反而学偏了。
数据增强方面,我用了随机水平翻转、随机90度旋转、随机色彩抖动(亮度正负10%、饱和度和对比度轻度扰动)。值得强调的是色彩抖动,去雨网络很容易把色彩和纹理一起“滤掉”,加入色彩扰动可以强迫网络关注结构信息而不是依赖颜色恒常性来做判断。实际效果表明,加上这一步之后,输出图像的颜色保留度明显变好。
3. 模型构建与训练:残差U-Net方案
3.1 网络结构设计
网络结构我采用的是残差U-Net变体,整体架构由编码器、中间残差块、解码器三部分组成,并引入跳层连接。编码器做3次下采样,通道数从32逐级翻倍到256,每层由两个卷积块组成,激活函数用LeakyReLU(斜率0.2),归一化层选择GroupNorm而不是BatchNorm——原因后面会细说。中间部分串联4个残差块,每个残差块内部包含两个3x3卷积。解码器用转置卷积做3次上采样,并和编码器同尺度的特征图拼接,最后输出一个和输入尺寸相同的通道数为1的残差雨纹图。
这里有一个我不太建议新手踩的坑:BatchNorm在图像复原任务中会引入严重的batch内部统计信息依赖,当batch size较小或者验证时输入尺寸变化较大时,模型效果会出现明显抖动。GroupNorm不依赖batch维度,每组自己归一化,稳定性好很多。实际训练中,GroupNorm版本在验证集上的PSNR比BatchNorm版高大约0.4dB左右,而且收敛更平滑。
import torch import torch.nn as nn class ResBlock(nn.Module): def __init__(self, channels): super().__init__() self.conv1 = nn.Conv2d(channels, channels, 3, 1, 1) self.gn1 = nn.GroupNorm(8, channels) self.act = nn.LeakyReLU(0.2) self.conv2 = nn.Conv2d(channels, channels, 3, 1, 1) self.gn2 = nn.GroupNorm(8, channels) def forward(self, x): out = self.act(self.gn1(self.conv1(x))) out = self.gn2(self.conv2(out)) return self.act(out + x) class DerainNet(nn.Module): def __init__(self, in_channels=3): super().__init__() self.enc1 = nn.Sequential(nn.Conv2d(in_channels, 32, 3, 1, 1), nn.GroupNorm(8, 32), nn.LeakyReLU(0.2)) self.enc2 = self._make_block(32, 64, downsample=True) self.enc3 = self._make_block(64, 128, downsample=True) self.enc4 = self._make_block(128, 256, downsample=True) self.resblocks = nn.Sequential(*[ResBlock(256) for _ in range(4)]) self.dec3 = self._make_block(256 + 128, 128, upsample=True) # 与enc3拼接 self.dec2 = self._make_block(128 + 64, 64, upsample=True) # 与enc2拼接 self.dec1 = self._make_block(64 + 32, 32, upsample=True) # 与enc1拼接 self.out_conv = nn.Conv2d(32, in_channels, 3, 1, 1) def _make_block(self, in_ch, out_ch, downsample=False, upsample=False): layers = [] if downsample: layers.append(nn.Conv2d(in_ch, out_ch, 3, 2, 1)) # 步长为2下采样 elif upsample: layers.append(nn.ConvTranspose2d(in_ch, out_ch, 3, 2, 1, output_padding=1)) else: layers.append(nn.Conv2d(in_ch, out_ch, 3, 1, 1)) layers.append(nn.GroupNorm(8, out_ch)) layers.append(nn.LeakyReLU(0.2)) return nn.Sequential(*layers) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(e1) e3 = self.enc3(e2) e4 = self.enc4(e3) m = self.resblocks(e4) d3 = self.dec3(torch.cat([m, e3], dim=1)) d2 = self.dec2(torch.cat([d3, e2], dim=1)) d1 = self.dec1(torch.cat([d2, e1], dim=1)) rain_residual = self.out_conv(d1) return x - rain_residual # 残差学习:输出 = 输入 - 预测雨纹3.2 损失函数组合
项目没有只用单一的L1损失,而是采用“像素损失 + 感知损失 + 结构损失”的三合一组合。L1损失保证逐像素强度的一致性;感知损失借助VGG19的relu2_2和relu3_3层特征,在更语义化的层面拉近生成结果和干净图像的距离——这一步能显著减少雨雾残留导致的“糊感”;结构损失(SSIM Loss)则保护边缘和纹理不被过度平滑。
总损失函数为:
L = L1 + 0.1 * L_percep + 0.05 * L_ssim
权重分配是我多次实验试出来的。感知损失如果超过0.1,生成图像会出现明显的色彩偏移和细节“油画感”,这是因为VGG特征空间中的差异不完全等价于视觉质量的差异。SSIM损失权重不宜太高,否则优化过程会变得不稳定,SSIM的梯度在局部区域容易震荡。三者的比例调到这个位置,既保证了去雨强度,又保留了纹理细节。
3.3 训练超参数与评估指标
训练配置如下:输入patch大小为128x128,batch size为16,优化器选用Adam,初始学习率2e-4,每30个epoch衰减为原来的一半,总共训练120个epoch。学习率这块我吃过亏,最初直接按1e-3起跑,前20个epoch损失确实下降快,但到达谷底后一直在震荡,无法收敛到更优的极小值点。后来改为2e-4起步,虽然前期慢一点,但后半程稳定得多,最终PSNR反而更高。
评估指标采用图像复原领域的两个标准:PSNR(峰值信噪比)和SSIM(结构相似性)。PSNR本质上是基于MSE的对数变换,数值越高说明像素层面越接近;SSIM则是从亮度、对比度、结构三个维度综合度量,更贴近人眼感知。只盯着PSNR调参是一个很常见的误区,PSNR高但视觉上纹理怪异的情况我遇到太多次了,所以每次epoch结束我都是两个指标一起看,以SSIM为主做早停判断。
4. 训练过程与效果复盘
训练日志记录得比较详细。模型从第5个epoch开始就能目视看出雨纹明显衰减,第20个epoch时背景纹理开始恢复,第60个epoch后PSNR提升趋于平缓,SSIM还有缓慢上升。最终在合成测试集上的效果为:PSNR从带雨图的22.1dB提升到31.8dB,SSIM从0.72提升到0.95。
一个值得注意的现象是,模型在小角度单调雨线场景下的去雨非常干净(实测能接近99%去除率),但在多角度交叉雨线场景下会有少量残留。原因很好理解——合成数据里交叉雨线出现的频率明显低于单方向雨线,模型对这类样本的拟合不充分。后来我在合成数据里人为提高了多角度雨线的比例,这个问题得到了明显改善。
推理阶段的速度同样关键。这个模型在单张RTX 3090上处理一张512x512的图片大约需要45ms,在CPU上则大约1.2秒。如果要对视频流做实时去雨,建议把转置卷积替换成PixelShuffle上采样,再叠加半精度FP16推理,整体延迟可以压到20ms以内。但要注意,模型修剪之后需要重新微调,直接砍层数会导致去雨能力断崖式下降。
5. 常见问题与排查技巧实录
5.1 典型故障速查表
我把训练过程中遇到的高频问题整理成了一张表,照着排查能省下大量试错时间。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 训练Loss震荡不下降 | 学习率过大、batch size太小 | 学习率降到1e-4,batch size提升到16以上 |
| 去雨后图像灰蒙蒙一片 | 雨纹残差估计有整体偏移 | 检查归一化层,尝试给残差加零均值约束 |
| 输出图像出现网格伪影 | 转置卷积重叠导致棋盘效应 | 改用PixelShuffle上采样,或加FFT频域损失 |
| 细节被过度平滑 | 感知损失权重过高 | 降低感知损失权重,改用浅层VGG特征 |
| 验证集效果差但训练集好 | 合成数据分布单一 | 增大雨纹角度、密度、尺度变化范围 |
| 输入尺寸变化后效果变差 | BN依赖batch统计信息 | 切换到GroupNorm或InstanceNorm |
5.2 从合成数据到真实雨图的泛化问题
合成数据和真实雨图之间存在明显的domain gap,这个项目做到最后也不得不面对它。真实雨图里的雨往往带有复杂的雾气散射效应和镜片反光,远不是“背景加几条雨线”能完全覆盖的。解决思路有几个方向,我实测推荐的方式是在合成阶段加入雨雾衰减模型,把大气散射模型同时加进来,让合成雨图在全局对比度和亮度上也向真实场景靠拢。另一个方向是引入少量弱监督的真实雨图做域自适应微调,不需要像素级标注,只要图像级标签,通过GAN的域判别器让模型在特征层面拉近合成域和真实域的分布。
这个项目做完后,我最大的体会是:图像复原任务里,数据工程和模型结构拼的是运气,数据拼的是耐心,合成数据的多样性直接决定模型的天花板。另外,损失函数的设计不是照搬论文就能奏效的,最好在验证集上观察具体是雨纹残留、细节丢失还是色彩失真,再反向调整每一项损失的权重。最后再分享一个实在的小技巧:每次epoch结束把当前模型处理同一张固定测试图的效果也存下来,自己肉眼扫一遍,比只盯指标曲线更能发现问题——因为指标会骗人,眼睛不会。
本文还有配套的精品资源,点击获取