☰
OTSU大津阈值法详解:类间方差推导与OpenCV二值化实战
2026/10/16 20:17:41 网站建设 项目流程

第一次看到“OTSU大津律法”这个写法的时候,我愣了好几秒——项目文档里明明写的是“OTSU”,怎么后半截就变成“律法”了?后来才反应过来,多半是把“大津阈值法”音译和意译混搭了:Otsu是“大津”,Method翻成“算法”或“律法”。不管叫法怎么变,这个1979年由日本研究者提出的自动阈值分割算法,到今天依然是图像处理领域最经典、最常用的入门算法之一。它的核心价值就一句话:不用人工调参,算法自己帮你找一个把前景和背景分得最开的灰度阈值。这篇就从头到尾拆一遍它的数学原理,再配一份能直接抄作业的源码解析,适合正在做图像处理课程项目、毕业设计,或者工业视觉检测里需要快速二值化的朋友。

1. 先把这个“律法”到底在算什么讲明白

很多人第一次接触OTSU,只知道调用现成接口:OpenCV里一句cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)就完事了。但如果只知道调用,遇到结果不对的时候基本无从下手。要理解OTSU,先得理解它解决的是一个什么样的最优化问题。

假设我们拿到一张灰度图,每个像素的灰度值分布在0到L-1之间。现在想找一个阈值k,把小于等于k的像素归为背景类C0,大于k的归为前景类C1。问题是:什么样的k算“好”?

人眼判断很直观——两类像素的灰度应该“泾渭分明”,背景归背景,前景归前景。如果阈值取得太偏,比如把一大半背景像素都划进前景类,两类灰度就糊在一起。OTSU的思想就是把这个“糊在一起”的程度量化成一个方差指标,然后遍历所有可能的k,找让这个指标最大化的那个k。

更精确地说,OTSU用的是“类间方差”,英文叫Between-class variance。它的直觉是这样:如果我们把图像的所有像素分成两个集合,那么这两个集合各自的灰度均值,应该尽量偏离整体均值。偏离得越远,说明两类灰度越是各占一方、互不掺和;偏离得近,说明这个阈值切得模棱两可,两堆像素的灰度范围大量重叠。

这个思想其实非常朴素,和聚类里的K-Means是同一个内核——都是希望类内紧凑、类间疏远。OTSU的聪明之处在于,它把灰度直方图当成一个概率分布来处理,把“找阈值”变成“遍历离散灰度级”,整个计算过程干净利落。

2. 类间方差公式推导:一个阈值如何让两类像素分得最开

这一节是全文的重点,我把数学步骤写得细一点。虽然符号有点多,但每步都是初等运算,纸笔推一遍就再也不会忘。

2.1 从直方图到概率分布

假设灰度级是0到L-1,灰度i对应的像素个数为n_i,图像总像素数为N。那么灰度i出现的概率就是:

p_i = n_i / N

这里p_i其实就是归一化直方图。我们把所有可能的p_i看成一个离散概率分布,后续所有均值、方差都在这套概率框架下计算。

现在选定一个阈值k,把灰度分成两类:

  • C0(背景):灰度0到k,概率记为 ω0(k) = Σ_{i=0}^{k} p_i
  • C1(前景):灰度k+1到L-1,概率记为 ω1(k) = 1 - ω0(k)

再算两类的灰度均值。注意这里的均值是“条件均值”,也就是在该类别内部按概率加权平均:

μ0(k) = Σ_{i=0}^{k} i·p_i / ω0(k) μ1(k) = Σ_{i=k+1}^{L-1} i·p_i / ω1(k)

整个图像的全局灰度均值:

μG = Σ_{i=0}^{L-1} i·p_i

有一个恒等式可以先记下来:

ω0·μ0 + ω1·μ1 = μG

这个式子不难验证,就是把μ0和μ1的定义式代进去通分。后面化简会用到。

2.2 类间方差的两种等价写法

类间方差定义如下:

σB² = ω0·(μ0 - μG)² + ω1·(μ1 - μG)²

