视觉模型 OCR 结果怎样按行合并文本
来源:17golang原创
时间:2026-10-17 23:46:37 461浏览 收藏
视觉模型返回的 OCR 结果,通常是一组带文字和矩形框的片段,而不是已经排好版的段落。要按行合并,关键不是把数组直接按 x 或 y 排序,而是先把坐标还原到原图语义,再用文字框的垂直中心和动态行高判断“属于哪一行”,最后在行内按横坐标排序。
本文只讨论框坐标排序和行聚合,不讨论 OCR 模型训练或识别率提升。Hugging Face Transformers 的视觉处理器会负责图像尺寸、归一化等预处理,目标检测后处理则可以把模型输出转换为原图上的边界框。官方资料可从这里复制查看:
官方资料:https://huggingface.co/docs/transformers/image_processors
目标检测文档:https://huggingface.co/docs/transformers/tasks/object_detection
稳定的做法是“先统一 box,再按垂直中心聚行,最后按每行的 x 坐标拼接”。固定写死一个全局 y 阈值很容易在字号变化、缩放或多栏文本中误合并;阈值应该跟当前片段的高度或候选行高度相关。
为什么 OCR 结果会看起来像打乱的数组
同一行的文字可能被切成多个片段,模型返回顺序还可能受候选框得分、检测头输出或图像预处理影响。尤其是先缩放、补边或裁剪再推理的模型,如果没有把框映射回原图,直接使用模型输入尺寸上的坐标,后面的行判断就会整体偏移。
把每个片段抽象成下面这个对象即可:
{
"text": "订单号",
"score": 0.96,
"box": [120.0, 82.0, 184.0, 108.0]
}
这里的 box 采用 [x1, y1, x2, y2],左上角是起点,右下角是终点。宽度是 x2 - x1,高度是 y2 - y1,垂直中心是 (y1 + y2) / 2。这些派生值最好在进入排序函数时一次性算出,避免后续代码混用左上角、中心点和底边。

先把不同返回格式统一成可排序片段
实际模型的字段名可能不同:有的返回 bbox,有的返回 box;有的坐标是绝对像素,有的坐标是 0 到 1 的归一化值。排序函数不应该同时处理这些差异,先建立一个内部格式。
from dataclasses import dataclass
from typing import Iterable, Sequence
@dataclass
class OCRFragment:
# 后续排序只依赖这几个统一字段。
text: str
score: float
x1: float
y1: float
x2: float
y2: float
@property
def width(self) -> float:
# 防止异常框导致宽高为负,调用方也能尽早发现坏数据。
return max(0.0, self.x2 - self.x1)
@property
def height(self) -> float:
return max(0.0, self.y2 - self.y1)
@property
def center_y(self) -> float:
# 用垂直中心判断归行,比只看 y1 对字号变化更稳。
return (self.y1 + self.y2) / 2.0
def to_fragments(items: Iterable[dict], image_width: int, image_height: int) -> list[OCRFragment]:
fragments: list[OCRFragment] = []
for item in items:
text = str(item.get("text", "")).strip()
raw_box: Sequence[float] = item.get("box") or item.get("bbox") or []
if not text or len(raw_box) != 4:
# 空文本和非法框不进入行聚合,避免污染邻近行。
continue
x1, y1, x2, y2 = map(float, raw_box)
# 归一化坐标通常落在 0 到 1 之间,转换为原图像素坐标。
if max(abs(x1), abs(y1), abs(x2), abs(y2))
这里的坐标归一化只是一个常见约定,不能替代具体模型文档。若模型明确规定坐标来自裁剪图或经过 padding 的画布,就应按模型的后处理接口把坐标恢复到原图,而不是仅仅乘上宽高。
不要用固定 y 值,先按垂直中心排序
最小可用的排序键是 (center_y, x1),但它只能解决“从上到下、从左到右”的粗排序,不能处理两行文字中心很接近的情况。行聚合应当单独进行。
def order_fragments(fragments: list[OCRFragment]) -> list[OCRFragment]:
# 先按垂直中心分层,再以左边界作为同层内的稳定次序。
return sorted(fragments, key=lambda part: (part.center_y, part.x1))
为什么用中心而不是 y1?同一行中大字和小字的顶部可能不同,但它们的中心往往仍然接近。中心点不是万能规则,遇到旋转文字或复杂表格时还需要角度、列区域或版面模型;但对普通横排文本,它是一个成本很低的起点。
用动态阈值把片段聚成文字行
可以维护一组候选行,每个候选行保存当前行的片段、中心和代表性高度。新片段到来时,寻找与它垂直中心距离最近且没有超过动态阈值的候选行;找不到时创建新行。
from statistics import median
@dataclass
class OCRLine:
# 一个 OCRLine 代表一行的片段集合,而不是最终字符串。
fragments: list[OCRFragment]
@property
def center_y(self) -> float:
return sum(item.center_y for item in self.fragments) / len(self.fragments)
@property
def typical_height(self) -> float:
heights = [item.height for item in self.fragments if item.height > 0]
return median(heights) if heights else 1.0
def cluster_rows(fragments: list[OCRFragment], tolerance_ratio: float = 0.55) -> list[OCRLine]:
rows: list[OCRLine] = []
# 由上到下处理,让候选行的中心随着同一行片段逐步稳定。
for part in sorted(fragments, key=lambda item: (item.center_y, item.x1)):
best_row: OCRLine | None = None
best_distance = float("inf")
for row in rows:
distance = abs(part.center_y - row.center_y)
# 用两者中较小的代表高度控制阈值,避免大字把相邻行吸进来。
scale = min(part.height, row.typical_height)
limit = max(2.0, scale * tolerance_ratio)
if distance

