Python+OpenCV答题卡识别与自动批改源码实战:从扫描到判分

发布时间:2026/10/12 1:39:29

Python+OpenCV答题卡识别与自动批改源码实战:从扫描到判分 简介本资源为基于Python的答题卡检测与自动批改项目源码包面向计算机、数学、电子信息等专业学生及需要实战演练的开发者可用于课程设计、期末大作业或毕设项目。项目围绕答题卡图像处理展开涵盖答题卡定位检测、试题区域切分、学生考号识别以及选择题自动批改等核心功能并配有说明文档与示例图片便于理解整体流程与算法实现。压缩包共35个文件约6.04MB包含8个py脚本、2个ipynb笔记本、11张jpg与10张png示例图以及txt、md、Dockerfile等配置说明文件覆盖从代码运行到环境部署的完整环节。目前已有238人学习下载读者可获取完整源码、检测与识别模块实现、示例数据及运行说明适合作为算法实践与项目立项的参考素材。1. 从一张被涂改的答题卡说起这套 Python 源码到底能干什么上周帮教务处处理一批期末答题卡扫描仪吐出来 800 多张 A4 图像有几张学生用修正带涂了又改有几张考号填涂区被手汗蹭花。如果靠人工一张张对答案、录考号两个人得干到后半夜。这套基于 Python 的答题卡检测与自动批改源码解决的正是这个场景从扫描图像输入到答题卡定位、试题区域切分、学生考号识别、选择题比对判分整条链路都有可运行的代码。它适合正在做毕设的学生、需要快速搭一个阅卷原型的开发者以及想把纸质考试流程数字化的教务技术人员。技术栈是 Python OpenCV不依赖深度学习训练装好环境就能跑对硬件要求不高一台普通笔记本就能完成几百张卡的处理。下面我按实际拆包和跑通的顺序把这份资源讲透。2. 环境搭建与图像预处理让答题卡从背景里“站”出来拿到源码包后第一件事不是急着跑主程序而是把环境配好、把输入图像的质量控制住。答题卡识别这类任务成败往往在预处理阶段就决定了——图像歪了、光照不均、噪点太多后面所有算法都是在垃圾上做文章。2.1 依赖安装与目录结构确认源码包解压后常见做法是看到类似main.py、answer_card.py、utils/、images/、templates/这样的结构。先别改代码用pip把依赖装齐。我一般会先建一个虚拟环境避免和系统里的其他包打架。# 创建虚拟环境Python 3.8 及以上都兼容 python -m venv venv # 激活环境Windows venv\Scripts\activate # 激活环境macOS / Linux source venv/bin/activate # 安装核心依赖 pip install opencv-python numpy imutils这里opencv-python负责图像处理numpy做矩阵运算imutils是源码里常用来做透视变换和轮廓排序的辅助库。如果源码里还引用了pandas或openpyxl来导出成绩一并装上。装完后用python -c import cv2; print(cv2.__version__)验证一下能打印版本号就说明环境通了。提示不要用pip install cv2这个包名在 PyPI 上是不存在的正确名称是opencv-python。这是新手最容易翻车的地方之一。2.2 答题卡定位与透视校正答题卡放在扫描仪里出来的图像往往带白边、有轻微旋转甚至因为纸张没放正而倾斜。源码里通常用“找最大轮廓 四点透视变换”来把答题卡摆正。核心思路是转灰度、高斯模糊、Canny 边缘检测、找轮廓、按面积排序取最大的四边形然后做透视变换。import cv2 import numpy as np 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 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 # 读取图像并预处理 image cv2.imread(images/sample.jpg) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edged cv2.Canny(blurred, 75, 200) # 找轮廓取面积最大的四边形 cnts, _ cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts sorted(cnts, keycv2.contourArea, reverseTrue) card_cnt None for c in cnts: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: card_cnt approx break warped four_point_transform(image, card_cnt.reshape(4, 2)) cv2.imwrite(output/warped.jpg, warped)order_points的作用是把任意顺序的四个顶点整理成固定的左上、右上、右下、左下这样透视变换才不会把图像翻转。four_point_transform里的maxWidth和maxHeight是根据四边形边长算出来的目标尺寸保证变换后不变形。Canny 的阈值75和200是经验值如果扫描图像偏暗可以降到50和150如果噪点多先把高斯核从(5,5)调到(7,7)。这一步跑完你会得到一张摆正的答题卡图像。如果card_cnt是None说明没找到四边形轮廓常见原因是背景太花或者答题卡边框太细。解决办法是换一张对比度更高的扫描图或者在 Canny 之前加一次自适应阈值处理。2.3 光照均衡与二值化透视校正之后图像可能还存在光照不均——左边亮右边暗直接二值化会把暗处的填涂块吞掉。源码里一般用自适应阈值或者先做一次 CLAHE限制对比度自适应直方图均衡化。# 转灰度 warped_gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # CLAHE 均衡化clipLimit 控制对比度增强幅度 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) equalized clahe.apply(warped_gray) # 自适应阈值二值化 thresh cv2.adaptiveThreshold(equalized, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) cv2.imwrite(output/thresh.jpg, thresh)clipLimit2.0是增强幅度的上限调太大会把噪点也放大tileGridSize(8,8)表示把图像分成 8×8 的块分别做均衡。自适应阈值的11是邻域大小必须是奇数2是从均值里减去的常数用来微调二值化松紧。如果发现填涂块被腐蚀得太细把11调到15或21如果背景噪点太多把2调到5以上。注意二值化后的图像应该是“填涂区域为白色背景为黑色”因为用了THRESH_BINARY_INV。如果你看到反过来的结果检查一下有没有漏掉_INV后缀。3. 试题区域切分与考号识别把整张卡拆成可判分的单元预处理做完接下来要把答题卡上的不同功能区拆开考号填涂区、选择题填涂区、主观题区域。源码里通常用固定坐标比例或者轮廓检测来定位这些区域。这一步的精度直接决定考号认不认得出、选择题判得对不对。3.1 基于比例坐标的区域切分大多数答题卡是标准模板各区域位置相对固定。源码里常见做法是在透视校正后的图像上按宽高比例划定矩形区域。比如考号区在顶部 15% 高度内选择题区在中间 60% 高度内。def split_regions(warped): h, w warped.shape[:2] regions {} # 考号区顶部 5% 到 18% 高度左右各留 10% 边距 regions[id_region] warped[int(h*0.05):int(h*0.18), int(w*0.10):int(w*0.90)] # 选择题区20% 到 80% 高度 regions[choice_region] warped[int(h*0.20):int(h*0.80), int(w*0.05):int(w*0.95)] # 主观题区80% 到 98% 高度 regions[subjective_region] warped[int(h*0.80):int(h*0.98), int(w*0.05):int(w*0.95)] return regions regions split_regions(warped) for name, img in regions.items(): cv2.imwrite(foutput/{name}.jpg, img)这里的比例参数0.05、0.18、0.10、0.90都是根据标准答题卡模板估的。如果你手上的答题卡版式不同需要拿一张空白卡对照着调。我一般会先把原图打印出来用尺子量出各区域的实际像素位置再换算成比例写进代码。调完之后把切出来的区域图存下来肉眼检查一遍确认没有把边框切进去或者把填涂块切掉一半。3.2 考号填涂识别从气泡检测到数字映射考号识别是这套源码里比较关键的一环。常见做法是在考号区内检测所有填涂气泡圆形或矩形按位置分组每组对应一位数字然后判断每组里哪个气泡被涂黑。def detect_bubbles(region_img, min_area50, max_area500): gray cv2.cvtColor(region_img, cv2.COLOR_BGR2GRAY) thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] cnts, _ cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) bubbles [] for c in cnts: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) area cv2.contourArea(c) # 气泡通常是接近正方形的轮廓 if min_area area max_area and 0.8 ar 1.2: bubbles.append((x, y, w, h)) return bubbles def group_bubbles_by_row(bubbles, row_tolerance10): # 按 y 坐标排序后分组同一行的气泡 y 值接近 bubbles sorted(bubbles, keylambda b: b[1]) rows [] current_row [bubbles[0]] for b in bubbles[1:]: if abs(b[1] - current_row[-1][1]) row_tolerance: current_row.append(b) else: rows.append(sorted(current_row, keylambda b: b[0])) current_row [b] rows.append(sorted(current_row, keylambda b: b[0])) return rows def read_student_id(id_region): bubbles detect_bubbles(id_region) rows group_bubbles_by_row(bubbles) student_id for row in rows: # 每行取填涂最深的那个气泡 filled None max_fill 0 for (x, y, w, h) in row: roi id_region[y:yh, x:xw] fill_ratio cv2.countNonZero(roi) / float(w * h) if fill_ratio max_fill: max_fill fill_ratio filled (x, y, w, h) if filled and max_fill 0.3: # 根据气泡在行内的序号映射到数字 0-9 idx row.index(filled) student_id str(idx) return student_iddetect_bubbles里的min_area和max_area用来过滤掉噪点和过大的边框轮廓0.8 ar 1.2保证只保留接近正方形的气泡。group_bubbles_by_row的row_tolerance10是行分组容差如果气泡在垂直方向有轻微偏移这个值可以适当放大到15。read_student_id里max_fill 0.3是判定“这个气泡被涂了”的阈值低于这个值就认为该位没填。提示如果考号识别结果全是 0 或者空字符串先检查detect_bubbles返回的气泡数量对不对。可以在detect_bubbles后面加一句print(len(bubbles))正常应该等于“位数 × 10”。数量不对就回去调二值化参数。3.3 选择题填涂判定与答案比对选择题区域的识别逻辑和考号类似区别在于每道题对应一组选项通常是 A/B/C/D 四个气泡需要判断哪个被涂黑然后和标准答案比对。def grade_choices(choice_region, answer_key): bubbles detect_bubbles(choice_region) rows group_bubbles_by_row(bubbles) results [] for i, row in enumerate(rows): if i len(answer_key): break max_fill 0 chosen None for j, (x, y, w, h) in enumerate(row): roi choice_region[y:yh, x:xw] fill_ratio cv2.countNonZero(roi) / float(w * h) if fill_ratio max_fill: max_fill fill_ratio chosen j # 选项序号 0/1/2/3 对应 A/B/C/D student_answer ABCD[chosen] if chosen is not None and max_fill 0.3 else correct answer_key[i] results.append({ question: i 1, student_answer: student_answer, correct_answer: correct, is_correct: student_answer correct }) return results # 示例标准答案 answer_key [A, B, C, D, A, B, C, D, A, B] results grade_choices(regions[choice_region], answer_key) for r in results: print(f第{r[question]}题: 学生选{r[student_answer]}, 正确答案{r[correct_answer]}, {正确 if r[is_correct] else 错误})answer_key是标准答案列表顺序对应题号。grade_choices返回每道题的作答和判分结果。max_fill 0.3这个阈值同样用于判断是否填涂。如果发现某道题学生明明涂了却判成空把阈值降到0.2如果出现一道题多个选项都被判为填涂说明气泡分组出了问题检查row_tolerance是不是太大导致相邻两题的气泡被分到同一行。注意这套逻辑假设每道题只有一个正确填涂。如果实际考试有多选题需要改判定逻辑把max_fill改成取所有超过阈值的选项。4. 避坑与排查那些让识别率从 95% 掉到 60% 的细节这套源码跑通不难难的是在实际扫描件上稳定输出。下面几条是我在调试过程中真实踩过的坑每条按“现象 → 原因 → 解决”整理。4.1 现象所有考号都识别成同一位数字原因气泡分组时row_tolerance设得太大把不同位的气泡合并到了一行导致每行取到的“最深气泡”永远是同一个位置。解决把row_tolerance从默认的10降到5或者在分组前先按 x 坐标做一次列分组再在每列内按 y 分组。更稳妥的做法是直接用答题卡模板的固定坐标去截取每个气泡而不是依赖轮廓检测。4.2 现象选择题判分结果整体偏移一位原因detect_bubbles把答题卡上的题号印刷数字也当成了气泡导致行数比实际题目数多后续题号和答案错位。解决在detect_bubbles里加一个面积上限把印刷数字的小轮廓过滤掉或者用cv2.contourArea结合轮廓的实心度area / (w*h)来区分填涂块和印刷字符。填涂块的实心度通常更高。4.3 现象扫描件稍微倾斜就找不到答题卡轮廓原因Canny 阈值固定倾斜后边缘断裂approxPolyDP拟合不出四边形。解决把 Canny 阈值改成自适应或者先用cv2.morphologyEx做一次闭运算把断裂的边缘连起来。另外approxPolyDP的0.02 * peri可以放宽到0.05 * peri容忍更多顶点。4.4 现象二值化后填涂块中间有空洞原因学生涂卡力度不均或者扫描仪曝光过度导致填涂区域中心反光。解决在二值化之后加一次形态学闭运算kernel用(3,3)或(5,5)把空洞填上。如果空洞太大闭运算也救不回来那就只能靠fill_ratio阈值下调来兜底。4.5 现象批量处理时程序跑几十张就卡死原因cv2.imread读进来的图像没有及时释放内存越积越多或者findContours在噪点图上返回了海量轮廓。解决每处理完一张图显式del掉大矩阵或者把处理逻辑封装成函数让局部变量自动回收。另外在findContours之前加一次中值滤波减少噪点轮廓数量。5. 批量处理与结果导出把单张调试变成流水线单张跑通之后真正的价值在于批量处理。我一般会把前面的函数串成一个process_card(image_path, answer_key)然后遍历整个扫描目录把结果写进 CSV。import os import csv def process_card(image_path, answer_key): image cv2.imread(image_path) if image is None: return None warped locate_and_warp(image) # 封装第 2 章的定位与校正 regions split_regions(warped) # 封装第 3 章的区域切分 student_id read_student_id(regions[id_region]) results grade_choices(regions[choice_region], answer_key) correct_count sum(1 for r in results if r[is_correct]) return { file: os.path.basename(image_path), student_id: student_id, total: len(results), correct: correct_count, score: round(correct_count / len(results) * 100, 1) if results else 0 } # 批量处理 scan_dir images/batch answer_key [A, B, C, D, A, B, C, D, A, B] all_results [] for fname in sorted(os.listdir(scan_dir)): if fname.lower().endswith((.jpg, .png, .jpeg)): path os.path.join(scan_dir, fname) result process_card(path, answer_key) if result: all_results.append(result) print(f处理完成: {result[file]} 考号:{result[student_id]} 得分:{result[score]}) # 导出 CSV with open(output/scores.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[file, student_id, total, correct, score]) writer.writeheader() writer.writerows(all_results)process_card把前面所有步骤串起来返回一个字典。encodingutf-8-sig是为了让 Excel 打开 CSV 时不乱码这个细节很多人会忽略。批量处理时建议先拿 10 张图跑一遍人工核对考号和得分确认无误再全量跑。提示如果扫描目录里有非答题卡的图片locate_and_warp可能返回None或者抛异常。在process_card里加一层try/except把失败的文件名记到单独的错误日志里不要让它中断整个批次。验证方法上我习惯抽 5% 的样本做人工复核重点看考号识别和边界情况涂改、多涂、漏涂。如果准确率低于 90%先别急着改算法回去检查扫描图像的分辨率和对比度——很多问题出在输入质量上而不是代码逻辑上。从那以后我每次拿到新的答题卡模板都会先跑一遍空白卡和一张故意涂错的卡确认区域切分和阈值判定都正常再上批量。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/12 1:39:29

