Pytorch图像去噪实战(五):FFDNet可控图像去噪实战,用噪声强度图解决不同噪声等级问题
一、问题场景:同一个模型面对不同噪声强度时效果不稳定
前面几篇我们做了 DnCNN、UNet、ResUNet 和 Attention UNet。
这些模型在固定噪声强度下效果不错,但在真实项目中,我遇到了一个很麻烦的问题:
用户上传的图片噪声强度不一致,同一个模型无法稳定处理所有情况。
比如:
- 有些图只是轻微压缩噪声
- 有些图是低光高ISO噪声
- 有些图是截图二次压缩噪声
- 有些图是扫描件颗粒噪声
如果模型训练时只见过 sigma=25,那么面对 sigma=10 时可能过度去噪,面对 sigma=50 时又去不干净。
我一开始的做法是训练多个模型:
- sigma=15 一个模型
- sigma=25 一个模型
- sigma=50 一个模型
但工程上非常麻烦:
- 模型多
- 推理复杂
- 部署成本高
- 噪声估计不准时效果会崩
因此这一篇我们做一个更工程化的方案:FFDNet。
二、FFDNet解决什么问题?
FFDNet的核心思想是:
不只输入带噪图像,还额外输入一张噪声强度图。
也就是说,模型输入不再是:
text
noisy_image
而是:
text
noisy_image + noise_level_map
这样模型就知道当前图片大概有多脏,从而控制去噪力度。
三、为什么噪声强度图很有用?
普通模型的问题是:模型自己猜噪声强度。
FFDNet的问题建模方式是:我们直接告诉模型噪声强度。
比如:
- sigma=15:轻度去噪
- sigma=25:中度去噪
- sigma=50:强力去噪
这在工程中非常实用,因为我们可以根据业务场景动态调整去噪强度。
四、工程目录结构
ffdnet_denoise/
├── data/
│ ├── train/
│ └── val/
├── models/
│ └── ffdnet.py
├── dataset.py
├── train.py
├── eval.py
└── utils.py
五、数据集构建:返回噪声强度图
FFDNet训练时需要返回三个数据:
- noisy:带噪图
- sigma_map:噪声强度图
- clean:干净图
dataset.py
python
import os
import random
import torch
from PIL import Image
from torch.utils.data import Dataset
import torchvision.transforms as transforms
class FFDNetDataset(Dataset):
def __init__(self, root_dir, patch_size=128):
self.paths = [
os.path.join(root_dir, name)
for name in os.listdir(root_dir)
if name.lower().endswith((".jpg", ".png", ".jpeg"))
]
self.patch_size = patch_size
self.to_tensor = transforms.ToTensor()
def __len__(self):
return len(self.paths)
def __getitem__(self, idx):
img = Image.open(self.paths[idx]).convert("L")
w, h = img.size
if w >= self.patch_size and h >= self.patch_size:
x = random.randint(0, w - self.patch_size)
y = random.randint(0, h - self.patch_size)
img = img.crop((x, y, x + self.patch_size, y + self.patch_size))
else:
img = img.resize((self.patch_size, self.patch_size))
clean = self.to_tensor(img)
sigma = random.uniform(0, 50)
sigma_value = sigma / 255.0
noise = torch.randn_like(clean) * sigma_value
noisy = torch.clamp(clean + noise, 0.0, 1.0)
sigma_map = torch.ones_like(clean) * sigma_value
return noisy, sigma_map, clean
六、FFDNet模型实现
这里我们实现一个简化版 FFDNet。
核心是把 noisy 和 sigma_map 在通道维度拼接:
python
x = torch.cat([noisy, sigma_map], dim=1)
models/ffdnet.py
python
import torch
import torch.nn as nn
class FFDNet(nn.Module):
def __init__(self, in_channels=2, out_channels=1, features=64):
super().__init__()
layers = []
layers.append(nn.Conv2d(in_channels, features, 3, padding=1))
layers.append(nn.ReLU(inplace=True))
for _ in range(10):
layers.append(nn.Conv2d(features, features, 3, padding=1))
layers.append(nn.BatchNorm2d(features))
layers.append(nn.ReLU(inplace=True))
layers.append(nn.Conv2d(features, out_channels, 3, padding=1))
self.net = nn.Sequential(*layers)
def forward(self, noisy, sigma_map):
x = torch.cat([noisy, sigma_map], dim=1)
residual = self.net(x)
return noisy - residual
七、训练代码
train.py
python
import torch
from torch.utils.data import DataLoader
from dataset import FFDNetDataset
from models.ffdnet import FFDNet
def train():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dataset = FFDNetDataset("data/train")
loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=4)
model = FFDNet().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
criterion = torch.nn.L1Loss()
for epoch in range(1, 51):
model.train()
total_loss = 0
for noisy, sigma_map, clean in loader:
noisy = noisy.to(device)
sigma_map = sigma_map.to(device)
clean = clean.to(device)
pred = model(noisy, sigma_map)
loss = criterion(pred, clean)
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"ffdnet_epoch_{epoch}.pth")
if __name__ == "__main__":
train()
八、推理时如何控制去噪强度?
这正是 FFDNet 最实用的地方。
如果你觉得图片噪声轻,就给小 sigma:
python
sigma = 15 / 255.0
如果噪声很重,就给大 sigma:
python
sigma = 50 / 255.0
完整推理代码:
python
import torch
from PIL import Image
import torchvision.transforms as transforms
import torchvision.utils as vutils
from models.ffdnet import FFDNet
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = FFDNet().to(device)
model.load_state_dict(torch.load("ffdnet_epoch_50.pth", map_location=device))
model.eval()
img = Image.open("test.png").convert("L")
to_tensor = transforms.ToTensor()
noisy = to_tensor(img).unsqueeze(0).to(device)
sigma = 25 / 255.0
sigma_map = torch.ones_like(noisy) * sigma
with torch.no_grad():
pred = model(noisy, sigma_map)
pred = torch.clamp(pred, 0.0, 1.0)
vutils.save_image(pred.cpu(), "denoised.png")
九、为什么训练时sigma要随机?
如果只训练固定 sigma,比如 25,那么模型只会处理一种噪声。
FFDNet的优势来自:
python
sigma = random.uniform(0, 50)
这让模型在训练时见到连续噪声强度,从而学会根据 sigma_map 控制去噪力度。
十、真实工程中的使用方式
在实际系统中,可以提供三个模式:
轻度去噪
python
sigma = 10
适合轻微压缩噪声。
标准去噪
python
sigma = 25
适合普通截图、扫描件。
强力去噪
python
sigma = 50
适合低光、颗粒明显的图片。
这样用户可以根据视觉效果选择强度,工程体验会比单一模型好很多。
十一、踩坑记录
坑1:sigma_map范围写错
sigma_map必须和图像一样归一化到 0~1。
错误写法:
python
sigma_map = torch.ones_like(clean) * 25
正确写法:
python
sigma_map = torch.ones_like(clean) * 25 / 255.0
坑2:推理时忘记输入sigma_map
FFDNet不是普通单输入模型,推理时必须传两个输入:
python
pred = model(noisy, sigma_map)
坑3:sigma给太大导致图像过度平滑
如果 sigma=80,可能会把真实纹理也当噪声去掉。
建议控制在:
text
0 ~ 50
十二、效果验证
FFDNet的优势不是单点PSNR最高,而是:
同一个模型可以适配多个噪声强度。
实际效果表现:
| 噪声强度 | 普通UNet | FFDNet |
|---|---|---|
| sigma=15 | 容易过度去噪 | 更自然 |
| sigma=25 | 效果接近 | 稳定 |
| sigma=50 | 去噪不足 | 更干净 |
十三、适合收藏总结
FFDNet完整流程
- 输入带噪图
- 构造噪声强度图
- noisy 与 sigma_map 拼接
- 模型预测残差噪声
- noisy - residual 得到结果
避坑清单
- sigma_map必须归一化
- 训练时sigma要随机
- 推理必须传sigma_map
- sigma过大会过度平滑
- FFDNet适合做可控去噪
十四、优化建议
可以继续优化:
- 把基础网络换成UNet
- 加残差块
- 加注意力模块
- 对真实噪声做噪声估计
- 结合图像质量评分自动选择sigma
结尾总结
FFDNet真正有价值的地方在于工程可控性。
很多时候,图像去噪不是追求一个固定模型处理所有情况,而是需要根据噪声强度灵活调整。
FFDNet提供了一个很实用的思路:
把噪声强度显式告诉模型,让模型按需去噪。
下一篇预告
Pytorch图像去噪实战(六):CBDNet真实噪声去噪实战,解决合成噪声到真实噪声的泛化问题