资讯中心

基于深度学习的手写OCR识别系统:从训练到结构化落地

📅 2026/10/3 4:43:48
基于深度学习的手写OCR识别系统:从训练到结构化落地
简介本资源是一套基于深度学习自主训练开发的手写文字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 训练数据的三条来源和标注格式手写票据数据不像公开数据集那么好找我一般从三个渠道凑来源数量级特点标注方式真实票据扫描件5002000 张分布真实但涉及隐私需脱敏检测框 文本内容手写体合成可无限生成用字体渲染 背景融合自动生成标注公开手写数据集如 CASIA-HWDB单字为主需拼接转成行级标注真实票据是核心但数量往往不够。我的做法是先用 500 张真实票据训一版 baseline再用合成数据做数据增强。合成时注意两点——背景要用真实票据的底纹不是纯白手写字体要选 35 种不同风格否则模型会对某一种字体过拟合。标注格式统一成检测和识别两套// 检测标注 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.0unclip_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 用两层双向 LSTMhidden_size 设 256。CTC 的好处是不需要字符级对齐标注只要行级文本就行。import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, hidden_size256): 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_layers2, bidirectionalTrue, batch_firstTrue) 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。训练时用CTCLossblank0zero_infinityTrue防止长序列梯度爆炸。3.2 手写连笔严重时换 SVTR 识别头CRNN 的 LSTM 对长距离依赖建模能力有限手写连笔严重时比如「银行」两个字连成一笔CTC 解码容易丢字。这时候我会换成 SVTRScene 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: 5mix_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. 结构化处理把识别结果变成可入库的 JSON4.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, keylambda 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 fieldsfind_candidates的逻辑是如果directionright取同一行 y 坐标接近、x 坐标更大的文本行如果directionbelow取 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 识别错误导致锚点词不匹配或者票据版式变化导致锚点位置偏移。解决锚点匹配用模糊匹配比如「金额」允许匹配「金頟」「金 额」「金额」等变体。可以用编辑距离阈值设 12。另外模板要支持多锚点一个字段配 23 个锚点词任意一个匹配上就行。6. 进阶技巧用置信度做字段级校验和人工复核分流整套系统跑通之后最后一个要解决的问题是哪些识别结果可以直接入库哪些需要人工复核。我的做法是用识别模型的置信度做字段级校验把结果分成三档置信度区间处理策略说明 0.95直接入库金额、日期等关键字段也直接采信0.80 ~ 0.95规则校验后入库金额走normalize_amount日期走normalize_date 0.80人工复核标记出来推送到复核队列置信度从 CTC 解码的 logits 里取具体是每个字符概率的乘积再开方几何平均。代码大概长这样import numpy as np def compute_confidence(logits, blank0): 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.991 个概率 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 周花在数据标注和合成上。如果你手里已经有票据数据建议先从检测模型开始训检测框准了识别和结构化都是水到渠成的事。希望帮到你。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案