☰
基于深度学习的手写OCR识别系统:从训练到结构化落地
2026/9/29 1:15:35 网站建设 项目流程

简介:本资源是一套基于深度学习自主训练开发的手写文字OCR识别系统,面向金融票据处理、文档数字化等场景的开发者与算法学习者,重点解决通用手写文字、银行支票与进账单识别,以及机打与手写文字混合识别的难题。压缩包共46个文件,约157.13MB,包含pyd编译模块、py源码、pb模型文件、jpg测试图片、md说明文档及xlsx结构化配置等,覆盖文字检测、文字识别与结构化处理完整链路。系统按服务化思路组织,提供通用识别、支票识别、进账单识别等模块,并配有测试数据与配置字典,便于快速验证与二次开发。目前已有99人学习下载。读者可据此掌握手写OCR从模型训练到结构化输出的工程实现,理解票据场景下的字段定位与信息抽取方法,并参考其目录结构与服务划分,搭建可扩展的文字识别应用。

1. 从一张进账单说起:手写 OCR 为什么比通用识别难啃

银行进账单、支票、回单这类票据,最麻烦的地方不是印刷体,而是那些手写的金额、日期、账号和摘要。通用 OCR 模型在印刷体上准确率能到 99% 以上,但一遇到手写数字「7」和「1」连笔、「0」和「6」潦草,识别率可能直接掉到 70% 以下。更棘手的是,同一张票据上往往机打和手写混排——户名是机打的,金额是手写的,备注又是手写的,模型必须同时处理两种分布完全不同的文字。

这套「基于深度学习自主训练开发的手写文字OCR识别系统」要解决的就是这个场景:通用场景手写文字识别、银行支票 OCR、银行进账单 OCR,并且支持机打和手写文字混合识别,整条链路覆盖文字检测、文字识别、结构化处理三个阶段。它适合两类人:一是手里有票据数据、想自己训练模型替代第三方 API 的工程师;二是做金融、财务、政务票据自动化的开发者,需要把非结构化的票据图片变成可入库的 JSON 字段。

我做过几个类似项目,最大的体会是:检测和识别分开做,比端到端硬训一个模型靠谱得多。下面按「数据怎么造 → 检测怎么训 → 识别怎么调 → 结构化怎么落 → 坑在哪」的顺序拆开讲。

2. 数据与模型选型:手写 OCR 的训练集怎么造才不翻车

2.1 为什么不能直接拿通用 OCR 模型微调

通用 OCR 模型(比如基于印刷体语料训练的 CRNN 或 PP-OCR 系列)的特征空间已经被印刷体的规整笔画「锁死」了。你拿几百张手写票据去微调,模型会灾难性遗忘印刷体能力,结果就是机打字段反而识别不准。常见做法是检测模型和识别模型分开处理:检测用通用模型(印刷体和手写的文字框检测逻辑一致),识别模型单独用手写数据训练,最后在结构化层做字段级融合。

另一个选型理由是手写文字的字符间距和倾斜角度变化极大。印刷体识别常用的 CTC 解码对手写连笔的切分很敏感,所以识别模型我一般会选 CRNN + CTC 作为 baseline,如果手写连笔严重,再上 Transformer-based 的识别头(比如 SVTR 结构)。检测侧用 DBNet 就够了,它对文字框的回归比较稳,票据上的表格线干扰也能扛住。

2.2 训练数据的三条来源和标注格式

手写票据数据不像公开数据集那么好找,我一般从三个渠道凑:

来源数量级特点标注方式
真实票据扫描件500~2000 张分布真实,但涉及隐私需脱敏检测框 + 文本内容
手写体合成可无限生成用字体渲染 + 背景融合自动生成标注
公开手写数据集如 CASIA-HWDB单字为主,需拼接转成行级标注

真实票据是核心,但数量往往不够。我的做法是:先用 500 张真实票据训一版 baseline,再用合成数据做数据增强。合成时注意两点——背景要用真实票据的底纹(不是纯白),手写字体要选 3~5 种不同风格,否则模型会对某一种字体过拟合。

