4. 直方图与对比度增强:让图像的“脾气”一目了然
各位同学,欢迎来到第四章。
前面我们聊了像素操作、空间滤波,算是把图像的“微观世界”摸了一遍。今天要讲的,是图像的“宏观统计”——直方图。说白了,直方图就是一张图里像素值的分布情况。你想想看,一张图是偏亮还是偏暗,对比度高不高,直方图上一眼就能看出来。
我个人习惯,拿到一张图第一件事就是看它的直方图。这就像医生看病人的血常规报告,很多问题在直方图上一目了然。这一章,我们就来彻底搞懂直方图,以及如何用它来给图像“提神”——也就是对比度增强。
4.1 直方图概念:图像的“人口普查”
直方图,本质上就是一个统计图。横轴是像素值(0-255),纵轴是该像素值出现的次数(或概率)。
举个例子,一张纯黑的图,直方图就只有一根柱子立在0的位置;一张纯白的图,柱子就立在255的位置。一张正常的风景照,直方图通常像一座连绵起伏的山脉,中间高两边低。
核心公式(理解即可):
对于灰度图,直方图定义为:H(r_k) = n_k,其中 r_k 是第k个灰度级,n_k 是该灰度级的像素个数。
归一化直方图:p(r_k) = n_k / N,N是总像素数。归一化后,纵轴就变成了概率,方便不同尺寸的图像进行比较。
在OpenCV里,计算直方图就一行代码的事。但我建议你,刚开始学的时候,自己用Python写一遍循环,感受一下像素是怎么被“数”出来的。
import cv2
import numpy as np
import matplotlib.pyplot as plt
# 读取灰度图
img = cv2.imread('example.jpg', 0)
# 方法一:OpenCV 官方函数
hist = cv2.calcHist([img], [0], None, [256], [0, 256])
# 方法二:自己动手算(理解原理)
# hist_manual = np.zeros((256,), dtype=np.int32)
# for pixel in img.flatten():
# hist_manual[pixel] += 1
# 画图
plt.plot(hist)
plt.title('灰度直方图')
plt.xlabel('像素值')
plt.ylabel('频数')
plt.show()
小技巧: cv2.calcHist 的参数里,[0] 表示通道索引(灰度图只有1个通道),[256] 表示bin的数目。如果你处理的是彩色图,可以分别计算R、G、B三个通道的直方图。
4.2 直方图均衡化:给图像“拉一把”
什么是直方图均衡化?
想象一下,一张图整体偏暗,它的直方图就都挤在左边。均衡化,就是把这些挤在一起的像素值,像拉手风琴一样,均匀地“拉开”到整个0-255区间。结果就是,暗的地方变亮了,亮的地方可能稍微暗一点,整体对比度大幅提升。
我记得有一次做水下图像增强,拍出来的鱼都是灰蒙蒙的。直方图一看,像素值全挤在50-100之间。用均衡化一处理,鱼的纹理和颜色瞬间就出来了。效果立竿见影。
OpenCV实现非常简单:
# 直方图均衡化
equ = cv2.equalizeHist(img)
# 对比显示
res = np.hstack((img, equ))
cv2.imshow('Original vs Equalized', res)
cv2.waitKey(0)
但是,这里有个坑。全局直方图均衡化,它是对整张图做同一个映射。如果图像本身有很亮和很暗的区域,均衡化后,亮区可能会过曝,暗区可能会变成一片死黑。为什么会这样?因为它是全局的,它不会区分“天空”和“地面”。
避坑指南: 我曾经在一个医疗影像项目里,直接用全局均衡化处理X光片。结果骨骼边缘是清楚了,但软组织区域出现了大量噪声和伪影。后来我才意识到,对于这类图像,全局方法太“粗暴”了。这时候,就需要我们的主角——自适应直方图均衡化(CLAHE)登场了。
4.3 自适应直方图均衡化(CLAHE):精细化的“局部手术”
CLAHE,全称是 Contrast Limited Adaptive Histogram Equalization。名字很长,但核心思想很简单:
- 分块处理: 把图像分成一个个小方块(tile),比如8x8的网格。
- 局部均衡: 对每个小方块单独做直方图均衡化。
- 对比度限制: 这是最关键的一步。它会设定一个阈值,如果某个小方块内的直方图峰值太高(意味着噪声多),就把它“剪掉”,把剪掉的部分均匀分布到其他bin上。这就防止了噪声被过度放大。
- 双线性插值: 为了避免块与块之间出现明显的边界,最后会用插值算法平滑过渡。
你想想看,这就像给图像做“局部手术”,哪里暗就提亮哪里,但又不让噪声“趁虚而入”。
# 创建CLAHE对象
# clipLimit: 对比度限制阈值,默认40。值越大,对比度增强越强。
# tileGridSize: 分块大小,默认(8,8)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
# 应用CLAHE
clahe_img = clahe.apply(img)
# 对比三种效果
res = np.hstack((img, equ, clahe_img))
cv2.imshow('Original | Global Equalization | CLAHE', res)
cv2.waitKey(0)
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 全局直方图均衡化 | 简单、速度快 | 容易过曝、放大噪声 | 整体偏暗或偏亮,且光照均匀的图像 |
| CLAHE | 局部自适应、抑制噪声、效果自然 | 速度稍慢、参数需要调 | 光照不均、医学影像、水下图像、夜间图像 |
调参经验: clipLimit 我一般从2.0开始试。如果图像噪声大,就调小到1.0或0.5;如果希望对比度更强烈,可以调到3.0或4.0。tileGridSize 越大,效果越接近全局均衡化;越小,局部细节越丰富,但计算量也越大。
4.4 直方图比较:判断两张图的“血缘关系”
学会了画直方图,我们还能用它来比较两张图是否相似。这在图像检索、目标跟踪、视频镜头切换检测里非常有用。
OpenCV提供了 cv2.compareHist 函数,支持多种比较方法:
| 方法 | OpenCV常量 | 说明 |
|---|---|---|
| 相关性 (Correlation) | cv2.HISTCMP_CORREL |
值越大越相似(1为完全相似,-1为完全不相似) |
| 卡方 (Chi-Square) | cv2.HISTCMP_CHISQR |
值越小越相似(0为完全相似) |
| 交叉 (Intersection) | cv2.HISTCMP_INTERSECT |
值越大越相似(直接计算重叠部分) |
| 巴氏距离 (Bhattacharyya) | cv2.HISTCMP_BHATTACHARYYA |
值越小越相似(0为完全相似,1为完全不相似) |
我个人最常用的是 巴氏距离,因为它对噪声不敏感,而且结果在0-1之间,非常直观。
# 计算两张图的直方图
hist1 = cv2.calcHist([img1], [0], None, [256], [0, 256])
hist2 = cv2.calcHist([img2], [0], None, [256], [0, 256])
# 归一化(重要!比较前一定要归一化)
hist1 = cv2.normalize(hist1, hist1).flatten()
hist2 = cv2.normalize(hist2, hist2).flatten()
# 比较
d = cv2.compareHist(hist1, hist2, cv2.HISTCMP_BHATTACHARYYA)
print(f'巴氏距离: {d:.4f}') # 越接近0,越相似
注意: 比较之前,一定要对直方图进行归一化!否则,两张尺寸不同的图,即使内容一样,直方图的数值也会差很多,比较结果就没有意义了。我曾经在这个细节上吃过亏,排查了半天才发现是归一化的问题。
4.5 本章知识体系图
下面这张图,帮你把本章的核心逻辑串起来:
好了,这一章的内容就是这些。从直方图的概念,到全局均衡化,再到精细的CLAHE,最后是直方图比较。每一步都有它的应用场景和坑。希望你在自己的项目里,能灵活运用这些工具,让图像“开口说话”。