Otsu算法:从原理到实战,实现图像二值化的自动阈值计算

1. 项目概述:从“猜”阈值到“算”阈值的跨越

在图像处理的世界里,二值化是个绕不开的基础操作。无论是想从一张发票照片里提取文字,还是从医学CT影像中分离出病灶区域,第一步往往就是把一张灰度图变成非黑即白的二值图。这个“变”的过程,核心就是找一个“门槛”——阈值。像素灰度值高于这个门槛的,我们让它变成白色(255),低于的,就变成黑色(0)。听起来很简单,对吧?但问题来了:这个门槛到底该设成多少?

新手最常见的做法是“拍脑袋”或者“试错法”。比如,面对一张光照不均的文档照片,你可能先试128(中间值),发现背景没干净,文字也断了;再试150,背景干净了但文字变细了;试100,文字是保住了,但背景的噪点全出来了。整个过程就像在黑暗中摸索,效率低,效果还不稳定。尤其是在需要批量处理成千上万张图像,或者图像质量参差不齐时,手动调阈值简直就是一场噩梦。

Otsu算法,也叫最大类间方差法或大津算法,就是为了终结这场噩梦而生的。它不是一个让你去“设置”阈值的工具,而是一个能自动“计算”出最佳阈值的数学方法。它的核心思想极其优雅:既然二值化是把像素分成了前景(我们感兴趣的目标,如文字、物体)和背景两类,那么最理想的阈值,就应该让这两类之间的差异最大,同时让每一类内部的像素尽可能相似。用统计学的语言来说,就是最大化类间方差。

我第一次接触这个算法是在处理一批古文献的扫描件时。文献纸张泛黄、墨迹深浅不一,还有各种污渍。手动调阈值调到眼花,效果还时好时坏。直到用上Otsu,一键处理,大部分图片的文字提取效果立刻变得清晰、连贯,一下子把我从重复劳动中解放了出来。从此,它就成了我图像处理工具箱里的“定海神针”。无论你是做OCR预处理、医学图像分析,还是简单的背景分割,理解并掌握Otsu算法,都能让你从“凭感觉”的初级阶段,跃升到“有理论依据”的实践层面。

2. 核心原理拆解:方差背后的分类哲学

要真正用好Otsu算法,不能只停留在调用cv2.threshold(img, 0, 255, cv2.THRESH_OTSU)这一行代码上。理解其背后的数学原理,不仅能让你在算法失效时知道如何排查,更能让你在更复杂的场景下(比如多阈值分割)举一反三。

2.1 从直方图到概率分布

Otsu算法的所有计算都基于图像的灰度直方图。假设我们有一张8位灰度图,灰度级L=256(0到255)。直方图h[i]就表示灰度值为i的像素有多少个。算法第一步,是把这直方图归一化成一个概率分布:

p(i) = h(i) / N, 其中N是图像总像素数,i = 0, 1, ..., L-1

这样,p(i)就代表了随机抽取一个像素,其灰度值为i的概率。整个图像的平均灰度(一阶矩)μ_T也就很容易计算:μ_T = Σ(i * p(i)),对i从0到L-1求和。

现在,我们假设选取一个阈值k,它将所有像素分成了两类C0和C1:

  • C0: 灰度值在 [0, k] 的像素(通常视为背景)。
  • C1: 灰度值在 [k+1, L-1] 的像素(通常视为前景)。

2.2 类内相似与类间差异的数学表达

有了分类,我们就可以计算每一类出现的概率(权重):

  • ω0 = P(C0) = Σ p(i) , i从0到k。 (背景类像素占总像素的比例)
  • ω1 = P(C1) = Σ p(i) , i从k+1到L-1。 (前景类像素占总像素的比例) 显然,ω0 + ω1 = 1。

每一类的平均灰度也可以算出:

  • μ0 = Σ (i * p(i)) / ω0 , i从0到k。 (背景类的平均灰度)
  • μ1 = Σ (i * p(i)) / ω1 , i从k+1到L-1。 (前景类的平均灰度)

整个图像的总平均灰度μ_T,其实就是这两类平均灰度的加权和:μ_T = ω0 * μ0 + ω1 * μ1。这个关系在后面推导时会用到。

