拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

图像质量评价指标全解析:PSNR、SSIM、LPIPS、FID、IS、P/R

图像质量评价指标全解析:PSNR、SSIM、LPIPS、FID、IS、P/R

做超分、去噪、修复、生成这一圈活儿的人,几乎都会在某个时刻被同一个问题卡住:这张图到底算不算好?你说好看,评审说不够锐;你说指标高,审稿人反问 PSNR 涨了 0.1dB 有什么意义。我自己最早接触 PSNR 和 SSIM 的时候,天真地以为只要这两个数上去了,图就一定是好的,结果在生成任务上被现实教育了一顿——SSIM 0.92 的图糊得像隔了层毛玻璃,而另一组 SSIM 只有 0.85 的结果肉眼看着舒服得多。后来才慢慢把 LPIPS、IS、FID、Precision、Recall 这一整套体系摸清楚:它们不是互相替代的关系,而是各自回答不同的问题。

这篇东西我打算把这七个指标的来龙去脉、计算方式、代码实现和踩坑经验一次讲透。适合谁看?正在写图像恢复或生成方向论文的研究生、需要给模型做客观评测的算法工程师、以及被"指标为什么对不上"折磨过的同行。PSNR、SSIM 是像素级的入门款,LPIPS 负责感知层面的相似度,IS 和 FID 站在分布层面衡量生成质量,Precision 和 Recall 则把"质量"和"多样性"这两件常被混为一谈的事拆开来看。全篇不聊虚的,每个指标我都会给出公式直觉、实测代码和真实踩过的坑,看完你至少能自己搭一套评测流水线,并且知道每个数字该信到什么程度。

1. 先把指标地图画清楚:七种指标各自回答什么问题

在动手算任何一个指标之前,我强烈建议先想清楚一件事:你手上这次比较,到底是"两张图之间像不像",还是"一堆生成图整体像不像真图"。这两个问题的答案完全不在一个层面上,混用指标是新手最常见的翻车原因。我自己带过的几个项目里,最典型的一幕就是有人拿 LPIPS 去评估整个数据集的生成质量,然后发现数值毫无区分度——因为 LPIPS 是逐对比较的,它压根不关心分布。选指标的第一步不是记公式,而是先给自己的任务归类。

1.1 全参考、无参考与分布级:三种完全不同的比较方式

全参考(Full-Reference)指标要求你同时拥有生成结果和对应的真值图,也就是说你必须有配对数据。PSNR、SSIM、LPIPS 都属于这一类。它们回答的是"这一对图有多像",适用于超分辨率、去噪、去模糊、压缩、图像修复这些有 ground truth 的任务。这里的关键约束是配对:如果生成的图和真值在图空间上对不齐,哪怕只是平移了两个像素,PSNR 都会掉得很难看,但这不代表图不好。

分布级(Distribution-level)指标不需要配对,它把真实图像集和生成图像集各看成一个整体分布,然后比较两个分布的距离或者重叠程度。IS、FID、Precision、Recall 都是这一挂。它们适用于无条件生成、文本生成图像、风格迁移这类"没有唯一正确答案"的任务。你不可能给一张"画一只猫"的生成结果找真值,但你可以问:这一万张生成的猫,和真实猫图片的分布差多远。

无参考(No-Reference)指标这块我没列进标题,但实测中很实用,比如 NIQE、BRISQUE、MUSIQ 这类,它们只吃一张图就能打分,常用于真实场景的退化图像评估。之所以不展开,是因为它们的可解释性和可复现性争议比较大,训练集偏置明显,做学术对比时容易被质疑。知道它们存在、知道什么时候该用,暂时就够了。

1.2 七种指标定位速查

我习惯用一张表把定位固定下来,写论文和做工程汇报时直接照着填,能省掉很多解释成本:

指标类型输入要求取值方向主要用途
PSNR全参考、像素级配对图像越大越好,单位 dB超分、去噪、压缩的基线对比
SSIM全参考、结构级配对图像0~1,越大越好关注结构保真的恢复任务
LPIPS全参考、感知级配对图像越小越好衡量人眼感知差异
IS分布级、无参考仅生成集越大越好生成图像清晰度与可辨识度
FID分布级、无参考真实集+生成集越小越好生成分布与真实分布的距离
Precision分布级真实集+生成集0~1,越高越保真生成样本落在真实流形内的比例
Recall分布级真实集+生成集0~1,越高越多样真实分布被生成覆盖的比例