tolerance_ratio 不宜写成一个永远正确的常数。清晰的横排文档可以从较小值开始;字体大小差异大、图片模糊或检测框偏松时,需要适当放宽。真正重要的是阈值与框高相关,而不是把某个像素值复制到所有分辨率。
行内按 x 排序,再决定是否加空格
得到行之后,必须重新按 x1 排序。文字片段之间是否插入空格,则要根据间隙和字符形态决定:中文短语通常直接拼接,英文单词、数字和混排内容通常需要保留分隔。
import re
def likely_needs_space(left: str, right: str) -> bool:
# 拉丁字母或数字相邻时保留分隔,避免 URL、变量名和英文单词粘连。
latin = re.compile(r"[A-Za-z0-9]")
return bool(left and right and latin.search(left[-1:]) and latin.search(right[:1]))
def merge_line(row: OCRLine) -> str:
parts = sorted(row.fragments, key=lambda item: item.x1)
if not parts:
return ""
result = parts[0].text
for previous, current in zip(parts, parts[1:]):
gap = current.x1 - previous.x2
# 用当前行的典型字高判断间隙,避免不同分辨率使用同一像素阈值。
gap_limit = max(2.0, row.typical_height * 0.35)
if gap > gap_limit or likely_needs_space(previous.text, current.text):
result += " "
result += current.text
return result.strip()
上面的空格策略仍然是启发式。对于表格,列间大间隙可能应该保留为列分隔符;对于中文书名号、标点和括号,通常不应机械插入空格。工程上可以把这一步做成可替换策略,而不是把所有语言规则塞进聚行函数。
低置信度、多栏和竖排文字怎样处理
行聚合只解决空间关系,不能替识别质量或版面理解做决定。实际接入时,至少要把下面几类边界单独处理:
- 低置信度片段:先记录分数,不要一律静默丢弃。可以设一个业务阈值,或者保留到人工复核队列。
- 多栏文档:先按列区域分桶,再在每一列内部聚行;否则左栏第一行和右栏第一行可能被交错拼接。
- 竖排或旋转文字:先根据四点框或方向角建立阅读方向,不能继续假设 x 轴就是行内方向。
- 表格:行聚合后还要维护列边界,单纯拼成段落会丢失单元格关系。
- 框重叠:重复检测的片段应在聚行前去重,否则会产生重复词。去重规则应结合重叠比例、置信度和文本相似度。
一个简单的组合入口如下,它把字段统一、聚行和行内合并串起来:
def merge_ocr_text(items: list[dict], image_width: int, image_height: int) -> str:
# 入口只负责组装步骤,模型差异和语言规则留在各自函数中。
fragments = to_fragments(items, image_width, image_height)
rows = cluster_rows(fragments)
return "\n".join(filter(None, (merge_line(row) for row in rows)))
最后用一张表确定调参方向
| 现象 | 优先检查 | 调整方向 |
|---|---|---|
| 同一行被拆成多行 | 中心距离与框高比例 | 小幅放宽动态阈值,检查坐标是否已还原 |
| 相邻两行粘在一起 | 大字号片段是否放大了阈值 | 使用较小的代表高度或先按版面区域分桶 |
| 英文单词粘连 | 行内 gap 规则 | 对字母和数字相邻保留空格 |
| 多栏顺序错乱 | 是否先做列分桶 | 把列区域作为比 y 更高一级的阅读边界 |
| 中文标点多空格 | 语言相关的拼接策略 | 对标点、括号和中文片段使用单独规则 |
常见问题
能不能直接按 y1、x1 排序?
可以作为初始基线,但不同字号、斜拍图片和宽松检测框会让 y1 差异变大。更稳妥的实现是先用垂直中心聚行,再在每行内按 x1 排序。
行阈值应该固定为多少像素?
不建议固定像素值。图片分辨率、字体大小和模型框的松紧都会变化,使用框高的比例或行高统计量更容易跨图片复用。
为什么模型已经返回了排序后的文本,仍然要自己处理?
有些接口会提供顺序,但不同模型、批处理方式和版面类型的排序语义并不一定相同。保留自己的后处理层,可以把坐标、阈值、列边界和语言拼接规则统一起来。
把 OCR 后处理拆成“坐标统一、聚行、行内排序、语言拼接”四个小层,问题就从一段难以解释的字符串拼接,变成可以分别调节和记录的空间规则。对于普通横排文本,这套方法足够作为稳定基线;进入多栏、表格或旋转文本后,再把列和方向加入阅读顺序模型。
-
273 收藏
-
346 收藏
-
235 收藏
-
120 收藏
-
168 收藏
-
318 收藏
-
413 收藏
-
390 收藏
-
371 收藏
-
262 收藏
-
158 收藏
-
199 收藏
-
119 收藏
-
147 收藏
-
219 收藏
-
433 收藏
-
297 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习