想在现有ERP/财务系统里快速集成一个OCR功能的兄弟们看的。

别去折腾什么 Tesseract 了,对于中文发票这种复杂排版,PaddleOCR 的预训练模型(尤其是 V4 版本)已经是目前开源界的“版本答案”。

不多废话,咱们的目标是:10分钟内,在一个 Python 环境里把发票图片变成结构化的 JSON 数据。

1. 环境准备:别在依赖上浪费时间

假设你机器上已经有了 Python 3.8+。 如果要在生产环境跑,强烈建议用 Linux + Docker,但本地调试的话,Windows/Mac 也行。

首先装 PaddlePaddle 核心库。

  • 如果你有 N 卡(GPU),一定要装 GPU 版,推理速度快几十倍:Bash# 这里的 cuda11.7 根据你服务器的 CUDA 版本改 python -m pip install paddlepaddle-gpu==2.6.1.post117 -f https://www.paddlepaddle.org.cn/whl/linux/mkl/avx/stable.html
  • 如果是 CPU 环境(办公电脑测试):Bashpython -m pip install paddlepaddle==2.6.1

接着装 PaddleOCR 的 whl 包:

Bash

pip install "paddleocr>=2.7.0"
pip install opencv-python-headless  # 加上这个防止cv2依赖报错

2. 核心思路:通用模型 + 正则暴力提取

做过发票识别的都知道,发票虽然有标准(专票、普票、卷票),但版式其实挺乱的。 “10分钟方案”的策略是:不重新训练模型(KIE 关键信息提取模型训练周期太长),直接用 PaddleOCR 的通用模型把所有文字“扫”出来,然后通过关键词定位和**正则(Regex)**来提取我们需要的信息(如发票代码、金额、日期)。

这种方式虽然看起来“土”,但在 90% 的标准增值税发票场景下,鲁棒性极高,而且不需要标注数据。

3. 代码实现

新建一个 invoice_ocr.py,直接把下面代码粘进去。

这段代码做了三件事:

  1. 加载模型:开启 use_angle_cls,因为用户上传的发票经常是歪的。
  2. 全图识别:拿到所有的文本块。
  3. 后处理(Post-processing):这是重点,用正则把非结构化文本变成 Key-Value。

Python

import re
from paddleocr import PaddleOCR
import cv2
import numpy as np

class InvoiceParser:
    def __init__(self, use_gpu=False):
        # 初始化 PaddleOCR
        # lang='ch' 会自动下载并加载 ch_PP-OCRv4_xx 模型
        # use_angle_cls=True 非常重要,防止发票倒置或倾斜
        print("正在加载 PaddleOCR 模型,初次运行需要下载模型权重...")
        self.ocr = PaddleOCR(use_angle_cls=True, lang="ch", use_gpu=use_gpu, show_log=False)

    def parse_invoice(self, img_path):
        # 1. 执行 OCR 识别
        # result 结构: [[[[x1,y1],[x2,y2]...], (text, confidence)], ...]
        result = self.ocr.ocr(img_path, cls=True)
        
        if not result or result[0] is None:
            return {"error": "未检测到文字"}
        
        # 2. 将所有识别出的文本拼接成列表,方便后续处理
        # 过滤掉置信度低于 0.6 的噪声
        txt_list = [line[1][0] for line in result[0] if line[1][1] > 0.6]
        
        print(f"--- 原始OCR文本 ({len(txt_list)} 行) ---")
        # 实际调试时可以打印出来看看
        # print(txt_list) 
        
        # 3. 关键信息提取 (基于规则和正则)
        extract_data = {
            "invoice_code": None,   # 发票代码
            "invoice_number": None, # 发票号码
            "date": None,           # 开票日期
            "amount": None,         # 价税合计
            "seller": None          # 销售方
        }

        # 简单的状态机或正则匹配
        # 注意:实际生产中,这里的正则库需要不断完善
        for i, txt in enumerate(txt_list):
            txt = txt.replace(" ", "").replace(":", ":") # 预处理符号

            # --- 提取发票号码 (通常是 8位 或 20位数字) ---
            if "发票号码" in txt:
                # 尝试在当前行找
                num = re.findall(r'\d{8,20}', txt)
                if num: 
                    extract_data["invoice_number"] = num[0]
                # 经常出现 "发票号码" 和 具体数字 分在两行的情况,往后找一行
                elif i + 1 < len(txt_list):
                    next_txt = txt_list[i+1]
                    num_next = re.findall(r'\d{8,20}', next_txt)
                    if num_next:
                        extract_data["invoice_number"] = num_next[0]

            # --- 提取发票代码 (通常 10-12位) ---
            if "发票代码" in txt and not extract_data["invoice_code"]:
                code = re.findall(r'\d{10,12}', txt)
                if code:
                    extract_data["invoice_code"] = code[0]
                elif i + 1 < len(txt_list):
                    code_next = re.findall(r'\d{10,12}', txt_list[i+1])
                    if code_next:
                        extract_data["invoice_code"] = code_next[0]

            # --- 提取金额 (价税合计) ---
            # 匹配逻辑:找 "小写" 或者 "¥" 或者 "¥" 附近的数字
            if ("小写" in txt or "¥" in txt or "¥" in txt) and "合计" in txt:
                # 匹配金额格式,如 123.00, 1,234.56
                amount = re.findall(r'\d{1,3}(?:,\d{3})*(?:\.\d+)?', txt)
                if amount:
                    extract_data["amount"] = amount[-1] # 通常取最后一个匹配到的数字

            # --- 提取日期 ---
            # 格式多样:2023年01月01日 或 2023-01-01
            if "年" in txt and "月" in txt and "日" in txt:
                 extract_data["date"] = txt # 简单处理,直接存文本

        return extract_data

