
简介这份源码包面向计算机、数学、电子信息等专业的学生与开发者聚焦答题卡自动识别与批改场景可用于课程设计、期末大作业、毕设项目或初期项目立项演示。项目基于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-python4.8.1.78 pip install numpy1.24.3 pip install Pillow10.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, interpolationcv2.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, keycv2.contourArea, reverseTrue)[: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]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(img, M, (width, height)) return warped def order_points(pts): # 按左上、右上、右下、左下排序 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) 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, encodingutf-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(keylambda 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:yh, x:xw] 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: i1, student: sa, standard: sta, result: 正确}) else: details.append({q: i1, 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, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[filename, student_id, score, answers]) writer.writeheader() writer.writerows(results)注意encodingutf-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, encodingutf-8) as f: json.dump({questions: questions}, f, ensure_asciiFalse, indent2) print(f模板已保存到 {output_json}共 {len(questions)} 道题)用法很简单运行脚本在弹出的窗口里点每道题的左上角和右下角程序自动记录坐标并画框。所有题点完后按任意键退出模板 JSON 就生成好了。这个工具帮我省了大量手工量坐标的时间而且不容易出错。另一个进阶技巧是给识别结果加置信度。每道题的填充率最大值就是置信度如果置信度在 0.3 到 0.5 之间标记为“低置信度”在输出 CSV 里加一列让老师优先复核这些题。这样既不会因为阈值太高导致大量误判也不会因为阈值太低放过可疑答案。我现在的习惯是每次拿到新的答题卡版式先用这个工具生成模板然后拿五张不同学生填的卡跑一遍人工核对识别结果。如果五张里有一张出错就调阈值或改坐标直到五张全对再批量处理。这个习惯让我避免了很多次全班成绩错乱的尴尬。希望帮到你。本文还有配套的精品资源点击获取