Otsu算法的目标是让两类“分得最开”。在统计学里,衡量数据分布离散程度的一个经典指标是方差。这里,我们定义类间方差(Between-class variance)σ_B²:σ_B² = ω0 * (μ0 - μ_T)² + ω1 * (μ1 - μ_T)²

这个公式非常直观:它衡量的是两类各自的均值与图像总均值之间的差异的加权平方和。如果背景和前景的灰度值本身相差很大(比如白纸黑字),那么μ0和μ1就会分别远离μ_T,导致σ_B²很大。反之,如果图像本身对比度很低,前景背景灰度接近,μ0和μ1都靠近μ_T,σ_B²就会很小。

注意:这里有一个非常重要的等价推导。利用μ_T = ω0 * μ0 + ω1 * μ1的关系,我们可以把类间方差公式简化为:σ_B² = ω0 * ω1 * (μ0 - μ1)²这个形式更简洁,计算量也更小。它直接告诉我们:最大化类间方差,等价于最大化前景与背景两类权重的乘积,再乘以两类均值之差的平方。当两类权重相等(ω0=ω1=0.5)且均值差距最大时,σ_B²取得理论最大值。这完美对应了我们的直觉:最好的分割,应该让前景和背景在“数量”和“灰度值”上都区分明显。

2.3 算法步骤与计算优化

理解了目标函数σ_B²,Otsu算法的步骤就清晰了:

  1. 计算灰度直方图:统计图像中每个灰度级的像素个数。
  2. 归一化直方图:得到概率分布p(i)。
  3. 遍历所有候选阈值k(从1到L-2,0和255作为阈值通常无意义)。
  4. 对于每个k: a. 计算累积权重ω0(k)和ω1(k)(ω1(k)=1-ω0(k))。 b. 计算累积均值μ0(k)和μ1(k)。这里可以利用递推公式高效计算,避免每次从头求和。 c. 计算当前k下的类间方差σ_B²(k) = ω0(k) * ω1(k) * [μ0(k) - μ1(k)]²。
  5. 寻找最佳阈值:找到使σ_B²(k)最大的那个k值,即为Otsu算法计算出的最佳全局阈值。

在实际编程中,步骤4的递推计算是关键。我们可以维护两个累积量:

  • ω0随着k增加,每次加上p(k)即可更新。
  • μ0的累积和sum0,随着k增加,每次加上k * p(k),然后μ0 = sum0 / ω0。 这样,整个遍历过程的时间复杂度是O(L),对于256个灰度级来说,计算速度极快,这也是Otsu算法得以广泛应用的原因之一。

3. 实战应用与代码解析

理论再漂亮,也得落地才行。下面我们抛开库函数,亲手实现一遍Otsu算法,并对比OpenCV等库的实现,看看在实际应用中需要注意哪些细节。

3.1 从零实现Otsu算法

我们用Python和NumPy来手动实现,这能帮你彻底吃透计算过程的每一个环节。

