ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

直方图运算全解析:从均衡化到规定化、阈值分割与图像匹配

直方图运算全解析:从均衡化到规定化、阈值分割与图像匹配

数字图像处理这门课,我在书桌前啃过好几遍。每次读到直方图运算这一章,都免不了先冒出一个念头:这还不简单?不就是把图像里每个灰度值出现的次数统计出来,画一张柱状图嘛。真正让我改变看法,是第一次处理一批航拍农田照片的时候。那些图整体偏灰、对比度极低,我用直方图均衡化跑了一遍,效果立竿见影;可同一套流程换到另一组夜景监控画面,结果却出现了大片灰白断层。从那时起我才明白,直方图运算里的每一行推导、每一个参数,都不像表面看起来那么人畜无害。这篇笔记是系列里的第九篇,专门梳理直方图运算从定义、计算到均衡化、规定化,再延伸至阈值分割与图像匹配的完整脉络。适合正在啃图像处理教材的学生、刚接触图像处理库的工程师,以及所有想进一步搞懂“图像增强到底在增强什么”的朋友。

1. 直方图到底是什么:从一张灰度图说起

1.1 直方图的形式定义:一个离散函数

对于一幅灰度图像,假设灰度级范围是 [0, L-1],L 通常取 256,那么直方图可以定义为一个离散函数:

h(r_k) = n_k

其中 r_k 是第 k 个灰度级,n_k 是整幅图像中灰度值等于 r_k 的像素个数。直接把 n_k 画出来,就是常见的直方图柱子图。如果把每个计数除以总像素数 M×N,得到:

p(r_k) = n_k / (M × N)

这就是归一化直方图,它近似等于该灰度级出现的概率。p 的所有值加起来等于 1,本质上是在描述一幅图像中像素取值的概率分布。

直方图有一个非常直观但容易被忽略的性质:它只记录像素取值的“数量”,完全丢掉空间位置信息。两张空间结构完全不同的图像,完全可能拥有几乎一模一样的直方图。这就像一个电商平台的销量分布报表——你只知道哪个价位段的商品卖得多,但根本不知道是哪个店铺、哪个牌子卖的。理解这一点,对后面使用直方图做图像匹配、做质量评估都很重要。

1.2 从零手写直方图计算与绘制

我习惯先把公式手写一遍,再调库。这里给出一段可以直接运行的核心代码:

import numpy as np import matplotlib.pyplot as plt from PIL import Image # 读取灰度图 img = Image.open('demo_image.png').convert('L') arr = np.array(img) # 方法1:用 bincount 统计每个灰度级出现次数 hist = np.bincount(arr.flatten(), minlength=256) # 方法2:用 np.histogram,注意返回 bins 边缘 hist2, bin_edges = np.histogram(arr, bins=256, range=(0, 255)) print(hist[:5], hist2[:5]) # 两者几乎一致 # 绘制 plt.figure(figsize=(8, 4)) plt.bar(np.arange(256), hist, width=1.0) plt.title("Grayscale Histogram") plt.xlabel("Gray level") plt.ylabel("Pixel count") plt.xlim([0, 255]) plt.show()

这段代码本身没什么难度,但有两个细节我想啰嗦一下。第一,np.bincount要带上minlength=256,否则如果图像最大灰度值不够大,返回数组长度会不够,后续做累积分布时下标容易出错。第二,plt.hist默认会把灰度值分到 10 个或若干个 bins 里,画出来的形状和真正的 256 级直方图差别挺大,所以要看单灰度级的分布,最好用plt.bar配合 256 个柱子,横轴密度高一点也不怕,柱子宽度设为 1 即可。

第一次跑完,看到直方图那一堆高高低低的柱子,其实感知还不够强。真正有用的是把直方图和图像放在一起观察,有些东西一眼就能读出来。

1.3 从直方图形状读出图像质量

