图像处理入门021 | 直方图:图像像素分布的可视化 —— cv2.calcHist 与 4 类典型分布

第021期 | 图像处理算法入门系列 | 2026-08-30

关键词:直方图、cv2.calcHist、灰度直方图、多通道直方图、归一化、CDF、直方图均衡化


一、引言

第020期我们讲了"如何把灰度图一刀切成两半"------二值化。但是在切之前,必须先回答一个问题:刀要落在哪里?

答案藏在图像的 像素分布 里。把一张图所有像素按灰度值数一遍,画成柱状图,就是直方图(Histogram)

直方图是图像处理里出场率第二高的工具,仅次于二值化。它是:

  • 看图的眼睛:一眼判断偏暗 / 偏亮 / 低对比度 / 高对比度
  • 选阈值的尺子:双峰直方图的"谷底"就是二值化的最佳阈值(接续 020 期)
  • 算法的前奏:直方图均衡化(022 期)、CLAHE(023 期)、Otsu 自动阈值(076 期)、直方图匹配、色彩迁移、图像检索、相机白平衡......几乎所有需要"理解图像统计规律"的算法,第一步都是直方图
text 复制代码
直方图的本质:把 [0, 255] 切成 256 个 bin,数每个 bin 里有多少像素。

  像素值 k (0-255) → 第 k 个 bin 高度 +1

看似一行图能讲清的事,里面藏着 5 个关键概念:
  ① 直方图定义与计算  ② 多通道叠加  ③ 归一化(PDF)  ④ 累积分布 CDF  ⑤ 性能陷阱

本期我们就把直方图从定义到实现一次性讲透,最后引出下期"直方图均衡化"。


二、核心概念与原理

2.1 直方图的数学定义

设图像为 I(x, y) ∈ [0, L-1](灰度图 L=256),灰度级 k 的概率密度为:

text 复制代码
p(k) = n_k / N

其中:
  n_k  : 灰度值 = k 的像素个数
  N    : 图像总像素数
  p(k) : 归一化后的概率密度(PDF),所有 p(k) 之和 = 1

由此衍生两个关键函数:

text 复制代码
概率密度函数(PDF):  p(k) = n_k / N                     ←  归一化直方图
累积分布函数(CDF):  c(k) = Σ_{i=0..k} p(i)              ←  022 期均衡化的核心

2.2 cv2.calcHist 函数签名

python 复制代码
hist = cv2.calcHist(images, channels, mask, histSize, ranges[, hist[, accumulate]])

参数:
  images   : 输入图像列表(用 [img] 包裹),单张灰度图也行,彩色图也行
  channels : 统计哪些通道
             - 灰度图:[0]
             - 彩色图:可以 [0](只算 B),[1](只算 G),[2](只算 R)
                       也可以 [0,1] / [0,1,2](多维直方图)
  mask     : 掩膜,None 表示全图统计;非 None 时只统计 mask>0 的区域
  histSize : 每个通道的分箱数(Bins),灰度图常用 [256]
  ranges   : 每个通道的取值范围,灰度图 [0, 256],彩色图 [0,256, 0,256, 0,256]
返回值:
  hist     : 形状为 (256, 1) 的 numpy 数组

2.3 灰度直方图 vs 多通道直方图

text 复制代码
灰度直方图:    1 个通道  →  1 条曲线     →  反映整体亮度分布
多通道直方图:  3 个通道  →  3 条曲线叠加  →  反映色彩平衡(B/G/R 各占多少)
N 维直方图:    N 通道联合  →  高维矩阵   →  反映色彩出现频率(图像检索/分割常用)

2.4 归一化的意义

text 复制代码
原始直方图:  纵轴是"像素个数"------受图像分辨率影响(1M 像素 vs 1K 像素差 1000 倍)
归一化直方图:纵轴是"概率密度"------和分辨率无关,可以跨图比较

两套做法等价:
  ① 手动:hist_norm = hist / hist.sum()
  ② OpenCV:cv2.normalize(hist, None, 0, 1, cv2.NORM_MINMAX)

三、实验:6 个动手环节

实验 1:灰度直方图基础

合成一张"高对比度双峰"灰度图(30 与 220),绘制直方图:

python 复制代码
import cv2
import numpy as np
import matplotlib.pyplot as plt