import numpy as np import cv2 from matplotlib import pyplot as plt def otsu_threshold_manual(image): """ 手动实现Otsu阈值计算 Args: image: 输入灰度图像 (numpy array) Returns: best_threshold: 计算得到的最佳阈值 between_variances: 所有阈值对应的类间方差列表(用于可视化) """ # 1. 计算灰度直方图 # 假设是8位图像,bins为256 hist, bin_edges = np.histogram(image.flatten(), bins=256, range=(0, 256)) # 2. 归一化得到概率分布 pixel_count = image.size prob = hist / pixel_count # 初始化变量 best_threshold = 0 max_variance = 0.0 between_variances = [] # 记录每个k的方差,方便观察 # 总平均灰度 total_mean = np.sum(np.arange(256) * prob) # 初始化累积量 omega0 = 0.0 # C0类的累积概率 sum0 = 0.0 # C0类的累积灰度值和 # 3. 遍历所有可能的阈值k (1 到 254) for k in range(1, 255): # 递推更新C0类的累积概率和累积灰度值和 omega0 += prob[k] sum0 += k * prob[k] # 如果omega0为0或1,跳过(此时一类为空,无意义) if omega0 < 1e-10 or omega0 > 1 - 1e-10: between_variances.append(0) continue # 计算C0类的平均灰度 mean0 = sum0 / omega0 # 计算C1类的概率和平均灰度 omega1 = 1.0 - omega0 # C1类的累积灰度值和 = 总灰度值和 - C0类累积灰度值和 # 总灰度值和 = total_mean * pixel_count,但这里用概率形式更简单 # mean1 = (total_mean - omega0 * mean0) / omega1 sum1 = total_mean - omega0 * mean0 mean1 = sum1 / omega1 # 4. 计算类间方差(使用简化公式) variance_b = omega0 * omega1 * (mean0 - mean1) ** 2 between_variances.append(variance_b) # 5. 更新最佳阈值 if variance_b > max_variance: max_variance = variance_b best_threshold = k return best_threshold, between_variances # 测试用例 # 生成一张简单的双峰图像:深色背景+亮色前景 np.random.seed(42) background = np.random.normal(70, 10, (200, 300)).astype(np.uint8) # 背景~N(70,10) foreground = np.random.normal(180, 10, (100, 200)).astype(np.uint8) # 前景~N(180,10) test_img = np.zeros((300, 400), dtype=np.uint8) test_img[50:250, 100:300] = foreground # 将前景块放入图像中心 # 计算阈值 thresh_manual, variances = otsu_threshold_manual(test_img) print(f"手动实现Otsu计算的最佳阈值: {thresh_manual}") # 使用OpenCV的Otsu方法进行验证 thresh_cv2, binary_cv2 = cv2.threshold(test_img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) print(f"OpenCV Otsu计算的最佳阈值: {thresh_cv2}") # 可视化 fig, axes = plt.subplots(2, 2, figsize=(12, 10)) axes[0,0].imshow(test_img, cmap='gray') axes[0,0].set_title('原始测试图像') axes[0,0].axis('off') axes[0,1].hist(test_img.flatten(), bins=50, color='blue', alpha=0.7) axes[0,1].axvline(x=thresh_manual, color='red', linestyle='--', label=f'Otsu Threshold={thresh_manual}') axes[0,1].set_xlabel('灰度值') axes[0,1].set_ylabel('像素频数') axes[0,1].set_title('图像直方图与阈值') axes[0,1].legend() axes[1,0].plot(range(1,255), variances, 'g-') axes[1,0].axvline(x=thresh_manual, color='red', linestyle='--') axes[1,0].set_xlabel('候选阈值 k') axes[1,0].set_ylabel('类间方差 σ_B²') axes[1,0].set_title('类间方差随阈值变化曲线') axes[1,0].grid(True) # 应用阈值进行二值化 binary_manual = np.where(test_img > thresh_manual, 255, 0).astype(np.uint8) axes[1,1].imshow(binary_manual, cmap='gray') axes[1,1].set_title(f'手动二值化结果 (阈值={thresh_manual})') axes[1,1].axis('off') plt.tight_layout() plt.show()

运行这段代码,你会看到生成的测试图像、其双峰直方图、类间方差随阈值变化的曲线(这条曲线通常会有一个明显的峰值),以及最终的二值化结果。手动计算的结果应该与OpenCV的结果完全一致(或相差1以内,因实现细节可能略有不同)。

3.2 OpenCV中的Otsu与高效使用技巧

在实际项目中,我们当然不会每次都自己写。OpenCV中的cv2.threshold()函数是标准用法。但直接调用之外,有些技巧能让你用得更好:

import cv2 import numpy as np # 标准用法 img = cv2.imread('document.jpg', cv2.IMREAD_GRAYSCALE) # 注意:THRESH_OTSU 必须与 THRESH_BINARY 或 THRESH_BINARY_INV 组合使用 thresh_val, binary_img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) print(f"Otsu自动计算的阈值: {thresh_val}") # 技巧1:预处理的重要性 # Otsu假设直方图是双峰的。如果图像噪声很大,直方图峰谷不明显,效果会变差。 # 常见的预处理是高斯模糊,可以平滑噪声,使直方图分布更集中。 img_blurred = cv2.GaussianBlur(img, (5,5), 0) thresh_val_blur, binary_img_blur = cv2.threshold(img_blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 对比 blur 前后的阈值和效果,通常 blur 后的结果更鲁棒。 # 技巧2:处理高光或阴影区域(局部Otsu) # 全局Otsu对光照不均的图像效果不佳。此时可以使用局部自适应阈值,如 cv2.adaptiveThreshold。 # 但 adaptiveThreshold 本身不支持 Otsu 方法。一个折中方案是分块处理。 height, width = img.shape block_size = 64 binary_local = np.zeros_like(img) for y in range(0, height, block_size): for x in range(0, width, block_size): block = img[y:y+block_size, x:x+block_size] if block.size > 0: # 防止边缘块为空 thresh_local, block_binary = cv2.threshold(block, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) binary_local[y:y+block_size, x:x+block_size] = block_binary # 注意:分块处理可能在块边界产生不连续效应,后续可能需要形态学操作平滑。 # 技巧3:获取并分析直方图 hist = cv2.calcHist([img], [0], None, [256], [0,256]) # 可以手动绘制直方图,观察是否具有双峰特性,预判Otsu效果。

