☰
基于OpenCV的答题卡识别与判分系统源码解析:从图像预处理到MySQL成绩落库
2026/10/26 16:58:43 网站建设 项目流程

简介:本资源为基于计算机视觉的答题卡识别及判分系统完整源码,面向计算机相关专业学生与开发者,适用于毕业设计、课程设计及OpenCV图像处理入门实践。项目采用Python 3.6.8开发,结合MySQL 5.7与Navicat11管理数据,在PyCharm中运行,实现答题卡图像去噪、二值化、边缘检测等预处理,识别选择题选项,与标准答案比对后自动算分并存入Excel,整体采用模块化设计,便于二次开发与维护。压缩包共293个文件,约15.36MB,涵盖30个py源码、32个pyc编译文件、48个js脚本、26个css样式及29个png、75个gif等前端与文档资源,另附sql建表脚本、docx说明与pptx演示,结构完整。已有92人学习下载,适合希望掌握图像处理流程、评分逻辑与数据库存储方案的读者参考借鉴。

1. 从一堆 CSS 文件说起:这套答题卡识别源码到底能跑出什么结果

如果你下载过带「LW」后缀的毕业设计压缩包,大概率见过这种场面:解压之后根目录躺着一排bootstrap.css、bootstrap.min.css、layui.css、font-awesome.css,前端样式文件比 Python 源码还显眼。这套基于计算机视觉的答题卡识别及判分系统源代码就是典型代表——它用 Python 加 OpenCV 把一张手机拍的答题卡照片,经过灰度、二值化、轮廓检测、透视校正,定位出选择题的填涂区域,判断每个选项是否被涂黑,再和标准答案比对算分,最后把成绩写进 Excel。整套流程不依赖深度学习模型,纯靠传统图像处理,Python 3.6.8 + OpenCV + MySQL 5.7 就能跑起来,适合毕业设计、课程设计这类需要「完整可演示、代码能讲清楚」的场景。它解决的不是工业级高并发阅卷,而是让你在本地把「拍图→识别→判分→存表」这条链路完整走通,并且每一行代码你都能看懂、能改、能答辩时讲出原理。

2. 图像预处理链路:从拍照到定位答题卡区域

2.1 为什么先做灰度、高斯模糊和自适应二值化

答题卡识别的第一步不是识别,而是让计算机「看清」哪块区域是答题卡。手机拍出来的照片带透视变形、光照不均、纸张阴影,直接做边缘检测会得到一堆杂乱轮廓。常见做法是先把图像转灰度,再用高斯模糊压掉高频噪点,最后用自适应阈值二值化把填涂的黑块和纸张背景分开。这里选自适应而不是全局阈值,是因为拍照时光照不可能均匀,全局阈值会把暗处的纸面也判成黑色,导致后续轮廓全乱。

import cv2 import numpy as np def preprocess(image_path): # 读取原始图像 img = cv2.imread(image_path) # 转灰度,减少计算量并统一通道 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊,核大小取奇数,5x5 适合手机拍摄分辨率 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值二值化,blockSize 决定局部区域大小,C 是常数修正项 binary = cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2 ) return img, gray, binary

这段代码里blockSize=11表示每个像素参考周围 11×11 的区域来算阈值,数值越大对光照渐变越宽容但细节越模糊;C=2是从计算出的阈值里减去的常数,用来抑制噪声被误判为前景。THRESH_BINARY_INV让黑色填涂变成白色前景,方便后面找轮廓。如果你换一张分辨率更高的图,blockSize可以提到 15 或 21,但别超过图像短边的十分之一,否则局部自适应就退化成全局阈值了。

2.2 轮廓检测与四点透视校正

二值化之后,答题卡外框会变成一个大的白色矩形轮廓。用cv2.findContours找出所有轮廓,按面积排序取最大的几个,再用多边形逼近判断哪个是四边形。找到四个顶点后,用透视变换把倾斜的答题卡「拉正」成标准矩形,后续所有坐标才能用固定比例去定位选项。

