ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+OpenCV答题卡识别判分实战:从图像预处理到成绩导出

Python+OpenCV答题卡识别判分实战:从图像预处理到成绩导出

简介:这是一份面向Python毕业设计/课程设计的答题卡识别判分系统完整源码包,适合计算机视觉、图像处理方向学生参考。系统基于OpenCV实现答题卡图像预处理、选择题选项识别、答案比对与成绩计算,并将结果写入MySQL数据库及Excel文件,覆盖了从图像输入到成绩导出的完整流程。压缩包共293个文件,体积15.36MB,核心为30个py源码文件,辅以HTML/CSS/JS前端页面、SQL数据库脚本、pyc编译文件及项目说明文档,便于直接运行与二次开发。项目采用模块化设计,环境基于Python 3.6.8与PyCharm,已集成前端展示与后端逻辑,适合学习Canny边缘检测、轮廓提取、模板匹配等OpenCV核心操作。目前已有92人学习下载,对于需要快速搭建完整课设项目并理解计算机视觉应用流程的读者极具参考价值。

1. 答题卡识别与判分:这套Python源码的价值和边界

答题卡识别并不是一个黑匣子项目。完整流程拆开看只有四步:图像预处理、选项区域定位、答案比对、成绩落库,真正属于自己的核心逻辑代码可能还不到200行,难点全在预处理参数和判分边界上。这套基于计算机视觉的Python毕业设计源码,用OpenCV把“答题卡照片”一路处理成“Excel成绩单”,还带了MySQL 5.7存储和基于bootstrap.css、layui.css的管理界面,对正在做毕业设计、课程设计,或者想低成本搭一个轻量级阅卷工具的人,都是一个值得完整复现的样本。我把它从环境到代码到坑位逐层拆开,读完你大概就知道该不该下,以及下回来第一步该干什么。

2. 识别流程拆解:图像预处理、选项定位与答案比对的完整闭环

2.1 模块化程序设计:把识别任务拆成五个可维护环节

我拿到这种项目有一个习惯:先不急着跑,而是把源码的文件结构扫一遍,确认模块边界。这套源码的模块化做得比较规矩,按职责分成五个环节:图像读取与缩放、预处理、选项提取、答案比对、成绩存储。每个环节是一个独立函数或独立文件,输入输出都是标准数据结构——numpy数组、字典、列表,调试时可以单独跑任意一环,不会牵一发动全身。

主控逻辑大致长这样:

def main(): # 步骤1:读取答题卡图片,统一尺寸 img = load_image("sheet_001.jpg") # 步骤2:预处理,返回二值化图像和答题卡外轮廓 binary, contours = preprocess(img) # 步骤3:定位并提取所有选项区域,返回填涂状态字典 options = extract_options(binary, contours) # 步骤4:与标准答案比对,逐题计分 score, detail = compare(options, answer_key) # 步骤5:写入Excel + MySQL save_result(score, detail) return score

每个函数职责很单一。preprocess只做图像层面的处理,不关心业务逻辑;extract_options只返回“题号 + 是否填涂”的字典;compare拿字典和标准答案比对,返回一个百分制分数和逐题明细。参数说明里有两点值得注意:extract_options返回的是以(题号, 选项)为 key 的字典而不是列表,这样后续比对时能直接按题号取学生涂写结果,避免列表顺序错位带来的隐性bug;compare的answer_key也是字典,题号和标准答案一一对应,不在answer_key里的题不会参与计分。

这种结构对课程设计非常友好。没有复杂框架,没有难以追踪的全局变量,每个环节都能单步调试。如果你要换成另一套答题卡模板,只需要改extract_options里的行列坐标配置,其余环节基本不用动。这也正是“模块化设计”在答辩时候的核心卖点。

2.2 答题卡图像预处理:灰度化、高斯滤波、二值化与边缘检测的参数选择

预处理是整个系统里最影响识别率的一环。它的目标不是让图片变好看,而是把“答题卡长什么样”这件事变成计算机能算的纯几何信息。具体做三件事:灰度化、去噪、二值化,顺序不能乱。

灰度化把三通道BGR图压缩成单通道亮度图,计算量直接降到原来的三分之一左右。去噪多用高斯模糊,因为高斯滤波是按邻域加权平均,能在保边缘细节的同时去掉传感器噪声。这个项目里用高斯滤波而不是均值滤波是合理的,均值滤波会把边缘抹平,后续轮廓检测容易丢失选项边界。二值化把图像变成黑白两色,填涂区域和印刷底色彻底分离。

