答题卡检测与自动批改:基于Python+OpenCV的机器视觉实践
简介:基于Python的答题卡处理源码包,实现了答题卡检测、试题切分、学生考号识别与选择题自动批改的完整流程,面向计算机、电子信息等专业学生,适合作为课程设计、期末大作业或毕业设计的参考,也适合入门者通过源码实践理解图像处理与识别算法。资源共35个文件,包含8个Python脚本、2个Jupyter Notebook演示文档、多张jpg/png样例图片以及Dockerfile、requirements.txt等环境配置,压缩包仅6.04MB,轻量易部署。代码按功能模块划分,涵盖检测、切分、考号识别和批改等环节,并附有测试脚本与样例图片,便于对照调试;Dockerfile可快速搭建运行环境,降低复现门槛。已有237人学习下载,适合需要完整项目方案或动手实战的开发者参考。
1. 答题卡检测与自动批改:一套让毕设答辩现场直接演示的 Python 方案
每年毕业季都有同学抱着「答题卡识别」这个题目找现成方案。商用 OMR 软件一小时就能跑通,但核心逻辑全封装在黑匣子里,答辩现场解释不了内部原理。基于 Python 的答题卡检测、试题切分、学生考号识别和选择题自动批改系统,正好是典型的机器视觉落地项目:拍一张照片进来,经过预处理、轮廓提取、透视矫正、网格切分、涂卡判断,最后算出分数,把 OpenCV 的核心知识点串成一条完整链路,也适合直接拿去做毕设演示。
这套方案解决的具体问题是:批量读取答题卡照片,自动定位答题区域、还原考号、逐题判断涂卡状态并生成得分。适合的人群也很明确——正在做毕设的学生、想从「看了教程还是不会写」进入实际项目的开发者,以及要给自己系统里加一个自动批改模块的工程人员。下面按每个模块拆开讲,参数和坑都会带到。
2. 答题卡定位与透视矫正:从 Canny 边缘提取到单应性矩阵的完整链路
2.1 预处理链路:为什么按灰度 → 高斯模糊 → Canny → 膨胀的顺序走
手持拍摄或扫描仪出来的答题卡照片,背景里经常有桌面纹理、手指、阴影,直接丢给轮廓查找会把背景里的杂物一起当成候选。处理这类卡片的常规链路是:先把彩色图转灰度、高斯模糊去噪、Canny 提边缘,再膨胀让边缘断口闭合。如果还没装 OpenCV,先 pip install opencv-python numpy ,后面所有代码都依赖这两个库。
import cv2
import numpy as np
def load_resized(image_path, max_width=1200):
"""读图并把宽度压到 1200 以内,统一后续参数基准"""
img = cv2.imread(image_path)
if img is None:
raise ValueError(f"读不到图片: {image_path}")
h, w = img.shape[:2]
if w > max_width:
scale = max_width / w
img = cv2.resize(img, (max_width, int(h * scale)))
return img
def preprocess_to_edges(img):
# 彩色图转灰度,减少光照和颜色噪声
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 50, 150)
kernel = np.ones((5, 5), np.uint8)
dilated = cv2.dilate(edges, kernel, iterations=2)
return dilated
这里有两个参数值得留意。第一个是 max_width=1200 :分辨率越大, cv2.findContours 耗时越长,而答题卡这种结构化对象 1200 宽度足够稳定识别。第二个是 Canny 的低阈值 50、高阈值 150:如果卡面打印颜色偏浅、边缘对比度低,把低阈值降到 30 左右,太高会滤掉答题卡的外框细线。膨胀的 iterations=2 是经验值,一次不够时边框缺口闭合不上,三次以上会让边缘线条粗到连成一片。
预处理这一步看起来简单,但后面所有顶点坐标、切分比例都建立在“边缘图干净”这个前提上。如果预处理结束后的边缘图上仍然能看到背景里的物体边缘,不要急着往下走,先调 Canny 阈值或者检查拍照角度。
2.2 四边形轮廓提取与顶点排序:面积筛选与四点排序细节
拿到干净的边缘图后,用 cv2.findContours 找外轮廓。答题卡一般是画面里面积最大的四边形,所以候选按面积降序排,找到的第一个四边轮廓就当作卡面。关键点是只用 RETR_EXTERNAL ,如果用了 RETR_LIST 或 RETR_TREE ,会把答题卡内部题号框、涂卡圆框也当成候选,严重影响排序。
def find_card_contour(dilated):
# 只取最外层轮廓,内部题号框和气泡圆不参与竞争
contours, _ = cv2.findContours(
dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
contours = sorted(contours, key=cv2.contourArea, reverse=True)
for c in contours:
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
area = cv2.contourArea(approx)
if len(approx) == 4 and area > 50000:
return approx
return None
approxPolyDP 的 epsilon 取 0.02 * peri ,意思是对周长做 2% 的逼近误差。这个值太小会把边缘的锯齿全留下来,太大则把圆角答题卡压成三角形。面积下限 50000 是用像素过滤小杂物,如果图片缩到 800 宽,下限要跟着调到 30000 左右。
轮廓找到之后,接下来是一个很隐蔽的坑: findContours 返回的四个顶点顺序并不固定,可能是逆时针、顺时针,也可能从任意点开始。如果不做排序,后面透视变换的顶点对应关系会乱,矫正出来的图可能是旋转 90 度甚至镜像的。我用的是经典的 sum/diff 排序法——和最小的是左上角,和最大的是右下角,差最小的是右上角,差最大的是左下角。
def order_points(pts):
rect = np.zeros((4, 2), dtype="float32")
s = pts.sum(axis=1) # x + y
diff = np.diff(pts, axis=1) # 第二列减第一列,即 y - x
rect[0] = pts[np.argmin(s)] # 左上:和最小
rect[2] = pts[np.argmax(s)] # 右下:和最大
rect[1] = pts[np.argmin(diff)] # 右上:y - x 最小
rect[3] = pts[np.argmax(diff)] # 左下:y - x 最大
return rect
需要强调: np.diff 得到的是 y - x ,不是 x - y 。左上角和右下角的判断用 sum 没问题,但右上角与左下角如果搞反,矫正出来的图会上下翻转,而程序没有任何报错,这种错误最难发现。我见过好几个同学的毕设就栽在这一行上。
2.3 透视矫正:把四边形投影成正视图的数学落点
得到排好序的四点后,把原图的这个四边形区域投影到一个正矩形上。这里特别要说明输出尺寸的计算:不要用固定宽高,而是用原始四边形的边长去算目标矩形,否则答题卡内容会被拉伸变形,切分坐标就全废了。
def perspective_transform(img, pts):
rect = order_points(pts.reshape(4, 2))
(tl, tr, br, bl) = rect
# 上边与下边取较长者作为输出宽,左右边取较长者作为输出高
width_a = np.linalg.norm(br - bl)
width_b = np.linalg.norm(tr - tl)
height_a = np.linalg.norm(tr - br)
height_b = np.linalg.norm(tl - bl)
max_width = max(int(width_a), int(width_b))
max_height = max(int(height_a), int(height_b))
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)
warped = cv2.warpPerspective(img, matrix, (max_width, max_height))
return warped
getPerspectiveTransform 只需要四个点就能算单应性矩阵,但它对顶点精度很敏感,任何一个顶点偏了 2 像素,矫正结果就可能是斜的。所以前面 approxPolyDP 的 epsilon 别贪大。 warpPerspective 默认用双线性插值,答题卡这种黑白高对比图像够用了,不需要开 INTER_CUBIC ,后者只是更慢,对圆框边缘的改善微乎其微。
矫正完的 warped 就是我们后面所有处理的基准图。到这一步,我一般先 cv2.imwrite 存下来肉眼检查一遍,确认外框是矩形、文字方向正常,再进入考号识别和试题切分。这一步检查能省掉后续很多“为什么全错”的排查时间。
3. 学生考号识别与试题切分:把答题卡网格化成可统计的数据结构
3.1 学生考号识别:涂卡式气泡阵列的逐位判定
考号识别有两条路线:一种是直接用 OCR 识别打印好的数字,另一种是识别学生涂的黑点气泡矩阵。新版源码里走的是后者,因为气泡矩阵的识别逻辑和选择题涂卡判断完全复用,代码结构上也更统一。
先约定配置。考号区域在矫正图中的位置,我一般用相对坐标而不是绝对坐标,这样换分辨率的图也不用改配置:
config = {
'id_x1': 0.08, 'id_y1': 0.06, # 考号区左上角相对矫正图宽高
'id_x2': 0.40, 'id_y2': 0.22, # 考号区右下角相对矫正图宽高
'id_rows': 10, 'id_cols': 10, # 每位考号 0-9,共 10 位
'grid_x1': 0.05, 'grid_y1': 0.30, # 选择题区左上角
'grid_w': 0.90, 'grid_h': 0.65, # 选择题区宽高
'question_count': 20, # 选择题个数
'option_count': 4, # 每道题选项数
}
| 参数 | 含义 | 常见取值 |
|---|---|---|
| id_x1 / id_y1 | 考号区左上角在矫正图中的相对位置 | 0.08 / 0.06 |
| id_x2 / id_y2 | 考号区右下角相对位置 | 0.40 / 0.22 |
| id_rows | 每位考号的可选数字个数 | 10(数字 0-9) |
| id_cols | 考号位数 | 10 |
| grid_w / grid_h | 选择题区域宽度和高度占比 | 0.90 / 0.65 |
考号识别函数如下:把考号区域二值化,然后按 10 行 × 10 列切成单个气泡格,统计每格里前景像素占比,占比最大且超过阈值的行号就是这一位的数字。
def extract_student_id(warped_gray, config):
h, w = warped_gray.shape[:2]
x1, y1 = int(w * config['id_x1']), int(h * config['id_y1'])
x2, y2 = int(w * config['id_x2']), int(h * config['id_y2'])
region = warped_gray[y1:y2, x1:x2]
# THRESH_BINARY_INV:涂黑的格子变成白色前景,方便 countNonZero
_, thresh = cv2.threshold(
region, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
)
rows, cols = config['id_rows'], config['id_cols']
cell_h, cell_w = thresh.shape[0] // rows, thresh.shape[1] // cols
student_id = ""
for col in range(cols):
max_ratio, best_row = 0.0, 0
for row in range(rows):
cell = thresh[row * cell_h:(row + 1) * cell_h,
col * cell_w:(col + 1) * cell_w]
ratio = cv2.countNonZero(cell) / (cell_h * cell_w)
if ratio > max_ratio:
max_ratio, best_row = ratio, row
# 整列都没有明显涂卡时按 0 处理,避免乱识别
student_id += str(best_row if max_ratio > 0.12 else 0)
return student_id
这里 0.12 是涂卡判定下限。如果学生用 2B 铅笔正常涂,一个格子的黑色覆盖一般会到 25% 以上;浅涂或扫描偏淡时可能掉到 15%。取 0.12 是给浅涂留余量。注意这里用的是绝对阈值,实际批次换打印纸或换光源后会漂移,这个问题的根治方案放在最后一章说。
3.2 试题切分:按行高列宽把选择题区域拆成单元格
选择题切分最稳的方式是基于配置直接切网格,而不是用轮廓去找每个选项格。原因很简单:涂卡后格子已经被大面积黑色占满,轮廓和圆圈对不上;而矫正图是规整矩形,按比例切分误差很小。
def split_choice_grid(warped_gray, config):
h, w = warped_gray.shape[:2]
gx1 = int(w * config['grid_x1'])
gy1 = int(h * config['grid_y1'])
g_w = int(w * config['grid_w'])
g_h = int(h * config['grid_h'])
q_count = config['question_count']
opt_count = config['option_count']
cell_w = g_w // opt_count
cell_h = g_h // q_count
cells = []
for q in range(q_count):
row_cells = []
for opt in range(opt_count):
x = gx1 + opt * cell_w
y = gy1 + q * cell_h
row_cells.append(warped_gray[y:y + cell_h, x:x + cell_w])
cells.append(row_cells)
return cells # cells[q][opt] 是第 q+1 题第 opt 个选项的灰度图
上面的逻辑默认题目按行排列:同一道题的 ABCD 横向排在一行。如果你的答题卡是纵向排列,也就是所有 A 选项在一列、所有 B 选项在另一列,把 opt 和 q 的索引交换即可。很多同学在这一步直接照搬别人的代码,没有意识到行列顺序是答题卡布局决定的。
切分完成后,建议顺手把格子边界画回原图存下来。用红框把 20×4 个格子框出来,和真实答题卡叠在一起看,比对着坐标想象可靠得多。这一步做一次,后面所有分析都踏实。
3.3 涂卡状态判定:OTSU 阈值与非零像素占比
单个格子的涂卡判断是整个系统里最核心的小函数。它做的事是:输入一个灰度格子,输出这个格子是否被涂。标准做法是 OTSU 二值化后统计非零像素占比,占比超过阈值就视为涂卡。
def cell_is_marked(cell_gray, ratio_threshold=0.15):
"""判断一个选项格是否被涂黑"""
blurred = cv2.GaussianBlur(cell_gray, (3, 3), 0)
_, thresh = cv2.threshold(
blurred, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
)
ratio = cv2.countNonZero(thresh) / thresh.size
if ratio >= ratio_threshold:
return True, ratio
return False, ratio
ratio_threshold=0.15 的含义是格子内至少 15% 的像素被判为前景,才认为被涂。这个值的选取逻辑是:正常涂卡覆盖率 25%-50%,空白格因为印刷底纹会有 3%-8% 的噪声,15% 正好卡在两类分布的中间。如果答题卡印刷底纹很深,噪声可能到 10%,阈值就得提到 18% 以上。
实际批量识别时,我会把每个格子的 ratio 值也返回而不是只返回布尔值。原因是纯布尔结果出了问题很难定位:到底是阈值低了还是格子切偏了?有了 ratio,一眼就能看出来是 0.1 附近徘徊的还是 0.5 稳定命中的。
4. 自动批改与结果导出:答案比对、得分统计与可视化核对
4.1 答案比对逻辑:单选与多选的得分策略
答案比对的核心不是 == ,而是把考生的涂卡结果规范化成和标准答案同样的字符串格式。前面 cell_is_marked 已经给出了每个格子的布尔值,按题目聚合:把 A-D 四个格子拼成 BC 这样的字符串,再与标准答案比较。
def grade_choices(cells, standard_answers, score_per_question=2):
"""
cells: split_choice_grid 返回的三维结构
standard_answers: {1: 'B', 2: 'A', 3: 'BCD', ...}
"""
results = {}
for q_idx, row in enumerate(cells):
marked = []
for opt_idx, cell in enumerate(row):
marked_flag, _ = cell_is_marked(cell)
if marked_flag:
marked.append(chr(ord('A') + opt_idx))
student_answer = ''.join(marked)
standard = standard_answers.get(q_idx + 1, '')
if student_answer == standard:
results[q_idx + 1] = {
'answer': student_answer,
'score': score_per_question,
'correct': True
}
else:
results[q_idx + 1] = {
'answer': student_answer,
'score': 0,
'correct': False
}
return results
这里有个得分策略的细节:如果是单选题,学生答案拼出来只能是一个字母,比如 A ;多选题拼出来可能是 BCD 。拼接顺序要按选项字母顺序,否则答案本身对但字符串顺序不一致会被误判。实际阅卷中多选题还有“部分得分”策略,例如标准 BCD 、考生 BC 得一半分。要不要加这个逻辑,取决于题目定义,源码包里一般有一个 partial_score 开关,开启时按“正确选项命中数 / 标准选项总数 × 满分”计算,按需调整即可。
4.2 导出双结果:CSV 明细表与错误标注图
只输出一个总分的系统在答辩和复查时都不好用。我一般同时导出两份:一份 CSV 明细,记录每道题的考生答案、标准答案、得分;一份标注图,在矫正图上把错题框标红。这样使用的人既能按题号追查,也能从图上直观看是哪道题出了问题。
import csv
def export_csv(results, standard_answers, student_id, output_path):
total = 0
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
writer.writerow(['考号', '题号', '考生答案', '标准答案', '得分'])
for q in sorted(results.keys()):
info = results[q]
total += info['score']
writer.writerow([
student_id, q,
info['answer'],
standard_answers.get(q, ''),
info['score']
])
writer.writerow(['', '', '总分', '', total])
utf-8-sig 编码是为了让 Excel 打开 CSV 不乱码,这是导出中经常被忽略的点。如果用了 utf-8 不带 BOM,Windows 上的 Excel 看到中文列名会直接显示成乱码。
标注图用 cv2.rectangle 在 warped 彩色图上重画网格,错题涂红框,正确题涂绿框。同时把每道题的题号写在框内,方便跟 CSV 对照:
def draw_marked_grid(warped_bgr, cells, results, config):
canvas = warped_bgr.copy()
h, w = canvas.shape[:2]
gx1 = int(w * config['grid_x1'])
gy1 = int(h * config['grid_y1'])
cell_w = int(w * config['grid_w']) // config['option_count']
cell_h = int(h * config['grid_h']) // config['question_count']
for q, row in enumerate(cells):
is_correct = results[q + 1]['correct']
color = (0, 0, 255) if not is_correct else (0, 255, 0)
for opt in range(config['option_count']):
x = gx1 + opt * cell_w
y = gy1 + q * cell_h
cv2.rectangle(canvas, (x, y),
(x + cell_w, y + cell_h), color, 2)
# 题号写在该题第一个选项格左侧
text_y = gy1 + q * cell_h + 20
cv2.putText(canvas, str(q + 1), (gx1 - 30, text_y),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
return canvas
红绿框只标对错,不标正确选项位置,所以配合 CSV 就能精确定位到某个具体格子。这套组合拳下来,批改结果基本就是“可视化可审计”的状态,比直接甩出一个总分有说服力得多,尤其是毕设答辩演示阶段。
5. 避坑指南:五个最容易翻车的位置与排查方法
5.1 透视矫正后图像还是斜的
现象: warpPerspective 输出图上答题卡边缘仍然带弧形,或者上下边不平行,切分出来的格子歪斜。
原因:普通手机镜头有明显的桶形畸变,单应性矩阵假设目标面是平面,但实际成像面是弯曲的,所以四周区域会有残余扭曲。加上 approxPolyDP 得到的顶点如果带 1-2 像素误差,放大到整张图上就变成几度的倾斜。
解决:拍摄时让卡片尽量占满画面、贴近画面中心。如果批量拍摄条件固定,先用棋盘格标定一次镜头畸变,在 warpPerspective 前做 cv2.undistort ;没有标定条件时,把图像四周裁掉 3%-5% 再检测轮廓,能明显降低边缘畸变影响。另外把 approxPolyDP 的 epsilon 从 0.02 降到 0.01,顶点会更贴合真实边角。
5.2 考号识别结果串位
现象:识别出来的考号部分位数正确、部分错位,例如实际是 20240713,识别成 20240731,且错误集中在后几位。
原因:打印批次不同,答题卡的左边距和上边距有 1-3mm 的裁切公差。相对坐标定位的考号区域在公差累积后,气泡矩阵整体和切分网格错开了半个格宽。
解决:在配置里给考号区做一次手动校准,印一张已涂好考号的样卡,拍照识别后微调 id_x1/id_y1 ,偏差通常只有几像素。更自动化的做法是检测考号区每一行的投影:竖直投影会有明显的十个波峰,如果波峰数量不等于 id_cols ,说明区域切偏了,按波峰中心重新定位。
5.3 浅涂和擦不干净引发的误判
现象:浅涂选项被识别成空白,橡皮擦过的地方反而被识别成涂卡。
原因:OTSU 是全局阈值,浅涂和深涂出现在同一张卡上时,浅涂的灰度可能被判成背景。橡皮擦残留则是小面积高密度噪声,绝对阈值判不出来。
解决:改用相对阈值。对一道题的四个格子,按格子覆盖率降序排列,取最大值和次大值的平均值作为本地阈值。正常情况最大值明显高于次大值,一判就准;只有最大值和次大值非常接近时,才退回绝对阈值并给人工复核标记。这个策略对浅涂和残留都有效,是我在这个项目里最推荐的一条改动。
5.4 光线不均导致一侧识别失败
现象:同一张答题卡,左边选项识别正常,右边全部判为空。
原因:拍摄角度或环境光造成卡面灰度从亮到暗的梯度。全局 OTSU 阈值只能抓住亮区的特征,暗区里浅涂的灰度整体更低,就滑到背景侧了。
解决:把 OTSU 做在单个格子上而不是整张卡上,每个格子都有自己的局部阈值,光线梯度的影响就基本被抵消了。如果局部阈值仍不稳定,先对矫正图做 CLAHE 均衡: clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) ,再用均衡后的图做网格切分。这样做之后识别率的不确定性明显降低。
5.5 切分网格与印刷区错位
现象:前 5 题识别全对,第 6 题开始题目整体错位一格,后面错位越来越多。
原因:打印时答题卡模板本身没有对齐,或者扫描时卡面在进纸方向上有偏移。配置里的 grid_y1 是一个整体起点,一旦印刷起点变化,后面的网格全跟着偏。
解决:在矫正图上先找定位标记。正规答题卡会在选择题区四周印黑色基准块,用轮廓检测找到这些基准块的中心,以它为网格起点,而不是直接用配置里的相对坐标。当检测不到基准块时,就检查预处理是否会腐蚀掉这些细小的黑色标记,必要时把膨胀的 iterations 降回 1。
6. 一个进阶技巧:批量识别前先做基线校准,稳定性直接上一个台阶
前面提到阈值会受打印批次、光源、扫描仪影响,解决这个问题最实用的办法不是反复调参,而是给整批答题卡做一次“空白基线校准”。具体做法:从同一批答题卡里抽出三张空白卡(没涂任何内容),放进同样的拍摄环境走下同一套预处理和切分流程,统计所有格子位置的灰度均值和噪声水平,存成一个 JSON 校准文件。真正识别时拿当前卡片的格子灰度去和基线对比,而不是和固定的 0.15 阈值硬比。
def calibrate_baseline(blank_cards_paths, config):
"""统计空白卡每个格子位置的灰度均值,返回基线矩阵"""
cell_means = np.zeros((config['question_count'], config['option_count']))
for path in blank_cards_paths:
img = load_resized(path)
# 这里复用前面的透视矫正,得到矫正后的灰度图
warped_gray = get_warped_gray(img)
cells = split_choice_grid(warped_gray, config)
for q, row in enumerate(cells):
for opt, cell in enumerate(row):
cell_means[q][opt] += cell.mean() / len(blank_cards_paths)
return cell_means.tolist()
识别当前卡时,每个格子的判定语句就变成:
dynamic_threshold = baseline[q][opt] + margin
marked = cell.mean() < dynamic_threshold
空白格会因为印刷底纹和噪点有一个稳定的灰度基线,一旦真正涂黑,灰度会显著低于这个基线,所以这个判定方式对批次差异并不敏感。注意 margin 要设置得比基线到涂黑格的距离小很多,否则会把浅涂漏掉。
校准完之后强烈建议先跑一遍可视化验证:把当前卡每个格子的灰度值按位置打印成 20×4 的矩阵,和基线矩阵做差值。差值矩阵里正常涂卡的位置会明显“凹”下去,如果整行都凹下去,那大概率是这道题整行都有涂卡;如果整列都不对,说明切分坐标问题,网格整体偏移了。这个方法能在一分钟内分辨出是切分问题还是涂卡问题,省掉盲目调参的时间。
从那以后我每次拿到新一批答题卡,都会强制自己先花五分钟做基线校准、存校准 JSON,再批量跑识别。这个习惯帮我避开了大多数标着「玄学」的识别失败——它们大部分不是算法不行,而是作业环境变了、阈值还留在原地。希望帮到你。
更多推荐
所有评论(0)