Python+OpenCV实现答题卡识别:从图像预处理到自动评分
2026/9/23 15:22:31 网站建设 项目流程

简介:面向计算机相关专业毕业设计、课程设计与期末大作业场景,这份资料包提供完整的 Python 数字图像处理答题卡识别项目。项目经导师指导并获高分评价,代码可直接运行,适合需要快速搭建图像处理实战项目的学习者。资源压缩包共48个文件,以jpg图片样本、py源码、pyc编译文件、xml配置文件为主,另含答辩PPT、实验报告和html展示页面,整体仅2.99MB,便于下载与二次修改。源码模块划分清晰,涵盖答题卡区域定位、选择题识别、考号识别、基于VGG的训练脚本及应用主程序等环节,同时提供图片样本与数据组织方式,可帮助理解数字图像处理在真实评分场景中的落地流程。目前已有211人学习下载,适合作为毕业设计或期末大作业的参考模板。

1. 答题卡识别课设为什么值得做:一张图走完数字图像处理主流程

答题卡识别是数字图像处理课设里出现频率最高的题目之一,因为一张照片就能串起灰度化、滤波、边缘检测、轮廓提取、透视变换、阈值分割这些核心章节。用 python 和 OpenCV 实现定位答题卡、判定涂卡选项并自动评分的源码,代码量在三百到五百行之间,每个环节都有中间结果可以验证。把预处理、透视矫正、二值化、判分的中间图往答辩 PPT 里一放,评委不用想象就知道你做了什么。这里按我搭这套课设的路径,把代码、参数和容易翻车的地方写清楚,给准备课设的学生和要快速搭演示系统的工程师参考。

2. 图像预处理与答题卡轮廓定位:python 代码先把有效区域抠出来

2.1 预处理顺序怎么定:灰度、模糊、边缘检测各解决什么问题

手机或相机拍答题卡,照片里必然有桌面、手指、阴影这些背景干扰。如果直接对整个图像做阈值分割,背景和答题卡的灰度混在一起,后续没法做区域定位。常见做法是先灰度化降低通道维度,再用高斯模糊压制传感器噪点和纸张纹理,最后用 Canny 边缘检测把答题卡的四条边变成连续的边缘线。

顺序不能乱:模糊放在边缘检测之前,因为 Canny 内部第一步就是高斯滤波,手动再模糊一次相当于把噪声抑制得更干净,边缘会更连续。但模糊核不能太大,否则答题卡的边框和涂卡圆点会被一起抹掉。灰度化放在最前面,既减少计算量,也让后续所有形态学操作只在一个通道上进行。

import cv2 import numpy as np def load_and_preprocess(image_path, target_width=800): # 统一宽度,避免高分辨率图拖慢后续轮廓检测 img = cv2.imread(image_path) if img is None: raise FileNotFoundError(f"cannot read image: {image_path}") scale = target_width / img.shape[1] img = cv2.resize(img, (target_width, int(img.shape[0] * scale))) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 5x5 高斯核对 A4 纸拍照图足够,过大会让答题卡边框变淡 blurred = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blurred, 75, 200) return img, gray, edged

Canny 的低阈值和高阈值决定边缘连续性。低阈值 75、高阈值 200 是适配大多数室内光照照片的经验值:低于低阈值的像素直接丢弃,高于高阈值的像素必选,介于两者之间且与高阈值边缘连通的像素保留。如果答题卡边框断开,优先把高阈值降到 150 左右,而不是动低阈值;如果背景纹理也被标记成边缘,则把低阈值提到 100 以上。

2.2 定位答题卡轮廓的 python 实现与参数选择

拿到边缘图后,下一步是把答题卡的外边界从一堆边缘线里挑出来。答题卡一般是矩形,策略是:找出所有外轮廓,按面积排序,从最大的开始检查,找到第一个能用多边形逼近成四边形的轮廓,就是答题卡边界。这个做法的隐含假设是答题卡在画面里占据主要面积,拍照时稍微收一下范围,成功率会高很多。

def find_document_contour(edged): # OpenCV 4.x 的 findContours 返回两个值,3.x 返回三个,解包方式不同 contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True) for cnt in contours: peri = cv2.arcLength(cnt, True) # 0.02 是经验系数,太大会把真实边界削成三角形,太小则保留多余折点 approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) == 4: return approx return None

