简介:这是一套面向计算机视觉初学者与课程设计者的智能答题卡识别完整项目,基于Python与OpenCV,结合深度学习与图像识别算法,解决答题卡自动检测、选项识别与分数计算问题。资源包共60个文件,包含39张jpg样本图像、8个xml标注文件、5个py源码脚本及pyc编译文件、xls成绩数据与readme说明,压缩包约38.86MB,覆盖从数据准备到结果输出的完整流程。项目围绕答题卡检测、选项区域分割、CNN选项分类、答案判定与分数统计等环节展开,配有demo、sheet、bad、school等多组实拍与测试图片,便于直接运行调试与二次开发。已有448人学习下载,适合需要快速搭建图像识别实战环境、理解OpenCV与深度学习结合方式的读者参考借鉴。
1. 从一张歪着拍的答题卡说起:这套系统到底在解决什么
教室里手机随手一拍,答题卡边缘带着桌面、手指、阴影,还歪了七八度。传统做法是人工翻卷、对答案、登分,一个班五十份就要耗掉小半节课。基于 Python+OpenCV 的智能答题卡识别系统,要干的事就是把这张"脏图"变成结构化分数:先做图像预处理把卡片摆正、去噪、二值化,再定位涂卡区域,判断每个选项是填涂还是空白,最后按标准答案算分并输出结果。它适合两类人:一类是想找一个完整 OpenCV 图像处理项目练手的 Python 学习者,另一类是需要低成本批量阅卷的教务、培训场景。整套流程里,OpenCV 负责几何校正与形态学处理,深度学习负责在光照不均、涂改、多选等复杂情况下提升判定鲁棒性,两者不是替代关系,而是分工。下面按"预处理 → 定位 → 识别 → 深度学习增强 → 落地调优"的顺序,把每一步的参数和坑讲清楚。
2. OpenCV 答题卡预处理:透视校正与二值化的关键参数
2.1 为什么预处理决定识别上限
答题卡识别的准确率,七成取决于预处理。手机拍摄引入的透视畸变会让同一列选项在图像里呈现不同宽度,如果直接按固定坐标切格子,边缘的选项必然错位。常见做法是先转灰度、做高斯模糊抑制噪点,再用 Canny 找轮廓,通过面积筛选出答题卡四边形,最后用四点透视变换把它拉成正视图。这一步做完,后续所有坐标才具备可比性。
import cv2 import numpy as np def preprocess(img_path): img = cv2.imread(img_path) # 缩放到固定宽度,保证后续参数稳定,避免大图小图阈值不一致 ratio = img.shape[1] / 800.0 img = cv2.resize(img, (800, int(img.shape[0] / ratio))) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯核取奇数,5x5 对手机拍摄噪点足够,过大糊掉选项边缘 blur = cv2.GaussianBlur(gray, (5, 5), 0) # Canny 双阈值比例约 1:2~1:3,低阈值太小会引入桌面纹理 edged = cv2.Canny(blur, 75, 200) return img, edged逻辑说明:先统一宽度是为了让 Canny 阈值、形态学核尺寸在不同分辨率下表现一致,否则同一套参数换台手机就失效。参数说明:GaussianBlur的核必须是奇数,(5,5)是经验起点;Canny的 75/200 适合白底黑框的答题卡,若卡片偏黄或反光,低阈值可降到 50 并配合自适应阈值。
2.2 四点透视变换的定位与排序
找到轮廓后不能直接取最大四边形,因为桌面边缘、试卷袋也可能形成大轮廓。稳妥做法是按面积排序取前几个,再判断轮廓近似后的顶点数是否为 4,并用宽高比过滤掉过于狭长的干扰项。四个角点还需要排序成"左上、右上、右下、左下",否则透视变换会翻转图像。
def order_points(pts): rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上:x+y 最小 rect[2] = pts[np.argmax(s)] # 右下:x+y 最大 diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] # 右上:x-y 最小 rect[3] = pts[np.argmax(diff)] # 左下:x-y 最大 return rect def four_point_transform(img, pts): rect = order_points(pts) (tl, tr, br, bl) = rect width = max(int(np.linalg.norm(br - bl)), int(np.linalg.norm(tr - tl))) height = max(int(np.linalg.norm(tr - br)), int(np.linalg.norm(tl - bl))) dst = np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(img, M, (width, height))逻辑说明:order_points用坐标和与坐标差两个不变量区分四个角,比按角度排序更稳。参数说明:warpPerspective的输出尺寸由变换后四边最大长度决定,避免拉伸变形;若答题卡本身有固定物理尺寸,可直接把width/height写死成标准像素,后续切格子坐标就能完全固定。
2.3 自适应二值化与形态学去噪
透视校正后进入二值化。全局阈值在光照不均时会把阴影区整片判黑,所以用自适应阈值。之后用开运算去掉细小噪点,用闭运算填补选项内部的空洞。
| 参数 | 常用取值 | 作用 | 调大后果 |
|---|---|---|---|
| adaptiveMethod | ADAPTIVE_THRESH_GAUSSIAN_C | 加权邻域均值 | 边缘更平滑 |
| blockSize | 25 | 邻域窗口 | 过大丢失细节 |
| C | 10 | 阈值修正量 | 过大整片变白 |
| 开运算核 | (3,3) | 去噪点 | 过大抹掉细线 |
| 闭运算核 | (5,5) | 填空洞 | 过大粘连选项 |
thresh = cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 25, 10) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) opened = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)注意:THRESH_BINARY_INV让填涂区域变成白色前景,方便后续按轮廓或像素占比统计。如果答题卡是浅色铅笔填涂,自适应阈值可能判不出来,这时要改用 Otsu 或先做对比度拉伸。
3. 答题卡选项定位与填涂判定:轮廓筛选与像素占比
3.1 用轮廓层级切出选项区域
二值化后,每个填涂框是一个独立轮廓。直接遍历所有轮廓会混入边框、文字、定位黑块。常见做法是用findContours的RETR_EXTERNAL只取外轮廓,再按面积和宽高比筛选:选项框通常接近正方形,面积在某个区间内。为了区分题号列,可以按轮廓中心的 x 坐标聚类,把同一列的选项归到一组。
cnts, _ = cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for c in cnts: x, y, w, h = cv2.boundingRect(c) ar = w / float(h) # 选项框近似正方形,面积过滤掉噪点和整卡边框 if 0.8 <= ar <= 1.2 and 200 <= w * h <= 3000: boxes.append((x, y, w, h)) # 按 y 再按 x 排序,得到从上到下、从左到右的题序 boxes.sort(key=lambda b: (b[1] // 20, b[0]))逻辑说明:b[1] // 20是把 y 坐标按行分桶,避免同一行因几像素偏差被拆成两行。参数说明:面积区间200~3000需要按实际分辨率调整,缩放后 800 宽度的答题卡,单个选项框一般在 400~1500 像素之间;宽高比 0.8~1.2 能排除长条形的题号分隔线。
3.2 填涂判定的两种策略
判定某个选项是否被涂,有两种主流做法。第一种是像素占比:统计该框内白色前景像素占框面积的比例,超过阈值即判为填涂。第二种是均值灰度:填涂区域整体偏暗,计算框内平均灰度,低于阈值判为填涂。前者对二值化质量敏感,后者对光照更鲁棒,实际项目里常把两者结合。
def is_filled(thresh, box, ratio_thr=0.35): x, y, w, h = box roi = thresh[y:y+h, x:x+w] # 去掉边框 2 像素,避免框线本身被计入前景 inner = roi[2:-2, 2:-2] fill_ratio = cv2.countNonZero(inner) / float(inner.size) return fill_ratio > ratio_thr, fill_ratio逻辑说明:内缩 2 像素是关键细节,否则选项框自身的黑边会让空白框的占比也偏高。参数说明:ratio_thr默认 0.35,铅笔轻涂可降到 0.2,但会引入误判;建议先用一批样本统计空白框和填涂框的占比分布,取两者中间值。
3.3 多选、涂改与漏涂的处理
真实答题卡上会出现涂了两个选项、涂了一半又擦掉、完全没涂的情况。工程上一般这样处理:占比超过高阈值(如 0.5)判为确定填涂;处于中间区间(0.2~0.5)判为可疑,标记出来人工复核;同一题有多个确定填涂则判为多选,按错误计分或单独提示。这套规则比单纯二分类更贴近阅卷实际,也方便后续接入深度学习做二次判定。
4. 深度学习增强:CNN 处理复杂填涂的落地方式
4.1 什么时候该上深度学习
纯 OpenCV 方案在标准答题卡、均匀光照下准确率已经很高,但遇到三种情况会明显掉点:一是填涂深浅差异极大,同一张卡上有人用 2B 铅笔重涂、有人用 HB 轻划;二是涂改痕迹,擦除后的灰底容易被误判;三是拍摄角度极端导致局部模糊。这些属于"规则难以穷举"的问题,正好是 CNN 的强项。做法是把每个选项框裁出来,缩放到固定尺寸,送进一个小型卷积网络做二分类(填涂/未填涂),用 OpenCV 的判定结果作为初筛,CNN 只处理可疑框,兼顾速度和准确率。
4.2 数据集构造与标注要点
训练数据来自实际拍摄的答题卡,把每个选项框裁成 32×32 或 48×48 的小图,按填涂状态打标签。这里有几个容易踩的坑:正负样本要平衡,空白框远多于填涂框,需要下采样或加权损失;要包含不同光照、不同笔迹、不同拍摄角度的样本,否则模型在换设备后失效;涂改样本要单独标注,不要简单归到空白,否则模型学不会灰底特征。
import os import cv2 import numpy as np def build_dataset(root, size=32): X, y = [], [] for label, folder in enumerate(["empty", "filled"]): path = os.path.join(root, folder) for name in os.listdir(path): img = cv2.imread(os.path.join(path, name), cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (size, size)) # 归一化到 0~1,加速收敛 X.append(img / 255.0) y.append(label) X = np.array(X).reshape(-1, size, size, 1) return X, np.array(y)逻辑说明:灰度输入足够,因为填涂判定不依赖颜色;reshape成(N, H, W, 1)适配 Keras/TensorFlow 的卷积输入。参数说明:size取 32 是速度与精度的折中,若选项框内还有字母需要区分,可提到 48;归一化用/255.0而非标准化,是因为灰度分布本身有界。
4.3 轻量 CNN 结构与训练参数
不需要上 ResNet,一个三层卷积加全连接的小网络就能达到很高准确率,推理也快。
from tensorflow.keras import layers, models model = models.Sequential([ layers.Conv2D(16, (3, 3), activation='relu', input_shape=(32, 32, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(32, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu'), layers.Flatten(), layers.Dense(64, activation='relu'), layers.Dropout(0.3), # 抑制小数据集过拟合 layers.Dense(1, activation='sigmoid') # 二分类输出 ]) model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy']) model.fit(X_train, y_train, epochs=20, batch_size=32, validation_split=0.2)逻辑说明:卷积核数量逐层翻倍是常见设计,浅层抓边缘、深层抓整体填涂形态;Dropout(0.3)在小样本下很必要。参数说明:epochs=20配合validation_split=0.2观察验证集是否过拟合,若验证损失回升就早停;batch_size=32适合几千到几万样本量级。训练完成后用model.save保存,推理时对可疑框批量预测,取概率 0.5 为界。
4.4 OpenCV 与 CNN 的协同推理流程
落地时不要每个框都过 CNN,那样慢且没必要。流程是:OpenCV 先算占比,确定填涂和确定空白的直接出结果,只把中间区间的可疑框裁出来送 CNN。这样 CNN 的调用量通常不到总框数的 10%,整体耗时可控。协同的关键是两边阈值要对齐,OpenCV 的高阈值和低阈值决定了 CNN 的工作区间,需要在实际数据上联合调参。
5. 系统落地调优:从单张识别到批量阅卷的工程细节
5.1 标准答案配置与成绩输出
识别出每题的填涂结果后,需要和标准答案比对。常见做法是把答案存成 JSON 或 CSV,键为题号、值为正确选项,程序读入后逐题比对,输出每题得分和总分。为了适配不同科目、不同题量,答案文件要支持多套模板,识别时先判断当前答题卡属于哪套模板,再加载对应答案。
import json def grade(answers, key_path): with open(key_path, encoding='utf-8') as f: key = json.load(f) score, detail = 0, [] for q, correct in key.items(): got = answers.get(q, "") ok = (got == correct) score += 1 if ok else 0 detail.append({"q": q, "got": got, "correct": correct, "ok": ok}) return score, detail逻辑说明:answers是识别阶段产出的题号到选项的映射,key是标准答案;逐题比对并保留明细,方便后续生成错题统计。参数说明:多选题可把correct写成字符串如"AB",比对时先排序再比较,避免顺序差异导致误判。
5.2 批量处理的目录约定与异常兜底
批量阅卷时,输入目录放原始照片,输出目录放结果 CSV 和标注图。每张图处理要包在 try 里,单张失败不能中断整批。常见异常包括:找不到答题卡四边形、透视变换后尺寸异常、选项框数量与预期不符。这些情况应记录文件名和原因,输出到错误日志,人工复核。
| 异常类型 | 触发条件 | 处理方式 |
|---|---|---|
| 未检测到卡片 | 轮廓筛选为空 | 跳过并记录,提示重拍 |
| 框数量不符 | 与模板题量偏差大 | 标记可疑,人工确认 |
| 透视后过小 | 输出宽高低于阈值 | 放弃该图,避免误判 |
| 可疑框过多 | 中间区间占比超 30% | 整卷转人工复核 |
5.3 提升鲁棒性的三个实用技巧
第一,模板对齐。把透视校正后的图像和标准模板做一次配准,用模板的固定坐标切框,比每次动态找轮廓更稳,尤其适合印刷质量一致的批量答题卡。第二,多帧投票。同一张卡如果拍了两张,可以对两次识别结果做投票,减少单次噪声影响。第三,阈值自学习。用一批已人工确认的样本,统计空白框和填涂框的占比分布,自动算出最优分割阈值,而不是拍脑袋定 0.35。这三点做完,系统在真实场景下的可用性会明显上一个台阶。
本文还有配套的精品资源,点击获取