资讯动态

基于深度学习的发票文字检测与识别:DBNet与CRNN实战

发布时间:2026/10/5 9:21:29 来源:尧图企业网站定制
简介这是一份面向计算机视觉与OCR方向学习者、研究者的学术论文资源聚焦发票场景下被印章遮挡文字的检测与识别难题。论文提出先以轻量级深度神经网络定位印章区域再依据颜色信息分离印章与文字并通过色彩阈值提取被覆盖字符最终合并文字实现去印章实验显示印章区域内文字检测准确率提升53%、识别准确率提升20%。资源包共1个PDF文件大小约2.39MB内容为完整的期刊论文含摘要、引言、相关研究、方法设计与实验分析等章节适合深度学习、机器学习与数据建模方向读者研读算法思路与实验设计。目前已有152人学习下载可用于了解神经网络在图像识别、文字识别中的具体落地方式并借鉴其颜色分离与阈值提取的工程实现路径。1. 发票文字检测与识别从一张歪斜的增值税发票说起财务共享中心的朋友给我看过一张扫描件增值税专用发票被塞进扫描仪时歪了大概七度右下角还有一枚红色发票专用章压在金额栏上。人眼扫一眼就能读出来的信息交给通用 OCR 接口返回的结果是「价税合计」四个字被拆成两行金额里的「8」被识别成「3」发票代码直接漏了一整段。这不是个例。发票类文档的文字检测与识别和拍路牌、拍菜单完全不是一回事——它版面高度结构化、字段位置相对固定、但干扰源极其密集印章、表格线、复写纸底纹、骑缝章、装订孔、折痕每一样都能让一个在通用场景下表现不错的模型当场翻车。基于神经网络的发票文字检测与识别方法核心要解决的就是两件事第一在整张发票图像里把「哪里有字」框出来也就是文字检测第二把框出来的文字区域转成可编辑、可校验的字符串也就是文字识别。前者决定召回率和框的精度后者决定字段准确率。这两步串起来才构成一条能落地的发票 OCR 流水线。适合谁看如果你正在做财务自动化、报销系统、进销项发票管理或者单纯想用深度学习把票据类文档的结构化提取跑通这篇笔记里的选型逻辑、参数设置和踩坑记录可以直接拿去用。下面从检测和识别两条线分别拆开讲再合到一条完整链路上。2. 文字检测选型CTPN、EAST 和 DBNet 在发票场景下怎么选发票文字检测的本质是「任意方向文本检测」的一个子问题。发票上的文字方向相对规整绝大多数是水平或接近水平的但扫描倾斜、拍照透视会让文本行出现小角度旋转。更麻烦的是密集小字发票代码、密码区、开票日期这些区域字号小、字符间距紧检测框稍微偏一点后面的识别就全乱。选检测模型核心看三个指标小字召回、框的紧致度、推理速度。2.1 三类主流检测网络的适用边界CTPN 是较早的方案基于 Faster R-CNN 的架构做竖直文本提议再通过 BLSTM 做序列化连接。它的优势是对水平文本行检测稳定缺点是只能处理水平或微倾斜文本遇到透视变形就吃力而且推理速度偏慢。在发票场景里如果扫描件质量高、倾斜角度小于五度CTPN 还能用但一旦涉及拍照上传基本可以放弃。EAST 走的是无锚框路线直接回归旋转矩形或四边形。它的速度优势明显单尺度推理在 1080Ti 上能跑到 13 FPS 以上对小字密集场景也比 CTPN 友好。但 EAST 的短板在于长文本行的边界回归容易发散发票上「货物或应税劳务、服务名称」这种长字段框的右边界经常多出一截或者少一截。调这个问题的经验是把回归分支的损失权重调高同时在训练数据里多塞长文本行的样本。DBNet 是近两年票据类场景里我用得最多的。它的核心思路是可微分二值化网络输出一张概率图通过一个可学习的阈值图把概率图二值化成文本区域再后处理成框。这个设计的好处是后处理极简对密集小字和粘连字符的分离效果明显好于前两者。在发票数据集上DBNet 的 F1 通常能比 EAST 高 3 到 5 个百分点代价是推理速度略慢但用轻量 backbone比如 ResNet-18 或 MobileNetV3可以拉回来。模型小字召回倾斜鲁棒性推理速度发票场景推荐度CTPN中弱慢低EAST中高中快中DBNet高强中快高2.2 用 DBNet 跑通发票检测的最小步骤假设你已经装好了 PyTorch 和 DBNet 的开源实现常见做法是直接用 PaddleOCR 或 MMOCR 里的 DBNet 配置下面是从数据准备到推理的完整链路。第一步把发票图像和标注整理成 ICDAR 格式。标注文件每行是x1,y1,x2,y2,x3,y3,x4,y4,文本内容四边形按顺时针排列。# 目录结构 # data/invoice/ # train_images/ *.jpg # train_labels/ *.txt # test_images/ *.jpg # test_labels/ *.txt第二步生成训练用的二值化概率图标签。DBNet 的训练标签不是简单的 0/1 掩码而是经过收缩的文本区域加上一个距离阈值图。import cv2 import numpy as np from shapely.geometry import Polygon def generate_db_label(image_path, label_path, shrink_ratio0.4): 生成 DBNet 训练所需的 probability map 和 threshold map shrink_ratio: 文本区域收缩比例发票小字建议 0.3-0.4 img cv2.imread(image_path) h, w img.shape[:2] prob_map np.zeros((h, w), dtypenp.float32) thresh_map np.zeros((h, w), dtypenp.float32) with open(label_path, r, encodingutf-8) as f: lines f.readlines() for line in lines: parts line.strip().split(,) coords list(map(float, parts[:8])) poly Polygon(np.array(coords).reshape(4, 2)) # 计算收缩后的多边形 shrunk poly.buffer(-poly.length * shrink_ratio / poly.length) if shrunk.is_empty: continue # 在 prob_map 上填充收缩后的区域 pts np.array(shrunk.exterior.coords, dtypenp.int32) cv2.fillPoly(prob_map, [pts], 1.0) # threshold map 用距离变换生成 cv2.fillPoly(thresh_map, [np.array(poly.exterior.coords, dtypenp.int32)], 1.0) return prob_map, thresh_map这段代码的关键参数是shrink_ratio。发票上的小字比如密码区的密文如果收缩太多正样本区域会消失导致漏检收缩太少相邻文本行容易粘连。我的经验值是字号大于 20 像素的字段用 0.4小于 20 像素的用 0.3。thresh_map这里简化处理了实际训练时 DBNet 会用距离变换生成更精细的阈值图但发票场景下文本行间距相对固定简化版也能收敛。第三步配置训练参数。DBNet 的损失由三部分组成概率图损失、阈值图损失、二值化近似损失。发票场景下概率图损失的权重建议调到 1.0阈值图损失 0.5二值化损失 0.1。学习率用 0.007 起步配合余弦退火。batch size 根据显存来8GB 显存用 4 张 640x640 的图。# 训练命令示例以 MMOCR 为例 python tools/train.py configs/dbnet/dbnet_r18_invoice.py \ --work-dir work_dirs/dbnet_invoice \ --cfg-options optimizer.lr0.007 \ data.samples_per_gpu4 \ total_epochs120第四步推理和后处理。DBNet 的输出是概率图需要经过二值化和轮廓提取才能得到文本框。import pyclipper def db_postprocess(prob_map, thresh0.3, min_area10): DBNet 后处理概率图 - 文本框 thresh: 二值化阈值发票场景建议 0.3 min_area: 最小文本框面积过滤噪点 binary (prob_map thresh).astype(np.uint8) * 255 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for cnt in contours: if cv2.contourArea(cnt) min_area: continue # 用 pyclipper 做多边形偏移恢复收缩前的框 rect cv2.minAreaRect(cnt) box cv2.boxPoints(rect) boxes.append(box) return boxes后处理里thresh这个参数很关键。设高了小字区域概率值不够直接漏掉设低了印章和表格线会被误检成文本。发票场景下 0.3 是个比较稳的起点如果印章干扰严重可以提到 0.35同时把min_area从 10 调到 20过滤掉印章边缘的碎框。3. 文字识别网络CRNNCTC 还是 Seq2SeqAttention检测框出来之后下一步是把每个框里的图像转成文字。发票识别的难点不在通用字符而在数字和符号的混淆0 和 O、1 和 I、5 和 S、8 和 B以及金额里的小数点和千分位逗号。另外发票上有大量固定字段「发票代码」「开票日期」「价税合计」这些字段的上下文信息可以用来纠错。3.1 CRNN 的 CTC 解码为什么在发票数字上容易出错CRNN 是识别侧最经典的方案CNN 提特征RNN 做序列建模CTC 做解码。它的优势是训练简单、不需要字符级标注、推理速度快。但在发票场景下CTC 有两个硬伤。第一个硬伤是重复字符的合并问题。CTC 的规则是「合并连续相同字符去掉 blank」这在英文和中文上没问题但发票金额里会出现「100.00」这种连续两个 0 的情况。如果两个 0 之间的特征不够清晰CTC 可能把它们合并成一个 0金额直接少一个数量级。我遇到过最离谱的一次价税合计「1130.00」被识别成「1130.0」财务对账时差了整整一分钱查了一下午。第二个硬伤是 CTC 对字符间距敏感。发票密码区的密文是等宽排列的字符间距极小CNN 下采样后相邻字符的特征容易混在一起CTC 解码时会出现字符插入或丢失。解决这两个问题的常见做法是在 CRNN 的 CNN 部分用更小的下采样倍数比如高度下采样 16 倍改成 8 倍保留更多水平方向的细节同时在训练数据里加入大量金额字段的合成样本让模型见过足够多的连续相同字符。3.2 Seq2SeqAttention 的落地配置与训练技巧Seq2SeqAttention 走的是另一条路编码器把图像特征编码成序列解码器用注意力机制逐字符生成不需要 CTC 的合并规则。它的优势是能利用语言模型信息对「0/O」「1/I」这类混淆有天然的纠错能力因为解码器在生成时会参考前文。但 Seq2Seq 的训练比 CRNN 麻烦。第一个坑是收敛慢没有 CTC 的强制对齐注意力对齐需要更长时间才能稳定。我的经验是先用 CRNN 预训练一个特征提取器再把 CNN 部分的权重迁移到 Seq2Seq 的编码器里这样能省掉至少三分之一的训练时间。第二个坑是注意力漂移。发票上的文本行长短差异极大短的两个字比如「备注」长的几十个字比如货物名称。注意力在长序列上容易跑偏解码到后半段时注意力权重散掉生成重复字符或者漏字。解决办法是在注意力机制里加位置编码或者用 coverage 机制惩罚重复关注同一位置。import torch import torch.nn as nn class InvoiceSeq2Seq(nn.Module): def __init__(self, num_chars, hidden_dim256, max_len50): super().__init__() # 编码器CNN BiLSTM self.cnn nn.Sequential( nn.Conv2d(1, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding1), nn.ReLU(), nn.MaxPool2d((2, 1)), # 高度下采样宽度保留 ) self.encoder_lstm nn.LSTM(256, hidden_dim, bidirectionalTrue, batch_firstTrue) # 解码器LSTM Attention self.decoder_lstm nn.LSTM(hidden_dim * 2 num_chars, hidden_dim, batch_firstTrue) self.attention nn.Linear(hidden_dim * 3, 1) self.fc nn.Linear(hidden_dim * 2, num_chars) self.max_len max_len def forward(self, x, targetsNone): # x: (B, 1, H, W) conv self.cnn(x) # (B, C, H, W) B, C, H, W conv.shape conv conv.permute(0, 3, 1, 2).reshape(B, W, C * H) enc_out, _ self.encoder_lstm(conv) # (B, W, 2*hidden) # 解码循环省略核心是 attention 计算 return enc_out这段代码里MaxPool2d((2, 1))是关键高度方向下采样宽度方向保留这样水平方向的字符细节不会丢失。发票识别里这个改动比换更大的 backbone 更有效。hidden_dim设 256 在发票场景下够用再大容易过拟合因为发票的字符集比通用 OCR 小得多。训练时还有一个技巧把发票字段按类型分桶。金额类字段数字、小数点、逗号单独一个 bucket日期类字段数字、横杠单独一个 bucket中文类字段货物名称、备注单独一个 bucket。每个 bucket 的字符集不同解码时的搜索空间小很多准确率能提升 2 到 3 个百分点。4. 检测与识别的串联从图像到结构化字段的完整链路检测和识别单独跑通只是第一步真正落地要把它们串成一条流水线并且加上后处理逻辑把识别结果映射到发票的各个字段上。4.1 文本框排序与字段映射规则检测出来的文本框是无序的需要先排序再映射。发票的版面有固定的阅读顺序先左后右、先上后下。但发票有表格线简单的按 y 坐标排序会把同一行的左右两栏拆开。我的做法是先用 x 坐标做一次聚类把水平方向重叠度大于 50% 的框归为同一行再在行内按 x 排序。def sort_boxes(boxes, overlap_thresh0.5): 发票文本框排序先按行分组再行内按 x 排序 boxes: list of (x_min, y_min, x_max, y_max) # 按 y_min 排序 boxes sorted(boxes, keylambda b: b[1]) rows [] for box in boxes: placed False for row in rows: # 计算与行内已有框的垂直重叠 row_y_min min(b[1] for b in row) row_y_max max(b[3] for b in row) overlap max(0, min(box[3], row_y_max) - max(box[1], row_y_min)) union max(box[3], row_y_max) - min(box[1], row_y_min) if union 0 and overlap / union overlap_thresh: row.append(box) placed True break if not placed: rows.append([box]) # 行内按 x 排序 result [] for row in rows: row_sorted sorted(row, keylambda b: b[0]) result.extend(row_sorted) return result排序之后是字段映射。发票的关键字段有固定的关键词比如「发票代码」「发票号码」「开票日期」「价税合计」。映射逻辑是找到关键词所在的文本框然后取它右侧或下方的文本框作为值。这里有个坑关键词和值可能不在同一个检测框里比如「价税合计」和金额之间隔了一条表格线检测时被分成两个框。解决办法是在关键词框的右侧搜索距离最近的框距离阈值设为关键词框宽度的 1.5 倍。4.2 金额和日期字段的正则校验与纠错识别结果不能直接信必须过一遍校验。金额字段的校验规则只允许数字、小数点、逗号小数点后最多两位逗号只能出现在整数部分的千分位。日期字段的校验规则格式为 YYYY年MM月DD日 或 YYYY-MM-DD年份在 2000 到 2099 之间月份 1 到 12日期 1 到 31。import re def validate_amount(text): 金额字段校验与纠错 # 去掉空格和常见误识别字符 text text.replace( , ).replace(O, 0).replace(I, 1) # 匹配金额模式 pattern r^[0-9]{1,3}(,[0-9]{3})*(\.[0-9]{1,2})?$ if re.match(pattern, text): return text # 纠错去掉多余的逗号或小数点 text re.sub(r[^0-9.], , text) if text.count(.) 1: # 多个小数点保留最后一个 parts text.split(.) text .join(parts[:-1]) . parts[-1] return text def validate_date(text): 日期字段校验 text text.replace(O, 0).replace(I, 1).replace( , ) pattern r(\d{4})[年\-/](\d{1,2})[月\-/](\d{1,2}) match re.search(pattern, text) if match: year, month, day match.groups() if 2000 int(year) 2099 and 1 int(month) 12 and 1 int(day) 31: return f{year}-{month.zfill(2)}-{day.zfill(2)} return None校验不通过的字段不能直接丢弃要标记出来交给人工复核。实际系统里我会把置信度低于 0.9 的识别结果和校验失败的字段一起推到人工审核队列这样既保证准确率又不至于让整个流程卡死。5. 避坑与排查发票 OCR 落地时最容易翻车的五个地方5.1 印章遮挡导致检测框断裂现象红色发票专用章压在金额或代码上时DBNet 的概率图在印章区域出现空洞检测框被切成两段识别结果只拿到一半字符。原因训练数据里印章样本太少模型没学过「印章下面还有字」这种情况。另外印章的红色通道在灰度化后和黑色文字对比度接近CNN 特征区分不开。解决训练时做印章增强随机在发票图像上叠加不同透明度、不同角度的印章让模型学会穿透印章找文字。推理时如果检测框在印章区域断裂可以用形态学闭运算把相邻的框合并再重新识别。5.2 表格线被误检为文本行现象发票的表格线在概率图上产生高响应后处理时被当成文本框识别出一串无意义的横线或空白。原因DBNet 的二值化阈值设得太低表格线的概率值刚好超过阈值。另外表格线的长宽比和文本行差异大但后处理没有做形状过滤。解决在后处理里加长宽比过滤文本行的长宽比一般在 3:1 到 20:1 之间超过 30:1 的框直接丢弃。同时把二值化阈值从 0.3 提到 0.35表格线的概率值通常比真实文本低 0.05 到 0.1。5.3 小字号字段漏检现象发票代码、密码区这些字号小于 12 像素的字段检测阶段直接漏掉识别阶段连机会都没有。原因CNN 的下采样倍数太大小字在特征图上只剩几个像素信息丢失严重。另外训练数据里小字样本的占比太低。解决把检测网络的输入尺寸从 640 提到 960 或 1280让小字在特征图上有更多像素。同时在训练数据里过采样小字字段把密码区、发票代码的样本复制多份。如果显存不够可以用滑动窗口推理把大图切成小块分别检测再合并。5.4 金额小数点丢失现象识别结果里「1130.00」变成「113000」小数点消失金额差了两个数量级。原因CTC 解码时小数点的特征被相邻数字覆盖或者注意力机制在生成小数点时权重不够。另外训练数据里小数点的样本太少模型对小数点的敏感度低。解决在 CTC 的字符集里给小数点更高的权重或者在损失函数里对小数点字符加权。Seq2Seq 方案里可以在解码器的输出层对小数点做 bias 调整。更直接的办法是识别完成后用正则校验金额格式如果发现没有小数点但金额大于 1000强制在最后两位前插入小数点。5.5 多页发票的字段串位现象一张发票有多个页面比如清单页检测和识别是按页独立跑的但字段映射时把第一页的「价税合计」映射到了第二页的金额上。原因字段映射逻辑没有区分页面全局搜索关键词导致跨页匹配。解决在流水线里加页面分隔符每页独立做字段映射映射完成后再按发票号码合并。如果发票号码本身识别错了可以用开票日期和金额做辅助匹配。6. 进阶技巧用合成数据把发票识别准确率再拉高三个点真实发票数据难拿、标注成本高这是所有做票据 OCR 的人都会遇到的瓶颈。我的做法是用合成数据补足长尾场景。合成数据的核心不是随便生成一堆文字图片而是针对真实场景里容易翻车的 case 做定向增强。第一个技巧是版面对齐合成。拿一张干净的发票模板把字段值随机替换成从真实数据里统计出来的分布金额符合正态分布日期符合均匀分布货物名称从常见商品库里采样然后叠加真实的干扰印章、折痕、扫描噪声、JPEG 压缩伪影。这样生成的样本在版面上和真实发票一致模型学到的特征不会跑偏。第二个技巧是字体和字号扰动。发票上的字体通常是宋体或黑体但不同开票软件渲染出来的字形有细微差异。合成时准备 5 到 8 种常见票据字体字号在 10 到 24 像素之间随机字符间距做 ±10% 的扰动。这一步能显著提升模型对不同开票软件的泛化能力。第三个技巧是困难样本挖掘。先用合成数据训练一个基础模型拿它在真实数据上跑推理把置信度低于 0.7 的样本挑出来人工修正后加入训练集。这个过程迭代两到三轮准确率通常能从 85% 提到 92% 以上。我自己的记录是纯合成数据训练金额字段准确率 88.3%加入 200 张真实困难样本后跳到 93.7%。验证方法上不要只看整体的字符准确率要按字段类型分开统计。金额字段看完全匹配率整个金额字符串一字不差日期字段看格式正确率中文名称字段看编辑距离。发票场景下金额字段的完全匹配率低于 95% 就不能上线因为一分钱的误差在财务系统里就是事故。最后说一个我踩过的坑合成数据不要一次性生成太多。我曾经生成了 50 万张合成发票训练时 loss 降得很漂亮但验证集准确率卡在 89% 上不去。后来发现是合成数据的分布太单一模型过拟合到了合成模板上。改成每轮训练动态生成 5 万张每轮的干扰参数随机变化验证集准确率才突破 93%。合成数据的多样性比数量重要得多这个教训希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