1DCNN轴承故障诊断实战:从原始波形到混淆矩阵

简介:一份基于一维卷积神经网络(1DCNN)的轴承故障诊断深度学习源码实现,面向机械状态监测与深度学习交叉领域的工程师和学习者,围绕振动时序信号处理,利用卷积层沿时间轴滑动提取局部特征,经池化…

2026/10/12 1:34:29

ESP32 MicroPython入门:环境搭建、固件烧录与点灯实战

1. 拿到一块 ESP32 开发板之后,先搞清楚它到底能干什么很多人第一次拿到 ESP32 开发板的时候,心态是既兴奋又迷茫。兴奋的是这块小板子据说能连 WiFi、能跑蓝牙、价格还便宜到离谱;迷茫的是,打开包装盒之后,除了板子本…

2026/10/12 1:34:29

CAPL入门只需掌握三个核心函数:on start、on message与output

1. 为什么CAPL入门只需要盯住三个函数很多人第一次打开CANoe的CAPL浏览器,看到满屏的on start、on message、on timer、on key、on signal,第一反应是"这得学到什么时候"。我当初也一样,翻了两天帮助文档,结果连一个能跑…

2026/10/12 4:15:00

Java实现电商自动下单:接口层状态机、会话管理与避坑实战

简介:一个基于 Java 的京东自动下单工具,已在京东商城验证,面向双十一等促销活动需要快速抢购的用户,也适合想学习模拟登录、请求抓包、网页解析和自动化下单的 Java 开发者,还可作为二次开发的基础模板。压缩包共 33 …

