很多老师第一次拿到整卷扫描件时,脑子里冒出来的方案是把PDF直接丢进文字提取工具里。我最初也这么干过,结果不出所料:文字倒是吐出来一大片,但题号、选项、答案混成一锅粥,图片全部丢失,排版顺序也乱了。后来我才想明白,这个需求真正要解决的并不是“识别出卷面上有什么字”,而是“让程序知道哪些字属于哪道题,哪些区域是题干、选项、填空和图片”。这也是“智能试卷解析系统”和普通OCR工具之间最本质的差别。
我前后用了大半年时间,从零搭了一套可用的试卷解析流程:输入是扫描件或手机拍摄的整卷照片,输出是按题目组织的结构化JSON,包含题号、题干、选项、答案、空白位置和图片资源。这套系统已经在一个教研平台里稳定跑了几个月,单张试卷从上传到解析完成大约十几秒,题目归属准确率能做到95%以上。如果你也在做题库建设、在线组卷或作业批改的前置环节,这篇文章可以把我的选型思路、实现细节和踩过的坑完整交给你。
1. 整卷解析的痛点和系统边界
1.1 一个看似OCR,实际是版面理解的任务
先抛一个反直觉的结论:OCR能力在整个系统里只占三成,剩下的七成功夫都花在“版面理解”上。
所谓版面理解,就是让程序回答几个问题:这一页卷子上哪些区域是标题,哪些是正文题干,哪些是选项区,哪些是填空线和图片;这些区域之间谁先谁后,谁能组成一道题;一道题的边界在哪里结束,下一道题从哪里开始。只有把这些顺序和隶属关系理清楚,OCR识别出来的文字才能落回正确的位置,最终生成一个可用的题目对象。
举个例子,一道选择题在卷面上通常是“题目序号+题干文字+四个选项”的组合,选项可能横排也可能竖排。如果只做整页OCR,得到的文本流是线性的,系统根本不知道选项A到选项D是同一道题的组成部分,更不知道它们和题干之间的归属关系。而做了版面解析之后,模型会先把“题干区域”和“选项区域”分别框出来,再从选项区域里按A、B、C、D的位置切割,最后把两段内容合并成同一道题。这一步的逻辑错位,是所有试卷解析系统最核心的难点。
1.2 先把系统边界画清楚
开始动手之前,我花了两天时间把需求和边界画清楚。这件事非常重要,因为试卷解析的诱人方向实在太多,不做限定很容易做成一个什么都想干、什么都不精的半成品。
我最终定义的边界是:系统接收扫描PDF或手机拍摄的整页图片,输出按题目组织的结构化数据;题目类型覆盖选择题、填空题、简答题;印刷体文字必须尽量完整保留;手写答案和数学公式不做深度理解,但要把它们裁剪出来并给出坐标和图片链接,交给下游人工或算法处理;不做客观题自动判分,也不做手写笔迹识别。
为什么这么切?因为在实际的业务场景里,最耗费人工的事情不是“看答案”,而是“把一道题从卷面上抠出来,整理成可检索的题库条目”。只要系统能把题目切分正确、文字提取到位,人工只需校对和补充语义信息,效率已经能提升非常多。至于手写答案识别、公式转LaTeX,这些要么准确率不够,要么投入产出比太低,在第一个版本里做了反而会把系统拖垮。
2. 扫描件到可检索文本的预处理与OCR选型
2.1 预处理四件套:去畸变、统一尺寸、二值化、清噪声
很多人忽略图像预处理对解析结果的影响,实际上这是性价比最高的一步。手机拍摄的卷子照片普遍存在透视畸变、光照不均、背景阴影,这些噪声对版面检测的干扰远大于对OCR的干扰。
我去畸变用的办法是检测卷面四角。大多数试卷是A3或A4纸,边缘是明显的矩形。借助一个开放的低层视觉算法库,提取图像边缘轮廓,然后筛选出最大矩形区域,再做透视变换把矩形拉正。这个步骤能解决大部分俯拍照片的形变问题。需要注意,拍摄时如果卷子有折叠痕迹,矩形检测会失败,这时候我会退回原始图像,不会硬做变换。
尺寸统一也很关键。版面检测模型对输入分辨率有要求,太小的图片会丢失题号、括号等小目标。我一般把最长边统一缩放为1800到2000像素,这样既能保证小字可识别,又不会让GPU显存爆炸。如果原图本身只有800像素宽,强行放大反而会引入锯齿,这种情况下我会保留原图尺寸,只做轻度锐化。
光照矫正是另一个高频问题。试卷照片常有半边亮半边暗的情况,我用自适应阈值代替全局二值化,局部窗口大小取图像宽度的十分之一,效果比简单灰度化好很多。扫描件的噪点通常是灰尘点,用中值滤波处理一遍即可;但手机照片的彩色背景不能直接暴力二值化,否则会把淡黄色的纸张纹理变成大量孤立噪点,影响后续的版面框回归。
2.2 OCR底座怎么挑:中文识别、坐标可信度、公式片段
OCR选型是我最早调研的部分。市面上开源OCR方案不少,但真正适合试卷场景的不多,我的筛选标准有三条。
第一,中文识别准确率必须高。试卷里大量使用“下列”“不正确”“综上所述”这类表述,如果OCR底座对中文长句的支持不好,后面合并题干时会反复出现添字漏字,人工校对成本直接翻倍。第二,必须能输出每个文字块的坐标,而且坐标要稳定。坐标不只是用来画框,还要用来做“哪段文字属于哪个版面区域”的空间判断,坐标一旦漂移,整个题目组装就会错位。第三,最好自带版面解析能力,或者能很容易跟版面模型配合,避免自己维护两套完全不相干的算法链路。
我最终选择的方案是以“中文识别的开源OCR底座”为主干,把整页OCR作为后置步骤,先跑版面检测拿到问题区域,再对每个区域针对性识别。这样做的原因是:整页OCR吐出来的文字块没有语义层级,我要的“题号下面跟着的几行字是同题的题干”这种结构,只有靠版面框才能组织出来。具体的OCR底座选型上,如果你是轻量场景,可以选用传统的开源OCR引擎,但更建议使用支持深度学习的中文识别模型,准确率和抗噪声能力都会好很多。
不推荐从头训练OCR模型,除非你有几十万张标注好的卷面数据。公开的预训练模型在通用中英文上已经足够,试卷字体又相对规整,微调的必要性不大。真要优化时,只需要针对试卷里常见的“括号内填序号”“√”“×”这类特殊字符做一些后处理映射即可。
2.3 先做版面解析再做OCR,还是先OCR再归类?
这两条路我都试过,直接说结论:必须先版面解析,再做区域OCR,不要反过来。
如果先整页OCR,再根据识别文字去推断题目区块,会遇到一个致命的性能问题:OCR识别出的文字块是没有结构的,即使按坐标合并,也拿不准“这是题目序号还是正文”,因为在文字层面两者没有任何区别。而且整页OCR计算量大,一页卷子可能出来几十个文字块,后续做坐标聚类、归属判断的时间比版面解析本身还长。
先做版面解析就顺畅得多。模型直接输出若干个语义框,比如“题干”“选项”“填空”“图片”,每个框自带类别和置信度。我只需要把框按阅读顺序排好队,再对每个框单独调用OCR,识别范围小、干扰少,准确率自然更高。更重要的是,版面解析给了我们一个稳定的“骨架”,即使某个框的OCR识别失败,也能把该区域单独裁剪出来交给人工处理,不会影响整体结构。
3. 把“题”从整页版面里切出来
3.1 用版面分析模型圈出题目区域
版面解析我一开始想用基于传统图像处理的方法,比如投影切分、连通域分析。对干净的一栏扫描件,这套办法确实有效;但试卷排版一复杂就崩,例如两栏混排、选项横竖不一、题间有插图,投影法的阈值很难统一。所以我转向了一个基于深度学习的版面分析模型。
模型选型上,我采用目标检测的思路,训练了几个目标类别:标题、题干、选项、填空区、图片、答题区。类别不用太多,太细反而增加标注成本。训练数据是最花时间的部分,我从公开渠道收集了上千张试卷图片,再用标注工具逐张框出区域。说实话这个工作量很大,我后来写了一个半自动脚本:先用预训练版面模型跑一遍初始框,人工只修边界和漏检,标注效率提升了两三倍。
如果不想自己训练,也可以先用公开的版面分析模型。市面上有几个开源版面分析模型在通用文档上表现已经不错,对试卷这种相对规整的纸质文档,直接推理也能用,只是对“选项区域”的识别偶尔会漏。我的建议是第一版先用现成模型跑通,等积攒了一批业务数据之后,再考虑微调,这样风险最低。
3.2 框的合并、排序和栏切分
版面模型输出的原始框往往比较碎。一个选项可能被拆成两个框,题干文字被横线隔开也会断开。所以后处理的第一步是合并。
我用的是基于阈值的重叠合并:两个框的交并比超过一定值时合并,同时把包围盒扩展一点,保证文字边缘不被切掉。合并阈值我调在0.3到0.4之间,太高会把相邻选项误并,太低又会留下碎框。
合并之后是阅读顺序排序。这里有个关键细节:试卷是二维排版,不能简单按y坐标从上到下排。两栏试卷在视觉上是左右并排的,如果直接按y排序,右栏的内容会跟左栏的内容交错,导致题序错乱。我的做法是:先按x坐标把所有框粗略分成左中右几栏,栏内再按y坐标从上到下排序,最后按栏序拼接。这个逻辑虽然笨,但极稳,一台普通CPU上就能跑,不需要额外训练模型。
排序之后是“题号锚定”。每道题开始通常会有一个题号,比如“1.”“2.”或者“(1)”。我会用规则从OCR文本里找出形如题号的内容,把它作为一道题的起点。接下来要解决的是“这个题号后面的区域,哪些归它”。我用一个简单的贪心策略:按排序顺序扫描版面框,遇到新的题号就开启一个新题,后续的题干、选项、图片框都并入当前题,直到遇到下一个题号为止。对于题号漏检的情况,我在后续环节又做了兜底,这里先不展开。
3.3 找不到题号时的兜底切分策略
真实试卷上题号漏检几乎是必然的。有些试卷题号用了带圈数字,OCR识别不了;有些试卷把题号藏在了底纹里,版面模型把它当成了背景。
我的兜底策略有两层。第一层是“体积启发法”:如果某一道题的题干区域面积特别大,比如占了一页长度的三分之一以上,而且前面没有新题号出现,就判断它可能是简答题的题干,理应自成一道题。第二层是“折行边界法”:当版面框出现明显的纵向空隙,空隙宽度超过行高的两倍,就认为前一道题已经结束。这个办法对简答题之间的间隔很有效,但对选择题连续排列的卷面,还是需要题号作为锚点。
遇到实在无法自动判断的情况,我的原则是“宁可分错,不可漏题”。一旦把两道题并成一道,后面的题库录入就会缺一条题目,人工排查成本更高。所以我宁可切多一道,让人工合并,也不让它吞题。这条原则在验收时帮了大忙,系统报告里的题数永远不小于人工统计的题数。
4. 答案识别与结构化导出
4.1 输出JSON的字段设计
题目切好后,接下来是结构化导出。这是接口设计最关键的环节,因为下游题库系统要消费这些数据,字段定义得不清晰,后面接入时就要反复改。
我最终采用的数据结构大致长这样:
{ "page_id": "exam_20250301_01", "items": [ { "item_id": 1, "question_no": "1", "type": "choice", "stem": "下列哪个选项是正确的?", "options": [ {"label": "A", "content": "第一项"}, {"label": "B", "content": "第二项"} ], "answer": "", "blank": null, "image_urls": [], "confidence": 0.92 } ] }字段设计上,我把question_no和item_id分开。question_no是卷面上的原始题号,可能是“1”也可能是“(1)”,保持原样不加工;item_id是系统内部的自增序号,用于处理题号不连续或漏检的情况。type字段维护了choice、fill、short_answer三种类型,由题型分类模块填充。blank字段存填空题空白区域的位置信息,包括坐标和对应的裁剪图片URL,方便下游做填空批改或解析。
这个结构的最大好处是:即使某道题的OCR结果不完整,系统仍然能输出一个结构完整的item对象,只是某些字段为空。下游系统拿到后,可以走人工补全流程,而不是因为缺字段直接报错。
4.2 填空题和选择题的形态识别
题型判断我用了很轻量的方法,没有上分类模型。
选择题有一个显著特征:题干区域后面通常紧跟一个选项区域,且选项区域里包含A、B、C、D四个字母标识。我用版面模型的类别信息就够了——如果检测到“options”类别的框,就判定为选择题,再在选项框内按字母位置切割。竖排选项的切割稍微麻烦一点,但逻辑也简单:先把A、B、C、D字母的坐标找出来,按字母的顺序把内容切成四段。这里有个小坑:选项内容里可能也会出现“A.”“B.”这样的缩写,比如“A. 正确”,所以切割时应该只认独立字母+标点,不能对任意字母都触发切分。
填空题的识别主要靠“空”的位置特征。试卷上的填空题通常是短横线、括号或者连续空格。我会在版面模型的框里,设置一个后处理规则:检测连续水平线的线段,线段两侧都是文字区域,且长度超过两个字符的宽度,就判定为一个填空位。题目包含填空位则类型记为fill,再把每个填空位和题干的相对坐标记录下来。
如果一道题既有选项区又有填空线,我会优先当作选择题处理,因为这在现实卷面里通常意味着“选择并填空”的综合题,选项是主要结构。
4.3 手写答案与数学公式,别硬上OCR
这一点是我踩坑最深的结论:试卷解析系统不要试图把手写答案和数学公式完整识别出来。不是技术上完全不可行,而是准确率达不到业务可用标准,强行识别只会制造大量脏数据。
数学公式区域在卷面上往往有独立排版,公式里的分式、根号、上下标,用普通OCR识别会产生非常离谱的符号串。我的策略是:版面模型把公式区域标为“图片区”,不丢进OCR,而是裁剪成独立图片,在JSON里输出image_urls字段。下游如果需要LaTeX,可以用专门的公式识别模型单独处理,不会污染整道题的题干文本。
手写答案同理。批改过的卷面上有红笔痕迹,既影响印刷体OCR的准确率,又无法用普通OCR有效识别。系统识别到答题区域后,会把答题区背景压暗、把印刷体部分移除,只保留手写笔迹的裁剪图,方便后续进行手写识别或人工批注。这样处理,既保证了题库核心的印刷体文本干净,又为未来扩展留了口子。
5. 效果评估与真实场景里的坑
5.1 指标怎么设,才不算“自我感觉良好”
效果评估如果只看“能不能识别出文字”,会掩盖大量问题。我建立了一套多维指标来复盘系统性能。
第一是版面框的准确率。我把模型输出的框和人工标注的框做IoU匹配,IoU大于0.5算命中。命中率按类别分别统计,题干、选项、图片分开看。这部分低于90%的话,题目组装层面一定会出问题。第二是题目切分的召回率和精确率。以“人工确认的一道题”为单位,系统能完整输出这道题的题干、选项、答案,才算一个命中。召回率看的是有没有漏题,精确率看的是有没有把无关内容拼进题目。第三是OCR文字层面的准确率,我用字符错误率来评估,整卷统计下来多数时候在1%以下,但题目归属错误率才是关键指标,这一项必须控制在5%以内。
整个系统最影响体验的不是单字识别,而是“题号和题目内容张冠李戴”。排版一旦错位,最严重的后果是下游题库里出现几道题的语义完全颠倒,这种错误比丢字缺字更难发现。所以我会在测试集里专门构造各种带横排选项、竖排选项、两栏排版、插图的卷面,确保排序逻辑在这些情况下都能稳定。
5.2 最容易翻车的几种卷面
我统计了真实运行中翻车最多的几种卷面,如果你也要做类似系统,可以提前防范。
第一种是带复杂表头和页脚框线的试卷。版面框检测会把表格线误认为图片区域,导致选项和题干被割裂。我的解决办法是在预处理阶段增加一次线性检测,先识别长横线和竖线,把表格线结构从版面模型的输入里弱化。第二种是严重倾斜且带黑边的扫描件。黑边会让透视校正失败,我后来在校正前先做一个边缘裁剪,把黑色边框去掉再检测矩形。第三种是灰度低、对比度低的复印件。这类卷面二值化之后文字轮廓发虚,OCR效果明显下降。我在灰度化之后增加了一步对比度拉伸,把直方图两端压缩到5%到95%的分位点,文字边缘会清晰不少。
还有一个容易忽略的细节:有些试卷的题目序号不是连续的,比如跳过第5题,或第8题分成(1)到(5)的小题。如果只是简单按题号递增来判断切分,就会在下一次出现新题号时误判。我的处理是:每遇到一个“根题号”,就开启一道新题;遇到“子题号”(带括号的数字)则悬挂到当前根题下,作为子题处理。这套逻辑能适配大多数卷面。
5.3 兜底与人工修正的闭环
再好的自动系统也需要一个人工修正的接口。我的系统在解析完成后,会给每一个item输出一个confidence字段,数值在0到1之间。如果某道题的置信度低于0.75,或者版面框数量异常,系统会自动把它标记为“待人工复核”。人工复核界面直接展示原图裁剪和结构化文本,修改后提交,修正结果会回流到一个增量训练集里。
这个闭环的价值在于,随着时间推移,模型会越来越懂“这类卷面长什么样”。事实上,我后来做的大部分模型迭代,训练数据都来自在线人工修正回流的积累,而不是一开始精心标注的基准集。如果你准备做类似的系统,务必在设计之初就把修正漏斗规划进去,不然后面加会很痛苦。
6. 从单机脚本到可发布解析服务
6.1 异步任务队列与前端轮询
单机脚本只能本地调试,真正让系统可用,还得把它包成一个服务。我的第一版服务非常朴素:用Python的异步Web框架暴露一个上传接口,前端传了试卷图片后,服务端同步跑完整流程再返回结果。随着图片体积增大和并发请求变多,同步阻塞的问题越来越明显,一张大图解析十秒,三个请求同时进来,服务就卡死了。
后来我改成异步任务队列。上传接口只负责两件事:把原始文件存到临时空间,把解析任务推进队列里,然后立刻返回一个task_id。后端有一个独立的工作进程消费队列,每处理完一单就把结果写进状态表。前端拿task_id定期轮询,解析完成后再拉取结果。
这个改造做得非常值得。理论上一个队列进程就够了,后续如果业务增长,可以轻易地水平扩展多个worker分担解析压力。我建议就算前期预测流量很低,也提前按这个模式做,因为从同步改异步的迁移成本不低,极容易引入状态不一致的bug。
6.2 图片资源怎么存储,比想象中重要
解析过程中会产生大量中间图片资源:题目裁剪图、公式裁剪图、手写答案区域图。一开始我图省事,把图片以本地文件路径的形式塞进JSON返回,结果部署到多机环境立刻出问题。第一台机器产生的路径,第二台机器根本访问不到。
修正后的做法是:解析worker在处理完一张题目的图片后,立即把图片上传到对象存储服务,再把返回的URL写进JSON的image_urls字段。上传时按“试卷ID/题号/类型.png”的规则命名,这样下游系统一看文件名就知道这个图片属于哪道题,调试时非常直观。
踩过的一个坑是上传太频繁。一道题可能有三四张裁剪图,一份试卷十几道题,就是几十次请求。后来我把同试卷的全部裁剪图先缓存在worker本地,最后统一打包批量上传,耗时从秒级降到了毫秒级。
6.3 性能调参与后续扩展方向
解析速度是我后来优化的重点。最初完整解析一份试卷需要40秒左右,最耗资源的是版面模型推理,然后是整页OCR。我把图像最长边从2400像素降到1800像素,OCR速度提升了近一倍,而准确率几乎没受到影响。如果后续数据量继续增长,可以考虑将版面模型和OCR分别部署到GPU服务上,用异步调用拼接,能进一步压到5秒内。
在扩展方向上,我觉得最有价值的是做“题干重排”。很多试卷会使用两栏布局,解析时是按阅读顺序整理的,但有些题干的文字跨越了两个栏位,自动切分时会拆成两段。我现在使用的是基于排序的简单拼接,更稳妥的做法是引入学习式的文档图重建,不过这是下一个版本的事情了。
我最近还在做的另一个实验是返回结果前的自动校验:用一个轻量级语言模型把输出JSON里每题的结构化文本和原图做一个语义比对,逻辑上应该包含的选项个数、题号是否齐全、空格位置是否合理,都能提前筛一遍。这套基于规则的校验配合人工修正漏斗,让系统的最终可用率明显提升。如果你刚起步,优先把规则校验做好,比纠结训练更大的模型来得快。