RETR_EXTERNAL 只取最外层轮廓,这一步直接排除答题卡内部的题目框、涂卡圆点这些内嵌轮廓。approxPolyDP 的多边形逼近系数设成周长的 2%,意思是拟合误差不超过周长的 2% 就接受,这个比例对矩形边框在拍照时产生的轻微弧线足够宽容。返回的 approx 是四个角点坐标,顺序是图像坐标系里的顺序,不保证从左上角开始,这留给下一章的透视变换去处理。

注意:如果你课设环境装的是 OpenCV 3.x,findContours 返回三个值,contours, _ = ...这一行会直接报错。建议在 requirements 里锁定 opencv-python 4.x,避免答辩前临时换代码。

2.3 预处理参数对照表和多场景验证

不同拍摄环境下,同一套参数的表现差别很大。下表是实测下来比较稳的调整区间,验证时建议每个参数至少试三个值并记录中间结果。

参数常用范围失败表现调整方向
高斯核大小(3,3) ~ (7,7)核太小边缘杂点多,太大边框断开背景杂乱加大核,图像清晰用 5x5
Canny 低阈值50 ~ 100边缘断裂是阈值过高降低低阈值,先看到连续边框
Canny 高阈值150 ~ 250背景纹理误检是高阈值过低只提高高阈值,保持低阈值不变
approxPolyDP 系数0.01 ~ 0.03四边形变三角形是系数过大降到 0.015 以内
resize 宽度600 ~ 1000过小丢失边缘细节保持 800,不依赖更高分辨率

验证时用三张不同光线、不同倾斜角度的照片跑同一段代码,把 edged 和轮廓叠加图都存下来对比。如果某张图定位失败,绝大多数情况是 Canny 参数问题,而不是轮廓筛选逻辑的问题。在代码里加一个--debug开关,每步把中间结果写到磁盘,调试效率会高很多,这个习惯在答辩现场也救过场。

3. 透视变换矫正与答题格坐标对齐:倾斜图也能稳定识别

3.1 四点透视变换的原理与坐标排序陷阱

第二章得到的四边形是拍摄视角下的形状,答题卡本身是矩形,拍出来却是任意四边形,直接用这个坐标去切答题格,每格的宽高都会变形,后续像素统计全部失真。解决办法是算一个单应矩阵,把四个角点映射到一个固定宽高的矩形上,这就是透视变换。OpenCV 的 getPerspectiveTransform 接收原图四点和目标矩形四点,但要求两点集按同一顺序排列,通常是左上、右上、右下、左下。顺序错了,矫正后的图会镜像、旋转或者被压扁。

坐标排序的经典做法是分两步:按 x+y 的和排序,和最小的是左上角,和最大的是右下角;按 x-y 的差值排序,差值最大的点是右上角,差值最小的点是左下角。这个规则在答题卡占画面主体、角度不是极端的情况下都成立。下表对应代码里的具体计算。

角点判定条件对应代码
左上x + y 最小np.argmin(pts.sum(axis=1))
右下x + y 最大np.argmax(pts.sum(axis=1))
右上x - y 最大np.argmin(np.diff(pts, axis=1).ravel())
左下x - y 最小np.argmax(np.diff(pts, axis=1).ravel())

3.2 透视变换的 python 实现:从四点排序到 warpPerspective

def order_points(pts): pts = pts.reshape(4, 2).astype("float32") rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上 rect[2] = pts[np.argmax(s)] # 右下 d = np.diff(pts, axis=1).ravel() rect[1] = pts[np.argmin(d)] # 右上 rect[3] = pts[np.argmax(d)] # 左下 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(tl - bl) height_right = np.linalg.norm(tr - br) 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]], dtype="float32") matrix = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, matrix, (max_width, max_height))

输出宽高为什么要取 max:倾斜视角下,矩形较远的一条边在图像里更短,如果取 min 会把较近的那条边压缩,导致答题格比例失真。取 max 相当于以最长的边为基准重建矩形,比例更接近答题卡本身。warpPerspective 默认插值是双线性,处理答题卡这类文字和圆点密集的图像够用,不需要换 INTER_CUBIC,后者更慢且没有可感知的精度提升。

3.3 矫正后的第二次阈值处理:OTSU 二值化为什么放在这一步

透视矫正后的图像已经是正视图,这时才适合做全局二值化。光照不均的问题在矫正后依然存在,直接用固定阈值(比如 127)会在一张纸上同时出现涂黑和灰块两种结果,所以用 OTSU 自适应地找阈值。OTSU 的假设是图像只有前景和背景两个峰,答题卡纸面白、涂卡笔迹黑,恰好满足。注意用 THRESH_BINARY_INV 把涂卡区域变成白色,后面的 countNonZero 统计的就是涂了多少。

