前言
第一次接触 OCR,还是在18年刚毕业的时候。那时候热衷于爬虫,在爬取数据的过程中总会遇到形形色色的验证码问题。虽然有很多打码平台可以解决这个问题,但是我还是趁着这个机会去学习了 OCR 的知识。
OCR 应用
时隔多年,再来回顾 OCR ,当时是使用 PIL 和 pytesseract 来简单的实现图片文字的识别。
from PIL import Imageimport pytesseract# 如果 Tesseract 未配置在系统路径中,指定其路径# pytesseract.pytesseract.tesseract_cmd = r'C:\\Program Files\\Tesseract- OCR \\tesseract.exe'# 打开图片image = Image.open("example_image.png")# 使用 Tesseract OCR 进行文字识别text = pytesseract.image_to_string(image, lang="eng")print("识别结果:", text)
以上代码仅仅是对于文字的识别,而对于发票这种结构化的识别,仅仅这样是完成不了的。所以后来在OA系统的财务报销模块改造升级的时候,果断使用了第三方的接口来实现发票的 OCR 。
OA 报销流程
最初公司在 OA 上的报销流程甚是复杂。首先我们需要从 OA 上提交报销工单,除了需要填写项目编号之外,就是填写报销金额,这个金额是需要根据手里需要报销的发票(电子发票或者纸质发票)自行计算。然后经过各级领导审批之后,财务在最终确认报销单,然后进入到贴发票环节。
将财务确认的报销单打印签字之后,需要将一张张发票用胶水粘到一张A4纸上,电子发票需要打印在纸上再粘,然后与报销单一起交给前台,等到到固定日期统一邮寄到总公司的财务处。然后财务收到您的报销单和发票之后进行核对校验,审核通过之后再等待财务出纳日,报销款才能到我的工资卡。
如果发票贴错了,或者报销金额与发票金额不对等怎么办?那么恭喜您,财务不仅会把发票寄还给您,您还要重新贴好发票或者修改报销单之后,等待前台邮寄、财务审核...

我当时经常加班打车,出租车票经常一攒一大堆,然后就用计算器挨张累加金额,反复计算好几次,生怕填错了,电子发票也经常搞得混乱不堪,所以每次报销对于我来说都是很累的事情。
OCR 在 OA 中的应用
后来,公司推行各种智能化,包括商旅、打车等,OA 接入订票平台和打车平台等,这样出差和打车就不用员工自己垫付。然后顺便也把报销平台进行了改造。OA 新增了发票夹功能。
将电子发票或者纸质发票的照片上传到发票夹,然后调用 OCR 接口,识别出来发票金额等信息之后,我们在提交报销单的时候,就能直接关联发票,同时发票金额也是自动计算填入报销单,再也不用我们自己再去挨个计算。

同时,报销平台除了接入 OCR,同时也接入了发票核验功能,报销单和电子发票也不用打印出来邮寄,全程实现了无纸化,报销周期也从之前的两个星期缩减到了现在的2天,极大提高了报销效率。
在之前 Tessera 的 OCR 代码中,只能对纯文本进行识别。如果想要识别发票这种结构化的表格信息,需要结合区域检测或专用 OCR 工具,所以本篇文章主要是对腾讯云的 OCR 产品文档智能的发票 OCR 能力来做一个深入探究。使用腾讯云文档智能来实现OA中的发票夹,并验证识别准确性是否满足 OA 发票识别的需求。
文档智能
文档智能(Document AI)融合了业界领先的深度学习技术、图像检测技术以及 OCR 大模型能力,能够实现不限版式的结构化信息抽取。支持智能提取各类证照、票据、表单、合同等结构化场景的 key:value 字段信息,并支持提取表格信息的 key:value 组的结构化。

开通之后,进入 OCR 控制台,在资源包管理菜单下,我们可以看到腾讯云赠送的文档抽取(基础版)和文档抽取(多模态版)各1000次的调用次数。
产品体验

我们也可以上传一些本地的单据,点击开始识别就可以调用 OCR 能力,有兴趣的小伙伴可以试用一下。
版本对比
文档智能一共提供了基础版和多模态版的识别接口调用。

