
第一次看到“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) Σ_{i0}^{k} p_iC1前景灰度k1到L-1概率记为 ω1(k) 1 - ω0(k)再算两类的灰度均值。注意这里的均值是“条件均值”也就是在该类别内部按概率加权平均μ0(k) Σ_{i0}^{k} i·p_i / ω0(k) μ1(k) Σ_{ik1}^{L-1} i·p_i / ω1(k)整个图像的全局灰度均值μG Σ_{i0}^{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.73112/160.5003.714约1.12924/161.2504.000约1.418310/162.3005.000约1.709413/162.6926.000约1.667514/162.8576.500约1.452615/163.0677.000约0.907最大σB²出现在k3所以最佳阈值T3。也就是灰度0到3归为背景灰度4到7归为前景。从这个例子可以清楚看到OTSU确实做了一件看起来很“聪明”的事灰度3附近像素扎堆它把阈值卡在紧挨着的灰度3和4之间让两类的均值差拉到最大。细心的读者会发现这个例子里灰度4也是6个像素中的一部分如果把阈值放在k4类间方差也接近峰值但比k3略小。这提示了一个实际问题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, levels256): # 1. 统计直方图并归一化 hist np.bincount(image.ravel(), minlengthlevels).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(fOpenCV 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, maskbinary)这个技巧在后续做灰度特征提取、边缘检测时很实用尤其适合那些整张图里背景干扰较强的工业成像场景。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一行接口也一定要亲手拿纸笔推导一遍类间方差公式再写一次累计直方图的实现。这两件事做完很多二值化问题的排查思路会一下子豁然开朗。