跳到主内容

发票OCR识别字段提取踩坑实录:卡住我的不是识别率,是被截断的发票号码

发票 OCR 识别字段提取,真正卡人的从来不是识别率,而是两个更隐蔽的坑:长数字串被 OCR 悄悄截断、字段标签跟着版式漂移。我拿 4 张合成的发票把这个坑复现了一遍,结论写在前面:字段校验逻辑必须先于识别精度做,否则识别率再高也白搭。

站里那篇 Python+OCR 提取发票关键字段并导出 Excel 讲的是基础流程,这一篇往深一层:字段定位之后,你怎么知道抽出来的值是完整的、可信的?

大家通常怎么做

网上教程的套路高度一致:装一个 OCR 引擎,写几条正则捞「发票号码:」「开票日期:」后面的内容,导出表格完事,测试用的图还是官方示例图。流程没毛病,坏在验证环节:测完「能抽出发票号码」就收工,没人去数号码是不是 20 位全电号码的完整长度。之前做验证码识别实测,整图识别率 93.5% 看着挺美,误差全藏在字符级(通用验证码识别实测)——长数字串正是「整体看着对、局部缺几位」的重灾区。

为什么这条路走不通

我做了组很小的实验:用 PIL 画 4 张版式各异的合成发票——A 版式标准排版、B 版式字段全挤左上、C 版式备注栏混入电话号码等干扰数字串,第四张在 A 基础上叠红章。识别引擎用 RapidOCR 的 onnxruntime 线(本机 pip 装到 1.2.3),纯 CPU 跑。

第一条结果就让我愣了几秒:A 版式和 C 版式抽出的发票号码是 243120000001847 和 243120000001847291,真实号码是 20 位 24312000000184729145——分别少了 3 位和 1 位,只有 B 版式完整。同一个号码、同一台机器,版式一变,OCR 就把它切成两块文本,正则只捞到前一块。

第二个坑是标签漂移。B 版式里我把「价税合计(小写)¥6784.00」写成「合计 ¥6784.00」,金额正则立刻失灵——几乎所有人的金额正则都锚定「(小写)」三个字。还有个自己挖的坑:合成数据时税号多打了一位成 19 位,正则按 18 位规则老实截了前 18 位,一声不吭。校验必须在位数层面显式做。

顺带说一句,红章压字那张反而全字段提取成功——印章没压住关键字段时,干扰比想象中小;真正致命的是备注栏那种电话号码式的数字串,一旦正则放宽到「捞任意长数字」,备注栏会大量混入。

换个思路

与其继续调正则,不如把字段提取拆成两层:第一层是带坐标的 OCR 文本块,第二层是「正则 + 几何补救 + 位数校验」的组合。invoice2data 这类模板驱动的开源工具走的本来就是模板定规则、OCR 供料的路,差异在于它按供应商维护 YAML 模板,供应商一多维护成本线性涨。行业基准测试(fintract.ai,2026-03)给发票场景的字段级准确率排了序:Surya 94%、PaddleOCR 92%、Tesseract 85%——即便 92%,也意味着每 100 个字段约 8 个要靠校验层兜回来,评测的原话是差额主要不在 OCR 本身,而在校验层。几何补救的价值在 OCR 拆块时才体现:找到「发票号码」所在文本块,去它右侧、y 坐标相近的块里找纯数字候选。本次 4 张图里它没被触发,但真实扫描件的倾斜和粘连会让拆块概率翻几倍,它是那之后的保险绳。

具体做法

核心代码骨架如下:

from rapidocr_onnxruntime import RapidOCR
import re

ocr = RapidOCR()
FIELDS = {
    "invoice_no": re.compile(r"发票号码[::]\s*([0-9]{8,24})"),
    "date":       re.compile(r"开票日期[::]\s*([0-9年月日]+)"),
    "total":      re.compile(r"[((]小写[))]\s*[¥¥]?\s*([0-9,]+\.[0-9]{2})"),
}

def extract(results):
    lines = [{"text": t, "x": min(p[0] for p in box),
              "y": min(p[1] for p in box)} for box, t, s in results]
    full = "\n".join(l["text"] for l in lines)
    out = {k: m.group(1) for k, pat in FIELDS.items()
           if (m := pat.search(full))}
    # 位数校验:全电发票号固定 20 位,不对就进人工复核队列
    if "invoice_no" in out and len(out["invoice_no"]) != 20:
        out["invoice_no_review"] = out.pop("invoice_no")
    # 几何补救:标签块右侧、y 相近的块里找纯数字
    if "invoice_no" not in out:
        for l in lines:
            if "发票号码" in l["text"]:
                right = sorted([r for r in lines
                                if r["x"] > l["x"] + 50
                                and abs(r["y"] - l["y"]) < 20
                                and re.fullmatch(r"[0-9]{8,24}", r["text"])],
                               key=lambda r: r["x"])
                if right:
                    out["invoice_no"] = right[0]["text"]
    return out

三个细节值得交代。其一,位数校验要输出「待复核」队列而不是直接丢弃——被截断的号码留给人工补,比静默丢掉强;工具无法判断对错时,先把可疑项隔离出来(图表字段认错的教训是同一件事)。其二,几何补救的 y 容差 20 像素按 900×560 画布定,真实扫描件要等比缩放。其三,外部项目的版本号务必对着官方仓库核——pip 上的 rapidocr-onnxruntime 停在 1.2.x 线,GitHub 主仓库 RapidAI/RapidOCR 最新 release 已到 v3.10.0(2026-10-08),两条版本线不是一回事(SQLite 实测那篇也提过这条纪律)。

效果对比

本机实测(Python 3.13,CPU 推理):首次调用 5.0–5.9 秒(含模型加载),纯推理单张 1.6–2.9 秒;批处理 20 张共 33.69 秒,均摊 1684 毫秒/张,20/20 跑通。红章版式 16 个文本块、无章 15 块,关键字段全部抽出。

把口径摊开:这是 900×560 合成图的成绩,真实发票的拍照透视变形会拖慢速度、加剧拆块;行业评测里扫描件相对数字版普遍掉 6–12 个百分点。fintract 还给了个反直觉的数:PaddleOCR 综合最强但比 Tesseract 慢 3–5 倍,财务等式校验通过率也只有 68%——开源方案「免费」只免了 OCR 引擎那层,校验的工程量一分不少。批处理单张 1.7 秒,100 张 3 分钟起,先上多进程并行,过千张再考虑 GPU。

常见问题

为什么图片很清晰,号码还是被截断?清晰不代表 OCR 会把一行合成一个文本块。识别引擎按视觉连通性切行,字号变化、行距不均、冒号间距都可能让一行断成两块。所以位数不对就进复核队列,这是刚需。

用云 OCR 接口是不是就没这些坑?商业接口直接返回结构化字段,截断问题好很多,但按量计费和票据出内网是两道新门槛。选型上,供应商固定、版式少,模板法(invoice2data 思路)最稳;供应商上百、格式杂,考虑 PaddleOCR PP-StructureV3 流水线(PaddleOCR 表格识别导出 Excel 的方法名坑有细节)。

税号校验除了位数还能做什么?统一社会信用代码 18 位自带校验位(GB 32100-2015 的 MOD 31-36 算法),再做校验位验证,能把 OCR 错认一两个字符的情况也拦下来。

相关阅读

分享到:

本文链接:https://www.biyeyuanma.cn/post/363.html

服务热线

加我微信

加我微信