OpenCV-Python (官方)中文教程(部分一)_Day21
22.2直方图均衡化
如果一副图像中的大多是像素点的像素值都集中在一个像素值范围之内会怎样呢?例如,如果一幅图片整体很亮,那所有的像素值应该都会很高。但是一副高质量的图像的像素值分布应该很广泛。所以你应该把它的直方图做一个横向拉伸(如下图),这就是直方图均衡化要做的事情。通常情况下这种操作会改善图像的对比度。
推荐你去读读维基百科中关于直方图均衡化的条目。其中的解释非常给力, 读完之后相信你就会对整个过程有一个详细的了解了。我们先看看怎样使用 Numpy 来进行直方图均衡化,然后再学习使用 OpenCV 进行直方图均衡化。
import cv2
import numpy as np
from matplotlib import pyplot as plt
img = cv2.imread('drawing.png',0)
#flatten() 将数组变成一维 256直方图bin的数量(对应256个灰度级)[0,256]像素值范围(实际计算0~255)
#输出:hist:长度为256的数组,每个元素表示对应灰度级的像素数量
#bins:257个边界值([0,1,...,256]),通常仅使用bins[:-1]
hist,bins = np.histogram(img.flatten(),256,[0,256])
# 计算累积分布图
cdf = hist.cumsum() ## 计算累积和
#将CDF缩放到与直方图相同的最大值,便于同图显示
cdf_normalized = cdf * hist.max()/ cdf.max()
plt.plot(cdf_normalized, color='b') # 蓝色CDF曲线
plt.hist(img.flatten(), 256, [0,256], color='r') # 红色直方图
plt.xlim([0,256]) # 限制X轴范围
plt.legend(('cdf', 'histogram'), loc='upper left') # 图例
plt.show()
可视化效果
红色柱状图:原始图像灰度分布
峰值表示出现频率高的灰度级
蓝色曲线:归一化CDF
单调递增,斜率大的区域对应直方图峰值
用于分析像素值的累积分布(直方图均衡化的核心)
我们可以看出来直方图大部分在灰度值较高的部分,而且分布很集中。而我们希望直方图的分布比较分散,能够涵盖整个 x 轴。所以,我们就需要一个变换函数帮助我们把现在的直方图映射到一个广泛分布的直方图中。这就是直方图均衡化要做的事情。
关键概念解析
1. 直方图(Histogram)
统计图像中每个灰度级的像素数量
本例中hist是长度为256的一维数组
2. 累积分布函数(CDF)
定义:
作用:反映灰度级的分布概率,用于直方图均衡化
3. 归一化目的
将CDF的纵坐标范围从[0,总像素数]缩放到[0, max(hist)]
使CDF曲线和直方图能在同一坐标系显示(Y轴范围一致)
CDF归一化原理详解
1. 原始CDF的范围问题
原始CDF值范围:[0, 总像素数]
例如一张100×100的图像,总像素=10,000,CDF最大值=10,000。
直方图值范围:[0, max(hist)]
max(hist)是某个灰度级对应的最大像素数量(可能远小于总像素数)。
直接绘制会导致:
CDF曲线因数值过大在图上几乎呈垂直线(如下图左侧)。
无法直观对比直方图和CDF的关系。
2. 归一化公式
cdf_normalized = cdf * hist.max() / cdf.max()
分步解析:
1、cdf.max()取CDF的最大值(即总像素数),例如10,000。
2、hist.max()取直方图的最大值(某个灰度级的像素数),例如2,000。
3、缩放比例
计算缩放因子:hist.max() / cdf.max() → 2000/10000 = 0.2
将CDF所有值乘以0.2,使最大值从10,000变为2,000。
数学表达:
- 归一化后的效果
关键点:归一化后的CDF最大值与直方图最大值相同,两者共享同一Y轴刻度。
可视化对比:
plt.plot(cdf_normalized, color='b') # 归一化CDF(Y范围=[0, max(hist)])
plt.hist(img.flatten(), 256, [0,256], color='r') # 直方图(Y范围=[0, max(hist)])
此时两条曲线的Y轴范围完全一致,可以清晰观察分布关系。
4. 为什么选择max(hist)作为上限?
物理意义:将CDF的“累积概率”映射到直方图的“单点频数”尺度。
直观解释:
若某灰度级k的hist[k] = max(hist),则归一化后cdf_normalized[k]也会接近max(hist)。
这样CDF曲线的峰值位置与直方图的峰值位置在Y方向上对齐。
我们现在要找到直方图中的最小值(除了 0),并把它用于 wiki 中的直方图均衡化公式。但是我在这里使用了 Numpy 的掩模数组。对于掩模数组的所有操作都只对 non-masked 元素有效。你可以到 Numpy 文档中获取更多掩 模数组的信息。
# 构建 Numpy 掩模数组,cdf 为原数组,当数组元素为 0 时,掩盖(计算时被忽略)。
cdf_m = np.ma.masked_equal(cdf,0)
cdf_m = (cdf_m - cdf_m.min())*255/(cdf_m.max()-cdf_m.min())
# 对被掩盖的元素赋值,这里赋值为 0
cdf = np.ma.filled(cdf_m,0).astype('uint8')
现在就获得了一个表,我们可以通过查表得知与输入像素对应的输出像素的值。我们只需要把这种变换应用到图像上就可以了。
img2 = cdf[img]
这段代码的作用是对累积分布函数(CDF)数组进行归一化并处理零值,最终将其映射到 [0, 255] 的整数范围(适用于8位图像)。以下是逐步解析:
1. 构建掩模数组(Masked Array)
cdf_m = np.ma.masked_equal(cdf, 0)
目的:忽略CDF中值为 0 的元素(避免后续计算受零值干扰)。
原理:np.ma.masked_equal(cdf, 0) 创建一个掩模数组,其中所有 cdf == 0 的位置被标记为“掩盖”(计算时被忽略)。
示例:输入 cdf = [0, 10, 20, 0, 30] → 输出 cdf_m = [--, 10, 20, --, 30](-- 表示被掩盖)。
2. 归一化到 [0, 255] 范围
cdf_m = (cdf_m - cdf_m.min()) * 255 / (cdf_m.max() - cdf_m.min())
目的:将非零的CDF值线性映射到 [0, 255](8位图像的标准范围)。
公式:
步骤:
1、减去最小值:cdf_m - cdf_m.min() → 将数据偏移到 [0, max-min]。
2、除以动态范围:/(cdf_m.max() - cdf_m.min()) → 缩放到 [0, 1]。
3、乘以255:映射到 [0, 255]。
示例:
若 cdf_m = [10, 20, 30](忽略掩盖值),min=10,max=30:
归一化后:[(10-10)*255/20=0, (20-10)*255/20=127.5, (30-10)*255/20=255] → [0, 127.5, 255]。
3. 恢复被掩盖的零值
cdf = np.ma.filled(cdf_m, 0).astype('uint8')
目的:将掩模数组中所有被掩盖的位置填充为 0,并转换为8位无符号整数。
函数:
np.ma.filled(cdf_m, 0):将掩模位置替换为 0。
.astype('uint8'):确保结果为 0-255 的整数(适合图像像素值)。
示例:
输入 cdf_m = [--, 127.5, 255, --] → 输出 cdf = [0, 127, 255, 0](浮点数四舍五入为整数)。
4. 完整流程示例
假设原始CDF数组为:
cdf = [0, 100, 200, 0, 300] # 原始CDF(含零值)
步骤1:掩模零值
cdf_m = [--, 100, 200, --, 300] # 0被掩盖
步骤2:归一化
min=100, max=300, 动态范围 =300-100=200
计算:
(100-100)*255/200 = 0
(200-100)*255/200 = 127.5
(300-100)*255/200 = 255
结果:
cdf_m = [--, 0, 127.5, --, 255]
步骤3:填充零并转换类型
cdf = [0, 0, 128, 0, 255] # 127.5四舍五入为128
5. 应用场景
直方图均衡化:将CDF归一化到 [0, 255] 后,可直接用作像素值的映射表。
忽略零值:避免图像中未出现的灰度级(CDF=0)干扰归一化结果。
6. 关键注意事项
1、零值的意义:
若CDF中 0 表示该灰度级不存在,掩盖它们是合理的。
若 0 是有效累积值(如第一灰度级的像素数),则不应掩盖。
2、动态范围为零的情况:
若 cdf.max() == cdf.min()(如全零数组),分母为0会导致错误,需额外处理。
3、数据类型转换:
astype('uint8') 会截断浮点数(如255.9→255),确保结果合法。
总结
这段代码通过掩模处理零值 → 归一化 → 恢复零值,将CDF转换为适用于图像处理的8位映射表,同时确保无效灰度级(零值)不影响归一化过程。
详解 astype('uint8') 的截断行为
1. 核心概念
astype('uint8')是 NumPy 中用于转换数组数据类型的方法,将数据强制转换为8位无符号整数(范围 0-255)。
截断(Truncation):当输入为浮点数时,直接丢弃小数部分(非四舍五入),可能导致精度损失。
2. 具体行为
浮点数转换规则:
若浮点数为 255.9 → 转换时直接截断小数部分 → 结果为 255(而非四舍五入到 256,因为 uint8 最大值为 255)。
若浮点数为 -1.2 → 无符号整数不支持负数 → 可能溢出为 255(危险行为,需避免)。
示例:
import numpy as np
arr = np.array([255.9, 127.3, -0.5])
print(arr.astype('uint8')) # 输出: [255, 127, 0](截断 + 溢出处理)
3. 为什么说“确保结果合法”?
uint8 的硬性范围是 [0, 255],任何超出此范围的值会被强制约束:
大于 255 的值 → 取模运算(如 256 → 0,257 → 1)。
负数 → 溢出为 255 - |x|(如 -1 → 255,-2 → 254)。
截断的合理性:
在图像处理中,像素值必须落在 0-255 范围内,截断可避免非法值。
但需注意:截断可能导致轻微精度损失(如 255.9 和 255.1 均变为 255)。
4. 与四舍五入的对比
若需更精确的转换,应显式使用四舍五入:
cdf_normalized = np.round(cdf_m).astype('uint8') # 先四舍五入再转换
示例:
arr = np.array([255.9, 127.3])
print(np.round(arr).astype('uint8')) # 输出: [256, 127] → 但 256 会溢出为 0!
注意:四舍五入后仍需保证值 ≤255,否则仍需截断。
5. 实际应用中的建议
预处理归一化范围:
确保归一化后的值不超过 255(如除以 max(cdf) 后乘以 254 而非 255)。
cdf_m = (cdf_m - cdf_m.min()) * 254 / (cdf_m.max() - cdf_m.min()) # 最大值限制为 254
cdf = np.ma.filled(cdf_m + 0.5, 0).astype('uint8') # +0.5 模拟四舍五入
显式裁剪:
使用 np.clip 确保数值安全:
cdf_normalized = np.clip(cdf_m, 0, 255).astype('uint8') # 强制限制范围
6. 总结
astype('uint8') 的截断:直接丢弃小数部分,确保结果在 0-255 范围内,但可能损失精度。
图像处理中的合法性:像素值必须为整数且在 0-255 之间,截断是快速有效的强制手段。
精度与安全的权衡:若需更高精度,需结合四舍五入和范围裁剪。
1. 无符号整数(uint8)的范围
uint8 表示8 位无符号整数,取值范围是 [0, 255]。
它不能存储负数,任何负数转换都会导致溢出。
2. 负数的二进制补码表示
计算机存储整数时,通常使用补码(Two's Complement)表示负数。例如:
-1 的 8 位补码表示是 11111111(即 255)。
-2 的 8 位补码表示是 11111110(即 254)。
依此类推,-k 在 uint8 下会变成 256 - k。
计算方式:
uint8_value=256+negative_float_value
例如:
−1.2→256+(−1)=255(小数部分被截断)
3. 为什么 -1.2 变成 255?
浮点数转整数时,先截断小数部分:
-1.2 → 截断后变成 -1(astype 不会四舍五入,而是直接丢弃小数)。
负数溢出到 uint8:
-1 的补码是 11111111(二进制),即 255(十进制)。
因此,np.array([-1.2]).astype('uint8') 会输出 [255]。
4. 实验验证
import numpy as np
# 测试负数转换
arr = np.array([-0.5, -1.0, -1.2, -2.9])
uint8_arr = arr.astype('uint8')
print(uint8_arr) # 输出: [255, 255, 255, 254]
解释:
-0.5 → 截断为 0 → 但 uint8 不能为负,溢出为 255(特殊行为)。
-1.0 → 截断为 -1 → 补码 11111111 → 255。
-1.2 → 截断为 -1 → 255。
-2.9 → 截断为 -2 → 补码 11111110 → 254。
5. 为什么这是“危险行为”?
数据失真:
负数被错误地映射到大正数,可能导致计算错误(如直方图均衡化时错误映射)。
难以调试:
如果数据中意外混入负数,转换后可能变成 255,导致程序逻辑错误,但不会报错。
安全隐患:
在图像处理中,255 通常代表白色像素,错误的负数转换可能导致图像异常。
我们再根据前面的方法绘制直方图和累积分布图,结果如下:
另一个重要的特点是,即使我们的输入图片是一个比较暗的图片(不像上边我们用到的整体都很亮的图片),在经过直方图均衡化之后也能得到相同的 结果。因此,直方图均衡化经常用来使所有的图片具有相同的亮度条件的参考工具。这在很多情况下都很有用。例如,脸部识别,在训练分类器前,训练集 的所有图片都要先进行直方图均衡化从而使它们达到相同的亮度条件。
OpenCV中的直方图均衡化
OpenCV 中的直方图均衡化函数为 cv2.equalizeHist()。这个函数的输入图片仅仅是一副灰度图像,输出结果是直方图均衡化之后的图像。
下边的代码还是对上边的那幅图像进行直方图均衡化:
img = cv2.imread('wiki.jpg',0)
equ = cv2.equalizeHist(img)
res = np.hstack((img,equ))
#stacking images side-by-side
cv2.imwrite('res.png',res)
现在你可以拿一些不同亮度的照片自己来试一下了。 当直方图中的数据集中在某一个灰度值范围内时,直方图均衡化很有用。但是如果像素的变化很大,而且占据的灰度范围非常广时,例如:既有很亮的 像素点又有很暗的像素点时。请查看更多资源中的 SOF 链接。
CLAHE有限对比适应性直方图均衡化
我们在上边做的直方图均衡化会改变整个图像的对比度,但是在很多情况下,这样做的效果并不好。例如,下图分别是输入图像和进行直方图均衡化之后的输出图像。
的确在进行完直方图均衡化之后,图片背景的对比度被改变了。但是你再对比一下两幅图像中雕像的面图,由于太亮我们丢失了很多信息。造成这种结果的根本原因在于这幅图像的直方图并不是集中在某一个区域(试着画出它的直方图就明白了)。
为了解决这个问题,我们需要使用自适应的直方图均衡化。这种情况下, 整幅图像会被分成很多小块,这些小块被称为“tiles”(在 OpenCV 中 tiles 的 大小默认是 8x8),然后再对每一个小块分别进行直方图均衡化(跟前面类似)。 所以在每一个的区域中,直方图会集中在某一个小的区域中(除非有噪声干 扰)。如果有噪声的话,噪声会被放大。为了避免这种情况的出现要使用对比度 限制。对于每个小块来说,如果直方图中的 bin 超过对比度的上限的话,就把 其中的像素点均匀分散到其他 bins 中,然后在进行直方图均衡化。最后,为了去除每一个小块之间“人造的”(由于算法造成)边界,再使用双线性差值,对小块进行缝合。
import numpy as np
import cv2
img = cv2.imread('tsukuba_l.png',0)
# create a CLAHE object (Arguments are optional).
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
cl1 = clahe.apply(img)
cv2.imwrite('clahe_2.jpg',cl1)
下面就是结果了,与前面的结果对比一下,尤其是雕像区域:
import cv2
import numpy as np
def advanced_histogram_equalization():
# 读取图像
img = cv2.imread('2.png', cv2.IMREAD_GRAYSCALE)
if img is None:
print("错误:图像加载失败!")
return
# 方法1:全局直方图均衡化(基础方法)
equ_global = cv2.equalizeHist(img)
# 方法2:CLAHE(对比度受限的自适应直方图均衡化,适合局部增强)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
equ_clahe = clahe.apply(img)
# 拼接所有结果
res = np.hstack((img, equ_global, equ_clahe))
# 显示并保存
cv2.imshow('Original | Global HE | CLAHE', res)
cv2.waitKey(0)
cv2.imwrite('advanced_result.png', res)
print("结果已保存为 advanced_result.png")
if __name__ == "__main__":
advanced_histogram_equalization()
更多资源
1. 维基百科中的直方图均衡化。
2. Masked Arrays in Numpy
关于调整图片对比度 SOF 问题:
1. 在 C 语言中怎样使用 OpenCV 调整图像对比度.
2. 怎样使用 OpenCV 调整图像的对比度和亮度.