简介:这份PDF文档面向教育技术研究者、机器视觉方向学生及学校信息化建设人员,系统讲解了一套基于机器视觉的试卷分数智能识别系统设计方案,用于解决人工合分速度慢、易出错、效率低等实际问题。文档共1个PDF文件,压缩包约1.26MB,内容涵盖系统架构、硬件模块与软件模块的完整设计思路。硬件部分详细介绍了工业相机、工业镜头与环形光源的选型依据及主要参数,软件部分则围绕图像采集、预处理、分数轮廓边缘提取、文字OCR识别与分数统计分析等关键环节展开,并给出算法验证与对比试验结果。读者可从中获取从硬件搭建到算法实现的完整技术路线,理解轮廓识别与OCR算子结合的具体做法,并参考其系统界面设计与试验数据,为课程设计、毕业课题或相关项目开发提供可借鉴的参考文献与专业指导。目前已有137人学习关注。
1. 从一张手写分数说起:机器视觉怎么把试卷分数读准
改卷老师最烦的不是批改,是批改完之后那一摞分数录入。一个班五十份卷子,每份正面的分数框里手写一个数字,教务要求当天出成绩,人工敲一遍至少半小时,还容易把 7 敲成 1、把 0 敲成 6。我最早接触「试卷分数智能识别」这个方向,就是因为一所中学的教务主任拿着手机拍的一叠卷子问我:能不能让机器自己把分数读出来,直接进 Excel。
这件事的本质,是机器视觉里一个非常典型的受限场景 OCR 问题:目标区域固定、字符集极小(通常只有 0-9 和少量符号)、背景是印刷体表格线、前景是手写数字。它不像通用 OCR 那样要面对千变万化的版面,也不像票据识别那样字段繁多。正因为约束多,反而容易做稳。整套系统拆开看就是四步:图像采集与预处理、分数区域定位(轮廓识别为主)、字符分割与识别(OCR 或轻量分类网络)、结果校验与导出。适合谁做?有 Python 基础、懂一点 OpenCV、想找一个完整机器视觉项目练手的人;也适合学校信息中心或小团队做一套内部工具。下面我按自己实际搭过的路径,把每一步的参数、坑和验证方法讲清楚。
2. 分数区域怎么定位:轮廓识别与版面约束的配合
2.1 为什么先定位再识别,而不是整图丢给 OCR
很多人第一反应是:直接上 OCR,把整张卷子丢进去,让它自己找分数。我试过,翻车得很彻底。通用 OCR 会把题干里的数字、题号、页码全部读出来,你根本分不清哪个是分数。而且手写数字和印刷体混在一起,识别引擎的置信度会互相干扰。
正确思路是先用机器视觉把「分数框」这个 ROI(感兴趣区域)抠出来,再只对这个区域做识别。试卷的分数框通常有强约束:位置固定在卷首右上角或每道题号旁边、有印刷的方框或下划线、尺寸相对统一。这些约束就是定位的抓手。
定位的主流做法有两类。一类是基于轮廓识别:对图像二值化后找连通域,用长宽比、面积、位置筛选出候选框。另一类是模板匹配:预先裁一个标准分数框模板,滑窗匹配。前者适应性更强,后者在版面完全固定时更快。我一般用轮廓识别打底,模板匹配做兜底校验。
2.2 预处理:把手机拍的歪卷子拉正
手机拍摄的卷子一定有透视畸变和光照不均,直接做轮廓会得到一堆碎块。预处理顺序我固定为:灰度化 → 高斯模糊 → 自适应二值化 → 形态学闭运算 → 透视校正。
import cv2 import numpy as np def preprocess(img_path): img = cv2.imread(img_path) # 缩小到固定宽度,减少后续计算量,同时弱化手机高分辨率带来的噪点 h, w = img.shape[:2] target_w = 1200 scale = target_w / w img = cv2.resize(img, (target_w, int(h * scale))) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊核用 5x5,太大糊掉细笔画,太小去不掉纸张纹理 blur = cv2.GaussianBlur(gray, (5, 5), 0) # 自适应二值化:blockSize 取 31,C 取 10,对光照不均最稳 binary = cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10) # 闭运算连接断裂的框线,核 3x3 足够 kernel = np.ones((3, 3), np.uint8) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return img, closed这段代码里三个参数最关键。target_w=1200是经验值,A4 卷子在这个宽度下,手写数字笔画大约 8-15 像素宽,既够识别又不至于让轮廓检测跑太久。blockSize=31控制自适应阈值的邻域大小,卷面有阴影时调大到 41,字迹淡时调小到 21。C=10是阈值偏移,值越大二值化越"狠",背景噪点多就往上加。
透视校正需要先找到卷子的四个角点。常见做法是找最大轮廓的近似四边形,用cv2.getPerspectiveTransform做映射。这一步不做,后面所有坐标都是歪的,分数框筛选会失准。
2.3 用轮廓识别筛出分数框
二值化之后,分数框会表现为一个闭合的矩形轮廓。用cv2.findContours拿到所有轮廓,然后按几何特征过滤。
def find_score_boxes(binary, img_shape): contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) h, w = img_shape[:2] candidates = [] for cnt in contours: x, y, bw, bh = cv2.boundingRect(cnt) area = bw * bh aspect = bw / float(bh) if bh else 0 # 分数框经验约束:面积占比、长宽比、位置 if area < 0.0005 * w * h or area > 0.02 * w * h: continue if aspect < 0.6 or aspect > 3.0: continue # 分数框一般在卷面上半部分或题号右侧,这里按需调整 candidates.append((x, y, bw, bh)) # 按 y 再按 x 排序,保证输出顺序和阅读顺序一致 candidates.sort(key=lambda b: (b[1] // 50, b[0])) return candidates参数说明:面积下限0.0005*w*h是为了滤掉标点和噪点,上限0.02*w*h是防止把整块答题区当成一个框。长宽比 0.6 到 3.0 覆盖了单数字框(接近方形)和横排分数框(扁长)。这两个阈值必须拿你自己学校的卷子实测调整,不同排版差异很大。
提示:如果卷面有大量印刷表格线,
RETR_EXTERNAL会把整张表当成一个外轮廓。这时改用RETR_LIST并加一层「轮廓内是否有手写笔画」的判断,或者先做直线检测把表格线抹掉。
2.4 定位结果怎么验证
定位对不对,不能靠肉眼看几张图就下结论。我一般写一个批量验证脚本:把检测到的框画在原图上,输出到一个文件夹,人工抽查 20 张。同时统计两个指标——召回率(真实分数框被检出的比例)和误检率(非分数框被当成候选的比例)。召回率低于 95% 就放宽面积和长宽比约束,误检率高就收紧。这个循环通常要跑两三版才能稳定。
3. 手写数字识别:OCR 引擎与轻量分类网络怎么选
3.1 通用 OCR 和自训练分类的边界
分数区域抠出来之后,识别环节有两条路。第一条是直接用 OCR 引擎,比如 Tesseract、PaddleOCR 这类。第二条是自己训一个数字分类器,把每个字符单独切出来送进 CNN。
我的判断标准很简单:如果你的分数是印刷体,直接上 OCR,配置好字符白名单(只允许 0-9 和 . )就能到 99% 以上。如果是手写体,通用 OCR 的准确率会掉到 70%-85%,因为手写数字的笔画变异太大,OCR 引擎的训练集以印刷体和规整手写为主。
自训练分类器的优势在于:字符集只有 11 类(0-9 加点号),数据需求小,几百到几千张标注样本就能训到 98% 以上。代价是你得自己收集和标注数据,还要处理字符分割。我一般建议:先跑通 OCR 版本验证整条链路,再根据实际准确率决定要不要上自训练模型。
3.2 用 Tesseract 跑通最小可用版本
先装环境。Tesseract 在 Windows 上直接下安装包,Linux 用包管理器。
# Ubuntu/Debian sudo apt-get install tesseract-ocr sudo apt-get install libtesseract-dev # Python 封装 pip install pytesseract opencv-python识别单个分数框的代码:
import cv2 import pytesseract import numpy as np def recognize_score(roi): # roi 是定位阶段裁出的分数框图像 gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) # 放大有助于 Tesseract 识别小字符,2 倍是常用值 gray = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) # OTSU 二值化,手写笔画和背景分离 _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 关键配置:--psm 7 表示单行文本,-c 限定字符白名单 config = '--psm 7 -c tessedit_char_whitelist=0123456789.' text = pytesseract.image_to_string(binary, config=config) return text.strip()--psm 7是页面分割模式,告诉 Tesseract 这是单行文本,不要按段落分析。tessedit_char_whitelist限定只输出数字和小数点,能挡掉大量误识别。放大 2 倍是因为 Tesseract 对小于 20 像素高的字符识别率明显下降。
3.3 自训练数字分类器的数据与结构
如果 OCR 版本准确率不够,就上分类器。数据来源有两个:一是从历史卷子里裁分数框,人工标注;二是用印刷体数字做数据增强,加随机旋转、缩放、弹性形变模拟手写。
网络结构不用复杂,三层卷积加两层全连接足够:
import torch import torch.nn as nn class DigitNet(nn.Module): def __init__(self, num_classes=11): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 64, 3, padding=1), nn.ReLU(), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))输入统一缩放到 28x28 灰度图。Dropout(0.3)是防过拟合的关键,手写样本少的时候尤其重要。训练时用交叉熵损失,Adam 优化器,学习率 1e-3,batch size 64,一般 20 个 epoch 收敛。
3.4 字符分割:粘连数字怎么切开
分数框里可能是「95」「100」这种多位数,分类器一次只能认一个字符,必须先分割。投影法是最常用的:对二值图做垂直投影,统计每列的黑色像素数,笔画之间的空白列就是切分点。
def split_digits(binary): # binary 是白字黑底或黑字白底,统一成黑字白底 col_sum = np.sum(binary == 0, axis=0) # 阈值取列高的 5%,低于认为无笔画 threshold = binary.shape[0] * 0.05 splits = [] in_char = False start = 0 for i, v in enumerate(col_sum): if v > threshold and not in_char: start = i in_char = True elif v <= threshold and in_char: splits.append((start, i)) in_char = False if in_char: splits.append((start, len(col_sum))) # 过滤过窄的碎片 return [s for s in splits if s[1] - s[0] > 3]粘连的情况投影法会失效,比如「1」和「0」挨在一起。这时用连通域分析,或者上 CTC 这类序列识别方案。实际项目里,我会在分割后加一步宽度校验:单个数字宽度应该在框高的 0.3-0.7 倍之间,超出就标记为可疑,转人工复核。
4. 避坑与排查:分数识别系统上线前必须过的五道坎
4.1 现象:白天测得好好的,晚上灯光下全乱
原因:自适应二值化的blockSize和C是在特定光照下调的,换了光照条件,阈值分布整体偏移,轮廓检测跟着崩。
解决:不要用固定参数。在预处理前加一步光照归一化,用 CLAHE(限制对比度自适应直方图均衡)把亮度拉平。或者干脆在采集端解决——固定拍摄台加环形补光灯,把光照变量消掉。我吃过这个亏之后,所有现场部署的方案都强制要求固定光源。
4.2 现象:分数框检出来了,但识别结果是空的
原因:ROI 裁切时坐标算错,或者裁出来的区域包含了大量框线,二值化后笔画被框线淹没。
解决:裁切时向内收缩 10%-15% 的边距,把框线排除在外。同时打印中间结果——把每个 ROI 单独存图,肉眼确认裁的是不是分数区域。这个调试习惯能省掉大量瞎猜时间。
4.3 现象:同一个分数,两次识别结果不一样
原因:Tesseract 对低质量图像的处理有随机性,尤其是笔画断裂时。另外图像缩放用的插值方法也会影响结果。
解决:固定所有随机源。插值统一用INTER_CUBIC,识别前做一次形态学闭运算把断裂笔画连上。如果还是不稳定,说明图像质量已经低于 OCR 的可靠边界,该上自训练模型了。
4.4 现象:小数点和数字「1」混淆
原因:手写小数点和短横线在低分辨率下形态接近,字符白名单里同时有.和数字时容易串。
解决:先做位置判断——小数点一定在数字右下角,且宽度明显小于数字。用连通域的位置和尺寸做二次校验。或者干脆在分数场景里禁用小数点,因为试卷分数极少有小数。
4.5 现象:批量处理时越跑越慢,内存爆掉
原因:OpenCV 和 PyTorch 的对象没有及时释放,循环里不断累积。
解决:每处理完一张图,显式del掉大对象,必要时调gc.collect()。PyTorch 推理时用torch.no_grad()包住,避免构建计算图。批量处理改成流式,不要一次性把所有图读进内存。
5. 把识别结果接进教务流程:校验规则与置信度兜底
识别出分数只是第一步,真正决定这套系统能不能用的是「错了怎么办」。我的做法是给每个识别结果配一个置信度,低于阈值的自动转人工复核,而不是硬着头皮输出。
Tesseract 可以用image_to_data拿到每个字符的置信度:
def recognize_with_confidence(roi): gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) config = '--psm 7 -c tessedit_char_whitelist=0123456789' data = pytesseract.image_to_data(binary, config=config, output_type=pytesseract.Output.DICT) text = '' confs = [] for t, c in zip(data['text'], data['conf']): if t.strip(): text += t confs.append(float(c)) avg_conf = sum(confs) / len(confs) if confs else 0 return text, avg_conf置信度阈值我一般设在 75。低于 75 的结果标黄,导出时单独列一个「待复核」sheet。这个机制上线后,人工复核量从 100% 降到 8% 左右,而且复核的人只需要看标黄的,心理负担小很多。
除了置信度,还要加业务规则校验。分数有明确范围:单题分数不会超过该题满分,总分不会超过卷面总分。识别出「950」而卷面满分是 100,直接判定为异常。这些规则用几行代码就能挡住大部分离谱错误。
def validate_score(text, max_score): try: val = float(text) except ValueError: return None, 'parse_error' if val < 0 or val > max_score: return None, 'out_of_range' return val, 'ok'导出环节用 pandas 写 Excel,把原图路径、识别分数、置信度、校验状态一起写进去。复核的人点开 Excel 就能看到对应的小图,改完直接覆盖。这套流程跑顺之后,一个班的分数录入从半小时压到三分钟以内。
最后说个我自己的习惯:每次部署到新学校,我一定先拿 50 张真实卷子跑一遍全流程,把失败案例全部截图存下来分类。是光照问题、版面问题还是手写太潦草,分类完再决定调参数还是加规则。这个「先收集失败再优化」的顺序,比一上来就调模型管用得多。希望帮到你。
本文还有配套的精品资源,点击获取