SSIM 这个指标第一次把我惹毛,是在一次图像压缩的横向比对里。当时一组图,PSNR 高出对方 1.8 dB,但负责看图的人一致觉得"我们的更糊、边缘更软"。反过来,另一组图 PSNR 落后,观感却明显更好。翻来覆去查了两天,才发现问题的根子不在编码器,而在我拿 MSE 当唯一裁判——像素级误差衡量的是"每个点差多少",而人眼判断的是"结构和结构像不像"。SSIM(structural similarity,结构相似性)就是冲着这个错位来的:它不看逐像素差值的绝对值,而是把图像拆成亮度、对比度、结构三块,分别比一遍,再乘起来。这篇东西我打算把 SSIM 从动机、公式、代码、参数到踩坑完整过一遍,适合两类人看:一类是被 PSNR 坑过、想搞清楚 SSIM 到底在算什么的人;另一类是已经会用现成库、但分数总跟别人对不上、想自己动手实现一遍的人。看完你至少能做到三件事:手写一个和主流库对得上数的 SSIM、知道窗口和常数该怎么选、以及在别人拿 SSIM 说事的时候快速判断这个数可不可信。
1. 为什么会有 SSIM:PSNR 到底在哪一步失手
1.1 均方误差给出的分数,和眼睛看到的常常不是一回事
先把 MSE 和 PSNR 摆出来。MSE 就是两幅图逐像素差值的平方和取平均,PSNR 是它的对数变换,公式是 (10\log_{10}(L^2/\text{MSE})),L 是动态范围,8 位图取 255。这套东西的优点是极其便宜、可微、物理含义清楚,缺点是它默认"所有像素、所有方向的误差同等重要"。
问题恰恰出在这个"同等重要"上。人眼对亮度绝对偏移不敏感——整幅图统一加 20 个灰度级,你未必看得出来;但对结构错位极其敏感——所有像素只朝右平移一个像素,MSE 可能大得吓人,可视觉上你几乎看不出差别。反过来,一块平滑天空区域产生 ±8 的随机噪声,MSE 贡献很大,但人眼基本无感;而一条强边缘上糊了 2 个像素,MSE 贡献很小,人眼却一眼看穿"这张图废了"。
提示:MSE 和 PSNR 是"工程可用"的指标,不是"感知对齐"的指标。它们在编码器内部做率失真优化时非常好用,但拿它们做最终画质结论,尤其是拿来做超分、去噪、生成的评价,误判率相当高。
1.2 SSIM 的核心假设:人眼专门盯着结构
SSIM 的出发点很朴素:自然图像有极强的结构冗余,相邻像素高度相关,而人眼的视觉系统正是被进化出来提取这种结构信息的。所以评价两幅图像像不像,应该比的是"结构",而不是"点"。
那结构怎么量化?把局部图像块看成一组随机变量,用统计量描述它。均值刻画整体明暗,标准差刻画反差强弱,协方差刻画两块图的变化是否同步——如果 A 块变亮的地方 B 块也变亮、A 变暗 B 也变暗,协方差就大,说明结构方向一致;如果 A 变亮 B 反而变暗,协方差是负的,说明结构反了。
于是 SSIM 把相似性拆成三个可比较的量:亮度比较 (l)、对比度比较 (c)、结构比较 (s),三者相乘得到局部 SSIM。这个分解不是数学游戏,它带来一个非常实用的性质——SSIM 对亮度的整体平移和对比度的整体缩放相对宽容,对结构的错位、模糊、块效应非常严厉,这恰好和人眼的敏感度分布对上了。
1.3 SSIM 的能力边界,别把它当万能尺
用久了会发现 SSIM 也有明显的脾气。第一,它对位移极其敏感。两幅图只要有一两个像素的错位,局部窗口内的协方差立刻塌下来,分数暴跌,哪怕视觉上几乎一致。所以拿 SSIM 做跨数据集比对之前,配准是必须的,不配准的 SSIM 数字没有比较价值。
第二,它对模糊和块效应敏感,对某些类型的噪声反而不如 PSNR 敏感。加性高斯噪声在平滑区域会被局部均值吸收一部分,SSIM 掉得比 PSNR 慢。第三,它不具备距离的数学性质——不满足三角不等式,所以别拿 SSIM 去做聚类或者检索空间的度量,它只能表示"这两张图像不像",不能表示"这两张图相距多远"。
注意:SSIM 的值域理论上是 [-1, 1],实际自然图像对比中绝大多数落在 [0, 1]。看到负值不要慌,那说明两个局部块呈反相关,通常意味着严重失真或者两幅图根本不是同一内容。
2. 拆开公式:三个分量到底在比什么
2.1 亮度项:先看两幅图的整体明暗
设 (x)、(y) 是两幅图在同一个局部窗口内取出的像素集合,(\mu_x)、(\mu_y) 是它们的均值,(\sigma_x^2)、(\sigma_y^2) 是方差,(\sigma_{xy}) 是协方差。亮度比较定义为:
[ l(x,y)=\frac{2\mu_x\mu_y+C_1}{\mu_x^2+\mu_y^2+C_1} ]
这个式子的形状值得琢磨。分子是 (2\mu_x\mu_y),分母是 (\mu_x^2+\mu_y^2)。由基本不等式 (2\mu_x\mu_y \le \mu_x^2+\mu_y^2),所以 (l \le 1),当且仅当 (\mu_x=\mu_y) 时取等号取到 1。也就是说亮度项最大是 1、只在两者均值完全相等时达到,这符合"相似度"的直觉。
那 (C_1) 是干什么的?如果两个窗口都极度平坦、均值又都接近 0(比如纯黑区域),分子分母同时趋近 0,比值会变成 0/0,数值上完全不可控。加上 (C_1) 之后,分母永远不会是 0,比值被稳稳地拉向 1——含义是"两块都很暗很平的区域,我们判定它们相似",这个判断是对的。所以 (C_1) 本质上是数值稳定项,同时也起到了在小信号区域抑制噪声放大的作用。
2.2 对比度项与结构项:方差和协方差的分工
对比度比较定义为:
[ c(x,y)=\frac{2\sigma_x\sigma_y+C_2}{\sigma_x^2+\sigma_y^2+C_2} ]
形式和亮度项完全同构,只是把均值换成了标准差。含义是"两块区域的起伏幅度是否相当"。一块平坦、一块有纹理,即使均值一样,(c) 也会明显小于 1。
结构比较定义为:
[ s(x,y)=\frac{\sigma_{xy}+C_2/2}{\sigma_x\sigma_y+C_2/2} ]
注意这里的分子分母都多了个 (C_2/2),不是随手写的。它的作用是让 (c) 和 (s) 相乘之后,恰好化简成一个干净的形式:
[ c\cdot s=\frac{2\sigma_{xy}+C_2}{\sigma_x^2+\sigma_y^2+C_2} ]
这个化简很关键,它意味着实现时完全不必分别算 (c) 和 (s) 再乘,直接一步到位。同时它也解释了为什么结构项里要放 (C_2/2)——纯代数配平。(\sigma_{xy}) 是协方差,可以取负值,所以 (s) 的取值范围是 ([-1,1]) 附近,这是 SSIM 能出现负值的直接来源。
2.3 常数 C1、C2 的取值,以及它到底在防什么
两个常数的标准取法是:
[ C_1=(K_1 L)^2,\quad C_2=(K_2 L)^2 ]
其中 (L) 是像素动态范围(8 位图取 255,归一化到 [0,1] 的浮点图取 1.0),(K_1=0.01),(K_2=0.03)。这两个系数来自原始论文的实验标定,意思是"我们允许 1% 和 3% 量级的扰动不参与惩罚"。
算一下 8 位图的具体数值:(C_1=(0.01\times255)^2=6.5025),(C_2=(0.03\times255)^2=58.5225)。归一化图则是 (C_1=10^{-4}),(C_2=9\times10^{-4})。
| 参数 | 8 位图 (L=255) | 归一化浮点图 (L=1.0) | 作用 |
|---|---|---|---|
| (K_1) | 0.01 | 0.01 | 亮度项稳定系数 |
| (K_2) | 0.03 | 0.03 | 对比度/结构项稳定系数 |
| (C_1) | 6.5025 | 0.0001 | 亮度分母下限 |
| (C_2) | 58.5225 | 0.0009 | 对比度与结构分母下限 |
这里有一个高频出错的点:(L) 必须和你传入的数据尺度一致。如果你的图已经除以 255 归一化到 [0,1],却还按 255 去算 (C_1)、(C_2),那么常数会大出 65025 倍,整个 SSIM 会被强行拉到 0.99 以上,几乎失去分辨力。反过来,8 位数据配归一化的常数,方差项会主导分母,分数会被压得很低。这个错误我在不止一个团队的评估脚本里见过,而且极难通过肉眼看出来,因为分数"看起来也挺合理的"。
2.4 从局部到全局:SSIM map 才是完整信息
把上面三项乘起来,得到局部 SSIM:
[ \text{SSIM}(x,y)=\frac{(2\mu_x\mu_y+C_1)(2\sigma_{xy}+C_2)}{(\mu_x^2+\mu_y^2+C_1)(\sigma_x^2+\sigma_y^2+C_2)} ]
注意这个值是对某一个窗口算出来的。整幅图的 SSIM 是把窗口滑遍全图,得到一张和各处局部得分对应的图,叫SSIM map,然后对这张 map 取算术平均。所以严格讲,我们平时报的那个标量是 MSSIM(mean SSIM)。
提示:SSIM map 本身信息量很大,非常值得可视化。它能直接告诉你是哪块区域在拉低分数——是文字边缘、是渐变天空、还是人脸的高光。只看平均数等于把最有用的定位信息扔了。
这也带来一个容易被忽略的后果:SSIM 强依赖图像尺寸。同一套编码参数,在 512×512 上算出来的 SSIM 通常高于 256×256,因为窗口数量变多、边界效应占比下降、而且下采样带来的高频损失在小图上更集中。所以报 SSIM 必须带上分辨率,跨尺寸比较基本没有意义。
3. 动手实现:把公式变成能跑的代码
3.1 用卷积一次性算出四个统计量
朴素写法是对每个窗口做一次切片、求均值方差协方差,图大一点就慢到没法用。工业写法是把"求局部均值"看成一次滤波:均值就是窗口内像素的加权和,权重是归一化的窗口核。于是:
[ \mu_x = w * x,\quad \sigma_x^2 = w * x^2 - \mu_x^2,\quad \sigma_{xy} = w*(xy)-\mu_x\mu_y ]
这里 (*) 表示滤波(卷积或相关,核对称时二者等价),(w) 是归一化到和为 1 的窗口核。这个变换把"每个窗口单独统计"变成了"四次滤波",复杂度从 (O(N\cdot W^2)) 降到 (O(N))(用可分离核的话还能再降),而且能直接吃到底层库的优化。
这个推导值得记住,因为它顺手解释了另外两件事。一是方差用 (E[x^2]-E[x]^2) 这个形式,在浮点下可能出现微小的负值(理论上方差非负,但数值上 (E[x^2]) 和 (\mu_x^2) 可能因为舍入而颠倒),所以实现里必须做max(·, 0)截断。二是协方差同样可能因为数值误差略微越界,理论上由柯西-施瓦茨不等式 (|\sigma_{xy}| \le \sigma_x\sigma_y),实现里最好也夹一下。
3.2 高斯窗还是均匀窗:窗口大小和 sigma 怎么定
原始论文用的是 8×8 的均匀窗(每个像素权重相同),后来的实践基本统一到了11×11 高斯窗、(\sigma=1.5)。这个搭配不是随便试出来的。
窗口太小,比如 3×3 或 5×5,局部统计量估计不稳,分数对噪声极度敏感,而且失去了"局部结构"的意义——3 个像素根本构不成结构。窗口太大,比如 21×21,会把不相干的区域混进同一个窗口,分数变得迟钝,同时空间分辨率大大下降。
11 这个数字和高斯的 3σ 原则有关:(\sigma=1.5) 时 (3\sigma=4.5),取半径 5 得窗口边长 11,窗口边缘的权重已经衰减到中心权重的 (e^{-25/4.5}\approx 0.004),基本可以忽略。再大就是在给几乎为零的权重付计算代价。
至于为什么用高斯而不是均匀,理由有两个。一是各向同性加权更符合视觉的中央-周边感受野特性,窗口中心的像素应该比边缘更有话语权,均匀窗会把窗口内的结构差异平均掉,导致分数偏乐观。二是可分离性,二维高斯可以拆成两个一维卷积,计算量从 (11\times11=121) 次乘加降到 (2\times11=22) 次,大图上差距相当明显。
| 窗口配置 | 典型用途 | 特点 |
|---|---|---|
| 7×7 均匀 | 快速筛查、小图 | 快,但对噪声敏感,分数偏高 |
| 8×8 均匀 | 原始论文、老实现 | 与现代库不对齐,慎用 |
| 11×11 高斯 σ=1.5 | 主流默认 | 与大多数论文结果可比 |
| 11×11 均匀 | 部分编码器内部 | 分数略高于高斯版 |
| 更大窗口(21+) | 极平滑图像、遥感 | 空间分辨率低,慎用 |
注意:如果你要和别人的数字对齐,窗口类型、窗口大小、sigma、L、是否做边界裁剪,这五项必须全部一致,少一项就可能差出小数点后第二位。这也是"同一个模型两篇论文报的 SSIM 对不上"的最常见原因。
3.3 一份能跑通的 Numpy 实现
下面这份代码我用了很久,和主流库的差异在 1e-3 量级以内:
import numpy as np from scipy.ndimage import gaussian_filter def ssim_map(x, y, L=255.0, win=11, sigma=1.5, K1=0.01, K2=0.03): # 必须先转浮点,uint8 自乘会溢出回绕 x = x.astype(np.float64) y = y.astype(np.float64) C1 = (K1 * L) ** 2 C2 = (K2 * L) ** 2 # truncate 决定滤波核半径: radius = int(truncate*sigma + 0.5) # win=11, sigma=1.5 时 truncate 取 3.33 附近可得半径 5 trunc = (win - 1) / 2.0 / sigma kw = dict(sigma=sigma, truncate=trunc, mode='reflect') mu_x = gaussian_filter(x, **kw) mu_y = gaussian_filter(y, **kw) mu_x2 = mu_x * mu_x mu_y2 = mu_y * mu_y mu_xy = mu_x * mu_y s_x2 = gaussian_filter(x * x, **kw) - mu_x2 s_y2 = gaussian_filter(y * y, **kw) - mu_y2 s_xy = gaussian_filter(x * y, **kw) - mu_xy # 数值保护 s_x2 = np.maximum(s_x2, 0.0) s_y2 = np.maximum(s_y2, 0.0) bound = np.sqrt(s_x2 * s_y2) s_xy = np.clip(s_xy, -bound, bound) num = (2 * mu_xy + C1) * (2 * s_xy + C2) den = (mu_x2 + mu_y2 + C1) * (s_x2 + s_y2 + C2) return num / den def ssim(x, y, **kwargs): return float(ssim_map(x, y, **kwargs).mean())几个容易被忽略的细节值得单独说。mode='reflect'是边界处理方式,反射填充不会像零填充那样把边界附近的方差人为抬高或压低;truncate直接决定核的实际尺寸,scipy 默认 4.0 会得到 13×13 的核,和你要的 11×11 不一样,必须显式覆盖;astype(np.float64)一定要在乘法之前,8 位图里 200×200 = 40000,早就溢出 uint8 的上限 255 了,结果会回绕成一个毫无意义的数,而且不会报错,只会让你的 SSIM 悄悄错掉。
3.4 PyTorch 版本:评估阶段的批量化写法
在评估流水线里我更倾向用 PyTorch 版本,原因是它能直接吃 GPU、能一次算一整个 batch,而且和训练脚本共用一套数据加载,不容易出现"训练用归一化图、评估用 8 位图"这种尺度错配。
import torch import torch.nn.functional as F def _gauss_kernel(win=11, sigma=1.5, dtype=torch.float32, device="cpu"): c = torch.arange(win, dtype=dtype, device=device) - (win - 1) / 2.0 g = torch.exp(-(c ** 2) / (2 * sigma ** 2)) g = g / g.sum() return (g[:, None] * g[None, :])[None, None] # (1,1,win,win) def ssim_torch(x, y, L=1.0, win=11, sigma=1.5, K1=0.01, K2=0.03): # x, y: (N, C, H, W),取值范围需与 L 一致 C1, C2 = (K1 * L) ** 2, (K2 * L) ** 2 C = x.shape[1] k = _gauss_kernel(win, sigma, x.dtype, x.device).expand(C, 1, win, win) pad = win // 2 def conv(t): return F.conv2d(t, k, padding=pad, groups=C) mx, my = conv(x), conv(y) sxx = conv(x * x) - mx * mx syy = conv(y * y) - my * my sxy = conv(x * y) - mx * my sxx = sxx.clamp_min(0) syy = syy.clamp_min(0) bound = (sxx * syy).sqrt() sxy = torch.min(torch.max(sxy, -bound), bound) num = (2 * mx * my + C1) * (2 * sxy + C2) den = (mx ** 2 + my ** 2 + C1) * (sxx + syy + C2) return (num / den).mean()这里用零填充(padding=pad且不设padding_mode)是为了走最快的卷积内核。代价是边界一圈的统计量会略偏,通常影响在小数点后第三位。如果对精度有要求,把F.conv2d换成padding_mode='reflect'的F.pad加无填充卷积即可,速度会掉一些。
提示:整个函数要放在
torch.no_grad()里跑,否则会建一张巨大的计算图,显存直接炸。评估代码里忘了这行,跑小图没事,一上 4K 就 OOM,这个坑我踩过不止一次。
3.5 彩色图像到底该怎么算
这是实践中最混乱的一块,三种做法都有人用,结论还不一样。
第一种是转灰度后算单通道 SSIM。优点是快、和大多数论文口径一致,缺点是完全丢掉了色度失真——色偏、色度压缩块效应在灰度图里看不见。如果你的评估对象可能有颜色问题,这个方法会漏检。
第二种是转 YCbCr,只在 Y 通道上算。这是视频编码领域的标准做法,理由是亮度通道承载了绝大部分结构信息,而且人眼对色度的空间分辨率本来就低。工程上非常实用。
第三种是每个通道各算一遍再取平均。优点是能反映色度失真,缺点是会把色度噪声的权重放得和亮度一样高,和人眼特性冲突,分数往往偏低且不稳定。
# 推荐做法:先转 Y,再算 SSIM def rgb_to_y(rgb): # rgb 为 float,范围 0-255 return 0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2]另一个相关的问题是色度下采样的影响。4:2:0 格式的色度分辨率只有亮度的四分之一,如果直接把重建的 RGB 拿去逐通道算 SSIM,会把"色度上采样带来的轻微模糊"算进分数里,而这个损失人眼基本感知不到。所以严格来说,做编码质量评估时应该先对齐到 YUV 域再比,而不是在 RGB 域比。
4. 多尺度 SSIM 与几个值得知道的变体
4.1 MS-SSIM:为什么单一尺度不够用
单尺度 SSIM 有个隐含假设:观察距离固定。但人看图的距离是会变的,退后几步看整体轮廓时,细节糊一点无所谓;凑近看纹理时,整体轮廓差一点也无所谓。单尺度 SSIM 用一个固定窗口,等于锁定了一个观察距离,很难同时兼顾。
MS-SSIM 的做法很直接:把图像做 (M) 级下采样(通常 (M=5),每级降一半),在每一级上算对比度-结构项 (c\cdot s),最后一级额外算亮度项 (l),然后用一组权重加权相乘:
[ \text{MS-SSIM}=\left[l_M\right]^{\alpha_M}\prod_{j=1}^{M}\left[c_j\right]^{\beta_j}\left[s_j\right]^{\gamma_j} ]
原始论文给出的 5 级权重是 ([0.0448,\ 0.2856,\ 0.3001,\ 0.2363,\ 0.1333]),粗尺度权重最大,符合"整体结构比细节更重要"的直觉。
实现上有两个坑。第一,下采样前必须先低通,直接隔点取样会产生混叠,把高频折叠到低频,分数变得不可信。用 2×2 平均池化是最省事的做法(F.avg_pool2d(kernel_size=2)),效果好且够快。第二,权重是分数次幂,底数一旦为负就会产生 NaN。SSIM map 里出现负值完全正常(反相关区域),但负数的 0.3 次幂在实数域无定义,numpy 会静默返回 NaN,PyTorch 会返回 NaN 而不报错,最后整个平均值变成 NaN,你还得回头一层层查。所以实现里必须对每一项做clamp_min(1e-8)。
def ms_ssim_weights(M=5, dtype=torch.float32, device="cpu"): w = [0.0448, 0.2856, 0.3001, 0.2363, 0.1333] return torch.tensor(w[:M], dtype=dtype, device=device)MS-SSIM 的代价是尺寸要求更严:5 级下采样意味着图像至少要有 (16\times) 个像素的边长余量,一张 64×64 的小图跑 5 级 MS-SSIM,最后一级只剩 4×4,窗口都放不下。所以小图要么减级数,要么老老实实用单尺度。
4.2 其它变体与选型建议
除了 MS-SSIM,工程上还常遇到这几个:
CSS(contrast-structure)就是去掉亮度项、只保留 (c\cdot s)。适合亮度经过归一化或有意调整的场景,比如 HDR 色调映射后的对比,或者不同曝光的图像比对。
4-SSIM把 SSIM 公式拆成 (2\times2) 矩阵的形式,在分母里加入更多交互项,改善了和主观评分的一致性,但要调参、实现复杂,实际用得不多。
FSIM / GMSD / VIF这些属于另一条技术路线,用相位一致性、梯度幅值、信息保真度等特征替代统计量,和人眼主观评分相关性普遍更高,但计算成本也高出一截,而且多数没有 GPU 实现。
LPIPS是基于深度特征的感知度量,在前沿评估里已经基本取代 SSIM 成为主流,但它依赖预训练网络,可解释性差,遇到分布外内容容易失效。
选型我的实际经验是这样:做编码器或压缩器的内部优化和快速回归,用 SSIM 或 MS-SSIM,快且趋势可靠;做最终画质报告的对外结论,SSIM 只作参考,必须配上人工主观评分或者 LPIPS 这类感知指标;做去噪任务的对比,SSIM 要谨慎,因为它在平滑区会高估,最好加一个在边缘区域单独统计的分项。
| 指标 | 计算成本 | 与主观一致性 | 典型用途 |
|---|---|---|---|
| PSNR | 极低 | 一般 | 编码器内部优化 |
| SSIM | 低 | 较好 | 常规画质回归 |
| MS-SSIM | 中 | 好 | 多尺度内容评估 |
| FSIM | 高 | 很好 | 学术对比 |
| LPIPS | 中(需 GPU) | 很好 | 感知质量结论 |
5. 实测中的坑与排查清单
5.1 分数对不上的五种典型原因
跟别人对不上数,按这个顺序排查,能解决九成问题。
第一种,动态范围 L 不一致。这是头号嫌疑。你的图归一化到 [0,1] 却按 255 算常数,或者反过来。判断方法很简单:算一下纯白噪声图的 SSIM,正常情况下应该贴近 0 附近,如果得到 0.95 以上,那基本就是常数尺度错了。
第二种,窗口类型不同。均匀窗的分数系统性高于高斯窗,因为在平滑区域均匀窗的方差估计更小,(c) 和 (s) 更容易接近 1。差多少?实测在压缩质量为中等的图上,差距大概在 0.005 到 0.02 之间,足以让"我们比 SOTA 高 0.01"这种结论反转。
第三种,边界处理不同。有的实现裁掉边界,有的用零填充,有的用反射填充。零填充会显著压低边界窗口的方差,进而让边界区域的 (c) 和 (s) 偏向 1,整体分数虚高。一张 256×256 的图,边界像素占比不小,这个差异不可忽略。
第四种,数据类型溢出。前面提过,uint8 做x*x会回绕。这个错误最阴险,因为它不报错、结果也"像是"一个合法分数。检查方法:把输入强制转成 float64 再算,如果结果明显变化,那就是溢出了。
第五种,配准问题。两幅图哪怕整体错位一个像素,SSIM 也会掉一大截。如果是编码对比,注意检查重建图有没有因为裁剪、色度对齐、padding 之类的原因产生整体偏移。这类问题在 PSNR 上表现为"轻微下降",在 SSIM 上表现为"断崖式下跌",特征很明显。
5.2 参数与现象对照速查
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 分数普遍在 0.98 以上,区分度极低 | (C_1)、(C_2) 尺度错(L 用错) | 核对数据范围与 L 的一致性 |
| 比别人的数系统性高 0.01 左右 | 用了均匀窗 | 换成 11×11 高斯 σ=1.5 |
| 边界区域 SSIM map 异常高 | 零填充 | 改反射填充或裁掉边界 |
| 分数为 NaN | MS-SSIM 对负数开分数次幂 | 每项clamp_min(1e-8) |
| 同一图不同尺寸分数差很多 | SSIM 的尺度依赖性 | 固定分辨率再比较 |
| 大分辨率图上显存爆掉 | 未关闭梯度 | 包torch.no_grad() |
| 分数随机波动大 | 图太小,窗口数不足 | 至少保证边长 > 4×窗口 |
| 主观很差的图分数却不低 | 平滑区域多,SSIM 高估 | 补充边缘区域专项统计 |
5.3 我个人的几条使用心得
把 SSIM map 存下来,别只存均值。我在每一个评估脚本里都会顺手把 map 可视化成热力图,看一眼就知道问题出在哪:如果低分集中在文字边缘,是锐化或去振铃没做好;如果集中在平坦区,是量化步长太大或者噪声残留;如果是零散的点状低分,多半是对齐问题。这个习惯帮我省掉的排查时间,比任何日志都多。
窗口大小要跟着内容走。11×11 是通用默认,但如果是文字图像、屏幕截图这类高频内容,窗口可以缩到 7×7,让局部性更强;如果是遥感、医学影像这种大面积平滑加稀疏结构的图,11×11 甚至更大都合理。改窗口之后记得重新标定基线,历史数据不能直接比。
别在极小图上用 SSIM。边长小于 40 像素的图,窗口数量太少,统计量本身就没意义,分数波动能到 ±0.05。这种情况下宁可退回 PSNR,或者先把图放大再算(但放大方式要一致)。
跨版本的库要锁版本。主流图像库在版本迭代中改过默认行为,比如通道参数从multichannel改名成channel_axis,默认的滤波方式、边界裁剪策略也调整过。同一个脚本换个环境跑出不同的数,八成是这个原因。评估脚本里我一般会直接把窗口参数、gaussian_weights、data_range全部显式写出来,绝不依赖默认值。
最后一条,也是最容易被忘的:SSIM 是相对指标,不是绝对指标。0.92 这个数本身没有意义,只有"同一套流程、同一批数据、同一个参数配置下,A 比 B 高 0.01"才有意义。任何人拿一个孤立的 SSIM 数字跟你论证画质好坏,先问他参数配置,问不出来这个结论就先放着。