标注格式统一成检测和识别两套:

// 检测标注 det_train.json { "image_path": "check_001.jpg", "annotations": [ {"bbox": [120, 340, 480, 390], "text": "2024年3月15日", "type": "handwritten"}, {"bbox": [500, 340, 800, 390], "text": "壹拾万元整", "type": "handwritten"}, {"bbox": [120, 200, 600, 240], "text": "中国工商银行进账单", "type": "printed"} ] }

bbox用[x1, y1, x2, y2]左上右下坐标,type字段区分机打和手写——这个字段在结构化阶段会用来决定后处理策略。识别模型的标注则是把每个 bbox 裁出来存成单行图片,配一个label.txt,每行格式图片名\t文本内容。

2.3 检测模型 DBNet 的训练配置

DBNet 的训练核心是可微分二值化,它把分割阈值也变成可学习的参数,对票据上深浅不一的文字特别友好。配置文件里几个关键参数:

# dbnet_config.yaml train: dataset: name: DetDataset data_dir: ./data/det_train transforms: - RandomCrop: {size: [640, 640]} - RandomRotate: {max_angle: 5} # 票据倾斜一般不超过5度 - ColorJitter: {brightness: 0.3, contrast: 0.3} loader: batch_size: 8 num_workers: 4 model: backbone: ResNet50 neck: FPN head: DBHead k: 50 # 二值化多项式系数,票据场景50够用 optimizer: name: Adam lr: 0.001 weight_decay: 0.0001 postprocess: thresh: 0.3 # 二值化阈值 box_thresh: 0.6 # 框置信度阈值 unclip_ratio: 1.5 # 框扩张比例,手写连笔建议1.5~2.0

unclip_ratio是手写场景最容易调错的参数。印刷体一般用 1.5 就行,但手写文字笔画外扩多,设小了会把笔画切掉,设大了相邻文字框会粘连。我的经验是:先用 1.5 跑一版,看验证集里有没有「文字被截断」的 case,有就往上加到 1.8。

训练命令:

python tools/train.py -c configs/det/dbnet_config.yaml \ --pretrained ./pretrain/dbnet_r50.pth \ --output ./output/det_handwritten

--pretrained加载通用检测预训练权重,只微调 head 和 neck 的前两层,backbone 冻结。这样 500 张图训 50 个 epoch 就能收敛,显存 8G 足够。

3. 识别模型训练:CRNN 和 SVTR 怎么选、参数怎么设

3.1 CRNN baseline 的搭建和 CTC 解码

CRNN 的结构是 CNN 提特征 + RNN 做序列建模 + CTC 解码。手写文字识别里,CNN 部分我一般用 ResNet34 砍掉最后的全连接,RNN 用两层双向 LSTM,hidden_size 设 256。CTC 的好处是不需要字符级对齐标注,只要行级文本就行。

import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, hidden_size=256): super().__init__() # CNN: 输入 [B, 3, 32, W] -> 输出 [B, 512, 1, W/4] self.cnn = nn.Sequential( nn.Conv2d(3, 64, 3, 1, 1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, 1, 1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, 1, 1), nn.ReLU(), nn.Conv2d(256, 256, 3, 1, 1), nn.ReLU(), nn.MaxPool2d((2,1)), nn.Conv2d(256, 512, 3, 1, 1), nn.ReLU(), nn.Conv2d(512, 512, 3, 1, 1), nn.ReLU(), nn.MaxPool2d((2,1)), ) self.rnn = nn.LSTM(512, hidden_size, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: [B, 3, 32, W] conv = self.cnn(x) # [B, 512, 1, W/4] conv = conv.squeeze(2) # [B, 512, W/4] conv = conv.permute(0, 2, 1) # [B, W/4, 512] rnn_out, _ = self.rnn(conv) # [B, W/4, 512] logits = self.fc(rnn_out) # [B, W/4, num_classes] return logits

输入高度固定 32 像素,宽度按比例缩放,这是 CRNN 的标准做法。num_classes要包含 CTC 的 blank 字符,比如字符集有 5000 个汉字 + 10 个数字 + 26 个字母 + 标点,那num_classes = 5000 + 10 + 26 + 标点数 + 1。训练时用CTCLoss,blank=0,zero_infinity=True防止长序列梯度爆炸。