def find_card_contour(binary): # 只找最外层轮廓,减少嵌套干扰 contours, _ = cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 按面积从大到小排序 contours = sorted(contours, key=cv2.contourArea, reverse=True) for c in contours[:5]: # 计算轮廓周长,用于多边形逼近 peri = cv2.arcLength(c, True) # 逼近精度取周长的 2%,太大漏掉角点,太小保留噪声 approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) == 4: return approx return None def four_point_transform(img, pts): # 把四个角点整理成左上、右上、右下、左下顺序 rect = order_points(pts.reshape(4, 2)) (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

approxPolyDP的精度参数0.02 * peri是血泪经验值:设成 0.01 会把纸张边缘的毛刺也当成角点,逼近出五边形六边形;设成 0.05 会把圆角答题卡直接压成三角形。order_points函数需要自己实现,逻辑是按坐标和与坐标差把四个点分成左上、右下、右上、左下。透视变换后的图像宽高由实际边长决定,不要硬编码成固定值,否则不同拍摄距离的答题卡会被拉伸变形,选项定位全错。

2.3 选项区域定位与填涂判断

校正后的答题卡是标准矩形,接下来按比例切出每道题的选项区域。常见做法是预先知道答题卡模板的行列数,比如 20 道题、每题 4 个选项,把校正图按行等分、按列等分,每个格子就是一个选项。判断填涂是否有效,统计格子内白色像素(二值化后的前景)占比,超过阈值就算涂了。

def detect_answers(warped, binary_warped, rows=20, cols=4): h, w = binary_warped.shape cell_h = h // rows cell_w = w // cols answers = [] for r in range(rows): row_answers = [] for c in range(cols): # 切出当前选项格子 cell = binary_warped[ r * cell_h:(r + 1) * cell_h, c * cell_w:(c + 1) * cell_w ] # 统计前景像素占比 ratio = cv2.countNonZero(cell) / (cell_h * cell_w) # 阈值 0.25 是实测经验值,低于它基本是空白或噪点 row_answers.append(1 if ratio > 0.25 else 0) answers.append(row_answers) return answers

阈值 0.25 不是拍脑袋定的:正常涂黑一个选项,前景占比在 0.4 到 0.7 之间;铅笔轻涂可能到 0.3;印刷的选项字母本身会贡献 0.05 到 0.1 的噪声。取 0.25 能过滤字母干扰又保留轻涂。如果你发现识别结果全是未涂,先打印几个格子的 ratio 看看,大概率是二值化反了或者透视校正没对齐。另外rows和cols必须和你的答题卡模板一致,这套源码默认按等分网格处理,如果答题卡有题号偏移或分组间隔,需要手动调整切分起点。

3. 判分逻辑与 MySQL 成绩落库:从答案比对到 Excel 导出

3.1 标准答案比对与分数计算

识别出每道题的填涂向量后,和标准答案做逐题比对。标准答案可以硬编码在配置里,也可以从数据库读。常见做法是维护一个answer_key列表,每个元素是正确选项的索引,然后遍历学生答案,匹配则加分。

def calculate_score(student_answers, answer_key, points_per_question=5): # answer_key 形如 [0, 2, 1, 3, ...],表示每题正确选项索引 total = 0 detail = [] for i, (stu, key) in enumerate(zip(student_answers, answer_key)): # 学生答案可能是多涂,取第一个有效选项或判错 stu_choice = stu.index(1) if 1 in stu else -1 is_correct = (stu_choice == key) if is_correct: total += points_per_question detail.append({ "question": i + 1, "student": stu_choice, "correct": key, "score": points_per_question if is_correct else 0 }) return total, detail

这里有个边界要处理:学生可能涂了多个选项。严格判分应该算错,但有些场景允许取第一个。源码里如果没处理多涂,stu.index(1)只取第一个,后面的忽略。你可以在detect_answers里加判断,如果一行有超过一个 1,标记为异常并记录题号,方便人工复核。points_per_question默认 5 分,按实际考试调整。

3.2 MySQL 表结构与成绩写入

成绩要存进 MySQL,先建库建表。用 Navicat11 或者命令行都行,表结构至少包含学生标识、总分、明细和创建时间。