这张表我会一直带在身边,因为它直接回答了一个非常现实的问题:评审问"你为什么不算 FID"的时候,你可以说"我的任务有配对真值,FID 不适用,我用的是 LPIPS 加 SSIM 组合"。这句话一说,专业度立刻不一样。

1.3 选型的第一原则:先确定你要证明什么

我逐渐总结出一条很朴素的规则:指标的选择应该由你想证明的结论倒推,而不是由习惯决定。如果你想证明"我的方法在像素保真上更接近真值",那就用 PSNR 和 SSIM,并且老老实实报告每张图的结果和平均值。如果你想证明"我的方法生成的图更像真的",那就上 FID 和 Precision/Recall,并且保证真实集和生成集的样本量、预处理方式完全一致。

这里有个很容易被忽略的细节:指标数量不是越多越好。我见过一些论文堆了七八个指标,结果其中两个互相矛盾,反而给审稿人递了刀子。比较稳妥的做法是主指标一到两个、辅助指标一到两个,并且提前想好:如果辅助指标和主指标打架,你怎么解释。通常打架的地方才藏着真正的信息量,比如 PSNR 高但 LPIPS 差,往往说明模型在做平滑,这个现象本身就值得写一段分析。

2. PSNR 与 SSIM:像素域的两位老将

这两个指标基本是所有图像任务的标配,但真正能说清楚它们适用边界的人并不多。我的经验是,把 PSNR 当"温度计"看,把 SSIM 当"结构体检"看,两者都不能单独作为最终结论。下面我把公式直觉、实现细节和坑一次性说清楚。

2.1 PSNR 的算法与 dB 值的直觉

PSNR 的定义非常直接,先算均方误差 MSE,再取对数:

MSE = (1 / (H*W*C)) * Σ (I_pred - I_gt)^2 PSNR = 10 * log10(MAX^2 / MSE)

其中 MAX 是像素动态范围,8 位图就是 255。这里的对数关系决定了 PSNR 的一个特性:它对误差的敏感度是非线性的。MSE 减半,PSNR 只涨大约 3dB。所以当你看到 PSNR 从 28.5 涨到 28.7,别急着高兴,这点提升在视觉上基本看不出来,可能只是某几个平坦区域的噪声稍微小了一点点。

再看 dB 值的直觉。30dB 大概是"能看出差异但整体可接受",35dB 以上人眼已经很难分辨,40dB 往上基本就是压缩算法在自娱自乐了。我实测过一个极端例子:把真值图加 1 的均匀偏移(所有像素加 1),PSNR 能到 48dB 左右,肉眼几乎看不出任何差别,但如果把图整体轻微模糊,PSNR 可能会掉到 32dB,而肉眼第一眼未必看得出来。这就是 PSNR 的局限:它对全局亮度和对比度的偏移非常宽容,对结构性模糊反而迟钝。

2.2 SSIM 三项分解与局部滑窗

SSIM 的设计初衷就是为了弥补 PSNR 只看逐像素误差的缺陷。它从三个维度同时比较:亮度、对比度、结构。

SSIM(x, y) = [l(x,y)]^α · [c(x,y)]^β · [s(x,y)]^γ l(x,y) = (2μxμy + C1) / (μx² + μy² + C1) # 亮度 c(x,y) = (2σxσy + C2) / (σx² + σy² + C2) # 对比度 s(x,y) = (σxy + C3) / (σxσy + C3) # 结构

实践中通常取 α=β=γ=1,C3=C2/2,简化成常见的那个长公式。这里的 μ 是局部均值,σ 是局部标准差,σxy 是协方差,全部在滑动窗口内计算。窗口一般用 11×11 的高斯核,σ 取 1.5。

