HZNU OS Lab

第二章 · 图像质量评估

PSNR?SSIM?用数学证明你的图片变好看了!

恭喜来到 ch2 的实验!

做完 ch1,大家有没有好奇,我们 ch1 的 main.c 到底在做什么?

#include "common/pipeline.h"

static int execute_jobs(const ImageJob jobs[], const FilterConfig* config, ImageResult results[],
                        int job_count) {
  int i;
  for (i = 0; i < job_count; ++i) {
    pipeline_process_one_image(&jobs[i], config, 1, &results[i]);
  }
  return 0;
}

int main(void) {
  return pipeline_run_image_batch(execute_jobs);
}

我们可以看到,图片处理的核心流程,其实都被封装进了一个叫 pipeline_process_one_image() 的函数里。把它当成一个 job 传递给批处理框架。

那这个函数究竟是怎么“处理一张图片”的呢?你可以尝试在 IDE 中按下 Ctrl(或 Cmd),然后点击这个函数名,IDE 会带你跳转到 pipeline.c 文件中的定义位置。

整体流程是什么?

读代码的诀窍

很多同学刚看 C 语言的长函数会恐慌。其实读代码有个诀窍:先看宏观的骨架,别一开始就死抠细节。 顺着函数名猜意思,大概了解它在干嘛就行了。放到生活中的任何事也是如此,不要被表面的复杂迷惑,相信大家都有能力一步步梳理清楚的 🥳

我们快速过一下这个函数的初始流程:

if (job == NULL || config == NULL || out_result == NULL) {
  return -1; // 检查参数合法性
}

pipeline_reset_result(out_result); // 清空上一次的残留数据

// 加载我们要处理的输入图 (input) 和作为标准答案的原图 (gt,即 Ground Truth)
if (image_load_png(job->input_path, &input) != 0 || image_load_png(job->gt_path, &gt) != 0) {
  out_result->status_code = PIPELINE_STATUS_LOAD;
  image_free(&input);
  image_free(&gt);
  return -1;
}

// 检查两张图的宽、高、通道数是不是完全一样
if (input.width != gt.width || input.height != gt.height || input.channels != gt.channels) {
  // ...报错清理并退出...
}

// 照着 input 的尺寸,在内存里挖一块同样大小的空白区域,用来放处理后的结果 (output)
if (image_allocate_like(&input, &output) != 0) {
  // ...报错清理并退出...
}

你看,这段代码逻辑其实非常线性:检查参数 → 读入图片 → 检查尺寸 → 分配内存。 如果中间任何一步失败了,就做清理工作(image_free 把之前申请的内存释放掉,防止内存泄漏),然后返回非 0 的错误码。


如何评价指标?

再往下看,你会发现代码里有这样两段长得很像的逻辑:

// 第一次算:处理前
metrics_compute_psnr(&input, &gt, &out_result->psnr_before);
if (compute_ssim) {
  if (metrics_compute_ssim(&input, &gt, &out_result->ssim_before) != 0) { ... }
}

/* ... 中间做了一些神秘的操作 ... */

// 第二次算:处理后
metrics_compute_psnr(&output, &gt, &out_result->psnr_after);
if (compute_ssim) {
  if (metrics_compute_ssim(&output, &gt, &out_result->ssim_after) != 0) { ... }
}

仔细看,二者的区别就是,metrics_compute_psnr 的第三个参数从 before 变成了 after。 为什么算两次?这其实是做实验的基本逻辑:控制变量对比。 我们要评价一个算法好不好,不能光看结果,得看**“进步了多少”**。

  • 第一次计算,我们拿加了噪点的图 (input) 去和完美的原图 (gt) 比,得到一个基础分数 (before)。
  • 中间这步,我们调用了 filter_apply(&input, &output, config),把噪点图进行了“滤波降噪”处理,存进了 output
  • 第二次计算,我们再拿降噪后的图 (output) 去和完美的原图 (gt) 比,得到一个新分数 (after)。

如果 after 的分数比 before 高,说明我们的降噪算法起作用了!

那这个 filter_apply 到底是怎么抹掉噪点的呢?这就引出了我们要讲的核心。