这个式子本身的含义就是:两类各自与全局均值的加权偏差平方和。直觉上,如果两类均值都离全局均值远,这个值就大;如果两类均值都快贴到全局均值上了,这个值就小。

但计算机里更常用的其实是另一个等价的写法。把恒等式 μG = ω0·μ0 + ω1·μ1 代入,可以做一步代数变形:

σB² = ω0·(μ0 - (ω0·μ0 + ω1·μ1))² + ω1·(μ1 - (ω0·μ0 + ω1·μ1))² = ω0·(ω1·(μ0 - μ1))² + ω1·(ω0·(μ1 - μ0))² = ω0·ω1²·(μ0 - μ1)² + ω1·ω0²·(μ1 - μ0)² = ω0·ω1·(μ0 - μ1)²

最终得到:

σB² = ω0·ω1·(μ0 - μ1)²

这个形式特别适合写代码:只需要知道两类的概率和各自的均值,一次乘方就出结果。它和原始定义是完全等价的,不是近似。顺便说一个常被忽略的点:在给定阈值k时,图像的总体方差是常数,因此“最大化类间方差”和“最小化类内方差”这两个说法其实是同一件事。

为了不引入额外的归一化系数导致公式表达歧义,这里统一约定自由度下的方差定义。实际计算中,由于我们只比较不同k下σB²的相对大小,系数缩放不影响最终阈值选择。

2.3 为什么遍历灰度级就够了

有人会问:为什么不把所有像素的组合都试一遍?因为阈值只可能落在L-1个灰度级之间。比如8位图,灰度范围0到255,我们只需要尝试k = 0, 1, ..., 254共255个分割点。每个k对应一个类间方差,找出最大值对应的k,就是OTSU的最佳阈值。这个搜索空间很小,所以哪怕是实时性要求不高的场景,直接在整张图上跑OTSU也完全没问题。

3. 手推一遍8级灰度图的阈值查找过程

公式看多了容易飘,来一个能拿纸笔算的实际例子。假设有一张16个像素的小图,灰度范围压缩到0到7,直方图分布如下:

灰度级 i01234567
像素数 n_i11263111

总像素数 N = 16。灰度3是一个明显的大多数,可以想象这张图的大部分像素都聚焦在中间偏暗的区域,另外散布了一些高亮像素。OTSU要做的,就是找一个最佳分界点。全局灰度总和是:

0×1 + 1×1 + 2×2 + 3×6 + 4×3 + 5×1 + 6×1 + 7×1 = 53

全局均值 μG = 53 / 16 = 3.3125。

现在逐个尝试阈值k,算ω0、ω1、μ0、μ1,然后代入 σB² = ω0·ω1·(μ0 - μ1)²。我直接列结果:

kω0μ0μ1σB²
01/160.0003.533约0.731
12/160.5003.714约1.129
24/161.2504.000约1.418
310/162.3005.000约1.709
413/162.6926.000约1.667
514/162.8576.500约1.452
615/163.0677.000约0.907

最大σB²出现在k=3,所以最佳阈值T=3。也就是灰度0到3归为背景,灰度4到7归为前景。从这个例子可以清楚看到,OTSU确实做了一件看起来很“聪明”的事:灰度3附近像素扎堆,它把阈值卡在紧挨着的灰度3和4之间,让两类的均值差拉到最大。

细心的读者会发现,这个例子里灰度4也是6个像素中的一部分,如果把阈值放在k=4,类间方差也接近峰值,但比k=3略小。这提示了一个实际问题:OTSU在直方图双峰不锐利时,解的稳定性一般。后面我会专门展开聊。

4. 从数学到代码:逐行拆解阈值寻优内核

有了公式,写代码就顺理成章了。我最常用的版本是NumPy实现,核心思想是用“累计和”把遍历过程压缩成一次线性扫描。

4.1 用累计和加速计算

先看一个关键技巧。每次算μ0和μ1时,如果都从0到k重新累加,复杂度会变成O(L²)。虽然灰度级通常只有256,但写算法还是要有点追求。我们用两个累计数组:

  • omega = cumsum(p):灰度0到k的累计概率
  • mu_cum = cumsum(arange(L) * p):灰度0到k的累计“灰度贡献”

