直方图计算与可视化:从数据到图形

直方图这东西,说白了就是一张统计图。它把图像里每个像素的亮度值数一遍,然后告诉你:这张图里有多少像素是暗的,多少是亮的。我刚开始学图像处理时,总觉得直方图就是个辅助工具,后来做项目才发现——它其实是图像分析的「第一道安检」。

直方图统计方法:灰度级与频数

一张灰度图像,每个像素的取值在 0 到 255 之间。直方图要做的就两件事:

  • 统计每个灰度级出现的像素个数(频数)
  • 把频数画成柱状图,横轴是灰度级,纵轴是频数

举个例子,一张 4×4 的 8 位灰度图:

像素值5050100100
像素值50100150150
像素值100100150200
像素值200200200255

统计结果就是:灰度 50 出现 3 次,100 出现 5 次,150 出现 3 次,200 出现 4 次,255 出现 1 次。就这么简单。

我的习惯:拿到一张图,我第一件事就是看它的直方图。如果直方图集中在左侧,说明图像偏暗;集中在右侧,说明偏亮;中间隆起但两边塌陷,说明对比度低。这比肉眼判断快多了。

OpenCV/Python 绘制直方图:三种常用方法

Python 里画直方图,我常用的工具是 OpenCV 配合 Matplotlib。下面给出三种写法,从最基础的到最实用的。

方法一:用 OpenCV 的 calcHist

import cv2
import matplotlib.pyplot as plt

# 读取灰度图
img = cv2.imread('example.jpg', cv2.IMREAD_GRAYSCALE)

# 计算直方图
hist = cv2.calcHist([img], [0], None, [256], [0, 256])

# 绘制
plt.plot(hist, color='black')
plt.xlabel('灰度级')
plt.ylabel('像素数')
plt.title('灰度直方图')
plt.show()

calcHist 的参数含义:

  • [img]:输入图像列表
  • [0]:通道索引,灰度图只有 0 通道
  • None:掩膜,None 表示全图统计
  • [256]:bin 的数量,256 表示每个灰度级一个 bin
  • [0, 256]:灰度范围

方法二:用 Matplotlib 的 hist 直接画

import cv2
import matplotlib.pyplot as plt

img = cv2.imread('example.jpg', cv2.IMREAD_GRAYSCALE)

# 将图像展平为一维数组
pixels = img.ravel()

# 直接画直方图
plt.hist(pixels, bins=256, range=[0, 256], color='gray', alpha=0.7)
plt.xlabel('灰度级')
plt.ylabel('像素数')
plt.title('灰度直方图(Matplotlib)')
plt.show()

这个方法更简洁,但底层也是先统计再绘图。我个人习惯用 calcHist,因为后续做直方图均衡化时可以直接复用统计结果。

方法三:彩色图像的三通道直方图

import cv2
import matplotlib.pyplot as plt

img = cv2.imread('example.jpg')
colors = ('b', 'g', 'r')

for i, color in enumerate(colors):
    hist = cv2.calcHist([img], [i], None, [256], [0, 256])
    plt.plot(hist, color=color, label=f'通道 {color}')

plt.xlabel('灰度级')
plt.ylabel('像素数')
plt.legend()
plt.title('彩色图像三通道直方图')
plt.show()
我曾经踩过的坑:OpenCV 读取彩色图像默认是 BGR 顺序,不是 RGB。画直方图时通道索引和颜色名称一定要对应好,否则画出来的红蓝通道是反的。我刚开始就因为这个查了半天 bug。

直方图归一化:让统计结果可比较

不同尺寸的图像,像素总数不一样。直接比较频数没有意义。比如一张 100×100 的图和一张 1000×1000 的图,同样灰度 128 出现 500 次,前者占比 5%,后者只有 0.05%。

归一化就是把频数转换成概率密度,公式很简单:

p(rk) = nk / N

其中 nk 是灰度级 rk 的像素数,N 是总像素数。

OpenCV 里归一化直方图有两种方式:

import cv2
import numpy as np

img = cv2.imread('example.jpg', cv2.IMREAD_GRAYSCALE)
hist = cv2.calcHist([img], [0], None, [256], [0, 256])

# 方式一:手动归一化
hist_norm = hist / (img.shape[0] * img.shape[1])

# 方式二:用 normalize 函数
hist_cv_norm = cv2.normalize(hist, hist, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX)

注意这两种方式的区别:

方式含义适用场景
手动归一化(除以 N)概率密度,总和为 1概率分析、熵计算
cv2.normalize(NORM_MINMAX)线性缩放到 [0,1] 区间可视化对比、特征归一化
核心理解:归一化后的直方图,纵轴不再是「多少个像素」,而是「每个灰度级出现的概率」。这样不同尺寸的图像之间才能公平比较。

本章知识体系

下面这张图把直方图计算与可视化的核心流程串起来了:

直方图计算与可视化核心流程 输入图像 统计每个灰度级的像素频数 可视化:绘制直方图 归一化:频数 → 概率 OpenCV calcHist / Matplotlib hist 手动归一化 / cv2.normalize 归一化后的直方图纵轴为概率密度,不同尺寸图像可直接比较

从输入图像开始,先统计每个灰度级的像素频数,然后分两条路走:一条是直接可视化绘制直方图,另一条是归一化处理。归一化后的结果既可以用于概率分析,也可以作为后续直方图均衡化、匹配等增强技术的输入。

避坑指南:我曾经在对比两张不同分辨率的图像时,直接比较原始频数直方图,结果完全看不出规律。后来归一化后再看,两张图的分布形态其实非常相似。记住:归一化是直方图分析的「标准动作」,不是可选项。