怎么抹除噪点?

想象你拿手机在暗光下拍了一张照片,因为光线太暗,传感器受到了干扰,照片上出现了很多极其突兀的亮点或暗点,这就是噪点 (Noise)

假设有一块 3×33 \times 3 的像素区域,原本应该是一片亮度为 11 左右的平缓夜空,但正中间突然爆出了一个亮度高达 250 的白色噪点:

10  11  12
12 250  13
11  12  10

我们怎么把这个 250 给抹掉?

方案一:求平均数? 如果我们把这 9 个数字加起来除以 9,算出来的平均值大约是 37.8。 我们把中心像素替换成 37,结果噪点不仅没完全消失(37 在 11 的包围下依然偏亮),反而把周围原本暗淡的夜空也给带亮了一片。 这就引出了一个非常重要的统计学常识:平均数极其容易被极端值(离群点)带偏。这就好比我和科比和砍42分,我和马云平均月入一亿一样。

方案二:求中位数!(中值滤波 Median Filter) 为了不受极端值的影响,我们不求平均,而是把这 9 个数字从小到大排个序: 10, 10, 11, 11, 12, 12, 12, 13, 250 最中间的那个数(第 5 个)是 12。 我们把这个中位数 12 赋值给中心像素:

10  11  12
12  12  13
11  12  10

突兀的 250 直接被物理消灭,完全没有影响到整体的均值。这就是中值滤波的威力:它能极其有效地剔除独立存在的极端噪点。

很多指标也是如此,我们不能只看报道中的平均数,更要关注更能反应整体数据的中位数。

双边滤波 (Bilateral Filter)

中值滤波杀噪点很爽,但人们很快发现了一个致命问题。 如果是平缓的区域还好,但如果遇到了图像的边缘呢?比如照片里有一条黑白分明的斑马线。 如果在这条线附近强行取中位数,黑白交界的地方就会被搅浑,整张图片看起来就会变得非常模糊,失去锐度,就像是被人用手硬生生抹平了一样。大家可以尝试在手机相册中拉高照片的 锐度,看看照片有什么样的变化。

我们能不能做到既抹平噪点,又保护物体的边缘不被糊掉?

为了解决这个问题,前人们发明了双边滤波。 既然粗暴地取中位数或平均数不行,那我们就给周围的每个像素打分,按**权重 (Weight)**来求加权平均:

新的像素值=(邻域像素值×对应权重)所有权重\text{新的像素值} = \frac{\sum \left(\text{邻域像素值} \times \text{对应权重}\right)}{\sum \text{所有权重}}

怎么给周围的像素打分?双边滤波极其聪明地考虑了两个“边 (Bilateral)”:

  1. 空间距离权重:这很直观。离中心像素越近的邻居,参考价值越大,权重越高;离得远的权重低。
  2. 色彩差异权重:如果旁边那个像素的值和中心像素差得极其离谱,那说明它们很可能属于两个不同的物体,中间隔着一条“边界”。这时候,即使它离得再近,我们也认为它没有参考价值,权重直接暴跌!

两者结合,它只会在颜色相近的“同一块平缓区域内”做平均降噪,一旦遇到颜色断崖式突变的边缘,它就立刻停止跨界平均。 这样,噪点消失了,而物体清晰的轮廓被完美保留!

(注:在我们的实验中,往往为了简化计算,会把 RGB 三通道彩色图片转换为单通道的灰度图。大家可能会好奇,为什么灰度值就能代表一张图片?其实,所谓的“灰度”,本质上就是像素的“亮度”。你可以想象一下以前的黑白老电视机,虽然没有色彩,但你依然能看清画面里的建筑和人脸,靠的就是不同区域从纯黑到纯白的明暗变化。所以把 RGB 强行融合成一个灰度值,虽然损失了色彩信息,但完美保留了所有的明暗层次和物理轮廓,完全足够我们用来验证算法的有效性了。)


怎么科学地证明图片变好看了?

我们刚才算出来的 psnr_beforepsnr_after 是怎么回事?

MSE(均方误差)

MSE 是衡量"两张图差了多少"最直觉的方式:把每个像素对应位置的差求出来,平方,再取平均

