简介:这份源码包面向计算机、数学、电子信息等专业的学生与开发者,聚焦答题卡自动识别与批改场景,可用于课程设计、期末大作业、毕设项目或初期项目立项演示。项目基于Python实现答题卡检测、试题切分、学生考号识别与选择题自动批改,涵盖图像预处理、轮廓定位、OCR识别与选项判定等关键环节,适合具备一定Python与OpenCV基础、希望深入理解图像识别流程的读者参考实践。资源共35个文件,包含8个py源码文件、2个ipynb实验笔记、11张jpg与10张png示例图片,以及txt依赖说明、md文档、Dockerfile和gitignore等配置,压缩包约6.04MB,目录结构清晰,便于按模块查阅。目前已有238人学习下载。读者可获取完整可运行源码、示例答题卡与检测结果图、Jupyter实验笔记及依赖配置,快速复现检测与批改流程,并在此基础上调试扩展。
1. 答题卡自动批改:从一张手机照片到成绩单,中间到底要跨几道坎
你拍了一张答题卡照片,想直接得到每个学生的分数。这件事听起来简单,做起来要跨四道坎:找到答题卡的四角并把它拉正、把整张卡切成一道道试题区域、认出每个学生的考号、判断每道选择题涂了哪个选项。任何一道坎翻车,后面的结果全是错的。这套基于 Python 的答题卡检测、试题切分、考号识别、选择题自动批改方案,解决的就是这条完整链路。它适合学校信息老师、培训机构技术负责人、想用 OpenCV 做教育工具的开发者。你不需要深度学习服务器,一台普通笔记本加一个摄像头就能跑通最小闭环。下面按实际落地顺序拆开讲,每一步都给可复现的命令和参数。
2. 答题卡检测与透视校正:把歪着拍的照片拉正
答题卡自动批改的第一步不是识别,而是把照片变成一张规整的、正面拍摄的图像。学生交上来的照片可能是斜着拍的、有阴影的、背景里还有课桌和笔袋。如果直接在这种图上做切分和识别,后面所有步骤的准确率都会崩。常见做法是:先转灰度、做高斯模糊去噪、用 Canny 找边缘、再用轮廓检测找出答题卡的外框,最后用透视变换把它拉成标准矩形。
2.1 环境准备与最小依赖
我一般用 Python 3.8 以上版本,核心库只有三个:OpenCV、NumPy、Pillow。安装命令如下,注意 opencv-python 和 opencv-contrib-python 不要同时装,否则容易冲突。
pip install opencv-python==4.8.1.78 pip install numpy==1.24.3 pip install Pillow==10.0.1如果你用的是 VS Code,记得在设置里把 Python 解释器指向你刚装好库的那个环境。很多新手卡在“明明装了 cv2 但 import 报错”,九成是解释器选错了。装完后跑一句python -c "import cv2; print(cv2.__version__)",能打印出版本号才算环境通了。
2.2 答题卡检测的四个关键步骤
下面这段代码是我在实际项目里反复调参后留下的版本,能处理大多数手机拍摄的答题卡。核心思路是:先缩小图像加速处理,再找最大四边形轮廓,最后做透视变换。
import cv2 import numpy as np def find_answer_sheet(image_path): # 读取图像并缩小,加快处理速度 img = cv2.imread(image_path) ratio = img.shape[0] / 800.0 dim = (int(img.shape[1] / ratio), 800) img_resized = cv2.resize(img, dim, interpolation=cv2.INTER_AREA) # 转灰度、高斯模糊、Canny 边缘检测 gray = cv2.cvtColor(img_resized, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blurred, 75, 200) # 找轮廓,按面积降序排列 contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5] sheet_contour = None for c in contours: # 用多边形逼近,判断是否为四边形 peri = cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) == 4: sheet_contour = approx break if sheet_contour is None: raise ValueError("未检测到答题卡轮廓,请检查背景是否干净") # 透视变换:把四边形拉成标准矩形 pts = sheet_contour.reshape(4, 2) * ratio rect = order_points(pts) (tl, tr, br, bl) = rect width = max(int(np.linalg.norm(br - bl)), int(np.linalg.norm(tr - tl))) height = max(int(np.linalg.norm(tr - br)), int(np.linalg.norm(tl - bl))) dst = np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(img, M, (width, height)) return warped def order_points(pts): # 按左上、右上、右下、左下排序 rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] rect[2] = pts[np.argmax(s)] diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] rect[3] = pts[np.argmax(diff)] return rect这段代码里最关键的参数是 Canny 的 75 和 200。75 是低阈值,200 是高阈值。如果答题卡边缘比较淡,把低阈值降到 50;如果背景杂物多,把高阈值提到 250。approxPolyDP的 0.02 是逼近精度,值越小越严格,四边形越难被识别;值越大越宽松,但可能把不规则形状也当成答题卡。我一般从 0.02 开始试,不行再调到 0.03 或 0.015。
2.3 透视变换后的验证方法
拉正之后不要急着往下走,先看一眼结果。我习惯把 warped 图像存下来,用肉眼确认四角是否对齐、有没有把桌面也拉进来。如果发现拉正后的图像上下颠倒,说明 order_points 里的排序逻辑被旋转角度干扰了,可以在排序前先判断图像长宽比,或者直接用cv2.rotate做一次 180 度旋转。另一个常见问题是答题卡边缘有阴影,导致 Canny 检测到的是阴影边界而不是纸张边界。解决办法是在高斯模糊之前先做一次自适应直方图均衡化,或者用形态学闭运算把边缘连起来。
3. 试题切分:把整张答题卡拆成一道一道题
答题卡拉正之后,下一步是把它切成一个个试题区域。切分的依据通常是答题卡上的定位黑块或表格线。常见做法有两种:一种是基于固定模板的坐标切分,适合答题卡版式固定的场景;另一种是基于轮廓检测的动态切分,适合版式有轻微变化的场景。我一般先用模板坐标切分,因为速度快、可控性强,只有在版式变化大时才上动态方法。
3.1 基于模板坐标的切分脚本
假设答题卡设计时已经规定了每道题的区域坐标,比如第 1 题在 (100, 200) 到 (300, 250) 之间。你可以把这些坐标写成一个 JSON 配置文件,切分时直接读取。
import cv2 import json def split_questions(warped_img, template_path): with open(template_path, 'r', encoding='utf-8') as f: template = json.load(f) questions = [] for q in template['questions']: x1, y1, x2, y2 = q['x1'], q['y1'], q['x2'], q['y2'] roi = warped_img[y1:y2, x1:x2] questions.append({ 'id': q['id'], 'image': roi, 'options': q['options'] }) return questions模板 JSON 的结构大概长这样:
{ "questions": [ {"id": 1, "x1": 100, "y1": 200, "x2": 300, "y2": 250, "options": ["A", "B", "C", "D"]}, {"id": 2, "x1": 100, "y1": 260, "x2": 300, "y2": 310, "options": ["A", "B", "C", "D"]} ] }坐标怎么来?我一般先用 OpenCV 把拉正后的答题卡显示出来,用鼠标事件打印点击位置的坐标,或者用画图工具量。注意坐标是相对于拉正后图像的,不是原始照片。如果答题卡有多个版本,每个版本存一份模板 JSON,切分时根据考号或页面标记选择对应模板。
3.2 动态轮廓切分的兜底方案
如果答题卡版式不固定,或者你拿不到精确坐标,可以用轮廓检测找每道题的边框。思路是:在拉正后的图像上做二值化,找矩形轮廓,按从上到下、从左到右排序,再根据面积过滤掉噪点。
def split_by_contours(warped_img): gray = cv2.cvtColor(warped_img, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for c in contours: x, y, w, h = cv2.boundingRect(c) # 过滤太小或太大的区域 if 50 < w < 400 and 20 < h < 100: boxes.append((x, y, w, h)) # 按 y 坐标排序,再按 x 坐标排序 boxes.sort(key=lambda b: (b[1] // 20, b[0])) return boxes这个方法的坑在于:答题卡上的涂卡点本身也是小矩形,容易被误切成题目。解决办法是加一个面积下限,或者用形态学操作把涂卡点腐蚀掉。我一般会把二值化后的图像先做一次开运算,去掉细小的噪点,再做轮廓检测。
3.3 切分结果的校验
切分完之后,我习惯把每道题的 ROI 拼成一张大图,快速扫一眼有没有切歪或漏切。如果发现某道题的区域包含了相邻题的涂卡点,说明坐标偏了,需要微调模板。另一个常见问题是图像旋转导致的坐标偏移,这时候要么重新做透视校正,要么在切分前加一次基于文本行的倾斜校正。
4. 考号识别:从涂卡点里读出学生身份
考号识别是整条链路里最容易翻车的一环。学生涂卡不规范、涂得太轻、涂出格、或者用铅笔涂完又擦掉,都会导致识别错误。常见做法是:先定位考号区域,再检测每个涂卡点的填充率,最后把填充率超过阈值的位映射成数字。
4.1 考号区域的定位与二值化
考号区域通常在答题卡顶部,由若干列涂卡点组成。假设考号是 8 位数字,每位有 0-9 十个涂卡点,那么考号区域就是一个 8 列 10 行的矩阵。定位方法可以用模板坐标,也可以用轮廓检测找矩形区域。
def locate_id_region(warped_img, template): x1, y1, x2, y2 = template['id_region'] id_region = warped_img[y1:y2, x1:x2] gray = cv2.cvtColor(id_region, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) return binary二值化之后,涂过的点应该是白色(前景),没涂的是黑色。但实际图像里,印刷的黑色边框也会变成白色,所以需要先去掉边框。我一般用形态学开运算去掉细线,或者用轮廓面积过滤掉边框。
4.2 填充率计算与阈值选择
每个涂卡点是一个小矩形,计算它的填充率就是统计白色像素占整个矩形面积的比例。
def calc_fill_ratio(binary, x, y, w, h): roi = binary[y:y+h, x:x+w] white_pixels = cv2.countNonZero(roi) total_pixels = w * h return white_pixels / total_pixels def recognize_id(binary, id_template): digits = [] for col in range(id_template['digits']): best_digit = -1 best_ratio = 0 for row in range(10): x = id_template['start_x'] + col * id_template['col_step'] y = id_template['start_y'] + row * id_template['row_step'] ratio = calc_fill_ratio(binary, x, y, id_template['bubble_w'], id_template['bubble_h']) if ratio > best_ratio: best_ratio = ratio best_digit = row # 填充率太低说明这一位没涂或涂得太轻 if best_ratio < 0.3: digits.append('?') else: digits.append(str(best_digit)) return ''.join(digits)阈值 0.3 是血泪经验。低于 0.3 基本可以认为没涂或者涂得太轻,高于 0.3 但低于 0.5 的,我一般会标记为“可疑”,让老师人工复核。如果学生用 2B 铅笔涂得很实,填充率通常能到 0.7 以上。如果用的是 HB 铅笔,可能只有 0.4 左右。所以阈值要根据实际用笔情况调整,不能一刀切。
4.3 多涂和漏涂的处理
一个考号位涂了多个选项怎么办?我的做法是:如果最高填充率和第二高填充率的差值小于 0.15,就判定为多涂,返回“?”并记录位置。漏涂就是所有填充率都低于 0.3,同样返回“?”。这些异常考号会在最后输出时单独列出来,提醒老师人工处理。不要试图用算法强行猜一个数字,考号错了后面所有成绩都挂到别人头上,这个后悔药没地方买。
5. 选择题自动批改:从涂卡点到分数
选择题批改的逻辑很简单:每道题有四个选项 A、B、C、D,学生涂了哪个,就和标准答案比对。但实际做起来,坑比想象的多。涂卡点定位不准、阈值设得不对、标准答案格式不统一,都会导致批改结果出错。
5.1 单题识别与答案映射
每道题的 ROI 切出来之后,里面通常有四个涂卡点,排成一行或一列。识别方法和考号识别一样,算填充率,取最大值对应的选项。
def recognize_answer(roi, options=['A', 'B', 'C', 'D']): gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) h, w = binary.shape bubble_w = w // len(options) ratios = [] for i in range(len(options)): x = i * bubble_w ratio = calc_fill_ratio(binary, x, 0, bubble_w, h) ratios.append(ratio) max_ratio = max(ratios) if max_ratio < 0.3: return '?' # 未涂或涂得太轻 if sorted(ratios)[-1] - sorted(ratios)[-2] < 0.15: return '!' # 多涂,需要人工复核 return options[ratios.index(max_ratio)]这里返回的 '?' 和 '!' 是两种异常标记。'?' 表示没涂或涂得太轻,'!' 表示多涂。批改时这两种都算错,但会在报告里单独标出来,方便老师核对。
5.2 标准答案比对与成绩统计
标准答案我一般存成一个列表,比如['A', 'B', 'C', 'D', 'A'],和学生答案逐题比对。
def grade_answers(student_answers, standard_answers): correct = 0 details = [] for i, (sa, sta) in enumerate(zip(student_answers, standard_answers)): if sa == sta: correct += 1 details.append({'q': i+1, 'student': sa, 'standard': sta, 'result': '正确'}) else: details.append({'q': i+1, 'student': sa, 'standard': sta, 'result': '错误'}) score = correct / len(standard_answers) * 100 return score, details如果学生答案里出现 '?' 或 '!',在 details 里把 result 写成“异常-未涂”或“异常-多涂”,方便后续统计。成绩统计不要只算总分,还要算每道题的正确率,这样老师能看出哪些题全班都错了,可能是教学重点没讲透。
5.3 批量处理与结果导出
实际使用时不会只处理一张答题卡,而是一个班几十张。批量处理的逻辑是:遍历文件夹里的所有图片,对每张图跑一遍完整流程,最后把结果写进 CSV。
import os import csv def batch_process(image_dir, template_path, standard_answers, output_csv): results = [] for filename in os.listdir(image_dir): if not filename.lower().endswith(('.jpg', '.png', '.jpeg')): continue image_path = os.path.join(image_dir, filename) try: warped = find_answer_sheet(image_path) questions = split_questions(warped, template_path) student_id = recognize_id(warped, template_path) answers = [recognize_answer(q['image']) for q in questions] score, details = grade_answers(answers, standard_answers) results.append({ 'filename': filename, 'student_id': student_id, 'score': score, 'answers': ''.join(answers) }) except Exception as e: results.append({ 'filename': filename, 'student_id': 'ERROR', 'score': 0, 'answers': str(e) }) with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=['filename', 'student_id', 'score', 'answers']) writer.writeheader() writer.writerows(results)注意encoding='utf-8-sig',这样 Excel 打开 CSV 不会乱码。如果某个文件处理失败,不要直接跳过,把错误信息写进结果里,方便排查是哪张图出了问题。
6. 避坑与排查:那些让我熬夜的翻车现场
这套流程我前后迭代了十几个版本,踩过的坑比写过的代码还多。下面挑五个最典型的,按“现象 → 原因 → 解决”写清楚。
6.1 检测不到答题卡轮廓
现象:find_answer_sheet抛出“未检测到答题卡轮廓”,或者检测到的轮廓明显不是答题卡。
原因:背景太乱、光照不均、答题卡边缘和桌面颜色接近,导致 Canny 找不到完整边缘。
解决:先做自适应直方图均衡化,再调低 Canny 低阈值到 50,高阈值到 150。如果还不行,改用 HSV 颜色空间做纸张分割,因为答题卡通常是白色或浅色,和桌面颜色差异明显。
6.2 透视变换后图像扭曲
现象:拉正后的答题卡看起来被拉伸或压缩,题目区域变形。
原因:order_points排序错误,把右上角当成了左上角,导致透视矩阵算错。
解决:在排序前先判断四个点的相对位置,或者用cv2.minAreaRect先找最小外接矩形,再根据角度旋转。另一个办法是限制透视变换后的宽高比,如果宽高比和实际答题卡差异太大,就拒绝这次变换,让用户重拍。
6.3 考号识别全错或大部分是问号
现象:识别出来的考号全是 '?',或者数字完全不对。
原因:考号区域的坐标偏了,或者二值化阈值不对,导致涂卡点没被正确提取。
解决:先把考号区域的二值化图像存下来看一眼,确认涂卡点是否清晰。如果涂卡点很淡,把二值化方法从 OTSU 改成自适应阈值。如果坐标偏了,用鼠标事件重新标定考号区域的起始坐标和行列步长。
6.4 选择题多涂误判
现象:学生只涂了一个选项,但识别结果返回 '!',提示多涂。
原因:相邻涂卡点的填充率差值小于 0.15,被误判为多涂。这通常是因为涂卡点定位不准,或者涂卡点之间有印刷污渍。
解决:把差值阈值从 0.15 降到 0.1,同时检查涂卡点坐标是否准确。如果涂卡点之间有污渍,在二值化后加一次形态学开运算,去掉小面积噪点。
6.5 批量处理时内存溢出
现象:处理到第几十张图时程序崩溃,报内存错误。
原因:每张图都保留在内存里,没有及时释放。OpenCV 的图像对象占内存较大,几十张 4000x3000 的照片就能把内存吃满。
解决:在每次循环结束时显式删除大对象,比如del warped, questions,并调用gc.collect()。另外,读图时可以用cv2.IMREAD_REDUCED_COLOR_2直接缩小读取,减少内存占用。
7. 进阶技巧:用答题卡模板自动生成切分坐标
手动标定每道题的坐标很痛苦,尤其是答题卡有几十道题的时候。我后来写了一个小工具,用 OpenCV 的鼠标事件在拉正后的答题卡上点选每道题的区域,自动生成模板 JSON。这个技巧让我的模板制作时间从半小时缩短到五分钟。
import cv2 import json def generate_template(image_path, output_json): img = cv2.imread(image_path) questions = [] current = {} def mouse_callback(event, x, y, flags, param): nonlocal current if event == cv2.EVENT_LBUTTONDOWN: if 'x1' not in current: current['x1'] = x current['y1'] = y print(f"起点: ({x}, {y})") else: current['x2'] = x current['y2'] = y current['id'] = len(questions) + 1 current['options'] = ['A', 'B', 'C', 'D'] questions.append(current.copy()) print(f"第 {current['id']} 题: ({current['x1']}, {current['y1']}) -> ({x}, {y})") current = {} cv2.rectangle(img, (current.get('x1', 0), current.get('y1', 0)), (x, y), (0, 255, 0), 2) cv2.imshow('template', img) cv2.imshow('template', img) cv2.setMouseCallback('template', mouse_callback) cv2.waitKey(0) cv2.destroyAllWindows() with open(output_json, 'w', encoding='utf-8') as f: json.dump({'questions': questions}, f, ensure_ascii=False, indent=2) print(f"模板已保存到 {output_json},共 {len(questions)} 道题")用法很简单:运行脚本,在弹出的窗口里点每道题的左上角和右下角,程序自动记录坐标并画框。所有题点完后按任意键退出,模板 JSON 就生成好了。这个工具帮我省了大量手工量坐标的时间,而且不容易出错。
另一个进阶技巧是给识别结果加置信度。每道题的填充率最大值就是置信度,如果置信度在 0.3 到 0.5 之间,标记为“低置信度”,在输出 CSV 里加一列,让老师优先复核这些题。这样既不会因为阈值太高导致大量误判,也不会因为阈值太低放过可疑答案。
我现在的习惯是:每次拿到新的答题卡版式,先用这个工具生成模板,然后拿五张不同学生填的卡跑一遍,人工核对识别结果。如果五张里有一张出错,就调阈值或改坐标,直到五张全对再批量处理。这个习惯让我避免了很多次全班成绩错乱的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取