Pytorch图像去噪实战(七):Noise2Noise自监督图像去噪实战,没有干净图也能训练模型
一、问题场景:真实项目里根本没有干净图
前面几篇文章中,我们默认有 clean image,也就是干净图像。
训练数据通常是:
text
noisy -> clean
但在真实项目里,经常遇到一个很现实的问题:
我们只有带噪图片,没有对应的干净图片。
比如:
- 夜间监控图像
- 医学低剂量图像
- 老照片扫描图
- 工业相机采集图
- 用户上传的真实图片
这种情况下,如果没有 clean target,普通监督学习就很难训练。
一开始我也尝试过人工构造干净图,比如用传统滤波先处理一遍作为伪标签。
但效果很差,因为伪标签本身就模糊,会把模型带偏。
后来我采用了 Noise2Noise 的思路:
不需要干净图,只需要同一场景下两张不同噪声版本的图。
二、Noise2Noise的核心思想
传统监督去噪:
text
noisy_image -> clean_image
Noise2Noise训练方式:
text
noisy_image_a -> noisy_image_b
前提是:
- 两张图对应同一个干净信号
- 噪声是独立随机的
- 噪声均值接近0
模型在大量样本上学习后,会趋向恢复共同的干净结构,而不是随机噪声。
三、为什么noisy到noisy也能学?
假设真实图像是 x,两张带噪图分别是:
text
y1 = x + n1
y2 = x + n2
其中 n1 和 n2 是独立噪声。
训练目标:
text
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 中的图片要一一对应。
比如:
text
noisy_a/001.png
noisy_b/001.png
六、数据集实现
dataset.py
python
import os
from PIL import Image
from torch.utils.data import Dataset
import torchvision.transforms as transforms
class Noise2NoiseDataset(Dataset):
def __init__(self, noisy_a_dir, noisy_b_dir):
self.noisy_a_paths = sorted([
os.path.join(noisy_a_dir, name)
for name in os.listdir(noisy_a_dir)
if name.lower().endswith((".jpg", ".png", ".jpeg"))
])
self.noisy_b_paths = sorted([
os.path.join(noisy_b_dir, name)
for name in os.listdir(noisy_b_dir)
if name.lower().endswith((".jpg", ".png", ".jpeg"))
])
assert len(self.noisy_a_paths) == len(self.noisy_b_paths)
self.transform = transforms.Compose([
transforms.Resize((256, 256)),
transforms.ToTensor()
])
def __len__(self):
return len(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)
return img_a, img_b
七、模型选择:使用UNet作为基础网络
Noise2Noise不是一个具体网络,而是一种训练方式。
这里我们用一个轻量 UNet。
models/unet.py
python
import torch
import torch.nn as nn
class ConvBlock(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)
)
def forward(self, x):
return self.net(x)
class SimpleUNet(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)
def forward(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)
return self.out(d1)
八、训练代码
train.py
python
import torch
from torch.utils.data import DataLoader
from dataset import Noise2NoiseDataset
from models.unet import SimpleUNet
def train():
device = torch.device("cuda" if torch.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()
for epoch in range(1, 81):
model.train()
total_loss = 0
for noisy_a, noisy_b in loader:
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}")
if epoch % 10 == 0:
torch.save(model.state_dict(), f"noise2noise_epoch_{epoch}.pth")
if __name__ == "__main__":
train()
九、推理代码
python
import torch
from PIL import Image
import torchvision.transforms as transforms
import torchvision.utils as vutils
from models.unet import SimpleUNet
device = torch.device("cuda" if torch.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)
with torch.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盲点网络实战,只有单张带噪图也能训练