def preprocess(image_path): # 读取并缩放成标准宽度,统一尺寸后坐标网格才好计算 img = cv2.imread(image_path) img = cv2.resize(img, (1200, 1600), interpolation=cv2.INTER_AREA) # 灰度化:三通道变单通道亮度信息 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊去噪,核大小(5,5),sigmaY自动按核计算 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # Otsu二值化:阈值传0表示自动计算,THRESH_BINARY_INV让填涂区域变白色 _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 取最外层轮廓,RETR_EXTERNAL避免把选项内部的空心圆也带进来 contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) return binary, contours

这里有两个细节要展开说。第一,cv2.threshold返回两个值,第一个是“实际使用的阈值”,Otsu模式下由算法自动算出来,代码里用_直接丢弃;二值化结果binary才是真正要用的。INTER_AREA缩放适合含有固定图案的图片,它能保留较清晰的边缘,如果你换成长方形的答题卡,宽度按 1200 固定、高度按比例缩放即可。第二,cv2.findContours在 OpenCV 3 和 OpenCV 4 里的返回结构不同,这里解包写成(contours, _)意味着你安装的必须是 OpenCV 4.x,这个坑我会在第四章详细说。

2.3 选项轮廓检测与答案比对:从像素密度到分数的核心逻辑

很多第一次接触答题卡识别的人会以为选项定位需要训练一个YOLO模型,其实完全不用。标准答题卡是规则印刷的,每个选项的位置理论上固定,只要对答题卡做一次透视校正,就能按行列坐标精确切出每一个选项区域。识别“学生涂了哪个选项”的核心手段是像素密度统计——把选项矩形从二值图里切出来,看白色像素占比。

def extract_options(binary, contours): # 从所有外轮廓里找面积最大的那个,认为是答题卡本体 card_contour = max(contours, key=cv2.contourArea) # approxPolyDP把任意轮廓拟合为四边形,得到答题卡四个顶点 epsilon = 0.02 * cv2.arcLength(card_contour, True) approx = cv2.approxPolyDP(card_contour, epsilon, True) # 坐标配置:6行×每行5题,每题A/B/C/D四个选项 rows = 6 cols = 5 options = {} for idx in range(rows * cols): # 按行列均分得到第idx题的矩形区域 x, y, w, h = get_option_rect(approx, idx, rows, cols) roi = binary[int(y):int(y + h), int(x):int(x + w)] # 计算白色像素占比,超过阈值认为已涂选 density = np.sum(roi > 0) / (w * h) if density > 0.4: options[idx] = "已涂选" return options

get_option_rect是坐标网格生成函数,核心思路是把答题卡的四个顶点围出的矩形,横向均分成cols列、纵向均分成rows行,第idx题的矩形就能通过行列索引反算出来。density > 0.4是一个经验阈值:低于它会把浅涂卡误判为未填,高于它会把残留笔迹误判成选项。真正生产环境里很少用固定阈值,我在第五章会给出更稳的“相对密度”方案。

取到学生涂写的选项之后,比对逻辑就很朴素:与answer_key逐题比对,答对加一分,最后换算百分制。这一层代码简单到没有太多可说的,但注意一个细节——比对时建议用student_answers.get(q_no, None)而不是直接索引,这样没填涂的题在成绩明细里会显示为“未作答”,而不是直接抛KeyError打断主流程。答题卡识别最怕在数据层崩溃,这种防御式写法值得保留。

3. 环境搭建与源码落地:Python 3.6.8 + OpenCV 4.x + MySQL 5.7 的坑位对齐

3.1 开发环境版本组合与依赖安装

这套源码指定的是 Python 3.6.8 + MySQL 5.7 + Navicat 11 + PyCharm。这套组合看起来“复古”,其实是刻意选稳定的。Python 3.6.8 时代,OpenCV 和 numpy 都有现成的预编译轮子,下载即装,不用碰源码编译;MySQL 5.7 和 Navicat 11 的组合在课程设计环境里非常成熟,网上能搜到的同类项目基本都是这套配置。

依赖清单建议直接锁版本:

opencv-python==4.5.4.60 numpy==1.19.5 pymysql==1.0.2 openpyxl==3.0.10 pillow==8.1.0
pip install -r requirements.txt

如果网络源慢,换国内镜像源一条命令解决:

pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

版本锁死是有原因的。numpy==1.19.5是最后一个完整支持 Python 3.6 的版本,装更新的 numpy 会在导入时报错;opencv-python==4.5.4.60不仅保证findContours返回值是双元组,而且这个版本对 Python 3.6 提供了完整 wheel 包。pymysql用它来连 MySQL,轻量且不需要装 MySQL 官方 C 客户端;openpyxl写 Excel,注意 2.x 和 3.x 的 API 有差异,本项目的代码按 3.x 写,锁定版本就不会踩差异坑。