这样,μ0(k) = mu_cum[k] / omega[k],μ1(k) = (mu_G - mu_cum[k]) / (1 - omega[k])。都是一步取值,不需要循环内再累加。整体复杂度O(L),对256个灰度级来说是零压力。

完整实现如下:

import numpy as np def otsu_threshold(image, levels=256): # 1. 统计直方图并归一化 hist = np.bincount(image.ravel(), minlength=levels).astype(np.float64) total = hist.sum() if total == 0: return 0, 0.0 p = hist / total # 2. 累计概率和累计灰度贡献 omega = np.cumsum(p) mu_cum = np.cumsum(np.arange(levels) * p) mu_G = mu_cum[-1] best_threshold = 0 max_sigma_b2 = 0.0 # 3. 遍历所有可能阈值 for k in range(levels - 1): w0 = omega[k] w1 = 1.0 - w0 # 当某一类概率几乎为0时,方差无意义 if w0 < 1e-12 or w1 < 1e-12: continue mu0 = mu_cum[k] / w0 mu1 = (mu_G - mu_cum[k]) / w1 sigma_b2 = w0 * w1 * (mu0 - mu1) ** 2 if sigma_b2 > max_sigma_b2: max_sigma_b2 = sigma_b2 best_threshold = k return best_threshold, max_sigma_b2

这段代码里我认为最有嚼头的是mu_cum = np.cumsum(np.arange(levels) * p)。它把“灰度值 i”和“该灰度概率 p_i”的乘积做了前缀和,这样任意区间[0, k]的灰度加权贡献都能O(1)拿到。整个过程连一层嵌套循环都没有,性能非常稳。

4.2 和OpenCV的接口对照

项目里已经装了OpenCV的话,通常不需要自己写这个函数。但自己实现有一个好处:你把计算结果打印出来后,能看到某一个k值上的类间方差到底长什么样,这对调试很有帮助。我习惯把自定义函数和OpenCV的接口对照着用:

import cv2 img = cv2.imread('sample.png', cv2.IMREAD_GRAYSCALE) # 自定义实现 t1, sigma = otsu_threshold(img) print(f"自定义OTSU阈值: {t1}, 最大类间方差: {sigma:.4f}") # OpenCV自带实现,threshold取0时配合OTSU自动计算 t2, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) print(f"OpenCV OTSU阈值: {t2}")

实测下来,一致率很高,通常是完全相同的整数。OpenCV内部实现本质上也是类间方差最大化,但它在边界处理、浮点累计上会有一些隐式细节,所以个别1灰度级的偏差属于正常现象。

4.3 二值化结果怎么用

拿到阈值之后,常规操作是整张图按阈值切割,大于阈值设成255,小于等于阈值设成0。有时我们不想丢掉前景的灰度信息,只想“留下前景、滤掉背景”,这时候可以做半阈值化:

_, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 保留前景原始灰度,背景置0 masked = cv2.bitwise_and(img, img, mask=binary)

这个技巧在后续做灰度特征提取、边缘检测时很实用,尤其适合那些整张图里背景干扰较强的工业成像场景。

5. 双峰不明显、比例悬殊时,OTSU会翻车吗

OTSU名气太大,很多人默认它是万能阈值器。其实它的前提假设非常明确:图像直方图应当近似呈现双峰分布。一旦这个假设不成立,结果就会变得微妙。

5.1 单峰直方图下的“假最优”

如果一张图整片区域都是相近灰度,只有少量噪点,直方图就是一个尖峰。OTSU照样会给你算出一个类间方差最大的阈值,但那个阈值本质上是把主峰横向劈开,分成“峰左”和“峰右”两坨。分割结果看起来就是在完整区域上莫名其妙切了一刀,完全没意义。

怎么尽早发现这个问题?调试时直接画出直方图和类间方差曲线:如果直方图不是双峰,而类间方差曲线也没有一个明显的尖峰,而是平缓的馒头形,那就要警惕OTSU并不适合当前图像。这时候宁可改用固定阈值或自适应局部阈值,也不要盲目相信OTSU自动出来的结果。

