图像分割是我读《图像处理、分析与机器视觉第四版》时最有“这页很简单”错觉的内容尤其是阈值化。原因很简单——公式就一句像素大于阈值就置为前景小于等于阈值就置为背景。可真正上手做分割项目之后我才发现阈值选多少、怎么选、选完之后二值图怎么用每一步背后都藏着细节。这篇笔记把我在这一章反复推敲的东西整理出来全局阈值、直方图、大津法、自适应阈值以及我和二值化结果“搏斗”时踩过的坑。这篇内容适合刚学图像分割的初学者也适合那些已经会用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, sigma3.0): # 简单的一维高斯平滑 kernel_size int(sigma * 6) | 1 kernel cv2.getGaussianKernel(kernel_size, sigma) return np.convolve(hist, kernel.ravel(), modesame)平滑之后只保留那些“左右邻域内都是最小值”的点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[t1]: 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_{i0}^{t} p_i, \quad \omega_1(t) 1 - \omega_0(t) ][ \mu_0(t) \frac{\sum_{i0}^{t} i \cdot p_i}{\omega_0(t)}, \quad \mu_1(t) \frac{\sum_{it1}^{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, connectivity8)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 学到的多得多。这个动手过程带来的手感是读多少页教材都换不来的。