1. 为什么需要答题卡自动识别系统每次考试结束后老师们最头疼的就是批改答题卡。传统的人工阅卷方式不仅效率低下而且容易因为疲劳导致误判。我记得有一次监考看到老师们加班到深夜批改试卷当时就在想能不能用技术来解决这个问题OpenCV这个强大的计算机视觉库正好能派上用场。通过图像处理技术我们可以实现答题卡的自动识别与评分整个过程只需要几秒钟。想象一下学生刚交卷系统就能立即给出成绩这不仅能减轻教师负担还能让学生及时获得反馈。这个系统特别适合以下场景学校期中/期末考试培训机构随堂测验各类资格认证考试线上教育平台的自动评测2. 环境准备与基础配置2.1 安装必要的软件包在开始之前我们需要准备好Python环境和必要的库。我推荐使用Anaconda来管理环境这样可以避免各种依赖冲突。以下是需要安装的包pip install opencv-python numpy matplotlib这里有个小技巧安装opencv-python时可以加上-i https://pypi.tuna.tsinghua.edu.cn/simple参数使用国内镜像速度会快很多。2.2 准备测试图像找一张标准的答题卡图片作为测试素材。建议先用手机拍摄注意以下几点尽量保持正面拍摄避免角度倾斜确保光线均匀不要有阴影背景要干净不要有其他干扰物我准备了张样例图片你可以从这里下载[样例图片链接]3. 图像预处理关键技术3.1 灰度转换与降噪处理原始彩色图像包含太多冗余信息我们首先需要转换为灰度图import cv2 import numpy as np image cv2.imread(answer_sheet.jpg) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)转换后图像会变成单通道的灰度图。但直接处理可能会有噪声干扰这时就需要高斯模糊来降噪blurred cv2.GaussianBlur(gray, (5, 5), 0)这里的(5,5)是卷积核大小数值越大模糊效果越明显。我测试过5×5的核在保持边缘清晰度和去噪效果之间取得了不错的平衡。3.2 边缘检测与轮廓提取边缘检测是识别答题卡边界的关键步骤。我们使用Canny算法edged cv2.Canny(blurred, 75, 200)这两个阈值参数很关键第一个阈值(75)低于此值的边缘会被丢弃第二个阈值(200)高于此值的边缘会被保留介于两者之间的边缘会根据连通性决定是否保留检测到边缘后就可以提取轮廓了cnts, _ cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)4. 透视变换与答案区域定位4.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: docCnt approx break4.2 执行透视变换有了四个角点就可以进行透视变换了def four_point_transform(image, pts): # 对四个点进行排序左上、右上、右下、左下 rect order_points(pts) (tl, tr, br, bl) rect # 计算新图像的宽度 widthA np.sqrt(((br[0]-bl[0])**2)((br[1]-bl[1])**2)) widthB np.sqrt(((tr[0]-tl[0])**2)((tr[1]-tl[1])**2)) maxWidth max(int(widthA), int(widthB)) # 计算新图像的高度 heightA np.sqrt(((tr[0]-br[0])**2)((tr[1]-br[1])**2)) heightB np.sqrt(((tl[0]-bl[0])**2)((tl[1]-bl[1])**2)) maxHeight max(int(heightA), int(heightB)) # 定义目标图像四个角点 dst np.array([ [0, 0], [maxWidth-1, 0], [maxWidth-1, maxHeight-1], [0, maxHeight-1]], dtypefloat32) # 计算变换矩阵并执行变换 M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped warped four_point_transform(gray, docCnt.reshape(4, 2))5. 答案识别与评分实现5.1 二值化处理为了更好地区分填涂区域我们需要进行二值化thresh cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]这里使用了OTSU算法自动确定最佳阈值省去了手动调参的麻烦。5.2 检测填涂选项现在要找出所有可能的选项圆圈cnts, _ cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) questionCnts [] for c in cnts: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) # 根据宽高比和大小筛选 if w 20 and h 20 and 0.9 ar 1.1: questionCnts.append(c)5.3 选项排序与匹配将选项按从上到下、从左到右排序def sort_contours(cnts, methodleft-to-right): reverse False i 0 if method right-to-left or method bottom-to-top: reverse True if method top-to-bottom or method bottom-to-top: i 1 boundingBoxes [cv2.boundingRect(c) for c in cnts] (cnts, boundingBoxes) zip(*sorted(zip(cnts, boundingBoxes), keylambda b:b[1][i], reversereverse)) return cnts, boundingBoxes questionCnts sort_contours(questionCnts, methodtop-to-bottom)[0]5.4 评分实现最后是评分逻辑ANSWER_KEY {0:1, 1:3, 2:0, 3:2, 4:4} # 假设正确答案 correct 0 for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)): cnts sort_contours(questionCnts[i:i5])[0] bubbled None for (j, c) in enumerate(cnts): mask np.zeros(thresh.shape, dtypeuint8) cv2.drawContours(mask, [c], -1, 255, -1) mask cv2.bitwise_and(thresh, thresh, maskmask) total cv2.countNonZero(mask) if bubbled is None or total bubbled[0]: bubbled (total, j) # 检查答案 if ANSWER_KEY[q] bubbled[1]: correct 1 score (correct / 5.0) * 100 print(f得分: {score:.2f}%)6. 实际应用中的优化建议在实际项目中我发现有几个地方需要特别注意光照条件不同光线条件下二值化效果差异很大。可以考虑加入自适应阈值处理thresh cv2.adaptiveThreshold(warped, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)答题卡设计最好在答题卡四周加入明显的定位标记这样能提高轮廓检测的准确性。异常处理要考虑到学生可能漏题或多选的情况代码中需要加入相应的判断逻辑。性能优化处理大量答题卡时可以考虑使用多线程或批处理来提高效率。这个系统我已经在实际教学中应用了两年准确率能达到98%以上。最大的收获不是技术本身而是看到老师们从繁重的阅卷工作中解放出来时的那种喜悦。技术最终还是要为人服务的这也是我做这个项目的初衷。