基于Bounding Box和统计方法矫正OCR文本的方向

前言

现在很多 OCR 模型实际上已经支持了一定程度内的旋转文本的识别,即图片中的非水平方向的文本可以正确识别其方向并按照正常的水平顺序返回该文本;但这仅限于每一个 Bounding Box 内的文本,因为 OCR 模型一般原始输出都是从图片中获取一个个单独的文本区域——即 Bounding Box,然后识别该区域的文本,并不会对这些 Bounding Box 进行拼接得到按正确顺序返回的完整文本,如:

image.png

从图中右侧不难发现哪怕图片中的文本有一些旋转,OCR 模型也可以识别出这些文本块的方向,并返回正确的文本顺序;但是如果要以正确的顺序(从左至右,从上至下)返回所有的文本行,就需要自己去处理了。

一般来说,我们可以假设图片中的文本旋转方向都是一样的(适用于常见的单页文本拍照或者扫描件),因此只需要得到一个整体的旋转方向,然后基于这个方向进行一个逆运算即可得到正常水平方向的 Bounding Box 位置,也就能拼接得到视觉层面上正常顺序的完整文本了。

PCA(主成分分析)

一开始我想到了 PCA,后面发现其实我有点望文生义了,我以为这个主成分指定的是从一堆数据样本中找到出现频率最高的那个数据,实际上 PCA 是对特征数据进行降维处理,重建更低数量的坐标轴,以更低的维度来描述特征数据😢。

局部截取_20251016_122940.png

所以我让 AI 使用 PCA 方法得到的效果也不尽如人意(毕竟方法就不适用),原因就是上面图中提到的 PCA 偏向于方差较大的情况,导致出现个别异常数据就会特别不准:

局部截取_20251023_115641.png

关于 PCA 的具体原理和用法可以看看下面的文章:

Bounding Box(边界框/包围盒)

这个术语一般指的是包围某个物体的最小矩形,最常见的形式是一个轴对齐的矩形,即 AABB(axis-aligned Bounding box)。

一般图像中的 AABB 是以左上角为原点,x 方向从左到右,y 方向从上至下为坐标系的。

实现过程

这里以 RpaidOCR + PP-OCRv5 mobile 模型为例。

基于 Bounding Box 计算文本块方向

RpaidOCR 输出的 Bounding Box 原始结果为四个点坐标,以顺时针方向进行排列,分别是左上、右上、右下和左下,如:

局部截取_20251027_121246.png

上图红框标注的是原始结果中的第一个 Bounding Box,其坐标为:

1
2
3
4
[[205.,   9.],
[248., 19.],
[243., 42.],
[200., 33.]]

由于坐标系是左手系加上返回的点的顺序是顺时针,所以这里我们可以假设长边的方向就是该 Bounding Box 的整体方向(因为大多数时候文字数量会超过一两个字,导致 Bounding Box 的宽度大于高度),即旋转量;对应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_orientation_vector(box):
"""根据矩形框较长的一边计算归一化的方向向量。"""
# 假设box有4个点
p0, p1, p2 = box[0], box[1], box[2]

# 计算两条相邻边的平方长度以找到较长的一边
vec1 = np.array(p1) - np.array(p0)
vec2 = np.array(p2) - np.array(p1)

len_sq1 = np.dot(vec1, vec1)
len_sq2 = np.dot(vec2, vec2)

# 选择较长边的向量
vec = vec1 if len_sq1 >= len_sq2 else vec2

norm = np.linalg.norm(vec)
return vec / norm if norm > 0 else np.array([1.0, 0.0])

不过这个方法计算得到的方向只在 (-90°, 90°] 范围内是正确的,因为不知道文本到底是从左到右还是从右到左,所以这里假设的文本方向就是从左到右,因此一旦图像中的文本块发生了翻转,计算得到的方向就是其真正旋转方向完全相反的方向。

基于文本块方向统计得到整体方向

在使用上面的方法计算得到所有文本块的方向后,理论上就可以得到当前输入图像中文本的整体旋转方向了;正如前言中所说的,一开始我所想到的 PCA 方法并不适合,实际上对于当前任务只需要一个很简单的统计方法就可以得到想要的结果——统计文本块方向的中位数即可。

因为在通常的单页文本图像中,所有的文本块方向理论上都是一样的,且宽度很短的文本块比较少,这样我们计算得到的 Bounding Box 方向实际上除了个别异常值,大部分的方向角度是很接近的

image.png

P.s:一开始我写这篇文章的时候还真以为算法是基于 PCA 实现的,结果一看代码,AI 中途换成了效果更好的中位数,我居然都没发现🙈

矫正旋转方向

局部截取_20251027_172732.png

上面是二维平面中的旋转变换公式,其中 θ\theta 就是旋转角度,上面计算得到的整体方向进行一个逆旋转就可以矫正文本为正常的水平方向了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 步骤3: 计算旋转矩阵,将主方向旋转到水平方向(1, 0)
cos_theta = np.cos(-principal_angle)
sin_theta = np.sin(-principal_angle)

# 旋转矩阵
rotation_matrix = np.array([
[cos_theta, -sin_theta],
[sin_theta, cos_theta]
])

# 步骤4: 计算每个文本框在旋转后的坐标系中的边界
box_info = []
for i, box in enumerate(boxes):
# 获取所有顶点坐标
points = box # shape: (4, 2)

# 旋转所有顶点
rotated_points = np.dot(points, rotation_matrix.T)

# 计算旋转后的边界框
x_coords = rotated_points[:, 0]
y_coords = rotated_points[:, 1]
x_min, x_max = np.min(x_coords), np.max(x_coords)
y_min, y_max = np.min(y_coords), np.max(y_coords)

