简介:VDSR-caffe 是一份使用 Caffe 框架复现 CVPR 2016 经典超分辨率论文的完整工程,聚焦于图像超分辨率重建任务,适合深度学习研究者、计算机视觉方向学生以及从事图像增强的工程师学习与二次开发。压缩包内共二百八十六个文件,整体大小约九点七五兆字节,其中上百个 MATLAB 脚本负责测试对比,CUDA 与 C++ 源码支撑自定义网络层编译,网络结构、预训练权重、论文源稿和使用文档一应俱全,整体组织结构清晰,便于查阅。目前已有二百六十四人学习下载。这套代码完整呈现了 VDSR 的训练与测试流程,借助预训练模型可直接对低分辨率图像的上采样结果进行残差修正,从而复现深层卷积网络的超分性能;同时可结合归一化、批归一化等关键模块,深入理解残差学习、梯度裁剪与多尺度处理等核心技术。无论是初次接触超分算法,还是希望在既有项目中引入更深层神经网络,这份代码都能提供直观参考和可运行的起点。
1. VDSR-caffe:一套能复现的超分辨率训练工程,别把它当黑匣子
VDSR 是超分辨率任务里绕不开的一篇经典工作,caffe 则是它最常见的训练载体。很多人拿到“VDSR-caffe”这个工程名,第一反应是打开网络定义看层数、看卷积核,结果数据喂进去训练一晚上,loss 纹丝不动。我最初也在这个坑里蹲了三天,后来才发现问题根本不在网络搭建,而在梯度裁剪、残差学习和数据归一化这三者的配合上。这篇笔记从一个能跑通的工程视角,讲清楚 VDSR 在 caffe 下的数据准备、网络结构、solver 配置和测试验证,新手可以照着复现,熟手可以直接把它当一份参数边界和踩坑清单用。
2. VDSR-caffe 环境搭建:caffe 版本与 GPU 依赖是第一道坎
VDSR 是 2016 年前后的工作,那时候的 caffe 生态和现在差别很大。你从网上找到一个“VDSR-caffe”仓库,第一件事不是看网络,而是确认它配套的 caffe 分支和编译环境。很多翻车现场都是因为用了太新的 CUDA 去编译太老的 caffe,结果编译报错、训练时算子崩溃,最后连数据都读不进去。
2.1 先定 caffe 版本,再谈训练
常见做法是使用 caffe 官方主线对应的某一历史版本,或者直接采用社区里维护较活跃的分支。判断版本是否合适,核心看三点:是否支持 GPU 训练、是否支持你需要的卷积层反向传播、是否带梯度裁剪参数。VDSR 训练时要用到clip_gradients,但不少早期 caffe 版本的 solver 里没有这个字段。我一般会在动手前打开caffe.proto搜一下clip_gradients,搜不到就先放弃这个分支,换一个已经支持它的版本,省得后面自己改源码。
# 以源码编译方式为例,下载好 caffe 源码后 cd caffe cp Makefile.config.example Makefile.config # 编辑 Makefile.config:开启 GPU 支持,关闭不需要的依赖 make all -j8这段命令看起来简单,但真正决定成败的是 Makefile.config 里的开关。VDSR 只需要卷积和 ReLU,不需要把 Python 层、HDF5、OpenCV 全部打开;关掉不必要的依赖能减少大量编译问题。-j8是并发编译参数,改成-j4更稳,编译内存小的机器开高并发容易直接卡死。
2.2 编译报错的三个高频原因
编译 caffe 的老版本时,最常遇到三类报错。第一类是cudnn版本不匹配,表现为编译到卷积相关文件时报“类型未定义”之类的错误。这类问题没有太多技巧,就是换分支或者换加速库版本去对齐。第二类是gflags、glog、protobuf这些基础库的版本冲突,常见于系统里同时装了多个版本,编译链接时找不到正确的符号。遇到这种报错,我会先卸载掉系统自带的旧 protobuf,再重新装一个与 caffe 匹配的版本。第三类是Makefile.config里的路径写错,尤其是 CUDA 安装路径不对,导致编译到一半找不到头文件。
提示:如果你对 caffe 的版本链不熟,不要试图在当前环境里硬刚编译问题。找一个支持梯度裁剪的分支,比手动往老代码里加字段要省力得多。
编译通过之后,先别急着跑 VDSR。我习惯先用命令python -c "import caffe; print(caffe.__version__)"验证一下 Python 接口能不能正常导入,再跑一次最简单的 forward 测试。这样能把环境问题和数据问题隔离开,后面训练出问题时排查范围会小很多。
3. VDSR-caffe 数据准备:从配对图到 LMDB,最容易出错的一环
VDSR 训练网络时并不直接拿低分辨率图当输入,而是先把低分辨率图用双三次插值放大到和高分辨率图一样大,再把这个放大后的图作为输入,让网络去学习“放大图到原图”的残差。很多人在这里懵住,以为是端到端地从 LR 直接出 HR,结果 LMS 数据配对逻辑全错。这一章把数据链路彻底拆开讲清楚。
3.1 配对数据生成:先下采样,再上采样回原尺寸
训练样本的生成逻辑是:从一张高分辨率原图出发,先按缩放因子做双三次下采样得到低分辨率图,再用双三次插值把它放大回原尺寸。这个放大后的图才是网络的输入,原始高分辨率图是训练标签。网络学的是两者之间的差,所以 VDSR 的输出是残差,而不是完整的 HR 图。
import cv2 import numpy as np def make_pair(img, scale): # img: 高分辨率原图, BGR 或灰度皆可 h, w = img.shape[:2] lr = cv2.resize(img, (w // scale, h // scale), interpolation=cv2.INTER_CUBIC) lr_up = cv2.resize(lr, (w, h), interpolation=cv2.INTER_CUBIC) return lr_up, img这段代码的核心在于两次插值必须都使用cv2.INTER_CUBIC。训练时用 OpenCV 做插值,测试时如果用 MATLAB 系脚本做插值,两者的双三次插值系数并不完全相同,会造成细微的 PSNR 偏差。虽然偏差不大,但对于追求复现论文数值的人来说,这属于隐藏的坑。scale在 VDSR 里通常取 2、3、4,训练时可以每个 epoch 随机选一个尺度,也可以在数据准备阶段按比例混合生成。
3.2 随机裁剪与数据增强:补丁尺寸选 41 还是 48
VDSR 原文使用的补丁尺寸是 41×41,这个数字和网络感受野有关。20 层 3×3 卷积、每层 pad 为 1 的情况下,感受野正好是 41×41。补丁小于感受野,边缘信息训练不充分;补丁过大,同尺寸下能抽出的样本数变少。我在实验里分别试过 41 和 48,最终效果接近,但 41 的训练收敛速度略快,所以默认按 41 来。
def random_crop(lr_up, hr, patch=41): h, w = hr.shape[:2] y = np.random.randint(0, h - patch) x = np.random.randint(0, w - patch) return lr_up[y:y+patch, x:x+patch], hr[y:y+patch, x:x+patch]数据增强方面,常见做法是对每张图做 8 倍增强:旋转 90 度、180 度、270 度,再加上水平翻转。增强的逻辑是超分任务对图像方向不敏感,但旋转和翻转能强迫网络学会更鲁棒的纹理先验。注意翻转和旋转要在裁剪之前做,这样可以保持样本的多样性,而且实现上只需要对整图操作一次,不用对每个补丁重复处理。
3.3 写入 LMDB:通道数必须为 1
VDSR 训练时只对亮度通道做超分,颜色通道保留原样。因此写入 LMDB 的数据应该是单通道灰度图,而不是三通道彩色图。很多人在这里写成了 3 通道,训练也能跑,但收敛速度和最终效果都会差不少,而且模型参数量被白白浪费在颜色通道上。
import lmdb import caffe env = lmdb.open('train_lmdb', map_size=100*1024*1024*1024) txn = env.begin(write=True) for idx, (lr_up, hr) in enumerate(pairs): # 转成 1x1xHxW 的 float32 数据 lr_up = lr_up.astype(np.float32) / 255.0 hr = hr.astype(np.float32) / 255.0 datum = caffe.io.array_to_datum( lr_up[np.newaxis, np.newaxis, :, :]) txn.put(f'{idx:06d}'.encode(), datum.SerializeToString()) # 标签单独存一个 lmdb 或作为第二个 datum 字段 txn.commit() env.close()array_to_datum生成的标准 datum 只包含一个数据块,所以标签通常单独建一个 lmdb,或者在批量读取时分别从两个 lmdb 取。map_size要设得足够大,100G 起步比较稳,太小会在写入到一半时报MapFullError。写入完成后,我一般会随机读几个 datum 打印形状和数值范围,确认归一化做到位了再开始训练。这一步能挡住大量低级问题,毕竟训练跑到一半才发现数据是坏的,代价太大了。
4. VDSR-caffe 网络与训练:prototxt 和 solver 的关键参数
网络结构本身并不复杂,但 prototxt 里每一处参数都有它的作用。这一章从 train_val 网络和 solver 两个文件入手,把 VDSR 的训练体系拆开。重点不是背参数,而是知道每个参数在什么情况下需要调整。
4.1 train_val.prototxt:残差结构是这样搭出来的
VDSR 的骨架是 20 层 3×3 卷积,前 19 层输出 64 个特征图,最后一层输出 1 个特征图,然后和输入做逐元素相加。每个卷积层都带 pad=1,这样特征图尺寸全程不变,最后才能直接相加。下面是一个精简版的 prototxt 结构,中间层用注释略过。
name: "VDSR" input: "data" input_dim: 1 input_dim: 1 input_dim: 41 input_dim: 41 layer { name: "conv1" type: "Convolution" bottom: "data" top: "conv1" param { lr_mult: 1 decay_mult: 0 } convolution_param { num_output: 64 kernel_size: 3 pad: 1 bias_term: false weight_filler { type: "xavier" } } } layer { name: "relu1" type: "ReLU" bottom: "conv1" top: "conv1" } # 第2层到第19层结构与conv1相同,仅层名不同 layer { name: "conv20" type: "Convolution" bottom: "conv18" top: "conv20" convolution_param { num_output: 1 kernel_size: 3 pad: 1 bias_term: false } } layer { name: "residual" type: "Eltwise" bottom: "data" bottom: "conv20" top: "residual" eltwise_param { operation: SUM } }关键在decay_mult: 0。VDSR 训练时不加权重衰减,因为残差学习的正则化需求和普通分类任务不同,加了 weight decay 反而会让模型偏向输出零残差,PSNR 上不去。bias_term: false也是为了减少不必要的参数,卷积层不带偏置对超分任务没有明显影响,还能省一点显存。xavier初始化是默认选择,但训练深层网络时它对 VDSR 并不是最优的,后面在 solver 里配合梯度裁剪一起看。
4.2 solver.prototxt:学习率 0.1 配上梯度裁剪才是完整配方
VDSR 最大的训练特色是学习率设为 0.1,远超一般 CNN 的 0.001 量级。高学习率让收敛速度飞快,但如果没有梯度裁剪,loss 会在某个迭代步突然炸掉,变成 NaN。以下是可用的 solver 配置。
net: "train_val.prototxt" test_iter: 100 test_interval: 1000 base_lr: 0.1 lr_policy: "step" gamma: 0.1 stepsize: 80000 momentum: 0.9 weight_decay: 0 clip_gradients: 0.1 max_iter: 200000 solver_mode: GPUclip_gradients: 0.1的含义是把梯度的 L2 范数裁剪到 0.1 以内。VDSR 用这么激进的裁剪阈值,是为了在 0.1 的高学习率下保证训练稳定。如果你的 caffe 分支不支持这个字段,训练过程中会经常看到 loss 突然变成nan,这就是梯度爆炸的典型表现。stepsize是学习率衰减的间隔,单位是迭代次数。训练到 8 万次迭代时学习率从 0.1 降到 0.01,再往后每 8 万次降 10 倍。test_iter: 100配合test_interval: 1000表示每 1000 次迭代用 100 个 batch 做一次验证。
注意:梯度裁剪不是越大越好,也不是越小越好。0.1 这个值本身就是 VDSR 复现工程里最常被验证的参数,建议第一轮训练不要改它。改它的唯一理由是训练稳定但收敛过慢,或者收敛太快但效果差。
训练命令本身没有太多花样:
caffe train --solver=solver.prototxt 2>&1 | tee train.logtee把训练日志同时输出到屏幕和文件,方便后面用脚本画 loss 曲线。训练中断时,caffe 会生成当前迭代数的.solverstate文件,续跑命令是:
caffe train --solver=solver.prototxt --snapshot=你的迭代次数.solverstate这个后悔药比从头训练省时间得多,尤其是训练到十几万次迭代时。老版本 caffe 的续跑对 solver 文件改动很敏感,如果你中途改了stepsize或者base_lr,续跑后的学习率行为可能和你预期的不一样,所以续跑前尽量别动 solver 参数。
5. VDSR-caffe 训练与测试的 5 个踩坑记录
这一章把我在复现和调参过程中真正遇到的坑按“现象、原因、解决”整理出来。每一条都是花过时间验证的,不是拍脑袋猜的。
5.1 现象:loss 不降反升,几轮迭代后直接变 NaN
训练日志里 loss 从 0.02 一路涨到几千,最后变成nan。原因基本只有一个:梯度爆炸。VDSR 的 20 层深度加上 0.1 的学习率,梯度范数很容易在反向传播过程中被放大。解决方法是确认 solver 里开了clip_gradients,并且学习率确实是 0.1 而不是 0.1 的十倍。我还遇到过一种隐蔽情况:数据里有 NaN。个别图像在读取时出现全黑区域,除以 255 后没问题,但如果代码里错误地除以了图像自身的标准差,就会出现除零。所以排查顺序是:先检查数据,再检查梯度裁剪。
5.2 现象:训练正常,但测试输出的图像整体发灰发暗
训练 loss 降得很好,可拿 deploy 网络跑出来的图却灰蒙蒙的。原因是对残差输出的理解错了。VDSR 网络的最后输出是残差,不是最终的超分结果。正确做法是在部署阶段把网络输出和双三次放大图逐像素相加,得到完整的高分辨率亮度图。很多人直接把网络输出转成图片保存,等于丢弃了绝大部分图像能量,当然发灰。解决方法是代码里明确区分“网络输出”和“最终结果”两个变量,加完再保存。
5.3 现象:PSNR 和论文数值差 2dB 以上,怎么调都不对
训练几乎完美,但测试 PSNR 就是上不去。我踩过的坑有三个。第一,PSNR 计算必须在 YCbCr 空间的 Y 通道上做,而不是在 RGB 三通道上做平均;第二,计算 PSNR 时需要裁掉图像边界若干像素,因为 padding 会让边界像素受到无效卷积的影响,常规做法是裁掉 6 到 8 个像素;第三,训练和测试用的插值实现不一致,训练用 OpenCV 的双三次,测试却用另一个库的插值,虽然差异细微,但在高 PSNR 区间会明显拉低分数。解决方法是统一所有插值操作,并且写一个固定的 PSNR 计算函数,不要每次手写。
5.4 现象:GPU 显存不够,batch size 调到 8 还是溢出
VDSR 的 patch 是 41×41,这个尺寸本身很小,64 的 batch 在多数显卡上都能跑。如果你的 batch 只能开到 8,说明问题不在网络,而在 caffe 的test_iter或验证阶段把整张测试图直接塞进了网络。测试阶段如果直接把 256×256 甚至更大的图喂进去,中间特征图占用的显存会暴涨。解决方法是训练时用小 patch,测试时用 deploy 网络并逐块推理,或者把测试图先裁块再拼回。还有一个常见原因是 prototxt 里的input_dim写错,写成了整图尺寸,导致训练时也按整图分配显存。
5.5 现象:多尺度训练后,在 scale=4 上效果明显差于 scale=2
VDSR 一般会在 2、3、4 三个尺度上混合训练,但测试时如果只在某个尺度上验证,可能出现尺度不均衡的问题。原因往往是训练数据的尺度分布不均匀。如果数据准备阶段把三个尺度的样本放在不同的 lmdbt 里并按顺序读取,网络会先学完 scale=2 再学 scale=3,造成灾难性遗忘。解决方法是训练时每个 batch 内随机混合三个尺度的样本,或者每个 epoch 都打乱尺度顺序。我在数据读取层用一个随机数决定当前样本的缩放因子,而不是按文件顺序遍历,这样每个 batch 里三个尺度的比例是均衡的。
6. 用 deploy.prototxt 做批量测试与 PSNR 验证
训练完成后,最后的验证环节直接影响你判断模型好坏。我习惯写一个固定模板的测试脚本,把所有测试图按同样的流程处理,避免每次临时改代码引入偏差。deploy 网络和 train_val 网络结构相同,但去掉了损失层,输入尺寸改为测试图的实际大小。
import caffe import cv2 import numpy as np net = caffe.Net('deploy.prototxt', 'VDSR.caffemodel', caffe.TEST) def inference(img_bgr, scale): h, w = img_bgr.shape[:2] ycbcr = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2YCrCb) y = ycbcr[:, :, 0].astype(np.float32) / 255.0 lr = cv2.resize(y, (w // scale, h // scale), interpolation=cv2.INTER_CUBIC) lr_up = cv2.resize(lr, (w, h), interpolation=cv2.INTER_CUBIC) net.blobs['data'].reshape(1, 1, h, w) net.blobs['data'].data[...] = lr_up[np.newaxis, np.newaxis, :, :] res = net.forward()['residual'][0, 0] sr_y = np.clip(lr_up + res, 0, 1) * 255.0 ycbcr[:, :, 0] = sr_y.astype(np.uint8) return cv2.cvtColor(ycbcr, cv2.COLOR_YCrCb2BGR) def psnr(a, b, border=8): a = a[border:-border, border:-border] b = b[border:-border, border:-border] mse = np.mean((a.astype(np.float32) - b.astype(np.float32)) ** 2) if mse == 0: return float('inf') return 10 * np.log10(255.0 ** 2 / mse)reshape这步不能省,否则输入形状和 prototxt 里的定义不一致时会直接报错。border=8是裁边参数,20 层 3×3 卷积的感受野是 41,理论上裁 20 个像素更严谨,但常见实现里 8 到 10 已经足够稳定,数值波动小于 0.05dB。
批量测试时,我一般把测试图路径存在一个 txt 里,脚本循环读取、推理、计算 PSNR 并累计平均值。测试过程如果发现某张图 PSNR 特别低,不要立刻怀疑模型,先看这张图的原始分辨率是否小于网络感受野。分辨率太小的图,边界裁剪后几乎没有有效区域,PSNR 低是正常的,这类图应该从测试集里剔除。我又一次在这个细节上纠结了很久,最后才发现那是张 32×32 的小图标,裁完边界后只剩 16×16,数值完全失去意义。
现在的习惯是每轮训练结束跑同一个测试脚本,把 PSNR 结果追加到日志文件,再拉出曲线看趋势,而不是只看最后一次迭代的数值。这样能明显看出模型是还在收敛、已经过拟合,还是中间某个 checkpoint 才是最佳结果。对 VDSR 这类残差学习模型来说,最后 1 万次迭代的 PSNR 往往不是最高的,最优 checkpoint 通常更早。希望这份工程笔记能帮你在 caffe 上把 VDSR 顺利跑通,少走我当初走过的弯路。
本文还有配套的精品资源,点击获取