Tesseract OCR之基线拟合和单词检测
1. 基线拟合(Baseline Fitting)
目标
找到文本行的虚拟基准线(如英文中字母底部对齐的线),即使字符存在断裂、倾斜或噪声干扰。
(1) 行提取(Line Extraction)
- 水平投影峰值检测:
对二值图像逐行计算黑色像素数,通过寻找局部最大值定位文本行粗位置。h_proj = np.sum(binary_img, axis=1) # 假设文字为黑色(0),背景为白色(255) peaks = [] for i in range(1, len(h_proj)-1):if h_proj[i] < h_proj[i-1] and h_proj[i] < h_proj[i+1]:peaks.append(i) # 局部极小值(行间空白)
- 聚类优化(解决波峰不清晰问题):
使用DBSCAN对字符blob的纵坐标聚类,自动合并同一行的blob。from sklearn.cluster import DBSCAN y_centers = [y + h/2 for (x,y,w,h) in blobs] # 所有blob的纵向中心 clustering = DBSCAN(eps=5, min_samples=3).fit(np.array(y_centers).reshape(-1,1)) line_labels = clustering.labels_ # 同一标签的blob属于同一行
(2) 基线计算
- 下边界中位数法:
对每行的所有字符blob,取其底部坐标(y_blob + height_blob
)的中位数作为基线初始值。line_bottoms = [y + h for (x,y,w,h) in blobs if label == line_num] baseline_y = np.median(line_bottoms) # 中位数抗噪声
- 最小二乘法拟合直线:
若文本行有倾斜,用所有blob底部点拟合直线y = kx + b
:bottoms = np.array([(x + w/2, y + h) for (x,y,w,h) in blobs]) # (x, y_bottom) A = np.vstack([bottoms[:,0], np.ones(len(bottoms))]).T k, b = np.linalg.lstsq(A, bottoms[:,1], rcond=None)[0] # 解最小二乘
场景示例
假设有一行歪歪扭扭的手写英文(模拟真实OCR场景):
"hello world" 的实际二值图像:h e l l o w o r l d
(每个字母的坐标和宽高如下表)
1. 字符Blob数据(模拟检测结果)
字母 | x (左侧) | y (顶部) | 宽度 (w) | 高度 (h) | 底部 (y + h) |
---|---|---|---|---|---|
h | 10 | 5 | 8 | 12 | 17 |
e | 20 | 7 | 7 | 10 | 17 |
l | 29 | 3 | 4 | 14 | 17 |
l | 35 | 6 | 4 | 13 | 19 |
o | 41 | 8 | 7 | 9 | 17 |
w | 55 | 2 | 9 | 14 | 16 |
o | 66 | 5 | 7 | 12 | 17 |
r | 75 | 9 | 6 | 8 | 17 |
l | 83 | 4 | 4 | 14 | 18 |
d | 89 | 6 | 7 | 11 | 17 |
第一步:行提取(Line Extraction)
目标:确认哪些Blob属于同一行(本例只有一行,省略DBSCAN聚类)。
第二步:基线拟合(Baseline Fitting)
方法1:中位数法
- 取所有字母的底部坐标:
[17, 17, 17, 19, 17, 16, 17, 17, 18, 17]
- 计算中位数:排序后第5/6个值是
17
→ 基线y=17
问题:字母l
的底部是19,导致基线偏高(不准确)。
方法2:最小二乘法拟合直线
- 计算每个字母的参考点(取blob底部中心):
points = [(10 + 8/2, 17), # h → (14,17)(20 + 7/2, 17), # e → (23.5,17)(29 + 4/2, 17), # l → (31,17)(35 + 4/2, 19), # l → (37,19) ← 异常点(41 + 7/2, 17), # o → (44.5,17)(55 + 9/2, 16), # w → (59.5,16)... ]
- 用最小二乘拟合直线
y = kx + b
:- 输入:所有
(x, y_bottom)
点 - 输出:假设拟合结果为
y = -0.02x + 17.8
(一条轻微下斜的线,更贴合实际)
- 输入:所有
2. 单词检测(Word Segmentation)
核心问题
如何区分字符间间隙(同一单词)与单词间间隙?
解决方案
(1) 固定间距文本(如打字机字体)
- 直接均匀切分:若字符宽度标准差 < 阈值(如平均宽度的10%),则按固定间隔切分。
char_widths = [w for (x,y,w,h) in blobs] if np.std(char_widths) / np.mean(char_widths) < 0.1:words = np.split(blobs, len(blobs) // avg_char_width)
(2) 非固定间距文本(常见情况)
-
归一化间隙计算:
对相邻blob,计算水平间隙与平均字符宽的比值:
dnorm=xnext−(xprev+wprev)avg_width d_{\text{norm}} = \frac{x_{\text{next}} - (x_{\text{prev}} + w_{\text{prev}})}{\text{avg\_width}} dnorm=avg_widthxnext−(xprev+wprev)def calc_gaps(blobs):blobs.sort(key=lambda b: b[0]) # 按x排序gaps = []for i in range(len(blobs)-1):gap = blobs[i+1][0] - (blobs[i][0] + blobs[i][2])gaps.append(gap / np.mean([b[2] for b in blobs]))return gaps
-
动态阈值切分:
若d_norm > threshold
(通常1.3~2.0),视为单词分界:gaps = calc_gaps(blobs) word_boundaries = [i+1 for i, gap in enumerate(gaps) if gap > 1.5] words = np.split(blobs, word_boundaries)
-
动态阈值选择技巧:
-
双峰法:对间隙分布直方图找两个峰(字符内间隙/单词间间隙),取谷底作为阈值。
场景示例
1. 计算相邻字母间隙
字母对 | 前字母右边界 (x + w) | 后字母左边界 (x) | 间隙宽度 |
---|---|---|---|
h-e | 10 + 8 = 18 | 20 | 2 |
e-l | 20 + 7 = 27 | 29 | 2 |
l-l | 29 + 4 = 33 | 35 | 2 |
l-o | 35 + 4 = 39 | 41 | 2 |
o-w | 41 + 7 = 48 | 55 | 7 |
w-o | 55 + 9 = 64 | 66 | 2 |
… | … | … | … |
2. 归一化间隙宽度
- 平均字符宽度:
avg_width = (8+7+4+4+7+9+7+6+4+7)/10 ≈ 6.3
- 归一化间隙:
o-w间隙
= 7 / 6.3 ≈ 1.11- 其他间隙 ≈ 2 / 6.3 ≈ 0.32
3. 动态阈值分割
假设设定阈值 >1.0
为单词间隔:
o-w间隙=1.11 > 1.0
→ 在此处切分- 其他间隙均小于阈值 → 不切分
最终分割结果:
[h e l l o]
和 [w o r l d]
(成功分离两个单词)