读直方图是图像处理里特别值钱的经验。我总结过几种常见形态:

  • 直方图整体集中在低灰度区,比如峰值落在 10-50 之间,说明图像整体偏暗,而且动态范围小,很多暗部细节实际上挤在一个很窄的灰度区间里。
  • 集中在高灰度区,峰值落在 200-250 附近,图像偏亮甚至过曝,同理缺少中间调。
  • 直方图几乎覆盖 0 到 255,但中间有肉眼可见的凹谷,说明图像有明确的明暗层次,通常看起来比较通透。
  • 出现两个明显峰,中间隔着低谷,典型的“双峰直方图”,前景和背景分布清晰,适合做阈值分割。

我拿一张测试图实验时,第一眼先看直方图,基本能猜到图像大概什么观感。偏灰的图,直方图一定像一根窄柱子杵在中间区域,既没有暗部也没有高光。这样的图,正好撞到了直方图均衡化的枪口上。

2. 直方图均衡化:把“灰蒙蒙”的图拉开层次

2.1 “灰度集中在窄区间”是低对比度的源头

低对比度图像的本质,是像素灰度值挤在一个狭窄的范围内。比如一幅灰度分布集中在 80 到 140 之间的图,视觉上就是灰蒙蒙一片,亮的也不够亮,暗的也不够暗。我们想要的,是把这堆挤在一起的灰度值,尽量拉伸到整个 0 到 255 的区间里,让亮的地方更亮、暗的地方更暗、层次更分明。

直方图均衡化的核心思路不是简单做一个线性拉伸,而是按照像素出现概率来重新分配灰度级。它要对出现概率高的灰度级分配更大的灰度区间,对出现概率低的灰度级则压缩甚至合并。这样做的直觉是:把视觉敏感、像素多的区域充分拉开,把像素稀少的区域适当牺牲掉。

2.2 均衡化公式其实是累计分布曲线

连续形式下,均衡化要找一个灰度变换函数 s = T(r),使得输出图像灰度 s 的概率密度是均匀的。数学推导可以这样理解:如果我们定义

s = (L-1) ∫₀^r p_r(w) dw

也就是从 0 到当前灰度级 r 的累计概率,乘以最大灰度值 L-1,那么变换后新变量的概率密度正好会变得均匀。这里的 T(r) 就是输入灰度级的累计分布函数 CDF。

离散图像里,积分变成求和:

s_k = round( (L-1) * ∑_{j=0}^{k} p_r(r_j) )

其中 ∑ p_r(r_j) 就是前 k 个灰度级的累计概率,也就是 CDF。每个输入灰度级 r_k 都能通过这张查找表映射到新的输出灰度级 s_k。

我们拿一个极简的例子手算一遍就通了。假设图像灰度只有 0、1、2 三级,像素数分别为 3、0、1。总像素数 4,概率分别是 0.75、0、0.25。CDF 为 0.75、0.75、1.0。乘以 2(L-1=2)再取整:

  • 灰度 0 → 2 × 0.75 = 1.5 → 取整为 2
  • 灰度 1 → 2 × 0.75 = 1.5 → 取整为 2
  • 灰度 2 → 2 × 1.0 = 2.0 → 取整为 2

结果所有像素都被映射到灰度 2。这个例子虽然极端,但很能说明问题:CDF 跳变越快,拉伸越剧烈;确认单调递增的目的是保证变换后灰度顺序不反转,否则图像会变成“负片”效果,原本亮的地方反而变暗。

2.3 自己实现一遍均衡化,再和库函数对答案

我强烈建议手写一遍再调库。自己的实现即使性能一般,也能让你彻底搞懂查找表机制。以下是我常用的手写版本:

def equalize_manual(img_arr): gray_levels = 256 hist = np.bincount(img_arr.flatten(), minlength=gray_levels) cdf = np.cumsum(hist) cdf_norm = cdf / cdf[-1] lut = np.round(cdf_norm * (gray_levels - 1)).astype(np.uint8) return lut[img_arr]