5.2 前景背景比例悬殊时的偏移

想象一个场景:高精度表面检测里,大部分区域是均匀背景,只有极小一块是缺陷。这时像素占比严重失衡,比如背景占95%、前景占5%。OTSU的目标是最大化类间方差,但极端比例下,最优解往往倾向于把阈值压低,尽量让大比例的背景保持纯正,而前景被牺牲掉一部分。最终阈值不合理地靠近前景灰度,导致缺陷区域被切开甚至丢失。

应对方法并不复杂。工程上常见两手:第一,先通过形态学、先验ROI等方式把无关背景裁掉,让前景占比回到一个更平衡的水平;第二,改用分块OTSU——把图像切成若干小窗,在每个窗内重新计算OTSU阈值,最后再做平滑,这样即使局部占比悬殊,也能通过窗口尺度缓解。很多嵌入式视觉框架里所谓“自适应阈值”,底层就是这套思路。

5.3 光照不均怎么办

OTSU是全局阈值方法,它假设整个图像的光照条件一致。如果图像一侧亮一侧暗,全局阈值不可能同时照顾到两个区域。这种情况下有两个方向可以选:

  • 先做背景拟合与校正,把光照不均匀分量除掉,再跑OTSU;
  • 直接换OpenCV的cv2.adaptiveThreshold,用局部邻域的均值/高斯加权做阈值。

我个人的习惯是:如果目标物体本身边缘清晰,先试全局OTSU,因为它快且稳定;当结果出现“阴影区域整个变黑”这类系统性错误时,再切换局部策略。

6. 我实践中的几个细节与扩展思路

最后这部分不按教科书顺序来,是我自己在实际项目里踩过坑、也从中受益的几个点,直接列出来,算是给正在上手的读者一些参考。

首先,纯黑或纯白图像跑到OTSU上会返回什么?OpenCV的cv2.threshold配合OTSU,对纯黑图通常返回0,纯白图返回255,但这没有实际意义。如果工业现场可能出现整幅过暗或过曝的情况,我会先检查直方图的方差或最大最小灰度差,低于阈值就直接告警,而不是拿OTSU的结果当有效分割。

其次,类间方差曲线不仅用于选阈值,还能当“可分割性”的置信度指标。我在一个模拟项目X里,把最大σB²做了归一化,然后根据它判断当前画面是否需要进入精检流程。最大类间方差越大,说明双峰越清晰,后续边缘提取越可靠;当这个指标持续偏小时,系统会更加保守,宁可多看几次也不误判。

再一个有点意思的扩展:多类OTSU。单阈值只能把图切成两类,但很多场景需要区分背景、中间过渡区、目标区。如果把OTSU最大化类间方差的思想推广到两个阈值k1、k2,把灰度切成三类,就得到多阈值OTSU的雏形。原理相同,但搜索空间暴涨,实际工程里我更推荐递归方式:先用OTSU粗分割一次,再在某个子区间内二次OTSU,等于是用分治思路逼近多类分割。代码简单、可解释性强,部署在低算力设备上也没有压力。

还有一个容易被忽视的点:OTSU对噪声很敏感。因为直方图统计的是灰度分布,噪声会把原本锐利的峰摊平,导致阈值漂移。我的常见预处理顺序是:先做一次轻量中值滤波或高斯模糊,再跑OTSU,最后用开运算清理二值图上的离散点。顺序不能搞反——先滤波再OTSU,远比先OTSU再花力气修补二值图有效得多。

把数学原理和源码结合着看,OTSU不再是一个玄学的“自动阈值”,而是一个思路清晰、四五步就能写出来的朴素最优化方法。我一直建议刚接触图像分割的人,哪怕项目中永远只调用OpenCV一行接口,也一定要亲手拿纸笔推导一遍类间方差公式,再写一次累计直方图的实现。这两件事做完,很多二值化问题的排查思路会一下子豁然开朗。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询