理解 SSIM 的一个好类比:它像在问三个问题——"这两块的明暗差不多吗""这两块的对比度差不多吗""这两块的花纹走向一致吗"。三个都点头才给高分。所以一片纯色区域即使整体偏亮,SSIM 也不会崩得太厉害;但如果把边缘位置挪动一个像素,结构项立刻掉下来。

2.3 代码实现与三个必踩的坑

我用得最多的是scikit-image,代码很短,但坑都在细节里:

import numpy as np from skimage.metrics import peak_signal_noise_ratio, structural_similarity def evaluate_pair(pred, gt, data_range=255.0): # 统一转成 float,避免 uint8 减法溢出 pred = pred.astype(np.float64) gt = gt.astype(np.float64) psnr = peak_signal_noise_ratio(gt, pred, data_range=data_range) # channel_axis 在新版本里替代了 multichannel=True ssim = structural_similarity( gt, pred, channel_axis=2, # 彩色图必须指定,否则会按单通道算 data_range=data_range, gaussian_weights=True, sigma=1.5, use_sample_covariance=False ) return psnr, ssim

第一个坑是uint8 溢出。我发现过好几次这种情况:直接把 uint8 图丢进去相减,结果 MSE 算出负数或者异常小的值,PSNR 直接飙到 80dB。后来养成习惯,所有指标计算内部一律先转 float64,边界上再转回去。

第二个坑是data_range 传错。如果你把图像归一化到 [0,1] 却仍然让函数按 255 处理,PSNR 会凭空少掉约 48dB,数值会非常难看。反过来,如果图是 [0,255] 却传了 data_range=1.0,PSNR 又会虚高。我现在写评测脚本,一定会在开头断言一下数据的取值范围:

def check_range(img, name): lo, hi = float(img.min()), float(img.max()) assert lo >= 0.0, f"{name} 有负值: {lo}" assert hi <= 255.0 + 1e-6, f"{name} 超出范围: {hi}" print(f"[{name}] range = [{lo:.3f}, {hi:.3f}]")

第三个坑是通道维度和四通道图。遇到 RGBA 或者多光谱图时,channel_axis要写对,否则要么报错,要么悄悄按错误的维度算,结果就完全不可比。我一般的做法是评测前统一把图转成 RGB 三通道、8 位或者 float32 [0,1],把格式收敛掉,后面所有指标都用同一份预处理结果。

2.4 什么时候该用,什么时候坚决不用

PSNR 和 SSIM 最适合的场景是有明确真值且目标是保真的任务。比如医学影像去噪、遥感图像复原、老照片修复、图像压缩算法对比。这些任务里,失真主要来自噪声和编码误差,像素级和结构级指标和主观感受的相关性还算能接受。

但在生成任务、风格迁移、艺术化处理上,我会明确避免用它们当主指标。原因很简单:这些任务允许多种正确答案,而 PSNR 只认一种。一个把图像风格化处理得很漂亮的结果,PSNR 可能只有 18dB;而一个把真值轻微模糊、什么也没生成出来的模型,PSNR 能有 35dB。如果你拿 PSNR 去挑模型,最终选出来的必然是那个最保守、最平滑、最不出错的——这恰恰是你不想看到的结果。我个人的经验是,遇到这类任务,把 PSNR 和 SSIM 放在"参考项"里,真正做决策的还是 LPIPS 和 FID。

3. LPIPS:把"像不像"的判断交给深度特征

LPIPS(Learned Perceptual Image Patch Similarity)是我个人认为近几年最实用的一个感知指标。它做的事情很朴素:既然人眼看图靠的是大脑的深层特征,那我们就用训练好的深度网络提取特征,在特征空间里比距离。这个思路一出来,"指标高但人眼觉得差"的矛盾就缓解了不少。

3.1 核心思想:特征空间的加权 L2

LPIPS 的计算流程可以拆成四步。第一步,把两张图送进一个预训练网络(通常是 AlexNet 或 VGG16)。第二步,在网络的若干中间层分别提取特征图,每一层都做通道维度的 L2 归一化,这一步很关键,目的是让不同层的激活尺度可比。第三步,对每一层的特征差取平方,在通道维求平均,再乘一个可学习的权重 w_l,最后对空间位置求平均。第四步,把所有层的分数加起来,得到最终距离。

