用 CPU 做发票识别:从 OCR 文本行到结构化明细表的 4 个硬骨头

0 阅读8分钟

用 CPU 做发票识别:从 OCR 文本行到结构化明细表的 4 个硬骨头

一个本地离线跑的增值税发票识别服务,不用云 API、不用 GPU,输出结构化的票面字段和商品明细表。

起因

手上有 73 页的发票 PDF,想把里面的明细表读出来——商品名、规格、数量、单价、金额、税率、税额,一列不能错。

听起来是个 OCR 问题,实际上不是。OCR 只负责把图片变成「一堆带坐标的文字」(下面是示意,实际是四点坐标):

[{"text": "*生物制品*胎牛血清", "box": [[53,412],[186,412],[186,441],[53,441]]},
 {"text": "500ml",             "box": [[201,415],[248,415],[248,438],[201,438]]},
 {"text": "瓶",                "box": [[265,415],[281,415],[281,438],[265,438]]},
 ...]

从这堆坐标还原成「第 1 行明细:胎牛血清,规格 500ml,单位瓶,数量 2」——中间那段才是真正花时间的地方,占了整个项目大半的工期。

为什么不直接用现成的

摆在面前的选择其实只有两类,各有各的别扭:

云 OCR API通用开源 OCR(PaddleOCR 等)
识别准确率高中
票据数据位置上传到第三方云本地
成本按调用次数计费免费
发票字段结构化现成几乎没有,要从零写
部署门槛低高,常常要 GPU

用 PaddleOCR 能识别出「胎牛血清」这四个字,但它不知道这是商品名、属于第几行、对应哪个规格。这些全靠后处理。

所以这个项目补的是中间那块空白:本地 CPU 推理,但直接产出结构化的发票字段和明细表。 请求和响应格式对齐腾讯云 VatInvoiceOCR,原来接云 API 的代码改个地址就能切过来。

文中代码为节选(去掉了日志与边界处理),完整实现见仓库 app/extract/ 目录。

整体流程

图片 / PDF
  ↓  PDF 按 2000px 长边反推倍率渲染(不固定 DPI,避免小票渲染过大、大票渲染过小)
RapidOCR(PP-OCRv6 + ONNX Runtime,CPU 推理)
  ↓  文本行 + 四点坐标
版面重建:按 y 聚类成行,行内按 x 排序
  ↓  行列表
找表头 → 由表头 token 的 x 中心算出每一列的边界
  ↓  列区间
明细归列 → 折行合并 → 数值校验 → 合计行对齐
  ↓
票头字段抽取 + 交叉校验(金额勾稽、税额勾稽)

后面四步各有一个坑,下面逐个说。


骨头一:折行的文字,该回到哪一列

商品名长了会折行。票面上是这样:

*生物制品*胎牛血清    500ml      瓶      2     294.247...
胎牛血清(澳洲来源)

第二行只有文字、没有数字。最直觉的做法是把整行拼进商品名,结果变成「胎牛血清(澳洲来源)500ml 瓶」——规格和单位跑到名字里去了。

真实情况更麻烦:OCR 把折行按列切开了,续行的 token 会分别落在名称列、规格列、单位列。比如规格 10只/包,500只/箱 折成两行,第二行的 只/箱 落在规格列。

所以不能整行拼,要按 x 从左到右逐块判断归属:

def _merge_continuation(pending, ordered):
    """把「只有文字、没有数字」的一行并进上一条明细。"""
    for _, column, text in sorted(ordered, key=lambda pair: pair[0]):
        value = clean_value(text)
        if not value:
            continue

        if column == "Spec" and _spec_continues(pending.get("Spec", ""), value):
            pending["Spec"] = clean_value(pending["Spec"] + value)
            continue

        if column == "Unit":
            if not pending.get("Unit"):
                pending["Unit"] = value
            elif _spec_continues(pending.get("Spec", ""), value):
                # 「箱」被切到单位列,但其实是规格 `50只/` 的结尾
                pending["Spec"] = clean_value(pending["Spec"] + value)
            elif value != pending["Unit"]:
                # 单位列里出现的非单位文本(如 `无`)属于商品名续写
                pending["Name"] = clean_value(pending["Name"] + value)
            continue

        pending["Name"] = clean_value(pending["Name"] + value)

核心是 _spec_continues:判断这段文字到底是规格的后半截,还是商品名的续写。

def _spec_continues(spec: str, text: str) -> bool:
    if not spec or not text or len(text) > 8:
        return False
    if spec.endswith(("/", "/", ",", ",", "、")):
        return True          # 规格明显没写完,如 `1只/包,50只/`
    return bool(re.search(r"[//]", text))

两个判断条件都很朴素但有效:上一段以 / 或逗号结尾(说明没写完),或者当前这段里带 /(规格才用斜杠)。

这段代码上线后,袋装无酶 这种商品名折行才不会再被塞进规格列。


骨头二:数量和单价被 OCR 粘成一个数

票面上「数量 1」「单价 397.34…」是两个框,但两者贴得近时,OCR 会输出一个 1397.34...。

难点在于:你怎么知道 1 是数量、397.34 是单价,而不是数量 13、单价 97.34?

靠一个约束:单价 × 数量 ≈ 金额。金额是独立的一列,一般不会错,用它反推:

