小样本发票识别:TIF/XML数据集解析与OCR信息抽取实战

发布时间:2026/10/7 5:33:08
小样本发票识别:TIF/XML数据集解析与OCR信息抽取实战 简介面向深度学习与人工智能方向的OCR研究者和算法工程师这份发票信息识别数据集提供了发票关键字段自动提取的完整训练素材覆盖发票号码、开票日期、购销公司名称、公司电话、地址等实体的标注内容。数据集中XML文件与tif发票图像按同名方式组织XML内保存从对应发票图像提取的实体数据方便用户关联使用直接开展目标检测、文本识别或信息抽取等模型的训练与验证。资源共1560个文件其中包含1040个xml标注文件与520张tif发票图像整体压缩包约34.06MB体量轻巧、结构清晰适合快速下载使用。目前已有257人学习下载。利用该数据集开发者可构建发票信息识别系统在报销审核、财务自动化、票据归档等场景中实现发票关键信息的批量提取与结构化输出。1. 发票信息识别图像数据集先看清这份数据到底给你什么发票信息识别图像数据集我拿到手的第一反应是——怎么这么小但把TIF和XML打开对照着看了一遍之后我反倒觉得这份资料比那些动辄几十个G的通用OCR数据集更有价值它麻雀虽小五脏俱全。数据集由一批发票图像和同名XML组成XML里已经有人工或半自动提取好的结构化信息需要自己动手去抽取的是发票号码、开票日期、公司名称从公司1到公司2的发票、公司电话号码、地址等实体。先说清楚它解决什么问题它不是一个拿来就能训练的“大包”而是一套验证和搭建发票识别pipeline的试验田。你可以用它验证PaddleOCR、TrOCR这类引擎在真实票面上的效果也可以拿XML作为标准答案评测自己写的字段抽取逻辑到底准不准。适合做财务单据自动化的工程师、刚接触OCR信息抽取的研究人员尤其适合刚接触深度学习与人工智能方向、想找个真实场景练手的人不适合指望直接训出一个生产级模型的同学。2. 数据集的真实结构TIF与XML的配对机制与字段解读2.1 文件命名规则同名配对怎么快速检查数据集中TIF和XML文件名完全一致这是整个数据集的索引基础。拿到数据第一件事不是急着看图像内容而是先确认配对是否完整。我有一套固定的检查顺序先列TIF文件名再列XML文件名最后做diff看有没有孤立的图像或孤儿XML。ls -1 *.tif | sed s/\.tif$// | sort tif_names.txt ls -1 *.xml | sed s/\.xml$// | sort xml_names.txt diff tif_names.txt xml_names.txt逻辑说明sed把后缀剥掉只保留基线文件名sort之后用diff比较输出为空说明配对完整有差异的行就是配对失败的文件。这里的关键是必须用sed剥后缀而不是用basename因为文件名里可能出现多个点号手工basename很容易剥错。参数说明ls -1保证一行一个文件避免带空格的文件名在shell里被拆词diff返回码为0表示两个文件完全相同。我遇到过文件名里带-这类特殊字符导致for f in $(ls *.tif)时路径被截断的情况直接走ls管道反而最安全。配对检查完之后再用file命令看一眼格式file 0060063491.tif如果输出里有multipage或CCITT这样的字样后面读取时就要换库处理。这一步能提前知道TIF是不是压缩扫描件很多OCR项目翻车都是从“图像读不进来”开始的。注意diff返回0只能证明文件一一对应不能证明XML内容与图像内容一致。实际开工前务必抽两三张人工把XML字段和票面文字对照一遍。2.2 XML字段解析用ElementTree把实体安全取出来这套数据的核心价值在XML里。XML字段大体是扁平结构常见字段包括发票号码、开票日期、公司名称、电话、地址但标签命名不固定有的叫invoiceNo有的叫invoice_no中文标签也可能直接出现。我先把一个通用的读取函数写出来它可以绕开标签名差异import xml.etree.ElementTree as ET from pathlib import Path def load_all_fields(xml_path): tree ET.parse(xml_path) root tree.getroot() fields {} # iter() 可以绕开嵌套层级和命名空间的差异 for node in root.iter(): tag node.tag.split(})[-1] # 去掉 {namespace} 前缀 if node.text and node.text.strip(): fields[tag] node.text.strip() return fields逻辑说明iter()返回所有层级的节点不需要预先知道XML是嵌套还是扁平split(})[-1]是为了去掉XML命名空间前缀。很多标注工具导出的XML都带{http://...}前缀不去掉的话tag会变得很长后面做字段映射时非常容易出bug。最后只保留非空文本避免把空标签也写进字段dict。在此基础上再写一个按业务实体取值的函数def extract_invoice_entities(xml_path): fields load_all_fields(xml_path) mapping { invoice_no: [invoiceNo, invoice_no, 发票号码, invoiceNum], invoice_date: [invoiceDate, invoice_date, 开票日期, 发票数据], company_name: [companyName, company_name, 名称, 销售方名称], phone: [companyPhone, phone_number, 电话号码], address: [companyAddress, address, 地址], } result {} for key, candidates in mapping.items(): for cand in candidates: if cand in fields: result[key] fields[cand] break return result逻辑说明候选列表的顺序就是优先级排前面的键名优先。为什么把“发票数据”和“开票日期”放在同一组候选里因为摘要描述里写的是“发票数据”但绝大多数业务表里对应的实体就是开票日期真实XML里两种命名都可能出现都列上才不会漏取。参数说明fields的键完全来自上一步的load_all_fields如果XML里出现InvoiceData这种带大小写的变体直接在候选列表里再加一个字符串就行不用改结构。需要注意的是XML里可能同时出现两家公司名称摘要说的“从公司1到公司2的发票”意味着购销双方都会落到字段里。如果发现companyName对应了两个值建议提取时用列表保存并把“销售方/购买方”分开处理否则后面做实体匹配时一定会混乱。2.3 标注质量检查字段完整性先过一遍再谈训练数据标注质量决定了后面所有流程的下限。我用一个固定的检查脚本盲扫所有XML统计每个实体字段的缺失情况from pathlib import Path required [invoice_no, invoice_date, company_name, phone, address] xml_files sorted(Path(./data).glob(*.xml)) missing_count {k: 0 for k in required} for xml_path in xml_files: ent extract_invoice_entities(xml_path) for k in required: if not ent.get(k): missing_count[k] 1 for k, v in missing_count.items(): print(f{k}: {v}/{len(xml_files)} missing)逻辑说明这个脚本不涉及图像直接对XML做实体完整性扫描跑完就能看到哪张票缺哪个字段。参数说明ent.get(k)返回None或空字符串都算缺失但不区分“字段不存在”和“字段值为空”如果想知道更细的原因需要拆成if k not in ent和if not ent[k]两段分开统计。这一步之后我会人工抽查3到5张重点看日期格式是否统一是2024-01-05还是2024/1/5电话里是不是混入了分机号地址有没有被截断。这些是评测时影响最大的三类问题。下面是我固定的检查清单检查项常见异常对后续流程的影响发票号码前导零丢失作为主键关联时匹配失败开票日期格式不统一实体F1比较时字符串不相等公司名称购销双方混存抽取模型目标不明确电话混入分机号正则匹配时被误截断地址只截到省/市关键实体信息缺失检查完这些才算对这份数据集有了底。下一步就可以进入识别管线的搭建。3. 从图像到结构化实体发票识别管线的技术选型与落地3.1 为什么先做版面分析再做文字识别发票不是自然场景文本它是强结构文档。版面分析的目的是先确定“哪块区域是票头、哪块区域是表格、哪块区域是备注”再针对每个区域做OCR。直接整图跑OCR时模型往往输出“发票号码0060063491 发票代码62314……”这种混杂文本行后续用正则截取字段时行内的位置关系已经完全乱了。这份数据里的发票涉及两家公司意味着票面模板不止一个不同模板的表格位置、公司名称栏位、发票号码栏位都会有差异。版面分析层可以先用固定版式做模板匹配如果确认这批票都是同类版式也可以跳过检测模型直接定区域。常见做法是先用PaddleOCR的检测模块拿到所有文本框再按文本框在图像中的归一化坐标y坐标优先排序把票头区域和表格区域分开。这一步的粒度决定了后面字段提取的准确率。3.2 识别引擎选型检测、方向分类、识别的三件套说到发票OCR我一般先推PaddleOCR这类检测方向分类识别的三段式方案而不是直接上端到端深度学习模型。原因是发票里存在大量表格线和印章端到端模型对这类干扰的容错不如显式分离的三段式。选型时先看三个要素中文支持、是否离线、能否看到中间结果。下面是一张常见方案的对比方案中文离线中间结果适用场景PaddleOCR好可以检测框/文本/置信度都可输出本项目首选TrOCR较好可以无检测框只有文本印刷干净的票面云服务OCR好不行文本置信度不介意数据出网的场景选PaddleOCR还有一个理由它能输出每个检测框的坐标和置信度这对一个小样本数据集来说太重要了。你可以直接打印检测框看区域对不对而不是像端到端模型那样只能看到一个黑盒输出。以下是推理侧的代码from paddleocr import PaddleOCR ocr PaddleOCR( det_model_dirch_PP-OCRv4_det, rec_model_dirch_PP-OCRv4_rec, use_angle_clsTrue, langch, det_db_thresh0.3, det_db_box_thresh0.5, ) result ocr.ocr(0060063491.tif, clsTrue) for line in result[0]: box, (text, score) line print(box, text, score)逻辑说明det_model_dir和rec_model_dir分别指向检测和识别模型目录use_angle_clsTrue开启方向分类器主要用于处理扫描时歪了的票面。result[0]是当前图像的识别结果列表每一项包含四个点坐标的box和(text, score)。参数说明det_db_thresh是检测层判定前景概率的阈值调低会保留更多碎片文本调高会滤掉浅色印刷字det_db_box_thresh是文本框置信度阈值过滤掉太小的框。对发票这种浅灰表格线多的图我一般把det_db_thresh从默认值0.3调到0.35左右减少表格线被当成文本的误检。这一步属于玄学调参阶段每批票的扫描分辨率不同必须打印检测框看几轮再定不能照抄别人的参数。3.3 结构化输出XML标注转JSON Lines的两条路径识别出文本行之后怎么变成invoice_no字段有两条路径一是纯正则规则解析适合版式固定的票二是序列标注模型适合模板多变的票。如果走规则解析核心是这样的正则写法import re text 发票号码0060063491\n开票日期2024-01-05 match re.search(r发票号码[:]\s*(\S), text) if match: print(match.group(1))逻辑说明[:]同时匹配中英文冒号\s*吃掉冒号后的空白(\S)抓取连续非空白字符作为号码。参数说明这个正则在票面文字顺序固定时很好用一旦出现“发票号码”和值之间换行或字段被表格线切断就会漏匹配。这也是我在多模板发票上不推荐纯规则的原因。更稳妥的做法是把XML转成JSON Lines让它在规则解析和模型训练两边都能当标准答案import json from pathlib import Path import xml.etree.ElementTree as ET def xml_to_jsonl(xml_dir, out_pathlabels.jsonl): with open(out_path, w, encodingutf-8) as fo: for xml_file in sorted(Path(xml_dir).glob(*.xml)): tree ET.parse(xml_file) root tree.getroot() item {image: xml_file.stem .tif} for node in root.iter(): tag node.tag.split(})[-1] if node.text and node.text.strip(): item[tag] node.text.strip() fo.write(json.dumps(item, ensure_asciiFalse) \n) if __name__ __main__: xml_to_jsonl(./data)逻辑说明item[image]用xml_file.stem取不带后缀的文件名再补.tif保证每条JSON记录都能映射到图像。输出文件每行一个对象后面无论是写评测脚本还是转成训练数据都按行读入即可。参数说明ensure_asciiFalse保证中文实体原样写入文件而不是变成\uXXXX转义否则打印和调试会非常痛苦。转出来的记录字段不全没关系保留原始标签就好缺失问题由2.3节的完整性扫描负责暴露。转完格式下一步的核心问题变成样本这么少怎么划分、怎么增强、怎么评估。这是数据集落地的三块基石放在下一章专门说。4. 小数据集的训练策略划分、增强与实体级评测4.1 数据划分再小的数据集也要分层采样只有十来条样本时随手切个前80%后20%会带来一个隐蔽问题如果前几份XML恰好都来自同一家公司训练集就学不到另一家公司的版面验证集又恰好全是这家公司评测结果虚高。小数据集更要用分层采样让训练和验证集里同时出现两家公司。from sklearn.model_selection import train_test_split from pathlib import Path import xml.etree.ElementTree as ET xml_files sorted(Path(./data).glob(*.xml)) labels [] for xf in xml_files: tree ET.parse(xf) root tree.getroot() label unknown for node in root.iter(): tag node.tag.split(})[-1] if tag in (companyName, company_name, 名称) and node.text and node.text.strip(): label node.text.strip() break labels.append(label) train_files, val_files train_test_split( xml_files, test_size0.3, stratifylabels, random_state42 ) print(len(train_files), len(val_files))逻辑说明先从每个XML里提取公司名称作为分层标签stratifylabels会让train_test_split保持两个类别的比例而不是随机散列。参数说明test_size0.3在样本很少时可以放宽到0.4但要保证每个类别至少有一个验证样本random_state42固定随机种子保证每次切分结果一致这是实验可复现的前提。分层采样的前提是能从XML里拿到公司标签。如果你也遇到我那样的情况——XML里同时有销售方和购买方两个公司名建议在分层前先统一规则用“销售方名称”做标签。发票识别任务的核心是从票面提取销方信息按销方分层才符合真实场景。4.2 图像增强不改变版面语义的才是安全增强OCR增强和图像分类增强是两回事。分类任务可以把图旋转90度发票旋转90度后整个版面信息顺序就错了字段位置和阅读顺序直接崩掉。所以发票增强只能在“不改变版面语义”的范围内做微小的旋转、轻微的透视、亮度和对比度抖动。我用albumentations固定了一套增强import albumentations as A import cv2 image cv2.imread(0060063491.tif) if image is None: raise ValueError(TIF读取失败先按5.1节的方法用PIL打开) aug A.Compose([ A.RandomBrightnessContrast(brightness_limit0.15, contrast_limit0.15, p0.6), A.HueSaturationValue(hue_range(-3, 3), sat_range(-10, 10), p0.3), A.Affine(scale(0.98, 1.02), translate_percent(-0.02, 0.02), rotate(-1.5, 1.5), p0.5), ]) for i in range(8): out aug(imageimage)[image] cv2.imwrite(faug_{i}.tif, out)逻辑说明RandomBrightnessContrast模拟不同扫描仪的感光差异HueSaturationValue只做小范围色相抖动因为发票纸张颜色本就统一范围太大会出现伪彩色。Affine里的旋转范围控制在正负1.5度对应扫描时纸张放歪的真实误差超过3度就会让表格线发生明显错位识别结果反而变差。参数说明translate_percent是平移比例正负0.02意味着最多平移图像宽高的2%p是每个变换的触发概率不是同时全部生效。代码里for i in range(8)等于把每张原始图扩出8个变体对十来张的小数据集增强后能到百级规模够用来微调一个预训练OCR也够训练一个轻量信息抽取模型。增强之后必须抽样人工检查别让增强图变得看不清票面。4.3 实体级评测精确率、召回率、F1的计算脚本图像分类看准确率信息抽取任务必须看实体级的精确率、召回率、F1。差别在于模型把发票号码识别错了而其他字段全对按样本算可能还是“这个样本对了50%”不容易暴露问题实体级指标会把每个字段单独摊开算。我习惯用一个只依赖标准库的小函数def entity_f1(preds, truths): p_keys set(preds.keys()) t_keys set(truths.keys()) tp 0 for k in p_keys t_keys: if str(preds[k]).strip() str(truths[k]).strip(): tp 1 precision tp / len(p_keys) if p_keys else 0.0 recall tp / len(t_keys) if t_keys else 0.0 f1 2 * precision * recall / (precision recall) if (precision recall) else 0.0 return {precision: precision, recall: recall, f1: f1}逻辑说明preds和truths是字段名到字段值的字典。交集p_keys t_keys只遍历两边都有的字段字段值做字符串级比较。漏检一个字段recall下降多出一个错误字段precision下降。两个指标分开看能直接判断问题是偏向“没提取出来”还是“提取错了”。参数说明如果日期字段一边是2024-01-05一边是2024/1/5这里会算成错误。跑评测前先做一次归一化把两种格式都转成%Y-%m-%d否则实体F1会被格式差异拖低。这类字段归一化问题在OCR项目里最隐蔽也是下一章要专门展开的坑。5. 发票识别项目的常见问题与避坑清单5.1 cv2.imread读TIF返回None扫描件压缩格式的坑现象cv2.imread(0060063491.tif)返回None代码直接崩在数据读取阶段。原因不少扫描发票TIF是CCITT Group 4压缩的1位黑白图OpenCV的imread对这类压缩格式支持不完整还有一种情况是TIF是多页文件读进来只取到第一页或者直接失败。解决改用PIL或tifffile读取再统一转成OpenCV习惯的BGR三通道from PIL import Image import numpy as np import cv2 pil_img Image.open(0060063491.tif) rgb pil_img.convert(RGB) image cv2.cvtColor(np.array(rgb), cv2.COLOR_RGB2BGR)逻辑说明convert(RGB)先把灰度或黑白图统一成三通道再转成OpenCV需要的BGR顺序。参数说明如果pil_img本身是多页TIF需要先seek(0)取第一页或者用tifffile.imread返回数组自行选页。这个坑我踩过好几次现在遇到TIF一律先PIL打开再转通道不再直接用cv2读。5.2 “发票数据”和“开票日期”混存字段命名不统一的隐形炸弹现象同一批XML里有的字段叫“发票数据”有的叫“开票日期”还有的叫invoiceDate按单一字段名提取会漏掉一半样本。原因标注脚本或人工录入时没有强制字段字典不同人把同一个业务含义写成了不同键名。摘要描述里写的就是“发票数据”实际数据里大概率也能看到这种写法。解决在读取层做字段归一化把所有别名通过映射表收敛到统一键。2.2节的extract_invoice_entities已经把这逻辑放在mapping里了。更彻底的做法是先把所有XML里出现过的键全部打印出来人工确认哪些是同一实体再合并到映射表。我曾见过一份标注里有五种写法表达“开票日期”不归一化的话模型训练和评测都会错乱。5.3 文件名含有“-”等特殊字符shell脚本踩过的坑现象用脚本遍历时发现TIF和XML配对数少了一对但文件夹里两个文件明明都在。原因文件名如514967102-7103.tif里的和-在shell某些上下文里会被当作特殊符号处理用for f in $(ls)或手工拼接路径时路径被截断后半段文件名丢失。解决不要用shell变量拼接路径用Path.glob或Path.iterdir()在Python侧遍历。shell里必须处理文件列表时沿用2.1节的ls | sed | sort | diff写法不要自己用for循环重组字符串。写文件路径时尽量用shutil.copy而不是cp $a $b的循环避免特殊字符导致的目标路径错位。5.4 表格线被识别成文本检测阈值和预处理的双重问题现象识别结果里出现一整行||||||或重复的“一”字而且置信度还不低。原因发票表格区域的横竖线在检测层被判成前景文本尤其是det_db_thresh调低后浅灰细线很容易误检如果票面背景偏黄二值化后表格线和文字对比度接近更分不开。解决先从参数入手把det_db_thresh回调到0.35左右看误检是否消失如果还在再做预处理用形态学开运算把细线去掉import cv2 import numpy as np gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 检测横向长直线 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (40, 1)) lines cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel) # 原图减去表格线保留文字 clean cv2.subtract(gray, lines)逻辑说明getStructuringElement生成一个宽40像素、高1像素的横条核MORPH_OPEN会保留宽于核的横线把窄的文字笔画滤掉得到“只有表格线”的图再用原图减去它就是去掉横线后的干净图。参数说明核宽40是按300DPI发票的表格线宽估的线更细就减到20更粗就加大竖线同理把核改成(1, 40)。处理之后必须重新跑4.3节的F1脚本评测如果发票号码字段被误删就把核宽调小。这些坑都是实际跑数据时一个个撞出来的。解决了它们整套流程才算真正能落地。6. 进阶用法置信度阈值、半自动标注与模型迭代当模型在验证集上的F1卡在0.7左右上不去时最容易犯的错是继续刷参数。我的做法是反向操作把预测结果按置信度排序只挑低置信度样本人工修正再把修正后的样本回填训练集重新训练一轮。小数据集的迭代逻辑不是“标更多人更多数据”而是“只标模型不确定的部分”。import json THRESHOLD 0.85 review_queue [] with open(predictions.jsonl, encodingutf-8) as f: for line in f: item json.loads(line) for field_name, field_info in item.get(fields, {}).items(): score field_info.get(score, 0.0) if score THRESHOLD: review_queue.append( (item[image], field_name, field_info[text], score) ) review_queue.sort(keylambda x: x[3]) # 置信度最低的优先复核 for image, field_name, text, score in review_queue[:30]: print(f{image} | {field_name} | {text} | {score:.3f})逻辑说明predictions.jsonl是模型对每张测试图输出的字段级结果每个字段带独立置信度低于阈值的进入人工复核队列sort后置信度最低的排在最前复核时优先处理模型最没把握的样本。参数说明阈值0.85是我在这类发票任务上的习惯起点——发票字段高价值、数量少宁愿多复查几条也不放任一条错标进入下一轮训练如果你想更保守可以提到0.9但人工成本会明显上升。阈值定完之后我固定走四条迭代节奏第一轮用全部真实XML做评测第二轮把低置信度结果集人工修正后并入训练集第三轮用修正后的模型重新预测所有验证图最后对比修正前后实体F1的差值。这套流程里有一条血泪经验人工修正时不要直接在预测结果上改字要同时截一张该字段所在位置的图像切片存下来下一次模型又把字段识别错时能直接对比是版面变化还是模型退化。这套发票信息识别图像数据集虽然小却是跑通上述迭代流程最快的起点。从那以后我每拿到一份新的发票数据集都强制先跑一遍低置信度筛选加人工复核哪怕只有十来张票也会把全部预测结果过一遍再进训练。模型可以一次次迭代错标混进训练集里才是真正没有后悔药的事。希望帮到你。本文还有配套的精品资源点击获取