实操心得cv2.threshold()的返回值中,第一个值thresh_val就是Otsu算法计算出的阈值。这个值对于后续分析非常有用。比如,你可以记录一批图像的处理阈值,如果某张图的阈值异常偏高或偏低,可能意味着这张图本身质量有问题(如过曝、欠曝),需要进行单独检查或不同的预处理。

4. 优势、局限与适用场景分析

没有一种算法是万能的,Otsu也不例外。清楚它的能力和边界,才能把它用在最合适的地方。

4.1 核心优势

  1. 完全自动,无需参数:这是它最吸引人的地方。你不需要凭经验去猜一个阈值,算法基于图像自身的统计特性给出“最优”解。对于批量处理任务,自动化程度极高。
  2. 计算高效:算法复杂度是O(L),对于256灰度级,计算几乎是瞬时的。即使在资源受限的嵌入式设备(如K230这类AIoT芯片)上,也能实时运行。
  3. 理论基础扎实:基于最大类间方差,有明确的数学解释和优化目标,结果可解释性强。
  4. 对双峰直方图图像效果卓越:当图像的前景和背景在灰度直方图上能形成两个分离的波峰时,Otsu找到的阈值通常就位于波谷处,分割效果非常理想。典型的例子就是白纸黑字的文档、在均匀背景下拍摄的物体等。

4.2 主要局限性及应对策略

Otsu的局限性主要源于其“全局”和“基于直方图形状”的假设。

局限性场景问题描述可能的现象应对策略
光照不均匀图像不同区域亮度差异大,导致全局单一阈值失效。同一物体,亮处被分为前景,暗处被分为背景。1. 使用局部自适应阈值(如cv2.adaptiveThreshold)。
2. 先进行光照校正(如同态滤波、Retinex算法),再用Otsu。
3. 采用分块Otsu,如上一节所示。
直方图非双峰前景和背景灰度重叠严重,或图像内容复杂,直方图呈单峰或多峰。Otsu阈值可能位于错误位置,导致大量误分割。1. 增加预处理,如对比度拉伸、直方图均衡化,尝试分离峰谷。
2. 考虑多阈值Otsu(将图像分为多类)。
3. 转向更复杂的算法,如基于熵的方法迭代阈值法深度学习分割模型(如UNet)。
前景/背景面积悬殊一类像素数量远多于另一类(如小目标检测)。算法倾向于保护大类,小目标可能被忽略。1. 尝试反转图像后再应用Otsu。
2. 使用加权Otsu,为目标类赋予更高权重。
3. 在目标区域使用ROI(感兴趣区域)单独分割。
噪声敏感图像中含有大量椒盐噪声或高斯噪声。直方图被噪声污染,波峰波谷模糊不清。1.滤波去噪预处理是关键。中值滤波对椒盐噪声效果好,高斯滤波对高斯噪声有效。
2. 结合小波阈值去噪等更高级方法预处理图像。

4.3 典型应用场景指南

根据其特性,Otsu算法在以下场景中表现出色:

  • 文档图像二值化:扫描或拍摄的文档、书籍、票据。前提是光照相对均匀,纸张背景与文字墨水对比度明显。这是Otsu的“主场”。
  • 工业视觉中的简单目标分割:在受控的工业环境下,产品与传送带背景颜色对比强烈时,用于定位产品轮廓。
  • 医学图像的初步分割:在某些模态的医学图像中,如细胞涂片、部分X光片,目标与背景对比度较高时,可作为快速初筛或预处理步骤。例如,在“口腔疾病图像分割系统”或“脊柱侧弯深度学习图像分割”的流程中,Otsu可能用于从原始影像中初步分离出感兴趣的大区域,作为更精细分割(如深度学习模型)的输入。
  • 遥感图像的土地覆盖分类初探:对于简单的植被、水体、建筑分类,当不同地物在特定波段灰度差异大时,可用Otsu进行快速粗略分割。
  • 作为更复杂算法的预处理或组成部分:例如,在深度学习时代,Otsu仍可用于生成训练数据的粗略标签,或是在模型推理后,对概率图进行二值化处理的一个可选方法。