拿两张 1×41 \times 4 的"迷你图片"举例:

原图:    10  20  30  40
处理后:  12  18  31  39
差值:    +2  -2  +1  -1

如果直接求差的平均值,结果是 0——看起来"没有误差",但显然是有的。问题在于正负相互抵消了。所以我们先平方4,4,1,14, 4, 1, 1),再取平均,得到 MSE=2.5MSE = 2.5。平方的好处有两个:消除正负抵消,并且放大大误差的惩罚——差 10 比差 1 严重得多,平方后是 100 vs 1 这很符合直觉吧

公式:MSE=1Ni=1N(xiyi)2MSE = \frac{1}{N}\sum_{i=1}^{N}(x_i - y_i)^2

PSNR(峰值信噪比)

MSE 是"绝对误差",但不同图片的像素范围不一样(8-bit 图最大 255,16-bit 可能到 65535),光看 MSE 的数值没法跨图片比较。PSNR 就是用像素最大值 MAXMAX 做归一化,再取对数换算成分贝(dB),让不同图片之间的分数可以横向对比:

PSNR=10log10(MAX2MSE)\text{PSNR} = 10 \cdot \log_{10}\left(\frac{MAX^2}{MSE}\right)

听起来很完美对不对? 但这有个大问题:人眼看图片,根本不是在逐个核对像素的值! 如果你把一张照片整体变暗了一点点,或者向右平移了 1 个像素。人眼看起来会觉得"这两张图明明差不多啊",但用 PSNR 去算,误差会直接爆炸,分数极低。

PSNRSSIM
衡量方式逐像素计算误差的平方从亮度、对比度、结构三个维度综合评估
符合人眼感知?不符合符合
弱点图片整体平移 1px,人眼看不出区别,但 PSNR 直接暴跌计算量更大
分数含义越高越好(单位 dB),一般 30+ 算不错越接近 1 越好

SSIM(结构相似性)

为了让数学指标像人类的眼睛一样工作,科学家发明了 SSIM (结构相似性, Structural Similarity)。 人眼是如何感知一张图片的?无非是看三个东西:整体有多亮?对比度够不够?物体的结构轮廓对不对? SSIM 巧妙地借用了统计学里的三个概念,完美量化了这三种视觉感受:

  1. 亮度比较 (Luminance) —— 用“均值 μ\mu”来衡量 把所有像素的值加起来求平均(Mean)。平均值越大,整张图就越亮。 如果两张图的平均值差不多,说明亮度相似。数学上用这个式子表示亮度分量 lll(x,y)=2μxμy+C1μx2+μy2+C1l(x, y) = \frac{2\mu_x \mu_y + C_1}{\mu_x^2 + \mu_y^2 + C_1}

  2. 对比度比较 (Contrast) —— 用“方差 σ2\sigma^2”来衡量 什么是对比度?对比度就是画面中最亮的部分和最暗的部分之间的反差大小。 这恰好和统计学里“方差”的物理意义不谋而合——方差代表了一串数据的值分布得有多分散。 如果图片像素的方差很大,说明数据波动极大,有极亮和极暗的部分交错,对比度就高,画面显得通透、锐利。 如果方差极小,说明所有像素的值全挤在了一起,亮也不够亮,暗也暗不下去,整张图像就会显得灰蒙蒙的,像蒙了一层雾,毫无层次感。 编辑图片对比度时,当你把它向右拉到最大,你会发现亮的更亮,暗的更暗(这其实就是手机里的算法在强行拉大像素值之间的方差);当你把它向左拉到最小,整张图就变成了一片死灰(方差被压到了极小)。是不是很奇妙? 数学上用这个式子表示对比度分量 ccc(x,y)=2σxσy+C2σx2+σy2+C2c(x, y) = \frac{2\sigma_x \sigma_y + C_2}{\sigma_x^2 + \sigma_y^2 + C_2}

  3. 结构相似度 (Structure) —— 用“协方差 σxy\sigma_{xy}”来衡量 这是最核心的!协方差衡量的是两组数据的变化趋势是不是一致。 如果原图某个地方变亮了,你处理后的图在这个地方也跟着变亮了(同涨同跌,协方差 > 0),这说明物体的轮廓和边缘走向被完美保住了!如果没有关系,协方差就会接近 0。 数学上用这个式子表示结构分量 sss(x,y)=σ_xy+C3σxσy+C3s(x, y) = \frac{\sigma\_{xy} + C_3}{\sigma_x \sigma_y + C_3}