warped = four_point_transform(img, doc_contour) warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 按位或组合:OTSU 自动选阈值,BINARY_INV 反转黑白 thresh = cv2.threshold(warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]

如果照片上答题卡本身有浅灰色底纹或者被揉皱了,OTSU 会把底纹也归为前景,这时可以退化到固定阈值加手动调参,或者在二值化前先做一次cv2.medianBlur(warped_gray, 3)。中值滤波在去底纹噪点上的效果比高斯滤波好,因为它不模糊边缘。这一步调完后,thresh 里白色像素的分布基本就是涂卡痕迹的分布,下一章的判定逻辑全部建立在这张图上。

4. 涂卡判定与自动评分:阈值分割和答案比对怎么写

4.1 涂卡区域的轮廓分组:按行排序再把选项从左到右编号

矫正并二值化之后,下一步是把每个涂卡位置切出来。这里有两种路线:如果答题卡版式固定,可以用行列坐标直接切片;更通用的做法是先找到所有涂卡格子的轮廓,再按坐标排序分组。第二种办法对不同排版容忍度高,课设答辩时也更好讲。

def sort_by_position(cnts, axis=1): # axis=1 按 y 排序,axis=0 按 x 排序 boxes = [cv2.boundingRect(c) for c in cnts] idx = np.argsort([b[axis] for b in boxes]) return [cnts[i] for i in idx], [boxes[i] for i in idx] def group_bubbles_into_rows(cnts, y_tol=15, min_area=50): cnts = [c for c in cnts if cv2.contourArea(c) > min_area] if not cnts: return [] cnts, boxes = sort_by_position(cnts, axis=1) rows = [] current = [cnts[0]] for i in range(1, len(cnts)): if abs(boxes[i][1] - boxes[i - 1][1]) < y_tol: current.append(cnts[i]) else: rows.append(sort_by_position(current, axis=0)[0]) current = [cnts[i]] rows.append(sort_by_position(current, axis=0)[0]) return rows

找到所有轮廓后,先按 y 坐标排序,把相邻 y 差值小于 y_tol 的轮廓聚成一行,行内再按 x 排序,就得到从左到右的 A、B、C、D。这里有两个常见问题:答题卡上如果有定位黑块或考试号数字,它们的轮廓会混进候选;涂卡圆点内部如果断裂,一个选项可能出现多个轮廓。处理办法是设置轮廓面积下界,比如cv2.contourArea(cnt) > 50,把印刷版的小噪点和断裂碎片过滤掉。

4.2 涂卡判定:用占比而不是绝对像素数

拿到单个选项轮廓后,要判断它是否被涂黑。最容易想到的做法是统计轮廓包围的白色像素数,但不同解析度、不同笔压导致绝对数值波动很大,所以应该和该格子的面积做比值。涂满与否的比值分界大致在 0.4 到 0.6 之间,我用 0.5,判单选的准确率在室内光照条件下能到九成以上。

def bubble_score(bubble_cnt, thresh, min_area=50): if cv2.contourArea(bubble_cnt) < min_area: return 0.0 x, y, w, h = cv2.boundingRect(bubble_cnt) mask = np.zeros(thresh.shape, dtype="uint8") cv2.drawContours(mask, [bubble_cnt], -1, 255, -1) cell = thresh[y:y + h, x:x + w] masked = cv2.bitwise_and(cell, cell, mask=mask[y:y + h, x:x + w]) total = cv2.countNonZero(masked) return total / float(w * h)

用 mask 限制统计范围很关键:直接对 boundingRect 里的像素做统计会把圆点外的空白区域也算进来,导致没涂的格子比值也很高。drawContours 把轮廓内部填充为白色,再 bitwise_and 只保留格子内部的前景像素,这样比值才反映真实的涂卡面积。调试时把每个格子的比值打印出来,如果未涂格子稳定在 0.1 以下、涂满格子稳定在 0.7 以上,这套判定就很稳。

4.3 单选题、多选和空题的评分逻辑

判定单选用逐题取最大值:一行有几个选项就有几个比值,最大且过阈值的就是答案。一旦涉及多选,取最大值的逻辑就错了,正确做法是每个选项独立和阈值比较,得到一组勾选项,再和答案键比对。空题的表现在于整行所有比值都低于阈值,这也要单独处理,避免误判成选了某个选项。