3.2 手写连笔严重时换 SVTR 识别头

CRNN 的 LSTM 对长距离依赖建模能力有限,手写连笔严重时(比如「银行」两个字连成一笔),CTC 解码容易丢字。这时候我会换成 SVTR(Scene Text Recognition with a Single Visual Model),它用纯 Transformer 做序列建模,对不规则排布的文字更鲁棒。

SVTR 的核心是局部和全局注意力混合:浅层用局部窗口注意力抓笔画细节,深层用全局注意力建模字符间关系。配置上主要调三个参数:

# svtr_config.yaml model: backbone: SVTRNet img_size: [32, 320] # 高度32,宽度320 patch_size: [4, 4] embed_dim: 192 depth: [3, 6, 3] # 三个stage的深度 num_heads: [6, 6, 6] mix_ratio: [0.5, 0.5, 0.5] # 局部/全局注意力比例 train: batch_size: 16 lr: 0.0005 epochs: 100 warmup_epochs: 5

mix_ratio控制每个 stage 里局部注意力和全局注意力的比例。手写票据建议前两个 stage 用 0.5(局部为主),最后一个 stage 用 0.8(全局为主),这样既能抓笔画又能处理连笔。

3.3 训练命令和验证指标怎么看

# CRNN 训练 python tools/train_rec.py --config configs/rec/crnn_handwritten.yaml \ --train_data ./data/rec_train/label.txt \ --val_data ./data/rec_val/label.txt \ --pretrained ./pretrain/crnn_pretrain.pth # SVTR 训练 python tools/train_rec.py --config configs/rec/svtr_handwritten.yaml \ --train_data ./data/rec_train/label.txt \ --val_data ./data/rec_val/label.txt

验证时看两个指标:行准确率(line accuracy)和字符准确率(char accuracy)。手写场景下行准确率能到 85% 就算不错,字符准确率要到 95% 以上。如果行准确率低但字符准确率高,说明是切分或对齐问题,不是识别能力问题——这时候回去检查检测框的unclip_ratio和识别模型的输入宽度是否匹配。

4. 结构化处理:把识别结果变成可入库的 JSON

4.1 票据字段的定位策略

识别出来的文字是一堆散乱的文本行,结构化要做的是把它们映射到「金额」「日期」「账号」「户名」这些字段上。银行进账单和支票的版式相对固定,我一般用模板匹配 + 关键词锚点的组合策略。

模板匹配负责定位大区域:比如「金额」字段永远在票据右侧中部,「日期」在右上角。关键词锚点负责精确定位:找到「金额」或「小写」这两个词,取它右边或下边的文本行。两种策略结合,比纯规则或纯模型都稳。

import re def extract_fields(ocr_results, template): """ ocr_results: [{"bbox": [x1,y1,x2,y2], "text": "...", "type": "handwritten"}, ...] template: 字段定位模板 """ fields = {} # 按 y 坐标排序,模拟阅读顺序 lines = sorted(ocr_results, key=lambda r: (r["bbox"][1], r["bbox"][0])) for field_name, rule in template.items(): anchor = rule.get("anchor") # 锚点关键词 direction = rule.get("direction") # right / below pattern = rule.get("pattern") # 正则 for i, line in enumerate(lines): if anchor and anchor in line["text"]: # 找锚点右边或下边的行 candidates = find_candidates(lines, line, direction) for cand in candidates: if pattern and re.search(pattern, cand["text"]): fields[field_name] = cand["text"] break break return fields

find_candidates的逻辑是:如果direction="right",取同一行 y 坐标接近、x 坐标更大的文本行;如果direction="below",取 y 坐标更大、x 坐标重叠的文本行。这个函数不复杂,但阈值要调——y 坐标差多少算「同一行」,x 坐标重叠多少算「同一列」,不同票据版式不一样。

4.2 金额和日期的后处理规则