CREATE DATABASE IF NOT EXISTS answer_sheet DEFAULT CHARSET utf8mb4; USE answer_sheet; CREATE TABLE score_record ( id INT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(32) NOT NULL COMMENT '学号或考生编号', student_name VARCHAR(64) DEFAULT NULL, total_score INT NOT NULL DEFAULT 0, detail_json TEXT COMMENT '每题判分明细JSON', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_student (student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Python 侧用pymysql连接,注意 Python 3.6.8 下pymysql要装 0.9.x 版本,新版本可能不兼容。写入时把detail列表转成 JSON 字符串存detail_json,方便后续查每道题的对错。

import pymysql import json def save_score(student_id, student_name, total, detail): conn = pymysql.connect( host='localhost', port=3306, user='root', password='your_password', database='answer_sheet', charset='utf8mb4' ) try: with conn.cursor() as cursor: sql = """INSERT INTO score_record (student_id, student_name, total_score, detail_json) VALUES (%s, %s, %s, %s)""" cursor.execute(sql, ( student_id, student_name, total, json.dumps(detail, ensure_ascii=False) )) conn.commit() finally: conn.close()

charset='utf8mb4'必须和建库时一致,否则中文姓名会变问号。json.dumps加ensure_ascii=False让明细里的中文正常存储。如果你批量处理一个班的答题卡,建议把连接放在循环外面,用executemany批量插入,否则每张卡开一次连接,50 张卡能跑好几分钟。

3.3 导出 Excel 与结果核对

成绩存库之后,用openpyxl或pandas导出 Excel。源码里如果用的是xlwt,注意它只支持.xls且 Python 3.6 之后维护少,建议换成openpyxl。

from openpyxl import Workbook def export_to_excel(records, output_path='scores.xlsx'): wb = Workbook() ws = wb.active ws.title = '成绩汇总' # 写表头 ws.append(['学号', '姓名', '总分', '明细']) for r in records: ws.append([r['student_id'], r['student_name'], r['total_score'], r['detail_json']]) wb.save(output_path) print(f'已导出 {len(records)} 条记录到 {output_path}')

导出后一定要人工抽几张核对:拿原始答题卡照片,对照 Excel 里的分数和明细,看识别结果是否和肉眼一致。常见偏差是某道题涂得太轻没识别到,或者相邻选项的格子切分偏了导致误判。核对三五张就能发现系统性问题,比盲目调参高效得多。

4. 避坑与常见问题排查:那些让识别率归零的细节

4.1 现象:所有题都识别为未涂,分数全零

原因通常是二值化反了。THRESH_BINARY_INV把黑色变白、白色变黑,如果答题卡是白底黑字,填涂区域在二值图里应该是白色前景。但如果你用的答题卡是深色底浅色涂,或者拍照时曝光过度导致纸面也偏暗,自适应阈值可能把整张图都判成前景或背景。解决方法是先cv2.imshow看一眼二值图,确认填涂块是白色、背景是黑色。如果反了,把THRESH_BINARY_INV换成THRESH_BINARY,或者对调后续countNonZero的判断逻辑。

4.2 现象:透视校正后答题卡是斜的或拉伸变形

原因在order_points的排序逻辑。四个角点如果顺序错乱,透视变换会把图像扭成麻花。常见错误是只按 x 坐标排序,没考虑 y 方向。正确做法是先按x+y之和找左上(最小)和右下(最大),再按x-y之差找右上(最大)和左下(最小)。另外width和height的计算要取对边最大值,如果取最小值,图像会被压缩,选项格子比例失真。校正后建议保存一张中间图,肉眼确认答题卡是正矩形再继续。

4.3 现象:MySQL 连接报Access denied或中文乱码

Access denied一般是用户名密码不对,或者 MySQL 8 的caching_sha2_password认证插件和旧版pymysql不兼容。Python 3.6.8 配 MySQL 5.7 通常没这个问题,但如果你本地装的是 MySQL 8,要么升级pymysql,要么把用户认证改成mysql_native_password。中文乱码则是连接、数据库、表三层字符集不一致,统一用utf8mb4,并且在pymysql.connect里显式写charset='utf8mb4'。

4.4 现象:批量处理时程序越跑越慢最后卡死

原因是每次循环都新建数据库连接和 OpenCV 图像对象,内存和连接数都没释放。解决方法是把数据库连接提到循环外,图像处理完手动del大图或复用变量。另外cv2.imread读进来的图如果不release,在批量处理几百张时会吃满内存。常见做法是每处理 20 张打印一次进度和内存占用,发现增长趋势就及时优化。

4.5 现象:Excel 导出成功但打开提示文件损坏

多半是xlwt写.xlsx后缀,或者openpyxl保存时文件被其他进程占用。xlwt只能写.xls,如果你强行改后缀成.xlsx,Excel 打开就会报格式错误。统一用openpyxl写.xlsx,保存前确认目标文件没有被 Excel 或 WPS 打开。另外路径里如果有中文或空格,某些旧版本库会出问题,输出路径尽量用英文和短横线。

5. 进阶技巧:用模板匹配替代等分网格,把识别率再提一档

等分网格的假设是答题卡选项排列绝对均匀,但实际印刷和拍照透视校正后,格子边界往往有 2 到 5 像素的偏移,题号多了累积误差就明显。更稳的做法是预先从一张标准空白答题卡上截取每个选项的小图作为模板,识别时用cv2.matchTemplate在对应区域搜索最佳匹配位置,再判断该位置是否被涂黑。这样即使整体有轻微平移,每个选项也能独立对齐。

具体操作分三步。第一步,找一张空白答题卡,跑完预处理和透视校正,手动或半自动截出每个选项的矩形区域,保存成小图,命名成q01_a.png、q01_b.png这种格式。第二步,识别时对每个选项,在等分网格给出的粗略位置周围扩大 10 像素的搜索范围,用模板匹配找到最相似的位置。第三步,在匹配位置上统计前景占比,判断填涂。

def match_and_check(binary_warped, template, rough_x, rough_y, search_pad=10): # 在粗略位置周围扩大搜索区域 x1 = max(0, rough_x - search_pad) y1 = max(0, rough_y - search_pad) x2 = min(binary_warped.shape[1], rough_x + template.shape[1] + search_pad) y2 = min(binary_warped.shape[0], rough_y + template.shape[0] + search_pad) roi = binary_warped[y1:y2, x1:x2] # 模板匹配,取最大相关系数位置 res = cv2.matchTemplate(roi, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) # 在最佳匹配位置切出同样大小区域 mx, my = max_loc cell = roi[my:my + template.shape[0], mx:mx + template.shape[1]] ratio = cv2.countNonZero(cell) / cell.size return ratio > 0.25, max_val

search_pad=10是经验值,对应校正后 2 到 3 像素的偏移足够。TM_CCOEFF_NORMED返回 0 到 1 的相关系数,如果max_val低于 0.5,说明这个选项区域和模板差异太大,可能是答题卡版本不对或校正失败,应该标记异常而不是强行判分。模板匹配的代价是每道题多几次匹配计算,20 题 4 选项也就 80 次,对单张图来说毫秒级,完全可接受。

另一个技巧是把标准答案和答题卡模板做成配置文件,而不是硬编码。用 JSON 存题数、选项数、正确答案、每题分值,换一套答题卡只改配置不改代码。这样你在答辩时能说清楚系统是可配置的,而不是写死的 demo。

{ "rows": 20, "cols": 4, "answer_key": [0, 2, 1, 3, 0, 1, 2, 3, 1, 0, 2, 3, 0, 1, 2, 3, 1, 0, 2, 3], "points_per_question": 5, "threshold": 0.25 }

从那以后我每次拿到这类图像识别项目,都先把中间结果存图看一眼,二值图、校正图、切分格子各存一张,确认每一步都符合预期再往下走。很多所谓的「识别率低」其实是某一步的中间结果已经错了,后面再怎么调参都是白费。希望这套源码和上面的排查思路能帮你把答题卡识别这条链路真正跑通,而不是停在解压看目录的阶段。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询