每年三月底到四月中旬,是财务部最兵荒马乱的时候。差旅报销单摞成山,每张后面贴着发票,而发票代码、号码、日期、金额、税额这些信息都要手工敲进Excel。去年帮财务同事整理这堆票时,我面临的是几千张纸质发票等着录入,突然意识到:这种机械重复的工作,完全可以用程序替代。这个念头催生了我的发票识别工具开发计划。经过几轮迭代,我做出了一套能批量识别增值税发票核心信息的小工具,虽然不是商业级产品,但在实际工作中已经能顶大用。
这篇笔记不是一篇从零开始的完整教程,而是对整个开发过程的复盘。我会把选型思路、图像预处理细节、识别后的信息抽取、逻辑校验方案,以及真实环境中踩过的坑一条条捋清楚。如果你正准备做报销自动化、进项发票管理,或者任何涉及票据信息采集的工具,这篇文章应该能帮你省下不少试错的时间。
1. 为什么最终选择自研,而不是直接调用云OCR接口
先聊最现实的选型问题。市面上做发票识别的云服务不少,按张计费,看上去门槛很低,那为什么还要自己搭一套?
第一个原因是成本。发票量一旦上规模,比如一个月几千上万张,按张计费的成本就不是小数目了。996过后加班费都没人给,这种长期持续的成本更扛不住。第二个原因是数据隐私。发票上有企业税号、交易明细、开票品名,这些都是敏感财务信息,很多公司明确不允许这些数据流向第三方云服务。第三个原因比较容易被忽略:通用云API只能返回标准字段,但实际业务里往往需要自定义校验规则、定制数据结构、识别失败时的灵活降级处理,用别人的接口很难做到这么细。
除了这三点,还有一个隐藏问题:通用云API的识别效果是针对"大多数标准发票"调优的,而真实世界里的发票形形色色,不同省份、不同年份、不同打印设备打出来的票,版式和清晰度差异都很大。一旦遇到特殊的版式,云API的识别率立刻下降,而你完全没有参数可以调整。自研方案最大的优势不是省钱,而是可控,从图像预处理到后处理规则,每个环节都能根据实际需求调整。
我的整体框架是这样的:
text复制输入图像 → 图像预处理 → 文字检测 → 文字识别 → 字段提取与结构化 → 逻辑校验 → 结构化JSON输出
技术选型上,我建立了一张表:
| 环节 | 选型 | 理由 |
|---|---|---|
| 图像预处理 | OpenCV + NumPy | 生态成熟,图像操作灵活,社区案例多 |
| 文字检测与识别 | PaddleOCR(PP-OCRv4) | 中英文识别效果好,开源,支持CPU与GPU |
| 后处理与校验 | 自研Python规则引擎 | 发票字段规则明确,不需要引入复杂模型 |
| 服务化部署 | FastAPI + Redis队列 | 轻量,请求量大时可以横向扩展 |
这套方案里,真实工作量最大的其实不是调用OCR引擎本身,而是OCR前后的两段:图像预处理和字段校验。OCR引擎现在已经很成熟了,真正的瓶颈在于"怎么把一张脏兮兮的真实照片变成模型更容易理解的干净图像"以及"怎么从一堆文字框里抽取出正确的业务字段"。这两个问题,后面每一节都会展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 识别前的图像处理:决定识别上限的隐形环节
很多人的第一反应是"把图片丢给OCR引擎,识别好不好全看模型"。我一开始也这么想,直到实测数据打脸。在未经处理的原始照片上,PaddleOCR的识别准确率大概只有78%左右;做了预处理之后,能提升到92%以上。OCR模型本身很强,但输入图像质量太差会直接卡死识别上限。
2.1 倾斜矫正:最简单也最有效的提升手段
发票拍摄时倾斜非常常见,尤其是手机拍摄,几乎不可能保证完全水平。PaddleOCR内置的文字方向分类器能处理小角度旋转,但角度一旦明显,识别效果会断崖式下降。
我采用的方案是经典的霍夫变换直线检测:
- 图像灰度化
- 高斯模糊降噪
- Canny边缘检测
- 用霍夫变换检测直线,计算每条直线与水平方向的角度
- 统计所有倾角的中位数,作为整体倾斜角
- 用仿射变换把图像旋转回正
有一个关键细节:统计倾角时用中位数而不是平均值。因为发票上有表格线、文字线,检测出的直线中会混入几条竖直表格线的干扰,中位数对离群值更鲁棒。如果用平均值,一条竖直线的强响应就能把整个角度估计带偏。
python复制import cv2
import numpy as np
def detect_skew_angle(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 50, 150, apertureSize=3)
lines = cv2.HoughLinesP(edges, 1, np.pi / 180,
threshold=300, minLineLength=200, maxLineGap=50)
if lines is None:
return 0.0
angles = []
for line in lines:
x1, y1, x2, y2 = line[0]
angle = np.degrees(np.arctan2(y2 - y1, x2 -