手写金额识别出来经常带噪声,比如「100000」被识别成「10000O」,「2024年3月15日」被识别成「2024年3月15曰」。后处理规则要针对这些高频错误:

def normalize_amount(text): """金额后处理:修正常见 OCR 混淆字符""" # O -> 0, l -> 1, 曰 -> 日 trans = str.maketrans({"O": "0", "o": "0", "l": "1", "I": "1", "曰": "日"}) text = text.translate(trans) # 去掉千分位逗号和空格 text = re.sub(r"[,\s]", "", text) # 提取数字和小数点 match = re.search(r"\d+\.?\d*", text) return float(match.group()) if match else None def normalize_date(text): """日期后处理:统一成 YYYY-MM-DD""" text = text.translate(str.maketrans({"曰": "日", "O": "0"})) match = re.search(r"(\d{4})[年\-/](\d{1,2})[月\-/](\d{1,2})", text) if match: y, m, d = match.groups() return f"{y}-{int(m):02d}-{int(d):02d}" return None

金额后处理里,O -> 0和l -> 1是手写 OCR 最常见的混淆对,几乎每个项目都要加。日期里的「曰」和「日」也是高频错误,因为手写「日」的最后一横经常写得很短,模型容易看成「曰」。

4.3 机打和手写混合字段的融合

一张进账单上,户名可能是机打的,金额是手写的,备注又是手写的。结构化时不能一刀切,要根据type字段走不同的后处理分支:

字段常见类型后处理策略
户名机打为主直接取识别结果,不做字符替换
金额手写为主走normalize_amount,修正常见混淆
日期手写为主走normalize_date,统一格式
账号机打为主去掉空格和横线,校验位数
备注手写为主保留原文,只做去噪

如果同一个字段既有手写又有识别置信度低的机打文本,我的做法是优先信手写识别结果——因为手写模型是针对这个场景专门训的,而机打文本如果置信度低,很可能是扫描质量或字体问题。

5. 避坑与排查:手写 OCR 落地时最容易翻车的 5 个点

5.1 检测框把相邻手写字符粘成一个框

现象:识别结果里出现「100000」被识别成「10000O」或者整行文字被合并成一个长文本,字段切分全乱。

原因:DBNet 的unclip_ratio设得太大,手写文字笔画外扩后相邻字符的框粘连了。或者训练数据里手写字符间距太小,模型学到了「粘连」的特征。

解决:先把unclip_ratio从 1.5 降到 1.2 试一版,看验证集里框的数量是否增加。如果降了还是粘连,检查训练数据里有没有「字符间距过小」的样本,有的话在合成数据时加大字间距。另外可以在后处理里加一步基于投影的切分:对粘连的框做垂直投影,找波谷切分。

5.2 手写数字「7」和「1」、「0」和「6」混淆

现象:金额字段频繁出现「7」识别成「1」,「0」识别成「6」,导致金额错误。

原因:手写数字的笔画变化太大,训练数据里这两种字体的样本不够均衡。或者识别模型的输入分辨率太低,32 像素高度下「7」的横折和「1」的竖线区分度不够。

解决:把识别模型的输入高度从 32 提到 48,宽度按比例缩放。同时在训练数据里针对性补充「7/1」「0/6」的混淆样本,每种至少 200 张。如果还不行,在识别头后面加一个数字专用分类器,对金额字段的识别结果做二次校验。

5.3 机打文字被误判为手写,走了错误的后处理

现象:机打的户名被当成手写,走了normalize_amount之类的后处理,把「O」改成了「0」,户名里的字母全乱了。

原因:检测模型输出的type字段不准,或者结构化时没有按字段类型区分后处理策略。

解决:type字段不能只靠检测模型输出,要在结构化层加一层规则校验。比如户名字段如果包含大量汉字,大概率是机打;金额字段如果全是数字和少数几个符号,大概率是手写。规则校验和模型输出冲突时,以规则为准。

5.4 训练 loss 不降或震荡

现象:CRNN 训练时 CTC loss 在前 10 个 epoch 降得很慢,或者来回震荡。