把这三个维度的比较结果直接相乘,并且把 C3C_3 巧妙地替换为 C2/2C_2 / 2 ,分子分母合并后,就得到了让人望而生畏但无比精妙的 SSIM 总公式:

SSIM(x,y)=(2μxμy+C1)(2σxy+C2)(μx2+μy2+C1)(σx2+σy2+C2)\mathrm{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)}

(注: C1,C2C_1, C_2 只是为了防止分母出现 0 而加的极小的常数可以直接套用公式)

SSIM = 1 回到我们那三个分量,当两张图像完全一模一样(即 x=yx = y)时:

  • 均值完全相等(μx=μy\mu_x = \mu_y),代入亮度公式,分母分子完全一样,所以 l(x,y)=1l(x, y) = 1
  • 方差完全相等(σx=σy\sigma_x = \sigma_y),代入对比度公式,得到 c(x,y)=1c(x, y) = 1
  • 自己和自己的协方差等于自身的方差(σxy=σx2\sigma_{xy} = \sigma_x^2),代入结构公式,得到 s(x,y)=1s(x, y) = 1

最终:SSIM=1×1×1=1\mathrm{SSIM} = 1 \times 1 \times 1 = 1

那为什么越接近 1 越好呢? 你可以把这三个分量看作是三个“扣分项”。它们的最高分都是 1 分。

  • 如果你处理完的图片,整体亮度变暗了或变亮了(μxμy\mu_x \neq \mu_y),亮度分量 ll 就会小于 1。
  • 如果你的对比度被压缩了,变得灰蒙蒙的(σxσy\sigma_x \neq \sigma_y),对比度分量 cc 就会小于 1。
  • 如果图片的边缘轮廓被糊掉了,结构对不上了(协方差 σxy\sigma_{xy} 变小),结构分量 ss 就会小于 1。

只要有任何一方面的差异,这三个乘数中的某一个就会跌破 1,导致最终相乘得到的 SSIM 越来越小。差异越大,分数越偏离 1。这就极其科学且严谨地证明了我们的算法到底行不行!

SSIM 的结果一般在 [0,1][0, 1] 之间。

  • 1:两张图像完全相同(完美匹配)。
  • 接近 0:两张图的结构差异极大。

你的任务 (TODO)

在 ch2 的实验中,我们已经提供好了 SSIM 的整体框架。你需要打开 src/ch2/metrics.c,找到 metrics_compute_ssim() 中的 TODO 部分。

你需要根据公式,自己完成均值、方差、协方差的计算 (注:在图像处理中,为了工程化计算方便,我们通常直接除以 N,而不是统计学里算样本方差的 N-1)

把它们算出来代入总公式,你的任务就大功告成了!完成代码后运行,去看看 output/ch2/metrics.csv 里,我们处理后的图片,SSIM 分数是不是真的变高了!

碎碎念

💬

遇到任何不会的内容时,大家可以大胆的把文档复制给豆包姐姐或者别的啥 AI,不要有任何负罪感 ●'◡'● 有理解上的困难是正常的,我们希望文档能解决你大多数的困惑,但是文档并不完美,也不一定适合所有人,这是文档的问题,绝对不是你的问题。要相信自己!

AI 就是这样用的嘛,对吧,用 AI 满足自己的好奇心和探索欲望。与其说是满足,其实更多的是对好奇心的"保护"——当你发现你有一个永远耐心和鼓励你的老师,探索未知就不再那么恐怖。能确信知识唾手可得,只是等着你去问,这种安全感太棒了!

无论是任何领域,专业课或者计算机科学之外,希望大家能保持探索未知的勇气和决心,做自己热爱的事情。不要怀疑自己的能力,你已经做得很好了,就是缺一点点时间成长 🥳