ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python+OpenCV实现答题卡智能识别判卷:从图像预处理到自动评分

Python+OpenCV实现答题卡智能识别判卷:从图像预处理到自动评分 简介基于Python的答题卡智能识别判卷项目源码包面向教育信息化开发者与机器学习初学者旨在解决纸质答题卡自动识别与评分的效率问题。项目综合运用OpenCV完成图像灰度化、二值化、去噪等预处理结合模板匹配定位各题目区域并利用SVM或CNN等机器学习模型识别填涂选项必要时还可借助OCR处理手写答案形成了一套完整的视觉识别工程流程。资源包共21个文件以1个Python主脚本为核心附带16张测试图片与识别效果图可直接运行并对照查看识别输出另有3个xml配置文件和1个iml工程文件便于还原项目结构。整体仅11.54MB轻量易用。包内素材覆盖多张不同填涂状态的答题卡样例目前已有1320人浏览学习。读者可借助这套代码理解图像特征提取、模型评估与参数调优的完整链路适合希望在真实场景中快速上手答题卡识别项目的开发者参考。1. 答题卡智能识别判卷这个 Python 项目解决的是每个老师的重复劳动手改答题卡是件极度消耗耐心的事尤其碰上两个班以上的选择题批改加登分能占用一个晚上。而基于 Python 的答题卡智能识别判卷项目就是用一个摄像头或扫描仪拍下答题卡通过 OpenCV 做透视矫正、轮廓定位、涂卡区域分析再和正确答案比对自动输出每张卡的得分和错题分布。它不依赖专用阅卷机一台普通电脑加一个拍照设备就能把流程跑起来对个人开发者、小型培训机构、学校信息化试点来说是最成熟也最容易复现的方向。这个项目源码以 zip 包形式分发核心价值不在于代码本身多复杂而在于它把「图像预处理 → 透视变换 → 选项提取 → 逻辑判卷」这条链路完整打通了。下面按我实际调试这类项目的顺序把每一步的做法、参数和踩过的坑讲清楚。2. 项目结构与前处理把答题卡图像变成干净矩阵的四个关键步骤2.1 读图与灰度化先解决色彩噪声拿到 zip 包解压后第一件事不是急着跑 main.py而是先确认项目里图像读取的入口。绝大多数这类项目的骨架是这样的读取图片 → 缩放 → 灰度化 → 滤波 → 二值化 → 找轮廓 → 透视矫正 → 分割选项 → 判卷。核心依赖是 opencv-python 和 numpy第一次跑之前先确认环境pip install opencv-python numpy python main.py --image ./test.jpg如果项目没有main.py一般会有一个recognize.py或score.py作为入口没有的话自己建一个。读图这一步最容易被忽略的是色彩空间的问题——答题卡如果是红头文件或者用红笔做了标记直接转灰度会把红色和黑色混在一起导致后面二值化时红色批注变成干扰块。我一般会在灰度化之前先做一次通道分离观察哪个通道对涂卡痕迹的对比度最大。常见的做法是用 OpenCV 的split函数把 BGR 三通道拆开蓝色通道对铅笔灰度痕迹的响应通常最干净而红色批注在蓝色通道里接近白色天然被滤掉了。如果你手里的答题卡是黑白印刷直接cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)就够了。import cv2 img cv2.imread(answer_sheet.jpg) # 先统一宽度太高分辨率的图会影响后面轮廓检测的耗时 img cv2.resize(img, None, fx0.5, fy0.5, interpolationcv2.INTER_AREA) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 轻度高斯模糊抑制纸张纹理噪声 blurred cv2.GaussianBlur(gray, (3, 3), 0)参数说明fx0.5, fy0.5表示宽度和高度各缩放一半INTER_AREA适合缩小图像能减少摩尔纹。GaussianBlur的核大小(3, 3)是经验值纸张纹理比较重时可以调到(5, 5)但别超过(7, 7)否则会把铅笔涂卡的边缘也磨掉后面找选项轮廓时会吃亏。2.2 透视矫正四角定位决定后面所有数据的准确性答题卡拍照很难保证完全正对镜头手稍微一歪选项网格就变成梯形或者任意四边形。如果不对图像做透视矫正后面按固定坐标切分选项必然错位。这个项目的核心步骤就在这里先用边缘检测找出答题卡的外轮廓再用轮廓逼近求出四个角点最后做透视变换把答题卡拉正成一个标准矩形。def order_points(pts): # 按左上、右上、右下、左下排序四个角点 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上角xy 最小 rect[2] pts[np.argmax(s)] # 右下角xy 最大 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上角x-y 最小 rect[3] pts[np.argmax(diff)] # 左下角x-y 最大 return rect逻辑说明np.sum和np.diff的组合是排序角点的经典技巧argmin和argmax分别找出最小和最大索引这样四个点各自归位。排序完角点后下一步用cv2.getPerspectiveTransform计算变换矩阵再用cv2.warpPerspective执行变换。def four_point_transform(image, pts): rect order_points(pts) (tl, tr, br, bl) rect widthA np.linalg.norm(br - bl) widthB np.linalg.norm(tr - tl) maxWidth max(int(widthA), int(widthB)) heightA np.linalg.norm(tr - br) heightB np.linalg.norm(tl - bl) maxHeight max(int(heightA), int(heightB)) dst np.array([ [0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped参数说明maxWidth和maxHeight由四个角点的欧氏距离决定保证变换后图像比例不失真。warpPerspective的第三个参数是输出尺寸这里直接取计算出来的最大宽度和高度能保证答题卡内容完整落在画布内。2.3 阈值分割与参数选型大津法还是固定阈值透视矫正后的图像接下来要做二值化把涂卡区域和背景彻底分开。很多源码里直接用固定阈值 127 或者 200这在光线均匀的扫描图上是没问题的但摄像头拍摄的图经常一面亮一面暗固定阈值会直接翻车。我一般会优先用大津法Otsu让算法自己找阈值效果不行再退回固定阈值配合局部自适应。_, thresh cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)逻辑说明THRESH_BINARY_INV把涂卡区域变成白色、背景变成黑色是后续findContours的标准输入形式。大津法会在灰度直方图上自动找一个类间方差最大的分割点不用手动调亮度参数。如果光照实在不均就改用cv2.adaptiveThreshold它按局部邻域算阈值能救回一部分阴影遮挡的卡但代价是纸张纹理也可能被当成前景需要在前面加大模糊核。参数说明THRESH_OTSU作为 flag 传入时thresh参数填 0 即可算法自己算。注意THRESH_BINARY_INV不是可选项而是必选方向反了后面找轮廓只能找到外边框内里的选项区域全是黑的。2.4 轮廓过滤与投影切分把选项从背景里拆出来二值化之后图像里会有三种东西答题卡的印刷框线、涂卡的黑色块、以及噪点。cv2.findContours会把所有连通区域都找出来这时候要用面积和宽高比把它筛一遍。答题卡上的选项块通常有相对固定的面积范围印刷框线是长条形的噪点面积很小三者靠这两个特征就能分开。cnts, _ cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) options [] for c in cnts: x, y, w, h cv2.boundingRect(c) area w * h if area 800 or area 8000: # 面积过滤数值随答题卡实际尺寸调节 continue if w 10 or h 10: # 排除细线条 continue options.append((x, y, w, h, c))逻辑说明RETR_EXTERNAL只取最外层轮廓避免内部轮廓干扰CHAIN_APPROX_SIMPLE压缩轮廓点减少内存占用。过滤条件里的800和8000是经验范围具体要看你拍的答题卡大小和图像分辨率这个参数会在后面单独调试。筛选完的轮廓按照y坐标排序就能按行分组按x坐标排序就能确定每题的 A/B/C/D 顺序。3. 识别与判卷核心选项检测、涂卡判断、答案比对3.1 选项区域的网格划分与逐格判断拿到全部轮廓后关键是把散落的涂卡块映射回「第几题第几个选项」这个网格坐标系里。常见做法是利用答题卡上印刷的定位块或选项框做分组先按 y 坐标聚类分出「题行」再在每行里按 x 坐标分成 A/B/C/D 四列。聚类这一步别用固定坐标切分因为拍照还是会有轻微旋转残留靠聚类比靠绝对坐标更稳。row_thresh 30 # 同一行的 y 坐标最大允许偏差 rows [] for (x, y, w, h, c) in options: placed False for i, row in enumerate(rows): if abs(y - row[0][1]) row_thresh: row.append((x, y, w, h, c)) placed True break if not placed: rows.append([(x, y, w, h, c)])逻辑说明遍历所有选项轮廓每次取第一个轮廓的 y 坐标作为锚点后续轮廓只要 y 差值小于row_thresh就归入同一行。这个阈值直接决定行切分的正确率太小会把同一行拆成多行太大会把相邻两行并成一行。确定行后每行内的 x 坐标从小到大排序前四个就是 A/B/C/D但要注意题号顺序必须从左到右这个在调用端要确认和你的答题卡一致。3.2 涂卡程度判定覆盖率阈值里的门道选项定位到了剩下的问题是「怎么判断一个格子被涂了」。项目里普遍的做法不是用像素和做比较而是算涂卡区域内非零像素的占比。占比超过一个经验阈值就认为有效作答。这个阈值有的项目写死 0.5有的写死 0.3但真实答题卡上不同铅笔的浓度差异极大——2B 铅笔涂得轻一点灰度只有 100 左右涂得重一点能到 20。def is_marked(cell, threshold0.4): # cell 是已经裁好的单个选项区域灰度图 _, binary cv2.threshold(cell, 100, 255, cv2.THRESH_BINARY_INV) total_pixels cell.shape[0] * cell.shape[1] marked_pixels cv2.countNonZero(binary) ratio marked_pixels / total_pixels return ratio threshold, ratio逻辑说明countNonZero统计二值图中白色像素数量除以总像素得到覆盖率。threshold0.4意味着选项格里 40% 以上被涂黑才算有效这个值在大多数答题卡上能同时兼容「轻涂」和「涂出边框」两种情况。判断得到的是每个选项是否被涂的布尔值多个选项同时为 True 就属于多选判卷逻辑里需要单独处理。参数说明threshold不宜低于 0.2否则橡皮擦没擦干净的痕迹会变成误判也不宜高于 0.6否则用 2B 铅笔正常涂卡却因为留白较多被判为空选。实际项目里我一般先跑一遍测试集统计每个格子覆盖率的分布取「明显涂卡区域」和「空白区域」之间的中位数作为阈值。3.3 读卡顺序与答案比对逻辑判卷逻辑本身相对简单每张答题卡的识别结果是一个二维数组answers[row][col]正确答案是预先定义好的一维列表逐题比对就行。但有一个容易翻车的细节——如果你的答题卡设计的题号是竖排而不是横排识别结果的行列顺序就要做转置否则从第 1 题开始就全错了。correct_answers [A, C, B, D, A, C, A, B, D, C] student_answers [] # row.answer_index 按 0A,1B,2C,3D 映射 for row in rows: marked_indices [i for i in range(4) if row[i][1]] if len(marked_indices) 1: student_answers.append([A, B, C, D][marked_indices[0]]) elif len(marked_indices) 0: student_answers.append() # 未作答 else: student_answers.append(X) # 多选标为异常 score sum(1 for s, c in zip(student_answers, correct_answers) if s c)逻辑说明marked_indices通过列表推导式把被封装的标记状态转成索引列表长度为 1 表示正常单选0 表示未作答大于 1 表示多选异常。计分这里用的是一题一分实际项目里可以扩展成每道题不同分值最后加权求和。这个判题逻辑虽然朴素但胜在稳定、容易改也方便输出错误题号列表给考生。3.4 判卷结果的输出写 CSV、标注原图、统计得分判完分数还不算完一个能直接投入使用的判卷项目必须把结果输出成两种形式一是结构化数据方便导入成绩系统二是可视化结果方便人工复核。我习惯在输出目录里同时生成score.csv和marked.png前者记录学号如果有条形码识别模块的话、得分和每题的答题情况后者把识别出的选项和正确答案不同地方用红框标出来。import csv with open(score.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([student_id, answers, score, wrong_questions]) for result in all_results: writer.writerow([ result[student_id], .join(result[answers]), result[score], ,.join(result[wrong]) ])逻辑说明csv.writer按行写入newline防止写入空行encodingutf-8避免 Excel 打开时中文乱码。wrong_questions存的是题号列表方便老师直接看哪些题错得多也能进一步做错误知识点分布统计。4. 判卷判错五个常见的涂卡识别翻车现场与排查记录4.1 现象同一张卡一半能识别一半判不了拍照时的自然光从侧面照过来答题卡靠窗一侧亮度高、背光一侧亮度低。固定阈值二值化后亮的一侧涂卡区域和背景对比明显暗的一侧涂卡灰度值直接低于阈值识别结果大面积为空选。原因cv2.threshold用的是全局阈值无法处理光照梯度差异。解决改用cv2.adaptiveThreshold按每个像素邻域计算局部阈值或者在拍照时用两张白纸挡在两侧减少环境光影响。代码层面更彻底的办法是先用大津法算全局阈值再用cv2.divide做背景归一化能有效把光照梯度抹平。gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 背景归一化消除光照梯度 blur cv2.GaussianBlur(gray, (15, 15), 0) normalized cv2.divide(gray, blur, scale255)4.2 现象铅笔涂得浅选项被判为空选学生用 HB 铅笔或者自动铅笔作答灰度值低二值化后覆盖率只有 0.15低于默认的 0.4 阈值。解决在is_marked里把灰度阈值从固定 100 下调到自适应或者把覆盖率阈值降到 0.25。但降阈值会带来误判风险所以要先找到答题卡上「没涂的格子」的最大覆盖率阈值取它和「涂了的格子」的最小覆盖率之间的中间值。4.3 现象扫描歪斜导致选项区域错位拍照时答题卡没有放正虽然做了透视矫正但答题卡本身在印刷时存在±1度的裁切误差透视矫正只能矫正镜头的透视变形矫正不了印刷自身的歪斜。此时按聚类行切分会出现「上一题的 B 被裁到下一题区域」。原因聚类时row_thresh设置过小或者旋转残留没有完全矫正。解决对矫正后的图像再做一次霍夫直线检测找出最长的水平直线计算倾斜角旋转补偿后再做行聚类。edges cv2.Canny(warped, 50, 150) lines cv2.HoughLinesP(edges, 1, np.pi / 180, threshold100, minLineLength200, maxLineGap10)4.4 现象字符串和边框粘连导致多选误判学生涂卡时用力过猛涂痕超出选项框和相邻选项框的印刷线连在一起轮廓合并成一个大块被判定为多选。解决在findContours后对每个小格单独裁剪再做一次腐蚀操作断开粘连腐蚀核大小设为(3, 3)迭代次数不宜超过两次否则会把正常涂卡区域也腐蚀掉。4.5 现象灰度阈值把浅色涂痕当成有效答案答题卡在运输过程中压出的折痕或灰尘灰度值刚好落在阈值附近被判定为「已涂」。这种情况通过覆盖率阈值无法完全排除需要在轮廓筛选时额外检查面积和形状——折痕通常是细长条涂卡块是接近圆形或矩形的团块。计算轮廓的宽高比超过 3:1 的直接丢弃。5. 参数调节与扩展把判卷项目用到自己的答题卡上5.1 三个必调参数面积范围、覆盖率阈值、腐蚀迭代次数任何现成的排队判卷项目拿过来都不能直接跑生产环境必须先做参数适配。因为每张答题卡的印刷规格、每个摄像头的光学参数、每台打印机的墨量都不同。这三个参数决定了识别率的上限area_min/area_max选项外轮廓的面积范围。以 1920×1080 的摄像头拍摄一张 A4 答题卡为例单个选项格大约 70×70 像素对应的轮廓面积在 4000 上下。把area_min设为 1000、area_max设为 10000先跑一轮打印结果观察识别框有没有遗漏再缩小范围。调参方式不是改代码一次一次试而是把每张图的识别结果可视化输出只框出被选中作为有效轮廓的区域一眼就能看出过滤条件对不对。coverage_threshold涂卡覆盖率阈值。这个值最敏感低了会把橡皮擦痕当答案高了会把正常的轻涂判为空选。调它的标准流程是准备 10 张答题卡其中 5 张正常涂卡、5 张留白分别打印输出每格的覆盖率取两类覆盖率分布的正中值。2B 铅笔正常涂卡的覆盖率通常在 0.5 到 0.8 之间留白区域一般在 0.05 以下所以 0.3 到 0.4 是比较安全的落点。dilate_iter/erode_iter腐蚀膨胀的迭代次数。腐蚀用来断开粘连膨胀用来补全涂卡痕迹中的不连续笔触。经验法则是腐蚀迭代数加上膨胀迭代数不要超过 4否则小选项格会被完全吞掉。建议先腐蚀 1 次再膨胀 1 次观察识别率变化再决定是否增加。5.2 答题卡布局变更时改哪些坐标而不改逻辑拿到新设计的答题卡选项顺序、题数、题号排列都可能和原项目预设不一样。正确的做法是把布局参数提取成独立的配置文件而不是在代码里到处改魔法数字。给项目加一个config.py把题数、选项数、行聚类阈值、面积过滤范围、覆盖率阈值都放进去每次换答题卡只改配置文件。# config.py SHEET_LAYOUT { total_questions: 20, options_per_question: 4, rows: 5, cols: 4, row_thresh: 30, area_min: 1000, area_max: 10000, coverage_threshold: 0.35 }逻辑说明SHEET_LAYOUT里定义的rows和cols是题面网格的行列数如果答题卡是竖排题号需要在外部把读取后的结果做转置而不是重新写一遍判卷逻辑。area_min/area_max一定要跟着答题卡的实际分辨率走——同样一张 A4 卡你用 300dpi 扫描和用手机拍的轮廓面积能差 4 倍直接把原项目的值抄过来是跑不通的。5.3 从纸质卡到屏幕端的扩展方向这个项目做完答题卡识别后向两个方向扩展是成本最低的一是批量处理接口化把判卷逻辑包成一个grade_answer_sheet(image_path) - dict的函数再用 Flask 包一层 HTTP 接口就能对接小程序和内部 OA 系统老师手机拍照上传、系统自动判卷、返回成绩。二是从「判卷」扩展到「数据分析」判卷结果本身只是第一层真正对老师和机构有价值的是错题集中在哪些知识点、哪个班在哪道题上失分最多。from flask import Flask, request, jsonify app Flask(__name__) app.route(/grade, methods[POST]) def grade(): file request.files[image] file.save(upload.jpg) result grade_answer_sheet(upload.jpg) return jsonify(result)参数说明methods[POST]限制请求方法request.files[image]接收上传的图片文件保存到本地后交给判卷函数。这里临时文件处理比较粗糙生产环境应该用tempfile.NamedTemporaryFile配合finally清理避免磁盘膨胀。5.4 与考试阅卷系统的对接思路实际落地到学校或者培训机构时判卷项目一般只是整个阅卷流程的中间件。上游是学生信息下游是成绩管理系统。对接的关键是学号识别——在答题卡上印条形码在判卷流程里加入pyzbar解码条形码把学号和判卷结果绑定而不是靠人工录入。pyzbar 的识别率对图像清晰度要求不高但对透视矫正比较敏感所以要在warpPerspective之后、切分选项之前做解码。from pyzbar import pyzbar def read_barcode(warped): barcodes pyzbar.decode(warped) if barcodes: return barcodes[0].data.decode(utf-8) return None逻辑说明pyzbar.decode返回一个列表每一元素包含条形码的数据和边框位置取第一个结果即可。条形码解码失败时不要直接跳过整张卡应该把图片单独保存到待人工复核的目录同时打印日志提醒操作员处理。6. 给判卷项目加一行自检可视化中间结果和批量回归测试调试这类图像识别项目最怕的就是「参数改了一个数字不知道是变好了还是变坏了」。只靠肉眼盯屏幕上的一次识别结果完全不可靠正确的做法是做一个批量自检脚本准备 30 张标注了标准答案的答题卡图片每次修改代码后全量跑一遍计算识别准确率、误判率、空选率用数据验证改动是否有效。import os test_dir test_sheets correct_map {} # 文件名 - 正确答案字符串 total 0 correct 0 for fname in os.listdir(test_dir): if not fname.endswith(.jpg): continue result grade_answer_sheet(os.path.join(test_dir, fname)) expected correct_map[fname] total 1 if result[answer_string] expected: correct 1 print(f正确率: {correct}/{total} {correct / total:.2%})逻辑说明这段代码循环读取测试目录下所有图片调用判卷入口函数和预设答案比对并统计正确率。每次调参后跑一遍这个脚本比对着图片调肉眼要靠谱得多。正确率低于 95% 时不要继续往下做功能先回头查是哪个环节误判。除了统计数字我的习惯是让脚本把每次识别失败的具体图片文件名和错题号打出来方便快速定位共性原因——比如所有失败图片都是某个角度拍摄的说明透视矫正参数还有问题都是某一道题错可能是布局参数没配对。另外一个值得养成的习惯是把中间结果存盘。在透视矫正后、选项切分后各保存一张 PNG标注好文件名后缀。这样当最终判卷结果出错时能一眼看出是「图就没拍正」还是「切分切歪了」还是「判断涂卡阈值不对」不用回放整个流程。我自己调试这类项目时至少保存灰度图、透视矫正图、轮廓标注图三张中间结果最后出问题时的排错时间能缩短至少一半。识别判卷这个领域代码本身不难难点永远在「你手上的答题卡和别人的不一样」。不要期望下载一个 zip 跑通就完事花两三个小时调参数、写自检脚本换来的是一套能用一整个学期的稳定工具。希望帮到你。本文还有配套的精品资源点击获取
返回列表