简介:这是一份面向发票信息识别任务的图像数据集,压缩包内共1560个文件,主要由1040个XML标注文件与520张TIF发票扫描图组成,包体大小34.06MB。XML文件与对应图像文件名完全一致,标注内容涵盖发票号、发票日期、开票公司名称、公司电话、地址等实体信息,可直接用于深度学习模型训练、OCR文本抽取以及票据结构化解析等典型场景。数据集来源于真实票据扫描,包含印章、折痕等自然干扰元素,规模适中,既能满足新手在目标检测、序列标注等任务上的实验需求,也可作为企业发票自动化识别项目的效果验证基准。目前已有257人学习下载,对于需要真实票据样本来打磨识别算法的开发者而言,是一份可显著降低数据清洗与标注成本、开箱即用的训练资源。清晰的命名对应关系和规范的标注结构,能帮助使用者快速进入模型设计、调参和评估环节,节省大量前期准备时间。
1. 发票信息识别图像数据集:决定识别上限的从来不是模型
做发票识别的人迟早会撞上一堵墙:模型换了好几版,准确率卡在某个数字上不动了。翻来覆去调参,最后低头看一眼训练数据——几百张清晰扫描件,歪斜角度不超过5度,光照均匀得不像话。真实业务里,报销单上的褶皱、手机拍摄的炫光、热敏纸褪色后的浅灰字,一张都没见过。这时候才明白,发票信息识别图像数据集才是整个识别链路里最值得砸时间的环节。
这个标题讲的东西,不是一个打包好的现成压缩包,而是围绕「发票信息识别」这个任务要的一组图像资产,以及怎么造、怎么筛、怎么用才不浪费。适合正在做财务自动化、报销单据解析、税务风险控制的工程师,也适合想自建OCR训练数据但不知道怎么起步的团队。数据集的建设优先级和技术选型,决定了后续模型上线后的真实表现。本文按我从业务出发的做法拆开讲:发票图像数据集该怎么定义、怎么分级、怎么处理瑕疵图像、怎么避免常见的坑,最后收在如何验证效果上。
2. 发票图像数据集的构成:拍摄场景、字段类别和标注粒度决定蓝图
2.1 先分清三种数据形态:结构化字段、版面布局、整图质量
发票信息识别这个任务,目标是从一张发票图像里抽取关键信息,比如发票号码、开票日期、购买方名称、金额价税合计。很多团队一开始就直奔OCR识别,把数据画像当成「一堆图加一堆标签」,忽略了图像本身要拆成三个层面来看。
第一个层面是字段识别数据。每张图对应一个JSON或者XML文本,里面含发票代码、号码、开票日期、校验码、税额、金额、销售方名称这些字段。第二个层面是版面结构数据,需要标注出每个字段在图像上的位置,通常用文本框或者四边形坐标表示,这是做键值对匹配、做版面分析模型的基础。第三个层面是图像质量数据,指这张图本身的光照、模糊、透视变形、遮挡、噪声情况,它不直接参与字段模型训练,但决定了前面的数据要不要被过滤掉,或者能不能做增强。
我一般建议团队在起步阶段就按这三个层面分别建目录,而不是一股脑把图片和标注文件平铺。目录结构像这样:
invoice_dataset/ ├── raw/ │ ├── field_ocr/ # 字段识别任务原始图 │ ├── layout_analysis/ # 版面分析任务原始图 │ └── quality_grading/ # 图像质量分级专用样本 ├── annotations/ │ ├── json/ # 字段级标注,key-value结构 │ ├── polygons/ # 版面区域四边形坐标 │ └── labels/ # 图像质量标签,0/1/2三级 ├── scripts/ └── splits/ ├── train.txt ├── val.txt └── test.txt这里有一个关键的设计决策:字段识别和版面分析不要共用一套标注文件。原因很实际,字段识别关心的是「文字内容对不对」,模型可以只对裁剪后的单词图片训练;版面分析关心的是「位置和结构对不对」,必须用原图比例训练。两者混在一起写标注,做数据增强时会互相干扰,比如旋转增强对字段识别是友好的,但对版面坐标就是一场灾难。
2.2 数据采集的覆盖面:设备、距离、光线和背景的排列组合
很多团队做发票识别,第一版数据集是从财务部门拿来的扫描件,效果看着不错,一旦换成手机拍照就崩。问题不在模型,在数据采集的覆盖范围太窄。发票这种票据,它的成像环境极其不稳定。
我见过一个比较扎实的采集设计。把设备分成三类:高拍仪、普通扫描仪、手机摄像头。手机摄像头再按档位区分,千元机、旗舰机、微距模式。每类设备拍摄时要覆盖三个距离段:填满画面、占画面三分之二、占画面一半。光线条件至少要拍四种:室内头顶灯、窗边自然光、夜间闪光灯、逆光。背景上要覆盖桌面、纸质文件堆、深色皮面、透明文件袋。
这样排列组合下来,同一张发票理论上可以产生几十种变体。实际执行中不需要每张发票都拍满全部组合,而是按比例抽拍。比如1000张基础发票,其中600张用设备矩阵里常见的组合拍,300张用极端组合拍,100张留出来做图像质量增强的底图。如果你的团队有标注人力富余,还可以在真实采集之外用程序生成合成样本,这个话题后面再展开。
采集动作本身要记录元数据。文件名不能写成IMG_001.jpg,一合并就乱。我这边习惯的方案是文件名里编码设备类型、环境光线、拍摄距离三个维度,例如:
import hashlib def generate_invoice_filename(invoice_no, device_class, light_condition, distance_band): # 发票号做匿名化,避免明文保存隐私字段 invoice_id = hashlib.md5(invoice_no.encode('utf-8')).hexdigest()[:12] # 归档命名:发票ID_设备类型_光线_距离_序号.jpg # device_class 取值 scanner / highshot / phone_low / phone_high # light_condition 取值 indoor / window / flash / backlight # distance_band 取值 full / 2over3 / 1over2 seq = "001" return f"{invoice_id}_{device_class}_{light_condition}_{distance_band}_{seq}.jpg"参数说明:匿名化前缀只用MD5前12位,保留唯一性但避免原始发票号出现在文件名里。设备类型和光线、距离三段信息会成为后续数据筛选的标签来源,训练时可以根据需要只取某个子集。比如模型在扫描仪数据上过拟合了,就少放scanner类样本。
2.3 标注体系按任务拆两层:检测框是骨架,字段内容是血肉
标注环节最容易沦为纯体力活,但标注质量直接决定了识别的天花板。如果标注框的边界不齐,模型学习到的位置先验就是乱的对齐方式;如果标注内容里金额少写一个数字,模型等于被喂了错误答案。我建议在动手标注前就把体系定清楚。
第一层是区域级标注。把发票版面切成几个固定区域:发票代码区域、发票号码区域、开票日期区域、购买方信息区域、销售方信息区域、商品明细区域、价税合计区域。每个区域用一个四边形的四个顶点坐标表示。注意不要用正矩形框,因为拍摄角度会带来透视变形,正矩形会把邻近字段切进来。
第二层是字段级标注。在这一层,每个区域里的文字块要按行切成文本,跟JSON里的字段名对应。这里有一个常见分歧是,行级标注还是词级标注。我的经验是,做发票这类版式相对固定的文档,行级标注优先。因为发票字段的语义边界常常在行层面就结束了,比如“购买方名称:ABC科技有限公司”,整一行才是完整语义。切成词级会让模型学散,而且后处理拼回一行时容易弄错顺序。
标注质量要用抽检来控制。抽检比例不需要很高,但抽检方式要固定。我一般用脚本把标注结果可视化到原图上,人工看图找错。下面这个脚本片段可以批量生成带标注框的校验图:
import cv2 import json def visualize_annotation(image_path, json_path, output_path): img = cv2.imread(image_path) with open(json_path, 'r', encoding='utf-8') as f: ann = json.load(f) # 遍历字段级标注,绘制四边形边界 for field in ann['fields']: pts = field['polygon'] # 格式为 [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] color = (0, 255, 0) if field['valid'] else (0, 0, 255) cv2.polylines(img, [pts], isClosed=True, color=color, thickness=2) # 在框左上角写字段名缩写 cv2.putText(img, field['name'][:4], tuple(pts[0]), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.imwrite(output_path, img)这段代码的逻辑很直白:把标注JSON里的多边形还原到图上,有效字段画绿框、无效字段画红框。人工看图时,重点检查框是否切到相邻文字、框边界是否明显小于文字区域、字段名和框内容是否匹配。批量跑完所有图,挑出有问题的回炉修标。
3. 发票图像的清洗与质量分级:用最少的人力筛出最有效的训练数据
3.1 质量分级标准和占比:模糊、遮挡、反光、透视四个维度
数据清洗不是只看图清不清晰,而是按任务需要分级。我常用的分级维度有四个:模糊程度、遮挡面积、反光干扰、透视形变。每个维度分三档,0为正常、1为轻微、2为严重。一张图在每个维度都打一个标签,组合成一个四维质量向量。
质量标签有三个用途。训练时按比例混入轻微瑕疵样本,提高模型泛化能力;严重瑕疵样本不进训练集,但保留在测试集里,用于测量模型的抗干扰下限;完全不清晰的样本单独存到一个目录,作为数据增强的底图素材。
什么样的分布算合理?纯扫描件场景,0档占八成,1档占两成,2档基本没有。手机拍摄场景,0档大概占三成,1档占五成,2档占两成。如果你的业务里手机图是主流,数据分布就要反过来。这里有一个容易犯的错误,就是不管业务场景,把所有图平均分布进训练集。这样模型学到的先验既不像扫描、也不像拍照,两边都讨好不了。
3.2 自动化清洗管线:图像质量评分与去重合并策略
手工一张张筛图不现实,但可以先用脚本做一次粗筛,把明显不合格的图自动剔除,再用人工看剩余难例。粗筛管线里我常放三个步骤。
第一步是清晰度评分,使用拉普拉斯方差。这个指标对模糊图非常敏感,拉到阈值以下就标记为严重模糊。第二步是亮度过曝判断,检查像素直方图的高光端占比。第三步是相似度去重,用感知哈希算法把近乎一样的重复图挑出来。
import cv2 import numpy as np def quality_screen(image_path, blur_thresh=120.0, overexpose_ratio=0.60): img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 拉普拉斯方差,值越小图像越模糊 laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var() # 高光像素占比,超过阈值判定为过曝 high_light = np.sum(gray > 235) / (gray.shape[0] * gray.shape[1]) if laplacian_var < blur_thresh: return "reject_blur", laplacian_var if high_light > overexpose_ratio: return "reject_overexpose", high_light return "pass", laplacian_var参数说明:blur_thresh设为120是我在真实发票图上统计出来的经验值,不同分辨率下要按比例缩放,2000像素宽的图可以放宽到80。overexpose_ratio设为0.6,意味着超过六成像素发白才判过曝,这个阈值不要设太低,否则把带反光的正常图误杀了。脚本跑完之后,把每个文件的质量分数输出为一个CSV,人工只看排序靠后的那批图。
3.3 脏数据增强:既保持文字语义,又覆盖更多废片情况
增强是数据扩量最便宜的手段。发票图像有一个特殊性,它的文字信息不能被扭曲到不可读,但图像质量可以去模拟各种脏场景。所以增强策略要围绕图像退化写,而不是围绕内容变形写。
常用增强手段包括高斯噪声、运动模糊、仿射变换、透视变换、亮度抖动、局部阴影遮挡、模拟折痕。每一项增强都要配一个低频概率,我一般把概率控制在0.15到0.25之间,太猛了会让模型学到「文字本身带噪」的错误印象。
这里需要注意一个技术细节,做透视变换时要避免把四边形变换得过度极端,否则文字扭曲到无法辨认,等于造了一批模型永远学不会的负样本。增强后的图片标签要保持和原图一致,但因为仿射变换会改变框坐标,所以训练代码里必须在每次迭代时同步变换图片和坐标。
import cv2 import numpy as np def random_perspective_augment(img, boxes, max_warp=0.08): h, w = img.shape[:2] # 四个角点做微小偏移,产生类似手持拍摄的透视效果 src = np.float32([[0, 0], [w, 0], [w, h], [0, h]]) dst = np.float32([ [w*max_warp*np.random.uniform(-1,1), h*max_warp*np.random.uniform(-1,1)], [w - w*max_warp*np.random.uniform(-1,1), h*max_warp*np.random.uniform(-1,1)], [w - w*max_warp*np.random.uniform(-1,1), h - h*max_warp*np.random.uniform(-1,1)], [w*max_warp*np.random.uniform(-1,1), h - h*max_warp*np.random.uniform(-1,1)] ]) matrix = cv2.getPerspectiveTransform(src, dst) warped_img = cv2.warpPerspective(img, matrix, (w, h)) # 同步变换坐标框:先将归一化坐标转成点集,再做透视投影 boxes_norm = boxes.reshape(-1, 2).astype(np.float32) ones = np.ones((boxes_norm.shape[0], 1), dtype=np.float32) points = np.hstack([boxes_norm, ones]) transformed = matrix.dot(points.T).T transformed = transformed[:, :2] / transformed[:, 2:3] transformed = transformed.reshape(-1, 4, 2) return warped_img, transformed这段代码的核心逻辑在坐标同步上,先用原始四边形四个顶点做透视变换,再通过矩阵乘法把点坐标映射到新图里。max_warp是最大偏移比例,0.08意味着角点最多偏移图宽8%,这个值是我在多次实测后觉得不会让文字过度扭曲的临界点。如果屏幕上文字出现明显形变,把这个参数往0.04调。
3.4 合成数据补充:为什么说它是小团队的后悔药
真实采集有个天花板,难以覆盖版式多变的发票种类。尤其专票和普票版式不同,不同省份的发票有细微差异,等你一张张攒够,项目早就凉了。合成数据在发票识别里是一个被严重低估的工具。
合成数据就是把真实发票的版面用程序排版出来,再渲染成图像。听起来像造假,但它的价值不在替代真实数据,而在补齐版式变化。比如你的业务要接电子发票和纸质专票两种版式,纸质的你采了2000张,电子的只有300张,模型在电子票上明显偏弱。用合成手段把电子票版式渲染2000张出来,模型的结构先验就能站稳。
合成图像时,背景、字体、打印色都做成可配置项。字体大小、字段间距、公章位置都可以随机扰动。生成之后建议再经过一次质量退化处理,让合成图看起来更接近手机实拍效果。
from PIL import Image, ImageDraw, ImageFont def render_synthetic_invoice(layout_json, font_path, output_path): img = Image.new('RGB', (1000, 1500), 'white') draw = ImageDraw.Draw(img) font_title = ImageFont.truetype(font_path, 28) font_body = ImageFont.truetype(font_path, 22) # 按JSON里的坐标块逐字段绘制文本 for block in layout_json['blocks']: txt = block['text'] + ":" + block['value'] # 模拟手写体与打印体的差异 if block.get('handwritten'): font_item = font_title fill = (30, 30, 30) else: font_item = font_body fill = (50, 50, 50) draw.text((block['x'], block['y']), txt, fill=fill, font=font_item) # 叠加印章效果,半透明红色圆形 overlay = Image.new('RGBA', img.size, (0, 0, 0, 0)) overlay_draw = ImageDraw.Draw(overlay) overlay_draw.ellipse((760, 1200, 920, 1360), outline=(200, 0, 0, 160), width=8) img = Image.alpha_composite(img.convert('RGBA'), overlay).convert('RGB') img.save(output_path)参数说明,layout_json保存了每个字段在版面中的坐标和内容,这个文件本质上就是标注,渲染代码从同一个JSON生成图像和标注,天然对齐。字体的选择很关键,不要只用一种黑体,混入宋体、仿宋、楷体能让模型对字形更鲁棒。印章叠加的位置随机偏移,避免模型把印章特征和字段位置绑定死。
合成数据占比控制在20%以内比较安全。超过这个比例,模型容易依赖合成数据里的规律性排布,反而降低真实场景泛化能力。
4. 发票识别模型训练中的数据配比与格式转换:5个必踩的坑
4.1 坑一:全量数据训练导致过拟合目标不突出
不少人拿到数据集之后,直接全量丢进模型训练。发票数据集和公开数据集不一样,它的类别分布天然倾斜。发票号码和金额这类字段样本量很大,而校验码、密码区这类字段样本量很小。全量训练的结果是模型对高频字段过拟合,低频字段识别率惨不忍睹。
解决方式是按字段出现频率做采样权重。每个字段在损失函数里单独计算loss,对低频字段的loss乘一个放大系数。实现上不用改模型结构,只要在数据加载器里控制。
class InvoiceBatchSampler: def __init__(self, field_freq, batch_size, oversample_ratio=3.0): # field_freq 是字段名到样本数的映射表 avg_freq = sum(field_freq.values()) / len(field_freq) # 相对频率越低的字段,采样权重越大 self.weights = [] for field, freq in field_freq.items(): w = 1.0 + oversample_ratio * max(0.0, 1.0 - freq / avg_freq) self.weights.append(w) self.batch_size = batch_size def __iter__(self): # 按权重随机抽取样本组成一个batch pass这个思路的核心不是复制样本,而是控制每个batch里各字段出现的比例。oversample_ratio是放大上限,3.0意味着最低频字段的采样权重最多是普通字段的4倍。注意不要把这个值设到10以上,模型会开始对低频字段产生位置上的选择偏好。
4.2 坑二:检测框格式在不同模型间转换时坐标丢失
发票识别常用的检测模型各有各的标注格式。用LabelImg标注出来的VOC格式,坐标是左上角和右下角;YOLO格式是归一化的中心点加宽高;在做分割或四边形检测时,需要四个角点坐标。转换本身不难,难的是舍入误差和越界处理。
四边形的角点坐标在归一化除法里容易出现小数截断。如果转成YOLO格式时直接四舍五入到小数点后4位,在2000像素宽的图上会带来约0.1像素的误差,看似无所谓,但如果后续做透视增强,多个角点误差累积起来会让框明显偏移。
做转换时要保留原始精度,归一化用float32,不要在中间步骤转成整形。
def voc_to_yolo_polygon(size, box): dw = 1.0 / size[0] dh = 1.0 / size[1] # box 是四个角点坐标 [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] x_coords = [p[0] for p in box] y_coords = [p[1] for p in box] # 求最小外接矩形的中心点和宽高 x_center = (min(x_coords) + max(x_coords)) / 2.0 y_center = (min(y_coords) + max(y_coords)) / 2.0 w = max(x_coords) - min(x_coords) h = max(y_coords) - min(y_coords) return [x_center * dw, y_center * dh, w * dw, h * dh]这里有一个隐蔽问题,四边形框转成外接矩形后,角点信息全部丢掉了。如果你的模型是基于旋转框检测的,这等于把标注精度人为降低。建议做法是保持两个标注版本:一个YOLO外接矩形版本用于普通检测模型,另一个保留四边形的JSON版本用于旋转框模型和版面分析。
4.3 坑三:训练集和验证集划分没有按来源隔离
最常见的数据泄露,是同一张发票的图像在增强后被同时分进了训练集和验证集。比如原图在train里,它的模糊版本被放进了val。验证集看起来loss很低、准确率很高,实际线上效果却差得离谱。
这个问题的根源在于划分是按文件做的,而不是按发票主体做的。同一张发票的不同拍摄角度、不同增强版本,本质上是同一个样本。划分必须先在发票ID级别完成,即一张发票的所有变体要么全进train,要么全进val。
import random from collections import defaultdict def split_by_invoice(sample_files, invoice_field_index, val_ratio=0.15): # sample_files 是文件路径列表,每个文件名里包含发票ID invoice_groups = defaultdict(list) for f in sample_files: invoice_id = f.split('_')[0] invoice_groups[invoice_id].append(f) all_invoice_ids = list(invoice_groups.keys()) random.shuffle(all_invoice_ids) val_count = max(1, int(len(all_invoice_ids) * val_ratio)) val_invoice_ids = set(all_invoice_ids[:val_count]) train_files = [] val_files = [] for inv_id, files in invoice_groups.items(): if inv_id in val_invoice_ids: val_files.extend(files) else: train_files.extend(files) return train_files, val_files提前按发票ID分组的价值在于,它把你从「拍脑袋挑文件」变成「按业务语义划分」。如果你的文件名里没有发票ID,那说明采集阶段命名规范就没有做对,赶紧回头改采集脚本,而不是在划分脚本里努力切分。
4.4 坑四:图像预处理参数写死在训练代码里导致换数据集就废
发票图像数据集在清洗阶段做了一堆预处理,比如灰度化、二值化、去噪、倾斜矫正。这些操作的效果高度依赖数据源。扫描件可以放心做灰度化,但手机拍摄的彩色图上做灰度化会丢掉发票专用章辨识度。二值化对红色印章尤其不友好,红章在中低分辨率下经常被二值化抹成一块黑斑。
常见悲剧是这样发生的:第一版数据集全是扫描件,预处理管线里写了二值化,模型效果不错。第二版加了手机照片,忘了改预处理,结果模型在印章区域疯狂误识别。修复方式是把预处理操作拆成可配置项,不同数据源用不同管线。
preprocess_config = { "scanner": { "gray": True, "binarize": True, "deskew": True, "denoise": False }, "phone": { "gray": False, "binarize": False, "deskew": True, "denoise": True, "color_balance": True } }这个脚本不是运行时逻辑,而是数据准备阶段的规范。你需要在生成训练集时就按来源打上预处理标记,让模型始终看到同一套分布下的图。如果你发现同一张发票在扫描和手机拍摄两种来源下都有,建议只保留一种,算法在不同成像风格间摇摆是最浪费训练资源的。
4.5 坑五:标注数据里的类别稀疏导致模型召回率虚高
发票识别里,金额和发票号码字段的样本多到用不完,但「备注」字段、「收款人」字段样本很少。模型对高频字段的召回率很高,平均指标被拉上去。上线后用户发现备注字段十次有五次识别不出,体验立刻崩盘。
评估指标必须分层看,不要只看一个整体准确率。 我习惯在每个字段维度单独计算准确率和召回率,并且设定最低门槛。对高频字段要求准确率99%以上,对低频字段至少90%。达不到最低门槛的字段,即使整体指标变差也要加大采样或补充数据。
5. 建一套跑得通的「图像质量难例集」,把测试从玄学变成日常
前四章把数据集的构成、清洗、训练配比都理顺了,最后要解决的是验证问题。很多团队的验证方式是把一批图扔给模型,人肉数错几个。这种做法最大的问题是不可重复,换了人、换了图结果都不一样。我习惯的做法是单独维护一份「图像质量难例集」,专门收集那些模型容易翻车的图,每次模型更新后固定跑一遍,用指标对比判断是变好还是变坏。
难例集的来源有三个渠道。第一个是从真实业务日志里捞,凡是线上识别失败的图都自动存档,攒够一批人工标注后加进去。第二个是从训练集划分时故意留出来的严重瑕疵样本,比如那张有反光的、那张有遮挡的,不进训练集但进难例集。第三个是用增强脚本把好图强行制造出难例,比如把清晰的扫描件叠上一半面积的阴影。
难例集建成后,要跑一个类似验收的脚本,输出每个字段在难例集上的指标变化。下面是一个简化的评估结构:
hard_set/ ├── backlight/ # 逆光样本 ├── blur/ # 运动模糊样本 ├── fold/ # 折痕样本 ├── occluded/ # 遮挡样本 └── low_contrast/ # 热敏纸褪色样本每个子目录对应一类失效模式。模型更新后,逐个目录看指标。如果新模型在blur上变好了但backlight上变差了,不是白干,至少知道退化的方向。我自己的习惯是每次训练完先跑难例集,再决定要不要上线,而不是直接拿整体准确率说话。
个人教训印象最深的是,有一版模型整体字段准确率从95%提到96.5%,我差点就要上线。幸好难例集里backlight子集的准确率从88%掉到了79%,追查下去发现是训练集里新增的样本全是高照度照片,压制了低光照特征的学习。把backlight样本权重调回去之后,整体指标回来了,难例集也回到了93%以上。
难例集这种验证方式的另一个好处,是让团队内部讨论具体了。同事之间不再说「模型好像不太行」,而是说「在折痕样本上准确率掉了5个点」。有了统一的话术,数据补采的方向也自然清晰了。希望这套思路在你的发票识别项目里也跑得通,帮你在模型训练之外,找到更可控的精度增长点。
本文还有配套的精品资源,点击获取