def _reconcile_qty_price(item):
    amount = to_float(item.get("AmountWithoutTax", ""))
    raw = nfkc(item.get("UnitPrice", "")).replace(" ", "")
    ...
    price, qty = to_float(raw), to_float(item.get("Quantity", ""))
    if abs(price * (qty if qty else 1) - amount) <= 0.02:
        return  # 已经自洽,不动

    for k in (1, 2, 3):                      # 前缀最多试 3 位
        head, rest = raw[:k], raw[k:]
        if not rest or not (rest[0].isdigit() or rest[0] == "."):
            continue
        q, p = float(head), float(rest)
        if q <= 0 or q != int(q) or p <= 0:  # 数量必须是正整数
            continue
        if abs(p * q - amount) <= 0.02:      # 拆开后能对上金额
            item["Quantity"] = str(int(q))
            item["UnitPrice"] = rest
            return

两个细节值得说:

  • 容差取 0.02,因为金额是两位小数,四舍五入会带来 ±0.01 的误差;
  • 数量必须是正整数(q != int(q) 直接跳过)。票面上不会出现 1.5 瓶。

另外 UnitPrice 我刻意保留票面原始精度(294.247787610619),不四舍五入到两位——因为下游如果要做「单价 × 数量 = 金额」的复核,截断后反而对不上。


骨头三:「合计」行,两页以上就会取错

这是最容易埋雷的地方,而且单页测试永远发现不了。

全电发票的多页清单,每一页底部都有小计和合计两行:

小  计   ¥415.29      ¥54.00        ← 本页小计
合  计   ¥82963.07    ¥10785.11     ← 整张发票合计

如果你按「第一个包含『合计』的行」去找,会命中 小 计("小计"里含"计"),拿到的就是单页金额。73 页的票,合计金额会变成四百分之一。

所以定位逻辑是这样:

def find_total_row(rows, start):
    subtotal = -1
    for idx in range(start, len(rows)):
        row = rows[idx]
        if any(row.contains(w) for w in _EXCLUDE_TOTAL):
            continue

        has_amount = any(RE_AMOUNT_ONLY.match(nfkc(i.text).replace(" ", "")) for i in row.items)
        next_is_tax_total = idx + 1 < len(rows) and rows[idx + 1].contains("价税合计")

        # 下一行是「价税合计」→ 本行必然是整票合计行,哪怕关键字被拆成「计」
        if next_is_tax_total and (row.contains("合计") or _is_summary_fragment(row)):
            return idx
        if row.contains("合计") and (has_amount or next_is_tax_total):
            return idx
        if row.contains("小计") and subtotal < 0 and has_amount:
            subtotal = idx          # 先记下,找不到合计行才退回

    return subtotal

还有第二种情况:OCR 把「合 计」拆得很碎,只剩一个「计」字跟着金额:

计   ¥8849.56   ¥1150.44
价税合计(大写)壹万圆整    (小写)¥10000.00

这时候「计」这个字不足以判断,但下一行是「价税合计」——这个结构在票面上是固定的,用它反证,就能确认上一行是合计行。

这套逻辑跑通之后,73 页那份 PDF 的明细金额合计 82963.07、税额合计 10785.11,跟票面完全一致。


骨头四:全电票的票头是艺术字

全电发票(20 位号码那种)票头「电子发票」四个字是艺术字,OCR 经常读成「国统」「电子发」甚至「固统」。

但有个确定的信息:20 位号码是全电票独有的(纸票是 8 / 10 / 12 位)。用它可以反过来纠正票头:

def _normalize_full_e_invoice_name(self):
    number = self._get("发票号码")
    name = self._get("发票名称")
    if len(number) != 20 or not name or "电子发票" in name:
        return
    if "增值税专用发票" in name:
        self._values["发票名称"] = "电子发票(增值税专用发票)"
    elif "普通发票" in name:
        self._values["发票名称"] = "电子发票(普通发票)"

一行 len(number) != 20 就把纸票排除在外,不会误伤。

这类「用一个稳定特征去纠正另一个不稳定字段」的模式,后面还用在省份推断、票种判定上,比死记版式靠谱。


实测数据

指标结果
连续识别73 页 PDF,73 页全部成功
明细金额合计82963.07(与票面一致)
明细税额合计10785.11(与票面一致)
勾稽差额0.00
总耗时275 秒(4 核 CPU)
单张耗时2~4 秒

和腾讯云 VatInvoiceOCR 的返回做逐字段递归比对(展开到叶子节点):128 个字段一致,0 个缺失。剩下的差异集中在坐标系数、角度精度、全角半角逗号这类,不影响字段值。

已知限制

写出来是为了省你的时间,不是自谦:

  • 只做了增值税发票(专票 / 普票 / 电子票 / 全电票)。火车票、出租车票、银行回单这些还没做,会落到 OtherInvoice。
  • 多页 PDF 走 /ocr/vatpdf 接口,不要用 EnableMultiplePage。后者一次跑完全部页,必然撞反向代理的读超时(默认 60s)。
  • PyMuPDF 是 AGPL-3.0。源码分发没问题,但如果你要把它打包成闭源二进制对外卖,得买 Artifex 授权,或者换 pypdfium2。
  • 全电票的版式税局还在调,遇到新版式可能要补规则。

项目地址

gitee.com/li_shuai_ca…

git clone https://gitee.com/li_shuai_carson/invoice-ocr.git
cd invoice-ocr && docker compose up -d

模型在 pip 包里,镜像构建完成后可以彻底断网运行。MIT 协议,商用也免费。

如果你们在用,可以说说你们的票种——通行费票和卷票我还没适配,这两种版式跟普通票差得挺多。