资讯动态

PaddleOCR试卷手写体识别微调实战:从数据标注到批改部署

发布时间:2026/10/19 8:00:12 来源:尧图企业网站定制
每年一到考试季就有做教育信息化、做题软件、自动批改系统的朋友来问我一套试卷上的手写答案用现成的OCR模型能不能直接识别我一般不会直接给结论而是先丢给他们一张小学低年级的数学卷子跑一遍 PaddleOCR 看看效果。结果往往很惨印刷题干几乎全对手写答案错一半以上。这种情况太典型了——不是 PaddleOCR 不够强而是预训练模型压根没怎么见过手写的、歪歪扭扭的中文和算式。这篇文章就把我最近在 PaddleOCR 上做中小学试卷手写体识别微调的全过程摊开讲。内容包括试卷数据怎么采集和标注、PP-OCRv3 识别模型怎么微调、训练完之后怎么评估和排错、最后怎么导出部署到批改流程里。每个环节都有具体命令和踩坑记录想动手做试卷识别、作业批改自动化的朋友可以直接照着走一遍。1. 试卷手写体识别的难点拆解为什么直接拿预训练模型会翻车1.1 印刷体与手写体的底层差异PaddleOCR 的预训练识别模型训练语料大部分来自印刷文档、网络图片、截图和部分拍摄文档汉字形态稳定、笔画清晰、字体基本规整。印刷体识别本质上是一个有限字形集合的分类问题同一个字在不同字体下的差异是可控的。但手写体完全是另一回事。同一个的字有人一笔带过有人分成四笔同一个学生的同一份答卷写题字的角度可能从 -30 度到 30 度之间摇摆连笔带来的笔画断点、墨迹粘黏、笔画重叠也让字符切分变得不确定。更麻烦的是OCR 的识别模型通常基于字符的序列特征做解码手写体的字符边界不清某些字写成一个小墨团整个序列都会被带偏。这些差异不是靠简单的后处理、词典纠错或者阈值调整就能硬掰回来的。想让模型真正认识某一类手写风格必须让它在训练阶段就见过足够多的同类样本——这就是微调的核心价值。1.2 中小学试卷场景的三个特殊变量第一书写者年龄跨度大。小学低年级学生的手写体和初中生写出来的完全是两种东西。低年级常见的问题是比例失衡、间距忽大忽小、笔画位置漂移初高中生追求书写速度连笔多、简省笔画多而且不同学校、不同地区的学生书写习惯差异明显。第二试卷版面是印刷题干和手写答案混排的。扫描后的图像里印刷体文本区域和手写体文本区域彼此交错检测模型经常把答:和后面的算式拆成两个独立区域或者连成一整个不规则区域。如果你只微调识别模型、不处理检测阶段训练数据里就会混入大量印刷体区域等于让模型花大量容量去学它本来就会的东西。第三数字、符号、中文混排的情况特别多。数学试卷里2.5和2、5长得非常像化学方程式里0偶尔被写成6语文卷子里已和己、人和入在潦草书写下几乎无法区分。这些系统性混淆直接考验的是你的训练数据里有几个代表性的正例以及字典文件里是否包含了试卷常用符号。1.3 PaddleOCR微调方案的可行性判断为什么选 PaddleOCR而不是从零训练一个手写识别模型两个理由。一是工程成本低。PaddleOCR 把文本检测、方向分类、文本识别三件套都打包好了识别模型可以直接加载在公开数据集上预训练好的权重做 fine-tune不需要自己设计网络结构也不需要从零拟合一套特征。对于试卷识别这种明确的任务场景增量学习是最划算的路线。二是工具链闭环。从 PPOCRLabel 标注工具到训练脚本、评估脚本、模型导出、推理部署都是配套的你不用在标注格式转换和模型推理接口这种琐碎环节上反复造轮子。我在实际项目中体会到OCR 微调的大部分时间其实不在模型训练而在数据准备和错误分析工具链是否顺手直接决定了项目能不能在预期时间内跑完。2. 数据标注的流水线落法把试卷扫描件变成可训练样本2.1 取样策略试卷图片怎么选集开始标注之前先想清楚一个问题你手里的试卷图片是否覆盖了目标场景的真实分布。我自己吃过亏一开始只找了一批同一所学校、同一台扫描仪、同一年级的卷子训练时准确率漂亮一换成其他学校的试卷立刻崩。后来总结出的取样原则有三条。第一条覆盖年级梯度。如果目标是小学到初中全学段那每个年级都要有样本不能只取高年级。低年级和高年级的书写特征差异比不同学校之间的差异还要大。第二条覆盖科目类型。语文卷子主要考验汉字数学卷子考验数字和算式英语卷子考验字母和单词。至少要把目标科目都纳入。第三条覆盖采集方式。扫描仪和手机拍照得到的图像特征差异很大拍照还涉及透视变形、反光、阴影。如果实际使用场景包含手机拍照训练数据里就必须有相当比例的拍照样本。取样数量上我的建议是最少准备 800 到 1200 张完整试卷图片作为标注底稿。单张试卷能标注出的文本行一般在 10 到 30 行之间刨除印刷题干和无效区域实际可用的手写样本行能到几千行这个量级对微调来说已经够用了。2.2 用PPOCRLabel做半自动标注PaddleOCR 官方配套的标注工具是 PPOCRLabel可以直接安装启动。pip install PPOCRLabel PPOCRLabel --lang ch --class_name det启动之后导入试卷图片目录先用预训练检测模型做一轮自动标注。这一步能把图像里的印刷体区域和大部分字迹可辨的手写区域先框出来人工要做的是调整边界、删除误检框、补充漏检框。这里有一个很多人忽略的点试卷里同时有印刷体和手写体PPOCRLabel 的自动检测模型会把印刷题干也框出来如果你不删掉这些印刷体文本行就会进入识别模型的训练集。虽然训练集里有印刷体不一定会坏事但会让模型把宝贵的学习容量浪费在已经掌握的字形上。我的做法是印刷题干区域直接删框只保留手写文本行如果某一行是印刷题干 手写答案紧贴在一起宁可把框画小一点只包住手写部分。标注颗粒度方面对于手写体建议按语义短语或短词组分框而不是严格按照单个字框。手写体字符之间的间距不稳定按字框容易把连笔的一撇一捺切到两个框里去导致标签和图像内容对应不上。按短语框可以保留上下文也给识别模型更多结构线索。2.3 标注规范与脏数据清理标注规范如果不提前定后面返工的成本会很高。我在项目里定的是这几条。无法辨认的字不标。有些字学生自己回头都认不出来强行标注只会给模型注入噪音。PPOCRLabel 里有个忽略功能专门用来把这类区域排除在导出之外。批改笔记不混入。红笔打勾、划线、圈出来的错号这些都不是要识别的内容标注时看到就删掉框。如果卷子上有老师的批注也不要标除非你的目标就是识别批注。标签文本严格遵守所见即所得。学生写2.5标签里就不要写成2﹒5或者2.5分标点符号、小数点、单位都要按图像实际内容记录。尤其是数学卷子里的÷和×很多标注人员会下意识改成/或x这个习惯要改。方向异常的区域不做转正。PPOCRLabel 支持画四点框如果某一行文字本身是倾斜的框跟着文字倾斜走不要为了好看把框改成水平矩形否则模型学到的是被拉伸变形的样本。标注完成之后点导出得到标注结果和裁剪后的文本行图片。下一步是把这些记录整理成 PaddleOCR 训练要用的格式每行是一个图片路径\t标签文本的 TSV 文件图片路径指向已经裁剪好的区域图。2.4 训练集/验证集划分与样本平衡训练集和验证集的划分是按试卷文件来切而不是按文本行随机切。这是我踩过坑之后才养成的习惯。按文本行随机切很容易让同一张试卷上的相似字迹同时出现在训练集和验证集里评估出来的准确率虚高一换新试卷就现原形。按试卷切训练集和验证集分别来自不同的卷子更能反映真实泛化能力。样本平衡方面重点看两类分布一是年级分布二是字符覆盖。如果数据集中小学一年级的样本占 80%模型会对这个年级的字形过拟合其他年级表现差。最好把每个年级的样本量控制在相对接近的水平。字符覆盖则可以通过统计工具检查标签里的字符表看看数学符号、常用汉字、大写数字是否都出现了缺什么就专项补找样本。3. PP-OCRv3识别模型微调的配置、训练与监视3.1 环境准备与预训练权重环境部分我不展开讲太多只给一套我验证过的流程。PaddleOCR 的版本迭代比较快不同大版本的命令细节会有差异这里以常见的 2.6/2.7 版本为主线新版 3.x 的目录结构略有调整但思路一致。# 安装 PaddlePaddleCPU 版和 GPU 版选一个 pip install paddlepaddle-gpu # 安装 PaddleOCR 及其依赖 pip install paddleocr git clone https://github.com/PaddlePaddle/PaddleOCR.git cd PaddleOCR pip install -r requirements.txt预训练权重可以从官方文档指定的链接下载识别模型一般叫ch_PP-OCRv3_rec_train得到一个目录里面有 best_accuracy 之类的模型文件。下载后解压到pretrain_models/目录下。需要提醒的是如果你手里的 GPU 显存比较小比如只有 4GB训练时 batch_size 和图像尺寸都要相应调小。PaddleOCR 默认的配置是按大显存机器设计的直接照搬很可能会 OOM。3.2 识别模型配置文件逐项解读微调的关键是改配置文件。以下面的configs/rec/PPOCRv3/ch_PP-OCRv3_rec.yml为例我逐个说清楚哪些字段必须改哪些字段建议别动。Global: character_dict_path: ./ppocr/utils/ppocr_keys_v1.txt pretrained_model: ./pretrain_models/ch_PP-OCRv3_rec_train/best_accuracy save_model_dir: ./output/rec_ppocr_v3_ch use_amp: False epoch_num: 60 Optimizer: name: Adam lr: name: Cosine learning_rate: 0.0005 Train: dataset: name: SimpleDataSet data_dir: ./train_data/ label_file_list: - ./train_data/rec_handwritten_train.tsv ratio_list: [1.0] transforms: - DecodeImage: img_mode: BGR channel_order: BGR - RecAug: {} - CTCLabelEncode: {} loader: shuffle: True batch_size: 64 reader_yml: null Eval: dataset: name: SimpleDataSet data_dir: ./train_data/ label_file_list: - ./train_data/rec_handwritten_eval.tsv transforms: - DecodeImage: img_mode: BGR channel_order: BGR - CTCLabelEncode: {} loader: shuffle: False batch_size: 64character_dict_path默认指向 PaddleOCR 自带的通用中文字典。这个字典覆盖了常用汉字和符号但学科类符号不一定会覆盖全比如数学公式里可能出现√π∞。训练前检查一下字典文件里有没有这些字符没有就手动追加追加完保存即可。这里有个容易犯的错改字典文件之后必须保证标签中的所有字符都能在字典里找到否则训练会报字符越界错误。pretrained_model指向刚才下载的预训练权重不必带 .pdmodel 后缀脚本会自动拼接。learning_rate这里我习惯设 0.0005这个值对数据量在几千行的微调任务比较安全。别用默认从头训练的学习率通常是 0.0005 以上的 Cosine 周期配大 epoch微调时模型已经预训练过学习率太大容易把已有知识冲掉。RecAug是随机增广模块包括旋转、模糊、拉伸、色彩扰动等。手写体识别场景推荐开启。但注意如果试卷图像本身很相似统一扫描仪增广太强反而会造成分布偏移可以先开、看过拟合情况再决定是否减弱。3.3 训练命令与参数选择配置改好后用下面这条命令启动训练。python tools/train.py -c configs/rec/PPOCRv3/ch_PP-OCRv3_rec.yml如果你不想改配置文件也可以通过-o参数覆盖python tools/train.py -c configs/rec/PPOCRv3/ch_PP-OCRv3_rec.yml \ -o Global.epoch_num80 \ Global.pretrained_model./pretrain_models/ch_PP-OCRv3_rec_train/best_accuracy \ Train.dataset.label_file_list[./train_data/rec_handwritten_train.tsv] \ Train.dataset.loader.batch_size48训练中一些典型参数的参考关系我整理成了下面的表格数据规模建议 epoch建议学习率建议 batch_size备注训练行数 200040~800.000332~48小样本严格监控验证集过拟合训练行数 2000~800040~600.000548~64最常见的区间效果比较稳训练行数 800020~400.0005~0.00164~128数据量大适当减小 epoch 防止过度拟合batch_size 的选择主要受显存限制4GB 显存建议 32 以内8GB 以上可以走 64。如果显存不够优先保证 batch_size 不低于 32太小的话 BatchNorm 层的统计量会不稳定训练损失波动很大。3.4 用日志和可视化跟踪训练过程训练启动后日志文件保存在./output/rec_ppocr_v3_ch/train.log。正常情况下loss 在前几百个迭代里快速下降然后进入缓慢下降阶段。每隔一定步数脚本会在评估集上打印acc和norm_edit_dis这两个指标是判断模型是否学好的核心依据。我习惯在训练时同时开着两个窗口一个看 loss 曲线一个看验证集准确率。一旦发现验证集准确率先升后降而训练集 loss 还在继续下降说明过拟合了此时应该提前停止训练、回滚到验证集准确率最高点的模型。PaddleOCR 默认会保存最新的模型但验证集最优的模型一般保存在best_accuracy前缀下。训练结束之后别随手拿最后一个 epoch 的模型去评估优先用best_accuracy。还有一个细节训练过程中global_step和epoch的关系容易绕晕。配置里的epoch_num指的是完整跑几遍训练集但日志里的global_step是迭代次数。如果数据集有 5000 行batch_size 是 64那么一个 epoch 大约 78 个迭代60 个 epoch 就是约 4680 个迭代。别一看 4680 这个数字以为要跑很久就慌其实比想象中快得多单卡 V100 上通常半小时到一小时就能跑完一轮微调任务。4. 评估指标与错误样本分析定位模型到底错在哪4.1 准确率之外还要看编辑距离PaddleOCR 的评估脚本会自动输出两个指标acc和norm_edit_dis。acc是整行逐字精确匹配的准确率一行里错一个字就算这行全错norm_edit_dis是归一化编辑距离它会把识别的文本和真值文本的相似程度计算进去。对试卷手写体识别来说这两个指标要一起看不能只盯 acc。举个例子标签是3.14模型输出3.14时 acc 计 1输出3.13时 acc 是 0但编辑距离其实只差一个字符业务上可能还能容忍。如果只看 acc你可能觉得模型烂透了但看编辑距离会发现误差异常集中只需要针对那几个字符补充样本就能显著改善。反过来如果 acc 高但编辑距离低就要警惕是不是测试集太简单或者测试集和训练集来自同一批试卷。评估命令python tools/eval.py -c configs/rec/PPOCRv3/ch_PP-OCRv3_rec.yml \ -o Global.checkpoints./output/rec_ppocr_v3_ch/best_accuracy4.2 典型错误类型与原因我做试卷手写体识别时反复踩到下面的几类错误每类都有比较明确的原因。错误类型表现常见原因数字黏连/混淆0识别为61识别为7手写数字连笔、起笔和收笔的墨迹方向特殊汉字左右结构拆分作识别成个乍或乱码检测框把左右结构切成两个区域或者识别序列特征没有覆盖整个字连笔漏字的字写得太快变成小点模型直接丢字训练集里缺少这种高潦草度的样本算式符号丢失2x1识别成2x1符号笔画太浅检测或识别阶段被当成噪声行间拼接错序两行紧密的手写区域被识别成一行检测框没有完全包住文本行行高相近、行距太小这里面有一类错误不是识别模型本身能解决的如果检测框就把文字切碎了识别模型再准也白搭。所以遇到识别结果莫名其妙少字、乱序先回来看检测阶段输出的框到底包住了什么内容。4.3 针对性数据和参数调整错误分析之后调优手段分为数据和参数两条线。数据线最简单粗暴把识别错的样本收集起来重新标注、纠正加入训练集再训一轮。这个错误回灌的方法非常有效尤其是针对个别高频混淆字符。比如我发现某份练习卷里0被识别成6的比例特别高就把所有写0的样本单独筛出来确认标注无误后扩充了一批同类样本再训练时这个错误率明显下降。参数线有几个方向可以试。一是降低学习率再训如果当前模型已经接近收敛可以在预训练模型的基础上、或者在当前 best 模型的基础上把学习率降到 0.0001 再看几个 epoch。二是调整输入图像预处理早期把图像转灰度、对比度增强的步骤可以在配置里通过Global.image_shape和归一化参数影响但更直接的是在数据准备阶段统一做一次图像增强处理保证送入模型的图不是灰蒙蒙的。三是关闭或加强RecAug过拟合时加强欠拟合时关闭。5. 模型导出、部署与批改流程的集成细节5.1 导出推理模型的基本步骤训练完的模型不能直接在生产环境调用需要导出成推理格式。导出命令python tools/export_model.py -c configs/rec/PPOCRv3/ch_PP-OCRv3_rec.yml \ -o Global.pretrained_model./output/rec_ppocr_v3_ch/best_accuracy执行完会在output/rec_ppocr_v3_ch/下生成inference.pdmodel和inference.pdiparams两个文件。导出成功后可以用 PaddleOCR 的推理脚本做一次快速验证from paddleocr import PaddleOCR ocr PaddleOCR( ocr_versionPP-OCRv3, rec_model_dir./output/rec_ppocr_v3_ch/inference, det_model_dir./pretrain_models/ch_PP-OCRv3_det_infer, ) result ocr.ocr(test_page.jpg, clsFalse)这里只替换了识别模型检测模型还是用预训练的检测模型因为试卷检测场景相对通用手写体和印刷体的文本检测在大多数数据集上预训练检测模型已经够用。如果你的试卷版面特殊比如左侧题目、右侧答题分区再考虑单独微调检测模型。5.2 识别调用与结果排序推理返回的结果是一个列表每项包含一个四边形的四角坐标、识别文本和置信度。试卷识别场景里文本行的顺序非常重要但 OCR 返回的顺序往往不做版面重排。我通常按下面的逻辑重新排序boxes [] for line in result: boxes.append({ points: line[0], text: line[1][0], score: line[1][1], }) # 先按行中心点的 Y 坐标分桶再在每个桶内按 X 坐标排序 lines sorted(boxes, keylambda b: (round(sum(p[1] for p in b[points]) / 4 / 50), sum(p[0] for p in b[points]) / 4))按 Y 坐标每 50 像素分桶是一个简单的做法能处理大部分扫描卷子的行序问题。碰到多栏排版需要先把框按栏聚类再分别排序。这一步在工程上比模型训练更磨人但直接决定下游批改匹配的准确性。5.3 试卷场景的工程化建议部署到实际批改流程时有几个建议值得提前考虑。第一如果是手机拍照的试卷务必先做透视矫正。拍照必然带来近大远小和梯形变形不矫正的话识别准确率会显著下降。矫正可以利用卷面边框或二维码定位点来做也可以先用检测模型识别的四边形区域反推透视矩阵。第二置信度阈值要调低。手写体的置信度普遍低于印刷体很多模型给手写文本打分只有 0.6 左右如果你沿用印刷体的 0.8 阈值大量正确识别结果会被丢弃。我更倾向于把阈值设在 0.4 到 0.5让下游的规则引擎根据文本语义再做筛选。第三区分印刷题干和手写答案不能靠识别模型要靠位置和颜色。扫描试卷时印刷体区域和手写体区域可以通过空间位置关系大致分开答题区域一般在试卷的下半部分、横线或括号附近。如果卷子本身版式多变训练一个简单的区域分类器比改 OCR 模型更省力。第四多页试卷要按页关联。识别结果最好带上页码信息批改系统才能把每道题的得分和对应的题目编号对上。这些看似和 OCR 无关的工程细节往往决定了整个系统能不能真正用起来。我之前有个项目识别模型已经做到比较满意的准确率但上线以后用户抱怨批错最后发现是识别结果行顺序在双栏页面里乱了导致答案匹配错位。后来用检测框坐标重新做版面排序问题立刻消失。所以别小看工程侧的处理模型只是整条链路里的一环。最后再补一条实践经验微调不要偷懒不要想着把所有数据一次性塞进去跑个几十遍就完事。更稳妥的做法是先挑 500 行样本做一轮快速微调把标注错误、字典缺失、图像格式问题先暴露出来修完之后再用全量数据正式训练。这个过程看似多花两天实际上能帮你省下大量的排查时间。

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

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

免费获取报价 →
↑