
简介面向计算机相关专业软件工程、计算机科学、人工智能、自动化等的在校学生、教师与企业开发者基于OpenCV与Python实现答题卡自动识别可应用于毕业设计、课程设计、项目初期演示及图像处理实战学习。资源提供完整可运行的答题卡识别源码包含答案识别主程序与辅助脚本并配有用于测试的标准答题卡图片覆盖图像灰度化、二值化、透视变换、轮廓定位、选项填涂检测与结果比对等核心流程可直接运行也可替换为自有答题卡图片进行验证。压缩包共4个文件包括两个Python源码文件与jpg、png两类测试图片整体约660KB体量精简、结构清晰便于快速定位识别算法与调试入口。目前已有366人浏览学习源码经本地编译运行通过项目难度适中且评审分达到95分以上适合具备一定Python基础的读者直接使用也可在此代码基础上按需修改以实现其他功能亦可作为课程作业或OpenCV学习进阶的实战参考。1. 用 OpenCV 做答题卡识别为什么这个方向值得花时间答题卡识别的本质是把一张扫描或拍照的纸面信息转成结构化的选项结果。多数人第一次接触 OpenCV 项目都会选它因为任务边界清晰定位答题卡、检测选项区域、判断涂卡状态、输出得分。这个标题里的源码包我虽然没跑过但“基于 OpenCV Python 的答题卡识别”这个组合几乎就是计算机视觉入门到实战的最短路径——它涵盖了灰度化、二值化、透视变换、轮廓检测、掩膜提取这一整套经典流程而且每一步都有肉眼可见的输出调试起来比训练神经网络直观得多。适合做这个项目的人不是想发论文的研究者而是刚学完 Python 基础、想用 OpenCV 做点真东西的初学者以及学校课程设计需要“高分项目”的学生。前者能通过它把图像处理的抽象概念落到代码里后者能直接拿到一套可演示、可答辩的完整方案。我接下来说的实现路径会按照最常见的工程做法来讲不依赖你手里那份 zip 的具体内容而是把这类项目该有的模块、参数和坑都摊开。2. 识别流程拆解从图像输入到得分输出每一环在做什么2.1 经典 OpenCV 答题卡识别管线的五个阶段一套完整的答题卡识别系统无论源码怎么写核心管线都绕不开以下五步图像预处理、答题卡定位、选项区域分割、涂卡状态判断、结果汇总。这五步对应的 OpenCV 函数几乎固定差异只在于参数和容错策略。预处理阶段通常做灰度化、高斯模糊和边缘检测。灰度化用cv2.cvtColor模糊用cv2.GaussianBlur边缘检测用cv2.Canny。为什么需要模糊因为扫描件或者手机拍照的图片往往有噪点直接做边缘检测会产生大量伪边缘干扰后续的轮廓查找。Canny 的双阈值参数需要根据实际图像调整我一般设低阈值 50、高阈值 150但这个值受光照影响很大后面我会单独讲这个坑。定位阶段用cv2.findContours找最外层轮廓然后通过轮廓面积和形状筛选出答题卡区域。筛选用cv2.approxPolyDP逼近多边形标准答题卡是矩形所以应该得到一个四边形的轮廓。拿到四个顶点后用cv2.getPerspectiveTransform和cv2.warpPerspective做透视变换把倾斜的答题卡校正成正视图。这是整个识别流程的基石透视变换做得不好后面的选项区域分割全是错的。2.2 选项区域分割先定位再切割不要跳步校正后的图像需要进一步找到每一题的每一个选项。常见做法是先对校正图做二值化用cv2.threshold配合THRESH_BINARY_INV把涂黑的选项变成白色背景变成黑色。然后再次查找轮廓这次查找的是内部的小轮廓也就是每个选项的填涂区域。这里有个关键设计选项区域的定位不能只靠轮廓检测。同一张答题卡不同题目区域的间距可能不同只靠轮廓容易把边框噪声也识别进来。更稳妥的做法是预先定义题目布局——比如“每行 4 个选项共 25 题”然后按坐标排序把图像均匀切割成网格。这个方法的优点是稳定缺点是需要知道答题卡模板的布局参数换一张答题卡就要改配置。我在实际项目中会把布局参数单独抽成一个字典而不是写死在识别代码里。切割之后每个选项区域是一个小的 ROIRegion of Interest。判断是否涂卡用的是cv2.countNonZero统计 ROI 内白色像素的数量。如果白色像素占比超过阈值比如 50% 或 60%就认为该选项被涂了。这个阈值也是经典玄学参数后面我会展开讲。2.3 得分计算标准答案比对和容错设计得分计算环节并不复杂但要考虑两种答题卡一种是题目顺序固定、左右栏连续的另一种是左右栏交替的题目 1 在左栏第一行题目 2 在右栏第一行。如果不做栏位映射比对结果会全部错位。我见过很多课程设计项目在这上面翻车因为源码是按固定模板写的换个排版就乱了。比对逻辑用集合操作就很清晰。每题的涂卡结果是若干选项的集合标准答案也是集合直接比较相等与否即可。对于多选题如果答题卡允许涂多个选项就判断两个集合是否相等对于单选题还要额外处理涂了多个选项的情况——这种应该直接判错而不是取第一个选项。这属于业务规则应该放在配置层而不是散落在识别代码里。得分输出建议同时保存两份一份打印在终端一份写进 CSV 或 Excel。答辩时考官大概率会问“怎么验证你们的结果是对的”你直接展示一张带标注的图片和对应的 CSV 文件比空口解释有说服力得多。接下来我会把每一步的代码落下来你可以直接照着跑。3. 用 Python OpenCV 跑通答题卡识别最小可运行代码与参数说明3.1 环境准备Python、OpenCV 和 imutils 的安装要点这个项目用到的第三方库只有两个opencv-python和numpyimutils是可选的用来做图像缩放和轮廓排序简便但非必需。安装命令在 Windows、macOS、Linux 上通用pip install opencv-python numpy imutils如果安装缓慢或超时可以指定国内镜像源pip install opencv-python numpy imutils -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成之后用下面这行命令验证是否可用python -c import cv2; print(cv2.__version__)能输出版本号就说明环境没问题。这里要提醒一个高频坑如果你电脑里装了多个 Python 环境比如 Anaconda 和系统自带 Python 并存pip安装的包可能和你python命令启动的解释器不是同一个导致import cv2报ModuleNotFoundError。解决办法是在你的项目目录里建一个虚拟环境用python -m venv venv创建激活后再装依赖不要直接在全局环境里硬装。3.2 预处理和透视变换把歪斜的答题卡扶正先写一个辅助函数负责把输入的图像转换成端正的俯视图。这一步是整个识别的基础函数输入是原始图像路径输出是透视校正后的灰度图和原始图像的副本便于后续可视化。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)] 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 def four_point_transform(image, pts): # 获取排序后的四个顶点计算透视变换矩阵并应用 rect order_points(pts) (tl, tr, br, bl) rect width_top np.linalg.norm(tr - tl) width_bottom np.linalg.norm(br - bl) height_left np.linalg.norm(bl - tl) height_right np.linalg.norm(br - tr) max_width max(int(width_top), int(width_bottom)) max_height max(int(height_left), int(height_right)) dst np.array([ [0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (max_width, max_height)) return warpedorder_points的作用是把approxPolyDP找到的四个顶点按固定顺序排列避免透视变换时顶点顺序错乱导致图像翻转。four_point_transform计算目标矩形的宽高然后用getPerspectiveTransform求出变换矩阵。注意这里宽高不能用原始四边形的边长直接取整否则图像边缘会有些许裁切影响后续轮廓检测。主流程里加载图像后先做缩放标准答题卡扫描件的分辨率很高直接处理会拖慢速度。然后用灰度化和高斯模糊预处理Canny 找边缘再findContours筛选最大轮廓。轮廓筛选的条件是面积足够大且接近四边形通常答题卡占据画面 80% 以上所以最大轮廓基本就是它。如果没找到合适的四边形直接报错提示用户重新拍照或调整角度不要再往下走。# 主流程读取、预处理、找轮廓、透视变换 image cv2.imread(test_card.jpg) ratio image.shape[0] / 500.0 orig image.copy() image cv2.resize(image, (int(image.shape[1] / ratio), 500)) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edged cv2.Canny(blurred, 50, 150) cnts cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts cnts[0] if len(cnts) 2 else cnts[1] cnts sorted(cnts, keycv2.contourArea, reverseTrue)[:5] for c in cnts: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: screenCnt approx break warped four_point_transform(orig, screenCnt.reshape(4, 2) * ratio) warped_gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)参数说明cv2.resize把高度缩放到 500 像素ratio保存缩放比例后面变换坐标时乘回去保证在原始分辨率下操作。approxPolyDP的第二个参数0.02 * peri是逼近精度值越小逼近越严格。如果找到的轮廓不是四边形可以适当调大比例到0.03或0.04但调太大会把圆角矩形也逼近成四边形导致定位不准。3.3 二值化与选项轮廓提取把涂卡区域变成可统计的白色块透视校正之后图像已经比较规整但这只是万里长征第一步。接下来要做二值化把灰度图变成黑白图。这里有个重要选择用全局阈值还是自适应阈值。标准答题卡扫描件的背景亮度均匀用全局阈值就够了但手机拍照的答题卡经常有阴影全局阈值会在阴影区域产生大片黑色噪声。# 二值化涂卡区域变白背景变黑 thresh cv2.threshold(warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] # 查找选项轮廓 cnts cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts cnts[0] if len(cnts) 2 else cnts[1] questionCnts [] for c in cnts: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) # 过滤掉太宽、太窄、太小的轮廓 if w 20 and h 20 and ar 0.8 and ar 1.3: questionCnts.append(c)cv2.THRESH_OTSU会自适应计算最佳阈值比自己拍脑袋设一个固定值更鲁棒。轮廓面积过滤条件第 20 行的判断是我写的实测经验选项涂卡区域的宽高比接近 1也就是差不多正方形如果答题卡的选项是长方形需要把ar的范围放宽到0.5 ~ 2.0。过滤后得到的questionCnts里面可能混入一些噪声轮廓比如印刷的题号、边框线等。下一步需要按坐标排序并分割成行。排序的逻辑是先按纵坐标排序分成行每一行内再按横坐标排序分成列。这个排序代码在课程设计中是加分项因为很多人只会用sorted(cnts, keycv2.boundingRect)简单排一次结果上下行混在一起。def sort_contours(cnts, methodtop-to-bottom): # 按轮廓左上角坐标排序支持从上到下或从左到右 bounding_boxes [cv2.boundingRect(c) for c in cnts] if method top-to-bottom: sorted_data sorted(zip(cnts, bounding_boxes), keylambda b: b[1][1]) elif method left-to-right: sorted_data sorted(zip(cnts, bounding_boxes), keylambda b: b[1][0]) return sorted_data sorted_cnts sort_contours(questionCnts, methodtop-to-bottom)排序之后还需要按行分组。因为同一行的选项纵坐标接近但不同行之间有明显的间隔。一个简单可靠的分组方式是把排序后的轮廓依次遍历如果当前轮廓的 y 坐标与上一行基准 y 坐标之差大于某个阈值比如 30 像素就认为开始了新的一行。这个阈值同样取决于答题卡的行距最好在配置里标注清楚。3.4 涂卡判断的判定逻辑阈值怎么设才不误判拿到每个选项区域的 ROI 后判断是否涂卡最常用的方法就是统计白色像素数量。但只统计数量还不够还要算占比因为不同选项区域的大小可能略有差别绝对值没法直接比较。# 遍历每一题的每个选项统计白色像素占比 def is_marked(roi, threshold_percent50): # 统计 ROI 中白色像素数量计算占比 total_pixels roi.shape[0] * roi.shape[1] white_pixels cv2.countNonZero(roi) percent white_pixels / total_pixels * 100 return percent threshold_percent, percent这个threshold_percent就是关键参数。我见过很多项目把这个值固定在 50%理由是“超过一半就是涂了”。但真实场景里铅笔涂卡的灰度深浅差异很大2B 铅笔涂得淡的时候白色像素占比可能只有 30% 多。更稳的做法是同时计算每个选项的白色像素占比然后取一行或一列中的最大值再用这个最大值与第二大的值做比值如果比值超过 1.5说明只有一个选项被明确涂了。这种“相对比较法”比“绝对阈值法”对光照和铅笔深浅的容忍度高很多。3.5 结果输出把识别结果可视化标记在原图上调试和答辩阶段可视化输出比终端打印重要得多。建议在原始校正图上画矩形框和题号涂卡结果用绿色框表示未涂卡用红色框表示最后把整张图保存成result.jpg。这样你可以一眼看出识别错误的位置而不是面对一堆数字无从下手。# 在 warped 图上画框标注识别结果 for (i, q) in enumerate(questionCnts): (x, y, w, h) cv2.boundingRect(q) color (0, 255, 0) if i in correct_answers else (0, 0, 255) cv2.rectangle(warped, (x, y), (x w, y h), color, 2) cv2.putText(warped, str(i 1), (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2)这里的correct_answers是一个集合存放判为已涂卡的题目索引。如果你是用轮廓序号索引注意轮廓排序必须在绘制前完成否则索引和第几题对不上。打印结果的顺序建议和题目顺序一致不要直接打印原始轮廓顺序。4. 参数调优与边缘情况光照、铅笔深浅、倾斜角度的影响4.1 Canny 边缘检测双阈值为什么默认值经常失效Canny 的50, 150双阈值对扫描件足够对手机拍照图就不一定了。如果模式是背景颜色深、答题卡颜色浅比如蓝色答题卡配白色纸张边缘对比度会降低低阈值 50 可能根本检测不到边缘。我一般会在调参阶段写一个小脚本用滑动条动态调整 Canny 阈值实时看边缘检测效果确定了再写死到代码里。另外一个常见问题是答题卡本身有边框和其他印刷图案Canny 会把它们也识别成边缘。这时单纯调阈值解决不了需要在轮廓筛选阶段增加面积比例过滤例如只保留面积最大的轮廓并且要求这个轮廓的包围盒面积占整图面积的 80% 以上。这个比例在扫描件中基本成立拍照件要放宽到 60%。4.2 透视变换的四个顶点检测什么时候会翻车透视变换最经典的翻车场景是答题卡背景复杂或者答题卡本身被手遮挡了一角导致approxPolyDP逼近的不是四边形而是五边形或三角形。这时代码会直接跳过该轮廓找不到screenCnt程序报错退出。遇到这种情况一个可行的兜底方案是改用最大矩形包围盒近似用cv2.minAreaRect得到最小外接旋转矩形再取矩形的四个角点作为答题卡顶点。因为答题卡本身是矩形即使边缘识别不完整最小外接矩形通常也八九不离十。代价是如果答题卡倾斜严重旋转矩形的四个角点和真实顶点之间会有几像素偏差透视变换后内容的边缘可能会轻微裁切但选项区域还在不影响后续识别。4.3 光照不均和阴影为什么 Otsu 也会翻车cv2.THRESH_OTSU只用全局直方图计算阈值遇到半边亮半边暗的图像就会出问题。比如答题卡放在窗边左边有阳光右边没阳光Otsu 可能把阴影区域全部判成黑色背景导致选项区域无法提取。更稳的方案是先用cv2.medianBlur做中值滤波去除噪声再试cv2.adaptiveThreshold做自适应阈值分割。自适应阈值会根据每个像素邻域的亮度计算局部阈值对光照梯度更鲁棒。# 光照不均时用自适应阈值替代全局 Otsu adapt_thresh cv2.adaptiveThreshold( warped_gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)blockSize11是计算局部阈值的邻域大小C2是从均值或高斯加权和中减去的常数。这两个参数也需要根据图像分辨率调整。分辨率越高blockSize应适当增大因为局部区域在低分辨率下的像素数变少了。我一般在 500 像素宽的图上用 11在 1000 像素宽的图上改用 21。4.4 识别结果偶尔不对用 OpenCV 反向验证如果识别结果时好时坏不要急着改算法先用可视化工具确认问题出现在哪一环。常见做法是把中间结果逐级保存成图片原图、灰度图、Canny 边缘图、透视校正图、二值化图、轮廓标注图。每张图都输出到debug/目录。然后回放一遍看哪一步出现异常。这种做法比单步调试快得多因为图像处理的问题是空间性的打印一堆数组数值反而不直观。我自己的项目里始终保留一个debug开关默认关闭出问题时开启。这个开关写在外面配置里不进识别核心函数。5. 项目增强从及格到高分你需要补哪些模块5.1 增加答题卡模板配置代码不动换卡不慌一个只能识别固定模板的答题卡项目价值有限。更好的设计是把题目数量、选项数量、行列布局、涂卡判定阈值全部抽到 JSON 配置文件里识别代码只读取配置不写死任何数字。这样换一张答题卡只需要改配置文件代码根本不用动。{ total_questions: 50, options_per_question: 4, columns: 2, rows_per_column: 25, threshold_percent: 40, sort_method: top-to-bottom }这个配置的好处是答辩时你可以现场演示“识别第二套答题卡”只需要加载一份新的配置不需要重新编译代码。配置驱动的思路在工程上是基本功在课程设计里却是明显的加分项。5.2 提供批处理能力一次性识别一个目录的所有答题卡如果只支持单张图片识别遇到一个班几十份答题卡就要手动跑几十次体验很差。批处理逻辑很简单用pathlib遍历目录下的*.jpg和*.png文件逐个调用识别函数把结果汇总到一个 CSV最后按总分排序输出。项目交付时带上批量处理脚本实用性立刻上一个档次。from pathlib import Path import csv result_rows [] for img_path in sorted(Path(exam_images).glob(*.jpg)): score, detail recognize_card(str(img_path)) result_rows.append([img_path.stem, score, detail]) with open(results.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([student_id, score, detail]) writer.writerows(result_rows)批处理时要注意输出编码问题。Windows 上直接用open(results.csv, w)写中文会有乱码风险建议用encodingutf-8-sig这样 Excel 打开时中文不会乱码。这个小细节经常被忽略但效果立竿见影。5.3 做一个小型 GUI 或 Web 界面让演示更直观如果时间充裕可以加一个简单的界面。用 Tkinter 做桌面 GUI 是最轻量的方案核心逻辑不需要重写只需要在按钮回调里调用识别函数然后在Label组件里展示结果图片。如果框架允许用 Flask 做 Web 上传页面也很简单from flask import Flask, request, render_template import cv2 app Flask(__name__) app.route(/upload, methods[POST]) def upload(): file request.files[image] img cv2.imdecode(np.frombuffer(file.read(), np.uint8), cv2.IMREAD_COLOR) score recognize_card(img) return render_template(result.html, scorescore)这里的关键是用cv2.imdecode直接解码上传的字节数据而不是先保存到临时文件再读取避免文件路径和权限问题。界面不是核心但有一个能点的按钮演示效果完全不同这也是“高分项目”的一个隐性评分点。6. 测试与验收用三张图片验证你要不要碰这个项目拿到任何答题卡识别项目我都建议先做一次冷静的验证准备一张标准扫描件、一张手机拍照图、一张故意倾斜 30 度的照片分别跑一遍。三张全过说明项目的鲁棒性在及格线以上只过第一张说明它只能处理理想输入真实场景落地价值存疑。这一步能帮你快速判断手里的源码是否值得继续投入。验证时重点看两件事一是透视校正后边缘是否裁切严重二是涂卡阈值是否对不同铅笔深浅都稳定。如果源码里的阈值写死为固定值你可以自己改成相对比较法改动量很小收益却明显。我做这类项目得到的经验是识别率不是靠某一招制敌而是靠每一层把噪声降一点。预处理把光照问题消化掉 30%透视变换把几何问题消化掉 40%轮廓筛选把干扰消化掉 20%最后的判定逻辑只需要处理剩下的 10%。如果你照着这个思路逐层排查绝大多数识别失败的问题都能定位到具体的某一环而不是在整条流水线上盲猜。如果你决定动手做这个方向我建议从最小闭环开始先拿一张干净的扫描件跑通五步流程再逐步引入噪声和倾斜。前提是环境就绪OpenCV 装好图像准备好一次只改一个参数。这套流程我已经在多个课程设计和入门项目中验证过按这个顺序走通常半天内能跑出第一次正确识别。希望帮到你。本文还有配套的精品资源点击获取