这里lut是一张长度为 256 的查找表,lut[k]存放原灰度 k 映射后的新灰度。最后lut[img_arr]是用 numpy 做查表,等于把整幅图像快速映射了一遍。

再用库函数跑一遍:

import cv2 equalized_cv = cv2.equalizeHist(arr)

对比两张输出图像和它们的直方图,通常会发现:手写版本和库函数结果非常接近,但在暗部细节上可能略有差别。原因是部分实现为了减少纯黑像素对整体灰度的影响,会额外减去累积直方图中的最小值。差别一般很小,肉眼不易察觉,但如果你在做科研或者需要严格复现论文结果,一定要明确自己用的是哪种定义。

实测下来,均衡化对“灰蒙蒙”图像的效果确实立竿见影。我之前拿一张对比度极低、直方图聚集在 90-120 区间的模拟图像做实验,均衡化后灰度基本铺满 0-250,暗部亮部都重新出现细节了。不过代价也很明显:如果原图存在大量相同灰度像素,均衡化会让这些区域被过度拉伸,出现“颗粒感”或“断层感”。

2.4 彩色图像的均衡化别直接三个通道一起做

很多人第一次处理彩色图像时,会顺手把 R、G、B 三个通道分别做一遍均衡化。我确实也这么干过,结果图像色彩变得极其刺眼,像胶片冲洗过度,色偏严重。原因是三个通道的累计分布各不相同,分别映射后,原本的色相关系被破坏了。

更稳妥的做法是先把图像从 RGB 空间转到 HSV 或 HSI 空间,只对亮度通道(V 或 I)做均衡化,保持色相和饱和度不变。比如 OpenCV 里:

hsv = cv2.cvtColor(img_color, cv2.COLOR_BGR2HSV) h, s, v = cv2.split(hsv) v_eq = cv2.equalizeHist(v) hsv_eq = cv2.merge([h, s, v_eq]) img_eq = cv2.cvtColor(hsv_eq, cv2.COLOR_HSV2BGR)

这样处理完,图像的明暗对比度明显提升,颜色却依然自然。这也是实际项目中更通用的做法。我自己的习惯是,在处理彩色图前先问一句:我到底想增强什么?如果只是希望亮度均匀、对比鲜明,那直接处理亮度通道就够了;如果想调整色彩表现,那才需要单独处理颜色通道。

3. 直方图规定化:给直方图装上“目标模板”

3.1 为什么均衡化不一定满足需求

均衡化有一种“自动、盲目”的感觉——它不管原图像的具体特点,一律把直方图往均匀方向推。但现实中,均匀分布不总是最优解。比如监控图像经常整体偏亮,我们希望输出直方图能更靠近某个“理想分布”;再比如医疗影像里,某个特定器官的灰度分布有先验规律,我们希望增强后的图尽量符合这个规律,而不是纯粹均匀展开。

直方图规定化(也叫直方图匹配)解决的就是这类问题:给定输入图像直方图 p_r,以及一个目标直方图 p_z,希望找到一个灰度变换,让输出图像的直方图尽可能接近 p_z。

规定化背后的数学逻辑非常漂亮:既然均衡化能强制输出均匀分布,那不如借助这个“中间站”来找映射关系。先把输入直方图均衡化,得到变换 s = T(r);再把目标直方图也均衡化,得到变换 v = G(z)。如果让 s 等于 v,也就是让输入均衡化后的灰度与目标均衡化后的灰度对齐,那么目标灰度 z 就等于 G⁻¹(s) = G⁻¹(T(r))。核心就是通过均衡化作为桥梁,建立输入灰度和目标灰度之间的一一映射。

3.2 SML与GML:两种映射策略的区别

离散场景下,灰度级就 256 个,s 和 v 很难做到一一精确相等,通常需要近似匹配。这里有两种经典策略。