公式的样子大概是这样:

d(x, x0) = Σ_l (1 / (H_l * W_l)) * Σ_hw || w_l ⊙ (ŷ_l^hw - ŷ0_l^hw) ||²

那个 w_l 不是拍脑袋定的,而是在一个带人类主观判断的数据集上训练出来的。也就是说,LPIPS 的"感知"并不是玄学,它是从大量真实的人类二选一标注里学出来的,这也是它能和主观打分保持较高相关性的原因。

3.2 骨干网络选择与版本差异

官方实现里有三个 backbone 可选:alex、vgg、squeeze。我在不同项目里都试过,体感差异挺明显:AlexNet 版本速度快、显存占用小,适合大规模批量评测,但灵敏度略低;VGG 版本对纹理和细节的变化更敏感,数值区分度更好,代价是慢得多、显存吃紧;SqueezeNet 版本适合嵌入式或者大批量场景,精度排在前两者之间。

还有一个必须注意的版本差异:LPIPS 有 0.1 和 1.0 两个版本,权重完全不同,算出来的数值不能混着比。我在一次跨组协作时就遇到过这个坑——对方用 0.1,我用 1.0,两边报告同一个方法的 LPIPS 差了将近 0.1,讨论了半天才发现是版本不一致。从那以后,我要求所有评测脚本在日志里打印 backbone 名字和 version:

import lpips import torch # 明确指定 backbone 和 version,别用默认值糊过去 lpips_fn = lpips.LPIPS(net='alex', version='0.1').eval().cuda() def to_lpips_input(tensor_01): # LPIPS 期望 [-1, 1],必须先做线性变换 return tensor_01 * 2.0 - 1.0 with torch.no_grad(): dist = lpips_fn(to_lpips_input(pred), to_lpips_input(gt)) print("lpips =", float(dist.mean()))

3.3 实操:归一化、reduction、显存控制

归一化这个点特别容易错。LPIPS 内部是按 [-1,1] 的输入设计的,如果你直接把 [0,1] 的张量塞进去,数值会系统性偏移。我做过对比测试,同一批数据,不归一化时 LPIPS 平均偏高约 0.03 到 0.06,虽然排序上未必翻车,但跨实验对比就全废了。所以这一行* 2 - 1千万别省。

reduction参数也值得一提。默认是mean,返回标量;如果你想分析每张图的分布、找异常样本,就设成none,拿到逐图分数后再自己统计中位数和分位数。我在做错误分析时特别依赖这个,因为平均 LPIPS 会把少数极差的样本淹没掉,而这些样本往往才是问题所在。

显存控制方面,VGG backbone 在 256×256 分辨率下,batch size 开到 16 就可能爆显存。我的做法是把评测拆成小 batch,用一个DataLoader流式处理,实时累加分数,这样无论多少张图都不会 OOM。代码结构大致如下:

def batch_lpips(pred_loader, gt_loader, net='alex', bs=16): fn = lpips.LPIPS(net=net, version='0.1').eval().cuda() total, count = 0.0, 0 with torch.no_grad(): for p, g in zip(pred_loader, gt_loader): p = (p.cuda() * 2 - 1) g = (g.cuda() * 2 - 1) d = fn(p, g).mean().item() total += d * p.size(0) count += p.size(0) return total / count

3.4 LPIPS 的局限

LPIPS 也不是万能的。它依赖 ImageNet 预训练特征,所以对训练分布之外的图像(医学影像、遥感、显微镜图)泛化性会打折扣。我试过用它评估病理切片,发现数值区分度很差,同类样本之间的 LPIPS 波动比不同方法之间的差距还大,这种情况下就不能硬用了。

另一个问题是它对全局色调变化的敏感度偏高。一张图整体调暖一点,感知上可能更讨喜,但 LPIPS 会明显变大。所以如果你的任务本身包含色彩风格调整,报告 LPIPS 时要额外说明,最好再配一张主观对比图。我的惯例是 LPIPS 加 SSIM 组合报告,一个看感知一个看结构,两个一起看才能判断模型是真进步还是只是换了个失真类型。

4. IS 与 FID:生成模型的分布级体检

