做超分、去噪、修复、生成这一圈活儿的人几乎都会在某个时刻被同一个问题卡住这张图到底算不算好你说好看评审说不够锐你说指标高审稿人反问 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 的均匀偏移所有像素加 1PSNR 能到 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) # 结构实践中通常取 αβγ1C3C2/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_range255.0): # 统一转成 float避免 uint8 减法溢出 pred pred.astype(np.float64) gt gt.astype(np.float64) psnr peak_signal_noise_ratio(gt, pred, data_rangedata_range) # channel_axis 在新版本里替代了 multichannelTrue ssim structural_similarity( gt, pred, channel_axis2, # 彩色图必须指定否则会按单通道算 data_rangedata_range, gaussian_weightsTrue, sigma1.5, use_sample_covarianceFalse ) return psnr, ssim第一个坑是uint8 溢出。我发现过好几次这种情况直接把 uint8 图丢进去相减结果 MSE 算出负数或者异常小的值PSNR 直接飙到 80dB。后来养成习惯所有指标计算内部一律先转 float64边界上再转回去。第二个坑是data_range 传错。如果你把图像归一化到 [0,1] 却仍然让函数按 255 处理PSNR 会凭空少掉约 48dB数值会非常难看。反过来如果图是 [0,255] 却传了 data_range1.0PSNR 又会虚高。我现在写评测脚本一定会在开头断言一下数据的取值范围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把像不像的判断交给深度特征LPIPSLearned Perceptual Image Patch Similarity是我个人认为近几年最实用的一个感知指标。它做的事情很朴素既然人眼看图靠的是大脑的深层特征那我们就用训练好的深度网络提取特征在特征空间里比距离。这个思路一出来指标高但人眼觉得差的矛盾就缓解了不少。3.1 核心思想特征空间的加权 L2LPIPS 的计算流程可以拆成四步。第一步把两张图送进一个预训练网络通常是 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 名字和 versionimport lpips import torch # 明确指定 backbone 和 version别用默认值糊过去 lpips_fn lpips.LPIPS(netalex, version0.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, netalex, bs16): fn lpips.LPIPS(netnet, version0.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 / count3.4 LPIPS 的局限LPIPS 也不是万能的。它依赖 ImageNet 预训练特征所以对训练分布之外的图像医学影像、遥感、显微镜图泛化性会打折扣。我试过用它评估病理切片发现数值区分度很差同类样本之间的 LPIPS 波动比不同方法之间的差距还大这种情况下就不能硬用了。另一个问题是它对全局色调变化的敏感度偏高。一张图整体调暖一点感知上可能更讨喜但 LPIPS 会明显变大。所以如果你的任务本身包含色彩风格调整报告 LPIPS 时要额外说明最好再配一张主观对比图。我的惯例是 LPIPS 加 SSIM 组合报告一个看感知一个看结构两个一起看才能判断模型是真进步还是只是换了个失真类型。4. IS 与 FID生成模型的分布级体检聊完逐对比较就要进入分布比较的领域了。IS 和 FID 是生成模型圈子里出现频率最高的两个数但它们也是最容易被误用、被过度解读的两个数。我把它们的原理、实操细节和那些没人明说的坑一起摆出来。4.1 IS 的设计初衷与它的历史包袱ISInception 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 的数学本质与直觉FIDFré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 特征的对纹理和细节的感知有限。替代或补充方案里我觉得值得关注的是 KIDKernel Inception Distance它用最大均值差异代替高斯假设不做分布假设在小样本下更稳健而且是无偏估计不需要靠样本量来压方差代价是计算慢一些。如果你手上的评测集规模上不去KID 是比 FID 更诚实的选择。5. Precision 与 Recall把质量和多样性拆开看FID 把质量压缩成一个数方便是方便但信息损失也大。同一批生成结果里每张都还行但都差不多和有几张惊艳但大量崩坏可能得到相近的 FID。这就是 Precision 和 Recall 的价值所在。5.1 FID 一个数的失效场景举个我实际遇到的例子。我们训练了两个生成模型 A 和 BA 的 FID 是 12.3B 是 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, k3): # 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, largestFalse) 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(dim1).float().mean() d_cross2 torch.cdist(feat_real, feat_gen) recall (d_cross2 r_gen.unsqueeze(0)).any(dim1).float().mean() return precision.item(), recall.item()这段代码是个简化版实际用的时候要注意几点特征维度一般不用 2048 那么高降到 64 或 128 维再做 KNN 效果更好也有利于计算k 的取值会明显影响结果我一般取 k3 到 k5并在论文里注明样本量大时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_fnNone): 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_range255.0) ssim structural_similarity( g255, p255, channel_axis2, data_range255.0, gaussian_weightsTrue, sigma1.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], dtypenp.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 - 1FID 为负或极小样本量太少或特征计算错误打印特征维度与均值增加到至少 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 这类分布级指标。因为全参考指标的错位问题肉眼就能看出来是最容易验证的锚点一旦这套流程可信了往上叠加分布级指标就只是工程量的问题了。