登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

视觉模型 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。这些派生值最好在进入排序函数时一次性算出,避免后续代码混用左上角、中心点和底边。

OCR 文本、置信度、矩形框统一到原图坐标和阅读方向的静态关系图
图1:OCR 字段、原图坐标和阅读方向的静态关系图,用来说明后处理前的数据边界。

先把不同返回格式统一成可排序片段

实际模型的字段名可能不同:有的返回 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 
OCR 片段中心 y、行中心、动态阈值、候选行和合并文本的静态关系图
图2:文字片段聚行与行内排序的静态关系图,重点看动态阈值怎样连接候选行。

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 后处理拆成“坐标统一、聚行、行内排序、语言拼接”四个小层,问题就从一段难以解释的字符串拼接,变成可以分别调节和记录的空间规则。对于普通横排文本,这套方法足够作为稳定基线;进入多栏、表格或旋转文本后,再把列和方向加入阅读顺序模型。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>