☰
基于深度学习的发票文字检测与识别:DBNet与CRNN实战
2026/10/5 9:21:20 网站建设 项目流程

简介:这是一份面向计算机视觉与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_ratio=0.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), dtype=np.float32) thresh_map = np.zeros((h, w), dtype=np.float32) with open(label_path, 'r', encoding='utf-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, dtype=np.int32) cv2.fillPoly(prob_map, [pts], 1.0) # threshold map 用距离变换生成 cv2.fillPoly(thresh_map, [np.array(poly.exterior.coords, dtype=np.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.lr=0.007 \ data.samples_per_gpu=4 \ total_epochs=120

第四步,推理和后处理。DBNet 的输出是概率图,需要经过二值化和轮廓提取才能得到文本框。

import pyclipper def db_postprocess(prob_map, thresh=0.3, min_area=10): """ 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. 文字识别网络:CRNN+CTC 还是 Seq2Seq+Attention

检测框出来之后,下一步是把每个框里的图像转成文字。发票识别的难点不在通用字符,而在数字和符号的混淆: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 Seq2Seq+Attention 的落地配置与训练技巧

Seq2Seq+Attention 走的是另一条路:编码器把图像特征编码成序列,解码器用注意力机制逐字符生成,不需要 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_dim=256, max_len=50): super().__init__() # 编码器:CNN + BiLSTM self.cnn = nn.Sequential( nn.Conv2d(1, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), nn.MaxPool2d((2, 1)), # 高度下采样,宽度保留 ) self.encoder_lstm = nn.LSTM(256, hidden_dim, bidirectional=True, batch_first=True) # 解码器:LSTM + Attention self.decoder_lstm = nn.LSTM(hidden_dim * 2 + num_chars, hidden_dim, batch_first=True) 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, targets=None): # 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_thresh=0.5): """ 发票文本框排序:先按行分组,再行内按 x 排序 boxes: list of (x_min, y_min, x_max, y_max) """ # 按 y_min 排序 boxes = sorted(boxes, key=lambda 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, key=lambda 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%。合成数据的多样性比数量重要得多,这个教训希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询