原因:学习率太大,或者num_classes设错了(比如漏了 blank 字符),或者输入图片的宽度不一致导致 batch 内 padding 太多。

解决:先把学习率从 0.001 降到 0.0005,加 warmup。检查num_classes是否等于字符集大小 + 1。输入图片按 batch 内最大宽度 padding,不要全局统一宽度——手写票据的行长度差异很大,全局统一会引入大量无效 padding。

5.5 结构化字段提取时锚点找不到

现象:模板里配了「金额」作为锚点,但识别结果里「金额」被识别成「金頟」或「金 额」,锚点匹配失败,字段提取为空。

原因:OCR 识别错误导致锚点词不匹配,或者票据版式变化导致锚点位置偏移。

解决:锚点匹配用模糊匹配,比如「金额」允许匹配「金頟」「金 额」「金额:」等变体。可以用编辑距离,阈值设 1~2。另外模板要支持多锚点:一个字段配 2~3 个锚点词,任意一个匹配上就行。

6. 进阶技巧:用置信度做字段级校验和人工复核分流

整套系统跑通之后,最后一个要解决的问题是:哪些识别结果可以直接入库,哪些需要人工复核。我的做法是用识别模型的置信度做字段级校验,把结果分成三档:

置信度区间处理策略说明
> 0.95直接入库金额、日期等关键字段也直接采信
0.80 ~ 0.95规则校验后入库金额走normalize_amount,日期走normalize_date
< 0.80人工复核标记出来,推送到复核队列

置信度从 CTC 解码的 logits 里取,具体是每个字符概率的乘积再开方(几何平均)。代码大概长这样:

import numpy as np def compute_confidence(logits, blank=0): """ logits: [T, num_classes] 识别模型的输出 返回:整行置信度(几何平均) """ probs = softmax(logits, axis=-1) # [T, num_classes] preds = np.argmax(probs, axis=-1) # [T] confidences = [] for t, p in enumerate(preds): if p != blank: # 跳过 blank confidences.append(probs[t, p]) if not confidences: return 0.0 # 几何平均,对低概率字符更敏感 return float(np.exp(np.mean(np.log(confidences))))

几何平均比算术平均更适合做置信度,因为它对「某一个字符概率特别低」的情况更敏感。比如一行 10 个字符,9 个概率 0.99,1 个概率 0.3,算术平均是 0.92,几何平均只有 0.79——后者更能反映「这行有一个字可能错了」。

字段级校验的规则我一般这么配:

FIELD_RULES = { "amount": { "confidence_threshold": 0.90, "validator": lambda x: x is not None and 0 < x < 1e9, "fallback": "manual_review" }, "date": { "confidence_threshold": 0.85, "validator": lambda x: x is not None and "2020" <= x[:4] <= "2030", "fallback": "manual_review" }, "account": { "confidence_threshold": 0.95, "validator": lambda x: x is not None and len(x) >= 8, "fallback": "manual_review" } }

金额字段的置信度阈值设 0.90,因为金额错了后果最严重。日期设 0.85,因为日期格式固定,后处理能修一部分。账号设 0.95,因为账号位数多,错一位就完全对不上。

人工复核分流这块,我的血泪经验是:不要把所有低置信度字段都推给人工,那样复核量太大。按票据维度聚合——一张票据上如果有超过 2 个字段置信度低于阈值,整张票据推人工;只有 1 个字段低,只推那个字段。这样能把复核量压到 10% 以下。

最后说一个我踩过的坑:置信度阈值不要一开始就设死,先用一批标注数据跑一遍,看准确率-召回率曲线,找到「准确率 99% 时召回率是多少」的那个点,再定阈值。我一开始拍脑袋设 0.9,结果召回率只有 60%,大量正常票据被推去人工复核,运营成本反而高了。后来按曲线调到 0.85,召回率上到 92%,准确率还有 98.5%,这才合理。

这套系统从数据准备到上线,我一个人大概做了 6 周,其中 3 周花在数据标注和合成上。如果你手里已经有票据数据,建议先从检测模型开始训,检测框准了,识别和结构化都是水到渠成的事。希望帮到你。

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

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

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

立即咨询