第一次接触OCR,还是在18年刚毕业的时候。那时候热衷于爬虫,在爬取数据的过程中总会遇到形形色色的验证码问题。虽然有很多打码平台可以解决这个问题,但是我还是趁着这个机会去学习了OCR的知识。
时隔多年,再来回顾OCR,当时是使用PIL和pytesseract来简单的实现图片文字的识别。
from PIL import Image
import 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上提交报销工单,除了需要填写项目编号之外,就是填写报销金额,这个金额是需要根据手里需要报销的发票(电子发票或者纸质发票)自行计算。然后经过各级领导审批之后,财务在最终确认报销单,然后进入到贴发票环节。
将财务确认的报销单打印签字之后,需要将一张张发票用胶水粘到一张A4纸上,电子发票需要打印在纸上再粘,然后与报销单一起交给前台,等到到固定日期统一邮寄到总公司的财务处。然后财务收到你的报销单和发票之后进行核对校验,审核通过之后再等待财务出纳日,报销款才能到我的工资卡。
如果发票贴错了,或者报销金额与发票金额不对等怎么办?那么恭喜你,财务不仅会把发票寄还给你,你还要重新贴好发票或者修改报销单之后,等待前台邮寄、财务审核...
我当时经常加班打车,出租车票经常一攒一大堆,然后就用计算器挨张累加金额,反复计算好几次,生怕填错了,电子发票也经常搞得混乱不堪,所以每次报销对于我来说都是很累的事情。
后来,公司推行各种智能化,包括商旅、打车等,OA接入订票平台和打车平台等,这样出差和打车就不用员工自己垫付。然后顺便也把报销平台进行了改造。OA新增了发票夹功能。
将电子发票或者纸质发票的照片上传到发票夹,然后调用OCR接口,识别出来发票金额等信息之后,我们在提交报销单的时候,就能直接关联发票,同时发票金额也是自动计算填入报销单,再也不用我们自己再去挨个计算。
同时,报销平台除了接入OCR,同时也接入了发票核验功能,报销单和电子发票也不用打印出来邮寄,全程实现了无纸化,报销周期也从之前的两个星期缩减到了现在的2天,极大提高了报销效率。
在之前Tesseract的OCR代码中,只能对纯文本进行识别。如果想要识别发票这种结构化的表格信息,需要结合区域检测或专用 OCR 工具,所以本篇文章主要是对腾讯云的OCR产品智能结构化的发票OCR能力来做一个深入探究。使用腾讯云智能结构化来实现OA中的发票夹,并验证识别准确性是否满足OA发票识别的需求。
智能结构化(Smart Structure Optical Character Recognition )融合了业界领先的深度学习技术、图像检测技术以及 OCR 大模型能力,能够实现不限版式的结构化信息抽取。支持智能提取各类证照、票据、表单、合同等结构化场景的key:value字段信息,并支持提取表格信息的key:value组的结构化。
在产品官网/文档:智能结构化OCR定制模板OCR自定义文字识别中,我们可以通过点击页面的开通服务按钮,开通智能结构化的服务。
开通之后,进入OCR控制台,在资源包管理菜单下,我们可以看到腾讯云赠送的智能结构化基础版和高级版各1000次的调用次数。
除了每个月会免费赠送智能结构化的资源包之外,还会赠送银行卡识别、英文识别的资源包等。
腾讯云提供了智能结构化高级版的产品demo体验,点击OCR Demo,在产品demo中识别的是国际物流的单据,可以看到精准识别了物流单据中的结构化信息。
我们也可以上传一些本地的单据,点击开始识别就可以调用OCR能力,有兴趣的小伙伴可以试用一下。
智能结构化一共提供了基础版和高级版的识别接口调用。
智能结构化高级版不限定版式,支持海外票据、提单、运单、进出口报关单、装箱单、过磅单、采购单等物流单据,识别率高且有更大的模型参数算力支撑。
而基础版只支持固定版式的识别,例如网约车行程单、驾驶证、运输证、房产证、不动产证、毕业证等各类证件单据,这种固定版式识别相对于简单,这也意味着相对有高级版来说识别速度更快。
我在这里选择了智能结构化高级版,用来开发发票夹,在开通和体验完智能结构化之后,就开始发票夹应用的开发环节。
在开发之前首先要确定开发架构,考虑到想要做一个多端的适配,且需要对用户的发票和请求做一个管理,所以这里我选择使用uni-app为开发底座,vue3 + spingboot前后端的架构来实现这个发票夹。
腾讯云产品提供了两种接口调用的方式。一是自己是实现接口请求,但是需要使用 签名方法v3 生成请求凭证。
第二种方式是使用腾讯云提供的tencentcloudapi SDK,这样就不要自己封装签名,通过SDK就可以直接调用智能结构化的接口。
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java</artifactId>
<version>3.1.322</version>
</dependency>
实现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);
}
}
在初始化请求的时候,除了要填写必要的请求参数,还要设置访问密钥:SecretId和SecretKey。在控制台就可以生成密钥。
调用智能结构化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 调用智能结构化高级版接口,然后将接口的响应原样返回。
实现Contorller对外暴露 /api/ocr/recognize 接口,通过调用service的方法,实现前端页面与高级版OCR接口的连通。
@Slf4j
@RestController
@RequestMapping("/api/ocr")
@CrossOrigin
public 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系统中发票夹为中心进行开发,实现用户上传发票、识别发票和保存发票的功能,详细功能点如下:
在发票夹的首页,实现了发票列表展示、筛选、搜索、删除的功能,同时也是用户添加发票的入口。
员工需要先添加发票,才能实现筛选、搜索、删除等功能,所以这里我们先看看添加、实现发票的功能是如何实现的,然后再探索其他的功能模块。
点击添加发票按钮,会弹出拍照/相册选择的提示框,这里点击相册,就会跳转到发票添加页面。
发票上传页面的实现就是简单的布局:
而页面除了需要实现OCR发票识别功能,还需要实现发票图片上传、发票查重、预览等功能。
点击页面,进入到相册选择发票。
选择发票之后,点击确认上传,就会调用OCR接口上传。
在点击确认上传之后,开始触发OCR识别,这时候就会调用后端服务中 /api/ocr/recognize 接口对发票进行识别。这里封装了 callOCRAPI 方法来实现接口的调用。
const API_CONFIG = {
baseUrl: 'http://localhost:8080/api/ocr' // Java 后端服务地址
};
// 调用 OCR API
async function callOCRAPI(imagePath) {
try {
// 将本地图片转为 base64
const 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请求的调用和返回日志。
这里同样需要实现纸质发票的识别,这里也相当于测试了相机拍照识别发票的功能,我们先选择一张纸质发票。
可以看到纸质发票比电子发票的结构更为复杂,而且照片中也有其他的干扰因素,所以识别起来应该更困难一些。
确认上传,可以看到纸质发票被识别成功。长按可以看到详细的发票信息:
进入详情页也可以预览发票图片:
在识别发票的模块中,为了避免一张发票多次添加到发票夹中,这里对发票做了一个排重的处理。在生产中需要在后台实现识别重复的逻辑,使用发票编号作为查找条件,去数据库中查找该用户是否已经存此张发票。
这里我在前端页面简单实现了去重的逻辑:
// 发票查重实现
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字段是发票中每一项的值。
这里我定义了字段结构,解析响应数据,将数据存储在invoiceData变量中:
{
id: String, // 发票唯一标识
imageKey: String, // 发票图片的存储key
number: 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 // 税额
}
]
}
在调用智能结构化接口识别时,因为是是对key:value进行结构化识别,但是不同的发票之间相同的value,key是不一样的,例如下面两张电子发票的纳税人识别号字段的key就不一样:
发票1:
发票2:
发票1返回的纳税人识别号结构:
发票2返回的纳税人识别号结构:
对于纳税人的key,一个是销售方信息统一社会信用代码/纳税人识别号,一个是销售方纳税人识别号。当时我是按照发票1的结构开发的解析程序,所以就导致发票2的许多字段没有解析出来。
所以,这里需要通过对不同的发票数据结构进行判断提取:
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考虑进去。
发票识别成功之后,就会展示在首页。
这里主要实现了两个发票的详情展示。第一个是长按列表中的发票,就会弹出发票的信息,包括发票号码等。绑定长按事件:
<view
class="invoice-item"
@tap="goToDetail(invoice)"
@longpress="showInvoicePopup(invoice)"
>
showInvoicePopup会修改showPopup变量,用来控制弹出层显示。
最终效果如下:
第二个就是点击发票,跳转发票详情页面:
在这个页面可以预览发票图片:
整体就是一个页面展示,主要是定义图片的预览事件previewImage。
<image
class="invoice-image"
:src="imageUrl"
mode="aspectFit"
@tap="previewImage"
/>
当触发点击事件(tap)时,通过uni.previewImage就可以实现图片预览。
previewImage() {
if (this.imageUrl) {
uni.previewImage({
urls: [this.imageUrl]
});
}
}
发票列表支持按日期范围筛选。
支持按时间正序/倒序排序。
提供快速重置筛选条件功能。
这个功能逻辑比较简单,主要是对发票时间进行一个排序和筛选,代码如下:
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;
}
支持实时搜索,可按销售方名称、发票号码、金额搜索。
并使用防抖处理优化性能。
debounce(fn, delay = this.debounceDelay) {
return (...args) => {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
this.searchDebounceTimer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
实现了左滑显示删除按钮、删除前二次确认,当用户点击确认之后,发票才会被删除。
实现代码如下:
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;
});
}
});
}
本篇文章主要使用腾讯云的智能结构化高级版OCR接口,实现了OA系统中的发票识别模块。在整体的开发过程中,智能结构化使用起来比较方便,通过SDK几行代码就能接入到系统中。
通过对各种版式的电子发票,以及纸质增值税专用发票的识别结果来看,智能结构化有着精准的识别能力,适合于各种版式的单据识别。
我是阿柒,本期结束咱们下次再见👋~
关注我不迷路,如果本篇文章对你有所帮助,或者你有什么疑问,欢迎在评论区留言,我一般看到都会回复的。大家点赞支持一下哟~
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。