box_info.append({
"index": i,
"x_min": x_min,
"x_max": x_max,
"y_min": y_min,
"y_max": y_max,
"x_center": (x_min + x_max) / 2,
"y_center": (y_min + y_max) / 2,
"width": x_max - x_min,
"height": y_max - y_min
})

按行聚合文本

在完成旋转方向之后,我们就得到了正常水平方向的文本图像了,这种情况下得到的文本块(Bounding Box)都遵循着从左往右,从上往下的排列,因此可以基于此规律来设计文本聚合算法。一种简单的思路就是根据 Bounding Box 在 x 和 y 方向上的重叠度来判断它们是否属于同一行:

  • 当在 y 方向(竖直方向)上重叠度超过某个阈值时可以初步认为是同一行
  • 当在 x 方向(水平方向)上重叠度超过某个阈值时可以认为不是同一行(因为同一行中的文本块之间不会出现太多重叠)
  • 同一行内的文本块按照 x 坐标(左边界)进行升序排序
  • 不同行之间则按 y 坐标(上边界)进行升序排序

对应的算法实现大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# 步骤5: 使用图算法将文本框分组到行中(在旋转后的坐标系中)
adj = [[] for _ in range(num_boxes)]
connection_count = 0

for i in range(num_boxes):
for j in range(i + 1, num_boxes):
box_i = box_info[i]
box_j = box_info[j]

# 检查1:垂直重叠是否足够(判断是否在同一行)
height_i = box_i["height"]
height_j = box_j["height"]

if height_i < 1e-6 or height_j < 1e-6:
continue

y_overlap = max(0, min(box_i["y_max"], box_j["y_max"]) - max(box_i["y_min"], box_j["y_min"]))
y_overlap_ratio = y_overlap / min(height_i, height_j)

if y_overlap_ratio <= y_overlap_thresh:
continue # 垂直重叠不够,不在同一行

# 检查2:水平重叠不能太多(避免重叠文字被错误分组)
width_i = box_i["width"]
width_j = box_j["width"]

if width_i > 1e-6 and width_j > 1e-6:
x_overlap = max(0, min(box_i["x_max"], box_j["x_max"]) - max(box_i["x_min"], box_j["x_min"]))
x_overlap_ratio = x_overlap / min(width_i, width_j)

if x_overlap_ratio > u_overlap_thresh:
continue # 水平重叠太多,不在同一行

# 如果两个检查都通过,则连接它们
adj[i].append(j)
adj[j].append(i)
connection_count += 1

# 步骤6: 使用深度优先搜索找到连通分量(每个分量代表一行)
visited = [False] * num_boxes
lines_of_indices = []

for i in range(num_boxes):
if not visited[i]:
component = []
stack = [i]
visited[i] = True

while stack:
u = stack.pop()
component.append(u)
for v in adj[u]:
if not visited[v]:
visited[v] = True
stack.append(v)

lines_of_indices.append(component)

# 步骤7: 对每行内的文本框按水平位置排序,然后按行的垂直位置排序
sorted_lines = []
for line_idx, line_indices in enumerate(lines_of_indices):
line_boxes = [box_info[i] for i in line_indices]
line_boxes.sort(key=lambda b: b["x_center"]) # 行内按水平位置排序

line_text = [texts[b["index"]] for b in line_boxes]
avg_y = np.mean([b["y_center"] for b in line_boxes])

sorted_lines.append({"text": line_text, "y_pos": avg_y})

# 按垂直位置对行进行排序
sorted_lines.sort(key=lambda l: l["y_pos"])

上面的代码实际上是 AI 基于我给出的算法思路实现的,现在回过头来细细品味(最初实现的时候我都没咋 code review,直接一遍通过)确实代码写得比我好😂,我要想到基于图算法和深度优先遍历这种实现估计得花一会时间。

最终聚合效果如下:

image.png

后话

模型对于文本块方向的识别

如果 OCR 模型可以直接输出这些 Bounding Box 的方向向量,那么进行旋转矫正准确性就会更高了,其实从模型返回的原始识别结果来看,至少 PP-OCRv5 这个模型是可以识别出旋转角度超过 (-90°, 90°] 的文本的:

image.png

可以看出文本块中返回的文本顺序是正确的。

轻量级 OCR

由于我的服务要运行在纯 CPU 环境,不能使用 GPU 进行推理,因此就选择了 Rapid OCR 这个轻量级且快速的 OCR 框架,其提供的模型大多是基于 PaddleOCR 的 onnx 版本,因此特别适合运行在 CPU 环境,尤其是 mobile 版本,模型大小只有 10M 左右,推理速度可达到 1~2s/每张图:

局部截取_20251030_104008.png

局部截取_20251030_104053.png

不过既然追求了推理速度,那么其识别精度肯定有所下降,尤其是对于一些分辨率比较低的图像。尽管 RapidOCR 提供了精度更高的服务端 onnx 模型,但是其推理速度已经不适合在 CPU 环境运行了,因为实测下来其推理时间大于 10s,这显然不符合实际业务接口的响应速度了。

更强大的 OCR 模型

当然,OCR 模型更适合的推理环境肯定是 GPU,有很多大参数的 OCR 只适用于有 GPU 的情况;此外,近年来也有很多多模态LLM 可以承担 OCR 的任务,比如 Qwen 的 VL 模型;最近(2025-10)deepseek 也推出了一个 3B 参数的 OCR 模型,除了传统的文字识别,还可以识别图表结构和指定特征等 [1][2]

局部截取_20251030_105619.png


  1. https://huggingface.co/deepseek-ai/DeepSeek-OCR ↩︎

  2. https://huggingface.co/spaces/khang119966/DeepSeek-OCR-DEMO ↩︎