资讯动态

发票OCR识别与Excel导出:PaddleOCR与Python自动化实战

发布时间:2026/10/19 2:19:51 来源:尧图企业网站定制
简介面向财务、审计及自动化办公开发者的发票处理工具包针对发票信息提取与Excel报表生成的常见业务场景提供了一套从扫描图像或电子文件中识别关键字段、清洗校验并导出Excel的完整流程。压缩包内共有2个文件Python源码脚本.py便于学习与二次开发可自定义识别规则、模板配置及输出表格样式exe可执行程序适合不具备Python环境的用户直接运行快速完成发票信息的提取与Excel文件生成。资源包整体大小为45.92MB文件数量少、部署轻量。目前已累计有1259人学习和下载使用。方案覆盖OCR识别、发票模板匹配、关键字段定位、数据清洗与逻辑验证如金额、税额、日期格式并利用pandas库将结构化数据写入规范Excel表格同时兼顾数据安全与税务合规要求。读者既可动手运行exe查看成果也可深入阅读py源码掌握实现细节进而将这套逻辑迁移到财务报表自动生成、成本分析、预算控制等扩展场景切实提升批量发票处理效率与准确性。1. 发票扫描与Excel导出把录入5分钟、核对半小时的活交给脚本朋友抱着一摞发票扫描件来找我说财务那边每个月都要花大半天把发票代码、开票日期、价税合计手工敲进Excel。这个项目要解决的就是这件事输入一张发票扫描图自动把版面上的关键字段抠出来整理成一行一行的Excel记录。它适合三类人被报销表格折磨的财务同学、要维护进项发票台账的行政、以及想学OCR落地套路的开发者。拆完之后最大的感受是这类项目真正的难点不在模型而在字段规整——发票版式五花八门同一个字段在不同票面上叫法完全不一样识别出来的文本要经过几层清洗才能安全落进单元格。2. 技术选型与目录结构为什么主流程只要三个Python文件2.1 项目视角看OCR选型PaddleOCR为何比Tesseract省心实际拆这个项目时第一件事是定OCR引擎。Tesseract对中文支持不算差但发票场景里大量出现“金额”“税额”这种竖排、倾斜文本需要额外做旋转矫正而且中文语言包在低分辨率扫描件上识别出来的数字经常混进标点。PaddleOCR自带方向分类器和文本检测返回结果里直接带文本框坐标和置信度省了很多预处理工作。动手做时我直接用了它的use_angle_clsTrue参数让模型自动纠正旋转方向。图像部分用OpenCV做放大和二值化Excel生成用openpyxl。整个项目的依赖很少opencv-python、paddleocr、paddlepaddle、openpyxl四个包就能跑。这里要提醒一句paddlepaddle的CPU版本和GPU版本体积差不少日常处理几十张发票用CPU版本完全够部署到没有独立显卡的办公电脑上也省心。2.2 目录结构每个文件负责什么文件路径职责关键输出main.py程序入口遍历目标文件夹、调用各模块最终Excel文件config.py集中维护OCR参数、目标字段、阈值配置字典utils/image_processor.py图像预处理封装二值化图像对象utils/ocr_engine.pyPaddleOCR调用与结果解析文本行列表utils/excel_writer.py记录写入Excel工作簿文件tests/review_samples.py抽样校验脚本校验CSV为什么刻意分成三个独立模块而不是写在一个脚本里因为发票版式差异会导致字段提取逻辑频繁改动。图像预处理、OCR识别、Excel写入这三层相对稳定只有中间“从文本到字段”的规则经常要动。拆开之后换版式时只改中间层不动另外两层的逻辑排查问题也快——识别出来是乱的先看图像预处理输出再看OCR文本不用在几百行代码里猜。2.3 数据流向扫描件怎么变成Excel行从扫描图走到Excel要过四关图像预处理、OCR识别、字段提取、表格写入。每关都是单点替换式设计数据从上一关的产出流进下一关中间如果结果不对直接在对应关卡输出中间文件定位问题。# config.py PADDLE_OCR_SETTINGS { lang: ch, use_angle_cls: True, show_log: False, } TARGET_FIELDS [invoice_code, invoice_number, invoice_date, buyer, seller, amount, tax, total] CONFIDENCE_THRESHOLD 0.8 DPI_SCALE 300 OUTPUT_EXCEL invoice_records.xlsx这份配置是整个项目的主心骨。lang选ch表示中文模型use_angle_cls开启角度分类器处理手机拍照歪斜的发票CONFIDENCE_THRESHOLD是后面待复核清单的阈值线识别置信度低于0.8的字段会被单独拎出来人工确认DPI_SCALE是图像放大的目标值扫描仪出来的文件通常不需要放大手机拍的必须要。字段清单TARGET_FIELDS看着简单实际是整套提取规则的锚点。发票代码、发票号码、开票日期、购买方、销售方、金额、税额、价税合计这8个字段对应着后续正则提取里的8组模式。改版式的时候优先改这里和正则模式主流程代码一行都不用动。3. 核心模块拆解从扫描图到Excel行的完整链路3.1 图像预处理把拍歪的扫描件扶正放大图像预处理是整条流水线的第一站也是最容易被跳过却最影响结果的一步。手机拍的发票照片往往有两个问题分辨率不足导致小字号文字在识别时糊成一团以及背景纸张与桌面颜色相近导致文字和背景对比度不够。import cv2 def preprocess_image(image_path: str, target_dpi: int 300) - cv2.Mat: img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 扫描件分辨率不足时先放大避免小字号识别成乱码 h, w gray.shape[:2] scale target_dpi / 72.0 if scale 1.0: gray cv2.resize(gray, (int(w * scale), int(h * scale)), interpolationcv2.INTER_CUBIC) # 高斯自适应二值化保留文字边缘同时去掉背景噪声 binary cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15 ) return binary逻辑是先转灰度再按目标DPI放大最后做自适应二值化。放大倍数按target_dpi / 72.0计算因为手机照片常见的隐含分辨率是72 DPI扫描仪输出可能是150或300。如果原图已经够清晰scale会小于等于1直接跳过放大避免把文件撑得太大拖慢OCR速度。adaptiveThreshold的blockSize固定为31块大小必须为奇数指的是计算阈值时参考的邻域范围。邻域太小会把笔画内的灰度波动当成噪声太大则可能把整块区域直接判成背景。C值15是经验值调太大字会“变瘦”笔画中段被识别成断裂调太小背景上的纹理斑点会变成伪文字。如果拍歪超过15度在灰度化之后先做旋转矫正计算最小外接矩形的角度反方向转回来即可这步放在放大之前执行更稳。3.2 OCR调用与结果解析PaddleOCR封装好之后调用并不复杂坑主要在返回结构上。不同版本返回的层级不一样有的版本ocr.ocr()返回的是嵌套两层列表有的版本返回的是单层写代码时要做兼容。from paddleocr import PaddleOCR from typing import List ocr PaddleOCR(langch, use_angle_clsTrue, show_logFalse) def extract_text_lines(image_path: str) - List[dict]: result ocr.ocr(image_path, clsTrue) lines [] # 不同版本返回层级不同这里兼容常见格式 if not result: return lines for page in result: for line in page: if line is None: continue text line[1][0] confidence float(line[1][1]) lines.append({text: text, confidence: confidence}) return lines每条识别记录的结构是[文本框坐标, (识别文本, 置信度)]。坐标信息在按位置切分字段时非常有用——比如“购买方”和“销售方”在发票上分列两行靠正则无法区分时可以按Y坐标分组同一行的文本合并再按X坐标排序这样字段归属就直观了。clsTrue参数开启文本方向分类和use_angle_cls配合对旋转过的图像做二次纠正。置信度是个0到1之间的浮点数很多时候文本识别对了但置信度只有0.6大概率是图像模糊导致模型“猜”出来的结果。这个值要留存下来第5章的待复核清单靠它排序。3.3 正则提取从识别文本里捞出8个关键字段OCR输出的是散乱文本行离结构化字段还有一步之遥。发票代码、发票号码这类定长数字相对好处理真正的坑在金额字段——备注栏里可能出现一串数字直接匹配小数会把备注里的数字当成金额。import re def normalize_text(text: str) - str: # 把全角冒号、空格、换行统一避免正则失配 text text.replace(, :) text re.sub(r\s, , text) return text def parse_amount(text: str) - str | None: # 锚定“价税合计(小写)”关键词只取它后面的数字 patterns [ r价税合计[(]小写[)][¥]?([0-9](\.[0-9]{2})?), r价税合计[(]小写[)][¥]?([0-9](\.[0-9]{2})?), ] for pattern in patterns: m re.search(pattern, text) if m: return m.group(1) return Nonenormalize_text先统一冒号再删除所有空白这个操作很关键。发票版面常有“发票 号码”中间夹一个空格的情况OCR识别时也可能把一行断成两行删除空白后正则模式简洁得多。注意只删空白不删标点否则金额里小数点前的千分位逗号会直接被吃掉。金额提取的核心思路是锚定而不是遍历数字。在“价税合计(小写)”这个关键词出现之前出现的所有数字都不可信只有紧跟其后的才是目标值。实际版式中还可能写“合计(小写)”或“总额(小写)”把同义词列表放进patterns里逐个试。匹配不到就返回None让这个字段进待复核清单绝不猜一个值填进去。3.4 写入Excelopenpyxl生成带表头的表格字段提取完最后一步是写Excel。openpyxl的优势是不依赖Office环境在服务器上也能生成文件表头加粗和列宽设置都在代码里控制。from openpyxl import Workbook from openpyxl.styles import Font def write_excel(records: list[dict], output_path: str) - None: wb Workbook() ws wb.active ws.title 发票台账 headers [发票代码, 发票号码, 开票日期, 购买方, 销售方, 金额, 税额, 价税合计] ws.append(headers) for rec in records: ws.append([rec.get(field, ) for field in [ invoice_code, invoice_number, invoice_date, buyer, seller, amount, tax, total ]]) # 表头加粗方便人工核对 for cell in ws[1]: cell.font Font(boldTrue) # 按内容长度自适应列宽至少保留10个字符宽度 for col_idx, header in enumerate(headers, start1): width max(10, len(header) * 2) ws.column_dimensions[ws.cell(row1, columncol_idx).column_letter].width width wb.save(output_path)ws.append会自动从第1行往下追加所以先写表头再写内容即可。列宽按表头字符长度估算中文一个字符占两个英文宽度len(header) * 2是常见的粗暴处理方式够用就好。保存时扩展名必须是.xlsx如果手动改成.xls再打开Excel会报“文件格式和扩展名不匹配”。提示很多项目在这里忘记处理的一个细节——如果records为空write_excel也要生成一个带表头的空文件否则下游流程去读这个Excel时会直接报错排查起来又得绕一圈。4. 避坑记录识别乱码、金额错位、Excel打不开的现场排查4.1 识别乱码低分辨率扫描件现象手机拍的角度不正的发票识别出来一半是乱码尤其是发票代码和号码里的数字经常混进“0”和“O”。原因原图分辨率不足文字区域像素太少模型只能靠猜测补全。解决预处理先把图像放大到300 DPI再做二值化同时开启use_angle_cls让模型自动转正。看预处理后的图像是否清晰可读如果放大后字仍然“发虚”问题就在原图本身打电话让供应商重新传一份PDF扫描件。4.2 金额张冠李戴现象提取出来的金额变成了备注栏里的数字金额和税额明显对不上。原因正则直接匹配[0-9]\.?[0-9]*在没有锚定词的情况下匹配到了备注“联行号1234567890”之类的数字串。解决用“价税合计(小写)”做锚定只取锚定词之后的数字。如果锚定词缺失把这一条丢进待复核清单而不是猜一个值。实际处理中还要注意“小写”后面的括号可能是全角也可能是半角正则里同时写两种。4.3 发票号码被拆行截断现象发票号码识别时被拆成两行正则只匹配到前段导致号码少一位。原因二值化后号码区域的笔画断开检测框把号码和后面的日期合并在一个框里或者OCR把一行断成两条记录。解决normalize_text时删除所有空白再匹配删掉换行符和空格后前后段自然拼合。无法匹配的字段值设为空字符串交给后续校验不要用截断后的值硬填。4.4 生成Excel打不开提示损坏现象在公司电脑上双击生成的文件Excel弹出“文件格式损坏”警告WPS直接全部乱码。原因两个常见情况一是输出文件后缀被手动改成.xls二是wb.save()时输出目录被其他程序占用文件只写了一半。解决始终以.xlsx后缀保存确保输出目录有写权限保存前关闭Excel中打开的同名文件。如果文件超过1MB且历史记录里反复出现损坏检查是不是用了CSV模块强行加.xlsx后缀——CSV和xlsx的编码格式完全不是一回事。4.5 同一个文件被处理两遍现象把同一个文件夹处理两遍Excel里有重复行金额汇总翻了一倍。原因程序没有记录文件指纹重复执行时无法识别“这个文件处理过”。解决入口处对文件内容做MD5处理过的哈希写进processed.txt下次跳过。MD5计算用hashlib分块读取避免大文件一次性读入内存。5. 数据校验与批量处理让结果可信而不是看一眼就完5.1 置信度阈值把不靠谱的识别结果隔离出来OCR识别从来不是100%可靠真正跑生产环境时要的不是“尽量准”而是“知道自己哪条不准”。PaddleOCR返回的置信度在这时候派上用场。import csv def write_review_records(review_items: list[dict]) - None: with open(review.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[file, field, value, confidence]) writer.writeheader() writer.writerows(review_items)review_items里存放的是识别置信度低于CONFIDENCE_THRESHOLD的字段或者正则没匹配到的字段。编码用utf-8-sig前面带BOM头Excel打开才不会乱码用纯utf-8会在首列出现“锘”字。待复核清单应该和Excel一样作为一等输出它不是垃圾收集站而是给人工核对的人看的操作列表——按置信度降序排列优先处理最可疑的记录。5.2 批量扫描文件夹并合并结果实际使用时不可能一张张图片调用批量处理是最基本的功能。glob递归匹配子目录里所有图片处理完统一写一个Excel。import glob def batch_process(folder: str) - None: records [] for path in sorted(glob.glob(f{folder}/**/*.png, recursiveTrue)): lines extract_text_lines(path) parsed parse_invoice(lines) parsed[file] path records.append(parsed) write_excel(records, OUTPUT_EXCEL)glob.glob的recursiveTrue配合**才能匹配子目录否则只认当前层。sorted保证输出顺序稳定同一个文件夹每次跑出来顺序一致方便和原图比对。这里没有加MD5去重实际使用要让batch_process和去重逻辑配合先算MD5查processed.txt命中就跳过。5.3 三种常见的校验策略校验方式怎么做能发现的问题人工抽核随机抽10%记录与原图逐项比对字段口径不对、正则锚定词写错金额加总比对识别出的金额加税额验证是否等于价税合计OCR数字识别误差、字段错位必填字段非空校验发票代码、号码、日期任一为空则进复核版式不兼容、图像缺页抽10%看着比例低但对人工核对来说核心目的是发现“规则性错误”不是“偶发错误”。如果10张里有一张对不上说明规则有漏洞不是个别误识别这时先把待复核清单全量过一遍再回去改正则模式。金额加总比对是纯数字逻辑适合写进自动化流程价税合计和“金额税额”差超过0.01元就直接标黄。6. 二次开发从固定版式到多版式发票的改造思路6.1 把提取器抽成策略不同版式挂不同正则项目最初只针对老式增值税发票拿到数电票打印件后字段名称从“发票代码”变成了“数电票号码”正则全盘失效。这时不能再往parse_invoice里堆if分支正确做法是把提取器按版式拆开。class InvoiceParser: def __init__(self, extractors: dict): self.extractors extractors def parse(self, text: str) - dict: result {} for field, patterns in self.extractors.items(): for pattern in patterns: m re.search(pattern, text) if m: result[field] m.group(1) break return resultextractors结构是{字段名: [正则列表]}不同版式传入不同字典主循环完全不用改。新来一种版式只需要新增一个配置文件写上字段名和对应的正则模式数组优先级高的模式放前面。6.2 加一个可视化工件从命令行到简易界面给财务同事用的工具命令行再方便他们也不会用。用tkinter写个最简单的界面上方选文件夹下方一个“开始处理”按钮中间留一块文本区实时打印进度最终弹出“生成完成复核条数”。改造时注意把OCR引擎初始化提到全局PaddleOCR()实例创建很重每次点击按钮都重新加载模型会让界面卡死。从那以后我每次拆OCR项目都先强制自己拿三张真实样本把字段口径对齐了再动代码——一张歪的、一张模糊的、一张有备注干扰的全部跑通了再谈批量。这几个样本就是这个小项目的验收基线没有基线之前做的所有优化都是凭感觉调试有了基线之后每次改动只要把三张样本重新跑一遍五分钟就能判断是不是改了也白改。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