3.2 源码结构:定位判分核心函数与主流程

整套源码的文件结构是标准的毕业设计形态。核心目录大致如下:

answer_sheet/ ├── config.py # 答题卡模板参数、标准答案、数据库连接配置 ├── image_utils.py # 图像预处理与透视变换 ├── option_detect.py # 选项区域定位与填涂判断 ├── score_engine.py # 答案比对与成绩计算 ├── excel_writer.py # 成绩写入Excel ├── mysql_writer.py # 成绩写入MySQL └── main.py # 主入口,串起整个流程

定位判分函数有个技巧:不用从main.py一行行追,直接在score_engine.py里搜def compare或def calc_score。这个函数入参是student_answers和answer_key,返回百分制分数和逐题明细。典型实现:

def calc_score(student_answers, answer_key): # student_answers: {题号: "A"} 学生实际涂选结果 # answer_key: {题号: "A"} 标准答案 correct_count = 0 detail = {} for q_no, correct in answer_key.items(): ans = student_answers.get(q_no, None) detail[q_no] = (ans, correct, 1 if ans == correct else 0) if ans == correct: correct_count += 1 total = len(answer_key) score = round(correct_count / total * 100, 2) return score, detail

这里用student_answers.get(q_no, None)代替方括号索引,是一个很实用的防御习惯。答题卡如果存在漏填,student_answers里就没有对应的题号,直接索引会抛KeyError,整个判分流程当场中断;用.get则会把漏填的题记为 None,最终在 Excel 里显示“未作答”。score保留两位小数,避免浮点精度带来 84.9999 这种难看的成绩。

3.3 成绩存储:Excel导出与MySQL写入的双通道配置

源码同时落 Excel 和 MySQL,很符合课程设计的评分点——既展示了文件输出能力,又展示了数据库操作能力。Excel 写入基于openpyxl:

import openpyxl def write_excel(scores, output="scores.xlsx"): # scores: [{"学号": "001", "姓名": "张三", "成绩": 85, "详情": "..."}] wb = openpyxl.Workbook() ws = wb.active ws.title = "答题卡成绩" ws.append(["学号", "姓名", "成绩", "答题详情"]) for row in scores: ws.append([row["学号"], row["姓名"], row["成绩"], row["详情"]]) wb.save(output) return output

MySQL 写入用pymysql:

import pymysql def write_mysql(scores): conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="123456", database="answer_sheet", charset="utf8mb4", autocommit=True ) try: with conn.cursor() as cursor: sql = "INSERT INTO score_record (student_no, student_name, score, detail) VALUES (%s, %s, %s, %s)" data = [(s["学号"], s["姓名"], s["成绩"], s["详情"]) for s in scores] cursor.executemany(sql, data) finally: conn.close()

注意charset="utf8mb4"必须显式指定。Navicat 11 建库默认字符集是 utf8,但 utf8 在 MySQL 里是 utf8mb3 的别名,存不了生僻字和 emoji,姓名里有“𬇹”这类字的考生,写入时会直接报Incorrect string value。executemany是批量插入,比循环单条 insert 快一个数量级,这个项目数据量不大,但代码习惯值得保留下来。

4. 避坑指南:答题卡识别实战中的五类典型问题与排查方案

4.1 环境与路径类:findContours返回值、中文路径、MySQL认证插件

现象1:在preprocess函数里按源码解包运行时,报TypeError: not enough values to unpack (expected 3, got 2)。

原因:OpenCV 版本差异。OpenCV 3.x 的cv2.findContours返回三个值,OpenCV 4.x 返回两个值,源码是按 4.x 写的,你的环境里装成了 3.x。

解决:锁定安装opencv-python==4.5.4.60。如果你确实只能待在 OpenCV 3 环境,解包改成_, contours, _ = cv2.findContours(...)也能跑,但后续依赖 4.x 行为的代码可能还会冒出来,建议直接换成 4.x。

现象2:图片放在纯英文路径下一切正常,一放到“桌面/张三/答题卡照片/”这类带中文的目录,cv2.imread返回值是 None。

原因:OpenCV 的imread底层走 C++ 的 fopen,对中文路径和部分 Unicode 字符支持不好,这在 Windows 中文系统上是必踩的坑。

解决:不要用imread,用 numpy 二进制读取加imdecode组合:

def imread_unicode(path): data = np.fromfile(path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)

把项目里所有读图入口都换成这个封装函数,中文路径问题当场消失。