文档抽取(多模态版)不限定版式,支持海外票据、提单、运单、进出口报关单、装箱单、过磅单、采购单等物流单据,识别率高且有更大的模型参数算力支撑。
而基础版只支持固定版式的识别,例如网约车行程单、驾驶证、运输证、房产证、不动产证、毕业证等各类证件单据,这种固定版式识别相对于简单,这也意味着相对有高级版来说识别速度更快。
我在这里选择了文档抽取(多模态版),用来开发发票夹,在开通和体验完文档抽取之后,就开始发票夹应用的开发环节。
后端开发
在开发之前首先要确定开发架构,考虑到想要做一个多端的适配,且需要对用户的发票和请求做一个管理,所以这里我选择使用 uni-app 为开发底座,vue3+springBoot 前后端的架构来实现这个发票夹。
接口调用
第二种方式是使用腾讯云提供的 tencentcloudapi SDK,这样就不要自己封装签名,通过 SDK 就可以直接调用文档抽取(多模态版)的接口。

<dependency><groupId>com.tencentcloudapi</groupId><artifactId>tencentcloud-sdk-java</artifactId><version>3.1.322</version></dependency>
Service
初始化客户端
实现 OcrService,初始化 tencentcloudapi 的请求客户端 CommonClient,代码如下:
public class OcrService {private final CommonClient client;public OcrService(OcrConfig ocrConfig) {// 实例化一个认证对象Credential cred = new Credential(ocrConfig.getSecretId(), ocrConfig.getSecretKey());// 实例化一个 HTTP 选项HttpProfile httpProfile = new HttpProfile();httpProfile.setEndpoint(ocrConfig.getEndpoint());// 实例化一个客户端配置对象ClientProfile clientProfile = new ClientProfile();clientProfile.setHttpProfile(httpProfile);// 实例化要请求产品的client对象this.client = new CommonClient("ocr", "2018-11-19", cred, ocrConfig.getRegion(), clientProfile);}}
构造 OCR 请求
调用文档抽取(多模态版)接口时,可以将图片上传到公网上,然后使用 ImageUrl 数调用接口。我使用的是第二种方法,现将图片转换成 Base64 格式,然后通过 ImageBase64 将图片发送到接口。
public String recognizeInvoice(String imageBase64) throws TencentCloudSDKException {try {// 构造请求参数String params = String.format("{\\"ImageBase64\\":\\"%s\\"}", imageBase64);// 发起请求log.info("开始调用OCR识别服务...");String response = client.call("SmartStructuralPro", params);log.info("OCR识别完成: {}", response);return response;} catch (TencentCloudSDKException e) {log.error("OCR识别失败: {}", e.getMessage(), e);throw e;}}
核心代码就是使用 client.call 调用文档抽取(多模态版)接口,然后将接口的响应原样返回。
Controller
实现 Controller 对外暴露 /api/ocr/recognize 接口,通过调用service的方法,实现前端页面与高级版 OCR 接口的连通。
@Slf4j@RestController@RequestMapping("/api/ocr")@CrossOriginpublic class OcrController {private final OcrService ocrService;public OcrController(OcrService ocrService) {this.ocrService = ocrService;}@PostMapping("/recognize")public OcrResponse<String> recognizeInvoice(@RequestBody OcrRequest request) {try {log.info("收到OCR识别请求, 图片大小: {} bytes", request.getImageBase64().length());String response = ocrService.recognizeInvoice(request.getImageBase64());return OcrResponse.success(response);} catch (TencentCloudSDKException e) {log.error("OCR识别失败: {}", e.getMessage(), e);return OcrResponse.error(e.getMessage());}}}
至此,后端开发接口服务已经开发完成。接下来就是开发前端页面,与将后端接口集成到前端中实现发票的 OCR 调用。
前端开发
前端使用 uni-app 和 vue3 完成开发,这里以 OA 系统中发票夹为中心进行开发,实现用户上传发票、识别发票和保存发票的功能,详细功能点如下:
1. 发票 OCR 识别;
2. 发票导入重复检查;
3. 发票列表展示、详情展示;
4. 发票排序;
5. 发票删除;
6. 发票搜索。
在发票夹的首页,实现了发票列表展示、筛选、搜索、删除的功能,同时也是用户添加发票的入口。

员工需要先添加发票,才能实现筛选、搜索、删除等功能,所以这里我们先看看添加、实现发票的功能是如何实现的,然后再探索其他的功能模块。
添加发票
点击添加发票按钮,会弹出拍照/相册选择的提示框,这里点击相册,就会跳转到发票添加页面。

发票上传页面的实现就是简单的布局:

而页面除了需要实现 OCR 发票识别功能,还需要实现发票图片上传、发票查重、预览等功能。
1. 发票上传
点击页面,进入到相册选择发票。

选择发票之后,点击确认上传,就会调用 OCR 接口上传。

2. 电子发票识别
在点击确认上传之后,开始触发 OCR 识别,这时候就会调用后端服务中 /api/ocr/recognize 接口对发票进行识别。这里封装了 call OCR API 方法来实现接口的调用。
const API_CONFIG = {baseUrl: 'http://localhost:8080/api/ocr' // Java 后端服务地址};// 调用 OCR APIasync function callOCRAPI(imagePath) {try {// 将本地图片转为 base64const base64 = await new Promise((resolve, reject) => {uni.getFileSystemManager().readFile({filePath: imagePath,encoding: 'base64',success: res => resolve(res.data),fail: err => reject(err)});});// 发起请求return await new Promise((resolve, reject) => {uni.request({url: `${API_CONFIG.baseUrl}/recognize`,method: 'POST',header: {'Content-Type': 'application/json'},data: {imageBase64: base64},success: (res) => {if (res.statusCode === 200 && res.data?.code === 0) {resolve(res.data);} else {reject(new Error(res.data?.message || '识别失败'));}},fail: (err) => {reject(err);}});});} catch (error) {throw error;}}
在上面的代码中主要实现了两个功能,首先实现图片转base64,然后发起后端服务的请求。

发票识别成功之后,发票就会添加到到首页。同时在控制台可以看到后台请求返回的数据,以及解析到的数据。

从后台也可以看到 OCR 请求的调用和返回日志。

3. 纸质发票识别
这里同样需要实现纸质发票的识别,这里也相当于测试了相机拍照识别发票的功能,我们先选择一张纸质发票。

可以看到纸质发票比电子发票的结构更为复杂,而且照片中也有其他的干扰因素,所以识别起来应该更困难一些。

确认上传,可以看到纸质发票被识别成功。长按可以看到详细的发票信息:

进入详情页也可以预览发票图片:

4. 发票去重
在识别发票的模块中,为了避免一张发票多次添加到发票夹中,这里对发票做了一个排重的处理。在生产中需要在后台实现识别重复的逻辑,使用发票编号作为查找条件,去数据库中查找该用户是否已经存此张发票。
这里我在前端页面简单实现了去重的逻辑:
// 发票查重实现checkInvoiceExists(number) {try {const savedInvoices = uni.getStorageSync('invoices');if (savedInvoices) {const invoices = JSON.parse(savedInvoices);return invoices.some(invoice => invoice.number === number);}return false;} catch (error) {console.error('检查发票失败:', error);return false;}}// 查重处理if (this.checkInvoiceExists(response.number)) {uni.hideLoading();uni.showModal({title: '提示',content: '该发票已存在,请勿重复添加',showCancel: false,success: () => {this.loading = false;}});return;}
使用 number 发票号码作为去重条件,最终展现效果:

数据解析
发票详细信息的展示,主要是对文档抽取(多模态版)接口返回信息的解析,结构将识别每条发票信息都会以相同的结构返回:

如图,响应数据中 key 的 AutoName 字段是发票中每一项的 key,value 中的 AutoContent 字段是发票中每一项的值。
1. 结构定义
这里我定义了字段结构,解析响应数据,将数据存储在 invoiceData 变量中:
{id: String, // 发票唯一标识imageKey: String, // 发票图片的存储keynumber: String, // 发票号码type: String, // 发票类型(增值税专用发票/普通发票/电子发票)amount: String, // 发票金额date: String, // 开票日期seller: String, // 销售方名称sellerTaxNo: String, // 销售方纳税人识别号buyer: String, // 购买方名称buyerTaxNo: String, // 购买方纳税人识别号items: [ // 发票商品明细{name: String, // 商品名称spec: String, // 规格型号unit: String, // 单位quantity: String,// 数量price: String, // 单价amount: String, // 金额taxRate: String, // 税率tax: String // 税额}]}
2. 结构分析
在调用文档抽取(多模态版)接口识别时,因为是是对 key:value 进行结构化识别,但是不同的发票之间相同的 value,key 是不一样的,例如下面两张电子发票的纳税人识别号字段的key就不一样:
发票1:

发票2:

发票1返回的纳税人识别号结构:

发票2返回的纳税人识别号结构:

对于纳税人的 key,一个是销售方信息统一社会信用代码/纳税人识别号,一个是销售方纳税人识别号。当时我是按照发票1的结构开发的解析程序,所以就导致发票2的许多字段没有解析出来。

3. 代码开发
所以,这里需要通过对不同的发票数据结构进行判断提取:
switch (AutoName) {case '标题':invoiceData.type = AutoContent || '电子发票';break;case '发票号码':case '号码':invoiceData.number = AutoContent || '';break;case '开票日期':invoiceData.date = AutoContent ? AutoContent.replace('年', '-').replace('月', '-').replace('日', '') : '';break;case '销售方名称':case '销售方信息名称':invoiceData.seller = AutoContent || '';break;case '销售方纳税人识别号':case '销售方信息统一社会信用代码/纳税人识别号':invoiceData.sellerTaxNo = AutoContent || '';break;case '购买方名称':case '购买方信息名称':invoiceData.buyer = AutoContent || '';break;case '价税合计(小写)':invoiceData.amount = AutoContent ? AutoContent.replace('¥', '').replace('¥', '') : '0';break;}// ...省略部分代码
这样,不同格式的发票也成功识别了。

同样也要将增值税专用发票、纸质发票结构的key考虑进去。
发票展示
发票识别成功之后,就会展示在首页。

1. 发票详情弹出层
这里主要实现了两个发票的详情展示。第一个是长按列表中的发票,就会弹出发票的信息,包括发票号码等。绑定长按事件:
<viewclass="invoice-item"@tap="goToDetail(invoice)"@longpress="showInvoicePopup(invoice)">
showInvoicePopup会修改showPopup变量,用来控制弹出层显示。

最终效果如下:

2. 发票详情页面
第二个就是点击发票,跳转发票详情页面:

在这个页面可以预览发票图片:

整体就是一个页面展示,主要是定义图片的预览事件 previewImage。
<imageclass="invoice-image":src="imageUrl"mode="aspectFit"@tap="previewImage"/>
当触发点击事件(tap)时,通过uni.previewImage就可以实现图片预览。
previewImage() {if (this.imageUrl) {uni.previewImage({urls: [this.imageUrl]});}}
3. 发票筛选
发票列表支持按日期范围筛选。

支持按时间正序/倒序排序。

提供快速重置筛选条件功能。

这个功能逻辑比较简单,主要是对发票时间进行一个排序和筛选,代码如下:
filteredInvoices() {let result = [...this.invoices];// 时间区间筛选if (this.startDate || this.endDate) {result = result.filter(invoice => {const invoiceDate = new Date(invoice.date);let isValid = true;if (this.startDate) {const start = new Date(this.startDate);isValid = isValid && invoiceDate >= start;}if (this.endDate) {const end = new Date(this.endDate);isValid = isValid && invoiceDate <= end;}return isValid;});}// 时间排序result.sort((a, b) => {const dateA = new Date(a.date);const dateB = new Date(b.date);return this.sortDesc ? dateB - dateA : dateA - dateB;});return result;}
4. 发票搜索
支持实时搜索,可按销售方名称、发票号码、金额搜索。

并使用防抖处理优化性能。
debounce(fn, delay = this.debounceDelay) {return (...args) => {if (this.searchDebounceTimer) {clearTimeout(this.searchDebounceTimer);}this.searchDebounceTimer = setTimeout(() => {fn.apply(this, args);}, delay);};}
5. 发票删除
实现了左滑显示删除按钮、删除前二次确认,当用户点击确认之后,发票才会被删除。

实现代码如下:
onDeleteTap(event, invoice) {if (event) {event.stopPropagation();}uni.showModal({title: '确认删除',content: '是否确认删除该发票?',confirmColor: '#ff4444',showCancel: true,success: (res) => {if (res.confirm) {this.invoices = this.invoices.filter(item => item.id !== invoice.id);uni.showToast({title: '删除成功',icon: 'success'});this.saveInvoices();}this.$nextTick(() => {invoice.x = 0;});}});}
结语
本篇文章主要使用腾讯云的文档抽取(多模态版)接口,实现了 OA 系统中的发票识别模块。在整体的开发过程中,文档抽取(多模态版)使用起来比较方便,通过 SDK 几行代码就能接入到系统中。
通过对各种版式的电子发票,以及纸质增值税专用发票的识别结果来看,文档抽取(多模态版)有着精准的识别能力,适合于各种版式的单据识别。