4. 直方图分析:灰度直方图概念、直方图绘制、直方图均衡化、直方图反向投影
直方图分析,说白了就是给图像的像素值「算账」。你想想看,一张灰度图有256个灰度级,每个灰度级上有多少个像素?把这些数字画成柱状图,就是直方图。我刚开始做图像处理时,觉得这玩意儿太简单了,不就是个统计图嘛。直到后来做工业检测,发现直方图能直接告诉我光照是否均匀、对比度够不够、甚至能判断产品有没有缺陷——嗯,从那以后我再也不敢小看它了。
4.1 灰度直方图概念
灰度直方图描述的是图像中每个灰度级出现的频率。横轴是灰度值(0~255),纵轴是像素个数(或归一化后的概率)。
数学上,对于灰度级 rk,其概率密度为:
P(r_k) = n_k / N
其中 nk 是灰度级 rk 的像素数,N 是总像素数。
我个人习惯把直方图分成三类来看:
| 直方图形态 | 图像特征 | 常见场景 |
|---|---|---|
| 窄峰集中在低灰度 | 整体偏暗,细节丢失 | 曝光不足、背光拍摄 |
| 窄峰集中在高灰度 | 整体偏亮,过曝 | 强光直射、反光表面 |
| 双峰或多峰 | 图像包含多个亮度区域 | 前景+背景分离、目标检测 |
| 均匀分布 | 对比度高,细节丰富 | 理想光照下的清晰图像 |
4.2 直方图绘制
用Python画直方图,我推荐两种方式:OpenCV的 calcHist 和 Matplotlib的 hist。前者更底层,后者更直观。
先看OpenCV版本:
import cv2
import numpy as np
import matplotlib.pyplot as plt
# 读取灰度图
img = cv2.imread('example.jpg', cv2.IMREAD_GRAYSCALE)
# 计算直方图
# 参数:图像、通道、掩膜、bin数、范围
hist = cv2.calcHist([img], [0], None, [256], [0, 256])
# 绘制
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.imshow(img, cmap='gray')
plt.title('原始图像')
plt.axis('off')
plt.subplot(1, 2, 2)
plt.plot(hist, color='black')
plt.title('灰度直方图')
plt.xlabel('灰度级')
plt.ylabel('像素数')
plt.grid(True, alpha=0.3)
plt.show()
再看Matplotlib直接绘制:
# 更简洁的方式
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.imshow(img, cmap='gray')
plt.title('原始图像')
plt.axis('off')
plt.subplot(1, 2, 2)
plt.hist(img.ravel(), bins=256, range=[0, 256], color='gray', alpha=0.7)
plt.title('灰度直方图')
plt.xlabel('灰度级')
plt.ylabel('像素数')
plt.grid(True, alpha=0.3)
plt.show()
img.ravel() 将二维数组展平为一维,否则 hist 会报错。我曾经在这里卡了半小时,一直报维度错误,后来才发现是忘了展平。
绘制直方图时,我建议加上网格线,方便观察峰值位置。另外,如果图像很大(比如4K分辨率),hist 的bins可以适当减少到128或64,绘制速度会快很多。
4.3 直方图均衡化
直方图均衡化的目标,是把原始图像的直方图变换成近似均匀分布。说白了,就是把像素值重新映射,让亮的地方更亮、暗的地方更暗,拉开对比度。
算法步骤很简单:
- 计算原始直方图 P(rk)
- 计算累积分布函数 CDF:Sk = Σj=0k P(rj)
- 映射到新灰度级:sk = round(Sk × 255)
- 用映射表替换每个像素的灰度值
OpenCV一行搞定:
# 直方图均衡化
equalized = cv2.equalizeHist(img)
# 对比显示
plt.figure(figsize=(12, 6))
plt.subplot(2, 2, 1)
plt.imshow(img, cmap='gray')
plt.title('原始图像')
plt.axis('off')
plt.subplot(2, 2, 2)
plt.hist(img.ravel(), bins=256, range=[0, 256], color='gray', alpha=0.7)
plt.title('原始直方图')
plt.grid(True, alpha=0.3)
plt.subplot(2, 2, 3)
plt.imshow(equalized, cmap='gray')
plt.title('均衡化后图像')
plt.axis('off')
plt.subplot(2, 2, 4)
plt.hist(equalized.ravel(), bins=256, range=[0, 256], color='gray', alpha=0.7)
plt.title('均衡化后直方图')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
不过,直方图均衡化也有坑。我做过一个项目,检测PCB板上的焊点缺陷。原始图像光照不均匀,左边暗右边亮。直接做全局均衡化,结果左边过亮、右边过暗,反而把缺陷信息淹没了。这时候需要用自适应直方图均衡化(CLAHE),限制对比度增幅,避免噪声放大。
# CLAHE 自适应直方图均衡化
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
clahe_img = clahe.apply(img)
# clipLimit 控制对比度限制,值越大增强越强
# tileGridSize 控制局部区域大小,一般8x8或16x16
4.4 直方图反向投影
直方图反向投影,听起来高大上,其实原理很简单:用已知目标的颜色直方图,去原图中找相似区域。说白了,就是「按图索骥」。
应用场景:
- 肤色检测:用肤色直方图在图像中找手、脸
- 目标跟踪:用目标区域的直方图在后续帧中定位
- 纹理识别:用纹理的灰度分布找相似区域
算法流程:
- 选取目标区域,计算其直方图(作为模板)
- 对原图每个像素,计算其灰度值在模板直方图中的概率
- 生成概率图,值越大表示越像目标
- 对概率图做阈值化,得到目标区域
OpenCV实现:
# 假设我们已经选定了目标区域 roi
roi = img[100:200, 150:250] # 示例区域
# 计算目标直方图
roi_hist = cv2.calcHist([roi], [0], None, [256], [0, 256])
# 归一化到[0,1]区间
cv2.normalize(roi_hist, roi_hist, 0, 255, cv2.NORM_MINMAX)
# 反向投影
back_proj = cv2.calcBackProject([img], [0], roi_hist, [0, 256], 1)
# 显示结果
plt.figure(figsize=(12, 4))
plt.subplot(1, 3, 1)
plt.imshow(cv2.cvtColor(cv2.imread('example.jpg'), cv2.COLOR_BGR2RGB))
plt.title('原始图像')
plt.axis('off')
plt.subplot(1, 3, 2)
plt.imshow(back_proj, cmap='jet')
plt.title('反向投影概率图')
plt.axis('off')
plt.subplot(1, 3, 3)
# 阈值化得到二值图
_, mask = cv2.threshold(back_proj, 50, 255, cv2.THRESH_BINARY)
plt.imshow(mask, cmap='gray')
plt.title('阈值分割结果')
plt.axis('off')
plt.show()
反向投影的阈值怎么选?我一般先看概率图的直方图,选一个能分离目标和背景的值。如果概率图峰值明显,取峰谷位置;如果分布平坦,用Otsu自动阈值。
4.5 本章知识体系
下面这张图总结了直方图分析的核心脉络,从概念到应用,每一步都有对应的技术手段:
直方图分析是图像处理的基础工具,也是阈值检测的前置技能。掌握了直方图,你就能读懂图像的「脾气」——它偏暗还是偏亮?对比度够不够?有没有明显的目标和背景分离?这些信息,都会在直方图上一目了然。