个人经验:我经常将Otsu作为一个“基准线”方法。接手一个新的图像分割任务时,我会先用Otsu试一下。如果效果不错,问题就简单解决了;如果效果不好,通过分析它失败的原因(看直方图、看分割结果),我能快速判断问题的症结是光照、噪声还是目标本身与背景难以区分,从而指引我选择更合适的进阶方法。它就像一个高效的“诊断工具”。

5. 进阶话题与变种算法

当你熟练掌握了经典Otsu后,可以进一步探索其改进和变种算法,以应对更复杂的场景。

5.1 多阈值Otsu(Multi-level Otsu)

经典Otsu是二分类的。但有时我们需要将图像分成多个类别,比如遥感图像中的水体、植被、裸地。多阈值Otsu将寻找多个阈值,使多个类之间的总类间方差最大。

假设我们要找M-1个阈值,将图像分成M类。目标函数变为最大化总的类间方差σ_B²,它是各个类之间方差的和。计算量会随着阈值数量指数级增长(穷举搜索)。通常采用最大类间方差法的推广公式,并利用动态规划优化算法来求解,以避免组合爆炸。

在OpenCV中,并没有直接的多阈值Otsu函数。你可以使用skimage.filters中的threshold_multiotsu

from skimage import filters, io, color import matplotlib.pyplot as plt # 读取图像并转为灰度 image = color.rgb2gray(io.imread('multi_class.jpg')) # 应用多阈值Otsu,指定类别数 thresholds = filters.threshold_multiotsu(image, classes=3) print(f"多阈值Otsu计算得到的阈值: {thresholds}") # 根据阈值生成区域 regions = np.digitize(image, bins=thresholds) # 可视化 plt.imshow(regions, cmap='jet') plt.show()

5.2 二维Otsu与二维熵阈值法

经典Otsu只利用了像素的灰度信息(一维直方图)。二维Otsu则同时考虑了像素的灰度值和其邻域平均灰度值。每个像素对应一个二维点(灰度值,邻域均值)。算法在二维平面上寻找一个阈值向量,将二维直方图划分成4个象限,同样以最大化类间方差为目标。

二维Otsu对噪声的鲁棒性更强,因为噪声像素的灰度值可能异常,但其邻域均值可能将其拉回正常范围。但计算量也显著增加。类似地,还有二维最大熵阈值法,它基于信息论,目标是最大化分割后图像的信息量。

5.3 Otsu与其他技术的结合

在实际的复杂系统中,Otsu很少单独使用,而是作为流水线中的一环:

  • Otsu + 形态学操作:这是非常经典的组合。Otsu进行初始分割后,结果可能包含小孔洞、毛刺或断裂。通过后续的开运算(先腐蚀后膨胀)可以去除小噪声点;闭运算(先膨胀后腐蚀)可以填充小孔洞、连接断点。
    import cv2 kernel = np.ones((3,3), np.uint8) binary_cleaned = cv2.morphologyEx(binary_img, cv2.MORPH_CLOSE, kernel) # 闭运算填充 binary_cleaned = cv2.morphologyEx(binary_cleaned, cv2.MORPH_OPEN, kernel) # 开运算去噪
  • Otsu 作为深度学习的前/后处理
    • 预处理:在标注数据不足时,用Otsu生成粗糙的伪标签,用于预训练或辅助训练深度学习模型(如UNet)。
    • 后处理:深度学习模型(如语义分割网络)输出的是每个像素属于各类别的概率图。对于二分类问题,可以直接对概率图使用Otsu自动确定二值化的阈值,这比手动设定一个固定阈值(如0.5)可能更适应不同的图像。

5.4 在资源受限设备上的考量

