图像分割是我读《图像处理、分析与机器视觉(第四版)》时最有“这页很简单”错觉的内容,尤其是阈值化。原因很简单——公式就一句:像素大于阈值就置为前景,小于等于阈值就置为背景。可真正上手做分割项目之后我才发现,阈值选多少、怎么选、选完之后二值图怎么用,每一步背后都藏着细节。这篇笔记把我在这一章反复推敲的东西整理出来:全局阈值、直方图、大津法、自适应阈值,以及我和二值化结果“搏斗”时踩过的坑。
这篇内容适合刚学图像分割的初学者,也适合那些已经会用threshold和adaptiveThreshold但从没深究过原理的工程师。阈值化不会替代任何深度学习模型,但它是理解分割问题的起点——几乎所有分割思路都能归结为“给每个像素分配一个类别标签”,阈值化只是这个决策过程的最简版本。
1. 为什么我在分割系列里先写阈值化:它是最简单但也最容易用错的分割
1.1 阈值化的数学表达
先复习一下教材上的标准形式。设输入灰度图像为 (f(x,y)),灰度级范围是 ([0, L-1]),选一个阈值 (T),输出二值图 (g(x,y)) 可以写为:
[ g(x,y) = \begin{cases} 1, & f(x,y) \ge T \ 0, & f(x,y) < T \end{cases} ]
1 表示我们关心的目标,0 表示背景;如果你不是提取亮目标而是提取暗目标,把比较方向反过来就行。这个式子把一个连续灰度空间上的问题变成了一个“分类问题”:像素只有两类,决策规则是一维的——灰度值够不够大。
我习惯把阈值想象成一个班上的录取分数线:像素是学生,灰度是考试成绩,过了线就进目标专业,没过线就留在背景学院。问题于是从“要不要分割”变成了“这条分数线的位置怎么定”。这是整章一切讨论的核心。
1.2 阈值化在分割工具箱里的位置
《图像处理、分析与机器视觉(第四版)》的图像分割章节,并不是只讲阈值化,它还把边缘检测、区域生长、分水岭等串在一起。我们先看到的总是阈值化,因为这个方法有三个无法替代的优点:
- 算得快:一次遍历所有像素就能完成,O(N) 的复杂度,实时系统特别友好。
- 可解释:阈值就是一个数,谁都能理解;出错也容易定位,不像某些黑盒方法。
- 是很多高级方法的起点:分水岭分割需要标记前景区域,OCR 需要先把文本变成白底黑字,工业缺陷检测经常先做阈值化再算连通域——这些都得从二值图开始。
很多学习者在听完这些之后会觉得“阈值化就是最简单那个,随便用用就行”,实际恰恰相反。我见过很多翻车项目,一张不均匀光照下的照片,直接拿全局阈值硬切,结果背景一团黑斑。阈值化的门槛低,但它的前提条件非常苛刻。
1.3 适合阈值化的场景长什么样
根据教材的描述和我自己的实践,我总结出三个“适合用阈值化”的前提:
- 目标和背景的灰度分布重叠尽可能小,直方图上最好能明显看到两个峰。
- 光照条件均匀,背景灰度在整幅图中相对稳定。
- 不需要太精细的形状精度,只要大致分离目标区域即可。
如果场景满足这三条,阈值化是最佳选择;如果有一条不满足,比如光照从左到右衰减,那就要往下读,考虑自适应阈值或者前处理校正。
2. 全局阈值的基本功:读直方图、找谷底、判断适不适合
2.1 双峰直方图和谷底
全局阈值的经典选法,是把图像灰度直方图画出来,找两个峰之间的谷底。为什么是谷底?因为如果目标和背景各自近似服从正态分布,那么两个分布交界处就是误分类概率最低的位置。换句话说,谷底附近灰度值的像素本身就“身份不明”,把它们归到哪一边,错误代价都最小。
举个例子,一张白纸黑字的扫描件,直方图上通常在 0 附近有一个代表黑色文字的峰,在 240 附近有一个代表白色纸张的峰,中间凹下去的位置比如 120 就是很自然的阈值。选 100 也行,选 130 差别也不大,因为谷底那段像素本来就少,阈值稍微移动对结果影响有限。
这一点上手很简单,但很多人不画直方图就直接拍脑袋定 128。128 这个数没有错,但它没有任何信息量——应该去看数据长什么样再定。
2.2 直方图先平滑再找谷底
实际图像的直方图并不总是教科书里那种光滑的双峰曲线,它往往像一把锯齿,一个又一个局部极小值。这种情况下直接找谷底,会找到一堆没有意义的“假谷”。我通常的做法是先对直方图做一次高斯平滑:
import numpy as np def smooth_histogram(hist, sigma=3.0): # 简单的一维高斯平滑 kernel_size = int(sigma * 6) | 1 kernel = cv2.getGaussianKernel(kernel_size, sigma) return np.convolve(hist, kernel.ravel(), mode='same')平滑之后,只保留那些“左右邻域内都是最小值”的点:
def find_valleys(smooth_hist): valleys = [] for t in range(1, len(smooth_hist) - 1): if smooth_hist[t] < smooth_hist[t-1] and smooth_hist[t] < smooth_hist[t+1]: valleys.append(t) return valleys这个方法足够简单,能帮我们从微观抖动中找出真正的谷底。当然,如果直方图没有双峰或不平滑,上面这些代码也就没意义了——所以我每次都先打印直方图,再决定要不要继续走全局阈值这条路。
2.3 全局阈值的适用边界
全局阈值看起来很直观,但有几个场景会让它直接失效:
- 类别不均衡:目标只占整幅图像的 0.1% 时,那个小峰可能在直方图里根本看不见,谷底无从谈起。
- 灰度重叠严重:目标和背景本身灰度范围大量交织,比如雾天拍车牌,任何一条竖向直线都不能把两类分干净。
- 光照不均匀:背景本身不是常数,左边亮右边暗,那么左边背景的灰度可能超过右边目标的灰度,一个全局阈值无论如何都救不回来。
遇到这三种情况,不要硬上全局阈值,要么改用局部自适应方法,要么先做光照校正。这也是我写这篇笔记最想强调的一点:读直方图不是为了完成任务,而是为了判断“这张图到底能不能用阈值化来分割”。
3. 大津法的笔记:从公式推导到三十行代码复现
3.1 它到底在优化什么
靠眼睛找谷底虽然可行,但不能自动化,也没法量化“这个阈值比另一个阈值好多少”。教材里给出了一个广为人知的自动选阈值方法——大津法(Otsu)。它的核心思想不是去拟合双峰,而是找一个阈值,使得分割出来的两类“类间差异”尽可能大。
问题来了:什么叫“类间差异大”?大津法把它定义为两类均值之间的加权距离。设阈值 (t) 把像素分成背景类 (C_0) 和目标类 (C_1),两类的像素占比分别是 (\omega_0(t)) 和 (\omega_1(t)),灰度均值分别是 (\mu_0(t)) 和 (\mu_1(t)),那么类间方差定义为:
[ \sigma_B^2(t) = \omega_0(t) \cdot \omega_1(t) \cdot [\mu_0(t) - \mu_1(t)]^2 ]
我们要做的是遍历所有 (t),找到让 (\sigma_B^2(t)) 最大的那个阈值。这个公式里有两部分信息:均值差 ([\mu_0(t)-\mu_1(t)]^2) 度量两类平均灰度隔得多远,(\omega_0(t)\omega_1(t)) 则是一个权重——当两类像素数量越接近时,这个乘积越大。整体含义是:不仅要让两类均值分开,还要让它们都不是“少数人”,否则哪怕均值差再大,也说明你只是抠出了一小撮极端像素,这种分割没有意义。
3.2 公式推导与三十行实现
有了上面的定义,实现就顺理成章了。设直方图为 (h(i)),总像素数为 (N),灰度 (i) 出现的概率记为 (p_i = h(i)/N)。对阈值 (t):
[ \omega_0(t) = \sum_{i=0}^{t} p_i, \quad \omega_1(t) = 1 - \omega_0(t) ]
[ \mu_0(t) = \frac{\sum_{i=0}^{t} i \cdot p_i}{\omega_0(t)}, \quad \mu_1(t) = \frac{\sum_{i=t+1}^{L-1} i \cdot p_i}{\omega_1(t)} ]
为了在线性时间内完成扫描,可以维护累计像素数cnt_b和累计灰度值sum_b,这样每个阈值只做常数级运算:
def otsu_threshold(gray): hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).ravel() total = gray.size sum_all = np.dot(np.arange(256), hist) cnt_b = 0.0 sum_b = 0.0 max_var = -1.0 best_t = 0 for t in range(256): cnt_b += hist[t] if cnt_b == 0: continue cnt_f = total - cnt_b if cnt_f == 0: break sum_b += t * hist[t] mean_b = sum_b / cnt_b mean_f = (sum_all - sum_b) / cnt_f var_between = cnt_b / total * cnt_f / total * (mean_b - mean_f) ** 2 if var_between > max_var: max_var = var_between best_t = t return best_t这段代码里,cnt_b / total * cnt_f / total就是 (\omega_0 \omega_1)。很多地方的实现里省去这个归一化因子,因为对所有 (t) 来说分母一样,不影响找最大值,但我还是会把完整形式写进去,方便对照公式理解。
3.3 大津法的边界情况与失败模式
大津法不是银弹。我自己跑实验时最常遇到两类失败:
第一类是直方图单峰。如果图像中背景很干净、目标很大,或者噪声把灰度分布拉得很难看,直方图上根本看不到两个峰,大津法也会硬生生找一个阈值出来,结果往往是把同一类目标切成了两半,或者把背景的某个灰度带单独抠出来。遇到单峰直方图,应该换思路,而不是继续调阈值。
第二类是目标占比极小的缺陷检测场景。假设要检测一块金属表面上的细微划痕,划痕只占 0.3% 像素,那么 (\omega) 和 (1-\omega) 极端不平衡,( \omega_0\omega_1) 会很接近 0,大津法为了避开这种不平衡,会把阈值偏向“把大背景完整保留、牺牲小目标”的方向。结果是划痕几乎全被滤掉。
所以我的使用原则是:大津法适合目标和背景像素比例不悬殊、灰度分布大致呈双峰的情况;它适合自动得到一个“不差的阈值”,但不保证结果符合人的视觉期望。把它和 OpenCV 内置的threshold(gray, 0, 255, THRESH_BINARY + THRESH_OTSU)对照过,两者输出一致,但我自己实现的版本更容易调试,也更容易二次扩展。
4. 光照不均匀会让全局阈值直接失效:自适应阈值与光照校正
4.1 全局阈值的隐藏假设
全局阈值所有方法都默认了一个隐藏假设:图像里的背景灰度是一个常数。等式 (g = f \ge T) 里并没有“局部”概念,如果左侧墙面的亮度是 200,右侧是 100,一个全局阈值根本无法同时处理两边。
我最早在实拍文档照片上栽过跟头。纸面中间平整,边缘因为弯曲或阴影变暗,全局阈值后暗边文字全灭背景还被误判。后来才明白,这类问题应该当作“背景光照不是常数”来处理,而不是简单把阈值调低。
4.2 自适应阈值:每个像素一个局部阈值
自适应阈值的思路很直接:不要用一个全局 (T),而是在每个像素周围的邻域里,根据局部灰度统计量估计出该位置的分割线。OpenCV 的adaptiveThreshold就是典型实现:
th = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, # blockSize,必须为正奇数 10 # C,常数减量 )参数只有两个:blockSize和C。blockSize是计算局部阈值用的邻域尺寸,C是一个偏移量。实际判断是:
[ g(x,y) = 1 \quad \text{if} \quad f(x,y) > \text{neighborhoodMean}(x,y) - C ]
这个判断的意思是:当前像素只要比它附近背景的平均亮度高出一小截,就视为目标。加C是为了抑制噪声,避免原本凹凸不平的表面上大量像素因为微小的灰度波动而乱跳。
调参经验上,首先blockSize要比目标最大尺寸大,最好大到目标内部也能包含足够多的背景像素,否则阈值会被目标自身带偏;C一般从 10 附近开始尝试,增大则目标更“紧”、噪点更少,增大太多会把弱边缘一同吞掉。不同图像理性值变化很大,记录参数并对比直方图是我常用的调试方式。
4.3 形态学背景估计与顶帽变换
自适应阈值并不是唯一出路。另一种我常用的做法是“光照校正 + 全局阈值”:先估计背景光照图,再从原图里把它除掉。
具体操作是:对灰度图做一个大结构元素的形态学开运算,比如cv2.morphologyEx(gray, MORPH_OPEN, kernel)。开运算先腐蚀再膨胀,能去掉小于结构元素的亮目标,剩下的就是“没有目标、只有背景”的估计图。将这个背景图从原图减去,再做一次全局阈值,就能把光源不均匀造成的低频变化抹掉。
如果目标是亮区域、背景是暗区域,还有一个更经典的做法:顶帽变换(Top-hat)。形态学顶帽的结果是原图减去开运算结果,相当于“专门提取比背景更亮的小块”,同时滤掉低频背景变化。我也用它在显微图像里提取细胞这一类亮目标,效果比直接全局阈值稳定得多。
这四种方法放在一起,可以直观对比:
| 方法 | 核心思想 | 适用场景 | 主要局限 |
|---|---|---|---|
| 固定阈值 | 一个全局 (T) | 光照均匀、双峰明显 | 对光照和分布变化极其敏感 |
| 大津法 | 自动最大化类间方差 | 双峰自动分割 | 单峰、类别极端不均衡时失效 |
| 自适应阈值 | 局部统计量作局部阈值 | 光照不均匀的文档、背景慢变 | 参数不好调,块大小敏感 |
| 形态学前处理 + 全局阈值 | 估计背景并扣除 | 目标亮度与背景差异稳定 | 结构元素尺寸影响大 |
4.4 为什么前处理比阈值本身更重要
当全局阈值和自适应阈值都能用时,我会先做一个低成本实验:画直方图,看背景峰是否偏移;如果不偏移,就用大津法;如果偏移,就用自适应或开运算背景扣除。很多教程会把这两类方法并列讲,但在实际项目里,判断“这张图该走哪条路”比“阈值调成几”重要得多——预处理和选阈值不是割裂的两步,而是一套完整的决策链。
5. 阈值化输出只是半成品:形态学清洗与连通域过滤
5.1 二值图的三类典型瑕疵
阈值化之后的输出,正常情况下都不会是一张“完美的黑白画”。常见瑕疵有三类:孤立噪点,比如某个像素因为传感器噪声被切成白点;目标内部空洞,比如文字笔画的内部因为灰度偏高被误判成背景;边缘毛刺,让本来平滑的轮廓像锯齿一样。
如果不处理这些瑕疵就直接计数或测量,结果会很糟。所以教材在阈值化之后都会接形态学,我现在也把它当成二值化的默认后续动作,而不是可选动作。
5.2 开闭运算的标准动作
形态学里最常用的两个组合:
- 开运算 = 先腐蚀再膨胀,用于去除小的亮斑和细的连接桥。
- 闭运算 = 先膨胀再腐蚀,用于填充目标内部的小洞和断裂。
总流程通常是“先开后闭”:开运算把孤立的白色噪点去掉,闭运算把目标里的空洞填平。这里有一个很关键的参数——结构元素尺寸。它必须跟目标尺寸匹配:如果目标直径是 20 像素,那么结构元素选 3×3 太小,选 15×15 才有效;反过来如果目标只有几个像素,15×15 会把目标整个抹掉。
5.3 连通域分析与面积过滤
形态学清洗完之后,就可以用连通域分析把“一块块的目标”提取出来。OpenCV 里有现成函数:
n, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)stats里每一行是一个区域的x, y, width, height, area等信息。按面积过滤是我最常用的操作:
for i in range(1, n): area = stats[i, cv2.CC_STAT_AREA] if 30 <= area <= 5000: # 保留该区域,可继续计算重心或外接矩形 pass这一步能把小噪点区域扔掉,也能把超出合理大小的连通块单独拿出来做异常检测。我经常在细胞计数或者表面缺陷统计里这么干:阈值化出二值图,形态学清洗,连通域过滤,得到目标数量和面积分布。整个过程没有任何机器学习,却已经能解决很多工业视觉问题。
6. 灰度之外:多阈值、彩色空间与阈值化的边界
6.1 多阈值和递归分割
如果一张图里有三个灰度水平,比如亮背景、中灰目标、暗目标,单阈值只能分出两类,需要两个阈值甚至更多。教材里把它称为多阈值化。实践中最简单的做法是递归地应用大津法:先用大津法把图像分成两类,再对其中较暗的那一类继续做一次大津法,得到第二个阈值。这个方法可解释性很强,但要注意每轮分割都会引入误差,递归层数多了以后边界会很乱。
6.2 最大熵阈值的思想
除了大津法,还值得了解一下最大熵阈值。它的想法是:阈值化后目标和背景两类的灰度分布都应当“保留尽可能多的信息”,具体表现为两个类的灰度熵之和最大。大津法追求的是平均灰度的分离,最大熵法追求的是灰度概率分布的复杂度最大化,所以它在分布重叠较多时有时能给出和大津法不同的结果。我一般不把它当主要工具,但会在双峰不明显时跑一下作为替补方案。
6.3 HSV 空间的彩色阈值化
很多人第一次做彩色分割时,会直接把 RGB 三通道分别阈值化,再把三个布尔结果按“与”“或”组合。这个做法坑很多:RGB 三个通道高度相关,亮度一变三个通道一起变,阈值规则非常脆弱。
更适合的做法是转到 HSV 颜色空间。H 表示色调,S 表示饱和度,V 表示亮度。这样可以把光照信息和颜色信息大致分离:颜色容易受到亮度影响的通道分开处理后再阈值化,效果会稳定很多。比如提取黄色车道标线,可以直接在 H 通道上限定一个范围,同时用 S 通道过滤掉灰白区域。这是阈值化思想在更高维特征空间里的自然拓展——同样是给每个像素定一条“分数线”,只不过现在这条线是多个维度联合的决策边界。
6.4 什么时候该换方法
阈值化的本质是一个基于像素独立判断的分类器,它不考虑邻居关系。如果图像中目标形状复杂、边界模糊、背景纹理丰富,那么无论如何调阈值都不够,此时应该转向区域生长、分水岭或深度学习方法。这个判断很重要——阈值化不是所有分割问题的万灵丹,它有明确的能力边界。
7. 学习阈值化时我踩过的坑,以及一套可复现的练手实验
7.1 我踩过的几个具体坑
这里列几个我真实碰到过的问题,给后来者提个醒:
- 忘记确认输入是灰度图:直接拿彩色图调
threshold,结果输出看上去全黑或全白,因为 OpenCV 对多通道图是逐个通道比较,语义完全不同。 - 光照不均的图还强行用全局阈值:要么把暗部细节全丢掉,要么把背景切成一大块一大块的“污渍”,怎么调阈值都没用。
- 把大津法当万能自动挡:单峰直方图硬切,目标区域出现大片断裂。大津法只负责“在给定分布下找最大分离线”,不负责“这张图一定能切”。
THRESH_BINARY和THRESH_BINARY_INV用反:在很多检测流程里,背景估计和前景提取黑白是反着的,颜色一颠倒,后面的形态学参数全都要跟着变。- 自适应阈值里
blockSize超过图像尺寸时报错:如果图像被缩放得很小,而参数还是 31、51,就会直接异常。我在一个数据集增强脚本里踩过一次,之后都加了对图像尺寸的保护判断。 - 不记录参数:同一张图换了不同
C值,效果差很多,但一周之后就忘了上次用的参数是多少。现在我会给每组实验写一个参数卡片。 - 默认开闭结构元素越大越好:结构元素比目标还大时,目标会被当噪声直接抹掉。
7.2 一套可复现的练手实验
如果你跟我一样是自学,我给你一套我的练手路径:
- 找一张光照均匀的文档照片,画灰度直方图,手动找一个谷底阈值,用
threshold分割并保存。 - 对同一张图改用大津法,打印阈值,看是否接近手动选的谷底。
- 再找一张侧面打光的文档照片,分别用大津法和
adaptiveThreshold分割,对比两种结果的文字完整性。 - 连接形态学清洗和
connectedComponentsWithStats,统计文字连通域个数,并过滤掉面积小于 50 的噪点区域。
这套流程看起来很基础,但它把直方图、自动阈值、自适应阈值、形态学、连通域这几件事串成了一条完整的小项目流水线。我把每步结果都保存成图片,和直方图叠在一起看,很快就理解了“阈值不是算出来的,是数据本身决定的”。
最后再分享一个我自己的小习惯:学习阈值化时,不要第一时间打开库函数,而是先用几十行代码自己实现一遍大津法和自适应阈值。哪怕只是随便写写,再和 OpenCV 结果对照,你会比我直接抄 API 学到的多得多。这个动手过程带来的手感,是读多少页教材都换不来的。