FFDNet图像去噪实战:从PyTorch推理到ONNX部署的完整指南

发布时间:2026/10/11 8:03:06
FFDNet图像去噪实战:从PyTorch推理到ONNX部署的完整指南
简介FFDNet-pytorch 是面向 PyTorch 用户的图像去噪完整实现适合从事计算机视觉的研究者、算法工程师及高年级学生。FFDNet 由 Zhang 等人于 2018 年提出通过卷积残差块和跳跃连接可有效去除高斯噪声、椒盐噪声并兼顾实时性与细节保留。压缩包共 404 个文件涵盖 293 张效果对照 PNG 图、41 个预训练权重 pth 文件、42 个说明与日志 txt、7 个 Python 脚本、2 个 h5 数据集整体约 374.65MB配置完整。已有 932 人学习浏览可用于论文复现、自定义噪声训练或作为二次开发基础。资源内包含 pth 权重与 events.out 训练日志便于还原实验细节代码覆盖数据预处理、损失计算、反向传播与优化器配置配合开箱即用的 h5 数据和脚本能有效降低 FFDNet 的复现门槛并帮助深入理解网络结构与调参思路。1. 打开 ffdnet-pytorch.zip 之后一个真正能落地的图像去噪网络而不是论文附赠的玩具如果你手头这个 ffdnet-pytorch.zip 是刚从某个项目页或网盘拖下来的大概率里面是一套基于 PyTorch 的 FFDNet 实现。FFDNet 是图像去噪领域里少有的“既快又灵活”的卷积网络它把噪声水平估计图sigma map直接拼到输入上一张网络同时应对已知噪声和未知噪声、灰度图和彩色图训练好之后在 CPU 上处理 512×512 的图也只需要几百毫秒。对正在做视频预处理、老旧照片修复或工业检测图像增强的工程师来说这份代码比很多动辄上 G 的生成模型实用得多——不需要多卡炼丹一张普通显卡就能跑推理甚至能转成 ONNX 扔到生产环境。这篇笔记就按我实际落地这套代码的顺序把解压、跑通、调参和踩坑的过程一次讲清楚。2. 把 ffdnet-pytorch.zip 变成可运行的模型目录结构、环境与最小推理命令2.1 压缩包里通常会有什么从权重到测试脚本的五个关键文件解压之后第一件事不是急着跑 demo而是先看目录。这类开源 PyTorch 实现的结构虽然各有差异但核心组成基本一致我一般会按下面这几类文件先对一遍缺了再想办法补文件/目录作用缺失时的表现models.py 或 FFDNet.py网络结构定义含下采样和子网络部分导入报错或找不到 FFDNet 类train.py / test.py训练与测试入口脚本只能手动写推理逻辑demo.py 或 run.py单张图片的演示脚本需要自己拼加载和去噪流程checkpoint 或 weights 目录预训练权重一般以 .pth 结尾推理结果全是噪声或崩溃utils.py / dataset.py数据加载、patch 裁剪、PSNR 计算工具训练脚本无法启动我拿到一个 ffdnet-pytorch.zip 时会先打开 models.py 看一眼网络类名。最常见的是class FFDNet(nn.Module)构造函数里接收num_input_channels和num_features两个参数前者灰度图设 1、彩图设 3后者是中间特征图数量默认一般是 64。如果压缩包里有预训练权重再看权重文件的 state_dict 里的键名确认它和网络定义是否匹配。这一步能避免后面加载权重时出现成堆的missing keys报错——那种翻车现场我在第 5 章会详细讲。2.2 用 PyTorch 加载 FFDNet 的最小 Python 脚本与参数说明不管压缩包里有没有 demo我通常习惯自己写一个最小推理脚本先跑通再谈别的。下面这份代码基于常见的 FFDNet 结构假设压缩包里的权重文件保存的是整个模型或只包含网络权重import torch import torch.nn as nn import numpy as np import cv2 # 假设 models.py 里定义了 FFDNet我们直接导入 # 如果压缩包里没有 models.py按 FFdNet 论文结构自行实现也可以 from models import FFDNet # 1. 加载网络灰度图输入通道为 1特征图数量用默认 64 net FFDNet(num_input_channels1, num_features64) # 2. 加载权重strictTrue 保证键名完全匹配 state_dict torch.load(weights/ffdnet_gray.pth, map_locationcpu) net.load_state_dict(state_dict) net.eval() # 3. 读取灰度图转为 float32并归一化到 [0, 1] img cv2.imread(test_noisy.png, cv2.IMREAD_GRAYSCALE) img img.astype(np.float32) / 255.0 # 4. 把 HxW 变成 1x1xHxW并生成一张全 25 的 sigma map # sigma 值表示噪声水平25 对应常见的高斯噪声标准差 25/255 img_tensor torch.from_numpy(img).unsqueeze(0).unsqueeze(0) # 1, 1, H, W sigma torch.full((1, 1, img_tensor.shape[2], img_tensor.shape[3]), 25.0 / 255.0) # 5. 前向计算得到去噪结果 with torch.no_grad(): output net(img_tensor, sigma) # 6. 转回 numpy 并保存 out_img output.squeeze().cpu().numpy() out_img np.clip(out_img * 255.0, 0, 255).astype(np.uint8) cv2.imwrite(test_denoised.png, out_img)这段代码里最关键的两个参数是num_input_channels和sigma。前者决定网络首层卷积的输入尺寸彩色图要改成 3并且图像读取方式也要同步换成cv2.imread默认的 BGR 三通道后者是 FFDNet 控制去噪强度的“旋钮”它的形状必须和输入图像一致因为它会被当作一张额外的通道拼到网络上。我见过不少新手把 sigma 写成标量结果 PyTorch 广播时报错或者网络输出变成全黑——这种问题统称为“Sigma 的玄学”后面第 3 章我会展开讲。3. 从灰度到彩色FFDNet 的 size 不是万能的sigma 和通道才是命门3.1 sigma 参数是怎么影响去噪强度的噪声水平图与可调范围FFDNet 区别于传统去噪 CNN 的核心在于它显式接收一个噪声水平图Noise Level Map。这个 map 是一个和图像等宽的矩阵每个像素位置的值表示该处的噪声标准差估计。我自己用下来的心得是sigma 设得越高去噪越强但细节丢失也越严重设得越低纹理保留越好但噪声残余越明显。这不是一个可以随便拍脑袋的数字。常见的预训练权重分两种固定噪声水平比如只在 sigma25 上训练和可变噪声水平在 sigma 075 范围内随机采样训练。如果你手上的权重是后者那么推理时你可以针对每张图灵活调整 sigma。我的经验是图像噪声肉眼明显但纹理不多sigma 取 3040图像本身细节丰富比如布料、树叶sigma 取 1020宁可留一点噪声也不要抹掉纹理相机长曝光产生的暗光噪声sigma 取 50 以上但需要接受分辨率感下降。sigma map 的具体构造要注意两点第一值要除以 255因为网络输入是归一化到 [0, 1] 的第二它的空间尺寸必须和输入图完全一致。常见做法是torch.full((1, 1, H, W), sigma_val / 255.0)。如果你处理的是空间变化噪声比如暗角区域噪声更大可以在不同区域填入不同 sigma 值这是 FFDNet 最灵活的地方也是它名字里 “Flexible” 的来源。3.2 彩色图像与灰度图像的输入格式差异通道、归一化与边界处理彩色图不能直接把灰度图的网络拿过来用。FFDNet 的彩色版本输入通道是 3对应的预训练权重也完全不同。切换时除了改num_input_channels还要处理三个容易被忽略的问题第一个是通道顺序。OpenCV 读出来是 BGR而 PyTorch 图像处理习惯里很多是 RGB。如果不做转换去噪效果不会受太大影响但如果你同时用 PSNR 评测最好统一用同一套通道顺序否则数值对比会不公平。第二个是归一化。灰度图是单通道除以 255 后值域 [0, 1]彩色图也一样但要确保每个通道都除。我自己写过一个翻车案例只对第一通道做了归一化后面两个通道还是 0255 的原始值结果网络输出直接出现绿色色偏找了好半天才发现是三通道中两个没做处理。第三个是边界填充。FFDNet 网络里有下采样和上采样操作输入尺寸不一定是 2 的倍数也能跑但为了减少边界伪影我一般会先把图像 pad 成 16 的倍数推理后再裁剪回来。这个技巧在批量处理一批不同尺寸图片时尤其重要。下面的代码演示了如何做这个 paddingdef pad_to_multiple(img, multiple16): h, w img.shape[:2] pad_h (multiple - h % multiple) % multiple pad_w (multiple - w % multiple) % multiple # 上下左右各 pad 一半这样去噪结果中心区域不受影响 padded cv2.copyMakeBorder( img, pad_h // 2, pad_h - pad_h // 2, pad_w // 2, pad_w - pad_w // 2, cv2.BORDER_REFLECT ) return padded, (pad_h, pad_w) padded_img, (pad_h, pad_w) pad_to_multiple(img) # ... 推理得到 padded_out ... # 裁剪回原尺寸 crop_h (padded_out.shape[0] - pad_h) // 2 orig_out padded_out[crop_h:crop_h img.shape[0], ...]BORDER_REFLECT比默认的补零好得多因为补零会在边界引入一个突变去噪网络会把突变当成高频细节去保留。用镜像填充后边界处的像素统计特性和图像内部更接近去噪结果的边缘不太会出现黑框。这个细节是我在跑一批屏幕截图去噪时对比出来的肉眼不一定看得出但 PSNR 能差 0.2~0.5 dB。4. 训练自己的 FFDNet数据准备、loss 与 L2 正则化的实际作用4.1 准备训练数据集从 DIV2K 到裁剪 patch 的常用流程如果你不想用压缩包里的现成权重而是想在自己的数据域上微调那就要走训练流程。FFDNet 的训练数据不需要昂贵的人工标注只需要干净图像然后在训练时动态加噪。最常见的干净图像来源是 DIV2K、Flickr2K 这类超分数据集或者你自己收集的高清照片。用的时候先做数据清洗去掉带水印、文字边框的图片否则网络会把水印当成要保留的纹理。训练前需要把大图裁成小 patch。FFDNet 原论文用的是 50×50 的 patch但我在实际训练中觉得 128×128 效果更好尤其是彩色图。太小 patch 会让网络很难学到全局的噪声分布太大 patch 又拖慢训练速度。下面的代码展示了一个简单的随机裁剪逻辑import random from PIL import Image import torchvision.transforms.functional as TF def random_crop_pair(clean_img, patch_size128): w, h clean_img.size left random.randint(0, w - patch_size) top random.randint(0, h - patch_size) clean_patch TF.crop(clean_img, top, left, patch_size, patch_size) return clean_patch训练时每个 step 随机裁剪一块相当于无限数据增强。这比提前把图裁好存盘要灵活因为每次 epoch 看到的 patch 位置都不同网络不容易过拟合到固定构图。4.2 训练脚本中的关键超参batch size、学习率与噪声增强FFDNet 的训练损失一般就是 MSE Loss也就是像素级 L2 距离。这个选择很自然因为去噪任务的目标就是让输出逼近干净图像。我见过有人想用 SSIM Loss 或者感知 Loss但在 FFDNet 这种轻量网络上MSE 依然是最稳的起点其他 loss 容易让训练不稳定或者产生奇怪的颜色偏移。超参方面batch size 在单卡上我一般设 16 或 32patch 越大batch 就要相应减小。学习率初始 1e-3用余弦退火或者每 30 个 epoch 衰减 0.1 都行。下面是一个典型的 PyTorch 训练循环片段optimizer torch.optim.Adam(net.parameters(), lr1e-3, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) criterion nn.MSELoss() for epoch in range(100): for batch in dataloader: clean batch[clean].cuda() # 干净 patch尺寸 B, C, H, W sigma_val random.uniform(0, 75) / 255.0 # 随机噪声水平增强泛化 noise torch.randn_like(clean) * sigma_val noisy clean noise sigma_map torch.full_like(clean[:, :1, :, :], sigma_val) out net(noisy, sigma_map) loss criterion(out, clean) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()这段代码里sigma_val是训练时随机采样的这也是 FFDNet 能处理不同噪声水平的关键。如果固定在一个 sigma 上训练网络只会做“盲去噪”换一个噪声强度就失效。另外注意sigma_map的形状是B, 1, H, W要和网络的输入通道拼接不能传成标量。4.3 为什么说 L2 正则化在 FFDNet 里不是主角从代码里看权重衰减很多搜索 Pytorch 教程的人会看到 “L2 正则化” 这个词并自然以为去噪网络训练时必须加大权重衰减。实际上在 FFDNet 这类图像复原网络里weight_decay只是一个很小的辅助项我一般设1e-5甚至0也不会有大问题。原因是 MSE Loss 本身已经对像素误差做了很强的约束网络参数不会像大模型那样出现爆炸式增长L2 惩罚作用有限。如果你真的想加 L2 正则化PyTorch 里最方便的做法就是在 Adam 优化器里设weight_decay它等价于对所有参数施加 L2 惩罚。但要注意这个惩罚会作用在 bias 和卷积核上。如果设得太大比如 1e-2会发现训练 loss 下不去输出变得模糊因为参数被压得太小无法拟合高频细节。我在一次实验中把 weight_decay 调到 1e-2结果 PSNR 掉了将近 2 dB血泪经验告诉大家这个值千万别乱调。训练时还需要配合一个关键操作对图像像素加上一个很小的随机偏移比如在 [0, 1] 之间归一化后随机 jitter这也能提升泛化。但这一步是可选的如果数据量够大不加也行。5. FFDNet PyTorch 落地避坑5 个我实际踩过的坑与排查方法5.1 现象加载 .pth 报错 missing keys 或 unexpected keys这是一个出现频率极高的报错几乎每个第一次用别人权重的人都会遇到。现象是load_state_dict抛出Missing key(s) in state_dict: subnet.conv1.weight之类的话。原因通常有两个一是权重文件保存的是完整模型torch.save(net, path)而你的代码里用的是net.load_state_dict二是网络类的命名空间和训练时不一致比如训练时包了一层nn.DataParallel权重里所有键都多了module.前缀。解决方法是先打印权重键名再做去前缀处理state torch.load(weights.pth, map_locationcpu) # 如果键名以 module. 开头说明是 DataParallel 保存的 if list(state.keys())[0].startswith(module.): state {k.replace(module., ): v for k, v in state.items()} net.load_state_dict(state)如果还是缺键就把网络定义里的层名和权重键名逐一比对。常见做法是手动重命名几个 conv 层的名字而不是重新训练。5.2 现象灰度图输出是黑的或者全灰这个坑我犯了至少两次。现象是输入一张正常灰度图输出要么全黑要么整体发灰图像内容完全看不见。原因多半是 sigma 的值没有除以 255或者图像归一化范围不对。FFDNet 训练时图像和 sigma 都在 [0, 1] 区间如果你把 sigma 直接传成 25函数内部会认为噪声水平是 25 倍标准差网络会疯狂平滑所有纹理输出接近全灰。解决办法就是把sigma_val sigma_val / 255.0。另一个隐藏原因是输入图像的 dtype 不是 float32。PyTorch 里 uint8 和 float32 做运算会直接报错但如果你用img.astype(np.float64)推理也能跑结果却会出现数值溢出。务必用 float32。5.3 现象CUDA 和 PyTorch 版本不匹配导致推理直接崩这个在 Ubuntu 和 Windows 上都很常见。现象是导入torch时报CUDA driver version is insufficient或者torch.cuda.is_available()返回 False。原因不是你代码的问题而是 PyTorch 的 CUDA 运行时和显卡驱动版本不匹配。PyTorch 是自带 CUDA 运行时的它要求驱动版本足够新。解决方法是先查驱动支持的 CUDA 版本然后装对应版本的 PyTorch。比如驱动支持 CUDA 12.8就选 PyTorch 官方提供的 cu128 版本安装。如果你用的是 Anaconda可以用conda install pytorch pytorch-cuda12.8 -c pytorch来配置环境。这个坑在环境搭建阶段出现最多python 安装 pytorch 教程里也反复强调过。5.4 现象测试集 PSNR 很高但视觉上过度平滑这是最让人纠结的一个坑。PSNR 涨了 0.5 dB图却变成磨皮效果纹理全没了。原因大概率是训练时 sigma 范围太窄比如只在 sigma25 附近采样测试时把 sigma 设成 50 去推理网络只能靠过度平滑来压噪声。另一个原因是 patch 尺寸太小。我最早用 32×32 的 patch 训练网络只学会了局部平均没有学到长程纹理。把 patch 提高到 128同时把训练时的 sigma 范围扩大到 075视觉质量会有明显改善。5.5 现象内存溢出patch 太大 / batch 太大显存不够时最直接的报错是CUDA out of memory。很多人第一反应是换更大的显卡但根本原因是 FFDNet 虽然有下采样但中间特征图数量 64 已经是常规配置你如果再加大num_features到 128内存消耗是指数级增长。我的经验是12GB 显存batch 16、patch 128、特征 64刚刚好如果算力允许把 batch 降到 8patch 保持不变。另外训练时开启cudnn.benchmarkTrue也能加快卷积运算减少显存碎片。torch.backends.cudnn.benchmark True6. 把 FFDNet 转到 ONNX 做工程部署一个验证输出一致性的具体技巧图像去噪模型跑通 PyTorch 只算完成了一半真正要上线往往是转成 ONNX再交给 TensorRT 或 OpenVINO 推理。FFDNet 转 ONNX 并不复杂因为它没有动态控制流但有一个细节必须处理ONNX 需要固定输入尺寸而 sigma map 的尺寸又是和图像绑定的。我一般用torch.onnx.export导出时设置动态轴让 H 和 W 可变dummy_img torch.randn(1, 1, 256, 256) dummy_sigma torch.full((1, 1, 256, 256), 25.0 / 255.0) torch.onnx.export( net, (dummy_img, dummy_sigma), ffdnet.onnx, input_names[input_image, sigma_map], output_names[output_image], dynamic_axes{ input_image: {2: height, 3: width}, sigma_map: {2: height, 3: width}, output_image: {2: height, 3: width}, }, opset_version11 )导出后别急着部署先用下面这段代码做一致性验证对比 PyTorch 模型和 ONNX Runtime 的输出最大绝对误差要小于 1e-4否则就要查是不是归一化方式不一致。import onnxruntime as ort import numpy as np onnx_session ort.InferenceSession(ffdnet.onnx) test_img torch.randn(1, 1, 128, 128) test_sigma torch.full((1, 1, 128, 128), 0.1) with torch.no_grad(): torch_out net(test_img, test_sigma).numpy() onnx_out onnx_session.run( None, {input_image: test_img.numpy(), sigma_map: test_sigma.numpy()} )[0] max_diff np.abs(torch_out - onnx_out).max() print(fmax diff: {max_diff:.2e})我在这个环节里翻过车起初为了省事把 sigma map 也当成固定值导出结果部署后图像尺寸一变直接报维度不匹配。后来把 sigma map 的输入当成和图像一样的动态维度问题就解决了。所以别嫌麻烦动态轴一定要加上。另外验证时一定要覆盖最小和最大输入尺寸不能只测 128×128否则在真实场景遇到 512×512 的图再报错就晚了。这套方案走到 ONNX 这一步基本就是从“能跑”到“能交付”的分界线。每当我拿到一个新的 PyTorch 去噪项目第一件事就是按这个流程跑一遍最小推理、调 sigma、测不同尺寸再考虑转 ONNX。希望帮到你。本文还有配套的精品资源点击获取