第二章 · 图像质量评估
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, >) != 0) {
out_result->status_code = PIPELINE_STATUS_LOAD;
image_free(&input);
image_free(>);
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, >, &out_result->psnr_before);
if (compute_ssim) {
if (metrics_compute_ssim(&input, >, &out_result->ssim_before) != 0) { ... }
}
/* ... 中间做了一些神秘的操作 ... */
// 第二次算:处理后
metrics_compute_psnr(&output, >, &out_result->psnr_after);
if (compute_ssim) {
if (metrics_compute_ssim(&output, >, &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)。
假设有一块 的像素区域,原本应该是一片亮度为 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)**来求加权平均:
怎么给周围的像素打分?双边滤波极其聪明地考虑了两个“边 (Bilateral)”:
- 空间距离权重:这很直观。离中心像素越近的邻居,参考价值越大,权重越高;离得远的权重低。
- 色彩差异权重:如果旁边那个像素的值和中心像素差得极其离谱,那说明它们很可能属于两个不同的物体,中间隔着一条“边界”。这时候,即使它离得再近,我们也认为它没有参考价值,权重直接暴跌!
两者结合,它只会在颜色相近的“同一块平缓区域内”做平均降噪,一旦遇到颜色断崖式突变的边缘,它就立刻停止跨界平均。 这样,噪点消失了,而物体清晰的轮廓被完美保留!
(注:在我们的实验中,往往为了简化计算,会把 RGB 三通道彩色图片转换为单通道的灰度图。大家可能会好奇,为什么灰度值就能代表一张图片?其实,所谓的“灰度”,本质上就是像素的“亮度”。你可以想象一下以前的黑白老电视机,虽然没有色彩,但你依然能看清画面里的建筑和人脸,靠的就是不同区域从纯黑到纯白的明暗变化。所以把 RGB 强行融合成一个灰度值,虽然损失了色彩信息,但完美保留了所有的明暗层次和物理轮廓,完全足够我们用来验证算法的有效性了。)
怎么科学地证明图片变好看了?
我们刚才算出来的 psnr_before 和 psnr_after 是怎么回事?
MSE(均方误差)
MSE 是衡量"两张图差了多少"最直觉的方式:把每个像素对应位置的差求出来,平方,再取平均。
拿两张 的"迷你图片"举例:
原图: 10 20 30 40
处理后: 12 18 31 39
差值: +2 -2 +1 -1如果直接求差的平均值,结果是 0——看起来"没有误差",但显然是有的。问题在于正负相互抵消了。所以我们先平方(),再取平均,得到 。平方的好处有两个:消除正负抵消,并且放大大误差的惩罚——差 10 比差 1 严重得多,平方后是 100 vs 1 这很符合直觉吧。
公式:
PSNR(峰值信噪比)
MSE 是"绝对误差",但不同图片的像素范围不一样(8-bit 图最大 255,16-bit 可能到 65535),光看 MSE 的数值没法跨图片比较。PSNR 就是用像素最大值 做归一化,再取对数换算成分贝(dB),让不同图片之间的分数可以横向对比:
听起来很完美对不对? 但这有个大问题:人眼看图片,根本不是在逐个核对像素的值!
如果你把一张照片整体变暗了一点点,或者向右平移了 1 个像素。人眼看起来会觉得"这两张图明明差不多啊",但用 PSNR 去算,误差会直接爆炸,分数极低。
| PSNR | SSIM | |
|---|---|---|
| 衡量方式 | 逐像素计算误差的平方 | 从亮度、对比度、结构三个维度综合评估 |
| 符合人眼感知? | 不符合 | 符合 |
| 弱点 | 图片整体平移 1px,人眼看不出区别,但 PSNR 直接暴跌 | 计算量更大 |
| 分数含义 | 越高越好(单位 dB),一般 30+ 算不错 | 越接近 1 越好 |
SSIM(结构相似性)
为了让数学指标像人类的眼睛一样工作,科学家发明了 SSIM (结构相似性, Structural Similarity)。 人眼是如何感知一张图片的?无非是看三个东西:整体有多亮?对比度够不够?物体的结构轮廓对不对? SSIM 巧妙地借用了统计学里的三个概念,完美量化了这三种视觉感受:
-
亮度比较 (Luminance) —— 用“均值 ”来衡量 把所有像素的值加起来求平均(Mean)。平均值越大,整张图就越亮。 如果两张图的平均值差不多,说明亮度相似。数学上用这个式子表示亮度分量 :
-
对比度比较 (Contrast) —— 用“方差 ”来衡量 什么是对比度?对比度就是画面中最亮的部分和最暗的部分之间的反差大小。 这恰好和统计学里“方差”的物理意义不谋而合——方差代表了一串数据的值分布得有多分散。 如果图片像素的方差很大,说明数据波动极大,有极亮和极暗的部分交错,对比度就高,画面显得通透、锐利。 如果方差极小,说明所有像素的值全挤在了一起,亮也不够亮,暗也暗不下去,整张图像就会显得灰蒙蒙的,像蒙了一层雾,毫无层次感。 编辑图片对比度时,当你把它向右拉到最大,你会发现亮的更亮,暗的更暗(这其实就是手机里的算法在强行拉大像素值之间的方差);当你把它向左拉到最小,整张图就变成了一片死灰(方差被压到了极小)。是不是很奇妙? 数学上用这个式子表示对比度分量 :
-
结构相似度 (Structure) —— 用“协方差 ”来衡量 这是最核心的!协方差衡量的是两组数据的变化趋势是不是一致。 如果原图某个地方变亮了,你处理后的图在这个地方也跟着变亮了(同涨同跌,协方差 > 0),这说明物体的轮廓和边缘走向被完美保住了!如果没有关系,协方差就会接近 0。 数学上用这个式子表示结构分量 :
把这三个维度的比较结果直接相乘,并且把 巧妙地替换为 ,分子分母合并后,就得到了让人望而生畏但无比精妙的 SSIM 总公式:
(注: 只是为了防止分母出现 0 而加的极小的常数可以直接套用公式)。
SSIM = 1 回到我们那三个分量,当两张图像完全一模一样(即 )时:
- 均值完全相等(),代入亮度公式,分母分子完全一样,所以 。
- 方差完全相等(),代入对比度公式,得到 。
- 自己和自己的协方差等于自身的方差(),代入结构公式,得到 。
最终:。
那为什么越接近 1 越好呢? 你可以把这三个分量看作是三个“扣分项”。它们的最高分都是 1 分。
- 如果你处理完的图片,整体亮度变暗了或变亮了(),亮度分量 就会小于 1。
- 如果你的对比度被压缩了,变得灰蒙蒙的(),对比度分量 就会小于 1。
- 如果图片的边缘轮廓被糊掉了,结构对不上了(协方差 变小),结构分量 就会小于 1。
只要有任何一方面的差异,这三个乘数中的某一个就会跌破 1,导致最终相乘得到的 SSIM 越来越小。差异越大,分数越偏离 1。这就极其科学且严谨地证明了我们的算法到底行不行!
SSIM 的结果一般在 之间。
- 1:两张图像完全相同(完美匹配)。
- 接近 0:两张图的结构差异极大。
你的任务 (TODO)
在 ch2 的实验中,我们已经提供好了 SSIM 的整体框架。你需要打开 src/ch2/metrics.c,找到 metrics_compute_ssim() 中的 TODO 部分。
你需要根据公式,自己完成均值、方差、协方差的计算 (注:在图像处理中,为了工程化计算方便,我们通常直接除以 N,而不是统计学里算样本方差的 N-1) 。
把它们算出来代入总公式,你的任务就大功告成了!完成代码后运行,去看看 output/ch2/metrics.csv 里,我们处理后的图片,SSIM 分数是不是真的变高了!
碎碎念
💬
遇到任何不会的内容时,大家可以大胆的把文档复制给豆包姐姐或者别的啥 AI,不要有任何负罪感 ●'◡'● 有理解上的困难是正常的,我们希望文档能解决你大多数的困惑,但是文档并不完美,也不一定适合所有人,这是文档的问题,绝对不是你的问题。要相信自己!
AI 就是这样用的嘛,对吧,用 AI 满足自己的好奇心和探索欲望。与其说是满足,其实更多的是对好奇心的"保护"——当你发现你有一个永远耐心和鼓励你的老师,探索未知就不再那么恐怖。能确信知识唾手可得,只是等着你去问,这种安全感太棒了!
无论是任何领域,专业课或者计算机科学之外,希望大家能保持探索未知的勇气和决心,做自己热爱的事情。不要怀疑自己的能力,你已经做得很好了,就是缺一点点时间成长 🥳