简介本资源为基于计算机视觉的答题卡识别及判分系统完整毕业设计源码面向计算机相关专业学生及需要课程设计参考的开发者帮助解决人工阅卷效率低、主观误差大的问题。系统采用Python与OpenCV实现答题卡图像预处理、选择题选项识别、答案比对与自动评分并将成绩写入Excel配合MySQL与Navicat完成数据管理整体采用模块化设计便于二次开发与维护。压缩包共293个文件约15.36MB涵盖30个py源码、32个pyc编译文件、48个js脚本、26个css样式及html页面等前端资源另含sql建库脚本、docx文档与pptx演示材料结构完整。目前已有91人学习下载。读者可获得可直接运行的完整源码、数据库脚本与配套LW文档快速理解图像去噪、二值化、边缘检测到评分输出的全流程适合作为毕业设计或课程设计的基础方案并在此基础上扩展功能与优化算法。1. 从一张歪斜的答题卡说起这套 Python 源码到底能跑出什么答题卡识别这件事听起来像是计算机视觉里的入门练手题真上手做才发现坑比想象中多。我见过太多人拿着手机拍一张答题卡丢进 OpenCV 里跑轮廓检测结果因为光照不均、纸张倾斜、填涂太浅识别率直接掉到六成以下。这套基于 Python 的答题卡识别及判分系统源码解决的正是这个从「拍一张照片」到「输出一份 Excel 成绩单」的完整链路问题。它用 OpenCV 做图像预处理和选项识别用 MySQL 存成绩数据最终把判分结果写进 Excel适合正在做毕业设计、课程设计的学生也适合想快速搭一个答题卡识别原型的开发者。整套代码结构清晰模块化程度够用拿来改比从零写省事得多。2. 图像预处理链路从原始照片到可识别二值图2.1 为什么预处理决定了识别率的上限很多人拿到这套源码第一反应是直接看识别逻辑但我建议先啃预处理部分。答题卡识别的本质是把「人眼能看懂的填涂区域」翻译成「程序能读懂的坐标和灰度值」而这一步的成败几乎全压在预处理上。原始图像里有什么纸张可能歪了、光照可能一边亮一边暗、扫描件可能有噪点、填涂的铅笔痕迹可能深浅不一。如果这些不处理干净后面的轮廓检测和选项判定就是在一个噪声堆里找信号玄学得很。这套源码的预处理链路大致是灰度化 → 高斯模糊 → 自适应二值化 → 边缘检测 → 透视变换。每一步都有明确的参数可以调不是那种写死了没法改的代码。我拆的时候注意到它用的是自适应阈值而不是全局阈值这个选择很关键。全局阈值在光照均匀的扫描件上没问题但手机拍的照片基本都会翻车因为纸张不同区域的亮度差异可能超过 50 个灰度级。2.2 灰度化与高斯模糊的参数选择先看灰度化和模糊这两步。代码里用的是cv2.cvtColor转灰度然后cv2.GaussianBlur做平滑。高斯核的大小直接影响到后续边缘检测的灵敏度核太小噪点去不掉核太大边缘会被糊掉。import cv2 import numpy as np def preprocess_image(image_path): # 读取原始图像 img cv2.imread(image_path) if img is None: raise FileNotFoundError(f无法读取图像: {image_path}) # 转灰度把三通道降到单通道减少计算量 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊核大小(5,5)是经验值标准差设为0让OpenCV自动计算 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 自适应二值化blockSize11, C2 是这套源码的默认参数 binary cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2 ) return img, gray, blurred, binary这段代码里有两个参数值得展开说。GaussianBlur的核大小(5, 5)意味着对每个像素取周围 5×5 区域的加权平均标准差传 0 表示由 OpenCV 根据核大小自动推算。如果你的答题卡分辨率很高比如 300 DPI 扫描件可以适当加大到(7, 7)如果是手机拍的 1080P 照片(5, 5)够用。adaptiveThreshold里的blockSize11表示每个像素参考周围 11×11 区域的局部阈值C2是从计算出的阈值里减去的常数用来微调二值化的激进程度。C 越大越容易把浅色区域判为背景C 越小越容易把噪点判为前景。提示如果你的答题卡填涂用的是 2B 铅笔且涂得比较实C 可以设到 3 或 4如果是圆珠笔或钢笔C 设 1 到 2 更合适。2.3 边缘检测与透视变换的实操细节二值化之后是找答题卡的四个角点然后做透视变换把歪斜的卡片「拉正」。这一步是整套流程里最容易出问题的地方。源码里用的是cv2.findContours找轮廓然后按面积排序取最大的四边形轮廓。逻辑没问题但实际跑的时候经常遇到两个坑一是背景里如果有比答题卡更大的矩形比如桌子的边缘轮廓排序会选错二是答题卡边缘如果有阴影或磨损角点检测会偏。def find_card_contour(binary): # 找所有外轮廓 contours, _ cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 按面积从大到小排序 contours sorted(contours, keycv2.contourArea, reverseTrue) for c in contours[:5]: # 只看前5个最大的 # 计算轮廓周长 peri cv2.arcLength(c, True) # 多边形逼近epsilon0.02*peri 是常用比例 approx cv2.approxPolyDP(c, 0.02 * peri, True) # 如果逼近结果是四边形认为找到了答题卡 if len(approx) 4: return approx return None def four_point_transform(image, pts): # 把四个角点整理成左上、右上、右下、左下的顺序 rect order_points(pts.reshape(4, 2)) (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 warpedapproxPolyDP里的epsilon0.02*peri控制多边形逼近的精度。这个值越小逼近越贴近原始轮廓但可能逼近出超过 4 个顶点越大则越容易简化成四边形但角点位置会偏。0.02 是一个比较稳的中间值。order_points函数负责把四个角点按左上、右上、右下、左下的顺序排列这个顺序不能乱否则透视变换会把图像翻转或扭曲。我一般会在order_points里加一步校验计算四个点的 xy 和 x-y分别取最小和最大来确定左上和右下再用 y-x 确定右上和左下。透视变换之后答题卡就被拉成了一个规整的矩形后续的选项定位和识别都在这个矩形上做坐标计算会简单很多。3. 选项识别与判分逻辑从填涂区域到成绩单3.1 答题卡结构定位的两种思路透视变换之后下一步是找到每个题目的选项区域。这套源码用的是「固定坐标映射」的思路假设答题卡是标准模板每个题号的 A、B、C、D 选项在变换后的图像里有固定的相对坐标。这种做法的好处是简单直接不需要做额外的模板匹配缺点是如果答题卡模板换了坐标就得重新标定。另一种常见做法是「轮廓检测 排序」先找到所有填涂区域的轮廓然后按位置排序再映射到题号和选项。这种做法适应性更强但对图像质量要求更高如果填涂区域粘连或者有噪点排序就容易乱。源码选了前者我理解是为了降低毕业设计的调试难度毕竟固定坐标映射的代码量少、可控性强。def get_option_regions(warped_img, num_questions20, num_options4): 根据标准答题卡模板计算每个选项的区域坐标 假设答题卡上方有标题区域下方有学生信息区域 h, w warped_img.shape[:2] # 定义答题区域的边界这些比例需要根据实际模板调整 start_y int(h * 0.25) # 答题区从25%高度开始 end_y int(h * 0.90) # 到90%高度结束 start_x int(w * 0.10) # 左边距10% end_x int(w * 0.90) # 右边距10% question_height (end_y - start_y) / num_questions option_width (end_x - start_x) / num_options regions [] for q in range(num_questions): q_regions [] for o in range(num_options): x1 int(start_x o * option_width) y1 int(start_y q * question_height) x2 int(x1 option_width) y2 int(y1 question_height) q_regions.append((x1, y1, x2, y2)) regions.append(q_regions) return regions这段代码里的比例参数0.25、0.90、0.10、0.90是这套源码的默认值实际使用时需要根据你的答题卡模板微调。我建议的做法是先用一张空白答题卡跑一遍把每个选项区域的矩形画出来肉眼确认覆盖到位了再跑识别。这个步骤花五分钟能省掉后面半小时的排查时间。3.2 填涂判定的阈值策略拿到每个选项的区域之后判定「有没有涂」的逻辑其实很简单统计区域内非零像素的比例。二值化之后填涂区域是白色255背景是黑色0所以非零像素占比越高说明涂得越实。def detect_answer(binary_warped, regions, threshold0.3): 检测每道题的作答选项 threshold: 非零像素占比超过这个值就认为该选项被填涂 answers [] for q_idx, q_regions in enumerate(regions): fill_ratios [] for (x1, y1, x2, y2) in q_regions: # 裁剪出选项区域 roi binary_warped[y1:y2, x1:x2] # 计算非零像素占比 ratio cv2.countNonZero(roi) / roi.size fill_ratios.append(ratio) # 找出占比最高的选项 max_ratio max(fill_ratios) if max_ratio threshold: answers.append(None) # 未作答 else: selected fill_ratios.index(max_ratio) answers.append(selected) # 0A, 1B, 2C, 3D return answersthreshold0.3这个值是整套识别逻辑里最需要根据实际情况调整的参数。涂得实的 2B 铅笔非零像素占比可能到 0.5 以上涂得浅的可能只有 0.15 到 0.2。如果阈值设太高浅涂的会被判为未作答设太低噪点或印刷痕迹可能被误判为填涂。我的经验是先跑一批样本把每道题每个选项的 fill_ratio 打印出来观察「已涂」和「未涂」的分布然后取两者之间的中间值。如果分布有重叠说明预处理环节还需要优化。注意如果发现某道题的多个选项 fill_ratio 都超过阈值说明选项区域可能有重叠需要检查get_option_regions里的坐标计算是否准确。3.3 判分与 Excel 输出识别出每道题的作答选项之后判分就是简单的字符串比较。源码里把标准答案存在一个列表里逐题对比统计正确数然后按每题分值算总分。import openpyxl from datetime import datetime def grade_and_export(student_id, detected_answers, answer_key, points_per_question5, output_filescores.xlsx): 判分并将结果写入Excel correct_count 0 details [] for i, (detected, correct) in enumerate(zip(detected_answers, answer_key)): is_correct (detected correct) if is_correct: correct_count 1 details.append({ 题号: i 1, 作答: chr(65 detected) if detected is not None else 未答, 正确答案: chr(65 correct), 是否正确: 是 if is_correct else 否 }) total_score correct_count * points_per_question # 写入Excel try: wb openpyxl.load_workbook(output_file) except FileNotFoundError: wb openpyxl.Workbook() ws wb.active ws.append([学号, 得分, 正确题数, 判分时间]) ws wb.active ws.append([student_id, total_score, correct_count, datetime.now().strftime(%Y-%m-%d %H:%M:%S)]) wb.save(output_file) return total_score, details这段代码里有一个细节值得注意openpyxl.load_workbook放在try块里如果文件不存在就新建一个并写入表头。这样多次运行判分时成绩会追加到同一个 Excel 文件里不会覆盖之前的数据。points_per_question5是每题分值可以根据实际考试调整。如果不同题目分值不同可以把answer_key改成一个包含分值的字典列表。MySQL 数据库在这套源码里的角色是存储学生信息和成绩记录。建表语句大致是学生表学号、姓名、班级和成绩表学号、总分、正确题数、判分时间通过学号关联。Navicat11 用来做数据库的可视化管理建表和查数据都比较方便。如果你只是跑通识别流程数据库这部分可以先跳过等识别稳定了再接。4. 避坑与排查那些让识别率骤降的细节4.1 现象识别结果整体偏移所有题目都判错原因透视变换后的图像尺寸和模板假设的尺寸不一致导致get_option_regions计算出的坐标全部偏移。源码里默认变换后的图像宽度和高度是根据角点距离算出来的如果角点检测有偏差变换后的尺寸就会变而选项区域的比例参数是固定的。解决在透视变换之后加一步cv2.resize把图像强制缩放到一个固定尺寸比如 800×1200然后再用比例参数计算选项区域。这样无论原始图像多大变换后的坐标系都是一致的。4.2 现象部分题目识别为「未作答」但实际涂了原因填涂太浅二值化之后非零像素占比低于threshold。或者是选项区域的坐标偏了裁出来的 ROI 没有覆盖到填涂痕迹。解决先把threshold从 0.3 降到 0.15 试试如果还是不行就把该题所有选项的 fill_ratio 打印出来看看是整体偏低还是某个选项异常。整体偏低说明二值化参数需要调某个选项异常说明坐标偏了。4.3 现象同一道题识别出多个填涂选项原因选项区域之间有重叠或者答题卡上该题位置有印刷污点、折痕。也有可能是get_option_regions里的option_width计算有误导致相邻选项的 ROI 互相覆盖。解决检查start_x、end_x和option_width的计算确保四个选项的 ROI 不重叠。可以在调试模式下把每个 ROI 的矩形画到图像上肉眼确认边界。4.4 现象程序跑几张图就崩溃报内存错误原因cv2.findContours在处理高分辨率图像时会消耗大量内存如果循环里没有及时释放中间变量内存会持续增长。另外如果图像读取失败返回None后续操作会直接抛异常。解决在每次循环结束时用del释放不再使用的图像变量或者把处理逻辑封装成函数让局部变量随函数返回自动回收。图像读取后加一步if img is None的判断避免对空对象做操作。4.5 现象Excel 文件写入后打不开提示文件损坏原因openpyxl在写入时如果程序异常退出文件可能处于不完整状态。另外如果多个进程同时写入同一个 Excel 文件也会导致文件损坏。解决写入时先写到临时文件成功后再替换目标文件。或者改用 CSV 格式输出CSV 的写入是追加模式不会出现文件损坏的问题。如果必须用 Excel确保每次写入后都调用wb.save()和wb.close()。5. 进阶调优把识别率从 85% 推到 95% 以上这套源码跑通之后默认参数下的识别率大概在 80% 到 90% 之间取决于答题卡质量和拍摄条件。如果你想让识别率再上一个台阶有几个方向可以试。第一个方向是动态阈值。现在的threshold0.3是全局固定的但不同题目的填涂深浅可能不一样。我一般会改成自适应阈值先计算所有题目所有选项的 fill_ratio取最大值和最小值然后用(max min) / 2作为动态阈值。如果某道题的所有选项 fill_ratio 都低于这个动态阈值就判为未作答。这样比固定阈值灵活得多。第二个方向是角点检测的鲁棒性。approxPolyDP在答题卡边缘有磨损或阴影时容易检测失败。可以加一个降级策略如果找不到四边形轮廓就退而求其次用cv2.minAreaRect找最小外接矩形然后取矩形的四个顶点做透视变换。虽然精度略低但至少不会直接报错。第三个方向是填涂区域的形态学处理。二值化之后填涂区域可能会有一些小孔洞或毛刺影响 fill_ratio 的计算。加一步cv2.morphologyEx做闭运算把孔洞填上再算 fill_ratio结果会更稳定。def adaptive_threshold_detection(binary_warped, regions): 自适应阈值版本根据所有选项的fill_ratio分布动态确定阈值 all_ratios [] for q_regions in regions: for (x1, y1, x2, y2) in q_regions: roi binary_warped[y1:y2, x1:x2] # 闭运算填充孔洞 kernel np.ones((3, 3), np.uint8) roi_closed cv2.morphologyEx(roi, cv2.MORPH_CLOSE, kernel) ratio cv2.countNonZero(roi_closed) / roi_closed.size all_ratios.append(ratio) # 动态阈值取最大值和最小值的中间偏下位置 max_ratio max(all_ratios) min_ratio min(all_ratios) dynamic_threshold min_ratio (max_ratio - min_ratio) * 0.4 answers [] for q_idx, q_regions in enumerate(regions): fill_ratios [] for (x1, y1, x2, y2) in q_regions: roi binary_warped[y1:y2, x1:x2] kernel np.ones((3, 3), np.uint8) roi_closed cv2.morphologyEx(roi, cv2.MORPH_CLOSE, kernel) ratio cv2.countNonZero(roi_closed) / roi_closed.size fill_ratios.append(ratio) max_fill max(fill_ratios) if max_fill dynamic_threshold: answers.append(None) else: answers.append(fill_ratios.index(max_fill)) return answers这段代码里的0.4是一个经验系数表示动态阈值取在最小值到最大值区间的 40% 位置。这个系数可以根据你的答题卡填涂习惯调整如果填涂普遍较浅可以降到 0.3如果填涂普遍较实可以升到 0.5。闭运算的核大小(3, 3)适合大多数情况如果填涂区域孔洞较大可以加大到(5, 5)。还有一个容易被忽略的点是图像分辨率。源码默认处理的是原始分辨率的图像但如果你的答题卡是 300 DPI 扫描的图像可能有 2000×3000 像素处理速度会很慢。我一般会在预处理阶段加一步缩放把图像的最长边限制在 1200 像素以内识别率基本不受影响但速度能快三到五倍。从那以后我每次拿到新的答题卡模板都会先用一张空白卡跑一遍坐标可视化确认每个选项区域都覆盖到位了再跑识别。这个习惯帮我省掉了无数次「识别结果整体偏移」的排查时间。希望帮到你。本文还有配套的精品资源点击获取