二值化是图像处理中最"武断"的操作: 每个像素只有两种命运------要么全黑,要么全白。 但正是这种武断,让机器能够"读懂"文字、识别形状。
一、什么是二值化
二值化(Binarization / Thresholding):将灰度图像转换为只有黑(0)和白(255)两种值的图像。
输入:灰度图(像素值 0~255)
输出:二值图(像素值只有 0 或 255)
规则:
像素亮度 ≥ 阈值(threshold) → 白色(255)
像素亮度 < 阈值 → 黑色(0)
为什么需要二值化?
人类用眼睛看图时,自然能区分前景和背景。但计算机处理灰度图时,"哪里是字,哪里是纸"这个问题并不简单------所有像素都是 0~255 之间的连续值。
二值化把这个连续问题变成了离散问题:任意像素要么属于前景,要么属于背景,没有中间地带。
典型应用场景
| 场景 | 说明 |
|---|---|
| OCR(文字识别) | 把扫描文档变成黑白,文字和背景分离 |
| 条码/二维码识别 | 黑条白条的判断 |
| 医学图像分割 | 从 CT 图像中分离骨骼和软组织 |
| 手写识别 | 提取笔画轮廓 |
| 运动检测 | 前景物体 vs 静态背景 |
二、固定阈值法
公式
css
I' = 255 if I ≥ T
I' = 0 if I < T
其中 T 是手动指定的阈值,默认通常取 128(中间灰)。
实现
swift
public struct ThresholdFilter: ImageFilter {
/// 阈值,像素亮度 ≥ threshold 变白,< threshold 变黑
public let threshold: UInt8
public init(threshold: UInt8 = 128) {
self.threshold = threshold
}
public func apply(to bitmap: MLBitmap) -> MLBitmap {
// Step 1:先灰度化(Sobel 只对亮度操作,不是颜色)
let gray = GrayscaleFilter().apply(to: bitmap)
var result = gray
for i in stride(from: 0, to: result.pixels.count, by: 4) {
let luminance = result.pixels[i] // 灰度图中 R = G = B
let v: UInt8 = luminance >= threshold ? 255 : 0
result.pixels[i] = v
result.pixels[i + 1] = v
result.pixels[i + 2] = v
// Alpha 不变
}
return result
}
}
为什么先灰度化?
彩色图像的 RGB 三通道需要变成单一的"亮度"才能做阈值比较。直接对 R/G/B 分别阈值化,会得到三张独立的黑白图,颜色信息混杂,不是我们想要的。
三、阈值的选择:最关键的参数
固定阈值的最大问题:阈值选错,结果完全不对。
场景 1:高亮文档
白纸黑字,背景 200250,文字 050。
- 阈值 128:文字全黑 ✅,背景全白 ✅,效果完美
场景 2:光照不均匀的文档
文档左侧曝光过度(背景 220),右侧曝光不足(背景 150)。
- 阈值 128:左侧背景 220 → 白 ✅,右侧背景 150 → 白 ✅(还行)
- 阈值 180:左侧背景 220 → 白 ✅,右侧背景 150 → 黑 ❌(背景被判为前景)
固定阈值在光照不均匀时会失效。
场景 3:低对比度图像
灰色背景 160,深灰文字 120。
- 阈值 128:文字 120 → 黑 ✅,背景 160 → 白 ✅(勉强可以)
- 但信噪比很差,稍有噪点就会误判
四、Otsu 算法:自动寻找最优阈值
Otsu 算法 (1979,大津展之)是经典的自适应阈值方法,核心思想:找到一个阈值 T,使得分割后前景和背景的类间方差最大。
直觉理解
方差描述数据的"散布程度"。如果 T 把图像分成两组:
- 组 1(暗区,亮度 < T):方差小 → 组内像素相似
- 组 2(亮区,亮度 ≥ T):方差小 → 组内像素相似
- 两组之间方差大 → 两组差异明显
最好的 T = 让类间方差最大的那个值。
数学推导
设灰度值 i ∈ [0, 255],频率(概率)为 p[i](直方图归一化后的值)。
对于阈值 T,将像素分为两类:
- 类 0(背景):像素值 0 ~ T-1
- 类 1(前景):像素值 T ~ 255
计算各类的概率和均值:
css
ω₀(T) = Σ p[i] (i=0 to T-1) ← 背景比例
ω₁(T) = Σ p[i] (i=T to 255) ← 前景比例
μ₀(T) = Σ i·p[i]/ω₀ ← 背景均值
μ₁(T) = Σ i·p[i]/ω₁ ← 前景均值
μ = ω₀·μ₀ + ω₁·μ₁ ← 全局均值
类间方差(Between-class variance):
r
σ²_B(T) = ω₀(T)·ω₁(T)·[μ₀(T) - μ₁(T)]²
最优阈值:
r
T* = argmax σ²_B(T) (T ∈ [0, 255])
Swift 实现
swift
/// 计算 Otsu 最优阈值
static func otsuThreshold(for bitmap: MLBitmap) -> UInt8 {
// 先灰度化
let gray = GrayscaleFilter().apply(to: bitmap)
// 统计直方图(256 个桶)
var histogram = [Int](repeating: 0, count: 256)
for i in stride(from: 0, to: gray.pixels.count, by: 4) {
histogram[Int(gray.pixels[i])] += 1
}
let total = gray.width * gray.height
var sumAll: Double = 0
for i in 0..<256 {
sumAll += Double(i * histogram[i])
}
var sumBackground: Double = 0
var weightBackground = 0
var maxVariance: Double = 0
var bestThreshold: UInt8 = 128
for t in 0..<256 {
weightBackground += histogram[t]
if weightBackground == 0 { continue }
let weightForeground = total - weightBackground
if weightForeground == 0 { break }
sumBackground += Double(t * histogram[t])
let meanBackground = sumBackground / Double(weightBackground)
let meanForeground = (sumAll - sumBackground) / Double(weightForeground)
let variance = Double(weightBackground) * Double(weightForeground)
* (meanBackground - meanForeground)
* (meanBackground - meanForeground)
if variance > maxVariance {
maxVariance = variance
bestThreshold = UInt8(t)
}
}
return bestThreshold
}
Otsu 算法的时间复杂度是 O(256) = O(1)(与图像大小无关,只取决于直方图),非常高效。
五、局部自适应阈值(CLAHE 思路)
Otsu 是全局阈值------整张图用同一个 T。当光照极不均匀时,还需要局部阈值:
将图像分成若干小块(如 32×32)
对每个小块单独计算 Otsu 阈值
用该块的局部阈值做二值化
更进阶的是 CLAHE(Contrast Limited Adaptive Histogram Equalization,限制对比度自适应直方图均衡),这是 Phase 2 的内容。
六、二值化的后处理
原始二值化结果通常有噪点(孤立的黑点或白点),需要后处理:
形态学操作
腐蚀(Erosion):用一个小结构元素(如 3×3 全白)扫描,只有结构元素完全在白区内时才保留白色。效果:缩小白色区域,消除细小噪点。
膨胀(Dilation):相反操作,扩大白色区域,填补空洞。
开运算(Opening) = 腐蚀 → 膨胀:去除小噪点,保留大物体。
闭运算(Closing) = 膨胀 → 腐蚀:填补小孔洞,连接断裂边缘。
这些操作是 Phase 2 vImage 的标配功能。
七、二值化与边缘检测的关系
二值化和 Sobel 边缘检测经常配合使用:
swift
// Sobel 输出的是梯度强度图(每个像素是边缘强度 0~255)
let edges = SobelEdgeFilter(preBlur: true).apply(to: bitmap)
// 再做二值化,只保留强边缘
let strongEdges = ThresholdFilter(threshold: 50).apply(to: edges)
流程:
原图 → 灰度化 → 高斯模糊 → Sobel 梯度 → 阈值化 → 二值边缘图
这是 Canny 边缘检测的简化版(Canny 还增加了非极大值抑制和双阈值法,Phase 2 可实现)。
八、验证测试
swift
func testThresholdOutputBinary() {
// 任意颜色的图,经过二值化后只有黑白两种值
let bmp = MLBitmap(width: 10, height: 10, filling: .init(r: 130, g: 80, b: 200))
let result = ThresholdFilter().apply(to: bmp)
for y in 0..<10 {
for x in 0..<10 {
let px = result[x, y]
XCTAssertTrue(px == .white || px == .black,
"二值化输出只应包含黑或白,实际:\(px)")
}
}
}
func testThresholdAboveBecomesWhite() {
// 亮度 200 ≥ 128 → 白色
var bmp = MLBitmap(width: 1, height: 1, filling: .white)
bmp[0, 0] = MLBitmap.Pixel(r: 200, g: 200, b: 200)
let result = ThresholdFilter(threshold: 128).apply(to: bmp)
XCTAssertEqual(result[0, 0], .white)
}
九、小结
| 概念 | 核心内容 |
|---|---|
| 二值化目的 | 区分前景和背景,简化图像信息 |
| 固定阈值 | 简单高效,光照均匀时效果好 |
| Otsu 算法 | 自动寻找最优阈值,适合光照均匀但不知道阈值时 |
| 局部阈值 | 光照不均匀时的解决方案 |
| 先灰度化 | 二值化需要单通道亮度,彩色图先灰度化 |
| 后处理 | 形态学操作去噪(腐蚀/膨胀/开/闭运算) |
思考题
- 对一张全黑图像(所有像素 = 0)做 Otsu 二值化,结果是什么?Otsu 算法此时有没有意义?
- 医学图像中,CT 扫描的骨骼很亮,软组织较暗,如何用二值化分离骨骼?
- 手机相机拍摄的照片,文字区域可能因为光照角度不同而出现阴影,导致同一张纸的背景亮度不均。设计一个算法流程,解决这个问题。
提示:3. 分块局部均值阈值化:对每个局部区域,阈值 = 局部均值 × 系数(如 0.85)。这比 Otsu 更鲁棒,是手机文档扫描 App 的常用算法。
往期推荐: