简介:发票信息识别图像数据集是一份面向OCR与深度学习信息抽取的中小型高质量数据资源,主要供算法工程师、科研人员及学习者用于构建和验证发票关键字段识别模型。数据集共1560个文件,包含1040个XML标注文件与520张TIFF发票图像,压缩包体积仅34.06MB,轻量易用。XML文件与对应的发票扫描图像同名,逐一映射,内部记录了从原始票面中提取出的结构化数据,包括发票号码、开票日期、开票方与收票方公司名称、公司电话号码、地址等核心实体。这样的组织方式便于开发者直接进行数据集的载入、拆分与对齐,可用于信息抽取模型的训练、验证和测评,也可作为OCR版面解析、财务票据自动化处理等实际项目的基准数据。数据量适中,既适合快速迭代实验,又覆盖多种真实票面版式,对于希望从事财务票据识别或流程自动化的初学者,能够帮助快速理解标注结构与关键字段定义。目前已有257人学习下载,是一份实用价值明确的发票识别数据集。
1. 发票信息识别图像数据集:为什么你的OCR模型总在报销单上翻车
发票信息识别是OCR落地最频繁、也最容易被低估的场景:一张增值税普票、机动车销售发票或电子行程单送到模型面前,要的不是“把字认出来”,而是精准输出发票号码、开票日期、购买方名称、税额、价税合计等结构化字段。很多团队把公开数据集跑通就以为完成了,可真到报销系统里一上线,识别率立刻掉到让人怀疑人生的水平——问题往往不在模型结构,而在图像数据集本身:票种单一、版式固定、拍摄角度太正、光照太均匀。这篇笔记不讲大道理,直接说清楚一套能支撑发票信息识别落地的图像数据集该怎么搭、怎么标、怎么切、怎么验,以及每一个环节里最常见的坑。
2. 数据集里到底该装什么:把发票图像拆成四层信息
2.1 发票版式分类:固定版式与开放版式的数据集取舍
发票信息识别和通用OCR最大的差异在于版式。增值税专用发票和普通发票的版面高度规范,票面元素位置几乎是固定的;而卷式发票、出租车票、银行回单、行程单这类开放版式则没有统一模板。数据集设计的第一步就要想清楚:你的系统要面对哪一种。
如果只做增值税发票识别,图像数据集的标注量可以小很多,因为检测模型只需要定位固定区域,甚至可以用基于模板的关键点回归方案。但如果你面对的是财务报销场景里混着一堆票据的情况,数据集就必须覆盖多票种,而且每个票种都要保留足够的样本量。我见过不少团队用纯增值税发票训练,上线后被一张出租车票直接击穿——不是模型差,是数据集压根没覆盖这种分布。数据集覆盖面决定系统边界,这是发票信息识别和自然场景OCR最不同的地方。
2.2 字段级标注:从“识别准”到“字段对”的关键跨越
通用OCR数据集的标注是四边形的文本行框加一行字符串,够用;但发票信息识别数据集如果只标到文本行,下游结构化会非常痛苦。因为发票字段是“键值对”,模型最终要输出的是某个语义字段的文字内容,而不是一堆散落的文本行。比较稳妥的做法是标三层:
- 文本行级:每个文本区域一个多边形框+字符串
- 字段语义级:每个框对应哪个字段名(如“发票号码”“开票日期”“价税合计”)
- 图像级:票种分类标签(如“增值税专用发票”“出租车票”“行程单”)
以 JSON 为例,一个完整的标注样例长这样:
{ "image_name": "invoice_0001.jpg", "category": "vat_special", "fields": [ {"name": "invoice_code", "text": "031002100411", "points": [[156,323],[428,323],[428,348],[156,348]]}, {"name": "invoice_number", "text": "12345678", "points": [[156,368],[428,368],[428,393],[156,393]]}, {"name": "date", "text": "2024年06月18日", "points": [[156,523],[320,523],[320,548],[156,548]]} ] }这里的points是四个角的像素坐标,顺序为左上、右上、右下、左下;category是票种分类;fields是字段级标签。训练时,检测模型用points学定位,识别模型用text学文字,结构化模型用name学语义映射——三层信息各司其职,缺一层,后面都补不回来。
2.3 硬件与规模基线:一万张还是十万张才够训练
关于数据量,有一条我反复验证过的经验线:单一固定版式发票,字段级标注达到5000张以上,检测+识别的准确率就能到可用的边缘;要稳定到生产级,2万~3万张往上走。多票种混合的场景,单票种至少3000张,否则模型会倾向于把所有票都认成样本量最大的那类。
3. 数据从哪来:自建标注、合成数据与开源数据集的组合路径
3.1 自建标注的流水线:采集、清洗、标注工具选型
自己采集发票是最可控但最重的路径。采集阶段要特别注意几点:真实报销场景里发票有褶皱、有折叠阴影、有订书钉孔、有书写笔迹覆盖,这些必须作为正样本收进来,不能觉得“脏”就删掉。收进来的图要按“单张发票”切好,避免一张照片里出现多张发票——否则检测模型会学出幻觉。
标注工具我用过 Labelme 和 PPOCRLabel,实际推荐 PPOCRLabel,因为它是为OCR设计的,支持四点框标注、文本框旋转、自动建议框,标注效率高很多。自建标注的核心不是工具,而是标注规范文档:
- 框必须紧贴文字,不能留白超过2像素
- 如果有盖章遮挡导致文字不完整,照常框出完整文字区域并原样标注文字内容
- 一票多联(发票联、抵扣联)算不同样本,分别标注
- 凡是票面出现但肉眼都看不懂的字,做“忽略区域”标掉,不要硬标
这份规范写清楚后,标注质量会稳定很多。否则每个人按自己理解标,模型训出来四处漏风。
3.2 合成数据怎么做:让训练集覆盖现实的脏乱差
真实样本不够,合成数据是最实用的补充手段。常用的做法是“票据模板+真实文本内容+图像退化”三板斧:把真实发票的版式抽成模板,替换文本内容,再做几何变换和噪声扰动。这个思路能批量生成几万张看起来像真的、标注完全准确的样本。下面是一个最小可跑的合成脚本示例:
import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont def synth_invoice(template_path, texts_dict, font_path, out_path): """ template_path: 干净的发票模板图 texts_dict: {"invoice_code": "031002100411", ...} font_path: 字体文件路径 """ img = cv2.imread(template_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(img) draw = ImageDraw.Draw(pil_img) for field_name, text in texts_dict.items(): # 每个字段在模板上的位置预设好 pos = FIELD_POS[field_name] # (x, y) font = ImageFont.truetype(font_path, FONT_SIZE[field_name]) draw.text(pos, text, fill=(0, 0, 0), font=font) # 图像退化:透视变换 + 高斯噪声 + 光照不均 rows, cols = pil_img.size pts1 = np.float32([[0,0],[cols,0],[0,rows],[cols,rows]]) pts2 = np.float32([[np.random.randint(0,10), np.random.randint(0,10)], [cols-np.random.randint(0,10), np.random.randint(0,10)], [np.random.randint(0,10), rows-np.random.randint(0,10)], [cols-np.random.randint(0,10), rows-np.random.randint(0,10)]]) M = cv2.getPerspectiveTransform(pts1, pts2) warped = cv2.warpPerspective(np.array(pil_img), M, (cols, rows)) noise = np.random.normal(0, 5, warped.shape).astype(np.uint8) out = cv2.add(warped, noise) cv2.imwrite(out_path, out)参数说明:pts2里每个角的偏移量控制透视强度,一般控制在0~10像素,太大生成的图像会明显失真,模型学到的是“畸形发票”而不是“有点歪的发票”;noise的均值5是高斯噪声标准差,太大会淹没文字笔画。字段位置FIELD_POS需要从真实标注里统计均值得到——这一步很像做“叶片病害图像数据集划分”时先按病害类型聚类,总之先摸清数据分布再做样本生成,不要凭空定位置。
3.3 先用开源数据跑通流程,再决定是否自建
建立发票信息识别图像数据集时,优先建议先拿公开可用的票据OCR数据集做流程验证。虽然公开数据的票种和你的场景不一定完全匹配,但足以验证模型选型、训练流程、评测指标是否跑得通。等流程顺畅了,再投入人力做真实场景数据采集和标注,效率会高很多。
4. 数据集划分的艺术:不能随机切,得按“票据来源”切
4.1 为什么随机划分会在发票场景泄漏数据
常规的图像数据集划分是随机按比例切出 train/val/test,比如7:2:1。这个做法在自然场景分类里没问题,但在发票场景会直接翻车。原因在于:发票样本往往来自同一个开票方,版式几乎完全一致,只是开票日期和金额不同。如果同一来源的200张发票里,180张进了训练集、20张进了验证集,模型看到的验证集和训练集本质上高度重复,验证分数会虚高。
真实上线后,模型面对的是从未见过的开票方版式——之前随机切分练出来的模型,实际表现会远低于验证分数。这个误差不是小数目。做作物病害图像数据集划分时大家强调“按叶片来源分组再划分”,发票数据集同理,必须按开票方/采集批次分组,不能让同一来源的样本同时出现在训练集和验证集里。
4.2 按来源分组的划分配置:一行代码保住验证集的可信度
按来源分组进行数据集划分的做法,代码实现并不复杂:
import pandas as pd from sklearn.model_selection import GroupShuffleSplit # df 需要包含列: image_name, source_id(开票方或批次), split df = pd.read_csv("dataset_meta.csv") gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df["source_id"])) df.loc[train_idx, "split"] = "train" df.loc[val_idx, "split"] = "val" # 按 source_id 统计切分结果,确认没有泄漏 print(df.groupby(["split", "source_id"]).size())参数说明:GroupShuffleSplit的groups参数传的是每个样本的来源ID,切分时整个来源组会被完整划到同一侧,不会拆散;test_size=0.2指验证集占比20%;random_state=42固定随机种子,保证每次实验切分一致。最后一步打印分组统计,是为了肉眼确认每个source_id只出现在一个 split 里,这是发票数据划分最关键的验证动作。
4.3 划分比例怎么调:小样本场景下验证集不能将就
发票场景里我实际用的比例通常是训练集70%、验证集10%、测试集20%。测试集单独留出来,等所有模型实验都做完才动它——这就是“后悔药”。验证集用来反复调参,测试集只用来做最终评估,防止调参过程中过拟合到验证集上。如果你的总样本量只有几千张,验证集比例调到15%左右也可以,但绝对不要低于10%,否则字段级准确率的波动会大到无法判断模型改动是好是坏。
5. 数据标注和训练避坑:3个让我返工到崩溃的真实问题
5.1 多联发票被当成同一张票
现象:模型对同一张发票的发票联和抵扣联识别结果不一致,两个联的号码都对不上。 原因:采集时把同一张发票的两个联拍成两张照片,但标注时没有区分联别,数据清洗时也不知道它们来自同一张原始发票,导致模型把两个联的微小差异当成本质差异去学。 解决:在元数据里为每个样本增加invoice_parent_id字段,同一张发票的不同联共享同一个父ID。划分数据集时按invoice_parent_id而不是source_id分组,确保同一张发票的多个联不会跨训练集和验证集出现。这个教训是我在第二版数据集上踩的,改完之后验证集的可信度才真正立住。
现象:模型在验证集上字段识别率高达99%,上线后全乱了。 原因:标注的文本里带了隐形字符,比如发票号码里的全角空格、日期里的中文年月日与大写数字混排。模型在训练时“背”住了这些字符分布,换到真实场景就露馅。 解决:在标注后处理阶段加一步字符级清洗:
import re def clean_invoice_text(raw_text): # 统一全角半角、去空格、去零宽字符 text = raw_text.replace("\u3000", "").replace("\ufeff", "") text = re.sub(r"[\x00-\x1f\x7f]", "", text) # 发票号码/代码只保留数字和字母 if re.fullmatch(r"[0-9A-Za-z]{8,20}", text): text = text.upper() return text for rec in annotations: rec["text"] = clean_invoice_text(rec["text"])参数说明:\u3000是全角空格,\ufeff是零宽不换行字符,这两个是发票OCR标注里最常见的隐形字符;长度8~20的纯字母数字串会统一转大写,因为发票号码本身不区分大小写。跑完这步再看验证分数,掉了2个百分点,但上线后的真实表现反而稳定了——之前的高分是“作弊”出来的。
现象:模型把发票上的两个相近文本框合并成一个,字段错位。 原因:标注框之间距离太近,检测模型的后处理NMS参数太宽松,把两个相邻框合并了。 解决:调低NMS的IoU阈值,检不出来的框宁可漏,不要错:
# 以 PaddleOCR 的检测阈值为例 det_db_thresh=0.3 # 检测得分阈值,漏检时调低 det_db_box_thresh=0.5 # 框阈值,框太多时调高 det_db_unclip_ratio=1.8 # 框扩展系数,框太小文字被截断时调大这三个参数的调法是我最常用的组合,注意det_db_unclip_ratio增大后相邻框更容易粘连,要配合det_db_box_thresh一起调。每次只动一个参数,观察验证集上的字段级准确率变化再决定下一步。
6. 验证集上怎么看效果:字段级评估才是发票识别的照妖镜
发票信息识别模型在验证集上的评估,不能只看整张图“识别对没对”,要看字段级指标。我给项目组定过一个简单可复用的评估脚本,先按字段类别计算精确率和召回率,再做汇总。
def evaluate_field(pred_dict, gt_dict, field_name): """ pred_dict: {image_name: {field_name: text}} gt_dict: 与 pred_dict 同构 """ tp = fp = fn = 0 for img_name in gt_dict: gt_text = gt_dict[img_name].get(field_name, "") pred_text = pred_dict.get(img_name, {}).get(field_name, "") if gt_text == "": if pred_text != "": fp += 1 else: if pred_text == gt_text: tp += 1 elif pred_text == "": fn += 1 else: fp += 1 # 识别了但内容错误,算假阳性 precision = tp / (tp + fp) if (tp + fp) > 0 else 0 recall = tp / (tp + fn) if (tp + fn) > 0 else 0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0 return {"precision": precision, "recall": recall, "f1": f1}代码里的关键判断是fp += 1那个分支:识别出了内容但和真值不一致,这是发票场景里最隐蔽的错误——系统看起来很流畅,结果全是错的。所以评估时一定要分开统计“没识别出来”和“识别错了”两种失败模式。我在第一版模型上就被发票金额这个字段坑过一次,当时只看整体准确率是97%,拆到字段级发现金额字段的F1只有81%,原因就是大写的“壹贰叁”识别不稳。
训练过程中我的固定动作是每轮保存模型后在验证集上跑字段级评估,看每个字段的F1曲线。如果某个字段两轮训练不涨,就先停下来,去查这个字段的标注质量——多数情况下是标注框偏移导致文字被截断。这个习惯帮我少走了很多弯路。真实项目里,字段级F1稳定在95%以上、单张推理耗时在300毫秒以内,基本上可以进入小范围试点。希望这套方法对你有用,也祝你少踩我踩过的坑。
本文还有配套的精品资源,点击获取