单映射规则 Single Mapping Law,SML:对输入均衡化后的每一个灰度 s_k,在目标均衡化序列 G(z_j) 中寻找灰度值最接近的那一项,把目标灰度 z_j 作为映射结果。这种方法实现简单,计算量小,但某些输入灰度级可能找不到“落点”,或者多个输入灰度级映射到同一个输出灰度,造成一定误差。

组映射规则 Group Mapping Law,GML:它把输入灰度序列按累计误差最小的方式分段,让每一段输入灰度整体映射到一个输出灰度,目标是让最终的累计直方图误差尽量小。GML 的匹配误差通常比 SML 小,输出直方图更接近目标分布,但代码写起来要复杂一点。

如果只是做学习实验,SML 完全够用。真正做项目时,如果对匹配精度有要求,我更倾向用组映射。曾经有位前辈与我交流时提到过,他自己写规定化代码时,就是趁 SML 在峰谷处不如 GML 贴合,才换用的组映射,可见两种方法的差距主要暴露在细节上。

3.3 一组可复现的规定化代码

SML 版本的实现并不复杂,核心就是为每个输入灰度找“最接近的目标 CDF 灰度级”:

def hist_specify(src_arr, target_hist): gray_levels = 256 src_hist = np.bincount(src_arr.flatten(), minlength=gray_levels) src_cdf = np.cumsum(src_hist) / src_hist.sum() target_hist = target_hist.astype(np.float64) target_cdf = np.cumsum(target_hist) / target_hist.sum() lut = np.zeros(gray_levels, dtype=np.uint8) for i in range(gray_levels): diff = np.abs(target_cdf - src_cdf[i]) lut[i] = np.argmin(diff) return lut[src_arr]

使用时,你需要构造一个目标直方图。常见做法是用一个高斯形状的分布作为目标:

x = np.arange(256) target = np.exp(-((x - 128) ** 2) / (2 * 50 ** 2)) target = target / target.sum() * 255 out = hist_specify(arr, target)

跑完以后,对比输出图像的直方图和目标分布,肉眼可见整体形状被拉向目标曲线。我实测的体会是:如果目标分布设计得比较宽,输出直方图能较好地贴合;如果目标分布很窄、峰值很高,那么直方图会被强行压缩到一个小范围内,图像反而可能失去层次。

3.4 规定化的目标直方图怎么选

这是规定化最容易踩坑的地方。目标直方图决定输出图像的“气质”,不能随便挑。结合我自己的经验,总结几个原则:

  1. 目标分布的灰度范围要合理。如果目标分布集中在 10-20 这种暗部,结果图会整体偏暗;集中在 200-240 则过曝。一般我会把峰控制在直方图中间区域,比如 120-150 左右。
  2. 分布宽度不能太窄。目标分布的标准差如果太小,相当于把所有灰度都往一个窄区间里塞,丢失信息。
  3. 如果目标分布是离散的,要先统一单位。别忘了目标直方图也要做归一化,否则累计概率的大小和真实概率不一致,映射会整体偏移。

在实际项目里,我更常用的一种做法是:先统计一批“理想结果图”的平均直方图,用它作为后续处理的标准模板。这样比手工构造高斯分布更贴合真实场景,也更容易在结果上说服人。

4. 直方图的进阶玩法:阈值分割与图像检索

4.1 双峰直方图与Otsu自动阈值分割

直方图最经典的应用之一,是帮助寻找分割阈值。如果图像的前景和背景灰度差异明显,直方图会出现两个峰,峰之间的低谷就是划分前景背景的天然边界。但人工选低谷很累,而且图像一多就不现实,所以就有了自动阈值算法。

我使用最多的是大津法,也就是 Otsu 方法。它的思路是:对于某一个候选阈值 t,把图像像素分成两类 C0(灰度小于等于 t)和 C1(灰度大于 t),分别计算两类的像素占比 ω0、ω1 和灰度均值 μ0、μ1,再计算类间方差:

σ_B² = ω0 × ω1 × (μ0 - μ1)²

