图片表格变结构化数据:OCR 后的三个坑
街道公示、年报、招标结果,越来越多以图片形式发布。要把它们变成能统计的数据,OCR 只是起点。本文记录一次 18 期、32 张图的还原过程,重点在**版面还原**和**校验**,代码可直接跑。
一、先判断有没有文字层
很多人一上手就截图丢给识别工具。先做一步更省事的:`Ctrl+F` 搜表里任意一个数字。
命中就是文字层,直接复制,不用 OCR。未命中才是图片。
二、版面还原:表头 → 列 → 行
OCR 输出的是文字块,每块带坐标。要变成表格,做三件事:
-
**定列**:按表头文字块的 x 坐标聚类,得到列中心;
-
**定行**:以「序号」列为行锚点(每行唯一),其余文字块按 y 距离归到最近序号;
-
**归并**:同一锚点内的块按 x 排序拼接,解决单元格折行。
三个具体坑:
- **双金额列**:存在「报送金额 / 实际划转金额」时取最右列,财务表惯例比字面匹配可靠;
- **续页无表头**:以首图列位置为基准,按图宽比例缩放套用;
- **列名被切断**(如「实际划转金」+「额」):列名匹配用前缀包含,不用全等。
下面是行锚点归并的最小实现,输入用合成块演示规则(真实场景里这些块来自识别引擎输出)。保存为 `anchor_demo.py`,直接跑:
text
$ python anchor_demo.py
exit code: 0
全程无异常日志输出,结果是纯文本,可再转 JSON。
text
BLOCKS = [
("序号", 40, 30, 30), ("项目", 260, 30, 60), ("金额", 620, 30, 50),
("1", 40, 80, 20), ("1栋屋面防水", 300, 80, 160), ("23,045.67", 620, 80, 120),
("2", 40, 130, 20), ("2栋电梯导向装置", 300, 130, 200), ("45,120.00", 620, 130, 120),
("3", 40, 180, 20), ("3栋外墙保温砂浆", 280, 176, 180), ("脱落维修(紧急)", 430, 196, 180),
("35,754.47", 620, 185, 120),
("4", 40, 235, 20), ("4栋主排污管", 300, 235, 160), ("23,850.36", 620, 235, 120),
]
ANCHOR_COL_X, ANCHOR_TOL, ROW_TOL = 40, 12, 18
def group_rows(blocks, anchor_x=ANCHOR_COL_X, tol=ANCHOR_TOL):
anchors = {}
for text, x, y, _w in blocks:
if abs(x - anchor_x) <= tol and text.isdigit():
anchors[y] = text
rows = {y: [] for y in anchors}
for text, x, y, _w in blocks:
if y in anchors and abs(x - anchor_x) <= tol:
continue
if y < min(rows) - 40:
continue
nearest = min(rows, key=lambda ay: abs(ay - y))
if abs(nearest - y) <= ROW_TOL:
rows[nearest].append((x, text))
out = []
for y in sorted(rows):
cells = sorted(rows[y])
project = "".join(t for x, t in cells if x < 560)
money = "".join(t for x, t in cells if x >= 560)
out.append((anchors[y], project, money))
return out
rows = group_rows(BLOCKS)
print("识别到行数:", len(rows))
for no, project, money in rows:
print(f"序号{no} | {project} | {money}")
max_no = max(int(n) for n, _p, _m in rows)
print("校验:行数 =", len(rows), ";序号最大值 =", max_no, ";一致 =", len(rows) == max_no)
实跑输出:
text
识别到行数: 4
序号1 | 1栋屋面防水 | 23,045.67
序号2 | 2栋电梯导向装置 | 45,120.00
序号3 | 3栋外墙保温砂浆脱落维修(紧急) | 35,754.47
序号4 | 4栋主排污管 | 23,850.36
校验:行数 = 4 ;序号最大值 = 4 ;一致 = True
第 3 行的项目名在图上折成两块(y=176 和 y=196),靠 18 像素的行容差拼回了同一行。这就是锚点法相对于「按 y 切行」的价值。
三、三道校验
| 校验 | 方法 | 不通过怎么办 |
|---|---|---|
| 行数 | `max(序号) == len(rows)` | 回查漏行,重跑该图 |
| 合计 | `sum(金额) == 图内印刷合计` | 逐行比对原图,标注差异 |
| 缺口 | 无法识别的单元格单列清单 | 标注「原图不可辨」,不插补 |
关键原则:**差异要保留,不要平滑**。合计对不上有两种原因------漏行,或原表本身未对齐,两者都要落进报告。
工程上还有两个细节:
- 金额解析要统一全角半角、去千分位、括号负数转负号;
- 每行保留「来源图 + 坐标」,便于人工回查。
四、这套方法的失效边界
写清楚做不到的部分,比吹方案有用:
- **没有印刷合计的表**:缺校验标尺,只能靠行号,可靠性下降一档;
- **每期格式都变的表**:列位置无法复用,解析规则要重写,人工看反而更快;
- **手写体、盖章遮挡**:识别率断崖式下跌,必须人工录入;
- **多栏排版**(左右两栏各一张表):纵向锚点会串行,要先做版面切分。
五、结论
瓶颈不是识别准确率,是**校验与缺口披露**。
没有校验环节的自动提取,产出的是「看起来像数据的东西」。一份能用的表,不是没有空缺的表,是空缺写在哪里的表。
我们是谁:做企业知识库与 AI 客服落地,也做「图片与散落文件变成可核对数据」这类活;交付原始文件、还原表、缺口清单三样。本文的示例数据与输出均为本机实跑,文字由作者与 AI 辅助整理,观点与结论由作者负责。