
简介这是一套面向计算机视觉初学者与课程设计者的智能答题卡识别完整项目基于Python与OpenCV结合深度学习与图像识别算法解决答题卡自动检测、选项识别与分数统计问题。资源包共60个文件包含39张jpg样本图像、8个xml标注文件、5个py源码脚本及pyc缓存、iml工程配置、xls成绩表与txt说明压缩包约38.86MB覆盖从图像预处理、答题卡定位、选项区域分割到CNN选项分类与答案判定的完整链路。项目已积累448人学习下载适合作为图像识别入门实战或毕业设计参考。读者可获得可直接运行的源码、训练与测试数据集、标注文件及项目文档借助xls成绩表与readme说明快速理解答题卡识别流程并在此基础上替换数据集、调整模型结构或扩展题型完成从数据准备到分数计算的全流程实践。1. 从一张拍歪的答题卡说起PythonOpenCV 智能识别到底能落地到什么程度考场收上来一摞答题卡手机随手一拍边缘歪了、光照不均、铅笔涂得深浅不一这种图丢给传统模板匹配基本当场翻车。基于 PythonOpenCV 的智能答题卡识别系统要解决的就是把这种“脏图”自动转成结构化分数先做图像预处理把卡面摆正、把填涂区域切出来再用图像识别算法判断每个选项是否被涂黑最后输出每道题的答案和得分。它适合两类人一类是想拿一个完整项目练手 Python 图像处理与深度学习入门的学生另一类是需要批量阅卷、想把人工从重复劳动里解放出来的教务或培训场景开发者。整套链路里OpenCV 负责几何校正和形态学操作深度学习模型负责在模糊、污损、多选等边界情况下兜底两者配合才是能真正跑起来的方案。2. 答题卡识别的技术链路从 OpenCV 预处理到 CNN 判题2.1 为什么不能只靠模板匹配必须引入深度学习传统答题卡识别最常见的做法是固定机位、固定模板用轮廓检测找到四个定位黑块做透视变换后按坐标切出每个选项区域再统计区域内黑色像素占比判断是否填涂。这套方法在理想条件下够用但它有三个硬伤一是对拍摄角度和光照极其敏感稍微偏一点定位块就找不准二是涂改、多涂、半涂的情况靠阈值很难区分三是不同学校、不同场次的答题卡版式一变模板就得重做。深度学习在这里的价值不是替代 OpenCV而是补上“判断”这一环。把每个选项区域切成小图送进一个轻量 CNN 做二分类涂了/没涂模型能学到铅笔灰度、涂改痕迹、边缘模糊这些手工阈值描述不清的特征。实际工程里我一般这样分工OpenCV 负责所有几何和形态学操作保证切出来的选项区域位置准确CNN 只负责在切好的小图上做分类输入尺寸固定推理速度快也方便后续换模型。这样即使版式微调只要定位逻辑还在模型不用重训。2.2 图像预处理把拍歪的答题卡摆正预处理的目标只有一个让后续切出来的每个选项区域都落在预期坐标上。核心步骤是灰度化、二值化、找定位块、透视变换。下面这段代码是我常用的定位与校正流程基于 OpenCV 实现。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)] # 左上角 sum 最小 rect[2] pts[np.argmax(s)] # 右下角 sum 最大 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上角 diff 最小 rect[3] pts[np.argmax(diff)] # 左下角 diff 最大 return rect def four_point_transform(image, 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]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped def preprocess(img_path): image cv2.imread(img_path) 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) # 自适应阈值比固定阈值更抗光照不均 thresh cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) cnts, _ cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts sorted(cnts, keycv2.contourArea, reverseTrue)[:5] docCnt None for c in cnts: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: # 找到近似四边形的最大轮廓 docCnt approx break if docCnt is None: raise ValueError(未找到答题卡四边形轮廓检查背景对比度) warped four_point_transform(orig, docCnt.reshape(4, 2) * ratio) return warped这段代码里几个参数直接决定成败。GaussianBlur的核大小(5,5)是经验值图像分辨率高时可以调到(7,7)adaptiveThreshold的blockSize11表示每个像素参考周围 11×11 区域算阈值光照越不均可以适当加大但必须是奇数C2是从均值里减去的常数值越大二值化越“狠”涂得浅的痕迹容易被抹掉一般 2 到 5 之间调。approxPolyDP的0.02*peri是轮廓逼近精度太小会把噪点当顶点太大四边形会退化成三角形。最后docCnt.reshape(4,2)*ratio是把缩小图上的坐标还原回原图这一步漏了透视变换就会切错位置。2.3 选项区域切分与填涂判定校正后的答题卡是标准矩形接下来按版式比例切出每个题目的选项区域。常见做法是先用形态学操作把填涂的圆形或方形区域分离出来再按行列排序。下面这段代码演示如何从校正图中提取选项并做初步判定。def extract_bubbles(warped, question_rows25, options4): gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] # 用开运算去掉细小的噪点和文字笔画 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) thresh cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) 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) # 选项区域通常是接近正方形的宽高比在 0.8~1.2 之间 if w 20 and h 20 and 0.8 ar 1.2: questionCnts.append(c) # 按从上到下、从左到右排序 questionCnts sort_contours(questionCnts, methodtop-to-bottom)[0] results [] for (q, i) in enumerate(np.arange(0, len(questionCnts), options)): cnts sort_contours(questionCnts[i:i options])[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) results.append(bubbled[1] if bubbled else -1) return results 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)这里question_rows和options要跟实际答题卡版式对齐切分前最好先可视化确认每个轮廓框住了选项。w20 and h20是过滤太小的噪点轮廓分辨率高时这个阈值要相应放大。0.8ar1.2是假设选项是正方形如果答题卡是椭圆形填涂这个范围要放宽到 0.6 到 1.6。判定逻辑是统计每个选项区域内非零像素数取最大的那个作为答案但这种方法在多涂和涂改时容易出错所以实际系统里我会把每个选项区域裁成 28×28 的小图送进 CNN 做二次判断。3. 用 CNN 给填涂判定兜底数据集制作与模型训练3.1 选项小图数据集的采集与标注CNN 要能区分“涂了”“没涂”“涂改”“多涂”训练数据必须覆盖这些情况。采集方式很简单用上面的切分代码把每张答题卡的每个选项区域裁出来保存成小图按类别放到不同文件夹。我一般分四类filled正常填涂、empty空白、modified涂改、multi多涂。每类至少 500 张来源要包括不同光照、不同铅笔深浅、不同拍摄角度。import os import cv2 def save_bubble_samples(warped, questionCnts, options, save_dir): # 按题目分组每组 options 个选项 for (q, i) in enumerate(np.arange(0, len(questionCnts), options)): cnts sort_contours(questionCnts[i:i options])[0] for (j, c) in enumerate(cnts): (x, y, w, h) cv2.boundingRect(c) # 外扩 2 像素保留边缘信息 roi warped[max(0, y-2):yh2, max(0, x-2):xw2] roi cv2.resize(roi, (28, 28)) # 文件名带题号和选项号方便回溯 fname fq{q:03d}_opt{j}.png cv2.imwrite(os.path.join(save_dir, fname), roi)裁图时外扩 2 像素是为了让模型看到选项边缘纯按轮廓裁会把边缘切掉模型学不到“涂出界”这种特征。保存的文件名带题号和选项号后续人工标注时能快速定位到原图位置。标注阶段我一般用文件夹分类代替打标签文件省去写标注工具的麻烦但要注意类别平衡empty 类容易过多需要下采样。3.2 轻量 CNN 模型结构与训练参数答题卡选项分类不需要 ResNet 这种大模型一个三层卷积加两层全连接的轻量网络足够推理快也方便部署到边缘设备。下面是一个可用的结构。import torch import torch.nn as nn class BubbleCNN(nn.Module): def __init__(self, num_classes4): super(BubbleCNN, self).__init__() self.features nn.Sequential( # 输入 28x28x3 nn.Conv2d(3, 16, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 14x14 nn.Conv2d(16, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), # 7x7 nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2) # 3x3 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 3 * 3, 128), nn.ReLU(), nn.Dropout(0.3), # 防止小数据集过拟合 nn.Linear(128, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x训练参数方面学习率用 1e-3优化器选 Adambatch size 设 64训练 30 个 epoch 左右。数据增强只做轻微的旋转±5 度和亮度调整不要做翻转因为答题卡选项的填涂位置有方向性。损失函数用交叉熵如果 modified 和 multi 类样本少可以加类别权重。验证集准确率到 98% 以上再上线低于这个值先检查数据标注有没有错而不是急着调模型。3.3 把 CNN 判定接回 OpenCV 流程模型训练好后推理阶段把每个选项区域裁出来送进模型取概率最高的类别。如果最高类是 filled就认为该选项被涂如果同一题有多个 filled标记为多涂异常如果全是 empty标记为未作答。这样 OpenCV 负责定位CNN 负责分类两者通过选项坐标衔接。def predict_bubble(model, roi, device): model.eval() # roi 是 BGR 图转 RGB 并归一化 img cv2.cvtColor(roi, cv2.COLOR_BGR2RGB) img img.astype(float32) / 255.0 img np.transpose(img, (2, 0, 1)) # HWC - CHW tensor torch.from_numpy(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) cls torch.argmax(prob, dim1).item() conf prob[0][cls].item() return cls, conf推理时conf低于 0.7 的样本建议人工复核这类通常是涂得特别浅或者有污损的情况。实际部署时我会把 conf 低的选项区域单独存下来方便后续补数据重训。4. 答题卡识别系统避坑5 个血泪踩坑记录4.1 定位块找不到报“未找到四边形轮廓”现象预处理阶段docCnt为 None程序直接抛异常。原因通常是背景和答题卡对比度不够或者答题卡边缘有阴影导致轮廓断裂。解决先检查thresh二值化结果如果定位块没被完整分离出来把adaptiveThreshold的blockSize从 11 调到 21 或 31或者改用cv2.Canny做边缘检测再找轮廓。另外拍摄时尽量让答题卡占画面 70% 以上背景用深色桌面。4.2 透视变换后选项切偏整体错位现象校正后的图看起来是正的但切出来的选项区域偏了半个格子。原因多半是order_points排序错了四个定位点顺序不对透视矩阵就反了。解决在four_point_transform前把rect四个点画到原图上可视化确认确保左上、右上、右下、左下顺序正确。另一个常见原因是ratio还原时漏乘缩小图坐标没乘回原图比例。4.3 涂得浅的选项被判成空白现象学生用 2B 铅笔轻轻涂的选项CNN 判成 empty。原因是训练数据里浅涂样本太少模型没见过这种灰度分布。解决采集数据时专门补一批浅涂样本或者在预处理阶段对选项区域做一次对比度拉伸把灰度范围拉开再送模型。OpenCV 里可以用cv2.normalize做 min-max 归一化。4.4 多涂和涂改被误判为正常填涂现象一道题涂了两个选项系统只取像素最多的那个没报异常。原因是判定逻辑只取最大值没有做多选检测。解决在 CNN 分类后加一层逻辑统计同一题 filled 类别的数量大于 1 就标记为 multi 异常输出时单独列出。涂改的情况类似modified 类概率高时也要标记。4.5 批量处理时内存暴涨现象处理几百张答题卡时程序越来越慢最后内存溢出。原因是每张图都保留了原图、校正图、二值图等多份副本没有及时释放。解决在循环里处理完一张图后显式del掉大数组或者用生成器逐张读取。另外cv2.imread读进来的图是 BGR 三通道如果后续只用灰度读完立刻转灰度并释放原图。5. 让识别率再上一个台阶置信度过滤与人工复核闭环系统跑通之后真正决定能不能上生产的是异常处理。我的习惯是给每个选项的判定结果加一个置信度字段CNN 输出的 softmax 概率低于 0.7 的不直接出分而是把该选项区域裁出来存到review/目录同时记录题号、选项号和原图路径。阅卷老师只需要复核这些低置信度样本工作量从整张卡降到几个格子。def grade_with_review(model, warped, questionCnts, options, device, answer_key, review_dirreview): os.makedirs(review_dir, exist_okTrue) results [] for (q, i) in enumerate(np.arange(0, len(questionCnts), options)): cnts sort_contours(questionCnts[i:i options])[0] filled [] for (j, c) in enumerate(cnts): (x, y, w, h) cv2.boundingRect(c) roi warped[max(0, y-2):yh2, max(0, x-2):xw2] roi cv2.resize(roi, (28, 28)) cls, conf predict_bubble(model, roi, device) if cls 0 and conf 0.7: # 0 表示 filled 类 filled.append(j) elif conf 0.7: # 低置信度样本存盘文件名带题号和选项号 fname fq{q:03d}_opt{j}_conf{conf:.2f}.png cv2.imwrite(os.path.join(review_dir, fname), roi) if len(filled) 1: results.append(filled[0]) elif len(filled) 1: results.append(-2) # -2 表示多涂 else: results.append(-1) # -1 表示未作答 return results这段代码里-1和-2是自定义的异常标记跟正常选项索引区分开。review_dir里的文件名带了置信度复核时优先看 conf 最低的。实际跑下来低置信度样本通常占总选项数的 2% 到 5%复核成本很低但能把整体准确率从 95% 拉到 99% 以上。还有一个技巧是定期把复核结果反哺训练集。老师改过的低置信度样本按正确类别重新标注后加入训练数据每个月重训一次模型识别率会随着使用时间逐步提升。这个闭环不需要多复杂的工程一个文件夹加一个重训脚本就够了。我自己踩过的最大坑是早期没做置信度过滤直接出分结果涂改的卡判错后家长找过来返工成本远高于加一个复核环节。希望帮到你。本文还有配套的精品资源点击获取