def detect_answers(rows, thresh, fill_thresh=0.5): result = [] for row in rows: scores = [bubble_score(c, thresh) for c in row] chosen = [s > fill_thresh for s in scores] result.append({'scores': scores, 'chosen': chosen, 'all_blank': not any(chosen)}) return result
判定场景判定方式阈值建议典型误判
单选涂卡取该行最大比值最大比值 > 0.5涂卡太淡导致最大比值偏低,可降到 0.4
多选题每个选项独立过阈值每个选项 > 0.5擦除不干净留下残迹,残迹占比往往在 0.3 以下
空题整行无选项过阈值印刷噪点被算入,靠 min_area 过滤
划痕干扰比值介于 0.3~0.5判为未涂无法完全避免,建议在 README 里注明限制

评分时把检测结果和答案键比对,单选按位置比最大值,多选按选项集合比对,最后统一按正确题数除以总题数算百分比分数。多选和单选混在一张卡上的情况,建议做题号到类型的映射表,逐题选择比对方式,而不是用同一种逻辑处理全部题目。

4.4 一个可运行的 main 流程串起整个管线

if __name__ == "__main__": img, gray, edged = load_and_preprocess("scan.jpg") doc = find_document_contour(edged) if doc is None: raise RuntimeError("答题卡轮廓未找到,检查拍摄范围") warped = four_point_transform(img, doc) thresh = cv2.threshold(cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY), 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] cnts, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) rows = group_bubbles_into_rows(cnts) answers = detect_answers(rows, thresh) score = grade(answers, answer_key) # grade 函数按 4.3 的映射表实现 print(f"score: {score:.1f}")

这个 main 把前三章的每个函数串联成一条可运行链路。实际课设里我会在 group_bubbles_into_rows 里额外返回每个格子的中心坐标,这样不仅能用cv2.circle把判定结果画回原图,还能生成一张带着对勾和叉号的批改结果图,这张图在答辩 PPT 里非常有说服力。评分逻辑单独抽成函数,方便单元测试时传入构造好的假数据,不用每次跑全流程。

5. 答辩 PPT 展示策略与源码交付的验证细节

5.1 答辩 PPT 里放哪些图、什么顺序讲

答辩 PPT 的核心不是讲代码,而是讲每一步图像处理解决了什么问题、效果如何变化。我的做法是放一张六宫格对比图:原图、边缘图、轮廓定位叠加图、透视矫正图、OTSU 二值图、最终批改结果图,按处理顺序从左到右、从上到下排。每张图配一句话说明它对应教材里的哪个知识点,比如透视矫正对应几何变换,二值化对应阈值分割。这样的结构让评委一眼看出你掌握了数字图像处理的完整链路,而不是只调了某个现成函数。

5.2 现场演示的验证命令和失败预案

演示环节最怕现场翻车。建议准备三张测试图:正常光照、强阴影、轻微倾斜,用命令行参数切换输入,并给脚本加一个--debug开关,把每步中间图写到 output 目录。一旦现场某张图定位失败,立刻能切到 debug 模式看边缘图,判断是 Canny 参数问题还是拍摄范围问题。调试开关的代码量很小,但它是答辩现场最有效的兜底手段。

python grade_sheet.py --image tests/indoor_light.jpg --debug python grade_sheet.py --image tests/hard_shadow.jpg python grade_sheet.py --image tests/slight_tilt.jpg

5.3 源码交付的工程化细节

源码交付不是扔一个单文件就行。我一般按下面的结构整理,既能体现工程意识,也方便评委在演示后自己跑:

answer_sheet_recognition/ ├── grade_sheet.py # 主入口,argparse 解析 --image 和 --debug ├── preprocessing.py # 读图、灰度、模糊、Canny ├── transform.py # 角点排序、透视变换 ├── bubble_detect.py # 轮廓分组、涂卡判定 ├── grade.py # 答案比对、评分 ├── requirements.txt # opencv-python, numpy ├── tests/ # 三张测试图和对应答案键 └── README.md # 运行方式、参数表、已知限制

requirements.txt 里写明opencv-pythonnumpy,给一个经过验证的最低版本,不额外加 imutils 这类辅助库,减少评委复现时的环境问题。README 里除了运行命令,把第二节的参数表和第四节表格里的已知限制写进去,特别是涂卡太淡、擦除不干净、极端角度这三类失败场景。诚实写明限制比答辩时被问倒要好得多,而且这些限制本身就是评委感兴趣的技术讨论点。最后把每章的中间结果图放进 PPT 附录,以备被问到某个环节时随时翻出来讲。

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

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

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

立即咨询