聊完逐对比较,就要进入分布比较的领域了。IS 和 FID 是生成模型圈子里出现频率最高的两个数,但它们也是最容易被误用、被过度解读的两个数。我把它们的原理、实操细节和那些没人明说的坑一起摆出来。

4.1 IS 的设计初衷与它的历史包袱

IS(Inception Score)的思路是:一张好的生成图,应该能被分类器以很高的置信度归到某一类(清晰、可辨识),同时在一个 batch 里,生成的类别分布应该足够分散(多样)。它用 Inception 网络输出的类别概率 p(y|x) 来量化这两件事:

IS = exp( E_x [ KL( p(y|x) || p(y) ) ] )

KL 散度衡量的是"单张图的类别分布"和"整个 batch 的边缘类别分布"之间的差异。单图越自信、整体越分散,KL 越大,IS 越高。听起来挺合理,但它的毛病也很明显。

第一个问题是它只关心"能不能被分类器认出来",不关心"像不像真的"。一个把纹理做得极其夸张但类别特征明显的模型,IS 可能很高。第二个问题是它对类别分布极度敏感:如果 ImageNet 的类别本身不均衡,或者你的数据集不是 ImageNet 那 1000 类,IS 就失去了可比性。第三个也是我印象最深的一点,IS 只能在同一模型的不同训练阶段横向对比,跨数据集、跨论文的 IS 数值几乎没有可比性。见过不少论文写"我们的 IS 达到 8.7,超过某基线",但如果两者的数据规模和预处理不同,这个对比是站不住的。所以我现在的态度是:IS 可以报,但不要作为主要论据。

4.2 FID 的数学本质与直觉

FID(Fréchet Inception Distance)是我个人认为目前最靠谱的单个分布级指标。它的做法是:把真实图和生成图分别送进 Inception 网络,取池化层输出(通常是 2048 维的特征向量),假设这两组特征分别服从多元高斯分布,然后计算两个高斯分布之间的 Fréchet 距离,也叫 2-Wasserstein 距离:

FID = ||μ_r - μ_g||² + Tr( Σ_r + Σ_g - 2 * (Σ_r Σ_g)^(1/2) )

公式里 μ 是特征均值,Σ 是协方差矩阵。第一项衡量两个分布的中心距离,第二项衡量形状(协方差)的差异。这个设计的巧妙之处在于,它同时考虑了均值和协方差,而不仅仅是类别概率,所以比 IS 稳定得多,也更能反映生成分布的整体形态。

用生活化的比喻:把真实图像集和生成图像集各想象成一团在 2048 维空间里的点云。FID 就是问"这两团云的中心离多远,形状差多少"。中心对齐、形状相似,FID 就小。

4.3 FID 实操细节清单

FID 的计算看起来就是调个库,但细节决定结果可信度。我整理了一份自查清单,每次跑评测都会过一遍:

项目推荐做法踩坑后果
样本数量真实集与生成集都至少 10000 张,能到 50000 更好样本少时 FID 方差极大,5000 张时波动可能超过 5
样本对等两边样本数尽量一致数量差距过大会让协方差估计有偏
图像尺寸统一 resize 到 Inception 的 299×299尺寸不一致会改变特征分布
插值方式固定用同一种(如 bicubic),并写进日志不同插值方式能带来 1~3 的 FID 差异
数据范围统一 [0,1] 或 [0,255],与库的要求对齐范围错位会导致特征完全失真
版本固定 Inception 权重来源并记录不同实现的基准值有系统性差异

我最常推荐的是pytorch-fid,命令行直接可用:

python -m pytorch_fid path/to/real_images path/to/fake_images \ --batch-size 50 \ --dims 2048 \ --device cuda:0

如果你需要更精细的控制、或者要复现论文里的数值,我更推荐clean-fid,它支持 resize 抗锯齿等细节,能显著降低实现差异带来的偏差。实测下来,同一个模型在两种实现下的 FID 可能差 2 到 5,对于本来差距只有 1 到 2 的两个方法来说,这个误差足以颠倒结论。所以我现在的做法是:所有对比都在同一套实现下跑,在论文里写清楚用的是哪个库、哪个版本。