现象3:Navicat 11 能正常连接 MySQL,但pymysql连接时报Authentication plugin 'caching_sha2_password' cannot be loaded或 1251 错误。

原因:MySQL 的默认认证插件从 5.7 到 8.0 发生了变化。如果你本机的 MySQL 不是 5.7 而是 8.0,或者用户密码被重置过加密方式,老版本pymysql不认caching_sha2_password。

解决:对这个项目,最省事的是让 MySQL 用户使用mysql_native_password。先查当前插件:

SELECT user, host, plugin FROM mysql.user WHERE user = 'root';

如果是caching_sha2_password,执行:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;

然后重启服务再连。

4.2 识别与判分类:透视变形、固定阈值漏判、涂改残留误判

现象4:答题卡放在桌面上用手机斜着拍,识别结果全是“未作答”,或者选项区域完全错位。

原因:手机拍摄角度不是正上方垂直视角,答题卡在图像里是倾斜甚至梯形的,直接按原始坐标切选项区域,切到的是斜矩形,像素统计完全失真。

解决:在extract_options前强制加透视校正。用approxPolyDP拿到答题卡四个顶点,再做透视变换:

def four_point_transform(image, pts): # 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]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, M, (maxWidth, maxHeight))

这段代码里order_points是最容易出错的地方,它负责把四个顶点按“左上、右上、右下、左下”的顺序排好,顺序错了透视矩阵直接算反。网上很多现成实现是拿坐标总和与差值排序,建议用np.argsort加坐标判断写,别直接用 x 排序。

现象5:密度阈值设 0.4,浅涂的答题卡大量漏判;调低到 0.25,又出现涂改残留误判。

原因:固定阈值在面对不同批次、不同深浅的碳素笔时,没有自适应能力。涂得太浅,像素占比达不到 0.4;橡皮擦没擦干净,背景密度本身就高,阈值低了就把残留笔迹算成涂选。

解决:放弃单阈值,改用“相对密度 + 领先倍数”的双重判断:

def detect_choice(densities): # densities: {"A": 0.3, "B": 0.05, "C": 0.02, "D": 0.01} max_option = max(densities, key=densities.get) max_density = densities[max_option] sorted_densities = sorted(densities.values(), reverse=True) # 最大密度超过0.25,且明显领先第二候选,才认为是有效涂选 if max_density > 0.25 and max_density >= 2 * sorted_densities[1]: return max_option return None

同一道题的四个选项先分别算密度,取最大者对应的选项,再检查它是否比第二高的选项有明显优势。这样即使整体偏浅,只要填涂区域确实比同题其他选项都重,就能正确识别。这是我用多批样本试出来的方案,比单纯调阈值稳得多。

5. 参数调优与质量验证:从“能识别”到“高准确率”的调试路径

5.1 阈值、轮廓面积与透视参数矩阵

整个答题卡识别项目里真正要调的参数不超过六个。我把它们列成一张矩阵,方便你对照排查:

参数位置作用常用范围影响
cv2.resize目标宽度统一处理尺寸1000~1500px太小选项区域不清晰,太大计算慢
高斯模糊核大小去噪强度(3,3)~(7,7)核越大边缘越模糊,选项边界越容易丢
Otsu二值化黑白分界自动基本不手调,但要保证光照相对均匀
密度阈值填涂判断0.2~0.5越低越容易误判残留,越高越容易漏判浅涂
轮廓面积过滤排除干扰卡片面积的1/10以上过滤掉桌边、杂物等其他轮廓
透视变换边长校正后图像尺寸由顶点距离自动算影响后续坐标网格的对齐精度

其中轮廓面积过滤是最容易被忽略的。如果不加限制,findContours可能把桌面边缘、手指阴影、抽屉线都当成候选轮廓,一旦max(contours, key=cv2.contourArea)选错,后面透视变换和坐标网格全错。加一个过滤条件能规避大半问题:

def is_card_like(c): area = cv2.contourArea(c) if area < 10000: # 按1200x1600的输入尺寸,答题卡面积远大于这个值 return False x, y, w, h = cv2.boundingRect(c) aspect = w / h return 0.6 < aspect < 1.5 # 答题卡长宽比一般在0.7~1.3之间

实际使用中我会把所有候选轮廓按面积排序,取前三个,再依次过is_card_like,这样比单纯取面积最大更稳,因为答题卡本体有时候不是面积最大的那个轮廓。

5.2 可视化调试:把判定结果画回原图上找问题

调识别系统最有效的招不是打印日志,而是画图。把每道题的判定结果直接画在原图上,一眼就能看出问题出在哪一层。常见做法是写一个调试函数:

def visualize_result(img, options, answers, output_path="result.jpg"): result = img.copy() for q_no, option in options.items(): if option == answers.get(q_no): color = (0, 255, 0) # 绿色为正确 else: color = (0, 0, 255) # 红色为错误或未作答 x, y, w, h = option_rects[q_no] cv2.rectangle(result, (x, y), (x + w, y + h), color, 2) cv2.imwrite(output_path, result)

调参时把output_path指到一个临时目录,跑完一组样本直接打开图片看颜色分布。绿色多说明识别正常;红色集中出现在某一行,基本就是坐标网格的行高没对齐;红色随机散布,多半是阈值和预处理的问题。这种方法比看数字更直观,也比逐行 print 高效得多,是我做这类项目时离不开的手段。

5.3 真实考场差异:倾斜、残留笔迹、光线不足的应对策略

这套源码的目标场景是手机拍摄或扫描仪扫描。真实考试环境里会有三种典型扰动。

倾斜:四点透视变换能修正大部分问题,但超过 15 度的倾斜不建议硬靠算法修。透视校正插值会损失边缘清晰度,选项目区域被插值涂抹,反而影响密度统计。拍摄时尽量让镜头正对答题卡,这个物理层面的改进比任何算法都有效。

残留笔迹:橡皮擦没擦干净导致的背景密度偏高,是阈值误判的重灾区。相对密度法可以缓解,但更稳妥的做法是加“空白卡基线校准”——先用一张空白答题卡算出每个选项区域的基准背景密度,识别时减去基线再算增量密度。实现上就是初始化时准备一份baseline_density字典,后续每个选项的密度都减去对应基线值。

光线不足:整体偏暗时 Otsu 二值化容易把填涂区域和背景糊成一片。优先做亮度归一化,用自适应直方图均衡:

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) normalized = clahe.apply(gray)

clipLimit=2.0限制对比度放大幅度,防止背景噪声被过度增强;tileGridSize是分块大小,越大对局部光照的适应能力越强,但计算也越慢。在基本光照均匀的场景下,不均衡直接走 Otsu 性能更好,所以这段代码通常放在一条“光照不足才启用”的分支里。

6. 进阶技巧:批量判分、动态答案与二次开发落地点

把源码跑通单张图之后,不要停在原地。真正能让这个项目变成毕业设计亮点或者工作里可用工具的,是把它扩成批量判分脚本。实现思路很简单:遍历目录下所有图片,文件名里带学号和姓名,逐张处理,结果汇总写一次 Excel 和 MySQL。

import os, glob def batch_score(folder_path, answer_key): results = [] for img_path in glob.glob(os.path.join(folder_path, "*.jpg")): # 文件名格式:"2023001_张三.jpg" student_no, name = os.path.splitext(os.path.basename(img_path))[0].split("_") binary, contours = preprocess(img_path) options = extract_options(binary, contours) score, detail = calc_score(options, answer_key) results.append({"学号": student_no, "姓名": name, "成绩": score, "详情": detail}) write_excel(results) write_mysql(results) return results

原来的顶层界面基于 bootstrap.css、layui.css 封装,跑通batch_score之后,你可以把返回的results直接接到 Web 层的路由上,做一个“上传图片就能看成绩”的小应用。三个二次开发落地点供参考。

第一个,把answer_key从config.py的硬编码改成 MySQL 表驱动。不同班级、不同试卷不需要改代码,在管理界面里导入答案即可,这比在源码里改字典更贴近真实使用。第二个,增加“待人工复核”机制——对得分异常低、空白题过多、或者检测到两道题同时被涂的试卷,在 Excel 里单独标记一列。这是我在实际使用中最有价值的改动,能有效兜住算法误判。第三个,成绩明细落库时保存完整的答题详情 JSON,比如{"1": "A", "2": "B", "3": "未填"},方便后续做知识点错题分析,比只存一个总分有挖掘价值得多。这些扩展都不需要动核心识别流程,因为它们本来就建立在源码的模块化边界之上。

从第一次跑通到改造成批处理工具,我翻过最大的一个车就是盲目调阈值。当时觉得浅涂漏判是系统有 bug,盯着阈值调了整整一晚上,后来才意识到问题出在固定阈值的统计方式上,换成“相对密度 + 领先倍数”之后一下就稳了。从那以后,我每次调识别参数都强制走一遍固定流程:先用 5 张不同光照的样本图跑一轮,把判定结果画出来,统一看错在哪一层,再决定是改预处理还是改阈值,绝不盯着一个指标死磕。这个习惯帮我少走了很多弯路,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进