遍历所有灰度级,选择使 σ_B² 最大的阈值。类间方差越大,说明两类被分得越开,分割效果越好。Otsu 不必知道前景背景的先验概率,只凭直方图就能自动算出阈值,所以非常实用。

手写一个简单版本也不难:

def otsu_threshold(hist): total = hist.sum() sum_total = np.dot(np.arange(256), hist) sum_b = 0 w_b = 0 best_thresh = 0 max_var = 0 for t in range(256): w_b += hist[t] if w_b == 0: continue w_f = total - w_b if w_f == 0: break sum_b += t * hist[t] mean_b = sum_b / w_b mean_f = (sum_total - sum_b) / w_f var_between = w_b * w_f * (mean_b - mean_f) ** 2 if var_between > max_var: max_var = var_between best_thresh = t return best_thresh

实测试验中,Otsu 对双峰明显的图像接近完美分割,但对直方图单峰或者严重不平衡的情况效果不稳定。有一类“目标特别小”的图像,比如一大片农田里寻找几个小水塘,前景像素占比极低,Otsu 往往会偏向把大多数背景归为一类,阈值偏移明显。这时候不能盲信 Otsu,灵活根据直方图形态调整才是正道。

4.2 直方图距离度量与图像检索

直方图还可以当作图像的一种全局特征,用来衡量两张图在灰度分布上的相似程度。常见的度量有:

  • 相关度量:类似向量点积或相关系数,值越大越相似。
  • 卡方距离:χ² = Σ (p_i - q_i)² / (p_i + q_i) 重点惩罚差异大的灰度级。
  • 巴氏距离:d = -ln( Σ √(p_i × q_i) ),用概率分布重叠程度衡量相似度。
  • 直方图相交:H = Σ min(p_i, q_i),值越接近 1 越相似。

用图像检索场景举例:我准备了一个小型模拟图像库,每张图都计算 256 维灰度直方图,然后给定一张查询图,计算它和库中每张图的巴氏距离,排序后取距离最小的几张。实验下来,对于灰度分布差异明显的图,这个方法非常有效;但如果图像内容不同但整体灰度分布相似(比如一张白天的天空图和一张雪地俯视图),直方图匹配就会产生误判。

直方图检索的本质是把图像退化成“灰度分布向量”,丢掉了空间信息。所以它适合做粗粒度场景过滤、内容查重,不适合用于精细的目标识别。做检索系统时,我通常会把直方图作为第一层粗筛,后面再接更精细的特征。

4.3 用直方图统计量评估曝光与对比度

直方图不仅能增强、分割、匹配,还能反过来当图像质量的“体检报告”。通过计算直方图上的一些统计量,可以大致判断出图像是否存在曝光问题。

比如直方图均值能反映整体亮度,标准差能反映对比度;直方图中灰度 0 附近的像素占比过高,说明暗部被“压死”了;灰度 255 附近像素占比太多,说明高光溢出。我习惯定义一个简易指标:

dark_ratio = hist[:10].sum() / hist.sum() bright_ratio = hist[245:].sum() / hist.sum()

如果dark_ratio超过 10%,这张图大概率有死黑区域;bright_ratio超过 10%,则可能过曝。我经常用这套思路在批量处理图片时做前置检查,先把问题严重的图筛出来,再进行针对性增强,比一张张看效果省时太多。这个思路后来被我扩展成一个简单的自动判断流程:先看直方图统计量,再决定是否走均衡化,还是只做局部亮度修正。

5. 直方图运算容易踩的坑与我的处理习惯

5.1 均衡化后灰度级被压缩,容易出现伪轮廓

均衡化是一把双刃剑。像素概率高的灰度级被大幅拉伸,概率低的灰度级会被合并,导致输出图像的灰度级数量减少。最典型的就是天空渐变图,原图的灰度过渡非常平滑,直方图上每个灰度级都有一点像素。均衡化后,部分中间灰阶被吞并,天空出现明显的带状断层,也就是伪轮廓。

