
1. 内容整体设计与思路拆解1.1 一维OTSU到底是干什么的OTSU算法也叫最大类间方差法是图像阈值分割里最经典的自适应算法之一。它解决的问题很简单给定一张灰度图我不想人工指定阈值而是让算法根据像素灰度分布自动算出一个能把目标和背景分开的最佳阈值。在OpenCV-Python里一维OTSU就是一行代码的事ret, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)这里ret就是算法自动算出的阈值binary是分割后的二值图。它背后做的事情是把灰度直方图看成两类像素的混合假设阈值k把灰度空间切成0~k和k1~255两堆然后分别计算两堆像素占全体像素的概率记为w0和w1、两堆灰度的均值记为u0和u1再算一个类间方差sigma_b w0 * (u0 - ut)^2 w1 * (u1 - ut)^2其中ut是整幅图的全局灰度均值。这个方差越大说明两类像素“分得越开”。OTSU就遍历k 0, 1, ..., 254找让这个方差最大的那个k作为阈值。这个思路在上世纪七十年代末由日本学者大津提出到现在依然是工业界做简单分割的首选因为它无参数、速度快、稳定。但它的推导里藏着一个隐含假设直方图近似呈双峰分布。一旦图像里存在噪声、光照不均匀、边缘过渡带很大一维直方图可能变成单峰、多峰或平缓的馒头形那它选出来的阈值就会很离谱。1.2 一维OTSU的软肋看不见的“空间结构”我最开始在项目里踩坑就是拿一维OTSU去分割一张带椒盐噪声的工件图像。噪声点本身灰度值非常高而且数量也不少它们会整体抬高直方图高灰度端的统计权重导致OTSU为了“照顾”这些错误的高亮像素把阈值往高处拉结果真正目标区域的边缘被漏掉了一大片前景和背景糊在一起。这里要理解一个关键点一维OTSU只统计灰度值分布完全不看像素与像素之间的空间关系。图像里像素不是孤立的目标区域通常是连成一片的背景也是连成一片的。灰度上的离群点比如噪声、划痕、反光虽然数量少但分布在高灰度端它们在直方图上会形成一条不可忽视的尾巴直接干扰类间方差的计算。所以阈值分割真正需要的不只是“灰度有多高”还包括“周围像素是不是也这样”。这就像你判断一个人是不是程序员不能只问他会不会写代码还得看他平时的行为习惯和圈子。一维OTSU只看一句话二维OTSU则连上下文一起看。1.3 二维OTSU的核心改进思路把邻域均值拉进来二维OTSU的改进思路很直接每个像素不只用自己的灰度值还用邻域像素的灰度均值作为另一个特征。比如对一个像素取出它周围3×3区域的灰度平均值作为第二个维度。如果一个点是孤立噪声它的灰度可能很高但周围像素的灰度均值不会太高如果一个点属于真正的亮目标区域那么它的灰度高周围均值也高。于是在“灰度–邻域均值”形成的二维直方图里噪声和目标就能被分开。这个做法在数学上等于把一维直方图扩展到二维联合分布再在这个二维分布上找一个阈值对(s, t)把平面切成背景和前景两个区域。换句话说一维OTSU找的是一条灰度分界线二维OTSU找的是一条在二维平面里的斜向分界线理论上能把那些单靠灰度值分不开的噪声点过滤掉。代价也很明显搜索空间从256个灰度级变成了256×25665536个阈值对如果朴素实现每个阈值对都要把直方图遍历一遍复杂度直接变成O(L^4)图像稍微大一点就跑不动。这也是很多教程只是介绍一下原理却很少给完整可运行代码的原因。这篇文章后面会给出完整实现并且把复杂度优化到O(L^2)实测百万像素图像也就是几十毫秒的量级。2. 二维OTSU算法原理与公式推导2.1 二维直方图灰度与邻域均值联合分布二维OTSU的第一步是给每个像素计算一个邻域均值。假设原始灰度图是f(x, y)用3×3的均值滤波实际就是cv2.blur得到g(x, y)它表示每个像素周围的平均灰度。然后我们把(f(x,y), g(x,y))这个二元组当作像素的新特征。从统计角度讲二维直方图是一个L×L的矩阵PP(i, j)表示灰度值为i、邻域均值为j的像素出现的概率实际是频数除以总像素数。i的取值范围是0~255j也是0~255。对于图像里的正常目标或背景像素i和j往往接近因为连通区域内部灰度和邻域均值基本一致但边缘像素和孤立噪声就不一样了它们的i和j会有明显偏差。这就是二维直方图能够更好地区分噪声的原因。在Python里构建这个二维直方图不建议用两层for循环去遍历像素那样速度太慢。正确姿势是用np.bincountimport cv2 import numpy as np gray cv2.imread(image.png, cv2.IMREAD_GRAYSCALE) # 1. 计算邻域均值注意要四舍五入回整数 mean_img cv2.blur(gray, (3, 3)) mean_img np.rint(mean_img).astype(np.uint8) # 2. 把 (灰度, 邻域均值) 压成一维索引 idx gray.astype(np.int32) * 256 mean_img.astype(np.int32) # 3. 统计频数并还原成二维直方图 hist np.bincount(idx.ravel(), minlength256 * 256) hist hist.reshape(256, 256).astype(np.float64)这一步得到的hist[i, j]就是二维直方图。np.bincount把(0,0)对应到索引0(0,1)对应到索引1(1,0)对应索引256以此类推。这个映射关系记住就行后面拆括号的时候方向不会乱。2.2 四个区域的物理解释A/B是主体C/D是边界选定阈值对(s, t)后二维直方图被切成四块A区i s且j t像素灰度和邻域均值都比较低一般是背景。B区i s且j t两者都比较高一般是前景目标。C区i s但j t本身灰度低周围均值高典型的是背景边缘被目标亮度辐射到的情况。D区i s但j t本身灰度很高邻域均值低典型的是孤立噪声或目标边缘外圈。经典二维OTSU算法在计算时只考虑A和B两个区域把C和D近似忽略掉。这个近似在大多数情况下是合理的因为边缘和噪声点占像素总数比例很小把它们算进去对类间方差贡献很有限却能让公式形式简洁很多。不过要留意如果图像里噪声特别重或者目标边缘特别密集C和D区域占比会上升这时经典的二维OTSU假设就开始松动了后面我会单独聊这个问题。2.3 类间方差公式与最终的阈值搜索目标假设二维直方图归一化后是P(i, j)全局两个维度的均值分别记为mu_i和mu_jmu_i Σ_i Σ_j i * P(i, j) mu_j Σ_i Σ_j j * P(i, j)对给定阈值对s, tA区的总概率记为P_A(s, t)A区在灰度维和一个均值维的累计矩分别记为M_i(s, t)和M_j(s, t)P_A(s, t) Σ_{is} Σ_{jt} P(i, j) M_i(s, t) Σ_{is} Σ_{jt} i * P(i, j) M_j(s, t) Σ_{is} Σ_{jt} j * P(i, j)那么A区的类均值向量就是(M_i / P_A, M_j / P_A)B区由于概率是1 - P_A灰度维均值可以写成(mu_i - M_i) / (1 - P_A)均值维同理。二维类间方差定义为两个类的均值向量到全局均值向量的加权平方距离之和化简之后能得到一个非常紧凑的表达式sigma_B2(s, t) [ (M_i - mu_i * P_A)^2 (M_j - mu_j * P_A)^2 ] / [ P_A * (1 - P_A) ]这个化简很关键它把原来需要分别算A、B两区的四个均值向量再加权求和的计算过程压缩成了一个分式。代码实现时只要维护三个矩阵——P_A的累计概率矩阵、M_i的累计矩矩阵、M_j的累计矩矩阵——就能对任意s, t在O(1)时间内算出类间方差。最后遍历所有s, t取sigma_B2最大的那一对作为最优阈值对。这个阈值对整体上就是二维OTSU算法的最终输出。3. Python与OpenCV完整实现3.1 核心步骤总览实现二维OTSU的完整流程可以拆成五步读入灰度图计算3×3邻域均值矩阵。用邻域均值矩阵和原灰度图构建二维直方图并归一化。对概率矩阵做二维前缀和同时计算两个维度的累计矩前缀和。基于化简后的类间方差公式一次性算出所有s, t对应的方差值。排出边界点取最大方差对应的s, t作为最优阈值。这五步在numpy里可以完全向量化核心代码只有十几行但每一步都有容易出错的细节。我逐个说。3.2 用numpy构建二维直方图前面已经写过用np.bincount构建直方图的代码这里再补充两个实战细节。第一邻域均值计算出来后一定要用np.rint做四舍五入而不是直接astype(np.uint8)。因为astype(uint8)是截断操作2.7会变成2而np.rint会变成3。虽然单个像素差1个灰度级影响不大但整个图像累积起来会造成直方图轻微偏移最后可能影响最优阈值位置的判断所以尽量做得精确一些。第二如果图像尺寸比较大比如几千万像素idx这个中间数组会占用较多内存。可以分块处理或者用np.histogram2dhist, _, _ np.histogram2d( gray.ravel(), mean_img.ravel(), bins[256, 256], range[[0, 256], [0, 256]] )要注意np.histogram2d默认返回的hist形状是(256, 256)第一个维度对应的是x即第一个传入参数的灰度第二个维度对应y邻域均值和np.bincount的做法一致。但histogram2d会比bincount慢一些所以常规情况我还是推荐bincount方案。另外如果图像本身是16位灰度图灰度级是65536那二维直方图就是65536×65536内存完全吃不消这种场景必须先降位到8位再做OTSU。3.3 前缀和加速从O(L⁴)到O(L²)没有优化的朴素实现是每给定一个s, t就重新遍历一遍二维直方图累加A区的概率和矩。假设L256阈值对有65536个每个都要算一次二维累加平均累加约32768个格子总操作量是20亿级别。Python里跑这种嵌套循环少说也要几十秒到几分钟根本没法用。优化的方法是先算二维前缀和矩阵。二维前缀和S的定义是S[s, t] Σ_{is} Σ_{jt} P(i, j)这个矩阵可以在O(L²)内递推得到S[s, t] S[s-1, t] S[s, t-1] - S[s-1, t-1] P[s, t]含义是“左边的累计 上边的累计 - 左上角重复累计 当前格子的值”。不过用numpy的话根本不用手写循环两行cumsum就搞定S np.cumsum(np.cumsum(P, axis0), axis1) M_i np.cumsum(np.cumsum(P * gray_levels, axis0), axis1) M_j np.cumsum(np.cumsum(P * mean_levels, axis0), axis1)其中gray_levels是形状(256,1)的列向量广播到每个格子后乘P就得到了每个格子对灰度累计矩的贡献。算完前缀和后任意s, t的P_A、M_i、M_j都直接查表整条遍历过程从20亿次操作降到了65536次Python里也是毫秒级完成。这也是图像处理里典型的“用空间换时间”思想和积分图加速是同一个套路。3.4 完整可运行代码把上面内容整合成一个函数import cv2 import numpy as np def otsu_2d(gray: np.ndarray, L: int 256, win: int 3): # 1. 邻域均值 mean_img cv2.blur(gray, (win, win)) mean_img np.rint(mean_img).astype(np.uint8) h, w gray.shape # 2. 二维直方图 idx gray.astype(np.int32) * L mean_img.astype(np.int32) hist np.bincount(idx.ravel(), minlengthL * L).reshape(L, L).astype(np.float64) P hist / (h * w) # 3. 前缀和与累计矩前缀和 gray_levels np.arange(L, dtypenp.float64).reshape(-1, 1) mean_levels np.arange(L, dtypenp.float64).reshape(1, -1) S np.cumsum(np.cumsum(P, axis0), axis1) M_i np.cumsum(np.cumsum(P * gray_levels, axis0), axis1) M_j np.cumsum(np.cumsum(P * mean_levels, axis0), axis1) mu_i M_i[-1, -1] mu_j M_j[-1, -1] # 4. 向量化计算类间方差 eps 1e-10 denom S * (1.0 - S) eps diff_i M_i - mu_i * S diff_j M_j - mu_j * S sigma (diff_i ** 2 diff_j ** 2) / denom # 5. 排除把整幅图归为一类的极端情况 sigma[-1, :] -np.inf sigma[:, -1] -np.inf best_s, best_t np.unravel_index(np.argmax(sigma), sigma.shape) return int(best_s), int(best_t), sigma这段代码返回三个值best_s是最优的灰度阈值best_t是最优的邻域均值阈值sigma是全部阈值对对应的类间方差矩阵调试时可以拿来可视化。使用它很简单gray cv2.imread(noisy_image.png, cv2.IMREAD_GRAYSCALE) s, t, _ otsu_2d(gray, L256, win3) print(最优灰度阈值:, s, 最优邻域均值阈值:, t) binary (gray s).astype(np.uint8) * 255如果你对像素级别的最终分类要求更严格可以把落在C区和D区的像素也做处理比如根据(灰度, 邻域均值)到A、B两个聚类中心的欧氏距离就近归类但这通常是后处理阶段的事不影响阈值对的计算。4. 实验结果与一维OTSU对比4.1 测试用例带椒盐噪声的合成图为了验证二维OTSU的优势我构造了一张容易让一维算法翻车的测试图背景灰度0前景是一个200×200的目标块灰度180。然后往图上加5%的椒盐噪声就是随机把热点变白、变黑。这类图在工业检测里很常见比如工件边缘有粉尘、反光点。一维OTSU的错误非常直观噪声把高灰度端的统计量拉高算法计算出的阈值很容易跑到150以上导致目标边缘大量像素被误分割成背景。二维OTSU因为把3×3邻域均值作为第二个维度孤立噪点在二维直方图里落在D区高灰度但低邻域均值几乎不会干扰前景和背景的聚类中心阈值对自然更稳定。4.2 一维与二维的分割效果对比用同一张噪声图跑两种情况一维OTSUcv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)算出一个全局阈值直接二值化。二维OTSU上面自带的otsu_2d函数取灰度阈值best_s同样二值化。一维OTSU对明显噪声图像的分割结果里往往存在两个问题一是噪声点本身被分割成前景的“白色毛刺”二是目标边缘出现空洞。二维OTSU的结果干净得多因为单个椒盐噪声虽然灰度很高但它的邻域均值并不高在联合特征空间里被分到了背景类。也不要把二维OTSU神化。如果噪声密度超过一定比例比如10%以上二维直方图里C、D区占比越来越大经典公式里“忽略C、D区”的假设就不那么成立了分割效果会和一维算法一起退化。这时候更该考虑的是先做中值滤波去噪再走OTSU流程。4.3 阈值选择与适用场景分析二维OTSU输出的是一对阈值(s, t)实际使用时有几种落地方式只用s做灰度阈值快速方便抗噪性已经比一维好很多。联合判断(f_i s and g_i t)归为背景(f_i s and g_i t)归为前景落在其他区域的像素按距离最近原则归类。用s和t的某种加权组合比如最终灰度阈值取(s t)/2之类的经验值但我不推荐没什么理论依据自找麻烦。从我实际项目经验看最常用还是第一种直接取s因为很多下游处理只需要一个全局二值图。只有在目标边缘精度要求很高的场景才需要做联合判断。另外要注意二维OTSU适合处理“有背景有前景且两类内部灰度稳定”的图像。如果图像照明是渐变式的比如左亮右暗二维OTSU依然可能失效这种场景更适合自适应阈值cv2.adaptiveThreshold。5. 常见问题与排查技巧实录5.1 除零与边界条件最容易踩的坑是除零。当s0, t0时A区可能没有任何像素P_A≈0分母P_A*(1-P_A)趋近0类间方差公式直接爆炸。我在代码里用eps1e-10兜底就是为了避免出现nan。另一个极端是s255, t255这时几乎全部像素都被归入A区1-P_A≈0同样除零。所以最后要把最后一行和最后一列设为-inf防止算法选出一个“全背景”或“全前景”的无效阈值。还要注意一个小细节gray.astype(np.int32) * L mean_img.astype(np.int32)这行如果gray已经是uint8astype(np.int32)不能省。因为uint8 * int在numpy里可能触发溢出转换某些版本下会把结果变成浮点甚至直接报错保险起见都转成np.int32再做运算。5.2 邻域均值核大小怎么选win参数直接决定邻域均值矩阵的平滑程度。win3是默认选择适合大多数图像既能抑制孤立噪声又不会过度模糊目标边界。win5会让直方图里C、D区域更小抗噪能力更强但目标边界上的过渡带也会变宽分割出的边缘可能偏大一圈。对细长结构、纹理密集的目标核太大会直接把细节抹掉建议用win3。还有一个常被忽略的问题图像边缘像素在计算均值时cv2.blur默认会做边界填充相当于镜像反射所以边缘像素的邻域均值也是可信的。如果你用别的库或者自己写均值滤波注意统一边界策略不然二维直方图在边界处会出现一条异常带。5.3 阈值对怎么落地到二值图很多人实现完二维OTSU之后拿到s和t却不知道最终二值图怎么生成。最简单的方式是直接用灰度阈值s_, binary cv2.threshold(gray, s, 255, cv2.THRESH_BINARY)如果要用联合分类就需要一点矩阵运算bg (gray s) (mean_img t) fg (gray s) (mean_img t) edge ~(bg | fg) # C区和D区 # 边界像素按灰度阈值s兜底归类 result np.zeros_like(gray) result[fg] 255 result[edge (gray s)] 255这块逻辑不复杂但要注意mean_img必须和建直方图时的均值矩阵一致否则阈值对和像素分类对不上。踩过一次这个坑当时忘了重新调用cv2.blur结果mean_img变量已经被覆盖成别的值分类结果惨不忍睹。5.4 性能优化与实时应用建议二维OTSU核心计算已经是O(L²)也就是65536次操作对现代CPU来说是毛毛雨。真正的耗时大头其实是全图遍历建直方图这部分是O(N)N是像素总数。百万像素图像Python里跑完整函数大概就是几十毫秒基本满足常规需求。如果你想在视频流里实时跑有几个优化方向下采样把图像缩到1/2或1/4再算阈值阈值本身是全局统计量对分辨率不敏感。降灰度级把256级压到128级或64级直方图矩阵从65536缩到4096速度进一步提升代价是阈值精度略降。简化邻域均值cv2.blur本身很快但如果用cv2.boxFilter加BORDER_REPLICATE对某些边缘策略会更可控。多帧复用相邻帧图像变化不大时可以隔几帧重新计算一次阈值中间帧直接沿用。另外如果你的图像里目标比例很小比如占0.1%二维OTSU的分割结果可能偏保守因为类间方差公式天然偏向两类像素数量接近的情况。这时候建议先做一次形态学预处理或者直方图均衡再进OTSU流程。关于这个算法我最后想多啰嗦一句。二维OTSU在实际项目中最大的价值不在于它一定比一维算法更“高级”而在于它给了你额外一个维度的信息去过滤那些灰度特征不可靠的像素。如果你要处理的问题里噪声明显、目标边缘要求不高、又需要一个可复现的自动阈值二维OTSU是一个非常划算的选择。如果图像本身很干净双峰直方图清晰那一维OTSU还是更轻快没必要硬上二维。算法选型这件事永远是先看问题再挑工具不是越复杂越好。