# 合成测试图:30% 暗像素 + 70% 亮像素,叠加少量噪声
size = (360, 540)
rng = np.random.default_rng(7)
high = np.zeros(size, dtype=np.uint8)
mask = rng.random(size) > 0.5
high[mask] = 220
high[~mask] = 30
high = np.clip(high.astype(np.int16) + rng.integers(-8, 8, size), 0, 255).astype(np.uint8)

# 计算并绘制直方图
hist = cv2.calcHist([high], [0], None, [256], [0, 256])   # 形状 (256, 1)

plt.figure(figsize=(8, 4))
plt.plot(hist, color='#2c3e50', lw=1.5)
plt.fill_between(np.arange(256), hist.ravel(), alpha=0.25, color='#3498db')
plt.axvline(30, color='#e74c3c', ls='--', label='左峰 ≈ 30')
plt.axvline(220, color='#27ae60', ls='--', label='右峰 ≈ 220')
plt.xlim(0, 256)
plt.xlabel('灰度级'); plt.ylabel('像素计数')
plt.title('灰度直方图(256 bins)')
plt.grid(True, alpha=0.3); plt.legend()
plt.show()

运行结果

可以清楚看到两个尖峰:左峰在 30 附近(背景区),右峰在 220 附近(前景区),中间几乎为 0------这就是典型的"双峰直方图",是 020 期选阈值的依据。

实验 2:多通道直方图

彩色图像分 B/G/R 三个通道统计,颜色与通道对应:

python 复制代码
colorful = cv2.imread('colorful.jpg')   # BGR 顺序
colors = ('b', 'g', 'r')                 # OpenCV 用 BGR
for i, c in enumerate(colors):
    hist = cv2.calcHist([colorful], [i], None, [256], [0, 256])
    plt.plot(hist, color=c, label=f'通道 {i} ({c.upper()})', lw=1.6)
plt.xlim(0, 256)
plt.xlabel('像素值'); plt.ylabel('像素计数')
plt.title('BGR 三通道直方图')
plt.legend(); plt.grid(True, alpha=0.3)

运行结果

可以看到蓝/绿/红三块区域的直方图分别集中在不同像素值段,没有任何交叉------这正是彩色直方图判断"图像颜色偏向"的最常用方法。

实验 3:归一化直方图与统计量

直方图本身是绝对计数,但跨图比较时需要归一化:

python 复制代码
gray = cv2.imread('low_contrast.jpg', cv2.IMREAD_GRAYSCALE)
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])

# 两种归一化做法
hist_manual = hist / hist.sum()                    # 手动除以总数
hist_cv = cv2.normalize(hist, None, 0, 1, cv2.NORM_MINMAX)  # OpenCV 归一到 [0,1]

# 直方图统计量
print(f'像素总数: {gray.size:,}')
print(f'最小值  : {gray.min()}')
print(f'最大值  : {gray.max()}')
print(f'均值    : {gray.mean():.2f}')
print(f'中位数  : {np.median(gray):.1f}')
print(f'标准差  : {gray.std():.2f}')

运行结果

指标 数值
形状 (360, 540)
像素总数 194,400
最小值 15
最大值 241
均值 127.51
中位数 127.0
标准差 24.97

观察发现:均值和中位数几乎相等(都是 127 左右),说明这张图的分布大致对称,标准差 24.97 偏小------典型的"低对比度"特征。

实验 4:四类典型图像的直方图对比

最有价值的不是单张图的直方图,而是多张图横向对比

python 复制代码
samples = {
    'dark':          (cv2.imread('dark.jpg', 0),           '偏暗图'),
    'bright':        (cv2.imread('bright.jpg', 0),         '偏亮图'),
    'low_contrast':  (cv2.imread('low_contrast.jpg', 0),   '低对比度'),
    'high_contrast': (cv2.imread('high_contrast.jpg', 0),  '高对比度'),
}

fig, axes = plt.subplots(2, 4, figsize=(15, 7))
for col, (key, (img, title)) in enumerate(samples.items()):
    axes[0, col].imshow(img, cmap='gray')
    axes[0, col].set_title(title); axes[0, col].axis('off')
    hist = cv2.calcHist([img], [0], None, [256], [0, 256])
    axes[1, col].plot(hist)
    axes[1, col].axvline(img.mean(), color='r', ls='--')
    axes[1, col].set_xlabel('灰度级')

运行结果