在诸如K230这类边缘AI芯片上部署时,需要权衡效果和效率。

  • 优势:Otsu算法本身计算量小,内存占用低(主要是一个长度为256的直方图数组),非常适合边缘设备。
  • 挑战:如果图像很大,计算整图的直方图仍有开销。可以考虑:
    1. 下采样:先将图像缩小,在小图上计算Otsu阈值,再应用到原图。
    2. 分块并行计算:如果芯片有多个计算核心,可以将图像分块,各块独立计算阈值(适用于光照均匀场景),或计算局部直方图后合并。
    3. 查找表(LUT)优化:对于固定流程,可以将部分中间计算结果预先存储。
  • “K230脱机调阈值”的启示:这个热词暗示了在嵌入式场景下阈值可能需要动态调整。Otsu的“自动”特性在这里很有价值。你可以设计一个系统,定期(如每天/每次启动)用Otsu计算当前环境下的最佳阈值,并更新到系统中,实现自适应的“脱机调参”,避免因环境光变化导致固定阈值失效。

6. 常见问题与调试技巧实录

即使理解了原理,在实际编码和调试中还是会遇到各种问题。下面是我踩过的一些坑和总结的排查思路。

6.1 问题排查速查表

问题现象可能原因排查步骤与解决方案
计算出的阈值明显不合理(如全黑/全白图的阈值是0或255)1. 图像本身对比度极低,直方图几乎单峰。
2. 代码实现有误,如直方图计算范围错误、方差计算溢出。
1.可视化直方图plt.hist(img.flatten(), bins=50)。检查是否无双峰特征。
2.检查输入图像:确认是单通道灰度图(img.shape为(H,W))。如果是三通道,先转换:cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
3.调试代码:在手动实现的循环中,打印出每个k对应的ω0, ω1, μ0, μ1,检查是否有异常值(如除零)。
Otsu分割结果噪声很多图像本身噪声大,导致直方图在波谷处不干净。1.预处理滤波:先对原图进行高斯模糊cv2.GaussianBlur(img, (5,5), 0)或中值滤波cv2.medianBlur(img, 5)
2.观察滤波后的直方图,看波峰波谷是否更清晰。
目标物体部分丢失或背景分割不干净1. 光照不均。
2. 前景/背景内部灰度不均匀。
3. 目标与背景灰度有重叠。
1.尝试局部阈值:换用cv2.adaptiveThreshold
2.尝试色彩空间:如果目标是彩色的,尝试在HSV的V通道或Lab的L通道应用Otsu,可能比灰度图效果更好。
3.结合边缘信息:可以先做Canny边缘检测,再结合Otsu结果进行逻辑运算。
OpenCV的cv2.threshold返回的阈值是0调用方式错误,未正确组合标志。正确写法retval, binary = cv2.threshold(gray_img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)。注意,THRESH_OTSU是一个标志,需要与THRESH_BINARYTHRESH_BINARY_INV用按位或(|)组合。传入的阈值参数(此处是0)会被忽略。
处理速度慢图像分辨率过高。1.缩放图像:对于实时性要求高的场景,先将图像缩放到固定大小(如640x480)再处理。
2.检查直方图计算cv2.calcHist对于大图可能慢,确保只对灰度图操作。对于超大规模图像,可以随机采样像素点来估算直方图。

6.2 调试与可视化技巧

  1. 直方图是灵魂:任何时候对Otsu效果有疑问,第一件事就是画出图像的灰度直方图,并把你计算出的阈值用竖线标在图上。一眼就能看出阈值是否位于预期的波谷位置。
    plt.figure(figsize=(10,4)) plt.subplot(121) plt.imshow(img, cmap='gray') plt.title('Original Image') plt.axis('off') plt.subplot(122) plt.hist(img.ravel(), bins=256, range=(0,256), color='blue', alpha=0.7) plt.axvline(x=thresh_val, color='red', linestyle='--', linewidth=2, label=f'OTSU Thresh={thresh_val:.1f}') plt.xlabel('Pixel Intensity') plt.ylabel('Frequency') plt.title('Grayscale Histogram') plt.legend() plt.tight_layout() plt.show()
  2. 类间方差曲线:在手动实现时,把每个候选阈值k对应的类间方差σ_B²(k)画出来。一个健康的、适合Otsu的图像,这条曲线应该有一个尖锐的、唯一的峰值。如果曲线平坦或有多个峰值,说明Otsu可能不是最佳选择,或者需要预处理。
  3. 分块测试:对于怀疑光照不均的图片,可以手动将图片分成4x4的网格,对每个小块分别用Otsu计算阈值并二值化,然后拼回。观察不同块的阈值差异。如果差异很大(比如超过50),那就证实了光照不均的问题,必须采用局部方法。