4.4 FID 的常见误用与替代方案

FID 最常见的误用有三个。第一是样本量太小。我见过用 2000 张图算 FID 然后声称提升 0.3 的,这在统计上毫无意义。第二是预处理不一致,比如真实集用 bicubic 而生成集用 nearest,这种对比是无效的。第三是把 FID 当成唯一指标,忽略了它本身也是基于 Inception 特征的,对纹理和细节的感知有限。

替代或补充方案里,我觉得值得关注的是 KID(Kernel Inception Distance),它用最大均值差异代替高斯假设,不做分布假设,在小样本下更稳健,而且是无偏估计,不需要靠样本量来压方差,代价是计算慢一些。如果你手上的评测集规模上不去,KID 是比 FID 更诚实的选择。

5. Precision 与 Recall:把"质量"和"多样性"拆开看

FID 把质量压缩成一个数,方便是方便,但信息损失也大。同一批生成结果里,"每张都还行但都差不多"和"有几张惊艳但大量崩坏"可能得到相近的 FID。这就是 Precision 和 Recall 的价值所在。

5.1 FID 一个数的失效场景

举个我实际遇到的例子。我们训练了两个生成模型 A 和 B,A 的 FID 是 12.3,B 是 12.5,按常规判断 A 更好。但我们抽查生成样本时发现,A 生成的图几乎清一色是同一类物体、同一个姿态,变化极少;B 虽然偶有崩坏,但覆盖的种类明显更广。这种差异,FID 是表达不出来的,因为它只看整体分布的均值和协方差,模式坍塌(mode collapse)程度只要不极端,对 FID 影响就有限。

Precision 和 Recall 正好补上这块。它们的设计借鉴了分类任务里的精度和召回率:Precision(有时也叫 fidelity)衡量生成的样本有多少落在真实分布内,Recall(有时也叫 diversity)衡量真实的分布有多少被生成覆盖了。一个高 Precision 低 Recall 的模型,就是典型的"质量还行但不够多样"。

5.2 流形估计的实现方式

目前最常用的实现思路是 Kynkäänniemi 那套基于 KNN 的流形估计,大意有三步。第一步,对真实集和生成集分别提取特征(一般还是 Inception 特征)。第二步,对真实集里的每个样本,找到它到第 k 个最近邻的距离,以此作为该点的半径,把半径内区域视为"真实流形"的一部分;生成集做同样的处理。第三步,判断一个生成样本是否落在真实流形内(即它到真实集中某个点的距离小于该真实点的半径),是则计入 Precision;反过来判断真实样本是否被生成流形覆盖,是则计入 Recall。

import torch def manifold_precision_recall(feat_real, feat_gen, k=3): # feat_real, feat_gen: [N, D],建议先做 L2 归一化 def knn_radius(feat): d = torch.cdist(feat, feat) d.fill_diagonal_(float('inf')) radius, _ = d.topk(k, largest=False) return radius[:, -1] # 第 k 近邻距离 r_real = knn_radius(feat_real) r_gen = knn_radius(feat_gen) d_cross = torch.cdist(feat_gen, feat_real) precision = (d_cross <= r_real.unsqueeze(0)).any(dim=1).float().mean() d_cross2 = torch.cdist(feat_real, feat_gen) recall = (d_cross2 <= r_gen.unsqueeze(0)).any(dim=1).float().mean() return precision.item(), recall.item()

这段代码是个简化版,实际用的时候要注意几点:特征维度一般不用 2048 那么高,降到 64 或 128 维再做 KNN 效果更好,也有利于计算;k 的取值会明显影响结果,我一般取 k=3 到 k=5,并在论文里注明;样本量大时cdist会吃掉大量显存,需要分块计算。这些细节看起来琐碎,但换一组参数,Precision 可能就有 0.05 的差别,所以务必固定下来并写清楚。

5.3 怎么读这四个象限

把 Precision 和 Recall 画在一起看,判断逻辑会清晰很多。大致可以分成四档:

  • 双高:生成图既真实又多样,这是最理想的情况。
  • 高 Precision 低 Recall:图像质量好,但模式覆盖不足,典型的过拟合到简单模式。
  • 低 Precision 高 Recall:覆盖了真实分布的大范围,但混入了不少不像真的样本。
  • 双低:模型没训好,或者评测流程有问题,先回头查代码。