类型 直方图形态 视觉特征
偏暗图 集中在 0-80 全图发暗,细节丢失在暗部
偏亮图 集中在 180-255 全图泛白,细节丢失在亮部
低对比度 集中在 100-150 灰蒙蒙,像隔着一层雾
高对比度 两端各一峰 黑白分明,过渡锐利

实操中拿到一张图,先画直方图再看图------能省 80% 的盲目调参时间。

实验 5:累积分布函数 CDF(铺垫下期)

把 PDF 累加起来就是 CDF,这是 022 期直方图均衡化的数学核心:

python 复制代码
gray = cv2.imread('low_contrast.jpg', cv2.IMREAD_GRAYSCALE)
hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).ravel()
pdf = hist / hist.sum()
cdf = np.cumsum(pdf)                  # 累积分布

fig, ax1 = plt.subplots(figsize=(8, 4))
ax1.plot(pdf, color='#3498db', label='PDF')
ax1.set_ylabel('PDF(归一化直方图)', color='#3498db')
ax2 = ax1.twinx()
ax2.plot(cdf, color='#e74c3c', label='CDF')
ax2.set_ylabel('CDF(累积分布)', color='#e74c3c')
ax2.set_ylim(0, 1.05)

运行结果

CDF 的形态是单调不减的 S 形------中间陡(像素集中),两端缓(像素稀疏)。直方图均衡化的本质就是把 CDF 拉成一条直线 y=x,让每个像素值区间被等概率使用------这就把 022 期的入口打开了。

实验 6:性能基准测试

实测三种写法的耗时差异(1080×720 彩色图):

python 复制代码
import time
big = cv2.imread('big.jpg')   # 1080×720×3

# 方式 A:cv2.calcHist(三通道 256³)
t0 = time.perf_counter()
for _ in range(50):
    cv2.calcHist([big], [0,1,2], None, [256,256,256], [0,256]*3)
t_cv = (time.perf_counter() - t0) / 50 * 1000

# 方式 B:np.bincount(单通道,最快)
t0 = time.perf_counter()
for _ in range(50):
    np.bincount(big[:,:,0].ravel(), minlength=256)
t_np = (time.perf_counter() - t0) / 50 * 1000

# 方式 C:Python 双循环(极慢)
hist = np.zeros(256, dtype=np.int64)
for y in range(0, big.shape[0], 4):       # 采样 1/16
    for x in range(0, big.shape[1], 4):
        hist[big[y, x, 0]] += 1

运行结果

方式 耗时 适用场景
cv2.calcHist(三通道 256³) 24.35 ms 通用,特别是多通道 / 掩膜
np.bincount(单通道) 4.45 ms 单通道灰度直方图的最快方案
Python 双循环 17.40 ms(仅 1/16 区域) 教学演示,生产环境禁用

关键洞察:

  • np.bincount 在单通道场景下比 cv2.calcHist 快约 5 倍
  • Python 双循环即使只采样 1/16 区域也比向量化方案慢------再次印证"必须向量化"
  • 实际项目里灰度直方图直接用 np.bincount,多通道 / 掩膜场景用 cv2.calcHist

四、常见陷阱

陷阱 1:彩色图忘了拆通道

python 复制代码
# 错误:直接对彩色图算"灰度直方图"
hist = cv2.calcHist([color_img], [0], None, [256], [0, 256])   # 只算了 B 通道
# 正确:三通道分别算,再叠加
for i in range(3):
    hist_i = cv2.calcHist([color_img], [i], None, [256], [0, 256])
    plt.plot(hist_i, color=('b','g','r')[i])

陷阱 2:直方图"假双峰"

python 复制代码
# 反例:高斯模糊过度
high = cv2.GaussianBlur(high, (11, 11), 3)   # 双峰被平滑成单峰!
# 正解:双峰图像不要做大核模糊

我第一次跑实验时就踩了这个坑------加了 (11,11) 高斯模糊,原本明显的双峰直接被磨平,标准差从 95 掉到 10,图就废了。

陷阱 3:bins 设错导致数据丢失

python 复制代码
# 把 256 个 bin 缩成 16 个 bin:信息丢失严重
hist = cv2.calcHist([gray], [0], None, [16], [0, 256])   # 每个 bin 合并 16 个灰度级
# 通常 256 bins 是最直观的默认选择

陷阱 4:横轴范围错误

python 复制代码
# OpenCV 范围是半开区间 [0, 256),不是 [0, 255]
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])   # 正确
hist = cv2.calcHist([gray], [0], None, [256], [0, 255])   # 错误,丢掉灰度=255

