简介:这套多帧图像超分辨率重构代码与分析资料面向图像处理、计算机视觉方向的学习者和开发者,围绕低分辨率图像如何借助多帧冗余信息恢复细节、抑制噪声并补偿运动模糊展开,提供可直接运行的项目代码与配套解析,适合希望从原理走向实操的中高级读者。压缩包共399个文件,约15.38MB,以337个bmp图像样本和21个mat数据文件为主体,辅以14个m脚本、7个tiff及gif、fig、docx、pdf等说明与结果文件,覆盖数据加载、模型构建、训练预测和结果展示等环节。已有1381人学习。读者可结合代码理解多帧融合、图像去噪去模糊、超分辨率模型训练及PSNR、SSIM评估流程,并通过现成样本复现实验、对照分析输出,积累视频处理、医学成像、遥感分析等场景的排错与调参思路。
1. 多帧超分不是"多拍几张取平均":先算清亚像素这笔账
手里压着十来张同一场景的低分辨率图,每张都糊,能不能拼出一张清楚的?多数人的第一反应是放大后平均一下,结果只会得到一张更平滑的糊图。多帧图像超分辨率重构真正的前提只有一条:帧与帧之间必须存在亚像素级的采样差异,并且这个差异要能被精确估计出来。差 0.3 个像素,融合出来就是双重边缘;差 1 个像素以上,融合结果还不如单帧 bicubic。
这份代码把整条链路拆成了可替换的六段:退化仿真、ECC 亚像素配准、加权 shift-and-add 融合、迭代反投影精修、PSNR/SSIM 评估、可视化诊断。纯 numpy + OpenCV,不依赖 GPU,也不需要任何预训练权重。它适合两类人:想弄明白经典多帧超分怎么落地而不是只背公式的;以及手上真有一组糊图,需要一份能调参、能改、能当基线对照的工程代码的。
2. 项目全景:从退化模型倒推出来的六个模块
2.1 目录结构:每个文件对应模型里的一个算子
mfsr/ ├── configs/ │ └── default.yaml # 全部可调参数集中在这, 不散落在代码里 ├── data/ │ ├── hr/ # 高分辨率参考图, 只用于评估和仿真 │ └── frames/ # 多帧低分辨率输入 frame_000.png ... ├── mfsr/ │ ├── degrade.py # 合成退化: 亚像素位移 + PSF 模糊 + 下采样 + 噪声 │ ├── register.py # ECC 亚像素配准, 输出 2x3 仿射矩阵 │ ├── fuse.py # 加权 shift-and-add, 落到 scale 倍 HR 网格 │ ├── ibp.py # 迭代反投影精修 │ ├── metrics.py # PSNR / SSIM / 对齐残差 │ └── viz.py # 三联对比图、误差热力图、频谱曲线 ├── run_demo.py # 一条命令: 仿真 -> 重构 -> 评估 -> 出图 └── requirements.txt拆成这个颗粒度不是洁癖。多帧超分最麻烦的地方在于出问题时你分不清是配准错了、PSF 估错了,还是迭代发散了。把每个算子独立成文件,就能单独跑、单独打印中间量:register输出相关系数,fuse输出每帧权重,ibp输出每轮残差能量。翻车的时候这三个数一摆出来,问题基本就定位了。
degrade.py是唯一可以被扔掉的一层。真实数据下你没有 HR 真值,也不需要仿真,直接把data/frames/喂给register就行,后面四个模块照跑不误。我见过太多 demo 把仿真和算法搅在一个main里,换真实数据时无从下手。保持"仿真层可抛弃",是这套结构里我最看重的一条。
2.2 退化模型先定死:y = D H F x + n
整套代码是围着这一个式子写的,所有参数都有物理含义:
$$ y_k = D , H , F_k , x + n_k ,\quad k = 1 \dots N $$
x是待求的高分辨率图,F_k是第 k 帧的几何位移(平移为主,也可扩展到旋转),H是光学点扩散函数,工程上用高斯核近似,D是下采样算子,n是加性噪声。多帧之所以能超分,是因为F_k各不相同,每帧的采样格点错开,N帧合起来携带了超过单帧奈奎斯特极限的信息。反过来说,F_k估计得准不准,直接决定重建上限——这就是为什么第 3 章要花最大篇幅讲配准。
| 参数 | 代码位置 | 典型取值 | 调错会怎样 |
|---|---|---|---|
scale | cfg['scale'] | 2 / 4 | 越大越依赖配准精度,4 倍以上纯平移模型就不够用了 |
psf_sigma | cfg['psf_sigma'] | 0.8 ~ 1.5 | 估小了结果过锐、出振铃;估大了高频补不回来 |
n_frames | cfg['n_frames'] | 6 ~ 16 | 超过 10 帧后边际收益明显递减,但配准失败的容错变好 |
lam | cfg['ibp']['lam'] | 0.6 ~ 1.0 | 大于 1 容易震荡,小于 0.4 收敛太慢 |
gauss_filt_size | cfg['ecc'] | 5 | 必须奇数,太小 ECC 在噪声上乱跑 |
配置文件长这样,改参数不用碰代码:
# configs/default.yaml scale: 4 psf_sigma: 1.2 noise_sigma: 2.0 n_frames: 9 ecc: motion: euclidean # 纯平移场景也可以设成 translation, 自由度更少更稳 max_iter: 200 eps: 1.0e-6 gauss_filt_size: 5 ibp: iters: 25 lam: 0.8 tol: 1.0e-52.3 环境与最小闭环:先跑通一份绝对能运行的最小闭环
装依赖这一步最容易翻车,我把踩过的记在这儿:
python -m venv .venv && source .venv/bin/activate pip install "numpy>=1.24" "opencv-python>=4.8" "scikit-image>=0.22" pyyaml python run_demo.py --config configs/default.yaml --scale 4 --frames 9findTransformECC在opencv-python主包里就有,不需要装 contrib 版本。scikit-image纯粹是为了省事拿peak_signal_noise_ratio和structural_similarity,不想装的话自己用 numpy 写十行也能算。
注意:
opencv-python和opencv-contrib-python同时装,import cv2会随机命中其中一个,出现莫名其妙的undefined symbol。只留一个。
入口脚本把四段串起来,顺便留了--hr、--scale、--frames三个命令行开关,方便后面扫参:
# run_demo.py —— 一条命令跑完仿真、重构、评估 import argparse, cv2, numpy as np, yaml from mfsr import degrade, register, fuse, ibp, metrics, viz def main(): ap = argparse.ArgumentParser() ap.add_argument("--config", default="configs/default.yaml") ap.add_argument("--hr", default="data/hr/target.png") ap.add_argument("--scale", type=int, default=None) ap.add_argument("--frames", type=int, default=None) args = ap.parse_args() cfg = yaml.safe_load(open(args.config, encoding="utf-8")) scale = args.scale or cfg["scale"] n = args.frames or cfg["n_frames"] hr = cv2.imread(args.hr, cv2.IMREAD_GRAYSCALE).astype(np.float32) # 1) 仿真: 同时拿到真值位移, 后面用来验证配准精度 lrs, gt_shifts = degrade.make_lr_frames( hr, n, scale, cfg["psf_sigma"], cfg["noise_sigma"]) ref = lrs[0] # 2) 配准: 全部对齐到 ref 的坐标系 aligned, warps, cc = register.align_to_reference(ref, lrs, **cfg["ecc"]) # 3) 融合 + 精修 hr_saa = fuse.shift_and_add(aligned, warps, cc, scale) hr_ibp = ibp.refine(hr_saa, aligned, scale, **cfg["ibp"]) # 4) 评估: 裁掉 2*scale 的边界, 避开 BORDER_REFLECT 的虚边 crop = 2 * scale gt = hr[crop:-crop, crop:-crop] base = cv2.resize(ref, (hr.shape[1], hr.shape[0]), interpolation=cv2.INTER_CUBIC)[crop:-crop, crop:-crop] for name, img in [("bicubic", base), ("saa", hr_saa[crop:-crop, crop:-crop]), ("saa+ibp", hr_ibp[crop:-crop, crop:-crop])]: print(f"{name:>8s} PSNR={metrics.psnr(gt, img):6.2f} " f"SSIM={metrics.ssim(gt, img):.4f}") viz.triplet(gt, base, hr_ibp, "out/compare.png") if __name__ == "__main__": main()gt_shifts这个返回值看着多余,其实是我特意留的后悔药:degrade里生成位移时用的矩阵是已知的,拿它和register估出来的warps一比,配准误差一目了然。搞视觉的人都知道,没有真值的时候调参全靠玄学,有真值的时候调参就是查表。
3. 核心链路拆解:配准、融合、反投影三段怎么接
3.1 造数据:把已知位移的多帧 LR 生成出来
真实数据不好拿,先把仿真这层写扎实。这一段的意义不只是造图,更是给你一个"知道正确答案"的实验环境:
# mfsr/degrade.py import numpy as np, cv2 def make_lr_frames(hr, n_frames=9, scale=4, psf_sigma=1.2, noise_sigma=2.0, seed=0): """从一张高分辨率图生成多帧低分辨率观测。 hr : float32 灰度图, 0~255 n_frames : 低分辨率帧数 scale : 下采样倍数 psf_sigma : 高斯 PSF 标准差, 模拟光学退化 noise_sigma : 加性高斯噪声标准差 返回 frames(列表) 和 shifts(N,2) 真值位移, 单位为 HR 像素 """ rng = np.random.default_rng(seed) h, w = hr.shape ksize = int(2 * np.ceil(3 * psf_sigma) + 1) if ksize % 2 == 0: ksize += 1 frames, shifts = [], [] for _ in range(n_frames): # 位移幅度控制在半个 LR 像素以内, 太大就超出配准的可搜索范围 dx, dy = rng.uniform(-scale / 2, scale / 2, size=2) M = np.array([[1, 0, dx], [0, 1, dy]], dtype=np.float32) shifted = cv2.warpAffine(hr, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REFLECT) blurred = cv2.GaussianBlur(shifted, (ksize, ksize), psf_sigma) lr = cv2.resize(blurred, (w // scale, h // scale), interpolation=cv2.INTER_AREA) lr = lr + rng.normal(0, noise_sigma, lr.shape) frames.append(np.clip(lr, 0, 255).astype(np.float32)) shifts.append((dx, dy)) return frames, np.array(shifts, dtype=np.float32)几个参数的选择依据:位移幅度卡在±scale/2之内,是因为 ECC 是局部优化,初值离真值太远会直接跑到错误的极值点;下采样用INTER_AREA而不是INTER_LINEAR,前者等价于像素块平均,和D算子(盒式下采样)的语义一致,用错了等于给模型里塞了一个不匹配的算子,后面怎么调都对不齐;模糊核尺寸必须奇数,ksize的2*ceil(3σ)+1是覆盖 ±3σ 的标准取法。
提示:想看配准的极限精度,把
noise_sigma设成 0 跑一遍再把估出来的warps和gt_shifts相减,误差应该在 0.05 像素以内。如果超过 0.2 像素,先别往下走,配准这层就有问题。
3.2 亚像素配准:findTransformECC 的 warp 方向别搞反
这是整个项目里最容易翻车的一段,没有之一。findTransformECC的返回值语义很绕,OpenCV 官方示例里warpAffine是带着WARP_INVERSE_MAP用的,很多人凭直觉再套一层invertAffineTransform,结果就是镜像加反相,融合出来满屏重影。
# mfsr/register.py import numpy as np, cv2 def align_to_reference(ref, frames, motion=cv2.MOTION_EUCLIDEAN, max_iter=200, eps=1e-6, gauss_filt_size=5): """以 ref 为基准, 把每帧重采样到 ref 的坐标系。 motion 支持 translation / euclidean / affine, 纯平移场景用 translation 自由度更少, 收敛更稳; 手持拍摄有轻微旋转就用 euclidean。 """ motion_map = { "translation": cv2.MOTION_TRANSLATION, "euclidean": cv2.MOTION_EUCLIDEAN, "affine": cv2.MOTION_AFFINE, } mtype = motion_map[motion] if isinstance(motion, str) else motion criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, max_iter, eps) aligned, warps, ccs = [], [], [] for f in frames: warp = np.eye(2, 3, dtype=np.float32) try: cc, warp = cv2.findTransformECC( ref, f, warp, mtype, criteria, None, gauss_filt_size) except cv2.error: # 不收敛就回退恒等, 相关系数记 0, 后面融合时会被降权丢掉 cc, warp = 0.0, np.eye(2, 3, dtype=np.float32) # 关键: warp 描述的是 template->input 的映射, # 想让 input 落到 template 的格点上, 必须用 WARP_INVERSE_MAP 采样 out = cv2.warpAffine(f, warp, (ref.shape[1], ref.shape[0]), flags=cv2.INTER_CUBIC | cv2.WARP_INVERSE_MAP, borderMode=cv2.BORDER_REFLECT) aligned.append(out) warps.append(warp) ccs.append(float(cc)) return aligned, np.array(warps, dtype=np.float32), np.array(ccs, dtype=np.float32)参数上几个经验值:gauss_filt_size从 5 起步,噪声大的序列调到 7,但别超过 9,否则等于把高频信息先糊掉再做配准,估出来的位移会偏;max_iter给 200 很宽裕,实际大多 30 次内就停;eps设1e-6已经过了收益拐点,再小只是多烧 CPU。
cc(相关系数)这个返回值不要浪费。它天然是一个"这一帧靠不靠谱"的置信度:低于 0.6 基本可以判定这帧要么过曝、要么被运动模糊毁了,直接踢出融合。这比事后看 PSNR 掉点再回头删帧高效得多。
返回的warps还有第二个用途——做单测。拿gt_shifts和warps[:, :, 2]对比(注意坐标缩放关系),可以量化配准精度,也可以在改参数时立刻看到影响。
3.3 加权 shift-and-add:把多帧的亚像素信息压到 HR 网格
配准完之后,所有帧都在同一个坐标系下了,但它们仍然是 LR 分辨率。融合这一步要做的是把它们各自携带的、位置略微不同的采样,累加到一个scale倍细的网格上:
# mfsr/fuse.py import numpy as np, cv2 def shift_and_add(aligned, warps, ccs, scale, min_cc=0.6): """加权 shift-and-add, 把对齐后的 LR 帧融合到 scale 倍 HR 网格。 aligned : 已对齐到参考帧坐标系的 LR 帧 warps : 每帧 2x3 仿射矩阵, 本版本只用来排查, 未参与散射 ccs : ECC 相关系数, 直接当融合权重 min_cc : 相关系数阈值, 低于此值的帧整帧丢弃 """ h, w = aligned[0].shape acc = np.zeros((h * scale, w * scale), np.float32) wsum = np.zeros_like(acc) for f, c in zip(aligned, ccs): if c < min_cc: continue # 双三次上采样, 每帧的亚像素差异会体现在插值权重的细微不同上 up = cv2.resize(f, (w * scale, h * scale), interpolation=cv2.INTER_CUBIC) weight = float(max(c, 1e-3)) acc += weight * up wsum += weight return (acc / np.maximum(wsum, 1e-6)).astype(np.float32)用INTER_CUBIC而不是INTER_NEAREST,是因为最近邻会把好不容易保留的亚像素信息直接扔掉——配准做得再准,上采样用最近邻就等于白干。权重直接取cc,是为了让配准质量好的帧占主导,这是一种简易的鲁棒融合,比直接取平均稳得多。
这个版本有一个已知的简化:warps只做了排查用途,没有真正参与 HR 网格上的散射。对纯平移、小位移的场景,配准后再上采样会造成一点点额外的平滑;真正严谨的做法是把warps的平移量乘以scale,在 HR 坐标系下做前向映射加 splatting。二次开发时如果要处理旋转或者位移超过 1 个 LR 像素的序列,这条必须补上,否则结果会明显发虚。
3.4 迭代反投影 IBP:用残差把高频"顶"回去
融合出来的 HR 图通常偏软,因为它本质上是若干插值结果的加权平均。IBP 的思路很直白:把当前 HR 估计按退化模型重新降级一次,和真实 LR 帧比,差值就是"少了什么",把差值反投回去补上:
# mfsr/ibp.py import numpy as np, cv2 def refine(hr, frames, scale, psf_sigma=1.2, iters=25, lam=0.8, tol=1e-5, clip=(0, 255)): """迭代反投影。frames 必须是同一坐标系下的 LR 帧。 lam : 反投影步长, 0.6~1.0 之间; 大于 1 容易在两个解之间来回震荡 tol : 残差能量的相对变化阈值, 用于早停 """ hr = hr.copy() k = int(2 * np.ceil(3 * psf_sigma) + 1) if k % 2 == 0: k += 1 prev = None for it in range(iters): energy = 0.0 for lr in frames: # 正向: 模糊 + 下采样, 逼近这一帧的观测 sim = cv2.GaussianBlur(hr, (k, k), psf_sigma) sim = cv2.resize(sim, (lr.shape[1], lr.shape[0]), interpolation=cv2.INTER_AREA) res = lr - sim energy += float(np.mean(res * res)) # 反向: 残差上采样 + 模糊(转置算子的近似), 按步长叠回 back = cv2.resize(res, (hr.shape[1], hr.shape[0]), interpolation=cv2.INTER_CUBIC) back = cv2.GaussianBlur(back, (k, k), psf_sigma) hr += lam * back / len(frames) hr = np.clip(hr, *clip) # 早停: 残差能量不再明显下降就退出, 再多跑只会放大噪声 if prev is not None and abs(prev - energy) <= tol * max(energy, 1e-9): print(f"[ibp] 第 {it} 轮早停, 残差能量 {energy:.4f}") break prev = energy return hr.astype(np.float32)注意这里的frames传的是aligned而不是原始的lrs。原因很实在:IBP 的公式里没有几何项,它假设 HR 估计和 LR 观测是同一坐标系的,只有对齐过的帧才满足这个前提。传原始帧进去,反投影的方向就全乱了。
lam和早停是配合使用的。lam大收敛快但容易把噪声一起放大,lam小则需要更多轮次。我的经验是:lam = 0.8配tol = 1e-5,在 4 倍放大、9 帧的条件下通常 8 到 15 轮就停了。如果你发现它跑满 25 轮还没收敛,多半是 PSF 参数和实际退化不匹配,别去调iters,回头查psf_sigma。
4. 评估与诊断:PSNR 涨了不代表图就对了
4.1 三个基线一起跑:bicubic / shift-and-add / +IBP
只跑最终结果看不出每一步贡献了多少。评估模块强制你同时输出三个数,才能判断多帧信息到底进来了没有:
# mfsr/metrics.py import numpy as np from skimage.metrics import peak_signal_noise_ratio as _psnr from skimage.metrics import structural_similarity as _ssim def psnr(gt, est, data_range=255.0): """图像未归一化到 0~1, 所以 data_range 必须显式给 255""" return _psnr(gt.astype(np.float64), est.astype(np.float64), data_range=data_range) def ssim(gt, est, data_range=255.0, win=7): """多帧超分关心的是局部纹理, 窗口开太大反而被平坦区稀释""" return _ssim(gt.astype(np.float64), est.astype(np.float64), data_range=data_range, win_size=win) def align_residual(ref, aligned): """每帧与参考帧的 RMS 差值, 用来判断配准是否真的收敛""" return [float(np.sqrt(np.mean((a - ref) ** 2))) for a in aligned]三个细节容易忽略。data_range不显式给默认按 1.0 算,你的图是 0~255 的话 PSNR 会离谱地高;win_size必须是小于图像边长的奇数,超分结果里平坦区域占比很大,窗口开 11 会被大面积平滑区主导,掩盖真实差异;align_residual那个函数不参与最终指标,但它是排查利器——如果某一帧的 RMS 比均值高出一倍,那帧大概率配准失败了,翻回去看cc就应该已经被 0.6 的阈值挡掉了,没挡住说明阈值设松了。
两个指标的分工要清楚:PSNR 对整体亮度偏移极其敏感,对轻微过锐反而不敏感;SSIM 看结构相似度,对纹理糊化更严格。两个一起看,一个涨一个掉,八成是过锐了。
4.2 对齐残差与频谱:定位问题到底出在哪一段
指标只告诉你"好不好",不告诉你"错在哪"。我习惯加两样诊断:误差热力图和径向频谱曲线。
# mfsr/viz.py 节选 import numpy as np def radial_spectrum(img, nbins=None): """径向平均幅度谱, 用来看高频到底补回来没有""" f = np.abs(np.fft.fft2(img - img.mean())) f = np.fft.fftshift(f) h, w = f.shape y, x = np.ogrid[:h, :w] r = np.sqrt((y - h / 2) ** 2 + (x - w / 2) ** 2).astype(np.int32) nbins = nbins or min(h, w) // 2 prof = np.zeros(nbins, dtype=np.float64) for i in range(nbins): mask = (r == i) if mask.any(): prof[i] = f[mask].mean() return prof用法很简单:把 bicubic 基线和 IBP 结果的频谱曲线画在同一张图上对比。如果两条线在中高频段几乎重合,说明多帧信息根本没进来——问题在配准或者融合,不在 IBP。如果 IBP 的曲线在最高频段突然翘起来一个尖峰,那是噪声被反投影放大了,把lam调小或者把早停阈值放宽。
误差热力图同理:把|gt - est|归一化后伪彩色。误差集中在强边缘周围,说明 PSF 估计和真实退化不匹配,调psf_sigma;误差均匀分布在整个画面,说明配准没收敛,回去查gauss_filt_size和参考帧选择。
提示:频谱曲线一定要在裁剪掉边界之后再算。
BORDER_REFLECT造出来的镜像边缘会贡献大量虚假高频,把整条曲线的形状带偏。
5. 避坑排查:五个让我返工过的具体故障
5.1 几何与配准:方向、初值、边界
故障一:融合结果每条边缘旁边有条浅影,像双重曝光。原因是warp方向理解反了——findTransformECC返回的矩阵描述的是 template 到 input 的映射,有人又套了一层invertAffineTransform再配WARP_INVERSE_MAP,等于反了两次。解决办法就一条:warpAffine直接吃 ECC 返回的矩阵,加WARP_INVERSE_MAP,不要做任何求逆。验证方式是拿仿真数据的gt_shifts做单测,误差应该小于 0.1 像素;双重边缘时这个误差通常在 1 像素以上,一眼就能看出来。
故障二:ECC 抛cv2.error,或者相关系数只有 0.1 出头。三个常见诱因:两帧整体亮度差太大(曝光变化)、初值离真值太远(大位移序列)、gauss_filt_size设得太小(噪声主导了梯度)。我的处理顺序是先把输入归一化到 0~1,再预平滑cv2.GaussianBlur(f, (0,0), 1.5),然后把gauss_filt_size从 5 往 7 调。大位移序列先跑一次cv2.phaseCorrelate拿平移初值,把结果矩阵塞进 ECC 的warp参数里,收敛率能高一大截。最后一定要留异常分支:try/except里回退恒等矩阵、cc记 0,让融合阶段自动降权,绝不能让一帧坏数据毁掉整张图。
故障三:结果四周一圈黑边或者亮边,还被当成特征。这是边界模式不统一造成的。warpAffine默认是 `BORDER_CONST
本文还有配套的精品资源,点击获取