我在实际调参时常用这两个指标定位问题。如果发现 Recall 一直上不去,通常是训练时的多样性约束不够,或者采样温度设得太低;如果 Precision 偏低,往往是生成细节不够锐利,或者噪声水平估计出错。这个诊断过程比盯着 FID 数字猜问题高效得多。需要提醒的是,Precision/Recall 的绝对值受 KNN 参数和特征提取方式影响很大,通常跨论文不可比,只在同一套实现内部做相对比较才有意义。

6. 搭一套可复现的评测流水线

指标算得对不对,一半取决于实现,一半取决于流程。我自己踩过的坑里,有相当一部分不是算法问题,而是数据对齐、预处理、文件名错位这些"工程事故"。所以我特别想把这一套流程固化下来。

6.1 数据准备与对齐的规范

配对的评测任务里,最要命的问题是图对错位。我在一个超分项目里曾经因为文件名排序不一致,导致 1000 张图里有 30 多对出现了错配,算出来的 PSNR 比实际低了近 2dB,排查了一整天才发现问题。从那之后我固定了几条规矩:

  • 生成图和真值图使用完全相同的文件名,包括扩展名之外的每一个字符。
  • 评测前先做一次交叉检查,断言两个目录下的文件名集合完全一致:
import os def assert_aligned(pred_dir, gt_dir): exts = ('.png', '.jpg', '.jpeg', '.bmp') pred = sorted(f for f in os.listdir(pred_dir) if f.lower().endswith(exts)) gt = sorted(f for f in os.listdir(gt_dir) if f.lower().endswith(exts)) assert len(pred) > 0, "生成目录为空" assert pred == gt, ( f"文件名不一致: 仅生成 {set(pred)-set(gt)}, " f"仅真值 {set(gt)-set(pred)}" ) print(f"对齐检查通过,共 {len(pred)} 对")
  • 统一存储格式,避免同一个数据集里混用有损和无损压缩。JPEG 的压缩伪影会直接污染 PSNR 和 SSIM,我一般全部转成 PNG 或者高质量的 WebP。

对于没有配对的生成任务,规范重点转向样本量和抽样方式。真实集和生成集都要有足够规模,而且要避免"挑好看的生成图去评测"这种自欺欺人的行为。我习惯在评测前固定一个随机种子,从全部生成结果里无放回抽样,这样结果可复现,也经得起质疑。

6.2 一套可复现的评测脚本结构

我的脚本一般拆成三层:数据层负责读图、转格式、转张量;指标层每个指标一个纯函数,输入统一是 [0,1] 的 float32 张量,输出标量或逐图数组;汇总层负责汇总、导出 CSV 和打印报告。这种分层的好处是指标可以随意组合,新增一个指标不用改数据层。

import numpy as np import torch from skimage.metrics import peak_signal_noise_ratio, structural_similarity class Metrics: def __init__(self, lpips_fn=None): self.lpips_fn = lpips_fn self.reset() def reset(self): self.records = [] def add(self, name, pred, gt): # pred / gt: [H, W, 3] float32 [0, 1] assert pred.shape == gt.shape, f"形状不一致: {pred.shape} vs {gt.shape}" assert pred.max() <= 1.0 + 1e-6 and pred.min() >= -1e-6, "取值范围错误" p255, g255 = pred * 255.0, gt * 255.0 psnr = peak_signal_noise_ratio(g255, p255, data_range=255.0) ssim = structural_similarity( g255, p255, channel_axis=2, data_range=255.0, gaussian_weights=True, sigma=1.5 ) self.records.append({'name': name, 'psnr': psnr, 'ssim': ssim}) def summary(self): keys = ['psnr', 'ssim'] out = {} for k in keys: vals = np.array([r[k] for r in self.records], dtype=np.float64) out[k] = { 'mean': vals.mean(), 'median': np.median(vals), 'std': vals.std(), 'min': vals.min(), 'max': vals.max(), } return out