五、产品推荐:照片分析与直方图工具

移动端(随手拍 + 现场分析)

应用 平台 直方图能力 特色
Lightroom Mobile iOS / Android 实时 RGB 直方图 + 直方图裁剪警示 Adobe 出品,专业摄影师首选
Snapseed iOS / Android 调色面板内嵌直方图 Google 出品,免费 + 局部调色
VSCO iOS / Android 调色时显示 RGB / 亮度直方图 滤镜直方图参考
手机自带"照片信息" iOS(点击 i)/ Android 缩略图 + 直方图(部分厂商) 零门槛,查看照片元数据

桌面端 / 专业级(修图 + 调色 + 科研)

工具 平台 直方图能力 特色
RawTherapee Windows / macOS / Linux 全通道直方图 + Lab 色空间直方图 免费开源,RAW 处理神器
Darktable Windows / macOS / Linux 实时直方图 + 波形图 + 矢量示波器 摄影师用的开源版 Lightroom
Adobe Lightroom Classic Windows / macOS 完整 RGB 直方图 + 直方图裁剪警告 业界标准,付费
Capture One Windows / macOS 高级直方图 + 皮肤色调直方图 高端商业摄影常用
ImageJ / FIJI 全平台 完整直方图 + 任意 ROI 区域直方图 科研 / 医学图像分析标配
GIMP 全平台 基础直方图 免费开源,入门级

在线工具

工具 网址 特色
Photopea photopea.com 浏览器内 PS,免费,支持直方图调色
Pixlr X pixlr.com 简易在线编辑器,含直方图

六、本期小结

维度 内容
核心 API cv2.calcHist(img, channels, mask, histSize, ranges)
输出 numpy 数组,形状 (bins, 1)
归一化 hist / hist.sum()cv2.normalize(..., cv2.NORM_MINMAX)
性能最优 单通道场景用 np.bincount,比 cv2.calcHist 快约 5 倍
形态识别 单峰位置(偏暗 / 偏亮)、峰宽(对比度)、双峰(高对比度 → 020 期选阈值)
衍生函数 CDF(累积分布)→ 铺垫 022 期直方图均衡化

七、下期预告

第022期:直方图均衡化(Histogram Equalization)------ cv2.equalizeHist 自动对比度增强

我们将学习:

  • HE 数学原理:CDF 映射到均匀分布
  • 全局 HE(cv2.equalizeHist) vs 自适应 HE(CLAHE,023 期)
  • 暗光照片 / 雾天图像的对比度提升
  • 过度均衡化的副作用与"伪影"
  • 产品实践:手机相机的"夜景模式"和"HDR"算法底层

下期不见不散~


版权声明:本文为「图像处理算法入门」系列第021期,原创首发于 CSDN。代码与配图均开源,遵循 MIT 协议。引用或转载请保留原作者与链接。

相关推荐
爱吃火鸡面呀2 小时前
OpenCV 实战:文档扫描与神经风格迁移完整指南
人工智能·opencv·计算机视觉
陈年老古董5 小时前
OpenCV实战:文档扫描与图像风格迁移
人工智能·python·opencv·计算机视觉
AI的探索之旅6 小时前
97 个 OpenCV 实例(十四):图像分割,GrabCut 抠图 + inpaint 去水印
人工智能·opencv·计算机视觉
词却7 小时前
OpenCV入门(五):图像拼接、背景建模、光流估计与DNN风格迁移
opencv
派大_星7 小时前
OpenCV中的文档扫描与DNN图像处理
人工智能·opencv·计算机视觉
QT界面美化性能优化17 小时前
QT+AI:使用AI技术为QT应用程序赋能
c++·人工智能·qt·opencv·qt教程·qt6.3
AndrewHZ1 天前
图像处理入门020 | 图像二值化:阈值分割基础 —— cv2.threshold 五种类型与自适应阈值铺垫
图像处理·opencv·文档扫描·阈值分割·图像二值化·trackbar·自适应阈值
笑霸final1 天前
不用上传服务器!用 Vue3 + ONNX Runtime Web 在浏览器本地实现智能抠图
运维·前端·图像处理·ai·onnx·canva可画·web性能优化
AI的探索之旅1 天前
97 个 OpenCV 实例(十三):目标跟踪,CamShift 反向投影
人工智能·opencv·目标跟踪