Pytorch图像去噪实战(七):Noise2Noise自监督图像去噪实战,没有干净图也能训练模型
Pytorch图像去噪实战(七):Noise2Noise自监督图像去噪实战,没有干净图也能训练模型
一、问题场景:真实项目里根本没有干净图
前面几篇文章中,我们默认有 clean image,也就是干净图像。
训练数据通常是:
noisy -> clean但在真实项目里,经常遇到一个很现实的问题:
我们只有带噪图片,没有对应的干净图片。
比如:
- 夜间监控图像
- 医学低剂量图像
- 老照片扫描图
- 工业相机采集图
- 用户上传的真实图片
这种情况下,如果没有 clean target,普通监督学习就很难训练。
一开始我也尝试过人工构造干净图,比如用传统滤波先处理一遍作为伪标签。
但效果很差,因为伪标签本身就模糊,会把模型带偏。
后来我采用了 Noise2Noise 的思路:
不需要干净图,只需要同一场景下两张不同噪声版本的图。
二、Noise2Noise的核心思想
传统监督去噪:
noisy_image -> clean_imageNoise2Noise训练方式:
noisy_image_a -> noisy_image_b前提是:
- 两张图对应同一个干净信号
- 噪声是独立随机的
- 噪声均值接近0
模型在大量样本上学习后,会趋向恢复共同的干净结构,而不是随机噪声。
三、为什么noisy到noisy也能学?
假设真实图像是 x,两张带噪图分别是:
y1 = x + n1 y2 = x + n2其中 n1 和 n2 是独立噪声。
训练目标:
model(y1) -> y2因为 n2 是随机的,模型无法预测具体噪声,只能学习稳定存在的 x。
最终模型会学到接近 clean image 的输出。
这就是 Noise2Noise 最有意思的地方。
四、工程适用场景
Noise2Noise特别适合:
- 同一场景可多次采集
- 连续视频帧
- 医学影像重复采样
- 工业检测多次曝光
- 没有clean标签的数据
如果你只有单张带噪图,Noise2Noise不一定适合,可以考虑 Noise2Void 或 Blind-Spot Network。
五、工程目录结构
noise2noise_denoise/ ├── data/ │ ├── noisy_a/ │ └── noisy_b/ ├── models/ │ └── unet.py ├── dataset.py ├── train.py ├── eval.py └── utils.py这里 noisy_a 和 noisy_b 中的图片要一一对应。
比如:
noisy_a/001.png noisy_b/001.png六、数据集实现
dataset.py
importosfromPILimportImagefromtorch.utils.dataimportDatasetimporttorchvision.transformsastransformsclassNoise2NoiseDataset(Dataset):def__init__(self,noisy_a_dir,noisy_b_dir):self.noisy_a_paths=sorted([os.path.join(noisy_a_dir,name)fornameinos.listdir(noisy_a_dir)ifname.lower().endswith((".jpg",".png",".jpeg"))])self.noisy_b_paths=sorted([os.path.join(noisy_b_dir,name)fornameinos.listdir(noisy_b_dir)ifname.lower().endswith((".jpg",".png",".jpeg"))])assertlen(self.noisy_a_paths)==len(self.noisy_b_paths)self.transform=transforms.Compose([transforms.Resize((256,256)),transforms.ToTensor()])def__len__(self):returnlen(self.noisy_a_paths)def__getitem__(self,idx):img_a=Image.open(self.noisy_a_paths[idx]).convert("L")img_b=Image.open(self.noisy_b_paths[idx]).convert("L")img_a=self.transform(img_a)img_b=self.transform(img_b)returnimg_a,img_b七、模型选择:使用UNet作为基础网络
Noise2Noise不是一个具体网络,而是一种训练方式。
这里我们用一个轻量 UNet。
models/unet.py
importtorchimporttorch.nnasnnclassConvBlock(nn.Module):def__init__(self,in_channels,out_channels):super().__init__()self.net=nn.Sequential(nn.Conv2d(in_channels,out_channels,3,padding=1),nn.ReLU(inplace=True),nn.Conv2d(out_channels,out_channels,3,padding=1),nn.ReLU(inplace=True))defforward(self,x):returnself.net(x)classSimpleUNet(nn.Module):def__init__(self):super().__init__()self.pool=nn.MaxPool2d(2)self.enc1=ConvBlock(1,64)self.enc2=ConvBlock(64,128)self.bottleneck=ConvBlock(128,256)self.up2=nn.ConvTranspose2d(256,128,2,2)self.dec2=ConvBlock(256,128)self.up1=nn.ConvTranspose2d(128,64,2,2)self.dec1=ConvBlock(128,64)self.out=nn.Conv2d(64,1,1)defforward(self,x):e1=self.enc1(x)e2=self.enc2(self.pool(e1))b=self.bottleneck(self.pool(e2))d2=self.up2(b)d2=torch.cat([d2,e2],dim=1)d2=self.dec2(d2)d1=self.up1(d2)d1=torch.cat([d1,e1],dim=1)d1=self.dec1(d1)returnself.out(d1)八、训练代码
train.py
importtorchfromtorch.utils.dataimportDataLoaderfromdatasetimportNoise2NoiseDatasetfrommodels.unetimportSimpleUNetdeftrain():device=torch.device("cuda"iftorch.cuda.is_available()else"cpu")dataset=Noise2NoiseDataset("data/noisy_a","data/noisy_b")loader=DataLoader(dataset,batch_size=8,shuffle=True,num_workers=4)model=SimpleUNet().to(device)optimizer=torch.optim.AdamW(model.parameters(),lr=1e-4)criterion=torch.nn.L1Loss()forepochinrange(1,81):model.train()total_loss=0fornoisy_a,noisy_binloader:noisy_a=noisy_a.to(device)noisy_b=noisy_b.to(device)pred=model(noisy_a)loss=criterion(pred,noisy_b)optimizer.zero_grad()loss.backward()optimizer.step()total_loss+=loss.item()print(f"Epoch{epoch}, Loss:{total_loss/len(loader):.6f}")ifepoch%10==0:torch.save(model.state_dict(),f"noise2noise_epoch_{epoch}.pth")if__name__=="__main__":train()九、推理代码
importtorchfromPILimportImageimporttorchvision.transformsastransformsimporttorchvision.utilsasvutilsfrommodels.unetimportSimpleUNet device=torch.device("cuda"iftorch.cuda.is_available()else"cpu")model=SimpleUNet().to(device)model.load_state_dict(torch.load("noise2noise_epoch_80.pth",map_location=device))model.eval()img=Image.open("test_noisy.png").convert("L")transform=transforms.Compose([transforms.Resize((256,256)),transforms.ToTensor()])noisy=transform(img).unsqueeze(0).to(device)withtorch.no_grad():pred=model(noisy)pred=torch.clamp(pred,0.0,1.0)vutils.save_image(pred.cpu(),"noise2noise_result.png")十、如果没有成对noisy图怎么办?
这是实际工程中最常见的问题。
如果没有同一场景的两张带噪图,可以考虑几种方案:
1. 从视频帧中构造
连续视频中相邻帧内容相近,可以近似作为 paired noisy 数据。
2. 多次采集
工业相机、医学设备、监控系统通常可以多次采样。
3. 用数据增强模拟第二噪声版本
如果只有 clean 不可得,但有一张 noisy,可以生成另一个噪声扰动版本。
不过这种方式严格来说不是真正的 Noise2Noise,效果要谨慎验证。
十一、踩坑记录
坑1:两张图没有对齐
Noise2Noise要求 noisy_a 和 noisy_b 内容一致。
如果两张图发生位移,模型会学糊。
解决:
- 数据采集时固定相机
- 做图像配准
- 只使用稳定区域
坑2:噪声不是独立的
如果两张图噪声模式相同,比如固定条纹噪声,模型可能学到噪声。
解决:
- 尽量使用独立采样
- 增加数据量
- 对固定噪声单独建模
坑3:训练结果偏模糊
原因可能是:
- 图像未对齐
- 数据量太少
- L1目标本身偏保守
解决方式:
- 使用patch训练
- 加边缘损失
- 提升数据质量
十二、效果验证
Noise2Noise的效果取决于数据条件。
如果满足:
- 同一场景
- 独立噪声
- 图像对齐
那么它可以在没有clean标签的情况下获得不错效果。
| 方法 | 是否需要clean | 适用场景 |
|---|---|---|
| 普通监督去噪 | 需要 | 合成数据 |
| Noise2Noise | 不需要 | 多次采样 |
| Noise2Void | 不需要 | 单图自监督 |
十三、适合收藏总结
Noise2Noise训练流程
- 准备两组对应noisy图
- noisy_a作为输入
- noisy_b作为目标
- 用UNet训练
- 推理时输入单张noisy图
避坑清单
- 两张图必须对齐
- 噪声最好独立
- 数据量不能太少
- 不适合严重运动场景
- 固定噪声可能被模型学进去
十四、优化建议
可以继续改进:
- 加图像配准模块
- 用视频帧构造训练集
- 加时间一致性损失
- 使用更强UNet
- 结合Noise2Void处理单图场景
结尾总结
Noise2Noise最有价值的地方在于:
它打破了图像去噪必须依赖clean标签的限制。
在真实项目中,干净图往往比模型更难获得。
如果你的业务场景可以采集多张同一对象的带噪图,Noise2Noise是非常值得尝试的方案。
下一篇预告
Pytorch图像去噪实战(八):Noise2Void盲点网络实战,只有单张带噪图也能训练