我特意在add里加了断言,两次都救过我:一次是有人把已经归一化的图又乘了一次 255,一次是把 BGR 当 RGB 传进来。断言的成本几乎为零,收益却很大。

关于报告,我的习惯是同时给均值和分布信息。只报平均值会让异常样本消失,所以我会额外输出中位数、标准差以及 5% 分位数。如果一个方法的均值不错但 5% 分位数很差,说明它存在明显的失败样本,这在真实应用中往往是致命的。

6.3 结果呈现与对比表的写法

最后一步是把数字变成结论。我比较喜欢用这种表格:

方法PSNR↑SSIM↑LPIPS↓FID↓Precision↑Recall↑
基线 A28.420.8210.24315.70.810.42
基线 B28.670.8350.22114.90.830.45
本文方法28.590.8290.18612.30.850.51

箭头方向一定要标,PSNR 和 SSIM 是越大越好,LPIPS 和 FID 是越小越好,标错方向会被认为不专业。另外数值一般保留两到三位小数,PSNR 两位就够,再多的小数位是噪声不是精度。如果不同方法的差距小于标准差,那就老实说"差异在噪声范围内",不要硬凑结论。

7. 常见问题排查与踩坑实录

这一节是我这些年积累的问题清单,基本覆盖了 90% 的"指标不对"情况。遇到异常时我会对照这张表逐条排查,通常十分钟内能定位到原因。

7.1 数值异常速查表

现象可能原因排查方法处理方式
PSNR 超过 50dB 且肉眼差异明显uint8 溢出或 data_range 传错打印数据实际范围全程用 float64 计算
PSNR 低于 15dB图对错位或归一化范围错可视化叠加对比图做文件名对齐断言
SSIM 全部接近 1.0图像接近纯色或 channel_axis 写错看数据方差检查通道参数
LPIPS 整体偏高忘做 [-1,1] 归一化检查输入最小最大值补上* 2 - 1
FID 为负或极小样本量太少或特征计算错误打印特征维度与均值增加到至少 1 万张
FID 大幅波动每次抽样不同固定随机种子重跑固定种子并交叉验证
Precision/Recall 都是 1.0KNN 的 k 过大或特征塌缩打印特征方差降维并调小 k
指标间结论矛盾平滑退化或模式坍塌抽查样本做人工对比组合解读并写分析

7.2 几个我踩过的坑

第一个坑是版本漂移。有一次我隔了三个月重跑同一个脚本,LPIPS 数值整体高了 0.02,查了半天发现是依赖库升级后默认 backbone 的加载方式变了。从那以后,我把所有评测依赖的版本号写进requirements.txt,并且在输出报告里打印版本信息。

第二个坑是Inception 特征的输入尺度。FID 的实现里,Inception 网络对输入是有明确要求的,有些库内部会帮你做缩放和归一化,有些不会。混用两种实现导致特征分布不一致,FID 数值直接差 3 以上。我的做法是统一到一套实现,并且在对比实验里绝不换库。

第三个坑是分辨率对指标的影响。同一个模型,在 128×128 上评测和在 256×256 上评测,PSNR 和 LPIPS 都可能出现明显差异,因为上采样会引入额外的平滑。所以跨分辨率的对比基本没有意义,要对比就得在同一分辨率下评测。我在论文里会专门写一句"所有评测均在 X 分辨率下完成",避免歧义。

第四个坑是只看平均值。早期我做模型选择时只看平均 LPIPS,结果选出的模型在部分样本上崩得很厉害。后来改成同时看中位数和 95% 分位数,选出来的模型在实际使用中体验好很多。这个教训挺值钱的:指标是给人做决策用的,不是用来贴标签的,多看几个分位数,你才能知道模型到底在什么情况下会失败。

如果你现在正准备给自己的一套结果做评测,我的建议是先把 PSNR、SSIM、LPIPS 这条全参考链路跑通,确认数据对齐和预处理没问题,再去碰 FID 和 Precision/Recall 这类分布级指标。因为全参考指标的错位问题肉眼就能看出来,是最容易验证的锚点;一旦这套流程可信了,往上叠加分布级指标就只是工程量的问题了。

返回列表