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

OCR表格结果按单元格坐标重建行列关系的方法

来源:17golang原创

时间:2026-09-20 15:32:40 385浏览 收藏

OCR 识别表格时,结果通常只是“文本 + 坐标框 + 置信度”的无序集合,并不会自动变成可查询的二维表。稳定的做法是先把四点框归一化,再按垂直重叠聚类出行,最后在每一行内按横坐标排序并推断列锚点。这样既能重建行列关系,也能把低置信度单元格留给复核,而不是用空字符串悄悄掩盖识别风险。

要点速览
  • 行判断看垂直重叠和相对高度,固定像素阈值很容易被扫描分辨率影响。
  • 列判断要在行聚类之后进行;先按 x1 排序,再用左边界锚点处理缺失单元格。
  • 置信度属于数据的一部分,输出表格时保留原分数,并单独标记低置信度记录。

统一坐标:先把 OCR 单元格变成可比较的数据

不同 OCR 引擎的字段名称并不统一,有的返回四个角点,有的返回 xmin/ymin/xmax/ymax,置信度也可能是 0 到 1 或 0 到 100。不要直接在聚类代码里兼容所有格式,先定义一个内部记录:textx1y1x2y2cxcywidthheightscore

OCR表格单元格四点坐标转换为矩形边界、中心点宽高和置信度统一记录的静态结构说明图
图1:OCR 单元格坐标归一化说明图,展示文本、框和置信度如何进入统一记录,不是运行截图。

下面的示例把常见四点框转换为外接矩形,同时把百分制置信度收敛到 0 到 1。坐标归一化只负责“字段可比较”,不负责判断它属于哪一行。

def normalize_cell(item):
    # 兼容四点框:每个点按 [x, y] 存储,先求外接矩形。
    points = item["box"]
    xs = [point[0] for point in points]
    ys = [point[1] for point in points]
    x1, x2 = min(xs), max(xs)
    y1, y2 = min(ys), max(ys)

    # 有些引擎返回 0..100 的分数,这里统一成 0..1。
    raw_score = float(item.get("score", 0.0))
    score = raw_score / 100 if raw_score > 1 else raw_score
    return {
        "text": item.get("text", "").strip(),
        "x1": x1, "y1": y1, "x2": x2, "y2": y2,
        "cx": (x1 + x2) / 2, "cy": (y1 + y2) / 2,
        "width": x2 - x1, "height": y2 - y1,
        "score": max(0.0, min(score, 1.0)),
    }

如果输入已经是矩形坐标,只需在进入这个函数前转换成四个角点即可。空文本、负宽高和明显越界的框应先记录为异常,不要让它们参与行列聚类。

按垂直重叠聚类:先确定哪些单元格属于同一行

行聚类的关键不是比较两个中心点是否“刚好相等”,而是比较它们在 y 轴上的覆盖。扫描倾斜、字体大小变化或同一行中包含粗体文字时,中心点会有偏移;把垂直重叠比例和相对高度结合起来,比固定的 5 像素阈值更稳。

def vertical_overlap(a, b):
    # 计算两个单元格在垂直方向的交集长度。
    overlap = max(0.0, min(a["y2"], b["y2"]) - max(a["y1"], b["y1"]))
    shorter = max(1.0, min(a["height"], b["height"]))
    return overlap / shorter

def cluster_rows(cells):
    rows = []
    for cell in sorted(cells, key=lambda value: (value["cy"], value["x1"])):
        matched = None
        for row in rows:
            # 高度自适应:不同分辨率下不使用固定像素阈值。
            tolerance = max(cell["height"], row["height"]) * 0.45
            same_band = abs(cell["cy"] - row["cy"]) = 0.35:
                matched = row
                break
        if matched is None:
            rows.append({"cy": cell["cy"], "height": cell["height"], "cells": [cell]})
        else:
            matched["cells"].append(cell)
            # 用当前行的中位中心和高度吸收少量倾斜与字号差异。
            centers = [item["cy"] for item in matched["cells"]]
            heights = [item["height"] for item in matched["cells"]]
            matched["cy"] = sorted(centers)[len(centers) // 2]
            matched["height"] = sorted(heights)[len(heights) // 2]
    return sorted(rows, key=lambda row: row["cy"])

这里的 0.350.45 是工程起点,不是所有票据和扫描件的通用答案。文字行间距很小的文档可以提高垂直重叠要求;拍照透视明显时,应先做透视校正,再聚类。

从坐标聚类到二维表:行和列要分两次判断

得到行之后,再处理横向关系。简单表格可以直接在每个行内按 x1 排序;如果某一行缺少一个单元格,不能仅凭“第几个文本”补列,否则后续行会整体错位。更稳的做法是收集每个单元格的左边界,按相近位置形成列锚点,再把单元格分配到最近锚点。

OCR表格从单元格集合经过行聚类、行内排序和列锚点生成二维表并保留低置信度复核区的静态结构图
图2:OCR 表格行列重建结构图,展示几何分组、列锚点、二维表和复核边界,不是运行结果截图。
def rebuild_table(raw_cells, column_gap=18, review_threshold=0.82):
    # 过滤空文本,保留原始置信度供后续复核。
    cells = [normalize_cell(item) for item in raw_cells]
    cells = [cell for cell in cells if cell["text"] and cell["width"] > 0 and cell["height"] > 0]
    rows = cluster_rows(cells)

    # 全表按左边界聚类列锚点;column_gap 要按图像尺度调整。
    anchors = []
    for cell in sorted(cells, key=lambda value: value["x1"]):
        anchor = next((value for value in anchors if abs(value - cell["x1"]) 

输出后的空列应保留为缺失值,而不是把右侧单元格左移。对于合并单元格,可以额外保存 colspan:根据框的宽度覆盖了几个列锚点来估计跨度,再把这个估计标记为待核对。OCR 坐标只能提供几何线索,不能替代业务表头语义。

置信度和异常框要成为可追踪的结果

现象优先检查处理方式
一行被拆成两行垂直重叠、拍照倾斜、字号差先校正透视,再调整相对高度阈值
缺失单元格导致列错位是否按文本序号直接补列使用全表列锚点,保留空列
文字正确但分数低置信度量纲和阈值统一到 0..1,并把记录送人工复核
合并单元格被拆开框宽度与相邻锚点覆盖记录 colspan 候选,不强行复制文本

建议最终输出两份数据:一份是给下游查询的二维表,另一份是包含原始框、分数、行列编号和异常原因的审计记录。这样当用户发现某个金额或日期不对时,可以回到原始单元格,而不是只能重新跑整页 OCR。

常见问题

为什么不直接按 OCR 返回顺序拼接文本?

返回顺序可能受检测框排序策略影响,不能代表视觉阅读顺序。坐标聚类先恢复几何关系,再决定文本顺序。

行聚类阈值应该固定多少?

不要固定为某个像素值。用单元格高度或垂直重叠比例做相对阈值,再用一组真实扫描样本校准。

低置信度单元格要不要直接删除?

通常不要。删除会破坏行列对齐;保留文本和分数,并通过 needs_review 交给人工或二次识别。

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