每年一到考试季,就有做教育信息化、做题软件、自动批改系统的朋友来问我:一套试卷上的手写答案,用现成的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 的目录结构略有调整,但思路一致。
# 安装 PaddlePaddle,CPU 版和 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_num=80 \ 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_size=48训练中一些典型参数的参考关系,我整理成了下面的表格:
| 数据规模 | 建议 epoch | 建议学习率 | 建议 batch_size | 备注 |
|---|---|---|---|---|
| 训练行数 < 2000 | 40~80 | 0.0003 | 32~48 | 小样本,严格监控验证集过拟合 |
| 训练行数 2000~8000 | 40~60 | 0.0005 | 48~64 | 最常见的区间,效果比较稳 |
| 训练行数 > 8000 | 20~40 | 0.0005~0.001 | 64~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"识别为"6","1"识别为"7" | 手写数字连笔、起笔和收笔的墨迹方向特殊 |
| 汉字左右结构拆分 | "作"识别成"个+乍"或乱码 | 检测框把左右结构切成两个区域,或者识别序列特征没有覆盖整个字 |
| 连笔漏字 | "的"字写得太快变成小点,模型直接丢字 | 训练集里缺少这种高潦草度的样本 |
| 算式符号丢失 | "2x+1"识别成"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_version="PP-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", cls=False)这里只替换了识别模型,检测模型还是用预训练的检测模型,因为试卷检测场景相对通用,手写体和印刷体的文本检测在大多数数据集上预训练检测模型已经够用。如果你的试卷版面特殊(比如左侧题目、右侧答题分区),再考虑单独微调检测模型。
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, key=lambda 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 行样本做一轮快速微调,把标注错误、字典缺失、图像格式问题先暴露出来,修完之后再用全量数据正式训练。这个过程看似多花两天,实际上能帮你省下大量的排查时间。