# --- 测试入口 ---
if __name__ == "__main__":
    # 替换成你本地的一张发票图片路径
    img_file = "test_invoice.jpg" 
    
    parser = InvoiceParser(use_gpu=False) # 如果有显卡改成 True
    
    try:
        data = parser.parse_invoice(img_file)
        print("\n=== 识别结果 ===")
        print(data)
    except Exception as e:
        print(f"处理失败: {e}")

4. 这里的几个“坑”和“填坑技巧”

代码跑通不难,难的是在业务里跑稳。以下是几个技术人员必须注意的点:

  1. 图片分辨率(Input Shape): 发票通常是高频细节很多的图。如果上传的是压缩得很厉害的缩略图,PaddleOCR 默认的 resize 策略可能会导致数字(特别是小数点)丢失。
    • 解决:在初始化 PaddleOCR 时,可以调整 det_limit_side_len 参数。默认是 960,如果你的图片是 2K/4K 的高清扫描件,建议调大到 1280 甚至 1600,否则大图会被强行缩小,导致小字看不清。
    • self.ocr = PaddleOCR(..., det_limit_side_len=1600)
  2. 红章干扰: 发票上那个鲜红的公章经常会盖在“价税合计”或者“开票人”上面,导致 OCR 识别乱码。
    • 解决:如果发现红章遮挡严重,可以在送入 OCR 之前用 OpenCV 做一个颜色过滤(HSV 空间),把红色通道淡化或者直接二值化处理掉。这比换更强的模型要经济实惠得多。
  3. 正则的局限性: 上面的代码用的是基于位置和关键词的“软匹配”。如果是处理海量的、版式完全固定的发票(比如只处理某一家供应商的),建议用坐标截图的方式:直接在该区域切片(Crop),然后只对这个切片做 OCR,速度快且准。

5. 下一步优化

如果你要上线这套东西,这个脚本只能算 Demo。下一步你需要做的是:

  1. 服务化:用 FastAPI 包一层,把 ocr 对象的初始化放在 startup 事件里(千万别每来一个请求 load 一次模型,内存会爆,耗时也伤不起)。
  2. KIE (Key Information Extraction):如果你发现正则写得想吐,这时候再去研究 PaddleOCR 文档里的 PP-Structure 或者 LayoutXLM。那是专门用来解决“不知道哪个数字是金额”的高级玩法,但也意味着你需要标注数据来微调了