2026/10/12 4:15:00

刚关门下架的小团队转头被苹果打包带走,背后的算盘藏不住了

刚关门下架的小团队转头被苹果打包带走,背后的算盘藏不住了 你可能很难想象,一家刚刚关门歇业、甚至把软件从各大应用商店下架删库的初创公司,转头就会被全球市值最高的科技巨头悄悄打包带走。 在布鲁塞尔欧盟委员会的一处公开监管备案数据库…

2026/10/12 4:15:00

SpringBoot+Vue陕西民俗网管理系统开发实践

一套基于SpringBootVue的陕西民俗网管理系统,用MyBatis做持久层、MySQL存数据,前后端分离,既能当毕业设计交差,也能真正跑起来做民俗文化展示和后台管理。这篇文章我会把项目从技术选型、数据库设计、核心功能实现,到部…

2026/10/12 4:15:00

兜里揣着千亿现金的苹果拟裁五千客服,机器接通后方案却被叫停

兜里揣着千亿现金的苹果拟裁五千客服,机器接通后方案却被叫停 如果你最近在美国或加拿大拨打过苹果的官方支持热线,接通那一刻,电话那头传来的很可能已经不是真人声音,而是一个能听懂日常口语、能分步骤教你排查手机故障的生成式人…

2026/10/12 4:10:00

100亿Token微调实战:从数据清洗到LoRA/QLoRA开源全流程

从立项到开源,这个项目整整花了我两个月时间,累计消耗的 Token 数超过了 100 亿。期间经历过数据质量不过关重来、训练中 loss 异常、评测结果不理想等多轮反复。这篇文章不打算只讲“我开源了一个模型”这样一个结果,而是把这套完整流程整理…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/12 0:04:22

绝缘子缺陷检测数据集清洗与工业级训练实战指南

简介:本资源是面向电力AI研发人员、工业视觉工程师及智能巡检系统开发者的绝缘子缺陷检测专用YOLO格式数据集,解决无人机航拍场景下绝缘子破损、污闪、积雪等9类典型缺陷的精准识别与定位难题。数据集共2139张真实巡检图像(含训练/验证/测试集…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