6.3 一个综合案例:处理光照不均的名片图像

假设我们有一张在侧面光下拍摄的名片照片,左边亮右边暗。

import cv2 import numpy as np import matplotlib.pyplot as plt # 模拟或读取一张光照不均的图像 # 这里用梯度模拟光照不均 height, width = 400, 600 gradient = np.tile(np.linspace(0.5, 1.2, width), (height, 1)) # 从左到右变亮 uniform_bg = np.ones((height, width)) * 120 text_region = np.random.randint(30, 60, (height, width)) # 创建名片:背景+深色文字 img_simulated = (uniform_bg - text_region * (text_region < 50)).astype(np.uint8) # 添加光照梯度 img_uneven = np.clip(img_simulated * gradient, 0, 255).astype(np.uint8) # 添加一些文字图案(简单模拟) cv2.putText(img_uneven, 'NAME', (150, 100), cv2.FONT_HERSHEY_SIMPLEX, 2, 80, 3) cv2.putText(img_uneven, 'TEL: 123-4567', (100, 200), cv2.FONT_HERSHEY_SIMPLEX, 1, 60, 2) # 方法1:直接全局Otsu(会失败) thresh_global, binary_global = cv2.threshold(img_uneven, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 方法2:高斯模糊后全局Otsu img_blur = cv2.GaussianBlur(img_uneven, (5,5), 0) thresh_blur, binary_blur = cv2.threshold(img_blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 方法3:局部自适应阈值(高斯加权) binary_adaptive = cv2.adaptiveThreshold(img_uneven, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2) # 块大小11,常数2 # 可视化比较 fig, axes = plt.subplots(2, 3, figsize=(15, 10)) axes[0,0].imshow(img_uneven, cmap='gray') axes[0,0].set_title('Original (Uneven Illumination)') axes[0,0].axis('off') axes[0,1].hist(img_uneven.ravel(), 256, [0,256], color='blue') axes[0,1].axvline(thresh_global, color='red', linestyle='--') axes[0,1].set_title(f'Global Histogram\nOtsu Thresh={thresh_global:.0f}') axes[0,2].imshow(binary_global, cmap='gray') axes[0,2].set_title('Global Otsu Result\n(Poor)') axes[0,2].axis('off') axes[1,0].imshow(binary_blur, cmap='gray') axes[1,0].set_title('Otsu after Gaussian Blur\n(Slightly Better)') axes[1,0].axis('off') axes[1,1].imshow(binary_adaptive, cmap='gray') axes[1,1].set_title('Adaptive Thresholding\n(Best for this case)') axes[1,1].axis('off') # 方法4:尝试分块Otsu(简易版) binary_block = np.zeros_like(img_uneven) block_h, block_w = 100, 100 for y in range(0, height, block_h): for x in range(0, width, block_w): block = img_uneven[y:y+block_h, x:x+block_w] if block.size > 0: t, b = cv2.threshold(block, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) binary_block[y:y+block_h, x:x+block_w] = b axes[1,2].imshow(binary_block, cmap='gray') axes[1,2].set_title('Block-wise Otsu\n(Boundary Artifacts)') axes[1,2].axis('off') plt.tight_layout() plt.show()

运行这段代码,你会清晰地看到:

  • 全局Otsu:由于光照不均导致直方图严重偏离双峰,计算出的阈值无法兼顾亮部和暗部,结果要么左边文字消失,要么右边背景变黑。
  • 模糊后Otsu:略有改善,但本质问题未解决。
  • 局部自适应阈值:效果最好,因为它为每个像素点根据其邻域单独计算阈值,完美适应了光照变化。
  • 分块Otsu:效果介于两者之间,但在块边界处会产生明显的“棋盘格”效应。

这个案例直观地告诉我们:Otsu不是银弹,理解其适用前提(全局、双峰直方图)比盲目调用更重要。当它失效时,分析直方图和图像特性,才能找到正确的解决路径。