我踩过这个坑之后,养成了一个习惯:处理自然风景类图像时,均衡化前先看一眼直方图是否平滑;如果灰度级覆盖已经比较均匀,我根本不会用全局均衡化,而是改用细节保留更好的局部直方图方法。均衡化不是越狠越好,适当削峰填谷才是关键。

5.2 暗部细节被“吃掉”的陷阱

当图像有大量像素集中在 0 灰度附近时,CDF 从 0 到第一个非零灰度会有一个剧烈跳变。均衡化会把这一小段差距映射成很大的灰度差,导致暗部原本仅有的一点层次被瞬间拉出巨大对比,亮部反而被迫压缩。换句话说,本来看得清的暗部细节,均衡化之后可能变成一团黑块。

解决思路有两个:一是对直方图做裁剪,比如先把灰度 0-10 的计数限制在某个上限;二是做灰度级映射时保留一个偏移量,避免暗部被过度拉伸。我经常在直方图映射前给累计概率加一个很小的平滑项,比如cdf = (cdf - cdf.min()) / (cdf.max() - cdf.min())配合下限控制,实测对暗部保护有一定帮助。

5.3 库函数和手写版本的结果差异

不同工具库的直方图运算口径并不完全一致,这个坑很隐蔽。比如 OpenCV 的calcHist默认统计的是固定 bins 的直方图,而equalizeHist又要求输入必须是 8 位单通道灰度图,多通道图会直接报错;matplotlib.pyplot.hist默认的 bins 数量和直方图形态会随数据范围变化。如果你拿手写直方图和网上博客里的直方图直接对比,很可能会因为统计口径问题对不上。

更值得小心的是,有些库对非零直方图最小值有特殊处理,导致均衡化结果和教科书公式有细微差异。做实验对比时,我会明确标注自己用的是哪种定义,否则在论文复现环节会吃大亏。毕竟直方图运算是整套图像处理的“公共标准”,口径统一非常重要。

5.4 16位图像与多通道图像的隐患

直方图运算的默认假设是 8 位灰度图,灰度级 256。一旦把 16 位医学影像或 RAW 格式图直接塞进某些函数,事情就麻烦了。原因很简单:16 位图灰度范围是 0-65535,如果按 0-255 去统计,大量细节会被压缩在低位上白白浪费。遇到这类输入,我一般先做灰度范围配准或量化到 256 级,再执行直方图运算。

另外,前面提到过彩色图不要直接三个通道分别均衡化。同样的道理放在直方图匹配上也成立:对 RGB 三通道分别做规定化,会出现难以预测的色偏。我现在的处理流程是统一的:先转 HSV,再操作 V 通道。不管用什么工具,这条原则都不会变。

5.5 我的几个实操小习惯

最后分享几个我自己长期积累下来的处理习惯。第一,看到一张新图,第一件事不是直接做增强,而是先输出直方图,观察灰度分布区间、峰谷位置、暗亮部占比,再决定下一步。第二,做均衡化之前,先备份原图,方便对比;增强操作不可逆,实测中试来试去最后还得靠原图重新来。第三,用直方图做质量判断时,不要只看均值或标准差,一定要结合暗部和亮部的比例,否则很容易被大背景的亮度掩盖掉局部的过曝。

有一次我在深夜处理一批模拟无人机巡检拍到的低照度图,眼看直方图暗部集中在 10-30 附近,我直接用均衡化,结果山路纹理全被拉成黑色块。后来改成先用直方图裁剪去掉少数极端点,再做灰度映射,才算保住细节。那次之后,我就把“先看直方图再动手”写成了自己的铁律。直方图运算看着基本面,可一旦用错场景,翻车速度同样飞快。这套逻辑后面还能继续延伸,比如局部直方图均衡化和对比度受限的自适应直方图均衡化,这些技术都建立在今天聊的全局直方图基础上。先把地基打牢,后续扩展才不慌。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进